@assuredloop/orca-bot-kit 0.26.1 → 0.28.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 +68 -41
- package/SETUP.md +26 -13
- package/package.json +1 -1
- package/rules/mail.md +8 -8
- package/skills/obk-bot-building/SKILL.md +24 -15
- package/skills/obk-grooming/SKILL.md +4 -3
- package/src/book.js +143 -23
- package/src/bot.js +246 -118
- package/src/clear.js +7 -3
- package/src/cli.js +129 -130
- package/src/commands.js +21 -14
- package/src/conversations.js +73 -40
- package/src/groom.js +27 -9
- package/src/health.js +79 -11
- package/src/hooks.js +42 -64
- package/src/init.js +36 -29
- package/src/launch.js +221 -20
- package/src/message.js +83 -335
- package/src/name.js +1 -1
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +35 -42
- package/src/permissions.js +30 -16
- package/src/pi.js +55 -0
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/rules.js +5 -2
- package/src/skills.js +51 -35
- package/src/sources.js +73 -4
- package/src/temp.js +65 -20
- package/src/up.js +80 -14
- package/src/unread.js +0 -160
package/src/bot.js
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
// The files are the user's. A command that cannot do what was asked refuses and
|
|
8
8
|
// writes nothing, rather than leave a bot half made or a bot.yaml half edited.
|
|
9
9
|
|
|
10
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { isDeepStrictEqual } from 'node:util';
|
|
14
14
|
import { isSeq, parse, parseDocument, stringify } from 'yaml';
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { takeBotTurn } from './book.js';
|
|
17
|
+
import { defaultApproval, HARNESSES, harnessOf, ownCli, refuseApprovalArgs, sessionTrouble, set, shellWord, workDirOf } from './launch.js';
|
|
17
18
|
|
|
18
19
|
/** Where the bots live inside the user's bots folder. */
|
|
19
20
|
export const botsDir = (bots) => path.join(bots, 'bots');
|
|
@@ -75,10 +76,21 @@ export function leadsOutside(home, target) {
|
|
|
75
76
|
}
|
|
76
77
|
// Links that never settle lead nowhere the kit can vouch for.
|
|
77
78
|
if (real === undefined) return `${target}, through links that never settle`;
|
|
78
|
-
const root = realpathSync(home);
|
|
79
|
+
const root = realpathSync.native(home);
|
|
79
80
|
return real === root || real.startsWith(root + path.sep) ? undefined : real;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Refuse to write a bot's own file — its `bot.yaml`, its `AGENTS.md` — through a
|
|
85
|
+
* link that takes it out of the bot folder at `home`: what is there is the
|
|
86
|
+
* user's own file, not the bot's (#569).
|
|
87
|
+
*/
|
|
88
|
+
export function refuseOutside(home, file) {
|
|
89
|
+
const real = leadsOutside(home, file);
|
|
90
|
+
if (real === undefined) return;
|
|
91
|
+
throw new Error(`${file} leads outside the bot folder, to ${real}, through a link, and the kit writes a bot's files only inside the bot folder, so nothing was written. Replace the link with a file of the bot's own, then run the command again.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
/** How many links deep a path is followed before it is taken to lead nowhere. */
|
|
83
95
|
const HOPS = 40;
|
|
84
96
|
|
|
@@ -99,10 +111,16 @@ function whereItLeads(target) {
|
|
|
99
111
|
at = path.dirname(at);
|
|
100
112
|
}
|
|
101
113
|
try {
|
|
102
|
-
|
|
114
|
+
// The native call, which asks the file system: the JS one runs
|
|
115
|
+
// path.resolve first, which takes `hop/..` away before `hop` is followed.
|
|
116
|
+
return path.join(realpathSync.native(at), ...rest);
|
|
103
117
|
} catch (error) {
|
|
104
118
|
if (!lstatSync(at).isSymbolicLink()) throw error;
|
|
105
|
-
|
|
119
|
+
// Joined as text, not with path.join or path.resolve: they take `hop/..`
|
|
120
|
+
// away before `hop` is followed, and the file system follows it first
|
|
121
|
+
// (#569 review).
|
|
122
|
+
const text = readlinkSync(at);
|
|
123
|
+
next = [path.isAbsolute(text) ? text : `${path.dirname(at)}${path.sep}${text}`, ...rest].join(path.sep);
|
|
106
124
|
}
|
|
107
125
|
}
|
|
108
126
|
return undefined;
|
|
@@ -162,7 +180,7 @@ export function readBot(home, name = path.basename(home)) {
|
|
|
162
180
|
// The roles its temporary sessions can be made in, judged by tempRoles.
|
|
163
181
|
...(bot.temp_roles === undefined ? {} : { temp_roles: bot.temp_roles }),
|
|
164
182
|
// The widest approval its temporary sessions may be made at, the user's yes
|
|
165
|
-
// kept by `permission approval --temps` (ADR
|
|
183
|
+
// kept by `permission approval --temps` (ADR 0051).
|
|
166
184
|
...(bot.temp_approval === undefined ? {} : { temp_approval: bot.temp_approval }),
|
|
167
185
|
// A paused bot is one `obk up` leaves closed; its sessions keep their book.
|
|
168
186
|
...(bot.paused === true ? { paused: true } : {}),
|
|
@@ -182,9 +200,11 @@ export function readBot(home, name = path.basename(home)) {
|
|
|
182
200
|
*
|
|
183
201
|
* The bot's own files, and no more: its `AGENTS.md` is built from this
|
|
184
202
|
* `bot.yaml` and the rule units, which is the rules build's job and is reported
|
|
185
|
-
* as one (PRD 6.6).
|
|
203
|
+
* as one (PRD 6.6). `allow` is the kit's default rules for it, written in the
|
|
204
|
+
* same first write, so that no later read and write of the file can lose a
|
|
205
|
+
* change another command makes at the same time (#595).
|
|
186
206
|
*/
|
|
187
|
-
export function createBot(bots, { name, harness, charter }) {
|
|
207
|
+
export function createBot(bots, { name, harness, charter, allow = [] }) {
|
|
188
208
|
if (!NAME.test(name)) {
|
|
189
209
|
throw new Error(`${name} cannot be a bot's name: a name is lower-case letters, digits and single hyphens, such as api-bot.`);
|
|
190
210
|
}
|
|
@@ -201,7 +221,7 @@ export function createBot(bots, { name, harness, charter }) {
|
|
|
201
221
|
|
|
202
222
|
const text = charter?.trim() ? `${charter.trim()}\n` : PLACEHOLDER_CHARTER(name);
|
|
203
223
|
const files = [
|
|
204
|
-
[BOT_YAML, botYaml(name, harness, text)],
|
|
224
|
+
[BOT_YAML, botYaml(name, harness, text, allow)],
|
|
205
225
|
['.gitignore', BOT_GITIGNORE],
|
|
206
226
|
];
|
|
207
227
|
|
|
@@ -232,35 +252,39 @@ export function addSession(bots, bot, settings) {
|
|
|
232
252
|
if (!existsSync(file)) {
|
|
233
253
|
throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
|
|
234
254
|
}
|
|
255
|
+
refuseOutside(home, file);
|
|
235
256
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const session = ordered({ ...settings, approval: settings.approval ?? DEFAULT_APPROVAL });
|
|
242
|
-
const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
|
|
243
|
-
if (trouble !== undefined) throw new Error(trouble);
|
|
257
|
+
return withBotTurn(home, () => {
|
|
258
|
+
const known = readBot(home, bot);
|
|
259
|
+
if (known.sessions.some((session) => session.name === settings.name)) {
|
|
260
|
+
throw new Error(`${bot} already has a session called ${settings.name}, and session add never writes over one.`);
|
|
261
|
+
}
|
|
244
262
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
263
|
+
// Pi has one level, and the session says so in bot.yaml (#547).
|
|
264
|
+
const session = ordered({ ...settings, approval: settings.approval ?? defaultApproval(harnessOf(settings, known.harness)) });
|
|
265
|
+
const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
|
|
266
|
+
if (trouble !== undefined) throw new Error(trouble);
|
|
267
|
+
|
|
268
|
+
const source = readFileSync(file, 'utf8');
|
|
269
|
+
const doc = parseDocument(source);
|
|
270
|
+
const sessions = doc.get('sessions', true);
|
|
271
|
+
if (sessions?.items === undefined) {
|
|
272
|
+
// No list to add to: an empty `sessions:`, or no sessions key at all.
|
|
273
|
+
doc.set('sessions', [session]);
|
|
274
|
+
} else {
|
|
275
|
+
// A list written inline is written out as a block list from here on, which
|
|
276
|
+
// is the one shape a session with a prompt in it reads well in.
|
|
277
|
+
sessions.flow = false;
|
|
278
|
+
doc.addIn(['sessions'], session);
|
|
279
|
+
}
|
|
257
280
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
281
|
+
const text = doc.toString(YAML_OUT);
|
|
282
|
+
if (!changesExactly(source, text, (was) => ({ ...was, sessions: [...(was.sessions ?? []), session] }))) {
|
|
283
|
+
throw new Error(`${file} cannot have a session added to it without changing something else in it, so nothing was written. Add ${settings.name} to its sessions list by hand.`);
|
|
284
|
+
}
|
|
285
|
+
writeWhole(file, text);
|
|
286
|
+
return { bot, home, session };
|
|
287
|
+
});
|
|
264
288
|
}
|
|
265
289
|
|
|
266
290
|
/**
|
|
@@ -274,8 +298,10 @@ export function changeBot(bots, bot, { charter }) {
|
|
|
274
298
|
if (charter.trim() === '') throw new Error('--charter is empty, and a bot with no charter has no boundary to act inside. Give it one.');
|
|
275
299
|
|
|
276
300
|
const text = `${charter.trim()}\n`;
|
|
277
|
-
|
|
278
|
-
|
|
301
|
+
return withBotTurn(existingBot(bots, bot), () => {
|
|
302
|
+
editBot(bots, bot, `give ${bot} a new charter`, (doc) => doc.set('charter', text), (was) => ({ ...was, charter: text }));
|
|
303
|
+
return { bot, home: botDir(bots, bot), charter: text };
|
|
304
|
+
});
|
|
279
305
|
}
|
|
280
306
|
|
|
281
307
|
/**
|
|
@@ -306,13 +332,15 @@ export function allowedNow(bots, bot, rules, flag = '--rule') {
|
|
|
306
332
|
* false it only refuses what the edit would refuse, and writes nothing.
|
|
307
333
|
*/
|
|
308
334
|
export function allowRules(bots, bot, rules, { write = true } = {}) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
335
|
+
return withBotTurn(existingBot(bots, bot), () => {
|
|
336
|
+
const { home, was } = allowedNow(bots, bot, rules);
|
|
337
|
+
const added = [...new Set(rules)].filter((rule) => !was.includes(rule));
|
|
338
|
+
const allow = [...was, ...added];
|
|
339
|
+
if (added.length > 0) {
|
|
340
|
+
editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
|
|
341
|
+
}
|
|
342
|
+
return { bot, home, allow, added };
|
|
343
|
+
});
|
|
316
344
|
}
|
|
317
345
|
|
|
318
346
|
/**
|
|
@@ -323,21 +351,29 @@ export function allowRules(bots, bot, rules, { write = true } = {}) {
|
|
|
323
351
|
* false it only refuses what the edit would refuse, and writes nothing.
|
|
324
352
|
*/
|
|
325
353
|
export function disallowRules(bots, bot, rules, { write = true } = {}) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
354
|
+
return withBotTurn(existingBot(bots, bot), () => {
|
|
355
|
+
const { home, was } = allowedNow(bots, bot, rules);
|
|
356
|
+
const disallowed = [...new Set(rules)];
|
|
357
|
+
const allow = was.filter((rule) => !disallowed.includes(rule));
|
|
358
|
+
editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
|
|
359
|
+
return { bot, home, allow, disallowed };
|
|
360
|
+
});
|
|
331
361
|
}
|
|
332
362
|
|
|
333
363
|
/**
|
|
334
364
|
* Change the settings of the session `name` of the bot `bot`. Returns
|
|
335
|
-
* { bot, home, session } — the session as it now reads
|
|
365
|
+
* { bot, home, session, moved } — the session as it now reads, and, when it
|
|
366
|
+
* moved to another harness, `{ from, to, defaults }`.
|
|
336
367
|
*
|
|
337
368
|
* A setting given replaces the one there, and one given empty is taken out,
|
|
338
369
|
* which leaves it to the harness's own default. A start prompt is written one
|
|
339
370
|
* way or the other, so giving one takes the other away. The result is held to
|
|
340
371
|
* the rules `session add` holds a new session to.
|
|
372
|
+
*
|
|
373
|
+
* A move to another harness takes out the model, effort and context the
|
|
374
|
+
* session had, which belong to the harness it is leaving, unless the same call
|
|
375
|
+
* gives them (#565). The book is not touched: the session's conversation is
|
|
376
|
+
* set aside when it next starts on the new harness.
|
|
341
377
|
*/
|
|
342
378
|
export function changeSession(bots, bot, name, settings) {
|
|
343
379
|
const changes = { ...settings };
|
|
@@ -347,26 +383,69 @@ export function changeSession(bots, bot, name, settings) {
|
|
|
347
383
|
if (changes.prompt !== undefined && changes.prompt_file === undefined) changes.prompt_file = '';
|
|
348
384
|
if (changes.prompt_file !== undefined && changes.prompt === undefined) changes.prompt = '';
|
|
349
385
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
.
|
|
356
|
-
|
|
357
|
-
|
|
386
|
+
return withBotTurn(existingBot(bots, bot), () => {
|
|
387
|
+
const { home, known, index } = sessionAt(bots, bot, name);
|
|
388
|
+
const was = known.sessions[index];
|
|
389
|
+
const from = harnessOf(was, known.harness);
|
|
390
|
+
let moved;
|
|
391
|
+
if (changes.harness !== undefined && changes.harness !== from) {
|
|
392
|
+
const defaults = ['model', 'effort', 'context'].filter((key) => changes[key] === undefined && ![undefined, null, ''].includes(was[key]));
|
|
393
|
+
for (const key of defaults) changes[key] = '';
|
|
394
|
+
moved = { from, to: changes.harness, defaults };
|
|
395
|
+
}
|
|
396
|
+
// Every key the entry had stays, a pause mark or one of the user's own among
|
|
397
|
+
// them; only the settings given change.
|
|
398
|
+
const session = Object.fromEntries(Object.entries({ ...was, ...changes })
|
|
399
|
+
.filter(([, value]) => value !== '' && !(Array.isArray(value) && value.every((one) => one === ''))));
|
|
400
|
+
const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
|
|
401
|
+
if (trouble !== undefined) throw new Error(trouble);
|
|
402
|
+
// A session with no approval runs at its harness's default, auto on claude
|
|
403
|
+
// and codex, and dangerously-skip on Pi: a move to Pi would widen it with
|
|
404
|
+
// nobody asked (ADR 0051, #589).
|
|
405
|
+
if (moved?.to === 'pi' && !set(session.approval) && defaultApproval(from) !== 'dangerously-skip') {
|
|
406
|
+
throw new Error(`${bot}'s session ${name} has no approval in bot.yaml, so on ${from} it runs at ${defaultApproval(from)}. Every Pi session runs as dangerously-skip, and session change widens no approval. After the user's yes, Bot Father sets it with ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${bot} --session ${name} --approval dangerously-skip; then the session can move to Pi. Nothing was changed.`);
|
|
407
|
+
}
|
|
408
|
+
// Judged on the session as it is written, which is the whole session, so a
|
|
409
|
+
// change another command made in between cannot be combined with this one.
|
|
410
|
+
refuseCodexOutside(bot, session, was, known.harness, home, 'session change');
|
|
411
|
+
// The same for an extra argument that sets approval on the session's
|
|
412
|
+
// harness: the caller's check read bot.yaml before this turn (#589).
|
|
413
|
+
if (changes.harness !== undefined || changes.extra_args !== undefined) {
|
|
414
|
+
refuseApprovalArgs(harnessOf(session, known.harness), session.extra_args);
|
|
415
|
+
}
|
|
358
416
|
|
|
359
|
-
|
|
360
|
-
|
|
417
|
+
editSession(bots, bot, index, session, `change ${bot}'s session ${name}`);
|
|
418
|
+
return { bot, home, session, ...(moved === undefined ? {} : { moved }) };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Refuse a session that would run on Codex with a work dir outside its bot
|
|
424
|
+
* home (ADR 0045): Codex is launched with `--add-dir` for it, which lets the
|
|
425
|
+
* session write there, and `session add` and `session change` were in every
|
|
426
|
+
* bot's default set (#585), and a bot may still hold their rules (#615). Outside is by the spelling, which the launch line
|
|
427
|
+
* names, or by where links really lead. `was` is the session before the
|
|
428
|
+
* change: one already on Codex with the same work dir widens nothing.
|
|
429
|
+
*/
|
|
430
|
+
export function refuseCodexOutside(bot, session, was, botHarness, home, command) {
|
|
431
|
+
if (harnessOf(session, botHarness) !== 'codex') return;
|
|
432
|
+
const workDir = workDirOf(session, home);
|
|
433
|
+
if (workDir === undefined) return;
|
|
434
|
+
const spelledIn = workDir === home || workDir.startsWith(home + path.sep);
|
|
435
|
+
if (spelledIn && leadsOutside(home, workDir) === undefined) return;
|
|
436
|
+
if (was !== undefined && harnessOf(was, botHarness) === 'codex' && workDirOf(was, home) === workDir) return;
|
|
437
|
+
throw new Error(`${command} would give ${bot}/${session.name}, a Codex session, the work dir ${workDir}, which is outside ${bot}'s folder, ${home}. Codex would then be allowed to write that folder. A Codex session's work dir stays in its bot folder, such as work/${session.name}. Nothing was changed.`);
|
|
361
438
|
}
|
|
362
439
|
|
|
363
440
|
/**
|
|
364
441
|
* Record the widest approval the bot's temporary sessions may be made at,
|
|
365
|
-
* `level`, as `temp_approval` (ADR
|
|
442
|
+
* `level`, as `temp_approval` (ADR 0051). Returns { bot, home, level }.
|
|
366
443
|
*/
|
|
367
444
|
export function setTempApproval(bots, bot, level) {
|
|
368
|
-
|
|
369
|
-
|
|
445
|
+
return withBotTurn(existingBot(bots, bot), () => {
|
|
446
|
+
editBot(bots, bot, `let ${bot}'s temporary sessions be made at ${level}`, (doc) => doc.set('temp_approval', level), (was) => ({ ...was, temp_approval: level }));
|
|
447
|
+
return { bot, home: botDir(bots, bot), level };
|
|
448
|
+
});
|
|
370
449
|
}
|
|
371
450
|
|
|
372
451
|
/**
|
|
@@ -379,37 +458,39 @@ export function setTempApproval(bots, bot, level) {
|
|
|
379
458
|
*/
|
|
380
459
|
export function setRoleCaps(bots, bot, caps, { write = true } = {}) {
|
|
381
460
|
const home = existingBot(bots, bot);
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
const capped = (written, cap) => {
|
|
391
|
-
const { cap: was, ...rest } = Array.isArray(written) ? { options: written } : written;
|
|
392
|
-
return cap === undefined ? rest : { ...rest, cap };
|
|
393
|
-
};
|
|
394
|
-
editBot(bots, bot, `set the caps of ${caps.map(([role]) => role).join(', ')}`, (doc) => {
|
|
395
|
-
for (const [role, cap] of caps) {
|
|
396
|
-
const node = doc.getIn(['temp_roles', role], true);
|
|
397
|
-
if (isSeq(node)) {
|
|
398
|
-
doc.setIn(['temp_roles', role], doc.createNode(capped(node.toJSON(), cap)));
|
|
399
|
-
} else if (cap === undefined) {
|
|
400
|
-
doc.deleteIn(['temp_roles', role, 'cap']);
|
|
401
|
-
} else {
|
|
402
|
-
doc.setIn(['temp_roles', role, 'cap'], cap);
|
|
461
|
+
return withBotTurn(home, () => {
|
|
462
|
+
const roles = readBot(home, bot).temp_roles;
|
|
463
|
+
const file = path.join(home, BOT_YAML);
|
|
464
|
+
for (const [role] of caps) {
|
|
465
|
+
if (roles === null || typeof roles !== 'object' || Array.isArray(roles) || !Object.hasOwn(roles, role)) {
|
|
466
|
+
const has = roles !== null && typeof roles === 'object' && !Array.isArray(roles) ? Object.keys(roles) : [];
|
|
467
|
+
throw new Error(`${bot} has no temporary-session role called ${role} in ${file}${has.length === 0 ? ', and no temp_roles at all' : `. It has: ${has.join(', ')}`}. Nothing was changed.`);
|
|
403
468
|
}
|
|
404
469
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
470
|
+
const capped = (written, cap) => {
|
|
471
|
+
const { cap: was, ...rest } = Array.isArray(written) ? { options: written } : written;
|
|
472
|
+
return cap === undefined ? rest : { ...rest, cap };
|
|
473
|
+
};
|
|
474
|
+
editBot(bots, bot, `set the caps of ${caps.map(([role]) => role).join(', ')}`, (doc) => {
|
|
475
|
+
for (const [role, cap] of caps) {
|
|
476
|
+
const node = doc.getIn(['temp_roles', role], true);
|
|
477
|
+
if (isSeq(node)) {
|
|
478
|
+
doc.setIn(['temp_roles', role], doc.createNode(capped(node.toJSON(), cap)));
|
|
479
|
+
} else if (cap === undefined) {
|
|
480
|
+
doc.deleteIn(['temp_roles', role, 'cap']);
|
|
481
|
+
} else {
|
|
482
|
+
doc.setIn(['temp_roles', role, 'cap'], cap);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}, (was) => ({
|
|
486
|
+
...was,
|
|
487
|
+
temp_roles: Object.fromEntries(Object.entries(was.temp_roles).map(([role, written]) => {
|
|
488
|
+
const asked = caps.findLast(([one]) => one === role);
|
|
489
|
+
return [role, asked === undefined ? written : capped(written, asked[1])];
|
|
490
|
+
})),
|
|
491
|
+
}), write);
|
|
492
|
+
return { bot, home, caps };
|
|
493
|
+
});
|
|
413
494
|
}
|
|
414
495
|
|
|
415
496
|
/**
|
|
@@ -417,33 +498,37 @@ export function setRoleCaps(bots, bot, caps, { write = true } = {}) {
|
|
|
417
498
|
* the file changed. A paused bot or session is one `obk up` leaves closed.
|
|
418
499
|
*/
|
|
419
500
|
export function markPaused(bots, bot, name, paused) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
501
|
+
return withBotTurn(existingBot(bots, bot), () => {
|
|
502
|
+
if (name === undefined) {
|
|
503
|
+
const known = readBot(botDir(bots, bot), bot);
|
|
504
|
+
if ((known.paused === true) === paused) return false;
|
|
505
|
+
editBot(
|
|
506
|
+
bots, bot, `${paused ? 'pause' : 'unpause'} ${bot}`,
|
|
507
|
+
(doc) => (paused ? doc.set('paused', true) : doc.delete('paused')),
|
|
508
|
+
({ paused: none, ...was }) => (paused ? { ...was, paused: true } : was),
|
|
509
|
+
);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
430
512
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
513
|
+
const { known, index } = sessionAt(bots, bot, name);
|
|
514
|
+
const was = known.sessions[index];
|
|
515
|
+
if ((was.paused === true) === paused) return false;
|
|
516
|
+
const { paused: none, ...rest } = was;
|
|
517
|
+
editSession(bots, bot, index, paused ? { ...rest, paused: true } : rest, `${paused ? 'pause' : 'unpause'} ${bot}'s session ${name}`);
|
|
518
|
+
return true;
|
|
519
|
+
});
|
|
437
520
|
}
|
|
438
521
|
|
|
439
522
|
/** Take the session `name` off the bot `bot`'s list. */
|
|
440
523
|
export function dropSession(bots, bot, name) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
524
|
+
withBotTurn(existingBot(bots, bot), () => {
|
|
525
|
+
const { index } = sessionAt(bots, bot, name);
|
|
526
|
+
editBot(
|
|
527
|
+
bots, bot, `take the session ${name} off ${bot}`,
|
|
528
|
+
(doc) => doc.deleteIn(['sessions', index]),
|
|
529
|
+
(was) => ({ ...was, sessions: was.sessions.filter((one, at) => at !== index) }),
|
|
530
|
+
);
|
|
531
|
+
});
|
|
447
532
|
}
|
|
448
533
|
|
|
449
534
|
/** Where a session sits in its bot's list, refusing a bot or a session that is not there. */
|
|
@@ -484,7 +569,11 @@ function editSession(bots, bot, index, session, what) {
|
|
|
484
569
|
* `write` is not false.
|
|
485
570
|
*/
|
|
486
571
|
function editBot(bots, bot, what, edit, expected, write = true) {
|
|
487
|
-
const
|
|
572
|
+
const home = existingBot(bots, bot);
|
|
573
|
+
const file = path.join(home, BOT_YAML);
|
|
574
|
+
// Before the dry run too, so a command that writes elsewhere first is
|
|
575
|
+
// refused before it writes anything.
|
|
576
|
+
refuseOutside(home, file);
|
|
488
577
|
const source = readFileSync(file, 'utf8');
|
|
489
578
|
const doc = parseDocument(source);
|
|
490
579
|
edit(doc);
|
|
@@ -497,7 +586,45 @@ function editBot(bots, bot, what, edit, expected, write = true) {
|
|
|
497
586
|
throw refusal;
|
|
498
587
|
}
|
|
499
588
|
if (!changesExactly(source, text, expected)) throw refusal;
|
|
500
|
-
if (write && text !== source)
|
|
589
|
+
if (write && text !== source) writeWhole(file, text);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Write `text` over the bot.yaml `file` in one step, as the book is written:
|
|
594
|
+
* to a file of this process's own beside it, then moved over it. Two commands
|
|
595
|
+
* that write it at once leave one whole text or the other, never the one with
|
|
596
|
+
* the end of the other after it, which can read as a session neither command
|
|
597
|
+
* wrote (#585). A link to it is written through, to the file it names, and the
|
|
598
|
+
* file keeps its mode. A file the user made read-only is refused, as a write in
|
|
599
|
+
* place would be, though a move over it would go through. Whatever is already
|
|
600
|
+
* at the new file's name is taken away and the file is made new, never written
|
|
601
|
+
* through: a link a session left there would take the text out of the bot
|
|
602
|
+
* folder, and the move would make bot.yaml that link.
|
|
603
|
+
*/
|
|
604
|
+
export function writeWhole(file, text) {
|
|
605
|
+
const real = realpathSync(file);
|
|
606
|
+
accessSync(real, constants.W_OK);
|
|
607
|
+
const half = `${real}.${process.pid}.part`;
|
|
608
|
+
rmSync(half, { force: true });
|
|
609
|
+
writeFileSync(half, text, { flag: 'wx', mode: statSync(real).mode & 0o777 });
|
|
610
|
+
renameSync(half, real);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Run `change`, which reads the bot's bot.yaml and writes it, while this
|
|
615
|
+
* command holds the bot's turn (#595): the read and the write are then of the
|
|
616
|
+
* same file, and another command's change cannot fall between them and be
|
|
617
|
+
* written over. Every kit change of a bot.yaml that is there is made in here;
|
|
618
|
+
* `bot create` writes its first whole, with no read before. Not taken twice:
|
|
619
|
+
* the commands that take it call only what does not.
|
|
620
|
+
*/
|
|
621
|
+
export function withBotTurn(home, change) {
|
|
622
|
+
const turn = takeBotTurn(home);
|
|
623
|
+
try {
|
|
624
|
+
return change();
|
|
625
|
+
} finally {
|
|
626
|
+
turn.release();
|
|
627
|
+
}
|
|
501
628
|
}
|
|
502
629
|
|
|
503
630
|
/** A bot that is there, by its home, or the refusal a caller who named another is owed. */
|
|
@@ -537,9 +664,10 @@ export function changesExactly(source, text, expected) {
|
|
|
537
664
|
}
|
|
538
665
|
|
|
539
666
|
/** The bot.yaml of a bot nobody has edited yet. */
|
|
540
|
-
function botYaml(name, harness, charter) {
|
|
667
|
+
function botYaml(name, harness, charter, allow) {
|
|
541
668
|
const header = `# ${displayName(name)}. Ask Bot Father for changes rather than editing this file.\n\n`;
|
|
542
|
-
|
|
669
|
+
const bot = { name, harness, charter, rules: [], skills: [], sessions: [] };
|
|
670
|
+
return header + stringify(allow.length === 0 ? bot : { ...bot, allow }, YAML_OUT);
|
|
543
671
|
}
|
|
544
672
|
|
|
545
673
|
/**
|
package/src/clear.js
CHANGED
|
@@ -105,7 +105,7 @@ const AT_WORK = [/esc to interrupt/i, /^\s*\S\s+\S+…\s+\(\d/];
|
|
|
105
105
|
* (or null) and the one it holds now.
|
|
106
106
|
*/
|
|
107
107
|
export async function clearSession(bots, { bot, session }) {
|
|
108
|
-
const it = sessionToType(bots, bot, session);
|
|
108
|
+
const it = sessionToType(bots, bot, session, 'clear');
|
|
109
109
|
const was = readBook(it.home).sessions[session]?.session ?? null;
|
|
110
110
|
const { command, handle, typing } = await enter(it, 'clear');
|
|
111
111
|
|
|
@@ -141,7 +141,7 @@ export async function clearSession(bots, { bot, session }) {
|
|
|
141
141
|
* command began, and not yet when COMPACT_MS passed without one.
|
|
142
142
|
*/
|
|
143
143
|
export async function compactSession(bots, { bot, session }) {
|
|
144
|
-
const it = sessionToType(bots, bot, session);
|
|
144
|
+
const it = sessionToType(bots, bot, session, 'compact');
|
|
145
145
|
// What the record holds when /compact is typed is not this compact's: one
|
|
146
146
|
// that ends while the kit waits for the session to be idle is the one
|
|
147
147
|
// before it (review of PR #466). So only what is written after counts.
|
|
@@ -168,13 +168,17 @@ export async function compactSession(bots, { bot, session }) {
|
|
|
168
168
|
/**
|
|
169
169
|
* The session as something to type into, once everything that refuses before
|
|
170
170
|
* Orca is asked to type has been asked: `{ name, home, session, harness, tabId }`.
|
|
171
|
+
* A `verb`, clear or compact, is refused on Pi until a later slice of #547.
|
|
171
172
|
*/
|
|
172
|
-
export function sessionToType(bots, bot, session) {
|
|
173
|
+
export function sessionToType(bots, bot, session, verb) {
|
|
173
174
|
botsNamed(bots, bot);
|
|
174
175
|
const home = realpathSync(botDir(bots, bot));
|
|
175
176
|
const known = readBot(home, bot);
|
|
176
177
|
const [settings] = sessionsOf(known, session);
|
|
177
178
|
const name = `${bot} ${session}`;
|
|
179
|
+
if (verb !== undefined && harnessOf(settings, known.harness) === 'pi') {
|
|
180
|
+
throw new Error(`${name} runs on Pi, and obk session ${verb} does not work on a Pi session yet: a later slice of #547 adds it. Nothing was typed.`);
|
|
181
|
+
}
|
|
178
182
|
const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot}`;
|
|
179
183
|
if (known.paused === true) throw new Error(`${bot} is paused, so ${name} is not running and nothing was typed. Bring it back with ${unpause}.`);
|
|
180
184
|
if (settings.paused === true) throw new Error(`${name} is paused, so it is not running and nothing was typed. Bring it back with ${unpause} --session ${session}.`);
|