@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.
- package/README.md +1 -1
- package/dist/crm-file-read-commands.d.ts +6 -0
- package/dist/crm-file-read-commands.js +16 -0
- package/dist/feed-read-flags.d.ts +3 -0
- package/dist/feed-read-flags.js +9 -0
- package/dist/functions-commands.js +2 -1
- package/dist/help.js +18 -5
- package/dist/index.js +168 -107
- package/dist/sender-limits-flags.js +6 -4
- package/dist/settings-commands.js +11 -11
- package/dist/whatsapp-account-flags.d.ts +4 -0
- package/dist/whatsapp-account-flags.js +18 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +2 -2
- package/node_modules/@oxygen/shared/dist/billing.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/billing.js +56 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +251 -21
- package/node_modules/@oxygen/shared/dist/contracts/billing-owner-read.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/contracts/billing-owner-read.js +7 -0
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +4 -2
- package/node_modules/@oxygen/shared/dist/contracts/copilot-sessions.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/contracts/copilot-sessions.js +7 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-file-bytes.d.ts +33 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-file-bytes.js +12 -0
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +6 -3
- package/node_modules/@oxygen/shared/dist/contracts/function-library-read.d.ts +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/function-library-read.js +16 -0
- package/node_modules/@oxygen/shared/dist/contracts/inbox-drafts-read.d.ts +45 -0
- package/node_modules/@oxygen/shared/dist/contracts/inbox-drafts-read.js +21 -0
- package/node_modules/@oxygen/shared/dist/contracts/limits-read.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/contracts/limits-read.js +121 -0
- package/node_modules/@oxygen/shared/dist/contracts/linkedin-invites-list.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/pipedrive-catalog.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/contracts/pipedrive-catalog.js +29 -0
- package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.d.ts +181 -0
- package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +62 -0
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +7 -5
- package/node_modules/@oxygen/shared/dist/contracts/sequence-list.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/contracts/sequence-list.js +8 -0
- package/node_modules/@oxygen/shared/dist/contracts/stored-read-window.d.ts +17 -0
- package/node_modules/@oxygen/shared/dist/contracts/stored-read-window.js +8 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-feed-read.d.ts +67 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-feed-read.js +19 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-read.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-read.js +9 -0
- package/node_modules/@oxygen/shared/dist/contracts/whatsapp-reads.d.ts +111 -0
- package/node_modules/@oxygen/shared/dist/contracts/whatsapp-reads.js +33 -0
- package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.d.ts +6 -5
- package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.js +5 -5
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.d.ts +4 -1
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +17 -0
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +26 -0
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.js +2 -32
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.js +6 -0
- package/node_modules/@oxygen/shared/dist/first-month-offer-window.d.ts +34 -0
- package/node_modules/@oxygen/shared/dist/first-month-offer-window.js +41 -0
- package/node_modules/@oxygen/shared/dist/first-month-offer.d.ts +42 -0
- package/node_modules/@oxygen/shared/dist/first-month-offer.js +56 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +32 -1
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +68 -4
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +6 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +11 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +13 -2
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +17 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +11 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +29 -3
- 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 (
|
|
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:
|
|
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.
|
|
345
|
-
comments_per_day:
|
|
346
|
-
total_actions_per_day:
|
|
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
|
-
/**
|
|
59
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
134
|
+
const bytes = Buffer.concat(chunks, retained);
|
|
109
135
|
signal.throwIfAborted();
|
|
110
|
-
if (size !== input.sizeBytes ||
|
|
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);
|