@oxygen-agent/cli 1.1010.644 → 1.1010.721

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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +1 -1
  3. package/dist/inbox-needs-reply-notice.d.ts +12 -0
  4. package/dist/inbox-needs-reply-notice.js +51 -0
  5. package/dist/index.js +186 -45
  6. package/dist/skills.js +48 -22
  7. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +33 -2
  8. package/node_modules/@oxygen/shared/dist/billing-anchors.js +67 -2
  9. package/node_modules/@oxygen/shared/dist/billing.d.ts +63 -9
  10. package/node_modules/@oxygen/shared/dist/billing.js +96 -14
  11. package/node_modules/@oxygen/shared/dist/capability-discovery.js +11 -1
  12. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
  13. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
  14. package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +25 -0
  15. package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
  16. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +6 -1
  17. package/node_modules/@oxygen/shared/dist/feature-gates.js +7 -1
  18. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -1
  19. package/node_modules/@oxygen/shared/dist/index.js +2 -1
  20. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
  21. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
  22. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
  23. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +118 -0
  24. package/node_modules/@oxygen/shared/dist/plan-band.js +147 -0
  25. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +131 -120
  26. package/node_modules/@oxygen/shared/dist/plan-limits.js +80 -71
  27. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +79 -14
  28. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +61 -12
  29. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +4 -3
  30. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +9 -3
  31. package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
  32. package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
  33. package/node_modules/@oxygen/shared/dist/repricing.d.ts +130 -0
  34. package/node_modules/@oxygen/shared/dist/repricing.js +320 -0
  35. package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +32 -0
  36. package/node_modules/@oxygen/shared/dist/sending-limits.js +49 -0
  37. package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
  38. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +24 -0
  39. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +58 -1
  40. package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +39 -10
  41. package/node_modules/@oxygen/shared/dist/table-capacity.js +68 -4
  42. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
  43. package/node_modules/@oxygen/shared/dist/telemetry.js +36 -2
  44. package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
  45. package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
  46. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  47. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  48. package/package.json +1 -1
  49. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
  50. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
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.1010.644
37
+ Version: 1.1010.721
@@ -68,7 +68,7 @@ const MUTATING_VERBS = new Set([
68
68
  // `support chat` starts or continues a customer-authored Plain Thread. The
69
69
  // conversational noun is still a real external write.
70
70
  "buy", "call", "cancel", "chat", "chat-action", "claim", "clear",
71
- "comment", "configure", "connect", "create", "decide", "delete", "delete-message", "delist", "disable",
71
+ "comment", "configure", "connect", "convert", "create", "decide", "delete", "delete-message", "delist", "disable",
72
72
  "disconnect", "dispatch", "done", "draft", "duplicate", "edit", "emit", "enable", "enroll",
73
73
  // Exact leaf, not a `field` prefix: `support admin field-set` writes a Plain
74
74
  // Thread field, while a future `field-get` would be a read.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A needs-reply inbox list's composition as one terminal line (ADR 0027,
3
+ * dev-only triage). `inbox list --needs-reply` keeps every thread AI triage has
4
+ * no verdict on. In the blind acceptance (2026-09-25) the rows' `triage: null`
5
+ * read as "analysed, no signal", so the agent called the filter leaky and
6
+ * re-triaged about 140 threads by hand. This line says how many rows carry a
7
+ * verdict, how many do not, and how to get one.
8
+ *
9
+ * Returns null unless the envelope carries `needs_reply_counts`, which the API
10
+ * sends only on a needs-reply list, so every other command prints nothing.
11
+ */
12
+ export declare function formatInboxNeedsReplyNotice(data: unknown): string | null;
@@ -0,0 +1,51 @@
1
+ import { isRecord } from "./util.js";
2
+ /**
3
+ * A needs-reply inbox list's composition as one terminal line (ADR 0027,
4
+ * dev-only triage). `inbox list --needs-reply` keeps every thread AI triage has
5
+ * no verdict on. In the blind acceptance (2026-09-25) the rows' `triage: null`
6
+ * read as "analysed, no signal", so the agent called the filter leaky and
7
+ * re-triaged about 140 threads by hand. This line says how many rows carry a
8
+ * verdict, how many do not, and how to get one.
9
+ *
10
+ * Returns null unless the envelope carries `needs_reply_counts`, which the API
11
+ * sends only on a needs-reply list, so every other command prints nothing.
12
+ */
13
+ export function formatInboxNeedsReplyNotice(data) {
14
+ if (!isRecord(data) || !isRecord(data.needs_reply_counts))
15
+ return null;
16
+ const counts = data.needs_reply_counts;
17
+ const count = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
18
+ const total = count(counts.total);
19
+ const judged = count(counts.needs_reply_analysed);
20
+ const notAnalysed = count(counts.not_analysed_included);
21
+ const undecided = count(counts.undecided_included);
22
+ // "kept", not "judged": triages written before needs_reply_decided existed
23
+ // count as analysed even when the model abstained (conversation-triage.ts).
24
+ const line = `Needs reply: ${total} threads. Triage kept ${judged} as needing a reply`
25
+ + `; ${notAnalysed} not analysed yet and ${undecided} undecided stay in because nothing shows they need no reply.`;
26
+ if (notAnalysed + undecided === 0)
27
+ return line;
28
+ const page = pageStatusCounts(data.conversations);
29
+ const onPage = page ? ` This page: ${page.not_analysed} rows triage_status not_analysed, ${page.undecided} undecided.` : "";
30
+ // Undecided threads were analysed and the model abstained: a second paid run
31
+ // on the same message is unlikely to decide, so only not_analysed get the command.
32
+ const abstained = undecided > 0 ? " Undecided threads were analysed and the model abstained; re-analysing is unlikely to change that." : "";
33
+ const analyse = notAnalysed > 0
34
+ ? " Analyse a not_analysed thread now: oxygen inbox analyze <id> --force (add --channel linkedin or whatsapp for a DM; a small paid AI call, managed credits)."
35
+ : "";
36
+ return `${line}${onPage}${abstained}${analyse}`;
37
+ }
38
+ function pageStatusCounts(rows) {
39
+ if (!Array.isArray(rows))
40
+ return null;
41
+ const tally = { not_analysed: 0, undecided: 0 };
42
+ for (const row of rows) {
43
+ if (!isRecord(row))
44
+ continue;
45
+ if (row.triage_status === "not_analysed")
46
+ tally.not_analysed += 1;
47
+ else if (row.triage_status === "undecided")
48
+ tally.undecided += 1;
49
+ }
50
+ return tally;
51
+ }
package/dist/index.js CHANGED
@@ -14,9 +14,10 @@ import { renderPrimaryProviderBoard } from "./admin-primary-providers-render.js"
14
14
  import { registerFunctionsCommands } from "./functions-commands.js";
15
15
  import { applyOxygenHelp, enableCommandSuggestions, unknownCommandHint, unknownOptionHint } from "./help.js";
16
16
  import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
17
- import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
17
+ import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, chunk, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_CLI_JSON_BODY_BYTES, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
18
18
  import { PURCHASABLE_PLAN_KEYS, resolveBasePricingPlan } from "@oxygen/shared/billing";
19
19
  import { TAG_COLORS } from "@oxygen/shared/select-options";
20
+ import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, } from "@oxygen/shared/linkedin-sequences";
20
21
  import { readColumnDecisionFlags } from "./column-decision-options.js";
21
22
  import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
22
23
  import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
@@ -32,6 +33,7 @@ import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJson
32
33
  import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./column-run-notices.js";
33
34
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
34
35
  import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
36
+ import { formatInboxNeedsReplyNotice } from "./inbox-needs-reply-notice.js";
35
37
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
36
38
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
37
39
  import { doctorAgentSkills, getAgentSkill, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, searchAgentSkills, } from "./skills.js";
@@ -313,7 +315,7 @@ const SAFE_IMPORT_WRITE_BATCH_SIZE = 500;
313
315
  // per-request chunk) and the background threshold, so users read 500 as a
314
316
  // per-file cap. Echo the shared row ceiling and the tiered byte ceilings instead
315
317
  // of restating either as a chunk-size constraint.
316
- const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: ${TABLE_IMPORT_ROW_LIMIT.toLocaleString("en-US")} rows on every plan. File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on paid plans.`;
318
+ const IMPORT_FILE_LIMIT_HELP = `Per-file row limit: your plan's rows-per-Table limit, since one file can fill an empty Table (\`oxygen limits show\` lists it for every plan size). File-size limit: ${formatImportFileSizeLimit("free")} on free, ${formatImportFileSizeLimit("starter")} on paid plans.`;
317
319
  const TABLE_ACTION_RUN_WAIT_DEFAULT_TIMEOUT_SECONDS = 600;
318
320
  const TABLE_ACTION_RUN_WAIT_DEFAULT_INTERVAL_SECONDS = 5;
319
321
  // Single-row paid runs are auto-backgrounded server-side; the CLI waits this
@@ -817,6 +819,7 @@ async function handleAsyncAction(command, options, action) {
817
819
  writeAvatarWarning(data);
818
820
  writeCreditsReceipt(data);
819
821
  writeSearchAiFilterTotalsNotice(data);
822
+ writeInboxNeedsReplyNotice(data);
820
823
  }
821
824
  catch (error) {
822
825
  emitCliFailure(command, error);
@@ -1102,6 +1105,11 @@ function writeSearchAiFilterTotalsNotice(data) {
1102
1105
  if (notice)
1103
1106
  process.stderr.write(`${notice}\n`);
1104
1107
  }
1108
+ function writeInboxNeedsReplyNotice(data) {
1109
+ const notice = formatInboxNeedsReplyNotice(data);
1110
+ if (notice)
1111
+ process.stderr.write(`${notice}\n`);
1112
+ }
1105
1113
  function writeAiPromptPreviewNotice(data) {
1106
1114
  const notice = formatAiPromptPreviewNotice(data);
1107
1115
  if (notice)
@@ -7134,7 +7142,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7134
7142
  .description("Free: read the page's follower count and quote the exact credits an order would reserve. No spend, no table; `orderable: false` means no order can be placed yet.")
7135
7143
  .requiredOption("--company <url>", "LinkedIn company page URL, e.g. https://www.linkedin.com/company/hubspot.")
7136
7144
  .option("--records <n>", "How many followers to price. Minimum 100; defaults to 1000.")
7137
- .option("--package <package>", "Basic (default) or Advanced.")
7145
+ .option("--package <package>", "Basic (default): profile, job title, location, employer. Advanced (10x the credits): also a verified work email and region.")
7138
7146
  .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
7139
7147
  .option("--json", "Print a JSON envelope.")
7140
7148
  .action(async (options) => {
@@ -7150,12 +7158,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7150
7158
  }));
7151
7159
  });
7152
7160
  followersCommand.command("order")
7153
- .description("Place the follower order after checking it. With managed credits it requires --max-credits at or above the quoted cost: this buys a real audience and is never approved implicitly. Creates the table now; rows land when ScrapeLi delivers.")
7161
+ .description("Place the follower order after checking it. Requires --approved, and with managed credits --max-credits at or above the quoted cost: this buys a real audience and is never approved implicitly. Creates the table now; rows land when ScrapeLi delivers.")
7162
+ .requiredOption("--approved", "Approve this purchase. Required, as on every command that buys data.")
7154
7163
  .requiredOption("--company <url>", "LinkedIn company page URL whose followers to order.")
7155
7164
  .option("--max-credits <credits>", "Hard credit ceiling for this order. Must be at least the amount `tables followers check` quoted. Not needed with --credential-mode user_api_key.")
7156
7165
  .requiredOption("--request-id <uuid>", "One stable UUID for this order. Reuse it after a timeout; a new key would buy the same audience twice.")
7157
7166
  .option("--records <n>", "How many followers to buy. Minimum 100; defaults to 1000.")
7158
- .option("--package <package>", "Basic (default) or Advanced.")
7167
+ .option("--package <package>", "Basic (default) or Advanced (10x the credits; adds a verified work email and region).")
7159
7168
  .option("--name <name>", "Table name. Defaults to \"<Company> Followers\".")
7160
7169
  .option("--project <project>", "Project id or slug; defaults to General.")
7161
7170
  .option("--credential-mode <mode>", "managed (default): Oxygen credits. user_api_key: your own ScrapeLi key saved in Connections, billed by ScrapeLi.")
@@ -7165,6 +7174,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7165
7174
  method: "POST",
7166
7175
  body: {
7167
7176
  action: "create",
7177
+ approved: true,
7168
7178
  company_url: readOption(options.company),
7169
7179
  ...(options.records !== undefined ? { records: readPositiveNumber(options.records) } : {}),
7170
7180
  ...(readOption(options.package) ? { package: readOption(options.package) } : {}),
@@ -9278,16 +9288,17 @@ Examples:
9278
9288
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key or --capability supplies a default title.")
9279
9289
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
9280
9290
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
9281
- .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web. Enrichment columns always hold a jsonb cell, and --data-type is set for you \u2014 use --capability to seed a ready-to-run one. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
9291
+ .option("--kind <kind>", "Column kind: manual, research (the Web Research Agent), ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web (an AI column cannot search; `columns convert --to agent` turns one into an agent); one research column answers several fields at once when its prompt names them as sections — one row, one read, one charge — so never add a second column for the same page. Enrichment columns always hold a jsonb cell, and --data-type is set for you \u2014 use --capability to seed a ready-to-run one. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
9282
9292
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
9283
9293
  .option("--definition-json <json>", "Optional JSON object with column definition metadata. Required for --kind formula as a flat object: {\"expression\":\"trim(company_name)\"}. Check the expression with `formulas validate` first.")
9284
- .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to search the web per row instead. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
9294
+ .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to make it a Web Research Agent that searches the web per row. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
9285
9295
  .option("--research-query <template>", "Research columns: the per-row web search query, templated like the prompt (e.g. \"{{company_name}} pricing page\"). Omit to derive the query from the row's values and the prompt.")
9286
9296
  .option("--research-domains <csv>", "Research columns: comma-separated domains to search within, e.g. techcrunch.com,sec.gov. Omit to search the whole web.")
9287
9297
  .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
9288
9298
  .option("--research-mode <mode>", "Research columns: how tightly the answer is bound to the sources. Default: summarize and combine what they say. strict: answer only from what a source states verbatim (for figures and identifiers). estimate: reason to a figure from the evidence (for revenue or headcount).")
9289
9299
  .option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25, default 5). More evidence costs no extra search, but a larger prompt.")
9290
9300
  .option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa), or with --research-url the page-fetch provider (firecrawl or exa). Defaults to serper for search and firecrawl for fetch, falling back automatically. Most users should not set this.")
9301
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
9291
9302
  .option("--research-url <column_key>", "Research columns: read the ONE page whose URL is in this column for each row instead of searching the web — a pricing page, a job posting, an event page, a 10-K. The page's content is the only evidence, the answer cites it as its source, and the row costs the fetch plus the model (see --dry-run). A URL ending in .pdf is read by the PDF-capable fetch lane first.")
9292
9303
  .option("--prompt-key <key>", "Add a column template from `oxygen columns catalog` (e.g. pricing_plan_summary_v1 reads a pricing-page URL column and returns plan count, price range and enterprise plan; company_founders_v1 reads a domain column and returns the founders with sources; person_skill_set_v1 reads the person_enrich payload column with --input profile=person_enrich). Materializes the template's prompt, output schema and grounding and sets its kind (ai, research, formula, or search — a managed web search whose cell is a page URL). Bind each declared input with --input <name>=<column_key>.")
9293
9304
  .option("--input-mapping <json>", "JSON alternative to --input for --prompt-key: an object mapping template input names to columns, e.g. '{\"url\":{\"column\":\"pricing_url\"}}' (a bare column-key string or {\"literal\": ...} also works). Copy templates (email_draft_v1, subject_line_variants_v1, ...) reject mappings built only from manual name/title/company columns — ground them on a research or enrichment column, or write a freeform prompt with --prompt instead.")
@@ -9532,6 +9543,9 @@ Examples:
9532
9543
  const body = { table, column };
9533
9544
  if (options.promptKey)
9534
9545
  body.prompt_key = options.promptKey;
9546
+ const researchDepth = readResearchDepthOption(options.researchDepth);
9547
+ if (researchDepth)
9548
+ body.research_depth = researchDepth;
9535
9549
  if (options.inputMapping) {
9536
9550
  body.input_mapping = parseJsonObject(options.inputMapping);
9537
9551
  }
@@ -9749,6 +9763,7 @@ Examples:
9749
9763
  .option("--research-mode <mode>", "Research columns: strict (answer only from the sources) or estimate (reason to a figure from them).")
9750
9764
  .option("--research-results <n>", "Research columns: how many search results to ground each row on (1-25).")
9751
9765
  .option("--research-engine <engine>", "Research columns: pin the search provider (serper or exa).")
9766
+ .option("--research-depth <depth>", RESEARCH_DEPTH_OPTION_HELP)
9752
9767
  // Declaring BOTH forms leaves the default undefined (Commander only
9753
9768
  // defaults to true when --no- is declared alone), so an update that
9754
9769
  // doesn't mention visibility leaves it untouched.
@@ -9797,11 +9812,16 @@ Examples:
9797
9812
  definition = { ...(definition ?? {}), inputMapping: parseJsonObject(inputMapping) };
9798
9813
  }
9799
9814
  await assertColumnDefinitionReferences(table, definition);
9815
+ // Sent beside `definition`, never inside it: the server merges the
9816
+ // depth into the column's STORED search block, so it cannot drop a
9817
+ // query template or domains the way a partial webSearch would.
9818
+ const researchDepth = readResearchDepthOption(options.researchDepth);
9800
9819
  const updated = await requestOxygen("/api/cli/tables/columns/update", {
9801
9820
  method: "POST",
9802
9821
  body: {
9803
9822
  table,
9804
9823
  column,
9824
+ ...(researchDepth ? { research_depth: researchDepth } : {}),
9805
9825
  ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
9806
9826
  ...(readOption(options.semanticType) ? { semantic_type: readOption(options.semanticType) } : {}),
9807
9827
  ...(definition ? { definition } : {}),
@@ -9818,6 +9838,37 @@ Examples:
9818
9838
  writeColumnReferenceNotices(updated);
9819
9839
  return updated;
9820
9840
  });
9841
+ }))
9842
+ .addCommand(new Command("convert")
9843
+ .description("Convert a column between an AI column and a Web Research Agent column (--to agent or --to ai), or switch an agent to Keyword search and back. Keeps its key, label, position, prompt, inputs and model tier, and clears its results, so it needs --yes; without it this prints what would change.")
9844
+ .argument("<table>", "Table id or slug.")
9845
+ .argument("<column>", "Column id or key.")
9846
+ .requiredOption("--to <target>", "agent (Web Research Agent), ai (AI column, no web access) or keyword (Keyword search).")
9847
+ .option("--prompt <text>", "Keyword search to agent only: the question the agent answers for each row.")
9848
+ .option("--yes", "Convert, clearing the column's current results.")
9849
+ .option("--json", "Print a JSON envelope.")
9850
+ .action(async (table, column, options) => {
9851
+ await handleAsyncAction("columns convert", options, async () => {
9852
+ const to = readOption(options.to)?.toLowerCase() ?? "";
9853
+ if (!["agent", "ai", "keyword"].includes(to)) {
9854
+ throw new OxygenError("invalid_request", `--to must be agent, ai or keyword (got ${to || "nothing"}).`, { exitCode: 1 });
9855
+ }
9856
+ const prompt = readOption(options.prompt);
9857
+ const body = { table, column, to, ...(prompt ? { prompt } : {}) };
9858
+ if (!options.yes) {
9859
+ const plan = await requestOxygen("/api/cli/tables/columns/convert", {
9860
+ method: "POST",
9861
+ body: { ...body, dry_run: true },
9862
+ });
9863
+ if (plan.effect_outcome === "no_change" && plan.results_cleared !== true)
9864
+ return plan;
9865
+ throw new OxygenError("confirmation_required", `Converting ${column} to ${String(plan.to_label ?? to)} clears its current results. Re-run with --yes to convert.`, { details: plan, exitCode: 2 });
9866
+ }
9867
+ return requestOxygen("/api/cli/tables/columns/convert", {
9868
+ method: "POST",
9869
+ body: { ...body, confirm: true },
9870
+ });
9871
+ });
9821
9872
  }))
9822
9873
  .addCommand(new Command("retype")
9823
9874
  .description("Convert a manual text column to a stronger data type, migrating stored values.")
@@ -10776,9 +10827,9 @@ Examples:
10776
10827
  }));
10777
10828
  program
10778
10829
  .command("limits")
10779
- .description("Plan-tier limits, spend-safety defaults, and storage capacity posture.")
10830
+ .description("Plan-tier limits, spend-safety defaults, per-channel sending limits, and storage capacity posture.")
10780
10831
  .addCommand(new Command("show")
10781
- .description("Show limits and Tables capacity usage: 3M rows/Table, 25M/workspace, and 20/30 GiB PostgreSQL warning/limit; S3 excluded. Recovery: https://oxygen-agent.com/docs/safety/billing.")
10832
+ .description("Show your plan band and its limits beside every plan size's (API rate, storage, spend defaults), the per-channel sending limits every plan shares (emails per mailbox, WhatsApp, LinkedIn, calls), plus Tables capacity usage. Read-only; spends no credits. Recovery: https://oxygen-agent.com/docs/safety/billing.")
10782
10833
  .option("--json", "Print a JSON envelope.")
10783
10834
  .action(async (options) => {
10784
10835
  await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
@@ -10854,7 +10905,7 @@ Examples:
10854
10905
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
10855
10906
  }))
10856
10907
  .addCommand(new Command("commitments")
10857
- .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, and connected LinkedIn account — with unit price, quantity, and next-due date. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
10908
+ .description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, connected LinkedIn, WhatsApp and X account, and rented phone number — with unit price, quantity, and next-due date. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
10858
10909
  .option("--json", "Print a JSON envelope.")
10859
10910
  .action(async (options) => {
10860
10911
  await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
@@ -12383,7 +12434,7 @@ Examples:
12383
12434
  .description("The priced catalog: every provider operation and every OXYGEN column (enrichment bundles, waterfalls, templates, Functions) with its credit cost per row. Start with `tools search --for-table <table>` to see what a table's own columns can be enriched with and what each costs, before adding anything.")
12384
12435
  .addCommand(new Command("search")
12385
12436
  .description("Search the priced catalog (0 credits): provider operations in `tools`, and OXYGEN's own columns in `native_columns` (enrichment bundles, waterfalls, templates, Functions), each with `price_label`, `estimated_credits_per_row`, `pricing_kind` and the exact `cli_add` command. With --for-table <table> it reads that table's columns and returns `suggestions`: the enrichments those columns already support, priced per row. Hydrate one provider operation with tools get. A response listing partial_sources means an optional catalog source timed out and totals may be understated — rerun for the complete catalog.")
12386
- .argument("[query]", "Search text, e.g. `web` for the Web search column and its modes (each priced in `modes`), or `email`.")
12437
+ .argument("[query]", "Search text, e.g. `web` for the Web Research Agent column and its modes (each priced in `modes`), or `email`.")
12387
12438
  .option("--verbosity <verbosity>", "minimal, summary, or full. Defaults to minimal; hydrate one result with tools get.")
12388
12439
  .option("--terse", "Alias for --verbosity minimal.")
12389
12440
  .option("--all", "Return the complete matching catalog — with no query that is every provider operation (7,000+ rows). Explicit because the default is bounded to 10; with --for-table the table-scoped answer is `suggestions`, which --all does not change.")
@@ -12580,7 +12631,7 @@ Examples:
12580
12631
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12581
12632
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
12582
12633
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
12583
- .option("--allow-premium-lanes", "Opt in to managed lanes billing over 500 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 1225cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12634
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12584
12635
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12585
12636
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
12586
12637
  .option("--all", "Estimate all rows.")
@@ -12615,7 +12666,7 @@ Examples:
12615
12666
  .option("--email-pattern-validation <mode>", "Work-email pattern pre-step: leadmagic_valid_only or disabled.")
12616
12667
  .option("--phone-waterfall-profile <profile>", "Phone waterfall profile: auto (input-aware), linkedin_url, email, or name_domain. Auto picks the cheapest cost-ordered provider set for each row's inputs (mobile match rate ~30-60%).")
12617
12668
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone (adds phone_line_type + phone_carrier; filter phone_line_type=mobile for mobile-only).")
12618
- .option("--allow-premium-lanes", "Opt in to managed lanes billing over 500 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 1225cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12669
+ .option("--allow-premium-lanes", "Opt in to managed lanes billing over 50 credits per call. Only linkedin_url has one (LeadMagic email_to_profile, 122.5cr); it is off by default so an unattended run can't bill-shock. No effect on mobile_phone, work_email or verify_email.")
12619
12670
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
12620
12671
  .option("--limit <n>", "Rows to queue.")
12621
12672
  .option("--all", "Queue all rows.")
@@ -13293,39 +13344,47 @@ Examples:
13293
13344
  }));
13294
13345
  })))
13295
13346
  .addCommand(new Command("limits")
13296
- .description("View and adjust per-account daily action limits and the daily-reset timezone.")
13347
+ .description("View and adjust per-account daily action limits, working hours (the times LinkedIn actions may run) and the timezone they use.")
13297
13348
  .option("--json", "Print a JSON envelope.")
13298
13349
  .addCommand(new Command("get")
13299
- .description("Show current limits, overrides, daily-reset timezone, defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
13350
+ .description("Show current limits, overrides, working hours (timezone, days, start, end), defaults, and safe maximums for an account. <id> accepts a sender account id, connection id, or Unipile account id.")
13300
13351
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13301
13352
  .option("--json", "Print a JSON envelope.")
13302
13353
  .action(async (id, options) => {
13303
13354
  await handleAsyncAction("senders limits get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/limits`));
13304
13355
  }))
13305
13356
  .addCommand(new Command("set")
13306
- .description("Adjust per-account daily action limits and the daily-reset timezone. Defaults: 20 invites/day, 100 invites/week, 40 messages/day, 5 comments/day. Values are clamped to hard maximums (30 invites/day, 150 invites/week, 40 messages/day, 20 comments/day, 300 human Unibox sends/hour); higher caps raise the risk of a LinkedIn restriction. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. Send windows (time of day) are set per sequence in the campaign schedule, not per account. <id> accepts a sender account id, connection id, or Unipile account id.")
13357
+ .description("Adjust per-account action limits and working hours. LinkedIn actions only run inside the account's working hours (default 07:00-22:00 every day, in the account's timezone); work due outside them is deferred to the next window, not failed. Set them with --timezone, --working-days, --working-hours-start and --working-hours-end; flags you omit keep their current value. A sequence's own schedule can narrow the window for its LinkedIn steps, never widen it; email steps follow the sequence's email send window instead. Every limit flag shows its default and hard maximum; values above the maximum are clamped, and higher caps raise the risk of a LinkedIn restriction. Each action has its own cap (InMails per day and per rolling 30 days, profile views, profile lookups, endorsements, invitation withdrawals, posts), and every send except posts also counts toward --total-actions-per-day. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. A withdrawn invitation cannot be re-sent to the same person for 21 days. Own-feed posts and your own Unibox replies ignore working hours. A refused action names its reason (outside_working_hours defers it; linkedin_rate_limited names the limit and resets_at). Changing limits is free. <id> accepts a sender account id, connection id, or Unipile account id.")
13307
13358
  .argument("<id>", "Sender account id, connection id, or Unipile account id.")
13308
- .option("--invites-per-day <n>", "Daily LinkedIn connection invites cap.")
13309
- .option("--invites-per-week <n>", "Weekly LinkedIn connection invites cap.")
13310
- .option("--messages-per-day <n>", "Daily direct messages cap.")
13311
- .option("--inmails-per-day <n>", "Daily InMail cap.")
13312
- .option("--profile-views-per-day <n>", "Daily profile views cap.")
13313
- .option("--follows-per-day <n>", "Daily follows cap.")
13314
- .option("--likes-per-day <n>", "Daily likes cap.")
13315
- .option("--comments-per-day <n>", "Daily comments cap.")
13316
- .option("--total-actions-per-day <n>", "Daily cap across all send/action types.")
13317
- .option("--relations-reads-per-day <n>", "Daily cap on relations/connections list reads (scrape protection).")
13318
- .option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
13319
- .option("--searches-per-day <n>", "Daily cap on LinkedIn search executions.")
13320
- .option("--sales-nav-search-results-per-day <n>", "Daily cap on Sales Navigator search result rows fetched.")
13321
- .option("--api-reads-per-day <n>", "Daily cap on all other LinkedIn API reads.")
13322
- .option("--total-reads-per-day <n>", "Daily cap across all read units.")
13323
- .option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
13324
- .option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
13325
- .option("--interactive-min-spacing-seconds <n>", "Minimum seconds between human-sent Unibox actions.")
13326
- .option("--interactive-spacing-jitter-seconds <n>", "Random jitter seconds added to human-sent Unibox action spacing.")
13327
- .option("--interactive-sends-per-hour <n>", "Hourly cap on human-sent Unibox actions.")
13328
- .option("--timezone <tz>", "IANA timezone the daily action counters reset in, e.g. Europe/Berlin. Defaults to the zone of the country chosen at connect.")
13359
+ .option("--invites-per-day <n>", linkedInLimitHelp("invites_per_day", "Daily LinkedIn connection invites cap"))
13360
+ .option("--invites-per-week <n>", linkedInLimitHelp("invites_per_week", "Weekly LinkedIn connection invites cap"))
13361
+ .option("--messages-per-day <n>", linkedInLimitHelp("messages_per_day", "Daily direct messages cap"))
13362
+ .option("--inmails-per-day <n>", linkedInLimitHelp("inmails_per_day", "Daily InMail cap"))
13363
+ .option("--inmails-per-month <n>", linkedInLimitHelp("inmails_per_month", "InMail cap over a rolling 30 days"))
13364
+ .option("--profile-views-per-day <n>", linkedInLimitHelp("profile_views_per_day", "Daily profile views cap; the member is notified"))
13365
+ .option("--profile-lookups-per-day <n>", linkedInLimitHelp("profile_lookups_per_day", "Daily invisible profile and company lookups cap"))
13366
+ .option("--endorsements-per-day <n>", linkedInLimitHelp("endorsements_per_day", "Daily skill endorsements cap"))
13367
+ .option("--withdrawals-per-day <n>", linkedInLimitHelp("withdrawals_per_day", "Daily cap on withdrawing sent invitations; reading the sent list uses the read budgets"))
13368
+ .option("--posts-per-day <n>", linkedInLimitHelp("posts_per_day", "Daily posts to the account's own feed, from every caller; not counted in the total"))
13369
+ .option("--follows-per-day <n>", linkedInLimitHelp("follows_per_day", "Daily follows cap"))
13370
+ .option("--likes-per-day <n>", linkedInLimitHelp("likes_per_day", "Daily likes cap"))
13371
+ .option("--comments-per-day <n>", linkedInLimitHelp("comments_per_day", "Daily comments cap"))
13372
+ .option("--total-actions-per-day <n>", linkedInLimitHelp("total_actions_per_day", "Daily cap across all send/action types except posts"))
13373
+ .option("--relations-reads-per-day <n>", linkedInLimitHelp("relations_reads_per_day", "Daily cap on relations/connections list reads, the scrape ban vector"))
13374
+ .option("--messages-reads-per-day <n>", linkedInLimitHelp("messages_reads_per_day", "Daily cap on chat and message-history reads"))
13375
+ .option("--searches-per-day <n>", linkedInLimitHelp("searches_per_day", "Daily cap on LinkedIn search executions"))
13376
+ .option("--sales-nav-search-results-per-day <n>", linkedInLimitHelp("sales_nav_search_results_per_day", "Daily cap on Sales Navigator search result rows fetched"))
13377
+ .option("--api-reads-per-day <n>", linkedInLimitHelp("api_reads_per_day", "Daily cap on all other LinkedIn API reads"))
13378
+ .option("--total-reads-per-day <n>", linkedInLimitHelp("total_reads_per_day", "Daily cap across all read units"))
13379
+ .option("--min-spacing-seconds <n>", linkedInLimitHelp("min_action_spacing_seconds", "Minimum seconds between actions"))
13380
+ .option("--spacing-jitter-seconds <n>", linkedInLimitHelp("action_spacing_jitter_seconds", "Random jitter seconds added to action spacing"))
13381
+ .option("--interactive-min-spacing-seconds <n>", linkedInLimitHelp("interactive_min_spacing_seconds", "Minimum seconds between human-sent Unibox actions"))
13382
+ .option("--interactive-spacing-jitter-seconds <n>", linkedInLimitHelp("interactive_spacing_jitter_seconds", "Random jitter seconds added to human-sent Unibox action spacing"))
13383
+ .option("--interactive-sends-per-hour <n>", linkedInLimitHelp("interactive_sends_per_hour", "Hourly cap on human-sent Unibox actions"))
13384
+ .option("--timezone <tz>", "IANA timezone for the working hours and the daily counter reset, e.g. Europe/Berlin. Defaults to the zone of the country chosen at connect.")
13385
+ .option("--working-days <days>", "Days LinkedIn actions may run: mon-fri, mon,wed,fri, or ISO numbers 1-7 (1 = Monday). A weekday-only window leaves a visible two-day gap every week.")
13386
+ .option("--working-hours-start <HH:MM>", "Local time working hours start, e.g. 09:00.")
13387
+ .option("--working-hours-end <HH:MM>", "Local time working hours end (exclusive), e.g. 18:00. Must be after the start.")
13329
13388
  .option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
13330
13389
  .option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
13331
13390
  .option("--read-warmup-disable", "Turn off the READ warm-up ramp for this account. Network capture (`oxygen linkedin network setup`) eases a newly armed account in at 5 \u2192 10 \u2192 20 reads a day over two weeks; this runs it at the full read budget immediately. Separate from --warmup-disable, which governs invites and messages.")
@@ -13688,7 +13747,7 @@ Examples:
13688
13747
  await handleAsyncAction("posts reactions", options, () => requestOxygen(`/api/cli/linkedin/posts/reactions?${buildPostEngagementQuery(options)}`));
13689
13748
  }))
13690
13749
  .addCommand(new Command("create")
13691
- .description("Publish a LinkedIn post from the connected account. A REAL public write, so it prints a preview by default and only publishes with --approved. Counts against the sender account's daily action quota. (Company-page posting is not supported — Unipile exposes it only via the fragile raw route.)")
13750
+ .description(`Publish a LinkedIn post from the connected account. A REAL public write, so it prints a preview by default and only publishes with --approved. Counts against the account's posts_per_day cap (default ${LINKEDIN_SENDER_LIMIT_DEFAULTS.posts_per_day}, max ${LINKEDIN_SENDER_LIMIT_MAXIMUMS.posts_per_day}), not the daily action total, and is exempt from working hours and action spacing. (Company-page posting is not supported — Unipile exposes it only via the fragile raw route.)`)
13692
13751
  .option("--text <text>", "Post body text.")
13693
13752
  .option("--text-file <path>", "Read the post body from a file (alternative to --text).")
13694
13753
  .option("--account <ref>", "Sender account to post from. Omit for the org default.")
@@ -14355,7 +14414,7 @@ Examples:
14355
14414
  // Hidden while the AI inbox triage is dev-only (ADR 0027): production
14356
14415
  // help must not offer a flag its API refuses. Unhide with the flag's
14357
14416
  // production rollout.
14358
- .addOption(new Option("--needs-reply", "Narrower than --unanswered: unanswered threads minus those AI inbox triage judged bulk (newsletters, notifications, mass pitches) or needing no answer. Threads not yet triaged stay in. Rows then carry `triage` in --json. Ignored while --search is set. Only where AI inbox triage is enabled; elsewhere the API answers needs_reply_unavailable.").hideHelp())
14417
+ .addOption(new Option("--needs-reply", "Narrower than --unanswered: unanswered threads minus those AI inbox triage judged bulk (newsletters, notifications, mass pitches) or needing no answer. Threads not yet analysed stay in: each row carries `triage_status` (analysed, undecided or not_analysed) next to `triage`, and `needs_reply_counts` sizes the whole list. `inbox analyze <id> --force` analyses one now. Ignored while --search is set. Only where AI inbox triage is enabled; elsewhere the API answers needs_reply_unavailable.").hideHelp())
14359
14418
  .option("--responses-only", "Email only: only conversations with an inbound reply (never sent-only threads).")
14360
14419
  .option("--bucket <bucket>", "Email only: primary or others (superseded by --segment).")
14361
14420
  .option("--segment <segment>", "Top tab: primary (everything but the negative status tier), all, or an email-only folder (others, sent, warmup, dmarc).")
@@ -16059,7 +16118,7 @@ Examples:
16059
16118
  });
16060
16119
  })))
16061
16120
  .addCommand(new Command("numbers")
16062
- .description("The org's dialing pool: list | tag | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
16121
+ .description("The org's dialing pool: list | tag | cap | release. Buying is done from the web shop, which prices and previews the recurring order before it is placed.")
16063
16122
  .addCommand(new Command("list")
16064
16123
  .description("List the org's phone numbers with warm-up state, daily cap, and what each one costs per month. Optional --tag narrows to one campaign's numbers.")
16065
16124
  .option("--tag <tags>", "Comma-separated workspace tags — matches phone numbers carrying ANY of these tags (see `oxygen tags list`).")
@@ -16085,6 +16144,24 @@ Examples:
16085
16144
  body: { tags: splitCommaList(options.tags) },
16086
16145
  }));
16087
16146
  }))
16147
+ .addCommand(new Command("cap")
16148
+ .description("View and adjust one number's daily dial cap.")
16149
+ .addCommand(new Command("set")
16150
+ .description("Set one phone number's daily dial cap (dials per day, 1-300; new numbers get 100). A warming number still dials at its ramp's ceiling until it has warmed. Consumes no credits.")
16151
+ .argument("<number>", "Phone number in E.164 (e.g. +14155550142) or its id.")
16152
+ .requiredOption("--daily-cap <n>", "New daily dial cap, a whole number from 1 to 300.")
16153
+ .option("--json", "Print a JSON envelope.")
16154
+ .action(async (number, options) => {
16155
+ await handleAsyncAction("voice numbers cap set", options, () => {
16156
+ const raw = readOption(options.dailyCap);
16157
+ if (!raw)
16158
+ throw new Error("--daily-cap is required.");
16159
+ return requestOxygen(`/api/cli/voice/numbers/${encodeURIComponent(number)}/cap`, {
16160
+ method: "PATCH",
16161
+ body: { daily_cap: Number(raw) },
16162
+ });
16163
+ });
16164
+ })))
16088
16165
  .addCommand(new Command("release")
16089
16166
  .description("Give a number back to the carrier and stop its monthly charge. IRREVERSIBLE — the number returns to the carrier's pool and can be taken by someone else within minutes; you cannot get it back. Previews by default; pass --approved to actually release.")
16090
16167
  .requiredOption("--number <e164>", "The number to release, e.g. +14155550142.")
@@ -16977,7 +17054,7 @@ Examples:
16977
17054
  });
16978
17055
  }))
16979
17056
  .addCommand(new Command("health")
16980
- .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients that first hard-bounced, sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, the advisory daily cap that warm-up age supports, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the two fleet reads instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool) and signals.capOverRecommendedMailboxes (mailboxes whose configured cap is above the one warm-up supports — per mailbox, compare dailyCap with recommendedDailyCap; the gap is flagged as the cap_exceeds_readiness warning). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
17057
+ .description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations — plus OXYGEN's OWN evidence for the last 7 days, which needs no provider connected: the bounce notifications each mailbox received classified as provider rejection / dead address / mailbox full / delay (a Gmail reputation block shows up as provider_rejected), distinct recipients that first hard-bounced, sequence sends OXYGEN logged (source=sequence only), warm-up day, health score and stop cause, each mailbox's configured daily cap, and the same rollup per sending domain ranked worst-first. Notifications whose body was never stored count as signals.dsn7d.unclassified, which means OXYGEN could not look — not that nothing was wrong. Read health_coverage to see how many mailboxes an external provider has actually scored. Start from the fleet read instead of scanning the mailbox array: warmup.byCause (why warm-up stopped, counted once for the whole pool). Every count is scoped, and the scope is stamped into the payload as signals.window (7d) and signals.sendSource (sequence sends only), on the fleet object and on every mailbox: a 0 means nothing was recorded in that window for that send source, NOT that the fleet is un-blocklisted everywhere. Pure read — 0 credits; never pauses a mailbox or changes a cap.")
16981
17058
  .option("--json", "Print a JSON envelope.")
16982
17059
  .action(async (options) => {
16983
17060
  await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
@@ -17133,7 +17210,7 @@ Examples:
17133
17210
  .addCommand(new Command("cap")
17134
17211
  .description("View and adjust a single mailbox's daily send cap (throttle a problem inbox or ramp a newly warmed one without pausing it).")
17135
17212
  .addCommand(new Command("set")
17136
- .description("Set one sending mailbox's daily send cap (sends per day). --daily-cap must be a positive whole number. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17213
+ .description("Set one sending mailbox's daily send cap (sends per day; new mailboxes get 25). --daily-cap must be a positive whole number; `oxygen limits show` reports the maximum in force. A new mailbox still sends 5, 10, then 20 a day while it warms up. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
17137
17214
  .argument("<mailbox>", "Mailbox id or email address.")
17138
17215
  .requiredOption("--daily-cap <n>", "New daily send cap (a positive whole number of sends per day).")
17139
17216
  .option("--json", "Print a JSON envelope.")
@@ -20517,6 +20594,18 @@ function applyAiColumnConfig(definition, options) {
20517
20594
  // and let the server reject it. `research-engine-roster.test.ts` now asserts
20518
20595
  // these against the tenant-db unions so the next stale rebase fails the gate
20519
20596
  // instead of reaching a customer.
20597
+ const RESEARCH_DEPTHS = new Set(["quick", "standard", "deep"]);
20598
+ const RESEARCH_DEPTH_OPTION_HELP = "Research columns: how hard each row searches. quick: one search and one page read (about 2.1 credits/row on the default tier; --reasoning-level applies to quick only). standard: the agent plans a few searches and page reads on Oxygen Fast (about 5.5; the default for new columns). deep: more searches and subpages on Oxygen Balanced (about 10). Standard and deep cost the same whatever --reasoning-level says. Each row reserves a higher maximum; `columns run --dry-run` quotes this column's typical and max.";
20599
+ /** `--research-depth`, validated before the round trip; null when not passed. */
20600
+ function readResearchDepthOption(value) {
20601
+ const depth = readOption(value)?.toLowerCase();
20602
+ if (!depth)
20603
+ return null;
20604
+ if (!RESEARCH_DEPTHS.has(depth)) {
20605
+ throw new OxygenError("invalid_request", `--research-depth must be quick, standard or deep (got ${depth}).`, { exitCode: 1 });
20606
+ }
20607
+ return depth;
20608
+ }
20520
20609
  const RESEARCH_ENGINES = new Set(["serper", "exa"]);
20521
20610
  const RESEARCH_FETCH_ENGINES = new Set(["firecrawl", "exa"]);
20522
20611
  const RESEARCH_MODES = new Set(["strict", "estimate"]);
@@ -28191,6 +28280,10 @@ table, options) {
28191
28280
  ...(options.onlyMissing ? { only_missing: true } : {}),
28192
28281
  };
28193
28282
  }
28283
+ /** "<text> (default N, max M)." for a LinkedIn sender limit flag, from the enforced constants. */
28284
+ function linkedInLimitHelp(key, text) {
28285
+ return `${text} (default ${LINKEDIN_SENDER_LIMIT_DEFAULTS[key]}, max ${LINKEDIN_SENDER_LIMIT_MAXIMUMS[key]}).`;
28286
+ }
28194
28287
  function buildLinkedinLimitsBody(// skipcq: JS-R1005 -- CLI body builder maps per-account limit flags + reset timezone.
28195
28288
  options) {
28196
28289
  const limits = {};
@@ -28203,7 +28296,12 @@ options) {
28203
28296
  setLimit("invites_per_week", options.invitesPerWeek);
28204
28297
  setLimit("messages_per_day", options.messagesPerDay);
28205
28298
  setLimit("inmails_per_day", options.inmailsPerDay);
28299
+ setLimit("inmails_per_month", options.inmailsPerMonth);
28206
28300
  setLimit("profile_views_per_day", options.profileViewsPerDay);
28301
+ setLimit("profile_lookups_per_day", options.profileLookupsPerDay);
28302
+ setLimit("endorsements_per_day", options.endorsementsPerDay);
28303
+ setLimit("withdrawals_per_day", options.withdrawalsPerDay);
28304
+ setLimit("posts_per_day", options.postsPerDay);
28207
28305
  setLimit("follows_per_day", options.followsPerDay);
28208
28306
  setLimit("likes_per_day", options.likesPerDay);
28209
28307
  setLimit("comments_per_day", options.commentsPerDay);
@@ -28219,12 +28317,21 @@ options) {
28219
28317
  setLimit("interactive_min_spacing_seconds", options.interactiveMinSpacingSeconds);
28220
28318
  setLimit("interactive_spacing_jitter_seconds", options.interactiveSpacingJitterSeconds);
28221
28319
  setLimit("interactive_sends_per_hour", options.interactiveSendsPerHour);
28222
- // Send windows are campaign-scoped; the only per-account time setting is the
28223
- // timezone the daily counters reset in.
28320
+ // Working hours: only the given fields are sent; the server merges them into
28321
+ // the stored window and validates the result (invalid_working_hours).
28224
28322
  const workingHours = {};
28225
28323
  const timezone = readOption(options.timezone);
28226
28324
  if (timezone)
28227
28325
  workingHours.timezone = timezone;
28326
+ const workingDays = readOption(options.workingDays);
28327
+ if (workingDays)
28328
+ workingHours.days = parseWorkingDaysOption(workingDays);
28329
+ const workingHoursStart = readOption(options.workingHoursStart);
28330
+ if (workingHoursStart)
28331
+ workingHours.start = workingHoursStart;
28332
+ const workingHoursEnd = readOption(options.workingHoursEnd);
28333
+ if (workingHoursEnd)
28334
+ workingHours.end = workingHoursEnd;
28228
28335
  // Warm-up override (LinkedIn only). A start-date wins over the booleans; the
28229
28336
  // WhatsApp `set` command doesn't expose these flags, so this stays empty there.
28230
28337
  let warmup;
@@ -28256,7 +28363,7 @@ options) {
28256
28363
  const hasWarmup = warmup !== undefined;
28257
28364
  const hasReadWarmup = readWarmup !== undefined;
28258
28365
  if (!hasLimits && !hasWorkingHours && !hasWarmup && !hasPreset && !hasRandomize && !hasReadWarmup && !hasWeekendFullVolume) {
28259
- throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone, a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date), --read-warmup-disable / --read-warmup-restart, --warmup-preset, --randomize-caps, or --weekend-full-volume.", { exitCode: 1 });
28366
+ throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), a working-hours flag (--timezone, --working-days, --working-hours-start, --working-hours-end), a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date), --read-warmup-disable / --read-warmup-restart, --warmup-preset, --randomize-caps, or --weekend-full-volume.", { exitCode: 1 });
28260
28367
  }
28261
28368
  return {
28262
28369
  ...(hasLimits ? { limits } : {}),
@@ -28268,6 +28375,40 @@ options) {
28268
28375
  ...(hasReadWarmup ? { read_warmup: readWarmup } : {}),
28269
28376
  };
28270
28377
  }
28378
+ const WORKING_DAY_NAMES = {
28379
+ mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4,
28380
+ fri: 5, friday: 5, sat: 6, saturday: 6, sun: 7, sunday: 7,
28381
+ };
28382
+ /**
28383
+ * `--working-days` → ISO weekday numbers. Accepts day names or 1-7, comma
28384
+ * separated, and ranges (`mon-fri`, `1-5`). A token it cannot read is sent as
28385
+ * given so the server's invalid_working_hours error names it.
28386
+ */
28387
+ function parseWorkingDaysOption(value) {
28388
+ const day = (token) => {
28389
+ const lower = token.trim().toLowerCase();
28390
+ if (/^[1-7]$/.test(lower))
28391
+ return Number(lower);
28392
+ return WORKING_DAY_NAMES[lower] ?? null;
28393
+ };
28394
+ const days = [];
28395
+ for (const token of value.split(",").map((part) => part.trim()).filter(Boolean)) {
28396
+ const [from, to, ...rest] = token.split("-");
28397
+ const start = from !== undefined ? day(from) : null;
28398
+ const end = to !== undefined ? day(to) : null;
28399
+ if (rest.length === 0 && start !== null && end !== null && start <= end) {
28400
+ for (let current = start; current <= end; current += 1)
28401
+ days.push(current);
28402
+ }
28403
+ else if (to === undefined && start !== null) {
28404
+ days.push(start);
28405
+ }
28406
+ else {
28407
+ days.push(token);
28408
+ }
28409
+ }
28410
+ return days;
28411
+ }
28271
28412
  /**
28272
28413
  * A whole seat count. Unlike readPositiveNumber, ZERO IS VALID and meaningful:
28273
28414
  * `--quantity 0` is how a customer gives back every seat of a channel, and