@letterstory/cli 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/lib/cli.mjs +45 -1
- package/lib/commands/admin.mjs +122 -1
- package/lib/commands/evals.mjs +198 -0
- package/lib/commands/outbound.mjs +137 -2
- package/lib/commands.mjs +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -670,6 +670,50 @@ letterstory insights bank [--source phantoms|marketing|all] [--phantom <uuid>]..
|
|
|
670
670
|
letterstory insights access [--period 14d|30d|90d] [--collection <uuid>]
|
|
671
671
|
```
|
|
672
672
|
|
|
673
|
+
## Outbound email — mailers, sequences, prospects
|
|
674
|
+
|
|
675
|
+
The cold-email stack: sending **mailers** (real Google mailboxes on domains we own), **sequences**
|
|
676
|
+
(multi-step flows sent from them, with a review queue) and **prospects** (who you write to). Writes
|
|
677
|
+
here can SEND email or spend money, so they refuse unknown flags instead of ignoring them. Scopes:
|
|
678
|
+
`mailer:read` / `mailer:manage` / `mailer:provision`, `sequence:read` / `sequence:write`,
|
|
679
|
+
`prospect:read` / `prospect:write` (the write scopes are admin-only keys).
|
|
680
|
+
|
|
681
|
+
```bash
|
|
682
|
+
# Mailers
|
|
683
|
+
letterstory mailers list [--domain acme.com] # status + daily send limit
|
|
684
|
+
letterstory mailers domain <owned-domain-id> # provisioning stage, DNS, mailers on it
|
|
685
|
+
letterstory mailers messages <mailer-id> [--folder inbox|sent|all] [--limit 25] # envelopes
|
|
686
|
+
letterstory mailers message <mailer-id> <uid> --folder inbox # one message, plain-text body
|
|
687
|
+
letterstory mailers forward <mailer-id> --to me@acme.com # or --clear
|
|
688
|
+
letterstory mailers warmup start|pause <mailer-id> # 10 days to ready
|
|
689
|
+
letterstory mailers limit <mailer-id> 40 # 0..500; refused while warming
|
|
690
|
+
letterstory mailers retire <mailer-id> # disable; history kept
|
|
691
|
+
letterstory mailers retire <mailer-id> --release-mailbox --yes # also delete the hosted mailbox (irreversible)
|
|
692
|
+
letterstory mailers suppressions [list] [--reason bounce] [--limit 100]
|
|
693
|
+
letterstory mailers suppressions add person@x.com --reason manual --note "asked to stop"
|
|
694
|
+
letterstory mailers suppressions remove person@x.com # only 'manual' entries; opt-outs stay
|
|
695
|
+
|
|
696
|
+
# Launching buys a domain (up to $30) and the mailboxes, so it is a tool call, not a command:
|
|
697
|
+
letterstory call launch_mailers --args '{"domain":"acme-mail.com","mailers":[{"local_part":"hello"}],"consent_attested":true,"mail_from_postal_address":"…"}'
|
|
698
|
+
|
|
699
|
+
# Org-level mail settings
|
|
700
|
+
letterstory mail connections # connected mailboxes, key + postal address status
|
|
701
|
+
letterstory mail postal-address "123 Main St, Portland, OR 97201" # CAN-SPAM footer
|
|
702
|
+
|
|
703
|
+
# Sequences (approving or enrolling SENDS)
|
|
704
|
+
letterstory sequences list | get <id> | create | update <id> | pause <id> | resume <id>
|
|
705
|
+
letterstory sequences enroll <id> --list <list-id> # or --email a@x.com ...
|
|
706
|
+
letterstory sequences review list | approve <item-id> | skip <item-id> | edit <item-id> --subject "…"
|
|
707
|
+
letterstory sequences links create --days 2 # no-login phone review link, shown once
|
|
708
|
+
|
|
709
|
+
# Prospects
|
|
710
|
+
letterstory prospects lists | ls --list <list-id> | get <id> | import --csv-file leads.csv --list-name "Q4"
|
|
711
|
+
letterstory prospects source --description "heads of growth at seed-stage fintech" --list-name "Fintech"
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Connecting a bring-your-own Gmail mailbox needs a browser (Google consent), so it is done in the
|
|
715
|
+
app; `letterstory mail connections` shows what is connected.
|
|
716
|
+
|
|
673
717
|
## Anything else
|
|
674
718
|
|
|
675
719
|
Every Letterstory tool is reachable, not just the ones with a dedicated command group:
|
package/lib/cli.mjs
CHANGED
|
@@ -45,14 +45,16 @@ import {
|
|
|
45
45
|
cmdSequences,
|
|
46
46
|
cmdProspects,
|
|
47
47
|
cmdMail,
|
|
48
|
+
cmdMailers,
|
|
48
49
|
cmdAdmin,
|
|
49
50
|
cmdFeedback,
|
|
51
|
+
cmdEvals,
|
|
50
52
|
cmdOfferings,
|
|
51
53
|
cmdTargets,
|
|
52
54
|
} from "./commands.mjs";
|
|
53
55
|
|
|
54
56
|
// Keep in sync with cli/package.json.
|
|
55
|
-
export const VERSION = "0.
|
|
57
|
+
export const VERSION = "0.48.0";
|
|
56
58
|
|
|
57
59
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
58
60
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -93,6 +95,8 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
93
95
|
"no-visuals",
|
|
94
96
|
"no-enhance",
|
|
95
97
|
"no-key",
|
|
98
|
+
// `admin evals triage --overdue`
|
|
99
|
+
"overdue",
|
|
96
100
|
// `autopen start --article <id> --from-scratch`
|
|
97
101
|
"from-scratch",
|
|
98
102
|
// `collections activate <id> --no-topic-map` / `--no-headlines`
|
|
@@ -593,6 +597,16 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
593
597
|
org:create. onboard-plan reads the calls
|
|
594
598
|
(sites, who to invite, paid or not);
|
|
595
599
|
payment-link needs billing:write.
|
|
600
|
+
admin evals scoreboard [--scope fleet|org|collection --id <uuid>] [--days N]
|
|
601
|
+
| cases [--org <uuid>] [--status s] [--stage s] | runs [--limit N] [--mode record|full]
|
|
602
|
+
| run [--mode record|full] [--keys a,b] [--org <uuid>] [--baseline latest|<id>]
|
|
603
|
+
| triage [--owner research|kernel|links|mentions|accuracy|pipeline] [--overdue]
|
|
604
|
+
| guard <case-key> [--sha <commit>] [--fix-pr #N] | retire <case-key> --reason "…"
|
|
605
|
+
The fleet-wide eval surface: scoreboard,
|
|
606
|
+
every org's cases and suite runs, the
|
|
607
|
+
triage queue, and the two lifecycle calls
|
|
608
|
+
an org key cannot make. Reads need
|
|
609
|
+
fleet:read; writes need tuning:experiment.
|
|
596
610
|
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
597
611
|
the pipeline as deployed, levers, measures,
|
|
598
612
|
goals, related history.
|
|
@@ -798,6 +812,22 @@ Prospects (who you write to):
|
|
|
798
812
|
[--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
|
|
799
813
|
[--city] [--country] [--years] [--notes] [--custom key=value]...
|
|
800
814
|
|
|
815
|
+
Mailers (the sending mailboxes; launching one buys a domain, so use: call launch_mailers):
|
|
816
|
+
mailers list [--domain <apex>] Your mailers + status + daily send limit
|
|
817
|
+
mailers domain <owned-domain-id> Provisioning stage, DNS state, mailers on a domain
|
|
818
|
+
mailers messages <mailer-id> [--folder all|inbox|sent] [--limit <n>]
|
|
819
|
+
Recent mail (envelopes)
|
|
820
|
+
mailers message <mailer-id> <uid> --folder inbox|sent
|
|
821
|
+
One message in full (plain-text body)
|
|
822
|
+
mailers forward <mailer-id> (--to <email> | --clear) Copy inbound mail to an address
|
|
823
|
+
mailers warmup start|pause <mailer-id> Start / pause warming (10 days to ready)
|
|
824
|
+
mailers limit <mailer-id> <per-day> Daily send limit, 0..500 (not while warming)
|
|
825
|
+
mailers retire <mailer-id> [--release-mailbox --yes] Disable a mailer; --release-mailbox also deletes
|
|
826
|
+
the hosted mailbox (stops billing, irreversible)
|
|
827
|
+
mailers suppressions [list] [--reason <r>] [--limit <n>]
|
|
828
|
+
mailers suppressions add <address> [--reason unsubscribe|bounce|complaint|manual] [--note <t>]
|
|
829
|
+
mailers suppressions remove <address> Only 'manual' entries can be removed
|
|
830
|
+
|
|
801
831
|
Mail:
|
|
802
832
|
mail connections Connected mailboxes, Google connect, Vercel key + mailing address status
|
|
803
833
|
mail postal-address "<address>" Set the org's CAN-SPAM mailing address (email footers)
|
|
@@ -816,6 +846,18 @@ Feedback (tell us what's broken, missing, unclear, or wanted):
|
|
|
816
846
|
[--message <text>|--body <text>|--file <path>|--file -] [--rating 1-5]
|
|
817
847
|
[--context '<json>']
|
|
818
848
|
|
|
849
|
+
Evals (how the pipeline is doing on your posts, and the regression cases captured from them):
|
|
850
|
+
evals scoreboard [--collection <id>] [--days N] Every measure by stage: posts, failing, rate
|
|
851
|
+
evals cases [--status open|guarding|proposed|retired|rejected] [--stage research|brief|links|mentions|accuracy|pipeline]
|
|
852
|
+
evals capture (--article <id> | --research-run <id> | --kernel-job <id>) --title "…"
|
|
853
|
+
(--description "…" | --body … | --file <path>) (--measure <key> | --assertion "…")
|
|
854
|
+
[--stage <s>] [--replay <stage>] [--auto-pen <id>] [--published-version N]
|
|
855
|
+
[--incident <ref>] [--link <url>] [--status open|proposed]
|
|
856
|
+
Freeze the post's record as a case; exits 1 when
|
|
857
|
+
it does NOT detect the issue on current code
|
|
858
|
+
evals run [--keys a,b] [--baseline latest|<run-id>] Record-only suite; exits 1 on a regression
|
|
859
|
+
evals run-status <run-id>
|
|
860
|
+
|
|
819
861
|
Anything else:
|
|
820
862
|
tools list List every tool this server exposes, by name
|
|
821
863
|
tools show <name> Show one tool's capability, argument schema, and return shape
|
|
@@ -888,7 +930,9 @@ const CLIENT_COMMANDS = {
|
|
|
888
930
|
sequences: cmdSequences,
|
|
889
931
|
prospects: cmdProspects,
|
|
890
932
|
mail: cmdMail,
|
|
933
|
+
mailers: cmdMailers,
|
|
891
934
|
feedback: cmdFeedback,
|
|
935
|
+
evals: cmdEvals,
|
|
892
936
|
// Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
|
|
893
937
|
admin: cmdAdmin,
|
|
894
938
|
};
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
8
|
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
9
|
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
10
|
+
// admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
|
|
10
11
|
// admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
|
|
11
12
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
12
13
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
@@ -879,6 +880,125 @@ async function cmdAdminCrm(ctx) {
|
|
|
879
880
|
}
|
|
880
881
|
}
|
|
881
882
|
|
|
883
|
+
/* ------------------------------------------------------------ content evals */
|
|
884
|
+
|
|
885
|
+
const EVALS = "/api/superadmin/evals";
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* `admin evals …` — the staff half of the eval surface, against /api/superadmin/evals: the
|
|
889
|
+
* fleet-wide scoreboard, every org's cases and runs, the triage queue, and the two lifecycle
|
|
890
|
+
* decisions an org key cannot make (guard, retire). Reads need fleet:read; writes need
|
|
891
|
+
* tuning:experiment. The org-scoped half is `letterstory evals …` on an org key.
|
|
892
|
+
*/
|
|
893
|
+
async function cmdAdminEvals(ctx) {
|
|
894
|
+
const [sub, id] = ctx.positionals;
|
|
895
|
+
const { flags, io } = ctx;
|
|
896
|
+
const json = flagBool(flags.json);
|
|
897
|
+
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
898
|
+
const query = (obj) => {
|
|
899
|
+
const params = new URLSearchParams(compact(obj));
|
|
900
|
+
const q = params.toString();
|
|
901
|
+
return q ? `?${q}` : "";
|
|
902
|
+
};
|
|
903
|
+
switch (sub) {
|
|
904
|
+
case "scoreboard": {
|
|
905
|
+
const q = query({ scope: flagStr(flags.scope), id: flagStr(flags.id), days: flagStr(flags.days) });
|
|
906
|
+
return out(
|
|
907
|
+
await staffRequest(ctx, "GET", `${EVALS}/scoreboard${q}`),
|
|
908
|
+
(r) =>
|
|
909
|
+
(r.stages ?? [])
|
|
910
|
+
.flatMap((st) => st.measures ?? [])
|
|
911
|
+
.map(
|
|
912
|
+
(m) => `${m.stage.padEnd(9)} ${m.measure.padEnd(44)} ${m.failing}/${m.posts} ${m.severity}`
|
|
913
|
+
)
|
|
914
|
+
.join("\n") || "(no measurements in the window)"
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
case "cases": {
|
|
918
|
+
const q = query({ org_id: flagStr(flags.org), status: flagStr(flags.status), stage: flagStr(flags.stage) });
|
|
919
|
+
return out(
|
|
920
|
+
await staffRequest(ctx, "GET", `${EVALS}/cases${q}`),
|
|
921
|
+
(r) =>
|
|
922
|
+
(r.cases ?? [])
|
|
923
|
+
.map(
|
|
924
|
+
(c) =>
|
|
925
|
+
`${c.key.padEnd(48)} ${c.status.padEnd(9)} ${c.stage.padEnd(9)} ${c.latestResult ? c.latestResult.verdict : "never run"}`
|
|
926
|
+
)
|
|
927
|
+
.join("\n") || "(no cases)"
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
case "runs": {
|
|
931
|
+
const q = query({ limit: flagStr(flags.limit), mode: flagStr(flags.mode) });
|
|
932
|
+
return out(
|
|
933
|
+
await staffRequest(ctx, "GET", `${EVALS}/suite/runs${q}`),
|
|
934
|
+
(r) =>
|
|
935
|
+
(r.runs ?? [])
|
|
936
|
+
.map(
|
|
937
|
+
(run) =>
|
|
938
|
+
`${run.id} ${run.startedAt} ${run.mode.padEnd(6)} ${run.verdict.padEnd(4)} ${run.isBaseline ? "baseline " : ""}${run.totals?.regressed ?? 0} regressed`
|
|
939
|
+
)
|
|
940
|
+
.join("\n") || "(no runs)"
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
case "run": {
|
|
944
|
+
const keys = flagStr(flags.keys);
|
|
945
|
+
const body = compact({
|
|
946
|
+
mode: flagStr(flags.mode),
|
|
947
|
+
case_keys: keys
|
|
948
|
+
? keys
|
|
949
|
+
.split(",")
|
|
950
|
+
.map((k) => k.trim())
|
|
951
|
+
.filter(Boolean)
|
|
952
|
+
: undefined,
|
|
953
|
+
org_id: flagStr(flags.org),
|
|
954
|
+
baseline: flagStr(flags.baseline),
|
|
955
|
+
});
|
|
956
|
+
const r = await staffRequest(ctx, "POST", `${EVALS}/suite/runs`, body);
|
|
957
|
+
out(
|
|
958
|
+
r,
|
|
959
|
+
(x) =>
|
|
960
|
+
`Run ${x.run?.id ?? "(queued)"} — ${String(x.run?.verdict ?? "").toUpperCase()} ${x.run?.totals?.regressed ?? 0} regressed`
|
|
961
|
+
);
|
|
962
|
+
return r.run?.verdict === "fail" ? 1 : 0;
|
|
963
|
+
}
|
|
964
|
+
case "triage": {
|
|
965
|
+
const q = query({ owner: flagStr(flags.owner), overdue: flagBool(flags.overdue) ? "true" : undefined });
|
|
966
|
+
return out(await staffRequest(ctx, "GET", `${EVALS}/triage${q}`), (r) =>
|
|
967
|
+
[
|
|
968
|
+
...(r.findings ?? []).map(
|
|
969
|
+
(f) =>
|
|
970
|
+
`${f.id} ${(f.owner ?? "unassigned").padEnd(10)} ${f.evalKey.padEnd(36)} ${f.overdue ? "OVERDUE " : ""}${f.title ?? f.summary ?? ""}`
|
|
971
|
+
),
|
|
972
|
+
`${r.total ?? 0} open, ${r.overdue ?? 0} overdue`,
|
|
973
|
+
].join("\n")
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
case "guard": {
|
|
977
|
+
if (!id) throw new CliError("Usage: admin evals guard <case-key> [--sha <commit>] [--fix-pr #123]");
|
|
978
|
+
const body = compact({ action: "guard", sha: flagStr(flags.sha), fix_pr: flagStr(flags["fix-pr"]) });
|
|
979
|
+
return out(await staffRequest(ctx, "PATCH", `${EVALS}/cases/${encodeURIComponent(id)}`, body), (r) =>
|
|
980
|
+
r.ok ? `${r.case.key} is now guarding (first passed ${r.case.firstPassedSha})` : `refused: ${r.reason}`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
case "retire": {
|
|
984
|
+
if (!id) throw new CliError('Usage: admin evals retire <case-key> --reason "…"');
|
|
985
|
+
const reason = flagStr(flags.reason);
|
|
986
|
+
if (!reason) throw new CliError("Retiring a case needs --reason.");
|
|
987
|
+
return out(
|
|
988
|
+
await staffRequest(ctx, "PATCH", `${EVALS}/cases/${encodeURIComponent(id)}`, {
|
|
989
|
+
action: "retire",
|
|
990
|
+
reason,
|
|
991
|
+
}),
|
|
992
|
+
(r) => (r.ok ? `${r.case.key} retired` : `refused: ${r.reason}`)
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
default:
|
|
996
|
+
throw new CliError(
|
|
997
|
+
`Unknown admin evals subcommand: ${sub ?? "(none)"}. Try: scoreboard, cases, runs, run, triage, guard, retire`
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
882
1002
|
export async function cmdAdmin(ctx) {
|
|
883
1003
|
const sub = ctx.positionals[0];
|
|
884
1004
|
if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
@@ -887,7 +1007,8 @@ export async function cmdAdmin(ctx) {
|
|
|
887
1007
|
if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
888
1008
|
if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
889
1009
|
if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
1010
|
+
if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
890
1011
|
throw new CliError(
|
|
891
|
-
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin tuning-context (see --help)'
|
|
1012
|
+
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning-context (see --help)'
|
|
892
1013
|
);
|
|
893
1014
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// `evals` — the content-eval surface on an org key: how the pipeline is doing on YOUR
|
|
2
|
+
// posts (the scoreboard), the regression cases captured from them, capturing a new one from
|
|
3
|
+
// a bad post, and running the record-only suite.
|
|
4
|
+
//
|
|
5
|
+
// evals scoreboard [--collection <id>] [--days N]
|
|
6
|
+
// evals cases [--status open|guarding|…] [--stage research|brief|…]
|
|
7
|
+
// evals capture (--article <id> | --research-run <id> | --kernel-job <id>) --title "…"
|
|
8
|
+
// --description "…" (--measure <key> | --assertion "…") [--stage s] [--replay <stage>]
|
|
9
|
+
// [--auto-pen <id>] [--published-version N] [--incident …] [--link …]
|
|
10
|
+
// evals run [--keys a,b] [--baseline latest|<run-id>] exits 1 when a guarding case regressed
|
|
11
|
+
// evals run-status <run-id>
|
|
12
|
+
//
|
|
13
|
+
// Five commands, five tools; every input key is declared in ARGS so parity-check compares
|
|
14
|
+
// them against the tools' published schemas (see shared.mjs `argsFromFlags`).
|
|
15
|
+
|
|
16
|
+
import { CliError } from "../client.mjs";
|
|
17
|
+
import { flagStr, readBodyInput, printResult, ok, argsFromFlags } from "./shared.mjs";
|
|
18
|
+
|
|
19
|
+
const STAGES = ["research", "brief", "links", "mentions", "accuracy", "pipeline"];
|
|
20
|
+
const STATUSES = ["proposed", "open", "guarding", "retired", "rejected"];
|
|
21
|
+
|
|
22
|
+
/** `--keys a,b,c` (or repeated `--keys`) → ["a", "b", "c"]; undefined when unset. */
|
|
23
|
+
function keysFrom(flags) {
|
|
24
|
+
const raw = flags.keys;
|
|
25
|
+
if (raw === undefined) return undefined;
|
|
26
|
+
const items = (Array.isArray(raw) ? raw : [raw])
|
|
27
|
+
.flatMap((v) => String(v).split(","))
|
|
28
|
+
.map((k) => k.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
return items.length ? items : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const ARGS = {
|
|
34
|
+
get_eval_scoreboard: {
|
|
35
|
+
// One collection narrows the scope; otherwise the whole org (the tool's default).
|
|
36
|
+
scope: { value: (flags) => (flagStr(flags.collection) ? "collection" : undefined) },
|
|
37
|
+
collection_id: { flag: "collection" },
|
|
38
|
+
days: { flag: "days", type: "num" },
|
|
39
|
+
},
|
|
40
|
+
list_eval_cases: {
|
|
41
|
+
status: { flag: "status", enum: STATUSES },
|
|
42
|
+
stage: { flag: "stage", enum: STAGES },
|
|
43
|
+
},
|
|
44
|
+
capture_eval_case: {
|
|
45
|
+
article_id: { flag: "article" },
|
|
46
|
+
research_run_id: { flag: "research-run" },
|
|
47
|
+
kernel_job_id: { flag: "kernel-job" },
|
|
48
|
+
title: { flag: "title", required: true },
|
|
49
|
+
// --description, or the larger-blob trio --body/--file/stdin.
|
|
50
|
+
description: { value: (flags) => flagStr(flags.description) ?? readBodyInput(flags) },
|
|
51
|
+
expected: { object: { measure: { flag: "measure" }, assertion: { flag: "assertion" } } },
|
|
52
|
+
stage: { flag: "stage", enum: STAGES },
|
|
53
|
+
// Pin the incident's auto-pen run and live version when the post has been re-run since.
|
|
54
|
+
auto_pen_id: { flag: "auto-pen" },
|
|
55
|
+
published_version: { flag: "published-version", type: "num" },
|
|
56
|
+
// A record-only replay stage to re-run over the fixture before grading (evals catalog).
|
|
57
|
+
replay: "replay",
|
|
58
|
+
link: "link",
|
|
59
|
+
incident: "incident",
|
|
60
|
+
status: { flag: "status", enum: ["open", "proposed"] },
|
|
61
|
+
},
|
|
62
|
+
run_eval_suite: {
|
|
63
|
+
case_keys: { value: (flags) => keysFrom(flags) },
|
|
64
|
+
baseline: { flag: "baseline" },
|
|
65
|
+
},
|
|
66
|
+
get_eval_suite_run: {
|
|
67
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export async function cmdEvals(ctx) {
|
|
72
|
+
const sub = ctx.positionals[0];
|
|
73
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
74
|
+
switch (sub) {
|
|
75
|
+
case "scoreboard":
|
|
76
|
+
return evalsScoreboard(rest);
|
|
77
|
+
case "cases":
|
|
78
|
+
return evalsCases(rest);
|
|
79
|
+
case "capture":
|
|
80
|
+
return evalsCapture(rest);
|
|
81
|
+
case "run":
|
|
82
|
+
return evalsRun(rest);
|
|
83
|
+
case "run-status":
|
|
84
|
+
return evalsRunStatus(rest);
|
|
85
|
+
default:
|
|
86
|
+
throw new CliError(
|
|
87
|
+
`Unknown evals subcommand: ${sub ?? "(none)"}. Try: scoreboard, cases, capture, run, run-status`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pct(rate) {
|
|
93
|
+
return `${Math.round((rate ?? 0) * 100)}%`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatScoreboard(r) {
|
|
97
|
+
const lines = [`Scoreboard — ${r.scope}${r.id ? ` ${r.id}` : ""}, last ${r.days} days`];
|
|
98
|
+
for (const stage of r.stages ?? []) {
|
|
99
|
+
if (!stage.measures?.length) continue;
|
|
100
|
+
lines.push(`\n${stage.stage}`);
|
|
101
|
+
for (const m of stage.measures) {
|
|
102
|
+
lines.push(
|
|
103
|
+
` ${m.measure.padEnd(44)} ${String(m.failing).padStart(4)}/${String(m.posts).padEnd(4)} ${pct(m.rate).padStart(4)} ${m.severity}${m.kind === "judge" ? " (judge)" : ""}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (r.judgeReliance) {
|
|
108
|
+
lines.push(`\njudge reliance: ${r.judgeReliance.measures} measure(s), ${r.judgeReliance.cases} case(s)`);
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function evalsScoreboard(ctx) {
|
|
114
|
+
const { client, flags, io } = ctx;
|
|
115
|
+
const args = argsFromFlags(ARGS.get_eval_scoreboard, flags, ctx.positionals);
|
|
116
|
+
const result = await client.callTool("get_eval_scoreboard", args);
|
|
117
|
+
printResult(io, flags, result, formatScoreboard);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function caseLine(c) {
|
|
122
|
+
const latest = c.latestResult ? `${c.latestResult.verdict} (${c.latestResult.gate})` : "never run";
|
|
123
|
+
const expected = c.expected?.measure ? c.expected.measure : `assertion: ${c.expected?.assertion ?? ""}`;
|
|
124
|
+
return `${c.key.padEnd(48)} ${c.status.padEnd(9)} ${c.stage.padEnd(9)} ${latest.padEnd(26)} ${expected}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function evalsCases(ctx) {
|
|
128
|
+
const { client, flags, io } = ctx;
|
|
129
|
+
const args = argsFromFlags(ARGS.list_eval_cases, flags, ctx.positionals);
|
|
130
|
+
const result = await client.callTool("list_eval_cases", args);
|
|
131
|
+
printResult(io, flags, result, (r) =>
|
|
132
|
+
r.cases?.length ? r.cases.map(caseLine).join("\n") : "(no cases captured from this org's posts)"
|
|
133
|
+
);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function evalsCapture(ctx) {
|
|
138
|
+
const { client, flags, io } = ctx;
|
|
139
|
+
const args = argsFromFlags(ARGS.capture_eval_case, flags, ctx.positionals);
|
|
140
|
+
if (!args.article_id && !args.research_run_id && !args.kernel_job_id) {
|
|
141
|
+
throw new CliError("Name the post: --article <id>, --research-run <id> or --kernel-job <id>.");
|
|
142
|
+
}
|
|
143
|
+
if (!args.description) {
|
|
144
|
+
throw new CliError(
|
|
145
|
+
"Missing --description (or --body / --file <path> / stdin): what went wrong, in a sentence or two."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const expected = args.expected ?? {};
|
|
149
|
+
if ((expected.measure ? 1 : 0) + (expected.assertion ? 1 : 0) !== 1) {
|
|
150
|
+
throw new CliError('Say what must hold once fixed: exactly one of --measure <key> or --assertion "…".');
|
|
151
|
+
}
|
|
152
|
+
const result = await client.callTool("capture_eval_case", args);
|
|
153
|
+
const detects = result.detectsIssue;
|
|
154
|
+
ok(
|
|
155
|
+
ctx,
|
|
156
|
+
detects
|
|
157
|
+
? `Captured ${result.case?.key}: it FAILS on current code, so it detects the issue.`
|
|
158
|
+
: `Captured ${result.case?.key}, but it PASSES on current code — it does not detect what it claims. Tighten the assertion or pick the right measure.`
|
|
159
|
+
);
|
|
160
|
+
printResult(
|
|
161
|
+
io,
|
|
162
|
+
flags,
|
|
163
|
+
result,
|
|
164
|
+
(r) => `${r.case?.key}\n ${r.firstResult?.verdict}: ${r.firstResult?.summary ?? ""}`
|
|
165
|
+
);
|
|
166
|
+
return detects === false ? 1 : 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resultLine(r) {
|
|
170
|
+
const delta = r.delta && r.delta !== "no_baseline" ? ` ${r.delta}` : "";
|
|
171
|
+
return ` ${r.caseKey.padEnd(48)} ${r.verdict.padEnd(6)} ${r.gate.padEnd(16)}${delta} ${r.summary ?? ""}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatRun(run) {
|
|
175
|
+
const t = run.totals ?? {};
|
|
176
|
+
const head = `Run ${run.id ?? "(not persisted)"} — ${run.verdict.toUpperCase()} (${t.cases ?? 0} cases: ${t.pass ?? 0} pass, ${t.fail ?? 0} fail, ${t.skip ?? 0} skip, ${t.error ?? 0} error; ${t.regressed ?? 0} regressed, ${t.readyToGuard ?? 0} ready to guard)${run.gitSha ? ` @ ${run.gitSha.slice(0, 10)}` : ""}`;
|
|
177
|
+
const lines = [head, ...(run.results ?? []).map(resultLine)];
|
|
178
|
+
if (run.unresolved?.length) {
|
|
179
|
+
lines.push("", ...run.unresolved.map((u) => ` ! ${u.key}: ${u.reason}`));
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function evalsRun(ctx) {
|
|
185
|
+
const { client, flags, io } = ctx;
|
|
186
|
+
const args = argsFromFlags(ARGS.run_eval_suite, flags, ctx.positionals);
|
|
187
|
+
const result = await client.callTool("run_eval_suite", args);
|
|
188
|
+
printResult(io, flags, result, (r) => formatRun(r.run));
|
|
189
|
+
return result.run?.verdict === "fail" ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function evalsRunStatus(ctx) {
|
|
193
|
+
const { client, flags, io } = ctx;
|
|
194
|
+
const args = argsFromFlags(ARGS.get_eval_suite_run, flags, ctx.positionals);
|
|
195
|
+
const result = await client.callTool("get_eval_suite_run", args);
|
|
196
|
+
printResult(io, flags, result, (r) => formatRun(r.run));
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `sequences`, `prospects` and `mail` — the outbound email stack, as thin wrappers
|
|
1
|
+
// `sequences`, `prospects`, `mailers` and `mail` — the outbound email stack, as thin wrappers
|
|
2
2
|
// over the MCP tools, which call the same lib functions the app's /api/sequences
|
|
3
3
|
// and /api/prospects routes do. Every call is built from the ARGS map below, so
|
|
4
4
|
// `scripts/parity-check.ts` can hold each command to its tool's input schema.
|
|
@@ -58,6 +58,22 @@ const mailerIds = {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// A required positional that must be a whole number (the server schema wants a number, not a string).
|
|
62
|
+
function intPositional(index, label) {
|
|
63
|
+
return {
|
|
64
|
+
value: (_flags, positionals) => {
|
|
65
|
+
const raw = positionals[index];
|
|
66
|
+
if (raw === undefined) throw new CliError(`Missing required <${label}>`);
|
|
67
|
+
const n = Number(raw);
|
|
68
|
+
if (!Number.isInteger(n)) throw new CliError(`<${label}> must be a whole number (got ${raw})`);
|
|
69
|
+
return n;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SUPPRESSION_REASONS = ["unsubscribe", "bounce", "complaint", "manual"];
|
|
75
|
+
const MAILER_FOLDERS = ["inbox", "sent"];
|
|
76
|
+
|
|
61
77
|
// How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
|
|
62
78
|
export const ARGS = {
|
|
63
79
|
list_sequences: {},
|
|
@@ -112,6 +128,9 @@ export const ARGS = {
|
|
|
112
128
|
get_prospect_list: {
|
|
113
129
|
list_id: { positional: 0, label: "list-id", required: true },
|
|
114
130
|
},
|
|
131
|
+
delete_prospect_list: {
|
|
132
|
+
list_id: { positional: 0, label: "list-id", required: true },
|
|
133
|
+
},
|
|
115
134
|
list_prospects: {
|
|
116
135
|
list_id: "list",
|
|
117
136
|
status: { flag: "status", enum: PROSPECT_STATUSES },
|
|
@@ -166,6 +185,59 @@ export const ARGS = {
|
|
|
166
185
|
notes: "notes",
|
|
167
186
|
custom: { value: readCustom },
|
|
168
187
|
},
|
|
188
|
+
list_mailers: {
|
|
189
|
+
domain: "domain",
|
|
190
|
+
},
|
|
191
|
+
get_mailer_domain_status: {
|
|
192
|
+
owned_domain_id: { positional: 0, label: "owned-domain-id", required: true },
|
|
193
|
+
},
|
|
194
|
+
list_mailer_messages: {
|
|
195
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
196
|
+
folder: { flag: "folder", enum: ["all", ...MAILER_FOLDERS] },
|
|
197
|
+
limit: { flag: "limit", type: "num" },
|
|
198
|
+
},
|
|
199
|
+
get_mailer_message: {
|
|
200
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
201
|
+
uid: intPositional(1, "uid"),
|
|
202
|
+
folder: { flag: "folder", required: true, enum: MAILER_FOLDERS },
|
|
203
|
+
},
|
|
204
|
+
set_mailer_forwarding: {
|
|
205
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
206
|
+
forward_to: {
|
|
207
|
+
value: (flags) => {
|
|
208
|
+
if (flags.clear === true) return null;
|
|
209
|
+
const to = flagStr(flags.to);
|
|
210
|
+
if (to === undefined) throw new CliError("Pass --to <email> (or --clear to stop forwarding)");
|
|
211
|
+
return to;
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
start_mailer_warmup: {
|
|
216
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
217
|
+
},
|
|
218
|
+
pause_mailer_warmup: {
|
|
219
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
220
|
+
},
|
|
221
|
+
set_mailer_daily_send_limit: {
|
|
222
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
223
|
+
daily_send_limit: intPositional(1, "per-day"),
|
|
224
|
+
},
|
|
225
|
+
retire_mailer: {
|
|
226
|
+
mailer_id: { positional: 0, label: "mailer-id", required: true },
|
|
227
|
+
release_mailbox: { flag: "release-mailbox", type: "bool" },
|
|
228
|
+
},
|
|
229
|
+
add_email_suppression: {
|
|
230
|
+
address: { positional: 0, label: "address", required: true },
|
|
231
|
+
reason: { flag: "reason", enum: SUPPRESSION_REASONS },
|
|
232
|
+
note: "note",
|
|
233
|
+
},
|
|
234
|
+
list_email_suppressions: {
|
|
235
|
+
reason: { flag: "reason", enum: SUPPRESSION_REASONS },
|
|
236
|
+
limit: { flag: "limit", type: "num" },
|
|
237
|
+
},
|
|
238
|
+
remove_email_suppression: {
|
|
239
|
+
address: { positional: 0, label: "address", required: true },
|
|
240
|
+
},
|
|
169
241
|
get_mail_connections: {},
|
|
170
242
|
set_mail_postal_address: {
|
|
171
243
|
mail_postal_address: { positional: 0, label: "address", required: true },
|
|
@@ -180,6 +252,7 @@ const VALUE_FLAGS = {
|
|
|
180
252
|
update_review_item: ["body", "file"],
|
|
181
253
|
import_prospects_csv: ["csv", "csv-file"],
|
|
182
254
|
update_prospect: ["custom"],
|
|
255
|
+
set_mailer_forwarding: ["to", "clear"],
|
|
183
256
|
};
|
|
184
257
|
|
|
185
258
|
async function run(ctx, tool, message) {
|
|
@@ -280,7 +353,7 @@ async function sequencesLinks(ctx, sub) {
|
|
|
280
353
|
}
|
|
281
354
|
}
|
|
282
355
|
|
|
283
|
-
const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|import|source|enrich|update>`;
|
|
356
|
+
const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|delete-list|import|source|enrich|update>`;
|
|
284
357
|
|
|
285
358
|
export async function cmdProspects(ctx) {
|
|
286
359
|
const sub = ctx.positionals[0];
|
|
@@ -297,6 +370,8 @@ export async function cmdProspects(ctx) {
|
|
|
297
370
|
return run(rest, "get_prospect");
|
|
298
371
|
case "create-list":
|
|
299
372
|
return run(rest, "create_prospect_list", "List ready.");
|
|
373
|
+
case "delete-list":
|
|
374
|
+
return run(rest, "delete_prospect_list", "List deleted (its prospects stay).");
|
|
300
375
|
case "import":
|
|
301
376
|
return run(rest, "import_prospects_csv", "Imported.");
|
|
302
377
|
case "source":
|
|
@@ -314,6 +389,66 @@ export async function cmdProspects(ctx) {
|
|
|
314
389
|
}
|
|
315
390
|
}
|
|
316
391
|
|
|
392
|
+
const MAILERS_USAGE = `Usage: mailers <list|domain|messages|message|forward|warmup|limit|retire|suppressions>`;
|
|
393
|
+
|
|
394
|
+
export async function cmdMailers(ctx) {
|
|
395
|
+
const sub = ctx.positionals[0];
|
|
396
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
397
|
+
switch (sub) {
|
|
398
|
+
case "list":
|
|
399
|
+
case "ls":
|
|
400
|
+
return run(rest, "list_mailers");
|
|
401
|
+
case "domain":
|
|
402
|
+
return run(rest, "get_mailer_domain_status");
|
|
403
|
+
case "messages":
|
|
404
|
+
return run(rest, "list_mailer_messages");
|
|
405
|
+
case "message":
|
|
406
|
+
case "show":
|
|
407
|
+
return run(rest, "get_mailer_message");
|
|
408
|
+
case "forward":
|
|
409
|
+
return run(rest, "set_mailer_forwarding", "Forwarding updated.");
|
|
410
|
+
case "warmup": {
|
|
411
|
+
const action = rest.positionals[0];
|
|
412
|
+
const inner = { ...rest, positionals: rest.positionals.slice(1) };
|
|
413
|
+
if (action === "start") return run(inner, "start_mailer_warmup", "Warmup started.");
|
|
414
|
+
if (action === "pause") return run(inner, "pause_mailer_warmup", "Warmup paused.");
|
|
415
|
+
throw new CliError(`Unknown warmup action: ${action ?? "(none)"}. Try: start, pause`);
|
|
416
|
+
}
|
|
417
|
+
case "limit":
|
|
418
|
+
return run(rest, "set_mailer_daily_send_limit", "Daily send limit set.");
|
|
419
|
+
case "retire": {
|
|
420
|
+
if (rest.flags["release-mailbox"] && !rest.flags.yes) {
|
|
421
|
+
const id = flagStr(rest.positionals[0]) ?? "<mailer-id>";
|
|
422
|
+
ctx.io.error(
|
|
423
|
+
`--release-mailbox deletes the hosted mailbox at the provider and cannot be undone. Re-run with --yes to confirm: ${ctx.bin} mailers retire ${id} --release-mailbox --yes`
|
|
424
|
+
);
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
return run(rest, "retire_mailer", "Mailer retired: nothing further sends from it.");
|
|
428
|
+
}
|
|
429
|
+
case "suppressions":
|
|
430
|
+
return mailersSuppressions(rest, rest.positionals[0]);
|
|
431
|
+
default:
|
|
432
|
+
throw new CliError(`Unknown mailers subcommand: ${sub ?? "(none)"}. ${MAILERS_USAGE}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function mailersSuppressions(ctx, sub) {
|
|
437
|
+
const inner = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
438
|
+
switch (sub) {
|
|
439
|
+
case "list":
|
|
440
|
+
case "ls":
|
|
441
|
+
case undefined:
|
|
442
|
+
return run(sub === undefined ? ctx : inner, "list_email_suppressions");
|
|
443
|
+
case "add":
|
|
444
|
+
return run(inner, "add_email_suppression", "Added to the do-not-send list.");
|
|
445
|
+
case "remove":
|
|
446
|
+
return run(inner, "remove_email_suppression", "Removed from the do-not-send list.");
|
|
447
|
+
default:
|
|
448
|
+
throw new CliError(`Unknown suppressions subcommand: ${sub}. Try: list, add, remove`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
317
452
|
export async function cmdMail(ctx) {
|
|
318
453
|
const sub = ctx.positionals[0];
|
|
319
454
|
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
package/lib/commands.mjs
CHANGED