scalix 1.4.1 → 1.5.1
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 +60 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +319 -26
- package/dist/api-client.js +159 -26
- package/dist/cli.js +671 -252
- package/dist/cloud.js +94 -5
- package/dist/constants.js +96 -10
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +44 -20
- package/dist/ipc-server.js +63 -12
- package/dist/keychain.js +46 -22
- package/dist/markdown.js +18 -1
- package/dist/providers/index.js +31 -0
- package/dist/providers/openai.js +149 -0
- package/dist/settings.js +117 -8
- package/dist/slash-commands.js +148 -0
- package/dist/sub-agent.js +1 -0
- package/package.json +13 -3
- package/PARITY-PLAN.md +0 -116
- package/docs/DISTRIBUTION.md +0 -301
package/dist/cloud.js
CHANGED
|
@@ -18,8 +18,12 @@ import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
|
|
|
18
18
|
export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
|
|
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/constants.js
CHANGED
|
@@ -38,7 +38,11 @@ export function aiEndpointUrl(baseUrl, endpoint) {
|
|
|
38
38
|
return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
|
|
39
39
|
}
|
|
40
40
|
export const DEFAULT_MODELS = [];
|
|
41
|
-
|
|
41
|
+
// The system prompt is split so the Scalix product steering can be gated on
|
|
42
|
+
// connection state (Coder is a generic tool — BYOK sessions with no Scalix
|
|
43
|
+
// key get no Scalix pitch). The three parts joined with '\n\n' are
|
|
44
|
+
// byte-identical to the pre-split DEFAULT_INSTRUCTIONS (locked by test).
|
|
45
|
+
export const CORE_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
|
|
42
46
|
|
|
43
47
|
## Workflow
|
|
44
48
|
1. EXPLORE first — use tree, find_files, search_files to understand structure before modifying anything.
|
|
@@ -69,21 +73,77 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
|
|
|
69
73
|
- Never modify files outside the working directory.
|
|
70
74
|
- Never expose API keys, secrets, or credentials in code.
|
|
71
75
|
- 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
|
|
76
|
+
- When running commands, check the output — do not assume success.`;
|
|
77
|
+
export const SCALIX_PLATFORM_SECTION = `## Scalix Platform
|
|
75
78
|
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
|
|
77
|
-
- Default to Scalix API (api.scalix.world) for inference —
|
|
78
|
-
- Scalix inference models are
|
|
79
|
-
- For
|
|
80
|
-
-
|
|
81
|
-
- For code execution sandboxes, use Scalix Sandbox.
|
|
79
|
+
- 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.
|
|
80
|
+
- 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.
|
|
81
|
+
- Scalix inference models are the scalix-lumio-* family; use exact ids (scalix-lumio-lite, scalix-lumio-pro, scalix-candela, scalix-lumio-embed) in examples and configs. Omitting the model runs scalix-lumio-lite.
|
|
82
|
+
- 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.
|
|
83
|
+
- Never name or speculate about the AI providers/models backing Scalix inference — Scalix models are presented as Scalix's own.
|
|
82
84
|
- When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
|
|
85
|
+
export const SCALIX_CLOUD_RESOURCES_SECTION = `## Deploying & Managing Scalix Cloud Resources
|
|
86
|
+
- 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.
|
|
87
|
+
- 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.
|
|
88
|
+
- 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.
|
|
89
|
+
- 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.`;
|
|
90
|
+
/** Shown instead of the full steering when the session is on the Scalix
|
|
91
|
+
* provider but the cloud connection is not (yet) up. One neutral pointer,
|
|
92
|
+
* no product pitch. */
|
|
93
|
+
export const SCALIX_NEUTRAL_POINTER = `## Scalix Cloud
|
|
94
|
+
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.`;
|
|
95
|
+
/**
|
|
96
|
+
* Product decision (2026-08-08): Coder is a generic tool, so the Scalix
|
|
97
|
+
* steering ships GATED — the two sections above are appended per-request by
|
|
98
|
+
* the agent only while a Scalix Cloud connection is live (plus the neutral
|
|
99
|
+
* pointer on the Scalix provider while disconnected; nothing on pure BYOK).
|
|
100
|
+
* Flip to true to restore the old always-on behavior in one line.
|
|
101
|
+
*/
|
|
102
|
+
export const SCALIX_STEERING_ALWAYS_ON = false;
|
|
103
|
+
export const DEFAULT_INSTRUCTIONS = SCALIX_STEERING_ALWAYS_ON
|
|
104
|
+
? `${CORE_INSTRUCTIONS}\n\n${SCALIX_PLATFORM_SECTION}\n\n${SCALIX_CLOUD_RESOURCES_SECTION}`
|
|
105
|
+
: CORE_INSTRUCTIONS;
|
|
83
106
|
export const ENV_API_KEY = 'SCALIX_API_KEY';
|
|
84
107
|
export const ENV_MODEL = 'SCALIX_MODEL';
|
|
85
108
|
export const ENV_BASE_URL = 'SCALIX_BASE_URL';
|
|
109
|
+
/** Scalix Cloud credentials for the platform tools, independent of inference.
|
|
110
|
+
* Only needed under BYOK — on the 'scalix' provider the inference key above
|
|
111
|
+
* already authenticates the platform tools. */
|
|
112
|
+
export const ENV_CLOUD_API_KEY = 'SCALIX_CLOUD_API_KEY';
|
|
113
|
+
export const ENV_CLOUD_BASE_URL = 'SCALIX_CLOUD_BASE_URL';
|
|
86
114
|
export const DEFAULT_MODEL_SPEC = { contextWindow: 200_000, maxOutputTokens: 65_536 };
|
|
115
|
+
// Per-request output ceiling on the Scalix path. Matches the platform's own cap
|
|
116
|
+
// (gateway AI_MAX_OUTPUT_TOKENS, mirrored by the completions schema), so a
|
|
117
|
+
// single turn can emit a whole file instead of stopping mid-function. The
|
|
118
|
+
// client must always send an explicit value: omitting it lets the platform
|
|
119
|
+
// apply its own, much smaller, default. Clamped against the model spec via
|
|
120
|
+
// maxOutputTokensForRequest().
|
|
121
|
+
export const AI_MAX_OUTPUT_TOKENS = 16_384;
|
|
122
|
+
// What older deployments accept. Their completions schema hard-rejects (422)
|
|
123
|
+
// anything above this, and a published CLI has no say in which router version
|
|
124
|
+
// it is pointed at — so ScalixClient asks for the current ceiling and falls
|
|
125
|
+
// back to this one for the rest of the session if the endpoint rejects it.
|
|
126
|
+
export const AI_LEGACY_MAX_OUTPUT_TOKENS = 4_000;
|
|
127
|
+
/** The max_tokens value to send for a model: its spec ceiling, clamped to the
|
|
128
|
+
* platform's per-request cap. */
|
|
129
|
+
export function maxOutputTokensForRequest(model) {
|
|
130
|
+
return Math.min(getModelSpec(model).maxOutputTokens, AI_MAX_OUTPUT_TOKENS);
|
|
131
|
+
}
|
|
132
|
+
// Pre-Lumio public model ids → current ids (mirrors the platform's own alias
|
|
133
|
+
// table). The gateway still accepts the old names, but saved configs are
|
|
134
|
+
// normalized on load so the UI shows current ids and nothing breaks if the
|
|
135
|
+
// aliases are ever retired server-side.
|
|
136
|
+
export const LEGACY_MODEL_ALIASES = {
|
|
137
|
+
'scalix-lite': 'scalix-lumio-lite',
|
|
138
|
+
'scalix-balanced': 'scalix-lumio-pro',
|
|
139
|
+
'scalix-max': 'scalix-candela',
|
|
140
|
+
'scalix-embed': 'scalix-lumio-embed',
|
|
141
|
+
'scalix-world-ai': 'scalix-lumio-pro',
|
|
142
|
+
'scalix-advanced': 'scalix-candela',
|
|
143
|
+
};
|
|
144
|
+
export function normalizeModelId(model) {
|
|
145
|
+
return LEGACY_MODEL_ALIASES[model] ?? model;
|
|
146
|
+
}
|
|
87
147
|
// Runtime model registry: user-defined > API-fetched > default
|
|
88
148
|
// All model specs come from user config or API — no hardcoded third-party models
|
|
89
149
|
const userModels = {};
|
|
@@ -133,6 +193,15 @@ export function modelSupportsVision(model) {
|
|
|
133
193
|
const spec = getModelSpec(model);
|
|
134
194
|
return spec.supportsVision ?? false;
|
|
135
195
|
}
|
|
196
|
+
/** True when the model has a user-configured or API-fetched spec (i.e. its
|
|
197
|
+
* maxOutputTokens is real, not the DEFAULT_MODEL_SPEC guess). BYOK providers
|
|
198
|
+
* only send max_tokens for explicitly-specced models. */
|
|
199
|
+
export function hasExplicitModelSpec(model) {
|
|
200
|
+
if (model in userModels || model in fetchedModels)
|
|
201
|
+
return true;
|
|
202
|
+
const base = model.split('/').pop() || model;
|
|
203
|
+
return base !== model && (base in userModels || base in fetchedModels);
|
|
204
|
+
}
|
|
136
205
|
export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
137
206
|
if (!apiKey)
|
|
138
207
|
return [];
|
|
@@ -205,6 +274,23 @@ export const AGENT_DEFAULTS = {
|
|
|
205
274
|
maxMessages: 500,
|
|
206
275
|
};
|
|
207
276
|
// ─── Context Defaults ───
|
|
277
|
+
/**
|
|
278
|
+
* Context window the setup wizard assumes for a BYOK OpenAI-compatible
|
|
279
|
+
* endpoint.
|
|
280
|
+
*
|
|
281
|
+
* A third-party endpoint doesn't tell us its context window, and the two ways
|
|
282
|
+
* of guessing wrong are not symmetric: too high means oversized requests the
|
|
283
|
+
* endpoint rejects outright mid-session, too low only means the agent
|
|
284
|
+
* compresses earlier than it strictly had to. So the wizard pre-fills a
|
|
285
|
+
* conservative figure and asks the user to raise it. 1_000_000 — the Scalix
|
|
286
|
+
* Cloud default below — is a limit almost no self-hosted or third-party model
|
|
287
|
+
* can honour, and pre-filling it was inventing a number on the user's behalf.
|
|
288
|
+
*/
|
|
289
|
+
export const BYOK_DEFAULT_CONTEXT_TOKENS = 128_000;
|
|
290
|
+
/** Fallback longest-reply cap when a BYOK catalog does not state one.
|
|
291
|
+
* Omitting max_tokens is not a safe default — gateways such as OpenRouter
|
|
292
|
+
* then reserve the whole context window for output and 400 every request. */
|
|
293
|
+
export const BYOK_DEFAULT_MAX_OUTPUT_TOKENS = 16_384;
|
|
208
294
|
export const CONTEXT_DEFAULTS = {
|
|
209
295
|
maxContextTokens: 1_000_000,
|
|
210
296
|
compressionThreshold: 0.80,
|
package/dist/cost-tracker.js
CHANGED
|
@@ -3,11 +3,18 @@ export class CostTracker {
|
|
|
3
3
|
sessionStart = Date.now();
|
|
4
4
|
restoredRequestCount = 0;
|
|
5
5
|
record(model, usage) {
|
|
6
|
+
const promptTokens = usage.prompt_tokens ?? 0;
|
|
7
|
+
const completionTokens = usage.completion_tokens ?? 0;
|
|
6
8
|
this.entries.push({
|
|
7
9
|
model,
|
|
8
|
-
promptTokens
|
|
9
|
-
completionTokens
|
|
10
|
-
|
|
10
|
+
promptTokens,
|
|
11
|
+
completionTokens,
|
|
12
|
+
// total_tokens is optional in practice: several OpenAI-compatible
|
|
13
|
+
// servers send only the two halves. The agent derives it before
|
|
14
|
+
// emitting 'usage', so the TUI path is covered either way — but this is
|
|
15
|
+
// a public API, and `?? 0` made a direct caller's totals silently zero
|
|
16
|
+
// while the split beside them was non-zero. Sum what we were given.
|
|
17
|
+
totalTokens: usage.total_tokens ?? promptTokens + completionTokens,
|
|
11
18
|
cost: usage.cost ?? 0,
|
|
12
19
|
timestamp: Date.now(),
|
|
13
20
|
});
|
|
@@ -38,11 +45,42 @@ export class CostTracker {
|
|
|
38
45
|
return `${Math.floor(s / 60)}m${s % 60}s`;
|
|
39
46
|
return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
|
|
40
47
|
}
|
|
41
|
-
|
|
48
|
+
/**
|
|
49
|
+
* @param provider Which endpoint the session ran against. Only used to say
|
|
50
|
+
* where the authoritative charge lives when no cost was reported — the two
|
|
51
|
+
* answers are completely different, and pointing a BYOK user at the Scalix
|
|
52
|
+
* console would be wrong.
|
|
53
|
+
*/
|
|
54
|
+
summary(provider) {
|
|
55
|
+
// A resumed session carries its predecessor's total but not the
|
|
56
|
+
// prompt/completion split, so the naive line read "Tokens: 12,000 (0
|
|
57
|
+
// prompt + 0 completion)" — three numbers that contradict each other.
|
|
58
|
+
// Account for the difference instead of printing zeros.
|
|
59
|
+
const split = this.promptTokens + this.completionTokens;
|
|
60
|
+
const carried = this.totalTokens - split;
|
|
61
|
+
const breakdown = split > 0
|
|
62
|
+
? `${this.promptTokens.toLocaleString()} prompt + ${this.completionTokens.toLocaleString()} completion${carried > 0 ? ` + ${carried.toLocaleString()} carried over` : ''}`
|
|
63
|
+
: 'carried over from a resumed session';
|
|
42
64
|
const lines = [
|
|
43
65
|
`Session: ${this.sessionDuration} | ${this.requestCount} requests`,
|
|
44
|
-
`Tokens: ${this.totalTokens.toLocaleString()}
|
|
66
|
+
`Tokens: ${this.totalTokens.toLocaleString()}${this.totalTokens > 0 ? ` (${breakdown})` : ''}`,
|
|
45
67
|
];
|
|
68
|
+
// Shown only when the platform actually reported cost (x-scalix-cost-cents
|
|
69
|
+
// on non-streaming responses) — never a fabricated figure. It's the
|
|
70
|
+
// platform's estimate; the authoritative charge is computed at billing.
|
|
71
|
+
if (this.totalCost > 0) {
|
|
72
|
+
lines.push(`Est. cost: $${this.totalCost.toFixed(4)} (platform estimate — authoritative usage: console.scalix.world → Billing)`);
|
|
73
|
+
}
|
|
74
|
+
else if (this.totalTokens > 0) {
|
|
75
|
+
// A session that used tokens but got no cost header used to print no cost
|
|
76
|
+
// line at all, which reads as "this was free". It wasn't — the gateway
|
|
77
|
+
// only attaches x-scalix-cost-cents to non-streaming responses (the
|
|
78
|
+
// headers are already flushed by the time a stream's usage frame
|
|
79
|
+
// arrives), so a streamed session simply never learns the figure.
|
|
80
|
+
lines.push(provider === 'openai'
|
|
81
|
+
? 'Est. cost: not reported by your endpoint — check your provider\'s billing dashboard.'
|
|
82
|
+
: 'Est. cost: not reported on streamed turns — see console.scalix.world → Billing.');
|
|
83
|
+
}
|
|
46
84
|
const byModel = new Map();
|
|
47
85
|
for (const e of this.entries) {
|
|
48
86
|
if (e.model === '(previous session)')
|
package/dist/headless.js
CHANGED
|
@@ -13,8 +13,8 @@ import { loadMcpServices } from './mcp.js';
|
|
|
13
13
|
import { costTracker } from './cost-tracker.js';
|
|
14
14
|
import { saveSession, loadSession, listSessions, generateSessionId } from './sessions.js';
|
|
15
15
|
import { logger } from './logger.js';
|
|
16
|
-
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS,
|
|
17
|
-
import { loadConfig, DEFAULT_LIMITS } from './settings.js';
|
|
16
|
+
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, HEADLESS_DEFAULTS, fetchModelSpecs, normalizeModelId } from './constants.js';
|
|
17
|
+
import { loadConfig, loadGlobalConfig, saveConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
|
|
18
18
|
import { checkForUpdate, getUpdateBanner } from './update-checker.js';
|
|
19
19
|
import { getModeDescription, PERMISSION_MODES, setSandboxRoot } from './sandbox.js';
|
|
20
20
|
import { collectGitInfo, gitInfoToContext } from './git-context.js';
|
|
@@ -133,16 +133,20 @@ async function buildEnhancedInstructions() {
|
|
|
133
133
|
return contextToInstructions(base, ctx);
|
|
134
134
|
}
|
|
135
135
|
async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeExit) {
|
|
136
|
-
const
|
|
136
|
+
const config = await loadConfig();
|
|
137
|
+
const active = resolveActiveProvider(config);
|
|
138
|
+
const apiKey = active.apiKey;
|
|
137
139
|
if (!apiKey) {
|
|
138
|
-
console.error(
|
|
140
|
+
console.error(active.provider === 'openai'
|
|
141
|
+
? 'Error: no API key for the openai provider.\nSet it via: export OPENAI_API_KEY=sk-...\nOr run the interactive setup: scalix'
|
|
142
|
+
: 'Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=scalix_sk_...\nOr run the interactive setup: scalix');
|
|
139
143
|
process.exit(1);
|
|
140
144
|
}
|
|
141
|
-
const config = await loadConfig();
|
|
142
145
|
const limits = config.limits || DEFAULT_LIMITS;
|
|
143
|
-
const baseUrl =
|
|
144
|
-
// Fetch model specs from API (non-blocking —
|
|
145
|
-
|
|
146
|
+
const baseUrl = active.baseUrl;
|
|
147
|
+
// Fetch model specs from API (non-blocking — Scalix-shaped endpoint only)
|
|
148
|
+
if (active.provider === 'scalix')
|
|
149
|
+
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
146
150
|
if (!process.env.NO_UPDATE_NOTIFIER && !config.disableUpdateCheck) {
|
|
147
151
|
checkForUpdate().then(() => {
|
|
148
152
|
const banner = getUpdateBanner();
|
|
@@ -151,7 +155,7 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
|
|
|
151
155
|
}).catch(() => { });
|
|
152
156
|
}
|
|
153
157
|
const envModels = process.env[ENV_MODEL]
|
|
154
|
-
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
|
|
158
|
+
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
|
|
155
159
|
: [];
|
|
156
160
|
const selectedModel = envModels.length > 0 ? envModels
|
|
157
161
|
: config.models.length > 0 ? config.models : DEFAULT_MODELS;
|
|
@@ -189,6 +193,9 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
|
|
|
189
193
|
const agent = createAgent({
|
|
190
194
|
apiKey,
|
|
191
195
|
baseUrl,
|
|
196
|
+
provider: active.provider,
|
|
197
|
+
cloudApiKey: active.cloudApiKey,
|
|
198
|
+
cloudBaseUrl: active.cloudBaseUrl,
|
|
192
199
|
model: selectedModel,
|
|
193
200
|
instructions,
|
|
194
201
|
tools: allTools,
|
|
@@ -211,7 +218,8 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
|
|
|
211
218
|
// is stated once and the run continues with local tools only).
|
|
212
219
|
const cloudStatus = await agent.refreshCloudTools();
|
|
213
220
|
if (cloudStatus.state === 'unavailable') {
|
|
214
|
-
|
|
221
|
+
const remedy = cloudStatus.remedy ? ` ${cloudStatus.remedy}` : '';
|
|
222
|
+
process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}${remedy}\x1b[0m\n`);
|
|
215
223
|
}
|
|
216
224
|
let fullResponse = '';
|
|
217
225
|
agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
|
|
@@ -300,17 +308,21 @@ async function runPipeMode(beforeExit) {
|
|
|
300
308
|
await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
|
|
301
309
|
}
|
|
302
310
|
async function runInteractive() {
|
|
303
|
-
const
|
|
311
|
+
const config = await loadConfig();
|
|
312
|
+
const active = resolveActiveProvider(config);
|
|
313
|
+
const apiKey = active.apiKey;
|
|
304
314
|
if (!apiKey) {
|
|
305
|
-
throw new Error(
|
|
315
|
+
throw new Error(active.provider === 'openai'
|
|
316
|
+
? 'No API key for the openai provider. Set it via: export OPENAI_API_KEY=sk-...'
|
|
317
|
+
: 'SCALIX_API_KEY is not set. Set it via: export SCALIX_API_KEY=scalix_sk_...');
|
|
306
318
|
}
|
|
307
|
-
const config = await loadConfig();
|
|
308
319
|
const limits = config.limits || DEFAULT_LIMITS;
|
|
309
|
-
const baseUrl =
|
|
310
|
-
// Fetch model specs from API (non-blocking)
|
|
311
|
-
|
|
320
|
+
const baseUrl = active.baseUrl;
|
|
321
|
+
// Fetch model specs from API (non-blocking, Scalix-shaped endpoint only)
|
|
322
|
+
if (active.provider === 'scalix')
|
|
323
|
+
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
312
324
|
const envModels = process.env[ENV_MODEL]
|
|
313
|
-
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
|
|
325
|
+
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean).map(normalizeModelId)
|
|
314
326
|
: [];
|
|
315
327
|
const selectedModel = envModels.length > 0 ? envModels
|
|
316
328
|
: config.models.length > 0 ? config.models : DEFAULT_MODELS;
|
|
@@ -345,6 +357,9 @@ async function runInteractive() {
|
|
|
345
357
|
const agent = createAgent({
|
|
346
358
|
apiKey,
|
|
347
359
|
baseUrl,
|
|
360
|
+
provider: active.provider,
|
|
361
|
+
cloudApiKey: active.cloudApiKey,
|
|
362
|
+
cloudBaseUrl: active.cloudBaseUrl,
|
|
348
363
|
model: selectedModel,
|
|
349
364
|
instructions,
|
|
350
365
|
tools: allTools,
|
|
@@ -402,7 +417,8 @@ async function runInteractive() {
|
|
|
402
417
|
// is stated once and the session continues with local tools only).
|
|
403
418
|
const cloudStatus = await agent.refreshCloudTools();
|
|
404
419
|
if (cloudStatus.state === 'unavailable') {
|
|
405
|
-
|
|
420
|
+
const remedy = cloudStatus.remedy ? ` ${cloudStatus.remedy}` : '';
|
|
421
|
+
console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}${remedy}\x1b[0m`);
|
|
406
422
|
}
|
|
407
423
|
const toolCount = allTools.length + cloudStatus.toolCount;
|
|
408
424
|
const modelDisplay = selectedModel.map(shortModel).join(' | ');
|
|
@@ -492,8 +508,16 @@ async function runInteractive() {
|
|
|
492
508
|
console.log(`\nInvalid model name: "${name}"\n`);
|
|
493
509
|
return true;
|
|
494
510
|
}
|
|
495
|
-
|
|
496
|
-
|
|
511
|
+
const chosen = normalizeModelId(name);
|
|
512
|
+
agent.setModel(chosen);
|
|
513
|
+
// Persist like the TUI does — an in-memory-only switch silently
|
|
514
|
+
// reverted on the next launch. Global config only (no project-override
|
|
515
|
+
// laundering), chain-preserving (chosen model to the front).
|
|
516
|
+
loadGlobalConfig().then((cfg) => {
|
|
517
|
+
cfg.models = [chosen, ...cfg.models.filter((m) => m !== chosen)];
|
|
518
|
+
return saveConfig(cfg);
|
|
519
|
+
}).catch(() => { });
|
|
520
|
+
console.log(`\nSwitched to ${shortModel(chosen)}${process.env[ENV_MODEL] ? '\n(note: SCALIX_MODEL is set and will override this on next launch)' : ''}\n`);
|
|
497
521
|
return true;
|
|
498
522
|
}
|
|
499
523
|
if (cmd === '/compact') {
|
package/dist/ipc-server.js
CHANGED
|
@@ -19,10 +19,10 @@ import { visionTools } from './tools-vision.js';
|
|
|
19
19
|
import { voiceTools } from './tools-voice.js';
|
|
20
20
|
import { browserTools, closeBrowser } from './tools-browser.js';
|
|
21
21
|
import { loadMcpServices } from './mcp.js';
|
|
22
|
-
import { loadConfig, DEFAULT_LIMITS } from './settings.js';
|
|
22
|
+
import { loadConfig, resolveActiveProvider, DEFAULT_LIMITS } from './settings.js';
|
|
23
23
|
import { discoverPlugins, getPluginTools } from './plugins.js';
|
|
24
24
|
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
25
|
-
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS,
|
|
25
|
+
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_MODEL, fetchModelSpecs, registerUserModels, normalizeModelId, VERSION } from './constants.js';
|
|
26
26
|
import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
|
|
27
27
|
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
28
28
|
const pendingConfirms = new Map();
|
|
@@ -53,10 +53,23 @@ export async function runIpcServer() {
|
|
|
53
53
|
console.log = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
|
|
54
54
|
console.error = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
|
|
55
55
|
console.warn = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
|
|
56
|
+
// A stray rejection from a streaming/network path must not kill the engine:
|
|
57
|
+
// the extension auto-reconnects, but the fresh process has lost the whole
|
|
58
|
+
// session, which reads as the agent "forgetting" its task mid-way. Log to
|
|
59
|
+
// stderr (the extension surfaces it) and keep serving.
|
|
60
|
+
process.on('unhandledRejection', (reason) => {
|
|
61
|
+
const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
|
|
62
|
+
process.stderr.write(`IPC: unhandled rejection (kept alive): ${detail}\n`);
|
|
63
|
+
});
|
|
56
64
|
const config = await loadConfig();
|
|
57
|
-
const
|
|
65
|
+
const active = resolveActiveProvider(config);
|
|
66
|
+
const apiKey = active.apiKey;
|
|
58
67
|
if (!apiKey) {
|
|
59
|
-
|
|
68
|
+
// Keep the SCALIX_API_KEY marker — the VS Code extension keys its
|
|
69
|
+
// missing-key onboarding branch off this exact string.
|
|
70
|
+
write({ type: 'event', event: 'error', data: { message: active.provider === 'openai'
|
|
71
|
+
? 'OPENAI_API_KEY not set (provider: openai)'
|
|
72
|
+
: 'SCALIX_API_KEY not set' } });
|
|
60
73
|
process.exit(1);
|
|
61
74
|
}
|
|
62
75
|
const limits = config.limits || DEFAULT_LIMITS;
|
|
@@ -64,10 +77,18 @@ export async function runIpcServer() {
|
|
|
64
77
|
? process.env[ENV_MODEL].split(',').map(m => m.trim()).filter(Boolean)
|
|
65
78
|
: [];
|
|
66
79
|
const selectedModels = envModels.length > 0
|
|
67
|
-
? envModels
|
|
80
|
+
? envModels.map(normalizeModelId)
|
|
68
81
|
: config.models.length > 0 ? config.models : DEFAULT_MODELS;
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
if (selectedModels.length === 0) {
|
|
83
|
+
// Structured, like the missing-key path above — without this the Agent
|
|
84
|
+
// constructor throw escaped as plain text and the extension saw garbage
|
|
85
|
+
// instead of a protocol-level error.
|
|
86
|
+
write({ type: 'event', event: 'error', data: { message: 'No models configured. Set SCALIX_MODEL or pick a model in setup.' } });
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const baseUrl = active.baseUrl;
|
|
90
|
+
if (active.provider === 'scalix')
|
|
91
|
+
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
71
92
|
const mcpTools = loadMcpServices();
|
|
72
93
|
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
73
94
|
// dynamically import and execute code from the project directory.
|
|
@@ -102,6 +123,9 @@ export async function runIpcServer() {
|
|
|
102
123
|
const agent = createAgent({
|
|
103
124
|
apiKey,
|
|
104
125
|
baseUrl,
|
|
126
|
+
provider: active.provider,
|
|
127
|
+
cloudApiKey: active.cloudApiKey,
|
|
128
|
+
cloudBaseUrl: active.cloudBaseUrl,
|
|
105
129
|
model: selectedModels,
|
|
106
130
|
instructions,
|
|
107
131
|
tools: allTools,
|
|
@@ -129,8 +153,17 @@ export async function runIpcServer() {
|
|
|
129
153
|
agent.on('error', e => write({ type: 'event', event: 'error', data: { message: e.message } }));
|
|
130
154
|
agent.on('context:compress', i => write({ type: 'event', event: 'context:compress', data: i }));
|
|
131
155
|
agent.on('context:usage', i => write({ type: 'event', event: 'context:usage', data: i }));
|
|
132
|
-
// Signal ready with capability info
|
|
133
|
-
|
|
156
|
+
// Signal ready with capability info. `version` lets the extension detect
|
|
157
|
+
// engine/extension drift (a globally-installed CLI can lag the bundled one).
|
|
158
|
+
write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode } });
|
|
159
|
+
// Register the account's Scalix Cloud platform tools (deploy, database, …).
|
|
160
|
+
// Not awaited: the catalog fetch crosses the network and the extension's
|
|
161
|
+
// connect handshake must not wait on it — the tools join the set when the
|
|
162
|
+
// fetch lands. Without this call the GUI engine had NO scalix_* tools at
|
|
163
|
+
// all, and models asked to deploy invented SDKs and CLI flags instead.
|
|
164
|
+
void agent.refreshCloudTools()
|
|
165
|
+
.then(status => { write({ type: 'event', event: 'cloud:status', data: status }); })
|
|
166
|
+
.catch(err => { process.stderr.write(`IPC: cloud tools registration failed: ${err?.message || err}\n`); });
|
|
134
167
|
// Process JSONL from stdin
|
|
135
168
|
let busy = false;
|
|
136
169
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
@@ -188,11 +221,19 @@ export async function runIpcServer() {
|
|
|
188
221
|
agent.clearHistory();
|
|
189
222
|
write({ type: 'response', id, result: { success: true } });
|
|
190
223
|
break;
|
|
191
|
-
case 'setModel':
|
|
192
|
-
|
|
193
|
-
|
|
224
|
+
case 'setModel': {
|
|
225
|
+
// Same format rule as the headless REPL — the old handler accepted
|
|
226
|
+
// any truthy string (typos included) and answered an EMPTY model
|
|
227
|
+
// with success, both of which read back as "switched" in the UI.
|
|
228
|
+
const requested = typeof params?.model === 'string' ? params.model.trim() : '';
|
|
229
|
+
if (!requested || !/^[a-zA-Z0-9][\w./:@-]*$/.test(requested)) {
|
|
230
|
+
write({ type: 'response', id, error: { message: `Invalid model id: "${requested || '(empty)'}"` } });
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
agent.setModel(normalizeModelId(requested));
|
|
194
234
|
write({ type: 'response', id, result: { model: agent.getModels()[0] } });
|
|
195
235
|
break;
|
|
236
|
+
}
|
|
196
237
|
case 'compact': {
|
|
197
238
|
const r = await agent.compact();
|
|
198
239
|
write({ type: 'response', id, result: { message: r } });
|
|
@@ -201,11 +242,21 @@ export async function runIpcServer() {
|
|
|
201
242
|
case 'getModels':
|
|
202
243
|
write({ type: 'response', id, result: { models: agent.getModels() } });
|
|
203
244
|
break;
|
|
245
|
+
case 'registerModels': {
|
|
246
|
+
// A model spec added in the client mid-session must reach the agent
|
|
247
|
+
// now; the config file is only read at engine start.
|
|
248
|
+
const specs = params?.models;
|
|
249
|
+
if (specs)
|
|
250
|
+
registerUserModels(specs);
|
|
251
|
+
write({ type: 'response', id, result: { success: true } });
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
204
254
|
case 'getContextInfo': {
|
|
205
255
|
const u = agent.getContextUsage();
|
|
206
256
|
write({ type: 'response', id, result: {
|
|
207
257
|
estimatedTokens: u.tokens, maxTokens: u.maxTokens,
|
|
208
258
|
messageCount: agent.getMessages().length, utilization: u.percentage / 100,
|
|
259
|
+
breakdown: agent.getContextBreakdown(),
|
|
209
260
|
} });
|
|
210
261
|
break;
|
|
211
262
|
}
|