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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/types/cli/file-processor.d.ts +1 -1
  3. package/dist/types/config/settings-schema.d.ts +45 -3
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/debug/raw-sse.d.ts +2 -0
  6. package/dist/types/edit/file-read-cache.d.ts +15 -4
  7. package/dist/types/edit/index.d.ts +3 -8
  8. package/dist/types/edit/renderer.d.ts +1 -2
  9. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  10. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  11. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  12. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  13. package/dist/types/eval/py/executor.d.ts +1 -2
  14. package/dist/types/eval/py/kernel.d.ts +6 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  16. package/dist/types/eval/session-id.d.ts +3 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  18. package/dist/types/hashline/anchors.d.ts +15 -9
  19. package/dist/types/hashline/constants.d.ts +0 -2
  20. package/dist/types/hashline/diff.d.ts +1 -2
  21. package/dist/types/hashline/executor.d.ts +52 -0
  22. package/dist/types/hashline/hash.d.ts +44 -93
  23. package/dist/types/hashline/index.d.ts +2 -1
  24. package/dist/types/hashline/input.d.ts +2 -9
  25. package/dist/types/hashline/recovery.d.ts +3 -9
  26. package/dist/types/hashline/tokenizer.d.ts +91 -0
  27. package/dist/types/hashline/types.d.ts +5 -7
  28. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  29. package/dist/types/modes/types.d.ts +1 -0
  30. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +2 -0
  32. package/dist/types/session/agent-session.d.ts +11 -15
  33. package/dist/types/session/agent-storage.d.ts +11 -10
  34. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  35. package/dist/types/slash-commands/types.d.ts +0 -5
  36. package/dist/types/task/executor.d.ts +2 -0
  37. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  38. package/dist/types/tools/index.d.ts +2 -8
  39. package/dist/types/tools/match-line-format.d.ts +4 -4
  40. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  41. package/dist/types/tools/review.d.ts +13 -0
  42. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  43. package/dist/types/tools/search.d.ts +4 -3
  44. package/dist/types/utils/edit-mode.d.ts +1 -1
  45. package/dist/types/web/kagi.d.ts +4 -2
  46. package/dist/types/web/parallel.d.ts +4 -3
  47. package/dist/types/web/scrapers/types.d.ts +2 -1
  48. package/dist/types/web/search/index.d.ts +12 -4
  49. package/dist/types/web/search/provider.d.ts +2 -1
  50. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  51. package/dist/types/web/search/providers/base.d.ts +34 -2
  52. package/dist/types/web/search/providers/brave.d.ts +8 -1
  53. package/dist/types/web/search/providers/codex.d.ts +13 -9
  54. package/dist/types/web/search/providers/exa.d.ts +10 -1
  55. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  56. package/dist/types/web/search/providers/jina.d.ts +2 -1
  57. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  58. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  59. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  60. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  61. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  62. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  63. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  64. package/dist/types/web/search/providers/utils.d.ts +8 -6
  65. package/dist/types/web/search/providers/zai.d.ts +12 -3
  66. package/package.json +7 -7
  67. package/src/cli/file-processor.ts +12 -2
  68. package/src/cli.ts +0 -8
  69. package/src/commands/commit.ts +8 -8
  70. package/src/config/prompt-templates.ts +6 -6
  71. package/src/config/settings-schema.ts +47 -3
  72. package/src/config/settings.ts +5 -5
  73. package/src/debug/raw-sse.ts +68 -3
  74. package/src/edit/file-read-cache.ts +68 -25
  75. package/src/edit/index.ts +6 -37
  76. package/src/edit/renderer.ts +9 -47
  77. package/src/edit/streaming.ts +43 -56
  78. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  79. package/src/eval/js/context-manager.ts +64 -53
  80. package/src/eval/js/shared/local-module-loader.ts +265 -0
  81. package/src/eval/js/shared/prelude.txt +4 -0
  82. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  83. package/src/eval/js/shared/runtime.ts +129 -86
  84. package/src/eval/js/worker-core.ts +23 -38
  85. package/src/eval/py/executor.ts +155 -84
  86. package/src/eval/py/kernel.ts +10 -1
  87. package/src/eval/py/prelude.py +22 -24
  88. package/src/eval/py/runner.py +203 -85
  89. package/src/eval/py/tool-bridge.ts +17 -10
  90. package/src/eval/session-id.ts +8 -0
  91. package/src/exec/bash-executor.ts +27 -16
  92. package/src/extensibility/extensions/runner.ts +0 -1
  93. package/src/extensibility/extensions/types.ts +1 -3
  94. package/src/hashline/anchors.ts +56 -65
  95. package/src/hashline/apply.ts +29 -31
  96. package/src/hashline/constants.ts +0 -3
  97. package/src/hashline/diff-preview.ts +4 -5
  98. package/src/hashline/diff.ts +30 -4
  99. package/src/hashline/execute.ts +91 -26
  100. package/src/hashline/executor.ts +239 -0
  101. package/src/hashline/grammar.lark +12 -10
  102. package/src/hashline/hash.ts +69 -114
  103. package/src/hashline/index.ts +2 -1
  104. package/src/hashline/input.ts +48 -41
  105. package/src/hashline/prefixes.ts +21 -11
  106. package/src/hashline/recovery.ts +63 -71
  107. package/src/hashline/stream.ts +2 -2
  108. package/src/hashline/tokenizer.ts +467 -0
  109. package/src/hashline/types.ts +6 -8
  110. package/src/internal-urls/docs-index.generated.ts +7 -7
  111. package/src/modes/components/extensions/types.ts +0 -5
  112. package/src/modes/components/session-observer-overlay.ts +11 -2
  113. package/src/modes/components/settings-selector.ts +10 -1
  114. package/src/modes/components/tree-selector.ts +10 -2
  115. package/src/modes/controllers/command-controller.ts +1 -3
  116. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  117. package/src/modes/controllers/selector-controller.ts +5 -5
  118. package/src/modes/theme/theme.ts +4 -2
  119. package/src/modes/types.ts +4 -1
  120. package/src/modes/utils/ui-helpers.ts +4 -0
  121. package/src/prompts/agents/explore.md +1 -1
  122. package/src/prompts/tools/ast-edit.md +1 -1
  123. package/src/prompts/tools/ast-grep.md +1 -1
  124. package/src/prompts/tools/eval.md +1 -1
  125. package/src/prompts/tools/hashline.md +73 -94
  126. package/src/prompts/tools/read.md +4 -4
  127. package/src/prompts/tools/search.md +3 -3
  128. package/src/sdk.ts +33 -26
  129. package/src/session/agent-session.ts +59 -66
  130. package/src/session/agent-storage.ts +13 -14
  131. package/src/slash-commands/acp-builtins.ts +3 -3
  132. package/src/slash-commands/types.ts +0 -6
  133. package/src/task/executor.ts +26 -57
  134. package/src/task/index.ts +8 -4
  135. package/src/tool-discovery/tool-index.ts +0 -134
  136. package/src/tools/ast-edit.ts +36 -13
  137. package/src/tools/ast-grep.ts +45 -4
  138. package/src/tools/browser/tab-worker.ts +3 -2
  139. package/src/tools/eval.ts +2 -1
  140. package/src/tools/fetch.ts +23 -14
  141. package/src/tools/index.ts +2 -8
  142. package/src/tools/irc.ts +59 -5
  143. package/src/tools/match-line-format.ts +5 -7
  144. package/src/tools/output-schema-validator.ts +132 -0
  145. package/src/tools/read.ts +142 -31
  146. package/src/tools/review.ts +23 -0
  147. package/src/tools/search-tool-bm25.ts +3 -30
  148. package/src/tools/search.ts +48 -16
  149. package/src/tools/write.ts +3 -3
  150. package/src/tools/yield.ts +32 -41
  151. package/src/utils/edit-mode.ts +1 -2
  152. package/src/utils/file-mentions.ts +2 -2
  153. package/src/web/kagi.ts +15 -6
  154. package/src/web/parallel.ts +9 -6
  155. package/src/web/scrapers/types.ts +7 -1
  156. package/src/web/scrapers/youtube.ts +13 -7
  157. package/src/web/search/index.ts +37 -11
  158. package/src/web/search/provider.ts +5 -3
  159. package/src/web/search/providers/anthropic.ts +30 -21
  160. package/src/web/search/providers/base.ts +35 -2
  161. package/src/web/search/providers/brave.ts +4 -4
  162. package/src/web/search/providers/codex.ts +118 -89
  163. package/src/web/search/providers/exa.ts +3 -2
  164. package/src/web/search/providers/gemini.ts +58 -155
  165. package/src/web/search/providers/jina.ts +4 -4
  166. package/src/web/search/providers/kagi.ts +17 -11
  167. package/src/web/search/providers/kimi.ts +29 -13
  168. package/src/web/search/providers/parallel.ts +171 -23
  169. package/src/web/search/providers/perplexity.ts +38 -37
  170. package/src/web/search/providers/searxng.ts +3 -1
  171. package/src/web/search/providers/synthetic.ts +16 -19
  172. package/src/web/search/providers/tavily.ts +23 -18
  173. package/src/web/search/providers/utils.ts +11 -17
  174. package/src/web/search/providers/zai.ts +16 -8
  175. package/dist/types/hashline/parser.d.ts +0 -7
  176. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  177. package/dist/types/tools/vim.d.ts +0 -58
  178. package/dist/types/vim/buffer.d.ts +0 -41
  179. package/dist/types/vim/commands.d.ts +0 -6
  180. package/dist/types/vim/engine.d.ts +0 -47
  181. package/dist/types/vim/parser.d.ts +0 -3
  182. package/dist/types/vim/render.d.ts +0 -25
  183. package/dist/types/vim/types.d.ts +0 -182
  184. package/src/hashline/parser.ts +0 -246
  185. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  186. package/src/prompts/tools/vim.md +0 -98
  187. package/src/tools/vim.ts +0 -949
  188. package/src/vim/buffer.ts +0 -309
  189. package/src/vim/commands.ts +0 -382
  190. package/src/vim/engine.ts +0 -2409
  191. package/src/vim/parser.ts +0 -134
  192. package/src/vim/render.ts +0 -252
  193. package/src/vim/types.ts +0 -197
@@ -1,15 +1,10 @@
1
1
  import * as path from "node:path";
2
- import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./constants";
3
- import { HL_FILE_PREFIX, HL_OP_CHARS } from "./hash";
4
- import type { SplitHashlineOptions } from "./types";
2
+ import { HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./hash";
3
+ import { HashlineTokenizer } from "./tokenizer";
4
+ import type { HashlineInputSection, SplitHashlineOptions } from "./types";
5
5
 
6
- const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
- const HASHLINE_OP_LINE_RE = new RegExp(`^[${regexEscape(HL_OP_CHARS)}]`);
8
-
9
- export interface HashlineInputSection {
10
- path: string;
11
- diff: string;
12
- }
6
+ // Pure classification single shared tokenizer is safe.
7
+ const TOKENIZER = new HashlineTokenizer();
13
8
 
14
9
  function unquoteHashlinePath(pathText: string): string {
15
10
  if (pathText.length < 2) return pathText;
@@ -27,27 +22,30 @@ function normalizeHashlinePath(rawPath: string, cwd?: string): string {
27
22
  return isWithinCwd ? relative || "." : unquoted;
28
23
  }
29
24
 
25
+ /**
26
+ * Parse a `¶PATH[#hash]` header line. Returns `null` for lines that do not
27
+ * begin with the `¶` prefix; throws the existing "Input header must be …"
28
+ * error when a `¶`-prefixed line fails the strict shape (so malformed paths
29
+ * surface immediately instead of being silently re-classified as payload).
30
+ */
30
31
  function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSection | null {
31
32
  const trimmed = line.trimEnd();
32
33
  if (!trimmed.startsWith(HL_FILE_PREFIX)) return null;
33
- // Strip a run of leading header markers so canonical `§PATH` and
34
- // runaway-prefix forms like `§§PATH` / `§§§PATH` route to the same file.
35
- let prefixEnd = 0;
36
- while (prefixEnd < trimmed.length && trimmed[prefixEnd] === HL_FILE_PREFIX) prefixEnd++;
37
- const rest = trimmed.slice(prefixEnd);
38
- if (rest.trim().length === 0) {
39
- throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
34
+
35
+ const token = TOKENIZER.tokenize(trimmed);
36
+ if (token.kind !== "header") {
37
+ throw new Error(
38
+ `Input header must be ${HL_FILE_PREFIX}PATH or ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH with a 4-hex file hash; got ${JSON.stringify(trimmed)}.`,
39
+ );
40
40
  }
41
- const parsedPath = normalizeHashlinePath(rest, cwd);
41
+
42
+ const parsedPath = normalizeHashlinePath(token.path, cwd);
42
43
  if (parsedPath.length === 0) {
43
44
  throw new Error(`Input header "${HL_FILE_PREFIX}" is empty; provide a file path.`);
44
45
  }
45
- return { path: parsedPath, diff: "" };
46
- }
47
-
48
- function isPatchEnvelopeMarker(line: string): boolean {
49
- const trimmed = line.trimEnd();
50
- return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER;
46
+ return token.fileHash !== undefined
47
+ ? { path: parsedPath, fileHash: token.fileHash, diff: "" }
48
+ : { path: parsedPath, diff: "" };
51
49
  }
52
50
 
53
51
  function stripLeadingBlankLines(input: string): string {
@@ -55,7 +53,7 @@ function stripLeadingBlankLines(input: string): string {
55
53
  const lines = stripped.split("\n");
56
54
  while (lines.length > 0) {
57
55
  const head = lines[0].replace(/\r$/, "");
58
- if (head.trim().length === 0 || head.trimEnd() === BEGIN_PATCH_MARKER) {
56
+ if (head.trim().length === 0 || TOKENIZER.tokenize(head).kind === "envelope-begin") {
59
57
  lines.shift();
60
58
  continue;
61
59
  }
@@ -66,7 +64,7 @@ function stripLeadingBlankLines(input: string): string {
66
64
 
67
65
  export function containsRecognizableHashlineOperations(input: string): boolean {
68
66
  for (const line of input.split(/\r?\n/)) {
69
- if (HASHLINE_OP_LINE_RE.test(line)) return true;
67
+ if (TOKENIZER.isOp(line)) return true;
70
68
  }
71
69
  return false;
72
70
  }
@@ -84,7 +82,7 @@ function normalizeFallbackInput(input: string, options: SplitHashlineOptions): s
84
82
  return `${HL_FILE_PREFIX}${fallbackPath}\n${input}`;
85
83
  }
86
84
 
87
- export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): { path: string; diff: string } {
85
+ export function splitHashlineInput(input: string, options: SplitHashlineOptions = {}): HashlineInputSection {
88
86
  const [section] = splitHashlineInputs(input, options);
89
87
  return section;
90
88
  }
@@ -97,33 +95,42 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions
97
95
  if (parseHashlineHeaderLine(firstLine, options.cwd) === null) {
98
96
  const preview = JSON.stringify(firstLine.slice(0, 120));
99
97
  throw new Error(
100
- `input must begin with "${HL_FILE_PREFIX}PATH" on the first non-blank line; got: ${preview}. ` +
101
- `Example: "${HL_FILE_PREFIX}src/foo.ts" then edit ops.`,
98
+ `input must begin with "${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}HASH" on the first non-blank line for anchored edits; got: ${preview}. ` +
99
+ `Example: "${HL_FILE_PREFIX}src/foo.ts${HL_FILE_HASH_SEP}1a2b" then edit ops.`,
102
100
  );
103
101
  }
104
102
 
105
103
  const sections: HashlineInputSection[] = [];
106
- let currentPath = "";
104
+ let current: HashlineInputSection | undefined;
107
105
  let currentLines: string[] = [];
108
106
 
109
107
  const flush = () => {
110
- if (currentPath.length === 0) return;
108
+ if (!current) return;
111
109
  const hasOps = currentLines.some(line => line.trim().length > 0);
112
- if (hasOps) sections.push({ path: currentPath, diff: currentLines.join("\n") });
110
+ if (hasOps) sections.push({ ...current, diff: currentLines.join("\n") });
113
111
  currentLines = [];
114
112
  };
115
113
 
116
114
  for (const line of lines) {
117
- if (line.trimEnd() === END_PATCH_MARKER || line.trimEnd() === ABORT_MARKER) break;
118
- if (isPatchEnvelopeMarker(line)) continue;
119
- const header = parseHashlineHeaderLine(line, options.cwd);
120
- if (header !== null) {
121
- flush();
122
- currentPath = header.path;
123
- currentLines = [];
124
- } else {
125
- currentLines.push(line);
115
+ const trimmed = line.trimEnd();
116
+ const token = TOKENIZER.tokenize(line);
117
+ if (token.kind === "envelope-end" || token.kind === "abort") break;
118
+ if (token.kind === "envelope-begin") continue;
119
+
120
+ // Route every `¶`-prefixed line through parseHashlineHeaderLine so
121
+ // malformed headers still raise the strict "Input header must be …"
122
+ // diagnostic (the tokenizer alone would silently classify them as
123
+ // payload).
124
+ if (trimmed.startsWith(HL_FILE_PREFIX)) {
125
+ const header = parseHashlineHeaderLine(line, options.cwd);
126
+ if (header !== null) {
127
+ flush();
128
+ current = header;
129
+ currentLines = [];
130
+ continue;
131
+ }
126
132
  }
133
+ currentLines.push(line);
127
134
  }
128
135
  flush();
129
136
  return sections;
@@ -1,8 +1,6 @@
1
- import { HL_BODY_SEP_RE_RAW } from "./hash";
2
-
3
- const HL_OUTPUT_PREFIX_SEPARATOR_RE = `[:${HL_BODY_SEP_RE_RAW}]`;
4
- const HL_PREFIX_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
5
- const HL_PREFIX_PLUS_RE = new RegExp(`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+[a-z]{2}${HL_OUTPUT_PREFIX_SEPARATOR_RE}`);
1
+ const HL_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:[+*-]\s*)?\d+:/;
2
+ const HL_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*\d+:/;
3
+ const HL_HEADER_RE = /^\s*¶\S+#[0-9a-f]{4}\s*$/;
6
4
  const DIFF_PLUS_RE = /^[+](?![+])/;
7
5
  const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bUse :L?\d+/;
8
6
 
@@ -20,12 +18,14 @@ function stripLeadingHashlinePrefixes(line: string): string {
20
18
  // 5. Read-output prefix stripping
21
19
  //
22
20
  // When a model echoes back content from a `read` or `search` response, every
23
- // line is prefixed with either a hashline tag (`123ab|`) or, for diff-style
24
- // echoes, a leading `+`. These helpers detect that and recover the raw text.
21
+ // line is prefixed with either a hashline-mode line number (`123:`) or, for
22
+ // diff-style echoes, a leading `+`. These helpers detect that and recover the
23
+ // raw text.
25
24
  // ───────────────────────────────────────────────────────────────────────────
26
25
 
27
26
  type LinePrefixStats = {
28
27
  nonEmpty: number;
28
+ headerCount: number;
29
29
  hashPrefixCount: number;
30
30
  diffPlusHashPrefixCount: number;
31
31
  diffPlusCount: number;
@@ -35,6 +35,7 @@ type LinePrefixStats = {
35
35
  function collectLinePrefixStats(lines: string[]): LinePrefixStats {
36
36
  const stats: LinePrefixStats = {
37
37
  nonEmpty: 0,
38
+ headerCount: 0,
38
39
  hashPrefixCount: 0,
39
40
  diffPlusHashPrefixCount: 0,
40
41
  diffPlusCount: 0,
@@ -47,6 +48,11 @@ function collectLinePrefixStats(lines: string[]): LinePrefixStats {
47
48
  stats.truncationNoticeCount++;
48
49
  continue;
49
50
  }
51
+ if (HL_HEADER_RE.test(line)) {
52
+ stats.nonEmpty++;
53
+ stats.headerCount++;
54
+ continue;
55
+ }
50
56
  stats.nonEmpty++;
51
57
  if (HL_PREFIX_RE.test(line)) stats.hashPrefixCount++;
52
58
  if (HL_PREFIX_PLUS_RE.test(line)) stats.diffPlusHashPrefixCount++;
@@ -59,7 +65,8 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
59
65
  const stats = collectLinePrefixStats(lines);
60
66
  if (stats.nonEmpty === 0) return lines;
61
67
 
62
- const stripHash = stats.hashPrefixCount > 0 && stats.hashPrefixCount === stats.nonEmpty;
68
+ const contentLineCount = stats.nonEmpty - stats.headerCount;
69
+ const stripHash = contentLineCount > 0 && stats.hashPrefixCount === contentLineCount;
63
70
  const stripPlus =
64
71
  !stripHash &&
65
72
  stats.diffPlusHashPrefixCount === 0 &&
@@ -69,7 +76,7 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
69
76
  if (!stripHash && !stripPlus && stats.diffPlusHashPrefixCount === 0) return lines;
70
77
 
71
78
  return lines
72
- .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line))
79
+ .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !(stripHash && HL_HEADER_RE.test(line)))
73
80
  .map(line => {
74
81
  if (stripHash) return stripLeadingHashlinePrefixes(line);
75
82
  if (stripPlus) return line.replace(DIFF_PLUS_RE, "");
@@ -83,8 +90,11 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
83
90
  export function stripHashlinePrefixes(lines: string[]): string[] {
84
91
  const stats = collectLinePrefixStats(lines);
85
92
  if (stats.nonEmpty === 0) return lines;
86
- if (stats.hashPrefixCount !== stats.nonEmpty) return lines;
87
- return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
93
+ const contentLineCount = stats.nonEmpty - stats.headerCount;
94
+ if (contentLineCount === 0 || stats.hashPrefixCount !== contentLineCount) return lines;
95
+ return lines
96
+ .filter(line => !READ_TRUNCATION_NOTICE_RE.test(line) && !HL_HEADER_RE.test(line))
97
+ .map(line => stripLeadingHashlinePrefixes(line));
88
98
  }
89
99
 
90
100
  /**
@@ -1,15 +1,15 @@
1
1
  import * as Diff from "diff";
2
2
  import { generateDiffString } from "../edit/diff";
3
- import type { FileReadCache } from "../edit/file-read-cache";
4
- import { HashlineMismatchError } from "./anchors";
3
+ import type { FileReadCache, FileReadSnapshot } from "../edit/file-read-cache";
5
4
  import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
6
- import { computeLineHash } from "./hash";
7
- import type { Anchor, HashlineApplyOptions, HashlineEdit } from "./types";
5
+ import { computeFileHash } from "./hash";
6
+ import type { HashlineApplyOptions, HashlineEdit } from "./types";
8
7
 
9
8
  export interface HashlineRecoveryArgs {
10
9
  cache: FileReadCache;
11
10
  absolutePath: string;
12
11
  currentText: string;
12
+ fileHash: string;
13
13
  edits: HashlineEdit[];
14
14
  options: HashlineApplyOptions;
15
15
  }
@@ -20,76 +20,28 @@ export interface HashlineRecoveryResult {
20
20
  warnings: string[];
21
21
  }
22
22
 
23
- // Anchors are line-precise; never let Diff.applyPatch slide a hunk onto a
24
- // duplicate closer 100+ lines away. If the snapshot-based replay does not
25
- // align by exact line number, refuse and let the model re-read.
23
+ // Section hashes are line-precise; never let Diff.applyPatch slide a hunk onto a
24
+ // duplicate closer 100+ lines away. If snapshot replay does not align exactly,
25
+ // refuse and let the model re-read.
26
26
  const HASHLINE_RECOVERY_FUZZ_FACTOR = 0;
27
27
 
28
- const HASHLINE_RECOVERY_WARNING =
29
- "Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit).";
30
-
31
- /** Collect every line anchor an edit batch depends on. */
32
- function collectEditAnchors(edits: HashlineEdit[]): Anchor[] {
33
- const anchors: Anchor[] = [];
34
- for (const edit of edits) {
35
- if (edit.kind === "delete") {
36
- anchors.push(edit.anchor);
37
- continue;
38
- }
39
- const cursor = edit.cursor;
40
- if (cursor.kind === "before_anchor" || cursor.kind === "after_anchor") {
41
- anchors.push(cursor.anchor);
42
- }
43
- }
44
- return anchors;
45
- }
46
-
47
- /**
48
- * Attempt to recover from a `HashlineMismatchError` by replaying the edits
49
- * against a cached pre-edit snapshot of the file and 3-way-merging the result
50
- * onto the current on-disk content. Returns `null` when no recovery is
51
- * possible — callers should propagate the original mismatch error in that
52
- * case.
53
- *
54
- * Recovery is gated on a strict precondition: every line the model anchored
55
- * MUST be present in the cached snapshot AND its content MUST hash to the
56
- * model-supplied hash. This prevents 3-way merges from silently sliding onto
57
- * the wrong site when only tangential parts of the file went stale.
58
- */
59
- export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null {
60
- const { cache, absolutePath, currentText, edits, options } = args;
61
- const snapshot = cache.get(absolutePath);
62
- if (!snapshot || snapshot.lines.size === 0) return null;
63
-
64
- // Precondition: the model's anchors must be vouched-for by the cache. If
65
- // even one anchored line is missing from the snapshot, or its cached
66
- // content hashes to a different value than the model supplied, refuse —
67
- // any merge from here is a guess.
68
- const anchors = collectEditAnchors(edits);
69
- for (const anchor of anchors) {
70
- const cachedLine = snapshot.lines.get(anchor.line);
71
- if (cachedLine === undefined) return null;
72
- if (computeLineHash(anchor.line, cachedLine) !== anchor.hash) return null;
73
- }
74
-
75
- const overlaid = currentText.split("\n");
76
- let maxCachedLine = 0;
77
- for (const lineNum of snapshot.lines.keys()) {
78
- if (lineNum > maxCachedLine) maxCachedLine = lineNum;
79
- }
80
- while (overlaid.length < maxCachedLine) overlaid.push("");
81
- for (const [lineNum, content] of snapshot.lines) {
82
- overlaid[lineNum - 1] = content;
83
- }
84
- const previousText = overlaid.join("\n");
85
- if (previousText === currentText) return null;
28
+ const HASHLINE_RECOVERY_EXTERNAL_WARNING =
29
+ "Recovered from a stale file hash using a previous read snapshot (file changed externally between read and edit).";
30
+ const HASHLINE_RECOVERY_SESSION_CHAIN_WARNING =
31
+ "Recovered from a stale file hash using an earlier in-session snapshot (the file hash advanced after a prior edit in this session).";
86
32
 
33
+ function applyEditsToSnapshot(
34
+ previousText: string,
35
+ currentText: string,
36
+ edits: HashlineEdit[],
37
+ options: HashlineApplyOptions,
38
+ recoveryWarning: string,
39
+ ): HashlineRecoveryResult | null {
87
40
  let applied: HashlineApplyResult;
88
41
  try {
89
42
  applied = applyHashlineEdits(previousText, edits, options);
90
- } catch (err) {
91
- if (err instanceof HashlineMismatchError) return null;
92
- throw err;
43
+ } catch {
44
+ return null;
93
45
  }
94
46
  if (applied.lines === previousText) return null;
95
47
 
@@ -98,11 +50,9 @@ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): Hashlin
98
50
  if (typeof merged !== "string" || merged === currentText) return null;
99
51
 
100
52
  const mergedDiff = generateDiffString(currentText, merged);
101
- // Only surface the recovery warning when the merge actually changed
102
- // something visible. A no-op merge (e.g. trailing-newline only) is noise.
103
53
  const hasNetChange = mergedDiff.firstChangedLine !== undefined;
104
54
  const recoveryWarnings = hasNetChange
105
- ? [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])]
55
+ ? [recoveryWarning, ...(applied.warnings ?? [])]
106
56
  : [...(applied.warnings ?? [])];
107
57
 
108
58
  return {
@@ -111,3 +61,45 @@ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): Hashlin
111
61
  warnings: recoveryWarnings,
112
62
  };
113
63
  }
64
+
65
+ function buildSparseOverlayText(currentText: string, snapshotLines: ReadonlyMap<number, string>): string {
66
+ const overlaid = currentText.split("\n");
67
+ let maxCachedLine = 0;
68
+ for (const lineNum of snapshotLines.keys()) {
69
+ if (lineNum > maxCachedLine) maxCachedLine = lineNum;
70
+ }
71
+ while (overlaid.length < maxCachedLine) overlaid.push("");
72
+ for (const [lineNum, content] of snapshotLines) {
73
+ overlaid[lineNum - 1] = content;
74
+ }
75
+ return overlaid.join("\n");
76
+ }
77
+
78
+ function isHeadSnapshot(head: FileReadSnapshot | null, snapshot: FileReadSnapshot): boolean {
79
+ return head === snapshot;
80
+ }
81
+
82
+ function resolveRecoveryWarning(head: FileReadSnapshot | null, snapshot: FileReadSnapshot): string {
83
+ return isHeadSnapshot(head, snapshot) ? HASHLINE_RECOVERY_EXTERNAL_WARNING : HASHLINE_RECOVERY_SESSION_CHAIN_WARNING;
84
+ }
85
+
86
+ /**
87
+ * Attempt to recover from a section file-hash mismatch by replaying the edits
88
+ * against a cached pre-edit snapshot of the file and 3-way-merging the result
89
+ * onto the current on-disk content. Returns `null` when no recovery is possible.
90
+ */
91
+ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null {
92
+ const { cache, absolutePath, currentText, fileHash, edits, options } = args;
93
+ const head = cache.get(absolutePath);
94
+ const snapshot = cache.getByHash(absolutePath, fileHash);
95
+ if (!snapshot || snapshot.lines.size === 0) return null;
96
+
97
+ const recoveryWarning = resolveRecoveryWarning(head, snapshot);
98
+ if (snapshot.fullText !== undefined) {
99
+ return applyEditsToSnapshot(snapshot.fullText, currentText, edits, options, recoveryWarning);
100
+ }
101
+
102
+ const overlayText = buildSparseOverlayText(currentText, snapshot.lines);
103
+ if (computeFileHash(overlayText) !== fileHash) return null;
104
+ return applyEditsToSnapshot(overlayText, currentText, edits, options, recoveryWarning);
105
+ }
@@ -1,4 +1,4 @@
1
- import { formatHashLine } from "./hash";
1
+ import { formatNumberedLine } from "./hash";
2
2
  import type { HashlineStreamOptions } from "./types";
3
3
 
4
4
  interface ResolvedHashlineStreamOptions {
@@ -34,7 +34,7 @@ function createHashlineChunkEmitter(options: ResolvedHashlineStreamOptions): Has
34
34
  };
35
35
 
36
36
  const pushLine = (line: string): string[] => {
37
- const formatted = formatHashLine(lineNumber, line);
37
+ const formatted = formatNumberedLine(lineNumber, line);
38
38
  lineNumber++;
39
39
 
40
40
  const chunks: string[] = [];