codemini-cli 0.2.8 → 0.3.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": "codemini-cli",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Coding CLI optimized for small-model workflows and Windows PowerShell",
5
5
  "keywords": [
6
6
  "cli",
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import { handleConfig } from './commands/config.js';
4
4
  import { handleDoctor } from './commands/doctor.js';
5
5
  import { handleSkill } from './commands/skill.js';
6
6
 
7
- const VERSION = '0.2.8';
7
+ const VERSION = '0.3.0';
8
8
 
9
9
  function printHelp() {
10
10
  console.log(`codemini ${VERSION}
@@ -406,6 +406,161 @@ export function trimInline(value, maxLen = 72) {
406
406
  return `${s.slice(0, maxLen - 3)}...`;
407
407
  }
408
408
 
409
+ function normalizeAssistantText(value) {
410
+ return String(value || '').trim();
411
+ }
412
+
413
+ function hasTrailingToolContext(messages = []) {
414
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
415
+ const message = messages[index];
416
+ if (!message || typeof message !== 'object') continue;
417
+ if (message.role === 'tool') return true;
418
+ if (message.role === 'assistant' || message.role === 'user') return false;
419
+ }
420
+ return false;
421
+ }
422
+
423
+ function isGenericCompletionText(text) {
424
+ const normalized = normalizeAssistantText(text).toLowerCase();
425
+ if (!normalized) return false;
426
+ const genericPhrases = new Set([
427
+ 'done',
428
+ 'completed',
429
+ 'complete',
430
+ 'finished',
431
+ 'task completed',
432
+ 'all done',
433
+ 'ok',
434
+ 'okay',
435
+ '已完成',
436
+ '已完成任务',
437
+ '完成',
438
+ '任务完成'
439
+ ]);
440
+ return genericPhrases.has(normalized);
441
+ }
442
+
443
+ function shouldAskForConcreteFinalAnswer(text, messages = []) {
444
+ if (!hasTrailingToolContext(messages)) return false;
445
+ const normalized = normalizeAssistantText(text);
446
+ if (!normalized) return true;
447
+ return isGenericCompletionText(normalized);
448
+ }
449
+
450
+ function isBroadRepositoryAnalysisTask(text) {
451
+ const normalized = String(text || '').trim().toLowerCase();
452
+ if (!normalized) return false;
453
+ return (
454
+ /optimi|improve|analy[sz]e|audit|review|overview|architecture|codebase|repository|repo/.test(normalized) ||
455
+ /项目.*优化|项目.*问题|可优化|分析这个项目|看看.*项目|代码库|仓库/.test(String(text || ''))
456
+ );
457
+ }
458
+
459
+ function parseProjectIndexSummary(text) {
460
+ const sourceRoots = [];
461
+ const entryCandidates = [];
462
+ const candidateFiles = [];
463
+ for (const line of String(text || '').split(/\r?\n/)) {
464
+ const trimmed = line.trim();
465
+ if (trimmed.startsWith('source_roots:')) {
466
+ sourceRoots.push(
467
+ ...String(trimmed.slice('source_roots:'.length))
468
+ .split(',')
469
+ .map((value) => value.trim())
470
+ .filter(Boolean)
471
+ );
472
+ } else if (trimmed.startsWith('entry_candidates:')) {
473
+ entryCandidates.push(
474
+ ...String(trimmed.slice('entry_candidates:'.length))
475
+ .split(',')
476
+ .map((value) => value.trim())
477
+ .filter(Boolean)
478
+ );
479
+ } else if (trimmed.startsWith('- ')) {
480
+ const match = trimmed.match(/^- ([^ ]+)/);
481
+ if (match?.[1]) candidateFiles.push(match[1].trim());
482
+ }
483
+ }
484
+ return { sourceRoots, entryCandidates, candidateFiles };
485
+ }
486
+
487
+ function createAnalysisGuardState(userPrompt) {
488
+ return {
489
+ active: isBroadRepositoryAnalysisTask(userPrompt),
490
+ indexQueried: false,
491
+ sourceRoots: new Set(),
492
+ entryCandidates: new Set(),
493
+ candidateFiles: new Set(),
494
+ relevantSourceReads: new Set(),
495
+ blockedExplorations: 0
496
+ };
497
+ }
498
+
499
+ function topLevelPath(value) {
500
+ const normalized = String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, '').trim();
501
+ return normalized.split('/')[0] || '';
502
+ }
503
+
504
+ function isRelevantSourcePath(filePath, state) {
505
+ const normalized = String(filePath || '').replace(/\\/g, '/').trim();
506
+ if (!normalized) return false;
507
+ if (state.candidateFiles.has(normalized) || state.entryCandidates.has(normalized)) return true;
508
+ for (const root of state.sourceRoots) {
509
+ if (normalized === root || normalized.startsWith(`${root}/`)) return true;
510
+ }
511
+ return false;
512
+ }
513
+
514
+ function blockedExplorationReason(toolName, args, state) {
515
+ if (!state.active) return '';
516
+ if (!state.indexQueried && toolName !== 'query_project_index') {
517
+ return 'Use query_project_index before broad repository exploration so the next reads stay focused on relevant source files.';
518
+ }
519
+
520
+ const target = String(args?.path || args?.pattern || args?.query || '').replace(/\\/g, '/').trim();
521
+ const top = topLevelPath(target);
522
+ if (!top) return '';
523
+
524
+ if (['skills', 'souls', 'templates', '.codemini', '.codemini-project'].includes(top)) {
525
+ return `Skip ${top}/ for broad repository analysis unless the user explicitly asks for it. Inspect relevant source files first.`;
526
+ }
527
+ if (top === 'tests' && state.relevantSourceReads.size < 2) {
528
+ return 'Inspect the next relevant source files before reading tests. Broad analysis should be grounded in production code first.';
529
+ }
530
+ return '';
531
+ }
532
+
533
+ function noteAnalysisEvidence(state, toolName, args, toolResult) {
534
+ if (!state.active) return;
535
+ if (toolName === 'query_project_index') {
536
+ state.indexQueried = true;
537
+ const summary = parseProjectIndexSummary(JSON.stringify(toolResult));
538
+ for (const root of summary.sourceRoots) state.sourceRoots.add(root);
539
+ for (const entry of summary.entryCandidates) state.entryCandidates.add(entry);
540
+ for (const file of summary.candidateFiles) state.candidateFiles.add(file);
541
+ const projectMap = toolResult?.project_map || {};
542
+ for (const root of projectMap.source_roots || []) state.sourceRoots.add(String(root));
543
+ for (const entry of projectMap.entry_candidates || []) state.entryCandidates.add(String(entry));
544
+ for (const match of toolResult?.matches || []) {
545
+ if (match?.file) state.candidateFiles.add(String(match.file));
546
+ }
547
+ return;
548
+ }
549
+
550
+ if (toolName === 'read') {
551
+ const filePath = String(toolResult?.path || args?.path || '').split(':')[0];
552
+ if (isRelevantSourcePath(filePath, state)) {
553
+ state.relevantSourceReads.add(filePath);
554
+ }
555
+ }
556
+ }
557
+
558
+ function needsMoreAnalysisEvidence(state) {
559
+ if (!state.active) return false;
560
+ if (!state.indexQueried) return true;
561
+ return state.relevantSourceReads.size < 2;
562
+ }
563
+
409
564
  function normalizeToolCallName(name) {
410
565
  return String(name || '').trim();
411
566
  }
@@ -509,6 +664,8 @@ export async function runAgentLoop({
509
664
 
510
665
  let finalText = '';
511
666
  let lastAssistantText = '';
667
+ let pendingSummaryNudges = 0;
668
+ const analysisGuard = createAnalysisGuardState(userPrompt);
512
669
  const alwaysAllowSet = new Set((Array.isArray(alwaysAllowTools) ? alwaysAllowTools : []).map((t) => String(t)));
513
670
 
514
671
  // Mutable tool list — grows as tool_search loads deferred tools
@@ -522,6 +679,10 @@ export async function runAgentLoop({
522
679
  tools: activeTools
523
680
  });
524
681
 
682
+ if (completion?.incomplete) {
683
+ continue;
684
+ }
685
+
525
686
  const toolCalls = Array.isArray(completion.toolCalls) ? completion.toolCalls : [];
526
687
  const assistantText = completion.text || '';
527
688
  lastAssistantText = assistantText || lastAssistantText;
@@ -546,10 +707,30 @@ export async function runAgentLoop({
546
707
  }
547
708
 
548
709
  if (toolCalls.length === 0) {
710
+ if (needsMoreAnalysisEvidence(analysisGuard) && pendingSummaryNudges < 2) {
711
+ pendingSummaryNudges += 1;
712
+ messages.push({
713
+ role: 'user',
714
+ content:
715
+ 'You have not inspected enough relevant source files yet. Query the project index if needed, then inspect the next relevant source files before concluding. Do not stop after unrelated directories, tests, skills, souls, or templates.'
716
+ });
717
+ continue;
718
+ }
719
+ if (shouldAskForConcreteFinalAnswer(assistantText, messages.slice(0, -1)) && pendingSummaryNudges < 2) {
720
+ pendingSummaryNudges += 1;
721
+ messages.push({
722
+ role: 'user',
723
+ content:
724
+ 'You have already inspected tool results. Before stopping, check whether the task is actually complete. If it is, provide a concise final answer with specific findings or concrete next steps. If it is not, continue with the next tool call.'
725
+ });
726
+ continue;
727
+ }
549
728
  finalText = assistantText;
550
729
  return { text: finalText, messages, steps: step + 1 };
551
730
  }
552
731
 
732
+ pendingSummaryNudges = 0;
733
+
553
734
  if (executionMode === 'plan') {
554
735
  const plannedLines = callsToPlanSummary(toolCalls);
555
736
  finalText = [
@@ -615,6 +796,20 @@ export async function runAgentLoop({
615
796
  throw new Error(`Unknown tool: ${call.name}`);
616
797
  }
617
798
 
799
+ const blockedReason = blockedExplorationReason(toolName, args, analysisGuard);
800
+ if (blockedReason) {
801
+ analysisGuard.blockedExplorations += 1;
802
+ const content = clipToolResult({ error: blockedReason }, toolResultMaxChars);
803
+ if (onEvent) {
804
+ onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: args, durationMs: 0, summary: trimInline(blockedReason, 120) });
805
+ }
806
+ return {
807
+ callId: call.id,
808
+ content,
809
+ error: true
810
+ };
811
+ }
812
+
618
813
  let toolResult;
619
814
  try {
620
815
  toolResult = await handler(args);
@@ -638,6 +833,7 @@ export async function runAgentLoop({
638
833
 
639
834
  // P1b: Use per-tool formatter if available, else fallback
640
835
  let formatted = formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars);
836
+ noteAnalysisEvidence(analysisGuard, toolName, args, toolResult);
641
837
 
642
838
  // P2: If tool_search loaded deferred tools, inject their schemas into activeTools
643
839
  if (toolName === 'tool_search' && toolResult && Array.isArray(toolResult.schemas)) {
@@ -1518,7 +1518,7 @@ async function askModel({
1518
1518
 
1519
1519
  const projectContextSnippet = await buildProjectContextSnippet(process.cwd(), text).catch(() => '');
1520
1520
  const effectiveSystemPrompt = projectContextSnippet
1521
- ? `${systemPrompt}\n\n${projectContextSnippet}\n\nUse this project context as lightweight guidance. Prefer tools for fresh verification before assuming details.`
1521
+ ? `${systemPrompt}\n\n${projectContextSnippet}\n\nUse this project context as lightweight guidance. Query the project index before broad globs or reading many files, then use targeted reads for fresh verification.`
1522
1522
  : systemPrompt;
1523
1523
 
1524
1524
  const { definitions, handlers, formatters, deferredDefinitions } = getBuiltinTools({
@@ -1586,8 +1586,15 @@ async function askModel({
1586
1586
  toolFormatters: formatters,
1587
1587
  deferredDefinitions,
1588
1588
  requestCompletion: async ({ messages, tools, model: selectedModel }) => {
1589
- if (onAgentEvent) onAgentEvent({ type: 'assistant:start' });
1590
- return createChatCompletionStream({
1589
+ let started = false;
1590
+ const startAssistantStream = () => {
1591
+ if (!started && onAgentEvent) {
1592
+ started = true;
1593
+ onAgentEvent({ type: 'assistant:start' });
1594
+ }
1595
+ };
1596
+
1597
+ const result = await createChatCompletionStream({
1591
1598
  baseUrl: config.gateway.base_url,
1592
1599
  apiKey: config.gateway.api_key,
1593
1600
  model: selectedModel,
@@ -1596,12 +1603,20 @@ async function askModel({
1596
1603
  timeoutMs: config.gateway.timeout_ms || 90000,
1597
1604
  maxRetries: config.gateway.max_retries ?? 2,
1598
1605
  onTextDelta: (delta) => {
1606
+ startAssistantStream();
1599
1607
  if (onAgentEvent) onAgentEvent({ type: 'assistant:delta', text: delta });
1600
1608
  },
1601
1609
  onToolCallDelta: (toolCall) => {
1610
+ startAssistantStream();
1602
1611
  if (onAgentEvent) onAgentEvent({ type: 'assistant:tool_call_delta', toolCall });
1603
1612
  }
1604
1613
  });
1614
+
1615
+ if (!started && !result?.incomplete && (result?.text || result?.toolCalls?.length)) {
1616
+ startAssistantStream();
1617
+ }
1618
+
1619
+ return result;
1605
1620
  }
1606
1621
  });
1607
1622
 
@@ -14,8 +14,8 @@ If the user mentions a project-relative path like src/app.ts, resolve it from ${
14
14
 
15
15
  1. File discovery then read
16
16
  User: compare the auth flow
17
- Assistant: first locate the relevant files
18
- Tool: glob({"pattern":"src/**/*auth*.ts"})
17
+ Assistant: first narrow the search with the project index
18
+ Tool: query_project_index({"query":"auth flow","path":"src","max_results":3})
19
19
  Tool: read({"file_path":"${cwd}/src/auth/service.ts"})
20
20
 
21
21
  2. Targeted search then exact text edit
@@ -3,7 +3,25 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { getFileIndexPath, getProjectIndexDir, getProjectMapPath, getProjectWorkspaceDir } from './paths.js';
5
5
 
6
- const SKIP_DIRS = new Set(['.git', 'node_modules', '.codemini', '.codemini-project', '.codemini-global', 'dist', 'coverage']);
6
+ const SKIP_DIRS = new Set([
7
+ '.git',
8
+ 'node_modules',
9
+ '.codemini',
10
+ '.codemini-project',
11
+ '.codemini-global',
12
+ 'dist',
13
+ 'coverage',
14
+ 'sessions',
15
+ 'tmp',
16
+ 'temp',
17
+ '.cache',
18
+ '.turbo',
19
+ '.next',
20
+ 'build',
21
+ 'out',
22
+ 'logs',
23
+ 'artifacts'
24
+ ]);
7
25
  const PROJECT_MARKER_FILES = new Set([
8
26
  'package.json',
9
27
  'tsconfig.json',
@@ -115,9 +133,9 @@ function gitignorePatternToRegex(pattern) {
115
133
  return new RegExp(`^${regexBody}$`);
116
134
  }
117
135
 
118
- async function readGitignoreRules(cwd) {
136
+ async function readIgnoreFileRules(cwd, fileName) {
119
137
  try {
120
- const raw = await fs.readFile(path.join(cwd, '.gitignore'), 'utf8');
138
+ const raw = await fs.readFile(path.join(cwd, fileName), 'utf8');
121
139
  return raw
122
140
  .split(/\r?\n/)
123
141
  .map((line) => line.trim())
@@ -143,6 +161,18 @@ async function readGitignoreRules(cwd) {
143
161
  }
144
162
  }
145
163
 
164
+ async function readProjectIgnoreRules(cwd) {
165
+ const [gitignoreRules, llmignoreRules] = await Promise.all([
166
+ readIgnoreFileRules(cwd, '.gitignore'),
167
+ readIgnoreFileRules(cwd, '.llmignore')
168
+ ]);
169
+ return {
170
+ gitignoreRules,
171
+ llmignoreRules,
172
+ combinedRules: [...gitignoreRules, ...llmignoreRules]
173
+ };
174
+ }
175
+
146
176
  function matchesGitignoreRule(rule, relativePath, isDirectory) {
147
177
  if (!rule || !relativePath) return false;
148
178
  if (rule.dirOnly && !isDirectory) return false;
@@ -212,17 +242,17 @@ async function findNearestIndexedProjectRoot(startDir, workspaceRoot) {
212
242
  return null;
213
243
  }
214
244
 
215
- async function walkFiles(cwd, start = cwd, out = [], gitignoreRules = []) {
245
+ async function walkFiles(cwd, start = cwd, out = [], ignoreRules = []) {
216
246
  const entries = await fs.readdir(start, { withFileTypes: true });
217
247
  for (const entry of entries) {
218
248
  const absolutePath = path.join(start, entry.name);
219
249
  const relativePath = rel(cwd, absolutePath);
220
250
  if (entry.isDirectory()) {
221
- if (shouldIgnorePath(relativePath, true, gitignoreRules)) continue;
222
- await walkFiles(cwd, absolutePath, out, gitignoreRules);
251
+ if (shouldIgnorePath(relativePath, true, ignoreRules)) continue;
252
+ await walkFiles(cwd, absolutePath, out, ignoreRules);
223
253
  continue;
224
254
  }
225
- if (shouldIgnorePath(relativePath, false, gitignoreRules)) continue;
255
+ if (shouldIgnorePath(relativePath, false, ignoreRules)) continue;
226
256
  out.push(absolutePath);
227
257
  }
228
258
  return out;
@@ -298,12 +328,12 @@ async function scanProject(cwd) {
298
328
  workspaceKind,
299
329
  projectMap: null,
300
330
  fileIndex: null,
301
- gitignoreRules: []
331
+ ignoreRules: []
302
332
  };
303
333
  }
304
334
 
305
- const gitignoreRules = await readGitignoreRules(cwd);
306
- const allFiles = await walkFiles(cwd, cwd, [], gitignoreRules);
335
+ const { gitignoreRules, llmignoreRules, combinedRules } = await readProjectIgnoreRules(cwd);
336
+ const allFiles = await walkFiles(cwd, cwd, [], combinedRules);
307
337
  const relativeFiles = allFiles.map((filePath) => rel(cwd, filePath));
308
338
  const sourceFiles = allFiles.filter((filePath) => SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase()));
309
339
 
@@ -362,13 +392,14 @@ async function scanProject(cwd) {
362
392
  frameworkHints,
363
393
  directories,
364
394
  gitignoreEnabled: gitignoreRules.length > 0,
395
+ llmignoreEnabled: llmignoreRules.length > 0,
365
396
  updatedAt: new Date().toISOString()
366
397
  },
367
398
  fileIndex: {
368
399
  updatedAt: new Date().toISOString(),
369
400
  files
370
401
  },
371
- gitignoreRules
402
+ ignoreRules: combinedRules
372
403
  };
373
404
  }
374
405
 
@@ -417,7 +448,7 @@ export async function refreshIndexedFile(cwd = process.cwd(), relativePath = '')
417
448
  const projectRoot = await findProjectRootFromFile(cwd, relativePath);
418
449
  if (!projectRoot) return null;
419
450
  const fileIndexPath = getFileIndexPath(projectRoot);
420
- const gitignoreRules = await readGitignoreRules(projectRoot);
451
+ const { combinedRules } = await readProjectIgnoreRules(projectRoot);
421
452
  const absolutePath = path.join(cwd, relativePath);
422
453
  const stat = await safeStat(absolutePath);
423
454
  let action = 'updated';
@@ -426,7 +457,7 @@ export async function refreshIndexedFile(cwd = process.cwd(), relativePath = '')
426
457
  const files = Array.isArray(current.files) ? [...current.files] : [];
427
458
  const index = files.findIndex((entry) => entry.file === projectRelativePath);
428
459
 
429
- if (shouldIgnorePath(projectRelativePath, Boolean(stat?.isDirectory?.()), gitignoreRules)) {
460
+ if (shouldIgnorePath(projectRelativePath, Boolean(stat?.isDirectory?.()), combinedRules)) {
430
461
  if (index >= 0) files.splice(index, 1);
431
462
  action = 'removed';
432
463
  } else if (!stat || !stat.isFile()) {
@@ -508,3 +539,99 @@ export async function buildProjectContextSnippet(cwd = process.cwd(), userText =
508
539
  const snippet = trimMultiline(lines.join('\n'));
509
540
  return snippet;
510
541
  }
542
+
543
+ export async function queryProjectIndex(cwd = process.cwd(), args = {}) {
544
+ const indexedRoot = await findNearestIndexedProjectRoot(cwd, cwd);
545
+ if (!indexedRoot) {
546
+ return {
547
+ query: String(args?.query || '').trim(),
548
+ project_root: '',
549
+ project_map: null,
550
+ matches: []
551
+ };
552
+ }
553
+
554
+ const projectMap = await safeReadJson(getProjectMapPath(indexedRoot), null);
555
+ const fileIndex = await safeReadJson(getFileIndexPath(indexedRoot), null);
556
+ const query = String(args?.query || '').trim();
557
+ const pathPrefix = normalizeRelativePath(args?.path || args?.path_prefix || '');
558
+ const languageFilter = String(args?.language || '').trim().toLowerCase();
559
+ const maxResults = Math.max(1, Math.min(20, Number(args?.max_results || 8) || 8));
560
+ const files = Array.isArray(fileIndex?.files) ? fileIndex.files : [];
561
+ const tokens = tokenizeQuery(query);
562
+
563
+ const matches = [];
564
+ for (const entry of files) {
565
+ const relativePath = String(entry?.file || '');
566
+ if (!relativePath) continue;
567
+ if (pathPrefix && !relativePath.startsWith(pathPrefix)) continue;
568
+ if (languageFilter && String(entry?.language || '').toLowerCase() !== languageFilter) continue;
569
+
570
+ let score = 0;
571
+ const reasons = [];
572
+ const fileText = relativePath.toLowerCase();
573
+ for (const token of tokens) {
574
+ if (!token) continue;
575
+ if (fileText.includes(token)) {
576
+ score += 5;
577
+ reasons.push(`path:${token}`);
578
+ }
579
+ if ((entry.exports || []).some((value) => String(value).toLowerCase() === token)) {
580
+ score += 4;
581
+ reasons.push(`export:${token}`);
582
+ }
583
+ if ((entry.functions || []).some((value) => String(value).toLowerCase().includes(token))) {
584
+ score += 4;
585
+ reasons.push(`function:${token}`);
586
+ }
587
+ if ((entry.classes || []).some((value) => String(value).toLowerCase().includes(token))) {
588
+ score += 4;
589
+ reasons.push(`class:${token}`);
590
+ }
591
+ if ((entry.imports || []).some((value) => String(value).toLowerCase().includes(token))) {
592
+ score += 2;
593
+ reasons.push(`import:${token}`);
594
+ }
595
+ }
596
+
597
+ if (!query) {
598
+ if ((projectMap?.entryCandidates || []).includes(relativePath)) score += 3;
599
+ if ((projectMap?.importantFiles || []).includes(relativePath)) score += 2;
600
+ if (String(relativePath).startsWith('src/')) score += 1;
601
+ }
602
+
603
+ if (score <= 0 && query) continue;
604
+ matches.push({
605
+ file: relativePath,
606
+ language: entry.language || 'text',
607
+ score,
608
+ reasons: clipList(reasons, 8),
609
+ exports: clipList(entry.exports || [], 6),
610
+ functions: clipList(entry.functions || [], 6),
611
+ classes: clipList(entry.classes || [], 6),
612
+ imports: clipList(entry.imports || [], 6)
613
+ });
614
+ }
615
+
616
+ matches.sort((left, right) => right.score - left.score || String(left.file).localeCompare(String(right.file)));
617
+
618
+ return {
619
+ query,
620
+ project_root: indexedRoot,
621
+ project_map: projectMap
622
+ ? {
623
+ workspace_kind: projectMap.workspaceKind || 'project',
624
+ languages: clipList(projectMap.languages || [], 8),
625
+ package_managers: clipList(projectMap.packageManagers || [], 8),
626
+ important_files: clipList(projectMap.importantFiles || [], 8),
627
+ source_roots: clipList(projectMap.sourceRoots || [], 8),
628
+ test_roots: clipList(projectMap.testRoots || [], 8),
629
+ entry_candidates: clipList(projectMap.entryCandidates || [], 8),
630
+ framework_hints: clipList(projectMap.frameworkHints || [], 8),
631
+ gitignore_enabled: Boolean(projectMap.gitignoreEnabled),
632
+ llmignore_enabled: Boolean(projectMap.llmignoreEnabled)
633
+ }
634
+ : null,
635
+ matches: matches.slice(0, maxResults)
636
+ };
637
+ }
@@ -145,13 +145,15 @@ function buildFinalStreamResult(text, toolCallsByIndex, usage, messages) {
145
145
  arguments: tc.arguments || '{}'
146
146
  }))
147
147
  .filter((tc) => tc.name);
148
+ const normalizedText = String(text || '').trim();
148
149
 
149
- if (!text && toolCalls.length === 0) {
150
+ if (!normalizedText && toolCalls.length === 0) {
150
151
  if (hasTrailingToolContext(messages)) {
151
152
  return {
152
153
  text: '',
153
154
  toolCalls: [],
154
- usage
155
+ usage,
156
+ incomplete: true
155
157
  };
156
158
  }
157
159
  throw new Error('Gateway stream returned empty assistant response');
@@ -160,7 +162,8 @@ function buildFinalStreamResult(text, toolCallsByIndex, usage, messages) {
160
162
  return {
161
163
  text,
162
164
  toolCalls,
163
- usage
165
+ usage,
166
+ incomplete: false
164
167
  };
165
168
  }
166
169
 
@@ -246,8 +249,17 @@ export async function createChatCompletion({
246
249
  name: tc.function?.name,
247
250
  arguments: normalizeIncomingToolCallArguments(tc.function?.arguments)
248
251
  }));
252
+ const normalizedText = String(text || '').trim();
249
253
 
250
- if (!text && toolCalls.length === 0) {
254
+ if (!normalizedText && toolCalls.length === 0) {
255
+ if (hasTrailingToolContext(messages)) {
256
+ return {
257
+ text: '',
258
+ toolCalls: [],
259
+ usage: data?.usage || null,
260
+ incomplete: true
261
+ };
262
+ }
251
263
  throw new Error('Gateway returned empty assistant response');
252
264
  }
253
265
 
@@ -123,6 +123,7 @@ export function getShellSystemPrompt(value) {
123
123
  # Using your tools
124
124
 
125
125
  ALWAYS prefer dedicated tools over raw shell commands:
126
+ - Use query_project_index first for broad repository understanding. It combines project-map metadata with indexed file symbols so you can narrow candidates before reading source files
126
127
  - Use read to inspect files — NEVER use cat, head, or tail via run. read returns content directly by default; demo-style shapes like {file_path:"src/app.ts"}, {path:"src/app.ts:10-40"}, or {file_path:"src/app.ts", offset:10, limit:30} are accepted
127
128
  - Use grep to search file contents — NEVER use grep or rg via run
128
129
  - Use glob to find files by pattern — NEVER use find via run
@@ -141,6 +142,7 @@ For services: use start_service to launch, list_services/get_service_status/get_
141
142
  Some tools are loaded on demand. If a needed tool is not listed, call tool_search first to load it.
142
143
 
143
144
  Common tool call patterns:
145
+ - Query the project index first: {query:"login auth flow", path:"src", max_results:5}
144
146
  - Read a file: {path:"src/app.ts"} or {file_path:"src/app.ts", offset:20, limit:40}
145
147
  - Read a specific range inline: {path:"src/app.ts:20-60"}
146
148
  - Search text: {pattern:"loginUser", path:"src"} or {query:"loginUser", directory:"src"}
@@ -158,6 +160,8 @@ Common tool call patterns:
158
160
  - Before substantial tool work, send a short progress update to the user about what you are about to inspect or do
159
161
  - Do not jump straight into tools without a brief user-facing note when the task is actionable
160
162
  - Search or read before editing unless the exact target is already known
163
+ - For broad or ambiguous requests, query_project_index before large globs or reading many files
164
+ - Do not read files one by one after a wide glob when query_project_index can narrow the candidates first
161
165
  - If a command or tool is blocked or fails, inspect the error and retry with allowed commands or tools
162
166
  - For AST-scoped edits, if edit rejects due to missing or stale ast_target, fix arguments and retry
163
167
  - Do not claim filesystem access is impossible unless search/read tools also fail