scalix 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +13 -2
- package/bin/scalix.js +8 -0
- package/dist/agent.js +92 -3
- package/dist/api-client.js +5 -2
- package/dist/cli.js +190 -6
- package/dist/cloud.js +236 -0
- package/dist/constants.js +55 -9
- package/dist/headless.js +16 -2
- package/dist/sandbox.js +21 -2
- package/dist/tools-voice.js +9 -15
- package/dist/tools-web.js +5 -32
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Scalix Coder will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.0] - 2026-07-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Scalix Cloud platform tools in the agent — your account's MCP catalog (`GET /api/v1/mcp`, scope-filtered per key) is fetched on start and registered as first-class function tools, executed via `POST /api/v1/mcp/call` with your own API key. Deploys, database queries, storage, functions and more become plain agent prompts
|
|
9
|
+
- `/cloud` command — `/cloud status` (identity, plan, limits, tool count), `/cloud tools` (available `scalix_*` tools grouped by platform area), `/cloud refresh` (re-fetch identity + catalog)
|
|
10
|
+
- `/login` command — links your Scalix account through the setup wizard
|
|
11
|
+
- Connected onboarding — the setup wizard validates the API key against `GET /v1/me` and shows a "Connected to Scalix Cloud" panel (org, project, plan, rate limit, platform tools unlocked) with a re-enter/skip choice on failure; a "Try:" block with real platform prompts appears after a successful connect
|
|
12
|
+
- Welcome/status line reflects the real connection: `☁ Scalix Cloud · <plan> · <N> platform tools` when connected, `☁ not connected — /login to link your Scalix account` otherwise
|
|
13
|
+
- `SCALIX_PROJECT` env var — sent as `x-scalix-project` on catalog/tool calls to scope an org-wide key to one project
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- When the catalog is unreachable or no key is configured, the agent states it once and runs with local tools only — availability is never fabricated and there is no retry loop. Tool counts in the status bar and headless banner include only tools that actually loaded
|
|
17
|
+
|
|
18
|
+
## [1.3.3] - 2026-07-05
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- API endpoints aligned to the Scalix gateway's `/v1/ai` nest: chat completions now POST to `/v1/ai/chat/completions` and the model list is fetched from `GET /v1/ai/models` (OpenAI-style `{data: [...]}`). The default base URL is now the host root `https://api.scalix.world`; a trailing `/v1` in existing configs is still accepted and normalized
|
|
22
|
+
- Voice input calls the single canonical `/v1/ai/audio/transcriptions` path and reports plainly when transcription isn't available on the deployment, instead of probing two dead endpoints
|
|
23
|
+
- Web search no longer calls a non-existent platform research endpoint; it states that web research isn't available via the Scalix API yet and uses the configured search providers (Brave/Tavily/DuckDuckGo)
|
|
24
|
+
- Sandbox no longer falsely blocks file access when the working directory sits behind a symlink (e.g. macOS temp dirs): symlink containment now compares real paths on both sides
|
|
25
|
+
- `User-Agent` header carries the real package version instead of a hardcoded `1.0`
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Model examples and defaults use current Scalix model names (`scalix-lumio-*`); the retired `scalix-world-ai` id removed from UI text and defaults
|
|
29
|
+
- Model list UI no longer shows tier/provider columns (the API does not expose them)
|
|
30
|
+
- README version badge matches the released package version
|
|
31
|
+
|
|
5
32
|
## [3.1.0] - 2026-04-20
|
|
6
33
|
|
|
7
34
|
### Added
|
package/README.md
CHANGED
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered coding agent for the terminal and VS Code — built by [Scalix World](https://scalix.world).
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|

|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
Scalix Coder is a full-featured AI coding assistant that runs in your terminal or as a VS Code extension. It connects to the Scalix API (or
|
|
9
|
+
Scalix Coder is a full-featured AI coding assistant that runs in your terminal or as a VS Code extension. It connects to the Scalix API (or your own compatible endpoint) and gives AI direct access to your codebase through 31 built-in tools — file editing, shell execution, search, git, web access, browser automation, vision, voice input, and more.
|
|
10
|
+
|
|
11
|
+
## One agent for your whole cloud
|
|
12
|
+
|
|
13
|
+
With a Scalix account, the agent is more than a coder: on start it fetches your account's platform tool catalog and registers every tool your API key's scopes unlock — databases, deployments (Scalix Run), storage, functions, cron, domains, and more. "Deploy this app", "create a table and seed it", "upload dist/ to a bucket" become plain prompts.
|
|
14
|
+
|
|
15
|
+
- Tools reflect **your key's scopes** — the catalog is filtered server-side, and mutating tools are refused for read-only keys
|
|
16
|
+
- `/cloud` shows your connection, plan, and available tools; `/cloud refresh` re-fetches the catalog
|
|
17
|
+
- No account (or catalog unreachable)? The agent says so once and runs in **local-only mode** with the built-in tools — nothing is faked
|
|
10
18
|
|
|
11
19
|
## Features
|
|
12
20
|
|
|
@@ -55,6 +63,7 @@ First launch opens a setup wizard to configure your Scalix API key, endpoint, an
|
|
|
55
63
|
| `SCALIX_API_KEY` | API key (overrides saved config) |
|
|
56
64
|
| `SCALIX_MODEL` | Comma-separated model IDs |
|
|
57
65
|
| `SCALIX_BASE_URL` | API base URL |
|
|
66
|
+
| `SCALIX_PROJECT` | Project ID for org-wide keys (sent as `x-scalix-project` on platform tool calls) |
|
|
58
67
|
| `BRAVE_API_KEY` | Brave Search API key |
|
|
59
68
|
| `TAVILY_API_KEY` | Tavily Search API key |
|
|
60
69
|
|
|
@@ -90,6 +99,8 @@ scalix run "generate a migration" --output migration.sql --format patch
|
|
|
90
99
|
| `/replay` | Session timeline |
|
|
91
100
|
| `/plugins` | Loaded plugins |
|
|
92
101
|
| `/settings` | Configure limits |
|
|
102
|
+
| `/cloud` | Scalix Cloud status, platform tools & refresh |
|
|
103
|
+
| `/login` | Link your Scalix account |
|
|
93
104
|
| `/setup` | Reconfigure API key & models |
|
|
94
105
|
| `/delegate <task>` | Run with sub-agent |
|
|
95
106
|
| `/checkpoint` | Create git checkpoint |
|
package/bin/scalix.js
CHANGED
|
@@ -8,6 +8,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
process.env.NODE_ENV = 'production';
|
|
9
9
|
|
|
10
10
|
const args = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
13
|
+
const { readFileSync } = await import('node:fs');
|
|
14
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
15
|
+
console.log(pkg.version);
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
const needsHeadless =
|
|
12
20
|
args.includes('run') ||
|
|
13
21
|
args.includes('--pipe') || args.includes('-p') ||
|
package/dist/agent.js
CHANGED
|
@@ -3,7 +3,7 @@ import { EventEmitter } from 'eventemitter3';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { getToolPermissionWithArgs } from './sandbox.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
|
-
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
|
|
6
|
+
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
|
|
7
7
|
import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
|
|
8
8
|
import { ContextManager } from './context-manager.js';
|
|
9
9
|
import { zodToJsonSchema } from './tool-helper.js';
|
|
@@ -19,8 +19,13 @@ import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFA
|
|
|
19
19
|
import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
|
|
20
20
|
import fs from 'node:fs/promises';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
+
import { fetchCloudTools, callCloudTool } from './cloud.js';
|
|
22
23
|
function transformToolsForApi(tools) {
|
|
23
24
|
return tools
|
|
25
|
+
// type:'mcp' entries are decorative config for user-registered external
|
|
26
|
+
// MCP servers and never reach the API. Scalix Cloud platform tools bypass
|
|
27
|
+
// this path entirely: they are plain function tools (CloudPlatformTool,
|
|
28
|
+
// registered by refreshCloudTools) whose `parameters` pass through as-is.
|
|
24
29
|
.filter((t) => !('type' in t && t.type === 'mcp'))
|
|
25
30
|
.map((t) => {
|
|
26
31
|
const local = t;
|
|
@@ -158,12 +163,14 @@ export class Agent extends EventEmitter {
|
|
|
158
163
|
retrievalConfig = null;
|
|
159
164
|
_sending = false;
|
|
160
165
|
outputFormatInstructionIndex = null;
|
|
166
|
+
cloudProjectId;
|
|
167
|
+
cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
161
168
|
constructor(config) {
|
|
162
169
|
super();
|
|
163
170
|
this.agentId = config.agentId ?? generateAgentId();
|
|
164
171
|
this.parentAgentId = config.parentAgentId ?? null;
|
|
165
172
|
this.agentDescription = config.agentDescription ?? 'main';
|
|
166
|
-
this.client = new ScalixClient(config.baseUrl ||
|
|
173
|
+
this.client = new ScalixClient(config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
|
|
167
174
|
this.log = logger.child();
|
|
168
175
|
const resolvedModels = Array.isArray(config.model)
|
|
169
176
|
? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
|
|
@@ -189,6 +196,7 @@ export class Agent extends EventEmitter {
|
|
|
189
196
|
thinkingMode: config.thinkingMode ?? 'off',
|
|
190
197
|
workspaceRoot: config.workspaceRoot ?? process.cwd(),
|
|
191
198
|
};
|
|
199
|
+
this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
|
|
192
200
|
this.contextManager = new ContextManager(config.contextManager);
|
|
193
201
|
if (config.dynamicToolSelection) {
|
|
194
202
|
this.toolSelector = new ToolSelector();
|
|
@@ -422,6 +430,82 @@ export class Agent extends EventEmitter {
|
|
|
422
430
|
if (this.toolSelector)
|
|
423
431
|
this.toolSelector.index(this.config.tools);
|
|
424
432
|
}
|
|
433
|
+
// ─── Scalix Cloud platform tools ───
|
|
434
|
+
isCloudTool(t) {
|
|
435
|
+
return 'source' in t && t.source === 'scalix-cloud';
|
|
436
|
+
}
|
|
437
|
+
/** Build a first-class function tool from a catalog entry. The server's JSON
|
|
438
|
+
* Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
|
|
439
|
+
* which transformToolsForApi would run through the Zod converter). */
|
|
440
|
+
makeCloudTool(ct) {
|
|
441
|
+
const baseUrl = this.client.baseURL;
|
|
442
|
+
const apiKey = this.config.apiKey;
|
|
443
|
+
const projectId = this.cloudProjectId;
|
|
444
|
+
return {
|
|
445
|
+
source: 'scalix-cloud',
|
|
446
|
+
name: ct.name,
|
|
447
|
+
description: ct.description,
|
|
448
|
+
parameters: ct.inputSchema,
|
|
449
|
+
execute: async (args) => {
|
|
450
|
+
const result = await callCloudTool(baseUrl, apiKey, ct.name, args ?? {}, projectId);
|
|
451
|
+
// Tool-level failures are tool results the model can react to, not
|
|
452
|
+
// exceptions — the "Error: " prefix is the model-facing signal.
|
|
453
|
+
return result.ok ? result.text : `Error: ${result.text}`;
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Fetch the account's Scalix Cloud tool catalog and register every returned
|
|
459
|
+
* tool as a first-class function tool. Called on agent start and on demand
|
|
460
|
+
* (/cloud refresh). On failure the previously registered cloud tools are
|
|
461
|
+
* removed and the agent keeps running with local tools only — the returned
|
|
462
|
+
* status carries the real error for a single honest notice; no retries.
|
|
463
|
+
*/
|
|
464
|
+
async refreshCloudTools() {
|
|
465
|
+
if (!this.config.apiKey) {
|
|
466
|
+
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
467
|
+
this.cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
468
|
+
return { ...this.cloudStatus };
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const catalog = await fetchCloudTools(this.client.baseURL, this.config.apiKey, this.cloudProjectId);
|
|
472
|
+
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
473
|
+
const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
|
|
474
|
+
let registered = 0;
|
|
475
|
+
for (const ct of catalog.tools) {
|
|
476
|
+
if (localNames.has(ct.name)) {
|
|
477
|
+
this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
this.config.tools.push(this.makeCloudTool(ct));
|
|
481
|
+
registered++;
|
|
482
|
+
}
|
|
483
|
+
if (this.toolSelector)
|
|
484
|
+
this.toolSelector.index(this.config.tools);
|
|
485
|
+
this.cloudStatus = {
|
|
486
|
+
state: 'connected',
|
|
487
|
+
toolCount: registered,
|
|
488
|
+
platformAvailable: catalog.platformAvailable,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
493
|
+
if (this.toolSelector)
|
|
494
|
+
this.toolSelector.index(this.config.tools);
|
|
495
|
+
this.cloudStatus = { state: 'unavailable', toolCount: 0, error: err?.message || String(err) };
|
|
496
|
+
this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
|
|
497
|
+
}
|
|
498
|
+
return { ...this.cloudStatus };
|
|
499
|
+
}
|
|
500
|
+
getCloudToolsStatus() {
|
|
501
|
+
return { ...this.cloudStatus };
|
|
502
|
+
}
|
|
503
|
+
getCloudTools() {
|
|
504
|
+
return this.config.tools.filter((t) => this.isCloudTool(t));
|
|
505
|
+
}
|
|
506
|
+
getCloudProjectId() {
|
|
507
|
+
return this.cloudProjectId;
|
|
508
|
+
}
|
|
425
509
|
addModel(model) {
|
|
426
510
|
if (!this.config.models.includes(model))
|
|
427
511
|
this.config.models.push(model);
|
|
@@ -973,7 +1057,12 @@ export class Agent extends EventEmitter {
|
|
|
973
1057
|
const execFn = tool?.function?.execute ?? tool?.execute;
|
|
974
1058
|
if (tool && typeof execFn === 'function') {
|
|
975
1059
|
try {
|
|
976
|
-
|
|
1060
|
+
// Cloud platform tools get the raw args: their JSON Schemas are
|
|
1061
|
+
// authoritative (snake_case) and the gateway validates them —
|
|
1062
|
+
// injecting camelCase duplicates could fail server-side validation.
|
|
1063
|
+
const args = tool?.source === 'scalix-cloud'
|
|
1064
|
+
? parsedArgs
|
|
1065
|
+
: normalizeArgs(parsedArgs);
|
|
977
1066
|
reqLog.debug('Executing tool', { tool: call.name });
|
|
978
1067
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
979
1068
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
package/dist/api-client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { USER_AGENT, aiEndpointUrl } from './constants.js';
|
|
1
2
|
export class ApiError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
body;
|
|
@@ -19,7 +20,9 @@ export class ScalixClient {
|
|
|
19
20
|
this.timeoutMs = timeoutMs;
|
|
20
21
|
}
|
|
21
22
|
async chatComplete(req, signal) {
|
|
22
|
-
|
|
23
|
+
// baseURL is the API host root; the gateway nests AI under /v1/ai.
|
|
24
|
+
// aiEndpointUrl() also tolerates a legacy base ending in /v1.
|
|
25
|
+
const url = aiEndpointUrl(this.baseURL, 'chat/completions');
|
|
23
26
|
const body = JSON.stringify(req);
|
|
24
27
|
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
25
28
|
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
@@ -28,7 +31,7 @@ export class ScalixClient {
|
|
|
28
31
|
headers: {
|
|
29
32
|
'Content-Type': 'application/json',
|
|
30
33
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
31
|
-
'User-Agent':
|
|
34
|
+
'User-Agent': USER_AGENT,
|
|
32
35
|
},
|
|
33
36
|
body,
|
|
34
37
|
signal: combined,
|
package/dist/cli.js
CHANGED
|
@@ -45,6 +45,7 @@ import { createWorktree, listWorktrees, removeWorktree, cleanupStaleWorktrees }
|
|
|
45
45
|
import { sendNotification, getTerminalType } from './terminal-notify.js';
|
|
46
46
|
import { stringWidth, truncateText } from './text-utils.js';
|
|
47
47
|
import { isWorkspaceTrusted, trustWorkspace, hasProjectExecSurface } from './workspace-trust.js';
|
|
48
|
+
import { fetchMe, fetchCloudTools, groupCloudTools, CONSOLE_KEYS_URL } from './cloud.js';
|
|
48
49
|
logger.setLevel('silent');
|
|
49
50
|
// Promisified async child_process helpers (keep the TUI responsive vs execSync).
|
|
50
51
|
const execFileAsync = promisify(execFile);
|
|
@@ -92,6 +93,11 @@ console.error = (...args) => {
|
|
|
92
93
|
// sanitizeInput) and buffer the pasted content into the input value instead of
|
|
93
94
|
// treating each embedded newline as a separate Enter (which caused many submits).
|
|
94
95
|
process.stdout.write('\x1b[?2004h');
|
|
96
|
+
// User-registered external MCP servers (mcp-services.json). These are
|
|
97
|
+
// decorative config entries only — they are filtered out of API requests by
|
|
98
|
+
// transformToolsForApi. Scalix Cloud platform tools do NOT use this path: they
|
|
99
|
+
// are fetched from the account's catalog and registered as first-class
|
|
100
|
+
// function tools via agent.refreshCloudTools() (see connectCloud below).
|
|
95
101
|
const mcpTools = loadMcpServices();
|
|
96
102
|
let pluginTools = [];
|
|
97
103
|
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools];
|
|
@@ -139,6 +145,48 @@ let loadedSkills = [];
|
|
|
139
145
|
// exec surface (plugins/hooks/rules/settings) that we skipped loading. The App
|
|
140
146
|
// reads this on mount to inform the user they can run /trust to enable them.
|
|
141
147
|
let workspaceUntrustedNotice = false;
|
|
148
|
+
let cloudConnection = { connected: false, identity: null, toolCount: 0 };
|
|
149
|
+
// Set by the setup wizard when its Scalix Cloud validation step succeeded, so
|
|
150
|
+
// the App shows the "Try:" teach-in exactly once after a wizard or /login
|
|
151
|
+
// connect (not on every ordinary startup reconnect).
|
|
152
|
+
let cloudTeachInPending = false;
|
|
153
|
+
// Validate the key against /v1/me, then fetch the account's tool catalog and
|
|
154
|
+
// register it on the agent. One attempt, no retry loop: failure leaves the
|
|
155
|
+
// agent with local tools only and carries the server's own error message.
|
|
156
|
+
async function connectCloud(targetAgent) {
|
|
157
|
+
if (!targetAgent.getApiKey()) {
|
|
158
|
+
cloudConnection = { connected: false, identity: null, toolCount: 0, error: 'no API key configured' };
|
|
159
|
+
return cloudConnection;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const identity = await fetchMe(targetAgent.getBaseUrl() || DEFAULT_BASE_URL, targetAgent.getApiKey());
|
|
163
|
+
const status = await targetAgent.refreshCloudTools();
|
|
164
|
+
if (status.state === 'connected') {
|
|
165
|
+
cloudConnection = { connected: true, identity, toolCount: status.toolCount };
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
cloudConnection = { connected: false, identity, toolCount: 0, error: status.error };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
// /v1/me failed. Run the catalog fetch anyway: on failure it clears any
|
|
173
|
+
// stale platform tools from a previous connect; in the rare case it
|
|
174
|
+
// succeeds, the tools genuinely work and we say so (identity unavailable).
|
|
175
|
+
const status = await targetAgent.refreshCloudTools().catch(() => null);
|
|
176
|
+
if (status?.state === 'connected') {
|
|
177
|
+
cloudConnection = { connected: true, identity: null, toolCount: status.toolCount, error: err?.message || String(err) };
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
cloudConnection = { connected: false, identity: null, toolCount: 0, error: err?.message || String(err) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return cloudConnection;
|
|
184
|
+
}
|
|
185
|
+
const CLOUD_TRY_PROMPTS = [
|
|
186
|
+
'deploy this app to Scalix Run',
|
|
187
|
+
'create a table in my Scalix database and insert seed data',
|
|
188
|
+
'list my storage buckets and upload dist/ to one',
|
|
189
|
+
];
|
|
142
190
|
// Discover and register project-local plugin tools for the given agent.
|
|
143
191
|
// Gated on workspace trust; reused by initAgent and the /trust command.
|
|
144
192
|
function discoverPluginsForAgent(targetAgent) {
|
|
@@ -403,7 +451,7 @@ function formatContextSize(tokens) {
|
|
|
403
451
|
return String(tokens);
|
|
404
452
|
}
|
|
405
453
|
function SetupScreen({ onComplete, existingConfig }) {
|
|
406
|
-
const STEPS = ['api-key', 'base-url', 'models', 'context-window'];
|
|
454
|
+
const STEPS = ['api-key', 'base-url', 'cloud', 'models', 'context-window'];
|
|
407
455
|
const [step, setStep] = useState('api-key');
|
|
408
456
|
const [apiKey, setApiKey] = useState(existingConfig?.apiKey || process.env[ENV_API_KEY] || '');
|
|
409
457
|
const [baseUrl, setBaseUrl] = useState(existingConfig?.baseUrl || DEFAULT_BASE_URL);
|
|
@@ -414,7 +462,33 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
414
462
|
const [apiKeyError, setApiKeyError] = useState(false);
|
|
415
463
|
const [fetchedModels, setFetchedModels] = useState([]);
|
|
416
464
|
const [fetchingModels, setFetchingModels] = useState(false);
|
|
465
|
+
const [cloudCheck, setCloudCheck] = useState({ phase: 'checking' });
|
|
417
466
|
const { exit } = useApp();
|
|
467
|
+
// Validate the key against Scalix Cloud when the cloud step is entered:
|
|
468
|
+
// /v1/me for identity/plan, then the tool catalog for the unlocked count.
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
if (step !== 'cloud')
|
|
471
|
+
return;
|
|
472
|
+
let cancelled = false;
|
|
473
|
+
setCloudCheck({ phase: 'checking' });
|
|
474
|
+
(async () => {
|
|
475
|
+
const identity = await fetchMe(baseUrl, apiKey);
|
|
476
|
+
const catalog = await fetchCloudTools(baseUrl, apiKey, process.env.SCALIX_PROJECT);
|
|
477
|
+
return { identity, toolCount: catalog.tools.length };
|
|
478
|
+
})().then((result) => {
|
|
479
|
+
if (cancelled)
|
|
480
|
+
return;
|
|
481
|
+
cloudTeachInPending = true;
|
|
482
|
+
setCloudCheck({ phase: 'ok', identity: result.identity, toolCount: result.toolCount });
|
|
483
|
+
}).catch((err) => {
|
|
484
|
+
if (cancelled)
|
|
485
|
+
return;
|
|
486
|
+
setCloudCheck({ phase: 'fail', error: err?.message || String(err) });
|
|
487
|
+
});
|
|
488
|
+
return () => { cancelled = true; };
|
|
489
|
+
// apiKey/baseUrl are intentionally not deps: they are frozen once the user
|
|
490
|
+
// advances past base-url, and the step transition is the trigger.
|
|
491
|
+
}, [step]);
|
|
418
492
|
const totalSteps = STEPS.length;
|
|
419
493
|
const stepNumber = STEPS.indexOf(step) + 1;
|
|
420
494
|
const goBack = useCallback(() => {
|
|
@@ -453,6 +527,8 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
453
527
|
}, placeholder: "sk-or-v1-..." })] }), apiKeyError && (_jsx(Box, { marginTop: 0, paddingLeft: 2, children: _jsx(Text, { color: "yellow", dimColor: true, children: "API key is required." }) }))] })), step === 'base-url' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Base URL" }), _jsx(Text, { dimColor: true, children: "Scalix API endpoint (or any compatible URL)" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "#20a0a0", bold: true, children: ['\u276F', " "] }), _jsx(TextInput, { value: baseUrl, onChange: (v) => setBaseUrl(sanitizeInput(v)), onSubmit: (v) => {
|
|
454
528
|
const url = sanitizeInput(v).trim() || baseUrl;
|
|
455
529
|
setBaseUrl(url);
|
|
530
|
+
// Model list loads in the background while the cloud step
|
|
531
|
+
// validates the key via /v1/me + the tool catalog.
|
|
456
532
|
setFetchingModels(true);
|
|
457
533
|
fetchModelSpecs(url, apiKey).then((result) => {
|
|
458
534
|
if (result.length > 0) {
|
|
@@ -460,9 +536,19 @@ function SetupScreen({ onComplete, existingConfig }) {
|
|
|
460
536
|
}
|
|
461
537
|
}).catch((err) => { logger.warn('Failed to fetch model specs', { error: err.message }); }).finally(() => {
|
|
462
538
|
setFetchingModels(false);
|
|
463
|
-
setStep('models');
|
|
464
539
|
});
|
|
465
|
-
|
|
540
|
+
setStep('cloud');
|
|
541
|
+
}, placeholder: DEFAULT_BASE_URL })] }), fetchingModels && (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsx(Text, { color: "#20a0a0", dimColor: true, children: "Fetching available models..." }) }))] })), step === 'cloud' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Scalix Cloud" }), cloudCheck.phase === 'checking' && (_jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { color: "#20a0a0", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: "Validating your API key with Scalix Cloud..." })] })), cloudCheck.phase === 'ok' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#30c090", paddingX: 2, marginTop: 1, alignSelf: "flex-start", children: [_jsxs(Text, { color: "#30c090", bold: true, children: ['✓', " Connected to Scalix Cloud"] }), _jsxs(Text, { children: ["Org: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.orgId })] }), _jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.identity.projectId || 'org-wide' })] }), _jsxs(Text, { children: ["Plan: ", _jsx(Text, { color: "#20a0a0", children: cloudCheck.identity.limits.plan })] }), _jsxs(Text, { children: ["Rate limit: ", _jsxs(Text, { color: "#20a0a0", children: [cloudCheck.identity.limits.rateLimitRps, " req/s"] })] }), _jsxs(Text, { color: "#30c090", bold: true, children: ['●', " ", cloudCheck.toolCount, " platform tools unlocked"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [{ label: '→ Continue', value: '__continue__' }], onSelect: () => setStep('models') }) })] })), cloudCheck.phase === 'fail' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ['✘', " ", cloudCheck.error] }) }), _jsxs(Text, { dimColor: true, children: ["Create or check your key at ", CONSOLE_KEYS_URL] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
542
|
+
{ label: 'Re-enter API key', value: '__reenter__' },
|
|
543
|
+
{ label: 'Skip — continue without Scalix Cloud (local tools only)', value: '__skip__' },
|
|
544
|
+
], onSelect: (item) => {
|
|
545
|
+
if (item.value === '__reenter__') {
|
|
546
|
+
setStep('api-key');
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
setStep('models');
|
|
550
|
+
}
|
|
551
|
+
} }) })] }))] })), step === 'models' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "white", children: "Models" }), fetchedModels.length > 0 && modelsMode === 'custom' && models.length === 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Select models from your API or type a custom model ID." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
466
552
|
...fetchedModels.filter((fm) => !models.includes(fm.id)).map((fm) => ({
|
|
467
553
|
label: ` ${fm.name || fm.id}`,
|
|
468
554
|
value: fm.id,
|
|
@@ -587,8 +673,8 @@ const StatusBar = memo(function StatusBar({ status, model, toolCount, thinkStart
|
|
|
587
673
|
const gap = Math.max(cols - leftText.length - rightLen - 8, 2);
|
|
588
674
|
return (_jsxs(Box, { paddingX: 2, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: statusColor, children: statusIcon }), _jsx(Text, { dimColor: true, children: leftText })] }), _jsx(Text, { children: ' '.repeat(gap) }), _jsxs(Box, { gap: 1, children: [rateLimitWarn && _jsxs(Text, { color: "red", bold: true, children: ['\u26A0', " Rate limited"] }), _jsx(Text, { color: modeColor, bold: true, children: mode }), elapsedStr ? _jsxs(Text, { dimColor: true, children: ['\u00B7', " ", elapsedStr] }) : null, _jsxs(Text, { dimColor: true, children: ['\u00B7', " v", VERSION] })] })] }));
|
|
589
675
|
});
|
|
590
|
-
const WelcomeScreen = memo(function WelcomeScreen() {
|
|
591
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, flexWrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "Tools:" }), ['shell', 'read', 'write', 'edit', 'search', 'find', 'git', 'web', 'tree'].map((t) => (_jsx(Text, { dimColor: true, bold: true, children: t }, t)))] })] }));
|
|
676
|
+
const WelcomeScreen = memo(function WelcomeScreen({ cloud }) {
|
|
677
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 0, children: [_jsx(Text, { dimColor: true, children: " Type a message or use /help for commands. Press Esc to exit." }), _jsxs(Box, { marginTop: 0, paddingLeft: 2, gap: 1, flexWrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "Tools:" }), ['shell', 'read', 'write', 'edit', 'search', 'find', 'git', 'web', 'tree'].map((t) => (_jsx(Text, { dimColor: true, bold: true, children: t }, t)))] }), _jsx(Box, { paddingLeft: 2, children: cloud.connected ? (_jsxs(Text, { color: "#20a0a0", children: ['☁', " Scalix Cloud ", '·', " ", cloud.identity ? `${cloud.identity.limits.plan} ${'·'} ` : '', cloud.toolCount, " platform tools"] })) : (_jsxs(Text, { dimColor: true, children: ['☁', " not connected ", '—', " /login to link your Scalix account"] })) })] }));
|
|
592
678
|
});
|
|
593
679
|
const UserMessage = memo(function UserMessage({ text }) {
|
|
594
680
|
return (_jsxs(Box, { paddingX: 2, marginTop: 1, gap: 1, children: [_jsx(Text, { color: "magentaBright", bold: true, children: '\u276F' }), _jsx(Text, { wrap: "wrap", bold: true, children: text })] }));
|
|
@@ -1251,6 +1337,8 @@ const InputSection = memo(function InputSection({ onSubmit, disabled, onAbort, o
|
|
|
1251
1337
|
{ cmd: '/status', desc: 'Version, model & connection info' },
|
|
1252
1338
|
{ cmd: '/doctor', desc: 'Diagnose installation' },
|
|
1253
1339
|
{ cmd: '/mcp', desc: 'Add MCP server' },
|
|
1340
|
+
{ cmd: '/cloud', desc: 'Scalix Cloud status, platform tools & refresh' },
|
|
1341
|
+
{ cmd: '/login', desc: 'Link your Scalix account (API key)' },
|
|
1254
1342
|
{ cmd: '/setup', desc: 'Reconfigure API key, URL & models' },
|
|
1255
1343
|
{ cmd: '/settings', desc: 'View & configure limits' },
|
|
1256
1344
|
{ cmd: '/permissions', desc: 'View permission rules & recent denials' },
|
|
@@ -1428,6 +1516,7 @@ function App({ config }) {
|
|
|
1428
1516
|
const [selectingMode, setSelectingMode] = useState(false);
|
|
1429
1517
|
const [editingSettings, setEditingSettings] = useState(false);
|
|
1430
1518
|
const [agentVersion, setAgentVersion] = useState(0);
|
|
1519
|
+
const [cloudInfo, setCloudInfo] = useState(cloudConnection);
|
|
1431
1520
|
const counter = useRef(0);
|
|
1432
1521
|
const showBannerRef = useRef(true);
|
|
1433
1522
|
const modelsRef = useRef(models);
|
|
@@ -1687,6 +1776,34 @@ function App({ config }) {
|
|
|
1687
1776
|
add('system', 'This workspace has project plugins/hooks/rules that were skipped because the workspace is not trusted. Run /trust to load them.');
|
|
1688
1777
|
}
|
|
1689
1778
|
}, [add]);
|
|
1779
|
+
// Scalix Cloud: on agent start (and after /setup or /login re-init), fetch
|
|
1780
|
+
// the account's tool catalog and register it. Failure or no key → local
|
|
1781
|
+
// tools only, with exactly one honest notice. No retry loop.
|
|
1782
|
+
useEffect(() => {
|
|
1783
|
+
if (!agent)
|
|
1784
|
+
return;
|
|
1785
|
+
const hasKey = !!agent.getApiKey();
|
|
1786
|
+
let cancelled = false;
|
|
1787
|
+
connectCloud(agent).then((state) => {
|
|
1788
|
+
if (cancelled)
|
|
1789
|
+
return;
|
|
1790
|
+
setCloudInfo({ ...state });
|
|
1791
|
+
if (state.connected) {
|
|
1792
|
+
// Teach-in: only right after a wizard or /login connect, and only
|
|
1793
|
+
// when tools actually loaded.
|
|
1794
|
+
if (cloudTeachInPending && state.toolCount > 0) {
|
|
1795
|
+
cloudTeachInPending = false;
|
|
1796
|
+
const plan = state.identity ? ` ${'·'} ${state.identity.limits.plan}` : '';
|
|
1797
|
+
add('system', `☁ Connected to Scalix Cloud${plan} ${'·'} ${state.toolCount} platform tools`);
|
|
1798
|
+
add('system', `Try:\n${CLOUD_TRY_PROMPTS.map((p) => ` ❯ ${p}`).join('\n')}`);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
else if (hasKey) {
|
|
1802
|
+
add('system', `☁ Scalix Cloud unavailable — running with local tools only. ${state.error ?? ''}`.trimEnd());
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
return () => { cancelled = true; };
|
|
1806
|
+
}, [add, agentVersion]);
|
|
1690
1807
|
// Idle session nudge: suggest /clear after 75+ minutes of inactivity
|
|
1691
1808
|
const lastActivityRef = useRef(Date.now());
|
|
1692
1809
|
const idleNudgedRef = useRef(false);
|
|
@@ -1804,6 +1921,8 @@ function App({ config }) {
|
|
|
1804
1921
|
'',
|
|
1805
1922
|
'Tools & Config:',
|
|
1806
1923
|
' /copy \u2014 copy last response to clipboard',
|
|
1924
|
+
' /cloud \u2014 Scalix Cloud status (/cloud tools, /cloud refresh)',
|
|
1925
|
+
' /login \u2014 link your Scalix account (API key)',
|
|
1807
1926
|
' /status \u2014 show version, model & connection info',
|
|
1808
1927
|
' /update \u2014 check for and install updates',
|
|
1809
1928
|
' /doctor \u2014 diagnose installation',
|
|
@@ -2027,6 +2146,68 @@ function App({ config }) {
|
|
|
2027
2146
|
setShowSetup(true);
|
|
2028
2147
|
return true;
|
|
2029
2148
|
}
|
|
2149
|
+
if (cmd === '/login') {
|
|
2150
|
+
// Key (re-)entry runs through the setup wizard, whose cloud step
|
|
2151
|
+
// performs the same fetchMe + catalog validation as first-run setup.
|
|
2152
|
+
setShowSetup(true);
|
|
2153
|
+
return true;
|
|
2154
|
+
}
|
|
2155
|
+
if (cmd === '/cloud') {
|
|
2156
|
+
const sub = text.split(' ')[1]?.toLowerCase() || 'status';
|
|
2157
|
+
if (sub === 'status') {
|
|
2158
|
+
const s = cloudConnection;
|
|
2159
|
+
if (s.connected) {
|
|
2160
|
+
add('system', `☁ Scalix Cloud ${'—'} connected`);
|
|
2161
|
+
if (s.identity) {
|
|
2162
|
+
add('system', `Org: ${s.identity.identity.orgId}`);
|
|
2163
|
+
add('system', `Project: ${s.identity.identity.projectId || agent.getCloudProjectId() || 'org-wide'}`);
|
|
2164
|
+
add('system', `Key: ${s.identity.identity.keyId} (${s.identity.permissions.role})`);
|
|
2165
|
+
add('system', `Plan: ${s.identity.limits.plan} ${'·'} ${s.identity.limits.rateLimitRps} req/s ${'·'} ${s.identity.limits.apiKeysMax} API keys max`);
|
|
2166
|
+
add('system', `Scopes: ${s.identity.permissions.scopes.join(', ') || 'none'}`);
|
|
2167
|
+
}
|
|
2168
|
+
else {
|
|
2169
|
+
add('system', `Identity unavailable: ${s.error ?? 'unknown error'}`);
|
|
2170
|
+
}
|
|
2171
|
+
add('system', `Platform tools: ${s.toolCount} registered ${'—'} /cloud tools to list them`);
|
|
2172
|
+
}
|
|
2173
|
+
else {
|
|
2174
|
+
add('system', `☁ not connected ${'—'} /login to link your Scalix account`);
|
|
2175
|
+
if (s.error)
|
|
2176
|
+
add('system', `Last error: ${s.error}`);
|
|
2177
|
+
}
|
|
2178
|
+
return true;
|
|
2179
|
+
}
|
|
2180
|
+
if (sub === 'tools') {
|
|
2181
|
+
const tools = agent.getCloudTools();
|
|
2182
|
+
if (tools.length === 0) {
|
|
2183
|
+
add('system', cloudConnection.connected
|
|
2184
|
+
? 'No platform tools are registered for this key (its scopes unlock none).'
|
|
2185
|
+
: `☁ not connected ${'—'} /login to link your Scalix account`);
|
|
2186
|
+
return true;
|
|
2187
|
+
}
|
|
2188
|
+
add('system', `Platform tools (${tools.length}):`);
|
|
2189
|
+
for (const [group, groupTools] of groupCloudTools(tools)) {
|
|
2190
|
+
add('system', ` ${group} (${groupTools.length}): ${groupTools.map((t) => t.name).join(', ')}`);
|
|
2191
|
+
}
|
|
2192
|
+
return true;
|
|
2193
|
+
}
|
|
2194
|
+
if (sub === 'refresh') {
|
|
2195
|
+
add('system', 'Refreshing Scalix Cloud connection...');
|
|
2196
|
+
connectCloud(agent).then((state) => {
|
|
2197
|
+
setCloudInfo({ ...state });
|
|
2198
|
+
if (state.connected) {
|
|
2199
|
+
const plan = state.identity ? `${state.identity.limits.plan} plan ${'·'} ` : '';
|
|
2200
|
+
add('system', `☁ Connected ${'—'} ${plan}${state.toolCount} platform tools`);
|
|
2201
|
+
}
|
|
2202
|
+
else {
|
|
2203
|
+
add('error', `☁ Scalix Cloud unavailable: ${state.error ?? 'unknown error'}`);
|
|
2204
|
+
}
|
|
2205
|
+
});
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
2208
|
+
add('system', 'Usage: /cloud [status|tools|refresh]');
|
|
2209
|
+
return true;
|
|
2210
|
+
}
|
|
2030
2211
|
if (cmd === '/mode') {
|
|
2031
2212
|
const arg = text.split(' ')[1]?.toLowerCase();
|
|
2032
2213
|
if (arg && PERMISSION_MODES.includes(arg)) {
|
|
@@ -2626,6 +2807,9 @@ function App({ config }) {
|
|
|
2626
2807
|
add('system', `${PRODUCT_NAME} v${VERSION}`);
|
|
2627
2808
|
add('system', `Model: ${modelsStr}`);
|
|
2628
2809
|
add('system', `API: ${baseUrl}`);
|
|
2810
|
+
add('system', cloudConnection.connected
|
|
2811
|
+
? `Cloud: ☁ Scalix Cloud ${'·'} ${cloudConnection.identity ? `${cloudConnection.identity.limits.plan} ${'·'} ` : ''}${cloudConnection.toolCount} platform tools`
|
|
2812
|
+
: `Cloud: ☁ not connected ${'—'} /login to link your Scalix account`);
|
|
2629
2813
|
add('system', `Mode: ${mode}`);
|
|
2630
2814
|
add('system', `Context: ${ctx.percentage}% (${formatTokens(ctx.tokens)} / ${formatTokens(ctx.maxTokens)})`);
|
|
2631
2815
|
add('system', `Messages: ${msgCount}`);
|
|
@@ -3060,7 +3244,7 @@ function App({ config }) {
|
|
|
3060
3244
|
if (showSetup) {
|
|
3061
3245
|
return _jsx(SetupScreen, { onComplete: handleSetupDone, existingConfig: config });
|
|
3062
3246
|
}
|
|
3063
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode, contextPercent: contextPercent, rateLimitWarn: rateLimitWarn }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 6, overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, {}), !isAtTop && !showBanner && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2191', " more above ", '\u2014', " Shift+Up "] }) })), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || '') }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
|
|
3247
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: rows, children: [showBanner && _jsx(Banner, {}), _jsx(StatusBar, { status: status, model: models[0] || 'no model', toolCount: allTools.length + pluginTools.length + cloudInfo.toolCount, thinkStart: thinkStart, tokens: totalTokens, mode: currentMode, contextPercent: contextPercent, rateLimitWarn: rateLimitWarn }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 6, overflowY: "hidden", children: [showBanner && !isLoading && _jsx(WelcomeScreen, { cloud: cloudInfo }), !isAtTop && !showBanner && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2191', " more above ", '\u2014', " Shift+Up "] }) })), visibleItems.map((item) => _jsx(RenderItem, { item: item }, item.id)), isLoading && _jsx(StreamingArea, { text: streamText, reasoning: reasoningText, model: shortModel(models[0] || '') }), !isAtBottom && (_jsx(Box, { justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: [" ", '\u2193', " more below ", '\u2014', " Shift+Down "] }) }))] }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: confirmInfo ? (_jsx(ConfirmPrompt, { toolName: confirmInfo.toolName, args: confirmInfo.args, onRespond: handleConfirm, onScrollUp: scrollUp, onScrollDown: scrollDown })) : selectingModel ? (_jsx(ModelPicker, { currentModels: models, onSelect: (v) => {
|
|
3064
3248
|
agent.setModel(v);
|
|
3065
3249
|
setModels([v]);
|
|
3066
3250
|
setSelectingModel(false);
|
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Scalix Cloud platform client — identity (/v1/me), the account's MCP tool
|
|
2
|
+
// catalog (/api/v1/mcp), and tool execution (/api/v1/mcp/call).
|
|
3
|
+
//
|
|
4
|
+
// Wire-format notes (verified against the gateway + mcp-server crates):
|
|
5
|
+
// - Catalog tool entries serialize the schema under `input_schema` (snake_case,
|
|
6
|
+
// serde default on McpToolDef in both crates/gateway/src/mcp.rs and
|
|
7
|
+
// crates/mcp-server/src/types.rs). The MCP JSON-RPC spec spells it
|
|
8
|
+
// `inputSchema`; we tolerate both so a future spec-aligned rename is a no-op.
|
|
9
|
+
// - Tool call results are `{content: [{type: 'text', text}], is_error}`
|
|
10
|
+
// (McpToolResult/McpContent — only text blocks are ever produced). `isError`
|
|
11
|
+
// is tolerated for the same forward-compat reason.
|
|
12
|
+
// - Error envelope on 4xx/5xx: `{error, code, request_id?, docs_url?}`
|
|
13
|
+
// (ApiError::into_response in crates/gateway/src/server.rs).
|
|
14
|
+
//
|
|
15
|
+
// Dependency-free: global fetch only. Reuses apiHostRoot() so legacy configs
|
|
16
|
+
// with a trailing /v1 keep working for these non-AI endpoints too.
|
|
17
|
+
import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
|
|
18
|
+
export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
|
|
19
|
+
export const CLOUD_DEFAULTS = {
|
|
20
|
+
fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
|
|
21
|
+
// Platform operations (deploys, migrations) can legitimately take a while.
|
|
22
|
+
callTimeoutMs: 120_000,
|
|
23
|
+
};
|
|
24
|
+
export class CloudError extends Error {
|
|
25
|
+
status;
|
|
26
|
+
code;
|
|
27
|
+
docsUrl;
|
|
28
|
+
requestId;
|
|
29
|
+
constructor(message, opts = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'CloudError';
|
|
32
|
+
this.status = opts.status;
|
|
33
|
+
this.code = opts.code;
|
|
34
|
+
this.docsUrl = opts.docsUrl;
|
|
35
|
+
this.requestId = opts.requestId;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// ─── Pure parsers (unit-tested without network) ───
|
|
39
|
+
/**
|
|
40
|
+
* Build a CloudError from a non-OK response body using the gateway's error
|
|
41
|
+
* envelope `{error, code, docs_url, request_id}`. Falls back to the raw body
|
|
42
|
+
* (or status text) when the body isn't the envelope — the message is always
|
|
43
|
+
* whatever the server actually said, never an invented one.
|
|
44
|
+
*/
|
|
45
|
+
export function errorFromEnvelope(status, statusText, bodyText) {
|
|
46
|
+
let envelope = null;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(bodyText);
|
|
49
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
50
|
+
envelope = parsed;
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
if (status === 401) {
|
|
54
|
+
return new CloudError(`API key rejected — create one at ${CONSOLE_KEYS_URL}`, {
|
|
55
|
+
status,
|
|
56
|
+
code: typeof envelope?.code === 'string' ? envelope.code : undefined,
|
|
57
|
+
docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
|
|
58
|
+
requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const message = typeof envelope?.error === 'string' && envelope.error
|
|
62
|
+
? envelope.error
|
|
63
|
+
: (bodyText.trim().slice(0, 200) || `${status} ${statusText}`);
|
|
64
|
+
return new CloudError(message, {
|
|
65
|
+
status,
|
|
66
|
+
code: typeof envelope?.code === 'string' ? envelope.code : undefined,
|
|
67
|
+
docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
|
|
68
|
+
requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Parse the `GET /v1/me` response. Throws CloudError on a malformed body. */
|
|
72
|
+
export function parseMeResponse(data) {
|
|
73
|
+
const d = data;
|
|
74
|
+
const identity = d?.identity;
|
|
75
|
+
const permissions = d?.permissions;
|
|
76
|
+
const limits = d?.limits;
|
|
77
|
+
if (!identity || typeof identity.org_id !== 'string'
|
|
78
|
+
|| !permissions || !Array.isArray(permissions.scopes)
|
|
79
|
+
|| !limits || typeof limits.plan !== 'string') {
|
|
80
|
+
throw new CloudError('Unexpected /v1/me response shape — the endpoint did not return identity/permissions/limits');
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
identity: {
|
|
84
|
+
keyId: String(identity.key_id ?? ''),
|
|
85
|
+
tokenType: String(identity.token_type ?? ''),
|
|
86
|
+
orgId: identity.org_id,
|
|
87
|
+
projectId: typeof identity.project_id === 'string' && identity.project_id ? identity.project_id : undefined,
|
|
88
|
+
createdBy: String(identity.created_by ?? ''),
|
|
89
|
+
},
|
|
90
|
+
permissions: {
|
|
91
|
+
role: String(permissions.role ?? ''),
|
|
92
|
+
scopes: permissions.scopes.filter((s) => typeof s === 'string'),
|
|
93
|
+
canCreateKeys: permissions.can_create_keys === true,
|
|
94
|
+
canCreateProjects: permissions.can_create_projects === true,
|
|
95
|
+
},
|
|
96
|
+
tools: Array.isArray(d.tools) ? d.tools.filter((t) => typeof t === 'string') : [],
|
|
97
|
+
limits: {
|
|
98
|
+
plan: limits.plan,
|
|
99
|
+
rateLimitRps: typeof limits.rate_limit_rps === 'number' ? limits.rate_limit_rps : 0,
|
|
100
|
+
apiKeysMax: typeof limits.api_keys_max === 'number' ? limits.api_keys_max : 0,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse the `GET /api/v1/mcp` catalog. Entries without a name or an object
|
|
106
|
+
* schema are dropped (the gateway guarantees both; we don't register a tool we
|
|
107
|
+
* can't describe to the model). Throws CloudError when the body isn't a catalog.
|
|
108
|
+
*/
|
|
109
|
+
export function parseCatalogResponse(data) {
|
|
110
|
+
const d = data;
|
|
111
|
+
const rawTools = d?.capabilities?.tools;
|
|
112
|
+
if (!Array.isArray(rawTools)) {
|
|
113
|
+
throw new CloudError('Unexpected /api/v1/mcp response shape — no capabilities.tools array');
|
|
114
|
+
}
|
|
115
|
+
const tools = [];
|
|
116
|
+
for (const entry of rawTools) {
|
|
117
|
+
if (!entry || typeof entry.name !== 'string' || !entry.name)
|
|
118
|
+
continue;
|
|
119
|
+
const schema = entry.input_schema ?? entry.inputSchema;
|
|
120
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema))
|
|
121
|
+
continue;
|
|
122
|
+
tools.push({
|
|
123
|
+
name: entry.name,
|
|
124
|
+
description: typeof entry.description === 'string' ? entry.description : '',
|
|
125
|
+
inputSchema: schema,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return { tools, platformAvailable: d.platform_tools_available === true };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Normalize a `{content, is_error}` tool result to plain text for the agent.
|
|
132
|
+
* Content is an array of `{type: 'text', text}` blocks (the only block type the
|
|
133
|
+
* gateway produces); a bare string is accepted too. Throws CloudError when the
|
|
134
|
+
* body carries no readable content at all.
|
|
135
|
+
*/
|
|
136
|
+
export function parseToolCallResult(data) {
|
|
137
|
+
const d = data;
|
|
138
|
+
if (!d || typeof d !== 'object') {
|
|
139
|
+
throw new CloudError('Unexpected tool call response — not an object');
|
|
140
|
+
}
|
|
141
|
+
const isError = d.is_error === true || d.isError === true;
|
|
142
|
+
let text;
|
|
143
|
+
if (typeof d.content === 'string') {
|
|
144
|
+
text = d.content;
|
|
145
|
+
}
|
|
146
|
+
else if (Array.isArray(d.content)) {
|
|
147
|
+
text = d.content
|
|
148
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
149
|
+
.map((b) => b.text)
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
throw new CloudError('Unexpected tool call response — no content field');
|
|
154
|
+
}
|
|
155
|
+
return { ok: !isError, text };
|
|
156
|
+
}
|
|
157
|
+
// ─── HTTP calls ───
|
|
158
|
+
function cloudHeaders(apiKey, projectId) {
|
|
159
|
+
const headers = {
|
|
160
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
161
|
+
'User-Agent': USER_AGENT,
|
|
162
|
+
'x-client-type': 'cli',
|
|
163
|
+
};
|
|
164
|
+
if (projectId)
|
|
165
|
+
headers['x-scalix-project'] = projectId;
|
|
166
|
+
return headers;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fetch the caller's identity, plan, and platform capabilities from `/v1/me`.
|
|
170
|
+
* Throws CloudError (with the gateway's own `error` text) on any failure.
|
|
171
|
+
*/
|
|
172
|
+
export async function fetchMe(baseUrl, apiKey) {
|
|
173
|
+
const url = `${apiHostRoot(baseUrl)}/v1/me`;
|
|
174
|
+
const res = await fetch(url, {
|
|
175
|
+
headers: cloudHeaders(apiKey),
|
|
176
|
+
signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const body = await res.text().catch(() => '');
|
|
180
|
+
throw errorFromEnvelope(res.status, res.statusText, body);
|
|
181
|
+
}
|
|
182
|
+
return parseMeResponse(await res.json());
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Fetch the account's MCP tool catalog from `/api/v1/mcp`. The catalog is
|
|
186
|
+
* scope-filtered server-side for the caller's key. Throws CloudError on failure.
|
|
187
|
+
*/
|
|
188
|
+
export async function fetchCloudTools(baseUrl, apiKey, projectId) {
|
|
189
|
+
const url = `${apiHostRoot(baseUrl)}/api/v1/mcp`;
|
|
190
|
+
const res = await fetch(url, {
|
|
191
|
+
headers: cloudHeaders(apiKey, projectId),
|
|
192
|
+
signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
|
|
193
|
+
});
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
const body = await res.text().catch(() => '');
|
|
196
|
+
throw errorFromEnvelope(res.status, res.statusText, body);
|
|
197
|
+
}
|
|
198
|
+
return parseCatalogResponse(await res.json());
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Execute a platform tool via `POST /api/v1/mcp/call` and normalize the result
|
|
202
|
+
* to `{ok, text}`. Tool-level errors (is_error) and HTTP-level rejections (e.g.
|
|
203
|
+
* a read-only key calling a mutating tool) both come back as `ok: false` with
|
|
204
|
+
* the server's text — the agent should see tool errors as tool results, not
|
|
205
|
+
* exceptions. Only network/timeout failures throw.
|
|
206
|
+
*/
|
|
207
|
+
export async function callCloudTool(baseUrl, apiKey, name, args, projectId) {
|
|
208
|
+
const url = `${apiHostRoot(baseUrl)}/api/v1/mcp/call`;
|
|
209
|
+
const res = await fetch(url, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { ...cloudHeaders(apiKey, projectId), 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ name, arguments: args }),
|
|
213
|
+
signal: AbortSignal.timeout(CLOUD_DEFAULTS.callTimeoutMs),
|
|
214
|
+
});
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
const body = await res.text().catch(() => '');
|
|
217
|
+
const err = errorFromEnvelope(res.status, res.statusText, body);
|
|
218
|
+
return { ok: false, text: err.docsUrl ? `${err.message} (docs: ${err.docsUrl})` : err.message };
|
|
219
|
+
}
|
|
220
|
+
return parseToolCallResult(await res.json());
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Group tools by their platform area for display: scalix_db_query → "db",
|
|
224
|
+
* scalix_run_deploy → "run", non-scalix names → "other".
|
|
225
|
+
*/
|
|
226
|
+
export function groupCloudTools(tools) {
|
|
227
|
+
const groups = new Map();
|
|
228
|
+
for (const t of tools) {
|
|
229
|
+
const m = t.name.match(/^scalix_([a-z0-9]+)_/) ?? t.name.match(/^scalix_([a-z0-9]+)$/);
|
|
230
|
+
const key = m ? m[1] : 'other';
|
|
231
|
+
const list = groups.get(key) ?? [];
|
|
232
|
+
list.push(t);
|
|
233
|
+
groups.set(key, list);
|
|
234
|
+
}
|
|
235
|
+
return groups;
|
|
236
|
+
}
|
package/dist/constants.js
CHANGED
|
@@ -1,7 +1,42 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
4
|
+
// Single source of truth for the version is package.json — a hardcoded copy
|
|
5
|
+
// here drifted twice (1.1.0 vs 1.2.0, then 1.3.0 vs 1.3.2). Both src/ (tsx
|
|
6
|
+
// dev) and dist/ (build) sit one level below the package root.
|
|
7
|
+
function readPackageVersion() {
|
|
8
|
+
try {
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkg = JSON.parse(readFileSync(resolvePath(here, '..', 'package.json'), 'utf-8'));
|
|
11
|
+
if (typeof pkg.version === 'string')
|
|
12
|
+
return pkg.version;
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
return '0.0.0';
|
|
16
|
+
}
|
|
17
|
+
export const VERSION = readPackageVersion();
|
|
2
18
|
export const PRODUCT_NAME = 'Scalix-Coder';
|
|
3
19
|
export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
|
|
4
|
-
|
|
20
|
+
// Default API base: the bare Scalix host root. The client composes the full
|
|
21
|
+
// gateway paths itself (chat → /v1/ai/chat/completions, models → /v1/ai/models,
|
|
22
|
+
// audio → /v1/ai/audio/transcriptions) via aiEndpointUrl(). Override with
|
|
23
|
+
// SCALIX_BASE_URL or the config file's `baseUrl` for self-hosted gateways —
|
|
24
|
+
// an override is likewise a host root. A trailing `/v1` or `/v1/ai` (the
|
|
25
|
+
// pre-1.3.3 default shape, still present in older saved configs) is tolerated
|
|
26
|
+
// and stripped by apiHostRoot().
|
|
27
|
+
export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world';
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a configured base URL to the API host root: trailing slashes are
|
|
30
|
+
* removed, and a trailing `/v1` or `/v1/ai` segment (legacy config shape) is
|
|
31
|
+
* stripped so old saved configs keep working.
|
|
32
|
+
*/
|
|
33
|
+
export function apiHostRoot(baseUrl) {
|
|
34
|
+
return baseUrl.replace(/\/+$/, '').replace(/\/v1(\/ai)?$/, '');
|
|
35
|
+
}
|
|
36
|
+
/** Compose a full URL for an endpoint under the gateway's `/v1/ai` nest. */
|
|
37
|
+
export function aiEndpointUrl(baseUrl, endpoint) {
|
|
38
|
+
return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
|
|
39
|
+
}
|
|
5
40
|
export const DEFAULT_MODELS = [];
|
|
6
41
|
export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
|
|
7
42
|
|
|
@@ -40,11 +75,11 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
|
|
|
40
75
|
When helping users build AI applications, agents, or LLM-powered features:
|
|
41
76
|
- Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
|
|
42
77
|
- Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
|
|
78
|
+
- Scalix inference models are named scalix-lumio-* (e.g. scalix-lumio-lite); use those ids in examples and configs.
|
|
43
79
|
- For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
|
|
44
80
|
- For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
|
|
45
81
|
- For code execution sandboxes, use Scalix Sandbox.
|
|
46
|
-
- When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice
|
|
47
|
-
- Scalix supports local AI via Ollama — suggest this for privacy-sensitive or offline use cases.`;
|
|
82
|
+
- When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
|
|
48
83
|
export const ENV_API_KEY = 'SCALIX_API_KEY';
|
|
49
84
|
export const ENV_MODEL = 'SCALIX_MODEL';
|
|
50
85
|
export const ENV_BASE_URL = 'SCALIX_BASE_URL';
|
|
@@ -102,7 +137,14 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
102
137
|
if (!apiKey)
|
|
103
138
|
return [];
|
|
104
139
|
try {
|
|
105
|
-
|
|
140
|
+
// GET /v1/ai/models — OpenAI-style `{data: [{id, ...}]}`. The gateway
|
|
141
|
+
// rebrands the list to Scalix model ids and strips provider metadata, so
|
|
142
|
+
// `id` (and sometimes `name`, which equals the id) is all that's
|
|
143
|
+
// guaranteed. Spec fields (context_window, max_output_tokens,
|
|
144
|
+
// supports_vision) are used when the platform supplies them; models
|
|
145
|
+
// without them are NOT registered and fall back to DEFAULT_MODEL_SPEC via
|
|
146
|
+
// getModelSpec() — no per-model limits are fabricated client-side.
|
|
147
|
+
const url = aiEndpointUrl(baseUrl, 'models');
|
|
106
148
|
const res = await fetch(url, {
|
|
107
149
|
headers: {
|
|
108
150
|
'Authorization': `Bearer ${apiKey}`,
|
|
@@ -113,10 +155,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
113
155
|
if (!res.ok)
|
|
114
156
|
return [];
|
|
115
157
|
const data = await res.json();
|
|
116
|
-
const models = data.
|
|
158
|
+
const models = data.data || [];
|
|
117
159
|
const fetched = {};
|
|
118
160
|
const result = [];
|
|
119
161
|
for (const m of models) {
|
|
162
|
+
if (!m || typeof m.id !== 'string')
|
|
163
|
+
continue;
|
|
120
164
|
if (m.context_window && m.context_window > 0) {
|
|
121
165
|
fetched[m.id] = {
|
|
122
166
|
contextWindow: m.context_window,
|
|
@@ -129,8 +173,6 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
129
173
|
name: m.name,
|
|
130
174
|
contextWindow: m.context_window,
|
|
131
175
|
maxOutputTokens: m.max_output_tokens,
|
|
132
|
-
tier: m.tier,
|
|
133
|
-
provider: m.provider,
|
|
134
176
|
supportsVision: m.supports_vision,
|
|
135
177
|
});
|
|
136
178
|
}
|
|
@@ -142,8 +184,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
142
184
|
}
|
|
143
185
|
}
|
|
144
186
|
// ─── Search Endpoints (configurable via env vars) ───
|
|
187
|
+
// The Scalix platform does not currently expose a customer-facing web-research
|
|
188
|
+
// endpoint (the internal research/search routes are not routed through the
|
|
189
|
+
// public gateway). Web search therefore relies on the user-configured
|
|
190
|
+
// providers below; when the platform ships a public research API, add it back
|
|
191
|
+
// here as the default.
|
|
145
192
|
export const SEARCH_ENDPOINTS = {
|
|
146
|
-
scalix: `${(process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1').replace(/\/v1$/, '')}/research/search`,
|
|
147
193
|
brave: process.env.BRAVE_SEARCH_URL || 'https://api.search.brave.com/res/v1/web/search',
|
|
148
194
|
tavily: process.env.TAVILY_SEARCH_URL || 'https://api.tavily.com/search',
|
|
149
195
|
duckduckgo: process.env.DDG_SEARCH_URL || 'https://api.duckduckgo.com/',
|
package/dist/headless.js
CHANGED
|
@@ -102,6 +102,7 @@ Options:
|
|
|
102
102
|
--output-format Alias for --format
|
|
103
103
|
--output-schema Path to JSON schema file (implies --format json-schema)
|
|
104
104
|
--worktree, -w Run in an isolated git worktree (optional: --worktree <name>)
|
|
105
|
+
--version, -v Print the installed version
|
|
105
106
|
--help, -h Show this help
|
|
106
107
|
`);
|
|
107
108
|
process.exit(0);
|
|
@@ -206,6 +207,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
|
|
|
206
207
|
groundingCheck: true,
|
|
207
208
|
contextRetrieval: true,
|
|
208
209
|
});
|
|
210
|
+
// Register the account's Scalix Cloud platform tools (one attempt; failure
|
|
211
|
+
// is stated once and the run continues with local tools only).
|
|
212
|
+
const cloudStatus = await agent.refreshCloudTools();
|
|
213
|
+
if (cloudStatus.state === 'unavailable') {
|
|
214
|
+
process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m\n`);
|
|
215
|
+
}
|
|
209
216
|
let fullResponse = '';
|
|
210
217
|
agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
|
|
211
218
|
agent.on('verify:pass', (r) => process.stderr.write(`\x1b[32m✓ ${r.checker} passed\x1b[0m\n`));
|
|
@@ -391,9 +398,16 @@ async function runInteractive() {
|
|
|
391
398
|
console.log(`\x1b[33m\u26A0 Context usage: ${info.percentage}% (${info.tokens}/${info.maxTokens} tokens)\x1b[0m`);
|
|
392
399
|
}
|
|
393
400
|
});
|
|
394
|
-
|
|
401
|
+
// Register the account's Scalix Cloud platform tools (one attempt; failure
|
|
402
|
+
// is stated once and the session continues with local tools only).
|
|
403
|
+
const cloudStatus = await agent.refreshCloudTools();
|
|
404
|
+
if (cloudStatus.state === 'unavailable') {
|
|
405
|
+
console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m`);
|
|
406
|
+
}
|
|
407
|
+
const toolCount = allTools.length + cloudStatus.toolCount;
|
|
395
408
|
const modelDisplay = selectedModel.map(shortModel).join(' | ');
|
|
396
|
-
|
|
409
|
+
const cloudSuffix = cloudStatus.state === 'connected' ? ` (${cloudStatus.toolCount} from Scalix Cloud)` : '';
|
|
410
|
+
console.log(`\x1b[36mAgent ready\x1b[0m (${modelDisplay}) with ${toolCount} tools${cloudSuffix}. Type /help for commands.`);
|
|
397
411
|
if (plugins.length > 0) {
|
|
398
412
|
console.log(`\x1b[36mPlugins:\x1b[0m ${plugins.map((p) => p.manifest.name).join(', ')}`);
|
|
399
413
|
}
|
package/dist/sandbox.js
CHANGED
|
@@ -50,6 +50,25 @@ function realpathLongestExistingPrefix(resolved) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
// Real path of the sandbox root, cached per root value. The symlink containment
|
|
54
|
+
// check compares the candidate's REAL path, so it must compare against the
|
|
55
|
+
// root's REAL path too — otherwise a root that is itself behind a symlink
|
|
56
|
+
// (macOS /tmp and /var/folders, or any symlinked project dir) makes every file
|
|
57
|
+
// inside it look like an escape and gets falsely blocked.
|
|
58
|
+
let _realRootCache = null;
|
|
59
|
+
function realSandboxRoot(root) {
|
|
60
|
+
if (_realRootCache?.root === root)
|
|
61
|
+
return _realRootCache.real;
|
|
62
|
+
let real;
|
|
63
|
+
try {
|
|
64
|
+
real = stripExtendedLengthPrefix(fsSync.realpathSync.native(root));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
real = root;
|
|
68
|
+
}
|
|
69
|
+
_realRootCache = { root, real };
|
|
70
|
+
return real;
|
|
71
|
+
}
|
|
53
72
|
export function sandboxPath(filePath) {
|
|
54
73
|
const root = getSandboxRoot();
|
|
55
74
|
const resolved = path.resolve(root, filePath);
|
|
@@ -59,7 +78,7 @@ export function sandboxPath(filePath) {
|
|
|
59
78
|
// Resolve symlinks on the longest existing prefix and re-verify containment so
|
|
60
79
|
// a symlink pointing outside the sandbox cannot be used to escape it.
|
|
61
80
|
const real = realpathLongestExistingPrefix(resolved);
|
|
62
|
-
if (!isWithinRoot(real, root)) {
|
|
81
|
+
if (!isWithinRoot(real, realSandboxRoot(root))) {
|
|
63
82
|
throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
|
|
64
83
|
}
|
|
65
84
|
return real;
|
|
@@ -72,7 +91,7 @@ export async function sandboxPathAsync(filePath) {
|
|
|
72
91
|
}
|
|
73
92
|
try {
|
|
74
93
|
const real = await fs.realpath(resolved);
|
|
75
|
-
if (!isWithinRoot(real, root)) {
|
|
94
|
+
if (!isWithinRoot(real, realSandboxRoot(root))) {
|
|
76
95
|
throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
|
|
77
96
|
}
|
|
78
97
|
return real;
|
package/dist/tools-voice.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { execFile, spawn } from 'node:child_process';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
+
import { DEFAULT_BASE_URL, aiEndpointUrl } from './constants.js';
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
async function findRecordCommand() {
|
|
9
10
|
const platform = process.platform;
|
|
@@ -64,32 +65,25 @@ export async function recordAudio(outputPath, durationSeconds) {
|
|
|
64
65
|
export async function transcribeAudio(audioPath, apiKey, baseUrl) {
|
|
65
66
|
const audioBuffer = await fs.readFile(audioPath);
|
|
66
67
|
const blob = new Blob([audioBuffer], { type: 'audio/wav' });
|
|
67
|
-
const apiBase = baseUrl.replace(/\/v1$/, '');
|
|
68
68
|
const formData = new FormData();
|
|
69
69
|
formData.append('file', blob, 'audio.wav');
|
|
70
70
|
if (process.env.SCALIX_STT_MODEL) {
|
|
71
71
|
formData.append('model', process.env.SCALIX_STT_MODEL);
|
|
72
72
|
}
|
|
73
|
-
|
|
73
|
+
// Single canonical gateway path. Transcription is not enabled on every
|
|
74
|
+
// deployment — a 404/503 means the endpoint isn't available there, so say
|
|
75
|
+
// that plainly instead of probing alternate paths.
|
|
76
|
+
const transcriptionUrl = aiEndpointUrl(baseUrl, 'audio/transcriptions');
|
|
74
77
|
const response = await fetch(transcriptionUrl, {
|
|
75
78
|
method: 'POST',
|
|
76
79
|
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
77
80
|
body: formData,
|
|
78
81
|
});
|
|
79
82
|
if (!response.ok) {
|
|
80
|
-
|
|
81
|
-
|
|
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}`);
|
|
83
|
+
if (response.status === 404 || response.status === 503) {
|
|
84
|
+
throw new Error("transcription isn't available on this deployment");
|
|
90
85
|
}
|
|
91
|
-
|
|
92
|
-
return data.text || '';
|
|
86
|
+
throw new Error(`Transcription failed: ${response.status} ${response.statusText}`);
|
|
93
87
|
}
|
|
94
88
|
const data = await response.json();
|
|
95
89
|
return data.text || '';
|
|
@@ -119,7 +113,7 @@ export const voiceInputTool = tool({
|
|
|
119
113
|
return { error: 'Recording too short or empty. Check microphone input.' };
|
|
120
114
|
}
|
|
121
115
|
const apiKey = process.env.SCALIX_API_KEY || '';
|
|
122
|
-
const baseUrl = process.env.SCALIX_BASE_URL ||
|
|
116
|
+
const baseUrl = process.env.SCALIX_BASE_URL || DEFAULT_BASE_URL;
|
|
123
117
|
if (!apiKey) {
|
|
124
118
|
return {
|
|
125
119
|
recorded: true,
|
package/dist/tools-web.js
CHANGED
|
@@ -165,7 +165,7 @@ export const webFetchTool = tool({
|
|
|
165
165
|
});
|
|
166
166
|
export const webSearchTool = tool({
|
|
167
167
|
name: 'web_search',
|
|
168
|
-
description: 'Search the web. Uses
|
|
168
|
+
description: 'Search the web. Uses Brave Search or Tavily when the corresponding API key is configured, with DuckDuckGo as a keyless fallback. Returns titles, URLs, and snippets.',
|
|
169
169
|
inputSchema: z.object({
|
|
170
170
|
query: z.string().min(1).max(500).describe('Search query'),
|
|
171
171
|
count: z.number().min(1).max(20).optional().describe(`Number of results (default: ${WEB_DEFAULTS.defaultSearchResults})`),
|
|
@@ -173,36 +173,9 @@ export const webSearchTool = tool({
|
|
|
173
173
|
execute: async ({ query, count }) => {
|
|
174
174
|
const numResults = count ?? _limits.defaultSearchResults;
|
|
175
175
|
const errors = [];
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
const headers = {
|
|
181
|
-
'Content-Type': 'application/json',
|
|
182
|
-
'Authorization': `Bearer ${scalixKey}`,
|
|
183
|
-
'User-Agent': USER_AGENT,
|
|
184
|
-
};
|
|
185
|
-
const response = await fetch(SEARCH_ENDPOINTS.scalix, {
|
|
186
|
-
method: 'POST',
|
|
187
|
-
headers,
|
|
188
|
-
body: JSON.stringify({ query, max_results: numResults }),
|
|
189
|
-
});
|
|
190
|
-
if (response.ok) {
|
|
191
|
-
const data = await response.json();
|
|
192
|
-
const results = (data.results || []).slice(0, numResults).map((r) => ({
|
|
193
|
-
title: r.title, url: r.url, snippet: r.snippet || r.content || '',
|
|
194
|
-
}));
|
|
195
|
-
if (results.length > 0) {
|
|
196
|
-
return { query, provider: 'scalix', count: results.length, results };
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
errors.push(`Scalix: HTTP ${response.status}`);
|
|
200
|
-
}
|
|
201
|
-
catch (e) {
|
|
202
|
-
errors.push(`Scalix: ${e.message}`);
|
|
203
|
-
logger.warn('Scalix search failed, falling back', { error: e.message });
|
|
204
|
-
}
|
|
205
|
-
}
|
|
176
|
+
// Web research isn't available via the Scalix API yet — the platform has
|
|
177
|
+
// no customer-routed research/search endpoint — so search goes straight to
|
|
178
|
+
// the user-configured providers below rather than probing a dead route.
|
|
206
179
|
const braveKey = process.env.BRAVE_API_KEY;
|
|
207
180
|
if (braveKey) {
|
|
208
181
|
try {
|
|
@@ -279,7 +252,7 @@ export const webSearchTool = tool({
|
|
|
279
252
|
}
|
|
280
253
|
const safeErrors = errors.map(e => e.replace(/[A-Za-z0-9_\-]{20,}/g, '[REDACTED]'));
|
|
281
254
|
return {
|
|
282
|
-
error: `No search results. Tried: ${safeErrors.join('; ')}. Set BRAVE_API_KEY (free: 2000/month) or TAVILY_API_KEY (free: 1000/month) for full search.`,
|
|
255
|
+
error: `No search results. Web research isn't available via the Scalix API yet. Tried: ${safeErrors.join('; ')}. Set BRAVE_API_KEY (free: 2000/month) or TAVILY_API_KEY (free: 1000/month) for full search.`,
|
|
283
256
|
query,
|
|
284
257
|
};
|
|
285
258
|
},
|