@thegitai/cli 1.0.0-beta.2 → 1.0.0-beta.20
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 +37 -2
- package/dist/bin/ai.js +148 -75
- package/dist/parsers/NOTICE +18 -0
- package/dist/src/agent-mode.js +5 -0
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +7 -41
- package/dist/src/api/chat.js +77 -20
- package/dist/src/api/http.js +81 -4
- package/dist/src/api/models.js +26 -18
- package/dist/src/artifact-policy.js +12 -0
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/cli-args.js +60 -0
- package/dist/src/client-environment.js +129 -0
- package/dist/src/colors.js +50 -0
- package/dist/src/core/clipboard.js +75 -0
- package/dist/src/core/image-path-extractor.js +144 -0
- package/dist/src/edit-journal.js +39 -6
- package/dist/src/executor.js +48 -12
- package/dist/src/help-text.js +24 -5
- package/dist/src/markdown-renderer.js +1 -1
- package/dist/src/patcher.js +17 -2
- package/dist/src/scanner.js +58 -17
- package/dist/src/scratch-dir.js +57 -0
- package/dist/src/secret-preview.js +0 -10
- package/dist/src/session-safety.js +64 -31
- package/dist/src/session-store.js +0 -1
- package/dist/src/todo-list.js +106 -0
- package/dist/src/tool-executor.js +164 -18
- package/dist/src/tools/delete-file.js +1 -1
- package/dist/src/tools/index.js +8 -0
- package/dist/src/tools/patch-file.js +16 -2
- package/dist/src/tools/path-suggest.js +139 -0
- package/dist/src/tools/read-document.js +15 -4
- package/dist/src/tools/read-file.js +23 -7
- package/dist/src/tools/replace-document-text.js +234 -0
- package/dist/src/tools/restore-checkpoint.js +1 -1
- package/dist/src/tools/run-command.js +83 -16
- package/dist/src/tools/run-node-script.js +3 -1
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/tools/str-replace.js +16 -2
- package/dist/src/tools/undo-edit.js +7 -5
- package/dist/src/tools/update-todos.js +27 -0
- package/dist/src/tools/write-file.js +14 -1
- package/dist/src/tree-sitter-runtime.js +8 -1
- package/dist/src/ui/repl.js +315 -24
- package/dist/src/ui/tui/bridge.js +2 -6
- package/dist/src/ui/tui/build-frame.js +224 -25
- package/dist/src/ui/tui/shell-input.js +42 -5
- package/dist/src/version.js +29 -0
- package/dist/vendor/web-tree-sitter/LICENSE +21 -0
- package/dist/vendor/web-tree-sitter/NOTICE +13 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/package.json +14 -15
package/dist/src/scanner.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
-
import { readFileSync, statSync } from 'fs';
|
|
3
|
-
import { glob } from 'glob';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
4
3
|
import path from 'path';
|
|
5
4
|
import { getNodePrimarySignature, getStructuralChildren, parseRepoSource, } from './tree-sitter-runtime.js';
|
|
6
|
-
import {
|
|
5
|
+
import { ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
|
|
7
6
|
const BINARY_EXTENSIONS = BINARY_ARTIFACT_EXTENSIONS;
|
|
8
7
|
const ALWAYS_IGNORE_FILES = ARTIFACT_IGNORE_FILES;
|
|
9
8
|
export const ALWAYS_IGNORE_DIRS = ARTIFACT_IGNORE_DIRS;
|
|
10
9
|
export const BLOCKED_PATH_INSPECT_DIRS = ARTIFACT_INSPECT_BLOCK_DIRS;
|
|
11
|
-
const FALLBACK_IGNORE = ARTIFACT_FALLBACK_IGNORE_GLOBS;
|
|
12
10
|
export const SCANNER_MAX_SOURCE_FILE_BYTES = 100 * 1024;
|
|
13
11
|
const MAX_FILE_SIZE = SCANNER_MAX_SOURCE_FILE_BYTES;
|
|
14
12
|
const MAX_CHUNKS = 2000;
|
|
@@ -16,26 +14,69 @@ const TARGET_CHUNK_CHARS = 1800;
|
|
|
16
14
|
const MAX_CHUNK_CHARS = 2800;
|
|
17
15
|
const FALLBACK_OVERLAP_LINES = 10;
|
|
18
16
|
const MAX_STRUCTURE_DEPTH = 2;
|
|
17
|
+
function parseGitLsFilesOutput(output) {
|
|
18
|
+
return String(output)
|
|
19
|
+
.split('\0')
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.filter((filePath) => !shouldIgnorePath(filePath));
|
|
22
|
+
}
|
|
19
23
|
function getFiles(rootDir, { limit = Infinity } = {}) {
|
|
20
24
|
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));
|
|
25
|
+
const output = execSync('git ls-files -z --cached --others --exclude-standard', { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
26
|
+
const files = parseGitLsFilesOutput(output);
|
|
26
27
|
return Number.isFinite(limit) ? files.slice(0, limit) : files;
|
|
27
28
|
}
|
|
28
29
|
catch {
|
|
29
|
-
return
|
|
30
|
-
.sync('**/*', {
|
|
31
|
-
cwd: rootDir,
|
|
32
|
-
nodir: true,
|
|
33
|
-
dot: false,
|
|
34
|
-
ignore: FALLBACK_IGNORE,
|
|
35
|
-
})
|
|
36
|
-
.slice(0, Number.isFinite(limit) ? limit : undefined);
|
|
30
|
+
return walkProjectFilesFallback(rootDir, Number.isFinite(limit) ? limit : Infinity);
|
|
37
31
|
}
|
|
38
32
|
}
|
|
33
|
+
const FALLBACK_LOCKFILES = new Set([
|
|
34
|
+
'package-lock.json',
|
|
35
|
+
'yarn.lock',
|
|
36
|
+
'pnpm-lock.yaml',
|
|
37
|
+
]);
|
|
38
|
+
function isFallbackIgnoredFile(relPath, fileName) {
|
|
39
|
+
if (shouldIgnorePath(relPath))
|
|
40
|
+
return true;
|
|
41
|
+
if (fileName.endsWith('.lock'))
|
|
42
|
+
return true;
|
|
43
|
+
return FALLBACK_LOCKFILES.has(fileName);
|
|
44
|
+
}
|
|
45
|
+
function walkProjectFilesFallback(rootDir, limit) {
|
|
46
|
+
const results = [];
|
|
47
|
+
const visit = (relDir) => {
|
|
48
|
+
if (results.length >= limit)
|
|
49
|
+
return;
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(path.join(rootDir, relDir), {
|
|
53
|
+
withFileTypes: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (results.length >= limit)
|
|
61
|
+
return;
|
|
62
|
+
const name = entry.name;
|
|
63
|
+
if (name.startsWith('.'))
|
|
64
|
+
continue;
|
|
65
|
+
const relPath = relDir ? `${relDir}/${name}` : name;
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
visit(relPath);
|
|
71
|
+
}
|
|
72
|
+
else if (entry.isFile() && !isFallbackIgnoredFile(relPath, name)) {
|
|
73
|
+
results.push(relPath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
visit('');
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
39
80
|
function shouldSkipFile(relPath, stat) {
|
|
40
81
|
if (shouldIgnorePath(relPath))
|
|
41
82
|
return true;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
let cachedScratchDir = null;
|
|
5
|
+
export function sessionScratchDir() {
|
|
6
|
+
if (!cachedScratchDir) {
|
|
7
|
+
cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
|
|
8
|
+
}
|
|
9
|
+
return cachedScratchDir;
|
|
10
|
+
}
|
|
11
|
+
function isSquattedScratchRoot(dir) {
|
|
12
|
+
try {
|
|
13
|
+
const st = lstatSync(dir);
|
|
14
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
15
|
+
return true;
|
|
16
|
+
if (process.platform !== 'win32' &&
|
|
17
|
+
typeof process.getuid === 'function' &&
|
|
18
|
+
st.uid !== process.getuid()) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function ensureSessionScratchDir() {
|
|
28
|
+
const dir = sessionScratchDir();
|
|
29
|
+
try {
|
|
30
|
+
if (isSquattedScratchRoot(dir)) {
|
|
31
|
+
cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
|
|
32
|
+
return cachedScratchDir;
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
if (process.platform !== 'win32') {
|
|
36
|
+
chmodSync(dir, 0o700);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
}
|
|
41
|
+
return sessionScratchDir();
|
|
42
|
+
}
|
|
43
|
+
export function isInsideTheGitAiScratch(absPath) {
|
|
44
|
+
const tempRoot = path.resolve(os.tmpdir());
|
|
45
|
+
const relPath = path.relative(tempRoot, path.resolve(absPath));
|
|
46
|
+
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const first = relPath.split(/[\\/]/, 1)[0] ?? '';
|
|
50
|
+
if (!/^thegitai(?:$|[-.])/i.test(first)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (/[*?[\]{}]/.test(first)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return !isSquattedScratchRoot(path.join(tempRoot, first));
|
|
57
|
+
}
|
|
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
|
|
|
6
6
|
const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
|
|
7
7
|
const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
|
|
8
8
|
const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
|
|
9
|
-
// Password embedded in a connection-string URL, e.g.
|
|
10
|
-
// `postgresql://user:PASS@host`. Redacted from shell output so secrets in
|
|
11
|
-
// commands like `cat .env` do not leak into history or telemetry.
|
|
12
9
|
const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
|
|
13
|
-
/** Redact only userinfo passwords in connection-string URLs (zero false positives). */
|
|
14
10
|
export function redactConnectionStringCredentials(text) {
|
|
15
11
|
return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
|
|
16
12
|
}
|
|
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
|
|
|
72
68
|
const base = path.posix.basename(text.replace(/\\/g, '/'));
|
|
73
69
|
return DOTENV_BASENAME_PATTERN.test(base);
|
|
74
70
|
}
|
|
75
|
-
/**
|
|
76
|
-
* True only for a clean dotenv file we can safely show with keys visible and
|
|
77
|
-
* values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
|
|
78
|
-
* is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
|
|
79
|
-
* raw secret) returns false so the caller keeps the opaque blackout instead.
|
|
80
|
-
*/
|
|
81
71
|
export function looksLikeEditableDotenv(content) {
|
|
82
72
|
PEM_BLOCK_PATTERN.lastIndex = 0;
|
|
83
73
|
if (PEM_BLOCK_PATTERN.test(content))
|
|
@@ -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) {
|
|
@@ -419,17 +454,6 @@ export function resolveRedactionTokens(state, text, filePath, hash) {
|
|
|
419
454
|
}
|
|
420
455
|
const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
|
|
421
456
|
const DOTENV_COMMENT_PATTERN = /^(\s*#\s*)(\S.*)$/;
|
|
422
|
-
/**
|
|
423
|
-
* Redact a dotenv file's values while leaving keys visible. Every assignment's
|
|
424
|
-
* value is replaced with a stable, reversible token so the agent can see the
|
|
425
|
-
* file's structure and edit it (remove or replace lines) without ever seeing a
|
|
426
|
-
* secret value; `resolveRedactionTokens` swaps the real values back on write.
|
|
427
|
-
* Comment bodies are tokenized too, because developers routinely leave
|
|
428
|
-
* commented-out credentials in dotenv files and those must not leak where the
|
|
429
|
-
* opaque preview would have hidden them. Callers must confirm the content is
|
|
430
|
-
* clean dotenv (`looksLikeEditableDotenv`) first so the only non-assignment
|
|
431
|
-
* lines reaching here are blanks and comments.
|
|
432
|
-
*/
|
|
433
457
|
export function redactDotenvWithStableTokens(state, content, filePath, hash) {
|
|
434
458
|
const tokens = [];
|
|
435
459
|
const redactedLines = content.split('\n').map((line) => {
|
|
@@ -455,14 +479,6 @@ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
|
|
|
455
479
|
});
|
|
456
480
|
return { content: redactedLines.join('\n'), tokens };
|
|
457
481
|
}
|
|
458
|
-
/**
|
|
459
|
-
* The redaction-token registry is capped at `MAX_REDACTION_TOKENS`; a read that
|
|
460
|
-
* emits more tokens than that would evict its own oldest tokens, leaving
|
|
461
|
-
* `[REDACTED:n]` markers in the preview that `write_file`/`str_replace` can no
|
|
462
|
-
* longer resolve (silently writing the literal token back). So a dotenv file
|
|
463
|
-
* with more tokenizable lines than the budget must not use the editable preview
|
|
464
|
-
* — the caller falls back to the opaque blackout instead.
|
|
465
|
-
*/
|
|
466
482
|
export function dotenvFitsRedactionBudget(content) {
|
|
467
483
|
let count = 0;
|
|
468
484
|
for (const line of content.split('\n')) {
|
|
@@ -661,7 +677,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
661
677
|
const before = readFileEditSnapshot(args.rootDir, snapshot.filePath);
|
|
662
678
|
try {
|
|
663
679
|
if (snapshot.exists) {
|
|
664
|
-
const result =
|
|
680
|
+
const result = writeStoredProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '', snapshot.contentEncoding);
|
|
665
681
|
if (result.changed)
|
|
666
682
|
changed = true;
|
|
667
683
|
if (syncPolicy)
|
|
@@ -693,7 +709,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
693
709
|
if (item.before.content == null) {
|
|
694
710
|
throw new Error('previous file content is unavailable');
|
|
695
711
|
}
|
|
696
|
-
|
|
712
|
+
writeStoredProjectFile(args.rootDir, item.snapshot.filePath, item.before.content, item.before.contentEncoding);
|
|
697
713
|
if (syncPolicy) {
|
|
698
714
|
await upsertIndexFile(args.projectIndex, item.snapshot.filePath);
|
|
699
715
|
}
|
|
@@ -749,6 +765,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
749
765
|
beforeHash: item.before.hash,
|
|
750
766
|
afterHash: item.after.hash,
|
|
751
767
|
beforeContent: item.before.content,
|
|
768
|
+
beforeContentEncoding: item.before.contentEncoding,
|
|
752
769
|
checkpointId: checkpoint.id,
|
|
753
770
|
});
|
|
754
771
|
restored.push({
|
|
@@ -777,6 +794,18 @@ function git(args, cwd) {
|
|
|
777
794
|
return null;
|
|
778
795
|
}
|
|
779
796
|
}
|
|
797
|
+
function gitBuffer(args, cwd) {
|
|
798
|
+
try {
|
|
799
|
+
return execFileSync('git', args, {
|
|
800
|
+
cwd,
|
|
801
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
802
|
+
timeout: 10_000,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
780
809
|
export function findGitRoot(startDir) {
|
|
781
810
|
const root = git(['rev-parse', '--show-toplevel'], startDir);
|
|
782
811
|
return root ? path.resolve(root) : null;
|
|
@@ -897,14 +926,15 @@ function readGitHeadSnapshot(rootDir, filePath) {
|
|
|
897
926
|
return null;
|
|
898
927
|
const abs = resolveProjectPath(rootDir, filePath);
|
|
899
928
|
const relToGit = path.relative(gitRoot, abs).split(path.sep).join('/');
|
|
900
|
-
const content =
|
|
929
|
+
const content = gitBuffer(['show', `HEAD:${relToGit}`], gitRoot);
|
|
901
930
|
if (content == null)
|
|
902
931
|
return null;
|
|
903
932
|
return {
|
|
904
933
|
filePath,
|
|
905
934
|
exists: true,
|
|
906
|
-
hash:
|
|
907
|
-
content,
|
|
935
|
+
hash: hashBytes(content),
|
|
936
|
+
content: content.toString('base64'),
|
|
937
|
+
contentEncoding: 'base64',
|
|
908
938
|
};
|
|
909
939
|
}
|
|
910
940
|
export function captureMutationBaseline(rootDir, options) {
|
|
@@ -932,6 +962,7 @@ export function captureMutationBaseline(rootDir, options) {
|
|
|
932
962
|
exists: true,
|
|
933
963
|
hash: null,
|
|
934
964
|
content: null,
|
|
965
|
+
contentEncoding: 'utf8',
|
|
935
966
|
skipped: `Pre-command snapshot skipped: project baseline content budget exceeded (${contentBudget} bytes). Restore for this file is not available.`,
|
|
936
967
|
});
|
|
937
968
|
continue;
|
|
@@ -978,6 +1009,7 @@ export function collectCommandMutations(args) {
|
|
|
978
1009
|
exists: false,
|
|
979
1010
|
hash: null,
|
|
980
1011
|
content: null,
|
|
1012
|
+
contentEncoding: 'utf8',
|
|
981
1013
|
};
|
|
982
1014
|
if (before.hash === after.hash && !before.skipped)
|
|
983
1015
|
continue;
|
|
@@ -1000,6 +1032,7 @@ export function collectCommandMutations(args) {
|
|
|
1000
1032
|
beforeHash: before.hash,
|
|
1001
1033
|
afterHash: after.hash,
|
|
1002
1034
|
beforeContent: before.content,
|
|
1035
|
+
beforeContentEncoding: before.contentEncoding,
|
|
1003
1036
|
checkpointId: args.checkpointId,
|
|
1004
1037
|
});
|
|
1005
1038
|
records.push(record);
|
|
@@ -122,7 +122,6 @@ function loadAllSnapshots(rootDir, env = process.env) {
|
|
|
122
122
|
snapshots.push(loadSnapshotFile(filePath, rootDir));
|
|
123
123
|
}
|
|
124
124
|
catch {
|
|
125
|
-
// Skip corrupted snapshots silently — customers have no actionable debug path here.
|
|
126
125
|
}
|
|
127
126
|
}
|
|
128
127
|
return snapshots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const MAX_TODO_ITEMS = 20;
|
|
2
|
+
export const MAX_TODO_TEXT_CHARS = 160;
|
|
3
|
+
let items = [];
|
|
4
|
+
let activeSessionId = null;
|
|
5
|
+
const STATUS_ALIASES = {
|
|
6
|
+
pending: 'pending',
|
|
7
|
+
todo: 'pending',
|
|
8
|
+
not_started: 'pending',
|
|
9
|
+
in_progress: 'in_progress',
|
|
10
|
+
active: 'in_progress',
|
|
11
|
+
doing: 'in_progress',
|
|
12
|
+
completed: 'completed',
|
|
13
|
+
complete: 'completed',
|
|
14
|
+
done: 'completed',
|
|
15
|
+
};
|
|
16
|
+
function normalizeStatus(raw) {
|
|
17
|
+
const key = String(raw ?? '')
|
|
18
|
+
.trim()
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[-\s]+/g, '_');
|
|
21
|
+
return STATUS_ALIASES[key] ?? null;
|
|
22
|
+
}
|
|
23
|
+
export function isCompletedStatus(raw) {
|
|
24
|
+
return normalizeStatus(raw) === 'completed';
|
|
25
|
+
}
|
|
26
|
+
const TODOS_ARG_ALIASES = ['items', 'todo_list', 'todoList', 'list', 'tasks'];
|
|
27
|
+
export function extractTodosArg(args) {
|
|
28
|
+
if (!args || typeof args !== 'object')
|
|
29
|
+
return undefined;
|
|
30
|
+
const record = args;
|
|
31
|
+
if (record.todos !== undefined)
|
|
32
|
+
return record.todos;
|
|
33
|
+
for (const key of TODOS_ARG_ALIASES) {
|
|
34
|
+
if (record[key] !== undefined)
|
|
35
|
+
return record[key];
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
export function setTodoSession(sessionId) {
|
|
40
|
+
const next = String(sessionId ?? '').trim() || null;
|
|
41
|
+
if (activeSessionId !== next) {
|
|
42
|
+
items = [];
|
|
43
|
+
}
|
|
44
|
+
activeSessionId = next;
|
|
45
|
+
}
|
|
46
|
+
export function listTodos() {
|
|
47
|
+
return items.map((item) => ({ ...item }));
|
|
48
|
+
}
|
|
49
|
+
export function getTodoSnapshot() {
|
|
50
|
+
return {
|
|
51
|
+
items: listTodos(),
|
|
52
|
+
completedCount: items.filter((item) => item.status === 'completed').length,
|
|
53
|
+
totalCount: items.length,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function clearTodos() {
|
|
57
|
+
items = [];
|
|
58
|
+
}
|
|
59
|
+
export function replaceTodos(raw) {
|
|
60
|
+
if (!Array.isArray(raw)) {
|
|
61
|
+
return { ok: false, error: 'todos must be an array of { text, status } items.' };
|
|
62
|
+
}
|
|
63
|
+
const normalizations = [];
|
|
64
|
+
if (raw.length > MAX_TODO_ITEMS) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: `todos supports at most ${MAX_TODO_ITEMS} items; got ${raw.length}. Use fewer, broader steps.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const next = [];
|
|
71
|
+
let sawInProgress = false;
|
|
72
|
+
for (const [index, entry] of raw.entries()) {
|
|
73
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
74
|
+
return { ok: false, error: `todos[${index}] must be an object with text and status.` };
|
|
75
|
+
}
|
|
76
|
+
const text = String(entry.text ?? '')
|
|
77
|
+
.replace(/\s+/g, ' ')
|
|
78
|
+
.trim();
|
|
79
|
+
if (!text) {
|
|
80
|
+
return { ok: false, error: `todos[${index}].text must be a non-empty string.` };
|
|
81
|
+
}
|
|
82
|
+
const status = normalizeStatus(entry.status);
|
|
83
|
+
if (!status) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: `todos[${index}].status must be one of: pending, in_progress, completed.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let boundedText = text;
|
|
90
|
+
if (boundedText.length > MAX_TODO_TEXT_CHARS) {
|
|
91
|
+
boundedText = `${boundedText.slice(0, MAX_TODO_TEXT_CHARS - 1)}…`;
|
|
92
|
+
normalizations.push(`todos[${index}].text truncated to ${MAX_TODO_TEXT_CHARS} chars`);
|
|
93
|
+
}
|
|
94
|
+
let finalStatus = status;
|
|
95
|
+
if (status === 'in_progress') {
|
|
96
|
+
if (sawInProgress) {
|
|
97
|
+
finalStatus = 'pending';
|
|
98
|
+
normalizations.push(`todos[${index}] demoted to pending: only one item can be in_progress`);
|
|
99
|
+
}
|
|
100
|
+
sawInProgress = true;
|
|
101
|
+
}
|
|
102
|
+
next.push({ text: boundedText, status: finalStatus });
|
|
103
|
+
}
|
|
104
|
+
items = next;
|
|
105
|
+
return { ok: true, snapshot: getTodoSnapshot(), normalizations };
|
|
106
|
+
}
|