@oh-my-pi/pi-coding-agent 15.3.2 → 15.4.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 (191) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/types/cli/file-processor.d.ts +1 -1
  3. package/dist/types/config/settings-schema.d.ts +45 -3
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/debug/raw-sse.d.ts +2 -0
  6. package/dist/types/edit/file-read-cache.d.ts +15 -4
  7. package/dist/types/edit/index.d.ts +3 -8
  8. package/dist/types/edit/renderer.d.ts +1 -2
  9. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  10. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  11. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  12. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  13. package/dist/types/eval/py/executor.d.ts +1 -2
  14. package/dist/types/eval/py/kernel.d.ts +6 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  16. package/dist/types/eval/session-id.d.ts +3 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  18. package/dist/types/hashline/anchors.d.ts +15 -9
  19. package/dist/types/hashline/constants.d.ts +0 -2
  20. package/dist/types/hashline/diff.d.ts +1 -2
  21. package/dist/types/hashline/executor.d.ts +52 -0
  22. package/dist/types/hashline/hash.d.ts +44 -93
  23. package/dist/types/hashline/index.d.ts +2 -1
  24. package/dist/types/hashline/input.d.ts +2 -9
  25. package/dist/types/hashline/recovery.d.ts +3 -9
  26. package/dist/types/hashline/tokenizer.d.ts +91 -0
  27. package/dist/types/hashline/types.d.ts +5 -7
  28. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  29. package/dist/types/modes/types.d.ts +1 -0
  30. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +2 -0
  32. package/dist/types/session/agent-session.d.ts +11 -15
  33. package/dist/types/session/agent-storage.d.ts +11 -10
  34. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  35. package/dist/types/slash-commands/types.d.ts +0 -5
  36. package/dist/types/task/executor.d.ts +2 -0
  37. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  38. package/dist/types/tools/index.d.ts +2 -8
  39. package/dist/types/tools/match-line-format.d.ts +4 -4
  40. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  41. package/dist/types/tools/review.d.ts +13 -0
  42. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  43. package/dist/types/tools/search.d.ts +4 -3
  44. package/dist/types/utils/edit-mode.d.ts +1 -1
  45. package/dist/types/web/kagi.d.ts +4 -2
  46. package/dist/types/web/parallel.d.ts +4 -3
  47. package/dist/types/web/scrapers/types.d.ts +2 -1
  48. package/dist/types/web/search/index.d.ts +12 -4
  49. package/dist/types/web/search/provider.d.ts +2 -1
  50. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  51. package/dist/types/web/search/providers/base.d.ts +34 -2
  52. package/dist/types/web/search/providers/brave.d.ts +8 -1
  53. package/dist/types/web/search/providers/codex.d.ts +13 -9
  54. package/dist/types/web/search/providers/exa.d.ts +10 -1
  55. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  56. package/dist/types/web/search/providers/jina.d.ts +2 -1
  57. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  58. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  59. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  60. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  61. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  62. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  63. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  64. package/dist/types/web/search/providers/utils.d.ts +8 -6
  65. package/dist/types/web/search/providers/zai.d.ts +12 -3
  66. package/package.json +7 -7
  67. package/src/cli/file-processor.ts +12 -2
  68. package/src/cli.ts +0 -8
  69. package/src/commands/commit.ts +8 -8
  70. package/src/config/prompt-templates.ts +6 -6
  71. package/src/config/settings-schema.ts +47 -3
  72. package/src/config/settings.ts +5 -5
  73. package/src/debug/raw-sse.ts +68 -3
  74. package/src/edit/file-read-cache.ts +68 -25
  75. package/src/edit/index.ts +6 -37
  76. package/src/edit/renderer.ts +9 -47
  77. package/src/edit/streaming.ts +43 -56
  78. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  79. package/src/eval/js/context-manager.ts +64 -53
  80. package/src/eval/js/shared/local-module-loader.ts +265 -0
  81. package/src/eval/js/shared/prelude.txt +4 -0
  82. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  83. package/src/eval/js/shared/runtime.ts +129 -86
  84. package/src/eval/js/worker-core.ts +23 -38
  85. package/src/eval/py/executor.ts +155 -84
  86. package/src/eval/py/kernel.ts +10 -1
  87. package/src/eval/py/prelude.py +22 -24
  88. package/src/eval/py/runner.py +203 -85
  89. package/src/eval/py/tool-bridge.ts +17 -10
  90. package/src/eval/session-id.ts +8 -0
  91. package/src/exec/bash-executor.ts +27 -16
  92. package/src/extensibility/extensions/runner.ts +0 -1
  93. package/src/extensibility/extensions/types.ts +1 -3
  94. package/src/hashline/anchors.ts +56 -65
  95. package/src/hashline/apply.ts +29 -31
  96. package/src/hashline/constants.ts +0 -3
  97. package/src/hashline/diff-preview.ts +4 -5
  98. package/src/hashline/diff.ts +30 -4
  99. package/src/hashline/execute.ts +91 -26
  100. package/src/hashline/executor.ts +239 -0
  101. package/src/hashline/grammar.lark +12 -10
  102. package/src/hashline/hash.ts +69 -114
  103. package/src/hashline/index.ts +2 -1
  104. package/src/hashline/input.ts +48 -41
  105. package/src/hashline/prefixes.ts +21 -11
  106. package/src/hashline/recovery.ts +63 -71
  107. package/src/hashline/stream.ts +2 -2
  108. package/src/hashline/tokenizer.ts +467 -0
  109. package/src/hashline/types.ts +6 -8
  110. package/src/internal-urls/docs-index.generated.ts +7 -7
  111. package/src/modes/components/extensions/types.ts +0 -5
  112. package/src/modes/components/session-observer-overlay.ts +11 -2
  113. package/src/modes/components/tree-selector.ts +10 -2
  114. package/src/modes/controllers/command-controller.ts +1 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  116. package/src/modes/controllers/selector-controller.ts +5 -5
  117. package/src/modes/types.ts +4 -1
  118. package/src/modes/utils/ui-helpers.ts +4 -0
  119. package/src/prompts/agents/explore.md +1 -1
  120. package/src/prompts/tools/ast-edit.md +1 -1
  121. package/src/prompts/tools/ast-grep.md +1 -1
  122. package/src/prompts/tools/eval.md +1 -1
  123. package/src/prompts/tools/hashline.md +73 -94
  124. package/src/prompts/tools/read.md +4 -4
  125. package/src/prompts/tools/search.md +3 -3
  126. package/src/sdk.ts +17 -23
  127. package/src/session/agent-session.ts +59 -66
  128. package/src/session/agent-storage.ts +13 -14
  129. package/src/slash-commands/acp-builtins.ts +3 -3
  130. package/src/slash-commands/types.ts +0 -6
  131. package/src/task/executor.ts +26 -57
  132. package/src/task/index.ts +8 -4
  133. package/src/tool-discovery/tool-index.ts +0 -134
  134. package/src/tools/ast-edit.ts +36 -13
  135. package/src/tools/ast-grep.ts +45 -4
  136. package/src/tools/browser/tab-worker.ts +3 -2
  137. package/src/tools/eval.ts +2 -1
  138. package/src/tools/fetch.ts +23 -14
  139. package/src/tools/index.ts +2 -8
  140. package/src/tools/irc.ts +59 -5
  141. package/src/tools/match-line-format.ts +5 -7
  142. package/src/tools/output-schema-validator.ts +132 -0
  143. package/src/tools/read.ts +142 -31
  144. package/src/tools/review.ts +23 -0
  145. package/src/tools/search-tool-bm25.ts +3 -30
  146. package/src/tools/search.ts +48 -16
  147. package/src/tools/write.ts +3 -3
  148. package/src/tools/yield.ts +32 -41
  149. package/src/utils/edit-mode.ts +1 -2
  150. package/src/utils/file-mentions.ts +2 -2
  151. package/src/web/kagi.ts +15 -6
  152. package/src/web/parallel.ts +9 -6
  153. package/src/web/scrapers/types.ts +7 -1
  154. package/src/web/scrapers/youtube.ts +13 -7
  155. package/src/web/search/index.ts +37 -11
  156. package/src/web/search/provider.ts +5 -3
  157. package/src/web/search/providers/anthropic.ts +30 -21
  158. package/src/web/search/providers/base.ts +35 -2
  159. package/src/web/search/providers/brave.ts +4 -4
  160. package/src/web/search/providers/codex.ts +118 -89
  161. package/src/web/search/providers/exa.ts +3 -2
  162. package/src/web/search/providers/gemini.ts +58 -155
  163. package/src/web/search/providers/jina.ts +4 -4
  164. package/src/web/search/providers/kagi.ts +17 -11
  165. package/src/web/search/providers/kimi.ts +29 -13
  166. package/src/web/search/providers/parallel.ts +171 -23
  167. package/src/web/search/providers/perplexity.ts +38 -37
  168. package/src/web/search/providers/searxng.ts +3 -1
  169. package/src/web/search/providers/synthetic.ts +16 -19
  170. package/src/web/search/providers/tavily.ts +23 -18
  171. package/src/web/search/providers/utils.ts +11 -17
  172. package/src/web/search/providers/zai.ts +16 -8
  173. package/dist/types/hashline/parser.d.ts +0 -7
  174. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  175. package/dist/types/tools/vim.d.ts +0 -58
  176. package/dist/types/vim/buffer.d.ts +0 -41
  177. package/dist/types/vim/commands.d.ts +0 -6
  178. package/dist/types/vim/engine.d.ts +0 -47
  179. package/dist/types/vim/parser.d.ts +0 -3
  180. package/dist/types/vim/render.d.ts +0 -25
  181. package/dist/types/vim/types.d.ts +0 -182
  182. package/src/hashline/parser.ts +0 -246
  183. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  184. package/src/prompts/tools/vim.md +0 -98
  185. package/src/tools/vim.ts +0 -949
  186. package/src/vim/buffer.ts +0 -309
  187. package/src/vim/commands.ts +0 -382
  188. package/src/vim/engine.ts +0 -2409
  189. package/src/vim/parser.ts +0 -134
  190. package/src/vim/render.ts +0 -252
  191. package/src/vim/types.ts +0 -197
@@ -9,6 +9,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
9
9
  import * as z from "zod/v4";
10
10
  import { getFileReadCache } from "../edit/file-read-cache";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
+ import { computeFileHash, formatHashlineHeader } from "../hashline/hash";
12
13
  import type { Theme } from "../modes/theme/theme";
13
14
  import searchDescription from "../prompts/tools/search.md" with { type: "text" };
14
15
  import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
@@ -38,13 +39,13 @@ import {
38
39
  import { ToolError } from "./tool-errors";
39
40
  import { toolResult } from "./tool-result";
40
41
 
42
+ const searchPathEntrySchema = z.string().describe("file, directory, glob, or internal URL to search");
41
43
  const searchSchema = z
42
44
  .object({
43
45
  pattern: z.string().describe("regex pattern"),
44
46
  paths: z
45
- .array(z.string().describe("file, directory, glob, or internal URL to search"))
46
- .min(1)
47
- .describe("files, directories, globs, or internal URLs to search"),
47
+ .union([searchPathEntrySchema, z.array(searchPathEntrySchema).min(1)])
48
+ .describe("file, directory, glob, internal URL, or array of those to search"),
48
49
  i: z.boolean().optional().describe("case-insensitive search"),
49
50
  gitignore: z.boolean().optional().describe("respect gitignore"),
50
51
  skip: z
@@ -55,6 +56,9 @@ const searchSchema = z
55
56
  .strict();
56
57
 
57
58
  export type SearchToolInput = z.infer<typeof searchSchema>;
59
+ export function toPathList(input: string | string[] | undefined): string[] {
60
+ return typeof input === "string" ? [input] : (input ?? []);
61
+ }
58
62
 
59
63
  /** Maximum number of distinct files surfaced in a single response. The
60
64
  * agent paginates further pages via `skip`. */
@@ -236,7 +240,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
236
240
  _onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
237
241
  _toolContext?: AgentToolContext,
238
242
  ): Promise<AgentToolResult<SearchToolDetails>> {
239
- const { pattern, paths, i, gitignore, skip } = params;
243
+ const { pattern, paths: rawPaths, i, gitignore, skip } = params;
240
244
 
241
245
  return untilAborted(signal, async () => {
242
246
  const normalizedPattern = pattern.trim();
@@ -248,6 +252,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
248
252
  if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
249
253
  throw new ToolError("Skip must be a non-negative number");
250
254
  }
255
+ const paths = toPathList(rawPaths);
251
256
  for (const entry of paths) {
252
257
  if (containsTopLevelComma(entry)) {
253
258
  throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
@@ -303,7 +308,6 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
303
308
  }
304
309
  const { globFilter } = scope;
305
310
  const baseDisplayMode = resolveFileDisplayMode(this.session);
306
- const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
307
311
 
308
312
  const effectiveOutputMode = GrepOutputMode.Content;
309
313
  // Multi-scope = more than one file may match. We fetch up to
@@ -485,14 +489,27 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
485
489
  matchesByFile.get(relativePath)!.push(match);
486
490
  }
487
491
  const displayLines: string[] = [];
492
+ const hashContexts = new Map<string, { absolutePath: string; fileHash: string }>();
493
+ if (baseDisplayMode.hashLines) {
494
+ for (const relativePath of fileList) {
495
+ if (archiveDisplaySet.has(relativePath)) continue;
496
+ const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
497
+ if (immutableSourcePaths.has(absoluteFilePath)) continue;
498
+ try {
499
+ const fullText = await Bun.file(absoluteFilePath).text();
500
+ const fileHash = computeFileHash(fullText);
501
+ hashContexts.set(relativePath, { absolutePath: absoluteFilePath, fileHash });
502
+ } catch {
503
+ // Best-effort: if the file disappeared between grep and render, fall back to plain line output.
504
+ }
505
+ }
506
+ }
488
507
  const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
489
508
  const modelOut: string[] = [];
490
509
  const displayOut: string[] = [];
491
510
  const fileMatches = matchesByFile.get(relativePath) ?? [];
492
- const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
493
- const useHashLines = immutableSourcePaths.has(absoluteFilePath)
494
- ? immutableDisplayMode.hashLines
495
- : baseDisplayMode.hashLines;
511
+ const hashContext = hashContexts.get(relativePath);
512
+ const useHashLines = hashContext !== undefined;
496
513
  const lineNumberWidth = fileMatches.reduce((width, match) => {
497
514
  let nextWidth = Math.max(width, String(match.lineNumber).length);
498
515
  for (const ctx of match.contextBefore ?? []) {
@@ -533,17 +550,21 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
533
550
  }
534
551
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
535
552
  }
536
- if (cacheEntries.length > 0 && !archiveDisplaySet.has(relativePath)) {
537
- getFileReadCache(this.session).recordSparse(path.resolve(searchPath, relativePath), cacheEntries);
553
+ if (cacheEntries.length > 0 && hashContext) {
554
+ getFileReadCache(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
555
+ fileHash: hashContext.fileHash,
556
+ });
538
557
  }
539
558
  return { model: modelOut, display: displayOut };
540
559
  };
541
560
  if (isDirectory) {
542
561
  const grouped = formatGroupedFiles(fileList, relativePath => {
543
562
  const rendered = renderMatchesForFile(relativePath);
563
+ const hashContext = hashContexts.get(relativePath);
544
564
  return {
545
565
  modelLines: rendered.model,
546
566
  displayLines: rendered.display,
567
+ headerSuffix: hashContext ? `#${hashContext.fileHash}` : "",
547
568
  skip: rendered.model.length === 0,
548
569
  };
549
570
  });
@@ -552,6 +573,15 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
552
573
  } else {
553
574
  for (const relativePath of fileList) {
554
575
  const rendered = renderMatchesForFile(relativePath);
576
+ if (rendered.model.length === 0) continue;
577
+ if (outputLines.length > 0) {
578
+ outputLines.push("");
579
+ displayLines.push("");
580
+ }
581
+ const hashContext = hashContexts.get(relativePath);
582
+ if (hashContext) {
583
+ outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash));
584
+ }
555
585
  outputLines.push(...rendered.model);
556
586
  displayLines.push(...rendered.display);
557
587
  }
@@ -607,7 +637,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
607
637
 
608
638
  interface SearchRenderArgs {
609
639
  pattern: string;
610
- paths?: string[];
640
+ paths?: string | string[];
611
641
  i?: boolean;
612
642
  gitignore?: boolean;
613
643
  skip?: number;
@@ -618,8 +648,9 @@ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
618
648
  export const searchToolRenderer = {
619
649
  inline: true,
620
650
  renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
651
+ const paths = toPathList(args.paths);
621
652
  const meta: string[] = [];
622
- if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
653
+ if (paths.length) meta.push(`in ${paths.join(", ")}`);
623
654
  if (args.i) meta.push("case:insensitive");
624
655
  if (args.gitignore === false) meta.push("gitignore:false");
625
656
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
@@ -745,11 +776,12 @@ export const searchToolRenderer = {
745
776
  let contextDir = searchBase ?? "";
746
777
  return group.map(line => {
747
778
  if (line.startsWith("## ")) {
748
- // Strip optional ` (suffix)` like ` (3 replacements)` before resolving.
779
+ // Strip optional ` (suffix)` and `#hash` before resolving.
749
780
  const fileName = line
750
781
  .slice(3)
751
782
  .trimEnd()
752
- .replace(/\s+\([^)]*\)\s*$/, "");
783
+ .replace(/\s+\([^)]*\)\s*$/, "")
784
+ .replace(/#[0-9a-f]+$/, "");
753
785
  const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
754
786
  const styled = uiTheme.fg("dim", line);
755
787
  return absPath ? fileHyperlink(absPath, styled) : styled;
@@ -760,7 +792,7 @@ export const searchToolRenderer = {
760
792
  .trimEnd()
761
793
  .replace(/\s+\([^)]*\)\s*$/, "");
762
794
  const isDirectory = raw.endsWith("/");
763
- const name = raw.replace(/\/$/, "");
795
+ const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
764
796
  if (isDirectory) {
765
797
  if (searchBase) {
766
798
  contextDir = name === "." ? searchBase : path.join(searchBase, name);
@@ -74,8 +74,8 @@ export interface WriteToolDetails {
74
74
  /**
75
75
  * Strip hashline display prefixes from write content.
76
76
  *
77
- * Only active when hashline edit mode is enabled — the model sees `LINE+ID|`
78
- * prefixes in read output and sometimes copies them into write content.
77
+ * Only active when hashline edit mode is enabled — the model sees `¶PATH#HASH`
78
+ * headers plus `LINE:` prefixes in read output and sometimes copies them into write content.
79
79
  */
80
80
  function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } {
81
81
  if (!resolveFileDisplayMode(session).hashLines) {
@@ -658,7 +658,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
658
658
  context?: AgentToolContext,
659
659
  ): Promise<AgentToolResult<WriteToolDetails>> {
660
660
  return untilAborted(signal, async () => {
661
- // Strip hashline display prefixes (LINE+ID|) if the model copied them from read output
661
+ // Strip hashline display prefixes (¶PATH#HASH + LINE:) if the model copied them from read output
662
662
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
663
663
  const internalRouter = InternalUrlRouter.instance();
664
664
  if (internalRouter.canHandle(path)) {
@@ -8,15 +8,13 @@ import type { TSchema } from "@oh-my-pi/pi-ai/types";
8
8
  import {
9
9
  dereferenceJsonSchema,
10
10
  isValidJsonSchema,
11
- type JsonSchemaValidationIssue,
12
11
  type JsonSchemaValidationResult,
13
12
  sanitizeSchemaForStrictMode,
14
13
  tryEnforceStrictSchema,
15
- validateJsonSchemaValue,
16
14
  } from "@oh-my-pi/pi-ai/utils/schema";
17
15
  import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
18
16
  import type { ToolSession } from ".";
19
- import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
17
+ import { buildOutputValidator, formatAllValidationIssues } from "./output-schema-validator";
20
18
 
21
19
  export interface YieldDetails {
22
20
  data: unknown;
@@ -34,16 +32,6 @@ function formatSchema(schema: unknown): string {
34
32
  }
35
33
  }
36
34
 
37
- function formatJsonSchemaIssues(issues: ReadonlyArray<JsonSchemaValidationIssue> | undefined): string {
38
- if (!issues || issues.length === 0) return "Unknown schema validation error.";
39
- return issues
40
- .map(issue => {
41
- const path = issue.path.length === 0 ? "" : `${issue.path.map(seg => String(seg)).join("/")}: `;
42
- return `${path}${issue.message}`;
43
- })
44
- .join("; ");
45
- }
46
-
47
35
  function looseRecordSchema(description: string): Record<string, unknown> {
48
36
  return {
49
37
  type: "object",
@@ -100,6 +88,15 @@ function wrapYieldParameters(dataSchema: Record<string, unknown>): Record<string
100
88
  };
101
89
  }
102
90
 
91
+ /**
92
+ * Max consecutive schema-validation failures before the yield tool overrides validation
93
+ * and lets non-conforming data through. The override is a safety net for schemas the
94
+ * JTD→JSON-Schema converter cannot fully express; it should not be reached during normal
95
+ * model retries. Three matches the existing "3 reminders" pattern elsewhere in the agent
96
+ * runtime.
97
+ */
98
+ const MAX_SCHEMA_RETRIES = 3;
99
+
103
100
  export class YieldTool implements AgentTool<TSchema, YieldDetails> {
104
101
  readonly name = "yield";
105
102
  readonly label = "Submit Result";
@@ -120,21 +117,14 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
120
117
  let parameters: TSchema;
121
118
 
122
119
  try {
123
- const schemaResult = normalizeSchema(session.outputSchema);
124
- const normalizedSchema =
125
- schemaResult.normalized !== undefined ? jtdToJsonSchema(schemaResult.normalized) : undefined;
126
- let schemaError = schemaResult.error;
127
-
128
- if (!schemaError && normalizedSchema === false) {
129
- schemaError = "boolean false schema rejects all outputs";
130
- }
131
-
132
- if (normalizedSchema !== undefined && normalizedSchema !== false && !schemaError) {
133
- if (!isValidJsonSchema(normalizedSchema)) {
134
- schemaError = "invalid JSON schema";
135
- } else {
136
- validate = value => validateJsonSchemaValue(normalizedSchema, value);
137
- }
120
+ const {
121
+ validator,
122
+ jsonSchema: normalizedSchema,
123
+ normalized,
124
+ error: schemaError,
125
+ } = buildOutputValidator(session.outputSchema);
126
+ if (validator) {
127
+ validate = value => validator.validate(value);
138
128
  }
139
129
 
140
130
  const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema);
@@ -142,21 +132,15 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
142
132
  ? `Structured JSON output (output schema invalid; accepting unconstrained object): ${schemaError}`
143
133
  : `Structured output matching the schema:\n${schemaHint}`;
144
134
  let sanitizedSchema: Record<string, unknown> | undefined;
145
- if (
146
- !schemaError &&
147
- normalizedSchema != null &&
148
- typeof normalizedSchema === "object" &&
149
- !Array.isArray(normalizedSchema)
150
- ) {
151
- const normalizedRecord = normalizedSchema as Record<string, unknown>;
152
- const strictProbe = tryEnforceStrictSchema(normalizedRecord);
135
+ if (!schemaError && normalizedSchema !== undefined) {
136
+ const strictProbe = tryEnforceStrictSchema(normalizedSchema);
153
137
  if (strictProbe.strict) {
154
- sanitizedSchema = sanitizeSchemaForStrictMode(normalizedRecord);
138
+ sanitizedSchema = sanitizeSchemaForStrictMode(normalizedSchema);
155
139
  } else {
156
- sanitizedSchema = normalizedRecord;
140
+ sanitizedSchema = normalizedSchema;
157
141
  this.strict = false;
158
142
  }
159
- } else if (!schemaError && normalizedSchema === true) {
143
+ } else if (!schemaError && normalized === true) {
160
144
  sanitizedSchema = {};
161
145
  this.strict = false;
162
146
  }
@@ -229,8 +213,15 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
229
213
  const parsed = this.#validate(data);
230
214
  if (!parsed.success) {
231
215
  this.#schemaValidationFailures++;
232
- if (this.#schemaValidationFailures <= 1) {
233
- throw new Error(`Output does not match schema: ${formatJsonSchemaIssues(parsed.issues)}`);
216
+ if (this.#schemaValidationFailures <= MAX_SCHEMA_RETRIES) {
217
+ const remaining = MAX_SCHEMA_RETRIES - this.#schemaValidationFailures;
218
+ const retryHint =
219
+ remaining > 0
220
+ ? ` Call yield again with the corrected shape — ${remaining} retry attempt(s) remain before the schema constraint is dropped.`
221
+ : " Call yield again with the corrected shape — this is the final retry before the schema constraint is dropped.";
222
+ throw new Error(
223
+ `Output does not match schema: ${formatAllValidationIssues(parsed.issues)}.${retryHint}`,
224
+ );
234
225
  }
235
226
  schemaValidationOverridden = true;
236
227
  }
@@ -1,6 +1,6 @@
1
1
  import { $env } from "@oh-my-pi/pi-utils";
2
2
 
3
- export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch";
3
+ export type EditMode = "replace" | "patch" | "hashline" | "apply_patch";
4
4
 
5
5
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
6
6
 
@@ -9,7 +9,6 @@ const EDIT_MODE_IDS = {
9
9
  hashline: "hashline",
10
10
  patch: "patch",
11
11
  replace: "replace",
12
- vim: "vim",
13
12
  } as const satisfies Record<string, EditMode>;
14
13
 
15
14
  export const EDIT_MODES = Object.keys(EDIT_MODE_IDS) as EditMode[];
@@ -12,7 +12,7 @@ import type { ImageContent } from "@oh-my-pi/pi-ai";
12
12
  import { glob } from "@oh-my-pi/pi-natives";
13
13
  import { fuzzyMatch } from "@oh-my-pi/pi-tui";
14
14
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
15
- import { formatHashLines } from "../hashline/hash";
15
+ import { computeFileHash, formatHashlineHeader, formatNumberedLines } from "../hashline/hash";
16
16
  import type { FileMentionMessage } from "../session/messages";
17
17
  import {
18
18
  DEFAULT_MAX_BYTES,
@@ -356,7 +356,7 @@ export async function generateFileMentionMessages(
356
356
  const content = await Bun.file(absolutePath).text();
357
357
  let { output, lineCount } = buildTextOutput(content);
358
358
  if (options?.useHashLines) {
359
- output = formatHashLines(output);
359
+ output = `${formatHashlineHeader(resolvedPath, computeFileHash(content))}\n${formatNumberedLines(output)}`;
360
360
  }
361
361
  files.push({ path: resolvedPath, content: output, lineCount });
362
362
  } catch {
package/src/web/kagi.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { getEnvApiKey } from "@oh-my-pi/pi-ai";
2
- import { findCredential, withHardTimeout } from "./search/providers/utils";
1
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
+ import { withHardTimeout } from "./search/providers/utils";
3
3
 
4
4
  const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
5
5
 
@@ -97,6 +97,7 @@ function parseKagiErrorResponse(statusCode: number, responseText: string): KagiA
97
97
 
98
98
  export interface KagiSearchOptions {
99
99
  limit?: number;
100
+ sessionId?: string;
100
101
  signal?: AbortSignal;
101
102
  }
102
103
 
@@ -113,8 +114,12 @@ export interface KagiSearchResult {
113
114
  relatedQuestions: string[];
114
115
  }
115
116
 
116
- export async function findKagiApiKey(): Promise<string | null> {
117
- return findCredential(getEnvApiKey("kagi"), "kagi");
117
+ export async function findKagiApiKey(
118
+ authStorage: AuthStorage,
119
+ sessionId?: string,
120
+ signal?: AbortSignal,
121
+ ): Promise<string | null> {
122
+ return (await authStorage.getApiKey("kagi", sessionId, { signal })) ?? null;
118
123
  }
119
124
 
120
125
  function getAuthHeaders(apiKey: string): Record<string, string> {
@@ -124,8 +129,12 @@ function getAuthHeaders(apiKey: string): Record<string, string> {
124
129
  };
125
130
  }
126
131
 
127
- export async function searchWithKagi(query: string, options: KagiSearchOptions = {}): Promise<KagiSearchResult> {
128
- const apiKey = await findKagiApiKey();
132
+ export async function searchWithKagi(
133
+ query: string,
134
+ options: KagiSearchOptions = {},
135
+ authStorage: AuthStorage,
136
+ ): Promise<KagiSearchResult> {
137
+ const apiKey = await findKagiApiKey(authStorage, options.sessionId, options.signal);
129
138
  if (!apiKey) {
130
139
  throw new KagiApiError("Kagi credentials not found. Set KAGI_API_KEY or login with 'omp /login kagi'.");
131
140
  }
@@ -1,4 +1,5 @@
1
1
  import { getEnvApiKey } from "@oh-my-pi/pi-ai";
2
+ import type { AgentStorage } from "../session/agent-storage";
2
3
  import { findCredential, withHardTimeout } from "./search/providers/utils";
3
4
 
4
5
  const PARALLEL_API_URL = "https://api.parallel.ai";
@@ -73,8 +74,8 @@ export class ParallelApiError extends Error {
73
74
  }
74
75
  }
75
76
 
76
- export async function findParallelApiKey(): Promise<string | null> {
77
- return findCredential(getEnvApiKey("parallel"), "parallel");
77
+ export function findParallelApiKey(storage: AgentStorage | null | undefined): string | null {
78
+ return findCredential(storage, getEnvApiKey("parallel"), "parallel");
78
79
  }
79
80
 
80
81
  export function getParallelExtractContent(document: ParallelExtractDocument): string {
@@ -284,9 +285,10 @@ function parseExtractPayload(payload: unknown): ParallelExtractResult {
284
285
  export async function searchWithParallel(
285
286
  objective: string,
286
287
  queries: string[],
287
- options: ParallelSearchOptions = {},
288
+ options: ParallelSearchOptions,
289
+ storage: AgentStorage | null | undefined,
288
290
  ): Promise<ParallelSearchResult> {
289
- const apiKey = await findParallelApiKey();
291
+ const apiKey = findParallelApiKey(storage);
290
292
  if (!apiKey) {
291
293
  throw new ParallelApiError(
292
294
  "Parallel credentials not found. Set PARALLEL_API_KEY or login with 'omp /login parallel'.",
@@ -316,9 +318,10 @@ export async function searchWithParallel(
316
318
 
317
319
  export async function extractWithParallel(
318
320
  urls: string[],
319
- options: ParallelExtractOptions = {},
321
+ options: ParallelExtractOptions,
322
+ storage: AgentStorage | null | undefined,
320
323
  ): Promise<ParallelExtractResult> {
321
- const apiKey = await findParallelApiKey();
324
+ const apiKey = findParallelApiKey(storage);
322
325
  if (!apiKey) {
323
326
  throw new ParallelApiError(
324
327
  "Parallel credentials not found. Set PARALLEL_API_KEY or login with 'omp /login parallel'.",
@@ -4,6 +4,7 @@
4
4
  import { ptree } from "@oh-my-pi/pi-utils";
5
5
  import type TurndownService from "turndown";
6
6
 
7
+ import type { AgentStorage } from "../../session/agent-storage";
7
8
  import { ToolAbortError } from "../../tools/tool-errors";
8
9
 
9
10
  export { formatNumber } from "@oh-my-pi/pi-utils";
@@ -19,7 +20,12 @@ export interface RenderResult {
19
20
  notes: string[];
20
21
  }
21
22
 
22
- export type SpecialHandler = (url: string, timeout: number, signal?: AbortSignal) => Promise<RenderResult | null>;
23
+ export type SpecialHandler = (
24
+ url: string,
25
+ timeout: number,
26
+ signal?: AbortSignal,
27
+ storage?: AgentStorage | null,
28
+ ) => Promise<RenderResult | null>;
23
29
 
24
30
  export const MAX_OUTPUT_CHARS = 500_000;
25
31
  export const MAX_BYTES = 50 * 1024 * 1024;
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { ptree, Snowflake } from "@oh-my-pi/pi-utils";
5
5
  import { settings } from "../../config/settings";
6
+ import type { AgentStorage } from "../../session/agent-storage";
6
7
  import { throwIfAborted } from "../../tools/tool-errors";
7
8
  import { ensureTool } from "../../utils/tools-manager";
8
9
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../parallel";
@@ -101,6 +102,7 @@ export const handleYouTube: SpecialHandler = async (
101
102
  url: string,
102
103
  timeout: number,
103
104
  userSignal?: AbortSignal,
105
+ storage?: AgentStorage | null,
104
106
  ): Promise<RenderResult | null> => {
105
107
  throwIfAborted(userSignal);
106
108
  const yt = parseYouTubeUrl(url);
@@ -112,14 +114,18 @@ export const handleYouTube: SpecialHandler = async (
112
114
  const videoUrl = `https://www.youtube.com/watch?v=${yt.videoId}`;
113
115
 
114
116
  // Prefer Parallel extract when credentials are available
115
- if (settings.get("providers.parallelFetch") && (await findParallelApiKey())) {
117
+ if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
116
118
  try {
117
- const parallelResult = await extractWithParallel([videoUrl], {
118
- objective: "Extract the main content of this YouTube video page",
119
- excerpts: true,
120
- fullContent: false,
121
- signal,
122
- });
119
+ const parallelResult = await extractWithParallel(
120
+ [videoUrl],
121
+ {
122
+ objective: "Extract the main content of this YouTube video page",
123
+ excerpts: true,
124
+ fullContent: false,
125
+ signal,
126
+ },
127
+ storage,
128
+ );
123
129
  const firstDocument = parallelResult.results[0];
124
130
  if (firstDocument) {
125
131
  const content = getParallelExtractContent(firstDocument);
@@ -3,15 +3,16 @@
3
3
  *
4
4
  * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, SearXNG, and Synthetic
5
5
  * providers with provider-specific parameters exposed conditionally.
6
- *
7
6
  */
8
7
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
8
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
9
9
  import { prompt } from "@oh-my-pi/pi-utils";
10
10
  import * as z from "zod/v4";
11
11
  import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types";
12
12
  import type { Theme } from "../../modes/theme/theme";
13
13
  import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" };
14
14
  import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
15
+ import { discoverAuthStorage } from "../../sdk";
15
16
  import type { ToolSession } from "../../tools";
16
17
  import { formatAge } from "../../tools/render-utils";
17
18
  import { throwIfAborted } from "../../tools/tool-errors";
@@ -114,18 +115,25 @@ function formatForLLM(response: SearchResponse): string {
114
115
  return parts.join("\n");
115
116
  }
116
117
 
118
+ interface ExecuteSearchOptions {
119
+ authStorage: AuthStorage;
120
+ sessionId?: string;
121
+ signal?: AbortSignal;
122
+ }
123
+
117
124
  /** Execute web search */
118
125
  async function executeSearch(
119
126
  _toolCallId: string,
120
127
  params: SearchQueryParams,
121
- signal?: AbortSignal,
128
+ options: ExecuteSearchOptions,
122
129
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
130
+ const { authStorage, sessionId, signal } = options;
123
131
  const providers =
124
132
  params.provider && params.provider !== "auto"
125
- ? await getSearchProvider(params.provider).then(provider =>
126
- provider.isAvailable() ? [provider] : resolveProviderChain("auto"),
133
+ ? await getSearchProvider(params.provider).then(async provider =>
134
+ (await provider.isAvailable(authStorage)) ? [provider] : resolveProviderChain(authStorage, "auto"),
127
135
  )
128
- : await resolveProviderChain();
136
+ : await resolveProviderChain(authStorage);
129
137
  if (providers.length === 0) {
130
138
  const message = "No web search provider configured.";
131
139
  return {
@@ -148,6 +156,8 @@ async function executeSearch(
148
156
  numSearchResults: params.num_search_results,
149
157
  temperature: params.temperature,
150
158
  signal,
159
+ authStorage,
160
+ sessionId,
151
161
  });
152
162
 
153
163
  const text = formatForLLM(response);
@@ -190,18 +200,27 @@ async function executeSearch(
190
200
 
191
201
  /**
192
202
  * Execute a web search query for CLI/testing workflows.
203
+ *
204
+ * `authStorage` may be omitted; in that case we discover one via the standard
205
+ * factory (`discoverAuthStorage`), which honours `OMP_AUTH_BROKER_URL` and
206
+ * otherwise opens the local SQLite credential store.
193
207
  */
194
208
  export async function runSearchQuery(
195
209
  params: SearchQueryParams,
210
+ options: { authStorage?: AuthStorage; sessionId?: string; signal?: AbortSignal } = {},
196
211
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
197
- return executeSearch("cli-web-search", params);
212
+ const authStorage = options.authStorage ?? (await discoverAuthStorage());
213
+ return executeSearch("cli-web-search", params, {
214
+ authStorage,
215
+ sessionId: options.sessionId,
216
+ signal: options.signal,
217
+ });
198
218
  }
199
219
 
200
220
  /**
201
221
  * Web search tool implementation.
202
222
  *
203
223
  * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, SearXNG, and Synthetic providers with automatic fallback.
204
- * Session is accepted for interface consistency but not used.
205
224
  */
206
225
  export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRenderDetails> {
207
226
  readonly name = "web_search";
@@ -212,7 +231,10 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
212
231
  readonly loadMode = "discoverable";
213
232
  readonly summary = "Search the web for up-to-date information";
214
233
 
215
- constructor(_session: ToolSession) {
234
+ #session: ToolSession;
235
+
236
+ constructor(session: ToolSession) {
237
+ this.#session = session;
216
238
  this.description = prompt.render(webSearchDescription);
217
239
  }
218
240
 
@@ -223,7 +245,9 @@ export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRe
223
245
  _onUpdate?: AgentToolUpdateCallback<SearchRenderDetails>,
224
246
  _context?: AgentToolContext,
225
247
  ): Promise<AgentToolResult<SearchRenderDetails>> {
226
- return executeSearch(_toolCallId, params, signal);
248
+ const authStorage = this.#session.authStorage ?? (await discoverAuthStorage());
249
+ const sessionId = this.#session.getSessionId?.() ?? undefined;
250
+ return executeSearch(_toolCallId, params, { authStorage, sessionId, signal });
227
251
  }
228
252
  }
229
253
 
@@ -238,10 +262,12 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, SearchRende
238
262
  toolCallId: string,
239
263
  params: SearchToolParams,
240
264
  _onUpdate,
241
- _ctx: CustomToolContext,
265
+ ctx: CustomToolContext,
242
266
  signal?: AbortSignal,
243
267
  ) {
244
- return executeSearch(toolCallId, params, signal);
268
+ const authStorage = ctx.modelRegistry?.authStorage ?? (await discoverAuthStorage());
269
+ const sessionId = ctx.sessionManager.getSessionId();
270
+ return executeSearch(toolCallId, params, { authStorage, sessionId, signal });
245
271
  },
246
272
 
247
273
  renderCall(args: SearchToolParams, options: RenderResultOptions, theme: Theme) {