@oxygen-agent/cli 1.662.0 → 1.681.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -294,7 +294,12 @@ export const OXYGEN_CAPABILITY_ROUTES = [
294
294
  gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list"],
295
295
  skills: ["oxygen-sequencer"],
296
296
  endpointSections: ["senders", "sequencer", "sequences", "spintax", "suppressions", "voice"],
297
- intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply"],
297
+ // "sender identity", "sender profile" and "phone number" are here because a
298
+ // blind-user eval (2026-08-09) asked for "our sender identity" and got routed
299
+ // to workspace-access — "identity" matched the AUTH sense. The nouns a user
300
+ // reaches for when naming a sending identity or the dialing pool have to land
301
+ // on the group that actually owns `senders profiles` and `voice numbers`.
302
+ intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer"],
298
303
  },
299
304
  {
300
305
  id: "publishing",
@@ -4,14 +4,15 @@ import { type TagKind } from "./tags.js";
4
4
  *
5
5
  * Seeded from {@link TAG_KINDS}: anything worth labelling for a campaign is
6
6
  * worth discussing with the client who signs that campaign off. The list is
7
- * RESTATED rather than aliased because it is also a stored value — the
8
- * `ox_collab` CHECK constraint and existing rows pin it — so widening the tag
9
- * vocabulary must be a deliberate decision here, not a silent side effect that
10
- * lets the API accept a kind the database rejects. The alignment is enforced
7
+ * RESTATED rather than aliased because it is also a stored value — existing
8
+ * `ox_collab` rows pin it — so widening the tag vocabulary must be a deliberate
9
+ * decision here, not a silent side effect. (`subject_kind` deliberately carries
10
+ * no enum CHECK; 0182_collab.sql explains why, so adding a kind here needs no
11
+ * tenant migration.) The alignment is enforced
11
12
  * both ways: {@link EveryTagKindIsCommentable} fails the build if a taggable
12
13
  * kind is missing, and the paired test pins today's list exactly.
13
14
  */
14
- export declare const COLLAB_SUBJECT_KINDS: readonly ["knowledge_page", "publishing_post", "sequence", "table", "workflow", "recipe", "conversation", "mailbox", "sender", "record", "domain", "project"];
15
+ export declare const COLLAB_SUBJECT_KINDS: readonly ["knowledge_page", "publishing_post", "sequence", "table", "workflow", "recipe", "conversation", "mailbox", "sender", "record", "domain", "project", "sender_profile", "voice_number"];
15
16
  export type CollabSubjectKind = (typeof COLLAB_SUBJECT_KINDS)[number];
16
17
  /** Compile-time assertion helper: instantiating with `false` is a type error. */
17
18
  type AssertTrue<T extends true> = T;
@@ -22,10 +22,11 @@ import { OxygenError } from "./cli-result.js";
22
22
  *
23
23
  * Seeded from {@link TAG_KINDS}: anything worth labelling for a campaign is
24
24
  * worth discussing with the client who signs that campaign off. The list is
25
- * RESTATED rather than aliased because it is also a stored value — the
26
- * `ox_collab` CHECK constraint and existing rows pin it — so widening the tag
27
- * vocabulary must be a deliberate decision here, not a silent side effect that
28
- * lets the API accept a kind the database rejects. The alignment is enforced
25
+ * RESTATED rather than aliased because it is also a stored value — existing
26
+ * `ox_collab` rows pin it — so widening the tag vocabulary must be a deliberate
27
+ * decision here, not a silent side effect. (`subject_kind` deliberately carries
28
+ * no enum CHECK; 0182_collab.sql explains why, so adding a kind here needs no
29
+ * tenant migration.) The alignment is enforced
29
30
  * both ways: {@link EveryTagKindIsCommentable} fails the build if a taggable
30
31
  * kind is missing, and the paired test pins today's list exactly.
31
32
  */
@@ -42,6 +43,8 @@ export const COLLAB_SUBJECT_KINDS = [
42
43
  "record",
43
44
  "domain",
44
45
  "project",
46
+ "sender_profile",
47
+ "voice_number",
45
48
  ];
46
49
  export function isCollabSubjectKind(value) {
47
50
  return typeof value === "string" && COLLAB_SUBJECT_KINDS.includes(value);
@@ -69,6 +72,8 @@ export const COLLAB_SUBJECT_LABELS = {
69
72
  record: { one: "CRM record", many: "CRM records" },
70
73
  domain: { one: "sending domain", many: "sending domains" },
71
74
  project: { one: "project", many: "projects" },
75
+ sender_profile: { one: "sender profile", many: "sender profiles" },
76
+ voice_number: { one: "phone number", many: "phone numbers" },
72
77
  };
73
78
  /**
74
79
  * What a thread on each kind is usually about, for `--help` and MCP tool
@@ -93,6 +98,8 @@ export const COLLAB_SUBJECT_PROSE = {
93
98
  record: "a CRM record's truth",
94
99
  domain: "a sending domain's DNS and warmup",
95
100
  project: "a project's scope",
101
+ sender_profile: "a sending identity's accounts and inboxes",
102
+ voice_number: "a dialing number's caps and warmup",
96
103
  };
97
104
  /** Oxford-comma list: ["a"] -> "a"; ["a","b"] -> "a and b"; ["a","b","c"] -> "a, b, and c". */
98
105
  function formatProseList(items) {
@@ -5,9 +5,18 @@ export type CreditGuidance = {
5
5
  available_credits: number | null;
6
6
  credit_posture: CreditPosture;
7
7
  credit_guidance: string;
8
+ /** Present only when the caller supplied an expected (average) figure. */
9
+ expected_credits?: number;
8
10
  };
9
11
  export declare function buildCreditGuidance(input: {
10
12
  estimatedCredits: number | null | undefined;
11
13
  availableCredits: number | null | undefined;
12
14
  headroomMultiplier?: number;
15
+ /** The average a run actually costs. When given, the ceiling is sized from this. */
16
+ expectedCredits?: number | null | undefined;
17
+ /** Dearest single lane a row can reach — the tail the band covers. */
18
+ worstCaseCreditsPerRow?: number | null | undefined;
19
+ rowCount?: number | null | undefined;
20
+ /** True when estimatedCredits is a genuine per-row bound (bill-on-hit lanes). */
21
+ worstCaseIsHardBound?: boolean;
13
22
  }): CreditGuidance;
@@ -1,10 +1,66 @@
1
1
  const DEFAULT_HEADROOM_MULTIPLIER = 1.25;
2
+ /**
3
+ * Size a ceiling from the EXPECTED cost plus a variance band, rather than from
4
+ * the worst case.
5
+ *
6
+ * A waterfall lane bills only on a hit and a row stops at the first lane that
7
+ * resolves it, so the worst case is the dearest lane on EVERY row — a tail event,
8
+ * not a price. Quoting it made a 4.8k job read as 31.6k and told workspaces with
9
+ * ample balance they were "tight".
10
+ *
11
+ * The band is `expected + worst_row x tailRows`, where `tailRows` scales like
12
+ * sqrt(n): unlucky rows are a sample of the tail, and their count grows with the
13
+ * square root of the run, not linearly. `+1` guarantees a run can always afford at
14
+ * least one row above the mean, and `min(n, ...)` collapses the whole thing to the
15
+ * true worst case on tiny runs, where it is cheap to just cover everything.
16
+ *
17
+ * `expected x 1.25` remains the floor, so a flat/cheap chain with no meaningful
18
+ * tail keeps exactly the old headroom.
19
+ */
20
+ function expectedCreditCeiling(input) {
21
+ const floor = input.expectedCredits * input.headroomMultiplier;
22
+ let ceiling = floor;
23
+ if (input.worstCaseCreditsPerRow !== null && input.rowCount !== null && input.rowCount > 0) {
24
+ const tailRows = Math.min(input.rowCount, Math.ceil(Math.sqrt(input.rowCount)) + 1);
25
+ ceiling = Math.max(floor, input.expectedCredits + input.worstCaseCreditsPerRow * tailRows);
26
+ }
27
+ // Only clamp where the worst case is a REAL per-row bound — bill-on-hit lanes,
28
+ // where one lane is the most a row can cost. An AI/tool column's "estimate" is a
29
+ // token guess that real usage can exceed, so clamping there would reintroduce
30
+ // mid-run truncation on exactly the columns that have no ceiling of their own.
31
+ //
32
+ // Clamp to the worst case WITH its headroom, not to the bare figure: the row
33
+ // count is itself an estimate for a filtered selection, and the multiplier is
34
+ // what absorbs an undercount. Clamping to the bare total would make a small run
35
+ // strictly tighter than before this change, which is the opposite of the point.
36
+ if (input.worstCaseIsHardBound && input.worstCaseTotal !== null) {
37
+ ceiling = Math.min(ceiling, input.worstCaseTotal * input.headroomMultiplier);
38
+ }
39
+ return Math.max(1, Math.ceil(ceiling));
40
+ }
2
41
  export function buildCreditGuidance(input) {
3
42
  const estimatedCredits = normalizeCreditValue(input.estimatedCredits);
4
43
  const availableCredits = normalizeCreditValue(input.availableCredits);
5
- const recommendedMaxCredits = recommendedCreditCeiling(estimatedCredits, input.headroomMultiplier ?? DEFAULT_HEADROOM_MULTIPLIER);
44
+ const expectedCredits = normalizeCreditValue(input.expectedCredits);
45
+ const headroomMultiplier = normalizeMultiplier(input.headroomMultiplier);
46
+ // No expected figure supplied => byte-identical to the pre-existing behaviour,
47
+ // so publishing / sequences / AI callers are untouched by this change.
48
+ const recommendedMaxCredits = expectedCredits === null
49
+ ? recommendedCreditCeiling(estimatedCredits, headroomMultiplier)
50
+ : expectedCreditCeiling({
51
+ expectedCredits,
52
+ worstCaseCreditsPerRow: normalizeCreditValue(input.worstCaseCreditsPerRow),
53
+ worstCaseTotal: estimatedCredits,
54
+ rowCount: typeof input.rowCount === "number" && Number.isFinite(input.rowCount)
55
+ ? Math.max(0, Math.floor(input.rowCount))
56
+ : null,
57
+ headroomMultiplier,
58
+ worstCaseIsHardBound: input.worstCaseIsHardBound === true,
59
+ });
60
+ const expectedFields = expectedCredits === null ? {} : { expected_credits: expectedCredits };
6
61
  if (estimatedCredits === null) {
7
62
  return {
63
+ ...expectedFields,
8
64
  estimated_credits: null,
9
65
  recommended_max_credits: null,
10
66
  available_credits: availableCredits,
@@ -14,6 +70,7 @@ export function buildCreditGuidance(input) {
14
70
  }
15
71
  if (availableCredits === null) {
16
72
  return {
73
+ ...expectedFields,
17
74
  estimated_credits: estimatedCredits,
18
75
  recommended_max_credits: recommendedMaxCredits,
19
76
  available_credits: null,
@@ -22,7 +79,30 @@ export function buildCreditGuidance(input) {
22
79
  };
23
80
  }
24
81
  if (recommendedMaxCredits !== null && recommendedMaxCredits <= availableCredits) {
82
+ // When an expected figure drove the ceiling, the copy has to describe THAT,
83
+ // not "the estimate plus 25% headroom" — the old sentence would misstate how
84
+ // the number was derived. The tail warning is added only when a full
85
+ // fall-through really could outrun the balance; saying it otherwise invents
86
+ // a risk that does not exist.
87
+ if (expectedCredits !== null) {
88
+ const tailWarning = estimatedCredits > availableCredits
89
+ ? " Only a run where nearly every row falls through to the dearest provider could exceed your"
90
+ + " balance — the run keeps going and stops when the balance runs out, keeping every row it"
91
+ + " has already completed."
92
+ : "";
93
+ return {
94
+ ...expectedFields,
95
+ estimated_credits: estimatedCredits,
96
+ recommended_max_credits: recommendedMaxCredits,
97
+ available_credits: availableCredits,
98
+ credit_posture: "sufficient",
99
+ credit_guidance: `Credits cover the expected cost (≈${formatCreditsForCopy(expectedCredits)} credits) with `
100
+ + "headroom for the rows that fall through to a pricier provider."
101
+ + `${tailWarning} Unused credits are not spent.`,
102
+ };
103
+ }
25
104
  return {
105
+ ...expectedFields,
26
106
  estimated_credits: estimatedCredits,
27
107
  recommended_max_credits: recommendedMaxCredits,
28
108
  available_credits: availableCredits,
@@ -33,6 +113,7 @@ export function buildCreditGuidance(input) {
33
113
  };
34
114
  }
35
115
  return {
116
+ ...expectedFields,
36
117
  estimated_credits: estimatedCredits,
37
118
  recommended_max_credits: availableCredits > 0 ? roundCreditValue(availableCredits) : null,
38
119
  available_credits: availableCredits,
@@ -51,6 +132,14 @@ function recommendedCreditCeiling(estimatedCredits, headroomMultiplier = DEFAULT
51
132
  : DEFAULT_HEADROOM_MULTIPLIER;
52
133
  return Math.max(1, Math.ceil(normalized * multiplier));
53
134
  }
135
+ function normalizeMultiplier(value) {
136
+ return Number.isFinite(value) && (value ?? 0) > 0
137
+ ? value
138
+ : DEFAULT_HEADROOM_MULTIPLIER;
139
+ }
140
+ function formatCreditsForCopy(value) {
141
+ return Math.round(value).toLocaleString("en-US");
142
+ }
54
143
  function normalizeCreditValue(value) {
55
144
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
56
145
  return null;
@@ -1,3 +1,4 @@
1
+ import { type Row } from "read-excel-file/node";
1
2
  import { type ImportColumnDataType } from "./column-types.js";
2
3
  export type RowsFileFormat = "json" | "jsonl" | "csv" | "xlsx";
3
4
  export type ImportTableColumn = {
@@ -16,6 +17,10 @@ export declare function normalizeRowsFormat(value: string | undefined, fallback:
16
17
  export declare function parseRowsFileBuffer(buffer: Buffer, format: RowsFileFormat, options?: {
17
18
  sheet?: string;
18
19
  }): Promise<Record<string, unknown>[]>;
20
+ /** Read raw workbook cells for consumers with stricter, domain-specific headers. */
21
+ export declare function parseXlsxWorkbookBuffer(buffer: Buffer, options?: {
22
+ sheet?: string;
23
+ }): Promise<Row[]>;
19
24
  export declare function iterateRowsFileBufferBatches(buffer: Buffer, format: RowsFileFormat, options?: {
20
25
  sheet?: string;
21
26
  batchSize?: number;
@@ -32,6 +32,21 @@ export async function parseRowsFileBuffer(buffer, format, options = {}) {
32
32
  return await parseXlsxRows(buffer, options);
33
33
  return parseRowsText(buffer.toString("utf8"), format);
34
34
  }
35
+ /** Read raw workbook cells for consumers with stricter, domain-specific headers. */
36
+ export async function parseXlsxWorkbookBuffer(buffer, options = {}) {
37
+ try {
38
+ return await readXlsxFile(buffer, options.sheet ? { sheet: options.sheet } : {});
39
+ }
40
+ catch (error) {
41
+ throw new OxygenError("invalid_rows", "XLSX sheet was not found.", {
42
+ details: {
43
+ sheet: options.sheet ?? null,
44
+ reason: error instanceof Error ? error.message : String(error),
45
+ },
46
+ exitCode: 1,
47
+ });
48
+ }
49
+ }
35
50
  export async function* iterateRowsFileBufferBatches(buffer, format, options = {}) {
36
51
  const batchSize = normalizeBatchSize(options.batchSize);
37
52
  if (format === "csv") {
@@ -127,19 +142,7 @@ export function normalizeImportColumnKey(value) {
127
142
  return toSnakeIdentifier(value, "column");
128
143
  }
129
144
  async function parseXlsxRows(buffer, options) {
130
- let rows;
131
- try {
132
- rows = await readXlsxFile(buffer, options.sheet ? { sheet: options.sheet } : {});
133
- }
134
- catch (error) {
135
- throw new OxygenError("invalid_rows", "XLSX sheet was not found.", {
136
- details: {
137
- sheet: options.sheet ?? null,
138
- reason: error instanceof Error ? error.message : String(error),
139
- },
140
- exitCode: 1,
141
- });
142
- }
145
+ const rows = await parseXlsxWorkbookBuffer(buffer, options);
143
146
  const [header, ...body] = rows;
144
147
  if (!header || header.length === 0)
145
148
  return [];
@@ -0,0 +1,58 @@
1
+ export type MailboxImportMode = "identity" | "credential";
2
+ export type MailboxImportFileFormat = "json" | "jsonl" | "csv" | "xlsx";
3
+ export type MailboxImportValidationSource = "identity_file" | "credential_file" | "hypertide_file";
4
+ export type NormalizedMailboxImportRow = {
5
+ email_address: string;
6
+ provider: "google" | "microsoft";
7
+ workspace_external_id?: string;
8
+ infrastructure_platform?: "google_workspace" | "microsoft_365" | "microsoft_azure";
9
+ tenant_id?: string;
10
+ app_password?: string;
11
+ };
12
+ export type MailboxImportValidationSummary = {
13
+ valid: true;
14
+ source: MailboxImportValidationSource;
15
+ source_provider?: string;
16
+ rows: number;
17
+ providers: {
18
+ google: number;
19
+ microsoft: number;
20
+ };
21
+ infrastructure_platforms: Record<string, number>;
22
+ credential_rows: number;
23
+ identity_only_rows: number;
24
+ limits: {
25
+ max_rows: number;
26
+ max_file_bytes: number;
27
+ };
28
+ mutation: false;
29
+ network_request: false;
30
+ provider_call: false;
31
+ credits_used: 0;
32
+ next_action: string;
33
+ };
34
+ export declare const MAILBOX_IMPORT_FILE_MAX_BYTES: number;
35
+ export declare const MAILBOX_IMPORT_ROW_LIMIT = 500;
36
+ /**
37
+ * Infer the bounded mailbox-import format without node:path so browser and CLI
38
+ * preflight use the same extension contract.
39
+ */
40
+ export declare function inferMailboxImportFileFormat(name: string): MailboxImportFileFormat;
41
+ /**
42
+ * Parse credential-sensitive text without ever echoing file contents. JSON may
43
+ * be either a bare array or the common `{ mailboxes: [...] }` export wrapper.
44
+ */
45
+ export declare function parseMailboxImportText(text: string, format: Exclude<MailboxImportFileFormat, "xlsx">): unknown[];
46
+ /** Convert read-excel-file's browser rows to the same object rows as CSV. */
47
+ export declare function normalizeMailboxWorkbookRows(rows: readonly (readonly unknown[])[]): unknown[];
48
+ /**
49
+ * Whitelist mailbox fields before a local file crosses the network. The same
50
+ * function runs in the CLI and first-party web app, so accepted aliases and
51
+ * non-transferable-secret rejection cannot drift by surface.
52
+ */
53
+ export declare function normalizeMailboxImportFile(value: unknown, mode: MailboxImportMode): NormalizedMailboxImportRow[];
54
+ export declare function summarizeMailboxImportValidation(mailboxes: readonly NormalizedMailboxImportRow[], input: {
55
+ source: MailboxImportValidationSource;
56
+ sourceProvider: string | null;
57
+ }): MailboxImportValidationSummary;
58
+ export declare function normalizeMailboxImportVendor(raw: string | null | undefined, from: string | null | undefined): string | null;