pi-t3-bridge 0.4.1 → 0.6.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 +32 -2
- package/amplike.ts +46 -0
- package/package.json +6 -4
- package/pstack/skills/create-verification-skill/SKILL.md +1 -1
- package/pstack/skills/setup-pstack/scripts/t3-sheet.mjs +10 -1
- package/src/amplike/btw.ts +91 -0
- package/src/amplike/handoff.ts +190 -0
- package/src/amplike/package.ts +123 -0
- package/src/amplike/selection.ts +132 -0
- package/src/amplike/t3-view.ts +88 -0
- package/src/bridge/config.ts +6 -0
- package/src/bridge/steer-judge.ts +236 -16
package/README.md
CHANGED
|
@@ -142,7 +142,13 @@ The bridge caps every shell command at 300 seconds, in the main thread and in ch
|
|
|
142
142
|
|
|
143
143
|
### Stop checks
|
|
144
144
|
|
|
145
|
-
When a run is about to stop, the bridge asks a classifier how the run ended.
|
|
145
|
+
When a run is about to stop, the bridge asks a classifier how the run ended. It picks the classifier in this order:
|
|
146
|
+
|
|
147
|
+
1. The chat model named by `classifierModel` in the config, as `"provider/modelId"`.
|
|
148
|
+
2. `typesafe/jev-latest`, when it is in your Pi model catalog and you have access to it.
|
|
149
|
+
3. The model of the current session.
|
|
150
|
+
|
|
151
|
+
A chat model gets the same questions through one tool call, and the bridge reads its answers from the tool arguments. A chat model's probabilities are its own estimates. They are not calibrated like Jev's, and the stop thresholds were fitted on Jev, so a chat model may nudge more or less often. If Jev is available but its call fails, the bridge does not fall back to a chat model. A `classifierModel` that is not in the catalog or has no auth is an error, and the bridge does not fall back from it either. When the classifier says the agent gave up partway, the bridge adds a note and gives the agent one more turn, at most twice per prompt. Every 3 turns during a run, the bridge also asks the classifier whether the agent shows a known failure, and it adds a note when one is likely. The failures it checks for come from the MAST list of agent failures:
|
|
146
152
|
|
|
147
153
|
- Stuck in a loop
|
|
148
154
|
- Doesn't know when to stop
|
|
@@ -161,6 +167,8 @@ on(run about to stop)
|
|
|
161
167
|
|
|
162
168
|
The stop check and the notes come from [pi-mast-steer](https://github.com/dbreunig/pi-mast-steer). Set `steer` to `"shadow"` in the config to log verdicts without changing the session, or to `"off"` to turn the checks off.
|
|
163
169
|
|
|
170
|
+
A chat-model judge costs tokens on your account, one call per stop check and one every 3 turns.
|
|
171
|
+
|
|
164
172
|
## The pstack extension
|
|
165
173
|
|
|
166
174
|
The pstack extension adds the pstack skills to Pi's skill catalog, and it adds a note to each prompt that tells the agent when to enter `poteto-mode`. `poteto-mode` picks the workflow that fits your goal, for example a bug fix, a feature, a design review, or a PR watch. The skills come from [pstack](https://github.com/cursor/plugins/tree/main/pstack) by way of [t3-pstack](https://github.com/chhoumann/t3-pstack), and they live unchanged under `pstack/`.
|
|
@@ -215,6 +223,26 @@ sequenceDiagram
|
|
|
215
223
|
Note over Parent: the extension adds the report from the ledger
|
|
216
224
|
```
|
|
217
225
|
|
|
226
|
+
## pi-amplike in T3
|
|
227
|
+
|
|
228
|
+
`amplike.ts` adapts [pi-amplike](https://github.com/pasky/pi-amplike) for T3 threads. T3 shows Pi's `notify`, `select`, `confirm`, `input` and `editor` calls. It drops widgets, status lines, custom UI and key presses, and it resumes a thread from the session file it first recorded. So in T3, amplike's subagents, `/btw` and handoff show nothing or break. To use the adapter, turn off amplike's own extensions in `~/.pi/agent/settings.json` and keep its skills:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{ "source": "npm:pi-amplike", "extensions": [] }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`amplike.ts` then loads amplike's extensions itself. Outside T3 they load unchanged. In a T3 thread, the adapter changes these parts:
|
|
235
|
+
|
|
236
|
+
| amplike | In a T3 thread |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| `subagent` tool | Off. T3's `delegate_task` runs subagents. |
|
|
239
|
+
| `/btw <prompt>` | Starts a `delegate_task` child that reads this thread's session with `session_query`. Its answer wakes this thread. |
|
|
240
|
+
| `handoff` tool and `/handoff <goal>` | Writes a summary with the session model, then starts a new T3 thread with `t3_thread_launch`. The new thread gets the goal, the summary and the parent session file. This thread keeps its session. |
|
|
241
|
+
| `/mode` | Works as in the terminal. Each model or thinking change also goes to `t3_thread_configure`, so T3's model picker matches. |
|
|
242
|
+
| Shortcuts | Off. T3 sends Pi no key presses. |
|
|
243
|
+
|
|
244
|
+
`session_query`, `/permissions` and amplike's skills work unchanged. While amplike still loads its own extensions, the adapter does nothing, and a T3 thread shows a notice with the settings change.
|
|
245
|
+
|
|
218
246
|
## Configuration
|
|
219
247
|
|
|
220
248
|
The bridge reads one JSON file per machine, at `~/.config/pi-t3/config.json`. Every key is optional. The bridge reads the file again on each prompt, so an edit applies on the next prompt. When one value is invalid, only the invalid key falls back to its default, and the bridge tells you once.
|
|
@@ -223,6 +251,7 @@ The bridge reads one JSON file per machine, at `~/.config/pi-t3/config.json`. Ev
|
|
|
223
251
|
{
|
|
224
252
|
"bashTimeoutSeconds": 300,
|
|
225
253
|
"childSandbox": "on",
|
|
254
|
+
"classifierModel": "anthropic/claude-sonnet-4-5",
|
|
226
255
|
"mcpUrl": "http://127.0.0.1:3773/mcp",
|
|
227
256
|
"steer": "on"
|
|
228
257
|
}
|
|
@@ -232,6 +261,7 @@ The bridge reads one JSON file per machine, at `~/.config/pi-t3/config.json`. Ev
|
|
|
232
261
|
| --- | --- | --- |
|
|
233
262
|
| `bashTimeoutSeconds` | `300` | The longest a shell command can run, in seconds. |
|
|
234
263
|
| `childSandbox` | `"on"` | Whether a delegated child's shell commands need a sandbox. With `"on"`, a child's `bash` is blocked when no sandbox can run. With `"off"`, commands run with no sandbox on every platform, and the child's report says `SANDBOX: off`. |
|
|
264
|
+
| `classifierModel` | unset | A chat model that judges the stop checks, as `"provider/modelId"`. The model id may contain more slashes. When unset, the bridge uses `typesafe/jev-latest` if it is available, and the session model if not. A chat model's probabilities are its own estimates, not Jev-calibrated. |
|
|
235
265
|
| `mcpUrl` | `http://127.0.0.1:3773/mcp` | The T3 MCP endpoint to use outside a T3 thread. |
|
|
236
266
|
| `steer` | `"on"` | The stop checks, as `"on"`, `"shadow"`, or `"off"`. |
|
|
237
267
|
|
|
@@ -251,7 +281,7 @@ Earlier releases kept the state and the model sheet under `t3-pstack`. The first
|
|
|
251
281
|
|
|
252
282
|
## Data handling
|
|
253
283
|
|
|
254
|
-
The package has no server and sends no telemetry. The stop checks send parts of the session transcript to the classifier provider in your
|
|
284
|
+
The package has no server and sends no telemetry. The stop checks send parts of the session transcript to the classifier provider, which is Jev, the model you name in `classifierModel`, or your session model, and any file a skill asks the agent to read goes to your model provider. Scripts run on your machine, and the PR tools use your GitHub CLI login.
|
|
255
285
|
|
|
256
286
|
## Development
|
|
257
287
|
|
package/amplike.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
findAmplike,
|
|
5
|
+
loadFactory,
|
|
6
|
+
loadModeSpecFrom,
|
|
7
|
+
} from "./src/amplike/package.ts";
|
|
8
|
+
import { t3View } from "./src/amplike/t3-view.ts";
|
|
9
|
+
import { insideT3 } from "./src/bridge/t3-http.ts";
|
|
10
|
+
|
|
11
|
+
// Loads pi-amplike's extensions in place of Pi, so a T3 thread gets T3's
|
|
12
|
+
// versions of the parts T3 can't show. Outside T3 amplike loads unchanged.
|
|
13
|
+
// It runs only when the settings entry for amplike turns its extensions off.
|
|
14
|
+
const amplikeExtension = async (pi: ExtensionAPI): Promise<void> => {
|
|
15
|
+
const amplike = await findAmplike(process.cwd());
|
|
16
|
+
|
|
17
|
+
if (amplike === undefined) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (amplike.selfLoaded) {
|
|
22
|
+
if (insideT3()) {
|
|
23
|
+
pi.on("session_start", (_event, ctx) => {
|
|
24
|
+
if (ctx.hasUI) {
|
|
25
|
+
ctx.ui.notify(
|
|
26
|
+
`pi-t3 can't adapt pi-amplike for T3 while pi-amplike loads its own extensions. In ~/.pi/agent/settings.json, change "${amplike.source}" to {"source":"${amplike.source}","extensions":[]}.`,
|
|
27
|
+
"warning"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const host = insideT3()
|
|
37
|
+
? t3View(pi, await loadModeSpecFrom(amplike.root))
|
|
38
|
+
: pi;
|
|
39
|
+
|
|
40
|
+
const factories = await Promise.all(amplike.extensions.map(loadFactory));
|
|
41
|
+
|
|
42
|
+
// Each call starts in file order, as when Pi loads the files itself.
|
|
43
|
+
await Promise.all(factories.map((factory) => factory(host)));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default amplikeExtension;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-t3-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Pi extension adapter for t3-pstack inside T3 Code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-extension",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"overlay",
|
|
39
39
|
"LICENSE",
|
|
40
40
|
"tstack.ts",
|
|
41
|
-
"tstack"
|
|
41
|
+
"tstack",
|
|
42
|
+
"amplike.ts"
|
|
42
43
|
],
|
|
43
44
|
"type": "module",
|
|
44
45
|
"scripts": {
|
|
@@ -81,11 +82,12 @@
|
|
|
81
82
|
"extensions": [
|
|
82
83
|
"./bridge.ts",
|
|
83
84
|
"./tstack.ts",
|
|
84
|
-
"./pstack.ts"
|
|
85
|
+
"./pstack.ts",
|
|
86
|
+
"./amplike.ts"
|
|
85
87
|
]
|
|
86
88
|
},
|
|
87
89
|
"upstreams": {
|
|
88
|
-
"t3": "0.1.
|
|
90
|
+
"t3": "0.1.7",
|
|
89
91
|
"tstack": "1085050f3baed1ac724caa4a8166145fb2ac88af"
|
|
90
92
|
}
|
|
91
93
|
}
|
|
@@ -14,7 +14,7 @@ Answer these from the codebase and only ask the user what you cannot observe:
|
|
|
14
14
|
|
|
15
15
|
- **Surface:** what does a user actually touch? A web UI, a CLI/TUI, a desktop app, an API, a mobile app, a library? A repo can have several; pick the primary one and note the rest.
|
|
16
16
|
- **Run:** how does the app start locally? Prefer the repo's own documented dev command (package scripts, Makefile, README quickstart). Note ports, env vars, seed data, auth.
|
|
17
|
-
- **Drive:** how can an agent interact with it programmatically? Existing harnesses first —
|
|
17
|
+
- **Drive:** how can an agent interact with it programmatically? Existing harnesses first — browser test specs, expect scripts, PTY helpers, curl-able endpoints, a debug port. Only then pick a generic recipe: browser/CDP for web and Electron, a tmux/PTY harness for CLI/TUI, plain HTTP for services.
|
|
18
18
|
- **Observe:** what evidence can be captured? Screenshots, terminal transcripts, response bodies, logs, exit codes, DB state.
|
|
19
19
|
- **Isolate:** can two instances run side by side (ports, data dirs, profiles)? If not, say so in the generated skill: refusing to double-drive a shared instance beats corrupting the user's session.
|
|
20
20
|
|
|
@@ -143,13 +143,22 @@ function checkTarget({ providerInstanceId, model, options }, catalog) {
|
|
|
143
143
|
const problems = [];
|
|
144
144
|
for (const [id, value] of Object.entries(options)) {
|
|
145
145
|
const option = known.find((o) => o.id === id);
|
|
146
|
-
const allowed =
|
|
146
|
+
const allowed = allowedValues(option);
|
|
147
147
|
if (!option) problems.push(`${at}: no option "${id}" (has ${known.map((o) => o.id).join(", ") || "none"})`);
|
|
148
148
|
else if (!allowed.includes(value)) problems.push(`${at}: ${id}=${value} is not one of ${allowed.join(", ")}`);
|
|
149
149
|
}
|
|
150
150
|
return problems;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// orchestrator_capabilities lists an option's values as a plain array, with
|
|
154
|
+
// booleans as JSON booleans. The `type` and `options: [{ id }]` shape is
|
|
155
|
+
// still read for catalogs saved in that form.
|
|
156
|
+
function allowedValues(option) {
|
|
157
|
+
if (Array.isArray(option?.values)) return option.values.map(String);
|
|
158
|
+
if (option?.type === "boolean") return ["true", "false"];
|
|
159
|
+
return (option?.options ?? []).map((o) => o.id);
|
|
160
|
+
}
|
|
161
|
+
|
|
153
162
|
const invoked = process.argv[1] && existsSync(process.argv[1]) ? realpathSync(process.argv[1]) : null;
|
|
154
163
|
if (invoked === fileURLToPath(import.meta.url)) {
|
|
155
164
|
const [sheetPath, catalogPath] = process.argv.slice(2);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
|
|
5
|
+
import { callT3Tool } from "../bridge/t3-http.ts";
|
|
6
|
+
import type { LoadModeSpec } from "./package.ts";
|
|
7
|
+
import {
|
|
8
|
+
changeFromFlags,
|
|
9
|
+
currentSelection,
|
|
10
|
+
isEmptyChange,
|
|
11
|
+
parseFlags,
|
|
12
|
+
} from "./selection.ts";
|
|
13
|
+
import type { SelectionChange } from "./selection.ts";
|
|
14
|
+
|
|
15
|
+
type Command = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
16
|
+
|
|
17
|
+
const DelegatedSchema = Type.Object({
|
|
18
|
+
link: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
19
|
+
taskId: Type.String(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const TITLE_LENGTH = 80;
|
|
23
|
+
|
|
24
|
+
const USAGE = "Usage: /btw [-mode <name>] [-model <provider/id>] <prompt>";
|
|
25
|
+
|
|
26
|
+
// delegate_task's target for a Pi child: model as "provider/modelId", and the
|
|
27
|
+
// thinking level as an option. A flag that names only one keeps the other.
|
|
28
|
+
const targetFor = async (change: SelectionChange) => {
|
|
29
|
+
const current = await currentSelection();
|
|
30
|
+
const options: Record<string, string> = {};
|
|
31
|
+
|
|
32
|
+
if (change.thinking !== undefined) {
|
|
33
|
+
options.thinking = change.thinking;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { model: change.model ?? current.model, options };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// In T3, /btw is a delegate_task child. The child shows up in T3, and its
|
|
40
|
+
// result wakes this thread when it finishes.
|
|
41
|
+
export const btwCommand = (loadModeSpec: LoadModeSpec): Command => ({
|
|
42
|
+
description:
|
|
43
|
+
"Ask a side question in a T3 child thread: /btw [-mode <name>] [-model <provider/id>] <prompt>",
|
|
44
|
+
handler: async (args, ctx) => {
|
|
45
|
+
try {
|
|
46
|
+
const { flags, text } = parseFlags(args);
|
|
47
|
+
|
|
48
|
+
if (text.length === 0) {
|
|
49
|
+
throw new Error(USAGE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const change = await changeFromFlags(flags, ctx.cwd, loadModeSpec);
|
|
53
|
+
const parent = ctx.sessionManager.getSessionFile();
|
|
54
|
+
|
|
55
|
+
const task = [
|
|
56
|
+
"The user asked this side question while working in another thread. Answer it. Inspect only, change no file.",
|
|
57
|
+
text,
|
|
58
|
+
...(parent === undefined
|
|
59
|
+
? []
|
|
60
|
+
: [
|
|
61
|
+
`The other thread's Pi session file is \`${parent}\`. Call session_query on it for any context the question needs.`,
|
|
62
|
+
]),
|
|
63
|
+
].join("\n\n");
|
|
64
|
+
|
|
65
|
+
const delegate = {
|
|
66
|
+
mode: "async",
|
|
67
|
+
role: "research",
|
|
68
|
+
task,
|
|
69
|
+
title: `btw: ${text}`.slice(0, TITLE_LENGTH),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const delegated = isEmptyChange(change)
|
|
73
|
+
? await callT3Tool("delegate_task", delegate)
|
|
74
|
+
: await callT3Tool("delegate_task", {
|
|
75
|
+
...delegate,
|
|
76
|
+
target: await targetFor(change),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!Value.Check(DelegatedSchema, delegated)) {
|
|
80
|
+
throw new Error("delegate_task returned no task.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.ui.notify(
|
|
84
|
+
`btw started: ${delegated.link ?? delegated.taskId}`,
|
|
85
|
+
"info"
|
|
86
|
+
);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
ctx.ui.notify(`btw failed: ${String(error)}`, "error");
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
convertToLlm,
|
|
4
|
+
defineTool,
|
|
5
|
+
serializeConversation,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type {
|
|
8
|
+
ExtensionAPI,
|
|
9
|
+
ExtensionContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { Value } from "typebox/value";
|
|
13
|
+
|
|
14
|
+
import { callT3Tool } from "../bridge/t3-http.ts";
|
|
15
|
+
import type { LoadModeSpec } from "./package.ts";
|
|
16
|
+
import {
|
|
17
|
+
applyChange,
|
|
18
|
+
changeFromFlags,
|
|
19
|
+
currentSelection,
|
|
20
|
+
isEmptyChange,
|
|
21
|
+
parseFlags,
|
|
22
|
+
} from "./selection.ts";
|
|
23
|
+
import type { Flags } from "./selection.ts";
|
|
24
|
+
|
|
25
|
+
type Command = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
26
|
+
|
|
27
|
+
const SUMMARY_PROMPT = `You write the first message of a new thread that continues work from a conversation. Given the conversation and the user's goal for the new thread, write a self-contained prompt with:
|
|
28
|
+
|
|
29
|
+
## Context
|
|
30
|
+
The decisions made, approaches taken, and key findings that matter for the goal.
|
|
31
|
+
|
|
32
|
+
Files involved:
|
|
33
|
+
- each relevant file that was discussed or changed
|
|
34
|
+
|
|
35
|
+
## Task
|
|
36
|
+
What the new thread should do next, based on the goal.
|
|
37
|
+
|
|
38
|
+
Output only the prompt, with no preamble.`;
|
|
39
|
+
|
|
40
|
+
const TITLE_LENGTH = 80;
|
|
41
|
+
|
|
42
|
+
const LaunchSchema = Type.Object({
|
|
43
|
+
link: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
44
|
+
threadId: Type.String(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const summarize = async (
|
|
48
|
+
ctx: ExtensionContext,
|
|
49
|
+
goal: string,
|
|
50
|
+
signal: AbortSignal | undefined
|
|
51
|
+
): Promise<string> => {
|
|
52
|
+
const { model } = ctx;
|
|
53
|
+
|
|
54
|
+
if (model === undefined) {
|
|
55
|
+
throw new Error("No model selected.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const messages = ctx.sessionManager
|
|
59
|
+
.getBranch()
|
|
60
|
+
.flatMap((entry) => (entry.type === "message" ? [entry.message] : []));
|
|
61
|
+
|
|
62
|
+
if (messages.length === 0) {
|
|
63
|
+
throw new Error("No conversation to hand off.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const request: Message = {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
text: `## Conversation\n\n${serializeConversation(convertToLlm(messages))}\n\n## Goal for the new thread\n\n${goal}`,
|
|
70
|
+
type: "text",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
role: "user",
|
|
74
|
+
timestamp: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const response = await ctx.modelRegistry
|
|
78
|
+
.streamSimple(
|
|
79
|
+
model,
|
|
80
|
+
{ messages: [request], systemPrompt: SUMMARY_PROMPT },
|
|
81
|
+
{ signal }
|
|
82
|
+
)
|
|
83
|
+
.result();
|
|
84
|
+
|
|
85
|
+
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
86
|
+
throw new Error(
|
|
87
|
+
response.errorMessage ?? `The summary ${response.stopReason}.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return response.content
|
|
92
|
+
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
|
93
|
+
.join("\n");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// In T3 a handoff is a new T3 thread. Pi keeps its own session file, which is
|
|
97
|
+
// the one T3 resumes this thread from.
|
|
98
|
+
const handoff = async (
|
|
99
|
+
ctx: ExtensionContext,
|
|
100
|
+
goal: string,
|
|
101
|
+
flags: Flags,
|
|
102
|
+
loadModeSpec: LoadModeSpec,
|
|
103
|
+
signal: AbortSignal | undefined
|
|
104
|
+
): Promise<string> => {
|
|
105
|
+
const change = await changeFromFlags(flags, ctx.cwd, loadModeSpec);
|
|
106
|
+
const summary = await summarize(ctx, goal, signal);
|
|
107
|
+
const parent = ctx.sessionManager.getSessionFile();
|
|
108
|
+
|
|
109
|
+
const message = [
|
|
110
|
+
goal,
|
|
111
|
+
...(parent === undefined
|
|
112
|
+
? []
|
|
113
|
+
: ["/skill:session-query", `**Parent session:** \`${parent}\``]),
|
|
114
|
+
summary,
|
|
115
|
+
].join("\n\n");
|
|
116
|
+
|
|
117
|
+
const launch = { message, title: goal.slice(0, TITLE_LENGTH) };
|
|
118
|
+
|
|
119
|
+
const launched = isEmptyChange(change)
|
|
120
|
+
? await callT3Tool("t3_thread_launch", launch)
|
|
121
|
+
: await callT3Tool("t3_thread_launch", {
|
|
122
|
+
...launch,
|
|
123
|
+
modelSelection: { ...applyChange(await currentSelection(), change) },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!Value.Check(LaunchSchema, launched)) {
|
|
127
|
+
throw new Error("t3_thread_launch returned no thread.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return launched.link ?? launched.threadId;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const handoffTool = (loadModeSpec: LoadModeSpec) =>
|
|
134
|
+
defineTool({
|
|
135
|
+
description:
|
|
136
|
+
"Transfer context to a new focused T3 thread. ONLY use this when the user explicitly asks for a handoff. Provide a goal describing what the new thread should focus on. The result is the new thread's link, which you show the user verbatim.",
|
|
137
|
+
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
|
138
|
+
const link = await handoff(
|
|
139
|
+
ctx,
|
|
140
|
+
params.goal,
|
|
141
|
+
{ mode: params.mode, model: params.model },
|
|
142
|
+
loadModeSpec,
|
|
143
|
+
signal
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
content: [{ text: `Handoff thread started: ${link}`, type: "text" }],
|
|
148
|
+
details: { link },
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
label: "Handoff",
|
|
152
|
+
name: "handoff",
|
|
153
|
+
parameters: Type.Object({
|
|
154
|
+
goal: Type.String({ description: "The goal/task for the new thread" }),
|
|
155
|
+
mode: Type.Optional(
|
|
156
|
+
Type.String({
|
|
157
|
+
description:
|
|
158
|
+
"Amplike mode name to start the new thread with (e.g. 'rush', 'smart', 'deep')",
|
|
159
|
+
})
|
|
160
|
+
),
|
|
161
|
+
model: Type.Optional(
|
|
162
|
+
Type.String({
|
|
163
|
+
description:
|
|
164
|
+
"Model to start the new thread with, as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')",
|
|
165
|
+
})
|
|
166
|
+
),
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export const handoffCommand = (loadModeSpec: LoadModeSpec): Command => ({
|
|
171
|
+
description:
|
|
172
|
+
"Start a new T3 thread with a summary of this one: /handoff [-mode <name>] [-model <provider/id>] <goal>",
|
|
173
|
+
handler: async (args, ctx) => {
|
|
174
|
+
try {
|
|
175
|
+
const { flags, text } = parseFlags(args);
|
|
176
|
+
|
|
177
|
+
if (text.length === 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
"Usage: /handoff [-mode <name>] [-model <provider/id>] <goal>"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const link = await handoff(ctx, text, flags, loadModeSpec, ctx.signal);
|
|
184
|
+
|
|
185
|
+
ctx.ui.notify(`Handoff thread started: ${link}`, "info");
|
|
186
|
+
} catch (error) {
|
|
187
|
+
ctx.ui.notify(`Handoff failed: ${String(error)}`, "error");
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DefaultPackageManager,
|
|
6
|
+
getAgentDir,
|
|
7
|
+
SettingsManager,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionFactory,
|
|
11
|
+
ResolvedResource,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import type { Static } from "typebox";
|
|
15
|
+
import { Value } from "typebox/value";
|
|
16
|
+
|
|
17
|
+
const PACKAGE_NAME = "pi-amplike";
|
|
18
|
+
|
|
19
|
+
const ManifestSchema = Type.Object({ name: Type.String() });
|
|
20
|
+
|
|
21
|
+
const FactoryModuleSchema = Type.Object({
|
|
22
|
+
default: Type.Function([Type.Unknown()], Type.Unknown()),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const ModeUtilsSchema = Type.Object({
|
|
26
|
+
loadModeSpec: Type.Function([Type.String(), Type.String()], Type.Unknown()),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const ModeSpecSchema = Type.Object({
|
|
30
|
+
modelId: Type.Optional(Type.String()),
|
|
31
|
+
provider: Type.Optional(Type.String()),
|
|
32
|
+
thinkingLevel: Type.Optional(Type.String()),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type ModeSpec = Static<typeof ModeSpecSchema>;
|
|
36
|
+
|
|
37
|
+
export type LoadModeSpec = (
|
|
38
|
+
cwd: string,
|
|
39
|
+
name: string
|
|
40
|
+
) => Promise<ModeSpec | undefined>;
|
|
41
|
+
|
|
42
|
+
export interface Amplike {
|
|
43
|
+
readonly extensions: readonly string[];
|
|
44
|
+
readonly root: string;
|
|
45
|
+
// True when Pi loads amplike's extensions itself, so this adapter must not.
|
|
46
|
+
readonly selfLoaded: boolean;
|
|
47
|
+
readonly source: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isAmplikeRoot = (root: string): boolean => {
|
|
51
|
+
try {
|
|
52
|
+
const manifest: unknown = JSON.parse(
|
|
53
|
+
readFileSync(path.join(root, "package.json"), "utf-8")
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
Value.Check(ManifestSchema, manifest) && manifest.name === PACKAGE_NAME
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const isAmplike = (resource: ResolvedResource): boolean =>
|
|
65
|
+
resource.metadata.packageRoot !== undefined &&
|
|
66
|
+
isAmplikeRoot(resource.metadata.packageRoot);
|
|
67
|
+
|
|
68
|
+
// Pi's own package manager gives the extension files amplike's manifest names,
|
|
69
|
+
// and whether the settings entry for amplike turns them off.
|
|
70
|
+
export const findAmplike = async (
|
|
71
|
+
cwd: string
|
|
72
|
+
): Promise<Amplike | undefined> => {
|
|
73
|
+
const agentDir = getAgentDir();
|
|
74
|
+
|
|
75
|
+
const manager = new DefaultPackageManager({
|
|
76
|
+
agentDir,
|
|
77
|
+
cwd,
|
|
78
|
+
settingsManager: SettingsManager.create(cwd, agentDir),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const resolved = await manager.resolve(async () => "skip");
|
|
82
|
+
const own = resolved.extensions.filter(isAmplike);
|
|
83
|
+
const [first] = own;
|
|
84
|
+
|
|
85
|
+
if (first?.metadata.packageRoot === undefined) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
extensions: own.map((resource) => resource.path),
|
|
91
|
+
root: first.metadata.packageRoot,
|
|
92
|
+
selfLoaded: own.some((resource) => resource.enabled),
|
|
93
|
+
source: first.metadata.source,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const loadFactory = async (file: string): Promise<ExtensionFactory> => {
|
|
98
|
+
const module: unknown = await import(file);
|
|
99
|
+
|
|
100
|
+
if (!Value.Check(FactoryModuleSchema, module)) {
|
|
101
|
+
throw new TypeError(`${file} has no default extension factory.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// SAFETY: amplike's extension files default-export a Pi extension factory.
|
|
105
|
+
return module.default as ExtensionFactory;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// amplike's handoff and subagent read -mode through this helper, so the T3
|
|
109
|
+
// replacements read it the same way.
|
|
110
|
+
export const loadModeSpecFrom = async (root: string): Promise<LoadModeSpec> => {
|
|
111
|
+
const file = path.join(root, "extensions", "lib", "mode-utils.ts");
|
|
112
|
+
const module: unknown = await import(file);
|
|
113
|
+
|
|
114
|
+
if (!Value.Check(ModeUtilsSchema, module)) {
|
|
115
|
+
throw new TypeError(`${file} has no loadModeSpec export.`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return async (cwd, name) => {
|
|
119
|
+
const spec = await module.loadModeSpec(cwd, name);
|
|
120
|
+
|
|
121
|
+
return Value.Check(ModeSpecSchema, spec) ? spec : undefined;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { Static } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
|
|
5
|
+
import { callT3Tool } from "../bridge/t3-http.ts";
|
|
6
|
+
import type { LoadModeSpec } from "./package.ts";
|
|
7
|
+
|
|
8
|
+
const OptionSchema = Type.Object({
|
|
9
|
+
id: Type.String(),
|
|
10
|
+
value: Type.Union([Type.String(), Type.Boolean()]),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const SelectionSchema = Type.Object({
|
|
14
|
+
instanceId: Type.Optional(Type.String()),
|
|
15
|
+
model: Type.String(),
|
|
16
|
+
options: Type.Optional(Type.Array(OptionSchema)),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const ConfigureSchema = Type.Object({ modelSelection: SelectionSchema });
|
|
20
|
+
|
|
21
|
+
// T3's modelSelection for a Pi thread: model is "provider/modelId", and the
|
|
22
|
+
// thinking level is the "thinking" option.
|
|
23
|
+
export type Selection = Static<typeof SelectionSchema>;
|
|
24
|
+
|
|
25
|
+
export interface SelectionChange {
|
|
26
|
+
model?: string;
|
|
27
|
+
thinking?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Flags {
|
|
31
|
+
mode?: string;
|
|
32
|
+
model?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ParsedArgs {
|
|
36
|
+
readonly flags: Flags;
|
|
37
|
+
readonly text: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const THINKING = "thinking";
|
|
41
|
+
|
|
42
|
+
export const isEmptyChange = (change: SelectionChange): boolean =>
|
|
43
|
+
change.model === undefined && change.thinking === undefined;
|
|
44
|
+
|
|
45
|
+
export const currentSelection = async (): Promise<Selection> => {
|
|
46
|
+
const result = await callT3Tool("t3_thread_configure", {});
|
|
47
|
+
|
|
48
|
+
if (!Value.Check(ConfigureSchema, result)) {
|
|
49
|
+
throw new Error("t3_thread_configure returned no modelSelection.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result.modelSelection;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const applyChange = (
|
|
56
|
+
current: Selection,
|
|
57
|
+
change: SelectionChange
|
|
58
|
+
): Selection => {
|
|
59
|
+
const next: Selection = { ...current, model: change.model ?? current.model };
|
|
60
|
+
|
|
61
|
+
if (change.thinking !== undefined) {
|
|
62
|
+
next.options = [
|
|
63
|
+
...(current.options ?? []).filter((option) => option.id !== THINKING),
|
|
64
|
+
{ id: THINKING, value: change.thinking },
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return next;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const configureThread = async (selection: Selection): Promise<void> => {
|
|
72
|
+
await callT3Tool("t3_thread_configure", { modelSelection: { ...selection } });
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// amplike's commands take `-mode <name>` and `-model <provider/id>` before the
|
|
76
|
+
// text, in either order.
|
|
77
|
+
export const parseFlags = (args: string): ParsedArgs => {
|
|
78
|
+
const words = args.trim().split(/\s+/u);
|
|
79
|
+
const flags: Flags = {};
|
|
80
|
+
|
|
81
|
+
while (words[0] === "-mode" || words[0] === "-model") {
|
|
82
|
+
const [flag, value] = words.splice(0, 2);
|
|
83
|
+
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
throw new Error(`${flag} needs a value.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (flag === "-mode") {
|
|
89
|
+
flags.mode = value;
|
|
90
|
+
} else {
|
|
91
|
+
flags.model = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { flags, text: words.join(" ") };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Same order as amplike: the mode sets model and thinking, then -model
|
|
99
|
+
// overrides the model.
|
|
100
|
+
export const changeFromFlags = async (
|
|
101
|
+
flags: Flags,
|
|
102
|
+
cwd: string,
|
|
103
|
+
loadModeSpec: LoadModeSpec
|
|
104
|
+
): Promise<SelectionChange> => {
|
|
105
|
+
const change: SelectionChange = {};
|
|
106
|
+
|
|
107
|
+
if (flags.mode !== undefined) {
|
|
108
|
+
const spec = await loadModeSpec(cwd, flags.mode);
|
|
109
|
+
|
|
110
|
+
if (spec === undefined) {
|
|
111
|
+
throw new Error(`Unknown mode "${flags.mode}".`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (spec.provider !== undefined && spec.modelId !== undefined) {
|
|
115
|
+
change.model = `${spec.provider}/${spec.modelId}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
change.thinking = spec.thinkingLevel;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (flags.model !== undefined) {
|
|
122
|
+
if (!flags.model.includes("/")) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Model "${flags.model}" must be written as provider/modelId.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
change.model = flags.model;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return change;
|
|
132
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { btwCommand } from "./btw.ts";
|
|
4
|
+
import { handoffCommand, handoffTool } from "./handoff.ts";
|
|
5
|
+
import type { LoadModeSpec } from "./package.ts";
|
|
6
|
+
import { applyChange, configureThread, currentSelection } from "./selection.ts";
|
|
7
|
+
import type { SelectionChange } from "./selection.ts";
|
|
8
|
+
|
|
9
|
+
type Tool = Parameters<ExtensionAPI["registerTool"]>[0];
|
|
10
|
+
|
|
11
|
+
type Command = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
12
|
+
|
|
13
|
+
// What a T3 thread gets for each name amplike registers. A missing name is
|
|
14
|
+
// registered as amplike wrote it. null drops it.
|
|
15
|
+
interface Rules {
|
|
16
|
+
readonly commands: Readonly<Record<string, Command | null>>;
|
|
17
|
+
readonly tools: Readonly<Record<string, Tool | null>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const t3Rules = (loadModeSpec: LoadModeSpec): Rules => ({
|
|
21
|
+
commands: {
|
|
22
|
+
btw: btwCommand(loadModeSpec),
|
|
23
|
+
handoff: handoffCommand(loadModeSpec),
|
|
24
|
+
},
|
|
25
|
+
tools: {
|
|
26
|
+
handoff: handoffTool(loadModeSpec),
|
|
27
|
+
// T3's delegate_task runs subagents as T3 threads.
|
|
28
|
+
subagent: null,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// T3 owns the thread's model. When amplike's /mode switches Pi's model or
|
|
33
|
+
// thinking level, the same change goes to T3, so T3's model picker matches and
|
|
34
|
+
// T3's next set_model keeps it. amplike awaits setModel before it sets the
|
|
35
|
+
// thinking level, so the two calls reach T3 in order.
|
|
36
|
+
const mirror = async (change: SelectionChange): Promise<void> => {
|
|
37
|
+
await configureThread(applyChange(await currentSelection(), change));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mirrorThinking = async (thinking: string): Promise<void> => {
|
|
41
|
+
try {
|
|
42
|
+
await mirror({ thinking });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`pi-t3: T3 kept its thinking level: ${String(error)}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Pi's extension API is a plain object of closures, so a copy with some
|
|
49
|
+
// methods replaced behaves like the original for everything else.
|
|
50
|
+
export const t3View = (
|
|
51
|
+
pi: ExtensionAPI,
|
|
52
|
+
loadModeSpec: LoadModeSpec
|
|
53
|
+
): ExtensionAPI => {
|
|
54
|
+
const rules = t3Rules(loadModeSpec);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...pi,
|
|
58
|
+
registerCommand: (name, options) => {
|
|
59
|
+
const rule = rules.commands[name];
|
|
60
|
+
|
|
61
|
+
if (rule !== null) {
|
|
62
|
+
pi.registerCommand(name, rule ?? options);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
// T3 sends Pi no key presses.
|
|
66
|
+
registerShortcut: () => null,
|
|
67
|
+
registerTool: (tool) => {
|
|
68
|
+
const rule = rules.tools[tool.name];
|
|
69
|
+
|
|
70
|
+
if (rule !== null) {
|
|
71
|
+
pi.registerTool(rule ?? tool);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
setModel: async (model) => {
|
|
75
|
+
const ok = await pi.setModel(model);
|
|
76
|
+
|
|
77
|
+
if (ok) {
|
|
78
|
+
await mirror({ model: `${model.provider}/${model.id}` });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return ok;
|
|
82
|
+
},
|
|
83
|
+
setThinkingLevel: (level) => {
|
|
84
|
+
pi.setThinkingLevel(level);
|
|
85
|
+
void mirrorThinking(level);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
};
|
package/src/bridge/config.ts
CHANGED
|
@@ -25,6 +25,7 @@ const ChildSandboxSchema = Type.Union([
|
|
|
25
25
|
export interface Config {
|
|
26
26
|
readonly bashTimeoutSeconds: number | undefined;
|
|
27
27
|
readonly childSandbox: Static<typeof ChildSandboxSchema> | undefined;
|
|
28
|
+
readonly classifierModel: string | undefined;
|
|
28
29
|
readonly mcpUrl: string | undefined;
|
|
29
30
|
readonly steer: Static<typeof SteerSchema> | undefined;
|
|
30
31
|
}
|
|
@@ -37,6 +38,7 @@ interface Loaded {
|
|
|
37
38
|
const EMPTY: Config = {
|
|
38
39
|
bashTimeoutSeconds: undefined,
|
|
39
40
|
childSandbox: undefined,
|
|
41
|
+
classifierModel: undefined,
|
|
40
42
|
mcpUrl: undefined,
|
|
41
43
|
steer: undefined,
|
|
42
44
|
};
|
|
@@ -88,6 +90,10 @@ const parse = (text: string): Loaded => {
|
|
|
88
90
|
Type.Number({ exclusiveMinimum: 0 })
|
|
89
91
|
),
|
|
90
92
|
childSandbox: field("childSandbox", ChildSandboxSchema),
|
|
93
|
+
classifierModel: field(
|
|
94
|
+
"classifierModel",
|
|
95
|
+
Type.String({ pattern: "^[^/\\s]+/.+$" })
|
|
96
|
+
),
|
|
91
97
|
mcpUrl: field("mcpUrl", Type.String({ minLength: 1 })),
|
|
92
98
|
steer: field("steer", SteerSchema),
|
|
93
99
|
},
|
|
@@ -2,21 +2,25 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import type {
|
|
5
|
+
Api,
|
|
5
6
|
ClassifierBoolQuestion,
|
|
6
7
|
ClassifierContext,
|
|
8
|
+
ClassifierQuestion,
|
|
7
9
|
JsonObject,
|
|
10
|
+
Model,
|
|
8
11
|
} from "@earendil-works/pi-ai";
|
|
9
12
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
13
|
import { Type } from "typebox";
|
|
11
|
-
import type { Static } from "typebox";
|
|
14
|
+
import type { Static, TSchema } from "typebox";
|
|
12
15
|
import { Value } from "typebox/value";
|
|
13
16
|
|
|
17
|
+
import { readConfig } from "./config.ts";
|
|
14
18
|
import type { MastExcerpt, StopInput } from "./steer-transcript.ts";
|
|
15
19
|
|
|
16
|
-
// Asks TypeSafe's Jev how a run ended
|
|
17
|
-
// pi-mast-steer's src/judge.ts (MIT,
|
|
18
|
-
// failure becomes an error value, so a
|
|
19
|
-
// session.
|
|
20
|
+
// Asks TypeSafe's Jev how a run ended, or a chat model when Jev is not
|
|
21
|
+
// available. The classifier call follows pi-mast-steer's src/judge.ts (MIT,
|
|
22
|
+
// github.com/dbreunig/pi-mast-steer): any failure becomes an error value, so a
|
|
23
|
+
// judge that is down never blocks a session.
|
|
20
24
|
|
|
21
25
|
const StopKindSchema = Type.Union([
|
|
22
26
|
Type.Literal("done"),
|
|
@@ -92,27 +96,243 @@ const readQuestions = (): Static<typeof QuestionsFileSchema> => {
|
|
|
92
96
|
// Fitted on the user's own labelled stops. See the stop-steer outline, phase 6.
|
|
93
97
|
export const stopThresholds = () => readQuestions().thresholds;
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
type Asked = { readonly answers: unknown } | { readonly error: string };
|
|
100
|
+
|
|
101
|
+
const ANSWER_TOOL = "classifier_answers";
|
|
102
|
+
|
|
103
|
+
// One flat argument per asked question: a bool is a probability, a choice is
|
|
104
|
+
// one of its criteria keys. No caller asks a score question.
|
|
105
|
+
const answerSchema = (question: ClassifierQuestion): TSchema => {
|
|
106
|
+
switch (question.type) {
|
|
107
|
+
case "bool": {
|
|
108
|
+
return Type.Number({ maximum: 1, minimum: 0 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case "choice": {
|
|
112
|
+
return Type.Union(
|
|
113
|
+
Object.keys(question.criteria).map((key) => Type.Literal(key))
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
default: {
|
|
118
|
+
return Type.Never();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const chatPrompt = (context: ClassifierContext): string =>
|
|
124
|
+
[
|
|
125
|
+
"Judge the state below by answering each question.",
|
|
126
|
+
`Call ${ANSWER_TOOL} exactly once, with one value for every question.`,
|
|
127
|
+
'For a bool question, give the probability from 0 to 1 that its "true" criteria holds.',
|
|
128
|
+
"For a choice question, give the key of the criteria that fits best.",
|
|
129
|
+
"",
|
|
130
|
+
"Questions, as JSON:",
|
|
131
|
+
JSON.stringify(context.questions),
|
|
132
|
+
"",
|
|
133
|
+
"State, as JSON:",
|
|
134
|
+
JSON.stringify(context.state),
|
|
135
|
+
].join("\n");
|
|
136
|
+
|
|
137
|
+
type FlatAnswer =
|
|
138
|
+
| { readonly probability: number; readonly type: "bool" }
|
|
139
|
+
| { readonly choice: string; readonly type: "choice" };
|
|
140
|
+
|
|
141
|
+
// Parses one flat value per question, checked against that question's own
|
|
142
|
+
// schema, and turns it into the answer shape a classifier returns.
|
|
143
|
+
const parseAnswers = <S extends TSchema>(
|
|
144
|
+
schema: S,
|
|
145
|
+
questions: ClassifierContext["questions"],
|
|
146
|
+
candidate: Static<S>
|
|
147
|
+
): Record<string, FlatAnswer> | undefined => {
|
|
148
|
+
if (!Value.Check(schema, candidate)) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (Object.keys(candidate).length !== Object.keys(questions).length) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const answers: Record<string, FlatAnswer> = {};
|
|
157
|
+
|
|
158
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
159
|
+
const value = candidate[name];
|
|
160
|
+
|
|
161
|
+
switch (question.type) {
|
|
162
|
+
case "bool": {
|
|
163
|
+
if (!Value.Check(Type.Number({ maximum: 1, minimum: 0 }), value)) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
answers[name] = { probability: value, type: "bool" };
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "choice": {
|
|
172
|
+
if (
|
|
173
|
+
!Value.Check(Type.String(), value) ||
|
|
174
|
+
!Value.Check(answerSchema(question), value)
|
|
175
|
+
) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
answers[name] = { choice: value, type: "choice" };
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
default: {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return answers;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const askChat = async (
|
|
97
193
|
ctx: ExtensionContext,
|
|
194
|
+
model: Model<Api>,
|
|
98
195
|
context: ClassifierContext,
|
|
99
196
|
signal: AbortSignal
|
|
100
|
-
): Promise<
|
|
197
|
+
): Promise<Asked> => {
|
|
198
|
+
const schema = Type.Object(
|
|
199
|
+
Object.fromEntries(
|
|
200
|
+
Object.entries(context.questions).map(([name, question]) => [
|
|
201
|
+
name,
|
|
202
|
+
answerSchema(question),
|
|
203
|
+
])
|
|
204
|
+
),
|
|
205
|
+
{ additionalProperties: false }
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const reply = await ctx.modelRegistry
|
|
210
|
+
.streamSimple(
|
|
211
|
+
model,
|
|
212
|
+
{
|
|
213
|
+
messages: [
|
|
214
|
+
{
|
|
215
|
+
content: chatPrompt(context),
|
|
216
|
+
role: "user",
|
|
217
|
+
timestamp: Date.now(),
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
tools: [
|
|
221
|
+
{
|
|
222
|
+
constrainedSampling: { strict: "prefer", type: "json_schema" },
|
|
223
|
+
description: "Record your answer to every question.",
|
|
224
|
+
name: ANSWER_TOOL,
|
|
225
|
+
parameters: schema,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
{ signal }
|
|
230
|
+
)
|
|
231
|
+
.result();
|
|
232
|
+
|
|
233
|
+
if (reply.stopReason !== "stop" && reply.stopReason !== "toolUse") {
|
|
234
|
+
return { error: reply.errorMessage ?? reply.stopReason };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const call = reply.content.find(
|
|
238
|
+
(part) => part.type === "toolCall" && part.name === ANSWER_TOOL
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const answers =
|
|
242
|
+
call?.type === "toolCall"
|
|
243
|
+
? parseAnswers(schema, context.questions, call.arguments)
|
|
244
|
+
: undefined;
|
|
245
|
+
|
|
246
|
+
if (answers === undefined) {
|
|
247
|
+
return {
|
|
248
|
+
error: `${model.provider}/${model.id} did not answer through ${ANSWER_TOOL}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { answers };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return {
|
|
255
|
+
error: error instanceof Error ? error.message : String(error),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// The judge is classifierModel when set, else Jev when it is available, else
|
|
261
|
+
// the session model. Failures are values. Nothing here throws on a judge that
|
|
262
|
+
// is down.
|
|
263
|
+
const askClassifier = async (
|
|
264
|
+
ctx: ExtensionContext,
|
|
265
|
+
context: ClassifierContext,
|
|
266
|
+
signal: AbortSignal
|
|
267
|
+
): Promise<Asked> => {
|
|
268
|
+
const { classifierModel } = readConfig();
|
|
269
|
+
|
|
270
|
+
if (classifierModel !== undefined) {
|
|
271
|
+
const slash = classifierModel.indexOf("/");
|
|
272
|
+
|
|
273
|
+
const chosen = ctx.modelRegistry.find(
|
|
274
|
+
classifierModel.slice(0, slash),
|
|
275
|
+
classifierModel.slice(slash + 1)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (chosen === undefined) {
|
|
279
|
+
return {
|
|
280
|
+
error: `classifierModel "${classifierModel}" is not in the model catalog`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(chosen)) {
|
|
285
|
+
return {
|
|
286
|
+
error: `classifierModel "${classifierModel}" has no configured auth`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return askChat(ctx, chosen, context, signal);
|
|
291
|
+
}
|
|
292
|
+
|
|
101
293
|
const jev = ctx.modelRegistry.findOfType(
|
|
102
294
|
"classifier",
|
|
103
295
|
"typesafe",
|
|
104
296
|
"jev-latest"
|
|
105
297
|
);
|
|
106
298
|
|
|
107
|
-
|
|
108
|
-
|
|
299
|
+
let available: Awaited<
|
|
300
|
+
ReturnType<typeof ctx.modelRegistry.getAvailableOfType>
|
|
301
|
+
>;
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
available = await ctx.modelRegistry.getAvailableOfType(
|
|
305
|
+
"classifier",
|
|
306
|
+
"typesafe",
|
|
307
|
+
{ signal }
|
|
308
|
+
);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return {
|
|
311
|
+
error: error instanceof Error ? error.message : String(error),
|
|
312
|
+
};
|
|
109
313
|
}
|
|
110
314
|
|
|
111
|
-
|
|
315
|
+
if (
|
|
316
|
+
jev !== undefined &&
|
|
317
|
+
available.some(
|
|
318
|
+
(model) => model.provider === jev.provider && model.id === jev.id
|
|
319
|
+
)
|
|
320
|
+
) {
|
|
321
|
+
const result = await ctx.modelRegistry.classify(jev, context, { signal });
|
|
322
|
+
|
|
323
|
+
return result.stopReason === "stop"
|
|
324
|
+
? { answers: result.answers }
|
|
325
|
+
: { error: result.errorMessage ?? result.stopReason };
|
|
326
|
+
}
|
|
112
327
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
328
|
+
if (ctx.model !== undefined) {
|
|
329
|
+
return askChat(ctx, ctx.model, context, signal);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
error:
|
|
334
|
+
"typesafe/jev-latest is not available and the session has no model to judge with",
|
|
335
|
+
};
|
|
116
336
|
};
|
|
117
337
|
|
|
118
338
|
export const judgeStop = async (
|
|
@@ -144,7 +364,7 @@ export const judgeStop = async (
|
|
|
144
364
|
asked.handoff_missing = questions.handoff_missing;
|
|
145
365
|
}
|
|
146
366
|
|
|
147
|
-
const result = await
|
|
367
|
+
const result = await askClassifier(ctx, { questions: asked, state }, signal);
|
|
148
368
|
|
|
149
369
|
if ("error" in result) {
|
|
150
370
|
return result;
|
|
@@ -230,7 +450,7 @@ export const judgeMast = async (
|
|
|
230
450
|
): Promise<MastVerdict> => {
|
|
231
451
|
const mast = readMast();
|
|
232
452
|
|
|
233
|
-
const result = await
|
|
453
|
+
const result = await askClassifier(
|
|
234
454
|
ctx,
|
|
235
455
|
{
|
|
236
456
|
// Pi sends `bool` questions to TypeSafe as `noul`, the type DSPy sends.
|