@velven/cli 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,6 +1,70 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.4.0
4
+
5
+ - `velven create send` lets Velven pick how much to do when you pass neither `--effort` nor `--plan`/`--no-plan`:
6
+ a quick change, a full build it playtests and fixes, or a plan when you ask for one. Its line before sending reads "Velven picks how much to do: about 20 credits for a small change, at most
7
+ 1500 held. You have 3200 available.", or on a project that never built "about 51 credits for a first build (its
8
+ pictures are made before the first preview)". With `--effort` or a plan flag the turn is as before; a later
9
+ message with neither flag is Velven's pick again (the help no longer says the flags are kept for every message).
10
+ - `--build` sends the message as Build it: no more questions, Velven fills in what the plan leaves open. Words beyond
11
+ a bare build (`--build "make it 2D"`) change the plan first, then it is built; `--build "Build it"` builds the plan as
12
+ it is, at once.
13
+ - `--ref <file>` (up to 4) uploads a reference picture (up to 10 MB) to the project and attaches it to the message:
14
+ the picture goes straight to Velven's storage through a link, then Velven keeps it. `--ref ref-3` sends a picture
15
+ the project already keeps, with no upload. A refused picture stops the CLI with Velven's sentence and exit 4, before
16
+ anything is sent; one over 10 MB is refused before it is uploaded.
17
+ - Following a turn Velven routes says "Started: Velven is choosing the work", then what it chose in words, with the
18
+ work and the reason on standard error. A question prints with its numbered choices and the commands that answer it.
19
+ - `velven create refs` lists the project's reference pictures (id, when added, size, how many messages carried each),
20
+ and `velven create refs rm <ref-n>` deletes one. A send whose picture finds the project's 16 full names both.
21
+ - A question Velven asks is printed once, with its choices: the turn's reply, the same question, is no longer printed
22
+ again at the end. `velven create watch <turn>` names the turn's own project in how to answer it, not the current one.
23
+ - A picture refused because the project has given out all 999 picture numbers stops with its own sentence and
24
+ `reference_numbers_used`, without the hint about removing a picture.
25
+ - `velven create stats` heads a routed group with its work ("quick fast first builds", "standard full later builds",
26
+ "quick interview turns", "quick routed plan turns"), and `velven create show --timeline` names a routed turn's work
27
+ and first build from its router step.
28
+ - `velven create new` without `--genre` makes an open game: Velven follows your idea instead of arcade's rules
29
+ (`--genre arcade` holds a project to them). Without `--engine`, Velven picks 2D (`phaser`) or 3D (`three`) from your
30
+ idea, 3D unless it is clearly 2D. A project made with `--engine` keeps its engine.
31
+ - A new game starts with a short conversation: Velven asks a question or two (2D or 3D, the style, at most four in
32
+ all), each with numbered choices and `Just build it: velven create send --build "Just build it"` (the style question
33
+ also offers `--ref <file>`), then sketches a short plan, printed as a card with `Build it: velven create send --build
34
+ "Build it"`. `velven create plan` prints the project's plan again (`--json` too).
35
+ - Following a build prints its plain steps ("Designing your game", "Painting the art", "Building the world", "Trying it
36
+ out", "Polishing", "Finishing up"), a first build's design once it is settled ("Designing <title>: <pitch>" with its
37
+ style and palette), and each picture as it lands ("Painted sky (1024 by 512)" and a link to it). The plan and the
38
+ brief as they are written are printed only with `watch --json`.
39
+ - A message sent while a turn runs on the project waits behind it instead of being refused (the CLI asks for that:
40
+ `queue=true` on the estimate, `queue: true` on the message; Velven still refuses a send that does not ask, as it
41
+ refused 0.3.0's): "Queued: it runs after the turn that is running. velven create queue lists what waits." (exit 0;
42
+ `--json` prints `{ ok, queued }`). It starts when that turn ends, and following that turn prints "Your next message
43
+ is running: velven create watch <turn>". `velven create queue` lists the waiting messages (and any Velven could not
44
+ start, with why); `velven create queue rm <id>` removes one.
45
+ - `velven create stats` heads a new game's plan turns "quick draft turns", and `velven create show --timeline` names a
46
+ draft or interview turn from its interviewer step.
47
+ - After a turn that ends with a playable game, following it prints "Where it could go next:" with two to four short
48
+ suggestions Velven wrote for this game, and the command that sends the first one; send any of them as an ordinary
49
+ message.
50
+ - `velven create usage` lists what each of your turns held and used, newest first (`--project <id>` for one project,
51
+ `--before <turn>` for the page before, `--json`).
52
+ - When Velven kept a turn quick because the day's credits would not cover more, its route line says so in words. A
53
+ reply may end "You're running low on credits for today." and, after a question, the CLI now prints what follows the
54
+ question in the reply.
55
+ - `velven create stats` adds a line under each group saying how the next message reacted to its turns (moved on,
56
+ corrected it, asked for checks), when any was labelled.
57
+ - On a project that never built, a message without `--build` is priced as what Velven answers it with first: "about 2
58
+ credits for a question or a short plan"; with `--build`, as a first build.
59
+ - A message sent while a turn runs that starts at once (the turn ended as it was queued) is followed as a turn ("Turn
60
+ <id> is on its way.", or "Turn <id> waits for room on Velven and starts on its own." when Velven's daily room holds
61
+ it back); one Velven refused when it came to start prints why and exits 4, instead of "Queued".
62
+ - "Your next message is running" names the project ("on project <id>") when the message that started waited on
63
+ another of your projects.
64
+ - When a new game's first build stops before it made the game (nothing saved, or only its design), the CLI prints
65
+ `Try again: velven create send --build "Build it"` under the reply.
66
+
67
+ ## 0.3.0
4
68
 
5
69
  - `velven create show --timeline` draws a turn's timeline as a text waterfall: every phase, step, model and tool call
6
70
  with its bar, start and time, the critical path marked with `*`. `--turn <id>` picks the turn (the current project's
@@ -17,9 +81,6 @@
17
81
  first build, `velven create show --timeline` calls such a turn "a quick first build", and `velven create stats` heads
18
82
  its groups "quick first builds", "quick later builds", "quick build turns" (turns from before first builds were told
19
83
  apart) and "quick plan turns". An older Velven's answers, without these fields, read as before.
20
-
21
- ## 0.3.0
22
-
23
84
  - A version holds up to 512 MB and 5,000 files when you're signed in, so a game engine's web export fits. Without an
24
85
  account it's still 50 MB and 1,000 files.
25
86
  - A file over 32 MB is sent in parts of 32 MB, each through its own upload link. A dropped upload resumes where it
package/README.md CHANGED
@@ -153,37 +153,80 @@ to pick another.
153
153
  it and give you a preview link to play. It needs an account, since every turn spends credits.
154
154
 
155
155
  ```sh
156
- velven create new "Fox Jump"
157
- velven create send "a one-button game where a fox jumps over logs" --effort quick
156
+ velven create new "Petal Drift"
157
+ velven create send "a cozy game where you grow a garden on a floating island"
158
158
  ```
159
159
 
160
160
  | Command | What it does |
161
161
  | --- | --- |
162
- | `velven create new [title]` | Start a project. It becomes the current project. `--engine` and `--genre` pick the packs (`phaser` and `arcade` by default) |
163
- | `velven create send <text>` | Send the current project a message. The CLI prints the estimate, asks before sending in a terminal, then follows the turn to its end |
162
+ | `velven create new [title]` | Start a project. It becomes the current project. Without `--genre` it is an open game: Velven follows your idea (`--genre arcade` holds it to arcade's rules). Without `--engine`, Velven picks 2D (`phaser`) or 3D (`three`) from your idea: 3D unless it is clearly 2D |
163
+ | `velven create send <text>` | Send the current project a message. Velven picks how much to do unless you pass `--effort` or a plan flag. The CLI prints the estimate, asks before sending in a terminal, then follows the turn to its end. `--build` builds now, `--ref <file>` attaches a picture (`--ref ref-3` one the project keeps). Sent while a turn runs on the project, the message waits and runs when that turn ends (followed at once when that turn ended as it was sent; exit 4 with why when Velven refused it) |
164
+ | `velven create plan` | A new game's short plan: what the game is, how it plays, its look, what is in it, how it ends. `--project <id>` picks another project |
165
+ | `velven create queue` | The messages waiting behind the running turn, in the order they run, and any Velven could not start with why. `--project <id>` picks another project |
166
+ | `velven create queue rm <id>` | Remove a waiting message before it runs |
164
167
  | `velven create watch [turn]` | Follow a turn, by default the last one you sent or watched. `--from <n>` starts from that event |
165
168
  | `velven create cancel [turn]` | Stop a turn. What it finished is saved, and unused credits come back |
166
169
  | `velven create list` | Your projects. `*` marks the current one |
167
170
  | `velven create show [project]` | A project, its newest turn and a fresh preview link (it works for 24 hours) |
168
171
  | `velven create show --timeline` | The newest turn's timeline as a waterfall (see below). `--turn <id>` picks another turn, `--all` lists every tool call |
172
+ | `velven create refs` | The project's reference pictures: each id, when it was added, its size and how many messages carried it. `--project <id>` picks another project |
173
+ | `velven create refs rm <ref-n>` | Delete a reference picture, sent or not. The messages that carried it keep its number, and the number is never given to another picture |
169
174
  | `velven create credits` | Your credits: today's allowance, your balance, what open turns hold, and the latest entries |
175
+ | `velven create usage` | What each of your turns held and used, newest first. `--project <id>` keeps one project's, `--before <turn>` reads the page before (the last line names it), `--json` prints the page |
170
176
  | `velven create stats` | For Velven's admins: p50 and p90 over recent turns. `--days <n>` (1 to 90, 7 by default), `--origin <origin>` |
171
177
 
172
178
  `velven create send` options:
173
179
 
174
- - `--effort quick|standard|deep` sets how much the turn may do. A quick turn writes and builds the game. A standard
175
- turn also plays it and has its code reviewed. Deep is not available yet.
176
- - `--plan` turns on plan mode: the turn writes or updates the project's design document and changes no game files.
177
- `--no-plan` turns it off. Plan mode is on by default for standard turns and off for quick ones.
178
- - Effort and plan mode stay as you last set them for the project, so the CLI sends them only when you pass them.
180
+ - Without `--effort`, `--plan` or `--no-plan`, Velven picks the turn's work from your message: a quick change, a full
181
+ build that it also playtests and fixes, or a plan. The CLI prints "Velven picks how much to do: about 20 credits
182
+ for a small change, at most 1500 held." first (on a project that never built, the figure is a first build's, and
183
+ the line says so); what is not spent comes back when the turn ends. Effort and plan mode then stay as the project
184
+ had them.
185
+ - A new game starts with a short conversation: Velven asks what matters most and is still open (2D or 3D, the style,
186
+ at most four questions in all, fewer for a specific idea), then sketches a short plan. A question comes with
187
+ numbered choices and how to answer it: send a choice, your own words, or `--build "Just build it"`; the style
188
+ question also takes a picture with `--ref <file>`. The line names the turn's own project, also when you follow it
189
+ with `velven create watch` from another. The plan prints as a card (the title, the pitch, 2D or 3D and the camera,
190
+ how it plays, the look and palette, what is in it, how it ends, a score or not); send a change in words to reshape
191
+ it, or `--build "Build it"` to build it. `velven create plan` prints it again.
192
+ - `--build` builds now: no more questions, and Velven fills in what the plan leaves open (writing the plan first when
193
+ there is none). Words beyond a bare build change the plan first (`--build "make it 2D"`: the plan is written again
194
+ in 2D, then built); `--build "Build it"` builds the plan as it is. Velven still picks how much to do.
195
+ - `--ref <file>` attaches a reference picture (PNG, JPEG or WebP, up to 10 MB, up to 4 on a message): the CLI uploads
196
+ each to the project first (straight to Velven's storage through a link Velven gives, then Velven keeps it), then
197
+ sends the message with them. `--ref ref-3` sends a picture the project already keeps, by the id Velven printed when
198
+ it was attached, with no upload. Velven's agents read a picture's style, a character or a game's screen from it. A
199
+ picture Velven refuses, or one over 10 MB, stops the CLI with its sentence and exit 4, before the message is sent.
200
+ A project keeps up to 16 pictures. One no message carried goes a day after it was last uploaded; a sent one stays
201
+ until you delete it. When a project is full, the CLI says so and names `velven create refs` and
202
+ `velven create refs rm <ref-n>`. A project that has given out all 999 picture numbers takes no new one (exit 4,
203
+ `reference_numbers_used`): start a new project.
204
+ - `--effort quick|standard|deep` sets how much the turn may do, in place of Velven's pick. A quick turn writes and
205
+ builds the game. A standard turn also plays it and has its code reviewed. Deep is not available yet.
206
+ - `--plan` turns on plan mode, in place of Velven's pick: the turn writes or updates the project's design document and
207
+ changes no game files. `--no-plan` turns it off.
208
+ - The project keeps the effort and plan mode you last passed, for a later message that passes only one of them (with
209
+ `--effort` alone, a new standard or deep effort turns plan mode on, quick turns it off). A message with neither is
210
+ Velven's pick again, whatever you passed before; the CLI sends them only when you pass them.
179
211
  - `--yes` sends without asking. Outside a terminal, the CLI never asks.
180
212
  - `--detach` sends the message and returns at once. Follow the turn later with `velven create watch`.
181
213
  - `--project <id>` sends to a project other than the current one.
214
+ - A message sent while a turn runs on the project waits behind it (at most five a project), holding no credits, and
215
+ starts on its own when that turn ends: the CLI prints "Queued: it runs after the turn that is running." and exits 0.
216
+ `velven create queue` lists what waits, `velven create queue rm <id>` removes one; cancelling the running turn
217
+ leaves the queue as it is. Following the turn that ends prints "Your next message is running: velven create watch
218
+ <turn>" (with "on project <id>" when the message that started waited on another of your projects; one waiting on
219
+ the same project starts first). A queued message that starts at once but waits for room on Velven says "Turn <id>
220
+ waits for room on Velven and starts on its own.", as any such send.
182
221
 
183
222
  The CLI says what the turn does as it goes. What the agents write goes to standard output, and their progress goes to
184
- standard error. You see what each step changed, the live link while the game is being built, each playtest's preview
185
- link (the first playable one, before the turn ends), its screenshots and its clip. When the turn ends, it prints the reply, the preview link and the credits used. A turn that stops at
186
- its credit cap or time limit saves what it finished and ends with exit 0. A failed turn costs nothing.
223
+ standard error. When Velven picked the work, it says so in words first ("I'll make the change."; when the day's
224
+ credits kept it from a fuller turn, "I'll make the change. I kept this one quick to save credits."), with the work it
225
+ picked and why on standard error. On a build it prints the plain step it is at ("Designing your game", "Painting the art", "Building the world", "Trying it out", "Polishing", "Finishing up"), and on a first build the design as it is settled ("Designing Petal Drift: ..." with its style and palette) and each picture once it lands ("Painted sky (1024 by 512)" with a link to look at it). You see what each step changed, the live link while the game is being built, each playtest's preview
226
+ link (the first playable one, before the turn ends), its screenshots and its clip. When the turn ends, it prints the reply (when the turn asked a question, which it already printed with its choices, only what follows it), then, after a turn that ended with a playable game, "Where it could go next:" with two to four short suggestions for this game and the command that sends the first (`velven create send "Add a boss at the end"`; each is an ordinary message), then the preview link and the credits used. A reply may end with one sentence on what the turn took from your pictures, and with "You're running low on credits for today." when what is left would not cover another small change. A turn that stops at
227
+ its credit cap or time limit saves what it finished and ends with exit 0. A failed turn costs nothing. When a new game's
228
+ first build stops before it made the game (it saved nothing, or only its design), the reply ends "Press Build it to try
229
+ again." and the CLI prints `Try again: velven create send --build "Build it"`; "try again" typed after it builds too.
187
230
 
188
231
  When Velven is busy, a turn waits for room and starts on its own. The CLI prints "Still waiting for room on Velven."
189
232
  and keeps following it, however long it waits. If the connection drops, the CLI reconnects and carries on where it
@@ -207,14 +250,15 @@ More than 20 tool calls or screenshots under one line fold into one; `--all` lis
207
250
  newest turn of the current project, or of the project you name.
208
251
 
209
252
  `velven create stats` is for Velven's admins (anyone else is told so, with exit 4). It prints one table for each effort,
210
- plan mode and engine over the turns created in the last `--days` days (`--origin` keeps the turns sent from one place:
253
+ plan mode, engine and, for the turns Velven routed, the work it picked ("quick fast first builds", "standard full later
254
+ builds", "quick interview turns", "quick draft turns" for a new game's short plan) over the turns created in the last `--days` days (`--origin` keeps the turns sent from one place:
211
255
  `web`, `api`, `cli`, `mcp` or `benchmark`): how many finished and failed, p50 and p90 of the first live link, the first
212
256
  change, the first playable preview and the whole turn, each phase, each job's cost in credits and USD cents and its model time, and the
213
- single steps worth comparing, longest first.
257
+ single steps worth comparing, longest first. Under a group's heading, "the next message:" says how the message after its turns reacted to them, as Velven's router labelled it (moved on, corrected it, asked for checks), when any was labelled.
214
258
 
215
- With `--json`, `new`, `send`, `list`, `show`, `credits` and `stats` print one JSON object, and `send` prints only the
216
- finished turn; `show --timeline --json` prints the turn's id and its timeline (`started_at` and the spans). `watch --json`
217
- is different: it prints each event of the turn as it comes, one JSON object per line.
259
+ With `--json`, `new`, `send`, `list`, `show`, `plan`, `queue`, `credits`, `usage` and `stats` print one JSON object, and `send` prints only the
260
+ finished turn (or the waiting message, `queued`, when it was queued); `show --timeline --json` prints the turn's id and its timeline (`started_at` and the spans). `watch --json`
261
+ is different: it prints each event of the turn as it comes, one JSON object per line (the plan and the brief as they are written included, which text mode leaves out until the last).
218
262
 
219
263
  The current project and the last turn are saved in `~/.config/velven/create.json`, one set for each Velven
220
264
  `VELVEN_API` points at.
package/dist/velven.js CHANGED
@@ -23,7 +23,7 @@ class CliError extends Error {
23
23
  }
24
24
  }
25
25
  // package.json
26
- var version = "0.3.0";
26
+ var version = "0.4.0";
27
27
 
28
28
  // src/version.ts
29
29
  var VERSION = version;
@@ -76,16 +76,28 @@ function causeOf(e) {
76
76
  return e instanceof Error && e.cause instanceof Error ? e.cause.message : e instanceof Error ? e.message : String(e);
77
77
  }
78
78
  async function call(api, method, path, body) {
79
+ return answerOf(api, path, body === undefined ? undefined : { type: "application/json", data: JSON.stringify(body) }, method);
80
+ }
81
+ async function putBytes(url, bytes, type, tooLarge) {
82
+ let response;
83
+ try {
84
+ response = await fetch(url, { method: "PUT", headers: { "content-type": type, "user-agent": USER_AGENT }, body: bytes });
85
+ } catch (e) {
86
+ throw new CliError(`Could not upload the picture (${causeOf(e)}).`, EXIT.failed, "network");
87
+ }
88
+ await response.text().catch(() => "");
89
+ if (response.status === 413)
90
+ throw new CliError(tooLarge, EXIT.refused, "too_large");
91
+ if (!response.ok)
92
+ throw new CliError(`The picture's upload was refused (HTTP ${response.status}). Try again in a moment.`, response.status >= 500 ? EXIT.failed : EXIT.refused, `http_${response.status}`);
93
+ }
94
+ async function answerOf(api, path, body, method) {
79
95
  const headers = requestHeaders(api, "application/json");
80
96
  if (body !== undefined)
81
- headers["content-type"] = "application/json";
97
+ headers["content-type"] = body.type;
82
98
  let response;
83
99
  try {
84
- response = await fetch(`${api.base}${path}`, {
85
- method,
86
- headers,
87
- body: body === undefined ? undefined : JSON.stringify(body)
88
- });
100
+ response = await fetch(`${api.base}${path}`, { method, headers, body: body?.data });
89
101
  } catch (e) {
90
102
  throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
91
103
  }
@@ -408,7 +420,11 @@ function parseArgs(argv, spec) {
408
420
  if (value === undefined || eq === -1 && value.startsWith("--")) {
409
421
  throw new CliError(`--${name} needs a value.`, EXIT.usage, "usage");
410
422
  }
411
- flags[name] = value;
423
+ if (kind === "strings") {
424
+ const given = flags[name];
425
+ flags[name] = [...Array.isArray(given) ? given : [], value];
426
+ } else
427
+ flags[name] = value;
412
428
  }
413
429
  return { positionals, flags };
414
430
  }
@@ -416,6 +432,10 @@ function stringFlag(parsed, name) {
416
432
  const value = parsed.flags[name];
417
433
  return typeof value === "string" ? value : undefined;
418
434
  }
435
+ function stringsFlag(parsed, name) {
436
+ const value = parsed.flags[name];
437
+ return Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
438
+ }
419
439
 
420
440
  // src/context.ts
421
441
  import { spawn } from "node:child_process";
@@ -467,6 +487,10 @@ function nodeContext() {
467
487
  };
468
488
  }
469
489
 
490
+ // src/create.ts
491
+ import { readFile as readFile3 } from "node:fs/promises";
492
+ import { resolve } from "node:path";
493
+
470
494
  // src/create-state.ts
471
495
  import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
472
496
  import { dirname as dirname2, join as join2 } from "node:path";
@@ -512,7 +536,7 @@ var BAR_WIDTH = 60;
512
536
  var FOLD_OVER = 20;
513
537
  var LABEL_MAX = 44;
514
538
  var CHAIN_SLACK_MS = 50;
515
- var PHASE_ORDER = ["workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
539
+ var PHASE_ORDER = ["routing", "workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
516
540
  var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
517
541
  function seconds(ms) {
518
542
  return `${(ms / 1000).toFixed(1)} s`;
@@ -569,7 +593,11 @@ function spanFacts(span) {
569
593
  fps_median: (v) => `${v} fps`,
570
594
  memory_mb: (v) => `${v} MB memory`,
571
595
  clocks: (v) => Number(v) > 1 ? "across two clocks" : null,
572
- reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null
596
+ reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null,
597
+ route: (v) => `routed ${v}`,
598
+ route_by: (v) => v === "fallback" ? "the router did not answer" : null,
599
+ downgraded: (v) => v === true ? "full was over the hold" : null,
600
+ route_reason: (v) => `"${v}"`
573
601
  };
574
602
  if (span.kind === "model") {
575
603
  const input = (num(a.input) ?? 0) + (num(a.cached) ?? 0);
@@ -707,7 +735,7 @@ function bar(row, total) {
707
735
  return `|${" ".repeat(a)}${"#".repeat(b - a)}${" ".repeat(BAR_WIDTH - b)}|`;
708
736
  }
709
737
  function renderTimeline(turn, spans, options = {}) {
710
- const kind = `${turn.effort} ${turn.plan ? "plan turn" : isFirstBuild(spans) ? "first build" : "build turn"}`;
738
+ const kind = turnKind(turn, spans);
711
739
  if (spans.length === 0) {
712
740
  const why = turn.status === "queued" || turn.status === "running" ? " A turn's timeline is written when it ends." : "";
713
741
  return [`Turn ${turn.id}, a ${kind}, ${turn.status}: no timeline.${why}`];
@@ -768,7 +796,23 @@ function renderTimeline(turn, spans, options = {}) {
768
796
  return lines;
769
797
  }
770
798
  function isFirstBuild(spans) {
771
- return spans.some((s) => s.name === "beginTurn" && s.attrs?.first_build === true);
799
+ return spans.some((s) => (s.name === "beginTurn" || s.name === "askRouter") && s.attrs?.first_build === true);
800
+ }
801
+ function workOf(spans) {
802
+ const interview = spans.find((s) => s.name === "askInterviewer" && typeof s.attrs?.interview === "string")?.attrs?.interview;
803
+ if (interview === "ask")
804
+ return "interview";
805
+ if (interview === "draft")
806
+ return "draft";
807
+ const route = spans.find((s) => s.name === "askRouter" && typeof s.attrs?.route === "string")?.attrs?.route;
808
+ return typeof route === "string" ? route : null;
809
+ }
810
+ function turnKind(turn, spans) {
811
+ const work = workOf(spans);
812
+ if (work === "interview" || work === "draft")
813
+ return `${turn.effort} ${work} turn`;
814
+ const what = turn.plan ? "plan turn" : isFirstBuild(spans) ? "first build" : "build turn";
815
+ return `${turn.effort} ${work === "fast" || work === "full" ? `${work} ` : ""}${what}`;
772
816
  }
773
817
  function firsts(timing) {
774
818
  if (!timing || timing.first_dev_ms === undefined && timing.first_write_ms === undefined && timing.first_preview_ms === undefined)
@@ -824,8 +868,11 @@ function table(rows, indent) {
824
868
  var sec = (ms) => ms === null ? "-" : seconds(ms);
825
869
  var pair = (p, f) => `${f(p.p50)} / ${f(p.p90)}`;
826
870
  function groupTitle(g) {
871
+ if (g.work === "interview" || g.work === "draft")
872
+ return `${g.effort} ${g.work} turns, ${g.engine}`;
827
873
  const what = g.plan ? "plan turns" : g.first_build === true ? "first builds" : g.first_build === false ? "later builds" : "build turns";
828
- return `${g.effort} ${what}, ${g.engine}`;
874
+ const work = g.work === "plan" ? "routed " : g.work ? `${g.work} ` : "";
875
+ return `${g.effort} ${work}${what}, ${g.engine}`;
829
876
  }
830
877
  function renderStats(groups, options) {
831
878
  const over = `the last ${plural(options.days, "day")}${options.origin ? `, from ${options.origin}` : ""}`;
@@ -837,6 +884,9 @@ function renderStats(groups, options) {
837
884
  const lines = [`Turns created in ${over}: p50 and p90 over the done turns.`];
838
885
  for (const g of groups) {
839
886
  lines.push("", `${groupTitle(g)}: ${plural(g.turns, "done turn")}, ${g.failed} failed`);
887
+ const reacted = REACTION_WORDS.filter(([key]) => (g.reactions?.[key] ?? 0) > 0).map(([key, words]) => `${words} ${g.reactions[key]}`);
888
+ if (reacted.length)
889
+ lines.push(` the next message: ${reacted.join(", ")}`);
840
890
  const figures = [
841
891
  ["figure", "p50", "p90", "turns"],
842
892
  ["first live link", sec(g.first_dev_ms.p50), sec(g.first_dev_ms.p90), ""],
@@ -865,6 +915,12 @@ function renderStats(groups, options) {
865
915
  }
866
916
  return lines;
867
917
  }
918
+ var REACTION_WORDS = [
919
+ ["moved_on", "moved on"],
920
+ ["corrected", "corrected it"],
921
+ ["asked_checks", "asked for checks"],
922
+ ["none", "no reaction"]
923
+ ];
868
924
  function rank(phase) {
869
925
  const i = PHASE_ORDER.indexOf(phase);
870
926
  return i === -1 ? PHASE_ORDER.length : i;
@@ -875,13 +931,24 @@ var CREATE_HELP = `velven create: describe a game, and Velven's agents build it.
875
931
 
876
932
  Usage:
877
933
  velven create new [title...] Start a project; it becomes the current one
878
- --engine <id> The engine pack (default phaser)
879
- --genre <id> The genre pack (default arcade)
880
- velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn
934
+ --engine <id> The engine pack (default: Velven picks, 3D unless your idea is clearly 2D)
935
+ --genre <id> The genre pack (default none: an open game, Velven follows your idea;
936
+ --genre arcade holds it to arcade's rules)
937
+ velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn.
938
+ Velven picks how much to do (a quick change, a full build with a playtest,
939
+ a plan; on a new game a question or two, then a short plan to shape) unless
940
+ you pass --effort or a plan flag. Sent while a turn runs, it waits its turn
881
941
  --project <id> A project other than the current one
882
- --effort quick|standard|deep How much the turn may do; kept for later messages until you change it
883
- --plan, --no-plan Plan mode: the turn updates the design document and changes no game files;
884
- kept until you change it (on by default for standard, off for quick)
942
+ --effort quick|standard|deep How much the turn may do, instead of Velven's pick. The project keeps it
943
+ for a later message that passes only a plan flag; a message with neither
944
+ flag is Velven's pick again
945
+ --plan, --no-plan Plan mode, instead of Velven's pick: the turn updates the design document
946
+ and changes no game files. Kept as --effort is (with --effort alone, on for
947
+ a new standard or deep, off for quick)
948
+ --build Build it: no more questions, Velven fills in what the plan leaves open
949
+ (Velven still picks how much)
950
+ --ref <file|ref-n> A reference picture (PNG, JPEG or WebP, up to 10 MB) sent with the message, or
951
+ one the project keeps by its id (ref-3); up to 4
885
952
  --yes, -y Do not ask before sending
886
953
  --detach Send it and return at once; follow it later with watch
887
954
  velven create watch [turn] Follow a turn to its end (default: the last one)
@@ -894,19 +961,31 @@ Usage:
894
961
  --turn <id> With --timeline: this turn instead of the project's newest
895
962
  --all With --timeline: list every tool call and screenshot (past 20 under one
896
963
  step they fold into one line)
964
+ velven create refs The project's reference pictures: each id, when it was added and how many
965
+ messages carried it (a project keeps up to 16)
966
+ --project <id> A project other than the current one
967
+ velven create refs rm <ref-n> Delete a picture, sent or not; the messages that carried it keep its number
968
+ velven create plan The project's short plan: what the game is, how it plays, its look
969
+ --project <id> A project other than the current one
970
+ velven create queue The messages waiting behind the running turn, in the order they run
971
+ --project <id> A project other than the current one
972
+ velven create queue rm <id> Remove a waiting message
897
973
  velven create credits Your credits and the latest entries
974
+ velven create usage What each of your turns held and used, newest first
975
+ --project <id> Only this project's turns
976
+ --before <turn> The page before this turn (the line at the end names it)
898
977
  velven create stats Admins: p50 and p90 over recent turns, by effort, plan mode and engine
899
978
  --days <n> Turns created in the last n days, 1 to 90 (default 7)
900
979
  --origin <origin> Only turns sent from web, api, cli, mcp or benchmark
901
980
 
902
- --json: new, send, list, show, credits and stats print one JSON object (send prints the finished turn,
981
+ --json: new, send, list, show, plan, queue, refs, credits, usage and stats print one JSON object (send prints the finished turn,
903
982
  show --timeline the timeline); watch prints one event per line.
904
983
  Ctrl+C stops following a turn; the turn goes on.
905
984
 
906
985
  Exit codes:
907
986
  0 done (also a turn that stopped at its credit cap or time limit) 1 failed, cancelled or no longer followed
908
987
  2 bad command 3 needs sign-in 4 refused (no credits, closed, busy, too many turns, effort unavailable,
909
- not found) 5 rate limited, try later`;
988
+ a picture refused, not found) 5 rate limited, try later`;
910
989
  var CREATE_FLAGS = {
911
990
  engine: "string",
912
991
  genre: "string",
@@ -914,6 +993,8 @@ var CREATE_FLAGS = {
914
993
  effort: "string",
915
994
  plan: "boolean",
916
995
  "no-plan": "boolean",
996
+ build: "boolean",
997
+ ref: "strings",
917
998
  yes: "boolean",
918
999
  detach: "boolean",
919
1000
  from: "string",
@@ -922,19 +1003,26 @@ var CREATE_FLAGS = {
922
1003
  turn: "string",
923
1004
  all: "boolean",
924
1005
  days: "string",
925
- origin: "string"
1006
+ origin: "string",
1007
+ before: "string"
926
1008
  };
927
1009
  var SUBCOMMANDS = {
928
1010
  new: ["engine", "genre", "json"],
929
- send: ["project", "effort", "plan", "no-plan", "yes", "detach", "json"],
1011
+ send: ["project", "effort", "plan", "no-plan", "build", "ref", "yes", "detach", "json"],
930
1012
  watch: ["from", "json"],
931
1013
  cancel: [],
932
1014
  list: ["json"],
933
1015
  show: ["json", "timeline", "turn", "all"],
1016
+ refs: ["project", "json"],
1017
+ plan: ["project", "json"],
1018
+ queue: ["project", "json"],
934
1019
  credits: ["json"],
1020
+ usage: ["project", "before", "json"],
935
1021
  stats: ["days", "origin", "json"]
936
1022
  };
937
1023
  var EFFORTS = ["quick", "standard", "deep"];
1024
+ var REFS_PER_MESSAGE = 4;
1025
+ var REFS_PER_PROJECT = 16;
938
1026
  var ORIGINS = ["web", "api", "cli", "mcp", "benchmark"];
939
1027
  var ROOT = "/api/v1/create";
940
1028
  async function create(ctx, parsed) {
@@ -974,6 +1062,14 @@ async function create(ctx, parsed) {
974
1062
  return show(ctx, api, args[0], json);
975
1063
  case "stats":
976
1064
  return stats(ctx, api, stringFlag(parsed, "days"), stringFlag(parsed, "origin"), json);
1065
+ case "refs":
1066
+ return referencePictures(ctx, api, args, stringFlag(parsed, "project"), json);
1067
+ case "usage":
1068
+ return usage(ctx, api, stringFlag(parsed, "project"), stringFlag(parsed, "before"), json);
1069
+ case "plan":
1070
+ return showPlan(ctx, api, args, stringFlag(parsed, "project"), json);
1071
+ case "queue":
1072
+ return queue(ctx, api, args, stringFlag(parsed, "project"), json);
977
1073
  default:
978
1074
  return credits(ctx, api, json);
979
1075
  }
@@ -993,7 +1089,11 @@ async function newProject(ctx, api, title, engine, genre, json) {
993
1089
  return EXIT.ok;
994
1090
  }
995
1091
  ctx.out(`Started ${project.title ?? "a project"} (${project.id}): ${packs(project)}. It is now the current project.`);
996
- ctx.out('Tell it what to build: velven create send "a one-button game where a fox jumps over logs"');
1092
+ if (!project.genre)
1093
+ ctx.out("An open game: Velven follows your idea. --genre arcade holds a project to arcade's rules.");
1094
+ if (!engine)
1095
+ ctx.out("Velven picks the engine from your idea: 3D unless it is clearly 2D.");
1096
+ ctx.out('Tell it what to build: velven create send "a cozy game where you grow a garden on a floating island"');
997
1097
  return EXIT.ok;
998
1098
  }
999
1099
  async function send(ctx, api, words, parsed, json) {
@@ -1008,17 +1108,32 @@ async function send(ctx, api, words, parsed, json) {
1008
1108
  throw new CliError("Pass --plan or --no-plan, not both.", EXIT.usage, "usage");
1009
1109
  }
1010
1110
  const plan = parsed.flags.plan === true ? true : parsed.flags["no-plan"] === true ? false : undefined;
1111
+ const routed = effort === undefined && plan === undefined;
1112
+ const refs = stringsFlag(parsed, "ref");
1113
+ if (refs.length > REFS_PER_MESSAGE)
1114
+ throw new CliError(`A message takes up to ${REFS_PER_MESSAGE} pictures; ${refs.length} were given with --ref.`, EXIT.usage, "usage");
1115
+ const pictures = await Promise.all(refs.map((ref) => REF_ID.test(ref) ? ref : readPicture(ctx, ref)));
1011
1116
  const project = await currentProject(ctx, api.base, stringFlag(parsed, "project"));
1012
1117
  const say = json ? () => {} : (line) => ctx.out(line);
1013
1118
  const query = new URLSearchParams;
1119
+ if (routed)
1120
+ query.set("routed", "true");
1121
+ if (routed && parsed.flags.build === true)
1122
+ query.set("intent", "build");
1014
1123
  if (effort !== undefined)
1015
1124
  query.set("effort", effort);
1016
1125
  if (plan !== undefined)
1017
1126
  query.set("plan", String(plan));
1127
+ query.set("queue", "true");
1018
1128
  const search = query.toString();
1019
1129
  const { estimate } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/estimate${search ? `?${search}` : ""}`);
1020
- const kind = estimate.plan ? `${estimate.effort} plan turn` : estimate.first_build === true ? `${estimate.effort} first build (its pictures are made before the first preview)` : `${estimate.effort} build turn`;
1021
- say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
1130
+ if (routed) {
1131
+ const what = estimate.shaping === true ? "a question or a short plan" : estimate.first_build === true ? "a first build (its pictures are made before the first preview)" : "a small change";
1132
+ say(`Velven picks how much to do: about ${estimate.credits.typical} credits for ${what}, at most ${estimate.credits.cap} held. You have ${estimate.available} available.`);
1133
+ } else {
1134
+ const kind = estimate.plan ? `${estimate.effort} plan turn` : estimate.first_build === true ? `${estimate.effort} first build (its pictures are made before the first preview)` : `${estimate.effort} build turn`;
1135
+ say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
1136
+ }
1022
1137
  if (!estimate.enough) {
1023
1138
  throw new CliError(estimate.reason ?? "Velven cannot start this turn now.", EXIT.refused, "not_enough");
1024
1139
  }
@@ -1029,13 +1144,39 @@ async function send(ctx, api, words, parsed, json) {
1029
1144
  return EXIT.failed;
1030
1145
  }
1031
1146
  }
1032
- const body = { text };
1147
+ const body = { text, queue: true };
1033
1148
  if (effort !== undefined)
1034
1149
  body.effort = effort;
1035
1150
  if (plan !== undefined)
1036
1151
  body.plan = plan;
1152
+ if (parsed.flags.build === true)
1153
+ body.intent = "build";
1154
+ if (pictures.length) {
1155
+ const ids = [];
1156
+ for (const picture of pictures)
1157
+ ids.push(typeof picture === "string" ? picture : await uploadPicture(api, project, picture));
1158
+ body.references = ids;
1159
+ say(`Attached ${plural2(ids.length, "picture")}: ${ids.join(", ")}.`);
1160
+ }
1037
1161
  const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body);
1038
- const turn = sent.turn;
1162
+ let turn;
1163
+ let stream2;
1164
+ if ("queued" in sent) {
1165
+ const queued = sent.queued;
1166
+ if (queued.state === "refused")
1167
+ throw new CliError(queued.refused ?? "Velven could not start this message.", EXIT.refused, "refused");
1168
+ if (queued.state === "waiting" || queued.turn === null) {
1169
+ if (json)
1170
+ ctx.out(JSON.stringify({ ok: true, queued }));
1171
+ else
1172
+ say(queuedLine(queued));
1173
+ return EXIT.ok;
1174
+ }
1175
+ turn = { id: queued.turn, ...queued.capped === true ? { queued: true } : {} };
1176
+ } else {
1177
+ turn = sent.turn;
1178
+ stream2 = sent.stream;
1179
+ }
1039
1180
  await saveCreateState(ctx, api.base, { project, turn: turn.id });
1040
1181
  if (turn.queued)
1041
1182
  say(`Turn ${turn.id} waits for room on Velven and starts on its own.`);
@@ -1048,8 +1189,46 @@ async function send(ctx, api, words, parsed, json) {
1048
1189
  say(`Follow it: velven create watch ${turn.id}; stop it: velven create cancel ${turn.id}`);
1049
1190
  return EXIT.ok;
1050
1191
  }
1051
- const path = sent.stream?.startsWith("/") ? sent.stream : `${ROOT}/turns/${seg(turn.id)}/stream`;
1052
- return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text");
1192
+ const path = stream2?.startsWith("/") ? stream2 : `${ROOT}/turns/${seg(turn.id)}/stream`;
1193
+ return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text", project);
1194
+ }
1195
+ var REF_ID = /^ref-[1-9][0-9]{0,2}$/;
1196
+ async function uploadPicture(api, project, picture) {
1197
+ const { upload } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references/uploads`);
1198
+ const tooLarge = `The picture ${picture.file} is larger than ${Math.floor(upload.max_bytes / (1024 * 1024))} MB. Send a smaller one.`;
1199
+ if (picture.bytes.length > upload.max_bytes)
1200
+ throw new CliError(tooLarge, EXIT.refused, "too_large");
1201
+ await putBytes(upload.url, picture.bytes, picture.type, tooLarge);
1202
+ try {
1203
+ const { reference } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references`, { upload: upload.id });
1204
+ return reference.id;
1205
+ } catch (e) {
1206
+ if (e instanceof CliError && e.code === "too_many_references") {
1207
+ const named = `velven create refs --project ${project}`;
1208
+ throw new CliError(`${e.message} See them: ${named}; remove one: velven create refs rm <ref-n> --project ${project}.`, e.exitCode, e.code);
1209
+ }
1210
+ throw e;
1211
+ }
1212
+ }
1213
+ var PICTURE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
1214
+ async function readPicture(ctx, file) {
1215
+ let bytes;
1216
+ try {
1217
+ bytes = new Uint8Array(await readFile3(resolve(ctx.cwd, file)));
1218
+ } catch {
1219
+ throw new CliError(`Could not read the picture ${file}.`, EXIT.usage, "usage");
1220
+ }
1221
+ const ext = file.toLowerCase().split(".").pop() ?? "";
1222
+ return { file, bytes, type: PICTURE_TYPES[ext] ?? "application/octet-stream" };
1223
+ }
1224
+ async function turnProject(ctx, api, turn) {
1225
+ const { project: current } = await readCreateState(ctx, api.base);
1226
+ try {
1227
+ const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
1228
+ return view.project ?? current ?? null;
1229
+ } catch {
1230
+ return current ?? null;
1231
+ }
1053
1232
  }
1054
1233
  async function watch(ctx, api, arg, fromFlag, json) {
1055
1234
  let from;
@@ -1059,9 +1238,10 @@ async function watch(ctx, api, arg, fromFlag, json) {
1059
1238
  from = Number(fromFlag);
1060
1239
  }
1061
1240
  const turn = await turnId(ctx, api.base, arg, "watch");
1241
+ const named = json ? null : await turnProject(ctx, api, turn);
1062
1242
  let code;
1063
1243
  try {
1064
- code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text");
1244
+ code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text", named);
1065
1245
  } catch (e) {
1066
1246
  if (e instanceof CliError && e.code === "stream_lost")
1067
1247
  await saveCreateState(ctx, api.base, { turn });
@@ -1070,10 +1250,10 @@ async function watch(ctx, api, arg, fromFlag, json) {
1070
1250
  await saveCreateState(ctx, api.base, { turn });
1071
1251
  return code;
1072
1252
  }
1073
- async function follow(ctx, api, turn, path, from, mode) {
1253
+ async function follow(ctx, api, turn, path, from, mode, project) {
1074
1254
  const controller = new AbortController;
1075
1255
  ctx.untilStopped().then(() => controller.abort());
1076
- const said = { held: null, engine: null };
1256
+ const said = { held: null, engine: null, project, asked: false, next: [] };
1077
1257
  let final;
1078
1258
  try {
1079
1259
  final = await stream(api, path, { from, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
@@ -1118,7 +1298,7 @@ async function follow(ctx, api, turn, path, from, mode) {
1118
1298
  }
1119
1299
  ctx.out(JSON.stringify({ ok: code === EXIT.ok, ...error, turn: view }));
1120
1300
  } else if (mode === "text") {
1121
- finish(ctx, turn, event);
1301
+ finish(ctx, turn, event, said);
1122
1302
  }
1123
1303
  return code;
1124
1304
  }
@@ -1165,12 +1345,51 @@ function render(ctx, event, said) {
1165
1345
  release(ctx, said);
1166
1346
  switch (event.type) {
1167
1347
  case "turn.started":
1168
- ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held.`);
1348
+ if (event.routed === true)
1349
+ ctx.err(`Started: Velven is choosing the work, ${plural2(event.credits_held, "credit")} held.`);
1350
+ else
1351
+ ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held.`);
1352
+ return;
1353
+ case "route":
1354
+ ctx.out(event.said);
1355
+ ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held: ${event.reason})`);
1356
+ return;
1357
+ case "ask": {
1358
+ ctx.out(`${event.question} (question ${event.index} of ${event.of})`);
1359
+ for (const [i, choice] of event.choices.entries())
1360
+ ctx.out(` ${i + 1}. ${choice}`);
1361
+ const send2 = sendCommand(said);
1362
+ ctx.out(`Answer: ${send2} "${event.choices[0]}", or in your own words.`);
1363
+ if (event.picture || event.topic === "style")
1364
+ ctx.out(`Or show the look: ${send2} --ref <file> "like this".`);
1365
+ ctx.out(`Just build it: ${send2} --build "Just build it"`);
1366
+ said.asked = true;
1169
1367
  return;
1368
+ }
1369
+ case "plan":
1370
+ if (!event.done)
1371
+ return;
1372
+ for (const line of planCard(event.plan))
1373
+ ctx.out(line);
1374
+ ctx.out(`Build it: ${sendCommand(said)} --build "Build it"`);
1375
+ return;
1376
+ case "brief": {
1377
+ if (!event.done)
1378
+ return;
1379
+ if (event.title || event.pitch)
1380
+ ctx.out(`Designing ${event.title ?? "your game"}${event.pitch ? `: ${event.pitch}` : ""}`);
1381
+ if (event.style)
1382
+ ctx.out(` Style: ${event.style}`);
1383
+ if (event.palette.length)
1384
+ ctx.out(` Palette: ${event.palette.join(", ")}`);
1385
+ return;
1386
+ }
1170
1387
  case "turn.queued":
1171
1388
  ctx.err("Still waiting for room on Velven.");
1172
1389
  return;
1173
1390
  case "phase":
1391
+ if (event.progress)
1392
+ ctx.out(event.progress.label);
1174
1393
  ctx.err(`${capital(event.phase)}${event.detail ? `: ${event.detail}` : ""}`);
1175
1394
  return;
1176
1395
  case "job": {
@@ -1192,6 +1411,8 @@ function render(ctx, event, said) {
1192
1411
  }
1193
1412
  case "tool":
1194
1413
  ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
1414
+ if (event.picture?.url)
1415
+ ctx.out(`Painted ${event.picture.id} (${event.picture.width} by ${event.picture.height}) ${dim(ctx, event.picture.url)}`);
1195
1416
  return;
1196
1417
  case "text":
1197
1418
  ctx.out(event.text);
@@ -1245,18 +1466,32 @@ function render(ctx, event, said) {
1245
1466
  case "cost":
1246
1467
  ctx.err(`${event.credits_used} of ${plural2(event.credits_held, "credit")} used so far.`);
1247
1468
  return;
1469
+ case "next":
1470
+ said.next = event.choices;
1471
+ return;
1248
1472
  default:
1249
1473
  return;
1250
1474
  }
1251
1475
  }
1252
- function finish(ctx, turn, event) {
1476
+ function finish(ctx, turn, event, said) {
1253
1477
  if (event.type === "error") {
1254
1478
  const tail = event.code === "turn_stopped" || event.code === "not_found" ? " Check it again later: velven create show" : ` The turn may go on. Watch it: velven create watch ${turn}; check it: velven create show`;
1255
1479
  ctx.err(`${event.message || "The turn's stream answered with an error."}${tail}`);
1256
1480
  return;
1257
1481
  }
1258
- if (event.reply)
1259
- ctx.out(event.reply);
1482
+ const reply = event.reply && said.asked ? event.reply.split(`
1483
+
1484
+ `).slice(1).join(`
1485
+
1486
+ `) : event.reply;
1487
+ if (reply)
1488
+ ctx.out(reply);
1489
+ if (said.next.length) {
1490
+ ctx.out("Where it could go next:");
1491
+ for (const [i, choice] of said.next.entries())
1492
+ ctx.out(` ${i + 1}. ${choice}`);
1493
+ ctx.out(`Send one: velven create send${said.project ? ` --project ${said.project}` : ""} "${said.next[0]}"`);
1494
+ }
1260
1495
  if (event.preview)
1261
1496
  ctx.out(`Play it: ${event.preview.url}`);
1262
1497
  const back = event.refunded > 0 ? `; ${plural2(event.refunded, "credit")} came back` : "";
@@ -1266,13 +1501,56 @@ function finish(ctx, turn, event) {
1266
1501
  ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
1267
1502
  if (event.stop_reason === "time")
1268
1503
  ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
1269
- return;
1270
- }
1271
- if (event.status === "cancelled") {
1504
+ } else if (event.status === "cancelled") {
1272
1505
  ctx.err(`Turn ${turn} was cancelled: ${plural2(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
1273
- return;
1506
+ } else {
1507
+ ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
1274
1508
  }
1275
- ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
1509
+ if (reply?.includes(BUILD_AGAIN))
1510
+ ctx.out(`Try again: ${sendCommand(said)} --build "Build it"`);
1511
+ if (event.next_turn) {
1512
+ const elsewhere = event.next_project && said.project && event.next_project !== said.project ? ` on project ${event.next_project}` : "";
1513
+ ctx.out(`Your next message${elsewhere} is running: velven create watch ${event.next_turn}`);
1514
+ }
1515
+ }
1516
+ var BUILD_AGAIN = "Press Build it to try again.";
1517
+ function sendCommand(said) {
1518
+ return `velven create send${said.project ? ` --project ${said.project}` : ""}`;
1519
+ }
1520
+ function dim(ctx, text) {
1521
+ return ctx.interactive && !ctx.env.NO_COLOR ? `\x1B[2m${text}\x1B[22m` : text;
1522
+ }
1523
+ function planCard(plan) {
1524
+ const lines = [];
1525
+ if (plan.title)
1526
+ lines.push(plan.title);
1527
+ if (plan.pitch)
1528
+ lines.push(plan.pitch);
1529
+ if (plan.dimension)
1530
+ lines.push(`${plan.dimension === "3d" ? "3D" : "2D"}${plan.camera ? `, ${plan.camera}` : ""}`);
1531
+ if (plan.play?.length) {
1532
+ lines.push("How it plays:");
1533
+ for (const [i, beat] of plan.play.entries())
1534
+ lines.push(` ${i + 1}. ${beat}`);
1535
+ }
1536
+ if (plan.look)
1537
+ lines.push(`Look: ${plan.look}`);
1538
+ if (plan.palette?.length)
1539
+ lines.push(`Palette: ${plan.palette.join(", ")}`);
1540
+ if (plan.contents?.length) {
1541
+ lines.push("What is in it:");
1542
+ for (const thing of plan.contents)
1543
+ lines.push(` - ${thing}`);
1544
+ }
1545
+ if (plan.ending)
1546
+ lines.push(`How it ends: ${plan.ending}`);
1547
+ if (plan.score !== undefined)
1548
+ lines.push(plan.score ? "It keeps a score." : "No score.");
1549
+ return lines;
1550
+ }
1551
+ function queuedLine(queued) {
1552
+ const ahead = queued.position !== null && queued.position > 1 ? ` and ${plural2(queued.position - 1, "message")} ahead of it` : "";
1553
+ return `Queued: it runs after the turn that is running${ahead}. velven create queue lists what waits.`;
1276
1554
  }
1277
1555
  async function cancel(ctx, api, arg) {
1278
1556
  const turn = await turnId(ctx, api.base, arg, "cancel");
@@ -1383,6 +1661,125 @@ async function stats(ctx, api, daysFlag, origin, json) {
1383
1661
  ctx.out(line);
1384
1662
  return EXIT.ok;
1385
1663
  }
1664
+ async function referencePictures(ctx, api, args, projectFlag, json) {
1665
+ const [action, id, ...rest] = args;
1666
+ if (action === "rm") {
1667
+ if (id === undefined || !REF_ID.test(id) || rest.length) {
1668
+ throw new CliError("Name one picture to remove by its id, like velven create refs rm ref-3.", EXIT.usage, "usage");
1669
+ }
1670
+ const project2 = await currentProject(ctx, api.base, projectFlag);
1671
+ await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/references/${seg(id)}`);
1672
+ if (json)
1673
+ ctx.out(JSON.stringify({ ok: true, deleted: id }));
1674
+ else
1675
+ ctx.out(`Removed ${id}. The messages that carried it keep its number, shown as removed.`);
1676
+ return EXIT.ok;
1677
+ }
1678
+ if (action !== undefined) {
1679
+ throw new CliError(`Unknown command "create refs ${action}". List the pictures with velven create refs, remove one with velven create refs rm ref-3.`, EXIT.usage, "usage");
1680
+ }
1681
+ const project = await currentProject(ctx, api.base, projectFlag);
1682
+ const { references } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/references`);
1683
+ if (json) {
1684
+ ctx.out(JSON.stringify({ ok: true, references }));
1685
+ return EXIT.ok;
1686
+ }
1687
+ if (!references.length) {
1688
+ ctx.out('No reference pictures yet. Attach one: velven create send --ref <picture> "like this".');
1689
+ return EXIT.ok;
1690
+ }
1691
+ for (const r of references) {
1692
+ const carried = r.messages !== undefined ? r.messages ? `on ${plural2(r.messages, "message")}` : "not sent yet" : r.attached ? "sent" : "not sent yet";
1693
+ ctx.out(`${r.id.padEnd(7)} ${when(r.created_at).padEnd(20)} ${`${r.width} by ${r.height}`.padEnd(12)} ${carried}`);
1694
+ }
1695
+ ctx.out(`${references.length} of ${REFS_PER_PROJECT} kept. A picture no message carried goes a day after it was last uploaded.`);
1696
+ return EXIT.ok;
1697
+ }
1698
+ async function usage(ctx, api, project, before, json) {
1699
+ if (before !== undefined && (!/^\d+$/.test(before) || Number(before) === 0))
1700
+ throw new CliError(`--before is a turn number, like 12, not "${before}".`, EXIT.usage, "usage");
1701
+ const query = new URLSearchParams;
1702
+ if (project)
1703
+ query.set("project", project);
1704
+ if (before)
1705
+ query.set("before", before);
1706
+ const search = query.toString();
1707
+ const answer = await call(api, "GET", `${ROOT}/usage${search ? `?${search}` : ""}`);
1708
+ if (json) {
1709
+ ctx.out(JSON.stringify({ ok: true, ...answer }));
1710
+ return EXIT.ok;
1711
+ }
1712
+ if (!answer.turns.length) {
1713
+ ctx.out(before ? "No earlier turns." : "No turns yet.");
1714
+ return EXIT.ok;
1715
+ }
1716
+ for (const t of answer.turns) {
1717
+ const used = t.credits.charged !== null ? `${plural2(t.credits.charged, "credit")} used` : `${plural2(t.credits.held, "credit")} held`;
1718
+ const kind = t.work ?? `${t.effort}${t.plan ? " plan" : ""}`;
1719
+ ctx.out(`turn ${String(t.turn).padEnd(6)} ${when(t.created_at).padEnd(20)} ${(t.project.title ?? "Untitled").padEnd(24)} ${kind.padEnd(10)} ${t.status.padEnd(9)} ${used}`.trimEnd());
1720
+ }
1721
+ if (answer.next !== null)
1722
+ ctx.out(`More: velven create usage${project ? ` --project ${project}` : ""} --before ${answer.next}`);
1723
+ return EXIT.ok;
1724
+ }
1725
+ async function showPlan(ctx, api, args, projectFlag, json) {
1726
+ if (args.length)
1727
+ throw new CliError("velven create plan takes no words: name another project with --project <id>.", EXIT.usage, "usage");
1728
+ const id = await currentProject(ctx, api.base, projectFlag);
1729
+ const { project } = await call(api, "GET", `${ROOT}/projects/${seg(id)}`);
1730
+ const plan = project.game_plan ?? null;
1731
+ if (json) {
1732
+ ctx.out(JSON.stringify({ ok: true, plan }));
1733
+ return EXIT.ok;
1734
+ }
1735
+ if (!plan) {
1736
+ ctx.out('This project has no plan yet. Send your idea and Velven sketches one with you: velven create send "a cozy farming game".');
1737
+ return EXIT.ok;
1738
+ }
1739
+ for (const line of planCard(plan))
1740
+ ctx.out(line);
1741
+ if (!project.head)
1742
+ ctx.out(`Build it: velven create send${projectFlag ? ` --project ${id}` : ""} --build "Build it"`);
1743
+ return EXIT.ok;
1744
+ }
1745
+ async function queue(ctx, api, args, projectFlag, json) {
1746
+ const [action, id, ...rest] = args;
1747
+ if (action === "rm") {
1748
+ if (id === undefined || !/^\d+$/.test(id) || Number(id) === 0 || rest.length) {
1749
+ throw new CliError("Name one waiting message by its number, like velven create queue rm 12.", EXIT.usage, "usage");
1750
+ }
1751
+ const project2 = await currentProject(ctx, api.base, projectFlag);
1752
+ await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/queue/${seg(id)}`);
1753
+ if (json)
1754
+ ctx.out(JSON.stringify({ ok: true, removed: Number(id) }));
1755
+ else
1756
+ ctx.out(`Removed message ${id}: it will not run.`);
1757
+ return EXIT.ok;
1758
+ }
1759
+ if (action !== undefined) {
1760
+ throw new CliError(`Unknown command "create queue ${action}". List what waits with velven create queue, remove one with velven create queue rm 12.`, EXIT.usage, "usage");
1761
+ }
1762
+ const project = await currentProject(ctx, api.base, projectFlag);
1763
+ const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
1764
+ if (json) {
1765
+ ctx.out(JSON.stringify({ ok: true, queued }));
1766
+ return EXIT.ok;
1767
+ }
1768
+ if (!queued.length) {
1769
+ ctx.out("Nothing is waiting on this project.");
1770
+ return EXIT.ok;
1771
+ }
1772
+ for (const q of queued) {
1773
+ const at = q.position !== null ? `${q.position}.` : q.state === "refused" ? "x" : "-";
1774
+ const words = q.text.length > 60 ? `${q.text.slice(0, 59)}…` : q.text;
1775
+ ctx.out(`${at.padStart(3)} ${String(q.id).padEnd(6)} ${when(q.created_at).padEnd(20)} ${q.intent === "build" ? "[build] " : ""}${words}`);
1776
+ if (q.state === "refused" && q.refused)
1777
+ ctx.out(` Not sent: ${q.refused}`);
1778
+ }
1779
+ if (queued.some((q) => q.state === "waiting"))
1780
+ ctx.out("They run in this order when the running turn ends. Remove one: velven create queue rm <number>");
1781
+ return EXIT.ok;
1782
+ }
1386
1783
  async function credits(ctx, api, json) {
1387
1784
  const view = await call(api, "GET", `${ROOT}/credits`);
1388
1785
  if (json) {
@@ -1428,9 +1825,9 @@ async function turnId(ctx, base, arg, verb) {
1428
1825
 
1429
1826
  // src/dev.ts
1430
1827
  import { createReadStream as createReadStream2 } from "node:fs";
1431
- import { open as open2, readdir as readdir3, readFile as readFile5, realpath as realpath2, stat as stat2 } from "node:fs/promises";
1828
+ import { open as open2, readdir as readdir3, readFile as readFile6, realpath as realpath2, stat as stat2 } from "node:fs/promises";
1432
1829
  import { createServer } from "node:http";
1433
- import { extname, join as join5, relative as relative2, resolve, sep } from "node:path";
1830
+ import { extname, join as join5, relative as relative2, resolve as resolve2, sep } from "node:path";
1434
1831
  import { createBrotliDecompress, createGunzip } from "node:zlib";
1435
1832
 
1436
1833
  // ../scan-rules/src/each-limit.ts
@@ -1753,7 +2150,7 @@ function refusalSentence(findings) {
1753
2150
  // src/files.ts
1754
2151
  import { createHash } from "node:crypto";
1755
2152
  import { createReadStream } from "node:fs";
1756
- import { open, readdir as readdir2, readFile as readFile4, realpath, stat } from "node:fs/promises";
2153
+ import { open, readdir as readdir2, readFile as readFile5, realpath, stat } from "node:fs/promises";
1757
2154
  import { join as join4, relative } from "node:path";
1758
2155
 
1759
2156
  // src/ignore.ts
@@ -2058,7 +2455,7 @@ function mediaProblem(kind, path, size2, bytes, missing = "is not among the file
2058
2455
  }
2059
2456
 
2060
2457
  // src/project.ts
2061
- import { readdir, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
2458
+ import { readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
2062
2459
  import { join as join3, win32 } from "node:path";
2063
2460
  var SPACE_TYPES = ["game", "world", "tool", "wonder"];
2064
2461
  var DEVICES = ["desktop", "mobile", "vr"];
@@ -2078,7 +2475,7 @@ async function readProject(dir) {
2078
2475
  const path = join3(dir, names.includes(PROJECT_FILE) ? PROJECT_FILE : names.find(isProjectName) ?? PROJECT_FILE);
2079
2476
  let text;
2080
2477
  try {
2081
- text = await readFile3(path, "utf8");
2478
+ text = await readFile4(path, "utf8");
2082
2479
  } catch {
2083
2480
  return { path, exists: false, data: {} };
2084
2481
  }
@@ -2243,7 +2640,7 @@ async function walk(root, skipped = () => {}) {
2243
2640
  const realRoot = await realpath(root);
2244
2641
  let rules = [];
2245
2642
  try {
2246
- rules = parseIgnore(await readFile4(join4(root, ".velvenignore"), "utf8"));
2643
+ rules = parseIgnore(await readFile5(join4(root, ".velvenignore"), "utf8"));
2247
2644
  } catch {
2248
2645
  rules = [];
2249
2646
  }
@@ -2364,7 +2761,7 @@ async function checkMedia(config, files) {
2364
2761
  continue;
2365
2762
  const path = mediaPath(raw);
2366
2763
  const file = files.find((f) => f.path === path);
2367
- const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile4(file.abs)) : await readHead(file.abs);
2764
+ const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile5(file.abs)) : await readHead(file.abs);
2368
2765
  const problem = mediaProblem(kind, path, file?.size, bytes, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
2369
2766
  if (problem)
2370
2767
  problems.push(problem);
@@ -2376,9 +2773,9 @@ async function checkMedia(config, files) {
2376
2773
  return out;
2377
2774
  }
2378
2775
  function hashFile(abs) {
2379
- return new Promise((resolve, reject) => {
2776
+ return new Promise((resolve2, reject) => {
2380
2777
  const hash = createHash("sha256");
2381
- createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
2778
+ createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
2382
2779
  });
2383
2780
  }
2384
2781
  var HASH_CHUNK = 4 * MB;
@@ -2551,7 +2948,7 @@ function injectSdk(html, src) {
2551
2948
  return tag + html;
2552
2949
  }
2553
2950
  async function ignoreRules(root) {
2554
- return parseIgnore(await readFile5(join5(root, ".velvenignore"), "utf8").catch(() => ""));
2951
+ return parseIgnore(await readFile6(join5(root, ".velvenignore"), "utf8").catch(() => ""));
2555
2952
  }
2556
2953
  async function namedAsOnDisk(base, rel) {
2557
2954
  const parts = rel.split("/").filter(Boolean);
@@ -2567,7 +2964,7 @@ async function statExact(base, rel) {
2567
2964
  return info && await namedAsOnDisk(base, rel) ? info : null;
2568
2965
  }
2569
2966
  async function startDevServer(root, options) {
2570
- const base = resolve(root);
2967
+ const base = resolve2(root);
2571
2968
  const realBase = await realpath2(base);
2572
2969
  const server = createServer(async (req, res) => {
2573
2970
  const send2 = (status, body2, headers2 = {}) => {
@@ -2586,7 +2983,7 @@ async function startDevServer(root, options) {
2586
2983
  if (!parts.every(servableSegment))
2587
2984
  return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
2588
2985
  let rel = parts.join("/");
2589
- let file = resolve(base, rel);
2986
+ let file = resolve2(base, rel);
2590
2987
  if (file !== base && !file.startsWith(base + sep))
2591
2988
  return send2(404, "Not found");
2592
2989
  let info = await statExact(base, rel);
@@ -2615,7 +3012,7 @@ async function startDevServer(root, options) {
2615
3012
  if (!real || !servableRelative(relative2(realBase, real))) {
2616
3013
  return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
2617
3014
  }
2618
- let body = await readFile5(file);
3015
+ let body = await readFile6(file);
2619
3016
  const { type, encoding } = contentType(file, body);
2620
3017
  if (rel === options.entry && options.sdkSrc && !encoding)
2621
3018
  body = injectSdk(body.toString("utf8"), options.sdkSrc);
@@ -2638,7 +3035,7 @@ async function startDevServer(root, options) {
2638
3035
  };
2639
3036
  }
2640
3037
  async function dev(ctx, dirArg, portFlag) {
2641
- const dir = resolve(ctx.cwd, dirArg ?? ".");
3038
+ const dir = resolve2(ctx.cwd, dirArg ?? ".");
2642
3039
  const info = await stat2(dir).catch(() => null);
2643
3040
  if (!info?.isDirectory())
2644
3041
  throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
@@ -2689,7 +3086,7 @@ async function dev(ctx, dirArg, portFlag) {
2689
3086
 
2690
3087
  // src/publish.ts
2691
3088
  import { stat as stat3 } from "node:fs/promises";
2692
- import { resolve as resolve2 } from "node:path";
3089
+ import { resolve as resolve3 } from "node:path";
2693
3090
 
2694
3091
  // src/upload.ts
2695
3092
  import { open as open3 } from "node:fs/promises";
@@ -2796,7 +3193,7 @@ function fileEntry(f) {
2796
3193
  }
2797
3194
  var hashesOf = (f) => f.pieces ?? (f.sha256 ? [f.sha256] : []);
2798
3195
  async function folderOf(ctx, arg) {
2799
- const dir = resolve2(ctx.cwd, arg ?? ".");
3196
+ const dir = resolve3(ctx.cwd, arg ?? ".");
2800
3197
  const info = await stat3(dir).catch(() => null);
2801
3198
  if (!info)
2802
3199
  throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velven/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Publish a folder to Velven, the community marketplace for spaces built with AI: a private preview first, then hosted and live on its own page in seconds.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://velven.ai/docs",