@oh-my-pi/pi-coding-agent 15.5.4 → 15.5.7

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 (43) hide show
  1. package/CHANGELOG.md +48 -2
  2. package/dist/types/config/settings-schema.d.ts +50 -2
  3. package/dist/types/edit/hashline/diff.d.ts +6 -1
  4. package/dist/types/edit/hashline/execute.d.ts +1 -2
  5. package/dist/types/edit/hashline/params.d.ts +4 -5
  6. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +23 -0
  7. package/dist/types/lib/xai-http.d.ts +40 -0
  8. package/dist/types/session/agent-session.d.ts +1 -0
  9. package/dist/types/tools/fetch.d.ts +19 -0
  10. package/dist/types/tools/find.d.ts +7 -0
  11. package/dist/types/tools/image-gen.d.ts +6 -2
  12. package/dist/types/tools/index.d.ts +1 -0
  13. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  14. package/dist/types/tools/tts.d.ts +18 -0
  15. package/package.json +8 -8
  16. package/scripts/build-binary.ts +11 -0
  17. package/src/config/model-registry.ts +41 -9
  18. package/src/config/settings-schema.ts +43 -2
  19. package/src/edit/diff.ts +5 -3
  20. package/src/edit/hashline/diff.ts +11 -4
  21. package/src/edit/hashline/execute.ts +3 -10
  22. package/src/edit/hashline/params.ts +10 -3
  23. package/src/edit/index.ts +9 -12
  24. package/src/edit/renderer.ts +14 -7
  25. package/src/edit/streaming.ts +15 -128
  26. package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
  27. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
  28. package/src/lib/xai-http.ts +124 -0
  29. package/src/main.ts +2 -1
  30. package/src/modes/controllers/selector-controller.ts +7 -2
  31. package/src/modes/interactive-mode.ts +1 -1
  32. package/src/modes/rpc/rpc-client.ts +3 -1
  33. package/src/prompts/tools/find.md +3 -2
  34. package/src/sdk.ts +15 -9
  35. package/src/session/agent-session.ts +48 -5
  36. package/src/tools/fetch.ts +145 -74
  37. package/src/tools/find.ts +38 -6
  38. package/src/tools/image-gen.ts +205 -7
  39. package/src/tools/index.ts +1 -0
  40. package/src/tools/plan-mode-guard.ts +14 -6
  41. package/src/tools/read.ts +57 -3
  42. package/src/tools/search.ts +2 -2
  43. package/src/tools/tts.ts +133 -0
@@ -6,6 +6,7 @@ import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
6
6
  import { type Component, Text } from "@oh-my-pi/pi-tui";
7
7
  import { $which, ptree, truncate } from "@oh-my-pi/pi-utils";
8
8
  import { parseHTML } from "linkedom";
9
+ import { LRUCache } from "lru-cache/raw";
9
10
  import type { Settings } from "../config/settings";
10
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
12
  import { type Theme, theme } from "../modes/theme/theme";
@@ -23,6 +24,7 @@ import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../we
23
24
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
24
25
  import { applyListLimit } from "./list-limit";
25
26
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
27
+ import { type LineRange, parseLineRanges } from "./path-utils";
26
28
  import { formatExpandHint, getDomain, replaceTabs } from "./render-utils";
27
29
  import { ToolAbortError, ToolError } from "./tool-errors";
28
30
  import { toolResult } from "./tool-result";
@@ -138,16 +140,31 @@ export function isReadableUrlPath(value: string): boolean {
138
140
  return /^https?:\/\//i.test(value) || /^www\./i.test(value);
139
141
  }
140
142
 
141
- // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:raw`.
142
- // If a URL would otherwise look like `host:port`, add a trailing slash before the selector
143
- // (e.g. `https://example.com/:80` to read line 80 of the document at `https://example.com/`).
144
- const URL_LINE_RANGE_RE = /^(\d+)(?:([-+])(\d+))?$/;
143
+ // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:5-10,20-30`, `:raw`,
144
+ // or `:raw:N-M` / `:N-M:raw` to combine raw mode with a range. If a URL would otherwise look
145
+ // like `host:port`, add a trailing slash before the selector (e.g. `https://example.com/:80`
146
+ // to read line 80 of the document at `https://example.com/`).
145
147
 
146
148
  export interface ParsedReadUrlTarget {
147
149
  path: string;
148
150
  raw: boolean;
149
151
  offset?: number;
150
152
  limit?: number;
153
+ /** Populated only when the selector carries 2+ ranges. Single-range stays on offset/limit. */
154
+ ranges?: readonly LineRange[];
155
+ }
156
+
157
+ /** Recognize a single selector token (`raw` or one/many line ranges). */
158
+ function isUrlSelectorToken(token: string): boolean {
159
+ if (token === "raw") return true;
160
+ try {
161
+ return parseLineRanges(token) !== null;
162
+ } catch {
163
+ // `parseLineRanges` throws `ToolError` for malformed ranges (e.g. `5+0`). Only treat the
164
+ // token as a selector when it parses cleanly so URL ports like `:80` keep flowing
165
+ // through to the URL path.
166
+ return false;
167
+ }
151
168
  }
152
169
 
153
170
  export function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null {
@@ -157,62 +174,71 @@ export function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null
157
174
  return null;
158
175
  }
159
176
 
160
- const selector = embedded?.sel;
161
- const raw = selector === "raw";
162
- const lineMatch = selector && selector !== "raw" ? URL_LINE_RANGE_RE.exec(selector) : null;
163
- if (lineMatch) {
164
- const startLine = Number.parseInt(lineMatch[1]!, 10);
165
- if (startLine < 1) {
166
- throw new ToolError("URL line selector 0 is invalid; lines are 1-indexed. Use :1.");
177
+ let raw = false;
178
+ let ranges: readonly LineRange[] | undefined;
179
+ for (const sel of embedded?.sels ?? []) {
180
+ if (sel === "raw") {
181
+ raw = true;
182
+ continue;
167
183
  }
168
- const sep = lineMatch[2];
169
- const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
170
- let endLine: number | undefined;
171
- if (sep === "+") {
172
- if (rhs === undefined || rhs < 1) {
173
- throw new ToolError(`Invalid range ${startLine}+${rhs ?? 0}: count must be >= 1.`);
174
- }
175
- endLine = startLine + rhs - 1;
176
- } else if (sep === "-") {
177
- if (rhs === undefined || rhs < startLine) {
178
- throw new ToolError(`Invalid range ${startLine}-${rhs ?? 0}: end must be >= start.`);
179
- }
180
- endLine = rhs;
184
+ if (ranges !== undefined) {
185
+ // Two range groups on the same URL (`…:5-10:20-30`) combine with commas instead.
186
+ throw new ToolError(
187
+ `URL selector has multiple range groups; combine them with commas (e.g. \`:5-10,20-30\`).`,
188
+ );
189
+ }
190
+ const parsed = parseLineRanges(sel);
191
+ if (parsed === null) {
192
+ // Shouldn't happen isUrlSelectorToken vetted it. Belt-and-suspenders.
193
+ throw new ToolError(`Invalid URL line selector: ${sel}`);
181
194
  }
195
+ ranges = parsed;
196
+ }
197
+
198
+ if (!ranges || ranges.length === 0) return { path: urlPath, raw };
199
+ if (ranges.length === 1) {
200
+ const r = ranges[0];
182
201
  return {
183
202
  path: urlPath,
184
- raw: false,
185
- offset: startLine,
186
- limit: endLine !== undefined ? endLine - startLine + 1 : undefined,
203
+ raw,
204
+ offset: r.startLine,
205
+ limit: r.endLine !== undefined ? r.endLine - r.startLine + 1 : undefined,
187
206
  };
188
207
  }
189
-
190
- return { path: urlPath, raw };
208
+ return { path: urlPath, raw, ranges };
191
209
  }
192
210
 
193
- function tryExtractEmbeddedUrlSelector(readPath: string): { path: string; sel?: string } | null {
194
- const lastColonIndex = readPath.lastIndexOf(":");
195
- if (lastColonIndex <= 0) {
196
- return null;
197
- }
211
+ /**
212
+ * Peel one or more selector tokens off the right of a URL string. Walks back through
213
+ * trailing `:tok` segments while each token (a) looks like a selector and (b) leaves
214
+ * behind a string that still parses as a URL. Returns selectors left-to-right so callers
215
+ * can apply them in source order.
216
+ */
217
+ function tryExtractEmbeddedUrlSelector(readPath: string): { path: string; sels: string[] } | null {
218
+ let basePath = readPath;
219
+ const sels: string[] = [];
220
+ while (true) {
221
+ const lastColonIndex = basePath.lastIndexOf(":");
222
+ if (lastColonIndex <= 0) break;
223
+
224
+ const candidate = basePath.slice(lastColonIndex + 1);
225
+ const remainder = basePath.slice(0, lastColonIndex);
226
+ if (!isReadableUrlPath(remainder)) break;
227
+ if (!isUrlSelectorToken(candidate)) break;
198
228
 
199
- const candidateSelector = readPath.slice(lastColonIndex + 1);
200
- const basePath = readPath.slice(0, lastColonIndex);
201
- if (!isReadableUrlPath(basePath)) {
202
- return null;
203
- }
229
+ try {
230
+ new URL(
231
+ remainder.startsWith("http://") || remainder.startsWith("https://") ? remainder : `https://${remainder}`,
232
+ );
233
+ } catch {
234
+ break;
235
+ }
204
236
 
205
- const isEmbeddedSelector = candidateSelector === "raw" || URL_LINE_RANGE_RE.test(candidateSelector);
206
- if (!isEmbeddedSelector) {
207
- return null;
208
- }
209
-
210
- try {
211
- new URL(basePath.startsWith("http://") || basePath.startsWith("https://") ? basePath : `https://${basePath}`);
212
- return { path: basePath, sel: candidateSelector };
213
- } catch {
214
- return null;
237
+ sels.unshift(candidate);
238
+ basePath = remainder;
215
239
  }
240
+ if (sels.length === 0) return null;
241
+ return { path: basePath, sels };
216
242
  }
217
243
 
218
244
  /**
@@ -536,9 +562,22 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
536
562
  }
537
563
 
538
564
  /**
539
- * Render HTML to markdown using Parallel, jina, trafilatura, lynx (in order of preference)
565
+ * Cap on any single remote reader-mode request (Parallel, Jina) so a stalled
566
+ * remote endpoint cannot consume the whole reader-mode budget and starve the
567
+ * local fallback renderers (trafilatura, lynx, native). See #1449.
568
+ */
569
+ const REMOTE_READER_MAX_MS = 10_000;
570
+
571
+ /**
572
+ * Render HTML to markdown using Parallel, jina, trafilatura, lynx, then the
573
+ * in-process native converter. The overall `timeout` budget bounds the call,
574
+ * but remote reader requests are additionally capped at `REMOTE_READER_MAX_MS`
575
+ * so that a hung remote endpoint cannot prevent local fallbacks from running.
576
+ * Only a real `userSignal` cancellation aborts the chain — remote per-attempt
577
+ * timeouts and the overall reader-mode timeout still allow later renderers
578
+ * (especially the purely-local native converter) to be tried.
540
579
  */
541
- async function renderHtmlToText(
580
+ export async function renderHtmlToText(
542
581
  url: string,
543
582
  html: string,
544
583
  timeout: number,
@@ -546,14 +585,15 @@ async function renderHtmlToText(
546
585
  userSignal: AbortSignal | undefined,
547
586
  storage: AgentStorage | null,
548
587
  ): Promise<{ content: string; ok: boolean; method: string }> {
549
- const signal = ptree.combineSignals(userSignal, timeout * 1000);
588
+ const overallSignal = ptree.combineSignals(userSignal, timeout * 1000);
550
589
  const execOptions = {
551
590
  mode: "group" as const,
552
591
  allowNonZero: true,
553
592
  allowAbort: true,
554
593
  stderr: "full" as const,
555
- signal,
594
+ signal: overallSignal,
556
595
  };
596
+ const remoteBudgetMs = Math.min(timeout * 1000, REMOTE_READER_MAX_MS);
557
597
 
558
598
  // Try Parallel extract first when credentials are configured
559
599
  if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
@@ -564,7 +604,7 @@ async function renderHtmlToText(
564
604
  objective: "Extract the main content",
565
605
  excerpts: true,
566
606
  fullContent: false,
567
- signal,
607
+ signal: ptree.combineSignals(userSignal, remoteBudgetMs),
568
608
  },
569
609
  storage,
570
610
  );
@@ -576,17 +616,18 @@ async function renderHtmlToText(
576
616
  }
577
617
  }
578
618
  } catch {
579
- // Parallel extract failed, continue to next method
580
- signal?.throwIfAborted();
619
+ // Parallel extract failed or stalled; honour real cancellation only.
620
+ userSignal?.throwIfAborted();
581
621
  }
582
622
  }
583
623
 
584
- // Try jina first (reader API)
624
+ // Try jina reader API with its own sub-budget so a stall cannot starve
625
+ // later fallbacks (#1449).
585
626
  try {
586
627
  const jinaUrl = `https://r.jina.ai/${url}`;
587
628
  const response = await fetch(jinaUrl, {
588
629
  headers: { Accept: "text/markdown" },
589
- signal,
630
+ signal: ptree.combineSignals(userSignal, remoteBudgetMs),
590
631
  });
591
632
  if (response.ok) {
592
633
  const content = await response.text();
@@ -595,37 +636,50 @@ async function renderHtmlToText(
595
636
  }
596
637
  }
597
638
  } catch {
598
- // Jina failed, continue to next method
599
- signal?.throwIfAborted();
639
+ // Jina failed or stalled; honour real cancellation only.
640
+ userSignal?.throwIfAborted();
600
641
  }
601
642
 
602
643
  // Try trafilatura (auto-install via uv/pip)
603
- const trafilatura = await ensureTool("trafilatura", { signal, silent: true });
604
- if (trafilatura) {
605
- const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
606
- if (result.ok && result.stdout.trim().length > 100) {
607
- return { content: result.stdout, ok: true, method: "trafilatura" };
644
+ try {
645
+ const trafilatura = await ensureTool("trafilatura", { signal: overallSignal, silent: true });
646
+ if (trafilatura) {
647
+ const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
648
+ if (result.ok && result.stdout.trim().length > 100) {
649
+ return { content: result.stdout, ok: true, method: "trafilatura" };
650
+ }
608
651
  }
652
+ } catch {
653
+ // trafilatura unavailable or stalled; continue to next method.
654
+ userSignal?.throwIfAborted();
609
655
  }
610
656
 
611
657
  // Try lynx (can't auto-install, system package)
612
- const lynx = hasCommand("lynx");
613
- if (lynx) {
614
- const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
615
- if (result.ok) {
616
- return { content: result.stdout, ok: true, method: "lynx" };
658
+ try {
659
+ const lynx = hasCommand("lynx");
660
+ if (lynx) {
661
+ const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
662
+ if (result.ok) {
663
+ return { content: result.stdout, ok: true, method: "lynx" };
664
+ }
617
665
  }
666
+ } catch {
667
+ // lynx failed or stalled; continue to native converter.
668
+ userSignal?.throwIfAborted();
618
669
  }
619
670
 
620
- // Fall back to native converter (fastest, no network/subprocess)
671
+ // Fall back to native converter (purely local, no network/subprocess).
672
+ // Always attempted: even if remote renderers and subprocesses were aborted
673
+ // by the overall reader-mode timeout, this still works on already-loaded
674
+ // HTML (#1449).
621
675
  try {
622
676
  const content = await htmlToMarkdown(html, { cleanContent: true });
623
677
  if (content.trim().length > 100 && !isLowQualityOutput(content)) {
624
678
  return { content, ok: true, method: "native" };
625
679
  }
626
680
  } catch {
627
- // Native converter failed, continue to next method
628
- signal?.throwIfAborted();
681
+ // Native converter failed; nothing else to try.
682
+ userSignal?.throwIfAborted();
629
683
  }
630
684
  return { content: "", ok: false, method: "none" };
631
685
  }
@@ -931,6 +985,22 @@ async function renderUrl(
931
985
  const isText = mime.includes("text/plain") || mime.includes("text/markdown");
932
986
  const isFeed = mime.includes("rss") || mime.includes("atom") || mime.includes("feed");
933
987
 
988
+ // Raw mode skips every text-shaping branch below (JSON pretty-print, feed-to-markdown,
989
+ // HTML extraction) and returns the response body verbatim. The image/markit branches
990
+ // above already ran because raw isn't useful for binary payloads.
991
+ if (raw) {
992
+ const output = finalizeOutput(rawContent);
993
+ return {
994
+ url,
995
+ finalUrl,
996
+ contentType: mime,
997
+ method: "raw",
998
+ content: output.content,
999
+ fetchedAt,
1000
+ truncated: output.truncated,
1001
+ notes,
1002
+ };
1003
+ }
934
1004
  if (isJson) {
935
1005
  const output = finalizeOutput(formatJson(rawContent));
936
1006
  return {
@@ -1174,7 +1244,8 @@ interface ReadUrlCacheEntry {
1174
1244
  output: string;
1175
1245
  }
1176
1246
 
1177
- const readUrlCache = new Map<string, ReadUrlCacheEntry>();
1247
+ const READ_URL_CACHE_MAX_ENTRIES = 100;
1248
+ const readUrlCache = new LRUCache<string, ReadUrlCacheEntry>({ max: READ_URL_CACHE_MAX_ENTRIES });
1178
1249
 
1179
1250
  function getReadUrlCacheKey(session: ToolSession, requestedUrl: string, raw: boolean): string {
1180
1251
  const scope = session.getSessionFile() ?? session.cwd;
package/src/tools/find.ts CHANGED
@@ -39,14 +39,15 @@ const findSchema = z
39
39
  paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
40
40
  hidden: z.boolean().default(true).describe("include hidden files").optional(),
41
41
  gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
42
- limit: z.number().default(1000).describe("max results").optional(),
42
+ limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(),
43
43
  timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
44
44
  })
45
45
  .strict();
46
46
 
47
47
  export type FindToolInput = z.infer<typeof findSchema>;
48
48
 
49
- const DEFAULT_LIMIT = 1000;
49
+ const DEFAULT_LIMIT = 200;
50
+ const MAX_LIMIT = 200;
50
51
  const DEFAULT_GLOB_TIMEOUT_MS = 5000;
51
52
  const MIN_GLOB_TIMEOUT_MS = 500;
52
53
  const MAX_GLOB_TIMEOUT_MS = 60_000;
@@ -78,6 +79,37 @@ export function validateFindPathInputs(paths: readonly string[]): void {
78
79
  }
79
80
  }
80
81
 
82
+ /**
83
+ * Group find matches by their directory so the model doesn't pay repeated
84
+ * tokens for shared path prefixes. Preserves the input order: groups appear in
85
+ * the order their first member was emitted (mtime-desc for native glob), and
86
+ * within a group entries keep their relative order.
87
+ */
88
+ export function formatFindGroupedOutput(paths: readonly string[]): string {
89
+ if (paths.length === 0) return "";
90
+ const groups = new Map<string, string[]>();
91
+ for (const entry of paths) {
92
+ const hasTrailingSlash = entry.endsWith("/");
93
+ const trimmed = hasTrailingSlash ? entry.slice(0, -1) : entry;
94
+ const slash = trimmed.lastIndexOf("/");
95
+ const dir = slash === -1 ? "" : trimmed.slice(0, slash);
96
+ const base = slash === -1 ? trimmed : trimmed.slice(slash + 1);
97
+ const label = hasTrailingSlash ? `${base}/` : base;
98
+ const list = groups.get(dir);
99
+ if (list) list.push(label);
100
+ else groups.set(dir, [label]);
101
+ }
102
+ const sections: string[] = [];
103
+ for (const [dir, entries] of groups) {
104
+ if (dir === "") {
105
+ sections.push(entries.join("\n"));
106
+ } else {
107
+ sections.push(`# ${dir}/\n${entries.join("\n")}`);
108
+ }
109
+ }
110
+ return sections.join("\n\n");
111
+ }
112
+
81
113
  export interface FindToolDetails {
82
114
  truncation?: TruncationResult;
83
115
  resultLimitReached?: number;
@@ -195,11 +227,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
195
227
  if (searchPath === "/") {
196
228
  throw new ToolError("Searching from root directory '/' is not allowed");
197
229
  }
198
- const rawLimit = limit ?? DEFAULT_LIMIT;
199
- const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
200
- if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
230
+ const requestedLimit = limit ?? DEFAULT_LIMIT;
231
+ if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
201
232
  throw new ToolError("Limit must be a positive number");
202
233
  }
234
+ const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
203
235
  const includeHidden = hidden ?? true;
204
236
  const useGitignore = gitignore ?? true;
205
237
  const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
@@ -241,7 +273,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
241
273
  const listLimit = applyListLimit(files, { limit: effectiveLimit });
242
274
  const limited = listLimit.items;
243
275
  const limitMeta = listLimit.meta;
244
- const baseOutput = limited.join("\n");
276
+ const baseOutput = formatFindGroupedOutput(limited);
245
277
  const trailingNotes: string[] = [];
246
278
  if (notice) trailingNotes.push(notice);
247
279
  if (missingPathsNote) trailingNotes.push(missingPathsNote);