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/skills.js ADDED
@@ -0,0 +1,269 @@
1
+ import fs from 'node:fs/promises';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { logger } from './logger.js';
5
+ import { PROJECT_LOOKUP_DIRS } from './constants.js';
6
+ const SKILL_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/skills`);
7
+ function parseSkillFrontmatter(content) {
8
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
9
+ if (!match)
10
+ return null;
11
+ const meta = {};
12
+ let currentKey = '';
13
+ let isList = false;
14
+ for (const line of match[1].split('\n')) {
15
+ const trimmed = line.trim();
16
+ if (trimmed.startsWith('- ') && isList && currentKey) {
17
+ const arr = meta[currentKey];
18
+ if (Array.isArray(arr))
19
+ arr.push(trimmed.slice(2).trim());
20
+ else
21
+ meta[currentKey] = [trimmed.slice(2).trim()];
22
+ continue;
23
+ }
24
+ const idx = line.indexOf(':');
25
+ if (idx > 0) {
26
+ currentKey = line.slice(0, idx).trim();
27
+ const value = line.slice(idx + 1).trim();
28
+ if (value === '' || value === '[]') {
29
+ meta[currentKey] = [];
30
+ isList = true;
31
+ }
32
+ else {
33
+ meta[currentKey] = value;
34
+ isList = false;
35
+ }
36
+ }
37
+ }
38
+ const name = meta.name || '';
39
+ const description = meta.description || '';
40
+ if (!name)
41
+ return null;
42
+ return {
43
+ meta: {
44
+ name,
45
+ description,
46
+ version: meta.version || '1.0.0',
47
+ paths: Array.isArray(meta.paths) ? meta.paths : (meta.paths ? [meta.paths] : []),
48
+ triggers: Array.isArray(meta.triggers) ? meta.triggers : (meta.triggers ? [meta.triggers] : []),
49
+ },
50
+ body: match[2],
51
+ };
52
+ }
53
+ async function discoverResources(skillDir) {
54
+ const resources = { scripts: [], references: [], examples: [], assets: [] };
55
+ const dirs = ['scripts', 'references', 'examples', 'assets'];
56
+ for (const dir of dirs) {
57
+ const fullDir = path.join(skillDir, dir);
58
+ try {
59
+ const entries = await fs.readdir(fullDir, { withFileTypes: true });
60
+ resources[dir] = entries
61
+ .filter(e => e.isFile())
62
+ .map(e => path.join(fullDir, e.name));
63
+ }
64
+ catch { }
65
+ }
66
+ return resources;
67
+ }
68
+ export async function loadSkills(cwd = process.cwd()) {
69
+ const skills = [];
70
+ for (const dir of SKILL_DIRS) {
71
+ const fullDir = path.resolve(cwd, dir);
72
+ let entries;
73
+ try {
74
+ entries = await fs.readdir(fullDir, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ for (const entry of entries) {
80
+ if (!entry.isDirectory()) {
81
+ if (entry.isFile() && entry.name === 'SKILL.md') {
82
+ const skill = await loadSkillFile(path.join(fullDir, entry.name), fullDir);
83
+ if (skill)
84
+ skills.push(skill);
85
+ }
86
+ continue;
87
+ }
88
+ const skillFile = path.join(fullDir, entry.name, 'SKILL.md');
89
+ if (existsSync(skillFile)) {
90
+ const skill = await loadSkillFile(skillFile, path.join(fullDir, entry.name));
91
+ if (skill)
92
+ skills.push(skill);
93
+ }
94
+ }
95
+ }
96
+ skills.push(...getBuiltinSkills());
97
+ return skills;
98
+ }
99
+ async function loadSkillFile(filePath, skillDir) {
100
+ try {
101
+ const raw = await fs.readFile(filePath, 'utf-8');
102
+ const parsed = parseSkillFrontmatter(raw);
103
+ if (!parsed)
104
+ return null;
105
+ const resources = await discoverResources(skillDir);
106
+ return {
107
+ name: parsed.meta.name,
108
+ description: parsed.meta.description,
109
+ version: parsed.meta.version || '1.0.0',
110
+ paths: parsed.meta.paths || [],
111
+ triggers: parsed.meta.triggers || [],
112
+ content: parsed.body.trim(),
113
+ source: filePath,
114
+ resources,
115
+ builtin: false,
116
+ };
117
+ }
118
+ catch (err) {
119
+ logger.warn('Failed to load skill', { path: filePath, error: err.message });
120
+ return null;
121
+ }
122
+ }
123
+ export function getSkillsForPath(skills, filePath) {
124
+ const normalized = filePath.replace(/\\/g, '/');
125
+ return skills.filter(skill => skill.paths.some(p => {
126
+ const pattern = p.replace(/\\/g, '/');
127
+ if (pattern.endsWith('/**')) {
128
+ return normalized.startsWith(pattern.slice(0, -3));
129
+ }
130
+ if (pattern.endsWith('/*')) {
131
+ const dir = pattern.slice(0, -2);
132
+ return normalized.startsWith(dir) && !normalized.slice(dir.length + 1).includes('/');
133
+ }
134
+ return normalized.startsWith(pattern);
135
+ }));
136
+ }
137
+ export function getSkillByTrigger(skills, input) {
138
+ const lower = input.toLowerCase();
139
+ return skills.find(skill => skill.triggers.some(t => lower.includes(t.toLowerCase()))) || null;
140
+ }
141
+ export function getSkillContent(skill, level = 'full') {
142
+ if (level === 'metadata') {
143
+ return `**${skill.name}** (v${skill.version}) — ${skill.description}`;
144
+ }
145
+ let content = skill.content;
146
+ if (level === 'resources') {
147
+ const resourceFiles = [
148
+ ...skill.resources.scripts,
149
+ ...skill.resources.references,
150
+ ...skill.resources.examples,
151
+ ];
152
+ for (const filePath of resourceFiles) {
153
+ if (existsSync(filePath)) {
154
+ try {
155
+ const fileContent = readFileSync(filePath, 'utf-8');
156
+ const relPath = path.basename(filePath);
157
+ content += `\n\n<resource path="${relPath}">\n${fileContent}\n</resource>`;
158
+ }
159
+ catch { }
160
+ }
161
+ }
162
+ }
163
+ return content;
164
+ }
165
+ export function skillsSummary(skills) {
166
+ if (skills.length === 0)
167
+ return 'No skills available.';
168
+ const lines = ['Available skills:'];
169
+ for (const s of skills) {
170
+ const paths = s.paths.length > 0 ? ` [paths: ${s.paths.join(', ')}]` : '';
171
+ const builtin = s.builtin ? ' (built-in)' : '';
172
+ lines.push(` ${s.name} — ${s.description}${paths}${builtin}`);
173
+ }
174
+ return lines.join('\n');
175
+ }
176
+ function getBuiltinSkills() {
177
+ const emptyResources = { scripts: [], references: [], examples: [], assets: [] };
178
+ return [
179
+ {
180
+ name: 'feature-dev',
181
+ description: 'Guided 7-phase feature development workflow',
182
+ version: '1.0.0',
183
+ paths: [],
184
+ triggers: ['new feature', 'implement feature', 'build feature', 'add feature'],
185
+ content: `# Feature Development Workflow
186
+
187
+ Follow this 7-phase process for developing new features:
188
+
189
+ ## Phase 1: Requirements
190
+ - Clarify the feature scope and acceptance criteria
191
+ - Identify affected files and components
192
+ - List edge cases and error scenarios
193
+
194
+ ## Phase 2: Design
195
+ - Propose the technical approach
196
+ - Identify new files, modified files, and interfaces
197
+ - Consider backwards compatibility
198
+
199
+ ## Phase 3: Implementation
200
+ - Write the core implementation
201
+ - Follow existing code conventions
202
+ - Keep changes minimal and focused
203
+
204
+ ## Phase 4: Testing
205
+ - Write unit tests for new functions
206
+ - Test edge cases identified in Phase 1
207
+ - Run existing test suite to check for regressions
208
+
209
+ ## Phase 5: Integration
210
+ - Wire up the feature with existing code
211
+ - Update configuration if needed
212
+ - Add feature flags if appropriate
213
+
214
+ ## Phase 6: Documentation
215
+ - Update relevant documentation
216
+ - Add inline comments only where the "why" is non-obvious
217
+ - Update CHANGELOG if one exists
218
+
219
+ ## Phase 7: Review
220
+ - Run linter and type checker
221
+ - Review diff for accidental changes
222
+ - Verify the feature works end-to-end`,
223
+ source: 'built-in',
224
+ resources: emptyResources,
225
+ builtin: true,
226
+ },
227
+ {
228
+ name: 'code-review',
229
+ description: 'Systematic code review of current changes',
230
+ version: '1.0.0',
231
+ paths: [],
232
+ triggers: ['review code', 'code review', 'review changes', 'review my changes'],
233
+ content: `# Code Review
234
+
235
+ Review the current changes systematically:
236
+
237
+ 1. **Run \`git diff\`** to see all uncommitted changes
238
+ 2. **Security**: Check for hardcoded secrets, SQL injection, XSS, path traversal
239
+ 3. **Correctness**: Verify logic, edge cases, error handling
240
+ 4. **Style**: Check naming, formatting, consistency with codebase conventions
241
+ 5. **Performance**: Look for N+1 queries, unnecessary allocations, blocking I/O
242
+ 6. **Testing**: Are new functions tested? Are existing tests still valid?
243
+ 7. **Summary**: Provide a structured review with findings categorized as Critical / Important / Minor`,
244
+ source: 'built-in',
245
+ resources: emptyResources,
246
+ builtin: true,
247
+ },
248
+ {
249
+ name: 'commit-push-pr',
250
+ description: 'Commit changes, push, and create a pull request',
251
+ version: '1.0.0',
252
+ paths: [],
253
+ triggers: ['commit and push', 'create pr', 'open pr', 'make a pr'],
254
+ content: `# Commit, Push & PR
255
+
256
+ 1. **Stage changes**: Review \`git status\` and stage relevant files
257
+ 2. **Commit**: Write a concise commit message (imperative mood, <72 chars)
258
+ 3. **Push**: Push to the remote branch
259
+ 4. **PR**: Create a pull request with:
260
+ - Clear title (matches commit message style)
261
+ - Description summarizing what changed and why
262
+ - Link to related issues if applicable
263
+ - List any manual testing done`,
264
+ source: 'built-in',
265
+ resources: emptyResources,
266
+ builtin: true,
267
+ },
268
+ ];
269
+ }
package/dist/sub-agent.js CHANGED
@@ -5,6 +5,11 @@ import { webTools } from './tools-web.js';
5
5
  import { advancedTools } from './tools-advanced.js';
6
6
  import { logger } from './logger.js';
7
7
  import { SUB_AGENT_DEFAULTS, CHEAP_MODEL_INDICATORS } from './constants.js';
8
+ import { getSandboxRoot, setSandboxRoot } from './sandbox.js';
9
+ /** Grace period to let an aborted sub-agent quiesce before its worktree is removed. */
10
+ const SUB_AGENT_GRACE_MS = 5000;
11
+ /** Tools excluded from the 'standard' tier (kept for 'all' only). */
12
+ const STANDARD_EXCLUDED_TOOLS = new Set(['agent', 'add_mcp_server']);
8
13
  import { AgentRegistry, generateAgentId } from './agent-registry.js';
9
14
  import { createWorktree, removeWorktree, mergeWorktree } from './worktree.js';
10
15
  const READ_ONLY_TOOL_NAMES = new Set([
@@ -27,7 +32,8 @@ function selectTools(level, allowAgentTool = false) {
27
32
  return all;
28
33
  case 'standard':
29
34
  default:
30
- return all;
35
+ // Standard: everything except the most dangerous tools.
36
+ return all.filter((t) => !STANDARD_EXCLUDED_TOOLS.has(t.function?.name || t.name));
31
37
  }
32
38
  }
33
39
  export async function spawnSubAgent(parentConfig, prompt, options = {}) {
@@ -76,6 +82,7 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
76
82
  agentDescription: description,
77
83
  groundingCheck: true,
78
84
  contextRetrieval: !isReadOnly,
85
+ workspaceRoot: worktreeInfo?.path,
79
86
  });
80
87
  let steps = 0;
81
88
  let tokenEstimate = 0;
@@ -87,9 +94,46 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
87
94
  const timeoutPromise = new Promise((_, reject) => {
88
95
  timer = setTimeout(() => reject(new Error(`Sub-agent timed out after ${timeout}ms`)), timeout);
89
96
  });
97
+ // Engage worktree isolation by pointing the process-global sandbox root and
98
+ // cwd at the worktree for the duration of this (serial) sub-agent run, so its
99
+ // file/command tools actually operate inside the worktree. This is only safe
100
+ // when the sub-agent runs alone; parallel agents disable isolation (see
101
+ // spawnParallelAgents) to avoid clobbering each other's global root.
102
+ let prevRoot = null;
103
+ let prevCwd = null;
104
+ const restoreWorkspace = () => {
105
+ if (prevCwd !== null) {
106
+ try {
107
+ process.chdir(prevCwd);
108
+ }
109
+ catch { }
110
+ prevCwd = null;
111
+ }
112
+ if (prevRoot !== null) {
113
+ setSandboxRoot(prevRoot);
114
+ prevRoot = null;
115
+ }
116
+ };
117
+ if (worktreeInfo) {
118
+ prevRoot = getSandboxRoot();
119
+ prevCwd = process.cwd();
120
+ try {
121
+ process.chdir(worktreeInfo.path);
122
+ setSandboxRoot(worktreeInfo.path);
123
+ }
124
+ catch (err) {
125
+ logger.warn('Worktree isolation failed to engage', { error: err.message });
126
+ restoreWorkspace();
127
+ }
128
+ }
129
+ const sendPromise = subAgent.send(prompt);
130
+ let completed = false;
90
131
  try {
91
- const response = await Promise.race([subAgent.send(prompt), timeoutPromise]);
132
+ const response = await Promise.race([sendPromise, timeoutPromise]);
133
+ completed = true;
92
134
  logger.info('Sub-agent completed', { agentId: subAgentId, model, steps, tokens: tokenEstimate });
135
+ // Restore the parent workspace before merging the worktree back.
136
+ restoreWorkspace();
93
137
  let mergeResult;
94
138
  if (worktreeInfo) {
95
139
  try {
@@ -125,6 +169,16 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
125
169
  }
126
170
  finally {
127
171
  clearTimeout(timer);
172
+ // If the run didn't finish cleanly (e.g. timeout), give the aborted agent a
173
+ // bounded grace period to stop writing before tearing down the worktree.
174
+ if (!completed) {
175
+ subAgent.abort();
176
+ await Promise.race([
177
+ sendPromise.catch(() => { }),
178
+ new Promise((r) => setTimeout(r, SUB_AGENT_GRACE_MS)),
179
+ ]);
180
+ }
181
+ restoreWorkspace();
128
182
  if (worktreeInfo) {
129
183
  try {
130
184
  await removeWorktree(worktreeInfo, process.cwd());
@@ -141,6 +195,9 @@ export async function spawnParallelAgents(parentConfig, requests) {
141
195
  ...r.options,
142
196
  allowAgentTool: r.options?.allowAgentTool ?? true,
143
197
  agentDescription: r.description,
198
+ // Parallel agents share the process-global sandbox root, so per-agent
199
+ // worktree isolation can't be applied safely; disable it by default.
200
+ isolate: r.options?.isolate ?? false,
144
201
  })));
145
202
  }
146
203
  export function pickCheapestModel(models) {
@@ -0,0 +1,73 @@
1
+ // Terminal notification passthrough for iTerm2, Kitty, Ghostty, and tmux
2
+ function detectTerminal() {
3
+ const term = process.env.TERM_PROGRAM?.toLowerCase() || '';
4
+ const tmux = !!process.env.TMUX;
5
+ if (tmux)
6
+ return 'tmux';
7
+ if (term === 'iterm.app' || term === 'iterm2')
8
+ return 'iterm2';
9
+ if (term === 'kitty')
10
+ return 'kitty';
11
+ if (term === 'ghostty')
12
+ return 'ghostty';
13
+ return 'generic';
14
+ }
15
+ function wrapForTmux(seq) {
16
+ return `\x1bPtmux;\x1b${seq}\x1b\\`;
17
+ }
18
+ function sendOSC(code, payload) {
19
+ const terminal = detectTerminal();
20
+ const seq = `\x1b]${code};${payload}\x07`;
21
+ if (terminal === 'tmux') {
22
+ process.stdout.write(wrapForTmux(seq));
23
+ }
24
+ else {
25
+ process.stdout.write(seq);
26
+ }
27
+ }
28
+ export function sendNotification(title, body) {
29
+ const terminal = detectTerminal();
30
+ switch (terminal) {
31
+ case 'iterm2':
32
+ sendOSC(9, body ? `${title}: ${body}` : title);
33
+ break;
34
+ case 'kitty':
35
+ sendOSC(99, `i=1:d=0:p=title;${title}`);
36
+ if (body) {
37
+ sendOSC(99, `i=1:d=1:p=body;${body}`);
38
+ }
39
+ else {
40
+ sendOSC(99, `i=1:d=1:p=body;`);
41
+ }
42
+ break;
43
+ case 'ghostty':
44
+ sendOSC(777, `notify;${title};${body || ''}`);
45
+ break;
46
+ case 'tmux': {
47
+ const tmuxPayload = body ? `${title}: ${body}` : title;
48
+ sendOSC(9, tmuxPayload);
49
+ break;
50
+ }
51
+ case 'generic':
52
+ // OSC 9 is widely supported (ConEmu, Windows Terminal, etc.)
53
+ sendOSC(9, body ? `${title}: ${body}` : title);
54
+ break;
55
+ }
56
+ }
57
+ export function sendBell() {
58
+ process.stdout.write('\x07');
59
+ }
60
+ export function setTerminalTitle(title) {
61
+ sendOSC(0, title);
62
+ }
63
+ export function notifyTaskComplete(taskName) {
64
+ sendNotification('Scalix', `${taskName} complete`);
65
+ sendBell();
66
+ }
67
+ export function notifyError(message) {
68
+ sendNotification('Scalix Error', message);
69
+ sendBell();
70
+ }
71
+ export function getTerminalType() {
72
+ return detectTerminal();
73
+ }
@@ -0,0 +1,146 @@
1
+ // CJK/RTL text support utilities
2
+ const CJK_RANGES = [
3
+ [0x2E80, 0x2EFF], // CJK Radicals Supplement
4
+ [0x2F00, 0x2FDF], // Kangxi Radicals
5
+ [0x3000, 0x303F], // CJK Symbols and Punctuation
6
+ [0x3040, 0x309F], // Hiragana
7
+ [0x30A0, 0x30FF], // Katakana
8
+ [0x3100, 0x312F], // Bopomofo
9
+ [0x3130, 0x318F], // Hangul Compatibility Jamo
10
+ [0x3200, 0x32FF], // Enclosed CJK Letters and Months
11
+ [0x3400, 0x4DBF], // CJK Unified Ideographs Extension A
12
+ [0x4E00, 0x9FFF], // CJK Unified Ideographs
13
+ [0xAC00, 0xD7AF], // Hangul Syllables
14
+ [0xF900, 0xFAFF], // CJK Compatibility Ideographs
15
+ [0xFE30, 0xFE4F], // CJK Compatibility Forms
16
+ [0xFF00, 0xFFEF], // Halfwidth and Fullwidth Forms
17
+ [0x20000, 0x2A6DF], // CJK Unified Ideographs Extension B
18
+ [0x2A700, 0x2B73F], // CJK Unified Ideographs Extension C
19
+ [0x2B740, 0x2B81F], // CJK Unified Ideographs Extension D
20
+ ];
21
+ const RTL_RANGES = [
22
+ [0x0590, 0x05FF], // Hebrew
23
+ [0x0600, 0x06FF], // Arabic
24
+ [0x0700, 0x074F], // Syriac
25
+ [0x0750, 0x077F], // Arabic Supplement
26
+ [0x0780, 0x07BF], // Thaana
27
+ [0x07C0, 0x07FF], // NKo
28
+ [0x0800, 0x083F], // Samaritan
29
+ [0x0840, 0x085F], // Mandaic
30
+ [0x08A0, 0x08FF], // Arabic Extended-A
31
+ [0xFB50, 0xFDFF], // Arabic Presentation Forms-A
32
+ [0xFE70, 0xFEFF], // Arabic Presentation Forms-B
33
+ [0x10800, 0x1083F], // Cypriot Syllabary
34
+ ];
35
+ function inRange(code, ranges) {
36
+ for (const [lo, hi] of ranges) {
37
+ if (code >= lo && code <= hi)
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ export function isCJK(char) {
43
+ const code = char.codePointAt(0) ?? 0;
44
+ return inRange(code, CJK_RANGES);
45
+ }
46
+ export function isRTL(char) {
47
+ const code = char.codePointAt(0) ?? 0;
48
+ return inRange(code, RTL_RANGES);
49
+ }
50
+ export function getCharWidth(char) {
51
+ const code = char.codePointAt(0) ?? 0;
52
+ if (inRange(code, CJK_RANGES))
53
+ return 2;
54
+ if (code >= 0xD800 && code <= 0xDFFF)
55
+ return 0; // surrogate pair half
56
+ if (code >= 0x0300 && code <= 0x036F)
57
+ return 0; // combining diacritical marks
58
+ if (code >= 0x0900 && code <= 0x097F)
59
+ return 1; // Devanagari
60
+ if (code >= 0xFE00 && code <= 0xFE0F)
61
+ return 0; // variation selectors
62
+ if (code >= 0x200B && code <= 0x200F)
63
+ return 0; // zero-width chars
64
+ if (code >= 0x202A && code <= 0x202E)
65
+ return 0; // bidi marks
66
+ if (code >= 0x2060 && code <= 0x206F)
67
+ return 0; // invisible operators
68
+ return 1;
69
+ }
70
+ export function stringWidth(str) {
71
+ let width = 0;
72
+ for (const char of str) {
73
+ width += getCharWidth(char);
74
+ }
75
+ return width;
76
+ }
77
+ export function detectTextDirection(text) {
78
+ let rtlCount = 0;
79
+ let ltrCount = 0;
80
+ for (const char of text) {
81
+ if (isRTL(char))
82
+ rtlCount++;
83
+ else if (/[a-zA-Z]/.test(char))
84
+ ltrCount++;
85
+ }
86
+ if (rtlCount === 0 && ltrCount === 0)
87
+ return 'ltr';
88
+ if (rtlCount > 0 && ltrCount === 0)
89
+ return 'rtl';
90
+ if (rtlCount === 0 && ltrCount > 0)
91
+ return 'ltr';
92
+ return 'mixed';
93
+ }
94
+ export function wrapText(text, maxWidth) {
95
+ const lines = [];
96
+ let currentLine = '';
97
+ let currentWidth = 0;
98
+ for (const char of text) {
99
+ if (char === '\n') {
100
+ lines.push(currentLine);
101
+ currentLine = '';
102
+ currentWidth = 0;
103
+ continue;
104
+ }
105
+ const charWidth = getCharWidth(char);
106
+ if (currentWidth + charWidth > maxWidth && currentLine.length > 0) {
107
+ lines.push(currentLine);
108
+ currentLine = char;
109
+ currentWidth = charWidth;
110
+ }
111
+ else {
112
+ currentLine += char;
113
+ currentWidth += charWidth;
114
+ }
115
+ }
116
+ if (currentLine.length > 0) {
117
+ lines.push(currentLine);
118
+ }
119
+ return lines;
120
+ }
121
+ export function truncateText(text, maxWidth, ellipsis = '…') {
122
+ const ellipsisWidth = stringWidth(ellipsis);
123
+ let width = 0;
124
+ let result = '';
125
+ for (const char of text) {
126
+ const charWidth = getCharWidth(char);
127
+ if (width + charWidth + ellipsisWidth > maxWidth) {
128
+ return result + ellipsis;
129
+ }
130
+ result += char;
131
+ width += charWidth;
132
+ }
133
+ return result;
134
+ }
135
+ export function applyRTLMarks(text) {
136
+ const dir = detectTextDirection(text);
137
+ if (dir === 'rtl') {
138
+ return `‏${text}‏`;
139
+ }
140
+ if (dir === 'mixed') {
141
+ return text.replace(/[֐-׿؀-ۿ܀-ݏ]+/g, (match) => {
142
+ return `‏${match}‏`;
143
+ });
144
+ }
145
+ return text;
146
+ }
@@ -19,6 +19,8 @@ export function estimateTokens(text) {
19
19
  const wordEstimate = words * TOKEN_ESTIMATION.wordTokenMultiplier + codeChars * TOKEN_ESTIMATION.codeCharTokenMultiplier;
20
20
  return Math.max(1, Math.ceil(charEstimate * TOKEN_ESTIMATION.charEstimateWeight + wordEstimate * TOKEN_ESTIMATION.wordEstimateWeight));
21
21
  }
22
+ // Fixed per-image token estimate so multimodal messages don't under-count the budget.
23
+ const IMAGE_PART_TOKENS = 1000;
22
24
  function contentToString(content) {
23
25
  if (!content)
24
26
  return '';
@@ -29,12 +31,19 @@ function contentToString(content) {
29
31
  .map((p) => p.text)
30
32
  .join('\n');
31
33
  }
34
+ function countImageParts(content) {
35
+ if (!content || typeof content === 'string')
36
+ return 0;
37
+ return content.filter((p) => p.type === 'image_url').length;
38
+ }
32
39
  export function estimateMessageTokens(messages) {
33
40
  let total = 0;
34
41
  for (const msg of messages) {
35
42
  total += TOKEN_ESTIMATION.messageOverhead;
36
- if (msg.content)
43
+ if (msg.content) {
37
44
  total += estimateTokens(contentToString(msg.content));
45
+ total += countImageParts(msg.content) * IMAGE_PART_TOKENS;
46
+ }
38
47
  if (msg.tool_calls) {
39
48
  for (const tc of msg.tool_calls) {
40
49
  total += estimateTokens(tc.function.name) + estimateTokens(tc.function.arguments) + TOKEN_ESTIMATION.messageOverhead;
@@ -16,7 +16,11 @@ function convertZod(schema) {
16
16
  const required = [];
17
17
  for (const [key, value] of Object.entries(shape)) {
18
18
  const prop = convertZod(value);
19
- const isOptional = value?._def?.typeName === 'ZodOptional';
19
+ // ZodDefault fields have a runtime default, so they are effectively
20
+ // optional in the JSON schema even though they aren't wrapped in
21
+ // ZodOptional. Treat both as optional so the schema matches validation.
22
+ const innerTypeName = value?._def?.typeName;
23
+ const isOptional = innerTypeName === 'ZodOptional' || innerTypeName === 'ZodDefault';
20
24
  if (!isOptional)
21
25
  required.push(key);
22
26
  properties[key] = prop;
@@ -66,10 +70,14 @@ function convertZod(schema) {
66
70
  inner.description = def.description;
67
71
  return inner;
68
72
  }
69
- case 'ZodDefault':
70
- return convertZod(def.innerType);
73
+ case 'ZodDefault': {
74
+ const inner = convertZod(def.innerType);
75
+ if (def.description && !inner.description)
76
+ inner.description = def.description;
77
+ return inner;
78
+ }
71
79
  case 'ZodEnum':
72
- return { type: 'string', enum: def.values };
80
+ return { type: 'string', enum: def.values, ...(def.description ? { description: def.description } : {}) };
73
81
  case 'ZodRecord': {
74
82
  const result = { type: 'object', additionalProperties: convertZod(def.valueType) };
75
83
  return result;