@assuredloop/orca-bot-kit 0.30.0 → 0.32.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 +18 -86
- package/SETUP.md +3 -3
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +5 -8
- package/src/bot.js +0 -20
- package/src/clear.js +50 -14
- package/src/cli.js +22 -23
- package/src/commands.js +5 -8
- package/src/health.js +75 -30
- package/src/launch.js +23 -0
- package/src/orca.js +104 -18
- package/src/permissions.js +35 -518
- package/src/pi-record.js +96 -0
- package/src/pi.js +55 -17
- package/src/processes.js +8 -9
- package/src/rules.js +9 -1
- package/src/settings.js +109 -19
- package/src/skills.js +22 -9
- package/src/temp.js +69 -10
- package/src/up.js +2 -2
- package/src/run.js +0 -155
package/src/pi-record.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The kit's Pi extension: what a Claude Code or Codex session's SessionStart
|
|
2
|
+
// hook does, for a Pi session (#662).
|
|
3
|
+
//
|
|
4
|
+
// Pi has no hooks; it has extensions. Every Pi launch line names this file with
|
|
5
|
+
// `--extension`, and Pi loads it even with `--no-approve`. At each session_start,
|
|
6
|
+
// a `/new` in the tab included, it runs the kit's own `session record`, as the
|
|
7
|
+
// hooks do (src/hooks.js), and hands Pi the start prompt when the kit answers
|
|
8
|
+
// with one. Seen live on Pi 1.3.0 (tech notes, section 6): the extension runs in
|
|
9
|
+
// Pi's own process, so the command it starts is Pi's child, as a hook is the
|
|
10
|
+
// harness's, and it has Pi's environment, the tab's id included.
|
|
11
|
+
//
|
|
12
|
+
// It runs inside the user's session, so it stays out of the way: whatever goes
|
|
13
|
+
// wrong, it goes wrong quietly, and a book left stale is for the health check
|
|
14
|
+
// to find (ADR 0022). It handles no credentials, types into no tab and reaches
|
|
15
|
+
// no network.
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
/** The CLI beside this file: the kit whose launch line named it (#220). */
|
|
23
|
+
const CLI = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
24
|
+
|
|
25
|
+
/** How long the kit is given to answer, as the hooks' TIMEOUT gives a harness. */
|
|
26
|
+
const WAIT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
/** What Pi calls the start prompt the kit hands it. */
|
|
29
|
+
const CUSTOM_TYPE = 'obk-start-prompt';
|
|
30
|
+
|
|
31
|
+
export default function piRecord(pi) {
|
|
32
|
+
pi.on('session_start', async (event, ctx) => {
|
|
33
|
+
try {
|
|
34
|
+
const bot = botAbove(ctx.cwd);
|
|
35
|
+
if (bot === undefined) return;
|
|
36
|
+
|
|
37
|
+
const said = { session_id: ctx.sessionManager.getSessionId(), source: event.reason, hook_event_name: event.type };
|
|
38
|
+
const duty = (await record(bot, said))?.hookSpecificOutput?.additionalContext;
|
|
39
|
+
// Into the conversation for its next turn, as Claude Code's
|
|
40
|
+
// additionalContext is: no turn is started for it.
|
|
41
|
+
if (typeof duty === 'string' && duty !== '') pi.sendMessage({ customType: CUSTOM_TYPE, content: duty, display: true });
|
|
42
|
+
} catch {
|
|
43
|
+
// Nothing: see above.
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The bot whose home is `cwd` or the nearest folder above it, as
|
|
50
|
+
* `{ bots, bot }`, or undefined. A bot lives at `<bots>/bots/<bot>` with its
|
|
51
|
+
* `bot.yaml` (botDir in src/bot.js). A kit tab starts at the bot home; a folder
|
|
52
|
+
* below it holds too.
|
|
53
|
+
*/
|
|
54
|
+
function botAbove(cwd) {
|
|
55
|
+
if (typeof cwd !== 'string' || cwd === '') return undefined;
|
|
56
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (path.basename(parent) === 'bots' && existsSync(path.join(dir, 'bot.yaml'))) {
|
|
59
|
+
return { bots: path.dirname(parent), bot: path.basename(dir) };
|
|
60
|
+
}
|
|
61
|
+
if (parent === dir) return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The kit's answer to what Pi said, or undefined when it has none or none could be had. */
|
|
66
|
+
function record({ bots, bot }, said) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let child;
|
|
69
|
+
try {
|
|
70
|
+
child = spawn(CLI, ['session', 'record', '--bots', bots, '--bot', bot], {
|
|
71
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
72
|
+
timeout: WAIT_MS,
|
|
73
|
+
killSignal: 'SIGKILL',
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
resolve(undefined);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let out = '';
|
|
80
|
+
child.stdout.setEncoding('utf8');
|
|
81
|
+
child.stdout.on('data', (chunk) => {
|
|
82
|
+
out += chunk;
|
|
83
|
+
});
|
|
84
|
+
child.on('error', () => resolve(undefined));
|
|
85
|
+
child.on('close', (code) => {
|
|
86
|
+
if (code !== 0) return resolve(undefined);
|
|
87
|
+
try {
|
|
88
|
+
resolve(JSON.parse(out));
|
|
89
|
+
} catch {
|
|
90
|
+
resolve(undefined);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
child.stdin.on('error', () => {});
|
|
94
|
+
child.stdin.end(JSON.stringify(said));
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/pi.js
CHANGED
|
@@ -20,36 +20,74 @@ const THINKING = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
|
20
20
|
* the models they name; a session that names none takes Pi's own default.
|
|
21
21
|
*/
|
|
22
22
|
export function piTrouble(models) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const any = ask(['--list-models']);
|
|
30
|
-
if (any.error !== undefined || any.status !== 0 || /^No models available\b/m.test(any.stdout ?? '')) {
|
|
23
|
+
const pi = askPi();
|
|
24
|
+
const runs = pi.runs();
|
|
25
|
+
if (runs !== undefined) return `Pi does not run here (${runs}), so no Pi session was started. Install Pi, then run this again.`;
|
|
26
|
+
if (pi.models().any !== true) {
|
|
31
27
|
return 'Pi has no model it can use: it is signed in to no provider. Sign in to Pi yourself (start pi and use /login, or set a provider\'s API key), then run this again. The kit never handles Pi\'s credentials.';
|
|
32
28
|
}
|
|
33
|
-
|
|
34
29
|
for (const model of new Set(models)) {
|
|
35
|
-
const found =
|
|
36
|
-
if (found.
|
|
37
|
-
|
|
38
|
-
return `Pi cannot say whether it can use the model ${model}: pi --list-models ${withoutThinking(model)} failed (${why}), so no Pi session was started. Run that command yourself to see why, then run this again.`;
|
|
30
|
+
const found = pi.model(model);
|
|
31
|
+
if (found.failed !== undefined) {
|
|
32
|
+
return `Pi cannot say whether it can use the model ${model}: pi --list-models ${found.search} failed (${found.failed}), so no Pi session was started. Run that command yourself to see why, then run this again.`;
|
|
39
33
|
}
|
|
40
|
-
|
|
41
|
-
// that one too, when a provider dropped out since the question above.
|
|
42
|
-
if (/^No models (matching|available)\b/m.test(found.stdout ?? '')) {
|
|
34
|
+
if (!found.listed) {
|
|
43
35
|
return `Pi cannot use the model ${model}: pi --list-models lists none matching it. Sign in to its provider in Pi yourself, or give the session a model Pi lists.`;
|
|
44
36
|
}
|
|
45
37
|
}
|
|
46
38
|
return undefined;
|
|
47
39
|
}
|
|
48
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Pi's answers, each question asked once however often it is asked here: for
|
|
43
|
+
* a caller that asks about many sessions, as `obk health` does (#660).
|
|
44
|
+
*
|
|
45
|
+
* - `runs()`: undefined when `pi --version` answers, or why it does not.
|
|
46
|
+
* - `models()`: `{ any }`, whether `pi --list-models` lists any model, or
|
|
47
|
+
* `{ failed }` with why that question failed.
|
|
48
|
+
* - `model(m)`: `{ search, listed }`, whether `pi --list-models <search>` lists
|
|
49
|
+
* a model matching `m` without its thinking level, or `{ search, failed }`
|
|
50
|
+
* with why that question failed.
|
|
51
|
+
*/
|
|
52
|
+
export function askPi() {
|
|
53
|
+
const asked = new Map();
|
|
54
|
+
const once = (args) => {
|
|
55
|
+
const key = args.join('\0');
|
|
56
|
+
if (!asked.has(key)) asked.set(key, ask(args));
|
|
57
|
+
return asked.get(key);
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
runs() {
|
|
61
|
+
const version = once(['--version']);
|
|
62
|
+
if (version.error === undefined && version.status === 0) return undefined;
|
|
63
|
+
return version.error?.code === 'ENOENT' ? 'there is no pi on PATH' : (version.error?.message ?? ((version.stderr ?? '').trim() || `it exited with ${version.status}`));
|
|
64
|
+
},
|
|
65
|
+
models() {
|
|
66
|
+
const all = once(['--list-models']);
|
|
67
|
+
if (failed(all)) return { failed: why(all) };
|
|
68
|
+
return { any: !/^No models available\b/m.test(all.stdout ?? '') };
|
|
69
|
+
},
|
|
70
|
+
model(model) {
|
|
71
|
+
const search = withoutThinking(model);
|
|
72
|
+
const found = once(['--list-models', search]);
|
|
73
|
+
if (failed(found)) return { search, failed: why(found) };
|
|
74
|
+
// Pi says it has no model at all before it searches, so the answer may be
|
|
75
|
+
// that one too, when a provider dropped out since the question above.
|
|
76
|
+
return { search, listed: !/^No models (matching|available)\b/m.test(found.stdout ?? '') };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
/** The model without a trailing `:<thinking level>`, which `--list-models` does not match. */
|
|
50
|
-
function withoutThinking(model) {
|
|
82
|
+
export function withoutThinking(model) {
|
|
51
83
|
const at = model.lastIndexOf(':');
|
|
52
84
|
return at > 0 && THINKING.includes(model.slice(at + 1)) ? model.slice(0, at) : model;
|
|
53
85
|
}
|
|
54
86
|
|
|
87
|
+
const failed = (answer) => answer.error !== undefined || answer.status !== 0;
|
|
88
|
+
|
|
89
|
+
/** Why a question to Pi failed: the error, what Pi printed, or how it ended. */
|
|
90
|
+
const why = (answer) => answer.error?.message
|
|
91
|
+
?? ((answer.stderr ?? '').trim() || (answer.signal ? `it was killed by ${answer.signal}` : `it exited with ${answer.status}`));
|
|
92
|
+
|
|
55
93
|
const ask = (args) => spawnSync('pi', args, { encoding: 'utf8', timeout: ASK_MS });
|
package/src/processes.js
CHANGED
|
@@ -19,19 +19,18 @@
|
|
|
19
19
|
// signals: SIGTERM first, to the whole group where every process in it is the
|
|
20
20
|
// session's own, then up to 3 seconds for them to end, then SIGKILL to each
|
|
21
21
|
// that still runs. Inside Codex's sandbox `ps` does not start (#298), and then
|
|
22
|
-
// nothing is stopped and the answer says it cannot tell.
|
|
23
|
-
//
|
|
24
|
-
// fakes and sends no real signal.
|
|
22
|
+
// nothing is stopped and the answer says it cannot tell. The kit runs the three
|
|
23
|
+
// by their fixed paths, and no setting changes them (#652).
|
|
25
24
|
|
|
26
25
|
import { spawnSync } from 'node:child_process';
|
|
27
26
|
import { realpathSync } from 'node:fs';
|
|
28
27
|
import path from 'node:path';
|
|
29
28
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
30
29
|
|
|
31
|
-
import {
|
|
30
|
+
import { LSOF, PS } from './orca.js';
|
|
32
31
|
|
|
33
|
-
/** The `kill`
|
|
34
|
-
const
|
|
32
|
+
/** The `kill` the kit sends signals with, by its fixed path. */
|
|
33
|
+
const KILL = '/bin/kill';
|
|
35
34
|
|
|
36
35
|
/** How long one `ps`, `lsof` or `kill` may take. */
|
|
37
36
|
const READ_MS = 10_000;
|
|
@@ -273,7 +272,7 @@ function readProcesses() {
|
|
|
273
272
|
* READ_MS.
|
|
274
273
|
*/
|
|
275
274
|
function workingFolders(ms = READ_MS) {
|
|
276
|
-
const asked = spawnSync(
|
|
275
|
+
const asked = spawnSync(LSOF, ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
|
|
277
276
|
if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time' };
|
|
278
277
|
if (asked.error || asked.status !== 0) return { unreadable: `lsof could not list the processes' working folders${because(asked)}` };
|
|
279
278
|
const cwds = new Map();
|
|
@@ -292,7 +291,7 @@ function workingFolders(ms = READ_MS) {
|
|
|
292
291
|
* own command line (review of PR #543). Or `{ unreadable: <why> }`.
|
|
293
292
|
*/
|
|
294
293
|
function processTable(ms = READ_MS) {
|
|
295
|
-
const asked = spawnSync(
|
|
294
|
+
const asked = spawnSync(PS, ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
|
|
296
295
|
if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'ps did not answer in time' };
|
|
297
296
|
if (asked.error || asked.status !== 0) return { unreadable: `ps could not read the process table${because(asked)}` };
|
|
298
297
|
const table = new Map();
|
|
@@ -383,7 +382,7 @@ const inside = (cwd, dir) => cwd === dir || cwd.startsWith(`${dir}${path.sep}`);
|
|
|
383
382
|
*/
|
|
384
383
|
function signal(name, target, ms = READ_MS) {
|
|
385
384
|
if (!Number.isInteger(target) || Math.abs(target) <= 1) return { refused: `the kit does not signal ${target}` };
|
|
386
|
-
const sent = spawnSync(
|
|
385
|
+
const sent = spawnSync(KILL, ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
|
|
387
386
|
// Cut off at its time: whether the signal went is not known.
|
|
388
387
|
if (sent.error?.code === 'ETIMEDOUT') return { late: true };
|
|
389
388
|
if (!sent.error && sent.status === 0) return {};
|
package/src/rules.js
CHANGED
|
@@ -22,7 +22,7 @@ import { parse } from 'yaml';
|
|
|
22
22
|
|
|
23
23
|
import { botDir, botNames, displayName, readBot, refuseOutside } from './bot.js';
|
|
24
24
|
import { dropRemovedHooks } from './hooks.js';
|
|
25
|
-
import { ownCli, shellWord } from './launch.js';
|
|
25
|
+
import { ownCli, runsOnPi, shellWord } from './launch.js';
|
|
26
26
|
|
|
27
27
|
/** The kit's own units, inside the installed package. */
|
|
28
28
|
const KIT_RULES = fileURLToPath(new URL('../rules', import.meta.url));
|
|
@@ -73,8 +73,16 @@ const checksum = (text) => createHash('sha256').update(text).digest('hex').slice
|
|
|
73
73
|
* What the bot carries is `failed` — a name that resolves to nothing, a unit
|
|
74
74
|
* that cannot be read — and the user fixes it in a list. The file itself is
|
|
75
75
|
* `conflict`, which only the person who edited it can settle.
|
|
76
|
+
*
|
|
77
|
+
* A bot that runs on Pi has `pi: true` too, for the report to say what the
|
|
78
|
+
* kit does and does not write for Pi (#659).
|
|
76
79
|
*/
|
|
77
80
|
export function buildAgents(bots, home, bot) {
|
|
81
|
+
const built = agentsInto(bots, home, bot);
|
|
82
|
+
return runsOnPi(bot) ? { ...built, pi: true } : built;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function agentsInto(bots, home, bot) {
|
|
78
86
|
const file = path.join(home, AGENTS);
|
|
79
87
|
|
|
80
88
|
let built;
|
package/src/settings.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A session can run on settings other than the ones bot.yaml asks for: it
|
|
4
4
|
// started before a change, a default of the user's own leaked in, or a flag was
|
|
5
|
-
// dropped. Each harness writes down what it used (tech notes, sections 2 and
|
|
5
|
+
// dropped. Each harness writes down what it used (tech notes, sections 2, 3 and 6),
|
|
6
6
|
// so the two can be put side by side. Read only: these are the harness's files.
|
|
7
7
|
//
|
|
8
8
|
// Four answers, and only one of them is good news. A record that cannot be read,
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// setting the session never asked for is not a disagreement either, whatever the
|
|
11
11
|
// harness chose: it is `not-asked-for`.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { defaultApproval } from './launch.js';
|
|
14
|
+
import { withoutThinking } from './pi.js';
|
|
14
15
|
import { lines } from './usage.js';
|
|
15
16
|
|
|
16
17
|
export const SETTINGS = ['model', 'effort', 'context', 'approval'];
|
|
@@ -22,47 +23,56 @@ export const SETTINGS = ['model', 'effort', 'context', 'approval'];
|
|
|
22
23
|
* every setting asked for is then unknown.
|
|
23
24
|
*/
|
|
24
25
|
export function settingsInUse(harness, session, file, since) {
|
|
25
|
-
const
|
|
26
|
-
|
|
26
|
+
const entries = file === undefined ? [] : lines(file);
|
|
27
|
+
const start = startOf(entries, since);
|
|
28
|
+
// Pi's reader also looks before the start, for the selection a reply follows.
|
|
29
|
+
const used = harness === 'pi' ? fromPi(entries, start)
|
|
30
|
+
: (harness === 'claude' ? fromClaude : fromCodex)(start === -1 ? [] : entries.slice(start));
|
|
31
|
+
return Object.fromEntries(SETTINGS.map((name) => [name, judged(harness, name, asked(harness, session, name), used[name])]));
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
|
-
*
|
|
31
|
-
* the conversation it had, so the file still holds what the
|
|
32
|
-
* wrote, and that says what that process ran with, not this
|
|
35
|
+
* Where the record since the kit last started the session begins, or -1. A
|
|
36
|
+
* start resumes the conversation it had, so the file still holds what the
|
|
37
|
+
* process before it wrote, and that says what that process ran with, not this
|
|
38
|
+
* one. Everything
|
|
33
39
|
* from the first line written at or after the start counts, a line with no time
|
|
34
40
|
* of its own included. With no start to go by, no line can be shown to be this
|
|
35
41
|
* process's, so none counts.
|
|
36
42
|
*/
|
|
37
|
-
function
|
|
43
|
+
function startOf(entries, since) {
|
|
38
44
|
const from = Date.parse(since ?? '');
|
|
39
|
-
if (Number.isNaN(from)) return
|
|
40
|
-
|
|
41
|
-
return first === -1 ? [] : entries.slice(first);
|
|
45
|
+
if (Number.isNaN(from)) return -1;
|
|
46
|
+
return entries.findIndex((entry) => Date.parse(entry.timestamp ?? '') >= from);
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/** What the session asks for, as bot.yaml has it, or undefined when it asks for nothing. */
|
|
45
|
-
function asked(session, name) {
|
|
50
|
+
function asked(harness, session, name) {
|
|
46
51
|
const value = session[name];
|
|
47
52
|
const set = value !== undefined && value !== null && String(value).trim() !== '';
|
|
48
53
|
// The kit puts an approval level on every launch line, so a session always
|
|
49
|
-
// asks for one:
|
|
50
|
-
|
|
54
|
+
// asks for one: its harness's default, when it names none (ADR 0015). Pi
|
|
55
|
+
// has one level (#547).
|
|
56
|
+
if (name === 'approval') return set ? String(value).trim() : defaultApproval(harness);
|
|
51
57
|
return set ? String(value).trim() : undefined;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
function judged(harness, name, configured, observed) {
|
|
55
|
-
|
|
61
|
+
// A Pi reply can leave two values open (see fromPi): its own, which shows,
|
|
62
|
+
// and the selection before it. Agreeing with one of the two is not a match.
|
|
63
|
+
const choices = observed?.choices ?? [observed];
|
|
64
|
+
const shown = choices[0] === undefined ? {} : { observed: said(name, choices[0]) };
|
|
56
65
|
if (configured === undefined) return { state: 'not-asked-for', ...shown };
|
|
57
|
-
const
|
|
58
|
-
if (verdict ===
|
|
59
|
-
return { state:
|
|
66
|
+
const verdicts = choices.map((choice) => same(harness, name, configured, choice));
|
|
67
|
+
if (verdicts.every((verdict) => verdict === false)) return { state: 'mismatch', configured, ...shown };
|
|
68
|
+
if (verdicts.length === 1 && verdicts[0] === true) return { state: 'match', configured, ...shown };
|
|
69
|
+
return { state: 'unknown', configured, ...shown };
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** Whether the record says what was asked for; undefined when it cannot say. */
|
|
63
73
|
function same(harness, name, configured, observed) {
|
|
64
74
|
if (observed === undefined) return undefined;
|
|
65
|
-
if (name === 'model') return sameModel(configured, observed);
|
|
75
|
+
if (name === 'model') return harness === 'pi' ? samePiModel(configured, observed) : sameModel(configured, observed);
|
|
66
76
|
// Codex reports the window it was given at 95%, rounded down (tech notes,
|
|
67
77
|
// section 3): 200000 comes back as 190000.
|
|
68
78
|
if (name === 'context') return Number(observed) === Math.floor(Number(configured) * 0.95);
|
|
@@ -82,6 +92,27 @@ function sameModel(configured, observed) {
|
|
|
82
92
|
return model.includes('-') ? observed === model : observed.split('-').includes(model);
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A Pi model as bot.yaml names it, `openai/gpt-6-luna` or `gpt-6-luna`, maybe
|
|
97
|
+
* with a trailing `:<thinking level>`, against the `<provider>/<model>` Pi
|
|
98
|
+
* wrote down: it matches either whole, in any case, as Pi does. Pi also takes
|
|
99
|
+
* a pattern that is part of a model's id, and which model it then chose
|
|
100
|
+
* depends on what else it lists, so such a pattern cannot be judged (Pi 1.3.0,
|
|
101
|
+
* resolveModelReference). Pi matches a display name too, which the record
|
|
102
|
+
* does not hold: such a pattern shows as a mismatch.
|
|
103
|
+
*/
|
|
104
|
+
function samePiModel(configured, observed) {
|
|
105
|
+
const model = withoutThinking(configured).toLowerCase();
|
|
106
|
+
const ran = observed.toLowerCase();
|
|
107
|
+
const provider = ran.slice(0, ran.indexOf('/'));
|
|
108
|
+
const id = ran.slice(ran.indexOf('/') + 1);
|
|
109
|
+
if (model === ran || model === id) return true;
|
|
110
|
+
// A provider before a slash is only a hint: the whole pattern may be part of an id.
|
|
111
|
+
const slash = model.indexOf('/');
|
|
112
|
+
const part = id.includes(model) || (slash !== -1 && model.slice(0, slash) === provider && id.includes(model.slice(slash + 1)));
|
|
113
|
+
return part ? undefined : false;
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
/** The permission mode Claude Code records for each level (tech notes, section 2). */
|
|
86
117
|
const CLAUDE_MODES = { auto: 'auto', ask: 'default', 'dangerously-skip': 'bypassPermissions' };
|
|
87
118
|
|
|
@@ -134,6 +165,65 @@ function fromClaude(entries) {
|
|
|
134
165
|
return used;
|
|
135
166
|
}
|
|
136
167
|
|
|
168
|
+
/** The api of a virtual model's catalog entry (Pi 1.3.0, virtual-models.js). */
|
|
169
|
+
const PI_VIRTUAL = 'pi-virtual';
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What a Pi record says it runs with, from `start` on: the model, as
|
|
173
|
+
* `<provider>/<model>`, and the thinking level (tech notes, section 6). Pi
|
|
174
|
+
* writes nothing about approval.
|
|
175
|
+
*
|
|
176
|
+
* The latest line that names one decides. A `model_change` or
|
|
177
|
+
* `thinking_level_change` line decides alone. A reply names the model that
|
|
178
|
+
* answered, and the level it was asked at. On a resume with `--model` or
|
|
179
|
+
* `--thinking`, Pi writes no change line, so a change before a reply may be
|
|
180
|
+
* stale; but a change to a virtual model, such as `router/auto`, stays the
|
|
181
|
+
* selection while the router is registered, and its replies name the model
|
|
182
|
+
* it routed to (Pi 1.3.0, getBranchSelection, docs/virtual-models.md). The
|
|
183
|
+
* record cannot show what is registered now. So the reply stands when the
|
|
184
|
+
* change names the same model, or a model a reply shows is physical, and
|
|
185
|
+
* otherwise the two are left open. A router may change the level as well, so
|
|
186
|
+
* a reply's level stands alone only on a physical model. A reply a router
|
|
187
|
+
* could not route names the virtual model, and is passed over, as Pi does.
|
|
188
|
+
*/
|
|
189
|
+
function fromPi(entries, start) {
|
|
190
|
+
const named = (provider, model) => (typeof provider === 'string' && typeof model === 'string' ? `${provider}/${model}` : undefined);
|
|
191
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
192
|
+
const assistant = (entry) => (entry.type === 'message' && entry.message?.role === 'assistant' ? entry.message : undefined);
|
|
193
|
+
const reply = (entry) => (assistant(entry)?.api === PI_VIRTUAL ? undefined : assistant(entry));
|
|
194
|
+
const modelOf = (entry) => (entry.type === 'model_change' ? named(entry.provider, entry.modelId) : named(reply(entry)?.provider, reply(entry)?.model));
|
|
195
|
+
const levelOf = (entry) => text(entry.type === 'thinking_level_change' ? entry.thinkingLevel : reply(entry)?.thinkingLevel);
|
|
196
|
+
const changeBefore = (type, read, at) => {
|
|
197
|
+
for (let before = at - 1; before >= 0; before -= 1) if (entries[before].type === type) return read(entries[before]);
|
|
198
|
+
return undefined;
|
|
199
|
+
};
|
|
200
|
+
const physical = (model) => entries.some((entry) => entry.type !== 'model_change' && modelOf(entry) === model);
|
|
201
|
+
const latest = (read) => {
|
|
202
|
+
if (start === -1) return -1;
|
|
203
|
+
for (let at = entries.length - 1; at >= start; at -= 1) if (read(entries[at]) !== undefined) return at;
|
|
204
|
+
return -1;
|
|
205
|
+
};
|
|
206
|
+
/** The model the line at `at` says, and whether it is a physical one that a reply's level is sure of. */
|
|
207
|
+
const modelAt = (at) => {
|
|
208
|
+
const own = modelOf(entries[at]);
|
|
209
|
+
if (entries[at].type === 'model_change') return { model: own, sure: false };
|
|
210
|
+
const earlier = changeBefore('model_change', modelOf, at);
|
|
211
|
+
if (own === undefined) return { sure: false };
|
|
212
|
+
// The reply itself shows its own model is physical, so a change to that model is settled too.
|
|
213
|
+
if (earlier === undefined || physical(earlier)) return { model: own, sure: true };
|
|
214
|
+
return { model: { choices: [own, earlier] }, sure: false };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const model = latest(modelOf);
|
|
218
|
+
const level = latest(levelOf);
|
|
219
|
+
const own = level === -1 ? undefined : levelOf(entries[level]);
|
|
220
|
+
const earlier = level === -1 || entries[level].type === 'thinking_level_change' ? undefined : changeBefore('thinking_level_change', levelOf, level);
|
|
221
|
+
return {
|
|
222
|
+
model: model === -1 ? undefined : modelAt(model).model,
|
|
223
|
+
effort: earlier === undefined || earlier === own || modelAt(level).sure ? own : { choices: [own, earlier] },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
137
227
|
/** What a Codex rollout says it runs with: its latest turn, and its latest count. */
|
|
138
228
|
function fromCodex(entries) {
|
|
139
229
|
const used = {};
|
package/src/skills.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// away reaches it without a restart. Claude Code takes it through its own
|
|
9
9
|
// `/reload-skills`, which `obk skills build` types into the session's tab.
|
|
10
10
|
// Codex has no such command and takes it at the start of its next turn (tech
|
|
11
|
-
// notes, sections 2 and 3).
|
|
11
|
+
// notes, sections 2 and 3). Pi loads the Codex directory, which its launch
|
|
12
|
+
// line names, and takes a change at its own `/reload`, typed only while it is
|
|
13
|
+
// idle (section 6, #659). Outwards: the kit takes away only the links it put
|
|
12
14
|
// there, and never the skill one was pointing at.
|
|
13
15
|
//
|
|
14
16
|
// An entry names one of four shelves: the kit's own (`kit:<name>`), an online
|
|
@@ -25,7 +27,7 @@ import { parse, parseDocument, stringify } from 'yaml';
|
|
|
25
27
|
|
|
26
28
|
import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
27
29
|
import { botDir, botNames, changesExactly, leadsOutside, readBot, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
|
|
28
|
-
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
30
|
+
import { harnessOf, ownCli, piSkillsDir, runsOnPi, shellWord } from './launch.js';
|
|
29
31
|
import { orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
30
32
|
import { listIn } from './rules.js';
|
|
31
33
|
import { cloneDir, isCloned, leavesClone, readSources, skillsIn, wrongClone } from './sources.js';
|
|
@@ -142,12 +144,18 @@ export function removeSkill(bots, bot, ref) {
|
|
|
142
144
|
/**
|
|
143
145
|
* Give one bot the skills its lists name. Returns what there is to report:
|
|
144
146
|
* `{ bot, skills, linked, removed }` for a bot whose links are in place, and
|
|
145
|
-
* `{ bot, skills, trouble }` for one whose list the kit could not follow.
|
|
147
|
+
* `{ bot, skills, trouble }` for one whose list the kit could not follow. A bot
|
|
148
|
+
* that runs on Pi has `pi` too: the directory its Pi sessions load (#659).
|
|
146
149
|
*
|
|
147
150
|
* A list with one entry the kit cannot follow links nothing at all: half a
|
|
148
151
|
* bot's skills is not a bot anybody asked for, and the user fixes one line.
|
|
149
152
|
*/
|
|
150
153
|
export function linkSkills(bots, home, bot) {
|
|
154
|
+
const entry = linkInto(bots, home, bot);
|
|
155
|
+
return runsOnPi(bot) ? { ...entry, pi: piSkillsDir(home) } : entry;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function linkInto(bots, home, bot) {
|
|
151
159
|
const record = readRecord(home);
|
|
152
160
|
const held = new Map();
|
|
153
161
|
|
|
@@ -440,7 +448,7 @@ const LOOK_MS = 2000;
|
|
|
440
448
|
* harness's own means (PRD 6.5: changes reach running sessions without a
|
|
441
449
|
* restart, and the sessions a change affects are told). Returns one entry per
|
|
442
450
|
* session, in the bot's order: `{ session, harness, state }`, where the state
|
|
443
|
-
* is `reloaded`, `next-turn` (with `read`), `not-up`, `blocked` (with
|
|
451
|
+
* is `reloaded`, `next-turn` (with `read`), `busy`, `not-up`, `blocked` (with
|
|
444
452
|
* `blocked`) or `unknown` (with `trouble`).
|
|
445
453
|
*
|
|
446
454
|
* Claude Code gets `/reload-skills` typed into its tab, through the same gate
|
|
@@ -449,7 +457,11 @@ const LOOK_MS = 2000;
|
|
|
449
457
|
* command and takes the change at the start of its next turn by itself, so
|
|
450
458
|
* nothing is typed, and the caller is given the SKILL.md of each skill linked
|
|
451
459
|
* in, for the session to read if the skill has not appeared (tech notes,
|
|
452
|
-
* sections 2 and 3).
|
|
460
|
+
* sections 2 and 3). Pi gets its own `/reload` through the same gate, and only
|
|
461
|
+
* while it is idle: a busy Pi refuses the command and keeps nothing for later,
|
|
462
|
+
* so a busy one is `busy`, and one with a draft in its input line is typed
|
|
463
|
+
* nothing either, since the command would join the draft (section 6, #659).
|
|
464
|
+
* Nothing is ever restarted.
|
|
453
465
|
*/
|
|
454
466
|
function tellSessions(home, bot, linked) {
|
|
455
467
|
let book;
|
|
@@ -478,9 +490,6 @@ function tellSessions(home, bot, linked) {
|
|
|
478
490
|
const told = { session: session.name, harness };
|
|
479
491
|
const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
|
|
480
492
|
if (tab === undefined) return { ...told, state: 'not-up' };
|
|
481
|
-
// Skills reach a Pi session in a later slice (#547), and nothing is typed
|
|
482
|
-
// into its tab before then.
|
|
483
|
-
if (harness === 'pi') return { ...told, state: 'unknown', trouble: 'it runs on Pi, and the kit does not give a Pi session skills yet, so nothing was typed' };
|
|
484
493
|
if (down !== undefined) return { ...told, state: 'unknown', trouble: `the kit could not look at its tab, and nothing was typed: ${down}` };
|
|
485
494
|
let turn;
|
|
486
495
|
try {
|
|
@@ -505,7 +514,11 @@ function tellSessions(home, bot, linked) {
|
|
|
505
514
|
const read = linked.map((name) => path.join(home, SKILL_DIRS.codex, name, MANIFEST)).filter((file) => existsSync(file));
|
|
506
515
|
return { ...told, state: 'next-turn', read };
|
|
507
516
|
}
|
|
508
|
-
|
|
517
|
+
if (harness === 'pi') {
|
|
518
|
+
if (found.draft !== undefined) return { ...told, state: 'unknown', trouble: `its input line holds a draft, "${found.draft}", and /reload would join it, so nothing was typed` };
|
|
519
|
+
if (!found.idle) return { ...told, state: 'busy' };
|
|
520
|
+
}
|
|
521
|
+
typeIntoTab(found.handle, harness === 'pi' ? '/reload' : '/reload-skills');
|
|
509
522
|
return { ...told, state: 'reloaded' };
|
|
510
523
|
} catch (error) {
|
|
511
524
|
// The links are made whatever Orca says, so this is reported, not
|