klyro 0.1.53 → 0.1.55

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 (50) hide show
  1. package/dist/agent/anthropic-adapter.js +22 -2
  2. package/dist/agent/registry.js +11 -1
  3. package/dist/agent/retry.d.ts +5 -0
  4. package/dist/agent/retry.js +28 -1
  5. package/dist/agent/runtime.d.ts +2 -0
  6. package/dist/agent/runtime.js +48 -35
  7. package/dist/chat.js +2 -2
  8. package/dist/checkpoints/store.js +86 -7
  9. package/dist/cli/config.js +13 -1
  10. package/dist/cli/repl.js +200 -36
  11. package/dist/cli/run.js +88 -69
  12. package/dist/context/klyro-md.d.ts +5 -0
  13. package/dist/context/klyro-md.js +28 -6
  14. package/dist/context/tokenizer.js +8 -1
  15. package/dist/index.js +49 -14
  16. package/dist/persistence/session.d.ts +2 -0
  17. package/dist/persistence/session.js +11 -5
  18. package/dist/persistence/store.d.ts +7 -0
  19. package/dist/persistence/store.js +77 -28
  20. package/dist/policy/engine.d.ts +10 -1
  21. package/dist/policy/engine.js +33 -2
  22. package/dist/tools/fs/apply-patch.d.ts +16 -1
  23. package/dist/tools/fs/apply-patch.js +163 -75
  24. package/dist/tools/fs/edit-file.d.ts +13 -0
  25. package/dist/tools/fs/edit-file.js +96 -54
  26. package/dist/tools/fs/list-dir.d.ts +2 -0
  27. package/dist/tools/fs/list-dir.js +7 -5
  28. package/dist/tools/fs/multi-edit.js +12 -1
  29. package/dist/tools/fs/read-file.js +1 -1
  30. package/dist/tools/fs/read-history.d.ts +2 -2
  31. package/dist/tools/fs/read-history.js +39 -7
  32. package/dist/tools/fs/write-file.js +1 -1
  33. package/dist/tools/normalize.js +11 -1
  34. package/dist/tools/shell/background.d.ts +5 -0
  35. package/dist/tools/shell/background.js +29 -0
  36. package/dist/tools/shell/shell-exec.d.ts +9 -0
  37. package/dist/tools/shell/shell-exec.js +54 -8
  38. package/dist/tools/verify/run-verify.js +2 -2
  39. package/dist/tui/app.js +10 -5
  40. package/dist/tui/app.test.js +42 -0
  41. package/dist/tui/approval.d.ts +1 -1
  42. package/dist/tui/approval.js +20 -5
  43. package/dist/tui/diff.d.ts +5 -19
  44. package/dist/tui/diff.js +9 -25
  45. package/dist/tui/status.d.ts +4 -8
  46. package/dist/tui/status.js +8 -17
  47. package/dist/tui/transcript.d.ts +13 -20
  48. package/dist/tui/transcript.js +9 -74
  49. package/dist/verification/engine.js +4 -4
  50. package/package.json +1 -1
@@ -1,112 +1,200 @@
1
1
  /**
2
- * 4.2 — apply_patch: Codex-style unified patch, tolerant hunks
2
+ * 4.2 — apply_patch: Codex-style unified patch with REAL hunk application.
3
+ *
4
+ * Format:
5
+ * *** Begin Patch
6
+ * *** Update File: <path> (must contain @@ hunks for existing files)
7
+ * *** Add File: <path> (created from + lines)
8
+ * *** End Patch
9
+ *
10
+ * Hunks are standard unified diff: `@@ -a,b +c,d @@` headers, ' ' context,
11
+ * '-' removals, '+' additions. Context/removals must match the file (exact
12
+ * first, trailing-whitespace-insensitive fallback with ±3 line drift);
13
+ * otherwise INVALID_PATCH names the file+hunk instead of corrupting it.
14
+ *
15
+ * Legacy compat: a file section with NO @@ lines and only + lines is treated
16
+ * as file creation (the old tolerant behavior) — but never as an edit to an
17
+ * existing file, where hunk-less appends used to corrupt content.
3
18
  */
4
19
  import * as fs from 'node:fs/promises';
5
20
  import * as path from 'node:path';
6
21
  import { z } from 'zod';
7
22
  import { defineTool } from '../types.js';
8
23
  import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
9
- import { safe, TOOL_ERROR_CODES } from '../normalize.js';
24
+ import { safe } from '../normalize.js';
10
25
  import { wasRead } from './read-history.js';
11
26
  const InputSchema = z.object({
12
27
  patch: z.string().min(1).describe('Unified diff patch text'),
13
28
  });
29
+ function parseHunks(lines) {
30
+ const hunks = [];
31
+ let cur = null;
32
+ for (const line of lines) {
33
+ const m = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
34
+ if (m) {
35
+ cur = { header: line, oldStart: parseInt(m[1], 10), lines: [] };
36
+ hunks.push(cur);
37
+ continue;
38
+ }
39
+ if (line.startsWith('--- ') || line.startsWith('+++ '))
40
+ continue;
41
+ if (line.startsWith('\\ '))
42
+ continue; // ""
43
+ if (!cur)
44
+ continue; // stray lines outside hunks are ignored
45
+ if (line.startsWith('+'))
46
+ cur.lines.push({ kind: 'add', text: line.slice(1) });
47
+ else if (line.startsWith('-'))
48
+ cur.lines.push({ kind: 'remove', text: line.slice(1) });
49
+ else if (line.startsWith(' '))
50
+ cur.lines.push({ kind: 'context', text: line.slice(1) });
51
+ else if (line === '')
52
+ cur.lines.push({ kind: 'context', text: '' });
53
+ // any other prefix inside a hunk is ignored (tolerant)
54
+ }
55
+ return hunks;
56
+ }
57
+ function linesEqual(a, b) {
58
+ return a === b || a.replace(/[ \t]+$/g, '') === b.replace(/[ \t]+$/g, '');
59
+ }
60
+ /** Match the hunk's old block (context+removals) starting at `at`. */
61
+ function matchAt(fileLines, hunk, at) {
62
+ let i = at;
63
+ for (const l of hunk.lines) {
64
+ if (l.kind === 'add')
65
+ continue;
66
+ if (i < 0 || i >= fileLines.length)
67
+ return false;
68
+ if (!linesEqual(fileLines[i], l.text))
69
+ return false;
70
+ i++;
71
+ }
72
+ return true;
73
+ }
74
+ function applyHunks(filePath, fileLines, hunks) {
75
+ const out = [...fileLines];
76
+ // Apply last hunk first so earlier line numbers stay valid.
77
+ const ordered = hunks
78
+ .map((h, idx) => ({ h, idx }))
79
+ .sort((a, b) => b.h.oldStart - a.h.oldStart || b.idx - a.idx);
80
+ for (const { h, idx } of ordered) {
81
+ const want = Math.max(0, h.oldStart - 1);
82
+ let at = -1;
83
+ for (let drift = 0; drift <= 3; drift++) {
84
+ if (matchAt(out, h, want + drift)) {
85
+ at = want + drift;
86
+ break;
87
+ }
88
+ if (drift > 0 && matchAt(out, h, want - drift)) {
89
+ at = want - drift;
90
+ break;
91
+ }
92
+ }
93
+ if (at === -1) {
94
+ throw Object.assign(new Error(`hunk ${idx + 1} (${h.header}) does not match ${filePath} — re-read the file and regenerate the patch`), { code: 'HUNK_MISMATCH' });
95
+ }
96
+ const replacement = [];
97
+ for (const l of h.lines) {
98
+ if (l.kind === 'add' || l.kind === 'context')
99
+ replacement.push(l.text);
100
+ }
101
+ const oldLen = h.lines.filter((l) => l.kind !== 'add').length;
102
+ out.splice(at, oldLen, ...replacement);
103
+ }
104
+ return out;
105
+ }
106
+ async function guardWrite(cwd, relPath, resolved) {
107
+ try {
108
+ const existing = await fs.readFile(resolved, 'utf-8');
109
+ if (!wasRead(relPath, cwd) && existing.length > 200) {
110
+ throw Object.assign(new Error('File was not read this session and is >200 bytes — re-read before writing (POLICY_DENIED)'), { code: 'POLICY_DENIED' });
111
+ }
112
+ }
113
+ catch (e) {
114
+ if (e && typeof e === 'object' && e.code === 'POLICY_DENIED')
115
+ throw e;
116
+ // missing file → creation path, no guard needed
117
+ }
118
+ }
14
119
  export const applyPatchTool = defineTool({
15
120
  name: 'apply_patch',
16
- description: 'Apply a unified diff patch (Codex-style). Tolerant hunks, per model config.',
121
+ description: 'Apply a unified diff patch (Codex-style). Real hunk application with context matching; mismatches fail loudly.',
17
122
  inputSchema: InputSchema,
18
123
  permission: 'edit',
19
124
  isConcurrencySafe: false,
20
125
  execute: async (input, ctx) => {
21
126
  return safe(async () => {
22
127
  const lines = input.patch.split('\n');
23
- let currentFile = null;
24
- let fileContent = null;
25
- let patchedFiles = [];
128
+ const sections = [];
129
+ let cur = null;
26
130
  for (const line of lines) {
27
131
  if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch'))
28
132
  continue;
29
133
  if (line.startsWith('*** Update File:')) {
30
- // Flush previous
31
- if (currentFile && fileContent !== null) {
32
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
33
- if (fileContent === null)
34
- throw Object.assign(new Error('fileContent is null — cannot write'), { code: TOOL_ERROR_CODES.INTERNAL });
35
- try {
36
- const existing = await fs.readFile(resolved, 'utf-8');
37
- if (!wasRead(currentFile) && existing.length > 200) {
38
- throw Object.assign(new Error('File was not read this session and is >200 bytes — re-read before writing (POLICY_DENIED)'), { code: 'POLICY_DENIED' });
39
- }
40
- }
41
- catch (e) {
42
- if (e && typeof e === 'object' && e.code === 'POLICY_DENIED')
43
- throw e;
44
- }
45
- await fs.mkdir(path.dirname(resolved), { recursive: true });
46
- await fs.writeFile(resolved, fileContent, 'utf-8');
47
- patchedFiles.push(currentFile);
48
- }
49
- currentFile = line.replace('*** Update File:', '').trim();
50
- if (currentFile) {
51
- try {
52
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
53
- fileContent = await fs.readFile(resolved, 'utf-8');
54
- }
55
- catch {
56
- fileContent = '';
57
- }
58
- }
134
+ cur = { op: 'update', path: line.replace('*** Update File:', '').trim(), body: [] };
135
+ sections.push(cur);
59
136
  continue;
60
137
  }
61
138
  if (line.startsWith('*** Add File:')) {
62
- if (currentFile && fileContent !== null) {
63
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
64
- if (fileContent === null)
65
- throw Object.assign(new Error('fileContent is null — cannot write'), { code: TOOL_ERROR_CODES.INTERNAL });
66
- try {
67
- const existing = await fs.readFile(resolved, 'utf-8');
68
- if (!wasRead(currentFile) && existing.length > 200) {
69
- throw Object.assign(new Error('File was not read this session and is >200 bytes — re-read before writing (POLICY_DENIED)'), { code: 'POLICY_DENIED' });
70
- }
71
- }
72
- catch (e) {
73
- if (e && typeof e === 'object' && e.code === 'POLICY_DENIED')
74
- throw e;
75
- }
76
- await fs.mkdir(path.dirname(resolved), { recursive: true });
77
- await fs.writeFile(resolved, fileContent, 'utf-8');
78
- patchedFiles.push(currentFile);
79
- }
80
- currentFile = line.replace('*** Add File:', '').trim();
81
- fileContent = '';
139
+ cur = { op: 'add', path: line.replace('*** Add File:', '').trim(), body: [] };
140
+ sections.push(cur);
82
141
  continue;
83
142
  }
84
- if (line.startsWith('+') && currentFile && fileContent !== null) {
85
- // Very tolerant: just append added lines, ignore removals for stub
86
- if (!line.startsWith('+++'))
87
- fileContent += line.slice(1) + '\n';
88
- }
89
- else if (line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')) {
143
+ if (cur)
144
+ cur.body.push(line);
145
+ }
146
+ if (sections.length === 0) {
147
+ throw Object.assign(new Error('No files patched — invalid patch format'), { code: 'INVALID_PATCH' });
148
+ }
149
+ const patchedFiles = [];
150
+ for (const sec of sections) {
151
+ if (!sec.path)
152
+ throw Object.assign(new Error('Patch section missing file path'), { code: 'INVALID_PATCH' });
153
+ const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, sec.path);
154
+ const hunks = parseHunks(sec.body);
155
+ if (sec.op === 'add' || hunks.length === 0) {
156
+ // Creation path: content from + lines (legacy tolerant format).
157
+ // For *** Update File *** this is only valid when the file does NOT
158
+ // exist yet — editing an existing file without hunks is rejected
159
+ // instead of appending junk at EOF (the old corruption).
160
+ let exists = false;
161
+ try {
162
+ await fs.stat(resolved);
163
+ exists = true;
164
+ }
165
+ catch { /* missing → create */ }
166
+ if (exists && sec.op === 'update') {
167
+ throw Object.assign(new Error(`No hunks for existing file ${sec.path} — provide @@ hunks (refusing hunk-less edit)`), { code: 'INVALID_PATCH' });
168
+ }
169
+ const content = sec.body
170
+ .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
171
+ .map((l) => l.slice(1))
172
+ .join('\n');
173
+ await guardWrite(ctx.cwd, sec.path, resolved);
174
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
175
+ await fs.writeFile(resolved, content ? content + '\n' : '', 'utf-8');
176
+ patchedFiles.push(sec.path);
90
177
  continue;
91
178
  }
92
- }
93
- if (currentFile && fileContent !== null) {
94
- const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
95
- if (fileContent === null)
96
- throw Object.assign(new Error('fileContent is null — cannot write'), { code: TOOL_ERROR_CODES.INTERNAL });
179
+ // Update path: apply hunks against current content.
180
+ let current;
97
181
  try {
98
- const existing = await fs.readFile(resolved, 'utf-8');
99
- if (!wasRead(currentFile) && existing.length > 200) {
100
- throw Object.assign(new Error('File was not read this session and is >200 bytes — re-read before writing (POLICY_DENIED)'), { code: 'POLICY_DENIED' });
101
- }
182
+ current = await fs.readFile(resolved, 'utf-8');
102
183
  }
103
- catch (e) {
104
- if (e && typeof e === 'object' && e.code === 'POLICY_DENIED')
105
- throw e;
184
+ catch {
185
+ throw Object.assign(new Error(`Cannot update missing file ${sec.path} with hunks — use *** Add File ***`), {
186
+ code: 'INVALID_PATCH',
187
+ });
106
188
  }
189
+ await guardWrite(ctx.cwd, sec.path, resolved);
190
+ const hasTrailingNewline = current.endsWith('\n');
191
+ const fileLines = current.split('\n');
192
+ if (hasTrailingNewline && fileLines[fileLines.length - 1] === '')
193
+ fileLines.pop();
194
+ const next = applyHunks(sec.path, fileLines, hunks);
107
195
  await fs.mkdir(path.dirname(resolved), { recursive: true });
108
- await fs.writeFile(resolved, fileContent, 'utf-8');
109
- patchedFiles.push(currentFile);
196
+ await fs.writeFile(resolved, next.join('\n') + (hasTrailingNewline ? '\n' : ''), 'utf-8');
197
+ patchedFiles.push(sec.path);
110
198
  }
111
199
  if (patchedFiles.length === 0)
112
200
  throw Object.assign(new Error('No files patched — invalid patch format'), { code: 'INVALID_PATCH' });
@@ -22,5 +22,18 @@ export declare const editFileTool: import("../types.js").Tool<{
22
22
  replace: string;
23
23
  replaceAll?: boolean | undefined;
24
24
  }, EditFileOutput>;
25
+ export interface FuzzySpan {
26
+ /** 0-based start line in the ORIGINAL text. */
27
+ start: number;
28
+ /** Number of original lines the match covers. */
29
+ len: number;
30
+ tier: string;
31
+ }
32
+ /**
33
+ * Locate fuzzy match spans by line number. The file itself is never
34
+ * transformed — transforms exist only to find candidate positions, which
35
+ * map back 1:1 by line because every tier preserves newline positions.
36
+ */
37
+ export declare function locateFuzzySpans(original: string, find: string): FuzzySpan[];
25
38
  export type EditFileInput = z.infer<typeof InputSchema>;
26
39
  export {};
@@ -8,6 +8,7 @@ import { z } from 'zod';
8
8
  import { defineTool } from '../types.js';
9
9
  import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
10
10
  import { safe } from '../normalize.js';
11
+ import { wasRead } from './read-history.js';
11
12
  const InputSchema = z.object({
12
13
  path: z.string().min(1),
13
14
  find: z.string().min(1).describe('Exact substring to match. Not a regex.'),
@@ -50,62 +51,52 @@ export const editFileTool = defineTool({
50
51
  if (checkStaleness(resolved, stat.mtimeMs, currentHash)) {
51
52
  throw Object.assign(new Error(`File ${input.path} changed externally since last read — please re-read before editing`), { code: 'STALE' });
52
53
  }
53
- let original = withoutBOM;
54
- let findStr = input.find;
55
- let count = countOccurrences(original, findStr);
54
+ // Read-before-write guard (same policy as write_file/apply_patch).
55
+ if (!wasRead(input.path, ctx.cwd) && withoutBOM.length > 200) {
56
+ throw Object.assign(new Error(`File ${input.path} was not read this session and is >200 bytes — re-read before editing (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
57
+ }
58
+ const original = withoutBOM;
59
+ const findStr = input.find;
60
+ const replaceAll = input.replaceAll === true;
61
+ const exactCount = countOccurrences(original, findStr);
62
+ let next;
63
+ let replacements;
56
64
  let fuzzyTier = null;
57
- // 4.2 fuzzy tiers (only after exact fails)
58
- if (count === 0) {
59
- const tiers = [
60
- { name: 'trailing-whitespace', transform: (s) => s.replace(/[ \t]+$/gm, '') },
61
- { name: 'indent-width', transform: (s) => s.replace(/^ {2,}/gm, (m) => '\t'.repeat(m.length / 2)) },
62
- { name: 'unicode-quotes', transform: (s) => s.replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/—/g, '-') },
63
- ];
64
- for (const tier of tiers) {
65
- const tFind = tier.transform(findStr);
66
- const tOrig = tier.transform(original);
67
- const c = countOccurrences(tOrig, tFind);
68
- if (c > 0) {
69
- findStr = tFind;
70
- original = tOrig;
71
- fuzzyTier = tier.name;
72
- count = c;
73
- break;
74
- }
75
- }
76
- // Line-window similarity >=0.95
77
- if (count === 0) {
78
- const lines = original.split('\n');
79
- let bestScore = 0;
80
- let bestIdx = -1;
81
- for (let i = 0; i < lines.length; i++) {
82
- const line = lines[i] ?? '';
83
- const score = line.length > 0 ? [...findStr].filter((ch, idx) => line[idx] === ch).length / Math.max(line.length, findStr.length) : 0;
84
- if (score > bestScore)
85
- bestScore = score;
86
- if (score >= 0.95) {
87
- bestIdx = i;
88
- break;
89
- }
90
- if (score > bestScore)
91
- bestIdx = i;
92
- }
93
- if (bestScore >= 0.95 && bestIdx >= 0) {
94
- findStr = lines[bestIdx] ?? findStr;
95
- count = 1;
96
- fuzzyTier = 'line-window-0.95';
97
- }
65
+ // Representative original text for the diff note below.
66
+ let diffNeedle = findStr;
67
+ if (exactCount > 0) {
68
+ if (!replaceAll && exactCount > 1) {
69
+ throw Object.assign(new Error(`find substring occurs ${exactCount} times in ${input.path}. Supply more context or pass replaceAll=true.`), { code: 'MATCH_AMBIGUOUS' });
98
70
  }
71
+ next = replaceAll ? original.split(findStr).join(input.replace) : original.replace(findStr, input.replace);
72
+ replacements = replaceAll ? exactCount : 1;
99
73
  }
100
- if (count === 0) {
101
- const closest = findClosestMatch(original, input.find);
102
- throw Object.assign(new Error(`find substring not present in ${input.path}. Closest match (similarity ${(closest.similarity * 100).toFixed(0)}%): line ${closest.lineRange[0]}-${closest.lineRange[1]} "${closest.snippet.slice(0, 80)}" — re-read and retry with exact context${fuzzyTier ? ` (tried fuzzy: ${fuzzyTier})` : ''}`), { code: 'MATCH_NOT_FOUND', details: closest });
103
- }
104
- const replaceAll = input.replaceAll === true;
105
- if (!replaceAll && count > 1) {
106
- throw Object.assign(new Error(`find substring occurs ${count} times in ${input.path}. Supply more context or pass replaceAll=true.`), { code: 'MATCH_AMBIGUOUS' });
74
+ else {
75
+ // Match-only fuzzy: locate candidate line spans WITHOUT rewriting the
76
+ // file for matching (a whole-file transform here used to corrupt
77
+ // indentation/whitespace/quotes across the entire file). All tiers
78
+ // preserve newline positions, so a match in transformed text maps
79
+ // back to original coordinates by line number.
80
+ const spans = locateFuzzySpans(original, findStr);
81
+ if (spans.length === 0) {
82
+ const closest = findClosestMatch(original, input.find);
83
+ throw Object.assign(new Error(`find substring not present in ${input.path}. Closest match (similarity ${(closest.similarity * 100).toFixed(0)}%): line ${closest.lineRange[0]}-${closest.lineRange[1]} "${closest.snippet.slice(0, 80)}" — re-read and retry with exact context`), { code: 'MATCH_NOT_FOUND', details: closest });
84
+ }
85
+ if (!replaceAll && spans.length > 1) {
86
+ throw Object.assign(new Error(`find substring occurs ${spans.length} times in ${input.path} (fuzzy:${spans[0].tier}). Supply more context or pass replaceAll=true.`), { code: 'MATCH_AMBIGUOUS' });
87
+ }
88
+ const targets = replaceAll ? spans : [spans[0]];
89
+ fuzzyTier = targets[0].tier;
90
+ const lines = original.split('\n');
91
+ const repLines = input.replace.split('\n');
92
+ // Last span first so earlier line numbers stay valid.
93
+ for (const s of [...targets].sort((a, b) => b.start - a.start)) {
94
+ diffNeedle = lines.slice(s.start, s.start + s.len).join('\n');
95
+ lines.splice(s.start, s.len, ...repLines);
96
+ }
97
+ next = lines.join('\n');
98
+ replacements = replaceAll ? spans.length : 1;
107
99
  }
108
- let next = replaceAll ? original.split(findStr).join(input.replace) : original.replace(findStr, input.replace);
109
100
  // Preserve EOL: normalize then convert
110
101
  if (eol === '\r\n') {
111
102
  next = next.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
@@ -145,8 +136,8 @@ export const editFileTool = defineTool({
145
136
  const diffNote = fuzzyTier ? ` [fuzzy:${fuzzyTier}]` : '';
146
137
  return {
147
138
  path: input.path,
148
- replacements: replaceAll ? count : 1,
149
- diff: simpleDiff(original, findStr, input.replace, replaceAll ? count : 1) + diffNote,
139
+ replacements,
140
+ diff: simpleDiff(original, diffNeedle, input.replace, replacements) + diffNote,
150
141
  };
151
142
  });
152
143
  },
@@ -165,6 +156,57 @@ function countOccurrences(haystack, needle) {
165
156
  }
166
157
  return count;
167
158
  }
159
+ /**
160
+ * Locate fuzzy match spans by line number. The file itself is never
161
+ * transformed — transforms exist only to find candidate positions, which
162
+ * map back 1:1 by line because every tier preserves newline positions.
163
+ */
164
+ export function locateFuzzySpans(original, find) {
165
+ const findLineCount = find.split('\n').length;
166
+ const locate = (tOrig, tFind) => {
167
+ if (tFind.length === 0)
168
+ return [];
169
+ const starts = [];
170
+ let idx = 0;
171
+ while (true) {
172
+ const at = tOrig.indexOf(tFind, idx);
173
+ if (at === -1)
174
+ break;
175
+ starts.push(tOrig.slice(0, at).split('\n').length - 1);
176
+ idx = at + Math.max(1, tFind.length);
177
+ }
178
+ return starts;
179
+ };
180
+ const tiers = [
181
+ { name: 'trailing-whitespace', transform: (s) => s.replace(/[ \t]+$/gm, '') },
182
+ { name: 'indent-width', transform: (s) => s.replace(/^ {2,}/gm, (m) => '\t'.repeat(m.length / 2)) },
183
+ { name: 'unicode-quotes', transform: (s) => s.replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/—/g, '-') },
184
+ ];
185
+ for (const tier of tiers) {
186
+ const starts = locate(tier.transform(original), tier.transform(find));
187
+ if (starts.length > 0) {
188
+ return starts.map((start) => ({ start, len: findLineCount, tier: tier.name }));
189
+ }
190
+ }
191
+ // Line-window similarity >= 0.95 (single best line only).
192
+ const lines = original.split('\n');
193
+ let bestScore = 0;
194
+ let bestIdx = -1;
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i] ?? '';
197
+ if (line.length === 0)
198
+ continue;
199
+ const score = [...find].filter((ch, idx) => line[idx] === ch).length / Math.max(line.length, find.length);
200
+ if (score > bestScore) {
201
+ bestScore = score;
202
+ bestIdx = i;
203
+ }
204
+ }
205
+ if (bestScore >= 0.95 && bestIdx >= 0) {
206
+ return [{ start: bestIdx, len: 1, tier: 'line-window-0.95' }];
207
+ }
208
+ return [];
209
+ }
168
210
  function findClosestMatch(text, needle) {
169
211
  const lines = text.split('\n');
170
212
  let bestIdx = 0;
@@ -8,6 +8,7 @@ declare const InputSchema: z.ZodObject<{
8
8
  path: z.ZodDefault<z.ZodString>;
9
9
  maxDepth: z.ZodOptional<z.ZodNumber>;
10
10
  skip: z.ZodOptional<z.ZodArray<z.ZodString>>;
11
+ showHidden: z.ZodOptional<z.ZodBoolean>;
11
12
  }, z.core.$strip>;
12
13
  export interface ListEntry {
13
14
  name: string;
@@ -22,6 +23,7 @@ export declare const listDirTool: import("../types.js").Tool<{
22
23
  path: string;
23
24
  maxDepth?: number | undefined;
24
25
  skip?: string[] | undefined;
26
+ showHidden?: boolean | undefined;
25
27
  }, {
26
28
  readonly ok: false;
27
29
  readonly error: {
@@ -14,10 +14,11 @@ const InputSchema = z.object({
14
14
  path: z.string().min(1).default('.').describe('Directory path relative to cwd. Defaults to cwd.'),
15
15
  maxDepth: z.number().int().min(1).max(5).optional().describe('How deep to recurse (default: 1 = top level only).'),
16
16
  skip: z.array(z.string()).optional().describe('Directory names to skip (default: node_modules, .git, etc.)'),
17
+ showHidden: z.boolean().optional().describe('Include dotfiles (default false — dotfiles are hidden).'),
17
18
  });
18
19
  export const listDirTool = defineTool({
19
20
  name: 'list_directory',
20
- description: 'List entries inside a directory. Skips node_modules, .git, and common build dirs by default.',
21
+ description: 'List entries inside a directory. Skips node_modules, .git, and common build dirs by default. Dotfiles hidden unless showHidden=true.',
21
22
  inputSchema: InputSchema,
22
23
  execute: async (input, ctx) => {
23
24
  return safe(async () => {
@@ -31,18 +32,19 @@ export const listDirTool = defineTool({
31
32
  }
32
33
  const skip = new Set([...DEFAULT_SKIP, ...(input.skip ?? [])]);
33
34
  const maxDepth = input.maxDepth ?? 1;
35
+ const showHidden = input.showHidden === true;
34
36
  const entries = [];
35
- await walk(resolved, resolved, 0, maxDepth, skip, entries);
37
+ await walk(resolved, resolved, 0, maxDepth, skip, showHidden, entries);
36
38
  return { path: input.path, entries };
37
39
  });
38
40
  },
39
41
  });
40
- async function walk(root, dir, depth, maxDepth, skip, out) {
42
+ async function walk(root, dir, depth, maxDepth, skip, showHidden, out) {
41
43
  const names = await fs.readdir(dir).catch(() => []);
42
44
  for (const name of names) {
43
45
  if (skip.has(name))
44
46
  continue;
45
- if (name.startsWith('.') && name !== '.' && name !== '..')
47
+ if (!showHidden && name.startsWith('.') && name !== '.' && name !== '..')
46
48
  continue;
47
49
  const full = path.join(dir, name);
48
50
  let entry;
@@ -66,7 +68,7 @@ async function walk(root, dir, depth, maxDepth, skip, out) {
66
68
  }
67
69
  out.push(entry);
68
70
  if (entry.type === 'directory' && depth + 1 < maxDepth) {
69
- await walk(root, full, depth + 1, maxDepth, skip, out);
71
+ await walk(root, full, depth + 1, maxDepth, skip, showHidden, out);
70
72
  }
71
73
  }
72
74
  }
@@ -2,10 +2,13 @@
2
2
  * 4.2 — multi_edit: atomic sequential edits with rollback on failure
3
3
  */
4
4
  import * as fs from 'node:fs/promises';
5
+ import * as crypto from 'node:crypto';
5
6
  import { z } from 'zod';
6
7
  import { defineTool } from '../types.js';
7
8
  import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
8
9
  import { safe } from '../normalize.js';
10
+ import { wasRead } from './read-history.js';
11
+ import { checkStaleness, recordEditStaleness } from './edit-file.js';
9
12
  const EditSchema = z.object({
10
13
  find: z.string().min(1),
11
14
  replace: z.string(),
@@ -24,8 +27,14 @@ export const multiEditTool = defineTool({
24
27
  execute: async (input, ctx) => {
25
28
  return safe(async () => {
26
29
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
30
+ const stat = await fs.stat(resolved);
27
31
  let content = await fs.readFile(resolved, 'utf-8');
28
- const original = content;
32
+ if (checkStaleness(resolved, stat.mtimeMs, crypto.createHash('sha256').update(content, 'utf-8').digest('hex'))) {
33
+ throw Object.assign(new Error(`File ${input.path} changed externally since last read — please re-read before editing`), { code: 'STALE' });
34
+ }
35
+ if (!wasRead(input.path, ctx.cwd) && content.length > 200) {
36
+ throw Object.assign(new Error(`File ${input.path} was not read this session and is >200 bytes — re-read before editing (POLICY_DENIED)`), { code: 'POLICY_DENIED' });
37
+ }
29
38
  for (let i = 0; i < input.edits.length; i++) {
30
39
  const e = input.edits[i];
31
40
  const count = content.split(e.find).length - 1;
@@ -44,6 +53,8 @@ export const multiEditTool = defineTool({
44
53
  await fs.unlink(tmp).catch(() => undefined);
45
54
  throw err;
46
55
  }
56
+ const newStat = await fs.stat(resolved);
57
+ recordEditStaleness(resolved, newStat.mtimeMs, crypto.createHash('sha256').update(content, 'utf-8').digest('hex'));
47
58
  return { path: input.path, edits: input.edits.length, diff: `multi_edit ${input.edits.length} edits` };
48
59
  });
49
60
  },
@@ -93,7 +93,7 @@ export const readFileTool = defineTool({
93
93
  if (truncatedTokens) {
94
94
  result.hint = 'Truncated to 8k tokens — use startLine/endLine to get more';
95
95
  }
96
- markRead(input.path);
96
+ markRead(input.path, ctx.cwd);
97
97
  // 8.2 lifecycle: store large results, duplicate detection <50 tokens
98
98
  const asStr = outLines.join('\n');
99
99
  if (asStr.length > 2000)
@@ -1,3 +1,3 @@
1
- export declare function markRead(p: string): void;
2
- export declare function wasRead(p: string): boolean;
1
+ export declare function markRead(p: string, cwd?: string): void;
2
+ export declare function wasRead(p: string, cwd?: string): boolean;
3
3
  export declare function clearReadHistory(): void;
@@ -1,12 +1,44 @@
1
- /** Track files read this session for write guard (3.2) */
1
+ /**
2
+ * Track files read this session for the write guard (3.2).
3
+ *
4
+ * Entries are stored normalized (lexical normalize + forward slashes, no
5
+ * leading ./) in raw AND cwd-resolved absolute form, so `sub/../x`,
6
+ * `./x`, and absolute-vs-relative spellings of the same file all match.
7
+ * History is per task-session: hosts call clearReadHistory() when a new
8
+ * task starts so reads don't leak across sessions in one process.
9
+ */
10
+ import * as path from 'node:path';
2
11
  const sessionReadFiles = new Set();
3
- export function markRead(p) {
4
- sessionReadFiles.add(p);
5
- // Also add normalized
6
- sessionReadFiles.add(p.replace(/\\/g, '/'));
12
+ function normalize(p) {
13
+ // Strip the drive letter: on Windows, path.normalize('/x') keeps the
14
+ // slashes while path.resolve('/x') prepends the cwd drive (A:/x) — without
15
+ // this, absolute-vs-relative spellings of the same file never match.
16
+ return path
17
+ .normalize(p)
18
+ .replace(/\\/g, '/')
19
+ .replace(/^\.\//, '')
20
+ .replace(/^[A-Za-z]:/, '');
7
21
  }
8
- export function wasRead(p) {
9
- return sessionReadFiles.has(p) || sessionReadFiles.has(p.replace(/\\/g, '/'));
22
+ export function markRead(p, cwd) {
23
+ sessionReadFiles.add(normalize(p));
24
+ if (cwd) {
25
+ try {
26
+ sessionReadFiles.add(normalize(path.resolve(cwd, p)));
27
+ }
28
+ catch { /* ignore */ }
29
+ }
30
+ }
31
+ export function wasRead(p, cwd) {
32
+ if (sessionReadFiles.has(normalize(p)))
33
+ return true;
34
+ if (cwd) {
35
+ try {
36
+ if (sessionReadFiles.has(normalize(path.resolve(cwd, p))))
37
+ return true;
38
+ }
39
+ catch { /* ignore */ }
40
+ }
41
+ return false;
10
42
  }
11
43
  export function clearReadHistory() {
12
44
  sessionReadFiles.clear();