scalix 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +13 -2
- package/bin/scalix.js +8 -0
- package/dist/agent.js +148 -32
- package/dist/api-client.js +49 -11
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +582 -173
- package/dist/cloud.js +236 -0
- package/dist/commands.js +19 -2
- package/dist/constants.js +138 -11
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +102 -45
- package/dist/hooks.js +55 -5
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +117 -4
- package/dist/markdown.js +4 -1
- package/dist/model-router.js +11 -0
- package/dist/sandbox.js +93 -10
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +6 -6
- package/dist/settings.js +53 -14
- package/dist/sub-agent.js +59 -2
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +42 -6
- package/dist/tools-voice.js +9 -15
- package/dist/tools-web.js +81 -54
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +1 -1
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Scalix Cloud platform client — identity (/v1/me), the account's MCP tool
|
|
2
|
+
// catalog (/api/v1/mcp), and tool execution (/api/v1/mcp/call).
|
|
3
|
+
//
|
|
4
|
+
// Wire-format notes (verified against the gateway + mcp-server crates):
|
|
5
|
+
// - Catalog tool entries serialize the schema under `input_schema` (snake_case,
|
|
6
|
+
// serde default on McpToolDef in both crates/gateway/src/mcp.rs and
|
|
7
|
+
// crates/mcp-server/src/types.rs). The MCP JSON-RPC spec spells it
|
|
8
|
+
// `inputSchema`; we tolerate both so a future spec-aligned rename is a no-op.
|
|
9
|
+
// - Tool call results are `{content: [{type: 'text', text}], is_error}`
|
|
10
|
+
// (McpToolResult/McpContent — only text blocks are ever produced). `isError`
|
|
11
|
+
// is tolerated for the same forward-compat reason.
|
|
12
|
+
// - Error envelope on 4xx/5xx: `{error, code, request_id?, docs_url?}`
|
|
13
|
+
// (ApiError::into_response in crates/gateway/src/server.rs).
|
|
14
|
+
//
|
|
15
|
+
// Dependency-free: global fetch only. Reuses apiHostRoot() so legacy configs
|
|
16
|
+
// with a trailing /v1 keep working for these non-AI endpoints too.
|
|
17
|
+
import { apiHostRoot, USER_AGENT, WEB_DEFAULTS } from './constants.js';
|
|
18
|
+
export const CONSOLE_KEYS_URL = 'https://console.scalix.world/org/keys';
|
|
19
|
+
export const CLOUD_DEFAULTS = {
|
|
20
|
+
fetchTimeoutMs: WEB_DEFAULTS.apiFetchTimeoutMs,
|
|
21
|
+
// Platform operations (deploys, migrations) can legitimately take a while.
|
|
22
|
+
callTimeoutMs: 120_000,
|
|
23
|
+
};
|
|
24
|
+
export class CloudError extends Error {
|
|
25
|
+
status;
|
|
26
|
+
code;
|
|
27
|
+
docsUrl;
|
|
28
|
+
requestId;
|
|
29
|
+
constructor(message, opts = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'CloudError';
|
|
32
|
+
this.status = opts.status;
|
|
33
|
+
this.code = opts.code;
|
|
34
|
+
this.docsUrl = opts.docsUrl;
|
|
35
|
+
this.requestId = opts.requestId;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// ─── Pure parsers (unit-tested without network) ───
|
|
39
|
+
/**
|
|
40
|
+
* Build a CloudError from a non-OK response body using the gateway's error
|
|
41
|
+
* envelope `{error, code, docs_url, request_id}`. Falls back to the raw body
|
|
42
|
+
* (or status text) when the body isn't the envelope — the message is always
|
|
43
|
+
* whatever the server actually said, never an invented one.
|
|
44
|
+
*/
|
|
45
|
+
export function errorFromEnvelope(status, statusText, bodyText) {
|
|
46
|
+
let envelope = null;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(bodyText);
|
|
49
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
50
|
+
envelope = parsed;
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
if (status === 401) {
|
|
54
|
+
return new CloudError(`API key rejected — create one at ${CONSOLE_KEYS_URL}`, {
|
|
55
|
+
status,
|
|
56
|
+
code: typeof envelope?.code === 'string' ? envelope.code : undefined,
|
|
57
|
+
docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
|
|
58
|
+
requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const message = typeof envelope?.error === 'string' && envelope.error
|
|
62
|
+
? envelope.error
|
|
63
|
+
: (bodyText.trim().slice(0, 200) || `${status} ${statusText}`);
|
|
64
|
+
return new CloudError(message, {
|
|
65
|
+
status,
|
|
66
|
+
code: typeof envelope?.code === 'string' ? envelope.code : undefined,
|
|
67
|
+
docsUrl: typeof envelope?.docs_url === 'string' ? envelope.docs_url : undefined,
|
|
68
|
+
requestId: typeof envelope?.request_id === 'string' ? envelope.request_id : undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Parse the `GET /v1/me` response. Throws CloudError on a malformed body. */
|
|
72
|
+
export function parseMeResponse(data) {
|
|
73
|
+
const d = data;
|
|
74
|
+
const identity = d?.identity;
|
|
75
|
+
const permissions = d?.permissions;
|
|
76
|
+
const limits = d?.limits;
|
|
77
|
+
if (!identity || typeof identity.org_id !== 'string'
|
|
78
|
+
|| !permissions || !Array.isArray(permissions.scopes)
|
|
79
|
+
|| !limits || typeof limits.plan !== 'string') {
|
|
80
|
+
throw new CloudError('Unexpected /v1/me response shape — the endpoint did not return identity/permissions/limits');
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
identity: {
|
|
84
|
+
keyId: String(identity.key_id ?? ''),
|
|
85
|
+
tokenType: String(identity.token_type ?? ''),
|
|
86
|
+
orgId: identity.org_id,
|
|
87
|
+
projectId: typeof identity.project_id === 'string' && identity.project_id ? identity.project_id : undefined,
|
|
88
|
+
createdBy: String(identity.created_by ?? ''),
|
|
89
|
+
},
|
|
90
|
+
permissions: {
|
|
91
|
+
role: String(permissions.role ?? ''),
|
|
92
|
+
scopes: permissions.scopes.filter((s) => typeof s === 'string'),
|
|
93
|
+
canCreateKeys: permissions.can_create_keys === true,
|
|
94
|
+
canCreateProjects: permissions.can_create_projects === true,
|
|
95
|
+
},
|
|
96
|
+
tools: Array.isArray(d.tools) ? d.tools.filter((t) => typeof t === 'string') : [],
|
|
97
|
+
limits: {
|
|
98
|
+
plan: limits.plan,
|
|
99
|
+
rateLimitRps: typeof limits.rate_limit_rps === 'number' ? limits.rate_limit_rps : 0,
|
|
100
|
+
apiKeysMax: typeof limits.api_keys_max === 'number' ? limits.api_keys_max : 0,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse the `GET /api/v1/mcp` catalog. Entries without a name or an object
|
|
106
|
+
* schema are dropped (the gateway guarantees both; we don't register a tool we
|
|
107
|
+
* can't describe to the model). Throws CloudError when the body isn't a catalog.
|
|
108
|
+
*/
|
|
109
|
+
export function parseCatalogResponse(data) {
|
|
110
|
+
const d = data;
|
|
111
|
+
const rawTools = d?.capabilities?.tools;
|
|
112
|
+
if (!Array.isArray(rawTools)) {
|
|
113
|
+
throw new CloudError('Unexpected /api/v1/mcp response shape — no capabilities.tools array');
|
|
114
|
+
}
|
|
115
|
+
const tools = [];
|
|
116
|
+
for (const entry of rawTools) {
|
|
117
|
+
if (!entry || typeof entry.name !== 'string' || !entry.name)
|
|
118
|
+
continue;
|
|
119
|
+
const schema = entry.input_schema ?? entry.inputSchema;
|
|
120
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema))
|
|
121
|
+
continue;
|
|
122
|
+
tools.push({
|
|
123
|
+
name: entry.name,
|
|
124
|
+
description: typeof entry.description === 'string' ? entry.description : '',
|
|
125
|
+
inputSchema: schema,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return { tools, platformAvailable: d.platform_tools_available === true };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Normalize a `{content, is_error}` tool result to plain text for the agent.
|
|
132
|
+
* Content is an array of `{type: 'text', text}` blocks (the only block type the
|
|
133
|
+
* gateway produces); a bare string is accepted too. Throws CloudError when the
|
|
134
|
+
* body carries no readable content at all.
|
|
135
|
+
*/
|
|
136
|
+
export function parseToolCallResult(data) {
|
|
137
|
+
const d = data;
|
|
138
|
+
if (!d || typeof d !== 'object') {
|
|
139
|
+
throw new CloudError('Unexpected tool call response — not an object');
|
|
140
|
+
}
|
|
141
|
+
const isError = d.is_error === true || d.isError === true;
|
|
142
|
+
let text;
|
|
143
|
+
if (typeof d.content === 'string') {
|
|
144
|
+
text = d.content;
|
|
145
|
+
}
|
|
146
|
+
else if (Array.isArray(d.content)) {
|
|
147
|
+
text = d.content
|
|
148
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
149
|
+
.map((b) => b.text)
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
throw new CloudError('Unexpected tool call response — no content field');
|
|
154
|
+
}
|
|
155
|
+
return { ok: !isError, text };
|
|
156
|
+
}
|
|
157
|
+
// ─── HTTP calls ───
|
|
158
|
+
function cloudHeaders(apiKey, projectId) {
|
|
159
|
+
const headers = {
|
|
160
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
161
|
+
'User-Agent': USER_AGENT,
|
|
162
|
+
'x-client-type': 'cli',
|
|
163
|
+
};
|
|
164
|
+
if (projectId)
|
|
165
|
+
headers['x-scalix-project'] = projectId;
|
|
166
|
+
return headers;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fetch the caller's identity, plan, and platform capabilities from `/v1/me`.
|
|
170
|
+
* Throws CloudError (with the gateway's own `error` text) on any failure.
|
|
171
|
+
*/
|
|
172
|
+
export async function fetchMe(baseUrl, apiKey) {
|
|
173
|
+
const url = `${apiHostRoot(baseUrl)}/v1/me`;
|
|
174
|
+
const res = await fetch(url, {
|
|
175
|
+
headers: cloudHeaders(apiKey),
|
|
176
|
+
signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const body = await res.text().catch(() => '');
|
|
180
|
+
throw errorFromEnvelope(res.status, res.statusText, body);
|
|
181
|
+
}
|
|
182
|
+
return parseMeResponse(await res.json());
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Fetch the account's MCP tool catalog from `/api/v1/mcp`. The catalog is
|
|
186
|
+
* scope-filtered server-side for the caller's key. Throws CloudError on failure.
|
|
187
|
+
*/
|
|
188
|
+
export async function fetchCloudTools(baseUrl, apiKey, projectId) {
|
|
189
|
+
const url = `${apiHostRoot(baseUrl)}/api/v1/mcp`;
|
|
190
|
+
const res = await fetch(url, {
|
|
191
|
+
headers: cloudHeaders(apiKey, projectId),
|
|
192
|
+
signal: AbortSignal.timeout(CLOUD_DEFAULTS.fetchTimeoutMs),
|
|
193
|
+
});
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
const body = await res.text().catch(() => '');
|
|
196
|
+
throw errorFromEnvelope(res.status, res.statusText, body);
|
|
197
|
+
}
|
|
198
|
+
return parseCatalogResponse(await res.json());
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Execute a platform tool via `POST /api/v1/mcp/call` and normalize the result
|
|
202
|
+
* to `{ok, text}`. Tool-level errors (is_error) and HTTP-level rejections (e.g.
|
|
203
|
+
* a read-only key calling a mutating tool) both come back as `ok: false` with
|
|
204
|
+
* the server's text — the agent should see tool errors as tool results, not
|
|
205
|
+
* exceptions. Only network/timeout failures throw.
|
|
206
|
+
*/
|
|
207
|
+
export async function callCloudTool(baseUrl, apiKey, name, args, projectId) {
|
|
208
|
+
const url = `${apiHostRoot(baseUrl)}/api/v1/mcp/call`;
|
|
209
|
+
const res = await fetch(url, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { ...cloudHeaders(apiKey, projectId), 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ name, arguments: args }),
|
|
213
|
+
signal: AbortSignal.timeout(CLOUD_DEFAULTS.callTimeoutMs),
|
|
214
|
+
});
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
const body = await res.text().catch(() => '');
|
|
217
|
+
const err = errorFromEnvelope(res.status, res.statusText, body);
|
|
218
|
+
return { ok: false, text: err.docsUrl ? `${err.message} (docs: ${err.docsUrl})` : err.message };
|
|
219
|
+
}
|
|
220
|
+
return parseToolCallResult(await res.json());
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Group tools by their platform area for display: scalix_db_query → "db",
|
|
224
|
+
* scalix_run_deploy → "run", non-scalix names → "other".
|
|
225
|
+
*/
|
|
226
|
+
export function groupCloudTools(tools) {
|
|
227
|
+
const groups = new Map();
|
|
228
|
+
for (const t of tools) {
|
|
229
|
+
const m = t.name.match(/^scalix_([a-z0-9]+)_/) ?? t.name.match(/^scalix_([a-z0-9]+)$/);
|
|
230
|
+
const key = m ? m[1] : 'other';
|
|
231
|
+
const list = groups.get(key) ?? [];
|
|
232
|
+
list.push(t);
|
|
233
|
+
groups.set(key, list);
|
|
234
|
+
}
|
|
235
|
+
return groups;
|
|
236
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -62,6 +62,11 @@ export async function loadCustomCommands(cwd = process.cwd()) {
|
|
|
62
62
|
}
|
|
63
63
|
function expandFileReferences(text, cwd) {
|
|
64
64
|
return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
|
|
65
|
+
// Reject path traversal so a command file can't pull in files outside the
|
|
66
|
+
// project via `@../../etc/passwd`.
|
|
67
|
+
if (filePath.split(/[\\/]/).includes('..')) {
|
|
68
|
+
return `@${filePath} (path traversal blocked)`;
|
|
69
|
+
}
|
|
65
70
|
const resolved = path.resolve(cwd, filePath);
|
|
66
71
|
if (!existsSync(resolved))
|
|
67
72
|
return `@${filePath} (file not found)`;
|
|
@@ -84,9 +89,18 @@ function expandInlineBash(text) {
|
|
|
84
89
|
}
|
|
85
90
|
});
|
|
86
91
|
}
|
|
92
|
+
// Apply the same sanitization used for $ARGS to a single argument so positional
|
|
93
|
+
// args ($1..$9) can't smuggle shell metacharacters into a later `!`cmd`` inline
|
|
94
|
+
// bash expansion.
|
|
95
|
+
function sanitizeArg(arg) {
|
|
96
|
+
return arg
|
|
97
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
98
|
+
.replace(/\$/g, '\\$')
|
|
99
|
+
.replace(/`/g, '\\`');
|
|
100
|
+
}
|
|
87
101
|
function expandPositionalArgs(text, args) {
|
|
88
102
|
const parts = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
89
|
-
const cleaned = parts.map(p => p.replace(/^["']|["']$/g, ''));
|
|
103
|
+
const cleaned = parts.map(p => sanitizeArg(p.replace(/^["']|["']$/g, '')));
|
|
90
104
|
let result = text;
|
|
91
105
|
for (let i = 0; i < 9; i++) {
|
|
92
106
|
const placeholder = `$${i + 1}`;
|
|
@@ -100,12 +114,15 @@ export function expandCommand(command, args) {
|
|
|
100
114
|
.replace(/\$/g, '\\$')
|
|
101
115
|
.replace(/`/g, '\\`');
|
|
102
116
|
let prompt = command.template;
|
|
117
|
+
// Run inline bash (!`cmd`) FIRST, against the raw template only, so that no
|
|
118
|
+
// user-supplied argument can be substituted into a command line before it is
|
|
119
|
+
// executed. Any $ / backtick coming from args afterward stays inert text.
|
|
120
|
+
prompt = expandInlineBash(prompt);
|
|
103
121
|
prompt = expandPositionalArgs(prompt, args);
|
|
104
122
|
prompt = prompt.replace(/\$ARGS/g, safeArgs);
|
|
105
123
|
prompt = prompt.replace(/\$CWD/g, process.cwd());
|
|
106
124
|
prompt = prompt.replace(/\$DATE/g, new Date().toISOString().split('T')[0]);
|
|
107
125
|
prompt = expandFileReferences(prompt, process.cwd());
|
|
108
|
-
prompt = expandInlineBash(prompt);
|
|
109
126
|
return prompt;
|
|
110
127
|
}
|
|
111
128
|
export function commandsSummary(commands) {
|
package/dist/constants.js
CHANGED
|
@@ -1,7 +1,42 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
4
|
+
// Single source of truth for the version is package.json — a hardcoded copy
|
|
5
|
+
// here drifted twice (1.1.0 vs 1.2.0, then 1.3.0 vs 1.3.2). Both src/ (tsx
|
|
6
|
+
// dev) and dist/ (build) sit one level below the package root.
|
|
7
|
+
function readPackageVersion() {
|
|
8
|
+
try {
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkg = JSON.parse(readFileSync(resolvePath(here, '..', 'package.json'), 'utf-8'));
|
|
11
|
+
if (typeof pkg.version === 'string')
|
|
12
|
+
return pkg.version;
|
|
13
|
+
}
|
|
14
|
+
catch { }
|
|
15
|
+
return '0.0.0';
|
|
16
|
+
}
|
|
17
|
+
export const VERSION = readPackageVersion();
|
|
2
18
|
export const PRODUCT_NAME = 'Scalix-Coder';
|
|
3
19
|
export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
|
|
4
|
-
|
|
20
|
+
// Default API base: the bare Scalix host root. The client composes the full
|
|
21
|
+
// gateway paths itself (chat → /v1/ai/chat/completions, models → /v1/ai/models,
|
|
22
|
+
// audio → /v1/ai/audio/transcriptions) via aiEndpointUrl(). Override with
|
|
23
|
+
// SCALIX_BASE_URL or the config file's `baseUrl` for self-hosted gateways —
|
|
24
|
+
// an override is likewise a host root. A trailing `/v1` or `/v1/ai` (the
|
|
25
|
+
// pre-1.3.3 default shape, still present in older saved configs) is tolerated
|
|
26
|
+
// and stripped by apiHostRoot().
|
|
27
|
+
export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world';
|
|
28
|
+
/**
|
|
29
|
+
* Normalize a configured base URL to the API host root: trailing slashes are
|
|
30
|
+
* removed, and a trailing `/v1` or `/v1/ai` segment (legacy config shape) is
|
|
31
|
+
* stripped so old saved configs keep working.
|
|
32
|
+
*/
|
|
33
|
+
export function apiHostRoot(baseUrl) {
|
|
34
|
+
return baseUrl.replace(/\/+$/, '').replace(/\/v1(\/ai)?$/, '');
|
|
35
|
+
}
|
|
36
|
+
/** Compose a full URL for an endpoint under the gateway's `/v1/ai` nest. */
|
|
37
|
+
export function aiEndpointUrl(baseUrl, endpoint) {
|
|
38
|
+
return `${apiHostRoot(baseUrl)}/v1/ai/${endpoint.replace(/^\/+/, '')}`;
|
|
39
|
+
}
|
|
5
40
|
export const DEFAULT_MODELS = [];
|
|
6
41
|
export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You write clean, correct, production-quality code. Be concise and direct.
|
|
7
42
|
|
|
@@ -38,13 +73,13 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
|
|
|
38
73
|
|
|
39
74
|
## Scalix Platform
|
|
40
75
|
When helping users build AI applications, agents, or LLM-powered features:
|
|
41
|
-
- Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
|
|
76
|
+
- Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
|
|
42
77
|
- Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
|
|
78
|
+
- Scalix inference models are named scalix-lumio-* (e.g. scalix-lumio-lite); use those ids in examples and configs.
|
|
43
79
|
- For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
|
|
44
80
|
- For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
|
|
45
81
|
- For code execution sandboxes, use Scalix Sandbox.
|
|
46
|
-
- When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice
|
|
47
|
-
- Scalix supports local AI via Ollama — suggest this for privacy-sensitive or offline use cases.`;
|
|
82
|
+
- When the user explicitly requests a specific provider (e.g. "use OpenAI directly"), respect that choice.`;
|
|
48
83
|
export const ENV_API_KEY = 'SCALIX_API_KEY';
|
|
49
84
|
export const ENV_MODEL = 'SCALIX_MODEL';
|
|
50
85
|
export const ENV_BASE_URL = 'SCALIX_BASE_URL';
|
|
@@ -102,7 +137,14 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
102
137
|
if (!apiKey)
|
|
103
138
|
return [];
|
|
104
139
|
try {
|
|
105
|
-
|
|
140
|
+
// GET /v1/ai/models — OpenAI-style `{data: [{id, ...}]}`. The gateway
|
|
141
|
+
// rebrands the list to Scalix model ids and strips provider metadata, so
|
|
142
|
+
// `id` (and sometimes `name`, which equals the id) is all that's
|
|
143
|
+
// guaranteed. Spec fields (context_window, max_output_tokens,
|
|
144
|
+
// supports_vision) are used when the platform supplies them; models
|
|
145
|
+
// without them are NOT registered and fall back to DEFAULT_MODEL_SPEC via
|
|
146
|
+
// getModelSpec() — no per-model limits are fabricated client-side.
|
|
147
|
+
const url = aiEndpointUrl(baseUrl, 'models');
|
|
106
148
|
const res = await fetch(url, {
|
|
107
149
|
headers: {
|
|
108
150
|
'Authorization': `Bearer ${apiKey}`,
|
|
@@ -113,10 +155,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
113
155
|
if (!res.ok)
|
|
114
156
|
return [];
|
|
115
157
|
const data = await res.json();
|
|
116
|
-
const models = data.
|
|
158
|
+
const models = data.data || [];
|
|
117
159
|
const fetched = {};
|
|
118
160
|
const result = [];
|
|
119
161
|
for (const m of models) {
|
|
162
|
+
if (!m || typeof m.id !== 'string')
|
|
163
|
+
continue;
|
|
120
164
|
if (m.context_window && m.context_window > 0) {
|
|
121
165
|
fetched[m.id] = {
|
|
122
166
|
contextWindow: m.context_window,
|
|
@@ -129,8 +173,6 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
129
173
|
name: m.name,
|
|
130
174
|
contextWindow: m.context_window,
|
|
131
175
|
maxOutputTokens: m.max_output_tokens,
|
|
132
|
-
tier: m.tier,
|
|
133
|
-
provider: m.provider,
|
|
134
176
|
supportsVision: m.supports_vision,
|
|
135
177
|
});
|
|
136
178
|
}
|
|
@@ -142,8 +184,12 @@ export async function fetchModelSpecs(baseUrl, apiKey) {
|
|
|
142
184
|
}
|
|
143
185
|
}
|
|
144
186
|
// ─── Search Endpoints (configurable via env vars) ───
|
|
187
|
+
// The Scalix platform does not currently expose a customer-facing web-research
|
|
188
|
+
// endpoint (the internal research/search routes are not routed through the
|
|
189
|
+
// public gateway). Web search therefore relies on the user-configured
|
|
190
|
+
// providers below; when the platform ships a public research API, add it back
|
|
191
|
+
// here as the default.
|
|
145
192
|
export const SEARCH_ENDPOINTS = {
|
|
146
|
-
scalix: `${(process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1').replace(/\/v1$/, '')}/research/search`,
|
|
147
193
|
brave: process.env.BRAVE_SEARCH_URL || 'https://api.search.brave.com/res/v1/web/search',
|
|
148
194
|
tavily: process.env.TAVILY_SEARCH_URL || 'https://api.tavily.com/search',
|
|
149
195
|
duckduckgo: process.env.DDG_SEARCH_URL || 'https://api.duckduckgo.com/',
|
|
@@ -272,8 +318,11 @@ export const TOKEN_ESTIMATION = {
|
|
|
272
318
|
export const PRIVATE_NETWORK_HOSTNAMES = new Set([
|
|
273
319
|
'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
|
|
274
320
|
'::ffff:127.0.0.1', '[::ffff:127.0.0.1]',
|
|
321
|
+
'::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
|
|
322
|
+
// common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
|
|
323
|
+
'metadata.google.internal', 'metadata',
|
|
275
324
|
]);
|
|
276
|
-
export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal'];
|
|
325
|
+
export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
|
|
277
326
|
export const PRIVATE_NETWORK_CIDRS = [
|
|
278
327
|
/^10\./,
|
|
279
328
|
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
@@ -292,6 +341,78 @@ export function setNetworkSecurityOverrides(overrides) {
|
|
|
292
341
|
allowedHosts: overrides.allowedHosts ? new Set(overrides.allowedHosts.map(h => h.toLowerCase())) : undefined,
|
|
293
342
|
};
|
|
294
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Canonicalize a host string to a comparable IPv4 dotted-quad when it is a
|
|
346
|
+
* numeric address written in a non-standard form (decimal, hex, octal, or
|
|
347
|
+
* with fewer than 4 parts). Returns null when the input is not a bare IPv4-ish
|
|
348
|
+
* number. This closes SSRF bypasses like http://2130706433/ (=127.0.0.1),
|
|
349
|
+
* http://0x7f.1/ or http://127.1/.
|
|
350
|
+
*/
|
|
351
|
+
export function canonicalizeIpv4(host) {
|
|
352
|
+
const h = host.trim().replace(/^\[|\]$/g, '');
|
|
353
|
+
if (!/^[0-9a-fx.]+$/i.test(h))
|
|
354
|
+
return null;
|
|
355
|
+
const parts = h.split('.');
|
|
356
|
+
if (parts.length === 0 || parts.length > 4)
|
|
357
|
+
return null;
|
|
358
|
+
const nums = [];
|
|
359
|
+
for (const p of parts) {
|
|
360
|
+
if (p === '')
|
|
361
|
+
return null;
|
|
362
|
+
let n;
|
|
363
|
+
if (/^0x[0-9a-f]+$/i.test(p))
|
|
364
|
+
n = parseInt(p, 16);
|
|
365
|
+
else if (/^0[0-7]+$/.test(p))
|
|
366
|
+
n = parseInt(p, 8);
|
|
367
|
+
else if (/^[0-9]+$/.test(p))
|
|
368
|
+
n = parseInt(p, 10);
|
|
369
|
+
else
|
|
370
|
+
return null;
|
|
371
|
+
if (!Number.isFinite(n) || n < 0)
|
|
372
|
+
return null;
|
|
373
|
+
nums.push(n);
|
|
374
|
+
}
|
|
375
|
+
// Fold the trailing part as a "rest" value per inet_aton semantics.
|
|
376
|
+
let value;
|
|
377
|
+
if (nums.length === 1) {
|
|
378
|
+
value = nums[0];
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const last = nums[nums.length - 1];
|
|
382
|
+
const head = nums.slice(0, -1);
|
|
383
|
+
if (head.some(n => n > 255))
|
|
384
|
+
return null;
|
|
385
|
+
const restMax = Math.pow(256, 4 - head.length);
|
|
386
|
+
if (last >= restMax)
|
|
387
|
+
return null;
|
|
388
|
+
value = last;
|
|
389
|
+
for (let i = 0; i < head.length; i++) {
|
|
390
|
+
value += head[i] * Math.pow(256, 3 - i);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (value < 0 || value > 0xffffffff)
|
|
394
|
+
return null;
|
|
395
|
+
return [
|
|
396
|
+
(value >>> 24) & 0xff,
|
|
397
|
+
(value >>> 16) & 0xff,
|
|
398
|
+
(value >>> 8) & 0xff,
|
|
399
|
+
value & 0xff,
|
|
400
|
+
].join('.');
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Check whether a concrete IP address (already resolved, or a literal) points
|
|
404
|
+
* at a private/loopback/link-local/metadata range. Use this after DNS
|
|
405
|
+
* resolution to defeat DNS-rebinding and numeric-encoding SSRF.
|
|
406
|
+
*/
|
|
407
|
+
export function isPrivateIp(ip) {
|
|
408
|
+
const canon = canonicalizeIpv4(ip);
|
|
409
|
+
const candidate = (canon ?? ip).toLowerCase();
|
|
410
|
+
if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
|
|
411
|
+
return true;
|
|
412
|
+
if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
|
|
413
|
+
return true;
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
295
416
|
export function isPrivateHost(hostname) {
|
|
296
417
|
const lower = hostname.toLowerCase();
|
|
297
418
|
if (_networkOverrides.allowAll)
|
|
@@ -300,6 +421,12 @@ export function isPrivateHost(hostname) {
|
|
|
300
421
|
return false;
|
|
301
422
|
if (_networkOverrides.blockedHosts?.has(lower))
|
|
302
423
|
return true;
|
|
424
|
+
// Normalize numeric IPv4 encodings (decimal/hex/octal/short forms) so that
|
|
425
|
+
// e.g. 2130706433, 0x7f000001 and 127.1 are all recognized as loopback.
|
|
426
|
+
const canonical = canonicalizeIpv4(lower);
|
|
427
|
+
if (canonical && (PRIVATE_NETWORK_HOSTNAMES.has(canonical) || PRIVATE_NETWORK_CIDRS.some(re => re.test(canonical)))) {
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
303
430
|
if (PRIVATE_NETWORK_HOSTNAMES.has(lower))
|
|
304
431
|
return true;
|
|
305
432
|
if (PRIVATE_NETWORK_SUFFIXES.some(s => lower.endsWith(s)))
|
|
@@ -70,7 +70,7 @@ function scoreFiles(analysis, index) {
|
|
|
70
70
|
deps.add(imp.to);
|
|
71
71
|
importGraph.set(imp.from, deps);
|
|
72
72
|
}
|
|
73
|
-
const recentFiles = new Set(index.files
|
|
73
|
+
const recentFiles = new Set([...index.files]
|
|
74
74
|
.sort((a, b) => b.modifiedAt - a.modifiedAt)
|
|
75
75
|
.slice(0, 20)
|
|
76
76
|
.map((f) => f.relativePath));
|
package/dist/cost-tracker.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export class CostTracker {
|
|
2
2
|
entries = [];
|
|
3
3
|
sessionStart = Date.now();
|
|
4
|
+
restoredRequestCount = 0;
|
|
4
5
|
record(model, usage) {
|
|
5
6
|
this.entries.push({
|
|
6
7
|
model,
|
|
@@ -24,7 +25,10 @@ export class CostTracker {
|
|
|
24
25
|
return this.entries.reduce((s, e) => s + e.completionTokens, 0);
|
|
25
26
|
}
|
|
26
27
|
get requestCount() {
|
|
27
|
-
|
|
28
|
+
// The single restored aggregate entry stands in for the previous session's
|
|
29
|
+
// request count, tracked separately so we don't over-count live requests.
|
|
30
|
+
const liveEntries = this.entries.filter((e) => e.model !== '(previous session)').length;
|
|
31
|
+
return liveEntries + this.restoredRequestCount;
|
|
28
32
|
}
|
|
29
33
|
get sessionDuration() {
|
|
30
34
|
const s = Math.floor((Date.now() - this.sessionStart) / 1000);
|
|
@@ -57,25 +61,26 @@ export class CostTracker {
|
|
|
57
61
|
}
|
|
58
62
|
reset() {
|
|
59
63
|
this.entries = [];
|
|
64
|
+
this.restoredRequestCount = 0;
|
|
60
65
|
this.sessionStart = Date.now();
|
|
61
66
|
}
|
|
62
67
|
restore(state) {
|
|
63
68
|
this.entries = [];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
69
|
+
// Store a single aggregate entry rather than fabricating `requestCount`
|
|
70
|
+
// synthetic entries — a corrupted/huge count would otherwise allocate
|
|
71
|
+
// unboundedly. The original request count is preserved separately.
|
|
72
|
+
this.restoredRequestCount = Number.isFinite(state.requestCount) && state.requestCount > 0
|
|
73
|
+
? Math.floor(state.requestCount)
|
|
74
|
+
: 0;
|
|
75
|
+
if (this.restoredRequestCount > 0 && state.totalTokens >= 0) {
|
|
76
|
+
this.entries.push({
|
|
77
|
+
model: '(previous session)',
|
|
78
|
+
promptTokens: 0,
|
|
79
|
+
completionTokens: 0,
|
|
80
|
+
totalTokens: state.totalTokens,
|
|
81
|
+
cost: state.totalCost,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
});
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
getState() {
|
package/dist/git-context.js
CHANGED
|
@@ -18,13 +18,13 @@ async function getBranch(cwd) {
|
|
|
18
18
|
return (await run('git branch --show-current', cwd)) || (await run('git rev-parse --short HEAD', cwd)) || 'unknown';
|
|
19
19
|
}
|
|
20
20
|
async function getRemoteBranch(cwd) {
|
|
21
|
-
return run('git rev-parse --abbrev-ref @{upstream}
|
|
21
|
+
return run('git rev-parse --abbrev-ref @{upstream}', cwd);
|
|
22
22
|
}
|
|
23
23
|
async function getRemoteUrl(cwd) {
|
|
24
|
-
return run('git remote get-url origin
|
|
24
|
+
return run('git remote get-url origin', cwd);
|
|
25
25
|
}
|
|
26
26
|
async function getAheadBehind(cwd) {
|
|
27
|
-
const result = await run('git rev-list --left-right --count @{upstream}...HEAD
|
|
27
|
+
const result = await run('git rev-list --left-right --count @{upstream}...HEAD', cwd);
|
|
28
28
|
if (!result)
|
|
29
29
|
return { ahead: 0, behind: 0 };
|
|
30
30
|
const parts = result.split('\t');
|
|
@@ -33,7 +33,7 @@ async function getAheadBehind(cwd) {
|
|
|
33
33
|
return { ahead: Number(parts[1]) || 0, behind: Number(parts[0]) || 0 };
|
|
34
34
|
}
|
|
35
35
|
async function getStashCount(cwd) {
|
|
36
|
-
const result = await run('git stash list
|
|
36
|
+
const result = await run('git stash list', cwd);
|
|
37
37
|
return result ? result.split('\n').filter(Boolean).length : 0;
|
|
38
38
|
}
|
|
39
39
|
async function getStatus(cwd) {
|
|
@@ -74,7 +74,10 @@ async function getStatus(cwd) {
|
|
|
74
74
|
async function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount) {
|
|
75
75
|
const SEP = '<%>';
|
|
76
76
|
const format = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`;
|
|
77
|
-
|
|
77
|
+
// No shell redirects here: `2>/dev/null` breaks under cmd.exe on Windows
|
|
78
|
+
// (redirects to a literal \dev\null path and fails the whole command).
|
|
79
|
+
// run() already discards stderr and swallows failures.
|
|
80
|
+
const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count}`, cwd);
|
|
78
81
|
if (!result)
|
|
79
82
|
return [];
|
|
80
83
|
const commits = [];
|
|
@@ -20,7 +20,14 @@ function surroundingContext(text, pos, radius = 60) {
|
|
|
20
20
|
const end = Math.min(text.length, pos + radius);
|
|
21
21
|
return text.slice(start, end).replace(/\n/g, ' ').trim();
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
// Blank out fenced code blocks (```...```) with equal-length whitespace so the
|
|
24
|
+
// model's own newly-authored symbols aren't flagged as ungrounded, while
|
|
25
|
+
// keeping character offsets stable for surroundingContext().
|
|
26
|
+
function maskFencedCodeBlocks(text) {
|
|
27
|
+
return text.replace(/```[\s\S]*?(?:```|$)/g, (block) => block.replace(/[^\n]/g, ' '));
|
|
28
|
+
}
|
|
29
|
+
export function extractCodeClaims(rawText) {
|
|
30
|
+
const text = maskFencedCodeBlocks(rawText);
|
|
24
31
|
const seen = new Set();
|
|
25
32
|
const claims = [];
|
|
26
33
|
function add(type, value, position) {
|