@oxygen-agent/cli 1.1003.12 → 1.1010.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/column-decision-options.d.ts +20 -0
- package/dist/column-decision-options.js +54 -0
- package/dist/command-manifest.js +15 -2
- package/dist/functions-commands.js +11 -11
- package/dist/index.js +1222 -159
- package/dist/search-ai-filter-notice.d.ts +17 -0
- package/dist/search-ai-filter-notice.js +38 -0
- package/dist/skills.js +34 -10
- package/dist/util.d.ts +9 -0
- package/dist/util.js +14 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/billing.js +45 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
- package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
- package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
- package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
- package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
- package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
- package/node_modules/@oxygen/shared/dist/version.js +8 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
- package/node_modules/@oxygen/shared/package.json +9 -0
- 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
|
-
|
|
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);
|
|
@@ -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":
|