@oxygen-agent/cli 1.1011.98 → 1.1011.322
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/admin-primary-providers-render.js +25 -5
- package/dist/column-run-notices.d.ts +16 -0
- package/dist/column-run-notices.js +57 -0
- package/dist/command-manifest.js +10 -1
- package/dist/connectors-commands.d.ts +11 -0
- package/dist/connectors-commands.js +63 -0
- package/dist/help.d.ts +27 -1
- package/dist/help.js +112 -29
- package/dist/http-client.js +8 -0
- package/dist/index.js +362 -130
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +20 -69
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +6 -4
- package/node_modules/@oxygen/shared/dist/byok-connect.js +11 -4
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +131 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +325 -30
- package/node_modules/@oxygen/shared/dist/collab.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/collab.js +12 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +7 -3
- package/node_modules/@oxygen/shared/dist/column-output-fields.js +45 -3
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +20 -13
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +29 -13
- package/node_modules/@oxygen/shared/dist/contracts/connectors.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/contracts/connectors.js +34 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +0 -6
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +8 -1
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +12 -1
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +37 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +79 -44
- 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/knowledge-bootstrap.d.ts +57 -8
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +74 -13
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +9 -0
- package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/log.js +15 -0
- package/node_modules/@oxygen/shared/dist/mcp-event-contract.d.ts +597 -0
- package/node_modules/@oxygen/shared/dist/mcp-event-contract.js +713 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.js +14 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +6 -2
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +7 -3
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +22 -1
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +3 -7
- package/node_modules/@oxygen/shared/dist/plan-limits.js +2 -14
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
- package/node_modules/@oxygen/shared/dist/sending-schedule.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/sending-schedule.js +41 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +20 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.js +63 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +13 -7
- package/node_modules/@oxygen/shared/dist/sequences.js +26 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +5 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +1 -15
- package/node_modules/@oxygen/shared/dist/standard-webhooks.d.ts +51 -0
- package/node_modules/@oxygen/shared/dist/standard-webhooks.js +124 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/tags.js +13 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +11 -7
- package/node_modules/@oxygen/shared/dist/ugc.js +14 -6
- 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/zoned-time.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/zoned-time.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The one switch for MCP Events (ADR 0030 §11, D3; ledger OXP-11.5.19). The
|
|
2
|
+
// `events/*` handlers, the delivery producer and the delivery worker all read it,
|
|
3
|
+
// so events are either on for every surface or off for all of them.
|
|
4
|
+
//
|
|
5
|
+
// Fail closed: only `1` or `true` turns events on. Unset, empty, `0`, `false`,
|
|
6
|
+
// `TRUE` and anything else is off. Production keeps it unset until the
|
|
7
|
+
// per-connection credit ceiling (OXP-11.5.23) exists and a human turns events on;
|
|
8
|
+
// the DEV Doppler configs set it to `1`. Read from Doppler at the call site,
|
|
9
|
+
// never from a remote flag service (NEVER_FLAGGABLE in ./feature-gates.ts).
|
|
10
|
+
export const MCP_EVENTS_ENABLED_ENV = "OXYGEN_MCP_EVENTS_ENABLED";
|
|
11
|
+
export function mcpEventsEnabled(env = process.env) {
|
|
12
|
+
const value = env[MCP_EVENTS_ENABLED_ENV]?.trim();
|
|
13
|
+
return value === "1" || value === "true";
|
|
14
|
+
}
|
|
@@ -2,6 +2,7 @@ type AttributeContainer = {
|
|
|
2
2
|
attributes?: Record<string, unknown>;
|
|
3
3
|
name?: string;
|
|
4
4
|
};
|
|
5
|
+
export declare function maskEmails(value: string): string;
|
|
5
6
|
/** Sink-local copy; the managed logger retains its original record. */
|
|
6
7
|
export declare function operationalLogSnapshot(record: Record<string, unknown>): Record<string, unknown>;
|
|
7
8
|
/** Export a sanitized copy; never mutate the span another destination reads. */
|
|
@@ -3,18 +3,22 @@ import { forwardExporterMethods } from "./telemetry-export-observer.js";
|
|
|
3
3
|
// Namespaced semantic-convention fields evade the generic exact-key log policy.
|
|
4
4
|
// Numeric usage remains operational data, subject to the shared secret policy.
|
|
5
5
|
const PAYLOAD_KEY = /(?:^|[._-])(?:prompts?|completions?|inputs?|outputs?|payloads?|rows?|messages?|content|body|customer_data)(?:$|[._-])|^(?:request|response)$/i;
|
|
6
|
+
// Error text, not a message body: ADR 0023 logs the detail withheld from a
|
|
7
|
+
// customer as `error_message` (errorFields) or `<scope>_error_message`, and
|
|
8
|
+
// SigNoz is the only dev log store. Sanitized like `error` (decided 2026-09-29).
|
|
9
|
+
const ERROR_TEXT_KEY = /(?:^|_)error_message$/i;
|
|
6
10
|
const EXCEPTION_CONTENT = /^exception\.(?:message|stacktrace)$/i;
|
|
7
11
|
const QUERY_CONTENT = /^(?:db\.(?:statement|query\.text)(?:[._].*)?|(?:http|url)\.(?:query|query_string|request\.query)|process\.command(?:_args|_line)?)$/i;
|
|
8
12
|
// Addresses keep their domain (the operational signal) and drop the person.
|
|
9
13
|
const EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})/g;
|
|
10
|
-
function maskEmails(value) {
|
|
14
|
+
export function maskEmails(value) {
|
|
11
15
|
return value.replace(EMAIL_ADDRESS, "***@$1");
|
|
12
16
|
}
|
|
13
17
|
function operationalText(value) {
|
|
14
18
|
return maskEmails(redactSecretsInString(value).replace(/(?:https?:\/\/|\/)[^\s"'<>]+/gi, stripOperationalUrl));
|
|
15
19
|
}
|
|
16
20
|
function operationalValue(value, key, depth = 0) {
|
|
17
|
-
if (PAYLOAD_KEY.test(key) && typeof value !== "number" && typeof value !== "boolean")
|
|
21
|
+
if (PAYLOAD_KEY.test(key) && !ERROR_TEXT_KEY.test(key) && typeof value !== "number" && typeof value !== "boolean")
|
|
18
22
|
return "[omitted]";
|
|
19
23
|
if (typeof value === "string")
|
|
20
24
|
return /(?:^|[._-])(?:url|path|target|href)(?:$|[._-])/i.test(key)
|
|
@@ -46,13 +46,17 @@ export type OtlpLogSinkOptions = {
|
|
|
46
46
|
/** Clock for the report throttle; tests only. */
|
|
47
47
|
now?: (() => number) | undefined;
|
|
48
48
|
/**
|
|
49
|
-
* Lowest level this sink ships
|
|
50
|
-
* or absent ships every level. Sinks
|
|
51
|
-
* stdout are unaffected
|
|
49
|
+
* Lowest level this sink ships, except exact messages listed in
|
|
50
|
+
* includeMessagesBelowMinLevel. Null or absent ships every level. Sinks
|
|
51
|
+
* composed beside this one (Axiom) and stdout are unaffected.
|
|
52
52
|
*/
|
|
53
53
|
minLevel?: LogLevel | null | undefined;
|
|
54
|
+
/** Exact, low-volume monitor events that must reach OTLP below minLevel. */
|
|
55
|
+
includeMessagesBelowMinLevel?: readonly string[] | undefined;
|
|
54
56
|
fetch?: typeof globalThis.fetch | undefined;
|
|
55
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"];
|
|
56
60
|
export type OtlpLogSinkHandle = {
|
|
57
61
|
sink: LogSink;
|
|
58
62
|
flush: () => Promise<void>;
|
|
@@ -6,6 +6,25 @@ 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. */
|
|
10
|
+
export const OTLP_MONITOR_INFO_EVENTS = [
|
|
11
|
+
"tenant.migrations.cron.done",
|
|
12
|
+
"outbound_send.sent",
|
|
13
|
+
"worker_workflow_cron_overlap_skipped",
|
|
14
|
+
"worker_workflow_cron_enqueued",
|
|
15
|
+
"copilot.turn.finished",
|
|
16
|
+
"worker.queue.heartbeat",
|
|
17
|
+
"worker.queue.ledger",
|
|
18
|
+
"worker.queue.redis",
|
|
19
|
+
"worker.queue.unregistered_kind",
|
|
20
|
+
"automation_usage.reconcile_completed",
|
|
21
|
+
"provider_health.cron_completed",
|
|
22
|
+
"provider_balances.cron_completed",
|
|
23
|
+
"cost_snapshot.cron_completed",
|
|
24
|
+
"worker.egress_order_reconcile_sweep",
|
|
25
|
+
"posthog_canary.round_trip_ok",
|
|
26
|
+
"web.log_volume",
|
|
27
|
+
];
|
|
9
28
|
const DEFAULT_BATCH_SIZE = 500;
|
|
10
29
|
const DEFAULT_FLUSH_INTERVAL_MS = 2_000;
|
|
11
30
|
const DEFAULT_MAX_QUEUE = 10_000;
|
|
@@ -189,6 +208,7 @@ export function createOtlpLogSink(options) {
|
|
|
189
208
|
const doFetch = options.fetch ?? globalThis.fetch;
|
|
190
209
|
const now = options.now ?? Date.now;
|
|
191
210
|
const minSeverity = options.minLevel ? SEVERITY_NUMBERS[options.minLevel] : null;
|
|
211
|
+
const includedMessages = new Set(options.includeMessagesBelowMinLevel ?? []);
|
|
192
212
|
// A null/undefined override is dropped rather than applied, so a caller that
|
|
193
213
|
// resolves its own environment and comes back empty-handed falls back to the
|
|
194
214
|
// default here instead of shipping records with no `deployment.environment`.
|
|
@@ -275,7 +295,8 @@ export function createOtlpLogSink(options) {
|
|
|
275
295
|
if (stopped || emittingInternal)
|
|
276
296
|
return;
|
|
277
297
|
// An unknown level ships as INFO (see toLogRecord), so it filters as one.
|
|
278
|
-
if (minSeverity !== null && SEVERITY_NUMBERS[recordLevel(record)] < minSeverity
|
|
298
|
+
if (minSeverity !== null && SEVERITY_NUMBERS[recordLevel(record)] < minSeverity
|
|
299
|
+
&& (typeof record.msg !== "string" || !includedMessages.has(record.msg)))
|
|
279
300
|
return;
|
|
280
301
|
// Drop the OLDEST record and keep this one: under sustained backpressure the
|
|
281
302
|
// newest lines are the ones describing the incident being debugged.
|
|
@@ -807,21 +807,17 @@ export declare function resolvePlanAgentCreditLimits(tier: LimitsTier, options?:
|
|
|
807
807
|
*/
|
|
808
808
|
export declare const AGENT_RUN_APPROVED_CEILING_MAX_CREDITS = 25000;
|
|
809
809
|
/** Where an org's default Agent run ceiling came from. */
|
|
810
|
-
export type AgentRunDefaultCeilingSource = "plan"
|
|
810
|
+
export type AgentRunDefaultCeilingSource = "plan";
|
|
811
811
|
/** The Agent run ceilings an org's runs are held to without a further approval. */
|
|
812
812
|
export type AgentRunDefaultCeilings = PlanAgentCreditLimits & {
|
|
813
813
|
source: AgentRunDefaultCeilingSource;
|
|
814
814
|
};
|
|
815
815
|
/**
|
|
816
|
-
* The default Agent run ceilings for an org
|
|
817
|
-
*
|
|
818
|
-
* replaces the rung's total and model calls take half of it, as on every paid
|
|
819
|
-
* rung. A contract value is clamped to the platform backstop; an unusable one
|
|
820
|
-
* (absent, non-numeric, not positive) is ignored.
|
|
816
|
+
* The default Agent run ceilings for an org come from its plan rung. Enterprise
|
|
817
|
+
* uses the $1,999 rung; any higher run ceiling needs explicit approval.
|
|
821
818
|
*/
|
|
822
819
|
export declare function resolveAgentRunDefaultCeilings(input: {
|
|
823
820
|
tier: LimitsTier;
|
|
824
|
-
enterpriseDefaultCredits?: number | null;
|
|
825
821
|
options?: RepricingOptions;
|
|
826
822
|
}): AgentRunDefaultCeilings;
|
|
827
823
|
/** `details.approval_kind` on the refusal below, so every surface can render its own approval card. */
|
|
@@ -368,22 +368,10 @@ export function resolvePlanAgentCreditLimits(tier, options = {}) {
|
|
|
368
368
|
*/
|
|
369
369
|
export const AGENT_RUN_APPROVED_CEILING_MAX_CREDITS = 25_000;
|
|
370
370
|
/**
|
|
371
|
-
* The default Agent run ceilings for an org
|
|
372
|
-
*
|
|
373
|
-
* replaces the rung's total and model calls take half of it, as on every paid
|
|
374
|
-
* rung. A contract value is clamped to the platform backstop; an unusable one
|
|
375
|
-
* (absent, non-numeric, not positive) is ignored.
|
|
371
|
+
* The default Agent run ceilings for an org come from its plan rung. Enterprise
|
|
372
|
+
* uses the $1,999 rung; any higher run ceiling needs explicit approval.
|
|
376
373
|
*/
|
|
377
374
|
export function resolveAgentRunDefaultCeilings(input) {
|
|
378
|
-
const contract = input.enterpriseDefaultCredits;
|
|
379
|
-
if (typeof contract === "number" && Number.isFinite(contract) && contract >= 1) {
|
|
380
|
-
const total = Math.min(Math.floor(contract), AGENT_RUN_APPROVED_CEILING_MAX_CREDITS);
|
|
381
|
-
return {
|
|
382
|
-
maxTotalCredits: total,
|
|
383
|
-
maxInferenceCredits: Math.max(1, Math.floor(total / 2)),
|
|
384
|
-
source: "enterprise_contract",
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
375
|
return { ...resolvePlanAgentCreditLimits(input.tier, input.options), source: "plan" };
|
|
388
376
|
}
|
|
389
377
|
/** `details.approval_kind` on the refusal below, so every surface can render its own approval card. */
|
|
@@ -33,3 +33,9 @@ export type SelectOption = {
|
|
|
33
33
|
order: number;
|
|
34
34
|
};
|
|
35
35
|
export declare function normalizeSelectOptions(raw: unknown): SelectOption[];
|
|
36
|
+
/**
|
|
37
|
+
* The id an option carries when it declares none, derived from its value. The
|
|
38
|
+
* web option editor saves new options with an empty id, so this is also how a
|
|
39
|
+
* stored cell value maps back to its option id (MCP stage events use it).
|
|
40
|
+
*/
|
|
41
|
+
export declare function slugifyOption(value: string): string;
|
|
@@ -145,7 +145,12 @@ function readTrimmedString(value) {
|
|
|
145
145
|
const trimmed = value.trim();
|
|
146
146
|
return trimmed ? trimmed : null;
|
|
147
147
|
}
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* The id an option carries when it declares none, derived from its value. The
|
|
150
|
+
* web option editor saves new options with an empty id, so this is also how a
|
|
151
|
+
* stored cell value maps back to its option id (MCP stage events use it).
|
|
152
|
+
*/
|
|
153
|
+
export function slugifyOption(value) {
|
|
149
154
|
const slug = value
|
|
150
155
|
.toLowerCase()
|
|
151
156
|
.replace(/[^a-z0-9]+/g, "_")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SequenceSendWindow } from "./sequences.js";
|
|
2
|
+
/**
|
|
3
|
+
* The one sending schedule Launch shows (web editor, CLI `--schedule-*`, MCP
|
|
4
|
+
* `sending_schedule`). Email and social dispatch keep separate stored windows,
|
|
5
|
+
* `settings.email_send_window` and `settings.schedule`, so one schedule is
|
|
6
|
+
* written to both.
|
|
7
|
+
*
|
|
8
|
+
* A leaf of its own, importing only types: the Launch editor's day and time
|
|
9
|
+
* pickers are also rendered on Connections, and a value import of the whole
|
|
10
|
+
* sequences module added 13.8 KB gzip to that page (measured 2026-09-29).
|
|
11
|
+
*/
|
|
12
|
+
export type SendingSchedule = {
|
|
13
|
+
timezone: string;
|
|
14
|
+
days: number[];
|
|
15
|
+
start: string;
|
|
16
|
+
end: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The settings fragment that applies one schedule to every channel. The email
|
|
20
|
+
* window keeps its recipient-timezone fields; the social window takes the same
|
|
21
|
+
* zone, days and hours.
|
|
22
|
+
*/
|
|
23
|
+
export declare function sendingScheduleSettings(emailWindow: SequenceSendWindow | null | undefined, schedule: SendingSchedule): {
|
|
24
|
+
email_send_window: SequenceSendWindow;
|
|
25
|
+
schedule: SendingSchedule;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Stable comparison key for a window: zone, sorted ISO days and hours. Days are
|
|
29
|
+
* read the way dispatch reads them (sendWindowAllowsWeekday in ./sequences):
|
|
30
|
+
* day names count, and a missing, malformed or unrecognisable list means every
|
|
31
|
+
* day.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sendingScheduleKey(window: Pick<SequenceSendWindow, "timezone" | "days" | "start" | "end">): string;
|
|
34
|
+
/**
|
|
35
|
+
* Whether a mixed journey's two stored windows disagree: the social window is
|
|
36
|
+
* unset, or its zone, days or hours differ from the effective email window.
|
|
37
|
+
* Launch marks this state and the launch check warns about it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sendingWindowsDiverge(effectiveEmailWindow: SequenceSendWindow, socialWindow: Pick<SequenceSendWindow, "timezone" | "days" | "start" | "end"> | null | undefined): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings fragment that applies one schedule to every channel. The email
|
|
3
|
+
* window keeps its recipient-timezone fields; the social window takes the same
|
|
4
|
+
* zone, days and hours.
|
|
5
|
+
*/
|
|
6
|
+
export function sendingScheduleSettings(emailWindow, schedule) {
|
|
7
|
+
return { email_send_window: { ...emailWindow, ...schedule }, schedule: { ...schedule } };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Stable comparison key for a window: zone, sorted ISO days and hours. Days are
|
|
11
|
+
* read the way dispatch reads them (sendWindowAllowsWeekday in ./sequences):
|
|
12
|
+
* day names count, and a missing, malformed or unrecognisable list means every
|
|
13
|
+
* day.
|
|
14
|
+
*/
|
|
15
|
+
export function sendingScheduleKey(window) {
|
|
16
|
+
const listed = Array.isArray(window.days) ? window.days : [];
|
|
17
|
+
const days = [...new Set(listed.map(isoWeekdayOf).filter((day) => day !== null))].sort((a, b) => a - b);
|
|
18
|
+
return JSON.stringify({
|
|
19
|
+
timezone: window.timezone,
|
|
20
|
+
days: days.length > 0 ? days : [1, 2, 3, 4, 5, 6, 7],
|
|
21
|
+
start: window.start,
|
|
22
|
+
end: window.end,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether a mixed journey's two stored windows disagree: the social window is
|
|
27
|
+
* unset, or its zone, days or hours differ from the effective email window.
|
|
28
|
+
* Launch marks this state and the launch check warns about it.
|
|
29
|
+
*/
|
|
30
|
+
export function sendingWindowsDiverge(effectiveEmailWindow, socialWindow) {
|
|
31
|
+
return !socialWindow || sendingScheduleKey(socialWindow) !== sendingScheduleKey(effectiveEmailWindow);
|
|
32
|
+
}
|
|
33
|
+
const WEEKDAY_BY_NAME = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
|
34
|
+
function isoWeekdayOf(entry) {
|
|
35
|
+
const day = typeof entry === "number"
|
|
36
|
+
? entry
|
|
37
|
+
: typeof entry === "string"
|
|
38
|
+
? WEEKDAY_BY_NAME[`${entry.trim().slice(0, 1).toUpperCase()}${entry.trim().slice(1, 3).toLowerCase()}`] ?? Number(entry)
|
|
39
|
+
: Number.NaN;
|
|
40
|
+
return Number.isInteger(day) && day >= 1 && day <= 7 ? day : null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Every lifecycle a person reads: the stored statuses plus the derived `completed`. */
|
|
2
|
+
export declare const SEQUENCE_LIFECYCLES: readonly ["draft", "active", "completed", "paused", "archived"];
|
|
3
|
+
export type SequenceLifecycle = (typeof SEQUENCE_LIFECYCLES)[number];
|
|
4
|
+
/**
|
|
5
|
+
* The lifecycle word a person reads for a Sequence.
|
|
6
|
+
*
|
|
7
|
+
* The stored status records launch intent only (draft, active, paused,
|
|
8
|
+
* archived), and nothing moves it when the last lead finishes, so a campaign
|
|
9
|
+
* that contacted everyone kept reading "Active" forever. Whether work remains
|
|
10
|
+
* is the derived operational state; `drained` means every enrolled lead has
|
|
11
|
+
* reached an end. An active Sequence in that state reads as completed.
|
|
12
|
+
*
|
|
13
|
+
* Derived on read and never stored: operational health is one derived
|
|
14
|
+
* contract, not another lifecycle (docs/primitives/sequences.md, 2026-08-26).
|
|
15
|
+
* Enrolling new leads makes the same Sequence read as active again with no
|
|
16
|
+
* transition to undo. A paused Sequence stays paused, because someone decided
|
|
17
|
+
* that and the decision still holds.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sequenceDisplayStatus<Status extends string>(status: Status, operationalState: string | null | undefined): Status | "completed";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Every lifecycle a person reads: the stored statuses plus the derived `completed`. */
|
|
2
|
+
export const SEQUENCE_LIFECYCLES = ["draft", "active", "completed", "paused", "archived"];
|
|
3
|
+
/**
|
|
4
|
+
* The lifecycle word a person reads for a Sequence.
|
|
5
|
+
*
|
|
6
|
+
* The stored status records launch intent only (draft, active, paused,
|
|
7
|
+
* archived), and nothing moves it when the last lead finishes, so a campaign
|
|
8
|
+
* that contacted everyone kept reading "Active" forever. Whether work remains
|
|
9
|
+
* is the derived operational state; `drained` means every enrolled lead has
|
|
10
|
+
* reached an end. An active Sequence in that state reads as completed.
|
|
11
|
+
*
|
|
12
|
+
* Derived on read and never stored: operational health is one derived
|
|
13
|
+
* contract, not another lifecycle (docs/primitives/sequences.md, 2026-08-26).
|
|
14
|
+
* Enrolling new leads makes the same Sequence read as active again with no
|
|
15
|
+
* transition to undo. A paused Sequence stays paused, because someone decided
|
|
16
|
+
* that and the decision still holds.
|
|
17
|
+
*/
|
|
18
|
+
export function sequenceDisplayStatus(status, operationalState) {
|
|
19
|
+
return status === "active" && operationalState === "drained" ? "completed" : status;
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reserved `{{sender_*}}` template variables. Every Sequence step can use them
|
|
3
|
+
* without a lead-table column: they resolve per action from whoever actually
|
|
4
|
+
* sends it, the person behind the sending inbox or LinkedIn/WhatsApp account.
|
|
5
|
+
* A lead column with the same key still wins, because row values merge last.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SEQUENCE_SENDER_NAME_VARIABLES: readonly ["sender_first_name", "sender_last_name", "sender_full_name", "sender_name"];
|
|
8
|
+
/** Email steps only: the sending inbox's address. */
|
|
9
|
+
export declare const SEQUENCE_SENDER_EMAIL_VARIABLE = "sender_email";
|
|
10
|
+
export declare const SEQUENCE_SENDER_VARIABLES: readonly string[];
|
|
11
|
+
/** Editor labels, in menu order. `sender_name` stays insertable-by-hand only. */
|
|
12
|
+
export declare const SEQUENCE_SENDER_VARIABLE_LABELS: ReadonlyArray<{
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
emailOnly?: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Who sends an action: the sender profile's own first and last name when it has
|
|
19
|
+
* them, else a display name (a profile's label, an account's or an inbox's).
|
|
20
|
+
*/
|
|
21
|
+
export type SequenceSenderIdentity = {
|
|
22
|
+
name?: string | null;
|
|
23
|
+
firstName?: string | null;
|
|
24
|
+
lastName?: string | null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The reserved variables for one sender. A profile's stored first and last name
|
|
28
|
+
* win, because its display name may be a label such as "Talia (EU)". Without
|
|
29
|
+
* them the display name is split at its first space, so "Anna von Berg" gives
|
|
30
|
+
* "Anna" and "von Berg". A missing name renders every variable empty, never the
|
|
31
|
+
* literal token. Passing `email` adds `{{sender_email}}`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sequenceSenderVariables(sender: SequenceSenderIdentity | string | null | undefined, email?: string | null): Record<string, string>;
|
|
34
|
+
/** Whether any of these templates refers to a reserved sender variable. */
|
|
35
|
+
export declare function referencesSequenceSenderVariables(templates: ReadonlyArray<unknown>): boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reserved `{{sender_*}}` template variables. Every Sequence step can use them
|
|
3
|
+
* without a lead-table column: they resolve per action from whoever actually
|
|
4
|
+
* sends it, the person behind the sending inbox or LinkedIn/WhatsApp account.
|
|
5
|
+
* A lead column with the same key still wins, because row values merge last.
|
|
6
|
+
*/
|
|
7
|
+
export const SEQUENCE_SENDER_NAME_VARIABLES = [
|
|
8
|
+
"sender_first_name",
|
|
9
|
+
"sender_last_name",
|
|
10
|
+
"sender_full_name",
|
|
11
|
+
"sender_name",
|
|
12
|
+
];
|
|
13
|
+
/** Email steps only: the sending inbox's address. */
|
|
14
|
+
export const SEQUENCE_SENDER_EMAIL_VARIABLE = "sender_email";
|
|
15
|
+
export const SEQUENCE_SENDER_VARIABLES = [
|
|
16
|
+
...SEQUENCE_SENDER_NAME_VARIABLES,
|
|
17
|
+
SEQUENCE_SENDER_EMAIL_VARIABLE,
|
|
18
|
+
];
|
|
19
|
+
/** Editor labels, in menu order. `sender_name` stays insertable-by-hand only. */
|
|
20
|
+
export const SEQUENCE_SENDER_VARIABLE_LABELS = [
|
|
21
|
+
{ key: "sender_first_name", label: "Sender first name" },
|
|
22
|
+
{ key: "sender_last_name", label: "Sender last name" },
|
|
23
|
+
{ key: "sender_full_name", label: "Sender full name" },
|
|
24
|
+
{ key: SEQUENCE_SENDER_EMAIL_VARIABLE, label: "Sender email", emailOnly: true },
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* The reserved variables for one sender. A profile's stored first and last name
|
|
28
|
+
* win, because its display name may be a label such as "Talia (EU)". Without
|
|
29
|
+
* them the display name is split at its first space, so "Anna von Berg" gives
|
|
30
|
+
* "Anna" and "von Berg". A missing name renders every variable empty, never the
|
|
31
|
+
* literal token. Passing `email` adds `{{sender_email}}`.
|
|
32
|
+
*/
|
|
33
|
+
export function sequenceSenderVariables(sender, email) {
|
|
34
|
+
const identity = typeof sender === "string" ? { name: sender } : (sender ?? {});
|
|
35
|
+
const clean = (value) => (value ?? "").trim().replace(/\s+/g, " ");
|
|
36
|
+
const firstName = clean(identity.firstName);
|
|
37
|
+
const lastName = clean(identity.lastName);
|
|
38
|
+
let first;
|
|
39
|
+
let last;
|
|
40
|
+
let full;
|
|
41
|
+
if (firstName || lastName) {
|
|
42
|
+
first = firstName;
|
|
43
|
+
last = lastName;
|
|
44
|
+
full = [firstName, lastName].filter(Boolean).join(" ");
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
full = clean(identity.name);
|
|
48
|
+
const [head = "", ...rest] = full ? full.split(" ") : [];
|
|
49
|
+
first = head;
|
|
50
|
+
last = rest.join(" ");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
sender_first_name: first,
|
|
54
|
+
sender_last_name: last,
|
|
55
|
+
sender_full_name: full,
|
|
56
|
+
sender_name: full,
|
|
57
|
+
...(email !== undefined ? { sender_email: email ?? "" } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Whether any of these templates refers to a reserved sender variable. */
|
|
61
|
+
export function referencesSequenceSenderVariables(templates) {
|
|
62
|
+
return templates.some((text) => typeof text === "string" && /\{\{\s*sender_/.test(text));
|
|
63
|
+
}
|
|
@@ -933,16 +933,22 @@ export type SequenceLintIssue = {
|
|
|
933
933
|
message: string;
|
|
934
934
|
};
|
|
935
935
|
/**
|
|
936
|
-
* A non-blocking fact about
|
|
937
|
-
*
|
|
938
|
-
* `email_reply_rewritten
|
|
939
|
-
*
|
|
940
|
-
* back sees a different `kind` than it wrote with nothing in the
|
|
941
|
-
* explain it — reported as a silent coercion by a customer
|
|
936
|
+
* A non-blocking fact about a saved definition that its author must be told, as
|
|
937
|
+
* opposed to an issue that rejects it:
|
|
938
|
+
* - `email_reply_rewritten`: the retired kind is accepted and rewritten into a
|
|
939
|
+
* subject-less `email_send`, and a caller that only reads the stored
|
|
940
|
+
* definition back sees a different `kind` than it wrote with nothing in the
|
|
941
|
+
* response to explain it — reported as a silent coercion by a customer
|
|
942
|
+
* (Plain T-163).
|
|
943
|
+
* - `sequence_linkedin_owner_conflict`: a draft saves LinkedIn relationship
|
|
944
|
+
* steps that name different people, which only the launch refuses (same
|
|
945
|
+
* code as its launch blocker and the preview's `blocked` marker). A blind MCP run
|
|
946
|
+
* pinned the founder on the connection request alone and learned why the
|
|
947
|
+
* draft could not launch only from the start preview (2026-09-29).
|
|
942
948
|
*/
|
|
943
949
|
export type SequenceDefinitionNotice = {
|
|
944
950
|
path: string;
|
|
945
|
-
code: "email_reply_rewritten";
|
|
951
|
+
code: "email_reply_rewritten" | "sequence_linkedin_owner_conflict";
|
|
946
952
|
message: string;
|
|
947
953
|
};
|
|
948
954
|
export type ValidateSequenceOptions = {
|
|
@@ -1110,6 +1110,25 @@ export function sequenceLinkedInOwnerConflicts(steps) {
|
|
|
1110
1110
|
message: `step '${step.id}': every LinkedIn connection, message and InMail step must come from the same person (or all from the lead's own). LinkedIn lets one of your accounts hold a conversation with a lead.`,
|
|
1111
1111
|
}));
|
|
1112
1112
|
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The draft-save counterpart of the launch's `sequence_linkedin_owner_conflict`:
|
|
1115
|
+
* one notice naming what each relationship step sends from, and both ways out.
|
|
1116
|
+
*/
|
|
1117
|
+
function sequenceLinkedInOwnerNotice(steps) {
|
|
1118
|
+
const conflicts = sequenceLinkedInOwnerConflicts(steps);
|
|
1119
|
+
const first = conflicts[0];
|
|
1120
|
+
if (!first)
|
|
1121
|
+
return null;
|
|
1122
|
+
const named = conflicts.map(({ index, stepId }) => {
|
|
1123
|
+
const person = steps[index] ? stepSenderProfileId(steps[index]) : undefined;
|
|
1124
|
+
return person ? `'${stepId}' names ${person}` : `'${stepId}' names no one`;
|
|
1125
|
+
});
|
|
1126
|
+
return {
|
|
1127
|
+
path: `steps[${first.index}].sender_profile_id`,
|
|
1128
|
+
code: "sequence_linkedin_owner_conflict",
|
|
1129
|
+
message: `Saved as a draft, but it cannot launch yet: LinkedIn connection, message, InMail and withdraw steps must all name the same sender_profile_id, or none (${named.join(", ")}). LinkedIn lets one of your accounts hold a conversation with a lead. To have one person send all of them, name that person on each; to keep every lead's own person, remove it from all.`,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1113
1132
|
export function sequenceStepsMissingCopy(steps) {
|
|
1114
1133
|
const missing = [];
|
|
1115
1134
|
const check = (step, field, value) => {
|
|
@@ -1259,6 +1278,13 @@ export function validateSequenceDefinition(input, options = {}) {
|
|
|
1259
1278
|
issues.push({ path: `steps[${conflict.index}].sender_profile_id`, message: conflict.message });
|
|
1260
1279
|
}
|
|
1261
1280
|
}
|
|
1281
|
+
else {
|
|
1282
|
+
// A draft may be half-built, so a split LinkedIn owner still saves; tell the
|
|
1283
|
+
// author now instead of leaving it for the launch preview to reveal.
|
|
1284
|
+
const ownerNotice = sequenceLinkedInOwnerNotice(normalized);
|
|
1285
|
+
if (ownerNotice)
|
|
1286
|
+
options.notices?.push(ownerNotice);
|
|
1287
|
+
}
|
|
1262
1288
|
if (issues.length > 0) {
|
|
1263
1289
|
throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
1264
1290
|
}
|
|
@@ -16,6 +16,8 @@ export type SignupLeadWebhookInput = {
|
|
|
16
16
|
firstName?: string | null;
|
|
17
17
|
lastName?: string | null;
|
|
18
18
|
phone?: string | null;
|
|
19
|
+
/** Whether the provider verified `phone`; sign-up collects numbers without verifying them. */
|
|
20
|
+
phoneVerified?: boolean | null;
|
|
19
21
|
createdAt?: Date | string | number | null | undefined;
|
|
20
22
|
/** A profile URL the provider already knows for this person; the consent-recorded creator URL still wins at enqueue. */
|
|
21
23
|
linkedinUrl?: string | null;
|
|
@@ -34,6 +34,9 @@ export function buildSignupLeadWebhookPayload(input) {
|
|
|
34
34
|
const firstName = normalizeString(input.user?.firstName);
|
|
35
35
|
const lastName = normalizeString(input.user?.lastName);
|
|
36
36
|
const phone = normalizeString(input.user?.phone);
|
|
37
|
+
const phoneVerified = phone && typeof input.user?.phoneVerified === "boolean"
|
|
38
|
+
? input.user.phoneVerified
|
|
39
|
+
: null;
|
|
37
40
|
const fullName = normalizeString([firstName, lastName].filter(Boolean).join(" "));
|
|
38
41
|
const clerkOrgId = normalizeString(input.organization?.clerkOrgId);
|
|
39
42
|
const companyName = normalizeString(input.organization?.name);
|
|
@@ -53,6 +56,7 @@ export function buildSignupLeadWebhookPayload(input) {
|
|
|
53
56
|
first_name: firstName,
|
|
54
57
|
last_name: lastName,
|
|
55
58
|
phone,
|
|
59
|
+
phone_verified: phoneVerified,
|
|
56
60
|
full_name: fullName,
|
|
57
61
|
created_at: normalizeDate(input.user?.createdAt),
|
|
58
62
|
linkedin_url: normalizeString(input.user?.linkedinUrl) ?? undefined,
|
|
@@ -121,6 +125,7 @@ export function buildSignupLeadWebhookPayload(input) {
|
|
|
121
125
|
first_name: firstName,
|
|
122
126
|
last_name: lastName,
|
|
123
127
|
phone,
|
|
128
|
+
phone_verified: phoneVerified,
|
|
124
129
|
full_name: fullName,
|
|
125
130
|
company_name: companyName,
|
|
126
131
|
company_domain: companyDomain,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { zoneOffsetMs } from "./zoned-time.js";
|
|
1
2
|
export const EARNED_SERIES_MAX_PRORATE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
2
3
|
function pointsFor(timeline, metric) {
|
|
3
4
|
const points = [];
|
|
@@ -179,21 +180,6 @@ export function zonedDayStarts(fromDate, toDate, timeZone) {
|
|
|
179
180
|
}
|
|
180
181
|
return { days, end: startOfZonedDay(cursor.toISOString().slice(0, 10), timeZone) };
|
|
181
182
|
}
|
|
182
|
-
function zoneOffsetMs(instant, timeZone) {
|
|
183
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
184
|
-
timeZone,
|
|
185
|
-
hourCycle: "h23",
|
|
186
|
-
year: "numeric",
|
|
187
|
-
month: "2-digit",
|
|
188
|
-
day: "2-digit",
|
|
189
|
-
hour: "2-digit",
|
|
190
|
-
minute: "2-digit",
|
|
191
|
-
second: "2-digit",
|
|
192
|
-
}).formatToParts(instant);
|
|
193
|
-
const read = (type) => Number(parts.find((part) => part.type === type)?.value ?? 0);
|
|
194
|
-
const asUtc = Date.UTC(read("year"), read("month") - 1, read("day"), read("hour"), read("minute"), read("second"));
|
|
195
|
-
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
|
|
196
|
-
}
|
|
197
183
|
function startOfZonedDay(date, timeZone) {
|
|
198
184
|
const utcMidnight = new Date(`${date}T00:00:00.000Z`).getTime();
|
|
199
185
|
const firstGuess = utcMidnight - zoneOffsetMs(new Date(utcMidnight), timeZone);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const STANDARD_WEBHOOK_SECRET_PREFIX = "whsec_";
|
|
2
|
+
/** MCP Events accepts only `whsec_` secrets whose base64 value decodes to 24–64 bytes. */
|
|
3
|
+
export declare const STANDARD_WEBHOOK_SECRET_MIN_BYTES = 24;
|
|
4
|
+
export declare const STANDARD_WEBHOOK_SECRET_MAX_BYTES = 64;
|
|
5
|
+
/** Default `webhook-timestamp` window, in either direction, as in the reference libraries. */
|
|
6
|
+
export declare const STANDARD_WEBHOOK_TOLERANCE_SECONDS: number;
|
|
7
|
+
export type StandardWebhookErrorReason = "secret_missing" | "secret_prefix" | "secret_encoding" | "secret_length" | "invalid_id" | "invalid_timestamp";
|
|
8
|
+
/** A secret or signing input that cannot produce a valid signature. Never carries the secret. */
|
|
9
|
+
export declare class StandardWebhookError extends Error {
|
|
10
|
+
readonly reason: StandardWebhookErrorReason;
|
|
11
|
+
constructor(reason: StandardWebhookErrorReason, message: string);
|
|
12
|
+
}
|
|
13
|
+
/** One `whsec_` secret, or several during a rotation window (every key signs). */
|
|
14
|
+
export type StandardWebhookSecrets = string | readonly string[];
|
|
15
|
+
/** A `Date`, or whole Unix seconds. */
|
|
16
|
+
export type StandardWebhookTime = Date | number;
|
|
17
|
+
export type StandardWebhookSignInput = {
|
|
18
|
+
secret: StandardWebhookSecrets;
|
|
19
|
+
id: string;
|
|
20
|
+
timestamp: StandardWebhookTime;
|
|
21
|
+
/** The exact request body. Send these same characters; re-serialising breaks the signature. */
|
|
22
|
+
body: string;
|
|
23
|
+
};
|
|
24
|
+
export type StandardWebhookHeaders = {
|
|
25
|
+
"webhook-id": string;
|
|
26
|
+
"webhook-timestamp": string;
|
|
27
|
+
"webhook-signature": string;
|
|
28
|
+
};
|
|
29
|
+
/** Decode a `whsec_` secret to its HMAC key, enforcing the 24–64 byte range. */
|
|
30
|
+
export declare function parseStandardWebhookSecret(secret: string): Buffer;
|
|
31
|
+
/** The `webhook-signature` value: `v1,<base64>`, space-separated when several secrets sign. */
|
|
32
|
+
export declare function signStandardWebhook(input: StandardWebhookSignInput): string;
|
|
33
|
+
/** The three Standard Webhooks headers for one delivery attempt. */
|
|
34
|
+
export declare function standardWebhookHeaders(input: StandardWebhookSignInput): StandardWebhookHeaders;
|
|
35
|
+
/**
|
|
36
|
+
* True when any `v1` entry of `signature` matches any of the secrets and the
|
|
37
|
+
* timestamp is within `toleranceSeconds` of `now`. A malformed id, timestamp or
|
|
38
|
+
* signature is `false`; an invalid secret, `now` or `toleranceSeconds` throws,
|
|
39
|
+
* because that is a misconfiguration rather than a bad request.
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyStandardWebhook(input: {
|
|
42
|
+
secret: StandardWebhookSecrets;
|
|
43
|
+
id: string;
|
|
44
|
+
/** The `webhook-timestamp` header as received, or a parsed time. */
|
|
45
|
+
timestamp: StandardWebhookTime | string;
|
|
46
|
+
body: string;
|
|
47
|
+
/** The `webhook-signature` header as received. */
|
|
48
|
+
signature: string;
|
|
49
|
+
toleranceSeconds?: number;
|
|
50
|
+
now?: StandardWebhookTime;
|
|
51
|
+
}): boolean;
|