@thegitai/cli 1.0.0-preview.3 → 1.0.0-preview.31
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/README.md +39 -6
- package/dist/bin/ai.js +142 -383
- package/dist/src/agent-mode.js +1 -6
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +152 -37
- package/dist/src/api/chat.js +258 -38
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +69 -7
- package/dist/src/api/models.js +19 -10
- package/dist/src/background-jobs.js +2 -2
- package/dist/src/cli-args.js +19 -5
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +199 -0
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +67 -18
- package/dist/src/permissions.js +243 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +14 -3
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +11 -46
- package/dist/src/tools/delete-file.js +15 -3
- package/dist/src/tools/index.js +13 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/turn-failure-marker.js +11 -0
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +649 -164
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +453 -115
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +206 -63
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +29 -6
- package/dist/src/markdown-renderer.js +0 -112
- package/dist/src/project-index.js +0 -221
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- package/dist/src/tools/signature-help.js +0 -118
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
2
|
import { startBackgroundJob } from '../background-jobs.js';
|
|
3
3
|
import { getBlockedCommandReason, runCommand, } from '../executor.js';
|
|
4
|
-
import {
|
|
4
|
+
import { ensurePermission } from '../permissions.js';
|
|
5
5
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
6
|
import { redactConnectionStringCredentials } from '../secret-preview.js';
|
|
7
7
|
import { buildNestedGitHint } from '../session-safety.js';
|
|
8
8
|
import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
|
|
9
9
|
const MAX_OUTPUT_CHARS = 4000;
|
|
10
10
|
export async function runShellCommand(context, args) {
|
|
11
|
-
const { rootDir,
|
|
11
|
+
const { rootDir, requestSudoPassword, onStatus, } = context;
|
|
12
12
|
const command = String(args.command ?? '').trim();
|
|
13
13
|
if (!command) {
|
|
14
14
|
return { ok: false, error: 'command is required' };
|
|
@@ -31,33 +31,18 @@ export async function runShellCommand(context, args) {
|
|
|
31
31
|
}
|
|
32
32
|
if (!isTuiMode())
|
|
33
33
|
console.log(chalk.bold.yellow(`\n ⚡ Command: ${command}`));
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
command,
|
|
39
|
-
error: 'confirmCommand is required when autoYes is false',
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
const approved = await confirmCommand(runInBackground
|
|
34
|
+
const denied = await ensurePermission(context, {
|
|
35
|
+
bucket: 'run',
|
|
36
|
+
title: 'Approve command?',
|
|
37
|
+
body: runInBackground
|
|
43
38
|
? `${command}\n\nRuns as a managed background job until it exits or is killed.`
|
|
44
|
-
: command
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
command,
|
|
52
|
-
failureCategory: 'user_declined',
|
|
53
|
-
failureDetails: {
|
|
54
|
-
category: 'user_declined',
|
|
55
|
-
tool: 'run_command',
|
|
56
|
-
action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
|
|
57
|
-
},
|
|
58
|
-
error: 'The real user rejected this proposed command. Nothing was executed; this was not a tool failure or an automated system skip.',
|
|
59
|
-
};
|
|
60
|
-
}
|
|
39
|
+
: command,
|
|
40
|
+
command,
|
|
41
|
+
}, 'run_command', { command });
|
|
42
|
+
if (denied) {
|
|
43
|
+
if (!isTuiMode())
|
|
44
|
+
console.log(chalk.dim(` ⏭ Skipped: ${command}`));
|
|
45
|
+
return denied;
|
|
61
46
|
}
|
|
62
47
|
if (runInBackground) {
|
|
63
48
|
return runBackgroundCommand(context, command, args.timeout_ms, repoHint);
|
|
@@ -66,12 +51,8 @@ export async function runShellCommand(context, args) {
|
|
|
66
51
|
requestSudoPassword,
|
|
67
52
|
timeout: typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : undefined,
|
|
68
53
|
});
|
|
69
|
-
const repoSync = await syncRepoIndex(projectIndex, onStatus);
|
|
70
54
|
invalidateShellDiagnosticsCache(rootDir);
|
|
71
55
|
const diagnostics = buildDeferredShellDiagnostics('run_command');
|
|
72
|
-
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
73
|
-
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
74
|
-
}
|
|
75
56
|
const output = typeof result.output === 'string'
|
|
76
57
|
? boundCommandOutput(redactConnectionStringCredentials(result.output))
|
|
77
58
|
: result.output;
|
|
@@ -81,8 +62,6 @@ export async function runShellCommand(context, args) {
|
|
|
81
62
|
exitCode: result.exitCode,
|
|
82
63
|
timedOut: result.timedOut,
|
|
83
64
|
output,
|
|
84
|
-
repoSync,
|
|
85
|
-
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
86
65
|
diagnostics,
|
|
87
66
|
repoHint,
|
|
88
67
|
};
|
|
@@ -96,36 +75,8 @@ export function boundCommandOutput(output) {
|
|
|
96
75
|
`\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
|
|
97
76
|
output.slice(-tailSize));
|
|
98
77
|
}
|
|
99
|
-
async function syncRepoIndex(projectIndex, onStatus) {
|
|
100
|
-
if (!projectIndex.initialized) {
|
|
101
|
-
return {
|
|
102
|
-
added: 0,
|
|
103
|
-
modified: 0,
|
|
104
|
-
removed: 0,
|
|
105
|
-
indexedChunks: 0,
|
|
106
|
-
retrievalTokensUsed: 0,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
try {
|
|
110
|
-
return await syncIndexFromDisk(projectIndex);
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
-
onStatus(`Command completed, but local index sync failed: ${message}`);
|
|
115
|
-
return {
|
|
116
|
-
added: 0,
|
|
117
|
-
modified: 0,
|
|
118
|
-
removed: 0,
|
|
119
|
-
indexedChunks: 0,
|
|
120
|
-
retrievalTokensUsed: 0,
|
|
121
|
-
skipped: true,
|
|
122
|
-
reason: 'local index sync failed after command execution',
|
|
123
|
-
error: message,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
78
|
async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
128
|
-
const { rootDir,
|
|
79
|
+
const { rootDir, onStatus } = context;
|
|
129
80
|
const started = await startBackgroundJob(command, rootDir, {
|
|
130
81
|
startupWaitMs: typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined,
|
|
131
82
|
sessionId: context.sessionId,
|
|
@@ -142,11 +93,7 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
|
142
93
|
};
|
|
143
94
|
}
|
|
144
95
|
const snapshot = started.snapshot;
|
|
145
|
-
const repoSync = await syncRepoIndex(projectIndex, onStatus);
|
|
146
96
|
invalidateShellDiagnosticsCache(rootDir);
|
|
147
|
-
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
148
|
-
onStatus(`Synced repo state after command (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
149
|
-
}
|
|
150
97
|
const output = boundCommandOutput(redactConnectionStringCredentials(started.startupOutput ?? '').trim());
|
|
151
98
|
if (snapshot.status === 'running') {
|
|
152
99
|
return {
|
|
@@ -158,8 +105,6 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
|
158
105
|
pid: snapshot.pid,
|
|
159
106
|
output,
|
|
160
107
|
note: `Background job ${snapshot.id} is running. Use shell_job_output to poll status and new output, and shell_job_kill to stop it.`,
|
|
161
|
-
repoSync,
|
|
162
|
-
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
163
108
|
repoHint,
|
|
164
109
|
};
|
|
165
110
|
}
|
|
@@ -172,8 +117,6 @@ async function runBackgroundCommand(context, command, timeoutMs, repoHint) {
|
|
|
172
117
|
exitCode: snapshot.exitCode,
|
|
173
118
|
output,
|
|
174
119
|
note: `Background job ${snapshot.id} finished during the startup window.`,
|
|
175
|
-
repoSync,
|
|
176
|
-
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
177
120
|
repoHint,
|
|
178
121
|
};
|
|
179
122
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { ensurePermission } from '../permissions.js';
|
|
4
4
|
import { isTuiMode } from '../runtime-mode.js';
|
|
5
5
|
import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
|
|
6
6
|
import { ensureSessionScratchDir } from '../scratch-dir.js';
|
|
@@ -56,29 +56,6 @@ function buildOutput(stdout, stderr, exitCode) {
|
|
|
56
56
|
return trimOutput(stdout.trim());
|
|
57
57
|
return trimOutput([stdout, stderr].filter(Boolean).join('\n').trim());
|
|
58
58
|
}
|
|
59
|
-
function emptyRepoSync(reason) {
|
|
60
|
-
return {
|
|
61
|
-
added: 0,
|
|
62
|
-
modified: 0,
|
|
63
|
-
removed: 0,
|
|
64
|
-
indexedChunks: 0,
|
|
65
|
-
retrievalTokensUsed: 0,
|
|
66
|
-
skipped: true,
|
|
67
|
-
...(reason ? { reason } : {}),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
function readGitStatusSignature(rootDir) {
|
|
71
|
-
try {
|
|
72
|
-
return execFileSync('git', ['-C', rootDir, 'status', '--porcelain=v1', '--untracked-files=all'], {
|
|
73
|
-
encoding: 'utf-8',
|
|
74
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
-
timeout: 10_000,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
59
|
function executeNodeScript(rootDir, script, timeout) {
|
|
83
60
|
const effectiveTimeout = typeof timeout === 'number' && timeout > 0
|
|
84
61
|
? timeout
|
|
@@ -148,7 +125,7 @@ function executeNodeScript(rootDir, script, timeout) {
|
|
|
148
125
|
});
|
|
149
126
|
}
|
|
150
127
|
export async function runNodeScript(context, args) {
|
|
151
|
-
const { rootDir
|
|
128
|
+
const { rootDir } = context;
|
|
152
129
|
const script = typeof args.script === 'string' ? args.script : '';
|
|
153
130
|
if (!script.trim()) {
|
|
154
131
|
return { ok: false, error: 'script is required' };
|
|
@@ -158,65 +135,21 @@ export async function runNodeScript(context, args) {
|
|
|
158
135
|
console.log(chalk.bold.yellow(`\n ⚡ Node script:\n${commandForApproval}\n`));
|
|
159
136
|
console.log(chalk.dim(` in: ${rootDir}\n`));
|
|
160
137
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const approved = await confirmCommand(commandForApproval);
|
|
170
|
-
if (!approved) {
|
|
171
|
-
if (!isTuiMode()) {
|
|
172
|
-
console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
|
|
173
|
-
}
|
|
174
|
-
return {
|
|
175
|
-
ok: false,
|
|
176
|
-
skipped: true,
|
|
177
|
-
command: COMMAND_LABEL,
|
|
178
|
-
failureCategory: 'user_declined',
|
|
179
|
-
failureDetails: {
|
|
180
|
-
category: 'user_declined',
|
|
181
|
-
tool: 'run_node_script',
|
|
182
|
-
action: 'Respect the real user’s decision. Do not retry the same or an equivalent action; reconsider the approach or ask one specific question if needed.',
|
|
183
|
-
},
|
|
184
|
-
error: 'The real user rejected this proposed script. Nothing was executed; this was not a tool failure or an automated system skip.',
|
|
185
|
-
};
|
|
138
|
+
const denied = await ensurePermission(context, {
|
|
139
|
+
bucket: 'run',
|
|
140
|
+
title: 'Approve node script?',
|
|
141
|
+
body: commandForApproval,
|
|
142
|
+
}, 'run_node_script', { command: COMMAND_LABEL });
|
|
143
|
+
if (denied) {
|
|
144
|
+
if (!isTuiMode()) {
|
|
145
|
+
console.log(chalk.dim(` ⏭ Skipped: ${COMMAND_LABEL}`));
|
|
186
146
|
}
|
|
147
|
+
return denied;
|
|
187
148
|
}
|
|
188
|
-
const beforeGitStatus = readGitStatusSignature(rootDir);
|
|
189
149
|
const result = await executeNodeScript(rootDir, script, typeof args.timeout_ms === 'number' && args.timeout_ms > 0
|
|
190
150
|
? args.timeout_ms
|
|
191
151
|
: undefined);
|
|
192
|
-
const afterGitStatus = readGitStatusSignature(rootDir);
|
|
193
|
-
const gitStatusCleanBeforeAndAfter = beforeGitStatus === '' && afterGitStatus === '';
|
|
194
|
-
const shouldSync = context.projectIndex.initialized && !gitStatusCleanBeforeAndAfter;
|
|
195
|
-
let repoSync;
|
|
196
|
-
if (shouldSync) {
|
|
197
|
-
try {
|
|
198
|
-
repoSync = await syncIndexFromDisk(context.projectIndex);
|
|
199
|
-
}
|
|
200
|
-
catch (error) {
|
|
201
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
-
context.onStatus(`Node script completed, but local index sync failed: ${message}`);
|
|
203
|
-
repoSync = {
|
|
204
|
-
...emptyRepoSync('local index sync failed after Node script execution'),
|
|
205
|
-
error: message,
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
else {
|
|
210
|
-
repoSync = emptyRepoSync(!context.projectIndex.initialized
|
|
211
|
-
? 'project index not initialized'
|
|
212
|
-
: gitStatusCleanBeforeAndAfter
|
|
213
|
-
? 'git status clean before and after'
|
|
214
|
-
: 'sync not needed');
|
|
215
|
-
}
|
|
216
152
|
invalidateShellDiagnosticsCache(rootDir);
|
|
217
|
-
if (repoSync.added || repoSync.modified || repoSync.removed) {
|
|
218
|
-
context.onStatus(`Synced repo state after Node script (${repoSync.added} added, ${repoSync.modified} modified, ${repoSync.removed} removed).`);
|
|
219
|
-
}
|
|
220
153
|
return {
|
|
221
154
|
ok: result.exitCode === 0,
|
|
222
155
|
command: COMMAND_LABEL,
|
|
@@ -225,8 +158,6 @@ export async function runNodeScript(context, args) {
|
|
|
225
158
|
stdout: result.stdout,
|
|
226
159
|
stderr: result.stderr,
|
|
227
160
|
output: result.output,
|
|
228
|
-
repoSync,
|
|
229
|
-
retrievalTokensUsed: repoSync.retrievalTokensUsed,
|
|
230
161
|
diagnostics: buildDeferredShellDiagnostics('run_node_script'),
|
|
231
162
|
};
|
|
232
163
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getClientStateDir } from '../client-state.js';
|
|
4
|
+
import { MAX_IMAGE_SIZE_BYTES, sniffImageMimeType, } from '../core/image-limits.js';
|
|
5
|
+
const IMAGE_FILE_MODE = 0o600;
|
|
6
|
+
const IMAGE_DIR_MODE = 0o700;
|
|
7
|
+
export const GENERATED_IMAGES_SUBDIR = 'generated_images';
|
|
8
|
+
export const GENERATED_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
9
|
+
export function getGeneratedImagesDir(env = process.env) {
|
|
10
|
+
return path.join(getClientStateDir(env), GENERATED_IMAGES_SUBDIR);
|
|
11
|
+
}
|
|
12
|
+
export function sanitizeGeneratedImageBasename(raw) {
|
|
13
|
+
const trimmed = String(raw ?? '').trim();
|
|
14
|
+
const base = trimmed
|
|
15
|
+
.replace(/\\/g, '/')
|
|
16
|
+
.split('/')
|
|
17
|
+
.pop()
|
|
18
|
+
?.replace(/^\.+/, '')
|
|
19
|
+
.replace(/[^\w.\-]+/g, '-')
|
|
20
|
+
.replace(/-+/g, '-')
|
|
21
|
+
.replace(/^-+|-+$/g, '')
|
|
22
|
+
.replace(/-\.|\.-/g, '.')
|
|
23
|
+
.replace(/^-+|-+$/g, '')
|
|
24
|
+
.slice(0, 80);
|
|
25
|
+
if (!base || base === '.' || base === '..') {
|
|
26
|
+
return `generated-${Date.now()}.png`;
|
|
27
|
+
}
|
|
28
|
+
if (/\.png$/i.test(base)) {
|
|
29
|
+
return base.replace(/\.png$/i, '.png');
|
|
30
|
+
}
|
|
31
|
+
const withoutExt = base.replace(/\.[^.]+$/, '').replace(/-+$/g, '');
|
|
32
|
+
return `${withoutExt || 'generated'}.png`;
|
|
33
|
+
}
|
|
34
|
+
function uniquePath(dir, basename) {
|
|
35
|
+
const candidate = path.join(dir, basename);
|
|
36
|
+
if (!existsSync(candidate)) {
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
const ext = path.extname(basename) || '.png';
|
|
40
|
+
const stem = path.basename(basename, ext);
|
|
41
|
+
for (let i = 2; i < 10_000; i += 1) {
|
|
42
|
+
const next = path.join(dir, `${stem}-${i}${ext}`);
|
|
43
|
+
if (!existsSync(next)) {
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return path.join(dir, `${stem}-${Date.now()}${ext}`);
|
|
48
|
+
}
|
|
49
|
+
export function sweepGeneratedImages({ maxAgeMs = GENERATED_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), } = {}) {
|
|
50
|
+
const dir = getGeneratedImagesDir(env);
|
|
51
|
+
if (!existsSync(dir))
|
|
52
|
+
return 0;
|
|
53
|
+
let removed = 0;
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(dir);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const filePath = path.join(dir, entry);
|
|
63
|
+
try {
|
|
64
|
+
if (now - statSync(filePath).mtimeMs < maxAgeMs)
|
|
65
|
+
continue;
|
|
66
|
+
rmSync(filePath, { force: true });
|
|
67
|
+
removed += 1;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return removed;
|
|
73
|
+
}
|
|
74
|
+
export function saveGeneratedImage(args) {
|
|
75
|
+
const base64Data = String(args.base64Data ?? '').trim();
|
|
76
|
+
if (!base64Data) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: 'This CLI build cannot generate images without server-supplied image bytes. Update TheGitAI CLI.',
|
|
80
|
+
failureCategory: 'tool_exception',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
let bytes;
|
|
84
|
+
try {
|
|
85
|
+
bytes = Buffer.from(base64Data, 'base64');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: 'Generated image bytes are invalid.',
|
|
91
|
+
failureCategory: 'invalid_argument',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (bytes.length === 0 || bytes.length > MAX_IMAGE_SIZE_BYTES) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: 'Generated image exceeded the allowed size limit.',
|
|
98
|
+
failureCategory: 'invalid_argument',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const sniffed = sniffImageMimeType(bytes);
|
|
102
|
+
if (sniffed !== 'image/png') {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: 'Generated image must be a PNG.',
|
|
106
|
+
failureCategory: 'invalid_argument',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const env = args.env ?? process.env;
|
|
110
|
+
const dir = getGeneratedImagesDir(env);
|
|
111
|
+
mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
|
|
112
|
+
const basename = sanitizeGeneratedImageBasename(args.suggestedFilename ?? args.filename);
|
|
113
|
+
const target = uniquePath(dir, basename);
|
|
114
|
+
writeFileSync(target, bytes, { mode: IMAGE_FILE_MODE });
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
path: target,
|
|
118
|
+
message: `Image saved to ${target}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -3,10 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
4
|
import { classifyProjectPath, readProjectFile, writeProjectFile, } from '../patcher.js';
|
|
5
5
|
import { repairFilePath } from './path-suggest.js';
|
|
6
|
-
import { upsertIndexFile } from '../project-index.js';
|
|
7
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
8
7
|
import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
|
|
9
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
import { ensurePermission } from '../permissions.js';
|
|
10
10
|
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
11
11
|
function countOccurrences(haystack, needle) {
|
|
12
12
|
if (needle.length === 0)
|
|
@@ -76,7 +76,7 @@ function buildStrReplacePreview(oldString, newString) {
|
|
|
76
76
|
return `@@ str_replace @@\n${minus}\n${plus}`;
|
|
77
77
|
}
|
|
78
78
|
export async function strReplace(context, args) {
|
|
79
|
-
const { rootDir
|
|
79
|
+
const { rootDir } = context;
|
|
80
80
|
const filePath = repairFilePath(rootDir, String(args.filePath ?? args.file_path ?? '').trim());
|
|
81
81
|
let oldString = typeof args.old_string === 'string'
|
|
82
82
|
? args.old_string
|
|
@@ -175,35 +175,23 @@ export async function strReplace(context, args) {
|
|
|
175
175
|
}
|
|
176
176
|
console.log();
|
|
177
177
|
}
|
|
178
|
-
if (!
|
|
179
|
-
const
|
|
180
|
-
|
|
178
|
+
if (!scratchPath) {
|
|
179
|
+
const denied = await ensurePermission(context, {
|
|
180
|
+
bucket: 'edit',
|
|
181
|
+
title: 'Approve patch?',
|
|
182
|
+
body: 'Review changes before applying.',
|
|
183
|
+
filePath,
|
|
184
|
+
diff: preview,
|
|
185
|
+
}, 'str_replace', { filePath });
|
|
186
|
+
if (denied) {
|
|
181
187
|
if (!isTuiMode())
|
|
182
188
|
console.log(chalk.dim(` ⏭ str_replace skipped: ${filePath}`));
|
|
183
|
-
return
|
|
184
|
-
ok: false,
|
|
185
|
-
skipped: true,
|
|
186
|
-
filePath,
|
|
187
|
-
failureCategory: 'user_declined',
|
|
188
|
-
failureDetails: {
|
|
189
|
-
category: 'user_declined',
|
|
190
|
-
tool: 'str_replace',
|
|
191
|
-
action: 'Respect the real user’s decision. Do not retry the same or an equivalent edit; reconsider the approach or ask one specific question if needed.',
|
|
192
|
-
},
|
|
193
|
-
error: 'The real user rejected this proposed edit. Nothing was changed; this was not a tool failure or an automated system skip.',
|
|
194
|
-
};
|
|
189
|
+
return denied;
|
|
195
190
|
}
|
|
196
191
|
}
|
|
197
192
|
const nextContent = originalContent.split(oldString).join(newString);
|
|
198
193
|
const { changed } = writeProjectFile(rootDir, filePath, nextContent);
|
|
199
194
|
const replacements = changed ? (replaceAll ? n : 1) : 0;
|
|
200
|
-
let indexedChunks = 0;
|
|
201
|
-
let retrievalTokensUsed = 0;
|
|
202
|
-
if (changed && !scratchPath) {
|
|
203
|
-
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
204
|
-
indexedChunks = indexResult.indexedChunks;
|
|
205
|
-
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
206
|
-
}
|
|
207
195
|
let diagnostics;
|
|
208
196
|
if (!scratchPath) {
|
|
209
197
|
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
@@ -223,8 +211,6 @@ export async function strReplace(context, args) {
|
|
|
223
211
|
operation: 'str_replace',
|
|
224
212
|
...(scratchPath ? { scratch: true } : {}),
|
|
225
213
|
replacements,
|
|
226
|
-
indexedChunks,
|
|
227
|
-
retrievalTokensUsed,
|
|
228
214
|
bytesWritten: Buffer.byteLength(nextContent, 'utf-8'),
|
|
229
215
|
diagnostics,
|
|
230
216
|
message: changed ? undefined : 'The provided replacement resulted in no changes to the file content.',
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
2
|
import { hashStoredContent, readFileEditSnapshot, storedContentBuffer, } from '../edit-journal.js';
|
|
3
3
|
import { deleteProjectFile, writeProjectFile, writeProjectFileBuffer, } from '../patcher.js';
|
|
4
|
-
import { removeIndexFile, upsertIndexFile, } from '../project-index.js';
|
|
5
4
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
5
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
7
6
|
function normalizeTarget(value) {
|
|
@@ -101,10 +100,9 @@ function validateUndoPlan(rootDir, records) {
|
|
|
101
100
|
return { ok: true };
|
|
102
101
|
}
|
|
103
102
|
async function applyUndo(context, record) {
|
|
104
|
-
const { rootDir
|
|
103
|
+
const { rootDir } = context;
|
|
105
104
|
if (record.operation === 'create') {
|
|
106
105
|
deleteProjectFile(rootDir, record.filePath);
|
|
107
|
-
await removeIndexFile(projectIndex, record.filePath);
|
|
108
106
|
return { changed: true };
|
|
109
107
|
}
|
|
110
108
|
if (record.beforeContent === null) {
|
|
@@ -113,9 +111,6 @@ async function applyUndo(context, record) {
|
|
|
113
111
|
const { changed } = record.beforeContentEncoding === 'base64'
|
|
114
112
|
? writeProjectFileBuffer(rootDir, record.filePath, storedContentBuffer(record.beforeContent, record.beforeContentEncoding))
|
|
115
113
|
: writeProjectFile(rootDir, record.filePath, record.beforeContent);
|
|
116
|
-
if (changed) {
|
|
117
|
-
await upsertIndexFile(projectIndex, record.filePath);
|
|
118
|
-
}
|
|
119
114
|
return { changed };
|
|
120
115
|
}
|
|
121
116
|
function summarizeUndoRecord(record) {
|
|
@@ -2,13 +2,48 @@ import chalk from '../colors.js';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
4
|
import { classifyProjectPath, writeProjectFile } from '../patcher.js';
|
|
5
|
-
import {
|
|
5
|
+
import { readFileEditSnapshot } from '../edit-journal.js';
|
|
6
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
7
7
|
import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
|
|
8
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
import { ensurePermission } from '../permissions.js';
|
|
9
10
|
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
11
|
+
const MAX_WRITE_PREVIEW_LINES = 400;
|
|
12
|
+
function boundedLines(text, limit) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
let omitted = 0;
|
|
15
|
+
let start = 0;
|
|
16
|
+
for (;;) {
|
|
17
|
+
const newline = text.indexOf('\n', start);
|
|
18
|
+
if (lines.length < limit) {
|
|
19
|
+
lines.push(text.slice(start, newline === -1 ? undefined : newline));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
omitted += 1;
|
|
23
|
+
}
|
|
24
|
+
if (newline === -1)
|
|
25
|
+
break;
|
|
26
|
+
start = newline + 1;
|
|
27
|
+
}
|
|
28
|
+
return { lines, omitted };
|
|
29
|
+
}
|
|
30
|
+
function buildWritePreview(previous, next) {
|
|
31
|
+
const rows = ['@@ write_file @@'];
|
|
32
|
+
const append = (text, sign, marker) => {
|
|
33
|
+
const { lines, omitted } = boundedLines(text, MAX_WRITE_PREVIEW_LINES);
|
|
34
|
+
for (const line of lines)
|
|
35
|
+
rows.push(`${sign}${line}`);
|
|
36
|
+
if (omitted > 0) {
|
|
37
|
+
rows.push(`@@ ${omitted} more ${marker} line(s) not shown @@`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
if (previous !== null)
|
|
41
|
+
append(previous, '-', 'removed');
|
|
42
|
+
append(next, '+', 'added');
|
|
43
|
+
return rows.join('\n');
|
|
44
|
+
}
|
|
10
45
|
export async function writeFile(context, args) {
|
|
11
|
-
const { rootDir
|
|
46
|
+
const { rootDir } = context;
|
|
12
47
|
const filePath = String(args.filePath ?? '').trim();
|
|
13
48
|
let content = typeof args.content === 'string' ? args.content : '';
|
|
14
49
|
if (!filePath) {
|
|
@@ -60,14 +95,37 @@ export async function writeFile(context, args) {
|
|
|
60
95
|
};
|
|
61
96
|
}
|
|
62
97
|
content = resolveRedactionTokens(context.safety, content, coveragePath, currentHash);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
98
|
+
if (!scratchPath) {
|
|
99
|
+
const before = currentHash === null ? null : readFileEditSnapshot(rootDir, filePath);
|
|
100
|
+
const existing = before && before.contentEncoding === 'utf8' ? before.content : null;
|
|
101
|
+
const denied = await ensurePermission(context, {
|
|
102
|
+
bucket: currentHash === null ? 'create' : 'edit',
|
|
103
|
+
title: currentHash === null ? 'Approve new file?' : 'Approve patch?',
|
|
104
|
+
body: 'Review changes before applying.',
|
|
105
|
+
filePath,
|
|
106
|
+
diff: buildWritePreview(existing, content),
|
|
107
|
+
}, 'write_file', { filePath });
|
|
108
|
+
if (denied) {
|
|
109
|
+
if (!isTuiMode())
|
|
110
|
+
console.log(chalk.dim(` ⏭ write_file skipped: ${filePath}`));
|
|
111
|
+
return denied;
|
|
112
|
+
}
|
|
113
|
+
if (getCurrentFileHash(rootDir, filePath) !== currentHash) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
filePath,
|
|
117
|
+
failureCategory: 'conflict',
|
|
118
|
+
error: `write_file refused: ${filePath} changed on disk while the approval prompt was open.`,
|
|
119
|
+
failureDetails: {
|
|
120
|
+
category: 'conflict',
|
|
121
|
+
tool: 'write_file',
|
|
122
|
+
action: 'Re-read the file to see its current contents, then decide whether the write is still correct and retry.',
|
|
123
|
+
},
|
|
124
|
+
currentHash,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
70
127
|
}
|
|
128
|
+
const { changed } = writeProjectFile(rootDir, filePath, content);
|
|
71
129
|
let diagnostics;
|
|
72
130
|
if (!scratchPath) {
|
|
73
131
|
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
@@ -84,8 +142,6 @@ export async function writeFile(context, args) {
|
|
|
84
142
|
changed,
|
|
85
143
|
operation: 'write',
|
|
86
144
|
...(scratchPath ? { scratch: true } : {}),
|
|
87
|
-
indexedChunks,
|
|
88
|
-
retrievalTokensUsed,
|
|
89
145
|
bytesWritten: Buffer.byteLength(content, 'utf-8'),
|
|
90
146
|
diagnostics,
|
|
91
147
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const TURN_FAILURE_MARKER_PATTERN = /^Turn failed before completion: ([a-z][a-z0-9_-]*)\.?$/i;
|
|
2
|
+
export function formatTurnFailureMarker(category) {
|
|
3
|
+
const normalized = category.trim().toLowerCase();
|
|
4
|
+
const safeCategory = /^[a-z][a-z0-9_-]*$/.test(normalized)
|
|
5
|
+
? normalized
|
|
6
|
+
: 'unknown_error';
|
|
7
|
+
return `Turn failed before completion: ${safeCategory}.`;
|
|
8
|
+
}
|
|
9
|
+
export function isTurnFailureMarker(text) {
|
|
10
|
+
return TURN_FAILURE_MARKER_PATTERN.test(text.trim());
|
|
11
|
+
}
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
4
|
const HISTORY_FILE = 'prompt-history.json';
|
|
5
5
|
const LEGACY_HISTORY_FILE = 'prompt-history.txt';
|
|
6
|
-
export const MAX_PROMPT_HISTORY_ENTRIES =
|
|
6
|
+
export const MAX_PROMPT_HISTORY_ENTRIES = 20;
|
|
7
7
|
const ENTRY_SEPARATOR = '\x1e';
|
|
8
8
|
function getHistoryFilePath(env = process.env) {
|
|
9
9
|
return path.join(getClientStateDir(env), HISTORY_FILE);
|