@oxygen-agent/cli 1.1014.1225 → 1.1014.1341

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 (70) hide show
  1. package/README.md +1 -1
  2. package/dist/browser-login.js +4 -1
  3. package/dist/cli-output.js +14 -0
  4. package/dist/cli-values.js +1 -1
  5. package/dist/command-manifest.js +4 -1
  6. package/dist/commands/billing.js +2 -2
  7. package/dist/commands/crm.js +12 -4
  8. package/dist/commands/db.js +30 -0
  9. package/dist/commands/domains.js +1 -1
  10. package/dist/commands/feeds.js +29 -1
  11. package/dist/commands/knowledge.js +24 -1
  12. package/dist/commands/sequences.js +44 -8
  13. package/dist/deliverability-evidence-commands.js +6 -6
  14. package/dist/functions-commands.js +1 -1
  15. package/dist/help.js +1 -1
  16. package/dist/local-custom-http-column.js +1 -1
  17. package/dist/runtime.js +1 -1
  18. package/dist/session.js +1 -1
  19. package/dist/transcript.js +1 -1
  20. package/dist/util.d.ts +1 -1
  21. package/dist/util.js +1 -1
  22. package/dist/visual-commands.js +1 -1
  23. package/dist/visual-render-wait.js +1 -1
  24. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +34 -173
  25. package/node_modules/@oxygen/shared/dist/agent-sessions.js +134 -18
  26. package/node_modules/@oxygen/shared/dist/capability-discovery.js +1 -1
  27. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +1 -0
  28. package/node_modules/@oxygen/shared/dist/conversation-lineage.d.ts +131 -0
  29. package/node_modules/@oxygen/shared/dist/conversation-lineage.js +72 -0
  30. package/node_modules/@oxygen/shared/dist/copilot-errors.d.ts +3 -3
  31. package/node_modules/@oxygen/shared/dist/copilot-errors.js +6 -3
  32. package/node_modules/@oxygen/shared/dist/crm-record-brief.d.ts +1 -1
  33. package/node_modules/@oxygen/shared/dist/crm-record-brief.js +3 -1
  34. package/node_modules/@oxygen/shared/dist/deliverability-surface.d.ts +4 -2
  35. package/node_modules/@oxygen/shared/dist/deliverability-surface.js +4 -2
  36. package/node_modules/@oxygen/shared/dist/dev-eval-context.d.ts +20 -0
  37. package/node_modules/@oxygen/shared/dist/enrollment-origin.d.ts +65 -0
  38. package/node_modules/@oxygen/shared/dist/enrollment-origin.js +92 -0
  39. package/node_modules/@oxygen/shared/dist/first-month-offer.d.ts +18 -0
  40. package/node_modules/@oxygen/shared/dist/first-month-offer.js +13 -0
  41. package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.d.ts +54 -0
  42. package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.js +100 -0
  43. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +6 -0
  44. package/node_modules/@oxygen/shared/dist/hosted-ai.js +9 -0
  45. package/node_modules/@oxygen/shared/dist/hubspot-engagements.d.ts +26 -0
  46. package/node_modules/@oxygen/shared/dist/hubspot-engagements.js +107 -0
  47. package/node_modules/@oxygen/shared/dist/integration-secrets.d.ts +19 -0
  48. package/node_modules/@oxygen/shared/dist/integration-secrets.js +77 -0
  49. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +15 -4
  50. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +15 -4
  51. package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.d.ts +2 -0
  52. package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.js +2 -0
  53. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +4 -0
  54. package/node_modules/@oxygen/shared/dist/network-mining-approval.d.ts +90 -0
  55. package/node_modules/@oxygen/shared/dist/network-mining-approval.js +225 -0
  56. package/node_modules/@oxygen/shared/dist/network-mining-signals.d.ts +25 -0
  57. package/node_modules/@oxygen/shared/dist/network-mining-signals.js +34 -0
  58. package/node_modules/@oxygen/shared/dist/reply-oxygent.d.ts +2 -0
  59. package/node_modules/@oxygen/shared/dist/reply-oxygent.js +6 -0
  60. package/node_modules/@oxygen/shared/dist/research-findings.d.ts +11 -2
  61. package/node_modules/@oxygen/shared/dist/research-findings.js +16 -3
  62. package/node_modules/@oxygen/shared/dist/sequences.d.ts +25 -4
  63. package/node_modules/@oxygen/shared/dist/sequences.js +40 -10
  64. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +18 -0
  65. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  66. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  67. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  68. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  69. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +21 -1
  70. package/package.json +1 -1
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.1014.1225
37
+ Version: 1.1014.1341
@@ -2,7 +2,10 @@ import { execFileSync } from "node:child_process";
2
2
  import { randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { createServer } from "node:http";
4
4
  import { hostname } from "node:os";
5
- import { OXYGEN_VERSION, OxygenError, deriveCliLoginConfirmationCode, sleep } from "@oxygen/shared";
5
+ import { OXYGEN_VERSION } from "@oxygen/shared/version";
6
+ import { OxygenError } from "@oxygen/shared/cli-result";
7
+ import { deriveCliLoginConfirmationCode } from "@oxygen/shared/cli-login-code";
8
+ import { sleep } from "@oxygen/shared/timing";
6
9
  import { requestOxygen } from "./http-client.js";
7
10
  import { readErrorMessage } from "./util.js";
8
11
  const CALLBACK_HOST = "127.0.0.1";
@@ -6,6 +6,7 @@ import { formatMailboxRampNotice } from "./mailbox-ramp-notice.js";
6
6
  import { formatSearchAiFilterTotalsNotice } from "./search-ai-filter-notice.js";
7
7
  import { isRecord } from "./util.js";
8
8
  import { exitCodeForOxygenError, OxygenError, success, toFailure } from "@oxygen/shared/cli-result";
9
+ import { renderLineageDigest } from "@oxygen/shared/conversation-lineage";
9
10
  export function writeJson(value) {
10
11
  process.stdout.write(serializeCliJson(value, process.argv.includes("--pretty")));
11
12
  }
@@ -32,6 +33,7 @@ export async function handleAsyncAction(command, options, action) {
32
33
  writeSearchAiFilterTotalsNotice(data);
33
34
  writeInboxNeedsReplyNotice(data);
34
35
  writeMailboxRampNotice(data);
36
+ writeLineageNotice(data);
35
37
  }
36
38
  catch (error) {
37
39
  emitCliFailure(command, error);
@@ -233,6 +235,18 @@ function writeInboxNeedsReplyNotice(data) {
233
235
  if (notice)
234
236
  process.stderr.write(`${notice}\n`);
235
237
  }
238
+ // Network Mining (DEV-only): where an opened conversation came from, as the
239
+ // same short digest agents read. Present only when the server sent `lineage`.
240
+ function writeLineageNotice(data) {
241
+ if (!data || typeof data !== "object")
242
+ return;
243
+ const lineage = data.lineage;
244
+ if (!lineage || typeof lineage !== "object" || lineage.schema_version !== 1)
245
+ return;
246
+ const digest = renderLineageDigest(lineage);
247
+ if (digest)
248
+ process.stderr.write(`Lineage: ${digest}\n`);
249
+ }
236
250
  function writeMailboxRampNotice(data) {
237
251
  const notice = formatMailboxRampNotice(data);
238
252
  if (notice)
@@ -4,7 +4,7 @@
4
4
  // leaf `OxygenError` primitive plus `readOption` from `./util.js`, so this module
5
5
  // is a dependency-free leaf that index.ts and run-wait.ts can both import without
6
6
  // a cycle. All symbols throw the same typed OxygenError codes the copies did.
7
- import { OxygenError } from "@oxygen/shared";
7
+ import { OxygenError } from "@oxygen/shared/cli-result";
8
8
  import { readOption } from "./util.js";
9
9
  /** Parse a `--limit`/`--interval`-style flag into a positive integer, or undefined when absent. */
10
10
  export function readPositiveInt(value) {
@@ -1,6 +1,9 @@
1
1
  import { OPERATION_SPECS } from "@oxygen/shared/contracts/operations";
2
2
  import { SPINTAX_GENERATION_CREDITS } from "@oxygen/shared/contracts/spintax";
3
- import { CLI_EXIT_CODE_TABLE, OXYGEN_VERSION, inferUserCapabilityRoute, serializeCapabilityRoute, } from "@oxygen/shared";
3
+ import { CLI_EXIT_CODE_TABLE } from "@oxygen/shared/cli-result";
4
+ import { OXYGEN_VERSION } from "@oxygen/shared/version";
5
+ import { inferUserCapabilityRoute } from "@oxygen/shared/user-capability-routing";
6
+ import { serializeCapabilityRoute } from "@oxygen/shared/capability-discovery";
4
7
  const DEFAULT_COMMAND_SEARCH_LIMIT = 10;
5
8
  const MAX_COMMAND_SEARCH_LIMIT = 25;
6
9
  const COMMAND_SEARCH_STOP_WORDS = new Set([
@@ -62,7 +62,7 @@ export function registerBillingFamily(program, handleAsyncAction, billingDocsUrl
62
62
  .command("billing")
63
63
  .description("Plan and managed credit commands. Credits reserved but not yet charged are listed hold by hold with `billing usage --meter holds`. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another. On a plan that pays for several workspaces, `billing usage --group-by workspace` totals what each one spent. To price a month of outreach before building it (leads, enrichment, sequence steps, senders), run `billing estimate`. Failed-payment grace, suspension, and recovery: " + billingDocsUrl + ".")
64
64
  .addCommand(new Command("change")
65
- .description("Create an approval link (`approval_url`) to start or change a plan; no charge or plan change until confirmation. First purchase needs an admin/owner; existing-plan changes allow any unrestricted member. Compare all six prices, credits and yearly terms with `billing balance` first. Use --interval year --term year to pay the full discounted year upfront: the $99 plan costs $990 per year before tax. Credits arrive monthly during the paid year. On a workspace that has never had a plan, the Checkout for any monthly plan from $99 up carries the first-month offer, half its first month, while its 24-hour window is open (`offer`; `billing balance` lists every size under `first_month_offer.plans`): `amount_due_now` is then the discounted first month and `next_invoice_total` the regular price, both before tax. Creating that Checkout starts the window if nothing has started it yet. Prepaid yearly upgrades apply immediately with proration; downgrades and switching to monthly wait until the paid year ends. Switching from monthly to yearly starts at the next paid-period boundary. Existing yearly installment plans keep their payments until conversion at the yearly commitment end. Omit interval and term to preserve the current contract. For an existing subscription, a yearly preview returns charges, start and term-end dates; nothing changes until separate confirmation.")
65
+ .description("Create an approval link (`approval_url`) to start or change a plan; no charge or plan change until confirmation. First purchase needs an admin/owner; existing-plan changes allow any unrestricted member. Compare all six prices, credits and yearly terms with `billing balance` first. Use --interval year --term year to pay the full discounted year upfront: the $99 plan costs $990 per year before tax. Credits arrive monthly during the paid year. On a workspace that has never had a plan, the Checkout for any monthly plan from $99 up carries the first-month offer, half its first month, while its 24-hour window is open (`offer`; `billing balance` lists every size under `first_month_offer.plans`): `amount_due_now` is then the discounted first month and `next_invoice_total` the regular price, both before tax. Creating that Checkout starts the window if nothing has started it yet. `first_month_offer` says whether the offer covers the chosen plan (`applies`) and names the plan it does cover (`plan`, the $99 plan by default: $49 for its first month); a plan it misses (the $49 plan, or yearly) still gets its own Checkout, so relay the offer before the human pays. Prepaid yearly upgrades apply immediately with proration; downgrades and switching to monthly wait until the paid year ends. Switching from monthly to yearly starts at the next paid-period boundary. Existing yearly installment plans keep their payments until conversion at the yearly commitment end. Omit interval and term to preserve the current contract. For an existing subscription, a yearly preview returns charges, start and term-end dates; nothing changes until separate confirmation.")
66
66
  .requiredOption("--to <plan>",
67
67
  // Derived from the catalog, never retyped: this help text is the only
68
68
  // place a customer's agent learns which plans exist, and it named
@@ -94,7 +94,7 @@ export function registerBillingFamily(program, handleAsyncAction, billingDocsUrl
94
94
  }));
95
95
  }))
96
96
  .addCommand(new Command("balance")
97
- .description("May apply due plan grants or credit expiry. Compare all six subscription sizes in `available_plans`: monthly prices, full upfront yearly prices, credit allowances and savings. `annual_billing_available` says whether yearly billing is offered; deprecated `annual_monthly_billing_available` is false for new purchases. `annual_terms` explains upfront payment, monthly credits and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the subscription's recurring amount in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` is payment cadence. New yearly plans charge the full year upfront; `commitment` identifies existing yearly installment contracts and is null for monthly and prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For an existing yearly installment commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. Its basis is the plan face value; `first_grant_timing` explains unknown processing time separately from the next scheduled grant. PARTNERS2026 ends on cancellation or effective plan change; `partner_bonus.conditions` and `coupon_authority` describe the separate prospective exclusion when it applies. See the agency-partner-bonus section of the billing policy. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: " + oxygenAppUrl(defaultApiUrl(), "/billing") + ". Policy: " + billingDocsUrl + ". 0 credits; reserves nothing.")
97
+ .description("May apply due plan grants or credit expiry. Compare all six subscription sizes in `available_plans`: monthly prices, full upfront yearly prices, credit allowances and savings. `annual_billing_available` says whether yearly billing is offered; deprecated `annual_monthly_billing_available` is false for new purchases. `annual_terms` explains upfront payment, monthly credits and plan-change timing. `first_month_offer` is the first-month offer this workspace can still claim: every size it applies to (`plans`, from `minimum_plan` up) with its first-month and regular price before tax, `default_plan` (the size the web proposes while the offer runs; propose it unless the human names another), and `ends_at`, null until its 24-hour window starts. The window starts the first time the workspace meets the offer: the app showing it, signup from a browser that saw it on the website, or creating its Checkout with `billing change`. Also show the current plan, commitment, subscription entitlement, and managed credit balance: available and reserved credits, FIXED credits committed to recurring per-resource charges, and the FLEXIBLE free-to-spend remainder. `next_renewal` answers \"will I be charged?\": read `will_charge` first — true only when this workspace's own Stripe plan is going to charge its card, in which case `charge_at` and the subscription's recurring amount in money say when and roughly how much (the exact amount, with any promotion code or tax, is on the invoice); `period_ends_at` is the trial or period end either way, `cancellation_scheduled` says whether a cancellation is already set, and `stop_command` names the one command that changes it (`billing cancel`, undone by `billing resume`) or is null when nothing here can stop it (staff-invoiced, billed through another workspace, not active). `billing_interval` is payment cadence. New yearly plans charge the full year upfront; `commitment` identifies existing yearly installment contracts and is null for monthly and prepaid yearly plans. Credits are granted monthly; `next_credit_grant_at` is the next grant. For an existing yearly installment commitment, cancellation stops annual renewal but remaining monthly payments continue until commitment.ends_at. `renewals_past_due` lists infrastructure renewals (managed-inbox domains, warm-ups) OXYGEN could NOT charge this month, what they cost, and the exact top-up that clears them — nothing is cancelled and billing retries automatically after a top-up. `warnings` also flags a recurring_shortfall when your connected infrastructure costs more per month than the plan grants. `partner_bonus` says whether this workspace earns the agency partner bonus (+15% credits a month for a published directory partner on $99 or above), how many credits, and why not when it does not; the bonus arrives as its own ledger line (`billing usage --category partner_bonus`), never inside plan.monthly_credits. Its basis is the plan face value; `first_grant_timing` explains unknown processing time separately from the next scheduled grant. PARTNERS2026 ends on cancellation or effective plan change; `partner_bonus.conditions` and `coupon_authority` describe the separate prospective exclusion when it applies. See the agency-partner-bonus section of the billing policy. A workspace linked to another organization (`orgs billing-link`) reads its billing at that owner: the balance is the owner's pool, `shared_billing` is true and `billing_owner_organization_id` names the owner. After failed-payment grace expires, mutations stop while read/export and billing recovery remain available. Recovery: " + oxygenAppUrl(defaultApiUrl(), "/billing") + ". Policy: " + billingDocsUrl + ". 0 credits; reserves nothing.")
98
98
  .option("--json", "Print a JSON envelope.")
99
99
  .action(async (options) => {
100
100
  await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
@@ -9,7 +9,7 @@ import { CRM_RECORD_BRIEF_SECTIONS } from "@oxygen/shared/crm-record-brief";
9
9
  import { isRecord, readClearableOption, readOption } from "../util.js";
10
10
  import { OxygenError, success } from "@oxygen/shared/cli-result";
11
11
  import { CRM_ENRICHMENT_READ_CONTRACT } from "@oxygen/shared/contracts/crm-enrichment-read";
12
- import { Command } from "commander";
12
+ import { Command, Option } from "commander";
13
13
  import { stdout as output } from "node:process";
14
14
  const DEFAULT_CRM_SETUP_OBJECTS = ["companies", "people", "deals"];
15
15
  function buildCrmSetupBody(options) {
@@ -129,6 +129,7 @@ function buildCrmSyncImportBody(provider, options) {
129
129
  object: options.object,
130
130
  mode: resolveLiveDryRunMode(options),
131
131
  ...(options.into ? { into: options.into } : {}),
132
+ ...(readOption(options.types) ? { types: readOption(options.types) } : {}),
132
133
  ...(readPositiveInt(options.limit) !== undefined ? { limit: readPositiveInt(options.limit) } : {}),
133
134
  ...(readPositiveNumber(options.maxCredits) !== undefined ? { max_credits: readPositiveNumber(options.maxCredits) } : {}),
134
135
  ...(options.approved ? { approved: true } : {}),
@@ -1275,7 +1276,7 @@ export function registerCrmFamily(program, handleAsyncAction) {
1275
1276
  .argument("<object>", "CRM object slug, such as people or companies.")
1276
1277
  .argument("<record>", "CRM record row id, an identity value such as sarah@acme.com or acme.com, or the record's exact name.")
1277
1278
  .option("--sections <list>", `Comma-separated subset: ${CRM_RECORD_BRIEF_SECTIONS.join(", ")}. Default all.`)
1278
- .option("--max-chars <n>", "Character budget for the whole brief, 3000-20000. Default 8000.")
1279
+ .option("--max-chars <n>", "Character budget for the whole brief, 3000-20000. Default 16000.")
1279
1280
  .option("--json", "Print a JSON envelope.")
1280
1281
  .action(async (object, record, options) => {
1281
1282
  await handleAsyncAction("crm brief", options, () => requestOxygen(buildCrmBriefPath(object, record, options)));
@@ -1414,10 +1415,14 @@ export function registerCrmFamily(program, handleAsyncAction) {
1414
1415
  .argument("<object>", "CRM object slug, such as companies or people.")
1415
1416
  .option("--field <key>", "Narrow the answer to one field by its key, e.g. --field pipeline_stage. Also accepted as --attribute.")
1416
1417
  .option("--attribute <key>", "Alias for --field.")
1418
+ // Network Mining (DEV-only): hidden from help, refused by the server while the gate is off.
1419
+ .addOption(new Option("--view <view>", "audiences: the Tables bound to an Audience object, with cold, in-CRM and graduated counts.").choices(["audiences"]).hideHelp())
1417
1420
  .option("--json", "Print a JSON envelope.")
1418
1421
  .action(async (object, options) => {
1419
1422
  const field = readOption(options.field) ?? readOption(options.attribute);
1420
- await handleAsyncAction("crm describe", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}${field ? `?attribute=${encodeURIComponent(field)}` : ""}`));
1423
+ const view = readOption(options.view);
1424
+ const query = new URLSearchParams({ ...(field ? { attribute: field } : {}), ...(view ? { view } : {}) }).toString();
1425
+ await handleAsyncAction("crm describe", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}${query ? `?${query}` : ""}`));
1421
1426
  }))
1422
1427
  .addCommand(new Command("duplicates")
1423
1428
  .description("List ranked duplicate-candidate record pairs for one CRM object (identity-overlap + fuzzy-label), across every record. Read-only.")
@@ -1521,7 +1526,10 @@ export function registerCrmFamily(program, handleAsyncAction) {
1521
1526
  .addCommand(new Command("import")
1522
1527
  .description("Import provider records into an Oxygen CRM object (canonical CRM truth) as a durable workflow run. Defaults to dry-run. This pulls the WHOLE object and has no list/segment filter; to import one HubSpot saved list or one Pipedrive saved filter into a new table you can filter and enrich, use `oxygen tables hubspot import` or `oxygen tables pipedrive import` instead.")
1523
1528
  .argument("<provider>", "Connected CRM to import from: hubspot, attio, or pipedrive.")
1524
- .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, attio people|companies, or pipedrive persons|organizations|deals. Import Pipedrive persons and organizations before deals so each deal links to them.")
1529
+ .requiredOption("--object <object>", networkMiningHostServed()
1530
+ ? "Provider object: hubspot contacts|companies|engagements, attio people|companies, or pipedrive persons|organizations|deals. Import Pipedrive persons and organizations before deals so each deal links to them. hubspot engagements (calls, meetings, notes, emails, tasks) land as historical timeline activities on contacts and companies imported earlier."
1531
+ : "Provider object: hubspot contacts|companies, attio people|companies, or pipedrive persons|organizations|deals. Import Pipedrive persons and organizations before deals so each deal links to them.")
1532
+ .addOption(new Option("--types <list>", "hubspot engagements only: comma-separated calls,meetings,notes,emails,tasks. Defaults to all.").hideHelp(!networkMiningHostServed()))
1525
1533
  .option("--into <object>", "Oxygen CRM object slug to import into. Defaults to the natural mapping.")
1526
1534
  .option("--limit <n>", "Max records to pull this run.")
1527
1535
  .option("--max-credits <n>", "OXYGEN credit ceiling for the run. Review the current quote: own-API-key calls cost 0.05 credits each after repricing takes effect; OAuth follows its tool quote. Workflow steps count toward the ceiling after repricing takes effect. Use --limit for rows; provider billing and API quota are separate.")
@@ -113,6 +113,36 @@ export function registerDbFamily(program, handleAsyncAction) {
113
113
  },
114
114
  });
115
115
  });
116
+ }))
117
+ .addCommand(new Command("backfill-conversation-enrollments")
118
+ .description("Link LinkedIn and WhatsApp conversations to the Sequence enrollment that owns them, for one workspace: a thread links when exactly one enrollment of its sender names its chat. Staff only; 0 credits. Defaults to a dry run that reports linkable, ambiguous and unmatched counts.")
119
+ .option("--org <id>", "The organization id to backfill. Required.")
120
+ .option("--live", "Write the links. Requires --confirm.")
121
+ .option("--revert", "Unlink exactly the conversations a --live run linked. Requires --confirm.")
122
+ .option("--confirm", "Confirm --live or --revert.")
123
+ .option("--limit <n>", "Maximum conversations to write in this batch. Defaults to 5000; hard cap is 50000.")
124
+ .option("--json", "Print a JSON envelope.")
125
+ .action(async (options) => {
126
+ await handleAsyncAction("db backfill-conversation-enrollments", options, () => {
127
+ if (options.live && options.revert) {
128
+ throw new OxygenError("invalid_request", "Pass --live or --revert, not both.", { exitCode: 1 });
129
+ }
130
+ const mode = options.live ? "live" : options.revert ? "revert" : "dry_run";
131
+ if (mode !== "dry_run" && !options.confirm) {
132
+ throw new OxygenError("confirmation_required", `Refusing to run --${mode} without --confirm.`, { exitCode: 1 });
133
+ }
134
+ const limit = readPositiveInt(options.limit);
135
+ const organizationId = readOption(options.org) ?? readOption(program.opts().org);
136
+ return requestOxygen("/api/cli/db/backfill-conversation-enrollments", {
137
+ method: "POST",
138
+ body: {
139
+ mode,
140
+ confirm: Boolean(options.confirm),
141
+ ...(organizationId ? { organization_id: organizationId } : {}),
142
+ ...(limit !== undefined ? { limit } : {}),
143
+ },
144
+ });
145
+ });
116
146
  }))
117
147
  .addCommand(new Command("backfill-table-order-indexes")
118
148
  .description("Converge stable row-order indexes on existing customer tables. Staff only; the default dry-run uses 0 credits and makes no database changes.")
@@ -437,7 +437,7 @@ export function registerDomainsFamily(program, handleAsyncAction) {
437
437
  await handleAsyncAction("domains get", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}`));
438
438
  }))
439
439
  .addCommand(new Command("dns")
440
- .description("Read domain DNS health, 0 credits. --cached reads saved daily authentication evidence without external calls; --authentication checks public SPF/DKIM/DMARC/MX/nameservers and saves the observation. Otherwise check the domain's live records: Cloudflare persists its verdict; managed domains contact their provider; external domains query public DNS. DNS records are never changed by this command. Add --deep for content-level checks (SPF DNS-lookup budget, DMARC enforcement, MX provider) plus an external DNS-over-HTTPS nameserver-authority check. An Oxygen-managed domain reads its vendor-held records and verdict instead (--deep ignored). A domain hosted outside Oxygen that you send from returns rail=external: the records public DNS resolves — the mail set, delegation, policy records and web root — with health.enumerable=false, because public DNS cannot list a zone (a DKIM key is probed at common selectors, so dkim=unknown means unproven, never missing). `dns plan`/`dns apply` edit your own Cloudflare zones only. DNS editing is unavailable for Oxygen-bought domains; keep their nameservers and contact support.")
440
+ .description("Check domain DNS health, 0 credits. --cached reads the latest saved authentication evidence without external calls; --authentication checks public SPF/DKIM/DMARC/MX/nameservers and saves the observation. Every saved check, kept 400 days, is in `mailboxes deliverability checks list --subject <domain>`. Otherwise check the domain's live records: Cloudflare persists its verdict; managed domains contact their provider; external domains query public DNS. DNS records are never changed by this command. Add --deep for content-level checks (SPF DNS-lookup budget, DMARC enforcement, MX provider) plus an external DNS-over-HTTPS nameserver-authority check. An Oxygen-managed domain reads its vendor-held records and verdict instead (--deep ignored). A domain hosted outside Oxygen that you send from returns rail=external: the records public DNS resolves — the mail set, delegation, policy records and web root — with health.enumerable=false, because public DNS cannot list a zone (a DKIM key is probed at common selectors, so dkim=unknown means unproven, never missing). `dns plan`/`dns apply` edit your own Cloudflare zones only. DNS editing is unavailable for Oxygen-bought domains; keep their nameservers and contact support.")
441
441
  .argument("[domain]", "Domain name, such as acme.com.")
442
442
  // NOT --provider: commander parses parent options greedily by default,
443
443
  // so a --provider here would swallow the `dns plan`/`dns apply`
@@ -6,7 +6,8 @@ import { requestOxygen } from "../http-client.js";
6
6
  import { isRecord, readOption } from "../util.js";
7
7
  import { OxygenError } from "@oxygen/shared/cli-result";
8
8
  import { TABLE_FEED_UPDATE_CONTRACT, tableFeedUpdateIssues } from "@oxygen/shared/contracts/table-feed-update";
9
- import { Command } from "commander";
9
+ import { Command, Option } from "commander";
10
+ import { networkMiningHostServed } from "../network-mining-host.js";
10
11
  function feedsListPath(options, tableArgument) {
11
12
  const params = feedReadWindowParams(options);
12
13
  const table = readOption(tableArgument) ?? readOption(options.table);
@@ -128,6 +129,25 @@ const FEED_BIND_WEB_INTENT_REFUSED_FLAGS = [
128
129
  // --request-json were requiredOptions and <table> was required: same messages, same
129
130
  // order (mandatory options before the argument), same stderr + exit 1 path. They
130
131
  // stopped being commander-required only because a push feed must not carry them.
132
+ /** Network Mining (DEV-only): the lead-post watch's source is fixed server-side; only its leads, cadence and cap are chosen. */
133
+ const LEAD_POST_WATCH_KIND = "lead_post_watch";
134
+ function readLeadPostWatchBindBody(table, options) {
135
+ const every = readOption(options.every);
136
+ const maxCreditsPerCycle = readPositiveNumber(options.maxCredits);
137
+ const focusTopK = readPositiveInt(options.focusTopK);
138
+ return {
139
+ ...(table ? { table } : {}),
140
+ transport: "pull",
141
+ feed_kind: LEAD_POST_WATCH_KIND,
142
+ sync: {
143
+ ...(every ? { every } : {}),
144
+ ...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
145
+ },
146
+ ...(focusTopK !== undefined ? { focus_top_k: focusTopK } : {}),
147
+ ...(readOption(options.name) ? { name: readOption(options.name) } : {}),
148
+ ...(options.approved ? { approved: true } : {}),
149
+ };
150
+ }
131
151
  function assertPullFeedBindArguments(table, options, command) {
132
152
  for (const [key, flag] of FEED_BIND_PUSH_ONLY_FLAGS) {
133
153
  if (options[key] !== undefined) {
@@ -359,6 +379,8 @@ export function registerFeedsFamily(program, handleAsyncAction) {
359
379
  .option("--registry-version <version>", "Routing-registry revision from the plan, retained with the frozen feed provenance.")
360
380
  .option("--source-prompt <text-or-file>", "The prompt this feed was planned from, or a path to a prompt file.")
361
381
  .option("--approved", "Approve the recurring paid pull. Required with --every and --max-credits on a paid provider.")
382
+ .addOption(new Option("--focus-top-k <n>", "--kind lead_post_watch only (Network Mining): how many focus leads to watch each cycle, default 25. The source is fixed: each watched lead's posts of the last week, one page per lead, trimmed to what --max-credits affords.")
383
+ .hideHelp(!networkMiningHostServed()))
362
384
  .option("--json", "Print a JSON envelope.")
363
385
  .addHelpText("after", `
364
386
  Examples:
@@ -367,6 +389,12 @@ Examples:
367
389
  oxygen feeds bind --source rb2b --create "Website visitors"
368
390
  `)
369
391
  .action((table, options, command) => {
392
+ if (options.kind === LEAD_POST_WATCH_KIND) {
393
+ return handleAsyncAction("feeds bind", options, () => requestOxygen("/api/cli/tables/feeds", {
394
+ method: "POST",
395
+ body: readLeadPostWatchBindBody(table, options),
396
+ }));
397
+ }
370
398
  const webIntent = options.source !== undefined;
371
399
  const push = options.schemaJson !== undefined;
372
400
  // Pull usage errors keep commander's exact wording and path (stderr, exit 1),
@@ -13,7 +13,8 @@ import { knowledgeProposalWindowContract } from "@oxygen/shared/contracts/knowle
13
13
  import { KNOWLEDGE_BOOTSTRAP_MAX_CREDITS } from "@oxygen/shared/knowledge-bootstrap";
14
14
  import { KNOWLEDGE_PAGE_TYPES } from "@oxygen/shared/knowledge-constants";
15
15
  import { parseKnowledgePageMarkdown } from "@oxygen/shared/knowledge-markdown";
16
- import { Command } from "commander";
16
+ import { Command, Option } from "commander";
17
+ import { networkMiningHostServed } from "../network-mining-host.js";
17
18
  import { existsSync, readFileSync } from "node:fs";
18
19
  // The wiki index counts the whole wiki even when the page list is a window, so
19
20
  // name both numbers: "200 of 512 pages" is honest, "200 pages" is not.
@@ -107,6 +108,7 @@ function buildKnowledgeSearchBody(query, options) {
107
108
  ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
108
109
  ...(tags.length > 0 ? { tags } : {}),
109
110
  ...(limit !== undefined ? { limit } : {}),
111
+ ...(readOption(options.sources) ? { sources: readOption(options.sources) } : {}),
110
112
  };
111
113
  }
112
114
  // Post-write staleness hook for `knowledge page upsert|pin|archive`: clearing
@@ -616,6 +618,7 @@ export function registerKnowledgeFamily(program, handleAsyncAction) {
616
618
  .option("--status <status>", "Filter by draft, active, or archived. Omitted, archived pages are excluded.")
617
619
  .option("--tags <csv>", "Comma-separated tags that must be present.")
618
620
  .option("--limit <n>", "Maximum pages to return.")
621
+ .addOption(new Option("--sources <sources>", "Network Mining (dev): knowledge (default), crm (CRM activities and record digests, cited by record), or all.").hideHelp(!networkMiningHostServed()))
619
622
  .option("--json", "Print a JSON envelope.")
620
623
  .action(async (query, options) => {
621
624
  await handleAsyncAction("knowledge search", options, () => requestOxygen("/api/cli/knowledge/search", {
@@ -837,6 +840,26 @@ export function registerKnowledgeFamily(program, handleAsyncAction) {
837
840
  return requestOxygen("/api/cli/knowledge/agent", { method: "POST", body });
838
841
  });
839
842
  })))
843
+ .addCommand(new Command("crm-index")
844
+ .description("Network Mining (dev): the semantic CRM index behind `knowledge search --sources crm`. Shows whether it is armed, its spend and documents. --quote counts what it would embed and the worst-case cost; --max-credits N --approved arms it with that cap (Oxygen's embedding budget, never your credits); --off stops new spend.")
845
+ .option("--quote", "Add the quote: sources to embed, chunks and worst-case credits. Read-only.")
846
+ .option("--max-credits <n>", "Arm the index with this cap in credits (needs --approved after the quote).")
847
+ .option("--approved", "Confirm arming after reviewing the quote.")
848
+ .option("--off", "Disarm: stop new embedding spend. Indexed documents stay searchable.")
849
+ .option("--json", "Print a JSON envelope.")
850
+ .action(async (options) => {
851
+ await handleAsyncAction("knowledge crm-index", options, () => {
852
+ if (options.off)
853
+ return requestOxygen("/api/cli/knowledge/agent", { method: "POST", body: { crm_index: false } });
854
+ if (options.maxCredits !== undefined) {
855
+ return requestOxygen("/api/cli/knowledge/agent", {
856
+ method: "POST",
857
+ body: { crm_index: { max_credits: Number(options.maxCredits), ...(options.approved ? { approved: true } : {}) } },
858
+ });
859
+ }
860
+ return requestOxygen(`/api/cli/knowledge/agent?crm_index=${options.quote ? "quote" : "status"}`);
861
+ });
862
+ }), { hidden: !networkMiningHostServed() })
840
863
  .addCommand(new Command("sync")
841
864
  .description("Clone or refresh the local knowledge mirror: server-rendered page markdown under the CLI config dir. Pass --all to mirror every workspace the credential can access.")
842
865
  .option("--all", "Sync every organization the credential can access, each into its own mirror; one org's failure never aborts the rest.")
@@ -3,6 +3,7 @@ import { parseJsonObject, readNonNegativeInt, readPositiveInt } from "../cli-val
3
3
  import { ansi, collectRepeatable, handleReadActionWithLens, parseJsonValue, readCreditCeilingOrZero, readCsvOption, readJsonFileValue, readPositiveNumber, readPublishingPostText, renderTextTable, splitCommaList } from "../command-support.js";
4
4
  import { addContractFlags, readContractFlags } from "../contract-flags.js";
5
5
  import { requestOxygen } from "../http-client.js";
6
+ import { networkMiningHostServed } from "../network-mining-host.js";
6
7
  import { parseWorkingDaysOption } from "../sender-limits-flags.js";
7
8
  import { isRecord, readOption } from "../util.js";
8
9
  import { OxygenError, success } from "@oxygen/shared/cli-result";
@@ -1332,28 +1333,28 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1332
1333
  }, formatSequenceListHealth);
1333
1334
  }))
1334
1335
  .addCommand(new Command("send")
1335
- .description("Initiate one net-new LinkedIn message through a one-recipient, one-step hosted Sequence. First call without a sequence id: pass --recipient, --text/--text-file, and --sender to create the inert draft and preview (no message, no credits). After showing that preview, re-run with the returned sequence id plus --approved --max-credits N. Existing-thread replies stay in `oxygen inbox send`.")
1336
+ .description("Initiate one net-new LinkedIn message through a one-recipient, one-step hosted Sequence. First call without a sequence id: pass --recipient, --text/--text-file, and --sender to create the inert draft and preview (no message, no credits). After showing that preview, re-run with the returned sequence id plus --approved. Existing-thread replies stay in `oxygen inbox send`.")
1336
1337
  .argument("[sequence]", "Sequence id returned by the preview call. Omit when creating the preview; required with --approved.")
1337
1338
  .option("--recipient <url-or-id>", "LinkedIn personal-profile URL or provider member id. Preview call only.")
1338
1339
  .option("--recipient-name <name>", "Optional display name stored on the enrollment. Preview call only.")
1339
1340
  .option("--text <text>", "Message body. Preview call only; use either --text or --text-file.")
1340
1341
  .option("--text-file <path>", "Read the message body from a file. Preview call only; use either --text-file or --text.")
1341
1342
  .option("--sender <ref>", "Connected LinkedIn sender id, connection id, or Unipile account id. List choices with `oxygen senders list`.")
1342
- .option("--approved", "Approve the previewed Sequence for live dispatch. Requires the returned sequence id and --max-credits.")
1343
- .option("--max-credits <n>", "Positive credit ceiling for the live LinkedIn dispatch.")
1343
+ .option("--approved", "Approve the previewed Sequence for live dispatch. Requires the returned sequence id.")
1344
+ .option("--max-credits <n>", "Optional credit ceiling (0 or more); the message costs 0 credits.")
1344
1345
  .option("--json", "Print a JSON envelope.")
1345
1346
  .action(async (sequence, options) => {
1346
1347
  await handleAsyncAction("sequences send", options, () => {
1347
1348
  const sequenceId = readOption(sequence);
1348
- const maxCredits = readPositiveNumber(options.maxCredits);
1349
+ const maxCredits = readCreditCeilingOrZero(options.maxCredits);
1349
1350
  if (sequenceId) {
1350
1351
  if (readOption(options.recipient) || readOption(options.recipientName) || readOption(options.text) || readOption(options.textFile) || readOption(options.sender)) {
1351
- throw new OxygenError("conflicting_flags", "With a sequence id, pass only --approved and --max-credits; recipient, copy, and sender are fixed by the previewed draft.", { exitCode: 1 });
1352
+ throw new OxygenError("conflicting_flags", "With a sequence id, pass only --approved (and an optional --max-credits); recipient, copy, and sender are fixed by the previewed draft.", { exitCode: 1 });
1352
1353
  }
1353
1354
  return requestOxygen("/api/cli/sequences/send", { method: "POST", body: { sequence: sequenceId, ...(options.approved ? { approved: true } : {}), ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}) } });
1354
1355
  }
1355
1356
  if (options.approved)
1356
- throw new OxygenError("sequence_preview_required", "Preview first without --approved; then pass the returned sequence id with --approved --max-credits N.", { exitCode: 1 });
1357
+ throw new OxygenError("sequence_preview_required", "Preview first without --approved; then pass the returned sequence id with --approved.", { exitCode: 1 });
1357
1358
  const recipient = readOption(options.recipient);
1358
1359
  const sender = readOption(options.sender);
1359
1360
  const text = readPublishingPostText(options, true);
@@ -1867,9 +1868,9 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1867
1868
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}${query}`));
1868
1869
  }))
1869
1870
  .addCommand(new Command("enroll")
1870
- .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a LinkedIn + email sequence a row with no usable profile but a recipient email is still enrolled for the email steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those. A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
1871
+ .description("Enroll leads into a sequence. Enrolling spends no credits. An active sequence contacts the new leads on its next dispatch tick; a draft or paused one holds them until `sequences start`. --from-table enrolls every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; the response's from_table.has_more says whether to run again) — for a LinkedIn sequence the profile is read from the bound table's URL column, where a full /in/ URL and a bare public handle carrying a hyphen or digit (ada-lovelace) both work, so no pre-resolved provider ids are needed; each handle Oxygen canonicalized is echoed in linkedin_handles_canonicalized (value + url) to check before start, and every row that still cannot be resolved is named in unresolved_linkedin_lead_details (row, value, reason) rather than only counted in unresolved_linkedin_leads. In a LinkedIn + email sequence a row with no usable profile but a recipient email is still enrolled for the email steps (its LinkedIn steps are skipped as no_linkedin_profile, a connection wait passes at once and a connection branch takes its not-connected path) and is listed in linkedin_unavailable_lead_details instead; unresolved_linkedin_leads names only rows that were not enrolled. --leads-file enrolls an explicit JSON list { leads: [...] }: for an email/WhatsApp sequence a lead identified only by an email (row_values.email) or phone (row_values.phone) is auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, so no table has to exist first (the table is returned as leads_table with a web_url); a LinkedIn + email sequence auto-files an email-only lead the same way; a LinkedIn lead needs a lead_provider_id, a lead_profile_url (a /in/ URL or a bare public handle with a hyphen or digit), or a table_row_id whose row carries one of those; in a sequence with LinkedIn steps a lead with a lead_profile_url and no table_row_id is filed the same way, onto the person's existing row in the bound table (another spelling of the same profile URL matches) or as a new row, deduped by profile URL, and a person the sequence already holds keeps that enrollment; where the sequence has no table and every new table needs a client signoff the enroll is refused instead (sequence_leads_table_needs_signoff). Like any table-row lead a filed one cannot re-enroll in that sequence once its enrollment ends; leads_without_table_row counts the leads in the request that no table lists (those given only a lead_provider_id, and people the sequence already holds without a row). A CRM-only lead needs a stable table_row_id or lead_provider_id in addition to mapped row_values; a HubSpot contact id used in crm_task associations identifies the destination record, not the Oxygen enrollment. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row (and per email/phone or profile URL for auto-filed leads). WhatsApp-only campaigns also enroll each normalized number once across all source rows and enrollment statuses; mixed campaigns keep other channels and skip duplicate WhatsApp actions. Duplicate protection defaults to this campaign; --whatsapp-duplicate-scope on create/update can additionally block identical copy across all campaigns. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings. This enrolls into an OXYGEN Sequence; to put the same rows into a campaign you already run in Smartlead, Instantly, lemlist or HeyReach, use `sequences push-external`. esp.pool_scope identifies the active workspace inventory used for routing and strict_deferred_leads estimates; these estimates omit sequence sender restrictions, readiness and daily limits. A zero estimate does not guarantee readiness. Review sequences preview before launch.")
1871
1872
  .argument("<sequence>", "Sequence id or slug.")
1872
- .option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
1873
+ .option("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ row_values: { email }, lead_name }] } for cold email; { lead_profile_url, lead_name, row_values } or { lead_provider_id, lead_name, table_row_id, row_values } for LinkedIn/existing rows; CRM-only leads require table_row_id or a stable lead_provider_id alongside mapped row_values. A CRM association object id is not the enrollment identity. Exactly one of --leads-file or --from-table.")
1873
1874
  .option("--from-table", "Enroll every not-yet-enrolled row of the sequence's bound source table (up to 500 per run; re-run to continue). Exactly one of --leads-file or --from-table.")
1874
1875
  .option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion), for THIS enroll. Off by default; forces exclusion on even if the sequence's exclude_contacted default is off.")
1875
1876
  .option("--no-exclude-contacted", "Force-INCLUDE cross-campaign leads for this enroll even when the sequence's exclude_contacted default is on (per-call override).")
@@ -1999,6 +2000,41 @@ export function registerSequencesFamily(program, handleAsyncAction) {
1999
2000
  // The start route's preview branch: no approved, no dry_run, no ceilings.
2000
2001
  requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/start`, { method: "POST", body: {} }));
2001
2002
  }))
2003
+ .addCommand(new Command("standing-approval")
2004
+ .description("Network Mining (dev): approval tiers for one Sequence. Without flags, previews a batch of the first 10-30 pending leads' exact drafts, each run through the automatic check (empty, placeholder, sender named in the third person, length), with the review state of every reviewed AI cell and a preview_hash; it changes nothing. A person then approves the batch (--approve-batch <hash>): its pending drafts are accepted together and it becomes the pilot. After the first launch, which always needs a person, a person grants standing approval (--approve-standing <hash>): later starts, resumes and Agent enrolls need no person while sources, qualification, enrichment, sender, copy and voice, brief schema, schedule, recipient limits and credit ceiling stay within what was approved. Agents and Auto-approve can preview and --revoke only.")
2005
+ .argument("<sequence>", "Sequence id or slug.")
2006
+ .option("--sample-size <n>", "Drafts in the batch, 1-30 (default 10). A pilot needs at least 10.")
2007
+ .option("--draft-samples", "Draft every AI-written invite note and DM of the batch now, as dispatch would (paid; needs --max-credits). Without it the drafts of the last paid preview are shown.")
2008
+ .option("--max-credits <n>", "Credit ceiling for --draft-samples.")
2009
+ .option("--approve-batch <hash>", "Accept the previewed batch as the pilot. Pass its preview_hash and the same --sample-size.")
2010
+ .option("--approve-standing <hash>", "Grant standing approval on the reviewed pilot, by its preview_hash.")
2011
+ .option("--revoke", "Revoke the standing approval. Needs no person; the pilot and first launch stay on record.")
2012
+ .option("--status", "Read the approval state only.")
2013
+ .option("--json", "Print a JSON envelope.")
2014
+ .action(async (sequence, options) => {
2015
+ await handleAsyncAction("sequences standing-approval", options, () => {
2016
+ const path = `/api/cli/sequences/${encodeURIComponent(sequence)}/standing-approval`;
2017
+ const chosen = [options.approveBatch !== undefined, options.approveStanding !== undefined, options.revoke === true, options.status === true].filter(Boolean).length;
2018
+ if (chosen > 1)
2019
+ throw new OxygenError("invalid_request", "Choose one of --approve-batch, --approve-standing, --revoke or --status.", { exitCode: 2 });
2020
+ if (options.status)
2021
+ return requestOxygen(path);
2022
+ const sampleSize = readPositiveInt(options.sampleSize);
2023
+ const maxCredits = readPositiveNumber(options.maxCredits);
2024
+ const body = options.revoke
2025
+ ? { action: "revoke" }
2026
+ : options.approveStanding !== undefined
2027
+ ? { action: "approve_standing", preview_hash: options.approveStanding }
2028
+ : {
2029
+ action: options.approveBatch !== undefined ? "approve_batch" : "preview",
2030
+ ...(options.approveBatch !== undefined ? { preview_hash: options.approveBatch } : {}),
2031
+ ...(sampleSize !== undefined ? { sample_size: sampleSize } : {}),
2032
+ ...(options.draftSamples ? { draft_samples: true } : {}),
2033
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
2034
+ };
2035
+ return requestOxygen(path, { method: "POST", body });
2036
+ });
2037
+ }), { hidden: !networkMiningHostServed() })
2002
2038
  .addCommand(new Command("render")
2003
2039
  .description("Render a sequence's copy as the web step editor's Preview does: every A/B variant of each step (email subject/body, LinkedIn note/message/InMail, WhatsApp, comments) for leads of the bound table, or sample values when it has none, signed by one eligible sender. Works on a draft with nobody enrolled. Read-only, 0 credits. Each lead gets one variant at send; this shows all of them side by side, and spintax shows one pick per lead.")
2004
2040
  .argument("<sequence>", "Sequence id or slug.")
@@ -21,14 +21,14 @@ function readTextOption(options, inline, file, label) {
21
21
  /** Adds the evidence leaves to the `mailboxes deliverability` group and returns it. */
22
22
  export function withDeliverabilityEvidenceCommands(root, handle) {
23
23
  root.addCommand(new Command("checks")
24
- .description("Stored deliverability check runs: DNS authentication, BIMI, tracking CNAME, blacklist, copy score and header proof, each with typed findings. 0 credits; runs no check.")
24
+ .description("Stored deliverability check runs: DNS authentication (SPF, DKIM, DMARC, MX), BIMI, tracking CNAME, blacklist, copy score and header proof, each with typed findings. 0 credits; runs no check.")
25
25
  .addCommand(new Command("list")
26
- .description("List stored check runs, newest first, with pass/warn/fail/unknown finding counts. Follow next_cursor with the same filters.")
27
- .addOption(new Option("--kind <kind>", "Check kind.").choices([...DELIVERABILITY_SURFACE_CHECK_KINDS]))
28
- .addOption(new Option("--subject-type <type>", "What was checked.").choices([...DELIVERABILITY_SURFACE_SUBJECT_TYPES]))
26
+ .description("List stored check runs, newest first, with pass/warn/fail/unknown finding counts. Follow next_cursor with the same filters. Placement results are `observations list`, not check runs.")
27
+ .addOption(new Option("--kind <kind>", `Check kind: ${DELIVERABILITY_SURFACE_CHECK_KINDS.join(", ")}.`).choices([...DELIVERABILITY_SURFACE_CHECK_KINDS]))
28
+ .addOption(new Option("--subject-type <type>", `What was checked: ${DELIVERABILITY_SURFACE_SUBJECT_TYPES.join(", ")}.`).choices([...DELIVERABILITY_SURFACE_SUBJECT_TYPES]))
29
29
  .option("--subject <subject>", "Exact subject: a domain, mailbox id or copy content hash.")
30
- .addOption(new Option("--status <status>", "Run status.").choices([...DELIVERABILITY_SURFACE_RUN_STATUSES]))
31
- .addOption(new Option("--finding-status <status>", "Only runs with at least one finding of this status.").choices([...DELIVERABILITY_SURFACE_FINDING_STATUSES]))
30
+ .addOption(new Option("--status <status>", `Run status: ${DELIVERABILITY_SURFACE_RUN_STATUSES.join(", ")}.`).choices([...DELIVERABILITY_SURFACE_RUN_STATUSES]))
31
+ .addOption(new Option("--finding-status <status>", `Only runs with at least one finding of this status: ${DELIVERABILITY_SURFACE_FINDING_STATUSES.join(", ")}.`).choices([...DELIVERABILITY_SURFACE_FINDING_STATUSES]))
32
32
  .option("--since <time>", `Started at or after. ${WINDOW_HELP}`)
33
33
  .option("--until <time>", `Started before. ${WINDOW_HELP}`)
34
34
  .option("--limit <n>", LIMIT_HELP)
@@ -1,4 +1,4 @@
1
- import { OxygenError } from "@oxygen/shared";
1
+ import { OxygenError } from "@oxygen/shared/cli-result";
2
2
  import { FUNCTION_LIBRARY_FILTER } from "@oxygen/shared/contracts/function-library-read";
3
3
  import { parseJsonObject } from "./cli-values.js";
4
4
  import { requestOxygen } from "./http-client.js";
package/dist/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CommanderError } from "commander";
2
- import { CLI_EXIT_CODE_TABLE } from "@oxygen/shared";
2
+ import { CLI_EXIT_CODE_TABLE } from "@oxygen/shared/cli-result";
3
3
  import { exitCodeForErrorCode, failure } from "@oxygen/shared/cli-result";
4
4
  // Grouped `--help` for the ~70 top-level command groups: commander's default
5
5
  // registration-order wall (login, auth, profiles, ... before any GTM noun) is
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { OxygenError } from "@oxygen/shared";
2
+ import { OxygenError } from "@oxygen/shared/cli-result";
3
3
  import { readJsonPath, walkJsonPath } from "@oxygen/shared/json-path";
4
4
  import { CustomHttpUrlSafetyError, assertCustomHttpPublicUrlSyntax, assertCustomHttpResolvedHostAllowed, } from "@oxygen/shared/custom-http-safety";
5
5
  import { requestOxygen } from "./http-client.js";
package/dist/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { basename } from "node:path";
2
- import { OxygenError } from "@oxygen/shared";
2
+ import { OxygenError } from "@oxygen/shared/cli-result";
3
3
  const PROD_API_HOSTNAME = "oxygen-agent.com";
4
4
  const DEV_CLI_BINARY = "oxygen-dev";
5
5
  const PROD_CLI_BINARY = "oxygen";
package/dist/session.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { OxygenError } from "@oxygen/shared";
5
+ import { OxygenError } from "@oxygen/shared/cli-result";
6
6
  export function startSession(input) {
7
7
  const now = new Date().toISOString();
8
8
  const session = {
@@ -2,7 +2,7 @@ import { gzipSync } from "node:zlib";
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { OXYGEN_VERSION } from "@oxygen/shared";
5
+ import { OXYGEN_VERSION } from "@oxygen/shared/version";
6
6
  // Keep at most the most-recent ~2MB of JSONL. gzip of conversational JSON lands
7
7
  // ~5–10x smaller, so the base64 payload stays well under Vercel's ~4.5MB body
8
8
  // limit without needing chunked upload. Past the cap we drop the oldest events.
package/dist/util.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { isRecord } from "@oxygen/shared";
1
+ export { isRecord } from "@oxygen/shared/type-guards";
2
2
  export declare function readOption(value: string | undefined): string | null;
3
3
  /**
4
4
  * A flag whose ABSENCE and whose EMPTY value mean different things: absent
package/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- export { isRecord } from "@oxygen/shared";
1
+ export { isRecord } from "@oxygen/shared/type-guards";
2
2
  export function readOption(value) {
3
3
  return typeof value === "string" && value.trim() ? value.trim() : null;
4
4
  }
@@ -2,7 +2,7 @@ import { Option } from "commander";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFileSync, statSync, writeFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
- import { OxygenError } from "@oxygen/shared";
5
+ import { OxygenError } from "@oxygen/shared/cli-result";
6
6
  import { requestOxygen } from "./http-client.js";
7
7
  import { waitForVisualRender } from "./visual-render-wait.js";
8
8
  export function registerVisualCommands(program, handle) {
@@ -1,4 +1,4 @@
1
- import { OxygenError } from "@oxygen/shared";
1
+ import { OxygenError } from "@oxygen/shared/cli-result";
2
2
  import { readPositiveInt, readRecordString } from "./cli-values.js";
3
3
  import { requestOxygen } from "./http-client.js";
4
4
  import { waitForCliRun } from "./run-wait.js";