@phi-code-admin/phi-code 0.83.0 → 0.84.1
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 +38 -0
- package/extensions/phi/btw/LICENSE +21 -0
- package/extensions/phi/btw/btw-ui.ts +238 -0
- package/extensions/phi/btw/btw.ts +363 -0
- package/extensions/phi/btw/index.ts +17 -0
- package/extensions/phi/btw/prompts/btw-system.txt +9 -0
- package/extensions/phi/chrome/LICENSE +21 -0
- package/extensions/phi/chrome/browser-extension/manifest.json +26 -0
- package/extensions/phi/chrome/browser-extension/service_worker.js +2388 -0
- package/extensions/phi/chrome/browser-extension/snapshot_injected.js +677 -0
- package/extensions/phi/chrome/index.ts +1799 -0
- package/extensions/phi/goal/LICENSE +21 -0
- package/extensions/phi/goal/index.ts +784 -0
- package/extensions/phi/todo/LICENSE +21 -0
- package/extensions/phi/todo/config.ts +14 -0
- package/extensions/phi/todo/index.ts +113 -0
- package/extensions/phi/todo/locales/de.json +17 -0
- package/extensions/phi/todo/locales/en.json +15 -0
- package/extensions/phi/todo/locales/es.json +17 -0
- package/extensions/phi/todo/locales/fr.json +17 -0
- package/extensions/phi/todo/locales/pt-BR.json +17 -0
- package/extensions/phi/todo/locales/pt.json +17 -0
- package/extensions/phi/todo/locales/ru.json +17 -0
- package/extensions/phi/todo/locales/uk.json +17 -0
- package/extensions/phi/todo/rpiv-config/config.ts +223 -0
- package/extensions/phi/todo/rpiv-config/index.ts +12 -0
- package/extensions/phi/todo/state/i18n-bridge.ts +65 -0
- package/extensions/phi/todo/state/invariants.ts +20 -0
- package/extensions/phi/todo/state/replay.ts +38 -0
- package/extensions/phi/todo/state/selectors.ts +107 -0
- package/extensions/phi/todo/state/state-reducer.ts +187 -0
- package/extensions/phi/todo/state/state.ts +18 -0
- package/extensions/phi/todo/state/store.ts +54 -0
- package/extensions/phi/todo/state/task-graph.ts +57 -0
- package/extensions/phi/todo/todo-overlay.ts +194 -0
- package/extensions/phi/todo/todo.ts +146 -0
- package/extensions/phi/todo/tool/response-envelope.ts +94 -0
- package/extensions/phi/todo/tool/types.ts +128 -0
- package/extensions/phi/todo/view/format.ts +162 -0
- package/package.json +1 -1
- package/scripts/postinstall.cjs +10 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.84.1] - 2026-06-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`phi` failed to start with "Cannot find module 'zod'" after the bundled MCP
|
|
8
|
+
extension shipped.** The postinstall scaffolds a copy of the bundled extensions
|
|
9
|
+
under `~/.phi/agent/extensions/` and symlinks their runtime deps into that
|
|
10
|
+
folder's `node_modules`, but it only linked the sigma packages, not `zod` or
|
|
11
|
+
`@modelcontextprotocol/sdk` (both added with the mcp extension in 0.83.0). The
|
|
12
|
+
global copy of `mcp/` could therefore not resolve `zod`. The postinstall now
|
|
13
|
+
also links `zod` and `@modelcontextprotocol/sdk`. (typebox and phi-code*
|
|
14
|
+
resolve through the loader's module aliases, so they do not need linking.)
|
|
15
|
+
|
|
16
|
+
## [0.84.0] - 2026-06-14
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Four new bundled extensions** (on by default, no install, fully self-contained,
|
|
21
|
+
vendored from MIT-licensed Pi packages and adapted for phi):
|
|
22
|
+
- **Goal Mode** (`/goal`): autonomous objective loop. Runs the agent toward a
|
|
23
|
+
goal, re-continuing after each turn until `goal_complete` or an optional
|
|
24
|
+
`/goal --tokens` budget is hit. From `@narumitw/pi-goal` (narumitw).
|
|
25
|
+
- **Todo** (`/todos` + a `todo` tool): persistent task list with a
|
|
26
|
+
`pending -> in_progress -> completed` state machine and dependency tracking.
|
|
27
|
+
From `@juicesharp/rpiv-todo` (juicesharp), with its `rpiv-config` helper
|
|
28
|
+
vendored; 8 UI locales ship.
|
|
29
|
+
- **Side Questions** (`/btw`): ask a one-off question on a read-only clone of
|
|
30
|
+
the context without polluting the transcript. From `@juicesharp/rpiv-btw`.
|
|
31
|
+
- **Chrome** (`/chrome` + `chrome_*` tools): drive your real signed-in Chrome
|
|
32
|
+
profile after an explicit, time-boxed grant. From `pi-chrome`. Highest-
|
|
33
|
+
privilege extension: while authorized the agent can act as you in your
|
|
34
|
+
authenticated browser; grant only for short, supervised tasks.
|
|
35
|
+
|
|
36
|
+
Adaptation: imports rewritten to `phi-code` / `phi-code-ai` / `phi-code-tui`,
|
|
37
|
+
config moved to the phi `configDir` (`.phi`, via `getAgentDir`), MIT LICENSE +
|
|
38
|
+
attribution kept in each `extensions/phi/<name>/`. No new npm dependencies
|
|
39
|
+
(typebox already present, rpiv-config vendored, btw/chrome have none).
|
|
40
|
+
|
|
3
41
|
## [0.83.0] - 2026-06-14
|
|
4
42
|
|
|
5
43
|
### Added
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 juicesharp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* btw-ui — dynamic-height bottom-slot overlay for /btw.
|
|
3
|
+
*
|
|
4
|
+
* Layout (grows with content, bottom-anchored, max = terminal height):
|
|
5
|
+
* banner (theme.bg stripe, padded to width) sticky top
|
|
6
|
+
* blank
|
|
7
|
+
* history — "/btw <q>" (accent prefix + muted text), left-padded 2 cols
|
|
8
|
+
* echo — "/btw <q>" (accent prefix + muted text), left-padded 2 cols
|
|
9
|
+
* blank
|
|
10
|
+
* answer — body wrapped at width-2, left-padded 2 cols
|
|
11
|
+
* blank
|
|
12
|
+
* footer — key hints (dim) sticky bottom
|
|
13
|
+
*
|
|
14
|
+
* Natural height = fixed(5: banner, 3 blanks, footer) + 2 (echo + 1 blank before answer)
|
|
15
|
+
* + history.length + answerLines.length.
|
|
16
|
+
* Pi-tui bottom-anchors the overlay so it grows upward with each /btw message.
|
|
17
|
+
* If natural height > terminal rows, we clip from the top (older history scrolls off)
|
|
18
|
+
* and ↑/↓ scroll the clip window.
|
|
19
|
+
*
|
|
20
|
+
* Keys (via matchesKey — handles ANSI + Kitty):
|
|
21
|
+
* Esc → abort in-flight call + dismiss
|
|
22
|
+
* ↑/↓ → scroll (when content exceeds terminal)
|
|
23
|
+
* x → clear current-session /btw history
|
|
24
|
+
* (f fork key deferred)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ExtensionCommandContext, Theme } from "phi-code";
|
|
28
|
+
import type { OverlayOptions } from "phi-code-tui";
|
|
29
|
+
import {
|
|
30
|
+
type Component,
|
|
31
|
+
Key,
|
|
32
|
+
matchesKey,
|
|
33
|
+
type TUI,
|
|
34
|
+
truncateToWidth,
|
|
35
|
+
visibleWidth,
|
|
36
|
+
wrapTextWithAnsi,
|
|
37
|
+
} from "phi-code-tui";
|
|
38
|
+
import { type BtwTurn, userMessageText } from "./btw.js";
|
|
39
|
+
|
|
40
|
+
const BTW_MAX_HEIGHT_RATIO = 0.85;
|
|
41
|
+
|
|
42
|
+
const BTW_OVERLAY_OPTIONS: OverlayOptions = {
|
|
43
|
+
anchor: "bottom-center",
|
|
44
|
+
width: "100%",
|
|
45
|
+
maxHeight: `${BTW_MAX_HEIGHT_RATIO * 100}%`,
|
|
46
|
+
margin: { left: 0, right: 0, bottom: 0 },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const SIDE_PAD = " "; // 2-col left gutter for history, echo, footer
|
|
50
|
+
const ANSWER_PAD = " "; // 4-col left gutter for answer body (double of SIDE_PAD)
|
|
51
|
+
const BTW_LITERAL = "/btw";
|
|
52
|
+
const PENDING_GLYPH = "…";
|
|
53
|
+
const FOOTER_SCROLL = "↑/↓ to scroll";
|
|
54
|
+
const FOOTER_CLEAR = "x to clear history";
|
|
55
|
+
const FOOTER_DISMISS = "Esc to dismiss";
|
|
56
|
+
const FOOTER_SEP = " · ";
|
|
57
|
+
|
|
58
|
+
type Mode = "pending" | "answer" | "error";
|
|
59
|
+
|
|
60
|
+
export interface ShowBtwOverlayParams {
|
|
61
|
+
ctx: ExtensionCommandContext;
|
|
62
|
+
question: string;
|
|
63
|
+
history: BtwTurn[];
|
|
64
|
+
controller: AbortController;
|
|
65
|
+
onClearHistory: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ShowBtwOverlayResult {
|
|
69
|
+
overlayPromise: Promise<void>;
|
|
70
|
+
controllerReady: Promise<BtwOverlayController>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class BtwOverlayController implements Component {
|
|
74
|
+
private mode: Mode = "pending";
|
|
75
|
+
private answer = "";
|
|
76
|
+
private error = "";
|
|
77
|
+
private scrollOffset = 0;
|
|
78
|
+
private history: BtwTurn[];
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
private readonly question: string,
|
|
82
|
+
history: BtwTurn[],
|
|
83
|
+
private readonly theme: Theme,
|
|
84
|
+
private readonly tui: TUI,
|
|
85
|
+
private readonly done: (result?: undefined) => void,
|
|
86
|
+
private readonly controller: AbortController,
|
|
87
|
+
private readonly onClearHistory: () => void,
|
|
88
|
+
) {
|
|
89
|
+
this.history = [...history];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setAnswer(text: string): void {
|
|
93
|
+
this.mode = "answer";
|
|
94
|
+
this.answer = text;
|
|
95
|
+
this.tui.requestRender();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setError(message: string): void {
|
|
99
|
+
this.mode = "error";
|
|
100
|
+
this.error = message;
|
|
101
|
+
this.tui.requestRender();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
handleInput(data: string): void {
|
|
105
|
+
if (matchesKey(data, Key.escape)) {
|
|
106
|
+
this.controller.abort();
|
|
107
|
+
this.done();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (matchesKey(data, Key.up)) {
|
|
111
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
112
|
+
this.tui.requestRender();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (matchesKey(data, Key.down)) {
|
|
116
|
+
this.scrollOffset = this.scrollOffset + 1;
|
|
117
|
+
this.tui.requestRender();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (data === "x") {
|
|
121
|
+
this.history = [];
|
|
122
|
+
this.onClearHistory();
|
|
123
|
+
this.scrollOffset = 0;
|
|
124
|
+
this.tui.requestRender();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
render(width: number): string[] {
|
|
130
|
+
const banner = this.renderBanner(width);
|
|
131
|
+
const historyLines = this.history.map((h) => this.historyLine(userMessageText(h.userMessage), width));
|
|
132
|
+
const echoLine = this.echoLine(this.question, width);
|
|
133
|
+
const answerLines = this.renderAnswer(width);
|
|
134
|
+
const footerAvail = Math.max(1, width - SIDE_PAD.length);
|
|
135
|
+
const footerParts: string[] = [];
|
|
136
|
+
if (this.mode !== "pending") footerParts.push(FOOTER_SCROLL);
|
|
137
|
+
if (this.history.length > 0) footerParts.push(FOOTER_CLEAR);
|
|
138
|
+
footerParts.push(FOOTER_DISMISS);
|
|
139
|
+
const footer =
|
|
140
|
+
SIDE_PAD + truncateToWidth(this.theme.fg("dim", footerParts.join(FOOTER_SEP)), footerAvail, "…", false);
|
|
141
|
+
|
|
142
|
+
// Natural content: banner + blank + history + echo + blank + answer + blank + footer
|
|
143
|
+
const natural: string[] = [banner, "", ...historyLines, echoLine, "", ...answerLines, "", footer];
|
|
144
|
+
|
|
145
|
+
// Clip to terminal height if we overflow. Bottom-anchor keeps footer+answer visible;
|
|
146
|
+
// ↑/↓ scrolls the top (history) up into the clipped region.
|
|
147
|
+
const termRows = (this.tui.terminal as { rows?: number }).rows ?? 24;
|
|
148
|
+
const maxRows = Math.max(4, Math.floor(termRows * BTW_MAX_HEIGHT_RATIO));
|
|
149
|
+
if (natural.length <= maxRows) {
|
|
150
|
+
return natural;
|
|
151
|
+
}
|
|
152
|
+
const excess = natural.length - maxRows;
|
|
153
|
+
if (this.scrollOffset > excess) this.scrollOffset = excess;
|
|
154
|
+
// scrollOffset=0 shows the BOTTOM (newest). Scrolling up reveals older history.
|
|
155
|
+
const start = excess - this.scrollOffset;
|
|
156
|
+
return natural.slice(start, start + maxRows);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
invalidate(): void {
|
|
160
|
+
// no-op — render recomputes from state each cycle
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private renderBanner(width: number): string {
|
|
164
|
+
const prefix = `${SIDE_PAD}${BTW_LITERAL} `;
|
|
165
|
+
const prefixWidth = visibleWidth(prefix);
|
|
166
|
+
const qAvail = Math.max(0, width - prefixWidth);
|
|
167
|
+
const qTrunc = truncateToWidth(this.question, qAvail, "…", false);
|
|
168
|
+
const raw = prefix + qTrunc;
|
|
169
|
+
const padded = raw + " ".repeat(Math.max(0, width - visibleWidth(raw)));
|
|
170
|
+
return this.theme.bg("customMessageBg", this.theme.fg("customMessageText", padded));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private historyLine(question: string, width: number): string {
|
|
174
|
+
const qAvail = Math.max(0, width - SIDE_PAD.length);
|
|
175
|
+
const qClean = question.replace(/\s+/g, " ").trim();
|
|
176
|
+
const raw = `${BTW_LITERAL} ${qClean}`;
|
|
177
|
+
const trunc = truncateToWidth(raw, qAvail, "…", false);
|
|
178
|
+
return SIDE_PAD + this.theme.fg("muted", trunc);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private echoLine(question: string, width: number): string {
|
|
182
|
+
const bodyAvail = Math.max(1, width - SIDE_PAD.length);
|
|
183
|
+
const prefixWidth = visibleWidth(BTW_LITERAL) + 1; // "/btw "
|
|
184
|
+
const qAvail = Math.max(0, bodyAvail - prefixWidth);
|
|
185
|
+
const qClean = question.replace(/\s+/g, " ").trim();
|
|
186
|
+
const qTrunc = truncateToWidth(qClean, qAvail, "…", false);
|
|
187
|
+
return `${SIDE_PAD + this.theme.fg("accent", BTW_LITERAL)} ${this.theme.fg("muted", qTrunc)}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private wrapBodyLines(text: string, bodyWidth: number, colorFn?: (s: string) => string): string[] {
|
|
191
|
+
const out: string[] = [];
|
|
192
|
+
for (const ln of text.split("\n")) {
|
|
193
|
+
const src = ln.length === 0 ? " " : ln;
|
|
194
|
+
const colored = colorFn ? colorFn(src) : src;
|
|
195
|
+
out.push(...wrapTextWithAnsi(colored, bodyWidth));
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private renderAnswer(width: number): string[] {
|
|
201
|
+
const bodyWidth = Math.max(1, width - ANSWER_PAD.length);
|
|
202
|
+
const indent = (lines: string[]) => lines.map((l) => ANSWER_PAD + l);
|
|
203
|
+
|
|
204
|
+
if (this.mode === "pending") {
|
|
205
|
+
return indent([this.theme.fg("warning", PENDING_GLYPH)]);
|
|
206
|
+
}
|
|
207
|
+
if (this.mode === "error") {
|
|
208
|
+
return indent(this.wrapBodyLines(this.error, bodyWidth, (s) => this.theme.fg("error", s)));
|
|
209
|
+
}
|
|
210
|
+
return indent(this.wrapBodyLines(this.answer, bodyWidth));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function showBtwOverlay(params: ShowBtwOverlayParams): ShowBtwOverlayResult {
|
|
215
|
+
let resolveReady!: (controller: BtwOverlayController) => void;
|
|
216
|
+
const controllerReady = new Promise<BtwOverlayController>((resolve) => {
|
|
217
|
+
resolveReady = resolve;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const overlayPromise = params.ctx.ui.custom<void>(
|
|
221
|
+
(tui, theme, _kb, done) => {
|
|
222
|
+
const controller = new BtwOverlayController(
|
|
223
|
+
params.question,
|
|
224
|
+
params.history,
|
|
225
|
+
theme,
|
|
226
|
+
tui,
|
|
227
|
+
done,
|
|
228
|
+
params.controller,
|
|
229
|
+
params.onClearHistory,
|
|
230
|
+
);
|
|
231
|
+
resolveReady(controller);
|
|
232
|
+
return controller;
|
|
233
|
+
},
|
|
234
|
+
{ overlay: true, overlayOptions: BTW_OVERLAY_OPTIONS },
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
return { overlayPromise, controllerReady };
|
|
238
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @juicesharp/rpiv-btw — /btw side-question slash command.
|
|
3
|
+
*
|
|
4
|
+
* Asks the same primary model a one-off side question using the cloned primary
|
|
5
|
+
* conversation as context. Answer is rendered ephemerally in a bottom-slot
|
|
6
|
+
* overlay (never enters main agent's messages). History persists per-session-file
|
|
7
|
+
* via globalThis-keyed storage; process-scoped only (no disk persistence).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import {
|
|
13
|
+
type AssistantMessage,
|
|
14
|
+
completeSimple,
|
|
15
|
+
type Message,
|
|
16
|
+
type StopReason,
|
|
17
|
+
type UserMessage,
|
|
18
|
+
} from "phi-code-ai";
|
|
19
|
+
import {
|
|
20
|
+
convertToLlm,
|
|
21
|
+
type ExtensionAPI,
|
|
22
|
+
type ExtensionCommandContext,
|
|
23
|
+
type ExtensionContext,
|
|
24
|
+
type SessionEntry,
|
|
25
|
+
} from "phi-code";
|
|
26
|
+
import { showBtwOverlay } from "./btw-ui.js";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Constants — flat named consts, grouped by concern (advisor pattern, b9428e9)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
// Identity
|
|
33
|
+
export const BTW_COMMAND_NAME = "btw";
|
|
34
|
+
|
|
35
|
+
// Storage — globalThis-keyed survives module re-import on /new, /fork, /resume.
|
|
36
|
+
// Lost on Pi process exit (intentional — no disk persistence).
|
|
37
|
+
export const BTW_STATE_KEY = Symbol.for("rpiv-btw");
|
|
38
|
+
|
|
39
|
+
// Cross-session pattern hint: how many recent question-strings to inject
|
|
40
|
+
export const CROSS_SESSION_HINT_LIMIT = 10;
|
|
41
|
+
|
|
42
|
+
// Messages (static)
|
|
43
|
+
const MSG_REQUIRES_INTERACTIVE = "/btw requires interactive mode";
|
|
44
|
+
const MSG_USAGE = "Usage: /btw <question>";
|
|
45
|
+
const MSG_NO_MODEL = "/btw requires an active model";
|
|
46
|
+
|
|
47
|
+
// Errors (static)
|
|
48
|
+
const ERR_EMPTY_RESPONSE = "/btw returned no text content.";
|
|
49
|
+
|
|
50
|
+
// Errors (parameterized)
|
|
51
|
+
const errMisconfigured = (label: string, err: string) => `/btw model (${label}) is misconfigured: ${err}`;
|
|
52
|
+
const errNoApiKey = (label: string) => `/btw model (${label}) has no API key available.`;
|
|
53
|
+
const errCallFailed = (err: string | undefined) => `/btw call failed: ${err ?? "unknown error"}`;
|
|
54
|
+
const errCallThrew = (msg: string) => `/btw call threw: ${msg}`;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Types
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
// Real messages — no fabrication. userMessage is built at call time; assistantMessage
|
|
61
|
+
// is the unmodified completeSimple response. Stable object references across calls →
|
|
62
|
+
// byte-identical prompt prefix on subsequent /btw invocations (cache parity).
|
|
63
|
+
export interface BtwTurn {
|
|
64
|
+
userMessage: UserMessage;
|
|
65
|
+
assistantMessage: AssistantMessage;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface BtwState {
|
|
69
|
+
histories: Map<string, BtwTurn[]>;
|
|
70
|
+
snapshots: Map<string, { messages: Message[] }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function branchToMessages(branch: SessionEntry[]): Message[] {
|
|
74
|
+
const agentMessages = branch
|
|
75
|
+
.filter((e): e is SessionEntry & { type: "message" } => e.type === "message")
|
|
76
|
+
.map((e) => e.message);
|
|
77
|
+
return convertToLlm(agentMessages);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// System prompt — loaded once at module init from prompts/btw-system.txt
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export const BTW_SYSTEM_PROMPT = readFileSync(
|
|
85
|
+
fileURLToPath(new URL("./prompts/btw-system.txt", import.meta.url)),
|
|
86
|
+
"utf-8",
|
|
87
|
+
).trimEnd();
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Storage — globalThis-keyed, survives module re-import on /new, /fork, /resume.
|
|
91
|
+
// Standard Node.js `globalThis + Symbol.for()` idiom for cross-import-graph
|
|
92
|
+
// singleton state (used by OpenTelemetry, etc.); lost on process exit.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function getState(): BtwState {
|
|
96
|
+
const g = globalThis as unknown as { [k: symbol]: BtwState | undefined };
|
|
97
|
+
let state = g[BTW_STATE_KEY];
|
|
98
|
+
if (!state) {
|
|
99
|
+
state = {
|
|
100
|
+
histories: new Map(),
|
|
101
|
+
snapshots: new Map(),
|
|
102
|
+
};
|
|
103
|
+
g[BTW_STATE_KEY] = state;
|
|
104
|
+
}
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getSessionFile(ctx: ExtensionContext): string {
|
|
109
|
+
return ctx.sessionManager.getSessionFile() ?? `memory:${ctx.sessionManager.getSessionId()}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getSessionHistory(ctx: ExtensionContext): BtwTurn[] {
|
|
113
|
+
const key = getSessionFile(ctx);
|
|
114
|
+
const state = getState();
|
|
115
|
+
let turns = state.histories.get(key);
|
|
116
|
+
if (!turns) {
|
|
117
|
+
turns = [];
|
|
118
|
+
state.histories.set(key, turns);
|
|
119
|
+
}
|
|
120
|
+
return turns;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pushSessionTurn(ctx: ExtensionContext, turn: BtwTurn): void {
|
|
124
|
+
getSessionHistory(ctx).push(turn);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function clearSessionHistory(ctx: ExtensionContext): void {
|
|
128
|
+
getState().histories.set(getSessionFile(ctx), []);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getSnapshot(ctx: ExtensionContext): { messages: Message[] } | undefined {
|
|
132
|
+
return getState().snapshots.get(getSessionFile(ctx));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setSnapshot(ctx: ExtensionContext, snapshot: { messages: Message[] }): void {
|
|
136
|
+
getState().snapshots.set(getSessionFile(ctx), snapshot);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function invalidateSnapshot(ctx: ExtensionContext): void {
|
|
140
|
+
getState().snapshots.delete(getSessionFile(ctx));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Extract text from a UserMessage's content.
|
|
144
|
+
export function userMessageText(msg: UserMessage): string {
|
|
145
|
+
if (typeof msg.content === "string") return msg.content;
|
|
146
|
+
return msg.content
|
|
147
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
148
|
+
.map((c) => c.text)
|
|
149
|
+
.join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Extract text from an AssistantMessage's content (text parts only).
|
|
153
|
+
export function assistantMessageText(msg: AssistantMessage): string {
|
|
154
|
+
return msg.content
|
|
155
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
156
|
+
.map((c) => c.text)
|
|
157
|
+
.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Cross-session pattern hint — last N question-strings across ALL sessions.
|
|
161
|
+
function getCrossSessionHint(): string {
|
|
162
|
+
const allTurns: { q: string; ts: number }[] = [];
|
|
163
|
+
for (const turns of getState().histories.values()) {
|
|
164
|
+
for (const t of turns) {
|
|
165
|
+
allTurns.push({ q: userMessageText(t.userMessage), ts: t.userMessage.timestamp });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (allTurns.length === 0) return "";
|
|
169
|
+
const recent = allTurns.sort((a, b) => a.ts - b.ts).slice(-CROSS_SESSION_HINT_LIMIT);
|
|
170
|
+
const lines = recent.map((t, i) => `${i + 1}. ${t.q.replace(/\s+/g, " ").slice(0, 200)}`);
|
|
171
|
+
return `\n\n## Recent /btw questions across sessions (oldest first)\n\n${lines.join("\n")}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Executor — auth, message threading, completeSimple, four StopReason branches
|
|
176
|
+
// Modeled after rpiv-advisor/advisor.ts:225-336
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export type BtwExecResult =
|
|
180
|
+
| { ok: true; answer: string; userMessage: UserMessage; assistantMessage: AssistantMessage; stopReason: StopReason }
|
|
181
|
+
| { ok: false; error: string; stopReason?: StopReason }
|
|
182
|
+
| { ok: false; aborted: true; stopReason: StopReason };
|
|
183
|
+
|
|
184
|
+
function readBranchMessages(ctx: ExtensionContext): Message[] {
|
|
185
|
+
const cached = getSnapshot(ctx);
|
|
186
|
+
if (cached) return cached.messages;
|
|
187
|
+
// Cold start (no message_end fired yet) — fall back to live read
|
|
188
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
189
|
+
return branchToMessages(branch);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildBtwMessages(ctx: ExtensionContext, userMessage: UserMessage): Message[] {
|
|
193
|
+
const branchMessages = readBranchMessages(ctx);
|
|
194
|
+
const history = getSessionHistory(ctx);
|
|
195
|
+
// Reusing stored real UserMessage/AssistantMessage object references across calls
|
|
196
|
+
// preserves byte-identical prompt prefix (cache parity).
|
|
197
|
+
const historyMessages: Message[] = history.flatMap((h) => [h.userMessage, h.assistantMessage]);
|
|
198
|
+
return [...branchMessages, ...historyMessages, userMessage];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildSystemPrompt(): string {
|
|
202
|
+
return BTW_SYSTEM_PROMPT + getCrossSessionHint();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function executeBtw(
|
|
206
|
+
question: string,
|
|
207
|
+
ctx: ExtensionContext,
|
|
208
|
+
controller: AbortController,
|
|
209
|
+
): Promise<BtwExecResult> {
|
|
210
|
+
const model = ctx.model;
|
|
211
|
+
if (!model) {
|
|
212
|
+
return { ok: false, error: MSG_NO_MODEL };
|
|
213
|
+
}
|
|
214
|
+
const modelLabel = `${model.provider}:${model.id}`;
|
|
215
|
+
|
|
216
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
217
|
+
if (!auth.ok) {
|
|
218
|
+
return { ok: false, error: errMisconfigured(modelLabel, auth.error) };
|
|
219
|
+
}
|
|
220
|
+
if (!auth.apiKey) {
|
|
221
|
+
return { ok: false, error: errNoApiKey(modelLabel) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const userMessage: UserMessage = {
|
|
225
|
+
role: "user",
|
|
226
|
+
content: [{ type: "text", text: question }],
|
|
227
|
+
timestamp: Date.now(),
|
|
228
|
+
};
|
|
229
|
+
const messages = buildBtwMessages(ctx, userMessage);
|
|
230
|
+
const systemPrompt = buildSystemPrompt();
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const response = await completeSimple(
|
|
234
|
+
model,
|
|
235
|
+
{ systemPrompt, messages, tools: [] },
|
|
236
|
+
{
|
|
237
|
+
apiKey: auth.apiKey,
|
|
238
|
+
headers: auth.headers,
|
|
239
|
+
signal: controller.signal, // own AbortController, NOT ctx.signal (Decision 8)
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
if (response.stopReason === "aborted") {
|
|
244
|
+
return { ok: false, aborted: true, stopReason: response.stopReason };
|
|
245
|
+
}
|
|
246
|
+
if (response.stopReason === "error") {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: errCallFailed(response.errorMessage),
|
|
250
|
+
stopReason: response.stopReason,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const answerText = assistantMessageText(response).trim();
|
|
255
|
+
if (!answerText) {
|
|
256
|
+
return { ok: false, error: ERR_EMPTY_RESPONSE, stopReason: response.stopReason };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
answer: answerText,
|
|
262
|
+
userMessage,
|
|
263
|
+
assistantMessage: response,
|
|
264
|
+
stopReason: response.stopReason,
|
|
265
|
+
};
|
|
266
|
+
} catch (err) {
|
|
267
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
268
|
+
if (controller.signal.aborted) {
|
|
269
|
+
return { ok: false, aborted: true, stopReason: "aborted" as const };
|
|
270
|
+
}
|
|
271
|
+
return { ok: false, error: errCallThrew(message) };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Registrars — 3 hooks total: command + message_end snapshot + compact/tree invalidate
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
export function registerMessageEndSnapshot(pi: ExtensionAPI): void {
|
|
280
|
+
pi.on("message_end", async (event, ctx) => {
|
|
281
|
+
const msg = event.message;
|
|
282
|
+
if (msg.role !== "assistant") return;
|
|
283
|
+
if ((msg as AssistantMessage).stopReason === "toolUse") return;
|
|
284
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
285
|
+
setSnapshot(ctx, { messages: branchToMessages(branch) });
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function registerInvalidationHooks(pi: ExtensionAPI): void {
|
|
290
|
+
pi.on("session_compact", async (_e, ctx) => safeInvalidateSnapshot(ctx));
|
|
291
|
+
pi.on("session_tree", async (_e, ctx) => safeInvalidateSnapshot(ctx));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Auto-compaction races session disposal: pi-core invalidates the extension
|
|
295
|
+
// runner while still emitting session_compact, so `ctx` may be a dead proxy
|
|
296
|
+
// whose getters throw the stale error. The compacting session is being
|
|
297
|
+
// discarded — there is no snapshot worth invalidating — so swallow only the
|
|
298
|
+
// stale error. Any other error is a real bug and must propagate.
|
|
299
|
+
function safeInvalidateSnapshot(ctx: ExtensionContext): void {
|
|
300
|
+
try {
|
|
301
|
+
invalidateSnapshot(ctx);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
if (!isStaleCtxError(e)) throw e;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// pi-core's ExtensionRunner throws this exact phrase from an invalidated ctx
|
|
308
|
+
// proxy after session replacement/reload. Match the stable substring.
|
|
309
|
+
function isStaleCtxError(e: unknown): boolean {
|
|
310
|
+
return /stale after session replacement/.test(String(e));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function registerBtwCommand(pi: ExtensionAPI): void {
|
|
314
|
+
pi.registerCommand(BTW_COMMAND_NAME, {
|
|
315
|
+
description: "Ask a side question without polluting the main conversation",
|
|
316
|
+
handler: (args: string, ctx: ExtensionCommandContext) => handleBtwCommand(pi, args, ctx),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function handleBtwCommand(_pi: ExtensionAPI, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
321
|
+
if (!ctx.hasUI) {
|
|
322
|
+
ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const question = args.trim();
|
|
326
|
+
if (!question) {
|
|
327
|
+
ctx.ui.notify(MSG_USAGE, "warning");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!ctx.model) {
|
|
331
|
+
ctx.ui.notify(MSG_NO_MODEL, "error");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const controller = new AbortController();
|
|
336
|
+
const historySnapshot = [...getSessionHistory(ctx)];
|
|
337
|
+
|
|
338
|
+
const { overlayPromise, controllerReady } = showBtwOverlay({
|
|
339
|
+
ctx,
|
|
340
|
+
question,
|
|
341
|
+
history: historySnapshot,
|
|
342
|
+
controller,
|
|
343
|
+
onClearHistory: () => clearSessionHistory(ctx),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const overlayCtl = await controllerReady;
|
|
347
|
+
const result = await executeBtw(question, ctx, controller);
|
|
348
|
+
|
|
349
|
+
if (result.ok) {
|
|
350
|
+
overlayCtl.setAnswer(result.answer);
|
|
351
|
+
pushSessionTurn(ctx, {
|
|
352
|
+
userMessage: result.userMessage,
|
|
353
|
+
assistantMessage: result.assistantMessage,
|
|
354
|
+
});
|
|
355
|
+
// No disk persistence — process-scoped only (Decision 4)
|
|
356
|
+
} else if ("aborted" in result) {
|
|
357
|
+
// User Esc'd — overlay already dismissed via done(); no further action
|
|
358
|
+
} else {
|
|
359
|
+
overlayCtl.setError(result.error);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
await overlayPromise;
|
|
363
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @juicesharp/rpiv-btw — Pi extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Registers /btw command + 2 lifecycle hooks (message_end snapshot,
|
|
5
|
+
* session_compact/tree invalidation). No tool, no model picker, no disk
|
|
6
|
+
* persistence. History lives in process-scoped globalThis state — survives
|
|
7
|
+
* /new, /fork, /reload, /resume; lost on Pi process exit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "phi-code";
|
|
11
|
+
import { registerBtwCommand, registerInvalidationHooks, registerMessageEndSnapshot } from "./btw.js";
|
|
12
|
+
|
|
13
|
+
export default function (pi: ExtensionAPI): void {
|
|
14
|
+
registerBtwCommand(pi);
|
|
15
|
+
registerMessageEndSnapshot(pi);
|
|
16
|
+
registerInvalidationHooks(pi);
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
You are answering a quick side question while the user's main pi session continues working.
|
|
2
|
+
|
|
3
|
+
You are given the user's primary conversation as the message context — treat it as background. Do NOT try to "continue" the assistant's prior work or pick up a tool call mid-flight; the side question is its own self-contained ask.
|
|
4
|
+
|
|
5
|
+
Answer directly and concisely. Prefer compact bullets or short paragraphs. Cite files, functions, and line numbers when grounding a claim in the context. If the context is insufficient to answer, say so briefly instead of guessing.
|
|
6
|
+
|
|
7
|
+
You have NO tools available. You will NOT call tools, even if the prior assistant turns demonstrate tool use. Reply in plain text only.
|
|
8
|
+
|
|
9
|
+
When a "Recent /btw questions across sessions" appendix is present below, treat it as a high-level pattern hint about what the user has been thinking about lately — useful only when the side question explicitly asks about patterns, trends, or recent topics.
|