@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.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.d.ts +3 -1
  3. package/dist/command-manifest.js +11 -0
  4. package/dist/customer-operation-commands.js +1 -1
  5. package/dist/index.js +57 -31
  6. package/dist/transcript.js +1 -1
  7. package/node_modules/@oxygen/formula/dist/expression.js +16 -0
  8. package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +8 -0
  9. package/node_modules/@oxygen/shared/dist/attempt-provenance.js +15 -0
  10. package/node_modules/@oxygen/shared/dist/byok-connect.js +2 -0
  11. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +2 -1
  12. package/node_modules/@oxygen/shared/dist/capability-discovery.js +28 -4
  13. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -2
  14. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +3 -3
  15. package/node_modules/@oxygen/shared/dist/contracts/geo.d.ts +2 -0
  16. package/node_modules/@oxygen/shared/dist/contracts/geo.js +138 -0
  17. package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
  18. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +8 -0
  19. package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +10 -4
  20. package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +6 -0
  21. package/node_modules/@oxygen/shared/dist/cost-estimate.js +23 -8
  22. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +2 -1
  23. package/node_modules/@oxygen/shared/dist/external-effect-arming.d.ts +54 -0
  24. package/node_modules/@oxygen/shared/dist/external-effect-arming.js +90 -0
  25. package/node_modules/@oxygen/shared/dist/geo.d.ts +247 -0
  26. package/node_modules/@oxygen/shared/dist/geo.js +314 -0
  27. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +9 -0
  28. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +1 -1
  29. package/node_modules/@oxygen/shared/dist/managed-email-branding.d.ts +15 -0
  30. package/node_modules/@oxygen/shared/dist/managed-email-branding.js +81 -0
  31. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +20 -24
  32. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +15 -16
  33. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +3 -3
  34. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +3 -3
  35. package/node_modules/@oxygen/shared/dist/product-doctrine.js +6 -5
  36. package/node_modules/@oxygen/shared/dist/recipes.d.ts +1 -1
  37. package/node_modules/@oxygen/shared/dist/recipes.js +1 -0
  38. package/node_modules/@oxygen/shared/dist/repricing.d.ts +4 -14
  39. package/node_modules/@oxygen/shared/dist/repricing.js +25 -64
  40. package/node_modules/@oxygen/shared/dist/research-depth.d.ts +11 -0
  41. package/node_modules/@oxygen/shared/dist/research-depth.js +7 -0
  42. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  43. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  44. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  45. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  46. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +46 -0
  47. package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +51 -1
  48. package/node_modules/@oxygen/shared/package.json +10 -0
  49. 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): 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.",
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 oxygen_capabilities_read; built-in agents invoke the native capability. Reads cost 0 credits.",
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. 0 credits; reserves nothing. Due plan grants or credit expiry may apply."),
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 filtered selection. Unsupported scope fields are refused.", TABLE_ROW_REMOVAL_FIELDS, [], {}, { destructiveHint: true }),
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 bounded ingestion items to an existing run; preserves the run's approved scope.", { items: rows(), max_attempts: int({ minimum: 1 }) }, ["items"], cliOnly),
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", "Poll pending BetterContact requests and save recovered phones; no new orders. Does not resume imports.", TABLE_RECOVER_PENDING_CONTRACT.properties, ["table"], { ...mcpOnly, annotations: { openWorldHint: true } }),
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. 0 credits; reserves nothing. Due plan grants or credit expiry may apply.", {}, [], mcpOnly),
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,2 @@
1
+ import type { OperationSpec } from "./operations.js";
2
+ export declare const GEO_OPERATIONS: readonly OperationSpec[];
@@ -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","filter":"<grid f>"}. Optional search, sort, range {offset,limit}, excluded_row_ids, crm_object. filter is base64url grid JSON; filters/limit are refused. Unqualified all_matching removes every row. Encoding guide: oxygen_skills_get({name:"oxygen-tables"}) or oxygen skills get oxygen-tables --json.',
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
- filter: str({ description: "Serialized browser filter tree." }),
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
- export const TABLE_ROW_REMOVAL_FIELDS = { row_ids: rowIds(), selection };
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 day = 1; day <= sendingDaysPerMonth; day += 1) {
119
- const tier = emailRamp.find((step) => day <= step.throughDay);
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 note = input.needed === 0
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: input.firstMonthPerSender === null ? null : input.senders * input.firstMonthPerSender,
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 shows that month.`);
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
- f("person_linkedin_followers", "LinkedIn followers", "person.linkedin_followers", "numeric"),
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;