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.
@@ -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('Last line to read (default: startLine + 100)'),
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(endLine ?? (startLine + 100), allLines.length);
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, isPrivateHost } from './constants.js';
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
- let parsedUrl;
363
- try {
364
- parsedUrl = new URL(url);
365
- }
366
- catch {
367
- return { success: false, error: `Invalid MCP server URL: "${url}" is not a valid 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
- const parsed = JSON.parse(fsSync.readFileSync(mcpConfigPath, 'utf-8'));
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}` };
@@ -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'), path.join(dir, 'rules'), path.join(dir, 'settings.local.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.1",
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",