scalix 1.5.0 → 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/CHANGELOG.md +24 -0
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +358 -42
- package/dist/api-client.js +12 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +435 -94
- package/dist/cloud.js +95 -6
- package/dist/config.js +44 -5
- package/dist/constants.js +121 -19
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +44 -13
- package/dist/ipc-server.js +362 -99
- 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/settings.js +33 -2
- 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/dist/cloud.js
CHANGED
|
@@ -15,11 +15,15 @@
|
|
|
15
15
|
// Dependency-free: global fetch only. Reuses apiHostRoot() so legacy configs
|
|
16
16
|
// with a trailing /v1 keep working for these non-AI endpoints too.
|
|
17
17
|
import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
|
|
18
|
-
export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
|
|
18
|
+
export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys?utm_source=scalix-coder&utm_medium=cli';
|
|
19
19
|
export const CLOUD_DEFAULTS = {
|
|
20
20
|
fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// The platform's own transport kills a tool call at ~30-35s (gateway →
|
|
22
|
+
// mcp-server hop, then the inner REST callback). 45s covers that ceiling
|
|
23
|
+
// plus jitter; anything longer is waiting on a call the server already
|
|
24
|
+
// abandoned. Long operations go through async pairs (build_create →
|
|
25
|
+
// build_status) — see buildCloudToolsPromptSection.
|
|
26
|
+
callTimeoutMs: 45_000,
|
|
23
27
|
};
|
|
24
28
|
export class CloudError extends Error {
|
|
25
29
|
status;
|
|
@@ -51,7 +55,7 @@ export function errorFromEnvelope(status, statusText, bodyText) {
|
|
|
51
55
|
}
|
|
52
56
|
catch { }
|
|
53
57
|
if (status === 401) {
|
|
54
|
-
return new CloudError(`API key rejected — create
|
|
58
|
+
return new CloudError(`API key rejected — create a project-scoped key (all scopes is simplest) at ${CONSOLE_KEYS_URL}`, {
|
|
55
59
|
status,
|
|
56
60
|
code: typeof envelope?.code === 'string' ? envelope.code : undefined,
|
|
57
61
|
docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
|
|
@@ -182,8 +186,11 @@ export async function fetchMe(baseUrl, apiKey) {
|
|
|
182
186
|
return parseMeResponse(await res.json());
|
|
183
187
|
}
|
|
184
188
|
/**
|
|
185
|
-
* Fetch the account's MCP tool catalog from `/api/v1/mcp`. The
|
|
186
|
-
*
|
|
189
|
+
* Fetch the account's MCP tool catalog from `/api/v1/mcp`. The gateway
|
|
190
|
+
* advertises the full platform tool set to every authenticated key — only the
|
|
191
|
+
* gateway-native DB tools are scope-filtered server-side — so callers that
|
|
192
|
+
* know the key's scopes should narrow the result with filterCatalogByScopes()
|
|
193
|
+
* before registering. Throws CloudError on failure.
|
|
187
194
|
*/
|
|
188
195
|
export async function fetchCloudTools(baseUrl, apiKey, projectId) {
|
|
189
196
|
const url = `${apiHostRoot(baseUrl)}/api/v1/mcp`;
|
|
@@ -219,6 +226,68 @@ export async function callCloudTool(baseUrl, apiKey, name, args, projectId) {
|
|
|
219
226
|
}
|
|
220
227
|
return parseToolCallResult(await res.json());
|
|
221
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* One-line, user-actionable fix for the two key-shape failures the platform
|
|
231
|
+
* returns on the MCP routes: 400 = org-wide key (platform tools require a
|
|
232
|
+
* project-scoped key), 403 = missing the database scope (database:query is
|
|
233
|
+
* the one that unlocks tools; the default all-scopes project key covers it).
|
|
234
|
+
* Anything else gets no remedy — the server's own error text stands alone.
|
|
235
|
+
*/
|
|
236
|
+
export function cloudKeyRemedy(err) {
|
|
237
|
+
if (err?.status === 400) {
|
|
238
|
+
return `This looks like an org-wide key — platform tools need a project-scoped key (all scopes, or at least database:query). Create one at ${CONSOLE_KEYS_URL}`;
|
|
239
|
+
}
|
|
240
|
+
if (err?.status === 403) {
|
|
241
|
+
return `This key is missing the database scope — create a project key with database:query (or all scopes) at ${CONSOLE_KEYS_URL}`;
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
// Tool-name prefix → API-key scope family (the part before ':'). Mirrors the
|
|
246
|
+
// gateway's route enforcement, NOT /v1/me's per-tool list: that list drifts
|
|
247
|
+
// from enforcement in places (run_scale/run_rollback, compute_delete), so a
|
|
248
|
+
// per-tool intersection would hide tools a key can actually call. Family
|
|
249
|
+
// granularity only drops whole areas the key holds no scope for at all.
|
|
250
|
+
const TOOL_SCOPE_FAMILIES = [
|
|
251
|
+
[/^scalix_db_/, 'database'],
|
|
252
|
+
[/^scalix_run_/, 'services'],
|
|
253
|
+
[/^scalix_(compute|domain|build)_/, 'compute'],
|
|
254
|
+
[/^scalix_storage_/, 'storage'],
|
|
255
|
+
[/^scalix_kv_/, 'kv'],
|
|
256
|
+
[/^scalix_fn_/, 'functions'],
|
|
257
|
+
[/^scalix_events_/, 'events'],
|
|
258
|
+
[/^scalix_cron_/, 'cron'],
|
|
259
|
+
[/^scalix_(computer_|sandbox_run$)/, 'sandbox'],
|
|
260
|
+
[/^scalix_ai_/, 'ai'],
|
|
261
|
+
[/^scalix_search$/, 'search'],
|
|
262
|
+
[/^scalix_usage$/, 'metering'],
|
|
263
|
+
[/^scalix_(project_|auth_configure$)/, 'admin'],
|
|
264
|
+
];
|
|
265
|
+
/** Scope family a tool belongs to, or null for tools that are never filtered
|
|
266
|
+
* (scalix_status, and any future tool whose prefix we don't know — fail open
|
|
267
|
+
* so a new platform area is not invisible until a client release). */
|
|
268
|
+
export function scopeFamilyForTool(name) {
|
|
269
|
+
for (const [re, family] of TOOL_SCOPE_FAMILIES) {
|
|
270
|
+
if (re.test(name))
|
|
271
|
+
return family;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Narrow a tool catalog to what the key's scopes can plausibly call. `*`
|
|
277
|
+
* keeps everything; otherwise a tool is kept when its scope family appears in
|
|
278
|
+
* the key's scopes (any action within the family counts) or when it has no
|
|
279
|
+
* known family. Call-time enforcement still has the final word — this only
|
|
280
|
+
* stops the model from seeing whole platform areas its key cannot touch.
|
|
281
|
+
*/
|
|
282
|
+
export function filterCatalogByScopes(tools, scopes) {
|
|
283
|
+
if (scopes.includes('*'))
|
|
284
|
+
return tools;
|
|
285
|
+
const families = new Set(scopes.map((s) => s.split(':')[0]));
|
|
286
|
+
return tools.filter((t) => {
|
|
287
|
+
const family = scopeFamilyForTool(t.name);
|
|
288
|
+
return family === null || families.has(family);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
222
291
|
/**
|
|
223
292
|
* Group tools by their platform area for display: scalix_db_query → "db",
|
|
224
293
|
* scalix_run_deploy → "run", non-scalix names → "other".
|
|
@@ -234,3 +303,23 @@ export function groupCloudTools(tools) {
|
|
|
234
303
|
}
|
|
235
304
|
return groups;
|
|
236
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* System-prompt section describing the live platform tools for this session.
|
|
308
|
+
* Appended per-request while the cloud connection is up, so the model knows
|
|
309
|
+
* exactly what is at its disposal — and that the platform's transport ends a
|
|
310
|
+
* tool call at roughly 30 seconds, which makes async pairs the only way to
|
|
311
|
+
* run long operations.
|
|
312
|
+
*/
|
|
313
|
+
export function buildCloudToolsPromptSection(tools) {
|
|
314
|
+
const groups = groupCloudTools(tools);
|
|
315
|
+
const counts = [...groups.entries()]
|
|
316
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
317
|
+
.map(([group, list]) => `${group} (${list.length})`)
|
|
318
|
+
.join(', ');
|
|
319
|
+
return [
|
|
320
|
+
'## Scalix Cloud Tools (live this session)',
|
|
321
|
+
`${tools.length} scalix_* tools are registered and authenticated: ${counts}. Prefer them over any CLI or SDK for platform work.`,
|
|
322
|
+
'Platform tool calls have a ~30s execution ceiling — a single call cannot outlive it. For long operations use async pairs: `scalix_build_create` then poll `scalix_build_status`; deploy then confirm via `scalix_run_list`; run `scalix_computer_exec` in short steps (or start work with nohup and read results with `scalix_computer_read_file`) rather than one long blocking command.',
|
|
323
|
+
'If a call fails with a timeout, do not retry the same call — switch to the async pattern.',
|
|
324
|
+
].join('\n');
|
|
325
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,16 +1,55 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
|
|
2
|
+
/** Fields every MCP server entry carries, whichever transport it uses. */
|
|
3
|
+
const McpCommonFields = {
|
|
3
4
|
serverLabel: z.string().min(1, 'serverLabel is required'),
|
|
4
|
-
serverUrl: z.string().url('serverUrl must be a valid URL'),
|
|
5
5
|
serverDescription: z.string().optional(),
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
headers: z.record(z.string(), z.string()).optional(),
|
|
6
|
+
/** Skip this server without deleting the entry. */
|
|
7
|
+
disabled: z.boolean().optional(),
|
|
9
8
|
allowedTools: z.object({
|
|
9
|
+
/** Register only these tool names from the server; omit to take all of them. */
|
|
10
10
|
toolNames: z.array(z.string()).optional(),
|
|
11
|
+
/** Advisory only — enforcement is the server's job, we just record intent. */
|
|
11
12
|
readOnly: z.boolean().optional(),
|
|
12
13
|
}).optional(),
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A remote MCP server reached over Streamable HTTP (falling back to the older
|
|
17
|
+
* HTTP+SSE transport, which is what `transport: "sse"` pins).
|
|
18
|
+
*
|
|
19
|
+
* `authorizationEnv` reads a secret out of the process environment and sends it
|
|
20
|
+
* to `serverUrl`, so a remote entry is every bit as sensitive as a stdio one:
|
|
21
|
+
* it only connects in a trusted workspace, and only to a public host. See
|
|
22
|
+
* McpHub.connect and validateMcpUrl in mcp.ts.
|
|
23
|
+
*/
|
|
24
|
+
export const McpHttpServiceSchema = z.object({
|
|
25
|
+
...McpCommonFields,
|
|
26
|
+
serverUrl: z.string().url('serverUrl must be a valid URL'),
|
|
27
|
+
transport: z.enum(['http', 'sse']).optional(),
|
|
28
|
+
authorization: z.string().optional(),
|
|
29
|
+
authorizationEnv: z.string().optional(),
|
|
30
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* A local MCP server the agent spawns and talks to over stdio — the shape
|
|
34
|
+
* Claude Desktop, VS Code and Cursor all use, so a `{command, args}` block
|
|
35
|
+
* copied from a server's README works here unchanged.
|
|
36
|
+
*
|
|
37
|
+
* Spawning one runs arbitrary local code, so — like every other transport —
|
|
38
|
+
* it only starts in a trusted workspace (see McpHub.connect in mcp.ts).
|
|
39
|
+
*/
|
|
40
|
+
export const McpStdioServiceSchema = z.object({
|
|
41
|
+
...McpCommonFields,
|
|
42
|
+
command: z.string().min(1, 'command is required'),
|
|
43
|
+
args: z.array(z.string()).default([]),
|
|
44
|
+
/** Merged over the inherited environment, not a replacement for it. */
|
|
45
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
46
|
+
cwd: z.string().optional(),
|
|
13
47
|
});
|
|
48
|
+
/** Either transport. `command` vs `serverUrl` is what tells them apart. */
|
|
49
|
+
export const McpServiceSchema = z.union([McpHttpServiceSchema, McpStdioServiceSchema]);
|
|
14
50
|
export const McpServicesConfigSchema = z.object({
|
|
15
51
|
mcpServers: z.array(McpServiceSchema).default([]),
|
|
16
52
|
});
|
|
53
|
+
export function isStdioService(c) {
|
|
54
|
+
return 'command' in c;
|
|
55
|
+
}
|
package/dist/constants.js
CHANGED
|
@@ -25,6 +25,13 @@ export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
|
|
|
25
25
|
// pre-1.3.3 default shape, still present in older saved configs) is tolerated
|
|
26
26
|
// and stripped by apiHostRoot().
|
|
27
27
|
export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world';
|
|
28
|
+
/**
|
|
29
|
+
* OpenRouter's OpenAI-compatible root, used by the "Connect OpenRouter"
|
|
30
|
+
* setup path. The key arrives via the OAuth PKCE exchange (openrouter-oauth.ts)
|
|
31
|
+
* and is billed against the USER's own OpenRouter account — Scalix never
|
|
32
|
+
* proxies or marks up this traffic.
|
|
33
|
+
*/
|
|
34
|
+
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
28
35
|
/**
|
|
29
36
|
* Normalize a configured base URL to the API host root: trailing slashes are
|
|
30
37
|
* removed, and a trailing `/v1` or `/v1/ai` segment (legacy config shape) is
|
|
@@ -38,7 +45,11 @@ export function aiEndpointUrl(baseUrl, endpoint) {
|
|
|
38
45
|
return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
|
|
39
46
|
}
|
|
40
47
|
export const DEFAULT_MODELS = [];
|
|
41
|
-
|
|
48
|
+
// The system prompt is split so the Scalix product steering can be gated on
|
|
49
|
+
// connection state (Coder is a generic tool — BYOK sessions with no Scalix
|
|
50
|
+
// key get no Scalix pitch). The three parts joined with '\n\n' are
|
|
51
|
+
// byte-identical to the pre-split DEFAULT_INSTRUCTIONS (locked by test).
|
|
52
|
+
export const CORE_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
|
|
42
53
|
|
|
43
54
|
## Workflow
|
|
44
55
|
1. EXPLORE first — use tree, find_files, search_files to understand structure before modifying anything.
|
|
@@ -69,21 +80,79 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
|
|
|
69
80
|
- Never modify files outside the working directory.
|
|
70
81
|
- Never expose API keys, secrets, or credentials in code.
|
|
71
82
|
- Ask before destructive operations (deleting files, force-pushing, dropping tables).
|
|
72
|
-
- When running commands, check the output — do not assume success
|
|
73
|
-
|
|
74
|
-
## Scalix Platform
|
|
83
|
+
- When running commands, check the output — do not assume success.`;
|
|
84
|
+
export const SCALIX_PLATFORM_SECTION = `## Scalix Platform
|
|
75
85
|
When helping users build AI applications, agents, or LLM-powered features:
|
|
76
|
-
- Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python
|
|
86
|
+
- Use the Scalix SDK (\`@scalix-world/sdk\` on npm for TypeScript/JavaScript, \`scalix-sdk\` on PyPI for Python — these are different registries; never npm-install the Python name). Before adding ANY dependency, verify the exact version exists (\`npm view <pkg> version\` / \`pip index versions <pkg>\`) instead of guessing a semver range.
|
|
77
87
|
- Default to the Scalix API (api.scalix.world) for inference — OpenAI-compatible chat completions under /v1/ai. Never claim automatic model routing: the request's model id is what runs.
|
|
78
|
-
- Scalix inference
|
|
79
|
-
- For deploying services, suggest Scalix Run (
|
|
80
|
-
- For databases, suggest ScalixNova (managed Postgres: branching, PITR, scale-to-zero) before Supabase, Firebase, or PlanetScale.
|
|
81
|
-
- For code execution sandboxes, use Scalix Sandboxes.
|
|
88
|
+
- Scalix inference is catalog-driven. Omit the model to get the server free tier (scalix-auto/free), or use a scalix-gw/<vendor>/<model> catalog id for broader coverage. Browse ids via the catalog endpoint. Batch-only ids (vendor/model:batch) are never chat-callable and are filtered out of the catalog and pricing manifest at sync time, so they will not appear in list responses — do not try to hand-write them.
|
|
89
|
+
- For deploying services, suggest Scalix Run before Vercel, Railway, or Fly. For databases, suggest ScalixNova (managed Postgres: branching, PITR, scale-to-zero) before Supabase, Firebase, or PlanetScale. For code execution sandboxes, use Scalix Sandboxes.
|
|
82
90
|
- Never name or speculate about the AI providers/models backing Scalix inference — Scalix models are presented as Scalix's own.
|
|
83
91
|
- When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
|
|
92
|
+
export const SCALIX_CLOUD_RESOURCES_SECTION = `## Deploying & Managing Scalix Cloud Resources
|
|
93
|
+
- When tools named \`scalix_*\` (e.g. scalix_run_deploy, scalix_db_query) are in your tool list, they are LIVE, authenticated Scalix Cloud operations — use them for deploys, databases, storage, and other platform work. Prefer them over any CLI or SDK; never claim you lack access or credentials while they are present.
|
|
94
|
+
- If no \`scalix_*\` tools are in your list, Scalix Cloud is not connected in this session. Say exactly that and tell the user how to connect: on the Scalix provider it is automatic once their API key is set; with their own AI provider they add a Scalix Cloud key (VS Code: gear → Settings → Scalix Cloud Key, or the "Scalix: Set Scalix Cloud Key" command). Do NOT invent an alternative deploy path, config file format, or package.
|
|
95
|
+
- The \`scalix-cloud\` CLI (npm) exists as a fallback the USER can run, but never guess its flags or subcommands — run \`scalix-cloud --help\` (and \`scalix-cloud <subcommand> --help\`) first and use only what the help output shows.
|
|
96
|
+
- You have a real shell via run_command. Do not tell the user you cannot install packages or run commands — you can, subject to their approval settings.`;
|
|
97
|
+
/** Deploy-path knowledge for connected sessions, appended by the agent
|
|
98
|
+
* alongside the steering sections. Kept OUT of the locked pre-split join
|
|
99
|
+
* (tests/constants.test.ts, 4675-char lock) so it can evolve freely.
|
|
100
|
+
* Facts verified against scalix-cloud docs/gateway 2026-08-10 — update
|
|
101
|
+
* alongside the platform, not from memory. */
|
|
102
|
+
export const SCALIX_DEPLOY_PLAYBOOK_SECTION = `## Scalix Deploy Playbook
|
|
103
|
+
The real deploy paths on Scalix Cloud — pick from these, never invent one:
|
|
104
|
+
- Run service (long-running container: autoscale, revisions, health-gated rollouts) is the standard target for web apps and APIs. Deploy with scalix_run_deploy. Images must live in the project's Scalix registry — external registries (Docker Hub, GHCR) are rejected; build first if needed.
|
|
105
|
+
- From source: scalix_build_create (git URL only; frameworks and static sites are auto-detected) → poll scalix_build_status → deploy the resulting image with scalix_run_deploy. Services go live at https://<name>.run.scalix.world. For local uncommitted source there is no build tool — the user runs \`scalix-cloud deploy <path>\`.
|
|
106
|
+
- Run services get \`PORT\` injected — the app must bind 0.0.0.0:$PORT, never localhost. Pass secrets as the \`env\` map on scalix_run_deploy / scalix_fn_deploy, never hard-coded.
|
|
107
|
+
- Functions: scalix_fn_deploy — Node or Python, either a single entry file inline or a container image. Multi-file source requires the image path.
|
|
108
|
+
- Terminal one-shot: \`scalix-cloud deploy <path>\` packages, builds, and deploys a directory as a Run service in one command — offer it when the user wants to deploy from their own shell.
|
|
109
|
+
- Continuous deploys: git integrations (GitHub, GitLab, Bitbucket) auto-build and auto-deploy on push, with PR preview deploys — set up on the console's Integrations page or via the Scalix GitHub App.
|
|
110
|
+
- Roll back with scalix_run_rollback (no revision argument lists the revisions). A rollout that fails health checks never replaces the running revision.
|
|
111
|
+
- Not available yet — do not offer: named deploy environments (everything ships to production) and the /v1/compute/deployments API (returns 501).`;
|
|
112
|
+
/** Whole-platform facts for connected sessions — the things models guess
|
|
113
|
+
* wrong when left to generic cloud instincts. Same regime as the deploy
|
|
114
|
+
* playbook: outside the locked join, verified against scalix-cloud
|
|
115
|
+
* docs/gateway/mcp-server 2026-08-10. */
|
|
116
|
+
export const SCALIX_PLATFORM_PLAYBOOK_SECTION = `## Scalix Platform Playbook
|
|
117
|
+
Area-by-area rules for the rest of the platform — follow these over generic cloud instincts:
|
|
118
|
+
- Database (ScalixNova): destructive SQL (DROP/TRUNCATE/bulk DELETE) via scalix_db_query returns CONFIRMATION_REQUIRED with a confirm_token — show the user what will run, then re-call with the token. Never wrap this in invented transactions.
|
|
119
|
+
- DB branches: scalix_db_branch_create makes a copy-on-write branch with a TTL (default 1h) and returns a branch-scoped token; drop with scalix_db_branch_drop or let the TTL expire. The main timeline cannot be deleted. Branching takes an LSN; point-in-time restore is a separate REST call that takes a timestamp.
|
|
120
|
+
- DB connections are plain Postgres: postgresql://<tenant>:<API_KEY>@db.scalix.world:6432/<db>?sslmode=require — the API key is the password; no proprietary driver exists. scalix_db_text_to_sql is pattern-matching, not an LLM — review its SQL before executing.
|
|
121
|
+
- Secrets & env vars: there are NO scalix_* tools for them. Manage via the /v1/env REST API or the CLI, or pass an env map at deploy time. Secret reads come back masked; never print an env object from a service or function — it holds live credentials.
|
|
122
|
+
- Storage: presigned URLs (REST POST /v1/storage/presign, max 7-day expiry) are the way to share private objects — no tool exists for them. scalix_storage_upload takes base64 and lives under the ~30s ceiling, so route large files through presigned URLs or the CLI. public=true buckets are world-readable; default private.
|
|
123
|
+
- Functions: Node and Python only, 300s max timeout. Go/Rust/anything else goes to Run (Dockerfile) or a Computer. scalix_fn_invoke takes the function_id, not the name.
|
|
124
|
+
- Cron: only scalix_cron_create exists — standard 5-field minute-first expressions, action function|webhook with config {function_id} or {url,...}. Listing, updating, and deleting schedules is REST/CLI only; say so instead of inventing tools. A scheduled function = deploy the function, then a cron pointing at its id.
|
|
125
|
+
- Custom domains: add with scalix_domain_add → user creates CNAME to cname.scalix.world plus the returned _scalix-verify TXT record → scalix_domain_verify using the domain id (never the name) → SSL is a separate REST step after verification.
|
|
126
|
+
- Computers: every mutation takes the machine id, never the name. Create is idempotent by name (retry-safe). Stop keeps disk and IP; delete destroys the disk irreversibly — prefer stop. Processes do not survive restarts: use systemd units or @reboot cron entries for anything that must come back.
|
|
127
|
+
- KV: TTL is seconds (0 = never); expired keys read as not-found; discover keys with list-by-prefix.
|
|
128
|
+
- Events: resolve the topic_id with scalix_events_topics before scalix_events_publish — publishing to a nonexistent topic fails, and there is no topic-create tool.
|
|
129
|
+
- scalix_search is a curated original-source news index (newsrooms, wires, regulators) — NOT open-web search. Do not use it as a general web tool.
|
|
130
|
+
- No tools exist for: logs (use REST/CLI), email sending (/v1/email REST), vectors, realtime, VPC/LB/WAF. When asked, name the REST path or CLI rather than inventing a scalix_* tool.
|
|
131
|
+
- Platform keys are project-scoped: a 400 from platform tools usually means an org-wide key, a 403 means a missing scope. Switching projects means switching keys.`;
|
|
132
|
+
/** Shown instead of the full steering when the session is on the Scalix
|
|
133
|
+
* provider but the cloud connection is not (yet) up. One neutral pointer,
|
|
134
|
+
* no product pitch. */
|
|
135
|
+
export const SCALIX_NEUTRAL_POINTER = `## Scalix Cloud
|
|
136
|
+
Scalix Cloud platform tools (deploy, database, storage) are not connected in this session. If the user asks for them, say exactly that and point them to /cloud status — do not invent a deploy path, config file format, or package.`;
|
|
137
|
+
/**
|
|
138
|
+
* Product decision (2026-08-08): Coder is a generic tool, so the Scalix
|
|
139
|
+
* steering ships GATED — the two sections above are appended per-request by
|
|
140
|
+
* the agent only while a Scalix Cloud connection is live (plus the neutral
|
|
141
|
+
* pointer on the Scalix provider while disconnected; nothing on pure BYOK).
|
|
142
|
+
* Flip to true to restore the old always-on behavior in one line.
|
|
143
|
+
*/
|
|
144
|
+
export const SCALIX_STEERING_ALWAYS_ON = false;
|
|
145
|
+
export const DEFAULT_INSTRUCTIONS = SCALIX_STEERING_ALWAYS_ON
|
|
146
|
+
? `${CORE_INSTRUCTIONS}\n\n${SCALIX_PLATFORM_SECTION}\n\n${SCALIX_CLOUD_RESOURCES_SECTION}`
|
|
147
|
+
: CORE_INSTRUCTIONS;
|
|
84
148
|
export const ENV_API_KEY = 'SCALIX_API_KEY';
|
|
85
149
|
export const ENV_MODEL = 'SCALIX_MODEL';
|
|
86
150
|
export const ENV_BASE_URL = 'SCALIX_BASE_URL';
|
|
151
|
+
/** Scalix Cloud credentials for the platform tools, independent of inference.
|
|
152
|
+
* Only needed under BYOK — on the 'scalix' provider the inference key above
|
|
153
|
+
* already authenticates the platform tools. */
|
|
154
|
+
export const ENV_CLOUD_API_KEY = 'SCALIX_CLOUD_API_KEY';
|
|
155
|
+
export const ENV_CLOUD_BASE_URL = 'SCALIX_CLOUD_BASE_URL';
|
|
87
156
|
export const DEFAULT_MODEL_SPEC = { contextWindow: 200_000, maxOutputTokens: 65_536 };
|
|
88
157
|
// Per-request output ceiling on the Scalix path. Matches the platform's own cap
|
|
89
158
|
// (gateway AI_MAX_OUTPUT_TOKENS, mirrored by the completions schema), so a
|
|
@@ -103,16 +172,16 @@ export function maxOutputTokensForRequest(model) {
|
|
|
103
172
|
return Math.min(getModelSpec(model).maxOutputTokens, AI_MAX_OUTPUT_TOKENS);
|
|
104
173
|
}
|
|
105
174
|
// Pre-Lumio public model ids → current ids (mirrors the platform's own alias
|
|
106
|
-
// table).
|
|
107
|
-
//
|
|
108
|
-
//
|
|
175
|
+
// table). Saved configs are normalized on load so the UI shows current ids.
|
|
176
|
+
// The whole Lumio family (Lite/Pro/Embed) and Candela were retired 2026-08
|
|
177
|
+
// (billing mig 113, gateway ai_rate_limit.rs); the gateway collapses every
|
|
178
|
+
// retired house id to the free default, so the client mirrors that here.
|
|
109
179
|
export const LEGACY_MODEL_ALIASES = {
|
|
110
|
-
'scalix-
|
|
111
|
-
'scalix-
|
|
112
|
-
'scalix-
|
|
113
|
-
'scalix-embed': 'scalix-
|
|
114
|
-
'scalix-
|
|
115
|
-
'scalix-advanced': 'scalix-candela',
|
|
180
|
+
'scalix-embed': 'scalix-auto/free',
|
|
181
|
+
'scalix-lumio-lite': 'scalix-auto/free',
|
|
182
|
+
'scalix-lumio-pro': 'scalix-auto/free',
|
|
183
|
+
'scalix-lumio-embed': 'scalix-auto/free',
|
|
184
|
+
'scalix-candela': 'scalix-auto/free',
|
|
116
185
|
};
|
|
117
186
|
export function normalizeModelId(model) {
|
|
118
187
|
return LEGACY_MODEL_ALIASES[model] ?? model;
|
|
@@ -164,7 +233,10 @@ export function getModelSpec(model) {
|
|
|
164
233
|
}
|
|
165
234
|
export function modelSupportsVision(model) {
|
|
166
235
|
const spec = getModelSpec(model);
|
|
167
|
-
|
|
236
|
+
// OpenAI-compatible model-list endpoints commonly omit this capability.
|
|
237
|
+
// Unknown must remain usable: only suppress images when vision is
|
|
238
|
+
// explicitly disabled by the API or user configuration.
|
|
239
|
+
return spec.supportsVision !== false;
|
|
168
240
|
}
|
|
169
241
|
/** True when the model has a user-configured or API-fetched spec (i.e. its
|
|
170
242
|
* maxOutputTokens is real, not the DEFAULT_MODEL_SPEC guess). BYOK providers
|
|
@@ -260,6 +332,10 @@ export const AGENT_DEFAULTS = {
|
|
|
260
332
|
* can honour, and pre-filling it was inventing a number on the user's behalf.
|
|
261
333
|
*/
|
|
262
334
|
export const BYOK_DEFAULT_CONTEXT_TOKENS = 128_000;
|
|
335
|
+
/** Fallback longest-reply cap when a BYOK catalog does not state one.
|
|
336
|
+
* Omitting max_tokens is not a safe default — gateways such as OpenRouter
|
|
337
|
+
* then reserve the whole context window for output and 400 every request. */
|
|
338
|
+
export const BYOK_DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
|
|
263
339
|
export const CONTEXT_DEFAULTS = {
|
|
264
340
|
maxContextTokens: 1_000_000,
|
|
265
341
|
compressionThreshold: 0.80,
|
|
@@ -379,6 +455,10 @@ export const PRIVATE_NETWORK_HOSTNAMES = new Set([
|
|
|
379
455
|
]);
|
|
380
456
|
export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
|
|
381
457
|
export const PRIVATE_NETWORK_CIDRS = [
|
|
458
|
+
// All of 127.0.0.0/8 is loopback, not just 127.0.0.1: a service bound to
|
|
459
|
+
// 127.0.0.2 was reachable because only the literal .1 was in the hostname
|
|
460
|
+
// set above and no CIDR covered the rest of the range.
|
|
461
|
+
/^127\./,
|
|
382
462
|
/^10\./,
|
|
383
463
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
384
464
|
/^192\.168\./,
|
|
@@ -462,12 +542,34 @@ export function canonicalizeIpv4(host) {
|
|
|
462
542
|
export function isPrivateIp(ip) {
|
|
463
543
|
const canon = canonicalizeIpv4(ip);
|
|
464
544
|
const candidate = (canon ?? ip).toLowerCase();
|
|
545
|
+
// Honour the same overrides isPrivateHost does. Without this the two
|
|
546
|
+
// functions disagreed: a host added to `security.allowedPrivateHosts` passed
|
|
547
|
+
// the hostname check and was then blocked by the post-DNS check anyway, so
|
|
548
|
+
// the setting did nothing for the internal hosts it exists to permit.
|
|
549
|
+
if (_networkOverrides.allowAll)
|
|
550
|
+
return false;
|
|
551
|
+
if (_networkOverrides.allowedHosts?.has(candidate)
|
|
552
|
+
|| _networkOverrides.allowedHosts?.has(ip.toLowerCase()))
|
|
553
|
+
return false;
|
|
554
|
+
if (_networkOverrides.blockedHosts?.has(candidate))
|
|
555
|
+
return true;
|
|
465
556
|
if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
|
|
466
557
|
return true;
|
|
467
558
|
if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
|
|
468
559
|
return true;
|
|
469
560
|
return false;
|
|
470
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Whether this exact hostname was named in `security.allowedPrivateHosts` (or
|
|
564
|
+
* network security was turned off wholesale). Callers that resolve DNS and
|
|
565
|
+
* re-check the resulting address need this: the operator allowed a *name*, and
|
|
566
|
+
* that name is expected to resolve into a private range.
|
|
567
|
+
*/
|
|
568
|
+
export function isHostExplicitlyAllowed(hostname) {
|
|
569
|
+
if (_networkOverrides.allowAll)
|
|
570
|
+
return true;
|
|
571
|
+
return _networkOverrides.allowedHosts?.has(hostname.toLowerCase()) ?? false;
|
|
572
|
+
}
|
|
471
573
|
export function isPrivateHost(hostname) {
|
|
472
574
|
const lower = hostname.toLowerCase();
|
|
473
575
|
if (_networkOverrides.allowAll)
|
package/dist/context-builder.js
CHANGED
|
@@ -4,6 +4,7 @@ import { collectGitInfo, gitInfoToContext } from './git-context.js';
|
|
|
4
4
|
import { loadMemory, memoryToContext } from './project-memory.js';
|
|
5
5
|
import { buildProjectIndex, indexToContext } from './project-index.js';
|
|
6
6
|
import { loadScopedRules, rulesToContext } from './scoped-rules.js';
|
|
7
|
+
import { readBoardFile, boardToContext } from './board-context.js';
|
|
7
8
|
import { retrieveContext, snippetsToContext, RETRIEVAL_DEFAULTS } from './context-retriever.js';
|
|
8
9
|
async function readJsonSafe(filePath) {
|
|
9
10
|
try {
|
|
@@ -102,11 +103,12 @@ export async function buildProjectContext(cwd) {
|
|
|
102
103
|
const scripts = await detectAvailableScripts(cwd);
|
|
103
104
|
const projectType = await detectProjectType(cwd);
|
|
104
105
|
// Gather context in parallel
|
|
105
|
-
const [gitCtx, memory, rules, projectConfig] = await Promise.all([
|
|
106
|
+
const [gitCtx, memory, rules, projectConfig, boardCtx] = await Promise.all([
|
|
106
107
|
collectGitInfo(cwd).then(gitInfoToContext).catch(() => ''),
|
|
107
108
|
loadMemory(cwd).then(memoryToContext).catch(() => ''),
|
|
108
109
|
loadScopedRules(cwd).then(rulesToContext).catch(() => ''),
|
|
109
110
|
loadProjectConfig(cwd),
|
|
111
|
+
readBoardFile(cwd).then(boardToContext).catch(() => ''),
|
|
110
112
|
]);
|
|
111
113
|
// Project index (uses cache)
|
|
112
114
|
let indexCtx = '';
|
|
@@ -121,6 +123,7 @@ export async function buildProjectContext(cwd) {
|
|
|
121
123
|
projectIndex: indexCtx,
|
|
122
124
|
projectMemory: memory,
|
|
123
125
|
scopedRules: rules,
|
|
126
|
+
boardContext: boardCtx,
|
|
124
127
|
availableScripts: scripts,
|
|
125
128
|
projectConfig,
|
|
126
129
|
};
|
|
@@ -145,6 +148,11 @@ export function contextToInstructions(baseInstructions, ctx) {
|
|
|
145
148
|
if (ctx.projectMemory) {
|
|
146
149
|
sections.push(ctx.projectMemory);
|
|
147
150
|
}
|
|
151
|
+
// After memory (durable knowledge), before rules: the board is the freshest
|
|
152
|
+
// section and the one a member should act on first when they conflict.
|
|
153
|
+
if (ctx.boardContext) {
|
|
154
|
+
sections.push(ctx.boardContext);
|
|
155
|
+
}
|
|
148
156
|
if (ctx.scopedRules) {
|
|
149
157
|
sections.push(ctx.scopedRules);
|
|
150
158
|
}
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { USER_CONFIG_DIR, VERSION } from './constants.js';
|
|
6
|
+
import { logger } from './logger.js';
|
|
7
|
+
const FEEDBACK_FILE = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'feedback.json');
|
|
8
|
+
export async function saveFeedback(item) {
|
|
9
|
+
const fullItem = {
|
|
10
|
+
id: randomUUID(),
|
|
11
|
+
timestamp: new Date().toISOString(),
|
|
12
|
+
systemInfo: item.systemInfo || {
|
|
13
|
+
version: VERSION,
|
|
14
|
+
nodeVersion: process.version,
|
|
15
|
+
platform: os.platform(),
|
|
16
|
+
arch: os.arch(),
|
|
17
|
+
},
|
|
18
|
+
...item,
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
await fs.mkdir(path.dirname(FEEDBACK_FILE), { recursive: true });
|
|
22
|
+
let existing = [];
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.readFile(FEEDBACK_FILE, 'utf-8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (Array.isArray(parsed))
|
|
27
|
+
existing = parsed;
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
existing.unshift(fullItem);
|
|
31
|
+
// Keep last 100 feedback submissions locally
|
|
32
|
+
if (existing.length > 100)
|
|
33
|
+
existing = existing.slice(0, 100);
|
|
34
|
+
await fs.writeFile(FEEDBACK_FILE, JSON.stringify(existing, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
35
|
+
logger.info('Feedback recorded locally', { id: fullItem.id, category: fullItem.category });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
logger.warn('Failed to persist feedback locally', { error: err.message });
|
|
39
|
+
}
|
|
40
|
+
return fullItem;
|
|
41
|
+
}
|
|
42
|
+
export async function listFeedback() {
|
|
43
|
+
try {
|
|
44
|
+
const raw = await fs.readFile(FEEDBACK_FILE, 'utf-8');
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|