@grrrck/pi-clear 0.3.1 → 0.4.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/CHANGELOG.md +19 -0
- package/README.md +77 -12
- package/index.ts +898 -50
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-10-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `/hand-off` asks **who writes the hand-off prompt** before it picks the destination model: *This agent — full context and tools* (the existing behavior, first in the list so Enter keeps it), *Fresh pass — current model*, or *Fresh pass — another model…*.
|
|
13
|
+
- A **fresh pass** writer: one out-of-band `modelRegistry.complete()` call over a minimal reconstruction of the conversation (user text, assistant text, one-line tool-call hints, file-operation footer; no tool output, no thinking) that can use a cheap model and does not inherit the session's context window. It has no tool access.
|
|
14
|
+
- Fresh-pass transcript budget keeps the opening message and the newest turns, elides the middle with a `[... <n> turns omitted — <k> chars ...]` marker, and notifies how much was dropped. Default `200000` characters; `0` disables it.
|
|
15
|
+
- The fresh pass asks for the writer model's thinking level, with the provider default preselected; models without reasoning controls skip the dialog. A standing preference for it comes from `handoff.summarizerThinkingLevel` in the config file.
|
|
16
|
+
- Command flags `--fresh` and `--live`: parsed off the front of the command args, with the rest still pre-filling the steering editor. Passing both is refused before any dialog. There is no command-arg model flag — the model that writes the prompt comes from the config file or the picker.
|
|
17
|
+
- Optional settings in `<agent-dir>/pi-clear.json` (`handoff.summarizerModel`, `handoff.askSummarizerModel`, `handoff.summarizerThinkingLevel`, `handoff.maxSourceChars`), read once per session; a malformed file warns and is ignored.
|
|
18
|
+
- CLI flags `--hand-off-summarizer=<provider/model>` and `--hand-off-max-source-chars=<n>`.
|
|
19
|
+
- Every hand-off prompt, from either writer, now starts with a provenance comment naming the source session: `<!-- Handed off from pi session <id> -->`. It is added by the extension after the prompt is written, so the writing model cannot reword it.
|
|
20
|
+
- `scripts/minimal-transcript.test.mjs`: offline tests for the fresh-pass serializer, budget, file lists, provenance, and command-arg parsing.
|
|
21
|
+
- `scripts/fresh-pass-flow.test.mjs`: offline tests for the whole `/hand-off` flow with mocked pi APIs — both writers, the writer thinking picker and its config preference, trimming, rejection, and the flag conflict.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `scripts/rpc-handoff-smoke-test.py` scripts the new writer picker and adds a `/hand-off --fresh` scenario that asserts no agent turn starts and that the provenance comment carries the previous session id.
|
|
26
|
+
|
|
8
27
|
## [0.3.1] - 2026-10-27
|
|
9
28
|
|
|
10
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A [pi](https://github.com/earendil-works/pi) extension with two commands:
|
|
4
4
|
|
|
5
5
|
- `/clear` — start a fresh session **without losing your model**
|
|
6
|
-
- `/hand-off` — hand the conversation to another model with a prompt written by the current agent
|
|
6
|
+
- `/hand-off` — hand the conversation to another model with a prompt written by the current agent, or by a cheap one-off "fresh pass"
|
|
7
7
|
|
|
8
8
|
## Why?
|
|
9
9
|
|
|
@@ -29,7 +29,7 @@ pi -e npm:@grrrck/pi-clear
|
|
|
29
29
|
|
|
30
30
|
```text
|
|
31
31
|
/clear
|
|
32
|
-
/hand-off [steering instructions]
|
|
32
|
+
/hand-off [--fresh | --live] [steering instructions]
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
## `/clear` behavior
|
|
@@ -48,17 +48,77 @@ Because the model is applied with `pi.setModel()`, it's recorded in the new sess
|
|
|
48
48
|
|
|
49
49
|
When `/hand-off` runs, the extension:
|
|
50
50
|
|
|
51
|
+
0. **chooses who writes the hand-off prompt** — see [Choosing the writer](#choosing-the-writer). The picker is skipped when `--fresh` or `--live` was passed
|
|
51
52
|
1. waits for the agent to become idle if needed
|
|
52
|
-
2. opens a model picker, then a thinking level picker, then a steering editor pre-filled with any text you typed after `/hand-off
|
|
53
|
-
3.
|
|
54
|
-
4.
|
|
53
|
+
2. opens a model picker, then a thinking level picker, then a steering editor pre-filled with any text you typed after `/hand-off` (and its flags). Defaults are sensible: the current model and thinking level are preselected, so pressing Enter at each step keeps them. The model picker respects your session's model scoping (`--models` / `enabledModels`, the same set `/scoped-models` shows) and offers a Tab toggle between scoped and all models. In non-TUI modes it falls back to plain dialogs: the scoped list when configured, otherwise a two-level provider → model pick so the dialog never floods the screen
|
|
54
|
+
3. writes the hand-off prompt with the chosen writer — the live agent, or a single off-screen model call over a minimal reconstruction of the conversation
|
|
55
|
+
4. prepends a provenance comment naming the source session, starts a new session, applies the model and thinking level you picked, and inserts the prompt into the editor via `ctx.ui.setEditorText()`
|
|
55
56
|
5. stops there: the prompt sits in the editor for you to review, edit, and submit yourself
|
|
56
57
|
|
|
57
|
-
Cancelling any dialog aborts the hand-off before anything is sent
|
|
58
|
+
Cancelling any dialog aborts the hand-off before anything is sent. If the writer doesn't produce a prompt, the extension stays in the current session and reports why.
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
On the live-agent path the request itself is recorded in the current session's transcript; either way, the hand-off prompt sits in the new session's editor — it becomes the first user message once you review and submit it.
|
|
60
61
|
|
|
61
|
-
Two limitations worth knowing: if you queue another message while the agent is writing the hand-off prompt, the capture may pick up the wrong response — avoid queueing during generation. And `/clear` and `/hand-off` guard against running at the same time.
|
|
62
|
+
Two limitations worth knowing: on the live-agent path, if you queue another message while the agent is writing the hand-off prompt, the capture may pick up the wrong response — avoid queueing during generation. And `/clear` and `/hand-off` guard against running at the same time.
|
|
63
|
+
|
|
64
|
+
### Choosing the writer
|
|
65
|
+
|
|
66
|
+
There is deliberately no configured default: which writer to use depends on the conversation in front of you, so `/hand-off` asks every time.
|
|
67
|
+
|
|
68
|
+
| Writer | How it writes the prompt |
|
|
69
|
+
|---|---|
|
|
70
|
+
| **This agent — full context and tools** (first, so Enter keeps the old behavior) | The current agent writes its next message as the hand-off prompt. It sees the whole transcript and keeps its tools. |
|
|
71
|
+
| **Fresh pass — current model** | One out-of-band `modelRegistry.complete()` call over a *minimal reconstruction* of the conversation: your messages, the assistant's visible text, and one-line hints for tool calls. No tool output, no thinking, and a head+tail character budget. **No tool access.** |
|
|
72
|
+
| **Fresh pass — another model…** | Same, but you pick the model that writes the prompt first (a cheap, fast model is a good choice). |
|
|
73
|
+
|
|
74
|
+
The fresh pass exists because the live agent's prompt inherits the session's whole context window: an old or long conversation may no longer fit, and a session whose model lost auth can still be handed off. Because it never reads tool output, it is told to have the next agent verify anything it cannot see in the transcript.
|
|
75
|
+
|
|
76
|
+
When the writer model supports reasoning, the fresh pass also asks for its **thinking level**, with **provider default** preselected — so pressing Enter passes straight through. Set a standing preference with `handoff.summarizerThinkingLevel` in the config file; the picker then preselects that level instead, and the level is passed to the model as `reasoning`. Models without reasoning controls skip the dialog entirely.
|
|
77
|
+
|
|
78
|
+
Fresh pass flags and limits:
|
|
79
|
+
|
|
80
|
+
- `--fresh` skips the writer picker and uses the fresh pass; `--live` skips it and uses the current agent. Passing both is refused before any dialog opens.
|
|
81
|
+
- There is no command-arg model flag: if you want a specific model to write the prompt, set it in the [config file](#configuration) (or pick it from the menu).
|
|
82
|
+
- The source is the post-compaction view of the session, so a compacted conversation is summarized from its summary rather than from nothing.
|
|
83
|
+
- Trimming keeps the opening message, the newest turns, and a contiguous run after the opening message; the middle is replaced by `[... <n> turns omitted — <k> chars ...]` and you get a notification saying how much was dropped. Raise the budget with the settings below if that happens.
|
|
84
|
+
|
|
85
|
+
### Session provenance
|
|
86
|
+
|
|
87
|
+
Every hand-off prompt (from either writer) starts with a comment naming the session it came from:
|
|
88
|
+
|
|
89
|
+
```markdown
|
|
90
|
+
<!-- Handed off from pi session 01a18da2-4eff-73b9-846d-5fd4c156e52e -->
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It's a comment, so it doesn't read as part of the task and stays visible in the editor for you to check. The extension adds it after the prompt is written, so the writing model can't reword or drop it.
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
Optional settings live in `<agent-dir>/pi-clear.json` (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`), read once per session:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"handoff": {
|
|
102
|
+
"summarizerModel": "anthropic/claude-haiku-4-5",
|
|
103
|
+
"askSummarizerModel": true,
|
|
104
|
+
"summarizerThinkingLevel": "low",
|
|
105
|
+
"maxSourceChars": 200000
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- `summarizerModel` — preselected in the writer-model picker, and offered as its own `Fresh pass — <model>` entry in the writer picker.
|
|
111
|
+
- `askSummarizerModel: false` — with `summarizerModel` set, skip the writer-model picker entirely and always use that model.
|
|
112
|
+
- `summarizerThinkingLevel` — preselected in the writer's thinking-level picker: `"default"` (or `"off"`) for the provider default, otherwise `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. The dialog stays available either way.
|
|
113
|
+
- `maxSourceChars` — characters of conversation sent to the fresh pass (default `200000`, about 50k tokens). `0` disables the budget.
|
|
114
|
+
|
|
115
|
+
A malformed file is reported as a warning and ignored. The same knobs are available per pi launch as CLI flags:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
pi --hand-off-summarizer=anthropic/claude-haiku-4-5 --hand-off-max-source-chars=400000
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Precedence: pi CLI flag > config file > built-in default. On the command line, only the writer choice (`--fresh` / `--live`) is available — a model ID belongs in the config file or the picker.
|
|
62
122
|
|
|
63
123
|
## Notes
|
|
64
124
|
|
|
@@ -70,7 +130,9 @@ Two limitations worth knowing: if you queue another message while the agent is w
|
|
|
70
130
|
|
|
71
131
|
pi tears down the current runtime when a session is replaced, and extension instances are recreated for the new session. Session-bound objects can't be reused across that boundary, and the post-switch context has no model setter — but the replacement happens within the same process. So both commands capture state as plain data in a `globalThis` property, and the new session's extension instance consumes it on `session_start` (reason `new`) and applies it with `pi.setModel()` (plus `ctx.ui.setEditorText()` for the hand-off prompt). The handoff is consumed exactly once, so it can never leak into a later session.
|
|
72
132
|
|
|
73
|
-
`/hand-off` captures the agent's response by watching the `agent_end` event while the hand-off request is in flight, using per-instance state that dies with the session — only the final payload crosses into the new session via `globalThis`.
|
|
133
|
+
`/hand-off` captures the live agent's response by watching the `agent_end` event while the hand-off request is in flight, using per-instance state that dies with the session — only the final payload crosses into the new session via `globalThis`.
|
|
134
|
+
|
|
135
|
+
The fresh pass never sends a message to the agent at all: it calls `ctx.modelRegistry.complete()` once with a system prompt and a single user message built from `buildSessionProjection().messages`, runs the reply through the same validation as the live path (`stopReason === "stop"`, non-empty, surrounding fences stripped), and carries the result across the same `globalThis` handoff.
|
|
74
136
|
|
|
75
137
|
## Development
|
|
76
138
|
|
|
@@ -80,14 +142,17 @@ Load the extension directly for local testing:
|
|
|
80
142
|
pi -e ./index.ts
|
|
81
143
|
```
|
|
82
144
|
|
|
83
|
-
There are also
|
|
145
|
+
There are also smoke tests that verify the behavior end-to-end by starting pi in RPC mode:
|
|
84
146
|
|
|
85
147
|
```sh
|
|
86
148
|
python3 scripts/rpc-smoke-test.py # /clear keeps model + thinking level
|
|
87
|
-
python3 scripts/rpc-handoff-smoke-test.py # /hand-off
|
|
149
|
+
python3 scripts/rpc-handoff-smoke-test.py # /hand-off, both writers, dialogs included
|
|
150
|
+
node --test scripts/model-picker.test.mjs # offline: model picker behavior
|
|
151
|
+
node --test scripts/minimal-transcript.test.mjs # offline: fresh-pass transcript + budget
|
|
152
|
+
node --test scripts/fresh-pass-flow.test.mjs # offline: /hand-off flow, both writers
|
|
88
153
|
```
|
|
89
154
|
|
|
90
|
-
|
|
155
|
+
The RPC smoke tests require at least two models with configured auth, and they make **real, billed model requests**. The `node --test` scripts need neither.
|
|
91
156
|
|
|
92
157
|
## Related extensions
|
|
93
158
|
|
package/index.ts
CHANGED
|
@@ -15,17 +15,32 @@
|
|
|
15
15
|
*
|
|
16
16
|
* `/hand-off`: hand the conversation to another model.
|
|
17
17
|
*
|
|
18
|
-
* 1. The user runs `/hand-off
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
18
|
+
* 1. The user runs `/hand-off`, optionally with `--fresh` or `--live` and
|
|
19
|
+
* steering text after them.
|
|
20
|
+
* 2. A dialog flow asks who writes the hand-off prompt, then collects the
|
|
21
|
+
* destination model and thinking level, then opens an editor pre-filled
|
|
22
|
+
* with the remaining command args.
|
|
23
|
+
* 3. The hand-off prompt is written by one of two writers:
|
|
24
|
+
* - the **live agent** (default, first in the picker): the extension sends
|
|
25
|
+
* the current agent a custom message asking it to write its next message
|
|
26
|
+
* as the hand-off prompt, and `agent_end` captures the final assistant
|
|
27
|
+
* message. It sees the whole transcript and keeps tool access.
|
|
28
|
+
* - the **fresh pass**: a single out-of-band `modelRegistry.complete()`
|
|
29
|
+
* call over a *minimal reconstruction* of the conversation (no tool
|
|
30
|
+
* output, no thinking, head+tail budget), so long or stale sessions can
|
|
31
|
+
* still be handed off, and a cheap model can do the writing. It has no
|
|
32
|
+
* tool access, and its thinking level is chosen separately (provider
|
|
33
|
+
* default preselected).
|
|
34
|
+
* 4. The extension prepends a provenance comment naming the source session
|
|
35
|
+
* (both writers), replaces the session, applies the chosen model and
|
|
36
|
+
* thinking level, and inserts the prompt into the editor.
|
|
27
37
|
* 5. The user reviews, edits, and submits the prompt themselves.
|
|
28
38
|
*
|
|
39
|
+
* Settings live in `<agent-dir>/pi-clear.json` (read once per
|
|
40
|
+
* `session_start`); CLI flags `--hand-off-max-source-chars` and
|
|
41
|
+
* `--hand-off-summarizer` override the file. Without a configured writer
|
|
42
|
+
* model, `/hand-off` just asks: there is no command-arg model flag.
|
|
43
|
+
*
|
|
29
44
|
* Mechanism:
|
|
30
45
|
* Both commands capture state as plain data into a `globalThis` property.
|
|
31
46
|
* Session replacement (`ctx.newSession()`) tears down the current runtime
|
|
@@ -40,11 +55,15 @@
|
|
|
40
55
|
* into the editor with `ctx.ui.setEditorText()`.
|
|
41
56
|
*/
|
|
42
57
|
|
|
58
|
+
import { randomUUID } from "node:crypto";
|
|
59
|
+
import { readFileSync } from "node:fs";
|
|
60
|
+
import { join } from "node:path";
|
|
43
61
|
import type {
|
|
44
62
|
ExtensionAPI,
|
|
63
|
+
ExtensionCommandContext,
|
|
45
64
|
ExtensionContext,
|
|
46
65
|
} from "@earendil-works/pi-coding-agent";
|
|
47
|
-
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
66
|
+
import { BorderedLoader, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
48
67
|
import { Container, fuzzyFilter, Input, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
|
|
49
68
|
|
|
50
69
|
// Model and thinking-level types, derived from the API surface because the
|
|
@@ -87,6 +106,14 @@ const THINKING_LEVELS: ThinkingLevel[] = [
|
|
|
87
106
|
|
|
88
107
|
const HANDOFF_CUSTOM_TYPE = "pi-clear:hand-off-request";
|
|
89
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Reasoning levels offered for the fresh pass. "off" is not offered: it means
|
|
111
|
+
* the same thing as the provider default here, because the option is omitted
|
|
112
|
+
* either way.
|
|
113
|
+
*/
|
|
114
|
+
const WRITER_REASONING_LEVELS: ThinkingLevel[] = THINKING_LEVELS.filter((level) => level !== "off");
|
|
115
|
+
const WRITER_DEFAULT_REASONING_LABEL = "provider default";
|
|
116
|
+
|
|
90
117
|
function sameModel(a: AnyModel | undefined, b: AnyModel | undefined): boolean {
|
|
91
118
|
return !!a && !!b && a.provider === b.provider && a.id === b.id;
|
|
92
119
|
}
|
|
@@ -120,6 +147,14 @@ interface PickedModel {
|
|
|
120
147
|
thinkingLevel?: ThinkingLevel;
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
/** How the model pickers label themselves and which model they treat as current. */
|
|
151
|
+
interface ModelPickerOptions {
|
|
152
|
+
/** Preselected model; defaults to the session's model. */
|
|
153
|
+
current?: AnyModel;
|
|
154
|
+
title?: string;
|
|
155
|
+
providerTitle?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
123
158
|
async function selectModel(
|
|
124
159
|
ctx: ExtensionContext,
|
|
125
160
|
models: AnyModel[],
|
|
@@ -151,20 +186,54 @@ async function selectThinkingLevel(ctx: ExtensionContext): Promise<ThinkingLevel
|
|
|
151
186
|
return index === -1 ? undefined : ordered[index];
|
|
152
187
|
}
|
|
153
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Thinking level for the fresh pass writer. The provider default comes first and
|
|
191
|
+
* is preselected unless the config file pins a level, so pressing Enter keeps
|
|
192
|
+
* today's behavior and the dialog is a no-op for most runs.
|
|
193
|
+
*/
|
|
194
|
+
async function selectWriterReasoningLevel(
|
|
195
|
+
ctx: ExtensionContext,
|
|
196
|
+
preselected: ThinkingLevel | undefined,
|
|
197
|
+
): Promise<{ reasoning?: ThinkingLevel } | undefined> {
|
|
198
|
+
const ordered = preselected
|
|
199
|
+
? [preselected, ...WRITER_REASONING_LEVELS.filter((level) => level !== preselected)]
|
|
200
|
+
: [...WRITER_REASONING_LEVELS];
|
|
201
|
+
const options: Array<{ reasoning?: ThinkingLevel; label: string }> = ordered.map((level) => ({
|
|
202
|
+
reasoning: level,
|
|
203
|
+
label: `${level}${level === preselected ? " (current)" : ""}`,
|
|
204
|
+
}));
|
|
205
|
+
if (preselected) {
|
|
206
|
+
options.push({ label: WRITER_DEFAULT_REASONING_LABEL });
|
|
207
|
+
} else {
|
|
208
|
+
options.unshift({ label: `${WRITER_DEFAULT_REASONING_LABEL} (current)` });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const labels = options.map((option) => option.label);
|
|
212
|
+
const chosen = await ctx.ui.select("Thinking level for the hand-off writer", labels);
|
|
213
|
+
if (chosen === undefined) return undefined;
|
|
214
|
+
const index = labelIndex(chosen, labels);
|
|
215
|
+
return index === -1 ? undefined : { reasoning: options[index].reasoning };
|
|
216
|
+
}
|
|
217
|
+
|
|
154
218
|
/**
|
|
155
219
|
* Non-interactive fallback (RPC mode has no custom components): scoped
|
|
156
220
|
* models in one dialog when scoping is configured, otherwise a two-level
|
|
157
221
|
* provider → model pick over the auth-configured catalogue.
|
|
158
222
|
*/
|
|
159
|
-
async function pickTargetModelFallback(
|
|
160
|
-
|
|
223
|
+
async function pickTargetModelFallback(
|
|
224
|
+
ctx: ExtensionContext,
|
|
225
|
+
options: ModelPickerOptions = {},
|
|
226
|
+
): Promise<PickedModel | undefined> {
|
|
227
|
+
const current = options.current ?? ctx.model;
|
|
228
|
+
const title = options.title ?? "Hand off to model";
|
|
229
|
+
const providerTitle = options.providerTitle ?? "Hand off to provider";
|
|
161
230
|
const scoped = ctx.scopedModels.map((scoped) => scoped.model);
|
|
162
231
|
|
|
163
232
|
if (scoped.length > 0) {
|
|
164
233
|
// Keep the current model selectable and preselected even when scoping
|
|
165
234
|
// excludes it.
|
|
166
235
|
const models = current && !scoped.some((m) => sameModel(m, current)) ? [current, ...scoped] : scoped;
|
|
167
|
-
const model = await selectModel(ctx, models,
|
|
236
|
+
const model = await selectModel(ctx, models, title, current);
|
|
168
237
|
return model ? { model } : undefined;
|
|
169
238
|
}
|
|
170
239
|
|
|
@@ -190,13 +259,13 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
|
|
|
190
259
|
|
|
191
260
|
const providerLabels = providers.map((p) => `${p} (${byProvider.get(p)!.length} models)`);
|
|
192
261
|
const providerChoice =
|
|
193
|
-
providers.length === 1 ? providerLabels[0] : await ctx.ui.select(
|
|
262
|
+
providers.length === 1 ? providerLabels[0] : await ctx.ui.select(providerTitle, providerLabels);
|
|
194
263
|
if (providerChoice === undefined) return undefined;
|
|
195
264
|
const providerIndex = labelIndex(providerChoice, providerLabels);
|
|
196
265
|
if (providerIndex === -1) return undefined;
|
|
197
266
|
const providerName = providers[providerIndex];
|
|
198
267
|
|
|
199
|
-
const model = await selectModel(ctx, byProvider.get(providerName) ?? [],
|
|
268
|
+
const model = await selectModel(ctx, byProvider.get(providerName) ?? [], title, current);
|
|
200
269
|
return model ? { model } : undefined;
|
|
201
270
|
}
|
|
202
271
|
|
|
@@ -204,8 +273,12 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
|
|
|
204
273
|
* `/model`-style model picker (TUI mode only): a searchable list with Tab to
|
|
205
274
|
* toggle between the session's scoped models and the full catalogue.
|
|
206
275
|
*/
|
|
207
|
-
async function pickTargetModelTui(
|
|
208
|
-
|
|
276
|
+
async function pickTargetModelTui(
|
|
277
|
+
ctx: ExtensionContext,
|
|
278
|
+
options: ModelPickerOptions = {},
|
|
279
|
+
): Promise<PickedModel | undefined> {
|
|
280
|
+
const current = options.current ?? ctx.model;
|
|
281
|
+
const title = options.title ?? "Hand off to model";
|
|
209
282
|
const allEntries: PickedModel[] = ctx.modelRegistry.getAvailable().map((model) => ({ model }));
|
|
210
283
|
if (allEntries.length === 0) {
|
|
211
284
|
ctx.ui.notify("No models available to hand off to", "error");
|
|
@@ -279,7 +352,7 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
|
|
|
279
352
|
theme.fg(
|
|
280
353
|
"accent",
|
|
281
354
|
theme.bold(
|
|
282
|
-
|
|
355
|
+
`${title} — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
|
|
283
356
|
),
|
|
284
357
|
),
|
|
285
358
|
);
|
|
@@ -331,12 +404,17 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
|
|
|
331
404
|
*
|
|
332
405
|
* TUI mode gets a `/model`-style searchable picker with Tab to toggle
|
|
333
406
|
* between scoped and all models; other modes fall back to plain dialogs.
|
|
407
|
+
* The same picker also chooses the fresh pass's writer model, with the
|
|
408
|
+
* title and preselected model supplied by the caller.
|
|
334
409
|
*/
|
|
335
|
-
async function pickTargetModel(
|
|
410
|
+
async function pickTargetModel(
|
|
411
|
+
ctx: ExtensionContext,
|
|
412
|
+
options: ModelPickerOptions = {},
|
|
413
|
+
): Promise<PickedModel | undefined> {
|
|
336
414
|
if (ctx.mode === "tui") {
|
|
337
|
-
return pickTargetModelTui(ctx);
|
|
415
|
+
return pickTargetModelTui(ctx, options);
|
|
338
416
|
}
|
|
339
|
-
return pickTargetModelFallback(ctx);
|
|
417
|
+
return pickTargetModelFallback(ctx, options);
|
|
340
418
|
}
|
|
341
419
|
|
|
342
420
|
/** Extract the text content of an agent message, ignoring thinking blocks and tool calls. */
|
|
@@ -371,12 +449,679 @@ function buildHandoffRequest(steering: string): string {
|
|
|
371
449
|
return lines.join("\n");
|
|
372
450
|
}
|
|
373
451
|
|
|
452
|
+
// ============================================================================
|
|
453
|
+
// Fresh pass: one out-of-band model call over a minimal reconstruction
|
|
454
|
+
// ============================================================================
|
|
455
|
+
|
|
456
|
+
const HANDOFF_SYSTEM_PROMPT = `You are a context transfer assistant. You will receive a minimal reconstruction of a long conversation between a user and an AI agent, and you must write the hand-off message that starts the next session.
|
|
457
|
+
|
|
458
|
+
Do not continue the conversation. Do not answer questions in it.
|
|
459
|
+
|
|
460
|
+
The reconstruction is lossy: tool output, thinking, and file contents are omitted, and a middle section may be elided. Do not assert file contents or the state of the working tree from memory — when something cannot be verified from the transcript, tell the next agent to verify it.`;
|
|
461
|
+
|
|
462
|
+
const DEFAULT_COVERAGE =
|
|
463
|
+
"Cover the conversation thoroughly: the current task and goal, what has been done so far (with concrete file paths, commands, and decisions), and exactly what to do next.";
|
|
464
|
+
|
|
465
|
+
/** Characters of conversation sent to the fresh pass (~50k tokens at 200k). */
|
|
466
|
+
const DEFAULT_MAX_SOURCE_CHARS = 200_000;
|
|
467
|
+
/** Share of the budget kept from the newest end of the conversation. */
|
|
468
|
+
const TAIL_SOURCE_SHARE = 0.6;
|
|
469
|
+
/** Share of the budget kept contiguously after the opening message. */
|
|
470
|
+
const HEAD_SOURCE_SHARE = 0.4;
|
|
471
|
+
const USER_MESSAGE_MAX_CHARS = 8_000;
|
|
472
|
+
const ASSISTANT_MESSAGE_MAX_CHARS = 4_000;
|
|
473
|
+
const SUMMARY_MAX_CHARS = 8_000;
|
|
474
|
+
const NOTE_MAX_CHARS = 2_000;
|
|
475
|
+
const HINT_MAX_CHARS = 200;
|
|
476
|
+
|
|
477
|
+
/** A projected session message, viewed loosely: only fields the serializer reads. */
|
|
478
|
+
interface TranscriptSource {
|
|
479
|
+
role?: string;
|
|
480
|
+
content?: unknown;
|
|
481
|
+
customType?: string;
|
|
482
|
+
summary?: string;
|
|
483
|
+
command?: string;
|
|
484
|
+
/** `!!` bash executions: never sent to a model, so never sent to the writer. */
|
|
485
|
+
excludeFromContext?: boolean;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
interface ContentBlock {
|
|
489
|
+
type?: string;
|
|
490
|
+
text?: string;
|
|
491
|
+
name?: string;
|
|
492
|
+
arguments?: unknown;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** One rendered unit of the minimal transcript; the budget never splits a block. */
|
|
496
|
+
interface TranscriptBlock {
|
|
497
|
+
text: string;
|
|
498
|
+
/** Marks the opening user message, which the budget always keeps. */
|
|
499
|
+
anchor?: boolean;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
interface TranscriptResult {
|
|
503
|
+
text: string;
|
|
504
|
+
/** Blocks dropped from the middle of the conversation, 0 when nothing was dropped. */
|
|
505
|
+
omittedTurns: number;
|
|
506
|
+
/** Characters those blocks would have contributed. */
|
|
507
|
+
droppedChars: number;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function contentBlocks(content: unknown): ContentBlock[] {
|
|
511
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
512
|
+
if (!Array.isArray(content)) return [];
|
|
513
|
+
const blocks: ContentBlock[] = [];
|
|
514
|
+
for (const block of content) {
|
|
515
|
+
if (typeof block === "object" && block !== null) blocks.push(block as ContentBlock);
|
|
516
|
+
}
|
|
517
|
+
return blocks;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Visible text only: thinking, tool calls, and images are dropped. */
|
|
521
|
+
function visibleText(content: unknown): string {
|
|
522
|
+
return contentBlocks(content)
|
|
523
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
524
|
+
.map((block) => block.text!.trim())
|
|
525
|
+
.filter((text) => text.length > 0)
|
|
526
|
+
.join("\n");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function toolCallsOf(content: unknown): ContentBlock[] {
|
|
530
|
+
return contentBlocks(content).filter(
|
|
531
|
+
(block) => block.type === "toolCall" && typeof block.name === "string",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function stringArg(args: unknown, key: string): string | undefined {
|
|
536
|
+
if (typeof args !== "object" || args === null) return undefined;
|
|
537
|
+
const value = (args as Record<string, unknown>)[key];
|
|
538
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function firstStringArg(args: unknown): string | undefined {
|
|
542
|
+
if (typeof args !== "object" || args === null) return undefined;
|
|
543
|
+
for (const value of Object.values(args as Record<string, unknown>)) {
|
|
544
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
545
|
+
}
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Keep both ends of an over-long text, and say how much was cut. */
|
|
550
|
+
function truncateMiddle(text: string, maxChars: number): string {
|
|
551
|
+
if (maxChars <= 0 || text.length <= maxChars) return text;
|
|
552
|
+
const headChars = Math.floor(maxChars * TAIL_SOURCE_SHARE);
|
|
553
|
+
const tailChars = maxChars - headChars;
|
|
554
|
+
const removed = text.length - maxChars;
|
|
555
|
+
return `${text.slice(0, headChars)}\n[... ${removed} chars truncated ...]\n${text.slice(text.length - tailChars)}`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** One line naming a tool call and its target — never its result. */
|
|
559
|
+
export function formatToolHint(name: string, args: unknown): string {
|
|
560
|
+
const path = stringArg(args, "path");
|
|
561
|
+
switch (name) {
|
|
562
|
+
case "read":
|
|
563
|
+
case "edit":
|
|
564
|
+
case "write":
|
|
565
|
+
return path ? `${name} ${path}` : name;
|
|
566
|
+
case "bash": {
|
|
567
|
+
const command = stringArg(args, "command")?.split("\n")[0].trim() ?? "";
|
|
568
|
+
return command ? `bash: ${command.slice(0, HINT_MAX_CHARS)}` : "bash";
|
|
569
|
+
}
|
|
570
|
+
case "grep": {
|
|
571
|
+
const pattern = stringArg(args, "pattern");
|
|
572
|
+
if (!pattern) return "grep";
|
|
573
|
+
return path ? `grep: /${pattern}/ in ${path}` : `grep: /${pattern}/`;
|
|
574
|
+
}
|
|
575
|
+
case "ls":
|
|
576
|
+
return path ? `ls: ${path}` : "ls";
|
|
577
|
+
case "find": {
|
|
578
|
+
const pattern = stringArg(args, "pattern");
|
|
579
|
+
if (pattern && path) return `find: ${pattern} in ${path}`;
|
|
580
|
+
if (path) return `find: ${path}`;
|
|
581
|
+
return pattern ? `find: ${pattern}` : "find";
|
|
582
|
+
}
|
|
583
|
+
default: {
|
|
584
|
+
const detail = path ?? firstStringArg(args);
|
|
585
|
+
return detail ? `tool: ${name} ${detail.slice(0, HINT_MAX_CHARS)}` : `tool: ${name}`;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
interface FileLists {
|
|
591
|
+
readFiles: string[];
|
|
592
|
+
modifiedFiles: string[];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Same rules pi uses for compaction: read-only files, then written/edited ones. */
|
|
596
|
+
function fileListsFromMessages(messages: readonly TranscriptSource[]): FileLists {
|
|
597
|
+
const read = new Set<string>();
|
|
598
|
+
const written = new Set<string>();
|
|
599
|
+
const edited = new Set<string>();
|
|
600
|
+
for (const message of messages) {
|
|
601
|
+
if (message.role !== "assistant") continue;
|
|
602
|
+
for (const call of toolCallsOf(message.content)) {
|
|
603
|
+
const path = stringArg(call.arguments, "path");
|
|
604
|
+
if (!path) continue;
|
|
605
|
+
if (call.name === "read") read.add(path);
|
|
606
|
+
else if (call.name === "write") written.add(path);
|
|
607
|
+
else if (call.name === "edit") edited.add(path);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const modified = new Set([...edited, ...written]);
|
|
611
|
+
return {
|
|
612
|
+
readFiles: [...read].filter((file) => !modified.has(file)).sort(),
|
|
613
|
+
modifiedFiles: [...modified].sort(),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function formatFileLists(lists: FileLists): string {
|
|
618
|
+
const sections: string[] = [];
|
|
619
|
+
if (lists.readFiles.length > 0) sections.push(`<read-files>\n${lists.readFiles.join("\n")}\n</read-files>`);
|
|
620
|
+
if (lists.modifiedFiles.length > 0) {
|
|
621
|
+
sections.push(`<modified-files>\n${lists.modifiedFiles.join("\n")}\n</modified-files>`);
|
|
622
|
+
}
|
|
623
|
+
return sections.join("\n\n");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Render the conversation as labelled blocks: visible text, tool-call hints,
|
|
628
|
+
* summaries, and notes. Tool results, thinking, and stale hand-off requests
|
|
629
|
+
* are dropped outright.
|
|
630
|
+
*/
|
|
631
|
+
export function renderTranscriptBlocks(messages: readonly TranscriptSource[]): TranscriptBlock[] {
|
|
632
|
+
const blocks: TranscriptBlock[] = [];
|
|
633
|
+
let sawUserMessage = false;
|
|
634
|
+
|
|
635
|
+
for (const message of messages) {
|
|
636
|
+
switch (message.role) {
|
|
637
|
+
case "user": {
|
|
638
|
+
const text = visibleText(message.content);
|
|
639
|
+
if (!text) break;
|
|
640
|
+
blocks.push({
|
|
641
|
+
text: `[User]: ${truncateMiddle(text, USER_MESSAGE_MAX_CHARS)}`,
|
|
642
|
+
anchor: !sawUserMessage,
|
|
643
|
+
});
|
|
644
|
+
sawUserMessage = true;
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
case "assistant": {
|
|
648
|
+
const text = visibleText(message.content);
|
|
649
|
+
if (text) {
|
|
650
|
+
blocks.push({ text: `[Assistant]: ${truncateMiddle(text, ASSISTANT_MESSAGE_MAX_CHARS)}` });
|
|
651
|
+
}
|
|
652
|
+
const hints = toolCallsOf(message.content).map((call) => formatToolHint(call.name!, call.arguments));
|
|
653
|
+
if (hints.length > 0) blocks.push({ text: `[tools]:\n${hints.join("\n")}` });
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
case "toolResult":
|
|
657
|
+
// Tool output never shapes the hand-off: it is the bulk of a transcript
|
|
658
|
+
// and the writer is told to have the next agent verify state itself.
|
|
659
|
+
break;
|
|
660
|
+
case "custom": {
|
|
661
|
+
if (message.customType === HANDOFF_CUSTOM_TYPE) break;
|
|
662
|
+
const text = visibleText(message.content);
|
|
663
|
+
if (text) blocks.push({ text: `[Note]: ${truncateMiddle(text, NOTE_MAX_CHARS)}` });
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
case "compactionSummary":
|
|
667
|
+
case "branchSummary": {
|
|
668
|
+
const summary = typeof message.summary === "string" ? message.summary.trim() : "";
|
|
669
|
+
if (summary) {
|
|
670
|
+
blocks.push({
|
|
671
|
+
text: `[Summary of earlier conversation]: ${truncateMiddle(summary, SUMMARY_MAX_CHARS)}`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case "bashExecution": {
|
|
677
|
+
if (message.excludeFromContext) break;
|
|
678
|
+
const command = typeof message.command === "string" ? message.command.trim() : "";
|
|
679
|
+
if (command) blocks.push({ text: `[User ran]: ${truncateMiddle(command, NOTE_MAX_CHARS)}` });
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
default:
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return blocks;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Keep the head and the tail of the conversation within `maxChars`, dropping
|
|
692
|
+
* the middle and leaving an elision marker in its place. `maxChars <= 0`
|
|
693
|
+
* disables the budget.
|
|
694
|
+
*/
|
|
695
|
+
export function applyTranscriptBudget(blocks: readonly TranscriptBlock[], maxChars: number): TranscriptResult {
|
|
696
|
+
const parts = blocks.map((block) => block.text);
|
|
697
|
+
const joinBlocks = (items: string[]) => items.join("\n\n");
|
|
698
|
+
const full = joinBlocks(parts);
|
|
699
|
+
if (maxChars <= 0 || full.length <= maxChars) {
|
|
700
|
+
return { text: full, omittedTurns: 0, droppedChars: 0 };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Head: everything up to and including the opening user message, always kept.
|
|
704
|
+
const anchor = blocks.findIndex((block) => block.anchor);
|
|
705
|
+
const anchorIndex = anchor === -1 ? 0 : anchor;
|
|
706
|
+
let headChars = parts.slice(0, anchorIndex + 1).reduce((sum, part) => sum + part.length + 2, 0);
|
|
707
|
+
|
|
708
|
+
// Tail: newest first, up to 60% of the budget.
|
|
709
|
+
const tailBudget = Math.floor(maxChars * TAIL_SOURCE_SHARE);
|
|
710
|
+
let tailStart = parts.length;
|
|
711
|
+
let tailChars = 0;
|
|
712
|
+
for (let i = parts.length - 1; i > anchorIndex; i--) {
|
|
713
|
+
const cost = parts[i].length + 2;
|
|
714
|
+
if (tailChars + cost > tailBudget) break;
|
|
715
|
+
tailChars += cost;
|
|
716
|
+
tailStart = i;
|
|
717
|
+
}
|
|
718
|
+
// Never drop the newest block: it is the most valuable one, and message-level
|
|
719
|
+
// caps normally keep it well inside the tail budget.
|
|
720
|
+
if (tailStart === parts.length && parts.length > anchorIndex + 1) tailStart = parts.length - 1;
|
|
721
|
+
|
|
722
|
+
// Gap: oldest first, contiguous from just after the head, while the head
|
|
723
|
+
// budget lasts.
|
|
724
|
+
const headBudget = Math.floor(maxChars * HEAD_SOURCE_SHARE);
|
|
725
|
+
let gapEnd = anchorIndex;
|
|
726
|
+
while (gapEnd + 1 < tailStart && headChars + parts[gapEnd + 1].length + 2 <= headBudget) {
|
|
727
|
+
headChars += parts[gapEnd + 1].length + 2;
|
|
728
|
+
gapEnd += 1;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const omitted = parts.slice(gapEnd + 1, tailStart);
|
|
732
|
+
const droppedChars = omitted.reduce((sum, part) => sum + part.length + 2, 0);
|
|
733
|
+
const kept = [...parts.slice(0, gapEnd + 1)];
|
|
734
|
+
if (omitted.length > 0) kept.push(`[... ${omitted.length} turns omitted — ${droppedChars} chars ...]`);
|
|
735
|
+
kept.push(...parts.slice(tailStart));
|
|
736
|
+
|
|
737
|
+
return { text: joinBlocks(kept), omittedTurns: omitted.length, droppedChars };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Minimal reconstruction of the conversation plus the file-operation footer. */
|
|
741
|
+
export function buildMinimalTranscript(
|
|
742
|
+
messages: readonly TranscriptSource[],
|
|
743
|
+
maxChars: number = DEFAULT_MAX_SOURCE_CHARS,
|
|
744
|
+
): TranscriptResult {
|
|
745
|
+
const budget = applyTranscriptBudget(renderTranscriptBlocks(messages), maxChars);
|
|
746
|
+
const fileLists = formatFileLists(fileListsFromMessages(messages));
|
|
747
|
+
if (!fileLists) return budget;
|
|
748
|
+
return { ...budget, text: budget.text ? `${budget.text}\n\n${fileLists}` : fileLists };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** The single user message of the fresh pass. */
|
|
752
|
+
export function buildFreshPassUserMessage(transcript: string, steering: string): string {
|
|
753
|
+
return [
|
|
754
|
+
"## Conversation (minimal reconstruction)",
|
|
755
|
+
transcript,
|
|
756
|
+
"",
|
|
757
|
+
"## What the hand-off must cover",
|
|
758
|
+
steering.trim() || DEFAULT_COVERAGE,
|
|
759
|
+
"",
|
|
760
|
+
"Requirements for your output:",
|
|
761
|
+
'- Written as instructions to the next agent ("you"), not a report about the past.',
|
|
762
|
+
"- Assume zero prior context: current task and goal, what was done so far with concrete file paths, commands, and decisions, and exactly what to do next.",
|
|
763
|
+
"- Name the files worth opening first.",
|
|
764
|
+
"- Reply with ONLY the hand-off message: no preamble, no postamble, no fences.",
|
|
765
|
+
].join("\n");
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ============================================================================
|
|
769
|
+
// Session provenance (both writers)
|
|
770
|
+
// ============================================================================
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* HTML comment naming the session a hand-off came from, so the next agent can
|
|
774
|
+
* look it up when the prompt is not enough. It is a comment, not prose, so it
|
|
775
|
+
* does not read as part of the task, and it stays visible in the editor.
|
|
776
|
+
*/
|
|
777
|
+
export function buildProvenanceComment(sessionId: string | undefined): string {
|
|
778
|
+
const id = sessionId?.trim();
|
|
779
|
+
return id ? `<!-- Handed off from pi session ${id} -->` : "";
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Prepends the provenance comment; called right before `writeHandoff()`. */
|
|
783
|
+
function withSessionProvenance(prompt: string, ctx: ExtensionContext): string {
|
|
784
|
+
const comment = buildProvenanceComment(ctx.sessionManager.getSessionId());
|
|
785
|
+
return comment ? `${comment}\n\n${prompt}` : prompt;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ============================================================================
|
|
789
|
+
// Fresh pass generation
|
|
790
|
+
// ============================================================================
|
|
791
|
+
|
|
792
|
+
/** Options for the one-shot writer call; `reasoning` is only set when supported. */
|
|
793
|
+
interface WriterRequestOptions {
|
|
794
|
+
signal?: AbortSignal;
|
|
795
|
+
cacheRetention: "none";
|
|
796
|
+
sessionId: string;
|
|
797
|
+
reasoning?: ThinkingLevel;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
interface WriterOutcome {
|
|
801
|
+
prompt?: string;
|
|
802
|
+
error?: string;
|
|
803
|
+
cancelled?: boolean;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Strip a fence that wraps the whole reply, leaving inner code blocks alone. */
|
|
807
|
+
function stripSurroundingFence(text: string): string {
|
|
808
|
+
const trimmed = text.trim();
|
|
809
|
+
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return trimmed;
|
|
810
|
+
const withoutOpen = trimmed.replace(/^```[^\n]*\n?/, "");
|
|
811
|
+
return withoutOpen.slice(0, withoutOpen.lastIndexOf("```")).trim();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function checkWrittenPrompt(response: { stopReason?: string; errorMessage?: string; content?: unknown }): WriterOutcome {
|
|
815
|
+
if (response.stopReason === "aborted") return { cancelled: true };
|
|
816
|
+
if (response.stopReason !== "stop") {
|
|
817
|
+
const detail = response.errorMessage ? `: ${response.errorMessage}` : "";
|
|
818
|
+
return { error: `stopped with reason "${response.stopReason}"${detail}` };
|
|
819
|
+
}
|
|
820
|
+
const text = stripSurroundingFence(visibleText(response.content));
|
|
821
|
+
if (!text) return { error: "returned an empty prompt" };
|
|
822
|
+
return { prompt: text };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function reportWriterOutcome(ctx: ExtensionContext, outcome: WriterOutcome, label: string): string | undefined {
|
|
826
|
+
if (outcome.prompt) return outcome.prompt;
|
|
827
|
+
if (outcome.cancelled) {
|
|
828
|
+
ctx.ui.notify("Hand-off cancelled", "info");
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
ctx.ui.notify(
|
|
832
|
+
`Hand-off writer (${label}) ${outcome.error ?? "failed"}; staying in this session`,
|
|
833
|
+
"error",
|
|
834
|
+
);
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Run the fresh pass: one `complete()` call over the minimal transcript.
|
|
840
|
+
* TUI mode gets a cancellable loader; other modes get a notification.
|
|
841
|
+
*/
|
|
842
|
+
async function generateFreshPassPrompt(
|
|
843
|
+
ctx: ExtensionContext,
|
|
844
|
+
model: AnyModel,
|
|
845
|
+
transcript: string,
|
|
846
|
+
steering: string,
|
|
847
|
+
reasoning: ThinkingLevel | undefined,
|
|
848
|
+
): Promise<string | undefined> {
|
|
849
|
+
const userMessage = {
|
|
850
|
+
role: "user" as const,
|
|
851
|
+
content: [{ type: "text" as const, text: buildFreshPassUserMessage(transcript, steering) }],
|
|
852
|
+
timestamp: Date.now(),
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const attempt = async (signal?: AbortSignal): Promise<WriterOutcome> => {
|
|
856
|
+
const options: WriterRequestOptions = { signal, cacheRetention: "none", sessionId: randomUUID() };
|
|
857
|
+
// Providers default when the option is absent, and models without
|
|
858
|
+
// reasoning controls would reject it; pi's own summarization does the
|
|
859
|
+
// same check before setting the level.
|
|
860
|
+
if (reasoning && model.reasoning) options.reasoning = reasoning;
|
|
861
|
+
try {
|
|
862
|
+
const response = await ctx.modelRegistry.complete(
|
|
863
|
+
model,
|
|
864
|
+
{ systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
|
|
865
|
+
options,
|
|
866
|
+
);
|
|
867
|
+
return checkWrittenPrompt(response);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
return { error: `failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const label = `${model.provider}/${model.id}`;
|
|
874
|
+
|
|
875
|
+
if (ctx.mode !== "tui") {
|
|
876
|
+
ctx.ui.notify(`Writing the hand-off prompt with ${label}…`, "info");
|
|
877
|
+
return reportWriterOutcome(ctx, await attempt(), label);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
let aborted = false;
|
|
881
|
+
let outcome: WriterOutcome | undefined;
|
|
882
|
+
const prompt = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
883
|
+
// Abort and completion can race; `done` must only be called once.
|
|
884
|
+
let finished = false;
|
|
885
|
+
const finish = (value: string | null) => {
|
|
886
|
+
if (finished) return;
|
|
887
|
+
finished = true;
|
|
888
|
+
done(value);
|
|
889
|
+
};
|
|
890
|
+
const loader = new BorderedLoader(tui, theme, `Writing the hand-off prompt with ${label}…`);
|
|
891
|
+
loader.onAbort = () => {
|
|
892
|
+
aborted = true;
|
|
893
|
+
finish(null);
|
|
894
|
+
};
|
|
895
|
+
void attempt(loader.signal).then((result) => {
|
|
896
|
+
outcome = result;
|
|
897
|
+
finish(result.prompt ?? null);
|
|
898
|
+
});
|
|
899
|
+
return loader;
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
if (prompt) return prompt;
|
|
903
|
+
if (aborted) {
|
|
904
|
+
ctx.ui.notify("Hand-off cancelled", "info");
|
|
905
|
+
return undefined;
|
|
906
|
+
}
|
|
907
|
+
return reportWriterOutcome(ctx, outcome ?? { error: "returned no prompt" }, label);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ============================================================================
|
|
911
|
+
// Command arguments, writer choice, and settings
|
|
912
|
+
// ============================================================================
|
|
913
|
+
|
|
914
|
+
interface ParsedHandoffArgs {
|
|
915
|
+
writer?: "live" | "fresh";
|
|
916
|
+
/** Everything after the leading flags: the steering-editor prefill. */
|
|
917
|
+
steering: string;
|
|
918
|
+
error?: string;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Parse leading `/hand-off` flags; the first unrecognized token starts the steering text. */
|
|
922
|
+
export function parseHandoffArgs(args: string): ParsedHandoffArgs {
|
|
923
|
+
const tokens = args.split(/\s+/).filter(Boolean);
|
|
924
|
+
const parsed: ParsedHandoffArgs = { steering: "" };
|
|
925
|
+
let index = 0;
|
|
926
|
+
|
|
927
|
+
for (; index < tokens.length; index++) {
|
|
928
|
+
const token = tokens[index];
|
|
929
|
+
if (token === "--fresh" || token === "--live") {
|
|
930
|
+
const writer = token === "--fresh" ? "fresh" : "live";
|
|
931
|
+
if (parsed.writer && parsed.writer !== writer) {
|
|
932
|
+
return { ...parsed, error: "--fresh and --live cannot be combined; nothing was sent" };
|
|
933
|
+
}
|
|
934
|
+
parsed.writer = writer;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
parsed.steering = tokens.slice(index).join(" ");
|
|
941
|
+
return parsed;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export interface HandoffSettings {
|
|
945
|
+
summarizerModel?: string;
|
|
946
|
+
askSummarizerModel?: boolean;
|
|
947
|
+
/** Thinking level for the fresh pass; absent means the provider default. */
|
|
948
|
+
summarizerThinkingLevel?: ThinkingLevel;
|
|
949
|
+
maxSourceChars?: number;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const SETTINGS_FILE_NAME = "pi-clear.json";
|
|
953
|
+
const FLAG_MAX_SOURCE_CHARS = "hand-off-max-source-chars";
|
|
954
|
+
const FLAG_SUMMARIZER = "hand-off-summarizer";
|
|
955
|
+
|
|
956
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
957
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Read `<agent-dir>/pi-clear.json`; a malformed file is a warning, not a failure. */
|
|
961
|
+
export function loadHandoffSettings(path: string = join(getAgentDir(), SETTINGS_FILE_NAME)): {
|
|
962
|
+
settings: HandoffSettings;
|
|
963
|
+
warning?: string;
|
|
964
|
+
} {
|
|
965
|
+
let raw: string;
|
|
966
|
+
try {
|
|
967
|
+
raw = readFileSync(path, "utf8");
|
|
968
|
+
} catch (err) {
|
|
969
|
+
if ((err as { code?: string }).code === "ENOENT") return { settings: {} };
|
|
970
|
+
return { settings: {}, warning: `pi-clear: could not read ${path}: ${messageOf(err)}` };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
let parsed: unknown;
|
|
974
|
+
try {
|
|
975
|
+
parsed = JSON.parse(raw);
|
|
976
|
+
} catch (err) {
|
|
977
|
+
return { settings: {}, warning: `pi-clear: ignoring malformed ${path}: ${messageOf(err)}` };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (!isRecord(parsed) || !isRecord(parsed.handoff)) {
|
|
981
|
+
return { settings: {}, warning: `pi-clear: ignoring ${path}: expected a "handoff" object` };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const handoff = parsed.handoff;
|
|
985
|
+
const settings: HandoffSettings = {};
|
|
986
|
+
const problems: string[] = [];
|
|
987
|
+
|
|
988
|
+
if (handoff.summarizerModel !== undefined) {
|
|
989
|
+
if (typeof handoff.summarizerModel === "string" && handoff.summarizerModel.includes("/")) {
|
|
990
|
+
settings.summarizerModel = handoff.summarizerModel.trim();
|
|
991
|
+
} else {
|
|
992
|
+
problems.push('"summarizerModel" must be a "provider/model" string');
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (handoff.summarizerThinkingLevel !== undefined) {
|
|
996
|
+
const value = handoff.summarizerThinkingLevel;
|
|
997
|
+
// "default" (and "off") mean "leave the choice to the provider", the same
|
|
998
|
+
// as omitting the key.
|
|
999
|
+
if (value === "default" || value === "off") {
|
|
1000
|
+
// Nothing to record.
|
|
1001
|
+
} else if (typeof value === "string" && WRITER_REASONING_LEVELS.includes(value as ThinkingLevel)) {
|
|
1002
|
+
settings.summarizerThinkingLevel = value as ThinkingLevel;
|
|
1003
|
+
} else {
|
|
1004
|
+
problems.push(
|
|
1005
|
+
`"summarizerThinkingLevel" must be "default" or one of ${WRITER_REASONING_LEVELS.join(", ")}`,
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (handoff.askSummarizerModel !== undefined) {
|
|
1010
|
+
if (typeof handoff.askSummarizerModel === "boolean") settings.askSummarizerModel = handoff.askSummarizerModel;
|
|
1011
|
+
else problems.push('"askSummarizerModel" must be true or false');
|
|
1012
|
+
}
|
|
1013
|
+
if (handoff.maxSourceChars !== undefined) {
|
|
1014
|
+
if (typeof handoff.maxSourceChars === "number" && Number.isFinite(handoff.maxSourceChars) && handoff.maxSourceChars >= 0) {
|
|
1015
|
+
settings.maxSourceChars = Math.floor(handoff.maxSourceChars);
|
|
1016
|
+
} else {
|
|
1017
|
+
problems.push('"maxSourceChars" must be a number >= 0');
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return problems.length > 0
|
|
1022
|
+
? { settings, warning: `pi-clear: ignoring ${path} ${problems.join(", ")}` }
|
|
1023
|
+
: { settings };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function messageOf(err: unknown): string {
|
|
1027
|
+
return err instanceof Error ? err.message : String(err);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Command arg > pi CLI flag > config file > built-in default. */
|
|
1031
|
+
export function resolveMaxSourceChars(flagValue: unknown, settings: HandoffSettings): number {
|
|
1032
|
+
if (typeof flagValue === "string" && flagValue.trim()) {
|
|
1033
|
+
const fromFlag = Number(flagValue);
|
|
1034
|
+
if (Number.isFinite(fromFlag) && fromFlag >= 0) return Math.floor(fromFlag);
|
|
1035
|
+
}
|
|
1036
|
+
if (settings.maxSourceChars !== undefined) return settings.maxSourceChars;
|
|
1037
|
+
return DEFAULT_MAX_SOURCE_CHARS;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** `118234` → `118k`, for the trimming notice. */
|
|
1041
|
+
function formatChars(chars: number): string {
|
|
1042
|
+
if (chars < 1000) return String(chars);
|
|
1043
|
+
if (chars < 1_000_000) return `${Math.round(chars / 1000)}k`;
|
|
1044
|
+
return `${(chars / 1_000_000).toFixed(1)}M`;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
type WriterChoice = { kind: "live" } | { kind: "fresh"; model: AnyModel };
|
|
1048
|
+
|
|
1049
|
+
interface WriterOptions {
|
|
1050
|
+
/** From `--fresh` / `--live`; skips the writer picker. */
|
|
1051
|
+
writer?: "live" | "fresh";
|
|
1052
|
+
/** Model pinned by command arg, pi flag, or config. */
|
|
1053
|
+
pinnedModel?: AnyModel;
|
|
1054
|
+
/** Whether the summarizer model picker still needs to open. */
|
|
1055
|
+
askSummarizer: boolean;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const WRITER_LIVE_LABEL = "This agent — full context and tools";
|
|
1059
|
+
const WRITER_FRESH_OTHER_LABEL = "Fresh pass — another model…";
|
|
1060
|
+
|
|
1061
|
+
/** Pick the hand-off writer: the live agent, or a fresh pass with a chosen model. */
|
|
1062
|
+
async function chooseWriter(ctx: ExtensionContext, options: WriterOptions): Promise<WriterChoice | undefined> {
|
|
1063
|
+
const withCurrentModel = (): WriterChoice | undefined => {
|
|
1064
|
+
const model = options.pinnedModel ?? ctx.model;
|
|
1065
|
+
if (!model) {
|
|
1066
|
+
ctx.ui.notify("No current model to write the hand-off; pick another model", "error");
|
|
1067
|
+
return undefined;
|
|
1068
|
+
}
|
|
1069
|
+
return { kind: "fresh", model };
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
const askForModel = async (): Promise<WriterChoice | undefined> => {
|
|
1073
|
+
if (!options.askSummarizer) return withCurrentModel();
|
|
1074
|
+
const picked = await pickTargetModel(ctx, {
|
|
1075
|
+
current: options.pinnedModel ?? ctx.model,
|
|
1076
|
+
title: "Hand-off writer model",
|
|
1077
|
+
providerTitle: "Hand-off writer provider",
|
|
1078
|
+
});
|
|
1079
|
+
if (!picked) return undefined;
|
|
1080
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(picked.model)) {
|
|
1081
|
+
ctx.ui.notify(
|
|
1082
|
+
`No auth configured for ${picked.model.provider}/${picked.model.id}; staying in this session`,
|
|
1083
|
+
"error",
|
|
1084
|
+
);
|
|
1085
|
+
return undefined;
|
|
1086
|
+
}
|
|
1087
|
+
return { kind: "fresh", model: picked.model };
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
if (options.writer === "live") return { kind: "live" };
|
|
1091
|
+
if (options.writer === "fresh") return askForModel();
|
|
1092
|
+
|
|
1093
|
+
const labels = [
|
|
1094
|
+
WRITER_LIVE_LABEL,
|
|
1095
|
+
options.pinnedModel
|
|
1096
|
+
? `Fresh pass — ${options.pinnedModel.provider}/${options.pinnedModel.id}`
|
|
1097
|
+
: "Fresh pass — current model",
|
|
1098
|
+
WRITER_FRESH_OTHER_LABEL,
|
|
1099
|
+
];
|
|
1100
|
+
const chosen = await ctx.ui.select("Who writes the hand-off prompt?", labels);
|
|
1101
|
+
if (chosen === undefined) return undefined;
|
|
1102
|
+
|
|
1103
|
+
switch (labelIndex(chosen, labels)) {
|
|
1104
|
+
case 0:
|
|
1105
|
+
return { kind: "live" };
|
|
1106
|
+
case 1:
|
|
1107
|
+
return withCurrentModel();
|
|
1108
|
+
case 2:
|
|
1109
|
+
return askForModel();
|
|
1110
|
+
default:
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
374
1115
|
export default function (pi: ExtensionAPI) {
|
|
375
1116
|
// In-session state for the hand-off capture; fresh per extension instance,
|
|
376
1117
|
// so it never leaks into a replacement session.
|
|
377
1118
|
let awaitingHandoff = false;
|
|
378
1119
|
let capturedPrompt: string | undefined;
|
|
379
1120
|
let flowInProgress = false;
|
|
1121
|
+
// Config file contents, refreshed on every `session_start` (the file is read
|
|
1122
|
+
// once per session, not per hand-off). The pi CLI flag is read per hand-off
|
|
1123
|
+
// so it works even if a command somehow runs before `session_start`.
|
|
1124
|
+
let handoffSettings: HandoffSettings = {};
|
|
380
1125
|
|
|
381
1126
|
/**
|
|
382
1127
|
* Apply a captured model and thinking level, reporting problems as
|
|
@@ -409,6 +1154,67 @@ export default function (pi: ExtensionAPI) {
|
|
|
409
1154
|
}
|
|
410
1155
|
}
|
|
411
1156
|
|
|
1157
|
+
/** Ask the live agent to write its next message as the hand-off prompt. */
|
|
1158
|
+
async function captureFromLiveAgent(
|
|
1159
|
+
ctx: ExtensionCommandContext,
|
|
1160
|
+
steering: string,
|
|
1161
|
+
): Promise<string | undefined> {
|
|
1162
|
+
awaitingHandoff = true;
|
|
1163
|
+
capturedPrompt = undefined;
|
|
1164
|
+
try {
|
|
1165
|
+
await pi.sendMessage(
|
|
1166
|
+
{
|
|
1167
|
+
customType: HANDOFF_CUSTOM_TYPE,
|
|
1168
|
+
content: buildHandoffRequest(steering),
|
|
1169
|
+
display: true,
|
|
1170
|
+
},
|
|
1171
|
+
{ triggerTurn: true },
|
|
1172
|
+
);
|
|
1173
|
+
await ctx.waitForIdle();
|
|
1174
|
+
} finally {
|
|
1175
|
+
awaitingHandoff = false;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (!capturedPrompt) {
|
|
1179
|
+
ctx.ui.notify("The agent did not produce a hand-off prompt; staying in this session", "error");
|
|
1180
|
+
return undefined;
|
|
1181
|
+
}
|
|
1182
|
+
return capturedPrompt;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** One fresh pass over the minimal transcript; notifies when it trims. */
|
|
1186
|
+
async function captureFromFreshPass(
|
|
1187
|
+
ctx: ExtensionContext,
|
|
1188
|
+
writer: WriterChoice & { kind: "fresh" },
|
|
1189
|
+
reasoning: ThinkingLevel | undefined,
|
|
1190
|
+
steering: string,
|
|
1191
|
+
): Promise<string | undefined> {
|
|
1192
|
+
const messages = ctx.sessionManager.buildSessionProjection().messages;
|
|
1193
|
+
const maxSourceChars = resolveMaxSourceChars(pi.getFlag(FLAG_MAX_SOURCE_CHARS), handoffSettings);
|
|
1194
|
+
const source = buildMinimalTranscript(messages, maxSourceChars);
|
|
1195
|
+
if (!source.text.trim()) {
|
|
1196
|
+
ctx.ui.notify("No conversation to hand off", "error");
|
|
1197
|
+
return undefined;
|
|
1198
|
+
}
|
|
1199
|
+
if (source.omittedTurns > 0) {
|
|
1200
|
+
ctx.ui.notify(
|
|
1201
|
+
`Hand-off source trimmed: ${source.omittedTurns} earlier turns omitted (~${formatChars(source.droppedChars)} chars dropped)`,
|
|
1202
|
+
"info",
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
return generateFreshPassPrompt(ctx, writer.model, source.text, steering, reasoning);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
pi.registerFlag(FLAG_MAX_SOURCE_CHARS, {
|
|
1209
|
+
description:
|
|
1210
|
+
"pi-clear: max characters of conversation sent to the /hand-off writer (0 disables the budget)",
|
|
1211
|
+
type: "string",
|
|
1212
|
+
});
|
|
1213
|
+
pi.registerFlag(FLAG_SUMMARIZER, {
|
|
1214
|
+
description: "pi-clear: model that writes /hand-off prompts, as provider/model",
|
|
1215
|
+
type: "string",
|
|
1216
|
+
});
|
|
1217
|
+
|
|
412
1218
|
pi.registerCommand("clear", {
|
|
413
1219
|
description: "Start a fresh session, keeping the current model and thinking level",
|
|
414
1220
|
handler: async (_args, ctx) => {
|
|
@@ -448,59 +1254,97 @@ export default function (pi: ExtensionAPI) {
|
|
|
448
1254
|
|
|
449
1255
|
pi.registerCommand("hand-off", {
|
|
450
1256
|
description:
|
|
451
|
-
"Hand the conversation to another model: pick the next model and thinking level,
|
|
1257
|
+
"Hand the conversation to another model: choose who writes the hand-off prompt (this agent or a fresh pass), pick the next model and thinking level, then review the prompt in a fresh session",
|
|
452
1258
|
handler: async (args, ctx) => {
|
|
1259
|
+
if (flowInProgress) {
|
|
1260
|
+
ctx.ui.notify("A hand-off is already in progress", "warning");
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
453
1263
|
try {
|
|
454
|
-
|
|
455
|
-
|
|
1264
|
+
flowInProgress = true;
|
|
1265
|
+
|
|
1266
|
+
const parsed = parseHandoffArgs(args);
|
|
1267
|
+
if (parsed.error) {
|
|
1268
|
+
ctx.ui.notify(parsed.error, "error");
|
|
456
1269
|
return;
|
|
457
1270
|
}
|
|
458
|
-
|
|
1271
|
+
|
|
459
1272
|
if (!ctx.isIdle()) {
|
|
460
1273
|
ctx.ui.notify("Waiting for the agent to finish before handing off", "info");
|
|
461
1274
|
await ctx.waitForIdle();
|
|
462
1275
|
}
|
|
463
1276
|
|
|
464
|
-
//
|
|
1277
|
+
// Writer model: pi CLI flag > config file. There is no command-arg
|
|
1278
|
+
// flag; without a configured preference the picker just opens.
|
|
1279
|
+
const flagSummarizer = pi.getFlag(FLAG_SUMMARIZER);
|
|
1280
|
+
const summarizerSpec =
|
|
1281
|
+
(typeof flagSummarizer === "string" && flagSummarizer.trim() ? flagSummarizer.trim() : undefined) ??
|
|
1282
|
+
handoffSettings.summarizerModel;
|
|
1283
|
+
const pinnedModel = summarizerSpec
|
|
1284
|
+
? ctx.modelRegistry.find(
|
|
1285
|
+
summarizerSpec.slice(0, summarizerSpec.indexOf("/")),
|
|
1286
|
+
summarizerSpec.slice(summarizerSpec.indexOf("/") + 1),
|
|
1287
|
+
)
|
|
1288
|
+
: undefined;
|
|
1289
|
+
if (summarizerSpec && !pinnedModel) {
|
|
1290
|
+
ctx.ui.notify(
|
|
1291
|
+
`Hand-off writer model ${summarizerSpec} is not available; choose one instead`,
|
|
1292
|
+
"warning",
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
// A pi CLI flag is not asked about again; a config-file model is only
|
|
1296
|
+
// silently accepted when `askSummarizerModel` is false.
|
|
1297
|
+
const pinnedByFlag = typeof flagSummarizer === "string";
|
|
1298
|
+
const askSummarizer =
|
|
1299
|
+
!pinnedModel || (!pinnedByFlag && handoffSettings.askSummarizerModel !== false);
|
|
1300
|
+
|
|
1301
|
+
// 1. Choose who writes the hand-off prompt, and (for the fresh pass)
|
|
1302
|
+
// how hard that model should think.
|
|
1303
|
+
const writer = await chooseWriter(ctx, {
|
|
1304
|
+
writer: parsed.writer,
|
|
1305
|
+
pinnedModel,
|
|
1306
|
+
askSummarizer,
|
|
1307
|
+
});
|
|
1308
|
+
if (!writer) return;
|
|
1309
|
+
|
|
1310
|
+
let writerReasoning: ThinkingLevel | undefined;
|
|
1311
|
+
if (writer.kind === "fresh" && writer.model.reasoning) {
|
|
1312
|
+
const chosen = await selectWriterReasoningLevel(ctx, handoffSettings.summarizerThinkingLevel);
|
|
1313
|
+
if (!chosen) return;
|
|
1314
|
+
writerReasoning = chosen.reasoning;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// 2. Pick the destination model.
|
|
465
1318
|
const target = await pickTargetModel(ctx);
|
|
466
1319
|
if (!target) return;
|
|
467
1320
|
|
|
468
|
-
//
|
|
1321
|
+
// 3. Pick the thinking level (a scoped `model:level` entry pins it).
|
|
469
1322
|
const level = target.thinkingLevel ?? (await selectThinkingLevel(ctx));
|
|
470
1323
|
if (level === undefined) return;
|
|
471
1324
|
|
|
472
|
-
//
|
|
1325
|
+
// 4. Collect steering instructions, pre-filled from the remaining args.
|
|
473
1326
|
const steering = await ctx.ui.editor(
|
|
474
|
-
|
|
475
|
-
|
|
1327
|
+
writer.kind === "live"
|
|
1328
|
+
? "Steering for the hand-off prompt (sent to the current agent)"
|
|
1329
|
+
: "Steering for the hand-off prompt (sent to the hand-off writer)",
|
|
1330
|
+
parsed.steering.trim(),
|
|
476
1331
|
);
|
|
477
1332
|
if (steering === undefined) return;
|
|
478
1333
|
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
content: buildHandoffRequest(steering),
|
|
486
|
-
display: true,
|
|
487
|
-
},
|
|
488
|
-
{ triggerTurn: true },
|
|
489
|
-
);
|
|
490
|
-
await ctx.waitForIdle();
|
|
491
|
-
awaitingHandoff = false;
|
|
1334
|
+
// 5. Write the hand-off prompt with the chosen writer.
|
|
1335
|
+
const prompt =
|
|
1336
|
+
writer.kind === "live"
|
|
1337
|
+
? await captureFromLiveAgent(ctx, steering)
|
|
1338
|
+
: await captureFromFreshPass(ctx, writer, writerReasoning, steering);
|
|
1339
|
+
if (!prompt) return;
|
|
492
1340
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// 5. Carry everything across session replacement as plain data.
|
|
1341
|
+
// 6. Carry everything across session replacement as plain data, with
|
|
1342
|
+
// the provenance comment added here (never by the writer model).
|
|
499
1343
|
writeHandoff({
|
|
500
1344
|
provider: target.model.provider,
|
|
501
1345
|
modelId: target.model.id,
|
|
502
1346
|
thinkingLevel: level,
|
|
503
|
-
prompt:
|
|
1347
|
+
prompt: withSessionProvenance(prompt, ctx),
|
|
504
1348
|
});
|
|
505
1349
|
|
|
506
1350
|
const result = await ctx.newSession();
|
|
@@ -537,6 +1381,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
537
1381
|
});
|
|
538
1382
|
|
|
539
1383
|
pi.on("session_start", async (event, ctx) => {
|
|
1384
|
+
const loaded = loadHandoffSettings();
|
|
1385
|
+
handoffSettings = loaded.settings;
|
|
1386
|
+
if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
|
|
1387
|
+
|
|
540
1388
|
if (event.reason !== "new") return;
|
|
541
1389
|
|
|
542
1390
|
const handoff = takeHandoff();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grrrck/pi-clear",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pi extension that starts a fresh session with /clear while keeping your current model and thinking level, and hands conversations off between models with /hand-off",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|