glitool 2.0.4 → 2.1.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.
@@ -44,6 +44,30 @@ function extractTarget(args) {
44
44
  }
45
45
  return String(first ?? '');
46
46
  }
47
+ // Look at the folder BEFORE planning, so the planner knows what it's working with.
48
+ async function inspectProject() {
49
+ const files = await fg(['**/*.{ts,tsx,js,jsx,json,html,css,md}'], {
50
+ cwd: process.cwd(),
51
+ ignore: ['node_modules/**', 'dist/**', '.next/**', '.git/**', 'build/**'],
52
+ onlyFiles: true,
53
+ suppressErrors: true,
54
+ });
55
+ const codeFiles = files.filter(f => /\.(ts|tsx|js|jsx)$/.test(f));
56
+ const isEmpty = codeFiles.length === 0;
57
+ const hasPackageJson = files.includes('package.json');
58
+ const fileTree = files.slice(0, 200).join('\n');
59
+ let summary;
60
+ if (isEmpty && !hasPackageJson) {
61
+ summary = 'EMPTY PROJECT — no source files. Build from scratch with "create" steps.';
62
+ }
63
+ else if (hasPackageJson) {
64
+ summary = `EXISTING PROJECT — ${files.length} files, has package.json. Edit/extend existing code.`;
65
+ }
66
+ else {
67
+ summary = `PARTIAL PROJECT — ${files.length} files, no package.json yet.`;
68
+ }
69
+ return { isEmpty, fileCount: files.length, fileTree, hasPackageJson, summary };
70
+ }
47
71
  export async function runAgentGraph(userMessage, systemPrompt, onToolCall, onStatus, decision, onStageEvent) {
48
72
  const plannerModel = getModelForTier('complex');
49
73
  const coderModel = decision.recommendedModel;
@@ -51,14 +75,11 @@ export async function runAgentGraph(userMessage, systemPrompt, onToolCall, onSta
51
75
  async function plannerNode(state) {
52
76
  onStatus('Planning...');
53
77
  onStageEvent?.({ type: 'stage_start', stage: 'planner' });
54
- const files = await fg(['**/*.{ts,tsx,js,jsx}'], {
55
- cwd: process.cwd(),
56
- ignore: ['node_modules/**', 'dist/**', '.next/**', '.git/**', 'build/**'],
57
- onlyFiles: true,
58
- suppressErrors: true,
59
- });
60
- const fileTree = files.slice(0, 200).join('\n');
61
- const groundedSystemPrompt = `${state.systemPrompt}\n\n=== Project file tree (use exact paths from this list when planning edits) ===\n${fileTree}`;
78
+ const project = await inspectProject();
79
+ onStageEvent?.({ type: 'reasoning', stage: 'planner', text: project.summary });
80
+ const groundedSystemPrompt = project.isEmpty
81
+ ? `${state.systemPrompt}\n\n=== PROJECT STATE: ${project.summary} ===\nPlan ONLY "create" steps (plus "run" steps for install/scaffold commands). Use real, conventional paths for the stack (e.g. package.json, app/page.tsx, src/index.ts). Do NOT plan "read", "search", or "edit" steps — there is nothing to read or edit.`
82
+ : `${state.systemPrompt}\n\n=== PROJECT STATE: ${project.summary} ===\n=== Project file tree (use exact paths when planning edits) ===\n${project.fileTree}`;
62
83
  const prompt = state.plannerHint
63
84
  ? `${state.userMessage}\n\nPrevious attempt failed. Fix hint: ${state.plannerHint}`
64
85
  : state.userMessage;
@@ -42,13 +42,10 @@ Rules:
42
42
  throw new Error('empty array');
43
43
  }
44
44
  catch {
45
- // Fallback treat the whole response as a single unstructured step
46
- return [{
47
- id: 1,
48
- action: 'edit',
49
- target: 'project',
50
- depends_on: [],
51
- why: content,
52
- }];
45
+ // Planner returned prose / a question instead of JSON. Don't fabricate a
46
+ // fake "edit" step — that sends the coder hunting for files that may not
47
+ // exist. Return null = "no plan", so the pipeline falls back to a normal
48
+ // conversational reply where the user can be asked properly.
49
+ return null;
53
50
  }
54
51
  }
@@ -0,0 +1,7 @@
1
+ let handler = async () => '';
2
+ export function setClarificationHandler(fn) {
3
+ handler = fn;
4
+ }
5
+ export async function requestClarification(question, options) {
6
+ return handler(question, options);
7
+ }
@@ -0,0 +1,101 @@
1
+ import { searchCodeTool } from './tools/index.js';
2
+ const SKIP_DOMAINS = new Set(['chat', 'explanation', 'git']);
3
+ const UI_SEARCH_MAP = {
4
+ button: 'onClick',
5
+ btn: 'onClick',
6
+ click: 'onClick',
7
+ pressed: 'onClick',
8
+ form: 'onSubmit',
9
+ submit: 'onSubmit',
10
+ login: 'login',
11
+ signin: 'signIn',
12
+ signup: 'signUp',
13
+ register: 'register',
14
+ modal: 'modal',
15
+ dialog: 'dialog',
16
+ dropdown: 'dropdown',
17
+ menu: 'menu',
18
+ cart: 'addToCart',
19
+ checkout: 'checkout',
20
+ payment: 'payment',
21
+ search: 'handleSearch',
22
+ filter: 'filter',
23
+ upload: 'upload',
24
+ delete: 'delete',
25
+ save: 'save',
26
+ update: 'update',
27
+ };
28
+ function extractSearchTerms(prompt) {
29
+ const terms = [];
30
+ const lower = prompt.toLowerCase();
31
+ for (const match of prompt.matchAll(/\b[\w/.-]+\.(tsx?|jsx?|css|html|json|md)\b/gi)) {
32
+ terms.push(match[0]);
33
+ }
34
+ for (const match of prompt.matchAll(/\b[a-z][a-zA-Z0-9]*(?:[A-Z][a-zA-Z0-9]*)+\b/g)) {
35
+ terms.push(match[0]);
36
+ }
37
+ for (const [word, searchTerm] of Object.entries(UI_SEARCH_MAP)) {
38
+ if (lower.includes(word)) {
39
+ terms.push(searchTerm);
40
+ }
41
+ }
42
+ return [...new Set(terms)].slice(0, 4);
43
+ }
44
+ async function codebaseSearch(prompt) {
45
+ const lower = prompt.toLowerCase();
46
+ const isUiQuery = ['button', 'btn', 'click', 'form', 'input', 'modal', 'dropdown', 'link']
47
+ .some(w => lower.includes(w));
48
+ const terms = extractSearchTerms(prompt);
49
+ const sections = [];
50
+ for (const term of terms) {
51
+ try {
52
+ const raw = await searchCodeTool.invoke({ keyword: term });
53
+ if (!raw || raw === 'No matches found.')
54
+ continue;
55
+ const fileMap = new Map();
56
+ for (const line of raw.split('\n').filter(Boolean)) {
57
+ const m = line.match(/^([^:]+):(\d+):(.*)/);
58
+ if (!m)
59
+ continue;
60
+ const [, file, lineNum, content] = m;
61
+ if (isUiQuery && /\.ts$/.test(file) && !(/\.tsx$/.test(file)))
62
+ continue;
63
+ if (!fileMap.has(file))
64
+ fileMap.set(file, []);
65
+ if (fileMap.get(file).length < 2) {
66
+ fileMap.get(file).push(`L${lineNum}: ${content.trim().slice(0, 80)}`);
67
+ }
68
+ }
69
+ if (fileMap.size === 0)
70
+ continue;
71
+ const summary = [`[found "${term}" in ${fileMap.size} file(s)]`];
72
+ let count = 0;
73
+ for (const [file, snippets] of fileMap) {
74
+ if (count++ >= 6)
75
+ break;
76
+ summary.push(`${file}:`);
77
+ snippets.forEach(s => summary.push(` ${s}`));
78
+ }
79
+ sections.push(summary.join('\n'));
80
+ }
81
+ catch { }
82
+ }
83
+ return sections.join('\n\n');
84
+ }
85
+ export async function runClarifier(prompt, domain, sessionMessageCount = 0) {
86
+ if (SKIP_DOMAINS.has(domain))
87
+ return {};
88
+ const words = prompt.trim().split(/\s+/);
89
+ const isShort = words.length < 10;
90
+ const hasAnaphora = /\b(that|it|this|the issue|the bug|the problem|the fix|the error|the feature)\b/i.test(prompt);
91
+ const ACTION_VERBS = /^(do|implement|build|add|make|create|fix|run|apply|execute|start|generate|write|update|remove|delete|refactor)\b/i;
92
+ const isActionOnDefinite = ACTION_VERBS.test(words[0]) && /\bthe\b/i.test(prompt);
93
+ const isShortFollowUp = isShort && (hasAnaphora || (isActionOnDefinite && sessionMessageCount > 2));
94
+ if (isShortFollowUp)
95
+ return {};
96
+ const codeContext = await codebaseSearch(prompt);
97
+ return { codeContext: codeContext || undefined };
98
+ }
99
+ export function buildEnhancedPrompt(original, answers) {
100
+ return `[Original request]: ${original}\n\n[User clarification]: ${answers}`;
101
+ }
@@ -5,17 +5,44 @@ function backendUrl() {
5
5
  return process.env.GLITOOL_BACKEND ?? 'https://api.glit.in';
6
6
  }
7
7
  let currentRequestId = null;
8
+ const resolvedModelByRequest = new Map();
8
9
  export function startNewRequest() {
9
10
  currentRequestId = randomUUID();
10
11
  return currentRequestId;
11
12
  }
13
+ /**
14
+ * Returns the actual model the server resolved for the current request,
15
+ * captured from the X-Glitool-Resolved-Model response header.
16
+ * Returns null until the first response of the current request has come back,
17
+ * or always when running BYOK (no Glitool server in the path).
18
+ */
19
+ export function getResolvedModelForCurrentRequest() {
20
+ return currentRequestId ? resolvedModelByRequest.get(currentRequestId) ?? null : null;
21
+ }
12
22
  function requestIdHeader() {
13
23
  return currentRequestId ? { 'X-Glitool-Request-ID': currentRequestId } : {};
14
24
  }
25
+ // Wraps fetch to capture the X-Glitool-Resolved-Model header from every server response.
26
+ // requestId is bound at LLM construction time so concurrent requests stay disjoint.
27
+ function makeCaptureFetch(requestId) {
28
+ return async (input, init) => {
29
+ const response = await fetch(input, init);
30
+ try {
31
+ const resolved = response.headers.get('X-Glitool-Resolved-Model');
32
+ if (resolved && requestId) {
33
+ resolvedModelByRequest.set(requestId, resolved);
34
+ }
35
+ }
36
+ catch { }
37
+ return response;
38
+ };
39
+ }
15
40
  export function makeLlm(model, extras = {}) {
16
41
  if (process.env.OPENAI_API_KEY) {
42
+ // BYOK path bypasses the Glitool server entirely — no header to capture.
17
43
  return new ChatOpenAI({ model, apiKey: process.env.OPENAI_API_KEY, streaming: true, ...extras });
18
44
  }
45
+ const captureFetch = makeCaptureFetch(currentRequestId);
19
46
  const token = getAuthToken();
20
47
  if (token) {
21
48
  return new ChatOpenAI({
@@ -25,6 +52,7 @@ export function makeLlm(model, extras = {}) {
25
52
  configuration: {
26
53
  baseURL: `${backendUrl()}/v1`,
27
54
  defaultHeaders: requestIdHeader(),
55
+ fetch: captureFetch,
28
56
  },
29
57
  ...extras,
30
58
  });
@@ -33,9 +61,11 @@ export function makeLlm(model, extras = {}) {
33
61
  model,
34
62
  apiKey: 'anon',
35
63
  streaming: true,
64
+ maxTokens: 8192,
36
65
  configuration: {
37
66
  baseURL: `${backendUrl()}/v1`,
38
67
  defaultHeaders: { 'X-Anon-ID': getOrCreateAnonId(), ...requestIdHeader() },
68
+ fetch: captureFetch,
39
69
  },
40
70
  ...extras,
41
71
  });
@@ -1,10 +1,19 @@
1
1
  import { classifyWithLlm } from './classifier.js';
2
2
  import { loadConfig } from '../config.js';
3
- const MODEL_BY_TIER = {
4
- quick: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
5
- standard: 'Qwen/Qwen2.5-Coder-72B-Instruct',
6
- complex: 'deepseek-ai/DeepSeek-V3',
3
+ // Domain semantic role. Server resolves role + plan to a vendor model.
4
+ const DOMAIN_TO_ROLE = {
5
+ chat: 'glitool/quick',
6
+ explanation: 'glitool/quick',
7
+ git: 'glitool/quick',
8
+ coding: 'glitool/coder',
9
+ debugging: 'glitool/coder',
10
+ refactoring: 'glitool/coder',
11
+ planning: 'glitool/planner',
12
+ review: 'glitool/planner',
7
13
  };
14
+ function roleFor(domain) {
15
+ return DOMAIN_TO_ROLE[domain] ?? 'glitool/quick';
16
+ }
8
17
  const ANAPHORA_PATTERNS = [
9
18
  /\b(this|that|it|those|these)\b/i,
10
19
  /\b(again|instead|previous|prior)\b/i,
@@ -102,7 +111,7 @@ export function parseExplicitRoute(prompt) {
102
111
  tier: route.tier,
103
112
  domain: route.domain,
104
113
  complexityScore: 0,
105
- recommendedModel: getModel(route.tier),
114
+ recommendedModel: roleFor(route.domain),
106
115
  reason: `explicit: ${cmd}`,
107
116
  source: 'explicit',
108
117
  confidence: 'high', // ← ADD
@@ -123,17 +132,6 @@ export function stripExplicitPrefix(prompt) {
123
132
  }
124
133
  return prompt;
125
134
  }
126
- function getModel(tier) {
127
- const userPref = loadConfig().preferredModel;
128
- // User preference applies ONLY to the quick tier (chat/explain).
129
- // Coding, planning, refactoring always use the tier-appropriate strong model.
130
- if (userPref && tier === 'quick')
131
- return userPref;
132
- return MODEL_BY_TIER[tier];
133
- }
134
- export function getModelForTier(tier) {
135
- return MODEL_BY_TIER[tier];
136
- }
137
135
  function detectDomain(prompt) {
138
136
  if (CHAT_PATTERNS.some(p => p.test(prompt)))
139
137
  return { domain: 'chat', matched: true };
@@ -206,7 +204,7 @@ export async function route(prompt, recentMessages = []) {
206
204
  tier,
207
205
  domain: regexDomain,
208
206
  complexityScore,
209
- recommendedModel: getModel(tier),
207
+ recommendedModel: roleFor(regexDomain),
210
208
  reason: `domain=${regexDomain} score=${complexityScore} tier=${tier} matched=${matched} anaphora=${anaphora}`,
211
209
  source: 'regex',
212
210
  confidence,
@@ -223,7 +221,7 @@ export async function route(prompt, recentMessages = []) {
223
221
  tier: llmTier,
224
222
  domain: result.domain,
225
223
  complexityScore,
226
- recommendedModel: getModel(llmTier),
224
+ recommendedModel: roleFor(result.domain),
227
225
  reason: result.reason,
228
226
  source: 'llm',
229
227
  confidence: result.confidence,
@@ -0,0 +1,22 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { requestClarification } from '../clarificationHandler.js';
4
+ export const askUserTool = tool(async ({ question, options }) => {
5
+ const answer = await requestClarification(question, options);
6
+ if (!answer || answer.trim() === 's' || answer.trim() === '/skip') {
7
+ return 'User skipped. Use your best judgment and proceed with the most reasonable approach.';
8
+ }
9
+ return `User answered: ${answer}`;
10
+ }, {
11
+ name: 'askUser',
12
+ description: `Ask the user ONE question when you cannot proceed without their input.
13
+ ONLY call this after you have already investigated (read files, searched code).
14
+ ONLY when multiple real candidates exist in the code and you cannot determine which one.
15
+ Ask the most specific question possible based on what you actually found.
16
+ Do NOT ask about design preferences, layout choices, or features — make sensible defaults.
17
+ Do NOT ask about things you can discover by reading files.`,
18
+ schema: z.object({
19
+ question: z.string().describe('The specific question to ask the user'),
20
+ options: z.array(z.string()).optional().describe('2-4 choices based on what you found in the code. Omit for open questions.'),
21
+ }),
22
+ });
@@ -4,10 +4,13 @@ import { z } from "zod";
4
4
  import { requestConfirm } from "../confirmHandler.js";
5
5
  import { scoreShellRisk } from "../trust/riskScorer.js";
6
6
  import { registerProcess } from "./processRegistry.js";
7
+ import path from 'path';
8
+ import fs from 'fs';
7
9
  const MAX_OUTPUT = 10_000;
8
10
  const DEFAULT_TIMEOUT_MS = 30_000;
9
- export const bashTool = tool(async ({ command, timeout, runInBackground }) => {
10
- // Risk check BEFORE spawn block dangerous, confirm sensitive
11
+ export const bashTool = tool(async ({ command, timeout, runInBackground, cwd }) => {
12
+ const timeoutMs = typeof timeout === 'string' ? parseInt(timeout, 10) : (timeout ?? DEFAULT_TIMEOUT_MS);
13
+ const bgMode = typeof runInBackground === 'string' ? runInBackground === 'true' : (runInBackground ?? false);
11
14
  const risk = scoreShellRisk(command);
12
15
  if (risk === 'block') {
13
16
  return `BLOCKED: This shell command is too dangerous to run: \`${command}\`. Try a safer alternative or ask the user to run it manually.`;
@@ -23,12 +26,21 @@ export const bashTool = tool(async ({ command, timeout, runInBackground }) => {
23
26
  return 'USER_CANCELLED: The user rejected this shell command. Do NOT retry.';
24
27
  }
25
28
  }
29
+ if (cwd) {
30
+ const resolvedCwd = path.resolve(process.cwd(), cwd);
31
+ if (!fs.existsSync(resolvedCwd)) {
32
+ return `Error: cwd "${cwd}" does not exist (resolved to ${resolvedCwd}). If you are about to CREATE this directory (e.g. a scaffolder like create-next-app), omit cwd. If you just created it, double-check the path/spelling.`;
33
+ }
34
+ if (!fs.statSync(resolvedCwd).isDirectory()) {
35
+ return `Error: cwd "${cwd}" exists but is not a directory.`;
36
+ }
37
+ }
26
38
  const proc = spawn(command, {
27
39
  shell: true,
28
- cwd: process.cwd(),
29
- ...(runInBackground ? {} : { timeout: timeout ?? DEFAULT_TIMEOUT_MS }),
40
+ cwd: cwd ? path.resolve(process.cwd(), cwd) : process.cwd(),
41
+ ...(bgMode ? {} : { timeout: timeoutMs }),
30
42
  });
31
- if (runInBackground) {
43
+ if (bgMode) {
32
44
  const handle = registerProcess(proc, command);
33
45
  return `Background process started. Handle: ${handle}. Use the read_background_output with this handle to see output.`;
34
46
  }
@@ -84,7 +96,8 @@ export const bashTool = tool(async ({ command, timeout, runInBackground }) => {
84
96
  description: 'Run a shell command. Use runInBackground:true for dev servers and long-running tests — returns a handle immediately. Use read_background_output to read accumulating output. Dangerous commands (rm -rf, sudo, fork bombs) are blocked. Sensitive commands (git push, npm install/publish) require user confirmation.',
85
97
  schema: z.object({
86
98
  command: z.string().describe('Shell command to run'),
87
- timeout: z.number().optional().describe('Timeout in ms for foreground commands (default 30000)'),
88
- runInBackground: z.boolean().optional().describe('If true, run without waiting and return a handle'),
99
+ timeout: z.union([z.number(), z.string()]).optional().describe('Timeout in ms for foreground commands (default 30000)'),
100
+ runInBackground: z.union([z.boolean(), z.string()]).optional().describe('If true, run in background and return a handle'),
101
+ cwd: z.string().optional().describe('Working directory relative to project root'),
89
102
  }),
90
103
  });
@@ -3,13 +3,17 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { z } from 'zod';
5
5
  import { requestConfirm } from '../confirmHandler.js';
6
- export const editFileTool = tool(async ({ filePath, oldString, newString }) => {
7
- const fullPath = path.resolve(process.cwd(), filePath);
6
+ export const editFileTool = tool(async ({ filePath, path: pathAlias, filename, oldString, newString }) => {
7
+ const resolvedPath = filePath ?? pathAlias ?? filename ?? '';
8
+ if (!resolvedPath) {
9
+ throw new Error('editFile requires filePath. To write a whole file, use writeFile instead.');
10
+ }
11
+ const fullPath = path.resolve(process.cwd(), resolvedPath);
8
12
  if (!fullPath.startsWith(process.cwd())) {
9
13
  throw new Error('Access denied: outside project root');
10
14
  }
11
15
  if (!fs.existsSync(fullPath)) {
12
- throw new Error(`File not found: ${filePath}`);
16
+ throw new Error(`File not found: ${resolvedPath}`);
13
17
  }
14
18
  const content = fs.readFileSync(fullPath, 'utf-8');
15
19
  if (!content.includes(oldString)) {
@@ -17,7 +21,7 @@ export const editFileTool = tool(async ({ filePath, oldString, newString }) => {
17
21
  }
18
22
  const ok = await requestConfirm({
19
23
  type: 'edit',
20
- filePath,
24
+ filePath: resolvedPath,
21
25
  oldString,
22
26
  newString,
23
27
  risk: 'low',
@@ -27,12 +31,14 @@ export const editFileTool = tool(async ({ filePath, oldString, newString }) => {
27
31
  }
28
32
  const update = content.split(oldString).join(newString);
29
33
  fs.writeFileSync(fullPath, update, 'utf-8');
30
- return `Successfully edited ${filePath}`;
34
+ return `Successfully edited ${resolvedPath}`;
31
35
  }, {
32
36
  name: 'editFile',
33
- description: 'Make a targeted edit to an existing file by replacing an exact string. Use readFile first to get the current content, then provide the exact oldString to replace.',
37
+ description: 'Make a targeted edit to an existing file by replacing an exact string. Use readFile first to get the current content, then provide the exact oldString to replace. To CREATE or fully OVERWRITE a file, use writeFile instead — editFile only modifies existing content.',
34
38
  schema: z.object({
35
- filePath: z.string().describe('Relative path to the file from the project root'),
39
+ filePath: z.string().optional().describe('Relative path to the file from the project root'),
40
+ path: z.string().optional(),
41
+ filename: z.string().optional(),
36
42
  oldString: z.string().describe('The exact string to find and replace - must match exactly including whitespace and indentation'),
37
43
  newString: z.string().describe('The string to replace it with')
38
44
  })
@@ -7,3 +7,4 @@ export { editFileTool } from './editFileTool.js';
7
7
  export { bashTool } from './bashTool.js';
8
8
  export { readBackgroundOutputTool } from './readBackgroundOutput.js';
9
9
  export { webFetchTool } from './webFetchTool.js';
10
+ export { askUserTool } from './askUserTool.js';
@@ -42,10 +42,11 @@ async function resolveFilePath(filePath) {
42
42
  }
43
43
  return { kind: 'ambiguous', matches };
44
44
  }
45
- export const readFileTool = tool(async ({ filePath }) => {
46
- const resolved = await resolveFilePath(filePath);
45
+ export const readFileTool = tool(async ({ filePath, path: pathAlias, filename }) => {
46
+ const resolvedPath = filePath ?? pathAlias ?? filename ?? '';
47
+ const resolved = await resolveFilePath(resolvedPath);
47
48
  if (resolved.kind === 'not_found') {
48
- return `File not found: ${filePath}
49
+ return `File not found: ${resolvedPath}
49
50
  Current working directory: ${process.cwd()}
50
51
  Hint: try a bare filename (e.g. "agent.ts") to search the whole project, or use an absolute path.`;
51
52
  }
@@ -54,7 +55,7 @@ Hint: try a bare filename (e.g. "agent.ts") to search the whole project, or use
54
55
  const more = resolved.matches.length > 10
55
56
  ? `\n ...and ${resolved.matches.length - 10} more`
56
57
  : '';
57
- return `Multiple files match "${filePath}":\n${list}${more}\n\nCall readFile again with one of these specific paths.`;
58
+ return `Multiple files match "${resolvedPath}":\n${list}${more}\n\nCall readFile again with one of these specific paths.`;
58
59
  }
59
60
  const stat = fs.statSync(resolved.absPath);
60
61
  if (stat.size > MAX_BYTES) {
@@ -63,13 +64,15 @@ Hint: try a bare filename (e.g. "agent.ts") to search the whole project, or use
63
64
  const content = fs.readFileSync(resolved.absPath, 'utf-8');
64
65
  // If we resolved a bare filename, tell the agent which path we used so future calls can be precise.
65
66
  if (resolved.resolvedFrom) {
66
- return `[resolved "${filePath}" → ${resolved.resolvedFrom}]\n\n${content}`;
67
+ return `[resolved "${resolvedPath}" → ${resolved.resolvedFrom}]\n\n${content}`;
67
68
  }
68
69
  return content;
69
70
  }, {
70
71
  name: 'readFile',
71
72
  description: 'Read the contents of a file. Accepts either a relative path from the project root (e.g. "src/foo.ts") or a bare filename (e.g. "foo.ts") — if a bare name is given, it searches the whole project. If multiple files match, the tool returns the list and you should call again with the specific path.',
72
73
  schema: z.object({
73
- filePath: z.string().describe('Relative path from project root (e.g. "CLI/src/agent.ts") OR a bare filename (e.g. "agent.ts") to search for project-wide.'),
74
+ filePath: z.string().optional().describe('Relative path or bare filename to read'),
75
+ path: z.string().optional(),
76
+ filename: z.string().optional(),
74
77
  }),
75
78
  });
@@ -1,13 +1,17 @@
1
1
  import { tool } from "@langchain/core/tools";
2
2
  import { execFileSync } from "child_process";
3
3
  import { z } from "zod";
4
- export const searchCodeTool = tool(async ({ keyword }) => {
4
+ export const searchCodeTool = tool(async ({ keyword, query }) => {
5
+ const searchTerm = keyword ?? query ?? '';
5
6
  try {
6
7
  const result = execFileSync('grep', [
7
8
  '-rn',
8
- keyword,
9
+ searchTerm,
9
10
  '--include=*.ts',
11
+ '--include=*.tsx',
10
12
  '--include=*.js',
13
+ '--include=*.jsx',
14
+ '--include=*.html',
11
15
  '--include=*.json',
12
16
  '--exclude-dir=node_modules',
13
17
  '--exclude-dir=dist',
@@ -23,6 +27,7 @@ export const searchCodeTool = tool(async ({ keyword }) => {
23
27
  name: 'searchCode',
24
28
  description: 'Search for a keyword or function name across all project files. Returns file paths and line numbers where the keyword is found. Use this tool to quickly locate where a specific function or variable is used in the codebase.',
25
29
  schema: z.object({
26
- keyword: z.string().describe('The keyword, function name, or pattern to search for in the codebase')
30
+ keyword: z.string().optional().describe('The keyword, function name, or pattern to search for'),
31
+ query: z.string().optional(),
27
32
  })
28
33
  });
package/dist/ui/App.js CHANGED
@@ -14,7 +14,6 @@ import { StatusBar } from './StatusBar.js';
14
14
  import { execSync } from 'child_process';
15
15
  import { SlashPalette, filterCommands } from './SlashPalette.js';
16
16
  import { ToolLog } from "./ToolLog.js";
17
- import { Pipeline } from "./Pipeline.js";
18
17
  import { ConfirmCard } from './ConfirmCard.js';
19
18
  import { ExplainCard } from "./ExplainCard.js";
20
19
  import { colors } from "./tokens.js";
@@ -23,6 +22,7 @@ import { log } from "../logger.js";
23
22
  import { renderMarkdown } from "./renderMarkdown.js";
24
23
  import { ProcessTrace } from './ProcessTrace.js';
25
24
  import { AuthFlow } from './AuthFlow.js';
25
+ import { ClarificationCard } from './ClarificationCard.js';
26
26
  import { getAnonRequestCount, incrementAnonCount, isAnonLimitReached, isAuthenticated, readAuth, ANON_LIMIT, } from '../auth.js';
27
27
  // const previousInputRef = useRef('');
28
28
  const config = loadConfig();
@@ -70,6 +70,8 @@ export const App = ({ explainMode = false }) => {
70
70
  const [streamingContent, setStreamingContent] = useState('');
71
71
  const [inputHistory, setInputHistory] = useState([]);
72
72
  const [historyIndex, setHistoryIndex] = useState(-1);
73
+ const [clarificationQuestions, setClarificationQuestions] = useState([]);
74
+ const clarificationResolverRef = useRef(null);
73
75
  const [statusState, setStatusState] = useState('idle');
74
76
  const [statusDetail, setStatusDetail] = useState('');
75
77
  const [tokens, setTokens] = useState(0);
@@ -139,6 +141,8 @@ export const App = ({ explainMode = false }) => {
139
141
  return;
140
142
  if (escalation)
141
143
  return;
144
+ if (clarificationQuestions.length > 0)
145
+ return;
142
146
  if (statusState === 'working')
143
147
  return;
144
148
  // Ctrl+V — read clipboard directly
@@ -399,10 +403,14 @@ export const App = ({ explainMode = false }) => {
399
403
  else if (status.startsWith('Escalating')) {
400
404
  setJudge({ status: 'active', detail: 'escalating' });
401
405
  }
402
- }, (token) => setStreamingContent(prev => prev + token), (payload) => setEscalation(payload), (newTokens, newCost) => {
406
+ }, (token) => setStreamingContent(prev => prev + token), (newTokens, newCost) => {
403
407
  setTokens(prev => prev + newTokens);
404
408
  setCost(prev => prev + newCost);
405
- }, (event) => setStageEvents(prev => [...prev, event]));
409
+ }, (event) => setStageEvents(prev => [...prev, event]), (question, options) => new Promise((resolve) => {
410
+ setClarificationQuestions([{ question, options }]);
411
+ clarificationResolverRef.current = resolve;
412
+ setStatusState('awaiting');
413
+ }));
406
414
  if (!isAuthenticated() && !process.env.OPENAI_API_KEY) {
407
415
  const newCount = incrementAnonCount();
408
416
  setAnonCount(newCount);
@@ -481,6 +489,16 @@ export const App = ({ explainMode = false }) => {
481
489
  setStatusState('working');
482
490
  resolve?.(choice === 'y');
483
491
  log('confirm:resolved', { value: choice === 'y' });
492
+ } })) : clarificationQuestions.length > 0 ? (_jsx(ClarificationCard, { questions: clarificationQuestions, onAnswer: (answers) => {
493
+ setClarificationQuestions([]);
494
+ setStatusState('working');
495
+ clarificationResolverRef.current?.(answers);
496
+ clarificationResolverRef.current = null;
497
+ }, onSkip: () => {
498
+ setClarificationQuestions([]);
499
+ setStatusState('working');
500
+ clarificationResolverRef.current?.('s');
501
+ clarificationResolverRef.current = null;
484
502
  } })) : showAuth ? (_jsx(AuthFlow, { onDone: (auth) => {
485
503
  setShowAuth(false);
486
504
  setMessages(prev => [...prev, {
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import { colors } from './tokens.js';
6
+ export const ClarificationCard = ({ questions, onAnswer, onSkip }) => {
7
+ const [currentIndex, setCurrentIndex] = useState(0);
8
+ const [collectedAnswers, setCollectedAnswers] = useState([]);
9
+ const [input, setInput] = useState('');
10
+ const [otherMode, setOtherMode] = useState(false);
11
+ const current = questions[currentIndex];
12
+ const options = current?.options;
13
+ const showOptions = !!options && options.length > 0 && !otherMode;
14
+ const otherIndex = options ? options.length + 1 : 0;
15
+ const isLast = currentIndex === questions.length - 1;
16
+ const advance = (answer) => {
17
+ const updated = [...collectedAnswers, answer];
18
+ if (isLast) {
19
+ const combined = questions
20
+ .map((q, i) => `Q${i + 1}: ${q.question}\nA: ${updated[i]}`)
21
+ .join('\n\n');
22
+ onAnswer(combined);
23
+ }
24
+ else {
25
+ setCollectedAnswers(updated);
26
+ setCurrentIndex(currentIndex + 1);
27
+ setInput('');
28
+ setOtherMode(false);
29
+ }
30
+ };
31
+ const handleSubmit = (value) => {
32
+ const trimmed = value.trim();
33
+ if (trimmed === 's' || trimmed === '/skip') {
34
+ onSkip();
35
+ return;
36
+ }
37
+ if (!trimmed)
38
+ return;
39
+ advance(trimmed);
40
+ };
41
+ // Single-keypress capture for the numbered-options view
42
+ useInput((ch) => {
43
+ if (!showOptions)
44
+ return;
45
+ if (ch === 's') {
46
+ onSkip();
47
+ return;
48
+ }
49
+ const n = parseInt(ch, 10);
50
+ if (isNaN(n))
51
+ return;
52
+ if (n >= 1 && n <= options.length) {
53
+ advance(options[n - 1]);
54
+ }
55
+ else if (n === otherIndex) {
56
+ setOtherMode(true);
57
+ }
58
+ }, { isActive: showOptions });
59
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginBottom: 1, children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "yellow", children: " Clarification needed " }), _jsxs(Text, { color: colors.muted, children: [" ", currentIndex + 1, " / ", questions.length, " "] })] }), collectedAnswers.length > 0 && (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: collectedAnswers.map((ans, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { dimColor: true, children: [questions[i].question.slice(0, 45), "..."] }), _jsx(Text, { color: colors.muted, children: "\u2192" }), _jsx(Text, { color: "green", children: ans })] }, i))) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "yellow", children: current.question }) }), showOptions ? (_jsxs(Box, { flexDirection: "column", children: [options.map((opt, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", children: [" ", i + 1, " "] }), _jsxs(Text, { children: [" ", opt] })] }, i))), _jsxs(Box, { children: [_jsxs(Text, { color: "cyan", children: [" ", otherIndex, " "] }), _jsx(Text, { dimColor: true, children: " Other (type your own)" })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["press 1-", otherIndex, " \u00B7 's' to skip"] }) })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["type 's' to skip \u00B7 ", isLast ? 'Enter to submit' : 'Enter for next question'] }), _jsxs(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, marginTop: 1, children: [_jsx(Text, { bold: true, color: "green", children: " You: " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: otherMode ? 'Your own answer...' : 'Your answer...' })] })] }))] }));
60
+ };