scalix 0.0.1 → 1.0.0
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/CHANGELOG.md +122 -0
- package/LICENSE +100 -0
- package/README.md +300 -2
- package/app.py +55 -0
- package/bin/scalix.js +19 -0
- package/dist/agent-replay.js +166 -0
- package/dist/agent.js +852 -0
- package/dist/auto-verify.js +136 -0
- package/dist/change-tracker.js +53 -0
- package/dist/cli.js +2329 -0
- package/dist/commands.js +71 -0
- package/dist/config.js +16 -0
- package/dist/constants.js +324 -0
- package/dist/context-builder.js +151 -0
- package/dist/context-manager.js +148 -0
- package/dist/cost-tracker.js +85 -0
- package/dist/diff.js +140 -0
- package/dist/git-context.js +180 -0
- package/dist/git-undo.js +67 -0
- package/dist/headless.js +654 -0
- package/dist/hooks.js +94 -0
- package/dist/ipc-server.js +228 -0
- package/dist/logger.js +45 -0
- package/dist/markdown.js +41 -0
- package/dist/mcp.js +49 -0
- package/dist/model-router.js +68 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +374 -0
- package/dist/project-memory.js +165 -0
- package/dist/safe-math.js +194 -0
- package/dist/sandbox.js +279 -0
- package/dist/scoped-rules.js +82 -0
- package/dist/sessions.js +71 -0
- package/dist/settings.js +155 -0
- package/dist/sub-agent.js +79 -0
- package/dist/token-counter.js +47 -0
- package/dist/tool-helper.js +97 -0
- package/dist/tool-selector.js +77 -0
- package/dist/tools-advanced.js +232 -0
- package/dist/tools-browser.js +273 -0
- package/dist/tools-extra.js +383 -0
- package/dist/tools-vision.js +115 -0
- package/dist/tools-voice.js +147 -0
- package/dist/tools-web.js +346 -0
- package/dist/tools.js +370 -0
- package/dist/types.js +1 -0
- package/docs/DISTRIBUTION.md +301 -0
- package/package.json +67 -6
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
import { PROJECT_LOOKUP_DIRS, HOOK_DEFAULTS } from './constants.js';
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
const HOOK_PATHS = PROJECT_LOOKUP_DIRS.map(d => `${d}/hooks.json`);
|
|
9
|
+
export function loadHooks(cwd = process.cwd()) {
|
|
10
|
+
for (const relPath of HOOK_PATHS) {
|
|
11
|
+
const fullPath = resolve(cwd, relPath);
|
|
12
|
+
if (!existsSync(fullPath))
|
|
13
|
+
continue;
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(fullPath, 'utf-8');
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed?.hooks && Array.isArray(parsed.hooks)) {
|
|
18
|
+
return parsed.hooks.filter((h) => typeof h.event === 'string' && typeof h.command === 'string');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
logger.warn('Failed to load hooks config', { path: fullPath, error: err.message });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
function matchesHook(hook, toolName) {
|
|
28
|
+
if (!hook.match)
|
|
29
|
+
return true;
|
|
30
|
+
if (!toolName)
|
|
31
|
+
return false;
|
|
32
|
+
if (hook.match === toolName)
|
|
33
|
+
return true;
|
|
34
|
+
if (hook.match.endsWith('*')) {
|
|
35
|
+
return toolName.startsWith(hook.match.slice(0, -1));
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export async function executeHook(hook, env) {
|
|
40
|
+
const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
|
|
41
|
+
const hookEnv = { ...process.env, ...env };
|
|
42
|
+
try {
|
|
43
|
+
const { stdout, stderr } = await execAsync(hook.command, {
|
|
44
|
+
timeout,
|
|
45
|
+
env: hookEnv,
|
|
46
|
+
maxBuffer: HOOK_DEFAULTS.maxBuffer,
|
|
47
|
+
});
|
|
48
|
+
return { allowed: true, output: (stdout || stderr).trim() || undefined };
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.killed || err.code === 'ETIMEDOUT') {
|
|
52
|
+
return { allowed: false, error: `Hook timed out after ${timeout}ms` };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
allowed: err.code === 0,
|
|
56
|
+
output: (err.stdout || '').trim() || undefined,
|
|
57
|
+
error: (err.stderr || err.message || '').trim(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function runHooks(hooks, event, context) {
|
|
62
|
+
const matching = hooks.filter((h) => h.event === event && matchesHook(h, context.toolName));
|
|
63
|
+
if (matching.length === 0)
|
|
64
|
+
return [];
|
|
65
|
+
const env = {};
|
|
66
|
+
if (context.toolName)
|
|
67
|
+
env.SCALIX_TOOL_NAME = context.toolName;
|
|
68
|
+
if (context.toolArgs)
|
|
69
|
+
env.SCALIX_TOOL_ARGS = context.toolArgs;
|
|
70
|
+
if (context.error)
|
|
71
|
+
env.SCALIX_ERROR = context.error;
|
|
72
|
+
if (context.prompt)
|
|
73
|
+
env.SCALIX_PROMPT = context.prompt.slice(0, 4096).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
74
|
+
env.SCALIX_EVENT = event;
|
|
75
|
+
const results = [];
|
|
76
|
+
for (const hook of matching) {
|
|
77
|
+
const result = await executeHook(hook, env);
|
|
78
|
+
results.push(result);
|
|
79
|
+
logger.debug('Hook executed', { event, command: hook.command, allowed: result.allowed });
|
|
80
|
+
if (!result.allowed && event === 'PreToolUse')
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
export function hooksSummary(hooks) {
|
|
86
|
+
if (hooks.length === 0)
|
|
87
|
+
return 'No hooks configured.';
|
|
88
|
+
const lines = ['Configured hooks:'];
|
|
89
|
+
for (const h of hooks) {
|
|
90
|
+
const match = h.match ? ` (match: ${h.match})` : '';
|
|
91
|
+
lines.push(` ${h.event}${match}: ${h.command}`);
|
|
92
|
+
}
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC server mode — JSONL protocol over stdin/stdout for the VS Code extension.
|
|
3
|
+
* Spawned by the extension to use the full CLI agent with all tools and features.
|
|
4
|
+
*
|
|
5
|
+
* Protocol:
|
|
6
|
+
* stdin (client→server): {"type":"request","id":"...","method":"...","params":{}}
|
|
7
|
+
* {"type":"response","id":"...","result":{}} (for confirmTool)
|
|
8
|
+
* stdout (server→client): {"type":"event","event":"...","data":{}}
|
|
9
|
+
* {"type":"response","id":"...","result":{}}
|
|
10
|
+
* {"type":"request","id":"...","method":"confirmTool","params":{}}
|
|
11
|
+
*/
|
|
12
|
+
import readline from 'node:readline';
|
|
13
|
+
import { createAgent } from './agent.js';
|
|
14
|
+
import { defaultTools } from './tools.js';
|
|
15
|
+
import { extraTools } from './tools-extra.js';
|
|
16
|
+
import { webTools } from './tools-web.js';
|
|
17
|
+
import { advancedTools } from './tools-advanced.js';
|
|
18
|
+
import { visionTools } from './tools-vision.js';
|
|
19
|
+
import { voiceTools } from './tools-voice.js';
|
|
20
|
+
import { browserTools } from './tools-browser.js';
|
|
21
|
+
import { loadMcpServices } from './mcp.js';
|
|
22
|
+
import { loadConfig, DEFAULT_LIMITS } from './settings.js';
|
|
23
|
+
import { discoverPlugins, getPluginTools } from './plugins.js';
|
|
24
|
+
import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, fetchModelSpecs } from './constants.js';
|
|
25
|
+
import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
|
|
26
|
+
import { buildProjectContext, contextToInstructions } from './context-builder.js';
|
|
27
|
+
const pendingConfirms = new Map();
|
|
28
|
+
let confirmCounter = 0;
|
|
29
|
+
function write(msg) {
|
|
30
|
+
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
31
|
+
}
|
|
32
|
+
async function buildInstructions() {
|
|
33
|
+
let base = DEFAULT_INSTRUCTIONS;
|
|
34
|
+
const cwd = process.cwd();
|
|
35
|
+
try {
|
|
36
|
+
const mem = await loadMemory(cwd);
|
|
37
|
+
const auto = await autoDetectConventions(cwd);
|
|
38
|
+
for (const c of auto) {
|
|
39
|
+
if (!mem.conventions.some(mc => mc.key === c.key))
|
|
40
|
+
mem.conventions.push(c);
|
|
41
|
+
}
|
|
42
|
+
if (mem.conventions.length > 0 || mem.patterns.length > 0 || mem.notes.length > 0) {
|
|
43
|
+
await saveMemory(cwd, mem);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
const ctx = await buildProjectContext(cwd);
|
|
48
|
+
return contextToInstructions(base, ctx);
|
|
49
|
+
}
|
|
50
|
+
export async function runIpcServer() {
|
|
51
|
+
// Keep stdout clean for JSONL — redirect console to stderr
|
|
52
|
+
console.log = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
|
|
53
|
+
console.error = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
|
|
54
|
+
console.warn = (...a) => { process.stderr.write(a.map(String).join(' ') + '\n'); };
|
|
55
|
+
const config = await loadConfig();
|
|
56
|
+
const apiKey = process.env[ENV_API_KEY] || config.apiKey || '';
|
|
57
|
+
if (!apiKey) {
|
|
58
|
+
write({ type: 'event', event: 'error', data: { message: 'SCALIX_API_KEY not set' } });
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const limits = config.limits || DEFAULT_LIMITS;
|
|
62
|
+
const envModels = process.env[ENV_MODEL]
|
|
63
|
+
? process.env[ENV_MODEL].split(',').map(m => m.trim()).filter(Boolean)
|
|
64
|
+
: [];
|
|
65
|
+
const selectedModels = envModels.length > 0
|
|
66
|
+
? envModels
|
|
67
|
+
: config.models.length > 0 ? config.models : DEFAULT_MODELS;
|
|
68
|
+
const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
|
|
69
|
+
fetchModelSpecs(baseUrl, apiKey).catch(() => { });
|
|
70
|
+
const mcpTools = loadMcpServices();
|
|
71
|
+
const plugins = await discoverPlugins(process.cwd());
|
|
72
|
+
const pluginTools = getPluginTools(plugins);
|
|
73
|
+
const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
|
|
74
|
+
const instructions = await buildInstructions();
|
|
75
|
+
// Bidirectional confirm: send request to VS Code, wait for response
|
|
76
|
+
const confirmToolCall = async (toolName, args) => {
|
|
77
|
+
const id = `confirm_${++confirmCounter}`;
|
|
78
|
+
write({ type: 'request', id, method: 'confirmTool', params: { name: toolName, args } });
|
|
79
|
+
return new Promise(resolve => {
|
|
80
|
+
let resolved = false;
|
|
81
|
+
const safeResolve = (v) => { if (!resolved) {
|
|
82
|
+
resolved = true;
|
|
83
|
+
resolve(v);
|
|
84
|
+
} };
|
|
85
|
+
pendingConfirms.set(id, { resolve: safeResolve });
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
if (pendingConfirms.has(id)) {
|
|
88
|
+
pendingConfirms.delete(id);
|
|
89
|
+
safeResolve(false);
|
|
90
|
+
}
|
|
91
|
+
}, 60_000);
|
|
92
|
+
pendingConfirms.set(id, { resolve: (v) => { clearTimeout(timer); safeResolve(v); } });
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const permissionMode = (process.env.SCALIX_PERMISSION_MODE || config.permissionMode || 'default');
|
|
96
|
+
const agent = createAgent({
|
|
97
|
+
apiKey,
|
|
98
|
+
baseUrl,
|
|
99
|
+
model: selectedModels,
|
|
100
|
+
instructions,
|
|
101
|
+
tools: allTools,
|
|
102
|
+
permissionMode,
|
|
103
|
+
maxSteps: limits.maxSteps,
|
|
104
|
+
maxMessages: limits.maxMessages,
|
|
105
|
+
contextManager: {
|
|
106
|
+
maxContextTokens: limits.maxContextTokens,
|
|
107
|
+
compressionThreshold: limits.compressionThreshold,
|
|
108
|
+
maxToolResultTokens: limits.maxToolResultTokens,
|
|
109
|
+
},
|
|
110
|
+
instructionLoader: buildInstructions,
|
|
111
|
+
confirmToolCall,
|
|
112
|
+
autoVerify: true,
|
|
113
|
+
});
|
|
114
|
+
// Forward all agent events as JSONL
|
|
115
|
+
agent.on('stream:delta', (d, a) => write({ type: 'event', event: 'stream:delta', data: { delta: d, accumulated: a } }));
|
|
116
|
+
agent.on('stream:end', t => write({ type: 'event', event: 'stream:end', data: { text: t } }));
|
|
117
|
+
agent.on('tool:call', (n, a) => write({ type: 'event', event: 'tool:call', data: { name: n, args: a } }));
|
|
118
|
+
agent.on('tool:result', (n, r) => write({ type: 'event', event: 'tool:result', data: { name: n, result: r } }));
|
|
119
|
+
agent.on('tool:blocked', (n, r) => write({ type: 'event', event: 'tool:blocked', data: { name: n, reason: r } }));
|
|
120
|
+
agent.on('thinking:start', () => write({ type: 'event', event: 'thinking:start' }));
|
|
121
|
+
agent.on('thinking:end', () => write({ type: 'event', event: 'thinking:end' }));
|
|
122
|
+
agent.on('usage', d => write({ type: 'event', event: 'usage', data: d }));
|
|
123
|
+
agent.on('error', e => write({ type: 'event', event: 'error', data: { message: e.message } }));
|
|
124
|
+
agent.on('context:compress', i => write({ type: 'event', event: 'context:compress', data: i }));
|
|
125
|
+
agent.on('context:usage', i => write({ type: 'event', event: 'context:usage', data: i }));
|
|
126
|
+
// Signal ready with capability info
|
|
127
|
+
write({ type: 'event', event: 'ready', data: { tools: allTools.length, models: selectedModels, permissionMode } });
|
|
128
|
+
// Process JSONL from stdin
|
|
129
|
+
let busy = false;
|
|
130
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
131
|
+
rl.on('line', async (line) => {
|
|
132
|
+
let msg;
|
|
133
|
+
try {
|
|
134
|
+
msg = JSON.parse(line);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
process.stderr.write(`IPC: invalid JSON: ${line.slice(0, 100)}\n`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Response to server-initiated confirmTool request
|
|
141
|
+
if (msg.type === 'response' && pendingConfirms.has(msg.id)) {
|
|
142
|
+
const { resolve } = pendingConfirms.get(msg.id);
|
|
143
|
+
pendingConfirms.delete(msg.id);
|
|
144
|
+
resolve(msg.result?.allowed ?? false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (msg.type !== 'request')
|
|
148
|
+
return;
|
|
149
|
+
const { id, method, params } = msg;
|
|
150
|
+
try {
|
|
151
|
+
switch (method) {
|
|
152
|
+
case 'send': {
|
|
153
|
+
if (!params?.content) {
|
|
154
|
+
write({ type: 'response', id, error: { message: 'Missing content' } });
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (busy) {
|
|
158
|
+
write({ type: 'response', id, error: { message: 'Agent is busy' } });
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
busy = true;
|
|
162
|
+
write({ type: 'event', event: 'status', data: { status: 'thinking' } });
|
|
163
|
+
try {
|
|
164
|
+
const images = params.images;
|
|
165
|
+
const text = await agent.send(params.content, images);
|
|
166
|
+
write({ type: 'response', id, result: { text } });
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
write({ type: 'response', id, error: { message: err.message } });
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
busy = false;
|
|
173
|
+
write({ type: 'event', event: 'status', data: { status: 'idle' } });
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'abort':
|
|
178
|
+
agent.abort();
|
|
179
|
+
write({ type: 'response', id, result: { success: true } });
|
|
180
|
+
break;
|
|
181
|
+
case 'clear':
|
|
182
|
+
agent.clearHistory();
|
|
183
|
+
write({ type: 'response', id, result: { success: true } });
|
|
184
|
+
break;
|
|
185
|
+
case 'setModel':
|
|
186
|
+
if (params?.model)
|
|
187
|
+
agent.setModel(params.model);
|
|
188
|
+
write({ type: 'response', id, result: { model: agent.getModels()[0] } });
|
|
189
|
+
break;
|
|
190
|
+
case 'compact': {
|
|
191
|
+
const r = await agent.compact();
|
|
192
|
+
write({ type: 'response', id, result: { message: r } });
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'getModels':
|
|
196
|
+
write({ type: 'response', id, result: { models: agent.getModels() } });
|
|
197
|
+
break;
|
|
198
|
+
case 'getContextInfo': {
|
|
199
|
+
const u = agent.getContextUsage();
|
|
200
|
+
write({ type: 'response', id, result: {
|
|
201
|
+
estimatedTokens: u.tokens, maxTokens: u.maxTokens,
|
|
202
|
+
messageCount: agent.getMessages().length, utilization: u.percentage / 100,
|
|
203
|
+
} });
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'getMessages':
|
|
207
|
+
write({ type: 'response', id, result: { messages: agent.getMessages() } });
|
|
208
|
+
break;
|
|
209
|
+
case 'loadMessages':
|
|
210
|
+
if (params?.messages)
|
|
211
|
+
agent.loadMessages(params.messages);
|
|
212
|
+
write({ type: 'response', id, result: { success: true } });
|
|
213
|
+
break;
|
|
214
|
+
case 'setPermissionMode':
|
|
215
|
+
if (params?.mode)
|
|
216
|
+
agent.setPermissionMode(params.mode);
|
|
217
|
+
write({ type: 'response', id, result: { mode: agent.getPermissionMode() } });
|
|
218
|
+
break;
|
|
219
|
+
default:
|
|
220
|
+
write({ type: 'response', id, error: { message: `Unknown method: ${method}` } });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
write({ type: 'response', id, error: { message: err.message } });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
rl.on('close', () => process.exit(0));
|
|
228
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
|
3
|
+
export class Logger {
|
|
4
|
+
level;
|
|
5
|
+
requestId;
|
|
6
|
+
constructor(level = 'info') {
|
|
7
|
+
this.level = level;
|
|
8
|
+
}
|
|
9
|
+
child(requestId) {
|
|
10
|
+
const child = new Logger(this.level);
|
|
11
|
+
child.requestId = requestId ?? randomUUID().slice(0, 8);
|
|
12
|
+
return child;
|
|
13
|
+
}
|
|
14
|
+
shouldLog(level) {
|
|
15
|
+
return LEVELS[level] >= LEVELS[this.level];
|
|
16
|
+
}
|
|
17
|
+
format(entry) {
|
|
18
|
+
const parts = [entry.timestamp, `[${entry.level.toUpperCase()}]`];
|
|
19
|
+
if (entry.requestId)
|
|
20
|
+
parts.push(`[${entry.requestId}]`);
|
|
21
|
+
parts.push(entry.message);
|
|
22
|
+
if (entry.data)
|
|
23
|
+
parts.push(JSON.stringify(entry.data));
|
|
24
|
+
return parts.join(' ');
|
|
25
|
+
}
|
|
26
|
+
log(level, message, data) {
|
|
27
|
+
if (!this.shouldLog(level))
|
|
28
|
+
return;
|
|
29
|
+
const entry = {
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
level,
|
|
32
|
+
requestId: this.requestId,
|
|
33
|
+
message,
|
|
34
|
+
data,
|
|
35
|
+
};
|
|
36
|
+
const output = this.format(entry);
|
|
37
|
+
process.stderr.write(output + '\n');
|
|
38
|
+
}
|
|
39
|
+
setLevel(level) { this.level = level; }
|
|
40
|
+
debug(msg, data) { this.log('debug', msg, data); }
|
|
41
|
+
info(msg, data) { this.log('info', msg, data); }
|
|
42
|
+
warn(msg, data) { this.log('warn', msg, data); }
|
|
43
|
+
error(msg, data) { this.log('error', msg, data); }
|
|
44
|
+
}
|
|
45
|
+
export const logger = new Logger(process.env.LOG_LEVEL || 'info');
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { Marked } from 'marked';
|
|
3
|
+
import { markedTerminal } from 'marked-terminal';
|
|
4
|
+
function createMarked(width) {
|
|
5
|
+
const m = new Marked();
|
|
6
|
+
const mt = markedTerminal({
|
|
7
|
+
strong: chalk.bold,
|
|
8
|
+
em: chalk.italic,
|
|
9
|
+
codespan: chalk.cyan,
|
|
10
|
+
code: chalk.yellow,
|
|
11
|
+
heading: chalk.green.bold,
|
|
12
|
+
firstHeading: chalk.magenta.bold,
|
|
13
|
+
hr: chalk.dim,
|
|
14
|
+
link: chalk.blue,
|
|
15
|
+
href: chalk.blue.underline,
|
|
16
|
+
blockquote: chalk.gray.italic,
|
|
17
|
+
del: chalk.dim.strikethrough,
|
|
18
|
+
width,
|
|
19
|
+
showSectionPrefix: true,
|
|
20
|
+
reflowText: false,
|
|
21
|
+
tab: 2,
|
|
22
|
+
});
|
|
23
|
+
m.use(mt);
|
|
24
|
+
return m;
|
|
25
|
+
}
|
|
26
|
+
let cachedWidth = 0;
|
|
27
|
+
let cachedMarked = null;
|
|
28
|
+
export function renderMarkdown(text, cols) {
|
|
29
|
+
const width = Math.max((cols ?? process.stdout.columns ?? 100) - 6, 40);
|
|
30
|
+
if (!cachedMarked || width !== cachedWidth) {
|
|
31
|
+
cachedWidth = width;
|
|
32
|
+
cachedMarked = createMarked(width);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const result = cachedMarked.parse(text);
|
|
36
|
+
return result.replace(/\n{3,}/g, '\n\n').trim();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { McpServicesConfigSchema } from './config.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
function resolveAuthorization(config) {
|
|
6
|
+
if (config.authorization)
|
|
7
|
+
return config.authorization;
|
|
8
|
+
if (config.authorizationEnv) {
|
|
9
|
+
const value = process.env[config.authorizationEnv];
|
|
10
|
+
if (typeof value === 'string' && value.trim().length > 0)
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function toMcpTool(config) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'mcp',
|
|
18
|
+
serverLabel: config.serverLabel,
|
|
19
|
+
serverUrl: config.serverUrl,
|
|
20
|
+
serverDescription: config.serverDescription,
|
|
21
|
+
authorization: resolveAuthorization(config),
|
|
22
|
+
headers: config.headers,
|
|
23
|
+
allowedTools: config.allowedTools,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function loadMcpServices(configPath = 'mcp-services.json') {
|
|
27
|
+
const absolutePath = resolve(process.cwd(), configPath);
|
|
28
|
+
if (!existsSync(absolutePath))
|
|
29
|
+
return [];
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(absolutePath, 'utf-8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
const validated = McpServicesConfigSchema.parse(parsed);
|
|
34
|
+
return validated.mcpServers
|
|
35
|
+
.filter((s) => {
|
|
36
|
+
const url = s.serverUrl.toLowerCase();
|
|
37
|
+
if (url.includes('example.com') || url.includes('your-') || url.includes('placeholder')) {
|
|
38
|
+
logger.warn(`Skipping MCP "${s.serverLabel}": placeholder URL detected`, { url: s.serverUrl });
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
})
|
|
43
|
+
.map(toMcpTool);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
logger.error('Failed to load MCP config', { path: absolutePath, error: err.message });
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { COMPLEXITY_PATTERNS, ROUTING_DEFAULTS } from './constants.js';
|
|
2
|
+
export function classifyComplexity(query, toolCallCount = 0, config) {
|
|
3
|
+
if (toolCallCount > ROUTING_DEFAULTS.toolCallComplexThreshold)
|
|
4
|
+
return 'complex';
|
|
5
|
+
const simplePatterns = config?.simplePatterns ?? COMPLEXITY_PATTERNS.simple;
|
|
6
|
+
const complexPatterns = config?.complexPatterns ?? COMPLEXITY_PATTERNS.complex;
|
|
7
|
+
for (const p of simplePatterns) {
|
|
8
|
+
if (p.test(query))
|
|
9
|
+
return 'simple';
|
|
10
|
+
}
|
|
11
|
+
for (const p of complexPatterns) {
|
|
12
|
+
if (p.test(query))
|
|
13
|
+
return 'complex';
|
|
14
|
+
}
|
|
15
|
+
const wordCount = query.split(/\s+/).length;
|
|
16
|
+
if (wordCount <= ROUTING_DEFAULTS.simpleWordMax)
|
|
17
|
+
return 'simple';
|
|
18
|
+
if (wordCount >= ROUTING_DEFAULTS.complexWordMin)
|
|
19
|
+
return 'complex';
|
|
20
|
+
return 'moderate';
|
|
21
|
+
}
|
|
22
|
+
export function selectModel(query, config, toolCallCount = 0) {
|
|
23
|
+
if (!config.enabled || config.tiers.length === 0) {
|
|
24
|
+
return config.defaultModel;
|
|
25
|
+
}
|
|
26
|
+
const complexity = classifyComplexity(query, toolCallCount, {
|
|
27
|
+
simplePatterns: config.simplePatterns,
|
|
28
|
+
complexPatterns: config.complexPatterns,
|
|
29
|
+
});
|
|
30
|
+
const tier = config.tiers.find((t) => t.complexity.includes(complexity));
|
|
31
|
+
if (tier)
|
|
32
|
+
return tier.model;
|
|
33
|
+
const fallback = config.tiers.find((t) => t.complexity.includes('moderate'));
|
|
34
|
+
return fallback?.model || config.defaultModel;
|
|
35
|
+
}
|
|
36
|
+
export function createRouterConfig(models, enabled = false) {
|
|
37
|
+
if (models.length === 0) {
|
|
38
|
+
return { tiers: [], enabled: false, defaultModel: '' };
|
|
39
|
+
}
|
|
40
|
+
if (models.length === 1) {
|
|
41
|
+
return {
|
|
42
|
+
tiers: [{ model: models[0], complexity: ['simple', 'moderate', 'complex'] }],
|
|
43
|
+
enabled,
|
|
44
|
+
defaultModel: models[0],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const tiers = [];
|
|
48
|
+
if (models.length >= 3) {
|
|
49
|
+
tiers.push({ model: models[0], complexity: ['simple'] });
|
|
50
|
+
tiers.push({ model: models[1], complexity: ['moderate'] });
|
|
51
|
+
tiers.push({ model: models[2], complexity: ['complex'] });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
tiers.push({ model: models[0], complexity: ['simple', 'moderate'] });
|
|
55
|
+
tiers.push({ model: models[1], complexity: ['complex'] });
|
|
56
|
+
}
|
|
57
|
+
return { tiers, enabled, defaultModel: models[0] };
|
|
58
|
+
}
|
|
59
|
+
export function getRouterStatus(config) {
|
|
60
|
+
if (!config.enabled)
|
|
61
|
+
return 'Model routing: disabled (using single model)';
|
|
62
|
+
const lines = ['Model routing: enabled'];
|
|
63
|
+
for (const tier of config.tiers) {
|
|
64
|
+
const model = tier.model.split('/').pop()?.replace(/:free$/, '') || tier.model;
|
|
65
|
+
lines.push(` ${tier.complexity.join(', ')} → ${model}`);
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { PROJECT_CONFIG_DIR, PLUGIN_DEFAULTS } from './constants.js';
|
|
6
|
+
const PLUGINS_DIR = `${PROJECT_CONFIG_DIR}/plugins`;
|
|
7
|
+
const manifestSchema = z.object({
|
|
8
|
+
name: z.string(),
|
|
9
|
+
version: z.string().default('1.0.0'),
|
|
10
|
+
description: z.string().default(''),
|
|
11
|
+
tools: z.array(z.object({
|
|
12
|
+
name: z.string(),
|
|
13
|
+
description: z.string(),
|
|
14
|
+
inputSchema: z.record(z.string(), z.unknown()).default({}),
|
|
15
|
+
handler: z.string(),
|
|
16
|
+
})),
|
|
17
|
+
permissions: z.object({
|
|
18
|
+
allowedTools: z.array(z.string()).optional(),
|
|
19
|
+
blockedTools: z.array(z.string()).optional(),
|
|
20
|
+
permissionMode: z.enum(['plan', 'default', 'auto']).optional(),
|
|
21
|
+
}).optional(),
|
|
22
|
+
});
|
|
23
|
+
let _pluginCache = new Map();
|
|
24
|
+
const CACHE_TTL_MS = PLUGIN_DEFAULTS.cacheTtlMs;
|
|
25
|
+
async function loadPluginHandler(pluginDir, handlerPath) {
|
|
26
|
+
const fullPath = path.resolve(pluginDir, handlerPath);
|
|
27
|
+
const normalizedFull = path.normalize(fullPath);
|
|
28
|
+
const normalizedDir = path.normalize(pluginDir) + path.sep;
|
|
29
|
+
if (!normalizedFull.startsWith(normalizedDir)) {
|
|
30
|
+
throw new Error(`Plugin handler path escapes plugin directory: ${handlerPath}`);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(fullPath);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error(`Plugin handler not found: ${fullPath}`);
|
|
37
|
+
}
|
|
38
|
+
const fileUrl = pathToFileURL(fullPath).href;
|
|
39
|
+
const mod = await import(fileUrl);
|
|
40
|
+
if (typeof mod.default === 'function')
|
|
41
|
+
return mod.default;
|
|
42
|
+
if (typeof mod.execute === 'function')
|
|
43
|
+
return mod.execute;
|
|
44
|
+
if (typeof mod.handler === 'function')
|
|
45
|
+
return mod.handler;
|
|
46
|
+
throw new Error(`Plugin handler ${handlerPath} must export default, execute, or handler function`);
|
|
47
|
+
}
|
|
48
|
+
export async function loadPlugin(pluginDir) {
|
|
49
|
+
const manifestPath = path.join(pluginDir, 'plugin.json');
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(manifestPath);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.readFile(manifestPath, 'utf-8');
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
const manifest = manifestSchema.parse(parsed);
|
|
60
|
+
const tools = [];
|
|
61
|
+
for (const toolDef of manifest.tools) {
|
|
62
|
+
try {
|
|
63
|
+
const handler = await loadPluginHandler(pluginDir, toolDef.handler);
|
|
64
|
+
tools.push({
|
|
65
|
+
type: 'function',
|
|
66
|
+
function: {
|
|
67
|
+
name: `plugin_${manifest.name}_${toolDef.name}`,
|
|
68
|
+
description: `[Plugin: ${manifest.name}] ${toolDef.description}`,
|
|
69
|
+
parameters: toolDef.inputSchema,
|
|
70
|
+
execute: handler,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
process.stderr.write(`Warning: failed to load plugin tool ${toolDef.name}: ${err.message}\n`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { manifest, tools, path: pluginDir };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
process.stderr.write(`Warning: failed to load plugin from ${pluginDir}: ${err.message}\n`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function discoverPlugins(projectRoot) {
|
|
86
|
+
const cached = _pluginCache.get(projectRoot);
|
|
87
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
88
|
+
return cached.plugins;
|
|
89
|
+
}
|
|
90
|
+
const pluginsDir = path.join(projectRoot, PLUGINS_DIR);
|
|
91
|
+
const plugins = [];
|
|
92
|
+
try {
|
|
93
|
+
await fs.access(pluginsDir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (!entry.isDirectory())
|
|
102
|
+
continue;
|
|
103
|
+
const pluginDir = path.join(pluginsDir, entry.name);
|
|
104
|
+
const plugin = await loadPlugin(pluginDir);
|
|
105
|
+
if (plugin)
|
|
106
|
+
plugins.push(plugin);
|
|
107
|
+
}
|
|
108
|
+
const rootPlugin = await loadPlugin(pluginsDir);
|
|
109
|
+
if (rootPlugin)
|
|
110
|
+
plugins.push(rootPlugin);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
_pluginCache.set(projectRoot, { plugins, timestamp: Date.now() });
|
|
116
|
+
return plugins;
|
|
117
|
+
}
|
|
118
|
+
export function getPluginTools(plugins) {
|
|
119
|
+
return plugins.flatMap((p) => p.tools);
|
|
120
|
+
}
|
|
121
|
+
export function pluginsSummary(plugins) {
|
|
122
|
+
if (plugins.length === 0)
|
|
123
|
+
return 'No plugins loaded.';
|
|
124
|
+
const lines = [`${plugins.length} plugin(s) loaded:`];
|
|
125
|
+
for (const p of plugins) {
|
|
126
|
+
lines.push(` ${p.manifest.name} v${p.manifest.version} (${p.tools.length} tools)`);
|
|
127
|
+
for (const t of p.tools) {
|
|
128
|
+
lines.push(` - ${t.function.name}: ${t.function.description}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|