@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -1,11 +1,10 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
- import { ImageFormat, PhotonImage, SamplingFilter } from "@oh-my-pi/pi-natives";
3
2
 
4
3
  export interface ImageResizeOptions {
5
- maxWidth?: number; // Default: 1568
6
- maxHeight?: number; // Default: 1568
7
- maxBytes?: number; // Default: 500KB
8
- jpegQuality?: number; // Default: 75
4
+ maxWidth?: number;
5
+ maxHeight?: number;
6
+ maxBytes?: number;
7
+ jpegQuality?: number;
9
8
  }
10
9
 
11
10
  export interface ResizedImage {
@@ -24,12 +23,12 @@ export interface ResizedImage {
24
23
  const DEFAULT_MAX_BYTES = 500 * 1024;
25
24
 
26
25
  const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
27
- // 1568px Anthropic downscales anything larger; OpenAI tiles at 768px;
28
- // sending bigger pixels wastes bandwidth the model never sees.
26
+ // Anthropic's "internal recommended size" Claude internally caps images at
27
+ // 1568px on the longest edge before vision processing.
29
28
  maxWidth: 1568,
30
29
  maxHeight: 1568,
31
30
  maxBytes: DEFAULT_MAX_BYTES,
32
- jpegQuality: 75,
31
+ jpegQuality: 80,
33
32
  };
34
33
 
35
34
  /** Pick the smallest of N encoded buffers. */
@@ -48,43 +47,34 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
48
47
  /**
49
48
  * Resize and recompress an image to fit within the specified max dimensions and file size.
50
49
  *
51
- * Defaults target Anthropic's internal 1568px downscale threshold and produce small
52
- * lossy JPEG output suitable for tool-call payloads (~100–500KB typical).
53
- *
54
50
  * Strategy:
55
- * 1. Fast path if input already fits dimensions AND is at <=25% of byte budget,
56
- * return as-is. Avoids re-encoding tiny icons/diagrams.
57
- * 2. Resize to maxWidth/maxHeight, encode both PNG and JPEG at default quality,
58
- * pick whichever is smaller. PNG wins for line art / few-color UI; JPEG wins
59
- * for photographic content.
60
- * 3. If still too large, JPEG-only quality ladder (PNG quality is a no-op).
61
- * 4. If still too large, progressively reduce dimensions and retry the JPEG ladder.
62
- * 5. Last resort: ship the smallest variant produced.
51
+ * 1. Probe metadata. If already within all limits, return original.
52
+ * 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG/WebP — return smallest.
53
+ * 3. If still too large, walk a lossy JPEG/WebP quality ladder.
54
+ * 4. If still too large, walk a dimension-scale ladder × quality ladder.
55
+ * 5. If still too large, return the smallest variant produced.
63
56
  *
64
- * On any decode failure, returns the original bytes unchanged with wasResized=false.
57
+ * Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
58
+ * off the JS thread when the terminal (`.bytes()`) is awaited.
65
59
  */
66
60
  export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
67
61
  const opts = { ...DEFAULT_OPTIONS, ...options };
68
62
  const inputBuffer = Buffer.from(img.data, "base64");
69
63
 
70
64
  try {
71
- const image = await PhotonImage.parse(inputBuffer);
72
-
73
- const originalWidth = image.width;
74
- const originalHeight = image.height;
75
- const format = img.mimeType?.split("/")[1] ?? "png";
65
+ const { width: originalWidth, height: originalHeight, format } = await new Bun.Image(inputBuffer).metadata();
66
+ const sourceMime = img.mimeType ?? `image/${format}`;
76
67
 
77
- // Check if already within all limits (dimensions AND size)
78
- const originalSize = inputBuffer.length;
79
- // Fast path: skip if already within dimensions AND well under budget.
68
+ // Fast path: already within dimensions AND well under budget.
80
69
  // Threshold is 1/4 of budget — if already that compact, don't re-encode.
81
70
  // Avoids wasted work on tiny icons/diagrams while ensuring larger PNGs
82
71
  // still get JPEG-compressed.
72
+ const originalSize = inputBuffer.length;
83
73
  const comfortableSize = opts.maxBytes / 4;
84
74
  if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= comfortableSize) {
85
75
  return {
86
76
  buffer: inputBuffer,
87
- mimeType: img.mimeType ?? `image/${format}`,
77
+ mimeType: sourceMime,
88
78
  originalWidth,
89
79
  originalHeight,
90
80
  width: originalWidth,
@@ -117,14 +107,11 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
117
107
  height: number,
118
108
  quality: number,
119
109
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
120
- const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
121
-
122
110
  const [pngBuffer, jpegBuffer, webpBuffer] = await Promise.all([
123
- resized.encode(ImageFormat.PNG, quality),
124
- resized.encode(ImageFormat.JPEG, quality),
125
- resized.encode(ImageFormat.WEBP, quality),
111
+ new Bun.Image(inputBuffer).resize(width, height).png().bytes(),
112
+ new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
113
+ new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
126
114
  ]);
127
-
128
115
  return pickSmallest(
129
116
  { buffer: pngBuffer, mimeType: "image/png" },
130
117
  { buffer: jpegBuffer, mimeType: "image/jpeg" },
@@ -140,10 +127,9 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
140
127
  height: number,
141
128
  quality: number,
142
129
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
143
- const resized = await image.resize(width, height, SamplingFilter.Lanczos3);
144
130
  const [jpegBuffer, webpBuffer] = await Promise.all([
145
- resized.encode(ImageFormat.JPEG, quality),
146
- resized.encode(ImageFormat.WEBP, quality),
131
+ new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
132
+ new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
147
133
  ]);
148
134
  return pickSmallest(
149
135
  { buffer: jpegBuffer, mimeType: "image/jpeg" },
@@ -159,7 +145,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
159
145
  let finalWidth = targetWidth;
160
146
  let finalHeight = targetHeight;
161
147
 
162
- // First attempt: resize to target, try both PNG and JPEG, pick smaller
148
+ // First attempt: resize to target, try PNG/JPEG/WebP, pick smallest
163
149
  best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
164
150
 
165
151
  if (best.buffer.length <= opts.maxBytes) {
@@ -264,9 +250,12 @@ export function formatDimensionNote(result: ResizedImage): string | undefined {
264
250
  if (!result.wasResized) {
265
251
  return undefined;
266
252
  }
267
-
253
+ if (!result.originalWidth || !result.originalHeight || !result.width || !result.height) {
254
+ return undefined;
255
+ }
256
+ if (result.width === result.originalWidth && result.height === result.originalHeight) {
257
+ return undefined;
258
+ }
268
259
  const scale = result.originalWidth / result.width;
269
- return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${
270
- result.height
271
- }. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
260
+ return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
272
261
  }
package/src/vim/parser.ts CHANGED
@@ -124,10 +124,6 @@ export function parseKeySequences(sequences: string[]): VimKeyToken[] {
124
124
  return tokens;
125
125
  }
126
126
 
127
- export function tokensToReplay(tokens: readonly VimKeyToken[]): string[] {
128
- return tokens.map(token => token.value);
129
- }
130
-
131
127
  export function replayTokens(values: readonly string[]): VimKeyToken[] {
132
128
  return values.map((value, index) => ({
133
129
  value,
@@ -136,16 +132,3 @@ export function replayTokens(values: readonly string[]): VimKeyToken[] {
136
132
  offset: index,
137
133
  }));
138
134
  }
139
-
140
- export function formatVimError(error: unknown): string {
141
- if (!(error instanceof VimError)) {
142
- return error instanceof Error ? error.message : String(error);
143
- }
144
-
145
- const base = error.message;
146
- if (!error.location) {
147
- return base;
148
- }
149
-
150
- return `${base} (sequence ${error.location.sequenceIndex + 1}, token ${error.location.offset + 1})`;
151
- }
package/src/vim/render.ts CHANGED
@@ -13,7 +13,7 @@ import type {
13
13
 
14
14
  export const VIM_OPEN_VIEWPORT_LINES = 80;
15
15
  export const VIM_DEFAULT_VIEWPORT_LINES = 10;
16
- export const VIM_TAB_DISPLAY = "→";
16
+ const VIM_TAB_DISPLAY = "→";
17
17
  const VIM_INLINE_CURSOR = "▏";
18
18
 
19
19
  const VIM_VIEWPORT_WIDTH = 140;
package/src/vim/types.ts CHANGED
@@ -164,7 +164,7 @@ export function clonePosition(position: Position): Position {
164
164
  return { line: position.line, col: position.col };
165
165
  }
166
166
 
167
- export function comparePositions(left: Position, right: Position): number {
167
+ function comparePositions(left: Position, right: Position): number {
168
168
  if (left.line !== right.line) {
169
169
  return left.line - right.line;
170
170
  }
@@ -5,15 +5,11 @@
5
5
  * Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
6
6
  * Returns synthesized answers with citations and source metadata from grounding chunks.
7
7
  */
8
- import {
9
- ANTIGRAVITY_SYSTEM_INSTRUCTION,
10
- extractRetryDelay,
11
- getAntigravityUserAgent,
12
- getGeminiCliHeaders,
13
- } from "@oh-my-pi/pi-ai";
8
+ import { ANTIGRAVITY_SYSTEM_INSTRUCTION, getAntigravityUserAgent, getGeminiCliHeaders } from "@oh-my-pi/pi-ai";
14
9
  import { refreshAntigravityToken } from "@oh-my-pi/pi-ai/utils/oauth/google-antigravity";
15
10
  import { refreshGoogleCloudToken } from "@oh-my-pi/pi-ai/utils/oauth/google-gemini-cli";
16
- import { getAgentDbPath } from "@oh-my-pi/pi-utils";
11
+ import { fetchWithRetry, getAgentDbPath } from "@oh-my-pi/pi-utils";
12
+
17
13
  import { AgentStorage } from "../../../session/agent-storage";
18
14
  import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
19
15
  import { SearchProviderError } from "../../../web/search/types";
@@ -303,98 +299,42 @@ async function callGeminiSearch(
303
299
  }
304
300
  (requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
305
301
  }
306
- let response: Response | undefined;
307
- let rateLimitTimeSpent = 0;
308
- let lastError: Error | undefined;
309
-
310
- for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
311
- const url = `${endpoints[endpointIndex]}/v1internal:streamGenerateContent?alt=sse`;
312
-
313
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
314
- try {
315
- response = await fetch(url, {
316
- method: "POST",
317
- headers: {
318
- Authorization: `Bearer ${auth.accessToken}`,
319
- "Content-Type": "application/json",
320
- Accept: "text/event-stream",
321
- ...headers,
322
- },
323
- body: JSON.stringify(requestBody),
324
- });
325
- } catch (error) {
326
- if (attempt < MAX_RETRIES) {
327
- await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
328
- continue;
329
- }
330
-
331
- if (auth.isAntigravity && endpointIndex < endpoints.length - 1) {
332
- break;
333
- }
334
-
335
- throw error;
336
- }
337
-
338
- if (response.ok) {
339
- break;
340
- }
341
-
342
- const errorText = await response.text();
343
- const canRefreshAuth =
344
- response.status === 401 ||
345
- response.status === 403 ||
346
- (response.status === 400 &&
347
- /api key not valid|invalid credentials|invalid authentication/i.test(errorText));
348
- if (canRefreshAuth && attempt === 0 && (await refreshGeminiAuth(auth))) {
349
- continue;
350
- }
351
- const isRetryableStatus =
352
- response.status === 429 ||
353
- response.status === 500 ||
354
- response.status === 502 ||
355
- response.status === 503 ||
356
- response.status === 504;
357
-
358
- if (isRetryableStatus && attempt < MAX_RETRIES) {
359
- const serverDelay = extractRetryDelay(errorText, response);
360
- if (response.status === 429) {
361
- if (serverDelay && rateLimitTimeSpent + serverDelay <= RATE_LIMIT_BUDGET_MS) {
362
- rateLimitTimeSpent += serverDelay;
363
- await Bun.sleep(serverDelay);
364
- continue;
365
- }
366
- if (!serverDelay) {
367
- await Bun.sleep(BASE_DELAY_MS * 2 ** attempt);
368
- continue;
369
- }
370
- } else {
371
- await Bun.sleep(serverDelay ?? BASE_DELAY_MS * 2 ** attempt);
372
- continue;
373
- }
374
- }
375
-
376
- lastError = new SearchProviderError(
377
- "gemini",
378
- `Gemini Cloud Code API error (${response.status}): ${errorText}`,
379
- response.status,
380
- );
381
-
382
- if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) {
383
- break;
384
- }
385
-
386
- throw lastError;
387
- }
302
+ const buildInit = (): RequestInit => ({
303
+ method: "POST",
304
+ headers: {
305
+ Authorization: `Bearer ${auth.accessToken}`,
306
+ "Content-Type": "application/json",
307
+ Accept: "text/event-stream",
308
+ ...headers,
309
+ },
310
+ body: JSON.stringify(requestBody),
311
+ });
312
+ const urlFor = (attempt: number) =>
313
+ `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
314
+
315
+ let response = await fetchWithRetry(urlFor, {
316
+ ...buildInit(),
317
+ maxAttempts: MAX_RETRIES + 1,
318
+ defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
319
+ maxDelayMs: RATE_LIMIT_BUDGET_MS,
320
+ });
388
321
 
389
- if (response?.ok) {
390
- break;
322
+ if (!response.ok) {
323
+ const errorText = await response.clone().text();
324
+ const canRefreshAuth =
325
+ response.status === 401 ||
326
+ response.status === 403 ||
327
+ (response.status === 400 && /api key not valid|invalid credentials|invalid authentication/i.test(errorText));
328
+ if (canRefreshAuth && (await refreshGeminiAuth(auth))) {
329
+ response = await fetchWithRetry(urlFor, {
330
+ ...buildInit(),
331
+ maxAttempts: MAX_RETRIES + 1,
332
+ defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
333
+ maxDelayMs: RATE_LIMIT_BUDGET_MS,
334
+ });
391
335
  }
392
336
  }
393
337
 
394
- if (!response) {
395
- throw new SearchProviderError("gemini", "Gemini API request failed", 500);
396
- }
397
-
398
338
  if (!response.ok) {
399
339
  const errorText = await response.text();
400
340
  throw new SearchProviderError(
@@ -1,6 +0,0 @@
1
- Submits a finalized implementation plan for user approval.
2
-
3
- Write the plan to `local://PLAN.md` first, then call this with `title` (e.g. `WP_MIGRATION_PLAN`); on approval the file is renamed to `local://<title>.md` and full tool access is restored.
4
- - Use only after planning implementation steps; not for pure research.
5
- - NEVER call before the plan file exists.
6
- - NEVER use `ask` to request plan approval — this tool does that.
@@ -1,97 +0,0 @@
1
- import * as fs from "node:fs/promises";
2
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
- import { isEnoent, prompt } from "@oh-my-pi/pi-utils";
4
- import { type Static, Type } from "@sinclair/typebox";
5
- import exitPlanModeDescription from "../prompts/tools/exit-plan-mode.md" with { type: "text" };
6
- import type { ToolSession } from ".";
7
- import { resolvePlanPath } from "./plan-mode-guard";
8
- import { ToolError } from "./tool-errors";
9
-
10
- const exitPlanModeSchema = Type.Object({
11
- title: Type.String({ description: "final plan title", examples: ["WP_MIGRATION_PLAN"] }),
12
- });
13
-
14
- type ExitPlanModeParams = Static<typeof exitPlanModeSchema>;
15
-
16
- function normalizePlanTitle(title: string): { title: string; fileName: string } {
17
- const trimmed = title.trim();
18
- if (!trimmed) {
19
- throw new ToolError("Title is required and must not be empty.");
20
- }
21
-
22
- if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
23
- throw new ToolError("Title must not contain path separators or '..'.");
24
- }
25
-
26
- const withExtension = trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`;
27
- if (!/^[A-Za-z0-9_-]+\.md$/.test(withExtension)) {
28
- throw new ToolError("Title may only contain letters, numbers, underscores, or hyphens.");
29
- }
30
-
31
- const normalizedTitle = withExtension.slice(0, -3);
32
- return { title: normalizedTitle, fileName: withExtension };
33
- }
34
-
35
- export interface ExitPlanModeDetails {
36
- planFilePath: string;
37
- planExists: boolean;
38
- title: string;
39
- finalPlanFilePath: string;
40
- }
41
-
42
- export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, ExitPlanModeDetails> {
43
- readonly name = "exit_plan_mode";
44
- readonly label = "ExitPlanMode";
45
- readonly description: string;
46
- readonly parameters = exitPlanModeSchema;
47
- readonly strict = true;
48
- readonly concurrency = "exclusive";
49
- readonly intent = (): string => "present plan";
50
-
51
- constructor(private readonly session: ToolSession) {
52
- this.description = prompt.render(exitPlanModeDescription);
53
- }
54
-
55
- async execute(
56
- _toolCallId: string,
57
- params: ExitPlanModeParams,
58
- _signal?: AbortSignal,
59
- _onUpdate?: AgentToolUpdateCallback<ExitPlanModeDetails>,
60
- _context?: AgentToolContext,
61
- ): Promise<AgentToolResult<ExitPlanModeDetails>> {
62
- const state = this.session.getPlanModeState?.();
63
- if (!state?.enabled) {
64
- throw new ToolError("Plan mode is not active.");
65
- }
66
-
67
- const normalized = normalizePlanTitle(params.title);
68
- const finalPlanFilePath = `local://${normalized.fileName}`;
69
- const resolvedPlanPath = resolvePlanPath(this.session, state.planFilePath);
70
- resolvePlanPath(this.session, finalPlanFilePath);
71
- let planExists = false;
72
- try {
73
- const stat = await fs.stat(resolvedPlanPath);
74
- planExists = stat.isFile();
75
- } catch (error) {
76
- if (!isEnoent(error)) {
77
- throw error;
78
- }
79
- }
80
-
81
- if (!planExists) {
82
- throw new ToolError(
83
- `Plan file not found at ${state.planFilePath}. Write the finalized plan to ${state.planFilePath} before calling exit_plan_mode.`,
84
- );
85
- }
86
-
87
- return {
88
- content: [{ type: "text", text: "Plan ready for approval." }],
89
- details: {
90
- planFilePath: state.planFilePath,
91
- planExists,
92
- title: normalized.title,
93
- finalPlanFilePath,
94
- },
95
- };
96
- }
97
- }
@@ -1,108 +0,0 @@
1
- // Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
2
- // Lower score = better match.
3
-
4
- export interface FuzzyMatch {
5
- matches: boolean;
6
- score: number;
7
- }
8
-
9
- export function fuzzyMatch(query: string, text: string): FuzzyMatch {
10
- const queryLower = query.toLowerCase();
11
- const textLower = text.toLowerCase();
12
-
13
- if (queryLower.length === 0) {
14
- return { matches: true, score: 0 };
15
- }
16
-
17
- if (queryLower.length > textLower.length) {
18
- return { matches: false, score: 0 };
19
- }
20
-
21
- let queryIndex = 0;
22
- let score = 0;
23
- let lastMatchIndex = -1;
24
- let consecutiveMatches = 0;
25
-
26
- for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
27
- if (textLower[i] === queryLower[queryIndex]) {
28
- const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!);
29
-
30
- // Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
31
- if (lastMatchIndex === i - 1) {
32
- consecutiveMatches++;
33
- score -= consecutiveMatches * 5;
34
- } else {
35
- consecutiveMatches = 0;
36
- // Penalize gaps between matched characters
37
- if (lastMatchIndex >= 0) {
38
- score += (i - lastMatchIndex - 1) * 2;
39
- }
40
- }
41
-
42
- // Reward matches at word boundaries (start of words are more likely intentional targets)
43
- if (isWordBoundary) {
44
- score -= 10;
45
- }
46
-
47
- // Slight penalty for matches later in the string (prefer earlier matches)
48
- score += i * 0.1;
49
-
50
- lastMatchIndex = i;
51
- queryIndex++;
52
- }
53
- }
54
-
55
- // Not all query characters were found in order
56
- if (queryIndex < queryLower.length) {
57
- return { matches: false, score: 0 };
58
- }
59
-
60
- return { matches: true, score };
61
- }
62
-
63
- // Filter and sort items by fuzzy match quality (best matches first)
64
- // Supports space-separated tokens: all tokens must match, sorted by match count then score
65
- export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
66
- if (!query.trim()) {
67
- return items;
68
- }
69
-
70
- // Split query into tokens
71
- const tokens = query
72
- .trim()
73
- .split(/\s+/)
74
- .filter(t => t.length > 0);
75
-
76
- if (tokens.length === 0) {
77
- return items;
78
- }
79
-
80
- const results: { item: T; totalScore: number }[] = [];
81
-
82
- for (const item of items) {
83
- const text = getText(item);
84
- let totalScore = 0;
85
- let allMatch = true;
86
-
87
- // Check each token against the text - ALL must match
88
- for (const token of tokens) {
89
- const match = fuzzyMatch(token, text);
90
- if (match.matches) {
91
- totalScore += match.score;
92
- } else {
93
- allMatch = false;
94
- break;
95
- }
96
- }
97
-
98
- // Only include if all tokens match
99
- if (allMatch) {
100
- results.push({ item, totalScore });
101
- }
102
- }
103
-
104
- // Sort by score (asc, lower is better)
105
- results.sort((a, b) => a.totalScore - b.totalScore);
106
-
107
- return results.map(r => r.item);
108
- }
@@ -1,27 +0,0 @@
1
- import { ImageFormat, PhotonImage } from "@oh-my-pi/pi-natives";
2
-
3
- /**
4
- * Convert image to PNG format for terminal display.
5
- * Kitty graphics protocol requires PNG format (f=100).
6
- */
7
- export async function convertToPng(
8
- base64Data: string,
9
- mimeType: string,
10
- ): Promise<{ data: string; mimeType: string } | null> {
11
- // Already PNG, no conversion needed
12
- if (mimeType === "image/png") {
13
- return { data: base64Data, mimeType };
14
- }
15
-
16
- try {
17
- const image = await PhotonImage.parse(new Uint8Array(Buffer.from(base64Data, "base64")));
18
- const pngBuffer = await image.encode(ImageFormat.PNG, 100);
19
- return {
20
- data: Buffer.from(pngBuffer).toBase64(),
21
- mimeType: "image/png",
22
- };
23
- } catch {
24
- // Conversion failed
25
- return null;
26
- }
27
- }