scalix 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -3
- package/CHANGELOG.md +24 -0
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +358 -42
- package/dist/api-client.js +12 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +435 -94
- package/dist/cloud.js +95 -6
- package/dist/config.js +44 -5
- package/dist/constants.js +121 -19
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +44 -13
- package/dist/ipc-server.js +362 -99
- package/dist/keychain.js +34 -9
- package/dist/mcp.js +470 -28
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/sandbox.js +115 -27
- package/dist/sessions.js +12 -0
- package/dist/settings.js +33 -2
- package/dist/slash-commands.js +6 -0
- package/dist/sub-agent.js +3 -2
- package/dist/tools-browser.js +87 -16
- package/dist/tools-extra.js +15 -3
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +67 -6
- package/dist/tools.js +15 -14
- package/dist/workspace-trust.js +7 -1
- package/package.json +3 -1
package/dist/project-index.js
CHANGED
|
@@ -205,42 +205,65 @@ async function extractSymbols(filePath, relativePath) {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
// Import patterns for dependency tracking
|
|
208
|
-
const
|
|
209
|
-
// JS/TS: import { x } from './y' or import x from './y' or require('./y')
|
|
210
|
-
{ ext: new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']),
|
|
211
|
-
pattern: /(?:import\s+(?:{([^}]+)}\s+from\s+|(\w+)\s+from\s+)?['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g },
|
|
212
|
-
// Python: from x import y or import x
|
|
213
|
-
{ ext: new Set(['.py']),
|
|
214
|
-
pattern: /(?:from\s+([\w.]+)\s+import\s+([\w,\s*]+)|import\s+([\w.]+))/g },
|
|
215
|
-
// Go: import "x" or import ( "x" )
|
|
216
|
-
{ ext: new Set(['.go']),
|
|
217
|
-
pattern: /import\s+(?:(?:\w+\s+)?"([^"]+)"|[\s\S]*?"([^"]+)")/g },
|
|
218
|
-
// Rust: use x::y
|
|
219
|
-
{ ext: new Set(['.rs']),
|
|
220
|
-
pattern: /use\s+([\w:]+(?:::\{[^}]+\})?)/g },
|
|
221
|
-
];
|
|
208
|
+
const JS_TS_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
222
209
|
function extractImports(content, relativePath, ext) {
|
|
223
210
|
const imports = [];
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const regex = new RegExp(pattern.source, pattern.flags);
|
|
211
|
+
if (JS_TS_EXTS.has(ext)) {
|
|
212
|
+
// Matches: import ... from '...'; import '...'; export ... from '...'; require('...')
|
|
213
|
+
const jsPattern = /(?:(?:import|export)\s+(?:type\s+)?(?:(?:\*\s+as\s+\w+|{([\s\S]*?)}|[\w\s,]+)\s+from\s+)?['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g;
|
|
228
214
|
let match;
|
|
229
|
-
while ((match =
|
|
230
|
-
const modulePath = match[
|
|
215
|
+
while ((match = jsPattern.exec(content)) !== null) {
|
|
216
|
+
const modulePath = match[2] || match[3] || '';
|
|
231
217
|
if (!modulePath)
|
|
232
218
|
continue;
|
|
233
|
-
// Only track local imports (starting with . or relative paths)
|
|
234
219
|
const isLocal = modulePath.startsWith('.') || modulePath.startsWith('/');
|
|
235
220
|
if (!isLocal)
|
|
236
221
|
continue;
|
|
237
|
-
const
|
|
222
|
+
const symbolsRaw = match[1] || '';
|
|
223
|
+
const symbols = symbolsRaw
|
|
224
|
+
? symbolsRaw.split(',').map((s) => s.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim()).filter(Boolean)
|
|
225
|
+
: [];
|
|
238
226
|
imports.push({
|
|
239
227
|
from: relativePath,
|
|
240
228
|
to: modulePath,
|
|
241
|
-
symbols
|
|
229
|
+
symbols,
|
|
242
230
|
});
|
|
243
231
|
}
|
|
232
|
+
return imports;
|
|
233
|
+
}
|
|
234
|
+
if (ext === '.py') {
|
|
235
|
+
const pyPattern = /(?:from\s+([\w.]+)\s+import\s+([\w,\s*]+)|import\s+([\w.]+))/g;
|
|
236
|
+
let match;
|
|
237
|
+
while ((match = pyPattern.exec(content)) !== null) {
|
|
238
|
+
const modulePath = match[1] || match[3] || '';
|
|
239
|
+
if (!modulePath || !modulePath.startsWith('.'))
|
|
240
|
+
continue;
|
|
241
|
+
const symbols = (match[2] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
242
|
+
imports.push({ from: relativePath, to: modulePath, symbols });
|
|
243
|
+
}
|
|
244
|
+
return imports;
|
|
245
|
+
}
|
|
246
|
+
if (ext === '.go') {
|
|
247
|
+
const goPattern = /import\s+(?:(?:\w+\s+)?"([^"]+)"|[\s\S]*?"([^"]+)")/g;
|
|
248
|
+
let match;
|
|
249
|
+
while ((match = goPattern.exec(content)) !== null) {
|
|
250
|
+
const modulePath = match[1] || match[2] || '';
|
|
251
|
+
if (!modulePath || (!modulePath.startsWith('.') && !modulePath.startsWith('/')))
|
|
252
|
+
continue;
|
|
253
|
+
imports.push({ from: relativePath, to: modulePath, symbols: [] });
|
|
254
|
+
}
|
|
255
|
+
return imports;
|
|
256
|
+
}
|
|
257
|
+
if (ext === '.rs') {
|
|
258
|
+
const rsPattern = /use\s+((?:crate|super|self)::[\w:]+(?:::\{[^}]+\})?)/g;
|
|
259
|
+
let match;
|
|
260
|
+
while ((match = rsPattern.exec(content)) !== null) {
|
|
261
|
+
const modulePath = match[1] || '';
|
|
262
|
+
if (!modulePath)
|
|
263
|
+
continue;
|
|
264
|
+
imports.push({ from: relativePath, to: modulePath, symbols: [] });
|
|
265
|
+
}
|
|
266
|
+
return imports;
|
|
244
267
|
}
|
|
245
268
|
return imports;
|
|
246
269
|
}
|
package/dist/sandbox.js
CHANGED
|
@@ -15,8 +15,15 @@ function normalizeSep(p) {
|
|
|
15
15
|
return process.platform === 'win32' ? p.replace(/\//g, '\\') : p;
|
|
16
16
|
}
|
|
17
17
|
export function isWithinRoot(p, root) {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Case-fold only where the filesystem does. Folding on Linux made
|
|
19
|
+
// `/srv/Secret/x` test as inside `/srv/secret` — two genuinely different
|
|
20
|
+
// directories — which is an escape, not a convenience. macOS is usually
|
|
21
|
+
// case-insensitive but can be formatted otherwise; comparing exactly there
|
|
22
|
+
// can only reject a path that is really inside the root, which fails closed,
|
|
23
|
+
// and callers realpath first so the on-disk casing already agrees.
|
|
24
|
+
const fold = (s) => (IS_WINDOWS ? s.toLowerCase() : s);
|
|
25
|
+
const np = fold(normalizeSep(p));
|
|
26
|
+
const nr = fold(normalizeSep(root));
|
|
20
27
|
return np === nr || np.startsWith(nr + path.sep);
|
|
21
28
|
}
|
|
22
29
|
// Resolve symlinks on the longest existing prefix of `resolved` synchronously.
|
|
@@ -83,24 +90,44 @@ export function sandboxPath(filePath) {
|
|
|
83
90
|
}
|
|
84
91
|
return real;
|
|
85
92
|
}
|
|
93
|
+
// Async twin of realpathLongestExistingPrefix.
|
|
94
|
+
async function realpathLongestExistingPrefixAsync(resolved) {
|
|
95
|
+
let existing = resolved;
|
|
96
|
+
const remainder = [];
|
|
97
|
+
while (true) {
|
|
98
|
+
try {
|
|
99
|
+
// fs/promises has no realpath.native, so strip the prefix defensively.
|
|
100
|
+
const real = stripExtendedLengthPrefix(await fs.realpath(existing));
|
|
101
|
+
return remainder.length > 0 ? path.join(real, ...remainder) : real;
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (err.code !== 'ENOENT')
|
|
105
|
+
throw err;
|
|
106
|
+
const parent = path.dirname(existing);
|
|
107
|
+
if (parent === existing)
|
|
108
|
+
return resolved;
|
|
109
|
+
remainder.unshift(path.basename(existing));
|
|
110
|
+
existing = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
86
114
|
export async function sandboxPathAsync(filePath) {
|
|
87
115
|
const root = getSandboxRoot();
|
|
88
116
|
const resolved = path.resolve(root, filePath);
|
|
89
117
|
if (!isWithinRoot(resolved, root)) {
|
|
90
118
|
throw new Error(`Path traversal blocked: "${filePath}" resolves outside working directory`);
|
|
91
119
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return resolved;
|
|
102
|
-
throw err;
|
|
120
|
+
// The path usually does NOT exist yet — every write of a new file lands here.
|
|
121
|
+
// Returning `resolved` unchecked on ENOENT was the hole: only the leaf has to
|
|
122
|
+
// be missing for the check to be skipped, so `<root>/link/new.txt`, where
|
|
123
|
+
// `link` is a symlink to /etc, wrote straight out of the sandbox. Resolve the
|
|
124
|
+
// longest ancestor that does exist and verify containment on that, exactly as
|
|
125
|
+
// the sync path already does.
|
|
126
|
+
const real = await realpathLongestExistingPrefixAsync(resolved);
|
|
127
|
+
if (!isWithinRoot(real, realSandboxRoot(root))) {
|
|
128
|
+
throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
|
|
103
129
|
}
|
|
130
|
+
return real;
|
|
104
131
|
}
|
|
105
132
|
let _scopedRules = [];
|
|
106
133
|
export function setScopedPermissionRules(rules) {
|
|
@@ -115,6 +142,12 @@ function parseScopedToolSpec(spec) {
|
|
|
115
142
|
return { tool: match[1], prefix: match[2].replace(/\*$/, '') };
|
|
116
143
|
return { tool: spec };
|
|
117
144
|
}
|
|
145
|
+
// A prefix rule describes the command it names, not everything that can be
|
|
146
|
+
// stapled onto it. `run_command(git *)` was matching `git status; curl x | sh`
|
|
147
|
+
// with a plain startsWith, so one allow rule for a read-only command handed
|
|
148
|
+
// over arbitrary execution. Anything here means the text after the prefix is no
|
|
149
|
+
// longer the command the rule was written about.
|
|
150
|
+
const SHELL_CHAINING_RE = /[;&|<>\n\r`]|\$\(/;
|
|
118
151
|
export function checkScopedPermission(toolName, args) {
|
|
119
152
|
if (_scopedRules.length === 0)
|
|
120
153
|
return null;
|
|
@@ -125,17 +158,40 @@ export function checkScopedPermission(toolName, args) {
|
|
|
125
158
|
continue;
|
|
126
159
|
if (parsed.tool !== toolName)
|
|
127
160
|
continue;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if
|
|
131
|
-
|
|
161
|
+
const deny = rule.action === 'deny';
|
|
162
|
+
// deny is an OR over its conditions, allow is an AND. Both directions round
|
|
163
|
+
// toward refusing: a deny rule fires if anything it names matches, and an
|
|
164
|
+
// allow rule fires only if everything it names matches. Previously both
|
|
165
|
+
// were an OR that `prefix`-less rules short-circuited past entirely, so
|
|
166
|
+
// `{tool:'run_command', regex:'^git status$', action:'allow'}` allowed
|
|
167
|
+
// every command the tool could run — the regex was never consulted.
|
|
168
|
+
let prefixHit = null;
|
|
169
|
+
if (parsed.prefix) {
|
|
170
|
+
prefixHit = !!argStr && argStr.startsWith(parsed.prefix);
|
|
171
|
+
// Only allow rules need the chaining check; a deny that matched should
|
|
172
|
+
// keep matching however the command continues.
|
|
173
|
+
if (prefixHit && !deny && toolName === 'run_command'
|
|
174
|
+
&& SHELL_CHAINING_RE.test(argStr.slice(parsed.prefix.length))) {
|
|
175
|
+
prefixHit = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
let regexHit = null;
|
|
132
179
|
if (rule.regex) {
|
|
133
180
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
181
|
+
regexHit = new RegExp(rule.regex).test(argStr || '');
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// An unparseable pattern is a config error. Honouring it as a match on
|
|
185
|
+
// a deny rule over-blocks; ignoring it on an allow rule under-permits.
|
|
186
|
+
regexHit = deny;
|
|
136
187
|
}
|
|
137
|
-
catch { }
|
|
138
188
|
}
|
|
189
|
+
const conditions = [prefixHit, regexHit].filter((c) => c !== null);
|
|
190
|
+
const matched = conditions.length === 0
|
|
191
|
+
? true // bare `run_command` — the whole tool
|
|
192
|
+
: deny ? conditions.some(Boolean) : conditions.every(Boolean);
|
|
193
|
+
if (matched)
|
|
194
|
+
return rule.action;
|
|
139
195
|
}
|
|
140
196
|
return null;
|
|
141
197
|
}
|
|
@@ -264,11 +320,16 @@ export function getToolPermission(toolName, mode) {
|
|
|
264
320
|
}
|
|
265
321
|
export function getToolPermissionWithArgs(toolName, mode, args) {
|
|
266
322
|
const scoped = checkScopedPermission(toolName, args);
|
|
267
|
-
if (scoped === 'allow')
|
|
268
|
-
return 'allow';
|
|
269
323
|
if (scoped === 'deny')
|
|
270
324
|
return 'block';
|
|
271
|
-
|
|
325
|
+
const base = getToolPermission(toolName, mode);
|
|
326
|
+
// A scoped allow skips the confirmation prompt; it does not widen the mode.
|
|
327
|
+
// Returning 'allow' unconditionally let a saved `run_command(...)` rule run
|
|
328
|
+
// commands in plan mode — the one mode whose entire promise is that nothing
|
|
329
|
+
// executes. Rules earn 'ask' → 'allow', never 'block' → 'allow'.
|
|
330
|
+
if (scoped === 'allow')
|
|
331
|
+
return base === 'block' ? 'block' : 'allow';
|
|
332
|
+
return base;
|
|
272
333
|
}
|
|
273
334
|
export function isDangerousTool(toolName) {
|
|
274
335
|
const sets = getEffectiveToolSets();
|
|
@@ -409,15 +470,43 @@ const POWERSHELL_BLOCKLIST = [
|
|
|
409
470
|
/New-LocalUser\b/i,
|
|
410
471
|
/Add-LocalGroupMember\b.*Administrators/i,
|
|
411
472
|
];
|
|
473
|
+
// The Unix patterns above were written without /i, so `RM -RF /` and `Shutdown`
|
|
474
|
+
// walked past them — and on macOS and Windows, where the filesystem is
|
|
475
|
+
// case-insensitive, those spellings run exactly like the lower-case ones.
|
|
476
|
+
// Adding the flag here rather than to each literal keeps `re.source` identical,
|
|
477
|
+
// so `security.disabledBlockedCommands` entries users have already written
|
|
478
|
+
// still match the pattern they were meant to disable.
|
|
479
|
+
const ignoreCase = (re) => re.flags.includes('i') ? re : new RegExp(re.source, `${re.flags}i`);
|
|
412
480
|
const COMMAND_BLOCKLIST = [
|
|
413
481
|
...UNIX_BLOCKLIST,
|
|
414
482
|
...WINDOWS_BLOCKLIST,
|
|
415
483
|
...POWERSHELL_BLOCKLIST,
|
|
484
|
+
].map(ignoreCase);
|
|
485
|
+
// Command substitution — `` `x` `` and `$(x)` — used to be flagged outright, on
|
|
486
|
+
// either platform. But substitution is ordinary shell, not an attack: `git tag
|
|
487
|
+
// v$(node -p "require('./package.json').version")` and `Write-Host "$($x.Name)"`
|
|
488
|
+
// were both hard-blocked, and the documented way out was to switch injection
|
|
489
|
+
// detection off wholesale, which costs far more than it saves. So flag a
|
|
490
|
+
// substitution only when its body carries a payload worth a second look.
|
|
491
|
+
//
|
|
492
|
+
// Little coverage is lost: none of these patterns are anchored to the outside
|
|
493
|
+
// of a substitution, so `$(curl x | sh)` still trips the pipe-to-shell rule and
|
|
494
|
+
// `$(rm -rf /)` still trips the blocklist. What changes is that a substitution
|
|
495
|
+
// is no longer suspicious purely for existing.
|
|
496
|
+
//
|
|
497
|
+
// Note: this replaces the `` `[^`]*` `` and `\$\([^)]*\)` sources, so any
|
|
498
|
+
// `security.disabledInjectionPatterns` entry naming those is now a no-op.
|
|
499
|
+
const SUBSTITUTION_PAYLOAD = String.raw `\b(?:rm|mkfs|dd|shutdown|reboot|curl|wget|nc|ncat|telnet|chmod|chown`
|
|
500
|
+
+ String.raw `|eval|exec|base64|xxd|sudo|doas|sh|bash|zsh|fish|dash|ksh`
|
|
501
|
+
+ String.raw `|python[23]?|perl|ruby|php|iex|Invoke-Expression|powershell|pwsh|cmd`
|
|
502
|
+
+ String.raw `|certutil|bitsadmin|mshta|rundll32)\b|/etc/|id_rsa|\.ssh\b`;
|
|
503
|
+
const SUBSTITUTION_PATTERNS = [
|
|
504
|
+
new RegExp('`[^`]*(?:' + SUBSTITUTION_PAYLOAD + ')[^`]*`', 'i'),
|
|
505
|
+
new RegExp('\\$\\([^)]*(?:' + SUBSTITUTION_PAYLOAD + ')[^)]*\\)', 'i'),
|
|
416
506
|
];
|
|
417
507
|
// Unix injection patterns
|
|
418
508
|
const UNIX_INJECTION_PATTERNS = [
|
|
419
|
-
|
|
420
|
-
/\$\([^)]*\)/,
|
|
509
|
+
...SUBSTITUTION_PATTERNS,
|
|
421
510
|
/;\s*(rm|mkfs|dd|shutdown|reboot|curl|wget)\b/,
|
|
422
511
|
/\|\s*(ba)?sh\b/,
|
|
423
512
|
/\beval\s+/,
|
|
@@ -430,7 +519,6 @@ const UNIX_INJECTION_PATTERNS = [
|
|
|
430
519
|
// Windows/PowerShell injection patterns
|
|
431
520
|
const WINDOWS_INJECTION_PATTERNS = [
|
|
432
521
|
/&\s*\{[^}]*\}/,
|
|
433
|
-
/\$\([^)]*\)/,
|
|
434
522
|
/;\s*(del|rd|rmdir|format|diskpart|net\s+user|reg\s+delete)\b/i,
|
|
435
523
|
/\|\s*powershell\b/i,
|
|
436
524
|
/\|\s*pwsh\b/i,
|
|
@@ -445,7 +533,7 @@ const WINDOWS_INJECTION_PATTERNS = [
|
|
|
445
533
|
const INJECTION_PATTERNS = [
|
|
446
534
|
...UNIX_INJECTION_PATTERNS,
|
|
447
535
|
...WINDOWS_INJECTION_PATTERNS,
|
|
448
|
-
];
|
|
536
|
+
].map(ignoreCase);
|
|
449
537
|
const SENSITIVE_ENV_PATTERNS = [
|
|
450
538
|
/API_KEY/i, /SECRET/i, /TOKEN/i, /PASSWORD/i, /CREDENTIAL/i, /AUTH/i, /PRIVATE/i,
|
|
451
539
|
// Broad substrings that commonly carry secrets or connection strings.
|
package/dist/sessions.js
CHANGED
|
@@ -92,6 +92,18 @@ export async function listSessions() {
|
|
|
92
92
|
}
|
|
93
93
|
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
94
94
|
}
|
|
95
|
+
export async function renameSession(id, newName) {
|
|
96
|
+
const session = await loadSession(id);
|
|
97
|
+
const trimmed = newName.trim();
|
|
98
|
+
if (trimmed) {
|
|
99
|
+
session.meta.name = trimmed;
|
|
100
|
+
session.meta.updatedAt = new Date().toISOString();
|
|
101
|
+
}
|
|
102
|
+
const tmpPath = path.join(SESSIONS_DIR, `scalix-session-${id}-${Date.now()}-${randomUUID().slice(0, 8)}.tmp`);
|
|
103
|
+
await fs.writeFile(tmpPath, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
104
|
+
await fs.rename(tmpPath, sessionPath(id));
|
|
105
|
+
return session.meta;
|
|
106
|
+
}
|
|
95
107
|
export async function deleteSession(id) {
|
|
96
108
|
try {
|
|
97
109
|
await fs.unlink(sessionPath(id));
|
package/dist/settings.js
CHANGED
|
@@ -91,6 +91,10 @@ function validateConfig(parsed) {
|
|
|
91
91
|
typeof s.contextWindow === 'number' && s.contextWindow > 0 &&
|
|
92
92
|
typeof s.maxOutputTokens === 'number' && s.maxOutputTokens > 0) {
|
|
93
93
|
validated[id] = { contextWindow: s.contextWindow, maxOutputTokens: s.maxOutputTokens };
|
|
94
|
+
// Keep the vision flag — dropping it made every user-declared vision
|
|
95
|
+
// model report supportsVision === false and silently strip images.
|
|
96
|
+
if (s.supportsVision === true)
|
|
97
|
+
validated[id].supportsVision = true;
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
if (Object.keys(validated).length > 0) {
|
|
@@ -387,6 +391,22 @@ export async function loadConfig() {
|
|
|
387
391
|
return config;
|
|
388
392
|
}
|
|
389
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* The GLOBAL config file only — no project-local settings.local.json, no
|
|
396
|
+
* managed-policy merge, no keychain resolution. This is the ONLY correct base
|
|
397
|
+
* for a read-modify-write persistence cycle: round-tripping loadConfig()
|
|
398
|
+
* (which merges overrides) through saveConfig() laundered project-local
|
|
399
|
+
* models/limits/customModels into ~/.scalix-coder/config.json permanently.
|
|
400
|
+
*/
|
|
401
|
+
export async function loadGlobalConfig() {
|
|
402
|
+
try {
|
|
403
|
+
const raw = await fs.readFile(getConfigFile(), 'utf-8');
|
|
404
|
+
return { ...DEFAULTS, ...validateConfig(JSON.parse(raw)) };
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return { ...DEFAULTS };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
390
410
|
export async function saveConfig(config) {
|
|
391
411
|
await ensureDir();
|
|
392
412
|
// Store API keys in keychain if available, strip from config file. The
|
|
@@ -436,23 +456,34 @@ export const OPENAI_KEYCHAIN_ACCOUNT = 'api-key-openai';
|
|
|
436
456
|
* path is byte-identical to the pre-BYOK behavior. There is NO default
|
|
437
457
|
* third-party URL — an openai selection without a baseUrl resolves with an
|
|
438
458
|
* empty one and the caller surfaces the configuration error.
|
|
459
|
+
*
|
|
460
|
+
* Scalix Cloud credentials resolve separately so BYOK sessions keep the
|
|
461
|
+
* platform tools: under `openai` they come from `SCALIX_CLOUD_API_KEY` →
|
|
462
|
+
* `SCALIX_API_KEY` → the top-level `config.apiKey`, and are simply empty when
|
|
463
|
+
* the user never added one.
|
|
439
464
|
*/
|
|
440
465
|
export function resolveActiveProvider(config, env = process.env) {
|
|
441
466
|
const envProvider = env.SCALIX_PROVIDER?.trim().toLowerCase();
|
|
442
467
|
const provider = isProviderId(envProvider)
|
|
443
468
|
? envProvider
|
|
444
469
|
: (config.provider ?? 'scalix');
|
|
470
|
+
const scalixBaseUrl = env.SCALIX_BASE_URL?.trim() || config.baseUrl;
|
|
471
|
+
const scalixApiKey = env.SCALIX_API_KEY?.trim() || config.apiKey;
|
|
445
472
|
if (provider === 'openai') {
|
|
446
473
|
const creds = config.providers?.openai ?? {};
|
|
447
474
|
return {
|
|
448
475
|
provider,
|
|
449
476
|
baseUrl: env.OPENAI_BASE_URL?.trim() || creds.baseUrl || '',
|
|
450
477
|
apiKey: env.OPENAI_API_KEY?.trim() || creds.apiKey || '',
|
|
478
|
+
cloudBaseUrl: env.SCALIX_CLOUD_BASE_URL?.trim() || scalixBaseUrl,
|
|
479
|
+
cloudApiKey: env.SCALIX_CLOUD_API_KEY?.trim() || scalixApiKey || '',
|
|
451
480
|
};
|
|
452
481
|
}
|
|
453
482
|
return {
|
|
454
483
|
provider: 'scalix',
|
|
455
|
-
baseUrl:
|
|
456
|
-
apiKey:
|
|
484
|
+
baseUrl: scalixBaseUrl,
|
|
485
|
+
apiKey: scalixApiKey,
|
|
486
|
+
cloudBaseUrl: scalixBaseUrl,
|
|
487
|
+
cloudApiKey: scalixApiKey,
|
|
457
488
|
};
|
|
458
489
|
}
|
package/dist/slash-commands.js
CHANGED
|
@@ -16,16 +16,19 @@ export const SLASH_COMMANDS = [
|
|
|
16
16
|
{ cmd: '/mode', desc: 'Permission mode (plan/default/auto/yolo)' },
|
|
17
17
|
{ cmd: '/thinking', desc: 'Thinking mode (off/low/medium/high)' },
|
|
18
18
|
{ cmd: '/plan', desc: 'Enter plan mode (read-only)' },
|
|
19
|
+
{ cmd: '/preset', desc: 'Set agent preset (persona)' },
|
|
19
20
|
{ cmd: '/compact', desc: 'Compress conversation context' },
|
|
20
21
|
{ cmd: '/context', desc: 'Show context usage & token count' },
|
|
21
22
|
{ cmd: '/cost', desc: 'Show token usage & cost' },
|
|
22
23
|
{ cmd: '/diff', desc: 'Show uncommitted changes' },
|
|
23
24
|
{ cmd: '/copy', desc: 'Copy last response to clipboard' },
|
|
24
25
|
{ cmd: '/save', desc: 'Save current session' },
|
|
26
|
+
{ cmd: '/rename', desc: 'Rename current chat session' },
|
|
25
27
|
{ cmd: '/load', desc: 'Resume a saved session' },
|
|
26
28
|
{ cmd: '/resume', desc: 'Resume from crash recovery or saved session' },
|
|
27
29
|
{ cmd: '/fork', desc: 'Fork conversation from a point in history' },
|
|
28
30
|
{ cmd: '/export', desc: 'Export conversation as text' },
|
|
31
|
+
{ cmd: '/feedback', desc: 'Submit user feedback & ratings' },
|
|
29
32
|
{ cmd: '/index', desc: 'Scan & index project files' },
|
|
30
33
|
{ cmd: '/init', desc: 'Initialize project config (SCALIX.md)' },
|
|
31
34
|
{ cmd: '/git', desc: 'Show git context' },
|
|
@@ -69,6 +72,7 @@ export const HELP_LINES = [
|
|
|
69
72
|
' /shortcuts \u2014 keyboard shortcuts',
|
|
70
73
|
' /clear \u2014 reset conversation',
|
|
71
74
|
' /save [name] \u2014 save current session',
|
|
75
|
+
' /rename [name]\u2014 rename current session',
|
|
72
76
|
' /load \u2014 resume a saved session',
|
|
73
77
|
' /resume \u2014 resume from crash recovery or saved session',
|
|
74
78
|
' /fork \u2014 fork conversation from a point in history',
|
|
@@ -81,6 +85,7 @@ export const HELP_LINES = [
|
|
|
81
85
|
' /mode [m] \u2014 permission mode (plan/default/auto/yolo)',
|
|
82
86
|
' /thinking [m] \u2014 thinking mode (off/low/medium/high)',
|
|
83
87
|
' /plan \u2014 enter plan mode (read-only)',
|
|
88
|
+
' /preset [p] \u2014 set agent preset (cloud-architect/dba/devops/full-access)',
|
|
84
89
|
'',
|
|
85
90
|
'Context & Cost:',
|
|
86
91
|
' /compact \u2014 compress conversation context',
|
|
@@ -109,6 +114,7 @@ export const HELP_LINES = [
|
|
|
109
114
|
'',
|
|
110
115
|
'Tools & Config:',
|
|
111
116
|
' /copy \u2014 copy last response to clipboard',
|
|
117
|
+
' /feedback [m] \u2014 submit user feedback',
|
|
112
118
|
' /cloud \u2014 Scalix Cloud status (/cloud tools, /cloud refresh)',
|
|
113
119
|
' /login \u2014 link your Scalix account (API key)',
|
|
114
120
|
' /status \u2014 show version, model & connection info',
|
package/dist/sub-agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createAgent } from './agent.js';
|
|
2
2
|
import { defaultTools } from './tools.js';
|
|
3
3
|
import { extraTools } from './tools-extra.js';
|
|
4
|
+
import { memoryTools } from './tools-memory.js';
|
|
4
5
|
import { webTools } from './tools-web.js';
|
|
5
6
|
import { advancedTools } from './tools-advanced.js';
|
|
6
7
|
import { logger } from './logger.js';
|
|
@@ -15,10 +16,10 @@ import { createWorktree, removeWorktree, mergeWorktree } from './worktree.js';
|
|
|
15
16
|
const READ_ONLY_TOOL_NAMES = new Set([
|
|
16
17
|
'get_current_time', 'calculate', 'read_file', 'read_file_lines',
|
|
17
18
|
'list_files', 'search_files', 'find_files', 'tree',
|
|
18
|
-
'git_status', 'git_diff', 'web_search', 'monitor',
|
|
19
|
+
'git_status', 'git_diff', 'web_search', 'monitor', 'get_memory',
|
|
19
20
|
]);
|
|
20
21
|
function selectTools(level, allowAgentTool = false) {
|
|
21
|
-
const all = [...defaultTools, ...extraTools, ...webTools, ...advancedTools]
|
|
22
|
+
const all = [...defaultTools, ...extraTools, ...memoryTools, ...webTools, ...advancedTools]
|
|
22
23
|
.filter((t) => {
|
|
23
24
|
const name = t.function?.name || t.name;
|
|
24
25
|
if (name === 'agent' && !allowAgentTool)
|