@oxygen-agent/cli 1.1011.974 → 1.1011.1091
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/command-manifest.js +18 -0
- package/dist/crm-support-flags.d.ts +1 -0
- package/dist/crm-support-flags.js +5 -1
- package/dist/customer-operation-commands.js +30 -1
- package/dist/help.js +10 -2
- package/dist/http-client.d.ts +4 -0
- package/dist/http-client.js +21 -10
- package/dist/index.js +272 -83
- package/dist/sender-limits-flags.d.ts +7 -0
- package/dist/sender-limits-flags.js +13 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.js +83 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +146 -11
- package/node_modules/@oxygen/shared/dist/cli-result.js +6 -0
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +19 -7
- package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.d.ts +7 -1
- package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.js +5 -1
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +4 -0
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +3 -3
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +21 -0
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +2 -2
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +48 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +94 -0
- package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +3 -2
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +42 -0
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +68 -0
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +33 -0
- package/node_modules/@oxygen/shared/package.json +5 -5
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +9 -5
- package/package.json +1 -1
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +0 -9
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +0 -33
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
1
2
|
import { type ContractFlagOverlay } from "./contract-flags.js";
|
|
3
|
+
/**
|
|
4
|
+
* `whatsapp limits set`: one flag per key the WhatsApp limits route keeps, with
|
|
5
|
+
* the WhatsApp defaults and maximums from the shared owner (OXP-11.3.19). The
|
|
6
|
+
* body is still built by buildSenderLimitsBody, which reads the same flag names.
|
|
7
|
+
*/
|
|
8
|
+
export declare function addWhatsAppLimitFlags(command: Command): Command;
|
|
2
9
|
/**
|
|
3
10
|
* `--working-days` → ISO weekday numbers. Accepts day names or 1-7, comma
|
|
4
11
|
* separated, and ranges (`mon-fri`, `1-5`). A token it cannot read is sent as
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
6
6
|
import { LINKEDIN_SENDER_LIMIT_DEFAULTS, LINKEDIN_SENDER_LIMIT_MAXIMUMS, linkedInSenderLimitCautionLevel, } from "@oxygen/shared/linkedin-sequences";
|
|
7
7
|
import { SENDER_LIMITS_PATCH_CONTRACT } from "@oxygen/shared/contracts/sender-limits";
|
|
8
|
+
import { WHATSAPP_SENDER_LIMIT_DEFAULTS, WHATSAPP_SENDER_LIMIT_KEYS, WHATSAPP_SENDER_LIMIT_MAXIMUMS, WHATSAPP_SENDER_LIMIT_NOTES, } from "@oxygen/shared/whatsapp-sender-limits";
|
|
8
9
|
import { readContractFlags } from "./contract-flags.js";
|
|
9
10
|
/** "<text> (default N, max M)." for a LinkedIn sender limit flag, from the enforced constants. */
|
|
10
11
|
function linkedInLimitHelp(key, text) {
|
|
@@ -48,6 +49,18 @@ const LIMIT_FLAG_NAMES = {
|
|
|
48
49
|
min_action_spacing_seconds: "min-spacing-seconds",
|
|
49
50
|
action_spacing_jitter_seconds: "spacing-jitter-seconds",
|
|
50
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* `whatsapp limits set`: one flag per key the WhatsApp limits route keeps, with
|
|
54
|
+
* the WhatsApp defaults and maximums from the shared owner (OXP-11.3.19). The
|
|
55
|
+
* body is still built by buildSenderLimitsBody, which reads the same flag names.
|
|
56
|
+
*/
|
|
57
|
+
export function addWhatsAppLimitFlags(command) {
|
|
58
|
+
for (const key of WHATSAPP_SENDER_LIMIT_KEYS) {
|
|
59
|
+
const flag = LIMIT_FLAG_NAMES[key] ?? key.replace(/_/g, "-");
|
|
60
|
+
command.option(`--${flag} <n>`, `${WHATSAPP_SENDER_LIMIT_NOTES[key]} (default ${WHATSAPP_SENDER_LIMIT_DEFAULTS[key]}, max ${WHATSAPP_SENDER_LIMIT_MAXIMUMS[key]}; higher values are clamped).`);
|
|
61
|
+
}
|
|
62
|
+
return command;
|
|
63
|
+
}
|
|
51
64
|
const WORKING_DAY_NAMES = {
|
|
52
65
|
mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, wednesday: 3, thu: 4, thur: 4, thurs: 4, thursday: 4,
|
|
53
66
|
fri: 5, friday: 5, sat: 6, saturday: 6, sun: 7, sunday: 7,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity a request asked about versus the one a provider answered with.
|
|
3
|
+
* Present on a refused attempt; `matched` is `false` whenever it is recorded.
|
|
4
|
+
*/
|
|
5
|
+
export type AttemptIdentityMatch = {
|
|
6
|
+
matched?: boolean;
|
|
7
|
+
requested?: string | null;
|
|
8
|
+
returned?: string | null;
|
|
9
|
+
};
|
|
10
|
+
/** Label for a skipped attempt's reason. Unknown reasons stay generic. */
|
|
11
|
+
export declare function describeSkipReason(reason: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Label for a `no_result` attempt whose answer was refused rather than absent,
|
|
14
|
+
* or `null` for an ordinary empty answer (which still reads "No result").
|
|
15
|
+
*/
|
|
16
|
+
export declare function describeRejectedAnswer(reason: string | null | undefined): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* One-line requested-vs-returned evidence for a refused attempt, for the cell's
|
|
19
|
+
* hover title. `null` when neither side named an identity — there is nothing to
|
|
20
|
+
* show and an empty tooltip is worse than none.
|
|
21
|
+
*/
|
|
22
|
+
export declare function describeIdentityMatch(identityMatch: AttemptIdentityMatch | null | undefined): string | null;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human labels for a waterfall attempt's recorded `reason`. Single source of
|
|
3
|
+
* truth for the /tables data grid (`tables-workspace.tsx`) and the MCP
|
|
4
|
+
* enrichment-cell-detail widget, so an attempt explains itself one way wherever
|
|
5
|
+
* it is shown (OXP-7.3.40).
|
|
6
|
+
*
|
|
7
|
+
* Every label is grid-cell sized — these render inside a truncated attempt cell,
|
|
8
|
+
* not a detail panel.
|
|
9
|
+
*/
|
|
10
|
+
const ATTEMPT_REASON_LABELS = {
|
|
11
|
+
missing_input: "Run condition not met",
|
|
12
|
+
// The company waterfall's spelling of `missing_input`: it needs at least one
|
|
13
|
+
// of domain / name / linkedin_url before any lane can be dispatched.
|
|
14
|
+
missing_identity_input: "Missing identity",
|
|
15
|
+
byok_not_connected: "Not connected",
|
|
16
|
+
byok_requires_paid_plan: "Plan required",
|
|
17
|
+
step_disabled: "Step disabled",
|
|
18
|
+
unsupported_credential_mode: "Mode unsupported",
|
|
19
|
+
unknown_capability: "Unknown provider",
|
|
20
|
+
// A provider answered about a DIFFERENT company than the request named. The
|
|
21
|
+
// answer was refused rather than written to the row (Plain T-111), so calling
|
|
22
|
+
// it "skipped" would describe the wrong event entirely.
|
|
23
|
+
identity_mismatch: "Wrong company",
|
|
24
|
+
// A search-shaped lane returned no identity at all, so its answer could not be
|
|
25
|
+
// checked against the requested company and was not trusted.
|
|
26
|
+
identity_unverifiable: "Identity unverified",
|
|
27
|
+
// A pre-step that ran only to resolve an identity (e.g. a company LinkedIn
|
|
28
|
+
// URL) a later requested field needs. It owns no target column and writes no
|
|
29
|
+
// cell — recorded so the extra provider call is visible, never silent.
|
|
30
|
+
identity_prerequisite: "Identity step",
|
|
31
|
+
// Bring-your-own-key lane with no usable connection for this workspace:
|
|
32
|
+
// not connected, unhealthy, missing secret, or plan-gated.
|
|
33
|
+
provider_credentials_unavailable: "No credentials",
|
|
34
|
+
// Priced above the run's approved credit ceiling, so it was never dispatched.
|
|
35
|
+
credit_ceiling_reached: "Credit cap reached",
|
|
36
|
+
// An identity pre-step lane the run stood down: the ceiling had no headroom
|
|
37
|
+
// above what the requested fields themselves need, so resolving the identity
|
|
38
|
+
// would have spent the answer-bearing lane's budget.
|
|
39
|
+
identity_prerequisite_unaffordable: "Identity step unfunded",
|
|
40
|
+
// Oxygen could not resolve a managed price for the lane, so it never ran.
|
|
41
|
+
managed_price_unavailable: "Price unavailable",
|
|
42
|
+
// The row's person LinkedIn input is a company or school page, or no profile
|
|
43
|
+
// link at all, so the row was refused before any lane ran.
|
|
44
|
+
linkedin_url_not_a_profile: "Not a profile URL",
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Reasons that mean "we got an answer and refused it", not "there was nothing
|
|
48
|
+
* to find". They arrive on an attempt whose status is `no_result`, where the
|
|
49
|
+
* bare "No result" reads as an absent answer and hides the rejection.
|
|
50
|
+
*/
|
|
51
|
+
const REJECTED_ANSWER_REASONS = new Set([
|
|
52
|
+
"identity_mismatch",
|
|
53
|
+
"identity_unverifiable",
|
|
54
|
+
]);
|
|
55
|
+
/** Label for a skipped attempt's reason. Unknown reasons stay generic. */
|
|
56
|
+
export function describeSkipReason(reason) {
|
|
57
|
+
return ATTEMPT_REASON_LABELS[reason] ?? "Skipped";
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Label for a `no_result` attempt whose answer was refused rather than absent,
|
|
61
|
+
* or `null` for an ordinary empty answer (which still reads "No result").
|
|
62
|
+
*/
|
|
63
|
+
export function describeRejectedAnswer(reason) {
|
|
64
|
+
if (!reason || !REJECTED_ANSWER_REASONS.has(reason))
|
|
65
|
+
return null;
|
|
66
|
+
return ATTEMPT_REASON_LABELS[reason] ?? null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One-line requested-vs-returned evidence for a refused attempt, for the cell's
|
|
70
|
+
* hover title. `null` when neither side named an identity — there is nothing to
|
|
71
|
+
* show and an empty tooltip is worse than none.
|
|
72
|
+
*/
|
|
73
|
+
export function describeIdentityMatch(identityMatch) {
|
|
74
|
+
if (!identityMatch)
|
|
75
|
+
return null;
|
|
76
|
+
const requested = typeof identityMatch.requested === "string" ? identityMatch.requested.trim() : "";
|
|
77
|
+
const returned = typeof identityMatch.returned === "string" ? identityMatch.returned.trim() : "";
|
|
78
|
+
if (!requested && !returned)
|
|
79
|
+
return null;
|
|
80
|
+
const asked = requested ? `Requested ${requested}` : "Requested company";
|
|
81
|
+
const got = returned ? `provider returned ${returned}` : "provider returned no identity";
|
|
82
|
+
return `${asked}; ${got}`;
|
|
83
|
+
}
|
|
@@ -119,6 +119,12 @@ export declare const SEQUENCE_UNSUBSCRIBE_COMMAND = "oxygen sequences update <se
|
|
|
119
119
|
* Factories, so a caller can edit the object it gets.
|
|
120
120
|
*/
|
|
121
121
|
export declare const CAPABILITY_ROUTE_MCP_CALLS: {
|
|
122
|
+
connectionPrices: () => {
|
|
123
|
+
tool: string;
|
|
124
|
+
arguments: {
|
|
125
|
+
view: string;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
122
128
|
connectedAppsList: () => {
|
|
123
129
|
tool: string;
|
|
124
130
|
arguments: {};
|
|
@@ -206,7 +206,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
206
206
|
primitive: "records",
|
|
207
207
|
owns: "Canonical companies, people, deals, identities, relationships, activities, notes, tasks (reminders), files, merges, and CRM sync state.",
|
|
208
208
|
notFor: "Disposable sourcing, enrichment, or scoring work; use Tables before promotion.",
|
|
209
|
-
execution: "Operate the hosted native CRM and preserve field/relationship provenance. Importing FROM a connected CRM lands here via `crm sync import <provider> --object <object>` (HubSpot contacts|companies, Attio people|companies, Pipedrive persons|organizations|deals), 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`.",
|
|
209
|
+
execution: "Operate the hosted native CRM and preserve field/relationship provenance. For an object inventory, `crm objects` supplies each backing Table; `tables preview <table> --fields <field-key> --summary-only` returns its exact total row count at 0 credits. Choose an existing column key from the object description for both counts and row lists (Companies: name; People: full_name). That total includes sample rows; native CRM reads mark seeded samples `_sample: true`; an absent marker means the record is not a seeded sample. Importing FROM a connected CRM lands here via `crm sync import <provider> --object <object>` (HubSpot contacts|companies, Attio people|companies, Pipedrive persons|organizations|deals), 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`.",
|
|
210
210
|
posture: "workspace_write",
|
|
211
211
|
gatewayTools: ["oxygen_crm_record_search", "oxygen_crm_record_get"],
|
|
212
212
|
gatewayCommands: ["crm search", "crm get", "crm describe", "crm tasks list", "crm notes list"],
|
|
@@ -225,8 +225,8 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
225
225
|
notFor: "Canonical CRM truth: use Records via oxygen_crm_objects_list, oxygen_crm_record_get or oxygen_crm_record_search. Not message cadence or an off-platform spreadsheet runtime.",
|
|
226
226
|
execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs. To see what a table's own columns can be enriched with and what each costs per row before adding anything, run `tools search --for-table <table>` (MCP oxygen_tools_search with for_table): free, it returns priced suggestions with the exact add command. For standard person or company enrichment, `columns add <table> --preset person_enrich|company_enrich` (MCP oxygen_columns_add with preset) adds the maintained bundle in one call before any hand-built tool column. Person questions over that bundle's profile payload (skill set, grad school, location, job fit, current company) and person appearance research (events, keynotes, podcasts, GitHub profile) are `columns catalog --category people` templates, added with `columns add <table> --prompt-key <key> --input <name>=<column>`. For a company fact OXYGEN already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, LinkedIn/Crunchbase page lookups) or a page extraction, `columns catalog` lists the ready-made template and `columns add <table> --prompt-key <key> --input <name>=<column>` (MCP oxygen_columns_add with prompt_key) adds it with no prompt to write. For ICP tiers, read the saved profile with `knowledge page get icp`, then add native Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt '<ICP criteria using {{evidence_column}} tokens>' --grounding workspace`. It costs 0.075 credits/row; preview with `columns run --dry-run`. `columns catalog --family classification --json` puts Classify in `data.native`; `data.entries` holds separate prompt templates that can cost more.",
|
|
227
227
|
posture: "mixed",
|
|
228
|
-
gatewayTools: ["oxygen_tables_create", "oxygen_tools_search", "oxygen_columns_add", "oxygen_columns_catalog", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk", "oxygen_callables_manage"],
|
|
229
|
-
gatewayCommands: ["tables create", "tools search", "columns add", "columns catalog", "enrich-column preview", "tables link", "functions list", "functions draft"],
|
|
228
|
+
gatewayTools: ["oxygen_tables_create", "oxygen_tools_search", "oxygen_columns_add", "oxygen_columns_catalog", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk", "oxygen_callables_manage", "oxygen_tables_records_import"],
|
|
229
|
+
gatewayCommands: ["tables create", "tools search", "columns add", "columns catalog", "enrich-column preview", "tables link", "functions list", "functions draft", "tables records import"],
|
|
230
230
|
skills: ["oxygen-tables", "oxygen-enrichment", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration", "oxygen-signals"],
|
|
231
231
|
endpointSections: ["action-columns", "callables", "functions", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "supabase", "table-action-items", "table-action-runs", "table-ingestion-runs", "tables"],
|
|
232
232
|
// "link"/"join"/"connect"/"relate" route here for `tables link`. Added after a
|
|
@@ -1041,7 +1041,9 @@ function routeCardFor(card, query) {
|
|
|
1041
1041
|
...card, posture: "paid_read",
|
|
1042
1042
|
execution: "Generate wording variations with the native Spintax owner; sending is separate. Hydrate oxygen_spintax_generate. External MCP uses oxygen_capabilities_call, including its explicit free mode=dry_run preview; built-in agents invoke the native capability under their current policy. Omitted mode is live and costs 3 credits. After preview, live requires approval of the exact text with approved=true and max_credits>=3. Failed generation refunds the reservation.",
|
|
1043
1043
|
};
|
|
1044
|
-
if (card.id === "knowledge-graph" &&
|
|
1044
|
+
if (card.id === "knowledge-graph" && isCanonicalCopyReadIntent(query))
|
|
1045
|
+
card = { ...card, posture: "read_only", execution: CANONICAL_COPY_READ_EXECUTION };
|
|
1046
|
+
else if (card.id === "knowledge-graph" && isWorkspaceVoiceIntent(query))
|
|
1045
1047
|
card = { ...card, execution: WORKSPACE_VOICE_EXECUTION };
|
|
1046
1048
|
if (card.primitive === "workflows" && isRecurringCompanyMonitoringIntent(query))
|
|
1047
1049
|
card = {
|
|
@@ -1063,12 +1065,18 @@ function routeCardFor(card, query) {
|
|
|
1063
1065
|
...card,
|
|
1064
1066
|
execution: "Create a workspace-private Table or inspect its schema and rows. Manual rows, column labels, visibility and member favorites cost 0 credits. Change only the display label with columns update (oxygen_columns_update); columns rename (oxygen_columns_rename) requires a new stable key. Favorite one table or folder with tables favorite (oxygen_tables_favorite), then read tables favorites (oxygen_tables_favorites). For tidy guidance, read oxygen-table-tidy with skills get (oxygen_skills_get); tables tidy-suggest (oxygen_tables_tidy_suggest) analyzes without changes. Read the owning schema, apply the requested scope, then describe or query the Table to verify it.",
|
|
1065
1067
|
};
|
|
1068
|
+
const recordsCopy = card.primitive === "tables" ? recordsTableImportRecommendation(query) : null;
|
|
1069
|
+
if (recordsCopy)
|
|
1070
|
+
card = { ...card, posture: "workspace_write", execution: recordsCopy.execution };
|
|
1066
1071
|
const maintenance = card.primitive === "tables" ? tableMaintenanceRecommendation(query) : null;
|
|
1067
1072
|
if (maintenance)
|
|
1068
1073
|
card = { ...card, execution: maintenance.execution };
|
|
1069
1074
|
const read = card.primitive === "tables" ? tableReadRecommendation(query) : null;
|
|
1070
1075
|
if (read)
|
|
1071
1076
|
card = { ...card, execution: read.execution };
|
|
1077
|
+
const removal = card.primitive === "tables" ? tableRowRemovalRecommendation(query) : null;
|
|
1078
|
+
if (removal)
|
|
1079
|
+
card = { ...card, posture: "workspace_write", execution: removal.execution };
|
|
1072
1080
|
const sent = card.id === "connected-linkedin" ? sentLinkedInInvitationReadRecommendation(query) : null;
|
|
1073
1081
|
if (sent)
|
|
1074
1082
|
card = { ...card, execution: sent.execution };
|
|
@@ -1162,6 +1170,23 @@ function isOwnLinkedInNetworkIntent(query) {
|
|
|
1162
1170
|
* claimed first (isUgcVoiceIntent); phone voice (numbers, calls) never matches.
|
|
1163
1171
|
* The blind baseline also searched "writing style" and "voice learn".
|
|
1164
1172
|
*/
|
|
1173
|
+
/**
|
|
1174
|
+
* Which voice, brand or positioning AI copy uses right now: a free read of the pinned
|
|
1175
|
+
* canonical doc. A blind operator searched "current canonical voice", got the wiki
|
|
1176
|
+
* search tools, and concluded no such read existed; "which voice does AI copy use"
|
|
1177
|
+
* read as "copy my voice" and was sent to paid synthesis (OXP-1.4.18).
|
|
1178
|
+
*/
|
|
1179
|
+
function isCanonicalCopyReadIntent(query) {
|
|
1180
|
+
if (/\b(?:learn\w*|synthesi[sz]\w*|generat\w*|creat\w*|writ\w*|set|pin|unpin|chang\w*|edit\w*|updat\w*|approv\w*|propos\w*|import\w*|calls?|phone|numbers?|creators?|ugc)\b/.test(query))
|
|
1181
|
+
return false;
|
|
1182
|
+
const copyType = /\b(?:voice|brand|positioning)\b/;
|
|
1183
|
+
if (!copyType.test(query))
|
|
1184
|
+
return false;
|
|
1185
|
+
return /\b(?:canonical|pinned|current|active|live)\b/.test(query)
|
|
1186
|
+
|| /\b(?:ai|generated) copy\b/.test(query)
|
|
1187
|
+
|| /\bwhich\b.{0,20}\b(?:voice|brand|positioning)\b/.test(query);
|
|
1188
|
+
}
|
|
1189
|
+
const CANONICAL_COPY_READ_EXECUTION = "Read the canonical copy AI uses with `oxygen context canonical get --type <voice|brand|positioning> --json` (MCP: oxygen_context_canonical_get), a free read. present=false means none is pinned, and grounding_when_absent says what AI copy uses instead. Pinning one (knowledge page pin) files a proposal a human approves.";
|
|
1165
1190
|
function isWorkspaceVoiceIntent(query) {
|
|
1166
1191
|
if (/\b(?:calls?|calling|dial\w*|phone|softphone|voicemail|numbers?)\b/.test(query))
|
|
1167
1192
|
return false;
|
|
@@ -1169,7 +1194,7 @@ function isWorkspaceVoiceIntent(query) {
|
|
|
1169
1194
|
|| /\b(?:learn|capture|mimic|copy|match)\w*\b.{0,30}\bvoice\b/.test(query)
|
|
1170
1195
|
|| /\bvoice\b.{0,30}\b(?:learn|capture|mimic|copy|match)\w*\b/.test(query);
|
|
1171
1196
|
}
|
|
1172
|
-
const WORKSPACE_VOICE_EXECUTION = "A writing voice is learned from the workspace's own sent LinkedIn and WhatsApp messages: `oxygen knowledge synthesize --kind voice --json` (MCP: oxygen_context_synthesize with kind voice) runs one low-cost AI call, priced as one low-reasoning AI column cell, the preview included, and reports credits_used; to
|
|
1197
|
+
const WORKSPACE_VOICE_EXECUTION = "A writing voice is learned from the workspace's own sent LinkedIn and WhatsApp messages: `oxygen knowledge synthesize --kind voice --json` (MCP: oxygen_context_synthesize with kind voice) runs one low-cost AI call, priced as one low-reasoning AI column cell, the preview included, and reports credits_used; to save the guide a preview showed, pass its body back with --approved --body (no new AI call) at 0 credits, because --approved alone synthesizes and charges again. Saving files a pin proposal: the guide becomes the canonical voice, which grounds message, reply, sequence and post drafts and AI columns, only once a human approves that proposal (oxygen knowledge proposals approve <id>; MCP oxygen_knowledge_proposal_decide). It reads sent messages only, not posts, emails or web pages. Brand voice and tone live in wiki pages (knowledge search). A creator's voice in a UGC program is `ugc voice import`. When the user already has the guide's text, no synthesis is needed: save it with `knowledge page upsert --type voice` and request the pin with `knowledge page pin` (MCP oxygen_knowledge_page_upsert with canonical: true), both 0 credits. Which voice AI copy uses now: `context canonical get --type voice` (MCP oxygen_context_canonical_get). To hard-cap a synthesis run, pass `--max-credits <n>` (MCP max_credits): a higher estimate is refused before the AI call, with nothing charged.";
|
|
1173
1198
|
/** One schedule for every channel of a Sequence, the same as Launch's Sending schedule. */
|
|
1174
1199
|
export const SEQUENCE_SENDING_SCHEDULE_COMMAND = "oxygen sequences update <sequence> --schedule-timezone <IANA zone> --schedule-days mon-fri --schedule-hours-start 09:00 --schedule-hours-end 17:00 --json";
|
|
1175
1200
|
/** Launch's unsubscribe switch: the visible link and the one-click headers together. */
|
|
@@ -1183,6 +1208,7 @@ export const SEQUENCE_UNSUBSCRIBE_COMMAND = "oxygen sequences update <sequence>
|
|
|
1183
1208
|
* Factories, so a caller can edit the object it gets.
|
|
1184
1209
|
*/
|
|
1185
1210
|
export const CAPABILITY_ROUTE_MCP_CALLS = {
|
|
1211
|
+
connectionPrices: () => ({ tool: "oxygen_billing_commitments", arguments: { view: "connection-prices" } }),
|
|
1186
1212
|
connectedAppsList: () => ({ tool: "oxygen_connector_list", arguments: {} }),
|
|
1187
1213
|
connectedAppEvents: () => ({ tool: "oxygen_connector_events_get", arguments: { connection: "<app>" } }),
|
|
1188
1214
|
workspacePagesList: () => ({ tool: "oxygen_settings_pages_get", arguments: {} }),
|
|
@@ -1242,6 +1268,13 @@ export function catalogueIntentMcpCall(intentId) {
|
|
|
1242
1268
|
// A Sequence's sending schedule or send window. Workflow language still wins
|
|
1243
1269
|
// ("a workflow that changes the send window nightly" is automation), and
|
|
1244
1270
|
// "sending hours" stays with the connected account's working hours.
|
|
1271
|
+
// Where each enrolled lead stands: its next message time, what holds it, why it
|
|
1272
|
+
// stopped. Both 2026-10-06 collateral acceptance operators searched for this
|
|
1273
|
+
// ("why is a lead stuck in a LinkedIn campaign, next message time") and got only
|
|
1274
|
+
// create/enroll/start, never the read that answers it.
|
|
1275
|
+
function isSequenceLeadProgressIntent(query) {
|
|
1276
|
+
return /\b(?:next (?:message|step|send)|stuck|blocked|held|per[- ]lead (?:status|progress)|why (?:\w+ )?(?:stopped|failed))\b/.test(query);
|
|
1277
|
+
}
|
|
1245
1278
|
function isSequenceScheduleIntent(query) {
|
|
1246
1279
|
return /\bsend(?:ing)? (?:schedule|window)s?\b/.test(query)
|
|
1247
1280
|
&& !/\b(workflow|automation|automate|webhook|cron)\b/.test(query);
|
|
@@ -1454,6 +1487,18 @@ function isCompanySourcingIntent(query) {
|
|
|
1454
1487
|
return /\b(companies|businesses|firms|company search|company sourcing)\b/.test(query)
|
|
1455
1488
|
&& /\b(search|plan|find|source|sourcing|discover|build|tam|prospect)\b/.test(query);
|
|
1456
1489
|
}
|
|
1490
|
+
/** Scoped row deletion keeps creation and enrichment out of its discovery path. */
|
|
1491
|
+
function tableRowRemovalRecommendation(query) {
|
|
1492
|
+
if (!/\brows?\b/.test(query) || !/\b(delete|remove)\b/.test(query)
|
|
1493
|
+
|| /\b(workflows?|agents?|campaigns?|sequences?|columns?|functions?|callables?|crm|records?|views?|dedup\w*|duplicates?)\b/.test(query)
|
|
1494
|
+
|| isSecondPartyDecisionIntent(query))
|
|
1495
|
+
return null;
|
|
1496
|
+
return {
|
|
1497
|
+
tools: ["oxygen_table_rows_remove_selection", "oxygen_tables_query", "oxygen_table_rows_page"],
|
|
1498
|
+
commands: ["tables rows remove-selection", "tables query", "tables rows page"],
|
|
1499
|
+
execution: "Delete Table rows with tables rows remove-selection (oxygen_table_rows_remove_selection): exact row_ids or all_matching with the browser's serialized filter, search, sort, range and exclusions. Hydrate its schema first. Unsupported filters arrays are refused; unqualified all_matching deletes every row. This is permanent and costs 0 credits. Read the matching rows before deletion and the survivors afterward; deleting rows does not stop campaign enrollment.",
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1457
1502
|
/** Reads of grid values/state are separate from executing or resolving actions. */
|
|
1458
1503
|
function tableReadRecommendation(query) {
|
|
1459
1504
|
if (!/\btables?\b/.test(query) || isFunctionIntent(query) || isSecondPartyDecisionIntent(query)
|
|
@@ -1612,6 +1657,14 @@ function crmSampleRecordRecommendation(query) {
|
|
|
1612
1657
|
execution: "A new workspace's seeded companies, CEOs and deals read _sample: true (sample: true on suggest hits and crm pipeline deals); pipeline, dashboard and chart totals leave them out and say how many. Keep one as real only when the user says it is: crm keep <object> <record> takes a row id or an identity such as stripe.com. It is free and idempotent, has no undo, and keeps that record alone: a kept deal's company and contact stay samples until each is kept. MCP runs oxygen_crm_record_keep through oxygen_capabilities_call.",
|
|
1613
1658
|
};
|
|
1614
1659
|
}
|
|
1660
|
+
/** Inventory reads select existing Table fields and retain honest sample totals. */
|
|
1661
|
+
function crmInventoryRecommendation(query) {
|
|
1662
|
+
if (!/\b(crm|records?)\b/.test(query)
|
|
1663
|
+
|| !/\b(count|how many|inventory|list|enumerate|browse)\b/.test(query)
|
|
1664
|
+
|| /\b(enrich\w*|configur\w*|settings?|presets?|fields?|attributes?|identities|relationships?|tasks?|notes?|files?|sync|hubspot|attio|pipedrive|import|create|add|edit|update|delete|remove|merge|workflows?|agents?)\b/.test(query))
|
|
1665
|
+
return null;
|
|
1666
|
+
return { tools: ["oxygen_crm_objects_list", "oxygen_tables_preview", "oxygen_crm_record_get"], commands: ["crm objects", "tables preview", "crm get"] };
|
|
1667
|
+
}
|
|
1615
1668
|
/** Saved contact facts belong to Records, regardless of email/phone channel words. */
|
|
1616
1669
|
function storedCrmContactReadRecommendation(query) {
|
|
1617
1670
|
if (!/\b(crm|records?)\b/.test(query)
|
|
@@ -1658,6 +1711,24 @@ function tableFeedRecommendation(rawQuery) {
|
|
|
1658
1711
|
return { tools: ["oxygen_feeds_bind", "oxygen_feeds_list"], commands: ["feeds bind", "feeds list"], execution: "Bind the native Table row-supply feed. Inspect source, cadence and standing credit scope first; pull feeds are distinct from inbound webhook endpoints. Feed tools are executable through the core gateways or Signals pack." };
|
|
1659
1712
|
return { tools: ["oxygen_feeds_list", "oxygen_feeds_get", "oxygen_feeds_update"], commands: ["feeds list", "feeds get", "feeds update"], execution: "Inspect the existing Table feed with feeds_get(include_route=true), then update it in place with oxygen_feeds_update (Signals pack or core write gateway). Preserve the complete frozen request; source replacement resets the cursor. Scheduled replacements require one executable lane; fallbacks require no cadence. Changed cadence, increased paid cap or replaced paid source can require renewed standing authorization. Configuration is free; future refreshes retain their approved cycle ceiling." };
|
|
1660
1713
|
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Records into a working Table (OXP-1.1.10): "turn my CRM view into a table
|
|
1716
|
+
* that stays current". External CRM imports keep their own lanes, and a Table
|
|
1717
|
+
* going INTO Records is promote, the other direction.
|
|
1718
|
+
*/
|
|
1719
|
+
function recordsTableImportRecommendation(query) {
|
|
1720
|
+
if (!/\b(crm|records?)\b/.test(query)
|
|
1721
|
+
|| !/\b(tables?|working copy|spreadsheet)\b/.test(query)
|
|
1722
|
+
|| !/\b(import\w*|cop(?:y|ies)|turn\w*|pull\w*|bring|sync\w*|mirror\w*|into)\b/.test(query)
|
|
1723
|
+
|| /\b(hubspot|attio|pipedrive|salesforce|csv|promot\w*|bind)\b/.test(query)
|
|
1724
|
+
|| /\b(tables?|rows?)\b.{0,40}\b(?:into|to)\s+(?:the\s+|my\s+|our\s+)?(?:crm|records)\b/.test(query))
|
|
1725
|
+
return null;
|
|
1726
|
+
return {
|
|
1727
|
+
tools: ["oxygen_tables_records_import", "oxygen_tables_records_sync", "oxygen_tables_records_status"],
|
|
1728
|
+
commands: ["tables records import", "tables records sync", "tables records status"],
|
|
1729
|
+
execution: "Copy a Records object or saved view into a new Table with oxygen_tables_records_import (dry_run first counts records and lists columns); refresh=sync with approved=true keeps it current on a schedule. One-way: the copy is free and Records never change, rows key on record_id, cells edited in the Table are kept, and records that leave the view stay as rows with in_records=false. Sync now or change the schedule with oxygen_tables_records_sync; read the last sync with oxygen_tables_records_status. Each scheduled sync bills one workflow step.",
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1661
1732
|
function providerPolicyReadRecommendation(query) {
|
|
1662
1733
|
if (!/\bproviders?\b/.test(query)
|
|
1663
1734
|
|| !/\b(limits?|request caps?|rate caps?|overrides?)\b/.test(query)
|
|
@@ -1681,7 +1752,7 @@ function spendBreakdownRecommendation(query) {
|
|
|
1681
1752
|
return null;
|
|
1682
1753
|
return {
|
|
1683
1754
|
tools: ["oxygen_billing_audit", "oxygen_billing_usage"], commands: ["billing audit", "billing usage"],
|
|
1684
|
-
execution: "Read where managed credits went with oxygen_billing_audit: spend_by_category ranks each area's credits and share of managed_credits_spent, largest first, over the whole window; group_by provider, tool, model, run or workspace narrows it. Spent is reserved minus released
|
|
1755
|
+
execution: "Read where managed credits went with oxygen_billing_audit: spend_by_category ranks each area's credits and share of managed_credits_spent, largest first, over the whole window; group_by provider, tool, model, run or workspace narrows it. Spent is reserved minus released in the window; it differs from captured (oxygen_billing_usage's managed_credits_used) by holds still open and holds settling across the window edge, so do not attribute the whole gap to open holds. External MCP uses oxygen_capabilities_read; built-in agents invoke the native capability. Reads cost 0 credits.",
|
|
1685
1756
|
};
|
|
1686
1757
|
}
|
|
1687
1758
|
function sendingBlockReadRecommendation(query) {
|
|
@@ -1708,6 +1779,8 @@ function isSpintaxIntent(query) {
|
|
|
1708
1779
|
|| /\b(paraphrase|rewrite|variations?)\b.{0,45}\b(wording|copy|message|draft|subject(?: line)?)\b/.test(query);
|
|
1709
1780
|
}
|
|
1710
1781
|
function explicitCapabilityIntent(query) {
|
|
1782
|
+
if (isConnectionPriceIntent(query))
|
|
1783
|
+
return ROUTE_BY_ID.get("billing-and-budgets") ?? null;
|
|
1711
1784
|
if (sentLinkedInInvitationReadRecommendation(query))
|
|
1712
1785
|
return ROUTE_BY_ID.get("connected-linkedin") ?? null;
|
|
1713
1786
|
if (isCrmEnrichmentConfigurationRead(query))
|
|
@@ -1726,6 +1799,8 @@ function explicitCapabilityIntent(query) {
|
|
|
1726
1799
|
return ROUTE_BY_PRIMITIVE.get("messages") ?? null;
|
|
1727
1800
|
if (tableFeedRecommendation(query))
|
|
1728
1801
|
return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
|
|
1802
|
+
if (recordsTableImportRecommendation(query))
|
|
1803
|
+
return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
|
|
1729
1804
|
if (providerPolicyReadRecommendation(query))
|
|
1730
1805
|
return ROUTE_BY_ID.get("billing-and-budgets") ?? null;
|
|
1731
1806
|
if (sendingBlockReadRecommendation(query))
|
|
@@ -1914,9 +1989,12 @@ function explicitCapabilityIntent(query) {
|
|
|
1914
1989
|
if (/\bwhatsapp\b.{0,30}\b(account|connect|limits?|sync)\b/.test(query)) {
|
|
1915
1990
|
return ROUTE_BY_ID.get("connected-whatsapp") ?? null;
|
|
1916
1991
|
}
|
|
1992
|
+
const hasSequenceProgramIntent = /\b(sequence|campaign|cadence|enroll|nurture)\b/.test(query)
|
|
1993
|
+
|| /\b(day\s*\d+|days?\s+\d|multi[ -]step|rotate senders?|sender rotation|stop on reply|until (?:they )?reply)\b/.test(query);
|
|
1917
1994
|
if (!isNetNewLinkedInInitiation(query)
|
|
1918
1995
|
&& (/\b(one[ -]off|ad[ -]hoc|just once|reply once|single message|direct message|direct email|no cadence)\b/.test(query)
|
|
1919
|
-
||
|
|
1996
|
+
|| (!hasSequenceProgramIntent
|
|
1997
|
+
&& /\b(reply|respond|follow up)\b.{0,30}\b(conversation|thread|email|linkedin|whatsapp|dm)\b/.test(query)))) {
|
|
1920
1998
|
return ROUTE_BY_PRIMITIVE.get("messages") ?? null;
|
|
1921
1999
|
}
|
|
1922
2000
|
if (/\b(my|our|own|connected)\b.{0,40}\blinkedin\b|\b(sales navigator|salesnav|recruiter|profile viewers?|linkedin inbox)\b/.test(query)) {
|
|
@@ -1925,8 +2003,7 @@ function explicitCapabilityIntent(query) {
|
|
|
1925
2003
|
if (/\b(publish|schedule|approve)\b.{0,24}\b(post|content)\b|\bcontent calendar\b/.test(query)) {
|
|
1926
2004
|
return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
|
|
1927
2005
|
}
|
|
1928
|
-
if (
|
|
1929
|
-
|| /\b(day\s*\d+|days?\s+\d|multi[ -]step|rotate senders?|sender rotation|stop on reply|until (?:they )?reply)\b/.test(query)) {
|
|
2006
|
+
if (hasSequenceProgramIntent) {
|
|
1930
2007
|
return ROUTE_BY_PRIMITIVE.get("sequences") ?? null;
|
|
1931
2008
|
}
|
|
1932
2009
|
// Existing reply handling is Messages even without a channel name. Keep this
|
|
@@ -2027,12 +2104,21 @@ function recommendationsFor(card, query) {
|
|
|
2027
2104
|
const contactFind = card.id === "sourcing-and-provider-tools" ? oneShotContactFindingRecommendation(query) : null;
|
|
2028
2105
|
if (contactFind)
|
|
2029
2106
|
return contactFind;
|
|
2107
|
+
if (card.id === "knowledge-graph" && isCanonicalCopyReadIntent(query)) {
|
|
2108
|
+
return {
|
|
2109
|
+
tools: ["oxygen_context_canonical_get", "oxygen_knowledge_page_get", "oxygen_knowledge_search"],
|
|
2110
|
+
commands: ["context canonical get", "knowledge page get", "knowledge search"],
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2030
2113
|
const crmSample = card.id === "records" ? crmSampleRecordRecommendation(query) : null;
|
|
2031
2114
|
if (crmSample)
|
|
2032
2115
|
return crmSample;
|
|
2033
2116
|
const crmContactRead = card.id === "records" ? storedCrmContactReadRecommendation(query) : null;
|
|
2034
2117
|
if (crmContactRead)
|
|
2035
2118
|
return crmContactRead;
|
|
2119
|
+
const crmInventory = card.id === "records" ? crmInventoryRecommendation(query) : null;
|
|
2120
|
+
if (crmInventory)
|
|
2121
|
+
return crmInventory;
|
|
2036
2122
|
const replyDraftRead = card.primitive === "messages" ? storedReplyDraftReadRecommendation(query) : null;
|
|
2037
2123
|
if (replyDraftRead)
|
|
2038
2124
|
return replyDraftRead;
|
|
@@ -2042,6 +2128,14 @@ function recommendationsFor(card, query) {
|
|
|
2042
2128
|
const spendRead = card.id === "billing-and-budgets" ? spendBreakdownRecommendation(query) : null;
|
|
2043
2129
|
if (spendRead)
|
|
2044
2130
|
return spendRead;
|
|
2131
|
+
if (card.id === "billing-and-budgets" && isConnectionPriceIntent(query)) {
|
|
2132
|
+
return {
|
|
2133
|
+
tools: ["oxygen_billing_commitments"],
|
|
2134
|
+
commands: ["billing commitments"],
|
|
2135
|
+
exactCommand: "oxygen billing commitments --view connection-prices --json",
|
|
2136
|
+
exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.connectionPrices(),
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2045
2139
|
const blockRead = card.primitive === "sequences" ? sendingBlockReadRecommendation(query) : null;
|
|
2046
2140
|
if (blockRead)
|
|
2047
2141
|
return blockRead;
|
|
@@ -2059,6 +2153,9 @@ function recommendationsFor(card, query) {
|
|
|
2059
2153
|
const feed = card.primitive === "tables" ? tableFeedRecommendation(query) : null;
|
|
2060
2154
|
if (feed)
|
|
2061
2155
|
return { tools: feed.tools, commands: feed.commands };
|
|
2156
|
+
const recordsCopy = card.primitive === "tables" ? recordsTableImportRecommendation(query) : null;
|
|
2157
|
+
if (recordsCopy)
|
|
2158
|
+
return { tools: recordsCopy.tools, commands: recordsCopy.commands };
|
|
2062
2159
|
if (card.id === "billing-and-budgets" && /\b(credit prices|credit price catalog|customer credit)\b/.test(query)) {
|
|
2063
2160
|
return { tools: ["oxygen_customer_credit_prices"], commands: ["pricing customer-credit-prices"] };
|
|
2064
2161
|
}
|
|
@@ -2113,7 +2210,7 @@ function recommendationsFor(card, query) {
|
|
|
2113
2210
|
if (card.primitive === "tables" && isCompanyPageFollowersIntent(query)) {
|
|
2114
2211
|
return {
|
|
2115
2212
|
tools: ["oxygen_tables_followers", "oxygen_tables_sources"],
|
|
2116
|
-
commands: ["tables followers check", "tables followers order", "tables sources"],
|
|
2213
|
+
commands: ["tables followers check", "tables followers order", "tables followers status", "tables sources"],
|
|
2117
2214
|
exactCommand: COMPANY_PAGE_FOLLOWERS_COMMAND,
|
|
2118
2215
|
exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.companyPageFollowers(),
|
|
2119
2216
|
};
|
|
@@ -2298,6 +2395,9 @@ function recommendationsFor(card, query) {
|
|
|
2298
2395
|
};
|
|
2299
2396
|
}
|
|
2300
2397
|
if (card.primitive === "tables") {
|
|
2398
|
+
const removal = tableRowRemovalRecommendation(query);
|
|
2399
|
+
if (removal)
|
|
2400
|
+
return removal;
|
|
2301
2401
|
const read = tableReadRecommendation(query);
|
|
2302
2402
|
if (read)
|
|
2303
2403
|
return read;
|
|
@@ -2496,6 +2596,12 @@ function recommendationsFor(card, query) {
|
|
|
2496
2596
|
commands: ["senders profiles list", "senders profiles get"],
|
|
2497
2597
|
};
|
|
2498
2598
|
}
|
|
2599
|
+
if (isSequenceLeadProgressIntent(query)) {
|
|
2600
|
+
return {
|
|
2601
|
+
tools: ["oxygen_sequences_enrollments", "oxygen_sequences_stats", "oxygen_sequences_list"],
|
|
2602
|
+
commands: ["sequences enrollments", "sequences stats", "sequences list"],
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2499
2605
|
if (isNetNewLinkedInInitiation(query)) {
|
|
2500
2606
|
const publicResearch = isPublicLinkedInRead(query);
|
|
2501
2607
|
const harvestEngagers = publicResearch && isPublicLinkedInEngagerHarvest(query);
|
|
@@ -2719,7 +2825,33 @@ function isSenderCopyVariableIntent(query) {
|
|
|
2719
2825
|
|| /\bsign off (?:variable|with|per sender)\b|\bper sender sign off\b/.test(query)
|
|
2720
2826
|
|| /\bsign (?:every|each|all) (?:emails?|messages?|dms?|notes?) with\b/.test(query);
|
|
2721
2827
|
}
|
|
2722
|
-
/**
|
|
2828
|
+
/** OXP-43.36: inspect recurring account prices before creating authorization. */
|
|
2829
|
+
function isConnectionPriceIntent(query) {
|
|
2830
|
+
if (!/\b(costs?|prices?|credits?|reser(?:ve[sd]?|vations?)|charges?|charged|fees?|pay|quote)\b/.test(query))
|
|
2831
|
+
return false;
|
|
2832
|
+
if (/\b(per (?:message|send|action|call)|scrap\w*|research|enrich\w*|public (?:profiles?|posts?))\b/.test(query))
|
|
2833
|
+
return false;
|
|
2834
|
+
// A whole mailbox onboarding motion can mention price alongside importing
|
|
2835
|
+
// history and syncing CRM. Keep that owner unless the ask isolates a quote.
|
|
2836
|
+
if (/\b(import|sync)\b/.test(query) && /\b(history|crm|unibox)\b/.test(query)
|
|
2837
|
+
&& !/\b(monthly|recurring|quote|before (?:consent|connect|authorization))\b/.test(query))
|
|
2838
|
+
return false;
|
|
2839
|
+
// Lookup, purchase and sending prices keep their own previews. Naming a
|
|
2840
|
+
// mailbox or a phone number alone does not make it an account connection.
|
|
2841
|
+
if (/\b(?:find|look ?up|lookup)\b/.test(query) && /\bphones?\b/.test(query))
|
|
2842
|
+
return false;
|
|
2843
|
+
if (/\b(?:warm ?up|deliverability|placement)\b/.test(query))
|
|
2844
|
+
return false;
|
|
2845
|
+
if (/\bmailbox(?:es)?\b/.test(query) && /\b(?:buy|purchase|order)\b/.test(query))
|
|
2846
|
+
return false;
|
|
2847
|
+
if (/\blinkedin\b/.test(query) && /\b(?:messages?|invites?|invitations?|connection requests?)\b/.test(query))
|
|
2848
|
+
return false;
|
|
2849
|
+
const accountContext = /\b(accounts?|connect(?:ing|ed|ion)?|reconnect|oauth|consent)\b/.test(query);
|
|
2850
|
+
return /\b(connected accounts?|account connections?|connection prices?)\b/.test(query)
|
|
2851
|
+
|| (/\b(linkedin|whatsapp|twitter|x)\b/.test(query)
|
|
2852
|
+
&& (accountContext || /\b(numbers?|seats?)\b/.test(query)))
|
|
2853
|
+
|| (/\bmailbox(?:es)?\b/.test(query) && accountContext);
|
|
2854
|
+
}
|
|
2723
2855
|
/**
|
|
2724
2856
|
* Connecting, or asking what it takes to connect, the workspace's own LinkedIn
|
|
2725
2857
|
* sending account. The 2026-09-26 repricing baseline asked "connect a LinkedIn
|
|
@@ -2824,6 +2956,9 @@ function isFailedPostIntent(query) {
|
|
|
2824
2956
|
function isPostCoverOrPreviewIntent(query) {
|
|
2825
2957
|
if (/\b(ads?|competitors?)\b/.test(query))
|
|
2826
2958
|
return false;
|
|
2959
|
+
// A draft in an existing inbox conversation is a reply, not a post artifact.
|
|
2960
|
+
if (/\b(inbox|unibox|conversation)\b/.test(query) && !/\bposts?\b/.test(query))
|
|
2961
|
+
return false;
|
|
2827
2962
|
return (/\b(cover|thumbnail)\b/.test(query) && /\b(post|video|draft)\b/.test(query))
|
|
2828
2963
|
|| /\bpreview\b.{0,40}\b(post|draft)\b/.test(query);
|
|
2829
2964
|
}
|
|
@@ -79,6 +79,12 @@ export function exitCodeForErrorCode(code) {
|
|
|
79
79
|
if (code.startsWith("invalid_")
|
|
80
80
|
|| code.startsWith("missing_")
|
|
81
81
|
|| code === "unknown_fields"
|
|
82
|
+
|| code === "unknown_filter_column"
|
|
83
|
+
|| code === "unknown_sort_column"
|
|
84
|
+
|| code === "formula_filter_not_supported"
|
|
85
|
+
|| code === "formula_sort_not_supported"
|
|
86
|
+
|| code === "lookup_filter_not_supported"
|
|
87
|
+
|| code === "lookup_sort_not_supported"
|
|
82
88
|
|| code === "validation_failed") {
|
|
83
89
|
return 2;
|
|
84
90
|
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
/** Browser resource operations, using the same scoped handlers on every surface. */
|
|
2
2
|
import { arr, bool, enumOf, int, nullable, num, obj, str } from "./schema.js";
|
|
3
3
|
import { TABLE_ROW_READ_WINDOW_FIELDS } from "./table-row-read.js";
|
|
4
|
+
import { TABLE_ROW_REMOVAL_FIELDS } from "./table-row-removal.js";
|
|
4
5
|
const text = (description) => str(description ? { description } : {});
|
|
6
|
+
// The grid's own URL state (Sequence lead/contact grids reuse the Table grid): copying f / s from a
|
|
7
|
+
// filtered, sorted browser view is the supported way to build them.
|
|
8
|
+
const FILTER_PARAM = "Filter tree as the grid URL's f parameter (base64url JSON); copy it from a filtered browser view.";
|
|
9
|
+
const SORT_PARAM = "Sort list as the grid URL's s parameter (base64url JSON); copy it from a sorted browser view.";
|
|
10
|
+
// Sequence lead/contact rows: one row can carry ~100 KB of research and
|
|
11
|
+
// enrichment values, so these reads select columns and compact values (OXP-1.11.67).
|
|
12
|
+
const sequenceRowsProjection = {
|
|
13
|
+
fields: text("Comma-separated column keys to return, e.g. full_name,company,__seq_status; omit for every column."),
|
|
14
|
+
projection: enumOf(["grid", "values"], { description: "values: compact {id, values} rows, long values capped; grid (default): the full grid payload." }),
|
|
15
|
+
};
|
|
16
|
+
const SEQUENCE_ROWS_TRUNCATION_HINT = "Result trimmed for context size. Pass projection=values (values over 2,000 characters are capped) and fields=<column keys>; a smaller limit cannot shrink one row.";
|
|
5
17
|
const json = { type: "object", additionalProperties: true };
|
|
6
18
|
const strings = () => arr(str());
|
|
7
19
|
const objects = () => arr(json);
|
|
@@ -15,12 +27,12 @@ const selection = obj({
|
|
|
15
27
|
const actions = arr(obj({ type: enumOf(["tool_column"]), column: text(), definition: json }, { required: ["column"] }));
|
|
16
28
|
const viewFields = { name: text(), view_type: text(), config: json, group_by: text(), is_default: bool(), position: int({ minimum: 0 }) };
|
|
17
29
|
const crmLinks = arr(obj({ object: text(), row_id: text(), role: text() }, { required: ["object", "row_id"] }));
|
|
18
|
-
function operation(id, route, method, cli, mcp, pack, summary, fields = {}, required = [], options = {}) {
|
|
30
|
+
function operation(id, route, method, cli, mcp, pack, summary, fields = {}, required = [], options = {}, annotations) {
|
|
19
31
|
return {
|
|
20
32
|
id, route, method, parameters: [...route.matchAll(/\{(\w+)\}/g)].map((match) => match[1]),
|
|
21
33
|
contract: obj(fields, { required }),
|
|
22
34
|
cli: { path: cli.split(" "), summary },
|
|
23
|
-
mcp: { name: mcp, title: `Oxygen: ${cli}`, description: summary, pack },
|
|
35
|
+
mcp: { name: mcp, title: `Oxygen: ${cli}`, description: summary, pack, ...(annotations ? { annotations } : {}) },
|
|
24
36
|
mutates: method !== "GET", spends: false, ...options,
|
|
25
37
|
};
|
|
26
38
|
}
|
|
@@ -33,17 +45,17 @@ const multipart = { contentField: "content_base64", nameField: "file_name", type
|
|
|
33
45
|
const nativeCli = { generated: { cli: false, mcp: false } };
|
|
34
46
|
export const BROWSER_OPERATIONS = [
|
|
35
47
|
operation("tables.resource.export_csv", `${table}/export/csv`, "GET", "tables export-csv", "oxygen_table_export_csv", "tables", "Download every row in the exact filtered Table view as streamed CSV. CLI writes --output; MCP returns an authenticated download resource link. Unsupported live lookup filters fail before download; rows and bytes are not capped or truncated.", { f: text("Serialized filter tree."), s: text("Serialized sort state."), q: text(), fields: text("Comma-separated exact column keys.") }, [], { binary: { stream: true, resourceParameter: "table" } }),
|
|
36
|
-
operation("billing.allowance.view", "/api/cli/billing/allowance/view", "GET", "billing allowance view", "oxygen_billing_allowance_view", "billing", "
|
|
48
|
+
operation("billing.allowance.view", "/api/cli/billing/allowance/view", "GET", "billing allowance view", "oxygen_billing_allowance_view", "billing", "Inspect the current-cycle allowance, billing owner and partner-bonus eligibility. 0 credits; reserves nothing. Due plan grants or credit expiry may apply."),
|
|
37
49
|
operation("billing.commitments.view", "/api/cli/billing/commitments/view", "GET", "billing commitments view", "oxygen_billing_commitments_view", "billing", "Read the complete fixed-credit commitment view and linked billing-owner display, without enumerating a child's sibling resources."),
|
|
38
50
|
operation("billing.invoices.view", "/api/cli/billing/invoices/view", "GET", "billing invoices view", "oxygen_billing_invoices_view", "billing", "Read the browser invoice-history projection, including exact native timestamps, amounts and signed Stripe invoice links."),
|
|
39
51
|
operation("billing.summary", "/api/cli/billing/summary", "GET", "billing summary", "oxygen_billing_summary", "billing", "Read the complete non-writing wallet, plan, seat-capacity and billing-owner summary."),
|
|
40
|
-
operation("billing.usage.view", "/api/cli/billing/usage/view", "GET", "billing usage view", "oxygen_billing_usage_view", "billing", "Credit spend over 7, 30 or 90 days by category and provider, each with its share, plus permitted linked-workspace rollups. totalSpent
|
|
52
|
+
operation("billing.usage.view", "/api/cli/billing/usage/view", "GET", "billing usage view", "oxygen_billing_usage_view", "billing", "Credit spend over 7, 30 or 90 days by category and provider, each with its share, plus permitted linked-workspace rollups. totalSpent is reserved minus released; billing usage managed_credits_used counts captured credits only. Other windows or groupings: billing audit.", { days: enumOf(["7", "30", "90"]), workspace: text() }),
|
|
41
53
|
operation("integrations.managed_endpoints", "/api/cli/integrations/composio/endpoints", "GET", "integrations managed-endpoints", "oxygen_integration_managed_endpoints", "integrations", "Read a managed integration's full action catalog and parameter schemas.", { integration_id: text() }, ["integration_id"]),
|
|
42
54
|
operation("integrations.openrouter_models", "/api/cli/integrations/openrouter/models", "GET", "integrations openrouter-models", "oxygen_openrouter_models", "integrations", "Read model and pricing metadata using this workspace's connected OpenRouter account; credentials are never returned."),
|
|
43
55
|
operation("integrations.status", "/api/cli/integrations/status", "GET", "integrations status", "oxygen_integration_status", "integrations", "Read active connection counts, sender connection state and the workspace's usable BYOK plan entitlement."),
|
|
44
56
|
operation("pricing.customer_credit_prices", "/api/cli/pricing/customer-credit-prices", "GET", "pricing customer-credit-prices", "oxygen_customer_credit_prices", "workspace", "Read the customer credit-price catalog, quantity decisions and price provenance used by the Table picker."),
|
|
45
|
-
operation("sequences.contacts.rows", "/api/cli/sequences/{sequence}/contacts/rows", "GET", "sequences contacts rows", "oxygen_sequence_contacts_rows", "sequences", "Read the full contact grid projection with exact contact-state filter and native synthetic status columns.", { contact_state: enumOf(["all", "not_contacted", "contacted", "connected", "replied", "positive_reply"]), limit: int({ minimum: 1, maximum: 2000 }), cursor: text(), offset: int({ minimum: 0 }), f: text(), s: text() }),
|
|
46
|
-
operation("sequences.leads.rows", "/api/cli/sequences/{sequence}/leads/rows", "GET", "sequences leads rows", "oxygen_sequence_leads_rows", "sequences", "Read the complete enrolled-lead grid projection, including sequence status, step, action and failure columns.", { limit: int({ minimum: 1, maximum: 2000 }), cursor: text(), offset: int({ minimum: 0 }), f: text(), s: text() }),
|
|
57
|
+
operation("sequences.contacts.rows", "/api/cli/sequences/{sequence}/contacts/rows", "GET", "sequences contacts rows", "oxygen_sequence_contacts_rows", "sequences", "Read the full contact grid projection with exact contact-state filter and native synthetic status columns.", { contact_state: enumOf(["all", "not_contacted", "contacted", "connected", "replied", "positive_reply"]), limit: int({ minimum: 1, maximum: 2000 }), cursor: text(), offset: int({ minimum: 0 }), f: text(FILTER_PARAM), s: text(SORT_PARAM), ...sequenceRowsProjection }, [], { truncationHint: SEQUENCE_ROWS_TRUNCATION_HINT }),
|
|
58
|
+
operation("sequences.leads.rows", "/api/cli/sequences/{sequence}/leads/rows", "GET", "sequences leads rows", "oxygen_sequence_leads_rows", "sequences", "Read the complete enrolled-lead grid projection, including sequence status, step, action and failure columns.", { limit: int({ minimum: 1, maximum: 2000 }), cursor: text(), offset: int({ minimum: 0 }), f: text(FILTER_PARAM), s: text(SORT_PARAM), ...sequenceRowsProjection }, [], { truncationHint: SEQUENCE_ROWS_TRUNCATION_HINT }),
|
|
47
59
|
operation("unibox.metadata", "/api/cli/unibox/metadata", "GET", "unibox metadata", "oxygen_unibox_metadata", "inbox", "Read the complete authenticated inbox account, filter and tag metadata, including each sender profile (person) and its id."),
|
|
48
60
|
operation("workflows.observed_output", "/api/cli/workflows/{workflow}/observed-output", "GET", "workflows observed-output", "oxygen_workflow_observed_output", "workflows", "Inspect observed output paths and bounded samples from an exact run or recent matching workflow revision.", { run_id: text(), source_hash: text() }),
|
|
49
61
|
operation("workflows.run.events", "/api/cli/workflows/{workflow}/runs/{run}/events", "GET", "workflows runs events", "oxygen_workflow_run_events", "workflows", "Read the exact Workflow run's bounded event history and whether it is still active.", { limit: int({ minimum: 1, maximum: 1000 }) }),
|
|
@@ -70,7 +82,7 @@ export const BROWSER_OPERATIONS = [
|
|
|
70
82
|
operation("tables.resource.rows.get", `${table}/rows/{row}`, "GET", "tables rows inspect", "oxygen_table_row_inspect", "tables", "Read one row; field selects column keys and hydrate_relations=1 includes relations. projection=value returns lossless JSON fragments; resume value_cursor with identical table/row/field/path.", { hydrate_relations: enumOf(["1"]), field: strings(), ...TABLE_ROW_READ_WINDOW_FIELDS }, [], { repeatedQueryFields: ["field"] }),
|
|
71
83
|
operation("tables.resource.rows.edit", `${table}/rows/{row}`, "PATCH", "tables rows edit", "oxygen_table_row_edit", "tables", "Patch one row's values and return the committed row.", { values: json }, ["values"]),
|
|
72
84
|
operation("tables.resource.rows.delete", `${table}/rows/{row}`, "DELETE", "tables rows remove", "oxygen_table_row_remove", "tables", "Remove one row from a workspace Table."),
|
|
73
|
-
operation("tables.resource.rows.bulk_delete", `${table}/rows/delete`, "POST", "tables rows remove-selection", "oxygen_table_rows_remove_selection", "tables", "
|
|
85
|
+
operation("tables.resource.rows.bulk_delete", `${table}/rows/delete`, "POST", "tables rows remove-selection", "oxygen_table_rows_remove_selection", "tables", "Permanently delete Table rows by exact ids or a matching filtered selection. Unsupported scope fields are refused.", TABLE_ROW_REMOVAL_FIELDS, [], {}, { destructiveHint: true }),
|
|
74
86
|
operation("tables.resource.rows.ids", `${table}/rows/ids`, "GET", "tables rows range-ids", "oxygen_table_rows_range_ids", "tables", "Resolve a bounded row range into exact ids, preserving the current filter, search, sort and CRM scope.", { offset: int({ minimum: 0 }), limit: int({ minimum: 1 }), filter: text(), search: text(), sort: text(), crm_object: text() }, ["offset", "limit"]),
|
|
75
87
|
operation("tables.resource.relationships.get", `${table}/records/{row}/relationships`, "GET", "tables rows relationships", "oxygen_table_row_relationships", "crm", "Read a CRM-backed Table row's relationship links and available targets."),
|
|
76
88
|
operation("tables.resource.relationships.set", `${table}/records/{row}/relationships`, "POST", "tables rows relationship-set", "oxygen_table_row_relationship_set", "crm", "Set one CRM-backed Table row's relationship and return the saved links.", { relationship: text(), target_row_id: text() }, ["relationship", "target_row_id"]),
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { ObjectProperty } from "./schema.js";
|
|
2
2
|
/** Custom CRM column writes accept both canonical snake_case and legacy aliases. */
|
|
3
|
-
export declare const CRM_CUSTOM_COLUMN_SCHEMA: ObjectProperty
|
|
3
|
+
export declare const CRM_CUSTOM_COLUMN_SCHEMA: ObjectProperty & {
|
|
4
|
+
allOf: Array<{
|
|
5
|
+
anyOf: Array<{
|
|
6
|
+
required: string[];
|
|
7
|
+
}>;
|
|
8
|
+
}>;
|
|
9
|
+
};
|
|
4
10
|
export declare const CRM_CUSTOM_IDENTITY_SCHEMA: ObjectProperty;
|