@thegitai/cli 1.0.0-preview.1 → 1.0.0-preview.10
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 +16 -4
- package/dist/bin/ai.js +57 -287
- package/dist/src/api/auth.js +2 -2
- package/dist/src/api/browser-login.js +72 -3
- package/dist/src/api/chat.js +125 -24
- package/dist/src/api/http.js +16 -3
- package/dist/src/api/models.js +9 -4
- package/dist/src/help-text.js +19 -7
- package/dist/src/patcher.js +96 -9
- package/dist/src/project-index.js +13 -1
- package/dist/src/project-orientation.js +99 -0
- package/dist/src/scratch-dir.js +51 -33
- package/dist/src/session-store.js +52 -20
- package/dist/src/session.js +8 -0
- package/dist/src/tool-executor.js +38 -6
- package/dist/src/tools/delete-file.js +22 -4
- package/dist/src/tools/patch-file.js +30 -5
- package/dist/src/tools/read-file.js +3 -1
- package/dist/src/tools/replace-document-text.js +7 -1
- package/dist/src/tools/run-command.js +37 -19
- package/dist/src/tools/run-node-script.js +24 -4
- package/dist/src/tools/str-replace.js +30 -5
- package/dist/src/tools/write-file.js +25 -5
- 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 +188 -49
- package/dist/src/ui/tui/bridge.js +3 -0
- package/dist/src/ui/tui/build-frame.js +179 -82
- package/dist/src/ui/tui/markdown-render.js +72 -73
- package/dist/src/ui/tui/shell-input.js +42 -13
- 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/utils.js +9 -0
- package/package.json +18 -6
- package/dist/src/markdown-renderer.js +0 -112
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { shouldIgnoreArtifactPath } from './artifact-policy.js';
|
|
4
|
+
const MAX_TOP_LEVEL_ENTRIES = 80;
|
|
5
|
+
const MAX_DECLARED_TASKS = 40;
|
|
6
|
+
const MAX_NAME_CHARS = 120;
|
|
7
|
+
const MAX_PACKAGE_JSON_BYTES = 1024 * 1024;
|
|
8
|
+
function normalizeName(value) {
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return null;
|
|
11
|
+
const normalized = value
|
|
12
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim()
|
|
15
|
+
.slice(0, MAX_NAME_CHARS);
|
|
16
|
+
return normalized || null;
|
|
17
|
+
}
|
|
18
|
+
function normalizeNames(value, limit) {
|
|
19
|
+
if (!Array.isArray(value))
|
|
20
|
+
return [];
|
|
21
|
+
const names = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const item of value) {
|
|
24
|
+
const name = normalizeName(item);
|
|
25
|
+
if (!name || seen.has(name))
|
|
26
|
+
continue;
|
|
27
|
+
seen.add(name);
|
|
28
|
+
names.push(name);
|
|
29
|
+
if (names.length >= limit)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
function readPackageTasks(rootDir) {
|
|
35
|
+
const packagePath = path.join(rootDir, 'package.json');
|
|
36
|
+
let fd = null;
|
|
37
|
+
try {
|
|
38
|
+
const pathStat = lstatSync(packagePath);
|
|
39
|
+
if (pathStat.isSymbolicLink() ||
|
|
40
|
+
!pathStat.isFile() ||
|
|
41
|
+
pathStat.nlink > 1 ||
|
|
42
|
+
pathStat.size > MAX_PACKAGE_JSON_BYTES) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
46
|
+
fd = openSync(packagePath, constants.O_RDONLY | noFollow);
|
|
47
|
+
const fileStat = fstatSync(fd);
|
|
48
|
+
if (!fileStat.isFile() ||
|
|
49
|
+
fileStat.nlink > 1 ||
|
|
50
|
+
fileStat.size > MAX_PACKAGE_JSON_BYTES ||
|
|
51
|
+
fileStat.dev !== pathStat.dev ||
|
|
52
|
+
fileStat.ino !== pathStat.ino) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const parsed = JSON.parse(readFileSync(fd, 'utf8'));
|
|
56
|
+
if (!parsed?.scripts || typeof parsed.scripts !== 'object')
|
|
57
|
+
return [];
|
|
58
|
+
return normalizeNames(Object.keys(parsed.scripts), MAX_DECLARED_TASKS);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (fd !== null)
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function normalizeProjectOrientation(value) {
|
|
69
|
+
if (!value || typeof value !== 'object')
|
|
70
|
+
return null;
|
|
71
|
+
const record = value;
|
|
72
|
+
const topLevelEntries = normalizeNames(record.topLevelEntries, MAX_TOP_LEVEL_ENTRIES);
|
|
73
|
+
const packageScripts = normalizeNames(record.packageScripts, MAX_DECLARED_TASKS);
|
|
74
|
+
if (topLevelEntries.length === 0 && packageScripts.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
topLevelEntries,
|
|
78
|
+
packageScripts,
|
|
79
|
+
truncated: record.truncated === true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function collectProjectOrientation(rootDir) {
|
|
83
|
+
try {
|
|
84
|
+
const entries = readdirSync(rootDir, { withFileTypes: true })
|
|
85
|
+
.filter((entry) => !shouldIgnoreArtifactPath(entry.name))
|
|
86
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
87
|
+
const topLevelEntries = entries
|
|
88
|
+
.slice(0, MAX_TOP_LEVEL_ENTRIES)
|
|
89
|
+
.map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`);
|
|
90
|
+
return normalizeProjectOrientation({
|
|
91
|
+
topLevelEntries,
|
|
92
|
+
packageScripts: readPackageTasks(rootDir),
|
|
93
|
+
truncated: entries.length > MAX_TOP_LEVEL_ENTRIES,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/src/scratch-dir.js
CHANGED
|
@@ -1,57 +1,75 @@
|
|
|
1
|
-
import { chmodSync, lstatSync,
|
|
1
|
+
import { chmodSync, lstatSync, mkdtempSync } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
4
|
+
const scratchDirs = new Map();
|
|
5
|
+
let activeSessionId = 'default';
|
|
6
|
+
export function setScratchSession(sessionId) {
|
|
7
|
+
activeSessionId = String(sessionId ?? '').trim() || 'default';
|
|
8
|
+
}
|
|
9
|
+
function allocateSessionScratchDir() {
|
|
10
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
|
|
11
|
+
scratchDirs.set(activeSessionId, dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
5
14
|
export function sessionScratchDir() {
|
|
6
|
-
|
|
7
|
-
cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
|
|
8
|
-
}
|
|
9
|
-
return cachedScratchDir;
|
|
15
|
+
return scratchDirs.get(activeSessionId) ?? allocateSessionScratchDir();
|
|
10
16
|
}
|
|
11
|
-
function
|
|
17
|
+
function isOwnedDirectory(dir) {
|
|
12
18
|
try {
|
|
13
19
|
const st = lstatSync(dir);
|
|
14
20
|
if (st.isSymbolicLink() || !st.isDirectory())
|
|
15
|
-
return
|
|
21
|
+
return false;
|
|
16
22
|
if (process.platform !== 'win32' &&
|
|
17
23
|
typeof process.getuid === 'function' &&
|
|
18
24
|
st.uid !== process.getuid()) {
|
|
19
|
-
return
|
|
25
|
+
return false;
|
|
20
26
|
}
|
|
21
|
-
return
|
|
27
|
+
return true;
|
|
22
28
|
}
|
|
23
29
|
catch {
|
|
24
30
|
return false;
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
export function ensureSessionScratchDir() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
let dir = sessionScratchDir();
|
|
35
|
+
if (!isOwnedDirectory(dir)) {
|
|
36
|
+
dir = allocateSessionScratchDir();
|
|
37
|
+
}
|
|
38
|
+
if (process.platform !== 'win32') {
|
|
39
|
+
chmodSync(dir, 0o700);
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
function hasUnsafeScratchComponent(root, relativePath) {
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
|
|
46
|
+
current = path.join(current, segment);
|
|
47
|
+
try {
|
|
48
|
+
const stat = lstatSync(current);
|
|
49
|
+
if (stat.isSymbolicLink())
|
|
50
|
+
return true;
|
|
51
|
+
if (stat.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
if (!stat.isFile() || stat.nlink > 1)
|
|
54
|
+
return true;
|
|
33
55
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
chmodSync(dir, 0o700);
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error?.code !== 'ENOENT';
|
|
37
58
|
}
|
|
38
59
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
export function isWithinSessionScratchDir(absPath) {
|
|
63
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
64
|
+
const resolved = path.resolve(absPath);
|
|
65
|
+
const relative = path.relative(root, resolved);
|
|
66
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
42
67
|
}
|
|
43
68
|
export function isInsideTheGitAiScratch(absPath) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
const first = relPath.split(/[\\/]/, 1)[0] ?? '';
|
|
50
|
-
if (!/^thegitai(?:$|[-.])/i.test(first)) {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
if (/[*?[\]{}]/.test(first)) {
|
|
69
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
70
|
+
const resolved = path.resolve(absPath);
|
|
71
|
+
const relative = path.relative(root, resolved);
|
|
72
|
+
if (!isWithinSessionScratchDir(resolved))
|
|
54
73
|
return false;
|
|
55
|
-
|
|
56
|
-
return !isSquattedScratchRoot(path.join(tempRoot, first));
|
|
74
|
+
return !hasUnsafeScratchComponent(root, relative);
|
|
57
75
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { getClientStateDir } from './client-state.js';
|
|
5
6
|
import { normalizeAssistantEditJournal } from './edit-journal.js';
|
|
6
7
|
import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
|
|
7
|
-
import {
|
|
8
|
+
import { singleLinePreview } from './utils.js';
|
|
8
9
|
const SESSION_STORE_VERSION = 1;
|
|
9
|
-
const MAX_RECENT_SESSIONS =
|
|
10
|
+
export const MAX_RECENT_SESSIONS = 10;
|
|
10
11
|
function cloneJson(value) {
|
|
11
12
|
return JSON.parse(JSON.stringify(value ?? null));
|
|
12
13
|
}
|
|
@@ -77,6 +78,12 @@ function listSessionFiles(rootDir, env = process.env) {
|
|
|
77
78
|
.filter((name) => name.endsWith('.json'))
|
|
78
79
|
.map((name) => path.join(dir, name));
|
|
79
80
|
}
|
|
81
|
+
function normalizeBranch(value) {
|
|
82
|
+
const text = String(value ?? '')
|
|
83
|
+
.replace(/[\r\n\t]/g, ' ')
|
|
84
|
+
.trim();
|
|
85
|
+
return text ? text.slice(0, 120) : null;
|
|
86
|
+
}
|
|
80
87
|
function normalizeHistory(value) {
|
|
81
88
|
if (!Array.isArray(value))
|
|
82
89
|
return [];
|
|
@@ -107,6 +114,7 @@ function normalizeSnapshot(raw, rootDir) {
|
|
|
107
114
|
createdAt: normalizeIsoDate(raw.createdAt),
|
|
108
115
|
updatedAt: normalizeIsoDate(raw.updatedAt),
|
|
109
116
|
modelId,
|
|
117
|
+
branch: normalizeBranch(raw.branch),
|
|
110
118
|
history: cloneJson(normalizeHistory(raw.history)),
|
|
111
119
|
clientState: sanitizeClientState(raw.clientState),
|
|
112
120
|
serverState: sanitizeOpaqueState(raw.serverState),
|
|
@@ -154,6 +162,21 @@ export function pruneSavedSessions(rootDir, env = process.env) {
|
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
}
|
|
165
|
+
export function readGitBranch(rootDir) {
|
|
166
|
+
try {
|
|
167
|
+
const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
168
|
+
cwd: rootDir,
|
|
169
|
+
encoding: 'utf8',
|
|
170
|
+
timeout: 1500,
|
|
171
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
172
|
+
});
|
|
173
|
+
const branch = result.status === 0 ? String(result.stdout ?? '').trim() : '';
|
|
174
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
157
180
|
export function snapshotFromSession(session) {
|
|
158
181
|
const now = new Date().toISOString();
|
|
159
182
|
const createdAt = normalizeIsoDate(session.sessionCreatedAt ?? now);
|
|
@@ -168,6 +191,7 @@ export function snapshotFromSession(session) {
|
|
|
168
191
|
createdAt,
|
|
169
192
|
updatedAt: now,
|
|
170
193
|
modelId: session.modelId,
|
|
194
|
+
branch: readGitBranch(session.rootDir),
|
|
171
195
|
history: cloneJson(session.history),
|
|
172
196
|
clientState: {
|
|
173
197
|
editCounter: session.clientState.editCounter,
|
|
@@ -210,6 +234,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
|
|
|
210
234
|
safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
|
|
211
235
|
};
|
|
212
236
|
}
|
|
237
|
+
export function sessionHasUserMessage(session) {
|
|
238
|
+
return session.history.some((entry) => Boolean(userPromptText(entry)));
|
|
239
|
+
}
|
|
213
240
|
function extractMarkedSection(text, marker) {
|
|
214
241
|
const index = text.indexOf(marker);
|
|
215
242
|
if (index === -1)
|
|
@@ -218,23 +245,27 @@ function extractMarkedSection(text, marker) {
|
|
|
218
245
|
const end = after.indexOf('\n\n');
|
|
219
246
|
return (end === -1 ? after : after.slice(0, end)).trim();
|
|
220
247
|
}
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
export function userPromptText(entry) {
|
|
249
|
+
if (!entry || entry.role !== 'user' || entry.kind !== 'turnStart')
|
|
250
|
+
return '';
|
|
251
|
+
const text = (entry.parts ?? [])
|
|
252
|
+
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.join('\n')
|
|
255
|
+
.trim();
|
|
256
|
+
if (!text)
|
|
257
|
+
return '';
|
|
258
|
+
const request = extractMarkedSection(text, 'Current user request:') ||
|
|
259
|
+
extractMarkedSection(text, 'User request:');
|
|
260
|
+
const message = extractMarkedSection(text, 'Current user message:') ||
|
|
261
|
+
extractMarkedSection(text, 'User message:');
|
|
262
|
+
return (request || message || text).trim();
|
|
263
|
+
}
|
|
264
|
+
function extractFirstUserPrompt(history) {
|
|
265
|
+
for (const entry of history) {
|
|
266
|
+
const text = userPromptText(entry);
|
|
267
|
+
if (text)
|
|
268
|
+
return singleLinePreview(text, 120);
|
|
238
269
|
}
|
|
239
270
|
return '';
|
|
240
271
|
}
|
|
@@ -247,8 +278,9 @@ function metadataFromSnapshot(snapshot) {
|
|
|
247
278
|
updatedAt: snapshot.updatedAt,
|
|
248
279
|
modelId: snapshot.modelId,
|
|
249
280
|
messageCount: snapshot.history.length,
|
|
250
|
-
lastUserMessage:
|
|
281
|
+
lastUserMessage: extractFirstUserPrompt(snapshot.history),
|
|
251
282
|
summaryPreview: '',
|
|
283
|
+
branch: snapshot.branch ?? null,
|
|
252
284
|
};
|
|
253
285
|
}
|
|
254
286
|
export function listSessionMetadata(rootDir, env = process.env) {
|
package/dist/src/session.js
CHANGED
|
@@ -69,6 +69,14 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
|
|
|
69
69
|
serverState: cloneOpaqueState(serverState),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
export function startNewConversation(session) {
|
|
73
|
+
clearConversation(session);
|
|
74
|
+
const createdAt = new Date().toISOString();
|
|
75
|
+
session.sessionId = createSessionId();
|
|
76
|
+
session.sessionName = null;
|
|
77
|
+
session.sessionCreatedAt = createdAt;
|
|
78
|
+
session.sessionUpdatedAt = createdAt;
|
|
79
|
+
}
|
|
72
80
|
export function clearConversation(session) {
|
|
73
81
|
session.history = [];
|
|
74
82
|
session.serverState = preserveProviderSelection(session.serverState);
|
|
@@ -2,6 +2,7 @@ import { drainBackgroundJobNotifications, getBackgroundJob, } from './background
|
|
|
2
2
|
import { canStoreEditSnapshot, isEditToolName, isGitWorkTree, MAX_EDIT_JOURNAL_RECORDS, operationFromSnapshots, readFileEditSnapshot, } from './edit-journal.js';
|
|
3
3
|
import { clearEditFailure, collectCommandMutations, captureMutationBaseline, ensureActiveCheckpoint, recordEditFailure, recordSessionEdit, rememberCheckpointFiles, } from './session-safety.js';
|
|
4
4
|
import { buildAgentModeToolBlockedResult, } from './agent-mode.js';
|
|
5
|
+
import { classifyProjectPath } from './patcher.js';
|
|
5
6
|
import { dispatchTool } from './tools/index.js';
|
|
6
7
|
import { syncIndexFromDisk } from './project-index.js';
|
|
7
8
|
import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
|
|
@@ -84,9 +85,38 @@ async function collectTrackedCommandMutations({ session, projectIndex, result, t
|
|
|
84
85
|
invalidateShellDiagnosticsCache(session.rootDir);
|
|
85
86
|
rememberCheckpointFiles(session.clientState.safety, session.rootDir, records.map((record) => record.filePath), turnId);
|
|
86
87
|
const priorSync = result.repoSync;
|
|
87
|
-
if (!(priorSync &&
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
if (!(priorSync && (priorSync.added || priorSync.modified || priorSync.removed))) {
|
|
89
|
+
const mutationCounts = {
|
|
90
|
+
added: records.filter((record) => record.operation === 'create').length,
|
|
91
|
+
modified: records.filter((record) => record.operation === 'update').length,
|
|
92
|
+
removed: records.filter((record) => record.operation === 'delete').length,
|
|
93
|
+
indexedChunks: 0,
|
|
94
|
+
retrievalTokensUsed: 0,
|
|
95
|
+
};
|
|
96
|
+
if (priorSync?.error) {
|
|
97
|
+
result.repoSync = {
|
|
98
|
+
...mutationCounts,
|
|
99
|
+
indexSyncError: priorSync.error,
|
|
100
|
+
skipped: true,
|
|
101
|
+
reason: 'local index sync failed after command execution',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
try {
|
|
106
|
+
const repoSync = await syncIndexFromDisk(projectIndex);
|
|
107
|
+
result.repoSync = {
|
|
108
|
+
...repoSync,
|
|
109
|
+
added: Math.max(repoSync.added, mutationCounts.added),
|
|
110
|
+
modified: Math.max(repoSync.modified, mutationCounts.modified),
|
|
111
|
+
removed: Math.max(repoSync.removed, mutationCounts.removed),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const indexSyncError = error instanceof Error ? error.message : String(error);
|
|
116
|
+
result.repoSync = { ...mutationCounts, indexSyncError };
|
|
117
|
+
session.onStatus(`Repository changes were recorded, but local index sync failed: ${indexSyncError}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
90
120
|
}
|
|
91
121
|
result.sessionEdits = records.map((record) => ({
|
|
92
122
|
id: record.id,
|
|
@@ -150,7 +180,7 @@ export async function collectBackgroundJobUiOutputMutations({ session, projectIn
|
|
|
150
180
|
}
|
|
151
181
|
}
|
|
152
182
|
function recordAssistantEdit(session, call, result, before) {
|
|
153
|
-
if (!before || !isEditToolName(call.name))
|
|
183
|
+
if (!before || !isEditToolName(call.name) || result?.scratch === true)
|
|
154
184
|
return;
|
|
155
185
|
if (!result || typeof result !== 'object' || result.ok !== true) {
|
|
156
186
|
const filePath = getEditToolFilePath(call);
|
|
@@ -231,10 +261,12 @@ export async function executeLocalToolCall(toolContext, session, call) {
|
|
|
231
261
|
!editToolWritesSeparateOutput(call)
|
|
232
262
|
? repairFilePath(session.rootDir, rawEditFilePath)
|
|
233
263
|
: rawEditFilePath;
|
|
234
|
-
|
|
264
|
+
const tracksRepositoryEdit = filePathBeforeEdit &&
|
|
265
|
+
classifyProjectPath(session.rootDir, filePathBeforeEdit) === 'project';
|
|
266
|
+
if (tracksRepositoryEdit) {
|
|
235
267
|
rememberCheckpointFiles(session.clientState.safety, session.rootDir, [filePathBeforeEdit], session.turnState.id);
|
|
236
268
|
}
|
|
237
|
-
const beforeEditSnapshot =
|
|
269
|
+
const beforeEditSnapshot = tracksRepositoryEdit
|
|
238
270
|
? readFileEditSnapshot(session.rootDir, filePathBeforeEdit)
|
|
239
271
|
: null;
|
|
240
272
|
const commandTracker = call.name === 'run_command' || call.name === 'run_node_script'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
|
-
import { deleteProjectFile } from '../patcher.js';
|
|
2
|
+
import { classifyProjectPath, deleteProjectFile } from '../patcher.js';
|
|
3
3
|
import { isTuiMode } from '../runtime-mode.js';
|
|
4
4
|
import { removeIndexFile } from '../project-index.js';
|
|
5
5
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
@@ -9,10 +9,27 @@ export async function deleteFile(context, args) {
|
|
|
9
9
|
if (!filePath) {
|
|
10
10
|
return { ok: false, error: 'filePath is required' };
|
|
11
11
|
}
|
|
12
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
13
|
+
if (pathKind === 'outside') {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
filePath,
|
|
17
|
+
error: `Refusing to delete outside the project root: ${filePath}. Deletable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
18
|
+
failureCategory: 'invalid_argument',
|
|
19
|
+
failureDetails: {
|
|
20
|
+
category: 'invalid_argument',
|
|
21
|
+
tool: 'delete_file',
|
|
22
|
+
action: 'Delete files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR).',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const scratchPath = pathKind === 'scratch';
|
|
12
27
|
const result = deleteProjectFile(rootDir, filePath);
|
|
13
28
|
if (result.deleted) {
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
if (!scratchPath) {
|
|
30
|
+
await removeIndexFile(projectIndex, filePath);
|
|
31
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
32
|
+
}
|
|
16
33
|
if (!isTuiMode())
|
|
17
34
|
console.log(chalk.red(` 🗑️ Deleted: ${filePath}`));
|
|
18
35
|
}
|
|
@@ -21,7 +38,8 @@ export async function deleteFile(context, args) {
|
|
|
21
38
|
filePath,
|
|
22
39
|
changed: result.deleted,
|
|
23
40
|
deleted: result.deleted,
|
|
41
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
24
42
|
content: result.content,
|
|
25
|
-
diagnostics: result.deleted ? runShellDiagnostics(rootDir) : undefined,
|
|
43
|
+
diagnostics: result.deleted && !scratchPath ? runShellDiagnostics(rootDir) : undefined,
|
|
26
44
|
};
|
|
27
45
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from '../colors.js';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
|
-
import { applyUnifiedPatch, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
4
|
+
import { applyUnifiedPatch, classifyProjectPath, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
5
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
6
6
|
import { repairFilePath } from './path-suggest.js';
|
|
7
7
|
import { isTuiMode } from '../runtime-mode.js';
|
|
@@ -29,6 +29,21 @@ export async function patchFile(context, args) {
|
|
|
29
29
|
failureCategory: 'invalid_argument',
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
const pathKind = classifyProjectPath(rootDir, filePath);
|
|
33
|
+
if (pathKind === 'outside') {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
filePath,
|
|
37
|
+
error: `Refusing to edit outside the project root: ${filePath}. Editable locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`,
|
|
38
|
+
failureCategory: 'invalid_argument',
|
|
39
|
+
failureDetails: {
|
|
40
|
+
category: 'invalid_argument',
|
|
41
|
+
tool: 'patch_file',
|
|
42
|
+
action: 'Edit files inside the project root, or use an absolute path under the session scratch directory ($THEGITAI_SCRATCH_DIR) for temporary files.',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const scratchPath = pathKind === 'scratch';
|
|
32
47
|
let originalContent;
|
|
33
48
|
try {
|
|
34
49
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -68,20 +83,29 @@ export async function patchFile(context, args) {
|
|
|
68
83
|
ok: false,
|
|
69
84
|
skipped: true,
|
|
70
85
|
filePath,
|
|
71
|
-
|
|
86
|
+
failureCategory: 'user_declined',
|
|
87
|
+
failureDetails: {
|
|
88
|
+
category: 'user_declined',
|
|
89
|
+
tool: 'patch_file',
|
|
90
|
+
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.',
|
|
91
|
+
},
|
|
92
|
+
error: 'The real user rejected this proposed patch. Nothing was changed; this was not a tool failure or an automated system skip.',
|
|
72
93
|
};
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
96
|
const { changed } = writeProjectFile(rootDir, filePath, patchedContent);
|
|
76
97
|
let indexedChunks = 0;
|
|
77
98
|
let retrievalTokensUsed = 0;
|
|
78
|
-
if (changed) {
|
|
99
|
+
if (changed && !scratchPath) {
|
|
79
100
|
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
80
101
|
indexedChunks = indexResult.indexedChunks;
|
|
81
102
|
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
82
103
|
}
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
let diagnostics;
|
|
105
|
+
if (!scratchPath) {
|
|
106
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
107
|
+
diagnostics = runShellDiagnostics(rootDir, filePath);
|
|
108
|
+
}
|
|
85
109
|
const originalLines = originalContent.split('\n').length;
|
|
86
110
|
const patchedLines = patchedContent.split('\n').length;
|
|
87
111
|
if (!isTuiMode()) {
|
|
@@ -94,6 +118,7 @@ export async function patchFile(context, args) {
|
|
|
94
118
|
filePath,
|
|
95
119
|
changed,
|
|
96
120
|
operation: 'patch',
|
|
121
|
+
...(scratchPath ? { scratch: true } : {}),
|
|
97
122
|
indexedChunks,
|
|
98
123
|
retrievalTokensUsed,
|
|
99
124
|
bytesWritten: Buffer.byteLength(patchedContent, 'utf-8'),
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
|
|
4
4
|
import { buildSecretFilePreview, isDotenvLikePath, looksLikeEditableDotenv, shouldUseSecretFilePreview, } from '../secret-preview.js';
|
|
5
5
|
import { readProjectFile } from '../patcher.js';
|
|
6
|
+
import { isWithinSessionScratchDir } from '../scratch-dir.js';
|
|
6
7
|
import { repairFilePath } from './path-suggest.js';
|
|
7
8
|
import { dotenvFitsRedactionBudget, getCurrentFileHash, recordReadCoverage, redactContentWithStableTokens, redactDotenvWithStableTokens, } from '../session-safety.js';
|
|
8
9
|
import { readFileRange, truncate } from '../utils.js';
|
|
@@ -16,6 +17,7 @@ export async function readFile(context, args) {
|
|
|
16
17
|
return { ok: false, error: 'filePath is required' };
|
|
17
18
|
}
|
|
18
19
|
const projectPath = normalizeProjectRelativePath(rootDir, filePath);
|
|
20
|
+
const scratchPath = path.isAbsolute(filePath) && isWithinSessionScratchDir(filePath);
|
|
19
21
|
if (!projectPath && !path.isAbsolute(filePath)) {
|
|
20
22
|
return {
|
|
21
23
|
ok: false,
|
|
@@ -37,7 +39,7 @@ export async function readFile(context, args) {
|
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
let content;
|
|
40
|
-
if (projectPath) {
|
|
42
|
+
if (projectPath || scratchPath) {
|
|
41
43
|
try {
|
|
42
44
|
content = readProjectFile(rootDir, filePath);
|
|
43
45
|
}
|
|
@@ -188,7 +188,13 @@ export async function replaceDocumentText(context, args) {
|
|
|
188
188
|
ok: false,
|
|
189
189
|
skipped: true,
|
|
190
190
|
filePath: targetPath,
|
|
191
|
-
|
|
191
|
+
failureCategory: 'user_declined',
|
|
192
|
+
failureDetails: {
|
|
193
|
+
category: 'user_declined',
|
|
194
|
+
tool: 'replace_document_text',
|
|
195
|
+
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.',
|
|
196
|
+
},
|
|
197
|
+
error: 'The real user rejected this proposed document edit. Nothing was changed; this was not a tool failure or an automated system skip.',
|
|
192
198
|
};
|
|
193
199
|
}
|
|
194
200
|
}
|