@oxygen-agent/cli 1.1003.12 → 1.1010.644

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 (102) hide show
  1. package/README.md +1 -1
  2. package/dist/column-decision-options.d.ts +20 -0
  3. package/dist/column-decision-options.js +54 -0
  4. package/dist/command-manifest.js +15 -2
  5. package/dist/functions-commands.js +11 -11
  6. package/dist/index.js +1222 -159
  7. package/dist/search-ai-filter-notice.d.ts +17 -0
  8. package/dist/search-ai-filter-notice.js +38 -0
  9. package/dist/skills.js +34 -10
  10. package/dist/util.d.ts +9 -0
  11. package/dist/util.js +14 -0
  12. package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
  13. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
  14. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
  15. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
  16. package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
  17. package/node_modules/@oxygen/shared/dist/billing.js +45 -0
  18. package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
  19. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
  20. package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
  21. package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
  22. package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
  23. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  24. package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
  25. package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
  26. package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
  27. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
  28. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
  29. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
  30. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
  31. package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
  32. package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
  33. package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
  34. package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
  35. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
  36. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
  37. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
  39. package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
  40. package/node_modules/@oxygen/shared/dist/index.js +2 -0
  41. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
  42. package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
  43. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
  44. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
  45. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
  46. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
  47. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
  48. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
  49. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
  50. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
  51. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
  52. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
  53. package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
  54. package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
  55. package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
  56. package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
  57. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
  58. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
  59. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
  60. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
  61. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
  62. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
  63. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
  64. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
  65. package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
  66. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
  67. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
  68. package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
  69. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
  70. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
  71. package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
  72. package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
  73. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
  74. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
  75. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
  76. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
  77. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
  78. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
  79. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
  80. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
  81. package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
  82. package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
  83. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
  84. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
  85. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
  86. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
  87. package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
  88. package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
  89. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
  90. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
  91. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
  92. package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
  93. package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
  94. package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
  95. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
  96. package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
  97. package/node_modules/@oxygen/shared/dist/version.js +8 -1
  98. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
  99. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
  100. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
  101. package/node_modules/@oxygen/shared/package.json +9 -0
  102. package/package.json +2 -1
@@ -3,5 +3,14 @@ export type Field = {
3
3
  kind: string;
4
4
  description: string;
5
5
  required: boolean;
6
+ /** CLI flag name when it differs from the body key (e.g. `at` for scheduled_for). */
7
+ flag?: string;
6
8
  };
9
+ /** `2/week`, `4/month`, `10/total` or `none` (clears); a count of 1–100. */
10
+ export declare function parsePostingTarget(value: string): {
11
+ count: number;
12
+ period: "week" | "month" | "total";
13
+ } | null;
14
+ /** A JSON array of emails, or emails separated by commas, semicolons or whitespace. */
15
+ export declare function parseEmailList(value: string): string[];
7
16
  export declare function parseField(value: unknown, field: Field): unknown;
@@ -1,4 +1,38 @@
1
+ /** `2/week`, `4/month`, `10/total` or `none` (clears); a count of 1–100. */
2
+ export function parsePostingTarget(value) {
3
+ const text = value.trim().toLowerCase();
4
+ if (text === "none")
5
+ return null;
6
+ const match = /^(\d{1,3})\s*\/\s*(week|month|total)$/.exec(text);
7
+ const count = match ? Number(match[1]) : 0;
8
+ if (!match || count < 1 || count > 100)
9
+ throw new Error("--posting-target must be <n>/month, <n>/total or <n>/week with n from 1 to 100, or none.");
10
+ return { count, period: match[2] };
11
+ }
12
+ /** A JSON array of emails, or emails separated by commas, semicolons or whitespace. */
13
+ export function parseEmailList(value) {
14
+ const text = value.trim();
15
+ let items;
16
+ if (text.startsWith("[")) {
17
+ items = JSON.parse(text);
18
+ if (!Array.isArray(items) || !items.every((item) => typeof item === "string"))
19
+ throw new Error("--emails must be a JSON array of strings or a comma-separated list.");
20
+ }
21
+ else
22
+ items = text.split(/[\s,;]+/);
23
+ const emails = items.map((item) => item.trim()).filter(Boolean);
24
+ if (!emails.length)
25
+ throw new Error("--emails needs at least one email address.");
26
+ return emails;
27
+ }
1
28
  export function parseField(value, field) {
29
+ if (field.kind === "posting_target")
30
+ return parsePostingTarget(String(value));
31
+ if (field.kind === "email_list")
32
+ return parseEmailList(String(value));
33
+ // Comma-separated words; an empty value is an empty list (it clears).
34
+ if (field.kind === "text_list")
35
+ return String(value).split(",").map((item) => item.trim()).filter(Boolean);
2
36
  if (field.kind === "array" || field.kind === "object") {
3
37
  const parsed = JSON.parse(String(value));
4
38
  if (field.kind === "array"
@@ -9,6 +9,11 @@ export type RecipeToolRunOptions = {
9
9
  connectionId?: string;
10
10
  /** Pin the credential rail instead of automatically selecting a connected key. */
11
11
  credentialMode?: "managed" | "user_api_key" | "user_oauth";
12
+ /**
13
+ * With credentialMode user_api_key on a LinkedIn rail (scraper.*) tool: run on
14
+ * the workspace's own Up2Data key rather than its HarvestAPI key.
15
+ */
16
+ byokKeySource?: "up2data";
12
17
  };
13
18
  export type RecipeToolApi = {
14
19
  run: <T = unknown>(toolId: string, payload?: Record<string, unknown>, options?: RecipeToolRunOptions) => Promise<T>;
@@ -113,9 +118,17 @@ export type RecipeCrmExternalRef = {
113
118
  provider_event_id?: string | null;
114
119
  confidence?: number | null;
115
120
  };
121
+ /** Source evidence only; never registers or resolves a provider external identity. */
122
+ export type RecipeCrmSourceProvenance = {
123
+ provider: string;
124
+ object: string;
125
+ external_id: string;
126
+ provider_account: string;
127
+ };
116
128
  export type RecipeCrmApi = {
117
129
  assert: <T = unknown>(object: string, identity: RecipeCrmIdentity, values: Record<string, unknown> | undefined, options: {
118
130
  key: string;
131
+ provenance?: RecipeCrmSourceProvenance;
119
132
  }) => Promise<T>;
120
133
  assertFromProvider: <T = unknown>(object: string, identity: RecipeCrmIdentity, values: Record<string, unknown> | undefined, external: RecipeCrmExternalRef, options: {
121
134
  key: string;
@@ -348,6 +348,8 @@ export declare function resolveBasePricingPlan(tier: string | null | undefined):
348
348
  export declare function applyPricingPlanMetadataOverrides(// skipcq: JS-R1005
349
349
  plan: PricingPlanDefinition, metadata: PricingPlanMetadata): PricingPlanDefinition;
350
350
  export declare function isSelfServePlanTier(value: string): value is SelfServePlanTier;
351
+ export declare const MANAGED_PHONE_PLAN_TIERS: readonly PlanTier[];
352
+ export declare function planAllowsManagedPhoneEnrichment(tier: string): boolean;
351
353
  /** True for the six rungs a customer may buy or switch to. */
352
354
  export declare function isPurchasablePlanKey(value: string): value is OxygenPlanKey;
353
355
  export declare function isBillingCurrency(value: string): value is BillingCurrency;
@@ -368,6 +370,25 @@ export type CreditCycleWindow = {
368
370
  /** "July 2026" | "Jun 10 – Jul 10" */
369
371
  label: string;
370
372
  };
373
+ /**
374
+ * Whether a subscription row has a real Stripe object behind it that Stripe's
375
+ * own APIs (retrieve, update, invoice preview, portal flows) can act on.
376
+ *
377
+ * Three kinds of row say no: a staff-invoiced manual contract (`manual_contract_`
378
+ * id and/or `off_stripe` in either spelling the codebase has written), a
379
+ * control-plane-only internal grant (`internal_dogfood_…`), and a hand-inserted
380
+ * synthetic row that DOES carry a `sub_` prefix but marks itself
381
+ * `metadata.synthetic: true`. That last shape is why the id prefix alone is not
382
+ * the test: the dev dogfood org's `sub_synthetic_dev_dogfood_…` row passed every
383
+ * `startsWith("sub_")` check and reached `stripe.subscriptions.retrieve`, where
384
+ * Stripe answered "No such subscription" and the customer read the generic
385
+ * "Unable to preview the plan change." Every self-serve Stripe mutation must
386
+ * refuse these rows before it calls Stripe, with a message that names the fix.
387
+ */
388
+ export declare function isStripeManagedSubscription(subscription: {
389
+ stripeSubscriptionId?: string | null;
390
+ metadata?: Record<string, unknown> | null;
391
+ }): boolean;
371
392
  /**
372
393
  * The window the credit-allowance bar measures: "this month's credits", resolved
373
394
  * to the boundary at which the plan's grant actually renews.
@@ -678,6 +678,23 @@ plan, metadata) {
678
678
  export function isSelfServePlanTier(value) {
679
679
  return SELF_SERVE_PLAN_TIERS.includes(value);
680
680
  }
681
+ // Keyed by PlanTier so a new tier is a compile error here until someone decides
682
+ // it. The hand-typed list this replaced predated the 2026-09-19 "oxygen" tier,
683
+ // so every customer on the one purchasable plan was refused managed phone
684
+ // enrichment that Free and the grandfathered plans still had.
685
+ const MANAGED_PHONE_PLAN_TIER_ALLOWED = {
686
+ free: true,
687
+ oxygen: true,
688
+ starter: true,
689
+ pro: true,
690
+ team: true,
691
+ scale: true,
692
+ enterprise: true,
693
+ };
694
+ export const MANAGED_PHONE_PLAN_TIERS = Object.keys(MANAGED_PHONE_PLAN_TIER_ALLOWED).filter((tier) => MANAGED_PHONE_PLAN_TIER_ALLOWED[tier]);
695
+ export function planAllowsManagedPhoneEnrichment(tier) {
696
+ return MANAGED_PHONE_PLAN_TIERS.includes(tier);
697
+ }
681
698
  /** True for the six rungs a customer may buy or switch to. */
682
699
  export function isPurchasablePlanKey(value) {
683
700
  return PURCHASABLE_PLAN_KEYS.includes(value);
@@ -755,6 +772,34 @@ function formatUtcDay(date) {
755
772
  timeZone: "UTC",
756
773
  });
757
774
  }
775
+ /**
776
+ * Whether a subscription row has a real Stripe object behind it that Stripe's
777
+ * own APIs (retrieve, update, invoice preview, portal flows) can act on.
778
+ *
779
+ * Three kinds of row say no: a staff-invoiced manual contract (`manual_contract_`
780
+ * id and/or `off_stripe` in either spelling the codebase has written), a
781
+ * control-plane-only internal grant (`internal_dogfood_…`), and a hand-inserted
782
+ * synthetic row that DOES carry a `sub_` prefix but marks itself
783
+ * `metadata.synthetic: true`. That last shape is why the id prefix alone is not
784
+ * the test: the dev dogfood org's `sub_synthetic_dev_dogfood_…` row passed every
785
+ * `startsWith("sub_")` check and reached `stripe.subscriptions.retrieve`, where
786
+ * Stripe answered "No such subscription" and the customer read the generic
787
+ * "Unable to preview the plan change." Every self-serve Stripe mutation must
788
+ * refuse these rows before it calls Stripe, with a message that names the fix.
789
+ */
790
+ export function isStripeManagedSubscription(subscription) {
791
+ // A row with no usable id is not something Stripe manages; answer false
792
+ // rather than throw, because this runs inside read-only pages (the billing
793
+ // summary) where a throw becomes a 500 for the whole page.
794
+ if (typeof subscription.stripeSubscriptionId !== "string")
795
+ return false;
796
+ if (!subscription.stripeSubscriptionId.startsWith("sub_"))
797
+ return false;
798
+ const metadata = subscription.metadata ?? {};
799
+ return (metadata.off_stripe !== true
800
+ && metadata.off_stripe !== "true"
801
+ && metadata.synthetic !== true);
802
+ }
758
803
  /**
759
804
  * The window the credit-allowance bar measures: "this month's credits", resolved
760
805
  * to the boundary at which the plan's grant actually renews.
@@ -72,13 +72,18 @@ export const NATIVE_API_KEY_CONNECT_INTEGRATION_IDS = new Set([
72
72
  // Native api-key connect added in catalog.ts (2026-09-10): connect writes an
73
73
  // integration_secrets api_key row the native parallel.* runner reads. T-104.
74
74
  "parallel",
75
+ "pipedrive",
75
76
  "predictleads",
76
77
  "prospeo",
77
78
  "scalelist",
79
+ // scrapeli / up2data (2026-09-25): the company-followers and LinkedIn post lanes
80
+ // offer "Bring your own API key"; both connect forms live in catalog.ts.
81
+ "scrapeli",
78
82
  // scraper: dev v1.920.2 added the HarvestAPI workspace-key connect (native api-key form).
79
83
  "scraper",
80
84
  "serper",
81
85
  "signalbase",
86
+ "up2data",
82
87
  "zapmail",
83
88
  "zerobounce",
84
89
  ]);
@@ -57,5 +57,15 @@ export declare function getCapabilityRouteMatch(id: string): CapabilityRouteMatc
57
57
  export declare function inferCapabilityRoute(query: string): CapabilityRouteMatch | null;
58
58
  export declare function inferPrimitiveRoute(query: string): PrimitiveRouteMatch | null;
59
59
  export declare function serializeCapabilityRoute(route: CapabilityRouteMatch | null): Record<string, unknown> | null;
60
+ export declare const COMPANY_PAGE_FOLLOWERS_COMMAND = "oxygen tables followers check --company <linkedin company page URL> --records <n> --json";
60
61
  /** The exact `companies search plan` invocation for the url_recovery intent. */
61
62
  export declare const COMPANY_URL_RECOVERY_COMMAND = "oxygen companies search plan --prompt \"Find the websites for these companies: <company names>\" --source-intent url_recovery";
63
+ /** The free native preview for Google Maps businesses; import follows its reviewed quote. */
64
+ export declare const LOCAL_BUSINESS_SCRAPE_COMMAND = "oxygen companies search preview --source google_maps --filters-json '{\"q\":\"<category> in <city, region>\"}'";
65
+ /**
66
+ * The free list of classification columns. Production answers it with its one
67
+ * classification template (B2B or B2C); a deployment that runs the native
68
+ * Classify column lists that entry here too. Any other scheme is an AI prompt
69
+ * column through `columns add`, which the route recommends next.
70
+ */
71
+ export declare const ROW_CLASSIFICATION_COMMAND = "oxygen columns catalog --family classification";
@@ -195,15 +195,18 @@ export const OXYGEN_CAPABILITY_ROUTES = [
195
195
  id: "records",
196
196
  layer: "Data",
197
197
  primitive: "records",
198
- owns: "Canonical companies, people, deals, identities, relationships, activities, merges, and CRM sync state.",
198
+ owns: "Canonical companies, people, deals, identities, relationships, activities, notes, tasks (reminders), files, merges, and CRM sync state.",
199
199
  notFor: "Disposable sourcing, enrichment, or scoring work; use Tables before promotion.",
200
- execution: "Operate the hosted native CRM and preserve field/relationship provenance.",
200
+ execution: "Operate the hosted native CRM and preserve field/relationship provenance. Importing FROM a connected CRM lands here via `crm sync import <provider> --object contacts|companies`, which 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 the user can filter and enrich — optionally suppressing a do-not-contact list/filter first — use `tables hubspot import` or `tables pipedrive import` instead. Open work across the workspace is one read: `crm tasks list`.",
201
201
  posture: "workspace_write",
202
202
  gatewayTools: ["oxygen_crm_record_search", "oxygen_crm_record_get"],
203
- gatewayCommands: ["crm search", "crm get", "crm describe"],
203
+ gatewayCommands: ["crm search", "crm get", "crm describe", "crm tasks list", "crm notes list"],
204
204
  skills: ["oxygen-gtm"],
205
205
  endpointSections: ["crm"],
206
- intentTerms: ["record", "records", "crm", "company record", "contact", "person record", "deal", "timeline", "merge", "relationship"],
206
+ // "reminder"/"to-do"/"open tasks" routed to workspace-access before these
207
+ // terms existed (blind acceptance, 2026-09-22): the customer's phrasing for
208
+ // the Tasks read has to land on the primitive that owns it.
209
+ intentTerms: ["record", "records", "crm", "company record", "contact", "person record", "deal", "timeline", "merge", "relationship", "note", "notes", "task", "tasks", "reminder", "reminders", "to-do", "open tasks", "follow-up", "attachment", "files on a record"],
207
210
  },
208
211
  {
209
212
  id: "tables",
@@ -222,12 +225,19 @@ export const OXYGEN_CAPABILITY_ROUTES = [
222
225
  // `tables create` / `columns add` / `enrich-column preview` — none of which
223
226
  // do it. The agent only found the right command by grepping the raw 25k-line
224
227
  // command manifest, which is not a discovery path a customer has.
228
+ // "hubspot list" / "saved segment" / "list import" route here for
229
+ // `tables hubspot import`. Added after the 2026-09-21 blind ACCEPTANCE run:
230
+ // the capability shipped that same day, the agent searched "import hubspot
231
+ // contacts", was routed to `records`, read `crm sync import --help`, found
232
+ // no --list flag, and reported segment-scoped import as a MISSING native
233
+ // capability. The records execution text now names the table lane too,
234
+ // because that is where this phrasing legitimately lands.
225
235
  // "founders" / "parent company" / "cloud provider" / "company research" route
226
236
  // here for `columns catalog`. Added after the 2026-09-16 blind baseline asked
227
237
  // `capabilities search "founders of a company"` and got `route: null` with a
228
238
  // generic sourcing pointer, then hand-wrote four research prompts for
229
239
  // questions the template catalog now answers by key.
230
- intentTerms: ["table", "spreadsheet", "rows", "column", "columns", "dataset", "csv", "import", "enrich", "enrichment", "waterfall", "score", "formula", "ai column", "lookup", "link", "link tables", "join", "connect", "relate", "relationship", "function", "functions", "reusable function", "function draft", "function version", "callable", "column template", "skill set", "grad school", "podcast appearance", "keynote", "github profile", "job fit", "founders", "parent company", "subsidiaries", "cloud provider", "company research", "research question", "research column", "web research", "template catalog", "ready-made", "offers demos", "naics", "cost per row", "price per row", "what can i enrich", "enrichment cost", "credits per row"],
240
+ intentTerms: ["table", "spreadsheet", "rows", "column", "columns", "dataset", "csv", "import", "hubspot list", "hubspot segment", "pipedrive filter", "pipedrive", "saved list", "saved segment", "saved filter", "crm segment", "list import", "segment import", "filter import", "import a segment", "import a list", "import a filter", "enrich", "enrichment", "waterfall", "score", "formula", "ai column", "lookup", "link", "link tables", "join", "connect", "relate", "relationship", "function", "functions", "reusable function", "function draft", "function version", "callable", "column template", "skill set", "grad school", "podcast appearance", "keynote", "github profile", "job fit", "founders", "parent company", "subsidiaries", "cloud provider", "company research", "research question", "research column", "web research", "template catalog", "ready-made", "offers demos", "naics", "cost per row", "price per row", "what can i enrich", "enrichment cost", "credits per row"],
231
241
  },
232
242
  {
233
243
  id: "messages",
@@ -363,7 +373,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
363
373
  // "remove program" / "delete program" landed on route: null in a blind eval
364
374
  // (2026-09-10); a program is a UGC noun here, and its lifecycle verbs must
365
375
  // resolve to the group that owns `ugc programs archive|delete|duplicate`.
366
- intentTerms: ["publish", "publishing", "schedule post", "content calendar", "posting calendar", "approve post", "social publishing", "ugc", "creator program", "user generated content", "creator voice", "sponsored creator", "ugc program", "archive program", "delete program", "remove program", "duplicate program", "copy program"],
376
+ intentTerms: ["publish", "publishing", "schedule post", "content calendar", "posting calendar", "approve post", "social publishing", "video cover", "video thumbnail", "cover image", "post preview", "ugc", "creator program", "user generated content", "creator voice", "sponsored creator", "ugc program", "archive program", "delete program", "remove program", "duplicate program", "copy program"],
367
377
  },
368
378
  {
369
379
  id: "workflows",
@@ -419,7 +429,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
419
429
  gatewayCommands: ["search plan", "sourcing plan", "find company", "tools search"],
420
430
  skills: ["oxygen-gtm"],
421
431
  endpointSections: ["companies", "find", "lead-sourcing", "people", "search", "sourcing", "tools", "verify"],
422
- intentTerms: ["find companies", "find people", "source leads", "build a list", "tam", "prospect", "provider", "tool catalog", "find email", "find phone"],
432
+ intentTerms: ["find companies", "find people", "source leads", "build a list", "tam", "prospect", "provider", "tool catalog", "find email", "find phone", "local businesses", "local business", "google maps", "storefronts"],
423
433
  },
424
434
  {
425
435
  id: "linkedin-public-research",
@@ -487,15 +497,15 @@ export const OXYGEN_CAPABILITY_ROUTES = [
487
497
  id: "connected-linkedin",
488
498
  layer: "External",
489
499
  primitive: null,
490
- owns: "The workspace LinkedIn account network/private data, relationship context such as mutual connections, and connected-account writes used by Signals, Messages, Sequences, and Publishing.",
500
+ owns: "The workspace LinkedIn account network/private data — everyone you are connected to and everyone who follows you, captured into one self-refreshing 'LinkedIn Network' table — relationship context such as mutual connections, and connected-account writes used by Signals, Messages, Sequences, and Publishing.",
491
501
  notFor: "Ordinary public or third-party LinkedIn research; use the cookieless native scraper.",
492
- execution: "For mutual/shared connection counts, resolve the connected account with `oxygen senders list --json`, inspect `oxygen tools get linkedin.users_get --json`, then run `oxygen tools run linkedin.users_get --input-json '{\"identifier\":\"<profile-url>\",\"account_id\":\"<connected-account-id>\",\"notify\":false}' --mode live --json`. This read is non-notifying and no-bill. An unavailable count is explicit and never means zero; retrying the same account/profile pair is not expected to reveal a provider-hidden count. Otherwise use the owning primitive gateway; the connected account provides identity and transport, not a competing product runtime.",
502
+ execution: "To put your own network in a table — connections and followers, one row per person with a Relationship column, kept current in the background — run `oxygen linkedin network setup --json` (add --account <ref> for one account; `oxygen linkedin network status` reports the pace and each account's state, `oxygen linkedin network pause` switches it off). Open connection requests — received and sent, on every connected account, in one table that marks each one accepted or no_longer_pending when it disappears — are `oxygen linkedin invitations setup --json` (`status` and `pause` beside it). Profile viewers are the same per account: `oxygen viewers import --account <ref>` switches one on, `oxygen viewers pause --account <ref>` off, and `oxygen viewers status` lists every account's state and `viewed_account` (whose profile each person viewed). `oxygen viewers enrichment --on` enriches each NEW viewer's full profile automatically at the per-person price `viewers status` quotes (existing viewers never). It is free, read-only on the account, and paced under its own daily read budget. For mutual/shared connection counts, resolve the connected account with `oxygen senders list --json`, inspect `oxygen tools get linkedin.users_get --json`, then run `oxygen tools run linkedin.users_get --input-json '{\"identifier\":\"<profile-url>\",\"account_id\":\"<connected-account-id>\",\"notify\":false}' --mode live --json`. This read is non-notifying and no-bill. An unavailable count is explicit and never means zero; retrying the same account/profile pair is not expected to reveal a provider-hidden count. Otherwise use the owning primitive gateway; the connected account provides identity and transport, not a competing product runtime.",
493
503
  posture: "mixed",
494
- gatewayTools: ["oxygen_senders_list", "oxygen_linkedin_connections_import", "oxygen_inbox_list"],
495
- gatewayCommands: ["senders list", "connections import", "inbox list"],
504
+ gatewayTools: ["oxygen_linkedin_network_setup", "oxygen_linkedin_invitations_setup", "oxygen_senders_list", "oxygen_linkedin_connections_import", "oxygen_inbox_list"],
505
+ gatewayCommands: ["linkedin network setup", "linkedin invitations setup", "senders list", "connections import", "inbox list"],
496
506
  skills: ["oxygen-linkedin-marketing", "oxygen-sequencer", "oxygen-unibox"],
497
507
  endpointSections: ["linkedin"],
498
- intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
508
+ intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "my network", "whole network", "linkedin network", "connections and followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
499
509
  },
500
510
  {
501
511
  id: "connected-whatsapp",
@@ -634,6 +644,23 @@ export function serializeCapabilityRoute(route) {
634
644
  function normalizeIntent(query) {
635
645
  return query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
636
646
  }
647
+ /**
648
+ * "LinkedIn posts about X" / "posts mentioning Y" / "monitor LinkedIn posts by
649
+ * keyword" is keyword post search, a Signals source with all-of / any-of /
650
+ * exclude rules. A blind user eval (2026-09-23) asked exactly this and was
651
+ * routed to Workflows, never learning `signals search --family linkedin_posts`
652
+ * exists. Posts FROM named profiles stay with the Profile Watcher, and the
653
+ * workspace's own posts stay with Posts. The free preview leads: it shows real
654
+ * posts, with the AI filter applied, before a table exists.
655
+ */
656
+ const LINKEDIN_KEYWORD_POST_COMMAND = 'oxygen signals search preview --family linkedin_posts --keywords "<any of>" --all-keywords "<all of>" --exclude-keywords "<none of>" --ai-filter "<posts to keep>" --json';
657
+ function isLinkedInKeywordPostIntent(query) {
658
+ return /\blinkedin\b/.test(query)
659
+ && /\b(posts?|posting)\b/.test(query)
660
+ && /\b(keywords?|about|mention\w*|topics?|niche|talk\w* about|viral|trending)\b/.test(query)
661
+ && !/\bprofiles?\b/.test(query)
662
+ && !/\b(my|our) (own )?posts?\b/.test(query);
663
+ }
637
664
  function isLinkedInProfileWatcherIntent(query) {
638
665
  return /\blinkedin\b/.test(query)
639
666
  && /\bprofiles?\b/.test(query)
@@ -641,9 +668,62 @@ function isLinkedInProfileWatcherIntent(query) {
641
668
  && (/\b(engagers?|engaging|engagement|reactions?|comments?|posts?)\b/.test(query) || /\bprofile watcher\b/.test(query))
642
669
  && !isNetNewLinkedInInitiation(query);
643
670
  }
671
+ /**
672
+ * "My LinkedIn connections and followers as a table" is the connected account's
673
+ * own network, not a Tables question. Without this the phrase "into a table"
674
+ * routed a blind user to the generic Tables card, which never names
675
+ * `linkedin network setup` (blind eval, 2026-09-22).
676
+ */
677
+ function isOwnLinkedInNetworkIntent(query) {
678
+ if (!/\blinkedin\b/.test(query))
679
+ return false;
680
+ if (/\b(mutual|shared|in common|public|third party|cookieless|post engagers?)\b/.test(query))
681
+ return false;
682
+ const ownNetwork = /\b(my|our|own|whole|entire|existing) (linkedin )?(network|connections|followers)\b/.test(query)
683
+ || /\b(everyone|everybody|people) (i am|i'm|we are|we're) connected (to|with)\b/.test(query)
684
+ || /\blinkedin network\b/.test(query);
685
+ const bothHalves = /\bconnections?\b/.test(query) && /\bfollowers?\b/.test(query);
686
+ return (ownNetwork || bothHalves) && !isNetNewLinkedInInitiation(query);
687
+ }
688
+ export const COMPANY_PAGE_FOLLOWERS_COMMAND = "oxygen tables followers check --company <linkedin company page URL> --records <n> --json";
689
+ /**
690
+ * "The people who follow HubSpot on LinkedIn" / "a competitor's followers" is a
691
+ * vendor order of a company page's audience (`tables followers`), not the
692
+ * workspace's own network and not a signal. The Signals card claims the bare
693
+ * word "follower", so without this a blind user (2026-09-24) got only
694
+ * `signals *` from `commands search "company followers"` and priced the order
695
+ * through raw `tools run` instead. The own-network ask stays with its owner.
696
+ */
697
+ function isCompanyPageFollowersIntent(query) {
698
+ const followers = /\bfollowers?\b/.test(query) || /\b(who|that) follows?\b/.test(query);
699
+ if (!followers || isOwnLinkedInNetworkIntent(query))
700
+ return false;
701
+ if (/\bfollows? (me|us)\b/.test(query) || /\b(my|our) (own )?(linkedin )?followers\b/.test(query))
702
+ return false;
703
+ return /\b(compan(y|ies)|competitors?|pages?|brands?|organi[sz]ations?)\b/.test(query)
704
+ || (/\blinkedin\b/.test(query) && /\bfollows? (?!me\b|us\b)[a-z0-9]/.test(query));
705
+ }
706
+ /**
707
+ * Open connection requests on the workspace's own accounts are the connected
708
+ * account's data. A blind user searching "pending LinkedIn connection requests"
709
+ * was routed nowhere near `linkedin invitations` (blind eval, 2026-09-24).
710
+ */
711
+ function isOpenLinkedInRequestsIntent(query) {
712
+ if (!/\blinkedin\b/.test(query))
713
+ return false;
714
+ // Sending requests to leads is Sequences, not this table.
715
+ if (/\b(send|sending|enroll|sequence|campaign|outreach|cadence)\b/.test(query))
716
+ return false;
717
+ return /\b(connection|invitation|invite|connect) requests?\b/.test(query)
718
+ || /\b(pending|open|unanswered|outstanding|received|sent) (linkedin )?(invitations?|invites?)\b/.test(query);
719
+ }
644
720
  function explicitCapabilityIntent(query) {
645
721
  if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
646
722
  return ROUTE_BY_ID.get("visual-rendering") ?? null;
723
+ if (isOwnLinkedInNetworkIntent(query) || isOpenLinkedInRequestsIntent(query))
724
+ return ROUTE_BY_ID.get("connected-linkedin") ?? null;
725
+ if (isCompanyPageFollowersIntent(query))
726
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
647
727
  // A whole-motion ask ("set up inbound-led outbound for my posts") is a
648
728
  // Recipe kit + playbook, not the connected-LinkedIn signals card that the
649
729
  // "my LinkedIn … engagers" rule below would otherwise claim. Resolve it first.
@@ -667,6 +747,21 @@ function explicitCapabilityIntent(query) {
667
747
  }
668
748
  if (isLinkedInProfileWatcherIntent(query))
669
749
  return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
750
+ // Profile viewers exist only as a connected account's data, and their capture
751
+ // owns its own on/off, pause and automatic enrichment. "Automatically enrich
752
+ // new profile viewers" reads as workflow language, but a hand-built workflow is
753
+ // the wrong answer when `viewers enrichment --on` is the switch (2026-09-23
754
+ // enrichment baseline).
755
+ if (/\bprofile viewers?\b/.test(query))
756
+ return ROUTE_BY_ID.get("connected-linkedin") ?? null;
757
+ if (isLinkedInKeywordPostIntent(query))
758
+ return ROUTE_BY_ID.get("signals") ?? null;
759
+ // Ahead of the workflow rule, whose "retry" would claim it. Why a post failed and how to get it out is Publishing's attempt ledger and
760
+ // unlock path: the 2026-09-25 stuck-post baseline sent "publishing failed post
761
+ // retry outcome uncertain" to Workflows and "why did my LinkedIn post fail to
762
+ // publish" to the connected-account catalog.
763
+ if (isFailedPostIntent(query))
764
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
670
765
  if (isHostedWorkflowIntent(query))
671
766
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
672
767
  if (isOwnedPostCommentIntent(query))
@@ -688,6 +783,17 @@ function explicitCapabilityIntent(query) {
688
783
  if (isMutualLinkedInConnectionsIntent(query)) {
689
784
  return ROUTE_BY_ID.get("connected-linkedin") ?? null;
690
785
  }
786
+ // Authoring a post's video cover or previewing a draft is Publishing work even
787
+ // when the ask says "LinkedIn post"; the public-read rule below otherwise sent
788
+ // "choose video cover frame for linkedin post" to the paid cookieless research
789
+ // catalog (blind acceptance run, 2026-09-24).
790
+ if (isPostCoverOrPreviewIntent(query))
791
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
792
+ // An X thread, poll, alt text or reply control is how an X post is authored, not
793
+ // a conversation thread (Messages): the 2026-09-25 X-formats baseline routed
794
+ // "thread" to Messages and "X poll post" nowhere.
795
+ if (isXPostFormatIntent(query))
796
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
691
797
  // A deferred acquisition motion starts with the public-data owner. This keeps
692
798
  // "find/qualify now, contact later" from skipping straight to the final write
693
799
  // step; the recommendation still hands the eventual initiation to Sequences.
@@ -728,6 +834,13 @@ function explicitCapabilityIntent(query) {
728
834
  }
729
835
  if (/\b(message )?reviews?\b/.test(query))
730
836
  return ROUTE_BY_PRIMITIVE.get("messages") ?? null;
837
+ // Sorting each row into categories ("classify companies by industry with
838
+ // confidence", "label every row as agency, SaaS or other") is a Tables
839
+ // column, not a workspace Tag. Before this rule the 2026-09-25 blind baseline
840
+ // got `route: null` for "classify companies by industry with confidence",
841
+ // and the "label" wording fell to the Tags card below.
842
+ if (isRowClassificationIntent(query))
843
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
731
844
  if (/\b(tag|tags|label|labels)\b/.test(query))
732
845
  return ROUTE_BY_PRIMITIVE.get("tags") ?? null;
733
846
  if (/\b(disposable|working)\b.{0,30}\b(list|dataset|table)\b|\b(prospect|sourcing) table\b/.test(query)) {
@@ -768,6 +881,12 @@ function explicitCapabilityIntent(query) {
768
881
  // company_enrich preset instead.
769
882
  if (isCompanyUrlRecoveryIntent(query))
770
883
  return ROUTE_BY_ID.get("sourcing-and-provider-tools") ?? null;
884
+ // A category-plus-place ask ("dental practices in Austin", "local businesses",
885
+ // "scrape Google Maps") is the companies-search `local` intent. A 2026-09-21
886
+ // blind user typing "find local businesses like dental practices in a city"
887
+ // got NO route and fell back to browsing every command group's --help.
888
+ if (isLocalBusinessIntent(query))
889
+ return ROUTE_BY_ID.get("sourcing-and-provider-tools") ?? null;
771
890
  if (/\b(find emails?|find phones?|find companies|find people|source (?:leads?|a list|companies|people)|build (?:me )?a list|tam|prospect)\b/.test(query)) {
772
891
  return ROUTE_BY_ID.get("sourcing-and-provider-tools") ?? null;
773
892
  }
@@ -822,6 +941,17 @@ function highestScoringRoute(query) {
822
941
  }
823
942
  /** The exact `companies search plan` invocation for the url_recovery intent. */
824
943
  export const COMPANY_URL_RECOVERY_COMMAND = 'oxygen companies search plan --prompt "Find the websites for these companies: <company names>" --source-intent url_recovery';
944
+ /** The free native preview for Google Maps businesses; import follows its reviewed quote. */
945
+ export const LOCAL_BUSINESS_SCRAPE_COMMAND = `oxygen companies search preview --source google_maps --filters-json '{"q":"<category> in <city, region>"}'`;
946
+ /**
947
+ * Storefronts and service businesses by category and place — the Scrape Google
948
+ * Maps Businesses table source. Named business categories are the everyday
949
+ * phrasing ("dentists in Austin"), so a short list of them is matched beside the
950
+ * generic words; "near me" and "in/around <place>" carry the same meaning.
951
+ */
952
+ function isLocalBusinessIntent(query) {
953
+ return /\b(local business(?:es)?|google maps|maps listings?|storefronts?|near me|(?:dental|medical|law|veterinary) (?:practices?|offices?|clinics?)|dentists?|restaurants?|cafes?|coffee shops?|gyms?|salons?|plumbers?|electricians?|contractors?|hvac|auto repair|real estate agents?|businesses (?:in|near|around) [a-z])\b/.test(query);
954
+ }
825
955
  function recommendationsFor(card, query) {
826
956
  if (card.id === "sourcing-and-provider-tools" && isCompanyUrlRecoveryIntent(query)) {
827
957
  return {
@@ -830,6 +960,20 @@ function recommendationsFor(card, query) {
830
960
  exactCommand: COMPANY_URL_RECOVERY_COMMAND,
831
961
  };
832
962
  }
963
+ if (card.primitive === "tables" && isCompanyPageFollowersIntent(query)) {
964
+ return {
965
+ tools: ["oxygen_tables_followers", "oxygen_tables_sources"],
966
+ commands: ["tables followers check", "tables followers order", "tables sources"],
967
+ exactCommand: COMPANY_PAGE_FOLLOWERS_COMMAND,
968
+ };
969
+ }
970
+ if (card.id === "sourcing-and-provider-tools" && isLocalBusinessIntent(query)) {
971
+ return {
972
+ tools: ["oxygen_companies_search_preview", "oxygen_companies_search_run"],
973
+ commands: ["companies search filters", "companies search preview", "companies search run"],
974
+ exactCommand: LOCAL_BUSINESS_SCRAPE_COMMAND,
975
+ };
976
+ }
833
977
  if (card.id === "connected-linkedin" && isMutualLinkedInConnectionsIntent(query)) {
834
978
  return {
835
979
  tools: ["oxygen_tools_get", "oxygen_tools_run_live", "oxygen_senders_list"],
@@ -916,6 +1060,17 @@ function recommendationsFor(card, query) {
916
1060
  commands: ["tables watcher preview", "tables watcher create", "tables watcher get", "tables watcher update", "tables watcher pause", "tables watcher resume"],
917
1061
  };
918
1062
  }
1063
+ if (isRowClassificationIntent(query)) {
1064
+ // The classification list (free) names the template production has and,
1065
+ // where the deployment runs it, the native Classify column; any other
1066
+ // scheme is a prompt column (free to add). The run's dry run prices it,
1067
+ // and the labelled rows are filtered natively with `tables query`.
1068
+ return {
1069
+ tools: ["oxygen_columns_catalog", "oxygen_columns_add", "oxygen_columns_run", "oxygen_tables_query"],
1070
+ commands: ["columns catalog", "columns add", "columns run", "tables query"],
1071
+ exactCommand: ROW_CLASSIFICATION_COMMAND,
1072
+ };
1073
+ }
919
1074
  if (isCompanyResearchQuestionIntent(query)) {
920
1075
  // A fact the template catalog already answers by key: list the keys first
921
1076
  // (free), add the column by key (free), then price and run it. Ahead of
@@ -1069,6 +1224,13 @@ function recommendationsFor(card, query) {
1069
1224
  };
1070
1225
  }
1071
1226
  }
1227
+ if (card.primitive === "signals" && isLinkedInKeywordPostIntent(query)) {
1228
+ return {
1229
+ tools: ["oxygen_signals_search_preview", "oxygen_signals_search_run"],
1230
+ commands: ["signals search preview", "signals search run"],
1231
+ exactCommand: LINKEDIN_KEYWORD_POST_COMMAND,
1232
+ };
1233
+ }
1072
1234
  if (card.primitive === "signals" && /\blinkedin\b.{0,40}\bengagers?\b/.test(query)) {
1073
1235
  return {
1074
1236
  tools: ["oxygen_engagement_list_engagers", "oxygen_signals_list", "oxygen_signals_leads_today"],
@@ -1093,6 +1255,36 @@ function isEmailVerificationIntent(query) {
1093
1255
  const addressThenGrade = /\b(e ?mails?|addresses)\b.{0,40}\b(verif(?:y|ied|ication)|validat(?:e|ed|ion)|valid|invalid|deliverable|(?:will|would|might) bounce|bounce risk|risky|safe to (?:send|email)|catch ?all|accept ?all)\b/;
1094
1256
  return gradeThenAddress.test(query) || addressThenGrade.test(query);
1095
1257
  }
1258
+ /**
1259
+ * The free list of classification columns. Production answers it with its one
1260
+ * classification template (B2B or B2C); a deployment that runs the native
1261
+ * Classify column lists that entry here too. Any other scheme is an AI prompt
1262
+ * column through `columns add`, which the route recommends next.
1263
+ */
1264
+ export const ROW_CLASSIFICATION_COMMAND = "oxygen columns catalog --family classification";
1265
+ /** Nouns another primitive owns: a classify ask that names one keeps that owner. */
1266
+ const ROW_CLASSIFICATION_OTHER_OWNER = /\b(inbox|repl(?:y|ies)|conversations?|threads?|messages?|dms?|e-?mails?|bounces?|signals?|visitors?|workflows?|deals?|crm|records?|icp|knowledge|wiki|pages?|ads|dashboards?|metrics?|agents?|runs)\b/;
1267
+ /** What one table row is: the noun a per-row classification is about. */
1268
+ const ROW_CLASSIFICATION_ROW_NOUN = /\b(rows?|columns?|tables?|compan(?:y|ies)|accounts?|leads?|contacts?|domains?|people|persons?)\b/;
1269
+ /**
1270
+ * Put every row of a table into one of a few buckets: classify or categorize
1271
+ * rows, companies, accounts, leads, contacts, domains or people (or anything
1272
+ * "with confidence"), "label every row as …", or a yes/no question asked per
1273
+ * row. The verb alone is not enough: "classify my records", "categorize my
1274
+ * workflows" or "classify leads into ICP tiers" name the primitive that owns
1275
+ * them, and conversation triage ("classify my inbox replies") keeps Messages.
1276
+ * A bare "tag these contacts" stays with Tags.
1277
+ */
1278
+ function isRowClassificationIntent(query) {
1279
+ if (ROW_CLASSIFICATION_OTHER_OWNER.test(query))
1280
+ return false;
1281
+ const classifyVerb = /\b(classif(?:y|ies|ied|ying|ication)|categori[sz](?:e|es|ed|ing|ation))\b/.test(query);
1282
+ return (classifyVerb && (ROW_CLASSIFICATION_ROW_NOUN.test(query) || /\bconfidence\b/.test(query)))
1283
+ || /\blabel(?:s|led|ed|ling|ing)?\b.{0,40}\b(?:each|every|all|per)\s+(?:rows?|compan(?:y|ies)|accounts?|leads?|contacts?|domains?)\b/.test(query)
1284
+ || /\blabel(?:s|led|ed|ling|ing)?\b.{0,80}\bconfidence\b/.test(query)
1285
+ || /\byes(?: or |\/)no\b.{0,60}\b(?:each|every|per)\s+(?:rows?|compan(?:y|ies)|accounts?|leads?|contacts?)\b/.test(query)
1286
+ || /\b(?:each|every|per)\s+(?:rows?|compan(?:y|ies)|accounts?|leads?|contacts?)\b.{0,60}\byes(?: or |\/)no\b/.test(query);
1287
+ }
1096
1288
  // "get company website URL from company name", "find the websites for this
1097
1289
  // list of company names", "recover domains for company names" — a URL-ish
1098
1290
  // noun, a company noun, a recovery verb, and the hint that the input is NAMES
@@ -1177,6 +1369,24 @@ function isSecondPartyDecisionIntent(query) {
1177
1369
  && !/\b(linkedin|public|published|post|posts|publishing)\b/.test(query);
1178
1370
  return decisionLanguage || clientDecision || internalThread;
1179
1371
  }
1372
+ /** An X (Twitter) post format: thread, poll, alt text, who-can-reply, quote, photo tags. */
1373
+ function isXPostFormatIntent(query) {
1374
+ return /\b(x|twitter|tweets?)\b/.test(query)
1375
+ && /\b(thread|poll|alt text|image descriptions?|reply settings?|who can reply|quote|photo tags?|tag (?:people|someone|accounts?) in)\b/.test(query);
1376
+ }
1377
+ /** A post's video cover (thumbnail) or a preview of a post being authored. Competitor ad creative stays with Ads. */
1378
+ function isFailedPostIntent(query) {
1379
+ if (/\b(ads?|competitors?|runs?|workflows?|sequences?|tables?|comments?)\b/.test(query))
1380
+ return false;
1381
+ return /\b(posts?|tweets?)\b/.test(query)
1382
+ && /\b(fail(s|ed|ing|ure)?|stuck|locked|uncertain|refused|rejected|retry|reschedul\w*)\b/.test(query);
1383
+ }
1384
+ function isPostCoverOrPreviewIntent(query) {
1385
+ if (/\b(ads?|competitors?)\b/.test(query))
1386
+ return false;
1387
+ return (/\b(cover|thumbnail)\b/.test(query) && /\b(post|video|draft)\b/.test(query))
1388
+ || /\bpreview\b.{0,40}\b(post|draft)\b/.test(query);
1389
+ }
1180
1390
  function isOwnedPostCommentIntent(query) {
1181
1391
  if (!/\bcomments?\b/.test(query) || !/\b(linkedin|posts?|publishing)\b/.test(query))
1182
1392
  return false;
@@ -1204,7 +1414,7 @@ function isPublicLinkedInRead(query) {
1204
1414
  return false;
1205
1415
  const disclaimsConnectedAccount = /\b(without|no|not using|does not require)\b.{0,24}\bconnected(?: linkedin)? account\b/.test(query);
1206
1416
  const connectedOwnership = !disclaimsConnectedAccount
1207
- && /\b(my|our|own|owned|connected|connections|followers|inbox|messages|recruiter|sales\s*navigator|salesnav|unipile|viewers)\b/.test(query);
1417
+ && /\b(my|our|own|owned|connected|connections|followers|inbox|messages|recruiter|sales\s*navigator|salesnav|unipile|viewers?)\b/.test(query);
1208
1418
  if (connectedOwnership)
1209
1419
  return false;
1210
1420
  return /\b(comments?|companies|company|competitor|cookieless|engagers?|harvest|posts?|profiles?|public|reactions?|reactors?|scrape|scraper|search|third[ -]party)\b/.test(query);
@@ -0,0 +1,8 @@
1
+ import { OxygenError } from "./cli-result.js";
2
+ export declare class CliHttpError extends OxygenError {
3
+ readonly status: number;
4
+ constructor(status: number, code: string, message: string, options?: {
5
+ details?: unknown;
6
+ exitCode?: number;
7
+ });
8
+ }
@@ -0,0 +1,8 @@
1
+ import { OxygenError } from "./cli-result.js";
2
+ export class CliHttpError extends OxygenError {
3
+ status;
4
+ constructor(status, code, message, options = {}) {
5
+ super(code, message, options);
6
+ this.status = status;
7
+ }
8
+ }
@@ -51,6 +51,7 @@ export function exitCodeForErrorCode(code) {
51
51
  case "max_credits_required":
52
52
  case "approval_required":
53
53
  case "effect_unknown_approval_required":
54
+ case "amplification_risk_acknowledgement_required":
54
55
  case "spend_cap_required":
55
56
  case "spend_cap_too_low":
56
57
  case "insufficient_credits":