@assuredloop/orca-bot-kit 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +548 -3
- package/SETUP.md +275 -0
- package/package.json +32 -13
- package/rules/evidence.md +15 -0
- package/rules/finishing.md +23 -0
- package/rules/limits.md +18 -0
- package/rules/mail.md +17 -0
- package/rules/profiles.md +9 -0
- package/rules/review.md +14 -0
- package/rules/scope.md +22 -0
- package/rules/simple.md +14 -0
- package/rules/talk.md +13 -0
- package/rules/tests-first.md +17 -0
- package/rules/the-ask.md +19 -0
- package/skills/obk-arch/LICENSES.md +52 -0
- package/skills/obk-arch/NOTICE.md +242 -0
- package/skills/obk-arch/SKILL.md +598 -0
- package/skills/obk-bot-building/LICENSES.md +21 -0
- package/skills/obk-bot-building/NOTICE.md +113 -0
- package/skills/obk-bot-building/SKILL.md +312 -0
- package/skills/obk-debugging/LICENSES.md +67 -0
- package/skills/obk-debugging/NOTICE.md +115 -0
- package/skills/obk-debugging/SKILL.md +434 -0
- package/skills/obk-decision-memo/LICENSES.md +49 -0
- package/skills/obk-decision-memo/NOTICE.md +181 -0
- package/skills/obk-decision-memo/SKILL.md +241 -0
- package/skills/obk-finops/LICENSES.md +19 -0
- package/skills/obk-finops/NOTICE.md +77 -0
- package/skills/obk-finops/SKILL.md +148 -0
- package/skills/obk-fleet-review/LICENSES.md +52 -0
- package/skills/obk-fleet-review/NOTICE.md +104 -0
- package/skills/obk-fleet-review/SKILL.md +256 -0
- package/skills/obk-grilling/LICENSES.md +42 -0
- package/skills/obk-grilling/NOTICE.md +111 -0
- package/skills/obk-grilling/SKILL.md +221 -0
- package/skills/obk-grooming/LICENSES.md +52 -0
- package/skills/obk-grooming/NOTICE.md +111 -0
- package/skills/obk-grooming/SKILL.md +222 -0
- package/skills/obk-handoff/LICENSES.md +45 -0
- package/skills/obk-handoff/NOTICE.md +51 -0
- package/skills/obk-handoff/SKILL.md +119 -0
- package/skills/obk-personal-facilitation/LICENSES.md +53 -0
- package/skills/obk-personal-facilitation/NOTICE.md +62 -0
- package/skills/obk-personal-facilitation/SKILL.md +86 -0
- package/skills/obk-recall/LICENSES.md +42 -0
- package/skills/obk-recall/NOTICE.md +53 -0
- package/skills/obk-recall/SKILL.md +129 -0
- package/skills/obk-researching/LICENSES.md +67 -0
- package/skills/obk-researching/NOTICE.md +170 -0
- package/skills/obk-researching/SKILL.md +269 -0
- package/skills/obk-reviewing/LICENSES.md +66 -0
- package/skills/obk-reviewing/NOTICE.md +58 -0
- package/skills/obk-reviewing/SKILL.md +411 -0
- package/skills/obk-tdd/LICENSES.md +68 -0
- package/skills/obk-tdd/NOTICE.md +54 -0
- package/skills/obk-tdd/SKILL.md +547 -0
- package/skills/obk-tdd/mutation-tools.md +80 -0
- package/skills/obk-teach/LICENSES.md +42 -0
- package/skills/obk-teach/NOTICE.md +65 -0
- package/skills/obk-teach/SKILL.md +106 -0
- package/skills/obk-why/LICENSES.md +42 -0
- package/skills/obk-why/NOTICE.md +74 -0
- package/skills/obk-why/SKILL.md +180 -0
- package/skills/obk-writing/LICENSES.md +272 -0
- package/skills/obk-writing/NOTICE.md +161 -0
- package/skills/obk-writing/SKILL.md +254 -0
- package/src/book.js +268 -0
- package/src/bot.js +451 -0
- package/src/cli.js +1169 -0
- package/src/conversations.js +150 -0
- package/src/groom.js +159 -0
- package/src/health.js +309 -0
- package/src/hooks.js +257 -0
- package/src/init.js +314 -0
- package/src/launch.js +299 -0
- package/src/message.js +402 -0
- package/src/orca.js +362 -0
- package/src/pause.js +63 -0
- package/src/record.js +209 -0
- package/src/restart.js +193 -0
- package/src/retire.js +92 -0
- package/src/roster.js +71 -0
- package/src/rules.js +408 -0
- package/src/skills.js +630 -0
- package/src/sources.js +371 -0
- package/src/up.js +500 -0
- package/src/usage.js +401 -0
- package/bin/okb.js +0 -4
- package/src/index.js +0 -11
package/src/bot.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// A bot: a folder in the user's bots repo with a `bot.yaml` in it, and the two
|
|
2
|
+
// commands that write one — `obk bot create` and `obk session add`.
|
|
3
|
+
//
|
|
4
|
+
// Neither of them touches Orca. They write the user's files and stop; `obk up`
|
|
5
|
+
// is what makes a bot real in Orca, and it can be run whenever.
|
|
6
|
+
//
|
|
7
|
+
// The files are the user's. A command that cannot do what was asked refuses and
|
|
8
|
+
// writes nothing, rather than leave a bot half made or a bot.yaml half edited.
|
|
9
|
+
|
|
10
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
13
|
+
import { parse, parseDocument, stringify } from 'yaml';
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_APPROVAL, HARNESSES, harnessOf, sessionTrouble } from './launch.js';
|
|
16
|
+
|
|
17
|
+
/** Where the bots live inside the user's bots folder. */
|
|
18
|
+
export const botsDir = (bots) => path.join(bots, 'bots');
|
|
19
|
+
|
|
20
|
+
/** Where one bot lives. */
|
|
21
|
+
export const botDir = (bots, name) => path.join(botsDir(bots), name);
|
|
22
|
+
|
|
23
|
+
/** A bot's name: the folder it lives in, and safe to be one. */
|
|
24
|
+
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
|
+
|
|
26
|
+
/** The file that says what a bot is. A folder without one is not a bot. */
|
|
27
|
+
const BOT_YAML = 'bot.yaml';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How the kit writes YAML: a line as long as it needs to be, and flow
|
|
31
|
+
* collections left as tight as the user writes them (`[a]`, not `[ a ]`).
|
|
32
|
+
*/
|
|
33
|
+
export const YAML_OUT = { lineWidth: 0, flowCollectionPadding: false };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What every bot keeps out of the bots repo: its `work/`, the scratch space it
|
|
37
|
+
* clones into, which is not the repo's business (PRD 6.3).
|
|
38
|
+
*/
|
|
39
|
+
export const BOT_GITIGNORE = 'work/\n';
|
|
40
|
+
|
|
41
|
+
/** What every new bot is given until its owner writes its own. */
|
|
42
|
+
const PLACEHOLDER_CHARTER = (name) =>
|
|
43
|
+
`${name} has no charter yet. Write here what it owns, what good looks like,\n`
|
|
44
|
+
+ 'and what it must ask about before it acts.\n';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The bots in a bots folder, in name order. A folder with no `bot.yaml` in it
|
|
48
|
+
* is somebody's own folder, not a bot, and is left alone.
|
|
49
|
+
*/
|
|
50
|
+
export function botNames(bots) {
|
|
51
|
+
const dir = requireBotsFolder(bots);
|
|
52
|
+
// Sorted: the order bots come up in, and the order they are reported in, is
|
|
53
|
+
// the user's, not whatever order the file system hands back.
|
|
54
|
+
return readdirSync(dir).sort().filter((name) => existsSync(path.join(dir, name, BOT_YAML)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where `target`, inside the bot folder at `home` by name, really leads when a
|
|
59
|
+
* link the user made takes it out of that folder; undefined when it stays in.
|
|
60
|
+
*
|
|
61
|
+
* The kit writes only in the bot folder, never in user-level settings (PRD 6.3,
|
|
62
|
+
* ADR 0020), and a link would make the one the other without a word. So the
|
|
63
|
+
* answer is the file system's, not the spelling's: see `whereItLeads`.
|
|
64
|
+
*/
|
|
65
|
+
export function leadsOutside(home, target) {
|
|
66
|
+
let real;
|
|
67
|
+
try {
|
|
68
|
+
real = whereItLeads(target);
|
|
69
|
+
} catch {
|
|
70
|
+
// Something in the way that is not a link at all — a file where a folder
|
|
71
|
+
// should be, a folder the kit may not enter. Whatever writes there next
|
|
72
|
+
// meets it and says so in its own words.
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
// Links that never settle lead nowhere the kit can vouch for.
|
|
76
|
+
if (real === undefined) return `${target}, through links that never settle`;
|
|
77
|
+
const root = realpathSync(home);
|
|
78
|
+
return real === root || real.startsWith(root + path.sep) ? undefined : real;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** How many links deep a path is followed before it is taken to lead nowhere. */
|
|
82
|
+
const HOPS = 40;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where writing `target` would really land: the nearest part of it that exists,
|
|
86
|
+
* resolved by the file system, with the rest of the path after it. A link that
|
|
87
|
+
* leads to nothing yet is followed to what it names, and that is resolved the
|
|
88
|
+
* same way in turn, since it can pass through further links on the way.
|
|
89
|
+
* Undefined for links that never settle.
|
|
90
|
+
*/
|
|
91
|
+
function whereItLeads(target) {
|
|
92
|
+
let next = target;
|
|
93
|
+
for (let hop = 0; hop < HOPS; hop += 1) {
|
|
94
|
+
let at = next;
|
|
95
|
+
const rest = [];
|
|
96
|
+
while (lstatSync(at, { throwIfNoEntry: false }) === undefined && path.dirname(at) !== at) {
|
|
97
|
+
rest.unshift(path.basename(at));
|
|
98
|
+
at = path.dirname(at);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return path.join(realpathSync(at), ...rest);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!lstatSync(at).isSymbolicLink()) throw error;
|
|
104
|
+
next = path.join(path.resolve(path.dirname(at), readlinkSync(at)), ...rest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What Orca is asked to call a bot's project and its tabs: the bot's name with
|
|
112
|
+
* the hyphens taken out, so `bot-father` shows up in Orca as `Bot Father`.
|
|
113
|
+
*/
|
|
114
|
+
export const displayName = (name) =>
|
|
115
|
+
name.split('-').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A bot's own file: which harness its sessions run on unless they say
|
|
119
|
+
* otherwise, and the sessions it keeps.
|
|
120
|
+
*
|
|
121
|
+
* A session written as a bare name is one with everything left to default.
|
|
122
|
+
*/
|
|
123
|
+
export function readBot(home, name = path.basename(home)) {
|
|
124
|
+
const file = path.join(home, BOT_YAML);
|
|
125
|
+
const bot = asMapping(parseYaml(file), file);
|
|
126
|
+
|
|
127
|
+
const sessions = bot.sessions ?? [];
|
|
128
|
+
if (!Array.isArray(sessions)) {
|
|
129
|
+
throw new Error(`${file} has a sessions entry that is not a list. Fix it, then run the command again.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
harness: bot.harness,
|
|
135
|
+
// What the rules build reads. Neither is judged here: a charter is prose,
|
|
136
|
+
// and a list the build cannot follow is that build's to report (PRD 6.6).
|
|
137
|
+
charter: bot.charter,
|
|
138
|
+
rules: bot.rules,
|
|
139
|
+
// A paused bot is one `obk up` leaves closed; its sessions keep their book.
|
|
140
|
+
...(bot.paused === true ? { paused: true } : {}),
|
|
141
|
+
sessions: sessions.map((session) => {
|
|
142
|
+
const entry = typeof session === 'string' ? { name: session } : session;
|
|
143
|
+
if (entry === null || typeof entry !== 'object' || !entry.name) {
|
|
144
|
+
throw new Error(`${file} has a session with no name. Give it one, then run the command again.`);
|
|
145
|
+
}
|
|
146
|
+
return entry;
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create the bot `name` in the bots folder at `bots`.
|
|
153
|
+
* Returns { bot, home, created } — `created` lists what was written, in order.
|
|
154
|
+
*
|
|
155
|
+
* The bot's own files, and no more: its `AGENTS.md` is built from this
|
|
156
|
+
* `bot.yaml` and the rule units, which is the rules build's job and is reported
|
|
157
|
+
* as one (PRD 6.6).
|
|
158
|
+
*/
|
|
159
|
+
export function createBot(bots, { name, harness, charter }) {
|
|
160
|
+
if (!NAME.test(name)) {
|
|
161
|
+
throw new Error(`${name} cannot be a bot's name: a name is lower-case letters, digits and single hyphens, such as api-bot.`);
|
|
162
|
+
}
|
|
163
|
+
if (!HARNESSES.includes(harness)) {
|
|
164
|
+
throw new Error(`--harness is ${HARNESSES.join(' or ')}, and got: ${harness}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
requireBotsFolder(bots);
|
|
168
|
+
|
|
169
|
+
const home = botDir(bots, name);
|
|
170
|
+
if (existsSync(home)) {
|
|
171
|
+
throw new Error(`there is already something at ${home}, and bot create never writes over it. Pick another name, or move it aside.`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const text = charter?.trim() ? `${charter.trim()}\n` : PLACEHOLDER_CHARTER(name);
|
|
175
|
+
const files = [
|
|
176
|
+
[BOT_YAML, botYaml(name, harness, text)],
|
|
177
|
+
['.gitignore', BOT_GITIGNORE],
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
mkdirSync(home, { recursive: true });
|
|
181
|
+
for (const [entry, contents] of files) writeFileSync(path.join(home, entry), contents, { flag: 'wx' });
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
bot: name,
|
|
185
|
+
home,
|
|
186
|
+
created: files.map(([entry]) => path.join('bots', name, entry)),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Add the session `name` to the bot `bot`, with the settings it was given.
|
|
192
|
+
* Returns { bot, home, session } — the session as it was written.
|
|
193
|
+
*
|
|
194
|
+
* The file is edited through the YAML library, which keeps the user's comments
|
|
195
|
+
* and their own values, and writes the new session wherever their sessions list
|
|
196
|
+
* is. What the library reformats on the way — an indent, a quote, an inline
|
|
197
|
+
* list that becomes a block one — is not worth a mechanism of the kit's own.
|
|
198
|
+
*/
|
|
199
|
+
export function addSession(bots, bot, settings) {
|
|
200
|
+
requireBotsFolder(bots);
|
|
201
|
+
|
|
202
|
+
const home = botDir(bots, bot);
|
|
203
|
+
const file = path.join(home, BOT_YAML);
|
|
204
|
+
if (!existsSync(file)) {
|
|
205
|
+
throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const known = readBot(home, bot);
|
|
209
|
+
if (known.sessions.some((session) => session.name === settings.name)) {
|
|
210
|
+
throw new Error(`${bot} already has a session called ${settings.name}, and session add never writes over one.`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const session = ordered({ ...settings, approval: settings.approval ?? DEFAULT_APPROVAL });
|
|
214
|
+
const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
|
|
215
|
+
if (trouble !== undefined) throw new Error(trouble);
|
|
216
|
+
|
|
217
|
+
const source = readFileSync(file, 'utf8');
|
|
218
|
+
const doc = parseDocument(source);
|
|
219
|
+
const sessions = doc.get('sessions', true);
|
|
220
|
+
if (sessions?.items === undefined) {
|
|
221
|
+
// No list to add to: an empty `sessions:`, or no sessions key at all.
|
|
222
|
+
doc.set('sessions', [session]);
|
|
223
|
+
} else {
|
|
224
|
+
// A list written inline is written out as a block list from here on, which
|
|
225
|
+
// is the one shape a session with a prompt in it reads well in.
|
|
226
|
+
sessions.flow = false;
|
|
227
|
+
doc.addIn(['sessions'], session);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const text = doc.toString(YAML_OUT);
|
|
231
|
+
if (!changesExactly(source, text, (was) => ({ ...was, sessions: [...(was.sessions ?? []), session] }))) {
|
|
232
|
+
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.`);
|
|
233
|
+
}
|
|
234
|
+
writeFileSync(file, text);
|
|
235
|
+
return { bot, home, session };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Give the bot `bot` a new charter. Returns { bot, home, charter }.
|
|
240
|
+
*
|
|
241
|
+
* The charter and nothing else: its `AGENTS.md` is rebuilt from it by the rules
|
|
242
|
+
* build, which the caller runs, as `bot create` does.
|
|
243
|
+
*/
|
|
244
|
+
export function changeBot(bots, bot, { charter }) {
|
|
245
|
+
if (charter === undefined) throw new Error('bot change needs --charter <text>: what to change.');
|
|
246
|
+
if (charter.trim() === '') throw new Error('--charter is empty, and a bot with no charter has no boundary to act inside. Give it one.');
|
|
247
|
+
|
|
248
|
+
const text = `${charter.trim()}\n`;
|
|
249
|
+
editBot(bots, bot, `give ${bot} a new charter`, (doc) => doc.set('charter', text), (was) => ({ ...was, charter: text }));
|
|
250
|
+
return { bot, home: botDir(bots, bot), charter: text };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Change the settings of the session `name` of the bot `bot`. Returns
|
|
255
|
+
* { bot, home, session } — the session as it now reads.
|
|
256
|
+
*
|
|
257
|
+
* A setting given replaces the one there, and one given empty is taken out,
|
|
258
|
+
* which leaves it to the harness's own default. A start prompt is written one
|
|
259
|
+
* way or the other, so giving one takes the other away. The result is held to
|
|
260
|
+
* the rules `session add` holds a new session to.
|
|
261
|
+
*/
|
|
262
|
+
export function changeSession(bots, bot, name, settings) {
|
|
263
|
+
const changes = { ...settings };
|
|
264
|
+
if (Object.keys(changes).length === 0) {
|
|
265
|
+
throw new Error(`session change needs a setting to change: say what ${bot}'s session ${name} is to be set to.`);
|
|
266
|
+
}
|
|
267
|
+
if (changes.prompt !== undefined && changes.prompt_file === undefined) changes.prompt_file = '';
|
|
268
|
+
if (changes.prompt_file !== undefined && changes.prompt === undefined) changes.prompt = '';
|
|
269
|
+
|
|
270
|
+
const { home, known, index } = sessionAt(bots, bot, name);
|
|
271
|
+
const was = known.sessions[index];
|
|
272
|
+
// Every key the entry had stays, a pause mark or one of the user's own among
|
|
273
|
+
// them; only the settings given change.
|
|
274
|
+
const session = Object.fromEntries(Object.entries({ ...was, ...changes })
|
|
275
|
+
.filter(([, value]) => value !== '' && !(Array.isArray(value) && value.every((one) => one === ''))));
|
|
276
|
+
const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
|
|
277
|
+
if (trouble !== undefined) throw new Error(trouble);
|
|
278
|
+
|
|
279
|
+
editSession(bots, bot, index, session, `change ${bot}'s session ${name}`);
|
|
280
|
+
return { bot, home, session };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Mark the bot `bot`, or its session `name`, paused or not. Returns whether
|
|
285
|
+
* the file changed. A paused bot or session is one `obk up` leaves closed.
|
|
286
|
+
*/
|
|
287
|
+
export function markPaused(bots, bot, name, paused) {
|
|
288
|
+
if (name === undefined) {
|
|
289
|
+
const known = readBot(botDir(bots, bot), bot);
|
|
290
|
+
if ((known.paused === true) === paused) return false;
|
|
291
|
+
editBot(
|
|
292
|
+
bots, bot, `${paused ? 'pause' : 'unpause'} ${bot}`,
|
|
293
|
+
(doc) => (paused ? doc.set('paused', true) : doc.delete('paused')),
|
|
294
|
+
({ paused: none, ...was }) => (paused ? { ...was, paused: true } : was),
|
|
295
|
+
);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const { known, index } = sessionAt(bots, bot, name);
|
|
300
|
+
const was = known.sessions[index];
|
|
301
|
+
if ((was.paused === true) === paused) return false;
|
|
302
|
+
const { paused: none, ...rest } = was;
|
|
303
|
+
editSession(bots, bot, index, paused ? { ...rest, paused: true } : rest, `${paused ? 'pause' : 'unpause'} ${bot}'s session ${name}`);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Take the session `name` off the bot `bot`'s list. */
|
|
308
|
+
export function dropSession(bots, bot, name) {
|
|
309
|
+
const { index } = sessionAt(bots, bot, name);
|
|
310
|
+
editBot(
|
|
311
|
+
bots, bot, `take the session ${name} off ${bot}`,
|
|
312
|
+
(doc) => doc.deleteIn(['sessions', index]),
|
|
313
|
+
(was) => ({ ...was, sessions: was.sessions.filter((one, at) => at !== index) }),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Where a session sits in its bot's list, refusing a bot or a session that is not there. */
|
|
318
|
+
function sessionAt(bots, bot, name) {
|
|
319
|
+
const home = existingBot(bots, bot);
|
|
320
|
+
const known = readBot(home, bot);
|
|
321
|
+
const index = known.sessions.findIndex((session) => session.name === name);
|
|
322
|
+
if (index === -1) {
|
|
323
|
+
throw new Error(`${bot} has no session called ${name}. It has: ${known.sessions.map((session) => session.name).join(', ') || 'none'}.`);
|
|
324
|
+
}
|
|
325
|
+
return { home, known, index };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Write the session at `index` as `session`, key by key where it is already a
|
|
330
|
+
* mapping, so what the user wrote beside the keys it keeps stays with them.
|
|
331
|
+
*/
|
|
332
|
+
function editSession(bots, bot, index, session, what) {
|
|
333
|
+
editBot(bots, bot, what, (doc) => {
|
|
334
|
+
const node = doc.getIn(['sessions', index], true);
|
|
335
|
+
if (node?.items === undefined) {
|
|
336
|
+
doc.setIn(['sessions', index], session);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
for (const pair of [...node.items]) {
|
|
340
|
+
const key = String(pair.key?.value ?? pair.key);
|
|
341
|
+
if (!(key in session)) doc.deleteIn(['sessions', index, key]);
|
|
342
|
+
}
|
|
343
|
+
for (const [key, value] of Object.entries(session)) {
|
|
344
|
+
if (!isDeepStrictEqual(doc.getIn(['sessions', index, key]), value)) doc.setIn(['sessions', index, key], value);
|
|
345
|
+
}
|
|
346
|
+
}, (was) => ({ ...was, sessions: was.sessions.map((one, at) => (at === index ? session : one)) }));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Make one edit to a bot's `bot.yaml` through the YAML library, and write it
|
|
351
|
+
* only if exactly the change `expected` describes is what came out.
|
|
352
|
+
*/
|
|
353
|
+
function editBot(bots, bot, what, edit, expected) {
|
|
354
|
+
const file = path.join(existingBot(bots, bot), BOT_YAML);
|
|
355
|
+
const source = readFileSync(file, 'utf8');
|
|
356
|
+
const doc = parseDocument(source);
|
|
357
|
+
edit(doc);
|
|
358
|
+
const text = doc.toString(YAML_OUT);
|
|
359
|
+
if (!changesExactly(source, text, expected)) {
|
|
360
|
+
throw new Error(`${file} cannot be changed to ${what} without changing something else in it, so nothing was written. Make the change by hand.`);
|
|
361
|
+
}
|
|
362
|
+
if (text !== source) writeFileSync(file, text);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A bot that is there, by its home, or the refusal a caller who named another is owed. */
|
|
366
|
+
export function existingBot(bots, bot) {
|
|
367
|
+
requireBotsFolder(bots);
|
|
368
|
+
const home = botDir(bots, bot);
|
|
369
|
+
if (!existsSync(path.join(home, BOT_YAML))) {
|
|
370
|
+
throw new Error(`there is no bot called ${bot} in ${bots}: ${path.join(home, BOT_YAML)} is not there.`);
|
|
371
|
+
}
|
|
372
|
+
return home;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Whether `text` is `source` with exactly the change `expected` describes and
|
|
377
|
+
* nothing besides: the check in front of every write the kit makes to a file
|
|
378
|
+
* the user also writes in.
|
|
379
|
+
*
|
|
380
|
+
* `expected` is given what the file said and answers with what it should say.
|
|
381
|
+
* How the two texts are laid out is not compared — that is the library's — but
|
|
382
|
+
* every value is.
|
|
383
|
+
*/
|
|
384
|
+
export function changesExactly(source, text, expected) {
|
|
385
|
+
let was;
|
|
386
|
+
let now;
|
|
387
|
+
try {
|
|
388
|
+
was = parse(source);
|
|
389
|
+
now = parse(text);
|
|
390
|
+
} catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
// Nothing but comments, or nothing at all, parses as nothing at all, and is
|
|
394
|
+
// still a file an entry can be the first thing in.
|
|
395
|
+
if (was === null) was = {};
|
|
396
|
+
if (typeof was !== 'object' || Array.isArray(was)) return false;
|
|
397
|
+
|
|
398
|
+
return isDeepStrictEqual(now, expected(was));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The bot.yaml of a bot nobody has edited yet. */
|
|
402
|
+
function botYaml(name, harness, charter) {
|
|
403
|
+
const header = `# ${displayName(name)}. Ask Bot Father for changes rather than editing this file.\n\n`;
|
|
404
|
+
return header + stringify({ name, harness, charter, rules: [], skills: [], sessions: [] }, YAML_OUT);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Everything a session can set, in the order it is written down. One list, so
|
|
409
|
+
* that a setting cannot reach `bot.yaml` through one place and be dropped by
|
|
410
|
+
* another.
|
|
411
|
+
*/
|
|
412
|
+
export const SESSION_FIELDS = [
|
|
413
|
+
'name', 'harness', 'model', 'effort', 'context', 'approval', 'prompt', 'prompt_file', 'work_dir', 'extra_args',
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
/** A session's settings, in that order, without the ones left out. */
|
|
417
|
+
function ordered(session) {
|
|
418
|
+
const entry = {};
|
|
419
|
+
for (const key of SESSION_FIELDS) {
|
|
420
|
+
if (session[key] !== undefined) entry[key] = session[key];
|
|
421
|
+
}
|
|
422
|
+
return entry;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseYaml(file) {
|
|
426
|
+
try {
|
|
427
|
+
return parse(readFileSync(file, 'utf8'));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
throw new Error(`${file} is not readable as YAML: ${error.message}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function asMapping(bot, file) {
|
|
434
|
+
if (bot === null || typeof bot !== 'object' || Array.isArray(bot)) {
|
|
435
|
+
throw new Error(`${file} is not a bot: it should be a YAML mapping with a name, a harness and a list of sessions.`);
|
|
436
|
+
}
|
|
437
|
+
return bot;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Where the bots live, once it is certain they live there. A folder `init`
|
|
442
|
+
* never made holds none of the kit's files, and the way out of that is `init`
|
|
443
|
+
* and not the command the user reached for.
|
|
444
|
+
*/
|
|
445
|
+
export function requireBotsFolder(bots) {
|
|
446
|
+
const dir = botsDir(bots);
|
|
447
|
+
if (statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
|
448
|
+
throw new Error(`${bots} is not a bots folder: run obk init --bots <path> --harness claude|codex first.`);
|
|
449
|
+
}
|
|
450
|
+
return dir;
|
|
451
|
+
}
|