@oxygen-agent/cli 1.377.3 → 1.575.19

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 (95) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +11 -0
  3. package/dist/column-run-notices.js +37 -0
  4. package/dist/command-manifest.js +13 -8
  5. package/dist/help.js +78 -16
  6. package/dist/index.js +3450 -447
  7. package/dist/skills.js +106 -1
  8. package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
  9. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  10. package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
  11. package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
  12. package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
  13. package/node_modules/@oxygen/formula/dist/expression.js +428 -0
  14. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
  15. package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
  16. package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
  17. package/node_modules/@oxygen/formula/dist/index.js +17 -0
  18. package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
  19. package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
  20. package/node_modules/@oxygen/formula/package.json +26 -0
  21. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
  22. package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
  24. package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
  25. package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -5
  26. package/node_modules/@oxygen/shared/dist/billing.js +185 -8
  27. package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
  28. package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
  29. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  30. package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
  31. package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
  32. package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
  33. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
  34. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
  36. package/node_modules/@oxygen/shared/dist/directory.js +1 -0
  37. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +15 -0
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.js +19 -0
  39. package/node_modules/@oxygen/shared/dist/index.d.ts +9 -0
  40. package/node_modules/@oxygen/shared/dist/index.js +9 -0
  41. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
  42. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
  43. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
  44. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
  45. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
  46. package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
  47. package/node_modules/@oxygen/shared/dist/log.js +41 -2
  48. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
  49. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
  50. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
  51. package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
  52. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
  53. package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
  54. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
  55. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
  56. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
  57. package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
  58. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
  59. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  60. package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
  61. package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
  62. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
  63. package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
  64. package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
  65. package/node_modules/@oxygen/shared/dist/tags.js +122 -6
  66. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  67. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  68. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
  69. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
  70. package/node_modules/@oxygen/shared/package.json +95 -0
  71. package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
  72. package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
  73. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
  74. package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
  75. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
  76. package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
  77. package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
  78. package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
  79. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
  80. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
  81. package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
  83. package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
  84. package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
  85. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
  86. package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
  87. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
  88. package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
  89. package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
  90. package/node_modules/@oxygen/workflows/dist/index.js +179 -13
  91. package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
  92. package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
  93. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
  94. package/node_modules/@oxygen/workflows/package.json +4 -0
  95. package/package.json +7 -5
@@ -0,0 +1,1100 @@
1
+ import { OxygenError } from "@oxygen/shared/cli-result";
2
+ import { isRecord } from "./coerce.js";
3
+ import { normalizeDomain, normalizeEmail, normalizeLinkedinUrl, } from "./value-normalizers.js";
4
+ export const MAX_FORMULA_REGEX_PATTERN_LENGTH = 256;
5
+ export const MAX_FORMULA_REGEX_INPUT_LENGTH = 20_000;
6
+ // ---------------------------------------------------------------------------
7
+ // Shared coercion helpers (used by the registry AND the runner's operators)
8
+ // ---------------------------------------------------------------------------
9
+ export function formulaExpressionError(message, details) {
10
+ return new OxygenError("invalid_formula_expression", message, {
11
+ details,
12
+ exitCode: 1,
13
+ });
14
+ }
15
+ export function isBlankFormulaValue(value) {
16
+ if (value === null || value === undefined)
17
+ return true;
18
+ if (typeof value === "string")
19
+ return value.trim().length === 0;
20
+ if (Array.isArray(value))
21
+ return value.length === 0;
22
+ return false;
23
+ }
24
+ export function stringifyFormulaValue(value) {
25
+ if (value === null || value === undefined)
26
+ return "";
27
+ if (typeof value === "string")
28
+ return value;
29
+ if (typeof value === "number" || typeof value === "boolean")
30
+ return String(value);
31
+ return JSON.stringify(value);
32
+ }
33
+ export function readFormulaNumber(value, operator) {
34
+ if (typeof value === "number" && Number.isFinite(value))
35
+ return value;
36
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
37
+ return Number(value);
38
+ }
39
+ throw formulaExpressionError("Formula numeric operation requires numeric values.", {
40
+ operator,
41
+ value,
42
+ });
43
+ }
44
+ export function toFormulaBoolean(value) {
45
+ if (typeof value === "boolean")
46
+ return value;
47
+ if (typeof value === "number")
48
+ return value !== 0;
49
+ if (typeof value === "string") {
50
+ const normalized = value.trim().toLowerCase();
51
+ if (["true", "yes", "y", "1"].includes(normalized))
52
+ return true;
53
+ if (["false", "no", "n", "0", ""].includes(normalized))
54
+ return false;
55
+ }
56
+ return Boolean(value);
57
+ }
58
+ export function compareFormulaValues(left, right) {
59
+ if (typeof left === "number" || typeof right === "number") {
60
+ return readFormulaNumber(left, "comparison") - readFormulaNumber(right, "comparison");
61
+ }
62
+ return String(left ?? "").localeCompare(String(right ?? ""));
63
+ }
64
+ export function formulaValuesEqual(left, right) {
65
+ if ((typeof left === "number" || typeof right === "number")
66
+ && !isBlankFormulaValue(left)
67
+ && !isBlankFormulaValue(right)) {
68
+ return readFormulaNumber(left, "comparison") === readFormulaNumber(right, "comparison");
69
+ }
70
+ return left === right;
71
+ }
72
+ export function readFormulaJsonPath(value, rawPath) {
73
+ const path = rawPath.trim();
74
+ if (!path)
75
+ return value ?? null;
76
+ let current = value;
77
+ for (const rawSegment of path.split(".")) {
78
+ const segment = rawSegment.trim();
79
+ if (!segment) {
80
+ throw formulaExpressionError("JSON path contains an empty segment.", {
81
+ path: rawPath,
82
+ });
83
+ }
84
+ if (current === null || current === undefined)
85
+ return null;
86
+ if (Array.isArray(current) && /^\d+$/.test(segment)) {
87
+ current = current[Number(segment)] ?? null;
88
+ continue;
89
+ }
90
+ if (isRecord(current) && Object.hasOwn(current, segment)) {
91
+ current = current[segment];
92
+ continue;
93
+ }
94
+ return null;
95
+ }
96
+ return current ?? null;
97
+ }
98
+ export function checkFormulaFunctionArity(spec, received) {
99
+ const withinMin = received >= spec.minArgs;
100
+ const withinMax = spec.maxArgs === null || received <= spec.maxArgs;
101
+ if (withinMin && withinMax)
102
+ return;
103
+ throw formulaExpressionError("Formula function received the wrong number of arguments.", {
104
+ function: spec.name,
105
+ ...(spec.maxArgs === spec.minArgs ? { expected: spec.minArgs } : {}),
106
+ min_args: spec.minArgs,
107
+ max_args: spec.maxArgs,
108
+ received,
109
+ });
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Local helpers
113
+ // ---------------------------------------------------------------------------
114
+ function asText(value) {
115
+ return value === null || value === undefined ? "" : stringifyFormulaValue(value);
116
+ }
117
+ function blankToNull(value, transform) {
118
+ return isBlankFormulaValue(value) ? null : transform(value);
119
+ }
120
+ function compileFormulaRegex(pattern, functionName, flags) {
121
+ if (typeof pattern !== "string" || !pattern) {
122
+ throw formulaExpressionError("Regular-expression functions require a non-empty string pattern.", {
123
+ function: functionName,
124
+ });
125
+ }
126
+ if (pattern.length > MAX_FORMULA_REGEX_PATTERN_LENGTH) {
127
+ throw formulaExpressionError("Regular-expression pattern is too long.", {
128
+ function: functionName,
129
+ max_length: MAX_FORMULA_REGEX_PATTERN_LENGTH,
130
+ received_length: pattern.length,
131
+ });
132
+ }
133
+ try {
134
+ return new RegExp(pattern, flags);
135
+ }
136
+ catch (error) {
137
+ throw formulaExpressionError("Invalid regular-expression pattern.", {
138
+ function: functionName,
139
+ pattern,
140
+ parse_error: error instanceof Error ? error.message : String(error),
141
+ });
142
+ }
143
+ }
144
+ function readRegexInput(value, functionName) {
145
+ const text = asText(value);
146
+ if (text.length > MAX_FORMULA_REGEX_INPUT_LENGTH) {
147
+ throw formulaExpressionError("Regular-expression input is too long.", {
148
+ function: functionName,
149
+ max_length: MAX_FORMULA_REGEX_INPUT_LENGTH,
150
+ received_length: text.length,
151
+ });
152
+ }
153
+ return text;
154
+ }
155
+ function collectNumericArgs(args, functionName) {
156
+ const values = [];
157
+ for (const arg of args) {
158
+ if (Array.isArray(arg)) {
159
+ for (const element of arg) {
160
+ if (isBlankFormulaValue(element))
161
+ continue;
162
+ values.push(readFormulaNumber(element, functionName));
163
+ }
164
+ continue;
165
+ }
166
+ if (isBlankFormulaValue(arg))
167
+ continue;
168
+ values.push(readFormulaNumber(arg, functionName));
169
+ }
170
+ return values;
171
+ }
172
+ const DATE_UNIT_MS = {
173
+ seconds: 1_000,
174
+ minutes: 60_000,
175
+ hours: 3_600_000,
176
+ days: 86_400_000,
177
+ weeks: 604_800_000,
178
+ };
179
+ export const FORMULA_DATE_UNITS = [
180
+ "seconds",
181
+ "minutes",
182
+ "hours",
183
+ "days",
184
+ "weeks",
185
+ "months",
186
+ "years",
187
+ ];
188
+ function parseFormulaDate(value) {
189
+ if (isBlankFormulaValue(value))
190
+ return null;
191
+ if (value instanceof Date)
192
+ return Number.isNaN(value.getTime()) ? null : value;
193
+ if (typeof value === "number" && Number.isFinite(value)) {
194
+ const fromEpochMs = new Date(value);
195
+ return Number.isNaN(fromEpochMs.getTime()) ? null : fromEpochMs;
196
+ }
197
+ if (typeof value !== "string")
198
+ return null;
199
+ const trimmed = value.trim();
200
+ if (!trimmed)
201
+ return null;
202
+ if (/^-?\d+$/.test(trimmed)) {
203
+ const fromEpochMs = new Date(Number(trimmed));
204
+ return Number.isNaN(fromEpochMs.getTime()) ? null : fromEpochMs;
205
+ }
206
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
207
+ const parsed = new Date(dateOnly ? `${trimmed}T00:00:00.000Z` : trimmed);
208
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
209
+ }
210
+ function readDateUnit(value, functionName) {
211
+ const unit = typeof value === "string" ? value.trim().toLowerCase() : "";
212
+ const singularless = unit.endsWith("s") ? unit : `${unit}s`;
213
+ if (!FORMULA_DATE_UNITS.includes(singularless)) {
214
+ throw formulaExpressionError("Unsupported date unit.", {
215
+ function: functionName,
216
+ unit: value,
217
+ supported: [...FORMULA_DATE_UNITS],
218
+ });
219
+ }
220
+ return singularless;
221
+ }
222
+ function calendarMonthsBetween(later, earlier) {
223
+ const sign = later.getTime() >= earlier.getTime() ? 1 : -1;
224
+ const [a, b] = sign === 1 ? [later, earlier] : [earlier, later];
225
+ let months = (a.getUTCFullYear() - b.getUTCFullYear()) * 12 + (a.getUTCMonth() - b.getUTCMonth());
226
+ const aDayStamp = a.getUTCDate() * 1_000_000 + a.getUTCHours() * 10_000 + a.getUTCMinutes() * 100 + a.getUTCSeconds();
227
+ const bDayStamp = b.getUTCDate() * 1_000_000 + b.getUTCHours() * 10_000 + b.getUTCMinutes() * 100 + b.getUTCSeconds();
228
+ if (aDayStamp < bDayStamp)
229
+ months -= 1;
230
+ return sign * months;
231
+ }
232
+ function addCalendarMonths(date, months) {
233
+ const result = new Date(date.getTime());
234
+ const targetDay = result.getUTCDate();
235
+ result.setUTCDate(1);
236
+ result.setUTCMonth(result.getUTCMonth() + months);
237
+ const lastDayOfTargetMonth = new Date(Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)).getUTCDate();
238
+ result.setUTCDate(Math.min(targetDay, lastDayOfTargetMonth));
239
+ return result;
240
+ }
241
+ function formatUtcDate(date, format) {
242
+ const pad = (value, width) => String(value).padStart(width, "0");
243
+ return format
244
+ .replace(/YYYY/g, pad(date.getUTCFullYear(), 4))
245
+ .replace(/MM/g, pad(date.getUTCMonth() + 1, 2))
246
+ .replace(/DD/g, pad(date.getUTCDate(), 2))
247
+ .replace(/HH/g, pad(date.getUTCHours(), 2))
248
+ .replace(/mm/g, pad(date.getUTCMinutes(), 2))
249
+ .replace(/ss/g, pad(date.getUTCSeconds(), 2));
250
+ }
251
+ // ---------------------------------------------------------------------------
252
+ // Registry
253
+ // ---------------------------------------------------------------------------
254
+ function spec(entry) {
255
+ return entry;
256
+ }
257
+ const SPECS = [
258
+ // --- logic ---------------------------------------------------------------
259
+ spec({
260
+ name: "if",
261
+ category: "logic",
262
+ signature: "if(condition, then, else?)",
263
+ description: "Returns `then` when the condition is truthy, otherwise `else` (or null). The untaken branch is never evaluated, so guards like if(x = 0, 0, 100 / x) are safe.",
264
+ minArgs: 2,
265
+ maxArgs: 3,
266
+ lazy: true,
267
+ examples: [{ expression: "if(score >= 80, \"hot\", \"warm\")", result: "hot" }],
268
+ evaluateLazy: (thunks) => {
269
+ if (toFormulaBoolean(thunks[0]?.()))
270
+ return thunks[1]?.() ?? null;
271
+ return thunks[2] ? thunks[2]() : null;
272
+ },
273
+ }),
274
+ spec({
275
+ name: "switch",
276
+ category: "logic",
277
+ signature: "switch(value, case1, result1, ..., default?)",
278
+ description: "Compares `value` against each case in order and returns the matching result; a trailing odd argument is the default. Unmatched without a default returns null. Results are evaluated lazily.",
279
+ minArgs: 3,
280
+ maxArgs: null,
281
+ lazy: true,
282
+ examples: [{ expression: "switch(tier, \"a\", 100, \"b\", 50, 10)", result: 50 }],
283
+ evaluateLazy: (thunks) => {
284
+ const value = thunks[0]?.();
285
+ const hasDefault = (thunks.length - 1) % 2 === 1;
286
+ const lastPairIndex = hasDefault ? thunks.length - 2 : thunks.length - 1;
287
+ for (let index = 1; index + 1 <= lastPairIndex; index += 2) {
288
+ const caseThunk = thunks[index];
289
+ const resultThunk = thunks[index + 1];
290
+ if (!caseThunk || !resultThunk)
291
+ break;
292
+ if (formulaValuesEqual(value, caseThunk()))
293
+ return resultThunk();
294
+ }
295
+ const defaultThunk = thunks[thunks.length - 1];
296
+ return hasDefault && defaultThunk ? defaultThunk() : null;
297
+ },
298
+ }),
299
+ spec({
300
+ name: "and",
301
+ category: "logic",
302
+ signature: "and(a, b, ...)",
303
+ description: "True when every argument is truthy; stops evaluating at the first falsy argument. Function form of the `and` operator.",
304
+ minArgs: 1,
305
+ maxArgs: null,
306
+ lazy: true,
307
+ examples: [{ expression: "and(is_blank(email) = false, score > 50)", result: true }],
308
+ evaluateLazy: (thunks) => {
309
+ for (const thunk of thunks) {
310
+ if (!toFormulaBoolean(thunk()))
311
+ return false;
312
+ }
313
+ return true;
314
+ },
315
+ }),
316
+ spec({
317
+ name: "or",
318
+ category: "logic",
319
+ signature: "or(a, b, ...)",
320
+ description: "True when any argument is truthy; stops evaluating at the first truthy argument. Function form of the `or` operator.",
321
+ minArgs: 1,
322
+ maxArgs: null,
323
+ lazy: true,
324
+ examples: [{ expression: "or(contains(title, \"founder\"), contains(title, \"ceo\"))", result: true }],
325
+ evaluateLazy: (thunks) => {
326
+ for (const thunk of thunks) {
327
+ if (toFormulaBoolean(thunk()))
328
+ return true;
329
+ }
330
+ return false;
331
+ },
332
+ }),
333
+ spec({
334
+ name: "bool",
335
+ category: "logic",
336
+ signature: "bool(value)",
337
+ description: "Casts a value to boolean: numbers by non-zero, strings by yes/no/true/false/1/0 words, blanks to false.",
338
+ minArgs: 1,
339
+ maxArgs: 1,
340
+ examples: [{ expression: "bool(\"yes\")", result: true }],
341
+ evaluate: (args) => toFormulaBoolean(args[0]),
342
+ }),
343
+ // --- string ---------------------------------------------------------------
344
+ spec({
345
+ name: "concat",
346
+ category: "string",
347
+ signature: "concat(a, b, ...)",
348
+ description: "Concatenates all arguments as text; blank arguments contribute nothing.",
349
+ minArgs: 0,
350
+ maxArgs: null,
351
+ examples: [{ expression: "concat(first_name, \" \", last_name)", result: "Ada Lovelace" }],
352
+ evaluate: (args) => args.map((arg) => (arg === null || arg === undefined ? "" : String(arg))).join(""),
353
+ }),
354
+ spec({
355
+ name: "lower",
356
+ category: "string",
357
+ signature: "lower(text)",
358
+ description: "Lowercases text.",
359
+ minArgs: 1,
360
+ maxArgs: 1,
361
+ examples: [{ expression: "lower(\"ACME\")", result: "acme" }],
362
+ evaluate: (args) => String(args[0] ?? "").toLowerCase(),
363
+ }),
364
+ spec({
365
+ name: "upper",
366
+ category: "string",
367
+ signature: "upper(text)",
368
+ description: "Uppercases text.",
369
+ minArgs: 1,
370
+ maxArgs: 1,
371
+ examples: [{ expression: "upper(\"acme\")", result: "ACME" }],
372
+ evaluate: (args) => String(args[0] ?? "").toUpperCase(),
373
+ }),
374
+ spec({
375
+ name: "trim",
376
+ category: "string",
377
+ signature: "trim(text)",
378
+ description: "Removes leading and trailing whitespace.",
379
+ minArgs: 1,
380
+ maxArgs: 1,
381
+ examples: [{ expression: "trim(\" hi \")", result: "hi" }],
382
+ evaluate: (args) => String(args[0] ?? "").trim(),
383
+ }),
384
+ spec({
385
+ name: "text",
386
+ category: "string",
387
+ signature: "text(value)",
388
+ description: "Casts any value to text; objects and arrays become JSON.",
389
+ minArgs: 1,
390
+ maxArgs: 1,
391
+ examples: [{ expression: "text(42)", result: "42" }],
392
+ evaluate: (args) => stringifyFormulaValue(args[0]),
393
+ }),
394
+ spec({
395
+ name: "split",
396
+ category: "string",
397
+ signature: "split(text, separator)",
398
+ description: "Splits text into an array of parts. Blank text yields an empty array.",
399
+ minArgs: 2,
400
+ maxArgs: 2,
401
+ examples: [{ expression: "split(\"a,b,c\", \",\")", result: ["a", "b", "c"] }],
402
+ evaluate: (args) => {
403
+ if (isBlankFormulaValue(args[0]))
404
+ return [];
405
+ const separator = asText(args[1]);
406
+ const input = asText(args[0]);
407
+ return separator ? input.split(separator) : [input];
408
+ },
409
+ }),
410
+ spec({
411
+ name: "join",
412
+ category: "string",
413
+ signature: "join(separator, a, b, ...)",
414
+ description: "Joins the non-blank arguments with the separator.",
415
+ minArgs: 1,
416
+ maxArgs: null,
417
+ examples: [{ expression: "join(\", \", city, country)", result: "Berlin, Germany" }],
418
+ evaluate: (args) => {
419
+ const [separator, ...values] = args;
420
+ return values
421
+ .filter((value) => !isBlankFormulaValue(value))
422
+ .map((value) => stringifyFormulaValue(value))
423
+ .join(asText(separator));
424
+ },
425
+ }),
426
+ spec({
427
+ name: "join_list",
428
+ category: "string",
429
+ signature: "join_list(array, separator?)",
430
+ description: "Joins the non-blank elements of an array with the separator (default \", \").",
431
+ minArgs: 1,
432
+ maxArgs: 2,
433
+ examples: [{ expression: "join_list(path(payload, \"tags\"), \" | \")", result: "b2b | saas" }],
434
+ evaluate: (args) => {
435
+ const separator = args.length > 1 ? asText(args[1]) : ", ";
436
+ if (isBlankFormulaValue(args[0]))
437
+ return "";
438
+ if (!Array.isArray(args[0]))
439
+ return stringifyFormulaValue(args[0]);
440
+ return args[0]
441
+ .filter((value) => !isBlankFormulaValue(value))
442
+ .map((value) => stringifyFormulaValue(value))
443
+ .join(separator);
444
+ },
445
+ }),
446
+ spec({
447
+ name: "replace",
448
+ category: "string",
449
+ signature: "replace(text, find, replacement)",
450
+ description: "Replaces every literal occurrence of `find` with `replacement`.",
451
+ minArgs: 3,
452
+ maxArgs: 3,
453
+ examples: [{ expression: "replace(\"a-b-c\", \"-\", \".\")", result: "a.b.c" }],
454
+ evaluate: (args) => {
455
+ const input = asText(args[0]);
456
+ const find = asText(args[1]);
457
+ if (!find)
458
+ return input;
459
+ return input.split(find).join(asText(args[2]));
460
+ },
461
+ }),
462
+ spec({
463
+ name: "regex_extract",
464
+ category: "string",
465
+ signature: "regex_extract(text, pattern, group?)",
466
+ description: "Returns the first regex match — capture group 1 when the pattern has groups (or the group index you pass), otherwise the whole match. No match returns null.",
467
+ minArgs: 2,
468
+ maxArgs: 3,
469
+ examples: [{ expression: "regex_extract(email, \"@(.+)$\")", result: "acme.com" }],
470
+ evaluate: (args) => {
471
+ const input = readRegexInput(args[0], "regex_extract");
472
+ if (!input)
473
+ return null;
474
+ const regex = compileFormulaRegex(args[1], "regex_extract", "");
475
+ const match = regex.exec(input);
476
+ if (!match)
477
+ return null;
478
+ if (args.length > 2) {
479
+ const group = readFormulaNumber(args[2], "regex_extract");
480
+ return match[group] ?? null;
481
+ }
482
+ return match.length > 1 ? match[1] ?? null : match[0] ?? null;
483
+ },
484
+ }),
485
+ spec({
486
+ name: "regex_match",
487
+ category: "string",
488
+ signature: "regex_match(text, pattern)",
489
+ description: "True when the text matches the regular expression.",
490
+ minArgs: 2,
491
+ maxArgs: 2,
492
+ examples: [{ expression: "regex_match(website, \"^https://\")", result: true }],
493
+ evaluate: (args) => {
494
+ const input = readRegexInput(args[0], "regex_match");
495
+ if (!input)
496
+ return false;
497
+ return compileFormulaRegex(args[1], "regex_match", "").test(input);
498
+ },
499
+ }),
500
+ spec({
501
+ name: "regex_replace",
502
+ category: "string",
503
+ signature: "regex_replace(text, pattern, replacement)",
504
+ description: "Replaces every regex match with the replacement ($1-style backreferences supported).",
505
+ minArgs: 3,
506
+ maxArgs: 3,
507
+ examples: [{ expression: "regex_replace(phone, \"[^0-9]\", \"\")", result: "14155550101" }],
508
+ evaluate: (args) => {
509
+ const input = readRegexInput(args[0], "regex_replace");
510
+ if (!input)
511
+ return "";
512
+ return input.replace(compileFormulaRegex(args[1], "regex_replace", "g"), asText(args[2]));
513
+ },
514
+ }),
515
+ spec({
516
+ name: "substring",
517
+ category: "string",
518
+ signature: "substring(text, start, length?)",
519
+ description: "Returns part of the text from a 0-based start index, optionally limited to `length` characters.",
520
+ minArgs: 2,
521
+ maxArgs: 3,
522
+ examples: [{ expression: "substring(\"oxygen\", 0, 2)", result: "ox" }],
523
+ evaluate: (args) => {
524
+ const input = asText(args[0]);
525
+ const start = Math.trunc(readFormulaNumber(args[1], "substring"));
526
+ if (args.length > 2) {
527
+ const length = Math.max(0, Math.trunc(readFormulaNumber(args[2], "substring")));
528
+ const from = start < 0 ? Math.max(0, input.length + start) : start;
529
+ return input.slice(from, from + length);
530
+ }
531
+ return input.slice(start);
532
+ },
533
+ }),
534
+ spec({
535
+ name: "left",
536
+ category: "string",
537
+ signature: "left(text, count)",
538
+ description: "Returns the first `count` characters.",
539
+ minArgs: 2,
540
+ maxArgs: 2,
541
+ examples: [{ expression: "left(\"oxygen\", 2)", result: "ox" }],
542
+ evaluate: (args) => asText(args[0]).slice(0, Math.max(0, Math.trunc(readFormulaNumber(args[1], "left")))),
543
+ }),
544
+ spec({
545
+ name: "right",
546
+ category: "string",
547
+ signature: "right(text, count)",
548
+ description: "Returns the last `count` characters.",
549
+ minArgs: 2,
550
+ maxArgs: 2,
551
+ examples: [{ expression: "right(\"oxygen\", 3)", result: "gen" }],
552
+ evaluate: (args) => {
553
+ const count = Math.max(0, Math.trunc(readFormulaNumber(args[1], "right")));
554
+ const input = asText(args[0]);
555
+ return count === 0 ? "" : input.slice(-count);
556
+ },
557
+ }),
558
+ spec({
559
+ name: "length",
560
+ category: "string",
561
+ signature: "length(value)",
562
+ description: "Length of a string in characters or an array in items; blank is 0.",
563
+ minArgs: 1,
564
+ maxArgs: 1,
565
+ examples: [{ expression: "length(company_name)", result: 4 }],
566
+ evaluate: (args) => {
567
+ if (isBlankFormulaValue(args[0]))
568
+ return 0;
569
+ if (Array.isArray(args[0]))
570
+ return args[0].length;
571
+ return asText(args[0]).length;
572
+ },
573
+ }),
574
+ spec({
575
+ name: "title_case",
576
+ category: "string",
577
+ signature: "title_case(text)",
578
+ description: "Capitalizes the first letter of every word and lowercases the rest.",
579
+ minArgs: 1,
580
+ maxArgs: 1,
581
+ examples: [{ expression: "title_case(\"acme CORP\")", result: "Acme Corp" }],
582
+ evaluate: (args) => asText(args[0]).replace(/([A-Za-z])([A-Za-z]*)/g, (_match, first, rest) => first.toUpperCase() + rest.toLowerCase()),
583
+ }),
584
+ spec({
585
+ name: "pad_start",
586
+ category: "string",
587
+ signature: "pad_start(text, length, pad?)",
588
+ description: "Pads the start of the text to the target length (default pad: space).",
589
+ minArgs: 2,
590
+ maxArgs: 3,
591
+ examples: [{ expression: "pad_start(\"7\", 3, \"0\")", result: "007" }],
592
+ evaluate: (args) => asText(args[0]).padStart(Math.max(0, Math.trunc(readFormulaNumber(args[1], "pad_start"))), args.length > 2 ? asText(args[2]) || " " : " "),
593
+ }),
594
+ spec({
595
+ name: "pad_end",
596
+ category: "string",
597
+ signature: "pad_end(text, length, pad?)",
598
+ description: "Pads the end of the text to the target length (default pad: space).",
599
+ minArgs: 2,
600
+ maxArgs: 3,
601
+ examples: [{ expression: "pad_end(\"7\", 3, \"0\")", result: "700" }],
602
+ evaluate: (args) => asText(args[0]).padEnd(Math.max(0, Math.trunc(readFormulaNumber(args[1], "pad_end"))), args.length > 2 ? asText(args[2]) || " " : " "),
603
+ }),
604
+ spec({
605
+ name: "starts_with",
606
+ category: "string",
607
+ signature: "starts_with(text, prefix)",
608
+ description: "True when the text starts with the prefix. Blank text or prefix is false.",
609
+ minArgs: 2,
610
+ maxArgs: 2,
611
+ examples: [{ expression: "starts_with(website, \"https\")", result: true }],
612
+ evaluate: (args) => {
613
+ if (isBlankFormulaValue(args[0]) || isBlankFormulaValue(args[1]))
614
+ return false;
615
+ return asText(args[0]).startsWith(asText(args[1]));
616
+ },
617
+ }),
618
+ spec({
619
+ name: "ends_with",
620
+ category: "string",
621
+ signature: "ends_with(text, suffix)",
622
+ description: "True when the text ends with the suffix. Blank text or suffix is false.",
623
+ minArgs: 2,
624
+ maxArgs: 2,
625
+ examples: [{ expression: "ends_with(email, \"@acme.com\")", result: true }],
626
+ evaluate: (args) => {
627
+ if (isBlankFormulaValue(args[0]) || isBlankFormulaValue(args[1]))
628
+ return false;
629
+ return asText(args[0]).endsWith(asText(args[1]));
630
+ },
631
+ }),
632
+ spec({
633
+ name: "contains",
634
+ category: "string",
635
+ signature: "contains(text, needle)",
636
+ description: "True when the text contains the needle (case-sensitive; wrap both in lower(...) for case-insensitive). Blank text or needle is false.",
637
+ minArgs: 2,
638
+ maxArgs: 2,
639
+ examples: [{ expression: "contains(lower(title), \"founder\")", result: true }],
640
+ evaluate: (args) => {
641
+ if (isBlankFormulaValue(args[0]) || isBlankFormulaValue(args[1]))
642
+ return false;
643
+ return asText(args[0]).includes(asText(args[1]));
644
+ },
645
+ }),
646
+ // --- number ----------------------------------------------------------------
647
+ spec({
648
+ name: "number",
649
+ category: "number",
650
+ signature: "number(value)",
651
+ description: "Casts a value to a number; blank becomes null; non-numeric text errors.",
652
+ minArgs: 1,
653
+ maxArgs: 1,
654
+ examples: [{ expression: "number(\"42\")", result: 42 }],
655
+ evaluate: (args) => (isBlankFormulaValue(args[0]) ? null : readFormulaNumber(args[0], "number")),
656
+ }),
657
+ spec({
658
+ name: "round",
659
+ category: "number",
660
+ signature: "round(value, digits?)",
661
+ description: "Rounds to the given number of decimal digits (default 0). Blank input is null.",
662
+ minArgs: 1,
663
+ maxArgs: 2,
664
+ examples: [{ expression: "round(3.14159, 2)", result: 3.14 }],
665
+ evaluate: (args) => blankToNull(args[0], (input) => {
666
+ const digits = args.length > 1 ? Math.trunc(readFormulaNumber(args[1], "round")) : 0;
667
+ const factor = 10 ** digits;
668
+ return Math.round(readFormulaNumber(input, "round") * factor) / factor;
669
+ }),
670
+ }),
671
+ spec({
672
+ name: "floor",
673
+ category: "number",
674
+ signature: "floor(value)",
675
+ description: "Rounds down to the nearest integer. Blank input is null.",
676
+ minArgs: 1,
677
+ maxArgs: 1,
678
+ examples: [{ expression: "floor(3.9)", result: 3 }],
679
+ evaluate: (args) => blankToNull(args[0], (input) => Math.floor(readFormulaNumber(input, "floor"))),
680
+ }),
681
+ spec({
682
+ name: "ceil",
683
+ category: "number",
684
+ signature: "ceil(value)",
685
+ description: "Rounds up to the nearest integer. Blank input is null.",
686
+ minArgs: 1,
687
+ maxArgs: 1,
688
+ examples: [{ expression: "ceil(3.1)", result: 4 }],
689
+ evaluate: (args) => blankToNull(args[0], (input) => Math.ceil(readFormulaNumber(input, "ceil"))),
690
+ }),
691
+ spec({
692
+ name: "abs",
693
+ category: "number",
694
+ signature: "abs(value)",
695
+ description: "Absolute value. Blank input is null.",
696
+ minArgs: 1,
697
+ maxArgs: 1,
698
+ examples: [{ expression: "abs(-5)", result: 5 }],
699
+ evaluate: (args) => blankToNull(args[0], (input) => Math.abs(readFormulaNumber(input, "abs"))),
700
+ }),
701
+ spec({
702
+ name: "min",
703
+ category: "number",
704
+ signature: "min(a, b, ...)",
705
+ description: "Smallest numeric argument; arrays are flattened one level; blanks are skipped. All-blank input is null.",
706
+ minArgs: 1,
707
+ maxArgs: null,
708
+ examples: [{ expression: "min(score_a, score_b)", result: 40 }],
709
+ evaluate: (args) => {
710
+ const values = collectNumericArgs(args, "min");
711
+ return values.length === 0 ? null : Math.min(...values);
712
+ },
713
+ }),
714
+ spec({
715
+ name: "max",
716
+ category: "number",
717
+ signature: "max(a, b, ...)",
718
+ description: "Largest numeric argument; arrays are flattened one level; blanks are skipped. All-blank input is null.",
719
+ minArgs: 1,
720
+ maxArgs: null,
721
+ examples: [{ expression: "max(score_a, score_b)", result: 90 }],
722
+ evaluate: (args) => {
723
+ const values = collectNumericArgs(args, "max");
724
+ return values.length === 0 ? null : Math.max(...values);
725
+ },
726
+ }),
727
+ spec({
728
+ name: "sum",
729
+ category: "number",
730
+ signature: "sum(a, b, ...)",
731
+ description: "Sum of the numeric arguments; arrays are flattened one level; blanks are skipped. All-blank input is 0.",
732
+ minArgs: 1,
733
+ maxArgs: null,
734
+ examples: [{ expression: "sum(path(payload, \"amounts\"))", result: 150 }],
735
+ evaluate: (args) => collectNumericArgs(args, "sum").reduce((total, value) => total + value, 0),
736
+ }),
737
+ spec({
738
+ name: "avg",
739
+ category: "number",
740
+ signature: "avg(a, b, ...)",
741
+ description: "Average of the numeric arguments; arrays are flattened one level; blanks are skipped. All-blank input is null.",
742
+ minArgs: 1,
743
+ maxArgs: null,
744
+ examples: [{ expression: "avg(q1, q2, q3)", result: 25 }],
745
+ evaluate: (args) => {
746
+ const values = collectNumericArgs(args, "avg");
747
+ if (values.length === 0)
748
+ return null;
749
+ return values.reduce((total, value) => total + value, 0) / values.length;
750
+ },
751
+ }),
752
+ // --- date -------------------------------------------------------------------
753
+ spec({
754
+ name: "now",
755
+ category: "date",
756
+ signature: "now()",
757
+ description: "The current timestamp as a UTC ISO-8601 string.",
758
+ minArgs: 0,
759
+ maxArgs: 0,
760
+ examples: [{ expression: "now()", result: "2026-07-13T12:00:00.000Z" }],
761
+ evaluate: () => new Date().toISOString(),
762
+ }),
763
+ spec({
764
+ name: "today",
765
+ category: "date",
766
+ signature: "today()",
767
+ description: "The current UTC date as YYYY-MM-DD.",
768
+ minArgs: 0,
769
+ maxArgs: 0,
770
+ examples: [{ expression: "today()", result: "2026-07-13" }],
771
+ evaluate: () => new Date().toISOString().slice(0, 10),
772
+ }),
773
+ spec({
774
+ name: "date_parse",
775
+ category: "date",
776
+ signature: "date_parse(value)",
777
+ description: "Parses an ISO-8601 string, YYYY-MM-DD date, or epoch-milliseconds number into a UTC ISO timestamp. Unparseable input returns null (dirty data flows instead of failing the run).",
778
+ minArgs: 1,
779
+ maxArgs: 1,
780
+ examples: [{ expression: "date_parse(\"2026-07-13\")", result: "2026-07-13T00:00:00.000Z" }],
781
+ evaluate: (args) => parseFormulaDate(args[0])?.toISOString() ?? null,
782
+ }),
783
+ spec({
784
+ name: "date_format",
785
+ category: "date",
786
+ signature: "date_format(date, format)",
787
+ description: "Formats a date in UTC using YYYY, MM, DD, HH, mm, ss tokens. Unparseable input returns null.",
788
+ minArgs: 2,
789
+ maxArgs: 2,
790
+ examples: [{ expression: "date_format(created_at, \"YYYY-MM-DD\")", result: "2026-07-13" }],
791
+ evaluate: (args) => {
792
+ const date = parseFormulaDate(args[0]);
793
+ return date ? formatUtcDate(date, asText(args[1])) : null;
794
+ },
795
+ }),
796
+ spec({
797
+ name: "date_diff",
798
+ category: "date",
799
+ signature: "date_diff(later, earlier, unit)",
800
+ description: "Whole units between two dates (later minus earlier; negative when reversed). Units: seconds, minutes, hours, days, weeks, months, years — months/years use calendar arithmetic. Unparseable input returns null.",
801
+ minArgs: 3,
802
+ maxArgs: 3,
803
+ examples: [{ expression: "date_diff(now(), last_reply_at, \"days\")", result: 12 }],
804
+ evaluate: (args) => {
805
+ const later = parseFormulaDate(args[0]);
806
+ const earlier = parseFormulaDate(args[1]);
807
+ if (!later || !earlier)
808
+ return null;
809
+ const unit = readDateUnit(args[2], "date_diff");
810
+ if (unit === "months")
811
+ return calendarMonthsBetween(later, earlier);
812
+ if (unit === "years")
813
+ return Math.trunc(calendarMonthsBetween(later, earlier) / 12);
814
+ const unitMs = DATE_UNIT_MS[unit];
815
+ if (!unitMs)
816
+ return null;
817
+ return Math.trunc((later.getTime() - earlier.getTime()) / unitMs);
818
+ },
819
+ }),
820
+ spec({
821
+ name: "date_add",
822
+ category: "date",
823
+ signature: "date_add(date, amount, unit)",
824
+ description: "Adds an amount of units to a date (negative subtracts) and returns a UTC ISO timestamp. Month/year additions clamp to the last day of the target month. Unparseable input returns null.",
825
+ minArgs: 3,
826
+ maxArgs: 3,
827
+ examples: [{ expression: "date_add(today(), 7, \"days\")", result: "2026-07-20T00:00:00.000Z" }],
828
+ evaluate: (args) => {
829
+ const date = parseFormulaDate(args[0]);
830
+ if (!date)
831
+ return null;
832
+ const amount = readFormulaNumber(args[1], "date_add");
833
+ const unit = readDateUnit(args[2], "date_add");
834
+ if (unit === "months")
835
+ return addCalendarMonths(date, Math.trunc(amount)).toISOString();
836
+ if (unit === "years")
837
+ return addCalendarMonths(date, Math.trunc(amount) * 12).toISOString();
838
+ const unitMs = DATE_UNIT_MS[unit];
839
+ if (!unitMs)
840
+ return null;
841
+ return new Date(date.getTime() + amount * unitMs).toISOString();
842
+ },
843
+ }),
844
+ // --- url / email --------------------------------------------------------------
845
+ spec({
846
+ name: "email_domain",
847
+ category: "url_email",
848
+ signature: "email_domain(email)",
849
+ description: "The canonical domain of an email address (lowercased, www/trailing dots stripped). Blank or @-less input returns null.",
850
+ minArgs: 1,
851
+ maxArgs: 1,
852
+ examples: [{ expression: "email_domain(\"Jo@Acme.com\")", result: "acme.com" }],
853
+ evaluate: (args) => {
854
+ if (isBlankFormulaValue(args[0]))
855
+ return null;
856
+ const input = asText(args[0]).trim();
857
+ const at = input.lastIndexOf("@");
858
+ if (at <= 0 || at === input.length - 1)
859
+ return null;
860
+ const domain = normalizeDomain(input.slice(at + 1));
861
+ return domain || null;
862
+ },
863
+ }),
864
+ spec({
865
+ name: "domain_of",
866
+ category: "url_email",
867
+ signature: "domain_of(url_or_domain)",
868
+ description: "Reduces a URL, hostname, or bare domain to its canonical form — identical normalization to CRM company-domain identity matching.",
869
+ minArgs: 1,
870
+ maxArgs: 1,
871
+ examples: [{ expression: "domain_of(\"https://www.Acme.com/about\")", result: "acme.com" }],
872
+ evaluate: (args) => {
873
+ if (isBlankFormulaValue(args[0]))
874
+ return null;
875
+ const domain = normalizeDomain(asText(args[0]));
876
+ return domain || null;
877
+ },
878
+ }),
879
+ spec({
880
+ name: "normalize_email",
881
+ category: "url_email",
882
+ signature: "normalize_email(email)",
883
+ description: "Canonical email form (trimmed, lowercased) — identical to CRM email identity matching.",
884
+ minArgs: 1,
885
+ maxArgs: 1,
886
+ examples: [{ expression: "normalize_email(\" Jo@Acme.COM \")", result: "jo@acme.com" }],
887
+ evaluate: (args) => blankToNull(args[0], (input) => normalizeEmail(asText(input))),
888
+ }),
889
+ spec({
890
+ name: "normalize_url",
891
+ category: "url_email",
892
+ signature: "normalize_url(url)",
893
+ description: "Canonical URL form: https scheme assumed when missing, lowercased host, trailing slashes stripped. Unparseable input returns the trimmed original.",
894
+ minArgs: 1,
895
+ maxArgs: 1,
896
+ examples: [{ expression: "normalize_url(\"WWW.Acme.com/pricing/\")", result: "https://www.acme.com/pricing" }],
897
+ evaluate: (args) => blankToNull(args[0], (input) => {
898
+ const raw = asText(input).trim();
899
+ try {
900
+ const parsed = new URL(raw.includes("://") ? raw : `https://${raw}`);
901
+ const path = parsed.pathname.replace(/\/+$/, "");
902
+ return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${path}${parsed.search}`;
903
+ }
904
+ catch {
905
+ return raw;
906
+ }
907
+ }),
908
+ }),
909
+ spec({
910
+ name: "normalize_linkedin_url",
911
+ category: "url_email",
912
+ signature: "normalize_linkedin_url(url)",
913
+ description: "Canonical LinkedIn URL form (trimmed, lowercased, trailing slashes stripped) — identical to CRM LinkedIn identity matching.",
914
+ minArgs: 1,
915
+ maxArgs: 1,
916
+ examples: [{ expression: "normalize_linkedin_url(\"https://linkedin.com/in/Ada/\")", result: "https://linkedin.com/in/ada" }],
917
+ evaluate: (args) => blankToNull(args[0], (input) => normalizeLinkedinUrl(asText(input))),
918
+ }),
919
+ // --- array -----------------------------------------------------------------
920
+ spec({
921
+ name: "first",
922
+ category: "array",
923
+ signature: "first(array)",
924
+ description: "First element of an array, or null when empty/blank.",
925
+ minArgs: 1,
926
+ maxArgs: 1,
927
+ examples: [{ expression: "first(path(payload, \"emails\"))", result: "jo@acme.com" }],
928
+ evaluate: (args) => (Array.isArray(args[0]) ? args[0][0] ?? null : null),
929
+ }),
930
+ spec({
931
+ name: "last",
932
+ category: "array",
933
+ signature: "last(array)",
934
+ description: "Last element of an array, or null when empty/blank.",
935
+ minArgs: 1,
936
+ maxArgs: 1,
937
+ examples: [{ expression: "last(split(name, \" \"))", result: "Lovelace" }],
938
+ evaluate: (args) => (Array.isArray(args[0]) ? args[0][args[0].length - 1] ?? null : null),
939
+ }),
940
+ spec({
941
+ name: "count",
942
+ category: "array",
943
+ signature: "count(value)",
944
+ description: "Number of items in an array; 0 for blank; 1 for any other non-blank value.",
945
+ minArgs: 1,
946
+ maxArgs: 1,
947
+ examples: [{ expression: "count(path(payload, \"jobs\"))", result: 3 }],
948
+ evaluate: (args) => {
949
+ if (isBlankFormulaValue(args[0]))
950
+ return 0;
951
+ return Array.isArray(args[0]) ? args[0].length : 1;
952
+ },
953
+ }),
954
+ spec({
955
+ name: "pluck",
956
+ category: "array",
957
+ signature: "pluck(array, path)",
958
+ description: "Maps each array element through a dot-separated JSON path, dropping blank results.",
959
+ minArgs: 2,
960
+ maxArgs: 2,
961
+ examples: [{ expression: "pluck(path(payload, \"contacts\"), \"email\")", result: ["a@x.com", "b@y.com"] }],
962
+ evaluate: (args) => {
963
+ if (!Array.isArray(args[0]))
964
+ return [];
965
+ if (typeof args[1] !== "string") {
966
+ throw formulaExpressionError("pluck requires a string path argument.", { function: "pluck" });
967
+ }
968
+ const path = args[1];
969
+ return args[0]
970
+ .map((element) => readFormulaJsonPath(element, path))
971
+ .filter((value) => !isBlankFormulaValue(value));
972
+ },
973
+ }),
974
+ spec({
975
+ name: "unique",
976
+ category: "array",
977
+ signature: "unique(array)",
978
+ description: "Removes duplicate elements (deep equality), preserving first-seen order.",
979
+ minArgs: 1,
980
+ maxArgs: 1,
981
+ examples: [{ expression: "unique(split(\"a,b,a\", \",\"))", result: ["a", "b"] }],
982
+ evaluate: (args) => {
983
+ if (!Array.isArray(args[0]))
984
+ return [];
985
+ const seen = new Set();
986
+ const result = [];
987
+ for (const element of args[0]) {
988
+ const key = JSON.stringify(element) ?? "undefined";
989
+ if (seen.has(key))
990
+ continue;
991
+ seen.add(key);
992
+ result.push(element);
993
+ }
994
+ return result;
995
+ },
996
+ }),
997
+ // --- json ------------------------------------------------------------------
998
+ spec({
999
+ name: "path",
1000
+ category: "json",
1001
+ signature: "path(value, \"a.b.0\")",
1002
+ description: "Reads a dot-separated path (object keys and array indexes) out of a JSON value; missing paths return null.",
1003
+ minArgs: 2,
1004
+ maxArgs: 2,
1005
+ examples: [{ expression: "path(enrichment, \"company.size\")", result: "51-200" }],
1006
+ evaluate: (args) => {
1007
+ if (typeof args[1] !== "string") {
1008
+ throw formulaExpressionError("path requires a string path argument.", {
1009
+ function: "path",
1010
+ });
1011
+ }
1012
+ return readFormulaJsonPath(args[0], args[1]);
1013
+ },
1014
+ }),
1015
+ // --- null handling -----------------------------------------------------------
1016
+ spec({
1017
+ name: "coalesce",
1018
+ category: "null_handling",
1019
+ signature: "coalesce(a, b, ...)",
1020
+ description: "First non-blank argument, or null when all are blank.",
1021
+ minArgs: 0,
1022
+ maxArgs: null,
1023
+ examples: [{ expression: "coalesce(work_email, personal_email)", result: "jo@acme.com" }],
1024
+ evaluate: (args) => args.find((arg) => !isBlankFormulaValue(arg)) ?? null,
1025
+ }),
1026
+ spec({
1027
+ name: "default",
1028
+ category: "null_handling",
1029
+ signature: "default(value, fallback)",
1030
+ description: "The value when it is non-blank, otherwise the fallback.",
1031
+ minArgs: 2,
1032
+ maxArgs: 2,
1033
+ examples: [{ expression: "default(title, \"(unknown)\")", result: "(unknown)" }],
1034
+ evaluate: (args) => (isBlankFormulaValue(args[0]) ? args[1] ?? null : args[0]),
1035
+ }),
1036
+ spec({
1037
+ name: "ifblank",
1038
+ category: "null_handling",
1039
+ signature: "ifblank(value, fallback)",
1040
+ description: "Alias of default(value, fallback).",
1041
+ minArgs: 2,
1042
+ maxArgs: 2,
1043
+ examples: [{ expression: "ifblank(phone, \"n/a\")", result: "n/a" }],
1044
+ evaluate: (args) => (isBlankFormulaValue(args[0]) ? args[1] ?? null : args[0]),
1045
+ }),
1046
+ spec({
1047
+ name: "is_blank",
1048
+ category: "null_handling",
1049
+ signature: "is_blank(value)",
1050
+ description: "True when the value is null, empty text, or an empty array.",
1051
+ minArgs: 1,
1052
+ maxArgs: 1,
1053
+ examples: [{ expression: "is_blank(email)", result: false }],
1054
+ evaluate: (args) => isBlankFormulaValue(args[0]),
1055
+ }),
1056
+ // --- cross row ----------------------------------------------------------------
1057
+ spec({
1058
+ name: "novelty_score",
1059
+ category: "cross_row",
1060
+ signature: "novelty_score(column)",
1061
+ description: "0..1 novelty of this row's value versus every other row's value in the same column (1 = unique). Requires a sync columns run so the column corpus can load; executed by the runner, not this registry.",
1062
+ minArgs: 1,
1063
+ maxArgs: 1,
1064
+ examples: [{ expression: "novelty_score(opening_line)", result: 0.8731 }],
1065
+ // No evaluate: the runner intercepts cross-row calls before registry dispatch.
1066
+ }),
1067
+ ];
1068
+ export const FORMULA_FUNCTION_REGISTRY = new Map(SPECS.map((entry) => [entry.name, entry]));
1069
+ export const FORMULA_FUNCTION_CATEGORIES = [
1070
+ "logic",
1071
+ "string",
1072
+ "number",
1073
+ "date",
1074
+ "url_email",
1075
+ "array",
1076
+ "json",
1077
+ "null_handling",
1078
+ "cross_row",
1079
+ ];
1080
+ export function listFormulaFunctions(category) {
1081
+ return SPECS
1082
+ .filter((entry) => !category || entry.category === category)
1083
+ .map(({ name, category: entryCategory, signature, description, minArgs, maxArgs, examples }) => ({
1084
+ name,
1085
+ category: entryCategory,
1086
+ signature,
1087
+ description,
1088
+ minArgs,
1089
+ maxArgs,
1090
+ examples,
1091
+ }));
1092
+ }
1093
+ /** Operators the expression language supports, for the discovery surfaces. */
1094
+ export const FORMULA_OPERATORS = [
1095
+ { operator: "+ - * / %", description: "Arithmetic (numbers or numeric strings)." },
1096
+ { operator: "= == != <>", description: "Equality (numbers compare numerically)." },
1097
+ { operator: "< <= > >=", description: "Comparison (numbers numerically, text lexicographically)." },
1098
+ { operator: "and or not (&& || !)", description: "Boolean logic with short-circuit evaluation." },
1099
+ { operator: "( )", description: "Grouping and function calls." },
1100
+ ];