@letterstory/cli 0.89.0 → 0.90.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
@@ -346,6 +346,21 @@ letterstory admin ask "which clients published nothing in the last 30 days?" --s
346
346
  This is the headless half of `/superadmin/ask` — same function, same schema — so a
347
347
  long-running agent can watch the fleet on a schedule without a browser.
348
348
 
349
+ ### Staff: client patterns and Fable
350
+
351
+ ```bash
352
+ # What separates clients whose AI visibility moves (docs/architecture/client-patterns.md):
353
+ # every client against itself on share of voice, mention rate and page access, with what we
354
+ # did for it. Needs fleet:read; `run` and `ask` spend Fable tokens and need fleet:query.
355
+ letterstory admin patterns --period 30d
356
+ letterstory admin patterns reports # Fable's weekly reports and runs, newest first
357
+ letterstory admin patterns report <id> # one report's markdown
358
+ letterstory admin patterns run # a report now (takes minutes)
359
+ letterstory admin patterns ask "do answer-gap posts help paying clients too?"
360
+ ```
361
+
362
+ The headless half of Admin → Client performance → Patterns: same functions, same schemas.
363
+
349
364
  ### Staff: kernel experiments
350
365
 
351
366
  ```bash
package/lib/cli.mjs CHANGED
@@ -57,13 +57,15 @@ import {
57
57
  } from "./commands.mjs";
58
58
 
59
59
  // Keep in sync with cli/package.json.
60
- export const VERSION = "0.89.0";
60
+ export const VERSION = "0.90.0";
61
61
 
62
62
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
63
63
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
64
64
  // `negate` spelling) in a command's ARGS map must be listed; boolean-flags.test.ts enforces it.
65
65
  export const BOOLEAN_FLAGS = new Set([
66
66
  "json",
67
+ // lede library update / lede proposals accept: clear or drop the line's condition.
68
+ "no-condition",
67
69
  "yes",
68
70
  "confirm-missing-target",
69
71
  "no-wait",
@@ -645,6 +647,14 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
645
647
  (fleet:instrument) — e.g. --term Context.dev for a name
646
648
  that is also a word. --clear goes back to inferred,
647
649
  recorded as your decision.
650
+ admin patterns [--period 14d|30d] [--segment "<segment>"] [--json]
651
+ Client patterns: every client's share of voice,
652
+ mention rate and page access against itself, with
653
+ what we did for it. Needs fleet:read.
654
+ admin patterns reports [--kind reports|weekly|run|ask] | report <id>
655
+ Fable's weekly reports, runs and Ask Fable answers.
656
+ admin patterns run | ask "<question>" Run a Fable report now, or ask Fable a question
657
+ about client patterns (minutes). Needs fleet:query.
648
658
  admin replays [--org <uuid>] [--target <uuid>] [--status captured|not_reproduced|queued|running|unsupported|failed] [--limit <n>]
649
659
  Live answer replays: breakthroughs re-asked in ChatGPT /
650
660
  Gemini / Google / Perplexity / Claude, with screenshots.
@@ -932,7 +942,7 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
932
942
  lede balance Ledes left on this org
933
943
  lede library list The org's opener library: pre-built + custom lines
934
944
  lede library create --text <line> [--alts <v>]... [--peer-alts <v>]... [--requires <trigger>]...
935
- [--threshold <0..100>] [--tone warm|direct|playful|formal] [--tag <t>]...
945
+ [--threshold <0..100>] [--tone warm|direct|playful|formal] [--tag <t>]... [--condition <use when...>]
936
946
  Add a custom line. A {{placeholder}} must appear in
937
947
  --requires; proprietary-engine triggers stay
938
948
  reserved for the pre-built lines. --threshold
@@ -941,6 +951,7 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
941
951
  lower = looser.
942
952
  lede library update <lede-id> [--text <line>] [--alts <v>]... [--peer-alts <v>]...
943
953
  [--requires <trigger>]... [--threshold <0..100>] [--tone <t>] [--tag <t>]...
954
+ [--condition <use when...> | --no-condition]
944
955
  Edit a line; only the flags you pass change
945
956
  lede library enable <lede-id> Make a line a run candidate again
946
957
  lede library disable <lede-id> Keep a line out of runs (reversible; the way to
@@ -951,6 +962,13 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
951
962
  Propose brand-voice rewrites of the wording only.
952
963
  Applies nothing: accept a proposal with
953
964
  lede library update --text/--alts
965
+ lede proposals [list] [--status proposed|captured|active|rejected] [--limit <n>]
966
+ Lines reviewers wrote while personalizing in
967
+ Dispatch, templated and judged, each with a
968
+ drafted "Use when ..." condition
969
+ lede proposals accept <proposal-id> [--text <line>] [--condition <c> | --no-condition] [--tone <t>]
970
+ Add it to the library (live immediately)
971
+ lede proposals reject <proposal-id> --reason <why> Keep it out; the reason stops it recurring
954
972
 
955
973
  Landings (a link + context; auto-mentioned in articles by collections you enable):
956
974
  landings list Your landing pages + mention counts
@@ -982,6 +1000,7 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
982
1000
  sequences stop <sequence-id> <enrollment-id> Stop one recipient
983
1001
  sequences review list Emails waiting for a decision
984
1002
  sequences review edit <item-id> [--subject <s>] [--body <text>|--file <path>]
1003
+ [--personalize <line> [--personalize-mode replace|insert]] Mark the edit a personalization (proposed to the opener library)
985
1004
  sequences review approve|skip|delete <item-id> Decide one (approve sends now)
986
1005
  sequences links list Live Pocket Dispatch links (no tokens)
987
1006
  sequences links create --days <1..3> Mint a no-login review link (shown once)
@@ -4,6 +4,7 @@
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 patterns [--period 14d|30d] [--segment "…"] | reports [--kind reports|weekly|run|ask] | report <id> | run | ask "<question>" (fleet:read / fleet:query)
7
8
  // admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
8
9
  // admin tuning-context [--q "<question>"] (tuning:experiment)
9
10
  // admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
@@ -241,6 +242,80 @@ async function cmdAdminAsk(ctx) {
241
242
  }
242
243
  }
243
244
 
245
+ /* ------------------------------------------------------------ client patterns */
246
+
247
+ const PATTERNS = "/api/superadmin/client-patterns";
248
+
249
+ /**
250
+ * `admin patterns …` — the Patterns tab without a browser (docs/architecture/client-patterns.md):
251
+ * the data, Fable's report history, a report on demand, and Ask Fable. `run` and `ask` wait for
252
+ * the finished answer (minutes, not seconds) and print its markdown.
253
+ */
254
+ async function cmdAdminPatterns(ctx) {
255
+ const [sub = "show", ...rest] = ctx.positionals;
256
+ const json = flagBool(ctx.flags.json);
257
+ switch (sub) {
258
+ case "show": {
259
+ const { payload } = await staffRequest(ctx, "GET", PATTERNS);
260
+ if (!payload) throw new CliError("The patterns snapshot has not been computed yet.");
261
+ const period = flagStr(ctx.flags.period) ?? "30d";
262
+ const segment = flagStr(ctx.flags.segment);
263
+ const clients = payload.clients.filter((c) => !segment || c.segment === segment);
264
+ if (json) return printResult(ctx.io, ctx.flags, { ...payload, clients });
265
+ const pts = (v) => (v == null ? "—" : `${v >= 0 ? "+" : "−"}${Math.abs(v * 100).toFixed(1)} pts`);
266
+ ctx.io.log(`${clients.length} clients, ${payload.postCount} posts · computed ${payload.generated}`);
267
+ for (const c of clients) {
268
+ const s = c.sov?.[period];
269
+ const n = c.named?.[period];
270
+ ctx.io.log(
271
+ `${c.name.padEnd(28)} ${c.segment.padEnd(30)} sov ${pts(s?.change)} mention ${pts(n?.change)} posts30 ${c.posts30}`
272
+ );
273
+ }
274
+ return;
275
+ }
276
+ case "reports": {
277
+ const kind = flagStr(ctx.flags.kind) ?? "reports";
278
+ const limit = flagStr(ctx.flags.limit) ?? "12";
279
+ const { reports } = await staffRequest(
280
+ ctx,
281
+ "GET",
282
+ `${PATTERNS}/reports?kind=${encodeURIComponent(kind)}&limit=${encodeURIComponent(limit)}`
283
+ );
284
+ if (json) return printResult(ctx.io, ctx.flags, { reports });
285
+ for (const r of reports)
286
+ ctx.io.log(
287
+ `${r.id} ${r.created_at} ${r.kind.padEnd(6)} ${r.status.padEnd(7)} ${(r.question ?? (r.summary ?? "").split("\n")[0] ?? "").slice(0, 80)}`
288
+ );
289
+ return;
290
+ }
291
+ case "report": {
292
+ const id = rest[0];
293
+ if (!id) throw new CliError("Usage: admin patterns report <id>");
294
+ const { report } = await staffRequest(ctx, "GET", `${PATTERNS}/reports/${encodeURIComponent(id)}`);
295
+ if (json) return printResult(ctx.io, ctx.flags, { report });
296
+ ctx.io.log(report.report ?? report.error ?? "(no report)");
297
+ return;
298
+ }
299
+ case "run": {
300
+ const { report } = await staffPost(ctx, `${PATTERNS}/reports`, {});
301
+ if (json) return printResult(ctx.io, ctx.flags, { report });
302
+ ctx.io.log(report.report ?? report.error ?? "(no report)");
303
+ return;
304
+ }
305
+ case "ask": {
306
+ const question = rest.join(" ").trim();
307
+ if (question.length < 3)
308
+ throw new CliError('Usage: admin patterns ask "do answer-gap posts help paying clients too?"');
309
+ const { answer } = await staffPost(ctx, `${PATTERNS}/ask`, { question });
310
+ if (json) return printResult(ctx.io, ctx.flags, { answer });
311
+ ctx.io.log(answer.report ?? answer.error ?? "(no answer)");
312
+ return;
313
+ }
314
+ default:
315
+ throw new CliError('Usage: admin patterns [show] | reports | report <id> | run | ask "<question>"');
316
+ }
317
+ }
318
+
244
319
  /* ------------------------------------------------------------ kernel experiments */
245
320
 
246
321
  const EXPERIMENTS = "/api/superadmin/tuning/experiments";
@@ -3576,6 +3651,7 @@ export async function cmdAdmin(ctx) {
3576
3651
  if (sub === "spend") return cmdAdminSpend({ ...ctx, positionals: ctx.positionals.slice(1) });
3577
3652
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
3578
3653
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
3654
+ if (sub === "patterns") return cmdAdminPatterns({ ...ctx, positionals: ctx.positionals.slice(1) });
3579
3655
  if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
3580
3656
  if (sub === "fleet-experiments") return cmdAdminFleetExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
3581
3657
  if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -3584,7 +3660,7 @@ export async function cmdAdmin(ctx) {
3584
3660
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
3585
3661
  if (sub === "brand-detection") return cmdAdminBrandDetection({ ...ctx, positionals: ctx.positionals.slice(1) });
3586
3662
  throw new CliError(
3587
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin fleet-experiments … | admin crm … | admin spend … | admin evals … | admin brand-detection … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3663
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin patterns … | admin experiments … | admin fleet-experiments … | admin crm … | admin spend … | admin evals … | admin brand-detection … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3588
3664
  );
3589
3665
  }
3590
3666
 
@@ -130,6 +130,8 @@ export const ARGS = {
130
130
  threshold: { flag: "threshold", type: "num" },
131
131
  tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
132
132
  tags: { flag: "tag", type: "list" },
133
+ // "Use when ..." clause, checked per prospect before the line is used.
134
+ condition: "condition",
133
135
  },
134
136
  update_lede: {
135
137
  id: { positional: 0, label: "lede-id", required: true },
@@ -140,6 +142,8 @@ export const ARGS = {
140
142
  threshold: { flag: "threshold", type: "num" },
141
143
  tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
142
144
  tags: { flag: "tag", type: "list" },
145
+ // --condition "<clause>" sets it; --no-condition clears it.
146
+ condition: { value: (flags) => (flags["no-condition"] ? null : flagStr(flags.condition)) },
143
147
  },
144
148
  set_lede_enabled: {
145
149
  id: { positional: 0, label: "lede-id", required: true },
@@ -153,6 +157,22 @@ export const ARGS = {
153
157
  voice: { flag: "voice", required: true },
154
158
  ids: { flag: "ids", type: "list" },
155
159
  },
160
+ // Opener proposals (`lede proposals ...`): lines captured from Dispatch personalizations,
161
+ // evaluated, waiting for an admin to accept into the library or reject with a reason.
162
+ list_lede_proposals: {
163
+ status: { flag: "status", enum: ["captured", "proposed", "active", "rejected"] },
164
+ limit: { flag: "limit", type: "num" },
165
+ },
166
+ decide_lede_proposal: {
167
+ proposal_id: { positional: 0, label: "proposal-id", required: true },
168
+ // `lede proposals accept|reject <id>` decides this; the subcommand passes it in.
169
+ action: { value: (_flags, _positionals, extra) => extra.action },
170
+ text: "text",
171
+ // --condition "<clause>" edits it on accept; --no-condition accepts with none.
172
+ condition: { value: (flags) => (flags["no-condition"] ? null : flagStr(flags.condition)) },
173
+ tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
174
+ reason: "reason",
175
+ },
156
176
  };
157
177
 
158
178
  export async function cmdLede(ctx) {
@@ -173,6 +193,9 @@ export async function cmdLede(ctx) {
173
193
  case "library":
174
194
  case "lib":
175
195
  return ledeLibrary(rest);
196
+ case "proposals":
197
+ case "proposal":
198
+ return ledeProposals(rest);
176
199
  case "run":
177
200
  return ledeRun(rest);
178
201
  case "runs":
@@ -193,7 +216,7 @@ export async function cmdLede(ctx) {
193
216
  return ledeFunnel(rest);
194
217
  default:
195
218
  throw new CliError(
196
- `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, library, run, runs, history, evals, balance, outcome, funnel`
219
+ `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, library, proposals, run, runs, history, evals, balance, outcome, funnel`
197
220
  );
198
221
  }
199
222
  }
@@ -445,3 +468,61 @@ async function ledeBalance(ctx) {
445
468
  printResult(io, flags, result);
446
469
  return 0;
447
470
  }
471
+
472
+ /**
473
+ * `lede proposals [list] [--status proposed|captured|active|rejected] [--limit n]`,
474
+ * `lede proposals accept <id> [--text ...] [--condition ...|--no-condition] [--tone ...]`,
475
+ * `lede proposals reject <id> --reason ...` — lines reviewers wrote while personalizing on
476
+ * the Dispatch desk, templatized and judged; accept adds one to the library (live now).
477
+ */
478
+ async function ledeProposals(ctx) {
479
+ const { client, positionals, flags, io } = ctx;
480
+ const action = positionals[0];
481
+ const rest = { ...ctx, positionals: positionals.slice(1) };
482
+ switch (action) {
483
+ case undefined:
484
+ case "list":
485
+ case "ls": {
486
+ const result = await client.callTool("list_lede_proposals", argsFromFlags(ARGS.list_lede_proposals, flags));
487
+ // One block per proposal: id, how often captured, the template, then its condition
488
+ // (or why it was rejected). `--json` for the judge's scores and the raw line.
489
+ printResult(
490
+ io,
491
+ flags,
492
+ result,
493
+ (r) =>
494
+ (r?.proposals ?? [])
495
+ .map((p) =>
496
+ [
497
+ `${p.id} x${p.capture_count} [${p.status}] ${p.templated_text ?? p.raw_line}`,
498
+ p.status_reason
499
+ ? ` why: ${p.status_reason}`
500
+ : p.suggested_condition
501
+ ? ` ${p.suggested_condition}`
502
+ : null,
503
+ ]
504
+ .filter(Boolean)
505
+ .join("\n")
506
+ )
507
+ .join("\n") || "No proposals."
508
+ );
509
+ return 0;
510
+ }
511
+ case "accept":
512
+ case "reject": {
513
+ const args = argsFromFlags(ARGS.decide_lede_proposal, flags, rest.positionals, { action });
514
+ if (action === "reject" && !args.reason) throw new CliError("A rejection needs --reason <why>.");
515
+ const result = await client.callTool("decide_lede_proposal", args);
516
+ ok(
517
+ ctx,
518
+ action === "accept"
519
+ ? `Accepted ${args.proposal_id}: the line is in the library now.`
520
+ : `Rejected ${args.proposal_id}.`
521
+ );
522
+ printResult(io, flags, result);
523
+ return 0;
524
+ }
525
+ default:
526
+ throw new CliError(`Unknown lede proposals action: ${action}. Try: list, accept, reject`);
527
+ }
528
+ }
@@ -197,6 +197,23 @@ export const ARGS = {
197
197
  item_id: { positional: 0, label: "item-id", required: true },
198
198
  subject: "subject",
199
199
  body: { value: (flags) => readTextInput(flags, "body", "file") },
200
+ // --personalize "<line>" marks the edit as a personalization (captured as an opener
201
+ // proposal for the org's admins); --personalize-mode says whether the line replaced
202
+ // the opener (default) or was inserted after the greeting.
203
+ personalization: {
204
+ value: (flags) => {
205
+ const line = flagStr(flags.personalize);
206
+ const mode = flagStr(flags["personalize-mode"]);
207
+ if (line === undefined) {
208
+ if (mode !== undefined) throw new CliError("--personalize-mode needs --personalize <line>");
209
+ return undefined;
210
+ }
211
+ if (mode !== undefined && mode !== "replace" && mode !== "insert") {
212
+ throw new CliError(`--personalize-mode must be replace or insert (got ${mode})`);
213
+ }
214
+ return { line, mode: mode ?? "replace" };
215
+ },
216
+ },
200
217
  },
201
218
  decide_review_item: {
202
219
  item_id: { positional: 0, label: "item-id", required: true },
@@ -366,7 +383,7 @@ const VALUE_FLAGS = {
366
383
  create_sequence: ["flow", "flow-file", "mailer", "send-window-start", "send-window-end"],
367
384
  update_sequence: ["flow", "flow-file", "mailer", "send-window-start", "send-window-end"],
368
385
  enroll_in_sequence: ["recipients", "recipients-file", "email"],
369
- update_review_item: ["body", "file"],
386
+ update_review_item: ["body", "file", "personalize", "personalize-mode"],
370
387
  delete_prospects: ["id"],
371
388
  import_prospects_csv: ["csv", "csv-file"],
372
389
  update_prospect: ["custom"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.89.0",
3
+ "version": "0.90.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",