@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.
Files changed (101) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +30 -0
  3. package/dist/bin/ai.js +438 -0
  4. package/dist/parsers/tree-sitter-c-sharp.wasm +0 -0
  5. package/dist/parsers/tree-sitter-c.wasm +0 -0
  6. package/dist/parsers/tree-sitter-cpp.wasm +0 -0
  7. package/dist/parsers/tree-sitter-css.wasm +0 -0
  8. package/dist/parsers/tree-sitter-go.wasm +0 -0
  9. package/dist/parsers/tree-sitter-html.wasm +0 -0
  10. package/dist/parsers/tree-sitter-java.wasm +0 -0
  11. package/dist/parsers/tree-sitter-javascript.wasm +0 -0
  12. package/dist/parsers/tree-sitter-objc.wasm +0 -0
  13. package/dist/parsers/tree-sitter-php.wasm +0 -0
  14. package/dist/parsers/tree-sitter-python.wasm +0 -0
  15. package/dist/parsers/tree-sitter-ruby.wasm +0 -0
  16. package/dist/parsers/tree-sitter-rust.wasm +0 -0
  17. package/dist/parsers/tree-sitter-tsx.wasm +0 -0
  18. package/dist/parsers/tree-sitter-typescript.wasm +0 -0
  19. package/dist/src/agent-mode.js +142 -0
  20. package/dist/src/api/auth.js +81 -0
  21. package/dist/src/api/browser-login.js +184 -0
  22. package/dist/src/api/chat.js +346 -0
  23. package/dist/src/api/contracts.js +1 -0
  24. package/dist/src/api/http.js +44 -0
  25. package/dist/src/api/index.js +11 -0
  26. package/dist/src/api/models.js +110 -0
  27. package/dist/src/api/sessions.js +72 -0
  28. package/dist/src/artifact-policy.js +207 -0
  29. package/dist/src/client-state.js +14 -0
  30. package/dist/src/core/clipboard.js +208 -0
  31. package/dist/src/core/open-url.js +32 -0
  32. package/dist/src/edit-journal.js +133 -0
  33. package/dist/src/executor.js +924 -0
  34. package/dist/src/extractors/cpp.js +18 -0
  35. package/dist/src/extractors/csharp.js +16 -0
  36. package/dist/src/extractors/css.js +12 -0
  37. package/dist/src/extractors/go.js +27 -0
  38. package/dist/src/extractors/index.js +52 -0
  39. package/dist/src/extractors/java.js +14 -0
  40. package/dist/src/extractors/javascript.js +33 -0
  41. package/dist/src/extractors/objc.js +14 -0
  42. package/dist/src/extractors/php.js +20 -0
  43. package/dist/src/extractors/python.js +11 -0
  44. package/dist/src/extractors/ruby.js +13 -0
  45. package/dist/src/extractors/rust.js +17 -0
  46. package/dist/src/extractors/utils.js +58 -0
  47. package/dist/src/help-text.js +125 -0
  48. package/dist/src/markdown-renderer.js +112 -0
  49. package/dist/src/patcher.js +279 -0
  50. package/dist/src/project-index.js +221 -0
  51. package/dist/src/repo-map-languages.js +100 -0
  52. package/dist/src/runtime-mode.js +35 -0
  53. package/dist/src/scanner.js +362 -0
  54. package/dist/src/secret-preview.js +137 -0
  55. package/dist/src/session-exit.js +17 -0
  56. package/dist/src/session-safety.js +1012 -0
  57. package/dist/src/session-store.js +266 -0
  58. package/dist/src/session.js +93 -0
  59. package/dist/src/tool-executor.js +188 -0
  60. package/dist/src/tools/code-intel.js +472 -0
  61. package/dist/src/tools/delete-file.js +27 -0
  62. package/dist/src/tools/exec-utils.js +17 -0
  63. package/dist/src/tools/find-symbol.js +70 -0
  64. package/dist/src/tools/get-diagnostics.js +22 -0
  65. package/dist/src/tools/grep-code.js +331 -0
  66. package/dist/src/tools/hover-symbol.js +95 -0
  67. package/dist/src/tools/index.js +73 -0
  68. package/dist/src/tools/list-checkpoints.js +11 -0
  69. package/dist/src/tools/list-directories.js +16 -0
  70. package/dist/src/tools/list-files.js +13 -0
  71. package/dist/src/tools/list-session-edits.js +9 -0
  72. package/dist/src/tools/list-symbols.js +55 -0
  73. package/dist/src/tools/patch-file.js +88 -0
  74. package/dist/src/tools/path-listing.js +83 -0
  75. package/dist/src/tools/read-document.js +111 -0
  76. package/dist/src/tools/read-file.js +109 -0
  77. package/dist/src/tools/restore-checkpoint.js +100 -0
  78. package/dist/src/tools/ripgrep.js +29 -0
  79. package/dist/src/tools/run-command.js +94 -0
  80. package/dist/src/tools/run-node-script.js +210 -0
  81. package/dist/src/tools/search-code.js +37 -0
  82. package/dist/src/tools/shell-diagnostics.js +707 -0
  83. package/dist/src/tools/signature-help.js +118 -0
  84. package/dist/src/tools/str-replace.js +193 -0
  85. package/dist/src/tools/types.js +1 -0
  86. package/dist/src/tools/undo-edit.js +202 -0
  87. package/dist/src/tools/write-file.js +59 -0
  88. package/dist/src/tree-sitter-runtime.js +135 -0
  89. package/dist/src/types.js +1 -0
  90. package/dist/src/ui/paste-collapse.js +22 -0
  91. package/dist/src/ui/prompt-history-store.js +96 -0
  92. package/dist/src/ui/repl.js +2238 -0
  93. package/dist/src/ui/tui/bridge.js +175 -0
  94. package/dist/src/ui/tui/build-frame.js +718 -0
  95. package/dist/src/ui/tui/markdown-render.js +455 -0
  96. package/dist/src/ui/tui/shell-input.js +488 -0
  97. package/dist/src/ui/tui/text.js +30 -0
  98. package/dist/src/ui/tui/types.js +1 -0
  99. package/dist/src/usage.js +47 -0
  100. package/dist/src/utils.js +38 -0
  101. package/package.json +38 -0
@@ -0,0 +1,1012 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { lstatSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ARTIFACT_IGNORE_DIRS, normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
5
+ import { hashContent, readFileEditSnapshot } from './edit-journal.js';
6
+ import { deleteProjectFile, resolveProjectPath, writeProjectFile } from './patcher.js';
7
+ import { removeIndexFile, upsertIndexFile } from './project-index.js';
8
+ const MAX_CHECKPOINTS = 20;
9
+ const MAX_SESSION_EDITS = 500;
10
+ const MAX_READ_RECORDS = 200;
11
+ const MAX_REDACTION_TOKENS = 500;
12
+ const MAX_SNAPSHOT_CONTENT_CHARS = 1_000_000;
13
+ const MAX_MUTATION_SCAN_FILES = 2000;
14
+ const MAX_BASELINE_CONTENT_BYTES = 50 * 1024 * 1024;
15
+ export function createSessionSafetyState() {
16
+ return {
17
+ checkpoints: [],
18
+ checkpointCounter: 0,
19
+ readCoverage: [],
20
+ redactionTokens: [],
21
+ redactionCounter: 0,
22
+ sessionEdits: [],
23
+ sessionEditCounter: 0,
24
+ editFailures: [],
25
+ lastVerification: null,
26
+ };
27
+ }
28
+ function cloneJson(value) {
29
+ return JSON.parse(JSON.stringify(value ?? null));
30
+ }
31
+ function normalizeEditOperation(value) {
32
+ const text = String(value ?? '');
33
+ return text === 'create' || text === 'update' || text === 'delete'
34
+ ? text
35
+ : null;
36
+ }
37
+ export function normalizeSessionSafetyState(value) {
38
+ const raw = value && typeof value === 'object' ? value : {};
39
+ const safety = createSessionSafetyState();
40
+ safety.checkpointCounter = Math.max(0, Number.parseInt(String(raw.checkpointCounter ?? 0), 10) || 0);
41
+ safety.redactionCounter = Math.max(0, Number.parseInt(String(raw.redactionCounter ?? 0), 10) || 0);
42
+ safety.sessionEditCounter = Math.max(0, Number.parseInt(String(raw.sessionEditCounter ?? 0), 10) || 0);
43
+ if (Array.isArray(raw.checkpoints)) {
44
+ safety.checkpoints = raw.checkpoints
45
+ .map((item) => {
46
+ const id = String(item?.id ?? '').trim();
47
+ if (!id)
48
+ return null;
49
+ const files = Array.isArray(item.files)
50
+ ? item.files
51
+ .map((file) => {
52
+ const filePath = String(file?.filePath ?? '').trim();
53
+ if (!filePath)
54
+ return null;
55
+ return {
56
+ filePath,
57
+ exists: file.exists === true,
58
+ hash: typeof file.hash === 'string' ? file.hash : null,
59
+ content: typeof file.content === 'string' ? file.content : null,
60
+ skipped: typeof file.skipped === 'string' && file.skipped.trim()
61
+ ? file.skipped.trim()
62
+ : undefined,
63
+ };
64
+ })
65
+ .filter(Boolean)
66
+ : [];
67
+ return {
68
+ id,
69
+ sequence: Math.max(0, Number.parseInt(String(item.sequence ?? 0), 10) || 0),
70
+ label: String(item.label ?? ''),
71
+ turnId: typeof item.turnId === 'string' && item.turnId ? item.turnId : null,
72
+ createdAt: typeof item.createdAt === 'string' && item.createdAt
73
+ ? item.createdAt
74
+ : new Date().toISOString(),
75
+ files,
76
+ };
77
+ })
78
+ .filter(Boolean)
79
+ .slice(-MAX_CHECKPOINTS);
80
+ }
81
+ if (Array.isArray(raw.readCoverage)) {
82
+ safety.readCoverage = raw.readCoverage
83
+ .map((item) => {
84
+ const filePath = String(item?.filePath ?? '').trim();
85
+ if (!filePath)
86
+ return null;
87
+ return {
88
+ filePath,
89
+ hash: typeof item.hash === 'string' ? item.hash : null,
90
+ fullFile: item.fullFile === true,
91
+ startLine: Math.max(1, Number.parseInt(String(item.startLine ?? 1), 10) || 1),
92
+ endLine: Math.max(1, Number.parseInt(String(item.endLine ?? 1), 10) || 1),
93
+ totalLines: Math.max(1, Number.parseInt(String(item.totalLines ?? 1), 10) || 1),
94
+ createdAt: typeof item.createdAt === 'string' && item.createdAt
95
+ ? item.createdAt
96
+ : new Date().toISOString(),
97
+ };
98
+ })
99
+ .filter(Boolean)
100
+ .slice(-MAX_READ_RECORDS);
101
+ }
102
+ if (Array.isArray(raw.redactionTokens)) {
103
+ safety.redactionTokens = raw.redactionTokens
104
+ .map((item) => {
105
+ const token = String(item?.token ?? '').trim();
106
+ const filePath = String(item?.filePath ?? '').trim();
107
+ if (!token || !filePath || typeof item.value !== 'string')
108
+ return null;
109
+ return {
110
+ token,
111
+ value: item.value,
112
+ filePath,
113
+ hash: typeof item.hash === 'string' ? item.hash : null,
114
+ createdAt: typeof item.createdAt === 'string' && item.createdAt
115
+ ? item.createdAt
116
+ : new Date().toISOString(),
117
+ };
118
+ })
119
+ .filter(Boolean)
120
+ .slice(-MAX_REDACTION_TOKENS);
121
+ }
122
+ if (Array.isArray(raw.sessionEdits)) {
123
+ safety.sessionEdits = raw.sessionEdits
124
+ .map((item) => {
125
+ const id = String(item?.id ?? '').trim();
126
+ const operation = normalizeEditOperation(item?.operation);
127
+ const filePath = String(item?.filePath ?? '').trim();
128
+ if (!id || !operation || !filePath)
129
+ return null;
130
+ const kind = String(item.kind ?? '');
131
+ return {
132
+ id,
133
+ kind: kind === 'command_mutation' || kind === 'restore'
134
+ ? kind
135
+ : 'tool_edit',
136
+ turnId: typeof item.turnId === 'string' && item.turnId ? item.turnId : null,
137
+ toolCallId: typeof item.toolCallId === 'string' && item.toolCallId
138
+ ? item.toolCallId
139
+ : null,
140
+ toolName: String(item.toolName ?? ''),
141
+ filePath,
142
+ operation,
143
+ beforeHash: typeof item.beforeHash === 'string' ? item.beforeHash : null,
144
+ afterHash: typeof item.afterHash === 'string' ? item.afterHash : null,
145
+ beforeContent: typeof item.beforeContent === 'string' ? item.beforeContent : null,
146
+ createdAt: typeof item.createdAt === 'string' && item.createdAt
147
+ ? item.createdAt
148
+ : new Date().toISOString(),
149
+ checkpointId: typeof item.checkpointId === 'string' && item.checkpointId
150
+ ? item.checkpointId
151
+ : null,
152
+ revertedAt: typeof item.revertedAt === 'string' && item.revertedAt
153
+ ? item.revertedAt
154
+ : null,
155
+ };
156
+ })
157
+ .filter(Boolean)
158
+ .slice(-MAX_SESSION_EDITS);
159
+ }
160
+ if (Array.isArray(raw.editFailures)) {
161
+ safety.editFailures = raw.editFailures
162
+ .map((item) => {
163
+ const filePath = String(item?.filePath ?? '').trim();
164
+ if (!filePath)
165
+ return null;
166
+ return {
167
+ filePath,
168
+ toolName: String(item.toolName ?? ''),
169
+ count: Math.max(1, Number.parseInt(String(item.count ?? 1), 10) || 1),
170
+ lastError: String(item.lastError ?? '').slice(0, 1000),
171
+ updatedAt: typeof item.updatedAt === 'string' && item.updatedAt
172
+ ? item.updatedAt
173
+ : new Date().toISOString(),
174
+ };
175
+ })
176
+ .filter(Boolean);
177
+ }
178
+ const verification = raw.lastVerification;
179
+ if (verification && typeof verification === 'object') {
180
+ safety.lastVerification = {
181
+ ok: verification.ok === true,
182
+ toolName: String(verification.toolName ?? ''),
183
+ command: typeof verification.command === 'string' && verification.command
184
+ ? verification.command
185
+ : undefined,
186
+ filePath: typeof verification.filePath === 'string' && verification.filePath
187
+ ? verification.filePath
188
+ : undefined,
189
+ createdAt: typeof verification.createdAt === 'string' && verification.createdAt
190
+ ? verification.createdAt
191
+ : new Date().toISOString(),
192
+ editId: typeof verification.editId === 'string' && verification.editId
193
+ ? verification.editId
194
+ : null,
195
+ errorCount: typeof verification.errorCount === 'number'
196
+ ? verification.errorCount
197
+ : undefined,
198
+ };
199
+ }
200
+ return safety;
201
+ }
202
+ export function cloneSessionSafetyState(state) {
203
+ return normalizeSessionSafetyState(cloneJson(state));
204
+ }
205
+ export function sanitizeSessionSafetyForServer(state) {
206
+ const safety = cloneSessionSafetyState(state ?? createSessionSafetyState());
207
+ safety.checkpoints = safety.checkpoints.map((checkpoint) => ({
208
+ ...checkpoint,
209
+ files: checkpoint.files.map((file) => ({ ...file, content: null })),
210
+ }));
211
+ safety.redactionTokens = safety.redactionTokens.map((token) => ({
212
+ ...token,
213
+ value: '',
214
+ }));
215
+ safety.sessionEdits = safety.sessionEdits.map((edit) => ({
216
+ ...edit,
217
+ beforeContent: null,
218
+ }));
219
+ if (safety.lastVerification?.command) {
220
+ safety.lastVerification = {
221
+ ...safety.lastVerification,
222
+ command: undefined,
223
+ };
224
+ }
225
+ return safety;
226
+ }
227
+ export function mergeLocalSessionSafetyState(local, incoming) {
228
+ const localState = cloneSessionSafetyState(local ?? createSessionSafetyState());
229
+ const next = cloneSessionSafetyState(incoming ?? createSessionSafetyState());
230
+ const localRedactionValues = new Map(localState.redactionTokens.map((token) => [
231
+ `${token.token}\0${token.filePath}\0${token.hash ?? ''}`,
232
+ token.value,
233
+ ]));
234
+ next.redactionTokens = next.redactionTokens.map((token) => {
235
+ if (token.value)
236
+ return token;
237
+ const value = localRedactionValues.get(`${token.token}\0${token.filePath}\0${token.hash ?? ''}`);
238
+ return value == null ? token : { ...token, value };
239
+ });
240
+ const localCheckpointContent = new Map();
241
+ for (const checkpoint of localState.checkpoints) {
242
+ for (const file of checkpoint.files) {
243
+ if (file.content == null)
244
+ continue;
245
+ localCheckpointContent.set(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`, file.content);
246
+ }
247
+ }
248
+ next.checkpoints = next.checkpoints.map((checkpoint) => ({
249
+ ...checkpoint,
250
+ files: checkpoint.files.map((file) => {
251
+ if (file.content != null)
252
+ return file;
253
+ const content = localCheckpointContent.get(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`);
254
+ return content == null ? file : { ...file, content };
255
+ }),
256
+ }));
257
+ const localBeforeContent = new Map(localState.sessionEdits
258
+ .filter((edit) => edit.beforeContent != null)
259
+ .map((edit) => [edit.id, edit.beforeContent]));
260
+ next.sessionEdits = next.sessionEdits.map((edit) => {
261
+ if (edit.beforeContent != null)
262
+ return edit;
263
+ const beforeContent = localBeforeContent.get(edit.id);
264
+ return beforeContent == null ? edit : { ...edit, beforeContent };
265
+ });
266
+ return next;
267
+ }
268
+ function normalizeFilePath(rootDir, filePath) {
269
+ const relPath = normalizeProjectRelativePath(rootDir, filePath);
270
+ if (!relPath || shouldIgnoreArtifactPath(relPath))
271
+ return null;
272
+ return relPath.split(path.sep).join('/');
273
+ }
274
+ function readCheckpointSnapshot(rootDir, filePath) {
275
+ const normalized = normalizeFilePath(rootDir, filePath);
276
+ if (!normalized) {
277
+ return {
278
+ filePath,
279
+ exists: false,
280
+ hash: null,
281
+ content: null,
282
+ skipped: `Refusing to checkpoint ignored or out-of-project path: ${filePath}`,
283
+ };
284
+ }
285
+ const snapshot = readFileEditSnapshot(rootDir, normalized);
286
+ if (snapshot.error) {
287
+ return {
288
+ filePath: normalized,
289
+ exists: snapshot.exists,
290
+ hash: snapshot.hash,
291
+ content: null,
292
+ skipped: snapshot.error,
293
+ };
294
+ }
295
+ if (snapshot.content && snapshot.content.length > MAX_SNAPSHOT_CONTENT_CHARS) {
296
+ return {
297
+ filePath: normalized,
298
+ exists: snapshot.exists,
299
+ hash: snapshot.hash,
300
+ content: null,
301
+ skipped: `File is too large to checkpoint (${snapshot.content.length} chars).`,
302
+ };
303
+ }
304
+ return {
305
+ filePath: normalized,
306
+ exists: snapshot.exists,
307
+ hash: snapshot.hash,
308
+ content: snapshot.content,
309
+ };
310
+ }
311
+ export function createPromptCheckpoint(state, label, turnId) {
312
+ const checkpoint = {
313
+ id: `checkpoint_${++state.checkpointCounter}`,
314
+ sequence: state.checkpointCounter,
315
+ label,
316
+ turnId,
317
+ createdAt: new Date().toISOString(),
318
+ files: [],
319
+ };
320
+ state.checkpoints.push(checkpoint);
321
+ if (state.checkpoints.length > MAX_CHECKPOINTS) {
322
+ state.checkpoints.splice(0, state.checkpoints.length - MAX_CHECKPOINTS);
323
+ }
324
+ return checkpoint;
325
+ }
326
+ export function ensureActiveCheckpoint(state, turnId) {
327
+ return (state.checkpoints[state.checkpoints.length - 1] ??
328
+ createPromptCheckpoint(state, 'session start', turnId));
329
+ }
330
+ export function rememberCheckpointFiles(state, rootDir, filePaths, turnId) {
331
+ const checkpoint = ensureActiveCheckpoint(state, turnId);
332
+ const existing = new Set(checkpoint.files.map((file) => file.filePath));
333
+ for (const rawPath of filePaths) {
334
+ const normalized = normalizeFilePath(rootDir, rawPath);
335
+ if (!normalized || existing.has(normalized))
336
+ continue;
337
+ checkpoint.files.push(readCheckpointSnapshot(rootDir, normalized));
338
+ existing.add(normalized);
339
+ }
340
+ return checkpoint;
341
+ }
342
+ export function recordReadCoverage(state, record) {
343
+ state.readCoverage.push(record);
344
+ if (state.readCoverage.length > MAX_READ_RECORDS) {
345
+ state.readCoverage.splice(0, state.readCoverage.length - MAX_READ_RECORDS);
346
+ }
347
+ }
348
+ export function hasFreshFullReadCoverage(state, filePath, hash) {
349
+ const records = state.readCoverage.filter((record) => record.filePath === filePath && record.hash === hash);
350
+ if (records.some((record) => record.fullFile))
351
+ return true;
352
+ const totalLines = records[records.length - 1]?.totalLines ?? 0;
353
+ if (totalLines <= 0)
354
+ return false;
355
+ const intervals = records
356
+ .map((record) => ({
357
+ start: Math.max(1, record.startLine),
358
+ end: Math.max(record.startLine, record.endLine),
359
+ }))
360
+ .sort((a, b) => a.start - b.start);
361
+ let coveredEnd = 0;
362
+ for (const interval of intervals) {
363
+ if (interval.start > coveredEnd + 1)
364
+ return false;
365
+ coveredEnd = Math.max(coveredEnd, interval.end);
366
+ if (coveredEnd >= totalLines)
367
+ return true;
368
+ }
369
+ return false;
370
+ }
371
+ const SENSITIVE_VALUE_PATTERN = /\b([A-Z0-9_]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS(?:[_-]|$)|CREDENTIAL(?:[_-]|$)|PRIVATE[_-]?KEY)[A-Z0-9_]*)\b\s*[:=]\s*(['"]?)([^\s'",;]+)/gi;
372
+ const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
373
+ function createRedactionToken(state, value, filePath, hash) {
374
+ const existing = state.redactionTokens.find((entry) => entry.value === value && entry.filePath === filePath && entry.hash === hash);
375
+ if (existing)
376
+ return existing.token;
377
+ const token = `[REDACTED:${++state.redactionCounter}]`;
378
+ state.redactionTokens.push({
379
+ token,
380
+ value,
381
+ filePath,
382
+ hash,
383
+ createdAt: new Date().toISOString(),
384
+ });
385
+ if (state.redactionTokens.length > MAX_REDACTION_TOKENS) {
386
+ state.redactionTokens.splice(0, state.redactionTokens.length - MAX_REDACTION_TOKENS);
387
+ }
388
+ return token;
389
+ }
390
+ export function redactContentWithStableTokens(state, content, filePath, hash) {
391
+ const tokens = [];
392
+ let next = content.replace(PEM_BLOCK_PATTERN, (value) => {
393
+ const token = createRedactionToken(state, value, filePath, hash);
394
+ tokens.push(token);
395
+ return token;
396
+ });
397
+ next = next.replace(SENSITIVE_VALUE_PATTERN, (_match, key, quote, value) => {
398
+ if (value.startsWith('[REDACTED:')) {
399
+ return `${key}=${quote}${value}${quote}`;
400
+ }
401
+ const token = createRedactionToken(state, value, filePath, hash);
402
+ tokens.push(token);
403
+ return `${key}=${quote}${token}${quote}`;
404
+ });
405
+ return { content: next, tokens };
406
+ }
407
+ export function resolveRedactionTokens(state, text, filePath, hash) {
408
+ if (!state || !text.includes('[REDACTED:'))
409
+ return text;
410
+ let next = text;
411
+ for (const entry of state.redactionTokens) {
412
+ if (entry.filePath !== filePath)
413
+ continue;
414
+ if (entry.hash !== hash)
415
+ continue;
416
+ next = next.split(entry.token).join(entry.value);
417
+ }
418
+ return next;
419
+ }
420
+ const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
421
+ 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
+ export function redactDotenvWithStableTokens(state, content, filePath, hash) {
434
+ const tokens = [];
435
+ const redactedLines = content.split('\n').map((line) => {
436
+ const assignment = DOTENV_ASSIGNMENT_PATTERN.exec(line);
437
+ if (assignment) {
438
+ const [, keyPart, value] = assignment;
439
+ if (value.trim() === '' || value.includes('[REDACTED:'))
440
+ return line;
441
+ const token = createRedactionToken(state, value, filePath, hash);
442
+ tokens.push(token);
443
+ return `${keyPart}${token}`;
444
+ }
445
+ const comment = DOTENV_COMMENT_PATTERN.exec(line);
446
+ if (comment) {
447
+ const [, prefix, body] = comment;
448
+ if (body.includes('[REDACTED:'))
449
+ return line;
450
+ const token = createRedactionToken(state, body, filePath, hash);
451
+ tokens.push(token);
452
+ return `${prefix}${token}`;
453
+ }
454
+ return line;
455
+ });
456
+ return { content: redactedLines.join('\n'), tokens };
457
+ }
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
+ export function dotenvFitsRedactionBudget(content) {
467
+ let count = 0;
468
+ for (const line of content.split('\n')) {
469
+ const assignment = DOTENV_ASSIGNMENT_PATTERN.exec(line);
470
+ if (assignment) {
471
+ if (assignment[2].trim() !== '' && !assignment[2].includes('[REDACTED:')) {
472
+ count += 1;
473
+ }
474
+ }
475
+ else if (DOTENV_COMMENT_PATTERN.test(line)) {
476
+ count += 1;
477
+ }
478
+ if (count > MAX_REDACTION_TOKENS)
479
+ return false;
480
+ }
481
+ return true;
482
+ }
483
+ export function recordSessionEdit(state, edit) {
484
+ const record = {
485
+ ...edit,
486
+ id: `session_edit_${++state.sessionEditCounter}`,
487
+ createdAt: edit.createdAt ?? new Date().toISOString(),
488
+ revertedAt: edit.revertedAt ?? null,
489
+ };
490
+ state.sessionEdits.push(record);
491
+ if (state.sessionEdits.length > MAX_SESSION_EDITS) {
492
+ state.sessionEdits.splice(0, state.sessionEdits.length - MAX_SESSION_EDITS);
493
+ }
494
+ return record;
495
+ }
496
+ export function recordEditFailure(state, filePath, toolName, error) {
497
+ if (!state || !filePath)
498
+ return null;
499
+ const now = new Date().toISOString();
500
+ let record = state.editFailures.find((item) => item.filePath === filePath);
501
+ if (!record) {
502
+ record = { filePath, toolName, count: 0, lastError: '', updatedAt: now };
503
+ state.editFailures.push(record);
504
+ }
505
+ record.toolName = toolName;
506
+ record.count += 1;
507
+ record.lastError = error.slice(0, 1000);
508
+ record.updatedAt = now;
509
+ return record;
510
+ }
511
+ export function clearEditFailure(state, filePath) {
512
+ if (!state || !filePath)
513
+ return;
514
+ state.editFailures = state.editFailures.filter((item) => item.filePath !== filePath);
515
+ }
516
+ export function recordVerification(state, verification) {
517
+ if (!state)
518
+ return;
519
+ state.lastVerification = verification;
520
+ }
521
+ export function listCheckpointSummaries(state) {
522
+ return state.checkpoints.map((checkpoint) => ({
523
+ id: checkpoint.id,
524
+ sequence: checkpoint.sequence,
525
+ label: checkpoint.label,
526
+ turnId: checkpoint.turnId,
527
+ createdAt: checkpoint.createdAt,
528
+ fileCount: checkpoint.files.length,
529
+ files: checkpoint.files.map((file) => ({
530
+ filePath: file.filePath,
531
+ exists: file.exists,
532
+ hash: file.hash,
533
+ restorable: !file.skipped && (file.content !== null || !file.exists),
534
+ skipped: file.skipped,
535
+ })),
536
+ }));
537
+ }
538
+ export function listSessionEditSummaries(state) {
539
+ return state.sessionEdits.map((edit) => ({
540
+ id: edit.id,
541
+ kind: edit.kind,
542
+ turnId: edit.turnId,
543
+ toolName: edit.toolName,
544
+ filePath: edit.filePath,
545
+ operation: edit.operation,
546
+ beforeHash: edit.beforeHash,
547
+ afterHash: edit.afterHash,
548
+ createdAt: edit.createdAt,
549
+ checkpointId: edit.checkpointId,
550
+ revertedAt: edit.revertedAt,
551
+ }));
552
+ }
553
+ function latestTrackedHashAfterCheckpoint(state, filePath, checkpoint) {
554
+ for (let i = state.sessionEdits.length - 1; i >= 0; i--) {
555
+ const edit = state.sessionEdits[i];
556
+ if (edit.filePath !== filePath || edit.revertedAt)
557
+ continue;
558
+ if (edit.createdAt < checkpoint.createdAt)
559
+ break;
560
+ return edit.afterHash;
561
+ }
562
+ return undefined;
563
+ }
564
+ function validateRestoreTarget(state, rootDir, checkpoint, snapshot) {
565
+ if (snapshot.skipped) {
566
+ return {
567
+ ok: false,
568
+ error: `Cannot restore ${snapshot.filePath}: ${snapshot.skipped}`,
569
+ currentHash: null,
570
+ };
571
+ }
572
+ if (snapshot.exists && snapshot.content === null) {
573
+ return {
574
+ ok: false,
575
+ error: `Cannot restore ${snapshot.filePath}: checkpoint has no stored file content.`,
576
+ currentHash: null,
577
+ };
578
+ }
579
+ const current = readFileEditSnapshot(rootDir, snapshot.filePath);
580
+ if (current.error) {
581
+ return {
582
+ ok: false,
583
+ error: `Cannot inspect ${snapshot.filePath} before restore: ${current.error}`,
584
+ currentHash: current.hash,
585
+ };
586
+ }
587
+ const latestTrackedHash = latestTrackedHashAfterCheckpoint(state, snapshot.filePath, checkpoint);
588
+ if (current.hash !== snapshot.hash &&
589
+ latestTrackedHash === undefined) {
590
+ return {
591
+ ok: false,
592
+ error: `Cannot restore ${snapshot.filePath}: current hash differs from ${checkpoint.id} and no assistant or command edit explains the change.`,
593
+ currentHash: current.hash,
594
+ };
595
+ }
596
+ if (current.hash !== snapshot.hash &&
597
+ latestTrackedHash !== undefined &&
598
+ current.hash !== latestTrackedHash) {
599
+ return {
600
+ ok: false,
601
+ error: `Cannot restore ${snapshot.filePath}: current hash is not a known assistant or command state after ${checkpoint.id}.`,
602
+ currentHash: current.hash,
603
+ };
604
+ }
605
+ return { ok: true };
606
+ }
607
+ export async function restoreCheckpointFiles(args) {
608
+ const checkpoint = args.state.checkpoints.find((item) => item.id === args.checkpointId);
609
+ if (!checkpoint) {
610
+ return {
611
+ ok: false,
612
+ checkpointId: args.checkpointId,
613
+ changed: false,
614
+ restored: [],
615
+ error: `Checkpoint not found: ${args.checkpointId}`,
616
+ };
617
+ }
618
+ const requested = new Set((args.filePaths ?? [])
619
+ .map((filePath) => normalizeFilePath(args.rootDir, filePath))
620
+ .filter(Boolean));
621
+ const targets = requested.size
622
+ ? checkpoint.files.filter((file) => requested.has(file.filePath))
623
+ : checkpoint.files;
624
+ const missing = [...requested].filter((filePath) => !checkpoint.files.some((file) => file.filePath === filePath));
625
+ if (missing.length) {
626
+ return {
627
+ ok: false,
628
+ checkpointId: checkpoint.id,
629
+ changed: false,
630
+ restored: [],
631
+ error: `Checkpoint ${checkpoint.id} has no snapshot for: ${missing.join(', ')}`,
632
+ };
633
+ }
634
+ const failures = [];
635
+ for (const snapshot of targets) {
636
+ const validation = validateRestoreTarget(args.state, args.rootDir, checkpoint, snapshot);
637
+ if (!validation.ok) {
638
+ failures.push({
639
+ filePath: snapshot.filePath,
640
+ error: validation.error,
641
+ expectedHash: snapshot.hash,
642
+ currentHash: validation.currentHash,
643
+ });
644
+ }
645
+ }
646
+ if (failures.length) {
647
+ return {
648
+ ok: false,
649
+ checkpointId: checkpoint.id,
650
+ changed: false,
651
+ restored: [],
652
+ error: 'Checkpoint restore refused because one or more files are unsafe to restore.',
653
+ failures,
654
+ };
655
+ }
656
+ const restored = [];
657
+ const applied = [];
658
+ let changed = false;
659
+ const syncPolicy = args.projectIndex.initialized;
660
+ for (const snapshot of targets) {
661
+ const before = readFileEditSnapshot(args.rootDir, snapshot.filePath);
662
+ try {
663
+ if (snapshot.exists) {
664
+ const result = writeProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '');
665
+ if (result.changed)
666
+ changed = true;
667
+ if (syncPolicy)
668
+ await upsertIndexFile(args.projectIndex, snapshot.filePath);
669
+ }
670
+ else {
671
+ const result = deleteProjectFile(args.rootDir, snapshot.filePath);
672
+ if (result.deleted)
673
+ changed = true;
674
+ if (syncPolicy)
675
+ await removeIndexFile(args.projectIndex, snapshot.filePath);
676
+ }
677
+ const after = readFileEditSnapshot(args.rootDir, snapshot.filePath);
678
+ applied.push({ snapshot, before, after });
679
+ }
680
+ catch (err) {
681
+ const rolledBack = [];
682
+ const rollbackFailures = [];
683
+ const rollbackTargets = [
684
+ { snapshot, before },
685
+ ...applied.map((item) => ({
686
+ snapshot: item.snapshot,
687
+ before: item.before,
688
+ })).reverse(),
689
+ ];
690
+ for (const item of rollbackTargets) {
691
+ try {
692
+ if (item.before.exists) {
693
+ if (item.before.content == null) {
694
+ throw new Error('previous file content is unavailable');
695
+ }
696
+ writeProjectFile(args.rootDir, item.snapshot.filePath, item.before.content);
697
+ if (syncPolicy) {
698
+ await upsertIndexFile(args.projectIndex, item.snapshot.filePath);
699
+ }
700
+ }
701
+ else {
702
+ deleteProjectFile(args.rootDir, item.snapshot.filePath);
703
+ if (syncPolicy) {
704
+ await removeIndexFile(args.projectIndex, item.snapshot.filePath);
705
+ }
706
+ }
707
+ rolledBack.push({ filePath: item.snapshot.filePath });
708
+ }
709
+ catch (rollbackErr) {
710
+ rollbackFailures.push({
711
+ filePath: item.snapshot.filePath,
712
+ error: rollbackErr?.message ? String(rollbackErr.message) : String(rollbackErr),
713
+ });
714
+ }
715
+ }
716
+ return {
717
+ ok: false,
718
+ checkpointId: checkpoint.id,
719
+ changed: applied.length > 0,
720
+ restored: applied.map((item) => ({
721
+ filePath: item.snapshot.filePath,
722
+ restoredHash: item.after.hash,
723
+ checkpointHash: item.snapshot.hash,
724
+ })),
725
+ error: `Checkpoint restore failed while restoring ${snapshot.filePath}.`,
726
+ failures: [
727
+ {
728
+ filePath: snapshot.filePath,
729
+ error: err?.message ? String(err.message) : String(err),
730
+ },
731
+ ],
732
+ rolledBack,
733
+ rollbackFailures,
734
+ };
735
+ }
736
+ }
737
+ for (const item of applied) {
738
+ recordSessionEdit(args.state, {
739
+ kind: 'restore',
740
+ turnId: args.currentTurnId,
741
+ toolCallId: args.currentToolCallId,
742
+ toolName: 'restore_to_checkpoint',
743
+ filePath: item.snapshot.filePath,
744
+ operation: item.snapshot.exists
745
+ ? item.before.exists
746
+ ? 'update'
747
+ : 'create'
748
+ : 'delete',
749
+ beforeHash: item.before.hash,
750
+ afterHash: item.after.hash,
751
+ beforeContent: item.before.content,
752
+ checkpointId: checkpoint.id,
753
+ });
754
+ restored.push({
755
+ filePath: item.snapshot.filePath,
756
+ restoredHash: item.after.hash,
757
+ checkpointHash: item.snapshot.hash,
758
+ });
759
+ }
760
+ return {
761
+ ok: true,
762
+ checkpointId: checkpoint.id,
763
+ changed,
764
+ restored,
765
+ };
766
+ }
767
+ function git(args, cwd) {
768
+ try {
769
+ return execFileSync('git', args, {
770
+ cwd,
771
+ encoding: 'utf-8',
772
+ stdio: ['ignore', 'pipe', 'ignore'],
773
+ timeout: 10_000,
774
+ }).trimEnd();
775
+ }
776
+ catch {
777
+ return null;
778
+ }
779
+ }
780
+ export function findGitRoot(startDir) {
781
+ const root = git(['rev-parse', '--show-toplevel'], startDir);
782
+ return root ? path.resolve(root) : null;
783
+ }
784
+ export function findNestedGitRoots(rootDir, limit = 12) {
785
+ const roots = [];
786
+ const root = path.resolve(rootDir);
787
+ const visit = (dir, depth) => {
788
+ if (roots.length >= limit || depth > 4)
789
+ return;
790
+ let entries;
791
+ try {
792
+ entries = readdirSync(dir);
793
+ }
794
+ catch {
795
+ return;
796
+ }
797
+ if (entries.includes('.git')) {
798
+ roots.push(path.relative(root, dir) || '.');
799
+ return;
800
+ }
801
+ for (const entry of entries) {
802
+ if (ARTIFACT_IGNORE_DIRS.has(entry))
803
+ continue;
804
+ const abs = path.join(dir, entry);
805
+ try {
806
+ if (lstatSync(abs).isDirectory())
807
+ visit(abs, depth + 1);
808
+ }
809
+ catch { }
810
+ }
811
+ };
812
+ visit(root, 0);
813
+ return roots;
814
+ }
815
+ export function buildNestedGitHint(rootDir, command) {
816
+ if (!/\bgit\b/.test(command))
817
+ return null;
818
+ if (findGitRoot(rootDir))
819
+ return null;
820
+ const nestedRoots = findNestedGitRoots(rootDir);
821
+ if (!nestedRoots.length)
822
+ return null;
823
+ return {
824
+ currentCwdIsGitWorkTree: false,
825
+ nestedGitRoots: nestedRoots,
826
+ message: `This cwd is not a git worktree, but nested git repos exist: ${nestedRoots.join(', ')}. ` +
827
+ 'Run git commands from the intended nested repo cwd.',
828
+ };
829
+ }
830
+ function gitStatusPaths(rootDir) {
831
+ const output = git(['status', '--porcelain=v1', '--untracked-files=all'], rootDir);
832
+ if (output == null || !output.trim())
833
+ return [];
834
+ const paths = new Set();
835
+ for (const line of output.split('\n')) {
836
+ if (!line.trim())
837
+ continue;
838
+ const raw = line.slice(3).trim();
839
+ const filePath = raw.includes(' -> ') ? raw.split(' -> ').pop() : raw;
840
+ const normalized = normalizeFilePath(rootDir, filePath);
841
+ if (normalized)
842
+ paths.add(normalized);
843
+ }
844
+ return [...paths].sort();
845
+ }
846
+ function filesystemStatMap(rootDir) {
847
+ const root = path.resolve(rootDir);
848
+ const stats = new Map();
849
+ const visit = (dir) => {
850
+ if (stats.size >= MAX_MUTATION_SCAN_FILES)
851
+ return;
852
+ let entries;
853
+ try {
854
+ entries = readdirSync(dir);
855
+ }
856
+ catch {
857
+ return;
858
+ }
859
+ for (const entry of entries) {
860
+ if (stats.size >= MAX_MUTATION_SCAN_FILES)
861
+ return;
862
+ if (ARTIFACT_IGNORE_DIRS.has(entry))
863
+ continue;
864
+ const abs = path.join(dir, entry);
865
+ const rel = path.relative(root, abs).split(path.sep).join('/');
866
+ if (!rel || shouldIgnoreArtifactPath(rel))
867
+ continue;
868
+ try {
869
+ const stat = lstatSync(abs);
870
+ if (stat.isSymbolicLink())
871
+ continue;
872
+ if (stat.isDirectory()) {
873
+ visit(abs);
874
+ }
875
+ else if (stat.isFile()) {
876
+ stats.set(rel, {
877
+ size: stat.size,
878
+ mtimeMs: stat.mtimeMs,
879
+ ctimeMs: stat.ctimeMs,
880
+ });
881
+ }
882
+ }
883
+ catch { }
884
+ }
885
+ };
886
+ visit(root);
887
+ return stats;
888
+ }
889
+ function statsDiffer(a, b) {
890
+ return (a.size !== b.size ||
891
+ a.mtimeMs !== b.mtimeMs ||
892
+ a.ctimeMs !== b.ctimeMs);
893
+ }
894
+ function readGitHeadSnapshot(rootDir, filePath) {
895
+ const gitRoot = findGitRoot(rootDir);
896
+ if (!gitRoot)
897
+ return null;
898
+ const abs = resolveProjectPath(rootDir, filePath);
899
+ const relToGit = path.relative(gitRoot, abs).split(path.sep).join('/');
900
+ const content = git(['show', `HEAD:${relToGit}`], gitRoot);
901
+ if (content == null)
902
+ return null;
903
+ return {
904
+ filePath,
905
+ exists: true,
906
+ hash: hashContent(content),
907
+ content,
908
+ };
909
+ }
910
+ export function captureMutationBaseline(rootDir, options) {
911
+ const mode = findGitRoot(rootDir) ? 'git' : 'filesystem';
912
+ if (mode === 'git') {
913
+ const beforePaths = gitStatusPaths(rootDir);
914
+ const beforeSnapshots = new Map();
915
+ for (const filePath of beforePaths) {
916
+ beforeSnapshots.set(filePath, readCheckpointSnapshot(rootDir, filePath));
917
+ }
918
+ return { mode, beforePaths, beforeSnapshots };
919
+ }
920
+ const contentBudget = options?.contentBudgetBytes ?? MAX_BASELINE_CONTENT_BYTES;
921
+ const beforeStats = filesystemStatMap(rootDir);
922
+ const beforePaths = [...beforeStats.keys()].sort();
923
+ const beforeSnapshots = new Map();
924
+ let baselineBytesUsed = 0;
925
+ let baselineTruncated = false;
926
+ for (const filePath of beforePaths) {
927
+ const stat = beforeStats.get(filePath);
928
+ if (baselineBytesUsed + stat.size > contentBudget) {
929
+ baselineTruncated = true;
930
+ beforeSnapshots.set(filePath, {
931
+ filePath,
932
+ exists: true,
933
+ hash: null,
934
+ content: null,
935
+ skipped: `Pre-command snapshot skipped: project baseline content budget exceeded (${contentBudget} bytes). Restore for this file is not available.`,
936
+ });
937
+ continue;
938
+ }
939
+ const snapshot = readCheckpointSnapshot(rootDir, filePath);
940
+ beforeSnapshots.set(filePath, snapshot);
941
+ if (snapshot.content)
942
+ baselineBytesUsed += snapshot.content.length;
943
+ }
944
+ return { mode, beforePaths, beforeSnapshots, beforeStats, baselineTruncated };
945
+ }
946
+ export function collectCommandMutations(args) {
947
+ let candidatePaths;
948
+ if (args.tracker.mode === 'git') {
949
+ const afterPaths = gitStatusPaths(args.rootDir);
950
+ candidatePaths = new Set([...args.tracker.beforePaths, ...afterPaths]);
951
+ }
952
+ else {
953
+ const afterStats = filesystemStatMap(args.rootDir);
954
+ candidatePaths = new Set();
955
+ const beforeStats = args.tracker.beforeStats ?? new Map();
956
+ for (const [filePath, stat] of afterStats) {
957
+ const beforeStat = beforeStats.get(filePath);
958
+ if (!beforeStat || statsDiffer(beforeStat, stat)) {
959
+ candidatePaths.add(filePath);
960
+ }
961
+ }
962
+ for (const filePath of beforeStats.keys()) {
963
+ if (!afterStats.has(filePath))
964
+ candidatePaths.add(filePath);
965
+ }
966
+ }
967
+ const records = [];
968
+ const checkpoint = args.checkpointId
969
+ ? args.state.checkpoints.find((item) => item.id === args.checkpointId)
970
+ : null;
971
+ const checkpointFiles = new Set(checkpoint?.files.map((snapshot) => snapshot.filePath) ?? []);
972
+ for (const filePath of candidatePaths) {
973
+ const after = readCheckpointSnapshot(args.rootDir, filePath);
974
+ const before = args.tracker.beforeSnapshots.get(filePath) ??
975
+ readGitHeadSnapshot(args.rootDir, filePath) ??
976
+ {
977
+ filePath,
978
+ exists: false,
979
+ hash: null,
980
+ content: null,
981
+ };
982
+ if (before.hash === after.hash && !before.skipped)
983
+ continue;
984
+ if (checkpoint && !checkpointFiles.has(filePath)) {
985
+ checkpoint.files.push(before);
986
+ checkpointFiles.add(filePath);
987
+ }
988
+ const operation = !before.exists
989
+ ? 'create'
990
+ : !after.exists
991
+ ? 'delete'
992
+ : 'update';
993
+ const record = recordSessionEdit(args.state, {
994
+ kind: 'command_mutation',
995
+ turnId: args.turnId,
996
+ toolCallId: args.toolCallId,
997
+ toolName: args.toolName,
998
+ filePath,
999
+ operation,
1000
+ beforeHash: before.hash,
1001
+ afterHash: after.hash,
1002
+ beforeContent: before.content,
1003
+ checkpointId: args.checkpointId,
1004
+ });
1005
+ records.push(record);
1006
+ }
1007
+ return records;
1008
+ }
1009
+ export function getCurrentFileHash(rootDir, filePath) {
1010
+ const snapshot = readFileEditSnapshot(rootDir, filePath);
1011
+ return snapshot.hash;
1012
+ }