scalix 0.0.1 → 1.0.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/CHANGELOG.md +122 -0
- package/LICENSE +100 -0
- package/README.md +300 -2
- package/app.py +55 -0
- package/bin/scalix.js +19 -0
- package/dist/agent-replay.js +166 -0
- package/dist/agent.js +852 -0
- package/dist/auto-verify.js +136 -0
- package/dist/change-tracker.js +53 -0
- package/dist/cli.js +2329 -0
- package/dist/commands.js +71 -0
- package/dist/config.js +16 -0
- package/dist/constants.js +324 -0
- package/dist/context-builder.js +151 -0
- package/dist/context-manager.js +148 -0
- package/dist/cost-tracker.js +85 -0
- package/dist/diff.js +140 -0
- package/dist/git-context.js +180 -0
- package/dist/git-undo.js +67 -0
- package/dist/headless.js +654 -0
- package/dist/hooks.js +94 -0
- package/dist/ipc-server.js +228 -0
- package/dist/logger.js +45 -0
- package/dist/markdown.js +41 -0
- package/dist/mcp.js +49 -0
- package/dist/model-router.js +68 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +374 -0
- package/dist/project-memory.js +165 -0
- package/dist/safe-math.js +194 -0
- package/dist/sandbox.js +279 -0
- package/dist/scoped-rules.js +82 -0
- package/dist/sessions.js +71 -0
- package/dist/settings.js +155 -0
- package/dist/sub-agent.js +79 -0
- package/dist/token-counter.js +47 -0
- package/dist/tool-helper.js +97 -0
- package/dist/tool-selector.js +77 -0
- package/dist/tools-advanced.js +232 -0
- package/dist/tools-browser.js +273 -0
- package/dist/tools-extra.js +383 -0
- package/dist/tools-vision.js +115 -0
- package/dist/tools-voice.js +147 -0
- package/dist/tools-web.js +346 -0
- package/dist/tools.js +370 -0
- package/dist/types.js +1 -0
- package/docs/DISTRIBUTION.md +301 -0
- package/package.json +67 -6
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { tool } from './tool-helper.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { sandboxPath } from './sandbox.js';
|
|
5
|
+
import { backgroundProcesses } from './tools.js';
|
|
6
|
+
import { spawnSubAgent } from './sub-agent.js';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
import { TOOL_DEFAULTS } from './constants.js';
|
|
9
|
+
import { DEFAULT_LIMITS } from './settings.js';
|
|
10
|
+
let _limits = DEFAULT_LIMITS;
|
|
11
|
+
export function setAdvancedToolLimits(limits) { _limits = limits; }
|
|
12
|
+
let currentTodos = [];
|
|
13
|
+
export function getTodos() {
|
|
14
|
+
return [...currentTodos];
|
|
15
|
+
}
|
|
16
|
+
export function clearTodos() {
|
|
17
|
+
currentTodos = [];
|
|
18
|
+
}
|
|
19
|
+
// ─── Todo Write Tool ───
|
|
20
|
+
export const todoWriteTool = tool({
|
|
21
|
+
name: 'todo_write',
|
|
22
|
+
description: 'Create or update a task list for tracking multi-step work. Each call replaces the entire list. Use to plan, track progress, and show completed work.',
|
|
23
|
+
inputSchema: z.object({
|
|
24
|
+
todos: z.array(z.object({
|
|
25
|
+
id: z.string().min(1).max(50).describe('Unique task ID'),
|
|
26
|
+
task: z.string().min(1).max(500).describe('Task description'),
|
|
27
|
+
status: z.enum(['pending', 'in_progress', 'completed']).describe('Task status'),
|
|
28
|
+
})).describe('The complete task list (replaces previous)'),
|
|
29
|
+
}),
|
|
30
|
+
execute: async ({ todos }) => {
|
|
31
|
+
currentTodos = todos.map((t) => ({ id: t.id, task: t.task, status: t.status }));
|
|
32
|
+
const pending = currentTodos.filter(t => t.status === 'pending').length;
|
|
33
|
+
const inProgress = currentTodos.filter(t => t.status === 'in_progress').length;
|
|
34
|
+
const completed = currentTodos.filter(t => t.status === 'completed').length;
|
|
35
|
+
const activeTask = currentTodos.find(t => t.status === 'in_progress');
|
|
36
|
+
return {
|
|
37
|
+
success: true,
|
|
38
|
+
total: currentTodos.length,
|
|
39
|
+
pending,
|
|
40
|
+
inProgress,
|
|
41
|
+
completed,
|
|
42
|
+
activeForm: activeTask?.id ?? null,
|
|
43
|
+
todos: currentTodos,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
function textToNotebookSource(text) {
|
|
48
|
+
if (!text)
|
|
49
|
+
return [];
|
|
50
|
+
const lines = text.split('\n');
|
|
51
|
+
return lines.map((l, i) => i < lines.length - 1 ? l + '\n' : l);
|
|
52
|
+
}
|
|
53
|
+
export const notebookEditTool = tool({
|
|
54
|
+
name: 'notebook_edit',
|
|
55
|
+
description: 'Edit Jupyter notebooks (.ipynb). Add, edit, delete, or move cells.',
|
|
56
|
+
inputSchema: z.object({
|
|
57
|
+
notebook_path: z.string().min(1).max(500).describe('Path to the .ipynb file'),
|
|
58
|
+
command: z.enum(['add_cell', 'edit_cell', 'delete_cell', 'move_cell']).describe('Operation to perform'),
|
|
59
|
+
cell_type: z.enum(['code', 'markdown']).optional().describe('Cell type (for add_cell)'),
|
|
60
|
+
cell_number: z.number().min(0).optional().describe('Cell index (0-based)'),
|
|
61
|
+
new_source: z.string().max(TOOL_DEFAULTS.maxNotebookSource).optional().describe('New cell content'),
|
|
62
|
+
position: z.number().min(0).optional().describe('Target position (for move_cell or add_cell insertion point)'),
|
|
63
|
+
}),
|
|
64
|
+
execute: async ({ notebook_path, command, cell_type, cell_number, new_source, position }) => {
|
|
65
|
+
try {
|
|
66
|
+
const safePath = sandboxPath(notebook_path);
|
|
67
|
+
const raw = await fs.readFile(safePath, 'utf-8');
|
|
68
|
+
const notebook = JSON.parse(raw);
|
|
69
|
+
if (notebook.nbformat !== 4) {
|
|
70
|
+
return { error: `Unsupported notebook format: nbformat ${notebook.nbformat}` };
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(notebook.cells)) {
|
|
73
|
+
return { error: 'Invalid notebook: cells is not an array' };
|
|
74
|
+
}
|
|
75
|
+
const cells = notebook.cells;
|
|
76
|
+
switch (command) {
|
|
77
|
+
case 'add_cell': {
|
|
78
|
+
const resolvedType = cell_type || 'code';
|
|
79
|
+
if (resolvedType !== 'code' && resolvedType !== 'markdown' && resolvedType !== 'raw') {
|
|
80
|
+
return { error: `Invalid cell_type: "${resolvedType}". Must be "code", "markdown", or "raw".` };
|
|
81
|
+
}
|
|
82
|
+
const newCell = {
|
|
83
|
+
cell_type: resolvedType,
|
|
84
|
+
source: textToNotebookSource(new_source || ''),
|
|
85
|
+
metadata: {},
|
|
86
|
+
...(resolvedType === 'code' ? { outputs: [], execution_count: null } : {}),
|
|
87
|
+
};
|
|
88
|
+
const insertAt = position !== undefined ? Math.min(position, cells.length) : cells.length;
|
|
89
|
+
cells.splice(insertAt, 0, newCell);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case 'edit_cell': {
|
|
93
|
+
if (cell_number === undefined || cell_number < 0 || cell_number >= cells.length) {
|
|
94
|
+
return { error: `Invalid cell_number: ${cell_number}. Notebook has ${cells.length} cells.` };
|
|
95
|
+
}
|
|
96
|
+
if (new_source === undefined) {
|
|
97
|
+
return { error: 'new_source is required for edit_cell' };
|
|
98
|
+
}
|
|
99
|
+
cells[cell_number].source = textToNotebookSource(new_source);
|
|
100
|
+
if (cells[cell_number].cell_type === 'code') {
|
|
101
|
+
cells[cell_number].outputs = [];
|
|
102
|
+
cells[cell_number].execution_count = null;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'delete_cell': {
|
|
107
|
+
if (cell_number === undefined || cell_number < 0 || cell_number >= cells.length) {
|
|
108
|
+
return { error: `Invalid cell_number: ${cell_number}. Notebook has ${cells.length} cells.` };
|
|
109
|
+
}
|
|
110
|
+
cells.splice(cell_number, 1);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'move_cell': {
|
|
114
|
+
if (cell_number === undefined || cell_number < 0 || cell_number >= cells.length) {
|
|
115
|
+
return { error: `Invalid cell_number: ${cell_number}. Notebook has ${cells.length} cells.` };
|
|
116
|
+
}
|
|
117
|
+
const [cell] = cells.splice(cell_number, 1);
|
|
118
|
+
// After splice, validate position against the reduced array
|
|
119
|
+
const maxPos = cells.length;
|
|
120
|
+
const targetPos = position !== undefined ? Math.min(position, maxPos) : maxPos;
|
|
121
|
+
cells.splice(targetPos, 0, cell);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
notebook.cells = cells;
|
|
126
|
+
await fs.writeFile(safePath, JSON.stringify(notebook, null, 1) + '\n', 'utf-8');
|
|
127
|
+
return { success: true, command, totalCells: cells.length, path: notebook_path };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
return { error: error.message };
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
// ─── Monitor Tool ───
|
|
135
|
+
export const monitorTool = tool({
|
|
136
|
+
name: 'monitor',
|
|
137
|
+
description: 'Check output and status of a background process started by run_command with run_in_background. Returns buffered stdout/stderr and process status.',
|
|
138
|
+
inputSchema: z.object({
|
|
139
|
+
pid: z.number().describe('Process ID returned by run_command'),
|
|
140
|
+
}),
|
|
141
|
+
execute: async ({ pid }) => {
|
|
142
|
+
const proc = backgroundProcesses.get(pid);
|
|
143
|
+
if (!proc) {
|
|
144
|
+
return { error: `No background process found with PID ${pid}. It may have already been cleaned up.` };
|
|
145
|
+
}
|
|
146
|
+
const isRunning = proc.exitCode === null;
|
|
147
|
+
const elapsed = Math.round((Date.now() - proc.startedAt) / 1000);
|
|
148
|
+
const stdout = proc.stdout.slice(proc.lastRead);
|
|
149
|
+
const stderr = proc.stderr.slice(proc.lastStderrRead ?? 0);
|
|
150
|
+
proc.lastRead = proc.stdout.length;
|
|
151
|
+
proc.lastStderrRead = proc.stderr.length;
|
|
152
|
+
const result = {
|
|
153
|
+
pid,
|
|
154
|
+
command: proc.command,
|
|
155
|
+
status: isRunning ? 'running' : 'exited',
|
|
156
|
+
exitCode: proc.exitCode,
|
|
157
|
+
elapsed: `${elapsed}s`,
|
|
158
|
+
stdout: stdout || '(no new output)',
|
|
159
|
+
stderr,
|
|
160
|
+
};
|
|
161
|
+
if (!isRunning) {
|
|
162
|
+
backgroundProcesses.delete(pid);
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
let parentAgentConfig = null;
|
|
168
|
+
export function setAgentToolConfig(config) {
|
|
169
|
+
parentAgentConfig = config;
|
|
170
|
+
}
|
|
171
|
+
export const agentTool = tool({
|
|
172
|
+
name: 'agent',
|
|
173
|
+
description: 'Spawn a sub-agent to handle a complex task in parallel or independently. The sub-agent has access to read/write tools but cannot spawn further agents. Returns the sub-agent\'s final response.',
|
|
174
|
+
inputSchema: z.object({
|
|
175
|
+
prompt: z.string().min(1).max(10000).describe('The task for the sub-agent to perform'),
|
|
176
|
+
description: z.string().min(1).max(100).describe('Short description of the task (3-5 words)'),
|
|
177
|
+
subagent_type: z.enum(['general', 'explore', 'plan']).optional().describe('Agent type: general (read+write), explore (read-only), plan (read-only analysis)'),
|
|
178
|
+
model: z.string().max(100).optional().describe('Model override for the sub-agent'),
|
|
179
|
+
}),
|
|
180
|
+
execute: async ({ prompt, description, subagent_type, model }) => {
|
|
181
|
+
if (!parentAgentConfig) {
|
|
182
|
+
return { error: 'Agent tool not configured. Parent agent config not set.' };
|
|
183
|
+
}
|
|
184
|
+
if (model && !parentAgentConfig.models.includes(model)) {
|
|
185
|
+
return { error: `Unknown model "${model}". Available: ${parentAgentConfig.models.join(', ')}` };
|
|
186
|
+
}
|
|
187
|
+
const isReadOnly = subagent_type === 'explore' || subagent_type === 'plan';
|
|
188
|
+
const toolLevel = isReadOnly ? 'read-only' : 'standard';
|
|
189
|
+
// General sub-agents inherit parent's permission mode (capped at 'auto' — never yolo)
|
|
190
|
+
// Read-only sub-agents always use 'plan' mode
|
|
191
|
+
let subPermissionMode = 'plan';
|
|
192
|
+
let subConfirmToolCall;
|
|
193
|
+
if (!isReadOnly && parentAgentConfig.permissionMode) {
|
|
194
|
+
const parentMode = parentAgentConfig.permissionMode;
|
|
195
|
+
subPermissionMode = parentMode === 'yolo' ? 'auto' : parentMode;
|
|
196
|
+
subConfirmToolCall = parentAgentConfig.confirmToolCall;
|
|
197
|
+
}
|
|
198
|
+
logger.info('Spawning sub-agent', { description, type: subagent_type || 'general', permissionMode: subPermissionMode });
|
|
199
|
+
try {
|
|
200
|
+
const result = await spawnSubAgent(parentAgentConfig, prompt, {
|
|
201
|
+
model,
|
|
202
|
+
tools: toolLevel,
|
|
203
|
+
permissionMode: subPermissionMode,
|
|
204
|
+
confirmToolCall: subConfirmToolCall,
|
|
205
|
+
maxSteps: subagent_type === 'explore' ? _limits.exploreMaxSteps : _limits.subAgentMaxSteps,
|
|
206
|
+
instructions: subagent_type === 'plan'
|
|
207
|
+
? 'You are a planning assistant. Analyze the codebase and design an implementation approach. Be thorough and specific.'
|
|
208
|
+
: subagent_type === 'explore'
|
|
209
|
+
? 'You are a code exploration assistant. Search and read files to answer questions. Be concise and factual.'
|
|
210
|
+
: 'You are a focused assistant. Complete the task efficiently. Be concise.',
|
|
211
|
+
timeout: _limits.agentToolTimeoutMs,
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
description,
|
|
215
|
+
response: result.response,
|
|
216
|
+
model: result.model,
|
|
217
|
+
steps: result.steps,
|
|
218
|
+
tokenEstimate: result.tokenEstimate,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
return { error: `Sub-agent failed: ${error.message}` };
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
// ─── Export ───
|
|
227
|
+
export const advancedTools = [
|
|
228
|
+
todoWriteTool,
|
|
229
|
+
notebookEditTool,
|
|
230
|
+
monitorTool,
|
|
231
|
+
agentTool,
|
|
232
|
+
];
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { tool } from './tool-helper.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { sandboxPath } from './sandbox.js';
|
|
6
|
+
import { encodeImageToDataUrl } from './tools-vision.js';
|
|
7
|
+
let browserInstance = null;
|
|
8
|
+
let browserContext = null;
|
|
9
|
+
let browserPage = null;
|
|
10
|
+
let playwrightAvailable = null;
|
|
11
|
+
async function getPlaywright() {
|
|
12
|
+
if (playwrightAvailable === false)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
// @ts-expect-error playwright is an optional peer dependency
|
|
16
|
+
const pw = await import('playwright');
|
|
17
|
+
playwrightAvailable = true;
|
|
18
|
+
return pw;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
playwrightAvailable = false;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function ensureBrowser() {
|
|
26
|
+
const pw = await getPlaywright();
|
|
27
|
+
if (!pw)
|
|
28
|
+
return null;
|
|
29
|
+
if (browserInstance && browserPage) {
|
|
30
|
+
try {
|
|
31
|
+
await browserPage.title();
|
|
32
|
+
return { browser: browserInstance, page: browserPage };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
try {
|
|
36
|
+
await browserInstance.close();
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
browserInstance = null;
|
|
40
|
+
browserContext = null;
|
|
41
|
+
browserPage = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
browserInstance = await pw.chromium.launch({ headless: true });
|
|
45
|
+
browserContext = await browserInstance.newContext({
|
|
46
|
+
viewport: { width: 1280, height: 720 },
|
|
47
|
+
});
|
|
48
|
+
browserPage = await browserContext.newPage();
|
|
49
|
+
return { browser: browserInstance, page: browserPage };
|
|
50
|
+
}
|
|
51
|
+
export async function closeBrowser() {
|
|
52
|
+
if (browserContext) {
|
|
53
|
+
try {
|
|
54
|
+
await browserContext.close();
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
browserContext = null;
|
|
58
|
+
}
|
|
59
|
+
if (browserInstance) {
|
|
60
|
+
try {
|
|
61
|
+
await browserInstance.close();
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
browserInstance = null;
|
|
65
|
+
browserPage = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export const browserNavigateTool = tool({
|
|
69
|
+
name: 'browser_navigate',
|
|
70
|
+
description: 'Navigate the browser to a URL. Launches a headless Chromium browser if not already running. Requires playwright (`npm i -g playwright`).',
|
|
71
|
+
inputSchema: z.object({
|
|
72
|
+
url: z.string().url().describe('The URL to navigate to'),
|
|
73
|
+
waitFor: z.enum(['load', 'domcontentloaded', 'networkidle']).optional().describe('Wait condition (default: load)'),
|
|
74
|
+
}),
|
|
75
|
+
execute: async ({ url, waitFor }) => {
|
|
76
|
+
const session = await ensureBrowser();
|
|
77
|
+
if (!session) {
|
|
78
|
+
return { error: 'Playwright not available. Install it: npm install playwright && npx playwright install chromium' };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await session.page.goto(url, {
|
|
82
|
+
waitUntil: waitFor || 'load',
|
|
83
|
+
timeout: 30_000,
|
|
84
|
+
});
|
|
85
|
+
const title = await session.page.title();
|
|
86
|
+
const currentUrl = session.page.url();
|
|
87
|
+
return {
|
|
88
|
+
url: currentUrl,
|
|
89
|
+
title,
|
|
90
|
+
status: 'navigated',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return { error: `Navigation failed: ${error.message}` };
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
export const browserScreenshotTool = tool({
|
|
99
|
+
name: 'browser_screenshot',
|
|
100
|
+
description: 'Take a screenshot of the current browser page. Returns the image as a base64 data URL for vision analysis.',
|
|
101
|
+
inputSchema: z.object({
|
|
102
|
+
fullPage: z.boolean().optional().describe('Capture full scrollable page (default: false, viewport only)'),
|
|
103
|
+
selector: z.string().max(500).optional().describe('CSS selector to screenshot a specific element'),
|
|
104
|
+
outputPath: z.string().max(500).optional().describe('Save screenshot to file (default: temporary)'),
|
|
105
|
+
}),
|
|
106
|
+
execute: async ({ fullPage, selector, outputPath }) => {
|
|
107
|
+
const session = await ensureBrowser();
|
|
108
|
+
if (!session) {
|
|
109
|
+
return { error: 'Browser not running. Use browser_navigate first.' };
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const outPath = outputPath
|
|
113
|
+
? sandboxPath(outputPath)
|
|
114
|
+
: path.join(process.cwd(), '.scalix', `browser-${Date.now()}.png`);
|
|
115
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
116
|
+
if (selector) {
|
|
117
|
+
const element = await session.page.$(selector);
|
|
118
|
+
if (!element)
|
|
119
|
+
return { error: `Element not found: ${selector}` };
|
|
120
|
+
await element.screenshot({ path: outPath });
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
await session.page.screenshot({ path: outPath, fullPage: fullPage ?? false });
|
|
124
|
+
}
|
|
125
|
+
const dataUrl = await encodeImageToDataUrl(outPath);
|
|
126
|
+
const stat = await fs.stat(outPath);
|
|
127
|
+
if (!outputPath) {
|
|
128
|
+
await fs.unlink(outPath).catch(() => { });
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
url: session.page.url(),
|
|
132
|
+
title: await session.page.title(),
|
|
133
|
+
path: outputPath || undefined,
|
|
134
|
+
size: stat.size,
|
|
135
|
+
dataUrl,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return { error: `Screenshot failed: ${error.message}` };
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
export const browserClickTool = tool({
|
|
144
|
+
name: 'browser_click',
|
|
145
|
+
description: 'Click an element on the current browser page by CSS selector or text content.',
|
|
146
|
+
inputSchema: z.object({
|
|
147
|
+
selector: z.string().max(500).optional().describe('CSS selector to click'),
|
|
148
|
+
text: z.string().max(200).optional().describe('Click element containing this text'),
|
|
149
|
+
button: z.enum(['left', 'right', 'middle']).optional().describe('Mouse button (default: left)'),
|
|
150
|
+
}),
|
|
151
|
+
execute: async ({ selector, text, button }) => {
|
|
152
|
+
const session = await ensureBrowser();
|
|
153
|
+
if (!session) {
|
|
154
|
+
return { error: 'Browser not running. Use browser_navigate first.' };
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
if (text) {
|
|
158
|
+
await session.page.getByText(text, { exact: false }).first().click({ button: button || 'left', timeout: 10_000 });
|
|
159
|
+
}
|
|
160
|
+
else if (selector) {
|
|
161
|
+
await session.page.click(selector, { button: button || 'left', timeout: 10_000 });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
return { error: 'Provide either selector or text to click' };
|
|
165
|
+
}
|
|
166
|
+
await session.page.waitForTimeout(500);
|
|
167
|
+
return { clicked: selector || `text:"${text}"`, url: session.page.url() };
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
return { error: `Click failed: ${error.message}` };
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
export const browserTypeTool = tool({
|
|
175
|
+
name: 'browser_type',
|
|
176
|
+
description: 'Type text into an input field on the current browser page.',
|
|
177
|
+
inputSchema: z.object({
|
|
178
|
+
selector: z.string().max(500).describe('CSS selector of the input field'),
|
|
179
|
+
text: z.string().max(5000).describe('Text to type'),
|
|
180
|
+
clear: z.boolean().optional().describe('Clear the field before typing (default: true)'),
|
|
181
|
+
pressEnter: z.boolean().optional().describe('Press Enter after typing (default: false)'),
|
|
182
|
+
}),
|
|
183
|
+
execute: async ({ selector, text, clear, pressEnter }) => {
|
|
184
|
+
const session = await ensureBrowser();
|
|
185
|
+
if (!session) {
|
|
186
|
+
return { error: 'Browser not running. Use browser_navigate first.' };
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
if (clear !== false) {
|
|
190
|
+
await session.page.fill(selector, text, { timeout: 10_000 });
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
await session.page.type(selector, text, { timeout: 10_000 });
|
|
194
|
+
}
|
|
195
|
+
if (pressEnter) {
|
|
196
|
+
await session.page.press(selector, 'Enter');
|
|
197
|
+
await session.page.waitForTimeout(500);
|
|
198
|
+
}
|
|
199
|
+
return { typed: text.length > 50 ? text.slice(0, 50) + '...' : text, selector, url: session.page.url() };
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
return { error: `Type failed: ${error.message}` };
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
export const browserEvalTool = tool({
|
|
207
|
+
name: 'browser_eval',
|
|
208
|
+
description: 'Execute JavaScript in the browser page context. Returns the result. Useful for reading page state, DOM queries, or interacting with the page programmatically.',
|
|
209
|
+
inputSchema: z.object({
|
|
210
|
+
script: z.string().max(10000).describe('JavaScript code to execute in the page context'),
|
|
211
|
+
}),
|
|
212
|
+
execute: async ({ script }) => {
|
|
213
|
+
const session = await ensureBrowser();
|
|
214
|
+
if (!session) {
|
|
215
|
+
return { error: 'Browser not running. Use browser_navigate first.' };
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const result = await session.page.evaluate(script);
|
|
219
|
+
return { result: typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result) };
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
return { error: `Eval failed: ${error.message}` };
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
export const browserGetTextTool = tool({
|
|
227
|
+
name: 'browser_get_text',
|
|
228
|
+
description: 'Extract visible text content from the current browser page or a specific element.',
|
|
229
|
+
inputSchema: z.object({
|
|
230
|
+
selector: z.string().max(500).optional().describe('CSS selector to extract text from (default: entire page body)'),
|
|
231
|
+
maxLength: z.number().optional().describe('Max characters to return (default: 50000)'),
|
|
232
|
+
}),
|
|
233
|
+
execute: async ({ selector, maxLength }) => {
|
|
234
|
+
const session = await ensureBrowser();
|
|
235
|
+
if (!session) {
|
|
236
|
+
return { error: 'Browser not running. Use browser_navigate first.' };
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const sel = selector || 'body';
|
|
240
|
+
const text = await session.page.$eval(sel, (el) => el.innerText);
|
|
241
|
+
const limit = maxLength ?? 50_000;
|
|
242
|
+
const content = text.length > limit ? text.slice(0, limit) + '\n...[truncated]' : text;
|
|
243
|
+
return {
|
|
244
|
+
url: session.page.url(),
|
|
245
|
+
title: await session.page.title(),
|
|
246
|
+
selector: sel,
|
|
247
|
+
length: text.length,
|
|
248
|
+
content,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
return { error: `Get text failed: ${error.message}` };
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
export const browserCloseTool = tool({
|
|
257
|
+
name: 'browser_close',
|
|
258
|
+
description: 'Close the browser instance and free resources.',
|
|
259
|
+
inputSchema: z.object({}),
|
|
260
|
+
execute: async () => {
|
|
261
|
+
await closeBrowser();
|
|
262
|
+
return { status: 'closed' };
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
export const browserTools = [
|
|
266
|
+
browserNavigateTool,
|
|
267
|
+
browserScreenshotTool,
|
|
268
|
+
browserClickTool,
|
|
269
|
+
browserTypeTool,
|
|
270
|
+
browserEvalTool,
|
|
271
|
+
browserGetTextTool,
|
|
272
|
+
browserCloseTool,
|
|
273
|
+
];
|