@letterstory/cli 0.40.0 → 0.41.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
@@ -367,6 +367,32 @@ The ledger behind Kernel Tuning's Experiments tab
367
367
  (docs/architecture/kernel-experiment-agent.md): every question asked of the kernels,
368
368
  how it was tested, and what we learned and why.
369
369
 
370
+ ### Staff: the CRM pipeline
371
+
372
+ ```bash
373
+ # Needs a STAFF key with crm:read (reads) and crm:write (writes). The same routes the
374
+ # crm.letter.dev front end calls with a staff JWT; any /api/superadmin/crm/* route takes
375
+ # the key as x-staff-api-key (GET → crm:read, anything else → crm:write).
376
+ export LETTERSTORY_STAFF_API_KEY=<your staff key with crm:read,crm:write>
377
+
378
+ letterstory admin crm signals --status new # the Inbound feed (unmatched signals)
379
+ letterstory admin crm record --source bookface --kind message \
380
+ --title "Asked about phantoms on Bookface" --name "Jane Doe" --company Acme --domain acme.com
381
+ letterstory admin crm record --source twitter --kind social_engagement \
382
+ --title "Liked our launch tweet" --name "Pat" --linkedin https://linkedin.com/in/pat
383
+ letterstory admin crm signal <signal-id> qualify # link/create the account
384
+ letterstory admin crm signal <signal-id> dismiss # the easy delete (kept, marked)
385
+ letterstory admin crm accounts --stage demo_scheduled # with likelihood
386
+ letterstory admin crm score <account-id> # recompute; prints the factors
387
+ ```
388
+
389
+ Every trigger — Cal.com bookings, Circleback recordings, Letterhome dinner RSVPs, RB2B
390
+ visits, and what an agent records here — goes through one intake (`ingestSignal`,
391
+ src/lib/crm/signals-intake.ts), so an agent's signal dedupes against a webhook's exactly
392
+ as two webhooks do. Qualified kinds (booking, call recording, dinner, message, form) link
393
+ or create the account; unqualified ones (a visit, a like) only attach to an existing
394
+ account and otherwise wait in the Inbound feed.
395
+
370
396
  ### Propose, then onboard a phantom
371
397
 
372
398
  The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
package/lib/cli.mjs CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  } from "./commands.mjs";
53
53
 
54
54
  // Keep in sync with cli/package.json.
55
- export const VERSION = "0.40.0";
55
+ export const VERSION = "0.41.0";
56
56
 
57
57
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
58
58
  // can't accidentally swallow the id as --json's value.
@@ -556,6 +556,16 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
556
556
  session (a trial per arm), linked to its
557
557
  ledger entry. Written by \`npm run lab --
558
558
  publish\` in the kernels repo.
559
+ admin crm signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
560
+ | record --title "…" [--source bookface] [--kind other] [--intent qualified]
561
+ [--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
562
+ | signal <id> qualify|dismiss|link [--account <id>]
563
+ | accounts [--stage lead] [--q acme] | account <id> | score <account-id>
564
+ The staff CRM's pipeline: the inbound signal
565
+ feed, recording a signal with no webhook
566
+ (Bookface, a tweet like), qualifying or
567
+ dismissing one, and the explained score.
568
+ Needs crm:read (reads) / crm:write (writes).
559
569
  admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
560
570
  the pipeline as deployed, levers, measures,
561
571
  goals, related history.
@@ -7,6 +7,7 @@
7
7
  // admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
8
8
  // admin tuning-context [--q "<question>"] (tuning:experiment)
9
9
  // admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
10
+ // admin crm signals|record|signal|accounts|account|score … (crm:read / crm:write)
10
11
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
11
12
  // [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
12
13
  // [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
@@ -410,14 +411,118 @@ async function cmdAdminTuningContext(ctx) {
410
411
  );
411
412
  }
412
413
 
414
+ /* ------------------------------------------------------------ the staff CRM */
415
+
416
+ const CRM = "/api/superadmin/crm";
417
+
418
+ function signalLine(s) {
419
+ const who = [s.personName, s.companyName ?? s.companyDomain].filter(Boolean).join(" · ");
420
+ return `${s.id} [${s.status} · ${s.intent}] ${s.occurredAt.slice(0, 10)} ${s.source}/${s.kind} ${s.title}${who ? `\n ${who}` : ""}`;
421
+ }
422
+
423
+ function accountLine(a) {
424
+ const score = a.likelihood == null ? "—" : a.likelihood.toFixed(2);
425
+ return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
426
+ }
427
+
428
+ /**
429
+ * `admin crm …` — the headless half of the staff CRM, so a background agent can watch the
430
+ * pipeline and record the signals that have no webhook (Bookface, tweet likes, DMs).
431
+ * Reads need `crm:read` on the staff key, writes `crm:write`. Every write goes through the
432
+ * same ingestSignal / actOnSignal the webhooks and the app use.
433
+ */
434
+ async function cmdAdminCrm(ctx) {
435
+ const [sub, id, action] = ctx.positionals;
436
+ const { flags, io } = ctx;
437
+ const json = flagBool(flags.json);
438
+ const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
439
+ switch (sub) {
440
+ case "signals": {
441
+ const params = new URLSearchParams(
442
+ compact({
443
+ status: flagStr(flags.status),
444
+ intent: flagStr(flags.intent),
445
+ source: flagStr(flags.source),
446
+ account_id: flagStr(flags.account),
447
+ limit: flagStr(flags.limit),
448
+ })
449
+ );
450
+ return out(await staffRequest(ctx, "GET", `${CRM}/signals?${params}`), (r) =>
451
+ r.signals.length ? r.signals.map(signalLine).join("\n") : "(no signals)"
452
+ );
453
+ }
454
+ case "record": {
455
+ const body = compact({
456
+ source: flagStr(flags.source) ?? "agent",
457
+ kind: flagStr(flags.kind) ?? "other",
458
+ intent: flagStr(flags.intent),
459
+ title: flagStr(flags.title),
460
+ person_name: flagStr(flags.name),
461
+ person_email: flagStr(flags.email),
462
+ person_title: flagStr(flags["person-title"]),
463
+ linkedin_url: flagStr(flags.linkedin),
464
+ company_name: flagStr(flags.company),
465
+ company_domain: flagStr(flags.domain),
466
+ detail: flagStr(flags.detail),
467
+ url: flagStr(flags.link),
468
+ external_id: flagStr(flags["external-id"]),
469
+ occurred_at: flagStr(flags.at),
470
+ account_id: flagStr(flags.account),
471
+ });
472
+ if (!body.title)
473
+ throw new CliError(
474
+ 'Usage: admin crm record --title "Liked our launch tweet" [--source twitter] [--kind social_engagement] …'
475
+ );
476
+ const r = await staffPost(ctx, `${CRM}/signals`, body);
477
+ return out(r, (x) => `${x.duplicate ? "already recorded" : "recorded"}: ${signalLine(x.signal)}`);
478
+ }
479
+ case "signal": {
480
+ if (!id || !["qualify", "dismiss", "link"].includes(action)) {
481
+ throw new CliError("Usage: admin crm signal <id> qualify|dismiss|link [--account <account-id>]");
482
+ }
483
+ const r = await staffRequest(
484
+ ctx,
485
+ "PATCH",
486
+ `${CRM}/signals/${id}`,
487
+ compact({ action, account_id: flagStr(flags.account) })
488
+ );
489
+ return out(r, (x) => signalLine(x.signal));
490
+ }
491
+ case "accounts": {
492
+ const params = new URLSearchParams(compact({ stage: flagStr(flags.stage), q: flagStr(flags.q) }));
493
+ return out(await staffRequest(ctx, "GET", `${CRM}/accounts?${params}`), (r) =>
494
+ r.accounts.length ? r.accounts.map(accountLine).join("\n") : "(no accounts)"
495
+ );
496
+ }
497
+ case "account":
498
+ if (!id) throw new CliError("Usage: admin crm account <id>");
499
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${CRM}/accounts/${id}`));
500
+ case "score": {
501
+ if (!id) throw new CliError("Usage: admin crm score <account-id>");
502
+ const r = await staffPost(ctx, `${CRM}/accounts/${id}/score`, {});
503
+ return out(r, (x) =>
504
+ [
505
+ accountLine(x.account),
506
+ ...(x.account.scoreFactors ?? []).map(
507
+ (f) => ` ${f.direction === "risk" ? "−" : "+"}${f.weight.toFixed(2)} ${f.label}`
508
+ ),
509
+ ].join("\n")
510
+ );
511
+ }
512
+ default:
513
+ throw new CliError("Usage: admin crm signals|record|signal|accounts|account|score … (see --help)");
514
+ }
515
+ }
516
+
413
517
  export async function cmdAdmin(ctx) {
414
518
  const sub = ctx.positionals[0];
519
+ if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
415
520
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
416
521
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
417
522
  if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
418
523
  if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
419
524
  if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
420
525
  throw new CliError(
421
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin tuning-context (see --help)'
526
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin tuning-context (see --help)'
422
527
  );
423
528
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",