@grrrck/pi-clear 0.3.1 → 0.5.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 +30 -0
- package/README.md +82 -12
- package/index.ts +953 -57
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ 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.5.0] - 2026-11-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- A **same-or-pick dialog** after the writer picker: *Same model and thinking level* (first, so Enter re-uses the current settings) or *Pick a model and thinking level…*, which opens the destination pickers.
|
|
13
|
+
- Flag `--same`: skips the same-or-pick dialog and re-uses the current session's model and thinking level, as if the first option was chosen.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `/hand-off <instructions>` uses the inline steering text as-is: the steering editor only opens when no instructions were typed after the command, so passing steering on the command line no longer adds a dialog to confirm or edit it.
|
|
18
|
+
|
|
19
|
+
## [0.4.0] - 2026-10-30
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `/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…*.
|
|
24
|
+
- 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.
|
|
25
|
+
- 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.
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- CLI flags `--hand-off-summarizer=<provider/model>` and `--hand-off-max-source-chars=<n>`.
|
|
30
|
+
- 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.
|
|
31
|
+
- `scripts/minimal-transcript.test.mjs`: offline tests for the fresh-pass serializer, budget, file lists, provenance, and command-arg parsing.
|
|
32
|
+
- `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.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `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.
|
|
37
|
+
|
|
8
38
|
## [0.3.1] - 2026-10-27
|
|
9
39
|
|
|
10
40
|
### 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] [--same] [steering instructions]
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
## `/clear` behavior
|
|
@@ -48,17 +48,79 @@ 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
|
|
53
|
-
3.
|
|
54
|
-
4.
|
|
53
|
+
2. asks whether the next session keeps the current model and thinking level — the first option, so pressing Enter keeps them — or opens a model picker, then a thinking level picker, then collects steering instructions: text you typed after `/hand-off` (and its flags) is used as-is, and the steering editor only opens when you passed none. `--same` skips the same-or-pick dialog and re-uses the current model and thinking level. 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
|
+
- `--same` skips the same-or-pick dialog and re-uses the current session's model and thinking level. It combines freely with `--fresh` / `--live` and inline steering.
|
|
82
|
+
- Flags are only recognized at the front of the command args: `/hand-off --same fix the tests` parses the flag, but `/hand-off fix the tests --same` passes `--same` through as steering text. Anything after the first non-flag token is steering.
|
|
83
|
+
- 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).
|
|
84
|
+
- The source is the post-compaction view of the session, so a compacted conversation is summarized from its summary rather than from nothing.
|
|
85
|
+
- 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.
|
|
86
|
+
|
|
87
|
+
### Session provenance
|
|
88
|
+
|
|
89
|
+
Every hand-off prompt (from either writer) starts with a comment naming the session it came from:
|
|
90
|
+
|
|
91
|
+
```markdown
|
|
92
|
+
<!-- Handed off from pi session 01a18da2-4eff-73b9-846d-5fd4c156e52e -->
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
Optional settings live in `<agent-dir>/pi-clear.json` (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`), read once per session:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"handoff": {
|
|
104
|
+
"summarizerModel": "anthropic/claude-haiku-4-5",
|
|
105
|
+
"askSummarizerModel": true,
|
|
106
|
+
"summarizerThinkingLevel": "low",
|
|
107
|
+
"maxSourceChars": 200000
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `summarizerModel` — preselected in the writer-model picker, and offered as its own `Fresh pass — <model>` entry in the writer picker.
|
|
113
|
+
- `askSummarizerModel: false` — with `summarizerModel` set, skip the writer-model picker entirely and always use that model.
|
|
114
|
+
- `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.
|
|
115
|
+
- `maxSourceChars` — characters of conversation sent to the fresh pass (default `200000`, about 50k tokens). `0` disables the budget.
|
|
116
|
+
|
|
117
|
+
A malformed file is reported as a warning and ignored. The same knobs are available per pi launch as CLI flags:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
pi --hand-off-summarizer=anthropic/claude-haiku-4-5 --hand-off-max-source-chars=400000
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
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
124
|
|
|
63
125
|
## Notes
|
|
64
126
|
|
|
@@ -70,7 +132,9 @@ Two limitations worth knowing: if you queue another message while the agent is w
|
|
|
70
132
|
|
|
71
133
|
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
134
|
|
|
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`.
|
|
135
|
+
`/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`.
|
|
136
|
+
|
|
137
|
+
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
138
|
|
|
75
139
|
## Development
|
|
76
140
|
|
|
@@ -80,14 +144,20 @@ Load the extension directly for local testing:
|
|
|
80
144
|
pi -e ./index.ts
|
|
81
145
|
```
|
|
82
146
|
|
|
83
|
-
There are also
|
|
147
|
+
There are also smoke tests that verify the behavior end-to-end by starting pi in RPC mode:
|
|
84
148
|
|
|
85
149
|
```sh
|
|
86
150
|
python3 scripts/rpc-smoke-test.py # /clear keeps model + thinking level
|
|
87
|
-
python3 scripts/rpc-handoff-smoke-test.py # /hand-off
|
|
151
|
+
python3 scripts/rpc-handoff-smoke-test.py # /hand-off, both writers, dialogs included
|
|
152
|
+
node --test scripts/model-picker.test.mjs # offline: model picker behavior
|
|
153
|
+
node --test scripts/minimal-transcript.test.mjs # offline: fresh-pass transcript + budget
|
|
154
|
+
node --test scripts/fresh-pass-flow.test.mjs # offline: /hand-off flow, both writers
|
|
88
155
|
```
|
|
89
156
|
|
|
90
|
-
|
|
157
|
+
The RPC smoke tests need at least two models with configured auth.
|
|
158
|
+
`rpc-handoff-smoke-test.py` makes **real, billed model requests** (the agent
|
|
159
|
+
writes the hand-off prompt); `rpc-smoke-test.py` only drives local RPC state
|
|
160
|
+
and makes none. The `node --test` scripts need neither auth nor a pi process.
|
|
91
161
|
|
|
92
162
|
## Related extensions
|
|
93
163
|
|
package/index.ts
CHANGED
|
@@ -13,19 +13,38 @@
|
|
|
13
13
|
* `/clear` starts an equally fresh session, then restores the model and
|
|
14
14
|
* thinking level that were active when you cleared.
|
|
15
15
|
*
|
|
16
|
-
* `/hand-off`: hand the conversation to another model
|
|
16
|
+
* `/hand-off`: hand the conversation to another model (or back to the same
|
|
17
|
+
* one, with `--same`).
|
|
17
18
|
*
|
|
18
|
-
* 1. The user runs `/hand-off
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
19
|
+
* 1. The user runs `/hand-off`, optionally with `--fresh` or `--live` and
|
|
20
|
+
* steering text after them.
|
|
21
|
+
* 2. A dialog flow asks who writes the hand-off prompt, then asks whether the
|
|
22
|
+
* next session keeps the current model and thinking level (first option,
|
|
23
|
+
* so Enter keeps them) or opens the destination pickers — `--same` skips
|
|
24
|
+
* that dialog and re-uses the current values. Then steering instructions:
|
|
25
|
+
* text typed inline after `/hand-off` is used as-is, and an editor opens
|
|
26
|
+
* only when no inline steering was passed.
|
|
27
|
+
* 3. The hand-off prompt is written by one of two writers:
|
|
28
|
+
* - the **live agent** (default, first in the picker): the extension sends
|
|
29
|
+
* the current agent a custom message asking it to write its next message
|
|
30
|
+
* as the hand-off prompt, and `agent_end` captures the final assistant
|
|
31
|
+
* message. It sees the whole transcript and keeps tool access.
|
|
32
|
+
* - the **fresh pass**: a single out-of-band `modelRegistry.complete()`
|
|
33
|
+
* call over a *minimal reconstruction* of the conversation (no tool
|
|
34
|
+
* output, no thinking, head+tail budget), so long or stale sessions can
|
|
35
|
+
* still be handed off, and a cheap model can do the writing. It has no
|
|
36
|
+
* tool access, and its thinking level is chosen separately (provider
|
|
37
|
+
* default preselected).
|
|
38
|
+
* 4. The extension prepends a provenance comment naming the source session
|
|
39
|
+
* (both writers), replaces the session, applies the chosen model and
|
|
40
|
+
* thinking level, and inserts the prompt into the editor.
|
|
27
41
|
* 5. The user reviews, edits, and submits the prompt themselves.
|
|
28
42
|
*
|
|
43
|
+
* Settings live in `<agent-dir>/pi-clear.json` (read once per
|
|
44
|
+
* `session_start`); CLI flags `--hand-off-max-source-chars` and
|
|
45
|
+
* `--hand-off-summarizer` override the file. Without a configured writer
|
|
46
|
+
* model, `/hand-off` just asks: there is no command-arg model flag.
|
|
47
|
+
*
|
|
29
48
|
* Mechanism:
|
|
30
49
|
* Both commands capture state as plain data into a `globalThis` property.
|
|
31
50
|
* Session replacement (`ctx.newSession()`) tears down the current runtime
|
|
@@ -40,11 +59,15 @@
|
|
|
40
59
|
* into the editor with `ctx.ui.setEditorText()`.
|
|
41
60
|
*/
|
|
42
61
|
|
|
62
|
+
import { randomUUID } from "node:crypto";
|
|
63
|
+
import { readFileSync } from "node:fs";
|
|
64
|
+
import { join } from "node:path";
|
|
43
65
|
import type {
|
|
44
66
|
ExtensionAPI,
|
|
67
|
+
ExtensionCommandContext,
|
|
45
68
|
ExtensionContext,
|
|
46
69
|
} from "@earendil-works/pi-coding-agent";
|
|
47
|
-
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
70
|
+
import { BorderedLoader, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
48
71
|
import { Container, fuzzyFilter, Input, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
|
|
49
72
|
|
|
50
73
|
// Model and thinking-level types, derived from the API surface because the
|
|
@@ -87,6 +110,14 @@ const THINKING_LEVELS: ThinkingLevel[] = [
|
|
|
87
110
|
|
|
88
111
|
const HANDOFF_CUSTOM_TYPE = "pi-clear:hand-off-request";
|
|
89
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Reasoning levels offered for the fresh pass. "off" is not offered: it means
|
|
115
|
+
* the same thing as the provider default here, because the option is omitted
|
|
116
|
+
* either way.
|
|
117
|
+
*/
|
|
118
|
+
const WRITER_REASONING_LEVELS: ThinkingLevel[] = THINKING_LEVELS.filter((level) => level !== "off");
|
|
119
|
+
const WRITER_DEFAULT_REASONING_LABEL = "provider default";
|
|
120
|
+
|
|
90
121
|
function sameModel(a: AnyModel | undefined, b: AnyModel | undefined): boolean {
|
|
91
122
|
return !!a && !!b && a.provider === b.provider && a.id === b.id;
|
|
92
123
|
}
|
|
@@ -120,6 +151,14 @@ interface PickedModel {
|
|
|
120
151
|
thinkingLevel?: ThinkingLevel;
|
|
121
152
|
}
|
|
122
153
|
|
|
154
|
+
/** How the model pickers label themselves and which model they treat as current. */
|
|
155
|
+
interface ModelPickerOptions {
|
|
156
|
+
/** Preselected model; defaults to the session's model. */
|
|
157
|
+
current?: AnyModel;
|
|
158
|
+
title?: string;
|
|
159
|
+
providerTitle?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
123
162
|
async function selectModel(
|
|
124
163
|
ctx: ExtensionContext,
|
|
125
164
|
models: AnyModel[],
|
|
@@ -135,6 +174,24 @@ async function selectModel(
|
|
|
135
174
|
return index === -1 ? undefined : ordered[index];
|
|
136
175
|
}
|
|
137
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Same-or-pick picker between the writer and destination dialogs. The first
|
|
179
|
+
* option re-uses the current session's model and thinking level in one Enter
|
|
180
|
+
* press; the second opens the destination pickers. Without a current model
|
|
181
|
+
* there is nothing to re-use, so the destination pickers open directly.
|
|
182
|
+
*/
|
|
183
|
+
async function chooseSameOrPick(ctx: ExtensionContext): Promise<"same" | "pick" | undefined> {
|
|
184
|
+
if (!ctx.model) return "pick";
|
|
185
|
+
const labels = [
|
|
186
|
+
`${SAME_SETTINGS_LABEL} (${ctx.model.provider}/${ctx.model.id}${ctx.thinkingLevel ? ` · ${ctx.thinkingLevel}` : ""})`,
|
|
187
|
+
PICK_SETTINGS_LABEL,
|
|
188
|
+
];
|
|
189
|
+
const chosen = await ctx.ui.select("Model for the next session", labels);
|
|
190
|
+
if (chosen === undefined) return undefined;
|
|
191
|
+
const index = labelIndex(chosen, labels);
|
|
192
|
+
return index === 0 ? "same" : index === 1 ? "pick" : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
138
195
|
/**
|
|
139
196
|
* Thinking level picker with the active level preselected, so Enter accepts
|
|
140
197
|
* the current setting.
|
|
@@ -151,20 +208,54 @@ async function selectThinkingLevel(ctx: ExtensionContext): Promise<ThinkingLevel
|
|
|
151
208
|
return index === -1 ? undefined : ordered[index];
|
|
152
209
|
}
|
|
153
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Thinking level for the fresh pass writer. The provider default comes first and
|
|
213
|
+
* is preselected unless the config file pins a level, so pressing Enter keeps
|
|
214
|
+
* today's behavior and the dialog is a no-op for most runs.
|
|
215
|
+
*/
|
|
216
|
+
async function selectWriterReasoningLevel(
|
|
217
|
+
ctx: ExtensionContext,
|
|
218
|
+
preselected: ThinkingLevel | undefined,
|
|
219
|
+
): Promise<{ reasoning?: ThinkingLevel } | undefined> {
|
|
220
|
+
const ordered = preselected
|
|
221
|
+
? [preselected, ...WRITER_REASONING_LEVELS.filter((level) => level !== preselected)]
|
|
222
|
+
: [...WRITER_REASONING_LEVELS];
|
|
223
|
+
const options: Array<{ reasoning?: ThinkingLevel; label: string }> = ordered.map((level) => ({
|
|
224
|
+
reasoning: level,
|
|
225
|
+
label: `${level}${level === preselected ? " (current)" : ""}`,
|
|
226
|
+
}));
|
|
227
|
+
if (preselected) {
|
|
228
|
+
options.push({ label: WRITER_DEFAULT_REASONING_LABEL });
|
|
229
|
+
} else {
|
|
230
|
+
options.unshift({ label: `${WRITER_DEFAULT_REASONING_LABEL} (current)` });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const labels = options.map((option) => option.label);
|
|
234
|
+
const chosen = await ctx.ui.select("Thinking level for the hand-off writer", labels);
|
|
235
|
+
if (chosen === undefined) return undefined;
|
|
236
|
+
const index = labelIndex(chosen, labels);
|
|
237
|
+
return index === -1 ? undefined : { reasoning: options[index].reasoning };
|
|
238
|
+
}
|
|
239
|
+
|
|
154
240
|
/**
|
|
155
241
|
* Non-interactive fallback (RPC mode has no custom components): scoped
|
|
156
242
|
* models in one dialog when scoping is configured, otherwise a two-level
|
|
157
243
|
* provider → model pick over the auth-configured catalogue.
|
|
158
244
|
*/
|
|
159
|
-
async function pickTargetModelFallback(
|
|
160
|
-
|
|
245
|
+
async function pickTargetModelFallback(
|
|
246
|
+
ctx: ExtensionContext,
|
|
247
|
+
options: ModelPickerOptions = {},
|
|
248
|
+
): Promise<PickedModel | undefined> {
|
|
249
|
+
const current = options.current ?? ctx.model;
|
|
250
|
+
const title = options.title ?? "Hand off to model";
|
|
251
|
+
const providerTitle = options.providerTitle ?? "Hand off to provider";
|
|
161
252
|
const scoped = ctx.scopedModels.map((scoped) => scoped.model);
|
|
162
253
|
|
|
163
254
|
if (scoped.length > 0) {
|
|
164
255
|
// Keep the current model selectable and preselected even when scoping
|
|
165
256
|
// excludes it.
|
|
166
257
|
const models = current && !scoped.some((m) => sameModel(m, current)) ? [current, ...scoped] : scoped;
|
|
167
|
-
const model = await selectModel(ctx, models,
|
|
258
|
+
const model = await selectModel(ctx, models, title, current);
|
|
168
259
|
return model ? { model } : undefined;
|
|
169
260
|
}
|
|
170
261
|
|
|
@@ -190,13 +281,13 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
|
|
|
190
281
|
|
|
191
282
|
const providerLabels = providers.map((p) => `${p} (${byProvider.get(p)!.length} models)`);
|
|
192
283
|
const providerChoice =
|
|
193
|
-
providers.length === 1 ? providerLabels[0] : await ctx.ui.select(
|
|
284
|
+
providers.length === 1 ? providerLabels[0] : await ctx.ui.select(providerTitle, providerLabels);
|
|
194
285
|
if (providerChoice === undefined) return undefined;
|
|
195
286
|
const providerIndex = labelIndex(providerChoice, providerLabels);
|
|
196
287
|
if (providerIndex === -1) return undefined;
|
|
197
288
|
const providerName = providers[providerIndex];
|
|
198
289
|
|
|
199
|
-
const model = await selectModel(ctx, byProvider.get(providerName) ?? [],
|
|
290
|
+
const model = await selectModel(ctx, byProvider.get(providerName) ?? [], title, current);
|
|
200
291
|
return model ? { model } : undefined;
|
|
201
292
|
}
|
|
202
293
|
|
|
@@ -204,8 +295,12 @@ async function pickTargetModelFallback(ctx: ExtensionContext): Promise<PickedMod
|
|
|
204
295
|
* `/model`-style model picker (TUI mode only): a searchable list with Tab to
|
|
205
296
|
* toggle between the session's scoped models and the full catalogue.
|
|
206
297
|
*/
|
|
207
|
-
async function pickTargetModelTui(
|
|
208
|
-
|
|
298
|
+
async function pickTargetModelTui(
|
|
299
|
+
ctx: ExtensionContext,
|
|
300
|
+
options: ModelPickerOptions = {},
|
|
301
|
+
): Promise<PickedModel | undefined> {
|
|
302
|
+
const current = options.current ?? ctx.model;
|
|
303
|
+
const title = options.title ?? "Hand off to model";
|
|
209
304
|
const allEntries: PickedModel[] = ctx.modelRegistry.getAvailable().map((model) => ({ model }));
|
|
210
305
|
if (allEntries.length === 0) {
|
|
211
306
|
ctx.ui.notify("No models available to hand off to", "error");
|
|
@@ -279,7 +374,7 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
|
|
|
279
374
|
theme.fg(
|
|
280
375
|
"accent",
|
|
281
376
|
theme.bold(
|
|
282
|
-
|
|
377
|
+
`${title} — ${scope === "scoped" ? `scoped (${scopedEntries.length})` : `all (${allEntries.length})`}`,
|
|
283
378
|
),
|
|
284
379
|
),
|
|
285
380
|
);
|
|
@@ -331,12 +426,17 @@ async function pickTargetModelTui(ctx: ExtensionContext): Promise<PickedModel |
|
|
|
331
426
|
*
|
|
332
427
|
* TUI mode gets a `/model`-style searchable picker with Tab to toggle
|
|
333
428
|
* between scoped and all models; other modes fall back to plain dialogs.
|
|
429
|
+
* The same picker also chooses the fresh pass's writer model, with the
|
|
430
|
+
* title and preselected model supplied by the caller.
|
|
334
431
|
*/
|
|
335
|
-
async function pickTargetModel(
|
|
432
|
+
async function pickTargetModel(
|
|
433
|
+
ctx: ExtensionContext,
|
|
434
|
+
options: ModelPickerOptions = {},
|
|
435
|
+
): Promise<PickedModel | undefined> {
|
|
336
436
|
if (ctx.mode === "tui") {
|
|
337
|
-
return pickTargetModelTui(ctx);
|
|
437
|
+
return pickTargetModelTui(ctx, options);
|
|
338
438
|
}
|
|
339
|
-
return pickTargetModelFallback(ctx);
|
|
439
|
+
return pickTargetModelFallback(ctx, options);
|
|
340
440
|
}
|
|
341
441
|
|
|
342
442
|
/** Extract the text content of an agent message, ignoring thinking blocks and tool calls. */
|
|
@@ -371,12 +471,687 @@ function buildHandoffRequest(steering: string): string {
|
|
|
371
471
|
return lines.join("\n");
|
|
372
472
|
}
|
|
373
473
|
|
|
474
|
+
// ============================================================================
|
|
475
|
+
// Fresh pass: one out-of-band model call over a minimal reconstruction
|
|
476
|
+
// ============================================================================
|
|
477
|
+
|
|
478
|
+
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.
|
|
479
|
+
|
|
480
|
+
Do not continue the conversation. Do not answer questions in it.
|
|
481
|
+
|
|
482
|
+
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.`;
|
|
483
|
+
|
|
484
|
+
const DEFAULT_COVERAGE =
|
|
485
|
+
"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.";
|
|
486
|
+
|
|
487
|
+
/** Characters of conversation sent to the fresh pass (~50k tokens at 200k). */
|
|
488
|
+
const DEFAULT_MAX_SOURCE_CHARS = 200_000;
|
|
489
|
+
/** Share of the budget kept from the newest end of the conversation. */
|
|
490
|
+
const TAIL_SOURCE_SHARE = 0.6;
|
|
491
|
+
/** Share of the budget kept contiguously after the opening message. */
|
|
492
|
+
const HEAD_SOURCE_SHARE = 0.4;
|
|
493
|
+
const USER_MESSAGE_MAX_CHARS = 8_000;
|
|
494
|
+
const ASSISTANT_MESSAGE_MAX_CHARS = 4_000;
|
|
495
|
+
const SUMMARY_MAX_CHARS = 8_000;
|
|
496
|
+
const NOTE_MAX_CHARS = 2_000;
|
|
497
|
+
const HINT_MAX_CHARS = 200;
|
|
498
|
+
|
|
499
|
+
/** A projected session message, viewed loosely: only fields the serializer reads. */
|
|
500
|
+
interface TranscriptSource {
|
|
501
|
+
role?: string;
|
|
502
|
+
content?: unknown;
|
|
503
|
+
customType?: string;
|
|
504
|
+
summary?: string;
|
|
505
|
+
command?: string;
|
|
506
|
+
/** `!!` bash executions: never sent to a model, so never sent to the writer. */
|
|
507
|
+
excludeFromContext?: boolean;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
interface ContentBlock {
|
|
511
|
+
type?: string;
|
|
512
|
+
text?: string;
|
|
513
|
+
name?: string;
|
|
514
|
+
arguments?: unknown;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** One rendered unit of the minimal transcript; the budget never splits a block. */
|
|
518
|
+
interface TranscriptBlock {
|
|
519
|
+
text: string;
|
|
520
|
+
/** Marks the opening user message, which the budget always keeps. */
|
|
521
|
+
anchor?: boolean;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
interface TranscriptResult {
|
|
525
|
+
text: string;
|
|
526
|
+
/** Blocks dropped from the middle of the conversation, 0 when nothing was dropped. */
|
|
527
|
+
omittedTurns: number;
|
|
528
|
+
/** Characters those blocks would have contributed. */
|
|
529
|
+
droppedChars: number;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function contentBlocks(content: unknown): ContentBlock[] {
|
|
533
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
534
|
+
if (!Array.isArray(content)) return [];
|
|
535
|
+
const blocks: ContentBlock[] = [];
|
|
536
|
+
for (const block of content) {
|
|
537
|
+
if (typeof block === "object" && block !== null) blocks.push(block as ContentBlock);
|
|
538
|
+
}
|
|
539
|
+
return blocks;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Visible text only: thinking, tool calls, and images are dropped. */
|
|
543
|
+
function visibleText(content: unknown): string {
|
|
544
|
+
return contentBlocks(content)
|
|
545
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
546
|
+
.map((block) => block.text!.trim())
|
|
547
|
+
.filter((text) => text.length > 0)
|
|
548
|
+
.join("\n");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function toolCallsOf(content: unknown): ContentBlock[] {
|
|
552
|
+
return contentBlocks(content).filter(
|
|
553
|
+
(block) => block.type === "toolCall" && typeof block.name === "string",
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function stringArg(args: unknown, key: string): string | undefined {
|
|
558
|
+
if (typeof args !== "object" || args === null) return undefined;
|
|
559
|
+
const value = (args as Record<string, unknown>)[key];
|
|
560
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function firstStringArg(args: unknown): string | undefined {
|
|
564
|
+
if (typeof args !== "object" || args === null) return undefined;
|
|
565
|
+
for (const value of Object.values(args as Record<string, unknown>)) {
|
|
566
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
567
|
+
}
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Keep both ends of an over-long text, and say how much was cut. */
|
|
572
|
+
function truncateMiddle(text: string, maxChars: number): string {
|
|
573
|
+
if (maxChars <= 0 || text.length <= maxChars) return text;
|
|
574
|
+
const headChars = Math.floor(maxChars * TAIL_SOURCE_SHARE);
|
|
575
|
+
const tailChars = maxChars - headChars;
|
|
576
|
+
const removed = text.length - maxChars;
|
|
577
|
+
return `${text.slice(0, headChars)}\n[... ${removed} chars truncated ...]\n${text.slice(text.length - tailChars)}`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** One line naming a tool call and its target — never its result. */
|
|
581
|
+
export function formatToolHint(name: string, args: unknown): string {
|
|
582
|
+
const path = stringArg(args, "path");
|
|
583
|
+
switch (name) {
|
|
584
|
+
case "read":
|
|
585
|
+
case "edit":
|
|
586
|
+
case "write":
|
|
587
|
+
return path ? `${name} ${path}` : name;
|
|
588
|
+
case "bash": {
|
|
589
|
+
const command = stringArg(args, "command")?.split("\n")[0].trim() ?? "";
|
|
590
|
+
return command ? `bash: ${command.slice(0, HINT_MAX_CHARS)}` : "bash";
|
|
591
|
+
}
|
|
592
|
+
case "grep": {
|
|
593
|
+
const pattern = stringArg(args, "pattern");
|
|
594
|
+
if (!pattern) return "grep";
|
|
595
|
+
return path ? `grep: /${pattern}/ in ${path}` : `grep: /${pattern}/`;
|
|
596
|
+
}
|
|
597
|
+
case "ls":
|
|
598
|
+
return path ? `ls: ${path}` : "ls";
|
|
599
|
+
case "find": {
|
|
600
|
+
const pattern = stringArg(args, "pattern");
|
|
601
|
+
if (pattern && path) return `find: ${pattern} in ${path}`;
|
|
602
|
+
if (path) return `find: ${path}`;
|
|
603
|
+
return pattern ? `find: ${pattern}` : "find";
|
|
604
|
+
}
|
|
605
|
+
default: {
|
|
606
|
+
const detail = path ?? firstStringArg(args);
|
|
607
|
+
return detail ? `tool: ${name} ${detail.slice(0, HINT_MAX_CHARS)}` : `tool: ${name}`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
interface FileLists {
|
|
613
|
+
readFiles: string[];
|
|
614
|
+
modifiedFiles: string[];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Same rules pi uses for compaction: read-only files, then written/edited ones. */
|
|
618
|
+
function fileListsFromMessages(messages: readonly TranscriptSource[]): FileLists {
|
|
619
|
+
const read = new Set<string>();
|
|
620
|
+
const written = new Set<string>();
|
|
621
|
+
const edited = new Set<string>();
|
|
622
|
+
for (const message of messages) {
|
|
623
|
+
if (message.role !== "assistant") continue;
|
|
624
|
+
for (const call of toolCallsOf(message.content)) {
|
|
625
|
+
const path = stringArg(call.arguments, "path");
|
|
626
|
+
if (!path) continue;
|
|
627
|
+
if (call.name === "read") read.add(path);
|
|
628
|
+
else if (call.name === "write") written.add(path);
|
|
629
|
+
else if (call.name === "edit") edited.add(path);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const modified = new Set([...edited, ...written]);
|
|
633
|
+
return {
|
|
634
|
+
readFiles: [...read].filter((file) => !modified.has(file)).sort(),
|
|
635
|
+
modifiedFiles: [...modified].sort(),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function formatFileLists(lists: FileLists): string {
|
|
640
|
+
const sections: string[] = [];
|
|
641
|
+
if (lists.readFiles.length > 0) sections.push(`<read-files>\n${lists.readFiles.join("\n")}\n</read-files>`);
|
|
642
|
+
if (lists.modifiedFiles.length > 0) {
|
|
643
|
+
sections.push(`<modified-files>\n${lists.modifiedFiles.join("\n")}\n</modified-files>`);
|
|
644
|
+
}
|
|
645
|
+
return sections.join("\n\n");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Render the conversation as labelled blocks: visible text, tool-call hints,
|
|
650
|
+
* summaries, and notes. Tool results, thinking, and stale hand-off requests
|
|
651
|
+
* are dropped outright.
|
|
652
|
+
*/
|
|
653
|
+
export function renderTranscriptBlocks(messages: readonly TranscriptSource[]): TranscriptBlock[] {
|
|
654
|
+
const blocks: TranscriptBlock[] = [];
|
|
655
|
+
let sawUserMessage = false;
|
|
656
|
+
|
|
657
|
+
for (const message of messages) {
|
|
658
|
+
switch (message.role) {
|
|
659
|
+
case "user": {
|
|
660
|
+
const text = visibleText(message.content);
|
|
661
|
+
if (!text) break;
|
|
662
|
+
blocks.push({
|
|
663
|
+
text: `[User]: ${truncateMiddle(text, USER_MESSAGE_MAX_CHARS)}`,
|
|
664
|
+
anchor: !sawUserMessage,
|
|
665
|
+
});
|
|
666
|
+
sawUserMessage = true;
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
case "assistant": {
|
|
670
|
+
const text = visibleText(message.content);
|
|
671
|
+
if (text) {
|
|
672
|
+
blocks.push({ text: `[Assistant]: ${truncateMiddle(text, ASSISTANT_MESSAGE_MAX_CHARS)}` });
|
|
673
|
+
}
|
|
674
|
+
const hints = toolCallsOf(message.content).map((call) => formatToolHint(call.name!, call.arguments));
|
|
675
|
+
if (hints.length > 0) blocks.push({ text: `[tools]:\n${hints.join("\n")}` });
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
case "toolResult":
|
|
679
|
+
// Tool output never shapes the hand-off: it is the bulk of a transcript
|
|
680
|
+
// and the writer is told to have the next agent verify state itself.
|
|
681
|
+
break;
|
|
682
|
+
case "custom": {
|
|
683
|
+
if (message.customType === HANDOFF_CUSTOM_TYPE) break;
|
|
684
|
+
const text = visibleText(message.content);
|
|
685
|
+
if (text) blocks.push({ text: `[Note]: ${truncateMiddle(text, NOTE_MAX_CHARS)}` });
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
case "compactionSummary":
|
|
689
|
+
case "branchSummary": {
|
|
690
|
+
const summary = typeof message.summary === "string" ? message.summary.trim() : "";
|
|
691
|
+
if (summary) {
|
|
692
|
+
blocks.push({
|
|
693
|
+
text: `[Summary of earlier conversation]: ${truncateMiddle(summary, SUMMARY_MAX_CHARS)}`,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case "bashExecution": {
|
|
699
|
+
if (message.excludeFromContext) break;
|
|
700
|
+
const command = typeof message.command === "string" ? message.command.trim() : "";
|
|
701
|
+
if (command) blocks.push({ text: `[User ran]: ${truncateMiddle(command, NOTE_MAX_CHARS)}` });
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
default:
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return blocks;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Keep the head and the tail of the conversation within `maxChars`, dropping
|
|
714
|
+
* the middle and leaving an elision marker in its place. `maxChars <= 0`
|
|
715
|
+
* disables the budget.
|
|
716
|
+
*/
|
|
717
|
+
export function applyTranscriptBudget(blocks: readonly TranscriptBlock[], maxChars: number): TranscriptResult {
|
|
718
|
+
const parts = blocks.map((block) => block.text);
|
|
719
|
+
const joinBlocks = (items: string[]) => items.join("\n\n");
|
|
720
|
+
const full = joinBlocks(parts);
|
|
721
|
+
if (maxChars <= 0 || full.length <= maxChars) {
|
|
722
|
+
return { text: full, omittedTurns: 0, droppedChars: 0 };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Head: everything up to and including the opening user message, always kept.
|
|
726
|
+
const anchor = blocks.findIndex((block) => block.anchor);
|
|
727
|
+
const anchorIndex = anchor === -1 ? 0 : anchor;
|
|
728
|
+
let headChars = parts.slice(0, anchorIndex + 1).reduce((sum, part) => sum + part.length + 2, 0);
|
|
729
|
+
|
|
730
|
+
// Tail: newest first, up to 60% of the budget.
|
|
731
|
+
const tailBudget = Math.floor(maxChars * TAIL_SOURCE_SHARE);
|
|
732
|
+
let tailStart = parts.length;
|
|
733
|
+
let tailChars = 0;
|
|
734
|
+
for (let i = parts.length - 1; i > anchorIndex; i--) {
|
|
735
|
+
const cost = parts[i].length + 2;
|
|
736
|
+
if (tailChars + cost > tailBudget) break;
|
|
737
|
+
tailChars += cost;
|
|
738
|
+
tailStart = i;
|
|
739
|
+
}
|
|
740
|
+
// Never drop the newest block: it is the most valuable one, and message-level
|
|
741
|
+
// caps normally keep it well inside the tail budget.
|
|
742
|
+
if (tailStart === parts.length && parts.length > anchorIndex + 1) tailStart = parts.length - 1;
|
|
743
|
+
|
|
744
|
+
// Gap: oldest first, contiguous from just after the head, while the head
|
|
745
|
+
// budget lasts.
|
|
746
|
+
const headBudget = Math.floor(maxChars * HEAD_SOURCE_SHARE);
|
|
747
|
+
let gapEnd = anchorIndex;
|
|
748
|
+
while (gapEnd + 1 < tailStart && headChars + parts[gapEnd + 1].length + 2 <= headBudget) {
|
|
749
|
+
headChars += parts[gapEnd + 1].length + 2;
|
|
750
|
+
gapEnd += 1;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const omitted = parts.slice(gapEnd + 1, tailStart);
|
|
754
|
+
const droppedChars = omitted.reduce((sum, part) => sum + part.length + 2, 0);
|
|
755
|
+
const kept = [...parts.slice(0, gapEnd + 1)];
|
|
756
|
+
if (omitted.length > 0) kept.push(`[... ${omitted.length} turns omitted — ${droppedChars} chars ...]`);
|
|
757
|
+
kept.push(...parts.slice(tailStart));
|
|
758
|
+
|
|
759
|
+
return { text: joinBlocks(kept), omittedTurns: omitted.length, droppedChars };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Minimal reconstruction of the conversation plus the file-operation footer. */
|
|
763
|
+
export function buildMinimalTranscript(
|
|
764
|
+
messages: readonly TranscriptSource[],
|
|
765
|
+
maxChars: number = DEFAULT_MAX_SOURCE_CHARS,
|
|
766
|
+
): TranscriptResult {
|
|
767
|
+
const budget = applyTranscriptBudget(renderTranscriptBlocks(messages), maxChars);
|
|
768
|
+
const fileLists = formatFileLists(fileListsFromMessages(messages));
|
|
769
|
+
if (!fileLists) return budget;
|
|
770
|
+
return { ...budget, text: budget.text ? `${budget.text}\n\n${fileLists}` : fileLists };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The single user message of the fresh pass. */
|
|
774
|
+
export function buildFreshPassUserMessage(transcript: string, steering: string): string {
|
|
775
|
+
return [
|
|
776
|
+
"## Conversation (minimal reconstruction)",
|
|
777
|
+
transcript,
|
|
778
|
+
"",
|
|
779
|
+
"## What the hand-off must cover",
|
|
780
|
+
steering.trim() || DEFAULT_COVERAGE,
|
|
781
|
+
"",
|
|
782
|
+
"Requirements for your output:",
|
|
783
|
+
'- Written as instructions to the next agent ("you"), not a report about the past.',
|
|
784
|
+
"- 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.",
|
|
785
|
+
"- Name the files worth opening first.",
|
|
786
|
+
"- Reply with ONLY the hand-off message: no preamble, no postamble, no fences.",
|
|
787
|
+
].join("\n");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ============================================================================
|
|
791
|
+
// Session provenance (both writers)
|
|
792
|
+
// ============================================================================
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* HTML comment naming the session a hand-off came from, so the next agent can
|
|
796
|
+
* look it up when the prompt is not enough. It is a comment, not prose, so it
|
|
797
|
+
* does not read as part of the task, and it stays visible in the editor.
|
|
798
|
+
*/
|
|
799
|
+
export function buildProvenanceComment(sessionId: string | undefined): string {
|
|
800
|
+
const id = sessionId?.trim();
|
|
801
|
+
return id ? `<!-- Handed off from pi session ${id} -->` : "";
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Prepends the provenance comment; called right before `writeHandoff()`. */
|
|
805
|
+
function withSessionProvenance(prompt: string, ctx: ExtensionContext): string {
|
|
806
|
+
const comment = buildProvenanceComment(ctx.sessionManager.getSessionId());
|
|
807
|
+
return comment ? `${comment}\n\n${prompt}` : prompt;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ============================================================================
|
|
811
|
+
// Fresh pass generation
|
|
812
|
+
// ============================================================================
|
|
813
|
+
|
|
814
|
+
/** Options for the one-shot writer call; `reasoning` is only set when supported. */
|
|
815
|
+
interface WriterRequestOptions {
|
|
816
|
+
signal?: AbortSignal;
|
|
817
|
+
cacheRetention: "none";
|
|
818
|
+
sessionId: string;
|
|
819
|
+
reasoning?: ThinkingLevel;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
interface WriterOutcome {
|
|
823
|
+
prompt?: string;
|
|
824
|
+
error?: string;
|
|
825
|
+
cancelled?: boolean;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Strip a fence that wraps the whole reply, leaving inner code blocks alone. */
|
|
829
|
+
function stripSurroundingFence(text: string): string {
|
|
830
|
+
const trimmed = text.trim();
|
|
831
|
+
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return trimmed;
|
|
832
|
+
const withoutOpen = trimmed.replace(/^```[^\n]*\n?/, "");
|
|
833
|
+
return withoutOpen.slice(0, withoutOpen.lastIndexOf("```")).trim();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function checkWrittenPrompt(response: { stopReason?: string; errorMessage?: string; content?: unknown }): WriterOutcome {
|
|
837
|
+
if (response.stopReason === "aborted") return { cancelled: true };
|
|
838
|
+
if (response.stopReason !== "stop") {
|
|
839
|
+
const detail = response.errorMessage ? `: ${response.errorMessage}` : "";
|
|
840
|
+
return { error: `stopped with reason "${response.stopReason}"${detail}` };
|
|
841
|
+
}
|
|
842
|
+
const text = stripSurroundingFence(visibleText(response.content));
|
|
843
|
+
if (!text) return { error: "returned an empty prompt" };
|
|
844
|
+
return { prompt: text };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function reportWriterOutcome(ctx: ExtensionContext, outcome: WriterOutcome, label: string): string | undefined {
|
|
848
|
+
if (outcome.prompt) return outcome.prompt;
|
|
849
|
+
if (outcome.cancelled) {
|
|
850
|
+
ctx.ui.notify("Hand-off cancelled", "info");
|
|
851
|
+
return undefined;
|
|
852
|
+
}
|
|
853
|
+
ctx.ui.notify(
|
|
854
|
+
`Hand-off writer (${label}) ${outcome.error ?? "failed"}; staying in this session`,
|
|
855
|
+
"error",
|
|
856
|
+
);
|
|
857
|
+
return undefined;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Run the fresh pass: one `complete()` call over the minimal transcript.
|
|
862
|
+
* TUI mode gets a cancellable loader; other modes get a notification.
|
|
863
|
+
*/
|
|
864
|
+
async function generateFreshPassPrompt(
|
|
865
|
+
ctx: ExtensionContext,
|
|
866
|
+
model: AnyModel,
|
|
867
|
+
transcript: string,
|
|
868
|
+
steering: string,
|
|
869
|
+
reasoning: ThinkingLevel | undefined,
|
|
870
|
+
): Promise<string | undefined> {
|
|
871
|
+
const userMessage = {
|
|
872
|
+
role: "user" as const,
|
|
873
|
+
content: [{ type: "text" as const, text: buildFreshPassUserMessage(transcript, steering) }],
|
|
874
|
+
timestamp: Date.now(),
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
const attempt = async (signal?: AbortSignal): Promise<WriterOutcome> => {
|
|
878
|
+
const options: WriterRequestOptions = { signal, cacheRetention: "none", sessionId: randomUUID() };
|
|
879
|
+
// Providers default when the option is absent, and models without
|
|
880
|
+
// reasoning controls would reject it; pi's own summarization does the
|
|
881
|
+
// same check before setting the level.
|
|
882
|
+
if (reasoning && model.reasoning) options.reasoning = reasoning;
|
|
883
|
+
try {
|
|
884
|
+
const response = await ctx.modelRegistry.complete(
|
|
885
|
+
model,
|
|
886
|
+
{ systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
|
|
887
|
+
options,
|
|
888
|
+
);
|
|
889
|
+
return checkWrittenPrompt(response);
|
|
890
|
+
} catch (err) {
|
|
891
|
+
return { error: `failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
const label = `${model.provider}/${model.id}`;
|
|
896
|
+
|
|
897
|
+
if (ctx.mode !== "tui") {
|
|
898
|
+
ctx.ui.notify(`Writing the hand-off prompt with ${label}…`, "info");
|
|
899
|
+
return reportWriterOutcome(ctx, await attempt(), label);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
let aborted = false;
|
|
903
|
+
let outcome: WriterOutcome | undefined;
|
|
904
|
+
const prompt = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
905
|
+
// Abort and completion can race; `done` must only be called once.
|
|
906
|
+
let finished = false;
|
|
907
|
+
const finish = (value: string | null) => {
|
|
908
|
+
if (finished) return;
|
|
909
|
+
finished = true;
|
|
910
|
+
done(value);
|
|
911
|
+
};
|
|
912
|
+
const loader = new BorderedLoader(tui, theme, `Writing the hand-off prompt with ${label}…`);
|
|
913
|
+
loader.onAbort = () => {
|
|
914
|
+
aborted = true;
|
|
915
|
+
finish(null);
|
|
916
|
+
};
|
|
917
|
+
void attempt(loader.signal).then((result) => {
|
|
918
|
+
outcome = result;
|
|
919
|
+
finish(result.prompt ?? null);
|
|
920
|
+
});
|
|
921
|
+
return loader;
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
if (prompt) return prompt;
|
|
925
|
+
if (aborted) {
|
|
926
|
+
ctx.ui.notify("Hand-off cancelled", "info");
|
|
927
|
+
return undefined;
|
|
928
|
+
}
|
|
929
|
+
return reportWriterOutcome(ctx, outcome ?? { error: "returned no prompt" }, label);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ============================================================================
|
|
933
|
+
// Command arguments, writer choice, and settings
|
|
934
|
+
// ============================================================================
|
|
935
|
+
|
|
936
|
+
interface ParsedHandoffArgs {
|
|
937
|
+
writer?: "live" | "fresh";
|
|
938
|
+
/** `--same`: skip the destination pickers and reuse the current session's model and thinking level. */
|
|
939
|
+
same?: boolean;
|
|
940
|
+
/** Everything after the leading flags: the steering-editor prefill. */
|
|
941
|
+
steering: string;
|
|
942
|
+
error?: string;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Parse leading `/hand-off` flags; the first unrecognized token starts the steering text. */
|
|
946
|
+
export function parseHandoffArgs(args: string): ParsedHandoffArgs {
|
|
947
|
+
const tokens = args.split(/\s+/).filter(Boolean);
|
|
948
|
+
const parsed: ParsedHandoffArgs = { steering: "" };
|
|
949
|
+
let index = 0;
|
|
950
|
+
|
|
951
|
+
for (; index < tokens.length; index++) {
|
|
952
|
+
const token = tokens[index];
|
|
953
|
+
if (token === "--fresh" || token === "--live") {
|
|
954
|
+
const writer = token === "--fresh" ? "fresh" : "live";
|
|
955
|
+
if (parsed.writer && parsed.writer !== writer) {
|
|
956
|
+
return { ...parsed, error: "--fresh and --live cannot be combined; nothing was sent" };
|
|
957
|
+
}
|
|
958
|
+
parsed.writer = writer;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (token === "--same") {
|
|
962
|
+
parsed.same = true;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
parsed.steering = tokens.slice(index).join(" ");
|
|
969
|
+
return parsed;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export interface HandoffSettings {
|
|
973
|
+
summarizerModel?: string;
|
|
974
|
+
askSummarizerModel?: boolean;
|
|
975
|
+
/** Thinking level for the fresh pass; absent means the provider default. */
|
|
976
|
+
summarizerThinkingLevel?: ThinkingLevel;
|
|
977
|
+
maxSourceChars?: number;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const SETTINGS_FILE_NAME = "pi-clear.json";
|
|
981
|
+
const FLAG_MAX_SOURCE_CHARS = "hand-off-max-source-chars";
|
|
982
|
+
const FLAG_SUMMARIZER = "hand-off-summarizer";
|
|
983
|
+
|
|
984
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
985
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Read `<agent-dir>/pi-clear.json`; a malformed file is a warning, not a failure. */
|
|
989
|
+
export function loadHandoffSettings(path: string = join(getAgentDir(), SETTINGS_FILE_NAME)): {
|
|
990
|
+
settings: HandoffSettings;
|
|
991
|
+
warning?: string;
|
|
992
|
+
} {
|
|
993
|
+
let raw: string;
|
|
994
|
+
try {
|
|
995
|
+
raw = readFileSync(path, "utf8");
|
|
996
|
+
} catch (err) {
|
|
997
|
+
if ((err as { code?: string }).code === "ENOENT") return { settings: {} };
|
|
998
|
+
return { settings: {}, warning: `pi-clear: could not read ${path}: ${messageOf(err)}` };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
let parsed: unknown;
|
|
1002
|
+
try {
|
|
1003
|
+
parsed = JSON.parse(raw);
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
return { settings: {}, warning: `pi-clear: ignoring malformed ${path}: ${messageOf(err)}` };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (!isRecord(parsed) || !isRecord(parsed.handoff)) {
|
|
1009
|
+
return { settings: {}, warning: `pi-clear: ignoring ${path}: expected a "handoff" object` };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const handoff = parsed.handoff;
|
|
1013
|
+
const settings: HandoffSettings = {};
|
|
1014
|
+
const problems: string[] = [];
|
|
1015
|
+
|
|
1016
|
+
if (handoff.summarizerModel !== undefined) {
|
|
1017
|
+
if (typeof handoff.summarizerModel === "string" && handoff.summarizerModel.includes("/")) {
|
|
1018
|
+
settings.summarizerModel = handoff.summarizerModel.trim();
|
|
1019
|
+
} else {
|
|
1020
|
+
problems.push('"summarizerModel" must be a "provider/model" string');
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (handoff.summarizerThinkingLevel !== undefined) {
|
|
1024
|
+
const value = handoff.summarizerThinkingLevel;
|
|
1025
|
+
// "default" (and "off") mean "leave the choice to the provider", the same
|
|
1026
|
+
// as omitting the key.
|
|
1027
|
+
if (value === "default" || value === "off") {
|
|
1028
|
+
// Nothing to record.
|
|
1029
|
+
} else if (typeof value === "string" && WRITER_REASONING_LEVELS.includes(value as ThinkingLevel)) {
|
|
1030
|
+
settings.summarizerThinkingLevel = value as ThinkingLevel;
|
|
1031
|
+
} else {
|
|
1032
|
+
problems.push(
|
|
1033
|
+
`"summarizerThinkingLevel" must be "default" or one of ${WRITER_REASONING_LEVELS.join(", ")}`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (handoff.askSummarizerModel !== undefined) {
|
|
1038
|
+
if (typeof handoff.askSummarizerModel === "boolean") settings.askSummarizerModel = handoff.askSummarizerModel;
|
|
1039
|
+
else problems.push('"askSummarizerModel" must be true or false');
|
|
1040
|
+
}
|
|
1041
|
+
if (handoff.maxSourceChars !== undefined) {
|
|
1042
|
+
if (typeof handoff.maxSourceChars === "number" && Number.isFinite(handoff.maxSourceChars) && handoff.maxSourceChars >= 0) {
|
|
1043
|
+
settings.maxSourceChars = Math.floor(handoff.maxSourceChars);
|
|
1044
|
+
} else {
|
|
1045
|
+
problems.push('"maxSourceChars" must be a number >= 0');
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return problems.length > 0
|
|
1050
|
+
? { settings, warning: `pi-clear: ignoring ${path} ${problems.join(", ")}` }
|
|
1051
|
+
: { settings };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function messageOf(err: unknown): string {
|
|
1055
|
+
return err instanceof Error ? err.message : String(err);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Command arg > pi CLI flag > config file > built-in default. */
|
|
1059
|
+
export function resolveMaxSourceChars(flagValue: unknown, settings: HandoffSettings): number {
|
|
1060
|
+
if (typeof flagValue === "string" && flagValue.trim()) {
|
|
1061
|
+
const fromFlag = Number(flagValue);
|
|
1062
|
+
if (Number.isFinite(fromFlag) && fromFlag >= 0) return Math.floor(fromFlag);
|
|
1063
|
+
}
|
|
1064
|
+
if (settings.maxSourceChars !== undefined) return settings.maxSourceChars;
|
|
1065
|
+
return DEFAULT_MAX_SOURCE_CHARS;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** `118234` → `118k`, for the trimming notice. */
|
|
1069
|
+
function formatChars(chars: number): string {
|
|
1070
|
+
if (chars < 1000) return String(chars);
|
|
1071
|
+
if (chars < 1_000_000) return `${Math.round(chars / 1000)}k`;
|
|
1072
|
+
return `${(chars / 1_000_000).toFixed(1)}M`;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
type WriterChoice = { kind: "live" } | { kind: "fresh"; model: AnyModel };
|
|
1076
|
+
|
|
1077
|
+
interface WriterOptions {
|
|
1078
|
+
/** From `--fresh` / `--live`; skips the writer picker. */
|
|
1079
|
+
writer?: "live" | "fresh";
|
|
1080
|
+
/** Model pinned by command arg, pi flag, or config. */
|
|
1081
|
+
pinnedModel?: AnyModel;
|
|
1082
|
+
/** Whether the summarizer model picker still needs to open. */
|
|
1083
|
+
askSummarizer: boolean;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const WRITER_LIVE_LABEL = "This agent — full context and tools";
|
|
1087
|
+
const WRITER_FRESH_OTHER_LABEL = "Fresh pass — another model…";
|
|
1088
|
+
const SAME_SETTINGS_LABEL = "Same model and thinking level";
|
|
1089
|
+
const PICK_SETTINGS_LABEL = "Pick a model and thinking level…";
|
|
1090
|
+
|
|
1091
|
+
/** Pick the hand-off writer: the live agent, or a fresh pass with a chosen model. */
|
|
1092
|
+
async function chooseWriter(ctx: ExtensionContext, options: WriterOptions): Promise<WriterChoice | undefined> {
|
|
1093
|
+
const withCurrentModel = (): WriterChoice | undefined => {
|
|
1094
|
+
const model = options.pinnedModel ?? ctx.model;
|
|
1095
|
+
if (!model) {
|
|
1096
|
+
ctx.ui.notify("No current model to write the hand-off; pick another model", "error");
|
|
1097
|
+
return undefined;
|
|
1098
|
+
}
|
|
1099
|
+
return { kind: "fresh", model };
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
const askForModel = async (): Promise<WriterChoice | undefined> => {
|
|
1103
|
+
if (!options.askSummarizer) return withCurrentModel();
|
|
1104
|
+
const picked = await pickTargetModel(ctx, {
|
|
1105
|
+
current: options.pinnedModel ?? ctx.model,
|
|
1106
|
+
title: "Hand-off writer model",
|
|
1107
|
+
providerTitle: "Hand-off writer provider",
|
|
1108
|
+
});
|
|
1109
|
+
if (!picked) return undefined;
|
|
1110
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(picked.model)) {
|
|
1111
|
+
ctx.ui.notify(
|
|
1112
|
+
`No auth configured for ${picked.model.provider}/${picked.model.id}; staying in this session`,
|
|
1113
|
+
"error",
|
|
1114
|
+
);
|
|
1115
|
+
return undefined;
|
|
1116
|
+
}
|
|
1117
|
+
return { kind: "fresh", model: picked.model };
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
if (options.writer === "live") return { kind: "live" };
|
|
1121
|
+
if (options.writer === "fresh") return askForModel();
|
|
1122
|
+
|
|
1123
|
+
const labels = [
|
|
1124
|
+
WRITER_LIVE_LABEL,
|
|
1125
|
+
options.pinnedModel
|
|
1126
|
+
? `Fresh pass — ${options.pinnedModel.provider}/${options.pinnedModel.id}`
|
|
1127
|
+
: "Fresh pass — current model",
|
|
1128
|
+
WRITER_FRESH_OTHER_LABEL,
|
|
1129
|
+
];
|
|
1130
|
+
const chosen = await ctx.ui.select("Who writes the hand-off prompt?", labels);
|
|
1131
|
+
if (chosen === undefined) return undefined;
|
|
1132
|
+
|
|
1133
|
+
switch (labelIndex(chosen, labels)) {
|
|
1134
|
+
case 0:
|
|
1135
|
+
return { kind: "live" };
|
|
1136
|
+
case 1:
|
|
1137
|
+
return withCurrentModel();
|
|
1138
|
+
case 2:
|
|
1139
|
+
return askForModel();
|
|
1140
|
+
default:
|
|
1141
|
+
return undefined;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
374
1145
|
export default function (pi: ExtensionAPI) {
|
|
375
1146
|
// In-session state for the hand-off capture; fresh per extension instance,
|
|
376
1147
|
// so it never leaks into a replacement session.
|
|
377
1148
|
let awaitingHandoff = false;
|
|
378
1149
|
let capturedPrompt: string | undefined;
|
|
379
1150
|
let flowInProgress = false;
|
|
1151
|
+
// Config file contents, refreshed on every `session_start` (the file is read
|
|
1152
|
+
// once per session, not per hand-off). The pi CLI flag is read per hand-off
|
|
1153
|
+
// so it works even if a command somehow runs before `session_start`.
|
|
1154
|
+
let handoffSettings: HandoffSettings = {};
|
|
380
1155
|
|
|
381
1156
|
/**
|
|
382
1157
|
* Apply a captured model and thinking level, reporting problems as
|
|
@@ -409,6 +1184,67 @@ export default function (pi: ExtensionAPI) {
|
|
|
409
1184
|
}
|
|
410
1185
|
}
|
|
411
1186
|
|
|
1187
|
+
/** Ask the live agent to write its next message as the hand-off prompt. */
|
|
1188
|
+
async function captureFromLiveAgent(
|
|
1189
|
+
ctx: ExtensionCommandContext,
|
|
1190
|
+
steering: string,
|
|
1191
|
+
): Promise<string | undefined> {
|
|
1192
|
+
awaitingHandoff = true;
|
|
1193
|
+
capturedPrompt = undefined;
|
|
1194
|
+
try {
|
|
1195
|
+
await pi.sendMessage(
|
|
1196
|
+
{
|
|
1197
|
+
customType: HANDOFF_CUSTOM_TYPE,
|
|
1198
|
+
content: buildHandoffRequest(steering),
|
|
1199
|
+
display: true,
|
|
1200
|
+
},
|
|
1201
|
+
{ triggerTurn: true },
|
|
1202
|
+
);
|
|
1203
|
+
await ctx.waitForIdle();
|
|
1204
|
+
} finally {
|
|
1205
|
+
awaitingHandoff = false;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (!capturedPrompt) {
|
|
1209
|
+
ctx.ui.notify("The agent did not produce a hand-off prompt; staying in this session", "error");
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
return capturedPrompt;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** One fresh pass over the minimal transcript; notifies when it trims. */
|
|
1216
|
+
async function captureFromFreshPass(
|
|
1217
|
+
ctx: ExtensionContext,
|
|
1218
|
+
writer: WriterChoice & { kind: "fresh" },
|
|
1219
|
+
reasoning: ThinkingLevel | undefined,
|
|
1220
|
+
steering: string,
|
|
1221
|
+
): Promise<string | undefined> {
|
|
1222
|
+
const messages = ctx.sessionManager.buildSessionProjection().messages;
|
|
1223
|
+
const maxSourceChars = resolveMaxSourceChars(pi.getFlag(FLAG_MAX_SOURCE_CHARS), handoffSettings);
|
|
1224
|
+
const source = buildMinimalTranscript(messages, maxSourceChars);
|
|
1225
|
+
if (!source.text.trim()) {
|
|
1226
|
+
ctx.ui.notify("No conversation to hand off", "error");
|
|
1227
|
+
return undefined;
|
|
1228
|
+
}
|
|
1229
|
+
if (source.omittedTurns > 0) {
|
|
1230
|
+
ctx.ui.notify(
|
|
1231
|
+
`Hand-off source trimmed: ${source.omittedTurns} earlier turns omitted (~${formatChars(source.droppedChars)} chars dropped)`,
|
|
1232
|
+
"info",
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
return generateFreshPassPrompt(ctx, writer.model, source.text, steering, reasoning);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
pi.registerFlag(FLAG_MAX_SOURCE_CHARS, {
|
|
1239
|
+
description:
|
|
1240
|
+
"pi-clear: max characters of conversation sent to the /hand-off writer (0 disables the budget)",
|
|
1241
|
+
type: "string",
|
|
1242
|
+
});
|
|
1243
|
+
pi.registerFlag(FLAG_SUMMARIZER, {
|
|
1244
|
+
description: "pi-clear: model that writes /hand-off prompts, as provider/model",
|
|
1245
|
+
type: "string",
|
|
1246
|
+
});
|
|
1247
|
+
|
|
412
1248
|
pi.registerCommand("clear", {
|
|
413
1249
|
description: "Start a fresh session, keeping the current model and thinking level",
|
|
414
1250
|
handler: async (_args, ctx) => {
|
|
@@ -448,59 +1284,115 @@ export default function (pi: ExtensionAPI) {
|
|
|
448
1284
|
|
|
449
1285
|
pi.registerCommand("hand-off", {
|
|
450
1286
|
description:
|
|
451
|
-
"Hand the conversation to another model: pick the next model and thinking level,
|
|
1287
|
+
"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
1288
|
handler: async (args, ctx) => {
|
|
1289
|
+
if (flowInProgress) {
|
|
1290
|
+
ctx.ui.notify("A hand-off is already in progress", "warning");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
453
1293
|
try {
|
|
454
|
-
|
|
455
|
-
|
|
1294
|
+
flowInProgress = true;
|
|
1295
|
+
|
|
1296
|
+
const parsed = parseHandoffArgs(args);
|
|
1297
|
+
if (parsed.error) {
|
|
1298
|
+
ctx.ui.notify(parsed.error, "error");
|
|
456
1299
|
return;
|
|
457
1300
|
}
|
|
458
|
-
|
|
1301
|
+
|
|
459
1302
|
if (!ctx.isIdle()) {
|
|
460
1303
|
ctx.ui.notify("Waiting for the agent to finish before handing off", "info");
|
|
461
1304
|
await ctx.waitForIdle();
|
|
462
1305
|
}
|
|
463
1306
|
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
1307
|
+
// Writer model: pi CLI flag > config file. There is no command-arg
|
|
1308
|
+
// flag; without a configured preference the picker just opens.
|
|
1309
|
+
const flagSummarizer = pi.getFlag(FLAG_SUMMARIZER);
|
|
1310
|
+
const summarizerSpec =
|
|
1311
|
+
(typeof flagSummarizer === "string" && flagSummarizer.trim() ? flagSummarizer.trim() : undefined) ??
|
|
1312
|
+
handoffSettings.summarizerModel;
|
|
1313
|
+
const pinnedModel = summarizerSpec
|
|
1314
|
+
? ctx.modelRegistry.find(
|
|
1315
|
+
summarizerSpec.slice(0, summarizerSpec.indexOf("/")),
|
|
1316
|
+
summarizerSpec.slice(summarizerSpec.indexOf("/") + 1),
|
|
1317
|
+
)
|
|
1318
|
+
: undefined;
|
|
1319
|
+
if (summarizerSpec && !pinnedModel) {
|
|
1320
|
+
ctx.ui.notify(
|
|
1321
|
+
`Hand-off writer model ${summarizerSpec} is not available; choose one instead`,
|
|
1322
|
+
"warning",
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
// A pi CLI flag is not asked about again; a config-file model is only
|
|
1326
|
+
// silently accepted when `askSummarizerModel` is false.
|
|
1327
|
+
const pinnedByFlag = typeof flagSummarizer === "string";
|
|
1328
|
+
const askSummarizer =
|
|
1329
|
+
!pinnedModel || (!pinnedByFlag && handoffSettings.askSummarizerModel !== false);
|
|
471
1330
|
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1331
|
+
// 1. Choose who writes the hand-off prompt, and (for the fresh pass)
|
|
1332
|
+
// how hard that model should think.
|
|
1333
|
+
const writer = await chooseWriter(ctx, {
|
|
1334
|
+
writer: parsed.writer,
|
|
1335
|
+
pinnedModel,
|
|
1336
|
+
askSummarizer,
|
|
1337
|
+
});
|
|
1338
|
+
if (!writer) return;
|
|
478
1339
|
|
|
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;
|
|
1340
|
+
let writerReasoning: ThinkingLevel | undefined;
|
|
1341
|
+
if (writer.kind === "fresh" && writer.model.reasoning) {
|
|
1342
|
+
const chosen = await selectWriterReasoningLevel(ctx, handoffSettings.summarizerThinkingLevel);
|
|
1343
|
+
if (!chosen) return;
|
|
1344
|
+
writerReasoning = chosen.reasoning;
|
|
1345
|
+
}
|
|
492
1346
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
1347
|
+
// 2–3. Destination model and thinking level. The same-or-pick picker's
|
|
1348
|
+
// first option re-uses the current session's values in one Enter press;
|
|
1349
|
+
// `--same` skips that dialog. On the picked-model path a scoped
|
|
1350
|
+
// `model:level` entry pins the level.
|
|
1351
|
+
const reuse = parsed.same ? "same" : await chooseSameOrPick(ctx);
|
|
1352
|
+
if (!reuse) return;
|
|
1353
|
+
let target: PickedModel | undefined;
|
|
1354
|
+
let level: ThinkingLevel | undefined;
|
|
1355
|
+
if (reuse === "same") {
|
|
1356
|
+
if (!ctx.model) {
|
|
1357
|
+
ctx.ui.notify("--same: the current session has no model to reuse", "error");
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
target = { model: ctx.model };
|
|
1361
|
+
level = ctx.thinkingLevel;
|
|
1362
|
+
} else {
|
|
1363
|
+
target = await pickTargetModel(ctx);
|
|
1364
|
+
if (!target) return;
|
|
1365
|
+
level = target.thinkingLevel ?? (await selectThinkingLevel(ctx));
|
|
1366
|
+
if (level === undefined) return;
|
|
496
1367
|
}
|
|
497
1368
|
|
|
498
|
-
//
|
|
1369
|
+
// 4. Steering instructions: when passed inline as `/hand-off <instructions>`
|
|
1370
|
+
// they are used as-is; only an empty steering text opens the editor.
|
|
1371
|
+
const inlineSteering = parsed.steering.trim();
|
|
1372
|
+
const steering =
|
|
1373
|
+
inlineSteering ||
|
|
1374
|
+
(await ctx.ui.editor(
|
|
1375
|
+
writer.kind === "live"
|
|
1376
|
+
? "Steering for the hand-off prompt (sent to the current agent)"
|
|
1377
|
+
: "Steering for the hand-off prompt (sent to the hand-off writer)",
|
|
1378
|
+
"",
|
|
1379
|
+
));
|
|
1380
|
+
if (steering === undefined) return;
|
|
1381
|
+
|
|
1382
|
+
// 5. Write the hand-off prompt with the chosen writer.
|
|
1383
|
+
const prompt =
|
|
1384
|
+
writer.kind === "live"
|
|
1385
|
+
? await captureFromLiveAgent(ctx, steering)
|
|
1386
|
+
: await captureFromFreshPass(ctx, writer, writerReasoning, steering);
|
|
1387
|
+
if (!prompt) return;
|
|
1388
|
+
|
|
1389
|
+
// 6. Carry everything across session replacement as plain data, with
|
|
1390
|
+
// the provenance comment added here (never by the writer model).
|
|
499
1391
|
writeHandoff({
|
|
500
1392
|
provider: target.model.provider,
|
|
501
1393
|
modelId: target.model.id,
|
|
502
1394
|
thinkingLevel: level,
|
|
503
|
-
prompt:
|
|
1395
|
+
prompt: withSessionProvenance(prompt, ctx),
|
|
504
1396
|
});
|
|
505
1397
|
|
|
506
1398
|
const result = await ctx.newSession();
|
|
@@ -537,6 +1429,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
537
1429
|
});
|
|
538
1430
|
|
|
539
1431
|
pi.on("session_start", async (event, ctx) => {
|
|
1432
|
+
const loaded = loadHandoffSettings();
|
|
1433
|
+
handoffSettings = loaded.settings;
|
|
1434
|
+
if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
|
|
1435
|
+
|
|
540
1436
|
if (event.reason !== "new") return;
|
|
541
1437
|
|
|
542
1438
|
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.5.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",
|