@thegitai/cli 1.0.0-beta.1
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/LICENSE +20 -0
- package/README.md +30 -0
- package/dist/bin/ai.js +438 -0
- package/dist/parsers/tree-sitter-c-sharp.wasm +0 -0
- package/dist/parsers/tree-sitter-c.wasm +0 -0
- package/dist/parsers/tree-sitter-cpp.wasm +0 -0
- package/dist/parsers/tree-sitter-css.wasm +0 -0
- package/dist/parsers/tree-sitter-go.wasm +0 -0
- package/dist/parsers/tree-sitter-html.wasm +0 -0
- package/dist/parsers/tree-sitter-java.wasm +0 -0
- package/dist/parsers/tree-sitter-javascript.wasm +0 -0
- package/dist/parsers/tree-sitter-objc.wasm +0 -0
- package/dist/parsers/tree-sitter-php.wasm +0 -0
- package/dist/parsers/tree-sitter-python.wasm +0 -0
- package/dist/parsers/tree-sitter-ruby.wasm +0 -0
- package/dist/parsers/tree-sitter-rust.wasm +0 -0
- package/dist/parsers/tree-sitter-tsx.wasm +0 -0
- package/dist/parsers/tree-sitter-typescript.wasm +0 -0
- package/dist/src/agent-mode.js +142 -0
- package/dist/src/api/auth.js +81 -0
- package/dist/src/api/browser-login.js +184 -0
- package/dist/src/api/chat.js +346 -0
- package/dist/src/api/contracts.js +1 -0
- package/dist/src/api/http.js +44 -0
- package/dist/src/api/index.js +11 -0
- package/dist/src/api/models.js +110 -0
- package/dist/src/api/sessions.js +72 -0
- package/dist/src/artifact-policy.js +207 -0
- package/dist/src/client-state.js +14 -0
- package/dist/src/core/clipboard.js +208 -0
- package/dist/src/core/open-url.js +32 -0
- package/dist/src/edit-journal.js +133 -0
- package/dist/src/executor.js +924 -0
- package/dist/src/extractors/cpp.js +18 -0
- package/dist/src/extractors/csharp.js +16 -0
- package/dist/src/extractors/css.js +12 -0
- package/dist/src/extractors/go.js +27 -0
- package/dist/src/extractors/index.js +52 -0
- package/dist/src/extractors/java.js +14 -0
- package/dist/src/extractors/javascript.js +33 -0
- package/dist/src/extractors/objc.js +14 -0
- package/dist/src/extractors/php.js +20 -0
- package/dist/src/extractors/python.js +11 -0
- package/dist/src/extractors/ruby.js +13 -0
- package/dist/src/extractors/rust.js +17 -0
- package/dist/src/extractors/utils.js +58 -0
- package/dist/src/help-text.js +125 -0
- package/dist/src/markdown-renderer.js +112 -0
- package/dist/src/patcher.js +279 -0
- package/dist/src/project-index.js +221 -0
- package/dist/src/repo-map-languages.js +100 -0
- package/dist/src/runtime-mode.js +35 -0
- package/dist/src/scanner.js +362 -0
- package/dist/src/secret-preview.js +137 -0
- package/dist/src/session-exit.js +17 -0
- package/dist/src/session-safety.js +1012 -0
- package/dist/src/session-store.js +266 -0
- package/dist/src/session.js +93 -0
- package/dist/src/tool-executor.js +188 -0
- package/dist/src/tools/code-intel.js +472 -0
- package/dist/src/tools/delete-file.js +27 -0
- package/dist/src/tools/exec-utils.js +17 -0
- package/dist/src/tools/find-symbol.js +70 -0
- package/dist/src/tools/get-diagnostics.js +22 -0
- package/dist/src/tools/grep-code.js +331 -0
- package/dist/src/tools/hover-symbol.js +95 -0
- package/dist/src/tools/index.js +73 -0
- package/dist/src/tools/list-checkpoints.js +11 -0
- package/dist/src/tools/list-directories.js +16 -0
- package/dist/src/tools/list-files.js +13 -0
- package/dist/src/tools/list-session-edits.js +9 -0
- package/dist/src/tools/list-symbols.js +55 -0
- package/dist/src/tools/patch-file.js +88 -0
- package/dist/src/tools/path-listing.js +83 -0
- package/dist/src/tools/read-document.js +111 -0
- package/dist/src/tools/read-file.js +109 -0
- package/dist/src/tools/restore-checkpoint.js +100 -0
- package/dist/src/tools/ripgrep.js +29 -0
- package/dist/src/tools/run-command.js +94 -0
- package/dist/src/tools/run-node-script.js +210 -0
- package/dist/src/tools/search-code.js +37 -0
- package/dist/src/tools/shell-diagnostics.js +707 -0
- package/dist/src/tools/signature-help.js +118 -0
- package/dist/src/tools/str-replace.js +193 -0
- package/dist/src/tools/types.js +1 -0
- package/dist/src/tools/undo-edit.js +202 -0
- package/dist/src/tools/write-file.js +59 -0
- package/dist/src/tree-sitter-runtime.js +135 -0
- package/dist/src/types.js +1 -0
- package/dist/src/ui/paste-collapse.js +22 -0
- package/dist/src/ui/prompt-history-store.js +96 -0
- package/dist/src/ui/repl.js +2238 -0
- package/dist/src/ui/tui/bridge.js +175 -0
- package/dist/src/ui/tui/build-frame.js +718 -0
- package/dist/src/ui/tui/markdown-render.js +455 -0
- package/dist/src/ui/tui/shell-input.js +488 -0
- package/dist/src/ui/tui/text.js +30 -0
- package/dist/src/ui/tui/types.js +1 -0
- package/dist/src/usage.js +47 -0
- package/dist/src/utils.js +38 -0
- package/package.json +38 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readProjectText, searchTextMatches } from './code-intel.js';
|
|
2
|
+
const SIGNATURE_PATTERNS = [
|
|
3
|
+
/(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*(?:<[^>]*>\s*)?\([^)]*\)/,
|
|
4
|
+
/func\s+(?:\([^)]+\)\s+)?(\w+)\s*\([^)]*\)/,
|
|
5
|
+
/(?:async\s+)?def\s+(\w+)\s*\([^)]*\)/,
|
|
6
|
+
/(?:export\s+)?(?:async\s+)?function\s*\*?\s+(\w+)\s*(?:<[^>]*>\s*)?\([^)]*\)/,
|
|
7
|
+
/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>/,
|
|
8
|
+
];
|
|
9
|
+
function extractCalledFunction(content, line, column) {
|
|
10
|
+
const lines = content.split('\n');
|
|
11
|
+
if (line < 1 || line > lines.length)
|
|
12
|
+
return null;
|
|
13
|
+
const text = lines[line - 1];
|
|
14
|
+
const prefix = text.slice(0, column);
|
|
15
|
+
const m = prefix.match(/(\w+)\s*\(\s*[^)]*$/);
|
|
16
|
+
if (m)
|
|
17
|
+
return m[1];
|
|
18
|
+
if (line > 1) {
|
|
19
|
+
const prevLine = lines[line - 2];
|
|
20
|
+
const pm = prevLine.match(/(\w+)\s*\(\s*$/);
|
|
21
|
+
if (pm)
|
|
22
|
+
return pm[1];
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
async function findSignature(rootDir, funcName) {
|
|
27
|
+
const matches = await searchTextMatches(rootDir, funcName, { limit: 40 });
|
|
28
|
+
for (const match of matches) {
|
|
29
|
+
for (const pattern of SIGNATURE_PATTERNS) {
|
|
30
|
+
const sigMatch = match.content.match(pattern);
|
|
31
|
+
if (sigMatch && sigMatch[1] === funcName) {
|
|
32
|
+
return extractFullSignature(rootDir, match.filePath, match.line);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function extractFullSignature(rootDir, filePath, lineNum) {
|
|
39
|
+
let fileContent;
|
|
40
|
+
try {
|
|
41
|
+
fileContent = readProjectText(rootDir, filePath);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const lines = fileContent.split('\n');
|
|
47
|
+
const startIdx = lineNum - 1;
|
|
48
|
+
if (startIdx >= lines.length)
|
|
49
|
+
return null;
|
|
50
|
+
const signatureLines = [];
|
|
51
|
+
let openParens = 0;
|
|
52
|
+
let foundOpen = false;
|
|
53
|
+
for (let i = startIdx; i < Math.min(lines.length, startIdx + 20); i++) {
|
|
54
|
+
signatureLines.push(lines[i]);
|
|
55
|
+
for (const ch of lines[i]) {
|
|
56
|
+
if (ch === '(') {
|
|
57
|
+
openParens++;
|
|
58
|
+
foundOpen = true;
|
|
59
|
+
}
|
|
60
|
+
else if (ch === ')')
|
|
61
|
+
openParens--;
|
|
62
|
+
}
|
|
63
|
+
if (foundOpen && openParens <= 0)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
const signature = signatureLines.join('\n');
|
|
67
|
+
const bodyStart = signature.match(/\)\s*(?:->.*?)?\s*[{:]/) ??
|
|
68
|
+
signature.match(/\)\s*[{:]/) ??
|
|
69
|
+
signature.match(/\)\s*$/m);
|
|
70
|
+
const display = bodyStart
|
|
71
|
+
? signature
|
|
72
|
+
.slice(0, bodyStart.index + bodyStart[0].length)
|
|
73
|
+
.replace(/\s*[{:]\s*$/, '')
|
|
74
|
+
: signature;
|
|
75
|
+
return `${filePath}:${lineNum}\n\`\`\`\n${display.trim()}\n\`\`\``;
|
|
76
|
+
}
|
|
77
|
+
export async function getSignatureHelp(context, args) {
|
|
78
|
+
const filePath = String(args.filePath ?? '').trim();
|
|
79
|
+
const line = Number(args.line ?? 0);
|
|
80
|
+
const column = Number(args.column ?? 0);
|
|
81
|
+
if (!filePath || !line || !column) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: 'signature_help requires filePath, line, and column.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
let content;
|
|
88
|
+
try {
|
|
89
|
+
content = readProjectText(context.rootDir, filePath);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { ok: false, error: `Could not read ${filePath}.` };
|
|
93
|
+
}
|
|
94
|
+
const funcName = extractCalledFunction(content, line, column);
|
|
95
|
+
if (!funcName) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: 'No function call found at the given position. Use hover_symbol or read_file.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const signature = await findSignature(context.rootDir, funcName);
|
|
102
|
+
if (!signature) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `Could not find definition for "${funcName}". Use find_symbol or grep_code.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
provider: 'text_search',
|
|
111
|
+
available: true,
|
|
112
|
+
filePath,
|
|
113
|
+
line,
|
|
114
|
+
column,
|
|
115
|
+
functionName: funcName,
|
|
116
|
+
output: signature,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
|
+
import { readProjectFile, writeProjectFile } from '../patcher.js';
|
|
4
|
+
import { upsertIndexFile } from '../project-index.js';
|
|
5
|
+
import { isTuiMode } from '../runtime-mode.js';
|
|
6
|
+
import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
|
|
7
|
+
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
8
|
+
function countOccurrences(haystack, needle) {
|
|
9
|
+
if (needle.length === 0)
|
|
10
|
+
return 0;
|
|
11
|
+
let count = 0;
|
|
12
|
+
let pos = 0;
|
|
13
|
+
while (pos <= haystack.length) {
|
|
14
|
+
const i = haystack.indexOf(needle, pos);
|
|
15
|
+
if (i === -1)
|
|
16
|
+
break;
|
|
17
|
+
count++;
|
|
18
|
+
pos = i + needle.length;
|
|
19
|
+
}
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
function similarityScore(a, b) {
|
|
23
|
+
const ta = a.trim();
|
|
24
|
+
const tb = b.trim();
|
|
25
|
+
if (!ta || !tb)
|
|
26
|
+
return 0;
|
|
27
|
+
const shorter = ta.length < tb.length ? ta : tb;
|
|
28
|
+
const longer = ta.length < tb.length ? tb : ta;
|
|
29
|
+
let common = 0;
|
|
30
|
+
for (let i = 0; i < shorter.length; i++) {
|
|
31
|
+
if (shorter[i] === longer[i])
|
|
32
|
+
common++;
|
|
33
|
+
}
|
|
34
|
+
const prefixRatio = common / longer.length;
|
|
35
|
+
if (longer.includes(shorter) && shorter.length >= 4) {
|
|
36
|
+
return Math.max(prefixRatio, shorter.length / longer.length);
|
|
37
|
+
}
|
|
38
|
+
return prefixRatio;
|
|
39
|
+
}
|
|
40
|
+
function buildClosestMatchHint(content, oldString) {
|
|
41
|
+
const contentLines = content.split('\n');
|
|
42
|
+
const needleLines = oldString
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map((line) => line.trim())
|
|
45
|
+
.filter((line) => line.length >= 3);
|
|
46
|
+
if (needleLines.length === 0)
|
|
47
|
+
return '';
|
|
48
|
+
const target = needleLines[0];
|
|
49
|
+
const scored = [];
|
|
50
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
51
|
+
const line = contentLines[i];
|
|
52
|
+
if (!line.trim())
|
|
53
|
+
continue;
|
|
54
|
+
const score = similarityScore(target, line);
|
|
55
|
+
if (score >= 0.5) {
|
|
56
|
+
scored.push({ lineNum: i + 1, line, score });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
scored.sort((a, b) => b.score - a.score);
|
|
60
|
+
const top = scored.slice(0, 3);
|
|
61
|
+
if (!top.length)
|
|
62
|
+
return '';
|
|
63
|
+
const formatted = top
|
|
64
|
+
.map((hint) => ` line ${hint.lineNum}: ${hint.line}`)
|
|
65
|
+
.join('\n');
|
|
66
|
+
return `\nClosest lines in the file:\n${formatted}\n\nRe-read the file to get the exact text before retrying.`;
|
|
67
|
+
}
|
|
68
|
+
function buildStrReplacePreview(oldString, newString) {
|
|
69
|
+
const oldLines = oldString.split('\n');
|
|
70
|
+
const newLines = newString.split('\n');
|
|
71
|
+
const minus = oldLines.map((l) => `-${l}`).join('\n');
|
|
72
|
+
const plus = newLines.map((l) => `+${l}`).join('\n');
|
|
73
|
+
return `@@ str_replace @@\n${minus}\n${plus}`;
|
|
74
|
+
}
|
|
75
|
+
export async function strReplace(context, args) {
|
|
76
|
+
const { rootDir, projectIndex, autoYes, confirmPatch } = context;
|
|
77
|
+
const filePath = String(args.filePath ?? args.file_path ?? '').trim();
|
|
78
|
+
let oldString = typeof args.old_string === 'string'
|
|
79
|
+
? args.old_string
|
|
80
|
+
: typeof args.oldString === 'string'
|
|
81
|
+
? args.oldString
|
|
82
|
+
: '';
|
|
83
|
+
let newString = typeof args.new_string === 'string'
|
|
84
|
+
? args.new_string
|
|
85
|
+
: typeof args.newString === 'string'
|
|
86
|
+
? args.newString
|
|
87
|
+
: '';
|
|
88
|
+
const replaceAll = args.replace_all === true || args.replaceAll === true;
|
|
89
|
+
if (!filePath) {
|
|
90
|
+
return { ok: false, error: 'file_path is required' };
|
|
91
|
+
}
|
|
92
|
+
if (oldString.length === 0) {
|
|
93
|
+
return { ok: false, error: 'old_string is required and must be non-empty' };
|
|
94
|
+
}
|
|
95
|
+
let originalContent;
|
|
96
|
+
try {
|
|
97
|
+
originalContent = readProjectFile(rootDir, filePath);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `Cannot read file for str_replace: ${err.message}. Use write_file to create new files.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const currentHash = getCurrentFileHash(rootDir, filePath);
|
|
106
|
+
const coveragePath = normalizeProjectRelativePath(rootDir, filePath) ?? filePath;
|
|
107
|
+
oldString = resolveRedactionTokens(context.safety, oldString, coveragePath, currentHash);
|
|
108
|
+
newString = resolveRedactionTokens(context.safety, newString, coveragePath, currentHash);
|
|
109
|
+
const n = countOccurrences(originalContent, oldString);
|
|
110
|
+
if (n === 0) {
|
|
111
|
+
const hint = buildClosestMatchHint(originalContent, oldString);
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
filePath,
|
|
115
|
+
error: `String not found in file.${hint}`,
|
|
116
|
+
failureCategory: 'conflict',
|
|
117
|
+
failureDetails: {
|
|
118
|
+
category: 'conflict',
|
|
119
|
+
tool: 'str_replace',
|
|
120
|
+
action: 'The oldString text does not exist in this file. Do not keep editing imagined code; retry with oldString copied exactly from read_file output or use patch_file with exact current context.',
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (n >= 2 && !replaceAll) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: `old_string matches ${n} locations. Provide more surrounding context to make it unique, or set replace_all: true.`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const preview = buildStrReplacePreview(oldString, newString);
|
|
131
|
+
if (!isTuiMode()) {
|
|
132
|
+
console.log(chalk.bold(`\n 📋 str_replace preview for ${filePath}:`));
|
|
133
|
+
for (const line of preview.split('\n')) {
|
|
134
|
+
if (line.startsWith('+')) {
|
|
135
|
+
console.log(chalk.green(line));
|
|
136
|
+
}
|
|
137
|
+
else if (line.startsWith('-')) {
|
|
138
|
+
console.log(chalk.red(line));
|
|
139
|
+
}
|
|
140
|
+
else if (line.startsWith('@@')) {
|
|
141
|
+
console.log(chalk.cyan(line));
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.log(chalk.dim(line));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log();
|
|
148
|
+
}
|
|
149
|
+
if (!autoYes && confirmPatch) {
|
|
150
|
+
const confirmed = await confirmPatch(filePath, preview);
|
|
151
|
+
if (!confirmed) {
|
|
152
|
+
if (!isTuiMode())
|
|
153
|
+
console.log(chalk.dim(` ⏭ str_replace skipped: ${filePath}`));
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
skipped: true,
|
|
157
|
+
filePath,
|
|
158
|
+
error: 'User declined str_replace',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const nextContent = originalContent.split(oldString).join(newString);
|
|
163
|
+
const { changed } = writeProjectFile(rootDir, filePath, nextContent);
|
|
164
|
+
const replacements = changed ? (replaceAll ? n : 1) : 0;
|
|
165
|
+
let indexedChunks = 0;
|
|
166
|
+
let retrievalTokensUsed = 0;
|
|
167
|
+
if (changed) {
|
|
168
|
+
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
169
|
+
indexedChunks = indexResult.indexedChunks;
|
|
170
|
+
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
171
|
+
}
|
|
172
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
173
|
+
const diagnostics = runShellDiagnostics(rootDir, filePath);
|
|
174
|
+
const originalLines = originalContent.split('\n').length;
|
|
175
|
+
const nextLines = nextContent.split('\n').length;
|
|
176
|
+
if (!isTuiMode()) {
|
|
177
|
+
const actionLabel = changed ? 'str_replace' : 'str_replace (no change)';
|
|
178
|
+
const color = changed ? chalk.green : chalk.yellow;
|
|
179
|
+
console.log(color(` ✏️ ${actionLabel}: ${filePath} (${originalLines} → ${nextLines} lines, ${replacements} replacement(s))`));
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
ok: true,
|
|
183
|
+
filePath,
|
|
184
|
+
changed,
|
|
185
|
+
operation: 'str_replace',
|
|
186
|
+
replacements,
|
|
187
|
+
indexedChunks,
|
|
188
|
+
retrievalTokensUsed,
|
|
189
|
+
bytesWritten: Buffer.byteLength(nextContent, 'utf-8'),
|
|
190
|
+
diagnostics,
|
|
191
|
+
message: changed ? undefined : 'The provided replacement resulted in no changes to the file content.',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { hashContent, readFileEditSnapshot, } from '../edit-journal.js';
|
|
3
|
+
import { deleteProjectFile, writeProjectFile, } from '../patcher.js';
|
|
4
|
+
import { removeIndexFile, upsertIndexFile, } from '../project-index.js';
|
|
5
|
+
import { isTuiMode } from '../runtime-mode.js';
|
|
6
|
+
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
7
|
+
function normalizeTarget(value) {
|
|
8
|
+
const text = String(value ?? '').trim();
|
|
9
|
+
if (text === 'last_turn' ||
|
|
10
|
+
text === 'file' ||
|
|
11
|
+
text === 'edit_id' ||
|
|
12
|
+
text === 'last_assistant_edit') {
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
return 'last_assistant_edit';
|
|
16
|
+
}
|
|
17
|
+
function activeRecords(records) {
|
|
18
|
+
return records.filter((record) => !record.revertedAt);
|
|
19
|
+
}
|
|
20
|
+
function latestRecord(records) {
|
|
21
|
+
return records.length ? records[records.length - 1] : null;
|
|
22
|
+
}
|
|
23
|
+
function selectRecords(records, target, editId, filePath) {
|
|
24
|
+
const active = activeRecords(records);
|
|
25
|
+
if (target === 'edit_id') {
|
|
26
|
+
return active.filter((record) => record.id === editId);
|
|
27
|
+
}
|
|
28
|
+
if (target === 'file') {
|
|
29
|
+
const latest = latestRecord(active.filter((record) => record.filePath === filePath));
|
|
30
|
+
return latest ? [latest] : [];
|
|
31
|
+
}
|
|
32
|
+
if (target === 'last_turn') {
|
|
33
|
+
const latest = latestRecord(active);
|
|
34
|
+
if (!latest)
|
|
35
|
+
return [];
|
|
36
|
+
if (!latest.turnId)
|
|
37
|
+
return [latest];
|
|
38
|
+
return active.filter((record) => record.turnId === latest.turnId).reverse();
|
|
39
|
+
}
|
|
40
|
+
const latest = latestRecord(active);
|
|
41
|
+
return latest ? [latest] : [];
|
|
42
|
+
}
|
|
43
|
+
function validateUndoPlan(rootDir, records) {
|
|
44
|
+
const simulatedHashes = new Map();
|
|
45
|
+
for (const record of records) {
|
|
46
|
+
let currentHash;
|
|
47
|
+
if (simulatedHashes.has(record.filePath)) {
|
|
48
|
+
currentHash = simulatedHashes.get(record.filePath) ?? null;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const current = readFileEditSnapshot(rootDir, record.filePath);
|
|
52
|
+
if (current.error) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `Cannot inspect ${record.filePath} before undo: ${current.error}`,
|
|
56
|
+
editId: record.id,
|
|
57
|
+
filePath: record.filePath,
|
|
58
|
+
expectedHash: record.afterHash,
|
|
59
|
+
currentHash: current.hash,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
currentHash = current.hash;
|
|
63
|
+
}
|
|
64
|
+
if (currentHash !== record.afterHash) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: `Cannot undo ${record.id} for ${record.filePath}: ` +
|
|
68
|
+
'the file changed after the assistant edit.',
|
|
69
|
+
editId: record.id,
|
|
70
|
+
filePath: record.filePath,
|
|
71
|
+
expectedHash: record.afterHash,
|
|
72
|
+
currentHash,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const needsPreimage = record.operation === 'update' || record.operation === 'delete';
|
|
76
|
+
if (needsPreimage) {
|
|
77
|
+
if (record.beforeContent === null || record.beforeHash === null) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `Cannot undo ${record.id}: the stored pre-edit snapshot is incomplete.`,
|
|
81
|
+
editId: record.id,
|
|
82
|
+
filePath: record.filePath,
|
|
83
|
+
expectedHash: record.beforeHash,
|
|
84
|
+
currentHash,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const beforeContentHash = hashContent(record.beforeContent);
|
|
88
|
+
if (beforeContentHash !== record.beforeHash) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `Cannot undo ${record.id}: stored before-content hash does not match the edit record.`,
|
|
92
|
+
editId: record.id,
|
|
93
|
+
filePath: record.filePath,
|
|
94
|
+
expectedHash: record.beforeHash,
|
|
95
|
+
currentHash: beforeContentHash,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
simulatedHashes.set(record.filePath, record.operation === 'create' ? null : record.beforeHash);
|
|
100
|
+
}
|
|
101
|
+
return { ok: true };
|
|
102
|
+
}
|
|
103
|
+
async function applyUndo(context, record) {
|
|
104
|
+
const { rootDir, projectIndex } = context;
|
|
105
|
+
if (record.operation === 'create') {
|
|
106
|
+
deleteProjectFile(rootDir, record.filePath);
|
|
107
|
+
await removeIndexFile(projectIndex, record.filePath);
|
|
108
|
+
return { changed: true };
|
|
109
|
+
}
|
|
110
|
+
if (record.beforeContent === null) {
|
|
111
|
+
throw new Error(`Cannot undo ${record.id}: the stored pre-edit snapshot is incomplete.`);
|
|
112
|
+
}
|
|
113
|
+
const { changed } = writeProjectFile(rootDir, record.filePath, record.beforeContent);
|
|
114
|
+
if (changed) {
|
|
115
|
+
await upsertIndexFile(projectIndex, record.filePath);
|
|
116
|
+
}
|
|
117
|
+
return { changed };
|
|
118
|
+
}
|
|
119
|
+
function summarizeUndoRecord(record) {
|
|
120
|
+
return {
|
|
121
|
+
id: record.id,
|
|
122
|
+
filePath: record.filePath,
|
|
123
|
+
operation: record.operation,
|
|
124
|
+
toolName: record.toolName,
|
|
125
|
+
turnId: record.turnId,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function undoEdit(context, args) {
|
|
129
|
+
const records = context.editJournal ?? [];
|
|
130
|
+
const target = normalizeTarget(args.target);
|
|
131
|
+
const editId = String(args.editId ?? args.edit_id ?? '').trim();
|
|
132
|
+
const filePath = String(args.filePath ?? args.file_path ?? '').trim();
|
|
133
|
+
if (target === 'edit_id' && !editId) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
failureCategory: 'missing_required_argument',
|
|
137
|
+
error: 'editId is required when target is edit_id',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (target === 'file' && !filePath) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
failureCategory: 'missing_required_argument',
|
|
144
|
+
error: 'filePath is required when target is file',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const selected = selectRecords(records, target, editId, filePath);
|
|
148
|
+
if (!selected.length) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
failureCategory: 'not_found',
|
|
152
|
+
error: 'No active assistant edit matched the undo request.',
|
|
153
|
+
target,
|
|
154
|
+
editId: editId || undefined,
|
|
155
|
+
filePath: filePath || undefined,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const validation = validateUndoPlan(context.rootDir, selected);
|
|
159
|
+
if (!validation.ok) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
failureCategory: 'conflict',
|
|
163
|
+
error: validation.error,
|
|
164
|
+
editId: validation.editId,
|
|
165
|
+
filePath: validation.filePath,
|
|
166
|
+
expectedHash: validation.expectedHash,
|
|
167
|
+
currentHash: validation.currentHash,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const dryRun = args.dryRun === true || args.dry_run === true;
|
|
171
|
+
let anyChanged = false;
|
|
172
|
+
if (!dryRun) {
|
|
173
|
+
for (const record of selected) {
|
|
174
|
+
const result = await applyUndo(context, record);
|
|
175
|
+
if (result.changed) {
|
|
176
|
+
anyChanged = true;
|
|
177
|
+
}
|
|
178
|
+
context.markEditReverted?.(record.id, context.currentToolCallId ?? 'undo_edit');
|
|
179
|
+
}
|
|
180
|
+
invalidateShellDiagnosticsCache(context.rootDir);
|
|
181
|
+
}
|
|
182
|
+
const previewed = dryRun ? selected.map(summarizeUndoRecord) : [];
|
|
183
|
+
const reverted = dryRun ? [] : selected.map(summarizeUndoRecord);
|
|
184
|
+
if (!isTuiMode()) {
|
|
185
|
+
const files = (dryRun ? previewed : reverted)
|
|
186
|
+
.map((record) => record.filePath)
|
|
187
|
+
.join(', ');
|
|
188
|
+
const actionLabel = anyChanged || dryRun ? 'Undo' : 'Undo (no change)';
|
|
189
|
+
console.log(chalk.green(` ${actionLabel} ${dryRun ? 'preview' : 'edit'}: ${files}`));
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
operation: 'undo_edit',
|
|
194
|
+
target,
|
|
195
|
+
changed: anyChanged,
|
|
196
|
+
dryRun,
|
|
197
|
+
previewed,
|
|
198
|
+
reverted,
|
|
199
|
+
gitWorkTree: selected.some((record) => record.gitWorkTree),
|
|
200
|
+
diagnostics: dryRun ? undefined : runShellDiagnostics(context.rootDir),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
|
+
import { writeProjectFile } from '../patcher.js';
|
|
4
|
+
import { upsertIndexFile } from '../project-index.js';
|
|
5
|
+
import { isTuiMode } from '../runtime-mode.js';
|
|
6
|
+
import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
|
|
7
|
+
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
8
|
+
export async function writeFile(context, args) {
|
|
9
|
+
const { rootDir, projectIndex } = context;
|
|
10
|
+
const filePath = String(args.filePath ?? '').trim();
|
|
11
|
+
let content = typeof args.content === 'string' ? args.content : '';
|
|
12
|
+
if (!filePath) {
|
|
13
|
+
return { ok: false, error: 'filePath is required' };
|
|
14
|
+
}
|
|
15
|
+
const coveragePath = normalizeProjectRelativePath(rootDir, filePath) ?? filePath;
|
|
16
|
+
const currentHash = getCurrentFileHash(rootDir, filePath);
|
|
17
|
+
if (currentHash !== null &&
|
|
18
|
+
context.safety &&
|
|
19
|
+
!hasFreshFullReadCoverage(context.safety, coveragePath, currentHash)) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
filePath,
|
|
23
|
+
failureCategory: 'conflict',
|
|
24
|
+
error: `write_file refused to replace existing file ${filePath} without a fresh full-file read at the current hash.`,
|
|
25
|
+
failureDetails: {
|
|
26
|
+
category: 'conflict',
|
|
27
|
+
tool: 'write_file',
|
|
28
|
+
action: 'Call read_file for the full file first, then retry write_file only if a full replacement is still necessary. Prefer str_replace or patch_file for targeted edits.',
|
|
29
|
+
},
|
|
30
|
+
currentHash,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
content = resolveRedactionTokens(context.safety, content, coveragePath, currentHash);
|
|
34
|
+
const { changed } = writeProjectFile(rootDir, filePath, content);
|
|
35
|
+
let indexedChunks = 0;
|
|
36
|
+
let retrievalTokensUsed = 0;
|
|
37
|
+
if (changed) {
|
|
38
|
+
const indexResult = await upsertIndexFile(projectIndex, filePath);
|
|
39
|
+
indexedChunks = indexResult.indexedChunks;
|
|
40
|
+
retrievalTokensUsed = indexResult.retrievalTokensUsed ?? 0;
|
|
41
|
+
}
|
|
42
|
+
invalidateShellDiagnosticsCache(rootDir, filePath);
|
|
43
|
+
const diagnostics = runShellDiagnostics(rootDir, filePath);
|
|
44
|
+
if (!isTuiMode()) {
|
|
45
|
+
const icon = changed ? '✨' : '📝';
|
|
46
|
+
const label = changed ? 'Created/Updated' : 'Created/Updated (no change)';
|
|
47
|
+
console.log(chalk.green(` ${icon} ${label}: ${filePath}`));
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
filePath,
|
|
52
|
+
changed,
|
|
53
|
+
operation: 'write',
|
|
54
|
+
indexedChunks,
|
|
55
|
+
retrievalTokensUsed,
|
|
56
|
+
bytesWritten: Buffer.byteLength(content, 'utf-8'),
|
|
57
|
+
diagnostics,
|
|
58
|
+
};
|
|
59
|
+
}
|