@oxygen-agent/cli 1.861.1 → 1.883.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.
- package/README.md +1 -1
- package/dist/column-run-notices.d.ts +10 -0
- package/dist/column-run-notices.js +22 -0
- package/dist/index.js +188 -83
- package/dist/local-custom-http-column.js +12 -37
- package/node_modules/@oxygen/formula/dist/formula-functions.js +10 -24
- package/node_modules/@oxygen/shared/dist/cell-format.js +23 -2
- package/node_modules/@oxygen/shared/dist/column-output-fields.d.ts +122 -0
- package/node_modules/@oxygen/shared/dist/column-output-fields.js +459 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/json-path.d.ts +109 -0
- package/node_modules/@oxygen/shared/dist/json-path.js +177 -0
- package/node_modules/@oxygen/shared/dist/log-collapse.d.ts +6 -3
- package/node_modules/@oxygen/shared/dist/log-collapse.js +6 -3
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +7 -1
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +13 -0
- package/node_modules/@oxygen/shared/dist/research-output-contract.d.ts +53 -0
- package/node_modules/@oxygen/shared/dist/research-output-contract.js +196 -0
- package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +17 -1
- package/node_modules/@oxygen/shared/dist/sending-seats.js +17 -1
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +47 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +301 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +119 -2
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +15 -0
- 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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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) {
|
|
@@ -39,6 +39,14 @@ export declare const PRICING_SHEET_VERIFIED = "2026-07";
|
|
|
39
39
|
export declare const PRICING_CREDITS_PER_USD = 1000;
|
|
40
40
|
/** On-demand top-up price per 1,000 credits (USD); subscriptions pay $1.00. */
|
|
41
41
|
export declare const TOPUP_USD_PER_1K = 1.25;
|
|
42
|
+
export declare const SENDING_SEAT_USD_CENTS: {
|
|
43
|
+
readonly linkedin: 3000;
|
|
44
|
+
readonly whatsapp: 3000;
|
|
45
|
+
readonly phone_number: 1000;
|
|
46
|
+
readonly email_sender: 100;
|
|
47
|
+
};
|
|
48
|
+
export declare const MANAGED_INBOX_USD_CENTS = 400;
|
|
49
|
+
export declare const SENDING_DOMAIN_USD_CENTS = 1250;
|
|
42
50
|
export declare const SUBSCRIPTION_USD_PER_1K = 1;
|
|
43
51
|
/**
|
|
44
52
|
* AI column runs, honest 5× of modeled tier COGS.
|
|
@@ -34,12 +34,18 @@
|
|
|
34
34
|
* loadPricingBook() instead of importing this file, so a staff re-price at
|
|
35
35
|
* /admin/costs takes effect without a deploy.
|
|
36
36
|
*/
|
|
37
|
-
import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, DELIVERABILITY_INCLUDED_TESTS_PER_INBOX as SNAPSHOT_DELIVERABILITY_INCLUDED_TESTS_PER_INBOX, DELIVERABILITY_SEAT_CREDITS_PER_MONTH as SNAPSHOT_DELIVERABILITY_SEAT_CREDITS_PER_MONTH, EGRESS_IP_CREDITS_PER_MONTH as SNAPSHOT_EGRESS_IP_CREDITS_PER_MONTH, ENRICHMENT_CREDITS_PER_ROW_TYPICAL as SNAPSHOT_ENRICHMENT_CREDITS_PER_ROW_TYPICAL, LINKEDIN_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_ACCOUNT_CREDITS_PER_MONTH, LINKEDIN_ACTION_CREDITS as SNAPSHOT_LINKEDIN_ACTION_CREDITS, LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH, MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL as SNAPSHOT_MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL, MANAGED_DOMAIN_MARKUP as SNAPSHOT_MANAGED_DOMAIN_MARKUP, MANAGED_INBOX_CREDITS_PER_MONTH as SNAPSHOT_MANAGED_INBOX_CREDITS_PER_MONTH, MANAGED_INBOX_MARKUP as SNAPSHOT_MANAGED_INBOX_MARKUP, PLACEMENT_TEST_OVERAGE_CREDITS as SNAPSHOT_PLACEMENT_TEST_OVERAGE_CREDITS, PRICE_SHEET_VERIFIED as SNAPSHOT_PRICE_SHEET_VERIFIED, SEND_CREDITS as SNAPSHOT_SEND_CREDITS, SUBSCRIPTION_USD_PER_1K as SNAPSHOT_SUBSCRIPTION_USD_PER_1K, TOPUP_USD_PER_1K as SNAPSHOT_TOPUP_USD_PER_1K, VOICE_CREDITS_PER_AMD_CALL as SNAPSHOT_VOICE_CREDITS_PER_AMD_CALL, VOICE_CREDITS_PER_MINUTE as SNAPSHOT_VOICE_CREDITS_PER_MINUTE, VOICE_MARKUP as SNAPSHOT_VOICE_MARKUP, VOICE_NUMBER_CREDITS_PER_MONTH as SNAPSHOT_VOICE_NUMBER_CREDITS_PER_MONTH, WHATSAPP_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_WHATSAPP_ACCOUNT_CREDITS_PER_MONTH, } from "./pricing-snapshot.generated.js";
|
|
37
|
+
import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, DELIVERABILITY_INCLUDED_TESTS_PER_INBOX as SNAPSHOT_DELIVERABILITY_INCLUDED_TESTS_PER_INBOX, DELIVERABILITY_SEAT_CREDITS_PER_MONTH as SNAPSHOT_DELIVERABILITY_SEAT_CREDITS_PER_MONTH, EGRESS_IP_CREDITS_PER_MONTH as SNAPSHOT_EGRESS_IP_CREDITS_PER_MONTH, MANAGED_INBOX_USD_CENTS as SNAPSHOT_MANAGED_INBOX_USD_CENTS, SENDING_DOMAIN_USD_CENTS as SNAPSHOT_SENDING_DOMAIN_USD_CENTS, SENDING_SEAT_USD_CENTS as SNAPSHOT_SENDING_SEAT_USD_CENTS, ENRICHMENT_CREDITS_PER_ROW_TYPICAL as SNAPSHOT_ENRICHMENT_CREDITS_PER_ROW_TYPICAL, LINKEDIN_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_ACCOUNT_CREDITS_PER_MONTH, LINKEDIN_ACTION_CREDITS as SNAPSHOT_LINKEDIN_ACTION_CREDITS, LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH as SNAPSHOT_LINKEDIN_TYPICAL_ACTIONS_CREDITS_PER_MONTH, MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL as SNAPSHOT_MANAGED_DOMAIN_CREDITS_PER_YEAR_TYPICAL, MANAGED_DOMAIN_MARKUP as SNAPSHOT_MANAGED_DOMAIN_MARKUP, MANAGED_INBOX_CREDITS_PER_MONTH as SNAPSHOT_MANAGED_INBOX_CREDITS_PER_MONTH, MANAGED_INBOX_MARKUP as SNAPSHOT_MANAGED_INBOX_MARKUP, PLACEMENT_TEST_OVERAGE_CREDITS as SNAPSHOT_PLACEMENT_TEST_OVERAGE_CREDITS, PRICE_SHEET_VERIFIED as SNAPSHOT_PRICE_SHEET_VERIFIED, SEND_CREDITS as SNAPSHOT_SEND_CREDITS, SUBSCRIPTION_USD_PER_1K as SNAPSHOT_SUBSCRIPTION_USD_PER_1K, TOPUP_USD_PER_1K as SNAPSHOT_TOPUP_USD_PER_1K, VOICE_CREDITS_PER_AMD_CALL as SNAPSHOT_VOICE_CREDITS_PER_AMD_CALL, VOICE_CREDITS_PER_MINUTE as SNAPSHOT_VOICE_CREDITS_PER_MINUTE, VOICE_MARKUP as SNAPSHOT_VOICE_MARKUP, VOICE_NUMBER_CREDITS_PER_MONTH as SNAPSHOT_VOICE_NUMBER_CREDITS_PER_MONTH, WHATSAPP_ACCOUNT_CREDITS_PER_MONTH as SNAPSHOT_WHATSAPP_ACCOUNT_CREDITS_PER_MONTH, } from "./pricing-snapshot.generated.js";
|
|
38
38
|
export { creditsToUsd, usdToCredits } from "./pricing-snapshot.generated.js";
|
|
39
39
|
export const PRICING_SHEET_VERIFIED = SNAPSHOT_PRICE_SHEET_VERIFIED;
|
|
40
40
|
export const PRICING_CREDITS_PER_USD = SNAPSHOT_CREDITS_PER_USD;
|
|
41
41
|
/** On-demand top-up price per 1,000 credits (USD); subscriptions pay $1.00. */
|
|
42
42
|
export const TOPUP_USD_PER_1K = SNAPSHOT_TOPUP_USD_PER_1K;
|
|
43
|
+
// Dollar-priced CAPACITY, re-exported on the client-safe subpath so the pricing
|
|
44
|
+
// calculator and the marketing sections quote the same numbers the product
|
|
45
|
+
// charges. These are not credits and must never be converted as if they were.
|
|
46
|
+
export const SENDING_SEAT_USD_CENTS = SNAPSHOT_SENDING_SEAT_USD_CENTS;
|
|
47
|
+
export const MANAGED_INBOX_USD_CENTS = SNAPSHOT_MANAGED_INBOX_USD_CENTS;
|
|
48
|
+
export const SENDING_DOMAIN_USD_CENTS = SNAPSHOT_SENDING_DOMAIN_USD_CENTS;
|
|
43
49
|
export const SUBSCRIPTION_USD_PER_1K = SNAPSHOT_SUBSCRIPTION_USD_PER_1K;
|
|
44
50
|
/**
|
|
45
51
|
* AI column runs, honest 5× of modeled tier COGS.
|
|
@@ -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
|
+
}
|