@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
@@ -0,0 +1,467 @@
1
+ import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants";
2
+ import {
3
+ describeAnchorExamples,
4
+ HL_FILE_HASH_SEP,
5
+ HL_FILE_PREFIX,
6
+ HL_OP_DELETE,
7
+ HL_OP_INSERT_AFTER,
8
+ HL_OP_INSERT_BEFORE,
9
+ HL_OP_REPLACE,
10
+ } from "./hash";
11
+ import type { Anchor, HashlineCursor } from "./types";
12
+
13
+ const CHAR_LINE_FEED = 10;
14
+ const CHAR_CARRIAGE_RETURN = 13;
15
+ const CHAR_ZERO = 48;
16
+ const CHAR_NINE = 57;
17
+ const CHAR_HASH = 35;
18
+ const CHAR_TAB = 9;
19
+ const CHAR_SPACE = 32;
20
+ const CHAR_LOWER_A = 97;
21
+ const CHAR_LOWER_F = 102;
22
+ const CHAR_PILCROW = HL_FILE_PREFIX.charCodeAt(0);
23
+ const FILE_HASH_LENGTH = 4;
24
+
25
+ function isDigitCode(code: number): boolean {
26
+ return code >= CHAR_ZERO && code <= CHAR_NINE;
27
+ }
28
+
29
+ function isNonZeroDigitCode(code: number): boolean {
30
+ return code > CHAR_ZERO && code <= CHAR_NINE;
31
+ }
32
+
33
+ function isDecorationCode(code: number): boolean {
34
+ return code === 42 || code === 43 || code === 45 || code === 62;
35
+ }
36
+
37
+ function isHexDigitCode(code: number): boolean {
38
+ return isDigitCode(code) || (code >= CHAR_LOWER_A && code <= CHAR_LOWER_F);
39
+ }
40
+
41
+ function skipWhitespace(line: string, index: number, end = line.length): number {
42
+ return end - line.slice(index, end).trimStart().length;
43
+ }
44
+
45
+ function trimEndIndex(line: string): number {
46
+ return line.trimEnd().length;
47
+ }
48
+
49
+ function isEmptyLine(line: string): boolean {
50
+ return line.length === 0;
51
+ }
52
+
53
+ function markerLineEquals(line: string, marker: string): boolean {
54
+ return line.trimEnd() === marker;
55
+ }
56
+
57
+ /**
58
+ * Split a hashline diff into individual lines without losing the trailing
59
+ * empty line that callers may rely on for explicit blank payloads. CRLF pairs
60
+ * are normalized to a single line break.
61
+ *
62
+ * This mirrors the line-splitting performed by {@link HashlineTokenizer}'s
63
+ * streaming drain loop and is kept for non-streaming callers that prefer
64
+ * a single-shot split.
65
+ */
66
+ export function splitHashlineLines(text: string): string[] {
67
+ if (text.length === 0) return [""];
68
+
69
+ const lines: string[] = [];
70
+ let start = 0;
71
+ for (let index = 0; index < text.length; index++) {
72
+ if (text.charCodeAt(index) !== CHAR_LINE_FEED) continue;
73
+ let end = index;
74
+ if (end > start && text.charCodeAt(end - 1) === CHAR_CARRIAGE_RETURN) end--;
75
+ lines.push(text.slice(start, end));
76
+ start = index + 1;
77
+ }
78
+
79
+ if (start < text.length) {
80
+ let end = text.length;
81
+ if (end > start && text.charCodeAt(end - 1) === CHAR_CARRIAGE_RETURN) end--;
82
+ lines.push(text.slice(start, end));
83
+ }
84
+ return lines;
85
+ }
86
+
87
+ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
88
+ if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
89
+ if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
90
+ return cursor;
91
+ }
92
+
93
+ // Leniently accept anchors copied from read/search output:
94
+ // - optional leading line-marker decoration (`*`, `>`, `+`, `-`)
95
+ // - the required bare line number
96
+ function skipDecoratedAnchorPrefix(line: string, end = trimEndIndex(line)): number {
97
+ let index = skipWhitespace(line, 0, end);
98
+ while (index < end && isDecorationCode(line.charCodeAt(index))) index++;
99
+ return skipWhitespace(line, index, end);
100
+ }
101
+
102
+ interface NumberScan {
103
+ line: number;
104
+ nextIndex: number;
105
+ }
106
+
107
+ function scanLineNumber(line: string, index: number, end: number): NumberScan | null {
108
+ if (index >= end || !isNonZeroDigitCode(line.charCodeAt(index))) return null;
109
+
110
+ let lineNumber = 0;
111
+ let nextIndex = index;
112
+ while (nextIndex < end) {
113
+ const code = line.charCodeAt(nextIndex);
114
+ if (!isDigitCode(code)) break;
115
+ lineNumber = lineNumber * 10 + (code - CHAR_ZERO);
116
+ nextIndex++;
117
+ }
118
+ return { line: lineNumber, nextIndex };
119
+ }
120
+
121
+ /** Parse a bare line-number anchor (used by insert ops). Throws on malformed input. */
122
+ export function parseLid(raw: string, lineNum: number): Anchor {
123
+ const end = trimEndIndex(raw);
124
+ const numberStart = skipDecoratedAnchorPrefix(raw, end);
125
+ const number = scanLineNumber(raw, numberStart, end);
126
+ if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) {
127
+ throw new Error(
128
+ `line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` +
129
+ `got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash from your latest read for file-version binding.`,
130
+ );
131
+ }
132
+ return { line: number.line };
133
+ }
134
+
135
+ export interface ParsedRange {
136
+ start: Anchor;
137
+ end: Anchor;
138
+ }
139
+
140
+ interface RangeScan {
141
+ range: ParsedRange;
142
+ nextIndex: number;
143
+ }
144
+
145
+ function scanRange(line: string, end = trimEndIndex(line)): RangeScan | null {
146
+ const numberStart = skipDecoratedAnchorPrefix(line, end);
147
+ const start = scanLineNumber(line, numberStart, end);
148
+ if (start === null) return null;
149
+
150
+ let nextIndex = start.nextIndex;
151
+ let rangeEnd = start.line;
152
+ if (nextIndex < end && line.charCodeAt(nextIndex) === 45) {
153
+ const endNumber = scanLineNumber(line, nextIndex + 1, end);
154
+ if (endNumber === null) return null;
155
+ rangeEnd = endNumber.line;
156
+ nextIndex = endNumber.nextIndex;
157
+ }
158
+
159
+ return {
160
+ range: { start: { line: start.line }, end: { line: rangeEnd } },
161
+ nextIndex: skipWhitespace(line, nextIndex, end),
162
+ };
163
+ }
164
+
165
+ function startsWithWord(line: string, index: number, end: number, word: string): boolean {
166
+ if (index + word.length > end) return false;
167
+ for (let offset = 0; offset < word.length; offset++) {
168
+ if (line.charCodeAt(index + offset) !== word.charCodeAt(offset)) return false;
169
+ }
170
+ return true;
171
+ }
172
+
173
+ function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after"): HashlineCursor {
174
+ const end = trimEndIndex(raw);
175
+ const targetStart = skipDecoratedAnchorPrefix(raw, end);
176
+
177
+ if (startsWithWord(raw, targetStart, end, "BOF") && skipWhitespace(raw, targetStart + 3, end) === end) {
178
+ return { kind: "bof" };
179
+ }
180
+ if (startsWithWord(raw, targetStart, end, "EOF") && skipWhitespace(raw, targetStart + 3, end) === end) {
181
+ return { kind: "eof" };
182
+ }
183
+
184
+ const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
185
+ return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
186
+ }
187
+
188
+ function scanInlineBody(line: string, index: number): string | undefined {
189
+ const end = trimEndIndex(line);
190
+ return index < end ? line.slice(index, end) : undefined;
191
+ }
192
+
193
+ interface ParsedInsertOp {
194
+ kind: "insert";
195
+ cursor: HashlineCursor;
196
+ inlineBody: string | undefined;
197
+ }
198
+
199
+ interface ParsedReplaceOp {
200
+ kind: "replace";
201
+ range: ParsedRange;
202
+ inlineBody: string | undefined;
203
+ }
204
+
205
+ interface ParsedDeleteOp {
206
+ kind: "delete";
207
+ range: ParsedRange;
208
+ trailingPayload: boolean;
209
+ }
210
+
211
+ type ParsedOp = ParsedInsertOp | ParsedReplaceOp | ParsedDeleteOp;
212
+
213
+ function tryParseInsertOp(line: string, sigil: string, kind: "before" | "after"): ParsedInsertOp | null {
214
+ const end = trimEndIndex(line);
215
+ const targetStart = skipDecoratedAnchorPrefix(line, end);
216
+
217
+ let targetEnd: number;
218
+ if (startsWithWord(line, targetStart, end, "BOF") || startsWithWord(line, targetStart, end, "EOF")) {
219
+ targetEnd = targetStart + 3;
220
+ } else {
221
+ const anchor = scanLineNumber(line, targetStart, end);
222
+ if (anchor === null) return null;
223
+ targetEnd = anchor.nextIndex;
224
+ }
225
+
226
+ const opIndex = skipWhitespace(line, targetEnd, end);
227
+ if (opIndex >= end || line[opIndex] !== sigil) return null;
228
+
229
+ // parseInsertTarget can only throw on inputs that already passed the
230
+ // BOF/EOF/line-number scan above, but guard the throw anyway — the
231
+ // tokenizer contract forbids it and a future refactor of the prefix
232
+ // scan must not silently start raising here.
233
+ try {
234
+ return {
235
+ kind: "insert",
236
+ cursor: parseInsertTarget(line.slice(0, opIndex), 0, kind),
237
+ inlineBody: scanInlineBody(line, opIndex + sigil.length),
238
+ };
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ function tryParseReplaceOp(line: string): ParsedReplaceOp | null {
245
+ const end = trimEndIndex(line);
246
+ const range = scanRange(line, end);
247
+ if (range === null || range.nextIndex >= end || line[range.nextIndex] !== HL_OP_REPLACE) return null;
248
+ return {
249
+ kind: "replace",
250
+ range: range.range,
251
+ inlineBody: scanInlineBody(line, range.nextIndex + HL_OP_REPLACE.length),
252
+ };
253
+ }
254
+
255
+ function tryParseDeleteOp(line: string): ParsedDeleteOp | null {
256
+ const end = trimEndIndex(line);
257
+ const range = scanRange(line, end);
258
+ if (range === null || range.nextIndex >= end || line[range.nextIndex] !== HL_OP_DELETE) return null;
259
+ const afterSigil = range.nextIndex + HL_OP_DELETE.length;
260
+ return { kind: "delete", range: range.range, trailingPayload: afterSigil !== end };
261
+ }
262
+
263
+ function tryParseOp(line: string): ParsedOp | null {
264
+ return (
265
+ tryParseInsertOp(line, HL_OP_INSERT_BEFORE, "before") ??
266
+ tryParseInsertOp(line, HL_OP_INSERT_AFTER, "after") ??
267
+ tryParseReplaceOp(line) ??
268
+ tryParseDeleteOp(line)
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Strict header scan: `¶+` prefix, optional whitespace, path body that excludes
274
+ * whitespace, `#`, and `¶`, optional `#[0-9a-f]{4}` hash suffix, optional
275
+ * trailing whitespace. Returns `null` when any byte deviates from the shape.
276
+ */
277
+ function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
278
+ const end = trimEndIndex(line);
279
+ if (end === 0 || line.charCodeAt(0) !== CHAR_PILCROW) return null;
280
+
281
+ let index = 0;
282
+ while (index < end && line.charCodeAt(index) === CHAR_PILCROW) index++;
283
+ index = skipWhitespace(line, index, end);
284
+ if (index >= end) return null;
285
+
286
+ const pathStart = index;
287
+ while (index < end) {
288
+ const code = line.charCodeAt(index);
289
+ if (code === CHAR_HASH || code === CHAR_PILCROW || code === CHAR_SPACE || code === CHAR_TAB) break;
290
+ index++;
291
+ }
292
+ if (index === pathStart) return null;
293
+ const path = line.slice(pathStart, index);
294
+
295
+ let fileHash: string | undefined;
296
+ if (index < end && line.charCodeAt(index) === CHAR_HASH) {
297
+ const hashStart = index + 1;
298
+ const hashEnd = hashStart + FILE_HASH_LENGTH;
299
+ if (hashEnd > end) return null;
300
+ for (let probe = hashStart; probe < hashEnd; probe++) {
301
+ if (!isHexDigitCode(line.charCodeAt(probe))) return null;
302
+ }
303
+ fileHash = line.slice(hashStart, hashEnd);
304
+ index = hashEnd;
305
+ }
306
+
307
+ // Anything other than trailing whitespace disqualifies the header.
308
+ if (skipWhitespace(line, index, end) !== end) return null;
309
+
310
+ return fileHash !== undefined ? { path, fileHash } : { path };
311
+ }
312
+
313
+ /**
314
+ * Returns true when the line scans as `LINE!payload` (delete sigil followed by
315
+ * additional content). The executor uses this for the dedicated "deletes only"
316
+ * diagnostic, separate from the standard "unrecognized op" path.
317
+ */
318
+ export function isDeleteOpWithPayload(line: string): boolean {
319
+ const range = scanRange(line, line.length);
320
+ return (
321
+ range !== null &&
322
+ range.nextIndex < line.length &&
323
+ line[range.nextIndex] === HL_OP_DELETE &&
324
+ range.nextIndex + HL_OP_DELETE.length < line.length
325
+ );
326
+ }
327
+
328
+ interface TokenBase {
329
+ /** 1-indexed line number in the original input stream. */
330
+ lineNum: number;
331
+ }
332
+
333
+ export type HashlineToken =
334
+ | (TokenBase & { kind: "blank" })
335
+ | (TokenBase & { kind: "envelope-begin" })
336
+ | (TokenBase & { kind: "envelope-end" })
337
+ | (TokenBase & { kind: "abort" })
338
+ | (TokenBase & { kind: "header"; path: string; fileHash?: string })
339
+ | (TokenBase & { kind: "op-insert"; cursor: HashlineCursor; inlineBody: string | undefined })
340
+ | (TokenBase & { kind: "op-replace"; range: ParsedRange; inlineBody: string | undefined })
341
+ | (TokenBase & { kind: "op-delete"; range: ParsedRange; trailingPayload: boolean })
342
+ | (TokenBase & { kind: "payload"; text: string });
343
+
344
+ function classifyLine(line: string, lineNum: number): HashlineToken {
345
+ if (isEmptyLine(line)) return { kind: "blank", lineNum };
346
+ if (markerLineEquals(line, BEGIN_PATCH_MARKER)) return { kind: "envelope-begin", lineNum };
347
+ if (markerLineEquals(line, END_PATCH_MARKER)) return { kind: "envelope-end", lineNum };
348
+ if (markerLineEquals(line, ABORT_MARKER)) return { kind: "abort", lineNum };
349
+
350
+ if (line.charCodeAt(0) === CHAR_PILCROW) {
351
+ const header = tryParseHeader(line);
352
+ if (header !== null) {
353
+ return header.fileHash !== undefined
354
+ ? { kind: "header", lineNum, path: header.path, fileHash: header.fileHash }
355
+ : { kind: "header", lineNum, path: header.path };
356
+ }
357
+ }
358
+
359
+ const op = tryParseOp(line);
360
+ if (op !== null) {
361
+ if (op.kind === "insert") {
362
+ return { kind: "op-insert", lineNum, cursor: op.cursor, inlineBody: op.inlineBody };
363
+ }
364
+ if (op.kind === "replace") {
365
+ return { kind: "op-replace", lineNum, range: op.range, inlineBody: op.inlineBody };
366
+ }
367
+ return { kind: "op-delete", lineNum, range: op.range, trailingPayload: op.trailingPayload };
368
+ }
369
+
370
+ return { kind: "payload", lineNum, text: line };
371
+ }
372
+
373
+ /**
374
+ * Stateful, line-oriented classifier for hashline diff text. Use the streaming
375
+ * {@link feed}/{@link end} pair to ingest text in chunks (each completed line
376
+ * emits exactly one token; a trailing partial line stays buffered until the
377
+ * next chunk or {@link end}). Use the stateless {@link tokenize}/predicate
378
+ * methods for callers that already hold whole lines and only need
379
+ * classification without buffering.
380
+ */
381
+ export class HashlineTokenizer {
382
+ #buffer = "";
383
+ #nextLineNum = 1;
384
+ #closed = false;
385
+
386
+ /**
387
+ * Ingest a chunk of input text. Each newline-terminated line in the
388
+ * combined buffer produces one token. A trailing partial line (no `\n`
389
+ * yet, possibly ending in a lone `\r`) stays buffered until the next
390
+ * `feed`/`end` call so CRLF pairs that straddle chunk boundaries are
391
+ * still normalized correctly.
392
+ */
393
+ feed(chunk: string): HashlineToken[] {
394
+ if (this.#closed) throw new Error("HashlineTokenizer is closed; call reset() before reusing.");
395
+ if (chunk.length === 0) return [];
396
+ this.#buffer = this.#buffer ? this.#buffer + chunk : chunk;
397
+ return this.#drainCompleteLines();
398
+ }
399
+
400
+ /**
401
+ * Flush any buffered residual line (the last line of input when it lacks
402
+ * a trailing newline) and mark the tokenizer closed. Calling `end` a
403
+ * second time returns `[]`; reuse requires `reset`.
404
+ */
405
+ end(): HashlineToken[] {
406
+ if (this.#closed) return [];
407
+ this.#closed = true;
408
+ const buf = this.#buffer;
409
+ this.#buffer = "";
410
+ if (buf.length === 0) return [];
411
+ let stop = buf.length;
412
+ if (buf.charCodeAt(stop - 1) === CHAR_CARRIAGE_RETURN) stop--;
413
+ const token = classifyLine(buf.slice(0, stop), this.#nextLineNum++);
414
+ return [token];
415
+ }
416
+
417
+ /** Discard any buffered text and reset the line counter to 1. */
418
+ reset(): void {
419
+ this.#buffer = "";
420
+ this.#nextLineNum = 1;
421
+ this.#closed = false;
422
+ }
423
+
424
+ /** Convenience: feed an entire text and immediately flush. */
425
+ tokenizeAll(text: string): HashlineToken[] {
426
+ this.reset();
427
+ const first = this.feed(text);
428
+ const last = this.end();
429
+ return last.length === 0 ? first : first.concat(last);
430
+ }
431
+
432
+ /** Stateless one-shot classification. Does not touch the streaming buffer. */
433
+ tokenize(line: string, lineNum = 0): HashlineToken {
434
+ return classifyLine(line, lineNum);
435
+ }
436
+
437
+ isOp(line: string): boolean {
438
+ return tryParseOp(line) !== null;
439
+ }
440
+
441
+ isHeader(line: string): boolean {
442
+ return tryParseHeader(line) !== null;
443
+ }
444
+
445
+ isEnvelopeMarker(line: string): boolean {
446
+ return (
447
+ markerLineEquals(line, BEGIN_PATCH_MARKER) ||
448
+ markerLineEquals(line, END_PATCH_MARKER) ||
449
+ markerLineEquals(line, ABORT_MARKER)
450
+ );
451
+ }
452
+
453
+ #drainCompleteLines(): HashlineToken[] {
454
+ const tokens: HashlineToken[] = [];
455
+ const buf = this.#buffer;
456
+ let start = 0;
457
+ for (let index = 0; index < buf.length; index++) {
458
+ if (buf.charCodeAt(index) !== CHAR_LINE_FEED) continue;
459
+ let stop = index;
460
+ if (stop > start && buf.charCodeAt(stop - 1) === CHAR_CARRIAGE_RETURN) stop--;
461
+ tokens.push(classifyLine(buf.slice(start, stop), this.#nextLineNum++));
462
+ start = index + 1;
463
+ }
464
+ this.#buffer = start < buf.length ? buf.slice(start) : "";
465
+ return tokens;
466
+ }
467
+ }
@@ -3,16 +3,8 @@ import type { LspBatchRequest } from "../edit/renderer";
3
3
  import type { WritethroughCallback, WritethroughDeferredHandle } from "../lsp";
4
4
  import type { ToolSession } from "../tools";
5
5
 
6
- export interface HashMismatch {
7
- line: number;
8
- expected: string;
9
- actual: string;
10
- }
11
-
12
6
  export type Anchor = {
13
7
  line: number;
14
- hash: string;
15
- contentHint?: string;
16
8
  };
17
9
 
18
10
  export type HashlineCursor =
@@ -25,6 +17,12 @@ export type HashlineEdit =
25
17
  | { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
26
18
  | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
27
19
 
20
+ export interface HashlineInputSection {
21
+ path: string;
22
+ fileHash?: string;
23
+ diff: string;
24
+ }
25
+
28
26
  /** `path` is accepted by the edit tool runtime; other extra keys are preserved. */
29
27
  export const hashlineEditParamsSchema = z.object({ input: z.string(), path: z.string().optional() }).passthrough();
30
28
  export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;