@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.
@@ -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
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exreve/exk",
3
- "version": "1.0.61",
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",