@oxygen-agent/cli 1.813.1 → 1.830.1

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 CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.813.1
37
+ Version: 1.830.1
@@ -205,7 +205,7 @@ function warnIfCliIsOlderThanApi(serverVersion, endpoint) {
205
205
  return;
206
206
  staleCliWarningVersions.add(warningKey);
207
207
  process.stderr.write(`[${guidance.binaryName}] CLI version ${OXYGEN_VERSION} is older than Oxygen API version ${serverVersion}. `
208
- + `${guidance.warningInstruction}\n`);
208
+ + `This compatible skew is non-blocking; the command can proceed. ${guidance.warningInstruction}\n`);
209
209
  }
210
210
  function isPatchOnlyVersionSkew(serverVersion, clientVersion) {
211
211
  const server = parseSemver(serverVersion);
package/dist/index.js CHANGED
@@ -247,6 +247,7 @@ const OXYGEN_WORDMARK = [
247
247
  " \\___/ /_/\\_\\ |_| \\____|_____|_| \\_|",
248
248
  ].join("\n");
249
249
  const LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD = 500;
250
+ const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
250
251
  // The only numbers on `tables import --help` used to be --batch-size (a
251
252
  // per-request chunk) and the background threshold, so users read 500 as a
252
253
  // per-file cap. Echo the real PLAN_LIMITS ceilings instead of restating them.
@@ -4715,7 +4716,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4715
4716
  .command("tables")
4716
4717
  .description("Tenant workspace table commands.")
4717
4718
  .addCommand(new Command("create")
4718
- .description("Create a real Postgres-backed workspace table.")
4719
+ .description("Create a real Postgres-backed workspace table. Free — 0 Oxygen credits. To create a table and import a file in one step, use `tables import --create <name>`.")
4719
4720
  .argument("<name>", "Display name for the table.")
4720
4721
  .requiredOption("--columns-json <json>", 'JSON array of column definitions, e.g. [{"key":"name","label":"Name","dataType":"text"},{"key":"domain","label":"Domain","dataType":"text"}]. Inspect an existing shape with `oxygen tables describe <table>`.')
4721
4722
  .option("--project <project>", "Project id or slug. Defaults to General.")
@@ -4750,6 +4751,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4750
4751
  .description("Insert rows into a workspace table.")
4751
4752
  .argument("<table>", "Table id or slug.")
4752
4753
  .requiredOption("--rows-json <json>", 'JSON array of row objects keyed by column key, e.g. [{"name":"Acme","domain":"acme.test"},{"name":"Globex","domain":"globex.test"}].')
4754
+ .option("--request-id <id>", "Stable retry key. Reuse it after a timeout to return the committed write without duplicating rows.")
4753
4755
  .option("--json", "Print a JSON envelope.")
4754
4756
  .action(async (table, options) => {
4755
4757
  await handleAsyncAction("tables insert", options, () => requestOxygen("/api/cli/tables/rows", {
@@ -4757,6 +4759,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4757
4759
  body: {
4758
4760
  table,
4759
4761
  rows: parseJsonArray(options.rowsJson),
4762
+ ...(readOption(options.requestId) ? { request_id: readOption(options.requestId) } : {}),
4760
4763
  },
4761
4764
  }));
4762
4765
  }))
@@ -4832,7 +4835,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4832
4835
  }));
4833
4836
  }))
4834
4837
  .addCommand(new Command("import")
4835
- .description(`Import JSON, JSONL, CSV, or XLSX rows into a workspace table. Files over ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows load durably in the background: the command returns a queued envelope and rows keep landing after it exits, so wait with the printed 'table-ingestions wait <id>' before reading row counts. ${IMPORT_FILE_LIMIT_HELP}`)
4838
+ .description(`Import JSON, JSONL, CSV, or XLSX rows into a workspace table. The table write is free — 0 Oxygen credits. Files over ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows load durably in the background: the command returns a queued envelope and rows keep landing after it exits, so wait with the printed 'table-ingestions wait <id>' before reading row counts. ${IMPORT_FILE_LIMIT_HELP}`)
4836
4839
  .argument("[table]", "Table id or slug. Omit when using --create.")
4837
4840
  .requiredOption("--file <path>", "Input file path.")
4838
4841
  .option("--format <format>", "json, jsonl, csv, or xlsx. Defaults from file extension.")
@@ -4840,7 +4843,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4840
4843
  .option("--create <name>", "Create a new table with columns inferred from the file before importing.")
4841
4844
  .option("--project <project>", "Project id or slug for --create. Defaults to General.")
4842
4845
  .option("--upsert-key <key>", "Column key used to upsert instead of inserting.")
4843
- .option("--batch-size <n>", "How many rows travel in one API request - a chunking knob, not a limit on the file. Defaults to 500; paid orgs may use up to 5000.")
4846
+ .option("--batch-size <n>", "Requested rows per import chunk, not a limit on the file. Defaults to 500; values up to 5000 are accepted, but Oxygen splits writes to at most 500 rows (and smaller for wide rows) so they fit request and worker lease budgets.")
4844
4847
  .option("--background", "Enqueue durable import chunks for the background worker.")
4845
4848
  .option("--sync", `Force foreground import even for files above ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows. That threshold picks foreground vs background; it does not cap how many rows a file may hold.`)
4846
4849
  .option("--max-concurrency <n>", "Maximum concurrent import chunks for background mode. Defaults to 5.")
@@ -4874,7 +4877,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4874
4877
  .option("--project <project>", "Project id or slug for the new table. Defaults to General. Ignored with --into.")
4875
4878
  .option("--into <table>", "Resume into an existing table (id or slug) instead of creating a new one. Requires --key. Use the table id printed by the failed run.")
4876
4879
  .option("--key <column>", "Upsert key column. When set, rows are matched by this key instead of blindly inserted, so re-running the import is idempotent (no duplicates). Required with --into.")
4877
- .option("--batch-size <n>", "Rows per write request. Defaults to 500; paid orgs may use up to 5000.")
4880
+ .option("--batch-size <n>", "Requested rows per write group. Defaults to 500; values up to 5000 are accepted and split into safe writes of at most 500 rows.")
4878
4881
  .option("--json", "Print a JSON envelope.")
4879
4882
  .action(async (options) => {
4880
4883
  await handleAsyncAction("tables import-bundle", options, () => importTableBundle(options));
@@ -4909,6 +4912,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4909
4912
  .option("--fields <columns>", "Comma-separated column keys or ids to include.")
4910
4913
  .option("--filter-json <json>", "Legacy filter object or array, e.g. '{\"column\":\"mobile_phone_e164\",\"op\":\"is_null\"}'. Mutually exclusive with --filter-tree-json/--sort-json.")
4911
4914
  .option("--filter-tree-json <json>", "Airtable-style filter group, e.g. '{\"type\":\"group\",\"conjunction\":\"and\",\"children\":[{\"type\":\"leaf\",\"columnKey\":\"stage\",\"operator\":\"is\",\"value\":\"won\"}]}'. Mutually exclusive with --filter-json.")
4915
+ .option("--formula-values <mode>", "Formula filters are refused by default because displayed formulas are evaluated live. Refresh the formula's stored cells with `columns run <table> <column> --force` (0 credits), then pass 'materialized'; filtering uses that stored snapshot and returns a freshness note.")
4912
4916
  .option("--sort-json <json>", "Ordered sort rules, e.g. '[{\"columnKey\":\"_created_at\",\"direction\":\"desc\"}]'. Earlier rules dominate. Mutually exclusive with --filter-json.")
4913
4917
  .option("--no-system-fields", "Omit _row_id, _created_at, and _updated_at from returned rows (included by default).")
4914
4918
  .option("--json", "Print a JSON envelope.")
@@ -4917,11 +4921,15 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4917
4921
  const limit = readPositiveInt(options.limit);
4918
4922
  const filters = readFilterJsonOption(options.filterJson);
4919
4923
  const filterTree = readJsonObjectOption(options.filterTreeJson);
4924
+ const formulaValues = readFormulaValuesOption(options.formulaValues);
4920
4925
  const sorts = readSortJsonOption(options.sortJson);
4921
4926
  const offset = readPositiveInt(options.offset);
4922
4927
  if (filters && (filterTree || sorts)) {
4923
4928
  throw new OxygenError("invalid_filter", "Pass either --filter-json (legacy) or --filter-tree-json/--sort-json, not both.", { exitCode: 1 });
4924
4929
  }
4930
+ if (formulaValues && !filters && !filterTree) {
4931
+ throw new OxygenError("invalid_filter", "--formula-values requires --filter-json or --filter-tree-json.", { exitCode: 1 });
4932
+ }
4925
4933
  if (readOption(options.cursor) && offset) {
4926
4934
  throw new OxygenError("invalid_request", "Pass either --cursor or --offset, not both.", { exitCode: 1 });
4927
4935
  }
@@ -4935,6 +4943,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
4935
4943
  ...(readOption(options.fields) ? { fields: readCsvOption(options.fields) } : {}),
4936
4944
  ...(filters ? { filters } : {}),
4937
4945
  ...(filterTree ? { filterTree } : {}),
4946
+ ...(formulaValues ? { formula_values: formulaValues } : {}),
4938
4947
  ...(sorts ? { sorts } : {}),
4939
4948
  ...(options.systemFields === false ? { include_system_fields: false } : {}),
4940
4949
  },
@@ -7158,6 +7167,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7158
7167
  .option("--limit <n>", "Run the next N rows whose target cell is still empty (--force runs the first N regardless). Repeat until rowCount is 0 to page through a table. Defaults to 10; inline deterministic runs have a hard cap of 25.")
7159
7168
  .option("--all", "Run all rows. Requires --background.")
7160
7169
  .option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
7170
+ .option("--formula-values <mode>", "With --filter-json on a formula column, first refresh that selector with `columns run <table> <column> --force` (0 credits), then pass 'materialized'. The run filters the stored snapshot and persists this freshness acknowledgement.")
7161
7171
  .option("--force", "Run even when the target cell already has a value.")
7162
7172
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
7163
7173
  .option("--background", "Create a durable background run for a free deterministic column. Paid AI/tool/enrichment/custom-HTTP server runs are always backgrounded.")
@@ -7174,11 +7184,18 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7174
7184
  const maxCredits = readPositiveNumber(options.maxCredits);
7175
7185
  const maxConcurrency = readPositiveInt(options.maxConcurrency);
7176
7186
  const filterSelection = readFilterSelectionOption(options.filterJson);
7187
+ const formulaValues = readFormulaValuesOption(options.formulaValues);
7188
+ if (formulaValues && !filterSelection) {
7189
+ throw new OxygenError("invalid_selection", "--formula-values requires --filter-json on columns run.", { exitCode: 1 });
7190
+ }
7191
+ const effectiveFilterSelection = filterSelection
7192
+ ? { ...filterSelection, ...(formulaValues ? { formula_values: formulaValues } : {}) }
7193
+ : undefined;
7177
7194
  const selectedModes = [
7178
7195
  Boolean(options.all),
7179
7196
  limit !== undefined,
7180
7197
  Boolean(readOption(options.rowId)),
7181
- Boolean(filterSelection),
7198
+ Boolean(effectiveFilterSelection),
7182
7199
  ].filter(Boolean).length;
7183
7200
  if (selectedModes > 1) {
7184
7201
  throw new OxygenError("invalid_selection", "Pass only one of --all, --limit, --row-id, or --filter-json.", {
@@ -7198,7 +7215,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7198
7215
  exitCode: 1,
7199
7216
  });
7200
7217
  }
7201
- if (filterSelection) {
7218
+ if (effectiveFilterSelection) {
7202
7219
  throw new OxygenError("invalid_column_run", "--filter-json requires --background for columns run.", {
7203
7220
  exitCode: 1,
7204
7221
  });
@@ -7214,7 +7231,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7214
7231
  table,
7215
7232
  column,
7216
7233
  ...(options.all ? { selection: { mode: "all" } } : {}),
7217
- ...(filterSelection ? { selection: filterSelection } : {}),
7234
+ ...(effectiveFilterSelection ? { selection: effectiveFilterSelection } : {}),
7218
7235
  ...(!options.all && readOption(options.rowId) ? { row_id: readOption(options.rowId) } : {}),
7219
7236
  ...(!options.all && !filterSelection && limit ? { limit } : {}),
7220
7237
  ...(options.force ? { force: true } : {}),
@@ -7568,6 +7585,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7568
7585
  .option("--all", "Run against all rows, up to the server cap.")
7569
7586
  .option("--row-ids <csv>", "Comma-separated row UUIDs to run.")
7570
7587
  .option("--filter-json <json>", "Filter object or array for server-side row selection.")
7588
+ .option("--formula-values <mode>", "With --filter-json on a formula column, first refresh that selector with `columns run <table> <column> --force` (0 credits), then pass 'materialized'; the durable row scope filters the stored snapshot and records this freshness acknowledgement.")
7571
7589
  .option("--force", "Run even when the target cell already has a value.")
7572
7590
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
7573
7591
  .option("--approved", "Confirm the paid table action run after inspecting a dry run or preview.")
@@ -9719,8 +9737,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9719
9737
  .option("--return <mode>", "Legacy response shape: raw, compact, or summary. Defaults to raw.")
9720
9738
  .option("--return-mode <mode>", "Response shape: raw, compact, or summary. Prefer summary for large search responses.")
9721
9739
  .option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
9722
- .option("--max-credits <n>", "Required credit ceiling for live runs of paid tools.")
9723
- .option("--approved", "Required for live runs of paid tools after inspecting dry-run output.")
9740
+ .option("--max-credits <n>", "Credit ceiling for live paid tools; not needed for no-bill tools.")
9741
+ .option("--approved", "Required for live paid tools and external writes after inspecting dry-run output.")
9724
9742
  .option("--json", "Print a JSON envelope.")
9725
9743
  .action(async (toolId, options) => {
9726
9744
  const maxCredits = readPositiveNumber(options.maxCredits);
@@ -13370,7 +13388,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13370
13388
  program.addCommand(new Command("mailboxes")
13371
13389
  .description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
13372
13390
  .addCommand(new Command("list")
13373
- .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), and a pool overview (including counts by source). To see only Google/Microsoft mailboxes that still need OAuth connection and the right remedy for each, use `oxygen mailboxes oauth-health --json`.")
13391
+ .description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), worker-owned connectionRepair and warmupRepair state, and a pool overview (including counts by source). mode=automatic means OXYGEN owns the next bounded retry — do not ask for browser consent or disable/re-enable an existing warm-up seat. To see only Google/Microsoft mailboxes that still need OAuth connection, including attempt budgets and manual remedies, use `oxygen mailboxes oauth-health --json`.")
13374
13392
  .option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
13375
13393
  .option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
13376
13394
  .option("--json", "Print a JSON envelope.")
@@ -13430,7 +13448,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13430
13448
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
13431
13449
  }))
13432
13450
  .addCommand(new Command("compatibility")
13433
- .description("Read-only compatibility report for every selected mailbox: generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, EmailGuard monitoring path, and exact next actions. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
13451
+ .description("Read-only compatibility report for every selected mailbox: generic origin class, real infrastructure tier (including InboxKit Azure), OXYGEN native-send connection quality, OXYGEN Warm-up path, EmailGuard monitoring path, and exact next actions. Read automatic_repair for native send and warmup_automatic_repair for an existing warm-up seat: mode=automatic means OXYGEN owns the next bounded retry. Native send distinguishes destination-bound OAuth, provider-managed API transport, admin delegation, and authorization_required. Use --catalog-only for the compact workspace-independent import contract. JSON data.compatibility contains checked rows; data.import_methods and data.import_fields describe accepted transfer inputs; data.provider_matrix is the current public product matrix; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. Downstream states distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
13434
13452
  .option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
13435
13453
  .option("--catalog-only", "Return only the bounded import-method, field, auth-boundary, and public provider catalogs; do not read or return workspace mailbox rows.")
13436
13454
  .option("--json", "Print a JSON envelope.")
@@ -13722,7 +13740,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13722
13740
  });
13723
13741
  }))
13724
13742
  .addCommand(new Command("oauth-health")
13725
- .description("Show every Google/Microsoft inbox with neither a usable per-mailbox OAuth token nor verified delegation/application access. Results name connect_oauth_oxygen for imported/manual mailboxes and connect_oauth_inboxkit for that managed path. Microsoft can use one-admin tenant authorization for exact selected addresses or individual OAuth. Read-only — 0 Oxygen credits.")
13743
+ .description("Show every Google/Microsoft inbox with neither a usable per-mailbox OAuth token nor verified delegation/application access. automatic_repair marks Zapmail-linked rows whose safe retries OXYGEN owns in the background; browser-consent rows remain explicit. Results name connect_oauth_oxygen for imported/manual mailboxes and connect_oauth_inboxkit for that managed path. Microsoft can use one-admin tenant authorization for exact selected addresses or individual OAuth. Read-only — 0 Oxygen credits; it can pause no mailbox and sends no email.")
13726
13744
  .option("--json", "Print a JSON envelope.")
13727
13745
  .action(async (options) => {
13728
13746
  await handleAsyncAction("mailboxes oauth-health", options, () => requestOxygen("/api/cli/mailboxes/oauth-health"));
@@ -16422,6 +16440,10 @@ function readTableRunSelection(options) {
16422
16440
  const limit = readPositiveInt(options.limit);
16423
16441
  const rowIds = readCsvOption(options.rowIds);
16424
16442
  const filterSelection = readFilterSelectionOption(options.filterJson);
16443
+ const formulaValues = readFormulaValuesOption(options.formulaValues);
16444
+ if (formulaValues && !filterSelection) {
16445
+ throw new OxygenError("invalid_table_run", "--formula-values requires --filter-json on table-runs create.", { exitCode: 1 });
16446
+ }
16425
16447
  const selectedModes = [hasAll, Boolean(limit), rowIds.length > 0, Boolean(filterSelection)].filter(Boolean).length;
16426
16448
  if (selectedModes > 1) {
16427
16449
  throw new OxygenError("invalid_table_run", "Pass only one of --all, --limit, --row-ids, or --filter-json.", {
@@ -16434,8 +16456,9 @@ function readTableRunSelection(options) {
16434
16456
  return { mode: "limit", limit };
16435
16457
  if (rowIds.length > 0)
16436
16458
  return { mode: "row_ids", row_ids: rowIds };
16437
- if (filterSelection)
16438
- return filterSelection;
16459
+ if (filterSelection) {
16460
+ return { ...filterSelection, ...(formulaValues ? { formula_values: formulaValues } : {}) };
16461
+ }
16439
16462
  throw new OxygenError("invalid_table_run", "Pass --all, --limit, --row-ids, or --filter-json.", {
16440
16463
  exitCode: 1,
16441
16464
  });
@@ -17742,6 +17765,15 @@ function readSortJsonOption(value) {
17742
17765
  }
17743
17766
  return parsed;
17744
17767
  }
17768
+ function readFormulaValuesOption(value) {
17769
+ const raw = readOption(value);
17770
+ if (raw === null)
17771
+ return null;
17772
+ if (raw !== "materialized") {
17773
+ throw new OxygenError("invalid_filter", "--formula-values must be 'materialized' when provided.", { details: { formula_values: raw }, exitCode: 1 });
17774
+ }
17775
+ return raw;
17776
+ }
17745
17777
  function parseStringArray(value) {
17746
17778
  const parsed = parseJsonArray(value);
17747
17779
  const labels = parsed
@@ -17778,6 +17810,7 @@ async function importRows(table, options) {
17778
17810
  });
17779
17811
  }
17780
17812
  const batchSize = normalizeImportBatchSize(options.batchSize);
17813
+ const effectiveBatchSize = Math.min(batchSize, SAFE_IMPORT_WRITE_BATCH_SIZE);
17781
17814
  const sourceHash = hashImportFile(options.file);
17782
17815
  const shouldUseBackground = options.background
17783
17816
  || (!options.sync && parsedRows.length > LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD);
@@ -17791,6 +17824,7 @@ async function importRows(table, options) {
17791
17824
  const staged = await tryEnqueueStagedFileImport(table, options, format, parsedRows, {
17792
17825
  autoBackground: !options.background,
17793
17826
  sourceHash,
17827
+ batchSize,
17794
17828
  }, preparedBackgroundTarget);
17795
17829
  if (staged)
17796
17830
  return withImportWaitNextStep(staged);
@@ -17810,11 +17844,14 @@ async function importRows(table, options) {
17810
17844
  let writeRequestCount = 0;
17811
17845
  let requestTooLargeRetries = 0;
17812
17846
  let minimumBatchSizeUsed = null;
17813
- for (const batch of chunk(target.rows, batchSize)) {
17847
+ for (const [batchIndex, batch] of chunk(target.rows, effectiveBatchSize).entries()) {
17814
17848
  const result = await writeImportBatchWithAutoSplit({
17815
17849
  tableRef: target.tableRef,
17816
17850
  upsertKey: target.upsertKey,
17817
17851
  rows: batch,
17852
+ ...(!target.upsertKey
17853
+ ? { requestId: `tables-import:${sourceHash}:${batchIndex}` }
17854
+ : {}),
17818
17855
  });
17819
17856
  rowCount += result.rowCount;
17820
17857
  insertedCount += result.insertedCount;
@@ -17844,7 +17881,8 @@ async function importRows(table, options) {
17844
17881
  ...(warningsTruncated ? { warningsTruncated: true } : {}),
17845
17882
  } : {}),
17846
17883
  batchCount: writeRequestCount,
17847
- batchSize,
17884
+ batchSize: effectiveBatchSize,
17885
+ ...(effectiveBatchSize !== batchSize ? { requestedBatchSize: batchSize } : {}),
17848
17886
  ...(requestTooLargeRetries > 0 ? {
17849
17887
  requestTooLargeRetries,
17850
17888
  minimumBatchSizeUsed,
@@ -17985,6 +18023,7 @@ async function writeImportBatchWithAutoSplit(input) {
17985
18023
  table: input.tableRef,
17986
18024
  rows: input.rows,
17987
18025
  ...(input.upsertKey ? { key: input.upsertKey } : {}),
18026
+ ...(!input.upsertKey && input.requestId ? { request_id: input.requestId } : {}),
17988
18027
  },
17989
18028
  });
17990
18029
  return summarizeImportBatchWrite(result, input.rows.length);
@@ -17998,10 +18037,12 @@ async function writeImportBatchWithAutoSplit(input) {
17998
18037
  const first = await writeImportBatchWithAutoSplit({
17999
18038
  ...input,
18000
18039
  rows: input.rows.slice(0, midpoint),
18040
+ ...(input.requestId ? { requestId: `${input.requestId}:0` } : {}),
18001
18041
  });
18002
18042
  const second = await writeImportBatchWithAutoSplit({
18003
18043
  ...input,
18004
18044
  rows: input.rows.slice(midpoint),
18045
+ ...(input.requestId ? { requestId: `${input.requestId}:1` } : {}),
18005
18046
  });
18006
18047
  return combineImportBatchWriteSummaries(first, second, 1);
18007
18048
  }
@@ -18055,47 +18096,55 @@ table, options, format, parsedRows, context, preparedTarget = null) {
18055
18096
  }
18056
18097
  const fileBuffer = readFileSync(options.file);
18057
18098
  const filename = basename(options.file);
18099
+ const contentType = importFileContentType(format);
18100
+ const traceId = randomUUID();
18058
18101
  // Probe for a presigned upload URL. If object storage isn't configured the
18059
18102
  // server returns object_storage_not_configured; signal the caller to fall
18060
18103
  // back to the inline multipart import by returning null.
18061
- let presigned;
18062
- try {
18063
- presigned = await requestOxygen("/api/cli/tables/import-url", {
18064
- method: "POST",
18065
- body: { file_name: filename, byte_length: fileBuffer.byteLength },
18066
- });
18067
- }
18068
- catch (error) {
18069
- if (error instanceof OxygenError && error.code === "object_storage_not_configured") {
18070
- return null;
18104
+ const requestPresign = async () => {
18105
+ try {
18106
+ return await requestOxygen("/api/cli/tables/import-url", {
18107
+ method: "POST",
18108
+ body: {
18109
+ file_name: filename,
18110
+ content_type: contentType,
18111
+ byte_length: fileBuffer.byteLength,
18112
+ trace_id: traceId,
18113
+ },
18114
+ });
18071
18115
  }
18072
- throw error;
18073
- }
18074
- const uploadUrl = readRecordString(presigned, "upload_url");
18075
- const storageKey = readRecordString(presigned, "storage_key");
18076
- const storageBucket = readRecordString(presigned, "storage_bucket");
18077
- const storageProvider = readRecordString(presigned, "storage_provider") ?? "s3";
18078
- if (!uploadUrl || !storageKey)
18116
+ catch (error) {
18117
+ if (error instanceof OxygenError && error.code === "object_storage_not_configured") {
18118
+ return null;
18119
+ }
18120
+ throw error;
18121
+ }
18122
+ };
18123
+ let presigned = await requestPresign();
18124
+ if (!presigned)
18079
18125
  return null;
18080
18126
  // Create the table for --create (or resolve the existing ref) before the
18081
18127
  // upload so a presign success always pairs with a real target.
18082
18128
  const target = preparedTarget ?? await prepareImportTarget(table, options, parsedRows);
18083
- const controller = new AbortController();
18084
- const timer = setTimeout(() => controller.abort(), 300_000);
18085
- let putResponse;
18086
- try {
18087
- putResponse = await fetch(uploadUrl, {
18088
- method: "PUT",
18089
- body: new Uint8Array(fileBuffer),
18090
- signal: controller.signal,
18091
- });
18092
- }
18093
- finally {
18094
- clearTimeout(timer);
18129
+ let putResponse = await putImportFile(presigned, fileBuffer, contentType);
18130
+ // A direct-upload 403 most commonly means a stale/mismatched signature.
18131
+ // Refresh once under the same client trace so a transient presign never
18132
+ // strands a durable import, while persistent policy failures stay explicit.
18133
+ if (putResponse.status === 403) {
18134
+ const refreshed = await requestPresign();
18135
+ if (refreshed) {
18136
+ presigned = refreshed;
18137
+ putResponse = await putImportFile(presigned, fileBuffer, contentType);
18138
+ }
18095
18139
  }
18096
18140
  if (!putResponse.ok) {
18097
- throw new OxygenError("import_upload_failed", `Uploading the import file to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
18141
+ throw new OxygenError("import_upload_failed", `Uploading the import file to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status, trace_id: traceId }, exitCode: 1 });
18098
18142
  }
18143
+ const storageKey = readRecordString(presigned, "storage_key");
18144
+ const storageBucket = readRecordString(presigned, "storage_bucket");
18145
+ const storageProvider = readRecordString(presigned, "storage_provider") ?? "s3";
18146
+ if (!storageKey)
18147
+ return null;
18099
18148
  const result = await requestOxygen("/api/cli/tables/import-staged", {
18100
18149
  method: "POST",
18101
18150
  timeoutMs: 120_000,
@@ -18109,6 +18158,10 @@ table, options, format, parsedRows, context, preparedTarget = null) {
18109
18158
  byte_length: fileBuffer.byteLength,
18110
18159
  sha256: context.sourceHash,
18111
18160
  row_count: parsedRows.length,
18161
+ batch_size: context.batchSize,
18162
+ ...(readPositiveInt(options.maxConcurrency)
18163
+ ? { max_concurrency: readPositiveInt(options.maxConcurrency) }
18164
+ : {}),
18112
18165
  ...(target.upsertKey ? { upsert_key: target.upsertKey } : {}),
18113
18166
  ...(target.sourceKeyMap ? { source_key_map: target.sourceKeyMap } : {}),
18114
18167
  },
@@ -18120,10 +18173,40 @@ table, options, format, parsedRows, context, preparedTarget = null) {
18120
18173
  ...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
18121
18174
  background: true,
18122
18175
  autoBackground: context.autoBackground,
18123
- import_engine: "bulk_file_v1",
18176
+ import_engine: "chunked_file_v2",
18124
18177
  storage_provider: storageProvider,
18125
18178
  };
18126
18179
  }
18180
+ async function putImportFile(presigned, fileBuffer, contentType) {
18181
+ const uploadUrl = readRecordString(presigned, "upload_url");
18182
+ if (!uploadUrl)
18183
+ return new Response(null, { status: 500 });
18184
+ const controller = new AbortController();
18185
+ const timer = setTimeout(() => controller.abort(), 300_000);
18186
+ try {
18187
+ return await fetch(uploadUrl, {
18188
+ method: "PUT",
18189
+ headers: {
18190
+ "content-type": contentType,
18191
+ "content-length": String(fileBuffer.byteLength),
18192
+ },
18193
+ body: new Uint8Array(fileBuffer),
18194
+ signal: controller.signal,
18195
+ });
18196
+ }
18197
+ finally {
18198
+ clearTimeout(timer);
18199
+ }
18200
+ }
18201
+ function importFileContentType(format) {
18202
+ if (format === "csv")
18203
+ return "text/csv";
18204
+ if (format === "xlsx")
18205
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
18206
+ if (format === "jsonl")
18207
+ return "application/x-ndjson";
18208
+ return "application/json";
18209
+ }
18127
18210
  async function enqueueImportFile(table, options, format, batchSize, context, preparedTarget = null) {
18128
18211
  if (options.create && table) {
18129
18212
  throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
@@ -18643,6 +18726,7 @@ options) {
18643
18726
  throw new OxygenError("invalid_input", "--file is required.", { exitCode: 1 });
18644
18727
  }
18645
18728
  const batchSize = normalizeImportBatchSize(options.batchSize);
18729
+ const effectiveBatchSize = Math.min(batchSize, SAFE_IMPORT_WRITE_BATCH_SIZE);
18646
18730
  const raw = readFileSync(resolve(path), "utf8");
18647
18731
  const bundle = parseBundleFile(raw);
18648
18732
  const columns = bundle.columns.map(bundleColumnToCreateInput);
@@ -18721,13 +18805,13 @@ options) {
18721
18805
  file: path,
18722
18806
  tableId: newTableId,
18723
18807
  key: upsertKey ?? null,
18724
- batchSize,
18808
+ batchSize: effectiveBatchSize,
18725
18809
  });
18726
18810
  let processed = 0;
18727
18811
  let insertedCount = 0;
18728
18812
  let updatedCount = 0;
18729
- for (let offset = 0; offset < stagedRows.length; offset += batchSize) {
18730
- const batch = stagedRows.slice(offset, offset + batchSize);
18813
+ for (let offset = 0; offset < stagedRows.length; offset += effectiveBatchSize) {
18814
+ const batch = stagedRows.slice(offset, offset + effectiveBatchSize);
18731
18815
  if (batch.length === 0)
18732
18816
  continue;
18733
18817
  try {
package/dist/runtime.js CHANGED
@@ -43,7 +43,7 @@ export function resolveCliUpdateGuidance(endpoint, env = process.env, argv = pro
43
43
  return {
44
44
  binaryName,
45
45
  channel: "dev",
46
- warningInstruction: "Rebuild `oxygen-dev` from the latest dev branch (`npm run install-dev-cli`) before using operational commands.",
46
+ warningInstruction: "Rebuild `oxygen-dev` from the latest dev branch (`npm run install-dev-cli`) to pick up the latest behavior.",
47
47
  failureInstruction: "Rebuild `oxygen-dev` from the latest dev branch (`npm run install-dev-cli`) before using this command.",
48
48
  details: {
49
49
  cli_update_instruction: "Rebuild `oxygen-dev` from the latest dev branch (`npm run install-dev-cli`).",
@@ -67,7 +67,7 @@ export function resolveCliUpdateGuidance(endpoint, env = process.env, argv = pro
67
67
  return {
68
68
  binaryName,
69
69
  channel: "npm",
70
- warningInstruction: "Run `oxygen update` before using operational commands.",
70
+ warningInstruction: "Run `oxygen update` to pick up the latest behavior.",
71
71
  failureInstruction: "Run `oxygen update` before using this command.",
72
72
  details: {
73
73
  cli_update_command: "oxygen update",
@@ -426,15 +426,15 @@ export const OXYGEN_CAPABILITY_ROUTES = [
426
426
  id: "connected-linkedin",
427
427
  layer: "External",
428
428
  primitive: null,
429
- owns: "The workspace LinkedIn account network/private data and connected-account writes used by Signals, Messages, Sequences, and Publishing.",
429
+ owns: "The workspace LinkedIn account network/private data, relationship context such as mutual connections, and connected-account writes used by Signals, Messages, Sequences, and Publishing.",
430
430
  notFor: "Ordinary public or third-party LinkedIn research; use the cookieless native scraper.",
431
- execution: "Use the owning primitive gateway; the connected account provides identity and transport, not a competing product runtime.",
431
+ execution: "For mutual/shared connection counts, resolve the connected account with `oxygen senders list --json`, inspect `oxygen tools get linkedin.users_get --json`, then run `oxygen tools run linkedin.users_get --input-json '{\"identifier\":\"<profile-url>\",\"account_id\":\"<connected-account-id>\",\"notify\":false}' --mode live --json`. This read is non-notifying and no-bill. An unavailable count is explicit and never means zero; retrying the same account/profile pair is not expected to reveal a provider-hidden count. Otherwise use the owning primitive gateway; the connected account provides identity and transport, not a competing product runtime.",
432
432
  posture: "mixed",
433
433
  gatewayTools: ["oxygen_senders_list", "oxygen_linkedin_connections_import", "oxygen_inbox_list"],
434
434
  gatewayCommands: ["senders list", "connections import", "inbox list"],
435
435
  skills: ["oxygen-linkedin-marketing", "oxygen-sequencer", "oxygen-unibox"],
436
436
  endpointSections: ["linkedin"],
437
- intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
437
+ intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
438
438
  },
439
439
  {
440
440
  id: "connected-whatsapp",
@@ -540,6 +540,13 @@ function explicitCapabilityIntent(query) {
540
540
  // published post still routes to Posts.
541
541
  if (isSecondPartyDecisionIntent(query))
542
542
  return ROUTE_BY_ID.get("collaboration") ?? null;
543
+ // A mutual/shared-connection count is relationship context that only a
544
+ // connected account can expose. Route it before the generic public-profile
545
+ // rule, which otherwise sees "LinkedIn profile" and sends the user to the
546
+ // cookieless research catalog that cannot answer this question.
547
+ if (isMutualLinkedInConnectionsIntent(query)) {
548
+ return ROUTE_BY_ID.get("connected-linkedin") ?? null;
549
+ }
543
550
  // A deferred acquisition motion starts with the public-data owner. This keeps
544
551
  // "find/qualify now, contact later" from skipping straight to the final write
545
552
  // step; the recommendation still hands the eventual initiation to Sequences.
@@ -617,6 +624,12 @@ function highestScoringRoute(query) {
617
624
  return best?.card ?? null;
618
625
  }
619
626
  function recommendationsFor(card, query) {
627
+ if (card.id === "connected-linkedin" && isMutualLinkedInConnectionsIntent(query)) {
628
+ return {
629
+ tools: ["oxygen_tools_get", "oxygen_tools_run_live", "oxygen_senders_list"],
630
+ commands: ["tools get", "tools run", "senders list"],
631
+ };
632
+ }
620
633
  if (card.id === "sending-infrastructure" && isMailboxDeleteIntent(query)) {
621
634
  return {
622
635
  tools: [
@@ -826,6 +839,12 @@ function recommendationsFor(card, query) {
826
839
  }
827
840
  return { tools: [...card.gatewayTools], commands: [...card.gatewayCommands] };
828
841
  }
842
+ function isMutualLinkedInConnectionsIntent(query) {
843
+ const linkedInContext = /\blinkedin\b|\bprofiles?\b/.test(query);
844
+ const mutualContext = /\b(mutual|shared|in common)\b.{0,48}\b(connections?|relations?)\b/.test(query)
845
+ || /\b(connections?|relations?)\b.{0,48}\b(mutual|shared|in common)\b/.test(query);
846
+ return linkedInContext && mutualContext;
847
+ }
829
848
  function isMailboxOnboardingIntent(query) {
830
849
  const operation = /\b(import|migrat\w*|onboard|register|upload|bring|connect existing|add existing)\b/.test(query);
831
850
  const mailboxScope = /\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?|sending pool)\b/.test(query);
@@ -1,5 +1,5 @@
1
1
  /** Normalized failure category, derived from SQLSTATE first, then errno/message. */
2
- export type SqlErrorCause = "connect_timeout" | "connection" | "statement_timeout" | "admin_shutdown" | "too_many_connections" | "insufficient_resources" | "auth" | "schema_drift" | "data_exception" | "integrity_constraint" | "serialization" | "deadlock" | "transaction_rollback" | "read_only" | "syntax_or_access" | "internal" | "unknown";
2
+ export type SqlErrorCause = "connect_timeout" | "connection" | "lock_timeout" | "statement_timeout" | "admin_shutdown" | "too_many_connections" | "insufficient_resources" | "auth" | "schema_drift" | "data_exception" | "integrity_constraint" | "serialization" | "deadlock" | "transaction_rollback" | "read_only" | "syntax_or_access" | "internal" | "unknown";
3
3
  export type SqlErrorAttribution = {
4
4
  /** Postgres SQLSTATE (5 chars) when present; null for connection-level errnos. */
5
5
  pgCode: string | null;
@@ -53,6 +53,13 @@ export declare function redactSqlParameters(text: string): string;
53
53
  * 40001/40P01 knowledge lives in one place. Never throws.
54
54
  */
55
55
  export declare function isRetryableConcurrencyError(error?: unknown): boolean;
56
+ /**
57
+ * True when a raw pg or drizzle-wrapped database failure is transient and a
58
+ * later durable-work attempt can safely try the transaction again. Unlike the
59
+ * narrower concurrency helper above, this includes lock/statement timeouts,
60
+ * connection failures, capacity pressure, and a temporarily read-only primary.
61
+ */
62
+ export declare function isRetryableSqlError(error?: unknown): boolean;
56
63
  /**
57
64
  * True when a write lost a concurrency race and is safe to re-read / retry: a
58
65
  * serialization (40001) or deadlock (40P01) transaction abort, OR the XX000
@@ -55,6 +55,7 @@ const CONNECTION_MESSAGE = /connection terminated|connection reset|socket hang u
55
55
  const TRANSIENT_CAUSES = new Set([
56
56
  "connect_timeout",
57
57
  "connection",
58
+ "lock_timeout",
58
59
  "statement_timeout",
59
60
  "admin_shutdown",
60
61
  "too_many_connections",
@@ -170,6 +171,15 @@ export function isRetryableConcurrencyError(error) {
170
171
  const cause = describeSqlError(error)?.cause;
171
172
  return cause === "serialization" || cause === "deadlock";
172
173
  }
174
+ /**
175
+ * True when a raw pg or drizzle-wrapped database failure is transient and a
176
+ * later durable-work attempt can safely try the transaction again. Unlike the
177
+ * narrower concurrency helper above, this includes lock/statement timeouts,
178
+ * connection failures, capacity pressure, and a temporarily read-only primary.
179
+ */
180
+ export function isRetryableSqlError(error) {
181
+ return describeSqlError(error)?.transient === true;
182
+ }
173
183
  /**
174
184
  * True when a write lost a concurrency race and is safe to re-read / retry: a
175
185
  * serialization (40001) or deadlock (40P01) transaction abort, OR the XX000
@@ -217,6 +227,7 @@ export function sqlErrorTelemetryAttributes(error) {
217
227
  // Specific SQLSTATEs that must resolve before the coarser class-prefix lookup
218
228
  // (e.g. 40001 serialization / 40P01 deadlock are more precise than class 40).
219
229
  const EXACT_SQLSTATE_CAUSES = new Map([
230
+ ["55P03", "lock_timeout"],
220
231
  ["57014", "statement_timeout"],
221
232
  ["53300", "too_many_connections"],
222
233
  ["40001", "serialization"],
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.813.1";
1
+ export declare const OXYGEN_VERSION = "1.830.1";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.813.1";
1
+ export const OXYGEN_VERSION = "1.830.1";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.813.1",
3
+ "version": "1.830.1",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",