@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
package/src/tools/irc.ts CHANGED
@@ -25,6 +25,7 @@ import ircDescription from "../prompts/tools/irc.md" with { type: "text" };
25
25
  import type { AgentRef, AgentRegistry } from "../registry/agent-registry";
26
26
  import type { ToolSession } from ".";
27
27
 
28
+ const DEFAULT_IRC_TIMEOUT_MS = 120_000;
28
29
  const ircSchema = z.object({
29
30
  op: z.enum(["send", "list"]).describe("irc operation"),
30
31
  to: z.string().optional().describe('recipient agent id or "all"'),
@@ -159,6 +160,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
159
160
 
160
161
  const awaitReply = params.awaitReply ?? !isBroadcast;
161
162
 
163
+ const timeoutMs = normalizeIrcTimeoutMs(this.session.settings.get("irc.timeoutMs"));
162
164
  const delivered: string[] = [];
163
165
  const replies: IrcReply[] = [];
164
166
  const failed: Array<{ id: string; error: string }> = [];
@@ -174,12 +176,18 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
174
176
  return;
175
177
  }
176
178
  try {
177
- const result = await targetSession.respondAsBackground({
178
- from: senderId,
179
- message,
180
- awaitReply,
179
+ const result = await runIrcDispatchWithTimeout(
180
+ timeoutMs,
181
181
  signal,
182
- });
182
+ timeoutSignal =>
183
+ targetSession.respondAsBackground({
184
+ from: senderId,
185
+ message,
186
+ awaitReply,
187
+ signal: timeoutSignal,
188
+ }),
189
+ target.id,
190
+ );
183
191
  delivered.push(target.id);
184
192
  if (awaitReply && result.replyText) {
185
193
  replies.push({ from: target.id, text: result.replyText });
@@ -237,3 +245,49 @@ function errorResult(text: string, details: IrcDetails): AgentToolResult<IrcDeta
237
245
  details,
238
246
  };
239
247
  }
248
+
249
+ function normalizeIrcTimeoutMs(value: number): number {
250
+ if (!Number.isFinite(value) || value === 0) return value === 0 ? 0 : DEFAULT_IRC_TIMEOUT_MS;
251
+ return Math.max(1, Math.trunc(value));
252
+ }
253
+
254
+ async function runIrcDispatchWithTimeout<T>(
255
+ timeoutMs: number,
256
+ parentSignal: AbortSignal | undefined,
257
+ run: (signal?: AbortSignal) => Promise<T>,
258
+ targetId: string,
259
+ ): Promise<T> {
260
+ if (timeoutMs <= 0) {
261
+ return await run(parentSignal);
262
+ }
263
+
264
+ const controller = new AbortController();
265
+ const timeoutError = new Error(`IRC timed out waiting for ${targetId} after ${timeoutMs} ms`);
266
+ let timeout: NodeJS.Timeout | undefined;
267
+ let parentAbortListener: (() => void) | undefined;
268
+
269
+ const timeoutDeferred = Promise.withResolvers<never>();
270
+ if (parentSignal) {
271
+ if (parentSignal.aborted) {
272
+ throw parentSignal.reason instanceof Error ? parentSignal.reason : new Error("IRC aborted");
273
+ }
274
+ parentAbortListener = () => {
275
+ controller.abort(parentSignal.reason);
276
+ timeoutDeferred.reject(parentSignal.reason instanceof Error ? parentSignal.reason : new Error("IRC aborted"));
277
+ };
278
+ parentSignal.addEventListener("abort", parentAbortListener, { once: true });
279
+ }
280
+
281
+ timeout = setTimeout(() => {
282
+ controller.abort(timeoutError);
283
+ timeoutDeferred.reject(timeoutError);
284
+ }, timeoutMs);
285
+ timeout.unref?.();
286
+
287
+ try {
288
+ return await Promise.race([run(controller.signal), timeoutDeferred.promise]);
289
+ } finally {
290
+ if (timeout) clearTimeout(timeout);
291
+ if (parentSignal && parentAbortListener) parentSignal.removeEventListener("abort", parentAbortListener);
292
+ }
293
+ }
@@ -1,12 +1,10 @@
1
- import { computeLineHash } from "../hashline/hash";
2
-
3
1
  /**
4
2
  * Format a single line of match output for grep/ast-grep style results.
5
3
  *
6
- * The anchor/content separator is always `|`. Matched lines are prefixed
7
- * with `*`; context lines are prefixed with a single space so anchors
8
- * align in column. In hashline mode the anchor is `LINE+ID` (no `#`); in
9
- * plain mode it is just the line number. Line numbers are never padded.
4
+ * Matched lines are prefixed with `*`; context lines are prefixed with a single
5
+ * space so line numbers align in column. In hashline mode the line uses the
6
+ * editable `LINE:content` shape under a file-hash header; in plain mode it keeps
7
+ * the legacy `LINE|content` display-only shape. Line numbers are never padded.
10
8
  */
11
9
  export function formatMatchLine(
12
10
  lineNumber: number,
@@ -16,7 +14,7 @@ export function formatMatchLine(
16
14
  ): string {
17
15
  const marker = isMatch ? "*" : " ";
18
16
  if (options.useHashLines) {
19
- return `${marker}${lineNumber}${computeLineHash(lineNumber, line)}|${line}`;
17
+ return `${marker}${lineNumber}:${line}`;
20
18
  }
21
19
  return `${marker}${lineNumber}|${line}`;
22
20
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared output-schema validation for subagent yield + executor finalization.
3
+ *
4
+ * Both the in-process `yield` tool (subagent side) and the executor's post-mortem
5
+ * finalize path (parent side) need to validate yield payloads against the agent's
6
+ * declared output schema. This module is the single source of truth for that
7
+ * pipeline — keeping the two callsites in lockstep so a schema accepted in-tool
8
+ * cannot be rejected post-mortem (or vice versa).
9
+ */
10
+ import {
11
+ isValidJsonSchema,
12
+ type JsonSchemaValidationIssue,
13
+ type JsonSchemaValidationResult,
14
+ validateJsonSchemaValue,
15
+ } from "@oh-my-pi/pi-ai/utils/schema";
16
+ import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
17
+
18
+ /** A validator bound to a specific output schema. */
19
+ export interface OutputValidator {
20
+ /** Run JSON Schema validation; returns the raw `success`/`issues` shape so callers may inspect every failure. */
21
+ validate(value: unknown): JsonSchemaValidationResult;
22
+ /** Top-level required property names. Empty if the schema has no `required` array at root. */
23
+ readonly requiredFields: readonly string[];
24
+ }
25
+
26
+ export interface BuildOutputValidatorResult {
27
+ /** Present when the schema produced a usable validator (i.e. constraining schemas). Absent for missing/unconstrained schemas. */
28
+ validator?: OutputValidator;
29
+ /** Raw JSON Schema produced by `jtdToJsonSchema`. Available alongside the validator so callers can derive related artifacts (strict-mode probe, dereference, hint text). */
30
+ jsonSchema?: Record<string, unknown>;
31
+ /**
32
+ * Normalized schema (post-`normalizeSchema`). Surfaced so callers can distinguish
33
+ * "no schema provided" (`undefined`) from "intentionally unconstrained" (`true`)
34
+ * when both produce no validator.
35
+ */
36
+ normalized?: unknown;
37
+ /** Set when the schema cannot be used. Callers should treat this as a "no validation" case (loose acceptance) and surface the message in diagnostics. */
38
+ error?: string;
39
+ }
40
+
41
+ /**
42
+ * Build the canonical validator for a JTD-or-JSON-Schema output declaration.
43
+ *
44
+ * Returns:
45
+ * - `{ validator, jsonSchema, normalized }` for constraining schemas — both callers use this path.
46
+ * - `{ normalized: true }` for an intentionally unconstrained schema (the JSON Schema literal `true`).
47
+ * No validator, but distinguishable from "no schema provided".
48
+ * - `{}` for an absent schema (`undefined`).
49
+ * - `{ error, normalized? }` when the schema cannot be honored (invalid syntax, `false`, malformed JTD).
50
+ */
51
+ export function buildOutputValidator(schema: unknown): BuildOutputValidatorResult {
52
+ const { normalized, error: normalizeError } = normalizeSchema(schema);
53
+ if (normalizeError) return { error: normalizeError, normalized };
54
+ if (normalized === undefined) return {};
55
+ if (normalized === false) return { error: "boolean false schema rejects all outputs", normalized };
56
+ if (normalized === true) return { normalized };
57
+
58
+ const jsonSchema = jtdToJsonSchema(normalized);
59
+ if (jsonSchema === undefined) return { normalized };
60
+ if (jsonSchema === false) return { error: "boolean false schema rejects all outputs", normalized };
61
+ if (jsonSchema === true) return { normalized };
62
+ if (typeof jsonSchema !== "object" || Array.isArray(jsonSchema)) {
63
+ return { error: "invalid JSON schema", normalized };
64
+ }
65
+ if (!isValidJsonSchema(jsonSchema)) return { error: "invalid JSON schema", normalized };
66
+
67
+ const jsonSchemaRecord = jsonSchema as Record<string, unknown>;
68
+ const required = extractRequiredFields(jsonSchemaRecord);
69
+ return {
70
+ normalized,
71
+ jsonSchema: jsonSchemaRecord,
72
+ validator: {
73
+ requiredFields: required,
74
+ validate: value => validateJsonSchemaValue(jsonSchemaRecord, value),
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Produce the executor's headline+missing-required summary from a failed validation. */
80
+ export function summarizeValidationFailure(
81
+ result: JsonSchemaValidationResult,
82
+ value: unknown,
83
+ requiredFields: readonly string[],
84
+ ): { message: string; missingRequired: string[] } {
85
+ if (result.success) return { message: "", missingRequired: [] };
86
+ const missing = computeMissingRequired(requiredFields, value);
87
+ const message = formatValidationIssueHeadline(result.issues[0]) ?? "schema validation failed";
88
+ return { message, missingRequired: missing };
89
+ }
90
+
91
+ export function extractRequiredFields(jsonSchema: unknown): string[] {
92
+ if (!jsonSchema || typeof jsonSchema !== "object") return [];
93
+ const required = (jsonSchema as { required?: unknown }).required;
94
+ return Array.isArray(required) ? required.filter((k): k is string => typeof k === "string") : [];
95
+ }
96
+
97
+ export function computeMissingRequired(required: readonly string[], value: unknown): string[] {
98
+ if (required.length === 0) return [];
99
+ if (value === null || value === undefined) return [...required];
100
+ if (typeof value !== "object" || Array.isArray(value)) return [];
101
+ const record = value as Record<string, unknown>;
102
+ return required.filter(key => !(key in record) || record[key] === undefined);
103
+ }
104
+
105
+ /**
106
+ * Format a single validation issue as `path.with.dots: message`.
107
+ *
108
+ * Used by the executor's post-mortem `schema_violation` headline — one line, dot-separated path,
109
+ * since the executor's error format already lists missing-required fields separately.
110
+ */
111
+ export function formatValidationIssueHeadline(issue: JsonSchemaValidationIssue | undefined): string | undefined {
112
+ if (!issue) return undefined;
113
+ const path = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
114
+ return `${path}: ${issue.message}`;
115
+ }
116
+
117
+ /**
118
+ * Format every validation issue as `path/with/slashes: message; ...`.
119
+ *
120
+ * Used by the yield tool's model-facing retry feedback — the model gets every problem at once so it
121
+ * can fix the entire output in one retry instead of iterating issue-by-issue. The slash separator
122
+ * mirrors JSON Pointer convention and disambiguates against fields whose names contain dots.
123
+ */
124
+ export function formatAllValidationIssues(issues: ReadonlyArray<JsonSchemaValidationIssue> | undefined): string {
125
+ if (!issues || issues.length === 0) return "Unknown schema validation error.";
126
+ return issues
127
+ .map(issue => {
128
+ const path = issue.path.length === 0 ? "" : `${issue.path.map(seg => String(seg)).join("/")}: `;
129
+ return `${path}${issue.message}`;
130
+ })
131
+ .join("; ");
132
+ }
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
  }