@letterstory/cli 0.27.0 → 0.28.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 +10 -0
- package/lib/cli.mjs +22 -1
- package/lib/commands/kernel.mjs +6 -0
- package/lib/commands/lede.mjs +225 -0
- package/lib/commands.mjs +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -341,6 +341,16 @@ letterstory arena endpoint create --name "Docs research" --defaults '{"depth":"b
|
|
|
341
341
|
letterstory arena endpoint config <arena-id> [--file cfg.json]
|
|
342
342
|
letterstory arena endpoint delete <arena-id> --yes
|
|
343
343
|
|
|
344
|
+
# Ledes: a prospect's context in, ready-to-send opening lines out (chosen from a
|
|
345
|
+
# hand-approved library, never generated; the top choice is flagged). Synchronous.
|
|
346
|
+
letterstory lede create --name "Clay outbound table"
|
|
347
|
+
letterstory lede run <endpoint-id> --first-name Jane --company Acme --company-url acme.com --years 3
|
|
348
|
+
letterstory lede run <endpoint-id> --email jane@acme.com --tone direct --candidates 3
|
|
349
|
+
letterstory lede run <endpoint-id> --company Acme --default-lede "Came across {{company}} this week."
|
|
350
|
+
letterstory lede runs --limit 20
|
|
351
|
+
letterstory lede balance
|
|
352
|
+
letterstory lede endpoint config <endpoint-id> [--file cfg.json]
|
|
353
|
+
|
|
344
354
|
# 2. Have a kernel write the draft
|
|
345
355
|
letterstory kernel list
|
|
346
356
|
letterstory kernel run --article <uuid> --kernel <uuid> [--brief "…" | --brief-file brief.md]
|
package/lib/cli.mjs
CHANGED
|
@@ -39,12 +39,13 @@ import {
|
|
|
39
39
|
cmdSeers,
|
|
40
40
|
cmdTemplates,
|
|
41
41
|
cmdShredder,
|
|
42
|
+
cmdLede,
|
|
42
43
|
cmdLandings,
|
|
43
44
|
cmdAdmin,
|
|
44
45
|
} from "./commands.mjs";
|
|
45
46
|
|
|
46
47
|
// Keep in sync with cli/package.json.
|
|
47
|
-
export const VERSION = "0.
|
|
48
|
+
export const VERSION = "0.28.0";
|
|
48
49
|
|
|
49
50
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
50
51
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -538,6 +539,24 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
|
|
|
538
539
|
shredder job <job-id> Poll a queued shred and print it when it lands
|
|
539
540
|
shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first
|
|
540
541
|
|
|
542
|
+
Ledes (opening lines for cold email, chosen per prospect from a hand-approved library):
|
|
543
|
+
lede list Your lede endpoints + run counts
|
|
544
|
+
lede create --name <n> New endpoint (returns a callable public id)
|
|
545
|
+
lede delete <endpoint-id> --yes
|
|
546
|
+
lede endpoint config <endpoint-id> [--file cfg.json] Print the endpoint's config, or replace it
|
|
547
|
+
lede run <endpoint-id> [--first-name <n>] [--name <full>] [--email <e>] [--title <t>]
|
|
548
|
+
[--company <c>] [--company-url <url>] [--school <s>] [--city <c>] [--country <c>]
|
|
549
|
+
[--years <n>] [--linkedin <url>] [--notes <text>]
|
|
550
|
+
[--candidates <1..10>] [--tone warm|direct|playful|formal] [--no-enrich]
|
|
551
|
+
Synchronous: prints the candidate lines with the
|
|
552
|
+
top choice flagged. Needs at least one of
|
|
553
|
+
--first-name, --name, --email, --company or
|
|
554
|
+
--company-url. Only a match consumes a lede;
|
|
555
|
+
no_match is free. --no-enrich skips the company
|
|
556
|
+
lookup when latency matters more than fit.
|
|
557
|
+
lede runs [--endpoint <id>] [--limit <n>] Recent lede calls (telemetry), newest first
|
|
558
|
+
lede balance Ledes left on this org
|
|
559
|
+
|
|
541
560
|
Landings (a link + context; auto-mentioned in articles by collections you enable):
|
|
542
561
|
landings list Your landing pages + mention counts
|
|
543
562
|
landings get <landing-id> Show one landing page
|
|
@@ -615,6 +634,8 @@ const CLIENT_COMMANDS = {
|
|
|
615
634
|
seers: cmdSeers,
|
|
616
635
|
templates: cmdTemplates,
|
|
617
636
|
shredder: cmdShredder,
|
|
637
|
+
lede: cmdLede,
|
|
638
|
+
ledes: cmdLede,
|
|
618
639
|
landings: cmdLandings,
|
|
619
640
|
// Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
|
|
620
641
|
admin: cmdAdmin,
|
package/lib/commands/kernel.mjs
CHANGED
|
@@ -92,6 +92,12 @@ export const ARGS = {
|
|
|
92
92
|
// A brief overrides the article's own content as the instruction; --brief-file reads
|
|
93
93
|
// it from a path. Omit both to write from the article as it stands.
|
|
94
94
|
brief: { value: (flags) => flagStr(flags.brief) ?? readBodyInput({ file: flagStr(flags["brief-file"]) }) },
|
|
95
|
+
// How to read --brief: auto (default) treats a short topic as instruction and a pasted
|
|
96
|
+
// draft/article as source material to decompose first. Same flag name as run_kernel's.
|
|
97
|
+
content_kind: {
|
|
98
|
+
flag: "read-as",
|
|
99
|
+
enum: ["auto", "instruction", "document", "article", "outline", "research", "notes"],
|
|
100
|
+
},
|
|
95
101
|
},
|
|
96
102
|
// The standalone door. Two subcommands share this one tool: `write` and `polish` both
|
|
97
103
|
// send `input`, `polish` forcing content_kind to "document" — the map is one, so a field
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// `lede` — create lede endpoints and turn a prospect's context (name, email, company,
|
|
2
|
+
// school, city...) into ready-to-send opening lines for cold email, chosen from a
|
|
3
|
+
// hand-approved library and scored by fit. A run is SYNCHRONOUS: one call returns the
|
|
4
|
+
// candidates in seconds (up to a minute for a company the enrichment has never seen),
|
|
5
|
+
// with the top choice flagged. Only a matched run consumes a lede; no_match is free.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { CliError } from "../client.mjs";
|
|
9
|
+
import { flagStr, requirePositional, printResult, ok, argsFromFlags } from "./shared.mjs";
|
|
10
|
+
|
|
11
|
+
/** `--<flag> <path>` → the file's parsed JSON, or undefined when the flag is not set. */
|
|
12
|
+
function readJsonFileFlag(flags, flag) {
|
|
13
|
+
const path = flagStr(flags[flag]);
|
|
14
|
+
if (path === undefined) return undefined;
|
|
15
|
+
let text;
|
|
16
|
+
try {
|
|
17
|
+
text = readFileSync(path, "utf8");
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new CliError(
|
|
20
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How every command in this file sources its tool's inputs — see `argsFromFlags` in
|
|
32
|
+
* shared.mjs and `scripts/parity-check.ts`, which diffs these keys against each tool's
|
|
33
|
+
* published schema. Every lede tool is mapped: a field added to one of them and
|
|
34
|
+
* forgotten here fails CI.
|
|
35
|
+
*/
|
|
36
|
+
export const ARGS = {
|
|
37
|
+
run_lede: {
|
|
38
|
+
endpoint_id: { positional: 0, label: "endpoint-id", required: true },
|
|
39
|
+
first_name: "first-name",
|
|
40
|
+
last_name: "last-name",
|
|
41
|
+
name: "name",
|
|
42
|
+
email: "email",
|
|
43
|
+
title: "title",
|
|
44
|
+
company: "company",
|
|
45
|
+
company_url: "company-url",
|
|
46
|
+
school: "school",
|
|
47
|
+
city: "city",
|
|
48
|
+
country: "country",
|
|
49
|
+
years_at_company: { flag: "years", type: "num" },
|
|
50
|
+
linkedin_url: "linkedin",
|
|
51
|
+
notes: "notes",
|
|
52
|
+
max_candidates: { flag: "candidates", type: "num" },
|
|
53
|
+
tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
|
|
54
|
+
// Your own fallback line, returned (unbilled, still no_match) when nothing qualifies.
|
|
55
|
+
default_lede: { flag: "default-lede" },
|
|
56
|
+
// Enrichment is ON by default server-side; `--no-enrich` skips it for latency.
|
|
57
|
+
enrich: { value: (flags) => (flags["no-enrich"] === true ? false : undefined) },
|
|
58
|
+
},
|
|
59
|
+
list_lede_runs: {
|
|
60
|
+
endpoint_id: "endpoint",
|
|
61
|
+
limit: { flag: "limit", type: "num" },
|
|
62
|
+
},
|
|
63
|
+
list_lede_endpoints: {},
|
|
64
|
+
get_lede_balance: {},
|
|
65
|
+
create_lede_endpoint: {
|
|
66
|
+
name: { flag: "name", required: true },
|
|
67
|
+
},
|
|
68
|
+
delete_lede_endpoint: {
|
|
69
|
+
endpoint_id: { positional: 1, label: "endpoint-id", required: true },
|
|
70
|
+
},
|
|
71
|
+
// The endpoint's configuration as ONE file: `lede endpoint config <id>` prints it,
|
|
72
|
+
// `lede endpoint config <id> --file cfg.json` replaces it with the file's document.
|
|
73
|
+
get_lede_endpoint_config: {
|
|
74
|
+
endpoint_id: { positional: 1, label: "endpoint-id", required: true },
|
|
75
|
+
},
|
|
76
|
+
set_lede_endpoint_config: {
|
|
77
|
+
endpoint_id: { positional: 1, label: "endpoint-id", required: true },
|
|
78
|
+
config: { value: (flags) => readJsonFileFlag(flags, "file") },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export async function cmdLede(ctx) {
|
|
83
|
+
const sub = ctx.positionals[0];
|
|
84
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
85
|
+
switch (sub) {
|
|
86
|
+
case "list":
|
|
87
|
+
case "ls":
|
|
88
|
+
return ledeList(rest);
|
|
89
|
+
case "new":
|
|
90
|
+
case "create":
|
|
91
|
+
return ledeCreate(rest);
|
|
92
|
+
case "delete":
|
|
93
|
+
case "rm":
|
|
94
|
+
return ledeDelete(rest);
|
|
95
|
+
case "endpoint":
|
|
96
|
+
return ledeEndpoint(rest);
|
|
97
|
+
case "run":
|
|
98
|
+
return ledeRun(rest);
|
|
99
|
+
case "runs":
|
|
100
|
+
case "telemetry":
|
|
101
|
+
return ledeRuns(rest);
|
|
102
|
+
case "balance":
|
|
103
|
+
return ledeBalance(rest);
|
|
104
|
+
default:
|
|
105
|
+
throw new CliError(
|
|
106
|
+
`Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, runs, balance`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function ledeList(ctx) {
|
|
112
|
+
const { client, flags, io } = ctx;
|
|
113
|
+
const result = await client.callTool("list_lede_endpoints", argsFromFlags(ARGS.list_lede_endpoints, flags));
|
|
114
|
+
printResult(io, flags, result);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function ledeCreate(ctx) {
|
|
119
|
+
const { client, flags, positionals, io } = ctx;
|
|
120
|
+
const args = argsFromFlags(ARGS.create_lede_endpoint, flags, positionals);
|
|
121
|
+
const result = await client.callTool("create_lede_endpoint", args);
|
|
122
|
+
ok(ctx, `Created lede endpoint "${args.name}".`);
|
|
123
|
+
printResult(io, flags, result);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function ledeDelete(ctx) {
|
|
128
|
+
const { client, positionals, flags, io } = ctx;
|
|
129
|
+
const id = requirePositional(positionals, 0, "endpoint-id");
|
|
130
|
+
if (!flags.yes) {
|
|
131
|
+
io.error(`Re-run with --yes to confirm: ${ctx.bin} lede delete ${id} --yes`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
// `lede delete <id>` and `lede endpoint delete <id>` are the same act; the ARGS map
|
|
135
|
+
// is written for the latter's positional layout.
|
|
136
|
+
const result = await client.callTool("delete_lede_endpoint", { endpoint_id: id });
|
|
137
|
+
ok(ctx, "Lede endpoint deleted.");
|
|
138
|
+
printResult(io, flags, result);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `lede endpoint config|delete` — the endpoint's own configuration and lifecycle, the
|
|
144
|
+
* same shape `shredder endpoint …` and `kernel endpoint …` have, so the primitives read
|
|
145
|
+
* the same way.
|
|
146
|
+
*/
|
|
147
|
+
async function ledeEndpoint(ctx) {
|
|
148
|
+
const { client, positionals, flags, io } = ctx;
|
|
149
|
+
const action = positionals[0];
|
|
150
|
+
if (action === "delete" || action === "rm") {
|
|
151
|
+
const id = requirePositional(positionals, 1, "endpoint-id");
|
|
152
|
+
if (!flags.yes) {
|
|
153
|
+
io.error(`Re-run with --yes to confirm: ${ctx.bin} lede endpoint delete ${id} --yes`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
const result = await client.callTool(
|
|
157
|
+
"delete_lede_endpoint",
|
|
158
|
+
argsFromFlags(ARGS.delete_lede_endpoint, flags, positionals)
|
|
159
|
+
);
|
|
160
|
+
ok(ctx, "Lede endpoint deleted.");
|
|
161
|
+
printResult(io, flags, result);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (action === "config") {
|
|
165
|
+
const id = requirePositional(positionals, 1, "endpoint-id");
|
|
166
|
+
if (flagStr(flags.file) === undefined) {
|
|
167
|
+
const result = await client.callTool(
|
|
168
|
+
"get_lede_endpoint_config",
|
|
169
|
+
argsFromFlags(ARGS.get_lede_endpoint_config, flags, positionals)
|
|
170
|
+
);
|
|
171
|
+
// The document itself, so `> cfg.json` round-trips into `--file cfg.json`.
|
|
172
|
+
printResult(io, flags, result, (r) => JSON.stringify(r?.config ?? r, null, 2));
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
const args = argsFromFlags(ARGS.set_lede_endpoint_config, flags, positionals);
|
|
176
|
+
const result = await client.callTool("set_lede_endpoint_config", args);
|
|
177
|
+
ok(ctx, `Replaced the configuration of lede endpoint ${id}.`);
|
|
178
|
+
printResult(io, flags, result);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
throw new CliError(`Unknown lede endpoint action: ${action ?? "(none)"}. Try: config, delete`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function ledeRun(ctx) {
|
|
185
|
+
const { client, positionals, flags, io } = ctx;
|
|
186
|
+
const args = argsFromFlags(ARGS.run_lede, flags, positionals);
|
|
187
|
+
const hasAnchor = !!(args.first_name || args.name || args.email || args.company || args.company_url);
|
|
188
|
+
if (!hasAnchor) {
|
|
189
|
+
throw new CliError(
|
|
190
|
+
"Give the prospect at least one of --first-name, --name, --email, --company or --company-url."
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const result = await client.callTool("run_lede", args);
|
|
194
|
+
// The operator ran this to get LINES, so print the lines: the top choice first with
|
|
195
|
+
// its confidence, then the rest. `--json` still gets the whole payload (enrichment,
|
|
196
|
+
// candidate ids for repeat-avoidance), which is what a script piping this wants.
|
|
197
|
+
printResult(io, flags, result, (r) => {
|
|
198
|
+
if (r?.status === "no_match" && !(r?.candidates ?? []).length) {
|
|
199
|
+
return "No line cleared the confidence floor for this person (not charged). More fields usually turn this into a match.";
|
|
200
|
+
}
|
|
201
|
+
const lines = (r?.candidates ?? []).map(
|
|
202
|
+
(c, i) => `${i + 1}. ${c.text} [${Math.round((c.confidence ?? 0) * 100)}%${c.top ? ", top choice" : ""}]`
|
|
203
|
+
);
|
|
204
|
+
const prefix =
|
|
205
|
+
r?.status === "no_match"
|
|
206
|
+
? "No line cleared the confidence floor (not charged); showing your default:\n"
|
|
207
|
+
: "";
|
|
208
|
+
return prefix + (lines.join("\n") || JSON.stringify(r, null, 2));
|
|
209
|
+
});
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function ledeRuns(ctx) {
|
|
214
|
+
const { client, flags, io } = ctx;
|
|
215
|
+
const result = await client.callTool("list_lede_runs", argsFromFlags(ARGS.list_lede_runs, flags));
|
|
216
|
+
printResult(io, flags, result);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function ledeBalance(ctx) {
|
|
221
|
+
const { client, flags, io } = ctx;
|
|
222
|
+
const result = await client.callTool("get_lede_balance", argsFromFlags(ARGS.get_lede_balance, flags));
|
|
223
|
+
printResult(io, flags, result);
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
package/lib/commands.mjs
CHANGED
|
@@ -23,6 +23,7 @@ export * from "./commands/arena.mjs";
|
|
|
23
23
|
export * from "./commands/templates.mjs";
|
|
24
24
|
export * from "./commands/autopen.mjs";
|
|
25
25
|
export * from "./commands/kernel.mjs";
|
|
26
|
+
export * from "./commands/lede.mjs";
|
|
26
27
|
export * from "./commands/phantom-job.mjs";
|
|
27
28
|
export * from "./commands/onboard.mjs";
|
|
28
29
|
export * from "./commands/seers.mjs";
|