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
package/.env.example
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Required — your Scalix API key
|
|
2
|
-
SCALIX_API_KEY=
|
|
2
|
+
SCALIX_API_KEY=scalix_sk_...
|
|
3
3
|
|
|
4
|
-
# Optional —
|
|
5
|
-
#
|
|
4
|
+
# Optional — API base URL. This is the host root, NOT the /v1 endpoint: the
|
|
5
|
+
# client appends /v1/ai/... itself. Point it at a self-hosted gateway if you
|
|
6
|
+
# have one; the default is Scalix cloud.
|
|
7
|
+
# SCALIX_BASE_URL=https://api.scalix.world
|
|
6
8
|
|
|
7
9
|
# Optional — override the default model
|
|
8
10
|
# SCALIX_MODEL=
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered coding agent for the terminal and [VS Code](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — built by [Scalix World](https://scalix.world).
|
|
4
4
|
|
|
5
|
-
](https://www.npmjs.com/package/scalix)
|
|
6
6
|

|
|
7
7
|

|
|
8
8
|
|
|
@@ -61,12 +61,28 @@ Notes:
|
|
|
61
61
|
|
|
62
62
|
### Install
|
|
63
63
|
|
|
64
|
+
```bash
|
|
65
|
+
npm install -g scalix
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Requires Node 18 or newer. If `npm install -g` needs `sudo` on your machine, that
|
|
69
|
+
is a sign npm's global prefix points somewhere root-owned — point it at your home
|
|
70
|
+
directory (`npm config set prefix ~/.npm-global`) rather than installing as root.
|
|
71
|
+
|
|
72
|
+
<details>
|
|
73
|
+
<summary>From source (contributors)</summary>
|
|
74
|
+
|
|
75
|
+
The repository is private; you need access before this will clone.
|
|
76
|
+
|
|
64
77
|
```bash
|
|
65
78
|
git clone https://github.com/scalixworld/scalix-coder.git && cd scalix-coder
|
|
66
79
|
npm install
|
|
67
|
-
|
|
80
|
+
npm run build
|
|
81
|
+
npm link
|
|
68
82
|
```
|
|
69
83
|
|
|
84
|
+
</details>
|
|
85
|
+
|
|
70
86
|
### Run
|
|
71
87
|
|
|
72
88
|
```bash
|
|
@@ -239,6 +255,35 @@ Config is stored in `~/.scalix-coder/config.json`.
|
|
|
239
255
|
| `.scalix/rules/*.md` | Scoped rules |
|
|
240
256
|
| `mcp-services.json` | MCP server config |
|
|
241
257
|
|
|
258
|
+
### MCP servers
|
|
259
|
+
|
|
260
|
+
Servers listed in `mcp-services.json` (project root, or `.scalix/` as a
|
|
261
|
+
fallback) are connected at startup and their tools are offered to the model as
|
|
262
|
+
`mcp__<server>__<tool>`. Both transports are supported:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"mcpServers": [
|
|
267
|
+
{ "serverLabel": "github", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
|
|
268
|
+
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_…" } },
|
|
269
|
+
{ "serverLabel": "docs", "serverUrl": "https://docs.example.com/mcp", "authorizationEnv": "DOCS_TOKEN" }
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The Claude Desktop / VS Code object-map dialect (`"mcpServers": {"github": {…}}`)
|
|
275
|
+
is accepted too, so an existing config can be dropped in unchanged.
|
|
276
|
+
|
|
277
|
+
- `disabled: true` skips an entry without deleting it; `allowedTools.toolNames`
|
|
278
|
+
registers only the named tools from that server.
|
|
279
|
+
- A server that fails to start is logged and skipped — it never takes the run
|
|
280
|
+
down with it. `/mcp` lists the status of each.
|
|
281
|
+
- **Command (stdio) servers only start in a trusted workspace**, because
|
|
282
|
+
spawning one runs arbitrary local code. They get a clean environment, not the
|
|
283
|
+
agent's, so they cannot read its API keys.
|
|
284
|
+
- MCP tools are external, so they require confirmation under the default
|
|
285
|
+
permission mode.
|
|
286
|
+
|
|
242
287
|
## VS Code Extension
|
|
243
288
|
|
|
244
289
|
Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — or from [Open VSX](https://open-vsx.org/extension/scalix/scalix) if you use Cursor, Windsurf, or VSCodium:
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const AGENT_PRESETS = [
|
|
2
|
+
{
|
|
3
|
+
id: 'cloud-architect',
|
|
4
|
+
name: 'Cloud Architect',
|
|
5
|
+
description: 'Full Scalix Cloud tools access with architecture-first planning',
|
|
6
|
+
systemPromptAdditions: 'You are an expert Scalix Cloud Architect. Prioritize robust, scalable infrastructure. Plan changes carefully.',
|
|
7
|
+
permissionMode: 'default'
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: 'dba',
|
|
11
|
+
name: 'Database Administrator',
|
|
12
|
+
description: 'Focused on database migrations and SQL optimization',
|
|
13
|
+
systemPromptAdditions: 'You are a Senior DBA. Prioritize data integrity and query performance. Always verify schemas before altering.',
|
|
14
|
+
permissionMode: 'default'
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'devops',
|
|
18
|
+
name: 'DevOps Engineer',
|
|
19
|
+
description: 'CI/CD, deployments, and infrastructure management',
|
|
20
|
+
systemPromptAdditions: 'You are a DevOps engineer focused on CI/CD pipelines, containerization, and safe deployments.',
|
|
21
|
+
permissionMode: 'default'
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'full-access',
|
|
25
|
+
name: 'Full Access (Auto)',
|
|
26
|
+
description: 'All tools enabled, autonomous execution mode',
|
|
27
|
+
systemPromptAdditions: 'You are fully autonomous. Execute the required steps directly.',
|
|
28
|
+
permissionMode: 'auto'
|
|
29
|
+
}
|
|
30
|
+
];
|
|
31
|
+
export function getPreset(id) {
|
|
32
|
+
return AGENT_PRESETS.find(p => p.id === id);
|
|
33
|
+
}
|
package/dist/agent.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { ApiError } from './api-client.js';
|
|
2
2
|
import { EventEmitter } from 'eventemitter3';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { getToolPermissionWithArgs } from './sandbox.js';
|
|
4
|
+
import { getToolPermissionWithArgs, getMaxImageSize } from './sandbox.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
|
-
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, CONTEXT_DEFAULTS, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
|
|
6
|
+
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, CONTEXT_DEFAULTS, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_DEPLOY_PLAYBOOK_SECTION, SCALIX_PLATFORM_PLAYBOOK_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
|
|
7
7
|
import { estimateMessageTokens as estimateTokenCount, estimateTokens } from './token-counter.js';
|
|
8
8
|
import { ContextManager } from './context-manager.js';
|
|
9
9
|
import { zodToJsonSchema } from './tool-helper.js';
|
|
10
10
|
import { ToolSelector } from './tool-selector.js';
|
|
11
11
|
import { AgentReplay } from './agent-replay.js';
|
|
12
12
|
import { loadHooks, runHooks } from './hooks.js';
|
|
13
|
+
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
13
14
|
import { setAgentToolConfig } from './tools-advanced.js';
|
|
14
15
|
import { AgentRegistry, generateAgentId } from './agent-registry.js';
|
|
16
|
+
import { getPreset } from './agent-presets.js';
|
|
15
17
|
import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
|
|
16
18
|
import { ChangeTracker } from './change-tracker.js';
|
|
17
19
|
import { detectPromptInjection, formatInjectionWarning } from './injection-detector.js';
|
|
@@ -21,6 +23,26 @@ import fs from 'node:fs/promises';
|
|
|
21
23
|
import path from 'node:path';
|
|
22
24
|
import { fetchCloudTools, callCloudTool, fetchMe, filterCatalogByScopes, buildCloudToolsPromptSection, cloudKeyRemedy } from './cloud.js';
|
|
23
25
|
import { createChatClient, capabilitiesFor } from './providers/index.js';
|
|
26
|
+
import { McpHub } from './mcp.js';
|
|
27
|
+
import { BoardTurnRefresher } from './board-context.js';
|
|
28
|
+
const SUPPORTED_PASTED_IMAGE_MIMES = new Set([
|
|
29
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml',
|
|
30
|
+
]);
|
|
31
|
+
export function validatePastedImages(images) {
|
|
32
|
+
const maxSize = getMaxImageSize();
|
|
33
|
+
for (const image of images) {
|
|
34
|
+
const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/i.exec(image);
|
|
35
|
+
if (!match || !SUPPORTED_PASTED_IMAGE_MIMES.has(match[1].toLowerCase())) {
|
|
36
|
+
throw new Error('Unsupported pasted image. Use PNG, JPEG, GIF, WebP, BMP, or SVG.');
|
|
37
|
+
}
|
|
38
|
+
const payload = match[2].replace(/\s/g, '');
|
|
39
|
+
const bytes = Math.floor(payload.length * 3 / 4)
|
|
40
|
+
- (payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0);
|
|
41
|
+
if (bytes > maxSize) {
|
|
42
|
+
throw new Error(`Pasted image is too large (${Math.ceil(bytes / 1024 / 1024)}MB). Maximum is ${Math.floor(maxSize / 1024 / 1024)}MB.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
24
46
|
/**
|
|
25
47
|
* Marks an error that Agent has already announced through the 'error' event.
|
|
26
48
|
* `send()` emits and then rethrows, so a caller that also prints from its catch
|
|
@@ -30,11 +52,10 @@ import { createChatClient, capabilitiesFor } from './providers/index.js';
|
|
|
30
52
|
export const REPORTED_ERROR = Symbol.for('scalix.coder.reportedError');
|
|
31
53
|
function transformToolsForApi(tools) {
|
|
32
54
|
return tools
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
.filter((t) => !('type' in t && t.type === 'mcp'))
|
|
55
|
+
// Everything here is a function tool. Remotely-backed ones — Scalix Cloud
|
|
56
|
+
// platform tools and MCP tool proxies — carry a ready-made JSON Schema in
|
|
57
|
+
// `parameters` that passes through as-is; local tools carry a Zod
|
|
58
|
+
// `inputSchema` that gets converted below.
|
|
38
59
|
.map((t) => {
|
|
39
60
|
const local = t;
|
|
40
61
|
const fn = local.function ?? local;
|
|
@@ -151,7 +172,10 @@ export class Agent extends EventEmitter {
|
|
|
151
172
|
_aborted = false;
|
|
152
173
|
_thinkingActive = false;
|
|
153
174
|
replay;
|
|
175
|
+
boardRefresher;
|
|
176
|
+
boardPrimed;
|
|
154
177
|
hooks = [];
|
|
178
|
+
hooksSkipped = 0;
|
|
155
179
|
instructionLoader;
|
|
156
180
|
_abortController = null;
|
|
157
181
|
modelFailures = new Map();
|
|
@@ -175,6 +199,7 @@ export class Agent extends EventEmitter {
|
|
|
175
199
|
outputFormatInstructionIndex = null;
|
|
176
200
|
cloudProjectId;
|
|
177
201
|
cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
202
|
+
mcpHub = null;
|
|
178
203
|
providerId;
|
|
179
204
|
capabilities;
|
|
180
205
|
cloudApiKey;
|
|
@@ -233,8 +258,28 @@ export class Agent extends EventEmitter {
|
|
|
233
258
|
this.toolSelector = new ToolSelector();
|
|
234
259
|
this.toolSelector.index(this.config.tools);
|
|
235
260
|
}
|
|
261
|
+
// Crew board freshness (board-context.ts): baseline taken here, right
|
|
262
|
+
// after the system prompt embedded the board, so the first turnNote()
|
|
263
|
+
// only fires on real change. Fire-and-forget; send() awaits it.
|
|
264
|
+
this.boardRefresher = new BoardTurnRefresher(this.config.workspaceRoot ?? process.cwd());
|
|
265
|
+
this.boardPrimed = this.boardRefresher.prime().catch(() => { });
|
|
236
266
|
this.replay = new AgentReplay(randomUUID().slice(0, 8), this.config.models[0]);
|
|
237
|
-
|
|
267
|
+
// Hooks are shell commands read out of the project's own .scalix/hooks.json
|
|
268
|
+
// — exec surface, exactly like plugins and scoped rules, and named as such
|
|
269
|
+
// in workspace-trust.ts's own docstring. The gate existed; this call site
|
|
270
|
+
// just never asked. Parsing the file while untrusted is still fine and is
|
|
271
|
+
// what lets us say "3 hooks were skipped" instead of going quietly inert.
|
|
272
|
+
const projectHooks = loadHooks();
|
|
273
|
+
if (isWorkspaceTrusted()) {
|
|
274
|
+
this.hooks = projectHooks;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
this.hooks = [];
|
|
278
|
+
this.hooksSkipped = projectHooks.length;
|
|
279
|
+
if (projectHooks.length > 0) {
|
|
280
|
+
this.log.warn('Project hooks skipped — untrusted workspace', { count: projectHooks.length });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
238
283
|
this.instructionLoader = config.instructionLoader;
|
|
239
284
|
if (config.autoVerify === true) {
|
|
240
285
|
this.verifyConfig = { ...VERIFY_DEFAULTS };
|
|
@@ -354,6 +399,16 @@ export class Agent extends EventEmitter {
|
|
|
354
399
|
message.includes('stream error') ||
|
|
355
400
|
message.includes('try again'));
|
|
356
401
|
}
|
|
402
|
+
setPreset(presetId) {
|
|
403
|
+
const preset = getPreset(presetId);
|
|
404
|
+
if (!preset)
|
|
405
|
+
throw new Error(`Unknown preset: ${presetId}`);
|
|
406
|
+
this.config.permissionMode = preset.permissionMode;
|
|
407
|
+
// We append the prompt to the base instructions
|
|
408
|
+
if (preset.systemPromptAdditions && !this.config.instructions.includes(preset.systemPromptAdditions)) {
|
|
409
|
+
this.config.instructions += '\n\n' + preset.systemPromptAdditions;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
357
412
|
isModelCircuitOpen(model) {
|
|
358
413
|
const state = this.modelFailures.get(model);
|
|
359
414
|
if (!state)
|
|
@@ -580,6 +635,64 @@ export class Agent extends EventEmitter {
|
|
|
580
635
|
if (this.toolSelector)
|
|
581
636
|
this.toolSelector.index(this.config.tools);
|
|
582
637
|
}
|
|
638
|
+
// ─── MCP servers ───
|
|
639
|
+
isMcpTool(t) {
|
|
640
|
+
return 'source' in t && t.source === 'mcp';
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Take ownership of a connected hub and register its tools. The entrypoint
|
|
644
|
+
* connects (so a slow server delays startup, not the first message) and hands
|
|
645
|
+
* the hub over; the agent needs it so `add_mcp_server` can connect at runtime
|
|
646
|
+
* and so `close()` can shut the child processes down.
|
|
647
|
+
*/
|
|
648
|
+
attachMcpHub(hub) {
|
|
649
|
+
this.mcpHub = hub;
|
|
650
|
+
this.syncMcpTools();
|
|
651
|
+
}
|
|
652
|
+
/** Replace the registered MCP proxies with whatever the hub currently holds. */
|
|
653
|
+
syncMcpTools() {
|
|
654
|
+
this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
|
|
655
|
+
if (this.mcpHub) {
|
|
656
|
+
const taken = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
|
|
657
|
+
for (const t of this.mcpHub.tools()) {
|
|
658
|
+
// A namespaced mcp__ name colliding with a local tool means two servers
|
|
659
|
+
// share a label; keep the first and say so rather than shadowing.
|
|
660
|
+
if (taken.has(t.name)) {
|
|
661
|
+
this.log.warn('MCP tool name already taken — skipping', { tool: t.name, server: t.serverLabel });
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
taken.add(t.name);
|
|
665
|
+
this.config.tools.push(t);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (this.toolSelector)
|
|
669
|
+
this.toolSelector.index(this.config.tools);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Connect one server and register its tools immediately. Creates the hub if
|
|
673
|
+
* the entrypoint never attached one, so a runtime `add_mcp_server` works even
|
|
674
|
+
* when no config file existed at startup.
|
|
675
|
+
*/
|
|
676
|
+
async connectMcpServer(config) {
|
|
677
|
+
if (!this.mcpHub)
|
|
678
|
+
this.mcpHub = new McpHub();
|
|
679
|
+
const [status] = await this.mcpHub.connect([config]);
|
|
680
|
+
this.syncMcpTools();
|
|
681
|
+
return status;
|
|
682
|
+
}
|
|
683
|
+
getMcpStatus() {
|
|
684
|
+
return this.mcpHub?.status() ?? [];
|
|
685
|
+
}
|
|
686
|
+
getMcpTools() {
|
|
687
|
+
return this.config.tools.filter((t) => this.isMcpTool(t));
|
|
688
|
+
}
|
|
689
|
+
/** Disconnect every MCP server. Call on shutdown — stdio servers are children. */
|
|
690
|
+
async closeMcp() {
|
|
691
|
+
const hub = this.mcpHub;
|
|
692
|
+
this.mcpHub = null;
|
|
693
|
+
this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
|
|
694
|
+
await hub?.close();
|
|
695
|
+
}
|
|
583
696
|
// ─── Scalix Cloud platform tools ───
|
|
584
697
|
isCloudTool(t) {
|
|
585
698
|
return 'source' in t && t.source === 'scalix-cloud';
|
|
@@ -731,6 +844,20 @@ export class Agent extends EventEmitter {
|
|
|
731
844
|
getReplay() { return this.replay; }
|
|
732
845
|
getChangeTracker() { return this.changeTracker; }
|
|
733
846
|
getHooks() { return this.hooks; }
|
|
847
|
+
/** How many project hooks the trust gate withheld. 0 when trusted. */
|
|
848
|
+
getSkippedHookCount() { return this.hooksSkipped; }
|
|
849
|
+
/**
|
|
850
|
+
* Re-read project hooks after the user trusts the workspace, so /trust picks
|
|
851
|
+
* them up in the same session rather than only after a restart. Returns the
|
|
852
|
+
* number now active.
|
|
853
|
+
*/
|
|
854
|
+
reloadHooks() {
|
|
855
|
+
if (!isWorkspaceTrusted())
|
|
856
|
+
return 0;
|
|
857
|
+
this.hooks = loadHooks();
|
|
858
|
+
this.hooksSkipped = 0;
|
|
859
|
+
return this.hooks.length;
|
|
860
|
+
}
|
|
734
861
|
getApiKey() { return this.config.apiKey; }
|
|
735
862
|
getBaseUrl() { return this.client.baseURL; }
|
|
736
863
|
getAgentId() { return this.agentId; }
|
|
@@ -903,6 +1030,8 @@ export class Agent extends EventEmitter {
|
|
|
903
1030
|
async send(content, images) {
|
|
904
1031
|
this._aborted = false;
|
|
905
1032
|
this.groundingNudgeCount = 0;
|
|
1033
|
+
if (images?.length)
|
|
1034
|
+
validatePastedImages(images);
|
|
906
1035
|
const hasFileRefs = /@(?:[\w./-]+\/)?[\w.-]+\.\w+/.test(content);
|
|
907
1036
|
const expandedContent = hasFileRefs ? await this.expandFileReferences(content) : content;
|
|
908
1037
|
let finalContent = expandedContent;
|
|
@@ -925,6 +1054,24 @@ export class Agent extends EventEmitter {
|
|
|
925
1054
|
this.emit('thinking:start');
|
|
926
1055
|
this.truncateMessages();
|
|
927
1056
|
this.replay.record('user_message', { content });
|
|
1057
|
+
// Crew board turn-refresh: the system prompt is assembled once per
|
|
1058
|
+
// session, so a board that moved since then would go stale mid-thread.
|
|
1059
|
+
// Runs AFTER the push — send()'s synchronous contract (user message in
|
|
1060
|
+
// history and message:user emitted before the first await) is what the
|
|
1061
|
+
// IDE paints on — with the note spliced in BEFORE the user message, so
|
|
1062
|
+
// the member still reads the news before the ask. One file read per
|
|
1063
|
+
// turn; a workspace with no BOARD.md never produces a note.
|
|
1064
|
+
try {
|
|
1065
|
+
await this.boardPrimed;
|
|
1066
|
+
const boardNote = await this.boardRefresher.turnNote();
|
|
1067
|
+
if (boardNote) {
|
|
1068
|
+
const boardMessage = { role: 'system', content: boardNote };
|
|
1069
|
+
const idx = this.messages.lastIndexOf(userMessage);
|
|
1070
|
+
this.messages.splice(idx < 0 ? this.messages.length : idx, 0, boardMessage);
|
|
1071
|
+
this.replay.record('board_refresh', { chars: boardNote.length });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
catch { }
|
|
928
1075
|
const requestId = randomUUID().slice(0, 8);
|
|
929
1076
|
const reqLog = logger.child(requestId);
|
|
930
1077
|
reqLog.info('User message received', { length: content.length });
|
|
@@ -949,7 +1096,7 @@ export class Agent extends EventEmitter {
|
|
|
949
1096
|
// platform key is a dead end that doesn't touch the credential that failed.
|
|
950
1097
|
if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
|
|
951
1098
|
error.message += this.capabilities.platformTools
|
|
952
|
-
? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys and update it via /setup.'
|
|
1099
|
+
? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys?utm_source=scalix-coder&utm_medium=cli and update it via /setup.'
|
|
953
1100
|
: ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
|
|
954
1101
|
}
|
|
955
1102
|
error.metadata = {
|
|
@@ -974,6 +1121,13 @@ export class Agent extends EventEmitter {
|
|
|
974
1121
|
let emptyTurnCount = 0;
|
|
975
1122
|
const MAX_EMPTY_TURNS = 5;
|
|
976
1123
|
let disableTools = false;
|
|
1124
|
+
let _stepIndex = 0;
|
|
1125
|
+
let _turnToolDurationMs = 0;
|
|
1126
|
+
let _turnLlmStartMs = 0;
|
|
1127
|
+
let _turnTtftMs = 0;
|
|
1128
|
+
let _firstDeltaSeen = false;
|
|
1129
|
+
let _turnInputTokens = 0;
|
|
1130
|
+
let _turnOutputTokens = 0;
|
|
977
1131
|
let forceNonStreaming = false;
|
|
978
1132
|
while (turnCount < this.config.maxSteps) {
|
|
979
1133
|
if (this._aborted) {
|
|
@@ -1039,6 +1193,9 @@ export class Agent extends EventEmitter {
|
|
|
1039
1193
|
if (!SCALIX_STEERING_ALWAYS_ON) {
|
|
1040
1194
|
effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
|
|
1041
1195
|
}
|
|
1196
|
+
// Deploy-path and platform facts ride with the connection too —
|
|
1197
|
+
// outside the ALWAYS_ON guard so flipping that flag never drops them.
|
|
1198
|
+
effectiveInstructions += '\n\n' + SCALIX_DEPLOY_PLAYBOOK_SECTION + '\n\n' + SCALIX_PLATFORM_PLAYBOOK_SECTION;
|
|
1042
1199
|
const liveCloudTools = this.getCloudTools();
|
|
1043
1200
|
if (liveCloudTools.length > 0) {
|
|
1044
1201
|
effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
|
|
@@ -1059,6 +1216,7 @@ export class Agent extends EventEmitter {
|
|
|
1059
1216
|
? true
|
|
1060
1217
|
: (!apiTools || apiTools.length === 0) && !hasToolMessages);
|
|
1061
1218
|
if (useStreaming) {
|
|
1219
|
+
_turnLlmStartMs = Date.now();
|
|
1062
1220
|
const stream = await this.client.chatComplete({
|
|
1063
1221
|
model: currentModel,
|
|
1064
1222
|
messages: apiMessages,
|
|
@@ -1078,6 +1236,10 @@ export class Agent extends EventEmitter {
|
|
|
1078
1236
|
if (choice?.delta) {
|
|
1079
1237
|
const delta = choice.delta;
|
|
1080
1238
|
if (delta.content) {
|
|
1239
|
+
if (!_firstDeltaSeen) {
|
|
1240
|
+
_turnTtftMs = Date.now() - _turnLlmStartMs;
|
|
1241
|
+
_firstDeltaSeen = true;
|
|
1242
|
+
}
|
|
1081
1243
|
fullText += delta.content;
|
|
1082
1244
|
this.emit('stream:delta', delta.content, fullText);
|
|
1083
1245
|
}
|
|
@@ -1101,6 +1263,8 @@ export class Agent extends EventEmitter {
|
|
|
1101
1263
|
const inTok = chunk.usage.prompt_tokens ?? 0;
|
|
1102
1264
|
const outTok = chunk.usage.completion_tokens ?? 0;
|
|
1103
1265
|
const totalTok = chunk.usage.total_tokens ?? (inTok + outTok);
|
|
1266
|
+
_turnInputTokens = inTok;
|
|
1267
|
+
_turnOutputTokens = outTok;
|
|
1104
1268
|
const cost = chunk.usage.cost ?? 0;
|
|
1105
1269
|
this.emit('usage', {
|
|
1106
1270
|
prompt_tokens: inTok,
|
|
@@ -1114,6 +1278,7 @@ export class Agent extends EventEmitter {
|
|
|
1114
1278
|
}
|
|
1115
1279
|
}
|
|
1116
1280
|
else {
|
|
1281
|
+
_turnLlmStartMs = Date.now();
|
|
1117
1282
|
this.emit('stream:start');
|
|
1118
1283
|
const result = await this.client.chatComplete({
|
|
1119
1284
|
model: currentModel,
|
|
@@ -1128,6 +1293,8 @@ export class Agent extends EventEmitter {
|
|
|
1128
1293
|
this.recordModelSuccess(currentModel);
|
|
1129
1294
|
const choice = result.choices?.[0];
|
|
1130
1295
|
if (choice?.message) {
|
|
1296
|
+
_turnTtftMs = Date.now() - _turnLlmStartMs;
|
|
1297
|
+
_firstDeltaSeen = true;
|
|
1131
1298
|
if (choice.message.content) {
|
|
1132
1299
|
fullText = choice.message.content;
|
|
1133
1300
|
this.emit('stream:delta', fullText, fullText);
|
|
@@ -1146,6 +1313,8 @@ export class Agent extends EventEmitter {
|
|
|
1146
1313
|
const inTok = result.usage.prompt_tokens ?? 0;
|
|
1147
1314
|
const outTok = result.usage.completion_tokens ?? 0;
|
|
1148
1315
|
const totalTok = result.usage.total_tokens ?? (inTok + outTok);
|
|
1316
|
+
_turnInputTokens = inTok;
|
|
1317
|
+
_turnOutputTokens = outTok;
|
|
1149
1318
|
const cost = result.usage.cost ?? 0;
|
|
1150
1319
|
this.emit('usage', {
|
|
1151
1320
|
prompt_tokens: inTok,
|
|
@@ -1320,10 +1489,13 @@ export class Agent extends EventEmitter {
|
|
|
1320
1489
|
const execFn = tool?.function?.execute ?? tool?.execute;
|
|
1321
1490
|
if (tool && typeof execFn === 'function') {
|
|
1322
1491
|
try {
|
|
1323
|
-
//
|
|
1324
|
-
// authoritative (snake_case) and the
|
|
1325
|
-
// injecting camelCase duplicates could fail
|
|
1326
|
-
|
|
1492
|
+
// Remotely-schema'd tools get the raw args: their JSON Schemas
|
|
1493
|
+
// are authoritative (snake_case) and the far side validates
|
|
1494
|
+
// them — injecting camelCase duplicates could fail that
|
|
1495
|
+
// validation. True for the Scalix Cloud gateway and equally for
|
|
1496
|
+
// any MCP server, which validates against its own inputSchema.
|
|
1497
|
+
const src = tool?.source;
|
|
1498
|
+
const args = (src === 'scalix-cloud' || src === 'mcp')
|
|
1327
1499
|
? parsedArgs
|
|
1328
1500
|
: normalizeArgs(parsedArgs);
|
|
1329
1501
|
// Validate against the very schema the model was shown. Nothing
|
|
@@ -1350,6 +1522,8 @@ export class Agent extends EventEmitter {
|
|
|
1350
1522
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
1351
1523
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
|
1352
1524
|
: this.config.toolTimeoutMs;
|
|
1525
|
+
const toolStart = Date.now();
|
|
1526
|
+
this.emit('step:start', { stepIndex: _stepIndex, name: call.name, args: parsedArgs, startedAt: toolStart });
|
|
1353
1527
|
result = await withTimeout(execFn(args, {
|
|
1354
1528
|
context: {},
|
|
1355
1529
|
workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
|
|
@@ -1363,8 +1537,21 @@ export class Agent extends EventEmitter {
|
|
|
1363
1537
|
confirmToolCall: this.config.confirmToolCall,
|
|
1364
1538
|
},
|
|
1365
1539
|
}), toolTimeout, call.name);
|
|
1540
|
+
const toolDurationMs = Date.now() - toolStart;
|
|
1541
|
+
_turnToolDurationMs += toolDurationMs;
|
|
1542
|
+
this.emit('step:done', { stepIndex: _stepIndex, name: call.name, durationMs: toolDurationMs, success: !result?.error });
|
|
1543
|
+
_stepIndex++;
|
|
1544
|
+
// The tool only persists the entry to mcp-services.json. Connect
|
|
1545
|
+
// it here too, so the server's tools are usable in this same
|
|
1546
|
+
// session instead of only after a restart — and report the
|
|
1547
|
+
// outcome back through the tool result, since a server that
|
|
1548
|
+
// won't answer is something the model needs to know now.
|
|
1366
1549
|
if (call.name === 'add_mcp_server' && result?.success) {
|
|
1367
|
-
|
|
1550
|
+
const connected = await this.connectMcpServer({
|
|
1551
|
+
serverLabel: args.label,
|
|
1552
|
+
serverUrl: args.url,
|
|
1553
|
+
});
|
|
1554
|
+
result = { ...result, connected: connected.state === 'connected', tools: connected.toolCount, ...(connected.error ? { connectError: connected.error } : {}) };
|
|
1368
1555
|
}
|
|
1369
1556
|
}
|
|
1370
1557
|
catch (e) {
|
|
@@ -1373,11 +1560,19 @@ export class Agent extends EventEmitter {
|
|
|
1373
1560
|
}
|
|
1374
1561
|
}
|
|
1375
1562
|
else {
|
|
1376
|
-
const
|
|
1563
|
+
const allNames = this.config.tools
|
|
1377
1564
|
.map((t) => t.function?.name || t.name)
|
|
1378
|
-
.filter(Boolean)
|
|
1379
|
-
|
|
1380
|
-
|
|
1565
|
+
.filter(Boolean);
|
|
1566
|
+
// Near-matches first: an invented "scalix_build_list" must surface
|
|
1567
|
+
// the real scalix_* names. Blindly listing the first 20 registry
|
|
1568
|
+
// entries hid every platform tool and read as "those don't exist" —
|
|
1569
|
+
// the model then invented workarounds instead of correcting the name
|
|
1570
|
+
// (seen 2026-08-09: a deploy derailed into "make the repo public").
|
|
1571
|
+
const stem = call.name.toLowerCase().split(/[_-]/)[0];
|
|
1572
|
+
const near = stem.length >= 3 ? allNames.filter((n) => n.toLowerCase().includes(stem)) : [];
|
|
1573
|
+
const shown = [...new Set([...near, ...allNames])].slice(0, Math.max(20, Math.min(near.length, 40)));
|
|
1574
|
+
const omitted = allNames.length - shown.length;
|
|
1575
|
+
result = { error: `Tool "${call.name}" does not exist.${near.length ? ` Did you mean one of: ${near.slice(0, 40).join(', ')}?` : ''} Available tools: ${shown.join(', ')}${omitted > 0 ? `, plus ${omitted} more registered but not listed here` : ''}. Do not invent tool names — only use tools from the list above, or respond with text directly.` };
|
|
1381
1576
|
}
|
|
1382
1577
|
let resultStr;
|
|
1383
1578
|
try {
|
|
@@ -1483,6 +1678,16 @@ export class Agent extends EventEmitter {
|
|
|
1483
1678
|
}
|
|
1484
1679
|
if (this._aborted)
|
|
1485
1680
|
return '[interrupted]';
|
|
1681
|
+
this.emit('turn:summary', {
|
|
1682
|
+
turnIndex: turnCount,
|
|
1683
|
+
steps: _stepIndex,
|
|
1684
|
+
llmDurationMs: Date.now() - _turnLlmStartMs,
|
|
1685
|
+
toolDurationMs: _turnToolDurationMs,
|
|
1686
|
+
ttftMs: _turnTtftMs,
|
|
1687
|
+
tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
|
|
1688
|
+
inputTokens: _turnInputTokens,
|
|
1689
|
+
outputTokens: _turnOutputTokens,
|
|
1690
|
+
});
|
|
1486
1691
|
continue;
|
|
1487
1692
|
}
|
|
1488
1693
|
if (this.groundingConfig?.enabled && this.groundingNudgeCount < this.groundingConfig.maxNudges) {
|
|
@@ -1502,6 +1707,16 @@ export class Agent extends EventEmitter {
|
|
|
1502
1707
|
}
|
|
1503
1708
|
}
|
|
1504
1709
|
}
|
|
1710
|
+
this.emit('turn:summary', {
|
|
1711
|
+
turnIndex: turnCount,
|
|
1712
|
+
steps: _stepIndex,
|
|
1713
|
+
llmDurationMs: Date.now() - _turnLlmStartMs,
|
|
1714
|
+
toolDurationMs: _turnToolDurationMs,
|
|
1715
|
+
ttftMs: _turnTtftMs,
|
|
1716
|
+
tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
|
|
1717
|
+
inputTokens: _turnInputTokens,
|
|
1718
|
+
outputTokens: _turnOutputTokens,
|
|
1719
|
+
});
|
|
1505
1720
|
return fullText;
|
|
1506
1721
|
}
|
|
1507
1722
|
catch (err) {
|
package/dist/api-client.js
CHANGED
|
@@ -35,7 +35,7 @@ export function parseRetryAfter(value) {
|
|
|
35
35
|
const DEFAULT_TIMEOUT_MS = (() => {
|
|
36
36
|
const v = process.env.SCALIX_REQUEST_TIMEOUT_MS;
|
|
37
37
|
const n = v ? parseInt(v, 10) : NaN;
|
|
38
|
-
return Number.isFinite(n)
|
|
38
|
+
return Number.isFinite(n) ? n : 0;
|
|
39
39
|
})();
|
|
40
40
|
// The capability probe only decides whether a turn streams. It must never be
|
|
41
41
|
// the reason a turn is slow to start, so it gives up quickly and falls back to
|
|
@@ -124,8 +124,8 @@ export class ScalixClient {
|
|
|
124
124
|
for (;;) {
|
|
125
125
|
const prepared = this.prepareRequest(req);
|
|
126
126
|
const body = JSON.stringify(prepared);
|
|
127
|
-
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
128
|
-
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
127
|
+
const timeoutSignal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : undefined;
|
|
128
|
+
const combined = signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal || timeoutSignal);
|
|
129
129
|
const response = await fetch(url, {
|
|
130
130
|
method: 'POST',
|
|
131
131
|
headers: {
|
|
@@ -135,7 +135,7 @@ export class ScalixClient {
|
|
|
135
135
|
'x-client-type': 'cli',
|
|
136
136
|
},
|
|
137
137
|
body,
|
|
138
|
-
signal: combined,
|
|
138
|
+
signal: combined || undefined,
|
|
139
139
|
});
|
|
140
140
|
if (!response.ok) {
|
|
141
141
|
const text = await response.text().catch(() => '');
|