@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/book.js
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
// theirs. Beside each session's tab it holds the harness session id that
|
|
7
7
|
// session is running under, and every id it ran under before.
|
|
8
8
|
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
11
12
|
import { DatabaseSync } from 'node:sqlite';
|
|
12
13
|
import { isDeepStrictEqual } from 'node:util';
|
|
13
14
|
import { parse, stringify } from 'yaml';
|
|
14
15
|
|
|
16
|
+
import { kitFolders } from './launch.js';
|
|
17
|
+
|
|
15
18
|
const HEADER = `# What Orca calls this bot on this machine, and where each of its sessions
|
|
16
19
|
# lives. \`obk up\` writes this file; it is committed with the rest of the repo.
|
|
17
20
|
#
|
|
@@ -83,11 +86,18 @@ function withHistoryListed(entry) {
|
|
|
83
86
|
* is, never half of it, and a writer that dies leaves the old file in place:
|
|
84
87
|
* the new text goes to a file of this process's own and is moved over the book,
|
|
85
88
|
* which is one operation the file system either did or did not do.
|
|
89
|
+
*
|
|
90
|
+
* Whatever is already at that file's name is taken away and the file is made
|
|
91
|
+
* new, never written through: a session can leave a link there in its own bot
|
|
92
|
+
* folder, which would take the book's text out of it, and the move would make
|
|
93
|
+
* the book that link (#599). A link made there in between makes the write fail,
|
|
94
|
+
* and the old book stays whole.
|
|
86
95
|
*/
|
|
87
96
|
export function writeBook(home, book) {
|
|
88
97
|
const file = bookFile(home);
|
|
89
98
|
const half = `${file}.${process.pid}.part`;
|
|
90
|
-
|
|
99
|
+
rmSync(half, { force: true });
|
|
100
|
+
writeFileSync(half, `${HEADER}${stringify(book)}`, { flag: 'wx' });
|
|
91
101
|
renameSync(half, file);
|
|
92
102
|
}
|
|
93
103
|
|
|
@@ -151,7 +161,7 @@ function takeLock(home) {
|
|
|
151
161
|
try {
|
|
152
162
|
return lockOn(lockFile(home), WAIT_MS);
|
|
153
163
|
} catch (error) {
|
|
154
|
-
throw waitedTooLong(home, error);
|
|
164
|
+
throw error instanceof LockNotWritable ? error : waitedTooLong(home, error);
|
|
155
165
|
}
|
|
156
166
|
}
|
|
157
167
|
|
|
@@ -162,10 +172,47 @@ function takeLock(home) {
|
|
|
162
172
|
* `EXCLUSIVE`, alone, once every `SHARED` holder has let go.
|
|
163
173
|
*/
|
|
164
174
|
function lockOn(file, waitMs, how = 'IMMEDIATE') {
|
|
165
|
-
|
|
175
|
+
// SQLite opens a file it cannot write read-only, without a word, and a
|
|
176
|
+
// read-only connection's BEGIN passes while another process holds the lock:
|
|
177
|
+
// seen in Codex's sandbox (#534). So the file is opened for writing first,
|
|
178
|
+
// and a lock that cannot be held is an error, never a turn that is not one.
|
|
179
|
+
// Only while this process holds no turn on it: closing any descriptor of a
|
|
180
|
+
// file lets go of every lock the process holds on that file, and SQLite's
|
|
181
|
+
// own are among them. A file a turn is held on was written already.
|
|
182
|
+
//
|
|
183
|
+
// A session can write its bot's folder of locks, so a link, a FIFO or a
|
|
184
|
+
// folder may sit at the lock's name, and a lock taken through a link would
|
|
185
|
+
// make or write a file anywhere the kit may write (#605). So the kit opens
|
|
186
|
+
// the file itself without following a link, and SQLite opens it only if it
|
|
187
|
+
// is there and makes nothing. A link put at the name only while SQLite
|
|
188
|
+
// opens it is followed, and SQLite names the file it opened, so a file of
|
|
189
|
+
// any other name is closed before it is locked or written. And the turn
|
|
190
|
+
// ends with ROLLBACK, so SQLite never writes the file it locks.
|
|
191
|
+
//
|
|
192
|
+
// What is left, accepted by the architect on #605: SQLite reads the first
|
|
193
|
+
// 100 bytes of the file it opens before the kit can ask its name, so such a
|
|
194
|
+
// link can have a file outside read into SQLite's memory and dropped; and a
|
|
195
|
+
// FIFO put there that instant can hang the command. A lock taken by name
|
|
196
|
+
// cannot close that without a lock Node does not have.
|
|
197
|
+
if (!heldHere.has(file)) {
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
200
|
+
plainFile(file);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new LockNotWritable(file, error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
166
205
|
|
|
167
|
-
|
|
206
|
+
let db;
|
|
207
|
+
try {
|
|
208
|
+
db = new DatabaseSync(new URL(`${pathToFileURL(file).href}?mode=rw`));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new LockNotWritable(file, error);
|
|
211
|
+
}
|
|
168
212
|
try {
|
|
213
|
+
if (db.location() !== path.join(realpathSync(path.dirname(file)), path.basename(file))) {
|
|
214
|
+
throw new LockNotWritable(file, notPlain(`a link at it led SQLite to ${db.location()}`));
|
|
215
|
+
}
|
|
169
216
|
// A writer that arrives while another is working waits for it rather than
|
|
170
217
|
// failing at once, and gives up saying so rather than waiting for ever.
|
|
171
218
|
db.exec(`PRAGMA busy_timeout = ${waitMs}`);
|
|
@@ -181,17 +228,48 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
|
|
|
181
228
|
throw error;
|
|
182
229
|
}
|
|
183
230
|
|
|
231
|
+
heldHere.set(file, (heldHere.get(file) ?? 0) + 1);
|
|
184
232
|
return {
|
|
185
233
|
release() {
|
|
186
234
|
try {
|
|
187
|
-
db.exec('
|
|
235
|
+
db.exec('ROLLBACK');
|
|
188
236
|
} finally {
|
|
189
237
|
db.close();
|
|
238
|
+
const left = heldHere.get(file) - 1;
|
|
239
|
+
if (left > 0) heldHere.set(file, left);
|
|
240
|
+
else heldHere.delete(file);
|
|
190
241
|
}
|
|
191
242
|
},
|
|
192
243
|
};
|
|
193
244
|
}
|
|
194
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Make the lock file at `file` if it is not there, and open it for writing
|
|
248
|
+
* without following a link or waiting on a FIFO. Anything at the name but a
|
|
249
|
+
* plain file is refused.
|
|
250
|
+
*/
|
|
251
|
+
function plainFile(file) {
|
|
252
|
+
let fd;
|
|
253
|
+
try {
|
|
254
|
+
fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// A link, a folder and a FIFO with no reader, in that order.
|
|
257
|
+
if (['ELOOP', 'EISDIR', 'ENXIO'].includes(error.code)) throw notPlain(error.code);
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
if (!fstatSync(fd).isFile()) throw notPlain('not a plain file');
|
|
262
|
+
} finally {
|
|
263
|
+
closeSync(fd);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const NOT_PLAIN = 'ENOTPLAIN';
|
|
268
|
+
const notPlain = (message) => Object.assign(new Error(message), { code: NOT_PLAIN });
|
|
269
|
+
|
|
270
|
+
/** How many turns this process holds on each lock file, by its path. */
|
|
271
|
+
const heldHere = new Map();
|
|
272
|
+
|
|
195
273
|
/**
|
|
196
274
|
* Where a writer takes its turn: a folder of the kit's own beside the bots repo,
|
|
197
275
|
* carrying that folder's own name, the way start-prompt files sit beside it
|
|
@@ -213,14 +291,56 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
|
|
|
213
291
|
* the bots folder through a link and another by its real path, and two paths
|
|
214
292
|
* would be two locks for one book (#375).
|
|
215
293
|
*/
|
|
216
|
-
const lockFile = (home) => {
|
|
294
|
+
const lockFile = (home) => path.join(botLocks(home), `${encodeURIComponent(path.basename(realpathSync(home)))}.lock`);
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The bot's own folder of locks, `<bots>.locks/<bot>/`: the one a Codex
|
|
298
|
+
* session of the bot is given to write, beside its mail and its start prompts
|
|
299
|
+
* (#534). One folder per bot, so a bot's sessions can take each other's turns
|
|
300
|
+
* and no other bot's.
|
|
301
|
+
*/
|
|
302
|
+
const botLocks = (home) => {
|
|
217
303
|
const real = realpathSync(home);
|
|
218
|
-
return path.
|
|
219
|
-
`${path.dirname(path.dirname(real))}.locks`,
|
|
220
|
-
`${encodeURIComponent(path.basename(real))}.lock`,
|
|
221
|
-
);
|
|
304
|
+
return kitFolders(path.dirname(path.dirname(real)), path.basename(real)).locks;
|
|
222
305
|
};
|
|
223
306
|
|
|
307
|
+
/**
|
|
308
|
+
* A lock the kit cannot write. Inside Codex's sandbox that is a session
|
|
309
|
+
* started before the kit gave its bot a folder of locks, so it says what puts
|
|
310
|
+
* it right.
|
|
311
|
+
*/
|
|
312
|
+
class LockNotWritable extends Error {
|
|
313
|
+
constructor(file, why) {
|
|
314
|
+
super(why.code === NOT_PLAIN
|
|
315
|
+
? `something other than a plain file is at the name of the kit's lock ${file} (${why.message}), so the kit did not take the turn, and nothing was done. Remove ${file}; the kit makes it again.`
|
|
316
|
+
: `the kit cannot write its lock ${file} (${why.code ?? why.message}), so it did not take the turn, and nothing was done. A Codex session started before this version of the kit cannot write its bot's folder of locks: restart this session with the kit. Elsewhere, make ${path.dirname(file)} writable.`);
|
|
317
|
+
this.code = why.code;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The right to change the bot's `bot.yaml` (#595), held from the read a command
|
|
323
|
+
* makes its change on until that change is written. Two commands that change
|
|
324
|
+
* one bot at the same time then each change what the other left, and neither
|
|
325
|
+
* change is lost.
|
|
326
|
+
*
|
|
327
|
+
* The book's kind of lock, on a file of its own beside the book's: `temp make`
|
|
328
|
+
* writes bot.yaml while it holds the book's, and nothing takes the book's while
|
|
329
|
+
* it holds this one, so neither waits on the other.
|
|
330
|
+
*/
|
|
331
|
+
export function takeBotTurn(home) {
|
|
332
|
+
const file = botLockFile(home);
|
|
333
|
+
try {
|
|
334
|
+
return lockOn(file, WAIT_MS);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof LockNotWritable) throw error;
|
|
337
|
+
throw new Error(`waited ${WAIT_MS / 1000} seconds for something else to finish writing ${path.join(home, 'bot.yaml')} and it did not (${error.message}). bot.yaml was not changed. Try again; if nothing is running, remove ${file}, which is where writers take their turn.`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Beside the book's lock, with the bot's name: the lock files of its sessions carry one more part. */
|
|
342
|
+
const botLockFile = (home) => path.join(botLocks(home), `${encodeURIComponent(path.basename(realpathSync(home)))}.bot.lock`);
|
|
343
|
+
|
|
224
344
|
/**
|
|
225
345
|
* One session's turn at its mailbox (#321): held by `session mailbox` for its
|
|
226
346
|
* whole step, by `message check` in the session's own tab for its bind, read and
|
|
@@ -286,11 +406,11 @@ export function takeTypingTurn(home, session, waitMs) {
|
|
|
286
406
|
|
|
287
407
|
/**
|
|
288
408
|
* A session's turn for one whole line, sent with its return in one go, as the
|
|
289
|
-
* mail
|
|
409
|
+
* mail interrupt is: through the typing turn's gate, waited for `waitMs` at most,
|
|
290
410
|
* and then held beside any other line, so whole lines never wait on each
|
|
291
411
|
* other, only on typing one key at a time. Returns `{ release }`, or undefined
|
|
292
412
|
* when the gate did not come within `waitMs`. Every whole line the kit types
|
|
293
|
-
* takes it: the
|
|
413
|
+
* takes it: the mail interrupt, the list line, the skills reload and the grooming line (#482).
|
|
294
414
|
*/
|
|
295
415
|
export function takeLineTurn(home, session, waitMs) {
|
|
296
416
|
const gate = turnOn(home, session, 'typing', waitMs);
|
|
@@ -312,8 +432,9 @@ export const TYPING_WAIT_MS = 5000;
|
|
|
312
432
|
export const TYPING_HELD = `the kit is typing into it, and it was still at it after ${TYPING_WAIT_MS / 1000} s, so nothing was typed`;
|
|
313
433
|
|
|
314
434
|
/**
|
|
315
|
-
* How long a typing turn waits for the lines already on their way: a
|
|
316
|
-
* holds its
|
|
435
|
+
* How long a typing turn waits for the lines already on their way: a line
|
|
436
|
+
* re-issued to Orca holds its turn while Orca waits up to 5 s to see it start
|
|
437
|
+
* a turn.
|
|
317
438
|
*/
|
|
318
439
|
const LINES_WAIT_MS = 10_000;
|
|
319
440
|
|
|
@@ -344,10 +465,8 @@ const SQLITE_BUSY = 5;
|
|
|
344
465
|
* link by one command and by its real path by another, and two paths would be
|
|
345
466
|
* two turns.
|
|
346
467
|
*/
|
|
347
|
-
const mailboxLockFile = (home, session) =>
|
|
348
|
-
|
|
349
|
-
return path.join(`${path.dirname(path.dirname(real))}.locks`, `${path.basename(real)}.${session}.mailbox.lock`);
|
|
350
|
-
};
|
|
468
|
+
const mailboxLockFile = (home, session) =>
|
|
469
|
+
path.join(botLocks(home), `${path.basename(realpathSync(home))}.${session}.mailbox.lock`);
|
|
351
470
|
|
|
352
471
|
/**
|
|
353
472
|
* Something else has been writing the book for longer than this run is prepared
|
|
@@ -429,10 +548,11 @@ export function rememberSession(entry = {}, id, ended) {
|
|
|
429
548
|
|
|
430
549
|
/**
|
|
431
550
|
* The session entry once the id it held has turned out to have no conversation
|
|
432
|
-
* behind it: the id goes into the history with that
|
|
433
|
-
*
|
|
551
|
+
* behind it on the session's harness: the id goes into the history with that
|
|
552
|
+
* reason, and anything else worth knowing about it, such as the harness it ran
|
|
553
|
+
* on, and the entry holds none until the harness names the new one.
|
|
434
554
|
*/
|
|
435
|
-
export function forgetSession(entry, ended) {
|
|
555
|
+
export function forgetSession(entry, ended, more = {}) {
|
|
436
556
|
const { session, ...rest } = entry;
|
|
437
|
-
return { ...rest, history: [...(entry.history ?? []), { session, ended, at: new Date().toISOString() }] };
|
|
557
|
+
return { ...rest, history: [...(entry.history ?? []), { session, ended, ...more, at: new Date().toISOString() }] };
|
|
438
558
|
}
|