create-interview-cockpit 0.24.0 → 0.26.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/package.json +1 -1
- package/template/client/src/api.ts +26 -4
- package/template/client/src/components/ChatView.tsx +24 -3
- package/template/client/src/components/CodeContextPanel.tsx +242 -60
- package/template/client/src/components/FileViewerModal.tsx +209 -49
- package/template/client/src/components/GitDiffPanel.tsx +403 -73
- package/template/client/src/components/GitDiffViewerModal.tsx +2 -1
- package/template/client/src/components/MarkdownRenderer.tsx +8 -1
- package/template/client/src/store.ts +17 -2
- package/template/client/src/types.ts +8 -0
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +1739 -185
- package/template/server/src/storage.ts +2 -0
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
tool,
|
|
18
18
|
stepCountIs,
|
|
19
19
|
type LanguageModel,
|
|
20
|
+
type ToolSet,
|
|
20
21
|
} from "ai";
|
|
21
22
|
import { z } from "zod";
|
|
22
23
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
@@ -163,6 +164,7 @@ function makeCodeReferenceFileEntry(
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
interface GitDiffContextPayload {
|
|
167
|
+
rootId?: string;
|
|
166
168
|
baseRef?: string;
|
|
167
169
|
headRef?: string;
|
|
168
170
|
mode?: string;
|
|
@@ -176,82 +178,56 @@ async function registerGitDiffContext(
|
|
|
176
178
|
fileRegistry: Map<string, ReferenceFileEntry>,
|
|
177
179
|
ctx: GitDiffContextPayload,
|
|
178
180
|
): Promise<string> {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const mode = parseDiffMode(ctx.mode);
|
|
183
|
-
const headRaw = (ctx.headRef || "").trim();
|
|
184
|
-
const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
|
|
185
|
-
if (mode !== "working-tree" && (!head || !isValidRef(head))) return "";
|
|
186
|
-
|
|
187
|
-
const selected = Array.isArray(ctx.selectedFiles)
|
|
188
|
-
? ctx.selectedFiles.filter(
|
|
189
|
-
(p) => typeof p === "string" && p && !p.includes("\0"),
|
|
190
|
-
)
|
|
191
|
-
: [];
|
|
192
|
-
if (selected.length === 0) return "";
|
|
181
|
+
const scoped = await getScopedGitDiffContext(ctx);
|
|
182
|
+
if (!scoped) return "";
|
|
183
|
+
const { root, base, head, mode, entries, rangeLabel, headLabel } = scoped;
|
|
193
184
|
|
|
194
|
-
let
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const byPath = new Map(changedFiles.map((f) => [f.path, f]));
|
|
201
|
-
const validSelected = selected.filter((p) => byPath.has(p));
|
|
202
|
-
if (validSelected.length === 0) return "";
|
|
203
|
-
|
|
204
|
-
const headLabel = mode === "working-tree" ? "working tree" : head;
|
|
205
|
-
const rangeLabel =
|
|
206
|
-
mode === "working-tree"
|
|
207
|
-
? `${base} → working tree`
|
|
208
|
-
: `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
|
|
209
|
-
|
|
210
|
-
let manifest = `\n\n--- Available Git Diff Context (${rangeLabel}) ---
|
|
211
|
-
You can pull lazy git diff context with the readFile tool. Three views are available per file:
|
|
212
|
-
• gitdiff:patch:<path> — unified diff hunks (recommended starting point)
|
|
213
|
-
• gitdiff:before:<path> — full file contents at the base ref
|
|
214
|
-
• gitdiff:after:<path> — full file contents at the head ref (or working tree)
|
|
215
|
-
Only read what you need; each file can be expensive.
|
|
185
|
+
let manifest = `\n\n--- Available Git Diff Context (${root.name}: ${rangeLabel}) ---
|
|
186
|
+
You can inspect the selected changed files with the Git Diff tools or pull lazy views with readFile. Three views are available per file:
|
|
187
|
+
• gitdiff:patch:<root>::<path> — unified diff hunks (recommended starting point)
|
|
188
|
+
• gitdiff:before:<root>::<path> — full file contents at the base ref
|
|
189
|
+
• gitdiff:after:<root>::<path> — full file contents at the head ref (or working tree)
|
|
190
|
+
Use listGitDiffFiles to see selected diff files, readGitDiffFile to get patch/before/after, searchGitDiff to find specific changes, and summarizeGitDiff for totals. Only read what you need; each file can be expensive.
|
|
216
191
|
|
|
217
192
|
`;
|
|
218
|
-
for (const
|
|
219
|
-
const entry = byPath.get(filePath)!;
|
|
193
|
+
for (const entry of entries) {
|
|
220
194
|
const sign = entry.binary
|
|
221
195
|
? "binary"
|
|
222
196
|
: `+${entry.additions}/-${entry.deletions}`;
|
|
223
197
|
const renamed = entry.oldPath ? ` (renamed from ${entry.oldPath})` : "";
|
|
224
|
-
manifest += `• [${entry.status}] ${entry.
|
|
225
|
-
manifest += ` patch id: "gitdiff:patch:${entry.
|
|
198
|
+
manifest += `• [${entry.status}] ${entry.displayPath} ${sign}${renamed}\n`;
|
|
199
|
+
manifest += ` patch id: "gitdiff:patch:${entry.id}"\n`;
|
|
226
200
|
if (entry.status !== "A" && entry.status !== "?") {
|
|
227
|
-
manifest += ` before id: "gitdiff:before:${entry.
|
|
201
|
+
manifest += ` before id: "gitdiff:before:${entry.id}"\n`;
|
|
228
202
|
}
|
|
229
203
|
if (entry.status !== "D") {
|
|
230
|
-
manifest += ` after id: "gitdiff:after:${entry.
|
|
204
|
+
manifest += ` after id: "gitdiff:after:${entry.id}"\n`;
|
|
231
205
|
}
|
|
232
206
|
|
|
233
|
-
const safePath =
|
|
207
|
+
const safePath = entry.path; // already validated to be in diff
|
|
234
208
|
fileRegistry.set(
|
|
235
|
-
`gitdiff:patch:${
|
|
236
|
-
makeCodeReferenceFileEntry(`[git diff] ${safePath}`, () =>
|
|
237
|
-
getDiffPatch(base, head, mode, safePath),
|
|
209
|
+
`gitdiff:patch:${entry.id}`,
|
|
210
|
+
makeCodeReferenceFileEntry(`[git diff:${root.name}] ${safePath}`, () =>
|
|
211
|
+
getDiffPatch(root, base, head, mode, safePath),
|
|
238
212
|
),
|
|
239
213
|
);
|
|
240
214
|
if (entry.status !== "A" && entry.status !== "?") {
|
|
241
215
|
const refPath = entry.oldPath ?? safePath;
|
|
242
216
|
fileRegistry.set(
|
|
243
|
-
`gitdiff:before:${
|
|
244
|
-
makeCodeReferenceFileEntry(
|
|
245
|
-
|
|
217
|
+
`gitdiff:before:${entry.id}`,
|
|
218
|
+
makeCodeReferenceFileEntry(
|
|
219
|
+
`[git before ${base}:${root.name}] ${safePath}`,
|
|
220
|
+
() => getFileAtRef(root, base, refPath),
|
|
246
221
|
),
|
|
247
222
|
);
|
|
248
223
|
}
|
|
249
224
|
if (entry.status !== "D") {
|
|
250
225
|
const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
|
|
251
226
|
fileRegistry.set(
|
|
252
|
-
`gitdiff:after:${
|
|
253
|
-
makeCodeReferenceFileEntry(
|
|
254
|
-
|
|
227
|
+
`gitdiff:after:${entry.id}`,
|
|
228
|
+
makeCodeReferenceFileEntry(
|
|
229
|
+
`[git after ${headLabel}:${root.name}] ${safePath}`,
|
|
230
|
+
() => getFileAtRef(root, ref, safePath),
|
|
255
231
|
),
|
|
256
232
|
);
|
|
257
233
|
}
|
|
@@ -343,7 +319,1235 @@ function createReadFileTool(fileRegistry: Map<string, ReferenceFileEntry>) {
|
|
|
343
319
|
}
|
|
344
320
|
|
|
345
321
|
const PORT = process.env.PORT || 3001;
|
|
346
|
-
const
|
|
322
|
+
const CODE_CONTEXT_ROOT_SEPARATOR = "::";
|
|
323
|
+
|
|
324
|
+
interface CodeContextRootConfig {
|
|
325
|
+
id: string;
|
|
326
|
+
name: string;
|
|
327
|
+
dir: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface CodeContextFileRef {
|
|
331
|
+
id: string;
|
|
332
|
+
root: CodeContextRootConfig;
|
|
333
|
+
relPath: string;
|
|
334
|
+
absolutePath: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function splitCodeContextRootEnv(value: string | undefined): string[] {
|
|
338
|
+
if (!value) return [];
|
|
339
|
+
return value
|
|
340
|
+
.split(/[\n,;]/)
|
|
341
|
+
.map((entry) => entry.trim())
|
|
342
|
+
.filter(Boolean);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function parseCodeContextRootSpec(
|
|
346
|
+
raw: string,
|
|
347
|
+
): { name?: string; dir: string } | null {
|
|
348
|
+
const value = raw.trim();
|
|
349
|
+
if (!value) return null;
|
|
350
|
+
const eq = value.indexOf("=");
|
|
351
|
+
if (eq > 0) {
|
|
352
|
+
const maybeName = value.slice(0, eq).trim();
|
|
353
|
+
const maybeDir = value.slice(eq + 1).trim();
|
|
354
|
+
if (maybeName && maybeDir && !/[\\/]/.test(maybeName)) {
|
|
355
|
+
return { name: maybeName, dir: path.resolve(maybeDir) };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { dir: path.resolve(value) };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function slugForCodeContextRoot(value: string): string {
|
|
362
|
+
return (
|
|
363
|
+
value
|
|
364
|
+
.toLowerCase()
|
|
365
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
366
|
+
.replace(/^-+|-+$/g, "") || "root"
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function uniqueCodeContextRootName(
|
|
371
|
+
baseName: string,
|
|
372
|
+
usedNames: Map<string, number>,
|
|
373
|
+
): string {
|
|
374
|
+
const clean = baseName.trim() || "Code Context";
|
|
375
|
+
const key = clean.toLowerCase();
|
|
376
|
+
const count = usedNames.get(key) ?? 0;
|
|
377
|
+
usedNames.set(key, count + 1);
|
|
378
|
+
return count === 0 ? clean : `${clean} (${count + 1})`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function uniqueCodeContextRootId(baseId: string, usedIds: Set<string>): string {
|
|
382
|
+
let candidate = baseId;
|
|
383
|
+
let suffix = 2;
|
|
384
|
+
while (usedIds.has(candidate)) {
|
|
385
|
+
candidate = `${baseId}-${suffix}`;
|
|
386
|
+
suffix += 1;
|
|
387
|
+
}
|
|
388
|
+
usedIds.add(candidate);
|
|
389
|
+
return candidate;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function resolveCodeContextRoots(): CodeContextRootConfig[] {
|
|
393
|
+
const specs = [
|
|
394
|
+
...(process.env.CODE_CONTEXT_DIR
|
|
395
|
+
? [
|
|
396
|
+
process.env.CODE_CONTEXT_NAME
|
|
397
|
+
? `${process.env.CODE_CONTEXT_NAME}=${process.env.CODE_CONTEXT_DIR}`
|
|
398
|
+
: process.env.CODE_CONTEXT_DIR,
|
|
399
|
+
]
|
|
400
|
+
: []),
|
|
401
|
+
...splitCodeContextRootEnv(process.env.CODE_CONTEXT_DIRS),
|
|
402
|
+
];
|
|
403
|
+
const seenDirs = new Set<string>();
|
|
404
|
+
const usedNames = new Map<string, number>();
|
|
405
|
+
const usedIds = new Set<string>();
|
|
406
|
+
const roots: CodeContextRootConfig[] = [];
|
|
407
|
+
|
|
408
|
+
for (const spec of specs) {
|
|
409
|
+
const parsed = parseCodeContextRootSpec(spec);
|
|
410
|
+
if (!parsed) continue;
|
|
411
|
+
const dir = path.resolve(parsed.dir);
|
|
412
|
+
if (seenDirs.has(dir)) continue;
|
|
413
|
+
seenDirs.add(dir);
|
|
414
|
+
const fallbackName = path.basename(dir) || "Code Context";
|
|
415
|
+
const name = uniqueCodeContextRootName(
|
|
416
|
+
parsed.name || fallbackName,
|
|
417
|
+
usedNames,
|
|
418
|
+
);
|
|
419
|
+
const id = uniqueCodeContextRootId(slugForCodeContextRoot(name), usedIds);
|
|
420
|
+
roots.push({ id, name, dir });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return roots;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const CODE_CONTEXT_ROOTS = resolveCodeContextRoots();
|
|
427
|
+
const CODE_CONTEXT_ROOT_BY_ID = new Map(
|
|
428
|
+
CODE_CONTEXT_ROOTS.map((root) => [root.id, root]),
|
|
429
|
+
);
|
|
430
|
+
// Backward-compatible primary root used by git-diff config when GIT_DIFF_DIR is unset.
|
|
431
|
+
const CODE_CONTEXT_DIR = CODE_CONTEXT_ROOTS[0]?.dir ?? "";
|
|
432
|
+
|
|
433
|
+
function makeCodeContextFileId(rootId: string, relPath: string): string {
|
|
434
|
+
return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${relPath}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function getCodeContextPrimaryRoot(): CodeContextRootConfig | null {
|
|
438
|
+
return CODE_CONTEXT_ROOTS[0] ?? null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const MAX_CODE_TOOL_LIST_RESULTS = 400;
|
|
442
|
+
const MAX_CODE_TOOL_SEARCH_RESULTS = 80;
|
|
443
|
+
const MAX_CODE_TOOL_SEARCH_FILE_BYTES = 512 * 1024;
|
|
444
|
+
const MAX_CODE_TOOL_READ_BYTES = 256 * 1024;
|
|
445
|
+
const MAX_CODE_TOOL_CONTEXT_LINES = 5;
|
|
446
|
+
const MAX_CODE_TOOL_LINE_CHARS = 600;
|
|
447
|
+
|
|
448
|
+
function normalizeCodeContextRelPath(value: string): string | null {
|
|
449
|
+
const raw = value.replaceAll("\\", "/").trim();
|
|
450
|
+
if (!raw || raw.includes("\0") || path.isAbsolute(raw)) return null;
|
|
451
|
+
const normalized = path.posix.normalize(raw.replace(/^\.\//, ""));
|
|
452
|
+
if (!normalized || normalized === ".") return null;
|
|
453
|
+
if (normalized === ".." || normalized.startsWith("../")) return null;
|
|
454
|
+
return normalized;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getCodeContextFileRef(
|
|
458
|
+
fileIdOrPath: string,
|
|
459
|
+
): CodeContextFileRef | null {
|
|
460
|
+
if (CODE_CONTEXT_ROOTS.length === 0) return null;
|
|
461
|
+
const raw = fileIdOrPath.replaceAll("\\", "/").trim();
|
|
462
|
+
if (!raw || raw.includes("\0")) return null;
|
|
463
|
+
|
|
464
|
+
const sepIdx = raw.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
|
|
465
|
+
const root =
|
|
466
|
+
sepIdx > 0
|
|
467
|
+
? CODE_CONTEXT_ROOT_BY_ID.get(raw.slice(0, sepIdx))
|
|
468
|
+
: getCodeContextPrimaryRoot();
|
|
469
|
+
const relRaw =
|
|
470
|
+
sepIdx > 0 ? raw.slice(sepIdx + CODE_CONTEXT_ROOT_SEPARATOR.length) : raw;
|
|
471
|
+
if (!root) return null;
|
|
472
|
+
|
|
473
|
+
const normalized = normalizeCodeContextRelPath(relRaw);
|
|
474
|
+
if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
|
|
475
|
+
|
|
476
|
+
const rootDir = path.resolve(root.dir);
|
|
477
|
+
const resolved = path.resolve(rootDir, normalized);
|
|
478
|
+
const relativeToRoot = path.relative(rootDir, resolved);
|
|
479
|
+
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
id: makeCodeContextFileId(root.id, normalized),
|
|
484
|
+
root,
|
|
485
|
+
relPath: normalized,
|
|
486
|
+
absolutePath: resolved,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function resolveCodeContextFilePath(fileIdOrPath: string): string | null {
|
|
491
|
+
return getCodeContextFileRef(fileIdOrPath)?.absolutePath ?? null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function codeContextRelPath(fileIdOrPath: string): string {
|
|
495
|
+
return getCodeContextFileRef(fileIdOrPath)?.relPath ?? fileIdOrPath;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function codeContextDisplayPath(fileIdOrPath: string): string {
|
|
499
|
+
const ref = getCodeContextFileRef(fileIdOrPath);
|
|
500
|
+
if (!ref) return fileIdOrPath;
|
|
501
|
+
return `${ref.root.name}/${ref.relPath}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function codeContextFilterText(fileIdOrPath: string): string {
|
|
505
|
+
const ref = getCodeContextFileRef(fileIdOrPath);
|
|
506
|
+
if (!ref) return fileIdOrPath;
|
|
507
|
+
return `${ref.id} ${ref.root.name} ${ref.relPath}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function getVisibleFilesForCodeContextRoot(
|
|
511
|
+
root: CodeContextRootConfig,
|
|
512
|
+
): Promise<Set<string>> {
|
|
513
|
+
try {
|
|
514
|
+
return new Set(await walkDir(root.dir));
|
|
515
|
+
} catch {
|
|
516
|
+
return new Set();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function getCodeContextRootsTree() {
|
|
521
|
+
const roots = [];
|
|
522
|
+
for (const root of CODE_CONTEXT_ROOTS) {
|
|
523
|
+
const files = [...(await getVisibleFilesForCodeContextRoot(root))].sort(
|
|
524
|
+
(a, b) => a.localeCompare(b),
|
|
525
|
+
);
|
|
526
|
+
roots.push({ id: root.id, name: root.name, files });
|
|
527
|
+
}
|
|
528
|
+
return { roots };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function getScopedCodeContextFiles(
|
|
532
|
+
selectedFiles: unknown,
|
|
533
|
+
): Promise<string[]> {
|
|
534
|
+
if (CODE_CONTEXT_ROOTS.length === 0 || !Array.isArray(selectedFiles)) {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const selected = selectedFiles
|
|
539
|
+
.filter((file): file is string => typeof file === "string")
|
|
540
|
+
.map((file) => getCodeContextFileRef(file))
|
|
541
|
+
.filter((file): file is CodeContextFileRef => Boolean(file));
|
|
542
|
+
|
|
543
|
+
if (selected.length === 0) return [];
|
|
544
|
+
|
|
545
|
+
const visibleByRoot = new Map<string, Set<string>>();
|
|
546
|
+
for (const root of CODE_CONTEXT_ROOTS.filter((root) =>
|
|
547
|
+
selected.some((file) => file.root.id === root.id),
|
|
548
|
+
)) {
|
|
549
|
+
visibleByRoot.set(root.id, await getVisibleFilesForCodeContextRoot(root));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return uniqueStrings(
|
|
553
|
+
selected
|
|
554
|
+
.filter((file) => visibleByRoot.get(file.root.id)?.has(file.relPath))
|
|
555
|
+
.map((file) => file.id),
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function escapeRegExp(value: string): string {
|
|
560
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function clampNumber(
|
|
564
|
+
value: unknown,
|
|
565
|
+
min: number,
|
|
566
|
+
max: number,
|
|
567
|
+
fallback: number,
|
|
568
|
+
) {
|
|
569
|
+
const n =
|
|
570
|
+
typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
571
|
+
return Math.min(Math.max(Math.floor(n), min), max);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function trimToolLine(line: string): string {
|
|
575
|
+
return line.length > MAX_CODE_TOOL_LINE_CHARS
|
|
576
|
+
? `${line.slice(0, MAX_CODE_TOOL_LINE_CHARS)}…`
|
|
577
|
+
: line;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function codePathMatchesFilter(relPath: string, filter?: string): boolean {
|
|
581
|
+
const normalizedFilter = filter?.trim().replaceAll("\\", "/").toLowerCase();
|
|
582
|
+
if (!normalizedFilter) return true;
|
|
583
|
+
return codeContextFilterText(relPath)
|
|
584
|
+
.toLowerCase()
|
|
585
|
+
.includes(normalizedFilter);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function formatCodeFileTree(paths: string[], limit: number): string {
|
|
589
|
+
const lines: string[] = [];
|
|
590
|
+
const seen = new Set<string>();
|
|
591
|
+
for (const fileId of paths.slice(0, limit)) {
|
|
592
|
+
const ref = getCodeContextFileRef(fileId);
|
|
593
|
+
const rootLabel = ref?.root.name ?? "Code Context";
|
|
594
|
+
const relPath = ref?.relPath ?? fileId;
|
|
595
|
+
const rootKey = `root:${rootLabel}`;
|
|
596
|
+
if (!seen.has(rootKey)) {
|
|
597
|
+
seen.add(rootKey);
|
|
598
|
+
lines.push(`${rootLabel}/`);
|
|
599
|
+
}
|
|
600
|
+
const parts = relPath.split("/");
|
|
601
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
602
|
+
const dirPath = `${rootLabel}:${parts.slice(0, i + 1).join("/")}`;
|
|
603
|
+
if (seen.has(dirPath)) continue;
|
|
604
|
+
seen.add(dirPath);
|
|
605
|
+
lines.push(`${" ".repeat(i + 1)}${parts[i]}/`);
|
|
606
|
+
}
|
|
607
|
+
lines.push(`${" ".repeat(Math.max(1, parts.length))}${parts.at(-1)}`);
|
|
608
|
+
}
|
|
609
|
+
return lines.join("\n");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function readCodeTextWithinLimit(
|
|
613
|
+
relPath: string,
|
|
614
|
+
maxBytes = MAX_CODE_TOOL_READ_BYTES,
|
|
615
|
+
): Promise<{ content: string; truncated: boolean; byteLength: number }> {
|
|
616
|
+
const resolved = resolveCodeContextFilePath(relPath);
|
|
617
|
+
if (!resolved) throw new Error("Access denied");
|
|
618
|
+
const buffer = await fs.readFile(resolved);
|
|
619
|
+
const truncated = buffer.byteLength > maxBytes;
|
|
620
|
+
return {
|
|
621
|
+
content: buffer.slice(0, maxBytes).toString("utf-8"),
|
|
622
|
+
truncated,
|
|
623
|
+
byteLength: buffer.byteLength,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function searchCodeInFiles(
|
|
628
|
+
scopedFiles: string[],
|
|
629
|
+
params: {
|
|
630
|
+
query: string;
|
|
631
|
+
isRegex?: boolean;
|
|
632
|
+
caseSensitive?: boolean;
|
|
633
|
+
pathFilter?: string;
|
|
634
|
+
maxResults?: number;
|
|
635
|
+
contextLines?: number;
|
|
636
|
+
wholeWord?: boolean;
|
|
637
|
+
},
|
|
638
|
+
) {
|
|
639
|
+
const query = params.query.trim();
|
|
640
|
+
if (!query) return { type: "error", error: "query is required" };
|
|
641
|
+
if (query.length > 500) {
|
|
642
|
+
return { type: "error", error: "query is too long" };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const maxResults = clampNumber(
|
|
646
|
+
params.maxResults,
|
|
647
|
+
1,
|
|
648
|
+
MAX_CODE_TOOL_SEARCH_RESULTS,
|
|
649
|
+
50,
|
|
650
|
+
);
|
|
651
|
+
const contextLines = clampNumber(
|
|
652
|
+
params.contextLines,
|
|
653
|
+
0,
|
|
654
|
+
MAX_CODE_TOOL_CONTEXT_LINES,
|
|
655
|
+
1,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
let matcher: RegExp;
|
|
659
|
+
try {
|
|
660
|
+
const source = params.isRegex ? query : escapeRegExp(query);
|
|
661
|
+
const wholeWord =
|
|
662
|
+
params.wholeWord && /^[A-Za-z_$][\w$]*$/.test(query)
|
|
663
|
+
? `\\b${source}\\b`
|
|
664
|
+
: source;
|
|
665
|
+
matcher = new RegExp(wholeWord, params.caseSensitive ? "" : "i");
|
|
666
|
+
} catch (err: any) {
|
|
667
|
+
return { type: "error", error: err?.message || "Invalid regex" };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const candidateFiles = scopedFiles.filter((file) =>
|
|
671
|
+
codePathMatchesFilter(file, params.pathFilter),
|
|
672
|
+
);
|
|
673
|
+
const matches: Array<{
|
|
674
|
+
path: string;
|
|
675
|
+
displayPath: string;
|
|
676
|
+
line: number;
|
|
677
|
+
lineText: string;
|
|
678
|
+
before: Array<{ line: number; text: string }>;
|
|
679
|
+
after: Array<{ line: number; text: string }>;
|
|
680
|
+
}> = [];
|
|
681
|
+
const truncatedFiles: string[] = [];
|
|
682
|
+
const unreadableFiles: string[] = [];
|
|
683
|
+
|
|
684
|
+
for (const relPath of candidateFiles) {
|
|
685
|
+
let content: string;
|
|
686
|
+
try {
|
|
687
|
+
const read = await readCodeTextWithinLimit(
|
|
688
|
+
relPath,
|
|
689
|
+
MAX_CODE_TOOL_SEARCH_FILE_BYTES,
|
|
690
|
+
);
|
|
691
|
+
content = read.content;
|
|
692
|
+
if (read.truncated) truncatedFiles.push(relPath);
|
|
693
|
+
} catch {
|
|
694
|
+
unreadableFiles.push(relPath);
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const lines = content.split(/\r?\n/);
|
|
699
|
+
for (let i = 0; i < lines.length; i++) {
|
|
700
|
+
if (!matcher.test(lines[i])) continue;
|
|
701
|
+
matcher.lastIndex = 0;
|
|
702
|
+
const beforeStart = Math.max(0, i - contextLines);
|
|
703
|
+
const afterEnd = Math.min(lines.length, i + contextLines + 1);
|
|
704
|
+
matches.push({
|
|
705
|
+
path: relPath,
|
|
706
|
+
displayPath: codeContextDisplayPath(relPath),
|
|
707
|
+
line: i + 1,
|
|
708
|
+
lineText: trimToolLine(lines[i]),
|
|
709
|
+
before: lines.slice(beforeStart, i).map((text, offset) => ({
|
|
710
|
+
line: beforeStart + offset + 1,
|
|
711
|
+
text: trimToolLine(text),
|
|
712
|
+
})),
|
|
713
|
+
after: lines.slice(i + 1, afterEnd).map((text, offset) => ({
|
|
714
|
+
line: i + offset + 2,
|
|
715
|
+
text: trimToolLine(text),
|
|
716
|
+
})),
|
|
717
|
+
});
|
|
718
|
+
if (matches.length >= maxResults) {
|
|
719
|
+
return {
|
|
720
|
+
query,
|
|
721
|
+
searchedFiles: candidateFiles.length,
|
|
722
|
+
truncated: true,
|
|
723
|
+
truncatedFiles,
|
|
724
|
+
unreadableFiles,
|
|
725
|
+
matches,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
query,
|
|
733
|
+
searchedFiles: candidateFiles.length,
|
|
734
|
+
truncated: false,
|
|
735
|
+
truncatedFiles,
|
|
736
|
+
unreadableFiles,
|
|
737
|
+
matches,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function classifyConfigFile(relPath: string): string | null {
|
|
742
|
+
const lower = codeContextRelPath(relPath).toLowerCase();
|
|
743
|
+
const base = lower.split("/").at(-1) || lower;
|
|
744
|
+
if (/^\.github\/workflows\/.+\.ya?ml$/.test(lower)) {
|
|
745
|
+
return "github-actions-workflow";
|
|
746
|
+
}
|
|
747
|
+
if (base === "action.yml" || base === "action.yaml") return "github-action";
|
|
748
|
+
if (base === "azure-pipelines.yml" || base === "azure-pipelines.yaml") {
|
|
749
|
+
return "azure-pipelines";
|
|
750
|
+
}
|
|
751
|
+
if (base === ".gitlab-ci.yml" || base === ".gitlab-ci.yaml") {
|
|
752
|
+
return "gitlab-ci";
|
|
753
|
+
}
|
|
754
|
+
if (base === "config.yml" && lower.includes(".circleci/")) {
|
|
755
|
+
return "circleci";
|
|
756
|
+
}
|
|
757
|
+
if (base === "jenkinsfile") return "jenkins";
|
|
758
|
+
if (lower.includes("buildkite") && lower.endsWith(".yml")) {
|
|
759
|
+
return "buildkite";
|
|
760
|
+
}
|
|
761
|
+
if (base === "package.json") return "package-json";
|
|
762
|
+
if (base === "dockerfile" || base.endsWith(".dockerfile")) {
|
|
763
|
+
return "dockerfile";
|
|
764
|
+
}
|
|
765
|
+
if (
|
|
766
|
+
base === "docker-compose.yml" ||
|
|
767
|
+
base === "docker-compose.yaml" ||
|
|
768
|
+
base === "compose.yml" ||
|
|
769
|
+
base === "compose.yaml"
|
|
770
|
+
) {
|
|
771
|
+
return "docker-compose";
|
|
772
|
+
}
|
|
773
|
+
if (lower.endsWith(".tf") || lower.endsWith(".tfvars")) return "terraform";
|
|
774
|
+
if (lower.includes("terraform/") && lower.endsWith(".hcl")) {
|
|
775
|
+
return "terraform-hcl";
|
|
776
|
+
}
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function summarizeConfigStructure(relPath: string, content: string) {
|
|
781
|
+
const relativePath = codeContextRelPath(relPath);
|
|
782
|
+
const lower = relativePath.toLowerCase();
|
|
783
|
+
if (lower.endsWith(".json")) {
|
|
784
|
+
try {
|
|
785
|
+
const parsed = JSON.parse(content);
|
|
786
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
787
|
+
const record = parsed as Record<string, unknown>;
|
|
788
|
+
return {
|
|
789
|
+
topLevelKeys: Object.keys(record).slice(0, 50),
|
|
790
|
+
scripts:
|
|
791
|
+
record.scripts && typeof record.scripts === "object"
|
|
792
|
+
? Object.keys(record.scripts as Record<string, unknown>).slice(
|
|
793
|
+
0,
|
|
794
|
+
50,
|
|
795
|
+
)
|
|
796
|
+
: undefined,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
} catch {
|
|
800
|
+
return { parseError: "Invalid JSON" };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (/\.ya?ml$/i.test(relativePath)) {
|
|
805
|
+
const topLevelKeys = uniqueStrings(
|
|
806
|
+
content
|
|
807
|
+
.split(/\r?\n/)
|
|
808
|
+
.map((line) => line.match(/^([A-Za-z0-9_.-]+):/)?.[1] || "")
|
|
809
|
+
.filter(Boolean),
|
|
810
|
+
).slice(0, 50);
|
|
811
|
+
return { topLevelKeys };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (/\.(tf|tfvars|hcl)$/i.test(relativePath)) {
|
|
815
|
+
const blocks = uniqueStrings(
|
|
816
|
+
content
|
|
817
|
+
.split(/\r?\n/)
|
|
818
|
+
.map(
|
|
819
|
+
(line) =>
|
|
820
|
+
line
|
|
821
|
+
.trim()
|
|
822
|
+
.match(
|
|
823
|
+
/^(terraform|provider|resource|module|variable|output|data|locals|backend)\b.*$/,
|
|
824
|
+
)?.[0],
|
|
825
|
+
)
|
|
826
|
+
.filter((line): line is string => Boolean(line)),
|
|
827
|
+
).slice(0, 60);
|
|
828
|
+
return { blocks };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return {};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function extractConfigHighlights(content: string) {
|
|
835
|
+
const interesting =
|
|
836
|
+
/\b(terraform\s+(init|plan|apply)|backend-config|azurerm|workflow_call|secrets:\s*inherit|uses:|runs-on:|permissions:|concurrency:|jobs:|steps:|env:|provider\s+"|backend\s+"|resource\s+")/i;
|
|
837
|
+
return content
|
|
838
|
+
.split(/\r?\n/)
|
|
839
|
+
.map((text, index) => ({ line: index + 1, text: trimToolLine(text) }))
|
|
840
|
+
.filter(({ text }) => interesting.test(text))
|
|
841
|
+
.slice(0, 30);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
845
|
+
if (CODE_CONTEXT_ROOTS.length === 0 || scopedCodeFiles.length === 0)
|
|
846
|
+
return {};
|
|
847
|
+
|
|
848
|
+
const files = uniqueStrings(scopedCodeFiles).sort((a, b) =>
|
|
849
|
+
a.localeCompare(b),
|
|
850
|
+
);
|
|
851
|
+
const fileSet = new Set(files);
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
listCodeFiles: tool({
|
|
855
|
+
description:
|
|
856
|
+
"List code-context files that are available to the model. This is read-only and restricted to files the user selected in Code Context, under configured code roots, after ignore rules are applied.",
|
|
857
|
+
inputSchema: z.object({
|
|
858
|
+
query: z
|
|
859
|
+
.string()
|
|
860
|
+
.optional()
|
|
861
|
+
.describe(
|
|
862
|
+
"Optional case-insensitive path substring to filter files.",
|
|
863
|
+
),
|
|
864
|
+
folder: z
|
|
865
|
+
.string()
|
|
866
|
+
.optional()
|
|
867
|
+
.describe(
|
|
868
|
+
"Optional folder prefix or path substring to narrow results.",
|
|
869
|
+
),
|
|
870
|
+
limit: z
|
|
871
|
+
.number()
|
|
872
|
+
.int()
|
|
873
|
+
.min(1)
|
|
874
|
+
.max(MAX_CODE_TOOL_LIST_RESULTS)
|
|
875
|
+
.optional(),
|
|
876
|
+
}),
|
|
877
|
+
execute: async ({ query, folder, limit }) => {
|
|
878
|
+
const cappedLimit = clampNumber(
|
|
879
|
+
limit,
|
|
880
|
+
1,
|
|
881
|
+
MAX_CODE_TOOL_LIST_RESULTS,
|
|
882
|
+
120,
|
|
883
|
+
);
|
|
884
|
+
const filtered = files.filter(
|
|
885
|
+
(file) =>
|
|
886
|
+
codePathMatchesFilter(file, query) &&
|
|
887
|
+
codePathMatchesFilter(file, folder),
|
|
888
|
+
);
|
|
889
|
+
const listed = filtered.slice(0, cappedLimit);
|
|
890
|
+
return {
|
|
891
|
+
scope: "selected-code-context-files",
|
|
892
|
+
totalAvailable: files.length,
|
|
893
|
+
totalMatched: filtered.length,
|
|
894
|
+
truncated: filtered.length > listed.length,
|
|
895
|
+
files: listed,
|
|
896
|
+
displayFiles: listed.map(codeContextDisplayPath),
|
|
897
|
+
tree: formatCodeFileTree(listed, cappedLimit),
|
|
898
|
+
};
|
|
899
|
+
},
|
|
900
|
+
}),
|
|
901
|
+
searchCode: tool({
|
|
902
|
+
description:
|
|
903
|
+
"Search text across available code-context files. Use this before opening many files. It cannot see ignored or unselected files.",
|
|
904
|
+
inputSchema: z.object({
|
|
905
|
+
query: z.string().describe("Text or regex to search for."),
|
|
906
|
+
isRegex: z
|
|
907
|
+
.boolean()
|
|
908
|
+
.optional()
|
|
909
|
+
.describe("Treat query as a JavaScript regular expression."),
|
|
910
|
+
caseSensitive: z.boolean().optional(),
|
|
911
|
+
pathFilter: z
|
|
912
|
+
.string()
|
|
913
|
+
.optional()
|
|
914
|
+
.describe(
|
|
915
|
+
"Optional path substring, e.g. .github/workflows or terraform.",
|
|
916
|
+
),
|
|
917
|
+
maxResults: z
|
|
918
|
+
.number()
|
|
919
|
+
.int()
|
|
920
|
+
.min(1)
|
|
921
|
+
.max(MAX_CODE_TOOL_SEARCH_RESULTS)
|
|
922
|
+
.optional(),
|
|
923
|
+
contextLines: z
|
|
924
|
+
.number()
|
|
925
|
+
.int()
|
|
926
|
+
.min(0)
|
|
927
|
+
.max(MAX_CODE_TOOL_CONTEXT_LINES)
|
|
928
|
+
.optional(),
|
|
929
|
+
}),
|
|
930
|
+
execute: async (params) => searchCodeInFiles(files, params),
|
|
931
|
+
}),
|
|
932
|
+
readCodeFile: tool({
|
|
933
|
+
description:
|
|
934
|
+
"Read one available code-context file by canonical path, optionally with a 1-based line range. Restricted to selected, non-ignored files.",
|
|
935
|
+
inputSchema: z.object({
|
|
936
|
+
path: z
|
|
937
|
+
.string()
|
|
938
|
+
.describe(
|
|
939
|
+
"Canonical path from listCodeFiles, e.g. root::src/file.ts. Legacy relative paths resolve against the first root.",
|
|
940
|
+
),
|
|
941
|
+
startLine: z.number().int().min(1).optional(),
|
|
942
|
+
endLine: z.number().int().min(1).optional(),
|
|
943
|
+
}),
|
|
944
|
+
execute: async ({ path: requestedPath, startLine, endLine }) => {
|
|
945
|
+
const ref = getCodeContextFileRef(requestedPath || "");
|
|
946
|
+
const fileId = ref?.id;
|
|
947
|
+
if (!ref || !fileId || !fileSet.has(fileId)) {
|
|
948
|
+
return {
|
|
949
|
+
type: "error",
|
|
950
|
+
error:
|
|
951
|
+
"File is not available. Use listCodeFiles first; only selected, non-ignored files can be read.",
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
const read = await readCodeTextWithinLimit(fileId);
|
|
957
|
+
const lines = read.content.split(/\r?\n/);
|
|
958
|
+
const totalLines = lines.length;
|
|
959
|
+
const first = clampNumber(startLine, 1, totalLines || 1, 1);
|
|
960
|
+
const last = clampNumber(
|
|
961
|
+
endLine,
|
|
962
|
+
first,
|
|
963
|
+
totalLines || first,
|
|
964
|
+
totalLines || first,
|
|
965
|
+
);
|
|
966
|
+
const numbered = lines
|
|
967
|
+
.slice(first - 1, last)
|
|
968
|
+
.map((line, index) => `${first + index}: ${line}`)
|
|
969
|
+
.join("\n");
|
|
970
|
+
return {
|
|
971
|
+
path: fileId,
|
|
972
|
+
displayPath: codeContextDisplayPath(fileId),
|
|
973
|
+
startLine: first,
|
|
974
|
+
endLine: last,
|
|
975
|
+
totalLines,
|
|
976
|
+
byteLength: read.byteLength,
|
|
977
|
+
truncated: read.truncated,
|
|
978
|
+
content: numbered,
|
|
979
|
+
};
|
|
980
|
+
} catch (err: any) {
|
|
981
|
+
return {
|
|
982
|
+
type: "error",
|
|
983
|
+
error: err?.message || "Could not read file",
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
}),
|
|
988
|
+
findCodeReferences: tool({
|
|
989
|
+
description:
|
|
990
|
+
"Find textual references to a symbol, function name, workflow name, file name, variable, or config key across available code-context files.",
|
|
991
|
+
inputSchema: z.object({
|
|
992
|
+
symbol: z.string().describe("Exact symbol or phrase to find."),
|
|
993
|
+
pathFilter: z.string().optional(),
|
|
994
|
+
caseSensitive: z.boolean().optional(),
|
|
995
|
+
maxResults: z
|
|
996
|
+
.number()
|
|
997
|
+
.int()
|
|
998
|
+
.min(1)
|
|
999
|
+
.max(MAX_CODE_TOOL_SEARCH_RESULTS)
|
|
1000
|
+
.optional(),
|
|
1001
|
+
contextLines: z
|
|
1002
|
+
.number()
|
|
1003
|
+
.int()
|
|
1004
|
+
.min(0)
|
|
1005
|
+
.max(MAX_CODE_TOOL_CONTEXT_LINES)
|
|
1006
|
+
.optional(),
|
|
1007
|
+
}),
|
|
1008
|
+
execute: async ({
|
|
1009
|
+
symbol,
|
|
1010
|
+
pathFilter,
|
|
1011
|
+
caseSensitive,
|
|
1012
|
+
maxResults,
|
|
1013
|
+
contextLines,
|
|
1014
|
+
}) =>
|
|
1015
|
+
searchCodeInFiles(files, {
|
|
1016
|
+
query: symbol,
|
|
1017
|
+
pathFilter,
|
|
1018
|
+
caseSensitive,
|
|
1019
|
+
maxResults,
|
|
1020
|
+
contextLines,
|
|
1021
|
+
wholeWord: true,
|
|
1022
|
+
}),
|
|
1023
|
+
}),
|
|
1024
|
+
inspectCiConfig: tool({
|
|
1025
|
+
description:
|
|
1026
|
+
"Find and summarize CI/CD, GitHub Actions, Docker, package script, and Terraform config files among the available code-context files.",
|
|
1027
|
+
inputSchema: z.object({
|
|
1028
|
+
pathFilter: z.string().optional(),
|
|
1029
|
+
maxFiles: z.number().int().min(1).max(30).optional(),
|
|
1030
|
+
}),
|
|
1031
|
+
execute: async ({ pathFilter, maxFiles }) => {
|
|
1032
|
+
const limit = clampNumber(maxFiles, 1, 30, 12);
|
|
1033
|
+
const candidates = files
|
|
1034
|
+
.map((file) => ({ file, kind: classifyConfigFile(file) }))
|
|
1035
|
+
.filter(
|
|
1036
|
+
(entry): entry is { file: string; kind: string } =>
|
|
1037
|
+
Boolean(entry.kind) &&
|
|
1038
|
+
codePathMatchesFilter(entry.file, pathFilter),
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
const inspected = [];
|
|
1042
|
+
for (const { file, kind } of candidates.slice(0, limit)) {
|
|
1043
|
+
try {
|
|
1044
|
+
const read = await readCodeTextWithinLimit(file);
|
|
1045
|
+
inspected.push({
|
|
1046
|
+
path: file,
|
|
1047
|
+
displayPath: codeContextDisplayPath(file),
|
|
1048
|
+
kind,
|
|
1049
|
+
truncated: read.truncated,
|
|
1050
|
+
structure: summarizeConfigStructure(file, read.content),
|
|
1051
|
+
highlights: extractConfigHighlights(read.content),
|
|
1052
|
+
});
|
|
1053
|
+
} catch (err: any) {
|
|
1054
|
+
inspected.push({
|
|
1055
|
+
path: file,
|
|
1056
|
+
displayPath: codeContextDisplayPath(file),
|
|
1057
|
+
kind,
|
|
1058
|
+
error: err?.message || "Could not inspect file",
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return {
|
|
1064
|
+
totalConfigFiles: candidates.length,
|
|
1065
|
+
truncated: candidates.length > inspected.length,
|
|
1066
|
+
files: inspected,
|
|
1067
|
+
};
|
|
1068
|
+
},
|
|
1069
|
+
}),
|
|
1070
|
+
runReadOnlyCodeCommand: tool({
|
|
1071
|
+
description:
|
|
1072
|
+
"Run a safe, whitelisted, read-only repository inspection command without a shell. Allowed commands: pwd, find, rg, git-ls-files, git-log, git-blame. Results are restricted to selected code-context files.",
|
|
1073
|
+
inputSchema: z.object({
|
|
1074
|
+
command: z.enum([
|
|
1075
|
+
"pwd",
|
|
1076
|
+
"find",
|
|
1077
|
+
"rg",
|
|
1078
|
+
"git-ls-files",
|
|
1079
|
+
"git-log",
|
|
1080
|
+
"git-blame",
|
|
1081
|
+
]),
|
|
1082
|
+
query: z
|
|
1083
|
+
.string()
|
|
1084
|
+
.optional()
|
|
1085
|
+
.describe("Required for rg. Optional for find."),
|
|
1086
|
+
path: z
|
|
1087
|
+
.string()
|
|
1088
|
+
.optional()
|
|
1089
|
+
.describe("Required for git-log and git-blame."),
|
|
1090
|
+
pathFilter: z.string().optional(),
|
|
1091
|
+
startLine: z.number().int().min(1).optional(),
|
|
1092
|
+
endLine: z.number().int().min(1).optional(),
|
|
1093
|
+
maxResults: z
|
|
1094
|
+
.number()
|
|
1095
|
+
.int()
|
|
1096
|
+
.min(1)
|
|
1097
|
+
.max(MAX_CODE_TOOL_LIST_RESULTS)
|
|
1098
|
+
.optional(),
|
|
1099
|
+
}),
|
|
1100
|
+
execute: async ({
|
|
1101
|
+
command,
|
|
1102
|
+
query,
|
|
1103
|
+
path: requestedPath,
|
|
1104
|
+
pathFilter,
|
|
1105
|
+
startLine,
|
|
1106
|
+
endLine,
|
|
1107
|
+
maxResults,
|
|
1108
|
+
}) => {
|
|
1109
|
+
const limit = clampNumber(
|
|
1110
|
+
maxResults,
|
|
1111
|
+
1,
|
|
1112
|
+
MAX_CODE_TOOL_LIST_RESULTS,
|
|
1113
|
+
100,
|
|
1114
|
+
);
|
|
1115
|
+
if (command === "pwd") {
|
|
1116
|
+
return {
|
|
1117
|
+
command: "pwd",
|
|
1118
|
+
roots: CODE_CONTEXT_ROOTS.map((root) => ({
|
|
1119
|
+
id: root.id,
|
|
1120
|
+
name: root.name,
|
|
1121
|
+
path: root.dir,
|
|
1122
|
+
})),
|
|
1123
|
+
note: "Read-only tools are still restricted to selected, non-ignored code-context files across these roots.",
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
if (command === "find") {
|
|
1127
|
+
const filtered = files
|
|
1128
|
+
.filter(
|
|
1129
|
+
(file) =>
|
|
1130
|
+
codePathMatchesFilter(file, query) &&
|
|
1131
|
+
codePathMatchesFilter(file, pathFilter),
|
|
1132
|
+
)
|
|
1133
|
+
.slice(0, limit);
|
|
1134
|
+
return {
|
|
1135
|
+
command: "find",
|
|
1136
|
+
files: filtered,
|
|
1137
|
+
displayFiles: filtered.map(codeContextDisplayPath),
|
|
1138
|
+
truncated: filtered.length >= limit,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
if (command === "rg") {
|
|
1142
|
+
if (!query?.trim())
|
|
1143
|
+
return { type: "error", error: "query is required for rg" };
|
|
1144
|
+
return searchCodeInFiles(files, {
|
|
1145
|
+
query,
|
|
1146
|
+
pathFilter,
|
|
1147
|
+
maxResults: Math.min(limit, MAX_CODE_TOOL_SEARCH_RESULTS),
|
|
1148
|
+
contextLines: 1,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (command === "git-ls-files") {
|
|
1153
|
+
try {
|
|
1154
|
+
const matchingFiles: string[] = [];
|
|
1155
|
+
const roots = CODE_CONTEXT_ROOTS.filter((root) =>
|
|
1156
|
+
files.some(
|
|
1157
|
+
(file) => getCodeContextFileRef(file)?.root.id === root.id,
|
|
1158
|
+
),
|
|
1159
|
+
);
|
|
1160
|
+
const errors: string[] = [];
|
|
1161
|
+
|
|
1162
|
+
for (const root of roots) {
|
|
1163
|
+
const result = await runGit(["ls-files", "-z"], {
|
|
1164
|
+
cwd: root.dir,
|
|
1165
|
+
allowFail: true,
|
|
1166
|
+
timeoutMs: 5000,
|
|
1167
|
+
maxBuffer: 1024 * 1024,
|
|
1168
|
+
});
|
|
1169
|
+
if (result.code !== 0) {
|
|
1170
|
+
errors.push(
|
|
1171
|
+
`${root.name}: ${result.stderr.trim() || "git ls-files failed"}`,
|
|
1172
|
+
);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
const tracked = new Set(
|
|
1176
|
+
result.stdout.split("\0").filter(Boolean),
|
|
1177
|
+
);
|
|
1178
|
+
for (const file of files) {
|
|
1179
|
+
const ref = getCodeContextFileRef(file);
|
|
1180
|
+
if (ref?.root.id !== root.id) continue;
|
|
1181
|
+
if (
|
|
1182
|
+
tracked.has(ref.relPath) &&
|
|
1183
|
+
codePathMatchesFilter(file, pathFilter)
|
|
1184
|
+
) {
|
|
1185
|
+
matchingFiles.push(file);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const filtered = matchingFiles.slice(0, limit);
|
|
1190
|
+
return {
|
|
1191
|
+
command: "git ls-files",
|
|
1192
|
+
files: filtered,
|
|
1193
|
+
displayFiles: filtered.map(codeContextDisplayPath),
|
|
1194
|
+
errors,
|
|
1195
|
+
truncated: filtered.length >= limit,
|
|
1196
|
+
};
|
|
1197
|
+
} catch (err: any) {
|
|
1198
|
+
return {
|
|
1199
|
+
type: "error",
|
|
1200
|
+
error: err?.message || "git ls-files failed",
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const fileRef = getCodeContextFileRef(requestedPath || "");
|
|
1206
|
+
const fileId = fileRef?.id;
|
|
1207
|
+
if (!fileRef || !fileId || !fileSet.has(fileId)) {
|
|
1208
|
+
return {
|
|
1209
|
+
type: "error",
|
|
1210
|
+
error:
|
|
1211
|
+
"path must be one of the selected, non-ignored code-context files",
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (command === "git-log") {
|
|
1216
|
+
try {
|
|
1217
|
+
const result = await runGit(
|
|
1218
|
+
[
|
|
1219
|
+
"log",
|
|
1220
|
+
"--oneline",
|
|
1221
|
+
"--decorate",
|
|
1222
|
+
`--max-count=${Math.min(limit, 50)}`,
|
|
1223
|
+
"--",
|
|
1224
|
+
fileRef.relPath,
|
|
1225
|
+
],
|
|
1226
|
+
{
|
|
1227
|
+
cwd: fileRef.root.dir,
|
|
1228
|
+
allowFail: true,
|
|
1229
|
+
timeoutMs: 5000,
|
|
1230
|
+
maxBuffer: 512 * 1024,
|
|
1231
|
+
},
|
|
1232
|
+
);
|
|
1233
|
+
return {
|
|
1234
|
+
command: "git log",
|
|
1235
|
+
path: fileId,
|
|
1236
|
+
displayPath: codeContextDisplayPath(fileId),
|
|
1237
|
+
output:
|
|
1238
|
+
result.stdout.trim() ||
|
|
1239
|
+
result.stderr.trim() ||
|
|
1240
|
+
"No git history found.",
|
|
1241
|
+
};
|
|
1242
|
+
} catch (err: any) {
|
|
1243
|
+
return { type: "error", error: err?.message || "git log failed" };
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
try {
|
|
1248
|
+
const args = ["blame", "--date=short"];
|
|
1249
|
+
if (startLine || endLine) {
|
|
1250
|
+
const first = Math.max(1, startLine ?? 1);
|
|
1251
|
+
const last = Math.max(first, endLine ?? first);
|
|
1252
|
+
args.push("-L", `${first},${last}`);
|
|
1253
|
+
}
|
|
1254
|
+
args.push("--", fileRef.relPath);
|
|
1255
|
+
const result = await runGit(args, {
|
|
1256
|
+
cwd: fileRef.root.dir,
|
|
1257
|
+
allowFail: true,
|
|
1258
|
+
timeoutMs: 5000,
|
|
1259
|
+
maxBuffer: 512 * 1024,
|
|
1260
|
+
});
|
|
1261
|
+
return {
|
|
1262
|
+
command: "git blame",
|
|
1263
|
+
path: fileId,
|
|
1264
|
+
displayPath: codeContextDisplayPath(fileId),
|
|
1265
|
+
output:
|
|
1266
|
+
result.stdout.trim() ||
|
|
1267
|
+
result.stderr.trim() ||
|
|
1268
|
+
"No git blame output.",
|
|
1269
|
+
};
|
|
1270
|
+
} catch (err: any) {
|
|
1271
|
+
return { type: "error", error: err?.message || "git blame failed" };
|
|
1272
|
+
}
|
|
1273
|
+
},
|
|
1274
|
+
}),
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function createGitDiffTools(ctx: GitDiffContextPayload): ToolSet {
|
|
1279
|
+
if (
|
|
1280
|
+
GIT_DIFF_ROOTS.length === 0 ||
|
|
1281
|
+
!Array.isArray(ctx.selectedFiles) ||
|
|
1282
|
+
ctx.selectedFiles.length === 0
|
|
1283
|
+
) {
|
|
1284
|
+
return {};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function loadScoped() {
|
|
1288
|
+
const scoped = await getScopedGitDiffContext(ctx);
|
|
1289
|
+
if (!scoped) {
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
"No selected git diff files are available for this root/range.",
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
return scoped;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function formatEntry(entry: ScopedGitDiffEntry) {
|
|
1298
|
+
return {
|
|
1299
|
+
path: entry.id,
|
|
1300
|
+
displayPath: entry.displayPath,
|
|
1301
|
+
relativePath: entry.path,
|
|
1302
|
+
oldPath: entry.oldPath,
|
|
1303
|
+
status: entry.status,
|
|
1304
|
+
additions: entry.additions,
|
|
1305
|
+
deletions: entry.deletions,
|
|
1306
|
+
binary: entry.binary,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return {
|
|
1311
|
+
listGitDiffFiles: tool({
|
|
1312
|
+
description:
|
|
1313
|
+
"List selected git diff files for the configured diff root/range. Restricted to files the user selected in Git Diff context.",
|
|
1314
|
+
inputSchema: z.object({
|
|
1315
|
+
query: z
|
|
1316
|
+
.string()
|
|
1317
|
+
.optional()
|
|
1318
|
+
.describe("Optional case-insensitive path/root/status filter."),
|
|
1319
|
+
status: z
|
|
1320
|
+
.enum(["A", "M", "D", "R", "C", "T", "U", "?"])
|
|
1321
|
+
.optional()
|
|
1322
|
+
.describe("Optional git status filter."),
|
|
1323
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
1324
|
+
}),
|
|
1325
|
+
execute: async ({ query, status, limit }) => {
|
|
1326
|
+
try {
|
|
1327
|
+
const scoped = await loadScoped();
|
|
1328
|
+
const filter = query?.trim().toLowerCase();
|
|
1329
|
+
const cappedLimit = clampNumber(limit, 1, 500, 200);
|
|
1330
|
+
const matched = scoped.entries.filter((entry) => {
|
|
1331
|
+
if (status && entry.status !== status) return false;
|
|
1332
|
+
if (!filter) return true;
|
|
1333
|
+
return `${entry.id} ${entry.displayPath} ${entry.status}`
|
|
1334
|
+
.toLowerCase()
|
|
1335
|
+
.includes(filter);
|
|
1336
|
+
});
|
|
1337
|
+
return {
|
|
1338
|
+
root: {
|
|
1339
|
+
id: scoped.root.id,
|
|
1340
|
+
name: scoped.root.name,
|
|
1341
|
+
},
|
|
1342
|
+
range: scoped.rangeLabel,
|
|
1343
|
+
totalSelected: scoped.entries.length,
|
|
1344
|
+
totalMatched: matched.length,
|
|
1345
|
+
truncated: matched.length > cappedLimit,
|
|
1346
|
+
files: matched.slice(0, cappedLimit).map(formatEntry),
|
|
1347
|
+
};
|
|
1348
|
+
} catch (err: any) {
|
|
1349
|
+
return { type: "error", error: err?.message || "diff list failed" };
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
}),
|
|
1353
|
+
readGitDiffFile: tool({
|
|
1354
|
+
description:
|
|
1355
|
+
"Read a selected git diff file as a patch, before blob, or after blob. Use patch for exact changed hunks; use before/after to compare full file versions.",
|
|
1356
|
+
inputSchema: z.object({
|
|
1357
|
+
path: z
|
|
1358
|
+
.string()
|
|
1359
|
+
.describe(
|
|
1360
|
+
"Canonical path from listGitDiffFiles, e.g. root::src/file.ts. Legacy relative paths resolve against the selected diff root.",
|
|
1361
|
+
),
|
|
1362
|
+
view: z
|
|
1363
|
+
.enum(["patch", "before", "after"])
|
|
1364
|
+
.optional()
|
|
1365
|
+
.describe("Which diff view to read. Defaults to patch."),
|
|
1366
|
+
startLine: z.number().int().min(1).optional(),
|
|
1367
|
+
endLine: z.number().int().min(1).optional(),
|
|
1368
|
+
}),
|
|
1369
|
+
execute: async ({ path: requestedPath, view, startLine, endLine }) => {
|
|
1370
|
+
try {
|
|
1371
|
+
const scoped = await loadScoped();
|
|
1372
|
+
const entry = findScopedGitDiffEntry(scoped, requestedPath);
|
|
1373
|
+
if (!entry) {
|
|
1374
|
+
return {
|
|
1375
|
+
type: "error",
|
|
1376
|
+
error: "File is not in the selected git diff context.",
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
const selectedView = view ?? "patch";
|
|
1380
|
+
const content = await readGitDiffView(scoped, entry, selectedView);
|
|
1381
|
+
const lines = content.split(/\r?\n/);
|
|
1382
|
+
const totalLines = lines.length;
|
|
1383
|
+
const first = clampNumber(startLine, 1, totalLines || 1, 1);
|
|
1384
|
+
const last = clampNumber(
|
|
1385
|
+
endLine,
|
|
1386
|
+
first,
|
|
1387
|
+
totalLines || first,
|
|
1388
|
+
totalLines || first,
|
|
1389
|
+
);
|
|
1390
|
+
const selectedLines = lines
|
|
1391
|
+
.slice(first - 1, last)
|
|
1392
|
+
.map((line, index) => `${first + index}: ${line}`)
|
|
1393
|
+
.join("\n");
|
|
1394
|
+
return {
|
|
1395
|
+
path: entry.id,
|
|
1396
|
+
displayPath: entry.displayPath,
|
|
1397
|
+
view: selectedView,
|
|
1398
|
+
status: entry.status,
|
|
1399
|
+
startLine: first,
|
|
1400
|
+
endLine: last,
|
|
1401
|
+
totalLines,
|
|
1402
|
+
content: selectedLines || "(empty)",
|
|
1403
|
+
};
|
|
1404
|
+
} catch (err: any) {
|
|
1405
|
+
return { type: "error", error: err?.message || "diff read failed" };
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
}),
|
|
1409
|
+
searchGitDiff: tool({
|
|
1410
|
+
description:
|
|
1411
|
+
"Search selected git diff patches and/or before/after blobs for exact text or a regular expression. Useful for finding specific changed symbols, config keys, or hunks.",
|
|
1412
|
+
inputSchema: z.object({
|
|
1413
|
+
query: z.string().min(1),
|
|
1414
|
+
view: z
|
|
1415
|
+
.enum(["patch", "before", "after", "all"])
|
|
1416
|
+
.optional()
|
|
1417
|
+
.describe("Which view to search. Defaults to patch."),
|
|
1418
|
+
regex: z.boolean().optional(),
|
|
1419
|
+
caseSensitive: z.boolean().optional(),
|
|
1420
|
+
pathFilter: z.string().optional(),
|
|
1421
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
1422
|
+
}),
|
|
1423
|
+
execute: async ({
|
|
1424
|
+
query,
|
|
1425
|
+
view,
|
|
1426
|
+
regex,
|
|
1427
|
+
caseSensitive,
|
|
1428
|
+
pathFilter,
|
|
1429
|
+
limit,
|
|
1430
|
+
}) => {
|
|
1431
|
+
try {
|
|
1432
|
+
const scoped = await loadScoped();
|
|
1433
|
+
const cappedLimit = clampNumber(limit, 1, 200, 80);
|
|
1434
|
+
const selectedView = view ?? "patch";
|
|
1435
|
+
const flags = caseSensitive ? "" : "i";
|
|
1436
|
+
const pattern = new RegExp(
|
|
1437
|
+
regex ? query : escapeRegExp(query),
|
|
1438
|
+
flags,
|
|
1439
|
+
);
|
|
1440
|
+
const filter = pathFilter?.trim().toLowerCase();
|
|
1441
|
+
const views =
|
|
1442
|
+
selectedView === "all"
|
|
1443
|
+
? (["patch", "before", "after"] as const)
|
|
1444
|
+
: ([selectedView] as const);
|
|
1445
|
+
const matches: Array<{
|
|
1446
|
+
path: string;
|
|
1447
|
+
displayPath: string;
|
|
1448
|
+
view: "patch" | "before" | "after";
|
|
1449
|
+
line: number;
|
|
1450
|
+
lineText: string;
|
|
1451
|
+
}> = [];
|
|
1452
|
+
|
|
1453
|
+
for (const entry of scoped.entries) {
|
|
1454
|
+
if (
|
|
1455
|
+
filter &&
|
|
1456
|
+
!`${entry.id} ${entry.displayPath}`.toLowerCase().includes(filter)
|
|
1457
|
+
) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
for (const currentView of views) {
|
|
1461
|
+
if (
|
|
1462
|
+
currentView === "before" &&
|
|
1463
|
+
(entry.status === "A" || entry.status === "?")
|
|
1464
|
+
) {
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (currentView === "after" && entry.status === "D") {
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
const content = await readGitDiffView(scoped, entry, currentView);
|
|
1471
|
+
const lines = content.split(/\r?\n/);
|
|
1472
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1473
|
+
pattern.lastIndex = 0;
|
|
1474
|
+
if (!pattern.test(lines[i])) continue;
|
|
1475
|
+
matches.push({
|
|
1476
|
+
path: entry.id,
|
|
1477
|
+
displayPath: entry.displayPath,
|
|
1478
|
+
view: currentView,
|
|
1479
|
+
line: i + 1,
|
|
1480
|
+
lineText: trimToolLine(lines[i]),
|
|
1481
|
+
});
|
|
1482
|
+
if (matches.length >= cappedLimit) {
|
|
1483
|
+
return {
|
|
1484
|
+
query,
|
|
1485
|
+
view: selectedView,
|
|
1486
|
+
totalMatches: matches.length,
|
|
1487
|
+
truncated: true,
|
|
1488
|
+
matches,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
return {
|
|
1496
|
+
query,
|
|
1497
|
+
view: selectedView,
|
|
1498
|
+
totalMatches: matches.length,
|
|
1499
|
+
truncated: false,
|
|
1500
|
+
matches,
|
|
1501
|
+
};
|
|
1502
|
+
} catch (err: any) {
|
|
1503
|
+
return { type: "error", error: err?.message || "diff search failed" };
|
|
1504
|
+
}
|
|
1505
|
+
},
|
|
1506
|
+
}),
|
|
1507
|
+
summarizeGitDiff: tool({
|
|
1508
|
+
description:
|
|
1509
|
+
"Summarize the selected git diff context with totals by status and additions/deletions. Use this before deeper reads when multiple files are selected.",
|
|
1510
|
+
inputSchema: z.object({
|
|
1511
|
+
includeFiles: z.boolean().optional(),
|
|
1512
|
+
}),
|
|
1513
|
+
execute: async ({ includeFiles }) => {
|
|
1514
|
+
try {
|
|
1515
|
+
const scoped = await loadScoped();
|
|
1516
|
+
const byStatus: Record<string, number> = {};
|
|
1517
|
+
let additions = 0;
|
|
1518
|
+
let deletions = 0;
|
|
1519
|
+
let binaryFiles = 0;
|
|
1520
|
+
for (const entry of scoped.entries) {
|
|
1521
|
+
byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1;
|
|
1522
|
+
additions += entry.additions;
|
|
1523
|
+
deletions += entry.deletions;
|
|
1524
|
+
if (entry.binary) binaryFiles += 1;
|
|
1525
|
+
}
|
|
1526
|
+
return {
|
|
1527
|
+
root: {
|
|
1528
|
+
id: scoped.root.id,
|
|
1529
|
+
name: scoped.root.name,
|
|
1530
|
+
},
|
|
1531
|
+
range: scoped.rangeLabel,
|
|
1532
|
+
filesChanged: scoped.entries.length,
|
|
1533
|
+
additions,
|
|
1534
|
+
deletions,
|
|
1535
|
+
binaryFiles,
|
|
1536
|
+
byStatus,
|
|
1537
|
+
...(includeFiles
|
|
1538
|
+
? { files: scoped.entries.map(formatEntry).slice(0, 200) }
|
|
1539
|
+
: {}),
|
|
1540
|
+
};
|
|
1541
|
+
} catch (err: any) {
|
|
1542
|
+
return {
|
|
1543
|
+
type: "error",
|
|
1544
|
+
error: err?.message || "diff summary failed",
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
}),
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
347
1551
|
|
|
348
1552
|
// ─── AI Providers (Vercel AI SDK) ────────────────────────
|
|
349
1553
|
// Set the provider + model in .env. Supports: openai, google, anthropic
|
|
@@ -998,6 +2202,7 @@ app.patch("/api/questions/:id", async (req, res) => {
|
|
|
998
2202
|
delete q.gitDiffContext;
|
|
999
2203
|
} else if (gdc && typeof gdc === "object") {
|
|
1000
2204
|
q.gitDiffContext = {
|
|
2205
|
+
rootId: String(gdc.rootId || ""),
|
|
1001
2206
|
baseRef: String(gdc.baseRef || ""),
|
|
1002
2207
|
headRef: String(gdc.headRef || ""),
|
|
1003
2208
|
mode:
|
|
@@ -1984,16 +3189,19 @@ app.post("/api/chat", async (req, res) => {
|
|
|
1984
3189
|
}
|
|
1985
3190
|
}
|
|
1986
3191
|
|
|
3192
|
+
const scopedCodeContextFiles =
|
|
3193
|
+
await getScopedCodeContextFiles(codeContextFiles);
|
|
3194
|
+
|
|
1987
3195
|
// Code-context files from the project directory
|
|
1988
|
-
if (
|
|
1989
|
-
for (const filePath of
|
|
1990
|
-
const
|
|
1991
|
-
|
|
1992
|
-
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
|
|
3196
|
+
if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
|
|
3197
|
+
for (const filePath of scopedCodeContextFiles) {
|
|
3198
|
+
const ref = getCodeContextFileRef(filePath);
|
|
3199
|
+
if (!ref) continue;
|
|
1993
3200
|
fileRegistry.set(
|
|
1994
3201
|
`code:${filePath}`,
|
|
1995
|
-
makeCodeReferenceFileEntry(
|
|
1996
|
-
|
|
3202
|
+
makeCodeReferenceFileEntry(
|
|
3203
|
+
`[code:${ref.root.name}] ${ref.relPath}`,
|
|
3204
|
+
() => fs.readFile(ref.absolutePath, "utf-8"),
|
|
1997
3205
|
),
|
|
1998
3206
|
);
|
|
1999
3207
|
}
|
|
@@ -2029,16 +3237,27 @@ For image files, readFile returns visual image data so you can inspect what is v
|
|
|
2029
3237
|
|
|
2030
3238
|
if (codeFilePaths.length > 0) {
|
|
2031
3239
|
system += `
|
|
3240
|
+
--- Code Context Exploration Tools ---
|
|
3241
|
+
You also have read-only code tools for the selected Code Context files:
|
|
3242
|
+
- listCodeFiles: see the selected, non-ignored file tree.
|
|
3243
|
+
- searchCode: search exact text or regex across selected files.
|
|
3244
|
+
- readCodeFile: open a selected file, optionally by line range.
|
|
3245
|
+
- findCodeReferences: trace textual usages of a symbol, config key, workflow, or file name.
|
|
3246
|
+
- inspectCiConfig: summarize CI/CD, package, Docker, and Terraform config among selected files.
|
|
3247
|
+
- runReadOnlyCodeCommand: safe whitelisted equivalents for pwd/find/rg/git-ls-files/git-log/git-blame.
|
|
3248
|
+
|
|
3249
|
+
These tools cannot access files outside the configured code-context roots, files hidden by ignore rules, or files the user did not select. File IDs are root-qualified like "root::relative/path". If something is missing, ask the user to select more code context or adjust ignore settings.
|
|
3250
|
+
|
|
2032
3251
|
--- Linking Code Files in Your Response ---
|
|
2033
3252
|
When you mention or reference one of the **[code]** files above in your response text, format it as a clickable link so the user can open it directly:
|
|
2034
3253
|
|
|
2035
|
-
[DisplayText](coderef://relative/path/to/file)
|
|
3254
|
+
[DisplayText](coderef://root::relative/path/to/file)
|
|
2036
3255
|
|
|
2037
3256
|
Examples:
|
|
2038
|
-
[EmployeesController](coderef://src/Controllers/EmployeesController.cs)
|
|
2039
|
-
[SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
|
|
3257
|
+
[EmployeesController](coderef://backend::src/Controllers/EmployeesController.cs)
|
|
3258
|
+
[SettleDeferredPaymentCaseRequest](coderef://api::src/Requests/SettleDeferredPaymentCaseRequest.cs)
|
|
2040
3259
|
|
|
2041
|
-
Use this for class names, method names, or any mention of a specific file from the code context. The display text should be the class, file, or concept name — not the raw path.
|
|
3260
|
+
Use this for class names, method names, or any mention of a specific file from the code context. The href must use the exact code file id shown above without the "code:" prefix. The display text should be the class, file, or concept name — not the raw path.
|
|
2042
3261
|
Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
2043
3262
|
}
|
|
2044
3263
|
|
|
@@ -2160,6 +3379,8 @@ Examples (illustrative only — use real ids and names from the list above):
|
|
|
2160
3379
|
readFile: createReadFileTool(fileRegistry),
|
|
2161
3380
|
}
|
|
2162
3381
|
: {}),
|
|
3382
|
+
...createCodeContextTools(scopedCodeContextFiles),
|
|
3383
|
+
...createGitDiffTools(gitDiffContext || {}),
|
|
2163
3384
|
},
|
|
2164
3385
|
stopWhen: stepCountIs(selectedResponseProfile.maxSteps),
|
|
2165
3386
|
});
|
|
@@ -2359,6 +3580,24 @@ function hasIgnoredExtension(fileNameLower: string): boolean {
|
|
|
2359
3580
|
return false;
|
|
2360
3581
|
}
|
|
2361
3582
|
|
|
3583
|
+
function isCodeContextPathAllowed(relPath: string): boolean {
|
|
3584
|
+
const normalized = normalizeCodeContextRelPath(relPath);
|
|
3585
|
+
if (!normalized) return false;
|
|
3586
|
+
|
|
3587
|
+
const parts = normalized.split("/");
|
|
3588
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
3589
|
+
const prefix = parts.slice(0, i + 1).join("/");
|
|
3590
|
+
if (IGNORE_DIRS.has(normalizeName(parts[i])) || shouldIgnorePath(prefix)) {
|
|
3591
|
+
return false;
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
const fileName = parts.at(-1)?.toLowerCase() ?? "";
|
|
3596
|
+
if (!fileName || IGNORE_FILES.has(fileName)) return false;
|
|
3597
|
+
if (shouldIgnorePath(normalized)) return false;
|
|
3598
|
+
return !hasIgnoredExtension(fileName);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
2362
3601
|
async function walkDir(dir: string, prefix = ""): Promise<string[]> {
|
|
2363
3602
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
2364
3603
|
const files: string[] = [];
|
|
@@ -2380,30 +3619,34 @@ async function walkDir(dir: string, prefix = ""): Promise<string[]> {
|
|
|
2380
3619
|
}
|
|
2381
3620
|
|
|
2382
3621
|
app.get("/api/code-context/tree", async (_req, res) => {
|
|
2383
|
-
if (
|
|
3622
|
+
if (CODE_CONTEXT_ROOTS.length === 0) return res.json({ roots: [] });
|
|
2384
3623
|
try {
|
|
2385
|
-
|
|
2386
|
-
res.json(files);
|
|
3624
|
+
res.json(await getCodeContextRootsTree());
|
|
2387
3625
|
} catch {
|
|
2388
|
-
res.json([]);
|
|
3626
|
+
res.json({ roots: [] });
|
|
2389
3627
|
}
|
|
2390
3628
|
});
|
|
2391
3629
|
|
|
2392
3630
|
app.get("/api/code-context/file", async (req, res) => {
|
|
2393
|
-
if (
|
|
2394
|
-
return res.status(400).json({ error: "No code context
|
|
3631
|
+
if (CODE_CONTEXT_ROOTS.length === 0)
|
|
3632
|
+
return res.status(400).json({ error: "No code context roots" });
|
|
2395
3633
|
const filePath = req.query.path as string;
|
|
2396
3634
|
if (!filePath) return res.status(400).json({ error: "Path required" });
|
|
2397
3635
|
|
|
2398
|
-
const
|
|
2399
|
-
|
|
2400
|
-
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) {
|
|
3636
|
+
const ref = getCodeContextFileRef(filePath);
|
|
3637
|
+
if (!ref) {
|
|
2401
3638
|
return res.status(403).json({ error: "Access denied" });
|
|
2402
3639
|
}
|
|
2403
3640
|
|
|
2404
3641
|
try {
|
|
2405
|
-
const content = await fs.readFile(
|
|
2406
|
-
res.json({
|
|
3642
|
+
const content = await fs.readFile(ref.absolutePath, "utf-8");
|
|
3643
|
+
res.json({
|
|
3644
|
+
path: ref.id,
|
|
3645
|
+
rootId: ref.root.id,
|
|
3646
|
+
rootName: ref.root.name,
|
|
3647
|
+
relativePath: ref.relPath,
|
|
3648
|
+
content,
|
|
3649
|
+
});
|
|
2407
3650
|
} catch {
|
|
2408
3651
|
res.status(404).json({ error: "File not found" });
|
|
2409
3652
|
}
|
|
@@ -2416,7 +3659,108 @@ app.get("/api/code-context/file", async (req, res) => {
|
|
|
2416
3659
|
// or before/after blobs are pulled lazily via the readFile tool using ids of
|
|
2417
3660
|
// the form gitdiff:patch:<path> / gitdiff:before:<path> / gitdiff:after:<path>.
|
|
2418
3661
|
|
|
2419
|
-
|
|
3662
|
+
function resolveGitDiffRoots(): CodeContextRootConfig[] {
|
|
3663
|
+
const specs = [
|
|
3664
|
+
...(process.env.GIT_DIFF_DIR
|
|
3665
|
+
? [
|
|
3666
|
+
process.env.GIT_DIFF_NAME
|
|
3667
|
+
? `${process.env.GIT_DIFF_NAME}=${process.env.GIT_DIFF_DIR}`
|
|
3668
|
+
: process.env.GIT_DIFF_DIR,
|
|
3669
|
+
]
|
|
3670
|
+
: []),
|
|
3671
|
+
...splitCodeContextRootEnv(process.env.GIT_DIFF_DIRS),
|
|
3672
|
+
];
|
|
3673
|
+
|
|
3674
|
+
if (specs.length === 0) {
|
|
3675
|
+
return CODE_CONTEXT_ROOTS.map((root) => ({ ...root }));
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
const seenDirs = new Set<string>();
|
|
3679
|
+
const usedNames = new Map<string, number>();
|
|
3680
|
+
const usedIds = new Set<string>();
|
|
3681
|
+
const roots: CodeContextRootConfig[] = [];
|
|
3682
|
+
|
|
3683
|
+
for (const spec of specs) {
|
|
3684
|
+
const parsed = parseCodeContextRootSpec(spec);
|
|
3685
|
+
if (!parsed) continue;
|
|
3686
|
+
const dir = path.resolve(parsed.dir);
|
|
3687
|
+
if (seenDirs.has(dir)) continue;
|
|
3688
|
+
seenDirs.add(dir);
|
|
3689
|
+
const fallbackName = path.basename(dir) || "Git Diff";
|
|
3690
|
+
const name = uniqueCodeContextRootName(
|
|
3691
|
+
parsed.name || fallbackName,
|
|
3692
|
+
usedNames,
|
|
3693
|
+
);
|
|
3694
|
+
const id = uniqueCodeContextRootId(slugForCodeContextRoot(name), usedIds);
|
|
3695
|
+
roots.push({ id, name, dir });
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
return roots;
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
const GIT_DIFF_ROOTS = resolveGitDiffRoots();
|
|
3702
|
+
const GIT_DIFF_ROOT_BY_ID = new Map(
|
|
3703
|
+
GIT_DIFF_ROOTS.map((root) => [root.id, root]),
|
|
3704
|
+
);
|
|
3705
|
+
const GIT_DIFF_DIR = GIT_DIFF_ROOTS[0]?.dir ?? "";
|
|
3706
|
+
|
|
3707
|
+
function getGitDiffPrimaryRoot(): CodeContextRootConfig | null {
|
|
3708
|
+
return GIT_DIFF_ROOTS[0] ?? null;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
function getGitDiffRoot(rootId?: string | null): CodeContextRootConfig | null {
|
|
3712
|
+
const normalized = rootId?.trim();
|
|
3713
|
+
if (normalized) return GIT_DIFF_ROOT_BY_ID.get(normalized) ?? null;
|
|
3714
|
+
return getGitDiffPrimaryRoot();
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
function makeGitDiffFileId(rootId: string, relPath: string): string {
|
|
3718
|
+
return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${relPath}`;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
interface GitDiffFileRef {
|
|
3722
|
+
id: string;
|
|
3723
|
+
root: CodeContextRootConfig;
|
|
3724
|
+
relPath: string;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
function getGitDiffFileRef(
|
|
3728
|
+
fileIdOrPath: string,
|
|
3729
|
+
fallbackRootId?: string,
|
|
3730
|
+
): GitDiffFileRef | null {
|
|
3731
|
+
if (GIT_DIFF_ROOTS.length === 0) return null;
|
|
3732
|
+
const raw = fileIdOrPath
|
|
3733
|
+
.replace(/^gitdiff:(patch|before|after):/, "")
|
|
3734
|
+
.replaceAll("\\", "/")
|
|
3735
|
+
.trim();
|
|
3736
|
+
if (!raw || raw.includes("\0")) return null;
|
|
3737
|
+
|
|
3738
|
+
const sepIdx = raw.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
|
|
3739
|
+
const root =
|
|
3740
|
+
sepIdx > 0
|
|
3741
|
+
? GIT_DIFF_ROOT_BY_ID.get(raw.slice(0, sepIdx))
|
|
3742
|
+
: getGitDiffRoot(fallbackRootId);
|
|
3743
|
+
const relRaw =
|
|
3744
|
+
sepIdx > 0 ? raw.slice(sepIdx + CODE_CONTEXT_ROOT_SEPARATOR.length) : raw;
|
|
3745
|
+
if (!root) return null;
|
|
3746
|
+
|
|
3747
|
+
const normalized = normalizeCodeContextRelPath(relRaw);
|
|
3748
|
+
if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
|
|
3749
|
+
|
|
3750
|
+
return {
|
|
3751
|
+
id: makeGitDiffFileId(root.id, normalized),
|
|
3752
|
+
root,
|
|
3753
|
+
relPath: normalized,
|
|
3754
|
+
};
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
function getGitDiffRootSummaries() {
|
|
3758
|
+
return GIT_DIFF_ROOTS.map((root) => ({
|
|
3759
|
+
id: root.id,
|
|
3760
|
+
name: root.name,
|
|
3761
|
+
path: root.dir,
|
|
3762
|
+
}));
|
|
3763
|
+
}
|
|
2420
3764
|
|
|
2421
3765
|
// Hard caps so a runaway repo can never blow up the response or the model's context.
|
|
2422
3766
|
const MAX_DIFF_FILES = 500;
|
|
@@ -2437,6 +3781,21 @@ interface GitDiffFileEntry {
|
|
|
2437
3781
|
binary: boolean;
|
|
2438
3782
|
}
|
|
2439
3783
|
|
|
3784
|
+
interface ScopedGitDiffEntry extends GitDiffFileEntry {
|
|
3785
|
+
id: string;
|
|
3786
|
+
displayPath: string;
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
interface ScopedGitDiffContext {
|
|
3790
|
+
root: CodeContextRootConfig;
|
|
3791
|
+
base: string;
|
|
3792
|
+
head: string;
|
|
3793
|
+
headLabel: string;
|
|
3794
|
+
mode: GitDiffMode;
|
|
3795
|
+
rangeLabel: string;
|
|
3796
|
+
entries: ScopedGitDiffEntry[];
|
|
3797
|
+
}
|
|
3798
|
+
|
|
2440
3799
|
function runGit(
|
|
2441
3800
|
args: string[],
|
|
2442
3801
|
opts: {
|
|
@@ -2523,13 +3882,16 @@ function isValidRef(ref: string): boolean {
|
|
|
2523
3882
|
return REF_PATTERN.test(ref);
|
|
2524
3883
|
}
|
|
2525
3884
|
|
|
2526
|
-
async function resolveRef(
|
|
3885
|
+
async function resolveRef(
|
|
3886
|
+
root: CodeContextRootConfig,
|
|
3887
|
+
ref: string,
|
|
3888
|
+
): Promise<string | null> {
|
|
2527
3889
|
if (!isValidRef(ref)) return null;
|
|
2528
3890
|
try {
|
|
2529
3891
|
const { stdout } = await runGit(
|
|
2530
3892
|
["rev-parse", "--verify", `${ref}^{commit}`],
|
|
2531
3893
|
{
|
|
2532
|
-
cwd:
|
|
3894
|
+
cwd: root.dir,
|
|
2533
3895
|
},
|
|
2534
3896
|
);
|
|
2535
3897
|
return stdout.trim() || null;
|
|
@@ -2560,6 +3922,7 @@ function buildDiffRange(
|
|
|
2560
3922
|
}
|
|
2561
3923
|
|
|
2562
3924
|
async function getChangedFiles(
|
|
3925
|
+
root: CodeContextRootConfig,
|
|
2563
3926
|
base: string,
|
|
2564
3927
|
head: string,
|
|
2565
3928
|
mode: GitDiffMode,
|
|
@@ -2569,10 +3932,10 @@ async function getChangedFiles(
|
|
|
2569
3932
|
|
|
2570
3933
|
const nameStatus = await runGit(
|
|
2571
3934
|
[...baseArgs, "--name-status", "-z", ...range],
|
|
2572
|
-
{ cwd:
|
|
3935
|
+
{ cwd: root.dir },
|
|
2573
3936
|
);
|
|
2574
3937
|
const numStat = await runGit([...baseArgs, "--numstat", "-z", ...range], {
|
|
2575
|
-
cwd:
|
|
3938
|
+
cwd: root.dir,
|
|
2576
3939
|
});
|
|
2577
3940
|
|
|
2578
3941
|
// numstat with -z uses NUL between records AND between additions/deletions/path,
|
|
@@ -2630,7 +3993,7 @@ async function getChangedFiles(
|
|
|
2630
3993
|
try {
|
|
2631
3994
|
const untracked = await runGit(
|
|
2632
3995
|
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
2633
|
-
{ cwd:
|
|
3996
|
+
{ cwd: root.dir },
|
|
2634
3997
|
);
|
|
2635
3998
|
for (const filePath of untracked.stdout
|
|
2636
3999
|
.split("\0")
|
|
@@ -2648,10 +4011,17 @@ async function getChangedFiles(
|
|
|
2648
4011
|
}
|
|
2649
4012
|
}
|
|
2650
4013
|
|
|
2651
|
-
return entries
|
|
4014
|
+
return entries
|
|
4015
|
+
.filter(
|
|
4016
|
+
(entry) =>
|
|
4017
|
+
isCodeContextPathAllowed(entry.path) &&
|
|
4018
|
+
(!entry.oldPath || isCodeContextPathAllowed(entry.oldPath)),
|
|
4019
|
+
)
|
|
4020
|
+
.slice(0, MAX_DIFF_FILES);
|
|
2652
4021
|
}
|
|
2653
4022
|
|
|
2654
4023
|
async function getDiffPatch(
|
|
4024
|
+
root: CodeContextRootConfig,
|
|
2655
4025
|
base: string,
|
|
2656
4026
|
head: string,
|
|
2657
4027
|
mode: GitDiffMode,
|
|
@@ -2668,7 +4038,7 @@ async function getDiffPatch(
|
|
|
2668
4038
|
"--",
|
|
2669
4039
|
filePath,
|
|
2670
4040
|
],
|
|
2671
|
-
{ cwd:
|
|
4041
|
+
{ cwd: root.dir, maxBuffer: MAX_DIFF_FILE_BYTES * 4 },
|
|
2672
4042
|
);
|
|
2673
4043
|
if (stdout.length > MAX_DIFF_FILE_BYTES) {
|
|
2674
4044
|
return (
|
|
@@ -2679,10 +4049,16 @@ async function getDiffPatch(
|
|
|
2679
4049
|
return stdout;
|
|
2680
4050
|
}
|
|
2681
4051
|
|
|
2682
|
-
async function getFileAtRef(
|
|
4052
|
+
async function getFileAtRef(
|
|
4053
|
+
root: CodeContextRootConfig,
|
|
4054
|
+
ref: string,
|
|
4055
|
+
filePath: string,
|
|
4056
|
+
): Promise<string> {
|
|
2683
4057
|
if (ref === WORKING_TREE_SENTINEL) {
|
|
2684
|
-
const
|
|
2685
|
-
|
|
4058
|
+
const rootDir = path.resolve(root.dir);
|
|
4059
|
+
const resolved = path.resolve(path.join(rootDir, filePath));
|
|
4060
|
+
const relativeToRoot = path.relative(rootDir, resolved);
|
|
4061
|
+
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
|
|
2686
4062
|
throw new Error("Access denied");
|
|
2687
4063
|
}
|
|
2688
4064
|
const buf = await fs.readFile(resolved);
|
|
@@ -2695,7 +4071,7 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
|
|
|
2695
4071
|
return buf.toString("utf-8");
|
|
2696
4072
|
}
|
|
2697
4073
|
const { stdout } = await runGit(["show", `${ref}:${filePath}`], {
|
|
2698
|
-
cwd:
|
|
4074
|
+
cwd: root.dir,
|
|
2699
4075
|
maxBuffer: MAX_DIFF_FILE_BYTES * 4,
|
|
2700
4076
|
});
|
|
2701
4077
|
if (stdout.length > MAX_DIFF_FILE_BYTES) {
|
|
@@ -2707,92 +4083,224 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
|
|
|
2707
4083
|
return stdout;
|
|
2708
4084
|
}
|
|
2709
4085
|
|
|
2710
|
-
|
|
2711
|
-
|
|
4086
|
+
async function getScopedGitDiffContext(
|
|
4087
|
+
ctx: GitDiffContextPayload,
|
|
4088
|
+
): Promise<ScopedGitDiffContext | null> {
|
|
4089
|
+
if (GIT_DIFF_ROOTS.length === 0) return null;
|
|
4090
|
+
const selectedRaw = Array.isArray(ctx.selectedFiles)
|
|
4091
|
+
? ctx.selectedFiles.filter((p): p is string => typeof p === "string")
|
|
4092
|
+
: [];
|
|
4093
|
+
const inferredRootId =
|
|
4094
|
+
ctx.rootId ||
|
|
4095
|
+
selectedRaw
|
|
4096
|
+
.map((p) => getGitDiffFileRef(p)?.root.id)
|
|
4097
|
+
.find((id): id is string => Boolean(id));
|
|
4098
|
+
const root = getGitDiffRoot(inferredRootId || null);
|
|
4099
|
+
if (!root) return null;
|
|
4100
|
+
|
|
4101
|
+
const base = (ctx.baseRef || "").trim();
|
|
4102
|
+
if (!base || !isValidRef(base)) return null;
|
|
4103
|
+
const mode = parseDiffMode(ctx.mode);
|
|
4104
|
+
const headRaw = (ctx.headRef || "").trim();
|
|
4105
|
+
const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
|
|
4106
|
+
if (mode !== "working-tree" && (!head || !isValidRef(head))) return null;
|
|
4107
|
+
|
|
4108
|
+
const selectedRefs = selectedRaw
|
|
4109
|
+
.map((p) => getGitDiffFileRef(p, root.id))
|
|
4110
|
+
.filter((p): p is GitDiffFileRef => Boolean(p))
|
|
4111
|
+
.filter((p) => p.root.id === root.id);
|
|
4112
|
+
if (selectedRefs.length === 0) return null;
|
|
4113
|
+
|
|
4114
|
+
let changedFiles: GitDiffFileEntry[];
|
|
2712
4115
|
try {
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
4116
|
+
changedFiles = await getChangedFiles(root, base, head, mode);
|
|
4117
|
+
} catch {
|
|
4118
|
+
return null;
|
|
4119
|
+
}
|
|
4120
|
+
const byPath = new Map(changedFiles.map((f) => [f.path, f]));
|
|
4121
|
+
const selectedPaths = uniqueStrings(selectedRefs.map((ref) => ref.relPath));
|
|
4122
|
+
const entries = selectedPaths
|
|
4123
|
+
.map((relPath) => byPath.get(relPath))
|
|
4124
|
+
.filter((entry): entry is GitDiffFileEntry => Boolean(entry))
|
|
4125
|
+
.map((entry) => ({
|
|
4126
|
+
...entry,
|
|
4127
|
+
id: makeGitDiffFileId(root.id, entry.path),
|
|
4128
|
+
displayPath: `${root.name}/${entry.path}`,
|
|
4129
|
+
}));
|
|
4130
|
+
if (entries.length === 0) return null;
|
|
2720
4131
|
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
4132
|
+
const headLabel = mode === "working-tree" ? "working tree" : head;
|
|
4133
|
+
const rangeLabel =
|
|
4134
|
+
mode === "working-tree"
|
|
4135
|
+
? `${base} → working tree`
|
|
4136
|
+
: `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
|
|
4137
|
+
|
|
4138
|
+
return {
|
|
4139
|
+
root,
|
|
4140
|
+
base,
|
|
4141
|
+
head,
|
|
4142
|
+
headLabel,
|
|
4143
|
+
mode,
|
|
4144
|
+
rangeLabel,
|
|
4145
|
+
entries,
|
|
4146
|
+
};
|
|
4147
|
+
}
|
|
4148
|
+
|
|
4149
|
+
function findScopedGitDiffEntry(
|
|
4150
|
+
scoped: ScopedGitDiffContext,
|
|
4151
|
+
fileIdOrPath: string,
|
|
4152
|
+
): ScopedGitDiffEntry | null {
|
|
4153
|
+
const ref = getGitDiffFileRef(fileIdOrPath, scoped.root.id);
|
|
4154
|
+
if (!ref || ref.root.id !== scoped.root.id) return null;
|
|
4155
|
+
return (
|
|
4156
|
+
scoped.entries.find(
|
|
4157
|
+
(entry) => entry.id === ref.id || entry.path === ref.relPath,
|
|
4158
|
+
) ?? null
|
|
4159
|
+
);
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
async function readGitDiffView(
|
|
4163
|
+
scoped: ScopedGitDiffContext,
|
|
4164
|
+
entry: ScopedGitDiffEntry,
|
|
4165
|
+
view: "patch" | "before" | "after",
|
|
4166
|
+
): Promise<string> {
|
|
4167
|
+
if (view === "before") {
|
|
4168
|
+
if (entry.status === "A" || entry.status === "?") return "";
|
|
4169
|
+
return getFileAtRef(scoped.root, scoped.base, entry.oldPath ?? entry.path);
|
|
4170
|
+
}
|
|
4171
|
+
if (view === "after") {
|
|
4172
|
+
if (entry.status === "D") return "";
|
|
4173
|
+
const ref =
|
|
4174
|
+
scoped.mode === "working-tree" ? WORKING_TREE_SENTINEL : scoped.head;
|
|
4175
|
+
return getFileAtRef(scoped.root, ref, entry.path);
|
|
4176
|
+
}
|
|
4177
|
+
return getDiffPatch(
|
|
4178
|
+
scoped.root,
|
|
4179
|
+
scoped.base,
|
|
4180
|
+
scoped.head,
|
|
4181
|
+
scoped.mode,
|
|
4182
|
+
entry.path,
|
|
4183
|
+
);
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
async function loadGitBranchesForRoot(root: CodeContextRootConfig) {
|
|
4187
|
+
// Refresh remote-tracking refs first. This is intentionally non-interactive
|
|
4188
|
+
// and time-capped so private/offline repos fall back to already-local refs.
|
|
4189
|
+
await runGit(["fetch", "--all", "--prune", "--quiet"], {
|
|
4190
|
+
cwd: root.dir,
|
|
4191
|
+
allowFail: true,
|
|
4192
|
+
timeoutMs: 8000,
|
|
4193
|
+
});
|
|
4194
|
+
|
|
4195
|
+
const [head, branches, tags, remotes] = await Promise.all([
|
|
4196
|
+
runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root.dir }),
|
|
4197
|
+
runGit(
|
|
4198
|
+
[
|
|
4199
|
+
"for-each-ref",
|
|
4200
|
+
"--sort=-committerdate",
|
|
4201
|
+
`--count=${MAX_BRANCHES}`,
|
|
4202
|
+
"--format=%(refname:short)",
|
|
4203
|
+
"refs/heads/",
|
|
4204
|
+
"refs/remotes/",
|
|
4205
|
+
],
|
|
4206
|
+
{ cwd: root.dir },
|
|
4207
|
+
),
|
|
4208
|
+
runGit(
|
|
4209
|
+
[
|
|
4210
|
+
"for-each-ref",
|
|
4211
|
+
"--sort=-creatordate",
|
|
4212
|
+
"--count=50",
|
|
4213
|
+
"--format=%(refname:short)",
|
|
4214
|
+
"refs/tags/",
|
|
4215
|
+
],
|
|
4216
|
+
{ cwd: root.dir, allowFail: true },
|
|
4217
|
+
),
|
|
4218
|
+
runGit(["remote"], { cwd: root.dir, allowFail: true }),
|
|
4219
|
+
]);
|
|
4220
|
+
const remoteNames = remotes.stdout
|
|
4221
|
+
.split("\n")
|
|
4222
|
+
.map((s) => s.trim())
|
|
4223
|
+
.filter(Boolean);
|
|
4224
|
+
const lsRemoteResults = await Promise.all(
|
|
4225
|
+
remoteNames.slice(0, 5).map((remote) =>
|
|
4226
|
+
runGit(["ls-remote", "--heads", remote], {
|
|
4227
|
+
cwd: root.dir,
|
|
4228
|
+
allowFail: true,
|
|
4229
|
+
timeoutMs: 5000,
|
|
4230
|
+
maxBuffer: 1024 * 1024,
|
|
4231
|
+
}).then((result) =>
|
|
4232
|
+
result.stdout
|
|
4233
|
+
.split("\n")
|
|
4234
|
+
.map((line) => line.trim().split("\t")[1] || "")
|
|
4235
|
+
.filter((ref) => ref.startsWith("refs/heads/"))
|
|
4236
|
+
.map((ref) => `${remote}/${ref.replace("refs/heads/", "")}`),
|
|
2764
4237
|
),
|
|
2765
|
-
)
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
.map((s) => s.trim())
|
|
2770
|
-
.filter(Boolean),
|
|
2771
|
-
...lsRemoteResults.flat(),
|
|
2772
|
-
]).filter((b) => !b.endsWith("/HEAD"));
|
|
2773
|
-
const tagList = tags.stdout
|
|
4238
|
+
),
|
|
4239
|
+
);
|
|
4240
|
+
const branchList = uniqueStrings([
|
|
4241
|
+
...branches.stdout
|
|
2774
4242
|
.split("\n")
|
|
2775
4243
|
.map((s) => s.trim())
|
|
2776
|
-
.filter(Boolean)
|
|
2777
|
-
|
|
4244
|
+
.filter(Boolean),
|
|
4245
|
+
...lsRemoteResults.flat(),
|
|
4246
|
+
]).filter((b) => !b.endsWith("/HEAD"));
|
|
4247
|
+
const tagList = tags.stdout
|
|
4248
|
+
.split("\n")
|
|
4249
|
+
.map((s) => s.trim())
|
|
4250
|
+
.filter(Boolean);
|
|
4251
|
+
const currentHead = head.stdout.trim();
|
|
4252
|
+
|
|
4253
|
+
return {
|
|
4254
|
+
enabled: true,
|
|
4255
|
+
rootId: root.id,
|
|
4256
|
+
rootName: root.name,
|
|
4257
|
+
head: currentHead,
|
|
4258
|
+
defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
|
|
4259
|
+
branches: branchList,
|
|
4260
|
+
tags: tagList,
|
|
4261
|
+
};
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
app.get("/api/code-context/git/branches", async (req, res) => {
|
|
4265
|
+
const roots = getGitDiffRootSummaries();
|
|
4266
|
+
if (GIT_DIFF_ROOTS.length === 0) {
|
|
4267
|
+
return res.json({ enabled: false, branches: [], roots });
|
|
4268
|
+
}
|
|
4269
|
+
const root = getGitDiffRoot(
|
|
4270
|
+
String(req.query.root || req.query.rootId || "").trim(),
|
|
4271
|
+
);
|
|
4272
|
+
if (!root) {
|
|
4273
|
+
return res.status(404).json({
|
|
4274
|
+
enabled: false,
|
|
4275
|
+
branches: [],
|
|
4276
|
+
roots,
|
|
4277
|
+
error: "Git diff root not found",
|
|
4278
|
+
});
|
|
4279
|
+
}
|
|
4280
|
+
try {
|
|
2778
4281
|
res.json({
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
|
|
2782
|
-
branches: branchList,
|
|
2783
|
-
tags: tagList,
|
|
4282
|
+
...(await loadGitBranchesForRoot(root)),
|
|
4283
|
+
roots,
|
|
2784
4284
|
});
|
|
2785
4285
|
} catch (err: any) {
|
|
2786
4286
|
res.status(500).json({
|
|
2787
4287
|
enabled: false,
|
|
4288
|
+
rootId: root.id,
|
|
4289
|
+
rootName: root.name,
|
|
2788
4290
|
branches: [],
|
|
4291
|
+
roots,
|
|
2789
4292
|
error: err?.message || "git not available",
|
|
2790
4293
|
});
|
|
2791
4294
|
}
|
|
2792
4295
|
});
|
|
2793
4296
|
|
|
2794
4297
|
app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
2795
|
-
if (
|
|
4298
|
+
if (GIT_DIFF_ROOTS.length === 0)
|
|
4299
|
+
return res.status(400).json({ error: "No git diff roots" });
|
|
4300
|
+
const root = getGitDiffRoot(
|
|
4301
|
+
String(req.query.root || req.query.rootId || "").trim(),
|
|
4302
|
+
);
|
|
4303
|
+
if (!root) return res.status(404).json({ error: "Git diff root not found" });
|
|
2796
4304
|
|
|
2797
4305
|
const base = String(req.query.base || "").trim();
|
|
2798
4306
|
const headRaw = String(req.query.head || "").trim();
|
|
@@ -2801,7 +4309,7 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
|
2801
4309
|
if (!isValidRef(base))
|
|
2802
4310
|
return res.status(400).json({ error: "Invalid base ref" });
|
|
2803
4311
|
|
|
2804
|
-
const baseSha = await resolveRef(base);
|
|
4312
|
+
const baseSha = await resolveRef(root, base);
|
|
2805
4313
|
if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
|
|
2806
4314
|
|
|
2807
4315
|
let head = headRaw;
|
|
@@ -2811,13 +4319,15 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
|
2811
4319
|
} else {
|
|
2812
4320
|
if (!isValidRef(head))
|
|
2813
4321
|
return res.status(400).json({ error: "Invalid head ref" });
|
|
2814
|
-
headSha = await resolveRef(head);
|
|
4322
|
+
headSha = await resolveRef(root, head);
|
|
2815
4323
|
if (!headSha) return res.status(404).json({ error: "Head ref not found" });
|
|
2816
4324
|
}
|
|
2817
4325
|
|
|
2818
4326
|
try {
|
|
2819
|
-
const files = await getChangedFiles(base, head, mode);
|
|
4327
|
+
const files = await getChangedFiles(root, base, head, mode);
|
|
2820
4328
|
res.json({
|
|
4329
|
+
rootId: root.id,
|
|
4330
|
+
rootName: root.name,
|
|
2821
4331
|
base,
|
|
2822
4332
|
baseSha,
|
|
2823
4333
|
head: mode === "working-tree" ? WORKING_TREE_SENTINEL : head,
|
|
@@ -2832,20 +4342,27 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
|
2832
4342
|
});
|
|
2833
4343
|
|
|
2834
4344
|
app.get("/api/code-context/git/diff-file", async (req, res) => {
|
|
2835
|
-
if (
|
|
4345
|
+
if (GIT_DIFF_ROOTS.length === 0)
|
|
4346
|
+
return res.status(400).json({ error: "No git diff roots" });
|
|
2836
4347
|
|
|
2837
4348
|
const base = String(req.query.base || "").trim();
|
|
2838
4349
|
const headRaw = String(req.query.head || "").trim();
|
|
2839
4350
|
const mode = parseDiffMode(req.query.mode);
|
|
2840
4351
|
const filePath = String(req.query.path || "").trim();
|
|
2841
4352
|
const view = (req.query.view as string) || "patch";
|
|
4353
|
+
const fileRef = getGitDiffFileRef(
|
|
4354
|
+
filePath,
|
|
4355
|
+
String(req.query.root || req.query.rootId || "").trim(),
|
|
4356
|
+
);
|
|
2842
4357
|
|
|
2843
4358
|
if (!isValidRef(base))
|
|
2844
4359
|
return res.status(400).json({ error: "Invalid base ref" });
|
|
2845
|
-
if (!
|
|
2846
|
-
return res.status(400).json({ error: "Invalid path" });
|
|
4360
|
+
if (!fileRef) return res.status(400).json({ error: "Invalid path" });
|
|
2847
4361
|
|
|
2848
|
-
const
|
|
4362
|
+
const { root } = fileRef;
|
|
4363
|
+
const relPath = fileRef.relPath;
|
|
4364
|
+
|
|
4365
|
+
const baseSha = await resolveRef(root, base);
|
|
2849
4366
|
if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
|
|
2850
4367
|
|
|
2851
4368
|
let head = headRaw;
|
|
@@ -2854,15 +4371,15 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
|
|
|
2854
4371
|
} else {
|
|
2855
4372
|
if (!isValidRef(head))
|
|
2856
4373
|
return res.status(400).json({ error: "Invalid head ref" });
|
|
2857
|
-
const headSha = await resolveRef(head);
|
|
4374
|
+
const headSha = await resolveRef(root, head);
|
|
2858
4375
|
if (!headSha) return res.status(404).json({ error: "Head ref not found" });
|
|
2859
4376
|
}
|
|
2860
4377
|
|
|
2861
4378
|
// Verify the path is actually part of the diff so callers cannot read arbitrary repo files.
|
|
2862
4379
|
let entry: GitDiffFileEntry | undefined;
|
|
2863
4380
|
try {
|
|
2864
|
-
const files = await getChangedFiles(base, head, mode);
|
|
2865
|
-
entry = files.find((f) => f.path ===
|
|
4381
|
+
const files = await getChangedFiles(root, base, head, mode);
|
|
4382
|
+
entry = files.find((f) => f.path === relPath || f.oldPath === relPath);
|
|
2866
4383
|
} catch (err: any) {
|
|
2867
4384
|
return res.status(500).json({ error: err?.message || "git diff failed" });
|
|
2868
4385
|
}
|
|
@@ -2871,23 +4388,53 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
|
|
|
2871
4388
|
try {
|
|
2872
4389
|
if (view === "before") {
|
|
2873
4390
|
if (entry.status === "A" || entry.status === "?") {
|
|
2874
|
-
return res.json({
|
|
4391
|
+
return res.json({
|
|
4392
|
+
path: fileRef.id,
|
|
4393
|
+
rootId: root.id,
|
|
4394
|
+
rootName: root.name,
|
|
4395
|
+
view,
|
|
4396
|
+
content: "",
|
|
4397
|
+
});
|
|
2875
4398
|
}
|
|
2876
|
-
const refPath = entry.oldPath ??
|
|
2877
|
-
const content = await getFileAtRef(base, refPath);
|
|
2878
|
-
return res.json({
|
|
4399
|
+
const refPath = entry.oldPath ?? relPath;
|
|
4400
|
+
const content = await getFileAtRef(root, base, refPath);
|
|
4401
|
+
return res.json({
|
|
4402
|
+
path: fileRef.id,
|
|
4403
|
+
rootId: root.id,
|
|
4404
|
+
rootName: root.name,
|
|
4405
|
+
view,
|
|
4406
|
+
content,
|
|
4407
|
+
});
|
|
2879
4408
|
}
|
|
2880
4409
|
if (view === "after") {
|
|
2881
4410
|
if (entry.status === "D") {
|
|
2882
|
-
return res.json({
|
|
4411
|
+
return res.json({
|
|
4412
|
+
path: fileRef.id,
|
|
4413
|
+
rootId: root.id,
|
|
4414
|
+
rootName: root.name,
|
|
4415
|
+
view,
|
|
4416
|
+
content: "",
|
|
4417
|
+
});
|
|
2883
4418
|
}
|
|
2884
4419
|
const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
|
|
2885
|
-
const content = await getFileAtRef(ref,
|
|
2886
|
-
return res.json({
|
|
4420
|
+
const content = await getFileAtRef(root, ref, relPath);
|
|
4421
|
+
return res.json({
|
|
4422
|
+
path: fileRef.id,
|
|
4423
|
+
rootId: root.id,
|
|
4424
|
+
rootName: root.name,
|
|
4425
|
+
view,
|
|
4426
|
+
content,
|
|
4427
|
+
});
|
|
2887
4428
|
}
|
|
2888
4429
|
// default: patch
|
|
2889
|
-
const patch = await getDiffPatch(base, head, mode,
|
|
2890
|
-
return res.json({
|
|
4430
|
+
const patch = await getDiffPatch(root, base, head, mode, relPath);
|
|
4431
|
+
return res.json({
|
|
4432
|
+
path: fileRef.id,
|
|
4433
|
+
rootId: root.id,
|
|
4434
|
+
rootName: root.name,
|
|
4435
|
+
view: "patch",
|
|
4436
|
+
content: patch,
|
|
4437
|
+
});
|
|
2891
4438
|
} catch (err: any) {
|
|
2892
4439
|
res.status(500).json({ error: err?.message || "git read failed" });
|
|
2893
4440
|
}
|
|
@@ -2971,15 +4518,18 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
2971
4518
|
}
|
|
2972
4519
|
}
|
|
2973
4520
|
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
4521
|
+
const scopedCodeContextFiles =
|
|
4522
|
+
await getScopedCodeContextFiles(codeContextFiles);
|
|
4523
|
+
|
|
4524
|
+
if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
|
|
4525
|
+
for (const fp of scopedCodeContextFiles) {
|
|
4526
|
+
const ref = getCodeContextFileRef(fp);
|
|
4527
|
+
if (!ref) continue;
|
|
2979
4528
|
fileRegistry.set(
|
|
2980
4529
|
`code:${fp}`,
|
|
2981
|
-
makeCodeReferenceFileEntry(
|
|
2982
|
-
|
|
4530
|
+
makeCodeReferenceFileEntry(
|
|
4531
|
+
`[code:${ref.root.name}] ${ref.relPath}`,
|
|
4532
|
+
() => fs.readFile(ref.absolutePath, "utf-8"),
|
|
2983
4533
|
),
|
|
2984
4534
|
);
|
|
2985
4535
|
}
|
|
@@ -3020,7 +4570,7 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
3020
4570
|
}
|
|
3021
4571
|
|
|
3022
4572
|
if (codeFilePaths.length > 0) {
|
|
3023
|
-
system += `\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link:\n [DisplayText](coderef://relative/path/to/file)\nOnly use coderef:// for [code] files.`;
|
|
4573
|
+
system += `\n--- Code Context Exploration Tools ---\nYou also have read-only tools to list, search, read line ranges, trace textual references, inspect CI/CD config, and run whitelisted read-only inspection commands. These tools are restricted to selected, non-ignored files under configured code-context roots. File IDs are root-qualified like "root::relative/path". If something is missing, ask the user to select more code context or adjust ignore settings.\n\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link using the exact code file id without the "code:" prefix:\n [DisplayText](coderef://root::relative/path/to/file)\nOnly use coderef:// for [code] files.`;
|
|
3024
4574
|
}
|
|
3025
4575
|
}
|
|
3026
4576
|
|
|
@@ -3054,9 +4604,13 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
3054
4604
|
system,
|
|
3055
4605
|
prompt: `File: ${filePath || "unknown"}\n\nHighlighted code:\n\`\`\`\n${selectedCode}\n\`\`\`\n\nQuestion: ${prompt.trim()}`,
|
|
3056
4606
|
tools:
|
|
3057
|
-
fileRegistry.size > 0
|
|
4607
|
+
fileRegistry.size > 0 || scopedCodeContextFiles.length > 0
|
|
3058
4608
|
? {
|
|
3059
|
-
|
|
4609
|
+
...(fileRegistry.size > 0
|
|
4610
|
+
? { readFile: createReadFileTool(fileRegistry) }
|
|
4611
|
+
: {}),
|
|
4612
|
+
...createCodeContextTools(scopedCodeContextFiles),
|
|
4613
|
+
...createGitDiffTools(gitDiffContext || {}),
|
|
3060
4614
|
}
|
|
3061
4615
|
: undefined,
|
|
3062
4616
|
stopWhen: stepCountIs(4),
|