scalix 1.1.0 → 1.3.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/dist/hooks.js CHANGED
@@ -1,10 +1,42 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { resolve, join, basename } from 'node:path';
3
3
  import { exec } from 'node:child_process';
4
- import { promisify } from 'node:util';
5
4
  import { logger } from './logger.js';
6
5
  import { PROJECT_LOOKUP_DIRS, HOOK_DEFAULTS } from './constants.js';
7
- const execAsync = promisify(exec);
6
+ import { sanitizeEnvForCommand } from './sandbox.js';
7
+ // Hook commands are written in POSIX sh syntax ($VAR, pipes, etc). On Windows
8
+ // the default exec shell is cmd.exe, which silently mangles them — so resolve
9
+ // a real POSIX shell (Git Bash) when one is available. `null` = not resolved.
10
+ let cachedHookShell = null;
11
+ function isPosixShell(shellPath) {
12
+ const name = basename(shellPath).toLowerCase().replace(/\.exe$/, '');
13
+ return name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash';
14
+ }
15
+ function resolveHookShell() {
16
+ if (cachedHookShell !== null)
17
+ return cachedHookShell;
18
+ if (process.platform !== 'win32') {
19
+ cachedHookShell = undefined;
20
+ return undefined;
21
+ }
22
+ const candidates = [
23
+ process.env.SCALIX_HOOK_SHELL,
24
+ process.env.SHELL,
25
+ join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
26
+ join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
27
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') : undefined,
28
+ ];
29
+ for (const c of candidates) {
30
+ // Skip WSL/System32 bash implicitly: only explicit paths are checked, and
31
+ // non-POSIX shells (e.g. a SHELL pointing at PowerShell) are rejected.
32
+ if (c && isPosixShell(c) && existsSync(c)) {
33
+ cachedHookShell = c;
34
+ return c;
35
+ }
36
+ }
37
+ cachedHookShell = undefined;
38
+ return undefined;
39
+ }
8
40
  const HOOK_PATHS = PROJECT_LOOKUP_DIRS.map(d => `${d}/hooks.json`);
9
41
  export function loadHooks(cwd = process.cwd()) {
10
42
  for (const relPath of HOOK_PATHS) {
@@ -29,26 +61,130 @@ function matchesHook(hook, toolName) {
29
61
  return true;
30
62
  if (!toolName)
31
63
  return false;
32
- if (hook.match === toolName)
64
+ const patterns = hook.match.split('|').map(p => p.trim()).filter(Boolean);
65
+ return patterns.some(pattern => matchPattern(pattern, toolName));
66
+ }
67
+ function matchPattern(pattern, value) {
68
+ if (pattern === value)
33
69
  return true;
34
- if (hook.match.endsWith('*')) {
35
- return toolName.startsWith(hook.match.slice(0, -1));
70
+ if (pattern === '*')
71
+ return true;
72
+ if (pattern.endsWith('*') && !pattern.startsWith('/')) {
73
+ return value.startsWith(pattern.slice(0, -1));
74
+ }
75
+ if (pattern.startsWith('*') && !pattern.endsWith('*')) {
76
+ return value.endsWith(pattern.slice(1));
77
+ }
78
+ if (pattern.startsWith('/') && pattern.endsWith('/')) {
79
+ try {
80
+ return new RegExp(pattern.slice(1, -1)).test(value);
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ if (pattern.includes('*')) {
87
+ const regex = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
88
+ try {
89
+ return new RegExp(`^${regex}$`).test(value);
90
+ }
91
+ catch {
92
+ return false;
93
+ }
36
94
  }
37
95
  return false;
38
96
  }
39
- export async function executeHook(hook, env) {
97
+ function buildHookInput(event, context) {
98
+ const input = {
99
+ event,
100
+ timestamp: new Date().toISOString(),
101
+ };
102
+ if (context.toolName)
103
+ input.tool_name = context.toolName;
104
+ if (context.toolArgs) {
105
+ try {
106
+ input.tool_args = JSON.parse(context.toolArgs);
107
+ }
108
+ catch {
109
+ input.tool_args = context.toolArgs;
110
+ }
111
+ }
112
+ if (context.toolResult !== undefined)
113
+ input.tool_result = context.toolResult;
114
+ if (context.error)
115
+ input.error = context.error;
116
+ if (context.prompt)
117
+ input.prompt = context.prompt.slice(0, 4096);
118
+ if (context.response)
119
+ input.response = context.response.slice(0, 4096);
120
+ if (context.notification)
121
+ input.notification = context.notification;
122
+ return JSON.stringify(input);
123
+ }
124
+ function parseHookOutput(stdout, exitCode) {
125
+ const trimmed = stdout.trim();
126
+ if (trimmed.startsWith('{')) {
127
+ try {
128
+ const parsed = JSON.parse(trimmed);
129
+ return {
130
+ allowed: exitCode === 0 || exitCode === undefined,
131
+ output: parsed.output ?? trimmed,
132
+ message: parsed.message,
133
+ data: parsed,
134
+ };
135
+ }
136
+ catch { }
137
+ }
138
+ const allowed = exitCode === 0 || exitCode === undefined;
139
+ return {
140
+ allowed,
141
+ output: trimmed || undefined,
142
+ message: exitCode === 2 ? trimmed : undefined,
143
+ };
144
+ }
145
+ export async function executeHook(hook, env, stdinData) {
40
146
  const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
41
- const hookEnv = { ...process.env, ...env };
147
+ // Strip secrets from the inherited environment so hook commands can't read
148
+ // API keys, tokens, cloud credentials, etc. The SCALIX_* context vars in
149
+ // `env` are still applied on top.
150
+ const hookEnv = { ...sanitizeEnvForCommand(process.env), ...env };
42
151
  const ac = new AbortController();
43
152
  const hardKill = setTimeout(() => ac.abort(), timeout + 5000);
44
153
  try {
45
- const { stdout, stderr } = await execAsync(hook.command, {
154
+ const shell = resolveHookShell();
155
+ const child = exec(hook.command, {
46
156
  timeout,
47
157
  env: hookEnv,
48
158
  maxBuffer: HOOK_DEFAULTS.maxBuffer,
49
159
  signal: ac.signal,
160
+ ...(shell ? { shell } : {}),
50
161
  });
51
- return { allowed: true, output: (stdout || stderr).trim() || undefined };
162
+ if (stdinData && child.stdin) {
163
+ // The hook may exit without reading stdin; swallow the resulting EPIPE.
164
+ child.stdin.on('error', () => { });
165
+ child.stdin.write(stdinData);
166
+ child.stdin.end();
167
+ }
168
+ const result = await new Promise((resolve, reject) => {
169
+ let stdout = '';
170
+ let stderr = '';
171
+ child.stdout?.on('data', (d) => { stdout += d; });
172
+ child.stderr?.on('data', (d) => { stderr += d; });
173
+ child.on('error', reject);
174
+ child.on('close', (code, signal) => resolve({
175
+ stdout,
176
+ stderr,
177
+ code: code ?? 0,
178
+ // A killed child reports code=null + a signal (or child.killed on
179
+ // Windows). We only ever kill on timeout/abort, so treat it as such —
180
+ // otherwise `code ?? 0` would misreport the hook as allowed.
181
+ killed: child.killed || signal !== null,
182
+ }));
183
+ });
184
+ if (result.killed) {
185
+ return { allowed: false, error: `Hook timed out after ${timeout}ms` };
186
+ }
187
+ return parseHookOutput(result.stdout, result.code);
52
188
  }
53
189
  catch (err) {
54
190
  if (err.killed || err.code === 'ETIMEDOUT' || err.name === 'AbortError') {
@@ -65,7 +201,25 @@ export async function executeHook(hook, env) {
65
201
  }
66
202
  }
67
203
  export async function runHooks(hooks, event, context) {
68
- const matching = hooks.filter((h) => h.event === event && matchesHook(h, context.toolName));
204
+ const matching = hooks.filter((h) => {
205
+ if (h.event !== event)
206
+ return false;
207
+ if (!matchesHook(h, context.toolName))
208
+ return false;
209
+ if (h.type === 'prompt')
210
+ return false;
211
+ if (h.if) {
212
+ try {
213
+ const conditionMet = evaluateCondition(h.if, context);
214
+ if (!conditionMet)
215
+ return false;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ return true;
222
+ });
69
223
  if (matching.length === 0)
70
224
  return [];
71
225
  const env = {};
@@ -77,10 +231,15 @@ export async function runHooks(hooks, event, context) {
77
231
  env.SCALIX_ERROR = context.error;
78
232
  if (context.prompt)
79
233
  env.SCALIX_PROMPT = context.prompt.slice(0, 4096).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
234
+ if (context.response)
235
+ env.SCALIX_RESPONSE = context.response.slice(0, 4096);
236
+ if (context.notification)
237
+ env.SCALIX_NOTIFICATION = context.notification;
80
238
  env.SCALIX_EVENT = event;
239
+ const stdinData = buildHookInput(event, context);
81
240
  const results = [];
82
241
  for (const hook of matching) {
83
- const result = await executeHook(hook, env);
242
+ const result = await executeHook(hook, env, stdinData);
84
243
  results.push(result);
85
244
  logger.debug('Hook executed', { event, command: hook.command, allowed: result.allowed });
86
245
  if (!result.allowed && event === 'PreToolUse')
@@ -88,13 +247,36 @@ export async function runHooks(hooks, event, context) {
88
247
  }
89
248
  return results;
90
249
  }
250
+ function evaluateCondition(condition, context) {
251
+ if (condition.startsWith('tool_name=')) {
252
+ const expected = condition.slice('tool_name='.length);
253
+ return matchPattern(expected, context.toolName || '');
254
+ }
255
+ if (condition.startsWith('tool_args~')) {
256
+ const pattern = condition.slice('tool_args~'.length);
257
+ try {
258
+ return new RegExp(pattern).test(context.toolArgs || '');
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
264
+ return true;
265
+ }
266
+ export function getPromptHooks(hooks, event, toolName) {
267
+ return hooks.filter(h => h.event === event &&
268
+ h.type === 'prompt' &&
269
+ matchesHook(h, toolName));
270
+ }
91
271
  export function hooksSummary(hooks) {
92
272
  if (hooks.length === 0)
93
273
  return 'No hooks configured.';
94
274
  const lines = ['Configured hooks:'];
95
275
  for (const h of hooks) {
96
276
  const match = h.match ? ` (match: ${h.match})` : '';
97
- lines.push(` ${h.event}${match}: ${h.command}`);
277
+ const type = h.type === 'prompt' ? ' [prompt]' : '';
278
+ const cond = h.if ? ` [if: ${h.if}]` : '';
279
+ lines.push(` ${h.event}${match}${type}${cond}: ${h.command}`);
98
280
  }
99
281
  return lines.join('\n');
100
282
  }
@@ -38,7 +38,24 @@ const SENSITIVITY_THRESHOLDS = {
38
38
  medium: { highConfidence: 3, mediumConfidence: 2 },
39
39
  high: { highConfidence: 2, mediumConfidence: 1 },
40
40
  };
41
- export function detectPromptInjection(content, source, sensitivity = 'medium', customPatterns) {
41
+ // Strip zero-width and other invisible/formatting characters that are used to
42
+ // smuggle instructions past pattern matching (zero-width space/joiner, BOM,
43
+ // word joiner, bidi controls, soft hyphen, etc.).
44
+ const INVISIBLE_CHARS_RE = new RegExp('[' +
45
+ '\\u00AD' + // soft hyphen
46
+ '\\u200B-\\u200F' + // zero-width space/non-joiner/joiner, LRM/RLM
47
+ '\\u202A-\\u202E' + // bidi embedding/override controls
48
+ '\\u2060-\\u2064' + // word joiner, invisible operators
49
+ '\\u206A-\\u206F' + // deprecated format controls
50
+ '\\uFEFF' + // BOM / zero-width no-break space
51
+ ']', 'g');
52
+ function normalizeForDetection(text) {
53
+ // NFKC folds homoglyph/compatibility variants (e.g. fullwidth chars) to their
54
+ // canonical forms so evasions collapse onto the same patterns.
55
+ return text.normalize('NFKC').replace(INVISIBLE_CHARS_RE, '');
56
+ }
57
+ export function detectPromptInjection(rawContent, source, sensitivity = 'medium', customPatterns) {
58
+ const content = normalizeForDetection(rawContent);
42
59
  const matched = [];
43
60
  let totalWeight = 0;
44
61
  for (const rule of ALL_PATTERNS) {
@@ -21,6 +21,7 @@ import { browserTools, closeBrowser } from './tools-browser.js';
21
21
  import { loadMcpServices } from './mcp.js';
22
22
  import { loadConfig, DEFAULT_LIMITS } from './settings.js';
23
23
  import { discoverPlugins, getPluginTools } from './plugins.js';
24
+ import { isWorkspaceTrusted } from './workspace-trust.js';
24
25
  import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, fetchModelSpecs } from './constants.js';
25
26
  import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
26
27
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
@@ -68,7 +69,12 @@ export async function runIpcServer() {
68
69
  const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
69
70
  fetchModelSpecs(baseUrl, apiKey).catch(() => { });
70
71
  const mcpTools = loadMcpServices();
71
- const plugins = await discoverPlugins(process.cwd());
72
+ // Only discover/load project-local plugins in a trusted workspace — plugins
73
+ // dynamically import and execute code from the project directory.
74
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
75
+ if (!isWorkspaceTrusted()) {
76
+ process.stderr.write('IPC: project plugins skipped (untrusted workspace)\n');
77
+ }
72
78
  const pluginTools = getPluginTools(plugins);
73
79
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
74
80
  const instructions = await buildInstructions();
@@ -0,0 +1,222 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { USER_CONFIG_DIR } from './constants.js';
7
+ const exec = promisify(execFile);
8
+ const SERVICE = 'scalix-coder';
9
+ const ACCOUNT = 'api-key';
10
+ /**
11
+ * SCALIX_NO_KEYCHAIN=1 disables OS keychain integration entirely — the API
12
+ * key stays in the config file. Intended for CI, headless environments, and
13
+ * hermetic tests where spawning security/secret-tool/PowerShell is unwanted.
14
+ */
15
+ function keychainDisabled() {
16
+ const flag = process.env.SCALIX_NO_KEYCHAIN;
17
+ return flag === '1' || flag?.toLowerCase() === 'true';
18
+ }
19
+ function isWindows() {
20
+ return process.platform === 'win32';
21
+ }
22
+ async function isMacOS() {
23
+ return process.platform === 'darwin';
24
+ }
25
+ // ─── Windows DPAPI (user-scoped) via PowerShell ───
26
+ function windowsCredentialPath() {
27
+ // Resolve the home directory the same way the rest of the codebase does
28
+ // (settings/sessions/workspace-trust) so the credential store stays
29
+ // 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');
31
+ }
32
+ async function runPowerShell(script, stdinData) {
33
+ const psExe = process.env.SystemRoot
34
+ ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
35
+ : 'powershell.exe';
36
+ const child = exec(psExe, ['-NoProfile', '-NonInteractive', '-Command', script], {
37
+ windowsHide: true,
38
+ maxBuffer: 1024 * 1024,
39
+ });
40
+ if (stdinData !== undefined) {
41
+ const stdin = child.child?.stdin;
42
+ if (!stdin)
43
+ throw new Error('PowerShell stdin unavailable');
44
+ stdin.write(stdinData);
45
+ stdin.end();
46
+ }
47
+ const { stdout } = await child;
48
+ return stdout;
49
+ }
50
+ async function setApiKeyWindows(apiKey) {
51
+ try {
52
+ const credPath = windowsCredentialPath();
53
+ await fs.mkdir(path.dirname(credPath), { recursive: true });
54
+ // Read the secret from stdin, encrypt it with user-scoped DPAPI, and print
55
+ // the encrypted string. Reading from stdin avoids putting the secret on the
56
+ // command line (where it could be seen in the process table).
57
+ const script = '$s = [Console]::In.ReadToEnd();' +
58
+ '$sec = ConvertTo-SecureString -String $s -AsPlainText -Force;' +
59
+ '[Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))';
60
+ const encrypted = (await runPowerShell(script, apiKey)).trim();
61
+ if (!encrypted)
62
+ return false;
63
+ await fs.writeFile(credPath, encrypted, { encoding: 'utf-8', mode: 0o600 });
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ async function getApiKeyWindows() {
71
+ try {
72
+ const credPath = windowsCredentialPath();
73
+ const encrypted = (await fs.readFile(credPath, 'utf-8')).trim();
74
+ if (!encrypted)
75
+ return null;
76
+ // Decrypt the DPAPI blob back to plaintext via Marshal. Pass the blob over
77
+ // stdin to avoid quoting/length issues on the command line.
78
+ const script = '$enc = [Console]::In.ReadToEnd().Trim();' +
79
+ '$sec = ConvertTo-SecureString -String $enc;' +
80
+ '$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec);' +
81
+ 'try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }' +
82
+ 'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }';
83
+ const plaintext = await runPowerShell(script, encrypted);
84
+ return plaintext.trim() || null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ async function deleteApiKeyWindows() {
91
+ try {
92
+ await fs.unlink(windowsCredentialPath());
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ async function isLinuxWithSecretTool() {
100
+ if (process.platform !== 'linux')
101
+ return false;
102
+ try {
103
+ await exec('which', ['secret-tool']);
104
+ return true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ export async function getApiKeyFromKeychain() {
111
+ if (keychainDisabled())
112
+ return null;
113
+ try {
114
+ if (isWindows()) {
115
+ return await getApiKeyWindows();
116
+ }
117
+ if (await isMacOS()) {
118
+ const { stdout } = await exec('security', [
119
+ 'find-generic-password',
120
+ '-s', SERVICE,
121
+ '-a', ACCOUNT,
122
+ '-w',
123
+ ]);
124
+ return stdout.trim() || null;
125
+ }
126
+ if (await isLinuxWithSecretTool()) {
127
+ const { stdout } = await exec('secret-tool', [
128
+ 'lookup', 'service', SERVICE, 'account', ACCOUNT,
129
+ ]);
130
+ return stdout.trim() || null;
131
+ }
132
+ }
133
+ catch {
134
+ // keychain item not found or command failed
135
+ }
136
+ return null;
137
+ }
138
+ export async function setApiKeyInKeychain(apiKey) {
139
+ if (keychainDisabled())
140
+ return false;
141
+ try {
142
+ if (isWindows()) {
143
+ return await setApiKeyWindows(apiKey);
144
+ }
145
+ if (await isMacOS()) {
146
+ // Delete existing entry first (ignore errors if it doesn't exist)
147
+ try {
148
+ await exec('security', [
149
+ 'delete-generic-password',
150
+ '-s', SERVICE,
151
+ '-a', ACCOUNT,
152
+ ]);
153
+ }
154
+ catch {
155
+ // entry didn't exist
156
+ }
157
+ await exec('security', [
158
+ 'add-generic-password',
159
+ '-s', SERVICE,
160
+ '-a', ACCOUNT,
161
+ '-w', apiKey,
162
+ '-U',
163
+ ]);
164
+ return true;
165
+ }
166
+ if (await isLinuxWithSecretTool()) {
167
+ const child = exec('secret-tool', [
168
+ 'store',
169
+ '--label', 'Scalix Coder API Key',
170
+ 'service', SERVICE,
171
+ 'account', ACCOUNT,
172
+ ]);
173
+ // secret-tool reads the secret from stdin. If stdin isn't available we
174
+ // cannot deliver the secret — treat that as a failure rather than
175
+ // silently reporting success (which would lose the key entirely).
176
+ const stdin = child.child?.stdin;
177
+ if (!stdin) {
178
+ return false;
179
+ }
180
+ stdin.write(apiKey);
181
+ stdin.end();
182
+ await child;
183
+ return true;
184
+ }
185
+ }
186
+ catch {
187
+ // keychain operation failed
188
+ }
189
+ return false;
190
+ }
191
+ export async function deleteApiKeyFromKeychain() {
192
+ if (keychainDisabled())
193
+ return false;
194
+ try {
195
+ if (isWindows()) {
196
+ return await deleteApiKeyWindows();
197
+ }
198
+ if (await isMacOS()) {
199
+ await exec('security', [
200
+ 'delete-generic-password',
201
+ '-s', SERVICE,
202
+ '-a', ACCOUNT,
203
+ ]);
204
+ return true;
205
+ }
206
+ if (await isLinuxWithSecretTool()) {
207
+ await exec('secret-tool', [
208
+ 'clear', 'service', SERVICE, 'account', ACCOUNT,
209
+ ]);
210
+ return true;
211
+ }
212
+ }
213
+ catch {
214
+ // entry didn't exist or command failed
215
+ }
216
+ return false;
217
+ }
218
+ export async function isKeychainAvailable() {
219
+ if (keychainDisabled())
220
+ return false;
221
+ return isWindows() || await isMacOS() || await isLinuxWithSecretTool();
222
+ }
package/dist/markdown.js CHANGED
@@ -1,13 +1,45 @@
1
1
  import chalk from 'chalk';
2
2
  import { Marked } from 'marked';
3
3
  import { markedTerminal } from 'marked-terminal';
4
+ import { createRequire } from 'node:module';
5
+ // This package is ESM ("type":"module"), so the CommonJS `require` global is
6
+ // not defined. Build one bound to this module's URL so cli-highlight loads.
7
+ const require = createRequire(import.meta.url);
8
+ let highlightFn = null;
9
+ let highlightLoaded = false;
10
+ function getHighlighter() {
11
+ if (highlightLoaded)
12
+ return highlightFn;
13
+ highlightLoaded = true;
14
+ try {
15
+ // cli-highlight is a dependency — provides 100+ language highlighting
16
+ const mod = require('cli-highlight');
17
+ highlightFn = mod.highlight;
18
+ }
19
+ catch {
20
+ highlightFn = null;
21
+ }
22
+ return highlightFn;
23
+ }
24
+ function highlightCode(code, language) {
25
+ const hl = getHighlighter();
26
+ if (!hl)
27
+ return chalk.yellow(code);
28
+ try {
29
+ return hl(code, { language: language || undefined });
30
+ }
31
+ catch {
32
+ // Unknown language or highlight error — fall back to yellow
33
+ return chalk.yellow(code);
34
+ }
35
+ }
4
36
  function createMarked(width) {
5
37
  const m = new Marked();
6
38
  const mt = markedTerminal({
7
39
  strong: chalk.bold,
8
40
  em: chalk.italic,
9
41
  codespan: chalk.cyan,
10
- code: chalk.yellow,
42
+ code: highlightCode,
11
43
  heading: chalk.green.bold,
12
44
  firstHeading: chalk.magenta.bold,
13
45
  hr: chalk.dim,
@@ -25,15 +57,29 @@ function createMarked(width) {
25
57
  }
26
58
  let cachedWidth = 0;
27
59
  let cachedMarked = null;
60
+ // Content-hash render cache to avoid re-rendering identical content
61
+ const renderCache = new Map();
62
+ const MAX_CACHE_SIZE = 128;
28
63
  export function renderMarkdown(text, cols) {
29
64
  const width = Math.max((cols ?? process.stdout.columns ?? 100) - 6, 40);
65
+ const cacheKey = `${width}:${text}`;
66
+ const cached = renderCache.get(cacheKey);
67
+ if (cached)
68
+ return cached;
30
69
  if (!cachedMarked || width !== cachedWidth) {
31
70
  cachedWidth = width;
32
71
  cachedMarked = createMarked(width);
33
72
  }
34
73
  try {
35
- const result = cachedMarked.parse(text);
36
- return result.replace(/\n{3,}/g, '\n\n').trim();
74
+ const result = cachedMarked.parse(text).replace(/\n{3,}/g, '\n\n').trim();
75
+ // Evict oldest entries when cache is full
76
+ if (renderCache.size >= MAX_CACHE_SIZE) {
77
+ const firstKey = renderCache.keys().next().value;
78
+ if (firstKey !== undefined)
79
+ renderCache.delete(firstKey);
80
+ }
81
+ renderCache.set(cacheKey, result);
82
+ return result;
37
83
  }
38
84
  catch {
39
85
  return text;
@@ -19,6 +19,17 @@ export function classifyComplexity(query, toolCallCount = 0, config) {
19
19
  return 'complex';
20
20
  return 'moderate';
21
21
  }
22
+ /**
23
+ * Complexity-based model selection.
24
+ *
25
+ * Intended use: a caller that owns a {@link RouterConfig} (see
26
+ * `createRouterConfig`/`getRouterStatus`, wired from cli.tsx) can call this per
27
+ * user turn to pick a cheaper/stronger model for the request. It is
28
+ * deliberately conservative — it only diverges from the caller's default when
29
+ * the router config is `enabled` (which `createRouterConfig` defaults to
30
+ * `false`), so importing/calling it does NOT change default behavior. When
31
+ * disabled, or with no tiers, it always returns `config.defaultModel`.
32
+ */
22
33
  export function selectModel(query, config, toolCallCount = 0) {
23
34
  if (!config.enabled || config.tiers.length === 0) {
24
35
  return config.defaultModel;