codeep 2.15.0 → 2.17.0

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 (41) hide show
  1. package/README.md +41 -7
  2. package/dist/acp/serverHandlers.js +1 -1
  3. package/dist/acp/session.js +22 -1
  4. package/dist/config/index.js +20 -4
  5. package/dist/config/providers.d.ts +3 -2
  6. package/dist/config/providers.js +163 -69
  7. package/dist/renderer/App.d.ts +89 -0
  8. package/dist/renderer/App.js +637 -43
  9. package/dist/renderer/Screen.d.ts +1 -0
  10. package/dist/renderer/Screen.js +8 -3
  11. package/dist/renderer/commands/helpers.d.ts +189 -0
  12. package/dist/renderer/commands/helpers.js +345 -0
  13. package/dist/renderer/commands/registry.js +2 -1
  14. package/dist/renderer/commands.js +218 -267
  15. package/dist/renderer/components/AgentTimeline.d.ts +44 -0
  16. package/dist/renderer/components/AgentTimeline.js +157 -0
  17. package/dist/renderer/components/Autocomplete.d.ts +25 -0
  18. package/dist/renderer/components/Autocomplete.js +35 -0
  19. package/dist/renderer/components/Status.d.ts +2 -0
  20. package/dist/renderer/layout.d.ts +5 -1
  21. package/dist/renderer/layout.js +12 -0
  22. package/dist/renderer/main.js +110 -30
  23. package/dist/utils/agent.js +1 -1
  24. package/dist/utils/agents.d.ts +1 -1
  25. package/dist/utils/agents.js +1 -1
  26. package/dist/utils/checkpoints.d.ts +1 -1
  27. package/dist/utils/checkpoints.js +1 -1
  28. package/dist/utils/diffPreview.d.ts +31 -0
  29. package/dist/utils/diffPreview.js +102 -0
  30. package/dist/utils/git.d.ts +28 -0
  31. package/dist/utils/git.js +111 -1
  32. package/dist/utils/mentions.d.ts +195 -0
  33. package/dist/utils/mentions.js +672 -0
  34. package/dist/utils/resourceImpact.d.ts +25 -0
  35. package/dist/utils/resourceImpact.js +54 -0
  36. package/dist/utils/tokenTracker.js +52 -37
  37. package/dist/utils/webFetch.d.ts +101 -0
  38. package/dist/utils/webFetch.js +375 -0
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/package.json +2 -1
@@ -0,0 +1,672 @@
1
+ /**
2
+ * `@-mention` context expansion for the CLI chat input.
3
+ *
4
+ * When the user types `@path/to/file.ts` inline in their prompt, we
5
+ * detect those mentions, read the file contents, and inject them as an
6
+ * "[Attached files]" block prepended to the prompt — same format as the
7
+ * explicit `/add` command, so the agent sees a single, consistent shape.
8
+ *
9
+ * Supported mention forms (case-sensitive `@`):
10
+ * @src/index.ts → relative-to-project-root file
11
+ * @./local.ts → relative-to-cwd file
12
+ * @/abs/path.ts → absolute path
13
+ * @"path with space.ts" → quoted (spaces/special chars allowed)
14
+ * @'path with space.ts' → single-quoted variant
15
+ *
16
+ * A `@` immediately followed by whitespace, another `@`, or a non-path
17
+ * character (e.g. an email like `user@host`, or a GitHub `@handle`) is
18
+ * left untouched.
19
+ *
20
+ * Mentions are resolved against the project root (or cwd when no
21
+ * project is open). Files larger than `MAX_MENTION_BYTES` are skipped
22
+ * with a warning rather than silently truncated — the user should
23
+ * explicitly `/add` very large files if they really want them.
24
+ */
25
+ import { statSync, readFileSync, readdirSync } from 'fs';
26
+ import { join, isAbsolute, relative, resolve, sep } from 'path';
27
+ /** Max file size we'll auto-inline from a mention (100 KB). */
28
+ export const MAX_MENTION_BYTES = 100 * 1024;
29
+ /**
30
+ * Regex matching a single `@-mention` at a position.
31
+ *
32
+ * Three branches:
33
+ * 1. `@"..."` or `@'...'` — quoted path (allows any char except the quote).
34
+ * 2. `@<path-chars>` — bare path: must contain at least one path-ish
35
+ * character beyond bare letters/digits (a `/`, `.`, `_`, or `-`),
36
+ * so that GitHub handles (`@octocat`) and emails (`user@host`)
37
+ * aren't mistaken for file mentions. `@index.ts`, `@src/a`,
38
+ * `@my-file` all qualify; `@octocat` doesn't.
39
+ *
40
+ * Anchored with a preceding boundary so `user@host` doesn't match.
41
+ * `(?<=^|[\s([{<,;])` — start-of-string or whitespace/punctuation before `@`.
42
+ */
43
+ const MENTION_RE = /(?<=^|[\s([{<,;\]}])@(?:"([^"]+)"|'([^']+)'|([^\s@"'`<>|&()[\]{}]*[\/._-][^\s@"'`<>|&()[\]{}]+))/g;
44
+ /**
45
+ * The single source of truth for "may a mention start after this character?".
46
+ * `MENTION_RE`'s lookbehind above and the editor's `detectMentionQuery` picker
47
+ * MUST agree — when they diverged, the picker happily completed mentions
48
+ * (e.g. after `]`) that the expander then ignored, so the file silently never
49
+ * got attached. Import this rather than re-spelling the class.
50
+ */
51
+ export const MENTION_BOUNDARY = /[\s([{<,;\]}]/;
52
+ /**
53
+ * Extract all `@-mention` tokens from `text`. Returns them in document
54
+ * order. Pure (no FS) — testable without touching the disk.
55
+ */
56
+ export function extractMentions(text) {
57
+ const tokens = [];
58
+ // Reset lastIndex in case the regex was used before (it's a /g flag).
59
+ MENTION_RE.lastIndex = 0;
60
+ let m;
61
+ while ((m = MENTION_RE.exec(text)) !== null) {
62
+ const path = m[1] ?? m[2] ?? m[3] ?? '';
63
+ if (!path)
64
+ continue;
65
+ tokens.push({
66
+ raw: m[0],
67
+ path,
68
+ start: m.index,
69
+ end: m.index + m[0].length,
70
+ });
71
+ }
72
+ return tokens;
73
+ }
74
+ /**
75
+ * Expand all `@-mentions` in `prompt`: load each referenced file,
76
+ * prepend the contents as an `[Attached files]` block, and strip the
77
+ * `@path` tokens from the visible prompt (replacing them with a bare
78
+ * path so the agent still sees what was referenced).
79
+ *
80
+ * Failures (missing file, too large, not a file) are collected and
81
+ * returned rather than thrown — the caller decides how to surface them.
82
+ */
83
+ export function expandMentions(prompt, opts) {
84
+ const tokens = extractMentions(prompt);
85
+ if (tokens.length === 0) {
86
+ return { enrichedPrompt: prompt, strippedPrompt: prompt, loaded: [], failures: [] };
87
+ }
88
+ const loaded = [];
89
+ const failures = [];
90
+ const seen = new Set();
91
+ for (const tok of tokens) {
92
+ const resolved = resolveMentionPath(tok.path, opts.root);
93
+ if (!resolved.ok) {
94
+ failures.push({ mention: tok.raw, reason: resolved.reason });
95
+ continue;
96
+ }
97
+ if (seen.has(resolved.fullPath))
98
+ continue; // dedupe repeat mentions
99
+ seen.add(resolved.fullPath);
100
+ // Never auto-inline a secret file. Mentions can come from text the user
101
+ // pasted (an issue body, a log, model output), so `@.env` or
102
+ // `@~/.aws/credentials` would silently ship credentials to the provider.
103
+ // `/add` remains the explicit, deliberate path for these.
104
+ if (isSensitiveFile(resolved.fullPath)) {
105
+ failures.push({ mention: tok.raw, reason: 'looks like a secrets file — use /add to attach it deliberately' });
106
+ continue;
107
+ }
108
+ const stat = safeStat(resolved.fullPath);
109
+ if (!stat.exists) {
110
+ failures.push({ mention: tok.raw, reason: 'file not found' });
111
+ continue;
112
+ }
113
+ if (!stat.isFile) {
114
+ failures.push({ mention: tok.raw, reason: 'not a file' });
115
+ continue;
116
+ }
117
+ if (stat.size > MAX_MENTION_BYTES) {
118
+ failures.push({
119
+ mention: tok.raw,
120
+ reason: `too large (${Math.round(stat.size / 1024)}KB, max ${Math.round(MAX_MENTION_BYTES / 1024)}KB)`,
121
+ });
122
+ continue;
123
+ }
124
+ const content = safeRead(resolved.fullPath);
125
+ if (content === null) {
126
+ failures.push({ mention: tok.raw, reason: 'could not read (binary?)' });
127
+ continue;
128
+ }
129
+ loaded.push({ fullPath: resolved.fullPath, relativePath: resolved.relativePath, content });
130
+ }
131
+ // Build the enriched prompt: strip the `@` prefix from each mention so
132
+ // the visible text reads naturally ("refactor @src/index.ts" → "refactor
133
+ // src/index.ts"), then prepend the file-contents block.
134
+ let stripped = prompt;
135
+ // Replace from the end so earlier indices stay valid.
136
+ for (let i = tokens.length - 1; i >= 0; i--) {
137
+ const tok = tokens[i];
138
+ stripped = stripped.slice(0, tok.start) + tok.path + stripped.slice(tok.end);
139
+ }
140
+ const fileBlock = formatFileBlock(loaded);
141
+ return {
142
+ enrichedPrompt: fileBlock ? fileBlock + stripped.trimStart() : stripped,
143
+ strippedPrompt: stripped,
144
+ loaded,
145
+ failures,
146
+ };
147
+ }
148
+ // ─── `@folder` mentions ──────────────────────────────────────────────────────
149
+ /**
150
+ * Max total bytes of file content we'll inline from a single `@folder`
151
+ * mention (200 KB). Prevents a huge directory from blowing the context
152
+ * window — the user can raise this via explicit `/add` if they really
153
+ * want everything.
154
+ */
155
+ export const MAX_FOLDER_BYTES = 200 * 1024;
156
+ /**
157
+ * Regex matching a `@folder <path>` or `@dir <path>` mention.
158
+ *
159
+ * `@folder`/`@dir` must be followed by whitespace, then a path token
160
+ * (no spaces). Quoted paths (`@folder "my dir"`) are supported.
161
+ */
162
+ const FOLDER_MENTION_RE = /(?:^|[\s([{<,;])@(?:folder|dir)\s+("[^"]+"|'[^']+'|[^\s@[({<,;]+)/gi;
163
+ /**
164
+ * Extract all `@folder`/`@dir` mentions from `text`. Pure (no FS).
165
+ * Returns them in document order.
166
+ */
167
+ export function extractFolderMentions(text) {
168
+ const tokens = [];
169
+ FOLDER_MENTION_RE.lastIndex = 0;
170
+ let m;
171
+ while ((m = FOLDER_MENTION_RE.exec(text)) !== null) {
172
+ let path = m[1] ?? '';
173
+ if (!path)
174
+ continue;
175
+ // Strip surrounding quotes if present.
176
+ if ((path.startsWith('"') && path.endsWith('"')) ||
177
+ (path.startsWith("'") && path.endsWith("'"))) {
178
+ path = path.slice(1, -1);
179
+ }
180
+ const matchText = m[0];
181
+ const atIdx = matchText.indexOf('@');
182
+ const start = m.index + (atIdx >= 0 ? atIdx : 0);
183
+ tokens.push({ raw: matchText.slice(atIdx).trim(), path, start, end: m.index + m[0].length });
184
+ }
185
+ return tokens;
186
+ }
187
+ /**
188
+ * Expand all `@folder`/`@dir` mentions in `prompt`: recursively read
189
+ * every source file under each directory, and return them in the same
190
+ * shape as `expandMentions` (so the caller can merge the results).
191
+ *
192
+ * Skips the same ignored directories (`node_modules`, `.git`, …) and
193
+ * binary/generated extensions as the autocomplete scanner. Caps total
194
+ * content per mention at `MAX_FOLDER_BYTES` so a single huge tree
195
+ * can't blow the context window.
196
+ *
197
+ * Sync (filesystem reads only) — call before or after `expandMentions`.
198
+ */
199
+ export function expandFolderMentions(prompt, opts) {
200
+ const tokens = extractFolderMentions(prompt);
201
+ if (tokens.length === 0) {
202
+ return { enrichedPrompt: prompt, strippedPrompt: prompt, loaded: [], failures: [] };
203
+ }
204
+ const loaded = [];
205
+ const failures = [];
206
+ const seen = new Set();
207
+ for (const tok of tokens) {
208
+ const resolved = resolveMentionPath(tok.path, opts.root);
209
+ if (!resolved.ok) {
210
+ failures.push({ mention: tok.raw, reason: resolved.reason });
211
+ continue;
212
+ }
213
+ const stat = safeStat(resolved.fullPath);
214
+ if (!stat.exists) {
215
+ failures.push({ mention: tok.raw, reason: 'directory not found' });
216
+ continue;
217
+ }
218
+ if (stat.isFile) {
219
+ failures.push({ mention: tok.raw, reason: 'not a directory (use @file)' });
220
+ continue;
221
+ }
222
+ const walked = walkDirectory(resolved.fullPath, opts.root, seen);
223
+ if (walked.files.length === 0) {
224
+ failures.push({ mention: tok.raw, reason: walked.capped ? `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap` : 'no source files found' });
225
+ continue;
226
+ }
227
+ loaded.push(...walked.files);
228
+ if (walked.capped) {
229
+ failures.push({ mention: tok.raw, reason: `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap, loaded ${walked.files.length} file(s)` });
230
+ }
231
+ }
232
+ // Strip the `@folder <path>` tokens from the visible prompt, leaving
233
+ // the bare path so the agent still sees what was referenced.
234
+ let stripped = prompt;
235
+ for (let i = tokens.length - 1; i >= 0; i--) {
236
+ const tok = tokens[i];
237
+ stripped = stripped.slice(0, tok.start) + tok.path + stripped.slice(tok.end);
238
+ }
239
+ const fileBlock = formatFileBlock(loaded);
240
+ return {
241
+ enrichedPrompt: fileBlock ? fileBlock + stripped.trimStart() : stripped,
242
+ strippedPrompt: stripped,
243
+ loaded,
244
+ failures,
245
+ };
246
+ }
247
+ // ─── Combined expansion (`@folder` + `@file`) ───────────────────────────────
248
+ /**
249
+ * Expand both `@folder` and `@file` mentions in one pass, merging the
250
+ * loaded files into a single `[Attached files]` block (instead of two
251
+ * separate blocks when called back-to-back).
252
+ *
253
+ * `@web` mentions are async and handled separately in `webFetch.ts`.
254
+ */
255
+ export function expandFileAndFolderMentions(prompt, opts) {
256
+ // Run `@folder` first. We call the lower-level `extractFolderMentions`
257
+ // + directory walk directly so we can collect the loaded files without
258
+ // formatting a block (the merge step formats once).
259
+ const folderTokens = extractFolderMentions(prompt);
260
+ const folderLoaded = [];
261
+ const folderFailures = [];
262
+ const seen = new Set();
263
+ for (const tok of folderTokens) {
264
+ const resolved = resolveMentionPath(tok.path, opts.root);
265
+ if (!resolved.ok) {
266
+ folderFailures.push({ mention: tok.raw, reason: resolved.reason });
267
+ continue;
268
+ }
269
+ const stat = safeStat(resolved.fullPath);
270
+ if (!stat.exists) {
271
+ folderFailures.push({ mention: tok.raw, reason: 'directory not found' });
272
+ continue;
273
+ }
274
+ if (stat.isFile) {
275
+ folderFailures.push({ mention: tok.raw, reason: 'not a directory (use @file)' });
276
+ continue;
277
+ }
278
+ const walked = walkDirectory(resolved.fullPath, opts.root, seen);
279
+ if (walked.files.length === 0) {
280
+ folderFailures.push({ mention: tok.raw, reason: walked.capped ? `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap` : 'no source files found' });
281
+ continue;
282
+ }
283
+ folderLoaded.push(...walked.files);
284
+ if (walked.capped) {
285
+ folderFailures.push({ mention: tok.raw, reason: `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap, loaded ${walked.files.length} file(s)` });
286
+ }
287
+ }
288
+ // Strip the `@folder` tokens from the prompt before running `@file`
289
+ // expansion, so `@folder src/x` isn't re-matched as a file mention.
290
+ let folderStripped = prompt;
291
+ for (let i = folderTokens.length - 1; i >= 0; i--) {
292
+ const tok = folderTokens[i];
293
+ folderStripped = folderStripped.slice(0, tok.start) + tok.path + folderStripped.slice(tok.end);
294
+ }
295
+ // Run `@file` on the folder-stripped prompt.
296
+ const fileResult = expandMentions(folderStripped, opts);
297
+ // Merge and format a single block.
298
+ const merged = [...folderLoaded, ...fileResult.loaded];
299
+ const failures = [...folderFailures, ...fileResult.failures];
300
+ // Use the block-free prompt directly. Regex-stripping the block back out of
301
+ // `enrichedPrompt` looked equivalent but wasn't: the pattern was lazy, so it
302
+ // removed the `[Attached files]` header and left every file body behind —
303
+ // and the merged block below then appended those same bodies a second time,
304
+ // silently doubling the token cost of every mention.
305
+ const strippedPrompt = fileResult.strippedPrompt;
306
+ const mergedBlock = formatFileBlock(merged);
307
+ return {
308
+ enrichedPrompt: mergedBlock ? mergedBlock + strippedPrompt.trimStart() : strippedPrompt,
309
+ strippedPrompt,
310
+ loaded: merged,
311
+ failures,
312
+ };
313
+ }
314
+ /**
315
+ * Walk a directory and return its source files, capped at
316
+ * `MAX_FOLDER_BYTES` total content. Mutates `seen` so repeat folders
317
+ * don't duplicate files.
318
+ */
319
+ function walkDirectory(dir, root, seen) {
320
+ const files = [];
321
+ let totalBytes = 0;
322
+ let capped = false;
323
+ const walk = (d, depth) => {
324
+ if (capped || depth > 6)
325
+ return;
326
+ let entries;
327
+ try {
328
+ entries = readdirSync(d);
329
+ }
330
+ catch {
331
+ return;
332
+ }
333
+ entries.sort((a, b) => a.localeCompare(b));
334
+ for (const name of entries) {
335
+ if (capped)
336
+ return;
337
+ const full = join(d, name);
338
+ let isDir = false;
339
+ try {
340
+ isDir = statSync(full).isDirectory();
341
+ }
342
+ catch {
343
+ continue;
344
+ }
345
+ if (isDir) {
346
+ if (DEFAULT_IGNORE_DIRS.has(name))
347
+ continue;
348
+ walk(full, depth + 1);
349
+ }
350
+ else {
351
+ if (!shouldSuggest(name))
352
+ continue;
353
+ if (seen.has(full))
354
+ continue;
355
+ const fstat = safeStat(full);
356
+ if (!fstat.exists || !fstat.isFile)
357
+ continue;
358
+ if (fstat.size > MAX_MENTION_BYTES)
359
+ continue;
360
+ const content = safeRead(full);
361
+ if (content === null)
362
+ continue;
363
+ if (totalBytes + content.length > MAX_FOLDER_BYTES) {
364
+ capped = true;
365
+ return;
366
+ }
367
+ totalBytes += content.length;
368
+ seen.add(full);
369
+ files.push({ fullPath: full, relativePath: relative(root, full), content });
370
+ }
371
+ }
372
+ };
373
+ walk(dir, 0);
374
+ return { files, capped };
375
+ }
376
+ /**
377
+ * Resolve a mention's path to an absolute filesystem path and a
378
+ * display path (relative to root when possible).
379
+ *
380
+ * Rules:
381
+ * `/abs/...` → used as-is, relativePath computed from root.
382
+ * `./rel/...` → resolved against cwd (not root), like a normal import.
383
+ * `rel/...` → resolved against root (project root).
384
+ * `~/...` → expanded to the home directory.
385
+ */
386
+ function resolveMentionPath(mentionPath, root) {
387
+ let fullPath;
388
+ if (mentionPath.startsWith('~/')) {
389
+ // Only `~/` is a home reference. `slice(2)` on a bare `~foo/bar.ts`
390
+ // silently produced `$HOME/oo/bar.ts` and then reported "file not found".
391
+ fullPath = resolve(join(getHomeDir(), mentionPath.slice(2)));
392
+ }
393
+ else if (isAbsolute(mentionPath)) {
394
+ fullPath = resolve(mentionPath);
395
+ }
396
+ else if (mentionPath.startsWith('./') || mentionPath.startsWith('.\\') || mentionPath === '.') {
397
+ fullPath = resolve(process.cwd(), mentionPath);
398
+ }
399
+ else {
400
+ fullPath = resolve(join(root, mentionPath));
401
+ }
402
+ const relativePath = computeRelativePath(fullPath, root);
403
+ return { ok: true, fullPath, relativePath };
404
+ }
405
+ /** `fullPath` relative to `root`, or the absolute path if outside root. */
406
+ function computeRelativePath(fullPath, root) {
407
+ const rel = relative(root, fullPath);
408
+ // `relative` returns an absolute path (or one starting with `..`)
409
+ // when `fullPath` is outside `root` — keep the absolute form then.
410
+ if (rel.startsWith('..') || isAbsolute(rel))
411
+ return fullPath;
412
+ return rel;
413
+ }
414
+ // ─── Formatting ───────────────────────────────────────────────────────────────
415
+ /** Format the `[Attached files]` block prepended to the enriched prompt. */
416
+ export function formatFileBlock(files) {
417
+ if (files.length === 0)
418
+ return '';
419
+ const parts = ['[Attached files]'];
420
+ for (const f of files) {
421
+ parts.push(`\nFile: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``);
422
+ }
423
+ return parts.join('\n') + '\n\n';
424
+ }
425
+ /** Default glob ignores when scanning for mention suggestions. */
426
+ const DEFAULT_IGNORE_DIRS = new Set([
427
+ 'node_modules', '.git', 'dist', 'build', '.next', '.cache',
428
+ 'coverage', '.turbo', '.nuxt', '.output', '.vercel',
429
+ '.DS_Store', '__pycache__', '.pytest_cache', '.mypy_cache',
430
+ 'vendor', 'Pods', 'DerivedData', '.build',
431
+ ]);
432
+ /** Default file extensions we won't suggest (binaries / generated / huge). */
433
+ const DEFAULT_IGNORE_EXTS = new Set([
434
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.tiff',
435
+ '.pdf', '.zip', '.gz', '.tar', '.bz2', '.7z', '.dmg', '.iso',
436
+ '.mp3', '.mp4', '.mov', '.avi', '.wav', '.flv',
437
+ '.exe', '.dll', '.so', '.dylib', '.o', '.a', '.class',
438
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
439
+ '.min.js', '.min.css',
440
+ '.lock', '.bin', '.dat',
441
+ ]);
442
+ let suggestionCache = null;
443
+ const SUGGEST_CACHE_TTL_MS = 5000;
444
+ /**
445
+ * Build (or reuse from cache) the flat list of suggestible files under
446
+ * `root`, then filter by `query`. The scan walks up to `maxScan` files,
447
+ * skipping ignored directories and binary/generated extensions.
448
+ */
449
+ export function suggestMentions(opts) {
450
+ const { root, query = '', limit = 20, extraIgnoreDirs = [] } = opts;
451
+ const ignores = new Set([...DEFAULT_IGNORE_DIRS, ...extraIgnoreDirs]);
452
+ const q = query.toLowerCase();
453
+ // Cache lookup — reuse the file list if it's fresh.
454
+ const now = Date.now();
455
+ let files;
456
+ if (suggestionCache && suggestionCache.root === root && now - suggestionCache.at < SUGGEST_CACHE_TTL_MS) {
457
+ files = suggestionCache.files;
458
+ }
459
+ else {
460
+ files = scanFiles(root, ignores);
461
+ suggestionCache = { root, files, at: now };
462
+ }
463
+ const results = [];
464
+ for (const rel of files) {
465
+ if (results.length >= limit)
466
+ break;
467
+ if (q && !rel.toLowerCase().includes(q))
468
+ continue;
469
+ const slashIdx = rel.lastIndexOf(sep);
470
+ const detail = slashIdx >= 0 ? rel.slice(0, slashIdx) : rel;
471
+ results.push({ label: rel, insertPath: rel, detail });
472
+ }
473
+ return results;
474
+ }
475
+ /** Walk `root` and return a flat list of suggestible relative paths. */
476
+ /**
477
+ * Hard ceiling on files visited by one suggestion scan. The walk runs on the
478
+ * first `@` keystroke and blocks the render loop; in a large monorepo an
479
+ * unbounded walk froze the TUI for seconds. 20k entries is far more than the
480
+ * picker can use (it shows 8) but still finds everything in a normal repo.
481
+ */
482
+ const MAX_SCAN_FILES = 20_000;
483
+ function scanFiles(root, ignores) {
484
+ const out = [];
485
+ let visited = 0;
486
+ const walk = (dir, depth) => {
487
+ if (depth > 8 || visited >= MAX_SCAN_FILES)
488
+ return;
489
+ let entries;
490
+ try {
491
+ // `withFileTypes` gives us the entry kind from the directory read
492
+ // itself — the previous per-entry `statSync` was an extra syscall for
493
+ // every file in the tree.
494
+ entries = readdirSync(dir, { withFileTypes: true });
495
+ }
496
+ catch {
497
+ return;
498
+ }
499
+ for (const entry of entries) {
500
+ if (visited >= MAX_SCAN_FILES)
501
+ return;
502
+ visited++;
503
+ const name = entry.name;
504
+ if (entry.isDirectory()) {
505
+ if (ignores.has(name))
506
+ continue;
507
+ walk(join(dir, name), depth + 1);
508
+ }
509
+ else if (entry.isFile()) {
510
+ if (!shouldSuggest(name))
511
+ continue;
512
+ out.push(relative(root, join(dir, name)));
513
+ }
514
+ }
515
+ };
516
+ walk(root, 0);
517
+ return out;
518
+ }
519
+ /** Clear the suggestion cache. Call between tests so fixtures don't leak. */
520
+ export function clearSuggestionCache() {
521
+ suggestionCache = null;
522
+ }
523
+ /** Dotfiles we never suggest (besides the implicit `.` / `..`). */
524
+ const IGNORED_DOTFILES = new Set(['.DS_Store', '.env']);
525
+ /**
526
+ * Filenames that typically hold credentials. Mentions never auto-inline
527
+ * these — see the guard in `expandMentions`. Matched on the basename so it
528
+ * catches the file wherever it lives (project root, `~/.aws/`, …).
529
+ */
530
+ const SENSITIVE_FILE_RE = /^(\.env(\..*)?|\.netrc|\.npmrc|\.pgpass|credentials|id_(rsa|dsa|ecdsa|ed25519)|.*\.(pem|key|p12|pfx|keystore))$/i;
531
+ /** True if `fullPath`'s basename looks like it holds secrets. */
532
+ export function isSensitiveFile(fullPath) {
533
+ const name = fullPath.split(sep).pop() ?? fullPath;
534
+ return SENSITIVE_FILE_RE.test(name);
535
+ }
536
+ /** True if a filename looks like a suggestible source file. */
537
+ function shouldSuggest(name) {
538
+ if (IGNORED_DOTFILES.has(name))
539
+ return false;
540
+ const lower = name.toLowerCase();
541
+ for (const ext of DEFAULT_IGNORE_EXTS) {
542
+ if (lower.endsWith(ext))
543
+ return false;
544
+ }
545
+ return true;
546
+ }
547
+ function safeStat(fullPath) {
548
+ try {
549
+ const stat = statSync(fullPath);
550
+ return { exists: true, isFile: stat.isFile(), size: stat.size };
551
+ }
552
+ catch {
553
+ return { exists: false, isFile: false, size: 0 };
554
+ }
555
+ }
556
+ function safeRead(fullPath) {
557
+ try {
558
+ const buf = readFileSync(fullPath);
559
+ // Reject obvious binaries (NUL byte in the first 8 KB).
560
+ const sniff = buf.subarray(0, Math.min(buf.length, 8192));
561
+ if (sniff.includes(0))
562
+ return null;
563
+ return buf.toString('utf-8');
564
+ }
565
+ catch {
566
+ return null;
567
+ }
568
+ }
569
+ function getHomeDir() {
570
+ try {
571
+ return process.env.HOME || process.env.USERPROFILE || '/';
572
+ }
573
+ catch {
574
+ return '/';
575
+ }
576
+ }
577
+ // ─── `@git <ref>` mentions ───────────────────────────────────────────────────
578
+ /**
579
+ * Format a `[Git ref]` block — same visual style as `[Attached files]`
580
+ * but for git diffs / file-at-ref content. Each entry is labeled with
581
+ * the git ref so the agent knows what it's looking at.
582
+ */
583
+ function formatGitBlock(entries) {
584
+ if (entries.length === 0)
585
+ return '';
586
+ const parts = ['[Git ref]'];
587
+ for (const e of entries) {
588
+ parts.push(`\nRef: ${e.label}\n\`\`\`diff\n${e.content}\n\`\`\``);
589
+ }
590
+ return parts.join('\n') + '\n\n';
591
+ }
592
+ /**
593
+ * Regex matching a `@git <ref>` mention. `@git` must be followed by
594
+ * whitespace, then a ref.
595
+ *
596
+ * Capture order:
597
+ * 1. Quoted (`"…"` / `'…'`) — full string, spaces allowed.
598
+ * 2. `diff …` — diff forms may carry flags (`--staged`, `--cached`)
599
+ * or a range (`a..b`, `a...b`). Each optional token is one word;
600
+ * bare words like "and" are NOT consumed (so `@git diff and @git
601
+ * HEAD` parses as two mentions).
602
+ * 3. Any other single token (SHA, branch, `HEAD:file`).
603
+ */
604
+ const GIT_MENTION_RE = /(?:^|[\s([{<,;])@git\s+("[^"]+"|'[^']+'|diff(?:\s+--?[a-zA-Z-]+|\s+\S*\.{2,3}\S*)*|[^\s@[({<,;]+)/gi;
605
+ /**
606
+ * Extract all `@git <ref>` mentions from `text`. Pure (no FS / no git).
607
+ */
608
+ export function extractGitMentions(text) {
609
+ const tokens = [];
610
+ GIT_MENTION_RE.lastIndex = 0;
611
+ let m;
612
+ while ((m = GIT_MENTION_RE.exec(text)) !== null) {
613
+ let ref = m[1] ?? '';
614
+ if (!ref)
615
+ continue;
616
+ if ((ref.startsWith('"') && ref.endsWith('"')) ||
617
+ (ref.startsWith("'") && ref.endsWith("'"))) {
618
+ ref = ref.slice(1, -1);
619
+ }
620
+ if (!ref)
621
+ continue;
622
+ const matchText = m[0];
623
+ const atIdx = matchText.indexOf('@');
624
+ const start = m.index + (atIdx >= 0 ? atIdx : 0);
625
+ tokens.push({ raw: matchText.slice(atIdx).trim(), ref, start, end: m.index + m[0].length });
626
+ }
627
+ return tokens;
628
+ }
629
+ /**
630
+ * Expand all `@git <ref>` mentions in `prompt`: resolve each ref to
631
+ * git content (diff, file-at-ref, or commit patch) and inject it as
632
+ * a `[Git ref]` block. Sync (git is run via `execSync`).
633
+ *
634
+ * The block is appended *after* any `[Attached files]` block from
635
+ * `@folder`/`@file` expansion, so the final prompt reads:
636
+ *
637
+ * [Attached files] … [Git ref] … <user text>
638
+ */
639
+ export async function expandGitMentions(prompt, opts) {
640
+ const tokens = extractGitMentions(prompt);
641
+ if (tokens.length === 0) {
642
+ return { enrichedPrompt: prompt, strippedPrompt: prompt, loaded: [], failures: [] };
643
+ }
644
+ // Lazy-import git to avoid pulling child_process into callers that
645
+ // never use `@git` (e.g. tests, the suggestion scanner). Using a
646
+ // dynamic import() keeps the module graph small and works under both
647
+ // CommonJS and ESM.
648
+ const { getGitContent } = await import('./git.js');
649
+ const entries = [];
650
+ const failures = [];
651
+ for (const tok of tokens) {
652
+ const result = getGitContent(tok.ref, opts.root);
653
+ if (!result.success || !result.content) {
654
+ failures.push({ mention: tok.raw, reason: result.error || 'empty result' });
655
+ continue;
656
+ }
657
+ entries.push({ label: result.label, content: result.content });
658
+ }
659
+ // Strip the `@git <ref>` tokens from the prompt, leaving the bare ref.
660
+ let stripped = prompt;
661
+ for (let i = tokens.length - 1; i >= 0; i--) {
662
+ const tok = tokens[i];
663
+ stripped = stripped.slice(0, tok.start) + tok.ref + stripped.slice(tok.end);
664
+ }
665
+ const gitBlock = formatGitBlock(entries);
666
+ return {
667
+ enrichedPrompt: gitBlock ? gitBlock + stripped.trimStart() : stripped,
668
+ strippedPrompt: stripped,
669
+ loaded: [],
670
+ failures,
671
+ };
672
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Broad operational-impact estimate for hosted LLM inference.
3
+ *
4
+ * This is deliberately a range, not a meter reading:
5
+ * - 0.3–1.5 J/token covers published H100 inference benchmarks.
6
+ * - 0.27–1.08 L/kWh spans efficient direct cooling and a full-stack
7
+ * production estimate that also captures associated infrastructure.
8
+ *
9
+ * Model size, batching, context length, hardware, data-centre location and
10
+ * provider efficiency can move the real result outside this band. Local
11
+ * models are included because their token usage still consumes electricity,
12
+ * but Codeep cannot measure the device directly.
13
+ */
14
+ export interface ResourceImpactEstimate {
15
+ energyWhLow: number;
16
+ energyWhHigh: number;
17
+ waterMlLow: number;
18
+ waterMlHigh: number;
19
+ }
20
+ export declare function estimateResourceImpact(totalTokens: number): ResourceImpactEstimate;
21
+ export declare function formatResourceImpact(estimate: ResourceImpactEstimate): {
22
+ energy: string;
23
+ water: string;
24
+ };
25
+ export declare function formatResourceImpactReport(totalTokens: number): string[];