@oxygen-agent/cli 1.1011.493 → 1.1011.600

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 (73) hide show
  1. package/README.md +1 -1
  2. package/dist/crm-file-read-commands.d.ts +6 -0
  3. package/dist/crm-file-read-commands.js +16 -0
  4. package/dist/feed-read-flags.d.ts +3 -0
  5. package/dist/feed-read-flags.js +9 -0
  6. package/dist/functions-commands.js +2 -1
  7. package/dist/help.js +18 -5
  8. package/dist/index.js +168 -107
  9. package/dist/sender-limits-flags.js +6 -4
  10. package/dist/settings-commands.js +11 -11
  11. package/dist/whatsapp-account-flags.d.ts +4 -0
  12. package/dist/whatsapp-account-flags.js +18 -0
  13. package/node_modules/@oxygen/cli-ugc/dist/commands.js +2 -2
  14. package/node_modules/@oxygen/shared/dist/billing.d.ts +41 -0
  15. package/node_modules/@oxygen/shared/dist/billing.js +56 -0
  16. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +21 -0
  17. package/node_modules/@oxygen/shared/dist/capability-discovery.js +251 -21
  18. package/node_modules/@oxygen/shared/dist/contracts/billing-owner-read.d.ts +6 -0
  19. package/node_modules/@oxygen/shared/dist/contracts/billing-owner-read.js +7 -0
  20. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +4 -2
  21. package/node_modules/@oxygen/shared/dist/contracts/copilot-sessions.d.ts +11 -0
  22. package/node_modules/@oxygen/shared/dist/contracts/copilot-sessions.js +7 -0
  23. package/node_modules/@oxygen/shared/dist/contracts/crm-file-bytes.d.ts +33 -0
  24. package/node_modules/@oxygen/shared/dist/contracts/crm-file-bytes.js +12 -0
  25. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +6 -3
  26. package/node_modules/@oxygen/shared/dist/contracts/function-library-read.d.ts +14 -0
  27. package/node_modules/@oxygen/shared/dist/contracts/function-library-read.js +16 -0
  28. package/node_modules/@oxygen/shared/dist/contracts/inbox-drafts-read.d.ts +45 -0
  29. package/node_modules/@oxygen/shared/dist/contracts/inbox-drafts-read.js +21 -0
  30. package/node_modules/@oxygen/shared/dist/contracts/limits-read.d.ts +29 -0
  31. package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +121 -0
  32. package/node_modules/@oxygen/shared/dist/contracts/linkedin-invites-list.js +1 -1
  33. package/node_modules/@oxygen/shared/dist/contracts/pipedrive-catalog.d.ts +18 -0
  34. package/node_modules/@oxygen/shared/dist/contracts/pipedrive-catalog.js +29 -0
  35. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.d.ts +181 -0
  36. package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +62 -0
  37. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +7 -5
  38. package/node_modules/@oxygen/shared/dist/contracts/sequence-list.d.ts +3 -0
  39. package/node_modules/@oxygen/shared/dist/contracts/sequence-list.js +8 -0
  40. package/node_modules/@oxygen/shared/dist/contracts/stored-read-window.d.ts +17 -0
  41. package/node_modules/@oxygen/shared/dist/contracts/stored-read-window.js +8 -0
  42. package/node_modules/@oxygen/shared/dist/contracts/table-feed-read.d.ts +67 -0
  43. package/node_modules/@oxygen/shared/dist/contracts/table-feed-read.js +19 -0
  44. package/node_modules/@oxygen/shared/dist/contracts/table-row-read.d.ts +30 -0
  45. package/node_modules/@oxygen/shared/dist/contracts/table-row-read.js +9 -0
  46. package/node_modules/@oxygen/shared/dist/contracts/whatsapp-reads.d.ts +111 -0
  47. package/node_modules/@oxygen/shared/dist/contracts/whatsapp-reads.js +33 -0
  48. package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.d.ts +6 -5
  49. package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.js +5 -5
  50. package/node_modules/@oxygen/shared/dist/copilot-playbooks.d.ts +4 -1
  51. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +17 -0
  52. package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +26 -0
  53. package/node_modules/@oxygen/shared/dist/copilot-playbooks.js +2 -32
  54. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +5 -0
  55. package/node_modules/@oxygen/shared/dist/feature-gates.js +6 -0
  56. package/node_modules/@oxygen/shared/dist/first-month-offer-window.d.ts +34 -0
  57. package/node_modules/@oxygen/shared/dist/first-month-offer-window.js +41 -0
  58. package/node_modules/@oxygen/shared/dist/first-month-offer.d.ts +42 -0
  59. package/node_modules/@oxygen/shared/dist/first-month-offer.js +56 -0
  60. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +32 -1
  61. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +68 -4
  62. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +6 -2
  63. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +6 -1
  64. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +11 -0
  65. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +11 -0
  66. package/node_modules/@oxygen/shared/dist/sql-error.js +13 -2
  67. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +3 -0
  68. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +17 -1
  69. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  70. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  71. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +11 -1
  72. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +29 -3
  73. package/package.json +1 -1
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The first-month offer's 24-hour window and the browser's record of it, with no
3
+ * plan catalog. The offer banner and the app shell run in the browser and import
4
+ * this; importing `first-month-offer.ts` there pulled the whole billing catalog
5
+ * into every dashboard route (+16 KB gzip) and every website page (+8 KB),
6
+ * which the release battery's bundle budget refused on 2026-10-03. Prices stay
7
+ * in `first-month-offer.ts`: a server computes the banner's headline with
8
+ * `firstMonthOfferHeadline()` and hands it down.
9
+ */
10
+ /** How long a person has to claim the offer after first seeing it. */
11
+ export const FIRST_MONTH_OFFER_WINDOW_MS = 24 * 60 * 60 * 1000;
12
+ /**
13
+ * First-party cookie holding when this browser first saw the offer, in epoch
14
+ * milliseconds. Readable by the page so the countdown needs no request; signup
15
+ * copies it onto the workspace, after which the workspace's own record decides.
16
+ * Clearing it starts a new window, which the founder accepted (2026-10-02).
17
+ */
18
+ export const FIRST_MONTH_OFFER_SEEN_COOKIE = "ox_first_month_offer_seen";
19
+ /** How long the browser keeps the start, so an expired window stays expired. */
20
+ export const FIRST_MONTH_OFFER_SEEN_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
21
+ /** When a window that started at `startedAtMs` closes. */
22
+ export function firstMonthOfferEndsAt(startedAtMs) {
23
+ return startedAtMs + FIRST_MONTH_OFFER_WINDOW_MS;
24
+ }
25
+ /**
26
+ * A remembered start, or null when there is none worth trusting: not a number,
27
+ * in the future (beyond a minute of clock skew), or older than the cookie may
28
+ * live. A start the browser invented is no worse than a cleared cookie.
29
+ */
30
+ export function parseFirstMonthOfferStart(raw, nowMs) {
31
+ if (!raw || !/^\d{10,16}$/.test(raw.trim()))
32
+ return null;
33
+ const startedAt = Number(raw.trim());
34
+ if (!Number.isSafeInteger(startedAt))
35
+ return null;
36
+ if (startedAt > nowMs + 60_000)
37
+ return null;
38
+ if (nowMs - startedAt > FIRST_MONTH_OFFER_SEEN_MAX_AGE_SECONDS * 1000)
39
+ return null;
40
+ return Math.min(startedAt, nowMs);
41
+ }
@@ -0,0 +1,42 @@
1
+ import { type OxygenPlanKey } from "./billing.js";
2
+ import { type FirstMonthOfferHeadline } from "./first-month-offer-window.js";
3
+ export { FIRST_MONTH_OFFER_SEEN_COOKIE, FIRST_MONTH_OFFER_SEEN_MAX_AGE_SECONDS, firstMonthOfferEndsAt, parseFirstMonthOfferStart, type FirstMonthOfferHeadline, } from "./first-month-offer-window.js";
4
+ /**
5
+ * The first-month offer (founder, 2026-10-02; widened 2026-10-03 to "all the
6
+ * plans above the $49/month plan ... from the $99/month plan upwards"): a new
7
+ * customer's first month of any Oxygen size from $99 up costs half its price,
8
+ * rounded down to the dollar, so they start on a bigger plan for less and, the
9
+ * hope is, keep it. $99 becomes $49, $199 $99, $499 $249, $999 $499 and $1,999
10
+ * $999. It is claimable for 24 hours from the first time that person sees it: a
11
+ * browser remembers when it first showed the offer, signup carries that start
12
+ * onto the new workspace, and checkout applies the discount only inside the
13
+ * workspace's window.
14
+ *
15
+ * This module carries the plan catalog, so the browser imports
16
+ * `first-month-offer-window.ts` instead and receives prices from the server.
17
+ * What arms the offer (Stripe coupons named by the environment) and who may
18
+ * claim it are server decisions.
19
+ */
20
+ export declare const FIRST_MONTH_OFFER: Readonly<{
21
+ /** Stable machine name, written to Checkout and subscription metadata. */
22
+ key: "first_month_half";
23
+ /** The cheapest size it applies to; every dearer size qualifies too. */
24
+ minimumPlanKey: "oxygen_99";
25
+ /** How long a person has to claim it after first seeing it. */
26
+ windowMs: number;
27
+ }>;
28
+ /** The sizes the offer applies to, cheapest first: the minimum and every dearer purchasable size. */
29
+ export declare const FIRST_MONTH_OFFER_PLAN_KEYS: readonly OxygenPlanKey[];
30
+ /** Whether the offer applies to this size (billed monthly; checkout checks the rest). */
31
+ export declare function firstMonthOfferAppliesTo(planKey: string): planKey is OxygenPlanKey;
32
+ export type FirstMonthOfferPrice = {
33
+ planKey: OxygenPlanKey;
34
+ regularCents: number;
35
+ firstMonthCents: number;
36
+ /** The headline discount, rounded down so the page never claims more than it gives. */
37
+ percentOff: number;
38
+ };
39
+ /** A size's first month under the offer: half its price, rounded down to the dollar ($99 → $49, 50% off). */
40
+ export declare function firstMonthOfferPrice(planKey?: OxygenPlanKey): FirstMonthOfferPrice;
41
+ /** The banner's headline, computed here so the browser never loads the catalog. */
42
+ export declare function firstMonthOfferHeadline(): FirstMonthOfferHeadline;
@@ -0,0 +1,56 @@
1
+ import { PURCHASABLE_PLAN_KEYS, resolveBasePricingPlan } from "./billing.js";
2
+ import { FIRST_MONTH_OFFER_WINDOW_MS } from "./first-month-offer-window.js";
3
+ export { FIRST_MONTH_OFFER_SEEN_COOKIE, FIRST_MONTH_OFFER_SEEN_MAX_AGE_SECONDS, firstMonthOfferEndsAt, parseFirstMonthOfferStart, } from "./first-month-offer-window.js";
4
+ /**
5
+ * The first-month offer (founder, 2026-10-02; widened 2026-10-03 to "all the
6
+ * plans above the $49/month plan ... from the $99/month plan upwards"): a new
7
+ * customer's first month of any Oxygen size from $99 up costs half its price,
8
+ * rounded down to the dollar, so they start on a bigger plan for less and, the
9
+ * hope is, keep it. $99 becomes $49, $199 $99, $499 $249, $999 $499 and $1,999
10
+ * $999. It is claimable for 24 hours from the first time that person sees it: a
11
+ * browser remembers when it first showed the offer, signup carries that start
12
+ * onto the new workspace, and checkout applies the discount only inside the
13
+ * workspace's window.
14
+ *
15
+ * This module carries the plan catalog, so the browser imports
16
+ * `first-month-offer-window.ts` instead and receives prices from the server.
17
+ * What arms the offer (Stripe coupons named by the environment) and who may
18
+ * claim it are server decisions.
19
+ */
20
+ export const FIRST_MONTH_OFFER = Object.freeze({
21
+ /** Stable machine name, written to Checkout and subscription metadata. */
22
+ key: "first_month_half",
23
+ /** The cheapest size it applies to; every dearer size qualifies too. */
24
+ minimumPlanKey: "oxygen_99",
25
+ /** How long a person has to claim it after first seeing it. */
26
+ windowMs: FIRST_MONTH_OFFER_WINDOW_MS,
27
+ });
28
+ /** The sizes the offer applies to, cheapest first: the minimum and every dearer purchasable size. */
29
+ export const FIRST_MONTH_OFFER_PLAN_KEYS = (() => {
30
+ const minimum = resolveBasePricingPlan(FIRST_MONTH_OFFER.minimumPlanKey)?.monthlyPriceCents;
31
+ if (!minimum)
32
+ throw new Error(`the first-month offer's minimum size ${FIRST_MONTH_OFFER.minimumPlanKey} has no monthly price`);
33
+ return PURCHASABLE_PLAN_KEYS.filter((planKey) => (resolveBasePricingPlan(planKey)?.monthlyPriceCents ?? 0) >= minimum);
34
+ })();
35
+ /** Whether the offer applies to this size (billed monthly; checkout checks the rest). */
36
+ export function firstMonthOfferAppliesTo(planKey) {
37
+ return FIRST_MONTH_OFFER_PLAN_KEYS.includes(planKey);
38
+ }
39
+ /** A size's first month under the offer: half its price, rounded down to the dollar ($99 → $49, 50% off). */
40
+ export function firstMonthOfferPrice(planKey = FIRST_MONTH_OFFER.minimumPlanKey) {
41
+ if (!firstMonthOfferAppliesTo(planKey))
42
+ throw new Error(`the first-month offer does not apply to ${planKey}`);
43
+ const regularCents = resolveBasePricingPlan(planKey)?.monthlyPriceCents ?? 0;
44
+ const firstMonthCents = Math.floor(regularCents / 200) * 100;
45
+ return {
46
+ planKey,
47
+ regularCents,
48
+ firstMonthCents,
49
+ percentOff: Math.floor(((regularCents - firstMonthCents) / regularCents) * 100),
50
+ };
51
+ }
52
+ /** The banner's headline, computed here so the browser never loads the catalog. */
53
+ export function firstMonthOfferHeadline() {
54
+ const price = firstMonthOfferPrice();
55
+ return { percentOff: price.percentOff, fromCents: price.regularCents };
56
+ }
@@ -287,7 +287,7 @@ export type LinkedInSenderLimits = {
287
287
  * Backstop on the interactive lane: how many human-paced sends an account may
288
288
  * make in a rolling hour.
289
289
  *
290
- * Mostly redundant with messages_per_day (whose maximum is 40), and that is
290
+ * Mostly redundant with messages_per_day (default 40, at most 200), and that is
291
291
  * deliberate — it bounds BURST SHAPE rather than volume. Volume is already
292
292
  * capped; this stops the whole daily allowance being spent in four minutes if
293
293
  * the interactive classification is ever wrong, which is the one failure mode
@@ -354,6 +354,37 @@ export type LinkedInSenderLimitKeysAreComplete = SameKeys<LinkedInSenderLimitKey
354
354
  */
355
355
  export declare const LINKEDIN_SENDER_LIMIT_DEFAULTS: LinkedInSenderLimits;
356
356
  export declare const LINKEDIN_SENDER_LIMIT_MAXIMUMS: LinkedInSenderLimits;
357
+ /**
358
+ * Daily send caps above which every surface warns: the maximums before the
359
+ * 2026-10-02 raise. Values above them are allowed and carry a higher risk of a
360
+ * LinkedIn restriction.
361
+ */
362
+ export declare const LINKEDIN_SENDER_LIMIT_CAUTION_LEVELS: {
363
+ readonly messages_per_day: 40;
364
+ readonly comments_per_day: 20;
365
+ readonly total_actions_per_day: 200;
366
+ };
367
+ /** The caution level of one limit, or undefined when it has none. */
368
+ export declare function linkedInSenderLimitCautionLevel(key: keyof LinkedInSenderLimits): number | undefined;
369
+ export type LinkedInLimitAboveCaution = {
370
+ limit: keyof LinkedInSenderLimits;
371
+ value: number;
372
+ caution_level: number;
373
+ };
374
+ /** The configured limits above their caution level, for the limits get/set responses. */
375
+ export declare function linkedInLimitsAboveCaution(limits: Partial<LinkedInSenderLimits>): LinkedInLimitAboveCaution[];
376
+ /** One sentence naming the limits above their caution level, or undefined when none is. */
377
+ export declare function linkedInLimitsCautionWarning(above: readonly LinkedInLimitAboveCaution[]): string | undefined;
378
+ /**
379
+ * Sender metadata key where tenant migration 0471 recorded the overrides it
380
+ * capped when the maximums were raised on 2026-10-02.
381
+ */
382
+ export declare const LINKEDIN_LIMIT_CAUTION_RECORD_KEY = "linkedin_limit_caution_2026_10";
383
+ /**
384
+ * Plain-language notes on that one-time cap, for each limit still at the value
385
+ * the migration wrote. Saving a new value for a limit retires its note.
386
+ */
387
+ export declare function linkedInLimitCautionNotices(metadata: Record<string, unknown>, overrides: Partial<LinkedInSenderLimits>): string[];
357
388
  /**
358
389
  * LinkedIn refuses a new invitation to a member whose earlier invitation from
359
390
  * the same account was withdrawn, for about three weeks. OXYGEN records every
@@ -322,10 +322,16 @@ export const LINKEDIN_SENDER_LIMIT_DEFAULTS = {
322
322
  // a full page of new people.
323
323
  network_delta_reads_per_day: 4,
324
324
  };
325
+ // Raised for inbound volume (2026-10-02, human decision in chat): a viral lead-magnet
326
+ // post brings hundreds of requests a day, so an account may opt into up to 200
327
+ // direct messages and 200 comments a day and 500 total actions. Defaults are
328
+ // unchanged; each account raises its own limits. The former maximums remain the
329
+ // caution levels below, and tenant migration 0471 caps overrides stored above them
330
+ // before this change, so nothing goes higher without a new explicit setting.
325
331
  export const LINKEDIN_SENDER_LIMIT_MAXIMUMS = {
326
332
  invites_per_day: 30,
327
333
  invites_per_week: 150,
328
- messages_per_day: 40,
334
+ messages_per_day: 200,
329
335
  // Lowered 40 → 25 and 150 → 100 (2026-09-26, founder decision "add missing
330
336
  // caps + tighten"): operator playbooks put InMail at ≤25/day and profile views
331
337
  // at 50-100. Stored overrides above these clamp at read time.
@@ -341,9 +347,9 @@ export const LINKEDIN_SENDER_LIMIT_MAXIMUMS = {
341
347
  likes_per_day: 50,
342
348
  // Lowered 50 → 20 (2026-09-25): public comments are the most visible automated
343
349
  // signal an account emits, and the one LinkedIn's inauthentic-engagement
344
- // enforcement keys on. Stored overrides above this clamp at read time.
345
- comments_per_day: 20,
346
- total_actions_per_day: 200,
350
+ // enforcement keys on. Raised to 200 (2026-10-02, above); 20 stays the caution level.
351
+ comments_per_day: 200,
352
+ total_actions_per_day: 500,
347
353
  min_action_spacing_seconds: 3600,
348
354
  action_spacing_jitter_seconds: 1800,
349
355
  interactive_min_spacing_seconds: 3600,
@@ -366,6 +372,64 @@ export const LINKEDIN_SENDER_LIMIT_MAXIMUMS = {
366
372
  network_backfill_reads_per_day: 100,
367
373
  network_delta_reads_per_day: 20,
368
374
  };
375
+ /**
376
+ * Daily send caps above which every surface warns: the maximums before the
377
+ * 2026-10-02 raise. Values above them are allowed and carry a higher risk of a
378
+ * LinkedIn restriction.
379
+ */
380
+ export const LINKEDIN_SENDER_LIMIT_CAUTION_LEVELS = {
381
+ messages_per_day: 40,
382
+ comments_per_day: 20,
383
+ total_actions_per_day: 200,
384
+ };
385
+ /** The caution level of one limit, or undefined when it has none. */
386
+ export function linkedInSenderLimitCautionLevel(key) {
387
+ return LINKEDIN_SENDER_LIMIT_CAUTION_LEVELS[key];
388
+ }
389
+ /** The configured limits above their caution level, for the limits get/set responses. */
390
+ export function linkedInLimitsAboveCaution(limits) {
391
+ const above = [];
392
+ for (const [limit, level] of Object.entries(LINKEDIN_SENDER_LIMIT_CAUTION_LEVELS)) {
393
+ const value = limits[limit];
394
+ if (typeof value === "number" && value > level)
395
+ above.push({ limit, value, caution_level: level });
396
+ }
397
+ return above;
398
+ }
399
+ /** One sentence naming the limits above their caution level, or undefined when none is. */
400
+ export function linkedInLimitsCautionWarning(above) {
401
+ if (above.length === 0)
402
+ return undefined;
403
+ const list = above.map((entry) => `${entry.limit} ${entry.value} (caution level ${entry.caution_level})`).join(", ");
404
+ return `Above its caution level each of these raises the risk of a LinkedIn restriction: ${list}.`;
405
+ }
406
+ /**
407
+ * Sender metadata key where tenant migration 0471 recorded the overrides it
408
+ * capped when the maximums were raised on 2026-10-02.
409
+ */
410
+ export const LINKEDIN_LIMIT_CAUTION_RECORD_KEY = "linkedin_limit_caution_2026_10";
411
+ /**
412
+ * Plain-language notes on that one-time cap, for each limit still at the value
413
+ * the migration wrote. Saving a new value for a limit retires its note.
414
+ */
415
+ export function linkedInLimitCautionNotices(metadata, overrides) {
416
+ const record = metadata[LINKEDIN_LIMIT_CAUTION_RECORD_KEY];
417
+ if (!record || typeof record !== "object" || Array.isArray(record))
418
+ return [];
419
+ const { previous_limits: previous, limits: capped } = record;
420
+ const notices = [];
421
+ for (const [limit, level] of Object.entries(capped ?? {})) {
422
+ const before = previous?.[limit];
423
+ if (typeof level !== "number" || typeof before !== "number")
424
+ continue;
425
+ if (overrides[limit] !== level)
426
+ continue;
427
+ notices.push(`On 2 Oct 2026 OXYGEN lowered this account's saved ${limit} from ${before} to ${level} once. `
428
+ + `The ${before} had been saved before the 25 Sep 2026 limit change and would otherwise have come back by itself. `
429
+ + "Limits you save now are kept as set.");
430
+ }
431
+ return notices;
432
+ }
369
433
  // ===== Re-invite cooldown =====
370
434
  /**
371
435
  * LinkedIn refuses a new invitation to a member whose earlier invitation from
@@ -55,8 +55,12 @@ export type OtlpLogSinkOptions = {
55
55
  includeMessagesBelowMinLevel?: readonly string[] | undefined;
56
56
  fetch?: typeof globalThis.fetch | undefined;
57
57
  };
58
- /** Source alerts #13, #16, #17, #19, #43, #45, #51, #71, #73, #75–77, #87, #90, #92, #95 and #97 need these INFO events. */
59
- export declare const OTLP_MONITOR_INFO_EVENTS: readonly ["tenant.migrations.cron.done", "outbound_send.sent", "worker_workflow_cron_overlap_skipped", "worker_workflow_cron_enqueued", "copilot.turn.finished", "worker.queue.heartbeat", "worker.queue.ledger", "worker.queue.redis", "worker.queue.unregistered_kind", "automation_usage.reconcile_completed", "provider_health.cron_completed", "provider_balances.cron_completed", "cost_snapshot.cron_completed", "worker.egress_order_reconcile_sweep", "posthog_canary.round_trip_ok", "web.log_volume"];
58
+ /**
59
+ * Source alerts #13, #16, #17, #19, #43, #45, #51, #71, #73, #75–77, #87, #90, #92, #95 and #97 need these INFO events.
60
+ * `worker.log_shipper.stats` is the Axiom shipper's once-a-minute loss accounting,
61
+ * which by design never reaches Axiom (OXP-1.6.126).
62
+ */
63
+ export declare const OTLP_MONITOR_INFO_EVENTS: readonly ["tenant.migrations.cron.done", "outbound_send.sent", "worker_workflow_cron_overlap_skipped", "worker_workflow_cron_enqueued", "copilot.turn.finished", "worker.queue.heartbeat", "worker.queue.ledger", "worker.queue.redis", "worker.queue.unregistered_kind", "automation_usage.reconcile_completed", "provider_health.cron_completed", "provider_balances.cron_completed", "cost_snapshot.cron_completed", "worker.egress_order_reconcile_sweep", "posthog_canary.round_trip_ok", "web.log_volume", "worker.log_shipper.stats"];
60
64
  export type OtlpLogSinkHandle = {
61
65
  sink: LogSink;
62
66
  flush: () => Promise<void>;
@@ -6,7 +6,11 @@ import { sanitizeLogFields } from "./redaction.js";
6
6
  import { trace } from "@opentelemetry/api";
7
7
  import { operationalLogSnapshot } from "./operational-telemetry.js";
8
8
  import { telemetryEndpointForLog } from "./telemetry-export-observer.js";
9
- /** Source alerts #13, #16, #17, #19, #43, #45, #51, #71, #73, #75–77, #87, #90, #92, #95 and #97 need these INFO events. */
9
+ /**
10
+ * Source alerts #13, #16, #17, #19, #43, #45, #51, #71, #73, #75–77, #87, #90, #92, #95 and #97 need these INFO events.
11
+ * `worker.log_shipper.stats` is the Axiom shipper's once-a-minute loss accounting,
12
+ * which by design never reaches Axiom (OXP-1.6.126).
13
+ */
10
14
  export const OTLP_MONITOR_INFO_EVENTS = [
11
15
  "tenant.migrations.cron.done",
12
16
  "outbound_send.sent",
@@ -24,6 +28,7 @@ export const OTLP_MONITOR_INFO_EVENTS = [
24
28
  "worker.egress_order_reconcile_sweep",
25
29
  "posthog_canary.round_trip_ok",
26
30
  "web.log_volume",
31
+ "worker.log_shipper.stats",
27
32
  ];
28
33
  const DEFAULT_BATCH_SIZE = 500;
29
34
  const DEFAULT_FLUSH_INTERVAL_MS = 2_000;
@@ -294,5 +294,16 @@ export declare const ENRICHMENT_CREDITS: {
294
294
  readonly page_scrape: 1.6;
295
295
  readonly linkedin_profile_scrape: 1;
296
296
  };
297
+ /**
298
+ * What the default work-email column spends per email it finds, measured, not
299
+ * the single-finder list price above. 23,400 production `find_email` rows across
300
+ * 10 tenants (2026-08-11 to 2026-09-11) spent 2.6 credits per found email in
301
+ * today's units: the cheap first lane (Blitz at 1.5 credits, plus 0.225 to
302
+ * verify) finds most of them, and a lane that misses bills nothing. Rounded up
303
+ * to 3, so a plan's "about N work emails" stays below what was measured.
304
+ * Source and method: `.agents/skills/oxygen-enrichment-waterfall` § find_email
305
+ * in production.
306
+ */
307
+ export declare const WORK_EMAIL_CREDITS_PER_FOUND_MEASURED = 3;
297
308
  /** Editable calculator default: blended credits per fully-enriched row. */
298
309
  export declare const ENRICHMENT_CREDITS_PER_ROW_TYPICAL = 10;
@@ -274,5 +274,16 @@ export const ENRICHMENT_CREDITS = {
274
274
  page_scrape: 1.6, // Firecrawl, 5x of $0.0032 (repricing 2026-09, S44)
275
275
  linkedin_profile_scrape: 1, // Managed profile credit at $0.002 COGS, exactly 5x
276
276
  };
277
+ /**
278
+ * What the default work-email column spends per email it finds, measured, not
279
+ * the single-finder list price above. 23,400 production `find_email` rows across
280
+ * 10 tenants (2026-08-11 to 2026-09-11) spent 2.6 credits per found email in
281
+ * today's units: the cheap first lane (Blitz at 1.5 credits, plus 0.225 to
282
+ * verify) finds most of them, and a lane that misses bills nothing. Rounded up
283
+ * to 3, so a plan's "about N work emails" stays below what was measured.
284
+ * Source and method: `.agents/skills/oxygen-enrichment-waterfall` § find_email
285
+ * in production.
286
+ */
287
+ export const WORK_EMAIL_CREDITS_PER_FOUND_MEASURED = 3;
277
288
  /** Editable calculator default: blended credits per fully-enriched row. */
278
289
  export const ENRICHMENT_CREDITS_PER_ROW_TYPICAL = SNAPSHOT_ENRICHMENT_CREDITS_PER_ROW_TYPICAL;
@@ -82,7 +82,7 @@ function describeSqlError(error) {
82
82
  const message = readMessage(error);
83
83
  const drizzle = isDrizzleQueryError(error, message);
84
84
  const sqlstateCause = sqlstate ? categoryFromSqlstate(sqlstate) ?? "unknown" : "unknown";
85
- const cause = sqlstateCause !== "unknown" ? sqlstateCause : causeFromErrnoOrMessage(errno, message);
85
+ const cause = sqlstateCause !== "unknown" ? sqlstateCause : causeFromErrnoOrMessage(errno, message, error);
86
86
  const hasSignal = Boolean(sqlstate) || Boolean(errno) || cause !== "unknown" || drizzle;
87
87
  if (!hasSignal)
88
88
  return null;
@@ -112,7 +112,15 @@ function describeSqlError(error) {
112
112
  }
113
113
  // Connection-level fallback when no SQLSTATE resolved a cause: prefer the
114
114
  // errno (set before/around the TCP+TLS handshake), then the message text.
115
- function causeFromErrnoOrMessage(errno, message) {
115
+ //
116
+ // A node-postgres pool checkout timeout ("timeout exceeded when trying to
117
+ // connect") carries no errno, and drizzle wraps it as `Failed query: …` with the
118
+ // pool error as `cause`, so the top-level message alone classified it `unknown`.
119
+ // The connect-timeout text is therefore matched along the cause chain too: the
120
+ // statement was never sent, so `transient` is exact. The broader
121
+ // connection-terminated patterns stay top-level only — one of those can follow
122
+ // a statement the server already applied.
123
+ function causeFromErrnoOrMessage(errno, message, error) {
116
124
  if (errno && CONNECT_TIMEOUT_ERRNOS.has(errno))
117
125
  return "connect_timeout";
118
126
  if (errno && CONNECTION_ERRNOS.has(errno))
@@ -121,6 +129,9 @@ function causeFromErrnoOrMessage(errno, message) {
121
129
  return "connect_timeout";
122
130
  if (CONNECTION_MESSAGE.test(message))
123
131
  return "connection";
132
+ const nestedConnectTimeout = readCauseChain(error, (obj) => typeof obj.message === "string" && CONNECT_TIMEOUT_MESSAGE.test(obj.message) ? true : null);
133
+ if (nestedConnectTimeout)
134
+ return "connect_timeout";
124
135
  return "unknown";
125
136
  }
126
137
  /** Log-field shape (snake_case) for merging into `errorFields(error)` payloads. */
@@ -5,6 +5,9 @@ export type PricingPlan = PricingPlanDefinition & {
5
5
  priceIds: Record<BillingCurrency, string>;
6
6
  priceId: string;
7
7
  };
8
+ export declare function annualMonthlyPlanPriceId(planKey: OxygenPlanKey): string;
9
+ export declare function isAnnualMonthlyPlanPrice(priceId: string | null | undefined): boolean;
10
+ export declare function annualMonthlyBillingAvailable(): boolean;
8
11
  export declare const PRICING_PLANS: {
9
12
  readonly free: PricingPlan;
10
13
  readonly oxygen: PricingPlan;
@@ -38,6 +38,20 @@ const STRIPE_PRICE_OXYGEN_ANNUAL_IDS = {
38
38
  oxygen_999: { usd: process.env.STRIPE_PRICE_OXYGEN_999_ANNUAL_USD ?? "" },
39
39
  oxygen_1999: { usd: process.env.STRIPE_PRICE_OXYGEN_1999_ANNUAL_USD ?? "" },
40
40
  };
41
+ const ANNUAL_MONTHLY_PRICE_IDS = Object.fromEntries(PURCHASABLE_PLAN_KEYS.map((key) => [
42
+ key, process.env[`STRIPE_PRICE_OXYGEN_${key.replace("oxygen_", "")}_ANNUAL_MONTHLY_USD`] ?? "",
43
+ ]));
44
+ export function annualMonthlyPlanPriceId(planKey) {
45
+ return ANNUAL_MONTHLY_PRICE_IDS[planKey];
46
+ }
47
+ export function isAnnualMonthlyPlanPrice(priceId) {
48
+ return !!priceId && Object.values(ANNUAL_MONTHLY_PRICE_IDS).includes(priceId);
49
+ }
50
+ export function annualMonthlyBillingAvailable() {
51
+ return process.env.OXYGEN_ANNUAL_MONTHLY_ENABLED === "true"
52
+ && !!process.env.STRIPE_ANNUAL_MONTHLY_PORTAL_CONFIGURATION_ID
53
+ && PURCHASABLE_PLAN_KEYS.every((key) => !!annualMonthlyPlanPriceId(key));
54
+ }
41
55
  // Grandfathered on 2026-09-19. These three stay pointed at their LIVE prices
42
56
  // (35 Starter + 2 Pro subscriptions bill against them) rather than moving into
43
57
  // the legacy CSV lists: tierFromPriceId must keep returning "starter"/"pro"/
@@ -160,7 +174,8 @@ export function tierFromPriceId(priceId) {
160
174
  return null;
161
175
  for (const planKey of PURCHASABLE_PLAN_KEYS) {
162
176
  if (Object.values(STRIPE_PRICE_OXYGEN_IDS[planKey]).includes(priceId)
163
- || Object.values(STRIPE_PRICE_OXYGEN_ANNUAL_IDS[planKey]).includes(priceId)) {
177
+ || Object.values(STRIPE_PRICE_OXYGEN_ANNUAL_IDS[planKey]).includes(priceId)
178
+ || ANNUAL_MONTHLY_PRICE_IDS[planKey] === priceId) {
164
179
  return planKey;
165
180
  }
166
181
  }
@@ -197,6 +212,7 @@ export function recognizedStripePriceIdsBySelfServeTier() {
197
212
  oxygen: [
198
213
  ...PURCHASABLE_PLAN_KEYS.flatMap((planKey) => Object.values(STRIPE_PRICE_OXYGEN_IDS[planKey])),
199
214
  ...configuredAnnualPlanPriceIds(),
215
+ ...Object.values(ANNUAL_MONTHLY_PRICE_IDS),
200
216
  ...LEGACY_CURRENT_PLAN_PRICE_IDS.oxygen,
201
217
  ],
202
218
  starter: [
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1011.493";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1011.600";
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
2
- export const OXYGEN_BUILD_VERSION = "1.1011.493";
2
+ export const OXYGEN_BUILD_VERSION = "1.1011.600";
@@ -28,7 +28,7 @@ export declare function assertWorkspaceVisualBinary(bytes: Uint8Array, mimeType:
28
28
  export declare function workspaceFileSha256(bytes: Uint8Array): string;
29
29
  /** Opaque vault attachments are downloaded as bytes, never rendered as active content. */
30
30
  export declare function assertWorkspaceStoredFile(bytes: Uint8Array, mimeType: WorkspaceStoredFileMimeType): void;
31
- export declare function readWorkspaceFileObject(input: {
31
+ type WorkspaceFileReadInput = {
32
32
  organizationId: string;
33
33
  fileId: string;
34
34
  storageKey: string;
@@ -36,6 +36,15 @@ export declare function readWorkspaceFileObject(input: {
36
36
  sha256: string;
37
37
  mimeType: WorkspaceStoredFileMimeType;
38
38
  signal?: AbortSignal;
39
+ };
40
+ export declare function readWorkspaceFileObject(input: WorkspaceFileReadInput): Promise<Uint8Array>;
41
+ /**
42
+ * Retains only this original-byte window while verifying the entire stored SHA-256.
43
+ * Each window still streams the whole object; an ETag does not prove its digest.
44
+ */
45
+ export declare function readWorkspaceFileObjectWindow(input: WorkspaceFileReadInput & {
46
+ offset: number;
47
+ limit: number;
39
48
  }): Promise<Uint8Array>;
40
49
  /** How long a presigned workspace-file upload stays valid. Long enough for 100 MiB on a slow link. */
41
50
  export declare const WORKSPACE_FILE_UPLOAD_PRESIGN_SECONDS = 900;
@@ -85,3 +94,4 @@ export declare function deleteWorkspaceFileObject(input: {
85
94
  fileId: string;
86
95
  storageKey: string;
87
96
  }): Promise<void>;
97
+ export {};
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
4
4
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
5
5
  import { OxygenError } from "./cli-result.js";
6
+ import { CRM_FILE_BYTES_MAX } from "./contracts/crm-file-bytes.js";
6
7
  import { resolveObjectStorageClient } from "./object-storage.js";
7
8
  export const WORKSPACE_VISUAL_SOURCE_MAX_BYTES = 1_500_000;
8
9
  export const WORKSPACE_VISUAL_BINARY_MAX_BYTES = 50 * 1024 * 1024;
@@ -74,6 +75,21 @@ export function assertWorkspaceStoredFile(bytes, mimeType) {
74
75
  assertWorkspaceVisualBinary(bytes, mimeType);
75
76
  }
76
77
  export async function readWorkspaceFileObject(input) {
78
+ return readVerifiedWorkspaceFileObject(input);
79
+ }
80
+ /**
81
+ * Retains only this original-byte window while verifying the entire stored SHA-256.
82
+ * Each window still streams the whole object; an ETag does not prove its digest.
83
+ */
84
+ export async function readWorkspaceFileObjectWindow(input) {
85
+ if (input.mimeType !== "application/octet-stream" || !Number.isSafeInteger(input.offset)
86
+ || input.offset < 0 || input.offset > input.sizeBytes || !Number.isSafeInteger(input.limit)
87
+ || input.limit < 1 || input.limit > CRM_FILE_BYTES_MAX) {
88
+ throw new OxygenError("invalid_file_window", "Read an original file window of 1–2048 bytes at an offset within the file.");
89
+ }
90
+ return readVerifiedWorkspaceFileObject(input, { offset: input.offset, limit: input.limit });
91
+ }
92
+ async function readVerifiedWorkspaceFileObject(input, window) {
77
93
  input.signal?.throwIfAborted();
78
94
  assertWorkspaceFileObjectKey(input);
79
95
  const maximumBytes = input.mimeType === "application/octet-stream" ? WORKSPACE_OPAQUE_FILE_MAX_BYTES : WORKSPACE_VISUAL_BINARY_MAX_BYTES;
@@ -95,19 +111,29 @@ export async function readWorkspaceFileObject(input) {
95
111
  if (typeof stream[Symbol.asyncIterator] !== "function")
96
112
  throw new OxygenError("workspace_file_storage_invalid", "Stored file transport is invalid.");
97
113
  const chunks = [];
114
+ const digest = createHash("sha256");
98
115
  let size = 0;
116
+ let retained = 0;
99
117
  for await (const chunk of stream) {
100
118
  signal.throwIfAborted();
101
119
  if (!(chunk instanceof Uint8Array))
102
120
  throw new OxygenError("workspace_file_storage_invalid", "Stored file transport is invalid.");
121
+ const chunkStart = size;
103
122
  size += chunk.byteLength;
104
123
  if (size > input.sizeBytes)
105
124
  throw new OxygenError("workspace_file_integrity_failed", "Stored file exceeds its recorded size.");
106
- chunks.push(Buffer.from(chunk));
125
+ digest.update(chunk);
126
+ const selected = window
127
+ ? chunk.subarray(Math.max(0, window.offset - chunkStart), Math.max(0, Math.min(chunk.byteLength, window.offset + window.limit - chunkStart)))
128
+ : chunk;
129
+ if (selected.byteLength > 0) {
130
+ chunks.push(Buffer.from(selected));
131
+ retained += selected.byteLength;
132
+ }
107
133
  }
108
- const bytes = Buffer.concat(chunks, size);
134
+ const bytes = Buffer.concat(chunks, retained);
109
135
  signal.throwIfAborted();
110
- if (size !== input.sizeBytes || workspaceFileSha256(bytes) !== input.sha256 || result.ContentType?.split(";", 1)[0]?.trim().toLowerCase() !== input.mimeType) {
136
+ if (size !== input.sizeBytes || digest.digest("hex") !== input.sha256 || result.ContentType?.split(";", 1)[0]?.trim().toLowerCase() !== input.mimeType) {
111
137
  throw new OxygenError("workspace_file_integrity_failed", "Stored file failed size, format or digest verification.");
112
138
  }
113
139
  assertWorkspaceStoredFile(bytes, input.mimeType);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1011.493",
3
+ "version": "1.1011.600",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",