@assuredloop/orca-bot-kit 0.27.0 → 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 +48 -15
- package/SETUP.md +26 -13
- package/package.json +1 -1
- package/rules/mail.md +3 -3
- package/skills/obk-bot-building/SKILL.md +24 -15
- package/skills/obk-grooming/SKILL.md +4 -3
- package/src/book.js +91 -10
- package/src/bot.js +246 -118
- package/src/clear.js +7 -3
- package/src/cli.js +95 -34
- package/src/commands.js +20 -11
- package/src/conversations.js +73 -3
- package/src/groom.js +27 -9
- package/src/health.js +18 -7
- package/src/hooks.js +7 -1
- package/src/init.js +36 -29
- package/src/launch.js +202 -18
- package/src/message.js +42 -2
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +16 -7
- package/src/permissions.js +30 -16
- package/src/pi.js +55 -0
- package/src/rules.js +5 -2
- package/src/skills.js +50 -34
- package/src/sources.js +73 -4
- package/src/temp.js +65 -20
- package/src/up.js +68 -13
package/src/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { parseArgs } from 'node:util';
|
|
14
14
|
|
|
15
|
-
import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
|
|
15
|
+
import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, refuseCodexOutside, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
|
|
16
16
|
import { COMMANDS } from './commands.js';
|
|
17
17
|
import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
|
|
18
18
|
import { checkHealth, orcaSettingFindings } from './health.js';
|
|
@@ -21,7 +21,7 @@ import { nameSession } from './name.js';
|
|
|
21
21
|
import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
|
|
22
22
|
import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
23
23
|
import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
|
|
24
|
-
import { allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
|
|
24
|
+
import { allowIn, beyondDefaults, botDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
|
|
25
25
|
import { pauseSessions, unpauseSessions } from './pause.js';
|
|
26
26
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
27
27
|
import { restartSessions } from './restart.js';
|
|
@@ -43,18 +43,19 @@ Usage:
|
|
|
43
43
|
Create your bots folder: a git repo holding your
|
|
44
44
|
bots' configuration, with Bot Father in it, and
|
|
45
45
|
open Bot Father in Orca.
|
|
46
|
-
obk bot create --bots <path> --name <bot> --harness claude|codex
|
|
46
|
+
obk bot create --bots <path> --name <bot> --harness claude|codex|pi
|
|
47
47
|
[--charter <text>]
|
|
48
48
|
Write a new bot in your bots folder. Nothing is
|
|
49
49
|
opened in Orca until you run obk up.
|
|
50
50
|
obk session add --bots <path> --bot <bot> --name <session>
|
|
51
|
-
[--harness claude|codex] [--model <m>] [--effort <e>]
|
|
51
|
+
[--harness claude|codex|pi] [--model <m>] [--effort <e>]
|
|
52
52
|
[--context <c>]
|
|
53
53
|
[--prompt <text> | --prompt-file <path>] [--work-dir <path>]
|
|
54
54
|
[--extra-arg=<arg>]
|
|
55
55
|
Add a session to a bot. Anything left out is the
|
|
56
56
|
harness's own default; approval is auto, and only
|
|
57
|
-
obk permission approval changes it. A
|
|
57
|
+
obk permission approval changes it. A session on
|
|
58
|
+
Pi is dangerously-skip, the one level Pi has. A long
|
|
58
59
|
start prompt lives in a file in the bot home, and
|
|
59
60
|
--prompt-file names it.
|
|
60
61
|
A value of your own that starts with a dash is
|
|
@@ -87,15 +88,18 @@ Usage:
|
|
|
87
88
|
the bot's temporary sessions may be made at. A
|
|
88
89
|
running session takes it when it next starts.
|
|
89
90
|
obk session change --bots <path> --bot <bot> --session <session>
|
|
91
|
+
[--harness claude|codex|pi]
|
|
90
92
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
91
93
|
[--prompt <text> | --prompt-file <path>] [--work-dir <path>]
|
|
92
94
|
[--extra-arg=<arg>]
|
|
93
95
|
Change a session's settings. What you leave out
|
|
94
96
|
stays as it is; a setting given empty, --model=,
|
|
95
97
|
goes back to the harness's own default. A running
|
|
96
|
-
session takes the change when it next starts.
|
|
97
|
-
|
|
98
|
-
and
|
|
98
|
+
session takes the change when it next starts. On
|
|
99
|
+
another harness it starts a new conversation, the
|
|
100
|
+
book keeps the old one in its history, and model,
|
|
101
|
+
effort and context go back to the new harness's
|
|
102
|
+
defaults unless given too.
|
|
99
103
|
obk pause --bots <path> --bot <bot> [--session <name>]
|
|
100
104
|
Stop a bot, or one of its sessions, for now: close
|
|
101
105
|
its tabs and have obk up leave it closed. The book
|
|
@@ -219,6 +223,8 @@ Usage:
|
|
|
219
223
|
folder and named in the message. A pair the
|
|
220
224
|
harness's own messaging reaches is not carried:
|
|
221
225
|
the address to write to is answered instead.
|
|
226
|
+
In a session's own tab it sends as that session,
|
|
227
|
+
and --from may name only that one.
|
|
222
228
|
obk message check --bots <path> [--bot <bot>] [--session <name>] [--peek]
|
|
223
229
|
Read what is waiting for a session and mark it
|
|
224
230
|
read. --peek leaves it unread. Run in a session's
|
|
@@ -274,7 +280,8 @@ Usage:
|
|
|
274
280
|
only while its tab is up in Orca. --run-on codex
|
|
275
281
|
makes each run a temporary Codex session of the
|
|
276
282
|
grooming session's, launched with --model, --effort
|
|
277
|
-
and --extra-arg, which
|
|
283
|
+
and --extra-arg, which carries the grooming
|
|
284
|
+
session's own prompt, reports back by the kit's
|
|
278
285
|
mail and is retired.
|
|
279
286
|
obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
|
|
280
287
|
Say what your sessions have used: the conversations
|
|
@@ -435,7 +442,7 @@ async function run(argv) {
|
|
|
435
442
|
}
|
|
436
443
|
|
|
437
444
|
// A permission rule narrows a command to one bots folder by its first words,
|
|
438
|
-
// whatever comes after them (ADR
|
|
445
|
+
// whatever comes after them (ADR 0050), and the last of two `--bots` would
|
|
439
446
|
// win: so a second one is refused before anything runs.
|
|
440
447
|
if (tokens.filter((token) => token.kind === 'option' && token.name === 'bots').length > 1) {
|
|
441
448
|
throw new Error('--bots is given more than once. Give it once: one command works on one bots folder. Nothing was done.');
|
|
@@ -595,6 +602,8 @@ async function record(bots, bot) {
|
|
|
595
602
|
|
|
596
603
|
const commands = {
|
|
597
604
|
async init(bots, values) {
|
|
605
|
+
// Bot Father on Pi is not in the first slice of #547.
|
|
606
|
+
if (values.harness === 'pi') throw new Error('init does not take --harness pi yet: Bot Father runs on claude or codex, and its other bots may run on Pi. Nothing was written.');
|
|
598
607
|
// Asked before anything is written, so an Orca that is down leaves the disk
|
|
599
608
|
// exactly as it was and the caller can simply run the command again.
|
|
600
609
|
refuseWhenOrcaIsDown();
|
|
@@ -827,7 +836,9 @@ const commands = {
|
|
|
827
836
|
},
|
|
828
837
|
|
|
829
838
|
'bot create'(bots, values) {
|
|
830
|
-
|
|
839
|
+
// The kit's default rules (ADR 0050) are in the bot's first bot.yaml.
|
|
840
|
+
const allow = botDefaults(bots, { harness: values.harness, sessions: [] });
|
|
841
|
+
const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter, allow });
|
|
831
842
|
// The bot's AGENTS.md is the rules build's, here as everywhere else, so
|
|
832
843
|
// that a new bot's file and a rebuilt one are written by the same code.
|
|
833
844
|
const rules = [buildAgents(bots, made.home, readBot(made.home))];
|
|
@@ -838,9 +849,10 @@ const commands = {
|
|
|
838
849
|
// instructions, so `up` will not start it, and saying "give it a session"
|
|
839
850
|
// would send the caller past the thing that needs settling first.
|
|
840
851
|
const trouble = rules[0].trouble !== undefined;
|
|
841
|
-
// The
|
|
842
|
-
// with no instructions gets them at the rules build that gives
|
|
843
|
-
|
|
852
|
+
// The default rules go into the harness's files for a bot that is set up
|
|
853
|
+
// to run: one with no instructions gets them at the rules build that gives
|
|
854
|
+
// it some. They were added by this command, so its report names them.
|
|
855
|
+
const permissions = trouble ? [] : writePermissions(bots, made.home, readBot(made.home)).map((entry) => ({ ...entry, defaults: allow }));
|
|
844
856
|
const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
|
|
845
857
|
return {
|
|
846
858
|
answer,
|
|
@@ -862,7 +874,7 @@ const commands = {
|
|
|
862
874
|
throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
|
|
863
875
|
}
|
|
864
876
|
// Permission changes have commands of their own, which keep the user's yes
|
|
865
|
-
// (ADR
|
|
877
|
+
// (ADR 0051): the old spelling refuses, and changes nothing.
|
|
866
878
|
const moved = [['allow', 'permission allow'], ['disallow', 'permission disallow']].filter(([flag]) => values[flag] !== undefined);
|
|
867
879
|
if (moved.length > 0) {
|
|
868
880
|
throw new Error(`bot change no longer takes ${moved.map(([flag]) => `--${flag}`).join(' or ')}: permission rules change through ${moved.map(([, now]) => `${shellWord(ownCli())} ${now} --bots ${shellWord(bots)} --bot ${values.bot} --rule <rule>`).join(' and ')}, after the user's yes. Nothing was changed.`);
|
|
@@ -1069,7 +1081,7 @@ const commands = {
|
|
|
1069
1081
|
...(values.effort === undefined ? {} : { effort: values.effort }),
|
|
1070
1082
|
...(values['extra-arg'] === undefined ? {} : { extra_args: values['extra-arg'] }),
|
|
1071
1083
|
};
|
|
1072
|
-
// A run's launch may not widen its approval (ADR
|
|
1084
|
+
// A run's launch may not widen its approval (ADR 0051).
|
|
1073
1085
|
if (values['run-on'] === 'codex') refuseApprovalArgs('codex', values['extra-arg']);
|
|
1074
1086
|
const groom = grooming(bots, { at: values.at, ask, run });
|
|
1075
1087
|
return { answer: { bots, groom }, lines: groomLines(groom, bots) };
|
|
@@ -1132,7 +1144,8 @@ const commands = {
|
|
|
1132
1144
|
},
|
|
1133
1145
|
|
|
1134
1146
|
async 'message send'(bots, values) {
|
|
1135
|
-
|
|
1147
|
+
// With --from too: in one of the fleet's tabs, the tab says who sends (#570).
|
|
1148
|
+
const tab = callerTab(bots, true);
|
|
1136
1149
|
refuseWhenOrcaIsDown();
|
|
1137
1150
|
const answer = await sendMessage(bots, {
|
|
1138
1151
|
to: values.to,
|
|
@@ -1204,6 +1217,7 @@ const commands = {
|
|
|
1204
1217
|
|
|
1205
1218
|
'session add'(bots, values) {
|
|
1206
1219
|
refuseApproval(bots, values, 'session add');
|
|
1220
|
+
refuseOutsideWorkDir(bots, values);
|
|
1207
1221
|
const added = addSession(bots, values.bot, settingsOf(values));
|
|
1208
1222
|
const found = workDirFound(values, added.bot, added.home, added.session.name);
|
|
1209
1223
|
const answer = { bots, bot: added.bot, home: added.home, session: added.session, found };
|
|
@@ -1233,21 +1247,23 @@ const commands = {
|
|
|
1233
1247
|
},
|
|
1234
1248
|
|
|
1235
1249
|
'session change'(bots, values) {
|
|
1236
|
-
if (values.harness !== undefined) {
|
|
1237
|
-
throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
|
|
1238
|
-
}
|
|
1239
1250
|
refuseApproval(bots, values, 'session change');
|
|
1240
1251
|
const { name, ...settings } = settingsOf(values);
|
|
1241
1252
|
const changed = changeSession(bots, values.bot, values.session, settings);
|
|
1242
1253
|
const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${changed.bot} --session ${values.session}`;
|
|
1243
1254
|
const found = workDirFound(values, changed.bot, changed.home, values.session);
|
|
1255
|
+
const { moved } = changed;
|
|
1244
1256
|
return {
|
|
1245
|
-
answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, restart, found },
|
|
1257
|
+
answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, ...(moved === undefined ? {} : { moved }), restart, found },
|
|
1246
1258
|
lines: [
|
|
1247
1259
|
`changed session ${values.session} in ${path.join('bots', changed.bot, 'bot.yaml')}`,
|
|
1248
1260
|
...Object.entries(changed.session)
|
|
1249
1261
|
.filter(([key]) => key !== 'name')
|
|
1250
1262
|
.map(([key, value]) => ` ${key} ${oneLine(value)}`),
|
|
1263
|
+
...(moved === undefined ? [] : [
|
|
1264
|
+
`moved from ${moved.from} to ${moved.to}: when it next starts, it starts a new conversation on ${moved.to}, and the book keeps its old one in the session's history.`,
|
|
1265
|
+
...(moved.defaults.length === 0 ? [] : [` ${moved.defaults.join(', ')} went back to ${moved.to}'s own default${moved.defaults.length === 1 ? '' : 's'}.`]),
|
|
1266
|
+
]),
|
|
1251
1267
|
...foundLines(found),
|
|
1252
1268
|
`A running session takes this when it next starts: ${restart}`,
|
|
1253
1269
|
],
|
|
@@ -1290,7 +1306,7 @@ function toLines(answer, bots, where) {
|
|
|
1290
1306
|
// says what to do instead, before the bot needs it (#521).
|
|
1291
1307
|
return [
|
|
1292
1308
|
` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`,
|
|
1293
|
-
" If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule
|
|
1309
|
+
" If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule. Send the refusal to whoever adds your rules: your maker, your user or your fleet's manager.",
|
|
1294
1310
|
];
|
|
1295
1311
|
}
|
|
1296
1312
|
return [
|
|
@@ -1306,25 +1322,45 @@ function toLines(answer, bots, where) {
|
|
|
1306
1322
|
* came to when one was asked for (#555).
|
|
1307
1323
|
*/
|
|
1308
1324
|
function sentLines(answer, where) {
|
|
1309
|
-
const road =
|
|
1325
|
+
const road = noticeLines(answer);
|
|
1310
1326
|
if (answer.interrupted === true) {
|
|
1311
1327
|
return [
|
|
1312
|
-
|
|
1328
|
+
...(road.length === 1 ? [road[0].replace('once it is idle', 'now')] : road),
|
|
1313
1329
|
` it was busy, so the kit pressed Escape once in its tab to end its turn.${answer.to.harness === 'codex' ? ' Codex keeps a command it was running as a background terminal after an Escape.' : ''}`,
|
|
1314
1330
|
];
|
|
1315
1331
|
}
|
|
1316
1332
|
if (answer.interrupted === false) {
|
|
1317
|
-
return [road, answer.idle ? ` it is idle, so it was not interrupted.` : ` it was not interrupted: ${answer.interruptTrouble}.`];
|
|
1333
|
+
return [...road, answer.idle ? ` it is idle, so it was not interrupted.` : ` it was not interrupted: ${answer.interruptTrouble}.`];
|
|
1318
1334
|
}
|
|
1319
|
-
return
|
|
1335
|
+
return road;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Whether Orca's notice will tell the receiver of its letter. It will not while
|
|
1340
|
+
* Orca lists the receiver's tab as orphaned, as after a window restart: the
|
|
1341
|
+
* window has not loaded the tab (#579). A switch to the tab loads it, and is
|
|
1342
|
+
* named as a fact with its cost, not run and not urged: it moves the user's view.
|
|
1343
|
+
*/
|
|
1344
|
+
function noticeLines(answer) {
|
|
1345
|
+
if (answer.orphaned === true) {
|
|
1346
|
+
return [
|
|
1347
|
+
" it is in its Orca mailbox, and it waits there: Orca lists its tab as orphaned, because the Orca window has not loaded that tab, and Orca will not tell it of the letter until the window loads it.",
|
|
1348
|
+
` ${shellWord(orcaCli())} terminal switch --terminal ${shellWord(answer.handle)} loads the tab. It also moves the user's view to that tab, so what the user types can land in it.`,
|
|
1349
|
+
];
|
|
1350
|
+
}
|
|
1351
|
+
if (answer.loadTrouble !== undefined) {
|
|
1352
|
+
return [` it is in its Orca mailbox. The kit could not tell whether the Orca window has loaded its tab (${answer.loadTrouble}), and Orca tells it of the letter only once the window has loaded the tab.`];
|
|
1353
|
+
}
|
|
1354
|
+
return [" it is in its Orca mailbox, and Orca's own notice tells it to look once it is idle."];
|
|
1320
1355
|
}
|
|
1321
1356
|
|
|
1322
1357
|
/**
|
|
1323
1358
|
* What there is to say about the work dir this command was given: one finding
|
|
1324
1359
|
* when it leads out of the bot home, through `..` or a link or as a path
|
|
1325
1360
|
* elsewhere, and nothing otherwise (#222). A bot's work and its clones belong
|
|
1326
|
-
* under its own `work/`.
|
|
1327
|
-
*
|
|
1361
|
+
* under its own `work/`. For a Claude or Pi session it is said, not refused: a
|
|
1362
|
+
* work dir is an instruction there (PRD 6.4), and it is written as given. A
|
|
1363
|
+
* Codex session's is refused before this (refuseCodexOutside).
|
|
1328
1364
|
*/
|
|
1329
1365
|
function workDirFound(values, bot, home, session) {
|
|
1330
1366
|
const workDir = workDirOf({ work_dir: values['work-dir'] }, home);
|
|
@@ -1336,16 +1372,35 @@ function workDirFound(values, bot, home, session) {
|
|
|
1336
1372
|
}];
|
|
1337
1373
|
}
|
|
1338
1374
|
|
|
1375
|
+
/**
|
|
1376
|
+
* Refuse a new Codex session a work dir outside its bot home (ADR 0045). A
|
|
1377
|
+
* changed session is judged in changeSession, on what it writes (#585).
|
|
1378
|
+
*/
|
|
1379
|
+
function refuseOutsideWorkDir(bots, values) {
|
|
1380
|
+
if (values['work-dir'] === undefined) return;
|
|
1381
|
+
let bot;
|
|
1382
|
+
try {
|
|
1383
|
+
bot = readBot(botDir(bots, values.bot), values.bot);
|
|
1384
|
+
} catch {
|
|
1385
|
+
// A bot that is not there is the command's own to refuse.
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
const session = { name: values.name, harness: values.harness, work_dir: values['work-dir'] };
|
|
1389
|
+
refuseCodexOutside(values.bot, session, undefined, bot.harness, botDir(bots, values.bot), 'session add');
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1339
1392
|
/**
|
|
1340
1393
|
* Refuse what would change a session's approval through `session add` or
|
|
1341
|
-
* `session change` (ADR
|
|
1394
|
+
* `session change` (ADR 0051): `--approval`, and an extra argument that sets
|
|
1342
1395
|
* it on the session's harness. Only `permission approval` changes it.
|
|
1343
1396
|
*/
|
|
1344
1397
|
function refuseApproval(bots, values, command) {
|
|
1345
1398
|
if (values.approval !== undefined) {
|
|
1346
1399
|
throw new Error(`${command} no longer takes --approval: a session's approval changes through ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${values.bot} --session ${values.session ?? values.name} --approval <level>, after the user's yes. Nothing was changed.`);
|
|
1347
1400
|
}
|
|
1348
|
-
|
|
1401
|
+
// A move to another harness keeps the extra arguments the session has, and
|
|
1402
|
+
// one of them can set approval on the new harness (#565).
|
|
1403
|
+
if (values['extra-arg'] === undefined && values.harness === undefined) return;
|
|
1349
1404
|
let bot;
|
|
1350
1405
|
try {
|
|
1351
1406
|
bot = readBot(botDir(bots, values.bot), values.bot);
|
|
@@ -1354,7 +1409,7 @@ function refuseApproval(bots, values, command) {
|
|
|
1354
1409
|
return;
|
|
1355
1410
|
}
|
|
1356
1411
|
const session = bot.sessions.find((one) => one.name === (values.session ?? values.name)) ?? {};
|
|
1357
|
-
refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg']);
|
|
1412
|
+
refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg'] ?? (command === 'session change' ? session.extra_args : undefined));
|
|
1358
1413
|
}
|
|
1359
1414
|
|
|
1360
1415
|
/**
|
|
@@ -1437,6 +1492,12 @@ function refuseWhenOrcaIsDown() {
|
|
|
1437
1492
|
/** The one line that says which conversation this tab was given, and from where. */
|
|
1438
1493
|
const howLines = (tab) => {
|
|
1439
1494
|
if (tab.resumed === true) return [' it was told to resume the session the book holds, with its conversation.'];
|
|
1495
|
+
if (tab.harnessChanged !== undefined) {
|
|
1496
|
+
return [
|
|
1497
|
+
` it was told to start a new session: the book's conversation ${tab.harnessChanged} ran on ${tab.ranOn},`,
|
|
1498
|
+
" and the session has moved from it, so it went into the session's history.",
|
|
1499
|
+
];
|
|
1500
|
+
}
|
|
1440
1501
|
if (tab.noConversation === undefined) return [' it was told to start a new session: the book holds none for this one yet.'];
|
|
1441
1502
|
return [
|
|
1442
1503
|
` it was told to start a new session: the book's conversation ${tab.noConversation} had nothing on record`,
|
|
@@ -1658,7 +1719,7 @@ function rosterLines(roster, bots) {
|
|
|
1658
1719
|
}
|
|
1659
1720
|
|
|
1660
1721
|
lines.push(roster.length === 0
|
|
1661
|
-
? `No bots yet. Make one: ${shellWord(ownCli())} bot create --bots ${shellWord(bots)} --name <name> --harness claude|codex`
|
|
1722
|
+
? `No bots yet. Make one: ${shellWord(ownCli())} bot create --bots ${shellWord(bots)} --name <name> --harness claude|codex|pi`
|
|
1662
1723
|
: `${roster.length} bot${roster.length === 1 ? '' : 's'}. Your bots folder: ${bots}`);
|
|
1663
1724
|
return lines;
|
|
1664
1725
|
}
|
|
@@ -1725,9 +1786,9 @@ function charterRulesLines(bots, bot, rules) {
|
|
|
1725
1786
|
|
|
1726
1787
|
/**
|
|
1727
1788
|
* What became of each bot's permission rules: each of the kit's default rules
|
|
1728
|
-
* the run added to the bot, word for word (ADR
|
|
1789
|
+
* the run added to the bot, word for word (ADR 0050), each rule of a command no
|
|
1729
1790
|
* longer in the set that the bot still holds, with the command that takes it
|
|
1730
|
-
* back (ADR
|
|
1791
|
+
* back (ADR 0051), and the file the allowed ones were written into. Nothing for
|
|
1731
1792
|
* a bot with nothing added, held or written.
|
|
1732
1793
|
*/
|
|
1733
1794
|
function permissionsLines(permissions, bots) {
|
package/src/commands.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The kit's commands, each with the flags it cannot do without, which `obk`
|
|
2
2
|
// reads to parse a command line; and the two lists every command is on one of:
|
|
3
|
-
// those every bot is allowed by default (#527, ADR
|
|
4
|
-
// for the user's yes (ADR
|
|
3
|
+
// those every bot is allowed by default (#527, ADR 0050), and those kept back
|
|
4
|
+
// for the user's yes (ADR 0051). A new command is on neither until someone
|
|
5
5
|
// puts it on one, and a test holds every command to that choice.
|
|
6
6
|
|
|
7
7
|
/** The commands, and the flags each one cannot do without. */
|
|
@@ -50,14 +50,14 @@ export const COMMANDS = {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* The commands every bot is allowed by default, each narrowed to its bots
|
|
53
|
-
* folder (ADR
|
|
53
|
+
* folder (ADR 0050). Only a command named here is in the default set; one that
|
|
54
54
|
* runs whatever its arguments say never is.
|
|
55
55
|
*/
|
|
56
56
|
export const DEFAULT_COMMANDS = [
|
|
57
57
|
'up', 'unpause', 'health', 'groom', 'roster', 'usage',
|
|
58
|
-
'
|
|
58
|
+
'rules build',
|
|
59
59
|
'skills add', 'skills remove', 'skills build', 'skills fetch', 'skills update', 'source add',
|
|
60
|
-
'session
|
|
60
|
+
'session clear', 'session compact',
|
|
61
61
|
'message to', 'message send', 'message check',
|
|
62
62
|
'session record', 'session sent', 'session name', 'session mailbox',
|
|
63
63
|
'session trust-hooks', 'session answer',
|
|
@@ -66,17 +66,26 @@ export const DEFAULT_COMMANDS = [
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* The commands kept out of the default permission set, which keep the user's
|
|
69
|
-
* yes (ADR
|
|
70
|
-
* session, make the bots folder, or make a bot
|
|
71
|
-
* Father (#548).
|
|
69
|
+
* yes (ADR 0051): the ones that change permissions, close a long-lived
|
|
70
|
+
* session, make the bots folder, or make or change a bot or its sessions,
|
|
71
|
+
* which the owner leaves to Bot Father (#548, #615).
|
|
72
72
|
*/
|
|
73
73
|
export const KEPT_BACK = [
|
|
74
74
|
'init', 'retire', 'pause', 'restart', 'bot create',
|
|
75
|
+
'bot change', 'session add', 'session change',
|
|
75
76
|
'permission allow', 'permission disallow', 'permission approval',
|
|
76
77
|
];
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* The commands kit
|
|
80
|
-
* (#548). A bot may still hold their rules, which the kit names and
|
|
80
|
+
* The commands the kit gave every bot by default and that are kept back now
|
|
81
|
+
* (#548, #615). A bot may still hold their rules, which the kit names and
|
|
82
|
+
* leaves.
|
|
81
83
|
*/
|
|
82
|
-
export const NO_LONGER_DEFAULT = ['restart', 'bot create'];
|
|
84
|
+
export const NO_LONGER_DEFAULT = ['restart', 'bot create', 'bot change', 'session add', 'session change'];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The commands kit 0.26.0 gave every bot by default and that the kit no longer
|
|
88
|
+
* has (#555). A bot may still hold their rules, which the kit names and leaves
|
|
89
|
+
* (#564).
|
|
90
|
+
*/
|
|
91
|
+
export const REMOVED = ['session mail', 'session nudge'];
|
package/src/conversations.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// the kit never recorded must not be mistaken for a session that never had one,
|
|
7
7
|
// so when the book cannot say, the harness is asked instead.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Every harness keeps its own record of every conversation, per working
|
|
10
10
|
// directory, and every session starts at its bot home. Read only, never
|
|
11
11
|
// written: these are the harness's files (tech notes, sections 2 and 3).
|
|
12
12
|
|
|
13
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
@@ -22,6 +22,38 @@ const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(hom
|
|
|
22
22
|
/** The folder's name is the working directory with everything else turned into a dash. */
|
|
23
23
|
const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Pi's sessions: one folder per working directory, named from its real path
|
|
27
|
+
* with the leading `/` taken off and `/`, `\` and `:` turned into dashes,
|
|
28
|
+
* between `--`; one `<time>_<id>.jsonl` per conversation (Pi 1.1.0,
|
|
29
|
+
* `getDefaultSessionDirPath`, tech notes, section 6).
|
|
30
|
+
*/
|
|
31
|
+
const piDir = (home) => path.join(homedir(), '.pi', 'agent', 'sessions', `--${realHome(home).replace(/^[/\\]/, '').replaceAll(/[/\\:]/g, '-')}--`);
|
|
32
|
+
|
|
33
|
+
/** The folder as Pi sees it from inside: the real path, when there is one. */
|
|
34
|
+
function realHome(home) {
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(home);
|
|
37
|
+
} catch {
|
|
38
|
+
return home;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A Pi session file's name, `<time>_<id>.jsonl`: the time is Pi's ISO time
|
|
44
|
+
* with `:` and `.` turned into dashes, and the id may hold a `_` of its own.
|
|
45
|
+
*/
|
|
46
|
+
const PI_FILE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_(.+)\.jsonl$/;
|
|
47
|
+
|
|
48
|
+
/** The id a Pi session file's name carries, or undefined for a file that is not one. */
|
|
49
|
+
const piIdOf = (name) => PI_FILE.exec(name)?.[1];
|
|
50
|
+
|
|
51
|
+
/** Where Pi keeps the conversation `id` of this bot home, or undefined when it has none yet. */
|
|
52
|
+
const piSession = (home, id) => {
|
|
53
|
+
const name = files(piDir(home)).find((one) => piIdOf(one) === id);
|
|
54
|
+
return name === undefined ? undefined : path.join(piDir(home), name);
|
|
55
|
+
};
|
|
56
|
+
|
|
25
57
|
/** Codex's rollouts: a file per conversation, filed by the day it started. */
|
|
26
58
|
const codexDir = () => path.join(homedir(), '.codex', 'sessions');
|
|
27
59
|
|
|
@@ -52,6 +84,7 @@ export function conversationsIn(harness, home, since) {
|
|
|
52
84
|
*/
|
|
53
85
|
export function hasConversation(harness, home, id) {
|
|
54
86
|
if (harness === 'claude') return existsSync(claudeTranscript(home, id));
|
|
87
|
+
if (harness === 'pi') return piSession(home, id) !== undefined;
|
|
55
88
|
return codexRollout(id) !== undefined;
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -120,7 +153,9 @@ export function claudeSubagentTranscripts(file) {
|
|
|
120
153
|
*/
|
|
121
154
|
export function transcriptsIn(harness, home, since) {
|
|
122
155
|
const from = since === undefined ? 0 : Date.parse(since);
|
|
123
|
-
const found = harness === 'claude' ? claudeConversations(home)
|
|
156
|
+
const found = harness === 'claude' ? claudeConversations(home)
|
|
157
|
+
: harness === 'pi' ? piConversations(home)
|
|
158
|
+
: codexConversations(home, from);
|
|
124
159
|
return found
|
|
125
160
|
.filter((one) => Number.isNaN(from) || one.at >= from)
|
|
126
161
|
.sort((left, right) => left.at - right.at)
|
|
@@ -173,6 +208,14 @@ function userTexts(harness, line) {
|
|
|
173
208
|
if (typeof content === 'string') return [content];
|
|
174
209
|
return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
|
|
175
210
|
}
|
|
211
|
+
if (harness === 'pi') {
|
|
212
|
+
// A turn is a `message` line, and the user's are role `user` (Pi 1.1.0,
|
|
213
|
+
// docs/session-format.md).
|
|
214
|
+
const message = parsed?.type === 'message' ? parsed.message : undefined;
|
|
215
|
+
if (message?.role !== 'user') return [];
|
|
216
|
+
if (typeof message.content === 'string') return [message.content];
|
|
217
|
+
return Array.isArray(message.content) ? message.content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
|
|
218
|
+
}
|
|
176
219
|
const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
|
|
177
220
|
if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
|
|
178
221
|
return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
|
|
@@ -191,6 +234,33 @@ function claudeConversations(home) {
|
|
|
191
234
|
});
|
|
192
235
|
}
|
|
193
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Pi's conversations in this bot home. The first line of each is its header,
|
|
239
|
+
* `{ type: 'session', id, timestamp, cwd }`, and says which conversation it is
|
|
240
|
+
* and when it began; the file's name says the id too.
|
|
241
|
+
*/
|
|
242
|
+
function piConversations(home) {
|
|
243
|
+
return files(piDir(home))
|
|
244
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
245
|
+
.flatMap((name) => {
|
|
246
|
+
const file = path.join(piDir(home), name);
|
|
247
|
+
const header = piHeader(file);
|
|
248
|
+
const id = typeof header?.id === 'string' ? header.id : piIdOf(name);
|
|
249
|
+
const at = Date.parse(header?.timestamp ?? '') || startedAt(file);
|
|
250
|
+
return at === undefined || id === undefined || id === '' ? [] : [{ id, at, file }];
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A Pi session's first line, when it is the header. */
|
|
255
|
+
function piHeader(file) {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(firstLine(file) ?? '');
|
|
258
|
+
return parsed?.type === 'session' ? parsed : undefined;
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
194
264
|
/** When a transcript's first line says the conversation was, if it says at all. */
|
|
195
265
|
function said(file) {
|
|
196
266
|
try {
|
package/src/groom.js
CHANGED
|
@@ -44,7 +44,7 @@ import path from 'node:path';
|
|
|
44
44
|
import { botDir, readBot, requireBotsFolder } from './bot.js';
|
|
45
45
|
import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
46
46
|
import { claudeTranscript } from './conversations.js';
|
|
47
|
-
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
47
|
+
import { harnessOf, ownCli, promptFile, shellWord, startPrompt } from './launch.js';
|
|
48
48
|
import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
49
49
|
import { BOT_FATHER, promptPath } from './up.js';
|
|
50
50
|
import { lines } from './usage.js';
|
|
@@ -123,7 +123,7 @@ export function grooming(bots, { at, ask, run } = {}) {
|
|
|
123
123
|
? groomingJobs(ofThisProcess(home, entry), markerOf(bots), Date.now())
|
|
124
124
|
: [];
|
|
125
125
|
|
|
126
|
-
const line = ask === undefined ? undefined : lineFor(ask, { bots, session, jobs, at, run });
|
|
126
|
+
const line = ask === undefined ? undefined : lineFor(ask, { bots, home, found, session, jobs, at, run });
|
|
127
127
|
if (line !== undefined) typeInto(bots, home, session, tab, line);
|
|
128
128
|
|
|
129
129
|
// A move is turning it on at another time, and is typed as one.
|
|
@@ -135,7 +135,7 @@ export function grooming(bots, { at, ask, run } = {}) {
|
|
|
135
135
|
* nothing to ask. Everything that makes the ask impossible refuses here, before
|
|
136
136
|
* Orca is asked for anything.
|
|
137
137
|
*/
|
|
138
|
-
function lineFor(ask, { bots, session, jobs, at, run }) {
|
|
138
|
+
function lineFor(ask, { bots, home, found, session, jobs, at, run }) {
|
|
139
139
|
// Nothing to turn off is an answer, unless a session is there to be told: an
|
|
140
140
|
// `--on` typed a moment ago may still be waiting in its tab.
|
|
141
141
|
if (ask === 'off' && jobs.length === 0 && !(session?.harness === 'claude' && session.up)) return undefined;
|
|
@@ -165,7 +165,8 @@ function lineFor(ask, { bots, session, jobs, at, run }) {
|
|
|
165
165
|
// A move keeps what the runs are on, when every job there says the same.
|
|
166
166
|
const runs = [...new Set(jobs.map((job) => JSON.stringify(job.run ?? null)))];
|
|
167
167
|
const keep = ask === 'move' && runs.length === 1 ? JSON.parse(runs[0]) ?? undefined : undefined;
|
|
168
|
-
|
|
168
|
+
const onRun = run ?? keep;
|
|
169
|
+
return onLine(bots, cronOf(when), jobs, onRun === undefined ? undefined : { ...onRun, task: writeRunTask(bots, ownPrompt(home, found)) });
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/**
|
|
@@ -367,7 +368,7 @@ function runElsewhere(bots, onRun) {
|
|
|
367
368
|
...(onRun.model === undefined ? [] : [`--model ${shellWord(onRun.model)}`]),
|
|
368
369
|
...(onRun.effort === undefined ? [] : [`--effort ${shellWord(onRun.effort)}`]),
|
|
369
370
|
...(onRun.extra_args ?? []).map((arg) => `--extra-arg=${shellWord(arg)}`),
|
|
370
|
-
`--prompt-file ${shellWord(
|
|
371
|
+
`--prompt-file ${shellWord(onRun.task)}`,
|
|
371
372
|
].join(' ');
|
|
372
373
|
return `${markerOf(bots)}: the daily grooming for this fleet, run on ${onRun.harness} as a temporary session of yours `
|
|
373
374
|
+ `that does the work and reports back to you. First, if any of your temporary sessions whose names start with groom- `
|
|
@@ -390,13 +391,29 @@ function readReport(bots) {
|
|
|
390
391
|
+ `Then retire the run with ${retire}, its name in place of ${RUN_NAME}. Schedule nothing then: the job renewed itself when it fired.`;
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The grooming session's own prompt, as the user wrote it, or undefined when it
|
|
396
|
+
* has none: the standing duties a run on Codex carries too (#566). Without the
|
|
397
|
+
* work-dir note the session is launched with, since a run has a work dir of its
|
|
398
|
+
* own. Read when the line is typed, so a prompt changed since reaches the runs
|
|
399
|
+
* at the next `--on`.
|
|
400
|
+
*/
|
|
401
|
+
function ownPrompt(home, session) {
|
|
402
|
+
try {
|
|
403
|
+
return startPrompt(session, { home });
|
|
404
|
+
} catch (error) {
|
|
405
|
+
throw new Error(`${BOT_FATHER}'s ${GROOMING} session's prompt file ${promptFile(session, home)} cannot be read (${error.code ?? error.message}), and each grooming run on Codex carries that prompt. Nothing was typed. Put the file back, or change the session's prompt, then run this again.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
393
409
|
/**
|
|
394
410
|
* Write the task a Codex run is started with, and answer where: do the daily
|
|
395
|
-
* grooming with the obk-grooming skill,
|
|
396
|
-
*
|
|
397
|
-
*
|
|
411
|
+
* grooming with the obk-grooming skill, and the grooming session's own duties
|
|
412
|
+
* when its prompt has any, send the report to the grooming session by the
|
|
413
|
+
* kit's mail, and stop. It schedules nothing: the schedule is the grooming
|
|
414
|
+
* session's.
|
|
398
415
|
*/
|
|
399
|
-
function writeRunTask(bots) {
|
|
416
|
+
function writeRunTask(bots, own) {
|
|
400
417
|
const real = realHome(bots);
|
|
401
418
|
const kit = shellWord(ownCli());
|
|
402
419
|
const file = path.resolve(promptPath(real, BOT_FATHER, `${GROOMING}-run`));
|
|
@@ -405,6 +422,7 @@ function writeRunTask(bots) {
|
|
|
405
422
|
+ `since the last run's end up to it, counting with ${kit} usage --since <last end> --until <this end>, and write this end down for the next run. `
|
|
406
423
|
+ 'Work out what it cost with obk-finops, and keep each bot\'s profile notes. '
|
|
407
424
|
+ `The kit here is ${kit}: run it wherever a skill says obk. The bots folder is ${real}. `
|
|
425
|
+
+ (own === undefined ? '' : `The ${GROOMING} session's own prompt is this run's too. It follows, word for word as the user wrote it:\n\n${own}\n\n`)
|
|
408
426
|
+ `When the grooming is done, send one short report to ${BOT_FATHER}/${GROOMING} by the kit's mail: `
|
|
409
427
|
+ `ask for the road with ${kit} message to --bots ${shellWord(real)} --to ${BOT_FATHER}/${GROOMING}, then send it with the message send command its answer names. `
|
|
410
428
|
+ 'Then stop: schedule nothing, make no session, and say nothing more.\n';
|