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/PARITY-PLAN.md +116 -0
- package/bin/scalix.js +1 -0
- package/dist/agent.js +69 -31
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +931 -183
- package/dist/commands.js +70 -2
- package/dist/constants.js +87 -6
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +116 -33
- package/dist/hooks.js +195 -13
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +222 -0
- package/dist/markdown.js +49 -3
- package/dist/model-router.js +11 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +20 -1
- package/dist/sandbox.js +139 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +65 -6
- package/dist/settings.js +124 -4
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +59 -2
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +44 -14
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +3 -2
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
|
-
|
|
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([
|
|
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
|
+
}
|
package/dist/token-counter.js
CHANGED
|
@@ -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;
|
package/dist/tool-helper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|