scalix 0.0.1 → 1.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/.env.example +29 -0
- package/CHANGELOG.md +122 -0
- package/LICENSE +100 -0
- package/README.md +300 -2
- package/bin/scalix.js +19 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent-replay.js +166 -0
- package/dist/agent.js +1130 -0
- package/dist/api-client.js +77 -0
- package/dist/auto-verify.js +136 -0
- package/dist/change-tracker.js +53 -0
- package/dist/cli.js +2454 -0
- package/dist/commands.js +71 -0
- package/dist/config.js +16 -0
- package/dist/constants.js +358 -0
- package/dist/context-builder.js +158 -0
- package/dist/context-manager.js +148 -0
- package/dist/context-retriever.js +257 -0
- package/dist/cost-tracker.js +85 -0
- package/dist/diff.js +140 -0
- package/dist/git-context.js +183 -0
- package/dist/git-undo.js +67 -0
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +743 -0
- package/dist/hooks.js +100 -0
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +232 -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 +376 -0
- package/dist/project-memory.js +165 -0
- package/dist/safe-math.js +194 -0
- package/dist/sandbox.js +403 -0
- package/dist/scoped-rules.js +82 -0
- package/dist/sessions.js +106 -0
- package/dist/settings.js +260 -0
- package/dist/sub-agent.js +154 -0
- package/dist/task-board.js +105 -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 +487 -0
- package/dist/tools-browser.js +534 -0
- package/dist/tools-extra.js +390 -0
- package/dist/tools-vision.js +116 -0
- package/dist/tools-voice.js +147 -0
- package/dist/tools-web.js +346 -0
- package/dist/tools.js +380 -0
- package/dist/types.js +1 -0
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/docs/DISTRIBUTION.md +301 -0
- package/package.json +71 -6
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { tool } from './tool-helper.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fg from 'fast-glob';
|
|
8
|
+
import { sandboxPath } from './sandbox.js';
|
|
9
|
+
import { computeDiff, formatDiff } from './diff.js';
|
|
10
|
+
import { TOOL_DEFAULTS, GIT_BOT_IDENTITY } from './constants.js';
|
|
11
|
+
import { DEFAULT_LIMITS } from './settings.js';
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
let _limits = DEFAULT_LIMITS;
|
|
14
|
+
export function setExtraToolLimits(limits) { _limits = limits; }
|
|
15
|
+
const BINARY_SIGNATURES = [
|
|
16
|
+
[0x7f, 0x45, 0x4c, 0x46], // ELF
|
|
17
|
+
[0x89, 0x50, 0x4e, 0x47], // PNG
|
|
18
|
+
[0xff, 0xd8, 0xff], // JPEG
|
|
19
|
+
[0x50, 0x4b, 0x03, 0x04], // ZIP/JAR
|
|
20
|
+
[0x47, 0x49, 0x46], // GIF
|
|
21
|
+
[0x25, 0x50, 0x44, 0x46], // PDF
|
|
22
|
+
];
|
|
23
|
+
async function isBinaryFile(filePath) {
|
|
24
|
+
let fd = null;
|
|
25
|
+
try {
|
|
26
|
+
fd = await fs.open(filePath, 'r');
|
|
27
|
+
const buf = Buffer.alloc(8);
|
|
28
|
+
await fd.read(buf, 0, 8, 0);
|
|
29
|
+
for (const sig of BINARY_SIGNATURES) {
|
|
30
|
+
if (sig.every((b, i) => buf[i] === b))
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (buf.includes(0x00))
|
|
34
|
+
return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await fd?.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export const editFileTool = tool({
|
|
45
|
+
name: 'edit_file',
|
|
46
|
+
description: 'Edit a file by replacing a specific string with a new string. More precise than write_file — only changes what you specify. The old_string must match exactly (including whitespace/indentation).',
|
|
47
|
+
inputSchema: z.object({
|
|
48
|
+
filePath: z.string().max(500).describe('Relative path to the file'),
|
|
49
|
+
oldString: z.string().min(1).max(TOOL_DEFAULTS.maxEditStringLength).describe('The exact string to find and replace'),
|
|
50
|
+
newString: z.string().max(TOOL_DEFAULTS.maxEditStringLength).describe('The replacement string'),
|
|
51
|
+
replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, replaces first only)'),
|
|
52
|
+
}),
|
|
53
|
+
execute: async ({ filePath, oldString, newString, replaceAll }) => {
|
|
54
|
+
try {
|
|
55
|
+
const safePath = sandboxPath(filePath);
|
|
56
|
+
if (await isBinaryFile(safePath)) {
|
|
57
|
+
return { error: 'Cannot edit binary file.' };
|
|
58
|
+
}
|
|
59
|
+
const content = await fs.readFile(safePath, 'utf-8');
|
|
60
|
+
if (!content.includes(oldString)) {
|
|
61
|
+
return { error: 'old_string not found in file. Make sure it matches exactly including whitespace.' };
|
|
62
|
+
}
|
|
63
|
+
const count = content.split(oldString).length - 1;
|
|
64
|
+
if (count > 1 && !replaceAll) {
|
|
65
|
+
return { error: `old_string found ${count} times. Set replaceAll=true or provide more context to make it unique.` };
|
|
66
|
+
}
|
|
67
|
+
const updated = replaceAll
|
|
68
|
+
? content.split(oldString).join(newString)
|
|
69
|
+
: content.replace(oldString, newString);
|
|
70
|
+
await fs.writeFile(safePath, updated, 'utf-8');
|
|
71
|
+
const replaced = replaceAll ? count : 1;
|
|
72
|
+
const diff = computeDiff(content, updated);
|
|
73
|
+
return { success: true, message: `Replaced ${replaced} occurrence(s) in ${filePath}`, diff: formatDiff(diff, filePath) };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return { error: error.message };
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
export const searchFilesTool = tool({
|
|
81
|
+
name: 'search_files',
|
|
82
|
+
description: 'Search for a regex pattern across files. Uses ripgrep (rg) when available, falls back to built-in search. Supports output modes, context lines, file type filters, and pagination.',
|
|
83
|
+
inputSchema: z.object({
|
|
84
|
+
pattern: z.string().min(1).max(500).describe('Regex pattern to search for'),
|
|
85
|
+
path: z.string().max(500).optional().describe('File or directory to search in (defaults to cwd)'),
|
|
86
|
+
glob: z.string().max(200).optional().describe('Glob pattern to filter files (e.g., "*.ts", "**/*.tsx")'),
|
|
87
|
+
type: z.string().max(50).optional().describe('File type filter (e.g., "js", "py", "rust") — maps to rg --type'),
|
|
88
|
+
output_mode: z.enum(['content', 'files_with_matches', 'count']).optional().describe('Output mode (default: "files_with_matches")'),
|
|
89
|
+
context: z.number().min(0).max(10).optional().describe('Lines of context before and after each match (-C)'),
|
|
90
|
+
'-A': z.number().min(0).max(10).optional().describe('Lines to show after each match'),
|
|
91
|
+
'-B': z.number().min(0).max(10).optional().describe('Lines to show before each match'),
|
|
92
|
+
'-i': z.boolean().optional().describe('Case insensitive search'),
|
|
93
|
+
'-n': z.boolean().optional().describe('Show line numbers (default: true for content mode)'),
|
|
94
|
+
multiline: z.boolean().optional().describe('Enable multiline mode (pattern can span lines)'),
|
|
95
|
+
head_limit: z.number().min(0).max(1000).optional().describe('Limit output to first N entries (default: 250)'),
|
|
96
|
+
offset: z.number().min(0).optional().describe('Skip first N entries before applying head_limit'),
|
|
97
|
+
}),
|
|
98
|
+
execute: async (args) => {
|
|
99
|
+
const { pattern, path: searchPath, glob: fileGlob, type: fileType, output_mode: outputMode = 'files_with_matches', context, multiline, head_limit: headLimit = 250, offset = 0, } = args;
|
|
100
|
+
const caseInsensitive = args['-i'];
|
|
101
|
+
const afterContext = args['-A'];
|
|
102
|
+
const beforeContext = args['-B'];
|
|
103
|
+
try {
|
|
104
|
+
const cwd = process.cwd();
|
|
105
|
+
const target = searchPath || '.';
|
|
106
|
+
// Try ripgrep first
|
|
107
|
+
const rgArgs = [];
|
|
108
|
+
if (outputMode === 'files_with_matches')
|
|
109
|
+
rgArgs.push('-l');
|
|
110
|
+
else if (outputMode === 'count')
|
|
111
|
+
rgArgs.push('-c');
|
|
112
|
+
else
|
|
113
|
+
rgArgs.push('-n');
|
|
114
|
+
if (caseInsensitive)
|
|
115
|
+
rgArgs.push('-i');
|
|
116
|
+
if (multiline)
|
|
117
|
+
rgArgs.push('-U', '--multiline-dotall');
|
|
118
|
+
if (fileGlob)
|
|
119
|
+
rgArgs.push('--glob', fileGlob);
|
|
120
|
+
if (fileType)
|
|
121
|
+
rgArgs.push('--type', fileType);
|
|
122
|
+
if (outputMode === 'content') {
|
|
123
|
+
if (context !== undefined)
|
|
124
|
+
rgArgs.push('-C', String(context));
|
|
125
|
+
if (afterContext !== undefined)
|
|
126
|
+
rgArgs.push('-A', String(afterContext));
|
|
127
|
+
if (beforeContext !== undefined)
|
|
128
|
+
rgArgs.push('-B', String(beforeContext));
|
|
129
|
+
}
|
|
130
|
+
rgArgs.push('--', pattern, target);
|
|
131
|
+
try {
|
|
132
|
+
const { stdout } = await execFileAsync('rg', rgArgs, {
|
|
133
|
+
cwd,
|
|
134
|
+
timeout: _limits.searchTimeoutMs,
|
|
135
|
+
maxBuffer: TOOL_DEFAULTS.maxExecBuffer,
|
|
136
|
+
});
|
|
137
|
+
const lines = stdout.split('\n').filter(Boolean);
|
|
138
|
+
const sliced = lines.slice(offset, headLimit > 0 ? offset + headLimit : undefined);
|
|
139
|
+
if (outputMode === 'files_with_matches') {
|
|
140
|
+
return { pattern, count: sliced.length, files: sliced };
|
|
141
|
+
}
|
|
142
|
+
else if (outputMode === 'count') {
|
|
143
|
+
const counts = sliced.map(line => {
|
|
144
|
+
const [file, cnt] = line.split(':');
|
|
145
|
+
return { file, count: parseInt(cnt, 10) || 0 };
|
|
146
|
+
});
|
|
147
|
+
return { pattern, entries: counts };
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
return { pattern, matchCount: sliced.length, content: sliced.join('\n') };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (rgErr) {
|
|
154
|
+
if (rgErr.code === 1) {
|
|
155
|
+
return { pattern, matchCount: 0, matches: [] };
|
|
156
|
+
}
|
|
157
|
+
// rg not found or other error — fall back to built-in search
|
|
158
|
+
if (!rgErr.message?.includes('command not found') && !rgErr.message?.includes('ENOENT') && rgErr.code !== 127) {
|
|
159
|
+
return { pattern, matchCount: 0, matches: [] };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Fallback: built-in search using fast-glob + regex
|
|
163
|
+
const globPattern = fileGlob || '**/*';
|
|
164
|
+
const files = await fg(globPattern, {
|
|
165
|
+
cwd,
|
|
166
|
+
ignore: ['node_modules/**', '.git/**', 'dist/**', 'coverage/**', '*.lock'],
|
|
167
|
+
absolute: true,
|
|
168
|
+
onlyFiles: true,
|
|
169
|
+
followSymbolicLinks: false,
|
|
170
|
+
});
|
|
171
|
+
let regex;
|
|
172
|
+
try {
|
|
173
|
+
const flags = `${caseInsensitive ? 'i' : ''}${multiline ? 'ms' : ''}`;
|
|
174
|
+
regex = new RegExp(pattern, flags);
|
|
175
|
+
// Probe with a short string to catch catastrophic patterns early
|
|
176
|
+
const probe = 'a'.repeat(50);
|
|
177
|
+
const start = performance.now();
|
|
178
|
+
regex.test(probe);
|
|
179
|
+
if (performance.now() - start > 100) {
|
|
180
|
+
return { error: 'Regex pattern is too complex (possible catastrophic backtracking). Simplify the pattern.' };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
return { error: `Invalid regex pattern: ${e.message}` };
|
|
185
|
+
}
|
|
186
|
+
if (outputMode === 'files_with_matches') {
|
|
187
|
+
const matchingFiles = [];
|
|
188
|
+
let skipped = 0;
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
try {
|
|
191
|
+
const content = await fs.readFile(file, 'utf-8');
|
|
192
|
+
if (regex.test(content)) {
|
|
193
|
+
matchingFiles.push(path.relative(cwd, file));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
skipped++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const sliced = matchingFiles.slice(offset, headLimit > 0 ? offset + headLimit : undefined);
|
|
201
|
+
const result = { pattern, count: sliced.length, files: sliced };
|
|
202
|
+
if (skipped > 0)
|
|
203
|
+
result.warning = `${skipped} file(s) could not be read`;
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
const matches = [];
|
|
207
|
+
const limit = headLimit > 0 ? offset + headLimit : offset + 1000;
|
|
208
|
+
let skipped = 0;
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
if (matches.length >= limit)
|
|
211
|
+
break;
|
|
212
|
+
try {
|
|
213
|
+
const fileContent = await fs.readFile(file, 'utf-8');
|
|
214
|
+
const fileLines = fileContent.split('\n');
|
|
215
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
216
|
+
if (regex.test(fileLines[i])) {
|
|
217
|
+
matches.push({
|
|
218
|
+
file: path.relative(cwd, file),
|
|
219
|
+
line: i + 1,
|
|
220
|
+
text: fileLines[i].trim().slice(0, 200),
|
|
221
|
+
});
|
|
222
|
+
if (matches.length >= limit)
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
skipped++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const sliced = matches.slice(offset);
|
|
232
|
+
const warn = skipped > 0 ? `${skipped} file(s) could not be read` : undefined;
|
|
233
|
+
if (outputMode === 'count') {
|
|
234
|
+
const countMap = new Map();
|
|
235
|
+
for (const m of sliced) {
|
|
236
|
+
countMap.set(m.file, (countMap.get(m.file) ?? 0) + 1);
|
|
237
|
+
}
|
|
238
|
+
const result = { pattern, entries: [...countMap.entries()].map(([file, count]) => ({ file, count })) };
|
|
239
|
+
if (warn)
|
|
240
|
+
result.warning = warn;
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
if (outputMode === 'content') {
|
|
244
|
+
const contentLines = sliced.map(m => `${m.file}:${m.line}:${m.text}`);
|
|
245
|
+
const result = { pattern, matchCount: sliced.length, content: contentLines.join('\n') };
|
|
246
|
+
if (warn)
|
|
247
|
+
result.warning = warn;
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
const result = { pattern, matchCount: sliced.length, matches: sliced };
|
|
251
|
+
if (warn)
|
|
252
|
+
result.warning = warn;
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return { error: error.message };
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
export const findFilesTool = tool({
|
|
261
|
+
name: 'find_files',
|
|
262
|
+
description: 'Find files matching a glob pattern. Returns file paths sorted by modification time (most recent first). Respects .gitignore.',
|
|
263
|
+
inputSchema: z.object({
|
|
264
|
+
pattern: z.string().min(1).max(500).describe('Glob pattern (e.g., "**/*.ts", "src/**/*.test.*")'),
|
|
265
|
+
path: z.string().max(500).optional().describe('Directory to search in (defaults to cwd)'),
|
|
266
|
+
maxResults: z.number().max(500).optional().describe('Maximum results (default: 100)'),
|
|
267
|
+
}),
|
|
268
|
+
execute: async ({ pattern, path: searchPath, maxResults }) => {
|
|
269
|
+
try {
|
|
270
|
+
const limit = maxResults ?? 100;
|
|
271
|
+
const cwd = searchPath ? sandboxPath(searchPath) : process.cwd();
|
|
272
|
+
const files = await fg(pattern, {
|
|
273
|
+
cwd,
|
|
274
|
+
ignore: ['node_modules/**', '.git/**', 'dist/**', 'coverage/**'],
|
|
275
|
+
absolute: true,
|
|
276
|
+
onlyFiles: true,
|
|
277
|
+
stats: true,
|
|
278
|
+
followSymbolicLinks: false,
|
|
279
|
+
});
|
|
280
|
+
const baseCwd = process.cwd();
|
|
281
|
+
const sorted = files
|
|
282
|
+
.sort((a, b) => (b.stats?.mtimeMs ?? 0) - (a.stats?.mtimeMs ?? 0))
|
|
283
|
+
.slice(0, limit)
|
|
284
|
+
.map((f) => ({
|
|
285
|
+
path: path.relative(baseCwd, f.path),
|
|
286
|
+
size: f.stats?.size ?? 0,
|
|
287
|
+
modified: f.stats?.mtime?.toISOString() ?? '',
|
|
288
|
+
}));
|
|
289
|
+
return { pattern, count: sorted.length, files: sorted };
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
return { error: error.message };
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
export const gitStatusTool = tool({
|
|
297
|
+
name: 'git_status',
|
|
298
|
+
description: 'Get git repository status: current branch, staged/unstaged changes, untracked files.',
|
|
299
|
+
inputSchema: z.object({}),
|
|
300
|
+
execute: async () => {
|
|
301
|
+
try {
|
|
302
|
+
const gitTimeout = _limits.gitTimeoutMs;
|
|
303
|
+
const [branch, status, log] = await Promise.all([
|
|
304
|
+
execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { timeout: gitTimeout }).then((r) => r.stdout.trim()),
|
|
305
|
+
execFileAsync('git', ['status', '--porcelain'], { timeout: gitTimeout }).then((r) => r.stdout.trim()),
|
|
306
|
+
execFileAsync('git', ['log', '--oneline', '-5'], { timeout: gitTimeout }).then((r) => r.stdout.trim()),
|
|
307
|
+
]);
|
|
308
|
+
const lines = status ? status.split('\n') : [];
|
|
309
|
+
const staged = lines.filter((l) => /^[MADRCUT]/.test(l)).map((l) => l.trim());
|
|
310
|
+
const unstaged = lines.filter((l) => /^.[MADRCUT]/.test(l)).map((l) => l.trim());
|
|
311
|
+
const untracked = lines.filter((l) => l.startsWith('??')).map((l) => l.slice(3));
|
|
312
|
+
return { branch, staged, unstaged, untracked, recentCommits: log.split('\n') };
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return { error: error.message };
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
export const gitDiffTool = tool({
|
|
320
|
+
name: 'git_diff',
|
|
321
|
+
description: 'Show git diff for staged or unstaged changes.',
|
|
322
|
+
inputSchema: z.object({
|
|
323
|
+
staged: z.boolean().optional().describe('Show staged changes (default: false, shows unstaged)'),
|
|
324
|
+
file: z.string().max(500).optional().describe('Specific file to diff'),
|
|
325
|
+
}),
|
|
326
|
+
execute: async ({ staged, file }) => {
|
|
327
|
+
try {
|
|
328
|
+
const args = staged ? ['diff', '--staged'] : ['diff'];
|
|
329
|
+
if (file)
|
|
330
|
+
args.push('--', file);
|
|
331
|
+
const maxBuffer = _limits.gitDiffMaxBuffer;
|
|
332
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
333
|
+
timeout: _limits.gitTimeoutMs,
|
|
334
|
+
maxBuffer,
|
|
335
|
+
});
|
|
336
|
+
const truncated = stdout.length >= maxBuffer - 1024;
|
|
337
|
+
return {
|
|
338
|
+
diff: stdout || '(no changes)',
|
|
339
|
+
staged: !!staged,
|
|
340
|
+
...(truncated ? { warning: `Output may be truncated at ${Math.round(maxBuffer / 1024)}KB. Use 'file' parameter to diff specific files.` } : {}),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
return { error: error.message };
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
export const gitCommitTool = tool({
|
|
349
|
+
name: 'git_commit',
|
|
350
|
+
description: 'Stage files and create a git commit. Requires user confirmation.',
|
|
351
|
+
inputSchema: z.object({
|
|
352
|
+
message: z.string().min(1).max(1000).describe('Commit message'),
|
|
353
|
+
files: z.array(z.string().max(500)).optional().describe('Files to stage (default: all changed files)'),
|
|
354
|
+
}),
|
|
355
|
+
execute: async ({ message, files }) => {
|
|
356
|
+
const gitEnv = {
|
|
357
|
+
...process.env,
|
|
358
|
+
GIT_AUTHOR_NAME: GIT_BOT_IDENTITY.name,
|
|
359
|
+
GIT_AUTHOR_EMAIL: GIT_BOT_IDENTITY.email,
|
|
360
|
+
GIT_COMMITTER_NAME: GIT_BOT_IDENTITY.name,
|
|
361
|
+
GIT_COMMITTER_EMAIL: GIT_BOT_IDENTITY.email,
|
|
362
|
+
};
|
|
363
|
+
try {
|
|
364
|
+
if (files && files.length > 0) {
|
|
365
|
+
const safePaths = files.map((f) => sandboxPath(f));
|
|
366
|
+
await execFileAsync('git', ['add', ...safePaths], { timeout: _limits.gitTimeoutMs });
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
await execFileAsync('git', ['add', '-A'], { timeout: _limits.gitTimeoutMs });
|
|
370
|
+
}
|
|
371
|
+
const { stdout: staged } = await execFileAsync('git', ['diff', '--cached', '--stat'], { timeout: _limits.gitTimeoutMs });
|
|
372
|
+
if (!staged.trim()) {
|
|
373
|
+
return { error: 'Nothing to commit. No changes are staged.' };
|
|
374
|
+
}
|
|
375
|
+
const { stdout } = await execFileAsync('git', ['commit', '-m', message], { timeout: _limits.gitTimeoutMs, env: gitEnv });
|
|
376
|
+
return { success: true, output: stdout.trim() };
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
return { error: error.message };
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
export const extraTools = [
|
|
384
|
+
editFileTool,
|
|
385
|
+
searchFilesTool,
|
|
386
|
+
findFilesTool,
|
|
387
|
+
gitStatusTool,
|
|
388
|
+
gitDiffTool,
|
|
389
|
+
gitCommitTool,
|
|
390
|
+
];
|
|
@@ -0,0 +1,116 @@
|
|
|
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, getMaxImageSize } from './sandbox.js';
|
|
6
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
7
|
+
const MIME_TYPES = {
|
|
8
|
+
'.png': 'image/png',
|
|
9
|
+
'.jpg': 'image/jpeg',
|
|
10
|
+
'.jpeg': 'image/jpeg',
|
|
11
|
+
'.gif': 'image/gif',
|
|
12
|
+
'.webp': 'image/webp',
|
|
13
|
+
'.bmp': 'image/bmp',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
};
|
|
16
|
+
export function isImageFile(filePath) {
|
|
17
|
+
return IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
export async function encodeImageToDataUrl(filePath) {
|
|
20
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
21
|
+
const mime = MIME_TYPES[ext] || 'image/png';
|
|
22
|
+
const buffer = await fs.readFile(filePath);
|
|
23
|
+
const base64 = buffer.toString('base64');
|
|
24
|
+
return `data:${mime};base64,${base64}`;
|
|
25
|
+
}
|
|
26
|
+
export const readImageTool = tool({
|
|
27
|
+
name: 'read_image',
|
|
28
|
+
description: 'Read an image file and return it as a base64 data URL for vision analysis. Supports PNG, JPG, GIF, WebP, BMP, SVG. Use this to analyze screenshots, mockups, diagrams, or any visual content.',
|
|
29
|
+
inputSchema: z.object({
|
|
30
|
+
filePath: z.string().max(500).describe('Relative path to the image file'),
|
|
31
|
+
detail: z.enum(['auto', 'low', 'high']).optional().describe('Vision detail level (default: auto)'),
|
|
32
|
+
}),
|
|
33
|
+
execute: async ({ filePath, detail }) => {
|
|
34
|
+
try {
|
|
35
|
+
const safePath = sandboxPath(filePath);
|
|
36
|
+
const ext = path.extname(safePath).toLowerCase();
|
|
37
|
+
if (!IMAGE_EXTENSIONS.has(ext)) {
|
|
38
|
+
return { error: `Not a supported image format. Supported: ${[...IMAGE_EXTENSIONS].join(', ')}` };
|
|
39
|
+
}
|
|
40
|
+
const stat = await fs.stat(safePath);
|
|
41
|
+
const maxSize = getMaxImageSize();
|
|
42
|
+
if (stat.size > maxSize) {
|
|
43
|
+
return { error: `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max ${Math.round(maxSize / 1024 / 1024)}MB.` };
|
|
44
|
+
}
|
|
45
|
+
const dataUrl = await encodeImageToDataUrl(safePath);
|
|
46
|
+
return {
|
|
47
|
+
filePath,
|
|
48
|
+
format: ext.slice(1),
|
|
49
|
+
size: stat.size,
|
|
50
|
+
dataUrl,
|
|
51
|
+
detail: detail || 'auto',
|
|
52
|
+
hint: 'Image loaded. The image data has been encoded for vision analysis.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return { error: error.message };
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
export const screenshotTool = tool({
|
|
61
|
+
name: 'screenshot',
|
|
62
|
+
description: 'Take a screenshot of the current screen or a specific window. Requires `screencapture` (macOS), `gnome-screenshot`/`scrot` (Linux), or `nircmd` (Windows).',
|
|
63
|
+
inputSchema: z.object({
|
|
64
|
+
outputPath: z.string().max(500).optional().describe('Where to save the screenshot (default: .scalix/screenshot.png)'),
|
|
65
|
+
window: z.boolean().optional().describe('Capture only the focused window (default: false, captures full screen)'),
|
|
66
|
+
}),
|
|
67
|
+
execute: async ({ outputPath, window: captureWindow }) => {
|
|
68
|
+
const { execFile } = await import('node:child_process');
|
|
69
|
+
const { promisify } = await import('node:util');
|
|
70
|
+
const execFileAsync = promisify(execFile);
|
|
71
|
+
const outPath = sandboxPath(outputPath || '.scalix/screenshot.png');
|
|
72
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
73
|
+
const platform = process.platform;
|
|
74
|
+
try {
|
|
75
|
+
if (platform === 'darwin') {
|
|
76
|
+
const args = captureWindow ? ['-w', outPath] : [outPath];
|
|
77
|
+
await execFileAsync('screencapture', args, { timeout: 10_000 });
|
|
78
|
+
}
|
|
79
|
+
else if (platform === 'linux') {
|
|
80
|
+
try {
|
|
81
|
+
const args = captureWindow ? ['-w', '-f', outPath] : ['-f', outPath];
|
|
82
|
+
await execFileAsync('gnome-screenshot', args, { timeout: 10_000 });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
const args = captureWindow ? ['-u', outPath] : [outPath];
|
|
86
|
+
await execFileAsync('scrot', args, { timeout: 10_000 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (platform === 'win32') {
|
|
90
|
+
const safePath = outPath.replace(/'/g, "''").replace(/`/g, '``').replace(/\$/g, '`$');
|
|
91
|
+
await execFileAsync('powershell', [
|
|
92
|
+
'-Command',
|
|
93
|
+
`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen | ForEach-Object { $b = $_.Bounds; $bmp = New-Object Drawing.Bitmap($b.Width,$b.Height); [Drawing.Graphics]::FromImage($bmp).CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $bmp.Save('${safePath}') }`,
|
|
94
|
+
], { timeout: 10_000 });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
return { error: `Screenshot not supported on platform: ${platform}` };
|
|
98
|
+
}
|
|
99
|
+
const stat = await fs.stat(outPath);
|
|
100
|
+
const dataUrl = await encodeImageToDataUrl(outPath);
|
|
101
|
+
return {
|
|
102
|
+
path: outPath,
|
|
103
|
+
size: stat.size,
|
|
104
|
+
dataUrl,
|
|
105
|
+
hint: 'Screenshot captured and encoded for vision analysis.',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return { error: `Screenshot failed: ${error.message}. Ensure screenshot tools are available.` };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
export const visionTools = [
|
|
114
|
+
readImageTool,
|
|
115
|
+
screenshotTool,
|
|
116
|
+
];
|
|
@@ -0,0 +1,147 @@
|
|
|
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 { execFile, spawn } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
async function findRecordCommand() {
|
|
9
|
+
const platform = process.platform;
|
|
10
|
+
if (platform === 'darwin') {
|
|
11
|
+
try {
|
|
12
|
+
await execFileAsync('which', ['sox'], { timeout: 3000 });
|
|
13
|
+
return { cmd: 'sox', args: ['-d', '-t', 'wav', '-r', '16000', '-c', '1'] };
|
|
14
|
+
}
|
|
15
|
+
catch { }
|
|
16
|
+
try {
|
|
17
|
+
await execFileAsync('which', ['rec'], { timeout: 3000 });
|
|
18
|
+
return { cmd: 'rec', args: ['-r', '16000', '-c', '1'] };
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
}
|
|
22
|
+
if (platform === 'linux') {
|
|
23
|
+
try {
|
|
24
|
+
await execFileAsync('which', ['arecord'], { timeout: 3000 });
|
|
25
|
+
return { cmd: 'arecord', args: ['-f', 'S16_LE', '-r', '16000', '-c', '1', '-t', 'wav'] };
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
try {
|
|
29
|
+
await execFileAsync('which', ['sox'], { timeout: 3000 });
|
|
30
|
+
return { cmd: 'sox', args: ['-d', '-t', 'wav', '-r', '16000', '-c', '1'] };
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
if (platform === 'win32') {
|
|
35
|
+
return {
|
|
36
|
+
cmd: 'powershell',
|
|
37
|
+
args: ['-Command', 'Add-Type -AssemblyName System.Speech; $r = New-Object System.Speech.Recognition.SpeechRecognitionEngine; $r.SetInputToDefaultAudioDevice(); $r.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar)); $result = $r.Recognize(); $result.Text'],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
export async function recordAudio(outputPath, durationSeconds) {
|
|
43
|
+
const recorder = await findRecordCommand();
|
|
44
|
+
if (!recorder)
|
|
45
|
+
return false;
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const args = [...recorder.args, outputPath];
|
|
48
|
+
const child = spawn(recorder.cmd, args, { stdio: 'ignore' });
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
child.kill('SIGTERM');
|
|
51
|
+
setTimeout(() => { if (!child.killed)
|
|
52
|
+
child.kill('SIGKILL'); }, 2000);
|
|
53
|
+
}, durationSeconds * 1000);
|
|
54
|
+
child.on('close', () => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
resolve(true);
|
|
57
|
+
});
|
|
58
|
+
child.on('error', () => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export async function transcribeAudio(audioPath, apiKey, baseUrl) {
|
|
65
|
+
const audioBuffer = await fs.readFile(audioPath);
|
|
66
|
+
const blob = new Blob([audioBuffer], { type: 'audio/wav' });
|
|
67
|
+
const apiBase = baseUrl.replace(/\/v1$/, '');
|
|
68
|
+
const formData = new FormData();
|
|
69
|
+
formData.append('file', blob, 'audio.wav');
|
|
70
|
+
if (process.env.SCALIX_STT_MODEL) {
|
|
71
|
+
formData.append('model', process.env.SCALIX_STT_MODEL);
|
|
72
|
+
}
|
|
73
|
+
const transcriptionUrl = `${apiBase}/v1/audio/transcribe`;
|
|
74
|
+
const response = await fetch(transcriptionUrl, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
77
|
+
body: formData,
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const fallbackUrl = `${apiBase}/v1/audio/transcriptions`;
|
|
81
|
+
const fallbackForm = new FormData();
|
|
82
|
+
fallbackForm.append('file', blob, 'audio.wav');
|
|
83
|
+
const fallbackResponse = await fetch(fallbackUrl, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
86
|
+
body: fallbackForm,
|
|
87
|
+
});
|
|
88
|
+
if (!fallbackResponse.ok) {
|
|
89
|
+
throw new Error(`Transcription failed: ${fallbackResponse.status} ${fallbackResponse.statusText}`);
|
|
90
|
+
}
|
|
91
|
+
const data = await fallbackResponse.json();
|
|
92
|
+
return data.text || '';
|
|
93
|
+
}
|
|
94
|
+
const data = await response.json();
|
|
95
|
+
return data.text || '';
|
|
96
|
+
}
|
|
97
|
+
export const voiceInputTool = tool({
|
|
98
|
+
name: 'voice_input',
|
|
99
|
+
description: 'Record audio from the microphone and transcribe it to text. Requires sox/arecord on the system and a speech-to-text API. Returns the transcribed text.',
|
|
100
|
+
inputSchema: z.object({
|
|
101
|
+
duration: z.number().min(1).max(60).optional().describe('Recording duration in seconds (default: 10)'),
|
|
102
|
+
}),
|
|
103
|
+
execute: async ({ duration }) => {
|
|
104
|
+
const seconds = duration ?? 10;
|
|
105
|
+
const recorder = await findRecordCommand();
|
|
106
|
+
if (!recorder) {
|
|
107
|
+
return { error: 'No audio recording tool found. Install sox (`brew install sox` / `apt install sox`) for voice input.' };
|
|
108
|
+
}
|
|
109
|
+
const tmpDir = path.join(process.cwd(), '.scalix');
|
|
110
|
+
await fs.mkdir(tmpDir, { recursive: true });
|
|
111
|
+
const audioPath = path.join(tmpDir, `voice-${Date.now()}.wav`);
|
|
112
|
+
const recorded = await recordAudio(audioPath, seconds);
|
|
113
|
+
if (!recorded) {
|
|
114
|
+
return { error: 'Failed to record audio. Check microphone permissions.' };
|
|
115
|
+
}
|
|
116
|
+
const stat = await fs.stat(audioPath).catch(() => null);
|
|
117
|
+
if (!stat || stat.size < 1000) {
|
|
118
|
+
await fs.unlink(audioPath).catch(() => { });
|
|
119
|
+
return { error: 'Recording too short or empty. Check microphone input.' };
|
|
120
|
+
}
|
|
121
|
+
const apiKey = process.env.SCALIX_API_KEY || '';
|
|
122
|
+
const baseUrl = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
|
|
123
|
+
if (!apiKey) {
|
|
124
|
+
return {
|
|
125
|
+
recorded: true,
|
|
126
|
+
audioPath,
|
|
127
|
+
audioSize: stat.size,
|
|
128
|
+
error: 'No API key available for transcription. Audio saved at: ' + audioPath,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const text = await transcribeAudio(audioPath, apiKey, baseUrl);
|
|
133
|
+
await fs.unlink(audioPath).catch(() => { });
|
|
134
|
+
return { text, duration: seconds };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return {
|
|
138
|
+
recorded: true,
|
|
139
|
+
audioPath,
|
|
140
|
+
error: `Transcription failed: ${err.message}. Audio saved at: ${audioPath}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
export const voiceTools = [
|
|
146
|
+
voiceInputTool,
|
|
147
|
+
];
|