@letterstory/cli 0.82.3 → 0.83.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/lib/cli.mjs CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  } from "./commands.mjs";
58
58
 
59
59
  // Keep in sync with cli/package.json.
60
- export const VERSION = "0.82.3";
60
+ export const VERSION = "0.83.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
@@ -333,16 +333,20 @@ Collections:
333
333
  collections autopen <id> [--template <key>|none] [--kernel-family <name>|org]
334
334
  [--mention off|subtle|woven] [--outline-depth barebones|fleshed_out]
335
335
  [--direction-gate true|false] [--auto-start true|false]
336
- [--prefer <site> …|none]
336
+ [--prefer <site> …|none] [--client-one-in <n>]
337
337
  The Auto-Pen settings the app's panel writes. --template binds a content
338
338
  template (from 'templates list') as this collection's default, which is how
339
339
  a template you author here actually gets used. A patch: unset flags are
340
340
  left alone. --prefer REPLACES the preferred sources: SITES research
341
341
  searches for every post's topic and reaches for first when a figure fits
342
342
  (a URL is saved as its site) — your own site included; 'none' clears them.
343
- collections cite-preferred <id> [--limit <n>] [--dry-run]
344
- Bring published posts up to the preferred sources: each eligible post gets
345
- at most one preferred figure, linked on the figure, then republishes.
343
+ collections preview-preferred <id>
344
+ Step 1: check every published post with a Sources list against the
345
+ preferred sites — dry, nothing written. Proposals are judged by fit.
346
+ collections preferred-preview <id>
347
+ Step 2: the proposals ranked, what the ceiling chooses, and the 1-in-N.
348
+ collections cite-preferred <id>
349
+ Step 3: apply exactly the chosen sentences, then republish.
346
350
 
347
351
  Authors (the per-collection author bank — recurring bylines):
348
352
  authors list <collection-id> List the bank + byline distribution
@@ -639,6 +643,21 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
639
643
  Replay a breakthrough live now (background; poll
640
644
  admin replays). With neither, the client's newest
641
645
  breakthrough. Needs fleet:replay.
646
+ admin fleet-experiments list Fleet experiments (sites, collections, single posts)
647
+ with each arm's rate per AI, before vs after.
648
+ | articles [--experiment <id>] [--org <uuid>] [--filter all|assigned|unassigned] [--arm <arm>] [--q …]
649
+ Posts on independent sites that name the client,
650
+ with the paragraphs that name it and the live URL.
651
+ | mark <experiment-id> <article-id…> --arm <arm> | unmark <experiment-id> <article-id…>
652
+ | new --template sponsor_disclosure_v1|sponsor_disclosure_clients_v1|article_disclosure_v1|mention_degree_v1|citation_shape_v1
653
+ | new --spec-file spec.json A custom experiment ({key, hypothesis, unit, arms, response_var}).
654
+ | assign <experiment-id> <unit-id…> | assign-eligible <experiment-id> | apply <experiment-id>
655
+ | assign-orgs <experiment-id> <org-domain-or-id…>
656
+ Site/collection arms (safe mode: our own sites, or one
657
+ site per named client). A running experiment applies
658
+ each new unit's arm at once; start applies them all.
659
+ | start|stop <experiment-id> | decide <experiment-id> --decision "…"
660
+ Reads need fleet:read; writes fleet:experiment.
642
661
  admin experiments list [--q colons] [--status proposed] [--primitive write] [--stage rarity] | show <id>
643
662
  | new "<what you are investigating>" [--primitive write|research|shred|enhance] [--stages a,b]
644
663
  [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
@@ -12,6 +12,7 @@
12
12
  // (feedback-add --fix "…" files the reader's rewrite; the diff is classified and a disposition suggested.
13
13
  // The old names — evals, eval-add, eval-set, proposals, propose-eval, capture-eval, reject-eval — still work.)
14
14
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
15
+ // admin fleet-experiments list|articles|mark|unmark|new|assign|assign-eligible|assign-orgs|apply|start|stop|decide … (fleet:read / fleet:experiment)
15
16
  // admin crm import|capture|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)
16
17
  // admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
17
18
  // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
@@ -3346,6 +3347,176 @@ async function cmdAdminSpend(ctx) {
3346
3347
  }
3347
3348
  }
3348
3349
 
3350
+ const FLEET_EXPERIMENTS = "/api/superadmin/experiments";
3351
+
3352
+ function pct(rate) {
3353
+ return rate && rate.asks > 0 ? `${Math.round(rate.point * 100)}% (${rate.hits}/${rate.asks})` : "no answers yet";
3354
+ }
3355
+
3356
+ function signedPoints(value) {
3357
+ if (value === null || value === undefined) return "not enough yet";
3358
+ const r = Number((value * 100).toFixed(1));
3359
+ return `${r > 0 ? "+" : r < 0 ? "-" : ""}${Math.abs(r).toFixed(1)} pts`;
3360
+ }
3361
+
3362
+ const READOUT_OUTCOMES = [
3363
+ ["cited", "cited when probed"],
3364
+ ["namedWhenCited", "client named when cited"],
3365
+ ["reads", "read by the AI"],
3366
+ ];
3367
+
3368
+ function fleetExperimentLines(e) {
3369
+ const lines = [`${e.id} ${e.key} [${e.status}] per ${e.unit} → ${e.responseVar}`, ` ${e.hypothesis}`];
3370
+ for (const arm of e.armStats ?? []) {
3371
+ const live = arm.live !== undefined ? `, ${arm.live} live` : "";
3372
+ lines.push(` ${arm.arm}: ${arm.units} unit${arm.units === 1 ? "" : "s"}${live} · after ${pct(arm.rate)}`);
3373
+ }
3374
+ const r = e.articleReadout;
3375
+ if (r) {
3376
+ for (const s of r.surfaces) {
3377
+ lines.push(` ${s.label}:`);
3378
+ for (const [key, title] of READOUT_OUTCOMES) {
3379
+ const arms = s.arms.map((a) => `${a.arm} ${pct(a[key].before)} → ${pct(a[key].after)}`).join("; ");
3380
+ const effects = s.effects
3381
+ .map((x) => `${x.arm} vs ${r.referenceArm} ${signedPoints(x[key])}`)
3382
+ .join("; ");
3383
+ lines.push(` ${title}: ${arms}${effects ? ` | ${effects}` : ""}`);
3384
+ }
3385
+ }
3386
+ }
3387
+ if (e.decision) lines.push(` decision: ${e.decision}`);
3388
+ return lines.join("\n");
3389
+ }
3390
+
3391
+ function mentionedArticleLines(a) {
3392
+ const state = a.assignment
3393
+ ? a.assignment.changedSinceApplied
3394
+ ? ", republished mid-test"
3395
+ : a.assignment.appliedAt
3396
+ ? `, live since ${a.assignment.appliedAt.slice(0, 10)}`
3397
+ : ", starts with the experiment"
3398
+ : "";
3399
+ const arm = a.assignment
3400
+ ? ` [${a.assignment.arm}${state}]`
3401
+ : a.ineligible
3402
+ ? ` [can't join: ${a.ineligible}]`
3403
+ : "";
3404
+ const lines = [
3405
+ `${a.articleId} ${a.orgName} · ${a.title}${arm}`,
3406
+ ` ${a.url ?? "(no live URL)"} · ${a.mentionCount} mention${a.mentionCount === 1 ? "" : "s"} · cited in ${a.citedAnswers} answer${a.citedAnswers === 1 ? "" : "s"}${a.probed ? " · page-probed" : ""}`,
3407
+ ];
3408
+ for (const p of a.passages) lines.push(` > ${p.text}`);
3409
+ if (a.passageTotal > a.passages.length) lines.push(` … ${a.passageTotal - a.passages.length} more`);
3410
+ return lines.join("\n");
3411
+ }
3412
+
3413
+ /**
3414
+ * `admin fleet-experiments …` — the fleet experiment registry and the client-mention
3415
+ * worklist: the headless half of /superadmin/experiments. Reads need `fleet:read` on the
3416
+ * staff key, writes `fleet:experiment`. (`admin experiments` is the kernel tuning ledger.)
3417
+ */
3418
+ async function cmdAdminFleetExperiments(ctx) {
3419
+ const [sub, id, ...rest] = ctx.positionals;
3420
+ const { flags, io } = ctx;
3421
+ const json = flagBool(flags.json);
3422
+ const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
3423
+ const action = (body) => staffRequest(ctx, "POST", FLEET_EXPERIMENTS, body);
3424
+ switch (sub) {
3425
+ case "list":
3426
+ return out(await staffRequest(ctx, "GET", FLEET_EXPERIMENTS), (r) =>
3427
+ r.experiments.length ? r.experiments.map(fleetExperimentLines).join("\n\n") : "(no experiments)"
3428
+ );
3429
+ case "articles": {
3430
+ const params = new URLSearchParams(
3431
+ compact({
3432
+ experiment_id: flagStr(flags.experiment),
3433
+ org_id: flagStr(flags.org),
3434
+ filter: flagStr(flags.filter),
3435
+ arm: flagStr(flags.arm),
3436
+ q: flagStr(flags.q),
3437
+ limit: flagStr(flags.limit),
3438
+ offset: flagStr(flags.offset),
3439
+ })
3440
+ );
3441
+ return out(await staffRequest(ctx, "GET", `${FLEET_EXPERIMENTS}/articles?${params}`), (r) => {
3442
+ const head = `${r.total} post${r.total === 1 ? "" : "s"} name the client (showing ${r.articles.length} from ${r.offset})${r.unaudited ? `; ${r.unaudited} unaudited` : ""}`;
3443
+ return [head, ...r.articles.map(mentionedArticleLines)].join("\n\n");
3444
+ });
3445
+ }
3446
+ case "mark":
3447
+ case "unmark": {
3448
+ const articleIds = rest.filter(Boolean);
3449
+ const arm = sub === "unmark" ? null : flagStr(flags.arm);
3450
+ if (!id || articleIds.length === 0 || (sub === "mark" && !arm))
3451
+ throw new CliError(
3452
+ `Usage: admin fleet-experiments ${sub} <experiment-id> <article-id> [<article-id> …]${sub === "mark" ? " --arm <arm>" : ""}`
3453
+ );
3454
+ const result = await staffRequest(ctx, "PATCH", `${FLEET_EXPERIMENTS}/articles`, {
3455
+ experiment_id: id,
3456
+ article_ids: articleIds,
3457
+ arm,
3458
+ });
3459
+ return out(
3460
+ result,
3461
+ (r) => `assigned ${r.assigned}, moved ${r.moved}, unchanged ${r.unchanged}, cleared ${r.cleared}`
3462
+ );
3463
+ }
3464
+ case "new": {
3465
+ const template = flagStr(flags.template);
3466
+ const specFile = flagStr(flags["spec-file"]);
3467
+ if (!template && !specFile)
3468
+ throw new CliError(
3469
+ "Usage: admin fleet-experiments new --template sponsor_disclosure_v1|sponsor_disclosure_clients_v1|article_disclosure_v1|mention_degree_v1|citation_shape_v1 | --spec-file spec.json"
3470
+ );
3471
+ const body = template
3472
+ ? { action: "create_template", template }
3473
+ : { action: "create", experiment: JSON.parse(readFileSync(specFile, "utf8")) };
3474
+ return out(await action(body), (r) => `created ${r.experiment_id}`);
3475
+ }
3476
+ case "assign":
3477
+ case "assign-eligible":
3478
+ case "assign-orgs":
3479
+ case "apply": {
3480
+ const ids = rest.filter(Boolean);
3481
+ if (!id || ((sub === "assign" || sub === "assign-orgs") && ids.length === 0))
3482
+ throw new CliError(
3483
+ `Usage: admin fleet-experiments ${sub} <experiment-id>${sub === "assign" ? " <unit-id…>" : sub === "assign-orgs" ? " <org-domain-or-id…>" : ""}`
3484
+ );
3485
+ const body =
3486
+ sub === "assign"
3487
+ ? { action: "assign", experiment_id: id, unit_ids: ids }
3488
+ : sub === "assign-orgs"
3489
+ ? { action: "assign_orgs", experiment_id: id, orgs: ids }
3490
+ : { action: sub === "apply" ? "apply" : "assign_eligible", experiment_id: id };
3491
+ return out(await action(body), (r) => {
3492
+ const lines = [];
3493
+ if (r.assigned !== undefined) lines.push(`assigned ${r.assigned}, already in ${r.skipped}`);
3494
+ if (r.applied)
3495
+ lines.push(
3496
+ `applied ${r.applied.applied}, unchanged ${r.applied.unchanged}, failed ${r.applied.failed.length}`
3497
+ );
3498
+ for (const f of r.applied?.failed ?? []) lines.push(` ${f.unitId} (${f.arm}): ${f.message}`);
3499
+ for (const k of r.skippedOrgs ?? []) lines.push(` skipped ${k.orgId}: ${k.reason}`);
3500
+ return lines.join("\n") || "ok";
3501
+ });
3502
+ }
3503
+ case "start":
3504
+ case "stop":
3505
+ if (!id) throw new CliError(`Usage: admin fleet-experiments ${sub} <experiment-id>`);
3506
+ return out(await action({ action: sub, experiment_id: id }), (r) => `${id} is ${r.status}`);
3507
+ case "decide": {
3508
+ const decision = flagStr(flags.decision);
3509
+ if (!id || !decision)
3510
+ throw new CliError('Usage: admin fleet-experiments decide <experiment-id> --decision "<readout>"');
3511
+ return out(await action({ action: "decide", experiment_id: id, decision }), (r) => `${id} is ${r.status}`);
3512
+ }
3513
+ default:
3514
+ throw new CliError(
3515
+ 'Usage: admin fleet-experiments list | articles [--experiment <id>] [--org <id>] [--filter all|assigned|unassigned] [--arm <arm>] [--q …] [--limit n] [--offset n] | mark <experiment-id> <article-id…> --arm <arm> | unmark <experiment-id> <article-id…> | new --template <key> | start|stop <experiment-id> | decide <experiment-id> --decision "…"'
3516
+ );
3517
+ }
3518
+ }
3519
+
3349
3520
  export async function cmdAdmin(ctx) {
3350
3521
  const sub = ctx.positionals[0];
3351
3522
  if (sub === "replays" || sub === "replay")
@@ -3355,11 +3526,12 @@ export async function cmdAdmin(ctx) {
3355
3526
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
3356
3527
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
3357
3528
  if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
3529
+ if (sub === "fleet-experiments") return cmdAdminFleetExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
3358
3530
  if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
3359
3531
  if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
3360
3532
  if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
3361
3533
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
3362
3534
  throw new CliError(
3363
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin spend … | admin evals … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3535
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin fleet-experiments … | admin crm … | admin spend … | admin evals … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3364
3536
  );
3365
3537
  }
@@ -47,10 +47,14 @@ export const ARGS = {
47
47
  regenerate_topic_map: { value: (flags) => (flags["no-topic-map"] === undefined ? undefined : false) },
48
48
  stock_headlines: { value: (flags) => (flags["no-headlines"] === undefined ? undefined : false) },
49
49
  },
50
+ preview_preferred_sources: {
51
+ collection_id: { positional: 0, label: "collection-id", required: true },
52
+ },
53
+ get_preferred_sources_preview: {
54
+ collection_id: { positional: 0, label: "collection-id", required: true },
55
+ },
50
56
  cite_preferred_sources: {
51
57
  collection_id: { positional: 0, label: "collection-id", required: true },
52
- limit: { flag: "limit", type: "num" },
53
- dry_run: { value: (flags) => (flags["dry-run"] === true ? true : undefined) },
54
58
  },
55
59
  update_auto_pen_config: {
56
60
  collection_id: { positional: 0, label: "collection-id", required: true },
@@ -76,6 +80,8 @@ export const ARGS = {
76
80
  auto_start_on_curate: stringBool("auto-start"),
77
81
  // Replaces the collection's preferred sources; `--prefer none` clears them.
78
82
  preferred_sources: { value: (flags) => preferredSourcesFlag(flags.prefer) },
83
+ // The ceiling on the client's own site: at most one in N published posts cites it.
84
+ client_one_in: { flag: "client-one-in", type: "num" },
79
85
  },
80
86
  };
81
87
 
@@ -106,11 +112,15 @@ export async function cmdCollections(ctx) {
106
112
  return collectionsAutoPen(rest);
107
113
  case "activate":
108
114
  return collectionsActivate(rest);
115
+ case "preview-preferred":
116
+ return collectionsPreviewPreferred(rest);
117
+ case "preferred-preview":
118
+ return collectionsReadPreferredPreview(rest);
109
119
  case "cite-preferred":
110
120
  return collectionsCitePreferred(rest);
111
121
  default:
112
122
  throw new CliError(
113
- `Unknown collections subcommand: ${sub ?? "(none)"}. Try: list, new, update, delete, assign, autopen, activate, cite-preferred`
123
+ `Unknown collections subcommand: ${sub ?? "(none)"}. Try: list, new, update, delete, assign, autopen, activate, preview-preferred, preferred-preview, cite-preferred`
114
124
  );
115
125
  }
116
126
  }
@@ -139,10 +149,36 @@ async function collectionsActivate(ctx) {
139
149
  }
140
150
 
141
151
  /**
142
- * `collections cite-preferred <id> [--limit <n>] [--dry-run]` — bring the collection's published
143
- * posts up to its preferred sources: each eligible post places at most one preferred figure and
144
- * republishes. The same function as the app's "Apply to published posts".
152
+ * `collections preview-preferred <id>` — step 1: queue a dry proposal per eligible published post
153
+ * (nothing is written to a post). The same preview as the app's "Preview published posts".
145
154
  */
155
+ async function collectionsPreviewPreferred(ctx) {
156
+ const { client, positionals, flags, io } = ctx;
157
+ const args = argsFromFlags(ARGS.preview_preferred_sources, flags, positionals);
158
+ const result = await client.callTool("preview_preferred_sources", args);
159
+ ok(
160
+ ctx,
161
+ `Queued ${result?.queued ?? 0} post${result?.queued === 1 ? "" : "s"} for a proposal. Read it with \`collections preferred-preview ${args.collection_id}\`.`
162
+ );
163
+ printResult(io, flags, result);
164
+ return 0;
165
+ }
166
+
167
+ /** `collections preferred-preview <id>` — step 2: the latest preview, ranked, and the 1-in-N it projects. */
168
+ async function collectionsReadPreferredPreview(ctx) {
169
+ const { client, positionals, flags, io } = ctx;
170
+ const args = argsFromFlags(ARGS.get_preferred_sources_preview, flags, positionals);
171
+ const r = await client.callTool("get_preferred_sources_preview", args);
172
+ const oneIn = r?.projected?.one_in ? `1 in ${r.projected.one_in}` : "none";
173
+ ok(
174
+ ctx,
175
+ `Preview ${r?.status}: ${r?.checked ?? 0}/${r?.total ?? 0} checked; ${r?.chosen?.length ?? 0} chosen, ${r?.held_by_ceiling?.length ?? 0} held by the ceiling, ${r?.below_bar?.length ?? 0} below the bar. Client cited in ${r?.projected?.citing_client ?? 0} of ${r?.projected?.of ?? 0} (${oneIn}).`
176
+ );
177
+ printResult(io, flags, r);
178
+ return 0;
179
+ }
180
+
181
+ /** `collections cite-preferred <id>` — step 3: apply exactly the latest finished preview's chosen sentences. */
146
182
  async function collectionsCitePreferred(ctx) {
147
183
  const { client, positionals, flags, io } = ctx;
148
184
  const args = argsFromFlags(ARGS.cite_preferred_sources, flags, positionals);
@@ -150,9 +186,7 @@ async function collectionsCitePreferred(ctx) {
150
186
  const n = result?.started?.length ?? 0;
151
187
  ok(
152
188
  ctx,
153
- result?.dry_run
154
- ? `Would run ${n} post${n === 1 ? "" : "s"}; ${result?.skipped?.length ?? 0} skipped.`
155
- : `Started ${n} post${n === 1 ? "" : "s"}${result?.republishes === false ? " (changes wait on the draft for approval)" : ""}; ${result?.skipped?.length ?? 0} skipped.`
189
+ `Started ${n} post${n === 1 ? "" : "s"}${result?.republishes === false ? " (changes wait on the draft for approval)" : ""}; ${result?.skipped?.length ?? 0} skipped.`
156
190
  );
157
191
  printResult(io, flags, result);
158
192
  return 0;
@@ -195,7 +229,7 @@ async function collectionsAutoPen(ctx) {
195
229
  const patch = argsFromFlags(ARGS.update_auto_pen_config, flags, positionals);
196
230
  if (Object.keys(patch).length === 1) {
197
231
  throw new CliError(
198
- "Nothing to change. Pass at least one of --template, --kernel-family, --mention, --outline-depth, --direction-gate, --auto-start, --prefer."
232
+ "Nothing to change. Pass at least one of --template, --kernel-family, --mention, --outline-depth, --direction-gate, --auto-start, --prefer, --client-one-in."
199
233
  );
200
234
  }
201
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.82.3",
3
+ "version": "0.83.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",