@letterstory/cli 0.87.1 → 0.89.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.87.1";
60
+ export const VERSION = "0.89.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
@@ -639,6 +639,12 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
639
639
  read-only SQL across every org; --sql prints
640
640
  the queries behind the answer. Needs the
641
641
  fleet:query capability on the staff key.
642
+ admin brand-detection <org-id> [--term <t>]... [--exact <t>]... [--exclude <phrase>]... [--note "…"] | --clear [--note "…"]
643
+ How a client's brand is detected in AI answers, with
644
+ every earlier setting; with flags, set the exact set
645
+ (fleet:instrument) — e.g. --term Context.dev for a name
646
+ that is also a word. --clear goes back to inferred,
647
+ recorded as your decision.
642
648
  admin replays [--org <uuid>] [--target <uuid>] [--status captured|not_reproduced|queued|running|unsupported|failed] [--limit <n>]
643
649
  Live answer replays: breakthroughs re-asked in ChatGPT /
644
650
  Gemini / Google / Perplexity / Claude, with screenshots.
@@ -1037,6 +1043,7 @@ Insights:
1037
1043
  insights competitors [--period 14d|30d|90d] GEO → Competitors: share of mentions per brand, is it moving, by engine/question
1038
1044
  insights question <key> [--period 14d|30d|90d] [--filter all|named_you|rival_not_you|no_one] [--limit n]
1039
1045
  One By-question row: the wordings asked and the AI answers
1046
+ insights detection How your brand is detected in AI answers (terms, who set them)
1040
1047
  insights site [--period 14d|30d|90d] [--collection <uuid>]
1041
1048
  insights post <article-id> [--period 14d|30d|90d]
1042
1049
  insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
@@ -15,6 +15,8 @@
15
15
  // admin fleet-experiments list|articles|clients|mark|unmark|new|assign|assign-eligible|assign-orgs|spin-up|apply|start|stop|decide … (fleet:read / fleet:experiment)
16
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)
17
17
  // admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
18
+ // admin brand-detection <org-id> (fleet:read)
19
+ // admin brand-detection <org-id> [--term t]... [--exact t]... [--exclude p]... [--note "…"] | --clear [--note "…"] (fleet:instrument)
18
20
  // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
19
21
  // admin replay --org <id> [<response-id> | --target <id>] [--surface …] (fleet:replay)
20
22
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
@@ -41,7 +43,7 @@
41
43
 
42
44
  import { readFileSync } from "node:fs";
43
45
  import { CliError, LetterstoryClient } from "../client.mjs";
44
- import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok } from "./shared.mjs";
46
+ import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok, requirePositional } from "./shared.mjs";
45
47
  import { watchDomainJob } from "./onboard.mjs";
46
48
 
47
49
  const SEER_MODES = ["suggest", "skip"];
@@ -453,12 +455,12 @@ async function cmdAdminTuningContext(ctx) {
453
455
  * admin tuning loop where the loop stands: what waits at each station, and where to go next
454
456
  * admin tuning ledger <comparison-id> the defect ledger: per arm, what the diagnosis read named, the floor and the ceiling, the candidate's next arm; then the reader edits per arm by lever, and the schema gaps
455
457
  * admin tuning checklist the checklist: every defect category with its status, the picks' agreement (n, agreement, suggestion) and its labels (+/−) (alias: evals)
456
- * admin tuning checklist-add --id … --label … --how … --layer … --lever … --locality local|structural --axis … [--example "span"] add a category (starts unvetted) (alias: eval-add)
457
- * admin tuning checklist-set <id> [--status confirmed|rejected|unvetted] [--example "span" [--fix "…"]] confirm / reject (a person's call), or add an example (alias: eval-set)
458
+ * admin tuning checklist-add --id … --label … --looks-like "…" --how "…" --layer … --lever … [--paths voice,…] --locality local|structural --axis … [--example "span"] add a category (starts unvetted; an id already on the list is refused); --looks-like is the defect as the judge reads it, --how the fix, --paths what a bake-off merge copies (alias: eval-add)
459
+ * admin tuning checklist-set <id> [--status confirmed|rejected|unvetted] [--example "span" [--fix "…"]] confirm / reject (a person's call, recorded as the key's owner), or add an example (alias: eval-set)
458
460
  * admin tuning checklist-labels <id> the labelled set of one category: positives (examples + feedback filed into it), negatives (rejected feedback suggested for it), grid overrules
459
461
  * admin tuning feedback [--status proposed|captured|rejected] the feedback: spans readers marked in a pair's text with their words, each with its disposition (alias: proposals)
460
462
  * admin tuning feedback-add --span "…" --comment "…" [--fix "…"] [--comparison <id> --cell <key> --side control|candidate] [--kind narrow|wide] add feedback (no --side from a blind read); --fix is the reader's rewrite, classified and a disposition suggested (alias: propose-eval)
461
- * admin tuning feedback-file <id> --as checklist --id … --label … --how … --layer … --lever … --locality local|structural --axis … file it on the checklist, the span (and its fix) as the category's example (appends when the id exists) (alias: capture-eval; --as category)
463
+ * admin tuning feedback-file <id> --as checklist --id … --label … --looks-like "…" --how "…" --layer … --lever … [--paths …] --locality local|structural --axis … file it on the checklist, the span (and its fix) as the category's example (appends when the id exists) (alias: capture-eval; --as category)
462
464
  * admin tuning feedback-file <id> --as rule file it as a rule; the rule itself is written in the kernels rule lab
463
465
  * admin tuning feedback-file <id> --as config [--note "…"] file it as a lever this candidate should pull
464
466
  * admin tuning feedback-file <id> --as implementation [--note "…"] file it as a schema gap no setting can express, for the kernels repo (--as lever still works)
@@ -516,7 +518,9 @@ async function cmdAdminTuningContext(ctx) {
516
518
  *
517
519
  * The bake-off: a screen's versions graded item by item against the checklist's confirmed and
518
520
  * unvetted categories and the craft axes commitment / economy / movement, the winner
519
- * per item tallied across the inputs, and a merge that composes the best of each into one candidate.
521
+ * per item tallied across the inputs, and a merge that composes the best of each into one candidate:
522
+ * each kernel-file path takes the value the most trusted wins stand behind (a person-confirmed
523
+ * category or an axis); versions on another writer take no part.
520
524
  * Deterministic; a person overrides a grade and confirms the merge, which is a hypothesis until the
521
525
  * eye test confirms it.
522
526
  * admin tuning bakeoff <screen-id> [--regrade] the winners table: item, trusted, leader, wins per version, graded; then the conflicts a merge would meet
@@ -614,7 +618,7 @@ function printProgramme(io, r) {
614
618
  for (const row of sm.table ?? [])
615
619
  if (row.leader !== null && row.leader !== sm.base)
616
620
  io.log(
617
- ` ${row.item.id.padEnd(32)} ${row.trusted ? "trusted " : "unvetted "} v${row.leader} ${Object.entries(
621
+ ` ${row.item.id.padEnd(32)} ${row.trusted ? "trusted " : row.item.status === "confirmed" ? "undecided" : "unvetted "} v${row.leader} ${Object.entries(
618
622
  row.wins
619
623
  )
620
624
  .map(([v, n]) => `v${v}:${n}`)
@@ -806,6 +810,7 @@ async function cmdAdminTuning(ctx) {
806
810
  if (json) return printResult(io, flags, r);
807
811
  const rows = r.categories ?? [];
808
812
  if (!rows.length) return io.log("(no categories)");
813
+ io.log('("confirmed?" = confirmed by nobody on record, a seeded status: graded, never merged)');
809
814
  const cell = (v, w) => String(v ?? "—").padEnd(w);
810
815
  io.log(
811
816
  `${cell("id", 24)} ${cell("status", 10)} ${cell("layer", 10)} ${cell("lever", 30)} ${"n".padStart(4)} ${"agree".padStart(6)} ${cell("suggest", 10)} ${"+".padStart(3)} ${"−".padStart(3)}`
@@ -813,8 +818,10 @@ async function cmdAdminTuning(ctx) {
813
818
  for (const c of rows) {
814
819
  const v = c.vetting ?? {};
815
820
  const l = c.labels ?? { positives: (c.examples ?? []).length, negatives: 0 };
821
+ // A confirmed status nobody is on record as deciding is a seeded one: the bake-off does not merge on it.
822
+ const status = c.status === "confirmed" && !c.decided_by ? "confirmed?" : c.status;
816
823
  io.log(
817
- `${cell(c.id, 24)} ${cell(c.status, 10)} ${cell(c.layer, 10)} ${cell(c.lever || "—", 30)} ${String(v.n ?? 0).padStart(4)} ${String(v.agreement ?? "—").padStart(6)} ${cell(v.suggestion, 10)} ${String(l.positives).padStart(3)} ${String(l.negatives).padStart(3)}`
824
+ `${cell(c.id, 24)} ${cell(status, 10)} ${cell(c.layer, 10)} ${cell(c.lever || "—", 30)} ${String(v.n ?? 0).padStart(4)} ${String(v.agreement ?? "—").padStart(6)} ${cell(v.suggestion, 10)} ${String(l.positives).padStart(3)} ${String(l.negatives).padStart(3)}${(c.paths ?? []).length ? "" : " (no path)"}`
818
825
  );
819
826
  }
820
827
  return;
@@ -832,30 +839,17 @@ async function cmdAdminTuning(ctx) {
832
839
  }
833
840
  case "checklist-add":
834
841
  case "eval-add": {
835
- const body = {
836
- id: flagStr(flags.id),
837
- label: flagStr(flags.label),
838
- how: flagStr(flags.how),
839
- layer: flagStr(flags.layer),
840
- lever: flagStr(flags.lever) ?? "",
841
- locality: flagStr(flags.locality),
842
- axis: flagStr(flags.axis),
843
- };
842
+ const body = checklistWords(flags);
844
843
  const example = flagStr(flags.example);
845
- if (
846
- !body.id ||
847
- !body.label ||
848
- !body.how ||
849
- !body.layer ||
850
- !body.axis ||
851
- !["local", "structural"].includes(body.locality ?? "")
852
- )
853
- throw new CliError(
854
- 'Usage: admin tuning checklist-add --id … --label … --how … --layer … --lever … --locality local|structural --axis … [--example "span"]'
855
- );
844
+ if (!body)
845
+ throw new CliError(`Usage: admin tuning checklist-add ${CHECKLIST_WORDS_USAGE} [--example "span"]`);
856
846
  if (example) body.examples = [{ span: example, ...(flags.fix ? { fix: flagStr(flags.fix) } : {}) }];
857
847
  const r = await staffRequest(ctx, "POST", `${T}/evals`, body);
858
- return json ? printResult(io, flags, r) : io.log(`added ${r.category.id} (${r.category.status})`);
848
+ if (json) return printResult(io, flags, r);
849
+ io.log(`added ${r.category.id} (${r.category.status})`);
850
+ if (!(r.category.paths ?? []).length)
851
+ io.log(" no --paths: a bake-off grades this item but can never merge on it");
852
+ return;
859
853
  }
860
854
  case "checklist-set":
861
855
  case "eval-set": {
@@ -877,7 +871,9 @@ async function cmdAdminTuning(ctx) {
877
871
  const r = await staffRequest(ctx, "PATCH", `${T}/evals/${encodeURIComponent(id)}`, patch);
878
872
  return json
879
873
  ? printResult(io, flags, r)
880
- : io.log(`${r.category.id}: ${r.category.status}, ${(r.category.examples ?? []).length} examples`);
874
+ : io.log(
875
+ `${r.category.id}: ${r.category.status}${r.category.decided_by ? ` (by ${r.category.decided_by})` : ""}, ${(r.category.examples ?? []).length} examples`
876
+ );
881
877
  }
882
878
  case "feedback":
883
879
  case "proposals": {
@@ -946,32 +942,15 @@ async function cmdAdminTuning(ctx) {
946
942
  const AS_ALIAS = { category: "checklist", lever: "implementation" };
947
943
  const asGiven = flagStr(flags.as);
948
944
  const as = AS_ALIAS[asGiven] ?? asGiven;
949
- const usage =
950
- 'Usage: admin tuning feedback-file <id> --as checklist --id … --label … --how … --layer … --lever … --locality local|structural --axis … | --as rule | --as config|implementation|material [--note "…"] | --as note';
945
+ const usage = `Usage: admin tuning feedback-file <id> --as checklist ${CHECKLIST_WORDS_USAGE} | --as rule | --as config|implementation|material [--note "…"] | --as note`;
951
946
  if (!id || !["checklist", "rule", "config", "implementation", "material", "note"].includes(as ?? ""))
952
947
  throw new CliError(usage);
953
948
  let body = { as };
954
949
  if (as === "config" || as === "implementation" || as === "material")
955
950
  body = compact({ as, note: flagStr(flags.note) });
956
951
  if (as === "checklist") {
957
- const category = {
958
- id: flagStr(flags.id),
959
- label: flagStr(flags.label),
960
- how: flagStr(flags.how),
961
- layer: flagStr(flags.layer),
962
- lever: flagStr(flags.lever) ?? "",
963
- locality: flagStr(flags.locality),
964
- axis: flagStr(flags.axis),
965
- };
966
- if (
967
- !category.id ||
968
- !category.label ||
969
- !category.how ||
970
- !category.layer ||
971
- !category.axis ||
972
- !["local", "structural"].includes(category.locality ?? "")
973
- )
974
- throw new CliError(usage);
952
+ const category = checklistWords(flags);
953
+ if (!category) throw new CliError(usage);
975
954
  body = { as, category };
976
955
  }
977
956
  const r = await staffRequest(ctx, "POST", `${T}/proposals/${id}/capture`, body);
@@ -1771,7 +1750,11 @@ async function cmdAdminTuning(ctx) {
1771
1750
  `merged from v${r.base} → candidate v${r.version}${r.comparison_id ? ` · comparison ${r.comparison_id}` : ""}`
1772
1751
  );
1773
1752
  for (const p of r.provenance)
1774
- io.log(` ${p.applied ? "applied" : `skipped (${p.skipped})`} ${p.item} ${p.path} from v${p.from}`);
1753
+ io.log(
1754
+ ` ${p.applied ? (p.from === r.base ? "kept (the base's, back on top of a wider copy)" : "applied") : `skipped (${p.skipped})`} ${p.item} ${p.path} from v${p.from}`
1755
+ );
1756
+ for (const e of r.excluded ?? [])
1757
+ io.log(` left out v${e.version}: runs on ${e.writer}, not the base's writer`);
1775
1758
  if (r.conflicts.length)
1776
1759
  io.log(
1777
1760
  `conflicts left (the base's value kept): ${r.conflicts.map((c) => `${c.path} ← ${c.candidates.map((x) => `${x.item} v${x.version}`).join(" | ")}`).join("; ")}`
@@ -3347,6 +3330,43 @@ async function cmdAdminSpend(ctx) {
3347
3330
  }
3348
3331
  }
3349
3332
 
3333
+ /**
3334
+ * A checklist item's words from the flags (checklist-add, feedback-file --as checklist): the
3335
+ * service's add schema — `--looks-like` is the defect as the judge reads it, `--how` the fix,
3336
+ * `--paths` the lever as kernel-file paths (comma-separated) so a bake-off can merge on it.
3337
+ * Null when a required word is missing.
3338
+ */
3339
+ function checklistWords(flags) {
3340
+ const paths = (flagStr(flags.paths) ?? "")
3341
+ .split(",")
3342
+ .map((p) => p.trim())
3343
+ .filter(Boolean);
3344
+ const words = {
3345
+ id: flagStr(flags.id),
3346
+ label: flagStr(flags.label),
3347
+ looks_like: flagStr(flags["looks-like"]),
3348
+ how: flagStr(flags.how),
3349
+ layer: flagStr(flags.layer),
3350
+ lever: flagStr(flags.lever),
3351
+ locality: flagStr(flags.locality),
3352
+ axis: flagStr(flags.axis),
3353
+ ...(paths.length ? { paths } : {}),
3354
+ };
3355
+ const complete =
3356
+ words.id &&
3357
+ words.label &&
3358
+ words.looks_like &&
3359
+ words.how &&
3360
+ words.layer &&
3361
+ words.lever &&
3362
+ words.axis &&
3363
+ ["local", "structural"].includes(words.locality ?? "");
3364
+ return complete ? words : null;
3365
+ }
3366
+
3367
+ const CHECKLIST_WORDS_USAGE =
3368
+ '--id … --label … --looks-like "what the defect looks like" --how "the fix" --layer writer|frame|voice|economy|tells|length|material --lever … [--paths \'voice,rules["<id>"]\'] --locality local|structural --axis specificity|commitment|economy|movement|none';
3369
+
3350
3370
  const FLEET_EXPERIMENTS = "/api/superadmin/experiments";
3351
3371
 
3352
3372
  function pct(rate) {
@@ -3562,7 +3582,65 @@ export async function cmdAdmin(ctx) {
3562
3582
  if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
3563
3583
  if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
3564
3584
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
3585
+ if (sub === "brand-detection") return cmdAdminBrandDetection({ ...ctx, positionals: ctx.positionals.slice(1) });
3565
3586
  throw new CliError(
3566
- '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)'
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)'
3567
3588
  );
3568
3589
  }
3590
+
3591
+ // `admin brand-detection <org-id>` — how the client's brand is detected in AI answers;
3592
+ // with --term / --exact / --exclude it SETS the exact set (replacing the inferred one),
3593
+ // --clear goes back to inferred (recorded as a decision, with --note as the why). Every
3594
+ // earlier setting is kept in `history`. The client's answers are recounted against the new set.
3595
+ async function cmdAdminBrandDetection(ctx) {
3596
+ const { flags, positionals, io } = ctx;
3597
+ const orgId = requirePositional(positionals, 0, "org-id");
3598
+ const path = `/api/superadmin/fleet/mentions/${encodeURIComponent(orgId)}/detection`;
3599
+ const terms = flagList(flags.term);
3600
+ const exact = flagList(flags.exact);
3601
+ const exclude = flagList(flags.exclude);
3602
+ let result;
3603
+ if (flags.clear === true)
3604
+ result = await staffRequest(ctx, "PUT", path, {
3605
+ clear: true,
3606
+ ...(flagStr(flags.note) !== undefined ? { note: flagStr(flags.note) } : {}),
3607
+ });
3608
+ else if (terms.length || exact.length || exclude.length || flagStr(flags.note) !== undefined) {
3609
+ if (!terms.length && !exact.length) throw new CliError("Give at least one --term or --exact (or --clear).");
3610
+ result = await staffRequest(ctx, "PUT", path, {
3611
+ terms,
3612
+ exact_case_terms: exact,
3613
+ exclude_phrases: exclude,
3614
+ ...(flagStr(flags.note) !== undefined ? { note: flagStr(flags.note) } : {}),
3615
+ });
3616
+ } else result = await staffRequest(ctx, "GET", path);
3617
+ printResult(io, flags, result, (r) => {
3618
+ const e = r.effective;
3619
+ const state = r.override
3620
+ ? `exact set, by ${r.override.setBy ?? "?"} at ${r.override.setAt ?? "?"}`
3621
+ : r.cleared
3622
+ ? `inferred (exact set cleared by ${r.cleared.by ?? "?"} at ${r.cleared.at ?? "?"})`
3623
+ : "inferred";
3624
+ const lines = [
3625
+ `${r.orgName}: ${state}`,
3626
+ ` terms (any case): ${e.terms.join(", ") || "—"}`,
3627
+ ` exact case: ${e.exactCaseTerms.join(", ") || "—"}`,
3628
+ ` excluded phrases: ${e.excludePhrases.join(", ") || "—"}`,
3629
+ ];
3630
+ const why = r.override?.note ?? r.cleared?.note;
3631
+ if (why) lines.push(` why: ${why}`);
3632
+ if (r.override) lines.push(` inferred would be: ${r.inferred.terms.join(", ")}`);
3633
+ for (const h of r.history ?? []) {
3634
+ const set = [
3635
+ h.terms.length ? `terms ${h.terms.join(", ")}` : "",
3636
+ h.exactCaseTerms.length ? `exact ${h.exactCaseTerms.join(", ")}` : "",
3637
+ h.excludePhrases.length ? `excludes ${h.excludePhrases.join(", ")}` : "",
3638
+ ].filter(Boolean);
3639
+ lines.push(
3640
+ ` earlier: ${h.action} by ${h.by ?? "?"} at ${h.at ?? "?"}${set.length ? ` — ${set.join("; ")}` : ""}${h.note ? ` (${h.note})` : ""}`
3641
+ );
3642
+ }
3643
+ return lines.join("\n");
3644
+ });
3645
+ return 0;
3646
+ }
@@ -27,6 +27,7 @@ export const ARGS = {
27
27
  get_share_of_voice_detail: {
28
28
  period: { flag: "period", enum: PERIODS },
29
29
  },
30
+ get_brand_detection: {},
30
31
  get_share_of_voice_question: {
31
32
  question_key: { positional: 0, label: "question-key", required: true },
32
33
  period: { flag: "period", enum: PERIODS },
@@ -55,9 +56,11 @@ export async function cmdInsights(ctx) {
55
56
  return call(rest, "get_share_of_voice_detail");
56
57
  case "question":
57
58
  return call(rest, "get_share_of_voice_question");
59
+ case "detection":
60
+ return call(rest, "get_brand_detection");
58
61
  default:
59
62
  throw new CliError(
60
- `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, competitors, question, site, post, top, bank, access`
63
+ `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, competitors, question, detection, site, post, top, bank, access`
61
64
  );
62
65
  }
63
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.87.1",
3
+ "version": "0.89.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",