pi-better-btw-plus 1.0.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/LICENSE +23 -0
- package/README.md +251 -0
- package/README.zh-CN.md +252 -0
- package/banner.png +0 -0
- package/config.json +23 -0
- package/package.json +69 -0
- package/prompts/btw-focus-anchor.md +1 -0
- package/prompts/btw-framing.md +8 -0
- package/prompts/lane-failed-note.md +1 -0
- package/prompts/lane-preamble.md +1 -0
- package/prompts/lane-reminder-base.md +1 -0
- package/prompts/lane-reminder-escalated.md +1 -0
- package/srcs/clipboard-read.ts +339 -0
- package/srcs/config.ts +341 -0
- package/srcs/file-activity-tracker.ts +21 -0
- package/srcs/fork-surgery.ts +106 -0
- package/srcs/index.ts +381 -0
- package/srcs/model-switch.ts +60 -0
- package/srcs/prompt-pack.ts +145 -0
- package/srcs/retry.ts +360 -0
- package/srcs/shortcuts.ts +4 -0
- package/srcs/side-chat-export.ts +302 -0
- package/srcs/side-chat-messages.ts +479 -0
- package/srcs/side-chat-mouse.ts +108 -0
- package/srcs/side-chat-overlay.ts +1513 -0
- package/srcs/tool-wrapper.ts +225 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
🚧 Lane blocked: `{{tool}}` is not available in this side chat. You are the btw side chat — a quick-question lane parallel to the main agent; the main line is the main agent's job. Answer the latest user message only, with read-only tools (read/grep/find/ls + allowlisted tools). If a write is genuinely needed, tell the user to switch to edit mode (Ctrl+T).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
🚧 You have now attempted out-of-lane tools {{count}} times this turn. Stop trying to write or execute. Answer the latest user message with read-only tools, or report back that you cannot.
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard reader (D3, issue #4): read plain text from the system clipboard
|
|
3
|
+
* with an injected platform-channel matrix and level-by-level fallback.
|
|
4
|
+
*
|
|
5
|
+
* The write side is pi's own `copyToClipboard` (public export); the read side
|
|
6
|
+
* is self-built because `readClipboardText` is not re-exported from the
|
|
7
|
+
* package entry. Each platform maps to a primary channel:
|
|
8
|
+
*
|
|
9
|
+
* - win32 → PowerShell `Get-Clipboard -Raw` (same channel family as pi's
|
|
10
|
+
* clipboard-image reader), then an OSC 52 query;
|
|
11
|
+
* - darwin → `pbpaste`, then an OSC 52 query;
|
|
12
|
+
* - linux → OSC 52 query (`\x1b]52;c;?\x07`, read the reply).
|
|
13
|
+
*
|
|
14
|
+
* Channels are injected functions, so tests mock success / fallback /
|
|
15
|
+
* all-fail without touching the host clipboard. The reader never throws:
|
|
16
|
+
* a broken channel falls through to the next one and total failure resolves
|
|
17
|
+
* to `{ ok: false, reason: "unavailable" }` — whether to surface a hint is
|
|
18
|
+
* the caller's decision.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Outcome of reading the system clipboard: text, an explicitly empty
|
|
24
|
+
* clipboard (a channel ran and saw nothing), or no usable channel at all.
|
|
25
|
+
* The empty/unavailable distinction lets callers show a reason-specific
|
|
26
|
+
* hint ("Clipboard is empty" vs "Clipboard read failed").
|
|
27
|
+
*/
|
|
28
|
+
export type ClipboardReadOutcome =
|
|
29
|
+
| { ok: true; text: string }
|
|
30
|
+
| { ok: false; reason: "empty" | "unavailable" };
|
|
31
|
+
|
|
32
|
+
/** A single clipboard read channel. */
|
|
33
|
+
export interface ClipboardReadChannel {
|
|
34
|
+
/** Channel identity for diagnostics and ordering assertions. */
|
|
35
|
+
readonly name: string;
|
|
36
|
+
/**
|
|
37
|
+
* Read plain text. Resolve `{ ok: false, reason: "empty" }` when the
|
|
38
|
+
* channel ran and found the clipboard empty, `unavailable` when it could
|
|
39
|
+
* not run at all (tool missing / non-zero exit / timeout / no reply).
|
|
40
|
+
*/
|
|
41
|
+
readonly read: () => Promise<ClipboardReadOutcome>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Result of running one subprocess-based channel. */
|
|
45
|
+
export interface ExecResult {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
stdout: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExecOptions {
|
|
51
|
+
timeoutMs: number;
|
|
52
|
+
maxBuffer: number;
|
|
53
|
+
env?: NodeJS.ProcessEnv;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Injected subprocess runner (tests replace with a fake). */
|
|
57
|
+
export type ExecFn = (
|
|
58
|
+
command: string,
|
|
59
|
+
args: readonly string[],
|
|
60
|
+
options: ExecOptions,
|
|
61
|
+
) => ExecResult;
|
|
62
|
+
|
|
63
|
+
/** Default subprocess runner: synchronous, mirrors pi's `runCommand`. */
|
|
64
|
+
export function defaultExec(
|
|
65
|
+
command: string,
|
|
66
|
+
args: readonly string[],
|
|
67
|
+
options: ExecOptions,
|
|
68
|
+
): ExecResult {
|
|
69
|
+
try {
|
|
70
|
+
const result = spawnSync(command, [...args], {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
timeout: options.timeoutMs,
|
|
73
|
+
maxBuffer: options.maxBuffer,
|
|
74
|
+
env: options.env,
|
|
75
|
+
});
|
|
76
|
+
if (result.error) return { ok: false, stdout: "" };
|
|
77
|
+
if (result.status !== 0) return { ok: false, stdout: "" };
|
|
78
|
+
return { ok: true, stdout: result.stdout };
|
|
79
|
+
} catch {
|
|
80
|
+
return { ok: false, stdout: "" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Try channels in order and return the first usable outcome. A channel that
|
|
86
|
+
* throws is skipped like one that reports unavailable; an explicitly empty
|
|
87
|
+
* clipboard is a definitive answer and stops the cascade — the reader
|
|
88
|
+
* never throws.
|
|
89
|
+
*/
|
|
90
|
+
export async function readClipboardText(
|
|
91
|
+
channels: readonly ClipboardReadChannel[],
|
|
92
|
+
): Promise<ClipboardReadOutcome> {
|
|
93
|
+
for (const channel of channels) {
|
|
94
|
+
try {
|
|
95
|
+
const outcome = await channel.read();
|
|
96
|
+
if (outcome.ok) return outcome;
|
|
97
|
+
if (outcome.reason === "empty") return outcome;
|
|
98
|
+
} catch {
|
|
99
|
+
// Fall through to the next channel.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { ok: false, reason: "unavailable" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Strip the single trailing newline CLI tools append to their output. */
|
|
106
|
+
function stripOneTrailingNewline(text: string): string {
|
|
107
|
+
if (text.endsWith("\r\n")) return text.slice(0, -2);
|
|
108
|
+
if (text.endsWith("\n")) return text.slice(0, -1);
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 5000; // pi's PowerShell channel uses 5s too
|
|
113
|
+
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
114
|
+
|
|
115
|
+
export interface CommandChannelOptions {
|
|
116
|
+
/** Subprocess runner override (tests). Defaults to {@link defaultExec}. */
|
|
117
|
+
exec?: ExecFn;
|
|
118
|
+
env?: NodeJS.ProcessEnv;
|
|
119
|
+
timeoutMs?: number;
|
|
120
|
+
maxBuffer?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* PowerShell channel: `powershell.exe -NoProfile -Command "Get-Clipboard
|
|
125
|
+
* -Raw"`, mirroring pi's clipboard-image PowerShell channel family. The
|
|
126
|
+
* `-Raw` flag preserves the exact text (no extra newline), and the output
|
|
127
|
+
* encoding is pinned to UTF-8 because the text travels over the console
|
|
128
|
+
* pipe (pi's image reader sidesteps this by writing a temp file).
|
|
129
|
+
*/
|
|
130
|
+
const PS_GET_CLIPBOARD_SCRIPT =
|
|
131
|
+
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard -Raw";
|
|
132
|
+
|
|
133
|
+
/** Shared shape of the subprocess-based channels: run → strip → outcome. */
|
|
134
|
+
function makeCommandChannel(
|
|
135
|
+
name: string,
|
|
136
|
+
command: string,
|
|
137
|
+
args: readonly string[],
|
|
138
|
+
options: CommandChannelOptions,
|
|
139
|
+
): ClipboardReadChannel {
|
|
140
|
+
const exec = options.exec ?? defaultExec;
|
|
141
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
142
|
+
const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
143
|
+
return {
|
|
144
|
+
name,
|
|
145
|
+
async read() {
|
|
146
|
+
let result: ExecResult;
|
|
147
|
+
try {
|
|
148
|
+
result = exec(command, args, { timeoutMs, maxBuffer, env: options.env });
|
|
149
|
+
} catch {
|
|
150
|
+
return { ok: false, reason: "unavailable" };
|
|
151
|
+
}
|
|
152
|
+
if (!result.ok) return { ok: false, reason: "unavailable" };
|
|
153
|
+
const text = stripOneTrailingNewline(result.stdout);
|
|
154
|
+
// An exit-0 empty output means the clipboard holds no text.
|
|
155
|
+
return text.length > 0
|
|
156
|
+
? { ok: true, text }
|
|
157
|
+
: { ok: false, reason: "empty" };
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* PowerShell channel: `powershell.exe -NoProfile -Command "Get-Clipboard
|
|
164
|
+
* -Raw"`, mirroring pi's clipboard-image PowerShell channel family. The
|
|
165
|
+
* `-Raw` flag preserves the exact text (no extra newline), and the output
|
|
166
|
+
* encoding is pinned to UTF-8 because the text travels over the console
|
|
167
|
+
* pipe (pi's image reader sidesteps this by writing a temp file).
|
|
168
|
+
*/
|
|
169
|
+
export function makePowerShellChannel(
|
|
170
|
+
options: CommandChannelOptions = {},
|
|
171
|
+
): ClipboardReadChannel {
|
|
172
|
+
return makeCommandChannel(
|
|
173
|
+
"powershell",
|
|
174
|
+
"powershell.exe",
|
|
175
|
+
["-NoProfile", "-Command", PS_GET_CLIPBOARD_SCRIPT],
|
|
176
|
+
options,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** macOS channel: `pbpaste` (adds a trailing newline the strip removes). */
|
|
181
|
+
export function makePbpasteChannel(
|
|
182
|
+
options: CommandChannelOptions = {},
|
|
183
|
+
): ClipboardReadChannel {
|
|
184
|
+
return makeCommandChannel("pbpaste", "pbpaste", [], options);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** OSC 52 clipboard query: query the clipboard (c = clipboard, ? = read). */
|
|
188
|
+
export const OSC52_QUERY = "\x1b]52;c;?\x07";
|
|
189
|
+
|
|
190
|
+
/** Terminal reply envelope: `\x1b]52;c;<base64>` ended by BEL or ST. */
|
|
191
|
+
const OSC52_REPLY_RE = /\x1b\]52;c;([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
192
|
+
|
|
193
|
+
/** Base64 payload guard (terminal replies are always well-formed). */
|
|
194
|
+
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
195
|
+
|
|
196
|
+
/** Decode an OSC 52 base64 payload; null for empty / echoed / malformed. */
|
|
197
|
+
export function decodeOsc52Payload(payload: string): string | null {
|
|
198
|
+
if (payload.length === 0 || payload === "?" || !BASE64_RE.test(payload)) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
return Buffer.from(payload, "base64").toString("utf8");
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract and decode the text from a raw OSC 52 reply. Accepts both BEL
|
|
210
|
+
* (`\x07`) and ST (`ESC \`) terminators, and replies that arrive split
|
|
211
|
+
* across stdin chunks (the regex matches once the sequence is complete).
|
|
212
|
+
* Returns null for empty / echoed-query / malformed replies.
|
|
213
|
+
*/
|
|
214
|
+
export function parseOsc52Reply(raw: string): string | null {
|
|
215
|
+
const match = OSC52_REPLY_RE.exec(raw);
|
|
216
|
+
if (!match) return null;
|
|
217
|
+
const text = decodeOsc52Payload(match[1]);
|
|
218
|
+
return text && text.length > 0 ? text : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface Osc52ChannelOptions {
|
|
222
|
+
/** Query writer override (tests). Defaults to `process.stdout.write`. */
|
|
223
|
+
write?: (sequence: string) => void;
|
|
224
|
+
/**
|
|
225
|
+
* Reply reader override (tests). Defaults to a best-effort
|
|
226
|
+
* `process.stdin` listener with a timeout. The overlay integration
|
|
227
|
+
* (right-click paste, C2) may supply its own reader that intercepts the
|
|
228
|
+
* reply before pi-tui's StdinBuffer sees it.
|
|
229
|
+
*/
|
|
230
|
+
readReply?: (timeoutMs: number) => Promise<string | null>;
|
|
231
|
+
timeoutMs?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const OSC52_READ_TIMEOUT_MS = 2000;
|
|
235
|
+
|
|
236
|
+
/** Best-effort default: buffer raw stdin until the reply completes or the
|
|
237
|
+
* timeout elapses (terminals without OSC 52 query support never reply). */
|
|
238
|
+
function defaultReadReply(timeoutMs: number): Promise<string | null> {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
const stdin = process.stdin;
|
|
241
|
+
if (!stdin || typeof stdin.on !== "function") {
|
|
242
|
+
resolve(null);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let buffer = "";
|
|
246
|
+
const timer = setTimeout(
|
|
247
|
+
() => finish(buffer.length > 0 ? buffer : null),
|
|
248
|
+
timeoutMs,
|
|
249
|
+
);
|
|
250
|
+
function onData(chunk: string | Buffer): void {
|
|
251
|
+
buffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
252
|
+
if (OSC52_REPLY_RE.test(buffer)) finish(buffer);
|
|
253
|
+
}
|
|
254
|
+
function finish(value: string | null): void {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
stdin.removeListener("data", onData);
|
|
257
|
+
resolve(value);
|
|
258
|
+
}
|
|
259
|
+
stdin.on("data", onData);
|
|
260
|
+
if (stdin.isPaused && stdin.isPaused()) stdin.resume();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* OSC 52 query channel: write the query, read and decode the reply. A
|
|
266
|
+
* missing/unparsable reply is reported `unavailable` (the terminal either
|
|
267
|
+
* does not answer queries or the clipboard is empty — OSC 52 cannot tell
|
|
268
|
+
* the two apart, so callers treat it as a failed read).
|
|
269
|
+
*/
|
|
270
|
+
export function makeOsc52Channel(
|
|
271
|
+
options: Osc52ChannelOptions = {},
|
|
272
|
+
): ClipboardReadChannel {
|
|
273
|
+
const write =
|
|
274
|
+
options.write ?? ((sequence: string) => process.stdout.write(sequence));
|
|
275
|
+
const readReply = options.readReply ?? defaultReadReply;
|
|
276
|
+
const timeoutMs = options.timeoutMs ?? OSC52_READ_TIMEOUT_MS;
|
|
277
|
+
return {
|
|
278
|
+
name: "osc52",
|
|
279
|
+
async read() {
|
|
280
|
+
// Attach the reply reader before sending the query so an immediate
|
|
281
|
+
// reply is not missed.
|
|
282
|
+
const replyPromise = readReply(timeoutMs);
|
|
283
|
+
write(OSC52_QUERY);
|
|
284
|
+
const raw = await replyPromise;
|
|
285
|
+
if (raw === null) return { ok: false, reason: "unavailable" };
|
|
286
|
+
const text = parseOsc52Reply(raw);
|
|
287
|
+
return text === null
|
|
288
|
+
? { ok: false, reason: "unavailable" }
|
|
289
|
+
: { ok: true, text };
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface PlatformChannelsOptions {
|
|
295
|
+
/** Platform override (tests). Defaults to `process.platform`. */
|
|
296
|
+
platform?: NodeJS.Platform;
|
|
297
|
+
env?: NodeJS.ProcessEnv;
|
|
298
|
+
exec?: ExecFn;
|
|
299
|
+
write?: (sequence: string) => void;
|
|
300
|
+
readReply?: (timeoutMs: number) => Promise<string | null>;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The per-platform channel matrix (primary first, then fallbacks). win32 /
|
|
305
|
+
* darwin lead with their native tool and fall back to an OSC 52 query;
|
|
306
|
+
* linux (and unknown platforms) query OSC 52 directly — mirroring the
|
|
307
|
+
* write-side cascade where OSC 52 is the universal last resort.
|
|
308
|
+
*/
|
|
309
|
+
export function buildPlatformChannels(
|
|
310
|
+
options: PlatformChannelsOptions = {},
|
|
311
|
+
): ClipboardReadChannel[] {
|
|
312
|
+
const osc52 = makeOsc52Channel({
|
|
313
|
+
write: options.write,
|
|
314
|
+
readReply: options.readReply,
|
|
315
|
+
});
|
|
316
|
+
switch (options.platform ?? process.platform) {
|
|
317
|
+
case "win32":
|
|
318
|
+
return [
|
|
319
|
+
makePowerShellChannel({ exec: options.exec, env: options.env }),
|
|
320
|
+
osc52,
|
|
321
|
+
];
|
|
322
|
+
case "darwin":
|
|
323
|
+
return [
|
|
324
|
+
makePbpasteChannel({ exec: options.exec, env: options.env }),
|
|
325
|
+
osc52,
|
|
326
|
+
];
|
|
327
|
+
case "linux":
|
|
328
|
+
return [osc52];
|
|
329
|
+
default:
|
|
330
|
+
return [osc52];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Read the clipboard with the current platform's channel matrix. */
|
|
335
|
+
export function readClipboardTextFromSystem(
|
|
336
|
+
options: PlatformChannelsOptions = {},
|
|
337
|
+
): Promise<ClipboardReadOutcome> {
|
|
338
|
+
return readClipboardText(buildPlatformChannels(options));
|
|
339
|
+
}
|
package/srcs/config.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import type { PromptPackManifest } from "./prompt-pack.ts";
|
|
5
|
+
import type { RetryPolicy } from "./retry.ts";
|
|
6
|
+
/**
|
|
7
|
+
* Layered config resolution for pi-better-btw.
|
|
8
|
+
*
|
|
9
|
+
* Sources, in increasing precedence (a layer only contributes keys it
|
|
10
|
+
* actually defines — absent/invalid keys fall through to the layer below):
|
|
11
|
+
*
|
|
12
|
+
* 1. bundle — <extensionDir>/config.json (shipped defaults)
|
|
13
|
+
* 2. user — ~/.pi/agent/pi-better-btw/config.json (personal defaults)
|
|
14
|
+
* 3. project — <cwd>/.pi/pi-better-btw/config.json (per-project overrides)
|
|
15
|
+
*
|
|
16
|
+
* Merge semantics:
|
|
17
|
+
* - `readOnlyExtensionAllowlist` is UNIONED across layers in bundle → user →
|
|
18
|
+
* project order (deduped, first occurrence wins). A higher layer adds tools,
|
|
19
|
+
* it never drops the defaults shipped below it.
|
|
20
|
+
* - `readOnlyExtensionAllowlistExclude` removes names from the final list, so
|
|
21
|
+
* a bundled default can be dropped explicitly.
|
|
22
|
+
* - `promptPack` merges per leaf key (framing / focusAnchor / each lane
|
|
23
|
+
* reminder), higher layer wins; relative paths resolve against the layer's
|
|
24
|
+
* own directory (so a user-level manifest may live next to the user config).
|
|
25
|
+
*
|
|
26
|
+
* Loaded fresh at every side-chat open — no caching, so edits to any layer
|
|
27
|
+
* apply on the next open (same philosophy as the prompt pack).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-feature kill switches (D11). Each defaults to true; a layer only
|
|
32
|
+
* overrides the keys it defines, so a user can disable one behavior without
|
|
33
|
+
* touching the others (or the bundle defaults). Read-only here — the
|
|
34
|
+
* switches are resolved at every side-chat open, like the rest of the config.
|
|
35
|
+
*/
|
|
36
|
+
export interface SideChatFeatures {
|
|
37
|
+
/** Right-click copy (chat selection) / paste (editor). Default: true. */
|
|
38
|
+
rightClickCopyPaste: boolean;
|
|
39
|
+
/** Alt+M fork model picker. Default: true. */
|
|
40
|
+
modelSwitch: boolean;
|
|
41
|
+
/** Turn-level auto-retry of transient provider errors. Default: true. */
|
|
42
|
+
retry: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SideChatConfig {
|
|
46
|
+
readOnlyExtensionAllowlist: string[];
|
|
47
|
+
promptPack: PromptPackManifest | undefined;
|
|
48
|
+
features: SideChatFeatures;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LoadConfigOptions {
|
|
52
|
+
/** Directory of the extension bundle (base for the bundle config.json). */
|
|
53
|
+
extensionDir: string;
|
|
54
|
+
/** Current working directory; project layer is skipped when absent. */
|
|
55
|
+
cwd?: string;
|
|
56
|
+
/** User config dir override (tests). Defaults to ~/.pi/agent/pi-sidechat. */
|
|
57
|
+
userConfigDir?: string;
|
|
58
|
+
/** Surfaces config problems (invalid JSON) instead of logging to console. */
|
|
59
|
+
onWarning?: (message: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const CONFIG_SUBDIR = "pi-better-btw";
|
|
63
|
+
export const USER_CONFIG_DIR = join(homedir(), ".pi", "agent", CONFIG_SUBDIR);
|
|
64
|
+
/** pi's own agent config dir (home of the shared settings.json). */
|
|
65
|
+
export const AGENT_CONFIG_DIR = join(homedir(), ".pi", "agent");
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read pi's `settings.retry` budget (D8). The fork shares pi's settings files
|
|
69
|
+
* rather than re-declaring them: global <agentConfigDir>/settings.json merged
|
|
70
|
+
* with project <cwd>/.pi/settings.json (project wins per key, mirroring pi's
|
|
71
|
+
* deepMergeSettings), then the `retry` block is extracted with pi's defaults
|
|
72
|
+
* (settingsManager.getRetrySettings: enabled=true, maxRetries=3,
|
|
73
|
+
* baseDelayMs=2000). Invalid/absent files contribute nothing; a present-but-
|
|
74
|
+
* unreadable file warns instead of failing the fork.
|
|
75
|
+
*/
|
|
76
|
+
export interface LoadRetryPolicyOptions {
|
|
77
|
+
/** Agent config dir holding pi's global settings.json (~/.pi/agent). */
|
|
78
|
+
agentConfigDir?: string;
|
|
79
|
+
/** cwd for the project layer (<cwd>/.pi/settings.json); skipped when absent. */
|
|
80
|
+
cwd?: string;
|
|
81
|
+
onWarning?: (message: string) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deep merge like pi's deepMergeSettings: nested plain objects merge, arrays/others replace. */
|
|
89
|
+
function deepMergeSettings(
|
|
90
|
+
base: Record<string, unknown>,
|
|
91
|
+
overrides: Record<string, unknown>,
|
|
92
|
+
): Record<string, unknown> {
|
|
93
|
+
const result = { ...base };
|
|
94
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
95
|
+
const baseValue = result[key];
|
|
96
|
+
result[key] =
|
|
97
|
+
isPlainRecord(baseValue) && isPlainRecord(value)
|
|
98
|
+
? deepMergeSettings(baseValue, value)
|
|
99
|
+
: value;
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readSettingsFile(
|
|
105
|
+
path: string,
|
|
106
|
+
onWarning?: (message: string) => void,
|
|
107
|
+
): Record<string, unknown> {
|
|
108
|
+
try {
|
|
109
|
+
const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
110
|
+
return isPlainRecord(raw) ? raw : {};
|
|
111
|
+
} catch {
|
|
112
|
+
if (existsSync(path) && onWarning) {
|
|
113
|
+
onWarning(`pi-better-btw: ignoring invalid settings ${path}`);
|
|
114
|
+
}
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function loadRetryPolicy(options: LoadRetryPolicyOptions = {}): RetryPolicy {
|
|
120
|
+
const agentConfigDir = options.agentConfigDir ?? AGENT_CONFIG_DIR;
|
|
121
|
+
const merged = deepMergeSettings(
|
|
122
|
+
readSettingsFile(join(agentConfigDir, "settings.json"), options.onWarning),
|
|
123
|
+
options.cwd
|
|
124
|
+
? readSettingsFile(join(options.cwd, ".pi", "settings.json"), options.onWarning)
|
|
125
|
+
: {},
|
|
126
|
+
);
|
|
127
|
+
const retry = isPlainRecord(merged.retry) ? merged.retry : {};
|
|
128
|
+
return {
|
|
129
|
+
enabled:
|
|
130
|
+
typeof retry.enabled === "boolean" ? retry.enabled : true,
|
|
131
|
+
maxRetries:
|
|
132
|
+
typeof retry.maxRetries === "number" ? retry.maxRetries : 3,
|
|
133
|
+
baseDelayMs:
|
|
134
|
+
typeof retry.baseDelayMs === "number" ? retry.baseDelayMs : 2000,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface ConfigLayer {
|
|
139
|
+
readOnlyExtensionAllowlist: string[] | undefined;
|
|
140
|
+
readOnlyExtensionAllowlistExclude: string[] | undefined;
|
|
141
|
+
promptPack: PromptPackManifest | undefined;
|
|
142
|
+
/** Feature switches this layer actually defines (undefined = falls through). */
|
|
143
|
+
features: Partial<SideChatFeatures> | undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseStringArray(value: unknown): string[] | undefined {
|
|
147
|
+
if (!Array.isArray(value)) return undefined;
|
|
148
|
+
const names = value.filter(
|
|
149
|
+
(n): n is string => typeof n === "string" && n.length > 0,
|
|
150
|
+
);
|
|
151
|
+
return names.length > 0 ? names : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Boolean feature switch; non-boolean values are ignored (fall through). */
|
|
155
|
+
function parseBoolean(value: unknown): boolean | undefined {
|
|
156
|
+
return typeof value === "boolean" ? value : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Resolve a prompt-pack path against the layer's dir; non-strings stay undefined. */
|
|
160
|
+
function resolvePath(value: unknown, dir: string): string | undefined {
|
|
161
|
+
if (typeof value !== "string") return undefined;
|
|
162
|
+
return isAbsolute(value) ? value : join(dir, value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parsePromptPack(
|
|
166
|
+
value: unknown,
|
|
167
|
+
dir: string,
|
|
168
|
+
): PromptPackManifest | undefined {
|
|
169
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
170
|
+
return undefined;
|
|
171
|
+
const rec = value as Record<string, unknown>;
|
|
172
|
+
const lane = rec.laneReminders;
|
|
173
|
+
const laneRec =
|
|
174
|
+
lane && typeof lane === "object" && !Array.isArray(lane)
|
|
175
|
+
? (lane as Record<string, unknown>)
|
|
176
|
+
: undefined;
|
|
177
|
+
return {
|
|
178
|
+
framing: resolvePath(rec.framing, dir),
|
|
179
|
+
focusAnchor: resolvePath(rec.focusAnchor, dir),
|
|
180
|
+
laneReminders: {
|
|
181
|
+
base: resolvePath(laneRec?.base, dir),
|
|
182
|
+
escalated: resolvePath(laneRec?.escalated, dir),
|
|
183
|
+
failedNote: resolvePath(laneRec?.failedNote, dir),
|
|
184
|
+
preamble: resolvePath(laneRec?.preamble, dir),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseConfigLayer(raw: unknown, dir: string): ConfigLayer {
|
|
190
|
+
const rec =
|
|
191
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
192
|
+
? (raw as Record<string, unknown>)
|
|
193
|
+
: {};
|
|
194
|
+
const rawFeatures = rec.features;
|
|
195
|
+
const featureRec =
|
|
196
|
+
rawFeatures && typeof rawFeatures === "object" && !Array.isArray(rawFeatures)
|
|
197
|
+
? (rawFeatures as Record<string, unknown>)
|
|
198
|
+
: undefined;
|
|
199
|
+
return {
|
|
200
|
+
readOnlyExtensionAllowlist: parseStringArray(
|
|
201
|
+
rec.readOnlyExtensionAllowlist,
|
|
202
|
+
),
|
|
203
|
+
readOnlyExtensionAllowlistExclude: parseStringArray(
|
|
204
|
+
rec.readOnlyExtensionAllowlistExclude,
|
|
205
|
+
),
|
|
206
|
+
promptPack: parsePromptPack(rec.promptPack, dir),
|
|
207
|
+
features: featureRec
|
|
208
|
+
? {
|
|
209
|
+
rightClickCopyPaste: parseBoolean(featureRec.rightClickCopyPaste),
|
|
210
|
+
modelSwitch: parseBoolean(featureRec.modelSwitch),
|
|
211
|
+
retry: parseBoolean(featureRec.retry),
|
|
212
|
+
}
|
|
213
|
+
: undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Read one config file; absent or invalid JSON contributes nothing (with a warning). */
|
|
218
|
+
function readLayer(
|
|
219
|
+
path: string,
|
|
220
|
+
dir: string,
|
|
221
|
+
onWarning?: (message: string) => void,
|
|
222
|
+
): ConfigLayer {
|
|
223
|
+
try {
|
|
224
|
+
const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
225
|
+
return parseConfigLayer(raw, dir);
|
|
226
|
+
} catch {
|
|
227
|
+
if (existsSync(path) && onWarning) {
|
|
228
|
+
// Present but unreadable: warn instead of silently ignoring a typo.
|
|
229
|
+
onWarning(`pi-better-btw: ignoring invalid config ${path}`);
|
|
230
|
+
}
|
|
231
|
+
return parseConfigLayer(undefined, dir);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Union allowlists bundle → user → project, then apply excludes. */
|
|
236
|
+
function mergeAllowlists(layers: ConfigLayer[]): string[] {
|
|
237
|
+
const names: string[] = [];
|
|
238
|
+
for (const layer of layers) {
|
|
239
|
+
for (const name of layer.readOnlyExtensionAllowlist ?? []) {
|
|
240
|
+
if (!names.includes(name)) names.push(name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const excluded = new Set<string>();
|
|
244
|
+
for (const layer of layers) {
|
|
245
|
+
for (const name of layer.readOnlyExtensionAllowlistExclude ?? [])
|
|
246
|
+
excluded.add(name);
|
|
247
|
+
}
|
|
248
|
+
return names.filter((name) => !excluded.has(name));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Per-leaf-key promptPack merge, higher layer wins; undefined when nothing defined. */
|
|
252
|
+
function mergePromptPacks(
|
|
253
|
+
layers: ConfigLayer[],
|
|
254
|
+
): PromptPackManifest | undefined {
|
|
255
|
+
const merged: PromptPackManifest = {};
|
|
256
|
+
const lane: NonNullable<PromptPackManifest["laneReminders"]> = {};
|
|
257
|
+
let defined = false;
|
|
258
|
+
for (const layer of layers) {
|
|
259
|
+
const pack = layer.promptPack;
|
|
260
|
+
if (!pack) continue;
|
|
261
|
+
if (pack.framing !== undefined) {
|
|
262
|
+
merged.framing = pack.framing;
|
|
263
|
+
defined = true;
|
|
264
|
+
}
|
|
265
|
+
if (pack.focusAnchor !== undefined) {
|
|
266
|
+
merged.focusAnchor = pack.focusAnchor;
|
|
267
|
+
defined = true;
|
|
268
|
+
}
|
|
269
|
+
const layerLane = pack.laneReminders ?? {};
|
|
270
|
+
for (const key of [
|
|
271
|
+
"base",
|
|
272
|
+
"escalated",
|
|
273
|
+
"failedNote",
|
|
274
|
+
"preamble",
|
|
275
|
+
] as const) {
|
|
276
|
+
if (layerLane[key] !== undefined) {
|
|
277
|
+
lane[key] = layerLane[key];
|
|
278
|
+
defined = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (Object.keys(lane).length > 0) merged.laneReminders = lane;
|
|
283
|
+
return defined ? merged : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Feature switches merge per leaf key, higher layer wins; keys no layer
|
|
288
|
+
* defines keep their default (true). A layer can disable one behavior
|
|
289
|
+
* (`"retry": false`) without re-declaring the others.
|
|
290
|
+
*/
|
|
291
|
+
const FEATURE_KEYS = [
|
|
292
|
+
"rightClickCopyPaste",
|
|
293
|
+
"modelSwitch",
|
|
294
|
+
"retry",
|
|
295
|
+
] as const;
|
|
296
|
+
function mergeFeatures(layers: ConfigLayer[]): SideChatFeatures {
|
|
297
|
+
const features: SideChatFeatures = {
|
|
298
|
+
rightClickCopyPaste: true,
|
|
299
|
+
modelSwitch: true,
|
|
300
|
+
retry: true,
|
|
301
|
+
};
|
|
302
|
+
for (const layer of layers) {
|
|
303
|
+
const layerFeatures = layer.features;
|
|
304
|
+
if (!layerFeatures) continue;
|
|
305
|
+
for (const key of FEATURE_KEYS) {
|
|
306
|
+
const value = layerFeatures[key];
|
|
307
|
+
if (value !== undefined) features[key] = value;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return features;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function loadConfig(options: LoadConfigOptions): SideChatConfig {
|
|
314
|
+
const layers: ConfigLayer[] = [];
|
|
315
|
+
layers.push(
|
|
316
|
+
readLayer(
|
|
317
|
+
join(options.extensionDir, "config.json"),
|
|
318
|
+
options.extensionDir,
|
|
319
|
+
options.onWarning,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
const userConfigDir = options.userConfigDir ?? USER_CONFIG_DIR;
|
|
323
|
+
layers.push(
|
|
324
|
+
readLayer(
|
|
325
|
+
join(userConfigDir, "config.json"),
|
|
326
|
+
userConfigDir,
|
|
327
|
+
options.onWarning,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
if (options.cwd) {
|
|
331
|
+
const projectDir = join(options.cwd, ".pi", CONFIG_SUBDIR);
|
|
332
|
+
layers.push(
|
|
333
|
+
readLayer(join(projectDir, "config.json"), projectDir, options.onWarning),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
readOnlyExtensionAllowlist: mergeAllowlists(layers),
|
|
338
|
+
promptPack: mergePromptPacks(layers),
|
|
339
|
+
features: mergeFeatures(layers),
|
|
340
|
+
};
|
|
341
|
+
}
|