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.
- package/dist/agent/anthropic-adapter.js +22 -2
- package/dist/agent/registry.js +11 -1
- package/dist/agent/retry.d.ts +5 -0
- package/dist/agent/retry.js +28 -1
- package/dist/agent/runtime.d.ts +2 -0
- package/dist/agent/runtime.js +48 -35
- package/dist/chat.js +2 -2
- package/dist/checkpoints/store.js +86 -7
- package/dist/cli/config.js +13 -1
- package/dist/cli/repl.js +200 -36
- package/dist/cli/run.js +88 -69
- package/dist/context/klyro-md.d.ts +5 -0
- package/dist/context/klyro-md.js +28 -6
- package/dist/context/tokenizer.js +8 -1
- package/dist/index.js +49 -14
- package/dist/persistence/session.d.ts +2 -0
- package/dist/persistence/session.js +11 -5
- package/dist/persistence/store.d.ts +7 -0
- package/dist/persistence/store.js +77 -28
- package/dist/policy/engine.d.ts +10 -1
- package/dist/policy/engine.js +33 -2
- package/dist/tools/fs/apply-patch.d.ts +16 -1
- package/dist/tools/fs/apply-patch.js +163 -75
- package/dist/tools/fs/edit-file.d.ts +13 -0
- package/dist/tools/fs/edit-file.js +96 -54
- package/dist/tools/fs/list-dir.d.ts +2 -0
- package/dist/tools/fs/list-dir.js +7 -5
- package/dist/tools/fs/multi-edit.js +12 -1
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/read-history.d.ts +2 -2
- package/dist/tools/fs/read-history.js +39 -7
- package/dist/tools/fs/write-file.js +1 -1
- package/dist/tools/normalize.js +11 -1
- package/dist/tools/shell/background.d.ts +5 -0
- package/dist/tools/shell/background.js +29 -0
- package/dist/tools/shell/shell-exec.d.ts +9 -0
- package/dist/tools/shell/shell-exec.js +54 -8
- package/dist/tools/verify/run-verify.js +2 -2
- package/dist/tui/app.js +10 -5
- package/dist/tui/app.test.js +42 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +20 -5
- package/dist/tui/diff.d.ts +5 -19
- package/dist/tui/diff.js +9 -25
- package/dist/tui/status.d.ts +4 -8
- package/dist/tui/status.js +8 -17
- package/dist/tui/transcript.d.ts +13 -20
- package/dist/tui/transcript.js +9 -74
- package/dist/verification/engine.js +4 -4
- package/package.json +1 -1
|
@@ -1,112 +1,200 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 4.2 — apply_patch: Codex-style unified patch
|
|
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
|
|
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).
|
|
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
|
-
|
|
24
|
-
let
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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 (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
109
|
-
patchedFiles.push(
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
149
|
-
diff: simpleDiff(original,
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
|
9
|
-
|
|
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();
|