@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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +10 -0
  3. package/dist/column-run-notices.js +22 -0
  4. package/dist/command-manifest.js +4 -0
  5. package/dist/index.js +187 -73
  6. package/dist/local-custom-http-column.js +12 -37
  7. package/node_modules/@oxygen/formula/dist/formula-functions.js +10 -24
  8. package/node_modules/@oxygen/shared/dist/cell-format.js +23 -2
  9. package/node_modules/@oxygen/shared/dist/column-output-fields.d.ts +122 -0
  10. package/node_modules/@oxygen/shared/dist/column-output-fields.js +459 -0
  11. package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
  12. package/node_modules/@oxygen/shared/dist/index.js +1 -0
  13. package/node_modules/@oxygen/shared/dist/json-path.d.ts +109 -0
  14. package/node_modules/@oxygen/shared/dist/json-path.js +177 -0
  15. package/node_modules/@oxygen/shared/dist/log-collapse.d.ts +6 -3
  16. package/node_modules/@oxygen/shared/dist/log-collapse.js +6 -3
  17. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +2 -0
  18. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +13 -0
  19. package/node_modules/@oxygen/shared/dist/research-output-contract.d.ts +53 -0
  20. package/node_modules/@oxygen/shared/dist/research-output-contract.js +196 -0
  21. package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +17 -1
  22. package/node_modules/@oxygen/shared/dist/sending-seats.js +17 -1
  23. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +47 -0
  24. package/node_modules/@oxygen/shared/dist/sequence-failures.js +301 -0
  25. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +1 -0
  26. package/node_modules/@oxygen/shared/dist/telemetry.js +119 -2
  27. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  28. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  29. package/node_modules/@oxygen/shared/package.json +15 -0
  30. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.861.0
37
+ Version: 1.879.0
@@ -9,3 +9,13 @@
9
9
  * live run, or a preview the server could not produce).
10
10
  */
11
11
  export declare function formatAiPromptPreviewNotice(data: unknown): string | null;
12
+ /**
13
+ * Advisory reference findings from `columns add` / `update` / `rename`.
14
+ *
15
+ * An unresolvable reference is a hard rejection, so anything here is something
16
+ * the author should see but does not have to act on: an input the prompt never
17
+ * writes down (the value still reaches the model, but nothing links the two),
18
+ * a column named in prose and never read, or a rename that just orphaned a
19
+ * sibling's reference. Printed to stderr so `--json` piping stays clean.
20
+ */
21
+ export declare function formatColumnReferenceNotices(data: unknown): string[];
@@ -35,3 +35,25 @@ export function formatAiPromptPreviewNotice(data) {
35
35
  shown,
36
36
  ].join("\n");
37
37
  }
38
+ /**
39
+ * Advisory reference findings from `columns add` / `update` / `rename`.
40
+ *
41
+ * An unresolvable reference is a hard rejection, so anything here is something
42
+ * the author should see but does not have to act on: an input the prompt never
43
+ * writes down (the value still reaches the model, but nothing links the two),
44
+ * a column named in prose and never read, or a rename that just orphaned a
45
+ * sibling's reference. Printed to stderr so `--json` piping stays clean.
46
+ */
47
+ export function formatColumnReferenceNotices(data) {
48
+ if (!isRecord(data) || !Array.isArray(data.reference_warnings))
49
+ return [];
50
+ const notices = [];
51
+ for (const entry of data.reference_warnings) {
52
+ if (!isRecord(entry))
53
+ continue;
54
+ const message = readRecordString(entry, "message");
55
+ if (message)
56
+ notices.push(`warning: ${message}`);
57
+ }
58
+ return notices;
59
+ }
@@ -32,6 +32,9 @@ const MUTATING_VERBS = new Set([
32
32
  // default (mutates:false) would advertise the most consequential write in the
33
33
  // activation play as a read.
34
34
  "autoenroll",
35
+ // Exact hyphenated leaf: EmailGuard auto-enrolment arms or revokes a standing
36
+ // recurring write, and --run can connect newly eligible mailboxes immediately.
37
+ "auto-enroll",
35
38
  "backfill",
36
39
  // Exact leaves, not the `billing` prefix — `orgs billing-owners` is a read.
37
40
  // Both move a workspace's billing owner, so an agent must treat them as writes.
@@ -84,6 +87,7 @@ const PREVIEW_BY_DEFAULT_COMMANDS = new Set([
84
87
  // audience and caps and writes nothing. Without this entry discovery would tell
85
88
  // an agent that previewing the play arms it, and the preview would go unrun.
86
89
  "linkedin intent autoenroll",
90
+ "mailboxes emailguard auto-enroll",
87
91
  "mailboxes delete",
88
92
  "support admin done",
89
93
  "support admin reply",
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
21
21
  import { acquireMirrorLock, clearConflictFiles, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, listConflictFiles, listLocalMirrors, localPageSha256, markMirrorStale, mirrorExists, pageFilePath, planMirrorPush, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile, } from "./knowledge-mirror.js";
22
22
  import { waitForCliRun } from "./run-wait.js";
23
23
  import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJsonObjectOption, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
24
- import { formatAiPromptPreviewNotice } from "./column-run-notices.js";
24
+ import { formatAiPromptPreviewNotice, formatColumnReferenceNotices, } from "./column-run-notices.js";
25
25
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
26
26
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
27
27
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
@@ -536,6 +536,11 @@ function writeAiPromptPreviewNotice(data) {
536
536
  if (notice)
537
537
  process.stderr.write(`${notice}\n`);
538
538
  }
539
+ function writeColumnReferenceNotices(data) {
540
+ for (const notice of formatColumnReferenceNotices(data)) {
541
+ process.stderr.write(`${notice}\n`);
542
+ }
543
+ }
539
544
  function writeDisabledWorkflowNotices(data) {
540
545
  for (const notice of formatDisabledWorkflowNotices(data)) {
541
546
  process.stderr.write(`${notice}\n`);
@@ -7116,7 +7121,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7116
7121
  .option("--kind <kind>", "Column kind: manual, research, ai, formula, enrichment, tool, bind, or lookup. Defaults to manual. Use research for anything you would look up on the web. `lookup` reads a value out of another table; to LINK two tables row-to-row use `oxygen tables relate` instead \u2014 relation columns are two-sided and cannot be added here.")
7117
7122
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
7118
7123
  .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
7119
- .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai and picks the data type (text, or jsonb with an output schema); pair it with --kind research to search the web per row instead. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
7124
+ .option("--prompt <text-or-file>", "AI or research column prompt, or a path to a prompt file — a value that resolves to a readable file is read as one, matching --prompt everywhere else in this CLI. On its own it sets kind=ai; pair it with --kind research to search the web per row instead. If the prompt names its output sections — a line reading 'Return the following sections:' followed by 'Score: ...', 'Reasoning: ...' — the column answers in exactly that shape and each section becomes a referenceable sub-column; otherwise it answers in plain text. Use --no-structured-output to keep it plain text either way. Reference other columns inline as {{column_key}} — no --input-mapping needed; unknown keys are rejected here instead of failing per row. Merges into --definition-json (the escape hatch for everything else); a `prompt` in both is an error.")
7120
7125
  .option("--research-query <template>", "Research columns: the per-row web search query, templated like the prompt (e.g. \"{{company_name}} pricing page\"). Omit to derive the query from the row's values and the prompt.")
7121
7126
  .option("--research-domains <csv>", "Research columns: comma-separated domains to search within, e.g. techcrunch.com,sec.gov. Omit to search the whole web.")
7122
7127
  .option("--research-exclude-domains <csv>", "Research columns: comma-separated domains to exclude from results.")
@@ -7127,8 +7132,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7127
7132
  .option("--input-mapping <json>", "Required with --prompt-key. JSON object mapping prompt input names to column or literal refs. Copy templates (email_draft_v1, subject_line_variants_v1, ...) reject mappings built only from manual name/title/company columns — ground them on a research or enrichment column, or write a freeform prompt with --prompt instead.")
7128
7133
  .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
7129
7134
  .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
7130
- .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row.")
7135
+ .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
7131
7136
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
7137
+ .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
7138
+ .option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
7132
7139
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
7133
7140
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
7134
7141
  .option("--bind-object <slug>", "Bind column CRM object slug (companies, people, deals, ...). Sets kind=bind and resolves rows to CRM records by identity.")
@@ -7274,12 +7281,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7274
7281
  body.prompt_key = options.promptKey;
7275
7282
  if (options.inputMapping)
7276
7283
  body.input_mapping = parseJsonObject(options.inputMapping);
7277
- if (prompt !== null)
7278
- await assertPromptColumnReferences(table, prompt);
7279
- return requestOxygen("/api/cli/tables/columns", {
7284
+ // Every authoring path, not just --prompt: a definition pasted into
7285
+ // --definition-json used to skip this entirely and fail once per row.
7286
+ await assertColumnDefinitionReferences(table, column.definition);
7287
+ const created = await requestOxygen("/api/cli/tables/columns", {
7280
7288
  method: "POST",
7281
7289
  body,
7282
7290
  });
7291
+ if (!options.json)
7292
+ writeColumnReferenceNotices(created);
7293
+ return created;
7283
7294
  });
7284
7295
  }))
7285
7296
  .addCommand(new Command("run")
@@ -7421,25 +7432,30 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7421
7432
  }));
7422
7433
  }))
7423
7434
  .addCommand(new Command("rename")
7424
- .description("Rename a workspace column key and optionally label.")
7435
+ .description("Rename a workspace column key and optionally label. Warns when another column reads the old key — a rename does not rewrite the references pointing at it.")
7425
7436
  .argument("<table>", "Table id or slug.")
7426
7437
  .argument("<column>", "Column id or key.")
7427
7438
  .requiredOption("--key <key>", "New stable column key.")
7428
7439
  .option("--label <label>", "Optional new display label.")
7429
7440
  .option("--json", "Print a JSON envelope.")
7430
7441
  .action(async (table, column, options) => {
7431
- await handleAsyncAction("columns rename", options, () => requestOxygen("/api/cli/tables/columns/rename", {
7432
- method: "POST",
7433
- body: {
7434
- table,
7435
- column,
7436
- key: options.key,
7437
- ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
7438
- },
7439
- }));
7442
+ await handleAsyncAction("columns rename", options, async () => {
7443
+ const renamed = await requestOxygen("/api/cli/tables/columns/rename", {
7444
+ method: "POST",
7445
+ body: {
7446
+ table,
7447
+ column,
7448
+ key: options.key,
7449
+ ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
7450
+ },
7451
+ });
7452
+ if (!options.json)
7453
+ writeColumnReferenceNotices(renamed);
7454
+ return renamed;
7455
+ });
7440
7456
  }))
7441
7457
  .addCommand(new Command("update")
7442
- .description("Update workspace column display metadata.")
7458
+ .description("Update a workspace column: label and visibility, and for AI/research columns the prompt, named inputs, model, reasoning level, run condition, output schema, and web-research settings.")
7443
7459
  .argument("<table>", "Table id or slug.")
7444
7460
  .argument("<column>", "Column id or key.")
7445
7461
  .option("--label <label>", "New display label.")
@@ -7448,10 +7464,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7448
7464
  .option("--definition-unset <keys>", "Comma-separated definition keys to REMOVE after the merge (e.g. outputSchema,model) — a merge can only add or overwrite. Lifecycle and provenance keys are rejected.")
7449
7465
  .option("--data-type <type>", "New output data type for a lookup column (text, numeric, boolean, jsonb, timestamptz). Discards the cached cells; other column kinds use `oxygen columns retype`.")
7450
7466
  .option("--prompt <text-or-file>", "Replacement AI column prompt, or a path to a prompt file. Reference other columns inline as {{column_key}}; unknown keys are rejected here instead of failing per row. Merges into --definition-json; a `prompt` in both is an error.")
7467
+ .option("--input-mapping <json>", "Replace the column's named inputs wholesale. Row columns belong in the prompt as {{column_key}}; use this for the inputs a prompt cannot name on its own — literals and workspace context ({\"icp\":{\"type\":\"context_profile\",\"path\":\"icp\"}}), which the prompt then reads as {{icp}}. Pass {} to clear it; --definition-json can only add mapping keys, never remove one.")
7451
7468
  .option("--model <id>", "AI column model id (e.g. claude-sonnet-4-5). Explicit models require credentialMode byok unless allow-listed managed.")
7452
7469
  .option("--reasoning-level <level>", "AI column reasoning level: low, medium, or high (shown in the web UI as Oxygen Fast, Oxygen Balanced, and Oxygen Max).")
7453
- .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row.")
7470
+ .option("--run-condition <formula>", "Formula expression gating whether the AI column runs per row. A formula over bare column keys (eu_israel = true), not {{tokens}}.")
7454
7471
  .option("--run-condition-columns <csv>", "Comma-separated column keys referenced by --run-condition.")
7472
+ .option("--no-structured-output", "Keep an AI column answering in plain text even when its prompt names output sections. Use this for copy — an email body is one answer, not a set of fields.")
7473
+ .option("--structured-output", "Undo --no-structured-output: let the prompt's named sections shape the answer again.")
7455
7474
  .option("--output-schema-json <json>", "AI column output JSON schema as inline JSON.")
7456
7475
  .option("--output-schema-file <path>", "AI column output JSON schema read from a file path.")
7457
7476
  .option("--research-query <template>", "Research columns: replace the per-row web search query template, e.g. \"{{company_name}} pricing page\".")
@@ -7499,9 +7518,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7499
7518
  definition = applyResearchColumnConfig(definition, options);
7500
7519
  }
7501
7520
  const definitionUnset = readCsvOption(options.definitionUnset);
7502
- if (prompt !== null)
7503
- await assertPromptColumnReferences(table, prompt);
7504
- return requestOxygen("/api/cli/tables/columns/update", {
7521
+ // --input-mapping is the only way to repair a bad mapping in place:
7522
+ // the server SHALLOW-merges `definition`, so a nested inputMapping
7523
+ // sent through --definition-json adds keys and can never remove one.
7524
+ // This replaces the object wholesale.
7525
+ const inputMapping = readOption(options.inputMapping);
7526
+ if (inputMapping) {
7527
+ definition = { ...(definition ?? {}), inputMapping: parseJsonObject(inputMapping) };
7528
+ }
7529
+ await assertColumnDefinitionReferences(table, definition);
7530
+ const updated = await requestOxygen("/api/cli/tables/columns/update", {
7505
7531
  method: "POST",
7506
7532
  body: {
7507
7533
  table,
@@ -7518,6 +7544,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7518
7544
  ...(options.dryRun ? { dry_run: true } : {}),
7519
7545
  },
7520
7546
  });
7547
+ if (!options.json)
7548
+ writeColumnReferenceNotices(updated);
7549
+ return updated;
7521
7550
  });
7522
7551
  }))
7523
7552
  .addCommand(new Command("retype")
@@ -7627,6 +7656,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7627
7656
  params.set("column", column);
7628
7657
  return requestOxygen(`/api/cli/tables/columns/deps?${params.toString()}`, { method: "GET" });
7629
7658
  });
7659
+ }))
7660
+ .addCommand(new Command("fields")
7661
+ .description("List what a column's output exposes: every field you can reference as {{column.field}}, filter on, sort by, or promote to its own column with `columns materialize`. Works before the column has ever run. Read-only and free.")
7662
+ .argument("<table>", "Table id or slug.")
7663
+ .argument("<column>", "Column key or id.")
7664
+ .option("--json", "Print a JSON envelope.")
7665
+ .action(async (table, column, options) => {
7666
+ await handleAsyncAction("columns fields", options, () => {
7667
+ const params = new URLSearchParams({ table, column });
7668
+ return requestOxygen(`/api/cli/tables/columns/fields?${params.toString()}`, { method: "GET" });
7669
+ });
7630
7670
  }));
7631
7671
  program
7632
7672
  .command("action-column")
@@ -9948,7 +9988,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9948
9988
  .option("--company-domain-column <column>", "Column key or id containing the company domain for work_email. Pair with full-name/first+last for name+company providers.")
9949
9989
  .option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
9950
9990
  .option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
9951
- .option("--capability <capability>", "Capability to enrich: mobile_phone or work_email. Defaults to mobile_phone.")
9991
+ .option("--capability <capability>", "What to run: mobile_phone, work_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). Defaults to mobile_phone.")
9952
9992
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
9953
9993
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
9954
9994
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
@@ -9983,7 +10023,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
9983
10023
  .option("--company-name-column <column>", "Column key or id containing the company name for work_email when no domain is available.")
9984
10024
  .option("--company-linkedin-url-column <column>", "Column key or id containing the company's LinkedIn URL for company identity fallback.")
9985
10025
  .requiredOption("--max-credits <credits>", "Required credit ceiling for the queued run.")
9986
- .option("--capability <capability>", "Capability to enrich: mobile_phone or work_email. Defaults to mobile_phone.")
10026
+ .option("--capability <capability>", "What to run: mobile_phone, work_email, linkedin_url (find a missing value), or verify_email (grade an email the row already has). Defaults to mobile_phone.")
9987
10027
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
9988
10028
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
9989
10029
  .option("--provider-order <providers>", "Comma-separated provider order. Overrides the default cost-aware waterfall profile.")
@@ -12285,7 +12325,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12285
12325
  });
12286
12326
  })))
12287
12327
  .addCommand(new Command("analytics")
12288
- .description("Show organization-level sequencer analytics, per-sequence funnels, and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person across their campaign accounts. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow.")
12328
+ .description("Show organization-level sequencer analytics, per-sequence funnels, and native email attribution under analytics.emailAttribution: byMailbox, byDomain, and explicit unattributed facts. With --sequence, analytics.senderPerformance groups channel-conditional counts and rates by sending person; analytics.messagePerformance reports each copy-bearing deterministic step variant, target/actual distribution, contacted recipients, and type-specific accepted/reply/positive outcomes (absent for note-less connect-only campaigns). Message-level reply outcomes require preserved step/variant provenance, so older unattributed replies remain in campaign totals without being guessed into a message row. Provider-owned campaigns appear only after their telemetry is normalized into Oxygen's native Sequence ledgers. Also reports whether the reply → CRM automation is armed, with a link to the workflow.")
12289
12329
  .option("--range <range>", "Preset range: 7d, 14d, 28d, 30d, 90d, 180d, 365d, or all. Defaults to 14d. `all` starts at the oldest campaign in scope.")
12290
12330
  .option("--from <date>", "Custom start date (YYYY-MM-DD). Windows up to five years are accepted.")
12291
12331
  .option("--to <date>", "Custom end date (YYYY-MM-DD).")
@@ -12655,16 +12695,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12655
12695
  });
12656
12696
  }))
12657
12697
  .addCommand(new Command("contacts")
12658
- .description("List a launched campaign's table-backed contacts with full source variables and durable contacted/not-contacted status. Contacts removed from this campaign are hidden; source rows remain intact.")
12698
+ .description("List a launched campaign's table-backed contacts with full source variables, durable engagement stage, and factual or assigned sender. Contacts removed from this campaign are hidden; source rows remain intact.")
12659
12699
  .argument("<sequence>", "Sequence id or slug.")
12660
- .option("--contact-state <state>", "Filter: all, contacted, or not_contacted.", "all")
12700
+ .option("--contact-state <state>", "Filter: all, not_contacted, contacted, connected, replied, or positive_reply.", "all")
12661
12701
  .option("--limit <n>", "Maximum contacts to return (1-500).", "100")
12662
12702
  .option("--json", "Print a JSON envelope.")
12663
12703
  .action(async (sequence, options) => {
12664
12704
  await handleAsyncAction("sequences contacts", options, () => {
12665
12705
  const contactState = readOption(options.contactState) ?? "all";
12666
- if (!["all", "contacted", "not_contacted"].includes(contactState)) {
12667
- throw new Error("--contact-state must be all, contacted, or not_contacted.");
12706
+ if (!["all", "not_contacted", "contacted", "connected", "replied", "positive_reply"].includes(contactState)) {
12707
+ throw new Error("--contact-state must be all, not_contacted, contacted, connected, replied, or positive_reply.");
12668
12708
  }
12669
12709
  const limit = readPositiveInt(options.limit);
12670
12710
  if (limit === undefined || limit > 500) {
@@ -12764,7 +12804,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12764
12804
  await handleSequenceEventsAction(sequence, options);
12765
12805
  }))
12766
12806
  .addCommand(new Command("variants")
12767
- .description("A/B scoreboard for one sequence: per-step/per-variant results plus native email by_mailbox, by_domain, reply types, rates, and explicit unattributed facts. Provider-owned campaigns appear only after normalization into Oxygen's native Sequence ledgers. Also returns reversible auto-winner state (paused variants + evidence); flags run or override/reset that state.")
12807
+ .description("Generic per-step action/base-variant scoreboard for one sequence, plus native email by_mailbox, by_domain, reply types, rates, and explicit unattributed facts. This rollup is not the copy-only analytics.messagePerformance view; base rows can represent non-message actions, and campaign replies may lack step/variant provenance. Provider-owned campaigns appear only after normalization into Oxygen's native Sequence ledgers. Also returns reversible auto-winner state (paused variants + evidence); flags run or override/reset that state.")
12768
12808
  .argument("<sequence>", "Sequence id or slug.")
12769
12809
  .option("--auto-optimize", "Run the auto-winner now: pause the statistically-losing variant(s) of any step carrying an auto_optimize config, once every variant clears its thresholds, on that step's configured metric — reply (the recommended default), click, or open (click/open need native open/click tracking; opens are soft signals under Apple Mail Privacy Protection). Reversible.")
12770
12810
  .option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
@@ -14630,26 +14670,6 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
14630
14670
  .option("--json", "Print a JSON envelope.")
14631
14671
  .action(async (domain, options) => {
14632
14672
  await handleAsyncAction("domains tracking status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`));
14633
- })))
14634
- .addCommand(new Command("postmaster")
14635
- .description("Google Postmaster Tools onboarding for a sending domain: register it with Postmaster, publish the DNS verification TXT via its Cloudflare zone, and check verification status. FREE — 0 Oxygen credits.")
14636
- .addCommand(new Command("onboard")
14637
- .description("Register the domain with Google Postmaster Tools and publish the DNS verification TXT automatically via the domain's Cloudflare zone (or return the record for manual DNS); verification then completes automatically in the background. Without --approve, returns a preview. FREE — 0 Oxygen credits.")
14638
- .argument("<domain>", "Sending domain, such as acme.com.")
14639
- .option("--approve", "Perform the onboarding. Without this flag, returns a preview only.")
14640
- .option("--json", "Print a JSON envelope.")
14641
- .action(async (domain, options) => {
14642
- await handleAsyncAction("domains postmaster onboard", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/postmaster`, {
14643
- method: "POST",
14644
- body: { ...(options.approve ? { approved: true } : {}) },
14645
- }));
14646
- }))
14647
- .addCommand(new Command("status")
14648
- .description("Show the Google Postmaster Tools onboarding status for a domain: registration, DNS verification state, and whether reputation data is available. Read-only — 0 Oxygen credits.")
14649
- .argument("<domain>", "Sending domain, such as acme.com.")
14650
- .option("--json", "Print a JSON envelope.")
14651
- .action(async (domain, options) => {
14652
- await handleAsyncAction("domains postmaster status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/postmaster`));
14653
14673
  })))
14654
14674
  .addCommand(new Command("add")
14655
14675
  .description("BYOK CLOUDFLARE: onboard a domain registered elsewhere by creating a zone in your connected Cloudflare account. For managed domain + inbox provisioning use `managed-inboxes subscribe`.")
@@ -16813,19 +16833,62 @@ const AI_PROMPT_TEMPLATE_TOKEN = /{{\s*([^{}]+?)\s*}}/g;
16813
16833
  // in packages/tenant-db), so a prompt may legitimately reference them.
16814
16834
  const AI_PROMPT_SYSTEM_ROW_KEYS = ["_row_id", "_created_at", "_updated_at"];
16815
16835
  const AI_PROMPT_AVAILABLE_COLUMNS_SHOWN = 20;
16816
- // A prompt's `{{tokens}}` resolve straight off the row at run time
16817
- // (resolveWorkspaceTemplateString, packages/integrations/src/workspace-column-inputs.ts)
16818
- // — independently of inputMapping, which is why --prompt needs no mapping at all.
16819
- // The first dot-segment is the column key; the rest is a JSON path into that cell,
16820
- // so `{{research.summary}}` only requires a `research` column to exist.
16821
- function readAiPromptColumnRefs(prompt) {
16822
- const refs = [];
16836
+ function readAiPromptTemplateTokens(prompt) {
16837
+ const tokens = [];
16838
+ const seen = new Set();
16823
16839
  for (const match of prompt.matchAll(AI_PROMPT_TEMPLATE_TOKEN)) {
16824
- const columnKey = (match[1] ?? "").split(".")[0]?.trim();
16825
- if (columnKey && !refs.includes(columnKey))
16826
- refs.push(columnKey);
16840
+ const raw = (match[1] ?? "").trim();
16841
+ if (!raw || seen.has(raw))
16842
+ continue;
16843
+ seen.add(raw);
16844
+ const segments = raw.split(".").map((segment) => segment.trim()).filter(Boolean);
16845
+ const root = segments.shift();
16846
+ if (!root)
16847
+ continue;
16848
+ tokens.push({ raw, root, path: segments.length > 0 ? segments.join(".") : null });
16849
+ }
16850
+ return tokens;
16851
+ }
16852
+ /**
16853
+ * The first `{{column.path}}` whose column declares fields but not that one.
16854
+ *
16855
+ * Author-time rather than per-row, and deliberately narrow: only a column that
16856
+ * actually publishes `output_fields` is checked. An undeclared jsonb payload can
16857
+ * hold anything, so rejecting a path into one would refuse references that work.
16858
+ */
16859
+ function findUnknownColumnFieldReference(tokens, columns) {
16860
+ for (const token of tokens) {
16861
+ if (!token.path)
16862
+ continue;
16863
+ const column = (columns ?? []).find((entry) => entry.key === token.root);
16864
+ const fields = column?.output_fields ?? null;
16865
+ if (!fields || fields.length === 0)
16866
+ continue;
16867
+ const paths = fields.map((field) => field.path);
16868
+ if (paths.includes(token.path))
16869
+ continue;
16870
+ // A path INTO a declared object field is fine — the declaration stops at the
16871
+ // depth the schema described, not at the depth the payload goes.
16872
+ if (paths.some((path) => token.path?.startsWith(`${path}.`)))
16873
+ continue;
16874
+ const nearMisses = nearMissColumnKeys(token.path, paths);
16875
+ return new OxygenError("invalid_prompt_column", [
16876
+ `Prompt references {{${token.raw}}}, but "${token.root}" has no field "${token.path}".`,
16877
+ nearMisses.length > 0
16878
+ ? ` Did you mean {{${token.root}.${nearMisses[0]}}}?`
16879
+ : "",
16880
+ ` Fields on "${token.root}": ${paths.slice(0, AI_PROMPT_AVAILABLE_COLUMNS_SHOWN).join(", ")}.`,
16881
+ ].join(""), {
16882
+ details: {
16883
+ column: token.root,
16884
+ unknown_field: token.path,
16885
+ near_misses: nearMisses,
16886
+ available_fields: paths,
16887
+ },
16888
+ exitCode: 1,
16889
+ });
16827
16890
  }
16828
- return refs;
16891
+ return null;
16829
16892
  }
16830
16893
  // Plain Levenshtein distance, capped at the short keys this is used on — enough to
16831
16894
  // catch the single-character and transposition typos ({{frist_name}}) the near-miss
@@ -16853,21 +16916,64 @@ function nearMissColumnKeys(reference, columnKeys) {
16853
16916
  .slice(0, 3)
16854
16917
  .map((candidate) => candidate.key);
16855
16918
  }
16856
- // Nothing validates an AI column's prompt at write time: normalizeAiColumnDefinition
16857
- // (packages/tenant-db/src/tables.ts) reads `prompt` as an opaque string, so a
16858
- // `{{typo}}` is accepted and then fails per row with `invalid_column_run` — one
16859
- // wasted run per typo. `--prompt` names a table, so resolve it here against the
16860
- // table's live columns and fail before the write instead.
16861
- async function assertPromptColumnReferences(table, prompt) {
16862
- const refs = readAiPromptColumnRefs(prompt);
16863
- if (refs.length === 0)
16919
+ /**
16920
+ * Resolve a column definition's `{{tokens}}` against the table's live columns
16921
+ * BEFORE the write, so a typo is one typed error instead of one failed row per
16922
+ * row. `analyzeColumnReferences` (packages/tenant-db) is the authority and the
16923
+ * server now rejects the same thing on every surface; this is the local
16924
+ * pre-flight that saves a round trip and prints the near-miss hint.
16925
+ *
16926
+ * Scans the whole definition, not just `--prompt`: the prompt, the research
16927
+ * query template, and every `type: "template"` input all share one grammar, and
16928
+ * a definition pasted into `--definition-json` used to skip the check entirely.
16929
+ * A token naming one of the column's OWN inputs resolves at run time, so those
16930
+ * names count as known.
16931
+ */
16932
+ async function assertColumnDefinitionReferences(table, definition) {
16933
+ if (!isRecord(definition))
16864
16934
  return;
16865
- const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
16866
- const columnKeys = (describe.columns ?? []).map((column) => column.key).filter(Boolean);
16867
- const known = new Set([...columnKeys, ...AI_PROMPT_SYSTEM_ROW_KEYS]);
16935
+ const inputMapping = isRecord(definition.inputMapping) ? definition.inputMapping : {};
16936
+ const templates = [];
16937
+ if (typeof definition.prompt === "string")
16938
+ templates.push(definition.prompt);
16939
+ const webSearch = isRecord(definition.webSearch) ? definition.webSearch : {};
16940
+ if (typeof webSearch.queryTemplate === "string")
16941
+ templates.push(webSearch.queryTemplate);
16942
+ for (const ref of Object.values(inputMapping)) {
16943
+ if (isRecord(ref) && ref.type === "template" && typeof ref.value === "string") {
16944
+ templates.push(ref.value);
16945
+ }
16946
+ }
16947
+ if (templates.length === 0)
16948
+ return;
16949
+ await assertPromptColumnReferences(table, templates.join("\n"), Object.keys(inputMapping));
16950
+ }
16951
+ // The core of the check above. The server is authoritative — this is the local
16952
+ // pre-flight that fails before the round trip and names the near miss.
16953
+ async function assertPromptColumnReferences(table, prompt, inputNames = []) {
16954
+ const tokens = readAiPromptTemplateTokens(prompt);
16955
+ if (tokens.length === 0)
16956
+ return;
16957
+ // include_archived: an archived column is a metadata flag over a physical
16958
+ // column that still exists, so `{{archived_key}}` still resolves at run time.
16959
+ // Leaving them out would reject here what the server accepts — the one
16960
+ // divergence a local pre-flight must never have.
16961
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table, include_archived: true } });
16962
+ const columns = describe.columns ?? [];
16963
+ const columnKeys = columns.map((column) => column.key).filter(Boolean);
16964
+ const known = new Set([...columnKeys, ...AI_PROMPT_SYSTEM_ROW_KEYS, ...inputNames]);
16965
+ const refs = [...new Set(tokens.map((token) => token.root))];
16868
16966
  const unknown = refs.filter((ref) => !known.has(ref));
16869
- if (unknown.length === 0)
16967
+ // A path into a column that DOES exist is checked against what that column
16968
+ // declares it exposes, so `{{icp_fit.scoer}}` fails here rather than resolving
16969
+ // to null on every row of a paid run. Only checked when the column actually
16970
+ // declares fields — an undeclared jsonb payload can hold anything.
16971
+ if (unknown.length === 0) {
16972
+ const badPath = findUnknownColumnFieldReference(tokens, columns);
16973
+ if (badPath)
16974
+ throw badPath;
16870
16975
  return;
16976
+ }
16871
16977
  const nearMisses = nearMissColumnKeys(unknown[0], columnKeys);
16872
16978
  const shown = columnKeys.slice(0, AI_PROMPT_AVAILABLE_COLUMNS_SHOWN);
16873
16979
  const overflow = columnKeys.length - shown.length;
@@ -16875,7 +16981,7 @@ async function assertPromptColumnReferences(table, prompt) {
16875
16981
  // invalid_* so exitCodeForOxygenError classifies this as a validation
16876
16982
  // failure (exit 2) alongside the server's invalid_column_definition.
16877
16983
  "invalid_prompt_column", [
16878
- `Prompt references {{${unknown[0]}}}, which is not a column on table "${table}".`,
16984
+ `Prompt references {{${unknown[0]}}}, which is not a column on table "${table}" and not one of this column's inputs.`,
16879
16985
  nearMisses.length > 0
16880
16986
  ? ` Did you mean ${nearMisses.map((key) => `{{${key}}}`).join(", ")}?`
16881
16987
  : "",
@@ -16916,6 +17022,14 @@ function applyAiColumnConfig(definition, options) {
16916
17022
  else if (outputSchemaFile) {
16917
17023
  definition.outputSchema = readJsonFileValue(resolve(outputSchemaFile), "--output-schema-file");
16918
17024
  }
17025
+ // Only written when the flag was actually passed. Absent must stay absent: on
17026
+ // `columns update` the definition is merged, so writing `true` by default
17027
+ // would re-enable structure on a column the author had deliberately set to
17028
+ // prose without ever mentioning it.
17029
+ if (options.structuredOutput === false)
17030
+ definition.structuredOutput = false;
17031
+ else if (options.structuredOutput === true)
17032
+ definition.structuredOutput = true;
16919
17033
  return definition;
16920
17034
  }
16921
17035
  const RESEARCH_ENGINES = new Set(["exa", "parallel", "firecrawl"]);
@@ -1,5 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { OxygenError } from "@oxygen/shared";
3
+ import { readJsonPath, walkJsonPath } from "@oxygen/shared/json-path";
3
4
  import { CustomHttpUrlSafetyError, assertCustomHttpPublicUrlSyntax, assertCustomHttpResolvedHostAllowed, } from "@oxygen/shared/custom-http-safety";
4
5
  import { requestOxygen } from "./http-client.js";
5
6
  import { isRecord } from "./util.js";
@@ -668,49 +669,23 @@ function resolveLocalTemplateRowRoot(root, segments, row, inputName, rawPath) {
668
669
  return { current: row[root], segments };
669
670
  }
670
671
  function resolveLocalTemplateSegments(current, segments) {
671
- let value = current;
672
- for (const segment of segments) {
673
- value = resolveLocalTemplateSegment(value, segment);
674
- if (value === null)
675
- return null;
676
- }
677
- return value ?? null;
678
- }
679
- function resolveLocalTemplateSegment(current, segment) {
680
- if (current === null || current === undefined)
681
- return null;
682
- if (Array.isArray(current) && /^\d+$/.test(segment))
683
- return current[Number(segment)] ?? null;
684
- if (isRecord(current) && Object.hasOwn(current, segment))
685
- return current[segment];
686
- return null;
672
+ return readJsonPath(current, segments);
687
673
  }
688
674
  function applyLocalOutputPath(value, outputPath) {
689
675
  const path = typeof outputPath === "string" ? outputPath.trim() : "";
690
676
  if (!path)
691
677
  return value;
692
- let current = value;
693
- for (const rawSegment of path.split(".")) {
694
- const segment = rawSegment.trim();
695
- if (!segment) {
696
- throw new OxygenError("invalid_output_path", "Custom HTTP outputPath is invalid.", {
697
- details: { output_path: outputPath },
698
- exitCode: 1,
699
- });
700
- }
701
- if (current === null || current === undefined)
702
- return null;
703
- if (Array.isArray(current) && /^\d+$/.test(segment)) {
704
- current = current[Number(segment)] ?? null;
705
- continue;
706
- }
707
- if (isRecord(current) && Object.hasOwn(current, segment)) {
708
- current = current[segment];
709
- continue;
710
- }
711
- return null;
678
+ // Must stay byte-identical to `applyOutputPath` in @oxygen/integrations —
679
+ // `--local` runs the same column config in-process, and a divergence here is a
680
+ // cell that changes value depending on where it ran.
681
+ const walked = walkJsonPath(value, path);
682
+ if (!walked.ok) {
683
+ throw new OxygenError("invalid_output_path", "Custom HTTP outputPath is invalid.", {
684
+ details: { output_path: outputPath },
685
+ exitCode: 1,
686
+ });
712
687
  }
713
- return current ?? null;
688
+ return walked.value;
714
689
  }
715
690
  function redactLocalSecrets(value, secretValues) {
716
691
  if (typeof value === "string") {
@@ -1,5 +1,5 @@
1
1
  import { OxygenError } from "@oxygen/shared/cli-result";
2
- import { isRecord } from "./coerce.js";
2
+ import { walkJsonPath } from "@oxygen/shared/json-path";
3
3
  import { normalizeDomain, normalizeEmail, normalizeLinkedinUrl, } from "./value-normalizers.js";
4
4
  export const MAX_FORMULA_REGEX_PATTERN_LENGTH = 256;
5
5
  export const MAX_FORMULA_REGEX_INPUT_LENGTH = 20_000;
@@ -70,30 +70,16 @@ export function formulaValuesEqual(left, right) {
70
70
  return left === right;
71
71
  }
72
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;
73
+ // Lazy validation, deliberately: a formula is evaluated per row, and an empty
74
+ // segment only surfaces on a row whose value actually reaches it. Switching to
75
+ // up-front validation would start failing rows that used to return blank.
76
+ const walked = walkJsonPath(value, rawPath);
77
+ if (!walked.ok) {
78
+ throw formulaExpressionError("JSON path contains an empty segment.", {
79
+ path: rawPath,
80
+ });
95
81
  }
96
- return current ?? null;
82
+ return walked.value;
97
83
  }
98
84
  export function checkFormulaFunctionArity(spec, received) {
99
85
  const withinMin = received >= spec.minArgs;