@oh-my-pi/pi-coding-agent 13.10.1 → 13.11.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 (76) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +7 -7
  3. package/src/commit/agentic/agent.ts +3 -1
  4. package/src/commit/agentic/index.ts +7 -1
  5. package/src/commit/analysis/conventional.ts +5 -1
  6. package/src/commit/analysis/summary.ts +5 -1
  7. package/src/commit/changelog/generate.ts +5 -1
  8. package/src/commit/changelog/index.ts +4 -0
  9. package/src/commit/map-reduce/index.ts +5 -0
  10. package/src/commit/map-reduce/map-phase.ts +17 -2
  11. package/src/commit/map-reduce/reduce-phase.ts +5 -1
  12. package/src/commit/model-selection.ts +38 -26
  13. package/src/commit/pipeline.ts +22 -11
  14. package/src/config/model-registry.ts +98 -17
  15. package/src/config/settings-schema.ts +31 -12
  16. package/src/config.ts +10 -3
  17. package/src/discovery/helpers.ts +10 -3
  18. package/src/exa/index.ts +1 -11
  19. package/src/exa/search.ts +1 -122
  20. package/src/internal-urls/docs-index.generated.ts +2 -2
  21. package/src/lsp/config.ts +1 -0
  22. package/src/lsp/defaults.json +3 -3
  23. package/src/lsp/index.ts +4 -4
  24. package/src/lsp/utils.ts +81 -0
  25. package/src/modes/components/settings-defs.ts +5 -0
  26. package/src/modes/components/todo-reminder.ts +8 -1
  27. package/src/modes/controllers/command-controller.ts +77 -3
  28. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  29. package/src/modes/controllers/input-controller.ts +2 -3
  30. package/src/modes/controllers/selector-controller.ts +18 -17
  31. package/src/modes/interactive-mode.ts +11 -7
  32. package/src/modes/theme/theme.ts +30 -27
  33. package/src/modes/types.ts +2 -1
  34. package/src/patch/hashline.ts +123 -22
  35. package/src/prompts/system/eager-todo.md +13 -0
  36. package/src/prompts/tools/ast-edit.md +1 -1
  37. package/src/prompts/tools/ast-grep.md +1 -1
  38. package/src/prompts/tools/code-search.md +45 -0
  39. package/src/prompts/tools/find.md +1 -0
  40. package/src/prompts/tools/grep.md +1 -0
  41. package/src/prompts/tools/hashline.md +26 -111
  42. package/src/prompts/tools/read.md +2 -2
  43. package/src/prompts/tools/todo-write.md +11 -1
  44. package/src/sdk.ts +20 -16
  45. package/src/session/agent-session.ts +85 -7
  46. package/src/session/streaming-output.ts +17 -54
  47. package/src/slash-commands/builtin-registry.ts +10 -2
  48. package/src/task/executor.ts +10 -19
  49. package/src/task/index.ts +8 -4
  50. package/src/task/render.ts +5 -10
  51. package/src/task/template.ts +4 -1
  52. package/src/task/types.ts +2 -0
  53. package/src/tools/ast-edit.ts +26 -7
  54. package/src/tools/ast-grep.ts +26 -9
  55. package/src/tools/exit-plan-mode.ts +6 -0
  56. package/src/tools/fetch.ts +37 -6
  57. package/src/tools/find.ts +13 -64
  58. package/src/tools/grep.ts +27 -10
  59. package/src/tools/output-meta.ts +10 -7
  60. package/src/tools/path-utils.ts +348 -0
  61. package/src/tools/read.ts +13 -26
  62. package/src/tools/todo-write.ts +27 -4
  63. package/src/utils/commit-message-generator.ts +27 -22
  64. package/src/utils/image-input.ts +1 -1
  65. package/src/utils/image-resize.ts +4 -4
  66. package/src/utils/title-generator.ts +36 -23
  67. package/src/utils/tool-choice.ts +28 -0
  68. package/src/web/parallel.ts +346 -0
  69. package/src/web/scrapers/youtube.ts +29 -0
  70. package/src/web/search/code-search.ts +385 -0
  71. package/src/web/search/index.ts +25 -280
  72. package/src/web/search/provider.ts +4 -1
  73. package/src/web/search/providers/parallel.ts +63 -0
  74. package/src/web/search/types.ts +29 -0
  75. package/src/exa/company.ts +0 -26
  76. package/src/exa/linkedin.ts +0 -26
@@ -214,8 +214,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
214
214
  {
215
215
  name: "copy",
216
216
  description: "Copy last agent message to clipboard",
217
- handle: async (_command, runtime) => {
218
- await runtime.ctx.handleCopyCommand();
217
+ subcommands: [
218
+ { name: "last", description: "Copy full last agent message" },
219
+ { name: "code", description: "Copy last code block" },
220
+ { name: "all", description: "Copy all code blocks from last message" },
221
+ { name: "cmd", description: "Copy last bash/python command" },
222
+ ],
223
+ allowArgs: true,
224
+ handle: async (command, runtime) => {
225
+ const sub = command.args.trim().toLowerCase() || undefined;
226
+ await runtime.ctx.handleCopyCommand(sub);
219
227
  runtime.ctx.editor.setText("");
220
228
  },
221
229
  },
@@ -5,7 +5,6 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
- import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai";
9
8
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
10
9
  import type { TSchema } from "@sinclair/typebox";
11
10
  import Ajv, { type ValidateFunction } from "ajv";
@@ -28,6 +27,7 @@ import { type ContextFileEntry, truncateTail } from "../tools";
28
27
  import { jtdToJsonSchema } from "../tools/jtd-to-json-schema";
29
28
  import { ToolAbortError } from "../tools/tool-errors";
30
29
  import type { EventBus } from "../utils/event-bus";
30
+ import { buildNamedToolChoice } from "../utils/tool-choice";
31
31
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
32
32
  import {
33
33
  type AgentDefinition,
@@ -117,28 +117,13 @@ function getReportFindingKey(value: unknown): string | null {
117
117
  return `${filePath}:${lineStart}:${lineEnd}:${priority ?? ""}:${title}`;
118
118
  }
119
119
 
120
- function buildSubmitResultToolChoice(model?: Model<Api>): ToolChoice | undefined {
121
- if (!model) return undefined;
122
- if (
123
- model.api === "openai-codex-responses" ||
124
- model.api === "openai-responses" ||
125
- model.api === "openai-completions" ||
126
- model.api === "azure-openai-responses"
127
- ) {
128
- return { type: "function", name: "submit_result" };
129
- }
130
- if (model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") {
131
- return { type: "tool", name: "submit_result" };
132
- }
133
- return undefined;
134
- }
135
-
136
120
  /** Options for subagent execution */
137
121
  export interface ExecutorOptions {
138
122
  cwd: string;
139
123
  worktree?: string;
140
124
  agent: AgentDefinition;
141
125
  task: string;
126
+ assignment?: string;
142
127
  description?: string;
143
128
  index: number;
144
129
  id: string;
@@ -458,6 +443,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
458
443
  cwd,
459
444
  agent,
460
445
  task,
446
+ assignment,
461
447
  index,
462
448
  id,
463
449
  worktree,
@@ -478,6 +464,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
478
464
  agentSource: agent.source,
479
465
  status: "running",
480
466
  task,
467
+ assignment,
481
468
  description: options.description,
482
469
  lastIntent: undefined,
483
470
  recentTools: [],
@@ -496,6 +483,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
496
483
  agent: agent.name,
497
484
  agentSource: agent.source,
498
485
  task,
486
+ assignment,
499
487
  description: options.description,
500
488
  exitCode: 1,
501
489
  output: "",
@@ -638,6 +626,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
638
626
  agent: agent.name,
639
627
  agentSource: agent.source,
640
628
  task,
629
+ assignment,
641
630
  progress: { ...progress },
642
631
  });
643
632
  }
@@ -727,6 +716,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
727
716
  agent: agent.name,
728
717
  agentSource: agent.source,
729
718
  task,
719
+ assignment,
730
720
  event,
731
721
  });
732
722
  }
@@ -1091,7 +1081,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1091
1081
  await session.prompt(task);
1092
1082
  await session.waitForIdle();
1093
1083
 
1094
- const reminderToolChoice = buildSubmitResultToolChoice(session.model);
1084
+ const reminderToolChoice = buildNamedToolChoice("submit_result", session.model);
1095
1085
 
1096
1086
  let retryCount = 0;
1097
1087
  while (!submitResultCalled && retryCount < MAX_SUBMIT_RESULT_RETRIES && !abortSignal.aborted) {
@@ -1247,12 +1237,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1247
1237
  agent: agent.name,
1248
1238
  agentSource: agent.source,
1249
1239
  task,
1240
+ assignment,
1250
1241
  description: options.description,
1251
1242
  lastIntent: progress.lastIntent,
1252
1243
  exitCode,
1253
1244
  output: truncatedOutput,
1254
1245
  stderr,
1255
- truncated,
1246
+ truncated: Boolean(truncated),
1256
1247
  durationMs: Date.now() - startTime,
1257
1248
  tokens: progress.tokens,
1258
1249
  modelOverride,
package/src/task/index.ts CHANGED
@@ -224,6 +224,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
224
224
  agentSource: fallbackAgentSource,
225
225
  status: "pending",
226
226
  task: renderedTask.task,
227
+ assignment: renderedTask.assignment,
227
228
  description: renderedTask.description,
228
229
  recentTools: [],
229
230
  recentOutput: [],
@@ -732,6 +733,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
732
733
  agentSource: agent.source,
733
734
  status: "pending",
734
735
  task: t.task,
736
+ assignment: t.assignment,
735
737
  recentTools: [],
736
738
  recentOutput: [],
737
739
  toolCount: 0,
@@ -749,6 +751,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
749
751
  cwd: this.session.cwd,
750
752
  agent,
751
753
  task: task.task,
754
+ assignment: task.assignment,
752
755
  description: task.description,
753
756
  index,
754
757
  id: task.id,
@@ -801,6 +804,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
801
804
  worktree: isolationDir,
802
805
  agent,
803
806
  task: task.task,
807
+ assignment: task.assignment,
804
808
  description: task.description,
805
809
  index,
806
810
  id: task.id,
@@ -834,11 +838,10 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
834
838
  const commitMsg =
835
839
  commitStyle === "ai" && this.session.modelRegistry
836
840
  ? async (diff: string) => {
837
- const smolModel = this.session.settings.getModelRole("smol");
838
841
  return generateCommitMessage(
839
842
  diff,
840
843
  this.session.modelRegistry!,
841
- smolModel,
844
+ this.session.settings,
842
845
  this.session.getSessionId?.() ?? undefined,
843
846
  );
844
847
  }
@@ -887,6 +890,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
887
890
  agent: agent.name,
888
891
  agentSource: agent.source,
889
892
  task: task.task,
893
+ assignment: task.assignment,
890
894
  description: task.description,
891
895
  exitCode: 1,
892
896
  output: "",
@@ -930,6 +934,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
930
934
  agent: agentName,
931
935
  agentSource: agent.source,
932
936
  task: task.task,
937
+ assignment: task.assignment,
933
938
  description: task.description,
934
939
  exitCode: 1,
935
940
  output: "",
@@ -1081,11 +1086,10 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
1081
1086
  const commitMsg =
1082
1087
  commitStyle === "ai" && this.session.modelRegistry
1083
1088
  ? async (diff: string) => {
1084
- const smolModel = this.session.settings.getModelRole("smol");
1085
1089
  return generateCommitMessage(
1086
1090
  diff,
1087
1091
  this.session.modelRegistry!,
1088
- smolModel,
1092
+ this.session.settings,
1089
1093
  this.session.getSessionId?.() ?? undefined,
1090
1094
  );
1091
1095
  }
@@ -374,16 +374,11 @@ function renderTaskSection(
374
374
  maxExpanded = 20,
375
375
  ): string[] {
376
376
  const lines: string[] = [];
377
- const trimmed = task.trimEnd();
377
+ const trimmed = task.trim();
378
378
  if (!expanded || !trimmed) return lines;
379
379
 
380
- // Strip the shared <context>...</context> block — it's the same
381
- // across all tasks and just adds noise when expanded.
382
- const stripped = trimmed.replace(/<context>[\s\S]*?<\/context>\s*/, "").trimStart();
383
- if (!stripped) return lines;
384
-
385
380
  lines.push(`${continuePrefix}${theme.fg("dim", "Task")}`);
386
- const taskLines = stripped.split("\n");
381
+ const taskLines = trimmed.split("\n");
387
382
  for (const line of taskLines.slice(0, maxExpanded)) {
388
383
  lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
389
384
  }
@@ -526,7 +521,7 @@ function renderAgentProgress(
526
521
 
527
522
  if (progress.status === "running") {
528
523
  if (!description) {
529
- const taskPreview = truncateToWidth(progress.task, 40);
524
+ const taskPreview = truncateToWidth(progress.assignment ?? progress.task, 40);
530
525
  statusLine += ` ${theme.fg("muted", taskPreview)}`;
531
526
  }
532
527
  if (progress.toolCount > 0) {
@@ -546,7 +541,7 @@ function renderAgentProgress(
546
541
 
547
542
  lines.push(statusLine);
548
543
 
549
- lines.push(...renderTaskSection(progress.task, continuePrefix, expanded, theme));
544
+ lines.push(...renderTaskSection(progress.assignment ?? progress.task, continuePrefix, expanded, theme));
550
545
 
551
546
  // Current tool (if running) or most recent completed tool
552
547
  if (progress.status === "running") {
@@ -781,7 +776,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
781
776
 
782
777
  lines.push(statusLine);
783
778
 
784
- lines.push(...renderTaskSection(result.task, continuePrefix, expanded, theme));
779
+ lines.push(...renderTaskSection(result.assignment ?? result.task, continuePrefix, expanded, theme));
785
780
 
786
781
  if (aborted && result.abortReason) {
787
782
  lines.push(
@@ -5,6 +5,8 @@ import type { TaskItem } from "./types";
5
5
  interface RenderResult {
6
6
  /** Full task text sent to the subagent */
7
7
  task: string;
8
+ /** Raw per-task assignment text, without prompt template boilerplate */
9
+ assignment: string;
8
10
  id: string;
9
11
  description: string;
10
12
  }
@@ -20,10 +22,11 @@ export function renderTemplate(context: string | undefined, task: TaskItem): Ren
20
22
  context = context?.trim();
21
23
 
22
24
  if (!context || !assignment) {
23
- return { task: assignment || context!, id, description };
25
+ return { task: assignment || context!, assignment: assignment || context!, id, description };
24
26
  }
25
27
  return {
26
28
  task: renderPromptTemplate(subagentUserPromptTemplate, { context, assignment }),
29
+ assignment,
27
30
  id,
28
31
  description,
29
32
  };
package/src/task/types.ts CHANGED
@@ -136,6 +136,7 @@ export interface AgentProgress {
136
136
  agentSource: AgentSource;
137
137
  status: "pending" | "running" | "completed" | "failed" | "aborted";
138
138
  task: string;
139
+ assignment?: string;
139
140
  description?: string;
140
141
  lastIntent?: string;
141
142
  currentTool?: string;
@@ -158,6 +159,7 @@ export interface SingleResult {
158
159
  agent: string;
159
160
  agentSource: AgentSource;
160
161
  task: string;
162
+ assignment?: string;
161
163
  description?: string;
162
164
  lastIntent?: string;
163
165
  exitCode: number;
@@ -14,7 +14,13 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
16
  import type { OutputMeta } from "./output-meta";
17
- import { combineSearchGlobs, hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
17
+ import {
18
+ combineSearchGlobs,
19
+ hasGlobPathChars,
20
+ parseSearchPath,
21
+ resolveMultiSearchPath,
22
+ resolveToCwd,
23
+ } from "./path-utils";
18
24
  import {
19
25
  dedupeParseErrors,
20
26
  formatCount,
@@ -98,7 +104,12 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
98
104
  }
99
105
  const maxFiles = parseInt(process.env.PI_MAX_AST_FILES ?? "", 10) || 1000;
100
106
 
107
+ const formatScopePath = (targetPath: string): string => {
108
+ const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
109
+ return relative.length === 0 ? "." : relative;
110
+ };
101
111
  let searchPath: string | undefined;
112
+ let scopePath: string | undefined;
102
113
  let globFilter = params.glob?.trim() || undefined;
103
114
  const rawPath = params.path?.trim();
104
115
  if (rawPath) {
@@ -112,21 +123,29 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
112
123
  throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
113
124
  }
114
125
  searchPath = resource.sourcePath;
126
+ scopePath = formatScopePath(searchPath);
115
127
  } else {
116
- const parsedPath = parseSearchPath(rawPath);
117
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
118
- globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
128
+ const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
129
+ if (multiSearchPath) {
130
+ searchPath = multiSearchPath.basePath;
131
+ globFilter = multiSearchPath.glob;
132
+ scopePath = multiSearchPath.scopePath;
133
+ } else {
134
+ const parsedPath = parseSearchPath(rawPath);
135
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
136
+ globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
137
+ scopePath = formatScopePath(searchPath);
138
+ }
119
139
  }
120
140
  }
121
-
122
141
  const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
123
- const scopePath = path.relative(this.session.cwd, resolvedSearchPath).replace(/\\/g, "/") || ".";
142
+ scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
124
143
  let isDirectory: boolean;
125
144
  try {
126
145
  const stat = await Bun.file(resolvedSearchPath).stat();
127
146
  isDirectory = stat.isDirectory();
128
147
  } catch {
129
- throw new ToolError(`Path not found: ${resolvedSearchPath}`);
148
+ throw new ToolError(`Path not found: ${scopePath}`);
130
149
  }
131
150
 
132
151
  const result = await astEdit({
@@ -14,7 +14,13 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
16
  import type { OutputMeta } from "./output-meta";
17
- import { combineSearchGlobs, hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
17
+ import {
18
+ combineSearchGlobs,
19
+ hasGlobPathChars,
20
+ parseSearchPath,
21
+ resolveMultiSearchPath,
22
+ resolveToCwd,
23
+ } from "./path-utils";
18
24
  import {
19
25
  dedupeParseErrors,
20
26
  formatCount,
@@ -86,7 +92,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
86
92
  throw new ToolError("Context must be a non-negative number");
87
93
  }
88
94
 
95
+ const formatScopePath = (targetPath: string): string => {
96
+ const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
97
+ return relative.length === 0 ? "." : relative;
98
+ };
89
99
  let searchPath: string | undefined;
100
+ let scopePath: string | undefined;
90
101
  let globFilter = params.glob?.trim() || undefined;
91
102
  const rawPath = params.path?.trim();
92
103
  if (rawPath) {
@@ -100,24 +111,30 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
100
111
  throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
101
112
  }
102
113
  searchPath = resource.sourcePath;
114
+ scopePath = formatScopePath(searchPath);
103
115
  } else {
104
- const parsedPath = parseSearchPath(rawPath);
105
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
106
- globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
116
+ const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
117
+ if (multiSearchPath) {
118
+ searchPath = multiSearchPath.basePath;
119
+ globFilter = multiSearchPath.glob;
120
+ scopePath = multiSearchPath.scopePath;
121
+ } else {
122
+ const parsedPath = parseSearchPath(rawPath);
123
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
124
+ globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
125
+ scopePath = formatScopePath(searchPath);
126
+ }
107
127
  }
108
128
  }
109
129
 
110
130
  const resolvedSearchPath = searchPath ?? resolveToCwd(".", this.session.cwd);
111
- const scopePath = (() => {
112
- const relative = path.relative(this.session.cwd, resolvedSearchPath).replace(/\\/g, "/");
113
- return relative.length === 0 ? "." : relative;
114
- })();
131
+ scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
115
132
  let isDirectory: boolean;
116
133
  try {
117
134
  const stat = await Bun.file(resolvedSearchPath).stat();
118
135
  isDirectory = stat.isDirectory();
119
136
  } catch {
120
- throw new ToolError(`Path not found: ${resolvedSearchPath}`);
137
+ throw new ToolError(`Path not found: ${scopePath}`);
121
138
  }
122
139
 
123
140
  const result = await astGrep({
@@ -77,6 +77,12 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
77
77
  }
78
78
  }
79
79
 
80
+ if (!planExists) {
81
+ throw new ToolError(
82
+ `Plan file not found at ${state.planFilePath}. Write the finalized plan to ${state.planFilePath} before calling exit_plan_mode.`,
83
+ );
84
+ }
85
+
80
86
  return {
81
87
  content: [{ type: "text", text: "Plan ready for approval." }],
82
88
  details: {
@@ -7,6 +7,7 @@ import { ptree, truncate } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
8
  import { parseHTML } from "linkedom";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
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";
12
13
  import fetchDescription from "../prompts/tools/fetch.md" with { type: "text" };
@@ -15,6 +16,7 @@ import { renderStatusLine } from "../tui";
15
16
  import { CachedOutputBlock } from "../tui/output-block";
16
17
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
18
  import { ensureTool } from "../utils/tools-manager";
19
+ import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
18
20
  import { specialHandlers } from "../web/scrapers";
19
21
  import type { RenderResult } from "../web/scrapers/types";
20
22
  import { finalizeOutput, loadPage, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
@@ -465,12 +467,13 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
465
467
  }
466
468
 
467
469
  /**
468
- * Render HTML to markdown using jina, trafilatura, lynx (in order of preference)
470
+ * Render HTML to markdown using Parallel, jina, trafilatura, lynx (in order of preference)
469
471
  */
470
472
  async function renderHtmlToText(
471
473
  url: string,
472
474
  html: string,
473
475
  timeout: number,
476
+ settings: Settings,
474
477
  userSignal?: AbortSignal,
475
478
  ): Promise<{ content: string; ok: boolean; method: string }> {
476
479
  const signal = ptree.combineSignals(userSignal, timeout * 1000);
@@ -482,6 +485,28 @@ async function renderHtmlToText(
482
485
  signal,
483
486
  };
484
487
 
488
+ // Try Parallel extract first when credentials are configured
489
+ if (settings.get("providers.parallelFetch") && (await findParallelApiKey())) {
490
+ try {
491
+ const parallelResult = await extractWithParallel([url], {
492
+ objective: "Extract the main content",
493
+ excerpts: true,
494
+ fullContent: false,
495
+ signal,
496
+ });
497
+ const firstDocument = parallelResult.results[0];
498
+ if (firstDocument) {
499
+ const content = getParallelExtractContent(firstDocument);
500
+ if (content.trim().length > 100 && !isLowQualityOutput(content)) {
501
+ return { content, ok: true, method: "parallel" };
502
+ }
503
+ }
504
+ } catch {
505
+ // Parallel extract failed, continue to next method
506
+ signal?.throwIfAborted();
507
+ }
508
+ }
509
+
485
510
  // Try jina first (reader API)
486
511
  try {
487
512
  const jinaUrl = `https://r.jina.ai/${url}`;
@@ -608,7 +633,13 @@ async function handleSpecialUrls(
608
633
  /**
609
634
  * Main render function implementing the full pipeline
610
635
  */
611
- async function renderUrl(url: string, timeout: number, raw: boolean, signal?: AbortSignal): Promise<FetchRenderResult> {
636
+ async function renderUrl(
637
+ url: string,
638
+ timeout: number,
639
+ raw: boolean,
640
+ settings: Settings,
641
+ signal?: AbortSignal,
642
+ ): Promise<FetchRenderResult> {
612
643
  const notes: string[] = [];
613
644
  const fetchedAt = new Date().toISOString();
614
645
  if (signal?.aborted) {
@@ -714,7 +745,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean, signal?: Ab
714
745
  }
715
746
 
716
747
  const resized = await resizeImage(
717
- { type: "image", data: binary.buffer.toBase64(), mimeType: imageMimeType },
748
+ { type: "image", data: Buffer.from(binary.buffer).toBase64(), mimeType: imageMimeType },
718
749
  { maxBytes: MAX_INLINE_IMAGE_OUTPUT_BYTES },
719
750
  );
720
751
  const isDecodedImage =
@@ -952,7 +983,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean, signal?: Ab
952
983
  }
953
984
 
954
985
  // 5E: Render HTML with lynx or html2text
955
- const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, signal);
986
+ const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal);
956
987
  if (!htmlResult.ok) {
957
988
  notes.push("html rendering failed (lynx/html2text unavailable)");
958
989
  const output = finalizeOutput(rawContent);
@@ -1092,7 +1123,7 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
1092
1123
  throw new ToolAbortError();
1093
1124
  }
1094
1125
 
1095
- const result = await renderUrl(url, effectiveTimeout, raw, signal);
1126
+ const result = await renderUrl(url, effectiveTimeout, raw, this.session.settings, signal);
1096
1127
  const truncation = truncateHead(result.content, {
1097
1128
  maxBytes: DEFAULT_MAX_BYTES,
1098
1129
  maxLines: FETCH_DEFAULT_MAX_LINES,
@@ -1128,7 +1159,7 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
1128
1159
  finalUrl: result.finalUrl,
1129
1160
  contentType: result.contentType,
1130
1161
  method: result.method,
1131
- truncated: result.truncated || needsArtifact,
1162
+ truncated: Boolean(result.truncated || needsArtifact),
1132
1163
  notes: result.notes,
1133
1164
  };
1134
1165
 
package/src/tools/find.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  import type { ToolSession } from ".";
25
25
  import { applyListLimit } from "./list-limit";
26
26
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
27
- import { resolveToCwd } from "./path-utils";
27
+ import { parseFindPattern, resolveMultiFindPattern, resolveToCwd } from "./path-utils";
28
28
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
29
29
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
30
30
  import { toolResult } from "./tool-result";
@@ -40,56 +40,6 @@ export type FindToolInput = Static<typeof findSchema>;
40
40
  const DEFAULT_LIMIT = 1000;
41
41
  const GLOB_TIMEOUT_MS = 5000;
42
42
 
43
- /**
44
- * Parse a pattern to extract the base directory path and glob pattern.
45
- * Examples:
46
- * "src/app/**\/*.tsx" → { basePath: "src/app", globPattern: "**\/*.tsx" }
47
- * "src/app/*.tsx" → { basePath: "src/app", globPattern: "*.tsx" }
48
- * "*.ts" → { basePath: ".", globPattern: "**\/*.ts" }
49
- * "**\/*.json" → { basePath: ".", globPattern: "**\/*.json" }
50
- * "/abs/path/**\/*.ts" → { basePath: "/abs/path", globPattern: "**\/*.ts" }
51
- */
52
- function parsePatternPath(pattern: string): { basePath: string; globPattern: string } {
53
- // Find the first segment containing glob characters
54
- const segments = pattern.split("/");
55
- const globChars = ["*", "?", "[", "{"];
56
-
57
- let firstGlobIndex = -1;
58
- for (let i = 0; i < segments.length; i++) {
59
- if (globChars.some(c => segments[i].includes(c))) {
60
- firstGlobIndex = i;
61
- break;
62
- }
63
- }
64
-
65
- // No glob characters found - treat as literal path with implicit **/*
66
- if (firstGlobIndex === -1) {
67
- // Pattern is a directory path like "src/app" - search recursively in it
68
- return { basePath: pattern, globPattern: "**/*" };
69
- }
70
-
71
- // Glob starts at first segment - no base path
72
- if (firstGlobIndex === 0) {
73
- // Simple pattern like "*.ts" needs **/ prefix for recursive search
74
- const needsRecursive = !pattern.startsWith("**/");
75
- return {
76
- basePath: ".",
77
- globPattern: needsRecursive ? `**/${pattern}` : pattern,
78
- };
79
- }
80
-
81
- // Split at the glob boundary
82
- const basePath = segments.slice(0, firstGlobIndex).join("/");
83
- const globPattern = segments.slice(firstGlobIndex).join("/");
84
-
85
- return { basePath, globPattern };
86
- }
87
-
88
- function hasGlobChars(pattern: string): boolean {
89
- const globChars = ["*", "?", "[", "{"];
90
- return globChars.some(char => pattern.includes(char));
91
- }
92
-
93
43
  export interface FindToolDetails {
94
44
  truncation?: TruncationResult;
95
45
  resultLimitReached?: number;
@@ -149,27 +99,26 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
149
99
  const { pattern, limit, hidden } = params;
150
100
 
151
101
  return untilAborted(signal, async () => {
152
- // Parse pattern to extract base directory and glob pattern
153
- // e.g., "src/app/**/*.tsx" basePath: "src/app", globPattern: "**/*.tsx"
154
- // e.g., "*.ts" basePath: ".", globPattern: "**/*.ts"
102
+ const formatScopePath = (targetPath: string): string => {
103
+ const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
104
+ return relative.length === 0 ? "." : relative;
105
+ };
155
106
  const normalizedPattern = pattern.trim().replace(/\\/g, "/");
156
107
  if (!normalizedPattern) {
157
108
  throw new ToolError("Pattern must not be empty");
158
109
  }
159
110
 
160
- const hasGlob = hasGlobChars(normalizedPattern);
161
- const { basePath, globPattern } = parsePatternPath(normalizedPattern);
162
- const searchPath = resolveToCwd(basePath, this.session.cwd);
111
+ const multiPattern = await resolveMultiFindPattern(normalizedPattern, this.session.cwd);
112
+ const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPattern);
113
+ const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
114
+ const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
115
+ const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
116
+ const scopePath = multiPattern?.scopePath ?? formatScopePath(searchPath);
163
117
 
164
118
  if (searchPath === "/") {
165
119
  throw new ToolError("Searching from root directory '/' is not allowed");
166
120
  }
167
121
 
168
- const scopePath = (() => {
169
- const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
170
- return relative.length === 0 ? "." : relative;
171
- })();
172
-
173
122
  const rawLimit = limit ?? DEFAULT_LIMIT;
174
123
  const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
175
124
  if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
@@ -180,7 +129,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
180
129
  // If custom operations provided with glob, use that instead of fd
181
130
  if (this.#customOps?.glob) {
182
131
  if (!(await this.#customOps.exists(searchPath))) {
183
- throw new ToolError(`Path not found: ${searchPath}`);
132
+ throw new ToolError(`Path not found: ${scopePath}`);
184
133
  }
185
134
 
186
135
  if (!hasGlob && this.#customOps.stat) {
@@ -245,7 +194,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
245
194
  searchStat = await fs.promises.stat(searchPath);
246
195
  } catch (err) {
247
196
  if (isEnoent(err)) {
248
- throw new ToolError(`Path not found: ${searchPath}`);
197
+ throw new ToolError(`Path not found: ${scopePath}`);
249
198
  }
250
199
  throw err;
251
200
  }