@oxygen-agent/cli 1.1010.650 → 1.1010.721

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 (50) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +1 -1
  3. package/dist/inbox-needs-reply-notice.d.ts +12 -0
  4. package/dist/inbox-needs-reply-notice.js +51 -0
  5. package/dist/index.js +186 -45
  6. package/dist/skills.js +48 -22
  7. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +33 -2
  8. package/node_modules/@oxygen/shared/dist/billing-anchors.js +67 -2
  9. package/node_modules/@oxygen/shared/dist/billing.d.ts +63 -9
  10. package/node_modules/@oxygen/shared/dist/billing.js +96 -14
  11. package/node_modules/@oxygen/shared/dist/capability-discovery.js +11 -1
  12. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
  13. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
  14. package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +25 -0
  15. package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
  16. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +6 -1
  17. package/node_modules/@oxygen/shared/dist/feature-gates.js +7 -1
  18. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -1
  19. package/node_modules/@oxygen/shared/dist/index.js +2 -1
  20. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
  21. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
  22. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
  23. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +118 -0
  24. package/node_modules/@oxygen/shared/dist/plan-band.js +147 -0
  25. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +131 -120
  26. package/node_modules/@oxygen/shared/dist/plan-limits.js +80 -71
  27. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +79 -14
  28. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +61 -12
  29. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +4 -3
  30. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +9 -3
  31. package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
  32. package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
  33. package/node_modules/@oxygen/shared/dist/repricing.d.ts +130 -0
  34. package/node_modules/@oxygen/shared/dist/repricing.js +320 -0
  35. package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +32 -0
  36. package/node_modules/@oxygen/shared/dist/sending-limits.js +49 -0
  37. package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
  38. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +24 -0
  39. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +58 -1
  40. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +39 -10
  41. package/node_modules/@oxygen/shared/dist/table-capacity.js +68 -4
  42. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
  43. package/node_modules/@oxygen/shared/dist/telemetry.js +36 -2
  44. package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
  45. package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
  46. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  47. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  48. package/package.json +1 -1
  49. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
  50. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
@@ -247,4 +247,118 @@ export declare function truncateLinkedInInviteNote(note: string, limit: number):
247
247
  truncated: boolean;
248
248
  originalLength: number;
249
249
  };
250
+ export type LinkedInSenderLimits = {
251
+ invites_per_day: number;
252
+ invites_per_week: number;
253
+ messages_per_day: number;
254
+ inmails_per_day: number;
255
+ /**
256
+ * InMails in a rolling 30 days (today and the 29 before it, in the account's
257
+ * day). Checked under the same sender row lock as the weekly invite cap, so
258
+ * concurrent claims cannot both pass a stale sum.
259
+ */
260
+ inmails_per_month: number;
261
+ profile_views_per_day: number;
262
+ /**
263
+ * Invisible profile and company lookups (`users_get` with notify:false,
264
+ * `company_get`). The member is never notified, so these are reads, but
265
+ * Unipile relays LinkedIn's ~100 profile retrievals a day for them.
266
+ */
267
+ profile_lookups_per_day: number;
268
+ /** Skill endorsements. Used to share the follow cap. */
269
+ endorsements_per_day: number;
270
+ /**
271
+ * Sent-invitation withdrawals (the withdraw action itself, not the reads of
272
+ * the sent-invite list). LinkedIn blocks re-inviting a withdrawn member for
273
+ * three weeks; see the re-invite guard.
274
+ */
275
+ withdrawals_per_day: number;
276
+ /** Posts to the member's own feed, from every caller. Timing-exempt, never uncapped. */
277
+ posts_per_day: number;
278
+ follows_per_day: number;
279
+ likes_per_day: number;
280
+ comments_per_day: number;
281
+ total_actions_per_day: number;
282
+ min_action_spacing_seconds: number;
283
+ action_spacing_jitter_seconds: number;
284
+ interactive_min_spacing_seconds: number;
285
+ interactive_spacing_jitter_seconds: number;
286
+ /**
287
+ * Backstop on the interactive lane: how many human-paced sends an account may
288
+ * make in a rolling hour.
289
+ *
290
+ * Mostly redundant with messages_per_day (whose maximum is 40), and that is
291
+ * deliberate — it bounds BURST SHAPE rather than volume. Volume is already
292
+ * capped; this stops the whole daily allowance being spent in four minutes if
293
+ * the interactive classification is ever wrong, which is the one failure mode
294
+ * the daily cap cannot see.
295
+ */
296
+ interactive_sends_per_hour: number;
297
+ relations_reads_per_day: number;
298
+ messages_reads_per_day: number;
299
+ searches_per_day: number;
300
+ sales_nav_search_results_per_day: number;
301
+ api_reads_per_day: number;
302
+ total_reads_per_day: number;
303
+ relations_ingest_reads_per_day: number;
304
+ api_ingest_reads_per_day: number;
305
+ messages_ingest_reads_per_day: number;
306
+ /**
307
+ * Network-capture read budgets: the two-phase allowance for enumerating an
308
+ * account's OWN 1st-degree connections and followers into a workspace table.
309
+ *
310
+ * Two keys, not one, because Unipile's own guidance for the relations list is
311
+ * explicitly two-phase: a heavier initial sync, then "retrieving the first page
312
+ * only a few times a day with randomly spaced intervals". One flat number would
313
+ * either make the first walk take weeks or leave a steady-state drip reading far
314
+ * more than it needs forever. Both still meter as `relations_ingest_read` against
315
+ * one shared daily counter, so an account cannot spend both budgets at once.
316
+ */
317
+ network_backfill_reads_per_day: number;
318
+ network_delta_reads_per_day: number;
319
+ };
320
+ /**
321
+ * Safe defaults for LinkedIn action limits.
322
+ *
323
+ * PROVENANCE, because these numbers get copied and should not be laundered into
324
+ * authority they do not have:
325
+ *
326
+ * - The 20-25/day connection-request range and the 40/day message posture come
327
+ * from third-party operator playbooks describing HeyReach-style outreach, NOT
328
+ * from HeyReach. HeyReach's own sending-limits article publishes no numbers at
329
+ * all — it only says you configure per-action daily limits and that it "will
330
+ * always try to reach those limits". An earlier version of this comment cited
331
+ * HeyReach directly; that attribution was wrong.
332
+ * - The READ budgets (relations/api/messages, ingest and network capture) are
333
+ * anchored on Unipile's documented per-route recommendations, which relay
334
+ * LinkedIn's own limits: ~80-100 invitations/day, ~100 profile retrievals/day,
335
+ * and 100/day per account for "all other routes" — the bucket users_relations
336
+ * and users_followers fall into. Unipile enforces none of it; cadence is ours
337
+ * to choose, and exceeding returns 429/500 and can disconnect the account.
338
+ * See developer.unipile.com/docs/provider-limits-and-restrictions.
339
+ *
340
+ * The weekly invite and 200-total-action guards stay enforced independently.
341
+ * Users can lower limits per account, but never raise them above
342
+ * LINKEDIN_SENDER_LIMIT_MAXIMUMS.
343
+ */
344
+ export declare const LINKEDIN_SENDER_LIMIT_DEFAULTS: LinkedInSenderLimits;
345
+ export declare const LINKEDIN_SENDER_LIMIT_MAXIMUMS: LinkedInSenderLimits;
346
+ /**
347
+ * LinkedIn refuses a new invitation to a member whose earlier invitation from
348
+ * the same account was withdrawn, for about three weeks. OXYGEN records every
349
+ * withdrawal it makes and refuses the re-invite itself for this long, so the
350
+ * refusal is ours (with a reset time) instead of a provider 422.
351
+ */
352
+ export declare const LINKEDIN_REINVITE_COOLDOWN_DAYS = 21;
353
+ /**
354
+ * Whether a refused invitation is LinkedIn's re-invite cooldown: Unipile answers
355
+ * HTTP 422 with the `cannot_resend_yet` error type. Anchored on the type, with
356
+ * the message as a fallback for the provider shapes that carry the type only in
357
+ * the sentence. Pure and total.
358
+ */
359
+ export declare function isLinkedInReinviteCooldownRefusal(input: {
360
+ status: number | null;
361
+ providerType: string | null;
362
+ providerMessage: string | null;
363
+ }): boolean;
250
364
  export {};
@@ -196,3 +196,153 @@ export function truncateLinkedInInviteNote(note, limit) {
196
196
  const kept = cut === 0 ? head : head.slice(0, cut);
197
197
  return { note: kept.join("").trimEnd(), truncated: true, originalLength };
198
198
  }
199
+ /**
200
+ * Safe defaults for LinkedIn action limits.
201
+ *
202
+ * PROVENANCE, because these numbers get copied and should not be laundered into
203
+ * authority they do not have:
204
+ *
205
+ * - The 20-25/day connection-request range and the 40/day message posture come
206
+ * from third-party operator playbooks describing HeyReach-style outreach, NOT
207
+ * from HeyReach. HeyReach's own sending-limits article publishes no numbers at
208
+ * all — it only says you configure per-action daily limits and that it "will
209
+ * always try to reach those limits". An earlier version of this comment cited
210
+ * HeyReach directly; that attribution was wrong.
211
+ * - The READ budgets (relations/api/messages, ingest and network capture) are
212
+ * anchored on Unipile's documented per-route recommendations, which relay
213
+ * LinkedIn's own limits: ~80-100 invitations/day, ~100 profile retrievals/day,
214
+ * and 100/day per account for "all other routes" — the bucket users_relations
215
+ * and users_followers fall into. Unipile enforces none of it; cadence is ours
216
+ * to choose, and exceeding returns 429/500 and can disconnect the account.
217
+ * See developer.unipile.com/docs/provider-limits-and-restrictions.
218
+ *
219
+ * The weekly invite and 200-total-action guards stay enforced independently.
220
+ * Users can lower limits per account, but never raise them above
221
+ * LINKEDIN_SENDER_LIMIT_MAXIMUMS.
222
+ */
223
+ export const LINKEDIN_SENDER_LIMIT_DEFAULTS = {
224
+ invites_per_day: 20,
225
+ invites_per_week: 100,
226
+ messages_per_day: 40,
227
+ inmails_per_day: 10,
228
+ // Competitors cap open-profile InMail near 800/month; 400 leaves headroom.
229
+ inmails_per_month: 400,
230
+ profile_views_per_day: 50,
231
+ profile_lookups_per_day: 80,
232
+ endorsements_per_day: 5,
233
+ withdrawals_per_day: 10,
234
+ // Matches the order of magnitude of a busy human poster; the publishing
235
+ // scheduler's own 25-per-24h ceiling stays a second guard.
236
+ posts_per_day: 10,
237
+ follows_per_day: 20,
238
+ likes_per_day: 20,
239
+ comments_per_day: 5,
240
+ total_actions_per_day: 150,
241
+ min_action_spacing_seconds: 300,
242
+ action_spacing_jitter_seconds: 300,
243
+ // 1s, no jitter: a person working their own inbox should never meet this gate.
244
+ // It exists only to stop a genuine burst — a stuck key, a runaway client — from
245
+ // firing an account's whole hour in a second, and to keep the per-account row
246
+ // lock (and with it the hourly counter) in play. Jitter is 0 deliberately: the
247
+ // variance in human sending is the human, and adding 0-5s on top only makes the
248
+ // product feel slow. Non-zero spacing is still required — see the fast-path note
249
+ // in reserveLinkedInSenderActionSlot.
250
+ interactive_min_spacing_seconds: 1,
251
+ interactive_spacing_jitter_seconds: 0,
252
+ // The primary bound on human sending now that it no longer claims the daily
253
+ // message cap. 200/hour is ~3/minute sustained: never felt by someone answering
254
+ // their inbox, and still a hard ceiling if the interactive classification is
255
+ // ever wrong.
256
+ interactive_sends_per_hour: 200,
257
+ // Conservative read budgets: roughly human-speed usage. Bulk scraping
258
+ // (full contact-list exports, whole-inbox pulls, search harvesting) is
259
+ // impossible by default — the pattern that gets accounts banned.
260
+ relations_reads_per_day: 20,
261
+ messages_reads_per_day: 200,
262
+ searches_per_day: 20,
263
+ sales_nav_search_results_per_day: 1000,
264
+ api_reads_per_day: 200,
265
+ total_reads_per_day: 1000,
266
+ // Conservative ingestion drips: a large network/history import spreads over
267
+ // many days rather than bursting, and stays well within total_reads_per_day.
268
+ // History backfill is the slowest drip (full thread walks are the most
269
+ // ban-sensitive bulk read), so its default is the tightest of the three.
270
+ relations_ingest_reads_per_day: 15,
271
+ api_ingest_reads_per_day: 20,
272
+ messages_ingest_reads_per_day: 10,
273
+ // Network capture, phase 1: 40% of Unipile's documented ~100/day recommendation
274
+ // for these routes. At 50 members a page that is ~2,000 people/day, so a typical
275
+ // network lands in days rather than weeks while leaving most of the account's
276
+ // daily read headroom unused.
277
+ network_backfill_reads_per_day: 40,
278
+ // Phase 2, once the walk is exhausted: Unipile's "first page a few times a day".
279
+ // New connections and followers arrive at the TOP of both listings, so a handful
280
+ // of first-page reads catches them all; the drip re-walks deeper only if it finds
281
+ // a full page of new people.
282
+ network_delta_reads_per_day: 4,
283
+ };
284
+ export const LINKEDIN_SENDER_LIMIT_MAXIMUMS = {
285
+ invites_per_day: 30,
286
+ invites_per_week: 150,
287
+ messages_per_day: 40,
288
+ // Lowered 40 → 25 and 150 → 100 (2026-09-26, founder decision "add missing
289
+ // caps + tighten"): operator playbooks put InMail at ≤25/day and profile views
290
+ // at 50-100. Stored overrides above these clamp at read time.
291
+ inmails_per_day: 25,
292
+ inmails_per_month: 800,
293
+ profile_views_per_day: 100,
294
+ // Unipile relays ~100 profile retrievals a day as LinkedIn's own ceiling.
295
+ profile_lookups_per_day: 100,
296
+ endorsements_per_day: 10,
297
+ withdrawals_per_day: 20,
298
+ posts_per_day: 25,
299
+ follows_per_day: 50,
300
+ likes_per_day: 50,
301
+ // Lowered 50 → 20 (2026-09-25): public comments are the most visible automated
302
+ // signal an account emits, and the one LinkedIn's inauthentic-engagement
303
+ // enforcement keys on. Stored overrides above this clamp at read time.
304
+ comments_per_day: 20,
305
+ total_actions_per_day: 200,
306
+ min_action_spacing_seconds: 3600,
307
+ action_spacing_jitter_seconds: 1800,
308
+ interactive_min_spacing_seconds: 3600,
309
+ interactive_spacing_jitter_seconds: 1800,
310
+ // Lowered 1000 → 300 (2026-09-25): 5/minute sustained is still far above
311
+ // anyone answering their own inbox, and bounds a misclassified bulk send.
312
+ interactive_sends_per_hour: 300,
313
+ relations_reads_per_day: 100,
314
+ messages_reads_per_day: 600,
315
+ searches_per_day: 100,
316
+ sales_nav_search_results_per_day: 1000,
317
+ api_reads_per_day: 1000,
318
+ total_reads_per_day: 2000,
319
+ relations_ingest_reads_per_day: 100,
320
+ api_ingest_reads_per_day: 200,
321
+ messages_ingest_reads_per_day: 200,
322
+ // 100/day is Unipile's own documented recommendation for "all other routes",
323
+ // which is the bucket users_relations and users_followers fall into. Our hard
324
+ // maximum is exactly the vendor's number and must not be raised above it.
325
+ network_backfill_reads_per_day: 100,
326
+ network_delta_reads_per_day: 20,
327
+ };
328
+ // ===== Re-invite cooldown =====
329
+ /**
330
+ * LinkedIn refuses a new invitation to a member whose earlier invitation from
331
+ * the same account was withdrawn, for about three weeks. OXYGEN records every
332
+ * withdrawal it makes and refuses the re-invite itself for this long, so the
333
+ * refusal is ours (with a reset time) instead of a provider 422.
334
+ */
335
+ export const LINKEDIN_REINVITE_COOLDOWN_DAYS = 21;
336
+ /**
337
+ * Whether a refused invitation is LinkedIn's re-invite cooldown: Unipile answers
338
+ * HTTP 422 with the `cannot_resend_yet` error type. Anchored on the type, with
339
+ * the message as a fallback for the provider shapes that carry the type only in
340
+ * the sentence. Pure and total.
341
+ */
342
+ export function isLinkedInReinviteCooldownRefusal(input) {
343
+ if (input.status !== 422)
344
+ return false;
345
+ if (typeof input.providerType === "string" && /(?:^|\/)cannot_resend_yet$/u.test(input.providerType.trim()))
346
+ return true;
347
+ return typeof input.providerMessage === "string" && /\bcannot_resend_yet\b/u.test(input.providerMessage);
348
+ }
@@ -1,5 +1,7 @@
1
1
  import { log } from "./log.js";
2
2
  import { resolveDeployEnv } from "./deploy-env.js";
3
+ import { processResourceIdentity } from "./process-resource.js";
4
+ import { OXYGEN_VERSION } from "./version.js";
3
5
  import { sanitizeLogFields } from "./redaction.js";
4
6
  import { trace } from "@opentelemetry/api";
5
7
  import { operationalLogSnapshot } from "./operational-telemetry.js";
@@ -44,6 +46,7 @@ async function rejectedRecords(response, batchLength) {
44
46
  reader.releaseLock();
45
47
  }
46
48
  }
49
+ const OTEL_TRACE_ID = /^[a-f\d]{32}$/i;
47
50
  // OTLP severity numbers (logs data model): the base of each 4-value band.
48
51
  const SEVERITY_NUMBERS = { debug: 5, info: 9, warn: 13, error: 17 };
49
52
  // Set by log() on every record; they become the OTLP record's own fields rather
@@ -197,6 +200,10 @@ export function createOtlpLogSink(options) {
197
200
  ?? process.env.NODE_ENV
198
201
  ?? null,
199
202
  "deployment.sha": process.env.OXYGEN_GIT_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA,
203
+ "service.version": OXYGEN_VERSION,
204
+ // host.name + service.instance.id: which box and which process emitted a
205
+ // line, the same identity the trace/metric resources now carry.
206
+ ...processResourceIdentity(),
200
207
  ...resourceOverrides,
201
208
  });
202
209
  const queue = [];
@@ -277,9 +284,19 @@ export function createOtlpLogSink(options) {
277
284
  droppedOverflow += 1;
278
285
  }
279
286
  const activeSpan = trace.getActiveSpan()?.spanContext();
287
+ // A request correlation id that is not an OTel trace id (the CLI's UUID
288
+ // `x-oxygen-trace-id`) cannot be the record's traceId, but it is the one key
289
+ // a customer can quote; keep it as an attribute instead of discarding it,
290
+ // and let the active span supply the real trace/span ids.
291
+ const requestTraceId = typeof record.trace_id === "string" && record.trace_id !== ""
292
+ && !OTEL_TRACE_ID.test(record.trace_id) ? record.trace_id : null;
293
+ const { trace_id: _requestTraceId, ...withoutRequestTraceId } = record;
294
+ const base = requestTraceId
295
+ ? { ...withoutRequestTraceId, "oxygen.request_trace_id": requestTraceId }
296
+ : record;
280
297
  queue.push(operationalLogSnapshot(sanitizeLogFields({
281
- ...record,
282
- ...(activeSpan && (!record.trace_id || record.trace_id === activeSpan.traceId)
298
+ ...base,
299
+ ...(activeSpan && (requestTraceId || !record.trace_id || record.trace_id === activeSpan.traceId)
283
300
  ? { trace_id: activeSpan.traceId, span_id: activeSpan.spanId }
284
301
  : {}),
285
302
  })));
@@ -0,0 +1,118 @@
1
+ import { type LimitsTier } from "./plan-limits.js";
2
+ import type { RepricingOptions } from "./repricing.js";
3
+ /**
4
+ * The seven plan bands of the 2026-09 repricing (spec § 4, slice S06): free
5
+ * plus one band per Oxygen plan size. They sit BESIDE the five-rung
6
+ * `LimitsTier`, which cannot tell $49 from $99 or $199 from $499, so any limit
7
+ * that differs between those sizes (storage, the per-delivery spend default)
8
+ * is resolved from the band instead of the rung.
9
+ *
10
+ * A band never changes a limit by itself. Until a limit is re-keyed onto the
11
+ * band, it keeps reading its rung, and `PLAN_BAND_LIMITS_TIER` says which rung
12
+ * that is.
13
+ */
14
+ export type PlanBand = "free" | "49" | "99" | "199" | "499" | "999" | "1999";
15
+ export declare const PLAN_BAND_ORDER: readonly PlanBand[];
16
+ /** The limits rung each band enforces at today. */
17
+ export declare const PLAN_BAND_LIMITS_TIER: Readonly<Record<PlanBand, LimitsTier>>;
18
+ /**
19
+ * PROPOSED (P-57, repricing spec § 4): a grandfathered or legacy plan takes the
20
+ * band of the limits rung it enforces at today, so no grandfathered customer
21
+ * loses a storage or rate limit. The pro rung spans $199–$499, whose columns
22
+ * differ only in the per-delivery spend default, which grandfathered plans
23
+ * resolve from their own monthly credits instead (P-62); it maps to the lower
24
+ * band. Enterprise enforces at the scale rung and therefore takes the top band.
25
+ */
26
+ export declare const LEGACY_PLAN_BAND_BY_LIMITS_TIER: Readonly<Record<LimitsTier, PlanBand>>;
27
+ /**
28
+ * Map a raw plan-tier string (subscription `tier`, metadata tier, legacy plan
29
+ * key, or an Oxygen plan key like `oxygen_499`) onto its band. Unknown or absent
30
+ * tiers land on the unentitled "free" band, the same fail-closed rule as
31
+ * `limitsTierForPlanTier`.
32
+ */
33
+ export declare function planBandForPlanTier(tier: string | null | undefined): PlanBand;
34
+ /**
35
+ * The band of an org whose limits rung is already resolved (no second lookup).
36
+ * An unentitled org keeps no plan tier, so it takes the free band.
37
+ */
38
+ export declare function planBandForLimitsResolution(resolution: {
39
+ planTier: string | null;
40
+ entitled: boolean;
41
+ }): PlanBand;
42
+ /** The band's monthly price, read from the Oxygen plan it names; null for free. */
43
+ export declare function planBandMonthlyPriceCents(band: PlanBand): number | null;
44
+ export declare function planBandLabel(band: PlanBand): string;
45
+ /**
46
+ * Request rates, per minute unless named otherwise. The totals bound every
47
+ * `/api/cli/*` call; the operation buckets bind first for the calls a script
48
+ * makes most (row reads and writes, live tool and AI calls, dry runs, bulk
49
+ * imports). A 429 names the bucket it hit in `error.details.bucket`: the
50
+ * totals are `cli.requests`, the others `tenant_read.requests`,
51
+ * `tenant_write.requests`, `tool_live.requests` / `ai_live.requests`,
52
+ * `tool_dry_run.requests` and `bulk_import.requests`.
53
+ */
54
+ export type ApiRateLimitsReport = {
55
+ org_requests_per_minute: number;
56
+ key_requests_per_minute: number;
57
+ tenant_read_requests_per_key_per_minute: number;
58
+ tenant_write_requests_per_key_per_minute: number;
59
+ live_action_requests_per_key_per_minute: number;
60
+ live_action_requests_per_org_per_hour: number;
61
+ tool_dry_run_requests_per_key_per_minute: number;
62
+ bulk_import_tables_per_org_per_hour: number;
63
+ /**
64
+ * Row budgets shared by every key in the workspace: each row a request reads
65
+ * or writes is one unit (`tenant_read.units`, `tenant_write.units`). The
66
+ * window is an hour on free and a day on paid plans.
67
+ */
68
+ rows_read_per_org: {
69
+ rows: number;
70
+ window_seconds: number;
71
+ };
72
+ rows_written_per_org: {
73
+ rows: number;
74
+ window_seconds: number;
75
+ };
76
+ };
77
+ /** The API rates a limits rung enforces, read from `PLAN_LIMITS`. */
78
+ export declare function describeApiRateLimits(tier: LimitsTier): ApiRateLimitsReport;
79
+ /**
80
+ * The limits in force today for one band, as the `/api/cli/limits` contract
81
+ * reports them. Every value is read from the constant that enforces it; nothing
82
+ * here is a second copy. Monthly credits are the Oxygen plan's grant (free has
83
+ * none, so its org-daily guard does not apply).
84
+ */
85
+ export type PlanBandLimitsReport = {
86
+ band: PlanBand;
87
+ label: string;
88
+ monthly_price_usd: number | null;
89
+ monthly_credits: number | null;
90
+ limits_tier: LimitsTier;
91
+ api: ApiRateLimitsReport;
92
+ storage: {
93
+ table_row_limit: number;
94
+ workspace_row_limit: number;
95
+ workspace_database_warning_bytes: number;
96
+ workspace_database_limit_bytes: number;
97
+ /** Equal to rows per Table (PROPOSED P-60): one file can fill an empty Table. */
98
+ import_max_rows_per_file: number;
99
+ import_max_file_bytes: number;
100
+ };
101
+ spend: {
102
+ trigger_run_credit_ceiling: number | null;
103
+ auto_run_batch_credit_ceiling: number | null;
104
+ agent_run_max_total_credits: number;
105
+ agent_run_max_inference_credits: number;
106
+ org_daily_guard_warn_credits: number | null;
107
+ org_daily_guard_block_credits: number | null;
108
+ byok_column_run_max_rows: number | null;
109
+ byok_provider_daily_calls: number | null;
110
+ };
111
+ signals: {
112
+ max_events: number;
113
+ max_window_days: number;
114
+ };
115
+ };
116
+ export declare function describePlanBandLimits(band: PlanBand, options?: RepricingOptions): PlanBandLimitsReport;
117
+ /** Every band's limits in force today, smallest first. */
118
+ export declare function describePlanBandLadder(options?: RepricingOptions): PlanBandLimitsReport[];
@@ -0,0 +1,147 @@
1
+ import { resolveBasePricingPlan } from "./billing.js";
2
+ import { planLimitsForTier, limitsTierForPlanTier } from "./plan-limits.js";
3
+ import { DEFAULT_AUTO_RUN_BATCH_CREDIT_CEILING, DEFAULT_BYOK_COLUMN_RUN_MAX_ROWS, DEFAULT_BYOK_PROVIDER_DAILY_CALL_CAP, DEFAULT_TRIGGER_RUN_CREDIT_CEILING, resolveOrgDailySpendGuard, } from "./spend-safety.js";
4
+ import { resolveWorkspaceTableCapacity } from "./table-capacity.js";
5
+ export const PLAN_BAND_ORDER = [
6
+ "free",
7
+ "49",
8
+ "99",
9
+ "199",
10
+ "499",
11
+ "999",
12
+ "1999",
13
+ ];
14
+ /** The limits rung each band enforces at today. */
15
+ export const PLAN_BAND_LIMITS_TIER = Object.freeze({
16
+ free: "free",
17
+ "49": "starter",
18
+ "99": "starter",
19
+ "199": "pro",
20
+ "499": "pro",
21
+ "999": "team",
22
+ "1999": "scale",
23
+ });
24
+ /**
25
+ * PROPOSED (P-57, repricing spec § 4): a grandfathered or legacy plan takes the
26
+ * band of the limits rung it enforces at today, so no grandfathered customer
27
+ * loses a storage or rate limit. The pro rung spans $199–$499, whose columns
28
+ * differ only in the per-delivery spend default, which grandfathered plans
29
+ * resolve from their own monthly credits instead (P-62); it maps to the lower
30
+ * band. Enterprise enforces at the scale rung and therefore takes the top band.
31
+ */
32
+ export const LEGACY_PLAN_BAND_BY_LIMITS_TIER = Object.freeze({
33
+ free: "free",
34
+ starter: "99",
35
+ pro: "199",
36
+ team: "999",
37
+ scale: "1999",
38
+ });
39
+ /**
40
+ * Map a raw plan-tier string (subscription `tier`, metadata tier, legacy plan
41
+ * key, or an Oxygen plan key like `oxygen_499`) onto its band. Unknown or absent
42
+ * tiers land on the unentitled "free" band, the same fail-closed rule as
43
+ * `limitsTierForPlanTier`.
44
+ */
45
+ export function planBandForPlanTier(tier) {
46
+ const plan = resolveBasePricingPlan(tier);
47
+ if (!plan || plan.tier === "free")
48
+ return "free";
49
+ if (plan.tier === "oxygen")
50
+ return oxygenPlanBand(plan.monthlyPriceCents);
51
+ return LEGACY_PLAN_BAND_BY_LIMITS_TIER[limitsTierForPlanTier(tier)];
52
+ }
53
+ /**
54
+ * The band of an org whose limits rung is already resolved (no second lookup).
55
+ * An unentitled org keeps no plan tier, so it takes the free band.
56
+ */
57
+ export function planBandForLimitsResolution(resolution) {
58
+ return resolution.entitled ? planBandForPlanTier(resolution.planTier) : "free";
59
+ }
60
+ /**
61
+ * An Oxygen plan sits on the largest size at or below its price. A plan with no
62
+ * resolvable price takes the entry size, the same rung `limitsTierForPlanTier`
63
+ * gives it.
64
+ */
65
+ function oxygenPlanBand(monthlyPriceCents) {
66
+ if (monthlyPriceCents === null)
67
+ return "49";
68
+ let band = "49";
69
+ for (const candidate of PLAN_BAND_ORDER) {
70
+ const price = planBandMonthlyPriceCents(candidate);
71
+ if (price !== null && price <= monthlyPriceCents)
72
+ band = candidate;
73
+ }
74
+ return band;
75
+ }
76
+ /** The band's monthly price, read from the Oxygen plan it names; null for free. */
77
+ export function planBandMonthlyPriceCents(band) {
78
+ if (band === "free")
79
+ return null;
80
+ return resolveBasePricingPlan(`oxygen_${band}`)?.monthlyPriceCents ?? null;
81
+ }
82
+ export function planBandLabel(band) {
83
+ return band === "free" ? "Free" : `Oxygen $${Number(band).toLocaleString("en-US")}`;
84
+ }
85
+ /** The API rates a limits rung enforces, read from `PLAN_LIMITS`. */
86
+ export function describeApiRateLimits(tier) {
87
+ const cli = planLimitsForTier(tier).cli;
88
+ return {
89
+ org_requests_per_minute: cli.global.orgRequestsPerMinute,
90
+ key_requests_per_minute: cli.global.keyRequestsPerMinute,
91
+ tenant_read_requests_per_key_per_minute: cli.tenantRead.requestsPerMinute,
92
+ tenant_write_requests_per_key_per_minute: cli.tenantWrite.requestsPerMinute,
93
+ // ai_live and tool_live are separate buckets with equal limits at every rung.
94
+ live_action_requests_per_key_per_minute: cli.toolLive.requestsPerMinute,
95
+ // Both org windows are hourly on every rung (pinned by plan-band.test.ts).
96
+ live_action_requests_per_org_per_hour: cli.toolLive.orgRequests.limit,
97
+ tool_dry_run_requests_per_key_per_minute: cli.toolDryRun.requestsPerMinute,
98
+ bulk_import_tables_per_org_per_hour: cli.bulkImport.orgRequests.limit,
99
+ rows_read_per_org: { rows: cli.tenantRead.orgUnits.limit, window_seconds: cli.tenantRead.orgUnits.windowSeconds },
100
+ rows_written_per_org: { rows: cli.tenantWrite.orgUnits.limit, window_seconds: cli.tenantWrite.orgUnits.windowSeconds },
101
+ };
102
+ }
103
+ export function describePlanBandLimits(band, options = {}) {
104
+ const limitsTier = PLAN_BAND_LIMITS_TIER[band];
105
+ const storage = resolveWorkspaceTableCapacity(band, options);
106
+ const limits = planLimitsForTier(limitsTier);
107
+ const monthlyPriceCents = planBandMonthlyPriceCents(band);
108
+ const monthlyCredits = band === "free"
109
+ ? null
110
+ : resolveBasePricingPlan(`oxygen_${band}`)?.monthlyCredits ?? null;
111
+ const dailyGuard = resolveOrgDailySpendGuard(monthlyCredits);
112
+ return {
113
+ band,
114
+ label: planBandLabel(band),
115
+ monthly_price_usd: monthlyPriceCents === null ? null : monthlyPriceCents / 100,
116
+ monthly_credits: monthlyCredits,
117
+ limits_tier: limitsTier,
118
+ api: describeApiRateLimits(limitsTier),
119
+ storage: {
120
+ table_row_limit: storage.tableRowLimit,
121
+ workspace_row_limit: storage.workspaceRowLimit,
122
+ workspace_database_warning_bytes: storage.workspaceDatabaseWarningBytes,
123
+ workspace_database_limit_bytes: storage.workspaceDatabaseLimitBytes,
124
+ import_max_rows_per_file: storage.tableRowLimit,
125
+ import_max_file_bytes: limits.import.maxFileBytes,
126
+ },
127
+ spend: {
128
+ // Bands are Oxygen plan sizes, so their spend tier is their limits rung.
129
+ trigger_run_credit_ceiling: DEFAULT_TRIGGER_RUN_CREDIT_CEILING[limitsTier],
130
+ auto_run_batch_credit_ceiling: DEFAULT_AUTO_RUN_BATCH_CREDIT_CEILING[limitsTier],
131
+ agent_run_max_total_credits: limits.agents.maxTotalCredits,
132
+ agent_run_max_inference_credits: limits.agents.maxInferenceCredits,
133
+ org_daily_guard_warn_credits: dailyGuard?.warnCredits ?? null,
134
+ org_daily_guard_block_credits: dailyGuard?.blockCredits ?? null,
135
+ byok_column_run_max_rows: DEFAULT_BYOK_COLUMN_RUN_MAX_ROWS[limitsTier],
136
+ byok_provider_daily_calls: DEFAULT_BYOK_PROVIDER_DAILY_CALL_CAP[limitsTier],
137
+ },
138
+ signals: {
139
+ max_events: limits.signals.maxEvents,
140
+ max_window_days: limits.signals.maxWindowDays,
141
+ },
142
+ };
143
+ }
144
+ /** Every band's limits in force today, smallest first. */
145
+ export function describePlanBandLadder(options = {}) {
146
+ return PLAN_BAND_ORDER.map((band) => describePlanBandLimits(band, options));
147
+ }