@letterstory/cli 0.53.0 → 0.54.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
@@ -520,6 +520,14 @@ letterstory arena endpoint config <arena-id> [--file cfg.json]
520
520
  letterstory arena endpoint update <arena-id> --name "Docs research v2" # creator or org admin only
521
521
  letterstory arena endpoint delete <arena-id> --yes
522
522
 
523
+ # The research agent as ONE versioned file (like a kernel file; schema at /api/schema/arena):
524
+ letterstory arena file <arena-id> > arena.json # the file the arena runs now
525
+ letterstory arena file <arena-id> --patch '{"rules":{"context-figures":"measure"}}' --note "try it"
526
+ letterstory arena file <arena-id> --file arena.json # save a whole file as the next version
527
+ letterstory arena versions <arena-id> # your copy's versions and our releases
528
+ letterstory arena pin <arena-id> <version-id|none> # pin one of our releases, or follow live
529
+ letterstory arena offer <arena-id> [--accept] # our newer version, over your own changes
530
+
523
531
  # Ledes: a prospect's context in, ready-to-send opening lines out (chosen from a
524
532
  # hand-approved library, never generated; the top choice is flagged). Synchronous.
525
533
  letterstory lede create --name "Clay outbound table"
package/lib/cli.mjs CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  } from "./commands.mjs";
55
55
 
56
56
  // Keep in sync with cli/package.json.
57
- export const VERSION = "0.53.0";
57
+ export const VERSION = "0.54.0";
58
58
 
59
59
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
60
60
  // can't accidentally swallow the id as --json's value.
@@ -429,7 +429,29 @@ async function cmdAdminTuningContext(ctx) {
429
429
  * admin tuning pick <comparison-id> --cell <key> --pick left|right|tie record a blind pick
430
430
  * admin tuning reading-list <comparison-id> [--n 10] your next blind pairs as links, to read off the screen
431
431
  * admin tuning promote <experiment-id> --versions <service-kernel-id>:<version>,… promote from a "ship" decision
432
+ *
433
+ * Research (Phase 3): our arena's versions as files, research comparisons, the deploy split.
434
+ * admin tuning arenas | arena <version-id> our arena's versions / one version's file and diff
435
+ * admin tuning arena-candidate --patch '{…}' | --file f.json [--of <version-id>] [--note "…"] save a candidate
436
+ * admin tuning research-compare --title "…" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys …]
437
+ * admin tuning research-inputs [--set v1] research's fixed inputs
438
+ * admin tuning research-split [--since-hours 72] [--at <iso>,…] what each deploy did to research in production
439
+ * admin tuning research-standing live's level on the fixed inputs, week by week
440
+ * admin tuning promote-arena <experiment-id> --version <arena-version-id> promote our arena from a "ship" decision
432
441
  */
442
+ /** `--flag '<json>'` → the parsed object, or undefined when the flag is absent. */
443
+ function jsonFlag(raw, name) {
444
+ const text = flagStr(raw);
445
+ if (text === undefined) return undefined;
446
+ try {
447
+ const v = JSON.parse(text);
448
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
449
+ return v;
450
+ } catch (err) {
451
+ throw new CliError(`${name} is not a JSON object: ${err.message}`);
452
+ }
453
+ }
454
+
433
455
  async function cmdAdminTuning(ctx) {
434
456
  const [sub, id] = ctx.positionals;
435
457
  const { flags, io } = ctx;
@@ -534,6 +556,100 @@ async function cmdAdminTuning(ctx) {
534
556
  );
535
557
  return json ? printResult(io, flags, r) : io.log(r.text);
536
558
  }
559
+ // --- Research: our arena's versions, research comparisons, the deploy split ---
560
+ case "arenas": {
561
+ const r = await staffRequest(ctx, "GET", `${T}/arena-files`);
562
+ if (json) return printResult(io, flags, r);
563
+ return io.log(
564
+ r.versions
565
+ .map((v) => `${v.label.padEnd(18)} ${v.status.padEnd(9)} ${v.id}${v.note ? ` ${v.note}` : ""}`)
566
+ .join("\n") || "(no versions)"
567
+ );
568
+ }
569
+ case "arena": {
570
+ if (!id) throw new CliError("Usage: admin tuning arena <version-id>");
571
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/arena-files/${id}`));
572
+ }
573
+ case "arena-candidate": {
574
+ const patch = jsonFlag(flags.patch, "--patch");
575
+ const file = jsonFile({ "arena-file": flags.file }, "arena");
576
+ if ((patch === undefined) === (file === undefined))
577
+ throw new CliError(
578
+ 'Usage: admin tuning arena-candidate --patch \'{"rules":{"context-figures":"off"}}\' | --file arena.json [--of <version-id>] [--note "…"]'
579
+ );
580
+ const r = await staffRequest(
581
+ ctx,
582
+ "POST",
583
+ `${T}/arena-files`,
584
+ compact({ patch, file, patch_of: flagStr(flags.of), note: flagStr(flags.note) })
585
+ );
586
+ return json ? printResult(io, flags, r) : io.log(`saved candidate ${r.version.label} (${r.version.id})`);
587
+ }
588
+ case "research-compare": {
589
+ const title = flagStr(flags.title);
590
+ const versionId = flagStr(flags.candidate);
591
+ const patch = jsonFlag(flags.patch, "--patch");
592
+ const nullCheck = flagBool(flags["null-check"]);
593
+ const named = [versionId !== undefined, patch !== undefined, nullCheck === true].filter(Boolean).length;
594
+ if (!title || named !== 1)
595
+ throw new CliError(
596
+ "Usage: admin tuning research-compare --title \"…\" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys k1,k2] [--concurrency 6] [--experiment <id>]"
597
+ );
598
+ const candidate = versionId ? { version_id: versionId } : patch ? { patch } : { null_check: true };
599
+ const r = await staffRequest(
600
+ ctx,
601
+ "POST",
602
+ `${T}/comparisons`,
603
+ compact({
604
+ primitive: "research",
605
+ title,
606
+ candidate,
607
+ keys: flagList(flags.keys),
608
+ concurrency: flagNum(flags.concurrency),
609
+ experiment_id: flagStr(flags.experiment),
610
+ })
611
+ );
612
+ return json ? printResult(io, flags, r) : io.log(`research comparison ${r.comparison?.id ?? ""} started`);
613
+ }
614
+ case "research-inputs": {
615
+ const set = flagStr(flags.set);
616
+ return printResult(
617
+ io,
618
+ { ...flags, json: true },
619
+ await staffRequest(ctx, "GET", `${T}/research/inputs${set ? `?${new URLSearchParams({ set })}` : ""}`)
620
+ );
621
+ }
622
+ case "research-split": {
623
+ const q = new URLSearchParams(
624
+ compact({
625
+ since_hours: flagStr(flags["since-hours"]),
626
+ window_hours: flagStr(flags["window-hours"]),
627
+ at: flagStr(flags.at),
628
+ })
629
+ );
630
+ return printResult(
631
+ io,
632
+ { ...flags, json: true },
633
+ await staffRequest(ctx, "GET", `${T}/research/deploy-split${q.size ? `?${q}` : ""}`)
634
+ );
635
+ }
636
+ case "research-standing":
637
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/research/standing`));
638
+ case "promote-arena": {
639
+ const version = flagStr(flags.version);
640
+ if (!id || !version)
641
+ throw new CliError("Usage: admin tuning promote-arena <experiment-id> --version <arena-version-id>");
642
+ return printResult(
643
+ io,
644
+ { ...flags, json: true },
645
+ await staffRequest(
646
+ ctx,
647
+ "POST",
648
+ `${EXPERIMENTS}/${id}/promote`,
649
+ compact({ arena_version_id: version, actor: actorFrom(flags) })
650
+ )
651
+ );
652
+ }
537
653
  case "promote": {
538
654
  const list = flagList(flags.versions);
539
655
  if (!id || !list?.length)
@@ -557,7 +673,7 @@ async function cmdAdminTuning(ctx) {
557
673
  }
558
674
  default:
559
675
  throw new CliError(
560
- "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|reading-list|promote (see --help)"
676
+ "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|reading-list|promote|arenas|arena|arena-candidate|research-compare|research-inputs|research-split|research-standing|promote-arena (see --help)"
561
677
  );
562
678
  }
563
679
  }
@@ -186,6 +186,33 @@ export const ARGS = {
186
186
  arena_id: { positional: 1, label: "arena-id", required: true },
187
187
  config: { value: (flags) => readJsonFileFlag(flags, "file") },
188
188
  },
189
+ // The arena's research agent as a versioned file (the kernel file's twin): `arena file <id>`
190
+ // prints it, `--file f.json` saves a whole file, `--patch '{…}'` only the fields to change.
191
+ get_arena_file: {
192
+ arena_id: { positional: 0, label: "arena-id", required: true },
193
+ },
194
+ save_arena_file: {
195
+ arena_id: { positional: 0, label: "arena-id", required: true },
196
+ file: { value: (flags) => readJsonFileFlag(flags, "file") },
197
+ patch: { value: (flags) => readJsonObjectFlag(flags, "patch") },
198
+ note: "note",
199
+ },
200
+ list_arena_versions: {
201
+ arena_id: { positional: 0, label: "arena-id", required: true },
202
+ },
203
+ pin_arena_version: {
204
+ arena_id: { positional: 0, label: "arena-id", required: true },
205
+ version_id: {
206
+ value: (_flags, positionals) => {
207
+ const v = positionals[1];
208
+ if (v === undefined) throw new CliError("Missing required <version-id|none>");
209
+ return v === "none" ? null : v;
210
+ },
211
+ },
212
+ },
213
+ accept_arena_offer: {
214
+ arena_id: { positional: 0, label: "arena-id", required: true },
215
+ },
189
216
  };
190
217
 
191
218
  export async function cmdArena(ctx) {
@@ -218,9 +245,17 @@ export async function cmdArena(ctx) {
218
245
  return arenaList(rest);
219
246
  case "endpoint":
220
247
  return arenaEndpoint(rest);
248
+ case "file":
249
+ return arenaFile(rest);
250
+ case "versions":
251
+ return arenaTool(rest, "list_arena_versions");
252
+ case "pin":
253
+ return arenaTool(rest, "pin_arena_version");
254
+ case "offer":
255
+ return rest.flags.accept === true ? arenaTool(rest, "accept_arena_offer") : arenaOffer(rest);
221
256
  default:
222
257
  throw new CliError(
223
- `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, latest, direction, cancel, balance, list, endpoint, roles, role`
258
+ `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, latest, direction, cancel, balance, list, endpoint, file, versions, pin, offer, roles, role`
224
259
  );
225
260
  }
226
261
  }
@@ -386,6 +421,51 @@ async function arenaList(ctx) {
386
421
  * `arena endpoint create|delete|config` — the arena's own CRUD, and its configuration as
387
422
  * one file (the same document `get_arena_config` returns and `set_arena_config` accepts).
388
423
  */
424
+ /**
425
+ * `arena file <arena-id>` prints the file the arena runs (the file itself, so `> f.json` round-trips);
426
+ * `arena file <arena-id> --file f.json | --patch '{…}' [--note …]` saves a new live version of the
427
+ * arena's own copy.
428
+ */
429
+ async function arenaFile(ctx) {
430
+ const { client, flags, io } = ctx;
431
+ const saving =
432
+ flagStr(flags.file) !== undefined ||
433
+ flagStr(flags.patch) !== undefined ||
434
+ flagStr(flags["patch-file"]) !== undefined;
435
+ if (!saving) {
436
+ const result = await client.callTool(
437
+ "get_arena_file",
438
+ argsFromFlags(ARGS.get_arena_file, flags, ctx.positionals)
439
+ );
440
+ printResult(io, flags, result, (r) => JSON.stringify(r?.file ?? r, null, 2));
441
+ return 0;
442
+ }
443
+ const result = await client.callTool(
444
+ "save_arena_file",
445
+ argsFromFlags(ARGS.save_arena_file, flags, ctx.positionals)
446
+ );
447
+ ok(ctx, `Saved ${toolField(result, "version")?.label ?? "a new version"}; the arena runs it from now on.`);
448
+ printResult(io, flags, result);
449
+ return 0;
450
+ }
451
+
452
+ /** `arena offer <arena-id>`: what our newer version would change; `--accept` takes it. */
453
+ async function arenaOffer(ctx) {
454
+ const { client, flags, io } = ctx;
455
+ const result = await client.callTool("get_arena_file", argsFromFlags(ARGS.get_arena_file, flags, ctx.positionals));
456
+ const offer = toolField(result, "offer");
457
+ if (!offer) ok(ctx, "No offer: your arena runs our latest version, or a copy based on it.");
458
+ printResult(io, flags, result, (r) => JSON.stringify(r?.offer ?? null, null, 2));
459
+ return 0;
460
+ }
461
+
462
+ /** One arena-file tool, its arguments from ARGS. */
463
+ async function arenaTool(ctx, tool) {
464
+ const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
465
+ printResult(ctx.io, ctx.flags, result);
466
+ return 0;
467
+ }
468
+
389
469
  async function arenaEndpoint(ctx) {
390
470
  const { client, positionals, flags, io } = ctx;
391
471
  const action = positionals[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",