@oxygen-agent/cli 1.1014.1087 → 1.1014.1136

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 (32) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/columns.js +1 -1
  3. package/dist/commands/viewers.js +1 -2
  4. package/dist/workspace-placement-commands.js +12 -2
  5. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +6 -1
  6. package/node_modules/@oxygen/shared/dist/agent-sessions.js +17 -5
  7. package/node_modules/@oxygen/shared/dist/agents-enabled.d.ts +8 -9
  8. package/node_modules/@oxygen/shared/dist/agents-enabled.js +9 -8
  9. package/node_modules/@oxygen/shared/dist/capability-discovery.js +1 -1
  10. package/node_modules/@oxygen/shared/dist/column-autofill.js +2 -2
  11. package/node_modules/@oxygen/shared/dist/contracts/teammates.js +2 -1
  12. package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +32 -2
  13. package/node_modules/@oxygen/shared/dist/data-suppliers.js +44 -4
  14. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +3 -4
  15. package/node_modules/@oxygen/shared/dist/feature-gates.js +3 -4
  16. package/node_modules/@oxygen/shared/dist/hosted-ai.js +108 -118
  17. package/node_modules/@oxygen/shared/dist/knowledge-links.d.ts +29 -0
  18. package/node_modules/@oxygen/shared/dist/knowledge-links.js +84 -1
  19. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -14
  20. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +22 -5
  21. package/node_modules/@oxygen/shared/dist/managed-scraper-backend.d.ts +18 -0
  22. package/node_modules/@oxygen/shared/dist/managed-scraper-backend.js +17 -0
  23. package/node_modules/@oxygen/shared/dist/teammates.d.ts +7 -5
  24. package/node_modules/@oxygen/shared/dist/teammates.js +11 -20
  25. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +12 -2
  26. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  27. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  28. package/node_modules/@oxygen/shared/dist/workspace-placement-view.d.ts +2 -1
  29. package/node_modules/@oxygen/shared/dist/workspace-placement-view.js +5 -4
  30. package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +7 -0
  31. package/node_modules/@oxygen/shared/dist/workspace-placement.js +16 -3
  32. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.1014.1087
37
+ Version: 1.1014.1136
@@ -830,7 +830,7 @@ export function registerColumnsFamily(program, handleAsyncAction) {
830
830
  .addCommand(new Command("add")
831
831
  .description("Add a nullable column, a catalog template (--prompt-key) or a multi-column enrichment bundle (--preset) to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-tables skill (`oxygen skills get oxygen-tables --json`). For ICP fit, use Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt 'Assess {{company_name}}: {{description}}, {{employees}} employees' --grounding workspace` (0.075 credits/row). A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately. A plain field is --type: text, number, checkbox, date, json, markdown, image, or a choice column — select, multi_select or status, with --options \"Lead,Contacted,Won\". To link another table row to row, use `oxygen tables relate <table> --to <other-table> --live`; to read a value out of one, --lookup-table.")
832
832
  .argument("<table>", "Table id or slug.")
833
- .option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, Professional Network Data (Up2Data) only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs, followers only on rows Professional Network Data answers because Blitz publishes none; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, Professional Network Data (Up2Data) only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), `company_funding` (total raised, latest round, number of rounds and investors), `company_revenue` (an annual revenue estimate), `company_hiring` (the roles a company is hiring for, with a count) — each of these four is one paid lookup column that fills its own columns, priced per row in `tools search --for-table` — plus `company_lookalikes` (one managed lookup from --input company_domain=website; keeps similar companies separate from the seed; dry-run for price) — or `domain_check` (0.1 credits/row at list price for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
833
+ .option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, Professional Network Data only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs, followers only on rows Professional Network Data answers because Blitz publishes none; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, Professional Network Data only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), `company_funding` (total raised, latest round, number of rounds and investors), `company_revenue` (an annual revenue estimate), `company_hiring` (the roles a company is hiring for, with a count) — each of these four is one paid lookup column that fills its own columns, priced per row in `tools search --for-table` — plus `company_lookalikes` (one managed lookup from --input company_domain=website; keeps similar companies separate from the seed; dry-run for price) — or `domain_check` (0.1 credits/row at list price for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
834
834
  .option("--input <slot=column...>", "Bind a preset or template input to an exact column, e.g. --input url=pricing_url with --prompt-key, or --input domain=website --input linkedin_url=company_li with --preset. Repeatable. For a preset it is only needed when the automatic match is wrong or missing; for a template it names the column each declared input reads.", collectRepeatable, [])
835
835
  // No preset takes a field list any more (founder, 2026-09-24). Hidden,
836
836
  // not removed: an old script passing them gets the server's refusal,
@@ -1,6 +1,5 @@
1
1
  import { requestOxygen } from "../http-client.js";
2
2
  import { readOption } from "../util.js";
3
- import { MANAGED_DATA_SUPPLIERS } from "@oxygen/shared/data-suppliers";
4
3
  import { Command } from "commander";
5
4
  export function registerViewersFamily(program, handleAsyncAction) {
6
5
  program.addCommand(new Command("viewers")
@@ -54,7 +53,7 @@ export function registerViewersFamily(program, handleAsyncAction) {
54
53
  });
55
54
  }))
56
55
  .addCommand(new Command("enrichment")
57
- .description("Turn automatic full-profile enrichment of NEW profile viewers on or off for the viewers table (" + MANAGED_DATA_SUPPLIERS.scraper + " profile: about, current role and company, experience, education, location). This is STANDING credit spend, charged credits_per_person credits per enriched viewer, applied only to viewers first seen after `enrichment.enabled_at`, each delivery bounded by `enrichment.max_credits_per_delivery` (the plan default). Switching on adds the table's Person profile columns (a paid lookup plus its extractors); they stay, with their data, when switched off. Existing rows are never charged by it — enrich them on demand by running the Person profile column. `viewers status` shows `enrichment` (on, credits_per_person, enabled_at, max_credits_per_delivery, next_capture_at: when new viewers can next arrive); the runs appear on the table's Runs page. Requires exactly one of --on or --off.")
56
+ .description("Turn automatic full-profile enrichment of NEW profile viewers on or off for the viewers table (managed LinkedIn profile: about, current role and company, experience, education, location). This is STANDING credit spend, charged credits_per_person credits per enriched viewer, applied only to viewers first seen after `enrichment.enabled_at`, each delivery bounded by `enrichment.max_credits_per_delivery` (the plan default). Switching on adds the table's Person profile columns (a paid lookup plus its extractors); they stay, with their data, when switched off. Existing rows are never charged by it — enrich them on demand by running the Person profile column. `viewers status` shows `enrichment` (on, credits_per_person, enabled_at, max_credits_per_delivery, next_capture_at: when new viewers can next arrive); the runs appear on the table's Runs page. Requires exactly one of --on or --off.")
58
57
  .option("--on", "Switch standing enrichment on for new viewers.")
59
58
  .option("--off", "Switch standing enrichment off.")
60
59
  .option("--json", "Print a JSON envelope.")
@@ -4,7 +4,8 @@ import { readJsonFileValue } from "./command-support.js";
4
4
  import { requestOxygen } from "./http-client.js";
5
5
  export function registerWorkspacePlacementCommands(root, handle) {
6
6
  root.addCommand(new Command("workspace-test").description("Compare email copy placement in this workspace's connected Google/Microsoft receiving inboxes: Gmail Primary/Promotions and Microsoft Focused/Other. Native sends, 0 additional credits; preview and exact message approval required.")
7
- .addCommand(new Command("run").description("Preview a workspace-only copy test. Input JSON: sender_mailbox_ids, receiver_mailbox_ids, variants [{key,subject,body_text,body_html?}]. Approve the same input with returned request_id/plan_hash/rendered_at and max_live_sends. Up to five variants and 50 messages; never buys provider tests or resumes campaigns.")
7
+ .addCommand(new Command("run").description("Preview an internal or cross-workspace copy test. Input JSON: sender_mailbox_ids, receiver_mailbox_ids, variants [{key,subject,body_text,body_html?}]. Approve the same input with returned request_id/plan_hash/rendered_at and max_live_sends. Up to five variants and 50 messages; never buys provider tests or resumes campaigns.")
8
+ .option("--receiver-scope <scope>", "workspace or network; network privately selects participating receivers.").option("--receiver-providers <providers>", "Comma-separated google,microsoft for network tests.")
8
9
  .requiredOption("--input <file>", "JSON test input file.").option("--approved", "Execute the exact reviewed matrix.")
9
10
  .option("--rendered-at <timestamp>", "Rendering timestamp returned by preview; reuse when approving.").option("--tracking-opens", "Preview with open tracking on verified workspace domain.").option("--tracking-clicks", "Preview with click tracking on verified workspace domain.")
10
11
  .option("--request-id <id>", "Request UUID from preview.").option("--plan <hash>", "Exact preview plan hash.").option("--max-live-sends <n>", "Maximum approved diagnostic emails (1–50).")
@@ -18,9 +19,18 @@ export function registerWorkspacePlacementCommands(root, handle) {
18
19
  return requestOxygen("/api/cli/deliverability/workspace-tests", { method: "POST", body: { ...input, approved: options.approved === true,
19
20
  ...(options.approved ? { approved: true } : {}), ...(options.requestId ? { request_id: options.requestId } : {}),
20
21
  ...(options.plan ? { plan_hash: options.plan } : {}), ...(options.renderedAt ? { rendered_at: options.renderedAt } : {}), ...(options.trackingOpens ? { tracking_opens: true } : {}), ...(options.trackingClicks ? { tracking_clicks: true } : {}), ...(options.maxLiveSends ? { max_live_sends: Number(options.maxLiveSends) } : {}),
21
- ...(options.allowPausedProbe ? { allow_paused_probe: true } : {}),
22
+ ...(options.allowPausedProbe ? { allow_paused_probe: true } : {}), ...(options.receiverScope ? { receiver_scope: options.receiverScope } : {}), ...(options.receiverProviders ? { receiver_providers: String(options.receiverProviders).split(",") } : {}),
22
23
  ...(options.previewCursor ? { preview_cursor: options.previewCursor } : {}), ...(options.previewLimit !== undefined ? { preview_limit: Number(options.previewLimit) } : {}),
23
24
  ...(options.previewProbeId ? { preview_probe_id: options.previewProbeId } : {}), ...(options.previewPart ? { preview_part: options.previewPart } : {}), ...(options.previewOffset !== undefined ? { preview_offset: Number(options.previewOffset) } : {}), ...(options.previewCopyLimit !== undefined ? { preview_copy_limit: Number(options.previewCopyLimit) } : {}) } });
25
+ })))
26
+ .addCommand(new Command("network").description("Read or manage default-on receiving-pool participation. Administrators can opt out or accept the current disclosure. Does not send mail.")
27
+ .option("--enabled <boolean>", "true or false; omit to read.").option("--accept-disclosure", "Accept real-copy delivery to participating mailboxes and metadata processing.").option("--json", "Print JSON.")
28
+ .action(async (options) => handle("deliverability workspace-test network", options, () => {
29
+ if (options.enabled === undefined)
30
+ return requestOxygen("/api/cli/deliverability/workspace-tests?action=network");
31
+ if (options.enabled !== "true" && options.enabled !== "false")
32
+ throw new Error("--enabled must be true or false.");
33
+ return requestOxygen("/api/cli/deliverability/workspace-tests", { method: "POST", body: { operation: "participation", network_enabled: options.enabled === "true", accept_network_disclosure: options.acceptDisclosure === true } });
24
34
  })))
25
35
  .addCommand(new Command("copy").description("Read a Sequence email step and its copy variants for workspace placement testing. Resolve personalization before preview; no send or charge.").argument("<sequence-id>").option("--step-index <n>", "Zero-based email step index.", "0").option("--json", "Print JSON.")
26
36
  .action(async (id, options) => handle("deliverability workspace-test copy", options, () => requestOxygen(`/api/cli/deliverability/workspace-tests/copy?sequence_id=${encodeURIComponent(id)}&step_index=${encodeURIComponent(options.stepIndex)}`))))
@@ -40,7 +40,12 @@ export type AgentSessionTeammateRoutine = {
40
40
  };
41
41
  /** The routine marker of a session's settings, or null for a general session. */
42
42
  export declare function agentSessionTeammateRoutine(config: Pick<AgentSessionConfig, "teammate_routine">): AgentSessionTeammateRoutine | null;
43
- /** Staff only: on dev, and in production's staff workspaces (`agentsEnabledIn`; Tim, 2026-10-16). */
43
+ /**
44
+ * Who has Ongoing Agent sessions, and so Oxygent routines: "staff" for staff
45
+ * emails, "all" for every member who is not a client login (the customer
46
+ * launch, Tim, 2026-10-17), in a workspace Agents run in (`agentsEnabledIn`).
47
+ */
48
+ export declare function agentSessionsAudience(env?: Record<string, string | undefined>): "off" | "staff" | "all";
44
49
  export declare function agentSessionsEnabled(env?: Record<string, string | undefined>): boolean;
45
50
  /** Whether this workspace has Ongoing Agent sessions, and so Oxygent routines. */
46
51
  export declare function agentSessionsEnabledIn(organizationId: string | null | undefined, env?: Record<string, string | undefined>): boolean;
@@ -11,18 +11,30 @@ export function agentSessionTeammateRoutine(config) {
11
11
  const marker = config.teammate_routine;
12
12
  return marker && typeof marker.chat_id === "string" ? marker : null;
13
13
  }
14
- /** Staff only: on dev, and in production's staff workspaces (`agentsEnabledIn`; Tim, 2026-10-16). */
14
+ /**
15
+ * Who has Ongoing Agent sessions, and so Oxygent routines: "staff" for staff
16
+ * emails, "all" for every member who is not a client login (the customer
17
+ * launch, Tim, 2026-10-17), in a workspace Agents run in (`agentsEnabledIn`).
18
+ */
19
+ export function agentSessionsAudience(env = process.env) {
20
+ if (env.OXYGEN_DEPLOY_ENV !== "selfhost-dev" && env.OXYGEN_DEPLOY_ENV !== "selfhost-prod")
21
+ return "off";
22
+ const flag = env.OXYGEN_AGENT_SESSIONS_ENABLED;
23
+ if ((flag !== "staff" && flag !== "all") || !agentsEnabled(env))
24
+ return "off";
25
+ return flag;
26
+ }
15
27
  export function agentSessionsEnabled(env = process.env) {
16
- return (env.OXYGEN_DEPLOY_ENV === "selfhost-dev" || env.OXYGEN_DEPLOY_ENV === "selfhost-prod")
17
- && env.OXYGEN_AGENT_SESSIONS_ENABLED === "staff" && agentsEnabled(env);
28
+ return agentSessionsAudience(env) !== "off";
18
29
  }
19
30
  /** Whether this workspace has Ongoing Agent sessions, and so Oxygent routines. */
20
31
  export function agentSessionsEnabledIn(organizationId, env = process.env) {
21
32
  return agentSessionsEnabled(env) && agentsEnabledIn(organizationId, env);
22
33
  }
23
34
  export function agentSessionsAvailableTo(env, viewer) {
24
- return agentSessionsEnabledIn(viewer.organizationId, env) && viewer.member && viewer.oxygenRole !== "client"
25
- && (guidedActivationEnabled(env) || isStaffEmail(viewer.email, env));
35
+ if (!agentSessionsEnabledIn(viewer.organizationId, env) || !viewer.member || viewer.oxygenRole === "client")
36
+ return false;
37
+ return agentSessionsAudience(env) === "all" || guidedActivationEnabled(env) || isStaffEmail(viewer.email, env);
26
38
  }
27
39
  /** Reviewed native adapters. Unknown tools (including generic dispatch and agent management) fail closed. */
28
40
  export const AGENT_SESSION_ADAPTERS = {
@@ -3,11 +3,11 @@
3
3
  * execution, which carries tool authority and credit spend (feature-gates.ts),
4
4
  * and Oxygents and their routines need it too (ADR 0032).
5
5
  *
6
- * - "true" opens every workspace on dev and in tests.
7
- * - "staff" opens only the workspaces listed in OXYGEN_AGENTS_WORKSPACE_IDS.
8
- * - Production opens only listed workspaces that are also in
9
- * PRODUCTION_AGENT_WORKSPACE_IDS below, whichever value switches it on, so
10
- * Doppler can narrow production but never widen it.
6
+ * - "true" opens every workspace: on dev and in tests, and in production since
7
+ * the customer launch (Tim, 2026-10-17: "all customers real launch").
8
+ * - "staff" opens only the workspaces listed in OXYGEN_AGENTS_WORKSPACE_IDS; on
9
+ * production they must also be in PRODUCTION_AGENT_WORKSPACE_IDS below, so the
10
+ * staff stage can be narrowed but never widened by a Doppler edit.
11
11
  * - Shadow never opens, a deployment name nobody knows never opens every
12
12
  * workspace, and anything else is off, as is an empty list where one is needed.
13
13
  *
@@ -16,10 +16,9 @@
16
16
  */
17
17
  export type AgentsWorkspaceScope = "off" | "all" | ReadonlySet<string>;
18
18
  /**
19
- * The production workspaces Agents, Oxygents and their routines may run in:
20
- * staff workspaces only (Tim, 2026-10-16). Adding a customer's workspace opens
21
- * autonomous runs and their spend there, so it needs a founder decision
22
- * recorded in OXYGEN_PRODUCT.md and a reviewed change here.
19
+ * The production workspaces the "staff" stage may open: staff workspaces only
20
+ * (Tim, 2026-10-16). Production opens every workspace only on "true", the
21
+ * customer launch.
23
22
  */
24
23
  export declare const PRODUCTION_AGENT_WORKSPACE_IDS: ReadonlySet<string>;
25
24
  export declare function agentsWorkspaceScope(env?: Record<string, string | undefined>): AgentsWorkspaceScope;
@@ -1,9 +1,8 @@
1
1
  import { isProductionData, resolveDeployEnv } from "./deploy-env.js";
2
2
  /**
3
- * The production workspaces Agents, Oxygents and their routines may run in:
4
- * staff workspaces only (Tim, 2026-10-16). Adding a customer's workspace opens
5
- * autonomous runs and their spend there, so it needs a founder decision
6
- * recorded in OXYGEN_PRODUCT.md and a reviewed change here.
3
+ * The production workspaces the "staff" stage may open: staff workspaces only
4
+ * (Tim, 2026-10-16). Production opens every workspace only on "true", the
5
+ * customer launch.
7
6
  */
8
7
  export const PRODUCTION_AGENT_WORKSPACE_IDS = new Set([
9
8
  "4257983c-406e-4658-b899-d7ba3cc760a6", // OXYGEN
@@ -16,12 +15,13 @@ export function agentsWorkspaceScope(env = process.env) {
16
15
  const tier = deploymentTier(env);
17
16
  if (tier === "shadow")
18
17
  return "off";
19
- if (flag === "true" && tier === "other")
18
+ if (flag === "true" && (tier === "other" || tier === "prod"))
20
19
  return "all";
20
+ const reviewedOnly = tier === "prod" || tier === "production-data";
21
21
  const listed = (env.OXYGEN_AGENTS_WORKSPACE_IDS ?? "")
22
22
  .split(",")
23
23
  .map((id) => id.trim().toLowerCase())
24
- .filter((id) => id.length > 0 && (tier !== "prod" || PRODUCTION_AGENT_WORKSPACE_IDS.has(id)));
24
+ .filter((id) => id.length > 0 && (!reviewedOnly || PRODUCTION_AGENT_WORKSPACE_IDS.has(id)));
25
25
  return listed.length > 0 ? new Set(listed) : "off";
26
26
  }
27
27
  /** Whether Agents run in any workspace here: what a worker loop or family checks before it looks at one. */
@@ -42,8 +42,9 @@ function deploymentTier(env) {
42
42
  const { tier } = resolveDeployEnv(env);
43
43
  if (tier === "prod" || tier === "shadow")
44
44
  return tier;
45
- // Managed dev telemetry can still carry production data via NODE_ENV.
46
- return isProductionData(env) ? "prod" : "other";
45
+ // Managed dev telemetry can still carry production data via NODE_ENV: it
46
+ // keeps the reviewed staff intersection and never opens every workspace.
47
+ return isProductionData(env) ? "production-data" : "other";
47
48
  }
48
49
  catch {
49
50
  return "unknown";
@@ -423,7 +423,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
423
423
  primitive: "agents",
424
424
  owns: "Adaptive governed worker definitions, versions, authorization, context, triggers, threads, checkpoints, inference, and first-class runs.",
425
425
  notFor: "A known deterministic graph that belongs in Workflows or an opaque uploaded runtime.",
426
- execution: "Publish and run a governed Agent with explicit tool authority and bounded spend. Where admitted, staff 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. Where admitted, Oxygents (AI teammates) default to Workspace: one ongoing chat shared by everyone with access. Choose visibility personal when creating one for creator-only access; an API key acts as its issuing person. Home stays Workspace. Owners change visibility with confirm_visibility: publishing shares all existing history; Personal keeps history but removes others' future access and cannot retract what they saw. Collaborators use the shared chat; managers also change its profile and access. everyone off restricts Workspace access to its owner and named people, with workspace administrators retaining management access; it does not make it Personal. Group chats hold up to six Oxygents and optional teammates; messages go to the Oxygents mentioned or addressed.",
426
+ execution: "Publish and run a governed Agent with explicit tool authority and bounded spend. Members 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. Where admitted, Oxygents (AI teammates) default to Workspace: one ongoing chat shared by everyone with access. Choose visibility personal when creating one for creator-only access; an API key acts as its issuing person. Home stays Workspace. Owners change visibility with confirm_visibility: publishing shares all existing history; Personal keeps history but removes others' future access and cannot retract what they saw. Collaborators use the shared chat; managers also change its profile and access. everyone off restricts Workspace access to its owner and named people, with workspace administrators retaining management access; it does not make it Personal. Group chats hold up to six Oxygents and optional teammates; messages go to the Oxygents mentioned or addressed.",
427
427
  posture: "mixed",
428
428
  gatewayTools: ["oxygen_agent_list", "oxygen_agent_create", "oxygen_agent_run"],
429
429
  gatewayCommands: ["agent list", "agent create", "agent run"],
@@ -1,4 +1,4 @@
1
- import { PROFESSIONAL_NETWORK_DATA_LABEL } from "./data-suppliers.js";
1
+ import { professionalNetworkDataLabel } from "./data-suppliers.js";
2
2
  function readString(definition, key) {
3
3
  const value = definition?.[key];
4
4
  return typeof value === "string" && value.trim() !== "" ? value : null;
@@ -20,7 +20,7 @@ function enrichmentLabel(column) {
20
20
  // data-extraction labels say "Professional Network"). The label comes from
21
21
  // one owner so it cannot drift from the picker and the Connections card.
22
22
  if (toolId?.startsWith("scraper.linkedin"))
23
- return `Enriched from ${PROFESSIONAL_NETWORK_DATA_LABEL}`;
23
+ return `Enriched from ${professionalNetworkDataLabel()}`;
24
24
  if (toolId)
25
25
  return "Enriched by a data provider";
26
26
  return "Enriched automatically";
@@ -6,7 +6,8 @@ import { arr, bool, enumOf, int, obj, str } from "./schema.js";
6
6
  /**
7
7
  * AI teammates (ADR 0032), branded Oxygents: Agents with one ongoing chat,
8
8
  * shared by default or Personal, the workspace's first one ("New Bot" until named) and
9
- * any others added, none with a role (Tim, 2026-10-13). Staff on dev only.
9
+ * any others added, none with a role (Tim, 2026-10-13). Every member but a client
10
+ * login has them where the workspace does (launched 2026-10-17).
10
11
  * The people-facing names say Oxygent (`oxygen oxygents`, `oxygen_oxygents_*`,
11
12
  * Tim 2026-10-12); the HTTP family and the code keep "teammates".
12
13
  */
@@ -1,3 +1,4 @@
1
+ import { type ManagedScraperBackend } from "./managed-scraper-backend.js";
1
2
  /**
2
3
  * The companies that supply OXYGEN's managed data, keyed by OXYGEN provider id.
3
4
  *
@@ -12,7 +13,11 @@
12
13
  * import it.
13
14
  */
14
15
  export declare const MANAGED_DATA_SUPPLIERS: {
15
- /** The managed `scraper.*` LinkedIn rail. HarvestAPI is BYOK only and is not listed. */
16
+ /**
17
+ * The managed `scraper.*` LinkedIn rail on its default backend. The rail's
18
+ * supplier follows LINKEDIN_SCRAPER_PROVIDER: read it through
19
+ * managedDataSupplier("scraper"), never from this entry.
20
+ */
16
21
  readonly scraper: "Up2Data";
17
22
  /** Company-page follower audiences, fulfilled by the vendor. */
18
23
  readonly scrapeli: "ScrapeLi";
@@ -22,6 +27,17 @@ export declare const MANAGED_DATA_SUPPLIERS: {
22
27
  readonly crustdata: "Crustdata";
23
28
  };
24
29
  export type ManagedDataSupplierProvider = keyof typeof MANAGED_DATA_SUPPLIERS;
30
+ /**
31
+ * Every company that can supply the managed LinkedIn rail, by backend (Philipp,
32
+ * 2026-10-17). Both are subprocessors: HarvestAPI serves OXYGEN's key once the
33
+ * switch is flipped, and Up2Data keeps serving a workspace's own Up2Data key.
34
+ */
35
+ export declare const MANAGED_SCRAPER_SUPPLIERS: {
36
+ readonly up2data: "Up2Data";
37
+ readonly harvestapi: "HarvestAPI";
38
+ };
39
+ /** The managed LinkedIn rail's supplier on the backend this server selects. */
40
+ export declare function managedScraperSupplier(backend?: ManagedScraperBackend): string;
25
41
  /**
26
42
  * Customer-visible name of the managed LinkedIn data rail (`scraper.*`). Founder
27
43
  * decision 2026-09-25: data-extraction labels say "Professional Network", while
@@ -36,7 +52,21 @@ export declare function managedDataSupplier(provider: string | null | undefined)
36
52
  * · supplied by Bright Data").
37
53
  */
38
54
  export declare function suppliedByLabel(name: string, provider: string | null | undefined): string;
39
- /** "Professional Network Data · supplied by Up2Data" — the managed LinkedIn rail's label. */
55
+ /**
56
+ * "Professional Network Data · supplied by <supplier>" — the managed LinkedIn
57
+ * rail's label, naming the supplier the server's backend switch selects. A
58
+ * browser bundle cannot read that switch, so there the label names no supplier
59
+ * rather than a possibly wrong one.
60
+ */
61
+ export declare function professionalNetworkDataLabel(): string;
62
+ /**
63
+ * "Professional Network Data (<supplier>)" in running prose on a server; the
64
+ * bare name where the backend switch cannot be read (a browser bundle).
65
+ */
66
+ export declare function professionalNetworkDataSource(): string;
67
+ /** "Supplied by <supplier>." for the managed LinkedIn rail, or "" where the switch cannot be read. */
68
+ export declare function managedScraperSuppliedBySentence(): string;
69
+ /** The label resolved when this module loads (server processes read their switch at boot). */
40
70
  export declare const PROFESSIONAL_NETWORK_DATA_LABEL: string;
41
71
  /**
42
72
  * How a supplier obtains an operation's data. Informational only: it never gates,
@@ -1,3 +1,4 @@
1
+ import { readManagedScraperBackend, serverEnv } from "./managed-scraper-backend.js";
1
2
  /**
2
3
  * The companies that supply OXYGEN's managed data, keyed by OXYGEN provider id.
3
4
  *
@@ -12,7 +13,11 @@
12
13
  * import it.
13
14
  */
14
15
  export const MANAGED_DATA_SUPPLIERS = {
15
- /** The managed `scraper.*` LinkedIn rail. HarvestAPI is BYOK only and is not listed. */
16
+ /**
17
+ * The managed `scraper.*` LinkedIn rail on its default backend. The rail's
18
+ * supplier follows LINKEDIN_SCRAPER_PROVIDER: read it through
19
+ * managedDataSupplier("scraper"), never from this entry.
20
+ */
16
21
  scraper: "Up2Data",
17
22
  /** Company-page follower audiences, fulfilled by the vendor. */
18
23
  scrapeli: "ScrapeLi",
@@ -21,6 +26,19 @@ export const MANAGED_DATA_SUPPLIERS = {
21
26
  /** Indexed company and people data on OXYGEN's managed key. */
22
27
  crustdata: "Crustdata",
23
28
  };
29
+ /**
30
+ * Every company that can supply the managed LinkedIn rail, by backend (Philipp,
31
+ * 2026-10-17). Both are subprocessors: HarvestAPI serves OXYGEN's key once the
32
+ * switch is flipped, and Up2Data keeps serving a workspace's own Up2Data key.
33
+ */
34
+ export const MANAGED_SCRAPER_SUPPLIERS = {
35
+ up2data: "Up2Data",
36
+ harvestapi: "HarvestAPI",
37
+ };
38
+ /** The managed LinkedIn rail's supplier on the backend this server selects. */
39
+ export function managedScraperSupplier(backend = readManagedScraperBackend()) {
40
+ return MANAGED_SCRAPER_SUPPLIERS[backend];
41
+ }
24
42
  /**
25
43
  * Customer-visible name of the managed LinkedIn data rail (`scraper.*`). Founder
26
44
  * decision 2026-09-25: data-extraction labels say "Professional Network", while
@@ -31,6 +49,8 @@ export const PROFESSIONAL_NETWORK_DATA_NAME = "Professional Network Data";
31
49
  export function managedDataSupplier(provider) {
32
50
  if (!provider || !Object.hasOwn(MANAGED_DATA_SUPPLIERS, provider))
33
51
  return null;
52
+ if (provider === "scraper")
53
+ return managedScraperSupplier();
34
54
  return MANAGED_DATA_SUPPLIERS[provider];
35
55
  }
36
56
  /**
@@ -44,11 +64,31 @@ export function suppliedByLabel(name, provider) {
44
64
  return name;
45
65
  return `${name} · supplied by ${supplier}`;
46
66
  }
47
- /** "Professional Network Data · supplied by Up2Data" — the managed LinkedIn rail's label. */
48
- export const PROFESSIONAL_NETWORK_DATA_LABEL = suppliedByLabel(PROFESSIONAL_NETWORK_DATA_NAME, "scraper");
67
+ /**
68
+ * "Professional Network Data · supplied by <supplier>" — the managed LinkedIn
69
+ * rail's label, naming the supplier the server's backend switch selects. A
70
+ * browser bundle cannot read that switch, so there the label names no supplier
71
+ * rather than a possibly wrong one.
72
+ */
73
+ export function professionalNetworkDataLabel() {
74
+ return serverEnv() ? suppliedByLabel(PROFESSIONAL_NETWORK_DATA_NAME, "scraper") : PROFESSIONAL_NETWORK_DATA_NAME;
75
+ }
76
+ /**
77
+ * "Professional Network Data (<supplier>)" in running prose on a server; the
78
+ * bare name where the backend switch cannot be read (a browser bundle).
79
+ */
80
+ export function professionalNetworkDataSource() {
81
+ return serverEnv() ? `${PROFESSIONAL_NETWORK_DATA_NAME} (${managedScraperSupplier()})` : PROFESSIONAL_NETWORK_DATA_NAME;
82
+ }
83
+ /** "Supplied by <supplier>." for the managed LinkedIn rail, or "" where the switch cannot be read. */
84
+ export function managedScraperSuppliedBySentence() {
85
+ return serverEnv() ? `Supplied by ${managedScraperSupplier()}.` : "";
86
+ }
87
+ /** The label resolved when this module loads (server processes read their switch at boot). */
88
+ export const PROFESSIONAL_NETWORK_DATA_LABEL = professionalNetworkDataLabel();
49
89
  /** The disclosure an operation carries, in one sentence per method. */
50
90
  export function dataSourcingFor(provider, method) {
51
- const supplier = MANAGED_DATA_SUPPLIERS[provider];
91
+ const supplier = managedDataSupplier(provider) ?? MANAGED_DATA_SUPPLIERS[provider];
52
92
  return {
53
93
  supplier,
54
94
  method,
@@ -71,10 +71,9 @@ export type FeatureGateResolution = {
71
71
  * - OXYGEN_AGENTS_ENABLED — (b). Authorizes autonomous Agent run execution —
72
72
  * tool authority AND credit spend (`apps/web/src/lib/agent-runtime.ts`,
73
73
  * `apps/worker/src/agent-cycle.ts`). Same class as publishing.
74
- * - OXYGEN_AGENTS_WORKSPACE_IDS — (b). Which workspaces that execution may run
75
- * in (`packages/shared/src/agents-enabled.ts`); production also requires its
76
- * reviewed PRODUCTION_AGENT_WORKSPACE_IDS, so a wrong "on" for a customer's
77
- * workspace there needs a code change as well.
74
+ * - OXYGEN_AGENTS_WORKSPACE_IDS — (b). Which workspaces the "staff" stage of
75
+ * that execution may run in (`packages/shared/src/agents-enabled.ts`);
76
+ * production also requires its reviewed PRODUCTION_AGENT_WORKSPACE_IDS.
78
77
  * - OXYGEN_REPRICING_2026_09_EFFECTIVE_AT — (b). The instant every held price
79
78
  * rise and limit cut of the 2026-09 repricing takes effect
80
79
  * (`packages/shared/src/repricing.ts`). A remotely flippable value would let
@@ -62,10 +62,9 @@
62
62
  * - OXYGEN_AGENTS_ENABLED — (b). Authorizes autonomous Agent run execution —
63
63
  * tool authority AND credit spend (`apps/web/src/lib/agent-runtime.ts`,
64
64
  * `apps/worker/src/agent-cycle.ts`). Same class as publishing.
65
- * - OXYGEN_AGENTS_WORKSPACE_IDS — (b). Which workspaces that execution may run
66
- * in (`packages/shared/src/agents-enabled.ts`); production also requires its
67
- * reviewed PRODUCTION_AGENT_WORKSPACE_IDS, so a wrong "on" for a customer's
68
- * workspace there needs a code change as well.
65
+ * - OXYGEN_AGENTS_WORKSPACE_IDS — (b). Which workspaces the "staff" stage of
66
+ * that execution may run in (`packages/shared/src/agents-enabled.ts`);
67
+ * production also requires its reviewed PRODUCTION_AGENT_WORKSPACE_IDS.
69
68
  * - OXYGEN_REPRICING_2026_09_EFFECTIVE_AT — (b). The instant every held price
70
69
  * rise and limit cut of the 2026-09 repricing takes effect
71
70
  * (`packages/shared/src/repricing.ts`). A remotely flippable value would let
@@ -22,6 +22,101 @@
22
22
  * can re-source the catalog without dragging the barrel in.
23
23
  */
24
24
  import { copilotAudience, isCopilotFlagOn } from "./copilot-access.js";
25
+ /**
26
+ * The Copilot's tiers, which an Oxygent's own chat runs at. Hoisted out of the
27
+ * registry so the Agent use case can share the same spec objects.
28
+ */
29
+ const COPILOT_MODEL_SPECS = {
30
+ /**
31
+ * "Oxygen Auto" -- the earlier default, retained as an explicit choice.
32
+ * Its conversation thread stays fixed for its whole life. It is NOT a router: the
33
+ * model never changes mid-session, because an OpenRouter prompt cache is
34
+ * model-scoped and a mid-turn switch would turn a ~66% cache HIT rate into a
35
+ * cache WRITE on a fresh model, ten times a turn. Models only change below
36
+ * this thread, where the context is fresh -- see COPILOT_ERRAND_LEVEL.
37
+ *
38
+ * Before the 2026-10-10 Sol refresh, Auto ran Kimi K3. Its
39
+ * `reasoningEffort: "medium"` made that tier worth shipping.
40
+ * Until 2026-09-21 Kimi was sent with NO `reasoning` field at
41
+ * all, on a registry comment asserting K3 was max-reasoning-only. Measured
42
+ * against the live API on the same prompt and tool schema, that claim was
43
+ * false and the omission was the expensive case:
44
+ *
45
+ * omitted -> 412 reasoning tokens, $0.00779, 10.0s
46
+ * low -> 206, $0.00566, 9.2s
47
+ * medium -> 124, $0.00342, 4.8s <- 56% cheaper, 2.1x faster
48
+ * high -> 528, $0.01075, 14.8s
49
+ *
50
+ * All four returned the same answer. Reasoning was 38% of output tokens on a
51
+ * surface where output is ~61% of the bill.
52
+ */
53
+ auto: {
54
+ model: "openai/gpt-6.1-sol",
55
+ displayName: "Oxygen Auto",
56
+ // Approved 2026-10-10 for NEW sessions. Existing sessions retain their
57
+ // persisted resolved_model; Kimi's priced spec survives in the legacy map.
58
+ fallbackModels: ["anthropic/claude-sonnet-5.5"],
59
+ reasoningEffort: "medium",
60
+ estPromptUsdPerM: 2,
61
+ estCompletionUsdPerM: 10,
62
+ // Reasoning tokens count against this cap, so 8,192 left a long tool call
63
+ // (a full sequence draft, a wide table write) to be cut off mid-argument,
64
+ // which fails the turn on the second time. Credit holds are sized from the
65
+ // expected output (billing.ts), so this moves only the conservative clamp.
66
+ maxOutputTokens: 16_384,
67
+ },
68
+ low: {
69
+ // The errand model: sub-agents, compaction summaries and auto-titles. A
70
+ // child builds its own Agent with its own messages, so it starts cold on
71
+ // any model -- which is why a switch is free HERE and nowhere above it.
72
+ //
73
+ // This was deepseek/deepseek-v4.1-flash for about an hour, chosen for
74
+ // being newer and measured correct on a one-tool arithmetic probe. Probed
75
+ // again against the REAL runtime tool schemas from tools.ts
76
+ // (capability_search + capability_run, nested `args`, optional approval
77
+ // fields) under the same provider.require_parameters=true the transport
78
+ // always sends, it returned `finish_reason: "error"` with zero tool calls
79
+ // on 1 of 5 attempts, every one served by the Relace endpoint. v4-flash
80
+ // went 5/5 clean on StreamLake, is CHEAPER on both axes, and is already
81
+ // what auto-titles run on -- so the newer model was worse on reliability
82
+ // and on price, and the only thing recommending it was its version number.
83
+ //
84
+ // A child that emits no tool call returns nothing for what it spent, and
85
+ // the transport does not retry `finish_reason: "error"` on the primary
86
+ // (classifyModelError sends it straight to the next model), so a 20%
87
+ // error rate here is a 20% double round trip on every delegation.
88
+ model: "deepseek/deepseek-v4-flash",
89
+ displayName: "Oxygen Fast",
90
+ // v4.1-flash earns its place HERE: it works 4 times in 5 and it is a
91
+ // genuinely different endpoint on a different provider, which is the one
92
+ // thing a fallback has to be.
93
+ fallbackModels: ["deepseek/deepseek-v4.1-flash"],
94
+ reasoningEffort: "low",
95
+ estPromptUsdPerM: 0.09,
96
+ estCompletionUsdPerM: 0.18,
97
+ maxOutputTokens: 8192,
98
+ },
99
+ medium: {
100
+ model: "anthropic/claude-haiku-5.5",
101
+ displayName: "Oxygen Balanced",
102
+ fallbackModels: ["deepseek/deepseek-v4.1-flash"],
103
+ reasoningEffort: "medium",
104
+ estPromptUsdPerM: 0.1,
105
+ estCompletionUsdPerM: 0.5,
106
+ // A conversation tier (the default since #5225), so Auto's cap (see Auto).
107
+ maxOutputTokens: 16_384,
108
+ },
109
+ high: {
110
+ model: "openai/gpt-6.1-sol",
111
+ displayName: "Oxygen Max",
112
+ fallbackModels: ["anthropic/claude-sonnet-5.5"],
113
+ reasoningEffort: "medium",
114
+ estPromptUsdPerM: 2,
115
+ estCompletionUsdPerM: 10,
116
+ // A conversation tier, so Auto's cap (see Auto).
117
+ maxOutputTokens: 16_384,
118
+ },
119
+ };
25
120
  export const HOSTED_AI_MODEL_REGISTRY = {
26
121
  /**
27
122
  * Historical calibration of the September AI-column tiers, re-picked
@@ -112,124 +207,20 @@ export const HOSTED_AI_MODEL_REGISTRY = {
112
207
  maxOutputTokens: 4096,
113
208
  },
114
209
  },
115
- copilot: {
116
- /**
117
- * "Oxygen Auto" -- the earlier default, retained as an explicit choice.
118
- * Its conversation thread stays fixed for its whole life. It is NOT a router: the
119
- * model never changes mid-session, because an OpenRouter prompt cache is
120
- * model-scoped and a mid-turn switch would turn a ~66% cache HIT rate into a
121
- * cache WRITE on a fresh model, ten times a turn. Models only change below
122
- * this thread, where the context is fresh -- see COPILOT_ERRAND_LEVEL.
123
- *
124
- * Before the 2026-10-10 Sol refresh, Auto ran Kimi K3. Its
125
- * `reasoningEffort: "medium"` made that tier worth shipping.
126
- * Until 2026-09-21 Kimi was sent with NO `reasoning` field at
127
- * all, on a registry comment asserting K3 was max-reasoning-only. Measured
128
- * against the live API on the same prompt and tool schema, that claim was
129
- * false and the omission was the expensive case:
130
- *
131
- * omitted -> 412 reasoning tokens, $0.00779, 10.0s
132
- * low -> 206, $0.00566, 9.2s
133
- * medium -> 124, $0.00342, 4.8s <- 56% cheaper, 2.1x faster
134
- * high -> 528, $0.01075, 14.8s
135
- *
136
- * All four returned the same answer. Reasoning was 38% of output tokens on a
137
- * surface where output is ~61% of the bill.
138
- */
139
- auto: {
140
- model: "openai/gpt-6.1-sol",
141
- displayName: "Oxygen Auto",
142
- // Approved 2026-10-10 for NEW sessions. Existing sessions retain their
143
- // persisted resolved_model; Kimi's priced spec survives in the legacy map.
144
- fallbackModels: ["anthropic/claude-sonnet-5.5"],
145
- reasoningEffort: "medium",
146
- estPromptUsdPerM: 2,
147
- estCompletionUsdPerM: 10,
148
- // Reasoning tokens count against this cap, so 8,192 left a long tool call
149
- // (a full sequence draft, a wide table write) to be cut off mid-argument,
150
- // which fails the turn on the second time. Credit holds are sized from the
151
- // expected output (billing.ts), so this moves only the conservative clamp.
152
- maxOutputTokens: 16_384,
153
- },
154
- low: {
155
- // The errand model: sub-agents, compaction summaries and auto-titles. A
156
- // child builds its own Agent with its own messages, so it starts cold on
157
- // any model -- which is why a switch is free HERE and nowhere above it.
158
- //
159
- // This was deepseek/deepseek-v4.1-flash for about an hour, chosen for
160
- // being newer and measured correct on a one-tool arithmetic probe. Probed
161
- // again against the REAL runtime tool schemas from tools.ts
162
- // (capability_search + capability_run, nested `args`, optional approval
163
- // fields) under the same provider.require_parameters=true the transport
164
- // always sends, it returned `finish_reason: "error"` with zero tool calls
165
- // on 1 of 5 attempts, every one served by the Relace endpoint. v4-flash
166
- // went 5/5 clean on StreamLake, is CHEAPER on both axes, and is already
167
- // what auto-titles run on -- so the newer model was worse on reliability
168
- // and on price, and the only thing recommending it was its version number.
169
- //
170
- // A child that emits no tool call returns nothing for what it spent, and
171
- // the transport does not retry `finish_reason: "error"` on the primary
172
- // (classifyModelError sends it straight to the next model), so a 20%
173
- // error rate here is a 20% double round trip on every delegation.
174
- model: "deepseek/deepseek-v4-flash",
175
- displayName: "Oxygen Fast",
176
- // v4.1-flash earns its place HERE: it works 4 times in 5 and it is a
177
- // genuinely different endpoint on a different provider, which is the one
178
- // thing a fallback has to be.
179
- fallbackModels: ["deepseek/deepseek-v4.1-flash"],
180
- reasoningEffort: "low",
181
- estPromptUsdPerM: 0.09,
182
- estCompletionUsdPerM: 0.18,
183
- maxOutputTokens: 8192,
184
- },
185
- medium: {
186
- model: "anthropic/claude-haiku-5.5",
187
- displayName: "Oxygen Balanced",
188
- fallbackModels: ["deepseek/deepseek-v4.1-flash"],
189
- reasoningEffort: "medium",
190
- estPromptUsdPerM: 0.1,
191
- estCompletionUsdPerM: 0.5,
192
- // A conversation tier (the default since #5225), so Auto's cap (see Auto).
193
- maxOutputTokens: 16_384,
194
- },
195
- high: {
196
- model: "openai/gpt-6.1-sol",
197
- displayName: "Oxygen Max",
198
- fallbackModels: ["anthropic/claude-sonnet-5.5"],
199
- reasoningEffort: "medium",
200
- estPromptUsdPerM: 2,
201
- estCompletionUsdPerM: 10,
202
- // A conversation tier, so Auto's cap (see Auto).
203
- maxOutputTokens: 16_384,
204
- },
205
- },
210
+ copilot: COPILOT_MODEL_SPECS,
211
+ /**
212
+ * An Oxygent's background tasks and routines run on the very models its chat
213
+ * does, so one tier means one model wherever the Oxygent works (Philipp,
214
+ * 2026-10-17). Until then Agents kept their September tiers (DeepSeek V4
215
+ * Flash / V4 Pro / Kimi K2.6), which the 2026-10-10 refresh left out of scope.
216
+ * Shared by reference, so a Copilot tier refresh moves Agents with it;
217
+ * `OXYGEN_AGENT_MODEL_*` still overrides Agents alone. Retired ids keep
218
+ * their tier label through LEGACY_MANAGED_AI_COLUMN_MODELS.
219
+ */
206
220
  agent: {
207
- low: {
208
- model: "deepseek/deepseek-v4-flash",
209
- displayName: "Oxygen Fast",
210
- fallbackModels: [],
211
- reasoningEffort: "medium",
212
- estPromptUsdPerM: 0.09,
213
- estCompletionUsdPerM: 0.18,
214
- maxOutputTokens: 8192,
215
- },
216
- medium: {
217
- model: "deepseek/deepseek-v4-pro",
218
- displayName: "Oxygen Balanced",
219
- fallbackModels: ["deepseek/deepseek-v4-flash"],
220
- reasoningEffort: "high",
221
- estPromptUsdPerM: 0.435,
222
- estCompletionUsdPerM: 0.87,
223
- maxOutputTokens: 8192,
224
- },
225
- high: {
226
- model: "moonshotai/kimi-k2.6",
227
- displayName: "Oxygen Max",
228
- fallbackModels: ["deepseek/deepseek-v4-pro"],
229
- estPromptUsdPerM: 0.66,
230
- estCompletionUsdPerM: 3.41,
231
- maxOutputTokens: 8192,
232
- },
221
+ low: COPILOT_MODEL_SPECS.low,
222
+ medium: COPILOT_MODEL_SPECS.medium,
223
+ high: COPILOT_MODEL_SPECS.high,
233
224
  },
234
225
  };
235
226
  /** Persisted conversations keep their original pricing, effort and fallbacks. */
@@ -449,7 +440,6 @@ export const IMAGE_INPUT_MODELS = new Set([
449
440
  "anthropic/claude-sonnet-5.5",
450
441
  "anthropic/claude-haiku-5.5",
451
442
  "moonshotai/kimi-k3",
452
- "moonshotai/kimi-k2.6",
453
443
  "deepseek/deepseek-v4.1-flash",
454
444
  "google/gemini-3.8-flash",
455
445
  ]);
@@ -14,3 +14,32 @@ export type KnowledgeWikilink = {
14
14
  * empty key (e.g. [[---]]) are dropped.
15
15
  */
16
16
  export declare function extractKnowledgeLinks(body: string | null | undefined): KnowledgeWikilink[];
17
+ /**
18
+ * The exact vault paths, in priority order, and the basename that
19
+ * resolveKnowledgeVaultLink can match for a [[wikilink]] target written in the
20
+ * file at `currentPath`. A store fetches only these files and resolves against
21
+ * them with the same result as against the whole vault. Null when the target is
22
+ * empty or absolute.
23
+ */
24
+ export declare function knowledgeVaultLinkCandidates(target: string, currentPath: string): {
25
+ paths: string[];
26
+ basename: string | null;
27
+ } | null;
28
+ /**
29
+ * Resolve a [[wikilink]] target in a repository vault the way Obsidian does: a
30
+ * bare name prefers the same folder, then the vault-relative path, a path target
31
+ * is also tried relative to the current folder, and a bare name finally matches
32
+ * a basename that is unique in the vault. A target without an extension names a
33
+ * Markdown note. The web file view and the page-link graph both resolve through
34
+ * here, so a link that opens a note is the edge the graph shows.
35
+ */
36
+ export declare function resolveKnowledgeVaultLink<T extends {
37
+ path: string;
38
+ }>(target: string, currentPath: string, files: readonly T[]): T | null;
39
+ /**
40
+ * Every link key a [[wikilink]] that resolves to the vault file at `path` can
41
+ * carry: each trailing path suffix, and for a note also without its .md
42
+ * extension. A new or moved file can satisfy only dangling links with one of
43
+ * these keys.
44
+ */
45
+ export declare function knowledgeVaultLinkKeys(path: string): string[];
@@ -4,7 +4,8 @@
4
4
  // here and nowhere else. The regex + alias/heading stripping mirror the vault
5
5
  // component's original parser (apps/web/src/components/vault/vault.ts), which
6
6
  // now delegates here — [[Target|alias]] and [[Target#heading]] both link to
7
- // Target.
7
+ // Target. Links inside a connected repository resolve by vault path first
8
+ // (resolveKnowledgeVaultLink), for the mounted file view and the graph alike.
8
9
  import { knowledgeLinkKey } from "./knowledge-constants.js";
9
10
  const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
10
11
  /**
@@ -36,3 +37,85 @@ export function extractKnowledgeLinks(body) {
36
37
  }
37
38
  return [...byKey.values()];
38
39
  }
40
+ function normalizedVaultPath(candidate) {
41
+ const normalized = [];
42
+ for (const segment of candidate.split("/")) {
43
+ if (!segment || segment === ".")
44
+ continue;
45
+ if (segment === "..") {
46
+ if (!normalized.pop())
47
+ return null;
48
+ }
49
+ else {
50
+ normalized.push(segment);
51
+ }
52
+ }
53
+ return normalized.join("/") || null;
54
+ }
55
+ /**
56
+ * The exact vault paths, in priority order, and the basename that
57
+ * resolveKnowledgeVaultLink can match for a [[wikilink]] target written in the
58
+ * file at `currentPath`. A store fetches only these files and resolves against
59
+ * them with the same result as against the whole vault. Null when the target is
60
+ * empty or absolute.
61
+ */
62
+ export function knowledgeVaultLinkCandidates(target, currentPath) {
63
+ const pathTarget = target.split("#", 1)[0]?.trim();
64
+ if (!pathTarget || pathTarget.startsWith("/"))
65
+ return null;
66
+ const withExtension = /\.[^/]+$/.test(pathTarget) ? pathTarget : `${pathTarget}.md`;
67
+ const directory = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
68
+ const paths = [];
69
+ if (!pathTarget.includes("/"))
70
+ paths.push(`${directory ? `${directory}/` : ""}${withExtension}`);
71
+ const vaultPath = normalizedVaultPath(withExtension);
72
+ if (vaultPath)
73
+ paths.push(vaultPath);
74
+ if (pathTarget.includes("/")) {
75
+ const relativePath = normalizedVaultPath(`${directory}/${withExtension}`);
76
+ if (relativePath)
77
+ paths.push(relativePath);
78
+ }
79
+ return { paths, basename: pathTarget.includes("/") ? null : withExtension };
80
+ }
81
+ /**
82
+ * Resolve a [[wikilink]] target in a repository vault the way Obsidian does: a
83
+ * bare name prefers the same folder, then the vault-relative path, a path target
84
+ * is also tried relative to the current folder, and a bare name finally matches
85
+ * a basename that is unique in the vault. A target without an extension names a
86
+ * Markdown note. The web file view and the page-link graph both resolve through
87
+ * here, so a link that opens a note is the edge the graph shows.
88
+ */
89
+ export function resolveKnowledgeVaultLink(target, currentPath, files) {
90
+ const candidates = knowledgeVaultLinkCandidates(target, currentPath);
91
+ if (!candidates)
92
+ return null;
93
+ for (const path of candidates.paths) {
94
+ const file = files.find((candidate) => candidate.path === path);
95
+ if (file)
96
+ return file;
97
+ }
98
+ if (!candidates.basename)
99
+ return null;
100
+ const matches = files.filter((file) => file.path.split("/").at(-1) === candidates.basename);
101
+ return matches.length === 1 ? matches[0] : null;
102
+ }
103
+ /**
104
+ * Every link key a [[wikilink]] that resolves to the vault file at `path` can
105
+ * carry: each trailing path suffix, and for a note also without its .md
106
+ * extension. A new or moved file can satisfy only dangling links with one of
107
+ * these keys.
108
+ */
109
+ export function knowledgeVaultLinkKeys(path) {
110
+ const segments = path.split("/");
111
+ const keys = new Set();
112
+ for (let start = segments.length - 1; start >= 0; start -= 1) {
113
+ const suffix = segments.slice(start).join("/");
114
+ for (const form of /\.md$/.test(suffix) ? [suffix, suffix.slice(0, -3)] : [suffix]) {
115
+ const key = knowledgeLinkKey(form);
116
+ if (key)
117
+ keys.add(key);
118
+ }
119
+ }
120
+ return [...keys];
121
+ }
@@ -202,17 +202,6 @@ export type LinkedInRefusalScope =
202
202
  | "already_engaged"
203
203
  /** Unrecognised. Callers MUST preserve their existing behaviour. */
204
204
  | "unknown";
205
- /**
206
- * Classify a LinkedIn refusal from the provider's own account of it.
207
- *
208
- * Deliberately a CLOSED set of anchored, full-string matches, in the same
209
- * discipline as the sequence-recovery allowlists: matching vendor prose is
210
- * fragile, so an unrecognised message must degrade to `unknown` and leave the
211
- * caller's behaviour exactly as it was. Extending the set is an evidence-driven
212
- * act — callers log what they could not classify.
213
- *
214
- * Pure and total; never throws.
215
- */
216
205
  export declare function classifyLinkedInRefusal(input: {
217
206
  providerType: string | null;
218
207
  providerMessage: string | null;
@@ -394,9 +383,11 @@ export declare function linkedInLimitCautionNotices(metadata: Record<string, unk
394
383
  export declare const LINKEDIN_REINVITE_COOLDOWN_DAYS = 21;
395
384
  /**
396
385
  * Whether a refused invitation is LinkedIn's re-invite cooldown: Unipile answers
397
- * HTTP 422 with the `cannot_resend_yet` error type. Anchored on the type, with
398
- * the message as a fallback for the provider shapes that carry the type only in
399
- * the sentence. Pure and total.
386
+ * HTTP 422 with the `cannot_resend_yet` error type, or with
387
+ * `provider/unprocessable_entity` and LinkedIn's "already handled a request from
388
+ * you in the past 3 weeks" sentence. Anchored on the type, with the message as a
389
+ * fallback for the provider shapes that carry the type only in the sentence.
390
+ * Pure and total.
400
391
  */
401
392
  export declare function isLinkedInReinviteCooldownRefusal(input: {
402
393
  status: number | null;
@@ -113,6 +113,14 @@ export const LINKEDIN_INVITE_NOTE_LIMIT_BASIC = 200;
113
113
  *
114
114
  * Pure and total; never throws.
115
115
  */
116
+ /**
117
+ * LinkedIn's sentence when an invitation is refused because the member handled
118
+ * an earlier one from this account (accepted, ignored or withdrawn) within three
119
+ * weeks. Unipile returns it as HTTP 422 `provider/unprocessable_entity`, the v2
120
+ * client prefixes the title. Verified on dev 2026-10-17 (Unipile req-3i5d,
121
+ * req-7edn, req-7nsa): no invitation was created.
122
+ */
123
+ const LINKEDIN_RECENTLY_HANDLED_INVITE_MESSAGE = /^(?:Unprocessable entity: )?This user has already handled a request from you in the past 3 weeks\./u;
116
124
  export function classifyLinkedInRefusal(input) {
117
125
  const message = typeof input.providerMessage === "string" ? input.providerMessage.trim() : "";
118
126
  if (message.length === 0)
@@ -146,7 +154,7 @@ export function classifyLinkedInRefusal(input) {
146
154
  }
147
155
  if (input.status === 422
148
156
  && input.providerType === "provider/unprocessable_entity"
149
- && /^Unprocessable entity: This user has already handled a request from you in the past 3 weeks\./u.test(message)) {
157
+ && LINKEDIN_RECENTLY_HANDLED_INVITE_MESSAGE.test(message)) {
150
158
  return "already_engaged";
151
159
  }
152
160
  if (input.status === 409
@@ -440,14 +448,23 @@ export function linkedInLimitCautionNotices(metadata, overrides) {
440
448
  export const LINKEDIN_REINVITE_COOLDOWN_DAYS = 21;
441
449
  /**
442
450
  * Whether a refused invitation is LinkedIn's re-invite cooldown: Unipile answers
443
- * HTTP 422 with the `cannot_resend_yet` error type. Anchored on the type, with
444
- * the message as a fallback for the provider shapes that carry the type only in
445
- * the sentence. Pure and total.
451
+ * HTTP 422 with the `cannot_resend_yet` error type, or with
452
+ * `provider/unprocessable_entity` and LinkedIn's "already handled a request from
453
+ * you in the past 3 weeks" sentence. Anchored on the type, with the message as a
454
+ * fallback for the provider shapes that carry the type only in the sentence.
455
+ * Pure and total.
446
456
  */
447
457
  export function isLinkedInReinviteCooldownRefusal(input) {
448
458
  if (input.status !== 422)
449
459
  return false;
450
460
  if (typeof input.providerType === "string" && /(?:^|\/)cannot_resend_yet$/u.test(input.providerType.trim()))
451
461
  return true;
452
- return typeof input.providerMessage === "string" && /\bcannot_resend_yet\b/u.test(input.providerMessage);
462
+ if (typeof input.providerMessage !== "string")
463
+ return false;
464
+ if (/\bcannot_resend_yet\b/u.test(input.providerMessage))
465
+ return true;
466
+ // The same refusal in LinkedIn's own words. Without it, the BullMQ runtime
467
+ // read the write as possibly delivered and parked the lead for reconciliation.
468
+ return input.providerType === "provider/unprocessable_entity"
469
+ && LINKEDIN_RECENTLY_HANDLED_INVITE_MESSAGE.test(input.providerMessage.trim());
453
470
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Which vendor serves OXYGEN's managed `scraper.*` LinkedIn rail
3
+ * (LINKEDIN_SCRAPER_PROVIDER, Philipp 2026-10-17: a pure provider exchange from
4
+ * Up2Data to HarvestAPI behind a Doppler switch). Up2Data is the default, so an
5
+ * unset or unrecognised value never moves paid traffic.
6
+ *
7
+ * Dependency-free and browser-safe: a bundle without the server environment
8
+ * reads the default, so a label rendered in the browser must not rely on it.
9
+ */
10
+ export type ManagedScraperBackend = "up2data" | "harvestapi";
11
+ export declare const MANAGED_SCRAPER_BACKEND_ENV = "LINKEDIN_SCRAPER_PROVIDER";
12
+ type Env = Readonly<Record<string, string | undefined>>;
13
+ /** The server environment, or null in a bundle that has none. */
14
+ export declare function serverEnv(): Env | null;
15
+ /** The configured backend, or null when the value names neither vendor. */
16
+ export declare function parseManagedScraperBackend(value: string | undefined): ManagedScraperBackend | null;
17
+ export declare function readManagedScraperBackend(env?: Env | null): ManagedScraperBackend;
18
+ export {};
@@ -0,0 +1,17 @@
1
+ export const MANAGED_SCRAPER_BACKEND_ENV = "LINKEDIN_SCRAPER_PROVIDER";
2
+ /** The server environment, or null in a bundle that has none. */
3
+ export function serverEnv() {
4
+ const env = typeof process !== "undefined" ? process.env : undefined;
5
+ // `window` is absent from this package's types (no DOM lib), so read it off globalThis.
6
+ return env && !("window" in globalThis) ? env : null;
7
+ }
8
+ /** The configured backend, or null when the value names neither vendor. */
9
+ export function parseManagedScraperBackend(value) {
10
+ const normalized = value?.trim().toLowerCase();
11
+ if (!normalized || normalized === "up2data")
12
+ return "up2data";
13
+ return normalized === "harvestapi" ? "harvestapi" : null;
14
+ }
15
+ export function readManagedScraperBackend(env = serverEnv()) {
16
+ return parseManagedScraperBackend(env?.[MANAGED_SCRAPER_BACKEND_ENV]) ?? "up2data";
17
+ }
@@ -3,12 +3,14 @@
3
3
  * per Workspace Oxygent, on the Copilot runtime. Explicit Personal Oxygents
4
4
  * are creator-only; the first one stays Workspace (Philipp, 2026-10-16).
5
5
  *
6
- * Staff only: on dev, and in production's staff workspaces, the ones Agents run
7
- * in (`agentsEnabledIn`; Tim, 2026-10-16). `OXYGEN_TEAMMATES_ENABLED` is the
8
- * switch; when it is unset the feature follows the Ongoing Agent sessions staff
9
- * gate, because teammates re-scope those sessions into routines. Shadow never
10
- * qualifies.
6
+ * Who has them: `OXYGEN_TEAMMATES_ENABLED` is the switch, "staff" for staff emails
7
+ * and "all" for every member who is not a client login (the customer launch,
8
+ * Tim, 2026-10-17). Unset, it follows the Ongoing Agent sessions switch, because
9
+ * teammates re-scope those sessions into routines. Only workspaces Agents run in
10
+ * qualify (`agentsEnabledIn`); shadow never does.
11
11
  */
12
+ export type TeammatesAudience = "off" | "staff" | "all";
13
+ export declare function teammatesAudience(env?: Record<string, string | undefined>): TeammatesAudience;
12
14
  export declare function teammatesEnabled(env?: Record<string, string | undefined>): boolean;
13
15
  /** Whether this workspace has Oxygents. */
14
16
  export declare function teammatesEnabledIn(organizationId: string | null | undefined, env?: Record<string, string | undefined>): boolean;
@@ -1,34 +1,25 @@
1
1
  import { agentsEnabled, agentsEnabledIn } from "./agents-enabled.js";
2
2
  import { guidedActivationEnabled } from "./guided-activation.js";
3
3
  import { isStaffEmail } from "./staff.js";
4
- /**
5
- * AI teammates (ADR 0032): Agents people talk to in one ongoing shared chat
6
- * per Workspace Oxygent, on the Copilot runtime. Explicit Personal Oxygents
7
- * are creator-only; the first one stays Workspace (Philipp, 2026-10-16).
8
- *
9
- * Staff only: on dev, and in production's staff workspaces, the ones Agents run
10
- * in (`agentsEnabledIn`; Tim, 2026-10-16). `OXYGEN_TEAMMATES_ENABLED` is the
11
- * switch; when it is unset the feature follows the Ongoing Agent sessions staff
12
- * gate, because teammates re-scope those sessions into routines. Shadow never
13
- * qualifies.
14
- */
15
- export function teammatesEnabled(env = process.env) {
4
+ export function teammatesAudience(env = process.env) {
16
5
  if (env.OXYGEN_DEPLOY_ENV !== "selfhost-dev" && env.OXYGEN_DEPLOY_ENV !== "selfhost-prod")
17
- return false;
6
+ return "off";
18
7
  if (!agentsEnabled(env))
19
- return false;
20
- const flag = env.OXYGEN_TEAMMATES_ENABLED?.trim().toLowerCase();
21
- if (flag)
22
- return flag === "staff";
23
- return env.OXYGEN_AGENT_SESSIONS_ENABLED === "staff";
8
+ return "off";
9
+ const flag = env.OXYGEN_TEAMMATES_ENABLED?.trim().toLowerCase() || env.OXYGEN_AGENT_SESSIONS_ENABLED?.trim();
10
+ return flag === "staff" || flag === "all" ? flag : "off";
11
+ }
12
+ export function teammatesEnabled(env = process.env) {
13
+ return teammatesAudience(env) !== "off";
24
14
  }
25
15
  /** Whether this workspace has Oxygents. */
26
16
  export function teammatesEnabledIn(organizationId, env = process.env) {
27
17
  return teammatesEnabled(env) && agentsEnabledIn(organizationId, env);
28
18
  }
29
19
  export function teammatesAvailableTo(env, viewer) {
30
- return teammatesEnabledIn(viewer.organizationId, env) && viewer.member && viewer.oxygenRole !== "client"
31
- && (guidedActivationEnabled(env) || isStaffEmail(viewer.email, env));
20
+ if (!teammatesEnabledIn(viewer.organizationId, env) || !viewer.member || viewer.oxygenRole === "client")
21
+ return false;
22
+ return teammatesAudience(env) === "all" || guidedActivationEnabled(env) || isStaffEmail(viewer.email, env);
32
23
  }
33
24
  /**
34
25
  * The Workspace Agent's slug. Every workspace's first Oxygent is that Agent: an
@@ -222,7 +222,14 @@ function isGuidedOnboardingDiscoveryIntent(query) {
222
222
  || (/\b(?:guide|guided|journey|part [12])\b/.test(query)
223
223
  && /\b(?:business|company|icp|personas?|prospects?|trial|campaign|sequence|briefing)\b/.test(query))
224
224
  || (/\b(?:trial|prospects?|personas?)\b/.test(query)
225
- && /\b(?:context|icp|campaign|sequence)\b/.test(query));
225
+ && /\b(?:context|icp|campaign|sequence)\b/.test(query))
226
+ // Company-statement review is a guided step even without the words guide
227
+ // or journey. Existing channel conversations keep their message owner.
228
+ || (/\bcompany profile\b/.test(query)
229
+ && /\b(?:review|correct|confirm)\b/.test(query)
230
+ && /\b(?:statement|profile revision)\b/.test(query)
231
+ && /\b(?:prospects?|evidence|sources?)\b/.test(query)
232
+ && !/\b(?:inbox|unibox|messages?|threads?|repl(?:y|ies)|email|whatsapp|slack|teams)\b/.test(query));
226
233
  }
227
234
  function isCompanyOnboardingContextIntent(query) {
228
235
  return /\bonboard\w*\b/.test(query)
@@ -337,7 +344,10 @@ function oxygentsRoute(normalized) {
337
344
  // Explicit content reads and other operations retain their native verbs.
338
345
  const privacy = /\b(?:visibility|personal|workspace|private|shared)\b/.test(normalized);
339
346
  const rosterRead = /\b(?:list|inventory|roster)\b|\bwhich\b.*\b(?:have|available)\b/.test(normalized);
340
- const contentRead = /\b(?:read|see|view|show|transcript)\b/.test(normalized);
347
+ // Bare history asks read content; creation/configuration asks describe its contract.
348
+ const historyContract = /\b(?:visibility|configur\w*|settings?|privacy|defaults?|create|creating|make|add|new|set up|spawn|hire)\b/.test(normalized);
349
+ const contentRead = /\b(?:read|see|view|show|transcript)\b/.test(normalized)
350
+ || (/\bhistory\b/.test(normalized) && !historyContract);
341
351
  const contentSend = /\b(?:messages?|ask|tell|send|talk|continue|resume|handoff|delegat\w*)\b|\b(?:chat with|hand\w* off)\b/.test(normalized);
342
352
  if (!group && privacy && ((verb === "list" && !rosterRead) || verb === "create"
343
353
  || (verb === "update" && /\bvisibility\b/.test(normalized))
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1014.1087";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1014.1136";
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
2
- export const OXYGEN_BUILD_VERSION = "1.1014.1087";
2
+ export const OXYGEN_BUILD_VERSION = "1.1014.1136";
@@ -1,4 +1,4 @@
1
- import type { WorkspacePlacementPlan, WorkspacePlacementObservation } from "./workspace-placement.js";
1
+ import { type WorkspacePlacementPlan, type WorkspacePlacementObservation } from "./workspace-placement.js";
2
2
  export type WorkspacePlacementView = {
3
3
  id: string;
4
4
  provider: "internal";
@@ -123,6 +123,7 @@ export declare function workspacePlacementPreviewPage(plan: WorkspacePlacementPl
123
123
  billing_mode: "internal";
124
124
  credits: 0;
125
125
  allow_paused_probe: boolean;
126
+ receiver_scope?: "workspace" | "network";
126
127
  rendered_at?: string;
127
128
  rendering?: {
128
129
  tracking_opens: boolean;
@@ -1,4 +1,5 @@
1
1
  import { CliHttpError } from "./cli-http-error.js";
2
+ import { placementReceiverLabel } from "./workspace-placement.js";
2
3
  export function workspacePlacementView(input, webUrl) {
3
4
  const variants = new Map();
4
5
  const probes = input.plan.probes.map(p => {
@@ -46,12 +47,12 @@ export function workspacePlacementView(input, webUrl) {
46
47
  else if (out?.state === "read_blocked")
47
48
  v.read_blocked++;
48
49
  const state = out?.state ?? (input.deliveries?.[p.id]?.status === "sent" ? "awaiting_results" : input.deliveries?.[p.id]?.status ?? "pending");
49
- return { ...workspacePlacementProbeMeta(p), state, status_label: workspacePlacementStateLabel(state), ...(input.deliveries?.[p.id] ? { delivery: input.deliveries[p.id] } : {}), ...(o ? { observation: o } : {}) };
50
+ return { ...workspacePlacementProbeMeta(p), state, status_label: workspacePlacementStateLabel(state), ...(input.deliveries?.[p.id] ? { delivery: input.deliveries[p.id] } : {}), ...(o ? { observation: p.receiver.network_id ? { ...o, receiver_message_id: "private" } : o } : {}) };
50
51
  });
51
52
  const settled = input.plan.probes.filter(p => input.outcomes[p.id]).length;
52
53
  return { id: input.id, provider: "internal", billing_mode: "internal", credits_used: 0, status: settled < probes.length ? "running" : probes.every(p => p.state === "observed") ? "completed" : "incomplete",
53
54
  created_at: new Date(input.created_at).toISOString(), message_count: probes.length, variants: [...variants.values()], probes, plan_hash: input.plan.plan_hash, web_url: webUrl,
54
- directional_note: "Observed placement in this workspace's receiving accounts; not a campaign placement guarantee. Primary and Focused are separate provider classifications." };
55
+ directional_note: "Observed placement in participating receiving accounts; not a campaign placement guarantee. Primary and Focused are separate provider classifications." };
55
56
  }
56
57
  export function workspacePlacementStateLabel(state) {
57
58
  return { pending: "Queued", leased: "Preparing send", sent: "Awaiting receiver observation", awaiting_results: "Awaiting receiver observation", observed: "Observed", not_observed: "Not observed within two hours", send_failed: "Send failed", send_unknown: "Send outcome unknown; do not resend", read_blocked: "Receiver could not be checked", uncertain: "Send outcome unknown; do not resend", canceled: "Send canceled", failed: "Send failed" }[state] ?? "Pending";
@@ -64,7 +65,7 @@ function pageNumber(value, fallback, max, field) {
64
65
  return value;
65
66
  }
66
67
  export function workspacePlacementProbeMeta(probe) {
67
- return { id: probe.id, sender: probe.sender.address, sender_name: probe.sender.display_name ?? null, receiver: probe.receiver.address, receiver_provider: probe.receiver.provider, variant: probe.variant.key, subject: probe.variant.subject,
68
+ return { id: probe.id, sender: probe.sender.address, sender_name: probe.sender.display_name ?? null, receiver: placementReceiverLabel(probe.receiver), receiver_provider: probe.receiver.provider, variant: probe.variant.key, subject: probe.variant.subject,
68
69
  body_text_characters: Array.from(probe.variant.body_text).length, body_html_characters: Array.from(probe.variant.body_html ?? "").length };
69
70
  }
70
71
  export function workspacePlacementCopyPage(plan, input) {
@@ -81,7 +82,7 @@ export function workspacePlacementCopyPage(plan, input) {
81
82
  if (limit < 1)
82
83
  throw new CliHttpError(400, "workspace_test_page_invalid", "copy_limit must be at least 1.");
83
84
  const end = Math.min(chars.length, offset + limit);
84
- return { id: probe.id, variant: probe.variant.key, sender: probe.sender.address, sender_name: probe.sender.display_name ?? null, receiver: probe.receiver.address, subject: probe.variant.subject, part, offset, total_characters: chars.length, text: chars.slice(offset, end).join(""), has_more: end < chars.length, next_offset: end < chars.length ? end : null };
85
+ return { id: probe.id, variant: probe.variant.key, sender: probe.sender.address, sender_name: probe.sender.display_name ?? null, receiver: placementReceiverLabel(probe.receiver), subject: probe.variant.subject, part, offset, total_characters: chars.length, text: chars.slice(offset, end).join(""), has_more: end < chars.length, next_offset: end < chars.length ? end : null };
85
86
  }
86
87
  export function workspacePlacementProbePage(probes, input, defaultLimit = 10) {
87
88
  const limit = pageNumber(input.limit, defaultLimit, 20, "limit");
@@ -11,6 +11,8 @@ export type WorkspacePlacementMailbox = {
11
11
  transport: string;
12
12
  display_name?: string | null;
13
13
  signature_html: string | null;
14
+ organization_id?: string;
15
+ network_id?: string;
14
16
  };
15
17
  export type WorkspacePlacementProbe = {
16
18
  id: string;
@@ -25,6 +27,7 @@ export type WorkspacePlacementPlan = {
25
27
  billing_mode: "internal";
26
28
  credits: 0;
27
29
  allow_paused_probe: boolean;
30
+ receiver_scope?: "workspace" | "network";
28
31
  rendered_at?: string;
29
32
  rendering?: {
30
33
  tracking_opens: boolean;
@@ -48,6 +51,7 @@ export declare function buildWorkspacePlacementPlan(input: {
48
51
  allowPausedProbe: boolean;
49
52
  renderedAt?: string;
50
53
  rendering?: WorkspacePlacementPlan["rendering"];
54
+ network?: boolean;
51
55
  renderVariant?: (variant: WorkspacePlacementVariant, sender: WorkspacePlacementMailbox, probeId: string) => WorkspacePlacementVariant;
52
56
  }): WorkspacePlacementPlan;
53
57
  export type WorkspacePlacementObservation = {
@@ -75,3 +79,6 @@ export declare function workspacePlacementPayload(probe: WorkspacePlacementProbe
75
79
  apply_mailbox_signature: boolean;
76
80
  diagnostic_probe_id: string;
77
81
  };
82
+ /** Foreign identities remain private even in previews, copy pages and manifests. */
83
+ export declare function placementReceiverLabel(receiver: WorkspacePlacementMailbox): string;
84
+ export declare function publicWorkspacePlacementPlan(plan: WorkspacePlacementPlan): WorkspacePlacementPlan;
@@ -51,17 +51,23 @@ export function buildWorkspacePlacementPlan(input) {
51
51
  else
52
52
  pairs++;
53
53
  }
54
- if (pairs * variants.length < 1 || pairs * variants.length > 50)
54
+ if ((input.network ? senders.length * variants.length * new Set(receivers.map(r => r.provider)).size : pairs * variants.length) < 1 || (input.network ? senders.length * variants.length * new Set(receivers.map(r => r.provider)).size : pairs * variants.length) > 50)
55
55
  invalid("A workspace test must send between one and 50 messages after excluding self-sends.");
56
56
  const probes = [];
57
57
  // Reject oversized matrices before signatures/HTML/tracking allocate per-probe content.
58
58
  // Rotate variants per receiver: no variant always gets the first submission.
59
- for (const sender of senders) {
59
+ for (const [senderIndex, sender] of senders.entries()) {
60
60
  for (const [index, receiver] of receivers.entries()) {
61
61
  if (self(sender, receiver))
62
62
  continue;
63
63
  for (let offset = 0; offset < variants.length; offset++) {
64
64
  const original = variants[(offset + index) % variants.length];
65
+ if (input.network) {
66
+ const group = receivers.filter(r => r.provider === receiver.provider);
67
+ const sequence = senderIndex * variants.length + variants.indexOf(original);
68
+ if (group[sequence % group.length].id !== receiver.id)
69
+ continue;
70
+ }
65
71
  const id = probeId(`${organizationId}:${requestId}:${sender.id}:${receiver.id}:${original.key}`);
66
72
  const variant = input.renderVariant ? input.renderVariant(original, sender, id) : original;
67
73
  probes.push({ id, sender, receiver, variant });
@@ -71,7 +77,7 @@ export function buildWorkspacePlacementPlan(input) {
71
77
  if (!probes.length || probes.length > 50)
72
78
  invalid("A workspace test must send between one and 50 messages after excluding self-sends.");
73
79
  const plan = { request_id: requestId, organization_id: organizationId, provider: "internal",
74
- billing_mode: "internal", credits: 0, allow_paused_probe: input.allowPausedProbe,
80
+ ...(input.network ? { receiver_scope: "network" } : {}), billing_mode: "internal", credits: 0, allow_paused_probe: input.allowPausedProbe,
75
81
  ...(input.renderedAt ? { rendered_at: input.renderedAt } : {}), ...(input.rendering ? { rendering: input.rendering } : {}),
76
82
  probes, excluded_self_sends: excluded, message_count: probes.length };
77
83
  return { ...plan, plan_hash: createHash("sha256").update(JSON.stringify(plan)).digest("hex") };
@@ -92,3 +98,10 @@ export function workspacePlacementPayload(probe) {
92
98
  return { kind: "email_send", to: probe.receiver.address, subject: probe.variant.subject, body: probe.variant.body_text,
93
99
  ...(probe.variant.body_html ? { body_html: probe.variant.body_html } : {}), apply_mailbox_signature: false, diagnostic_probe_id: probe.id };
94
100
  }
101
+ /** Foreign identities remain private even in previews, copy pages and manifests. */
102
+ export function placementReceiverLabel(receiver) {
103
+ return receiver.network_id ? `${receiver.provider === "google" ? "Google" : "Microsoft"} receiver ${receiver.network_id.slice(0, 8)}` : receiver.address;
104
+ }
105
+ export function publicWorkspacePlacementPlan(plan) {
106
+ return { ...plan, probes: plan.probes.map(p => ({ ...p, receiver: p.receiver.network_id ? { id: p.receiver.network_id, address: placementReceiverLabel(p.receiver), provider: p.receiver.provider, transport: "network", signature_html: null } : p.receiver })) };
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1014.1087",
3
+ "version": "1.1014.1136",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",