create-interview-cockpit 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.env.example +51 -9
- 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/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 +1038 -243
- package/template/server/src/storage.ts +2 -0
|
@@ -164,6 +164,7 @@ function makeCodeReferenceFileEntry(
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
interface GitDiffContextPayload {
|
|
167
|
+
rootId?: string;
|
|
167
168
|
baseRef?: string;
|
|
168
169
|
headRef?: string;
|
|
169
170
|
mode?: string;
|
|
@@ -177,82 +178,56 @@ async function registerGitDiffContext(
|
|
|
177
178
|
fileRegistry: Map<string, ReferenceFileEntry>,
|
|
178
179
|
ctx: GitDiffContextPayload,
|
|
179
180
|
): Promise<string> {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const mode = parseDiffMode(ctx.mode);
|
|
184
|
-
const headRaw = (ctx.headRef || "").trim();
|
|
185
|
-
const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
|
|
186
|
-
if (mode !== "working-tree" && (!head || !isValidRef(head))) return "";
|
|
187
|
-
|
|
188
|
-
const selected = Array.isArray(ctx.selectedFiles)
|
|
189
|
-
? ctx.selectedFiles.filter(
|
|
190
|
-
(p) => typeof p === "string" && p && !p.includes("\0"),
|
|
191
|
-
)
|
|
192
|
-
: [];
|
|
193
|
-
if (selected.length === 0) return "";
|
|
194
|
-
|
|
195
|
-
let changedFiles: GitDiffFileEntry[];
|
|
196
|
-
try {
|
|
197
|
-
changedFiles = await getChangedFiles(base, head, mode);
|
|
198
|
-
} catch {
|
|
199
|
-
return "";
|
|
200
|
-
}
|
|
201
|
-
const byPath = new Map(changedFiles.map((f) => [f.path, f]));
|
|
202
|
-
const validSelected = selected.filter((p) => byPath.has(p));
|
|
203
|
-
if (validSelected.length === 0) return "";
|
|
204
|
-
|
|
205
|
-
const headLabel = mode === "working-tree" ? "working tree" : head;
|
|
206
|
-
const rangeLabel =
|
|
207
|
-
mode === "working-tree"
|
|
208
|
-
? `${base} → working tree`
|
|
209
|
-
: `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
|
|
181
|
+
const scoped = await getScopedGitDiffContext(ctx);
|
|
182
|
+
if (!scoped) return "";
|
|
183
|
+
const { root, base, head, mode, entries, rangeLabel, headLabel } = scoped;
|
|
210
184
|
|
|
211
|
-
let manifest = `\n\n--- Available Git Diff Context (${rangeLabel}) ---
|
|
212
|
-
You can
|
|
213
|
-
• gitdiff:patch:<path> — unified diff hunks (recommended starting point)
|
|
214
|
-
• gitdiff:before:<path> — full file contents at the base ref
|
|
215
|
-
• gitdiff:after:<path> — full file contents at the head ref (or working tree)
|
|
216
|
-
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.
|
|
217
191
|
|
|
218
192
|
`;
|
|
219
|
-
for (const
|
|
220
|
-
const entry = byPath.get(filePath)!;
|
|
193
|
+
for (const entry of entries) {
|
|
221
194
|
const sign = entry.binary
|
|
222
195
|
? "binary"
|
|
223
196
|
: `+${entry.additions}/-${entry.deletions}`;
|
|
224
197
|
const renamed = entry.oldPath ? ` (renamed from ${entry.oldPath})` : "";
|
|
225
|
-
manifest += `• [${entry.status}] ${entry.
|
|
226
|
-
manifest += ` patch id: "gitdiff:patch:${entry.
|
|
198
|
+
manifest += `• [${entry.status}] ${entry.displayPath} ${sign}${renamed}\n`;
|
|
199
|
+
manifest += ` patch id: "gitdiff:patch:${entry.id}"\n`;
|
|
227
200
|
if (entry.status !== "A" && entry.status !== "?") {
|
|
228
|
-
manifest += ` before id: "gitdiff:before:${entry.
|
|
201
|
+
manifest += ` before id: "gitdiff:before:${entry.id}"\n`;
|
|
229
202
|
}
|
|
230
203
|
if (entry.status !== "D") {
|
|
231
|
-
manifest += ` after id: "gitdiff:after:${entry.
|
|
204
|
+
manifest += ` after id: "gitdiff:after:${entry.id}"\n`;
|
|
232
205
|
}
|
|
233
206
|
|
|
234
|
-
const safePath =
|
|
207
|
+
const safePath = entry.path; // already validated to be in diff
|
|
235
208
|
fileRegistry.set(
|
|
236
|
-
`gitdiff:patch:${
|
|
237
|
-
makeCodeReferenceFileEntry(`[git diff] ${safePath}`, () =>
|
|
238
|
-
getDiffPatch(base, head, mode, safePath),
|
|
209
|
+
`gitdiff:patch:${entry.id}`,
|
|
210
|
+
makeCodeReferenceFileEntry(`[git diff:${root.name}] ${safePath}`, () =>
|
|
211
|
+
getDiffPatch(root, base, head, mode, safePath),
|
|
239
212
|
),
|
|
240
213
|
);
|
|
241
214
|
if (entry.status !== "A" && entry.status !== "?") {
|
|
242
215
|
const refPath = entry.oldPath ?? safePath;
|
|
243
216
|
fileRegistry.set(
|
|
244
|
-
`gitdiff:before:${
|
|
245
|
-
makeCodeReferenceFileEntry(
|
|
246
|
-
|
|
217
|
+
`gitdiff:before:${entry.id}`,
|
|
218
|
+
makeCodeReferenceFileEntry(
|
|
219
|
+
`[git before ${base}:${root.name}] ${safePath}`,
|
|
220
|
+
() => getFileAtRef(root, base, refPath),
|
|
247
221
|
),
|
|
248
222
|
);
|
|
249
223
|
}
|
|
250
224
|
if (entry.status !== "D") {
|
|
251
225
|
const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
|
|
252
226
|
fileRegistry.set(
|
|
253
|
-
`gitdiff:after:${
|
|
254
|
-
makeCodeReferenceFileEntry(
|
|
255
|
-
|
|
227
|
+
`gitdiff:after:${entry.id}`,
|
|
228
|
+
makeCodeReferenceFileEntry(
|
|
229
|
+
`[git after ${headLabel}:${root.name}] ${safePath}`,
|
|
230
|
+
() => getFileAtRef(root, ref, safePath),
|
|
256
231
|
),
|
|
257
232
|
);
|
|
258
233
|
}
|
|
@@ -344,7 +319,124 @@ function createReadFileTool(fileRegistry: Map<string, ReferenceFileEntry>) {
|
|
|
344
319
|
}
|
|
345
320
|
|
|
346
321
|
const PORT = process.env.PORT || 3001;
|
|
347
|
-
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
|
+
}
|
|
348
440
|
|
|
349
441
|
const MAX_CODE_TOOL_LIST_RESULTS = 400;
|
|
350
442
|
const MAX_CODE_TOOL_SEARCH_RESULTS = 80;
|
|
@@ -362,38 +454,106 @@ function normalizeCodeContextRelPath(value: string): string | null {
|
|
|
362
454
|
return normalized;
|
|
363
455
|
}
|
|
364
456
|
|
|
365
|
-
function
|
|
366
|
-
|
|
367
|
-
|
|
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);
|
|
368
474
|
if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
|
|
369
475
|
|
|
370
|
-
const
|
|
371
|
-
const resolved = path.resolve(
|
|
372
|
-
const relativeToRoot = path.relative(
|
|
476
|
+
const rootDir = path.resolve(root.dir);
|
|
477
|
+
const resolved = path.resolve(rootDir, normalized);
|
|
478
|
+
const relativeToRoot = path.relative(rootDir, resolved);
|
|
373
479
|
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
|
|
374
480
|
return null;
|
|
375
481
|
}
|
|
376
|
-
return
|
|
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 };
|
|
377
529
|
}
|
|
378
530
|
|
|
379
531
|
async function getScopedCodeContextFiles(
|
|
380
532
|
selectedFiles: unknown,
|
|
381
533
|
): Promise<string[]> {
|
|
382
|
-
if (
|
|
534
|
+
if (CODE_CONTEXT_ROOTS.length === 0 || !Array.isArray(selectedFiles)) {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
383
537
|
|
|
384
538
|
const selected = selectedFiles
|
|
385
539
|
.filter((file): file is string => typeof file === "string")
|
|
386
|
-
.map((file) =>
|
|
387
|
-
.filter((file): file is
|
|
540
|
+
.map((file) => getCodeContextFileRef(file))
|
|
541
|
+
.filter((file): file is CodeContextFileRef => Boolean(file));
|
|
388
542
|
|
|
389
543
|
if (selected.length === 0) return [];
|
|
390
544
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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));
|
|
396
550
|
}
|
|
551
|
+
|
|
552
|
+
return uniqueStrings(
|
|
553
|
+
selected
|
|
554
|
+
.filter((file) => visibleByRoot.get(file.root.id)?.has(file.relPath))
|
|
555
|
+
.map((file) => file.id),
|
|
556
|
+
);
|
|
397
557
|
}
|
|
398
558
|
|
|
399
559
|
function escapeRegExp(value: string): string {
|
|
@@ -420,21 +580,31 @@ function trimToolLine(line: string): string {
|
|
|
420
580
|
function codePathMatchesFilter(relPath: string, filter?: string): boolean {
|
|
421
581
|
const normalizedFilter = filter?.trim().replaceAll("\\", "/").toLowerCase();
|
|
422
582
|
if (!normalizedFilter) return true;
|
|
423
|
-
return relPath
|
|
583
|
+
return codeContextFilterText(relPath)
|
|
584
|
+
.toLowerCase()
|
|
585
|
+
.includes(normalizedFilter);
|
|
424
586
|
}
|
|
425
587
|
|
|
426
588
|
function formatCodeFileTree(paths: string[], limit: number): string {
|
|
427
589
|
const lines: string[] = [];
|
|
428
|
-
const
|
|
429
|
-
for (const
|
|
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
|
+
}
|
|
430
600
|
const parts = relPath.split("/");
|
|
431
601
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
432
|
-
const dirPath = parts.slice(0, i + 1).join("/")
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
lines.push(`${" ".repeat(i)}${parts[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]}/`);
|
|
436
606
|
}
|
|
437
|
-
lines.push(`${" ".repeat(Math.max(
|
|
607
|
+
lines.push(`${" ".repeat(Math.max(1, parts.length))}${parts.at(-1)}`);
|
|
438
608
|
}
|
|
439
609
|
return lines.join("\n");
|
|
440
610
|
}
|
|
@@ -502,6 +672,7 @@ async function searchCodeInFiles(
|
|
|
502
672
|
);
|
|
503
673
|
const matches: Array<{
|
|
504
674
|
path: string;
|
|
675
|
+
displayPath: string;
|
|
505
676
|
line: number;
|
|
506
677
|
lineText: string;
|
|
507
678
|
before: Array<{ line: number; text: string }>;
|
|
@@ -532,6 +703,7 @@ async function searchCodeInFiles(
|
|
|
532
703
|
const afterEnd = Math.min(lines.length, i + contextLines + 1);
|
|
533
704
|
matches.push({
|
|
534
705
|
path: relPath,
|
|
706
|
+
displayPath: codeContextDisplayPath(relPath),
|
|
535
707
|
line: i + 1,
|
|
536
708
|
lineText: trimToolLine(lines[i]),
|
|
537
709
|
before: lines.slice(beforeStart, i).map((text, offset) => ({
|
|
@@ -567,7 +739,7 @@ async function searchCodeInFiles(
|
|
|
567
739
|
}
|
|
568
740
|
|
|
569
741
|
function classifyConfigFile(relPath: string): string | null {
|
|
570
|
-
const lower = relPath.toLowerCase();
|
|
742
|
+
const lower = codeContextRelPath(relPath).toLowerCase();
|
|
571
743
|
const base = lower.split("/").at(-1) || lower;
|
|
572
744
|
if (/^\.github\/workflows\/.+\.ya?ml$/.test(lower)) {
|
|
573
745
|
return "github-actions-workflow";
|
|
@@ -606,7 +778,8 @@ function classifyConfigFile(relPath: string): string | null {
|
|
|
606
778
|
}
|
|
607
779
|
|
|
608
780
|
function summarizeConfigStructure(relPath: string, content: string) {
|
|
609
|
-
const
|
|
781
|
+
const relativePath = codeContextRelPath(relPath);
|
|
782
|
+
const lower = relativePath.toLowerCase();
|
|
610
783
|
if (lower.endsWith(".json")) {
|
|
611
784
|
try {
|
|
612
785
|
const parsed = JSON.parse(content);
|
|
@@ -628,7 +801,7 @@ function summarizeConfigStructure(relPath: string, content: string) {
|
|
|
628
801
|
}
|
|
629
802
|
}
|
|
630
803
|
|
|
631
|
-
if (/\.ya?ml$/i.test(
|
|
804
|
+
if (/\.ya?ml$/i.test(relativePath)) {
|
|
632
805
|
const topLevelKeys = uniqueStrings(
|
|
633
806
|
content
|
|
634
807
|
.split(/\r?\n/)
|
|
@@ -638,7 +811,7 @@ function summarizeConfigStructure(relPath: string, content: string) {
|
|
|
638
811
|
return { topLevelKeys };
|
|
639
812
|
}
|
|
640
813
|
|
|
641
|
-
if (/\.(tf|tfvars|hcl)$/i.test(
|
|
814
|
+
if (/\.(tf|tfvars|hcl)$/i.test(relativePath)) {
|
|
642
815
|
const blocks = uniqueStrings(
|
|
643
816
|
content
|
|
644
817
|
.split(/\r?\n/)
|
|
@@ -669,7 +842,8 @@ function extractConfigHighlights(content: string) {
|
|
|
669
842
|
}
|
|
670
843
|
|
|
671
844
|
function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
672
|
-
if (
|
|
845
|
+
if (CODE_CONTEXT_ROOTS.length === 0 || scopedCodeFiles.length === 0)
|
|
846
|
+
return {};
|
|
673
847
|
|
|
674
848
|
const files = uniqueStrings(scopedCodeFiles).sort((a, b) =>
|
|
675
849
|
a.localeCompare(b),
|
|
@@ -679,7 +853,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
679
853
|
return {
|
|
680
854
|
listCodeFiles: tool({
|
|
681
855
|
description:
|
|
682
|
-
"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
|
|
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.",
|
|
683
857
|
inputSchema: z.object({
|
|
684
858
|
query: z
|
|
685
859
|
.string()
|
|
@@ -719,6 +893,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
719
893
|
totalMatched: filtered.length,
|
|
720
894
|
truncated: filtered.length > listed.length,
|
|
721
895
|
files: listed,
|
|
896
|
+
displayFiles: listed.map(codeContextDisplayPath),
|
|
722
897
|
tree: formatCodeFileTree(listed, cappedLimit),
|
|
723
898
|
};
|
|
724
899
|
},
|
|
@@ -756,17 +931,20 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
756
931
|
}),
|
|
757
932
|
readCodeFile: tool({
|
|
758
933
|
description:
|
|
759
|
-
"Read one available code-context file by
|
|
934
|
+
"Read one available code-context file by canonical path, optionally with a 1-based line range. Restricted to selected, non-ignored files.",
|
|
760
935
|
inputSchema: z.object({
|
|
761
936
|
path: z
|
|
762
937
|
.string()
|
|
763
|
-
.describe(
|
|
938
|
+
.describe(
|
|
939
|
+
"Canonical path from listCodeFiles, e.g. root::src/file.ts. Legacy relative paths resolve against the first root.",
|
|
940
|
+
),
|
|
764
941
|
startLine: z.number().int().min(1).optional(),
|
|
765
942
|
endLine: z.number().int().min(1).optional(),
|
|
766
943
|
}),
|
|
767
944
|
execute: async ({ path: requestedPath, startLine, endLine }) => {
|
|
768
|
-
const
|
|
769
|
-
|
|
945
|
+
const ref = getCodeContextFileRef(requestedPath || "");
|
|
946
|
+
const fileId = ref?.id;
|
|
947
|
+
if (!ref || !fileId || !fileSet.has(fileId)) {
|
|
770
948
|
return {
|
|
771
949
|
type: "error",
|
|
772
950
|
error:
|
|
@@ -775,7 +953,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
775
953
|
}
|
|
776
954
|
|
|
777
955
|
try {
|
|
778
|
-
const read = await readCodeTextWithinLimit(
|
|
956
|
+
const read = await readCodeTextWithinLimit(fileId);
|
|
779
957
|
const lines = read.content.split(/\r?\n/);
|
|
780
958
|
const totalLines = lines.length;
|
|
781
959
|
const first = clampNumber(startLine, 1, totalLines || 1, 1);
|
|
@@ -790,7 +968,8 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
790
968
|
.map((line, index) => `${first + index}: ${line}`)
|
|
791
969
|
.join("\n");
|
|
792
970
|
return {
|
|
793
|
-
path:
|
|
971
|
+
path: fileId,
|
|
972
|
+
displayPath: codeContextDisplayPath(fileId),
|
|
794
973
|
startLine: first,
|
|
795
974
|
endLine: last,
|
|
796
975
|
totalLines,
|
|
@@ -865,6 +1044,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
865
1044
|
const read = await readCodeTextWithinLimit(file);
|
|
866
1045
|
inspected.push({
|
|
867
1046
|
path: file,
|
|
1047
|
+
displayPath: codeContextDisplayPath(file),
|
|
868
1048
|
kind,
|
|
869
1049
|
truncated: read.truncated,
|
|
870
1050
|
structure: summarizeConfigStructure(file, read.content),
|
|
@@ -873,6 +1053,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
873
1053
|
} catch (err: any) {
|
|
874
1054
|
inspected.push({
|
|
875
1055
|
path: file,
|
|
1056
|
+
displayPath: codeContextDisplayPath(file),
|
|
876
1057
|
kind,
|
|
877
1058
|
error: err?.message || "Could not inspect file",
|
|
878
1059
|
});
|
|
@@ -934,8 +1115,12 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
934
1115
|
if (command === "pwd") {
|
|
935
1116
|
return {
|
|
936
1117
|
command: "pwd",
|
|
937
|
-
|
|
938
|
-
|
|
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.",
|
|
939
1124
|
};
|
|
940
1125
|
}
|
|
941
1126
|
if (command === "find") {
|
|
@@ -949,6 +1134,7 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
949
1134
|
return {
|
|
950
1135
|
command: "find",
|
|
951
1136
|
files: filtered,
|
|
1137
|
+
displayFiles: filtered.map(codeContextDisplayPath),
|
|
952
1138
|
truncated: filtered.length >= limit,
|
|
953
1139
|
};
|
|
954
1140
|
}
|
|
@@ -965,28 +1151,47 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
965
1151
|
|
|
966
1152
|
if (command === "git-ls-files") {
|
|
967
1153
|
try {
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
+
}
|
|
979
1188
|
}
|
|
980
|
-
const
|
|
981
|
-
const filtered = files
|
|
982
|
-
.filter(
|
|
983
|
-
(file) =>
|
|
984
|
-
tracked.has(file) && codePathMatchesFilter(file, pathFilter),
|
|
985
|
-
)
|
|
986
|
-
.slice(0, limit);
|
|
1189
|
+
const filtered = matchingFiles.slice(0, limit);
|
|
987
1190
|
return {
|
|
988
1191
|
command: "git ls-files",
|
|
989
1192
|
files: filtered,
|
|
1193
|
+
displayFiles: filtered.map(codeContextDisplayPath),
|
|
1194
|
+
errors,
|
|
990
1195
|
truncated: filtered.length >= limit,
|
|
991
1196
|
};
|
|
992
1197
|
} catch (err: any) {
|
|
@@ -997,8 +1202,9 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
997
1202
|
}
|
|
998
1203
|
}
|
|
999
1204
|
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1205
|
+
const fileRef = getCodeContextFileRef(requestedPath || "");
|
|
1206
|
+
const fileId = fileRef?.id;
|
|
1207
|
+
if (!fileRef || !fileId || !fileSet.has(fileId)) {
|
|
1002
1208
|
return {
|
|
1003
1209
|
type: "error",
|
|
1004
1210
|
error:
|
|
@@ -1015,10 +1221,10 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
1015
1221
|
"--decorate",
|
|
1016
1222
|
`--max-count=${Math.min(limit, 50)}`,
|
|
1017
1223
|
"--",
|
|
1018
|
-
relPath,
|
|
1224
|
+
fileRef.relPath,
|
|
1019
1225
|
],
|
|
1020
1226
|
{
|
|
1021
|
-
cwd:
|
|
1227
|
+
cwd: fileRef.root.dir,
|
|
1022
1228
|
allowFail: true,
|
|
1023
1229
|
timeoutMs: 5000,
|
|
1024
1230
|
maxBuffer: 512 * 1024,
|
|
@@ -1026,7 +1232,8 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
1026
1232
|
);
|
|
1027
1233
|
return {
|
|
1028
1234
|
command: "git log",
|
|
1029
|
-
path:
|
|
1235
|
+
path: fileId,
|
|
1236
|
+
displayPath: codeContextDisplayPath(fileId),
|
|
1030
1237
|
output:
|
|
1031
1238
|
result.stdout.trim() ||
|
|
1032
1239
|
result.stderr.trim() ||
|
|
@@ -1044,16 +1251,17 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
1044
1251
|
const last = Math.max(first, endLine ?? first);
|
|
1045
1252
|
args.push("-L", `${first},${last}`);
|
|
1046
1253
|
}
|
|
1047
|
-
args.push("--", relPath);
|
|
1254
|
+
args.push("--", fileRef.relPath);
|
|
1048
1255
|
const result = await runGit(args, {
|
|
1049
|
-
cwd:
|
|
1256
|
+
cwd: fileRef.root.dir,
|
|
1050
1257
|
allowFail: true,
|
|
1051
1258
|
timeoutMs: 5000,
|
|
1052
1259
|
maxBuffer: 512 * 1024,
|
|
1053
1260
|
});
|
|
1054
1261
|
return {
|
|
1055
1262
|
command: "git blame",
|
|
1056
|
-
path:
|
|
1263
|
+
path: fileId,
|
|
1264
|
+
displayPath: codeContextDisplayPath(fileId),
|
|
1057
1265
|
output:
|
|
1058
1266
|
result.stdout.trim() ||
|
|
1059
1267
|
result.stderr.trim() ||
|
|
@@ -1067,6 +1275,280 @@ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
|
|
|
1067
1275
|
};
|
|
1068
1276
|
}
|
|
1069
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
|
+
}
|
|
1551
|
+
|
|
1070
1552
|
// ─── AI Providers (Vercel AI SDK) ────────────────────────
|
|
1071
1553
|
// Set the provider + model in .env. Supports: openai, google, anthropic
|
|
1072
1554
|
|
|
@@ -1720,6 +2202,7 @@ app.patch("/api/questions/:id", async (req, res) => {
|
|
|
1720
2202
|
delete q.gitDiffContext;
|
|
1721
2203
|
} else if (gdc && typeof gdc === "object") {
|
|
1722
2204
|
q.gitDiffContext = {
|
|
2205
|
+
rootId: String(gdc.rootId || ""),
|
|
1723
2206
|
baseRef: String(gdc.baseRef || ""),
|
|
1724
2207
|
headRef: String(gdc.headRef || ""),
|
|
1725
2208
|
mode:
|
|
@@ -2710,14 +3193,15 @@ app.post("/api/chat", async (req, res) => {
|
|
|
2710
3193
|
await getScopedCodeContextFiles(codeContextFiles);
|
|
2711
3194
|
|
|
2712
3195
|
// Code-context files from the project directory
|
|
2713
|
-
if (scopedCodeContextFiles.length &&
|
|
3196
|
+
if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
|
|
2714
3197
|
for (const filePath of scopedCodeContextFiles) {
|
|
2715
|
-
const
|
|
2716
|
-
if (!
|
|
3198
|
+
const ref = getCodeContextFileRef(filePath);
|
|
3199
|
+
if (!ref) continue;
|
|
2717
3200
|
fileRegistry.set(
|
|
2718
3201
|
`code:${filePath}`,
|
|
2719
|
-
makeCodeReferenceFileEntry(
|
|
2720
|
-
|
|
3202
|
+
makeCodeReferenceFileEntry(
|
|
3203
|
+
`[code:${ref.root.name}] ${ref.relPath}`,
|
|
3204
|
+
() => fs.readFile(ref.absolutePath, "utf-8"),
|
|
2721
3205
|
),
|
|
2722
3206
|
);
|
|
2723
3207
|
}
|
|
@@ -2762,18 +3246,18 @@ You also have read-only code tools for the selected Code Context files:
|
|
|
2762
3246
|
- inspectCiConfig: summarize CI/CD, package, Docker, and Terraform config among selected files.
|
|
2763
3247
|
- runReadOnlyCodeCommand: safe whitelisted equivalents for pwd/find/rg/git-ls-files/git-log/git-blame.
|
|
2764
3248
|
|
|
2765
|
-
These tools cannot access files outside
|
|
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.
|
|
2766
3250
|
|
|
2767
3251
|
--- Linking Code Files in Your Response ---
|
|
2768
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:
|
|
2769
3253
|
|
|
2770
|
-
[DisplayText](coderef://relative/path/to/file)
|
|
3254
|
+
[DisplayText](coderef://root::relative/path/to/file)
|
|
2771
3255
|
|
|
2772
3256
|
Examples:
|
|
2773
|
-
[EmployeesController](coderef://src/Controllers/EmployeesController.cs)
|
|
2774
|
-
[SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
|
|
3257
|
+
[EmployeesController](coderef://backend::src/Controllers/EmployeesController.cs)
|
|
3258
|
+
[SettleDeferredPaymentCaseRequest](coderef://api::src/Requests/SettleDeferredPaymentCaseRequest.cs)
|
|
2775
3259
|
|
|
2776
|
-
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.
|
|
2777
3261
|
Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
|
|
2778
3262
|
}
|
|
2779
3263
|
|
|
@@ -2896,6 +3380,7 @@ Examples (illustrative only — use real ids and names from the list above):
|
|
|
2896
3380
|
}
|
|
2897
3381
|
: {}),
|
|
2898
3382
|
...createCodeContextTools(scopedCodeContextFiles),
|
|
3383
|
+
...createGitDiffTools(gitDiffContext || {}),
|
|
2899
3384
|
},
|
|
2900
3385
|
stopWhen: stepCountIs(selectedResponseProfile.maxSteps),
|
|
2901
3386
|
});
|
|
@@ -3134,30 +3619,34 @@ async function walkDir(dir: string, prefix = ""): Promise<string[]> {
|
|
|
3134
3619
|
}
|
|
3135
3620
|
|
|
3136
3621
|
app.get("/api/code-context/tree", async (_req, res) => {
|
|
3137
|
-
if (
|
|
3622
|
+
if (CODE_CONTEXT_ROOTS.length === 0) return res.json({ roots: [] });
|
|
3138
3623
|
try {
|
|
3139
|
-
|
|
3140
|
-
res.json(files);
|
|
3624
|
+
res.json(await getCodeContextRootsTree());
|
|
3141
3625
|
} catch {
|
|
3142
|
-
res.json([]);
|
|
3626
|
+
res.json({ roots: [] });
|
|
3143
3627
|
}
|
|
3144
3628
|
});
|
|
3145
3629
|
|
|
3146
3630
|
app.get("/api/code-context/file", async (req, res) => {
|
|
3147
|
-
if (
|
|
3148
|
-
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" });
|
|
3149
3633
|
const filePath = req.query.path as string;
|
|
3150
3634
|
if (!filePath) return res.status(400).json({ error: "Path required" });
|
|
3151
3635
|
|
|
3152
|
-
const
|
|
3153
|
-
|
|
3154
|
-
if (!normalized || !resolved) {
|
|
3636
|
+
const ref = getCodeContextFileRef(filePath);
|
|
3637
|
+
if (!ref) {
|
|
3155
3638
|
return res.status(403).json({ error: "Access denied" });
|
|
3156
3639
|
}
|
|
3157
3640
|
|
|
3158
3641
|
try {
|
|
3159
|
-
const content = await fs.readFile(
|
|
3160
|
-
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
|
+
});
|
|
3161
3650
|
} catch {
|
|
3162
3651
|
res.status(404).json({ error: "File not found" });
|
|
3163
3652
|
}
|
|
@@ -3170,7 +3659,108 @@ app.get("/api/code-context/file", async (req, res) => {
|
|
|
3170
3659
|
// or before/after blobs are pulled lazily via the readFile tool using ids of
|
|
3171
3660
|
// the form gitdiff:patch:<path> / gitdiff:before:<path> / gitdiff:after:<path>.
|
|
3172
3661
|
|
|
3173
|
-
|
|
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
|
+
}
|
|
3174
3764
|
|
|
3175
3765
|
// Hard caps so a runaway repo can never blow up the response or the model's context.
|
|
3176
3766
|
const MAX_DIFF_FILES = 500;
|
|
@@ -3191,6 +3781,21 @@ interface GitDiffFileEntry {
|
|
|
3191
3781
|
binary: boolean;
|
|
3192
3782
|
}
|
|
3193
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
|
+
|
|
3194
3799
|
function runGit(
|
|
3195
3800
|
args: string[],
|
|
3196
3801
|
opts: {
|
|
@@ -3277,13 +3882,16 @@ function isValidRef(ref: string): boolean {
|
|
|
3277
3882
|
return REF_PATTERN.test(ref);
|
|
3278
3883
|
}
|
|
3279
3884
|
|
|
3280
|
-
async function resolveRef(
|
|
3885
|
+
async function resolveRef(
|
|
3886
|
+
root: CodeContextRootConfig,
|
|
3887
|
+
ref: string,
|
|
3888
|
+
): Promise<string | null> {
|
|
3281
3889
|
if (!isValidRef(ref)) return null;
|
|
3282
3890
|
try {
|
|
3283
3891
|
const { stdout } = await runGit(
|
|
3284
3892
|
["rev-parse", "--verify", `${ref}^{commit}`],
|
|
3285
3893
|
{
|
|
3286
|
-
cwd:
|
|
3894
|
+
cwd: root.dir,
|
|
3287
3895
|
},
|
|
3288
3896
|
);
|
|
3289
3897
|
return stdout.trim() || null;
|
|
@@ -3314,6 +3922,7 @@ function buildDiffRange(
|
|
|
3314
3922
|
}
|
|
3315
3923
|
|
|
3316
3924
|
async function getChangedFiles(
|
|
3925
|
+
root: CodeContextRootConfig,
|
|
3317
3926
|
base: string,
|
|
3318
3927
|
head: string,
|
|
3319
3928
|
mode: GitDiffMode,
|
|
@@ -3323,10 +3932,10 @@ async function getChangedFiles(
|
|
|
3323
3932
|
|
|
3324
3933
|
const nameStatus = await runGit(
|
|
3325
3934
|
[...baseArgs, "--name-status", "-z", ...range],
|
|
3326
|
-
{ cwd:
|
|
3935
|
+
{ cwd: root.dir },
|
|
3327
3936
|
);
|
|
3328
3937
|
const numStat = await runGit([...baseArgs, "--numstat", "-z", ...range], {
|
|
3329
|
-
cwd:
|
|
3938
|
+
cwd: root.dir,
|
|
3330
3939
|
});
|
|
3331
3940
|
|
|
3332
3941
|
// numstat with -z uses NUL between records AND between additions/deletions/path,
|
|
@@ -3384,7 +3993,7 @@ async function getChangedFiles(
|
|
|
3384
3993
|
try {
|
|
3385
3994
|
const untracked = await runGit(
|
|
3386
3995
|
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
3387
|
-
{ cwd:
|
|
3996
|
+
{ cwd: root.dir },
|
|
3388
3997
|
);
|
|
3389
3998
|
for (const filePath of untracked.stdout
|
|
3390
3999
|
.split("\0")
|
|
@@ -3402,10 +4011,17 @@ async function getChangedFiles(
|
|
|
3402
4011
|
}
|
|
3403
4012
|
}
|
|
3404
4013
|
|
|
3405
|
-
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);
|
|
3406
4021
|
}
|
|
3407
4022
|
|
|
3408
4023
|
async function getDiffPatch(
|
|
4024
|
+
root: CodeContextRootConfig,
|
|
3409
4025
|
base: string,
|
|
3410
4026
|
head: string,
|
|
3411
4027
|
mode: GitDiffMode,
|
|
@@ -3422,7 +4038,7 @@ async function getDiffPatch(
|
|
|
3422
4038
|
"--",
|
|
3423
4039
|
filePath,
|
|
3424
4040
|
],
|
|
3425
|
-
{ cwd:
|
|
4041
|
+
{ cwd: root.dir, maxBuffer: MAX_DIFF_FILE_BYTES * 4 },
|
|
3426
4042
|
);
|
|
3427
4043
|
if (stdout.length > MAX_DIFF_FILE_BYTES) {
|
|
3428
4044
|
return (
|
|
@@ -3433,10 +4049,16 @@ async function getDiffPatch(
|
|
|
3433
4049
|
return stdout;
|
|
3434
4050
|
}
|
|
3435
4051
|
|
|
3436
|
-
async function getFileAtRef(
|
|
4052
|
+
async function getFileAtRef(
|
|
4053
|
+
root: CodeContextRootConfig,
|
|
4054
|
+
ref: string,
|
|
4055
|
+
filePath: string,
|
|
4056
|
+
): Promise<string> {
|
|
3437
4057
|
if (ref === WORKING_TREE_SENTINEL) {
|
|
3438
|
-
const
|
|
3439
|
-
|
|
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)) {
|
|
3440
4062
|
throw new Error("Access denied");
|
|
3441
4063
|
}
|
|
3442
4064
|
const buf = await fs.readFile(resolved);
|
|
@@ -3449,7 +4071,7 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
|
|
|
3449
4071
|
return buf.toString("utf-8");
|
|
3450
4072
|
}
|
|
3451
4073
|
const { stdout } = await runGit(["show", `${ref}:${filePath}`], {
|
|
3452
|
-
cwd:
|
|
4074
|
+
cwd: root.dir,
|
|
3453
4075
|
maxBuffer: MAX_DIFF_FILE_BYTES * 4,
|
|
3454
4076
|
});
|
|
3455
4077
|
if (stdout.length > MAX_DIFF_FILE_BYTES) {
|
|
@@ -3461,92 +4083,224 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
|
|
|
3461
4083
|
return stdout;
|
|
3462
4084
|
}
|
|
3463
4085
|
|
|
3464
|
-
|
|
3465
|
-
|
|
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[];
|
|
3466
4115
|
try {
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
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;
|
|
3474
4131
|
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
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/", "")}`),
|
|
3518
4237
|
),
|
|
3519
|
-
)
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
.map((s) => s.trim())
|
|
3524
|
-
.filter(Boolean),
|
|
3525
|
-
...lsRemoteResults.flat(),
|
|
3526
|
-
]).filter((b) => !b.endsWith("/HEAD"));
|
|
3527
|
-
const tagList = tags.stdout
|
|
4238
|
+
),
|
|
4239
|
+
);
|
|
4240
|
+
const branchList = uniqueStrings([
|
|
4241
|
+
...branches.stdout
|
|
3528
4242
|
.split("\n")
|
|
3529
4243
|
.map((s) => s.trim())
|
|
3530
|
-
.filter(Boolean)
|
|
3531
|
-
|
|
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 {
|
|
3532
4281
|
res.json({
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
|
|
3536
|
-
branches: branchList,
|
|
3537
|
-
tags: tagList,
|
|
4282
|
+
...(await loadGitBranchesForRoot(root)),
|
|
4283
|
+
roots,
|
|
3538
4284
|
});
|
|
3539
4285
|
} catch (err: any) {
|
|
3540
4286
|
res.status(500).json({
|
|
3541
4287
|
enabled: false,
|
|
4288
|
+
rootId: root.id,
|
|
4289
|
+
rootName: root.name,
|
|
3542
4290
|
branches: [],
|
|
4291
|
+
roots,
|
|
3543
4292
|
error: err?.message || "git not available",
|
|
3544
4293
|
});
|
|
3545
4294
|
}
|
|
3546
4295
|
});
|
|
3547
4296
|
|
|
3548
4297
|
app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
3549
|
-
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" });
|
|
3550
4304
|
|
|
3551
4305
|
const base = String(req.query.base || "").trim();
|
|
3552
4306
|
const headRaw = String(req.query.head || "").trim();
|
|
@@ -3555,7 +4309,7 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
|
3555
4309
|
if (!isValidRef(base))
|
|
3556
4310
|
return res.status(400).json({ error: "Invalid base ref" });
|
|
3557
4311
|
|
|
3558
|
-
const baseSha = await resolveRef(base);
|
|
4312
|
+
const baseSha = await resolveRef(root, base);
|
|
3559
4313
|
if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
|
|
3560
4314
|
|
|
3561
4315
|
let head = headRaw;
|
|
@@ -3565,13 +4319,15 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
|
3565
4319
|
} else {
|
|
3566
4320
|
if (!isValidRef(head))
|
|
3567
4321
|
return res.status(400).json({ error: "Invalid head ref" });
|
|
3568
|
-
headSha = await resolveRef(head);
|
|
4322
|
+
headSha = await resolveRef(root, head);
|
|
3569
4323
|
if (!headSha) return res.status(404).json({ error: "Head ref not found" });
|
|
3570
4324
|
}
|
|
3571
4325
|
|
|
3572
4326
|
try {
|
|
3573
|
-
const files = await getChangedFiles(base, head, mode);
|
|
4327
|
+
const files = await getChangedFiles(root, base, head, mode);
|
|
3574
4328
|
res.json({
|
|
4329
|
+
rootId: root.id,
|
|
4330
|
+
rootName: root.name,
|
|
3575
4331
|
base,
|
|
3576
4332
|
baseSha,
|
|
3577
4333
|
head: mode === "working-tree" ? WORKING_TREE_SENTINEL : head,
|
|
@@ -3586,20 +4342,27 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
|
|
|
3586
4342
|
});
|
|
3587
4343
|
|
|
3588
4344
|
app.get("/api/code-context/git/diff-file", async (req, res) => {
|
|
3589
|
-
if (
|
|
4345
|
+
if (GIT_DIFF_ROOTS.length === 0)
|
|
4346
|
+
return res.status(400).json({ error: "No git diff roots" });
|
|
3590
4347
|
|
|
3591
4348
|
const base = String(req.query.base || "").trim();
|
|
3592
4349
|
const headRaw = String(req.query.head || "").trim();
|
|
3593
4350
|
const mode = parseDiffMode(req.query.mode);
|
|
3594
4351
|
const filePath = String(req.query.path || "").trim();
|
|
3595
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
|
+
);
|
|
3596
4357
|
|
|
3597
4358
|
if (!isValidRef(base))
|
|
3598
4359
|
return res.status(400).json({ error: "Invalid base ref" });
|
|
3599
|
-
if (!
|
|
3600
|
-
return res.status(400).json({ error: "Invalid path" });
|
|
4360
|
+
if (!fileRef) return res.status(400).json({ error: "Invalid path" });
|
|
3601
4361
|
|
|
3602
|
-
const
|
|
4362
|
+
const { root } = fileRef;
|
|
4363
|
+
const relPath = fileRef.relPath;
|
|
4364
|
+
|
|
4365
|
+
const baseSha = await resolveRef(root, base);
|
|
3603
4366
|
if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
|
|
3604
4367
|
|
|
3605
4368
|
let head = headRaw;
|
|
@@ -3608,15 +4371,15 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
|
|
|
3608
4371
|
} else {
|
|
3609
4372
|
if (!isValidRef(head))
|
|
3610
4373
|
return res.status(400).json({ error: "Invalid head ref" });
|
|
3611
|
-
const headSha = await resolveRef(head);
|
|
4374
|
+
const headSha = await resolveRef(root, head);
|
|
3612
4375
|
if (!headSha) return res.status(404).json({ error: "Head ref not found" });
|
|
3613
4376
|
}
|
|
3614
4377
|
|
|
3615
4378
|
// Verify the path is actually part of the diff so callers cannot read arbitrary repo files.
|
|
3616
4379
|
let entry: GitDiffFileEntry | undefined;
|
|
3617
4380
|
try {
|
|
3618
|
-
const files = await getChangedFiles(base, head, mode);
|
|
3619
|
-
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);
|
|
3620
4383
|
} catch (err: any) {
|
|
3621
4384
|
return res.status(500).json({ error: err?.message || "git diff failed" });
|
|
3622
4385
|
}
|
|
@@ -3625,23 +4388,53 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
|
|
|
3625
4388
|
try {
|
|
3626
4389
|
if (view === "before") {
|
|
3627
4390
|
if (entry.status === "A" || entry.status === "?") {
|
|
3628
|
-
return res.json({
|
|
4391
|
+
return res.json({
|
|
4392
|
+
path: fileRef.id,
|
|
4393
|
+
rootId: root.id,
|
|
4394
|
+
rootName: root.name,
|
|
4395
|
+
view,
|
|
4396
|
+
content: "",
|
|
4397
|
+
});
|
|
3629
4398
|
}
|
|
3630
|
-
const refPath = entry.oldPath ??
|
|
3631
|
-
const content = await getFileAtRef(base, refPath);
|
|
3632
|
-
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
|
+
});
|
|
3633
4408
|
}
|
|
3634
4409
|
if (view === "after") {
|
|
3635
4410
|
if (entry.status === "D") {
|
|
3636
|
-
return res.json({
|
|
4411
|
+
return res.json({
|
|
4412
|
+
path: fileRef.id,
|
|
4413
|
+
rootId: root.id,
|
|
4414
|
+
rootName: root.name,
|
|
4415
|
+
view,
|
|
4416
|
+
content: "",
|
|
4417
|
+
});
|
|
3637
4418
|
}
|
|
3638
4419
|
const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
|
|
3639
|
-
const content = await getFileAtRef(ref,
|
|
3640
|
-
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
|
+
});
|
|
3641
4428
|
}
|
|
3642
4429
|
// default: patch
|
|
3643
|
-
const patch = await getDiffPatch(base, head, mode,
|
|
3644
|
-
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
|
+
});
|
|
3645
4438
|
} catch (err: any) {
|
|
3646
4439
|
res.status(500).json({ error: err?.message || "git read failed" });
|
|
3647
4440
|
}
|
|
@@ -3728,14 +4521,15 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
3728
4521
|
const scopedCodeContextFiles =
|
|
3729
4522
|
await getScopedCodeContextFiles(codeContextFiles);
|
|
3730
4523
|
|
|
3731
|
-
if (scopedCodeContextFiles.length &&
|
|
4524
|
+
if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
|
|
3732
4525
|
for (const fp of scopedCodeContextFiles) {
|
|
3733
|
-
const
|
|
3734
|
-
if (!
|
|
4526
|
+
const ref = getCodeContextFileRef(fp);
|
|
4527
|
+
if (!ref) continue;
|
|
3735
4528
|
fileRegistry.set(
|
|
3736
4529
|
`code:${fp}`,
|
|
3737
|
-
makeCodeReferenceFileEntry(
|
|
3738
|
-
|
|
4530
|
+
makeCodeReferenceFileEntry(
|
|
4531
|
+
`[code:${ref.root.name}] ${ref.relPath}`,
|
|
4532
|
+
() => fs.readFile(ref.absolutePath, "utf-8"),
|
|
3739
4533
|
),
|
|
3740
4534
|
);
|
|
3741
4535
|
}
|
|
@@ -3776,7 +4570,7 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
3776
4570
|
}
|
|
3777
4571
|
|
|
3778
4572
|
if (codeFilePaths.length > 0) {
|
|
3779
|
-
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
|
|
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.`;
|
|
3780
4574
|
}
|
|
3781
4575
|
}
|
|
3782
4576
|
|
|
@@ -3816,6 +4610,7 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
3816
4610
|
? { readFile: createReadFileTool(fileRegistry) }
|
|
3817
4611
|
: {}),
|
|
3818
4612
|
...createCodeContextTools(scopedCodeContextFiles),
|
|
4613
|
+
...createGitDiffTools(gitDiffContext || {}),
|
|
3819
4614
|
}
|
|
3820
4615
|
: undefined,
|
|
3821
4616
|
stopWhen: stepCountIs(4),
|