@oxygen-agent/cli 1.1003.12 → 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 (102) hide show
  1. package/README.md +1 -1
  2. package/dist/column-decision-options.d.ts +20 -0
  3. package/dist/column-decision-options.js +54 -0
  4. package/dist/command-manifest.js +15 -2
  5. package/dist/functions-commands.js +11 -11
  6. package/dist/index.js +1222 -159
  7. package/dist/search-ai-filter-notice.d.ts +17 -0
  8. package/dist/search-ai-filter-notice.js +38 -0
  9. package/dist/skills.js +34 -10
  10. package/dist/util.d.ts +9 -0
  11. package/dist/util.js +14 -0
  12. package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
  13. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
  14. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
  15. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
  16. package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
  17. package/node_modules/@oxygen/shared/dist/billing.js +45 -0
  18. package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
  19. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
  20. package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
  21. package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
  22. package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
  23. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  24. package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
  25. package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
  26. package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
  27. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
  28. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
  29. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
  30. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
  31. package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
  32. package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
  33. package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
  34. package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
  35. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
  36. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
  37. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
  39. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
  40. package/node_modules/@oxygen/shared/dist/index.js +2 -0
  41. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
  42. package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
  43. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
  44. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
  45. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
  46. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
  47. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
  48. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
  49. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
  50. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
  51. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
  52. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
  53. package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
  54. package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
  55. package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
  56. package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
  57. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
  58. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
  59. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
  60. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
  61. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
  62. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
  63. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
  64. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
  65. package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
  66. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
  67. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
  68. package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
  69. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
  70. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
  71. package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
  72. package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
  73. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
  74. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
  75. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
  76. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
  77. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
  78. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
  79. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
  80. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
  81. package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
  82. package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
  83. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
  84. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
  85. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
  86. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
  87. package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
  88. package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
  89. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
  90. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
  91. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
  92. package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
  93. package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
  94. package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
  95. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
  96. package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
  97. package/node_modules/@oxygen/shared/dist/version.js +8 -1
  98. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
  99. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
  100. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
  101. package/node_modules/@oxygen/shared/package.json +9 -0
  102. package/package.json +2 -1
@@ -0,0 +1,213 @@
1
+ export const EARNED_SERIES_MAX_PRORATE_MS = 3 * 24 * 60 * 60 * 1000;
2
+ function pointsFor(timeline, metric) {
3
+ const points = [];
4
+ const first = timeline.observations.find((observation) => observation.metrics[metric] !== undefined);
5
+ if (!first)
6
+ return points;
7
+ if (timeline.publishedAt && timeline.publishedAt.getTime() <= first.observedAt.getTime()) {
8
+ points.push({ from: timeline.publishedAt.getTime(), to: timeline.publishedAt.getTime(), value: 0, anchor: true });
9
+ }
10
+ for (const observation of timeline.observations) {
11
+ const value = observation.metrics[metric];
12
+ if (value === undefined)
13
+ continue;
14
+ points.push({ from: observation.observedAt.getTime(), to: observation.confirmedAt.getTime(), value, anchor: false });
15
+ }
16
+ return points;
17
+ }
18
+ /**
19
+ * Earned per day for one metric, or the sum of several (engagements), across a
20
+ * set of posts. `dayStarts` are the instants each local day begins; `windowEnd`
21
+ * closes the last day.
22
+ */
23
+ export function computeEarnedWindow(input) {
24
+ const maxProrateMs = input.maxProrateMs ?? EARNED_SERIES_MAX_PRORATE_MS;
25
+ const bounds = input.dayStarts.map((day, index) => ({
26
+ date: day.date,
27
+ start: day.start.getTime(),
28
+ end: (input.dayStarts[index + 1]?.start ?? input.windowEnd).getTime(),
29
+ }));
30
+ const windowStart = bounds[0]?.start ?? input.windowEnd.getTime();
31
+ const windowEnd = input.windowEnd.getTime();
32
+ const values = bounds.map(() => 0);
33
+ const estimated = bounds.map(() => false);
34
+ const tracked = bounds.map(() => new Set());
35
+ let unattributed = 0;
36
+ let decreases = 0;
37
+ let reporting = 0;
38
+ input.timelines.forEach((timeline, postIndex) => {
39
+ let reports = false;
40
+ for (const metric of input.metrics) {
41
+ const points = pointsFor(timeline, metric);
42
+ if (points.length === 0)
43
+ continue;
44
+ reports = true;
45
+ // A post is tracked on a day only where its earning is known: a flat
46
+ // interval it was observed through, or a short gap between two reads.
47
+ const markTracked = (from, to) => {
48
+ bounds.forEach((day, index) => {
49
+ if (day.start <= to && day.end > from)
50
+ tracked[index].add(postIndex);
51
+ });
52
+ };
53
+ for (const point of points)
54
+ if (!point.anchor)
55
+ markTracked(point.from, point.to);
56
+ for (let index = 1; index < points.length; index += 1) {
57
+ const previous = points[index - 1];
58
+ const current = points[index];
59
+ const delta = current.value - previous.value;
60
+ if (delta === 0)
61
+ continue;
62
+ if (delta < 0)
63
+ decreases += 1;
64
+ const gapStart = Math.min(previous.to, current.from);
65
+ const gapEnd = current.from;
66
+ const gap = gapEnd - gapStart;
67
+ if (gap <= 0) {
68
+ const day = bounds.findIndex((bound) => gapEnd >= bound.start && gapEnd < bound.end);
69
+ if (day >= 0)
70
+ values[day] += delta;
71
+ continue;
72
+ }
73
+ const insideWindow = Math.max(0, Math.min(gapEnd, windowEnd) - Math.max(gapStart, windowStart));
74
+ if (insideWindow === 0)
75
+ continue;
76
+ if (gap > maxProrateMs) {
77
+ unattributed += delta * (insideWindow / gap);
78
+ continue;
79
+ }
80
+ markTracked(gapStart, gapEnd);
81
+ let spannedDays = 0;
82
+ bounds.forEach((bound, day) => {
83
+ const overlap = Math.max(0, Math.min(gapEnd, bound.end) - Math.max(gapStart, bound.start));
84
+ if (overlap === 0)
85
+ return;
86
+ spannedDays += 1;
87
+ values[day] += delta * (overlap / gap);
88
+ });
89
+ if (spannedDays > 1 || insideWindow < gap) {
90
+ bounds.forEach((bound, day) => {
91
+ if (Math.min(gapEnd, bound.end) > Math.max(gapStart, bound.start))
92
+ estimated[day] = true;
93
+ });
94
+ }
95
+ }
96
+ }
97
+ if (reports)
98
+ reporting += 1;
99
+ });
100
+ const days = bounds.map((bound, index) => ({
101
+ date: bound.date,
102
+ value: tracked[index].size > 0 ? Math.round(values[index]) : null,
103
+ estimated: tracked[index].size > 0 && estimated[index],
104
+ postsTracked: tracked[index].size,
105
+ }));
106
+ // Round the window once from unrounded parts: per-day rounding would let a net
107
+ // change of -2 spread over two half-days read as -1.
108
+ const anyTracked = tracked.some((posts) => posts.size > 0);
109
+ const trackedSum = values.reduce((sum, value, index) => sum + (tracked[index].size > 0 ? value : 0), 0);
110
+ return {
111
+ days,
112
+ total: anyTracked || Math.round(unattributed) !== 0 ? Math.round(trackedSum + unattributed) : null,
113
+ unattributed: Math.round(unattributed),
114
+ decreases,
115
+ postsReporting: reporting,
116
+ };
117
+ }
118
+ /**
119
+ * A post's cumulative metric (or sum of metrics) exactly `ageMs` after publishing,
120
+ * so posts can be compared at the same age ("impressions after 24 hours"). Exact
121
+ * inside a known-flat interval; interpolated linearly across a gap no longer than
122
+ * `maxProrateMs`; null when the age falls in a longer gap, before the first read,
123
+ * after the last confirmed read, or when the post has no publish time.
124
+ */
125
+ export function valueAtAge(input) {
126
+ if (!input.timeline.publishedAt)
127
+ return null;
128
+ const maxProrateMs = input.maxProrateMs ?? EARNED_SERIES_MAX_PRORATE_MS;
129
+ const target = input.timeline.publishedAt.getTime() + input.ageMs;
130
+ let total = 0;
131
+ let interpolated = false;
132
+ for (const metric of input.metrics) {
133
+ const points = pointsFor(input.timeline, metric);
134
+ if (points.length === 0)
135
+ return null;
136
+ let found = null;
137
+ for (let index = 0; index < points.length && found === null; index += 1) {
138
+ const point = points[index];
139
+ if (target >= point.from && target <= point.to) {
140
+ found = point.value;
141
+ break;
142
+ }
143
+ const next = points[index + 1];
144
+ if (next && target > point.to && target < next.from) {
145
+ const gap = next.from - point.to;
146
+ if (gap > maxProrateMs)
147
+ return null;
148
+ found = point.value + (next.value - point.value) * ((target - point.to) / gap);
149
+ interpolated = true;
150
+ }
151
+ }
152
+ if (found === null)
153
+ return null;
154
+ total += found;
155
+ }
156
+ return { value: Math.round(total), interpolated };
157
+ }
158
+ /** Median of the values, or null for none. */
159
+ export function medianOf(values) {
160
+ if (values.length === 0)
161
+ return null;
162
+ const sorted = [...values].sort((left, right) => left - right);
163
+ const middle = Math.floor(sorted.length / 2);
164
+ return sorted.length % 2 === 1 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
165
+ }
166
+ /**
167
+ * The instant each calendar day in [fromDate, toDate] (inclusive, YYYY-MM-DD)
168
+ * starts in `timeZone`, plus the instant after the last day. DST-safe: each start
169
+ * is resolved against that day's own offset.
170
+ */
171
+ export function zonedDayStarts(fromDate, toDate, timeZone) {
172
+ const days = [];
173
+ const cursor = new Date(`${fromDate}T00:00:00.000Z`);
174
+ const last = new Date(`${toDate}T00:00:00.000Z`);
175
+ while (cursor.getTime() <= last.getTime()) {
176
+ const date = cursor.toISOString().slice(0, 10);
177
+ days.push({ date, start: startOfZonedDay(date, timeZone) });
178
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
179
+ }
180
+ return { days, end: startOfZonedDay(cursor.toISOString().slice(0, 10), timeZone) };
181
+ }
182
+ function zoneOffsetMs(instant, timeZone) {
183
+ const parts = new Intl.DateTimeFormat("en-US", {
184
+ timeZone,
185
+ hourCycle: "h23",
186
+ year: "numeric",
187
+ month: "2-digit",
188
+ day: "2-digit",
189
+ hour: "2-digit",
190
+ minute: "2-digit",
191
+ second: "2-digit",
192
+ }).formatToParts(instant);
193
+ const read = (type) => Number(parts.find((part) => part.type === type)?.value ?? 0);
194
+ const asUtc = Date.UTC(read("year"), read("month") - 1, read("day"), read("hour"), read("minute"), read("second"));
195
+ return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
196
+ }
197
+ function startOfZonedDay(date, timeZone) {
198
+ const utcMidnight = new Date(`${date}T00:00:00.000Z`).getTime();
199
+ const firstGuess = utcMidnight - zoneOffsetMs(new Date(utcMidnight), timeZone);
200
+ return new Date(utcMidnight - zoneOffsetMs(new Date(firstGuess), timeZone));
201
+ }
202
+ /** An IANA zone Intl accepts, or null. Validated here so Postgres never sees a bad one. */
203
+ export function validTimeZone(value) {
204
+ if (!value)
205
+ return null;
206
+ try {
207
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
208
+ return value;
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
@@ -0,0 +1,13 @@
1
+ import { type ParsedSocialPostMetrics } from "./social-post-metrics-core.js";
2
+ export declare const X_COMPOSIO_POST_METRICS_PARSER_VERSION = 1;
3
+ /**
4
+ * Every field the X parse reads, and nothing else: the post's and its media's
5
+ * metric blocks, without the post text or author.
6
+ */
7
+ export declare function xComposioMetricEvidence(payload: Record<string, unknown>): Record<string, unknown>;
8
+ /**
9
+ * Map an X v2 post lookup (through Composio) into the registry vocabulary. X post
10
+ * lookup exposes no send/share-via-DM counter, and link clicks only come back in
11
+ * the author's user context.
12
+ */
13
+ export declare function parseXComposioPostMetrics(payload: Record<string, unknown>): ParsedSocialPostMetrics;
@@ -0,0 +1,78 @@
1
+ import { pickMetricCount, presentMetrics, } from "./social-post-metrics-core.js";
2
+ import { isRecord } from "./type-guards.js";
3
+ export const X_COMPOSIO_POST_METRICS_PARSER_VERSION = 1;
4
+ function xPostEnvelope(payload) {
5
+ const firstData = isRecord(payload.data) ? payload.data : payload;
6
+ const post = isRecord(firstData.data) ? firstData.data : firstData;
7
+ const includes = isRecord(payload.includes)
8
+ ? payload.includes
9
+ : isRecord(firstData.includes)
10
+ ? firstData.includes
11
+ : {};
12
+ return { post, includes };
13
+ }
14
+ function sumXMediaViews(includes) {
15
+ const media = Array.isArray(includes.media) ? includes.media : [];
16
+ let total = 0;
17
+ let found = false;
18
+ for (const item of media) {
19
+ if (!isRecord(item))
20
+ continue;
21
+ const publicMetrics = isRecord(item.public_metrics) ? item.public_metrics : {};
22
+ const nonPublicMetrics = isRecord(item.non_public_metrics) ? item.non_public_metrics : {};
23
+ const organicMetrics = isRecord(item.organic_metrics) ? item.organic_metrics : {};
24
+ const count = pickMetricCount({ ...organicMetrics, ...nonPublicMetrics, ...publicMetrics }, ["view_count", "video_view_count"]);
25
+ if (count === null)
26
+ continue;
27
+ total += count;
28
+ found = true;
29
+ }
30
+ return found ? total : null;
31
+ }
32
+ const X_METRIC_FIELDS = ["public_metrics", "non_public_metrics", "organic_metrics"];
33
+ function pickMetricFields(record, identity) {
34
+ const picked = {};
35
+ for (const key of [...identity, ...X_METRIC_FIELDS]) {
36
+ if (key in record)
37
+ picked[key] = record[key];
38
+ }
39
+ return picked;
40
+ }
41
+ /**
42
+ * Every field the X parse reads, and nothing else: the post's and its media's
43
+ * metric blocks, without the post text or author.
44
+ */
45
+ export function xComposioMetricEvidence(payload) {
46
+ const { post, includes } = xPostEnvelope(payload);
47
+ const media = Array.isArray(includes.media)
48
+ ? includes.media.filter(isRecord).map((item) => pickMetricFields(item, ["media_key", "type"]))
49
+ : [];
50
+ return { data: pickMetricFields(post, ["id"]), ...(media.length > 0 ? { includes: { media } } : {}) };
51
+ }
52
+ /**
53
+ * Map an X v2 post lookup (through Composio) into the registry vocabulary. X post
54
+ * lookup exposes no send/share-via-DM counter, and link clicks only come back in
55
+ * the author's user context.
56
+ */
57
+ export function parseXComposioPostMetrics(payload) {
58
+ const { post, includes } = xPostEnvelope(payload);
59
+ const publicMetrics = isRecord(post.public_metrics) ? post.public_metrics : {};
60
+ const nonPublicMetrics = isRecord(post.non_public_metrics) ? post.non_public_metrics : {};
61
+ const organicMetrics = isRecord(post.organic_metrics) ? post.organic_metrics : {};
62
+ const privateMetrics = { ...organicMetrics, ...nonPublicMetrics };
63
+ return {
64
+ metrics: presentMetrics({
65
+ impressions: pickMetricCount(publicMetrics, ["impression_count"]),
66
+ reactions: pickMetricCount(publicMetrics, ["like_count"]),
67
+ comments: pickMetricCount(publicMetrics, ["reply_count"]),
68
+ reposts: pickMetricCount(publicMetrics, ["retweet_count", "repost_count"]),
69
+ saves: pickMetricCount(publicMetrics, ["bookmark_count"]),
70
+ clicks: pickMetricCount(privateMetrics, ["url_link_clicks"]),
71
+ video_views: sumXMediaViews(includes),
72
+ }),
73
+ extended: {},
74
+ shape: isRecord(post.public_metrics) ? "x_v2_lookup" : "unknown",
75
+ attributable: true,
76
+ rejected: [],
77
+ };
78
+ }
@@ -0,0 +1,36 @@
1
+ import { type SocialPostMetric } from "./social-capabilities.js";
2
+ import type { ParsedSocialPostMetrics } from "./social-post-metrics-core.js";
3
+ export type { ParsedSocialPostMetrics } from "./social-post-metrics-core.js";
4
+ /** Null when no parser is registered for the pair. */
5
+ export declare function parseSocialPostMetrics(input: {
6
+ channel: string;
7
+ providerRail: string;
8
+ payload: Record<string, unknown>;
9
+ }): ParsedSocialPostMetrics | null;
10
+ export declare function socialPostMetricsParserVersion(channel: string, providerRail: string): number | null;
11
+ /**
12
+ * The metric-bearing subset of a payload, stored with each observation so a
13
+ * parser fix can re-derive it without keeping post text, authors or media.
14
+ * Unknown pairs keep nothing.
15
+ */
16
+ export declare function socialPostMetricEvidence(input: {
17
+ channel: string;
18
+ providerRail: string;
19
+ payload: Record<string, unknown>;
20
+ }): Record<string, unknown>;
21
+ /** Every registered (channel, provider rail) pair with its current parser version. */
22
+ export declare function listSocialPostMetricsParsers(): Array<{
23
+ channel: string;
24
+ providerRail: string;
25
+ version: number;
26
+ }>;
27
+ /**
28
+ * Metrics the registry declares fully `available` for the pair that a parse did
29
+ * not produce. A non-empty answer on a real provider read is parser drift — the
30
+ * failure that silently stored every LinkedIn metric as NULL twice.
31
+ */
32
+ export declare function missingDeclaredPostMetrics(input: {
33
+ channel: string;
34
+ providerRail: string;
35
+ parsed: ParsedSocialPostMetrics;
36
+ }): SocialPostMetric[];
@@ -0,0 +1,51 @@
1
+ import { getSocialCapabilityProfile, SOCIAL_POST_METRICS, } from "./social-capabilities.js";
2
+ import { LINKEDIN_UNIPILE_POST_METRICS_PARSER_VERSION, linkedInUnipileMetricEvidence, parseLinkedInUnipilePostMetrics, } from "./social-post-metrics-linkedin.js";
3
+ import { parseXComposioPostMetrics, X_COMPOSIO_POST_METRICS_PARSER_VERSION, xComposioMetricEvidence, } from "./social-post-metrics-x.js";
4
+ const POST_METRICS_PARSERS = {
5
+ "linkedin:unipile": {
6
+ version: LINKEDIN_UNIPILE_POST_METRICS_PARSER_VERSION,
7
+ parse: parseLinkedInUnipilePostMetrics,
8
+ evidence: linkedInUnipileMetricEvidence,
9
+ },
10
+ "x:composio": {
11
+ version: X_COMPOSIO_POST_METRICS_PARSER_VERSION,
12
+ parse: parseXComposioPostMetrics,
13
+ evidence: xComposioMetricEvidence,
14
+ },
15
+ };
16
+ function parserFor(channel, providerRail) {
17
+ return POST_METRICS_PARSERS[`${channel}:${providerRail}`] ?? null;
18
+ }
19
+ /** Null when no parser is registered for the pair. */
20
+ export function parseSocialPostMetrics(input) {
21
+ return parserFor(input.channel, input.providerRail)?.parse(input.payload) ?? null;
22
+ }
23
+ export function socialPostMetricsParserVersion(channel, providerRail) {
24
+ return parserFor(channel, providerRail)?.version ?? null;
25
+ }
26
+ /**
27
+ * The metric-bearing subset of a payload, stored with each observation so a
28
+ * parser fix can re-derive it without keeping post text, authors or media.
29
+ * Unknown pairs keep nothing.
30
+ */
31
+ export function socialPostMetricEvidence(input) {
32
+ return parserFor(input.channel, input.providerRail)?.evidence(input.payload) ?? {};
33
+ }
34
+ /** Every registered (channel, provider rail) pair with its current parser version. */
35
+ export function listSocialPostMetricsParsers() {
36
+ return Object.entries(POST_METRICS_PARSERS).map(([key, parser]) => {
37
+ const [channel, providerRail] = key.split(":");
38
+ return { channel, providerRail, version: parser.version };
39
+ });
40
+ }
41
+ /**
42
+ * Metrics the registry declares fully `available` for the pair that a parse did
43
+ * not produce. A non-empty answer on a real provider read is parser drift — the
44
+ * failure that silently stored every LinkedIn metric as NULL twice.
45
+ */
46
+ export function missingDeclaredPostMetrics(input) {
47
+ const profile = getSocialCapabilityProfile(input.channel, input.providerRail);
48
+ if (!profile)
49
+ return [];
50
+ return SOCIAL_POST_METRICS.filter((metric) => profile.metrics[metric].status === "available" && input.parsed.metrics[metric] === undefined);
51
+ }
@@ -0,0 +1,36 @@
1
+ import { CONTACT_SALES_URL, PURCHASABLE_PLAN_KEYS, SELF_SERVE_PLAN_TIERS, normalizeBillingCurrency, type BillingCurrency, type OxygenPlanKey, type PricingPlanDefinition, type SelfServePlanTier } from "./billing.js";
2
+ export { CONTACT_SALES_URL, PURCHASABLE_PLAN_KEYS, SELF_SERVE_PLAN_TIERS, normalizeBillingCurrency, };
3
+ export type { BillingCurrency, OxygenPlanKey, SelfServePlanTier };
4
+ export type PricingPlan = PricingPlanDefinition & {
5
+ priceIds: Record<BillingCurrency, string>;
6
+ priceId: string;
7
+ };
8
+ export declare const PRICING_PLANS: {
9
+ readonly free: PricingPlan;
10
+ readonly oxygen: PricingPlan;
11
+ readonly starter: PricingPlan;
12
+ readonly pro: PricingPlan;
13
+ readonly team: PricingPlan;
14
+ readonly scale: PricingPlan;
15
+ readonly enterprise: PricingPlan;
16
+ };
17
+ /** The six purchasable rungs with their Stripe price ids, cheapest first. */
18
+ export declare const PURCHASABLE_PLANS: Record<OxygenPlanKey, PricingPlan>;
19
+ export declare function getPlanPriceId(plan: PricingPlan, currency: BillingCurrency): string;
20
+ /**
21
+ * Resolve a Stripe price id to the plan key stored in `subscriptions.tier`.
22
+ *
23
+ * Returns a plan KEY ("oxygen_499") for the six purchasable rungs and a TIER
24
+ * ("starter", "growth_250") for grandfathered plans. Both are resolvable by
25
+ * resolveBasePricingPlan, which is what the credit grant reads — so the rung a
26
+ * customer paid for is the allowance they receive.
27
+ */
28
+ export declare function tierFromPriceId(priceId: string): string | null;
29
+ /**
30
+ * Complete runtime-recognized Price set grouped by the current self-serve
31
+ * family. Catalog audit/migration code consumes this instead of maintaining a
32
+ * second env map that can silently omit grandfathered subscriptions.
33
+ */
34
+ export declare function recognizedStripePriceIdsBySelfServeTier(): Record<SelfServePlanTier, readonly string[]>;
35
+ export declare function formatMonthlyPrice(priceCents: number | null, currency?: BillingCurrency): string;
36
+ export declare function getMonthlyAllowanceLabel(plan: PricingPlanDefinition): string;
@@ -0,0 +1,184 @@
1
+ import { BASE_PRICING_PLANS, CONTACT_SALES_URL, DEFAULT_BILLING_CURRENCY, PURCHASABLE_PLAN_KEYS, SELF_SERVE_PLAN_TIERS, normalizeBillingCurrency, resolveBasePricingPlan, } from "./billing.js";
2
+ export { CONTACT_SALES_URL, PURCHASABLE_PLAN_KEYS, SELF_SERVE_PLAN_TIERS, normalizeBillingCurrency, };
3
+ const EMPTY_PRICE_IDS = { usd: "" };
4
+ /**
5
+ * The six purchasable Oxygen rungs: one Stripe Product EACH, one monthly
6
+ * Price on each. They cannot share a Product -- a Billing Portal configuration
7
+ * refuses two Prices of the same interval under one Product, which is what
8
+ * makes the plan picker configurable at all.
9
+ *
10
+ * These are consulted BEFORE the grandfathered maps in tierFromPriceId: if an
11
+ * Oxygen price id were ever also listed in a legacy env var, the legacy branch
12
+ * winning would grant a $499 customer a $99 plan's credits.
13
+ */
14
+ const STRIPE_PRICE_OXYGEN_IDS = {
15
+ oxygen_49: { usd: process.env.STRIPE_PRICE_OXYGEN_49_USD ?? "" },
16
+ oxygen_99: { usd: process.env.STRIPE_PRICE_OXYGEN_99_USD ?? "" },
17
+ oxygen_199: { usd: process.env.STRIPE_PRICE_OXYGEN_199_USD ?? "" },
18
+ oxygen_499: { usd: process.env.STRIPE_PRICE_OXYGEN_499_USD ?? "" },
19
+ oxygen_999: { usd: process.env.STRIPE_PRICE_OXYGEN_999_USD ?? "" },
20
+ oxygen_1999: { usd: process.env.STRIPE_PRICE_OXYGEN_1999_USD ?? "" },
21
+ };
22
+ // Grandfathered on 2026-09-19. These three stay pointed at their LIVE prices
23
+ // (35 Starter + 2 Pro subscriptions bill against them) rather than moving into
24
+ // the legacy CSV lists: tierFromPriceId must keep returning "starter"/"pro"/
25
+ // "team" for them so resolveBasePricingPlan yields the plan those customers
26
+ // actually bought. They are simply no longer offered for sale.
27
+ const STRIPE_PRICE_STARTER_IDS = {
28
+ usd: process.env.STRIPE_PRICE_STARTER_99_USD ?? "",
29
+ };
30
+ const STRIPE_PRICE_PRO_IDS = {
31
+ usd: process.env.STRIPE_PRICE_PRO_249_USD ?? "",
32
+ };
33
+ const STRIPE_PRICE_TEAM_IDS = {
34
+ usd: process.env.STRIPE_PRICE_TEAM_749_USD ?? "",
35
+ };
36
+ const LEGACY_STRIPE_PRICE_IDS = {
37
+ starter_50: {
38
+ usd: process.env.STRIPE_PRICE_STARTER_USD ?? "",
39
+ },
40
+ pro_149: {
41
+ usd: process.env.STRIPE_PRICE_PRO_USD ?? "",
42
+ },
43
+ team_399: {
44
+ usd: process.env.STRIPE_PRICE_TEAM_USD ?? "",
45
+ },
46
+ };
47
+ const LEGACY_CURRENT_PLAN_PRICE_IDS = {
48
+ oxygen: readCommaSeparatedPriceIds(process.env.STRIPE_LEGACY_PRICE_OXYGEN_IDS),
49
+ starter: readCommaSeparatedPriceIds(process.env.STRIPE_LEGACY_PRICE_STARTER_IDS),
50
+ pro: readCommaSeparatedPriceIds(process.env.STRIPE_LEGACY_PRICE_PRO_IDS),
51
+ team: readCommaSeparatedPriceIds(process.env.STRIPE_LEGACY_PRICE_TEAM_IDS),
52
+ };
53
+ const SELF_SERVE_TIER_BY_LEGACY_TIER = {
54
+ starter_50: "starter",
55
+ pro_149: "pro",
56
+ team_399: "team",
57
+ };
58
+ function withPriceIds(plan, priceIds = EMPTY_PRICE_IDS) {
59
+ return {
60
+ ...plan,
61
+ priceIds,
62
+ priceId: priceIds[DEFAULT_BILLING_CURRENCY],
63
+ };
64
+ }
65
+ export const PRICING_PLANS = {
66
+ free: withPriceIds(BASE_PRICING_PLANS.free),
67
+ oxygen: withPriceIds(BASE_PRICING_PLANS.oxygen, STRIPE_PRICE_OXYGEN_IDS.oxygen_49),
68
+ starter: withPriceIds(BASE_PRICING_PLANS.starter, STRIPE_PRICE_STARTER_IDS),
69
+ pro: withPriceIds(BASE_PRICING_PLANS.pro, STRIPE_PRICE_PRO_IDS),
70
+ team: withPriceIds(BASE_PRICING_PLANS.team, STRIPE_PRICE_TEAM_IDS),
71
+ scale: withPriceIds(BASE_PRICING_PLANS.scale),
72
+ enterprise: withPriceIds(BASE_PRICING_PLANS.enterprise),
73
+ };
74
+ /** The six purchasable rungs with their Stripe price ids, cheapest first. */
75
+ export const PURCHASABLE_PLANS = Object.fromEntries(PURCHASABLE_PLAN_KEYS.map((planKey) => {
76
+ const plan = resolveBasePricingPlan(planKey);
77
+ if (!plan)
78
+ throw new Error(`purchasable plan "${planKey}" is missing from the catalog`);
79
+ return [planKey, withPriceIds(plan, STRIPE_PRICE_OXYGEN_IDS[planKey])];
80
+ }));
81
+ export function getPlanPriceId(plan, currency) {
82
+ return (plan.priceIds[currency]
83
+ || (currency === DEFAULT_BILLING_CURRENCY ? plan.priceId : ""));
84
+ }
85
+ /**
86
+ * Resolve a Stripe price id to the plan key stored in `subscriptions.tier`.
87
+ *
88
+ * Returns a plan KEY ("oxygen_499") for the six purchasable rungs and a TIER
89
+ * ("starter", "growth_250") for grandfathered plans. Both are resolvable by
90
+ * resolveBasePricingPlan, which is what the credit grant reads — so the rung a
91
+ * customer paid for is the allowance they receive.
92
+ */
93
+ export function tierFromPriceId(priceId) {
94
+ // An absent/unset Stripe price is "" on every map below (each env read falls
95
+ // back to ""), so without this guard an empty id matches the FIRST unset rung
96
+ // and silently provisions a paid plan. Never resolve a tier from no price.
97
+ if (!priceId)
98
+ return null;
99
+ for (const planKey of PURCHASABLE_PLAN_KEYS) {
100
+ if (Object.values(STRIPE_PRICE_OXYGEN_IDS[planKey]).includes(priceId)) {
101
+ return planKey;
102
+ }
103
+ }
104
+ for (const tier of SELF_SERVE_PLAN_TIERS) {
105
+ const plan = PRICING_PLANS[tier];
106
+ if (plan.priceId === priceId
107
+ || Object.values(plan.priceIds).includes(priceId)) {
108
+ return tier;
109
+ }
110
+ }
111
+ for (const [tier, priceIds] of Object.entries(LEGACY_STRIPE_PRICE_IDS)) {
112
+ if (Object.values(priceIds).includes(priceId))
113
+ return tier;
114
+ }
115
+ for (const [tier, priceIds] of Object.entries(LEGACY_CURRENT_PLAN_PRICE_IDS)) {
116
+ if (priceIds.includes(priceId))
117
+ return tier;
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Complete runtime-recognized Price set grouped by the current self-serve
123
+ * family. Catalog audit/migration code consumes this instead of maintaining a
124
+ * second env map that can silently omit grandfathered subscriptions.
125
+ */
126
+ export function recognizedStripePriceIdsBySelfServeTier() {
127
+ const byTier = {
128
+ oxygen: [
129
+ ...PURCHASABLE_PLAN_KEYS.flatMap((planKey) => Object.values(STRIPE_PRICE_OXYGEN_IDS[planKey])),
130
+ ...LEGACY_CURRENT_PLAN_PRICE_IDS.oxygen,
131
+ ],
132
+ starter: [
133
+ ...Object.values(STRIPE_PRICE_STARTER_IDS),
134
+ ...LEGACY_CURRENT_PLAN_PRICE_IDS.starter,
135
+ ],
136
+ pro: [
137
+ ...Object.values(STRIPE_PRICE_PRO_IDS),
138
+ ...LEGACY_CURRENT_PLAN_PRICE_IDS.pro,
139
+ ],
140
+ team: [
141
+ ...Object.values(STRIPE_PRICE_TEAM_IDS),
142
+ ...LEGACY_CURRENT_PLAN_PRICE_IDS.team,
143
+ ],
144
+ };
145
+ for (const legacyTier of Object.keys(LEGACY_STRIPE_PRICE_IDS)) {
146
+ byTier[SELF_SERVE_TIER_BY_LEGACY_TIER[legacyTier]]
147
+ .push(...Object.values(LEGACY_STRIPE_PRICE_IDS[legacyTier]));
148
+ }
149
+ return {
150
+ oxygen: uniqueNonEmptyPriceIds(byTier.oxygen),
151
+ starter: uniqueNonEmptyPriceIds(byTier.starter),
152
+ pro: uniqueNonEmptyPriceIds(byTier.pro),
153
+ team: uniqueNonEmptyPriceIds(byTier.team),
154
+ };
155
+ }
156
+ function readCommaSeparatedPriceIds(value) {
157
+ if (!value)
158
+ return [];
159
+ return [...new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean))];
160
+ }
161
+ function uniqueNonEmptyPriceIds(values) {
162
+ return [...new Set(values.map((entry) => entry.trim()).filter(Boolean))];
163
+ }
164
+ export function formatMonthlyPrice(priceCents, currency = DEFAULT_BILLING_CURRENCY) {
165
+ if (priceCents == null)
166
+ return "Custom";
167
+ if (priceCents === 0)
168
+ return "Free";
169
+ return new Intl.NumberFormat("en-US", {
170
+ style: "currency",
171
+ currency: currency.toUpperCase(),
172
+ maximumFractionDigits: 0,
173
+ }).format(priceCents / 100);
174
+ }
175
+ function formatCredits(credits) {
176
+ if (credits == null)
177
+ return "Custom credits";
178
+ return `${new Intl.NumberFormat("en-US").format(credits)} credits`;
179
+ }
180
+ export function getMonthlyAllowanceLabel(plan) {
181
+ if (plan.monthlyCredits == null)
182
+ return "Custom monthly credits";
183
+ return `${formatCredits(plan.monthlyCredits)} / month`;
184
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Which Stripe subscriptions are NOT an OXYGEN plan.
3
+ *
4
+ * OXYGEN runs three recurring rails on the same Stripe customer, and only one of
5
+ * them is a plan: the PLAN subscription (born from a Checkout Session),
6
+ * EMAIL INFRASTRUCTURE (`infra-subscription.ts`) and SENDING SEATS
7
+ * (`seat-subscription.ts`). The two rails are separate on purpose — a workspace
8
+ * with no plan at all must be able to hold managed inboxes and buy a sending
9
+ * seat — so a rail event is not a smaller version of a plan event, it is a
10
+ * different subject entirely, and plan-shaped handling of one is always wrong.
11
+ *
12
+ * THE WITNESS IS `metadata.oxygen_kind`. Both rails stamp it at
13
+ * `subscriptions.create` (the only two such call sites in the repo), and a plan
14
+ * subscription never carries it: a plan is born from a Checkout Session whose
15
+ * `subscription_data.metadata` carries organization_id/clerk_org_id and no
16
+ * `oxygen_kind`. (The third `oxygen_kind` in the codebase, `credit_topup`, rides
17
+ * a one-off `mode: "payment"` Checkout Session and never produces a
18
+ * subscription at all.) So the key's PRESENCE is a positive witness of "not a
19
+ * plan".
20
+ *
21
+ * PRESENCE, NOT A VALUE LIST, deliberately. Every previous gate matched the one
22
+ * value `"email_infra"`, which is why the seat rail — shipped later — walked
23
+ * straight into the plan handlers: adding a rail silently opted it INTO plan
24
+ * treatment, the wrong default for a safety gate. Testing presence means the
25
+ * next rail needs no edit here. This is the same reasoning `orphan-reconcile.ts`
26
+ * measured in production on 2026-09-06, where all 24 of the 24 daily
27
+ * `billing.subscription_health.unknown_plan_price` error lines were the same 4
28
+ * live sending-seat subscriptions x 6 cron runs.
29
+ *
30
+ * DELIBERATELY NARROW. This answers "did the subscription declare itself a
31
+ * non-plan rail", never "does it carry a recognised plan price". The catalog
32
+ * question stays with `tierFromPriceId` / `recognizedStripePlanItems`, and
33
+ * callers that need both keep asking both — `orphan-reconcile.ts` reclassifies
34
+ * only the zero-recognised-item half for exactly that reason.
35
+ */
36
+ type StripeSubscriptionMetadata = Readonly<Record<string, string | null | undefined>> | null | undefined;
37
+ /** The declared non-plan rail (`email_infra`, `sending_seats`, …), or null for a plan. */
38
+ export declare function nonPlanSubscriptionKind(metadata: StripeSubscriptionMetadata): string | null;
39
+ /** True when the subscription declared itself one of OXYGEN's non-plan rails. */
40
+ export declare function isNonPlanSubscriptionKind(metadata: StripeSubscriptionMetadata): boolean;
41
+ export {};