@erdoai/cli 0.91.0 → 0.99.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 (2) hide show
  1. package/dist/index.js +903 -59
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -312,6 +312,19 @@ var ErdoClient = class {
312
312
  createManagerKey() {
313
313
  return this.request("POST", "/v1/manager-key");
314
314
  }
315
+ // Daily paid-media and lead performance for one organization, or for every
316
+ // client org this account manages when organization_slug is omitted.
317
+ managerPerformance(params) {
318
+ const qs = new URLSearchParams();
319
+ if (params.organization_slug) qs.set("organization_slug", params.organization_slug);
320
+ if (params.from) qs.set("from", params.from);
321
+ if (params.to) qs.set("to", params.to);
322
+ const q = qs.toString();
323
+ return this.request(
324
+ "GET",
325
+ `/v1/managed-organization-performance${q ? `?${q}` : ""}`
326
+ );
327
+ }
315
328
  // Brings an EXISTING org under your manager org. Two consents redeem here: a
316
329
  // one-time token minted by the target org's owner (or, for an ownerless managed
317
330
  // org, its current manager) — a manager credential on its own can never adopt an
@@ -464,6 +477,8 @@ var ErdoClient = class {
464
477
  const q = new URLSearchParams();
465
478
  if (params?.domain) q.set("domain", params.domain);
466
479
  if (params?.limit) q.set("limit", String(params.limit));
480
+ if (params?.leadRef) q.set("lead_ref", params.leadRef);
481
+ if (params?.dataset) q.set("dataset", params.dataset);
467
482
  const qs = q.toString();
468
483
  return this.request(
469
484
  "GET",
@@ -604,6 +619,16 @@ var ErdoClient = class {
604
619
  { enabled }
605
620
  );
606
621
  }
622
+ // A separate switch from the text one, on the same number: the two are
623
+ // separate permissions, so one endpoint per channel rather than a channel
624
+ // argument that could move the wrong one.
625
+ setVoiceAgentWhatsAppReplies(slug, enabled) {
626
+ return this.request(
627
+ "POST",
628
+ `/v1/voice/agents/${encodeURIComponent(slug)}/whatsapp-replies`,
629
+ { enabled }
630
+ );
631
+ }
607
632
  // Run a read-only HogQL query against the org's page-analytics events. Rows are
608
633
  // positional per columns; enabled:false means page analytics is off for the org
609
634
  // (not zero traffic). A rejected query surfaces PostHog's message as the error.
@@ -884,6 +909,9 @@ var ErdoClient = class {
884
909
  for (const s of params?.statuses ?? []) q.append("statuses", s);
885
910
  if (params?.engine_actions_only) q.set("engine_actions_only", "true");
886
911
  if (params?.item_id) q.set("item_id", params.item_id);
912
+ if (params?.organization_slug) q.set("organization_slug", params.organization_slug);
913
+ if (params?.subject_kind) q.set("subject_kind", params.subject_kind);
914
+ if (params?.subject_id) q.set("subject_id", params.subject_id);
887
915
  if (params?.limit) q.set("limit", String(params.limit));
888
916
  if (params?.offset) q.set("offset", String(params.offset));
889
917
  const qs = q.toString();
@@ -1128,6 +1156,85 @@ var ErdoClient = class {
1128
1156
  input
1129
1157
  );
1130
1158
  }
1159
+ // The lead playbook: plain text that decides each lead's next action. An
1160
+ // organization that never saved one gets the default template, exists=false.
1161
+ getLeadPlaybook() {
1162
+ return this.request("GET", "/v1/lead-playbook");
1163
+ }
1164
+ // Saving reads the text into the typed read-back and stores both. enabled and
1165
+ // daily_contact_cap keep their saved values when omitted. Org admins only.
1166
+ // agent_id links the concierge every draft is written as. Unlike enabled and
1167
+ // daily_contact_cap it is NOT sticky: omitting it clears the link.
1168
+ putLeadPlaybook(input) {
1169
+ return this.request("PUT", "/v1/lead-playbook", input);
1170
+ }
1171
+ // The read-back of a draft, without saving it.
1172
+ readLeadPlaybook(body, agentID) {
1173
+ return this.request("POST", "/v1/lead-playbook/read-back", { body, agent_id: agentID });
1174
+ }
1175
+ // Every saved version of the playbook, newest first. Read-only: a revision is
1176
+ // written once, inside the save's own transaction.
1177
+ listLeadPlaybookRevisions(params) {
1178
+ const q = new URLSearchParams();
1179
+ if (params?.limit) q.set("limit", String(params.limit));
1180
+ if (params?.offset) q.set("offset", String(params.offset));
1181
+ const qs = q.toString();
1182
+ return this.request("GET", `/v1/lead-playbook/revisions${qs ? `?${qs}` : ""}`);
1183
+ }
1184
+ // One saved revision in full — what explains a decision whose
1185
+ // playbook_revision names it, long after the playbook has moved on.
1186
+ getLeadPlaybookRevision(revision) {
1187
+ return this.request("GET", `/v1/lead-playbook/revisions/${encodeURIComponent(String(revision))}`);
1188
+ }
1189
+ // The organization's concierges, for linking one to the playbook.
1190
+ listVoiceAgents() {
1191
+ return this.request("GET", "/v1/voice/agents");
1192
+ }
1193
+ // Each lead's current suggestion, highest priority first.
1194
+ listLeadNextActions(params) {
1195
+ const q = new URLSearchParams();
1196
+ if (params?.dataset) q.set("dataset", params.dataset);
1197
+ if (params?.status) q.set("status", params.status);
1198
+ if (params?.priority) q.set("priority", params.priority);
1199
+ if (params?.limit) q.set("limit", String(params.limit));
1200
+ if (params?.offset) q.set("offset", String(params.offset));
1201
+ const qs = q.toString();
1202
+ return this.request("GET", `/v1/lead-next-actions${qs ? `?${qs}` : ""}`);
1203
+ }
1204
+ // Every decision about one lead, newest first.
1205
+ getLeadNextActions(datasetSlug, lead) {
1206
+ return this.request(
1207
+ "GET",
1208
+ `/v1/datasets/${encodeURIComponent(datasetSlug)}/leads/${encodeURIComponent(lead)}/next-actions`
1209
+ );
1210
+ }
1211
+ // A person's hold or close. It replaces the lead's open suggestion and
1212
+ // withdraws any card that suggestion filed.
1213
+ recordLeadNextAction(datasetSlug, lead, input) {
1214
+ return this.request(
1215
+ "POST",
1216
+ `/v1/datasets/${encodeURIComponent(datasetSlug)}/leads/${encodeURIComponent(lead)}/next-actions`,
1217
+ input
1218
+ );
1219
+ }
1220
+ // Everything the evaluation reads about one lead.
1221
+ getLeadTimeline(datasetSlug, lead) {
1222
+ return this.request(
1223
+ "GET",
1224
+ `/v1/datasets/${encodeURIComponent(datasetSlug)}/leads/${encodeURIComponent(lead)}/timeline`
1225
+ );
1226
+ }
1227
+ // A dry run: nothing is stored and nothing is sent. body tries a draft
1228
+ // playbook instead of the saved one.
1229
+ // agent_id tries the draft as a different concierge; it is read only
1230
+ // alongside body, since a dry run of the saved playbook uses its saved one.
1231
+ evaluateLeadNextAction(datasetSlug, lead, body, agentID) {
1232
+ return this.request(
1233
+ "POST",
1234
+ `/v1/datasets/${encodeURIComponent(datasetSlug)}/leads/${encodeURIComponent(lead)}/next-actions/evaluate`,
1235
+ body ? { body, agent_id: agentID } : {}
1236
+ );
1237
+ }
1131
1238
  listDatasetRevisions(slug) {
1132
1239
  return this.request("GET", `/v1/datasets/${encodeURIComponent(slug)}/revisions`);
1133
1240
  }
@@ -1982,6 +2089,29 @@ function timedOutMessage(threadID) {
1982
2089
  function print(value) {
1983
2090
  console.log(JSON.stringify(value, null, 2));
1984
2091
  }
2092
+ function formatNumber(n) {
2093
+ if (!Number.isFinite(n)) return String(n);
2094
+ if (Number.isInteger(n)) return n.toLocaleString("en-US");
2095
+ if (Math.abs(n) < 1e-6) {
2096
+ return n.toFixed(10).replace(/0+$/, "").replace(/\.$/, "");
2097
+ }
2098
+ return n.toLocaleString("en-US", { maximumFractionDigits: 6 });
2099
+ }
2100
+ function formatDate(iso) {
2101
+ if (!iso) return void 0;
2102
+ const d = new Date(iso);
2103
+ if (isNaN(d.getTime())) return iso;
2104
+ return `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
2105
+ }
2106
+ function compactJSON(value) {
2107
+ if (value === void 0 || value === null) return void 0;
2108
+ try {
2109
+ const s = JSON.stringify(value);
2110
+ return s === "null" ? void 0 : s;
2111
+ } catch {
2112
+ return String(value);
2113
+ }
2114
+ }
1985
2115
  async function readAllStdin() {
1986
2116
  if (process.stdin.isTTY) {
1987
2117
  throw new Error("nothing is piped to standard input");
@@ -2030,7 +2160,11 @@ function printPageReview(review) {
2030
2160
  }
2031
2161
  }
2032
2162
  function printAlignedTable(columns, rows) {
2033
- const cell = (v) => v === null || v === void 0 ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
2163
+ const cell = (v) => {
2164
+ if (v === null || v === void 0) return "";
2165
+ if (typeof v === "object") return JSON.stringify(v);
2166
+ return String(v);
2167
+ };
2034
2168
  const widths = columns.map((c, i) => Math.max(c.length, ...rows.map((r) => cell(r[i]).length), 0));
2035
2169
  const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2036
2170
  console.log(line(columns));
@@ -2045,7 +2179,10 @@ var collect = (v, acc) => {
2045
2179
  function summariseResults(results, cases = []) {
2046
2180
  const nameByID = new Map(cases.map((c) => [c.id, c.name]));
2047
2181
  for (const r of results) {
2048
- const status = r.agent_error ? "ERROR" : r.passed ? "PASS" : "FAIL";
2182
+ let status;
2183
+ if (r.agent_error) status = "ERROR";
2184
+ else if (r.passed) status = "PASS";
2185
+ else status = "FAIL";
2049
2186
  const name = nameByID.get(r.case_id) ?? r.case_id;
2050
2187
  console.log(` [${status}] ${r.score.toFixed(2)} ${name}`);
2051
2188
  if (r.agent_error) console.log(` agent error: ${r.agent_error}`);
@@ -2273,6 +2410,7 @@ function printBusinessProfile(profile) {
2273
2410
  ["industry", profile.industry],
2274
2411
  ["privacy policy", profile.privacy_policy_url],
2275
2412
  ["terms", profile.terms_and_conditions_url],
2413
+ ["example landing page", profile.example_landing_page_url],
2276
2414
  ["representative", `${profile.representative_first_name} ${profile.representative_last_name}`.trim()],
2277
2415
  ["title", profile.representative_title],
2278
2416
  ["job position", profile.representative_job_position],
@@ -2324,7 +2462,10 @@ businessProfileCmd.command("set").description("Save the organization's business
2324
2462
  ).option(
2325
2463
  "--tax-id-stdin",
2326
2464
  "read the tax id from standard input instead of prompting, for a script \u2014 e.g. `pass show ein | erdo org business-profile set --tax-id-stdin`"
2327
- ).option("--street <street>", "street address of the registered business").option("--city <city>", "city of the registered business").option("--region <region>", "state or province, e.g. FL").option("--postal-code <code>", "postal or ZIP code").option("--country <iso2>", "two-letter ISO country code, e.g. US").option("--website-url <url>", "the business's own website \u2014 carriers check that it describes it").option("--industry <industry>", "one of the industries listed by: erdo org business-profile get --json").option("--privacy-policy-url <url>", "the business's own privacy policy page (https) \u2014 a campaign without one is rejected").option("--terms-url <url>", "the business's own terms and conditions page (https) \u2014 required for the same reason").option("--first-name <name>", "given name of the person carriers may contact").option("--last-name <name>", "family name of that person").option("--title <title>", "their job title in the business's own words, e.g. Managing Partner").option(
2465
+ ).option("--street <street>", "street address of the registered business").option("--city <city>", "city of the registered business").option("--region <region>", "state or province, e.g. FL").option("--postal-code <code>", "postal or ZIP code").option("--country <iso2>", "two-letter ISO country code, e.g. US").option("--website-url <url>", "the business's own website \u2014 carriers check that it describes it").option("--industry <industry>", "one of the industries listed by: erdo org business-profile get --json").option("--privacy-policy-url <url>", "the business's own privacy policy page (https) \u2014 a campaign without one is rejected").option("--terms-url <url>", "the business's own terms and conditions page (https) \u2014 required for the same reason").option(
2466
+ "--example-landing-page-url <url>",
2467
+ "a public landing page (https) where people opt in to texts through the form's SMS consent checkbox and that shows your phone number \u2014 the opt-in the campaign reviewer opens"
2468
+ ).option("--first-name <name>", "given name of the person carriers may contact").option("--last-name <name>", "family name of that person").option("--title <title>", "their job title in the business's own words, e.g. Managing Partner").option(
2328
2469
  "--job-position <position>",
2329
2470
  "their role from the fixed list (ceo, cfo, director, general_counsel, gm, vp, other)"
2330
2471
  ).option("--email <email>", "their email address").option("--phone <number>", "their phone number in E.164, e.g. +13055550123").option("--json", "print the raw JSON result instead of a table").action(
@@ -2362,6 +2503,7 @@ businessProfileCmd.command("set").description("Save the organization's business
2362
2503
  industry: opts.industry ?? stored.industry,
2363
2504
  privacy_policy_url: opts.privacyPolicyUrl ?? stored.privacy_policy_url,
2364
2505
  terms_and_conditions_url: opts.termsUrl ?? stored.terms_and_conditions_url,
2506
+ example_landing_page_url: opts.exampleLandingPageUrl ?? stored.example_landing_page_url ?? "",
2365
2507
  representative_first_name: opts.firstName ?? stored.representative_first_name,
2366
2508
  representative_last_name: opts.lastName ?? stored.representative_last_name,
2367
2509
  representative_title: opts.title ?? stored.representative_title,
@@ -2522,9 +2664,11 @@ managed.command("ads-container <orgSlug>").description(
2522
2664
  timeZone: opts.timeZone,
2523
2665
  replaceCurrentAccount: opts.replaceCurrentAccount
2524
2666
  });
2525
- console.log(
2526
- c.already_provisioned ? `Google Ads container already existed for ${c.org_slug}` : c.adopted ? `Adopted Google Ads account ${c.customer_id} as the container for ${c.org_slug}` : `Created Google Ads container for ${c.org_slug}`
2527
- );
2667
+ let outcome;
2668
+ if (c.already_provisioned) outcome = `Google Ads container already existed for ${c.org_slug}`;
2669
+ else if (c.adopted) outcome = `Adopted Google Ads account ${c.customer_id} as the container for ${c.org_slug}`;
2670
+ else outcome = `Created Google Ads container for ${c.org_slug}`;
2671
+ console.log(outcome);
2528
2672
  printAdsContainer(c);
2529
2673
  const leftBehind = c.previous_campaign_count ?? 0;
2530
2674
  if (c.previous_customer_id && leftBehind > 0) {
@@ -2583,6 +2727,90 @@ This key operates every org you manage; target one with X-Organization-ID (or \`
2583
2727
  fail(e);
2584
2728
  }
2585
2729
  });
2730
+ managed.command("performance").description(
2731
+ "Daily spend, traffic and captured leads for the client orgs you manage \u2014 one organization with --organization, otherwise every client. A metric whose source dataset is missing or unreadable prints as '-' and is explained under coverage; it is never reported as zero."
2732
+ ).option("--organization <slug>", "read one organization instead of every client org").option("--from <YYYY-MM-DD>", "first day to include (inclusive); defaults to 89 days before --to").option("--to <YYYY-MM-DD>", "last day to include (inclusive); defaults to today (UTC)").option("--daily", "one row per organization per day instead of window totals").option("--json", "print the raw JSON result instead of a table").action(
2733
+ async (opts) => {
2734
+ try {
2735
+ const res = await new ErdoClient().managerPerformance({
2736
+ organization_slug: opts.organization,
2737
+ from: opts.from,
2738
+ to: opts.to
2739
+ });
2740
+ if (opts.json) {
2741
+ print(res);
2742
+ return;
2743
+ }
2744
+ if (res.organizations.length === 0) {
2745
+ console.log(res.message || "No organizations to read.");
2746
+ return;
2747
+ }
2748
+ const cell = (v, digits = 0) => v === null ? "-" : v.toFixed(digits);
2749
+ const total = (days, pick) => {
2750
+ let sum = 0;
2751
+ for (const day of days) {
2752
+ const v = pick(day);
2753
+ if (v === null) return null;
2754
+ sum += v;
2755
+ }
2756
+ return sum;
2757
+ };
2758
+ if (opts.daily) {
2759
+ const rows = [];
2760
+ for (const org2 of res.organizations) {
2761
+ for (const day of org2.days) {
2762
+ rows.push([
2763
+ org2.organization_slug,
2764
+ day.date,
2765
+ cell(day.spend, 2),
2766
+ cell(day.clicks),
2767
+ cell(day.visits),
2768
+ cell(day.form_starts),
2769
+ cell(day.leads_on_page),
2770
+ cell(day.leads_captured)
2771
+ ]);
2772
+ }
2773
+ }
2774
+ printAlignedTable(
2775
+ ["organization", "date", "spend", "clicks", "visits", "form starts", "page leads", "leads"],
2776
+ rows
2777
+ );
2778
+ } else {
2779
+ printAlignedTable(
2780
+ ["organization", "spend", "clicks", "visits", "form starts", "page leads", "leads", "cost/lead"],
2781
+ res.organizations.map((org2) => {
2782
+ const spend = total(org2.days, (d) => d.spend);
2783
+ const leads = total(org2.days, (d) => d.leads_captured);
2784
+ const costPerLead = spend === null || leads === null || leads === 0 ? null : spend / leads;
2785
+ return [
2786
+ org2.organization_slug,
2787
+ cell(spend, 2),
2788
+ cell(total(org2.days, (d) => d.clicks)),
2789
+ cell(total(org2.days, (d) => d.visits)),
2790
+ cell(total(org2.days, (d) => d.form_starts)),
2791
+ cell(total(org2.days, (d) => d.leads_on_page)),
2792
+ cell(leads, 0),
2793
+ cell(costPerLead, 2)
2794
+ ];
2795
+ })
2796
+ );
2797
+ }
2798
+ process.stderr.write(`
2799
+ ${res.from} to ${res.to} (${res.timezone} days)
2800
+ `);
2801
+ for (const org2 of res.organizations) {
2802
+ for (const gap of org2.coverage) {
2803
+ process.stderr.write(
2804
+ `${org2.organization_slug}: ${gap.metrics.join(", ")} unavailable \u2014 ${gap.reason}
2805
+ `
2806
+ );
2807
+ }
2808
+ }
2809
+ } catch (e) {
2810
+ fail(e);
2811
+ }
2812
+ }
2813
+ );
2586
2814
  var tokenCmd = program.command("token").description("Manage your API tokens (account-level credentials)");
2587
2815
  tokenCmd.command("create").description("Mint a new API token; the secret is printed ONCE").requiredOption("--name <name>", "a label for the token").option("--expires-days <n>", "days until expiry (default: 30)", (v) => parseInt(v, 10)).addOption(
2588
2816
  orgOption("the token's default org (defaults to your active org; must be one you belong to)")
@@ -2871,7 +3099,7 @@ function splitVariantSpec(spec) {
2871
3099
  const key = eq > 0 ? trimmed.slice(0, eq).trim() : "";
2872
3100
  const startsToken = trimmed === "control" || trimmed === "is_control" || key !== "" && VARIANT_SPEC_KEYS.has(key);
2873
3101
  if (!startsToken && parts.length > 0 && COMMA_SAFE_VARIANT_KEYS.has(lastKey)) {
2874
- parts[parts.length - 1] += "," + raw;
3102
+ parts[parts.length - 1] += `,${raw}`;
2875
3103
  continue;
2876
3104
  }
2877
3105
  parts.push(raw);
@@ -3381,9 +3609,13 @@ ${"Case".padEnd(34)}${res.models.map(modelCol).join("")}`);
3381
3609
  console.log(`
3382
3610
  ${"Summary (avg of all cases)".padEnd(34)}${res.models.map(modelCol).join("")}`);
3383
3611
  for (const stat of ["score", "pass", "cost", "secs"]) {
3384
- let line = `${" " + stat}`.padEnd(34);
3612
+ let line = ` ${stat}`.padEnd(34);
3385
3613
  for (const s of res.summary) {
3386
- const v = stat === "score" ? s.avg_score.toFixed(2) : stat === "pass" ? `${Math.round(s.pass_rate * 100)}%` : stat === "cost" ? `$${(s.avg_cost_millicents / 1e5).toFixed(3)}/case` : `${(s.avg_duration_ms / 1e3).toFixed(0)}s`;
3614
+ let v;
3615
+ if (stat === "score") v = s.avg_score.toFixed(2);
3616
+ else if (stat === "pass") v = `${Math.round(s.pass_rate * 100)}%`;
3617
+ else if (stat === "cost") v = `$${(s.avg_cost_millicents / 1e5).toFixed(3)}/case`;
3618
+ else v = `${(s.avg_duration_ms / 1e3).toFixed(0)}s`;
3387
3619
  line += v.padEnd(18);
3388
3620
  }
3389
3621
  console.log(line);
@@ -3731,9 +3963,11 @@ function printRunDetail(detail, wantResources, wantSteps) {
3731
3963
  group.set(key, entry);
3732
3964
  byKind.set(kind, group);
3733
3965
  }
3734
- const kinds = [...byKind.keys()].sort(
3735
- (a, b) => a === "skill" ? -1 : b === "skill" ? 1 : a.localeCompare(b)
3736
- );
3966
+ const kinds = [...byKind.keys()].sort((a, b) => {
3967
+ if (a === "skill") return -1;
3968
+ if (b === "skill") return 1;
3969
+ return a.localeCompare(b);
3970
+ });
3737
3971
  for (const kind of kinds) {
3738
3972
  console.log(` ${kind}`);
3739
3973
  for (const entry of byKind.get(kind).values()) {
@@ -3811,6 +4045,46 @@ function decisionLine(d) {
3811
4045
  }
3812
4046
  return parts.join(" ");
3813
4047
  }
4048
+ function renderApprovalCard(approval) {
4049
+ console.log("");
4050
+ console.log("Approval:");
4051
+ const headline = approval.action_display || approval.action_headline;
4052
+ if (headline) console.log(` ${headline}`);
4053
+ if (approval.action_context) console.log(` ${approval.action_context}`);
4054
+ for (const item of approval.items ?? []) {
4055
+ const params = item.params ? Object.entries(item.params).map(([k, v]) => `${k}=${v}`).join(", ") : "";
4056
+ console.log(` - ${item.what}${params ? ` (${params})` : ""}`);
4057
+ if (item.why) console.log(` why: ${item.why}`);
4058
+ }
4059
+ if (approval.omitted_items) console.log(` and ${approval.omitted_items} more`);
4060
+ if (approval.occurrence_count && approval.occurrence_count > 1) {
4061
+ console.log(` proposed ${approval.occurrence_count}x`);
4062
+ }
4063
+ }
4064
+ function renderReportEffect(e) {
4065
+ if (e.measurability !== "measurable") {
4066
+ const reason = e.unmeasurable_reason ? `: ${e.unmeasurable_reason}` : "";
4067
+ console.log(` ${e.metric || e.measurability} \u2014 ${e.measurability}${reason}`);
4068
+ return;
4069
+ }
4070
+ const predicateValue = e.target_value ?? e.min_delta;
4071
+ const predicate = [
4072
+ e.metric,
4073
+ e.success_operator,
4074
+ predicateValue !== void 0 && predicateValue !== null ? formatNumber(predicateValue) : void 0
4075
+ ].filter((p) => p !== void 0 && p !== null && p !== "").join(" ");
4076
+ const windows = [];
4077
+ if (e.baseline_window_days) windows.push(`baseline ${e.baseline_window_days}d`);
4078
+ if (e.outcome_window_days) windows.push(`outcome ${e.outcome_window_days}d`);
4079
+ const windowStr = windows.length ? ` (${windows.join(", ")})` : "";
4080
+ console.log(` ${predicate || e.metric || "effect"}${windowStr} \u2014 ${e.status}`);
4081
+ if (e.baseline_value !== void 0 && e.baseline_value !== null && e.outcome_value !== void 0 && e.outcome_value !== null) {
4082
+ const label = e.outcome_label ? ` (${e.outcome_label})` : "";
4083
+ console.log(` measured: ${formatNumber(e.baseline_value)} \u2192 ${formatNumber(e.outcome_value)}${label}`);
4084
+ } else if (e.outcome_label) {
4085
+ console.log(` outcome: ${e.outcome_label}`);
4086
+ }
4087
+ }
3814
4088
  decisionsCmd.command("list").description("List decisions, newest first").option("--workstream <slug>", "only decisions filed under this workstream/Strategy").option(
3815
4089
  "--source <source>",
3816
4090
  "producer family: approval | escalation | engine_gate | allocator | experiment | workstream_commitment"
@@ -3870,6 +4144,7 @@ function renderDecision(detail) {
3870
4144
  console.log("");
3871
4145
  console.log(`Why: ${d.why}`);
3872
4146
  }
4147
+ if (d.alternatives_considered) console.log(`Alternatives considered: ${d.alternatives_considered}`);
3873
4148
  if (d.decider_rationale) console.log(`Decider said: ${d.decider_rationale}`);
3874
4149
  if (detail.actions.length) {
3875
4150
  console.log("");
@@ -3879,6 +4154,12 @@ function renderDecision(detail) {
3879
4154
  if (a.subject_label) console.log(` subject: ${a.subject_label}`);
3880
4155
  if (a.effective_at) console.log(` effective: ${a.effective_at}`);
3881
4156
  if (a.result_summary) console.log(` result: ${a.result_summary}`);
4157
+ if (a.annotations && Object.keys(a.annotations).length) {
4158
+ console.log(" before:");
4159
+ for (const [k, v] of Object.entries(a.annotations)) {
4160
+ console.log(` ${k}: ${compactJSON(v) ?? "null"}`);
4161
+ }
4162
+ }
3882
4163
  });
3883
4164
  }
3884
4165
  if (detail.effects.length) {
@@ -3896,13 +4177,28 @@ function renderDecision(detail) {
3896
4177
  if (e.unmeasurable_reason) console.log(` reason: ${e.unmeasurable_reason}`);
3897
4178
  });
3898
4179
  }
3899
- if (detail.lineage.supersedes_slug || detail.lineage.superseded_by_slugs?.length) {
4180
+ const lineage = detail.lineage;
4181
+ if (lineage.supersedes_slug || lineage.superseded_by_slugs?.length || lineage.workstream_slug || lineage.attention_item_slug) {
3900
4182
  console.log("");
3901
4183
  console.log("Lineage:");
3902
- if (detail.lineage.supersedes_slug) console.log(` replaced ${detail.lineage.supersedes_slug}`);
3903
- for (const slug of detail.lineage.superseded_by_slugs ?? []) {
4184
+ if (lineage.supersedes_slug) console.log(` replaced ${lineage.supersedes_slug}`);
4185
+ for (const slug of lineage.superseded_by_slugs ?? []) {
3904
4186
  console.log(` replaced by ${slug}`);
3905
4187
  }
4188
+ if (lineage.workstream_slug) {
4189
+ console.log(` workstream: ${lineage.workstream_title || lineage.workstream_slug} (${lineage.workstream_slug})`);
4190
+ }
4191
+ if (lineage.attention_item_slug) {
4192
+ console.log(
4193
+ ` raised by: ${lineage.attention_item_title || lineage.attention_item_slug} (${lineage.attention_item_slug})`
4194
+ );
4195
+ }
4196
+ }
4197
+ if (lineage.approval) renderApprovalCard(lineage.approval);
4198
+ else if (lineage.approval_unreadable) {
4199
+ console.log("");
4200
+ console.log("Approval:");
4201
+ console.log(" The approval card could not be loaded.");
3906
4202
  }
3907
4203
  }
3908
4204
  decisionsCmd.command("show <slug>").description(
@@ -4176,9 +4472,7 @@ approvalsCmd.command("settings [mode]").description(
4176
4472
  try {
4177
4473
  const client = new ErdoClient();
4178
4474
  let gates;
4179
- if (opts.gates !== void 0) {
4180
- gates = opts.gates.split(",").map((g) => g.trim()).filter(Boolean);
4181
- } else {
4475
+ if (opts.gates === void 0) {
4182
4476
  switch (mode) {
4183
4477
  case void 0:
4184
4478
  break;
@@ -4195,6 +4489,8 @@ approvalsCmd.command("settings [mode]").description(
4195
4489
  default:
4196
4490
  throw new Error(`unknown mode ${mode} \u2014 use safe | all | reset, or --gates spend,destructive`);
4197
4491
  }
4492
+ } else {
4493
+ gates = opts.gates.split(",").map((g) => g.trim()).filter(Boolean);
4198
4494
  }
4199
4495
  if (gates !== void 0) {
4200
4496
  await client.setApprovalSettings(gates);
@@ -4212,19 +4508,100 @@ approvalsCmd.command("settings [mode]").description(
4212
4508
  }
4213
4509
  });
4214
4510
  var attnCmd = program.command("attention").description("The attention feed \u2014 digests, choices, escalations awaiting a human");
4215
- attnCmd.command("list").description("List attention items").option("--status <status...>", "open | answered | dismissed | expired").option("--open", "shorthand for --status open").option("--engine-actions", "only engine-generated items").option("--item <idOrSlug>", "read one item: its slug or its uuid").option("-n, --limit <n>", "max items", (v) => parseInt(v, 10)).option("--offset <n>", "pagination offset", (v) => parseInt(v, 10)).action(
4511
+ function attentionItemLine(item) {
4512
+ return [item.slug, item.severity, item.kind, item.status, item.title].join(" ");
4513
+ }
4514
+ function renderAttentionItemReport(item, report) {
4515
+ console.log("");
4516
+ console.log("Safe default:");
4517
+ const expires = formatDate(item.expires_at);
4518
+ if (report.proposed_answer) {
4519
+ if (!expires) {
4520
+ console.log(` Erdo proposes: ${report.proposed_answer}`);
4521
+ console.log(" No deadline \u2014 nothing applies until somebody answers");
4522
+ } else if (report.safe_default_approves) {
4523
+ console.log(` If nobody answers: ${report.proposed_answer}`);
4524
+ console.log(` Applies ${expires}`);
4525
+ } else {
4526
+ console.log(` Erdo proposes: ${report.proposed_answer}`);
4527
+ console.log(` If nobody answers, Erdo will NOT do this \u2014 the item closes ${expires}`);
4528
+ }
4529
+ } else {
4530
+ console.log(expires ? ` Closes unanswered ${expires}` : " Does not expire");
4531
+ }
4532
+ if (report.has_pending_action) console.log(" Holds a pending engine action");
4533
+ const safeDefault = compactJSON(report.safe_default);
4534
+ if (safeDefault) console.log(` Default answer: ${safeDefault}`);
4535
+ if (report.workstream_slug || report.workstream_title) {
4536
+ console.log("");
4537
+ console.log("Workstream:");
4538
+ console.log(` ${report.workstream_title || report.workstream_slug} (${report.workstream_slug})`);
4539
+ }
4540
+ if (report.decision_slug) {
4541
+ console.log("");
4542
+ console.log("Decision:");
4543
+ if (report.decision) {
4544
+ const d = report.decision;
4545
+ console.log(` ${d.slug} \u2014 ${d.what}`);
4546
+ if (d.why) console.log(` why: ${d.why}`);
4547
+ if (d.alternatives_considered) console.log(` alternatives considered: ${d.alternatives_considered}`);
4548
+ console.log(` decided by: ${d.decider_kind}`);
4549
+ console.log(` status: ${d.status}`);
4550
+ if (d.outcome_label) console.log(` outcome: ${d.outcome_label}`);
4551
+ } else {
4552
+ console.log(` ${report.decision_slug} \u2014 exists, but in a project you cannot view`);
4553
+ }
4554
+ }
4555
+ if (report.approval) renderApprovalCard(report.approval);
4556
+ else if (report.approval_unreadable) {
4557
+ console.log("");
4558
+ console.log("Approval:");
4559
+ console.log(" The approval card could not be loaded.");
4560
+ }
4561
+ if (report.effects?.length) {
4562
+ console.log("");
4563
+ console.log("Predicted vs measured:");
4564
+ for (const e of report.effects) renderReportEffect(e);
4565
+ }
4566
+ if (report.related_items?.length) {
4567
+ console.log("");
4568
+ console.log("Earlier items:");
4569
+ for (const r of report.related_items) {
4570
+ const reasons = [r.same_workstream ? "same workstream" : void 0, r.same_title ? "same title" : void 0].filter(Boolean).join(", ");
4571
+ const created = formatDate(r.created_at) ?? r.created_at;
4572
+ console.log(
4573
+ ` [${r.severity}] ${r.status} \u2014 ${r.title} (${r.slug}) \u2014 ${created}${reasons ? ` \u2014 ${reasons}` : ""}`
4574
+ );
4575
+ }
4576
+ }
4577
+ }
4578
+ attnCmd.command("list").description("List attention items").option("--status <status...>", "open | answered | dismissed | expired").option("--open", "shorthand for --status open").option("--engine-actions", "only engine-generated items").option("--item <idOrSlug>", "read one item: its slug or its uuid").option("--org <slug>", "read another organization's feed \u2014 yours, or a client you manage").option(
4579
+ "--subject-kind <kind>",
4580
+ "only items about this kind of thing: google_ads_campaign | meta_ads_campaign | google_ads_ad_group | google_ads_ad | page | dataset | workstream"
4581
+ ).option("--subject-id <id>", "with --subject-kind, only items about this exact subject").option("-n, --limit <n>", "max items", (v) => parseInt(v, 10)).option("--offset <n>", "pagination offset", (v) => parseInt(v, 10)).option("--json", "output raw JSON").action(
4216
4582
  async (opts) => {
4217
4583
  try {
4218
4584
  const statuses = opts.status?.length ? opts.status : opts.open ? ["open"] : void 0;
4219
- print(
4220
- await new ErdoClient().listAttentionItems({
4221
- statuses,
4222
- engine_actions_only: opts.engineActions,
4223
- item_id: opts.item,
4224
- limit: opts.limit,
4225
- offset: opts.offset
4226
- })
4227
- );
4585
+ const res = await new ErdoClient().listAttentionItems({
4586
+ statuses,
4587
+ engine_actions_only: opts.engineActions,
4588
+ item_id: opts.item,
4589
+ organization_slug: opts.org,
4590
+ subject_kind: opts.subjectKind,
4591
+ subject_id: opts.subjectId,
4592
+ limit: opts.limit,
4593
+ offset: opts.offset
4594
+ });
4595
+ if (opts.json) {
4596
+ print(res);
4597
+ return;
4598
+ }
4599
+ if (!res.items.length) {
4600
+ console.log("No attention items match. Widen the filters.");
4601
+ return;
4602
+ }
4603
+ for (const item of res.items) console.log(attentionItemLine(item));
4604
+ if (res.report) renderAttentionItemReport(res.items[0], res.report);
4228
4605
  } catch (e) {
4229
4606
  fail(e);
4230
4607
  }
@@ -4235,7 +4612,7 @@ attnCmd.command("respond <id>").description(
4235
4612
  ).option("--answer <json>", `a JSON answer, e.g. '{"q1":["option-a"]}'`).option(
4236
4613
  "--flag-broken <json>",
4237
4614
  'flag choice variants as broken: JSON array [{"variant_key":"...","reason":"..."}]'
4238
- ).option("--ack", "mark read / acknowledge").option("--dismiss", "dismiss the item").action(
4615
+ ).option("--ack", "mark read / acknowledge").option("--dismiss", "dismiss the item").option("--org <slug>", "the organization holding the item \u2014 yours, or a client you manage").action(
4239
4616
  async (id, opts) => {
4240
4617
  try {
4241
4618
  if (opts.ack || opts.dismiss) {
@@ -4247,7 +4624,8 @@ attnCmd.command("respond <id>").description(
4247
4624
  }
4248
4625
  print(
4249
4626
  await new ErdoClient().respondAttentionItem(id, {
4250
- action: opts.ack ? "acknowledge" : "dismiss"
4627
+ action: opts.ack ? "acknowledge" : "dismiss",
4628
+ organization_slug: opts.org
4251
4629
  })
4252
4630
  );
4253
4631
  return;
@@ -4255,7 +4633,7 @@ attnCmd.command("respond <id>").description(
4255
4633
  if (opts.answer === void 0 && opts.flagBroken === void 0) {
4256
4634
  fail(new Error("provide one of --answer, --flag-broken, --ack, or --dismiss"));
4257
4635
  }
4258
- const input = { action: "answer" };
4636
+ const input = { action: "answer", organization_slug: opts.org };
4259
4637
  if (opts.answer !== void 0) {
4260
4638
  try {
4261
4639
  input.answer = JSON.parse(opts.answer);
@@ -4311,7 +4689,19 @@ pageFeedbackCmd.command("record").description(
4311
4689
  );
4312
4690
  }
4313
4691
  let items;
4314
- if (opts.items !== void 0) {
4692
+ if (opts.items === void 0) {
4693
+ if (!opts.experiment || !opts.variant || !opts.feedback) {
4694
+ throw new Error("provide --items, or all of --experiment, --variant, --feedback");
4695
+ }
4696
+ items = [
4697
+ {
4698
+ experiment_slug: opts.experiment,
4699
+ variant_key: opts.variant,
4700
+ feedback: opts.feedback,
4701
+ image_bucket_key: opts.imageKey
4702
+ }
4703
+ ];
4704
+ } else {
4315
4705
  let parsed;
4316
4706
  try {
4317
4707
  parsed = JSON.parse(readMaybeFile(opts.items));
@@ -4324,18 +4714,6 @@ pageFeedbackCmd.command("record").description(
4324
4714
  throw new Error("--items must be a JSON array of {experiment_slug, variant_key, feedback} objects");
4325
4715
  }
4326
4716
  items = parsed;
4327
- } else {
4328
- if (!opts.experiment || !opts.variant || !opts.feedback) {
4329
- throw new Error("provide --items, or all of --experiment, --variant, --feedback");
4330
- }
4331
- items = [
4332
- {
4333
- experiment_slug: opts.experiment,
4334
- variant_key: opts.variant,
4335
- feedback: opts.feedback,
4336
- image_bucket_key: opts.imageKey
4337
- }
4338
- ];
4339
4717
  }
4340
4718
  print(await new ErdoClient().recordPageFeedback(items));
4341
4719
  } catch (e) {
@@ -4581,7 +4959,7 @@ reviewsCmd.command("decide <id>").description(
4581
4959
  opts.apply ? "apply" : null,
4582
4960
  opts.resolve ? "resolve" : null,
4583
4961
  opts.reject ? "reject" : null,
4584
- opts.snooze !== void 0 ? "snooze" : null
4962
+ opts.snooze === void 0 ? null : "snooze"
4585
4963
  ].filter(Boolean);
4586
4964
  if (chosen.length !== 1) {
4587
4965
  fail(new Error("provide exactly one of --apply, --resolve, --reject, or --snooze"));
@@ -4601,6 +4979,11 @@ reviewsCmd.command("decide <id>").description(
4601
4979
  }
4602
4980
  );
4603
4981
  var pagesCmd = program.command("pages").description("Create and manage pages/artifacts");
4982
+ function publicFlag(opts) {
4983
+ if (opts.public) return true;
4984
+ if (opts.private) return false;
4985
+ return void 0;
4986
+ }
4604
4987
  function grantList(v) {
4605
4988
  if (v === void 0) return void 0;
4606
4989
  if (v.trim() === "[]") return [];
@@ -4676,7 +5059,9 @@ pagesCmd.command("update <id>").description(
4676
5059
  writable_dataset_slugs: grantList(opts.writableDatasets),
4677
5060
  kv_slugs: grantList(opts.kv),
4678
5061
  writable_kv_slugs: grantList(opts.writableKv),
4679
- public: opts.public ? true : opts.private ? false : void 0,
5062
+ // --public and --private are mutually exclusive flags; neither set
5063
+ // leaves the server's default standing.
5064
+ public: publicFlag(opts),
4680
5065
  request_review: !!opts.requestReview
4681
5066
  });
4682
5067
  console.log(`${res.id} ${res.public_url || res.url}`);
@@ -5041,11 +5426,13 @@ sendingDomainsCmd.command("remove <domain>").description(
5041
5426
  });
5042
5427
  emailCmd.command("received").description(
5043
5428
  "Read the mail that arrived at the org's sending domain, newest first \u2014 usually leads replying to outreach an agent sent. Omit --domain: an org has one sending domain and the read resolves it."
5044
- ).option("--domain <domain>", "the sending domain to read (only needed if the org has more than one)").option("--limit <n>", "maximum messages to return, newest first").option("--json", "print the raw JSON result instead of the message list").action(async (opts) => {
5429
+ ).option("--domain <domain>", "the sending domain to read (only needed if the org has more than one)").option("--limit <n>", "maximum messages to return, newest first").option("--lead-ref <ref>", "only one lead's email conversation with the desk").option("--dataset <slug>", "with --lead-ref, only that lead dataset").option("--json", "print the raw JSON result instead of the message list").action(async (opts) => {
5045
5430
  try {
5046
5431
  const res = await new ErdoClient().listReceivedEmails({
5047
5432
  domain: opts.domain,
5048
- limit: opts.limit ? Number(opts.limit) : void 0
5433
+ limit: opts.limit ? Number(opts.limit) : void 0,
5434
+ leadRef: opts.leadRef,
5435
+ dataset: opts.dataset
5049
5436
  });
5050
5437
  if (opts.json) {
5051
5438
  print(res);
@@ -5062,12 +5449,17 @@ emailCmd.command("received").description(
5062
5449
  `);
5063
5450
  for (const m of emails) {
5064
5451
  const who = m.from_name ? `${m.from_name} <${m.from_email}>` : m.from_email;
5065
- console.log(`${m.received_at} ${who} ${m.subject || "(no subject)"}`);
5452
+ const side = m.direction ? ` [${m.direction}${m.lead_email ? ` \xB7 lead ${m.lead_email}` : ""}]` : "";
5453
+ console.log(`${m.received_at} ${who} ${m.subject || "(no subject)"}${side}`);
5066
5454
  const preview = (m.text_preview || "").replace(/\s+/g, " ").trim();
5067
5455
  if (preview) console.log(` ${preview.length > 160 ? `${preview.slice(0, 160)}\u2026` : preview}`);
5068
- console.log(
5069
- m.forwarded_at ? ` forwarded ${m.forwarded_at}` : " not forwarded \u2014 this one is in Erdo only, so nobody on your side has necessarily seen it"
5070
- );
5456
+ if (m.relay_skip_reason) {
5457
+ console.log(` kept in Erdo, not relayed (${m.relay_skip_reason})`);
5458
+ } else {
5459
+ console.log(
5460
+ m.forwarded_at ? ` ${m.relayed_email_id ? "relayed" : "forwarded"} ${m.forwarded_at}` : " not forwarded \u2014 this one is in Erdo only, so nobody on your side has necessarily seen it"
5461
+ );
5462
+ }
5071
5463
  }
5072
5464
  } catch (e) {
5073
5465
  fail(e);
@@ -5265,6 +5657,8 @@ voiceCallsCmd.command("list").description("List the organization's phone calls,
5265
5657
  "status",
5266
5658
  "secs",
5267
5659
  "transcript",
5660
+ "placed by",
5661
+ "dial",
5268
5662
  "contact",
5269
5663
  "lead",
5270
5664
  "lead ref",
@@ -5279,6 +5673,10 @@ voiceCallsCmd.command("list").description("List the organization's phone calls,
5279
5673
  call.status,
5280
5674
  call.duration_seconds,
5281
5675
  call.has_transcript ? "yes" : "no",
5676
+ // "-" rather than blank: a call the AI agent placed has no person
5677
+ // behind it, which is a fact, not a missing value.
5678
+ call.placed_by_name || "-",
5679
+ call.dial_outcome || "-",
5282
5680
  contactLabel(call.contact),
5283
5681
  leadStatusLabel(call.lead_status),
5284
5682
  leadReferenceLabel(call.lead_reference),
@@ -5298,7 +5696,7 @@ voiceCallsCmd.command("list").description("List the organization's phone calls,
5298
5696
  }
5299
5697
  );
5300
5698
  voiceCallsCmd.command("get <callID>").description(
5301
- "Read one phone call in full: transcript, summary, per-turn LLM metrics, and the lead it produced (contact, lead_status, lead_saved_at, canonical_lead_id, lead_reference)"
5699
+ "Read one phone call in full: transcript, summary, per-turn LLM metrics, who placed it by hand (placed_by_name) and what became of the lead's leg (dial_outcome), and the lead it produced (contact, lead_status, lead_saved_at, canonical_lead_id, lead_reference)"
5302
5700
  ).action(async (callID) => {
5303
5701
  try {
5304
5702
  print(await new ErdoClient().getVoiceCall(callID));
@@ -5537,12 +5935,14 @@ voiceNumbersCmd.command("list").description("List the organization's numbers wit
5537
5935
  if (numbers.length === 0) {
5538
5936
  console.log("This organization holds no phone numbers.");
5539
5937
  } else {
5938
+ const callable = new Set(res.person_call_numbers ?? []);
5540
5939
  printAlignedTable(
5541
- ["number", "agent", "kind", "status", "waiting on", "reason"],
5940
+ ["number", "agent", "kind", "calls", "status", "waiting on", "reason"],
5542
5941
  numbers.map((n) => [
5543
5942
  n.address,
5544
5943
  n.agent_ref ?? "",
5545
5944
  n.sender_kind,
5945
+ callable.has(n.address) ? "yes" : "",
5546
5946
  n.status,
5547
5947
  n.next_step ?? "",
5548
5948
  n.reason ?? ""
@@ -5608,7 +6008,27 @@ voiceNumbersCmd.command("provision-sms <number>").description("Ask for A2P SMS r
5608
6008
  fail(e);
5609
6009
  }
5610
6010
  });
5611
- var voiceAgentsCmd = voiceCmd.command("agents").description("Settings on a voice agent itself");
6011
+ var voiceAgentsCmd = voiceCmd.command("agents").description("List the organization's concierges and change settings on one");
6012
+ voiceAgentsCmd.command("list", { isDefault: true }).description(
6013
+ "List the organization's concierges: the voice agents that answer its website widget, its phone and its texts. The id is what the lead playbook stores as its linked concierge (`erdo leads playbook set --agent-id`). Archived agents are not listed."
6014
+ ).option("--json", "print the raw JSON result instead of a table").action(async (opts) => {
6015
+ try {
6016
+ const res = await new ErdoClient().listVoiceAgents();
6017
+ if (opts.json) {
6018
+ print(res);
6019
+ return;
6020
+ }
6021
+ if (!res.agents.length) {
6022
+ console.error("(no concierges in this organization)");
6023
+ return;
6024
+ }
6025
+ for (const a of res.agents) {
6026
+ console.log(`${a.id} ${a.slug} ${a.phone_number || "-"} ${a.name}`);
6027
+ }
6028
+ } catch (e) {
6029
+ fail(e);
6030
+ }
6031
+ });
5612
6032
  voiceAgentsCmd.command("sms-replies <slug>").description("Turn a voice agent's answering of texts on or off").option("--on", "the agent answers texts sent to its own number").option("--off", "texts still arrive and stay readable; the agent does not write back").action(async (slug, opts) => {
5613
6033
  if (Boolean(opts.on) === Boolean(opts.off)) {
5614
6034
  fail(new Error("say which way: pass --on or --off (exactly one)"));
@@ -5627,6 +6047,28 @@ voiceAgentsCmd.command("sms-replies <slug>").description("Turn a voice agent's a
5627
6047
  fail(e);
5628
6048
  }
5629
6049
  });
6050
+ voiceAgentsCmd.command("whatsapp-replies <slug>").description("Turn a voice agent's answering on WhatsApp on or off").option("--on", "the agent answers WhatsApp messages sent to its own number").option("--off", "messages still arrive and stay readable; the agent does not write back").action(async (slug, opts) => {
6051
+ if (Boolean(opts.on) === Boolean(opts.off)) {
6052
+ fail(new Error("say which way: pass --on or --off (exactly one)"));
6053
+ }
6054
+ try {
6055
+ const agent = await new ErdoClient().setVoiceAgentWhatsAppReplies(slug, Boolean(opts.on));
6056
+ console.log(
6057
+ `${agent.slug} ${agent.whatsapp_replies_enabled ? "on" : "off"} ${agent.phone_number ?? "-"} ${agent.name}`
6058
+ );
6059
+ if (!agent.phone_number) {
6060
+ process.stderr.write(
6061
+ "this agent has no phone number yet \u2014 the setting is stored and applies once it gets one\n"
6062
+ );
6063
+ } else {
6064
+ process.stderr.write(
6065
+ "the number also has to be set up as a WhatsApp sender before any reply can go out\n"
6066
+ );
6067
+ }
6068
+ } catch (e) {
6069
+ fail(e);
6070
+ }
6071
+ });
5630
6072
  var datasetsCmd = program.command("datasets").description("Datasets");
5631
6073
  datasetsCmd.command("list").description("List datasets").option(
5632
6074
  "--class <class>",
@@ -6070,8 +6512,10 @@ refreshCmd.command("set <slug>").description(
6070
6512
  const slugs = opts.contextDatasets.split(",").map((s) => s.trim()).filter(Boolean);
6071
6513
  if (slugs.length) body.context_dataset_slugs = slugs;
6072
6514
  }
6073
- if (typeof opts.onView === "boolean") body.refresh_on_view_enabled = opts.onView;
6074
- if (opts.staleAfter !== void 0) body.refresh_on_view_stale_after_seconds = opts.staleAfter;
6515
+ if (opts.onView !== void 0) body.refresh_on_view_enabled = opts.onView;
6516
+ if (opts.staleAfter !== void 0) {
6517
+ body.refresh_on_view_stale_after_seconds = opts.staleAfter;
6518
+ }
6075
6519
  if (opts.debounce !== void 0) body.refresh_on_view_debounce_seconds = opts.debounce;
6076
6520
  if (opts.liveWriteMode) body.live_write_mode = opts.liveWriteMode;
6077
6521
  if (opts.payloadSchema) {
@@ -6208,7 +6652,9 @@ filterCmd.command("list <slug>").description("List the default filters on a data
6208
6652
  fail(e);
6209
6653
  }
6210
6654
  });
6211
- var leadsCmd = program.command("leads").description("Read leads in a lead dataset, and merge two that are the same person");
6655
+ var leadsCmd = program.command("leads").description(
6656
+ "Read leads in a lead dataset, merge two that are the same person, and run the lead playbook that suggests each lead's next action"
6657
+ );
6212
6658
  leadsCmd.command("get <dataset> <lead>").description(
6213
6659
  "Read one lead: its permanent canonical_lead_id, the email and phone evidence bound to it, its dataset row, the leads merged into it, and its capture history. <lead> is either the canonical_lead_id UUID or the 22-character lead reference, and an id that has since been merged away resolves to the lead it became \u2014 so an id read off an old capture response still works."
6214
6660
  ).option("--json", "print the raw JSON result instead of a summary").action(async (dataset, lead, opts) => {
@@ -6305,6 +6751,404 @@ leadsCmd.command("merge <dataset> <survivor>").description(
6305
6751
  }
6306
6752
  }
6307
6753
  );
6754
+ var leadPlaybookCmd = leadsCmd.command("playbook").description("Read, try and save the lead playbook: the plain-text rules for what Erdo does next with each lead");
6755
+ leadPlaybookCmd.command("get").description(
6756
+ "Show the saved playbook: whether the sweep is enabled, the daily contact cap, the read-back Erdo enforces (each action's mode and the working hours), and the text. An organization that never saved one sees the default template."
6757
+ ).option("--json", "print the raw JSON result instead of a summary").action(async (opts) => {
6758
+ try {
6759
+ const res = await new ErdoClient().getLeadPlaybook();
6760
+ if (opts.json) {
6761
+ print(res);
6762
+ return;
6763
+ }
6764
+ printLeadPlaybook(res);
6765
+ } catch (e) {
6766
+ fail(e);
6767
+ }
6768
+ });
6769
+ leadPlaybookCmd.command("set").description(
6770
+ "Save the playbook text from a file. Erdo reads it into the typed read-back and stores both; a text it cannot read is refused rather than saved with a guess. --enable turns the sweep on, which lets Erdo email leads automatically wherever the text marks email automatic. Organization admins only."
6771
+ ).requiredOption("-f, --file <path>", "file holding the full playbook text").option("--enable", "turn the sweep on for the organization").option("--disable", "turn the sweep off for the organization").option(
6772
+ "--daily-contact-cap <n>",
6773
+ "emails sent or put in front of a person per 24 hours, 0 to 500 (default 40)",
6774
+ (v) => parseInt(v, 10)
6775
+ ).option(
6776
+ "--agent-id <id>",
6777
+ "link the concierge every draft is written as, by its id from `erdo voice agents`. Unlike the other options this one is not kept: leave it off and the link is cleared"
6778
+ ).option("--json", "print the raw JSON result instead of a summary").action(
6779
+ async (opts) => {
6780
+ try {
6781
+ if (opts.enable && opts.disable) {
6782
+ throw new Error("pass --enable or --disable, not both");
6783
+ }
6784
+ if (opts.dailyContactCap !== void 0 && !Number.isInteger(opts.dailyContactCap)) {
6785
+ throw new Error("--daily-contact-cap must be a whole number");
6786
+ }
6787
+ const body = readFileSync4(opts.file, "utf8");
6788
+ const res = await new ErdoClient().putLeadPlaybook({
6789
+ body,
6790
+ enabled: opts.enable ? true : opts.disable ? false : void 0,
6791
+ daily_contact_cap: opts.dailyContactCap,
6792
+ agent_id: opts.agentId
6793
+ });
6794
+ if (opts.json) {
6795
+ print(res);
6796
+ return;
6797
+ }
6798
+ console.log(`Saved revision ${res.revision}.`);
6799
+ printLeadPlaybook(res, { withBody: false });
6800
+ } catch (e) {
6801
+ fail(e);
6802
+ }
6803
+ }
6804
+ );
6805
+ leadPlaybookCmd.command("read-back").description(
6806
+ "Show how Erdo reads a draft playbook without saving it: the stages, each action's mode after platform limits, the limits Erdo added, and the working hours."
6807
+ ).requiredOption("-f, --file <path>", "file holding the draft playbook text").option(
6808
+ "--agent-id <id>",
6809
+ "read the draft back as saving it with that concierge would; leave it off to read it back with none, which is what saving with none does"
6810
+ ).option("--json", "print the raw JSON result instead of a summary").action(async (opts) => {
6811
+ try {
6812
+ const res = await new ErdoClient().readLeadPlaybook(readFileSync4(opts.file, "utf8"), opts.agentId);
6813
+ if (opts.json) {
6814
+ print(res);
6815
+ return;
6816
+ }
6817
+ printLeadReadBack(res);
6818
+ } catch (e) {
6819
+ fail(e);
6820
+ }
6821
+ });
6822
+ leadPlaybookCmd.command("revisions").description(
6823
+ "List every saved version of the playbook, newest first: the revision number, whether the sweep was on, the daily cap, the concierge it was saved with and who saved it when. History starts at the revision that was current when Erdo began keeping it."
6824
+ ).option("--limit <n>", "maximum revisions to return (default 20, maximum 100)", (v) => parseInt(v, 10)).option("--offset <n>", "revisions to skip, for paging further back", (v) => parseInt(v, 10)).option("--json", "print the raw JSON result instead of a summary").action(async (opts) => {
6825
+ try {
6826
+ const res = await new ErdoClient().listLeadPlaybookRevisions({ limit: opts.limit, offset: opts.offset });
6827
+ if (opts.json) {
6828
+ print(res);
6829
+ return;
6830
+ }
6831
+ if (!res.revisions?.length) {
6832
+ console.log("No playbook revisions are kept for this organization.");
6833
+ return;
6834
+ }
6835
+ printAlignedTable(
6836
+ ["revision", "saved", "enabled", "daily cap", "concierge", "saved by"],
6837
+ res.revisions.map((r) => [
6838
+ String(r.revision),
6839
+ r.saved_at,
6840
+ r.enabled ? "yes" : "no",
6841
+ String(r.daily_contact_cap),
6842
+ r.agent_id ?? "none",
6843
+ r.saved_by ?? ""
6844
+ ])
6845
+ );
6846
+ console.log(`
6847
+ ${res.revisions.length} of ${res.total} kept revision(s). Read one with \`erdo leads playbook revision <n>\`.`);
6848
+ } catch (e) {
6849
+ fail(e);
6850
+ }
6851
+ });
6852
+ leadPlaybookCmd.command("revision <revision>").description(
6853
+ "Show one saved revision in full: the text exactly as it was saved and the read-back exactly as Erdo understood it then. This is what explains a decision whose playbook_revision names it."
6854
+ ).option("--json", "print the raw JSON result instead of a summary").action(async (revision, opts) => {
6855
+ try {
6856
+ const n = parseInt(revision, 10);
6857
+ if (!Number.isFinite(n) || n <= 0) {
6858
+ fail(new Error("revision must be a positive number"));
6859
+ return;
6860
+ }
6861
+ const res = await new ErdoClient().getLeadPlaybookRevision(n);
6862
+ if (opts.json) {
6863
+ print(res);
6864
+ return;
6865
+ }
6866
+ printLeadPlaybookRevision(res);
6867
+ } catch (e) {
6868
+ fail(e);
6869
+ }
6870
+ });
6871
+ leadsCmd.command("timeline <dataset> <lead>").description(
6872
+ "Show everything the next-action evaluation reads about one lead: captures, sent emails, replies, SMS, calls, website chats, bookings and earlier suggestions in time order, plus the timing facts a follow-up rule matches on. <lead> is the canonical_lead_id UUID or the 22-character lead reference."
6873
+ ).option("--json", "print the raw JSON result instead of a summary").action(async (dataset, lead, opts) => {
6874
+ try {
6875
+ const res = await new ErdoClient().getLeadTimeline(dataset, lead);
6876
+ if (opts.json) {
6877
+ print(res);
6878
+ return;
6879
+ }
6880
+ console.log(`lead ${res.canonical_lead_id}`);
6881
+ console.log(`reference ${res.lead_reference}`);
6882
+ console.log(`dataset ${res.dataset_slug}`);
6883
+ if (res.entries?.length) {
6884
+ console.log("\nentries");
6885
+ printAlignedTable(
6886
+ ["when", "kind", "direction", "status", "summary"],
6887
+ res.entries.map((e) => [e.at, e.kind, e.direction ?? "", e.status ?? "", clipLeadText(e.summary, 90)])
6888
+ );
6889
+ } else {
6890
+ console.log("\nNo timeline entries.");
6891
+ }
6892
+ console.log("\ntiming");
6893
+ printAlignedTable(
6894
+ ["fact", "value"],
6895
+ Object.entries(res.timing ?? {}).map(([k, v]) => [k, v === null || v === void 0 ? "" : String(v)])
6896
+ );
6897
+ for (const [source, reason] of Object.entries(res.unavailable ?? {})) {
6898
+ console.log(`
6899
+ unavailable: ${source} \u2014 ${reason}`);
6900
+ }
6901
+ } catch (e) {
6902
+ fail(e);
6903
+ }
6904
+ });
6905
+ var leadNextActionsCmd = leadsCmd.command("next-actions").description("Read the suggested next action for each lead, hold or close a lead, and dry-run the playbook on one lead");
6906
+ leadNextActionsCmd.command("list").description(
6907
+ "List each lead's current suggestion, highest priority first. --status pending_approval lists the cards waiting for a person."
6908
+ ).option("--dataset <slug>", "narrow to one lead dataset").option(
6909
+ "--status <status>",
6910
+ "queued, pending_approval, executing, executed, approved (a text or call somebody said yes to, not yet carried out), rejected, expired, superseded, failed or recorded"
6911
+ ).option("--priority <priority>", "high, medium or low").option("-l, --limit <n>", "maximum suggestions to return (default 50, maximum 200)", (v) => parseInt(v, 10)).option("--offset <n>", "skip that many suggestions, for paging", (v) => parseInt(v, 10)).option("--json", "print the raw JSON result instead of a table").action(
6912
+ async (opts) => {
6913
+ try {
6914
+ const res = await new ErdoClient().listLeadNextActions({
6915
+ dataset: opts.dataset,
6916
+ status: opts.status,
6917
+ priority: opts.priority,
6918
+ limit: opts.limit,
6919
+ offset: opts.offset
6920
+ });
6921
+ if (opts.json) {
6922
+ print(res);
6923
+ return;
6924
+ }
6925
+ const items = res.next_actions ?? [];
6926
+ if (items.length === 0) {
6927
+ console.log("No lead next actions match.");
6928
+ return;
6929
+ }
6930
+ printAlignedTable(
6931
+ ["priority", "status", "action", "mode", "lead", "evaluated", "rationale"],
6932
+ items.map((a) => [
6933
+ a.priority,
6934
+ a.status,
6935
+ a.action_kind,
6936
+ a.mode,
6937
+ a.lead_reference,
6938
+ a.evaluated_at,
6939
+ clipLeadText(a.rationale, 80)
6940
+ ])
6941
+ );
6942
+ if (items.length === res.limit) {
6943
+ console.log(`
6944
+ More may follow: --offset ${res.offset + res.limit}`);
6945
+ }
6946
+ } catch (e) {
6947
+ fail(e);
6948
+ }
6949
+ }
6950
+ );
6951
+ leadNextActionsCmd.command("history <dataset> <lead>").description(
6952
+ "Show every decision about one lead, newest first: what was suggested, why, and what became of it. <lead> is the canonical_lead_id UUID or the 22-character lead reference."
6953
+ ).option("--json", "print the raw JSON result instead of a table").action(async (dataset, lead, opts) => {
6954
+ try {
6955
+ const res = await new ErdoClient().getLeadNextActions(dataset, lead);
6956
+ if (opts.json) {
6957
+ print(res);
6958
+ return;
6959
+ }
6960
+ const items = res.next_actions ?? [];
6961
+ if (items.length === 0) {
6962
+ console.log("No decisions have been recorded for this lead.");
6963
+ return;
6964
+ }
6965
+ printAlignedTable(
6966
+ ["evaluated", "source", "action", "mode", "status", "emailed", "rationale"],
6967
+ items.map((a) => [
6968
+ a.evaluated_at,
6969
+ a.source,
6970
+ a.action_kind,
6971
+ a.mode,
6972
+ a.status,
6973
+ // Whether the lead heard anything while this decision waited for a
6974
+ // person. On a call or a text card it is the difference between a
6975
+ // lead sitting in silence and one holding a booking link.
6976
+ a.accompanying_email?.status ?? "-",
6977
+ clipLeadText(a.rationale, 80)
6978
+ ])
6979
+ );
6980
+ } catch (e) {
6981
+ fail(e);
6982
+ }
6983
+ });
6984
+ leadNextActionsCmd.command("hold <dataset> <lead>").description(
6985
+ "Tell Erdo the sales desk is handling this lead. The lead's open suggestion is replaced and any card it filed is withdrawn, so Erdo does not email someone the desk is already talking to. The hold ends at --until (default seven days, at most 180) or when the lead writes back."
6986
+ ).option("--until <time>", "when the hold ends, as an RFC 3339 time, e.g. 2026-10-01T09:00:00-04:00").option("--note <text>", "why, in the desk's words").option("--json", "print the raw JSON result instead of a summary").action(async (dataset, lead, opts) => {
6987
+ try {
6988
+ let until;
6989
+ if (opts.until) {
6990
+ const ms = Date.parse(opts.until);
6991
+ if (Number.isNaN(ms)) throw new Error(`--until is not a time: ${opts.until}`);
6992
+ until = new Date(ms).toISOString();
6993
+ }
6994
+ const res = await new ErdoClient().recordLeadNextAction(dataset, lead, { kind: "hold", until, note: opts.note });
6995
+ if (opts.json) {
6996
+ print(res);
6997
+ return;
6998
+ }
6999
+ printRecordedLeadDecision(res);
7000
+ } catch (e) {
7001
+ fail(e);
7002
+ }
7003
+ });
7004
+ leadNextActionsCmd.command("close <dataset> <lead>").description(
7005
+ "Stop working this lead. The lead's open suggestion is replaced and any card it filed is withdrawn; Erdo suggests nothing more until the lead gets in touch again."
7006
+ ).option("--note <text>", "why, in the desk's words").option("--json", "print the raw JSON result instead of a summary").action(async (dataset, lead, opts) => {
7007
+ try {
7008
+ const res = await new ErdoClient().recordLeadNextAction(dataset, lead, { kind: "close", note: opts.note });
7009
+ if (opts.json) {
7010
+ print(res);
7011
+ return;
7012
+ }
7013
+ printRecordedLeadDecision(res);
7014
+ } catch (e) {
7015
+ fail(e);
7016
+ }
7017
+ });
7018
+ leadNextActionsCmd.command("evaluate <dataset> <lead>").description(
7019
+ "Dry-run the playbook on one lead: show the decision Erdo would make now, with the email or handoff it would write, the rule it cites and why. Nothing is stored and nothing is sent. --file tries a draft playbook instead of the saved one."
7020
+ ).option("-f, --file <path>", "a draft playbook text to evaluate with instead of the saved one").option("--agent-id <id>", "try the draft as that concierge, from `erdo voice agents`; only read alongside --file").option("--json", "print the raw JSON result instead of a summary").action(async (dataset, lead, opts) => {
7021
+ try {
7022
+ const body = opts.file ? readFileSync4(opts.file, "utf8") : void 0;
7023
+ const res = await new ErdoClient().evaluateLeadNextAction(dataset, lead, body, opts.agentId);
7024
+ if (opts.json) {
7025
+ print(res);
7026
+ return;
7027
+ }
7028
+ const ev = res.evaluation;
7029
+ if (res.agent) console.log(`as ${res.agent.name} (${res.agent.slug})`);
7030
+ console.log(`stage ${ev.stage}`);
7031
+ console.log(`priority ${ev.priority}${ev.priority_reason ? ` \u2014 ${ev.priority_reason}` : ""}`);
7032
+ const proposed = ev.proposed_kind && ev.proposed_kind !== ev.action_kind ? ` (proposed ${ev.proposed_kind})` : "";
7033
+ console.log(`action ${ev.action_kind}${proposed}`);
7034
+ console.log(`mode ${ev.mode}`);
7035
+ if (ev.due_at) console.log(`due ${ev.due_at}`);
7036
+ console.log(`rule ${ev.rule ? `"${ev.rule}"` : "(none)"}${ev.rule_verified ? "" : " \u2014 not found in the playbook"}`);
7037
+ console.log(`
7038
+ ${ev.rationale}`);
7039
+ const input = ev.action_input;
7040
+ if (input.email) {
7041
+ console.log(`
7042
+ email to ${input.to ?? "(no address)"}`);
7043
+ console.log(`subject: ${input.email.subject}
7044
+ `);
7045
+ console.log(input.email.body_markdown);
7046
+ }
7047
+ if (input.handoff) {
7048
+ const channel = input.handoff.suggested_channel ? ` (by ${input.handoff.suggested_channel})` : "";
7049
+ console.log(`
7050
+ handoff${channel}: ${input.handoff.summary}`);
7051
+ }
7052
+ if (input.sms) {
7053
+ console.log(`
7054
+ text to send:
7055
+ ${input.sms.body}`);
7056
+ }
7057
+ if (input.agent_instructions) {
7058
+ console.log(`
7059
+ for the concierge \u2014 ${input.agent_instructions.objective}`);
7060
+ console.log(input.agent_instructions.instructions);
7061
+ }
7062
+ if (input.downgrade_cause) {
7063
+ console.log(`
7064
+ why a person decides: ${input.downgrade_cause}`);
7065
+ }
7066
+ } catch (e) {
7067
+ fail(e);
7068
+ }
7069
+ });
7070
+ function clipLeadText(text, max) {
7071
+ const flat = (text ?? "").replace(/\s+/g, " ").trim();
7072
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
7073
+ }
7074
+ function printLeadPlaybook(pb, opts = {}) {
7075
+ if (!pb.exists) {
7076
+ console.log("No playbook is saved. This is the default template; save one with `erdo leads playbook set`.");
7077
+ } else {
7078
+ console.log(`revision ${pb.revision}${pb.updated_at ? ` (saved ${pb.updated_at})` : ""}`);
7079
+ }
7080
+ console.log(`enabled ${pb.enabled ? "yes" : "no"}`);
7081
+ console.log(`daily cap ${pb.daily_contact_cap}`);
7082
+ if (pb.agent) {
7083
+ console.log(`concierge ${pb.agent.name} (${pb.agent.slug})${pb.agent.phone_number ? ` ${pb.agent.phone_number}` : ""}`);
7084
+ } else if (pb.agent_id) {
7085
+ console.log(`concierge ${pb.agent_id} \u2014 archived; pick another with \`erdo leads playbook set --agent-id\``);
7086
+ } else {
7087
+ console.log("concierge none \u2014 texts and calls cannot be proposed");
7088
+ }
7089
+ if (pb.exists) {
7090
+ console.log("");
7091
+ printLeadReadBack(pb.read_back);
7092
+ }
7093
+ if (opts.withBody !== false) {
7094
+ console.log("\n--- playbook ---");
7095
+ console.log(pb.body);
7096
+ }
7097
+ }
7098
+ function printLeadReadBack(rb) {
7099
+ if (rb.actions?.length) {
7100
+ printAlignedTable(
7101
+ ["action", "mode", "when"],
7102
+ rb.actions.map((a) => [a.kind, a.mode, clipLeadText(a.when, 80)])
7103
+ );
7104
+ }
7105
+ if (rb.working_hours) {
7106
+ const wh = rb.working_hours;
7107
+ console.log(`
7108
+ working hours ${wh.days.join(", ")} ${wh.start}\u2013${wh.end} ${wh.timezone}`);
7109
+ }
7110
+ if (rb.stages?.length) {
7111
+ console.log("\nstages");
7112
+ printAlignedTable(
7113
+ ["stage", "actions", "summary"],
7114
+ rb.stages.map((s) => [s.name, s.actions.join(", "), clipLeadText(s.summary, 80)])
7115
+ );
7116
+ }
7117
+ if (rb.priority) {
7118
+ console.log("\npriority Erdo read from the text (not enforced)");
7119
+ printAlignedTable(
7120
+ ["level", "conditions"],
7121
+ [
7122
+ ["high", rb.priority.high.join("; ") || "\u2014"],
7123
+ ["medium", rb.priority.medium.join("; ") || "\u2014"],
7124
+ ["low", rb.priority.low.join("; ") || "\u2014"]
7125
+ ]
7126
+ );
7127
+ if (rb.priority.forbidden_factors?.length) {
7128
+ console.log(`
7129
+ must not affect priority ${rb.priority.forbidden_factors.join(", ")}`);
7130
+ }
7131
+ }
7132
+ if (rb.limits?.length) {
7133
+ console.log("\nlimits Erdo applied");
7134
+ for (const l of rb.limits) console.log(` ${l.kind} \u2192 ${l.mode}: ${l.reason}`);
7135
+ }
7136
+ }
7137
+ function printLeadPlaybookRevision(rev) {
7138
+ console.log(`revision ${rev.revision} (saved ${rev.saved_at}${rev.saved_by ? ` by ${rev.saved_by}` : ""})`);
7139
+ console.log(`enabled ${rev.enabled ? "yes" : "no"}`);
7140
+ console.log(`daily cap ${rev.daily_contact_cap}`);
7141
+ console.log(`concierge ${rev.agent_id ?? "none"}`);
7142
+ console.log("");
7143
+ printLeadReadBack(rev.read_back);
7144
+ console.log("\n--- playbook as it was saved ---");
7145
+ console.log(rev.body);
7146
+ }
7147
+ function printRecordedLeadDecision(a) {
7148
+ const until = a.revisit_at ? ` until ${a.revisit_at}` : "";
7149
+ console.log(`Recorded ${a.action_kind} on lead ${a.lead_reference}${a.action_kind === "hold" ? until : ""}.`);
7150
+ if (a.rationale) console.log(a.rationale);
7151
+ }
6308
7152
  function printLead(lead) {
6309
7153
  console.log(`lead ${lead.canonical_lead_id}`);
6310
7154
  console.log(`reference ${lead.reference}`);
@@ -6520,7 +7364,7 @@ List tables with: erdo integrations tables ${app} <schema>`);
6520
7364
  return;
6521
7365
  }
6522
7366
  for (const t of res.tables ?? []) {
6523
- const rows = t.estimated_row_count != null ? ` ~${t.estimated_row_count} rows` : "";
7367
+ const rows = t.estimated_row_count == null ? "" : ` ~${t.estimated_row_count} rows`;
6524
7368
  console.log(`${t.schema_name}.${t.table_name} ${t.columns.length} columns${rows}`);
6525
7369
  }
6526
7370
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erdoai/cli",
3
- "version": "0.91.0",
3
+ "version": "0.99.0",
4
4
  "description": "Erdo CLI — drive datasets, pages, and evals from the terminal or CI",
5
5
  "type": "module",
6
6
  "bin": {