@letterstory/cli 0.36.1 → 0.38.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 CHANGED
@@ -324,6 +324,29 @@ letterstory admin ask "which clients published nothing in the last 30 days?" --s
324
324
  This is the headless half of `/superadmin/ask` — same function, same schema — so a
325
325
  long-running agent can watch the fleet on a schedule without a browser.
326
326
 
327
+ ### Staff: kernel experiments
328
+
329
+ ```bash
330
+ # Needs a STAFF key with the tuning:experiment capability. A key always acts as an
331
+ # agent: it can propose, plan, note, spend and conclude, never approve a plan.
332
+ export LETTERSTORY_STAFF_API_KEY=<your staff key with tuning:experiment>
333
+
334
+ letterstory admin tuning-context --q "colons" # what the agent knows (JSON)
335
+ letterstory admin experiments list --q colons # what we've tried
336
+ letterstory admin experiments new "We use too many colons" --as "claude-code"
337
+ letterstory admin experiments update <id> --plan-file plan.json --budget 120
338
+ # ...a person approves the plan in Kernel Tuning -> Experiments...
339
+ letterstory admin experiments spend <id> 54
340
+ letterstory admin tuning-import lab/results/colons/publish/<brief>.json --experiment <id>
341
+ letterstory admin experiments conclude <id> --decision drop \
342
+ --conclusion "Colons fell 60%; P(machine) did not move; quality held." \
343
+ --evidence lab:"kernels lab/results/colons/report.md"
344
+ ```
345
+
346
+ The ledger behind Kernel Tuning's Experiments tab
347
+ (docs/architecture/kernel-experiment-agent.md): every question asked of the kernels,
348
+ how it was tested, and what we learned and why.
349
+
327
350
  ### Propose, then onboard a phantom
328
351
 
329
352
  The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
package/lib/cli.mjs CHANGED
@@ -41,13 +41,16 @@ import {
41
41
  cmdShredder,
42
42
  cmdLede,
43
43
  cmdLandings,
44
+ cmdSequences,
45
+ cmdProspects,
46
+ cmdMail,
44
47
  cmdAdmin,
45
48
  cmdFeedback,
46
49
  cmdOfferings,
47
50
  } from "./commands.mjs";
48
51
 
49
52
  // Keep in sync with cli/package.json.
50
- export const VERSION = "0.36.1";
53
+ export const VERSION = "0.38.0";
51
54
 
52
55
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
53
56
  // can't accidentally swallow the id as --json's value.
@@ -101,8 +104,18 @@ const BOOLEAN_FLAGS = new Set([
101
104
  "clear-topics",
102
105
  "clear-stances",
103
106
  "clear-avoid",
107
+ // `lede run --diverse`, `--high-temp`, their `--no-*` negations and `--no-enrich`
108
+ // are value-less and can sit next to the endpoint id or another value; without
109
+ // these the parser eats the following token.
110
+ "diverse",
111
+ "no-diverse",
112
+ "high-temp",
113
+ "no-high-temp",
114
+ "no-enrich",
104
115
  // `offerings set-collection <id> --none` (the org itself is the subject again).
105
116
  "none",
117
+ // `prospects import|source --one-per-company`.
118
+ "one-per-company",
106
119
  ]);
107
120
 
108
121
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -506,6 +519,24 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
506
519
  read-only SQL across every org; --sql prints
507
520
  the queries behind the answer. Needs the
508
521
  fleet:query capability on the staff key.
522
+ admin experiments list [--q colons] [--status proposed] | show <id>
523
+ | new "<question>" [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
524
+ | update <id> [--plan-file …] [--result-file …] [--evidence kind:ref,…] [--budget N]
525
+ | note|started <id> "<text>" | spend <id> <runs>
526
+ | conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "…" [--evidence …]
527
+ | abandon <id> --reason "…"
528
+ The kernel experiment ledger: what we tried,
529
+ how, what it found and why. Approving a plan
530
+ stays with a signed-in person. Needs the
531
+ tuning:experiment capability.
532
+ admin tuning-import <brief-results.json> [--experiment <id>]
533
+ One brief's kernels lab results as a console
534
+ session (a trial per arm), linked to its
535
+ ledger entry. Written by \`npm run lab --
536
+ publish\` in the kernels repo.
537
+ admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
538
+ the pipeline as deployed, levers, measures,
539
+ goals, related history.
509
540
  admin create-org --domain <bare-domain> [--name "Acme"] [--test] [--no-key]
510
541
  [--key-name N] [--capabilities a,b] [--onboard] [--watch] [+ the onboard domain flags]
511
542
  The key exchange: create the client org from
@@ -624,6 +655,8 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
624
655
  [--years <n>] [--linkedin <url>] [--notes <text>]
625
656
  [--candidates <1..10>] [--tone warm|direct|playful|formal] [--no-enrich]
626
657
  [--deadline-ms <2000..120000>]
658
+ [--default-lede <line>] [--disable-lede <id>]... [--diverse [--recent-lede <id>]...]
659
+ [--high-temp|--no-high-temp]
627
660
  Synchronous: prints the candidate lines with the
628
661
  top choice flagged. Needs at least one of
629
662
  --first-name, --name, --email, --company or
@@ -632,10 +665,39 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
632
665
  lookup when latency matters more than fit.
633
666
  --deadline-ms leaves out evidence that cannot
634
667
  arrive in time (fewer lines may qualify).
668
+ --disable-lede keeps a library line out (by id,
669
+ repeatable). --diverse gently rotates away from
670
+ recently used lines; pass the ids your sequence
671
+ already sent with --recent-lede (repeatable), or
672
+ omit them to count the endpoint's own recent runs.
673
+ --high-temp writes one strictly checked opener
674
+ (marked generated) when no library line qualifies;
675
+ the --no-* spellings override endpoint defaults.
635
676
  lede runs [--endpoint <id>] [--limit <n>] Recent lede calls (telemetry), newest first
636
677
  lede history [<domain>] [--days <n>] [--limit <n>] Openers already sent to an account
637
678
  lede evals [--sweep] Lede quality evals still above zero
638
679
  lede balance Ledes left on this org
680
+ lede library list The org's opener library: pre-built + custom lines
681
+ lede library create --text <line> [--alts <v>]... [--peer-alts <v>]... [--requires <trigger>]...
682
+ [--threshold <0..100>] [--tone warm|direct|playful|formal] [--tag <t>]...
683
+ Add a custom line. A {{placeholder}} must appear in
684
+ --requires; proprietary-engine triggers stay
685
+ reserved for the pre-built lines. --threshold
686
+ nudges the trigger's gate strictness around the
687
+ engine default: 50 = default, higher = pickier,
688
+ lower = looser.
689
+ lede library update <lede-id> [--text <line>] [--alts <v>]... [--peer-alts <v>]...
690
+ [--requires <trigger>]... [--threshold <0..100>] [--tone <t>] [--tag <t>]...
691
+ Edit a line; only the flags you pass change
692
+ lede library enable <lede-id> Make a line a run candidate again
693
+ lede library disable <lede-id> Keep a line out of runs (reversible; the way to
694
+ retire a pre-built line)
695
+ lede library delete <lede-id> --yes Delete a line (a deleted pre-built re-seeds at its
696
+ seed wording on the next list)
697
+ lede library rewrite --voice <description> [--ids <lede-id>]...
698
+ Propose brand-voice rewrites of the wording only.
699
+ Applies nothing: accept a proposal with
700
+ lede library update --text/--alts
639
701
 
640
702
  Landings (a link + context; auto-mentioned in articles by collections you enable):
641
703
  landings list Your landing pages + mention counts
@@ -645,6 +707,41 @@ Landings (a link + context; auto-mentioned in articles by collections you enable
645
707
  landings update <landing-id> [--link <url>] [--context <text>|--file <path>]
646
708
  landings delete <landing-id> --yes
647
709
 
710
+ Sequences (multi-step cold email sent from your mailers; approving or enrolling SENDS):
711
+ sequences list Your sequences + per-step funnel
712
+ sequences get <sequence-id> Flow, enrollments, who replied at each step
713
+ sequences create --name <n> (--flow '<json>'|--flow-file <path>) [--mailer <mailer-id>]...
714
+ New draft sequence (nothing sends yet)
715
+ sequences update <sequence-id> [--name <n>] [--flow '<json>'|--flow-file <path>]
716
+ [--status draft|active|paused|archived] [--mailer <mailer-id>]...
717
+ sequences pause|resume <sequence-id> Stop / restart sending
718
+ sequences enroll <sequence-id> (--list <list-id> | --email <addr>... | --recipients '<json>'|--recipients-file <path>)
719
+ sequences stop <sequence-id> <enrollment-id> Stop one recipient
720
+ sequences review list Emails waiting for a decision
721
+ sequences review edit <item-id> [--subject <s>] [--body <text>|--file <path>]
722
+ sequences review approve|skip|delete <item-id> Decide one (approve sends now)
723
+ sequences links list Live Pocket Dispatch links (no tokens)
724
+ sequences links create --days <1..3> Mint a no-login review link (shown once)
725
+ sequences links revoke <link-id> --yes
726
+
727
+ Prospects (who you write to):
728
+ prospects lists Your lists + counts
729
+ prospects list <list-id> One list
730
+ prospects ls [--list <list-id>] [--status <s>] [--q <search>] [--limit <n>]
731
+ prospects get <prospect-id>
732
+ prospects create-list --name <n>
733
+ prospects import --csv-file <path|-> [--list <list-id>|--list-name <n>] [--one-per-company]
734
+ prospects source --description <who you want> [--list <list-id>|--list-name <n>] [--limit <1..100>]
735
+ [--one-per-company] Fill a list from paid data providers (background)
736
+ prospects enrich [--list <list-id>] [--status <s>] [--q <search>]
737
+ Funding + competitors for un-enriched rows
738
+ prospects update <prospect-id> [--status new|contacted|replied|unsubscribed|archived] [--name <n>]
739
+ [--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
740
+ [--city] [--country] [--years] [--notes] [--custom key=value]...
741
+
742
+ Mail:
743
+ mail connections Connected mailboxes, Google connect + Vercel key status
744
+
648
745
  Insights:
649
746
  insights site [--period 14d|30d|90d] [--collection <uuid>]
650
747
  insights post <article-id> [--period 14d|30d|90d]
@@ -722,6 +819,9 @@ const CLIENT_COMMANDS = {
722
819
  lede: cmdLede,
723
820
  ledes: cmdLede,
724
821
  landings: cmdLandings,
822
+ sequences: cmdSequences,
823
+ prospects: cmdProspects,
824
+ mail: cmdMail,
725
825
  feedback: cmdFeedback,
726
826
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
727
827
  admin: cmdAdmin,
@@ -4,6 +4,9 @@
4
4
  // routes, because they exist precisely to act BEFORE an org exists.
5
5
  //
6
6
  // admin ask "which kernels are hooked up to the most clients?" [--sql] [--json]
7
+ // admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
8
+ // admin tuning-context [--q "<question>"] (tuning:experiment)
9
+ // admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
7
10
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
8
11
  // [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
9
12
  // [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
@@ -26,6 +29,7 @@
26
29
  // The staff key comes from --staff-key or LETTERSTORY_STAFF_API_KEY; the base URL from the
27
30
  // usual --url / LETTERSTORY_API_URL / config resolution.
28
31
 
32
+ import { readFileSync } from "node:fs";
29
33
  import { CliError, LetterstoryClient } from "../client.mjs";
30
34
  import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok } from "./shared.mjs";
31
35
  import { watchDomainJob } from "./onboard.mjs";
@@ -46,12 +50,17 @@ function staffKeyFrom(flags) {
46
50
 
47
51
  /** POST a superadmin route with the staff key; surfaces the server's error text on non-2xx. */
48
52
  async function staffPost(ctx, path, body) {
53
+ return staffRequest(ctx, "POST", path, body);
54
+ }
55
+
56
+ /** Any method against a superadmin route with the staff key. */
57
+ async function staffRequest(ctx, method, path, body) {
49
58
  const { client, flags } = ctx;
50
59
  const url = `${client.url}${path}`;
51
60
  const res = await client.fetch(url, {
52
- method: "POST",
61
+ method,
53
62
  headers: { "content-type": "application/json", "x-staff-api-key": staffKeyFrom(flags) },
54
- body: JSON.stringify(body),
63
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
55
64
  });
56
65
  let payload = null;
57
66
  try {
@@ -220,11 +229,195 @@ async function cmdAdminAsk(ctx) {
220
229
  }
221
230
  }
222
231
 
232
+ /* ------------------------------------------------------------ kernel experiments */
233
+
234
+ const EXPERIMENTS = "/api/superadmin/tuning/experiments";
235
+
236
+ /** `--as "<agent name>"` records the actor as that agent; a staff key always acts as an agent. */
237
+ function actorFrom(flags) {
238
+ const as = flagStr(flags.as);
239
+ return as ? { kind: "agent", label: as } : undefined;
240
+ }
241
+
242
+ /** A JSON object from `--<name>-file <path>` (or `-` for stdin is not supported: pass a file). */
243
+ function jsonFile(flags, name) {
244
+ const path = flagStr(flags[`${name}-file`]);
245
+ if (!path) return undefined;
246
+ try {
247
+ return JSON.parse(readFileSync(path, "utf8"));
248
+ } catch (err) {
249
+ throw new CliError(`--${name}-file ${path}: ${err.message}`);
250
+ }
251
+ }
252
+
253
+ /** `--evidence kind:ref[,kind:ref…]`, e.g. `lab:kernels lab/results/colons/report.md,session:<uuid>`. */
254
+ function evidenceFrom(flags) {
255
+ const items = flagList(flags.evidence);
256
+ if (!items?.length) return undefined;
257
+ return items.map((item) => {
258
+ const at = item.indexOf(":");
259
+ if (at < 1) throw new CliError(`--evidence items are kind:ref, got "${item}"`);
260
+ return { kind: item.slice(0, at), ref: item.slice(at + 1) };
261
+ });
262
+ }
263
+
264
+ function experimentLine(e) {
265
+ const decision = e.decision ? ` → ${e.decision}` : "";
266
+ return `${e.id} [${e.status}${decision}] ${e.question}${e.gist ? `\n ${e.gist}` : ""}`;
267
+ }
268
+
269
+ /**
270
+ * `admin experiments …` — the experiment ledger (docs/architecture/kernel-experiment-agent.md),
271
+ * the headless half of the tuning console's Experiments tab. Needs `tuning:experiment` on the key.
272
+ */
273
+ async function cmdAdminExperiments(ctx) {
274
+ const [sub, id, ...rest] = ctx.positionals;
275
+ const { flags, io } = ctx;
276
+ const json = flagBool(flags.json);
277
+ const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
278
+ const one = (r) => {
279
+ const e = r.experiment;
280
+ const lines = [experimentLine(e)];
281
+ if (e.restated) lines.push(` hypothesis: ${e.restated}`);
282
+ if (e.plan) lines.push(` plan: ${JSON.stringify(e.plan)}`);
283
+ lines.push(` runs: ${e.runsSpent} of ${e.budgetRuns}`);
284
+ for (const ev of e.events ?? [])
285
+ lines.push(` ${ev.at} ${ev.kind} ${ev.actorLabel ?? ev.actorKind}${ev.text ? `: ${ev.text}` : ""}`);
286
+ return lines.join("\n");
287
+ };
288
+ switch (sub) {
289
+ case "list": {
290
+ const params = new URLSearchParams(
291
+ compact({ q: flagStr(flags.q), status: flagStr(flags.status), limit: flagStr(flags.limit) })
292
+ );
293
+ const result = await staffRequest(ctx, "GET", `${EXPERIMENTS}?${params}`);
294
+ return out(result, (r) =>
295
+ r.experiments.length ? r.experiments.map(experimentLine).join("\n") : "(no experiments)"
296
+ );
297
+ }
298
+ case "show":
299
+ if (!id) throw new CliError("Usage: admin experiments show <id>");
300
+ return out(await staffRequest(ctx, "GET", `${EXPERIMENTS}/${id}`), one);
301
+ case "new": {
302
+ const question = [id, ...rest].filter(Boolean).join(" ").trim();
303
+ if (question.length < 3)
304
+ throw new CliError('Usage: admin experiments new "<question>" [--plan-file plan.json] [--tags a,b]');
305
+ const body = compact({
306
+ question,
307
+ restated: flagStr(flags.hypothesis),
308
+ plan: jsonFile(flags, "plan"),
309
+ method: flagStr(flags.method),
310
+ tags: flagList(flags.tags),
311
+ builds_on: flagList(flags["builds-on"]),
312
+ evidence: evidenceFrom(flags),
313
+ actor: actorFrom(flags),
314
+ });
315
+ return out(await staffRequest(ctx, "POST", EXPERIMENTS, body), one);
316
+ }
317
+ case "update": {
318
+ if (!id)
319
+ throw new CliError(
320
+ "Usage: admin experiments update <id> [--hypothesis …] [--plan-file …] [--result-file …] [--evidence kind:ref,…]"
321
+ );
322
+ const body = compact({
323
+ restated: flagStr(flags.hypothesis),
324
+ plan: jsonFile(flags, "plan"),
325
+ measures: jsonFile(flags, "measures"),
326
+ result: jsonFile(flags, "result"),
327
+ method: flagStr(flags.method),
328
+ tags: flagList(flags.tags),
329
+ budget_runs: flagNum(flags.budget),
330
+ engine_build: flagStr(flags.build),
331
+ add_evidence: evidenceFrom(flags),
332
+ actor: actorFrom(flags),
333
+ });
334
+ return out(await staffRequest(ctx, "PATCH", `${EXPERIMENTS}/${id}`, body), one);
335
+ }
336
+ case "note":
337
+ case "started": {
338
+ const text = rest.join(" ").trim();
339
+ if (!id || (sub === "note" && !text)) throw new CliError(`Usage: admin experiments ${sub} <id> "<text>"`);
340
+ const body = compact({ kind: sub, text: text || undefined, actor: actorFrom(flags) });
341
+ return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
342
+ }
343
+ case "spend": {
344
+ const runs = Number(rest[0]);
345
+ if (!id || !Number.isInteger(runs) || runs < 1)
346
+ throw new CliError("Usage: admin experiments spend <id> <runs> [--text …]");
347
+ const body = compact({ kind: "spend", runs, text: flagStr(flags.text), actor: actorFrom(flags) });
348
+ return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
349
+ }
350
+ case "conclude": {
351
+ const decision = flagStr(flags.decision);
352
+ const conclusion = flagStr(flags.conclusion);
353
+ if (!id || !decision || !conclusion)
354
+ throw new CliError(
355
+ 'Usage: admin experiments conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "<what we learned and why>" [--evidence kind:ref,…] [--result-file r.json]'
356
+ );
357
+ const body = compact({
358
+ action: "conclude",
359
+ decision,
360
+ conclusion,
361
+ result: jsonFile(flags, "result"),
362
+ add_evidence: evidenceFrom(flags),
363
+ actor: actorFrom(flags),
364
+ });
365
+ return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
366
+ }
367
+ case "abandon": {
368
+ const reason = flagStr(flags.reason);
369
+ if (!id || !reason) throw new CliError('Usage: admin experiments abandon <id> --reason "<why>"');
370
+ const body = compact({ action: "abandon", reason, actor: actorFrom(flags) });
371
+ return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
372
+ }
373
+ default:
374
+ throw new CliError(
375
+ "Usage: admin experiments list|show|new|update|note|started|spend|conclude|abandon (see --help)"
376
+ );
377
+ }
378
+ }
379
+
380
+ /**
381
+ * `admin tuning-import <file.json>` — one brief's kernels lab results into a console session
382
+ * (the lab's `publish` writes these files; the body is POST /api/superadmin/tuning/lab-import's).
383
+ */
384
+ async function cmdAdminTuningImport(ctx) {
385
+ const path = ctx.positionals[0];
386
+ if (!path) throw new CliError("Usage: admin tuning-import <brief-results.json> [--experiment <id>]");
387
+ let body;
388
+ try {
389
+ body = JSON.parse(readFileSync(path, "utf8"));
390
+ } catch (err) {
391
+ throw new CliError(`${path}: ${err.message}`);
392
+ }
393
+ const experiment = flagStr(ctx.flags.experiment);
394
+ if (experiment) body.experiment_id = experiment;
395
+ const result = await staffRequest(ctx, "POST", "/api/superadmin/tuning/lab-import", body);
396
+ if (flagBool(ctx.flags.json)) return printResult(ctx.io, ctx.flags, result);
397
+ ctx.io.log(
398
+ `${result.trials} trials, ${result.runs} runs → ${ctx.client.url}/superadmin/tuning/${result.sessionId}`
399
+ );
400
+ }
401
+
402
+ /** `admin tuning-context [--q "<question>"]` — the experiment agent's context pack, as JSON. */
403
+ async function cmdAdminTuningContext(ctx) {
404
+ const q = flagStr(ctx.flags.q) || ctx.positionals.join(" ").trim() || undefined;
405
+ const params = q ? `?${new URLSearchParams({ q })}` : "";
406
+ printResult(
407
+ ctx.io,
408
+ { ...ctx.flags, json: true },
409
+ await staffRequest(ctx, "GET", `/api/superadmin/tuning/context${params}`)
410
+ );
411
+ }
412
+
223
413
  export async function cmdAdmin(ctx) {
224
414
  const sub = ctx.positionals[0];
225
415
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
226
416
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
417
+ if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
418
+ if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
419
+ if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
227
420
  throw new CliError(
228
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" (see --help)'
421
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin tuning-context (see --help)'
229
422
  );
230
423
  }
@@ -59,6 +59,15 @@ export const ARGS = {
59
59
  enrich: { value: (flags) => (flags["no-enrich"] === true ? false : undefined) },
60
60
  // A latency budget: optional evidence that cannot arrive in time is left out.
61
61
  deadline_ms: { flag: "deadline-ms", type: "num" },
62
+ // Advanced: keep specific library lines out of this run (repeatable, by line id).
63
+ disabled_ledes: { flag: "disable-lede", type: "list" },
64
+ // Gently rotate away from openers this endpoint (or --recent-lede) already used.
65
+ // The negate spelling exists so a call can override an endpoint default to OFF.
66
+ optimize_diversity: { flag: "diverse", type: "bool", negate: "no-diverse" },
67
+ // The line ids this sequence already used (repeatable); counted by --diverse.
68
+ recent_ledes: { flag: "recent-lede", type: "list" },
69
+ // When no library line qualifies, WRITE one (strictly checked, marked generated).
70
+ high_temperature: { flag: "high-temp", type: "bool", negate: "no-high-temp" },
62
71
  },
63
72
  list_lede_runs: {
64
73
  endpoint_id: "endpoint",
@@ -108,6 +117,42 @@ export const ARGS = {
108
117
  endpoint_id: { positional: 1, label: "endpoint-id", required: true },
109
118
  config: { value: (flags) => readJsonFileFlag(flags, "file") },
110
119
  },
120
+ // The editable library (`lede library ...`): the org's own opening lines, pre-built
121
+ // and custom. Reads seed the pre-built set idempotently; writes are validated server-
122
+ // side (known triggers, placeholder coverage, the custom-line engine ban).
123
+ list_ledes: {},
124
+ create_lede: {
125
+ text: { flag: "text", required: true },
126
+ alts: { flag: "alts", type: "list" },
127
+ peer_alts: { flag: "peer-alts", type: "list" },
128
+ requires: { flag: "requires", type: "list" },
129
+ // 0-100 strictness around the engine default (50): higher = pickier, lower = looser.
130
+ threshold: { flag: "threshold", type: "num" },
131
+ tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
132
+ tags: { flag: "tag", type: "list" },
133
+ },
134
+ update_lede: {
135
+ id: { positional: 0, label: "lede-id", required: true },
136
+ text: "text",
137
+ alts: { flag: "alts", type: "list" },
138
+ peer_alts: { flag: "peer-alts", type: "list" },
139
+ requires: { flag: "requires", type: "list" },
140
+ threshold: { flag: "threshold", type: "num" },
141
+ tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
142
+ tags: { flag: "tag", type: "list" },
143
+ },
144
+ set_lede_enabled: {
145
+ id: { positional: 0, label: "lede-id", required: true },
146
+ // `lede library enable|disable <id>` decides this; the subcommand passes it in.
147
+ enabled: { value: (_flags, _positionals, extra) => extra.enabled },
148
+ },
149
+ delete_lede: {
150
+ id: { positional: 0, label: "lede-id", required: true },
151
+ },
152
+ rewrite_ledes_voice: {
153
+ voice: { flag: "voice", required: true },
154
+ ids: { flag: "ids", type: "list" },
155
+ },
111
156
  };
112
157
 
113
158
  export async function cmdLede(ctx) {
@@ -125,6 +170,9 @@ export async function cmdLede(ctx) {
125
170
  return ledeDelete(rest);
126
171
  case "endpoint":
127
172
  return ledeEndpoint(rest);
173
+ case "library":
174
+ case "lib":
175
+ return ledeLibrary(rest);
128
176
  case "run":
129
177
  return ledeRun(rest);
130
178
  case "runs":
@@ -145,7 +193,103 @@ export async function cmdLede(ctx) {
145
193
  return ledeFunnel(rest);
146
194
  default:
147
195
  throw new CliError(
148
- `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, runs, history, evals, balance, outcome, funnel`
196
+ `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, library, run, runs, history, evals, balance, outcome, funnel`
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * `lede library list|create|update|enable|disable|delete|rewrite` — the org's own opener
203
+ * library (pre-built lines seeded on first read, plus lines the org authors). Runs pick
204
+ * only from enabled lines; `rewrite` only PROPOSES new wordings, and an accepted proposal
205
+ * is applied with `lede library update`.
206
+ */
207
+ async function ledeLibrary(ctx) {
208
+ const { client, positionals, flags, io } = ctx;
209
+ const action = positionals[0];
210
+ const rest = { ...ctx, positionals: positionals.slice(1) };
211
+ switch (action) {
212
+ case "list":
213
+ case "ls": {
214
+ const result = await client.callTool("list_ledes", argsFromFlags(ARGS.list_ledes, flags));
215
+ // One line per row: state, id, provenance, then the wording. `--json` for the rest
216
+ // (alts, requires, threshold, tags) when editing.
217
+ printResult(io, flags, result, (r) =>
218
+ (r?.ledes ?? [])
219
+ .map(
220
+ (l) =>
221
+ `${l.enabled ? "on " : "off"} ${l.id} [${l.source}${l.engine ? `/${l.engine}` : ""}] ${l.text}`
222
+ )
223
+ .join("\n")
224
+ );
225
+ return 0;
226
+ }
227
+ case "create":
228
+ case "new":
229
+ case "add": {
230
+ const args = argsFromFlags(ARGS.create_lede, flags, rest.positionals);
231
+ const result = await client.callTool("create_lede", args);
232
+ ok(ctx, "Added the line to the library.");
233
+ printResult(io, flags, result);
234
+ return 0;
235
+ }
236
+ case "update":
237
+ case "edit": {
238
+ const args = argsFromFlags(ARGS.update_lede, flags, rest.positionals);
239
+ const result = await client.callTool("update_lede", args);
240
+ ok(ctx, `Updated line ${args.id}.`);
241
+ printResult(io, flags, result);
242
+ return 0;
243
+ }
244
+ case "enable":
245
+ case "disable": {
246
+ const enabled = action === "enable";
247
+ const args = argsFromFlags(ARGS.set_lede_enabled, flags, rest.positionals, { enabled });
248
+ const result = await client.callTool("set_lede_enabled", args);
249
+ ok(ctx, `${enabled ? "Enabled" : "Disabled"} line ${args.id}.`);
250
+ printResult(io, flags, result);
251
+ return 0;
252
+ }
253
+ case "delete":
254
+ case "rm": {
255
+ const id = requirePositional(rest.positionals, 0, "lede-id");
256
+ if (!flags.yes) {
257
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} lede library delete ${id} --yes`);
258
+ return 1;
259
+ }
260
+ const result = await client.callTool(
261
+ "delete_lede",
262
+ argsFromFlags(ARGS.delete_lede, flags, rest.positionals)
263
+ );
264
+ ok(
265
+ ctx,
266
+ "Line deleted. A pre-built line re-seeds at its seed wording on the next list; disable instead to keep one out."
267
+ );
268
+ printResult(io, flags, result);
269
+ return 0;
270
+ }
271
+ case "rewrite": {
272
+ const args = argsFromFlags(ARGS.rewrite_ledes_voice, flags, rest.positionals);
273
+ const result = await client.callTool("rewrite_ledes_voice", args);
274
+ // Nothing is applied: print the proposals for a human to vouch for, with the
275
+ // command that applies an accepted one.
276
+ printResult(io, flags, result, (r) => {
277
+ const proposals = (r?.proposals ?? []).map((p) => `${p.id}\n ${p.text}`);
278
+ const rejected = (r?.rejected ?? []).map((x) => `${x.id}: ${(x.reasons ?? []).join("; ")}`);
279
+ const parts = [];
280
+ parts.push(
281
+ proposals.length
282
+ ? `Proposed rewrites (nothing applied; accept one with: ${ctx.bin} lede library update <lede-id> --text "..."):\n${proposals.join("\n")}`
283
+ : "No rewrite cleared the checks; the originals stand."
284
+ );
285
+ if (rejected.length) parts.push(`Rejected (original stands):\n${rejected.join("\n")}`);
286
+ return parts.join("\n\n");
287
+ });
288
+ return 0;
289
+ }
290
+ default:
291
+ throw new CliError(
292
+ `Unknown lede library action: ${action ?? "(none)"}. Try: list, create, update, enable, disable, delete, rewrite`
149
293
  );
150
294
  }
151
295
  }
@@ -257,7 +401,8 @@ async function ledeRun(ctx) {
257
401
  return "No line cleared the confidence floor for this person (not charged). More fields usually turn this into a match.";
258
402
  }
259
403
  const lines = (r?.candidates ?? []).map(
260
- (c, i) => `${i + 1}. ${c.text} [${Math.round((c.confidence ?? 0) * 100)}%${c.top ? ", top choice" : ""}]`
404
+ (c, i) =>
405
+ `${i + 1}. ${c.text} [${Math.round((c.confidence ?? 0) * 100)}%${c.top ? ", top choice" : ""}${c.generated ? ", written for this prospect" : ""}]`
261
406
  );
262
407
  const prefix =
263
408
  r?.status === "no_match"
@@ -0,0 +1,324 @@
1
+ // `sequences`, `prospects` and `mail` — the outbound email stack, as thin wrappers
2
+ // over the MCP tools, which call the same lib functions the app's /api/sequences
3
+ // and /api/prospects routes do. Every call is built from the ARGS map below, so
4
+ // `scripts/parity-check.ts` can hold each command to its tool's input schema.
5
+
6
+ import { CliError } from "../client.mjs";
7
+ import {
8
+ flagStr,
9
+ flagList,
10
+ requirePositional,
11
+ printResult,
12
+ argsFromFlags,
13
+ refuseUnknownFlags,
14
+ readTextInput,
15
+ ok,
16
+ } from "./shared.mjs";
17
+
18
+ // A JSON document from --<name> '<json>' or --<name>-file <path|->.
19
+ function jsonInput(flag) {
20
+ return (flags) => {
21
+ const raw = readTextInput(flags, flag, `${flag}-file`);
22
+ if (raw === undefined) return undefined;
23
+ try {
24
+ return JSON.parse(raw);
25
+ } catch (err) {
26
+ throw new CliError(`--${flag} is not valid JSON: ${err.message}`);
27
+ }
28
+ };
29
+ }
30
+
31
+ // Recipients from repeatable --email <addr>, or a JSON array via --recipients/--recipients-file.
32
+ function readRecipients(flags) {
33
+ const fromJson = jsonInput("recipients")(flags);
34
+ if (fromJson !== undefined) return fromJson;
35
+ const emails = flagList(flags.email);
36
+ return emails.length ? emails.map((email) => ({ email })) : undefined;
37
+ }
38
+
39
+ // `--custom key=value` (repeatable) → { key: value }.
40
+ function readCustom(flags) {
41
+ const entries = flagList(flags.custom);
42
+ if (!entries.length) return undefined;
43
+ const out = {};
44
+ for (const entry of entries) {
45
+ const i = entry.indexOf("=");
46
+ if (i <= 0) throw new CliError(`--custom takes key=value (got ${entry})`);
47
+ out[entry.slice(0, i)] = entry.slice(i + 1);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ const SEQUENCE_STATUSES = ["draft", "active", "paused", "archived"];
53
+ const PROSPECT_STATUSES = ["new", "contacted", "replied", "unsubscribed", "archived"];
54
+ const mailerIds = {
55
+ value: (flags) => {
56
+ const ids = flagList(flags.mailer);
57
+ return ids.length ? ids : undefined;
58
+ },
59
+ };
60
+
61
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
62
+ export const ARGS = {
63
+ list_sequences: {},
64
+ get_sequence: {
65
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
66
+ },
67
+ create_sequence: {
68
+ name: { flag: "name", required: true },
69
+ flow: {
70
+ value: (flags) => {
71
+ const flow = jsonInput("flow")(flags);
72
+ if (flow === undefined) throw new CliError("Missing required --flow '<json>' (or --flow-file <path>)");
73
+ return flow;
74
+ },
75
+ },
76
+ mailer_ids: mailerIds,
77
+ },
78
+ update_sequence: {
79
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
80
+ name: "name",
81
+ flow: { value: jsonInput("flow") },
82
+ status: { flag: "status", enum: SEQUENCE_STATUSES },
83
+ mailer_ids: mailerIds,
84
+ },
85
+ enroll_in_sequence: {
86
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
87
+ recipients: { value: readRecipients },
88
+ list_id: "list",
89
+ },
90
+ stop_sequence_enrollment: {
91
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
92
+ enrollment_id: { positional: 1, label: "enrollment-id", required: true },
93
+ },
94
+ list_review_items: {},
95
+ update_review_item: {
96
+ item_id: { positional: 0, label: "item-id", required: true },
97
+ subject: "subject",
98
+ body: { value: (flags) => readTextInput(flags, "body", "file") },
99
+ },
100
+ decide_review_item: {
101
+ item_id: { positional: 0, label: "item-id", required: true },
102
+ action: { flag: "action", required: true, enum: ["approve", "skip", "delete"] },
103
+ },
104
+ list_dispatch_links: {},
105
+ create_dispatch_link: {
106
+ days: { flag: "days", type: "num", required: true },
107
+ },
108
+ revoke_dispatch_link: {
109
+ link_id: { positional: 0, label: "link-id", required: true },
110
+ },
111
+ list_prospect_lists: {},
112
+ get_prospect_list: {
113
+ list_id: { positional: 0, label: "list-id", required: true },
114
+ },
115
+ list_prospects: {
116
+ list_id: "list",
117
+ status: { flag: "status", enum: PROSPECT_STATUSES },
118
+ q: "q",
119
+ limit: { flag: "limit", type: "num" },
120
+ },
121
+ get_prospect: {
122
+ prospect_id: { positional: 0, label: "prospect-id", required: true },
123
+ },
124
+ create_prospect_list: {
125
+ name: { flag: "name", required: true },
126
+ },
127
+ import_prospects_csv: {
128
+ csv_text: {
129
+ value: (flags) => {
130
+ const csv = readTextInput(flags, "csv", "csv-file");
131
+ if (csv === undefined)
132
+ throw new CliError("Missing CSV: pass --csv-file <path> (or --csv-file - for stdin)");
133
+ return csv;
134
+ },
135
+ },
136
+ one_per_company: { flag: "one-per-company", type: "bool" },
137
+ list_id: "list",
138
+ list_name: "list-name",
139
+ },
140
+ create_prospect_list_from_prompt: {
141
+ description: { flag: "description", required: true },
142
+ list_id: "list",
143
+ list_name: "list-name",
144
+ limit: { flag: "limit", type: "num" },
145
+ one_per_company: { flag: "one-per-company", type: "bool" },
146
+ },
147
+ enrich_prospects: {
148
+ list_id: "list",
149
+ status: { flag: "status", enum: PROSPECT_STATUSES },
150
+ q: "q",
151
+ },
152
+ update_prospect: {
153
+ prospect_id: { positional: 0, label: "prospect-id", required: true },
154
+ status: { flag: "status", enum: PROSPECT_STATUSES },
155
+ name: "name",
156
+ first_name: "first-name",
157
+ last_name: "last-name",
158
+ title: "title",
159
+ company: "company",
160
+ website: "website",
161
+ linkedin_url: "linkedin",
162
+ school: "school",
163
+ city: "city",
164
+ country: "country",
165
+ years_at_company: "years",
166
+ notes: "notes",
167
+ custom: { value: readCustom },
168
+ },
169
+ get_mail_connections: {},
170
+ };
171
+
172
+ // Flags the computed `value` sources read (refuseUnknownFlags can't see them).
173
+ const VALUE_FLAGS = {
174
+ create_sequence: ["flow", "flow-file", "mailer"],
175
+ update_sequence: ["flow", "flow-file", "mailer"],
176
+ enroll_in_sequence: ["recipients", "recipients-file", "email"],
177
+ update_review_item: ["body", "file"],
178
+ import_prospects_csv: ["csv", "csv-file"],
179
+ update_prospect: ["custom"],
180
+ };
181
+
182
+ async function run(ctx, tool, message) {
183
+ refuseUnknownFlags(ctx, ARGS[tool], VALUE_FLAGS[tool] ?? []);
184
+ const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
185
+ if (message) ok(ctx, message);
186
+ printResult(ctx.io, ctx.flags, result);
187
+ return 0;
188
+ }
189
+
190
+ const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links>`;
191
+
192
+ export async function cmdSequences(ctx) {
193
+ const sub = ctx.positionals[0];
194
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
195
+ switch (sub) {
196
+ case "list":
197
+ case "ls":
198
+ return run(rest, "list_sequences");
199
+ case "get":
200
+ case "show":
201
+ return run(rest, "get_sequence");
202
+ case "create":
203
+ case "new":
204
+ return run(rest, "create_sequence", "Created sequence (a draft: set --status active to send).");
205
+ case "update":
206
+ return run(rest, "update_sequence", "Updated sequence.");
207
+ case "pause":
208
+ case "resume": {
209
+ const id = requirePositional(rest.positionals, 0, "sequence-id");
210
+ const status = sub === "pause" ? "paused" : "active";
211
+ const result = await ctx.client.callTool(
212
+ "update_sequence",
213
+ argsFromFlags(ARGS.update_sequence, { status }, [id])
214
+ );
215
+ ok(ctx, sub === "pause" ? "Paused: nothing further sends." : "Resumed: sending again.");
216
+ printResult(ctx.io, ctx.flags, result);
217
+ return 0;
218
+ }
219
+ case "enroll":
220
+ return run(rest, "enroll_in_sequence", "Enrolled.");
221
+ case "stop":
222
+ return run(rest, "stop_sequence_enrollment", "Enrollment stopped.");
223
+ case "review":
224
+ return sequencesReview({ ...rest, positionals: rest.positionals.slice(1) }, rest.positionals[0]);
225
+ case "links":
226
+ return sequencesLinks({ ...rest, positionals: rest.positionals.slice(1) }, rest.positionals[0]);
227
+ default:
228
+ throw new CliError(`Unknown sequences subcommand: ${sub ?? "(none)"}. ${SEQUENCES_USAGE}`);
229
+ }
230
+ }
231
+
232
+ async function sequencesReview(ctx, sub) {
233
+ switch (sub) {
234
+ case "list":
235
+ case "ls":
236
+ case undefined:
237
+ return run(ctx, "list_review_items");
238
+ case "edit":
239
+ return run(ctx, "update_review_item", "Saved the edit.");
240
+ case "decide":
241
+ return run(ctx, "decide_review_item", "Decided.");
242
+ case "approve":
243
+ case "skip":
244
+ case "delete": {
245
+ const id = requirePositional(ctx.positionals, 0, "item-id");
246
+ const result = await ctx.client.callTool(
247
+ "decide_review_item",
248
+ argsFromFlags(ARGS.decide_review_item, { action: sub }, [id])
249
+ );
250
+ ok(ctx, sub === "approve" ? "Approved: sending now." : sub === "skip" ? "Skipped." : "Deleted.");
251
+ printResult(ctx.io, ctx.flags, result);
252
+ return 0;
253
+ }
254
+ default:
255
+ throw new CliError(`Unknown review subcommand: ${sub}. Try: list, edit, decide, approve, skip, delete`);
256
+ }
257
+ }
258
+
259
+ async function sequencesLinks(ctx, sub) {
260
+ switch (sub) {
261
+ case "list":
262
+ case "ls":
263
+ case undefined:
264
+ return run(ctx, "list_dispatch_links");
265
+ case "create":
266
+ return run(ctx, "create_dispatch_link", "Link minted. Anyone holding it can send; it is shown only once.");
267
+ case "revoke": {
268
+ if (!ctx.flags.yes) {
269
+ const id = flagStr(ctx.positionals[0]) ?? "<link-id>";
270
+ ctx.io.error(`Re-run with --yes to confirm: ${ctx.bin} sequences links revoke ${id} --yes`);
271
+ return 1;
272
+ }
273
+ return run(ctx, "revoke_dispatch_link", "Link revoked.");
274
+ }
275
+ default:
276
+ throw new CliError(`Unknown links subcommand: ${sub}. Try: list, create, revoke`);
277
+ }
278
+ }
279
+
280
+ const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|import|source|enrich|update>`;
281
+
282
+ export async function cmdProspects(ctx) {
283
+ const sub = ctx.positionals[0];
284
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
285
+ switch (sub) {
286
+ case "lists":
287
+ return run(rest, "list_prospect_lists");
288
+ case "list":
289
+ return run(rest, "get_prospect_list");
290
+ case "ls":
291
+ return run(rest, "list_prospects");
292
+ case "get":
293
+ case "show":
294
+ return run(rest, "get_prospect");
295
+ case "create-list":
296
+ return run(rest, "create_prospect_list", "List ready.");
297
+ case "import":
298
+ return run(rest, "import_prospects_csv", "Imported.");
299
+ case "source":
300
+ return run(
301
+ rest,
302
+ "create_prospect_list_from_prompt",
303
+ "Sourcing started; rows land in the list as they arrive."
304
+ );
305
+ case "enrich":
306
+ return run(rest, "enrich_prospects", "Enrichment queued.");
307
+ case "update":
308
+ return run(rest, "update_prospect", "Updated prospect.");
309
+ default:
310
+ throw new CliError(`Unknown prospects subcommand: ${sub ?? "(none)"}. ${PROSPECTS_USAGE}`);
311
+ }
312
+ }
313
+
314
+ export async function cmdMail(ctx) {
315
+ const sub = ctx.positionals[0];
316
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
317
+ switch (sub) {
318
+ case "connections":
319
+ case "status":
320
+ return run(rest, "get_mail_connections");
321
+ default:
322
+ throw new CliError(`Unknown mail subcommand: ${sub ?? "(none)"}. Try: connections`);
323
+ }
324
+ }
package/lib/commands.mjs CHANGED
@@ -32,6 +32,7 @@ export * from "./commands/onboard.mjs";
32
32
  export * from "./commands/seers.mjs";
33
33
  export * from "./commands/shredder.mjs";
34
34
  export * from "./commands/landings.mjs";
35
+ export * from "./commands/outbound.mjs";
35
36
  export * from "./commands/offerings.mjs";
36
37
  export * from "./commands/admin.mjs";
37
38
  export * from "./commands/feedback.mjs";
package/lib/documents.mjs CHANGED
@@ -77,6 +77,8 @@ export const JSON_ARG_FLAG_PAIRS = [
77
77
  ["plan", "plan-file"],
78
78
  // A reviewed plan handed back to be applied verbatim (onboard.mjs readAcceptedPlan).
79
79
  ["accept-plan-json", "accept-plan"],
80
+ // A prospects CSV, parsed and header-mapped server-side (outbound.mjs prospects import).
81
+ ["csv", "csv-file"],
80
82
  ];
81
83
 
82
84
  /** Lower-case extension without the dot. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.36.1",
3
+ "version": "0.38.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",