@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.
- package/README.md +1 -1
- package/dist/column-run-notices.d.ts +11 -0
- package/dist/column-run-notices.js +37 -0
- package/dist/command-manifest.js +13 -8
- package/dist/help.js +78 -16
- package/dist/index.js +3450 -447
- package/dist/skills.js +106 -1
- package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
- package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
- package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
- package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
- package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
- package/node_modules/@oxygen/formula/dist/expression.js +428 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
- package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
- package/node_modules/@oxygen/formula/dist/index.js +17 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
- package/node_modules/@oxygen/formula/package.json +26 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -5
- package/node_modules/@oxygen/shared/dist/billing.js +185 -8
- package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
- package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
- package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
- package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/directory.js +1 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +19 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/index.js +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
- package/node_modules/@oxygen/shared/dist/log.js +41 -2
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
- package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
- package/node_modules/@oxygen/shared/dist/tags.js +122 -6
- 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/dist/workflow-trigger-metadata.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
- package/node_modules/@oxygen/shared/package.json +95 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
- package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
- package/node_modules/@oxygen/workflows/dist/index.js +179 -13
- package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
- package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
- package/node_modules/@oxygen/workflows/package.json +4 -0
- package/package.json +7 -5
package/dist/skills.js
CHANGED
|
@@ -10,6 +10,16 @@ import { resolveCmdShimInvocation } from "./windows-shim.js";
|
|
|
10
10
|
const DEFAULT_SKILL_AGENTS = ["codex", "claude-code", "cursor"];
|
|
11
11
|
const SKILL_INDEX_PATH = "/.well-known/skills/index.json";
|
|
12
12
|
const SKILLS_ACCESS_API_PATH = "/api/cli/skills/access";
|
|
13
|
+
// Display names the vendored `npx skills` installer (1.5.x) uses in its own
|
|
14
|
+
// "Failed to install" console lines (`✗ <skill> → <displayName>: <reason>`).
|
|
15
|
+
// Only used to tell a genuine failure of an agent we actually requested apart
|
|
16
|
+
// from an agent name the installer swept in on its own — see
|
|
17
|
+
// classifyInstallerFailures below.
|
|
18
|
+
const AGENT_DISPLAY_NAMES = {
|
|
19
|
+
codex: "Codex",
|
|
20
|
+
"claude-code": "Claude Code",
|
|
21
|
+
cursor: "Cursor",
|
|
22
|
+
};
|
|
13
23
|
/**
|
|
14
24
|
* Resolves where to install skills from. Logged-in CLIs mint a short-lived
|
|
15
25
|
* org-bound access URL (the skills surface is gated; anonymous serving is
|
|
@@ -136,6 +146,7 @@ options, runtime = {}) {
|
|
|
136
146
|
// passed straight through as before.
|
|
137
147
|
let stagingDir = null;
|
|
138
148
|
const outputs = [];
|
|
149
|
+
const skippedTargets = [];
|
|
139
150
|
try {
|
|
140
151
|
let installSource = source.install_url;
|
|
141
152
|
if (source.authenticated) {
|
|
@@ -174,6 +185,22 @@ options, runtime = {}) {
|
|
|
174
185
|
exitCode: 1,
|
|
175
186
|
});
|
|
176
187
|
}
|
|
188
|
+
// A 0 exit code alone doesn't mean every requested agent succeeded (see
|
|
189
|
+
// classifyInstallerFailures) — check the installer's own per-target report.
|
|
190
|
+
const { failed, skipped } = classifyInstallerFailures(stdout, agents);
|
|
191
|
+
if (failed.length > 0) {
|
|
192
|
+
throw new OxygenError("skills_install_failed", "Unable to install Oxygen agent skills.", {
|
|
193
|
+
details: {
|
|
194
|
+
index_url: source.index_url,
|
|
195
|
+
authenticated: source.authenticated,
|
|
196
|
+
skill: target,
|
|
197
|
+
failed_targets: failed,
|
|
198
|
+
stdout: stdout.slice(0, 2000),
|
|
199
|
+
},
|
|
200
|
+
exitCode: 1,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
skippedTargets.push(...skipped);
|
|
177
204
|
outputs.push(stdout);
|
|
178
205
|
}
|
|
179
206
|
}
|
|
@@ -181,6 +208,10 @@ options, runtime = {}) {
|
|
|
181
208
|
if (stagingDir)
|
|
182
209
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
183
210
|
}
|
|
211
|
+
// Every requested agent that didn't throw above installed cleanly. Agents the
|
|
212
|
+
// installer swept in beyond what we requested are reported distinctly and
|
|
213
|
+
// never counted as a failure — see classifyInstallerFailures.
|
|
214
|
+
const skippedAgents = [...new Set(skippedTargets.map((entry) => entry.agent))];
|
|
184
215
|
if (dependencySkills.length > 0) {
|
|
185
216
|
process.stderr.write(`note: '${skill}' also installs its prerequisite skill(s): ${dependencySkills.join(", ")}.\n`);
|
|
186
217
|
}
|
|
@@ -195,6 +226,15 @@ options, runtime = {}) {
|
|
|
195
226
|
...(dependencySkills.length > 0 ? { dependency_skills: dependencySkills } : {}),
|
|
196
227
|
scope: options.project ? "project" : "global",
|
|
197
228
|
output: outputs.join(""),
|
|
229
|
+
// Truthful counts a caller (human or scripting agent) can trust at a
|
|
230
|
+
// glance: a not-detected agent is never folded into `failed`.
|
|
231
|
+
agents_installed: agents,
|
|
232
|
+
installed_count: agents.length,
|
|
233
|
+
...(skippedAgents.length > 0 ? { agents_skipped: skippedAgents } : {}),
|
|
234
|
+
skipped_count: skippedAgents.length,
|
|
235
|
+
// Any genuine failure threw above rather than reaching this return.
|
|
236
|
+
failed_count: 0,
|
|
237
|
+
summary: summarizeInstallOutcome(agents.length, skippedAgents.length, 0),
|
|
198
238
|
};
|
|
199
239
|
}
|
|
200
240
|
/**
|
|
@@ -590,7 +630,17 @@ function installerAddArgs(indexUrl, agents, skill) {
|
|
|
590
630
|
"skills",
|
|
591
631
|
"add",
|
|
592
632
|
indexUrl,
|
|
593
|
-
|
|
633
|
+
// The installer's own parser only recognizes the singular `-a`/`--agent`
|
|
634
|
+
// flag (confirmed against `npx skills --help`, 1.5.x). The plural
|
|
635
|
+
// `--agents` this used to send is silently dropped as an unrecognized
|
|
636
|
+
// flag, so our explicit agent list never reaches the installer at all —
|
|
637
|
+
// it falls through to its own "no --agent given" auto-detect path, which
|
|
638
|
+
// (non-interactively, via --yes) sweeps in every agent sharing the
|
|
639
|
+
// universal `.agents/skills` convention (Codex, Cursor, but also things
|
|
640
|
+
// like PromptScript/Neovate/Pochi/Zenflow the user never asked for and
|
|
641
|
+
// that don't all support `--global`), producing a wall of unrelated
|
|
642
|
+
// "Failed" lines for agents this install was never targeting.
|
|
643
|
+
"--agent",
|
|
594
644
|
...agents,
|
|
595
645
|
"--yes",
|
|
596
646
|
"--skill",
|
|
@@ -598,6 +648,61 @@ function installerAddArgs(indexUrl, agents, skill) {
|
|
|
598
648
|
"--full-depth",
|
|
599
649
|
];
|
|
600
650
|
}
|
|
651
|
+
// The installer's exit code doesn't reflect write-level (skill, agent)
|
|
652
|
+
// failures — `npx skills add` (1.5.x) exits 0 even when individual targets
|
|
653
|
+
// failed, reporting them only in a "Failed to install N" console section
|
|
654
|
+
// (there is no --json mode for `add` and no per-target exit code). Reading
|
|
655
|
+
// that text is the only signal available: a failure naming one of the agents
|
|
656
|
+
// we actually requested is real and must still surface as a failure; a
|
|
657
|
+
// failure naming any other agent means the installer targeted something this
|
|
658
|
+
// install was never for (see installerAddArgs) — report that as skipped, not
|
|
659
|
+
// a failure, so an agent that merely isn't on this machine never reads as
|
|
660
|
+
// "something is broken."
|
|
661
|
+
/** "GitHub Copilot" and "github-copilot" are the same agent; compare them as one. */
|
|
662
|
+
function slugifyAgentLabel(label) {
|
|
663
|
+
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
664
|
+
}
|
|
665
|
+
function classifyInstallerFailures(stdout, requestedAgents) {
|
|
666
|
+
const plain = stdout.replace(/\x1B\[[0-9;]*m/g, "");
|
|
667
|
+
// The installer labels each line with the agent's DISPLAY name ("GitHub
|
|
668
|
+
// Copilot") while we requested its slug ("github-copilot"). Matching through a
|
|
669
|
+
// hand-maintained display-name map silently misclassified every agent missing
|
|
670
|
+
// from it — ~27 of the installer's targets have displayName ≠ slug — turning a
|
|
671
|
+
// real permission error into a reassuring "skipped, not detected". Slugify both
|
|
672
|
+
// sides instead, so the comparison cannot rot as the installer adds agents.
|
|
673
|
+
const requestedNames = new Set(requestedAgents.flatMap((agent) => [slugifyAgentLabel(agent), slugifyAgentLabel(AGENT_DISPLAY_NAMES[agent] ?? agent)]));
|
|
674
|
+
const failed = [];
|
|
675
|
+
const skipped = [];
|
|
676
|
+
const lineRe = /✗\s+(.+?)\s+→\s+(.+?):\s+(.+)/g;
|
|
677
|
+
let match;
|
|
678
|
+
while ((match = lineRe.exec(plain))) {
|
|
679
|
+
const skillName = match[1]?.trim();
|
|
680
|
+
const agentLabel = match[2]?.trim();
|
|
681
|
+
const reason = match[3]?.trim();
|
|
682
|
+
if (!skillName || !agentLabel || !reason)
|
|
683
|
+
continue;
|
|
684
|
+
if (requestedNames.has(slugifyAgentLabel(agentLabel))) {
|
|
685
|
+
failed.push({ skill: skillName, agent: agentLabel, reason });
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
skipped.push({ skill: skillName, agent: agentLabel });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return { failed, skipped };
|
|
692
|
+
}
|
|
693
|
+
// Truthful, non-alarming one-line summary for both human and --json readers:
|
|
694
|
+
// counts what actually happened instead of letting a "not detected" target
|
|
695
|
+
// read like a broken install.
|
|
696
|
+
function summarizeInstallOutcome(installedAgents, skippedAgents, failedAgents) {
|
|
697
|
+
const parts = [`installed for ${installedAgents} agent${installedAgents === 1 ? "" : "s"}`];
|
|
698
|
+
if (skippedAgents > 0) {
|
|
699
|
+
parts.push(`skipped ${skippedAgents} agent${skippedAgents === 1 ? "" : "s"} not detected on this machine`);
|
|
700
|
+
}
|
|
701
|
+
if (failedAgents > 0) {
|
|
702
|
+
parts.push(`${failedAgents} agent${failedAgents === 1 ? "" : "s"} failed`);
|
|
703
|
+
}
|
|
704
|
+
return `${parts.join("; ")}.`;
|
|
705
|
+
}
|
|
601
706
|
function readWords(value) {
|
|
602
707
|
const text = Array.isArray(value) ? value.join(" ") : value;
|
|
603
708
|
return text
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one coercion helper the formula engine needs. Kept here (rather than
|
|
3
|
+
* imported from a host package) so the engine stays dependency-free and
|
|
4
|
+
* browser-safe; tenant-db re-exports it from its own `coerce.ts` so the
|
|
5
|
+
* existing internal import path keeps working.
|
|
6
|
+
*/
|
|
7
|
+
/** Loose record check: any non-array object (class instances included). */
|
|
8
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one coercion helper the formula engine needs. Kept here (rather than
|
|
3
|
+
* imported from a host package) so the engine stays dependency-free and
|
|
4
|
+
* browser-safe; tenant-db re-exports it from its own `coerce.ts` so the
|
|
5
|
+
* existing internal import path keeps working.
|
|
6
|
+
*/
|
|
7
|
+
/** Loose record check: any non-array object (class instances included). */
|
|
8
|
+
export function isRecord(value) {
|
|
9
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type FormulaAst, type ParseOptions } from "./expression.js";
|
|
2
|
+
/**
|
|
3
|
+
* Expression evaluation. The engine never sees the host's data shape: every
|
|
4
|
+
* bare identifier goes through `FormulaScope.resolve`, so the same evaluator
|
|
5
|
+
* serves a tenant-db table row, a workflow node's inputs, and a browser
|
|
6
|
+
* preview pane with no host imports.
|
|
7
|
+
*/
|
|
8
|
+
/** Cross-row corpus, keyed by the column key the corpus was loaded for. */
|
|
9
|
+
export type FormulaCrossRowCorpus = Record<string, readonly string[]>;
|
|
10
|
+
export type FormulaScope = {
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a bare identifier to a value. Throw (with
|
|
13
|
+
* `formulaExpressionError`) when the identifier is not in scope — a typo'd
|
|
14
|
+
* reference must fail loudly rather than silently evaluate to null.
|
|
15
|
+
*/
|
|
16
|
+
resolve(identifier: string): unknown;
|
|
17
|
+
/**
|
|
18
|
+
* Lenient read used only by cross-row functions, which take a column key
|
|
19
|
+
* rather than an in-scope identifier and treat an absent value as blank.
|
|
20
|
+
* Omit it and the strict `resolve` is used, with its throw absorbed.
|
|
21
|
+
*/
|
|
22
|
+
resolveOptional?(identifier: string): unknown;
|
|
23
|
+
/** Cross-row corpus, required by `novelty_score`. */
|
|
24
|
+
corpus?: FormulaCrossRowCorpus;
|
|
25
|
+
/** The key being computed — carried for self-reference guards. */
|
|
26
|
+
selfKey?: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function evaluateFormulaInScope(expression: string, scope: FormulaScope, options?: ParseOptions): unknown;
|
|
29
|
+
export declare function evaluateFormulaAst(node: FormulaAst, scope: FormulaScope): unknown;
|
|
30
|
+
/** Coerce a formula result to the boolean gate decision used for run conditions. */
|
|
31
|
+
export declare function isTruthyFormulaResult(value: unknown): boolean;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
2
|
+
import { isRecord } from "./coerce.js";
|
|
3
|
+
import { CROSS_ROW_FUNCTIONS, parseFormulaExpression, } from "./expression.js";
|
|
4
|
+
import { FORMULA_FUNCTION_REGISTRY, checkFormulaFunctionArity, compareFormulaValues, formulaExpressionError, formulaValuesEqual, readFormulaNumber, toFormulaBoolean, } from "./formula-functions.js";
|
|
5
|
+
export function evaluateFormulaInScope(expression, scope, options = {}) {
|
|
6
|
+
return evaluateFormulaAst(parseFormulaExpression(expression, options), scope);
|
|
7
|
+
}
|
|
8
|
+
export function evaluateFormulaAst(node, scope) {
|
|
9
|
+
switch (node.type) { // skipcq: JS-0047
|
|
10
|
+
case "literal":
|
|
11
|
+
return node.value;
|
|
12
|
+
case "identifier":
|
|
13
|
+
return scope.resolve(node.name);
|
|
14
|
+
case "unary":
|
|
15
|
+
return evaluateUnary(node.operator, evaluateFormulaAst(node.argument, scope));
|
|
16
|
+
case "binary":
|
|
17
|
+
return evaluateBinary(node.operator, () => evaluateFormulaAst(node.left, scope), () => evaluateFormulaAst(node.right, scope));
|
|
18
|
+
case "call": {
|
|
19
|
+
const normalized = node.name.toLowerCase();
|
|
20
|
+
if (CROSS_ROW_FUNCTIONS.has(normalized)) {
|
|
21
|
+
return evaluateCrossRowFunction({ name: normalized, args: node.args, scope });
|
|
22
|
+
}
|
|
23
|
+
const spec = FORMULA_FUNCTION_REGISTRY.get(normalized);
|
|
24
|
+
if (spec)
|
|
25
|
+
checkFormulaFunctionArity(spec, node.args.length);
|
|
26
|
+
if (spec?.lazy && spec.evaluateLazy) {
|
|
27
|
+
return spec.evaluateLazy(node.args.map((arg) => () => evaluateFormulaAst(arg, scope)));
|
|
28
|
+
}
|
|
29
|
+
if (spec?.evaluate) {
|
|
30
|
+
return spec.evaluate(node.args.map((arg) => evaluateFormulaAst(arg, scope)));
|
|
31
|
+
}
|
|
32
|
+
throw formulaExpressionError("Unsupported formula function.", {
|
|
33
|
+
function: node.name,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Coerce a formula result to the boolean gate decision used for run conditions. */
|
|
39
|
+
export function isTruthyFormulaResult(value) {
|
|
40
|
+
if (typeof value === "boolean")
|
|
41
|
+
return value;
|
|
42
|
+
if (typeof value === "number")
|
|
43
|
+
return value !== 0;
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
const normalized = value.trim().toLowerCase();
|
|
46
|
+
return normalized !== "" && normalized !== "false" && normalized !== "0" && normalized !== "no";
|
|
47
|
+
}
|
|
48
|
+
return value !== null && value !== undefined;
|
|
49
|
+
}
|
|
50
|
+
function evaluateCrossRowFunction(input) {
|
|
51
|
+
if (input.name !== "novelty_score") {
|
|
52
|
+
throw formulaExpressionError("Unsupported cross-row formula function.", {
|
|
53
|
+
function: input.name,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const firstArg = input.args[0];
|
|
57
|
+
if (input.args.length !== 1 || !firstArg) {
|
|
58
|
+
throw formulaExpressionError("novelty_score requires exactly one column argument.", {
|
|
59
|
+
function: input.name,
|
|
60
|
+
received: input.args.length,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const targetColumnKey = readCrossRowColumnArg(firstArg, input.name);
|
|
64
|
+
if (!input.scope.corpus) {
|
|
65
|
+
throw new OxygenError("novelty_score_corpus_unavailable", "novelty_score requires a sync columns run on the current table so the column corpus can be loaded.", {
|
|
66
|
+
details: {
|
|
67
|
+
column_key: targetColumnKey,
|
|
68
|
+
function: input.name,
|
|
69
|
+
hint: "Re-run with `oxygen columns run` (sync) instead of `--background` so the corpus is available.",
|
|
70
|
+
},
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const corpus = input.scope.corpus[targetColumnKey];
|
|
75
|
+
if (!corpus) {
|
|
76
|
+
throw new OxygenError("novelty_score_corpus_unavailable", `novelty_score corpus for column ${targetColumnKey} is empty or was not provided.`, {
|
|
77
|
+
details: { column_key: targetColumnKey, function: input.name },
|
|
78
|
+
exitCode: 1,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const cellValue = stringifyNoveltyValue(readOptionalValue(input.scope, targetColumnKey));
|
|
82
|
+
if (!cellValue)
|
|
83
|
+
return null;
|
|
84
|
+
const noveltyScore = computeNoveltyScore(cellValue, corpus);
|
|
85
|
+
return Math.round(noveltyScore * 10_000) / 10_000;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Cross-row functions take a column key, not an in-scope identifier: a column
|
|
89
|
+
* that exists in the corpus but not on this row scores as blank instead of
|
|
90
|
+
* failing the row. Scopes that can answer leniently supply `resolveOptional`;
|
|
91
|
+
* otherwise the strict resolver's unknown-identifier throw is absorbed here.
|
|
92
|
+
*/
|
|
93
|
+
function readOptionalValue(scope, identifier) {
|
|
94
|
+
if (scope.resolveOptional)
|
|
95
|
+
return scope.resolveOptional(identifier);
|
|
96
|
+
try {
|
|
97
|
+
return scope.resolve(identifier);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function readCrossRowColumnArg(node, functionName) {
|
|
104
|
+
if (node.type === "identifier")
|
|
105
|
+
return node.name;
|
|
106
|
+
if (node.type === "literal" && typeof node.value === "string")
|
|
107
|
+
return node.value;
|
|
108
|
+
throw formulaExpressionError("Cross-row function requires a column key argument.", {
|
|
109
|
+
function: functionName,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function stringifyNoveltyValue(value) {
|
|
113
|
+
if (value === null || value === undefined)
|
|
114
|
+
return "";
|
|
115
|
+
if (typeof value === "string")
|
|
116
|
+
return value.trim();
|
|
117
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
118
|
+
return String(value).trim();
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
return value
|
|
121
|
+
.map((entry) => stringifyNoveltyValue(entry))
|
|
122
|
+
.filter((entry) => entry.length > 0)
|
|
123
|
+
.join(" ")
|
|
124
|
+
.trim();
|
|
125
|
+
}
|
|
126
|
+
if (isRecord(value)) {
|
|
127
|
+
const preferred = value.body ?? value.text ?? value.email ?? value.message;
|
|
128
|
+
if (typeof preferred === "string")
|
|
129
|
+
return preferred.trim();
|
|
130
|
+
return JSON.stringify(value);
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
const NOVELTY_SHINGLE_SIZE = 5;
|
|
135
|
+
function computeNoveltyScore(cellValue, corpus) {
|
|
136
|
+
const cellShingles = buildShingleSet(cellValue);
|
|
137
|
+
if (cellShingles.size === 0)
|
|
138
|
+
return 1;
|
|
139
|
+
let maxOverlap = 0;
|
|
140
|
+
let skippedSelfMatch = false;
|
|
141
|
+
for (const other of corpus) {
|
|
142
|
+
if (other === cellValue && !skippedSelfMatch) {
|
|
143
|
+
skippedSelfMatch = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const otherShingles = buildShingleSet(other);
|
|
147
|
+
if (otherShingles.size === 0)
|
|
148
|
+
continue;
|
|
149
|
+
const overlap = jaccardSimilarity(cellShingles, otherShingles);
|
|
150
|
+
if (overlap > maxOverlap)
|
|
151
|
+
maxOverlap = overlap;
|
|
152
|
+
}
|
|
153
|
+
const novelty = 1 - maxOverlap;
|
|
154
|
+
if (novelty < 0)
|
|
155
|
+
return 0;
|
|
156
|
+
if (novelty > 1)
|
|
157
|
+
return 1;
|
|
158
|
+
return novelty;
|
|
159
|
+
}
|
|
160
|
+
function buildShingleSet(value) {
|
|
161
|
+
const normalized = value
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
164
|
+
.replace(/\s+/g, " ")
|
|
165
|
+
.trim();
|
|
166
|
+
const tokens = normalized ? normalized.split(" ") : [];
|
|
167
|
+
const shingles = new Set();
|
|
168
|
+
if (tokens.length < NOVELTY_SHINGLE_SIZE) {
|
|
169
|
+
if (tokens.length > 0)
|
|
170
|
+
shingles.add(tokens.join(" "));
|
|
171
|
+
return shingles;
|
|
172
|
+
}
|
|
173
|
+
for (let i = 0; i <= tokens.length - NOVELTY_SHINGLE_SIZE; i += 1) {
|
|
174
|
+
shingles.add(tokens.slice(i, i + NOVELTY_SHINGLE_SIZE).join(" "));
|
|
175
|
+
}
|
|
176
|
+
return shingles;
|
|
177
|
+
}
|
|
178
|
+
function jaccardSimilarity(left, right) {
|
|
179
|
+
let intersection = 0;
|
|
180
|
+
const [smaller, larger] = left.size <= right.size ? [left, right] : [right, left];
|
|
181
|
+
for (const entry of smaller) {
|
|
182
|
+
if (larger.has(entry))
|
|
183
|
+
intersection += 1;
|
|
184
|
+
}
|
|
185
|
+
const union = left.size + right.size - intersection;
|
|
186
|
+
return union === 0 ? 0 : intersection / union;
|
|
187
|
+
}
|
|
188
|
+
function evaluateUnary(operator, value) {
|
|
189
|
+
switch (operator) {
|
|
190
|
+
case "+":
|
|
191
|
+
return readFormulaNumber(value, operator);
|
|
192
|
+
case "-":
|
|
193
|
+
return -readFormulaNumber(value, operator);
|
|
194
|
+
case "not":
|
|
195
|
+
return !toFormulaBoolean(value);
|
|
196
|
+
default:
|
|
197
|
+
throw formulaExpressionError("Unsupported unary operator.", { operator });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function evaluateBinary(// skipcq: JS-R1005
|
|
201
|
+
operator, leftThunk, rightThunk) {
|
|
202
|
+
if (operator === "and") {
|
|
203
|
+
return toFormulaBoolean(leftThunk()) && toFormulaBoolean(rightThunk());
|
|
204
|
+
}
|
|
205
|
+
if (operator === "or") {
|
|
206
|
+
return toFormulaBoolean(leftThunk()) || toFormulaBoolean(rightThunk());
|
|
207
|
+
}
|
|
208
|
+
const left = leftThunk();
|
|
209
|
+
const right = rightThunk();
|
|
210
|
+
switch (operator) {
|
|
211
|
+
case "=":
|
|
212
|
+
case "==":
|
|
213
|
+
return formulaValuesEqual(left, right);
|
|
214
|
+
case "!=":
|
|
215
|
+
case "<>":
|
|
216
|
+
return !formulaValuesEqual(left, right);
|
|
217
|
+
case "<":
|
|
218
|
+
return compareFormulaValues(left, right) < 0;
|
|
219
|
+
case "<=":
|
|
220
|
+
return compareFormulaValues(left, right) <= 0;
|
|
221
|
+
case ">":
|
|
222
|
+
return compareFormulaValues(left, right) > 0;
|
|
223
|
+
case ">=":
|
|
224
|
+
return compareFormulaValues(left, right) >= 0;
|
|
225
|
+
case "+":
|
|
226
|
+
return readFormulaNumber(left, operator) + readFormulaNumber(right, operator);
|
|
227
|
+
case "-":
|
|
228
|
+
return readFormulaNumber(left, operator) - readFormulaNumber(right, operator);
|
|
229
|
+
case "*":
|
|
230
|
+
return readFormulaNumber(left, operator) * readFormulaNumber(right, operator);
|
|
231
|
+
case "/": {
|
|
232
|
+
const divisor = readFormulaNumber(right, operator);
|
|
233
|
+
if (divisor === 0) {
|
|
234
|
+
throw formulaExpressionError("Formula division by zero.", { operator });
|
|
235
|
+
}
|
|
236
|
+
return readFormulaNumber(left, operator) / divisor;
|
|
237
|
+
}
|
|
238
|
+
case "%": {
|
|
239
|
+
const divisor = readFormulaNumber(right, operator);
|
|
240
|
+
if (divisor === 0) {
|
|
241
|
+
throw formulaExpressionError("Formula modulo by zero.", { operator });
|
|
242
|
+
}
|
|
243
|
+
return readFormulaNumber(left, operator) % divisor;
|
|
244
|
+
}
|
|
245
|
+
default:
|
|
246
|
+
throw formulaExpressionError("Unsupported binary operator.", { operator });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** The parsed expression tree. Identifiers are resolved by a FormulaScope. */
|
|
2
|
+
export type FormulaAst = {
|
|
3
|
+
type: "literal";
|
|
4
|
+
value: unknown;
|
|
5
|
+
} | {
|
|
6
|
+
type: "identifier";
|
|
7
|
+
name: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "unary";
|
|
10
|
+
operator: string;
|
|
11
|
+
argument: FormulaAst;
|
|
12
|
+
} | {
|
|
13
|
+
type: "binary";
|
|
14
|
+
operator: string;
|
|
15
|
+
left: FormulaAst;
|
|
16
|
+
right: FormulaAst;
|
|
17
|
+
} | {
|
|
18
|
+
type: "call";
|
|
19
|
+
name: string;
|
|
20
|
+
args: FormulaAst[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Functions evaluated against the whole column rather than the current scope.
|
|
24
|
+
* They are intercepted before registry dispatch (the registry entry carries
|
|
25
|
+
* metadata only) because they need a corpus the scope supplies.
|
|
26
|
+
*/
|
|
27
|
+
export declare const CROSS_ROW_FUNCTIONS: ReadonlySet<string>;
|
|
28
|
+
/**
|
|
29
|
+
* How to tokenize. One flag today, and it is deliberately opt-in: a table formula
|
|
30
|
+
* must parse byte-identically to before this existed.
|
|
31
|
+
*/
|
|
32
|
+
export type ParseOptions = {
|
|
33
|
+
/**
|
|
34
|
+
* Let an identifier carry `.field` and `[0]` segments, so `steps.enrich.email`
|
|
35
|
+
* is one identifier rather than a syntax error. Workflow expressions set this;
|
|
36
|
+
* table formulas do not, because a bare column name is their whole vocabulary and
|
|
37
|
+
* `a.b` there should stay the error it has always been.
|
|
38
|
+
*/
|
|
39
|
+
paths?: boolean;
|
|
40
|
+
};
|
|
41
|
+
export declare function parseFormulaExpression(expression: string, options?: ParseOptions): FormulaAst;
|
|
42
|
+
/** Validate supported functions and arity on an already-parsed tree. */
|
|
43
|
+
export declare function validateFormulaAst(ast: FormulaAst): void;
|
|
44
|
+
/** Validate syntax, supported functions, and arity without evaluating any data. */
|
|
45
|
+
export declare function validateFormulaExpression(expression: string, options?: ParseOptions): void;
|
|
46
|
+
export declare function walkExpression(node: FormulaAst, visit: (node: FormulaAst) => void): void;
|
|
47
|
+
export declare function analyzeCrossRowDependencies(node: FormulaAst): {
|
|
48
|
+
functions: string[];
|
|
49
|
+
columnKeys: string[];
|
|
50
|
+
};
|
|
51
|
+
export declare function analyzeFormulaCrossRowDependencies(expression: string): {
|
|
52
|
+
functions: string[];
|
|
53
|
+
columnKeys: string[];
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Every identifier the expression reads, plus its cross-row dependencies.
|
|
57
|
+
* Identifiers are bare names — the host decides what they bind to (a table
|
|
58
|
+
* column key today, a workflow node output tomorrow).
|
|
59
|
+
*/
|
|
60
|
+
export declare function analyzeFormulaDependencies(expression: string, options?: ParseOptions): {
|
|
61
|
+
identifiers: string[];
|
|
62
|
+
crossRowFunctions: string[];
|
|
63
|
+
crossRowColumnKeys: string[];
|
|
64
|
+
};
|