@assuredloop/orca-bot-kit 0.25.2 → 0.25.4
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 +17 -6
- package/SETUP.md +1 -1
- package/package.json +1 -1
- package/src/clear.js +42 -25
- package/src/cli.js +59 -9
- package/src/conversations.js +37 -0
- package/src/health.js +17 -2
- package/src/hooks.js +25 -1
- package/src/launch.js +2 -2
- package/src/message.js +219 -42
- package/src/name.js +4 -3
- package/src/orca.js +141 -16
- package/src/retire.js +31 -6
- package/src/unread.js +160 -0
- package/src/up.js +32 -11
package/src/retire.js
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
//
|
|
18
18
|
// Closing a tab here ends the conversation in it on purpose, so unlike a
|
|
19
19
|
// restart or a pause it does not wait for the book to know which one it was.
|
|
20
|
-
// The mailbox Runs stay: Orca has no way to remove one (ADR
|
|
20
|
+
// The mailbox Runs stay: Orca has no way to remove one (ADR 0035). What the
|
|
21
|
+
// kit knows of mail sent to a session and not read with `obk message check`
|
|
22
|
+
// is said, with who sent it, so it is not lost without a word (#509).
|
|
21
23
|
|
|
22
24
|
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
23
25
|
import path from 'node:path';
|
|
@@ -29,6 +31,7 @@ import { shellWord } from './launch.js';
|
|
|
29
31
|
import { fleetMember } from './pause.js';
|
|
30
32
|
import { closeTabs, commandLine, tabsToClose } from './restart.js';
|
|
31
33
|
import { unlinkSkills } from './skills.js';
|
|
34
|
+
import { takeUnread } from './unread.js';
|
|
32
35
|
import { promptPath, sessionsOf } from './up.js';
|
|
33
36
|
|
|
34
37
|
/** Where retired bots go: beside `bots/`, where nothing the kit runs looks. */
|
|
@@ -38,7 +41,8 @@ export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
|
38
41
|
* Retire the session `session` of the bot `bot`, and first the temporary
|
|
39
42
|
* sessions it made, and theirs (#464, ADR 0033). Returns `{ bot, session,
|
|
40
43
|
* closed, retiredWith }`, and `promptsLeft` when its prompt file could not be
|
|
41
|
-
* removed
|
|
44
|
+
* removed, and `unread: { count, from }` when the kit knows of mail sent to it
|
|
45
|
+
* that it did not read with `obk message check` (#509). `retiredWith` holds one `{ bot, session, maker, closed }` for each
|
|
42
46
|
* session that went with it, deepest first, with its own `promptsLeft`.
|
|
43
47
|
*/
|
|
44
48
|
export async function retireSession(bots, { bot, session }) {
|
|
@@ -73,8 +77,9 @@ export async function retireSession(bots, { bot, session }) {
|
|
|
73
77
|
throw error;
|
|
74
78
|
}
|
|
75
79
|
const left = removePrompts([promptPath(bots, bot, session)]);
|
|
80
|
+
const unread = takeUnread(home, session);
|
|
76
81
|
|
|
77
|
-
return { bot, session, closed, retiredWith, ...left };
|
|
82
|
+
return { bot, session, closed, retiredWith, ...left, ...(unread === undefined ? {} : { unread }) };
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
@@ -85,14 +90,29 @@ function goneBefore(session, retiredWith) {
|
|
|
85
90
|
const names = retiredWith.map((gone) => `${gone.bot}/${gone.session}, a temporary session of ${gone.maker}'s`);
|
|
86
91
|
const left = retiredWith.flatMap((gone) => gone.promptsLeft ?? [])
|
|
87
92
|
.map(({ file, reason }) => ` ${file} could not be removed (${reason}), and nothing reads it. Remove it with rm ${shellWord(file)}`);
|
|
88
|
-
|
|
93
|
+
// Their hint of unread mail went with them, so this is the only place it is
|
|
94
|
+
// said (#509 review).
|
|
95
|
+
const unread = retiredWith.flatMap((gone) => (gone.unread === undefined ? [] : [` ${unreadWords(gone.unread, `${gone.bot}/${gone.session}`)}`]));
|
|
96
|
+
return `Retired along with ${session} before that, and still retired: ${names.join('; ')}.${unread.join('')}${left.join('')}`;
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The mail a retired session did not read, as far as the kit knows (#509): the
|
|
101
|
+
* kit cannot see a read made with Orca's own check, so it says only this.
|
|
102
|
+
*/
|
|
103
|
+
export const unreadWords = (unread, who) =>
|
|
104
|
+
`${unread.count} ${unread.count === 1 ? 'message' : 'messages'} sent to ${who} ${unread.count === 1 ? 'was' : 'were'} not read with obk message check, from ${andList(unread.from)}.`;
|
|
105
|
+
|
|
106
|
+
/** `a`, `a and b`, `a, b and c`. */
|
|
107
|
+
const andList = (items) => (items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`);
|
|
108
|
+
|
|
91
109
|
/**
|
|
92
110
|
* Retire the bot `bot`. Returns `{ bot, closed, project, windowReloaded, moved }`:
|
|
93
111
|
* the tabs it closed, the Orca project it took away (if it had one), whether
|
|
94
112
|
* Orca's window was reloaded after that, and where the bot is now; and
|
|
95
|
-
* `promptsLeft` when a prompt file could not be removed
|
|
113
|
+
* `promptsLeft` when a prompt file could not be removed, and `unread: [{
|
|
114
|
+
* session, count, from }]` for its sessions with mail the kit knows was not
|
|
115
|
+
* read with `obk message check` (#509).
|
|
96
116
|
* When Orca does not confirm the project gone, it returns `{ bot, closed,
|
|
97
117
|
* project, trouble }` instead, and the bot is left where it was.
|
|
98
118
|
*/
|
|
@@ -142,11 +162,16 @@ export async function retireBot(bots, { bot }) {
|
|
|
142
162
|
|
|
143
163
|
const names = new Set([...known.sessions, ...booked].map((session) => session.name));
|
|
144
164
|
const left = removePrompts([...names].map((name) => promptPath(bots, bot, name)));
|
|
165
|
+
// Before the folder moves: the hint is filed under the bot home's real path.
|
|
166
|
+
const unread = [...names].flatMap((name) => {
|
|
167
|
+
const taken = takeUnread(home, name);
|
|
168
|
+
return taken === undefined ? [] : [{ session: name, ...taken }];
|
|
169
|
+
});
|
|
145
170
|
unlinkSkills(home);
|
|
146
171
|
mkdirSync(retiredDir(bots), { recursive: true });
|
|
147
172
|
renameSync(botDir(bots, bot), moved);
|
|
148
173
|
|
|
149
|
-
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved, ...left };
|
|
174
|
+
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved, ...left, ...(unread.length === 0 ? {} : { unread }) };
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
/**
|
package/src/unread.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The kit's hint of the fleet mail it sent and that is not read yet (#509, ADR 0035).
|
|
2
|
+
//
|
|
3
|
+
// The mailbox is the record of a message; this is not. It is what the kit
|
|
4
|
+
// itself knows: a send writes one file for the message, `obk message check`
|
|
5
|
+
// takes out what it read, and a retire says what is left. A Claude session's
|
|
6
|
+
// turn-end hook reads it first, so a session with no mail asks Orca nothing,
|
|
7
|
+
// and asks Orca before it tells, so a stale entry tells nothing.
|
|
8
|
+
//
|
|
9
|
+
// It sits in the system temp folder because a sender inside Codex's sandbox
|
|
10
|
+
// may write there and nowhere outside its own bot (#350). So it is private:
|
|
11
|
+
// every folder the kit makes here is 0700, every file 0600, and a file holds
|
|
12
|
+
// who sent the message and its subject, never its body. A temp clean-up loses
|
|
13
|
+
// it, and loses no mail: the message still waits in the mailbox.
|
|
14
|
+
//
|
|
15
|
+
// One file for each message, `<session folder>/<message id>.json`, so senders
|
|
16
|
+
// writing at once never write the same file; a `<message id>.told` beside it
|
|
17
|
+
// once the session's hook told it, so it is told once; and a `<message
|
|
18
|
+
// id>.read` once `obk message check` read it, so a send whose answer came back
|
|
19
|
+
// after that read does not leave an entry for read mail (#509 review).
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
/** Where the hint lives. */
|
|
26
|
+
export const unreadRoot = () => path.join(os.tmpdir(), 'obk-unread');
|
|
27
|
+
|
|
28
|
+
/** One session's folder: its bot home, by its real path, and its name. */
|
|
29
|
+
function sessionDir(home, session) {
|
|
30
|
+
let real = home;
|
|
31
|
+
try {
|
|
32
|
+
real = realpathSync(home);
|
|
33
|
+
} catch {
|
|
34
|
+
// A home that is gone is named as it was given.
|
|
35
|
+
}
|
|
36
|
+
return path.join(unreadRoot(), encodeURIComponent(real), encodeURIComponent(session));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How long a read mark is kept for a send still waiting on Orca's answer. */
|
|
40
|
+
const READ_MARK_MS = 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/** A message id as a file name: Orca's ids are plain, and anything else is made plain. */
|
|
43
|
+
const fileOf = (id) => encodeURIComponent(String(id));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Note one message sent to `session` of the bot at `home`: `{ id, from, subject,
|
|
47
|
+
* at }`. Quiet whatever happens: the message is sent either way.
|
|
48
|
+
*/
|
|
49
|
+
export function noteUnread(home, session, { id, from, subject, at }) {
|
|
50
|
+
if (id === undefined) return;
|
|
51
|
+
try {
|
|
52
|
+
const dir = sessionDir(home, session);
|
|
53
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
|
+
const file = path.join(dir, `${fileOf(id)}.json`);
|
|
55
|
+
// Written aside, then put in place whole: a reader never takes half a file.
|
|
56
|
+
writeFileSync(`${file}.${process.pid}.writing`, `${JSON.stringify({ id, from, subject, at })}\n`, { mode: 0o600 });
|
|
57
|
+
renameSync(`${file}.${process.pid}.writing`, file);
|
|
58
|
+
// Read already, while the send waited for Orca's answer: the entry is
|
|
59
|
+
// written first and taken out here, and a check writes its mark before it
|
|
60
|
+
// takes an entry out, so in either order no entry is left.
|
|
61
|
+
const read = path.join(dir, `${fileOf(id)}.read`);
|
|
62
|
+
if (existsSync(read)) {
|
|
63
|
+
rmSync(file, { force: true });
|
|
64
|
+
rmSync(read, { force: true });
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Nothing: the hint is lost, the mail is not.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What the hint holds for `session`: `[{ id, from, subject, at, told }]`,
|
|
73
|
+
* oldest first. An entry that cannot be read is left out. A folder that is not
|
|
74
|
+
* there is no mail; one that cannot be read throws, for the caller to stay
|
|
75
|
+
* quiet about.
|
|
76
|
+
*/
|
|
77
|
+
export function unreadOf(home, session) {
|
|
78
|
+
const dir = sessionDir(home, session);
|
|
79
|
+
let names;
|
|
80
|
+
try {
|
|
81
|
+
names = readdirSync(dir);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error.code === 'ENOENT') return [];
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const told = new Set(names.filter((name) => name.endsWith('.told')).map((name) => name.slice(0, -'.told'.length)));
|
|
87
|
+
return names
|
|
88
|
+
.filter((name) => name.endsWith('.json'))
|
|
89
|
+
.flatMap((name) => {
|
|
90
|
+
try {
|
|
91
|
+
const entry = JSON.parse(readFileSync(path.join(dir, name), 'utf8'));
|
|
92
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.from !== 'string' || typeof entry.subject !== 'string') return [];
|
|
93
|
+
return [{ id: entry.id, from: entry.from, subject: entry.subject, at: entry.at, told: told.has(name.slice(0, -'.json'.length)) }];
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Mark these messages told. Throws when a mark cannot be written: a hook that
|
|
103
|
+
* cannot remember it told must not tell, or it would tell again.
|
|
104
|
+
*/
|
|
105
|
+
export function markTold(home, session, ids) {
|
|
106
|
+
const dir = sessionDir(home, session);
|
|
107
|
+
for (const id of ids) writeFileSync(path.join(dir, `${fileOf(id)}.told`), '', { mode: 0o600 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Take these messages out: they were read. Each gets a read mark first, for a
|
|
112
|
+
* send still waiting on Orca's answer (see `noteUnread`); marks older than an
|
|
113
|
+
* hour are cleared here, since no send waits that long. Quiet whatever happens.
|
|
114
|
+
*/
|
|
115
|
+
export function forgetUnread(home, session, ids) {
|
|
116
|
+
const dir = sessionDir(home, session);
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
119
|
+
for (const name of readdirSync(dir).filter((one) => one.endsWith('.read'))) {
|
|
120
|
+
if (Date.now() - statSync(path.join(dir, name)).mtimeMs > READ_MARK_MS) rmSync(path.join(dir, name), { force: true });
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Nothing: a mark that stays is a few bytes in the temp folder.
|
|
124
|
+
}
|
|
125
|
+
for (const id of ids) {
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(path.join(dir, `${fileOf(id)}.read`), '', { mode: 0o600 });
|
|
128
|
+
} catch {
|
|
129
|
+
// Nothing: the entry is still taken out below.
|
|
130
|
+
}
|
|
131
|
+
for (const end of ['.json', '.told']) {
|
|
132
|
+
try {
|
|
133
|
+
rmSync(path.join(dir, `${fileOf(id)}${end}`), { force: true });
|
|
134
|
+
} catch {
|
|
135
|
+
// Nothing: a stale entry tells nothing, since the hook asks Orca first.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What the hint holds for a session that is going, as a retire says it:
|
|
143
|
+
* `{ count, from: [<bot>/<session>, …] }`, or undefined when nothing; and the
|
|
144
|
+
* session's folder is removed. Quiet whatever happens.
|
|
145
|
+
*/
|
|
146
|
+
export function takeUnread(home, session) {
|
|
147
|
+
let entries = [];
|
|
148
|
+
try {
|
|
149
|
+
entries = unreadOf(home, session);
|
|
150
|
+
} catch {
|
|
151
|
+
// Unreadable: nothing can be said of it.
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
rmSync(sessionDir(home, session), { recursive: true, force: true });
|
|
155
|
+
} catch {
|
|
156
|
+
// Nothing: it is in the temp folder, and holds no body.
|
|
157
|
+
}
|
|
158
|
+
if (entries.length === 0) return undefined;
|
|
159
|
+
return { count: entries.length, from: [...new Set(entries.map((entry) => entry.from))] };
|
|
160
|
+
}
|
package/src/up.js
CHANGED
|
@@ -14,7 +14,7 @@ import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from
|
|
|
14
14
|
import { installHook } from './hooks.js';
|
|
15
15
|
import { writePermissions } from './permissions.js';
|
|
16
16
|
import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
17
|
-
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
17
|
+
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
18
18
|
import { TAB_ENV } from './record.js';
|
|
19
19
|
import { buildAgents, rulesStamp } from './rules.js';
|
|
20
20
|
import { linkSkills } from './skills.js';
|
|
@@ -648,7 +648,8 @@ async function writeAddress(home, session, address) {
|
|
|
648
648
|
* a restart (tech notes, section 1). Orca offers no way to delete one, so this
|
|
649
649
|
* makes one for a session whose book names none, and otherwise binds the one
|
|
650
650
|
* the book names to this tab: after a restart, or a closed tab brought back,
|
|
651
|
-
* it would stay with the tab that is gone (review of PR #248, finding 1).
|
|
651
|
+
* it would stay with the tab that is gone (review of PR #248, finding 1). A
|
|
652
|
+
* Run the book names and this Orca does not have at all is replaced (#508).
|
|
652
653
|
*
|
|
653
654
|
* Only inside an Orca tab. Asked from anywhere else, Orca picks a terminal
|
|
654
655
|
* itself, and seen live that was not the asker's (tech notes, section 1), so
|
|
@@ -705,20 +706,34 @@ async function mailboxInTurn(home, botName, sessionName, who) {
|
|
|
705
706
|
if (error.code === TIMED_OUT) {
|
|
706
707
|
throw new Error(`${error.message}\n${who}'s mailbox ${held} may now be bound to this tab, or may still be bound where it was: Orca did not say which.`);
|
|
707
708
|
}
|
|
708
|
-
|
|
709
|
+
// A Run this Orca has never had, as when the book was written on another
|
|
710
|
+
// machine, can never be bound, so every start would fail the same way.
|
|
711
|
+
// It is replaced as a session with none would be given one (#508).
|
|
712
|
+
if (!runMissing(held, STEP_WAIT)) {
|
|
713
|
+
throw new Error(`${error.message}\n${who}'s mailbox ${held} is unchanged: it is still bound to ${boundTo(held)}.`);
|
|
714
|
+
}
|
|
715
|
+
return newMailbox(home, botName, sessionName, who, here, held);
|
|
709
716
|
}
|
|
710
717
|
return { bot: botName, session: sessionName, mailbox: held, change: 'bound' };
|
|
711
718
|
}
|
|
719
|
+
return newMailbox(home, botName, sessionName, who, here);
|
|
720
|
+
}
|
|
712
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Make a mailbox in this tab and write it into the book, for a session whose
|
|
724
|
+
* book names none, or names `stale`, a Run this Orca does not have.
|
|
725
|
+
*/
|
|
726
|
+
async function newMailbox(home, botName, sessionName, who, here, stale) {
|
|
713
727
|
// Made outside the book's lock, which is held for one read and one write
|
|
714
728
|
// (book.js).
|
|
715
729
|
let made;
|
|
716
730
|
try {
|
|
717
731
|
made = makeMailbox(who, STEP_WAIT);
|
|
718
732
|
} catch (error) {
|
|
733
|
+
const written = stale === undefined ? 'none is written down' : `the book still names ${stale}, which this Orca does not have`;
|
|
719
734
|
const left = error.code === TIMED_OUT
|
|
720
|
-
? `
|
|
721
|
-
: `No mailbox was made for ${who}, and
|
|
735
|
+
? `For ${who}, ${written}; a Run Orca made after the kit stopped waiting is left unused.`
|
|
736
|
+
: `No mailbox was made for ${who}, and ${written}.`;
|
|
722
737
|
throw new Error(`${error.message}\n${left} It gets one the next time the kit starts it.`);
|
|
723
738
|
}
|
|
724
739
|
|
|
@@ -734,10 +749,11 @@ async function mailboxInTurn(home, botName, sessionName, who) {
|
|
|
734
749
|
movedTo = now.tab ?? 'none';
|
|
735
750
|
return undefined;
|
|
736
751
|
}
|
|
737
|
-
// Under the lock, and only if the book still has none
|
|
738
|
-
// would each have made one, and a session
|
|
739
|
-
// half its mail never reaches. The
|
|
740
|
-
|
|
752
|
+
// Under the lock, and only if the book still has none, or still the
|
|
753
|
+
// stale one: two runs at once would each have made one, and a session
|
|
754
|
+
// with two mailboxes is a session half its mail never reaches. The
|
|
755
|
+
// loser's Run is left unused.
|
|
756
|
+
if (typeof now.mailbox !== 'string' || now.mailbox === stale) now.mailbox = made;
|
|
741
757
|
kept = now.mailbox;
|
|
742
758
|
current.sessions[sessionName] = now;
|
|
743
759
|
return undefined;
|
|
@@ -750,8 +766,13 @@ async function mailboxInTurn(home, botName, sessionName, who) {
|
|
|
750
766
|
}
|
|
751
767
|
// One terminal holds one Run, so making the loser took this tab off the one
|
|
752
768
|
// the book kept. It goes back.
|
|
753
|
-
if (kept !== made)
|
|
754
|
-
|
|
769
|
+
if (kept !== made) {
|
|
770
|
+
useMailbox(kept, undefined, STEP_WAIT);
|
|
771
|
+
return { bot: botName, session: sessionName, mailbox: kept, change: 'bound' };
|
|
772
|
+
}
|
|
773
|
+
return stale === undefined
|
|
774
|
+
? { bot: botName, session: sessionName, mailbox: made, change: 'made' }
|
|
775
|
+
: { bot: botName, session: sessionName, mailbox: made, change: 'replaced', replaced: stale };
|
|
755
776
|
}
|
|
756
777
|
|
|
757
778
|
/**
|