@oxygen-agent/cli 1.152.15 → 1.162.10

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.
@@ -0,0 +1,21 @@
1
+ export type TranscriptSource = "claude_code" | "codex" | "cursor" | "file";
2
+ export type CapturedTranscript = {
3
+ source: TranscriptSource;
4
+ session_id: string | null;
5
+ tool: string;
6
+ event_count: number;
7
+ byte_size: number;
8
+ format: "jsonl.gz.b64";
9
+ content_b64: string;
10
+ truncated: boolean;
11
+ path: string;
12
+ };
13
+ export declare function redactTranscriptSecrets(text: string): string;
14
+ export type CaptureOptions = {
15
+ sessionId?: string | null;
16
+ file?: string | null;
17
+ };
18
+ export declare class TranscriptCaptureError extends Error {
19
+ }
20
+ export declare function captureCurrentTranscript(options?: CaptureOptions): CapturedTranscript | null;
21
+ export declare function collectFeedbackEnvironment(): Record<string, string>;
@@ -0,0 +1,208 @@
1
+ import { gzipSync } from "node:zlib";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { OXYGEN_VERSION } from "@oxygen/shared";
6
+ // Keep at most the most-recent ~2MB of JSONL. gzip of conversational JSON lands
7
+ // ~5–10x smaller, so the base64 payload stays well under Vercel's ~4.5MB body
8
+ // limit without needing chunked upload. Past the cap we drop the oldest events.
9
+ const MAX_RAW_BYTES = 2_000_000;
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ function transcriptRoots() {
12
+ const home = homedir();
13
+ return [
14
+ { dir: join(process.env.CLAUDE_HOME ?? join(home, ".claude"), "projects"), source: "claude_code", tool: "claude-code" },
15
+ { dir: join(process.env.CODEX_HOME ?? join(home, ".codex"), "sessions"), source: "codex", tool: "codex" },
16
+ { dir: join(process.env.CURSOR_HOME ?? join(home, ".cursor"), "projects"), source: "cursor", tool: "cursor" },
17
+ ];
18
+ }
19
+ // Bounded recursive walk for *.jsonl files. Depth-limited so a pathological tree
20
+ // can't hang the CLI; symlink/permission errors are swallowed per-entry.
21
+ function collectJsonl(dir, depth, out) {
22
+ if (depth < 0)
23
+ return;
24
+ let names = [];
25
+ try {
26
+ names = readdirSync(dir);
27
+ }
28
+ catch {
29
+ return;
30
+ }
31
+ for (const name of names) {
32
+ const full = join(dir, name);
33
+ let isDir = false;
34
+ let isFile = false;
35
+ try {
36
+ const stat = statSync(full);
37
+ isDir = stat.isDirectory();
38
+ isFile = stat.isFile();
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ if (isDir) {
44
+ collectJsonl(full, depth - 1, out);
45
+ }
46
+ else if (isFile && name.endsWith(".jsonl")) {
47
+ out.push(full);
48
+ }
49
+ }
50
+ }
51
+ function newestTranscriptFile() {
52
+ let newestPath = null;
53
+ let newestMtime = -1;
54
+ let newest = null;
55
+ for (const root of transcriptRoots()) {
56
+ if (!existsSync(root.dir))
57
+ continue;
58
+ const files = [];
59
+ collectJsonl(root.dir, 6, files);
60
+ for (const file of files) {
61
+ try {
62
+ const mtime = statSync(file).mtimeMs;
63
+ if (mtime > newestMtime) {
64
+ newestMtime = mtime;
65
+ newestPath = file;
66
+ newest = root;
67
+ }
68
+ }
69
+ catch {
70
+ // ignore
71
+ }
72
+ }
73
+ }
74
+ if (!newestPath || !newest)
75
+ return null;
76
+ return { path: newestPath, source: newest.source, tool: newest.tool };
77
+ }
78
+ function findBySessionId(sessionId) {
79
+ for (const root of transcriptRoots()) {
80
+ if (!existsSync(root.dir))
81
+ continue;
82
+ const files = [];
83
+ collectJsonl(root.dir, 6, files);
84
+ // Case-insensitive: UUID session ids can be passed in any casing.
85
+ const needle = `${sessionId.toLowerCase()}.jsonl`;
86
+ const match = files.find((file) => file.toLowerCase().endsWith(needle));
87
+ if (match)
88
+ return { path: match, source: root.source, tool: root.tool };
89
+ }
90
+ return null;
91
+ }
92
+ function sessionIdFromPath(path) {
93
+ const base = path.split(/[\\/]/).pop() ?? "";
94
+ const stem = base.endsWith(".jsonl") ? base.slice(0, -".jsonl".length) : base;
95
+ return UUID_RE.test(stem) ? stem : null;
96
+ }
97
+ // Trim to the last `MAX_RAW_BYTES`, snapping to a newline so the first kept line
98
+ // is whole. Returns the kept buffer and whether anything was dropped.
99
+ function capToRecent(raw) {
100
+ if (raw.length <= MAX_RAW_BYTES)
101
+ return { kept: raw, truncated: false };
102
+ let start = raw.length - MAX_RAW_BYTES;
103
+ const nl = raw.indexOf(0x0a, start); // '\n'
104
+ if (nl !== -1 && nl + 1 < raw.length)
105
+ start = nl + 1;
106
+ return { kept: raw.subarray(start), truncated: true };
107
+ }
108
+ function countEvents(buf) {
109
+ const text = buf.toString("utf8");
110
+ let count = 0;
111
+ for (const line of text.split("\n")) {
112
+ if (line.trim().length > 0)
113
+ count += 1;
114
+ }
115
+ return count;
116
+ }
117
+ // Redact obvious credentials from transcript text before it leaves the machine.
118
+ // Agent JSONL can embed API keys, auth headers, and tokens captured from tool
119
+ // calls / shell output; scrub the common shapes so `oxygen feedback` never ships
120
+ // raw secrets to the support API. Best-effort (not a guarantee) and deliberately
121
+ // conservative — it targets credential shapes, not general prose, so the
122
+ // transcript stays useful for debugging.
123
+ const SECRET_PATTERNS = [
124
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]"],
125
+ [/\bsk-ant-[A-Za-z0-9_-]{12,}/g, "sk-ant-[REDACTED]"],
126
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, "sk-[REDACTED]"],
127
+ [/\bAKIA[0-9A-Z]{16}\b/g, "AKIA[REDACTED]"],
128
+ [/\bAIza[0-9A-Za-z_-]{20,}/g, "AIza[REDACTED]"],
129
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "ghx_[REDACTED]"],
130
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox-[REDACTED]"],
131
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, "[REDACTED_JWT]"],
132
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi, "$1 [REDACTED]"],
133
+ // "secret-ish key": "value" / secret_ish_key=value (JSON + env shapes)
134
+ [/("?(?:api[_-]?key|apikey|secret|password|passwd|token|access[_-]?token|refresh[_-]?token|client[_-]?secret|authorization|auth[_-]?token|private[_-]?key)"?\s*[:=]\s*"?)([^"\s,}]{6,})/gi, "$1[REDACTED]"],
135
+ ];
136
+ export function redactTranscriptSecrets(text) {
137
+ let out = text;
138
+ for (const [pattern, replacement] of SECRET_PATTERNS)
139
+ out = out.replace(pattern, replacement);
140
+ return out;
141
+ }
142
+ export class TranscriptCaptureError extends Error {
143
+ }
144
+ // Resolve and package the transcript. Returns null only when no transcript could
145
+ // be found for the auto-detect path (so feedback can still send a note-only
146
+ // report); an explicit --session-id / --file that doesn't resolve throws.
147
+ export function captureCurrentTranscript(options = {}) {
148
+ let resolved = null;
149
+ if (options.file) {
150
+ if (!existsSync(options.file)) {
151
+ throw new TranscriptCaptureError(`Transcript file not found: ${options.file}`);
152
+ }
153
+ resolved = { path: options.file, source: "file", tool: "file" };
154
+ }
155
+ else if (options.sessionId) {
156
+ resolved = findBySessionId(options.sessionId);
157
+ if (!resolved) {
158
+ throw new TranscriptCaptureError(`No local transcript found for session ${options.sessionId}.`);
159
+ }
160
+ }
161
+ else {
162
+ resolved = newestTranscriptFile();
163
+ if (!resolved)
164
+ return null;
165
+ }
166
+ let raw;
167
+ try {
168
+ raw = readFileSync(resolved.path);
169
+ }
170
+ catch (error) {
171
+ throw new TranscriptCaptureError(`Could not read transcript ${resolved.path}: ${error instanceof Error ? error.message : String(error)}`);
172
+ }
173
+ const { kept, truncated } = capToRecent(raw);
174
+ // Scrub credentials from the raw bytes before gzip+base64 — the encoded blob is
175
+ // what leaves the machine, so redaction must happen here, not server-side.
176
+ const redacted = Buffer.from(redactTranscriptSecrets(kept.toString("utf8")), "utf8");
177
+ const content_b64 = gzipSync(redacted).toString("base64");
178
+ return {
179
+ source: resolved.source,
180
+ session_id: sessionIdFromPath(resolved.path),
181
+ tool: resolved.tool,
182
+ event_count: countEvents(redacted),
183
+ byte_size: redacted.length,
184
+ format: "jsonl.gz.b64",
185
+ content_b64,
186
+ truncated,
187
+ path: resolved.path,
188
+ };
189
+ }
190
+ // Lightweight, non-sensitive environment snapshot attached to feedback context so
191
+ // staff can reproduce. Deliberately excludes env vars / secrets.
192
+ export function collectFeedbackEnvironment() {
193
+ const env = {
194
+ os: `${process.platform} ${process.arch}`,
195
+ node: process.version,
196
+ cli_version: OXYGEN_VERSION,
197
+ cwd: process.cwd(),
198
+ };
199
+ const shell = (process.env.SHELL ?? "").trim();
200
+ if (shell)
201
+ env.shell = shell;
202
+ const term = (process.env.TERM_PROGRAM ?? "").trim();
203
+ if (term) {
204
+ const ver = (process.env.TERM_PROGRAM_VERSION ?? "").trim();
205
+ env.terminal = ver ? `${term} ${ver}` : term;
206
+ }
207
+ return env;
208
+ }
@@ -5,9 +5,11 @@ export * from "./cell-format.js";
5
5
  export * from "./column-types.js";
6
6
  export * from "./credit-guidance.js";
7
7
  export * from "./linkedin-sequences.js";
8
+ export * from "./sequences.js";
8
9
  export * from "./log.js";
9
10
  export * from "./provider-request-outcomes.js";
10
11
  export * from "./signup-lead-deliveries.js";
12
+ export * from "./sql-error.js";
11
13
  export * from "./telemetry.js";
12
14
  export declare const MAX_ROW_LOOP_WRITE_ROWS = 500;
13
15
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
@@ -6,9 +6,11 @@ export * from "./cell-format.js";
6
6
  export * from "./column-types.js";
7
7
  export * from "./credit-guidance.js";
8
8
  export * from "./linkedin-sequences.js";
9
+ export * from "./sequences.js";
9
10
  export * from "./log.js";
10
11
  export * from "./provider-request-outcomes.js";
11
12
  export * from "./signup-lead-deliveries.js";
13
+ export * from "./sql-error.js";
12
14
  export * from "./telemetry.js";
13
15
  // Maximum rows a single row-loop write (insert/upsert/preview) may process. The
14
16
  // row-loop engine issues one DB round-trip per row, so a 500-row write already
@@ -1,8 +1,10 @@
1
1
  /**
2
- * LinkedIn sequence step schema the shared contract validated identically by
3
- * CLI, MCP, API, and web. A sequence is an ordered list of steps applied to
4
- * each enrolled lead; the worker dispatch engine materializes one
5
- * sequence_action per step as it comes due.
2
+ * LinkedIn-channel step types + helpers for the sequencer. These typed shapes
3
+ * describe the LinkedIn steps of a sequence and are consumed by the dispatch
4
+ * engine and the web flow editor/canvas. The canonical multichannel validator
5
+ * lives in `sequences.ts` (validateSequenceDefinition) this module is types +
6
+ * the two pure helpers the dispatcher uses (renderLinkedInTemplate, the
7
+ * action-kind map, waitStepDelayMs). It carries no validator of its own.
6
8
  *
7
9
  * Step kinds:
8
10
  * - visit_profile — view the lead's profile (warms up before an invite)
@@ -11,67 +13,90 @@
11
13
  * webhook) or timeout_days elapses; on timeout stop or
12
14
  * continue per on_timeout
13
15
  * - wait — fixed delay (days/hours) before the next step
14
- * - message — send a message (opens a chat if none exists);
15
- * template supports {{column}} interpolation
16
- * - inmail — send an InMail (works on non-connections); subject +
17
- * template
16
+ * - message — send a message; template supports {{column}} interp
17
+ * - inmail — send an InMail (works on non-connections)
18
18
  */
19
- export declare const LINKEDIN_SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "wait", "message", "inmail"];
19
+ export declare const LINKEDIN_SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "wait", "message", "inmail", "branch", "stop"];
20
20
  export type LinkedInSequenceStepKind = typeof LINKEDIN_SEQUENCE_STEP_KINDS[number];
21
- export type LinkedInVisitProfileStep = {
21
+ /** Conditions a `branch` step routes on. Both are LinkedIn-signal driven. */
22
+ export declare const LINKEDIN_BRANCH_CONDITIONS: readonly ["connection_accepted", "already_connected"];
23
+ export type LinkedInBranchCondition = typeof LINKEDIN_BRANCH_CONDITIONS[number];
24
+ /** Every step carries a stable id so branch edges can target it by reference. */
25
+ export type LinkedInStepBase = {
26
+ id: string;
27
+ };
28
+ export type LinkedInVisitProfileStep = LinkedInStepBase & {
22
29
  kind: "visit_profile";
23
30
  };
24
- export type LinkedInInviteStep = {
31
+ export type LinkedInInviteStep = LinkedInStepBase & {
25
32
  kind: "invite";
26
33
  /** Optional connection-request note. Supports {{column}} interpolation. ~300 char max on LinkedIn. */
27
34
  note_template?: string;
28
35
  };
29
- export type LinkedInWaitForConnectionStep = {
36
+ export type LinkedInWaitForConnectionStep = LinkedInStepBase & {
30
37
  kind: "wait_for_connection";
31
38
  /** Days to wait for the invite to be accepted before acting on on_timeout. */
32
39
  timeout_days: number;
33
40
  /** What to do if the invite is never accepted. Defaults to "stop". */
34
41
  on_timeout?: "stop" | "continue";
35
42
  };
36
- export type LinkedInWaitStep = {
43
+ export type LinkedInWaitStep = LinkedInStepBase & {
37
44
  kind: "wait";
38
45
  /** Fixed delay before the next step. Provide days and/or hours (>= 1 total). */
39
46
  days?: number;
40
47
  hours?: number;
41
48
  };
42
- export type LinkedInMessageStep = {
49
+ export type LinkedInMessageStep = LinkedInStepBase & {
43
50
  kind: "message";
44
51
  /** Message body. Supports {{column}} interpolation from the source-table row. */
45
52
  template: string;
46
53
  };
47
- export type LinkedInInMailStep = {
54
+ export type LinkedInInMailStep = LinkedInStepBase & {
48
55
  kind: "inmail";
49
56
  subject_template: string;
50
57
  template: string;
51
58
  };
52
- export type LinkedInSequenceStep = LinkedInVisitProfileStep | LinkedInInviteStep | LinkedInWaitForConnectionStep | LinkedInWaitStep | LinkedInMessageStep | LinkedInInMailStep;
59
+ /**
60
+ * A routing gate. Unlike a workflow branch (synchronous expression), a sequence
61
+ * branch routes on a LinkedIn signal that arrives over time:
62
+ * - `connection_accepted`: enter a wait until the invite is accepted (the
63
+ * new_relation webhook sets connectedAt) or `timeout_days` elapses. Accepted →
64
+ * `then_id`; timeout → `else_id` (omitted = stop).
65
+ * - `already_connected`: at entry, resolve whether the lead is already a
66
+ * 1st-degree connection. Connected → `then_id`; not → `else_id`.
67
+ * A missing `then_id`/`else_id` ends that path (enrollment completes/stops).
68
+ */
69
+ export type LinkedInBranchStep = LinkedInStepBase & {
70
+ kind: "branch";
71
+ condition: LinkedInBranchCondition;
72
+ /** connection_accepted only: days to wait for acceptance. Defaults to 14. */
73
+ timeout_days?: number;
74
+ /** Step id to route to when the condition is TRUE. Omitted = end this path. */
75
+ then_id?: string;
76
+ /** Step id to route to when FALSE / timeout. Omitted = stop this path. */
77
+ else_id?: string;
78
+ };
79
+ /**
80
+ * Explicit terminal step. Ends the enrollment here regardless of any steps that
81
+ * follow in the array — the only way to give a `branch`'s two arms genuinely
82
+ * distinct endings (e.g. a "warm" message that stops, vs an invite→wait→"cold"
83
+ * message that stops), instead of forcing both arms to fall through into the
84
+ * same trailing step.
85
+ */
86
+ export type LinkedInStopStep = LinkedInStepBase & {
87
+ kind: "stop";
88
+ };
89
+ export type LinkedInSequenceStep = LinkedInVisitProfileStep | LinkedInInviteStep | LinkedInWaitForConnectionStep | LinkedInWaitStep | LinkedInMessageStep | LinkedInInMailStep | LinkedInBranchStep | LinkedInStopStep;
53
90
  export type LinkedInSequenceDefinition = {
54
91
  steps: LinkedInSequenceStep[];
55
92
  };
56
93
  /**
57
94
  * The dispatch-queue action kind a step produces (matches the
58
- * ox_linkedin.sequence_actions.action_kind enum), or null for gate/wait steps
95
+ * ox_sequencer.sequence_actions.action_kind enum), or null for gate/wait steps
59
96
  * that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
60
97
  * — visit_profile maps to the quota kind profile_view downstream.
61
98
  */
62
99
  export declare const LINKEDIN_STEP_ACTION_KIND: Record<LinkedInSequenceStepKind, "visit_profile" | "invite" | "message" | "inmail" | null>;
63
- export type LinkedInSequenceLintIssue = {
64
- path: string;
65
- message: string;
66
- };
67
- /**
68
- * Validate a raw sequence definition. Returns the normalized definition or
69
- * throws OxygenError("invalid_linkedin_sequence") with per-step issues. Pure —
70
- * safe to call from any surface.
71
- */
72
- export declare function validateLinkedInSequenceDefinition(input: unknown): LinkedInSequenceDefinition;
73
- /** Non-throwing variant for lint surfaces. */
74
- export declare function lintLinkedInSequenceDefinition(input: unknown): LinkedInSequenceLintIssue[];
75
100
  /** Total delay in milliseconds a `wait` step introduces. */
76
101
  export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
77
102
  /**
@@ -79,5 +104,3 @@ export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
79
104
  * render empty. Used by the dispatch engine to produce the final message text.
80
105
  */
81
106
  export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>): string;
82
- /** Column keys referenced by {{...}} placeholders across all steps. */
83
- export declare function linkedInTemplateVariables(definition: LinkedInSequenceDefinition): string[];
@@ -1,9 +1,10 @@
1
- import { OxygenError } from "./index.js";
2
1
  /**
3
- * LinkedIn sequence step schema the shared contract validated identically by
4
- * CLI, MCP, API, and web. A sequence is an ordered list of steps applied to
5
- * each enrolled lead; the worker dispatch engine materializes one
6
- * sequence_action per step as it comes due.
2
+ * LinkedIn-channel step types + helpers for the sequencer. These typed shapes
3
+ * describe the LinkedIn steps of a sequence and are consumed by the dispatch
4
+ * engine and the web flow editor/canvas. The canonical multichannel validator
5
+ * lives in `sequences.ts` (validateSequenceDefinition) this module is types +
6
+ * the two pure helpers the dispatcher uses (renderLinkedInTemplate, the
7
+ * action-kind map, waitStepDelayMs). It carries no validator of its own.
7
8
  *
8
9
  * Step kinds:
9
10
  * - visit_profile — view the lead's profile (warms up before an invite)
@@ -12,10 +13,8 @@ import { OxygenError } from "./index.js";
12
13
  * webhook) or timeout_days elapses; on timeout stop or
13
14
  * continue per on_timeout
14
15
  * - wait — fixed delay (days/hours) before the next step
15
- * - message — send a message (opens a chat if none exists);
16
- * template supports {{column}} interpolation
17
- * - inmail — send an InMail (works on non-connections); subject +
18
- * template
16
+ * - message — send a message; template supports {{column}} interp
17
+ * - inmail — send an InMail (works on non-connections)
19
18
  */
20
19
  export const LINKEDIN_SEQUENCE_STEP_KINDS = [
21
20
  "visit_profile",
@@ -24,10 +23,14 @@ export const LINKEDIN_SEQUENCE_STEP_KINDS = [
24
23
  "wait",
25
24
  "message",
26
25
  "inmail",
26
+ "branch",
27
+ "stop",
27
28
  ];
29
+ /** Conditions a `branch` step routes on. Both are LinkedIn-signal driven. */
30
+ export const LINKEDIN_BRANCH_CONDITIONS = ["connection_accepted", "already_connected"];
28
31
  /**
29
32
  * The dispatch-queue action kind a step produces (matches the
30
- * ox_linkedin.sequence_actions.action_kind enum), or null for gate/wait steps
33
+ * ox_sequencer.sequence_actions.action_kind enum), or null for gate/wait steps
31
34
  * that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
32
35
  * — visit_profile maps to the quota kind profile_view downstream.
33
36
  */
@@ -38,190 +41,9 @@ export const LINKEDIN_STEP_ACTION_KIND = {
38
41
  wait: null,
39
42
  message: "message",
40
43
  inmail: "inmail",
44
+ branch: null,
45
+ stop: null,
41
46
  };
42
- const MAX_STEPS = 25;
43
- const MAX_TEMPLATE_LENGTH = 8_000;
44
- const MAX_NOTE_LENGTH = 300;
45
- /**
46
- * Validate a raw sequence definition. Returns the normalized definition or
47
- * throws OxygenError("invalid_linkedin_sequence") with per-step issues. Pure —
48
- * safe to call from any surface.
49
- */
50
- export function validateLinkedInSequenceDefinition(input) {
51
- const issues = [];
52
- const steps = collectSteps(input, issues);
53
- const normalized = [];
54
- steps.forEach((rawStep, index) => {
55
- const step = normalizeStep(rawStep, index, issues);
56
- if (step)
57
- normalized.push(step);
58
- });
59
- validateStructure(normalized, issues);
60
- if (issues.length > 0) {
61
- throw new OxygenError("invalid_linkedin_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
62
- }
63
- return { steps: normalized };
64
- }
65
- /** Non-throwing variant for lint surfaces. */
66
- export function lintLinkedInSequenceDefinition(input) {
67
- try {
68
- validateLinkedInSequenceDefinition(input);
69
- return [];
70
- }
71
- catch (error) {
72
- if (error instanceof OxygenError && error.details && typeof error.details === "object") {
73
- const issues = error.details.issues;
74
- if (Array.isArray(issues))
75
- return issues;
76
- }
77
- return [{ path: "steps", message: error instanceof Error ? error.message : "Invalid sequence." }];
78
- }
79
- }
80
- function collectSteps(input, issues) {
81
- const record = isRecord(input) ? input : null;
82
- const steps = record?.steps;
83
- if (!Array.isArray(steps)) {
84
- issues.push({ path: "steps", message: "steps must be an array." });
85
- return [];
86
- }
87
- if (steps.length === 0) {
88
- issues.push({ path: "steps", message: "A sequence needs at least one step." });
89
- }
90
- if (steps.length > MAX_STEPS) {
91
- issues.push({ path: "steps", message: `A sequence may have at most ${MAX_STEPS} steps.` });
92
- }
93
- return steps;
94
- }
95
- function normalizeStep(// skipcq: JS-R1005 -- step normalization validates a discriminated sequence DSL with per-step fields.
96
- raw, index, issues) {
97
- const path = `steps[${index}]`;
98
- if (!isRecord(raw)) {
99
- issues.push({ path, message: "Each step must be an object." });
100
- return null;
101
- }
102
- const kind = raw.kind;
103
- if (typeof kind !== "string" || !LINKEDIN_SEQUENCE_STEP_KINDS.includes(kind)) {
104
- issues.push({
105
- path: `${path}.kind`,
106
- message: `kind must be one of: ${LINKEDIN_SEQUENCE_STEP_KINDS.join(", ")}.`,
107
- });
108
- return null;
109
- }
110
- switch (kind) {
111
- case "visit_profile":
112
- return { kind: "visit_profile" };
113
- case "invite": {
114
- const note = optionalTemplate(raw.note_template, `${path}.note_template`, MAX_NOTE_LENGTH, issues);
115
- return note !== undefined ? { kind: "invite", note_template: note } : { kind: "invite" };
116
- }
117
- case "wait_for_connection": {
118
- // timeout_days is optional (defaults to 14); only validate when provided.
119
- const timeoutDays = raw.timeout_days === undefined || raw.timeout_days === null
120
- ? undefined
121
- : positiveInt(raw.timeout_days, `${path}.timeout_days`, issues);
122
- const onTimeout = raw.on_timeout;
123
- if (onTimeout !== undefined && onTimeout !== "stop" && onTimeout !== "continue") {
124
- issues.push({ path: `${path}.on_timeout`, message: "on_timeout must be 'stop' or 'continue'." });
125
- }
126
- return {
127
- kind: "wait_for_connection",
128
- timeout_days: timeoutDays ?? 14,
129
- on_timeout: onTimeout === "continue" ? "continue" : "stop",
130
- };
131
- }
132
- case "wait": {
133
- const days = optionalNonNegativeInt(raw.days, `${path}.days`, issues);
134
- const hours = optionalNonNegativeInt(raw.hours, `${path}.hours`, issues);
135
- if ((days ?? 0) + (hours ?? 0) <= 0) {
136
- issues.push({ path, message: "A wait step needs days and/or hours totaling at least 1 hour." });
137
- }
138
- return {
139
- kind: "wait",
140
- ...(days !== undefined ? { days } : {}),
141
- ...(hours !== undefined ? { hours } : {}),
142
- };
143
- }
144
- case "message": {
145
- const template = requiredTemplate(raw.template, `${path}.template`, issues);
146
- return { kind: "message", template: template ?? "" };
147
- }
148
- case "inmail": {
149
- const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
150
- const template = requiredTemplate(raw.template, `${path}.template`, issues);
151
- return { kind: "inmail", subject_template: subject ?? "", template: template ?? "" };
152
- }
153
- default:
154
- return null;
155
- }
156
- }
157
- /**
158
- * Structural rules across steps:
159
- * - The first send step must be invite or visit_profile (you can't message a
160
- * stranger without connecting); a message before any invite/wait_for_connection
161
- * is valid for warm lists whose leads are already 1st-degree connections.
162
- * - wait_for_connection must be preceded by an invite.
163
- * - Two consecutive wait/wait_for_connection steps are pointless.
164
- */
165
- function validateStructure(steps, issues) {
166
- let sawInvite = false;
167
- steps.forEach((step, index) => {
168
- if (step.kind === "invite")
169
- sawInvite = true;
170
- if (step.kind === "wait_for_connection" && !sawInvite) {
171
- issues.push({
172
- path: `steps[${index}]`,
173
- message: "wait_for_connection must come after an invite step.",
174
- });
175
- }
176
- if (step.kind === "message" && !sawInvite && !steps.slice(0, index).some((s) => s.kind === "wait_for_connection")) {
177
- // A message before connecting only works for existing 1st-degree
178
- // connections. This is valid for warm lists; do not add a fatal issue
179
- // until the API has a separate warning channel.
180
- }
181
- });
182
- }
183
- function requiredTemplate(value, path, issues) {
184
- if (typeof value !== "string" || !value.trim()) {
185
- issues.push({ path, message: "is required and must be a non-empty string." });
186
- return undefined;
187
- }
188
- if (value.length > MAX_TEMPLATE_LENGTH) {
189
- issues.push({ path, message: `must be at most ${MAX_TEMPLATE_LENGTH} characters.` });
190
- return undefined;
191
- }
192
- return value;
193
- }
194
- function optionalTemplate(value, path, maxLength, issues) {
195
- if (value === undefined || value === null)
196
- return undefined;
197
- if (typeof value !== "string") {
198
- issues.push({ path, message: "must be a string." });
199
- return undefined;
200
- }
201
- if (value.length > maxLength) {
202
- issues.push({ path, message: `must be at most ${maxLength} characters.` });
203
- return undefined;
204
- }
205
- return value;
206
- }
207
- function positiveInt(value, path, issues) {
208
- const num = Number(value);
209
- if (!Number.isInteger(num) || num <= 0) {
210
- issues.push({ path, message: "must be a positive integer." });
211
- return undefined;
212
- }
213
- return num;
214
- }
215
- function optionalNonNegativeInt(value, path, issues) {
216
- if (value === undefined || value === null)
217
- return undefined;
218
- const num = Number(value);
219
- if (!Number.isInteger(num) || num < 0) {
220
- issues.push({ path, message: "must be a non-negative integer." });
221
- return undefined;
222
- }
223
- return num;
224
- }
225
47
  /** Total delay in milliseconds a `wait` step introduces. */
226
48
  export function waitStepDelayMs(step) {
227
49
  const days = step.days ?? 0;
@@ -240,29 +62,3 @@ export function renderLinkedInTemplate(template, values) {
240
62
  return typeof value === "string" ? value : String(value);
241
63
  });
242
64
  }
243
- /** Column keys referenced by {{...}} placeholders across all steps. */
244
- export function linkedInTemplateVariables(definition) {
245
- const vars = new Set();
246
- const scan = (template) => {
247
- if (!template)
248
- return;
249
- for (const match of template.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) {
250
- if (match[1])
251
- vars.add(match[1]);
252
- }
253
- };
254
- for (const step of definition.steps) {
255
- if (step.kind === "invite")
256
- scan(step.note_template);
257
- if (step.kind === "message")
258
- scan(step.template);
259
- if (step.kind === "inmail") {
260
- scan(step.subject_template);
261
- scan(step.template);
262
- }
263
- }
264
- return [...vars];
265
- }
266
- function isRecord(value) {
267
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
268
- }