@oh-my-pi/pi-coding-agent 10.5.0 → 10.6.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [10.6.0] - 2026-02-04
6
+ ### Breaking Changes
7
+
8
+ - Removed `output_mode` parameter from grep tool—results now always use content mode with formatted match output
9
+ - Renamed grep context parameters from `context_pre`/`context_post` to `pre`/`post`
10
+ - Removed `n` (show line numbers) parameter—line numbers are now always displayed in grep results
11
+
12
+ ### Added
13
+
14
+ - Added Jina as a web search provider option alongside Exa, Perplexity, and Anthropic
15
+ - Added support for Jina Reader API integration with automatic provider detection when JINA_API_KEY is configured
16
+
17
+ ### Changed
18
+
19
+ - Reformatted grep output to display matches grouped by file with numbered match headers and aligned context lines
20
+ - Updated grep output to use `>>` prefix for match lines and aligned spacing for context lines for improved readability
21
+ - Changed multiline matching to automatically enable when pattern contains literal newlines (`
22
+ `)
23
+ - Split grep context parameter into separate `context_pre` and `context_post` options for independent control of lines before and after matches
24
+ - Updated grep tool to use configurable default context settings from `grep.contextBefore` and `grep.contextAfter` configuration
25
+ - Added configurable grep context defaults and reduced the default to 1 line before, 3 lines after
26
+ - Enabled the browser tool by default
27
+
28
+ ### Removed
29
+
30
+ - Removed `filesWithMatches` and `count` output modes from grep tool
31
+
5
32
  ## [10.5.0] - 2026-02-04
6
33
 
7
34
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "10.5.0",
3
+ "version": "10.6.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -80,12 +80,12 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@mozilla/readability": "0.6.0",
83
- "@oh-my-pi/omp-stats": "10.5.0",
84
- "@oh-my-pi/pi-agent-core": "10.5.0",
85
- "@oh-my-pi/pi-ai": "10.5.0",
86
- "@oh-my-pi/pi-natives": "10.5.0",
87
- "@oh-my-pi/pi-tui": "10.5.0",
88
- "@oh-my-pi/pi-utils": "10.5.0",
83
+ "@oh-my-pi/omp-stats": "10.6.0",
84
+ "@oh-my-pi/pi-agent-core": "10.6.0",
85
+ "@oh-my-pi/pi-ai": "10.6.0",
86
+ "@oh-my-pi/pi-natives": "10.6.0",
87
+ "@oh-my-pi/pi-tui": "10.6.0",
88
+ "@oh-my-pi/pi-utils": "10.6.0",
89
89
  "@openai/agents": "^0.4.5",
90
90
  "@sinclair/typebox": "^0.34.48",
91
91
  "ajv": "^8.17.1",
@@ -329,6 +329,26 @@ export const SETTINGS_SCHEMA = {
329
329
  default: true,
330
330
  ui: { tab: "tools", label: "Enable Grep", description: "Enable the grep tool for content searching" },
331
331
  },
332
+ "grep.contextBefore": {
333
+ type: "number",
334
+ default: 1,
335
+ ui: {
336
+ tab: "tools",
337
+ label: "Grep context before",
338
+ description: "Lines of context before each grep match",
339
+ submenu: true,
340
+ },
341
+ },
342
+ "grep.contextAfter": {
343
+ type: "number",
344
+ default: 3,
345
+ ui: {
346
+ tab: "tools",
347
+ label: "Grep context after",
348
+ description: "Lines of context after each grep match",
349
+ submenu: true,
350
+ },
351
+ },
332
352
  "notebook.enabled": {
333
353
  type: "boolean",
334
354
  default: true,
@@ -360,7 +380,7 @@ export const SETTINGS_SCHEMA = {
360
380
  },
361
381
  "browser.enabled": {
362
382
  type: "boolean",
363
- default: false,
383
+ default: true,
364
384
  ui: {
365
385
  tab: "tools",
366
386
  label: "Enable Browser",
@@ -485,7 +505,7 @@ export const SETTINGS_SCHEMA = {
485
505
  // ─────────────────────────────────────────────────────────────────────────
486
506
  "providers.webSearch": {
487
507
  type: "enum",
488
- values: ["auto", "exa", "perplexity", "anthropic"] as const,
508
+ values: ["auto", "exa", "jina", "perplexity", "anthropic"] as const,
489
509
  default: "auto",
490
510
  ui: { tab: "services", label: "Web search provider", description: "Provider for web search tool", submenu: true },
491
511
  },
package/src/cursor.ts CHANGED
@@ -167,7 +167,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
167
167
  pattern: args.pattern,
168
168
  path: args.path || undefined,
169
169
  glob: args.glob || undefined,
170
- output_mode: args.outputMode || undefined,
170
+ mode: args.outputMode || undefined,
171
171
  context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined,
172
172
  ignore_case: args.caseInsensitive || undefined,
173
173
  type: args.type || undefined,
@@ -6,17 +6,13 @@ Powerful search tool built on ripgrep.
6
6
  - Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`)
7
7
  - Filter files with `glob` (e.g., `*.js`, `**/*.tsx`) or `type` (e.g., `js`, `py`, `rust`)
8
8
  - Pattern syntax uses ripgrep—literal braces need escaping (`interface\\{\\}` to find `interface{}` in Go)
9
- - For cross-line patterns like `struct \\{[\\s\\S]*?field`, use `multiline: true`
9
+ - For cross-line patterns like `struct \\{[\\s\\S]*?field`, set `multiline: true` if needed
10
+ - If the pattern contains a literal `\n`, multiline defaults to true
10
11
  </instruction>
11
12
 
12
13
  <output>
13
- Results depend on `output_mode`:
14
- - `content`: Matching lines with file paths and line numbers
15
- - `files_with_matches`: File paths only (one per line)
16
- - `count`: Match counts per file
17
-
18
- In `content` mode, truncated at 100 matches default (configurable via `limit`).
19
- For `files_with_matches` and `count` modes, use `limit` truncate results.
14
+ Results are always content mode: matching lines with file paths and line numbers.
15
+ Truncated at 100 matches by default (configurable via `limit`).
20
16
  </output>
21
17
 
22
18
  <critical>
package/src/tools/grep.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as nodePath from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
- import { StringEnum } from "@oh-my-pi/pi-ai";
4
- import { type GrepMatch as WasmGrepMatch, grep as wasmGrep } from "@oh-my-pi/pi-natives";
3
+
4
+ import { grep as wasmGrep } 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 { untilAborted } from "@oh-my-pi/pi-utils";
@@ -10,7 +10,7 @@ import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
13
- import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
13
+ import { renderStatusLine, renderTreeList } from "../tui";
14
14
  import type { ToolSession } from ".";
15
15
  import type { OutputMeta } from "./output-meta";
16
16
  import { resolveToCwd } from "./path-utils";
@@ -24,16 +24,11 @@ const grepSchema = Type.Object({
24
24
  path: Type.Optional(Type.String({ description: "File or directory to search (default: cwd)" })),
25
25
  glob: Type.Optional(Type.String({ description: "Filter files by glob pattern (e.g., '*.js')" })),
26
26
  type: Type.Optional(Type.String({ description: "Filter by file type (e.g., js, py, rust)" })),
27
- output_mode: Type.Optional(
28
- StringEnum(["filesWithMatches", "content", "count"], {
29
- description: "Output format (default: files_with_matches)",
30
- }),
31
- ),
32
27
  i: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
33
- n: Type.Optional(Type.Boolean({ description: "Show line numbers (default: true)" })),
34
- context: Type.Optional(Type.Number({ description: "Lines of context (default: 5)" })),
35
- multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching (default: false)" })),
36
- limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 100 in content mode)" })),
28
+ pre: Type.Optional(Type.Number({ description: "Lines of context before matches" })),
29
+ post: Type.Optional(Type.Number({ description: "Lines of context after matches" })),
30
+ multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching" })),
31
+ limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 100)" })),
37
32
  offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
38
33
  });
39
34
 
@@ -50,7 +45,6 @@ export interface GrepToolDetails {
50
45
  fileCount?: number;
51
46
  files?: string[];
52
47
  fileMatches?: Array<{ path: string; count: number }>;
53
- mode?: "content" | "filesWithMatches" | "count";
54
48
  truncated?: boolean;
55
49
  error?: string;
56
50
  }
@@ -86,7 +80,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
86
80
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
87
81
  _toolContext?: AgentToolContext,
88
82
  ): Promise<AgentToolResult<GrepToolDetails>> {
89
- const { pattern, path: searchDir, glob, type, output_mode, i, n, context, multiline, limit, offset } = params;
83
+ const { pattern, path: searchDir, glob, type, i, pre, post, multiline, limit, offset } = params;
90
84
 
91
85
  return untilAborted(signal, async () => {
92
86
  const normalizedPattern = pattern.trim();
@@ -105,10 +99,13 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
105
99
  }
106
100
  const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined;
107
101
 
108
- const normalizedContext = context ?? 5;
109
- const showLineNumbers = n ?? true;
102
+ const defaultContextBefore = this.session.settings.get("grep.contextBefore");
103
+ const defaultContextAfter = this.session.settings.get("grep.contextAfter");
104
+ const normalizedContextBefore = pre ?? defaultContextBefore;
105
+ const normalizedContextAfter = post ?? defaultContextAfter;
110
106
  const ignoreCase = i ?? false;
111
- const hasContentHints = limit !== undefined || context !== undefined;
107
+ const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
108
+ const effectiveMultiline = multiline ?? patternHasNewline;
112
109
 
113
110
  const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
114
111
  const scopePath = (() => {
@@ -124,9 +121,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
124
121
  throw new ToolError(`Path not found: ${searchPath}`);
125
122
  }
126
123
 
127
- const effectiveOutputMode = output_mode ?? (!isDirectory || hasContentHints ? "content" : "filesWithMatches");
128
- const effectiveLimit =
129
- effectiveOutputMode === "content" ? (normalizedLimit ?? DEFAULT_MATCH_LIMIT) : normalizedLimit;
124
+ const effectiveOutputMode = "content";
125
+ const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT;
130
126
 
131
127
  // Run WASM grep
132
128
  let result: Awaited<ReturnType<typeof wasmGrep>>;
@@ -137,11 +133,12 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
137
133
  glob: glob?.trim() || undefined,
138
134
  type: type?.trim() || undefined,
139
135
  ignoreCase,
140
- multiline: multiline ?? false,
136
+ multiline: effectiveMultiline,
141
137
  hidden: true,
142
138
  maxCount: effectiveLimit,
143
139
  offset: normalizedOffset > 0 ? normalizedOffset : undefined,
144
- context: effectiveOutputMode === "content" ? normalizedContext : undefined,
140
+ contextBefore: normalizedContextBefore,
141
+ contextAfter: normalizedContextAfter,
145
142
  maxColumns: DEFAULT_MAX_COLUMN,
146
143
  mode: effectiveOutputMode,
147
144
  });
@@ -180,71 +177,66 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
180
177
  matchCount: 0,
181
178
  fileCount: 0,
182
179
  files: [],
183
- mode: effectiveOutputMode,
184
180
  truncated: false,
185
181
  };
186
182
  return toolResult(details).text("No matches found").done();
187
183
  }
188
184
 
189
- let outputLines: string[] = [];
185
+ const outputLines: string[] = [];
190
186
  let linesTruncated = false;
187
+ let matchIndex = 0;
191
188
 
192
189
  for (const match of result.matches) {
193
190
  recordFile(match.path);
194
191
  const relativePath = formatPath(match.path);
195
192
 
196
- if (effectiveOutputMode === "content") {
197
- // Add context before
198
- if (match.contextBefore) {
199
- for (const ctx of match.contextBefore) {
200
- outputLines.push(
201
- showLineNumbers
202
- ? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
203
- : `${relativePath}- ${ctx.line}`,
204
- );
205
- }
193
+ matchIndex += 1;
194
+ if (matchIndex > 1) {
195
+ outputLines.push("");
196
+ }
197
+ outputLines.push(`${matchIndex}. ${relativePath}:${match.lineNumber}`);
198
+
199
+ const lineNumbers: number[] = [match.lineNumber];
200
+ if (match.contextBefore) {
201
+ for (const ctx of match.contextBefore) {
202
+ lineNumbers.push(ctx.lineNumber);
206
203
  }
204
+ }
205
+ if (match.contextAfter) {
206
+ for (const ctx of match.contextAfter) {
207
+ lineNumbers.push(ctx.lineNumber);
208
+ }
209
+ }
210
+ const lineWidth = Math.max(...lineNumbers.map(value => value.toString().length));
207
211
 
208
- // Add match line
209
- outputLines.push(
210
- showLineNumbers
211
- ? `${relativePath}:${match.lineNumber}: ${match.line}`
212
- : `${relativePath}: ${match.line}`,
213
- );
212
+ const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
213
+ const padded = lineNumber.toString().padStart(lineWidth, " ");
214
+ return isMatch ? `>>${padded} ${line}` : ` ${padded} ${line}`;
215
+ };
214
216
 
215
- if (match.truncated) {
216
- linesTruncated = true;
217
+ // Add context before
218
+ if (match.contextBefore) {
219
+ for (const ctx of match.contextBefore) {
220
+ outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
217
221
  }
222
+ }
218
223
 
219
- // Add context after
220
- if (match.contextAfter) {
221
- for (const ctx of match.contextAfter) {
222
- outputLines.push(
223
- showLineNumbers
224
- ? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
225
- : `${relativePath}- ${ctx.line}`,
226
- );
227
- }
228
- }
224
+ // Add match line
225
+ outputLines.push(formatLine(match.lineNumber, match.line, true));
229
226
 
230
- // Track per-file counts
231
- fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
232
- } else if (effectiveOutputMode === "filesWithMatches") {
233
- // One line per file
234
- const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
235
- fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 1);
236
- } else {
237
- // count mode
238
- const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
239
- fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 0);
227
+ if (match.truncated) {
228
+ linesTruncated = true;
240
229
  }
241
- }
242
230
 
243
- // Format output based on mode
244
- if (effectiveOutputMode === "filesWithMatches") {
245
- outputLines = fileList;
246
- } else if (effectiveOutputMode === "count") {
247
- outputLines = fileList.map(f => `${f}:${fileMatchCounts.get(f) ?? 0}`);
231
+ // Add context after
232
+ if (match.contextAfter) {
233
+ for (const ctx of match.contextAfter) {
234
+ outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
235
+ }
236
+ }
237
+
238
+ // Track per-file counts
239
+ fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
248
240
  }
249
241
 
250
242
  const rawOutput = outputLines.join("\n");
@@ -261,7 +253,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
261
253
  path,
262
254
  count: fileMatchCounts.get(path) ?? 0,
263
255
  })),
264
- mode: effectiveOutputMode,
265
256
  truncated,
266
257
  matchLimitReached: result.limitReached ? effectiveLimit : undefined,
267
258
  };
@@ -295,15 +286,13 @@ interface GrepRenderArgs {
295
286
  glob?: string;
296
287
  type?: string;
297
288
  i?: boolean;
298
- n?: boolean;
299
- context?: number;
289
+ pre?: number;
290
+ post?: number;
300
291
  multiline?: boolean;
301
- output_mode?: string;
302
292
  limit?: number;
303
293
  offset?: number;
304
294
  }
305
295
 
306
- const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
307
296
  const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
308
297
 
309
298
  export const grepToolRenderer = {
@@ -313,10 +302,13 @@ export const grepToolRenderer = {
313
302
  if (args.path) meta.push(`in ${args.path}`);
314
303
  if (args.glob) meta.push(`glob:${args.glob}`);
315
304
  if (args.type) meta.push(`type:${args.type}`);
316
- if (args.output_mode && args.output_mode !== "filesWithMatches") meta.push(`mode:${args.output_mode}`);
317
305
  if (args.i) meta.push("case:insensitive");
318
- if (args.n === false) meta.push("no-line-numbers");
319
- if (args.context !== undefined && args.context > 0) meta.push(`context:${args.context}`);
306
+ if (args.pre !== undefined && args.pre > 0) {
307
+ meta.push(`pre:${args.pre}`);
308
+ }
309
+ if (args.post !== undefined && args.post > 0) {
310
+ meta.push(`post:${args.post}`);
311
+ }
320
312
  if (args.multiline) meta.push("multiline");
321
313
  if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
322
314
  if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
@@ -369,13 +361,11 @@ export const grepToolRenderer = {
369
361
 
370
362
  const matchCount = details?.matchCount ?? 0;
371
363
  const fileCount = details?.fileCount ?? 0;
372
- const mode = details?.mode ?? "filesWithMatches";
373
364
  const truncation = details?.meta?.truncation;
374
365
  const limits = details?.meta?.limits;
375
366
  const truncated = Boolean(
376
367
  details?.truncated || truncation || limits?.matchLimit || limits?.resultLimit || limits?.columnTruncated,
377
368
  );
378
- const files = details?.files ?? [];
379
369
 
380
370
  if (matchCount === 0) {
381
371
  const header = renderStatusLine(
@@ -385,10 +375,7 @@ export const grepToolRenderer = {
385
375
  return new Text([header, formatEmptyMessage("No matches found", uiTheme)].join("\n"), 0, 0);
386
376
  }
387
377
 
388
- const summaryParts =
389
- mode === "filesWithMatches"
390
- ? [formatCount("file", fileCount)]
391
- : [formatCount("match", matchCount), formatCount("file", fileCount)];
378
+ const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
392
379
  const meta = [...summaryParts];
393
380
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
394
381
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
@@ -398,34 +385,51 @@ export const grepToolRenderer = {
398
385
  uiTheme,
399
386
  );
400
387
 
401
- if (mode === "content") {
402
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
403
- const contentLines = textContent.split("\n").filter(line => line.trim().length > 0);
404
- const matchLines = renderTreeList(
405
- {
406
- items: contentLines,
407
- expanded,
408
- maxCollapsed: COLLAPSED_TEXT_LIMIT,
409
- itemType: "match",
410
- renderItem: line => uiTheme.fg("toolOutput", line),
411
- },
412
- uiTheme,
413
- );
414
- return new Text([header, ...matchLines].join("\n"), 0, 0);
388
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
389
+ const rawLines = textContent.split("\n");
390
+ const hasSeparators = rawLines.some(line => line.trim().length === 0);
391
+ const matchGroups: string[][] = [];
392
+ if (hasSeparators) {
393
+ let current: string[] = [];
394
+ for (const line of rawLines) {
395
+ if (line.trim().length === 0) {
396
+ if (current.length > 0) {
397
+ matchGroups.push(current);
398
+ current = [];
399
+ }
400
+ continue;
401
+ }
402
+ current.push(line);
403
+ }
404
+ if (current.length > 0) matchGroups.push(current);
405
+ } else {
406
+ for (const line of rawLines) {
407
+ if (line.trim().length === 0) continue;
408
+ matchGroups.push([line]);
409
+ }
415
410
  }
416
411
 
417
- const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
418
- ? details.fileMatches.map(entry => ({ path: entry.path, count: entry.count }))
419
- : files.map(path => ({ path }));
420
- const fileLines = renderFileList(
412
+ const getCollapsedMatchLimit = (groups: string[][], maxLines: number): number => {
413
+ if (groups.length === 0) return 0;
414
+ let usedLines = 0;
415
+ let count = 0;
416
+ for (const group of groups) {
417
+ if (count > 0 && usedLines + group.length > maxLines) break;
418
+ usedLines += group.length;
419
+ count += 1;
420
+ if (usedLines >= maxLines) break;
421
+ }
422
+ return count;
423
+ };
424
+
425
+ const maxCollapsed = expanded ? matchGroups.length : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT);
426
+ const matchLines = renderTreeList(
421
427
  {
422
- files: fileEntries.map(entry => ({
423
- path: entry.path,
424
- isDirectory: entry.path.endsWith("/"),
425
- meta: entry.count !== undefined ? `(${entry.count} match${entry.count !== 1 ? "es" : ""})` : undefined,
426
- })),
428
+ items: matchGroups,
427
429
  expanded,
428
- maxCollapsed: COLLAPSED_LIST_LIMIT,
430
+ maxCollapsed,
431
+ itemType: "match",
432
+ renderItem: group => group.map(line => uiTheme.fg("toolOutput", line)),
429
433
  },
430
434
  uiTheme,
431
435
  );
@@ -440,7 +444,7 @@ export const grepToolRenderer = {
440
444
  const extraLines =
441
445
  truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : [];
442
446
 
443
- return new Text([header, ...fileLines, ...extraLines].join("\n"), 0, 0);
447
+ return new Text([header, ...matchLines, ...extraLines].join("\n"), 0, 0);
444
448
  },
445
449
  mergeCallAndResult: true,
446
450
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Unified Web Search Tool
3
3
  *
4
- * Single tool supporting Anthropic, Perplexity, and Exa providers with
4
+ * Single tool supporting Anthropic, Perplexity, Exa, and Jina providers with
5
5
  * provider-specific parameters exposed conditionally.
6
6
  *
7
7
  * When EXA_API_KEY is available, additional specialized tools are exposed:
@@ -27,6 +27,7 @@ import { formatAge } from "../../tools/render-utils";
27
27
  import { findAnthropicAuth } from "./auth";
28
28
  import { searchAnthropic } from "./providers/anthropic";
29
29
  import { searchExa } from "./providers/exa";
30
+ import { findApiKey as findJinaKey, searchJina } from "./providers/jina";
30
31
  import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
31
32
  import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
32
33
  import type { WebSearchProvider, WebSearchResponse } from "./types";
@@ -36,7 +37,7 @@ import { WebSearchProviderError } from "./types";
36
37
  export const webSearchSchema = Type.Object({
37
38
  query: Type.String({ description: "Search query" }),
38
39
  provider: Type.Optional(
39
- StringEnum(["auto", "exa", "anthropic", "perplexity"], {
40
+ StringEnum(["auto", "exa", "jina", "anthropic", "perplexity"], {
40
41
  description: "Search provider (default: auto)",
41
42
  }),
42
43
  ),
@@ -50,7 +51,7 @@ export const webSearchSchema = Type.Object({
50
51
 
51
52
  export type WebSearchParams = {
52
53
  query: string;
53
- provider?: "auto" | "exa" | "anthropic" | "perplexity";
54
+ provider?: "auto" | "exa" | "jina" | "anthropic" | "perplexity";
54
55
  recency?: "day" | "week" | "month" | "year";
55
56
  limit?: number;
56
57
  };
@@ -70,6 +71,9 @@ async function getAvailableProviders(): Promise<WebSearchProvider[]> {
70
71
  const exaKey = await findExaKey();
71
72
  if (exaKey) providers.push("exa");
72
73
 
74
+ const jinaKey = await findJinaKey();
75
+ if (jinaKey) providers.push("jina");
76
+
73
77
  const perplexityKey = await findPerplexityKey();
74
78
  if (perplexityKey) providers.push("perplexity");
75
79
 
@@ -83,6 +87,8 @@ function formatProviderLabel(provider: WebSearchProvider): string {
83
87
  switch (provider) {
84
88
  case "exa":
85
89
  return "Exa";
90
+ case "jina":
91
+ return "Jina";
86
92
  case "perplexity":
87
93
  return "Perplexity";
88
94
  case "anthropic":
@@ -237,6 +243,11 @@ async function executeWebSearch(
237
243
  query: params.query,
238
244
  num_results: params.limit,
239
245
  });
246
+ } else if (provider === "jina") {
247
+ response = await searchJina({
248
+ query: params.query,
249
+ num_results: params.limit,
250
+ });
240
251
  } else if (provider === "anthropic") {
241
252
  response = await searchAnthropic({
242
253
  query: params.query,
@@ -279,7 +290,7 @@ async function executeWebSearch(
279
290
  /**
280
291
  * Web search tool implementation.
281
292
  *
282
- * Supports Anthropic, Perplexity, and Exa providers with automatic fallback.
293
+ * Supports Anthropic, Perplexity, Exa, and Jina providers with automatic fallback.
283
294
  * Session is accepted for interface consistency but not used.
284
295
  */
285
296
  export class WebSearchTool implements AgentTool<typeof webSearchSchema, WebSearchRenderDetails> {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Jina Reader Web Search Provider
3
+ *
4
+ * Uses the Jina Reader `s.jina.ai` endpoint to fetch search results with
5
+ * cleaned content.
6
+ */
7
+
8
+ import { getEnvApiKey } from "@oh-my-pi/pi-ai";
9
+ import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types";
10
+ import { WebSearchProviderError } from "../../../web/search/types";
11
+
12
+ const JINA_SEARCH_URL = "https://s.jina.ai";
13
+
14
+ export interface JinaSearchParams {
15
+ query: string;
16
+ num_results?: number;
17
+ }
18
+
19
+ interface JinaSearchResult {
20
+ title?: string | null;
21
+ url?: string | null;
22
+ content?: string | null;
23
+ }
24
+
25
+ type JinaSearchResponse = JinaSearchResult[];
26
+
27
+ /** Find JINA_API_KEY from environment or .env files. */
28
+ export function findApiKey(): string | null {
29
+ return getEnvApiKey("jina") ?? null;
30
+ }
31
+
32
+ /** Call Jina Reader search API. */
33
+ async function callJinaSearch(apiKey: string, query: string): Promise<JinaSearchResponse> {
34
+ const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`;
35
+ const response = await fetch(requestUrl, {
36
+ headers: {
37
+ Accept: "application/json",
38
+ Authorization: `Bearer ${apiKey}`,
39
+ },
40
+ });
41
+
42
+ if (!response.ok) {
43
+ const errorText = await response.text();
44
+ throw new WebSearchProviderError("jina", `Jina API error (${response.status}): ${errorText}`, response.status);
45
+ }
46
+
47
+ const data = (await response.json()) as unknown;
48
+ return Array.isArray(data) ? (data as JinaSearchResponse) : [];
49
+ }
50
+
51
+ /** Execute Jina web search. */
52
+ export async function searchJina(params: JinaSearchParams): Promise<WebSearchResponse> {
53
+ const apiKey = findApiKey();
54
+ if (!apiKey) {
55
+ throw new Error("JINA_API_KEY not found. Set it in environment or .env file.");
56
+ }
57
+
58
+ const response = await callJinaSearch(apiKey, params.query);
59
+ const sources: WebSearchSource[] = [];
60
+
61
+ for (const result of response) {
62
+ if (!result?.url) continue;
63
+ sources.push({
64
+ title: result.title ?? result.url,
65
+ url: result.url,
66
+ snippet: result.content ?? undefined,
67
+ });
68
+ }
69
+
70
+ const limitedSources = params.num_results ? sources.slice(0, params.num_results) : sources;
71
+
72
+ return {
73
+ provider: "jina",
74
+ sources: limitedSources,
75
+ };
76
+ }
@@ -113,7 +113,9 @@ export function renderWebSearchResult(
113
113
  ? "Perplexity"
114
114
  : provider === "exa"
115
115
  ? "Exa"
116
- : "Unknown";
116
+ : provider === "jina"
117
+ ? "Jina"
118
+ : "Unknown";
117
119
  const queryPreview = args?.query
118
120
  ? truncateToWidth(args.query, 80)
119
121
  : searchQueries[0]
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Web Search Types
3
3
  *
4
- * Unified types for web search responses across Anthropic and Perplexity providers.
4
+ * Unified types for web search responses across supported providers.
5
5
  */
6
6
 
7
7
  /** Supported web search providers */
8
- export type WebSearchProvider = "exa" | "anthropic" | "perplexity" | "gemini" | "codex";
8
+ export type WebSearchProvider = "exa" | "jina" | "anthropic" | "perplexity" | "gemini" | "codex";
9
9
 
10
10
  /** Source returned by search (all providers) */
11
11
  export interface WebSearchSource {