@oxygen-agent/cli 1.152.15 → 1.160.18
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 +2 -2
- package/dist/index.js +841 -127
- package/dist/transcript.d.ts +21 -0
- package/dist/transcript.js +208 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +54 -31
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +15 -219
- package/node_modules/@oxygen/shared/dist/log.js +12 -4
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +238 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +501 -0
- package/node_modules/@oxygen/shared/dist/sql-error.d.ts +43 -0
- package/node_modules/@oxygen/shared/dist/sql-error.js +318 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +26 -3
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/version.js +5 -2
- package/node_modules/@oxygen/workflows/dist/index.d.ts +0 -19
- package/node_modules/@oxygen/workflows/dist/index.js +16 -19
- package/package.json +1 -1
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
-
}
|