@thegitai/cli 1.0.0-beta.6 → 1.0.0-beta.8
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/src/api/chat.js +2 -0
- package/dist/src/artifact-policy.js +9 -0
- package/dist/src/client-environment.js +127 -0
- package/dist/src/edit-journal.js +39 -6
- package/dist/src/patcher.js +17 -0
- package/dist/src/scanner.js +8 -5
- package/dist/src/session-safety.js +64 -12
- package/dist/src/tool-executor.js +8 -0
- package/dist/src/tools/index.js +2 -0
- package/dist/src/tools/patch-file.js +13 -0
- package/dist/src/tools/path-suggest.js +66 -0
- package/dist/src/tools/read-document.js +14 -3
- package/dist/src/tools/read-file.js +9 -0
- package/dist/src/tools/replace-document-text.js +242 -0
- package/dist/src/tools/str-replace.js +13 -0
- package/dist/src/tools/undo-edit.js +6 -4
- package/dist/src/tools/write-file.js +13 -0
- package/package.json +5 -5
package/dist/src/api/chat.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../sess
|
|
|
2
2
|
import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
|
|
3
3
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
4
4
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
5
|
+
import { collectClientEnvironment } from '../client-environment.js';
|
|
5
6
|
export class TurnCancelledError extends Error {
|
|
6
7
|
name = 'TurnCancelledError';
|
|
7
8
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -288,6 +289,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
288
289
|
modelId: session.modelId,
|
|
289
290
|
session: snapshotForServer(session),
|
|
290
291
|
input,
|
|
292
|
+
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
291
293
|
imageAttachments,
|
|
292
294
|
maxToolSteps: session.maxToolSteps,
|
|
293
295
|
autoYes: session.autoYes,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const PACKAGE_MANAGER_CANDIDATES = [
|
|
5
|
+
'apt',
|
|
6
|
+
'apt-get',
|
|
7
|
+
'dnf',
|
|
8
|
+
'yum',
|
|
9
|
+
'pacman',
|
|
10
|
+
'zypper',
|
|
11
|
+
'apk',
|
|
12
|
+
'brew',
|
|
13
|
+
'nix',
|
|
14
|
+
'snap',
|
|
15
|
+
'flatpak',
|
|
16
|
+
'winget',
|
|
17
|
+
'choco',
|
|
18
|
+
'scoop',
|
|
19
|
+
];
|
|
20
|
+
function detectShell(platform, env) {
|
|
21
|
+
if (platform === 'win32') {
|
|
22
|
+
const comspec = env.COMSPEC;
|
|
23
|
+
return comspec ? path.win32.basename(comspec) : 'unknown';
|
|
24
|
+
}
|
|
25
|
+
const shell = env.SHELL;
|
|
26
|
+
return shell ? path.basename(shell) : 'unknown';
|
|
27
|
+
}
|
|
28
|
+
function unquoteOsReleaseValue(value) {
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (trimmed.length < 2)
|
|
31
|
+
return trimmed;
|
|
32
|
+
const quote = trimmed[0];
|
|
33
|
+
if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) {
|
|
34
|
+
return trimmed;
|
|
35
|
+
}
|
|
36
|
+
return trimmed
|
|
37
|
+
.slice(1, -1)
|
|
38
|
+
.replace(/\\(["'`$\\])/g, '$1')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
export function parseLinuxOsRelease(text) {
|
|
42
|
+
const values = {};
|
|
43
|
+
for (const line of text.split(/\r?\n/)) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
46
|
+
continue;
|
|
47
|
+
const index = trimmed.indexOf('=');
|
|
48
|
+
if (index <= 0)
|
|
49
|
+
continue;
|
|
50
|
+
const key = trimmed.slice(0, index);
|
|
51
|
+
const value = unquoteOsReleaseValue(trimmed.slice(index + 1));
|
|
52
|
+
values[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
distroId: values.ID,
|
|
56
|
+
distroName: values.PRETTY_NAME ?? values.NAME,
|
|
57
|
+
distroVersion: values.VERSION_ID ?? values.VERSION,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function readLinuxOsReleaseText(options) {
|
|
61
|
+
if ('osReleaseText' in options) {
|
|
62
|
+
return options.osReleaseText ?? '';
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync('/etc/os-release', 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function pathEnv(env) {
|
|
72
|
+
return env.PATH ?? env.Path ?? env.path ?? '';
|
|
73
|
+
}
|
|
74
|
+
function windowsExecutableNames(command, env) {
|
|
75
|
+
if (path.extname(command))
|
|
76
|
+
return [command];
|
|
77
|
+
const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
|
|
78
|
+
.split(';')
|
|
79
|
+
.map((extension) => extension.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
return [command, ...extensions.map((extension) => `${command}${extension}`)];
|
|
82
|
+
}
|
|
83
|
+
function defaultExecutableExists(command, env, platform) {
|
|
84
|
+
const searchPath = pathEnv(env);
|
|
85
|
+
if (!searchPath)
|
|
86
|
+
return false;
|
|
87
|
+
const names = platform === 'win32' ? windowsExecutableNames(command, env) : [command];
|
|
88
|
+
for (const dir of searchPath.split(path.delimiter)) {
|
|
89
|
+
if (!dir.trim())
|
|
90
|
+
continue;
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
try {
|
|
93
|
+
accessSync(path.join(dir, name), constants.X_OK);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
try {
|
|
98
|
+
accessSync(path.join(dir, name));
|
|
99
|
+
return platform === 'win32';
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function detectPackageManagers(env, platform, executableExists) {
|
|
110
|
+
return PACKAGE_MANAGER_CANDIDATES.filter((command) => executableExists(command, env, platform));
|
|
111
|
+
}
|
|
112
|
+
export function collectClientEnvironment(options = {}) {
|
|
113
|
+
const platform = options.platform ?? process.platform;
|
|
114
|
+
const env = options.env ?? process.env;
|
|
115
|
+
const executableExists = options.executableExists ?? defaultExecutableExists;
|
|
116
|
+
const linuxDistro = platform === 'linux'
|
|
117
|
+
? parseLinuxOsRelease(readLinuxOsReleaseText(options))
|
|
118
|
+
: {};
|
|
119
|
+
return {
|
|
120
|
+
platform,
|
|
121
|
+
arch: options.arch ?? process.arch,
|
|
122
|
+
release: options.release ?? os.release(),
|
|
123
|
+
shell: detectShell(platform, env),
|
|
124
|
+
...linuxDistro,
|
|
125
|
+
packageManagers: detectPackageManagers(env, platform, executableExists),
|
|
126
|
+
};
|
|
127
|
+
}
|
package/dist/src/edit-journal.js
CHANGED
|
@@ -1,33 +1,64 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { BINARY_ARTIFACT_EXTENSIONS } from './artifact-policy.js';
|
|
4
6
|
import { resolveProjectPath } from './patcher.js';
|
|
5
7
|
export const MAX_EDIT_JOURNAL_RECORDS = 50;
|
|
6
|
-
const MAX_STORED_CONTENT_CHARS =
|
|
7
|
-
export function
|
|
8
|
+
const MAX_STORED_CONTENT_CHARS = 8_000_000;
|
|
9
|
+
export function hashBytes(content) {
|
|
8
10
|
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
9
11
|
}
|
|
12
|
+
export function hashContent(content) {
|
|
13
|
+
return hashBytes(Buffer.from(content, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
export function hashStoredContent(content, encoding = 'utf8') {
|
|
16
|
+
return encoding === 'base64'
|
|
17
|
+
? hashBytes(Buffer.from(content, 'base64'))
|
|
18
|
+
: hashContent(content);
|
|
19
|
+
}
|
|
20
|
+
export function storedContentBuffer(content, encoding = 'utf8') {
|
|
21
|
+
return encoding === 'base64'
|
|
22
|
+
? Buffer.from(content, 'base64')
|
|
23
|
+
: Buffer.from(content, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
function snapshotEncoding(filePath, content) {
|
|
26
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
27
|
+
if (BINARY_ARTIFACT_EXTENSIONS.has(ext))
|
|
28
|
+
return 'base64';
|
|
29
|
+
if (content.includes(0))
|
|
30
|
+
return 'base64';
|
|
31
|
+
return 'utf8';
|
|
32
|
+
}
|
|
10
33
|
export function readFileEditSnapshot(rootDir, filePath) {
|
|
11
34
|
try {
|
|
12
35
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
13
36
|
if (!existsSync(absPath)) {
|
|
14
|
-
return { exists: false, content: null, hash: null };
|
|
37
|
+
return { exists: false, content: null, contentEncoding: 'utf8', hash: null };
|
|
15
38
|
}
|
|
16
39
|
if (lstatSync(absPath).isSymbolicLink()) {
|
|
17
40
|
return {
|
|
18
41
|
exists: true,
|
|
19
42
|
content: null,
|
|
43
|
+
contentEncoding: 'utf8',
|
|
20
44
|
hash: null,
|
|
21
45
|
error: `Refusing to snapshot symbolic link: ${filePath}`,
|
|
22
46
|
};
|
|
23
47
|
}
|
|
24
|
-
const content = readFileSync(absPath
|
|
25
|
-
|
|
48
|
+
const content = readFileSync(absPath);
|
|
49
|
+
const encoding = snapshotEncoding(filePath, content);
|
|
50
|
+
return {
|
|
51
|
+
exists: true,
|
|
52
|
+
content: encoding === 'base64' ? content.toString('base64') : content.toString('utf8'),
|
|
53
|
+
contentEncoding: encoding,
|
|
54
|
+
hash: hashBytes(content),
|
|
55
|
+
};
|
|
26
56
|
}
|
|
27
57
|
catch (err) {
|
|
28
58
|
return {
|
|
29
59
|
exists: false,
|
|
30
60
|
content: null,
|
|
61
|
+
contentEncoding: 'utf8',
|
|
31
62
|
hash: null,
|
|
32
63
|
error: err?.message ? String(err.message) : String(err),
|
|
33
64
|
};
|
|
@@ -46,7 +77,8 @@ export function isEditToolName(toolName) {
|
|
|
46
77
|
return (toolName === 'write_file' ||
|
|
47
78
|
toolName === 'patch_file' ||
|
|
48
79
|
toolName === 'str_replace' ||
|
|
49
|
-
toolName === 'delete_file'
|
|
80
|
+
toolName === 'delete_file' ||
|
|
81
|
+
toolName === 'replace_document_text');
|
|
50
82
|
}
|
|
51
83
|
export function operationFromSnapshots(before, after) {
|
|
52
84
|
if (before.hash === after.hash)
|
|
@@ -102,6 +134,7 @@ export function normalizeAssistantEditJournal(value) {
|
|
|
102
134
|
? entry.afterHash
|
|
103
135
|
: null,
|
|
104
136
|
beforeContent: typeof entry.beforeContent === 'string' ? entry.beforeContent : null,
|
|
137
|
+
beforeContentEncoding: entry.beforeContentEncoding === 'base64' ? 'base64' : 'utf8',
|
|
105
138
|
createdAt: typeof entry.createdAt === 'string' && entry.createdAt
|
|
106
139
|
? entry.createdAt
|
|
107
140
|
: new Date().toISOString(),
|
package/dist/src/patcher.js
CHANGED
|
@@ -169,6 +169,23 @@ export function writeProjectFile(rootDir, filePath, content) {
|
|
|
169
169
|
writeFileSync(absPath, content, 'utf-8');
|
|
170
170
|
return { absPath, changed: true };
|
|
171
171
|
}
|
|
172
|
+
export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
173
|
+
const absPath = resolveProjectPath(rootDir, filePath);
|
|
174
|
+
if (existsSync(absPath)) {
|
|
175
|
+
try {
|
|
176
|
+
const existingContent = readFileSync(absPath);
|
|
177
|
+
if (existingContent.equals(content)) {
|
|
178
|
+
return { absPath, changed: false };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// If we can't read it for some reason, proceed with write
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
mkdirSync(path.dirname(absPath), { recursive: true });
|
|
186
|
+
writeFileSync(absPath, content);
|
|
187
|
+
return { absPath, changed: true };
|
|
188
|
+
}
|
|
172
189
|
export function deleteProjectFile(rootDir, filePath) {
|
|
173
190
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
174
191
|
if (!existsSync(absPath)) {
|
package/dist/src/scanner.js
CHANGED
|
@@ -16,13 +16,16 @@ const TARGET_CHUNK_CHARS = 1800;
|
|
|
16
16
|
const MAX_CHUNK_CHARS = 2800;
|
|
17
17
|
const FALLBACK_OVERLAP_LINES = 10;
|
|
18
18
|
const MAX_STRUCTURE_DEPTH = 2;
|
|
19
|
+
function parseGitLsFilesOutput(output) {
|
|
20
|
+
return String(output)
|
|
21
|
+
.split('\0')
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.filter((filePath) => !shouldIgnorePath(filePath));
|
|
24
|
+
}
|
|
19
25
|
function getFiles(rootDir, { limit = Infinity } = {}) {
|
|
20
26
|
try {
|
|
21
|
-
const output = execSync('git ls-files --cached --others --exclude-standard', { cwd: rootDir,
|
|
22
|
-
const files = output
|
|
23
|
-
.split('\n')
|
|
24
|
-
.filter(Boolean)
|
|
25
|
-
.filter((filePath) => !shouldIgnorePath(filePath));
|
|
27
|
+
const output = execSync('git ls-files -z --cached --others --exclude-standard', { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
28
|
+
const files = parseGitLsFilesOutput(output);
|
|
26
29
|
return Number.isFinite(limit) ? files.slice(0, limit) : files;
|
|
27
30
|
}
|
|
28
31
|
catch {
|
|
@@ -2,14 +2,14 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { lstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ARTIFACT_IGNORE_DIRS, normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
|
|
5
|
-
import {
|
|
6
|
-
import { deleteProjectFile, resolveProjectPath, writeProjectFile } from './patcher.js';
|
|
5
|
+
import { hashBytes, readFileEditSnapshot, storedContentBuffer, } from './edit-journal.js';
|
|
6
|
+
import { deleteProjectFile, resolveProjectPath, writeProjectFile, writeProjectFileBuffer, } from './patcher.js';
|
|
7
7
|
import { removeIndexFile, upsertIndexFile } from './project-index.js';
|
|
8
8
|
const MAX_CHECKPOINTS = 20;
|
|
9
9
|
const MAX_SESSION_EDITS = 500;
|
|
10
10
|
const MAX_READ_RECORDS = 200;
|
|
11
11
|
const MAX_REDACTION_TOKENS = 500;
|
|
12
|
-
const MAX_SNAPSHOT_CONTENT_CHARS =
|
|
12
|
+
const MAX_SNAPSHOT_CONTENT_CHARS = 8_000_000;
|
|
13
13
|
const MAX_MUTATION_SCAN_FILES = 2000;
|
|
14
14
|
const MAX_BASELINE_CONTENT_BYTES = 50 * 1024 * 1024;
|
|
15
15
|
export function createSessionSafetyState() {
|
|
@@ -34,6 +34,14 @@ function normalizeEditOperation(value) {
|
|
|
34
34
|
? text
|
|
35
35
|
: null;
|
|
36
36
|
}
|
|
37
|
+
function normalizeStoredContentEncoding(value) {
|
|
38
|
+
return value === 'base64' ? 'base64' : 'utf8';
|
|
39
|
+
}
|
|
40
|
+
function writeStoredProjectFile(rootDir, filePath, content, encoding) {
|
|
41
|
+
return encoding === 'base64'
|
|
42
|
+
? writeProjectFileBuffer(rootDir, filePath, storedContentBuffer(content, encoding))
|
|
43
|
+
: writeProjectFile(rootDir, filePath, content);
|
|
44
|
+
}
|
|
37
45
|
export function normalizeSessionSafetyState(value) {
|
|
38
46
|
const raw = value && typeof value === 'object' ? value : {};
|
|
39
47
|
const safety = createSessionSafetyState();
|
|
@@ -57,6 +65,7 @@ export function normalizeSessionSafetyState(value) {
|
|
|
57
65
|
exists: file.exists === true,
|
|
58
66
|
hash: typeof file.hash === 'string' ? file.hash : null,
|
|
59
67
|
content: typeof file.content === 'string' ? file.content : null,
|
|
68
|
+
contentEncoding: normalizeStoredContentEncoding(file.contentEncoding),
|
|
60
69
|
skipped: typeof file.skipped === 'string' && file.skipped.trim()
|
|
61
70
|
? file.skipped.trim()
|
|
62
71
|
: undefined,
|
|
@@ -143,6 +152,7 @@ export function normalizeSessionSafetyState(value) {
|
|
|
143
152
|
beforeHash: typeof item.beforeHash === 'string' ? item.beforeHash : null,
|
|
144
153
|
afterHash: typeof item.afterHash === 'string' ? item.afterHash : null,
|
|
145
154
|
beforeContent: typeof item.beforeContent === 'string' ? item.beforeContent : null,
|
|
155
|
+
beforeContentEncoding: normalizeStoredContentEncoding(item.beforeContentEncoding),
|
|
146
156
|
createdAt: typeof item.createdAt === 'string' && item.createdAt
|
|
147
157
|
? item.createdAt
|
|
148
158
|
: new Date().toISOString(),
|
|
@@ -242,7 +252,10 @@ export function mergeLocalSessionSafetyState(local, incoming) {
|
|
|
242
252
|
for (const file of checkpoint.files) {
|
|
243
253
|
if (file.content == null)
|
|
244
254
|
continue;
|
|
245
|
-
localCheckpointContent.set(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`,
|
|
255
|
+
localCheckpointContent.set(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`, {
|
|
256
|
+
content: file.content,
|
|
257
|
+
contentEncoding: file.contentEncoding,
|
|
258
|
+
});
|
|
246
259
|
}
|
|
247
260
|
}
|
|
248
261
|
next.checkpoints = next.checkpoints.map((checkpoint) => ({
|
|
@@ -251,17 +264,35 @@ export function mergeLocalSessionSafetyState(local, incoming) {
|
|
|
251
264
|
if (file.content != null)
|
|
252
265
|
return file;
|
|
253
266
|
const content = localCheckpointContent.get(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`);
|
|
254
|
-
return content == null
|
|
267
|
+
return content == null
|
|
268
|
+
? file
|
|
269
|
+
: {
|
|
270
|
+
...file,
|
|
271
|
+
content: content.content,
|
|
272
|
+
contentEncoding: content.contentEncoding,
|
|
273
|
+
};
|
|
255
274
|
}),
|
|
256
275
|
}));
|
|
257
276
|
const localBeforeContent = new Map(localState.sessionEdits
|
|
258
277
|
.filter((edit) => edit.beforeContent != null)
|
|
259
|
-
.map((edit) => [
|
|
278
|
+
.map((edit) => [
|
|
279
|
+
edit.id,
|
|
280
|
+
{
|
|
281
|
+
beforeContent: edit.beforeContent,
|
|
282
|
+
beforeContentEncoding: edit.beforeContentEncoding,
|
|
283
|
+
},
|
|
284
|
+
]));
|
|
260
285
|
next.sessionEdits = next.sessionEdits.map((edit) => {
|
|
261
286
|
if (edit.beforeContent != null)
|
|
262
287
|
return edit;
|
|
263
288
|
const beforeContent = localBeforeContent.get(edit.id);
|
|
264
|
-
return beforeContent == null
|
|
289
|
+
return beforeContent == null
|
|
290
|
+
? edit
|
|
291
|
+
: {
|
|
292
|
+
...edit,
|
|
293
|
+
beforeContent: beforeContent.beforeContent,
|
|
294
|
+
beforeContentEncoding: beforeContent.beforeContentEncoding,
|
|
295
|
+
};
|
|
265
296
|
});
|
|
266
297
|
return next;
|
|
267
298
|
}
|
|
@@ -279,6 +310,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
279
310
|
exists: false,
|
|
280
311
|
hash: null,
|
|
281
312
|
content: null,
|
|
313
|
+
contentEncoding: 'utf8',
|
|
282
314
|
skipped: `Refusing to checkpoint ignored or out-of-project path: ${filePath}`,
|
|
283
315
|
};
|
|
284
316
|
}
|
|
@@ -289,6 +321,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
289
321
|
exists: snapshot.exists,
|
|
290
322
|
hash: snapshot.hash,
|
|
291
323
|
content: null,
|
|
324
|
+
contentEncoding: snapshot.contentEncoding,
|
|
292
325
|
skipped: snapshot.error,
|
|
293
326
|
};
|
|
294
327
|
}
|
|
@@ -298,6 +331,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
298
331
|
exists: snapshot.exists,
|
|
299
332
|
hash: snapshot.hash,
|
|
300
333
|
content: null,
|
|
334
|
+
contentEncoding: snapshot.contentEncoding,
|
|
301
335
|
skipped: `File is too large to checkpoint (${snapshot.content.length} chars).`,
|
|
302
336
|
};
|
|
303
337
|
}
|
|
@@ -306,6 +340,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
306
340
|
exists: snapshot.exists,
|
|
307
341
|
hash: snapshot.hash,
|
|
308
342
|
content: snapshot.content,
|
|
343
|
+
contentEncoding: snapshot.contentEncoding,
|
|
309
344
|
};
|
|
310
345
|
}
|
|
311
346
|
export function createPromptCheckpoint(state, label, turnId) {
|
|
@@ -661,7 +696,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
661
696
|
const before = readFileEditSnapshot(args.rootDir, snapshot.filePath);
|
|
662
697
|
try {
|
|
663
698
|
if (snapshot.exists) {
|
|
664
|
-
const result =
|
|
699
|
+
const result = writeStoredProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '', snapshot.contentEncoding);
|
|
665
700
|
if (result.changed)
|
|
666
701
|
changed = true;
|
|
667
702
|
if (syncPolicy)
|
|
@@ -693,7 +728,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
693
728
|
if (item.before.content == null) {
|
|
694
729
|
throw new Error('previous file content is unavailable');
|
|
695
730
|
}
|
|
696
|
-
|
|
731
|
+
writeStoredProjectFile(args.rootDir, item.snapshot.filePath, item.before.content, item.before.contentEncoding);
|
|
697
732
|
if (syncPolicy) {
|
|
698
733
|
await upsertIndexFile(args.projectIndex, item.snapshot.filePath);
|
|
699
734
|
}
|
|
@@ -749,6 +784,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
749
784
|
beforeHash: item.before.hash,
|
|
750
785
|
afterHash: item.after.hash,
|
|
751
786
|
beforeContent: item.before.content,
|
|
787
|
+
beforeContentEncoding: item.before.contentEncoding,
|
|
752
788
|
checkpointId: checkpoint.id,
|
|
753
789
|
});
|
|
754
790
|
restored.push({
|
|
@@ -777,6 +813,18 @@ function git(args, cwd) {
|
|
|
777
813
|
return null;
|
|
778
814
|
}
|
|
779
815
|
}
|
|
816
|
+
function gitBuffer(args, cwd) {
|
|
817
|
+
try {
|
|
818
|
+
return execFileSync('git', args, {
|
|
819
|
+
cwd,
|
|
820
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
821
|
+
timeout: 10_000,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
780
828
|
export function findGitRoot(startDir) {
|
|
781
829
|
const root = git(['rev-parse', '--show-toplevel'], startDir);
|
|
782
830
|
return root ? path.resolve(root) : null;
|
|
@@ -897,14 +945,15 @@ function readGitHeadSnapshot(rootDir, filePath) {
|
|
|
897
945
|
return null;
|
|
898
946
|
const abs = resolveProjectPath(rootDir, filePath);
|
|
899
947
|
const relToGit = path.relative(gitRoot, abs).split(path.sep).join('/');
|
|
900
|
-
const content =
|
|
948
|
+
const content = gitBuffer(['show', `HEAD:${relToGit}`], gitRoot);
|
|
901
949
|
if (content == null)
|
|
902
950
|
return null;
|
|
903
951
|
return {
|
|
904
952
|
filePath,
|
|
905
953
|
exists: true,
|
|
906
|
-
hash:
|
|
907
|
-
content,
|
|
954
|
+
hash: hashBytes(content),
|
|
955
|
+
content: content.toString('base64'),
|
|
956
|
+
contentEncoding: 'base64',
|
|
908
957
|
};
|
|
909
958
|
}
|
|
910
959
|
export function captureMutationBaseline(rootDir, options) {
|
|
@@ -932,6 +981,7 @@ export function captureMutationBaseline(rootDir, options) {
|
|
|
932
981
|
exists: true,
|
|
933
982
|
hash: null,
|
|
934
983
|
content: null,
|
|
984
|
+
contentEncoding: 'utf8',
|
|
935
985
|
skipped: `Pre-command snapshot skipped: project baseline content budget exceeded (${contentBudget} bytes). Restore for this file is not available.`,
|
|
936
986
|
});
|
|
937
987
|
continue;
|
|
@@ -978,6 +1028,7 @@ export function collectCommandMutations(args) {
|
|
|
978
1028
|
exists: false,
|
|
979
1029
|
hash: null,
|
|
980
1030
|
content: null,
|
|
1031
|
+
contentEncoding: 'utf8',
|
|
981
1032
|
};
|
|
982
1033
|
if (before.hash === after.hash && !before.skipped)
|
|
983
1034
|
continue;
|
|
@@ -1000,6 +1051,7 @@ export function collectCommandMutations(args) {
|
|
|
1000
1051
|
beforeHash: before.hash,
|
|
1001
1052
|
afterHash: after.hash,
|
|
1002
1053
|
beforeContent: before.content,
|
|
1054
|
+
beforeContentEncoding: before.contentEncoding,
|
|
1003
1055
|
checkpointId: args.checkpointId,
|
|
1004
1056
|
});
|
|
1005
1057
|
records.push(record);
|
|
@@ -32,6 +32,12 @@ export function formatToolCallForStatus(call) {
|
|
|
32
32
|
}
|
|
33
33
|
function getEditToolFilePath(call) {
|
|
34
34
|
const args = call.args && typeof call.args === 'object' ? call.args : {};
|
|
35
|
+
if (call.name === 'replace_document_text') {
|
|
36
|
+
const outputPath = args.outputPath ?? args.output_path;
|
|
37
|
+
if (typeof outputPath === 'string' && outputPath.trim()) {
|
|
38
|
+
return outputPath.trim();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
35
41
|
for (const key of EDIT_FILE_PATH_ARG_ALIASES) {
|
|
36
42
|
const value = args[key];
|
|
37
43
|
if (typeof value === 'string' && value.trim())
|
|
@@ -79,6 +85,7 @@ function recordAssistantEdit(session, call, result, before) {
|
|
|
79
85
|
beforeHash: before.hash,
|
|
80
86
|
afterHash: after.hash,
|
|
81
87
|
beforeContent: operation === 'create' ? null : before.content,
|
|
88
|
+
beforeContentEncoding: before.contentEncoding,
|
|
82
89
|
createdAt: new Date().toISOString(),
|
|
83
90
|
revertedAt: null,
|
|
84
91
|
revertedByToolCallId: null,
|
|
@@ -98,6 +105,7 @@ function recordAssistantEdit(session, call, result, before) {
|
|
|
98
105
|
beforeHash: before.hash,
|
|
99
106
|
afterHash: after.hash,
|
|
100
107
|
beforeContent: operation === 'create' ? null : before.content,
|
|
108
|
+
beforeContentEncoding: before.contentEncoding,
|
|
101
109
|
checkpointId: checkpoint.id,
|
|
102
110
|
});
|
|
103
111
|
clearEditFailure(session.clientState.safety, filePath);
|
package/dist/src/tools/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { listSymbols } from './list-symbols.js';
|
|
|
11
11
|
import { patchFile } from './patch-file.js';
|
|
12
12
|
import { readDocument } from './read-document.js';
|
|
13
13
|
import { readFile } from './read-file.js';
|
|
14
|
+
import { replaceDocumentText } from './replace-document-text.js';
|
|
14
15
|
import { runShellCommand } from './run-command.js';
|
|
15
16
|
import { runNodeScript } from './run-node-script.js';
|
|
16
17
|
import { restoreFilesToCheckpoint, restoreToCheckpoint, } from './restore-checkpoint.js';
|
|
@@ -25,6 +26,7 @@ export const TOOL_MAP = {
|
|
|
25
26
|
list_directories: (context, args) => listDirectories(context, args),
|
|
26
27
|
read_file: (context, args) => readFile(context, args),
|
|
27
28
|
read_document: (context, args) => readDocument(context.rootDir, args, context.env),
|
|
29
|
+
replace_document_text: replaceDocumentText,
|
|
28
30
|
grep_code: (context, args) => grepCode(context.rootDir, args),
|
|
29
31
|
find_symbol: (context, args) => findSymbol(context, args),
|
|
30
32
|
list_symbols: (context, args) => listSymbols(context, args),
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
4
|
import { applyUnifiedPatch, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
4
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
5
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
7
|
import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
|
|
7
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
8
10
|
export async function patchFile(context, args) {
|
|
9
11
|
const { rootDir, projectIndex, autoYes, confirmPatch } = context;
|
|
10
12
|
const filePath = String(args.filePath ?? '').trim();
|
|
@@ -15,6 +17,17 @@ export async function patchFile(context, args) {
|
|
|
15
17
|
if (!patch.trim()) {
|
|
16
18
|
return { ok: false, error: 'patch is required' };
|
|
17
19
|
}
|
|
20
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
21
|
+
if (DOCUMENT_EXTENSIONS.has(ext)) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
filePath,
|
|
25
|
+
error: ext === '.docx'
|
|
26
|
+
? 'Use replace_document_text for .docx files.'
|
|
27
|
+
: `Use read_document for ${ext} files; patching is not supported.`,
|
|
28
|
+
failureCategory: 'invalid_argument',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
18
31
|
let originalContent;
|
|
19
32
|
try {
|
|
20
33
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isSensitiveProjectPath } from '../artifact-policy.js';
|
|
4
|
+
// "File not found" recovery hint: when a model mistypes a filename (most
|
|
5
|
+
// often Unicode punctuation — a straight ' for a curly ’ — or a small typo),
|
|
6
|
+
// suggest the closest real file from the same directory.
|
|
7
|
+
function foldName(name) {
|
|
8
|
+
return name
|
|
9
|
+
.normalize('NFC')
|
|
10
|
+
.replace(/[‘’ʼ]/g, "'")
|
|
11
|
+
.replace(/[“”]/g, '"')
|
|
12
|
+
.replace(/ /g, ' ')
|
|
13
|
+
.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
function levenshtein(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return 0;
|
|
18
|
+
const rows = a.length + 1;
|
|
19
|
+
const cols = b.length + 1;
|
|
20
|
+
let prev = Array.from({ length: cols }, (_, j) => j);
|
|
21
|
+
for (let i = 1; i < rows; i++) {
|
|
22
|
+
const current = [i, ...new Array(cols - 1).fill(0)];
|
|
23
|
+
for (let j = 1; j < cols; j++) {
|
|
24
|
+
current[j] = Math.min(prev[j] + 1, current[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
25
|
+
}
|
|
26
|
+
prev = current;
|
|
27
|
+
}
|
|
28
|
+
return prev[cols - 1];
|
|
29
|
+
}
|
|
30
|
+
export function suggestClosestPath(rootDir, missingPath) {
|
|
31
|
+
const resolved = path.isAbsolute(missingPath)
|
|
32
|
+
? missingPath
|
|
33
|
+
: path.resolve(rootDir, missingPath);
|
|
34
|
+
const directory = path.dirname(resolved);
|
|
35
|
+
const wantedBase = foldName(path.basename(resolved));
|
|
36
|
+
if (!wantedBase)
|
|
37
|
+
return null;
|
|
38
|
+
let candidates;
|
|
39
|
+
try {
|
|
40
|
+
candidates = readdirSync(directory);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const threshold = Math.max(2, Math.floor(wantedBase.length * 0.25));
|
|
46
|
+
let best = null;
|
|
47
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
// Never suggest a file the caller would refuse to read/write directly:
|
|
50
|
+
// probing a near-miss like `.enx` or `credential.docx` must not leak the
|
|
51
|
+
// existence of `.env`/credentials through the recovery hint.
|
|
52
|
+
const candidateRelative = path.relative(rootDir, path.join(directory, candidate));
|
|
53
|
+
if (isSensitiveProjectPath(candidateRelative))
|
|
54
|
+
continue;
|
|
55
|
+
const distance = levenshtein(wantedBase, foldName(candidate));
|
|
56
|
+
if (distance < bestDistance) {
|
|
57
|
+
bestDistance = distance;
|
|
58
|
+
best = candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!best || bestDistance > threshold)
|
|
62
|
+
return null;
|
|
63
|
+
const suggested = path.join(directory, best);
|
|
64
|
+
const relative = path.relative(rootDir, suggested);
|
|
65
|
+
return relative && !relative.startsWith('..') ? relative : suggested;
|
|
66
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
|
|
4
|
+
import { suggestClosestPath } from './path-suggest.js';
|
|
4
5
|
import { readCliAuthConfig } from '../api/auth.js';
|
|
5
6
|
export function normalizeDocumentText(raw) {
|
|
6
7
|
const text = String(raw ?? '').replace(/\r\n?/g, '\n');
|
|
@@ -36,6 +37,7 @@ export function normalizeDocumentText(raw) {
|
|
|
36
37
|
}
|
|
37
38
|
async function parseDocumentOnServer(config, fileName, fileData, ext, args) {
|
|
38
39
|
const includePageArgs = ext === '.pdf';
|
|
40
|
+
const includeParagraphArgs = ext === '.docx';
|
|
39
41
|
const response = await globalThis.fetch(`${config.serverUrl.replace(/\/+$/, '')}/v1/document/parse`, {
|
|
40
42
|
method: 'POST',
|
|
41
43
|
headers: {
|
|
@@ -51,6 +53,12 @@ async function parseDocumentOnServer(config, fileName, fileData, ext, args) {
|
|
|
51
53
|
...(includePageArgs && args.lastPage !== undefined
|
|
52
54
|
? { lastPage: args.lastPage }
|
|
53
55
|
: {}),
|
|
56
|
+
...(includeParagraphArgs && args.firstParagraph !== undefined
|
|
57
|
+
? { firstParagraph: args.firstParagraph }
|
|
58
|
+
: {}),
|
|
59
|
+
...(includeParagraphArgs && args.lastParagraph !== undefined
|
|
60
|
+
? { lastParagraph: args.lastParagraph }
|
|
61
|
+
: {}),
|
|
54
62
|
}),
|
|
55
63
|
});
|
|
56
64
|
const data = await response.json().catch(() => null);
|
|
@@ -76,17 +84,20 @@ export async function readDocument(rootDir, args, env) {
|
|
|
76
84
|
};
|
|
77
85
|
}
|
|
78
86
|
if (!existsSync(resolvedPath)) {
|
|
87
|
+
const suggestion = suggestClosestPath(rootDir, resolvedPath);
|
|
79
88
|
return {
|
|
80
89
|
ok: false,
|
|
81
|
-
error:
|
|
90
|
+
error: suggestion
|
|
91
|
+
? `File not found: ${resolvedPath}. Did you mean "${suggestion}"? Note the exact punctuation (e.g. curly apostrophe ’ vs straight ').`
|
|
92
|
+
: `File not found: ${resolvedPath}`,
|
|
82
93
|
failureCategory: 'not_found',
|
|
83
94
|
};
|
|
84
95
|
}
|
|
85
96
|
const ext = path.extname(resolvedPath).toLowerCase();
|
|
86
|
-
if (ext !== '.pdf' && ext !== '.xlsx') {
|
|
97
|
+
if (ext !== '.pdf' && ext !== '.xlsx' && ext !== '.docx') {
|
|
87
98
|
return {
|
|
88
99
|
ok: false,
|
|
89
|
-
error: `Unsupported file type: "${ext}". read_document only supports .pdf and .
|
|
100
|
+
error: `Unsupported file type: "${ext}". read_document only supports .pdf, .xlsx, and .docx.`,
|
|
90
101
|
};
|
|
91
102
|
}
|
|
92
103
|
const authConfig = readCliAuthConfig(env);
|
|
@@ -6,6 +6,7 @@ import { readProjectFile } from '../patcher.js';
|
|
|
6
6
|
import { dotenvFitsRedactionBudget, getCurrentFileHash, recordReadCoverage, redactContentWithStableTokens, redactDotenvWithStableTokens, } from '../session-safety.js';
|
|
7
7
|
import { readFileRange, truncate } from '../utils.js';
|
|
8
8
|
const MAX_FILE_READ_CHARS = 12000;
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
9
10
|
export async function readFile(context, args) {
|
|
10
11
|
const rootDir = typeof context === 'string' ? context : context.rootDir;
|
|
11
12
|
const safety = typeof context === 'string' ? undefined : context.safety;
|
|
@@ -26,6 +27,14 @@ export async function readFile(context, args) {
|
|
|
26
27
|
error: 'This path is not permitted.',
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
const documentExt = path.extname(projectPath ?? filePath).toLowerCase();
|
|
31
|
+
if (DOCUMENT_EXTENSIONS.has(documentExt)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: `Use read_document for ${documentExt} files.`,
|
|
35
|
+
failureCategory: 'invalid_argument',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
29
38
|
let content;
|
|
30
39
|
if (projectPath) {
|
|
31
40
|
try {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
|
|
5
|
+
import { readCliAuthConfig } from '../api/auth.js';
|
|
6
|
+
import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
|
|
7
|
+
import { suggestClosestPath } from './path-suggest.js';
|
|
8
|
+
import { isTuiMode } from '../runtime-mode.js';
|
|
9
|
+
function normalizeReplacements(value) {
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return [];
|
|
12
|
+
return value
|
|
13
|
+
.map((item) => {
|
|
14
|
+
if (!item || typeof item !== 'object')
|
|
15
|
+
return null;
|
|
16
|
+
const entry = item;
|
|
17
|
+
const oldText = String(entry.oldText ?? entry.old_text ?? '');
|
|
18
|
+
const newText = String(entry.newText ?? entry.new_text ?? '');
|
|
19
|
+
if (!oldText)
|
|
20
|
+
return null;
|
|
21
|
+
return { oldText, newText };
|
|
22
|
+
})
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
function relativeEditablePath(rootDir, rawPath) {
|
|
26
|
+
const resolvedPath = path.isAbsolute(rawPath)
|
|
27
|
+
? rawPath
|
|
28
|
+
: path.resolve(rootDir, rawPath);
|
|
29
|
+
const projectPath = normalizeProjectRelativePath(rootDir, resolvedPath);
|
|
30
|
+
if (!projectPath || isSensitiveProjectPath(projectPath))
|
|
31
|
+
return null;
|
|
32
|
+
return projectPath.split(path.sep).join('/');
|
|
33
|
+
}
|
|
34
|
+
function renderPreview(filePath, preview) {
|
|
35
|
+
if (isTuiMode())
|
|
36
|
+
return;
|
|
37
|
+
console.log(chalk.bold(`\n Document text replacement preview for ${filePath}:`));
|
|
38
|
+
for (const line of preview.split('\n')) {
|
|
39
|
+
if (line.startsWith('+')) {
|
|
40
|
+
console.log(chalk.green(line));
|
|
41
|
+
}
|
|
42
|
+
else if (line.startsWith('-')) {
|
|
43
|
+
console.log(chalk.red(line));
|
|
44
|
+
}
|
|
45
|
+
else if (line.startsWith('@@')) {
|
|
46
|
+
console.log(chalk.cyan(line));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.log(chalk.dim(line));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
console.log();
|
|
53
|
+
}
|
|
54
|
+
async function replaceDocumentTextOnServer(config, fileName, fileData, replacements, replaceAll, validate) {
|
|
55
|
+
const response = await globalThis.fetch(`${config.serverUrl.replace(/\/+$/, '')}/v1/document/replace-text`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
authorization: `Bearer ${config.token}`,
|
|
59
|
+
'content-type': 'application/json',
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
fileName,
|
|
63
|
+
fileData: fileData.toString('base64'),
|
|
64
|
+
replacements,
|
|
65
|
+
replaceAll,
|
|
66
|
+
validate,
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
const data = await response.json().catch(() => null);
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: `Server document edit failed: ${data?.error?.message ?? response.status}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
export async function replaceDocumentText(context, args) {
|
|
79
|
+
const sourceRaw = String(args.filePath ?? args.file_path ?? '').trim();
|
|
80
|
+
if (!sourceRaw) {
|
|
81
|
+
return { ok: false, error: 'filePath is required' };
|
|
82
|
+
}
|
|
83
|
+
const sourcePath = relativeEditablePath(context.rootDir, sourceRaw);
|
|
84
|
+
if (!sourcePath) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: 'replace_document_text can only edit permitted files inside the project root.',
|
|
88
|
+
failureCategory: 'permission_denied',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (path.extname(sourcePath).toLowerCase() !== '.docx') {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: 'replace_document_text only supports .docx files.',
|
|
95
|
+
failureCategory: 'invalid_argument',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const outputRaw = String(args.outputPath ?? args.output_path ?? '').trim();
|
|
99
|
+
const targetPath = outputRaw
|
|
100
|
+
? relativeEditablePath(context.rootDir, outputRaw)
|
|
101
|
+
: sourcePath;
|
|
102
|
+
if (!targetPath) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: 'outputPath must be inside the project root.',
|
|
106
|
+
failureCategory: 'permission_denied',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (path.extname(targetPath).toLowerCase() !== '.docx') {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: 'outputPath must end with .docx.',
|
|
113
|
+
failureCategory: 'invalid_argument',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const replacements = normalizeReplacements(args.replacements);
|
|
117
|
+
if (!replacements.length) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: 'replacements must include at least one { oldText, newText } item.',
|
|
121
|
+
failureCategory: 'missing_required_argument',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const authConfig = readCliAuthConfig(context.env);
|
|
125
|
+
if (!authConfig) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: 'Document editing requires a server connection. Please log in first.',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const sourceAbsPath = resolveProjectPath(context.rootDir, sourcePath);
|
|
132
|
+
if (!existsSync(sourceAbsPath)) {
|
|
133
|
+
const suggestion = suggestClosestPath(context.rootDir, sourceAbsPath);
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
filePath: sourcePath,
|
|
137
|
+
error: suggestion
|
|
138
|
+
? `File does not exist: ${sourcePath}. Did you mean "${suggestion}"? Note the exact punctuation (e.g. curly apostrophe ’ vs straight ').`
|
|
139
|
+
: `File does not exist: ${sourcePath}`,
|
|
140
|
+
failureCategory: 'not_found',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const validateOnly = args.validate === true || args.dryRun === true;
|
|
144
|
+
const serverResult = await replaceDocumentTextOnServer(authConfig, path.basename(sourcePath), readFileSync(sourceAbsPath), replacements, args.replaceAll === true || args.replace_all === true, validateOnly);
|
|
145
|
+
if (!serverResult.ok) {
|
|
146
|
+
return {
|
|
147
|
+
...serverResult,
|
|
148
|
+
filePath: sourcePath,
|
|
149
|
+
failureCategory: serverResult.failureCategory ?? 'external_service',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Validate-only: report per-replacement match info without touching the file.
|
|
153
|
+
// changed:false marks it non-mutating so the agent loop does not count a
|
|
154
|
+
// dry-run as an applied edit.
|
|
155
|
+
if (validateOnly) {
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
validate: true,
|
|
159
|
+
changed: false,
|
|
160
|
+
filePath: sourcePath,
|
|
161
|
+
operation: 'replace_document_text',
|
|
162
|
+
results: serverResult.results,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// No replacement matched: nothing was written. Surface per-item reasons so
|
|
166
|
+
// the model can correct and resend only the failing entries.
|
|
167
|
+
const replacementCount = Number(serverResult.replacementCount ?? 0);
|
|
168
|
+
if (replacementCount === 0) {
|
|
169
|
+
const failures = Array.isArray(serverResult.replacements)
|
|
170
|
+
? serverResult.replacements
|
|
171
|
+
.filter((item) => item && item.ok === false)
|
|
172
|
+
.map((item) => `- ${item.error ?? 'no match'}`)
|
|
173
|
+
: [];
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
filePath: sourcePath,
|
|
177
|
+
operation: 'replace_document_text',
|
|
178
|
+
failureCategory: 'conflict',
|
|
179
|
+
error: `0 of ${serverResult.requestedCount ?? replacements.length} replacements applied; file unchanged.` +
|
|
180
|
+
(failures.length ? `\n${failures.join('\n')}` : ''),
|
|
181
|
+
replacements: serverResult.replacements,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const preview = String(serverResult.preview ?? '');
|
|
185
|
+
renderPreview(targetPath, preview);
|
|
186
|
+
if (!context.autoYes && context.confirmPatch) {
|
|
187
|
+
const confirmed = await context.confirmPatch(targetPath, preview);
|
|
188
|
+
if (!confirmed) {
|
|
189
|
+
if (!isTuiMode()) {
|
|
190
|
+
console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
skipped: true,
|
|
195
|
+
filePath: targetPath,
|
|
196
|
+
error: 'User declined replace_document_text',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const fileData = String(serverResult.fileData ?? '');
|
|
201
|
+
const nextData = Buffer.from(fileData, 'base64');
|
|
202
|
+
const write = writeProjectFileBuffer(context.rootDir, targetPath, nextData);
|
|
203
|
+
const failedCount = Number(serverResult.failedCount ?? 0);
|
|
204
|
+
const appliedCount = Number(serverResult.appliedCount ?? replacementCount);
|
|
205
|
+
const requestedCount = Number(serverResult.requestedCount ?? replacements.length);
|
|
206
|
+
const partialFailures = Array.isArray(serverResult.replacements)
|
|
207
|
+
? serverResult.replacements
|
|
208
|
+
.filter((item) => item && item.ok === false)
|
|
209
|
+
.map((item) => `- ${item.error ?? 'no match'}`)
|
|
210
|
+
: [];
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
filePath: targetPath,
|
|
214
|
+
sourceFilePath: sourcePath,
|
|
215
|
+
changed: write.changed,
|
|
216
|
+
operation: 'replace_document_text',
|
|
217
|
+
replacementCount: serverResult.replacementCount,
|
|
218
|
+
requestedCount: serverResult.requestedCount,
|
|
219
|
+
appliedCount: serverResult.appliedCount,
|
|
220
|
+
failedCount: serverResult.failedCount,
|
|
221
|
+
replacements: serverResult.replacements,
|
|
222
|
+
bytesWritten: nextData.length,
|
|
223
|
+
// A partial batch still wrote the matched entries (changed:true above), but
|
|
224
|
+
// the loop must reflect and repair the missed entries — needsRepair forces
|
|
225
|
+
// that without losing credit for the applied edits.
|
|
226
|
+
...(failedCount > 0
|
|
227
|
+
? {
|
|
228
|
+
needsRepair: true,
|
|
229
|
+
failureCategory: 'conflict',
|
|
230
|
+
error: `${appliedCount} of ${requestedCount} replacements applied; ` +
|
|
231
|
+
`${failedCount} missed and still need to be fixed:\n` +
|
|
232
|
+
partialFailures.join('\n'),
|
|
233
|
+
failureDetails: {
|
|
234
|
+
category: 'conflict',
|
|
235
|
+
tool: 'replace_document_text',
|
|
236
|
+
action: 'Re-issue replace_document_text for the missed entries only, with corrected oldText. ' +
|
|
237
|
+
'Copy the exact text from read_document (mind curly vs straight quotes), or pass validate:true to test a match first.',
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
: {}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
4
|
import { readProjectFile, writeProjectFile } from '../patcher.js';
|
|
4
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
5
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
7
|
import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
|
|
7
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
8
10
|
function countOccurrences(haystack, needle) {
|
|
9
11
|
if (needle.length === 0)
|
|
10
12
|
return 0;
|
|
@@ -92,6 +94,17 @@ export async function strReplace(context, args) {
|
|
|
92
94
|
if (oldString.length === 0) {
|
|
93
95
|
return { ok: false, error: 'old_string is required and must be non-empty' };
|
|
94
96
|
}
|
|
97
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
98
|
+
if (DOCUMENT_EXTENSIONS.has(ext)) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
filePath,
|
|
102
|
+
error: ext === '.docx'
|
|
103
|
+
? 'Use replace_document_text for .docx files.'
|
|
104
|
+
: `Use read_document for ${ext} files; text replacement is not supported.`,
|
|
105
|
+
failureCategory: 'invalid_argument',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
95
108
|
let originalContent;
|
|
96
109
|
try {
|
|
97
110
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
3
|
-
import { deleteProjectFile, writeProjectFile, } from '../patcher.js';
|
|
2
|
+
import { hashStoredContent, readFileEditSnapshot, storedContentBuffer, } from '../edit-journal.js';
|
|
3
|
+
import { deleteProjectFile, writeProjectFile, writeProjectFileBuffer, } from '../patcher.js';
|
|
4
4
|
import { removeIndexFile, upsertIndexFile, } from '../project-index.js';
|
|
5
5
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
6
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
@@ -84,7 +84,7 @@ function validateUndoPlan(rootDir, records) {
|
|
|
84
84
|
currentHash,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
-
const beforeContentHash =
|
|
87
|
+
const beforeContentHash = hashStoredContent(record.beforeContent, record.beforeContentEncoding);
|
|
88
88
|
if (beforeContentHash !== record.beforeHash) {
|
|
89
89
|
return {
|
|
90
90
|
ok: false,
|
|
@@ -110,7 +110,9 @@ async function applyUndo(context, record) {
|
|
|
110
110
|
if (record.beforeContent === null) {
|
|
111
111
|
throw new Error(`Cannot undo ${record.id}: the stored pre-edit snapshot is incomplete.`);
|
|
112
112
|
}
|
|
113
|
-
const { changed } =
|
|
113
|
+
const { changed } = record.beforeContentEncoding === 'base64'
|
|
114
|
+
? writeProjectFileBuffer(rootDir, record.filePath, storedContentBuffer(record.beforeContent, record.beforeContentEncoding))
|
|
115
|
+
: writeProjectFile(rootDir, record.filePath, record.beforeContent);
|
|
114
116
|
if (changed) {
|
|
115
117
|
await upsertIndexFile(projectIndex, record.filePath);
|
|
116
118
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
4
|
import { writeProjectFile } from '../patcher.js';
|
|
4
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
5
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
7
|
import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
|
|
7
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
8
10
|
export async function writeFile(context, args) {
|
|
9
11
|
const { rootDir, projectIndex } = context;
|
|
10
12
|
const filePath = String(args.filePath ?? '').trim();
|
|
@@ -12,6 +14,17 @@ export async function writeFile(context, args) {
|
|
|
12
14
|
if (!filePath) {
|
|
13
15
|
return { ok: false, error: 'filePath is required' };
|
|
14
16
|
}
|
|
17
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
18
|
+
if (DOCUMENT_EXTENSIONS.has(ext)) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
filePath,
|
|
22
|
+
error: ext === '.docx'
|
|
23
|
+
? 'Use replace_document_text for .docx files.'
|
|
24
|
+
: `Use read_document for ${ext} files; write_file is not supported.`,
|
|
25
|
+
failureCategory: 'invalid_argument',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
15
28
|
const coveragePath = normalizeProjectRelativePath(rootDir, filePath) ?? filePath;
|
|
16
29
|
const currentHash = getCurrentFileHash(rootDir, filePath);
|
|
17
30
|
if (currentHash !== null &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegitai/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.8",
|
|
4
4
|
"description": "TheGitAI CLI client (source-visible, proprietary)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://thegit.ai",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"web-tree-sitter": "^0.26.6"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
-
"@thegitai/tui-darwin-arm64": "1.0.0-beta.
|
|
31
|
-
"@thegitai/tui-darwin-x64": "1.0.0-beta.
|
|
32
|
-
"@thegitai/tui-linux-x64": "1.0.0-beta.
|
|
33
|
-
"@thegitai/tui-win32-x64": "1.0.0-beta.
|
|
30
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-beta.8",
|
|
31
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.8",
|
|
32
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.8",
|
|
33
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.8"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|