@nexrall/code-core 1.3.1 → 1.4.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.
@@ -43,13 +43,25 @@ const dns = __importStar(require("dns"));
43
43
  const child_process_1 = require("child_process");
44
44
  const sandbox_1 = require("./sandbox");
45
45
  const auth_1 = require("../auth");
46
+ const editCompleteness_1 = require("../agent/editCompleteness");
47
+ const crossFile_1 = require("../agent/crossFile");
46
48
  const client_1 = require("../api/client");
47
49
  const symbols_1 = require("./symbols");
50
+ const tsLangService_1 = require("./tsLangService");
48
51
  // ─── Constants ────────────────────────────────────────────────────────────────
49
52
  const DEFAULT_TIMEOUT_MS = 60000;
50
53
  const MAX_FETCH_BYTES = 200 * 1024; // 200 KB
51
54
  const MAX_READ_BYTES = 500 * 1024; // 500 KB
52
55
  const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
56
+ // GAP B — output spillover. The inline bash result keeps only HEAD+TAIL (~100 KB),
57
+ // which loses the MIDDLE of a large log — often exactly where a stack trace's root
58
+ // cause or a failing assertion lives. To make the full log recoverable WITHOUT
59
+ // bloating the model context, we stream the complete stdout+stderr to a temp file
60
+ // (bounded by MAX_SPILL_BYTES) and tell the model it can `read_file` that path with
61
+ // offset/limit to inspect any section. This is the key long-debug capability: the
62
+ // agent decides which slice of a 5 MB log it needs, instead of us guessing.
63
+ const MAX_SPILL_BYTES = 20 * 1024 * 1024; // 20 MB cap on a single spill file
64
+ const SPILL_DIR = path.join(os.tmpdir(), 'nexrall-code', 'bash-output');
53
65
  // ─── Blocked commands (safety) ────────────────────────────────────────────────
54
66
  // Regexes are tested against the LOWERCASED command so case variants and minor
55
67
  // spacing differences (double spaces, no-space pipes) are all caught.
@@ -204,6 +216,59 @@ function isBinaryFile(filePath) {
204
216
  return false;
205
217
  }
206
218
  }
219
+ /**
220
+ * Read a bounded line window [offset, offset+limit) from a file too large to load
221
+ * whole, by streaming it in chunks and keeping only the requested lines. Memory is
222
+ * bounded by (limit lines actually kept) + one chunk, never the whole file — so a
223
+ * 20MB bash-output spill file can be inspected section-by-section. (GAP B.)
224
+ */
225
+ async function readLargeFileWindow(resolved, offset, limit, kb) {
226
+ return new Promise((resolve) => {
227
+ const endLine = offset + limit; // exclusive, 0-based
228
+ const kept = [];
229
+ let lineNo = 0; // 0-based index of the NEXT line to be completed
230
+ let carry = ''; // partial line spanning chunk boundaries
231
+ let stopped = false;
232
+ const stream = fs.createReadStream(resolved, { encoding: 'utf-8', highWaterMark: 256 * 1024 });
233
+ const finish = () => {
234
+ if (stopped)
235
+ return;
236
+ stopped = true;
237
+ stream.destroy();
238
+ const first = offset + 1;
239
+ const last = offset + kept.length;
240
+ const numbered = kept.map((l, i) => `${String(offset + i + 1).padStart(4, ' ')}\t${l}`).join('\n');
241
+ const note = kept.length < limit
242
+ ? ` (reached end of file at line ${last})`
243
+ : ` (more lines follow — increase offset to continue)`;
244
+ resolve({ output: `[File: ${resolved} — ${kb} KB, showing lines ${first}-${last}${note}]\n${numbered}` });
245
+ };
246
+ stream.on('data', (chunk) => {
247
+ const text = carry + (typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
248
+ const lines = text.split('\n');
249
+ carry = lines.pop() ?? ''; // last element is an incomplete line (or '')
250
+ for (const line of lines) {
251
+ if (lineNo >= offset && lineNo < endLine)
252
+ kept.push(line);
253
+ lineNo++;
254
+ if (lineNo >= endLine) {
255
+ finish();
256
+ return;
257
+ }
258
+ }
259
+ });
260
+ stream.on('end', () => {
261
+ // Flush the final carry (file not ending in newline) if it's in range.
262
+ if (!stopped && carry !== '' && lineNo >= offset && lineNo < endLine)
263
+ kept.push(carry);
264
+ finish();
265
+ });
266
+ stream.on('error', (err) => { if (!stopped) {
267
+ stopped = true;
268
+ resolve({ error: err.message });
269
+ } });
270
+ });
271
+ }
207
272
  async function readFile(input, workDir) {
208
273
  const filePath = typeof input.path === 'string' ? input.path : '';
209
274
  const offset = typeof input.offset === 'number' ? Math.max(0, Math.floor(input.offset)) : 0;
@@ -213,17 +278,22 @@ async function readFile(input, workDir) {
213
278
  try {
214
279
  const resolved = resolvePath(filePath, workDir);
215
280
  const stat = fs.statSync(resolved);
216
- // Always enforce size cap — even with offset/limit we still readFileSync the whole
217
- // file before slicing, so a 500 MB file would OOM regardless of the slice range.
218
- if (stat.size > MAX_READ_BYTES) {
219
- const kb = (stat.size / 1024).toFixed(0);
220
- return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Use offset+limit to read sections, or search_files to locate the relevant part first.` };
221
- }
222
281
  // Reject binary files early — reading them as UTF-8 produces garbage.
223
282
  if (isBinaryFile(resolved)) {
224
283
  const ext = path.extname(resolved).toLowerCase();
225
284
  return { error: `Cannot read binary file: ${resolved} (${ext || 'no extension'}). Use a text-based tool or convert it first.` };
226
285
  }
286
+ // Large-file path: when the file exceeds the in-memory cap, a full readFileSync
287
+ // would OOM — but we must still be able to inspect SECTIONS (this is what makes a
288
+ // 20MB bash-output spill file usable, GAP B). If offset+limit are given we stream
289
+ // line-by-line and materialise only the requested window; otherwise we require it.
290
+ if (stat.size > MAX_READ_BYTES) {
291
+ const kb = (stat.size / 1024).toFixed(0);
292
+ if (limit <= 0) {
293
+ return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Pass offset + limit to read a section (e.g. {offset:0, limit:500}), or search_files to locate the relevant part first.` };
294
+ }
295
+ return await readLargeFileWindow(resolved, offset, limit, kb);
296
+ }
227
297
  const content = fs.readFileSync(resolved, 'utf-8');
228
298
  const allLines = content.split('\n');
229
299
  const totalLines = allLines.length;
@@ -260,14 +330,32 @@ async function writeFile(input, workDir) {
260
330
  if (!isNew && content === '') {
261
331
  return { error: `Refusing to overwrite ${resolved} with empty content. Provide the full file content or use edit_file for targeted changes.` };
262
332
  }
333
+ // Edit-completeness guard: refuse writes that elide code with a placeholder
334
+ // ("// ... rest of the code unchanged") — a silent, irreversible data loss —
335
+ // or that still contain unresolved merge-conflict markers. Opt-out with
336
+ // NEXRALL_ALLOW_ELIDED_WRITE=1 for the rare intentional case.
337
+ if (process.env.NEXRALL_ALLOW_ELIDED_WRITE !== '1') {
338
+ const problem = (0, editCompleteness_1.checkEditCompleteness)(content, !isNew);
339
+ if (problem) {
340
+ if (problem.kind === 'elision-placeholder') {
341
+ return { error: `Refusing to write ${resolved}: ${problem.reason}.\nOffending line: ${problem.sample}\nwrite_file must contain the COMPLETE file — never a "rest unchanged" placeholder. Send the full content, or use edit_file/multi_edit for a targeted change so surrounding code is preserved.` };
342
+ }
343
+ return { error: `Refusing to write ${resolved}: ${problem.reason}.\nOffending line: ${problem.sample}\nResolve the conflict (remove <<<<<<< / ======= / >>>>>>> markers and keep the intended code) before writing.` };
344
+ }
345
+ }
263
346
  // Preserve file permissions (mode bits) when overwriting an existing file.
264
347
  // writeFileSync creates new files with umask-default mode, stripping +x etc.
265
348
  let existingMode;
349
+ let priorContent = '';
266
350
  if (!isNew) {
267
351
  try {
268
352
  existingMode = fs.statSync(resolved).mode;
269
353
  }
270
354
  catch { /* ignore */ }
355
+ try {
356
+ priorContent = fs.readFileSync(resolved, 'utf-8');
357
+ }
358
+ catch { /* ignore */ }
271
359
  }
272
360
  fs.writeFileSync(resolved, content, 'utf-8');
273
361
  if (existingMode !== undefined) {
@@ -281,7 +369,8 @@ async function writeFile(input, workDir) {
281
369
  if (isNew) {
282
370
  return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
283
371
  }
284
- return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)` };
372
+ const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
373
+ return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}` };
285
374
  }
286
375
  catch (err) {
287
376
  return { error: err.message };
@@ -340,11 +429,44 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
340
429
  const shell = {
341
430
  id, command: displayCommand, child, output: '', readCursor: 0, truncated: false,
342
431
  status: 'running', exitCode: null,
432
+ spillPath: null, spillFd: null, spillBytes: 0, spillFull: false,
343
433
  };
344
434
  // Rolling buffer: keep the LAST MAX_OUTPUT_CHARS instead of the first, so a
345
435
  // long-lived watcher/dev-server's recent output (the useful part) is never
346
- // permanently lost behind an early cap.
436
+ // permanently lost behind an early cap. In parallel, once output exceeds the
437
+ // inline cap we mirror the FULL stream to a bounded spill file (GAP B) so the
438
+ // discarded earlier output is still recoverable via read_file.
347
439
  const append = (chunk) => {
440
+ if (shell.spillPath || shell.output.length + chunk.length > MAX_OUTPUT_CHARS) {
441
+ if (!shell.spillPath && !shell.spillFull) {
442
+ try {
443
+ fs.mkdirSync(SPILL_DIR, { recursive: true });
444
+ shell.spillPath = path.join(SPILL_DIR, `${id}-${Date.now()}.log`);
445
+ shell.spillFd = fs.openSync(shell.spillPath, 'w');
446
+ fs.writeSync(shell.spillFd, shell.output); // backfill what we already have
447
+ }
448
+ catch {
449
+ shell.spillPath = null;
450
+ shell.spillFd = null;
451
+ }
452
+ }
453
+ if (shell.spillFd !== null && !shell.spillFull) {
454
+ const buf = Buffer.from(chunk, 'utf-8');
455
+ const room = MAX_SPILL_BYTES - shell.spillBytes;
456
+ if (room <= 0) {
457
+ shell.spillFull = true;
458
+ }
459
+ else {
460
+ try {
461
+ fs.writeSync(shell.spillFd, room >= buf.length ? buf : buf.subarray(0, room));
462
+ shell.spillBytes += Math.min(room, buf.length);
463
+ if (buf.length > room)
464
+ shell.spillFull = true;
465
+ }
466
+ catch { /* degrade to inline-only */ }
467
+ }
468
+ }
469
+ }
348
470
  shell.output += chunk;
349
471
  if (shell.output.length > MAX_OUTPUT_CHARS) {
350
472
  // Drop from the front but never behind the read cursor's logical position;
@@ -360,6 +482,13 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
360
482
  child.on('close', (code) => {
361
483
  shell.status = shell.status === 'killed' ? 'killed' : 'exited';
362
484
  shell.exitCode = code;
485
+ if (shell.spillFd !== null) {
486
+ try {
487
+ fs.closeSync(shell.spillFd);
488
+ }
489
+ catch { /* ignore */ }
490
+ shell.spillFd = null;
491
+ }
363
492
  // Evict oldest exited shell once map grows past 50 to prevent session-long leak
364
493
  if (_bgShells.size > 50) {
365
494
  for (const [k, v] of _bgShells) {
@@ -387,7 +516,9 @@ async function bashOutput(input) {
387
516
  const statusLine = shell.status === 'running'
388
517
  ? '[still running]'
389
518
  : `[${shell.status}${shell.exitCode != null ? `, exit ${shell.exitCode}` : ''}]`;
390
- const trunc = shell.truncated ? '\n[output truncated at 100KB]' : '';
519
+ const trunc = shell.truncated
520
+ ? `\n[output truncated at 100KB${shell.spillPath ? `; full log at ${shell.spillPath} — read_file with offset/limit` : ''}]`
521
+ : '';
391
522
  return { output: `Shell ${id} ${statusLine}\n${fresh || '(no new output)'}${trunc}` };
392
523
  }
393
524
  async function killShell(input) {
@@ -496,8 +627,66 @@ async function bash(input, abortSignal, sandbox, workDir) {
496
627
  let totalLen = 0;
497
628
  let truncated = false;
498
629
  let settled = false;
630
+ // GAP B — full-output spill. We lazily open a temp file the FIRST time output
631
+ // exceeds the inline budget and stream everything to it (bounded), so the
632
+ // complete log survives even though the inline result is HEAD+TAIL only. The
633
+ // model is told the path and can read_file it with offset/limit.
634
+ let spillPath = null;
635
+ let spillFd = null;
636
+ let spillBytes = 0;
637
+ let spillFull = false;
638
+ const openSpill = () => {
639
+ if (spillPath || spillFull)
640
+ return;
641
+ try {
642
+ fs.mkdirSync(SPILL_DIR, { recursive: true });
643
+ spillPath = path.join(SPILL_DIR, `bash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.log`);
644
+ spillFd = fs.openSync(spillPath, 'w');
645
+ }
646
+ catch {
647
+ spillPath = null;
648
+ spillFd = null;
649
+ } // spill is best-effort; never break the command
650
+ };
651
+ const writeSpill = (chunk) => {
652
+ if (spillFull || spillFd === null)
653
+ return;
654
+ const buf = Buffer.from(chunk, 'utf-8');
655
+ const room = MAX_SPILL_BYTES - spillBytes;
656
+ if (room <= 0) {
657
+ spillFull = true;
658
+ return;
659
+ }
660
+ try {
661
+ fs.writeSync(spillFd, room >= buf.length ? buf : buf.subarray(0, room));
662
+ spillBytes += Math.min(room, buf.length);
663
+ if (buf.length > room)
664
+ spillFull = true;
665
+ }
666
+ catch { /* disk full / closed — degrade to inline-only */ }
667
+ };
668
+ const closeSpill = () => {
669
+ if (spillFd !== null) {
670
+ try {
671
+ fs.closeSync(spillFd);
672
+ }
673
+ catch { /* ignore */ }
674
+ spillFd = null;
675
+ }
676
+ };
499
677
  const appendOutput = (chunk) => {
500
678
  totalLen += chunk.length;
679
+ // Mirror the full stream to the spill file once we know output is large.
680
+ if (spillPath || totalLen > MAX_OUTPUT_CHARS) {
681
+ if (!spillPath) {
682
+ openSpill();
683
+ if (spillPath) {
684
+ writeSpill(head);
685
+ writeSpill(tail);
686
+ }
687
+ }
688
+ writeSpill(chunk);
689
+ }
501
690
  if (head.length < HEAD_CHARS) {
502
691
  const room = HEAD_CHARS - head.length;
503
692
  head += chunk.slice(0, room);
@@ -513,10 +702,14 @@ async function bash(input, abortSignal, sandbox, workDir) {
513
702
  }
514
703
  };
515
704
  const collect = () => {
705
+ closeSpill();
516
706
  if (!truncated)
517
707
  return head + tail;
518
708
  const omitted = totalLen - head.length - tail.length;
519
- return `${head}\n\n[… ${omitted} chars omitted (kept first ${HEAD_CHARS / 1024}KB + last ${Math.round(TAIL_CHARS / 1024)}KB) …]\n\n${tail}`;
709
+ const spillNote = spillPath
710
+ ? ` Full output (${(totalLen / 1024).toFixed(0)}KB${spillFull ? '+, truncated at 20MB' : ''}) saved to ${spillPath} — read_file that path with offset/limit to inspect the omitted middle.`
711
+ : '';
712
+ return `${head}\n\n[… ${omitted} chars omitted (kept first ${HEAD_CHARS / 1024}KB + last ${Math.round(TAIL_CHARS / 1024)}KB).${spillNote} …]\n\n${tail}`;
520
713
  };
521
714
  const done = (code, signal, timedOut = false) => {
522
715
  if (settled)
@@ -579,6 +772,90 @@ function ripgrepAvailable() {
579
772
  }
580
773
  return _rgAvailable;
581
774
  }
775
+ // ── Cross-file breakage warning ──────────────────────────────────────────────
776
+ // After an edit removes/renames an exported symbol, scan the rest of the repo for
777
+ // surviving references. Returns a short warning string (or '' when clean). Best-
778
+ // effort, time-boxed, and never throws — a scan failure must not fail the edit.
779
+ function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir) {
780
+ if (process.env.NEXRALL_CROSSFILE_CHECK === '0')
781
+ return '';
782
+ let removed;
783
+ try {
784
+ removed = (0, crossFile_1.findRemovedExports)(editedAbsPath, oldContent, newContent)
785
+ .filter((s) => (0, crossFile_1.isCheckableSymbol)(s.name));
786
+ }
787
+ catch {
788
+ return '';
789
+ }
790
+ if (!removed.length)
791
+ return '';
792
+ const editedReal = (() => { try {
793
+ return fs.realpathSync(editedAbsPath);
794
+ }
795
+ catch {
796
+ return editedAbsPath;
797
+ } })();
798
+ const hits = [];
799
+ const MAX_SYMBOLS = 8;
800
+ for (const sym of removed.slice(0, MAX_SYMBOLS)) {
801
+ const refs = scanReferences(sym.name, workDir, editedReal);
802
+ if (refs.length) {
803
+ const shown = refs.slice(0, 3).map((r) => ` ${r}`).join('\n');
804
+ const more = refs.length > 3 ? `\n … and ${refs.length - 3} more` : '';
805
+ hits.push(` • ${sym.kind} "${sym.name}" (removed/renamed) still referenced in:\n${shown}${more}`);
806
+ }
807
+ }
808
+ if (!hits.length)
809
+ return '';
810
+ return (`\n\n⚠ CROSS-FILE BREAKAGE: you removed/renamed exported symbol(s) still used elsewhere.\n` +
811
+ hits.join('\n') +
812
+ `\nUpdate these call-sites (or restore the symbol), then run a build/typecheck to confirm nothing is broken.`);
813
+ }
814
+ // Find files (other than the edited one) that reference `name` as a whole word.
815
+ // Uses ripgrep when available (fast, .gitignore-aware), else a bounded grep -r.
816
+ function scanReferences(name, workDir, excludeRealPath) {
817
+ const pattern = `\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`;
818
+ const files = new Set();
819
+ try {
820
+ if (ripgrepAvailable()) {
821
+ const r = (0, child_process_1.spawnSync)('rg', ['-l', '--no-messages', '-e', pattern, '.'], {
822
+ cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
823
+ });
824
+ if (r.stdout)
825
+ for (const line of r.stdout.split('\n')) {
826
+ if (line.trim())
827
+ files.add(line.trim());
828
+ }
829
+ }
830
+ else {
831
+ const r = (0, child_process_1.spawnSync)('grep', ['-rlI', '--exclude-dir=node_modules', '--exclude-dir=.git',
832
+ '--exclude-dir=dist', '--exclude-dir=.next', '-E', pattern, '.'], {
833
+ cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
834
+ });
835
+ if (r.stdout)
836
+ for (const line of r.stdout.split('\n')) {
837
+ if (line.trim())
838
+ files.add(line.trim());
839
+ }
840
+ }
841
+ }
842
+ catch {
843
+ return [];
844
+ }
845
+ const rel = [];
846
+ for (const f of files) {
847
+ const abs = path.isAbsolute(f) ? f : path.join(workDir, f);
848
+ let real = abs;
849
+ try {
850
+ real = fs.realpathSync(abs);
851
+ }
852
+ catch { /* keep abs */ }
853
+ if (real === excludeRealPath)
854
+ continue; // the file we just edited
855
+ rel.push(path.relative(workDir, abs) || f);
856
+ }
857
+ return rel;
858
+ }
582
859
  async function searchFiles(input, workDir) {
583
860
  const pattern = typeof input.pattern === 'string' ? input.pattern : '';
584
861
  const searchPath = typeof input.path === 'string' ? input.path : '.';
@@ -896,7 +1173,8 @@ async function editFile(input, workDir) {
896
1173
  const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
897
1174
  const linesBefore = origNorm.split('\n').length;
898
1175
  const linesAfter = updated.split('\n').length;
899
- return { output: `Edited ${resolved} (${linesBefore} ${linesAfter} lines)\n\n${diff}` };
1176
+ const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
1177
+ return { output: `Edited ${resolved} (${linesBefore} → ${linesAfter} lines)\n\n${diff}${xfile}` };
900
1178
  }
901
1179
  catch (err) {
902
1180
  return { error: err.message };
@@ -1145,7 +1423,11 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
1145
1423
  if (isHtml)
1146
1424
  body = stripHtml(body);
1147
1425
  const truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
1148
- resolve({ output: `HTTP ${status}\n\n${body}${truncNote}` });
1426
+ // Prompt-injection mitigation: fetched web content is fully attacker-controlled
1427
+ // (anyone can put "ignore previous instructions..." on a page). Wrap it in an
1428
+ // explicit untrusted-data delimiter so the model treats it as DATA to read, not
1429
+ // as instructions to follow — defense-in-depth alongside the system-prompt rule.
1430
+ resolve({ output: `HTTP ${status}\n\n<untrusted_web_content url="${url}">\n${body}${truncNote}\n</untrusted_web_content>` });
1149
1431
  });
1150
1432
  res.on('error', (err) => resolve({ error: err.message }));
1151
1433
  });
@@ -1202,6 +1484,7 @@ async function multiEdit(input, workDir) {
1202
1484
  // Work in LF-normalised space so CRLF files match LF old_strings
1203
1485
  const wasCRLF = rawFile.includes('\r\n');
1204
1486
  let content = normalizeLF(rawFile);
1487
+ const originalNorm = content; // captured before edits mutate `content`
1205
1488
  const diffs = [];
1206
1489
  for (let i = 0; i < edits.length; i++) {
1207
1490
  const edit = edits[i];
@@ -1228,9 +1511,10 @@ async function multiEdit(input, workDir) {
1228
1511
  // preserving the original mode bits (+x on scripts etc.).
1229
1512
  const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
1230
1513
  atomicWritePreservingMode(resolved, finalContent, pre.mode);
1514
+ const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
1231
1515
  return {
1232
1516
  output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
1233
- diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n'),
1517
+ diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n') + xfile,
1234
1518
  };
1235
1519
  }
1236
1520
  catch (err) {
@@ -1670,7 +1954,46 @@ async function memoryRead(_input) {
1670
1954
  return { error: err.message };
1671
1955
  }
1672
1956
  }
1673
- // ─── Tool Dispatch ────────────────────────────────────────────────────────────
1957
+ function semanticPreamble(input, workDir) {
1958
+ const p = typeof input.path === 'string' ? input.path : '';
1959
+ const line = typeof input.line === 'number' ? Math.floor(input.line) : 0;
1960
+ const character = typeof input.character === 'number' ? Math.floor(input.character) : 0;
1961
+ if (!p)
1962
+ return { ok: false, err: { error: 'Missing required parameter: path' } };
1963
+ if (line < 1 || character < 1)
1964
+ return { ok: false, err: { error: 'line and character are 1-based and required' } };
1965
+ let resolved;
1966
+ try {
1967
+ resolved = resolvePath(p, workDir);
1968
+ }
1969
+ catch (e) {
1970
+ return { ok: false, err: { error: e.message } };
1971
+ }
1972
+ if (!(0, tsLangService_1.isTsLike)(resolved)) {
1973
+ return { ok: false, err: { error: `Semantic navigation currently supports TypeScript/JavaScript only. For ${path.extname(resolved) || 'this file type'} use get_symbols / get_workspace_symbols (regex-based).` } };
1974
+ }
1975
+ return { ok: true, args: { workDir: workDir ?? process.cwd(), filePath: resolved, line, character } };
1976
+ }
1977
+ const NO_TS_SERVICE = 'No TypeScript language service available (install `typescript` in the project). Use get_workspace_symbols / get_symbols instead.';
1978
+ async function goToDefinition(input, workDir) {
1979
+ const pre = semanticPreamble(input, workDir);
1980
+ if (!pre.ok)
1981
+ return pre.err;
1982
+ return (0, tsLangService_1.tsGoToDefinition)(pre.args) ?? { error: NO_TS_SERVICE };
1983
+ }
1984
+ async function findReferences(input, workDir) {
1985
+ const pre = semanticPreamble(input, workDir);
1986
+ if (!pre.ok)
1987
+ return pre.err;
1988
+ const includeDeclaration = input.include_declaration !== false;
1989
+ return (0, tsLangService_1.tsFindReferences)({ ...pre.args, includeDeclaration }) ?? { error: NO_TS_SERVICE };
1990
+ }
1991
+ async function getHover(input, workDir) {
1992
+ const pre = semanticPreamble(input, workDir);
1993
+ if (!pre.ok)
1994
+ return pre.err;
1995
+ return (0, tsLangService_1.tsGetHover)(pre.args) ?? { error: NO_TS_SERVICE };
1996
+ }
1674
1997
  const TOOL_MAP = {
1675
1998
  read_file: readFile,
1676
1999
  write_file: writeFile,
@@ -1700,6 +2023,10 @@ const TOOL_MAP = {
1700
2023
  // so this fallback only serves the CLI / headless environments.
1701
2024
  get_symbols: symbols_1.getSymbols,
1702
2025
  get_workspace_symbols: symbols_1.getWorkspaceSymbols,
2026
+ // Real semantic navigation (TS/JS) for the CLI — see GAP A above.
2027
+ go_to_definition: goToDefinition,
2028
+ find_references: findReferences,
2029
+ get_hover: getHover,
1703
2030
  };
1704
2031
  // Tools that accept a workDir for correct relative-path resolution.
1705
2032
  // TOOL_MAP stores the base signatures; executeTool injects workDir at call time.
@@ -1709,6 +2036,7 @@ const WORKDIR_TOOLS = new Set([
1709
2036
  'delete_file', 'notebook_read', 'notebook_edit',
1710
2037
  'generate_image', 'stock_photo',
1711
2038
  'get_symbols', 'get_workspace_symbols',
2039
+ 'go_to_definition', 'find_references', 'get_hover',
1712
2040
  ]);
1713
2041
  async function executeTool(name, input, abortSignal, sandbox, workDir, agentScope) {
1714
2042
  if (!TOOL_MAP[name])
@@ -0,0 +1,21 @@
1
+ import type { ToolResult } from '../types';
2
+ export declare function isTsLike(filePath: string): boolean;
3
+ export interface SemanticDeps {
4
+ workDir: string;
5
+ filePath: string;
6
+ line: number;
7
+ character: number;
8
+ }
9
+ /** go_to_definition — resolve the symbol at (line, character) to its declaration(s). */
10
+ export declare function tsGoToDefinition(deps: SemanticDeps): ToolResult | null;
11
+ /** find_references — every usage of the symbol at (line, character) across the project. */
12
+ export declare function tsFindReferences(deps: SemanticDeps & {
13
+ includeDeclaration?: boolean;
14
+ }): ToolResult | null;
15
+ /** get_hover — the inferred type + doc for the symbol at (line, character). */
16
+ export declare function tsGetHover(deps: SemanticDeps): ToolResult | null;
17
+ /** Test/diagnostic helper: is a real TS language service available for this workspace? */
18
+ export declare function tsServiceAvailable(workDir: string): boolean;
19
+ /** Reset cached services + module resolution (tests only). */
20
+ export declare function _resetTsServiceCache(): void;
21
+ //# sourceMappingURL=tsLangService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tsLangService.d.ts","sourceRoot":"","sources":["../../src/tools/tsLangService.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAsB3C,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAElD;AAsID,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wFAAwF;AACxF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU,GAAG,IAAI,CAgBtE;AAED,2FAA2F;AAC3F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG;IAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,UAAU,GAAG,IAAI,CAmBzG;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU,GAAG,IAAI,CAchE;AAED,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,8DAA8D;AAC9D,wBAAgB,oBAAoB,IAAI,IAAI,CAI3C"}