@oxygen-agent/cli 1.1011.1091 → 1.1012.2
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.d.ts +3 -1
- package/dist/command-manifest.js +11 -0
- package/dist/customer-operation-commands.js +1 -1
- package/dist/index.js +57 -31
- package/dist/transcript.js +1 -1
- package/node_modules/@oxygen/formula/dist/expression.js +16 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.js +15 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +2 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +28 -4
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -2
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +3 -3
- package/node_modules/@oxygen/shared/dist/contracts/geo.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/contracts/geo.js +138 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- 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 +10 -4
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +23 -8
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +2 -1
- package/node_modules/@oxygen/shared/dist/external-effect-arming.d.ts +54 -0
- package/node_modules/@oxygen/shared/dist/external-effect-arming.js +90 -0
- package/node_modules/@oxygen/shared/dist/geo.d.ts +247 -0
- package/node_modules/@oxygen/shared/dist/geo.js +314 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +1 -1
- package/node_modules/@oxygen/shared/dist/managed-email-branding.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/managed-email-branding.js +81 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +20 -24
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +15 -16
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +3 -3
- package/node_modules/@oxygen/shared/dist/product-doctrine.js +6 -5
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/recipes.js +1 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +4 -14
- package/node_modules/@oxygen/shared/dist/repricing.js +25 -64
- package/node_modules/@oxygen/shared/dist/research-depth.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/research-depth.js +7 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- 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/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +46 -0
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +51 -1
- package/node_modules/@oxygen/shared/package.json +10 -0
- package/package.json +1 -1
|
@@ -4,6 +4,16 @@ import { RECIPE_PRIMITIVES } from "./recipes.js";
|
|
|
4
4
|
// It is navigation, never execution: live schemas, prices, readiness, and
|
|
5
5
|
// approval enforcement remain on the exact command/tool selected from here.
|
|
6
6
|
export const OXYGEN_CAPABILITY_ROUTES = [
|
|
7
|
+
{
|
|
8
|
+
id: "geo", layer: "Data", primitive: "geo", posture: "mixed",
|
|
9
|
+
owns: "GEO / AEO AI-search tracking: buyer prompts, consumer answers, citations, competitive visibility, customer chat evidence and optimization experiments. Experimental staff/dev access.",
|
|
10
|
+
notFor: "Direct model API benchmarks, automatic public edits, autonomous publishing or personalized chat attribution.",
|
|
11
|
+
execution: "Staff in dev workspaces only. Preview consumer collection and cost, start within the $10 shared pilot cap, inspect answers and citations, then record reviewed changes and compare later samples. Reads never collect or spend.",
|
|
12
|
+
gatewayTools: ["oxygen_geo_projects_list", "oxygen_geo_engines_list", "oxygen_geo_runs_preview"],
|
|
13
|
+
gatewayCommands: ["geo projects list", "geo engines list", "geo runs preview"],
|
|
14
|
+
endpointSections: ["geo"], skills: ["oxygen-geo"],
|
|
15
|
+
intentTerms: ["GEO", "AEO", "answer engine optimization", "generative engine optimization", "AI search visibility", "AI visibility", "consumer AI answers", "answer engines", "brand visibility", "brand citations", "chatgpt mentions", "citation gaps"],
|
|
16
|
+
},
|
|
7
17
|
{
|
|
8
18
|
id: "visual-rendering",
|
|
9
19
|
layer: "Action",
|
|
@@ -686,6 +696,7 @@ export function inferPrimitiveRoute(query) {
|
|
|
686
696
|
export const RETIRED_PRODUCT_SKILLS = Object.freeze(["oxygen-playbooks", "oxygen-visual-design", "oxygen-linkedin-marketing"]);
|
|
687
697
|
/** Current hosted instruction audiences, shared by discovery and Agent snapshots. */
|
|
688
698
|
export const OXYGEN_PRODUCT_SKILL_AUDIENCES = Object.freeze({
|
|
699
|
+
"oxygen-geo": "internal_dev",
|
|
689
700
|
"oxygen-onboarding": "workspace",
|
|
690
701
|
"oxygen-gtm-consultant": "workspace",
|
|
691
702
|
"oxygen-gtm": "workspace",
|
|
@@ -721,7 +732,7 @@ export function canReadProductSkillInstructions(name, entitlements) {
|
|
|
721
732
|
const audience = Object.hasOwn(OXYGEN_PRODUCT_SKILL_AUDIENCES, name)
|
|
722
733
|
? OXYGEN_PRODUCT_SKILL_AUDIENCES[name]
|
|
723
734
|
: undefined;
|
|
724
|
-
return audience === "workspace" || (audience === "agency_partner" && entitlements.agencyPartner);
|
|
735
|
+
return audience === "workspace" || (audience === "internal_dev" && entitlements.internalDev === true) || (audience === "agency_partner" && entitlements.agencyPartner);
|
|
725
736
|
}
|
|
726
737
|
/** Identify hosted instruction bodies by native tool lineage, never by user prose. */
|
|
727
738
|
export function readProductSkillInstructionResult(capabilityName, result) {
|
|
@@ -1496,7 +1507,7 @@ function tableRowRemovalRecommendation(query) {
|
|
|
1496
1507
|
return {
|
|
1497
1508
|
tools: ["oxygen_table_rows_remove_selection", "oxygen_tables_query", "oxygen_table_rows_page"],
|
|
1498
1509
|
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):
|
|
1510
|
+
execution: "Delete Table rows with tables rows remove-selection (oxygen_table_rows_remove_selection): the rows a readable filter tree matches (--filter-tree-json / filter_tree, the same tree tables query takes), exact row_ids, or an all_matching selection with search, sort, range and exclusions. Hydrate its schema first. Unreadable filter nodes and filters arrays are refused; unqualified all_matching deletes every row. This is permanent and costs 0 credits; to keep a restorable copy first, tables duplicate (oxygen_tables_duplicate, 0 credits). Read the matching rows before deletion and the survivors afterward; each removed row's values stay readable with rows history (oxygen_rows_history). Deleting rows does not stop campaign enrollment.",
|
|
1500
1511
|
};
|
|
1501
1512
|
}
|
|
1502
1513
|
/** Reads of grid values/state are separate from executing or resolving actions. */
|
|
@@ -1752,7 +1763,7 @@ function spendBreakdownRecommendation(query) {
|
|
|
1752
1763
|
return null;
|
|
1753
1764
|
return {
|
|
1754
1765
|
tools: ["oxygen_billing_audit", "oxygen_billing_usage"], commands: ["billing audit", "billing usage"],
|
|
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
|
|
1766
|
+
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_call; built-in agents invoke the native capability. Reads cost 0 credits.",
|
|
1756
1767
|
};
|
|
1757
1768
|
}
|
|
1758
1769
|
function sendingBlockReadRecommendation(query) {
|
|
@@ -1779,6 +1790,9 @@ function isSpintaxIntent(query) {
|
|
|
1779
1790
|
|| /\b(paraphrase|rewrite|variations?)\b.{0,45}\b(wording|copy|message|draft|subject(?: line)?)\b/.test(query);
|
|
1780
1791
|
}
|
|
1781
1792
|
function explicitCapabilityIntent(query) {
|
|
1793
|
+
// Importing an AI answer is GEO evidence, rather than a Table file import.
|
|
1794
|
+
if (/\b(?:geo|aeo|ai (?:search )?visibility|consumer ai answers?|answer engine optimization|generative engine optimization)\b/.test(query))
|
|
1795
|
+
return ROUTE_BY_ID.get("geo") ?? null;
|
|
1782
1796
|
if (isConnectionPriceIntent(query))
|
|
1783
1797
|
return ROUTE_BY_ID.get("billing-and-budgets") ?? null;
|
|
1784
1798
|
if (sentLinkedInInvitationReadRecommendation(query))
|
|
@@ -1857,7 +1871,7 @@ function explicitCapabilityIntent(query) {
|
|
|
1857
1871
|
if (isMailboxDeleteIntent(query) || isSendingDomainDeleteIntent(query)) {
|
|
1858
1872
|
return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
|
|
1859
1873
|
}
|
|
1860
|
-
if (isMailboxOnboardingIntent(query)) {
|
|
1874
|
+
if (isMailboxOnboardingIntent(query) || isWarmupEnrollmentIntent(query)) {
|
|
1861
1875
|
return ROUTE_BY_ID.get("sending-infrastructure") ?? null;
|
|
1862
1876
|
}
|
|
1863
1877
|
if (isLinkedInAccountConnectIntent(query))
|
|
@@ -2332,6 +2346,12 @@ function recommendationsFor(card, query) {
|
|
|
2332
2346
|
commands: ["mailboxes ramp set", "mailboxes cap set", "mailboxes get"],
|
|
2333
2347
|
};
|
|
2334
2348
|
}
|
|
2349
|
+
if (card.id === "sending-infrastructure" && isWarmupEnrollmentIntent(query)) {
|
|
2350
|
+
return {
|
|
2351
|
+
tools: ["oxygen_mailboxes_warmup_enable", "oxygen_mailboxes_compatibility", "oxygen_mailboxes_list"],
|
|
2352
|
+
commands: ["mailboxes warmup enable", "mailboxes compatibility", "mailboxes list"],
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2335
2355
|
if (card.id === "sending-infrastructure" && isMailboxOnboardingIntent(query)) {
|
|
2336
2356
|
return {
|
|
2337
2357
|
tools: [
|
|
@@ -2882,6 +2902,10 @@ function isMutualLinkedInConnectionsIntent(query) {
|
|
|
2882
2902
|
|| /\b(connections?|relations?)\b.{0,48}\b(mutual|shared|in common)\b/.test(query);
|
|
2883
2903
|
return linkedInContext && mutualContext;
|
|
2884
2904
|
}
|
|
2905
|
+
function isWarmupEnrollmentIntent(query) {
|
|
2906
|
+
return /\b(warmup|warm-up|warm up)\b/.test(query)
|
|
2907
|
+
&& /\b(enroll\w*|enable|quote|price|cost)\b/.test(query);
|
|
2908
|
+
}
|
|
2885
2909
|
function isMailboxOnboardingIntent(query) {
|
|
2886
2910
|
const operation = /\b(import|migrat\w*|onboard|register|upload|bring|connect existing|add existing)\b/.test(query);
|
|
2887
2911
|
const mailboxScope = /\b(mailbox(?:es)?|inbox(?:es)?|sender accounts?|email accounts?|sending pool)\b/.test(query);
|
|
@@ -45,7 +45,7 @@ const multipart = { contentField: "content_base64", nameField: "file_name", type
|
|
|
45
45
|
const nativeCli = { generated: { cli: false, mcp: false } };
|
|
46
46
|
export const BROWSER_OPERATIONS = [
|
|
47
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" } }),
|
|
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
|
|
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; due plan grants or credit expiry may apply. 0 credits; reserves nothing.", {}, [], { mutates: true }),
|
|
49
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."),
|
|
50
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."),
|
|
51
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."),
|
|
@@ -82,7 +82,7 @@ export const BROWSER_OPERATIONS = [
|
|
|
82
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"] }),
|
|
83
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"]),
|
|
84
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."),
|
|
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
|
|
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 a readable filter tree, exact ids or a matching selection. Unsupported scope fields are refused.", TABLE_ROW_REMOVAL_FIELDS, [], {}, { destructiveHint: true }),
|
|
86
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"]),
|
|
87
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."),
|
|
88
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"]),
|
|
@@ -81,7 +81,7 @@ export const CUSTOMER_OPERATIONS = [
|
|
|
81
81
|
operation("settings.beta.set", "/api/cli/settings/beta-features", "POST", "settings beta-features set", "oxygen_settings_beta_features_set", "workspace", "Enable or disable beta navigation for every workspace member; admin or owner only. Approve the preference change. This does not disable routes. 0 credits.", {}, [], { contract: betaFeaturesUpdateContract }),
|
|
82
82
|
operation("spintax.generate", "/api/cli/spintax", "POST", "spintax generate", "oxygen_spintax_generate", "sequences", "Generate wording or copy variations as spintax: live (default) costs 3 credits and requires approval and a credit ceiling; explicit dry_run is a free preview. Preserve merge variables; send nothing. Live requires approved:true and max_credits>=3 after preview; failed generation refunds the reservation.", {}, [], { contract: spintaxContract, defaults: { mode: "live" }, spends: true }),
|
|
83
83
|
operation("table_action_items.resolve", "/api/cli/table-action-items/{item_id}/resolve", "POST", "table-actions resolve", "oxygen_table_action_item_resolve", "tables", "Record a human-verified uncertain Table action outcome without repeating it. Use cells resolve for row/column coordinates.", TABLE_ITEM_RESOLVE_CONTRACT.properties, ["outcome"]),
|
|
84
|
-
operation("table_ingestions.append", "/api/cli/table-ingestion-runs/{ingestion_run_id}/items", "POST", "table-ingestions append", "oxygen_table_ingestions_append_items", "runs", "Append
|
|
84
|
+
operation("table_ingestions.append", "/api/cli/table-ingestion-runs/{ingestion_run_id}/items", "POST", "table-ingestions append", "oxygen_table_ingestions_append_items", "runs", "Append generated row chunks with items [{position:0,payload:{rows:[{column:value}]}}]. For capped provider paging, create a new approved tool_page run from the last completed item.nextCursor; preserve the Table, filters/mapping/upsert key and use a new credit cap.", { items: rows(), max_attempts: int({ minimum: 1 }) }, ["items"], cliOnly),
|
|
85
85
|
operation("tables.favorites.get", "/api/cli/tables/favorite", "GET", "tables favorites", "oxygen_tables_favorites", "tables", "Read starred Tables/folders (per-member preference; free (0 credits)); next_cursor. Catalog stars differ.", TABLE_FAVORITES_LIST_CONTRACT.properties, [], { generated: { cli: true, mcp: false } }),
|
|
86
86
|
operation("tables.favorite.set", "/api/cli/tables/favorite", "POST", "tables favorite", "oxygen_tables_favorite", "tables", "Star/unstar exactly one table or folder (saved per-member preference). Free (0 credits). Catalog stars differ.", TABLE_FAVORITE_CONTRACT.properties, ["favorite"]),
|
|
87
87
|
operation("tables.import.csv", "/api/cli/tables/import-csv", "POST", "tables import-csv", "oxygen_tables_import_csv", "tables", "Import inline CSV into a hosted Table through its durable ingestion run. Use staged upload for large files.", { csv_text: text(), csv_base64: text(), table: text(), table_name: text(), project: text(), filename: text(), upsert_key: text(), batch_size: int({ minimum: 1, maximum: 5000 }) }, [], cliOnly),
|
|
@@ -90,7 +90,7 @@ export const CUSTOMER_OPERATIONS = [
|
|
|
90
90
|
operation("tables.import.staged", "/api/cli/tables/import-staged", "POST", "tables import-staged", "oxygen_tables_import_staged", "tables", "Start a durable hosted import from this workspace's staged file; choose an existing table or create_name.", { table: text(), create_name: text(), project: text(), storage_key: text(), storage_bucket: text(), storage_provider: enumOf(["s3"]), file_name: text(), format: enumOf(["csv", "json", "xlsx"]), sha256: text(), upsert_key: text(), source_key_map: json, byte_length: int({ minimum: 1 }), row_count: int({ minimum: 1 }), max_concurrency: int({ minimum: 1, maximum: 1000 }), source: enumOf(["cli", "mcp", "web"]), batch_size: int({ minimum: 1, maximum: 5000 }) }, ["storage_key", "byte_length"]),
|
|
91
91
|
operation("tables.import.file", "/api/cli/tables/import-file", "POST", "tables import-file", "oxygen_tables_import_file", "tables", "Import small base64 file bytes through the shared multipart import API; use staged upload for large files.", { content_base64: text(), file_name: text(), content_type: text(), table: text(), table_name: text(), project: text(), format: enumOf(["csv", "json", "xlsx"]), sheet: text(), upsert_key: text(), source_key_map: json, source_hash: text(), batch_size: int({ minimum: 1, maximum: 5000 }), max_concurrency: int({ minimum: 1 }) }, ["content_base64", "file_name"], { multipart: { contentField: "content_base64", nameField: "file_name", typeField: "content_type", fileField: "file" } }),
|
|
92
92
|
operation("tables.link.suggest", "/api/cli/tables/link/suggest", "POST", "tables link-suggest", "oxygen_tables_link_suggest", "tables", "Plan column links and matching row counts between Tables; creates no relation. Apply with tables link bulk.", TABLE_LINK_SUGGEST_CONTRACT.properties, ["table", "to"], { mutates: false }),
|
|
93
|
-
operation("tables.recover_pending", "/api/cli/tables/recover-pending", "POST", "tables recover-pending", "oxygen_tables_recover_pending", "tables", "
|
|
93
|
+
operation("tables.recover_pending", "/api/cli/tables/recover-pending", "POST", "tables recover-pending", "oxygen_tables_recover_pending", "tables", "Recover BetterContact email/phone requests and read verification blockers; no new orders or imports.", TABLE_RECOVER_PENDING_CONTRACT.properties, ["table"], { ...mcpOnly, annotations: { openWorldHint: true } }),
|
|
94
94
|
operation("tables.webhooks.create", "/api/cli/tables/webhooks", "POST", "tables webhook create", "oxygen_table_webhook_create", "tables", "Create an inbound Table webhook or platform LinkedIn feed. Optional auto_run is standing permission with explicit or plan-default delivery ceilings.", { table: text(), name: text(), mode: enumOf(["insert", "upsert"]), upsert_key: text(), items_path: text(), event_id_path: text(), event_type_path: text(), occurred_at_path: text(), auth_mode: enumOf(["none", "secret"]), feed_kind: enumOf(["generic", "unipile"]), unipile_account_id: text(), event_types: list(), mapping: text(), auto_run: json }, ["table"], mcpOnly),
|
|
95
95
|
operation("tables.webhooks.update", "/api/cli/tables/webhooks", "PATCH", "tables webhook update", "oxygen_table_webhook_update", "tables", "Edit a Table webhook's name, state or standing auto_run; auth_mode=secret rotates its secret and returns it once.", { endpoint_id: text(), name: text(), status: enumOf(["active", "disabled"]), auth_mode: enumOf(["none", "secret"]), auto_run: json, clear_auto_run: bool() }, ["endpoint_id"], mcpOnly),
|
|
96
96
|
operation("whatsapp.draft", "/api/cli/whatsapp/draft", "POST", "whatsapp draft", "oxygen_whatsapp_draft", "inbox", "Generate a WhatsApp reply draft in the workspace voice, or approve a reviewed draft for an existing chat. Draft generation uses managed AI credits.", { conversation_id: text(), goal: text(), row_id: text(), object: text(), approved: bool(), draft_id: text(), text: text() }, ["conversation_id"], { ...cliOnly, spends: true }),
|
|
@@ -102,7 +102,7 @@ export const CUSTOMER_OPERATIONS = [
|
|
|
102
102
|
operation("db.provision", "/api/cli/db/provision", "POST", "db provision", "oxygen_workspace_database_provision", "workspace", "Provision or retry this workspace database immediately; already-ready workspaces retain it. No preview or confirmation field; native workspace write access applies. May outlast the connector deadline: read oxygen_workspace_database_status before retrying. Hosting is plan-included; 0 credits.", {}, [], mcpOnly),
|
|
103
103
|
operation("sessions.list", "/api/cli/sessions", "GET", "sessions list", "oxygen_sessions_list", "workspace", "List your personal CLI sessions across workspaces; OAuth or personal user session required, organization API keys are refused."),
|
|
104
104
|
operation("sessions.revoke", "/api/cli/sessions/{session_id}", "DELETE", "sessions revoke", "oxygen_sessions_revoke", "workspace", "Revoke one of your personal CLI sessions; OAuth or personal user session required, organization API keys are refused."),
|
|
105
|
-
operation("billing.allowance", "/api/cli/billing/allowance", "GET", "billing allowance", "oxygen_billing_allowance", "billing", "Inspect this workspace's recurring credit allowance and period
|
|
105
|
+
operation("billing.allowance", "/api/cli/billing/allowance", "GET", "billing allowance", "oxygen_billing_allowance", "billing", "Inspect this workspace's recurring credit allowance and period; due plan grants or credit expiry may apply. 0 credits; reserves nothing.", {}, [], { ...mcpOnly, mutates: true }),
|
|
106
106
|
operation("billing.invoices", "/api/cli/billing/invoices", "GET", "billing invoices", "oxygen_billing_invoices", "billing", "Read this workspace's billing invoices and their hosted payment links.", {}, [], mcpOnly),
|
|
107
107
|
operation("billing.seats.set", "/api/cli/billing/seats", "POST", "billing seats set", "oxygen_billing_seats_set", "billing", "Set purchased legacy sending-seat quantity. Retired seats cannot increase; reducing existing seats lowers the next renewal charge.", { seat_key: enumOf(["linkedin", "whatsapp", "phone_number", "email_sender"]), quantity: int({ minimum: 0 }) }, ["seat_key", "quantity"], mcpOnly),
|
|
108
108
|
operation("collaboration.gates.list", "/api/cli/collab/gates", "GET", "approvals gate list", "oxygen_approval_gates_list", "collaboration", "Read this workspace's approval gates, optionally for one exact subject.", { subject: text() }, [], mcpOnly),
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { arr, bool, enumOf, int, obj, str, } from "./schema.js";
|
|
2
|
+
import { GEO_ENGINES } from "../geo.js";
|
|
3
|
+
const text = (maxLength = 1000) => str({ maxLength });
|
|
4
|
+
const strings = () => arr(text(), { maxItems: 100 });
|
|
5
|
+
const engine = enumOf(GEO_ENGINES);
|
|
6
|
+
const market = obj({
|
|
7
|
+
country: str({ minLength: 2, maxLength: 2 }),
|
|
8
|
+
language: str({ minLength: 2, maxLength: 10 }),
|
|
9
|
+
}, { required: ["country", "language"] });
|
|
10
|
+
const project = {
|
|
11
|
+
name: str({ minLength: 1, maxLength: 120 }),
|
|
12
|
+
aliases: strings(),
|
|
13
|
+
domains: arr(text(), { minItems: 1, maxItems: 100 }),
|
|
14
|
+
icp: text(5000),
|
|
15
|
+
engines: arr(engine, { minItems: 1, maxItems: 7 }),
|
|
16
|
+
markets: arr(market, { minItems: 1, maxItems: 10 }),
|
|
17
|
+
repetitions: int({ minimum: 1, maximum: 5 }),
|
|
18
|
+
archived: bool(),
|
|
19
|
+
};
|
|
20
|
+
const prompt = {
|
|
21
|
+
text: str({ minLength: 1, maxLength: 500 }),
|
|
22
|
+
topic: text(120),
|
|
23
|
+
persona: text(120),
|
|
24
|
+
stage: text(120),
|
|
25
|
+
source: enumOf(["hypothesis", "research", "customer"]),
|
|
26
|
+
evidence_id: text(),
|
|
27
|
+
enabled: bool(),
|
|
28
|
+
};
|
|
29
|
+
const filters = {
|
|
30
|
+
engine,
|
|
31
|
+
country: text(2),
|
|
32
|
+
language: text(10),
|
|
33
|
+
prompt_id: text(),
|
|
34
|
+
topic: text(120),
|
|
35
|
+
from: text(40),
|
|
36
|
+
to: text(40),
|
|
37
|
+
limit: int({ minimum: 1, maximum: 200 }),
|
|
38
|
+
cursor: text(100),
|
|
39
|
+
};
|
|
40
|
+
function operation(id, suffix, method, command, summary, fields = {}, required = [], spends = false) {
|
|
41
|
+
const route = `/api/cli/geo${suffix}`;
|
|
42
|
+
return {
|
|
43
|
+
id: `geo.${id}`,
|
|
44
|
+
route,
|
|
45
|
+
method,
|
|
46
|
+
parameters: [...route.matchAll(/\{(\w+)\}/g)].map((m) => m[1]),
|
|
47
|
+
contract: obj(fields, { required }),
|
|
48
|
+
cli: {
|
|
49
|
+
path: ["geo", ...command.split(" ")],
|
|
50
|
+
summary: `Staff-only dev pilot: ${summary}`,
|
|
51
|
+
},
|
|
52
|
+
mcp: {
|
|
53
|
+
name: `oxygen_geo_${id.replaceAll(".", "_")}`,
|
|
54
|
+
title: `Oxygen GEO: ${command}`,
|
|
55
|
+
description: `${summary} Experimental: staff in dev workspaces only.`,
|
|
56
|
+
pack: "geo",
|
|
57
|
+
},
|
|
58
|
+
mutates: method !== "GET",
|
|
59
|
+
spends,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export const GEO_OPERATIONS = [
|
|
63
|
+
operation("projects.list", "/projects", "GET", "projects list", "List GEO / AEO AI-search tracking projects."),
|
|
64
|
+
operation("projects.create", "/projects", "POST", "projects create", "Create a GEO / AEO brand tracking project.", project, ["name", "domains"]),
|
|
65
|
+
operation("projects.get", "/projects/{project_id}", "GET", "projects get", "Read a GEO / AEO project and its tracking settings."),
|
|
66
|
+
operation("projects.update", "/projects/{project_id}", "PATCH", "projects update", "Change brand aliases, ICP, markets or tracking settings.", project),
|
|
67
|
+
operation("context.get", "/context", "GET", "context get", "Read editable brand, ICP and offering context from workspace Knowledge."),
|
|
68
|
+
operation("engines.list", "/engines", "GET", "engines list", "Inspect consumer answer-engine readiness, supported markets and collection cost."),
|
|
69
|
+
operation("prompts.list", "/projects/{project_id}/prompts", "GET", "prompts list", "List tracked buyer prompts and research provenance.", { limit: filters.limit, cursor: filters.cursor }),
|
|
70
|
+
operation("prompts.create", "/projects/{project_id}/prompts", "POST", "prompts add", "Add a reviewed buyer question to GEO / AEO tracking.", prompt, ["text"]),
|
|
71
|
+
operation("prompts.update", "/projects/{project_id}/prompts/{document_id}", "PATCH", "prompts update", "Revise or pause one tracked buyer question.", prompt),
|
|
72
|
+
operation("prompts.import", "/projects/{project_id}/prompts/import", "POST", "prompts import", "Import reviewed buyer prompts in one bounded batch.", {
|
|
73
|
+
prompts: arr(obj(prompt, { required: ["text"] }), {
|
|
74
|
+
minItems: 1,
|
|
75
|
+
maxItems: 100,
|
|
76
|
+
}),
|
|
77
|
+
}, ["prompts"]),
|
|
78
|
+
operation("competitors.list", "/projects/{project_id}/competitors", "GET", "competitors list", "List brands benchmarked against this project.", { limit: filters.limit, cursor: filters.cursor }),
|
|
79
|
+
operation("competitors.create", "/projects/{project_id}/competitors", "POST", "competitors add", "Add a competitive brand and its aliases and domains.", { name: project.name, aliases: strings(), domains: strings() }, ["name"]),
|
|
80
|
+
operation("competitors.update", "/projects/{project_id}/competitors/{document_id}", "PATCH", "competitors update", "Update competitive brand aliases and owned domains.", { name: project.name, aliases: strings(), domains: strings() }),
|
|
81
|
+
operation("runs.preview", "/projects/{project_id}/runs/preview", "POST", "runs preview", "Preview consumer collection coverage and cost without spending.", { prompt_ids: strings() }),
|
|
82
|
+
operation("runs.start", "/projects/{project_id}/runs", "POST", "runs start", "Run reviewed buyer prompts against real consumer answer engines within the pilot budget.", { preview_token: text(128) }, ["preview_token"], true),
|
|
83
|
+
operation("runs.list", "/projects/{project_id}/runs", "GET", "runs list", "List tracking runs with completion and costs."),
|
|
84
|
+
operation("runs.get", "/projects/{project_id}/runs/{run_id}", "GET", "runs get", "Inspect a tracking run, provider tasks, costs, failures and retries."),
|
|
85
|
+
operation("runs.retry", "/projects/{project_id}/runs/{run_id}/retry", "POST", "runs retry", "Resume failed reads of saved provider tasks without a new paid submission."),
|
|
86
|
+
operation("runs.cancel", "/projects/{project_id}/runs/{run_id}/cancel", "POST", "runs cancel", "Stop unsubmitted samples; submitted collection retains its spend reservation."),
|
|
87
|
+
operation("schedules.set", "/projects/{project_id}/schedule", "PATCH", "schedule set", "Pause or configure daily/weekly GEO tracking within a reviewed cost ceiling.", {
|
|
88
|
+
cadence: enumOf(["paused", "daily", "weekly"]),
|
|
89
|
+
preview_token: text(128),
|
|
90
|
+
}, ["cadence"], true),
|
|
91
|
+
operation("answers.list", "/projects/{project_id}/answers", "GET", "answers list", "Inspect collected answers and imported samples with citations and mention evidence.", filters),
|
|
92
|
+
operation("answers.get", "/projects/{project_id}/answers/{document_id}", "GET", "answers get", "Read the immutable original answer and full provider payload."),
|
|
93
|
+
operation("answers.import", "/projects/{project_id}/answers", "POST", "answers import", "Import a manually observed consumer answer, separate from controlled tracker metrics.", {
|
|
94
|
+
engine,
|
|
95
|
+
prompt: text(500),
|
|
96
|
+
text: text(100000),
|
|
97
|
+
urls: strings(),
|
|
98
|
+
captured_at: text(40),
|
|
99
|
+
}, ["engine", "prompt", "text"]),
|
|
100
|
+
operation("analytics.get", "/projects/{project_id}/analytics", "GET", "analytics get", "Read AI visibility, citations, share of voice and trends with sample denominators.", filters),
|
|
101
|
+
operation("sources.list", "/projects/{project_id}/sources", "GET", "sources list", "Find domains and pages cited by AI, including competitive citation gaps.", filters),
|
|
102
|
+
operation("evidence.preview", "/projects/{project_id}/evidence/preview", "POST", "evidence preview", "Preview and redact ChatGPT/Claude conversation exports before saving.", { transcript: text(1000000), redact: strings() }, ["transcript"]),
|
|
103
|
+
operation("evidence.list", "/projects/{project_id}/evidence", "GET", "evidence list", "List customer conversation evidence and original buyer language.", { limit: filters.limit, cursor: filters.cursor }),
|
|
104
|
+
operation("evidence.create", "/projects/{project_id}/evidence", "POST", "evidence save", "Save a reviewed redacted conversation with optional CRM attribution.", {
|
|
105
|
+
transcript: text(1000000),
|
|
106
|
+
redact: strings(),
|
|
107
|
+
record_id: text(),
|
|
108
|
+
record_object: text(120),
|
|
109
|
+
engine,
|
|
110
|
+
title: text(120),
|
|
111
|
+
}, ["transcript", "engine"]),
|
|
112
|
+
operation("audits.create", "/projects/{project_id}/audits", "POST", "audits run", "Audit up to ten owned public pages for crawlability and citation readiness.", { urls: arr(text(2000), { minItems: 1, maxItems: 10 }) }, ["urls"]),
|
|
113
|
+
operation("audits.list", "/projects/{project_id}/audits", "GET", "audits list", "Read site audit findings and proposed fixes.", { limit: filters.limit, cursor: filters.cursor }),
|
|
114
|
+
operation("opportunities.list", "/projects/{project_id}/opportunities", "GET", "opportunities list", "Read prioritized GEO content, source and messaging opportunities.", { limit: filters.limit, cursor: filters.cursor }),
|
|
115
|
+
operation("opportunities.refresh", "/projects/{project_id}/opportunities/refresh", "POST", "opportunities refresh", "Derive actionable content briefs and citation gaps from stored tracking evidence."),
|
|
116
|
+
operation("opportunities.draft", "/projects/{project_id}/opportunities/{document_id}/draft", "POST", "opportunities draft", "Save a reviewed content brief as a Knowledge page or an unapproved social draft.", {
|
|
117
|
+
target: enumOf(["knowledge", "post"]),
|
|
118
|
+
content: text(50000),
|
|
119
|
+
channel: enumOf(["linkedin", "x"]),
|
|
120
|
+
}, ["target"]),
|
|
121
|
+
operation("experiments.list", "/projects/{project_id}/experiments", "GET", "experiments list", "List optimization experiments and observed before/after changes.", { limit: filters.limit, cursor: filters.cursor }),
|
|
122
|
+
operation("experiments.create", "/projects/{project_id}/experiments", "POST", "experiments create", "Record an optimization hypothesis, baseline and target buyer questions.", {
|
|
123
|
+
title: text(120),
|
|
124
|
+
hypothesis: text(5000),
|
|
125
|
+
prompt_ids: strings(),
|
|
126
|
+
opportunity_id: text(),
|
|
127
|
+
proposed_change: text(10000),
|
|
128
|
+
}, ["title", "hypothesis"]),
|
|
129
|
+
operation("experiments.update", "/projects/{project_id}/experiments/{document_id}", "PATCH", "experiments update", "Record a reviewed change or evaluate an experiment against subsequent samples.", {
|
|
130
|
+
status: enumOf(["planned", "changed", "evaluated", "cancelled"]),
|
|
131
|
+
change_url: text(2000),
|
|
132
|
+
notes: text(10000),
|
|
133
|
+
}),
|
|
134
|
+
{
|
|
135
|
+
...operation("exports.get", "/projects/{project_id}/export", "GET", "export", "Download GEO evidence, metrics or an internal PDF report.", { format: enumOf(["json", "csv", "pdf"]), ...filters }),
|
|
136
|
+
binary: { maxBytes: 20_000_000 },
|
|
137
|
+
},
|
|
138
|
+
];
|
|
@@ -10,6 +10,7 @@ import { SENDER_LIMITS_SET_OPERATION } from "./sender-limits.js";
|
|
|
10
10
|
import { WORKSPACE_PAGES_GET_OPERATION, WORKSPACE_PAGES_UPDATE_OPERATION } from "./workspace-pages.js";
|
|
11
11
|
import { BROWSER_OPERATIONS } from "./browser-operations.js";
|
|
12
12
|
import { TABLE_FEED_UPDATE_OPERATION } from "./table-feed-update.js";
|
|
13
|
+
import { GEO_OPERATIONS } from "./geo.js";
|
|
13
14
|
import { CUSTOMER_OPERATIONS } from "./customer-operations.js";
|
|
14
15
|
/** The route with its `{param}` segments filled in, each URL-encoded. */
|
|
15
16
|
export function operationPath(spec, params) {
|
|
@@ -80,6 +81,6 @@ export function operationFormData(spec, body) {
|
|
|
80
81
|
}
|
|
81
82
|
return form;
|
|
82
83
|
}
|
|
83
|
-
export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION, CONNECTOR_UPDATE_OPERATION, CONNECTOR_EVENTS_REVOKE_OPERATION, WORKSPACE_PAGES_GET_OPERATION, WORKSPACE_PAGES_UPDATE_OPERATION, TABLE_FEED_UPDATE_OPERATION, ...CUSTOMER_OPERATIONS, ...BROWSER_OPERATIONS];
|
|
84
|
-
export const CLI_OPERATIONS = [...CUSTOMER_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.cli !== false);
|
|
85
|
-
export const MCP_OPERATIONS = [...CUSTOMER_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.mcp !== false);
|
|
84
|
+
export const OPERATION_SPECS = [SENDER_LIMITS_SET_OPERATION, CONNECTOR_UPDATE_OPERATION, CONNECTOR_EVENTS_REVOKE_OPERATION, WORKSPACE_PAGES_GET_OPERATION, WORKSPACE_PAGES_UPDATE_OPERATION, TABLE_FEED_UPDATE_OPERATION, ...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS];
|
|
85
|
+
export const CLI_OPERATIONS = [...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.cli !== false);
|
|
86
|
+
export const MCP_OPERATIONS = [...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS].filter((spec) => spec.generated?.mcp !== false);
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
export declare const TABLE_ROW_REMOVAL_FIELDS: {
|
|
2
2
|
row_ids: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
|
|
3
|
+
filter_tree: {
|
|
4
|
+
type?: never;
|
|
5
|
+
description?: string;
|
|
6
|
+
};
|
|
3
7
|
selection: import("./schema.js").OneOfProperty;
|
|
4
8
|
};
|
|
5
9
|
export declare const TABLE_ROW_REMOVAL_CONTRACT: import("./schema.js").ContractObject<{
|
|
6
10
|
row_ids: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
|
|
11
|
+
filter_tree: {
|
|
12
|
+
type?: never;
|
|
13
|
+
description?: string;
|
|
14
|
+
};
|
|
7
15
|
selection: import("./schema.js").OneOfProperty;
|
|
8
16
|
}, never>;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { arr, enumOf, int, obj, str } from "./schema.js";
|
|
1
|
+
import { arr, enumOf, int, jsonValue, obj, str } from "./schema.js";
|
|
2
2
|
const rowIds = () => arr(str({ minLength: 1 }), { minItems: 1 });
|
|
3
3
|
// Removal uses the grid's scope, not the broader execution selection. In
|
|
4
4
|
// particular, execution's `filters` array is not a serialized browser filter.
|
|
5
5
|
const selection = {
|
|
6
|
-
description: '{"mode":"row_ids","row_ids":["id"]} or {"mode":"all_matching","
|
|
6
|
+
description: '{"mode":"row_ids","row_ids":["id"]} or {"mode":"all_matching","filter_tree":{...}}, with optional search, sort, range {offset,limit}, excluded_row_ids, crm_object. Unreadable filter nodes and filters/limit are refused. Unqualified all_matching removes every row.',
|
|
7
7
|
oneOf: [
|
|
8
8
|
obj({ mode: enumOf(["row_ids"]), row_ids: rowIds() }, { required: ["mode", "row_ids"] }),
|
|
9
9
|
obj({
|
|
10
10
|
mode: enumOf(["all_matching"]),
|
|
11
|
-
|
|
11
|
+
filter_tree: jsonValue({ description: "Readable filter tree, as tables query takes. Not with filter." }),
|
|
12
|
+
filter: str({ description: "Browser URL filter (f). Not with filter_tree." }),
|
|
12
13
|
search: str(),
|
|
13
14
|
sort: str(),
|
|
14
15
|
range: obj({ offset: int({ minimum: 0 }), limit: int({ minimum: 1 }) }, { required: ["offset", "limit"] }),
|
|
@@ -17,5 +18,10 @@ const selection = {
|
|
|
17
18
|
}, { required: ["mode"] }),
|
|
18
19
|
],
|
|
19
20
|
};
|
|
20
|
-
|
|
21
|
+
// The readable filtered scope (OXP-1.1.12): removes exactly the rows this
|
|
22
|
+
// filter tree matches. Shorthand for an all_matching selection.
|
|
23
|
+
const filterTree = jsonValue({
|
|
24
|
+
description: 'Remove the rows this filter tree matches (as tables query takes), e.g. {"type":"group","children":[{"type":"leaf","columnKey":"status","operator":"is","value":"churned"}]} (text: is/is_not/contains; numbers: eq/gt/lt). Unreadable nodes are refused. Not with row_ids/selection.',
|
|
25
|
+
});
|
|
26
|
+
export const TABLE_ROW_REMOVAL_FIELDS = { row_ids: rowIds(), filter_tree: filterTree, selection };
|
|
21
27
|
export const TABLE_ROW_REMOVAL_CONTRACT = obj(TABLE_ROW_REMOVAL_FIELDS);
|
|
@@ -82,6 +82,7 @@ export type CostEstimatePriceSheet = {
|
|
|
82
82
|
topupStepCredits: number;
|
|
83
83
|
sending: {
|
|
84
84
|
emailPerMailboxPerDay: number;
|
|
85
|
+
/** Inclusive completed sending days, starting at 0, as in email-campaign-ramp. */
|
|
85
86
|
emailRamp: readonly {
|
|
86
87
|
throughDay: number;
|
|
87
88
|
perDay: number;
|
|
@@ -133,6 +134,11 @@ export type CostEstimateLimit = {
|
|
|
133
134
|
capacity_per_month: number;
|
|
134
135
|
/** What a newly connected sender manages in its first month (email ramp). */
|
|
135
136
|
first_month_capacity: number | null;
|
|
137
|
+
/** Sender need when all entered mailboxes start at the beginning of the ramp. */
|
|
138
|
+
first_month_senders_needed: number | null;
|
|
139
|
+
/** null when this channel has no modeled first-month capacity. */
|
|
140
|
+
first_month_hit: boolean | null;
|
|
141
|
+
/** Steady-state sender need and hit; the first-month ramp does not change these. */
|
|
136
142
|
senders_needed: number;
|
|
137
143
|
hit: boolean;
|
|
138
144
|
note: string;
|
|
@@ -115,8 +115,8 @@ function cheapestRung(credits, usdSeats, sheet) {
|
|
|
115
115
|
function rampFirstMonthCapacity(sheet) {
|
|
116
116
|
const { emailRamp, emailPerMailboxPerDay, sendingDaysPerMonth } = sheet.sending;
|
|
117
117
|
let total = 0;
|
|
118
|
-
for (let
|
|
119
|
-
const tier = emailRamp.find((step) =>
|
|
118
|
+
for (let completedDays = 0; completedDays < sendingDaysPerMonth; completedDays += 1) {
|
|
119
|
+
const tier = emailRamp.find((step) => completedDays <= step.throughDay);
|
|
120
120
|
total += tier ? Math.min(tier.perDay, emailPerMailboxPerDay) : emailPerMailboxPerDay;
|
|
121
121
|
}
|
|
122
122
|
return total;
|
|
@@ -125,11 +125,19 @@ function limitRow(input) {
|
|
|
125
125
|
const capacity = input.senders * input.perSender;
|
|
126
126
|
const sendersNeeded = input.needed === 0 || input.perSender <= 0 ? 0 : Math.ceil(input.needed / input.perSender);
|
|
127
127
|
const hit = input.needed > capacity;
|
|
128
|
-
const
|
|
128
|
+
const firstMonthCapacity = input.firstMonthPerSender === null ? null : input.senders * input.firstMonthPerSender;
|
|
129
|
+
const firstMonthSendersNeeded = input.firstMonthPerSender === null || input.firstMonthPerSender <= 0
|
|
130
|
+
? null
|
|
131
|
+
: Math.ceil(input.needed / input.firstMonthPerSender);
|
|
132
|
+
const firstMonthHit = firstMonthCapacity === null ? null : input.needed > firstMonthCapacity;
|
|
133
|
+
const steadyNote = input.needed === 0
|
|
129
134
|
? "Not used by this scenario."
|
|
130
135
|
: hit
|
|
131
136
|
? `${formatInt(input.needed)} ${input.unit} a month needs ${formatInt(sendersNeeded)}; you entered ${formatInt(input.senders)}. Each sends ${formatInt(input.perSender)} a month (${input.bindingLimit}). ${input.addWith}`
|
|
132
137
|
: `${formatInt(input.needed)} of ${formatInt(capacity)} ${input.unit} a month (${input.bindingLimit} each).`;
|
|
138
|
+
const rampNote = firstMonthHit && input.firstMonthPerSender !== null && input.firstMonthPerSender < input.perSender
|
|
139
|
+
? ` If these mailboxes are newly connected, their first-month capacity is ${formatInt(firstMonthCapacity)} ${input.unit}; ${formatInt(input.needed)} needs ${firstMonthSendersNeeded === null ? "more mailboxes" : `${formatInt(firstMonthSendersNeeded)} mailboxes`}. This quote prices the ${formatInt(input.senders)} entered mailboxes; request a new estimate after adding mailboxes.`
|
|
140
|
+
: "";
|
|
133
141
|
return {
|
|
134
142
|
key: input.key,
|
|
135
143
|
label: input.label,
|
|
@@ -138,10 +146,12 @@ function limitRow(input) {
|
|
|
138
146
|
per_sender_per_month: input.perSender,
|
|
139
147
|
binding_limit: input.bindingLimit,
|
|
140
148
|
capacity_per_month: capacity,
|
|
141
|
-
first_month_capacity:
|
|
149
|
+
first_month_capacity: firstMonthCapacity,
|
|
150
|
+
first_month_senders_needed: firstMonthSendersNeeded,
|
|
151
|
+
first_month_hit: firstMonthHit,
|
|
142
152
|
senders_needed: sendersNeeded,
|
|
143
153
|
hit,
|
|
144
|
-
note,
|
|
154
|
+
note: steadyNote + rampNote,
|
|
145
155
|
};
|
|
146
156
|
}
|
|
147
157
|
/** Clamp a scenario into the accepted bounds: whole, non-negative numbers. */
|
|
@@ -316,6 +326,8 @@ export function estimateScenarioCost(rawScenario, sheet) {
|
|
|
316
326
|
}),
|
|
317
327
|
].filter((row) => row.needed_per_month > 0);
|
|
318
328
|
const limitsHit = limits.filter((row) => row.hit).map((row) => row.key);
|
|
329
|
+
const firstMonthRampHit = limits.find((row) => row.key === "email_per_mailbox"
|
|
330
|
+
&& row.first_month_hit && row.first_month_capacity !== null && row.first_month_capacity < row.capacity_per_month);
|
|
319
331
|
const assumptions = [
|
|
320
332
|
"Every lead is already in a table and goes through each enrichment and every sequence step. Finding new leads is priced separately.",
|
|
321
333
|
`Sending limits use ${sending.sendingDaysPerMonth} sending days a month and the defaults for a newly connected account (a sender you already run may be set lower): ${sending.emailPerMailboxPerDay} emails a mailbox a day, ${sending.linkedinInvitesPerDay} LinkedIn invites a day and ${sending.linkedinInvitesPerWeek} a week, ${sending.linkedinMessagesPerDay} LinkedIn messages a day.`,
|
|
@@ -323,9 +335,9 @@ export function estimateScenarioCost(rawScenario, sheet) {
|
|
|
323
335
|
"While senders are paid as sending seats (sender_billing: sending_seats) they reserve no credits; once seats are retired each connected sender reserves its monthly price in credits instead, never both.",
|
|
324
336
|
`Credits are shown at face value, ${formatInt(sheet.creditsPerUsd)} credits to $1; top-ups cost ${formatUsd(sheet.topupUsdCentsPer100 / 100)} per 100 credits.`,
|
|
325
337
|
];
|
|
326
|
-
if (totalMailboxes > 0 && emails > 0) {
|
|
338
|
+
if (totalMailboxes > 0 && emails > 0 && sending.emailRamp.length > 0) {
|
|
327
339
|
const ramp = sending.emailRamp.map((step) => step.perDay).join(", ");
|
|
328
|
-
assumptions.push(`A newly connected mailbox ramps up (${ramp} a day) before it sends at its full daily limit; first_month_capacity
|
|
340
|
+
assumptions.push(`A newly connected mailbox ramps up (${ramp} a day) before it sends at its full daily limit; first_month_capacity, first_month_senders_needed and first_month_hit show that first ${sending.sendingDaysPerMonth}-sending-day window. The other capacity and hit fields describe steady state.`);
|
|
329
341
|
}
|
|
330
342
|
const planText = recommended
|
|
331
343
|
? `cheapest all in: the ${recommended.expected.label} plan${recommended.expected.topup_credits > 0 ? " with top-ups" : ""}${usdSeats > 0 ? " and seats" : ""}, ${formatUsd(recommended.expected.monthly_usd ?? 0)} a month`
|
|
@@ -337,7 +349,10 @@ export function estimateScenarioCost(rawScenario, sheet) {
|
|
|
337
349
|
`About ${formatInt(credits)} credits a month expected (up to ${formatInt(worstCredits)} worst case)${usdSeats > 0 ? ` plus ${formatUsd(usdSeats)} a month in sending seats` : ""}; ${planText}.`,
|
|
338
350
|
limitsHit.length > 0
|
|
339
351
|
? `Limits hit: ${limits.filter((row) => row.hit).map((row) => `${row.label.toLowerCase()} (needs ${formatInt(row.senders_needed)})`).join(", ")}.`
|
|
340
|
-
: limits.length > 0 ? "No sending limit is hit." : "",
|
|
352
|
+
: limits.length > 0 ? firstMonthRampHit ? "No steady-state sending limit is hit." : "No sending limit is hit." : "",
|
|
353
|
+
firstMonthRampHit
|
|
354
|
+
? `New-mailbox ramp shortfall: ${formatInt(firstMonthRampHit.first_month_capacity)} of ${formatInt(firstMonthRampHit.needed_per_month)} emails in the first ${sending.sendingDaysPerMonth} sending days${firstMonthRampHit.first_month_senders_needed === null ? "" : ` (needs ${formatInt(firstMonthRampHit.first_month_senders_needed)} mailboxes)`}. Prices cover the entered mailboxes; request a new estimate after adding mailboxes.`
|
|
355
|
+
: "",
|
|
341
356
|
].filter(Boolean).join(" ");
|
|
342
357
|
return {
|
|
343
358
|
lines,
|
|
@@ -56,7 +56,8 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
56
56
|
f("job_start_date", "Job start date", null),
|
|
57
57
|
f("person_location", "Location", "person.location"),
|
|
58
58
|
f("person_bio", "Bio", "person.bio"),
|
|
59
|
-
|
|
59
|
+
// Blitz publishes no follower count; only the fallback lane fills it (OXP-1.27.6).
|
|
60
|
+
f("person_linkedin_followers", "LinkedIn followers (only when the fallback lane answers)", "person.linkedin_followers", "numeric"),
|
|
60
61
|
f("education", "Education", null, "jsonb"),
|
|
61
62
|
f("school_name", "School", null),
|
|
62
63
|
],
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type DeployTier } from "./deploy-env.js";
|
|
2
|
+
/**
|
|
3
|
+
* Which background EXTERNAL EFFECTS each deployment is armed to dispatch, declared
|
|
4
|
+
* here per environment rather than implied by whichever credentials a box holds.
|
|
5
|
+
*
|
|
6
|
+
* OXP-1.6.190 (classify-A P-05). Three background families write outside OXYGEN with
|
|
7
|
+
* no human in the loop: the off-session Stripe writes of `inbox_billing_reconcile`,
|
|
8
|
+
* the Zapmail writes of `mailbox_connection_repair`, and the dedicated-egress vendor
|
|
9
|
+
* release (IPRoyal `toggle-auto-extend`) of the egress order reconcile and lifecycle.
|
|
10
|
+
* Before this table their arming was implicit: present credentials meant armed.
|
|
11
|
+
*
|
|
12
|
+
* Chosen design (coordinator, 2026-10-07, "option 2"): every CURRENT default is kept, so
|
|
13
|
+
* dev billing reconcile, Zapmail repair and egress releases keep working for dev flows,
|
|
14
|
+
* but the state is now explicit, reviewable and read back, and two mismatches fail
|
|
15
|
+
* closed outside production-data environments: a LIVE Stripe key, and a vendor release
|
|
16
|
+
* whose family is not armed here. Production stays armed; nothing here changes a
|
|
17
|
+
* production default or touches a secret. A default-off posture outside production
|
|
18
|
+
* was considered and not chosen, because other dev workflows depend on these effects.
|
|
19
|
+
*
|
|
20
|
+
* Changing an entry is a reviewed code change. `OXYGEN_WORKER_MAILBOX_CONNECTION_REPAIR`
|
|
21
|
+
* remains the existing runtime kill switch for the repair family (`false` disarms it).
|
|
22
|
+
*/
|
|
23
|
+
export declare const EXTERNAL_EFFECT_FAMILIES: readonly ["inbox_billing_stripe", "mailbox_connection_repair", "egress_vendor_release"];
|
|
24
|
+
export type ExternalEffectFamily = typeof EXTERNAL_EFFECT_FAMILIES[number];
|
|
25
|
+
export type ExternalEffectArmingState = "armed" | "disarmed";
|
|
26
|
+
/** The checked-in declaration, per deploy tier (see `resolveDeployEnv`). */
|
|
27
|
+
export declare const EXTERNAL_EFFECT_ARMING: Readonly<Record<ExternalEffectFamily, Readonly<Record<DeployTier, ExternalEffectArmingState>>>>;
|
|
28
|
+
export type ExternalEffectArming = {
|
|
29
|
+
family: ExternalEffectFamily;
|
|
30
|
+
armed: boolean;
|
|
31
|
+
declared: ExternalEffectArmingState;
|
|
32
|
+
/** `declaration` = the table above; `env` = the family's kill switch disarmed it. */
|
|
33
|
+
source: "declaration" | "env";
|
|
34
|
+
tier: DeployTier;
|
|
35
|
+
deploy_env: string;
|
|
36
|
+
production_data: boolean;
|
|
37
|
+
};
|
|
38
|
+
export declare function resolveExternalEffectArming(family: ExternalEffectFamily, env?: NodeJS.ProcessEnv): ExternalEffectArming;
|
|
39
|
+
/** The readback: every family's effective gate for this process. */
|
|
40
|
+
export declare function describeExternalEffectArming(env?: NodeJS.ProcessEnv): ExternalEffectArming[];
|
|
41
|
+
/**
|
|
42
|
+
* Throw before an effect is dispatched when this process is not armed for it.
|
|
43
|
+
* Production-data environments keep their declared state; the refusal names the
|
|
44
|
+
* family and environment so a skipped effect is never silent.
|
|
45
|
+
*/
|
|
46
|
+
export declare function assertExternalEffectArmed(family: ExternalEffectFamily, env?: NodeJS.ProcessEnv): ExternalEffectArming;
|
|
47
|
+
/** Live-mode Stripe secret or restricted key. Test keys (`sk_test_`/`rk_test_`) never match. */
|
|
48
|
+
export declare function isLiveStripeKey(key: string | null | undefined): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Fail closed when a process that does NOT hold production data is about to write to
|
|
51
|
+
* Stripe with a LIVE key: that is a dev or local box pointed at real money. The key is
|
|
52
|
+
* never echoed; only its mode is named.
|
|
53
|
+
*/
|
|
54
|
+
export declare function assertStripeKeyMatchesEnvironment(key: string | null | undefined, env?: NodeJS.ProcessEnv): void;
|