@ottocode/sdk 0.1.235 → 0.1.237
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/package.json +1 -1
- package/src/config/src/manager.ts +55 -0
- package/src/config/src/paths.ts +27 -0
- package/src/core/src/tools/builtin/fs/edit-shared.ts +83 -0
- package/src/core/src/tools/builtin/fs/edit.ts +129 -0
- package/src/core/src/tools/builtin/fs/edit.txt +9 -0
- package/src/core/src/tools/builtin/fs/index.ts +4 -0
- package/src/core/src/tools/builtin/fs/multiedit.ts +137 -0
- package/src/core/src/tools/builtin/fs/multiedit.txt +9 -0
- package/src/core/src/tools/builtin/fs/read-tracker.ts +72 -0
- package/src/core/src/tools/builtin/fs/read.ts +2 -0
- package/src/core/src/tools/builtin/fs/write.ts +2 -0
- package/src/core/src/tools/builtin/fs/write.txt +1 -1
- package/src/core/src/utils/debug.ts +28 -1
- package/src/core/src/utils/logger.ts +116 -27
- package/src/index.ts +9 -1
- package/src/prompts/src/agents/build.txt +9 -2
- package/src/prompts/src/base.txt +10 -7
- package/src/prompts/src/debug.ts +0 -7
- package/src/prompts/src/providers/default.txt +25 -14
- package/src/prompts/src/providers/glm.txt +25 -14
- package/src/prompts/src/providers/google.txt +3 -3
- package/src/prompts/src/providers/moonshot.txt +25 -14
- package/src/prompts/src/providers/openai.txt +1 -1
- package/src/providers/src/catalog.ts +497 -7
- package/src/types/src/config.ts +2 -0
package/package.json
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
getGlobalConfigDir,
|
|
11
11
|
getGlobalConfigPath,
|
|
12
|
+
getGlobalDebugDir,
|
|
13
|
+
getGlobalDebugLogPath,
|
|
14
|
+
getGlobalDebugSessionsDir,
|
|
12
15
|
getLocalDataDir,
|
|
13
16
|
joinPath,
|
|
14
17
|
} from './paths.ts';
|
|
@@ -20,6 +23,14 @@ import {
|
|
|
20
23
|
|
|
21
24
|
export type Scope = 'global' | 'local';
|
|
22
25
|
|
|
26
|
+
export type DebugConfig = {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
scopes: string[];
|
|
29
|
+
logPath: string;
|
|
30
|
+
sessionsDir: string;
|
|
31
|
+
debugDir: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
23
34
|
export async function read(projectRoot?: string) {
|
|
24
35
|
const cfg = await loadConfig(projectRoot);
|
|
25
36
|
const auth = await getAllAuth(projectRoot);
|
|
@@ -111,6 +122,50 @@ export async function writeDefaults(
|
|
|
111
122
|
await Bun.write(globalPath, JSON.stringify(next, null, 2));
|
|
112
123
|
}
|
|
113
124
|
|
|
125
|
+
export async function readDebugConfig(
|
|
126
|
+
projectRoot?: string,
|
|
127
|
+
): Promise<DebugConfig> {
|
|
128
|
+
const cfg = await loadConfig(projectRoot);
|
|
129
|
+
return {
|
|
130
|
+
enabled: cfg.debugEnabled === true,
|
|
131
|
+
scopes: Array.isArray(cfg.debugScopes)
|
|
132
|
+
? cfg.debugScopes.filter(
|
|
133
|
+
(scope): scope is string =>
|
|
134
|
+
typeof scope === 'string' && scope.trim().length > 0,
|
|
135
|
+
)
|
|
136
|
+
: [],
|
|
137
|
+
logPath: getGlobalDebugLogPath(),
|
|
138
|
+
sessionsDir: getGlobalDebugSessionsDir(),
|
|
139
|
+
debugDir: getGlobalDebugDir(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function writeDebugConfig(
|
|
144
|
+
updates: Partial<{
|
|
145
|
+
enabled: boolean;
|
|
146
|
+
scopes: string[];
|
|
147
|
+
}>,
|
|
148
|
+
) {
|
|
149
|
+
const globalPath = getGlobalConfigPath();
|
|
150
|
+
const existing = await readJsonFile(globalPath);
|
|
151
|
+
const next: Record<string, unknown> = { ...(existing ?? {}) };
|
|
152
|
+
|
|
153
|
+
if (updates.enabled !== undefined) {
|
|
154
|
+
next.debugEnabled = updates.enabled;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (updates.scopes !== undefined) {
|
|
158
|
+
next.debugScopes = updates.scopes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const base = getGlobalConfigDir();
|
|
162
|
+
try {
|
|
163
|
+
const { promises: fs } = await import('node:fs');
|
|
164
|
+
await fs.mkdir(base, { recursive: true }).catch(() => {});
|
|
165
|
+
} catch {}
|
|
166
|
+
await Bun.write(globalPath, JSON.stringify(next, null, 2));
|
|
167
|
+
}
|
|
168
|
+
|
|
114
169
|
async function readJsonFile(
|
|
115
170
|
filePath: string,
|
|
116
171
|
): Promise<Record<string, unknown> | undefined> {
|
package/src/config/src/paths.ts
CHANGED
|
@@ -79,6 +79,33 @@ export function getGlobalCommandsDir(): string {
|
|
|
79
79
|
return joinPath(getGlobalConfigDir(), 'commands');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export function getGlobalDebugDir(): string {
|
|
83
|
+
return joinPath(getGlobalConfigDir(), 'debug');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getGlobalDebugLogPath(): string {
|
|
87
|
+
return joinPath(getGlobalDebugDir(), 'latest.log');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getGlobalDebugSessionsDir(): string {
|
|
91
|
+
return joinPath(getGlobalDebugDir(), 'sessions');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getSessionDebugLogPath(sessionId: string): string {
|
|
95
|
+
return joinPath(getGlobalDebugSessionsDir(), `${sessionId}.log`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getSessionDebugDetailsLogPath(sessionId: string): string {
|
|
99
|
+
return joinPath(getGlobalDebugSessionsDir(), `${sessionId}.details.log`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getSessionSystemPromptPath(sessionId: string): string {
|
|
103
|
+
return joinPath(
|
|
104
|
+
getGlobalDebugSessionsDir(),
|
|
105
|
+
`${sessionId}.system-prompt.txt`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
export function getLocalDataDir(projectRoot: string): string {
|
|
83
110
|
return joinPath(projectRoot, '.otto');
|
|
84
111
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export function normalizeLineEndings(text: string): string {
|
|
2
|
+
return text.replace(/\r\n/g, '\n');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function detectLineEnding(text: string): '\n' | '\r\n' {
|
|
6
|
+
return text.includes('\r\n') ? '\r\n' : '\n';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function convertToLineEnding(
|
|
10
|
+
text: string,
|
|
11
|
+
lineEnding: '\n' | '\r\n',
|
|
12
|
+
): string {
|
|
13
|
+
if (lineEnding === '\n') return text;
|
|
14
|
+
return text.replace(/\n/g, '\r\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function countOccurrences(content: string, search: string): number {
|
|
18
|
+
if (!search) return 0;
|
|
19
|
+
let count = 0;
|
|
20
|
+
let start = 0;
|
|
21
|
+
while (true) {
|
|
22
|
+
const index = content.indexOf(search, start);
|
|
23
|
+
if (index === -1) return count;
|
|
24
|
+
count += 1;
|
|
25
|
+
start = index + search.length;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyStringEdit(
|
|
30
|
+
content: string,
|
|
31
|
+
oldString: string,
|
|
32
|
+
newString: string,
|
|
33
|
+
replaceAll = false,
|
|
34
|
+
): { content: string; occurrences: number } {
|
|
35
|
+
if (oldString.length === 0) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'oldString must not be empty. Use write to create files or apply_patch for structural insertions.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (oldString === newString) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'No changes to apply: oldString and newString are identical.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lineEnding = detectLineEnding(content);
|
|
47
|
+
const normalizedOld = convertToLineEnding(
|
|
48
|
+
normalizeLineEndings(oldString),
|
|
49
|
+
lineEnding,
|
|
50
|
+
);
|
|
51
|
+
const normalizedNew = convertToLineEnding(
|
|
52
|
+
normalizeLineEndings(newString),
|
|
53
|
+
lineEnding,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const occurrences = countOccurrences(content, normalizedOld);
|
|
57
|
+
if (occurrences === 0) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'oldString not found in content. Read the file again and copy the exact text, including whitespace.',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (occurrences > 1 && !replaceAll) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'Found multiple matches for oldString. Provide more surrounding lines to make it unique or set replaceAll to true.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (replaceAll) {
|
|
69
|
+
return {
|
|
70
|
+
content: content.split(normalizedOld).join(normalizedNew),
|
|
71
|
+
occurrences,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const index = content.indexOf(normalizedOld);
|
|
76
|
+
return {
|
|
77
|
+
content:
|
|
78
|
+
content.slice(0, index) +
|
|
79
|
+
normalizedNew +
|
|
80
|
+
content.slice(index + normalizedOld.length),
|
|
81
|
+
occurrences,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tool, type Tool } from 'ai';
|
|
3
|
+
import { z } from 'zod/v3';
|
|
4
|
+
import DESCRIPTION from './edit.txt' with { type: 'text' };
|
|
5
|
+
import { buildWriteArtifact, isAbsoluteLike, resolveSafePath } from './util.ts';
|
|
6
|
+
import { applyStringEdit } from './edit-shared.ts';
|
|
7
|
+
import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
|
|
8
|
+
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
9
|
+
|
|
10
|
+
export function buildEditTool(projectRoot: string): {
|
|
11
|
+
name: string;
|
|
12
|
+
tool: Tool;
|
|
13
|
+
} {
|
|
14
|
+
const edit = tool({
|
|
15
|
+
description: DESCRIPTION,
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
path: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe(
|
|
20
|
+
'Relative file path within the project. Absolute paths are not allowed.',
|
|
21
|
+
),
|
|
22
|
+
oldString: z.string().describe('Exact text to replace'),
|
|
23
|
+
newString: z.string().describe('Replacement text'),
|
|
24
|
+
replaceAll: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.default(false)
|
|
28
|
+
.describe(
|
|
29
|
+
'Replace every matching occurrence instead of requiring a unique match',
|
|
30
|
+
),
|
|
31
|
+
}),
|
|
32
|
+
async execute({
|
|
33
|
+
path,
|
|
34
|
+
oldString,
|
|
35
|
+
newString,
|
|
36
|
+
replaceAll = false,
|
|
37
|
+
}: {
|
|
38
|
+
path: string;
|
|
39
|
+
oldString: string;
|
|
40
|
+
newString: string;
|
|
41
|
+
replaceAll?: boolean;
|
|
42
|
+
}): Promise<
|
|
43
|
+
ToolResponse<{
|
|
44
|
+
path: string;
|
|
45
|
+
occurrences: number;
|
|
46
|
+
bytes: number;
|
|
47
|
+
artifact: unknown;
|
|
48
|
+
}>
|
|
49
|
+
> {
|
|
50
|
+
if (!path || path.trim().length === 0) {
|
|
51
|
+
return createToolError(
|
|
52
|
+
'Missing required parameter: path',
|
|
53
|
+
'validation',
|
|
54
|
+
{
|
|
55
|
+
parameter: 'path',
|
|
56
|
+
value: path,
|
|
57
|
+
suggestion: 'Provide a relative file path to edit',
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (isAbsoluteLike(path)) {
|
|
62
|
+
return createToolError(
|
|
63
|
+
`Refusing to edit outside project root: ${path}`,
|
|
64
|
+
'permission',
|
|
65
|
+
{
|
|
66
|
+
parameter: 'path',
|
|
67
|
+
value: path,
|
|
68
|
+
suggestion: 'Use a relative path within the project',
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const abs = resolveSafePath(projectRoot, path);
|
|
74
|
+
try {
|
|
75
|
+
await assertFreshRead(projectRoot, abs, path);
|
|
76
|
+
const original = await readFile(abs, 'utf-8');
|
|
77
|
+
const updated = applyStringEdit(
|
|
78
|
+
original,
|
|
79
|
+
oldString,
|
|
80
|
+
newString,
|
|
81
|
+
replaceAll,
|
|
82
|
+
);
|
|
83
|
+
if (updated.content === original) {
|
|
84
|
+
return createToolError('No changes applied.', 'validation', {
|
|
85
|
+
suggestion:
|
|
86
|
+
'Adjust oldString/newString so the file content actually changes',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await writeFile(abs, updated.content, 'utf-8');
|
|
91
|
+
await rememberFileWrite(projectRoot, abs);
|
|
92
|
+
const artifact = await buildWriteArtifact(
|
|
93
|
+
path,
|
|
94
|
+
true,
|
|
95
|
+
original,
|
|
96
|
+
updated.content,
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
path,
|
|
101
|
+
occurrences: updated.occurrences,
|
|
102
|
+
bytes: updated.content.length,
|
|
103
|
+
artifact,
|
|
104
|
+
};
|
|
105
|
+
} catch (error: unknown) {
|
|
106
|
+
const isEnoent =
|
|
107
|
+
error &&
|
|
108
|
+
typeof error === 'object' &&
|
|
109
|
+
'code' in error &&
|
|
110
|
+
error.code === 'ENOENT';
|
|
111
|
+
return createToolError(
|
|
112
|
+
isEnoent
|
|
113
|
+
? `File not found: ${path}`
|
|
114
|
+
: `Failed to edit file: ${error instanceof Error ? error.message : String(error)}`,
|
|
115
|
+
isEnoent ? 'not_found' : 'execution',
|
|
116
|
+
{
|
|
117
|
+
parameter: 'path',
|
|
118
|
+
value: path,
|
|
119
|
+
suggestion: isEnoent
|
|
120
|
+
? 'Use read or ls to confirm the file path first'
|
|
121
|
+
: undefined,
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return { name: 'edit', tool: edit };
|
|
129
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Replace an exact text block in an existing file.
|
|
2
|
+
|
|
3
|
+
Use this for targeted edits instead of apply_patch whenever possible.
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- You must read the file first in the current session before editing it.
|
|
7
|
+
- `oldString` must match the file exactly, including whitespace.
|
|
8
|
+
- If `oldString` appears multiple times, provide more context or set `replaceAll: true`.
|
|
9
|
+
- Use `write` for new files and `apply_patch` for structural multi-file diffs.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Tool } from 'ai';
|
|
2
|
+
import { buildEditTool } from './edit.ts';
|
|
2
3
|
import { buildReadTool } from './read.ts';
|
|
4
|
+
import { buildMultiEditTool } from './multiedit.ts';
|
|
3
5
|
import { buildWriteTool } from './write.ts';
|
|
4
6
|
import { buildLsTool } from './ls.ts';
|
|
5
7
|
import { buildTreeTool } from './tree.ts';
|
|
@@ -11,6 +13,8 @@ export function buildFsTools(
|
|
|
11
13
|
): Array<{ name: string; tool: Tool }> {
|
|
12
14
|
const out: Array<{ name: string; tool: Tool }> = [];
|
|
13
15
|
out.push(buildReadTool(projectRoot));
|
|
16
|
+
out.push(buildEditTool(projectRoot));
|
|
17
|
+
out.push(buildMultiEditTool(projectRoot));
|
|
14
18
|
out.push(buildWriteTool(projectRoot));
|
|
15
19
|
out.push(buildLsTool(projectRoot));
|
|
16
20
|
out.push(buildTreeTool(projectRoot));
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tool, type Tool } from 'ai';
|
|
3
|
+
import { z } from 'zod/v3';
|
|
4
|
+
import DESCRIPTION from './multiedit.txt' with { type: 'text' };
|
|
5
|
+
import { buildWriteArtifact, isAbsoluteLike, resolveSafePath } from './util.ts';
|
|
6
|
+
import { applyStringEdit } from './edit-shared.ts';
|
|
7
|
+
import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
|
|
8
|
+
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
9
|
+
|
|
10
|
+
const multiEditSchema = z.object({
|
|
11
|
+
path: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe(
|
|
14
|
+
'Relative file path within the project. Absolute paths are not allowed.',
|
|
15
|
+
),
|
|
16
|
+
edits: z
|
|
17
|
+
.array(
|
|
18
|
+
z.object({
|
|
19
|
+
oldString: z.string().describe('Exact text to replace'),
|
|
20
|
+
newString: z.string().describe('Replacement text'),
|
|
21
|
+
replaceAll: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.optional()
|
|
24
|
+
.default(false)
|
|
25
|
+
.describe('Replace every matching occurrence for this edit'),
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
.min(1)
|
|
29
|
+
.describe('Edits to apply sequentially to the same file'),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function buildMultiEditTool(projectRoot: string): {
|
|
33
|
+
name: string;
|
|
34
|
+
tool: Tool;
|
|
35
|
+
} {
|
|
36
|
+
const multiedit = tool({
|
|
37
|
+
description: DESCRIPTION,
|
|
38
|
+
inputSchema: multiEditSchema,
|
|
39
|
+
async execute({ path, edits }: z.infer<typeof multiEditSchema>): Promise<
|
|
40
|
+
ToolResponse<{
|
|
41
|
+
path: string;
|
|
42
|
+
editsApplied: number;
|
|
43
|
+
bytes: number;
|
|
44
|
+
artifact: unknown;
|
|
45
|
+
}>
|
|
46
|
+
> {
|
|
47
|
+
if (!path || path.trim().length === 0) {
|
|
48
|
+
return createToolError(
|
|
49
|
+
'Missing required parameter: path',
|
|
50
|
+
'validation',
|
|
51
|
+
{
|
|
52
|
+
parameter: 'path',
|
|
53
|
+
value: path,
|
|
54
|
+
suggestion: 'Provide a relative file path to edit',
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (isAbsoluteLike(path)) {
|
|
59
|
+
return createToolError(
|
|
60
|
+
`Refusing to edit outside project root: ${path}`,
|
|
61
|
+
'permission',
|
|
62
|
+
{
|
|
63
|
+
parameter: 'path',
|
|
64
|
+
value: path,
|
|
65
|
+
suggestion: 'Use a relative path within the project',
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const abs = resolveSafePath(projectRoot, path);
|
|
71
|
+
try {
|
|
72
|
+
await assertFreshRead(projectRoot, abs, path);
|
|
73
|
+
const original = await readFile(abs, 'utf-8');
|
|
74
|
+
let nextContent = original;
|
|
75
|
+
for (let i = 0; i < edits.length; i++) {
|
|
76
|
+
const edit = edits[i];
|
|
77
|
+
try {
|
|
78
|
+
nextContent = applyStringEdit(
|
|
79
|
+
nextContent,
|
|
80
|
+
edit.oldString,
|
|
81
|
+
edit.newString,
|
|
82
|
+
edit.replaceAll,
|
|
83
|
+
).content;
|
|
84
|
+
} catch (error: unknown) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Edit ${i + 1} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (nextContent === original) {
|
|
92
|
+
return createToolError('No changes applied.', 'validation', {
|
|
93
|
+
suggestion:
|
|
94
|
+
'Adjust your edits so the file content actually changes',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await writeFile(abs, nextContent, 'utf-8');
|
|
99
|
+
await rememberFileWrite(projectRoot, abs);
|
|
100
|
+
const artifact = await buildWriteArtifact(
|
|
101
|
+
path,
|
|
102
|
+
true,
|
|
103
|
+
original,
|
|
104
|
+
nextContent,
|
|
105
|
+
);
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
path,
|
|
109
|
+
editsApplied: edits.length,
|
|
110
|
+
bytes: nextContent.length,
|
|
111
|
+
artifact,
|
|
112
|
+
};
|
|
113
|
+
} catch (error: unknown) {
|
|
114
|
+
const isEnoent =
|
|
115
|
+
error &&
|
|
116
|
+
typeof error === 'object' &&
|
|
117
|
+
'code' in error &&
|
|
118
|
+
error.code === 'ENOENT';
|
|
119
|
+
return createToolError(
|
|
120
|
+
isEnoent
|
|
121
|
+
? `File not found: ${path}`
|
|
122
|
+
: `Failed to edit file: ${error instanceof Error ? error.message : String(error)}`,
|
|
123
|
+
isEnoent ? 'not_found' : 'execution',
|
|
124
|
+
{
|
|
125
|
+
parameter: 'path',
|
|
126
|
+
value: path,
|
|
127
|
+
suggestion: isEnoent
|
|
128
|
+
? 'Use read or ls to confirm the file path first'
|
|
129
|
+
: undefined,
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return { name: 'multiedit', tool: multiedit };
|
|
137
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Apply multiple exact text replacements to a single existing file atomically.
|
|
2
|
+
|
|
3
|
+
Use this when you need several edits in one file.
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- Read the file first before editing.
|
|
7
|
+
- Each edit uses exact `oldString` -> `newString` replacement.
|
|
8
|
+
- All edits must succeed or none are written.
|
|
9
|
+
- Use `replaceAll: true` only when every occurrence should change.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve as resolvePath } from 'node:path';
|
|
3
|
+
|
|
4
|
+
type FileStamp = {
|
|
5
|
+
readAt: number;
|
|
6
|
+
mtimeMs?: number;
|
|
7
|
+
ctimeMs?: number;
|
|
8
|
+
size?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const readState = new Map<string, Map<string, FileStamp>>();
|
|
12
|
+
|
|
13
|
+
function getProjectState(projectRoot: string): Map<string, FileStamp> {
|
|
14
|
+
const key = resolvePath(projectRoot);
|
|
15
|
+
let state = readState.get(key);
|
|
16
|
+
if (!state) {
|
|
17
|
+
state = new Map<string, FileStamp>();
|
|
18
|
+
readState.set(key, state);
|
|
19
|
+
}
|
|
20
|
+
return state;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function captureFileStamp(absPath: string): Promise<FileStamp> {
|
|
24
|
+
const stats = await stat(absPath);
|
|
25
|
+
return {
|
|
26
|
+
readAt: Date.now(),
|
|
27
|
+
mtimeMs: Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : undefined,
|
|
28
|
+
ctimeMs: Number.isFinite(stats.ctimeMs) ? stats.ctimeMs : undefined,
|
|
29
|
+
size: typeof stats.size === 'number' ? stats.size : undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function rememberFileRead(
|
|
34
|
+
projectRoot: string,
|
|
35
|
+
absPath: string,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const state = getProjectState(projectRoot);
|
|
38
|
+
state.set(absPath, await captureFileStamp(absPath));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function rememberFileWrite(
|
|
42
|
+
projectRoot: string,
|
|
43
|
+
absPath: string,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const state = getProjectState(projectRoot);
|
|
46
|
+
state.set(absPath, await captureFileStamp(absPath));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function assertFreshRead(
|
|
50
|
+
projectRoot: string,
|
|
51
|
+
absPath: string,
|
|
52
|
+
displayPath: string,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const state = getProjectState(projectRoot);
|
|
55
|
+
const previous = state.get(absPath);
|
|
56
|
+
if (!previous) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`You must read file ${displayPath} before editing it. Use the read tool first.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const current = await captureFileStamp(absPath);
|
|
63
|
+
const changed =
|
|
64
|
+
current.mtimeMs !== previous.mtimeMs ||
|
|
65
|
+
current.ctimeMs !== previous.ctimeMs ||
|
|
66
|
+
current.size !== previous.size;
|
|
67
|
+
if (!changed) return;
|
|
68
|
+
|
|
69
|
+
throw new Error(
|
|
70
|
+
`File ${displayPath} has changed since it was last read. Read it again before editing.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -2,6 +2,7 @@ import { tool, type Tool } from 'ai';
|
|
|
2
2
|
import { z } from 'zod/v3';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { expandTilde, isAbsoluteLike, resolveSafePath } from './util.ts';
|
|
5
|
+
import { rememberFileRead } from './read-tracker.ts';
|
|
5
6
|
import DESCRIPTION from './read.txt' with { type: 'text' };
|
|
6
7
|
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
7
8
|
|
|
@@ -108,6 +109,7 @@ export function buildReadTool(projectRoot: string): {
|
|
|
108
109
|
try {
|
|
109
110
|
let content = await readFile(abs, 'utf-8');
|
|
110
111
|
const indent = detectIndentation(content);
|
|
112
|
+
await rememberFileRead(projectRoot, abs);
|
|
111
113
|
|
|
112
114
|
if (startLine !== undefined && endLine !== undefined) {
|
|
113
115
|
const lines = content.split('\n');
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
expandTilde,
|
|
9
9
|
isAbsoluteLike,
|
|
10
10
|
} from './util.ts';
|
|
11
|
+
import { rememberFileWrite } from './read-tracker.ts';
|
|
11
12
|
import DESCRIPTION from './write.txt' with { type: 'text' };
|
|
12
13
|
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
13
14
|
|
|
@@ -79,6 +80,7 @@ export function buildWriteTool(projectRoot: string): {
|
|
|
79
80
|
existed = true;
|
|
80
81
|
} catch {}
|
|
81
82
|
await writeFile(abs, content);
|
|
83
|
+
await rememberFileWrite(projectRoot, abs);
|
|
82
84
|
const artifact = await buildWriteArtifact(
|
|
83
85
|
req,
|
|
84
86
|
existed,
|
|
@@ -6,6 +6,6 @@
|
|
|
6
6
|
Usage tips:
|
|
7
7
|
- Use for creating NEW files
|
|
8
8
|
- Use when replacing >70% of a file's content (almost complete rewrite)
|
|
9
|
-
- NEVER use for partial/targeted edits - use apply_patch
|
|
9
|
+
- NEVER use for partial/targeted edits - use edit/multiedit first, or apply_patch for structural changes
|
|
10
10
|
- Using write for partial edits wastes output tokens and risks hallucinating unchanged parts
|
|
11
11
|
- Prefer idempotent writes by providing the full intended content when you do use write
|
|
@@ -1,7 +1,34 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { getGlobalConfigPath } from '../../../config/src/paths.ts';
|
|
3
|
+
|
|
4
|
+
type DebugSettings = {
|
|
5
|
+
debugEnabled?: boolean;
|
|
6
|
+
debugScopes?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function readDebugSettings(): DebugSettings {
|
|
10
|
+
try {
|
|
11
|
+
const raw = readFileSync(getGlobalConfigPath(), 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw) as DebugSettings;
|
|
13
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
export function isDebugEnabled(): boolean {
|
|
2
|
-
return
|
|
20
|
+
return readDebugSettings().debugEnabled === true;
|
|
3
21
|
}
|
|
4
22
|
|
|
5
23
|
export function isTraceEnabled(): boolean {
|
|
6
24
|
return false;
|
|
7
25
|
}
|
|
26
|
+
|
|
27
|
+
export function getDebugScopes(): string[] {
|
|
28
|
+
const scopes = readDebugSettings().debugScopes;
|
|
29
|
+
if (!Array.isArray(scopes)) return [];
|
|
30
|
+
return scopes.filter(
|
|
31
|
+
(scope): scope is string =>
|
|
32
|
+
typeof scope === 'string' && scope.trim().length > 0,
|
|
33
|
+
);
|
|
34
|
+
}
|