@oxygen-agent/cli 1.1013.94 → 1.1014.18

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 (33) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +190 -70
  3. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +115 -0
  4. package/node_modules/@oxygen/shared/dist/agent-sessions.js +133 -0
  5. package/node_modules/@oxygen/shared/dist/app-storage-maintenance.d.ts +3 -0
  6. package/node_modules/@oxygen/shared/dist/app-storage-maintenance.js +16 -0
  7. package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -4
  8. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +72 -0
  9. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +35 -0
  10. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
  11. package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
  12. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +3 -0
  13. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +16 -4
  14. package/node_modules/@oxygen/shared/dist/copilot-access.d.ts +33 -0
  15. package/node_modules/@oxygen/shared/dist/copilot-access.js +33 -0
  16. package/node_modules/@oxygen/shared/dist/copilot-questions.d.ts +91 -0
  17. package/node_modules/@oxygen/shared/dist/copilot-questions.js +246 -0
  18. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +28 -18
  19. package/node_modules/@oxygen/shared/dist/hosted-ai.js +96 -45
  20. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +1 -1
  21. package/node_modules/@oxygen/shared/dist/langfuse.js +37 -9
  22. package/node_modules/@oxygen/shared/dist/object-storage.js +9 -0
  23. package/node_modules/@oxygen/shared/dist/sequences.d.ts +5 -1
  24. package/node_modules/@oxygen/shared/dist/sequences.js +91 -0
  25. package/node_modules/@oxygen/shared/dist/tool-call-markup.d.ts +23 -0
  26. package/node_modules/@oxygen/shared/dist/tool-call-markup.js +213 -0
  27. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +5 -0
  28. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  29. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  30. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  31. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  32. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +4 -0
  33. package/package.json +1 -1
@@ -0,0 +1,133 @@
1
+ import { isStaffEmail } from "./staff.js";
2
+ import { OxygenError } from "./cli-result.js";
3
+ import { WORKSPACE_EVENT_CATALOG } from "./workspace-event-catalog.js";
4
+ export function agentSessionsEnabled(env = process.env) {
5
+ return env.OXYGEN_DEPLOY_ENV === "selfhost-dev" && env.OXYGEN_AGENT_SESSIONS_ENABLED === "staff" && env.OXYGEN_AGENTS_ENABLED?.trim().toLowerCase() === "true";
6
+ }
7
+ export function agentSessionsAvailableTo(env, viewer) {
8
+ return agentSessionsEnabled(env) && viewer.member && viewer.oxygenRole !== "client" && isStaffEmail(viewer.email, env);
9
+ }
10
+ /** Reviewed native adapters. Unknown tools (including generic dispatch and agent management) fail closed. */
11
+ export const AGENT_SESSION_ADAPTERS = {
12
+ oxygen_sequences_get: { resource: "sequences", field: "sequence", read: true },
13
+ oxygen_sequences_analytics: { resource: "sequences", field: "sequence", read: true },
14
+ oxygen_sequences_contacts: { resource: "sequences", field: "sequence", read: true },
15
+ oxygen_sequences_enrollments: { resource: "sequences", field: "sequence", read: true },
16
+ oxygen_sequences_enroll: { resource: "sequences", field: "sequence", read: false },
17
+ oxygen_sequences_set_status: { resource: "sequences", field: "sequence", read: false },
18
+ oxygen_tables_describe: { resource: "tables", field: "table", read: true },
19
+ oxygen_table_rows_page: { resource: "tables", field: "table", read: true },
20
+ oxygen_table_row_inspect: { resource: "tables", field: "table", read: true },
21
+ oxygen_rows_upsert: { resource: "tables", field: "table", read: false },
22
+ oxygen_rows_update: { resource: "tables", field: "table", read: false },
23
+ oxygen_tables_dedupe: { resource: "tables", field: "table", read: false },
24
+ oxygen_inbox_get: { resource: "conversations", field: "conversation", read: true },
25
+ oxygen_inbox_set_status: { resource: "conversations", field: "conversation", read: false },
26
+ oxygen_inbox_send: { resource: "conversations", field: "conversation", read: false },
27
+ oxygen_mailboxes_get: { resource: "mailboxes", field: "mailbox", read: true },
28
+ oxygen_mailboxes_set_status: { resource: "mailboxes", field: "mailbox", read: false },
29
+ oxygen_mailboxes_set_cap: { resource: "mailboxes", field: "mailbox", read: false },
30
+ oxygen_crm_record_get: { resource: "records", field: "row_id", read: true },
31
+ oxygen_crm_notes_list: { resource: "records", field: "row_id", read: true },
32
+ oxygen_crm_notes_add: { resource: "records", field: "row_id", read: false },
33
+ oxygen_crm_record_timeline: { resource: "records", field: "row_id", read: true },
34
+ oxygen_crm_merge: { resource: "objects", field: "object", read: false },
35
+ oxygen_crm_record_search: { resource: "objects", field: "objects", read: true, array: true },
36
+ oxygen_crm_pipeline: { resource: "objects", field: "", read: true, fixed: "deals" },
37
+ oxygen_crm_duplicates: { resource: "objects", field: "object", read: true },
38
+ oxygen_knowledge_page_get: { resource: "knowledge", field: "slug", read: true },
39
+ oxygen_knowledge_page_upsert: { resource: "knowledge", field: "page.slug", read: false },
40
+ oxygen_find_company: { resource: "domains", field: "domain", read: false },
41
+ };
42
+ const CONTEXT_READS = new Set([
43
+ "oxygen_capabilities_search", "oxygen_capabilities_schema", "oxygen_skills_list", "oxygen_skills_get",
44
+ "oxygen_knowledge_index", "oxygen_knowledge_search", "oxygen_workspace_get", "oxygen_enrichment_catalog",
45
+ ]);
46
+ export function isAgentSessionCapability(name) { return CONTEXT_READS.has(name) || Boolean(AGENT_SESSION_ADAPTERS[name]); }
47
+ export function agentSessionCapabilityDecision(config, name, args) {
48
+ if (CONTEXT_READS.has(name))
49
+ return "allow";
50
+ const adapter = AGENT_SESSION_ADAPTERS[name];
51
+ if (!adapter)
52
+ return "deny";
53
+ // No alternate aggregate scope may widen a resource-specific read.
54
+ if (["campaigns", "senders", "account", "crm_object", "ignore_sender_bindings"].some(key => args[key] !== undefined && args[key] !== false))
55
+ return "deny";
56
+ if (args.from_table !== undefined && args.from_table !== false && name !== "oxygen_sequences_enroll")
57
+ return "deny";
58
+ if (name === "oxygen_find_company" && (args.name !== undefined || args.linkedin_url !== undefined || typeof args.domain !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(args.domain)))
59
+ return "deny";
60
+ if (args.sender_account_id !== undefined && (name !== "oxygen_inbox_send" || typeof args.sender_account_id !== "string" || !config.resources.mailboxes.includes(args.sender_account_id)))
61
+ return "ask";
62
+ if (name === "oxygen_knowledge_page_upsert" && args.page && typeof args.page === "object" && "id" in args.page)
63
+ return "deny";
64
+ if (name === "oxygen_knowledge_page_get" && args.id !== undefined)
65
+ return "deny";
66
+ const ref = adapter.fixed ?? adapter.field.split(".").reduce((value, key) => value && typeof value === "object" ? value[key] : undefined, args);
67
+ if (adapter.array) {
68
+ if (!Array.isArray(ref) || !ref.length || ref.some(value => typeof value !== "string" || value.includes(",")))
69
+ return "deny";
70
+ return ref.every(value => config.resources[adapter.resource].includes(value)) ? "allow" : "ask";
71
+ }
72
+ if (typeof ref !== "string" || !ref || ref.includes(","))
73
+ return "deny";
74
+ if (!config.resources[adapter.resource].includes(ref))
75
+ return "ask";
76
+ if (name === "oxygen_sequences_set_status" && args.status !== "paused")
77
+ return "ask";
78
+ return adapter.read || config.actions.includes(name) ? "allow" : "ask";
79
+ }
80
+ export const EMPTY_AGENT_SESSION_RESOURCES = {
81
+ sequences: [], tables: [], conversations: [], mailboxes: [], records: [], objects: [], knowledge: [], domains: [],
82
+ };
83
+ export const AGENT_SESSION_EVENT_RESOURCES = {
84
+ sequence_id: "sequences", table_id: "tables", conversation_id: "conversations",
85
+ mailbox_id: "mailboxes", row_id: "records", record_id: "records",
86
+ };
87
+ export const AGENT_SESSION_EVENTS = WORKSPACE_EVENT_CATALOG.filter(event => Object.keys(AGENT_SESSION_EVENT_RESOURCES).some(key => event.payloadFields.includes(key)));
88
+ export function agentSessionEventIsScoped(config, payload) {
89
+ return Object.entries(AGENT_SESSION_EVENT_RESOURCES).some(([key, resource]) => typeof payload[key] === "string" && config.resources[resource].includes(payload[key]));
90
+ }
91
+ export const AGENT_SESSION_TEMPLATES = [
92
+ { id: "campaign-monitor", title: "Campaign monitor", goal: "Watch this native campaign. Compare sends, replies and meetings with earlier reviews; explain changes, propose improvements and schedule the next review.", resources: ["sequences"], actions: ["oxygen_sequences_set_status"], events: ["sequencer.contact_activity.any"] },
93
+ { id: "lead-replenishment", title: "Lead replenishment", goal: "Maintain a qualified lead pool in the selected Table for this native campaign. Research gaps, deduplicate and enroll only qualified leads within the approved scope.", resources: ["tables", "sequences"], actions: ["oxygen_rows_upsert", "oxygen_sequences_enroll"], events: [] },
94
+ { id: "reply-handling", title: "Reply handling", goal: "Review the selected conversations, remember prior responses, qualify intent and draft replies. Send only when explicitly authorized; hand off uncertain or sensitive replies.", resources: ["conversations"], actions: ["oxygen_inbox_set_status"], events: ["crm.email_reply_classified"] },
95
+ { id: "deliverability", title: "Deliverability", goal: "Watch the selected mailboxes for sending health changes. Explain evidence and recommend or apply only approved cap and status adjustments.", resources: ["mailboxes"], actions: ["oxygen_mailboxes_set_cap", "oxygen_mailboxes_set_status"], events: [] },
96
+ { id: "account-research", title: "Account research", goal: "Maintain cited account research for the selected records and knowledge pages. Track changes and recommend next actions; keep facts separate from hypotheses.", resources: ["records", "knowledge"], actions: ["oxygen_crm_notes_add", "oxygen_knowledge_page_upsert"], events: [] },
97
+ { id: "inbound-qualification", title: "Inbound qualification", goal: "Qualify the selected inbound conversations and records against workspace ICP and product knowledge. Record evidence and recommendations; hand off high intent to a person.", resources: ["conversations", "records"], actions: ["oxygen_inbox_set_status", "oxygen_crm_notes_add"], events: ["crm.email_reply_classified"] },
98
+ { id: "crm-hygiene", title: "CRM hygiene", goal: "Review the selected CRM objects for duplicates and missing or inconsistent fields. Propose repairs with evidence, and apply only explicitly authorized changes.", resources: ["objects"], actions: ["oxygen_crm_merge"], events: [] },
99
+ { id: "pipeline-review", title: "Pipeline review", goal: "Review the selected pipeline and lifecycle stages. Remember earlier reviews, identify stalled opportunities and recommend the next action with evidence.", resources: ["objects", "records"], actions: ["oxygen_crm_notes_add"], events: [] },
100
+ ];
101
+ export function validateAgentSessionConfig(config, requireResources = true) {
102
+ const fail = (message) => { throw new OxygenError("invalid_session_config", message); };
103
+ if (!config.title?.trim() || !config.goal?.trim())
104
+ fail("A title and goal are required.");
105
+ if (config.template !== "custom" && !AGENT_SESSION_TEMPLATES.some(t => t.id === config.template))
106
+ fail("Unknown session template.");
107
+ const { per_run_credits: perRun, daily_credits: daily, lifetime_credits: lifetime } = config.budget;
108
+ if (!Number.isFinite(perRun) || perRun <= 0 || perRun > 25000 || !Number.isFinite(daily) || daily < perRun)
109
+ fail("Set positive per-run and rolling daily caps; daily must cover one run and per-run cannot exceed 25,000 credits.");
110
+ if (lifetime !== undefined && (!Number.isFinite(lifetime) || lifetime < perRun))
111
+ fail("The lifetime cap must cover one run.");
112
+ const { interval_minutes: interval, min_interval_minutes: min, max_interval_minutes: max } = config.schedule;
113
+ if (![interval, min, max].every(Number.isFinite) || min < 5 || max < min || interval < min || interval > max || max > 525600)
114
+ fail("The check interval must fit approved bounds (at least five minutes).");
115
+ if (config.ends_at && !Number.isFinite(Date.parse(config.ends_at)))
116
+ fail("Use an ISO date for the end time.");
117
+ if (config.actions.some(name => !AGENT_SESSION_ADAPTERS[name] || AGENT_SESSION_ADAPTERS[name].read))
118
+ fail("Choose a reviewed native action.");
119
+ if (config.events.some(id => !AGENT_SESSION_EVENTS.some(event => event.id === id)))
120
+ fail("Choose an event that can match the session's selected resources.");
121
+ for (const values of Object.values(config.resources)) {
122
+ if (!Array.isArray(values) || values.some(ref => typeof ref !== "string" || !ref.trim() || ref.includes(",") || ref === "*"))
123
+ fail("Resources require exact native IDs or slugs.");
124
+ }
125
+ const template = AGENT_SESSION_TEMPLATES.find(t => t.id === config.template);
126
+ if (requireResources && config.events.some(id => {
127
+ const event = AGENT_SESSION_EVENTS.find(entry => entry.id === id);
128
+ return !Object.entries(AGENT_SESSION_EVENT_RESOURCES).some(([key, resource]) => event.payloadFields.includes(key) && config.resources[resource].length);
129
+ }))
130
+ fail("Select a matching resource for each event before activating.");
131
+ if (requireResources && template?.resources.some(key => !config.resources[key]?.length))
132
+ fail("Select the template's required resources before activating.");
133
+ }
@@ -0,0 +1,3 @@
1
+ /** Storage cutovers pause writes without disabling reads or observability. */
2
+ export declare function isAppStorageWritePaused(env?: Record<string, string | undefined>): boolean;
3
+ export declare function assertAppStorageWriteAllowed(env?: Record<string, string | undefined>): void;
@@ -0,0 +1,16 @@
1
+ import { OxygenError } from "./cli-result.js";
2
+ /** Storage cutovers pause writes without disabling reads or observability. */
3
+ export function isAppStorageWritePaused(env = process.env) {
4
+ return /^(1|true|yes|on)$/i.test(env.OXYGEN_APP_STORAGE_WRITE_PAUSED?.trim() ?? "");
5
+ }
6
+ export function assertAppStorageWriteAllowed(env = process.env) {
7
+ if (isAppStorageWritePaused(env)) {
8
+ throw new OxygenError("maintenance_in_progress", "File uploads are temporarily paused. Try again shortly.", {
9
+ exitCode: 1,
10
+ details: {
11
+ retry_after_seconds: 30,
12
+ next_step: "Retry after file maintenance finishes.",
13
+ },
14
+ });
15
+ }
16
+ }
@@ -382,7 +382,9 @@ export const OXYGEN_CAPABILITY_ROUTES = [
382
382
  // The sign-off terms are here because a 2026-09-29 blind user searched
383
383
  // "preview rendered message per sender sign-off variable" and landed on
384
384
  // collaboration; the {{sender_*}} copy variables live on sequence steps.
385
- intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "company replied", "pause company", "pause outreach to a company", "stop outreach to a company", "stop emailing a company", "colleagues", "do not contact a company", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign", "sign-off", "sign off", "signature", "sender name", "sender first name", "sender variable", "sender_first_name"],
385
+ // A/B tests are Sequence step variants: "pause a losing A/B variant" ranked
386
+ // Table-run pauses first (2026-10-10 parity audit).
387
+ intentTerms: ["a/b test", "a/b variant", "ab test", "split test", "auto-winner", "winning variant", "losing variant", "sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "company replied", "pause company", "pause outreach to a company", "stop outreach to a company", "stop emailing a company", "colleagues", "do not contact a company", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign", "sign-off", "sign off", "signature", "sender name", "sender first name", "sender variable", "sender_first_name"],
386
388
  },
387
389
  {
388
390
  id: "publishing",
@@ -421,13 +423,13 @@ export const OXYGEN_CAPABILITY_ROUTES = [
421
423
  primitive: "agents",
422
424
  owns: "Adaptive governed worker definitions, versions, authorization, context, triggers, threads, checkpoints, inference, and first-class runs.",
423
425
  notFor: "A known deterministic graph that belongs in Workflows or an opaque uploaded runtime.",
424
- execution: "Publish and run a hosted governed Agent with explicit tool authority and a separate bounded spend ceiling.",
426
+ execution: "Publish and run a governed Agent with explicit tool authority and bounded spend. Staff on dev can create ongoing sessions: draft, exact resource scope, per-run and rolling 24-hour caps, preview/activate, continuing conversation and bounded self-scheduling. Inspect agent session templates; pause/resume preserves history.",
425
427
  posture: "mixed",
426
428
  gatewayTools: ["oxygen_agent_list", "oxygen_agent_create", "oxygen_agent_run"],
427
429
  gatewayCommands: ["agent list", "agent create", "agent run"],
428
430
  skills: ["oxygen-gtm"],
429
431
  endpointSections: ["agent", "notetaker"],
430
- intentTerms: ["agent", "adaptive", "goal directed", "autonomous", "persistent thread", "checkpoint", "standing job", "notetaker"],
432
+ intentTerms: ["agent", "adaptive", "goal directed", "autonomous", "persistent thread", "checkpoint", "standing job", "notetaker", "ongoing worker", "ongoing session", "watch a campaign", "watch one campaign", "campaign monitor", "remember earlier reviews", "rolling daily", "long horizon", "long session"],
431
433
  },
432
434
  {
433
435
  id: "observability",
@@ -1076,6 +1078,12 @@ function routeCardFor(card, query) {
1076
1078
  ...card,
1077
1079
  execution: "Create a workspace-private Table or inspect its schema and rows. Manual rows, column labels, visibility and member favorites cost 0 credits. Change only the display label with columns update (oxygen_columns_update); columns rename (oxygen_columns_rename) requires a new stable key. Favorite one table or folder with tables favorite (oxygen_tables_favorite), then read tables favorites (oxygen_tables_favorites). For tidy guidance, read oxygen-table-tidy with skills get (oxygen_skills_get); tables tidy-suggest (oxygen_tables_tidy_suggest) analyzes without changes. Read the owning schema, apply the requested scope, then describe or query the Table to verify it.",
1078
1080
  };
1081
+ if (card.primitive === "tables" && isTableInventoryIntent(query))
1082
+ card = {
1083
+ ...card,
1084
+ posture: "read_only",
1085
+ execution: "List or count this workspace's Tables with tables list (oxygen_tables_list): `total` counts every Table the filters cover, while each page holds a window (MCP 50) with nextCursor for the rest. Archived Tables are excluded unless include_archived; narrow by project, tag or a name query. Free.",
1086
+ };
1079
1087
  const recordsCopy = card.primitive === "tables" ? recordsTableImportRecommendation(query) : null;
1080
1088
  if (recordsCopy)
1081
1089
  card = { ...card, posture: "workspace_write", execution: recordsCopy.execution };
@@ -1488,6 +1496,21 @@ function isWorkspaceSettingsIntent(query) {
1488
1496
  return /\b(workspace|organi[sz]ations?|orgs?|team)\s+settings?\b/.test(query)
1489
1497
  || /\bsettings?\s+(?:for|of)\s+(?:the |my |our |this )?(workspace|organi[sz]ation|org|team)\b/.test(query);
1490
1498
  }
1499
+ /**
1500
+ * "How many tables do I have?", "list my tables": the workspace's own table
1501
+ * inventory, which `tables list` answers with its `total`. Live on dev
1502
+ * (2026-10-10), "How many tables are in this workspace?" routed to
1503
+ * workspace-access on its "workspace" term, and the Tables card led with
1504
+ * creation and enrichment, so a Copilot turn searched three times before it
1505
+ * found the list.
1506
+ */
1507
+ function isTableInventoryIntent(query) {
1508
+ if (!/\btables\b/.test(query))
1509
+ return false;
1510
+ if (/\b(create|add|new|build|make|import\w*|enrich\w*|delete|remove|rename|archive|duplicate|copy|link|join|merge|rows?|columns?|cells?|largest|biggest|sizes?|favou?rites?|starred|webhooks?|feeds?|functions?|callables?|views?|crm|records?|agents?|workflows?|sequences?|limits?|plan|allow\w*|quota|capacity|teammates?)\b/.test(query))
1511
+ return false;
1512
+ return /\b(how many|count|number of|list|show|which|what)\b/.test(query);
1513
+ }
1491
1514
  function isTableCapacityReadIntent(query) {
1492
1515
  if (/\b(monitor|watch|workflow|automate|automation|increase|resize|upgrade)\b/.test(query))
1493
1516
  return false;
@@ -1844,7 +1867,8 @@ function isSpintaxIntent(query) {
1844
1867
  // unrelated physical variants keep their existing owners.
1845
1868
  if (/\bspintax\b/.test(query))
1846
1869
  return true;
1847
- if (/\b(posts?|publishing|ugc|visuals?|images?|videos?|scoreboard|stats|statistics|performance|winners?)\b/.test(query))
1870
+ // Rendering copy that exists is sequences render, not new variations.
1871
+ if (/\b(posts?|publishing|ugc|visuals?|images?|videos?|scoreboard|stats|statistics|performance|winners?|render|preview)\b/.test(query))
1848
1872
  return false;
1849
1873
  return /\b(wording|copy|message|draft|subject(?: line)?)\b.{0,45}\b(variations?|variants?|paraphrase|rewrite)\b/.test(query)
1850
1874
  || /\b(paraphrase|rewrite|variations?)\b.{0,45}\b(wording|copy|message|draft|subject(?: line)?)\b/.test(query);
@@ -1901,6 +1925,8 @@ function explicitCapabilityIntent(query) {
1901
1925
  return ROUTE_BY_ID.get("collaboration") ?? null;
1902
1926
  if (tableReadRecommendation(query) || tableMaintenanceRecommendation(query))
1903
1927
  return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
1928
+ if (isTableInventoryIntent(query))
1929
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
1904
1930
  if (/\btables?\b/.test(query) && /\b(navigation|nav|paging|pagination)\b/.test(query)) {
1905
1931
  return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
1906
1932
  }
@@ -2493,6 +2519,13 @@ function recommendationsFor(card, query) {
2493
2519
  const maintenance = tableMaintenanceRecommendation(query);
2494
2520
  if (maintenance)
2495
2521
  return maintenance;
2522
+ if (isTableInventoryIntent(query))
2523
+ return {
2524
+ tools: ["oxygen_tables_list", "oxygen_workspace_map"],
2525
+ commands: ["tables list", "workspace map"],
2526
+ exactCommand: "oxygen tables list --json",
2527
+ exactMcpCall: { tool: "oxygen_tables_list", arguments: {} },
2528
+ };
2496
2529
  const structure = tableStructureRecommendation(query);
2497
2530
  if (structure)
2498
2531
  return structure;
@@ -2694,6 +2727,9 @@ function recommendationsFor(card, query) {
2694
2727
  commands: ["sequences enrollments", "sequences stats", "sequences list"],
2695
2728
  };
2696
2729
  }
2730
+ const edit = sequenceEditRecommendation(query);
2731
+ if (edit)
2732
+ return edit;
2697
2733
  if (isNetNewLinkedInInitiation(query)) {
2698
2734
  const publicResearch = isPublicLinkedInRead(query);
2699
2735
  const harvestEngagers = publicResearch && isPublicLinkedInEngagerHarvest(query);
@@ -2900,6 +2936,43 @@ function isCompanyUrlRecoveryIntent(query) {
2900
2936
  function isInboxAvatarIntent(query) {
2901
2937
  return /\b(avatar|profile (?:picture|photo)|headshot|hosted (?:picture|image)|mailbox (?:picture|photo))\b/.test(query);
2902
2938
  }
2939
+ /**
2940
+ * Changing a Sequence that exists: its copy rendered, a copy of it, its A/B
2941
+ * decisions, or its senders, caps, steps and settings. The 2026-10-10 parity
2942
+ * audit searched "change the sender of my sequence" and "remove the daily send
2943
+ * cap" and got create/enroll/start/list, never oxygen_sequences_update, which
2944
+ * reads as "the API cannot modify the sender".
2945
+ */
2946
+ function sequenceEditRecommendation(query) {
2947
+ if (/\b(launch|go live)\b/.test(query))
2948
+ return null;
2949
+ if (/\b(render|preview|see|show|look)\b/.test(query) && /\b(copy|emails?|messages?|variants?|subjects?|body|bodies|notes?)\b/.test(query)) {
2950
+ return {
2951
+ tools: ["oxygen_sequences_render", "oxygen_sequences_variants", "oxygen_sequences_get"],
2952
+ commands: ["sequences render", "sequences variants", "sequences get"],
2953
+ };
2954
+ }
2955
+ if (/\b(duplicate|clone)\b/.test(query) || /\bcopy (?:of )?(?:a |the |my |this )?(?:running |live |started |active )?(?:sequence|campaign)\b/.test(query)) {
2956
+ return {
2957
+ tools: ["oxygen_sequences_duplicate", "oxygen_sequences_get", "oxygen_sequences_update"],
2958
+ commands: ["sequences duplicate", "sequences get", "sequences update"],
2959
+ };
2960
+ }
2961
+ if (/\b(variants?|a\/b|ab tests?|split tests?)\b/.test(query) && /\b(pause|activate|reactivate|reset|winner|winning|losing|loser|auto[- ]?optimi[sz]e|pick)\b/.test(query)) {
2962
+ return {
2963
+ tools: ["oxygen_sequences_variants_action", "oxygen_sequences_variants", "oxygen_sequences_update"],
2964
+ commands: ["sequences variants", "sequences update"],
2965
+ };
2966
+ }
2967
+ if (/\b(change|switch|swap|replace|remove|edit|update|set|clear|unset|turn (?:on|off)|raise|lower|increase|decrease|reduce)\b/.test(query)
2968
+ && /\b(senders?|sending accounts?|accounts?|inbox(?:es)?|mailbox(?:es)?|caps?|limits?|daily|steps?|copy|variants?|delays?|waits?|settings?|tracking)\b/.test(query)) {
2969
+ return {
2970
+ tools: ["oxygen_sequences_update", "oxygen_sequences_get", "oxygen_senders_list"],
2971
+ commands: ["sequences update", "sequences get", "senders list"],
2972
+ };
2973
+ }
2974
+ return null;
2975
+ }
2903
2976
  function isSenderProfileIntent(query) {
2904
2977
  return /\b(?:sender|sending|unified)\s+(?:profiles?|identit(?:y|ies))\b/.test(query)
2905
2978
  || (/\bprofiles?\b.{0,64}\b(?:linkedin|whatsapp|mailboxes?|inboxes?|email)\b/.test(query)
@@ -0,0 +1,72 @@
1
+ import type { OperationSpec } from "./operations.js";
2
+ export declare const AGENT_SESSION_CONFIG_CONTRACT: import("./schema.js").ContractObject<{
3
+ readonly title: import("./schema.js").StringProperty;
4
+ readonly goal: import("./schema.js").StringProperty;
5
+ readonly template: import("./schema.js").StringProperty;
6
+ readonly resources: import("./schema.js").ContractObject<{
7
+ readonly sequences: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
8
+ readonly tables: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
9
+ readonly conversations: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
10
+ readonly mailboxes: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
11
+ readonly records: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
12
+ readonly objects: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
13
+ readonly knowledge: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
14
+ readonly domains: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
15
+ }, "conversations" | "domains" | "knowledge" | "mailboxes" | "objects" | "records" | "sequences" | "tables">;
16
+ readonly actions: {
17
+ readonly type: "array";
18
+ readonly items: import("./schema.js").StringProperty;
19
+ readonly minItems?: number;
20
+ readonly maxItems?: number;
21
+ readonly description: "Optional standing write permissions from the template catalog. Empty means scoped reads only; template suggestions are never automatically granted.";
22
+ };
23
+ readonly events: import("./schema.js").ArrayProperty<import("./schema.js").StringProperty>;
24
+ readonly schedule: import("./schema.js").ContractObject<{
25
+ readonly interval_minutes: {
26
+ type: "integer";
27
+ description?: string;
28
+ minimum?: number;
29
+ maximum?: number;
30
+ "x-clamp"?: true;
31
+ };
32
+ readonly min_interval_minutes: {
33
+ type: "integer";
34
+ description?: string;
35
+ minimum?: number;
36
+ maximum?: number;
37
+ "x-clamp"?: true;
38
+ };
39
+ readonly max_interval_minutes: {
40
+ type: "integer";
41
+ description?: string;
42
+ minimum?: number;
43
+ maximum?: number;
44
+ "x-clamp"?: true;
45
+ };
46
+ }, "interval_minutes" | "max_interval_minutes" | "min_interval_minutes">;
47
+ readonly budget: import("./schema.js").ContractObject<{
48
+ readonly per_run_credits: {
49
+ type: "number";
50
+ description?: string;
51
+ minimum?: number;
52
+ maximum?: number;
53
+ "x-clamp"?: true;
54
+ };
55
+ readonly daily_credits: {
56
+ type: "number";
57
+ description?: string;
58
+ minimum?: number;
59
+ maximum?: number;
60
+ "x-clamp"?: true;
61
+ };
62
+ readonly lifetime_credits: {
63
+ type: "number";
64
+ description?: string;
65
+ minimum?: number;
66
+ maximum?: number;
67
+ "x-clamp"?: true;
68
+ };
69
+ }, "daily_credits" | "per_run_credits">;
70
+ readonly ends_at: import("./schema.js").StringProperty;
71
+ }, "actions" | "budget" | "events" | "goal" | "resources" | "schedule" | "template" | "title">;
72
+ export declare const AGENT_SESSION_OPERATIONS: readonly OperationSpec[];
@@ -0,0 +1,35 @@
1
+ import { arr, bool, int, num, obj, str } from "./schema.js";
2
+ const strings = () => arr(str({ minLength: 1 }), { maxItems: 100 });
3
+ export const AGENT_SESSION_CONFIG_CONTRACT = obj({
4
+ title: str({ minLength: 1, maxLength: 160 }), goal: str({ minLength: 1, maxLength: 16000 }),
5
+ template: str({ minLength: 1 }),
6
+ resources: obj({ sequences: strings(), tables: strings(), conversations: strings(), mailboxes: strings(), records: strings(), objects: strings(), knowledge: strings(), domains: strings() }, { required: ["sequences", "tables", "conversations", "mailboxes", "records", "objects", "knowledge", "domains"] }),
7
+ actions: { ...strings(), description: "Optional standing write permissions from the template catalog. Empty means scoped reads only; template suggestions are never automatically granted." }, events: strings(),
8
+ schedule: obj({ interval_minutes: int({ minimum: 5, description: "Default cadence in minutes; 1440 means one day." }), min_interval_minutes: int({ minimum: 5, description: "Earliest permitted interval when the Agent requests its next check." }), max_interval_minutes: int({ minimum: 5, maximum: 525600, description: "Latest permitted interval when the Agent requests its next check." }) }, { required: ["interval_minutes", "min_interval_minutes", "max_interval_minutes"] }),
9
+ budget: obj({ per_run_credits: num({ minimum: 0.01, maximum: 25000, description: "Credit cap for each check, including inference and paid tools. The host reserves this full cap before starting." }), daily_credits: num({ minimum: 0.01, description: "Rolling 24-hour credit cap. Settled costs count for 24 hours; unresolved costs remain held until settled." }), lifetime_credits: num({ minimum: 0.01, description: "Optional total credit cap across all checks in this session." }) }, { required: ["per_run_credits", "daily_credits"] }),
10
+ ends_at: str({ description: "Optional ISO end time." }),
11
+ }, { required: ["title", "goal", "template", "resources", "actions", "events", "schedule", "budget"] });
12
+ function agentSessionOperation(id, verb, method, suffix, summary, fields = {}, required = [], spends = false) {
13
+ const route = `/api/cli/agent/{slug}/sessions${suffix}`;
14
+ return {
15
+ id, route, method,
16
+ parameters: [...route.matchAll(/\{(\w+)\}/g)].map(match => match[1]),
17
+ contract: obj(fields, { required }), cli: { path: ["agent", "session", verb], summary },
18
+ mcp: { name: `oxygen_agent_session_${verb}`, title: `Oxygen: Agent session ${verb}`, description: summary, pack: "agents" },
19
+ mutates: method !== "GET", spends,
20
+ };
21
+ }
22
+ export const AGENT_SESSION_OPERATIONS = [
23
+ agentSessionOperation("agent.sessions.templates", "templates", "GET", "/templates", "Discover eight ongoing Agent job templates, reviewed actions, required resources, the full config schema and a draft example. Staff on dev only; legacy Agents remain available."),
24
+ agentSessionOperation("agent.sessions.list", "list", "GET", "", "List independent ongoing sessions under an Agent, including status, next check and spend."),
25
+ agentSessionOperation("agent.sessions.create", "create", "POST", "", "Create a draft ongoing session. Explicit caps are required; review and activate separately.", { config: AGENT_SESSION_CONFIG_CONTRACT }, ["config"]),
26
+ agentSessionOperation("agent.sessions.get", "get", "GET", "/{session}", "Read this session's configuration revision, conversation, memory, next check, approvals and budget reservations."),
27
+ agentSessionOperation("agent.sessions.configure", "configure", "PATCH", "/{session}", "Replace settings at the expected revision. Clears standing authority and pauses active work; review again before activation.", { revision: int({ minimum: 1 }), config: AGENT_SESSION_CONFIG_CONTRACT }, ["revision", "config"]),
28
+ agentSessionOperation("agent.sessions.preview", "preview", "POST", "/{session}/preview", "Preview the exact pinned Agent version, resource scope, actions, schedule and caps. Returns the revision and approval token; starts no inference."),
29
+ agentSessionOperation("agent.sessions.activate", "activate", "POST", "/{session}/activate", "Approve the exact preview revision and activate scoped standing authority. Starts bounded inference. A cap above default_max_credits requires separate approve_above_default=true. Later settings changes require a fresh preview.", { revision: int({ minimum: 1 }), preview_token: str({ minLength: 1 }), approved: bool(), approve_above_default: bool({ description: "Separately approve a per-run cap above the workspace's default Agent ceiling." }) }, ["revision", "preview_token", "approved"], true),
30
+ agentSessionOperation("agent.sessions.message", "message", "POST", "/{session}/messages", "Queue durable human steering. Draft chat proposes settings for review; active chat resumes the same memory. A bounded run reserves the approved per-run cap.", { message: str({ minLength: 1, maxLength: 16000 }), idempotency_key: str({ minLength: 1, maxLength: 200 }) }, ["message", "idempotency_key"], true),
31
+ agentSessionOperation("agent.sessions.pause", "pause", "POST", "/{session}/pause", "Pause this ongoing session, retaining conversation and run history."),
32
+ agentSessionOperation("agent.sessions.resume", "resume", "POST", "/{session}/resume", "Resume this ongoing session. Requires unchanged approved settings.", {}, [], true),
33
+ agentSessionOperation("agent.sessions.complete", "complete", "POST", "/{session}/complete", "Complete this ongoing session, retaining conversation and run history."),
34
+ agentSessionOperation("agent.sessions.archive", "archive", "POST", "/{session}/archive", "Archive this ongoing session, retaining conversation and run history."),
35
+ ];
@@ -122,4 +122,5 @@ export const BROWSER_OPERATIONS = [
122
122
  operation("notetaker.note.edit", "/api/cli/notetaker/sessions/{session}/note", "PATCH", "notetaker notes edit", "oxygen_notetaker_note_edit", "crm", "Save the markdown note for one meeting session and return the committed note.", { markdown: text() }, ["markdown"]),
123
123
  operation("orgs.create", "/api/cli/orgs/create", "POST", "orgs create", "oxygen_workspace_create", "workspace", "Create a workspace for the authenticated person's account and begin native company setup. Workspace API keys cannot create personal workspaces.", { name: text(), company_domain: text("Company website or domain, or its LinkedIn company page URL when it has no website."), linkedin_profile_url: text("Creator's LinkedIn profile URL, separate from the company's page.") }, ["name", "company_domain"]),
124
124
  operation("onboarding.complete", "/api/cli/onboarding/complete", "POST", "onboarding complete", "oxygen_onboarding_complete", "workspace", "Complete the active workspace's onboarding step and record its discovery-source choices. The other choice requires source_other.", { sources: arr(enumOf(["linkedin_content", "linkedin_dm", "linkedin_ads", "x", "youtube", "google", "email", "word_of_mouth", "referral", "event", "other"])), source_other: text(), redirect_url: text() }, ["sources"]),
125
+ operation("onboarding.start", "/api/cli/onboarding/start", "POST", "onboarding start", "oxygen_onboarding_start", "workspace", "Answer onboarding's choice card. own_agent continues to connecting your own agent. copilot opens the Workspace Copilot with the onboarding conversation already started, billed in credits like any Copilot turn; choosing again reopens that conversation.", { path: enumOf(["own_agent", "copilot"]), play: text("A play slug carried to the install step.") }, ["path"], { spends: true }),
125
126
  ];
@@ -12,6 +12,7 @@ import { BROWSER_OPERATIONS } from "./browser-operations.js";
12
12
  import { TABLE_FEED_UPDATE_OPERATION } from "./table-feed-update.js";
13
13
  import { GEO_OPERATIONS } from "./geo.js";
14
14
  import { CUSTOMER_OPERATIONS } from "./customer-operations.js";
15
+ import { AGENT_SESSION_OPERATIONS } from "./agent-sessions.js";
15
16
  /** The route with its `{param}` segments filled in, each URL-encoded. */
16
17
  export function operationPath(spec, params) {
17
18
  return spec.route.replace(/\{(\w+)\}/g, (_match, name) => {
@@ -81,6 +82,6 @@ export function operationFormData(spec, body) {
81
82
  }
82
83
  return form;
83
84
  }
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);
85
+ 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, ...AGENT_SESSION_OPERATIONS];
86
+ export const CLI_OPERATIONS = [...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS, ...AGENT_SESSION_OPERATIONS].filter((spec) => spec.generated?.cli !== false);
87
+ export const MCP_OPERATIONS = [...CUSTOMER_OPERATIONS, ...GEO_OPERATIONS, ...BROWSER_OPERATIONS, ...AGENT_SESSION_OPERATIONS].filter((spec) => spec.generated?.mcp !== false);
@@ -17,5 +17,8 @@ export declare const TABLE_QUERY_GRID_CONTRACT: import("./schema.js").ContractOb
17
17
  }, never>;
18
18
  export declare const TABLE_QUERY_GRID_NATIVE_WINDOW = 10000;
19
19
  export declare const TABLE_QUERY_GRID_BUILTIN_WINDOW = 4000;
20
+ export declare const TABLE_QUERY_GRID_COPILOT_WINDOW = 24000;
21
+ /** The window a built-in host reads tables in; other clients have none. */
22
+ export declare function tableQueryBuiltinWindow(surface: string | null | undefined): number | undefined;
20
23
  /** Measure the complete native receipt and the escaped built-in host receipt. */
21
24
  export declare function tableQueryGridResultFits(data: Record<string, unknown>, maxChars: number | undefined, surface: string | null): boolean;
@@ -12,16 +12,28 @@ export const TABLE_QUERY_GRID_NATIVE_WINDOW = 10_000;
12
12
  // Deliberately below Strands' shipped 6000 default; counts the complete host
13
13
  // object rather than only its text. This changes no adapter's outer ceiling.
14
14
  export const TABLE_QUERY_GRID_BUILTIN_WINDOW = 4_000;
15
+ // The Copilot hands its model the structured result alone, whole up to 32,000
16
+ // characters (ADR 0013, amendment 2026-10-10). One qualified row measured 4,537
17
+ // on dev, so the Agent window refused even a single row (eval, 2026-10-11).
18
+ export const TABLE_QUERY_GRID_COPILOT_WINDOW = 24_000;
19
+ /** The window a built-in host reads tables in; other clients have none. */
20
+ export function tableQueryBuiltinWindow(surface) {
21
+ if (surface === "copilot")
22
+ return TABLE_QUERY_GRID_COPILOT_WINDOW;
23
+ return surface === "agent" ? TABLE_QUERY_GRID_BUILTIN_WINDOW : undefined;
24
+ }
15
25
  /** Measure the complete native receipt and the escaped built-in host receipt. */
16
26
  export function tableQueryGridResultFits(data, maxChars, surface) {
17
27
  if (maxChars === undefined)
18
28
  return true;
19
29
  if (JSON.stringify(success("tables query", data)).length > maxChars)
20
30
  return false;
21
- if (surface === "agent" || surface === "copilot") {
22
- return JSON.stringify({
23
- ok: true, text: JSON.stringify(data), structured: { ...data, oxygen_version: OXYGEN_VERSION },
24
- }).length <= TABLE_QUERY_GRID_BUILTIN_WINDOW;
31
+ const structured = { ...data, oxygen_version: OXYGEN_VERSION };
32
+ if (surface === "agent") {
33
+ return JSON.stringify({ ok: true, text: JSON.stringify(data), structured }).length <= TABLE_QUERY_GRID_BUILTIN_WINDOW;
34
+ }
35
+ if (surface === "copilot") {
36
+ return JSON.stringify({ ok: true, result: structured }).length <= TABLE_QUERY_GRID_COPILOT_WINDOW;
25
37
  }
26
38
  return true;
27
39
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Who may use the Workspace Copilot on this deployment (founder, 2026-10-10):
3
+ * one flag, `OXYGEN_COPILOT_ENABLED`, read only on the two self-hosted
4
+ * deployments that serve customer workspaces.
5
+ *
6
+ * - `off`: `0`, `false`, unset or any unknown value, everywhere.
7
+ * - `staff`: `staff`, plus the legacy `1` / `true` on dev only. Those opened the
8
+ * staff-only dev restoration of 2026-10-09, so dev keeps its meaning.
9
+ * - `all`: `all`, every workspace member except restricted client logins.
10
+ *
11
+ * Production opens only on an explicit `staff` or `all`, never on a legacy
12
+ * value. Shadow carries production-shaped data and its config still holds
13
+ * `true`, so it never opens, and neither does local.
14
+ */
15
+ export type CopilotAudience = "off" | "staff" | "all";
16
+ type Env = Record<string, string | undefined>;
17
+ export declare function copilotAudience(env?: Env): CopilotAudience;
18
+ /**
19
+ * True for any value that turns the Copilot on for someone, on any deployment.
20
+ * Historical cost reconciliation keys off this so receipts still settle where
21
+ * the deployment itself cannot run new turns.
22
+ */
23
+ export declare function isCopilotFlagOn(env?: Env): boolean;
24
+ export type CopilotViewer = {
25
+ email: string | null | undefined;
26
+ /** The workspace membership's OXYGEN role; `client` logins never use the Copilot. */
27
+ oxygenRole?: string | null | undefined;
28
+ /** False when the person has no current membership in the workspace. */
29
+ member: boolean;
30
+ };
31
+ /** Whether this person may open and run the Copilot in this workspace. */
32
+ export declare function isCopilotAvailableTo(env: Env, viewer: CopilotViewer): boolean;
33
+ export {};
@@ -0,0 +1,33 @@
1
+ import { isStaffEmail } from "./staff.js";
2
+ export function copilotAudience(env = process.env) {
3
+ const deployment = env.OXYGEN_DEPLOY_ENV?.trim().toLowerCase();
4
+ if (deployment !== "selfhost-dev" && deployment !== "selfhost-prod")
5
+ return "off";
6
+ const flag = normalizedFlag(env);
7
+ if (flag === "all")
8
+ return "all";
9
+ if (flag === "staff")
10
+ return "staff";
11
+ if (deployment === "selfhost-dev" && (flag === "1" || flag === "true"))
12
+ return "staff";
13
+ return "off";
14
+ }
15
+ /**
16
+ * True for any value that turns the Copilot on for someone, on any deployment.
17
+ * Historical cost reconciliation keys off this so receipts still settle where
18
+ * the deployment itself cannot run new turns.
19
+ */
20
+ export function isCopilotFlagOn(env = process.env) {
21
+ const flag = normalizedFlag(env);
22
+ return flag === "1" || flag === "true" || flag === "staff" || flag === "all";
23
+ }
24
+ /** Whether this person may open and run the Copilot in this workspace. */
25
+ export function isCopilotAvailableTo(env, viewer) {
26
+ const audience = copilotAudience(env);
27
+ if (audience === "off" || !viewer.member || viewer.oxygenRole === "client")
28
+ return false;
29
+ return audience === "all" || isStaffEmail(viewer.email, env);
30
+ }
31
+ function normalizedFlag(env) {
32
+ return env.OXYGEN_COPILOT_ENABLED?.trim().toLowerCase() ?? "";
33
+ }