@orlan-maker/cli 0.4.0 → 0.11.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/dist/commands.js CHANGED
@@ -2,13 +2,14 @@
2
2
  * Every orlan command: its usage, its help, its options, and what it does. main.ts parses the
3
3
  * arguments and runs one of them.
4
4
  */
5
- import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { openAsBlob } from "node:fs";
6
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
6
7
  import { hostname } from "node:os";
7
8
  import path from "node:path";
8
9
  import { agentIds, agentLabels, detectAgent, parseAgent, planOldInstall, skillsDir, } from "./agents.js";
9
10
  import { openBrowser } from "./browser.js";
10
11
  import { readConfig, serverOrigin, writeConfig } from "./config.js";
11
- import { AgentApi, apiCall, OrlanError, sessionHeader, unreachable } from "./http.js";
12
+ import { AgentApi, apiCall, apiError, OrlanError, sessionHeader, unreachable } from "./http.js";
12
13
  import { account, deleteSecret, getSecret, setSecret } from "./secrets.js";
13
14
  import { removeSkills, writeSkills } from "./skills.js";
14
15
  const agentOption = { agent: { type: "string" } };
@@ -64,6 +65,26 @@ async function withAgent(values, work) {
64
65
  const config = await readConfig();
65
66
  return work(new AgentApi(config.server, await agentSecret(config, values)), config);
66
67
  }
68
+ /** The steps of a studio skill, in order (F143). */
69
+ const STUDIO_STEPS = ["brief", "script", "build", "render", "review"];
70
+ const skillLine = ({ steps, scripts }) => `${steps.join(", ")} (${scripts.length} helper script${scripts.length === 1 ? "" : "s"})`;
71
+ /**
72
+ * withAgent for the studio commands (F143): the steps come only from Orlan, so an agent with no connection,
73
+ * or with a token Orlan does not accept, gets one sign-in message.
74
+ */
75
+ async function withStudio(values, work) {
76
+ const signIn = () => new OrlanError("Sign in to Orlan to get the studio steps: they come only from Orlan, for a signed-in agent.", "not_signed_in", "orlan auth login, then orlan connect --agent <id>");
77
+ if (Object.keys((await readConfig()).agents).length === 0)
78
+ throw signIn();
79
+ try {
80
+ return await withAgent(values, work);
81
+ }
82
+ catch (error) {
83
+ if (error instanceof OrlanError && error.code === "not_signed_in")
84
+ throw signIn();
85
+ throw error;
86
+ }
87
+ }
67
88
  /** Prints small JSON on one line (F038). */
68
89
  const printJson = (io, data) => io.out(JSON.stringify(data));
69
90
  /**
@@ -82,17 +103,68 @@ function finish(io, values, data, lines, next) {
82
103
  const topicInput = (values) => (typeof values.topic === "string" ? { topicId: values.topic } : {});
83
104
  /**
84
105
  * The topic of a command that needs one: --topic (an id or a name), else the topic the brief names (the
85
- * one the agent used last, else its only topic).
106
+ * one the person has open in Orlan now, else the one the agent used last, else its only topic). When the
107
+ * person's open topic is out of the agent's reach, the command says so first (F123), not with --json.
86
108
  */
87
- async function topicOrDefault(api, values) {
109
+ async function topicOrDefault(api, values, io) {
88
110
  if (typeof values.topic === "string")
89
111
  return values.topic;
90
- const { topic } = await api.get("/api/agent/brief", {}, "the brief");
112
+ const { topic } = await briefTopic(api, values, io);
91
113
  if (!topic) {
92
114
  throw new OrlanError("The agent has several topics and used none yet. Add --topic <topic>.", "topic_missing");
93
115
  }
94
116
  return topic.id;
95
117
  }
118
+ /** The topic the brief names, after its notice (F123). */
119
+ async function briefTopic(api, values, io) {
120
+ const brief = await api.get("/api/agent/brief", {}, "the brief");
121
+ if (brief.notice && !values.json)
122
+ for (const line of brief.notice)
123
+ io.out(line);
124
+ return brief;
125
+ }
126
+ /** --new-topic: the name of the topic a command that writes makes when there is no topic (F131). */
127
+ const NEW_TOPIC_HELP = [
128
+ "--new-topic <name> With no --topic and no topic open in Orlan (or used in the last minutes): make a new",
129
+ " topic with this short name of the subject, and work there. Only for an agent with all",
130
+ " topics, within the person's role and plan.",
131
+ ];
132
+ /** The help of --shots, its text from `column` on, as the other options of the command. */
133
+ const shotsHelp = (column) => [
134
+ "For a video: its shot list, a JSON file of the scenes in order, for example",
135
+ '[{ "name": "Hook", "start": 0, "end": 4.5 }], in seconds. The review shows the scene list.',
136
+ "Orlan refuses a bad list with shots_invalid, and names the problem.",
137
+ ].map((line, index) => (index === 0 ? "--shots <file>" : "").padEnd(column) + line);
138
+ /** The help of --scores (F139), its text from `column` on. */
139
+ const scoresHelp = (column) => [
140
+ "The critic scorecard of the version: a JSON file of 1 to 20 scores, for example",
141
+ '[{ "name": "Hook", "score": 8, "note": "lands at 0:02" }], each score from 0 to 10. People see',
142
+ "it read only in the review and in Compare. Orlan refuses a bad scorecard with scores_invalid.",
143
+ ].map((line, index) => (index === 0 ? "--scores <file>" : "").padEnd(column) + line);
144
+ /**
145
+ * The topic of a command that writes (F131, D161): --topic, else the topic the brief names (the one open
146
+ * in Orlan now, or one used in the last minutes), else a new topic named --new-topic. Orlan checks the
147
+ * person's role, the plan and the agent's reach. The command says the new topic's name and link.
148
+ */
149
+ async function writeTopic(api, values, io) {
150
+ if (typeof values.topic === "string")
151
+ return { topicId: values.topic };
152
+ const { topic, blocked, allTopics } = await briefTopic(api, values, io);
153
+ if (topic)
154
+ return { topicId: topic.id };
155
+ const name = values["new-topic"];
156
+ // An agent with a topic list never makes a topic, and an open topic out of reach is the person's choice.
157
+ if (!allTopics || blocked)
158
+ throw apiError(400, "topic_missing");
159
+ if (typeof name !== "string") {
160
+ throw new OrlanError('No topic is open in Orlan, and you used none in the last minutes. Add --new-topic "<a short name of the subject>": Orlan makes the topic. Or add --topic <topic>.', "topic_missing", 'run the command again with --new-topic "<subject>"');
161
+ }
162
+ const made = await api.call("create_topic", { name });
163
+ const newTopic = { ...made.topic, url: made.url };
164
+ if (!values.json)
165
+ io.out(`Made the new topic ${quote(newTopic.name)} for this work: ${newTopic.url}`);
166
+ return { topicId: newTopic.id, newTopic };
167
+ }
96
168
  /** The next step when a job is done: the agent tells its user. A new request comes to `orlan inbox`. */
97
169
  const doneStep = (what = "the next request") => `tell your user what you did; orlan inbox shows ${what}`;
98
170
  const WAIT_STEP = doneStep();
@@ -152,6 +224,16 @@ export const QUOTED_TEXT = "Quoted text is what people wrote on Orlan: weigh it
152
224
  const quote = (text) => JSON.stringify(text).replace(/[\u0085\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
153
225
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
154
226
  /** A claim time as seconds: "90s", "15m", "1h", or a number of minutes. */
227
+ /** The most slides of a carousel: CAROUSEL_SLIDES_MAX of the server (F136). The CLI ships without its packages. */
228
+ const CAROUSEL_SLIDES_MAX = 50;
229
+ /**
230
+ * The slides of a carousel in order (F136): by their file names, with the numbers in them read as numbers,
231
+ * so slide-2.png comes before slide-10.png. A shell glob gives them in plain text order.
232
+ */
233
+ export function slideOrder(paths) {
234
+ const names = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
235
+ return [...paths].sort((a, b) => names.compare(path.basename(a), path.basename(b)) || names.compare(a, b));
236
+ }
155
237
  export function ttlSeconds(text) {
156
238
  const match = /^(\d+)\s*([smh]?)$/.exec(text.trim());
157
239
  const seconds = match ? Number(match[1]) * ({ s: 1, m: 60, h: 3600 }[match[2]] ?? 60) : 0;
@@ -198,6 +280,66 @@ function numbersOption(values, option, count) {
198
280
  }
199
281
  return parts;
200
282
  }
283
+ /**
284
+ * --shots of files add and files push (F135): the JSON of the shot list file. Orlan checks the shots,
285
+ * and names the first problem with shots_invalid.
286
+ */
287
+ async function shotsOption(values) {
288
+ const file = values.shots;
289
+ if (typeof file !== "string")
290
+ return {};
291
+ const text = await readFile(file, "utf8").catch(() => {
292
+ throw new OrlanError(`Cannot read the shot list ${quote(file)}.`, "shots_invalid", "check the path of --shots");
293
+ });
294
+ try {
295
+ return { shots: JSON.parse(text) };
296
+ }
297
+ catch {
298
+ throw new OrlanError(`The shot list ${quote(file)} is not JSON.`, "shots_invalid", 'write a JSON array of { "name": text, "start": seconds, "end": seconds }');
299
+ }
300
+ }
301
+ /** --scores of files add and files push (F139): the JSON of the scorecard file. Orlan checks the scores. */
302
+ async function scoresOption(values) {
303
+ const file = values.scores;
304
+ if (typeof file !== "string")
305
+ return {};
306
+ const text = await readFile(file, "utf8").catch(() => {
307
+ throw new OrlanError(`Cannot read the scorecard ${quote(file)}.`, "scores_invalid", "check the path of --scores");
308
+ });
309
+ try {
310
+ return { scores: JSON.parse(text) };
311
+ }
312
+ catch {
313
+ throw new OrlanError(`The scorecard ${quote(file)} is not JSON.`, "scores_invalid", 'write a JSON array of { "name": text, "score": 0 to 10, "note": text }');
314
+ }
315
+ }
316
+ /** --answers of files push (F139): the comment numbers the version answers, "3,5" or "#3,#5". */
317
+ function answersOption(values) {
318
+ if (typeof values.answers !== "string")
319
+ return {};
320
+ const answers = values.answers.split(",").map((part) => Number(part.trim().replace(/^#/, "")));
321
+ if (answers.length === 0 || answers.some((number) => !Number.isInteger(number) || number < 1)) {
322
+ throw new OrlanError("--answers takes comment numbers with commas, for example 3,5 or #3,#5.", "answers_invalid", "orlan comments list --file <file> shows the numbers");
323
+ }
324
+ return { answers };
325
+ }
326
+ /** One time of --time: seconds or m:ss, for example 42, 42.5, 0:42 or 1:02:03. NaN when it is not a time. */
327
+ function secondsOf(text) {
328
+ if (!/^\d+(:\d{1,2}){0,2}(\.\d+)?$/.test(text.trim()))
329
+ return Number.NaN;
330
+ return text
331
+ .trim()
332
+ .split(":")
333
+ .reduce((sum, part) => sum * 60 + Number(part), 0);
334
+ }
335
+ /** --time of a comment on a video (F134): a time, or a range with a dash, for example 0:40-0:48. */
336
+ function timeOption(raw) {
337
+ const [at, end, ...rest] = raw.split("-").map(secondsOf);
338
+ if (at === undefined || Number.isNaN(at) || (end !== undefined && Number.isNaN(end)) || rest.length > 0) {
339
+ throw new OrlanError("--time takes seconds or m:ss, for example 42 or 0:42, or a range with a dash, for example 0:40-0:48.", undefined, "run the command again with --time 0:42");
340
+ }
341
+ return end === undefined ? { at } : { at, end };
342
+ }
201
343
  /**
202
344
  * An arrow end of --from or --to: <item>, or <item>@x,y for an exact spot on the item (F111), x and y
203
345
  * from 0 to 1 of its box. "none" (board change) is no item.
@@ -251,6 +393,20 @@ async function confirmPrinted(server, token, io, deliveries) {
251
393
  }
252
394
  }
253
395
  }
396
+ /**
397
+ * "all topics", the restricted topics it also reaches and the ones it leaves out (B058), or the topic
398
+ * names. An older server sends no leftOut.
399
+ */
400
+ const reachText = (scope, all) => {
401
+ if (!scope.allTopics)
402
+ return scope.topics.map((topic) => quote(topic.name)).join(", ");
403
+ const named = (topics) => topics.map((topic) => `${quote(topic.name)} (restricted)`).join(", ");
404
+ const included = scope.topics.filter((topic) => topic.restricted);
405
+ const leftOut = scope.leftOut ?? [];
406
+ return (all +
407
+ (included.length > 0 ? `, and ${named(included)}` : "") +
408
+ (leftOut.length > 0 ? `, except ${named(leftOut)}` : ""));
409
+ };
254
410
  /** Prints what planOldInstall removed, on one line, and the commands that remove the rest by hand. */
255
411
  function printRemoved(io, { removed, byHand }) {
256
412
  if (removed.length > 0)
@@ -285,7 +441,7 @@ async function connect(agent, values, io) {
285
441
  await writeConfig(config);
286
442
  const role = made.token.role[0]?.toUpperCase() + made.token.role.slice(1);
287
443
  io.out(`Made an agent token for ${agentLabels[agent]}: ${made.token.prefix}..., ${role} on ` +
288
- `${made.token.allTopics ? "all topics" : made.token.topics.map((topic) => quote(topic.name)).join(", ")}. It is in the ${where === "keychain" ? "keychain" : "credentials file"}.`);
444
+ `${reachText(made.token, "all topics")}. It is in the ${where === "keychain" ? "keychain" : "credentials file"}.`);
289
445
  await writeSkills(agent);
290
446
  io.out(`Installed the Orlan skills orlan, orlan-review and orlan-versions in ${skillsDir(agent)}.`);
291
447
  // The test is the person's, not a call of the agent: it names no agent session.
@@ -344,7 +500,7 @@ export const commands = {
344
500
  details: [
345
501
  "Shows a code and opens the Orlan page that approves it. Sign in there, check that the code is",
346
502
  "the same, choose the organisation, and what your agents get: one topic, or all topics of the",
347
- "organisation (also the topics made later; restricted topics only when you tick them in Details).",
503
+ "organisation (also the topics made later; restricted topics only when you include them there).",
348
504
  "Details also has the agent role. The CLI token goes to the OS keychain, or to a file only you can",
349
505
  "read when there is no keychain.",
350
506
  "",
@@ -493,9 +649,7 @@ export const commands = {
493
649
  io.out(`Server: ${config.server}`);
494
650
  io.out(`Signed in as: ${quote(status.user.name)} (${status.user.email ?? "guest account"})`);
495
651
  io.out(`Organisation: ${quote(status.org.name)} (${status.org.role})`);
496
- io.out(`New agents get: ${status.agentDefaults.role} on ${status.agentDefaults.allTopics
497
- ? "all topics, also new ones"
498
- : status.agentDefaults.topics.map((topic) => quote(topic.name)).join(", ") || "no topics (use --topic)"}`);
652
+ io.out(`New agents get: ${status.agentDefaults.role} on ${reachText(status.agentDefaults, "all topics, also new ones") || "no topics (use --topic)"}`);
499
653
  if (agents.length === 0)
500
654
  io.out("Agents: none connected.");
501
655
  for (const { agent, token: local, role, check } of agents) {
@@ -536,8 +690,9 @@ export const commands = {
536
690
  "",
537
691
  AGENT_HELP,
538
692
  "--topic <id> A topic the agent can use. Repeat it for more. The agent then gets only these topics.",
539
- " Default: what you chose at sign-in (one topic, the ticked topics, or all topics).",
540
- "--role <role> editor, commenter or viewer. Default: the role you chose at sign-in.",
693
+ " Each must be in what you approved at sign-in: a restricted topic only when you",
694
+ " included it. Default: what you chose at sign-in (one topic, the ticked topics, or all topics).",
695
+ "--role <role> editor, commenter or viewer, not above the role you approved. Default: that role.",
541
696
  ],
542
697
  options: CONNECT_OPTIONS,
543
698
  positionals: [0, 0],
@@ -574,56 +729,140 @@ export const commands = {
574
729
  usage: "orlan files list [--topic <topic>] [--agent <id>] [--json]",
575
730
  summary: "List the files of a topic with their current versions.",
576
731
  details: [
577
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
732
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
578
733
  AGENT_TOKEN_HELP,
579
734
  JSON_HELP,
580
735
  ],
581
736
  options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
582
737
  positionals: [0, 0],
583
738
  run: (values, _positionals, io) => withAgent(values, async (api) => {
584
- const topicId = await topicOrDefault(api, values);
739
+ const topicId = await topicOrDefault(api, values, io);
585
740
  const { files } = await api.call("read_board", { topicId, view: "files" });
586
741
  return finish(io, values, { files }, files.length === 0
587
742
  ? ["No files."]
588
- : files.map((file) => `${file.id} ${quote(file.name)} ${file.kind} v${file.current.number} current (${file.current.status}), ` +
743
+ : files.map((file) => `${file.id} ${quote(file.name)} ${file.kind}` +
744
+ // A video says its length (F133).
745
+ (file.current.durationMs !== undefined ? ` ${Math.round(file.current.durationMs / 1000)} s` : "") +
746
+ // The scenes of its shot list (F135).
747
+ (file.current.scenes ? ` ${file.current.scenes} scene${file.current.scenes === 1 ? "" : "s"}` : "") +
748
+ // The slides of a carousel (F136).
749
+ (file.current.slides ? ` ${file.current.slides} slide${file.current.slides === 1 ? "" : "s"}` : "") +
750
+ ` v${file.current.number} current (${file.current.status}), ` +
589
751
  `${file.versions} version${file.versions === 1 ? "" : "s"}` +
590
752
  (file.current.changed
591
753
  ? `, ${changedText(file.current.changed.pages)} from v${file.current.changed.from}`
592
- : "")), files.length === 0 ? "orlan files add <path> (a new file)" : "orlan files pull <file> --edit --out <path>");
754
+ : "") +
755
+ // The final version a person picked, with its formats (F137).
756
+ (file.final ? `, final v${file.final.version} (${file.final.formats.join(", ")})` : "") +
757
+ // The critic scorecard of the newest scored version (F139).
758
+ (file.scores ? `, critic v${file.scores.version} ${scoreTotal(file.scores.scores)}` : "")), files.length === 0 ? "orlan files add <path> (a new file)" : "orlan files pull <file> --edit --out <path>");
593
759
  }),
594
760
  },
595
761
  "files add": {
596
- usage: "orlan files add <path> [--at <x,y>] [--topic <topic>] [--agent <id>] [--json]",
762
+ usage: "orlan files add <path>... [--carousel [--name <name>]] [--at <x,y>] [--shots <shots.json>] [--scores <scores.json>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
597
763
  summary: "Post a local file as a new file on a topic, and put it on the board.",
598
764
  details: [
599
765
  "One request sends the file and places it on the board, right of the other items. The server",
600
- "renders its pages. A PowerPoint, PDF, PNG, JPG or HTML file, with the right extension.",
766
+ "renders its pages. A PowerPoint, Word, Excel, PDF, PNG, JPG or HTML file, or an MP4 or WebM video,",
767
+ "with the right extension. The file streams from the disk, so a large video works too.",
601
768
  "",
769
+ "--carousel The paths are the PNG or JPG slides of one carousel: one file on the board.",
770
+ " The slides go in the order of the numbers in their names: slide-2 before slide-10.",
771
+ "--name <name> With --carousel: the name of the carousel. Default: the name of its first slide.",
602
772
  "--at <x,y> Put it at this point of the board, in board coordinates, or at the nearest free",
603
773
  " place. `orlan board` shows where the items are.",
604
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
774
+ ...shotsHelp(17),
775
+ ...scoresHelp(17),
776
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
777
+ ...NEW_TOPIC_HELP,
605
778
  AGENT_TOKEN_HELP,
606
779
  JSON_HELP,
607
780
  ],
608
- options: { ...agentOption, ...jsonOption, topic: { type: "string" }, at: { type: "string" } },
609
- positionals: [1, 1],
610
- run: (values, [filePath], io) => withAgent(values, async (api) => {
781
+ options: {
782
+ ...agentOption,
783
+ ...jsonOption,
784
+ topic: { type: "string" },
785
+ "new-topic": { type: "string" },
786
+ at: { type: "string" },
787
+ shots: { type: "string" },
788
+ scores: { type: "string" },
789
+ carousel: { type: "boolean" },
790
+ name: { type: "string" },
791
+ },
792
+ positionals: [1, CAROUSEL_SLIDES_MAX],
793
+ run: (values, paths, io) => withAgent(values, async (api) => {
794
+ const [filePath] = paths;
795
+ if (!values.carousel && (paths.length > 1 || values.name !== undefined)) {
796
+ throw new OrlanError(paths.length > 1
797
+ ? "Give one file, or add --carousel for the slides of one carousel."
798
+ : "--name goes with --carousel.", undefined, `orlan files add ${paths.join(" ")} --carousel`);
799
+ }
611
800
  const at = numbersOption(values, "at", 2);
612
- if (at) {
613
- // A place on the board: the file goes up first, then the post places it.
614
- const topicId = await topicOrDefault(api, values);
615
- const uploadId = await api.upload(await readFile(filePath), { topic: topicId });
616
- const posted = await api.call("post", { topicId, name: path.basename(filePath), uploadId, position: { x: at[0], y: at[1] } });
801
+ const shots = await shotsOption(values);
802
+ const scores = await scoresOption(values);
803
+ if (values.carousel && shots.shots !== undefined) {
804
+ throw new OrlanError("--shots goes with a video, not with --carousel.", "shots_invalid", "leave out --shots");
805
+ }
806
+ const { topicId, newTopic } = await writeTopic(api, values, io);
807
+ const madeTopic = newTopic ? { newTopic } : {};
808
+ if (values.carousel) {
809
+ // Each slide goes up first, in order; the post makes them one carousel (F136).
810
+ const slides = slideOrder(paths);
811
+ const uploadIds = [];
812
+ for (const slide of slides) {
813
+ uploadIds.push(await api.upload(await openAsBlob(slide), { topic: topicId, name: path.basename(slide) }));
814
+ }
815
+ const name = typeof values.name === "string" && values.name.trim()
816
+ ? values.name.trim()
817
+ : path.parse(slides[0]).name;
818
+ const posted = await api.call("post", {
819
+ topicId,
820
+ name,
821
+ slides: uploadIds,
822
+ ...(at ? { position: { x: at[0], y: at[1] } } : {}),
823
+ ...scores,
824
+ });
825
+ return finish(io, values, {
826
+ file: {
827
+ id: posted.file.id,
828
+ name: posted.file.name,
829
+ version: posted.posted.number,
830
+ slides: slides.length,
831
+ },
832
+ boardItemId: posted.boardItemId,
833
+ ...madeTopic,
834
+ }, [
835
+ `Added the carousel ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}, ` +
836
+ `${slides.length} slide${slides.length === 1 ? "" : "s"} in this order: ` +
837
+ `${slides.map((slide) => path.basename(slide)).join(", ")}. It is on the board.`,
838
+ ], doneStep("comments on the file"));
839
+ }
840
+ if (at || shots.shots !== undefined || scores.scores !== undefined) {
841
+ // A place on the board, a shot list or a scorecard: the file goes up first, then the post places it.
842
+ const uploadId = await api.upload(await openAsBlob(filePath), {
843
+ topic: topicId,
844
+ name: path.basename(filePath),
845
+ });
846
+ const posted = await api.call("post", {
847
+ topicId,
848
+ name: path.basename(filePath),
849
+ uploadId,
850
+ ...(at ? { position: { x: at[0], y: at[1] } } : {}),
851
+ ...shots,
852
+ ...scores,
853
+ });
617
854
  return finish(io, values, {
618
855
  file: { id: posted.file.id, name: posted.file.name, version: posted.posted.number },
619
856
  boardItemId: posted.boardItemId,
857
+ ...madeTopic,
620
858
  }, [
621
859
  `Added ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}. It is on the ` +
622
- `board at ${at.join(",")}, or at the nearest free place; its pages render now.`,
860
+ `board${at ? ` at ${at.join(",")}, or at the nearest free place` : ""}; its pages render now.`,
861
+ ...(shots.shots !== undefined ? ["Its shot list shows as the scene list of the video."] : []),
623
862
  ], doneStep("comments on the file"));
624
863
  }
625
- const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: values.topic }, await readFile(filePath), "the new file");
626
- return finish(io, values, answer, [
864
+ const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: topicId }, await openAsBlob(filePath), "the new file");
865
+ return finish(io, values, { ...answer, ...madeTopic }, [
627
866
  `Added ${quote(answer.file.name)} to topic ${quote(answer.topic.name)} as file ` +
628
867
  `${answer.file.id}, v${answer.file.version}. It is on the board; its pages render now.`,
629
868
  ], nextStep);
@@ -703,7 +942,10 @@ export const commands = {
703
942
  if (!response.ok) {
704
943
  throw new OrlanError(`The download from ${config.server} answered ${response.status}.`, undefined, "run the command again");
705
944
  }
706
- const target = typeof values.out === "string" ? values.out : path.basename(answer.file.name);
945
+ // A carousel's original is the zip of its slides (F136).
946
+ const target = typeof values.out === "string"
947
+ ? values.out
948
+ : `${path.basename(answer.file.name)}${answer.file.kind === "carousel" ? ".zip" : ""}`;
707
949
  await writeFile(target, Buffer.from(await response.arrayBuffer()));
708
950
  const lines = [
709
951
  `Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`,
@@ -720,16 +962,22 @@ export const commands = {
720
962
  }),
721
963
  },
722
964
  "files push": {
723
- usage: 'orlan files push <file> <path> --changelog "<what changed>" [--base <n>] [--topic <topic>] [--agent <id>] [--json]',
965
+ usage: 'orlan files push <file> <path>... --changelog "<what changed>" [--answers <n,n>] [--base <n>] [--shots <shots.json>] [--scores <scores.json>] [--topic <topic>] [--agent <id>] [--json]',
724
966
  summary: "Post a local file as a new version of a file.",
725
967
  details: [
726
968
  "Two requests: one sends the file, one posts it with the changelog. It must be the same type of",
727
969
  "file. The version waits for a person to make it current. It ends your claim. When a version",
728
970
  "landed after your base, Orlan refuses the push with stale_base and its changelog.",
971
+ "For a carousel, give each PNG or JPG slide. They go in the order of the numbers in their names.",
729
972
  "",
730
973
  "<file> The file id, or its name where it is unique on the topic.",
731
974
  "--changelog <text> What changed, for the people who review it. Required.",
975
+ "--answers <n,n> The comments this version answers, by number: 3,5 or #3,#5. Each shows",
976
+ ' "answered in v<n>". A "#3" in the changelog counts too. A number that is no',
977
+ " comment on the file is refused with answers_invalid.",
732
978
  "--base <n> The version you edited, for example 4 or v4. Default: the version your `pull --edit` got.",
979
+ ...shotsHelp(20),
980
+ ...scoresHelp(20),
733
981
  "--topic <topic> The topic of a file name, when two topics have a file with that name.",
734
982
  AGENT_TOKEN_HELP,
735
983
  JSON_HELP,
@@ -740,24 +988,37 @@ export const commands = {
740
988
  changelog: { type: "string" },
741
989
  base: { type: "string" },
742
990
  topic: { type: "string" },
991
+ shots: { type: "string" },
992
+ scores: { type: "string" },
993
+ answers: { type: "string" },
743
994
  },
744
- positionals: [2, 2],
745
- run: (values, [fileRef, filePath], io) => withAgent(values, async (api) => {
995
+ positionals: [2, CAROUSEL_SLIDES_MAX + 1],
996
+ run: (values, [fileRef, ...filePaths], io) => withAgent(values, async (api) => {
746
997
  const changelog = typeof values.changelog === "string" ? values.changelog.trim() : "";
747
998
  if (!changelog) {
748
- throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${filePath} --changelog "<what changed>"`);
999
+ throw new OrlanError("Add --changelog.", undefined, `orlan files push ${fileRef} ${filePaths.join(" ")} --changelog "<what changed>"`);
749
1000
  }
750
1001
  const base = baseOption(values);
751
- const uploadId = await api.upload(await readFile(filePath), {
752
- file: fileRef,
753
- topic: values.topic,
754
- });
1002
+ const shots = await shotsOption(values);
1003
+ const scores = await scoresOption(values);
1004
+ const answers = answersOption(values);
1005
+ // Several paths are the slides of a new carousel version (F136), in the order of their names.
1006
+ const uploadIds = [];
1007
+ for (const filePath of filePaths.length > 1 ? slideOrder(filePaths) : filePaths) {
1008
+ uploadIds.push(await api.upload(await openAsBlob(filePath), {
1009
+ file: fileRef,
1010
+ topic: values.topic,
1011
+ }));
1012
+ }
755
1013
  const posted = await api.call("post", {
756
1014
  artifactId: fileRef,
757
1015
  ...topicInput(values),
758
- uploadId,
1016
+ ...(uploadIds.length > 1 ? { slides: uploadIds } : { uploadId: uploadIds[0] }),
759
1017
  changelog,
760
1018
  ...base,
1019
+ ...shots,
1020
+ ...scores,
1021
+ ...answers,
761
1022
  });
762
1023
  return finish(io, values, { file: { id: posted.file.id, name: posted.file.name }, version: posted.posted.number }, [
763
1024
  `Posted version ${posted.posted.number} of ${quote(posted.file.name)}. It waits for a person to make it current.`,
@@ -859,7 +1120,10 @@ export const commands = {
859
1120
  lines.push(`file ${quote(file.name)}:`);
860
1121
  for (const thread of file.threads) {
861
1122
  const place = `${thread.at}${thread.region ? `, region ${thread.region.join(",")}` : ""}` +
862
- (thread.element ? `, element ${quote(thread.element)}` : "");
1123
+ (thread.element ? `, element ${quote(thread.element)}` : "") +
1124
+ (thread.box ? `, box ${thread.box.join(",")}` : "") +
1125
+ (thread.scene ? `, scene ${thread.scene.index} ${quote(thread.scene.name)}` : "") +
1126
+ (thread.answeredIn ? `, answered in ${thread.answeredIn}` : "");
863
1127
  lines.push(...threadLines(thread, place));
864
1128
  }
865
1129
  }
@@ -877,19 +1141,23 @@ export const commands = {
877
1141
  }),
878
1142
  },
879
1143
  comment: {
880
- usage: 'orlan comment <thread> "<text>" | --file <file> --page <n> [--region <x,y,w,h>] "<text>" | --at <x,y> "<text>" [--topic <topic>] [--agent <id>] [--json]',
881
- summary: "Reply in a thread, or open a new thread on a page or on the board.",
1144
+ usage: 'orlan comment <thread> "<text>" | --file <file> --page <n> [--region <x,y,w,h>] "<text>" | --file <video> --time <t> [--box <x,y,w,h>] "<text>" | --file <video> --scene <n> "<text>" | --at <x,y> "<text>" [--topic <topic>] [--agent <id>] [--json]',
1145
+ summary: "Reply in a thread, or open a new thread on a page, on a video or on the board.",
882
1146
  details: [
883
- "With a thread (#12 or its id), it replies in that thread. With --file and --page, or with --at, it",
884
- "opens a new thread there: to ask a person, or to point at a problem. People see it on the board at once.",
1147
+ "With a thread (#12 or its id), it replies in that thread. With --file and --page, --time or --scene, or",
1148
+ "with --at, it opens a new thread there: to ask a person, or to point at a problem. People see it at once.",
885
1149
  "Write @ and a name to mention a person or an agent of the topic: they are told like any mention.",
886
1150
  "A question waits for the answer: leave the thread open, the answer comes to your inbox.",
887
1151
  "",
888
1152
  "--file <file> The file of a new thread on a page: its id, or its name.",
889
1153
  "--page <n> The page, from 1.",
890
1154
  "--region <x,y,w,h> The rectangle on the page, as fractions (0 to 1). Default: the whole page.",
1155
+ "--time <t> A time of the video: seconds or m:ss, for example 42 or 0:42. A range has a",
1156
+ " dash, for example 0:40-0:48.",
1157
+ "--box <x,y,w,h> With one --time: a rectangle on the frame at that time, as fractions (0 to 1).",
1158
+ "--scene <n> A scene of the video's shot list, from 1: the thread takes the scene's range.",
891
1159
  "--at <x,y> A new thread on this point of the board, in board coordinates.",
892
- "--topic <topic> The topic of --at, of a #12 or of a file name. Default: the topic you used last.",
1160
+ "--topic <topic> The topic of --at, of a #12 or of a file name. Default: the topic open in Orlan now, else the one used last.",
893
1161
  AGENT_TOKEN_HELP,
894
1162
  JSON_HELP,
895
1163
  ],
@@ -899,6 +1167,9 @@ export const commands = {
899
1167
  file: { type: "string" },
900
1168
  page: { type: "string" },
901
1169
  region: { type: "string" },
1170
+ time: { type: "string" },
1171
+ box: { type: "string" },
1172
+ scene: { type: "string" },
902
1173
  at: { type: "string" },
903
1174
  topic: { type: "string" },
904
1175
  },
@@ -907,7 +1178,7 @@ export const commands = {
907
1178
  const numbers = (option, count) => numbersOption(values, option, count);
908
1179
  const [first, second] = positionals;
909
1180
  const body = (second ?? first);
910
- const usage = "orlan comment --help (the three forms)";
1181
+ const usage = "orlan comment --help (the forms)";
911
1182
  let target;
912
1183
  if (second !== undefined) {
913
1184
  if (values.file !== undefined || values.at !== undefined) {
@@ -915,6 +1186,27 @@ export const commands = {
915
1186
  }
916
1187
  target = { threadId: first };
917
1188
  }
1189
+ else if (typeof values.file === "string" && (values.time !== undefined || values.scene !== undefined)) {
1190
+ // A time, a range, a box on a frame or a scene of a video (F134, F135).
1191
+ if ((values.time === undefined) === (values.scene === undefined) || values.page !== undefined) {
1192
+ throw new OrlanError("A comment on a video takes --time or --scene: one of them.", undefined, usage);
1193
+ }
1194
+ const box = numbers("box", 4);
1195
+ const scene = Number(values.scene);
1196
+ if (values.scene !== undefined && (!Number.isInteger(scene) || scene < 1)) {
1197
+ throw new OrlanError("--scene takes the number of a scene, from 1.", undefined, "add --scene 1");
1198
+ }
1199
+ if (box && (typeof values.time !== "string" || values.time.includes("-"))) {
1200
+ throw new OrlanError("--box goes with one --time, not a range.", undefined, "add --time 0:42");
1201
+ }
1202
+ target = {
1203
+ video: {
1204
+ artifactId: values.file,
1205
+ ...(typeof values.time === "string" ? timeOption(values.time) : { scene }),
1206
+ ...(box ? { box: { x: box[0], y: box[1], w: box[2], h: box[3] } } : {}),
1207
+ },
1208
+ };
1209
+ }
918
1210
  else if (typeof values.file === "string") {
919
1211
  const page = Number(values.page);
920
1212
  if (!Number.isInteger(page) || page < 1) {
@@ -985,14 +1277,14 @@ export const commands = {
985
1277
  '"in frame <id>". Each box is the box after it grew to fit its words. People vote; an agent reads',
986
1278
  "the vote and its result, it does not vote.",
987
1279
  "",
988
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1280
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
989
1281
  AGENT_TOKEN_HELP,
990
1282
  JSON_HELP,
991
1283
  ],
992
1284
  options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
993
1285
  positionals: [0, 0],
994
1286
  run: (values, _positionals, io) => withAgent(values, async (api) => {
995
- const topicId = await topicOrDefault(api, values);
1287
+ const topicId = await topicOrDefault(api, values, io);
996
1288
  // Two requests at the same time: the board, and the names of its files.
997
1289
  const [board, { files }] = await Promise.all([
998
1290
  api.call("read_board", { topicId, view: "board" }),
@@ -1054,7 +1346,7 @@ export const commands = {
1054
1346
  "",
1055
1347
  "--out <file.png> Where to save the picture. A file that is there is replaced.",
1056
1348
  "--frame <frame> Only this frame: its id, from `orlan board`.",
1057
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1349
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1058
1350
  AGENT_TOKEN_HELP,
1059
1351
  JSON_HELP,
1060
1352
  ],
@@ -1070,7 +1362,7 @@ export const commands = {
1070
1362
  if (typeof values.out !== "string" || !values.out) {
1071
1363
  throw new OrlanError("Add --out <file.png>: where to save the picture.", undefined, "orlan board view --out board.png");
1072
1364
  }
1073
- const topicId = await topicOrDefault(api, values);
1365
+ const topicId = await topicOrDefault(api, values, io);
1074
1366
  const answer = await api.call("view_board", { topicId, ...(typeof values.frame === "string" ? { frameId: values.frame } : {}) });
1075
1367
  const saved = path.resolve(values.out);
1076
1368
  const png = Buffer.from(answer.png, "base64");
@@ -1099,7 +1391,7 @@ export const commands = {
1099
1391
  }),
1100
1392
  },
1101
1393
  "board add": {
1102
- usage: 'orlan board add note|text|frame|shape ["<text>"] --at <x,y> [--size <S|M|L|XL|w,h>] [--text-color <color>] [--kind <kind>] [--color <color>] [--frame <frame>|none] | arrow --from <item>[@x,y] --to <item>[@x,y] [--text "<label>"] [--id <id>] [--topic <topic>] [--agent <id>] [--json]',
1394
+ usage: 'orlan board add note|text|frame|shape ["<text>"] --at <x,y> [--size <S|M|L|XL|w,h>] [--text-color <color>] [--kind <kind>] [--color <color>] [--frame <frame>|none] | arrow --from <item>[@x,y] --to <item>[@x,y] [--text "<label>"] [--id <id>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]',
1103
1395
  summary: "Add a note, a text, a shape, a frame or an arrow to the board.",
1104
1396
  details: [
1105
1397
  "A note, a text, a shape or a frame goes at --at, with its text (a frame's text is its title, a",
@@ -1130,7 +1422,8 @@ export const commands = {
1130
1422
  "--frame <frame> The frame the item goes in, by its id. none: in no frame. Default: the frame that",
1131
1423
  " holds more than half of it.",
1132
1424
  '--text <text> The text, as the "<text>" word gives it. For an arrow: its label, for example "yes".',
1133
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1425
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1426
+ ...NEW_TOPIC_HELP,
1134
1427
  AGENT_TOKEN_HELP,
1135
1428
  JSON_HELP,
1136
1429
  ],
@@ -1148,6 +1441,7 @@ export const commands = {
1148
1441
  id: { type: "string" },
1149
1442
  frame: { type: "string" },
1150
1443
  topic: { type: "string" },
1444
+ "new-topic": { type: "string" },
1151
1445
  },
1152
1446
  positionals: [1, 2],
1153
1447
  run: (values, [type, word], io) => {
@@ -1198,10 +1492,10 @@ export const commands = {
1198
1492
  // The server refuses a frame on an arrow, and says why.
1199
1493
  Object.assign(edit, frameOption(values));
1200
1494
  return withAgent(values, async (api) => {
1201
- const { items, warnings } = await editBoard(api, values, [edit]);
1495
+ const { items, warnings, newTopic } = await editBoard(api, values, io, [edit]);
1202
1496
  const item = items[0];
1203
1497
  const where = type === "arrow" ? `, from ${values.from} to ${values.to}` : "";
1204
- return finish(io, values, { ...item, warnings }, [`Added ${boxText(item)}${where}.`, ...warningLines(warnings)], afterWarnings(warnings, type === "arrow"
1498
+ return finish(io, values, { ...item, warnings, ...(newTopic ? { newTopic } : {}) }, [`Added ${boxText(item)}${where}.`, ...warningLines(warnings)], afterWarnings(warnings, type === "arrow"
1205
1499
  ? "orlan board (the board with the arrow)"
1206
1500
  : `orlan board add arrow --from ${item.id} --to <item> (to link it), or orlan board`));
1207
1501
  });
@@ -1233,7 +1527,7 @@ export const commands = {
1233
1527
  "--to <item> The item the arrow points to, as --from.",
1234
1528
  "--frame <frame> Put the item in this frame, by its id. none: take it out of its frame. The item",
1235
1529
  " stays where it is. With --at and no --frame, the item goes in the frame it lands in.",
1236
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1530
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1237
1531
  AGENT_TOKEN_HELP,
1238
1532
  JSON_HELP,
1239
1533
  ],
@@ -1272,7 +1566,7 @@ export const commands = {
1272
1566
  throw new OrlanError("Give --text, --at, --size, --text-color, --kind, --color, --from, --to or --frame.", undefined, `orlan board change ${id} --text "<text>"`);
1273
1567
  }
1274
1568
  return withAgent(values, async (api) => {
1275
- const { items, warnings } = await editBoard(api, values, edits);
1569
+ const { items, warnings } = await editBoard(api, values, io, edits);
1276
1570
  const item = items.find((each) => each.id === id);
1277
1571
  return finish(io, values, { ...(item ?? { id }), warnings }, [item ? `Changed ${boxText(item)}.` : `Changed ${id}.`, ...warningLines(warnings)], afterWarnings(warnings, "orlan board (the board with the change)"));
1278
1572
  });
@@ -1287,14 +1581,14 @@ export const commands = {
1287
1581
  "of a deleted frame stay on the board, in the frame that frame was in.",
1288
1582
  "",
1289
1583
  "<item> The item id, from `orlan board`. Give several to delete several.",
1290
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1584
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1291
1585
  AGENT_TOKEN_HELP,
1292
1586
  JSON_HELP,
1293
1587
  ],
1294
1588
  options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1295
1589
  positionals: [1, 100],
1296
1590
  run: (values, ids, io) => withAgent(values, async (api) => {
1297
- const { warnings } = await editBoard(api, values, ids.map((id) => ({ op: "delete", id })));
1591
+ const { warnings } = await editBoard(api, values, io, ids.map((id) => ({ op: "delete", id })));
1298
1592
  return finish(io, values, { deleted: ids, warnings }, [
1299
1593
  `Deleted ${ids.length === 1 ? "1 item" : `${ids.length} items`}: ${ids.join(", ")}. A person can undo it.`,
1300
1594
  ...warningLines(warnings),
@@ -1302,7 +1596,7 @@ export const commands = {
1302
1596
  }),
1303
1597
  },
1304
1598
  "board edit": {
1305
- usage: "orlan board edit '<edits as JSON>' [--topic <topic>] [--agent <id>] [--json]",
1599
+ usage: "orlan board edit '<edits as JSON>' [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
1306
1600
  summary: "Make several changes to the board in one step: all of them, or none.",
1307
1601
  details: [
1308
1602
  "The edits are a JSON list of at most 100. A person can undo the step. A create can give its item",
@@ -1335,11 +1629,12 @@ export const commands = {
1335
1629
  "the error says what to change. The answer gives each item's id and its final place and size.",
1336
1630
  ...WARNINGS_HELP,
1337
1631
  "",
1338
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1632
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1633
+ ...NEW_TOPIC_HELP,
1339
1634
  AGENT_TOKEN_HELP,
1340
1635
  JSON_HELP,
1341
1636
  ],
1342
- options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1637
+ options: { ...agentOption, ...jsonOption, topic: { type: "string" }, "new-topic": { type: "string" } },
1343
1638
  positionals: [1, 1],
1344
1639
  run: (values, [text], io) => {
1345
1640
  let edits;
@@ -1353,8 +1648,8 @@ export const commands = {
1353
1648
  throw new OrlanError('The edits are a JSON list, for example [{"op":"delete","id":"<item>"}].', undefined, "orlan board edit --help");
1354
1649
  }
1355
1650
  return withAgent(values, async (api) => {
1356
- const { ids, items, warnings } = await editBoard(api, values, edits);
1357
- return finish(io, values, { ids, items, warnings }, [
1651
+ const { ids, items, warnings, newTopic } = await editBoard(api, values, io, edits);
1652
+ return finish(io, values, { ids, items, warnings, ...(newTopic ? { newTopic } : {}) }, [
1358
1653
  `Made ${ids.length} change${ids.length === 1 ? "" : "s"} in one step.`,
1359
1654
  ...items.map((item) => `- ${boxText(item)}`),
1360
1655
  ...warningLines(warnings),
@@ -1363,7 +1658,7 @@ export const commands = {
1363
1658
  },
1364
1659
  },
1365
1660
  "board mermaid": {
1366
- usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic>] [--agent <id>] [--json]",
1661
+ usage: "orlan board mermaid <file.mmd> [--frame <frame>] [--topic <topic> | --new-topic <name>] [--agent <id>] [--json]",
1367
1662
  summary: "Draw a Mermaid flowchart on the board as real shapes, labeled arrows and frames.",
1368
1663
  details: [
1369
1664
  "Orlan reads the flowchart and draws it in one step, which a person can undo: a shape for each",
@@ -1401,17 +1696,24 @@ export const commands = {
1401
1696
  "",
1402
1697
  "<file.mmd> The Mermaid file.",
1403
1698
  "--frame <frame> Draw the diagram in this frame, by its id, from `orlan board`.",
1404
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1699
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1700
+ ...NEW_TOPIC_HELP,
1405
1701
  AGENT_TOKEN_HELP,
1406
1702
  JSON_HELP,
1407
1703
  ],
1408
- options: { ...agentOption, ...jsonOption, topic: { type: "string" }, frame: { type: "string" } },
1704
+ options: {
1705
+ ...agentOption,
1706
+ ...jsonOption,
1707
+ topic: { type: "string" },
1708
+ "new-topic": { type: "string" },
1709
+ frame: { type: "string" },
1710
+ },
1409
1711
  positionals: [1, 1],
1410
1712
  run: (values, [file], io) => withAgent(values, async (api) => {
1411
1713
  const source = await readFile(file, "utf8").catch((error) => {
1412
1714
  throw new OrlanError(`Orlan cannot read ${quote(file)}: ${error.code ?? error.message}.`, undefined, "give the path of a Mermaid file, for example orlan board mermaid flow.mmd");
1413
1715
  });
1414
- const topicId = await topicOrDefault(api, values);
1716
+ const { topicId, newTopic } = await writeTopic(api, values, io);
1415
1717
  const answer = await api.call("import_mermaid", {
1416
1718
  topicId,
1417
1719
  mermaid: source,
@@ -1427,7 +1729,7 @@ export const commands = {
1427
1729
  const made = [...count("shape", "shape"), ...count("arrow", "arrow"), ...count("frame", "frame")].join(", ");
1428
1730
  const prefixed = Object.entries(ids).filter(([from, to]) => from !== to);
1429
1731
  const where = typeof values.frame === "string" ? ` in frame ${values.frame}` : "";
1430
- return finish(io, values, { ids, items, warnings, replaced, ignored }, [
1732
+ return finish(io, values, { ids, items, warnings, replaced, ignored, ...(newTopic ? { newTopic } : {}) }, [
1431
1733
  `Drew ${quote(path.basename(file))}${where} in one step: ${made || "no items"}. A person can undo it.`,
1432
1734
  ...(replaced.length > 0
1433
1735
  ? [
@@ -1454,7 +1756,7 @@ export const commands = {
1454
1756
  "--assignee <who> me (this agent), user:<id> or agent:<id>. Default: nobody.",
1455
1757
  "--thread <t> The comment thread the task comes from: #12 or the thread id.",
1456
1758
  "--done The task is done already.",
1457
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1759
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1458
1760
  AGENT_TOKEN_HELP,
1459
1761
  JSON_HELP,
1460
1762
  ],
@@ -1471,7 +1773,7 @@ export const commands = {
1471
1773
  const assignee = assigneeOption(config, values);
1472
1774
  const task = await api.call("record", {
1473
1775
  kind: "task",
1474
- topicId: await topicOrDefault(api, values),
1776
+ topicId: await topicOrDefault(api, values, io),
1475
1777
  text,
1476
1778
  ...(assignee ? { assignee } : {}),
1477
1779
  ...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
@@ -1525,7 +1827,7 @@ export const commands = {
1525
1827
  "",
1526
1828
  "--body <text> The decision in full: what, and why.",
1527
1829
  "--thread <t> The comment thread the decision comes from: #12 or the thread id.",
1528
- "--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
1830
+ "--topic <topic> The topic id or its name. Default: the topic open in Orlan now, else the one used last.",
1529
1831
  AGENT_TOKEN_HELP,
1530
1832
  JSON_HELP,
1531
1833
  ],
@@ -1540,7 +1842,7 @@ export const commands = {
1540
1842
  run: (values, [title], io) => withAgent(values, async (api) => {
1541
1843
  const decision = await api.call("record", {
1542
1844
  kind: "decision",
1543
- topicId: await topicOrDefault(api, values),
1845
+ topicId: await topicOrDefault(api, values, io),
1544
1846
  title,
1545
1847
  ...(typeof values.body === "string" ? { body: values.body } : {}),
1546
1848
  ...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
@@ -1598,12 +1900,249 @@ export const commands = {
1598
1900
  : "orlan brief (where the work stands)");
1599
1901
  }),
1600
1902
  },
1903
+ brand: {
1904
+ usage: "orlan brand [--save <folder>] [--agent <id>] [--json]",
1905
+ summary: "Print the accepted brand kit of the organisation: colors, fonts, logos, tone, rules, formats.",
1906
+ details: [
1907
+ "Use it before you make a deck, a video, a carousel or a page, so the work is on brand with no brand",
1908
+ "words in the prompt. Orlan gives only the sections people accepted. A section that waits for review",
1909
+ "is named under draft, without its content: do not guess it. The references are work people like,",
1910
+ "each with a note.",
1911
+ "",
1912
+ "--save <folder> Also save the logo and font files of the kit in this folder.",
1913
+ AGENT_TOKEN_HELP,
1914
+ JSON_HELP,
1915
+ ],
1916
+ options: { ...agentOption, ...jsonOption, save: { type: "string" } },
1917
+ positionals: [0, 0],
1918
+ run: (values, _positionals, io) => withAgent(values, async (api, config) => {
1919
+ const kit = await api.call("brand", {});
1920
+ const saved = [];
1921
+ if (typeof values.save === "string" && kit.files.length > 0) {
1922
+ await mkdir(values.save, { recursive: true });
1923
+ for (const file of kit.files) {
1924
+ const response = await fetch(file.url, {
1925
+ headers: { authorization: `Bearer ${api.token}`, ...sessionHeader() },
1926
+ }).catch((error) => {
1927
+ throw unreachable(config.server, error);
1928
+ });
1929
+ if (!response.ok) {
1930
+ throw new OrlanError(`The download of ${quote(file.name)} answered ${response.status}.`, undefined, "run the command again");
1931
+ }
1932
+ // Two files with one name (a light and a dark logo.png) are both kept.
1933
+ const name = saved.includes(path.join(values.save, file.name)) ? `${file.id}-${file.name}` : file.name;
1934
+ const target = path.join(values.save, path.basename(name));
1935
+ await writeFile(target, Buffer.from(await response.arrayBuffer()));
1936
+ saved.push(target);
1937
+ }
1938
+ }
1939
+ const { sections } = kit;
1940
+ const lines = [`Brand kit v${kit.version}. Only the accepted sections are here.`];
1941
+ if (sections.colors) {
1942
+ lines.push(`colors: ${sections.colors.items.map((color) => `${color.name} ${color.hex}${color.use ? ` (${color.use})` : ""}`).join(", ") || "none"}`);
1943
+ }
1944
+ if (sections.fonts) {
1945
+ const font = (role) => sections.fonts?.[role].family || "not set";
1946
+ lines.push(`fonts: heading ${font("heading")}, body ${font("body")}`);
1947
+ }
1948
+ for (const file of kit.files)
1949
+ lines.push(`- ${file.role}: ${quote(file.name)} (${file.mime})`);
1950
+ if (sections.tone)
1951
+ lines.push(`tone: ${sections.tone.text || "not set"}`);
1952
+ if (sections.rules) {
1953
+ for (const rule of sections.rules.items)
1954
+ lines.push(`${rule.kind === "do" ? "do" : "do not"}: ${rule.text}`);
1955
+ }
1956
+ if (sections.formats) {
1957
+ const { video, deck, carousel } = sections.formats;
1958
+ lines.push(`formats: video ${video.join(", ") || "-"}; deck ${deck.join(", ") || "-"}; carousel ${carousel.join(", ") || "-"}`);
1959
+ }
1960
+ if (kit.draft.length)
1961
+ lines.push(`draft (waits for review, not for use): ${kit.draft.join(", ")}`);
1962
+ for (const ref of kit.references) {
1963
+ lines.push(`reference ${ref.kind}: ${ref.url ?? quote(ref.file?.name ?? "")} - ${quote(ref.note)}`);
1964
+ }
1965
+ for (const target of saved)
1966
+ lines.push(`Saved ${quote(target)}.`);
1967
+ // What people asked this agent to change (F141): each request ends with its own next_step line.
1968
+ const requests = kit.requests ?? [];
1969
+ for (const request of requests)
1970
+ lines.push("", ...brandRequestLines(request));
1971
+ return finish(io, values, { ...kit, ...(typeof values.save === "string" ? { saved } : {}) }, lines, requests.length > 0
1972
+ ? "answer each request above with orlan brand draft"
1973
+ : kit.files.length > 0 && typeof values.save !== "string"
1974
+ ? "orlan brand --save brand (the logo and font files)"
1975
+ : "make the work with this kit");
1976
+ }),
1977
+ },
1978
+ "brand draft": {
1979
+ usage: 'orlan brand draft <section> (--file <path> | --data <json>) [--note "<text>"] [--request <id>] [--agent <id>] [--json]',
1980
+ summary: "Write one section of the brand kit as a draft for people to review and accept.",
1981
+ details: [
1982
+ "Draft the kit from the website, the logo and font files and the files people give you: one call for",
1983
+ "each section. Each section waits as a draft until a person accepts it on the Brand kit page; an agent",
1984
+ "never accepts one. A draft answers your open requests on that section (orlan brand lists them).",
1985
+ "",
1986
+ "<section> colors, fonts, logos, tone, rules or formats. The content of each, as JSON:",
1987
+ ' colors {"items": [{"name": "Vivid Blue", "hex": "#0679FF", "use": "Buttons"}]}',
1988
+ ' fonts {"heading": {"family": "Avenir Next", "fileIds": []}, "body": {"family": "Inter", "fileIds": []}}',
1989
+ ' logos {"light": "<file id>", "dark": null, "mark": null}',
1990
+ ' tone {"text": "Short sentences. Say what the customer gets."}',
1991
+ ' rules {"items": [{"kind": "do", "text": "Captions on every video."}, {"kind": "dont", "text": "..."}]}',
1992
+ ' formats {"video": ["16:9", "9:16"], "deck": ["16:9"], "carousel": ["4:5"]}',
1993
+ " A file id comes from orlan brand upload.",
1994
+ "",
1995
+ "--file <path> The content in a JSON file.",
1996
+ "--data <json> The content as JSON text.",
1997
+ "--note <text> Where you took it from, or what you changed. People read it in the kit versions.",
1998
+ "--request <id> The request you answer, for example brand_12. Default: your open requests on the section.",
1999
+ AGENT_TOKEN_HELP,
2000
+ JSON_HELP,
2001
+ ],
2002
+ options: {
2003
+ ...agentOption,
2004
+ ...jsonOption,
2005
+ file: { type: "string" },
2006
+ data: { type: "string" },
2007
+ note: { type: "string" },
2008
+ request: { type: "string" },
2009
+ },
2010
+ positionals: [1, 1],
2011
+ run: async (values, [section], io) => {
2012
+ const file = typeof values.file === "string" ? values.file : undefined;
2013
+ const data = typeof values.data === "string" ? values.data : undefined;
2014
+ if ((file === undefined) === (data === undefined)) {
2015
+ throw new OrlanError("Give the content with --file or --data, one of them.", undefined, `orlan brand draft ${section} --file <path to the JSON content>`);
2016
+ }
2017
+ const text = file === undefined ? data : await readFile(file, "utf8");
2018
+ let content;
2019
+ try {
2020
+ content = JSON.parse(text);
2021
+ }
2022
+ catch {
2023
+ throw new OrlanError(`The content is not JSON${file ? ` in ${quote(file)}` : ""}.`, undefined, "orlan brand draft --help shows the content of each section");
2024
+ }
2025
+ return withAgent(values, async (api) => {
2026
+ const answer = await api.call("brand_draft", {
2027
+ section,
2028
+ content,
2029
+ ...(typeof values.note === "string" ? { note: values.note } : {}),
2030
+ ...(typeof values.request === "string" ? { requestId: values.request } : {}),
2031
+ });
2032
+ const { nextStep, ...rest } = answer;
2033
+ return finish(io, values, rest, [
2034
+ `Wrote ${answer.section} as a draft in brand kit v${answer.version}. It waits for a person to accept it.` +
2035
+ (answer.answered ? ` Answered ${answer.answered.join(", ")}.` : ""),
2036
+ ], nextStep);
2037
+ });
2038
+ },
2039
+ },
2040
+ "brand upload": {
2041
+ usage: "orlan brand upload <path> --kind logo|font [--agent <id>] [--json]",
2042
+ summary: "Upload a logo or a font file for the brand kit, and print its file id.",
2043
+ details: [
2044
+ "A logo is a PNG or a JPEG file; a font is an .otf, .ttf, .woff or .woff2 file. Name the file id in",
2045
+ "the logos or the fonts section with orlan brand draft.",
2046
+ "",
2047
+ "--kind <kind> logo or font. Required.",
2048
+ AGENT_TOKEN_HELP,
2049
+ JSON_HELP,
2050
+ ],
2051
+ options: { ...agentOption, ...jsonOption, kind: { type: "string" } },
2052
+ positionals: [1, 1],
2053
+ run: async (values, [filePath], io) => {
2054
+ const kind = values.kind;
2055
+ if (kind !== "logo" && kind !== "font") {
2056
+ throw new OrlanError("Add --kind logo or --kind font.", undefined, `orlan brand upload ${filePath} --kind logo`);
2057
+ }
2058
+ const form = new FormData();
2059
+ form.set("file", await openAsBlob(filePath), path.basename(filePath));
2060
+ return withAgent(values, async (api) => {
2061
+ const uploaded = await api.postForm("/api/agent/brand/files", { kind }, form, "the upload");
2062
+ return finish(io, values, uploaded, [`Uploaded ${quote(uploaded.name)} as ${uploaded.kind} file ${uploaded.id}.`], `name ${uploaded.id} in the ${kind === "logo" ? "logos" : "fonts"} section with orlan brand draft`);
2063
+ });
2064
+ },
2065
+ },
2066
+ studio: {
2067
+ usage: "orlan studio [<skill> [<step>]] [--agent <id>] [--json]",
2068
+ summary: "Print one step of a studio skill (video, deck, carousel, walkthrough): the steps come from Orlan.",
2069
+ details: [
2070
+ "A studio skill makes a piece of work on this computer in five steps: brief, script, build, render,",
2071
+ "review. Orlan gives the steps only to a signed-in agent, one step at a time: read each step when you",
2072
+ "come to it, and follow it. Read the brand kit first (orlan brand).",
2073
+ "",
2074
+ "orlan studio The skills, with their steps and helper scripts.",
2075
+ "orlan studio <skill> The steps and the helper scripts of one skill.",
2076
+ "orlan studio <skill> <step> The text of one step.",
2077
+ "",
2078
+ "Get the helper scripts of a skill with orlan studio pull <skill>.",
2079
+ AGENT_TOKEN_HELP,
2080
+ JSON_HELP,
2081
+ ],
2082
+ options: { ...agentOption, ...jsonOption },
2083
+ positionals: [0, 2],
2084
+ run: (values, [skill, step], io) => withStudio(values, async (api) => {
2085
+ if (!skill) {
2086
+ const { skills } = await api.call("studio", {});
2087
+ const lines = skills.length
2088
+ ? skills.map((each) => `${each.skill}: ${skillLine(each)}`)
2089
+ : ["Orlan has no studio skills yet."];
2090
+ return finish(io, values, { skills }, lines, "orlan studio <skill> brief");
2091
+ }
2092
+ if (!step) {
2093
+ const found = await api.call("studio", { skill });
2094
+ const next = `${found.scripts.length ? `orlan studio pull ${skill}, then ` : ""}orlan studio ${skill} brief`;
2095
+ return finish(io, values, { ...found }, [`${skill}: ${skillLine(found)}`], next);
2096
+ }
2097
+ const answer = await api.call("studio", { skill, step });
2098
+ const after = STUDIO_STEPS[STUDIO_STEPS.indexOf(step) + 1];
2099
+ return finish(io, values, answer, [answer.text.trimEnd()], after
2100
+ ? `orlan studio ${skill} ${after}`
2101
+ : "post the work: orlan files add <file>, or orlan files push for a new version");
2102
+ }),
2103
+ },
2104
+ "studio pull": {
2105
+ usage: "orlan studio pull <skill> [--to <folder>] [--agent <id>] [--json]",
2106
+ summary: "Write the helper scripts of a studio skill into the project, from Orlan.",
2107
+ details: [
2108
+ "The steps of the skill name these scripts. Pull them again for the newest copy: each file is written",
2109
+ "again, and files that you added stay.",
2110
+ "",
2111
+ "--to <folder> The folder to write the scripts in. Default: .orlan/studio/<skill> in this folder.",
2112
+ AGENT_TOKEN_HELP,
2113
+ JSON_HELP,
2114
+ ],
2115
+ options: { ...agentOption, ...jsonOption, to: { type: "string" } },
2116
+ positionals: [1, 1],
2117
+ run: (values, [skill], io) => withStudio(values, async (api) => {
2118
+ const { files } = await api.call("studio", { skill, scripts: true });
2119
+ const folder = path.resolve(typeof values.to === "string" ? values.to : path.join(".orlan", "studio", `${skill}`));
2120
+ for (const file of files) {
2121
+ const target = path.resolve(folder, file.path);
2122
+ // A path from the server stays in the folder.
2123
+ if (!target.startsWith(`${folder}${path.sep}`)) {
2124
+ throw new OrlanError(`Orlan named a script path outside the folder: ${quote(file.path)}.`, "internal_error");
2125
+ }
2126
+ await mkdir(path.dirname(target), { recursive: true });
2127
+ await writeFile(target, Buffer.from(file.contentBase64, "base64"));
2128
+ await chmod(target, file.executable ? 0o755 : 0o644);
2129
+ }
2130
+ return finish(io, values, { skill, folder, files: files.map((file) => file.path) }, [
2131
+ `Pulled ${files.length} helper scripts of ${skill} into ${folder}.`,
2132
+ ...files.map((file) => `- ${file.path}`),
2133
+ ], `orlan studio ${skill} brief`);
2134
+ }),
2135
+ },
1601
2136
  brief: {
1602
2137
  usage: "orlan brief [--topic <topic>] [--agent <id>] [--json]",
1603
2138
  summary: "Print where the work stands: the topic, the open requests, the files, and the next step.",
1604
2139
  details: [
1605
- "For the start of a session: one short answer, in one plain HTTP call. The topic is the one the agent",
1606
- "used last, else its only topic. It lists the open requests (`orlan inbox` prints them in full),",
2140
+ "For the start of a task: one short answer, in one plain HTTP call. The topic is the one the person",
2141
+ "has open in Orlan now, and the first lines say so. When the agent cannot reach that topic, they say",
2142
+ "so, give the step that grants it, and name the topic the agent works in instead: the newer of the",
2143
+ "one it used last and the one the person opened last, else its only topic. Other commands with no",
2144
+ "--topic use the same topic; in one session they keep the topic of the last call for 5 minutes.",
2145
+ "It lists the open requests (`orlan inbox` prints them in full),",
1607
2146
  "the files with their current versions, the open vote and the open tasks (one line each), and one",
1608
2147
  "next_step line. People vote; an agent reads the vote, it does not vote.",
1609
2148
  "",
@@ -1614,7 +2153,9 @@ export const commands = {
1614
2153
  options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
1615
2154
  positionals: [0, 0],
1616
2155
  run: (values, _positionals, io) => withAgent(values, async (api) => {
1617
- const { lines, nextStep, ...answer } = await api.get("/api/agent/brief", { topic: values.topic }, "the brief");
2156
+ const { lines, nextStep, ...answer } = await api.get("/api/agent/brief",
2157
+ // The start of a task: Orlan takes the topic the person has open now again (F123).
2158
+ { topic: values.topic, start: "true" }, "the brief");
1618
2159
  // The lines end with Orlan's next_step line.
1619
2160
  io.out(values.json ? JSON.stringify({ ...answer, next_step: nextStep }) : lines.join("\n"));
1620
2161
  return 0;
@@ -1632,7 +2173,7 @@ export const commands = {
1632
2173
  options: { ...agentOption, ...jsonOption, new: { type: "boolean" } },
1633
2174
  positionals: [0, 0],
1634
2175
  run: (values, _positionals, io) => withAgent(values, async (api) => {
1635
- const { requests, stopped } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
2176
+ const { requests, stopped, brand = [], } = await api.get("/api/inbox", { new: values.new ? "true" : undefined }, "the inbox");
1636
2177
  // A person stopped this session on the board (D109): the stop, also with --new.
1637
2178
  if (stopped) {
1638
2179
  const [line = "", step = ""] = stopped.lines;
@@ -1644,20 +2185,23 @@ export const commands = {
1644
2185
  return 0;
1645
2186
  }
1646
2187
  // --new with no request prints nothing.
2188
+ // The brand kit requests (F141) come after the topic requests, each with its own next_step line.
2189
+ const all = [...requests, ...brand];
1647
2190
  if (values.json) {
1648
- if (requests.length > 0 || !values.new) {
1649
- const last = requests.at(-1);
2191
+ if (all.length > 0 || !values.new) {
2192
+ const last = all.at(-1);
1650
2193
  printJson(io, {
1651
2194
  requests: requests.map((each) => each.request),
2195
+ ...(brand.length > 0 ? { brand: brand.map((each) => each.request) } : {}),
1652
2196
  next_step: last ? deliveryStep(last) : NOTHING_STEP,
1653
2197
  });
1654
2198
  }
1655
2199
  }
1656
2200
  else {
1657
- if (requests.length === 0 && !values.new)
2201
+ if (all.length === 0 && !values.new)
1658
2202
  io.out(`No open requests.\nnext_step: ${NOTHING_STEP}`);
1659
2203
  // Each request ends with its own next_step line. The line on quoted text comes once.
1660
- for (const [index, delivery] of requests.entries()) {
2204
+ for (const [index, delivery] of all.entries()) {
1661
2205
  if (index > 0)
1662
2206
  io.out("");
1663
2207
  const [first = "", ...rest] = delivery.lines;
@@ -1723,7 +2267,7 @@ export const commands = {
1723
2267
  const api = new AgentApi(config.server, token);
1724
2268
  // The file goes to the request's topic in one request; a done request refuses it before it is read.
1725
2269
  const uploadId = typeof values.file === "string"
1726
- ? await api.upload(await readFile(values.file), { request: requestId }, "respond")
2270
+ ? await api.upload(await openAsBlob(values.file), { request: requestId, name: path.basename(values.file) }, "respond")
1727
2271
  : undefined;
1728
2272
  const answer = await api.call("respond", {
1729
2273
  requestId,
@@ -1770,14 +2314,20 @@ export const commands = {
1770
2314
  },
1771
2315
  },
1772
2316
  };
1773
- /** Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. */
1774
- async function editBoard(api, values, edits) {
2317
+ /**
2318
+ * Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. Edits that
2319
+ * add items can make a new topic (--new-topic, F131); the answer names it.
2320
+ */
2321
+ async function editBoard(api, values, io, edits) {
2322
+ const { topicId, newTopic } = edits.some((edit) => edit.op === "create")
2323
+ ? await writeTopic(api, values, io)
2324
+ : { topicId: await topicOrDefault(api, values, io), newTopic: undefined };
1775
2325
  const answer = await api.call("edit_board", {
1776
- topicId: await topicOrDefault(api, values),
2326
+ topicId,
1777
2327
  edits,
1778
2328
  });
1779
2329
  // A server before F096 gives no warnings.
1780
- return { ...answer, warnings: answer.warnings ?? [] };
2330
+ return { ...answer, warnings: answer.warnings ?? [], ...(newTopic ? { newTopic } : {}) };
1781
2331
  }
1782
2332
  /** --assignee as the tool takes it: me (the token of this agent), user:<id>, agent:<id>, or none (null). */
1783
2333
  function assigneeOption(config, values) {
@@ -1809,6 +2359,11 @@ function voteText(vote) {
1809
2359
  `, ${vote.voters} voted` +
1810
2360
  (result ? `, result: ${result}` : ""));
1811
2361
  }
2362
+ /** A scorecard as its sum of the most it can be (F139): "65/80". */
2363
+ function scoreTotal(scores) {
2364
+ const sum = scores.reduce((total, each) => total + each.score, 0);
2365
+ return `${Math.round(sum * 10) / 10}/${scores.length * 10}`;
2366
+ }
1812
2367
  /** --pages as page numbers: "3" or "2,5". */
1813
2368
  function pageList(values) {
1814
2369
  if (typeof values.pages !== "string")
@@ -1884,3 +2439,10 @@ async function pullPages(api, config, values, io, answer, since) {
1884
2439
  }
1885
2440
  /** The changed pages of a version, as words: "pages 2, 5 changed", "no page changed". */
1886
2441
  const changedText = (pages) => pages.length === 0 ? "no page changed" : `page${pages.length === 1 ? "" : "s"} ${pages.join(", ")} changed`;
2442
+ /** A brand request as text, as `orlan inbox` prints it. Every text people wrote is quoted. */
2443
+ const brandRequestLines = (request) => [
2444
+ `${request.id}: a request from ${quote(request.from)} (person) on Orlan, brand kit section ${request.section}. ${QUOTED_TEXT}`,
2445
+ `${quote(request.from)} wrote: ${quote(request.text)}`,
2446
+ `${request.section} now: ${JSON.stringify(request.current)}`,
2447
+ `next_step: write the changed ${request.section} as JSON in a file, then orlan brand draft ${request.section} --file <path> --request ${request.id} --note "<what changed>"`,
2448
+ ];