@oh-my-pi/pi-coding-agent 14.0.4 → 14.0.5

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.
@@ -1,7 +1,6 @@
1
1
  import { extractMermaidBlocks, logger, renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils";
2
2
 
3
- const cache = new Map<bigint, string>();
4
- const failed = new Set<bigint>();
3
+ const cache = new Map<bigint | number, string | null>();
5
4
 
6
5
  let onRenderNeeded: (() => void) | null = null;
7
6
 
@@ -16,7 +15,7 @@ export function setMermaidRenderCallback(callback: (() => void) | null): void {
16
15
  * Get a pre-rendered mermaid ASCII diagram by hash.
17
16
  * Returns null if not cached or rendering failed.
18
17
  */
19
- export function getMermaidAscii(hash: bigint): string | null {
18
+ export function getMermaidAscii(hash: bigint | number): string | null {
20
19
  return cache.get(hash) ?? null;
21
20
  }
22
21
 
@@ -31,14 +30,14 @@ export function prerenderMermaid(markdown: string): void {
31
30
  let hasNew = false;
32
31
 
33
32
  for (const { source, hash } of blocks) {
34
- if (cache.has(hash) || failed.has(hash)) continue;
33
+ if (cache.has(hash)) continue;
35
34
 
36
35
  const ascii = renderMermaidAsciiSafe(source);
37
36
  if (ascii) {
38
37
  cache.set(hash, ascii);
39
38
  hasNew = true;
40
39
  } else {
41
- failed.add(hash);
40
+ cache.set(hash, null);
42
41
  }
43
42
  }
44
43
 
@@ -58,7 +57,7 @@ export function prerenderMermaid(markdown: string): void {
58
57
  */
59
58
  export function hasPendingMermaid(markdown: string): boolean {
60
59
  const blocks = extractMermaidBlocks(markdown);
61
- return blocks.some(({ hash }) => !cache.has(hash) && !failed.has(hash));
60
+ return blocks.some(({ hash }) => !cache.has(hash));
62
61
  }
63
62
 
64
63
  /**
@@ -66,5 +65,4 @@ export function hasPendingMermaid(markdown: string): boolean {
66
65
  */
67
66
  export function clearMermaidCache(): void {
68
67
  cache.clear();
69
- failed.clear();
70
68
  }
package/src/priority.json CHANGED
@@ -25,5 +25,13 @@
25
25
  "opus-4.1",
26
26
  "opus-4-1",
27
27
  "pro"
28
+ ],
29
+ "designer": [
30
+ "google-gemini-cli/gemini-3.1-pro",
31
+ "google-gemini-cli/gemini-3-pro",
32
+ "gemini-3.1-pro",
33
+ "gemini-3-1-pro",
34
+ "gemini-3-pro",
35
+ "gemini-3"
28
36
  ]
29
37
  }
@@ -1,8 +1,7 @@
1
1
  ---
2
2
  name: designer
3
3
  description: UI/UX specialist for design implementation, review, visual refinement
4
- spawns: explore
5
- model: google-gemini-cli/gemini-3.1-pro, google-gemini-cli/gemini-3-pro, gemini-3.1-pro, gemini-3-1-pro, gemini-3-pro, gemini-3, pi/default
4
+ model: pi/designer
6
5
  ---
7
6
 
8
7
  You are an expert UI/UX designer implementing and reviewing UI designs.
@@ -3,67 +3,66 @@ Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit
3
3
  <rules>
4
4
  - **MUST** `read` first. Never invent chunk paths or CRCs. Copy them from the latest `read` output or edit response.
5
5
  - `sel` format:
6
- - insertions: `chunk` or `chunk@region`
7
- - replacements: `chunk#CRC` or `chunk#CRC@region`
8
- - Without a `@region` it defaults to the entire chunk including leading trivia. Valid regions: `head`, `body`, `tail`, `decl`.
6
+ - insertions: `chunk`, `chunk~`, or `chunk^`
7
+ - replacements: `chunk#CRC`, `chunk#CRC~`, or `chunk#CRC^`
8
+ - Without a suffix it defaults to the entire chunk including leading trivia. `~` targets the body, `^` targets the head.
9
9
  - If the exact chunk path is unclear, run `read(path="file", sel="?")` and copy a selector from that listing.
10
10
  {{#if chunkAutoIndent}}
11
- - Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `@body` of a method, write the body starting at column 0:
11
+ - Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `~` of a method, write the body starting at column 0:
12
12
  ```
13
13
  content: "if (x) {\n\treturn true;\n}"
14
14
  ```
15
15
  The tool adds the correct base indent automatically. Never manually pad with the chunk's own indentation.
16
16
  {{else}}
17
17
  - Match the file's literal tabs/spaces in `content`. Do not convert indentation to canonical `\t`.
18
- - Write content at indent-level 0 relative to the target region. For example, to replace `@body` of a method, write:
18
+ - Write content at indent-level 0 relative to the target region. For example, to replace `~` of a method, write:
19
19
  ```
20
20
  content: "if (x) {\n return true;\n}"
21
21
  ```
22
22
  The tool adds the correct base indent automatically, then preserves the tabs/spaces you used inside the snippet. Never manually pad with the chunk's own indentation.
23
23
  {{/if}}
24
- - `@region` only works on container chunks (classes, functions, impl blocks, sections). Do **not** use `@head`/`@body`/`@tail` on leaf chunks (enum variants, fields, single statements) — use the whole chunk instead.
24
+ - Region suffixes only apply to container chunks (classes, functions, impl blocks, sections). On leaf chunks (enum variants, fields, single statements, and compound statements like `if`/`for`/`while`/`match`/`try`), `~` and `^` silently fall back to whole-chunk replacement prefer the unsuffixed form and always supply the complete replacement (condition + body, not just the body) to avoid dropping structural parts.
25
25
  - `replace` requires the current CRC. Insertions do not.
26
- - **CRCs change after every edit.** Always use the selectors/CRCs from the most recent `read` or edit response. Never reuse a CRC from a previous edit.
26
+ - **CRCs change after every edit.** The edit response always carries the new CRCs use those for the next call or run `read(path="file", sel="?")` to refresh. Never reuse a CRC from before the latest edit.
27
27
  </rules>
28
28
 
29
29
  <critical>
30
30
  You **MUST** use the narrowest region that covers your change. Replacing without a region replaces the **entire chunk including leading comments, decorators, and attributes** — omitting them from `content` deletes them.
31
31
 
32
- **`replace` is total, not surgical.** The `content` you supply becomes the *complete* new content for the targeted region. Everything in the original region that you omit from `content` is deleted. Before replacing `@body` on any chunk, verify the chunk does not contain children you intend to keep. If a chunk spans hundreds of lines and your change touches only a few, target a specific child chunk — not the parent.
32
+ **`replace` is total, not surgical.** The `content` you supply becomes the *complete* new content for the targeted region. Everything in the original region that you omit from `content` is deleted. Before replacing `~` on any chunk, verify the chunk does not contain children you intend to keep. If a chunk spans hundreds of lines and your change touches only a few, target a specific child chunk — not the parent.
33
33
 
34
- **Group chunks (`stmts_*`, `imports_*`, `decls_*`) are containers.** They hold many sibling items (test functions, import statements, declarations). Replacing `@body` on a group chunk replaces **all** of its children. To edit one item inside a group, target that item's own chunk path. If no child chunk exists, use the specific child's chunk selector from `read` output — do not replace the parent group.
34
+ **Group chunks (`stmts_*`, `imports_*`, `decls_*`) are containers.** They hold many sibling items (test functions, import statements, declarations). Replacing `~` on a group chunk replaces **all** of its children. To edit one item inside a group, target that item's own chunk path. If no child chunk exists, use the specific child's chunk selector from `read` output — do not replace the parent group.
35
35
  </critical>
36
36
 
37
37
  <regions>
38
+ Given a chunk like:
38
39
  ```
39
- @head ··· ┊ /// doc comment
40
- · ┊ #[attr]
41
- @decl ··· ┊ fn foo(x: i32) {
42
- @body ·· ┊ body();
43
- @tail ·· ┊ }
40
+ /// doc comment <-- leading trivia
41
+ #[attr] <-- leading trivia
42
+ fn foo(x: i32) { <-- signature + opening delimiter
43
+ body(); <-- body
44
+ } <-- closing delimiter
44
45
  ```
45
- - `@body` — the interior only. **Use for most edits.**
46
- - `@head` — leading trivia + signature + opening delimiter.
47
- - `@tail` — the closing delimiter.
48
- - `@decl` — everything except leading trivia (signature + body + closing delimiter).
49
- - *(no region)* — the entire chunk including leading trivia. Same as `@head` + `@body` + `@tail`.
50
46
 
51
- For leaf chunks (fields, variants, single-line items), `@body` falls back to the full chunk.
47
+ Append `~` to target the body, `^` to target the head (trivia + signature), or nothing for the whole chunk:
48
+ - `fn_foo#CRC~` — body only. **Use for most edits.** On leaf chunks, falls back to whole chunk.
49
+ - `fn_foo#CRC^` — head (decorators, attributes, doc comments, signature, opening delimiter).
50
+ - `fn_foo#CRC` — entire chunk including leading trivia.
51
+ - `chunk~` + `append`/`prepend` inserts *inside* the container. `chunk` + `append`/`prepend` inserts *outside*.
52
52
 
53
- `append`/`prepend` without a `@region` inserts _outside_ the chunk. To add children _inside_ a class, struct, enum, or function body, use `@body`:
54
- - `class_Foo@body` + `append` → adds inside the class before `}`
55
- - `class_Foo@body` + `prepend` adds inside the class after `{`
56
- - `class_Foo` + `append` → adds after the entire class (after `}`)
53
+ **Note on leading trivia:** whether a decorator/doc comment belongs to `^` depends on the parser. In Rust and Python, attributes and decorators are attached to the function chunk, so `^` covers them. In TypeScript/JavaScript, a `@decorator` + `/** jsdoc */` block immediately above a method often surfaces as a **separate sibling chunk** (shown as `chunk#CRC` in the `?` listing) rather than as part of the function's `^`. If you need to rewrite a decorator, check the `?` listing for a sibling `chunk#CRC` directly above your target.
54
+
55
+ **Note on non-code formats:** for prose and data formats (markdown, YAML, JSON, fenced code blocks, frontmatter), `^` and `~` fall back to the whole chunk. Always replace the entire chunk and include any delimiter syntax (fence backticks, `---` frontmatter markers, list markers) in your `content` omitting them deletes them. For markdown sections (`sect_*`), always use unsuffixed whole-chunk replace — `^` and `~` on section containers also fall back to whole-chunk replace. When editing fenced code blocks in markdown, use the exact whitespace from the file (read with `raw` first) the tool preserves literal indentation inside fenced blocks, but any content you supply is written verbatim. To insert content after a markdown section heading, use `after` on the heading chunk (`sect_*.chunk` or `sect_*.chunk_1`) — not `before`/`prepend` on the section itself, which lands physically before the heading and gets absorbed by the preceding section on reparse.
57
56
  </regions>
58
57
 
59
58
  <ops>
60
59
  |op|sel|effect|
61
60
  |---|---|---|
62
- |`replace`|`chunk#CRC(@region)?`|rewrite the addressed region|
63
- |`before`|`chunk(@region)?`|insert before the region span|
64
- |`after`|`chunk(@region)?`|insert after the region span|
65
- |`prepend`|`chunk(@region)?`|insert at the start inside the region|
66
- |`append`|`chunk(@region)?`|insert at the end inside the region|
61
+ |`replace`|`chunk#CRC`, `chunk#CRC~`, or `chunk#CRC^`|rewrite the addressed region|
62
+ |`before`|`chunk`, `chunk~`, or `chunk^`|insert before the region span|
63
+ |`after`|`chunk`, `chunk~`, or `chunk^`|insert after the region span|
64
+ |`prepend`|`chunk`, `chunk~`, or `chunk^`|insert at the start inside the region|
65
+ |`append`|`chunk`, `chunk~`, or `chunk^`|insert at the end inside the region|
67
66
  </ops>
68
67
 
69
68
  <examples>
@@ -137,9 +136,9 @@ function makeCounter(start: number): Counter {
137
136
  }
138
137
  ```
139
138
 
140
- **Replace a method body** (`@body`):
139
+ **Replace a method body** (`~`):
141
140
  ```
142
- { "sel": "class_Counter.fn_increment#NQWY@body", "op": "replace", "content": "this.value += 1;\nconsole.log('incremented to', this.value);\n" }
141
+ { "sel": "class_Counter.fn_increment#NQWY~", "op": "replace", "content": "this.value += 1;\nconsole.log('incremented to', this.value);\n" }
143
142
  ```
144
143
  Result — only the body changes, signature and braces are kept:
145
144
  ```
@@ -149,9 +148,9 @@ Result — only the body changes, signature and braces are kept:
149
148
  }
150
149
  ```
151
150
 
152
- **Replace a function header** (`@head` — signature and doc comment):
151
+ **Replace a function header** (`^` — signature and doc comment):
153
152
  ```
154
- { "sel": "fn_createCounter#PQQY@head", "op": "replace", "content": "/** Creates a counter with the given start value. */\nfunction createCounter(initial: number, label?: string): Counter {\n" }
153
+ { "sel": "fn_createCounter#PQQY^", "op": "replace", "content": "/** Creates a counter with the given start value. */\nfunction createCounter(initial: number, label?: string): Counter {\n" }
155
154
  ```
156
155
  Result — adds a doc comment and updates the signature, body untouched:
157
156
  ```
@@ -197,9 +196,9 @@ function isActive(s: Status): boolean {
197
196
  function createCounter(initial: number): Counter {
198
197
  ```
199
198
 
200
- **Prepend inside a container** (`@body` + `prepend`):
199
+ **Prepend inside a container** (`~` + `prepend`):
201
200
  ```
202
- { "sel": "class_Counter@body", "op": "prepend", "content": "label: string = 'default';\n\n" }
201
+ { "sel": "class_Counter~", "op": "prepend", "content": "label: string = 'default';\n\n" }
203
202
  ```
204
203
  Result — a new field is added at the top of the class body, before existing members:
205
204
  ```
@@ -209,12 +208,12 @@ class Counter {
209
208
  value: number = 0;
210
209
  ```
211
210
 
212
- **Append inside a container** (`@body` + `append`):
211
+ **Append inside a container** (`~` + `append`):
213
212
  ~~~json
214
213
  {{#if chunkAutoIndent}}
215
- { "sel": "class_Counter@body", "op": "append", "content": "\nreset(): void {\n\tthis.value = 0;\n}\n" }
214
+ { "sel": "class_Counter~", "op": "append", "content": "\nreset(): void {\n\tthis.value = 0;\n}\n" }
216
215
  {{else}}
217
- { "sel": "class_Counter@body", "op": "append", "content": "\nreset(): void {\n this.value = 0;\n}\n" }
216
+ { "sel": "class_Counter~", "op": "append", "content": "\nreset(): void {\n this.value = 0;\n}\n" }
218
217
  {{/if}}
219
218
  ~~~
220
219
  Result — a new method is added at the end of the class body, before the closing `}`:
@@ -241,8 +240,8 @@ Result — the method is removed from the class.
241
240
  - Match the file's real indentation characters in your snippet. The tool preserves your literal tabs/spaces after adding the target region's base indent.
242
241
  {{/if}}
243
242
  - Do NOT include the chunk's base indentation — only indent relative to the region's opening level.
244
- - For `@body` of a function: write at column 0, e.g. `"return x;\n"`. The tool adds the correct base indent.
245
- - For `@head`: write at the chunk's own depth. A class member's head uses `"/** doc */\nstart(): void {"`.
243
+ - For `~` of a function: write at column 0, and use `\t` for *relative* nesting. Flat body: `"return x;\n"`. Nested body: `"if (cond) {\n\treturn x;\n}\n"` — the `if` is at column 0, the `return` is one tab in, and the tool adds the method's base indent to both.
244
+ - For `^`: write at the chunk's own depth. A class member's head uses `"/** doc */\nstart(): void {"`.
246
245
  {{#if chunkAutoIndent}}
247
246
  - For a top-level item: start at zero indent. Write `"function foo() {\n\treturn 1;\n}\n"`.
248
247
  {{else}}
@@ -2,13 +2,15 @@ Reads files using syntax-aware chunks.
2
2
 
3
3
  <instruction>
4
4
  - `path` — file path or URL; may include `:selector` suffix
5
- - `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD@body`, `?`, `L50`, `L50-L120`, or `raw`
5
+ - `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD~`, `?`, `L50`, `L50-L120`, or `raw`
6
6
  - `timeout` — seconds, for URLs only
7
7
 
8
8
  Each opening anchor `[< full.chunk.path#CCCC ]` in the default output identifies a chunk. Use `full.chunk.path#CCCC` as-is to read truncated chunks.
9
9
  If you need a canonical target list, run `read(path="file", sel="?")`. That listing shows chunk paths with CRCs.
10
10
  Line numbers in the gutter are absolute file line numbers.
11
11
 
12
+ `L20` (single line, no explicit end) is shorthand for `L20` to end-of-file. Use `L20-L20` for a one-line window.
13
+
12
14
  {{#if chunkAutoIndent}}
13
15
  Chunk reads normalize leading indentation so copied content round-trips cleanly into chunk edits.
14
16
  {{else}}
@@ -20,4 +22,5 @@ Chunk trees: JS, TS, TSX, Python, Rust, Go. Others use blank-line fallback.
20
22
 
21
23
  <critical>
22
24
  - **MUST** `read` before editing — never invent chunk names or CRCs.
25
+ - Chunk names are truncated (e.g., `handleRequest` becomes `fn_handleRequ`). Always copy chunk paths from `read` or `?` output — never construct them from source identifiers.
23
26
  </critical>
@@ -997,6 +997,16 @@ export class AgentSession {
997
997
  this.#lastAssistantMessage = undefined;
998
998
  if (!msg) return;
999
999
 
1000
+ // Invalidate GitHub Copilot credentials on auth failure so stale tokens
1001
+ // aren't reused on the next request
1002
+ if (
1003
+ msg.stopReason === "error" &&
1004
+ msg.provider === "github-copilot" &&
1005
+ msg.errorMessage?.includes("GitHub Copilot authentication failed")
1006
+ ) {
1007
+ await this.#modelRegistry.authStorage.remove("github-copilot");
1008
+ }
1009
+
1000
1010
  if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
1001
1011
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
1002
1012
  return;
@@ -761,7 +761,7 @@ function buildOpenAiNativeHistory(
761
761
  if (!msgId) {
762
762
  msgId = `msg_${msgIndex}`;
763
763
  } else if (msgId.length > 64) {
764
- msgId = `msg_${Bun.hash.xxHash64(msgId).toString(36)}`;
764
+ msgId = `msg_${Bun.hash(msgId).toString(36)}`;
765
765
  }
766
766
  input.push({
767
767
  type: "message",
@@ -501,6 +501,83 @@ export interface ReadableResult {
501
501
  markdown?: string;
502
502
  }
503
503
 
504
+ type ReadableFormat = "text" | "markdown";
505
+
506
+ /** Trim to non-empty string or undefined. */
507
+ function normalize(text: string | null | undefined): string | undefined {
508
+ const trimmed = text?.trim();
509
+ return trimmed || undefined;
510
+ }
511
+
512
+ /**
513
+ * Extract readable content from raw HTML.
514
+ * Tries Readability (article-isolation scoring) first, then falls back to a
515
+ * CSS selector chain over the same pre-parsed DOM. Returns null if neither
516
+ * path yields usable content.
517
+ */
518
+ export function extractReadableFromHtml(html: string, url: string, format: ReadableFormat): ReadableResult | null {
519
+ const { document } = parseHTML(html);
520
+
521
+ // --- Primary: Readability article extraction ---
522
+ const article = new Readability(document).parse();
523
+ if (article) {
524
+ const result = toReadableResult(url, format, article.textContent, article.content, {
525
+ title: article.title,
526
+ byline: article.byline,
527
+ excerpt: article.excerpt,
528
+ length: article.length,
529
+ });
530
+ if (result) return result;
531
+ }
532
+
533
+ // --- Fallback: CSS selector chain ---
534
+ const candidates = [
535
+ document.querySelector("[data-pagefind-body]"),
536
+ document.querySelector("main article"),
537
+ document.querySelector("article"),
538
+ document.querySelector("main"),
539
+ document.querySelector("[role='main']"),
540
+ document.body,
541
+ ];
542
+ for (const el of candidates) {
543
+ if (!el) continue;
544
+ const innerHTML = el.innerHTML?.trim();
545
+ const textContent = el.textContent?.trim();
546
+ if (!innerHTML || !textContent) continue;
547
+ const result = toReadableResult(url, format, textContent, innerHTML, {
548
+ title: document.title,
549
+ excerpt: textContent.slice(0, 240),
550
+ length: textContent.length,
551
+ });
552
+ if (result) return result;
553
+ }
554
+
555
+ return null;
556
+ }
557
+
558
+ /** Shared builder for both extraction paths. */
559
+ function toReadableResult(
560
+ url: string,
561
+ format: ReadableFormat,
562
+ textContent: string | null | undefined,
563
+ htmlContent: string | null | undefined,
564
+ meta: { title?: string | null; byline?: string | null; excerpt?: string | null; length?: number | null },
565
+ ): ReadableResult | null {
566
+ const text = normalize(textContent);
567
+ const markdown = format === "markdown" ? (normalize(htmlToBasicMarkdown(htmlContent ?? "")) ?? text) : undefined;
568
+ const normalizedText = format === "text" ? text : undefined;
569
+ if (!normalizedText && !markdown) return null;
570
+ return {
571
+ url,
572
+ title: normalize(meta.title),
573
+ byline: normalize(meta.byline),
574
+ excerpt: normalize(meta.excerpt),
575
+ contentLength: meta.length ?? text?.length ?? markdown?.length ?? 0,
576
+ text: normalizedText,
577
+ markdown,
578
+ };
579
+ }
580
+
504
581
  function ensureParam<T>(value: T | undefined, name: string, action: string): T {
505
582
  if (value === undefined || value === null || value === "") {
506
583
  throw new ToolError(`Missing required parameter '${name}' for action '${action}'.`);
@@ -1365,26 +1442,13 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1365
1442
  const format = params.format ?? "markdown";
1366
1443
  const html = (await untilAborted(signal, () => page.content())) as string;
1367
1444
  const url = page.url();
1368
- const { document } = parseHTML(html);
1369
- const reader = new Readability(document);
1370
- const article = reader.parse();
1371
- if (!article) {
1445
+ const readable = extractReadableFromHtml(html, url, format);
1446
+ if (!readable) {
1372
1447
  throw new ToolError("Readable content not found");
1373
1448
  }
1374
- const markdown = format === "markdown" ? htmlToBasicMarkdown(article.content ?? "") : undefined;
1375
- const text = format === "text" ? (article.textContent ?? "") : undefined;
1376
- const readable: ReadableResult = {
1377
- url,
1378
- title: article.title ?? undefined,
1379
- byline: article.byline ?? undefined,
1380
- excerpt: article.excerpt ?? undefined,
1381
- contentLength: article.length ?? article.textContent?.length ?? 0,
1382
- text,
1383
- markdown,
1384
- };
1385
1449
  details.url = url;
1386
1450
  details.readable = readable;
1387
- details.result = format === "markdown" ? (markdown ?? "") : (text ?? "");
1451
+ details.result = format === "markdown" ? (readable.markdown ?? "") : (readable.text ?? "");
1388
1452
  return toolResult(details)
1389
1453
  .text(JSON.stringify(readable, null, 2))
1390
1454
  .done();
@@ -1407,13 +1471,12 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1407
1471
  buffer = (await untilAborted(signal, () => page.screenshot({ type: "png", fullPage }))) as Buffer;
1408
1472
  }
1409
1473
 
1410
- // Compress for API content (same as pasted images)
1411
- // NOTE: screenshots can be deceptively large (especially PNG) even at modest resolutions,
1412
- // and tool results are immediately embedded in the next LLM request.
1413
- // Use a tighter budget than the global per-image limit to avoid 413 request_too_large.
1474
+ // Compress aggressively for API content screenshots are the most
1475
+ // frequent image source and land directly in the next LLM request.
1476
+ // 1024px is plenty for OCR/UI inspection; 150KB keeps payloads lean.
1414
1477
  const resized = await resizeImage(
1415
1478
  { type: "image", data: buffer.toBase64(), mimeType: "image/png" },
1416
- { maxBytes: 0.75 * 1024 * 1024 },
1479
+ { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
1417
1480
  );
1418
1481
  // Resolve destination: user-defined path > screenshotDir (auto-named) > temp file.
1419
1482
  const screenshotDir = (() => {
@@ -84,7 +84,7 @@ const IMAGE_MIME_BY_EXTENSION = new Map<string, string>([
84
84
  ]);
85
85
  const SUPPORTED_INLINE_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
86
86
  const MAX_INLINE_IMAGE_SOURCE_BYTES = 20 * 1024 * 1024;
87
- const MAX_INLINE_IMAGE_OUTPUT_BYTES = 0.75 * 1024 * 1024;
87
+ const MAX_INLINE_IMAGE_OUTPUT_BYTES = 300 * 1024;
88
88
 
89
89
  // =============================================================================
90
90
  // Utilities
package/src/tools/find.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
- import { FileType, type GlobMatch, glob } from "@oh-my-pi/pi-natives";
4
+ import * as natives from "@oh-my-pi/pi-natives";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
@@ -124,46 +124,15 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
124
124
  throw new ToolError("Limit must be a positive number");
125
125
  }
126
126
  const includeHidden = hidden ?? true;
127
-
128
- // If custom operations provided with glob, use that instead of fd
129
- if (this.#customOps?.glob) {
130
- if (!(await this.#customOps.exists(searchPath))) {
131
- throw new ToolError(`Path not found: ${scopePath}`);
132
- }
133
-
134
- if (!hasGlob && this.#customOps.stat) {
135
- const stat = await this.#customOps.stat(searchPath);
136
- if (stat.isFile()) {
137
- const files = [scopePath];
138
- const details: FindToolDetails = {
139
- scopePath,
140
- fileCount: 1,
141
- files,
142
- truncated: false,
143
- };
144
- return toolResult(details).text(files.join("\n")).done();
145
- }
146
- }
147
-
148
- const results = await this.#customOps.glob(globPattern, searchPath, {
149
- ignore: ["**/node_modules/**", "**/.git/**"],
150
- limit: effectiveLimit,
151
- });
152
-
153
- if (results.length === 0) {
127
+ const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
128
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
129
+ const buildResult = (files: string[]): AgentToolResult<FindToolDetails> => {
130
+ if (files.length === 0) {
154
131
  const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
155
132
  return toolResult(details).text("No files found matching pattern").done();
156
133
  }
157
134
 
158
- // Relativize paths
159
- const relativized = results.map(p => {
160
- if (p.startsWith(searchPath)) {
161
- return p.slice(searchPath.length + 1);
162
- }
163
- return path.relative(searchPath, p);
164
- });
165
-
166
- const listLimit = applyListLimit(relativized, { limit: effectiveLimit });
135
+ const listLimit = applyListLimit(files, { limit: effectiveLimit });
167
136
  const limited = listLimit.items;
168
137
  const limitMeta = listLimit.meta;
169
138
  const rawOutput = limited.join("\n");
@@ -186,6 +155,32 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
186
155
  }
187
156
 
188
157
  return resultBuilder.done();
158
+ };
159
+
160
+ if (this.#customOps?.glob) {
161
+ if (!(await this.#customOps.exists(searchPath))) {
162
+ throw new ToolError(`Path not found: ${scopePath}`);
163
+ }
164
+
165
+ if (!hasGlob && this.#customOps.stat) {
166
+ const stat = await this.#customOps.stat(searchPath);
167
+ if (stat.isFile()) {
168
+ return buildResult([scopePath]);
169
+ }
170
+ }
171
+
172
+ const results = await this.#customOps.glob(globPattern, searchPath, {
173
+ ignore: ["**/node_modules/**", "**/.git/**"],
174
+ limit: effectiveLimit,
175
+ });
176
+ const relativized = results.map(p => {
177
+ if (p.startsWith(searchPath)) {
178
+ return p.slice(searchPath.length + 1);
179
+ }
180
+ return path.relative(searchPath, p);
181
+ });
182
+
183
+ return buildResult(relativized);
189
184
  }
190
185
 
191
186
  let searchStat: fs.Stats;
@@ -199,20 +194,13 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
199
194
  }
200
195
 
201
196
  if (!hasGlob && searchStat.isFile()) {
202
- const files = [scopePath];
203
- const details: FindToolDetails = {
204
- scopePath,
205
- fileCount: 1,
206
- files,
207
- truncated: false,
208
- };
209
- return toolResult(details).text(files.join("\n")).done();
197
+ return buildResult([scopePath]);
210
198
  }
211
199
  if (!searchStat.isDirectory()) {
212
200
  throw new ToolError(`Path is not a directory: ${searchPath}`);
213
201
  }
214
202
 
215
- let matches: GlobMatch[];
203
+ let matches: natives.GlobMatch[];
216
204
  const onUpdateMatches: string[] = [];
217
205
  const updateIntervalMs = 200;
218
206
  let lastUpdate = 0;
@@ -233,27 +221,25 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
233
221
  });
234
222
  };
235
223
  const onMatch = onUpdate
236
- ? (err: Error | null, match: GlobMatch | null) => {
224
+ ? (err: Error | null, match: natives.GlobMatch | null) => {
237
225
  if (err || signal?.aborted || !match) return;
238
226
  let relativePath = match.path;
239
227
  if (!relativePath) return;
240
- if (match.fileType === FileType.Dir && !relativePath.endsWith("/")) {
228
+ if (match.fileType === natives.FileType.Dir && !relativePath.endsWith("/")) {
241
229
  relativePath += "/";
242
230
  }
243
231
  onUpdateMatches.push(relativePath);
244
232
  emitUpdate();
245
233
  }
246
234
  : undefined;
247
- const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
248
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
249
235
 
250
236
  const doGlob = async (useGitignore: boolean) =>
251
237
  untilAborted(combinedSignal, () =>
252
- glob(
238
+ natives.glob(
253
239
  {
254
240
  pattern: globPattern,
255
241
  path: searchPath,
256
- fileType: FileType.File,
242
+ fileType: natives.FileType.File,
257
243
  hidden: includeHidden,
258
244
  maxResults: effectiveLimit,
259
245
  sortByMtime: true,
@@ -266,7 +252,6 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
266
252
 
267
253
  try {
268
254
  let result = await doGlob(true);
269
- // If gitignore filtering yielded nothing, retry without it
270
255
  if (result.matches.length === 0) {
271
256
  result = await doGlob(false);
272
257
  }
@@ -282,12 +267,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
282
267
  throw error;
283
268
  }
284
269
 
285
- if (matches.length === 0) {
286
- const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
287
- return toolResult(details).text("No files found matching pattern").done();
288
- }
289
270
  const relativized: string[] = [];
290
-
291
271
  for (const match of matches) {
292
272
  throwIfAborted(signal);
293
273
  const line = match.path;
@@ -297,9 +277,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
297
277
 
298
278
  const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
299
279
  let relativePath = line;
300
-
301
- const isDirectory = match.fileType === FileType.Dir;
302
-
280
+ const isDirectory = match.fileType === natives.FileType.Dir;
303
281
  if ((isDirectory || hadTrailingSlash) && !relativePath.endsWith("/")) {
304
282
  relativePath += "/";
305
283
  }
@@ -307,39 +285,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
307
285
  relativized.push(relativePath);
308
286
  }
309
287
 
310
- if (relativized.length === 0) {
311
- const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
312
- return toolResult(details).text("No files found matching pattern").done();
313
- }
314
-
315
- // Results are already sorted by mtime from native (sortByMtime: true)
316
-
317
- const listLimit = applyListLimit(relativized, { limit: effectiveLimit });
318
- const limited = listLimit.items;
319
- const limitMeta = listLimit.meta;
320
-
321
- // Apply byte truncation (no line limit since we already have result limit)
322
- const rawOutput = limited.join("\n");
323
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
324
-
325
- const resultOutput = truncation.content;
326
- const details: FindToolDetails = {
327
- scopePath,
328
- fileCount: limited.length,
329
- files: limited,
330
- truncated: Boolean(limitMeta.resultLimit || truncation.truncated),
331
- resultLimitReached: limitMeta.resultLimit?.reached,
332
- truncation: truncation.truncated ? truncation : undefined,
333
- };
334
-
335
- const resultBuilder = toolResult(details)
336
- .text(resultOutput)
337
- .limits({ resultLimit: limitMeta.resultLimit?.reached });
338
- if (truncation.truncated) {
339
- resultBuilder.truncation(truncation, { direction: "head" });
340
- }
341
-
342
- return resultBuilder.done();
288
+ return buildResult(relativized);
343
289
  });
344
290
  }
345
291
  }
@@ -728,6 +728,7 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
728
728
  headers: {
729
729
  "Content-Type": "application/json",
730
730
  Authorization: `Bearer ${apiKey.apiKey}`,
731
+ "X-Title": "Oh-My-Pi",
731
732
  },
732
733
  body: JSON.stringify(requestBody),
733
734
  signal: requestSignal,