@letterstory/cli 0.25.1 → 0.25.3
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 +1 -1
- package/lib/commands/kernel.mjs +34 -27
- package/lib/commands/shredder.mjs +128 -22
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
} from "./commands.mjs";
|
|
45
45
|
|
|
46
46
|
// Keep in sync with cli/package.json.
|
|
47
|
-
export const VERSION = "0.25.
|
|
47
|
+
export const VERSION = "0.25.3";
|
|
48
48
|
|
|
49
49
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
50
50
|
// can't accidentally swallow the id as --json's value.
|
package/lib/commands/kernel.mjs
CHANGED
|
@@ -78,28 +78,32 @@ export const ARGS = {
|
|
|
78
78
|
// it from a path. Omit both to write from the article as it stands.
|
|
79
79
|
brief: { value: (flags) => flagStr(flags.brief) ?? readBodyInput({ file: flagStr(flags["brief-file"]) }) },
|
|
80
80
|
},
|
|
81
|
-
// The standalone door. Two subcommands share this one tool: `write`
|
|
82
|
-
// `polish`
|
|
83
|
-
// BOTH subcommands must learn (or knowingly gap) — that is the
|
|
81
|
+
// The standalone door. Two subcommands share this one tool: `write` and `polish` both
|
|
82
|
+
// send `input`, `polish` forcing content_kind to "document" — the map is one, so a field
|
|
83
|
+
// the tool gains is a field BOTH subcommands must learn (or knowingly gap) — that is the
|
|
84
|
+
// ratchet.
|
|
84
85
|
run_kernel: {
|
|
85
86
|
endpoint_id: { positional: 0, label: "endpoint-id", required: true },
|
|
86
|
-
|
|
87
|
-
document: { value: (flags, _p, extra) => (extra.door === "document" ? readBodyInput(flags) : undefined) },
|
|
88
|
-
// Documented dead field on the tool; accepted so an old --format still validates.
|
|
89
|
-
format: { flag: "format", enum: ["auto", "html", "markdown"] },
|
|
87
|
+
input: { value: (flags) => readBodyInput(flags) },
|
|
90
88
|
kernel_id: "kernel",
|
|
91
89
|
target_word_count: { flag: "words", type: "num" },
|
|
92
|
-
|
|
90
|
+
label: "label",
|
|
93
91
|
// The flag is --read-as, which is what shipped; an earlier draft called it --brief-mode.
|
|
94
|
-
|
|
92
|
+
content_kind: {
|
|
93
|
+
flag: "read-as",
|
|
94
|
+
enum: ["auto", "instruction", "document", "article", "outline", "research", "notes"],
|
|
95
|
+
},
|
|
95
96
|
// The two products, in one run: every round is shredded before the kernel's jury sees
|
|
96
97
|
// it, so the verdict lands on the text that actually ships. Costs a shred as well as a
|
|
97
98
|
// run.
|
|
98
99
|
shredder_endpoint_id: "shredder",
|
|
99
100
|
shred_aggression: { flag: "shred-aggression", enum: ["standard", "high", "max"] },
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
voice: {
|
|
102
|
+
object: {
|
|
103
|
+
person: { flag: "person", enum: ["third", "first_plural", "first_singular"] },
|
|
104
|
+
allow_experience_claims: { flag: "experience-claims", type: "bool" },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
103
107
|
// The mechanism's full contract, declared on the door (2026-09-17, Kernels wave step 1).
|
|
104
108
|
// A shape and a typed brief are files, not flags — `--skeleton-file shape.json` is the
|
|
105
109
|
// field a template resolves to, sent by a caller who has their own.
|
|
@@ -123,12 +127,13 @@ export const ARGS = {
|
|
|
123
127
|
evidence: { value: (flags) => readTextFileFlag(flags, "shred-evidence-file") },
|
|
124
128
|
},
|
|
125
129
|
},
|
|
126
|
-
// Named-entity treatment (2026-09-17): a list, not a scalar flag,
|
|
127
|
-
// skeleton/brief_json — `--entities-file entities.json` of
|
|
128
|
-
// "
|
|
130
|
+
// Named-entity treatment (2026-09-17, renamed 2026-09-18): a list, not a scalar flag,
|
|
131
|
+
// so it's a file like skeleton/brief_json — `--entities-file entities.json` of
|
|
132
|
+
// `[{"name":"...","treatment":"never"|"mention_once"|"mention_often"|"lead_company",
|
|
133
|
+
// "linkable":false}, ...]` (linkable optional, default true).
|
|
129
134
|
entities: { value: (flags) => readJsonFileFlag(flags, "entities-file") },
|
|
130
|
-
// document
|
|
131
|
-
// --no-preserve-shape to let the kernel restructure freely.
|
|
135
|
+
// content_kind "document" only: hold the document's own shape through the edit. Default
|
|
136
|
+
// true; pass --no-preserve-shape to let the kernel restructure freely.
|
|
132
137
|
preserve_shape: { flag: "preserve-shape", type: "bool" },
|
|
133
138
|
},
|
|
134
139
|
create_kernel_endpoint: {
|
|
@@ -228,8 +233,8 @@ async function kernelEndpoints(ctx) {
|
|
|
228
233
|
|
|
229
234
|
async function kernelWrite(ctx) {
|
|
230
235
|
const { client, positionals, flags, io } = ctx;
|
|
231
|
-
const args = argsFromFlags(ARGS.run_kernel, flags, positionals
|
|
232
|
-
if (args.
|
|
236
|
+
const args = argsFromFlags(ARGS.run_kernel, flags, positionals);
|
|
237
|
+
if (args.input === undefined) {
|
|
233
238
|
throw new CliError("Provide the brief with --body <text>, --file <path>, or --file - (stdin).");
|
|
234
239
|
}
|
|
235
240
|
const result = await client.callTool("run_kernel", args);
|
|
@@ -322,22 +327,24 @@ async function kernelEndpoint(ctx) {
|
|
|
322
327
|
|
|
323
328
|
/**
|
|
324
329
|
* The OTHER door: run the same pipeline against a document you already have, instead of
|
|
325
|
-
* writing a new one.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
330
|
+
* writing a new one. Decomposed into a brief and GENERATED FRESH (content_kind: "document",
|
|
331
|
+
* forced below) — not edited in place — with your own headings and embedded media (images,
|
|
332
|
+
* tables) extracted first and spliced back into the generated draft afterward, so they
|
|
333
|
+
* survive a pass that would otherwise be text-only and lose them.
|
|
328
334
|
*
|
|
329
335
|
* ASYNCHRONOUS, same as `write` — this used to be a separate, synchronous, unmetered "polish"
|
|
330
|
-
* that answered in one request
|
|
331
|
-
*
|
|
332
|
-
*
|
|
336
|
+
* that answered in one request, and before that an EARLIER `document` mode that skipped
|
|
337
|
+
* generation to hold shape "in place." Neither exists any more (2026-09-15, letterstory#1783;
|
|
338
|
+
* 2026-09-17). `document` now costs a run and follows the identical submit-and-poll shape
|
|
333
339
|
* `write` does, so this command does too — `--no-wait` behaves the same way here.
|
|
334
340
|
*/
|
|
335
341
|
async function kernelPolish(ctx) {
|
|
336
342
|
const { client, positionals, flags, io } = ctx;
|
|
337
|
-
const args = argsFromFlags(ARGS.run_kernel, flags, positionals
|
|
338
|
-
if (args.
|
|
343
|
+
const args = argsFromFlags(ARGS.run_kernel, flags, positionals);
|
|
344
|
+
if (args.input === undefined) {
|
|
339
345
|
throw new CliError("Provide the document with --body <text>, --file <path>, or --file - (stdin).");
|
|
340
346
|
}
|
|
347
|
+
args.content_kind = args.content_kind ?? "document";
|
|
341
348
|
const result = await client.callTool("run_kernel", args);
|
|
342
349
|
const runId = toolField(result, "run_id");
|
|
343
350
|
if (!runId) {
|
|
@@ -1,44 +1,101 @@
|
|
|
1
1
|
// `shredder` — create shredder endpoints and run content produced OUTSIDE Letterstory
|
|
2
2
|
// through the cross-provider shredder. A shred rewrites each sentence across multiple
|
|
3
3
|
// LLM providers to diversify, leaving structure (headings, lists, tables) untouched, and
|
|
4
|
-
// logs every call to telemetry.
|
|
4
|
+
// logs every call to telemetry against the org's shred balance.
|
|
5
5
|
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
6
7
|
import { CliError } from "../client.mjs";
|
|
7
8
|
import {
|
|
8
9
|
flagStr,
|
|
9
|
-
flagNum,
|
|
10
|
-
requireFlag,
|
|
11
10
|
requirePositional,
|
|
12
11
|
readBodyInput,
|
|
13
12
|
printResult,
|
|
14
|
-
compact,
|
|
15
13
|
ok,
|
|
16
14
|
toolField,
|
|
17
15
|
followJob,
|
|
18
16
|
argsFromFlags,
|
|
19
17
|
} from "./shared.mjs";
|
|
20
18
|
|
|
19
|
+
/** `--<flag> <path>` → the file's parsed JSON, or undefined when the flag is not set. */
|
|
20
|
+
function readJsonFileFlag(flags, flag) {
|
|
21
|
+
const path = flagStr(flags[flag]);
|
|
22
|
+
if (path === undefined) return undefined;
|
|
23
|
+
let text;
|
|
24
|
+
try {
|
|
25
|
+
text = readFileSync(path, "utf8");
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new CliError(
|
|
28
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `--<flag> <path>` → the file's text, or undefined when the flag is not set. */
|
|
39
|
+
function readTextFileFlag(flags, flag) {
|
|
40
|
+
const path = flagStr(flags[flag]);
|
|
41
|
+
if (path === undefined) return undefined;
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(path, "utf8");
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new CliError(
|
|
46
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
/**
|
|
22
|
-
* How
|
|
23
|
-
* `scripts/parity-check.ts`, which diffs these keys against
|
|
24
|
-
*
|
|
52
|
+
* How every command in this file sources its tool's inputs — see `argsFromFlags` in
|
|
53
|
+
* shared.mjs and `scripts/parity-check.ts`, which diffs these keys against each tool's
|
|
54
|
+
* published schema. Every shredder tool is mapped: a field added to one of them and
|
|
55
|
+
* forgotten here fails CI.
|
|
25
56
|
*/
|
|
26
57
|
export const ARGS = {
|
|
27
58
|
run_shred: {
|
|
28
59
|
endpoint_id: { positional: 0, label: "endpoint-id", required: true },
|
|
29
60
|
text: { value: (flags) => readBodyInput(flags) },
|
|
30
|
-
coverage: { flag: "coverage", type: "num" },
|
|
31
|
-
attempts_per_unit: { flag: "attempts", type: "num" },
|
|
32
61
|
// What the text IS. Without this an HTML file could only go through the `auto`
|
|
33
62
|
// heuristic, which misreads a markdown post carrying an embed.
|
|
34
63
|
format: { flag: "format", enum: ["auto", "html", "markdown"] },
|
|
35
64
|
// How hard to rewrite. Validated service-side; sent only when the caller asked.
|
|
36
65
|
aggression: { flag: "aggression", enum: ["standard", "high", "max"] },
|
|
66
|
+
coverage: { flag: "coverage", type: "num" },
|
|
67
|
+
attempts_per_unit: { flag: "attempts", type: "num" },
|
|
37
68
|
// Hold every rewrite to the live writing rules. A trade, not a free win.
|
|
38
69
|
apply_rules: { flag: "apply-rules", type: "bool" },
|
|
70
|
+
// The facts each rewrite must conserve — a file, since it is a document's worth of text.
|
|
71
|
+
evidence: { value: (flags) => readTextFileFlag(flags, "evidence-file") },
|
|
39
72
|
// ASYNCHRONOUS BY DEFAULT — `--sync` is the opt-out (see shredderRun for why).
|
|
40
73
|
async: { value: (flags) => (flags.sync === true ? undefined : true) },
|
|
41
74
|
},
|
|
75
|
+
get_shred_job: {
|
|
76
|
+
job_id: { positional: 0, label: "job-id", required: true },
|
|
77
|
+
},
|
|
78
|
+
list_shred_runs: {
|
|
79
|
+
endpoint_id: "endpoint",
|
|
80
|
+
limit: { flag: "limit", type: "num" },
|
|
81
|
+
},
|
|
82
|
+
list_shredder_endpoints: {},
|
|
83
|
+
get_shred_balance: {},
|
|
84
|
+
create_shredder_endpoint: {
|
|
85
|
+
name: { flag: "name", required: true },
|
|
86
|
+
},
|
|
87
|
+
delete_shredder_endpoint: {
|
|
88
|
+
endpoint_id: { positional: 1, label: "endpoint-id", required: true },
|
|
89
|
+
},
|
|
90
|
+
// The endpoint's configuration as ONE file: `shredder endpoint config <id>` prints it,
|
|
91
|
+
// `shredder endpoint config <id> --file cfg.json` replaces it with the file's document.
|
|
92
|
+
get_shredder_endpoint_config: {
|
|
93
|
+
endpoint_id: { positional: 1, label: "endpoint-id", required: true },
|
|
94
|
+
},
|
|
95
|
+
set_shredder_endpoint_config: {
|
|
96
|
+
endpoint_id: { positional: 1, label: "endpoint-id", required: true },
|
|
97
|
+
config: { value: (flags) => readJsonFileFlag(flags, "file") },
|
|
98
|
+
},
|
|
42
99
|
};
|
|
43
100
|
|
|
44
101
|
export async function cmdShredder(ctx) {
|
|
@@ -54,6 +111,8 @@ export async function cmdShredder(ctx) {
|
|
|
54
111
|
case "delete":
|
|
55
112
|
case "rm":
|
|
56
113
|
return shredderDelete(rest);
|
|
114
|
+
case "endpoint":
|
|
115
|
+
return shredderEndpoint(rest);
|
|
57
116
|
case "run":
|
|
58
117
|
case "shred":
|
|
59
118
|
return shredderRun(rest);
|
|
@@ -63,25 +122,27 @@ export async function cmdShredder(ctx) {
|
|
|
63
122
|
case "runs":
|
|
64
123
|
case "telemetry":
|
|
65
124
|
return shredderRuns(rest);
|
|
125
|
+
case "balance":
|
|
126
|
+
return shredderBalance(rest);
|
|
66
127
|
default:
|
|
67
128
|
throw new CliError(
|
|
68
|
-
`Unknown shredder subcommand: ${sub ?? "(none)"}. Try: list, create, delete, run, job, runs`
|
|
129
|
+
`Unknown shredder subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, job, runs, balance`
|
|
69
130
|
);
|
|
70
131
|
}
|
|
71
132
|
}
|
|
72
133
|
|
|
73
134
|
async function shredderList(ctx) {
|
|
74
135
|
const { client, flags, io } = ctx;
|
|
75
|
-
const result = await client.callTool("list_shredder_endpoints",
|
|
136
|
+
const result = await client.callTool("list_shredder_endpoints", argsFromFlags(ARGS.list_shredder_endpoints, flags));
|
|
76
137
|
printResult(io, flags, result);
|
|
77
138
|
return 0;
|
|
78
139
|
}
|
|
79
140
|
|
|
80
141
|
async function shredderCreate(ctx) {
|
|
81
|
-
const { client, flags, io } = ctx;
|
|
82
|
-
const
|
|
83
|
-
const result = await client.callTool("create_shredder_endpoint",
|
|
84
|
-
ok(ctx, `Created shredder endpoint "${name}".`);
|
|
142
|
+
const { client, flags, positionals, io } = ctx;
|
|
143
|
+
const args = argsFromFlags(ARGS.create_shredder_endpoint, flags, positionals);
|
|
144
|
+
const result = await client.callTool("create_shredder_endpoint", args);
|
|
145
|
+
ok(ctx, `Created shredder endpoint "${args.name}".`);
|
|
85
146
|
printResult(io, flags, result);
|
|
86
147
|
return 0;
|
|
87
148
|
}
|
|
@@ -93,12 +154,55 @@ async function shredderDelete(ctx) {
|
|
|
93
154
|
io.error(`Re-run with --yes to confirm: ${ctx.bin} shredder delete ${id} --yes`);
|
|
94
155
|
return 1;
|
|
95
156
|
}
|
|
157
|
+
// `shredder delete <id>` and `shredder endpoint delete <id>` are the same act; the ARGS
|
|
158
|
+
// map is written for the latter's positional layout.
|
|
96
159
|
const result = await client.callTool("delete_shredder_endpoint", { endpoint_id: id });
|
|
97
160
|
ok(ctx, "Shredder endpoint deleted.");
|
|
98
161
|
printResult(io, flags, result);
|
|
99
162
|
return 0;
|
|
100
163
|
}
|
|
101
164
|
|
|
165
|
+
/**
|
|
166
|
+
* `shredder endpoint config|delete` — the endpoint's own configuration and lifecycle, the
|
|
167
|
+
* same shape `kernel endpoint …` has, so the two primitives read the same way.
|
|
168
|
+
*/
|
|
169
|
+
async function shredderEndpoint(ctx) {
|
|
170
|
+
const { client, positionals, flags, io } = ctx;
|
|
171
|
+
const action = positionals[0];
|
|
172
|
+
if (action === "delete" || action === "rm") {
|
|
173
|
+
const id = requirePositional(positionals, 1, "endpoint-id");
|
|
174
|
+
if (!flags.yes) {
|
|
175
|
+
io.error(`Re-run with --yes to confirm: ${ctx.bin} shredder endpoint delete ${id} --yes`);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
const result = await client.callTool(
|
|
179
|
+
"delete_shredder_endpoint",
|
|
180
|
+
argsFromFlags(ARGS.delete_shredder_endpoint, flags, positionals)
|
|
181
|
+
);
|
|
182
|
+
ok(ctx, "Shredder endpoint deleted.");
|
|
183
|
+
printResult(io, flags, result);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
if (action === "config") {
|
|
187
|
+
const id = requirePositional(positionals, 1, "endpoint-id");
|
|
188
|
+
if (flagStr(flags.file) === undefined) {
|
|
189
|
+
const result = await client.callTool(
|
|
190
|
+
"get_shredder_endpoint_config",
|
|
191
|
+
argsFromFlags(ARGS.get_shredder_endpoint_config, flags, positionals)
|
|
192
|
+
);
|
|
193
|
+
// The document itself, so `> cfg.json` round-trips into `--file cfg.json`.
|
|
194
|
+
printResult(io, flags, result, (r) => JSON.stringify(r?.config ?? r, null, 2));
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
const args = argsFromFlags(ARGS.set_shredder_endpoint_config, flags, positionals);
|
|
198
|
+
const result = await client.callTool("set_shredder_endpoint_config", args);
|
|
199
|
+
ok(ctx, `Replaced the configuration of shredder endpoint ${id}.`);
|
|
200
|
+
printResult(io, flags, result);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
throw new CliError(`Unknown shredder endpoint action: ${action ?? "(none)"}. Try: config, delete`);
|
|
204
|
+
}
|
|
205
|
+
|
|
102
206
|
/**
|
|
103
207
|
* How long to wait between polls of a queued shred, and for how long in total.
|
|
104
208
|
*
|
|
@@ -175,19 +279,21 @@ const readStatus = (result) => toolField(result, "status");
|
|
|
175
279
|
|
|
176
280
|
async function shredderJob(ctx) {
|
|
177
281
|
const { client, positionals, flags, io } = ctx;
|
|
178
|
-
const
|
|
179
|
-
const result = await client.callTool("get_shred_job", { job_id: jobId });
|
|
282
|
+
const result = await client.callTool("get_shred_job", argsFromFlags(ARGS.get_shred_job, flags, positionals));
|
|
180
283
|
printResult(io, flags, result);
|
|
181
284
|
return 0;
|
|
182
285
|
}
|
|
183
286
|
|
|
184
287
|
async function shredderRuns(ctx) {
|
|
185
288
|
const { client, flags, io } = ctx;
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
289
|
+
const result = await client.callTool("list_shred_runs", argsFromFlags(ARGS.list_shred_runs, flags));
|
|
290
|
+
printResult(io, flags, result);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function shredderBalance(ctx) {
|
|
295
|
+
const { client, flags, io } = ctx;
|
|
296
|
+
const result = await client.callTool("get_shred_balance", argsFromFlags(ARGS.get_shred_balance, flags));
|
|
191
297
|
printResult(io, flags, result);
|
|
192
298
|
return 0;
|
|
193
299
|
}
|