@letterstory/cli 0.25.6 → 0.27.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 +23 -14
- package/lib/cli.mjs +23 -11
- package/lib/commands/arena.mjs +348 -0
- package/lib/commands/kernel.mjs +31 -0
- package/lib/commands/seers.mjs +1 -121
- package/lib/commands/templates.mjs +267 -0
- package/lib/commands.mjs +2 -1
- package/package.json +1 -1
- package/lib/commands/research.mjs +0 -87
package/README.md
CHANGED
|
@@ -312,25 +312,34 @@ letterstory series create --name "Secrets, tool by tool" \
|
|
|
312
312
|
letterstory series backfill <series-id> --collection <uuid>
|
|
313
313
|
```
|
|
314
314
|
|
|
315
|
-
##
|
|
315
|
+
## Arenas and writing kernels
|
|
316
316
|
|
|
317
|
-
Two different steps, in this order.
|
|
318
|
-
|
|
319
|
-
|
|
317
|
+
Two different steps, in this order. An **arena** — an adversarial cluster of research agents —
|
|
318
|
+
reads the live web and writes a sourced, fact-checked outline (into the post when you pass
|
|
319
|
+
`--article`, otherwise back on the run); a **writing kernel** writes the draft from the brief
|
|
320
|
+
it's given and does no retrieval of its own. Both take minutes, so both are start-then-poll:
|
|
320
321
|
|
|
321
322
|
```bash
|
|
322
|
-
# 1. Research the topic
|
|
323
|
-
letterstory
|
|
324
|
-
--
|
|
325
|
-
letterstory
|
|
326
|
-
letterstory
|
|
323
|
+
# 1. Research the topic — hosted on a post (outline written into it) or standalone
|
|
324
|
+
letterstory arena run --article <uuid> --topic "payroll solutions" \
|
|
325
|
+
--source https://example.com/a --must-include "Pricing" --depth fleshed_out
|
|
326
|
+
letterstory arena run --topic "payroll solutions" --no-wait # standalone; prints the run id
|
|
327
|
+
letterstory arena run-status <run-id> # phases, sources, outline, verification
|
|
328
|
+
letterstory arena cancel <run-id>
|
|
329
|
+
letterstory arena balance # arena runs left (direct runs only)
|
|
327
330
|
|
|
328
331
|
# …with --gate, the run pauses for you to pick the angle:
|
|
329
|
-
letterstory
|
|
330
|
-
letterstory
|
|
331
|
-
letterstory
|
|
332
|
-
letterstory
|
|
333
|
-
letterstory
|
|
332
|
+
letterstory arena run --article <uuid> --gate
|
|
333
|
+
letterstory arena run-status <run-id> # status: awaiting_direction + direction_options
|
|
334
|
+
letterstory arena direction <run-id> --choice 1
|
|
335
|
+
letterstory arena direction <run-id> --custom "Lead with the hidden costs"
|
|
336
|
+
letterstory arena direction <run-id> --skip # let the agents decide
|
|
337
|
+
|
|
338
|
+
# Arenas are callable endpoints with one-file defaults (the org's default arena is "Letterstory"):
|
|
339
|
+
letterstory arena list
|
|
340
|
+
letterstory arena endpoint create --name "Docs research" --defaults '{"depth":"barebones"}'
|
|
341
|
+
letterstory arena endpoint config <arena-id> [--file cfg.json]
|
|
342
|
+
letterstory arena endpoint delete <arena-id> --yes
|
|
334
343
|
|
|
335
344
|
# 2. Have a kernel write the draft
|
|
336
345
|
letterstory kernel list
|
package/lib/cli.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
cmdOnboarding,
|
|
31
31
|
cmdSeries,
|
|
32
32
|
cmdInsights,
|
|
33
|
-
|
|
33
|
+
cmdArena,
|
|
34
34
|
cmdAutoPen,
|
|
35
35
|
cmdCovers,
|
|
36
36
|
cmdKernel,
|
|
@@ -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.
|
|
47
|
+
export const VERSION = "0.27.0";
|
|
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.
|
|
@@ -64,7 +64,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
64
64
|
"rebuild",
|
|
65
65
|
"print-key",
|
|
66
66
|
"stdin",
|
|
67
|
-
// `covers generate <id> --regenerate` and `
|
|
67
|
+
// `covers generate <id> --regenerate` and `arena run --gate --article <id>`
|
|
68
68
|
// both put a value-less flag next to a value: without these entries the parser
|
|
69
69
|
// hands the following token to the flag and the real argument disappears.
|
|
70
70
|
"regenerate",
|
|
@@ -74,6 +74,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
74
74
|
"no-auto-deploy",
|
|
75
75
|
"stock-only",
|
|
76
76
|
"gate",
|
|
77
|
+
"no-verify",
|
|
77
78
|
"skip",
|
|
78
79
|
// `phantom-onboard domain` / `admin create-org --onboard` switches.
|
|
79
80
|
"watch",
|
|
@@ -277,14 +278,25 @@ Strategy & onboarding:
|
|
|
277
278
|
series create --name <n> --description <d> [--coverage a,b,c] [--collection <uuid>] [--template <key>]
|
|
278
279
|
series backfill <series-id> [--collection <uuid>] Reconstruct a series from existing posts
|
|
279
280
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
Arenas (adversarial clusters of research agents -> a sourced, fact-checked outline):
|
|
282
|
+
arena run --topic <text> [--source <url> …] [--must-include <text> …] [--depth barebones|fleshed_out]
|
|
283
|
+
[--gate] [--no-verify] [--provider native|exa|youcom|firecrawl] [--mention off|subtle|woven]
|
|
284
|
+
[--context-file ctx.json] [--block <name> …] [--block-domain <domain> …]
|
|
285
|
+
[--arena <arena-id>] [--article <uuid>] [--no-wait]
|
|
286
|
+
Start a run and follow it. --article makes
|
|
287
|
+
it HOSTED (outline written into that post,
|
|
288
|
+
its title is the default topic); otherwise
|
|
289
|
+
everything comes back on the run.
|
|
290
|
+
arena run-status <run-id> Phases, sources, outline, verification
|
|
291
|
+
arena runs [--arena <id>] [--limit <n>] Recent runs (origin, caller, verification)
|
|
292
|
+
arena direction <run-id> (--choice <n> | --custom <text> | --skip)
|
|
286
293
|
Answer the --gate pause
|
|
287
|
-
|
|
294
|
+
arena cancel <run-id> Cancel a run in flight
|
|
295
|
+
arena balance Arena runs left on this org
|
|
296
|
+
arena list Your arenas (callable endpoints)
|
|
297
|
+
arena endpoint create --name <n> [--defaults '<json>'|--defaults-file <path>]
|
|
298
|
+
arena endpoint config <arena-id> [--file cfg.json] Print the one-file config, or replace it
|
|
299
|
+
arena endpoint delete <arena-id> --yes
|
|
288
300
|
|
|
289
301
|
Auto-Pen (research -> kernel -> Enhance, in one run — and the ONLY path that
|
|
290
302
|
applies a collection's content template; kernel run does not):
|
|
@@ -594,7 +606,7 @@ const CLIENT_COMMANDS = {
|
|
|
594
606
|
onboarding: cmdOnboarding,
|
|
595
607
|
series: cmdSeries,
|
|
596
608
|
insights: cmdInsights,
|
|
597
|
-
|
|
609
|
+
arena: cmdArena,
|
|
598
610
|
autopen: cmdAutoPen,
|
|
599
611
|
covers: cmdCovers,
|
|
600
612
|
kernel: cmdKernel,
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// `arena` — Arenas, adversarial clusters of research agents, on both of their doors.
|
|
2
|
+
//
|
|
3
|
+
// An arena turns a TOPIC into a sourced, fact-checked outline: gather agents for the topic
|
|
4
|
+
// and for every named subject it finds, a reader, a direction agent proposing competing
|
|
5
|
+
// theses, a synthesis gated on a ledger of sourced figures, and a verifier that argues
|
|
6
|
+
// every claim against the sources and the live web. Minutes long, so `arena run` submits
|
|
7
|
+
// and then follows the run unless you tell it not to (`--no-wait`); `arena run-status`
|
|
8
|
+
// picks it up later. Pass `--article <id>` to make it a HOSTED run (the outline is written
|
|
9
|
+
// into that post — what the post editor's Research panel does); omit it and everything
|
|
10
|
+
// comes back on the run. Every direct run spends one arena run (`arena balance`).
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { CliError } from "../client.mjs";
|
|
14
|
+
import {
|
|
15
|
+
flagStr,
|
|
16
|
+
flagNum,
|
|
17
|
+
flagBool,
|
|
18
|
+
requirePositional,
|
|
19
|
+
printResult,
|
|
20
|
+
compact,
|
|
21
|
+
ok,
|
|
22
|
+
toolField,
|
|
23
|
+
followJob,
|
|
24
|
+
argsFromFlags,
|
|
25
|
+
} from "./shared.mjs";
|
|
26
|
+
|
|
27
|
+
/** `--<flag> <path>` → the file's parsed JSON, or undefined when the flag is not set. */
|
|
28
|
+
function readJsonFileFlag(flags, flag) {
|
|
29
|
+
const path = flagStr(flags[flag]);
|
|
30
|
+
if (path === undefined) return undefined;
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = readFileSync(path, "utf8");
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new CliError(
|
|
36
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(text);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `--<flag> '<json>'` inline, or `--<flag>-file <path>` → a JSON object, or undefined. */
|
|
47
|
+
function readJsonObjectFlag(flags, name) {
|
|
48
|
+
const inline = flagStr(flags[name]);
|
|
49
|
+
if (inline !== undefined) {
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(inline);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new CliError(`--${name} is not valid JSON: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
57
|
+
throw new CliError(`--${name} must be a JSON object`);
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
return readJsonFileFlag(flags, `${name}-file`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The per-run knobs, shared by run_arena and the arena's own defaults. */
|
|
64
|
+
const RUN_OPTIONS = {
|
|
65
|
+
depth: { flag: "depth", enum: ["barebones", "fleshed_out"] },
|
|
66
|
+
direction_gate: { flag: "gate", type: "bool" },
|
|
67
|
+
provider: { flag: "provider", enum: ["native", "exa", "youcom", "firecrawl"] },
|
|
68
|
+
// --no-verify turns the fact-check off; nothing sent otherwise (the default is on).
|
|
69
|
+
verify: { value: (flags) => (flags["no-verify"] === true ? false : undefined) },
|
|
70
|
+
mention_degree: { flag: "mention", enum: ["off", "subtle", "woven"] },
|
|
71
|
+
// Positioning as data — a file, not flags: `--context-file ctx.json` of
|
|
72
|
+
// {manifesto, stances[], priority_topics[], avoid_topics[], identity{is[],is_not[],one_liner}}.
|
|
73
|
+
context: { value: (flags) => readJsonObjectFlag(flags, "context") },
|
|
74
|
+
blocked_mentions: { flag: "block", type: "list" },
|
|
75
|
+
blocked_domains: { flag: "block-domain", type: "list" },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How every command in this file sources its tool inputs — see `argsFromFlags` in
|
|
80
|
+
* shared.mjs and `scripts/parity-check.ts`, which compares these keys against each tool's
|
|
81
|
+
* published input schema.
|
|
82
|
+
*/
|
|
83
|
+
/** `--var Name=Value` (repeatable) → `{ Name: "Value" }` for a template's {Variables}. */
|
|
84
|
+
function templateVariablesFromFlags(flags) {
|
|
85
|
+
const raw = flags.var;
|
|
86
|
+
if (raw === undefined) return undefined;
|
|
87
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const entry of list) {
|
|
90
|
+
const text = String(entry);
|
|
91
|
+
const eq = text.indexOf("=");
|
|
92
|
+
if (eq <= 0) throw new CliError(`--var must be Name=Value (got "${text}").`);
|
|
93
|
+
out[text.slice(0, eq).trim()] = text.slice(eq + 1).trim();
|
|
94
|
+
}
|
|
95
|
+
return Object.keys(out).length ? out : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const ARGS = {
|
|
99
|
+
run_arena: {
|
|
100
|
+
arena_id: { flag: "arena" },
|
|
101
|
+
topic: "topic",
|
|
102
|
+
source_urls: { flag: "source", type: "list" },
|
|
103
|
+
must_include: { flag: "must-include", type: "list" },
|
|
104
|
+
shape: { value: (flags) => readJsonObjectFlag(flags, "shape") },
|
|
105
|
+
// A TEMPLATE INSTEAD OF LISTING THE SHAPE BY HAND. Resolving one fills both
|
|
106
|
+
// `must_include` and `shape`; the `constraints` it returns come back on the run, and
|
|
107
|
+
// passing them to `kernel write --template-constraints` is what stops the same template
|
|
108
|
+
// being resolved twice into two different sets of section labels.
|
|
109
|
+
template: {
|
|
110
|
+
object: {
|
|
111
|
+
key: { flag: "template" },
|
|
112
|
+
blocks: { value: (flags) => readJsonFileFlag(flags, "template-blocks") },
|
|
113
|
+
title: { flag: "title" },
|
|
114
|
+
variables: { value: (flags) => templateVariablesFromFlags(flags) },
|
|
115
|
+
constraints: { value: (flags) => readJsonFileFlag(flags, "template-constraints") },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
existing_content: { value: (flags) => flagStr(flags["existing-content"]) },
|
|
119
|
+
article_id: { flag: "article" },
|
|
120
|
+
...RUN_OPTIONS,
|
|
121
|
+
},
|
|
122
|
+
get_arena_run: {
|
|
123
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
124
|
+
},
|
|
125
|
+
list_arena_runs: {
|
|
126
|
+
arena_id: { flag: "arena" },
|
|
127
|
+
limit: { flag: "limit", type: "num" },
|
|
128
|
+
},
|
|
129
|
+
submit_arena_direction: {
|
|
130
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
131
|
+
choice: { flag: "choice", type: "num" },
|
|
132
|
+
custom: "custom",
|
|
133
|
+
skip: { flag: "skip", type: "bool" },
|
|
134
|
+
},
|
|
135
|
+
cancel_arena_run: {
|
|
136
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
137
|
+
},
|
|
138
|
+
get_arena_run_balance: {},
|
|
139
|
+
list_arenas: {},
|
|
140
|
+
create_arena: {
|
|
141
|
+
name: { flag: "name", required: true },
|
|
142
|
+
defaults: { value: (flags) => readJsonObjectFlag(flags, "defaults") },
|
|
143
|
+
},
|
|
144
|
+
delete_arena: {
|
|
145
|
+
arena_id: { positional: 1, label: "arena-id", required: true },
|
|
146
|
+
},
|
|
147
|
+
// The arena's configuration as ONE file: `arena endpoint config <id>` prints it,
|
|
148
|
+
// `arena endpoint config <id> --file cfg.json` replaces it with the file's document.
|
|
149
|
+
get_arena_config: {
|
|
150
|
+
arena_id: { positional: 1, label: "arena-id", required: true },
|
|
151
|
+
},
|
|
152
|
+
set_arena_config: {
|
|
153
|
+
arena_id: { positional: 1, label: "arena-id", required: true },
|
|
154
|
+
config: { value: (flags) => readJsonFileFlag(flags, "file") },
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export async function cmdArena(ctx) {
|
|
159
|
+
const sub = ctx.positionals[0];
|
|
160
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
161
|
+
switch (sub) {
|
|
162
|
+
case "run":
|
|
163
|
+
case "start":
|
|
164
|
+
return arenaRun(rest);
|
|
165
|
+
case "run-status":
|
|
166
|
+
case "status":
|
|
167
|
+
return arenaRunStatus(rest);
|
|
168
|
+
case "runs":
|
|
169
|
+
return arenaRuns(rest);
|
|
170
|
+
case "direction":
|
|
171
|
+
return arenaDirection(rest);
|
|
172
|
+
case "cancel":
|
|
173
|
+
return arenaCancel(rest);
|
|
174
|
+
case "balance":
|
|
175
|
+
return arenaBalance(rest);
|
|
176
|
+
case "list":
|
|
177
|
+
case "ls":
|
|
178
|
+
case "endpoints":
|
|
179
|
+
return arenaList(rest);
|
|
180
|
+
case "endpoint":
|
|
181
|
+
return arenaEndpoint(rest);
|
|
182
|
+
default:
|
|
183
|
+
throw new CliError(
|
|
184
|
+
`Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, direction, cancel, balance, list, endpoint`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A run takes minutes; this is about feeling alive. */
|
|
190
|
+
const POLL_INTERVAL_MS = 15_000;
|
|
191
|
+
/** Stop following after this; the run keeps going and `run-status` picks it up. */
|
|
192
|
+
const POLL_CEILING_MS = 30 * 60 * 1000;
|
|
193
|
+
|
|
194
|
+
function isActive(status) {
|
|
195
|
+
return status === "running";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function arenaRun(ctx) {
|
|
199
|
+
const { client, flags, io } = ctx;
|
|
200
|
+
const args = argsFromFlags(ARGS.run_arena, flags, ctx.positionals);
|
|
201
|
+
if (args.topic === undefined && args.article_id === undefined) {
|
|
202
|
+
throw new CliError("Provide --topic <text> (or --article <id> to research a post by its title).");
|
|
203
|
+
}
|
|
204
|
+
const result = await client.callTool("run_arena", args);
|
|
205
|
+
const runId = toolField(result, "run_id");
|
|
206
|
+
if (!runId) {
|
|
207
|
+
printResult(io, flags, result);
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
if (flags["no-wait"] === true) {
|
|
211
|
+
ok(ctx, `Run started as ${runId}. Follow it with: ${ctx.bin} arena run-status ${runId}`);
|
|
212
|
+
printResult(io, flags, result);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
ok(ctx, `Run started as ${runId}. Waiting — this keeps running on the server if you stop.`);
|
|
217
|
+
let lastStatus;
|
|
218
|
+
const { settled, value: polled } = await followJob(
|
|
219
|
+
io,
|
|
220
|
+
async () => {
|
|
221
|
+
const r = await client.callTool("get_arena_run", { run_id: runId });
|
|
222
|
+
lastStatus = toolField(r?.run ?? r, "status");
|
|
223
|
+
return { done: !!lastStatus && !isActive(lastStatus), value: r };
|
|
224
|
+
},
|
|
225
|
+
{ intervalMs: POLL_INTERVAL_MS, ceilingMs: POLL_CEILING_MS }
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (!settled) {
|
|
229
|
+
ok(ctx, `Still running after 30 minutes. Follow it with: ${ctx.bin} arena run-status ${runId}`);
|
|
230
|
+
printResult(io, flags, polled);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
if (lastStatus === "awaiting_direction") {
|
|
234
|
+
ok(
|
|
235
|
+
ctx,
|
|
236
|
+
`Paused for a direction. Answer with: ${ctx.bin} arena direction ${runId} (--choice <n> | --custom <text> | --skip)`
|
|
237
|
+
);
|
|
238
|
+
printResult(io, flags, polled);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
ok(ctx, `Run ${lastStatus}.`);
|
|
242
|
+
// The operator waited for the OUTLINE; print it, not the envelope. --json gets it all.
|
|
243
|
+
printResult(io, flags, polled, (v) =>
|
|
244
|
+
typeof v?.run?.outline === "string" ? v.run.outline : JSON.stringify(v, null, 2)
|
|
245
|
+
);
|
|
246
|
+
return lastStatus === "done" ? 0 : 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function arenaRunStatus(ctx) {
|
|
250
|
+
const { client, flags, io } = ctx;
|
|
251
|
+
const args = argsFromFlags(ARGS.get_arena_run, flags, ctx.positionals);
|
|
252
|
+
const result = await client.callTool("get_arena_run", args);
|
|
253
|
+
printResult(io, flags, result);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function arenaRuns(ctx) {
|
|
258
|
+
const { client, flags, io } = ctx;
|
|
259
|
+
const args = argsFromFlags(ARGS.list_arena_runs, flags, ctx.positionals);
|
|
260
|
+
const result = await client.callTool("list_arena_runs", args);
|
|
261
|
+
printResult(io, flags, result);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function arenaDirection(ctx) {
|
|
266
|
+
const { client, flags, io } = ctx;
|
|
267
|
+
const runId = requirePositional(ctx.positionals, 0, "run-id");
|
|
268
|
+
const choice = flagNum(flags.choice);
|
|
269
|
+
const custom = flagStr(flags.custom);
|
|
270
|
+
const skip = flagBool(flags.skip);
|
|
271
|
+
const given = [choice !== undefined, custom !== undefined, skip].filter(Boolean).length;
|
|
272
|
+
if (given !== 1) {
|
|
273
|
+
throw new CliError("Provide exactly one of --choice <n>, --custom <text>, --skip");
|
|
274
|
+
}
|
|
275
|
+
const args = compact({ run_id: runId, choice, custom, skip: skip ? true : undefined });
|
|
276
|
+
const result = await client.callTool("submit_arena_direction", args);
|
|
277
|
+
ok(ctx, "Direction recorded; the run resumed into synthesis.");
|
|
278
|
+
printResult(io, flags, result);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function arenaCancel(ctx) {
|
|
283
|
+
const { client, flags, io } = ctx;
|
|
284
|
+
const args = argsFromFlags(ARGS.cancel_arena_run, flags, ctx.positionals);
|
|
285
|
+
const result = await client.callTool("cancel_arena_run", args);
|
|
286
|
+
ok(ctx, result?.cancelled ? "Arena run cancelled." : "No run was in flight.");
|
|
287
|
+
printResult(io, flags, result);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function arenaBalance(ctx) {
|
|
292
|
+
const { client, flags, io } = ctx;
|
|
293
|
+
const result = await client.callTool("get_arena_run_balance", {});
|
|
294
|
+
printResult(io, flags, result);
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function arenaList(ctx) {
|
|
299
|
+
const { client, flags, io } = ctx;
|
|
300
|
+
const result = await client.callTool("list_arenas", {});
|
|
301
|
+
printResult(io, flags, result);
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* `arena endpoint create|delete|config` — the arena's own CRUD, and its configuration as
|
|
307
|
+
* one file (the same document `get_arena_config` returns and `set_arena_config` accepts).
|
|
308
|
+
*/
|
|
309
|
+
async function arenaEndpoint(ctx) {
|
|
310
|
+
const { client, positionals, flags, io } = ctx;
|
|
311
|
+
const action = positionals[0];
|
|
312
|
+
if (action === "create" || action === "new") {
|
|
313
|
+
const args = argsFromFlags(ARGS.create_arena, flags, positionals);
|
|
314
|
+
const result = await client.callTool("create_arena", args);
|
|
315
|
+
ok(ctx, `Created arena "${args.name}".`);
|
|
316
|
+
printResult(io, flags, result);
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
if (action === "delete" || action === "rm") {
|
|
320
|
+
const id = requirePositional(positionals, 1, "arena-id");
|
|
321
|
+
if (!flags.yes) {
|
|
322
|
+
io.error(`Re-run with --yes to confirm: ${ctx.bin} arena endpoint delete ${id} --yes`);
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
const result = await client.callTool("delete_arena", argsFromFlags(ARGS.delete_arena, flags, positionals));
|
|
326
|
+
ok(ctx, "Arena deleted.");
|
|
327
|
+
printResult(io, flags, result);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
if (action === "config") {
|
|
331
|
+
const id = requirePositional(positionals, 1, "arena-id");
|
|
332
|
+
if (flagStr(flags.file) === undefined) {
|
|
333
|
+
const result = await client.callTool(
|
|
334
|
+
"get_arena_config",
|
|
335
|
+
argsFromFlags(ARGS.get_arena_config, flags, positionals)
|
|
336
|
+
);
|
|
337
|
+
// The document itself, so `> cfg.json` round-trips into `--file cfg.json`.
|
|
338
|
+
printResult(io, flags, result, (r) => JSON.stringify(r?.config ?? r, null, 2));
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
const args = argsFromFlags(ARGS.set_arena_config, flags, positionals);
|
|
342
|
+
const result = await client.callTool("set_arena_config", args);
|
|
343
|
+
ok(ctx, `Replaced the configuration of arena ${id}.`);
|
|
344
|
+
printResult(io, flags, result);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
throw new CliError(`Unknown arena endpoint action: ${action ?? "(none)"}. Try: create, delete, config`);
|
|
348
|
+
}
|
package/lib/commands/kernel.mjs
CHANGED
|
@@ -69,6 +69,21 @@ function readTextFileFlag(flags, flag) {
|
|
|
69
69
|
* The rest of this module predates the field-level ratchet and is covered by the
|
|
70
70
|
* frozen UNMAPPED_TOOLS list. New commands declare; that list only shrinks.
|
|
71
71
|
*/
|
|
72
|
+
/** `--var Name=Value` (repeatable) → `{ Name: "Value" }` for a template's {Variables}. */
|
|
73
|
+
function variablesFromFlags(flags) {
|
|
74
|
+
const raw = flags.var;
|
|
75
|
+
if (raw === undefined) return undefined;
|
|
76
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const entry of list) {
|
|
79
|
+
const text = String(entry);
|
|
80
|
+
const eq = text.indexOf("=");
|
|
81
|
+
if (eq <= 0) throw new CliError(`--var must be Name=Value (got "${text}").`);
|
|
82
|
+
out[text.slice(0, eq).trim()] = text.slice(eq + 1).trim();
|
|
83
|
+
}
|
|
84
|
+
return Object.keys(out).length ? out : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
export const ARGS = {
|
|
73
88
|
// The article-bound door: submit a kernel run INTO an article you already have.
|
|
74
89
|
submit_kernel_run: {
|
|
@@ -109,6 +124,22 @@ export const ARGS = {
|
|
|
109
124
|
// field a template resolves to, sent by a caller who has their own.
|
|
110
125
|
skeleton: { value: (flags) => readJsonFileFlag(flags, "skeleton-file") },
|
|
111
126
|
brief_json: { value: (flags) => readJsonFileFlag(flags, "brief-json-file") },
|
|
127
|
+
// A TEMPLATE INSTEAD OF HAND-BUILDING THOSE. `--template <key>` resolves one of your
|
|
128
|
+
// templates against the material you sent and fills `skeleton`, `target_word_count` and
|
|
129
|
+
// `brief_json` for this call — and the finished run comes back ASSEMBLED rather than as
|
|
130
|
+
// the scaffolding the kernel writes. `--template-constraints <path>` passes back the
|
|
131
|
+
// answer `templates resolve` already gave, so the same template is not resolved twice
|
|
132
|
+
// (which would relabel the sections the outline planned). `--var Name=Value` fills a
|
|
133
|
+
// {Variable} the template declares.
|
|
134
|
+
template: {
|
|
135
|
+
object: {
|
|
136
|
+
key: { flag: "template" },
|
|
137
|
+
blocks: { value: (flags) => readJsonFileFlag(flags, "template-blocks") },
|
|
138
|
+
title: { flag: "title" },
|
|
139
|
+
variables: { value: (flags) => variablesFromFlags(flags) },
|
|
140
|
+
constraints: { value: (flags) => readJsonFileFlag(flags, "template-constraints") },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
112
143
|
max_iterations: { flag: "max-iterations", type: "num" },
|
|
113
144
|
kernel_version: { flag: "kernel-version", type: "num" },
|
|
114
145
|
// In-loop shred tuning. Only sent when a flag is set; ignored when no shred runs.
|
package/lib/commands/seers.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { CliError } from "../client.mjs";
|
|
17
|
-
import { flagStr, flagBool,
|
|
17
|
+
import { flagStr, flagBool, printResult, ok, argsFromFlags } from "./shared.mjs";
|
|
18
18
|
|
|
19
19
|
// A single-value flag the API clears with an explicit `null` (same idiom as
|
|
20
20
|
// strategy.mjs's clearableList): `--clear-collection` sends null, `--collection <uuid>`
|
|
@@ -721,123 +721,3 @@ async function seersApply(ctx) {
|
|
|
721
721
|
}
|
|
722
722
|
return 0;
|
|
723
723
|
}
|
|
724
|
-
|
|
725
|
-
// --- Templates ---------------------------------------------------------------------
|
|
726
|
-
|
|
727
|
-
export async function cmdTemplates(ctx) {
|
|
728
|
-
const sub = ctx.positionals[0];
|
|
729
|
-
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
730
|
-
switch (sub) {
|
|
731
|
-
case "list":
|
|
732
|
-
case "ls":
|
|
733
|
-
return templatesList(rest);
|
|
734
|
-
case "show":
|
|
735
|
-
case "get":
|
|
736
|
-
return templatesShow(rest);
|
|
737
|
-
case "create":
|
|
738
|
-
case "new":
|
|
739
|
-
return templatesCreate(rest);
|
|
740
|
-
case "update":
|
|
741
|
-
return templatesUpdate(rest);
|
|
742
|
-
case "delete":
|
|
743
|
-
case "rm":
|
|
744
|
-
return templatesDelete(rest);
|
|
745
|
-
default:
|
|
746
|
-
throw new CliError(
|
|
747
|
-
`Unknown templates subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete`
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
async function templatesList(ctx) {
|
|
753
|
-
const { client, flags, io } = ctx;
|
|
754
|
-
const result = await client.callTool("list_templates", {});
|
|
755
|
-
printResult(io, flags, result, (r) => {
|
|
756
|
-
const custom = (r.templates ?? []).map((t) => ` ${t.key} ${t.name} — ${t.angle} (id ${t.id})`);
|
|
757
|
-
const builtIn = (r.built_ins ?? []).map((t) => ` ${t.key} ${t.name}`);
|
|
758
|
-
return [
|
|
759
|
-
custom.length ? "Custom templates:" : "No custom templates yet.",
|
|
760
|
-
...custom,
|
|
761
|
-
"",
|
|
762
|
-
"Built-in templates (read-only):",
|
|
763
|
-
...builtIn,
|
|
764
|
-
].join("\n");
|
|
765
|
-
});
|
|
766
|
-
return 0;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
async function templatesShow(ctx) {
|
|
770
|
-
const { client, positionals, flags, io } = ctx;
|
|
771
|
-
const templateId = requirePositional(positionals, 0, "template-id");
|
|
772
|
-
const result = await client.callTool("get_template", { template_id: templateId });
|
|
773
|
-
printResult(io, flags, result);
|
|
774
|
-
return 0;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
function templateBlocksFromFlags(flags) {
|
|
778
|
-
const inline = flagStr(flags.blocks);
|
|
779
|
-
const file = flagStr(flags["blocks-file"]);
|
|
780
|
-
if (inline === undefined && file === undefined) return undefined;
|
|
781
|
-
const raw = inline !== undefined ? inline : readFileOrThrow(file, "--blocks-file");
|
|
782
|
-
let parsed;
|
|
783
|
-
try {
|
|
784
|
-
parsed = JSON.parse(raw);
|
|
785
|
-
} catch (err) {
|
|
786
|
-
throw new CliError(`--blocks is not valid JSON: ${err.message}`);
|
|
787
|
-
}
|
|
788
|
-
if (!Array.isArray(parsed)) throw new CliError("--blocks must be a JSON array of block objects");
|
|
789
|
-
return parsed;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
async function templatesCreate(ctx) {
|
|
793
|
-
const { client, flags, io } = ctx;
|
|
794
|
-
const name = flagStr(flags.name);
|
|
795
|
-
const angle = flagStr(flags.angle);
|
|
796
|
-
const blocks = templateBlocksFromFlags(flags);
|
|
797
|
-
if (!name) throw new CliError("Missing required --name");
|
|
798
|
-
// `--angle` is OPTIONAL, and omitting it is the better answer. It was required here while
|
|
799
|
-
// the server schema had already made it optional and the app had dropped the field: a
|
|
800
|
-
// template constrains a post's SHAPE and never says what it is about, so an angle belongs
|
|
801
|
-
// to the post — which already has one, from its research direction.
|
|
802
|
-
if (!blocks) throw new CliError("Missing required --blocks '<json>' or --blocks-file <path>");
|
|
803
|
-
const result = await client.callTool("create_template", { name, blocks, ...(angle ? { angle } : {}) });
|
|
804
|
-
ok(ctx, `Template created — key ${result.template?.key}.`);
|
|
805
|
-
printResult(io, flags, result);
|
|
806
|
-
return 0;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
async function templatesUpdate(ctx) {
|
|
810
|
-
const { client, positionals, flags, io } = ctx;
|
|
811
|
-
const templateId = requirePositional(positionals, 0, "template-id");
|
|
812
|
-
const name = flagStr(flags.name);
|
|
813
|
-
const angle = flagStr(flags.angle);
|
|
814
|
-
const blocks = templateBlocksFromFlags(flags);
|
|
815
|
-
// update_template replaces the whole template; fetch current values for any
|
|
816
|
-
// field the caller didn't pass so a partial edit doesn't blank the rest.
|
|
817
|
-
let current;
|
|
818
|
-
if (!name || !angle || !blocks) {
|
|
819
|
-
const existing = await client.callTool("get_template", { template_id: templateId });
|
|
820
|
-
current = existing.template;
|
|
821
|
-
}
|
|
822
|
-
const result = await client.callTool("update_template", {
|
|
823
|
-
template_id: templateId,
|
|
824
|
-
name: name ?? current.name,
|
|
825
|
-
angle: angle ?? current.angle,
|
|
826
|
-
blocks: blocks ?? current.blocks,
|
|
827
|
-
});
|
|
828
|
-
ok(ctx, "Template updated.");
|
|
829
|
-
printResult(io, flags, result);
|
|
830
|
-
return 0;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
async function templatesDelete(ctx) {
|
|
834
|
-
const { client, positionals, flags, io } = ctx;
|
|
835
|
-
const templateId = requirePositional(positionals, 0, "template-id");
|
|
836
|
-
if (!flagBool(flags.yes)) {
|
|
837
|
-
throw new CliError("Seers bound to this template fall back to defaults. Re-run with --yes to delete.");
|
|
838
|
-
}
|
|
839
|
-
const result = await client.callTool("delete_template", { template_id: templateId });
|
|
840
|
-
ok(ctx, "Template deleted.");
|
|
841
|
-
printResult(io, flags, result);
|
|
842
|
-
return 0;
|
|
843
|
-
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// `templates` — the Templates FORMAT: authoring it, and the two operations that make it
|
|
2
|
+
// composable.
|
|
3
|
+
//
|
|
4
|
+
// MOVED OUT OF `seers.mjs` (2026-09-18) for the same reason `src/lib/templates/` moved out of
|
|
5
|
+
// `src/lib/seers/`: a template stopped being seer-owned when collections, series and per-article
|
|
6
|
+
// overrides started binding them, and two of the primitives that consume one have nothing to do
|
|
7
|
+
// with seers at all.
|
|
8
|
+
//
|
|
9
|
+
// A template is not a primitive: no endpoint, no run, no balance, nothing to poll. It is a SHAPE
|
|
10
|
+
// two primitives consume — what sections an article has, in what order, in what form. What it has
|
|
11
|
+
// instead of a door is a RESOLUTION:
|
|
12
|
+
//
|
|
13
|
+
// templates resolve <key> the SHAPE: feeds `arena run --template`
|
|
14
|
+
// templates resolve <key> --file material.md the PLAN too: skeleton, budget and brief for `kernel write`
|
|
15
|
+
// templates assemble --spans s.json --file a.md a templated run's output, turned back into the article
|
|
16
|
+
//
|
|
17
|
+
// Resolve ONCE and pass `--constraints` back on every later call. Resolving twice is a model call
|
|
18
|
+
// twice and two different sets of section labels — and the writer writes each section under its
|
|
19
|
+
// label while assembly matches output back by that label, so a second resolution files sections
|
|
20
|
+
// under headings the outline never planned.
|
|
21
|
+
//
|
|
22
|
+
// For a single write with no research, skip all of this: `kernel write --template <key>` resolves
|
|
23
|
+
// through the same function and returns the article already assembled.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { CliError } from "../client.mjs";
|
|
27
|
+
import { flagStr, flagBool, requirePositional, printResult, ok, argsFromFlags, readBodyInput } from "./shared.mjs";
|
|
28
|
+
|
|
29
|
+
/** `--<flag> <path>` → the file's parsed JSON, or undefined when the flag is not set. */
|
|
30
|
+
function readJsonFileFlag(flags, flag) {
|
|
31
|
+
const path = flagStr(flags[flag]);
|
|
32
|
+
if (path === undefined) return undefined;
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
text = readFileSync(path, "utf8");
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new CliError(
|
|
38
|
+
`Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(text);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new CliError(`--${flag} ${path} is not valid JSON.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read a file, or throw a CLI error naming the flag that asked for it. */
|
|
49
|
+
function readFileOrThrow(path, flag) {
|
|
50
|
+
try {
|
|
51
|
+
return readFileSync(path, "utf8");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new CliError(`Could not read ${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `--var Name=Value` (repeatable) → `{ Name: "Value" }`. */
|
|
58
|
+
function variablesFromFlags(flags) {
|
|
59
|
+
const raw = flags.var;
|
|
60
|
+
if (raw === undefined) return undefined;
|
|
61
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const entry of list) {
|
|
64
|
+
const text = String(entry);
|
|
65
|
+
const eq = text.indexOf("=");
|
|
66
|
+
if (eq <= 0) throw new CliError(`--var must be Name=Value (got "${text}").`);
|
|
67
|
+
out[text.slice(0, eq).trim()] = text.slice(eq + 1).trim();
|
|
68
|
+
}
|
|
69
|
+
return Object.keys(out).length ? out : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How the RESOLVE and ASSEMBLE commands source their arguments — see `argsFromFlags` in
|
|
74
|
+
* shared.mjs, and `scripts/parity-check.ts`, which compares these keys against each tool's
|
|
75
|
+
* published input schema. The CRUD commands below predate the field-level ratchet and are
|
|
76
|
+
* covered by the frozen `UNMAPPED_TOOLS` list, which only shrinks.
|
|
77
|
+
*/
|
|
78
|
+
export const ARGS = {
|
|
79
|
+
resolve_template: {
|
|
80
|
+
key: { positional: 0, label: "template-key" },
|
|
81
|
+
blocks: { value: (flags) => readJsonFileFlag(flags, "blocks-file") },
|
|
82
|
+
title: "title",
|
|
83
|
+
material: { value: (flags) => readBodyInput(flags) },
|
|
84
|
+
variables: { value: variablesFromFlags },
|
|
85
|
+
constraints: { value: (flags) => readJsonFileFlag(flags, "constraints") },
|
|
86
|
+
// The brand a {Brand}-style variable names. Omitted = this org's own; an org with no
|
|
87
|
+
// positioning simply resolves no brand variable rather than failing.
|
|
88
|
+
brand: {
|
|
89
|
+
object: {
|
|
90
|
+
name: { flag: "brand" },
|
|
91
|
+
one_liner: { flag: "brand-one-liner" },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
// Declared rather than re-frozen. These two were on the `UNMAPPED_TOOLS` list while they lived
|
|
96
|
+
// in `seers.mjs`; moving the file is not a reason to re-freeze them, and they are one
|
|
97
|
+
// positional each.
|
|
98
|
+
get_template: {
|
|
99
|
+
template_id: { positional: 0, label: "template-id", required: true },
|
|
100
|
+
},
|
|
101
|
+
delete_template: {
|
|
102
|
+
template_id: { positional: 0, label: "template-id", required: true },
|
|
103
|
+
},
|
|
104
|
+
assemble_template: {
|
|
105
|
+
spans: { value: (flags) => readJsonFileFlag(flags, "spans") },
|
|
106
|
+
article: { value: (flags) => readBodyInput(flags) },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export async function cmdTemplates(ctx) {
|
|
111
|
+
const sub = ctx.positionals[0];
|
|
112
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
113
|
+
switch (sub) {
|
|
114
|
+
case "list":
|
|
115
|
+
case "ls":
|
|
116
|
+
return templatesList(rest);
|
|
117
|
+
case "show":
|
|
118
|
+
case "get":
|
|
119
|
+
return templatesShow(rest);
|
|
120
|
+
case "create":
|
|
121
|
+
case "new":
|
|
122
|
+
return templatesCreate(rest);
|
|
123
|
+
case "update":
|
|
124
|
+
return templatesUpdate(rest);
|
|
125
|
+
case "delete":
|
|
126
|
+
case "rm":
|
|
127
|
+
return templatesDelete(rest);
|
|
128
|
+
case "resolve":
|
|
129
|
+
return templatesResolve(rest);
|
|
130
|
+
case "assemble":
|
|
131
|
+
return templatesAssemble(rest);
|
|
132
|
+
default:
|
|
133
|
+
throw new CliError(
|
|
134
|
+
`Unknown templates subcommand: ${sub ?? "(none)"}. Try: list, show, create, update, delete, resolve, assemble`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function templatesList(ctx) {
|
|
140
|
+
const { client, flags, io } = ctx;
|
|
141
|
+
const result = await client.callTool("list_templates", {});
|
|
142
|
+
printResult(io, flags, result, (r) => {
|
|
143
|
+
const custom = (r.templates ?? []).map((t) => ` ${t.key} ${t.name} — ${t.angle} (id ${t.id})`);
|
|
144
|
+
const builtIn = (r.built_ins ?? []).map((t) => ` ${t.key} ${t.name}`);
|
|
145
|
+
return [
|
|
146
|
+
custom.length ? "Custom templates:" : "No custom templates yet.",
|
|
147
|
+
...custom,
|
|
148
|
+
"",
|
|
149
|
+
"Built-in templates (read-only):",
|
|
150
|
+
...builtIn,
|
|
151
|
+
].join("\n");
|
|
152
|
+
});
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function templatesShow(ctx) {
|
|
157
|
+
const { client, positionals, flags, io } = ctx;
|
|
158
|
+
requirePositional(positionals, 0, "template-id");
|
|
159
|
+
const result = await client.callTool("get_template", argsFromFlags(ARGS.get_template, flags, positionals));
|
|
160
|
+
printResult(io, flags, result);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function templateBlocksFromFlags(flags) {
|
|
165
|
+
const inline = flagStr(flags.blocks);
|
|
166
|
+
const file = flagStr(flags["blocks-file"]);
|
|
167
|
+
if (inline === undefined && file === undefined) return undefined;
|
|
168
|
+
const raw = inline !== undefined ? inline : readFileOrThrow(file, "--blocks-file");
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(raw);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
throw new CliError(`--blocks is not valid JSON: ${err.message}`);
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(parsed)) throw new CliError("--blocks must be a JSON array of block objects");
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function templatesCreate(ctx) {
|
|
180
|
+
const { client, flags, io } = ctx;
|
|
181
|
+
const name = flagStr(flags.name);
|
|
182
|
+
const angle = flagStr(flags.angle);
|
|
183
|
+
const blocks = templateBlocksFromFlags(flags);
|
|
184
|
+
if (!name) throw new CliError("Missing required --name");
|
|
185
|
+
// `--angle` is OPTIONAL, and omitting it is the better answer. It was required here while
|
|
186
|
+
// the server schema had already made it optional and the app had dropped the field: a
|
|
187
|
+
// template constrains a post's SHAPE and never says what it is about, so an angle belongs
|
|
188
|
+
// to the post — which already has one, from its research direction.
|
|
189
|
+
if (!blocks) throw new CliError("Missing required --blocks '<json>' or --blocks-file <path>");
|
|
190
|
+
const result = await client.callTool("create_template", { name, blocks, ...(angle ? { angle } : {}) });
|
|
191
|
+
ok(ctx, `Template created — key ${result.template?.key}.`);
|
|
192
|
+
printResult(io, flags, result);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function templatesUpdate(ctx) {
|
|
197
|
+
const { client, positionals, flags, io } = ctx;
|
|
198
|
+
const templateId = requirePositional(positionals, 0, "template-id");
|
|
199
|
+
const name = flagStr(flags.name);
|
|
200
|
+
const angle = flagStr(flags.angle);
|
|
201
|
+
const blocks = templateBlocksFromFlags(flags);
|
|
202
|
+
// update_template replaces the whole template; fetch current values for any
|
|
203
|
+
// field the caller didn't pass so a partial edit doesn't blank the rest.
|
|
204
|
+
let current;
|
|
205
|
+
if (!name || !angle || !blocks) {
|
|
206
|
+
const existing = await client.callTool("get_template", { template_id: templateId });
|
|
207
|
+
current = existing.template;
|
|
208
|
+
}
|
|
209
|
+
const result = await client.callTool("update_template", {
|
|
210
|
+
template_id: templateId,
|
|
211
|
+
name: name ?? current.name,
|
|
212
|
+
angle: angle ?? current.angle,
|
|
213
|
+
blocks: blocks ?? current.blocks,
|
|
214
|
+
});
|
|
215
|
+
ok(ctx, "Template updated.");
|
|
216
|
+
printResult(io, flags, result);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function templatesDelete(ctx) {
|
|
221
|
+
const { client, positionals, flags, io } = ctx;
|
|
222
|
+
requirePositional(positionals, 0, "template-id");
|
|
223
|
+
if (!flagBool(flags.yes)) {
|
|
224
|
+
throw new CliError("Seers bound to this template fall back to defaults. Re-run with --yes to delete.");
|
|
225
|
+
}
|
|
226
|
+
const result = await client.callTool("delete_template", argsFromFlags(ARGS.delete_template, flags, positionals));
|
|
227
|
+
ok(ctx, "Template deleted.");
|
|
228
|
+
printResult(io, flags, result);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `templates resolve <key> [--title t] [--body/--file material] [--var K=V] [--constraints c.json]`
|
|
234
|
+
*
|
|
235
|
+
* THE ONE PLACE A TEMPLATE BECOMES CONSTRAINTS. Without material: the SHAPE — `must_include`,
|
|
236
|
+
* `gather_hints`, `form_guidance`, which is what `arena run` takes. With it: the PLAN as well —
|
|
237
|
+
* `skeleton`, `target_word_count`, `brief`, `brief_json`, `spans`, which is what `kernel write`
|
|
238
|
+
* takes. Pass `--constraints` back on the second call so nothing is re-decided.
|
|
239
|
+
*/
|
|
240
|
+
async function templatesResolve(ctx) {
|
|
241
|
+
const { client, positionals, flags, io } = ctx;
|
|
242
|
+
const args = argsFromFlags(ARGS.resolve_template, flags, positionals);
|
|
243
|
+
if (args.key === undefined && args.blocks === undefined) {
|
|
244
|
+
throw new CliError(
|
|
245
|
+
"Name the template: `templates resolve <template-key>`, or --blocks-file <path> for an inline one."
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const result = await client.callTool("resolve_template", args);
|
|
249
|
+
printResult(io, flags, result);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `templates assemble --spans spans.json --file article.md` — the way back. */
|
|
254
|
+
async function templatesAssemble(ctx) {
|
|
255
|
+
const { client, positionals, flags, io } = ctx;
|
|
256
|
+
const args = argsFromFlags(ARGS.assemble_template, flags, positionals);
|
|
257
|
+
if (args.spans === undefined) {
|
|
258
|
+
throw new CliError("--spans <path> is required: the spans `templates resolve` returned.");
|
|
259
|
+
}
|
|
260
|
+
if (args.article === undefined) {
|
|
261
|
+
throw new CliError("Provide the run's output with --body <text>, --file <path>, or --file - (stdin).");
|
|
262
|
+
}
|
|
263
|
+
const result = await client.callTool("assemble_template", args);
|
|
264
|
+
// The article itself, so `> post.html` works.
|
|
265
|
+
printResult(io, flags, result, (r) => r?.article ?? "");
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
package/lib/commands.mjs
CHANGED
|
@@ -19,7 +19,8 @@ export * from "./commands/probes.mjs";
|
|
|
19
19
|
export * from "./commands/series.mjs";
|
|
20
20
|
export * from "./commands/insights.mjs";
|
|
21
21
|
export * from "./commands/covers.mjs";
|
|
22
|
-
export * from "./commands/
|
|
22
|
+
export * from "./commands/arena.mjs";
|
|
23
|
+
export * from "./commands/templates.mjs";
|
|
23
24
|
export * from "./commands/autopen.mjs";
|
|
24
25
|
export * from "./commands/kernel.mjs";
|
|
25
26
|
export * from "./commands/phantom-job.mjs";
|
package/package.json
CHANGED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
// `research` — the deep-research agent: read supplied URLs, search the live web, and
|
|
2
|
-
// write a sourced outline into a post. Same shape as `kernel`: a run takes minutes, so
|
|
3
|
-
// `start` returns as soon as the run is queued and `status` checks on demand. Writing
|
|
4
|
-
// kernels do NOT research — `research start` then `kernel run` is the full pipeline.
|
|
5
|
-
|
|
6
|
-
import { CliError } from "../client.mjs";
|
|
7
|
-
import { flagStr, flagNum, flagBool, requireFlag, printResult, compact, ok, argsFromFlags } from "./shared.mjs";
|
|
8
|
-
|
|
9
|
-
/** See `argsFromFlags` in shared.mjs; `scripts/parity-check.ts` diffs these keys against the tool. */
|
|
10
|
-
export const ARGS = {
|
|
11
|
-
start_research: {
|
|
12
|
-
article_id: { flag: "article", required: true },
|
|
13
|
-
topic: "topic",
|
|
14
|
-
source_urls: { flag: "url", type: "list" },
|
|
15
|
-
must_include: { flag: "must-include", type: "list" },
|
|
16
|
-
depth: { flag: "depth", enum: ["barebones", "fleshed_out"] },
|
|
17
|
-
direction_gate: { flag: "gate", type: "bool" },
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export async function cmdResearch(ctx) {
|
|
22
|
-
const sub = ctx.positionals[0];
|
|
23
|
-
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
24
|
-
switch (sub) {
|
|
25
|
-
case "start":
|
|
26
|
-
case "run":
|
|
27
|
-
return researchStart(rest);
|
|
28
|
-
case "status":
|
|
29
|
-
return researchStatus(rest);
|
|
30
|
-
case "direction":
|
|
31
|
-
return researchDirection(rest);
|
|
32
|
-
case "cancel":
|
|
33
|
-
return researchCancel(rest);
|
|
34
|
-
default:
|
|
35
|
-
throw new CliError(
|
|
36
|
-
`Unknown research subcommand: ${sub ?? "(none)"}. Try: start, status, direction, cancel`
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function researchStart(ctx) {
|
|
42
|
-
const { client, flags, io } = ctx;
|
|
43
|
-
const args = argsFromFlags(ARGS.start_research, flags, ctx.positionals);
|
|
44
|
-
const result = await client.callTool("start_research", args);
|
|
45
|
-
ok(ctx, `Research started. Check progress with: research status --article ${args.article_id}`);
|
|
46
|
-
printResult(io, flags, result);
|
|
47
|
-
return 0;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function researchStatus(ctx) {
|
|
51
|
-
const { client, flags, io } = ctx;
|
|
52
|
-
const article = requireFlag(flags, "article");
|
|
53
|
-
const result = await client.callTool("get_research_status", { article_id: article });
|
|
54
|
-
printResult(io, flags, result);
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function researchDirection(ctx) {
|
|
59
|
-
const { client, flags, io } = ctx;
|
|
60
|
-
const article = requireFlag(flags, "article");
|
|
61
|
-
const choice = flagNum(flags.choice);
|
|
62
|
-
const custom = flagStr(flags.custom);
|
|
63
|
-
const skip = flagBool(flags.skip);
|
|
64
|
-
const given = [choice !== undefined, custom !== undefined, skip].filter(Boolean).length;
|
|
65
|
-
if (given !== 1) {
|
|
66
|
-
throw new CliError("Provide exactly one of --choice <n>, --custom <text>, --skip");
|
|
67
|
-
}
|
|
68
|
-
const args = compact({
|
|
69
|
-
article_id: article,
|
|
70
|
-
choice,
|
|
71
|
-
custom,
|
|
72
|
-
skip: skip ? true : undefined,
|
|
73
|
-
});
|
|
74
|
-
const result = await client.callTool("submit_research_direction", args);
|
|
75
|
-
ok(ctx, "Direction recorded; the run resumed into synthesis.");
|
|
76
|
-
printResult(io, flags, result);
|
|
77
|
-
return 0;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function researchCancel(ctx) {
|
|
81
|
-
const { client, flags, io } = ctx;
|
|
82
|
-
const article = requireFlag(flags, "article");
|
|
83
|
-
const result = await client.callTool("cancel_research", { article_id: article });
|
|
84
|
-
ok(ctx, result?.cancelled ? "Research run cancelled." : "No research run was in flight.");
|
|
85
|
-
printResult(io, flags, result);
|
|
86
|
-
return 0;
|
|
87
|
-
}
|