gekto 0.0.8 → 0.0.9

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.
@@ -35,9 +35,15 @@ export class HeadlessAgent {
35
35
  isRunning() {
36
36
  return this.currentProc !== null && !this.currentProc.killed;
37
37
  }
38
- async send(message, callbacks) {
38
+ async send(message, callbacks, imagePaths) {
39
+ // Append image file paths to the message so Claude can read them
40
+ let finalMessage = message;
41
+ if (imagePaths && imagePaths.length > 0) {
42
+ const imageRefs = imagePaths.map(p => ` - ${p}`).join('\n');
43
+ finalMessage += `\n\n[The user attached ${imagePaths.length} image(s). Use the Read tool to view them:\n${imageRefs}]`;
44
+ }
39
45
  const args = [
40
- '-p', message,
46
+ '-p', finalMessage,
41
47
  '--output-format', 'stream-json',
42
48
  '--verbose',
43
49
  '--model', 'claude-opus-4-5-20251101',
@@ -71,6 +77,8 @@ export class HeadlessAgent {
71
77
  let buffer = '';
72
78
  let lastResult = null;
73
79
  let currentTool = null;
80
+ const toolUseIdToName = new Map();
81
+ const streamState = { receivedDeltas: false };
74
82
  proc.stdout.on('data', (data) => {
75
83
  buffer += data.toString();
76
84
  const lines = buffer.split('\n');
@@ -80,7 +88,7 @@ export class HeadlessAgent {
80
88
  continue;
81
89
  try {
82
90
  const event = JSON.parse(line);
83
- this.processStreamEvent(event, callbacks, (tool) => {
91
+ this.processStreamEvent(event, callbacks, toolUseIdToName, streamState, (tool) => {
84
92
  currentTool = tool;
85
93
  }, () => {
86
94
  currentTool = null;
@@ -125,7 +133,7 @@ export class HeadlessAgent {
125
133
  proc.on('error', reject);
126
134
  });
127
135
  }
128
- processStreamEvent(event, callbacks, setCurrentTool, clearCurrentTool) {
136
+ processStreamEvent(event, callbacks, toolUseIdToName, streamState, setCurrentTool, clearCurrentTool) {
129
137
  if (event.type === 'assistant' && event.message) {
130
138
  const message = event.message;
131
139
  if (message.content) {
@@ -133,6 +141,10 @@ export class HeadlessAgent {
133
141
  if (block.type === 'tool_use' && block.name) {
134
142
  setCurrentTool?.(block.name);
135
143
  callbacks?.onToolStart?.(block.name, block.input);
144
+ // Track tool name by ID for tool_result matching
145
+ if (block.id) {
146
+ toolUseIdToName?.set(block.id, block.name);
147
+ }
136
148
  // Track file changes for Write/Edit tools
137
149
  if ((block.name === 'Write' || block.name === 'Edit') && block.id && block.input?.file_path) {
138
150
  const filePath = String(block.input.file_path);
@@ -144,19 +156,55 @@ export class HeadlessAgent {
144
156
  });
145
157
  }
146
158
  }
147
- // Extract text content from assistant messages
148
- if (block.type === 'text' && block.text) {
159
+ // Fallback: emit full text block only if no streaming deltas were received
160
+ if (block.type === 'text' && block.text && !streamState?.receivedDeltas) {
149
161
  callbacks?.onText?.(block.text);
150
162
  }
163
+ // Thinking block from assistant event (fallback when no thinking_delta)
164
+ if (block.type === 'thinking' && block.thinking && !streamState?.receivedDeltas) {
165
+ callbacks?.onThinking?.(block.thinking);
166
+ }
151
167
  }
152
168
  }
153
169
  }
170
+ // Streaming deltas — character-by-character text and thinking
171
+ if (event.type === 'content_block_delta') {
172
+ const delta = event.delta;
173
+ if (delta?.type === 'text_delta' && delta.text) {
174
+ if (streamState)
175
+ streamState.receivedDeltas = true;
176
+ callbacks?.onText?.(delta.text);
177
+ }
178
+ else if (delta?.type === 'thinking_delta' && delta.thinking) {
179
+ if (streamState)
180
+ streamState.receivedDeltas = true;
181
+ callbacks?.onThinking?.(delta.thinking);
182
+ }
183
+ }
154
184
  if (event.type === 'user' && event.message) {
155
185
  const message = event.message;
156
186
  if (message.content) {
157
187
  for (const block of message.content) {
158
188
  if (block.type === 'tool_result') {
159
189
  clearCurrentTool?.();
190
+ // Extract tool result content
191
+ if (block.tool_use_id && block.content != null) {
192
+ const toolName = toolUseIdToName?.get(block.tool_use_id) || 'unknown';
193
+ let resultText = '';
194
+ if (typeof block.content === 'string') {
195
+ resultText = block.content;
196
+ }
197
+ else if (Array.isArray(block.content)) {
198
+ // content is array of {type: 'text', text: '...'} blocks
199
+ resultText = block.content
200
+ .filter(c => c.type === 'text' && c.text)
201
+ .map(c => c.text)
202
+ .join('\n');
203
+ }
204
+ if (resultText) {
205
+ callbacks?.onToolResult?.(toolName, resultText, block.tool_use_id);
206
+ }
207
+ }
160
208
  // Complete file change tracking
161
209
  if (block.tool_use_id && this.pendingFileChanges.has(block.tool_use_id)) {
162
210
  const pending = this.pendingFileChanges.get(block.tool_use_id);
@@ -1,7 +1,10 @@
1
1
  import path from 'path';
2
2
  import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
3
3
  import { dirname } from 'path';
4
+ import { randomUUID } from 'crypto';
5
+ import { tmpdir } from 'os';
4
6
  import { HeadlessAgent } from './HeadlessAgent.js';
7
+ import { getState, mutate, broadcastFileChange, broadcastAgent } from '../state.js';
5
8
  // Per-lizard sessions
6
9
  const sessions = new Map();
7
10
  // Summarize tool input for display
@@ -100,14 +103,51 @@ export function getQueueLength(lizardId) {
100
103
  function safeSend(session, data) {
101
104
  const ws = session.currentWs;
102
105
  if (ws && ws.readyState === ws.OPEN) {
106
+ const type = data.type;
107
+ const lizardId = data.lizardId;
108
+ // Log outgoing messages (skip noisy streaming deltas)
109
+ if (type !== 'text' && type !== 'thinking') {
110
+ console.log(`[WS→] ${type}${lizardId ? ` [${lizardId}]` : ''}${data.tool ? ` tool=${data.tool}` : ''}${data.state ? ` state=${data.state}` : ''}`);
111
+ }
103
112
  ws.send(JSON.stringify(data));
104
113
  }
105
114
  }
106
- export async function sendMessage(lizardId, message, ws) {
115
+ // Save base64 data URL images to temp files and return file paths
116
+ export function saveImagesToTempFiles(images) {
117
+ const paths = [];
118
+ const dir = path.join(tmpdir(), 'gekto-images');
119
+ if (!existsSync(dir)) {
120
+ mkdirSync(dir, { recursive: true });
121
+ }
122
+ for (const dataUrl of images) {
123
+ const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
124
+ if (!match)
125
+ continue;
126
+ const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
127
+ const buffer = Buffer.from(match[2], 'base64');
128
+ const filePath = path.join(dir, `gekto-img-${randomUUID()}.${ext}`);
129
+ writeFileSync(filePath, buffer);
130
+ paths.push(filePath);
131
+ }
132
+ return paths;
133
+ }
134
+ export async function sendMessage(lizardId, message, ws, images) {
107
135
  const session = getOrCreateSession(lizardId, ws);
136
+ // Save images to temp files and build the final message
137
+ let finalMessage = message;
138
+ let imagePaths;
139
+ if (images && images.length > 0) {
140
+ imagePaths = saveImagesToTempFiles(images);
141
+ }
142
+ // Accumulators for streaming deltas — reset on each tool start
143
+ let accumulatedText = '';
144
+ let accumulatedThinking = '';
108
145
  // Create streaming callbacks that use session's current WebSocket
109
146
  const callbacks = {
110
147
  onToolStart: (tool, input) => {
148
+ // Reset accumulators when a new tool starts (text block ended)
149
+ accumulatedText = '';
150
+ accumulatedThinking = '';
111
151
  safeSend(session, {
112
152
  type: 'tool',
113
153
  lizardId,
@@ -126,24 +166,75 @@ export async function sendMessage(lizardId, message, ws) {
126
166
  });
127
167
  },
128
168
  onText: (text) => {
169
+ accumulatedText += text;
129
170
  safeSend(session, {
130
171
  type: 'text',
131
172
  lizardId,
132
- text,
173
+ text: accumulatedText,
174
+ });
175
+ },
176
+ onThinking: (text) => {
177
+ accumulatedThinking += text;
178
+ safeSend(session, {
179
+ type: 'thinking',
180
+ lizardId,
181
+ text: accumulatedThinking,
182
+ });
183
+ },
184
+ onToolResult: (tool, content, toolUseId) => {
185
+ safeSend(session, {
186
+ type: 'tool_result',
187
+ lizardId,
188
+ tool,
189
+ content: content.length > 2000 ? content.substring(0, 2000) + '…' : content,
190
+ toolUseId,
133
191
  });
134
192
  },
135
193
  onFileChange: (change) => {
194
+ // Encode path for use as key
195
+ const encodedPath = change.filePath.replace(/\//g, '--');
196
+ // Enrich change with metadata
197
+ const enrichedChange = {
198
+ ...change,
199
+ agentId: lizardId,
200
+ taskId: getState().agents[lizardId]?.taskId,
201
+ timestamp: new Date().toISOString(),
202
+ };
203
+ // Write to top-level fileChanges collection
204
+ mutate(`fileChanges.${encodedPath}`, enrichedChange);
205
+ broadcastFileChange(encodedPath);
206
+ // Also update agent's fileChangePaths for reference
207
+ const agent = getState().agents[lizardId];
208
+ if (agent) {
209
+ const paths = agent.fileChangePaths ?? [];
210
+ if (!paths.includes(change.filePath)) {
211
+ mutate(`agents.${lizardId}.fileChangePaths`, [...paths, change.filePath]);
212
+ }
213
+ // Keep backward-compat fileChanges on agent
214
+ const existing = agent.fileChanges ?? [];
215
+ const existingIndex = existing.findIndex(fc => fc.filePath === change.filePath);
216
+ let updated;
217
+ if (existingIndex >= 0) {
218
+ updated = [...existing];
219
+ updated[existingIndex] = { ...updated[existingIndex], after: change.after, tool: change.tool };
220
+ }
221
+ else {
222
+ updated = [...existing, enrichedChange];
223
+ }
224
+ mutate(`agents.${lizardId}.fileChanges`, updated);
225
+ broadcastAgent(lizardId);
226
+ }
136
227
  safeSend(session, {
137
228
  type: 'file_change',
138
229
  lizardId,
139
- change,
230
+ change: enrichedChange,
140
231
  });
141
232
  },
142
233
  };
143
234
  // If already processing, queue the message
144
235
  if (session.isProcessing) {
145
236
  return new Promise((resolve, reject) => {
146
- session.queue.push({ message, ws, callbacks, resolve, reject });
237
+ session.queue.push({ message: finalMessage, ws, callbacks, resolve, reject, imagePaths });
147
238
  const position = session.queue.length;
148
239
  ws.send(JSON.stringify({
149
240
  type: 'queued',
@@ -153,14 +244,14 @@ export async function sendMessage(lizardId, message, ws) {
153
244
  });
154
245
  }
155
246
  // Process immediately
156
- return processMessage(lizardId, session, message, ws, callbacks);
247
+ return processMessage(lizardId, session, finalMessage, ws, callbacks, imagePaths);
157
248
  }
158
249
  async function processMessage(lizardId, session, message, _ws, // Kept for queue compatibility, but we use session.currentWs
159
- callbacks) {
250
+ callbacks, imagePaths) {
160
251
  session.isProcessing = true;
161
252
  safeSend(session, { type: 'state', lizardId, state: 'working' });
162
253
  try {
163
- const response = await session.agent.send(message, callbacks);
254
+ const response = await session.agent.send(message, callbacks, imagePaths);
164
255
  safeSend(session, {
165
256
  type: 'response',
166
257
  lizardId,
@@ -185,7 +276,7 @@ callbacks) {
185
276
  // Process next queued message if any
186
277
  if (session.queue.length > 0) {
187
278
  const next = session.queue.shift();
188
- processMessage(lizardId, session, next.message, next.ws, next.callbacks)
279
+ processMessage(lizardId, session, next.message, next.ws, next.callbacks, next.imagePaths)
189
280
  .then(next.resolve)
190
281
  .catch(next.reject);
191
282
  }