@exreve/exk 1.0.61 → 1.0.63
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/cli/agentBackend.js +12 -0
- package/dist/cli/agentSession.js +253 -685
- package/dist/cli/claudeBackend.js +535 -0
- package/dist/cli/moduleMcpServer.js +50 -311
- package/dist/cli/piBackend.js +384 -0
- package/dist/cli/sessionHandlers.js +3 -2
- package/dist/cli/sharedTools.js +259 -0
- package/dist/ttc-cli.tar.gz +0 -0
- package/package.json +2 -1
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi.dev Agent SDK Backend
|
|
3
|
+
*
|
|
4
|
+
* Implements AgentBackend using @mariozechner/pi-coding-agent SDK.
|
|
5
|
+
*
|
|
6
|
+
* Key differences from Claude Agent SDK:
|
|
7
|
+
* - Pi runs in-process (no subprocess spawning)
|
|
8
|
+
* - Uses event subscription model instead of async iterable
|
|
9
|
+
* - Different tool registration (defineTool + TypeBox vs createSdkMcpServer + Zod)
|
|
10
|
+
* - Session management via SessionManager instead of resume IDs
|
|
11
|
+
* - Multi-provider support is first-class via AuthStorage + ModelRegistry
|
|
12
|
+
*
|
|
13
|
+
* NOTE: This backend requires @mariozechner/pi-coding-agent to be installed.
|
|
14
|
+
* If not available, the backend factory will fall back to ClaudeBackend.
|
|
15
|
+
*/
|
|
16
|
+
import { executeAnalyzeImage, executeSendFile, executeBrowserQuery } from './sharedTools.js';
|
|
17
|
+
// Conditional import — will be undefined if package not installed
|
|
18
|
+
let piSdk;
|
|
19
|
+
try {
|
|
20
|
+
piSdk = await import('@mariozechner/pi-coding-agent');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Pi SDK not installed — this backend will be unavailable
|
|
24
|
+
}
|
|
25
|
+
export class PiBackend {
|
|
26
|
+
id = 'pi';
|
|
27
|
+
activeSessions = new Map(); // sessionId -> AgentSession
|
|
28
|
+
isAvailable() {
|
|
29
|
+
return piSdk !== undefined;
|
|
30
|
+
}
|
|
31
|
+
async *executePrompt(prompt, config) {
|
|
32
|
+
if (!piSdk) {
|
|
33
|
+
throw new Error('Pi SDK (@mariozechner/pi-coding-agent) is not installed. Install with: npm install @mariozechner/pi-coding-agent');
|
|
34
|
+
}
|
|
35
|
+
const { cwd, apiKey, model, provider, signal, attachmentDir, routingSessionId, routingPromptId, } = config;
|
|
36
|
+
// Set up auth and model registry
|
|
37
|
+
const authStorage = piSdk.AuthStorage.create();
|
|
38
|
+
const modelRegistry = piSdk.ModelRegistry.create(authStorage);
|
|
39
|
+
// For custom providers (Z.ai, MiniMax, etc.), set runtime API key
|
|
40
|
+
if (provider && apiKey) {
|
|
41
|
+
authStorage.setRuntimeApiKey(provider, apiKey);
|
|
42
|
+
}
|
|
43
|
+
else if (apiKey) {
|
|
44
|
+
// Default: assume anthropic provider
|
|
45
|
+
authStorage.setRuntimeApiKey('anthropic', apiKey);
|
|
46
|
+
}
|
|
47
|
+
// Build custom tools (equivalent of MCP tools in Claude SDK)
|
|
48
|
+
const customTools = [];
|
|
49
|
+
customTools.push(this.createAnalyzeImageTool(attachmentDir));
|
|
50
|
+
customTools.push(this.createSendFileTool(attachmentDir));
|
|
51
|
+
customTools.push(this.createBrowserQueryTool(routingSessionId, routingPromptId));
|
|
52
|
+
// Create session options
|
|
53
|
+
const sessionOpts = {
|
|
54
|
+
cwd,
|
|
55
|
+
authStorage,
|
|
56
|
+
modelRegistry,
|
|
57
|
+
sessionManager: piSdk.SessionManager.inMemory(),
|
|
58
|
+
customTools,
|
|
59
|
+
};
|
|
60
|
+
// Set model if specified
|
|
61
|
+
if (model) {
|
|
62
|
+
try {
|
|
63
|
+
// Try as a custom model via ModelRegistry
|
|
64
|
+
const found = modelRegistry.find(provider || 'anthropic', model);
|
|
65
|
+
if (found) {
|
|
66
|
+
sessionOpts.model = found;
|
|
67
|
+
}
|
|
68
|
+
// If not found, Pi will use its default model
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Model not found — Pi will use default
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Create the agent session
|
|
75
|
+
const { session } = await piSdk.createAgentSession(sessionOpts);
|
|
76
|
+
const sessionKey = `${cwd}:${Date.now()}`;
|
|
77
|
+
this.activeSessions.set(sessionKey, session);
|
|
78
|
+
// Create event queue — Pi uses subscribe(), we need to bridge to async iterable
|
|
79
|
+
const eventQueue = [];
|
|
80
|
+
let resolveEvent = null;
|
|
81
|
+
let streamDone = false;
|
|
82
|
+
let streamError = null;
|
|
83
|
+
// Subscribe to Pi events and translate to BackendEvent
|
|
84
|
+
const unsubscribe = session.subscribe((event) => {
|
|
85
|
+
const backendEvent = this.translateEvent(event);
|
|
86
|
+
if (backendEvent) {
|
|
87
|
+
if (resolveEvent) {
|
|
88
|
+
// Someone is waiting — resolve immediately
|
|
89
|
+
const r = resolveEvent;
|
|
90
|
+
resolveEvent = null;
|
|
91
|
+
r({ value: backendEvent, done: false });
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
eventQueue.push(backendEvent);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// Listen for agent completion
|
|
99
|
+
session.prompt(prompt).then(() => {
|
|
100
|
+
// Prompt completed — drain remaining events then signal done
|
|
101
|
+
streamDone = true;
|
|
102
|
+
if (resolveEvent) {
|
|
103
|
+
const r = resolveEvent;
|
|
104
|
+
resolveEvent = null;
|
|
105
|
+
r({ value: undefined, done: true });
|
|
106
|
+
}
|
|
107
|
+
}).catch((err) => {
|
|
108
|
+
streamError = err;
|
|
109
|
+
streamDone = true;
|
|
110
|
+
if (resolveEvent) {
|
|
111
|
+
const r = resolveEvent;
|
|
112
|
+
resolveEvent = null;
|
|
113
|
+
r({ value: undefined, done: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// Handle abort signal
|
|
117
|
+
if (signal) {
|
|
118
|
+
const onAbort = () => {
|
|
119
|
+
session.abort();
|
|
120
|
+
streamDone = true;
|
|
121
|
+
if (resolveEvent) {
|
|
122
|
+
const r = resolveEvent;
|
|
123
|
+
resolveEvent = null;
|
|
124
|
+
r({ value: undefined, done: true });
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
128
|
+
}
|
|
129
|
+
// Yield events as async generator
|
|
130
|
+
try {
|
|
131
|
+
while (!streamDone || eventQueue.length > 0) {
|
|
132
|
+
if (streamError) {
|
|
133
|
+
throw streamError;
|
|
134
|
+
}
|
|
135
|
+
if (eventQueue.length > 0) {
|
|
136
|
+
yield eventQueue.shift();
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (streamDone) {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
// Wait for next event
|
|
143
|
+
const event = await new Promise((resolve) => {
|
|
144
|
+
resolveEvent = resolve;
|
|
145
|
+
});
|
|
146
|
+
if (event.done)
|
|
147
|
+
break;
|
|
148
|
+
yield event.value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
unsubscribe();
|
|
153
|
+
session.dispose();
|
|
154
|
+
this.activeSessions.delete(sessionKey);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Translate a Pi SDK event to our normalized BackendEvent format.
|
|
159
|
+
*/
|
|
160
|
+
translateEvent(event) {
|
|
161
|
+
switch (event.type) {
|
|
162
|
+
// Text streaming from assistant
|
|
163
|
+
case 'message_update': {
|
|
164
|
+
const sub = event.assistantMessageEvent;
|
|
165
|
+
if (sub?.type === 'text_delta') {
|
|
166
|
+
// Accumulate into an assistant message event
|
|
167
|
+
// Pi streams text deltas — we batch these into assistant_message events
|
|
168
|
+
return {
|
|
169
|
+
type: 'assistant_message',
|
|
170
|
+
raw: { type: 'text_delta', text: sub.delta },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (sub?.type === 'thinking_delta') {
|
|
174
|
+
// Thinking output — emit as system event
|
|
175
|
+
return {
|
|
176
|
+
type: 'system',
|
|
177
|
+
raw: { type: 'thinking_delta', text: sub.delta },
|
|
178
|
+
subtype: 'thinking',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
// Tool execution
|
|
184
|
+
case 'tool_execution_start': {
|
|
185
|
+
return {
|
|
186
|
+
type: 'tool_progress',
|
|
187
|
+
raw: event,
|
|
188
|
+
toolName: event.toolName,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
case 'tool_execution_update': {
|
|
192
|
+
return {
|
|
193
|
+
type: 'tool_progress',
|
|
194
|
+
raw: event,
|
|
195
|
+
toolName: event.toolName,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
case 'tool_execution_end': {
|
|
199
|
+
const toolResult = event.result;
|
|
200
|
+
return {
|
|
201
|
+
type: 'tool_result',
|
|
202
|
+
result: toolResult?.content || toolResult,
|
|
203
|
+
toolName: event.toolName || null,
|
|
204
|
+
toolUseId: event.toolCallId || null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// Message lifecycle
|
|
208
|
+
case 'message_start': {
|
|
209
|
+
return {
|
|
210
|
+
type: 'system',
|
|
211
|
+
raw: event,
|
|
212
|
+
subtype: 'message_start',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
case 'message_end': {
|
|
216
|
+
return {
|
|
217
|
+
type: 'system',
|
|
218
|
+
raw: event,
|
|
219
|
+
subtype: 'message_end',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// Agent lifecycle
|
|
223
|
+
case 'agent_start': {
|
|
224
|
+
return {
|
|
225
|
+
type: 'system',
|
|
226
|
+
raw: event,
|
|
227
|
+
subtype: 'agent_start',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
case 'agent_end': {
|
|
231
|
+
// Agent finished — this is the final event
|
|
232
|
+
// Extract usage from the last message if available
|
|
233
|
+
return {
|
|
234
|
+
type: 'result',
|
|
235
|
+
raw: event,
|
|
236
|
+
isError: false,
|
|
237
|
+
usage: event.usage ? {
|
|
238
|
+
inputTokens: event.usage.input_tokens || 0,
|
|
239
|
+
outputTokens: event.usage.output_tokens || 0,
|
|
240
|
+
} : undefined,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// Turn lifecycle
|
|
244
|
+
case 'turn_start': {
|
|
245
|
+
return {
|
|
246
|
+
type: 'system',
|
|
247
|
+
raw: event,
|
|
248
|
+
subtype: 'turn_start',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
case 'turn_end': {
|
|
252
|
+
// A complete assistant turn — this maps well to our assistant_message
|
|
253
|
+
const message = event.message;
|
|
254
|
+
const toolUses = [];
|
|
255
|
+
// Extract tool use info from message content blocks
|
|
256
|
+
if (message?.content && Array.isArray(message.content)) {
|
|
257
|
+
for (const block of message.content) {
|
|
258
|
+
if (block.type === 'tool_use' && block.id && block.name) {
|
|
259
|
+
toolUses.push({ id: block.id, name: block.name });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
type: 'assistant_message',
|
|
265
|
+
raw: message,
|
|
266
|
+
toolUses: toolUses.length > 0 ? toolUses : undefined,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// Queue events
|
|
270
|
+
case 'queue_update': {
|
|
271
|
+
return {
|
|
272
|
+
type: 'progress',
|
|
273
|
+
message: 'Queue updated',
|
|
274
|
+
raw: event,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Compaction
|
|
278
|
+
case 'compaction_start':
|
|
279
|
+
case 'compaction_end': {
|
|
280
|
+
return {
|
|
281
|
+
type: 'system',
|
|
282
|
+
raw: event,
|
|
283
|
+
subtype: event.type,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// Retry
|
|
287
|
+
case 'auto_retry_start':
|
|
288
|
+
case 'auto_retry_end': {
|
|
289
|
+
return {
|
|
290
|
+
type: 'system',
|
|
291
|
+
raw: event,
|
|
292
|
+
subtype: event.type,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
default:
|
|
296
|
+
// Unknown event — emit as system
|
|
297
|
+
return {
|
|
298
|
+
type: 'system',
|
|
299
|
+
raw: event,
|
|
300
|
+
subtype: `pi_${event.type}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// ============ Custom Tools (Pi SDK defineTool format) ============
|
|
305
|
+
// These use the same shared executors as the Claude backend (sharedTools.ts)
|
|
306
|
+
createAnalyzeImageTool(attachmentDir) {
|
|
307
|
+
if (!piSdk)
|
|
308
|
+
return null;
|
|
309
|
+
const sharedConfig = { attachmentDir };
|
|
310
|
+
return piSdk.defineTool({
|
|
311
|
+
name: 'analyze_image',
|
|
312
|
+
label: 'Analyze Image',
|
|
313
|
+
description: 'Analyze one or more image files using a vision model. Pass the path to an image file and a question.',
|
|
314
|
+
parameters: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
properties: {
|
|
317
|
+
image_path: { type: 'string', description: 'Path to the image file to analyze' },
|
|
318
|
+
question: { type: 'string', description: 'Question about the image' },
|
|
319
|
+
},
|
|
320
|
+
required: ['image_path', 'question'],
|
|
321
|
+
},
|
|
322
|
+
execute: async (_toolCallId, params) => {
|
|
323
|
+
const result = await executeAnalyzeImage({ image_path: params.image_path, question: params.question }, sharedConfig);
|
|
324
|
+
return { ...result, details: {} };
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
createSendFileTool(attachmentDir) {
|
|
329
|
+
if (!piSdk)
|
|
330
|
+
return null;
|
|
331
|
+
const sharedConfig = { attachmentDir };
|
|
332
|
+
return piSdk.defineTool({
|
|
333
|
+
name: 'send_file',
|
|
334
|
+
label: 'Send File',
|
|
335
|
+
description: 'Send a file to the user for display in chat.',
|
|
336
|
+
parameters: {
|
|
337
|
+
type: 'object',
|
|
338
|
+
properties: {
|
|
339
|
+
file_path: { type: 'string', description: 'Path to a local file' },
|
|
340
|
+
data: { type: 'string', description: 'Base64-encoded file content' },
|
|
341
|
+
mime_type: { type: 'string', description: 'MIME type' },
|
|
342
|
+
filename: { type: 'string', description: 'Display name' },
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
execute: async (_toolCallId, params) => {
|
|
346
|
+
const result = await executeSendFile(params, sharedConfig);
|
|
347
|
+
return { ...result, details: {} };
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
createBrowserQueryTool(sessionId, promptId) {
|
|
352
|
+
if (!piSdk)
|
|
353
|
+
return null;
|
|
354
|
+
const sharedConfig = { sessionId, promptId };
|
|
355
|
+
return piSdk.defineTool({
|
|
356
|
+
name: 'browser_query',
|
|
357
|
+
label: 'Browser Query',
|
|
358
|
+
description: 'Launch a headless browser to automate web tasks.',
|
|
359
|
+
parameters: {
|
|
360
|
+
type: 'object',
|
|
361
|
+
properties: {
|
|
362
|
+
query: { type: 'string', description: 'Natural language task' },
|
|
363
|
+
maxSteps: { type: 'number', description: 'Max steps (default 20)' },
|
|
364
|
+
},
|
|
365
|
+
required: ['query'],
|
|
366
|
+
},
|
|
367
|
+
execute: async (_toolCallId, params) => {
|
|
368
|
+
const result = await executeBrowserQuery({ query: params.query, maxSteps: params.maxSteps }, sharedConfig);
|
|
369
|
+
return { ...result, details: {} };
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async dispose() {
|
|
374
|
+
for (const [_key, session] of this.activeSessions) {
|
|
375
|
+
try {
|
|
376
|
+
session.dispose();
|
|
377
|
+
}
|
|
378
|
+
catch { /* ignore */ }
|
|
379
|
+
}
|
|
380
|
+
this.activeSessions.clear();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** Singleton instance */
|
|
384
|
+
export const piBackend = new PiBackend();
|
|
@@ -111,7 +111,7 @@ export function registerSessionHandlers(socket, foreground, activeSessions, getS
|
|
|
111
111
|
});
|
|
112
112
|
socket.on('session:prompt', async (data) => {
|
|
113
113
|
try {
|
|
114
|
-
const { sessionId, prompt, projectPath: providedProjectPath, promptId, enhancers, model } = data;
|
|
114
|
+
const { sessionId, prompt, projectPath: providedProjectPath, promptId, enhancers, model, agentBackend } = data;
|
|
115
115
|
if (!promptId) {
|
|
116
116
|
if (foreground) {
|
|
117
117
|
console.error(`✗ Missing required promptId for session: ${sessionId}`);
|
|
@@ -127,7 +127,7 @@ export function registerSessionHandlers(socket, foreground, activeSessions, getS
|
|
|
127
127
|
socket.emit('session:error', { sessionId, error: 'Session not found or projectPath missing' });
|
|
128
128
|
return;
|
|
129
129
|
}
|
|
130
|
-
activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model });
|
|
130
|
+
activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model, agentBackend });
|
|
131
131
|
const capturedPromptId = promptId;
|
|
132
132
|
if (foreground) {
|
|
133
133
|
console.log(`\n[CLI] 📤 Received prompt for session: ${sessionId}, promptId: ${capturedPromptId}`);
|
|
@@ -142,6 +142,7 @@ export function registerSessionHandlers(socket, foreground, activeSessions, getS
|
|
|
142
142
|
projectPath,
|
|
143
143
|
promptId: capturedPromptId,
|
|
144
144
|
model: model,
|
|
145
|
+
agentBackend: agentBackend,
|
|
145
146
|
attachments: data.attachments,
|
|
146
147
|
onStatusUpdate: (status) => {
|
|
147
148
|
if (!capturedPromptId) {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Tool Executors
|
|
3
|
+
*
|
|
4
|
+
* Provider-agnostic implementations of custom tools (analyze_image, send_file, browser_query).
|
|
5
|
+
* These functions contain the actual business logic and can be wrapped by any agent SDK
|
|
6
|
+
* (Claude Agent SDK MCP tools, Pi SDK defineTool, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Each executor takes typed parameters and returns a standardized result.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
import { getOpenrouterApiKey, getApiUrl } from './agentSession.js';
|
|
14
|
+
// ============ Constants ============
|
|
15
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
16
|
+
const MIME_MAP = {
|
|
17
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
18
|
+
gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
|
|
19
|
+
svg: 'image/svg+xml', ico: 'image/x-icon', tiff: 'image/tiff', tif: 'image/tiff',
|
|
20
|
+
avif: 'image/avif',
|
|
21
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
|
|
22
|
+
m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac',
|
|
23
|
+
wma: 'audio/x-ms-wma', opus: 'audio/opus',
|
|
24
|
+
mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
|
25
|
+
avi: 'video/x-msvideo', mov: 'video/quicktime', wmv: 'video/x-ms-wmv',
|
|
26
|
+
m4v: 'video/mp4', '3gp': 'video/3gpp',
|
|
27
|
+
pdf: 'application/pdf',
|
|
28
|
+
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
|
|
29
|
+
json: 'application/json', xml: 'text/xml', yaml: 'text/yaml', yml: 'text/yaml',
|
|
30
|
+
toml: 'text/plain', html: 'text/html', htm: 'text/html',
|
|
31
|
+
css: 'text/css', scss: 'text/x-scss', less: 'text/x-less',
|
|
32
|
+
js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
|
|
33
|
+
ts: 'text/typescript', tsx: 'text/typescript',
|
|
34
|
+
jsx: 'text/javascript', py: 'text/x-python', rs: 'text/x-rust',
|
|
35
|
+
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
|
|
36
|
+
h: 'text/x-c', hpp: 'text/x-c++', rb: 'text/x-ruby', php: 'text/x-php',
|
|
37
|
+
sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
|
|
38
|
+
sql: 'text/x-sql', graphql: 'text/graphql', vue: 'text/x-vue',
|
|
39
|
+
svelte: 'text/x-svelte', dart: 'text/x-dart', swift: 'text/x-swift',
|
|
40
|
+
kt: 'text/x-kotlin', scala: 'text/x-scala', lua: 'text/x-lua',
|
|
41
|
+
r: 'text/x-r', dockerfile: 'text/x-dockerfile',
|
|
42
|
+
};
|
|
43
|
+
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif', 'avif']);
|
|
44
|
+
const MAX_IMAGE_DIMENSION = 2048;
|
|
45
|
+
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
46
|
+
// ============ Helpers ============
|
|
47
|
+
function getMimeType(filePath) {
|
|
48
|
+
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
|
49
|
+
return MIME_MAP[ext] || 'application/octet-stream';
|
|
50
|
+
}
|
|
51
|
+
function isImageFile(filePath) {
|
|
52
|
+
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
|
53
|
+
return IMAGE_EXTENSIONS.has(ext);
|
|
54
|
+
}
|
|
55
|
+
async function compressImage(buf) {
|
|
56
|
+
// Dynamic import of sharp — it's an optional dependency
|
|
57
|
+
let sharp;
|
|
58
|
+
try {
|
|
59
|
+
sharp = (await import('sharp')).default;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { data: buf, mime: 'image/jpeg' };
|
|
63
|
+
}
|
|
64
|
+
const metadata = await sharp(buf).metadata();
|
|
65
|
+
const { width = 0, height = 0, size = 0 } = metadata;
|
|
66
|
+
const needsResize = width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION;
|
|
67
|
+
const needsCompress = (size || buf.length) > MAX_IMAGE_BYTES;
|
|
68
|
+
if (!needsResize && !needsCompress) {
|
|
69
|
+
const fmt = metadata.format || 'jpeg';
|
|
70
|
+
const mime = fmt === 'png' ? 'image/png' : fmt === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
71
|
+
return { data: buf, mime };
|
|
72
|
+
}
|
|
73
|
+
let pipeline = sharp(buf)
|
|
74
|
+
.resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, { fit: 'inside', withoutEnlargement: true });
|
|
75
|
+
if (metadata.hasAlpha) {
|
|
76
|
+
pipeline = pipeline.webp({ quality: 80 });
|
|
77
|
+
return { data: await pipeline.toBuffer(), mime: 'image/webp' };
|
|
78
|
+
}
|
|
79
|
+
pipeline = pipeline.jpeg({ quality: 80 });
|
|
80
|
+
return { data: await pipeline.toBuffer(), mime: 'image/jpeg' };
|
|
81
|
+
}
|
|
82
|
+
async function fileToDataUri(filePath) {
|
|
83
|
+
try {
|
|
84
|
+
const buf = fs.readFileSync(filePath);
|
|
85
|
+
if (isImageFile(filePath)) {
|
|
86
|
+
const { data, mime } = await compressImage(buf);
|
|
87
|
+
return `data:${mime};base64,${data.toString('base64')}`;
|
|
88
|
+
}
|
|
89
|
+
const mime = getMimeType(filePath);
|
|
90
|
+
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// ============ Tool Executors ============
|
|
97
|
+
/**
|
|
98
|
+
* analyze_image — analyze an image file using a vision model via OpenRouter.
|
|
99
|
+
*/
|
|
100
|
+
export async function executeAnalyzeImage(args, config) {
|
|
101
|
+
const workDir = config.attachmentDir || os.tmpdir();
|
|
102
|
+
const apiKey = getOpenrouterApiKey();
|
|
103
|
+
if (!apiKey) {
|
|
104
|
+
return { content: [{ type: 'text', text: 'Error: OPENROUTER_API_KEY not configured.' }], isError: true };
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const imagePath = path.resolve(workDir, args.image_path);
|
|
108
|
+
if (!fs.existsSync(imagePath)) {
|
|
109
|
+
return { content: [{ type: 'text', text: `Error: Image file not found: ${args.image_path}` }], isError: true };
|
|
110
|
+
}
|
|
111
|
+
const dataUri = await fileToDataUri(imagePath);
|
|
112
|
+
if (!dataUri) {
|
|
113
|
+
return { content: [{ type: 'text', text: `Error: Could not read image file: ${args.image_path}` }], isError: true };
|
|
114
|
+
}
|
|
115
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
model: 'qwen/qwen3.5-27b',
|
|
120
|
+
messages: [{ role: 'user', content: [
|
|
121
|
+
{ type: 'text', text: args.question },
|
|
122
|
+
{ type: 'image_url', image_url: { url: dataUri } },
|
|
123
|
+
] }],
|
|
124
|
+
}),
|
|
125
|
+
signal: AbortSignal.timeout(60_000),
|
|
126
|
+
});
|
|
127
|
+
const raw = await res.text();
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
return { content: [{ type: 'text', text: `Error from vision API (${res.status}): ${raw.slice(0, 500)}` }], isError: true };
|
|
130
|
+
}
|
|
131
|
+
const parsed = JSON.parse(raw);
|
|
132
|
+
return { content: [{ type: 'text', text: parsed.choices?.[0]?.message?.content || raw }] };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return { content: [{ type: 'text', text: `Error analyzing image: ${error.message}` }], isError: true };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* send_file — send a file to the user for display in chat.
|
|
140
|
+
*/
|
|
141
|
+
export async function executeSendFile(args, config) {
|
|
142
|
+
const workDir = config.attachmentDir || os.tmpdir();
|
|
143
|
+
try {
|
|
144
|
+
let dataUri;
|
|
145
|
+
let mimeType;
|
|
146
|
+
let fileName;
|
|
147
|
+
let fileSize;
|
|
148
|
+
if (args.file_path) {
|
|
149
|
+
const filePath = path.resolve(workDir, args.file_path);
|
|
150
|
+
if (!fs.existsSync(filePath)) {
|
|
151
|
+
return { content: [{ type: 'text', text: `Error: File not found: ${args.file_path}` }], isError: true };
|
|
152
|
+
}
|
|
153
|
+
const stat = fs.statSync(filePath);
|
|
154
|
+
fileSize = stat.size;
|
|
155
|
+
if (fileSize > MAX_FILE_SIZE) {
|
|
156
|
+
return { content: [{ type: 'text', text: `Error: File too large (${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
|
|
157
|
+
}
|
|
158
|
+
const buf = fs.readFileSync(filePath);
|
|
159
|
+
mimeType = args.mime_type || getMimeType(filePath);
|
|
160
|
+
fileName = args.filename || path.basename(filePath);
|
|
161
|
+
dataUri = `data:${mimeType};base64,${buf.toString('base64')}`;
|
|
162
|
+
}
|
|
163
|
+
else if (args.data) {
|
|
164
|
+
mimeType = args.mime_type || 'application/octet-stream';
|
|
165
|
+
fileName = args.filename || 'file';
|
|
166
|
+
const rawBase64 = args.data.replace(/^data:[^;]+;base64,/, '');
|
|
167
|
+
fileSize = Math.floor(rawBase64.length * 0.75);
|
|
168
|
+
if (fileSize > MAX_FILE_SIZE) {
|
|
169
|
+
return { content: [{ type: 'text', text: `Error: Data too large (~${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
|
|
170
|
+
}
|
|
171
|
+
dataUri = `data:${mimeType};base64,${rawBase64}`;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
return { content: [{ type: 'text', text: 'Error: Either file_path or data must be provided.' }], isError: true };
|
|
175
|
+
}
|
|
176
|
+
const result = JSON.stringify({
|
|
177
|
+
_type: 'send_file',
|
|
178
|
+
data: dataUri,
|
|
179
|
+
mime_type: mimeType,
|
|
180
|
+
filename: fileName,
|
|
181
|
+
size: fileSize,
|
|
182
|
+
});
|
|
183
|
+
return { content: [{ type: 'text', text: result }] };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return { content: [{ type: 'text', text: `Error sending file: ${error.message}` }], isError: true };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* browser_query — launch a headless browser to automate web tasks via the backend.
|
|
191
|
+
*/
|
|
192
|
+
export async function executeBrowserQuery(args, config) {
|
|
193
|
+
const apiUrl = getApiUrl();
|
|
194
|
+
let deviceId = '';
|
|
195
|
+
try {
|
|
196
|
+
const deviceIdPath = path.join(os.homedir(), '.talk-to-code', 'device-id.json');
|
|
197
|
+
const data = fs.readFileSync(deviceIdPath, 'utf-8');
|
|
198
|
+
deviceId = JSON.parse(data).deviceId || '';
|
|
199
|
+
}
|
|
200
|
+
catch { /* no device ID */ }
|
|
201
|
+
try {
|
|
202
|
+
const body = {
|
|
203
|
+
query: args.query,
|
|
204
|
+
maxSteps: args.maxSteps || 20,
|
|
205
|
+
};
|
|
206
|
+
if (args.schema) {
|
|
207
|
+
try {
|
|
208
|
+
body.schema = JSON.parse(args.schema);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
body.schema = args.schema;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (args.country)
|
|
215
|
+
body.country = args.country;
|
|
216
|
+
if (args.mobile)
|
|
217
|
+
body.mobile = args.mobile;
|
|
218
|
+
if (config.sessionId)
|
|
219
|
+
body.sessionId = config.sessionId;
|
|
220
|
+
if (config.promptId)
|
|
221
|
+
body.promptId = config.promptId;
|
|
222
|
+
const res = await fetch(`${apiUrl}/api/browser/query`, {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: {
|
|
225
|
+
'Content-Type': 'application/json',
|
|
226
|
+
...(deviceId ? { 'X-Device-ID': deviceId } : {}),
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify(body),
|
|
229
|
+
signal: AbortSignal.timeout(10 * 60 * 1000),
|
|
230
|
+
});
|
|
231
|
+
const raw = await res.text();
|
|
232
|
+
if (!res.ok) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: 'text', text: `Error from browser agent (${res.status}): ${raw.slice(0, 500)}` }],
|
|
235
|
+
isError: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const result = JSON.parse(raw);
|
|
239
|
+
const summary = [
|
|
240
|
+
`Browser query completed in ${result.steps} steps.`,
|
|
241
|
+
result.answer ? `\n\n**Answer:** ${result.answer}` : '',
|
|
242
|
+
result.data ? `\n\n**Structured Data:**\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\`` : '',
|
|
243
|
+
result.logs?.length ? `\n\n**Log:**\n${result.logs.slice(-5).join('\n')}` : '',
|
|
244
|
+
].join('');
|
|
245
|
+
return { content: [{ type: 'text', text: summary }] };
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error.name === 'TimeoutError') {
|
|
249
|
+
return {
|
|
250
|
+
content: [{ type: 'text', text: 'Browser query timed out after 10 minutes. Try reducing maxSteps or simplifying the query.' }],
|
|
251
|
+
isError: true,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: 'text', text: `Error running browser query: ${error.message}` }],
|
|
256
|
+
isError: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
package/dist/ttc-cli.tar.gz
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exreve/exk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.63",
|
|
4
4
|
"description": "exk - Control Claude CLI with voice and programmable interfaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"@anthropic-ai/claude-agent-sdk": "^0.2.126",
|
|
37
37
|
"@anthropic-ai/sdk": "^0.92.0",
|
|
38
38
|
"@fastify/static": "^9.0.0",
|
|
39
|
+
"@mariozechner/pi-coding-agent": "^0.73.1",
|
|
39
40
|
"@xenova/transformers": "^2.17.2",
|
|
40
41
|
"anthropic-proxy": "^1.3.0",
|
|
41
42
|
"chokidar": "^3.6.0",
|