@assuredloop/orca-bot-kit 0.20.0 → 0.22.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/src/temp.js CHANGED
@@ -4,7 +4,10 @@
4
4
  //
5
5
  // Any long-lived session can make a temporary session of its own bot for a
6
6
  // piece of work, and retire it when the work is done, without Bot Father, who
7
- // stays the manager of long-lived sessions. A temporary session is an ordinary
7
+ // stays the manager of long-lived sessions. A temporary session can make one
8
+ // of its own, one level deep and one at a time (#464, ADR 0033). It can be
9
+ // made in a role of its bot's `temp_roles`, which gives its settings, and
10
+ // whose cap limits how many are open (#465). A temporary session is an ordinary
8
11
  // session in every other way: in bot.yaml, brought up with a tab and a mailbox,
9
12
  // its conversations kept in the book. What makes it temporary is one field of
10
13
  // its book entry, `temporary: { maker, made }`, and the book is the one place
@@ -26,8 +29,8 @@ import path from 'node:path';
26
29
  import { setTimeout as pause } from 'node:timers/promises';
27
30
 
28
31
  import { readBook, updateBook } from './book.js';
29
- import { addSession, dropSession, NAME, readBot } from './bot.js';
30
- import { harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
32
+ import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
33
+ import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
31
34
  import { sessionInTab } from './message.js';
32
35
  import { orca, screenRows, tabs } from './orca.js';
33
36
  import { retireSession } from './retire.js';
@@ -38,87 +41,232 @@ const INHERITED = ['model', 'effort', 'context', 'approval'];
38
41
 
39
42
  /**
40
43
  * Make a temporary session of the caller's own bot and bring it up. `given`
41
- * holds what the caller said: `name`, `prompt` or `prompt_file`, and any of
42
- * `harness` and the inherited settings. Returns `{ bot, session, maker,
43
- * settings, up }`, where `up` is what bringing it up answered.
44
+ * holds what the caller said: `name`, `prompt` or `prompt_file`, any of
45
+ * `harness` and the inherited settings, and `role`, as `role` or
46
+ * `role:option`, to take the settings that role's option gives in its bot's
47
+ * `temp_roles` (#465). Returns `{ bot, session, maker, settings, chosen,
48
+ * role, up }`: `chosen` says where each setting came from, `role` is there
49
+ * when one was asked for, and `up` is what bringing it up answered.
44
50
  */
45
51
  export async function makeTemp(bots, { tab, ...given }) {
46
52
  const caller = callerIn(bots, tab, 'make');
47
- if (caller.temporary !== undefined) {
48
- throw new Error(`${caller.bot}/${caller.session} is a temporary session, and only a long-lived session makes one. Nothing was made.`);
49
- }
53
+ // Checked here, before anything is written, and again under the book's lock
54
+ // below, where two makes at once cannot both pass it.
55
+ refuseNested(bots, caller, readBook(caller.home));
56
+ const bot = readBot(caller.home, caller.bot);
57
+ const role = given.role === undefined ? undefined : roleAsked(caller, bot, given.role);
58
+ const name = role === undefined ? given.name : roleName(caller, bot, role, given.name);
50
59
  // The name is a folder under work/ and a file beside the bots folder, so it
51
60
  // is held to the rule a bot's name is: one plain name, never a path.
52
- if (!NAME.test(given.name)) {
53
- throw new Error(`${given.name} cannot be a session's name: a name is lower-case letters, digits and single hyphens, such as review-250. Nothing was made.`);
54
- }
55
- if (given.prompt === undefined && given.prompt_file === undefined) {
56
- throw new Error(`temp make needs the task: --prompt <text> or --prompt-file <path>. Nothing was made.`);
61
+ if (!NAME.test(name)) {
62
+ throw new Error(`${name} cannot be a session's name: a name is lower-case letters, digits and single hyphens, such as review-250. Nothing was made.`);
57
63
  }
58
64
  if (given.prompt !== undefined && given.prompt_file !== undefined) {
59
65
  throw new Error(`temp make was given the task twice, as --prompt and as --prompt-file, and a session is told its duty once. Give it one way. Nothing was made.`);
60
66
  }
67
+ if (given.prompt === undefined && given.prompt_file === undefined && role?.prompt_file === undefined) {
68
+ throw new Error(`temp make needs the task: --prompt <text> or --prompt-file <path>${role === undefined ? '' : `, since the role ${role.name} has no prompt file`}. Nothing was made.`);
69
+ }
70
+ if (role !== undefined) refuseCap(caller, role, readBook(caller.home));
61
71
 
62
- const bot = readBot(caller.home, caller.bot);
63
72
  const maker = bot.sessions.find((session) => session.name === caller.session);
64
- const settings = { name: given.name };
65
- const harness = given.harness ?? maker?.harness;
66
- if (harness !== undefined) settings.harness = harness;
67
- // On another harness than its maker's, the model, the effort and the context
68
- // are that harness's own, and only the approval carries over (#238, PRD 6.4).
69
- const same = (harness ?? bot.harness) === harnessOf(maker ?? {}, bot.harness);
70
- for (const field of INHERITED) {
71
- const value = given[field] ?? (same || field === 'approval' ? maker?.[field] : undefined);
72
- if (value !== undefined) settings[field] = value;
73
- }
73
+ const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
74
+ settings.name = name;
74
75
  if (given.extra_args !== undefined) settings.extra_args = given.extra_args;
75
76
  if (given.prompt !== undefined) settings.prompt = given.prompt;
76
- else settings.prompt_file = given.prompt_file;
77
- settings.work_dir = `work/${given.name}`;
77
+ else settings.prompt_file = given.prompt_file ?? role.prompt_file;
78
+ settings.work_dir = `work/${name}`;
78
79
 
79
- const written = bot.sessions.some((session) => session.name === given.name) ? undefined : writePromptFirst(bots, caller, settings);
80
- // Refuses a name the bot already has, and a setting that will not work,
81
- // before it writes anything; the prompt written first goes with a refusal.
82
- let added;
83
- try {
84
- added = addSession(bots, caller.bot, settings);
85
- } catch (error) {
86
- if (written !== undefined) rmSync(written, { force: true });
87
- throw error;
88
- }
80
+ const written = bot.sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
81
+ // bot.yaml and the book are written under the book's lock, so that a second
82
+ // make from the same maker waits, and then sees this one (#464). addSession
83
+ // refuses a name the bot already has, and a setting that will not work,
84
+ // before it writes anything.
89
85
  const made = new Date().toISOString();
86
+ let locked = false;
87
+ let added;
90
88
  try {
91
89
  await updateBook(caller.home, (book) => {
92
- book.sessions[given.name] = { ...book.sessions[given.name], temporary: { maker: caller.session, made } };
90
+ locked = true;
91
+ refuseNested(bots, caller, book);
92
+ if (role !== undefined) refuseCap(caller, role, book);
93
+ added = addSession(bots, caller.bot, settings);
94
+ const kind = role === undefined ? {} : { role: role.name, option: role.option.name };
95
+ book.sessions[name] = { ...book.sessions[name], temporary: { maker: caller.session, made, ...kind } };
93
96
  });
94
97
  } catch (error) {
98
+ // A refusal under the lock has written nothing but the prompt file.
99
+ const refused = locked && added === undefined;
100
+ const failed = `${caller.bot}/${name} could not be recorded in the book as temporary (${error.message})`;
95
101
  // A session in bot.yaml that the book does not call temporary would be a
96
102
  // long-lived one to everything that reads it, its maker's retire included,
97
103
  // so it is taken back off rather than left that way.
98
- const failed = `${caller.bot}/${given.name} could not be recorded in the book as temporary (${error.message})`;
99
- try {
100
- dropSession(bots, caller.bot, given.name);
101
- } catch (undo) {
102
- throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name}`);
104
+ if (added !== undefined) {
105
+ try {
106
+ dropSession(bots, caller.bot, name);
107
+ } catch (undo) {
108
+ throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${name}`);
109
+ }
103
110
  }
104
111
  // Off bot.yaml, nothing is made; a prompt file that stays is only a file,
105
112
  // and nothing reads it (#393).
113
+ const why = refused ? error.message.replace(/ ?Nothing was made\.$/, '') : `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
106
114
  try {
107
115
  if (written !== undefined) rmSync(written, { force: true });
108
116
  } catch (left) {
109
- throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
117
+ throw new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
110
118
  }
111
- throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Run this again once the book can be written.`);
119
+ if (refused) throw error;
120
+ throw new Error(`${why} Nothing was made. Run this again once the book can be written.`);
112
121
  }
113
122
 
114
123
  let up;
115
124
  try {
116
- up = await bringUp(bots, { bot: caller.bot, session: given.name });
125
+ up = await bringUp(bots, { bot: caller.bot, session: name });
117
126
  } catch (error) {
118
127
  const cli = shellWord(ownCli());
119
- throw new Error(`${caller.bot}/${given.name} is made, in bot.yaml and in the book as temporary with its maker ${caller.session}, and could not be brought up: ${error.message} Bring it up with ${cli} up --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name} or retire it with ${cli} temp retire --bots ${shellWord(bots)} --name ${given.name}`);
128
+ throw new Error(`${caller.bot}/${name} is made, in bot.yaml and in the book as temporary with its maker ${caller.session}, and could not be brought up: ${error.message} Bring it up with ${cli} up --bots ${shellWord(bots)} --bot ${caller.bot} --session ${name} or retire it with ${cli} temp retire --bots ${shellWord(bots)} --name ${name}`);
129
+ }
130
+ return { bot: caller.bot, session: name, maker: caller.session, settings: added.session, chosen, ...(role === undefined ? {} : { role: roleAnswer(role) }), up };
131
+ }
132
+
133
+ /**
134
+ * The settings a temporary session is made with, and for each of the ones
135
+ * that can be inherited, what it got and where from: `chosen` (#465). A flag
136
+ * wins; then the role's option, on the option's harness, which is the maker's
137
+ * when the option names none; then the maker, on the maker's harness; and
138
+ * otherwise the harness's own default. On another harness than its maker's,
139
+ * only the approval carries over (#238, PRD 6.4).
140
+ */
141
+ function settingsFor(bot, maker, option, given) {
142
+ const makers = harnessOf(maker ?? {}, bot.harness);
143
+ const settings = {};
144
+ const chosen = {};
145
+ const pick = (field, ...sources) => {
146
+ // YAML reads a setting written with no value as null, which gives nothing.
147
+ const found = sources.find(([, value]) => value !== undefined && value !== null);
148
+ if (found === undefined) {
149
+ chosen[field] = { from: 'harness default' };
150
+ return;
151
+ }
152
+ chosen[field] = { value: found[1], from: found[0] };
153
+ if (found[0] !== 'maker' || field !== 'harness' || maker?.harness !== undefined) settings[field] = found[1];
154
+ };
155
+ pick('harness', ['flag', given.harness], ['role', option.harness], ['maker', makers]);
156
+ const harness = chosen.harness.value;
157
+ const fromRole = harness === (option.harness ?? makers);
158
+ const same = harness === makers;
159
+ for (const field of INHERITED) {
160
+ pick(
161
+ field,
162
+ ['flag', given[field]],
163
+ ['role', fromRole && field !== 'approval' ? option[field] : undefined],
164
+ ['maker', same || field === 'approval' ? maker?.[field] : undefined],
165
+ // A session's approval is never left to the harness (ADR 0015).
166
+ ['kit default', field === 'approval' ? DEFAULT_APPROVAL : undefined],
167
+ );
168
+ }
169
+ return { settings, chosen };
170
+ }
171
+
172
+ /**
173
+ * The role and option `asked` names, as `role` or `role:option`, from the
174
+ * bot's roles, with the option it takes; or a refusal that names what the bot
175
+ * has to choose from.
176
+ */
177
+ function roleAsked(caller, bot, asked) {
178
+ const [name, optionName, ...more] = asked.split(':');
179
+ const roles = tempRoles(caller.home, bot);
180
+ if (roles.length === 0) {
181
+ throw new Error(`${caller.bot} has no roles for temporary sessions: its bot.yaml has no temp_roles. Make it without --role, or give the bot its roles first. Nothing was made.`);
182
+ }
183
+ const role = roles.find((one) => one.name === name);
184
+ if (role === undefined || more.length > 0) {
185
+ throw new Error(`${caller.bot} has no role ${asked}. Its roles are ${roles.map((one) => one.name).join(', ')}; ask for one as --role <role> or --role <role>:<option>. Nothing was made.`);
186
+ }
187
+ if (role.trouble.length > 0) {
188
+ throw new Error(`the role ${name} cannot be used as it is written: ${role.trouble.join(' ')} Nothing was made.`);
189
+ }
190
+ const option = optionName === undefined ? role.options[0] : role.options.find((one) => one.name === optionName);
191
+ if (option === undefined) {
192
+ throw new Error(`the role ${name} has no option ${optionName}. Its options are ${optionsLine(optionsOf(role))}. Nothing was made.`);
193
+ }
194
+ return { ...role, option };
195
+ }
196
+
197
+ /**
198
+ * The name of a session made in `role`: `--name` as given when it already
199
+ * begins with the role, or with the role put in front. A name that begins with
200
+ * another of the bot's roles would say the wrong one, and is refused.
201
+ */
202
+ function roleName(caller, bot, role, given) {
203
+ if (given.startsWith(`${role.name}-`)) return given;
204
+ const other = tempRoles(caller.home, bot).find((one) => given.startsWith(`${one.name}-`));
205
+ if (other !== undefined) {
206
+ throw new Error(`${given} begins with ${other.name}-, the name of another of ${caller.bot}'s roles, and a session made in ${role.name} is named for ${role.name}. Give --name without it, such as --name ${given.slice(other.name.length + 1)}, to make ${role.name}-${given.slice(other.name.length + 1)}. Nothing was made.`);
207
+ }
208
+ return `${role.name}-${given}`;
209
+ }
210
+
211
+ /** Refuse a make in a role whose cap of open sessions is reached, against `book` (#465). */
212
+ function refuseCap(caller, role, book) {
213
+ if (role.cap === undefined) return;
214
+ const open = Object.keys(book.sessions).filter((name) => book.sessions[name]?.temporary?.role === role.name);
215
+ if (open.length >= role.cap) {
216
+ throw new Error(`the role ${role.name} has a cap of ${role.cap} open temporary sessions, and ${caller.bot} has ${open.length} open: ${open.join(', ')}. Retire one whose work is done first. Nothing was made.`);
217
+ }
218
+ }
219
+
220
+ /** A role's options as an answer gives them: each one's name, what it is for, and whether it is the default. */
221
+ const optionsOf = (role) => role.options.map((option, at) => ({ name: option.name, ...(option.for === undefined ? {} : { for: option.for }), default: at === 0 }));
222
+
223
+ /** Options as `optionsOf` gives them, on one line. */
224
+ export const optionsLine = (options) => options
225
+ .map((option) => `${option.name}${option.default ? ' (the default)' : ''}${option.for === undefined ? '' : `, for ${option.for}`}`)
226
+ .join('; ');
227
+
228
+ /** A role as an answer gives it: the option used, and every option the role has. */
229
+ const roleAnswer = (role) => ({
230
+ name: role.name,
231
+ option: role.option.name,
232
+ ...(role.option.for === undefined ? {} : { for: role.option.for }),
233
+ options: optionsOf(role),
234
+ });
235
+
236
+ /**
237
+ * The roles of the caller's bot, each with its cap, its open temporary
238
+ * sessions, its prompt file and its options, the first the default (#465).
239
+ * Returns `{ bot, roles }`.
240
+ */
241
+ export function listRoles(bots, { tab }) {
242
+ const caller = callerIn(bots, tab, 'roles', 'list the roles');
243
+ const book = readBook(caller.home);
244
+ const roles = tempRoles(caller.home, readBot(caller.home, caller.bot)).map((role) => ({
245
+ name: role.name,
246
+ ...(role.cap === undefined ? {} : { cap: role.cap }),
247
+ open: Object.keys(book.sessions).filter((name) => book.sessions[name]?.temporary?.role === role.name),
248
+ ...(role.prompt_file === undefined ? {} : { prompt_file: role.prompt_file }),
249
+ options: role.options.map((option, at) => ({ ...option, default: at === 0 })),
250
+ ...(role.trouble.length === 0 ? {} : { trouble: role.trouble }),
251
+ }));
252
+ return { bot: caller.bot, roles };
253
+ }
254
+
255
+ /**
256
+ * Refuse a make by a temporary session that may not make one now, against
257
+ * `book`: one made by a temporary session makes none, and a temporary session
258
+ * has one of its own open at a time (#464, ADR 0033).
259
+ */
260
+ function refuseNested(bots, caller, book) {
261
+ if (caller.temporary === undefined) return;
262
+ const maker = caller.temporary.maker;
263
+ if (book.sessions[maker]?.temporary !== undefined) {
264
+ throw new Error(`${caller.bot}/${caller.session} is a temporary session ${maker} made, and ${maker} is temporary itself, so it makes none of its own: temporary sessions go one level deep. Nothing was made.`);
265
+ }
266
+ const open = Object.keys(book.sessions).filter((name) => book.sessions[name]?.temporary?.maker === caller.session);
267
+ if (open.length > 0) {
268
+ throw new Error(`${caller.bot}/${caller.session} is a temporary session, and it already has ${open.join(', ')} open; a temporary session has one of its own at a time. Retire it first with ${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${open[0]} Nothing was made.`);
120
269
  }
121
- return { bot: caller.bot, session: given.name, maker: caller.session, settings: added.session, up };
122
270
  }
123
271
 
124
272
  /**