@oh-my-pi/pi-coding-agent 15.3.2 → 15.4.2

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 (193) hide show
  1. package/CHANGELOG.md +110 -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/settings-selector.ts +10 -1
  114. package/src/modes/components/tree-selector.ts +10 -2
  115. package/src/modes/controllers/command-controller.ts +1 -3
  116. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  117. package/src/modes/controllers/selector-controller.ts +5 -5
  118. package/src/modes/theme/theme.ts +4 -2
  119. package/src/modes/types.ts +4 -1
  120. package/src/modes/utils/ui-helpers.ts +4 -0
  121. package/src/prompts/agents/explore.md +1 -1
  122. package/src/prompts/tools/ast-edit.md +1 -1
  123. package/src/prompts/tools/ast-grep.md +1 -1
  124. package/src/prompts/tools/eval.md +1 -1
  125. package/src/prompts/tools/hashline.md +73 -94
  126. package/src/prompts/tools/read.md +4 -4
  127. package/src/prompts/tools/search.md +3 -3
  128. package/src/sdk.ts +33 -26
  129. package/src/session/agent-session.ts +59 -66
  130. package/src/session/agent-storage.ts +13 -14
  131. package/src/slash-commands/acp-builtins.ts +3 -3
  132. package/src/slash-commands/types.ts +0 -6
  133. package/src/task/executor.ts +26 -57
  134. package/src/task/index.ts +8 -4
  135. package/src/tool-discovery/tool-index.ts +0 -134
  136. package/src/tools/ast-edit.ts +36 -13
  137. package/src/tools/ast-grep.ts +45 -4
  138. package/src/tools/browser/tab-worker.ts +3 -2
  139. package/src/tools/eval.ts +2 -1
  140. package/src/tools/fetch.ts +23 -14
  141. package/src/tools/index.ts +2 -8
  142. package/src/tools/irc.ts +59 -5
  143. package/src/tools/match-line-format.ts +5 -7
  144. package/src/tools/output-schema-validator.ts +132 -0
  145. package/src/tools/read.ts +142 -31
  146. package/src/tools/review.ts +23 -0
  147. package/src/tools/search-tool-bm25.ts +3 -30
  148. package/src/tools/search.ts +48 -16
  149. package/src/tools/write.ts +3 -3
  150. package/src/tools/yield.ts +32 -41
  151. package/src/utils/edit-mode.ts +1 -2
  152. package/src/utils/file-mentions.ts +2 -2
  153. package/src/web/kagi.ts +15 -6
  154. package/src/web/parallel.ts +9 -6
  155. package/src/web/scrapers/types.ts +7 -1
  156. package/src/web/scrapers/youtube.ts +13 -7
  157. package/src/web/search/index.ts +37 -11
  158. package/src/web/search/provider.ts +5 -3
  159. package/src/web/search/providers/anthropic.ts +30 -21
  160. package/src/web/search/providers/base.ts +35 -2
  161. package/src/web/search/providers/brave.ts +4 -4
  162. package/src/web/search/providers/codex.ts +118 -89
  163. package/src/web/search/providers/exa.ts +3 -2
  164. package/src/web/search/providers/gemini.ts +58 -155
  165. package/src/web/search/providers/jina.ts +4 -4
  166. package/src/web/search/providers/kagi.ts +17 -11
  167. package/src/web/search/providers/kimi.ts +29 -13
  168. package/src/web/search/providers/parallel.ts +171 -23
  169. package/src/web/search/providers/perplexity.ts +38 -37
  170. package/src/web/search/providers/searxng.ts +3 -1
  171. package/src/web/search/providers/synthetic.ts +16 -19
  172. package/src/web/search/providers/tavily.ts +23 -18
  173. package/src/web/search/providers/utils.ts +11 -17
  174. package/src/web/search/providers/zai.ts +16 -8
  175. package/dist/types/hashline/parser.d.ts +0 -7
  176. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  177. package/dist/types/tools/vim.d.ts +0 -58
  178. package/dist/types/vim/buffer.d.ts +0 -41
  179. package/dist/types/vim/commands.d.ts +0 -6
  180. package/dist/types/vim/engine.d.ts +0 -47
  181. package/dist/types/vim/parser.d.ts +0 -3
  182. package/dist/types/vim/render.d.ts +0 -25
  183. package/dist/types/vim/types.d.ts +0 -182
  184. package/src/hashline/parser.ts +0 -246
  185. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  186. package/src/prompts/tools/vim.md +0 -98
  187. package/src/tools/vim.ts +0 -949
  188. package/src/vim/buffer.ts +0 -309
  189. package/src/vim/commands.ts +0 -382
  190. package/src/vim/engine.ts +0 -2409
  191. package/src/vim/parser.ts +0 -134
  192. package/src/vim/render.ts +0 -252
  193. package/src/vim/types.ts +0 -197
package/src/tools/read.ts CHANGED
@@ -9,9 +9,10 @@ import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import * as z from "zod/v4";
11
11
  import { getFileReadCache } from "../edit/file-read-cache";
12
+ import { normalizeToLF } from "../edit/normalize";
12
13
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
13
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
- import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
15
+ import { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "../hashline/hash";
15
16
  import { InternalUrlRouter } from "../internal-urls";
16
17
  import { parseInternalUrl } from "../internal-urls/parse";
17
18
  import type { InternalUrl } from "../internal-urls/types";
@@ -113,13 +114,50 @@ function prependLineNumbers(text: string, startNum: number): string {
113
114
  return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n");
114
115
  }
115
116
 
117
+ interface HashlineHeaderContext {
118
+ header: string;
119
+ fileHash: string;
120
+ fullText: string;
121
+ }
122
+
123
+ function buildHashlineHeaderContext(displayPath: string, fullText: string): HashlineHeaderContext {
124
+ const normalized = normalizeToLF(fullText);
125
+ const fileHash = computeFileHash(normalized);
126
+ return {
127
+ header: formatHashlineHeader(displayPath, fileHash),
128
+ fileHash,
129
+ fullText: normalized,
130
+ };
131
+ }
132
+
133
+ async function readHashlineHeaderContext(absolutePath: string, cwd: string): Promise<HashlineHeaderContext> {
134
+ const fullText = await Bun.file(absolutePath).text();
135
+ return buildHashlineHeaderContext(formatPathRelativeToCwd(absolutePath, cwd), fullText);
136
+ }
137
+
138
+ function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
139
+ return context ? `${context.header}\n${text}` : text;
140
+ }
141
+
142
+ function recordHashlineSnapshot(
143
+ session: ToolSession,
144
+ absolutePath: string | undefined,
145
+ context: HashlineHeaderContext | undefined,
146
+ ): void {
147
+ if (!context || !absolutePath || !path.isAbsolute(absolutePath)) return;
148
+ getFileReadCache(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), {
149
+ fullText: context.fullText,
150
+ fileHash: context.fileHash,
151
+ });
152
+ }
153
+
116
154
  function formatTextWithMode(
117
155
  text: string,
118
156
  startNum: number,
119
157
  shouldAddHashLines: boolean,
120
158
  shouldAddLineNumbers: boolean,
121
159
  ): string {
122
- if (shouldAddHashLines) return formatHashLines(text, startNum);
160
+ if (shouldAddHashLines) return formatNumberedLines(text, startNum);
123
161
  if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
124
162
  return text;
125
163
  }
@@ -150,7 +188,7 @@ function formatSingleLine(
150
188
  shouldAddHashLines: boolean,
151
189
  shouldAddLineNumbers: boolean,
152
190
  ): string {
153
- if (shouldAddHashLines) return formatHashLine(line, text);
191
+ if (shouldAddHashLines) return formatNumberedLine(line, text);
154
192
  if (shouldAddLineNumbers) return `${line}|${text}`;
155
193
  return text;
156
194
  }
@@ -165,9 +203,7 @@ function formatMergedBraceLine(
165
203
  ): { model: string; display: string } {
166
204
  const merged = `${headText.trimEnd()} .. ${tailText.trim()}`;
167
205
  if (shouldAddHashLines) {
168
- const start = formatLineHash(startLine, headText);
169
- const end = formatLineHash(endLine, tailText);
170
- return { model: `${start}-${end}${HL_BODY_SEP}${merged}`, display: merged };
206
+ return { model: `${startLine}-${endLine}:${merged}`, display: merged };
171
207
  }
172
208
  if (shouldAddLineNumbers) {
173
209
  return { model: `${startLine}-${endLine}|${merged}`, display: merged };
@@ -180,17 +216,38 @@ function countTextLines(text: string): number {
180
216
  return text.split("\n").length;
181
217
  }
182
218
 
219
+ /** Inclusive line range describing one elided span in a structural summary. */
220
+ interface ElidedRange {
221
+ start: number;
222
+ end: number;
223
+ }
224
+
225
+ /** Sample ranges shown in the footer to demonstrate the multi-range syntax. */
226
+ const FOOTER_RANGE_SAMPLES = 2;
227
+
183
228
  /**
184
229
  * Footer appended to summarized reads telling the model how to recover the
185
230
  * elided body. Without this hint, agents either ignore the `...`/`{ .. }`
186
- * markers or burn a turn guessing the right selector (see issue #1046).
231
+ * markers or burn a turn guessing the right selector (see issue #1046). The
232
+ * footer demonstrates the multi-range selector syntax with concrete sample
233
+ * ranges drawn from the actual elision so the model re-reads only what it
234
+ * needs instead of falling back to `:raw` or whole-file reads.
187
235
  */
188
- function formatSummaryElisionFooter(readPath: string, elidedSpans: number, elidedLines: number): string {
189
- if (elidedSpans <= 0) return "";
190
- const spanWord = elidedSpans === 1 ? "region" : "regions";
236
+ function formatSummaryElisionFooter(
237
+ readPath: string,
238
+ elidedRanges: ReadonlyArray<ElidedRange>,
239
+ elidedLines: number,
240
+ ): string {
241
+ if (elidedRanges.length === 0) return "";
191
242
  const lineWord = elidedLines === 1 ? "line" : "lines";
192
- const linePart = elidedLines > 0 ? `${elidedLines} ${lineWord} across ` : "";
193
- return `[${linePart}${elidedSpans} elided ${spanWord}; read ${readPath}:raw or a line range like ${readPath}:1-9999 for verbatim content]`;
243
+ const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES);
244
+ const selector = elidedRanges
245
+ .slice(0, sampleCount)
246
+ .map(r => `${r.start}-${r.end}`)
247
+ .join(",");
248
+ const example = `${readPath}:${selector}`;
249
+ const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`;
250
+ return `[${elidedLines} ${lineWord} elided; re-read needed ranges${tail}]`;
194
251
  }
195
252
  const READ_CHUNK_SIZE = 8 * 1024;
196
253
 
@@ -844,9 +901,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
844
901
 
845
902
  const shouldAddHashLines = displayMode.hashLines;
846
903
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
904
+ const hashContext =
905
+ shouldAddHashLines && options.sourcePath
906
+ ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text)
907
+ : undefined;
908
+ recordHashlineSnapshot(this.session, options.sourcePath, hashContext);
909
+ let emittedHashlineHeader = false;
847
910
  const formatText = (content: string, startNum: number): string => {
848
911
  details.displayContent = { text: content, startLine: startNum };
849
- return formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
912
+ const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
913
+ if (!hashContext || emittedHashlineHeader) return formatted;
914
+ emittedHashlineHeader = true;
915
+ return prependHashlineHeader(formatted, hashContext);
850
916
  };
851
917
 
852
918
  let outputText: string;
@@ -862,7 +928,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
862
928
  if (shouldAddHashLines) {
863
929
  outputText = `[Line ${startLineDisplay} is ${formatBytes(
864
930
  firstLineBytes,
865
- )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
931
+ )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`;
866
932
  } else {
867
933
  outputText = formatText(snippet.text, startLineDisplay);
868
934
  }
@@ -928,6 +994,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
928
994
  const totalLines = allLines.length;
929
995
  const shouldAddHashLines = displayMode.hashLines;
930
996
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
997
+ const hashContext =
998
+ shouldAddHashLines && options.sourcePath
999
+ ? buildHashlineHeaderContext(formatPathRelativeToCwd(options.sourcePath, this.session.cwd), text)
1000
+ : undefined;
1001
+ recordHashlineSnapshot(this.session, options.sourcePath, hashContext);
1002
+ let emittedHashlineHeader = false;
931
1003
 
932
1004
  const resultBuilder = toolResult(details);
933
1005
  if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
@@ -943,7 +1015,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
943
1015
  }
944
1016
  const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
945
1017
  const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n");
946
- parts.push(formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1018
+ const formatted = formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers);
1019
+ parts.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted);
1020
+ if (hashContext) emittedHashlineHeader = true;
947
1021
  }
948
1022
 
949
1023
  const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : "";
@@ -1002,6 +1076,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1002
1076
 
1003
1077
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1004
1078
  const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1079
+ const hashContext = shouldAddHashLines
1080
+ ? await readHashlineHeaderContext(absolutePath, this.session.cwd)
1081
+ : undefined;
1082
+ recordHashlineSnapshot(this.session, absolutePath, hashContext);
1083
+ let emittedHashlineHeader = false;
1005
1084
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1006
1085
 
1007
1086
  const blocks: string[] = [];
@@ -1042,11 +1121,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1042
1121
  }
1043
1122
 
1044
1123
  if (collectedLines.length > 0) {
1045
- getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, collectedLines);
1124
+ getFileReadCache(this.session).recordContiguous(
1125
+ absolutePath,
1126
+ range.startLine,
1127
+ collectedLines,
1128
+ hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {},
1129
+ );
1046
1130
  }
1047
1131
 
1048
1132
  const blockText = collectedLines.join("\n");
1049
- blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1133
+ const formatted = formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers);
1134
+ blocks.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted);
1135
+ if (hashContext) emittedHashlineHeader = true;
1050
1136
  }
1051
1137
 
1052
1138
  let outputText = blocks.join("\n\n…\n\n");
@@ -1335,7 +1421,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1335
1421
  #renderSummary(summary: SummaryResult): {
1336
1422
  text: string;
1337
1423
  displayText: string;
1338
- elidedSpans: number;
1424
+ elidedRanges: ElidedRange[];
1339
1425
  elidedLines: number;
1340
1426
  } {
1341
1427
  const displayMode = resolveFileDisplayMode(this.session);
@@ -1396,13 +1482,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1396
1482
 
1397
1483
  const modelParts: string[] = [];
1398
1484
  const displayParts: string[] = [];
1399
- let elidedSpans = 0;
1485
+ const elidedRanges: ElidedRange[] = [];
1400
1486
  let elidedLines = 0;
1401
1487
  for (const unit of units) {
1402
1488
  if (unit.kind === "elided") {
1403
1489
  modelParts.push("...");
1404
1490
  displayParts.push("...");
1405
- elidedSpans++;
1491
+ elidedRanges.push({ start: unit.startLine, end: unit.endLine });
1406
1492
  elidedLines += unit.endLine - unit.startLine + 1;
1407
1493
  continue;
1408
1494
  }
@@ -1417,7 +1503,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1417
1503
  );
1418
1504
  modelParts.push(formatted.model);
1419
1505
  displayParts.push(formatted.display);
1420
- elidedSpans++;
1506
+ // Suggest the full brace range so re-reading shows both braces
1507
+ // plus the elided body in one shot.
1508
+ elidedRanges.push({ start: unit.startLine, end: unit.endLine });
1421
1509
  // Merged brace pair encloses (start+1)..(end-1) as elided.
1422
1510
  elidedLines += Math.max(0, unit.endLine - unit.startLine - 1);
1423
1511
  continue;
@@ -1426,7 +1514,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1426
1514
  displayParts.push(unit.text);
1427
1515
  }
1428
1516
 
1429
- return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans, elidedLines };
1517
+ return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedRanges, elidedLines };
1430
1518
  }
1431
1519
 
1432
1520
  async execute(
@@ -1674,15 +1762,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1674
1762
  const renderedSummary = this.#renderSummary(summary);
1675
1763
  const footer = formatSummaryElisionFooter(
1676
1764
  localReadPath,
1677
- renderedSummary.elidedSpans,
1765
+ renderedSummary.elidedRanges,
1678
1766
  renderedSummary.elidedLines,
1679
1767
  );
1680
- const modelText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
1768
+ const summaryHashContext = displayMode.hashLines
1769
+ ? await readHashlineHeaderContext(absolutePath, this.session.cwd)
1770
+ : undefined;
1771
+ recordHashlineSnapshot(this.session, absolutePath, summaryHashContext);
1772
+ const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
1773
+ const modelText = prependHashlineHeader(bodyText, summaryHashContext);
1681
1774
  details = {
1682
1775
  displayContent: { text: renderedSummary.displayText, startLine: 1 },
1683
1776
  summary: {
1684
1777
  lines: countTextLines(renderedSummary.text),
1685
- elidedSpans: renderedSummary.elidedSpans,
1778
+ elidedSpans: renderedSummary.elidedRanges.length,
1686
1779
  elidedLines: renderedSummary.elidedLines,
1687
1780
  },
1688
1781
  };
@@ -1820,16 +1913,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1820
1913
  firstLineExceedsLimit,
1821
1914
  };
1822
1915
 
1916
+ const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1917
+ const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1918
+ const hashContext = shouldAddHashLines
1919
+ ? await readHashlineHeaderContext(absolutePath, this.session.cwd)
1920
+ : undefined;
1921
+
1823
1922
  if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1824
- getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
1923
+ getFileReadCache(this.session).recordContiguous(
1924
+ absolutePath,
1925
+ startLineDisplay,
1926
+ collectedLines,
1927
+ hashContext ? { fullText: hashContext.fullText, fileHash: hashContext.fileHash } : {},
1928
+ );
1825
1929
  }
1826
1930
 
1827
- const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1828
- const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1829
1931
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
1932
+ let emittedHashlineHeader = false;
1830
1933
  const formatText = (text: string, startNum: number): string => {
1831
1934
  capturedDisplayContent = { text, startLine: startNum };
1832
- return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1935
+ const formatted = formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1936
+ if (!hashContext || emittedHashlineHeader) return formatted;
1937
+ emittedHashlineHeader = true;
1938
+ return prependHashlineHeader(formatted, hashContext);
1833
1939
  };
1834
1940
 
1835
1941
  let outputText: string;
@@ -1841,7 +1947,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1841
1947
  if (shouldAddHashLines) {
1842
1948
  outputText = `[Line ${startLineDisplay} is ${formatBytes(
1843
1949
  firstLineBytes,
1844
- )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1950
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`;
1845
1951
  } else {
1846
1952
  outputText = formatText(snippet.text, startLineDisplay);
1847
1953
  }
@@ -1964,7 +2070,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1964
2070
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
1965
2071
 
1966
2072
  const rawText = region.lines.join("\n");
1967
- const formattedText = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
2073
+ const hashContext = shouldAddHashLines
2074
+ ? await readHashlineHeaderContext(entry.absolutePath, this.session.cwd)
2075
+ : undefined;
2076
+ recordHashlineSnapshot(this.session, entry.absolutePath, hashContext);
2077
+ const formattedBody = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
2078
+ const formattedText = prependHashlineHeader(formattedBody, hashContext);
1968
2079
 
1969
2080
  const details: ReadToolDetails = {
1970
2081
  resolvedPath: entry.absolutePath,
@@ -15,6 +15,7 @@ import { isRecord } from "@oh-my-pi/pi-utils";
15
15
  import * as z from "zod/v4";
16
16
  import type { Theme, ThemeColor } from "../modes/theme/theme";
17
17
  import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
18
+ import type { ReviewFinding } from "../task/types";
18
19
  export type FindingPriority = "P0" | "P1" | "P2" | "P3";
19
20
 
20
21
  export interface FindingPriorityInfo {
@@ -186,6 +187,28 @@ export interface SubmitReviewDetails {
186
187
 
187
188
  // Re-export types for external use
188
189
  export type { ReportFindingDetails };
190
+ /**
191
+ * Coerce a tool-side `ReportFindingDetails` into the cross-boundary
192
+ * `ReviewFinding` shape consumed by the reviewer agent's JTD output schema.
193
+ *
194
+ * The `report_finding` tool exposes `priority` as a string enum (`"P0".."P3"`)
195
+ * for ergonomics, but the bundled reviewer schema (and every custom review
196
+ * agent that mirrors it) declares `priority: number`. Without this coercion
197
+ * the auto-populated `findings[]` fails JTD validation and every review run
198
+ * that surfaces a finding is rejected with `findings.0.priority: expected
199
+ * number, received string`.
200
+ */
201
+ export function toReviewFinding(details: ReportFindingDetails): ReviewFinding {
202
+ return {
203
+ title: details.title,
204
+ body: details.body,
205
+ priority: getPriorityInfo(details.priority).ord,
206
+ confidence: details.confidence,
207
+ file_path: details.file_path,
208
+ line_start: details.line_start,
209
+ line_end: details.line_end,
210
+ };
211
+ }
189
212
 
190
213
  // Register report_finding handler
191
214
  subprocessToolRegistry.register<ReportFindingDetails>("report_finding", {
@@ -19,12 +19,6 @@ import type { ToolSession } from ".";
19
19
  import { formatCount, replaceTabs, TRUNCATE_LENGTHS } from "./render-utils";
20
20
  import { ToolError } from "./tool-errors";
21
21
 
22
- // Re-export legacy MCP types for back-compat (tests and external callers may reference them)
23
- export type {
24
- DiscoverableMCPSearchIndex,
25
- DiscoverableMCPTool,
26
- } from "../mcp/discoverable-tool-metadata";
27
-
28
22
  const DEFAULT_LIMIT = 8;
29
23
  const TOOL_DISCOVERY_TITLE = "Tool Discovery";
30
24
  const COLLAPSED_MATCH_LIMIT = 5;
@@ -81,21 +75,7 @@ function buildSearchToolBm25Content(details: SearchToolBm25Details): string {
81
75
  /** Get discoverable tools for description rendering. Falls back to empty array on error. */
82
76
  function getDiscoverableToolsForDescription(session: ToolSession): DiscoverableTool[] {
83
77
  try {
84
- // Prefer generic method; fall back to legacy MCP-only
85
- if (session.getDiscoverableTools) {
86
- return session.getDiscoverableTools();
87
- }
88
- // Legacy MCP path — adapt DiscoverableMCPTool (with `description`) → DiscoverableTool.
89
- const legacy = session.getDiscoverableMCPTools?.() ?? [];
90
- return legacy.map(t => ({
91
- name: t.name,
92
- label: t.label,
93
- summary: t.description,
94
- source: "mcp" as const,
95
- serverName: t.serverName,
96
- mcpToolName: t.mcpToolName,
97
- schemaKeys: t.schemaKeys,
98
- }));
78
+ return session.getDiscoverableTools?.() ?? [];
99
79
  } catch {
100
80
  return [];
101
81
  }
@@ -103,15 +83,8 @@ function getDiscoverableToolsForDescription(session: ToolSession): DiscoverableT
103
83
 
104
84
  function getDiscoverableToolSearchIndexForExecution(session: ToolSession): DiscoverableToolSearchIndex {
105
85
  try {
106
- // Prefer generic cached index
107
- if (session.getDiscoverableToolSearchIndex) {
108
- const cached = session.getDiscoverableToolSearchIndex();
109
- if (cached) return cached;
110
- }
111
- // Legacy MCP: use cached MCP index. Its documents expose `tool.description` as well as
112
- // `tool.summary`, so it is structurally compatible with DiscoverableToolSearchIndex.
113
- const mcpCached = session.getDiscoverableMCPSearchIndex?.();
114
- if (mcpCached) return mcpCached as unknown as DiscoverableToolSearchIndex;
86
+ const cached = session.getDiscoverableToolSearchIndex?.();
87
+ if (cached) return cached;
115
88
  } catch {}
116
89
  return buildDiscoverableToolSearchIndex(getDiscoverableToolsForDescription(session));
117
90
  }
@@ -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)) {