@oxygen-agent/cli 1.861.0 → 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.
- package/README.md +1 -1
- package/dist/column-run-notices.d.ts +10 -0
- package/dist/column-run-notices.js +22 -0
- package/dist/command-manifest.js +4 -0
- package/dist/index.js +187 -73
- 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-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
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* version strings, phone numbers, URLs): {@link rescueNumericText} returns
|
|
15
15
|
* null for those.
|
|
16
16
|
*/
|
|
17
|
+
import { columnOutputPrimaryField } from "./column-output-fields.js";
|
|
17
18
|
import { isMarkdownColumnSemantic } from "./select-options.js";
|
|
18
19
|
const DEFAULT_LOCALE = "en-US";
|
|
19
20
|
const INTEGER_RE = /^-?\d{1,15}$/;
|
|
@@ -33,6 +34,10 @@ const WRAPPER_COLUMN_KINDS = new Set([
|
|
|
33
34
|
"tool",
|
|
34
35
|
"tool_enrichment",
|
|
35
36
|
"ai",
|
|
37
|
+
// A research cell is the {answer, found, confidence, sources} envelope. It was
|
|
38
|
+
// missing here, so every research cell rendered as raw JSON in the CLI, in MCP
|
|
39
|
+
// payloads and in the grid.
|
|
40
|
+
"research",
|
|
36
41
|
"action",
|
|
37
42
|
"formula",
|
|
38
43
|
"relation",
|
|
@@ -72,7 +77,7 @@ export function formatCellForDisplay(value, column, options) {
|
|
|
72
77
|
return markdownToPlainText(typeof value === "string" ? value : String(value));
|
|
73
78
|
}
|
|
74
79
|
if (isEnrichmentPayload(value) && columnHasWrapperKind(column)) {
|
|
75
|
-
const wrapped =
|
|
80
|
+
const wrapped = readWrappedValue(value, column);
|
|
76
81
|
if (wrapped !== undefined) {
|
|
77
82
|
return formatCellForDisplay(wrapped, column, options);
|
|
78
83
|
}
|
|
@@ -287,7 +292,23 @@ function formatTimestamp(value, surface) {
|
|
|
287
292
|
function isEnrichmentPayload(value) {
|
|
288
293
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
289
294
|
}
|
|
290
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Pull the carried value out of a wrapper cell.
|
|
297
|
+
*
|
|
298
|
+
* The DECLARED primary field wins: an enrichment cell says its value lives under
|
|
299
|
+
* `value`, a research cell says `answer`. That is the column telling us, not a
|
|
300
|
+
* key-name guess — which matters because the fallback below is a guess, and a
|
|
301
|
+
* manual jsonb cell a user typed themselves is deliberately left alone (it is
|
|
302
|
+
* not a wrapper kind, so it never reaches here at all).
|
|
303
|
+
*
|
|
304
|
+
* The key list stays as the fallback for wrapper kinds that declare no primary
|
|
305
|
+
* field — `tool`, `action`, `formula`, `relation` — whose payload shape comes
|
|
306
|
+
* from a provider rather than from us.
|
|
307
|
+
*/
|
|
308
|
+
function readWrappedValue(payload, column) {
|
|
309
|
+
const declared = column ? columnOutputPrimaryField(column) : null;
|
|
310
|
+
if (declared && declared in payload)
|
|
311
|
+
return payload[declared];
|
|
291
312
|
for (const key of ENRICHMENT_VALUE_KEYS) {
|
|
292
313
|
if (key in payload)
|
|
293
314
|
return payload[key];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export type ColumnOutputFieldSource = "ai_schema" | "research_contract" | "tool_schema" | "waterfall_fields" | "enrichment_envelope" | "bind_envelope" | "lookup_return";
|
|
2
|
+
/** The logical cell types a table column can hold. Mirrors WorkspaceDataType. */
|
|
3
|
+
export type ColumnOutputDataType = "text" | "numeric" | "boolean" | "jsonb" | "timestamptz";
|
|
4
|
+
export type ColumnOutputField = {
|
|
5
|
+
/** Dotted path from the CELL ROOT. Lossy when a segment contains a dot. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Pre-parsed path. Authoritative. */
|
|
8
|
+
segments: string[];
|
|
9
|
+
/** Stable identifier, unique within the column. */
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/** The table data type this field would have if it were its own column. */
|
|
13
|
+
dataType: ColumnOutputDataType | null;
|
|
14
|
+
/** The raw JSON-Schema `type`, when the field came from a schema. */
|
|
15
|
+
jsonType: string | null;
|
|
16
|
+
description: string | null;
|
|
17
|
+
/** Can this be written as a `{{column.path}}` token? */
|
|
18
|
+
referenceable: boolean;
|
|
19
|
+
/** Materialize target column key, when the config names one. */
|
|
20
|
+
targetColumn: string | null;
|
|
21
|
+
source: ColumnOutputFieldSource;
|
|
22
|
+
};
|
|
23
|
+
export type ColumnOutputContract = {
|
|
24
|
+
/** The schema the fields were derived from, when there was one. */
|
|
25
|
+
schema: Record<string, unknown> | null;
|
|
26
|
+
fields: ColumnOutputField[];
|
|
27
|
+
/**
|
|
28
|
+
* The field a BARE reference to this column resolves to.
|
|
29
|
+
*
|
|
30
|
+
* An enrichment cell is `{value, source, confidence, …}`; a user writing
|
|
31
|
+
* `{{work_email}}` means the email, not the envelope. Declared per kind rather
|
|
32
|
+
* than guessed from key names, so a manual jsonb cell that happens to have a
|
|
33
|
+
* `value` key is never silently unwrapped.
|
|
34
|
+
*/
|
|
35
|
+
primaryField: string | null;
|
|
36
|
+
/** True when the shape is re-derived from config rather than authored. */
|
|
37
|
+
serverManaged: boolean;
|
|
38
|
+
};
|
|
39
|
+
export type ColumnOutputColumnLike = {
|
|
40
|
+
key?: string | null;
|
|
41
|
+
label?: string | null;
|
|
42
|
+
kind?: string | null;
|
|
43
|
+
dataType?: string | null;
|
|
44
|
+
data_type?: string | null;
|
|
45
|
+
definition?: Record<string, unknown> | null;
|
|
46
|
+
};
|
|
47
|
+
export type ResolveColumnOutputOptions = {
|
|
48
|
+
/**
|
|
49
|
+
* Look up a native/composio tool's declared output schema. Server-only — the
|
|
50
|
+
* tool catalog must not reach a browser bundle — so this is injected rather
|
|
51
|
+
* than imported. Absent means tool columns resolve to no declared fields and
|
|
52
|
+
* fall back to sampling, never to an error.
|
|
53
|
+
*/
|
|
54
|
+
toolOutputSchema?: (toolId: string) => Record<string, unknown> | null;
|
|
55
|
+
/** Resolve another workspace table's columns, for `lookup` return columns. */
|
|
56
|
+
lookupSourceColumns?: (sourceTable: string) => Array<{
|
|
57
|
+
key: string;
|
|
58
|
+
label?: string;
|
|
59
|
+
dataType?: string | null;
|
|
60
|
+
}> | null;
|
|
61
|
+
};
|
|
62
|
+
export declare function humanizeFieldSegment(segment: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Map a JSON-Schema `type` onto the table data type the field would take as its
|
|
65
|
+
* own column. A union like `["string","null"]` is its non-null member — the
|
|
66
|
+
* nullability is the cell being empty, not a different type.
|
|
67
|
+
*/
|
|
68
|
+
export declare function dataTypeForJsonType(jsonType: unknown): ColumnOutputDataType | null;
|
|
69
|
+
type RawField = {
|
|
70
|
+
segments: string[];
|
|
71
|
+
jsonType: string | null;
|
|
72
|
+
description?: string | null;
|
|
73
|
+
label?: string;
|
|
74
|
+
dataType?: ColumnOutputDataType | null;
|
|
75
|
+
targetColumn?: string | null;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Enumerate the leaf and branch paths a JSON Schema declares.
|
|
79
|
+
*
|
|
80
|
+
* Object properties recurse. An array's item schema contributes index `0` as the
|
|
81
|
+
* representative element, which is what makes `sources.0.url` referenceable.
|
|
82
|
+
*
|
|
83
|
+
* Note this does NOT drop keys that are not dot-safe. An earlier version did,
|
|
84
|
+
* which silently hid every field of a research column whose prompt named its
|
|
85
|
+
* sections in plain English — while filters accepted those exact paths. The
|
|
86
|
+
* field is emitted and `referenceable` tells the caller what it can be used for.
|
|
87
|
+
*/
|
|
88
|
+
export declare function collectJsonSchemaFields(schema: unknown, prefix?: readonly string[], depth?: number): RawField[];
|
|
89
|
+
/**
|
|
90
|
+
* Follow a tool column's `outputPath` into its provider schema, so the declared
|
|
91
|
+
* fields describe what the CELL holds rather than what the provider returned.
|
|
92
|
+
*
|
|
93
|
+
* Returns null when the path leaves the schema — better no declared fields (and
|
|
94
|
+
* a sampling fallback) than fields pointing at a level the cell does not have.
|
|
95
|
+
*/
|
|
96
|
+
export declare function narrowSchemaAtOutputPath(schema: Record<string, unknown>, outputPath: string | null): Record<string, unknown> | null;
|
|
97
|
+
/**
|
|
98
|
+
* The declared output contract for one column.
|
|
99
|
+
*
|
|
100
|
+
* Never throws and never partially fails: a kind with nothing to declare, a tool
|
|
101
|
+
* whose catalog entry is missing, a lookup whose source table cannot be
|
|
102
|
+
* resolved — all return an empty contract, and the caller falls back to
|
|
103
|
+
* sampling or to showing the whole cell.
|
|
104
|
+
*/
|
|
105
|
+
export declare function resolveColumnOutputContract(column: ColumnOutputColumnLike, options?: ResolveColumnOutputOptions): ColumnOutputContract;
|
|
106
|
+
/** Just the fields. */
|
|
107
|
+
export declare function columnOutputFields(column: ColumnOutputColumnLike, options?: ResolveColumnOutputOptions): ColumnOutputField[];
|
|
108
|
+
/** The field a bare `{{column}}` reference resolves to, if the kind declares one. */
|
|
109
|
+
export declare function columnOutputPrimaryField(column: ColumnOutputColumnLike, options?: ResolveColumnOutputOptions): string | null;
|
|
110
|
+
/** Full `column.path` tokens this column exposes to a template. */
|
|
111
|
+
export declare function columnOutputReferenceKeys(column: ColumnOutputColumnLike, options?: ResolveColumnOutputOptions): string[];
|
|
112
|
+
/**
|
|
113
|
+
* Read one declared field out of a cell value.
|
|
114
|
+
*
|
|
115
|
+
* `parseJsonStrings` is on because a structured AI column stored as `text` holds
|
|
116
|
+
* its validated JSON as a string; without it every field of such a column reads
|
|
117
|
+
* blank.
|
|
118
|
+
*/
|
|
119
|
+
export declare function readColumnOutputField(cellValue: unknown, field: ColumnOutputField): unknown;
|
|
120
|
+
/** Does this column's cell type allow a JSON path to be pushed into SQL? */
|
|
121
|
+
export declare function columnOutputFieldsAreQueryable(column: ColumnOutputColumnLike): boolean;
|
|
122
|
+
export {};
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What fields does an output-producing column expose?
|
|
3
|
+
*
|
|
4
|
+
* A table column that runs — AI, research, tool, enrichment, bind, lookup —
|
|
5
|
+
* writes a structured value into one cell. Everything downstream then wants ONE
|
|
6
|
+
* field out of it: a prompt referencing `{{icp_fit.score}}`, a filter on
|
|
7
|
+
* `answer.confidence`, a sub-column in the grid, a CSV export of just the email.
|
|
8
|
+
*
|
|
9
|
+
* Until now each of those discovered fields its own way, and the most common way
|
|
10
|
+
* was to guess from whatever happened to be in a loaded row. That means a column
|
|
11
|
+
* that has never run exposes nothing, the field set changes as rows load, and
|
|
12
|
+
* two surfaces looking at the same column can disagree about what is in it.
|
|
13
|
+
*
|
|
14
|
+
* This module is the one answer, DECLARED rather than sampled: per kind, derive
|
|
15
|
+
* the fields from the column's own configuration — its output schema, its
|
|
16
|
+
* provider descriptor, its waterfall field list, its lookup return columns, or
|
|
17
|
+
* the fixed envelope its runtime writes. Sampling stays as a fallback for
|
|
18
|
+
* undeclared tool payloads; it is no longer the primary source.
|
|
19
|
+
*
|
|
20
|
+
* Two encodings of the same path travel together on purpose. `path` is the
|
|
21
|
+
* dotted string that filters, sorts and `{{mention}}` tokens already speak;
|
|
22
|
+
* `segments` is the pre-parsed array the value readers want. They are one
|
|
23
|
+
* producer with two encodings, not two sources of truth — but `segments` is
|
|
24
|
+
* AUTHORITATIVE, because `path` is lossy for exactly one real case: a research
|
|
25
|
+
* column names its output sections after free-text prompt labels, and a label
|
|
26
|
+
* may contain a dot or a space. Such a field carries `referenceable: false` and
|
|
27
|
+
* is display-only.
|
|
28
|
+
*/
|
|
29
|
+
import { buildResearchCellSchema, deriveResearchOutputContract, usesServerManagedResearchSchema, } from "./research-output-contract.js";
|
|
30
|
+
import { isTemplateSafePath, parseJsonPath, readJsonPath } from "./json-path.js";
|
|
31
|
+
const MAX_FIELDS = 40;
|
|
32
|
+
const MAX_SCHEMA_DEPTH = 5;
|
|
33
|
+
const EMPTY_CONTRACT = {
|
|
34
|
+
schema: null,
|
|
35
|
+
fields: [],
|
|
36
|
+
primaryField: null,
|
|
37
|
+
serverManaged: false,
|
|
38
|
+
};
|
|
39
|
+
function isRecord(value) {
|
|
40
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
function readString(value) {
|
|
43
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
44
|
+
}
|
|
45
|
+
export function humanizeFieldSegment(segment) {
|
|
46
|
+
const titled = segment
|
|
47
|
+
.split(/[_\s-]+/)
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
50
|
+
.join(" ");
|
|
51
|
+
return titled || segment;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The label a field is rendered with: its LAST segment, humanized.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately not the whole path. Every surface that shows a field already
|
|
57
|
+
* shows its parent beside it — the grid puts sub-columns under a band carrying
|
|
58
|
+
* the column name, the reference picker nests them under the column node, a
|
|
59
|
+
* filter chip renders "Column / Field". A full-path label there reads as
|
|
60
|
+
* "Company / Name" nested under "Company", which is the parent said twice.
|
|
61
|
+
* `path` carries the rest for anything that needs it flat.
|
|
62
|
+
*/
|
|
63
|
+
function defaultFieldLabel(segments) {
|
|
64
|
+
const last = segments[segments.length - 1];
|
|
65
|
+
return last ? humanizeFieldSegment(last) : "";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Map a JSON-Schema `type` onto the table data type the field would take as its
|
|
69
|
+
* own column. A union like `["string","null"]` is its non-null member — the
|
|
70
|
+
* nullability is the cell being empty, not a different type.
|
|
71
|
+
*/
|
|
72
|
+
export function dataTypeForJsonType(jsonType) {
|
|
73
|
+
const type = Array.isArray(jsonType)
|
|
74
|
+
? jsonType.find((entry) => typeof entry === "string" && entry !== "null")
|
|
75
|
+
: jsonType;
|
|
76
|
+
switch (type) {
|
|
77
|
+
case "string":
|
|
78
|
+
return "text";
|
|
79
|
+
case "number":
|
|
80
|
+
case "integer":
|
|
81
|
+
return "numeric";
|
|
82
|
+
case "boolean":
|
|
83
|
+
return "boolean";
|
|
84
|
+
case "object":
|
|
85
|
+
case "array":
|
|
86
|
+
return "jsonb";
|
|
87
|
+
default:
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function toField(raw, source) {
|
|
92
|
+
const path = raw.segments.join(".");
|
|
93
|
+
// A path round-trips only when no segment contains a dot. Research section
|
|
94
|
+
// labels are free text, so this is a real case rather than a defensive one.
|
|
95
|
+
const parsed = parseJsonPath(path, { emptySegment: "reject" });
|
|
96
|
+
const roundTrips = parsed.ok
|
|
97
|
+
&& parsed.segments.length === raw.segments.length
|
|
98
|
+
&& parsed.segments.every((segment, index) => segment === raw.segments[index]);
|
|
99
|
+
return {
|
|
100
|
+
path,
|
|
101
|
+
segments: raw.segments,
|
|
102
|
+
key: path,
|
|
103
|
+
label: raw.label ?? defaultFieldLabel(raw.segments),
|
|
104
|
+
dataType: raw.dataType ?? dataTypeForJsonType(raw.jsonType),
|
|
105
|
+
jsonType: raw.jsonType,
|
|
106
|
+
description: raw.description ?? null,
|
|
107
|
+
referenceable: roundTrips && isTemplateSafePath(path),
|
|
108
|
+
targetColumn: raw.targetColumn ?? null,
|
|
109
|
+
source,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Enumerate the leaf and branch paths a JSON Schema declares.
|
|
114
|
+
*
|
|
115
|
+
* Object properties recurse. An array's item schema contributes index `0` as the
|
|
116
|
+
* representative element, which is what makes `sources.0.url` referenceable.
|
|
117
|
+
*
|
|
118
|
+
* Note this does NOT drop keys that are not dot-safe. An earlier version did,
|
|
119
|
+
* which silently hid every field of a research column whose prompt named its
|
|
120
|
+
* sections in plain English — while filters accepted those exact paths. The
|
|
121
|
+
* field is emitted and `referenceable` tells the caller what it can be used for.
|
|
122
|
+
*/
|
|
123
|
+
export function collectJsonSchemaFields(schema, prefix = [], depth = 0) {
|
|
124
|
+
if (!isRecord(schema) || depth > MAX_SCHEMA_DEPTH)
|
|
125
|
+
return [];
|
|
126
|
+
const fields = [];
|
|
127
|
+
const properties = isRecord(schema.properties) ? schema.properties : null;
|
|
128
|
+
if (properties) {
|
|
129
|
+
for (const key of orderedPropertyKeys(schema, properties)) {
|
|
130
|
+
const segments = [...prefix, key];
|
|
131
|
+
const rawProperty = properties[key];
|
|
132
|
+
const property = isRecord(rawProperty) ? rawProperty : {};
|
|
133
|
+
fields.push({
|
|
134
|
+
segments,
|
|
135
|
+
// Kept RAW, including a nullable union like ["string","null"]:
|
|
136
|
+
// `dataTypeForJsonType` reads the non-null member, and collapsing the
|
|
137
|
+
// union to null here would lose the type of every derived field, since
|
|
138
|
+
// every one of them is nullable.
|
|
139
|
+
jsonType: normalizeJsonType(property.type),
|
|
140
|
+
description: readString(property.description),
|
|
141
|
+
});
|
|
142
|
+
fields.push(...collectJsonSchemaFields(property, segments, depth + 1));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (schema.type === "array" && isRecord(schema.items)) {
|
|
146
|
+
const segments = [...prefix, "0"];
|
|
147
|
+
fields.push({
|
|
148
|
+
segments,
|
|
149
|
+
jsonType: normalizeJsonType(schema.items.type),
|
|
150
|
+
description: readString(schema.items.description),
|
|
151
|
+
});
|
|
152
|
+
fields.push(...collectJsonSchemaFields(schema.items, segments, depth + 1));
|
|
153
|
+
}
|
|
154
|
+
return fields;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The schema's `required` array is the author's field order; `properties` is not.
|
|
158
|
+
*
|
|
159
|
+
* A definition round-trips through a jsonb column, and Postgres normalizes jsonb
|
|
160
|
+
* object keys by length then bytewise — so a prompt asking for Score, Reasoning,
|
|
161
|
+
* Risks comes back as Risks, Score, Reasoning. `required` is a JSON *array*, so
|
|
162
|
+
* it survives, and it is the order the model was asked to produce the fields in,
|
|
163
|
+
* which is the order a reader expects to see them.
|
|
164
|
+
*
|
|
165
|
+
* Falls back to property order when `required` does not cover the object, and
|
|
166
|
+
* appends anything it misses so a field can never be dropped by this.
|
|
167
|
+
*/
|
|
168
|
+
function orderedPropertyKeys(schema, properties) {
|
|
169
|
+
const keys = Object.keys(properties);
|
|
170
|
+
const required = Array.isArray(schema.required)
|
|
171
|
+
? schema.required.filter((entry) => typeof entry === "string")
|
|
172
|
+
: [];
|
|
173
|
+
if (required.length === 0)
|
|
174
|
+
return keys;
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
const ordered = [];
|
|
177
|
+
for (const key of required) {
|
|
178
|
+
if (!Object.hasOwn(properties, key) || seen.has(key))
|
|
179
|
+
continue;
|
|
180
|
+
seen.add(key);
|
|
181
|
+
ordered.push(key);
|
|
182
|
+
}
|
|
183
|
+
for (const key of keys) {
|
|
184
|
+
if (seen.has(key))
|
|
185
|
+
continue;
|
|
186
|
+
ordered.push(key);
|
|
187
|
+
}
|
|
188
|
+
return ordered;
|
|
189
|
+
}
|
|
190
|
+
/** A JSON-Schema `type`, keeping a nullable union intact. */
|
|
191
|
+
function normalizeJsonType(value) {
|
|
192
|
+
if (typeof value === "string")
|
|
193
|
+
return value;
|
|
194
|
+
if (!Array.isArray(value))
|
|
195
|
+
return null;
|
|
196
|
+
const named = value.find((entry) => typeof entry === "string" && entry !== "null");
|
|
197
|
+
return typeof named === "string" ? named : null;
|
|
198
|
+
}
|
|
199
|
+
// --- per-kind envelopes ----------------------------------------------------
|
|
200
|
+
/**
|
|
201
|
+
* The shape an enrichment cell is written with — see the cell assembly in the
|
|
202
|
+
* inline executor and its worker twin. `line_type` / `carrier` are deliberately
|
|
203
|
+
* absent: the runner surfaces those as companion COLUMNS, not as cell fields.
|
|
204
|
+
*/
|
|
205
|
+
const ENRICHMENT_CELL_SCHEMA = {
|
|
206
|
+
type: "object",
|
|
207
|
+
additionalProperties: true,
|
|
208
|
+
properties: {
|
|
209
|
+
value: { type: ["string", "null"], description: "The enriched value, when one was found." },
|
|
210
|
+
source: { type: ["string", "null"], description: "Provider that produced the winning value." },
|
|
211
|
+
confidence: { type: ["string", "null"], description: "Provider-reported confidence." },
|
|
212
|
+
status: { type: ["string", "null"], description: "Outcome of the waterfall." },
|
|
213
|
+
attempts: {
|
|
214
|
+
type: "array",
|
|
215
|
+
description: "Every provider leg tried, in order.",
|
|
216
|
+
items: {
|
|
217
|
+
type: "object",
|
|
218
|
+
additionalProperties: true,
|
|
219
|
+
properties: {
|
|
220
|
+
provider: { type: ["string", "null"], description: "Provider id." },
|
|
221
|
+
operation: { type: ["string", "null"], description: "Provider operation." },
|
|
222
|
+
status: { type: ["string", "null"], description: "Leg outcome." },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
cache_hit: { type: "boolean", description: "The winning leg was reused from cache and billed 0." },
|
|
227
|
+
credits_saved: { type: ["number", "null"], description: "Credits a live call would have cost." },
|
|
228
|
+
reused_from: { type: ["string", "null"], description: "When the reused value was enriched." },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
/** The shape a `bind` cell is written with. */
|
|
232
|
+
const BIND_CELL_SCHEMA = {
|
|
233
|
+
type: "object",
|
|
234
|
+
additionalProperties: true,
|
|
235
|
+
properties: {
|
|
236
|
+
status: { type: "string", description: "matched, created, no_match, or ambiguous." },
|
|
237
|
+
record_id: { type: ["string", "null"], description: "The CRM record this row resolved to." },
|
|
238
|
+
object: { type: ["string", "null"], description: "CRM object the row was bound against." },
|
|
239
|
+
confidence: { type: ["number", "null"], description: "Match confidence." },
|
|
240
|
+
bound_at: { type: ["string", "null"], description: "When the binding was written." },
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
// --- per-kind resolution ---------------------------------------------------
|
|
244
|
+
function readDefinition(column) {
|
|
245
|
+
return isRecord(column.definition) ? column.definition : {};
|
|
246
|
+
}
|
|
247
|
+
function readColumnDataType(column) {
|
|
248
|
+
return readString(column.dataType) ?? readString(column.data_type);
|
|
249
|
+
}
|
|
250
|
+
function aiContract(definition) {
|
|
251
|
+
const schema = isRecord(definition.outputSchema) ? definition.outputSchema : null;
|
|
252
|
+
if (!schema)
|
|
253
|
+
return EMPTY_CONTRACT;
|
|
254
|
+
return {
|
|
255
|
+
schema,
|
|
256
|
+
fields: collectJsonSchemaFields(schema).map((raw) => toField(raw, "ai_schema")),
|
|
257
|
+
primaryField: readString(isRecord(definition.aiOutput) ? definition.aiOutput.primaryField : null),
|
|
258
|
+
serverManaged: isRecord(definition.aiOutput),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function researchContract(definition) {
|
|
262
|
+
const serverManaged = usesServerManagedResearchSchema(definition);
|
|
263
|
+
const prompt = readString(definition.prompt) ?? "";
|
|
264
|
+
const webSearch = isRecord(definition.webSearch) ? definition.webSearch : null;
|
|
265
|
+
const contract = deriveResearchOutputContract(prompt, {
|
|
266
|
+
evidenceRequired: webSearch?.evidenceMode === "strict",
|
|
267
|
+
});
|
|
268
|
+
// The STORED shape, not the model-facing one: a research cell carries
|
|
269
|
+
// `sources`, never the `citations` the model answered with.
|
|
270
|
+
const schema = serverManaged
|
|
271
|
+
? buildResearchCellSchema(contract)
|
|
272
|
+
: (isRecord(definition.outputSchema) ? definition.outputSchema : null);
|
|
273
|
+
if (!schema)
|
|
274
|
+
return EMPTY_CONTRACT;
|
|
275
|
+
return {
|
|
276
|
+
schema,
|
|
277
|
+
fields: collectJsonSchemaFields(schema).map((raw) => toField(raw, "research_contract")),
|
|
278
|
+
// A sectioned answer has no single primary field; an unsectioned one is
|
|
279
|
+
// just its answer.
|
|
280
|
+
primaryField: contract === null ? "answer" : null,
|
|
281
|
+
serverManaged,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function waterfallContract(definition) {
|
|
285
|
+
const declared = Array.isArray(definition.fields) ? definition.fields : [];
|
|
286
|
+
const targets = isRecord(definition.targetColumns) ? definition.targetColumns : {};
|
|
287
|
+
const fields = declared
|
|
288
|
+
.filter((entry) => typeof entry === "string" && entry.length > 0)
|
|
289
|
+
.map((field) => toField({
|
|
290
|
+
// A waterfall cell normalizes to { fields: { <name>: { value, provider } } },
|
|
291
|
+
// so the value lives one level below the field name. Expressing it as a
|
|
292
|
+
// real root path is what makes these filterable and sortable at all.
|
|
293
|
+
segments: ["fields", field, "value"],
|
|
294
|
+
jsonType: null,
|
|
295
|
+
label: humanizeFieldSegment(field),
|
|
296
|
+
targetColumn: typeof targets[field] === "string" ? targets[field] : null,
|
|
297
|
+
}, "waterfall_fields"));
|
|
298
|
+
return { schema: null, fields, primaryField: null, serverManaged: true };
|
|
299
|
+
}
|
|
300
|
+
function toolContract(definition, options) {
|
|
301
|
+
const mode = readString(definition.mode) ?? "native";
|
|
302
|
+
if (mode === "company_enrichment_waterfall")
|
|
303
|
+
return waterfallContract(definition);
|
|
304
|
+
if (mode === "callable_table") {
|
|
305
|
+
const mapping = isRecord(definition.outputMapping) ? definition.outputMapping : {};
|
|
306
|
+
const fields = Object.entries(mapping).map(([outputKey, target]) => toField({
|
|
307
|
+
segments: [outputKey],
|
|
308
|
+
jsonType: null,
|
|
309
|
+
targetColumn: typeof target === "string" ? target : null,
|
|
310
|
+
}, "tool_schema"));
|
|
311
|
+
return { schema: null, fields, primaryField: null, serverManaged: true };
|
|
312
|
+
}
|
|
313
|
+
const toolId = readString(definition.toolId);
|
|
314
|
+
const schema = toolId ? options.toolOutputSchema?.(toolId) ?? null : null;
|
|
315
|
+
if (!schema)
|
|
316
|
+
return EMPTY_CONTRACT;
|
|
317
|
+
// `applyToolColumnOutputPath` already projected `outputPath` before the cell
|
|
318
|
+
// was written, so the declared paths are relative to that projection, not to
|
|
319
|
+
// the provider's whole response.
|
|
320
|
+
const narrowed = narrowSchemaAtOutputPath(schema, readString(definition.outputPath));
|
|
321
|
+
if (!narrowed)
|
|
322
|
+
return EMPTY_CONTRACT;
|
|
323
|
+
return {
|
|
324
|
+
schema: narrowed,
|
|
325
|
+
fields: collectJsonSchemaFields(narrowed).map((raw) => toField(raw, "tool_schema")),
|
|
326
|
+
primaryField: null,
|
|
327
|
+
serverManaged: true,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Follow a tool column's `outputPath` into its provider schema, so the declared
|
|
332
|
+
* fields describe what the CELL holds rather than what the provider returned.
|
|
333
|
+
*
|
|
334
|
+
* Returns null when the path leaves the schema — better no declared fields (and
|
|
335
|
+
* a sampling fallback) than fields pointing at a level the cell does not have.
|
|
336
|
+
*/
|
|
337
|
+
export function narrowSchemaAtOutputPath(schema, outputPath) {
|
|
338
|
+
if (!outputPath)
|
|
339
|
+
return schema;
|
|
340
|
+
const parsed = parseJsonPath(outputPath, { emptySegment: "reject" });
|
|
341
|
+
if (!parsed.ok)
|
|
342
|
+
return null;
|
|
343
|
+
let current = schema;
|
|
344
|
+
for (const segment of parsed.segments) {
|
|
345
|
+
if (!current)
|
|
346
|
+
return null;
|
|
347
|
+
if (current.type === "array" && isRecord(current.items) && /^\d+$/.test(segment)) {
|
|
348
|
+
current = current.items;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const properties = isRecord(current.properties)
|
|
352
|
+
? current.properties
|
|
353
|
+
: null;
|
|
354
|
+
const next = properties?.[segment];
|
|
355
|
+
current = isRecord(next) ? next : null;
|
|
356
|
+
}
|
|
357
|
+
return current;
|
|
358
|
+
}
|
|
359
|
+
function lookupContract(definition, options) {
|
|
360
|
+
if (definition.mode !== "first_match")
|
|
361
|
+
return EMPTY_CONTRACT;
|
|
362
|
+
const returnColumns = Array.isArray(definition.returnColumns)
|
|
363
|
+
? definition.returnColumns.filter((entry) => typeof entry === "string")
|
|
364
|
+
: [];
|
|
365
|
+
// A single return column IS the cell — a scalar, with nothing to decompose.
|
|
366
|
+
if (returnColumns.length < 2)
|
|
367
|
+
return EMPTY_CONTRACT;
|
|
368
|
+
const sourceTable = readString(definition.sourceTable);
|
|
369
|
+
const sourceColumns = sourceTable ? options.lookupSourceColumns?.(sourceTable) ?? null : null;
|
|
370
|
+
const byKey = new Map((sourceColumns ?? []).map((column) => [column.key, column]));
|
|
371
|
+
const fields = returnColumns.map((key) => {
|
|
372
|
+
const source = byKey.get(key);
|
|
373
|
+
return toField({
|
|
374
|
+
segments: [key],
|
|
375
|
+
jsonType: null,
|
|
376
|
+
label: source?.label ?? humanizeFieldSegment(key),
|
|
377
|
+
dataType: source?.dataType ?? null,
|
|
378
|
+
}, "lookup_return");
|
|
379
|
+
});
|
|
380
|
+
return { schema: null, fields, primaryField: null, serverManaged: true };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The declared output contract for one column.
|
|
384
|
+
*
|
|
385
|
+
* Never throws and never partially fails: a kind with nothing to declare, a tool
|
|
386
|
+
* whose catalog entry is missing, a lookup whose source table cannot be
|
|
387
|
+
* resolved — all return an empty contract, and the caller falls back to
|
|
388
|
+
* sampling or to showing the whole cell.
|
|
389
|
+
*/
|
|
390
|
+
export function resolveColumnOutputContract(column, options = {}) {
|
|
391
|
+
const definition = readDefinition(column);
|
|
392
|
+
const contract = resolveByKind(column, definition, options);
|
|
393
|
+
if (contract.fields.length <= MAX_FIELDS)
|
|
394
|
+
return contract;
|
|
395
|
+
return { ...contract, fields: contract.fields.slice(0, MAX_FIELDS) };
|
|
396
|
+
}
|
|
397
|
+
function resolveByKind(column, definition, options) {
|
|
398
|
+
switch (column.kind) {
|
|
399
|
+
case "ai":
|
|
400
|
+
return aiContract(definition);
|
|
401
|
+
case "research":
|
|
402
|
+
return researchContract(definition);
|
|
403
|
+
case "tool":
|
|
404
|
+
return toolContract(definition, options);
|
|
405
|
+
case "enrichment":
|
|
406
|
+
return {
|
|
407
|
+
schema: ENRICHMENT_CELL_SCHEMA,
|
|
408
|
+
fields: collectJsonSchemaFields(ENRICHMENT_CELL_SCHEMA).map((raw) => toField(raw, "enrichment_envelope")),
|
|
409
|
+
// `{{work_email}}` means the email, not the envelope around it.
|
|
410
|
+
primaryField: "value",
|
|
411
|
+
serverManaged: true,
|
|
412
|
+
};
|
|
413
|
+
case "bind":
|
|
414
|
+
return {
|
|
415
|
+
schema: BIND_CELL_SCHEMA,
|
|
416
|
+
fields: collectJsonSchemaFields(BIND_CELL_SCHEMA).map((raw) => toField(raw, "bind_envelope")),
|
|
417
|
+
// Deliberately none: promoting `status` to the bare reference would
|
|
418
|
+
// change how every existing bind cell renders, which is its own opt-in
|
|
419
|
+
// decision rather than a side effect of declaring fields.
|
|
420
|
+
primaryField: null,
|
|
421
|
+
serverManaged: true,
|
|
422
|
+
};
|
|
423
|
+
case "lookup":
|
|
424
|
+
return lookupContract(definition, options);
|
|
425
|
+
default:
|
|
426
|
+
return EMPTY_CONTRACT;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Just the fields. */
|
|
430
|
+
export function columnOutputFields(column, options = {}) {
|
|
431
|
+
return resolveColumnOutputContract(column, options).fields;
|
|
432
|
+
}
|
|
433
|
+
/** The field a bare `{{column}}` reference resolves to, if the kind declares one. */
|
|
434
|
+
export function columnOutputPrimaryField(column, options = {}) {
|
|
435
|
+
return resolveColumnOutputContract(column, options).primaryField;
|
|
436
|
+
}
|
|
437
|
+
/** Full `column.path` tokens this column exposes to a template. */
|
|
438
|
+
export function columnOutputReferenceKeys(column, options = {}) {
|
|
439
|
+
const key = readString(column.key);
|
|
440
|
+
if (!key)
|
|
441
|
+
return [];
|
|
442
|
+
return columnOutputFields(column, options)
|
|
443
|
+
.filter((field) => field.referenceable)
|
|
444
|
+
.map((field) => `${key}.${field.path}`);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Read one declared field out of a cell value.
|
|
448
|
+
*
|
|
449
|
+
* `parseJsonStrings` is on because a structured AI column stored as `text` holds
|
|
450
|
+
* its validated JSON as a string; without it every field of such a column reads
|
|
451
|
+
* blank.
|
|
452
|
+
*/
|
|
453
|
+
export function readColumnOutputField(cellValue, field) {
|
|
454
|
+
return readJsonPath(cellValue, field.segments, { parseJsonStrings: true });
|
|
455
|
+
}
|
|
456
|
+
/** Does this column's cell type allow a JSON path to be pushed into SQL? */
|
|
457
|
+
export function columnOutputFieldsAreQueryable(column) {
|
|
458
|
+
return readColumnDataType(column) === "jsonb";
|
|
459
|
+
}
|
|
@@ -51,6 +51,7 @@ export * from "./person-name.js";
|
|
|
51
51
|
export * from "./recipes.js";
|
|
52
52
|
export * from "./sequence-template.js";
|
|
53
53
|
export * from "./sequence-crm-events.js";
|
|
54
|
+
export * from "./sequence-failures.js";
|
|
54
55
|
export * from "./sequence-hubspot-sync.js";
|
|
55
56
|
export * from "./sequence-terminal-events.js";
|
|
56
57
|
export * from "./call-outcomes.js";
|
|
@@ -51,6 +51,7 @@ export * from "./person-name.js";
|
|
|
51
51
|
export * from "./recipes.js";
|
|
52
52
|
export * from "./sequence-template.js";
|
|
53
53
|
export * from "./sequence-crm-events.js";
|
|
54
|
+
export * from "./sequence-failures.js";
|
|
54
55
|
export * from "./sequence-hubspot-sync.js";
|
|
55
56
|
export * from "./sequence-terminal-events.js";
|
|
56
57
|
export * from "./call-outcomes.js";
|