@pi-unipi/btw 3.0.0-alpha.4 → 3.0.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -13
- package/extensions/btw.ts +134 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,23 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# BTW
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ask a side question while the main agent works, without adding it to the main conversation.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`@pi-unipi/btw` · part of [UniPi](../../README.md)
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## What it does
|
|
8
8
|
|
|
9
|
+
- Opens a panel in place of the input box. The main agent continues to run.
|
|
10
|
+
- Answers each question in a new, read-only session. This session starts with a copy of the main conversation, with tool calls that are in progress.
|
|
11
|
+
- Gives the side session four tools only: `read`, `grep`, `find` and `ls`. It cannot run commands or change files.
|
|
12
|
+
- Uses the model and the thinking level of the main session.
|
|
13
|
+
- Writes nothing to the main session. The main agent never sees the question or the answer.
|
|
14
|
+
- Keeps no memory between questions. Each question starts again from the main conversation.
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
UniPi installs this package:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:@pi-unipi/unipi
|
|
9
22
|
```
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
|
|
24
|
+
To install this package alone:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install npm:@pi-unipi/btw
|
|
15
28
|
```
|
|
16
29
|
|
|
17
|
-
|
|
30
|
+
Then ask a question:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
/unipi:btw what does the retry loop in client.ts do?
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
| Command | What it does |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `/unipi:btw [question]` | Opens the panel. If you give a question, the panel starts to answer it. |
|
|
41
|
+
|
|
42
|
+
In print mode (no UI), `/unipi:btw <question>` shows the answer as a notification. A question is necessary in this mode.
|
|
43
|
+
|
|
44
|
+
## Panel keys
|
|
45
|
+
|
|
46
|
+
| Key | What it does |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `Enter` | Asks the question in the input. It does nothing while an answer streams. |
|
|
49
|
+
| `↑` / `↓` | Shows an earlier or later question and answer. Works when the input is empty. |
|
|
50
|
+
| `PgUp` / `PgDn` | Scrolls a long answer. |
|
|
51
|
+
| `Ctrl+C` | Stops the answer that streams. If no answer streams, it closes the panel. |
|
|
52
|
+
| `Esc` | Closes the panel. An answer that streams continues and goes into the history. |
|
|
53
|
+
|
|
54
|
+
Run `/unipi:btw` again to open the history. Only one answer can stream at a time.
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
The panel shows each question, a line for each tool call, and the answer as markdown. The history stays in memory for the Pi session only. BTW clears it when a session starts or resumes, and when you move in the session tree.
|
|
59
|
+
|
|
60
|
+
If you want a change to your files, ask in the main conversation.
|
|
61
|
+
|
|
62
|
+
Older sessions can contain `btw-note` messages from an earlier BTW version. BTW still shows these messages and hides them from the model.
|
|
18
63
|
|
|
19
|
-
|
|
64
|
+
BTW comes from [pi-btw](https://github.com/Neuron-Mr-White/pi-btw) by Dan Bachelder.
|
|
20
65
|
|
|
21
|
-
|
|
66
|
+
## See also
|
|
22
67
|
|
|
23
|
-
|
|
68
|
+
- [Ask User](../ask-user/README.md)
|
|
69
|
+
- [Commands reference](../../docs/reference/commands.md)
|
package/extensions/btw.ts
CHANGED
|
@@ -147,16 +147,64 @@ export function buildSeedEntries(ctx: {
|
|
|
147
147
|
// ─── Page history (session-memory only, never persisted) ───────────────────
|
|
148
148
|
|
|
149
149
|
export interface BtwPage {
|
|
150
|
+
/** Stable id (`btw-<pid>-<n>`), shared by the TUI panel and the app bridge
|
|
151
|
+
* (the bridge's `btw_delta`/`btw_end` `id` and `btw_list` page ids). */
|
|
152
|
+
id: string;
|
|
150
153
|
question: string;
|
|
151
154
|
/** One line per tool call: "… Reading <p>" while running → "✓ <desc>" */
|
|
152
155
|
toolLines: string[];
|
|
153
156
|
answer: string;
|
|
157
|
+
/** Text streamed so far (the final `answer` lands at the end). */
|
|
158
|
+
partial: string;
|
|
159
|
+
/** Model reasoning streamed so far (kept for `btw_list` replays). */
|
|
160
|
+
thinking: string;
|
|
154
161
|
error?: string;
|
|
155
162
|
aborted?: boolean;
|
|
156
163
|
done: boolean;
|
|
164
|
+
/** Token usage for the one assistant response (bridge forwards it in `btw_end`). */
|
|
165
|
+
usage?: { input: number; output: number; totalTokens: number };
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
const pages: BtwPage[] = [];
|
|
169
|
+
let pageSeq = 0;
|
|
170
|
+
|
|
171
|
+
/** A fresh page with a process-unique id. */
|
|
172
|
+
export function newPage(question: string): BtwPage {
|
|
173
|
+
return { id: `btw-${process.pid}-${++pageSeq}`, question, toolLines: [], answer: "", partial: "", thinking: "", done: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One page as the app bridge's `btw_list` sends it. `question`/`answer`/
|
|
177
|
+
* `error` keep their original meaning (older apps read only those); `id`,
|
|
178
|
+
* `done`/`running` and `toolLines` let the app reconcile its own pages.
|
|
179
|
+
* A running page's `answer` is the text streamed so far. */
|
|
180
|
+
export interface BtwListPage {
|
|
181
|
+
id: string;
|
|
182
|
+
question: string;
|
|
183
|
+
answer: string;
|
|
184
|
+
error?: string;
|
|
185
|
+
done: boolean;
|
|
186
|
+
running: boolean;
|
|
187
|
+
toolLines?: string[];
|
|
188
|
+
/** Running pages only: the tail of the model's reasoning so far. */
|
|
189
|
+
thinking?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Tail kept for a running page's `thinking` in `btw_list`. */
|
|
193
|
+
const LIST_THINKING_TAIL = 2000;
|
|
194
|
+
|
|
195
|
+
/** Read-only snapshot of recent pages for a `btw_list{}` reply — never mutate the result. */
|
|
196
|
+
export function listPages(): BtwListPage[] {
|
|
197
|
+
return pages.map((p) => ({
|
|
198
|
+
id: p.id,
|
|
199
|
+
question: p.question,
|
|
200
|
+
answer: p.done ? p.answer : p.partial,
|
|
201
|
+
...(p.error ? { error: p.error } : {}),
|
|
202
|
+
done: p.done,
|
|
203
|
+
running: !p.done,
|
|
204
|
+
...(p.toolLines.length ? { toolLines: [...p.toolLines] } : {}),
|
|
205
|
+
...(!p.done && p.thinking ? { thinking: p.thinking.slice(-LIST_THINKING_TAIL) } : {}),
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
160
208
|
|
|
161
209
|
/** ↑/↓ paging reducer — clamp the target index into [0, count-1]. */
|
|
162
210
|
export function pageIndexAfter(current: number, delta: number, count: number): number {
|
|
@@ -210,12 +258,20 @@ export function formatToolLine(
|
|
|
210
258
|
return `${mark} ${label}${detail}`;
|
|
211
259
|
}
|
|
212
260
|
|
|
261
|
+
/** Delta kinds forwarded to `onDelta` (the bridge's UI-free API streams these
|
|
262
|
+
* to the phone as `btw_delta`; the TUI panel ignores it — it re-renders
|
|
263
|
+
* `page.answer`/`toolLines` from `onUpdate` instead). */
|
|
264
|
+
export type BtwDeltaKind = "text" | "thinking" | "tool";
|
|
265
|
+
|
|
213
266
|
function runQuestion(
|
|
214
267
|
ctx: ExtensionCommandContext,
|
|
215
268
|
question: string,
|
|
216
269
|
page: BtwPage,
|
|
217
270
|
thinkingLevel: AiThinkingLevel,
|
|
218
271
|
onUpdate: () => void,
|
|
272
|
+
/** `index` (tool deltas only): the `page.toolLines` slot this line fills —
|
|
273
|
+
* a tool's "done" line replaces its own "running" line. */
|
|
274
|
+
onDelta?: (kind: BtwDeltaKind, text: string, index?: number) => void,
|
|
219
275
|
): BtwRun {
|
|
220
276
|
let session: AgentSession | null = null;
|
|
221
277
|
let unsubscribe: (() => void) | null = null;
|
|
@@ -253,8 +309,10 @@ function runQuestion(
|
|
|
253
309
|
const e = event as { toolCallId: string; toolName: string; args?: Record<string, unknown> };
|
|
254
310
|
pendingStarts.set(e.toolCallId, { args: e.args });
|
|
255
311
|
lineIndex.set(e.toolCallId, page.toolLines.length);
|
|
256
|
-
|
|
312
|
+
const line = formatToolLine(e.toolName, e.args, ctx.cwd, "running");
|
|
313
|
+
page.toolLines.push(line);
|
|
257
314
|
onUpdate();
|
|
315
|
+
onDelta?.("tool", line, page.toolLines.length - 1);
|
|
258
316
|
} else if (event.type === "tool_execution_end") {
|
|
259
317
|
const e = event as { toolCallId: string; toolName: string; isError: boolean };
|
|
260
318
|
const start = pendingStarts.get(e.toolCallId);
|
|
@@ -264,6 +322,17 @@ function runQuestion(
|
|
|
264
322
|
else page.toolLines.push(line);
|
|
265
323
|
pendingStarts.delete(e.toolCallId);
|
|
266
324
|
onUpdate();
|
|
325
|
+
onDelta?.("tool", line, idx ?? page.toolLines.length - 1);
|
|
326
|
+
} else if (event.type === "message_update") {
|
|
327
|
+
const e = event as { assistantMessageEvent?: { type?: string; delta?: string } };
|
|
328
|
+
if (e.assistantMessageEvent?.type === "text_delta" && e.assistantMessageEvent.delta) {
|
|
329
|
+
page.partial += e.assistantMessageEvent.delta;
|
|
330
|
+
onDelta?.("text", e.assistantMessageEvent.delta);
|
|
331
|
+
}
|
|
332
|
+
else if (e.assistantMessageEvent?.type === "thinking_delta" && e.assistantMessageEvent.delta) {
|
|
333
|
+
page.thinking += e.assistantMessageEvent.delta;
|
|
334
|
+
onDelta?.("thinking", e.assistantMessageEvent.delta);
|
|
335
|
+
}
|
|
267
336
|
}
|
|
268
337
|
});
|
|
269
338
|
|
|
@@ -279,6 +348,7 @@ function runQuestion(
|
|
|
279
348
|
page.error = response.errorMessage || "request failed";
|
|
280
349
|
} else {
|
|
281
350
|
page.answer = extractText(response.content, "text") || "(no text)";
|
|
351
|
+
if (response.usage) page.usage = { input: response.usage.input, output: response.usage.output, totalTokens: response.usage.totalTokens };
|
|
282
352
|
}
|
|
283
353
|
} catch (err) {
|
|
284
354
|
page.error = err instanceof Error ? err.message : String(err);
|
|
@@ -455,7 +525,65 @@ class BtwPanel implements Component {
|
|
|
455
525
|
dispose(): void {}
|
|
456
526
|
}
|
|
457
527
|
|
|
458
|
-
// ───
|
|
528
|
+
// ─── UI-free API (the app bridge, no TUI needed) ───────────────────
|
|
529
|
+
|
|
530
|
+
/** One streamed update from `BtwApi.ask` (mirrors the bridge's `btw_delta`/`btw_end`). */
|
|
531
|
+
export type BtwEvent =
|
|
532
|
+
| { type: "delta"; kind: BtwDeltaKind; text: string; index?: number }
|
|
533
|
+
| { type: "end"; answer: string; error?: string; usage?: { input: number; output: number; totalTokens: number } };
|
|
534
|
+
|
|
535
|
+
export interface BtwApi {
|
|
536
|
+
/** Starts a question, streaming `onEvent` until it ends (also resolved by `finished`).
|
|
537
|
+
* Only one question runs at a time (shared with the TUI panel): a second call while
|
|
538
|
+
* one is in flight ends immediately with an error. */
|
|
539
|
+
ask(cctx: ExtensionCommandContext, question: string, onEvent: (event: BtwEvent) => void): { id: string; finished: Promise<void> };
|
|
540
|
+
/** Recent pages for this pi session — shared with the TUI panel. */
|
|
541
|
+
list(): BtwListPage[];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const BTW_API_KEY = Symbol.for("unipi.btw.api");
|
|
545
|
+
|
|
546
|
+
/** Publishes the UI-free API on globalThis so the app bridge (a sibling
|
|
547
|
+
* extension, no shared module instance guaranteed) can ask a side question
|
|
548
|
+
* without a TUI: `(globalThis as any)[Symbol.for("unipi.btw.api")]`. */
|
|
549
|
+
function publishUiFreeApi(pi: ExtensionAPI, getActiveRun: () => BtwRun | null, setActiveRun: (run: BtwRun | null) => void): void {
|
|
550
|
+
let idSeq = 0;
|
|
551
|
+
const api: BtwApi = {
|
|
552
|
+
ask(cctx, question, onEvent) {
|
|
553
|
+
if (getActiveRun()) {
|
|
554
|
+
const id = `btw-${process.pid}-busy-${++idSeq}`;
|
|
555
|
+
const finished = Promise.resolve().then(() => onEvent({ type: "end", answer: "", error: "btw is already answering another question" }));
|
|
556
|
+
return { id, finished };
|
|
557
|
+
}
|
|
558
|
+
const page = newPage(question);
|
|
559
|
+
const id = page.id;
|
|
560
|
+
pages.push(page);
|
|
561
|
+
const run = runQuestion(
|
|
562
|
+
cctx,
|
|
563
|
+
question,
|
|
564
|
+
page,
|
|
565
|
+
pi.getThinkingLevel() as AiThinkingLevel,
|
|
566
|
+
() => {},
|
|
567
|
+
(kind, text, index) => onEvent(index === undefined ? { type: "delta", kind, text } : { type: "delta", kind, text, index }),
|
|
568
|
+
);
|
|
569
|
+
setActiveRun(run);
|
|
570
|
+
const finished = run.finished.then(() => {
|
|
571
|
+
setActiveRun(null);
|
|
572
|
+
onEvent({ type: "end", answer: page.answer, error: page.error, usage: page.usage });
|
|
573
|
+
});
|
|
574
|
+
return { id, finished };
|
|
575
|
+
},
|
|
576
|
+
list: () => listPages(),
|
|
577
|
+
};
|
|
578
|
+
(globalThis as unknown as Record<symbol, unknown>)[BTW_API_KEY] = api;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Reads the published API, or undefined when @pi-unipi/btw is not loaded. */
|
|
582
|
+
export function getBtwApi(): BtwApi | undefined {
|
|
583
|
+
return (globalThis as unknown as Record<symbol, unknown>)[BTW_API_KEY] as BtwApi | undefined;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ─── Extension ─────────────────────────────────────────────────────────────────────
|
|
459
587
|
|
|
460
588
|
export default function btwExtension(pi: ExtensionAPI): void {
|
|
461
589
|
let panelRef: BtwPanel | null = null;
|
|
@@ -466,7 +594,7 @@ export default function btwExtension(pi: ExtensionAPI): void {
|
|
|
466
594
|
|
|
467
595
|
function startRun(ctx: ExtensionCommandContext, question: string): void {
|
|
468
596
|
if (activeRun) return; // never two answers at once
|
|
469
|
-
const page
|
|
597
|
+
const page = newPage(question);
|
|
470
598
|
const index = pages.length;
|
|
471
599
|
pages.push(page);
|
|
472
600
|
const run = runQuestion(ctx, question, page, pi.getThinkingLevel() as AiThinkingLevel, () => panelRef?.tick());
|
|
@@ -510,7 +638,7 @@ export default function btwExtension(pi: ExtensionAPI): void {
|
|
|
510
638
|
}
|
|
511
639
|
|
|
512
640
|
async function headlessAsk(ctx: ExtensionCommandContext, question: string): Promise<void> {
|
|
513
|
-
const page
|
|
641
|
+
const page = newPage(question);
|
|
514
642
|
pages.push(page);
|
|
515
643
|
notify(ctx, `btw: ${question}`, "info");
|
|
516
644
|
const run = runQuestion(ctx, question, page, pi.getThinkingLevel() as AiThinkingLevel, () => {});
|
|
@@ -520,6 +648,8 @@ export default function btwExtension(pi: ExtensionAPI): void {
|
|
|
520
648
|
notify(ctx, page.error ? `btw failed: ${page.error}` : `btw: ${page.answer}`, page.error ? "error" : "info");
|
|
521
649
|
}
|
|
522
650
|
|
|
651
|
+
publishUiFreeApi(pi, () => activeRun, (run) => (activeRun = run));
|
|
652
|
+
|
|
523
653
|
pi.on("session_start", () => {
|
|
524
654
|
abortActiveRun();
|
|
525
655
|
clearPages();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/btw",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.41",
|
|
4
4
|
"description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "extensions/btw.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@earendil-works/pi-tui": "^0.87.1"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@pi-unipi/core": "3.0.0-alpha.
|
|
40
|
+
"@pi-unipi/core": "3.0.0-alpha.41"
|
|
41
41
|
},
|
|
42
42
|
"pi": {
|
|
43
43
|
"extensions": [
|