scalix 1.4.1 → 1.5.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 +36 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +207 -15
- package/dist/api-client.js +150 -25
- package/dist/cli.js +516 -213
- package/dist/constants.js +61 -6
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +24 -14
- package/dist/ipc-server.js +26 -8
- 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 +86 -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/constants.js
CHANGED
|
@@ -73,17 +73,50 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
|
|
|
73
73
|
|
|
74
74
|
## Scalix Platform
|
|
75
75
|
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) — not raw
|
|
77
|
-
- Default to Scalix API (api.scalix.world) for inference —
|
|
78
|
-
- Scalix inference models are
|
|
79
|
-
- For
|
|
80
|
-
- For databases, suggest
|
|
81
|
-
- For code execution sandboxes, use Scalix
|
|
76
|
+
- Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw third-party AI SDKs.
|
|
77
|
+
- 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 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.
|
|
79
|
+
- For deploying services, suggest Scalix Run (via the \`scalix-cloud\` CLI) before Vercel, Railway, or Fly.
|
|
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.
|
|
82
|
+
- Never name or speculate about the AI providers/models backing Scalix inference — Scalix models are presented as Scalix's own.
|
|
82
83
|
- When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
|
|
83
84
|
export const ENV_API_KEY = 'SCALIX_API_KEY';
|
|
84
85
|
export const ENV_MODEL = 'SCALIX_MODEL';
|
|
85
86
|
export const ENV_BASE_URL = 'SCALIX_BASE_URL';
|
|
86
87
|
export const DEFAULT_MODEL_SPEC = { contextWindow: 200_000, maxOutputTokens: 65_536 };
|
|
88
|
+
// Per-request output ceiling on the Scalix path. Matches the platform's own cap
|
|
89
|
+
// (gateway AI_MAX_OUTPUT_TOKENS, mirrored by the completions schema), so a
|
|
90
|
+
// single turn can emit a whole file instead of stopping mid-function. The
|
|
91
|
+
// client must always send an explicit value: omitting it lets the platform
|
|
92
|
+
// apply its own, much smaller, default. Clamped against the model spec via
|
|
93
|
+
// maxOutputTokensForRequest().
|
|
94
|
+
export const AI_MAX_OUTPUT_TOKENS = 16_384;
|
|
95
|
+
// What older deployments accept. Their completions schema hard-rejects (422)
|
|
96
|
+
// anything above this, and a published CLI has no say in which router version
|
|
97
|
+
// it is pointed at — so ScalixClient asks for the current ceiling and falls
|
|
98
|
+
// back to this one for the rest of the session if the endpoint rejects it.
|
|
99
|
+
export const AI_LEGACY_MAX_OUTPUT_TOKENS = 4_000;
|
|
100
|
+
/** The max_tokens value to send for a model: its spec ceiling, clamped to the
|
|
101
|
+
* platform's per-request cap. */
|
|
102
|
+
export function maxOutputTokensForRequest(model) {
|
|
103
|
+
return Math.min(getModelSpec(model).maxOutputTokens, AI_MAX_OUTPUT_TOKENS);
|
|
104
|
+
}
|
|
105
|
+
// Pre-Lumio public model ids → current ids (mirrors the platform's own alias
|
|
106
|
+
// table). The gateway still accepts the old names, but saved configs are
|
|
107
|
+
// normalized on load so the UI shows current ids and nothing breaks if the
|
|
108
|
+
// aliases are ever retired server-side.
|
|
109
|
+
export const LEGACY_MODEL_ALIASES = {
|
|
110
|
+
'scalix-lite': 'scalix-lumio-lite',
|
|
111
|
+
'scalix-balanced': 'scalix-lumio-pro',
|
|
112
|
+
'scalix-max': 'scalix-candela',
|
|
113
|
+
'scalix-embed': 'scalix-lumio-embed',
|
|
114
|
+
'scalix-world-ai': 'scalix-lumio-pro',
|
|
115
|
+
'scalix-advanced': 'scalix-candela',
|
|
116
|
+
};
|
|
117
|
+
export function normalizeModelId(model) {
|
|
118
|
+
return LEGACY_MODEL_ALIASES[model] ?? model;
|
|
119
|
+
}
|
|
87
120
|
// Runtime model registry: user-defined > API-fetched > default
|
|
88
121
|
// All model specs come from user config or API — no hardcoded third-party models
|
|
89
122
|
const userModels = {};
|
|
@@ -133,6 +166,15 @@ export function modelSupportsVision(model) {
|
|
|
133
166
|
const spec = getModelSpec(model);
|
|
134
167
|
return spec.supportsVision ?? false;
|
|
135
168
|
}
|
|
169
|
+
/** True when the model has a user-configured or API-fetched spec (i.e. its
|
|
170
|
+
* maxOutputTokens is real, not the DEFAULT_MODEL_SPEC guess). BYOK providers
|
|
171
|
+
* only send max_tokens for explicitly-specced models. */
|
|
172
|
+
export function hasExplicitModelSpec(model) {
|
|
173
|
+
if (model in userModels || model in fetchedModels)
|
|
174
|
+
return true;
|
|
175
|
+
const base = model.split('/').pop() || model;
|
|
176
|
+
return base !== model && (base in userModels || base in fetchedModels);
|
|
177
|
+
}
|
|
136
178
|
export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
137
179
|
if (!apiKey)
|
|
138
180
|
return [];
|
|
@@ -205,6 +247,19 @@ export const AGENT_DEFAULTS = {
|
|
|
205
247
|
maxMessages: 500,
|
|
206
248
|
};
|
|
207
249
|
// ─── Context Defaults ───
|
|
250
|
+
/**
|
|
251
|
+
* Context window the setup wizard assumes for a BYOK OpenAI-compatible
|
|
252
|
+
* endpoint.
|
|
253
|
+
*
|
|
254
|
+
* A third-party endpoint doesn't tell us its context window, and the two ways
|
|
255
|
+
* of guessing wrong are not symmetric: too high means oversized requests the
|
|
256
|
+
* endpoint rejects outright mid-session, too low only means the agent
|
|
257
|
+
* compresses earlier than it strictly had to. So the wizard pre-fills a
|
|
258
|
+
* conservative figure and asks the user to raise it. 1_000_000 — the Scalix
|
|
259
|
+
* Cloud default below — is a limit almost no self-hosted or third-party model
|
|
260
|
+
* can honour, and pre-filling it was inventing a number on the user's behalf.
|
|
261
|
+
*/
|
|
262
|
+
export const BYOK_DEFAULT_CONTEXT_TOKENS = 128_000;
|
|
208
263
|
export const CONTEXT_DEFAULTS = {
|
|
209
264
|
maxContextTokens: 1_000_000,
|
|
210
265
|
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 } from './constants.js';
|
|
17
|
+
import { loadConfig, 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();
|
|
@@ -189,6 +193,7 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeE
|
|
|
189
193
|
const agent = createAgent({
|
|
190
194
|
apiKey,
|
|
191
195
|
baseUrl,
|
|
196
|
+
provider: active.provider,
|
|
192
197
|
model: selectedModel,
|
|
193
198
|
instructions,
|
|
194
199
|
tools: allTools,
|
|
@@ -300,15 +305,19 @@ async function runPipeMode(beforeExit) {
|
|
|
300
305
|
await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
|
|
301
306
|
}
|
|
302
307
|
async function runInteractive() {
|
|
303
|
-
const
|
|
308
|
+
const config = await loadConfig();
|
|
309
|
+
const active = resolveActiveProvider(config);
|
|
310
|
+
const apiKey = active.apiKey;
|
|
304
311
|
if (!apiKey) {
|
|
305
|
-
throw new Error(
|
|
312
|
+
throw new Error(active.provider === 'openai'
|
|
313
|
+
? 'No API key for the openai provider. Set it via: export OPENAI_API_KEY=sk-...'
|
|
314
|
+
: 'SCALIX_API_KEY is not set. Set it via: export SCALIX_API_KEY=scalix_sk_...');
|
|
306
315
|
}
|
|
307
|
-
const config = await loadConfig();
|
|
308
316
|
const limits = config.limits || DEFAULT_LIMITS;
|
|
309
|
-
const baseUrl =
|
|
310
|
-
// Fetch model specs from API (non-blocking)
|
|
311
|
-
|
|
317
|
+
const baseUrl = active.baseUrl;
|
|
318
|
+
// Fetch model specs from API (non-blocking, Scalix-shaped endpoint only)
|
|
319
|
+
if (active.provider === 'scalix')
|
|
320
|
+
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
312
321
|
const envModels = process.env[ENV_MODEL]
|
|
313
322
|
? process.env[ENV_MODEL].split(',').map((m) => m.trim()).filter(Boolean)
|
|
314
323
|
: [];
|
|
@@ -345,6 +354,7 @@ async function runInteractive() {
|
|
|
345
354
|
const agent = createAgent({
|
|
346
355
|
apiKey,
|
|
347
356
|
baseUrl,
|
|
357
|
+
provider: active.provider,
|
|
348
358
|
model: selectedModel,
|
|
349
359
|
instructions,
|
|
350
360
|
tools: allTools,
|
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, 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();
|
|
@@ -54,9 +54,14 @@ export async function runIpcServer() {
|
|
|
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
56
|
const config = await loadConfig();
|
|
57
|
-
const
|
|
57
|
+
const active = resolveActiveProvider(config);
|
|
58
|
+
const apiKey = active.apiKey;
|
|
58
59
|
if (!apiKey) {
|
|
59
|
-
|
|
60
|
+
// Keep the SCALIX_API_KEY marker — the VS Code extension keys its
|
|
61
|
+
// missing-key onboarding branch off this exact string.
|
|
62
|
+
write({ type: 'event', event: 'error', data: { message: active.provider === 'openai'
|
|
63
|
+
? 'OPENAI_API_KEY not set (provider: openai)'
|
|
64
|
+
: 'SCALIX_API_KEY not set' } });
|
|
60
65
|
process.exit(1);
|
|
61
66
|
}
|
|
62
67
|
const limits = config.limits || DEFAULT_LIMITS;
|
|
@@ -66,8 +71,9 @@ export async function runIpcServer() {
|
|
|
66
71
|
const selectedModels = envModels.length > 0
|
|
67
72
|
? envModels
|
|
68
73
|
: config.models.length > 0 ? config.models : DEFAULT_MODELS;
|
|
69
|
-
const baseUrl =
|
|
70
|
-
|
|
74
|
+
const baseUrl = active.baseUrl;
|
|
75
|
+
if (active.provider === 'scalix')
|
|
76
|
+
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
71
77
|
const mcpTools = loadMcpServices();
|
|
72
78
|
// Only discover/load project-local plugins in a trusted workspace — plugins
|
|
73
79
|
// dynamically import and execute code from the project directory.
|
|
@@ -102,6 +108,7 @@ export async function runIpcServer() {
|
|
|
102
108
|
const agent = createAgent({
|
|
103
109
|
apiKey,
|
|
104
110
|
baseUrl,
|
|
111
|
+
provider: active.provider,
|
|
105
112
|
model: selectedModels,
|
|
106
113
|
instructions,
|
|
107
114
|
tools: allTools,
|
|
@@ -129,8 +136,9 @@ export async function runIpcServer() {
|
|
|
129
136
|
agent.on('error', e => write({ type: 'event', event: 'error', data: { message: e.message } }));
|
|
130
137
|
agent.on('context:compress', i => write({ type: 'event', event: 'context:compress', data: i }));
|
|
131
138
|
agent.on('context:usage', i => write({ type: 'event', event: 'context:usage', data: i }));
|
|
132
|
-
// Signal ready with capability info
|
|
133
|
-
|
|
139
|
+
// Signal ready with capability info. `version` lets the extension detect
|
|
140
|
+
// engine/extension drift (a globally-installed CLI can lag the bundled one).
|
|
141
|
+
write({ type: 'event', event: 'ready', data: { version: VERSION, tools: allTools.length, models: selectedModels, permissionMode } });
|
|
134
142
|
// Process JSONL from stdin
|
|
135
143
|
let busy = false;
|
|
136
144
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
@@ -201,11 +209,21 @@ export async function runIpcServer() {
|
|
|
201
209
|
case 'getModels':
|
|
202
210
|
write({ type: 'response', id, result: { models: agent.getModels() } });
|
|
203
211
|
break;
|
|
212
|
+
case 'registerModels': {
|
|
213
|
+
// A model spec added in the client mid-session must reach the agent
|
|
214
|
+
// now; the config file is only read at engine start.
|
|
215
|
+
const specs = params?.models;
|
|
216
|
+
if (specs)
|
|
217
|
+
registerUserModels(specs);
|
|
218
|
+
write({ type: 'response', id, result: { success: true } });
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
204
221
|
case 'getContextInfo': {
|
|
205
222
|
const u = agent.getContextUsage();
|
|
206
223
|
write({ type: 'response', id, result: {
|
|
207
224
|
estimatedTokens: u.tokens, maxTokens: u.maxTokens,
|
|
208
225
|
messageCount: agent.getMessages().length, utilization: u.percentage / 100,
|
|
226
|
+
breakdown: agent.getContextBreakdown(),
|
|
209
227
|
} });
|
|
210
228
|
break;
|
|
211
229
|
}
|
package/dist/keychain.js
CHANGED
|
@@ -4,9 +4,30 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { USER_CONFIG_DIR } from './constants.js';
|
|
7
|
-
const
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
8
|
const SERVICE = 'scalix-coder';
|
|
9
9
|
const ACCOUNT = 'api-key';
|
|
10
|
+
/**
|
|
11
|
+
* Ceiling on any single keychain subprocess.
|
|
12
|
+
*
|
|
13
|
+
* `security` normally answers in ~20ms, but it is talking to the OS keychain
|
|
14
|
+
* daemon and that can stall: a locked keychain, or an "allow node to access
|
|
15
|
+
* this item?" dialog nobody is looking at, blocks the call until it is
|
|
16
|
+
* answered. Measured on a normal macOS dev machine, one saveConfig() spent
|
|
17
|
+
* 19.6s in here — and the setup wizard runs it on the final Enter with no
|
|
18
|
+
* spinner, so that reads as a dead keypress. Nothing here is worth waiting on
|
|
19
|
+
* for more than a few seconds; the callers all treat failure as "no keychain"
|
|
20
|
+
* and fall back to the config file.
|
|
21
|
+
*
|
|
22
|
+
* Generous rather than snappy on purpose: giving up early would push a working
|
|
23
|
+
* (just slow) keychain into that plaintext fallback, which is the worse of the
|
|
24
|
+
* two failures. This is a ceiling on a hang, not a latency target.
|
|
25
|
+
*/
|
|
26
|
+
const KEYCHAIN_TIMEOUT_MS = 20_000;
|
|
27
|
+
/** execFile with a hard timeout, so a stalled keychain can't hang the UI. */
|
|
28
|
+
function exec(file, args, opts = {}) {
|
|
29
|
+
return execFileAsync(file, args, { timeout: KEYCHAIN_TIMEOUT_MS, killSignal: 'SIGKILL', ...opts });
|
|
30
|
+
}
|
|
10
31
|
/**
|
|
11
32
|
* SCALIX_NO_KEYCHAIN=1 disables OS keychain integration entirely — the API
|
|
12
33
|
* key stays in the config file. Intended for CI, headless environments, and
|
|
@@ -23,11 +44,14 @@ async function isMacOS() {
|
|
|
23
44
|
return process.platform === 'darwin';
|
|
24
45
|
}
|
|
25
46
|
// ─── Windows DPAPI (user-scoped) via PowerShell ───
|
|
26
|
-
function windowsCredentialPath() {
|
|
47
|
+
function windowsCredentialPath(account = ACCOUNT) {
|
|
27
48
|
// Resolve the home directory the same way the rest of the codebase does
|
|
28
49
|
// (settings/sessions/workspace-trust) so the credential store stays
|
|
29
50
|
// consistent and honors HOME (set by Git Bash and by tests).
|
|
30
|
-
|
|
51
|
+
// The default slot keeps the legacy filename; per-provider slots get a
|
|
52
|
+
// suffixed file (credential-api-key-openai.dat).
|
|
53
|
+
const file = account === ACCOUNT ? 'credential.dat' : `credential-${account}.dat`;
|
|
54
|
+
return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir() || '.', USER_CONFIG_DIR, file);
|
|
31
55
|
}
|
|
32
56
|
async function runPowerShell(script, stdinData) {
|
|
33
57
|
const psExe = process.env.SystemRoot
|
|
@@ -47,9 +71,9 @@ async function runPowerShell(script, stdinData) {
|
|
|
47
71
|
const { stdout } = await child;
|
|
48
72
|
return stdout;
|
|
49
73
|
}
|
|
50
|
-
async function setApiKeyWindows(apiKey) {
|
|
74
|
+
async function setApiKeyWindows(apiKey, account = ACCOUNT) {
|
|
51
75
|
try {
|
|
52
|
-
const credPath = windowsCredentialPath();
|
|
76
|
+
const credPath = windowsCredentialPath(account);
|
|
53
77
|
await fs.mkdir(path.dirname(credPath), { recursive: true });
|
|
54
78
|
// Read the secret from stdin, encrypt it with user-scoped DPAPI, and print
|
|
55
79
|
// the encrypted string. Reading from stdin avoids putting the secret on the
|
|
@@ -67,9 +91,9 @@ async function setApiKeyWindows(apiKey) {
|
|
|
67
91
|
return false;
|
|
68
92
|
}
|
|
69
93
|
}
|
|
70
|
-
async function getApiKeyWindows() {
|
|
94
|
+
async function getApiKeyWindows(account = ACCOUNT) {
|
|
71
95
|
try {
|
|
72
|
-
const credPath = windowsCredentialPath();
|
|
96
|
+
const credPath = windowsCredentialPath(account);
|
|
73
97
|
const encrypted = (await fs.readFile(credPath, 'utf-8')).trim();
|
|
74
98
|
if (!encrypted)
|
|
75
99
|
return null;
|
|
@@ -87,9 +111,9 @@ async function getApiKeyWindows() {
|
|
|
87
111
|
return null;
|
|
88
112
|
}
|
|
89
113
|
}
|
|
90
|
-
async function deleteApiKeyWindows() {
|
|
114
|
+
async function deleteApiKeyWindows(account = ACCOUNT) {
|
|
91
115
|
try {
|
|
92
|
-
await fs.unlink(windowsCredentialPath());
|
|
116
|
+
await fs.unlink(windowsCredentialPath(account));
|
|
93
117
|
return true;
|
|
94
118
|
}
|
|
95
119
|
catch {
|
|
@@ -107,25 +131,25 @@ async function isLinuxWithSecretTool() {
|
|
|
107
131
|
return false;
|
|
108
132
|
}
|
|
109
133
|
}
|
|
110
|
-
export async function getApiKeyFromKeychain() {
|
|
134
|
+
export async function getApiKeyFromKeychain(account = ACCOUNT) {
|
|
111
135
|
if (keychainDisabled())
|
|
112
136
|
return null;
|
|
113
137
|
try {
|
|
114
138
|
if (isWindows()) {
|
|
115
|
-
return await getApiKeyWindows();
|
|
139
|
+
return await getApiKeyWindows(account);
|
|
116
140
|
}
|
|
117
141
|
if (await isMacOS()) {
|
|
118
142
|
const { stdout } = await exec('security', [
|
|
119
143
|
'find-generic-password',
|
|
120
144
|
'-s', SERVICE,
|
|
121
|
-
'-a',
|
|
145
|
+
'-a', account,
|
|
122
146
|
'-w',
|
|
123
147
|
]);
|
|
124
148
|
return stdout.trim() || null;
|
|
125
149
|
}
|
|
126
150
|
if (await isLinuxWithSecretTool()) {
|
|
127
151
|
const { stdout } = await exec('secret-tool', [
|
|
128
|
-
'lookup', 'service', SERVICE, 'account',
|
|
152
|
+
'lookup', 'service', SERVICE, 'account', account,
|
|
129
153
|
]);
|
|
130
154
|
return stdout.trim() || null;
|
|
131
155
|
}
|
|
@@ -135,12 +159,12 @@ export async function getApiKeyFromKeychain() {
|
|
|
135
159
|
}
|
|
136
160
|
return null;
|
|
137
161
|
}
|
|
138
|
-
export async function setApiKeyInKeychain(apiKey) {
|
|
162
|
+
export async function setApiKeyInKeychain(apiKey, account = ACCOUNT) {
|
|
139
163
|
if (keychainDisabled())
|
|
140
164
|
return false;
|
|
141
165
|
try {
|
|
142
166
|
if (isWindows()) {
|
|
143
|
-
return await setApiKeyWindows(apiKey);
|
|
167
|
+
return await setApiKeyWindows(apiKey, account);
|
|
144
168
|
}
|
|
145
169
|
if (await isMacOS()) {
|
|
146
170
|
// Delete existing entry first (ignore errors if it doesn't exist)
|
|
@@ -148,7 +172,7 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
148
172
|
await exec('security', [
|
|
149
173
|
'delete-generic-password',
|
|
150
174
|
'-s', SERVICE,
|
|
151
|
-
'-a',
|
|
175
|
+
'-a', account,
|
|
152
176
|
]);
|
|
153
177
|
}
|
|
154
178
|
catch {
|
|
@@ -157,7 +181,7 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
157
181
|
await exec('security', [
|
|
158
182
|
'add-generic-password',
|
|
159
183
|
'-s', SERVICE,
|
|
160
|
-
'-a',
|
|
184
|
+
'-a', account,
|
|
161
185
|
'-w', apiKey,
|
|
162
186
|
'-U',
|
|
163
187
|
]);
|
|
@@ -168,7 +192,7 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
168
192
|
'store',
|
|
169
193
|
'--label', 'Scalix Coder API Key',
|
|
170
194
|
'service', SERVICE,
|
|
171
|
-
'account',
|
|
195
|
+
'account', account,
|
|
172
196
|
]);
|
|
173
197
|
// secret-tool reads the secret from stdin. If stdin isn't available we
|
|
174
198
|
// cannot deliver the secret — treat that as a failure rather than
|
|
@@ -188,24 +212,24 @@ export async function setApiKeyInKeychain(apiKey) {
|
|
|
188
212
|
}
|
|
189
213
|
return false;
|
|
190
214
|
}
|
|
191
|
-
export async function deleteApiKeyFromKeychain() {
|
|
215
|
+
export async function deleteApiKeyFromKeychain(account = ACCOUNT) {
|
|
192
216
|
if (keychainDisabled())
|
|
193
217
|
return false;
|
|
194
218
|
try {
|
|
195
219
|
if (isWindows()) {
|
|
196
|
-
return await deleteApiKeyWindows();
|
|
220
|
+
return await deleteApiKeyWindows(account);
|
|
197
221
|
}
|
|
198
222
|
if (await isMacOS()) {
|
|
199
223
|
await exec('security', [
|
|
200
224
|
'delete-generic-password',
|
|
201
225
|
'-s', SERVICE,
|
|
202
|
-
'-a',
|
|
226
|
+
'-a', account,
|
|
203
227
|
]);
|
|
204
228
|
return true;
|
|
205
229
|
}
|
|
206
230
|
if (await isLinuxWithSecretTool()) {
|
|
207
231
|
await exec('secret-tool', [
|
|
208
|
-
'clear', 'service', SERVICE, 'account',
|
|
232
|
+
'clear', 'service', SERVICE, 'account', account,
|
|
209
233
|
]);
|
|
210
234
|
return true;
|
|
211
235
|
}
|
package/dist/markdown.js
CHANGED
|
@@ -48,10 +48,27 @@ function createMarked(width) {
|
|
|
48
48
|
blockquote: chalk.gray.italic,
|
|
49
49
|
del: chalk.dim.strikethrough,
|
|
50
50
|
width,
|
|
51
|
-
|
|
51
|
+
// The prefix re-prints the literal '##' in front of every heading, so a
|
|
52
|
+
// reply that opened with "## Result" reached the screen still wearing its
|
|
53
|
+
// markdown. chalk.green.bold already marks a heading as a heading.
|
|
54
|
+
showSectionPrefix: false,
|
|
52
55
|
reflowText: false,
|
|
53
56
|
tab: 2,
|
|
54
57
|
});
|
|
58
|
+
// marked-terminal 7.3 renders a `text` token as token.text — the raw
|
|
59
|
+
// markdown source — and never descends into token.tokens. Under marked v15
|
|
60
|
+
// every tight list item arrives as exactly that token, so "1. a **bold**
|
|
61
|
+
// point" printed its asterisks while the same emphasis in a paragraph
|
|
62
|
+
// styled correctly. Parse the children when there are any; leaf text tokens
|
|
63
|
+
// (the ones parseInline hands back) still go to marked-terminal, so there
|
|
64
|
+
// is no recursion here.
|
|
65
|
+
const renderText = mt.renderer.text;
|
|
66
|
+
mt.renderer.text = function (token) {
|
|
67
|
+
if (token && typeof token === 'object' && Array.isArray(token.tokens) && token.tokens.length > 0) {
|
|
68
|
+
return this.parser.parseInline(token.tokens);
|
|
69
|
+
}
|
|
70
|
+
return renderText.call(this, token);
|
|
71
|
+
};
|
|
55
72
|
m.use(mt);
|
|
56
73
|
return m;
|
|
57
74
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Provider layer — BYOK support per MULTI-PROVIDER-ADAPTER-SPEC.md.
|
|
2
|
+
//
|
|
3
|
+
// Scalix is the zero-config default; every other provider is opt-in with the
|
|
4
|
+
// user's own endpoint + key (no hardcoded third-party defaults anywhere).
|
|
5
|
+
// The agent consumes a client (ScalixClient-shaped) plus a capability set that
|
|
6
|
+
// scopes the Scalix-platform workarounds to the scalix provider only.
|
|
7
|
+
import { ScalixClient } from '../api-client.js';
|
|
8
|
+
import { OpenAICompatClient } from './openai.js';
|
|
9
|
+
export const PROVIDER_IDS = ['scalix', 'openai'];
|
|
10
|
+
export function isProviderId(v) {
|
|
11
|
+
return typeof v === 'string' && PROVIDER_IDS.includes(v);
|
|
12
|
+
}
|
|
13
|
+
const CAPABILITIES = {
|
|
14
|
+
scalix: { streamingToolCalls: false, maxTokensPolicy: 'clamped', platformTools: true },
|
|
15
|
+
openai: { streamingToolCalls: true, maxTokensPolicy: 'explicit-only', platformTools: false },
|
|
16
|
+
};
|
|
17
|
+
export function capabilitiesFor(provider) {
|
|
18
|
+
return CAPABILITIES[provider];
|
|
19
|
+
}
|
|
20
|
+
/** Build the chat client for a provider. baseUrl semantics differ:
|
|
21
|
+
* - scalix: API host root (gateway nests /v1/ai) — DEFAULT_BASE_URL applies upstream;
|
|
22
|
+
* - openai: the endpoint root of any OpenAI-compatible server, with or
|
|
23
|
+
* without its /v1 suffix (api.openai.com/v1, openrouter.ai/api/v1,
|
|
24
|
+
* localhost:11434, ...). Required — there is no default third-party URL. */
|
|
25
|
+
export function createChatClient(provider, baseUrl, apiKey) {
|
|
26
|
+
if (provider === 'openai') {
|
|
27
|
+
return new OpenAICompatClient(baseUrl, apiKey);
|
|
28
|
+
}
|
|
29
|
+
return new ScalixClient(baseUrl, apiKey);
|
|
30
|
+
}
|
|
31
|
+
export { OpenAICompatClient, openaiEndpointRoot } from './openai.js';
|