@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/README.md +14 -0
- package/package.json +1 -1
- package/rules/temporary.md +7 -8
- package/src/book.js +2 -1
- package/src/bot.js +78 -1
- package/src/cli.js +134 -21
- package/src/health.js +12 -1
- package/src/hooks.js +127 -1
- package/src/message.js +81 -4
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +12 -10
- package/src/retire.js +45 -13
- package/src/sent.js +111 -0
- package/src/temp.js +196 -48
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
|
|
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`,
|
|
42
|
-
* `harness` and the inherited settings
|
|
43
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
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(
|
|
53
|
-
throw new Error(`${
|
|
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 =
|
|
65
|
-
|
|
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/${
|
|
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 ===
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(`${
|
|
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
|
-
|
|
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:
|
|
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}/${
|
|
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
|
/**
|