create-interview-cockpit 0.24.0 → 0.25.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, useCallback } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
2
2
  import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
3
3
  import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
4
4
  import {
@@ -72,9 +72,208 @@ const MIN_W = 380;
72
72
  const MIN_H = 260;
73
73
  const DEFAULT_W = 720;
74
74
  const DEFAULT_H = 520;
75
+ const LARGE_FILE_LINE_THRESHOLD = 2_500;
76
+ const LARGE_FILE_CHAR_THRESHOLD = 220_000;
77
+ const VIRTUAL_ROW_HEIGHT = 20;
78
+ const VIRTUAL_OVERSCAN = 24;
75
79
 
76
80
  type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
77
81
 
82
+ interface CodeViewerContentProps {
83
+ content: string;
84
+ lang: string;
85
+ selectedLines: Set<number>;
86
+ chatContextLines: Set<number>;
87
+ codeAnnotations: CodeAnnotation[];
88
+ onLineClick: (e: React.MouseEvent, lineNumber: number) => void;
89
+ }
90
+
91
+ const CodeViewerContent = memo(function CodeViewerContent({
92
+ content,
93
+ lang,
94
+ selectedLines,
95
+ chatContextLines,
96
+ codeAnnotations,
97
+ onLineClick,
98
+ }: CodeViewerContentProps) {
99
+ const annotationLineSet = useMemo(
100
+ () => new Set(codeAnnotations.map((annotation) => annotation.lineNumber)),
101
+ [codeAnnotations],
102
+ );
103
+ const lineCount = useMemo(() => content.split(/\r?\n/).length, [content]);
104
+ const isLargeFile =
105
+ lineCount > LARGE_FILE_LINE_THRESHOLD ||
106
+ content.length > LARGE_FILE_CHAR_THRESHOLD;
107
+
108
+ if (isLargeFile) {
109
+ return (
110
+ <VirtualizedPlainCodeViewer
111
+ content={content}
112
+ selectedLines={selectedLines}
113
+ chatContextLines={chatContextLines}
114
+ annotationLineSet={annotationLineSet}
115
+ onLineClick={onLineClick}
116
+ />
117
+ );
118
+ }
119
+
120
+ return (
121
+ <div className="h-full overflow-auto">
122
+ <SyntaxHighlighter
123
+ language={lang}
124
+ style={oneDark}
125
+ showLineNumbers
126
+ wrapLines
127
+ wrapLongLines={false}
128
+ lineProps={(lineNumber) => {
129
+ const hasAnnotation = annotationLineSet.has(lineNumber);
130
+ const isChatCtx = chatContextLines.has(lineNumber);
131
+ const isSelected = selectedLines.has(lineNumber);
132
+ let bg: string | undefined;
133
+ let outline: string | undefined;
134
+ if (hasAnnotation) {
135
+ bg = "rgba(139, 92, 246, 0.2)";
136
+ outline = "1px solid rgba(139, 92, 246, 0.35)";
137
+ } else if (isChatCtx) {
138
+ bg = "rgba(245, 158, 11, 0.15)";
139
+ outline = "1px solid rgba(245, 158, 11, 0.3)";
140
+ } else if (isSelected) {
141
+ bg = "rgba(6, 182, 212, 0.15)";
142
+ outline = "1px solid rgba(6, 182, 212, 0.3)";
143
+ }
144
+ return {
145
+ onClick: (e: React.MouseEvent) =>
146
+ lineNumber !== undefined && onLineClick(e, lineNumber),
147
+ style: {
148
+ display: "block",
149
+ cursor: "pointer",
150
+ backgroundColor: bg,
151
+ outline,
152
+ },
153
+ };
154
+ }}
155
+ customStyle={{
156
+ margin: 0,
157
+ borderRadius: 0,
158
+ background: "#0f172a",
159
+ fontSize: "0.75rem",
160
+ lineHeight: "1.6",
161
+ minHeight: "100%",
162
+ }}
163
+ lineNumberStyle={{ color: "#334155", minWidth: "2.8em" }}
164
+ >
165
+ {content}
166
+ </SyntaxHighlighter>
167
+ </div>
168
+ );
169
+ });
170
+
171
+ interface VirtualizedPlainCodeViewerProps {
172
+ content: string;
173
+ selectedLines: Set<number>;
174
+ chatContextLines: Set<number>;
175
+ annotationLineSet: Set<number>;
176
+ onLineClick: (e: React.MouseEvent, lineNumber: number) => void;
177
+ }
178
+
179
+ function VirtualizedPlainCodeViewer({
180
+ content,
181
+ selectedLines,
182
+ chatContextLines,
183
+ annotationLineSet,
184
+ onLineClick,
185
+ }: VirtualizedPlainCodeViewerProps) {
186
+ const lines = useMemo(() => content.split(/\r?\n/), [content]);
187
+ const containerRef = useRef<HTMLDivElement>(null);
188
+ const [scrollTop, setScrollTop] = useState(0);
189
+ const [viewportHeight, setViewportHeight] = useState(0);
190
+
191
+ useEffect(() => {
192
+ const el = containerRef.current;
193
+ if (!el) return;
194
+ const update = () => setViewportHeight(el.clientHeight);
195
+ update();
196
+ const observer = new ResizeObserver(update);
197
+ observer.observe(el);
198
+ return () => observer.disconnect();
199
+ }, []);
200
+
201
+ const visibleStart = Math.max(
202
+ 0,
203
+ Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN,
204
+ );
205
+ const visibleEnd = Math.min(
206
+ lines.length,
207
+ Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) +
208
+ VIRTUAL_OVERSCAN,
209
+ );
210
+ const visibleLines = lines.slice(visibleStart, visibleEnd);
211
+
212
+ return (
213
+ <div
214
+ ref={containerRef}
215
+ className="h-full overflow-auto bg-slate-950 font-mono text-xs text-slate-300"
216
+ onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
217
+ >
218
+ <div
219
+ style={{
220
+ height: lines.length * VIRTUAL_ROW_HEIGHT,
221
+ position: "relative",
222
+ minWidth: "max-content",
223
+ }}
224
+ >
225
+ <div
226
+ style={{
227
+ position: "absolute",
228
+ top: visibleStart * VIRTUAL_ROW_HEIGHT,
229
+ left: 0,
230
+ right: 0,
231
+ }}
232
+ >
233
+ {visibleLines.map((line, offset) => {
234
+ const lineNumber = visibleStart + offset + 1;
235
+ const hasAnnotation = annotationLineSet.has(lineNumber);
236
+ const isChatCtx = chatContextLines.has(lineNumber);
237
+ const isSelected = selectedLines.has(lineNumber);
238
+ const backgroundColor = hasAnnotation
239
+ ? "rgba(139, 92, 246, 0.2)"
240
+ : isChatCtx
241
+ ? "rgba(245, 158, 11, 0.15)"
242
+ : isSelected
243
+ ? "rgba(6, 182, 212, 0.15)"
244
+ : undefined;
245
+ const outline = hasAnnotation
246
+ ? "1px solid rgba(139, 92, 246, 0.35)"
247
+ : isChatCtx
248
+ ? "1px solid rgba(245, 158, 11, 0.3)"
249
+ : isSelected
250
+ ? "1px solid rgba(6, 182, 212, 0.3)"
251
+ : undefined;
252
+
253
+ return (
254
+ <div
255
+ key={lineNumber}
256
+ onClick={(e) => onLineClick(e, lineNumber)}
257
+ className="flex cursor-pointer whitespace-pre leading-none hover:bg-slate-800/60"
258
+ style={{
259
+ height: VIRTUAL_ROW_HEIGHT,
260
+ backgroundColor,
261
+ outline,
262
+ }}
263
+ >
264
+ <span className="sticky left-0 z-10 w-12 shrink-0 select-none bg-slate-950 pr-3 text-right text-slate-600">
265
+ {lineNumber}
266
+ </span>
267
+ <span className="pr-6">{line || " "}</span>
268
+ </div>
269
+ );
270
+ })}
271
+ </div>
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
78
277
  export default function FileViewerModal({ filePath, onClose }: Props) {
79
278
  const {
80
279
  addSnippet,
@@ -598,7 +797,7 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
598
797
  </div>
599
798
 
600
799
  {/* ── Content ── */}
601
- <div className="flex-1 overflow-auto">
800
+ <div className="flex-1 min-h-0">
602
801
  {loading && (
603
802
  <div className="flex items-center justify-center h-full">
604
803
  <Loader2 className="w-5 h-5 text-cyan-400 animate-spin" />
@@ -610,53 +809,14 @@ export default function FileViewerModal({ filePath, onClose }: Props) {
610
809
  </div>
611
810
  )}
612
811
  {!loading && !error && content !== null && (
613
- <SyntaxHighlighter
614
- language={lang}
615
- style={oneDark}
616
- showLineNumbers
617
- wrapLines
618
- wrapLongLines={false}
619
- lineProps={(lineNumber) => {
620
- const hasAnnotation = codeAnnotations.some(
621
- (a) => a.lineNumber === lineNumber,
622
- );
623
- const isChatCtx = chatContextLines.has(lineNumber);
624
- const isSelected = selectedLines.has(lineNumber);
625
- let bg: string | undefined;
626
- let outline: string | undefined;
627
- if (hasAnnotation) {
628
- bg = "rgba(139, 92, 246, 0.2)";
629
- outline = "1px solid rgba(139, 92, 246, 0.35)";
630
- } else if (isChatCtx) {
631
- bg = "rgba(245, 158, 11, 0.15)";
632
- outline = "1px solid rgba(245, 158, 11, 0.3)";
633
- } else if (isSelected) {
634
- bg = "rgba(6, 182, 212, 0.15)";
635
- outline = "1px solid rgba(6, 182, 212, 0.3)";
636
- }
637
- return {
638
- onClick: (e: React.MouseEvent) =>
639
- lineNumber !== undefined && handleLineClick(e, lineNumber),
640
- style: {
641
- display: "block",
642
- cursor: "pointer",
643
- backgroundColor: bg,
644
- outline,
645
- },
646
- };
647
- }}
648
- customStyle={{
649
- margin: 0,
650
- borderRadius: 0,
651
- background: "#0f172a",
652
- fontSize: "0.75rem",
653
- lineHeight: "1.6",
654
- minHeight: "100%",
655
- }}
656
- lineNumberStyle={{ color: "#334155", minWidth: "2.8em" }}
657
- >
658
- {content}
659
- </SyntaxHighlighter>
812
+ <CodeViewerContent
813
+ content={content}
814
+ lang={lang}
815
+ selectedLines={selectedLines}
816
+ chatContextLines={chatContextLines}
817
+ codeAnnotations={codeAnnotations}
818
+ onLineClick={handleLineClick}
819
+ />
660
820
  )}
661
821
  </div>
662
822
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.23.1"
2
+ "version": "0.24.0"
3
3
  }
@@ -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";
@@ -345,6 +346,727 @@ function createReadFileTool(fileRegistry: Map<string, ReferenceFileEntry>) {
345
346
  const PORT = process.env.PORT || 3001;
346
347
  const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
347
348
 
349
+ const MAX_CODE_TOOL_LIST_RESULTS = 400;
350
+ const MAX_CODE_TOOL_SEARCH_RESULTS = 80;
351
+ const MAX_CODE_TOOL_SEARCH_FILE_BYTES = 512 * 1024;
352
+ const MAX_CODE_TOOL_READ_BYTES = 256 * 1024;
353
+ const MAX_CODE_TOOL_CONTEXT_LINES = 5;
354
+ const MAX_CODE_TOOL_LINE_CHARS = 600;
355
+
356
+ function normalizeCodeContextRelPath(value: string): string | null {
357
+ const raw = value.replaceAll("\\", "/").trim();
358
+ if (!raw || raw.includes("\0") || path.isAbsolute(raw)) return null;
359
+ const normalized = path.posix.normalize(raw.replace(/^\.\//, ""));
360
+ if (!normalized || normalized === ".") return null;
361
+ if (normalized === ".." || normalized.startsWith("../")) return null;
362
+ return normalized;
363
+ }
364
+
365
+ function resolveCodeContextFilePath(relPath: string): string | null {
366
+ if (!CODE_CONTEXT_DIR) return null;
367
+ const normalized = normalizeCodeContextRelPath(relPath);
368
+ if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
369
+
370
+ const root = path.resolve(CODE_CONTEXT_DIR);
371
+ const resolved = path.resolve(root, normalized);
372
+ const relativeToRoot = path.relative(root, resolved);
373
+ if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
374
+ return null;
375
+ }
376
+ return resolved;
377
+ }
378
+
379
+ async function getScopedCodeContextFiles(
380
+ selectedFiles: unknown,
381
+ ): Promise<string[]> {
382
+ if (!CODE_CONTEXT_DIR || !Array.isArray(selectedFiles)) return [];
383
+
384
+ const selected = selectedFiles
385
+ .filter((file): file is string => typeof file === "string")
386
+ .map((file) => normalizeCodeContextRelPath(file))
387
+ .filter((file): file is string => Boolean(file));
388
+
389
+ if (selected.length === 0) return [];
390
+
391
+ try {
392
+ const visibleFiles = new Set(await walkDir(CODE_CONTEXT_DIR));
393
+ return uniqueStrings(selected).filter((file) => visibleFiles.has(file));
394
+ } catch {
395
+ return [];
396
+ }
397
+ }
398
+
399
+ function escapeRegExp(value: string): string {
400
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
401
+ }
402
+
403
+ function clampNumber(
404
+ value: unknown,
405
+ min: number,
406
+ max: number,
407
+ fallback: number,
408
+ ) {
409
+ const n =
410
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
411
+ return Math.min(Math.max(Math.floor(n), min), max);
412
+ }
413
+
414
+ function trimToolLine(line: string): string {
415
+ return line.length > MAX_CODE_TOOL_LINE_CHARS
416
+ ? `${line.slice(0, MAX_CODE_TOOL_LINE_CHARS)}…`
417
+ : line;
418
+ }
419
+
420
+ function codePathMatchesFilter(relPath: string, filter?: string): boolean {
421
+ const normalizedFilter = filter?.trim().replaceAll("\\", "/").toLowerCase();
422
+ if (!normalizedFilter) return true;
423
+ return relPath.toLowerCase().includes(normalizedFilter);
424
+ }
425
+
426
+ function formatCodeFileTree(paths: string[], limit: number): string {
427
+ const lines: string[] = [];
428
+ const seenDirs = new Set<string>();
429
+ for (const relPath of paths.slice(0, limit)) {
430
+ const parts = relPath.split("/");
431
+ for (let i = 0; i < parts.length - 1; i++) {
432
+ const dirPath = parts.slice(0, i + 1).join("/");
433
+ if (seenDirs.has(dirPath)) continue;
434
+ seenDirs.add(dirPath);
435
+ lines.push(`${" ".repeat(i)}${parts[i]}/`);
436
+ }
437
+ lines.push(`${" ".repeat(Math.max(0, parts.length - 1))}${parts.at(-1)}`);
438
+ }
439
+ return lines.join("\n");
440
+ }
441
+
442
+ async function readCodeTextWithinLimit(
443
+ relPath: string,
444
+ maxBytes = MAX_CODE_TOOL_READ_BYTES,
445
+ ): Promise<{ content: string; truncated: boolean; byteLength: number }> {
446
+ const resolved = resolveCodeContextFilePath(relPath);
447
+ if (!resolved) throw new Error("Access denied");
448
+ const buffer = await fs.readFile(resolved);
449
+ const truncated = buffer.byteLength > maxBytes;
450
+ return {
451
+ content: buffer.slice(0, maxBytes).toString("utf-8"),
452
+ truncated,
453
+ byteLength: buffer.byteLength,
454
+ };
455
+ }
456
+
457
+ async function searchCodeInFiles(
458
+ scopedFiles: string[],
459
+ params: {
460
+ query: string;
461
+ isRegex?: boolean;
462
+ caseSensitive?: boolean;
463
+ pathFilter?: string;
464
+ maxResults?: number;
465
+ contextLines?: number;
466
+ wholeWord?: boolean;
467
+ },
468
+ ) {
469
+ const query = params.query.trim();
470
+ if (!query) return { type: "error", error: "query is required" };
471
+ if (query.length > 500) {
472
+ return { type: "error", error: "query is too long" };
473
+ }
474
+
475
+ const maxResults = clampNumber(
476
+ params.maxResults,
477
+ 1,
478
+ MAX_CODE_TOOL_SEARCH_RESULTS,
479
+ 50,
480
+ );
481
+ const contextLines = clampNumber(
482
+ params.contextLines,
483
+ 0,
484
+ MAX_CODE_TOOL_CONTEXT_LINES,
485
+ 1,
486
+ );
487
+
488
+ let matcher: RegExp;
489
+ try {
490
+ const source = params.isRegex ? query : escapeRegExp(query);
491
+ const wholeWord =
492
+ params.wholeWord && /^[A-Za-z_$][\w$]*$/.test(query)
493
+ ? `\\b${source}\\b`
494
+ : source;
495
+ matcher = new RegExp(wholeWord, params.caseSensitive ? "" : "i");
496
+ } catch (err: any) {
497
+ return { type: "error", error: err?.message || "Invalid regex" };
498
+ }
499
+
500
+ const candidateFiles = scopedFiles.filter((file) =>
501
+ codePathMatchesFilter(file, params.pathFilter),
502
+ );
503
+ const matches: Array<{
504
+ path: string;
505
+ line: number;
506
+ lineText: string;
507
+ before: Array<{ line: number; text: string }>;
508
+ after: Array<{ line: number; text: string }>;
509
+ }> = [];
510
+ const truncatedFiles: string[] = [];
511
+ const unreadableFiles: string[] = [];
512
+
513
+ for (const relPath of candidateFiles) {
514
+ let content: string;
515
+ try {
516
+ const read = await readCodeTextWithinLimit(
517
+ relPath,
518
+ MAX_CODE_TOOL_SEARCH_FILE_BYTES,
519
+ );
520
+ content = read.content;
521
+ if (read.truncated) truncatedFiles.push(relPath);
522
+ } catch {
523
+ unreadableFiles.push(relPath);
524
+ continue;
525
+ }
526
+
527
+ const lines = content.split(/\r?\n/);
528
+ for (let i = 0; i < lines.length; i++) {
529
+ if (!matcher.test(lines[i])) continue;
530
+ matcher.lastIndex = 0;
531
+ const beforeStart = Math.max(0, i - contextLines);
532
+ const afterEnd = Math.min(lines.length, i + contextLines + 1);
533
+ matches.push({
534
+ path: relPath,
535
+ line: i + 1,
536
+ lineText: trimToolLine(lines[i]),
537
+ before: lines.slice(beforeStart, i).map((text, offset) => ({
538
+ line: beforeStart + offset + 1,
539
+ text: trimToolLine(text),
540
+ })),
541
+ after: lines.slice(i + 1, afterEnd).map((text, offset) => ({
542
+ line: i + offset + 2,
543
+ text: trimToolLine(text),
544
+ })),
545
+ });
546
+ if (matches.length >= maxResults) {
547
+ return {
548
+ query,
549
+ searchedFiles: candidateFiles.length,
550
+ truncated: true,
551
+ truncatedFiles,
552
+ unreadableFiles,
553
+ matches,
554
+ };
555
+ }
556
+ }
557
+ }
558
+
559
+ return {
560
+ query,
561
+ searchedFiles: candidateFiles.length,
562
+ truncated: false,
563
+ truncatedFiles,
564
+ unreadableFiles,
565
+ matches,
566
+ };
567
+ }
568
+
569
+ function classifyConfigFile(relPath: string): string | null {
570
+ const lower = relPath.toLowerCase();
571
+ const base = lower.split("/").at(-1) || lower;
572
+ if (/^\.github\/workflows\/.+\.ya?ml$/.test(lower)) {
573
+ return "github-actions-workflow";
574
+ }
575
+ if (base === "action.yml" || base === "action.yaml") return "github-action";
576
+ if (base === "azure-pipelines.yml" || base === "azure-pipelines.yaml") {
577
+ return "azure-pipelines";
578
+ }
579
+ if (base === ".gitlab-ci.yml" || base === ".gitlab-ci.yaml") {
580
+ return "gitlab-ci";
581
+ }
582
+ if (base === "config.yml" && lower.includes(".circleci/")) {
583
+ return "circleci";
584
+ }
585
+ if (base === "jenkinsfile") return "jenkins";
586
+ if (lower.includes("buildkite") && lower.endsWith(".yml")) {
587
+ return "buildkite";
588
+ }
589
+ if (base === "package.json") return "package-json";
590
+ if (base === "dockerfile" || base.endsWith(".dockerfile")) {
591
+ return "dockerfile";
592
+ }
593
+ if (
594
+ base === "docker-compose.yml" ||
595
+ base === "docker-compose.yaml" ||
596
+ base === "compose.yml" ||
597
+ base === "compose.yaml"
598
+ ) {
599
+ return "docker-compose";
600
+ }
601
+ if (lower.endsWith(".tf") || lower.endsWith(".tfvars")) return "terraform";
602
+ if (lower.includes("terraform/") && lower.endsWith(".hcl")) {
603
+ return "terraform-hcl";
604
+ }
605
+ return null;
606
+ }
607
+
608
+ function summarizeConfigStructure(relPath: string, content: string) {
609
+ const lower = relPath.toLowerCase();
610
+ if (lower.endsWith(".json")) {
611
+ try {
612
+ const parsed = JSON.parse(content);
613
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
614
+ const record = parsed as Record<string, unknown>;
615
+ return {
616
+ topLevelKeys: Object.keys(record).slice(0, 50),
617
+ scripts:
618
+ record.scripts && typeof record.scripts === "object"
619
+ ? Object.keys(record.scripts as Record<string, unknown>).slice(
620
+ 0,
621
+ 50,
622
+ )
623
+ : undefined,
624
+ };
625
+ }
626
+ } catch {
627
+ return { parseError: "Invalid JSON" };
628
+ }
629
+ }
630
+
631
+ if (/\.ya?ml$/i.test(relPath)) {
632
+ const topLevelKeys = uniqueStrings(
633
+ content
634
+ .split(/\r?\n/)
635
+ .map((line) => line.match(/^([A-Za-z0-9_.-]+):/)?.[1] || "")
636
+ .filter(Boolean),
637
+ ).slice(0, 50);
638
+ return { topLevelKeys };
639
+ }
640
+
641
+ if (/\.(tf|tfvars|hcl)$/i.test(relPath)) {
642
+ const blocks = uniqueStrings(
643
+ content
644
+ .split(/\r?\n/)
645
+ .map(
646
+ (line) =>
647
+ line
648
+ .trim()
649
+ .match(
650
+ /^(terraform|provider|resource|module|variable|output|data|locals|backend)\b.*$/,
651
+ )?.[0],
652
+ )
653
+ .filter((line): line is string => Boolean(line)),
654
+ ).slice(0, 60);
655
+ return { blocks };
656
+ }
657
+
658
+ return {};
659
+ }
660
+
661
+ function extractConfigHighlights(content: string) {
662
+ const interesting =
663
+ /\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;
664
+ return content
665
+ .split(/\r?\n/)
666
+ .map((text, index) => ({ line: index + 1, text: trimToolLine(text) }))
667
+ .filter(({ text }) => interesting.test(text))
668
+ .slice(0, 30);
669
+ }
670
+
671
+ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
672
+ if (!CODE_CONTEXT_DIR || scopedCodeFiles.length === 0) return {};
673
+
674
+ const files = uniqueStrings(scopedCodeFiles).sort((a, b) =>
675
+ a.localeCompare(b),
676
+ );
677
+ const fileSet = new Set(files);
678
+
679
+ return {
680
+ listCodeFiles: tool({
681
+ 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 CODE_CONTEXT_DIR, after ignore rules are applied.",
683
+ inputSchema: z.object({
684
+ query: z
685
+ .string()
686
+ .optional()
687
+ .describe(
688
+ "Optional case-insensitive path substring to filter files.",
689
+ ),
690
+ folder: z
691
+ .string()
692
+ .optional()
693
+ .describe(
694
+ "Optional folder prefix or path substring to narrow results.",
695
+ ),
696
+ limit: z
697
+ .number()
698
+ .int()
699
+ .min(1)
700
+ .max(MAX_CODE_TOOL_LIST_RESULTS)
701
+ .optional(),
702
+ }),
703
+ execute: async ({ query, folder, limit }) => {
704
+ const cappedLimit = clampNumber(
705
+ limit,
706
+ 1,
707
+ MAX_CODE_TOOL_LIST_RESULTS,
708
+ 120,
709
+ );
710
+ const filtered = files.filter(
711
+ (file) =>
712
+ codePathMatchesFilter(file, query) &&
713
+ codePathMatchesFilter(file, folder),
714
+ );
715
+ const listed = filtered.slice(0, cappedLimit);
716
+ return {
717
+ scope: "selected-code-context-files",
718
+ totalAvailable: files.length,
719
+ totalMatched: filtered.length,
720
+ truncated: filtered.length > listed.length,
721
+ files: listed,
722
+ tree: formatCodeFileTree(listed, cappedLimit),
723
+ };
724
+ },
725
+ }),
726
+ searchCode: tool({
727
+ description:
728
+ "Search text across available code-context files. Use this before opening many files. It cannot see ignored or unselected files.",
729
+ inputSchema: z.object({
730
+ query: z.string().describe("Text or regex to search for."),
731
+ isRegex: z
732
+ .boolean()
733
+ .optional()
734
+ .describe("Treat query as a JavaScript regular expression."),
735
+ caseSensitive: z.boolean().optional(),
736
+ pathFilter: z
737
+ .string()
738
+ .optional()
739
+ .describe(
740
+ "Optional path substring, e.g. .github/workflows or terraform.",
741
+ ),
742
+ maxResults: z
743
+ .number()
744
+ .int()
745
+ .min(1)
746
+ .max(MAX_CODE_TOOL_SEARCH_RESULTS)
747
+ .optional(),
748
+ contextLines: z
749
+ .number()
750
+ .int()
751
+ .min(0)
752
+ .max(MAX_CODE_TOOL_CONTEXT_LINES)
753
+ .optional(),
754
+ }),
755
+ execute: async (params) => searchCodeInFiles(files, params),
756
+ }),
757
+ readCodeFile: tool({
758
+ description:
759
+ "Read one available code-context file by relative path, optionally with a 1-based line range. Restricted to selected, non-ignored files.",
760
+ inputSchema: z.object({
761
+ path: z
762
+ .string()
763
+ .describe("Relative path from the code context file list."),
764
+ startLine: z.number().int().min(1).optional(),
765
+ endLine: z.number().int().min(1).optional(),
766
+ }),
767
+ execute: async ({ path: requestedPath, startLine, endLine }) => {
768
+ const relPath = normalizeCodeContextRelPath(requestedPath || "");
769
+ if (!relPath || !fileSet.has(relPath)) {
770
+ return {
771
+ type: "error",
772
+ error:
773
+ "File is not available. Use listCodeFiles first; only selected, non-ignored files can be read.",
774
+ };
775
+ }
776
+
777
+ try {
778
+ const read = await readCodeTextWithinLimit(relPath);
779
+ const lines = read.content.split(/\r?\n/);
780
+ const totalLines = lines.length;
781
+ const first = clampNumber(startLine, 1, totalLines || 1, 1);
782
+ const last = clampNumber(
783
+ endLine,
784
+ first,
785
+ totalLines || first,
786
+ totalLines || first,
787
+ );
788
+ const numbered = lines
789
+ .slice(first - 1, last)
790
+ .map((line, index) => `${first + index}: ${line}`)
791
+ .join("\n");
792
+ return {
793
+ path: relPath,
794
+ startLine: first,
795
+ endLine: last,
796
+ totalLines,
797
+ byteLength: read.byteLength,
798
+ truncated: read.truncated,
799
+ content: numbered,
800
+ };
801
+ } catch (err: any) {
802
+ return {
803
+ type: "error",
804
+ error: err?.message || "Could not read file",
805
+ };
806
+ }
807
+ },
808
+ }),
809
+ findCodeReferences: tool({
810
+ description:
811
+ "Find textual references to a symbol, function name, workflow name, file name, variable, or config key across available code-context files.",
812
+ inputSchema: z.object({
813
+ symbol: z.string().describe("Exact symbol or phrase to find."),
814
+ pathFilter: z.string().optional(),
815
+ caseSensitive: z.boolean().optional(),
816
+ maxResults: z
817
+ .number()
818
+ .int()
819
+ .min(1)
820
+ .max(MAX_CODE_TOOL_SEARCH_RESULTS)
821
+ .optional(),
822
+ contextLines: z
823
+ .number()
824
+ .int()
825
+ .min(0)
826
+ .max(MAX_CODE_TOOL_CONTEXT_LINES)
827
+ .optional(),
828
+ }),
829
+ execute: async ({
830
+ symbol,
831
+ pathFilter,
832
+ caseSensitive,
833
+ maxResults,
834
+ contextLines,
835
+ }) =>
836
+ searchCodeInFiles(files, {
837
+ query: symbol,
838
+ pathFilter,
839
+ caseSensitive,
840
+ maxResults,
841
+ contextLines,
842
+ wholeWord: true,
843
+ }),
844
+ }),
845
+ inspectCiConfig: tool({
846
+ description:
847
+ "Find and summarize CI/CD, GitHub Actions, Docker, package script, and Terraform config files among the available code-context files.",
848
+ inputSchema: z.object({
849
+ pathFilter: z.string().optional(),
850
+ maxFiles: z.number().int().min(1).max(30).optional(),
851
+ }),
852
+ execute: async ({ pathFilter, maxFiles }) => {
853
+ const limit = clampNumber(maxFiles, 1, 30, 12);
854
+ const candidates = files
855
+ .map((file) => ({ file, kind: classifyConfigFile(file) }))
856
+ .filter(
857
+ (entry): entry is { file: string; kind: string } =>
858
+ Boolean(entry.kind) &&
859
+ codePathMatchesFilter(entry.file, pathFilter),
860
+ );
861
+
862
+ const inspected = [];
863
+ for (const { file, kind } of candidates.slice(0, limit)) {
864
+ try {
865
+ const read = await readCodeTextWithinLimit(file);
866
+ inspected.push({
867
+ path: file,
868
+ kind,
869
+ truncated: read.truncated,
870
+ structure: summarizeConfigStructure(file, read.content),
871
+ highlights: extractConfigHighlights(read.content),
872
+ });
873
+ } catch (err: any) {
874
+ inspected.push({
875
+ path: file,
876
+ kind,
877
+ error: err?.message || "Could not inspect file",
878
+ });
879
+ }
880
+ }
881
+
882
+ return {
883
+ totalConfigFiles: candidates.length,
884
+ truncated: candidates.length > inspected.length,
885
+ files: inspected,
886
+ };
887
+ },
888
+ }),
889
+ runReadOnlyCodeCommand: tool({
890
+ description:
891
+ "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.",
892
+ inputSchema: z.object({
893
+ command: z.enum([
894
+ "pwd",
895
+ "find",
896
+ "rg",
897
+ "git-ls-files",
898
+ "git-log",
899
+ "git-blame",
900
+ ]),
901
+ query: z
902
+ .string()
903
+ .optional()
904
+ .describe("Required for rg. Optional for find."),
905
+ path: z
906
+ .string()
907
+ .optional()
908
+ .describe("Required for git-log and git-blame."),
909
+ pathFilter: z.string().optional(),
910
+ startLine: z.number().int().min(1).optional(),
911
+ endLine: z.number().int().min(1).optional(),
912
+ maxResults: z
913
+ .number()
914
+ .int()
915
+ .min(1)
916
+ .max(MAX_CODE_TOOL_LIST_RESULTS)
917
+ .optional(),
918
+ }),
919
+ execute: async ({
920
+ command,
921
+ query,
922
+ path: requestedPath,
923
+ pathFilter,
924
+ startLine,
925
+ endLine,
926
+ maxResults,
927
+ }) => {
928
+ const limit = clampNumber(
929
+ maxResults,
930
+ 1,
931
+ MAX_CODE_TOOL_LIST_RESULTS,
932
+ 100,
933
+ );
934
+ if (command === "pwd") {
935
+ return {
936
+ command: "pwd",
937
+ output: CODE_CONTEXT_DIR,
938
+ note: "Read-only tools are still restricted to selected, non-ignored code-context files.",
939
+ };
940
+ }
941
+ if (command === "find") {
942
+ const filtered = files
943
+ .filter(
944
+ (file) =>
945
+ codePathMatchesFilter(file, query) &&
946
+ codePathMatchesFilter(file, pathFilter),
947
+ )
948
+ .slice(0, limit);
949
+ return {
950
+ command: "find",
951
+ files: filtered,
952
+ truncated: filtered.length >= limit,
953
+ };
954
+ }
955
+ if (command === "rg") {
956
+ if (!query?.trim())
957
+ return { type: "error", error: "query is required for rg" };
958
+ return searchCodeInFiles(files, {
959
+ query,
960
+ pathFilter,
961
+ maxResults: Math.min(limit, MAX_CODE_TOOL_SEARCH_RESULTS),
962
+ contextLines: 1,
963
+ });
964
+ }
965
+
966
+ if (command === "git-ls-files") {
967
+ try {
968
+ const result = await runGit(["ls-files", "-z"], {
969
+ cwd: CODE_CONTEXT_DIR,
970
+ allowFail: true,
971
+ timeoutMs: 5000,
972
+ maxBuffer: 1024 * 1024,
973
+ });
974
+ if (result.code !== 0) {
975
+ return {
976
+ type: "error",
977
+ error: result.stderr.trim() || "git ls-files failed",
978
+ };
979
+ }
980
+ const tracked = new Set(result.stdout.split("\0").filter(Boolean));
981
+ const filtered = files
982
+ .filter(
983
+ (file) =>
984
+ tracked.has(file) && codePathMatchesFilter(file, pathFilter),
985
+ )
986
+ .slice(0, limit);
987
+ return {
988
+ command: "git ls-files",
989
+ files: filtered,
990
+ truncated: filtered.length >= limit,
991
+ };
992
+ } catch (err: any) {
993
+ return {
994
+ type: "error",
995
+ error: err?.message || "git ls-files failed",
996
+ };
997
+ }
998
+ }
999
+
1000
+ const relPath = normalizeCodeContextRelPath(requestedPath || "");
1001
+ if (!relPath || !fileSet.has(relPath)) {
1002
+ return {
1003
+ type: "error",
1004
+ error:
1005
+ "path must be one of the selected, non-ignored code-context files",
1006
+ };
1007
+ }
1008
+
1009
+ if (command === "git-log") {
1010
+ try {
1011
+ const result = await runGit(
1012
+ [
1013
+ "log",
1014
+ "--oneline",
1015
+ "--decorate",
1016
+ `--max-count=${Math.min(limit, 50)}`,
1017
+ "--",
1018
+ relPath,
1019
+ ],
1020
+ {
1021
+ cwd: CODE_CONTEXT_DIR,
1022
+ allowFail: true,
1023
+ timeoutMs: 5000,
1024
+ maxBuffer: 512 * 1024,
1025
+ },
1026
+ );
1027
+ return {
1028
+ command: "git log",
1029
+ path: relPath,
1030
+ output:
1031
+ result.stdout.trim() ||
1032
+ result.stderr.trim() ||
1033
+ "No git history found.",
1034
+ };
1035
+ } catch (err: any) {
1036
+ return { type: "error", error: err?.message || "git log failed" };
1037
+ }
1038
+ }
1039
+
1040
+ try {
1041
+ const args = ["blame", "--date=short"];
1042
+ if (startLine || endLine) {
1043
+ const first = Math.max(1, startLine ?? 1);
1044
+ const last = Math.max(first, endLine ?? first);
1045
+ args.push("-L", `${first},${last}`);
1046
+ }
1047
+ args.push("--", relPath);
1048
+ const result = await runGit(args, {
1049
+ cwd: CODE_CONTEXT_DIR,
1050
+ allowFail: true,
1051
+ timeoutMs: 5000,
1052
+ maxBuffer: 512 * 1024,
1053
+ });
1054
+ return {
1055
+ command: "git blame",
1056
+ path: relPath,
1057
+ output:
1058
+ result.stdout.trim() ||
1059
+ result.stderr.trim() ||
1060
+ "No git blame output.",
1061
+ };
1062
+ } catch (err: any) {
1063
+ return { type: "error", error: err?.message || "git blame failed" };
1064
+ }
1065
+ },
1066
+ }),
1067
+ };
1068
+ }
1069
+
348
1070
  // ─── AI Providers (Vercel AI SDK) ────────────────────────
349
1071
  // Set the provider + model in .env. Supports: openai, google, anthropic
350
1072
 
@@ -1984,12 +2706,14 @@ app.post("/api/chat", async (req, res) => {
1984
2706
  }
1985
2707
  }
1986
2708
 
2709
+ const scopedCodeContextFiles =
2710
+ await getScopedCodeContextFiles(codeContextFiles);
2711
+
1987
2712
  // Code-context files from the project directory
1988
- if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
1989
- for (const filePath of codeContextFiles as string[]) {
1990
- const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
1991
- const resolved = path.resolve(fullPath);
1992
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
2713
+ if (scopedCodeContextFiles.length && CODE_CONTEXT_DIR) {
2714
+ for (const filePath of scopedCodeContextFiles) {
2715
+ const resolved = resolveCodeContextFilePath(filePath);
2716
+ if (!resolved) continue;
1993
2717
  fileRegistry.set(
1994
2718
  `code:${filePath}`,
1995
2719
  makeCodeReferenceFileEntry(`[code] ${filePath}`, () =>
@@ -2029,6 +2753,17 @@ For image files, readFile returns visual image data so you can inspect what is v
2029
2753
 
2030
2754
  if (codeFilePaths.length > 0) {
2031
2755
  system += `
2756
+ --- Code Context Exploration Tools ---
2757
+ You also have read-only code tools for the selected Code Context files:
2758
+ - listCodeFiles: see the selected, non-ignored file tree.
2759
+ - searchCode: search exact text or regex across selected files.
2760
+ - readCodeFile: open a selected file, optionally by line range.
2761
+ - findCodeReferences: trace textual usages of a symbol, config key, workflow, or file name.
2762
+ - inspectCiConfig: summarize CI/CD, package, Docker, and Terraform config among selected files.
2763
+ - runReadOnlyCodeCommand: safe whitelisted equivalents for pwd/find/rg/git-ls-files/git-log/git-blame.
2764
+
2765
+ These tools cannot access files outside CODE_CONTEXT_DIR, files hidden by ignore rules, or files the user did not select. If something is missing, ask the user to select more code context or adjust ignore settings.
2766
+
2032
2767
  --- Linking Code Files in Your Response ---
2033
2768
  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
2769
 
@@ -2160,6 +2895,7 @@ Examples (illustrative only — use real ids and names from the list above):
2160
2895
  readFile: createReadFileTool(fileRegistry),
2161
2896
  }
2162
2897
  : {}),
2898
+ ...createCodeContextTools(scopedCodeContextFiles),
2163
2899
  },
2164
2900
  stopWhen: stepCountIs(selectedResponseProfile.maxSteps),
2165
2901
  });
@@ -2359,6 +3095,24 @@ function hasIgnoredExtension(fileNameLower: string): boolean {
2359
3095
  return false;
2360
3096
  }
2361
3097
 
3098
+ function isCodeContextPathAllowed(relPath: string): boolean {
3099
+ const normalized = normalizeCodeContextRelPath(relPath);
3100
+ if (!normalized) return false;
3101
+
3102
+ const parts = normalized.split("/");
3103
+ for (let i = 0; i < parts.length - 1; i++) {
3104
+ const prefix = parts.slice(0, i + 1).join("/");
3105
+ if (IGNORE_DIRS.has(normalizeName(parts[i])) || shouldIgnorePath(prefix)) {
3106
+ return false;
3107
+ }
3108
+ }
3109
+
3110
+ const fileName = parts.at(-1)?.toLowerCase() ?? "";
3111
+ if (!fileName || IGNORE_FILES.has(fileName)) return false;
3112
+ if (shouldIgnorePath(normalized)) return false;
3113
+ return !hasIgnoredExtension(fileName);
3114
+ }
3115
+
2362
3116
  async function walkDir(dir: string, prefix = ""): Promise<string[]> {
2363
3117
  const entries = await fs.readdir(dir, { withFileTypes: true });
2364
3118
  const files: string[] = [];
@@ -2395,15 +3149,15 @@ app.get("/api/code-context/file", async (req, res) => {
2395
3149
  const filePath = req.query.path as string;
2396
3150
  if (!filePath) return res.status(400).json({ error: "Path required" });
2397
3151
 
2398
- const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
2399
- const resolved = path.resolve(fullPath);
2400
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) {
3152
+ const normalized = normalizeCodeContextRelPath(filePath);
3153
+ const resolved = normalized ? resolveCodeContextFilePath(normalized) : null;
3154
+ if (!normalized || !resolved) {
2401
3155
  return res.status(403).json({ error: "Access denied" });
2402
3156
  }
2403
3157
 
2404
3158
  try {
2405
3159
  const content = await fs.readFile(resolved, "utf-8");
2406
- res.json({ path: filePath, content });
3160
+ res.json({ path: normalized, content });
2407
3161
  } catch {
2408
3162
  res.status(404).json({ error: "File not found" });
2409
3163
  }
@@ -2971,11 +3725,13 @@ app.post("/api/code-line-ask", async (req, res) => {
2971
3725
  }
2972
3726
  }
2973
3727
 
2974
- if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
2975
- for (const fp of codeContextFiles as string[]) {
2976
- const fullPath = path.join(CODE_CONTEXT_DIR, fp);
2977
- const resolved = path.resolve(fullPath);
2978
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
3728
+ const scopedCodeContextFiles =
3729
+ await getScopedCodeContextFiles(codeContextFiles);
3730
+
3731
+ if (scopedCodeContextFiles.length && CODE_CONTEXT_DIR) {
3732
+ for (const fp of scopedCodeContextFiles) {
3733
+ const resolved = resolveCodeContextFilePath(fp);
3734
+ if (!resolved) continue;
2979
3735
  fileRegistry.set(
2980
3736
  `code:${fp}`,
2981
3737
  makeCodeReferenceFileEntry(`[code] ${fp}`, () =>
@@ -3020,7 +3776,7 @@ app.post("/api/code-line-ask", async (req, res) => {
3020
3776
  }
3021
3777
 
3022
3778
  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.`;
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 CODE_CONTEXT_DIR. 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:\n [DisplayText](coderef://relative/path/to/file)\nOnly use coderef:// for [code] files.`;
3024
3780
  }
3025
3781
  }
3026
3782
 
@@ -3054,9 +3810,12 @@ app.post("/api/code-line-ask", async (req, res) => {
3054
3810
  system,
3055
3811
  prompt: `File: ${filePath || "unknown"}\n\nHighlighted code:\n\`\`\`\n${selectedCode}\n\`\`\`\n\nQuestion: ${prompt.trim()}`,
3056
3812
  tools:
3057
- fileRegistry.size > 0
3813
+ fileRegistry.size > 0 || scopedCodeContextFiles.length > 0
3058
3814
  ? {
3059
- readFile: createReadFileTool(fileRegistry),
3815
+ ...(fileRegistry.size > 0
3816
+ ? { readFile: createReadFileTool(fileRegistry) }
3817
+ : {}),
3818
+ ...createCodeContextTools(scopedCodeContextFiles),
3060
3819
  }
3061
3820
  : undefined,
3062
3821
  stopWhen: stepCountIs(4),