@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.
@@ -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
- sha: process.env.VERCEL_GIT_COMMIT_SHA ?? null,
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
- error_message: err.message,
65
- error_stack: err.stack,
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
  }