@herbertgao/pi-extensions 2026.8.1 → 2026.8.2

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.
Files changed (162) hide show
  1. package/README.md +5 -2
  2. package/node_modules/@czottmann/pi-automode/README.md +16 -2
  3. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +35 -0
  4. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +18 -9
  5. package/node_modules/@czottmann/pi-automode/docs/defaults.md +6 -2
  6. package/node_modules/@czottmann/pi-automode/examples/automode.local.json +6 -0
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +11 -7
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +100 -0
  9. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +32 -0
  10. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +78 -3
  11. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +16 -4
  12. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/paths.ts +23 -1
  13. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/permissions.ts +22 -1
  14. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/types.ts +18 -2
  15. package/node_modules/@czottmann/pi-automode/package.json +4 -4
  16. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +5 -0
  17. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +5 -0
  18. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +5 -0
  19. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-help.md +5 -0
  20. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +5 -0
  21. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +5 -0
  22. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail-frontmatter.cjs +23 -0
  23. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail.mjs +99 -0
  24. package/node_modules/@dietrichgebert/ponytail/.qoder/rules/ponytail.md +30 -0
  25. package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +16 -0
  26. package/node_modules/@dietrichgebert/ponytail/AGENTS.md +32 -0
  27. package/node_modules/@dietrichgebert/ponytail/LICENSE +21 -0
  28. package/node_modules/@dietrichgebert/ponytail/README.es.md +298 -0
  29. package/node_modules/@dietrichgebert/ponytail/README.ko.md +315 -0
  30. package/node_modules/@dietrichgebert/ponytail/README.md +351 -0
  31. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-3model.svg +21 -0
  32. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-agentic.svg +62 -0
  33. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.png +0 -0
  34. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.svg +115 -0
  35. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt-dark.svg +27 -0
  36. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt.svg +27 -0
  37. package/node_modules/@dietrichgebert/ponytail/assets/logo.png +0 -0
  38. package/node_modules/@dietrichgebert/ponytail/assets/social-preview.png +0 -0
  39. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-es.png +0 -0
  40. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-ko.png +0 -0
  41. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner.png +0 -0
  42. package/node_modules/@dietrichgebert/ponytail/hooks/claude-codex-hooks.json +44 -0
  43. package/node_modules/@dietrichgebert/ponytail/hooks/copilot-hooks.json +21 -0
  44. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +96 -0
  45. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-config.js +169 -0
  46. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-instructions.js +98 -0
  47. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-mode-tracker.js +130 -0
  48. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-runtime.js +85 -0
  49. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-statusline.ps1 +24 -0
  50. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-statusline.sh +18 -0
  51. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-subagent.js +77 -0
  52. package/node_modules/@dietrichgebert/ponytail/hooks/qoder-hooks.json +26 -0
  53. package/node_modules/@dietrichgebert/ponytail/package.json +47 -0
  54. package/node_modules/@dietrichgebert/ponytail/pi-extension/index.js +211 -0
  55. package/node_modules/@dietrichgebert/ponytail/pi-extension/package.json +8 -0
  56. package/node_modules/@dietrichgebert/ponytail/scripts/uninstall.js +60 -0
  57. package/node_modules/@dietrichgebert/ponytail/skills/ponytail/SKILL.md +120 -0
  58. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +41 -0
  59. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  60. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +50 -0
  61. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +71 -0
  62. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +57 -0
  63. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -3
  64. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -3
  65. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +10 -6
  66. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary.ts +6 -2
  67. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +24 -83
  68. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +1 -1
  69. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/markdown-enhance.ts +119 -0
  70. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +73 -9
  71. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +2 -2
  72. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-hover.ts +130 -0
  73. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-interaction.ts +65 -542
  74. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-layout.ts +160 -0
  75. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-packets.ts +143 -0
  76. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-scroll.ts +184 -0
  77. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool-diff/diff-renderer.ts +2 -2
  78. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool-grouping.ts +9 -2
  79. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/transcript-refresh.ts +91 -0
  80. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/agent-summary.ts +1 -1
  81. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  82. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -0
  83. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +2 -2
  84. package/node_modules/@herbertgao/pi-stash/README.md +9 -9
  85. package/node_modules/@herbertgao/pi-stash/package.json +1 -1
  86. package/node_modules/@herbertgao/pi-stash/src/index.ts +2 -1
  87. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +14 -0
  88. package/node_modules/@herbertgao/pi-subagents/README.md +6 -2
  89. package/node_modules/@herbertgao/pi-subagents/package.json +3 -3
  90. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +85 -11
  91. package/node_modules/@herbertgao/pi-subagents/src/index.ts +28 -4
  92. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +12 -0
  93. package/node_modules/@herbertgao/pi-subagents/src/types.ts +2 -0
  94. package/node_modules/@narumitw/pi-btw/LICENSE +21 -0
  95. package/node_modules/@narumitw/pi-btw/README.md +210 -0
  96. package/node_modules/@narumitw/pi-btw/package.json +53 -0
  97. package/node_modules/@narumitw/pi-btw/src/bring-to-main.ts +632 -0
  98. package/node_modules/@narumitw/pi-btw/src/btw.ts +996 -0
  99. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +339 -0
  100. package/node_modules/@narumitw/pi-btw/src/index.ts +1 -0
  101. package/node_modules/@narumitw/pi-btw/src/menu.ts +296 -0
  102. package/node_modules/@narumitw/pi-btw/src/settings.ts +237 -0
  103. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +238 -0
  104. package/node_modules/@narumitw/pi-btw/src/text.ts +10 -0
  105. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +620 -0
  106. package/node_modules/@pi-plugins/fast-mode/LICENSE +21 -0
  107. package/node_modules/@pi-plugins/fast-mode/README.md +92 -0
  108. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts +6 -0
  109. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -0
  110. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +2026 -0
  111. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -0
  112. package/node_modules/@pi-plugins/fast-mode/package.json +73 -0
  113. package/node_modules/pi-mcp-adapter/CHANGELOG.md +59 -0
  114. package/node_modules/pi-mcp-adapter/README.md +62 -13
  115. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +377 -0
  116. package/node_modules/pi-mcp-adapter/commands.ts +9 -7
  117. package/node_modules/pi-mcp-adapter/config.ts +110 -49
  118. package/node_modules/pi-mcp-adapter/direct-tools.ts +34 -19
  119. package/node_modules/pi-mcp-adapter/elicitation-handler.ts +37 -36
  120. package/node_modules/pi-mcp-adapter/errors.ts +21 -17
  121. package/node_modules/pi-mcp-adapter/host-html-template.ts +13 -7
  122. package/node_modules/pi-mcp-adapter/index.ts +37 -14
  123. package/node_modules/pi-mcp-adapter/init.ts +9 -7
  124. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +2 -2
  125. package/node_modules/pi-mcp-adapter/lifecycle.ts +8 -8
  126. package/node_modules/pi-mcp-adapter/logger.ts +1 -1
  127. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +36 -22
  128. package/node_modules/pi-mcp-adapter/mcp-auth.ts +120 -2
  129. package/node_modules/pi-mcp-adapter/mcp-code.ts +13 -11
  130. package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +19 -13
  131. package/node_modules/pi-mcp-adapter/mcp-output-guard.ts +15 -11
  132. package/node_modules/pi-mcp-adapter/mcp-panel.ts +34 -15
  133. package/node_modules/pi-mcp-adapter/mcp-probe.ts +131 -34
  134. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +2 -2
  135. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +7 -4
  136. package/node_modules/pi-mcp-adapter/mcp-trace.ts +59 -64
  137. package/node_modules/pi-mcp-adapter/metadata-cache.ts +34 -26
  138. package/node_modules/pi-mcp-adapter/npx-resolver.ts +6 -4
  139. package/node_modules/pi-mcp-adapter/oauth-handler.ts +1 -1
  140. package/node_modules/pi-mcp-adapter/onboarding-state.ts +19 -11
  141. package/node_modules/pi-mcp-adapter/package.json +13 -8
  142. package/node_modules/pi-mcp-adapter/panel-keys.ts +20 -4
  143. package/node_modules/pi-mcp-adapter/prompts.ts +2 -2
  144. package/node_modules/pi-mcp-adapter/proxy-modes.ts +48 -11
  145. package/node_modules/pi-mcp-adapter/sampling-handler.ts +22 -15
  146. package/node_modules/pi-mcp-adapter/search-ranking.ts +2 -1
  147. package/node_modules/pi-mcp-adapter/server-manager.ts +165 -113
  148. package/node_modules/pi-mcp-adapter/session-recovery.ts +11 -12
  149. package/node_modules/pi-mcp-adapter/skills/mcp-scripting/SKILL.md +3 -3
  150. package/node_modules/pi-mcp-adapter/state.ts +3 -1
  151. package/node_modules/pi-mcp-adapter/tool-approval.ts +67 -4
  152. package/node_modules/pi-mcp-adapter/tool-metadata.ts +6 -5
  153. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +14 -2
  154. package/node_modules/pi-mcp-adapter/ts-shape.ts +9 -2
  155. package/node_modules/pi-mcp-adapter/types.ts +109 -22
  156. package/node_modules/pi-mcp-adapter/ui-app-bridge-helpers.ts +40 -0
  157. package/node_modules/pi-mcp-adapter/ui-resource-handler.ts +28 -19
  158. package/node_modules/pi-mcp-adapter/ui-server.ts +90 -58
  159. package/node_modules/pi-mcp-adapter/ui-session.ts +16 -14
  160. package/node_modules/pi-mcp-adapter/unix-socket-transport.ts +4 -4
  161. package/node_modules/pi-mcp-adapter/utils.ts +23 -9
  162. package/package.json +22 -7
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
7
+
8
+ export const BTW_SETTINGS_FILE = "pi-btw.json";
9
+ export const DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
10
+ const MAX_SETTINGS_BYTES = 64 * 1024;
11
+
12
+ export interface BtwSettings {
13
+ model?: string;
14
+ thinkingLevel?: BtwThinkingLevel;
15
+ rememberThinkingLevelChanges?: boolean;
16
+ }
17
+
18
+ export type BtwSettingsLoadResult =
19
+ | { kind: "missing" }
20
+ | { kind: "invalid"; reason: string }
21
+ | { kind: "loaded"; settings: BtwSettings };
22
+
23
+ export interface UpdateBtwSettingsOptions {
24
+ settingsPath?: string;
25
+ signal?: AbortSignal;
26
+ beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
27
+ }
28
+
29
+ type SettingsDocument = Record<string, unknown>;
30
+
31
+ const mutationQueues = new Map<string, Promise<void>>();
32
+
33
+ export function btwSettingsPath(): string {
34
+ return join(getAgentDir(), BTW_SETTINGS_FILE);
35
+ }
36
+
37
+ export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
38
+ if (!isSettingsDocument(value)) return undefined;
39
+
40
+ const settings: BtwSettings = {};
41
+ if (Object.hasOwn(value, "model")) {
42
+ const model = Reflect.get(value, "model");
43
+ if (typeof model !== "string" || !parseBtwModelReference(model)) return undefined;
44
+ settings.model = model;
45
+ }
46
+ if (Object.hasOwn(value, "thinkingLevel")) {
47
+ const thinkingLevel = Reflect.get(value, "thinkingLevel");
48
+ if (!isBtwThinkingLevel(thinkingLevel)) return undefined;
49
+ settings.thinkingLevel = thinkingLevel;
50
+ }
51
+ if (Object.hasOwn(value, "rememberThinkingLevelChanges")) {
52
+ const remember = Reflect.get(value, "rememberThinkingLevelChanges");
53
+ if (typeof remember !== "boolean") return undefined;
54
+ settings.rememberThinkingLevelChanges = remember;
55
+ }
56
+ return settings;
57
+ }
58
+
59
+ export function parseBtwModelReference(
60
+ reference: string,
61
+ ): { provider: string; modelId: string } | undefined {
62
+ if (/[\s\p{Cc}]/u.test(reference)) return undefined;
63
+ const separator = reference.indexOf("/");
64
+ if (separator <= 0 || separator === reference.length - 1) return undefined;
65
+ return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
66
+ }
67
+
68
+ export function effectiveRememberThinkingLevelChanges(settings: BtwSettings): boolean {
69
+ return settings.rememberThinkingLevelChanges ?? DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES;
70
+ }
71
+
72
+ export async function readBtwSettings(
73
+ settingsPath = btwSettingsPath(),
74
+ ): Promise<BtwSettingsLoadResult> {
75
+ await awaitBtwSettingsWrites(settingsPath);
76
+ return readBtwSettingsUncoordinated(settingsPath);
77
+ }
78
+
79
+ export function updateBtwSettings(
80
+ patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
81
+ options: UpdateBtwSettingsOptions = {},
82
+ ): Promise<BtwSettings> {
83
+ const settingsPath = options.settingsPath ?? btwSettingsPath();
84
+ return enqueueMutation(settingsPath, async () => {
85
+ options.signal?.throwIfAborted();
86
+ const current = await readSettingsDocumentForUpdate(settingsPath);
87
+ const updated: SettingsDocument = { ...current, ...patch };
88
+ const settings = normalizeBtwSettings(updated);
89
+ if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
90
+ await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
91
+ return settings;
92
+ });
93
+ }
94
+
95
+ export async function awaitBtwSettingsWrites(settingsPath = btwSettingsPath()): Promise<void> {
96
+ await mutationQueues.get(settingsPath);
97
+ }
98
+
99
+ function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): Promise<T> {
100
+ const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
101
+ const result = previous.then(mutation, mutation);
102
+ const settled = result.then(
103
+ () => undefined,
104
+ () => undefined,
105
+ );
106
+ mutationQueues.set(settingsPath, settled);
107
+ void settled.finally(() => {
108
+ if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
109
+ });
110
+ return result;
111
+ }
112
+
113
+ async function readBtwSettingsUncoordinated(settingsPath: string): Promise<BtwSettingsLoadResult> {
114
+ let contents: string;
115
+ try {
116
+ contents = await readSettingsContents(settingsPath);
117
+ } catch (error: unknown) {
118
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
119
+ return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
120
+ }
121
+
122
+ try {
123
+ const settings = normalizeBtwSettings(JSON.parse(contents) as unknown);
124
+ return settings
125
+ ? { kind: "loaded", settings }
126
+ : { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
127
+ } catch {
128
+ return { kind: "invalid", reason: `${settingsPath}: invalid JSON` };
129
+ }
130
+ }
131
+
132
+ async function readSettingsDocumentForUpdate(settingsPath: string): Promise<SettingsDocument> {
133
+ let contents: string;
134
+ try {
135
+ contents = await readSettingsContents(settingsPath);
136
+ } catch (error: unknown) {
137
+ if (isNodeError(error) && error.code === "ENOENT") return {};
138
+ throw invalidSettingsError(settingsPath, formatError(error));
139
+ }
140
+
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(contents) as unknown;
144
+ } catch {
145
+ throw invalidSettingsError(settingsPath, "invalid JSON");
146
+ }
147
+ if (!isSettingsDocument(parsed) || !normalizeBtwSettings(parsed)) {
148
+ throw invalidSettingsError(settingsPath, "invalid settings shape");
149
+ }
150
+ return parsed;
151
+ }
152
+
153
+ async function readSettingsContents(settingsPath: string): Promise<string> {
154
+ const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
155
+ const handle = await open(settingsPath, flags);
156
+ try {
157
+ const descriptorStats = await handle.stat();
158
+ if (!descriptorStats.isFile()) throw new Error("settings path is not a regular file");
159
+ if (descriptorStats.size > MAX_SETTINGS_BYTES) {
160
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
161
+ }
162
+
163
+ const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
164
+ let offset = 0;
165
+ while (offset < buffer.byteLength) {
166
+ const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
167
+ if (bytesRead === 0) break;
168
+ offset += bytesRead;
169
+ }
170
+ if (offset > MAX_SETTINGS_BYTES) {
171
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
172
+ }
173
+ try {
174
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
175
+ buffer.subarray(0, offset),
176
+ );
177
+ } catch {
178
+ throw new Error("settings file is not valid UTF-8");
179
+ }
180
+ } finally {
181
+ await handle.close();
182
+ }
183
+ }
184
+
185
+ async function publishSettings(
186
+ settingsPath: string,
187
+ document: SettingsDocument,
188
+ signal?: AbortSignal,
189
+ beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>,
190
+ ): Promise<void> {
191
+ signal?.throwIfAborted();
192
+ const contents = `${JSON.stringify(document, null, 2)}\n`;
193
+ if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
194
+ throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
195
+ }
196
+ const directory = dirname(settingsPath);
197
+ await mkdir(directory, { recursive: true });
198
+ signal?.throwIfAborted();
199
+ const temporaryPath = join(
200
+ directory,
201
+ `.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`,
202
+ );
203
+ try {
204
+ await writeFile(temporaryPath, contents, {
205
+ encoding: "utf8",
206
+ flag: "wx",
207
+ mode: 0o600,
208
+ signal,
209
+ });
210
+ await beforeRename?.(temporaryPath, settingsPath);
211
+ signal?.throwIfAborted();
212
+ await rename(temporaryPath, settingsPath);
213
+ } catch (error) {
214
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ function isSettingsDocument(value: unknown): value is SettingsDocument {
220
+ return typeof value === "object" && value !== null && !Array.isArray(value);
221
+ }
222
+
223
+ function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
224
+ return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
225
+ }
226
+
227
+ function invalidSettingsError(settingsPath: string, reason: string): Error {
228
+ return new Error(`pi-btw settings at ${settingsPath} are invalid: ${reason}`);
229
+ }
230
+
231
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
232
+ return error instanceof Error && "code" in error;
233
+ }
234
+
235
+ function formatError(error: unknown): string {
236
+ return error instanceof Error ? error.message : String(error);
237
+ }
@@ -0,0 +1,238 @@
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ Context,
5
+ Message,
6
+ Model,
7
+ ProviderHeaders,
8
+ SimpleStreamOptions,
9
+ UserMessage,
10
+ } from "@earendil-works/pi-ai";
11
+
12
+ export const BTW_THINKING_LEVELS = [
13
+ "off",
14
+ "minimal",
15
+ "low",
16
+ "medium",
17
+ "high",
18
+ "xhigh",
19
+ "max",
20
+ ] as const;
21
+
22
+ export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
23
+
24
+ export interface SideQuestionAuth {
25
+ apiKey?: string;
26
+ headers?: ProviderHeaders;
27
+ env?: Record<string, string>;
28
+ }
29
+
30
+ export type CompleteSimpleFunction = <TApi extends Api>(
31
+ model: Model<TApi>,
32
+ context: Context,
33
+ options?: SimpleStreamOptions,
34
+ ) => Promise<AssistantMessage>;
35
+
36
+ export type SideThreadTurn =
37
+ | {
38
+ kind: "answered";
39
+ question: string;
40
+ answer: string;
41
+ response: AssistantMessage;
42
+ }
43
+ | {
44
+ kind: "error";
45
+ question: string;
46
+ answer: string;
47
+ };
48
+
49
+ export interface SideThread {
50
+ conversationContext: string;
51
+ turns: SideThreadTurn[];
52
+ }
53
+
54
+ export function createSideThread(conversationContext: string): SideThread {
55
+ return { conversationContext, turns: [] };
56
+ }
57
+
58
+ export function buildSideThreadMessages(thread: SideThread, question: string): Message[] {
59
+ const answeredTurns = thread.turns.filter(
60
+ (turn): turn is Extract<SideThreadTurn, { kind: "answered" }> => turn.kind === "answered",
61
+ );
62
+ const messages: Message[] = [];
63
+
64
+ if (answeredTurns.length === 0) {
65
+ messages.push(createUserMessage(buildUserPrompt(question, thread.conversationContext)));
66
+ return messages;
67
+ }
68
+
69
+ const [first, ...rest] = answeredTurns;
70
+ messages.push(
71
+ createUserMessage(buildUserPrompt(first.question, thread.conversationContext)),
72
+ first.response,
73
+ );
74
+ for (const turn of rest) {
75
+ messages.push(createUserMessage(buildFollowUpPrompt(turn.question)), turn.response);
76
+ }
77
+ messages.push(createUserMessage(buildFollowUpPrompt(question)));
78
+ return messages;
79
+ }
80
+
81
+ export interface CompleteSideThreadTurnOptions {
82
+ thread: SideThread;
83
+ model: Model<Api>;
84
+ question: string;
85
+ thinkingLevel: BtwThinkingLevel;
86
+ auth: SideQuestionAuth;
87
+ signal?: AbortSignal;
88
+ completeSimple: CompleteSimpleFunction;
89
+ }
90
+
91
+ export type CompleteSideThreadTurnResult =
92
+ | { kind: "answered"; response: AssistantMessage; answer: string }
93
+ | { kind: "aborted" }
94
+ | { kind: "error"; message: string };
95
+
96
+ export async function completeSideThreadTurn({
97
+ thread,
98
+ model,
99
+ question,
100
+ thinkingLevel,
101
+ auth,
102
+ signal,
103
+ completeSimple,
104
+ }: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
105
+ if (signal?.aborted) return { kind: "aborted" };
106
+ try {
107
+ const response = await completeSimple(
108
+ model,
109
+ { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
110
+ buildStreamOptions(auth, thinkingLevel, signal),
111
+ );
112
+ if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
113
+ if (!isAssistantMessage(response)) {
114
+ return { kind: "error", message: "The side model returned a malformed response." };
115
+ }
116
+ if (response.stopReason === "error") {
117
+ return {
118
+ kind: "error",
119
+ message: response.errorMessage ?? "The side model returned an error.",
120
+ };
121
+ }
122
+
123
+ const answer = extractAssistantText(response) || "No response received.";
124
+ thread.turns.push({ kind: "answered", question, answer, response });
125
+ return { kind: "answered", response, answer };
126
+ } catch (error: unknown) {
127
+ if (signal?.aborted) return { kind: "aborted" };
128
+ return { kind: "error", message: formatError(error) };
129
+ }
130
+ }
131
+
132
+ export interface CompleteSideQuestionOptions {
133
+ model: Model<Api>;
134
+ question: string;
135
+ conversationContext: string;
136
+ thinkingLevel: BtwThinkingLevel;
137
+ auth: SideQuestionAuth;
138
+ signal?: AbortSignal;
139
+ completeSimple: CompleteSimpleFunction;
140
+ }
141
+
142
+ export async function completeSideQuestion({
143
+ model,
144
+ question,
145
+ conversationContext,
146
+ thinkingLevel,
147
+ auth,
148
+ signal,
149
+ completeSimple,
150
+ }: CompleteSideQuestionOptions): Promise<AssistantMessage> {
151
+ return completeSimple(
152
+ model,
153
+ {
154
+ systemPrompt: SYSTEM_PROMPT,
155
+ messages: [createUserMessage(buildUserPrompt(question, conversationContext))],
156
+ },
157
+ buildStreamOptions(auth, thinkingLevel, signal),
158
+ );
159
+ }
160
+
161
+ export function extractAssistantText(response: AssistantMessage): string {
162
+ return response.content
163
+ .filter(
164
+ (content): content is { type: "text"; text: string } =>
165
+ content !== null &&
166
+ typeof content === "object" &&
167
+ content.type === "text" &&
168
+ typeof content.text === "string",
169
+ )
170
+ .map((content) => content.text)
171
+ .join("\n")
172
+ .trim();
173
+ }
174
+
175
+ function isAssistantMessage(value: unknown): value is AssistantMessage {
176
+ if (value === null || typeof value !== "object") return false;
177
+ const candidate = value as Partial<AssistantMessage>;
178
+ return (
179
+ candidate.role === "assistant" &&
180
+ Array.isArray(candidate.content) &&
181
+ typeof candidate.stopReason === "string"
182
+ );
183
+ }
184
+
185
+ export function buildUserPrompt(question: string, conversationContext: string): string {
186
+ return [
187
+ "Answer this side question without modifying the main conversation.",
188
+ "",
189
+ "<side_question>",
190
+ question,
191
+ "</side_question>",
192
+ "",
193
+ "<conversation_context>",
194
+ conversationContext || "No prior conversation context was available.",
195
+ "</conversation_context>",
196
+ ].join("\n");
197
+ }
198
+
199
+ export function buildFollowUpPrompt(question: string): string {
200
+ return [
201
+ "Continue the same side conversation.",
202
+ "",
203
+ "<side_question>",
204
+ question,
205
+ "</side_question>",
206
+ ].join("\n");
207
+ }
208
+
209
+ function createUserMessage(text: string): UserMessage {
210
+ return {
211
+ role: "user",
212
+ content: [{ type: "text", text }],
213
+ timestamp: Date.now(),
214
+ };
215
+ }
216
+
217
+ function buildStreamOptions(
218
+ auth: SideQuestionAuth,
219
+ thinkingLevel: BtwThinkingLevel,
220
+ signal?: AbortSignal,
221
+ ): SimpleStreamOptions {
222
+ const options: SimpleStreamOptions = {
223
+ apiKey: auth.apiKey,
224
+ headers: auth.headers,
225
+ env: auth.env,
226
+ signal,
227
+ };
228
+ if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
229
+ return options;
230
+ }
231
+
232
+ function formatError(error: unknown): string {
233
+ return error instanceof Error ? error.message : String(error);
234
+ }
235
+
236
+ const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
237
+
238
+ Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
@@ -0,0 +1,10 @@
1
+ export function sanitizeSingleLine(text: string): string {
2
+ return [...text.replace(/[\r\n\t]/gu, " ")]
3
+ .filter((character) => {
4
+ const code = character.charCodeAt(0);
5
+ return code > 31 && (code < 127 || code > 159);
6
+ })
7
+ .join("")
8
+ .replace(/ +/gu, " ")
9
+ .trim();
10
+ }