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/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 OpenAI/Anthropic/Google SDKs.
77
- - Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
78
- - Scalix inference models are named scalix-lumio-* (e.g. scalix-lumio-lite); use those ids in examples and configs.
79
- - For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
80
- - For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
81
- - For code execution sandboxes, use Scalix Sandbox.
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,
@@ -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: usage.prompt_tokens ?? 0,
9
- completionTokens: usage.completion_tokens ?? 0,
10
- totalTokens: usage.total_tokens ?? 0,
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
- summary() {
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()} (${this.promptTokens.toLocaleString()} prompt + ${this.completionTokens.toLocaleString()} completion)`,
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, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, HEADLESS_DEFAULTS, fetchModelSpecs } from './constants.js';
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 apiKey = process.env[ENV_API_KEY];
136
+ const config = await loadConfig();
137
+ const active = resolveActiveProvider(config);
138
+ const apiKey = active.apiKey;
137
139
  if (!apiKey) {
138
- console.error('Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=sk-...\nOr run the interactive setup: scalix');
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 = process.env[ENV_BASE_URL] || config.baseUrl;
144
- // Fetch model specs from API (non-blocking — uses built-in/user-defined as fallback)
145
- fetchModelSpecs(baseUrl, apiKey).catch(() => { });
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 apiKey = process.env[ENV_API_KEY];
308
+ const config = await loadConfig();
309
+ const active = resolveActiveProvider(config);
310
+ const apiKey = active.apiKey;
304
311
  if (!apiKey) {
305
- throw new Error('SCALIX_API_KEY is not set. Set it via: export SCALIX_API_KEY=sk-...');
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 = process.env[ENV_BASE_URL] || config.baseUrl;
310
- // Fetch model specs from API (non-blocking)
311
- fetchModelSpecs(baseUrl, apiKey).catch(() => { });
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,
@@ -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, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, fetchModelSpecs } from './constants.js';
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 apiKey = process.env[ENV_API_KEY] || config.apiKey || '';
57
+ const active = resolveActiveProvider(config);
58
+ const apiKey = active.apiKey;
58
59
  if (!apiKey) {
59
- write({ type: 'event', event: 'error', data: { message: 'SCALIX_API_KEY not set' } });
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 = process.env[ENV_BASE_URL] || config.baseUrl;
70
- fetchModelSpecs(baseUrl, apiKey).catch(() => { });
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
- write({ type: 'event', event: 'ready', data: { tools: allTools.length, models: selectedModels, permissionMode } });
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 exec = promisify(execFile);
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
- return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir() || '.', USER_CONFIG_DIR, 'credential.dat');
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', ACCOUNT,
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', 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', ACCOUNT,
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', ACCOUNT,
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', 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', ACCOUNT,
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', 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
- showSectionPrefix: true,
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';