@oh-my-pi/pi-coding-agent 15.3.1 → 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 (200) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/dist/types/cli/auth-gateway-cli.d.ts +1 -1
  3. package/dist/types/cli/file-processor.d.ts +1 -1
  4. package/dist/types/config/settings-schema.d.ts +45 -3
  5. package/dist/types/config/settings.d.ts +1 -1
  6. package/dist/types/debug/raw-sse.d.ts +2 -0
  7. package/dist/types/edit/file-read-cache.d.ts +15 -4
  8. package/dist/types/edit/index.d.ts +3 -8
  9. package/dist/types/edit/renderer.d.ts +1 -2
  10. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  11. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  12. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  13. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  14. package/dist/types/eval/py/executor.d.ts +1 -2
  15. package/dist/types/eval/py/kernel.d.ts +6 -0
  16. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  17. package/dist/types/eval/session-id.d.ts +3 -0
  18. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  19. package/dist/types/hashline/anchors.d.ts +15 -9
  20. package/dist/types/hashline/constants.d.ts +0 -2
  21. package/dist/types/hashline/diff.d.ts +1 -2
  22. package/dist/types/hashline/executor.d.ts +52 -0
  23. package/dist/types/hashline/hash.d.ts +44 -93
  24. package/dist/types/hashline/index.d.ts +2 -1
  25. package/dist/types/hashline/input.d.ts +2 -9
  26. package/dist/types/hashline/recovery.d.ts +3 -9
  27. package/dist/types/hashline/tokenizer.d.ts +91 -0
  28. package/dist/types/hashline/types.d.ts +5 -7
  29. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  30. package/dist/types/modes/types.d.ts +1 -0
  31. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  32. package/dist/types/sdk.d.ts +2 -0
  33. package/dist/types/session/agent-session.d.ts +11 -15
  34. package/dist/types/session/agent-storage.d.ts +11 -10
  35. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  36. package/dist/types/slash-commands/types.d.ts +0 -5
  37. package/dist/types/task/executor.d.ts +2 -0
  38. package/dist/types/task/types.d.ts +8 -0
  39. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  40. package/dist/types/tools/index.d.ts +2 -8
  41. package/dist/types/tools/match-line-format.d.ts +4 -4
  42. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  43. package/dist/types/tools/review.d.ts +13 -0
  44. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  45. package/dist/types/tools/search.d.ts +4 -3
  46. package/dist/types/utils/edit-mode.d.ts +1 -1
  47. package/dist/types/web/kagi.d.ts +4 -2
  48. package/dist/types/web/parallel.d.ts +4 -3
  49. package/dist/types/web/scrapers/types.d.ts +2 -1
  50. package/dist/types/web/search/index.d.ts +12 -4
  51. package/dist/types/web/search/provider.d.ts +2 -1
  52. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  53. package/dist/types/web/search/providers/base.d.ts +34 -2
  54. package/dist/types/web/search/providers/brave.d.ts +8 -1
  55. package/dist/types/web/search/providers/codex.d.ts +13 -9
  56. package/dist/types/web/search/providers/exa.d.ts +10 -1
  57. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  58. package/dist/types/web/search/providers/jina.d.ts +2 -1
  59. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  60. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  61. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  62. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  63. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  64. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  65. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  66. package/dist/types/web/search/providers/utils.d.ts +8 -6
  67. package/dist/types/web/search/providers/zai.d.ts +12 -3
  68. package/package.json +7 -7
  69. package/src/cli/auth-gateway-cli.ts +71 -2
  70. package/src/cli/file-processor.ts +12 -2
  71. package/src/cli.ts +0 -8
  72. package/src/commands/auth-gateway.ts +2 -0
  73. package/src/commands/commit.ts +8 -8
  74. package/src/config/prompt-templates.ts +6 -6
  75. package/src/config/settings-schema.ts +47 -3
  76. package/src/config/settings.ts +5 -5
  77. package/src/debug/raw-sse.ts +68 -3
  78. package/src/edit/file-read-cache.ts +68 -25
  79. package/src/edit/index.ts +6 -37
  80. package/src/edit/renderer.ts +9 -47
  81. package/src/edit/streaming.ts +43 -56
  82. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  83. package/src/eval/js/context-manager.ts +64 -53
  84. package/src/eval/js/shared/local-module-loader.ts +265 -0
  85. package/src/eval/js/shared/prelude.txt +4 -0
  86. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  87. package/src/eval/js/shared/runtime.ts +129 -86
  88. package/src/eval/js/worker-core.ts +23 -38
  89. package/src/eval/py/executor.ts +155 -84
  90. package/src/eval/py/kernel.ts +10 -1
  91. package/src/eval/py/prelude.py +22 -24
  92. package/src/eval/py/runner.py +203 -85
  93. package/src/eval/py/tool-bridge.ts +17 -10
  94. package/src/eval/session-id.ts +8 -0
  95. package/src/exec/bash-executor.ts +27 -16
  96. package/src/extensibility/extensions/runner.ts +0 -1
  97. package/src/extensibility/extensions/types.ts +1 -3
  98. package/src/extensibility/plugins/marketplace/manager.ts +20 -1
  99. package/src/hashline/anchors.ts +56 -65
  100. package/src/hashline/apply.ts +29 -31
  101. package/src/hashline/constants.ts +0 -3
  102. package/src/hashline/diff-preview.ts +4 -5
  103. package/src/hashline/diff.ts +30 -4
  104. package/src/hashline/execute.ts +91 -26
  105. package/src/hashline/executor.ts +239 -0
  106. package/src/hashline/grammar.lark +12 -10
  107. package/src/hashline/hash.ts +69 -114
  108. package/src/hashline/index.ts +2 -1
  109. package/src/hashline/input.ts +48 -41
  110. package/src/hashline/prefixes.ts +21 -11
  111. package/src/hashline/recovery.ts +63 -71
  112. package/src/hashline/stream.ts +2 -2
  113. package/src/hashline/tokenizer.ts +467 -0
  114. package/src/hashline/types.ts +6 -8
  115. package/src/internal-urls/docs-index.generated.ts +9 -8
  116. package/src/lsp/config.ts +87 -22
  117. package/src/modes/components/extensions/types.ts +0 -5
  118. package/src/modes/components/session-observer-overlay.ts +11 -2
  119. package/src/modes/components/tree-selector.ts +10 -2
  120. package/src/modes/controllers/command-controller.ts +1 -3
  121. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  122. package/src/modes/controllers/selector-controller.ts +5 -5
  123. package/src/modes/types.ts +4 -1
  124. package/src/modes/utils/ui-helpers.ts +4 -0
  125. package/src/prompts/agents/explore.md +1 -1
  126. package/src/prompts/tools/ast-edit.md +1 -1
  127. package/src/prompts/tools/ast-grep.md +1 -1
  128. package/src/prompts/tools/eval.md +1 -1
  129. package/src/prompts/tools/hashline.md +73 -94
  130. package/src/prompts/tools/read.md +4 -4
  131. package/src/prompts/tools/search.md +3 -3
  132. package/src/sdk.ts +21 -24
  133. package/src/session/agent-session.ts +59 -66
  134. package/src/session/agent-storage.ts +13 -14
  135. package/src/slash-commands/acp-builtins.ts +3 -3
  136. package/src/slash-commands/types.ts +0 -6
  137. package/src/task/executor.ts +55 -57
  138. package/src/task/index.ts +8 -4
  139. package/src/task/render.ts +53 -1
  140. package/src/task/types.ts +8 -0
  141. package/src/tool-discovery/tool-index.ts +0 -134
  142. package/src/tools/ast-edit.ts +36 -13
  143. package/src/tools/ast-grep.ts +45 -4
  144. package/src/tools/browser/tab-worker.ts +3 -2
  145. package/src/tools/eval.ts +2 -1
  146. package/src/tools/fetch.ts +23 -14
  147. package/src/tools/index.ts +2 -8
  148. package/src/tools/irc.ts +59 -5
  149. package/src/tools/jtd-to-json-schema.ts +5 -1
  150. package/src/tools/match-line-format.ts +5 -7
  151. package/src/tools/output-schema-validator.ts +132 -0
  152. package/src/tools/read.ts +142 -63
  153. package/src/tools/review.ts +23 -0
  154. package/src/tools/search-tool-bm25.ts +3 -30
  155. package/src/tools/search.ts +48 -16
  156. package/src/tools/write.ts +3 -3
  157. package/src/tools/yield.ts +32 -41
  158. package/src/utils/edit-mode.ts +1 -2
  159. package/src/utils/file-mentions.ts +2 -2
  160. package/src/web/kagi.ts +15 -6
  161. package/src/web/parallel.ts +9 -6
  162. package/src/web/scrapers/types.ts +7 -1
  163. package/src/web/scrapers/youtube.ts +13 -7
  164. package/src/web/search/index.ts +37 -11
  165. package/src/web/search/provider.ts +5 -3
  166. package/src/web/search/providers/anthropic.ts +30 -21
  167. package/src/web/search/providers/base.ts +35 -2
  168. package/src/web/search/providers/brave.ts +4 -4
  169. package/src/web/search/providers/codex.ts +118 -89
  170. package/src/web/search/providers/exa.ts +3 -2
  171. package/src/web/search/providers/gemini.ts +58 -155
  172. package/src/web/search/providers/jina.ts +4 -4
  173. package/src/web/search/providers/kagi.ts +17 -11
  174. package/src/web/search/providers/kimi.ts +29 -13
  175. package/src/web/search/providers/parallel.ts +171 -23
  176. package/src/web/search/providers/perplexity.ts +38 -37
  177. package/src/web/search/providers/searxng.ts +3 -1
  178. package/src/web/search/providers/synthetic.ts +16 -19
  179. package/src/web/search/providers/tavily.ts +23 -18
  180. package/src/web/search/providers/utils.ts +11 -17
  181. package/src/web/search/providers/zai.ts +16 -8
  182. package/dist/types/hashline/parser.d.ts +0 -7
  183. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  184. package/dist/types/tools/vim.d.ts +0 -58
  185. package/dist/types/vim/buffer.d.ts +0 -41
  186. package/dist/types/vim/commands.d.ts +0 -6
  187. package/dist/types/vim/engine.d.ts +0 -47
  188. package/dist/types/vim/parser.d.ts +0 -3
  189. package/dist/types/vim/render.d.ts +0 -25
  190. package/dist/types/vim/types.d.ts +0 -182
  191. package/src/hashline/parser.ts +0 -212
  192. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  193. package/src/prompts/tools/vim.md +0 -98
  194. package/src/tools/vim.ts +0 -949
  195. package/src/vim/buffer.ts +0 -309
  196. package/src/vim/commands.ts +0 -382
  197. package/src/vim/engine.ts +0 -2409
  198. package/src/vim/parser.ts +0 -134
  199. package/src/vim/render.ts +0 -252
  200. 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
+ }
@@ -180,7 +180,11 @@ function normalizeMixedSchemaNode(schema: unknown): unknown {
180
180
  }
181
181
 
182
182
  if (isJTDSchema(schema)) {
183
- return normalizeMixedSchemaNode(convertSchema(schema));
183
+ // `convertSchema` is itself fully recursive and emits pure JSON Schema, so
184
+ // re-walking the result with `normalizeMixedSchemaNode` is unnecessary and
185
+ // unsafe: it would treat user-named properties whose keys happen to be JTD
186
+ // keywords (e.g. `ref`, `elements`) as nested JTD forms (#1345).
187
+ return convertSchema(schema);
184
188
  }
185
189
 
186
190
  const normalized: Record<string, unknown> = {};
@@ -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,27 +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
- truncatedLines?: ReadonlySet<number>,
122
159
  ): string {
123
- if (shouldAddHashLines) {
124
- if (!truncatedLines || truncatedLines.size === 0) return formatHashLines(text, startNum);
125
- // Column-truncated lines hash differently from the on-disk line that the
126
- // edit verifier reads back. Drop the anchor (`LINE|TEXT` instead of
127
- // `LINE+HASH|TEXT`) so the model treats the line as un-anchorable rather
128
- // than copying a hash that will be rejected as stale.
129
- const lines = text.split("\n");
130
- return lines
131
- .map((line, i) => {
132
- const ln = startNum + i;
133
- return truncatedLines.has(ln) ? `${ln}${HL_BODY_SEP}${line}` : formatHashLine(ln, line);
134
- })
135
- .join("\n");
136
- }
160
+ if (shouldAddHashLines) return formatNumberedLines(text, startNum);
137
161
  if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
138
162
  return text;
139
163
  }
@@ -164,7 +188,7 @@ function formatSingleLine(
164
188
  shouldAddHashLines: boolean,
165
189
  shouldAddLineNumbers: boolean,
166
190
  ): string {
167
- if (shouldAddHashLines) return formatHashLine(line, text);
191
+ if (shouldAddHashLines) return formatNumberedLine(line, text);
168
192
  if (shouldAddLineNumbers) return `${line}|${text}`;
169
193
  return text;
170
194
  }
@@ -179,9 +203,7 @@ function formatMergedBraceLine(
179
203
  ): { model: string; display: string } {
180
204
  const merged = `${headText.trimEnd()} .. ${tailText.trim()}`;
181
205
  if (shouldAddHashLines) {
182
- const start = formatLineHash(startLine, headText);
183
- const end = formatLineHash(endLine, tailText);
184
- return { model: `${start}-${end}${HL_BODY_SEP}${merged}`, display: merged };
206
+ return { model: `${startLine}-${endLine}:${merged}`, display: merged };
185
207
  }
186
208
  if (shouldAddLineNumbers) {
187
209
  return { model: `${startLine}-${endLine}|${merged}`, display: merged };
@@ -194,17 +216,38 @@ function countTextLines(text: string): number {
194
216
  return text.split("\n").length;
195
217
  }
196
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
+
197
228
  /**
198
229
  * Footer appended to summarized reads telling the model how to recover the
199
230
  * elided body. Without this hint, agents either ignore the `...`/`{ .. }`
200
- * 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.
201
235
  */
202
- function formatSummaryElisionFooter(readPath: string, elidedSpans: number, elidedLines: number): string {
203
- if (elidedSpans <= 0) return "";
204
- 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 "";
205
242
  const lineWord = elidedLines === 1 ? "line" : "lines";
206
- const linePart = elidedLines > 0 ? `${elidedLines} ${lineWord} across ` : "";
207
- 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}]`;
208
251
  }
209
252
  const READ_CHUNK_SIZE = 8 * 1024;
210
253
 
@@ -858,9 +901,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
858
901
 
859
902
  const shouldAddHashLines = displayMode.hashLines;
860
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;
861
910
  const formatText = (content: string, startNum: number): string => {
862
911
  details.displayContent = { text: content, startLine: startNum };
863
- 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);
864
916
  };
865
917
 
866
918
  let outputText: string;
@@ -876,7 +928,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
876
928
  if (shouldAddHashLines) {
877
929
  outputText = `[Line ${startLineDisplay} is ${formatBytes(
878
930
  firstLineBytes,
879
- )}, 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.]`;
880
932
  } else {
881
933
  outputText = formatText(snippet.text, startLineDisplay);
882
934
  }
@@ -942,6 +994,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
942
994
  const totalLines = allLines.length;
943
995
  const shouldAddHashLines = displayMode.hashLines;
944
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;
945
1003
 
946
1004
  const resultBuilder = toolResult(details);
947
1005
  if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
@@ -957,7 +1015,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
957
1015
  }
958
1016
  const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
959
1017
  const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n");
960
- 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;
961
1021
  }
962
1022
 
963
1023
  const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : "";
@@ -1016,6 +1076,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1016
1076
 
1017
1077
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1018
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;
1019
1084
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1020
1085
 
1021
1086
  const blocks: string[] = [];
@@ -1045,32 +1110,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1045
1110
  }
1046
1111
 
1047
1112
  const collectedLines = streamResult.lines;
1048
- const truncatedLineNumbers = new Set<number>();
1049
1113
  if (!rawSelector && maxColumns > 0) {
1050
1114
  for (let i = 0; i < collectedLines.length; i++) {
1051
1115
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1052
1116
  if (wasTruncated) {
1053
1117
  collectedLines[i] = text;
1054
1118
  columnTruncated = maxColumns;
1055
- truncatedLineNumbers.add(range.startLine + i);
1056
1119
  }
1057
1120
  }
1058
1121
  }
1059
1122
 
1060
1123
  if (collectedLines.length > 0) {
1061
- 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
+ );
1062
1130
  }
1063
1131
 
1064
1132
  const blockText = collectedLines.join("\n");
1065
- blocks.push(
1066
- formatTextWithMode(
1067
- blockText,
1068
- range.startLine,
1069
- shouldAddHashLines,
1070
- shouldAddLineNumbers,
1071
- truncatedLineNumbers,
1072
- ),
1073
- );
1133
+ const formatted = formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers);
1134
+ blocks.push(hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted);
1135
+ if (hashContext) emittedHashlineHeader = true;
1074
1136
  }
1075
1137
 
1076
1138
  let outputText = blocks.join("\n\n…\n\n");
@@ -1359,7 +1421,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1359
1421
  #renderSummary(summary: SummaryResult): {
1360
1422
  text: string;
1361
1423
  displayText: string;
1362
- elidedSpans: number;
1424
+ elidedRanges: ElidedRange[];
1363
1425
  elidedLines: number;
1364
1426
  } {
1365
1427
  const displayMode = resolveFileDisplayMode(this.session);
@@ -1420,13 +1482,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1420
1482
 
1421
1483
  const modelParts: string[] = [];
1422
1484
  const displayParts: string[] = [];
1423
- let elidedSpans = 0;
1485
+ const elidedRanges: ElidedRange[] = [];
1424
1486
  let elidedLines = 0;
1425
1487
  for (const unit of units) {
1426
1488
  if (unit.kind === "elided") {
1427
1489
  modelParts.push("...");
1428
1490
  displayParts.push("...");
1429
- elidedSpans++;
1491
+ elidedRanges.push({ start: unit.startLine, end: unit.endLine });
1430
1492
  elidedLines += unit.endLine - unit.startLine + 1;
1431
1493
  continue;
1432
1494
  }
@@ -1441,7 +1503,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1441
1503
  );
1442
1504
  modelParts.push(formatted.model);
1443
1505
  displayParts.push(formatted.display);
1444
- 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 });
1445
1509
  // Merged brace pair encloses (start+1)..(end-1) as elided.
1446
1510
  elidedLines += Math.max(0, unit.endLine - unit.startLine - 1);
1447
1511
  continue;
@@ -1450,7 +1514,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1450
1514
  displayParts.push(unit.text);
1451
1515
  }
1452
1516
 
1453
- return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans, elidedLines };
1517
+ return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedRanges, elidedLines };
1454
1518
  }
1455
1519
 
1456
1520
  async execute(
@@ -1698,15 +1762,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1698
1762
  const renderedSummary = this.#renderSummary(summary);
1699
1763
  const footer = formatSummaryElisionFooter(
1700
1764
  localReadPath,
1701
- renderedSummary.elidedSpans,
1765
+ renderedSummary.elidedRanges,
1702
1766
  renderedSummary.elidedLines,
1703
1767
  );
1704
- 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);
1705
1774
  details = {
1706
1775
  displayContent: { text: renderedSummary.displayText, startLine: 1 },
1707
1776
  summary: {
1708
1777
  lines: countTextLines(renderedSummary.text),
1709
- elidedSpans: renderedSummary.elidedSpans,
1778
+ elidedSpans: renderedSummary.elidedRanges.length,
1710
1779
  elidedLines: renderedSummary.elidedLines,
1711
1780
  },
1712
1781
  };
@@ -1814,14 +1883,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1814
1883
  // view — column truncation surfaces separately via `.limits()`.
1815
1884
  const rawSelector = isRawSelector(parsed);
1816
1885
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1817
- const truncatedLineNumbers = new Set<number>();
1818
1886
  if (!rawSelector && maxColumns > 0) {
1819
1887
  for (let i = 0; i < collectedLines.length; i++) {
1820
1888
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1821
1889
  if (wasTruncated) {
1822
1890
  collectedLines[i] = text;
1823
1891
  columnTruncated = maxColumns;
1824
- truncatedLineNumbers.add(startLineDisplay + i);
1825
1892
  }
1826
1893
  }
1827
1894
  }
@@ -1846,22 +1913,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1846
1913
  firstLineExceedsLimit,
1847
1914
  };
1848
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
+
1849
1922
  if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1850
- 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
+ );
1851
1929
  }
1852
1930
 
1853
- const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1854
- const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1855
1931
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
1932
+ let emittedHashlineHeader = false;
1856
1933
  const formatText = (text: string, startNum: number): string => {
1857
1934
  capturedDisplayContent = { text, startLine: startNum };
1858
- return formatTextWithMode(
1859
- text,
1860
- startNum,
1861
- shouldAddHashLines,
1862
- shouldAddLineNumbers,
1863
- truncatedLineNumbers,
1864
- );
1935
+ const formatted = formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1936
+ if (!hashContext || emittedHashlineHeader) return formatted;
1937
+ emittedHashlineHeader = true;
1938
+ return prependHashlineHeader(formatted, hashContext);
1865
1939
  };
1866
1940
 
1867
1941
  let outputText: string;
@@ -1873,7 +1947,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1873
1947
  if (shouldAddHashLines) {
1874
1948
  outputText = `[Line ${startLineDisplay} is ${formatBytes(
1875
1949
  firstLineBytes,
1876
- )}, 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.]`;
1877
1951
  } else {
1878
1952
  outputText = formatText(snippet.text, startLineDisplay);
1879
1953
  }
@@ -1996,7 +2070,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1996
2070
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
1997
2071
 
1998
2072
  const rawText = region.lines.join("\n");
1999
- 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);
2000
2079
 
2001
2080
  const details: ReadToolDetails = {
2002
2081
  resolvedPath: entry.absolutePath,