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.
- package/README.md +41 -7
- package/dist/acp/serverHandlers.js +1 -1
- package/dist/acp/session.js +22 -1
- package/dist/config/index.js +20 -4
- package/dist/config/providers.d.ts +3 -2
- package/dist/config/providers.js +163 -69
- package/dist/renderer/App.d.ts +89 -0
- package/dist/renderer/App.js +637 -43
- package/dist/renderer/Screen.d.ts +1 -0
- package/dist/renderer/Screen.js +8 -3
- package/dist/renderer/commands/helpers.d.ts +189 -0
- package/dist/renderer/commands/helpers.js +345 -0
- package/dist/renderer/commands/registry.js +2 -1
- package/dist/renderer/commands.js +218 -267
- package/dist/renderer/components/AgentTimeline.d.ts +44 -0
- package/dist/renderer/components/AgentTimeline.js +157 -0
- package/dist/renderer/components/Autocomplete.d.ts +25 -0
- package/dist/renderer/components/Autocomplete.js +35 -0
- package/dist/renderer/components/Status.d.ts +2 -0
- package/dist/renderer/layout.d.ts +5 -1
- package/dist/renderer/layout.js +12 -0
- package/dist/renderer/main.js +110 -30
- package/dist/utils/agent.js +1 -1
- package/dist/utils/agents.d.ts +1 -1
- package/dist/utils/agents.js +1 -1
- package/dist/utils/checkpoints.d.ts +1 -1
- package/dist/utils/checkpoints.js +1 -1
- package/dist/utils/diffPreview.d.ts +31 -0
- package/dist/utils/diffPreview.js +102 -0
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +111 -1
- package/dist/utils/mentions.d.ts +195 -0
- package/dist/utils/mentions.js +672 -0
- package/dist/utils/resourceImpact.d.ts +25 -0
- package/dist/utils/resourceImpact.js +54 -0
- package/dist/utils/tokenTracker.js +52 -37
- package/dist/utils/webFetch.d.ts +101 -0
- package/dist/utils/webFetch.js +375 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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[];
|