@exreve/exk 1.0.60 → 1.0.62

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.
@@ -0,0 +1,391 @@
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
+ // @ts-expect-error — Pi SDK is an optional dependency, may not be installed
19
+ let piSdk;
20
+ try {
21
+ // @ts-expect-error — optional dependency
22
+ piSdk = await import('@mariozechner/pi-coding-agent');
23
+ }
24
+ catch {
25
+ // Pi SDK not installed — this backend will be unavailable
26
+ }
27
+ export class PiBackend {
28
+ id = 'pi';
29
+ activeSessions = new Map(); // sessionId -> AgentSession
30
+ isAvailable() {
31
+ return piSdk !== undefined;
32
+ }
33
+ async *executePrompt(prompt, config) {
34
+ if (!piSdk) {
35
+ throw new Error('Pi SDK (@mariozechner/pi-coding-agent) is not installed. Install with: npm install @mariozechner/pi-coding-agent');
36
+ }
37
+ const { cwd, apiKey, model, provider, signal, attachmentDir, routingSessionId, routingPromptId, } = config;
38
+ // Set up auth and model registry
39
+ const authStorage = piSdk.AuthStorage.create();
40
+ const modelRegistry = piSdk.ModelRegistry.create(authStorage);
41
+ // For custom providers (Z.ai, MiniMax, etc.), set runtime API key
42
+ if (provider && apiKey) {
43
+ authStorage.setRuntimeApiKey(provider, apiKey);
44
+ }
45
+ else if (apiKey) {
46
+ // Default: assume anthropic provider
47
+ authStorage.setRuntimeApiKey('anthropic', apiKey);
48
+ }
49
+ // Build custom tools (equivalent of MCP tools in Claude SDK)
50
+ const customTools = [];
51
+ customTools.push(this.createAnalyzeImageTool(attachmentDir));
52
+ customTools.push(this.createSendFileTool(attachmentDir));
53
+ customTools.push(this.createBrowserQueryTool(routingSessionId, routingPromptId));
54
+ // Create session options
55
+ const sessionOpts = {
56
+ cwd,
57
+ authStorage,
58
+ modelRegistry,
59
+ sessionManager: piSdk.SessionManager.inMemory(),
60
+ customTools,
61
+ };
62
+ // Set model if specified
63
+ if (model) {
64
+ try {
65
+ // Try to find a built-in model first
66
+ const builtInModel = piSdk.getModel?.('anthropic', model);
67
+ if (builtInModel) {
68
+ sessionOpts.model = builtInModel;
69
+ }
70
+ else {
71
+ // Try as a custom model — Pi supports custom providers via models.json
72
+ // For now, we try the model string directly
73
+ const found = modelRegistry.find(provider || 'anthropic', model);
74
+ if (found) {
75
+ sessionOpts.model = found;
76
+ }
77
+ // If not found, Pi will use its default model
78
+ }
79
+ }
80
+ catch {
81
+ // Model not found — Pi will use default
82
+ }
83
+ }
84
+ // Create the agent session
85
+ const { session } = await piSdk.createAgentSession(sessionOpts);
86
+ const sessionKey = `${cwd}:${Date.now()}`;
87
+ this.activeSessions.set(sessionKey, session);
88
+ // Create event queue — Pi uses subscribe(), we need to bridge to async iterable
89
+ const eventQueue = [];
90
+ let resolveEvent = null;
91
+ let streamDone = false;
92
+ let streamError = null;
93
+ // Subscribe to Pi events and translate to BackendEvent
94
+ const unsubscribe = session.subscribe((event) => {
95
+ const backendEvent = this.translateEvent(event);
96
+ if (backendEvent) {
97
+ if (resolveEvent) {
98
+ // Someone is waiting — resolve immediately
99
+ const r = resolveEvent;
100
+ resolveEvent = null;
101
+ r({ value: backendEvent, done: false });
102
+ }
103
+ else {
104
+ eventQueue.push(backendEvent);
105
+ }
106
+ }
107
+ });
108
+ // Listen for agent completion
109
+ session.prompt(prompt).then(() => {
110
+ // Prompt completed — drain remaining events then signal done
111
+ streamDone = true;
112
+ if (resolveEvent) {
113
+ const r = resolveEvent;
114
+ resolveEvent = null;
115
+ r({ value: undefined, done: true });
116
+ }
117
+ }).catch((err) => {
118
+ streamError = err;
119
+ streamDone = true;
120
+ if (resolveEvent) {
121
+ const r = resolveEvent;
122
+ resolveEvent = null;
123
+ r({ value: undefined, done: true });
124
+ }
125
+ });
126
+ // Handle abort signal
127
+ if (signal) {
128
+ const onAbort = () => {
129
+ session.abort();
130
+ streamDone = true;
131
+ if (resolveEvent) {
132
+ const r = resolveEvent;
133
+ resolveEvent = null;
134
+ r({ value: undefined, done: true });
135
+ }
136
+ };
137
+ signal.addEventListener('abort', onAbort, { once: true });
138
+ }
139
+ // Yield events as async generator
140
+ try {
141
+ while (!streamDone || eventQueue.length > 0) {
142
+ if (streamError) {
143
+ throw streamError;
144
+ }
145
+ if (eventQueue.length > 0) {
146
+ yield eventQueue.shift();
147
+ continue;
148
+ }
149
+ if (streamDone) {
150
+ break;
151
+ }
152
+ // Wait for next event
153
+ const event = await new Promise((resolve) => {
154
+ resolveEvent = resolve;
155
+ });
156
+ if (event.done)
157
+ break;
158
+ yield event.value;
159
+ }
160
+ }
161
+ finally {
162
+ unsubscribe();
163
+ session.dispose();
164
+ this.activeSessions.delete(sessionKey);
165
+ }
166
+ }
167
+ /**
168
+ * Translate a Pi SDK event to our normalized BackendEvent format.
169
+ */
170
+ translateEvent(event) {
171
+ switch (event.type) {
172
+ // Text streaming from assistant
173
+ case 'message_update': {
174
+ const sub = event.assistantMessageEvent;
175
+ if (sub?.type === 'text_delta') {
176
+ // Accumulate into an assistant message event
177
+ // Pi streams text deltas — we batch these into assistant_message events
178
+ return {
179
+ type: 'assistant_message',
180
+ raw: { type: 'text_delta', text: sub.delta },
181
+ };
182
+ }
183
+ if (sub?.type === 'thinking_delta') {
184
+ // Thinking output — emit as system event
185
+ return {
186
+ type: 'system',
187
+ raw: { type: 'thinking_delta', text: sub.delta },
188
+ subtype: 'thinking',
189
+ };
190
+ }
191
+ return null;
192
+ }
193
+ // Tool execution
194
+ case 'tool_execution_start': {
195
+ return {
196
+ type: 'tool_progress',
197
+ raw: event,
198
+ toolName: event.toolName,
199
+ };
200
+ }
201
+ case 'tool_execution_update': {
202
+ return {
203
+ type: 'tool_progress',
204
+ raw: event,
205
+ toolName: event.toolName,
206
+ };
207
+ }
208
+ case 'tool_execution_end': {
209
+ const toolResult = event.result;
210
+ return {
211
+ type: 'tool_result',
212
+ result: toolResult?.content || toolResult,
213
+ toolName: event.toolName || null,
214
+ toolUseId: event.toolCallId || null,
215
+ };
216
+ }
217
+ // Message lifecycle
218
+ case 'message_start': {
219
+ return {
220
+ type: 'system',
221
+ raw: event,
222
+ subtype: 'message_start',
223
+ };
224
+ }
225
+ case 'message_end': {
226
+ return {
227
+ type: 'system',
228
+ raw: event,
229
+ subtype: 'message_end',
230
+ };
231
+ }
232
+ // Agent lifecycle
233
+ case 'agent_start': {
234
+ return {
235
+ type: 'system',
236
+ raw: event,
237
+ subtype: 'agent_start',
238
+ };
239
+ }
240
+ case 'agent_end': {
241
+ // Agent finished — this is the final event
242
+ // Extract usage from the last message if available
243
+ return {
244
+ type: 'result',
245
+ raw: event,
246
+ isError: false,
247
+ usage: event.usage ? {
248
+ inputTokens: event.usage.input_tokens || 0,
249
+ outputTokens: event.usage.output_tokens || 0,
250
+ } : undefined,
251
+ };
252
+ }
253
+ // Turn lifecycle
254
+ case 'turn_start': {
255
+ return {
256
+ type: 'system',
257
+ raw: event,
258
+ subtype: 'turn_start',
259
+ };
260
+ }
261
+ case 'turn_end': {
262
+ // A complete assistant turn — this maps well to our assistant_message
263
+ const message = event.message;
264
+ const toolUses = [];
265
+ // Extract tool use info from message content blocks
266
+ if (message?.content && Array.isArray(message.content)) {
267
+ for (const block of message.content) {
268
+ if (block.type === 'tool_use' && block.id && block.name) {
269
+ toolUses.push({ id: block.id, name: block.name });
270
+ }
271
+ }
272
+ }
273
+ return {
274
+ type: 'assistant_message',
275
+ raw: message,
276
+ toolUses: toolUses.length > 0 ? toolUses : undefined,
277
+ };
278
+ }
279
+ // Queue events
280
+ case 'queue_update': {
281
+ return {
282
+ type: 'progress',
283
+ message: 'Queue updated',
284
+ raw: event,
285
+ };
286
+ }
287
+ // Compaction
288
+ case 'compaction_start':
289
+ case 'compaction_end': {
290
+ return {
291
+ type: 'system',
292
+ raw: event,
293
+ subtype: event.type,
294
+ };
295
+ }
296
+ // Retry
297
+ case 'auto_retry_start':
298
+ case 'auto_retry_end': {
299
+ return {
300
+ type: 'system',
301
+ raw: event,
302
+ subtype: event.type,
303
+ };
304
+ }
305
+ default:
306
+ // Unknown event — emit as system
307
+ return {
308
+ type: 'system',
309
+ raw: event,
310
+ subtype: `pi_${event.type}`,
311
+ };
312
+ }
313
+ }
314
+ // ============ Custom Tools (Pi SDK defineTool format) ============
315
+ // These use the same shared executors as the Claude backend (sharedTools.ts)
316
+ createAnalyzeImageTool(attachmentDir) {
317
+ if (!piSdk)
318
+ return null;
319
+ const sharedConfig = { attachmentDir };
320
+ return piSdk.defineTool({
321
+ name: 'analyze_image',
322
+ label: 'Analyze Image',
323
+ description: 'Analyze one or more image files using a vision model. Pass the path to an image file and a question.',
324
+ parameters: {
325
+ type: 'object',
326
+ properties: {
327
+ image_path: { type: 'string', description: 'Path to the image file to analyze' },
328
+ question: { type: 'string', description: 'Question about the image' },
329
+ },
330
+ required: ['image_path', 'question'],
331
+ },
332
+ execute: async (_toolCallId, params) => {
333
+ return executeAnalyzeImage({ image_path: params.image_path, question: params.question }, sharedConfig);
334
+ },
335
+ });
336
+ }
337
+ createSendFileTool(attachmentDir) {
338
+ if (!piSdk)
339
+ return null;
340
+ const sharedConfig = { attachmentDir };
341
+ return piSdk.defineTool({
342
+ name: 'send_file',
343
+ label: 'Send File',
344
+ description: 'Send a file to the user for display in chat.',
345
+ parameters: {
346
+ type: 'object',
347
+ properties: {
348
+ file_path: { type: 'string', description: 'Path to a local file' },
349
+ data: { type: 'string', description: 'Base64-encoded file content' },
350
+ mime_type: { type: 'string', description: 'MIME type' },
351
+ filename: { type: 'string', description: 'Display name' },
352
+ },
353
+ },
354
+ execute: async (_toolCallId, params) => {
355
+ return executeSendFile(params, sharedConfig);
356
+ },
357
+ });
358
+ }
359
+ createBrowserQueryTool(sessionId, promptId) {
360
+ if (!piSdk)
361
+ return null;
362
+ const sharedConfig = { sessionId, promptId };
363
+ return piSdk.defineTool({
364
+ name: 'browser_query',
365
+ label: 'Browser Query',
366
+ description: 'Launch a headless browser to automate web tasks.',
367
+ parameters: {
368
+ type: 'object',
369
+ properties: {
370
+ query: { type: 'string', description: 'Natural language task' },
371
+ maxSteps: { type: 'number', description: 'Max steps (default 20)' },
372
+ },
373
+ required: ['query'],
374
+ },
375
+ execute: async (_toolCallId, params) => {
376
+ return executeBrowserQuery({ query: params.query, maxSteps: params.maxSteps }, sharedConfig);
377
+ },
378
+ });
379
+ }
380
+ async dispose() {
381
+ for (const [_key, session] of this.activeSessions) {
382
+ try {
383
+ session.dispose();
384
+ }
385
+ catch { /* ignore */ }
386
+ }
387
+ this.activeSessions.clear();
388
+ }
389
+ }
390
+ /** Singleton instance */
391
+ 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
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exreve/exk",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
4
4
  "description": "exk - Control Claude CLI with voice and programmable interfaces",
5
5
  "type": "module",
6
6
  "bin": {