@oxygen-agent/cli 1.861.1 → 1.879.0

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 (29) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +10 -0
  3. package/dist/column-run-notices.js +22 -0
  4. package/dist/index.js +187 -73
  5. package/dist/local-custom-http-column.js +12 -37
  6. package/node_modules/@oxygen/formula/dist/formula-functions.js +10 -24
  7. package/node_modules/@oxygen/shared/dist/cell-format.js +23 -2
  8. package/node_modules/@oxygen/shared/dist/column-output-fields.d.ts +122 -0
  9. package/node_modules/@oxygen/shared/dist/column-output-fields.js +459 -0
  10. package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
  11. package/node_modules/@oxygen/shared/dist/index.js +1 -0
  12. package/node_modules/@oxygen/shared/dist/json-path.d.ts +109 -0
  13. package/node_modules/@oxygen/shared/dist/json-path.js +177 -0
  14. package/node_modules/@oxygen/shared/dist/log-collapse.d.ts +6 -3
  15. package/node_modules/@oxygen/shared/dist/log-collapse.js +6 -3
  16. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +2 -0
  17. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +13 -0
  18. package/node_modules/@oxygen/shared/dist/research-output-contract.d.ts +53 -0
  19. package/node_modules/@oxygen/shared/dist/research-output-contract.js +196 -0
  20. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +17 -1
  21. package/node_modules/@oxygen/shared/dist/sending-seats.js +17 -1
  22. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +47 -0
  23. package/node_modules/@oxygen/shared/dist/sequence-failures.js +301 -0
  24. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +1 -0
  25. package/node_modules/@oxygen/shared/dist/telemetry.js +119 -2
  26. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  27. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  28. package/node_modules/@oxygen/shared/package.json +15 -0
  29. package/package.json +1 -1
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The one dot-path parser and walker.
3
+ *
4
+ * Before this module the repo carried a dozen near-identical implementations —
5
+ * tool `outputPath`, custom-HTTP output projection, formula `path()`, table-send
6
+ * mappings, prompt templates, SQL filters, the grid's payload reader, and more.
7
+ * They agreed on the happy path and disagreed on the edges, which is exactly how
8
+ * "the filter and the cell read different values" bugs are born.
9
+ *
10
+ * The one design constraint that made consolidation possible: **nothing here
11
+ * throws.** Every existing call site raises its own typed `OxygenError` on a
12
+ * malformed path — `invalid_output_path`, `invalid_table_send_mapping`,
13
+ * `formulaExpressionError`, `invalid_column_run` — and a caller must be able to
14
+ * keep raising exactly the error it raised before. So parse/walk failures come
15
+ * back as discriminated results and the call site decides what they mean.
16
+ *
17
+ * Two behaviours differ between call sites and are therefore explicit options
18
+ * rather than defaults:
19
+ *
20
+ * - `brackets` (`a[0]` as sugar for `a.0`) exists only in the SQL filter parser.
21
+ * - `parseJsonStrings` (treat a string that looks like a JSON container as its
22
+ * parsed value) exists only in table-send, whose documented reason — "text
23
+ * columns holding JSON (AI outputs) still resolve" — is also why structured AI
24
+ * columns stored as `text` are reachable at all.
25
+ *
26
+ * Both default OFF so a migrated call site is provably unchanged.
27
+ *
28
+ * There are also two *validation timings* in the wild, and both are preserved:
29
+ *
30
+ * - **Up-front** (`parseJsonPath` + `readJsonPath`): the whole path is validated
31
+ * before traversal, so `"a..b"` is rejected even against a null value.
32
+ * - **Lazy** (`walkJsonPath`): an empty segment is only noticed if traversal
33
+ * actually reaches it, so `"a..b"` against a null value resolves to null and
34
+ * never reports the malformed path.
35
+ *
36
+ * That difference is observable, so it is a choice the caller makes, not one
37
+ * this module makes for it.
38
+ */
39
+ export type JsonPathSegment = string | number;
40
+ export type ParseJsonPathResult = {
41
+ ok: true;
42
+ segments: string[];
43
+ } | {
44
+ ok: false;
45
+ reason: "empty_segment";
46
+ };
47
+ export type WalkJsonPathResult = {
48
+ ok: true;
49
+ value: unknown;
50
+ } | {
51
+ ok: false;
52
+ reason: "empty_segment";
53
+ };
54
+ export type JsonPathReadOptions = {
55
+ /**
56
+ * Parse a string that looks like a serialized JSON object/array into its
57
+ * container before traversing it — at the root and at every level, because a
58
+ * provider payload can nest a JSON string inside a real object.
59
+ */
60
+ parseJsonStrings?: boolean;
61
+ };
62
+ export type ParseJsonPathOptions = {
63
+ /** Accept `a[0]` / `a[0][1]` as sugar for `a.0` / `a.0.1`. */
64
+ brackets?: boolean;
65
+ /** What an empty segment (`"a..b"`, `"a."`) means. Default `"reject"`. */
66
+ emptySegment?: "skip" | "reject";
67
+ };
68
+ /**
69
+ * A string that stores a serialized JSON object/array is treated as its parsed
70
+ * container; anything else is returned untouched — including strings that fail
71
+ * to parse, which stay the string they were rather than becoming null.
72
+ */
73
+ export declare function coerceJsonContainer(value: unknown): unknown;
74
+ /**
75
+ * Split a dot-path into trimmed segments, validating up front.
76
+ *
77
+ * An empty or whitespace-only path is "no path" and parses to zero segments —
78
+ * every call site short-circuits on it before validating, and treating it as one
79
+ * empty segment would reject the one input they all accept. A path that has real
80
+ * segments AND an empty one (`"a."`, `"a..b"`) is still malformed.
81
+ */
82
+ export declare function parseJsonPath(path: string, options?: ParseJsonPathOptions): ParseJsonPathResult;
83
+ /**
84
+ * Walk pre-parsed segments. A missing key, an out-of-range index, or a null
85
+ * encountered mid-path all resolve to `null` — a path that does not lead
86
+ * anywhere is an absent value, never an error.
87
+ *
88
+ * Traversal is own-property only (`Object.hasOwn`), so `__proto__`,
89
+ * `constructor` and the rest of the prototype chain are unreachable through a
90
+ * path even when the path names them.
91
+ */
92
+ export declare function readJsonPath(value: unknown, segments: readonly JsonPathSegment[], options?: JsonPathReadOptions): unknown;
93
+ /**
94
+ * Walk a raw dot-path with LAZY validation: an empty segment is reported only if
95
+ * traversal reaches it. This is the shape tool `outputPath` and formula `path()`
96
+ * have always had, and the difference is observable — `("a..b")` against a null
97
+ * value resolves to null here and is rejected by `parseJsonPath`.
98
+ *
99
+ * Prefer `parseJsonPath` + `readJsonPath` for new code; this exists so the
100
+ * existing call sites stay byte-identical.
101
+ */
102
+ export declare function walkJsonPath(value: unknown, rawPath: string, options?: JsonPathReadOptions): WalkJsonPathResult;
103
+ /**
104
+ * Can this path be written as a `{{column.path}}` mention token? Segments with
105
+ * spaces, dots, or punctuation cannot — which is not hypothetical: a research
106
+ * column names its output sections after free-text prompt labels ("Funding
107
+ * Stage"), and those are filterable and sortable but not referenceable.
108
+ */
109
+ export declare function isTemplateSafePath(path: string): boolean;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The one dot-path parser and walker.
3
+ *
4
+ * Before this module the repo carried a dozen near-identical implementations —
5
+ * tool `outputPath`, custom-HTTP output projection, formula `path()`, table-send
6
+ * mappings, prompt templates, SQL filters, the grid's payload reader, and more.
7
+ * They agreed on the happy path and disagreed on the edges, which is exactly how
8
+ * "the filter and the cell read different values" bugs are born.
9
+ *
10
+ * The one design constraint that made consolidation possible: **nothing here
11
+ * throws.** Every existing call site raises its own typed `OxygenError` on a
12
+ * malformed path — `invalid_output_path`, `invalid_table_send_mapping`,
13
+ * `formulaExpressionError`, `invalid_column_run` — and a caller must be able to
14
+ * keep raising exactly the error it raised before. So parse/walk failures come
15
+ * back as discriminated results and the call site decides what they mean.
16
+ *
17
+ * Two behaviours differ between call sites and are therefore explicit options
18
+ * rather than defaults:
19
+ *
20
+ * - `brackets` (`a[0]` as sugar for `a.0`) exists only in the SQL filter parser.
21
+ * - `parseJsonStrings` (treat a string that looks like a JSON container as its
22
+ * parsed value) exists only in table-send, whose documented reason — "text
23
+ * columns holding JSON (AI outputs) still resolve" — is also why structured AI
24
+ * columns stored as `text` are reachable at all.
25
+ *
26
+ * Both default OFF so a migrated call site is provably unchanged.
27
+ *
28
+ * There are also two *validation timings* in the wild, and both are preserved:
29
+ *
30
+ * - **Up-front** (`parseJsonPath` + `readJsonPath`): the whole path is validated
31
+ * before traversal, so `"a..b"` is rejected even against a null value.
32
+ * - **Lazy** (`walkJsonPath`): an empty segment is only noticed if traversal
33
+ * actually reaches it, so `"a..b"` against a null value resolves to null and
34
+ * never reports the malformed path.
35
+ *
36
+ * That difference is observable, so it is a choice the caller makes, not one
37
+ * this module makes for it.
38
+ */
39
+ const NUMERIC_SEGMENT = /^\d+$/;
40
+ const TEMPLATE_SAFE_PATH = /^[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*$/;
41
+ // A head of one-or-more non-`[` characters, optionally followed by one or more
42
+ // `[123]` index groups. Anything else (a stray bracket, an unclosed group) is
43
+ // left as a single literal segment rather than silently reinterpreted.
44
+ const BRACKET_SEGMENT = /^([^[]+)((?:\[\d+\])+)?$/;
45
+ function isRecord(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+ /**
49
+ * A string that stores a serialized JSON object/array is treated as its parsed
50
+ * container; anything else is returned untouched — including strings that fail
51
+ * to parse, which stay the string they were rather than becoming null.
52
+ */
53
+ export function coerceJsonContainer(value) {
54
+ if (typeof value !== "string")
55
+ return value;
56
+ const trimmed = value.trim();
57
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
58
+ return value;
59
+ try {
60
+ return JSON.parse(trimmed);
61
+ }
62
+ catch {
63
+ return value;
64
+ }
65
+ }
66
+ /**
67
+ * Split a dot-path into trimmed segments, validating up front.
68
+ *
69
+ * An empty or whitespace-only path is "no path" and parses to zero segments —
70
+ * every call site short-circuits on it before validating, and treating it as one
71
+ * empty segment would reject the one input they all accept. A path that has real
72
+ * segments AND an empty one (`"a."`, `"a..b"`) is still malformed.
73
+ */
74
+ export function parseJsonPath(path, options = {}) {
75
+ const emptySegment = options.emptySegment ?? "reject";
76
+ const segments = [];
77
+ if (!path.trim())
78
+ return { ok: true, segments };
79
+ for (const rawSegment of path.split(".")) {
80
+ const segment = rawSegment.trim();
81
+ if (!segment) {
82
+ if (emptySegment === "skip")
83
+ continue;
84
+ return { ok: false, reason: "empty_segment" };
85
+ }
86
+ if (!options.brackets) {
87
+ segments.push(segment);
88
+ continue;
89
+ }
90
+ const bracketMatch = BRACKET_SEGMENT.exec(segment);
91
+ if (!bracketMatch) {
92
+ segments.push(segment);
93
+ continue;
94
+ }
95
+ const head = bracketMatch[1]?.trim();
96
+ if (head)
97
+ segments.push(head);
98
+ for (const index of bracketMatch[2]?.match(/\d+/g) ?? [])
99
+ segments.push(index);
100
+ }
101
+ return { ok: true, segments };
102
+ }
103
+ /**
104
+ * Walk pre-parsed segments. A missing key, an out-of-range index, or a null
105
+ * encountered mid-path all resolve to `null` — a path that does not lead
106
+ * anywhere is an absent value, never an error.
107
+ *
108
+ * Traversal is own-property only (`Object.hasOwn`), so `__proto__`,
109
+ * `constructor` and the rest of the prototype chain are unreachable through a
110
+ * path even when the path names them.
111
+ */
112
+ export function readJsonPath(value, segments, options = {}) {
113
+ const parseStrings = options.parseJsonStrings === true;
114
+ let current = parseStrings ? coerceJsonContainer(value) : value;
115
+ for (const segment of segments) {
116
+ if (current === null || current === undefined)
117
+ return null;
118
+ if (parseStrings)
119
+ current = coerceJsonContainer(current);
120
+ if (Array.isArray(current)
121
+ && (typeof segment === "number" || NUMERIC_SEGMENT.test(segment))) {
122
+ current = current[Number(segment)] ?? null;
123
+ continue;
124
+ }
125
+ const key = String(segment);
126
+ if (isRecord(current) && Object.hasOwn(current, key)) {
127
+ current = current[key];
128
+ continue;
129
+ }
130
+ return null;
131
+ }
132
+ return current ?? null;
133
+ }
134
+ /**
135
+ * Walk a raw dot-path with LAZY validation: an empty segment is reported only if
136
+ * traversal reaches it. This is the shape tool `outputPath` and formula `path()`
137
+ * have always had, and the difference is observable — `("a..b")` against a null
138
+ * value resolves to null here and is rejected by `parseJsonPath`.
139
+ *
140
+ * Prefer `parseJsonPath` + `readJsonPath` for new code; this exists so the
141
+ * existing call sites stay byte-identical.
142
+ */
143
+ export function walkJsonPath(value, rawPath, options = {}) {
144
+ const parseStrings = options.parseJsonStrings === true;
145
+ const path = rawPath.trim();
146
+ let current = parseStrings ? coerceJsonContainer(value) : value;
147
+ if (!path)
148
+ return { ok: true, value: current ?? null };
149
+ for (const rawSegment of path.split(".")) {
150
+ const segment = rawSegment.trim();
151
+ if (!segment)
152
+ return { ok: false, reason: "empty_segment" };
153
+ if (current === null || current === undefined)
154
+ return { ok: true, value: null };
155
+ if (parseStrings)
156
+ current = coerceJsonContainer(current);
157
+ if (Array.isArray(current) && NUMERIC_SEGMENT.test(segment)) {
158
+ current = current[Number(segment)] ?? null;
159
+ continue;
160
+ }
161
+ if (isRecord(current) && Object.hasOwn(current, segment)) {
162
+ current = current[segment];
163
+ continue;
164
+ }
165
+ return { ok: true, value: null };
166
+ }
167
+ return { ok: true, value: current ?? null };
168
+ }
169
+ /**
170
+ * Can this path be written as a `{{column.path}}` mention token? Segments with
171
+ * spaces, dots, or punctuation cannot — which is not hypothetical: a research
172
+ * column names its output sections after free-text prompt labels ("Funding
173
+ * Stage"), and those are filterable and sortable but not referenceable.
174
+ */
175
+ export function isTemplateSafePath(path) {
176
+ return TEMPLATE_SAFE_PATH.test(path);
177
+ }
@@ -37,9 +37,12 @@
37
37
  * ingest batch. Query them as `['worker_fields']['occurrences']`, never flat.
38
38
  *
39
39
  * NOT a substitute for the log line: an OTel counter is not a backstop for
40
- * collapsed volume. `registerOTel()` on web is called with no metric readers and
41
- * the worker only builds one when AXIOM_METRICS_DATASET is set (it is not), so
42
- * every recordTelemetryCounter call in this repo is a no-op today. If a signal
40
+ * collapsed volume. `registerOTel()` on web is called with no metric readers
41
+ * (apps/web/src/instrumentation.ts:48), so a counter on the WEB surface is a
42
+ * no-op. The worker DOES build one — AXIOM_METRICS_DATASET is set in Doppler
43
+ * `oxygen-worker/prd` — so worker counters are live and re-exported every 60s,
44
+ * which is why their labels are allow-listed in packages/shared/src/telemetry.ts.
45
+ * Neither case makes a counter the right home for collapsed volume: if a signal
43
46
  * matters it stays in the LOG — which is what `occurrences`/`suppressed` are for.
44
47
  */
45
48
  /** Why a window's state was retired — carried on the closing rollup line. */
@@ -37,9 +37,12 @@
37
37
  * ingest batch. Query them as `['worker_fields']['occurrences']`, never flat.
38
38
  *
39
39
  * NOT a substitute for the log line: an OTel counter is not a backstop for
40
- * collapsed volume. `registerOTel()` on web is called with no metric readers and
41
- * the worker only builds one when AXIOM_METRICS_DATASET is set (it is not), so
42
- * every recordTelemetryCounter call in this repo is a no-op today. If a signal
40
+ * collapsed volume. `registerOTel()` on web is called with no metric readers
41
+ * (apps/web/src/instrumentation.ts:48), so a counter on the WEB surface is a
42
+ * no-op. The worker DOES build one — AXIOM_METRICS_DATASET is set in Doppler
43
+ * `oxygen-worker/prd` — so worker counters are live and re-exported every 60s,
44
+ * which is why their labels are allow-listed in packages/shared/src/telemetry.ts.
45
+ * Neither case makes a counter the right home for collapsed volume: if a signal
43
46
  * matters it stays in the LOG — which is what `occurrences`/`suppressed` are for.
44
47
  */
45
48
  export function createLogCollapser(options) {
@@ -35,6 +35,8 @@ export declare const SENDING_SEAT_USD_CENTS: {
35
35
  readonly email_sender: 100;
36
36
  };
37
37
  export type SendingSeatKey = keyof typeof SENDING_SEAT_USD_CENTS;
38
+ export declare const MANAGED_INBOX_USD_CENTS = 400;
39
+ export declare const SENDING_DOMAIN_USD_CENTS = 1250;
38
40
  export declare const VOICE_NUMBER_CREDITS_PER_MONTH = 5000;
39
41
  export declare const VOICE_CREDITS_PER_MINUTE = 90;
40
42
  export declare const VOICE_CREDITS_PER_AMD_CALL = 38;
@@ -60,6 +60,19 @@ export const SENDING_SEAT_USD_CENTS = {
60
60
  phone_number: 1_000,
61
61
  email_sender: 100,
62
62
  };
63
+ // --- email infrastructure (USD, Stripe-billed) -------------------------------
64
+ //
65
+ // Sold in dollars as real Stripe products, not credits. MANAGED_INBOX is ALL-IN:
66
+ // it already contains the sending seat and warm-up, so a customer never assembles
67
+ // a working mailbox out of parts and can never end up holding a mailbox with no
68
+ // seat to send from. A customer bringing their OWN mailbox pays the $1
69
+ // email_sender seat instead.
70
+ //
71
+ // The domain is FLAT rather than cost x 1.25. That is only safe because the
72
+ // below-cost guard refuses any quote whose line price falls under live vendor
73
+ // cost, so an expensive TLD is refused rather than silently sold at a loss.
74
+ export const MANAGED_INBOX_USD_CENTS = 400;
75
+ export const SENDING_DOMAIN_USD_CENTS = 1_250;
63
76
  // --- voice ------------------------------------------------------------------
64
77
  export const VOICE_NUMBER_CREDITS_PER_MONTH = 5_000;
65
78
  export const VOICE_CREDITS_PER_MINUTE = 90;
@@ -0,0 +1,53 @@
1
+ export type ResearchOutputContract = {
2
+ version: 1;
3
+ sections: string[];
4
+ evidenceRequired: boolean;
5
+ derivedFrom: "prompt_labeled_sections";
6
+ };
7
+ /**
8
+ * Derive the result shape the prompt explicitly asks the research column to
9
+ * return. This is intentionally conservative: prose containing colons is not a
10
+ * contract. We only accept a contiguous list after an explicit output-sections
11
+ * marker, and require at least two distinct labels.
12
+ */
13
+ export declare function deriveResearchOutputContract(prompt: string, options: {
14
+ evidenceRequired: boolean;
15
+ }): ResearchOutputContract | null;
16
+ export declare function buildResearchOutputSchema(contract: ResearchOutputContract | null): Record<string, unknown>;
17
+ /**
18
+ * The schema of the research cell as it is actually STORED — which is not the
19
+ * schema the model answers in.
20
+ *
21
+ * `finalizeResearchCellValue` rewrites the model's answer on the way to the
22
+ * cell: `citations` (1-based indices into the evidence block) are resolved
23
+ * against the sources Oxygen really fetched and replaced by `sources`, and a
24
+ * `validation` block records whether the prompt's own section contract held.
25
+ * So a consumer asking "what fields does this column expose" — the reference
26
+ * picker, a filter, a sub-column — must read THIS shape. Deriving those from
27
+ * `buildResearchOutputSchema` would offer `citations`, which no cell has, and
28
+ * hide `sources`, which every cell does.
29
+ */
30
+ export declare function buildResearchCellSchema(contract: ResearchOutputContract | null): Record<string, unknown>;
31
+ /**
32
+ * Is this research column's output schema OWNED BY THE SERVER, i.e. re-derived
33
+ * from the prompt rather than authored by hand?
34
+ *
35
+ * Three cases mean yes, and they are not interchangeable:
36
+ * - no schema at all — a column created before contracts existed, or one the
37
+ * author never touched;
38
+ * - exactly the legacy broad `{answer,found,confidence,citations}` shape,
39
+ * which is how a pre-contract column upgrades itself at run time and on its
40
+ * next edit with no data migration;
41
+ * - a stored `researchOutput` marker, which says a previous normalize derived
42
+ * the shape and a prompt edit must re-derive it.
43
+ *
44
+ * The normalizer and the runner both have to answer this, and they used to
45
+ * answer it with two hand-written copies that could drift into "the run used a
46
+ * different schema than the column was saved with".
47
+ */
48
+ export declare function usesServerManagedResearchSchema(definition: {
49
+ outputSchema?: unknown;
50
+ researchOutput?: unknown;
51
+ } | null | undefined): boolean;
52
+ /** Stored columns predating the prompt-derived contract carry this broad schema. */
53
+ export declare function isLegacyResearchOutputSchema(schema: unknown): boolean;
@@ -0,0 +1,196 @@
1
+ const MAX_RESEARCH_SECTIONS = 20;
2
+ const OUTPUT_SECTIONS_MARKER = /\b(?:return|provide|include|produce|output)\b.{0,80}\b(?:labeled|named|required|requested)?\s*(?:output\s+)?sections?\s*:/i;
3
+ const LABELED_SECTION = /^(?:[-*+]\s+|\d+[.)]\s+)?([A-Za-z][A-Za-z0-9_. /&()'’+\-]{1,79})\s*:\s*.*$/;
4
+ /**
5
+ * Derive the result shape the prompt explicitly asks the research column to
6
+ * return. This is intentionally conservative: prose containing colons is not a
7
+ * contract. We only accept a contiguous list after an explicit output-sections
8
+ * marker, and require at least two distinct labels.
9
+ */
10
+ export function deriveResearchOutputContract(prompt, options) {
11
+ const lines = prompt.replaceAll("\r\n", "\n").split("\n");
12
+ const markerIndex = lines.findIndex((line) => OUTPUT_SECTIONS_MARKER.test(line));
13
+ if (markerIndex < 0)
14
+ return null;
15
+ const sections = [];
16
+ const seen = new Set();
17
+ let started = false;
18
+ for (const rawLine of lines.slice(markerIndex + 1)) {
19
+ const line = rawLine.trim();
20
+ if (!line) {
21
+ if (started)
22
+ break;
23
+ continue;
24
+ }
25
+ const match = LABELED_SECTION.exec(line);
26
+ if (!match) {
27
+ if (started)
28
+ break;
29
+ continue;
30
+ }
31
+ started = true;
32
+ const heading = match[1].trim();
33
+ const normalized = heading.toLocaleLowerCase("en-US");
34
+ if (!seen.has(normalized)) {
35
+ seen.add(normalized);
36
+ sections.push(heading);
37
+ }
38
+ if (sections.length >= MAX_RESEARCH_SECTIONS)
39
+ break;
40
+ }
41
+ if (sections.length < 2)
42
+ return null;
43
+ return {
44
+ version: 1,
45
+ sections,
46
+ evidenceRequired: options.evidenceRequired,
47
+ derivedFrom: "prompt_labeled_sections",
48
+ };
49
+ }
50
+ export function buildResearchOutputSchema(contract) {
51
+ const answer = contract
52
+ ? {
53
+ type: "object",
54
+ additionalProperties: false,
55
+ properties: Object.fromEntries(contract.sections.map((section) => [
56
+ section,
57
+ {
58
+ type: ["string", "null"],
59
+ description: `The requested '${section}' section, or null when the evidence does not support it.`,
60
+ },
61
+ ])),
62
+ required: contract.sections,
63
+ }
64
+ : {
65
+ type: ["string", "null"],
66
+ description: "The answer, or null when the evidence does not contain it.",
67
+ };
68
+ return {
69
+ type: "object",
70
+ additionalProperties: false,
71
+ properties: {
72
+ answer,
73
+ found: {
74
+ type: "boolean",
75
+ description: "True only when the evidence actually answers the question.",
76
+ },
77
+ confidence: {
78
+ type: "string",
79
+ enum: ["high", "medium", "low"],
80
+ description: "How well the evidence supports the answer.",
81
+ },
82
+ citations: {
83
+ type: "array",
84
+ items: { type: "integer" },
85
+ description: "1-based indices of the <web_evidence> entries the answer relies on.",
86
+ },
87
+ },
88
+ required: ["answer", "found", "confidence", "citations"],
89
+ };
90
+ }
91
+ /**
92
+ * The schema of the research cell as it is actually STORED — which is not the
93
+ * schema the model answers in.
94
+ *
95
+ * `finalizeResearchCellValue` rewrites the model's answer on the way to the
96
+ * cell: `citations` (1-based indices into the evidence block) are resolved
97
+ * against the sources Oxygen really fetched and replaced by `sources`, and a
98
+ * `validation` block records whether the prompt's own section contract held.
99
+ * So a consumer asking "what fields does this column expose" — the reference
100
+ * picker, a filter, a sub-column — must read THIS shape. Deriving those from
101
+ * `buildResearchOutputSchema` would offer `citations`, which no cell has, and
102
+ * hide `sources`, which every cell does.
103
+ */
104
+ export function buildResearchCellSchema(contract) {
105
+ const modelSchema = buildResearchOutputSchema(contract);
106
+ const properties = isObject(modelSchema.properties) ? { ...modelSchema.properties } : {};
107
+ delete properties.citations;
108
+ return {
109
+ type: "object",
110
+ additionalProperties: false,
111
+ properties: {
112
+ ...properties,
113
+ sources: {
114
+ type: "array",
115
+ description: "The sources the answer actually grounded on, in evidence order.",
116
+ items: {
117
+ type: "object",
118
+ additionalProperties: true,
119
+ properties: {
120
+ title: { type: ["string", "null"], description: "Source title." },
121
+ url: { type: ["string", "null"], description: "Source URL." },
122
+ engine: { type: ["string", "null"], description: "Search engine that returned it." },
123
+ published_date: { type: ["string", "null"], description: "Publication date, when known." },
124
+ },
125
+ },
126
+ },
127
+ validation: {
128
+ type: "object",
129
+ additionalProperties: true,
130
+ description: "Whether the answer satisfied the prompt's section and evidence contract.",
131
+ properties: {
132
+ valid: { type: "boolean", description: "True when every contract check passed." },
133
+ },
134
+ },
135
+ },
136
+ required: ["answer", "found", "confidence", "sources"],
137
+ };
138
+ }
139
+ /**
140
+ * Is this research column's output schema OWNED BY THE SERVER, i.e. re-derived
141
+ * from the prompt rather than authored by hand?
142
+ *
143
+ * Three cases mean yes, and they are not interchangeable:
144
+ * - no schema at all — a column created before contracts existed, or one the
145
+ * author never touched;
146
+ * - exactly the legacy broad `{answer,found,confidence,citations}` shape,
147
+ * which is how a pre-contract column upgrades itself at run time and on its
148
+ * next edit with no data migration;
149
+ * - a stored `researchOutput` marker, which says a previous normalize derived
150
+ * the shape and a prompt edit must re-derive it.
151
+ *
152
+ * The normalizer and the runner both have to answer this, and they used to
153
+ * answer it with two hand-written copies that could drift into "the run used a
154
+ * different schema than the column was saved with".
155
+ */
156
+ export function usesServerManagedResearchSchema(definition) {
157
+ if (!definition)
158
+ return true;
159
+ const schema = definition.outputSchema;
160
+ return schema === undefined
161
+ || schema === null
162
+ || isLegacyResearchOutputSchema(schema)
163
+ || isObject(definition.researchOutput);
164
+ }
165
+ /** Stored columns predating the prompt-derived contract carry this broad schema. */
166
+ export function isLegacyResearchOutputSchema(schema) {
167
+ if (!isObject(schema))
168
+ return false;
169
+ if (schema.type !== "object" || schema.additionalProperties !== false)
170
+ return false;
171
+ const properties = isObject(schema.properties) ? schema.properties : null;
172
+ if (!properties || !hasExactMembers(Object.keys(properties), ["answer", "found", "confidence", "citations"])) {
173
+ return false;
174
+ }
175
+ if (!hasExactMembers(schema.required, ["answer", "found", "confidence", "citations"]))
176
+ return false;
177
+ if (!isObject(properties.answer) || !isObject(properties.found)
178
+ || !isObject(properties.confidence) || !isObject(properties.citations))
179
+ return false;
180
+ const answerType = properties.answer.type;
181
+ const citationItems = isObject(properties.citations.items) ? properties.citations.items : null;
182
+ return hasExactMembers(answerType, ["string", "null"])
183
+ && properties.found.type === "boolean"
184
+ && properties.confidence.type === "string"
185
+ && hasExactMembers(properties.confidence.enum, ["high", "medium", "low"])
186
+ && properties.citations.type === "array"
187
+ && citationItems?.type === "integer";
188
+ }
189
+ function hasExactMembers(value, expected) {
190
+ return Array.isArray(value)
191
+ && value.length === expected.length
192
+ && expected.every((member) => value.includes(member));
193
+ }
194
+ function isObject(value) {
195
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
196
+ }
@@ -25,8 +25,24 @@
25
25
  * "skips the seat check", it is one that holds a legacy entitlement which
26
26
  * SATISFIES the seat check. The gate always runs.
27
27
  */
28
- import { SENDING_SEAT_USD_CENTS, type SendingSeatKey } from "./pricing-snapshot.generated.js";
28
+ import { MANAGED_INBOX_USD_CENTS, SENDING_DOMAIN_USD_CENTS, SENDING_SEAT_USD_CENTS, type SendingSeatKey } from "./pricing-snapshot.generated.js";
29
29
  export { SENDING_SEAT_USD_CENTS };
30
+ /**
31
+ * Email infrastructure, kept next to the seats because the relationship between
32
+ * them is the whole design.
33
+ *
34
+ * MANAGED_INBOX_USD_CENTS is ALL-IN: it already contains the sending seat and
35
+ * warm-up. A customer buying an Oxygen mailbox buys ONE thing at ONE price and
36
+ * can never end up holding a mailbox with no seat to send from. A customer
37
+ * bringing their own mailbox pays SENDING_SEAT_USD_CENTS.email_sender instead.
38
+ * The two are alternatives, never both for the same mailbox.
39
+ *
40
+ * The practical consequence for the connect gate: email capacity is
41
+ * (email_sender seats) + (managed inbox slots). Counting only seats would refuse
42
+ * a customer who bought inboxes, which is exactly the assemble-the-parts failure
43
+ * this bundle exists to prevent.
44
+ */
45
+ export { MANAGED_INBOX_USD_CENTS, SENDING_DOMAIN_USD_CENTS };
30
46
  export type { SendingSeatKey };
31
47
  export declare const SENDING_SEAT_KEYS: readonly ["linkedin", "whatsapp", "phone_number", "email_sender"];
32
48
  export type SendingSeatDefinition = {
@@ -25,8 +25,24 @@
25
25
  * "skips the seat check", it is one that holds a legacy entitlement which
26
26
  * SATISFIES the seat check. The gate always runs.
27
27
  */
28
- import { SENDING_SEAT_USD_CENTS, } from "./pricing-snapshot.generated.js";
28
+ import { MANAGED_INBOX_USD_CENTS, SENDING_DOMAIN_USD_CENTS, SENDING_SEAT_USD_CENTS, } from "./pricing-snapshot.generated.js";
29
29
  export { SENDING_SEAT_USD_CENTS };
30
+ /**
31
+ * Email infrastructure, kept next to the seats because the relationship between
32
+ * them is the whole design.
33
+ *
34
+ * MANAGED_INBOX_USD_CENTS is ALL-IN: it already contains the sending seat and
35
+ * warm-up. A customer buying an Oxygen mailbox buys ONE thing at ONE price and
36
+ * can never end up holding a mailbox with no seat to send from. A customer
37
+ * bringing their own mailbox pays SENDING_SEAT_USD_CENTS.email_sender instead.
38
+ * The two are alternatives, never both for the same mailbox.
39
+ *
40
+ * The practical consequence for the connect gate: email capacity is
41
+ * (email_sender seats) + (managed inbox slots). Counting only seats would refuse
42
+ * a customer who bought inboxes, which is exactly the assemble-the-parts failure
43
+ * this bundle exists to prevent.
44
+ */
45
+ export { MANAGED_INBOX_USD_CENTS, SENDING_DOMAIN_USD_CENTS };
30
46
  export const SENDING_SEAT_KEYS = [
31
47
  "linkedin",
32
48
  "whatsapp",