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.
- package/dist/agent.js +104 -103
- package/dist/agents/coder.js +26 -9
- package/dist/agents/debugger.js +17 -5
- package/dist/agents/executor.js +144 -0
- package/dist/agents/graph.js +29 -8
- package/dist/agents/planner.js +5 -8
- package/dist/clarificationHandler.js +7 -0
- package/dist/clarifier.js +101 -0
- package/dist/llm/factory.js +30 -0
- package/dist/llm/router.js +16 -18
- package/dist/tools/askUserTool.js +22 -0
- package/dist/tools/bashTool.js +20 -7
- package/dist/tools/editFileTool.js +13 -7
- package/dist/tools/index.js +1 -0
- package/dist/tools/readFileTool.js +9 -6
- package/dist/tools/searchCodeTool.js +8 -3
- package/dist/ui/App.js +21 -3
- package/dist/ui/ClarificationCard.js +60 -0
- package/dist/ui/ProcessTrace.js +12 -16
- package/package.json +1 -1
- package/dist/agents/reviewer.js +0 -22
- package/dist/readProject.js +0 -51
- package/dist/tools/analyzeProject.js +0 -61
package/dist/agents/graph.js
CHANGED
|
@@ -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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
package/dist/agents/planner.js
CHANGED
|
@@ -42,13 +42,10 @@ Rules:
|
|
|
42
42
|
throw new Error('empty array');
|
|
43
43
|
}
|
|
44
44
|
catch {
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,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
|
+
}
|
package/dist/llm/factory.js
CHANGED
|
@@ -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
|
});
|
package/dist/llm/router.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { classifyWithLlm } from './classifier.js';
|
|
2
2
|
import { loadConfig } from '../config.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
});
|
package/dist/tools/bashTool.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
40
|
+
cwd: cwd ? path.resolve(process.cwd(), cwd) : process.cwd(),
|
|
41
|
+
...(bgMode ? {} : { timeout: timeoutMs }),
|
|
30
42
|
});
|
|
31
|
-
if (
|
|
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
|
|
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
|
|
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: ${
|
|
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 ${
|
|
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
|
})
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
|
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: ${
|
|
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 "${
|
|
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 "${
|
|
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
|
|
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
|
-
|
|
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
|
|
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), (
|
|
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
|
+
};
|