@oxygen-agent/cli 1.146.1 → 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/http-client.js +40 -2
- package/dist/index.js +1129 -114
- package/dist/transcript.d.ts +21 -0
- package/dist/transcript.js +208 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/index.js +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +106 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +64 -0
- 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
|
+
}
|
|
@@ -4,10 +4,14 @@ export * from "./billing.js";
|
|
|
4
4
|
export * from "./cell-format.js";
|
|
5
5
|
export * from "./column-types.js";
|
|
6
6
|
export * from "./credit-guidance.js";
|
|
7
|
+
export * from "./linkedin-sequences.js";
|
|
8
|
+
export * from "./sequences.js";
|
|
7
9
|
export * from "./log.js";
|
|
8
10
|
export * from "./provider-request-outcomes.js";
|
|
9
11
|
export * from "./signup-lead-deliveries.js";
|
|
12
|
+
export * from "./sql-error.js";
|
|
10
13
|
export * from "./telemetry.js";
|
|
14
|
+
export declare const MAX_ROW_LOOP_WRITE_ROWS = 500;
|
|
11
15
|
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
12
16
|
[key: string]: JsonValue;
|
|
13
17
|
};
|
|
@@ -5,10 +5,19 @@ export * from "./billing.js";
|
|
|
5
5
|
export * from "./cell-format.js";
|
|
6
6
|
export * from "./column-types.js";
|
|
7
7
|
export * from "./credit-guidance.js";
|
|
8
|
+
export * from "./linkedin-sequences.js";
|
|
9
|
+
export * from "./sequences.js";
|
|
8
10
|
export * from "./log.js";
|
|
9
11
|
export * from "./provider-request-outcomes.js";
|
|
10
12
|
export * from "./signup-lead-deliveries.js";
|
|
13
|
+
export * from "./sql-error.js";
|
|
11
14
|
export * from "./telemetry.js";
|
|
15
|
+
// Maximum rows a single row-loop write (insert/upsert/preview) may process. The
|
|
16
|
+
// row-loop engine issues one DB round-trip per row, so a 500-row write already
|
|
17
|
+
// approaches request timeouts (~50s observed in prod); larger batches must use
|
|
18
|
+
// the COPY-based bulk engine. Tenant-db enforces this and the CLI/API row caps
|
|
19
|
+
// reference it so they never advertise a batch the row-loop will reject.
|
|
20
|
+
export const MAX_ROW_LOOP_WRITE_ROWS = 500;
|
|
12
21
|
export class OxygenError extends Error {
|
|
13
22
|
code;
|
|
14
23
|
details;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Step kinds:
|
|
10
|
+
* - visit_profile — view the lead's profile (warms up before an invite)
|
|
11
|
+
* - invite — send a connection request, optional note_template
|
|
12
|
+
* - wait_for_connection — gate: wait until the invite is accepted (new_relation
|
|
13
|
+
* webhook) or timeout_days elapses; on timeout stop or
|
|
14
|
+
* continue per on_timeout
|
|
15
|
+
* - wait — fixed delay (days/hours) before the next step
|
|
16
|
+
* - message — send a message; template supports {{column}} interp
|
|
17
|
+
* - inmail — send an InMail (works on non-connections)
|
|
18
|
+
*/
|
|
19
|
+
export declare const LINKEDIN_SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "wait", "message", "inmail", "branch", "stop"];
|
|
20
|
+
export type LinkedInSequenceStepKind = typeof LINKEDIN_SEQUENCE_STEP_KINDS[number];
|
|
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 & {
|
|
29
|
+
kind: "visit_profile";
|
|
30
|
+
};
|
|
31
|
+
export type LinkedInInviteStep = LinkedInStepBase & {
|
|
32
|
+
kind: "invite";
|
|
33
|
+
/** Optional connection-request note. Supports {{column}} interpolation. ~300 char max on LinkedIn. */
|
|
34
|
+
note_template?: string;
|
|
35
|
+
};
|
|
36
|
+
export type LinkedInWaitForConnectionStep = LinkedInStepBase & {
|
|
37
|
+
kind: "wait_for_connection";
|
|
38
|
+
/** Days to wait for the invite to be accepted before acting on on_timeout. */
|
|
39
|
+
timeout_days: number;
|
|
40
|
+
/** What to do if the invite is never accepted. Defaults to "stop". */
|
|
41
|
+
on_timeout?: "stop" | "continue";
|
|
42
|
+
};
|
|
43
|
+
export type LinkedInWaitStep = LinkedInStepBase & {
|
|
44
|
+
kind: "wait";
|
|
45
|
+
/** Fixed delay before the next step. Provide days and/or hours (>= 1 total). */
|
|
46
|
+
days?: number;
|
|
47
|
+
hours?: number;
|
|
48
|
+
};
|
|
49
|
+
export type LinkedInMessageStep = LinkedInStepBase & {
|
|
50
|
+
kind: "message";
|
|
51
|
+
/** Message body. Supports {{column}} interpolation from the source-table row. */
|
|
52
|
+
template: string;
|
|
53
|
+
};
|
|
54
|
+
export type LinkedInInMailStep = LinkedInStepBase & {
|
|
55
|
+
kind: "inmail";
|
|
56
|
+
subject_template: string;
|
|
57
|
+
template: string;
|
|
58
|
+
};
|
|
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;
|
|
90
|
+
export type LinkedInSequenceDefinition = {
|
|
91
|
+
steps: LinkedInSequenceStep[];
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* The dispatch-queue action kind a step produces (matches the
|
|
95
|
+
* ox_sequencer.sequence_actions.action_kind enum), or null for gate/wait steps
|
|
96
|
+
* that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
|
|
97
|
+
* — visit_profile maps to the quota kind profile_view downstream.
|
|
98
|
+
*/
|
|
99
|
+
export declare const LINKEDIN_STEP_ACTION_KIND: Record<LinkedInSequenceStepKind, "visit_profile" | "invite" | "message" | "inmail" | null>;
|
|
100
|
+
/** Total delay in milliseconds a `wait` step introduces. */
|
|
101
|
+
export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
|
|
102
|
+
/**
|
|
103
|
+
* Render a {{column}} template against a row's values. Unknown placeholders
|
|
104
|
+
* render empty. Used by the dispatch engine to produce the final message text.
|
|
105
|
+
*/
|
|
106
|
+
export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>): string;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Step kinds:
|
|
10
|
+
* - visit_profile — view the lead's profile (warms up before an invite)
|
|
11
|
+
* - invite — send a connection request, optional note_template
|
|
12
|
+
* - wait_for_connection — gate: wait until the invite is accepted (new_relation
|
|
13
|
+
* webhook) or timeout_days elapses; on timeout stop or
|
|
14
|
+
* continue per on_timeout
|
|
15
|
+
* - wait — fixed delay (days/hours) before the next step
|
|
16
|
+
* - message — send a message; template supports {{column}} interp
|
|
17
|
+
* - inmail — send an InMail (works on non-connections)
|
|
18
|
+
*/
|
|
19
|
+
export const LINKEDIN_SEQUENCE_STEP_KINDS = [
|
|
20
|
+
"visit_profile",
|
|
21
|
+
"invite",
|
|
22
|
+
"wait_for_connection",
|
|
23
|
+
"wait",
|
|
24
|
+
"message",
|
|
25
|
+
"inmail",
|
|
26
|
+
"branch",
|
|
27
|
+
"stop",
|
|
28
|
+
];
|
|
29
|
+
/** Conditions a `branch` step routes on. Both are LinkedIn-signal driven. */
|
|
30
|
+
export const LINKEDIN_BRANCH_CONDITIONS = ["connection_accepted", "already_connected"];
|
|
31
|
+
/**
|
|
32
|
+
* The dispatch-queue action kind a step produces (matches the
|
|
33
|
+
* ox_sequencer.sequence_actions.action_kind enum), or null for gate/wait steps
|
|
34
|
+
* that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
|
|
35
|
+
* — visit_profile maps to the quota kind profile_view downstream.
|
|
36
|
+
*/
|
|
37
|
+
export const LINKEDIN_STEP_ACTION_KIND = {
|
|
38
|
+
visit_profile: "visit_profile",
|
|
39
|
+
invite: "invite",
|
|
40
|
+
wait_for_connection: null,
|
|
41
|
+
wait: null,
|
|
42
|
+
message: "message",
|
|
43
|
+
inmail: "inmail",
|
|
44
|
+
branch: null,
|
|
45
|
+
stop: null,
|
|
46
|
+
};
|
|
47
|
+
/** Total delay in milliseconds a `wait` step introduces. */
|
|
48
|
+
export function waitStepDelayMs(step) {
|
|
49
|
+
const days = step.days ?? 0;
|
|
50
|
+
const hours = step.hours ?? 0;
|
|
51
|
+
return (days * 24 + hours) * 60 * 60 * 1000;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Render a {{column}} template against a row's values. Unknown placeholders
|
|
55
|
+
* render empty. Used by the dispatch engine to produce the final message text.
|
|
56
|
+
*/
|
|
57
|
+
export function renderLinkedInTemplate(template, values) {
|
|
58
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, key) => {
|
|
59
|
+
const value = values[key];
|
|
60
|
+
if (value === null || value === undefined)
|
|
61
|
+
return "";
|
|
62
|
+
return typeof value === "string" ? value : String(value);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { sanitizeLogFields } from "./redaction.js";
|
|
3
|
+
import { redactSqlParameters } from "./sql-error.js";
|
|
3
4
|
import { OXYGEN_VERSION } from "./version.js";
|
|
4
5
|
const store = new AsyncLocalStorage();
|
|
5
6
|
export function withLogContext(ctx, fn) {
|
|
@@ -16,7 +17,12 @@ export function log(level, msg, fields) {
|
|
|
16
17
|
msg,
|
|
17
18
|
service_name: process.env.OXYGEN_SERVICE_NAME ?? process.env.OTEL_SERVICE_NAME ?? null,
|
|
18
19
|
oxygen_version: OXYGEN_VERSION,
|
|
19
|
-
|
|
20
|
+
// Deploying commit. Vercel injects VERCEL_GIT_COMMIT_SHA; the Fly worker has
|
|
21
|
+
// no such env, so it resolves the image-baked SHA at startup and exports it as
|
|
22
|
+
// OXYGEN_GIT_SHA (see apps/worker build-info). Without that fallback every
|
|
23
|
+
// recurring worker line carried sha:null even though /health knew the commit
|
|
24
|
+
// (OXY-61). This module stays edge-safe by reading only the env var.
|
|
25
|
+
sha: process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.OXYGEN_GIT_SHA ?? null,
|
|
20
26
|
region: process.env.VERCEL_REGION ?? null,
|
|
21
27
|
env: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
|
|
22
28
|
...sanitizeLogFields({
|
|
@@ -61,9 +67,11 @@ export function errorFields(err) {
|
|
|
61
67
|
return {
|
|
62
68
|
error_id: errorId(err),
|
|
63
69
|
error_name: err.name,
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
// Strip drizzle's `\nparams: <values>` tail so SQL parameter values never
|
|
71
|
+
// reach logs (OXY-46); the SQL text and stack frames are preserved.
|
|
72
|
+
error_message: redactSqlParameters(err.message),
|
|
73
|
+
error_stack: err.stack ? redactSqlParameters(err.stack) : err.stack,
|
|
66
74
|
};
|
|
67
75
|
}
|
|
68
|
-
return { error_id: "non_error", error_message: String(err) };
|
|
76
|
+
return { error_id: "non_error", error_message: redactSqlParameters(String(err)) };
|
|
69
77
|
}
|