scalix 1.5.1 → 1.5.2
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 +5 -3
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +233 -18
- package/dist/api-client.js +4 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +270 -45
- package/dist/cloud.js +1 -1
- package/dist/config.js +44 -5
- package/dist/constants.js +82 -11
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +22 -5
- package/dist/ipc-server.js +338 -108
- package/dist/keychain.js +34 -9
- package/dist/mcp.js +470 -28
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/sandbox.js +115 -27
- package/dist/sessions.js +12 -0
- package/dist/slash-commands.js +6 -0
- package/dist/sub-agent.js +3 -2
- package/dist/tools-browser.js +87 -16
- package/dist/tools-extra.js +15 -3
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +67 -6
- package/dist/tools.js +15 -14
- package/dist/workspace-trust.js +7 -1
- package/package.json +3 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { tool } from './tool-helper.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { loadMemory, addConvention, addPattern, addNote } from './project-memory.js';
|
|
4
|
+
import { getPersistentCwd } from './tools.js';
|
|
5
|
+
export const saveMemoryTool = tool({
|
|
6
|
+
name: 'save_memory',
|
|
7
|
+
description: 'Save a project convention, architectural pattern, or durable note into the project memory (.scalix/memory.json). Use whenever the user asks you to remember something, corrects a convention, or when establishing project standards.',
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
category: z.enum(['convention', 'pattern', 'note']).describe('Type of memory to store'),
|
|
10
|
+
key: z.string().max(100).optional().describe('Convention key (e.g., "package_manager", "database", "api_base", "styling") when category is "convention"'),
|
|
11
|
+
value: z.string().max(1000).optional().describe('Convention value (e.g., "pnpm", "PostgreSQL", "Tailwind CSS") when category is "convention"'),
|
|
12
|
+
note: z.string().max(2000).optional().describe('Text note/guideline/rule when category is "note"'),
|
|
13
|
+
pattern: z.object({
|
|
14
|
+
name: z.string().max(100).describe('Pattern name (e.g., "Repository Pattern", "Zod Validation")'),
|
|
15
|
+
description: z.string().max(2000).describe('Detailed explanation of how and when to use this pattern'),
|
|
16
|
+
example: z.string().max(2000).optional().describe('Code snippet or usage example'),
|
|
17
|
+
}).optional().describe('Pattern definition when category is "pattern"'),
|
|
18
|
+
}),
|
|
19
|
+
execute: async ({ category, key, value, note, pattern }) => {
|
|
20
|
+
const cwd = getPersistentCwd() || process.cwd();
|
|
21
|
+
if (category === 'convention') {
|
|
22
|
+
if (!key || !value) {
|
|
23
|
+
return { success: false, error: 'Both "key" and "value" are required when saving a convention.' };
|
|
24
|
+
}
|
|
25
|
+
await addConvention(cwd, key, value, 'user');
|
|
26
|
+
return { success: true, message: `Saved convention: ${key} = ${value}` };
|
|
27
|
+
}
|
|
28
|
+
if (category === 'pattern') {
|
|
29
|
+
if (!pattern || !pattern.name || !pattern.description) {
|
|
30
|
+
return { success: false, error: '"pattern.name" and "pattern.description" are required when saving a pattern.' };
|
|
31
|
+
}
|
|
32
|
+
await addPattern(cwd, pattern);
|
|
33
|
+
return { success: true, message: `Saved pattern: ${pattern.name}` };
|
|
34
|
+
}
|
|
35
|
+
if (category === 'note') {
|
|
36
|
+
if (!note || !note.trim()) {
|
|
37
|
+
return { success: false, error: '"note" text is required when saving a note.' };
|
|
38
|
+
}
|
|
39
|
+
await addNote(cwd, note.trim());
|
|
40
|
+
return { success: true, message: `Saved note: "${note.trim()}"` };
|
|
41
|
+
}
|
|
42
|
+
return { success: false, error: `Unsupported category "${category}"` };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
export const getMemoryTool = tool({
|
|
46
|
+
name: 'get_memory',
|
|
47
|
+
description: 'Retrieve stored project memory (.scalix/memory.json), including conventions, architectural patterns, and user notes.',
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
category: z.enum(['all', 'conventions', 'patterns', 'notes']).optional().describe('Filter by memory category (default: "all")'),
|
|
50
|
+
}),
|
|
51
|
+
execute: async ({ category = 'all' }) => {
|
|
52
|
+
const cwd = getPersistentCwd() || process.cwd();
|
|
53
|
+
const mem = await loadMemory(cwd);
|
|
54
|
+
if (category === 'conventions') {
|
|
55
|
+
return { conventions: mem.conventions, lastUpdated: mem.lastUpdated };
|
|
56
|
+
}
|
|
57
|
+
if (category === 'patterns') {
|
|
58
|
+
return { patterns: mem.patterns, lastUpdated: mem.lastUpdated };
|
|
59
|
+
}
|
|
60
|
+
if (category === 'notes') {
|
|
61
|
+
return { notes: mem.notes, lastUpdated: mem.lastUpdated };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
conventions: mem.conventions,
|
|
65
|
+
patterns: mem.patterns,
|
|
66
|
+
notes: mem.notes,
|
|
67
|
+
lastUpdated: mem.lastUpdated,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
export const memoryTools = [
|
|
72
|
+
saveMemoryTool,
|
|
73
|
+
getMemoryTool,
|
|
74
|
+
];
|
package/dist/tools-web.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import dns from 'node:dns';
|
|
6
6
|
import { sandboxPath } from './sandbox.js';
|
|
7
7
|
import { logger } from './logger.js';
|
|
8
|
-
import { USER_AGENT, SEARCH_ENDPOINTS, WEB_DEFAULTS, TOOL_DEFAULTS, HTML_STRIP_TAGS, isPrivateHost, isPrivateIp, } from './constants.js';
|
|
8
|
+
import { USER_AGENT, SEARCH_ENDPOINTS, WEB_DEFAULTS, TOOL_DEFAULTS, HTML_STRIP_TAGS, isPrivateHost, isPrivateIp, isHostExplicitlyAllowed, } from './constants.js';
|
|
9
9
|
import { detectPromptInjection } from './injection-detector.js';
|
|
10
10
|
import { DEFAULT_LIMITS } from './settings.js';
|
|
11
11
|
const MAX_FETCH_REDIRECTS = 5;
|
|
@@ -20,6 +20,10 @@ async function validateFetchTarget(target) {
|
|
|
20
20
|
if (isPrivateHost(hostname)) {
|
|
21
21
|
return 'Blocked: requests to localhost and private networks are not allowed';
|
|
22
22
|
}
|
|
23
|
+
// An operator who allow-listed this exact name did so knowing it points into
|
|
24
|
+
// a private range; re-checking the resolved address would only undo that.
|
|
25
|
+
if (isHostExplicitlyAllowed(hostname))
|
|
26
|
+
return null;
|
|
23
27
|
try {
|
|
24
28
|
const addresses = await dns.promises.lookup(hostname, { all: true });
|
|
25
29
|
if (addresses.some((a) => isPrivateIp(a.address))) {
|
|
@@ -325,30 +329,86 @@ export const treeTool = tool({
|
|
|
325
329
|
return { tree: lines.join('\n') };
|
|
326
330
|
},
|
|
327
331
|
});
|
|
332
|
+
// The tool advertises itself as the one to reach for on large files, so it is
|
|
333
|
+
// the last place that should read the whole file into a string first. Above
|
|
334
|
+
// `readFileMaxSize` it streams and stops at the requested end line; below it,
|
|
335
|
+
// the whole-file read stays (it is cheaper and gives an exact totalLines).
|
|
336
|
+
// Either way the span itself is capped — `endLine: 10_000_000` used to return
|
|
337
|
+
// every line in the file and blow the context window open.
|
|
338
|
+
const MAX_LINE_SPAN = 2000;
|
|
339
|
+
const MAX_LINE_CHARS = 4000;
|
|
340
|
+
async function streamLineRange(safePath, start, end) {
|
|
341
|
+
const { createReadStream } = await import('node:fs');
|
|
342
|
+
const readline = await import('node:readline');
|
|
343
|
+
const rl = readline.createInterface({
|
|
344
|
+
input: createReadStream(safePath, { encoding: 'utf-8' }),
|
|
345
|
+
crlfDelay: Infinity,
|
|
346
|
+
});
|
|
347
|
+
const lines = [];
|
|
348
|
+
let n = 0;
|
|
349
|
+
let reachedEof = true;
|
|
350
|
+
try {
|
|
351
|
+
for await (const line of rl) {
|
|
352
|
+
n++;
|
|
353
|
+
if (n < start)
|
|
354
|
+
continue;
|
|
355
|
+
lines.push({ lineNumber: n, text: line.slice(0, MAX_LINE_CHARS) });
|
|
356
|
+
if (n >= end) {
|
|
357
|
+
reachedEof = false;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
rl.close();
|
|
364
|
+
}
|
|
365
|
+
return { lines, reachedEof, scanned: n };
|
|
366
|
+
}
|
|
328
367
|
export const readFileLinesTool = tool({
|
|
329
368
|
name: 'read_file_lines',
|
|
330
369
|
description: 'Read specific line range from a file. Useful for large files. Returns lines with line numbers.',
|
|
331
370
|
inputSchema: z.object({
|
|
332
371
|
filePath: z.string().max(500).describe('Relative path to the file'),
|
|
333
372
|
startLine: z.number().min(1).describe('First line to read (1-based)'),
|
|
334
|
-
endLine: z.number().min(1).optional().describe(
|
|
373
|
+
endLine: z.number().min(1).optional().describe(`Last line to read (default: startLine + 100, at most ${MAX_LINE_SPAN} lines per call)`),
|
|
335
374
|
}),
|
|
336
375
|
execute: async ({ filePath, startLine, endLine }) => {
|
|
337
376
|
try {
|
|
338
377
|
const safePath = sandboxPath(filePath);
|
|
339
|
-
const content = await fs.readFile(safePath, 'utf-8');
|
|
340
|
-
const allLines = content.split('\n');
|
|
341
378
|
const start = Math.max(1, startLine);
|
|
342
379
|
if (endLine !== undefined && endLine < start) {
|
|
343
380
|
return { error: `endLine (${endLine}) must be >= startLine (${start})` };
|
|
344
381
|
}
|
|
382
|
+
const requestedEnd = endLine ?? (start + 100);
|
|
383
|
+
const cappedEnd = Math.min(requestedEnd, start + MAX_LINE_SPAN - 1);
|
|
384
|
+
const spanCapped = cappedEnd < requestedEnd;
|
|
385
|
+
const stat = await fs.stat(safePath);
|
|
386
|
+
if (stat.isDirectory())
|
|
387
|
+
return { error: `${filePath} is a directory` };
|
|
388
|
+
if (stat.size > _limits.readFileMaxSize) {
|
|
389
|
+
const { lines, reachedEof, scanned } = await streamLineRange(safePath, start, cappedEnd);
|
|
390
|
+
if (lines.length === 0) {
|
|
391
|
+
return { error: `startLine (${start}) exceeds file length (${scanned} lines)`, totalLines: scanned };
|
|
392
|
+
}
|
|
393
|
+
const last = lines[lines.length - 1].lineNumber;
|
|
394
|
+
return {
|
|
395
|
+
filePath,
|
|
396
|
+
startLine: start,
|
|
397
|
+
endLine: last,
|
|
398
|
+
...(reachedEof ? { totalLines: scanned } : { hint: `Showing lines ${start}-${last}; file exceeds ${_limits.readFileMaxSize} bytes so the total line count was not counted` }),
|
|
399
|
+
...(spanCapped ? { truncated: `Capped at ${MAX_LINE_SPAN} lines per call; continue from line ${last + 1}` } : {}),
|
|
400
|
+
lines,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const content = await fs.readFile(safePath, 'utf-8');
|
|
404
|
+
const allLines = content.split('\n');
|
|
345
405
|
if (start > allLines.length) {
|
|
346
406
|
return { error: `startLine (${start}) exceeds file length (${allLines.length} lines)`, totalLines: allLines.length };
|
|
347
407
|
}
|
|
348
|
-
const end = Math.min(
|
|
408
|
+
const end = Math.min(cappedEnd, allLines.length);
|
|
349
409
|
const selectedLines = allLines.slice(start - 1, end).map((line, i) => ({
|
|
350
410
|
lineNumber: start + i,
|
|
351
|
-
text: line,
|
|
411
|
+
text: line.slice(0, MAX_LINE_CHARS),
|
|
352
412
|
}));
|
|
353
413
|
return {
|
|
354
414
|
filePath,
|
|
@@ -356,6 +416,7 @@ export const readFileLinesTool = tool({
|
|
|
356
416
|
startLine: start,
|
|
357
417
|
endLine: end,
|
|
358
418
|
...(end < allLines.length ? { hint: `Showing lines ${start}-${end} of ${allLines.length}` } : {}),
|
|
419
|
+
...(spanCapped ? { truncated: `Capped at ${MAX_LINE_SPAN} lines per call; continue from line ${end + 1}` } : {}),
|
|
359
420
|
lines: selectedLines,
|
|
360
421
|
};
|
|
361
422
|
}
|
package/dist/tools.js
CHANGED
|
@@ -8,8 +8,9 @@ import path from 'node:path';
|
|
|
8
8
|
import { safeEvaluate } from './safe-math.js';
|
|
9
9
|
import { sandboxPath, isBlockedCommand, detectInjection, sanitizeEnvForCommand, getShellInfo, isWithinRoot, getSandboxRoot } from './sandbox.js';
|
|
10
10
|
import { DEFAULT_LIMITS } from './settings.js';
|
|
11
|
-
import { TOOL_DEFAULTS, BINARY_DETECT_BUFFER_SIZE
|
|
11
|
+
import { TOOL_DEFAULTS, BINARY_DETECT_BUFFER_SIZE } from './constants.js';
|
|
12
12
|
import { computeDiff, formatDiff } from './diff.js';
|
|
13
|
+
import { normalizeMcpConfig, validateMcpUrl } from './mcp.js';
|
|
13
14
|
let _limits = DEFAULT_LIMITS;
|
|
14
15
|
export function setToolLimits(limits) { _limits = limits; }
|
|
15
16
|
const execAsync = promisify(exec);
|
|
@@ -359,24 +360,21 @@ export const addMcpServerTool = tool({
|
|
|
359
360
|
}),
|
|
360
361
|
execute: async ({ label, url }) => {
|
|
361
362
|
try {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return { success: false, error: `Invalid MCP server URL:
|
|
368
|
-
}
|
|
369
|
-
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
370
|
-
return { success: false, error: `Invalid MCP server URL: only http/https schemes are allowed (got ${parsedUrl.protocol})` };
|
|
371
|
-
}
|
|
372
|
-
if (isPrivateHost(parsedUrl.hostname)) {
|
|
373
|
-
return { success: false, error: 'Blocked: MCP server URL points at localhost or a private network address' };
|
|
363
|
+
// Shared with the config-file and /mcp paths so all three refuse the
|
|
364
|
+
// same URLs — this check used to live only here, which is how the
|
|
365
|
+
// config file ended up being the way around it.
|
|
366
|
+
const blocked = validateMcpUrl(url);
|
|
367
|
+
if (blocked) {
|
|
368
|
+
return { success: false, error: `Invalid MCP server URL: ${blocked}` };
|
|
374
369
|
}
|
|
375
370
|
const mcpConfigPath = path.resolve(process.cwd(), 'mcp-services.json');
|
|
376
371
|
let config = { mcpServers: [] };
|
|
377
372
|
try {
|
|
378
373
|
if (fsSync.existsSync(mcpConfigPath)) {
|
|
379
|
-
|
|
374
|
+
// Through normalizeMcpConfig, because the IDE panel writes the
|
|
375
|
+
// object-map dialect. Blindly resetting a non-array mcpServers to []
|
|
376
|
+
// here would silently delete every server the user configured there.
|
|
377
|
+
const parsed = normalizeMcpConfig(JSON.parse(fsSync.readFileSync(mcpConfigPath, 'utf-8')));
|
|
380
378
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
381
379
|
config = parsed;
|
|
382
380
|
if (!Array.isArray(config.mcpServers))
|
|
@@ -385,6 +383,9 @@ export const addMcpServerTool = tool({
|
|
|
385
383
|
}
|
|
386
384
|
}
|
|
387
385
|
catch { }
|
|
386
|
+
if (config.mcpServers.some((s) => s?.serverLabel === label)) {
|
|
387
|
+
return { success: false, error: `An MCP server labelled "${label}" is already configured` };
|
|
388
|
+
}
|
|
388
389
|
config.mcpServers.push({ serverLabel: label, serverUrl: url });
|
|
389
390
|
await fs.writeFile(mcpConfigPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
390
391
|
return { success: true, message: `Added MCP server ${label} at ${url}` };
|
package/dist/workspace-trust.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Scalix Coder loads and executes code/config from the current project
|
|
5
5
|
* directory — plugin handler modules (dynamic import), hooks (shell commands),
|
|
6
|
+
* MCP servers (a spawned process, or a URL we hand an environment secret to),
|
|
6
7
|
* scoped rules (injected into the model context) and local settings overrides
|
|
7
8
|
* (which can change permission mode / security). Opening the tool in an
|
|
8
9
|
* untrusted / freshly-cloned repository must NOT silently run any of that.
|
|
@@ -43,7 +44,12 @@ function normalize(cwd) {
|
|
|
43
44
|
export function hasProjectExecSurface(cwd = process.cwd()) {
|
|
44
45
|
const candidates = ['mcp-services.json'];
|
|
45
46
|
for (const dir of PROJECT_LOOKUP_DIRS) {
|
|
46
|
-
candidates.push(path.join(dir, 'plugins'), path.join(dir, 'hooks.json'),
|
|
47
|
+
candidates.push(path.join(dir, 'plugins'), path.join(dir, 'hooks.json'),
|
|
48
|
+
// resolveMcpConfigPath() falls back to .scalix/mcp-services.json, so a
|
|
49
|
+
// repo can carry MCP config without a root-level file. Listing only the
|
|
50
|
+
// root filename meant an MCP-only clone looked inert and the user was
|
|
51
|
+
// never offered the trust prompt for the one thing it did have.
|
|
52
|
+
path.join(dir, 'mcp-services.json'), path.join(dir, 'rules'), path.join(dir, 'settings.local.json'));
|
|
47
53
|
}
|
|
48
54
|
return candidates.some(rel => {
|
|
49
55
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scalix",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "AI coding agent for the terminal — edit code, run commands, and build with Scalix",
|
|
5
5
|
"main": "dist/headless.js",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"start:headless": "tsx src/headless.ts",
|
|
18
18
|
"dev": "tsx watch src/cli.tsx",
|
|
19
19
|
"build": "tsc",
|
|
20
|
+
"bundle:ide": "node scripts/bundle-ide.mjs",
|
|
20
21
|
"typecheck": "tsc --noEmit",
|
|
21
22
|
"lint": "eslint src/ tests/",
|
|
22
23
|
"test": "vitest run",
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
},
|
|
52
53
|
"type": "module",
|
|
53
54
|
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
54
56
|
"chalk": "^5.6.2",
|
|
55
57
|
"cli-highlight": "^2.1.11",
|
|
56
58
|
"eventemitter3": "^5.0.4",
|