@codewithdan/zingit 0.0.1

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,168 @@
1
+ // server/src/agents/copilot.ts
2
+ // Agent that uses GitHub Copilot SDK
3
+ import { CopilotClient } from '@github/copilot-sdk';
4
+ import { BaseAgent } from './base.js';
5
+ import { promises as fs } from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ import { randomUUID } from 'crypto';
9
+ export class CopilotAgent extends BaseAgent {
10
+ name = 'copilot';
11
+ model;
12
+ client = null;
13
+ constructor() {
14
+ super();
15
+ this.model = process.env.COPILOT_MODEL || 'claude-sonnet-4-20250514';
16
+ }
17
+ async start() {
18
+ // Initialize the Copilot client
19
+ this.client = new CopilotClient({
20
+ logLevel: 'info',
21
+ autoRestart: true,
22
+ });
23
+ await this.client.start();
24
+ console.log(`✓ Copilot SDK initialized (model: ${this.model})`);
25
+ }
26
+ async stop() {
27
+ if (this.client) {
28
+ await this.client.stop();
29
+ this.client = null;
30
+ }
31
+ }
32
+ async createSession(ws, projectDir) {
33
+ if (!this.client) {
34
+ throw new Error('Copilot client not initialized');
35
+ }
36
+ const send = (data) => {
37
+ if (ws.readyState === ws.OPEN) {
38
+ ws.send(JSON.stringify(data));
39
+ }
40
+ };
41
+ // Create a Copilot session with streaming enabled
42
+ // Note: Copilot SDK doesn't support cwd in session config, so we include it in the system message
43
+ const session = await this.client.createSession({
44
+ model: this.model,
45
+ streaming: true,
46
+ systemMessage: {
47
+ mode: 'append',
48
+ content: `
49
+ <context>
50
+ You are a UI debugging assistant working in the project directory: ${projectDir}
51
+
52
+ When given annotations about UI elements:
53
+ 1. Search for the corresponding code using the selectors and HTML context provided
54
+ 2. Make the requested changes in the project at ${projectDir}
55
+ 3. Be thorough in finding the right files and making precise edits
56
+
57
+ When screenshots are provided, use them to:
58
+ - Better understand the visual context and styling of the elements
59
+ - Identify the exact appearance that needs to be changed
60
+ - Verify you're targeting the correct element based on its visual representation
61
+
62
+ IMPORTANT: Format all responses using markdown:
63
+ - Use **bold** for emphasis on important points
64
+ - Use numbered lists for sequential steps (1. 2. 3.)
65
+ - Use bullet points for non-sequential items
66
+ - Use code blocks with \`\`\`language syntax for code examples
67
+ - Use inline \`code\` for file paths, selectors, and technical terms
68
+ </context>
69
+ `
70
+ },
71
+ onPermissionRequest: async (request) => {
72
+ // Auto-approve read/write operations for file edits
73
+ if (request.kind === 'read' || request.kind === 'write') {
74
+ return { kind: 'approved' };
75
+ }
76
+ return { kind: 'approved' };
77
+ },
78
+ });
79
+ // Track temp files for cleanup on session destroy (prevents race condition)
80
+ const sessionTempFiles = [];
81
+ // Subscribe to streaming events and capture unsubscribe function
82
+ const unsubscribe = session.on((event) => {
83
+ switch (event.type) {
84
+ case 'assistant.message_delta':
85
+ // Streaming chunk
86
+ send({ type: 'delta', content: event.data.deltaContent });
87
+ break;
88
+ case 'assistant.message':
89
+ // Final message (we already sent deltas, so just log)
90
+ break;
91
+ case 'tool.execution_start':
92
+ send({ type: 'tool_start', tool: event.data.toolName });
93
+ break;
94
+ case 'tool.execution_complete':
95
+ send({ type: 'tool_end', tool: event.data.toolCallId });
96
+ break;
97
+ case 'session.idle':
98
+ send({ type: 'idle' });
99
+ break;
100
+ case 'session.error':
101
+ send({ type: 'error', message: event.data.message });
102
+ break;
103
+ }
104
+ });
105
+ return {
106
+ send: async (msg) => {
107
+ try {
108
+ // If images are provided, save them as temp files and attach them
109
+ // Copilot SDK supports file attachments for images
110
+ const attachments = [];
111
+ if (msg.images && msg.images.length > 0) {
112
+ const tempDir = os.tmpdir();
113
+ for (let i = 0; i < msg.images.length; i++) {
114
+ const img = msg.images[i];
115
+ // Use UUID to avoid filename collisions
116
+ const ext = img.mediaType.split('/')[1] || 'png';
117
+ const tempPath = path.join(tempDir, `zingit-screenshot-${randomUUID()}.${ext}`);
118
+ // Decode base64 to buffer with error handling
119
+ let buffer;
120
+ try {
121
+ buffer = Buffer.from(img.base64, 'base64');
122
+ }
123
+ catch (decodeErr) {
124
+ console.warn(`ZingIt: Failed to decode base64 for image ${i + 1}:`, decodeErr);
125
+ continue; // Skip this image
126
+ }
127
+ // Save with restrictive permissions (owner read/write only)
128
+ await fs.writeFile(tempPath, buffer, { mode: 0o600 });
129
+ sessionTempFiles.push(tempPath);
130
+ attachments.push({
131
+ type: 'file',
132
+ path: tempPath,
133
+ displayName: img.label || `Screenshot ${i + 1}`
134
+ });
135
+ }
136
+ }
137
+ await session.sendAndWait({
138
+ prompt: msg.prompt,
139
+ attachments: attachments.length > 0 ? attachments : undefined
140
+ });
141
+ }
142
+ catch (err) {
143
+ send({ type: 'error', message: err.message });
144
+ }
145
+ // Note: Temp files cleaned up on session destroy to avoid race condition
146
+ },
147
+ destroy: async () => {
148
+ try {
149
+ unsubscribe();
150
+ await session.destroy();
151
+ }
152
+ finally {
153
+ // Clean up all temp files even if destroy() fails
154
+ for (const tempPath of sessionTempFiles) {
155
+ try {
156
+ await fs.unlink(tempPath);
157
+ }
158
+ catch (cleanupErr) {
159
+ // Ignore errors (file may already be deleted)
160
+ console.warn(`ZingIt: Failed to clean up temp file ${tempPath}:`, cleanupErr.message);
161
+ }
162
+ }
163
+ sessionTempFiles.length = 0; // Clear the array
164
+ }
165
+ }
166
+ };
167
+ }
168
+ }
@@ -0,0 +1,57 @@
1
+ import type { WebSocket } from 'ws';
2
+ import type { Agent, WSIncomingMessage, WSOutgoingMessage } from '../types.js';
3
+ import type { GitManager, GitManagerError as GitManagerErrorType } from '../services/git-manager.js';
4
+ export { GitManagerError } from '../services/git-manager.js';
5
+ export interface ConnectionState {
6
+ session: any | null;
7
+ agentName: string | null;
8
+ agent: Agent | null;
9
+ gitManager: GitManager | null;
10
+ currentCheckpointId: string | null;
11
+ }
12
+ export interface MessageHandlerDeps {
13
+ projectDir: string;
14
+ detectAgents: () => Promise<any[]>;
15
+ getAgent: (name: string) => Promise<Agent>;
16
+ }
17
+ export declare function sendMessage(ws: WebSocket, msg: WSOutgoingMessage): void;
18
+ /**
19
+ * Handle get_agents message
20
+ */
21
+ export declare function handleGetAgents(ws: WebSocket, deps: MessageHandlerDeps): Promise<void>;
22
+ /**
23
+ * Handle select_agent message
24
+ */
25
+ export declare function handleSelectAgent(ws: WebSocket, state: ConnectionState, msg: WSIncomingMessage, deps: MessageHandlerDeps): Promise<void>;
26
+ /**
27
+ * Handle batch message
28
+ */
29
+ export declare function handleBatch(ws: WebSocket, state: ConnectionState, msg: WSIncomingMessage, deps: MessageHandlerDeps): Promise<void>;
30
+ /**
31
+ * Handle message (follow-up message to agent)
32
+ */
33
+ export declare function handleMessage(ws: WebSocket, state: ConnectionState, msg: WSIncomingMessage): Promise<void>;
34
+ /**
35
+ * Handle reset message
36
+ */
37
+ export declare function handleReset(ws: WebSocket, state: ConnectionState): Promise<void>;
38
+ /**
39
+ * Handle stop message
40
+ */
41
+ export declare function handleStop(ws: WebSocket, state: ConnectionState): Promise<void>;
42
+ /**
43
+ * Handle get_history message
44
+ */
45
+ export declare function handleGetHistory(ws: WebSocket, state: ConnectionState): Promise<void>;
46
+ /**
47
+ * Handle undo message
48
+ */
49
+ export declare function handleUndo(ws: WebSocket, state: ConnectionState, GitManagerError: typeof GitManagerErrorType): Promise<void>;
50
+ /**
51
+ * Handle revert_to message
52
+ */
53
+ export declare function handleRevertTo(ws: WebSocket, state: ConnectionState, msg: WSIncomingMessage, GitManagerError: typeof GitManagerErrorType): Promise<void>;
54
+ /**
55
+ * Handle clear_history message
56
+ */
57
+ export declare function handleClearHistory(ws: WebSocket, state: ConnectionState): Promise<void>;
@@ -0,0 +1,329 @@
1
+ // server/src/handlers/messageHandlers.ts
2
+ import { validateBatchData } from '../validation/payload.js';
3
+ // Re-export GitManagerError for instanceof checks
4
+ export { GitManagerError } from '../services/git-manager.js';
5
+ // Helper to send messages
6
+ export function sendMessage(ws, msg) {
7
+ if (ws.readyState === ws.OPEN) {
8
+ ws.send(JSON.stringify(msg));
9
+ }
10
+ }
11
+ /**
12
+ * Handle get_agents message
13
+ */
14
+ export async function handleGetAgents(ws, deps) {
15
+ const agents = await deps.detectAgents();
16
+ sendMessage(ws, { type: 'agents', agents });
17
+ }
18
+ /**
19
+ * Handle select_agent message
20
+ */
21
+ export async function handleSelectAgent(ws, state, msg, deps) {
22
+ if (!msg.agent) {
23
+ sendMessage(ws, { type: 'agent_error', message: 'No agent specified' });
24
+ return;
25
+ }
26
+ // Check if agent is available
27
+ const agentInfo = (await deps.detectAgents()).find(a => a.name === msg.agent);
28
+ if (!agentInfo) {
29
+ sendMessage(ws, { type: 'agent_error', message: `Unknown agent: ${msg.agent}` });
30
+ return;
31
+ }
32
+ if (!agentInfo.available) {
33
+ sendMessage(ws, {
34
+ type: 'agent_error',
35
+ message: agentInfo.reason || `Agent ${msg.agent} is not available`,
36
+ agent: msg.agent
37
+ });
38
+ return;
39
+ }
40
+ // Destroy existing session if switching agents
41
+ if (state.session && state.agentName !== msg.agent) {
42
+ try {
43
+ await state.session.destroy();
44
+ }
45
+ catch (err) {
46
+ console.error('Error destroying session during agent switch:', err.message);
47
+ }
48
+ finally {
49
+ state.session = null;
50
+ }
51
+ }
52
+ // Initialize the agent
53
+ try {
54
+ state.agent = await deps.getAgent(msg.agent);
55
+ state.agentName = msg.agent;
56
+ sendMessage(ws, {
57
+ type: 'agent_selected',
58
+ agent: msg.agent,
59
+ model: state.agent.model,
60
+ projectDir: deps.projectDir
61
+ });
62
+ }
63
+ catch (err) {
64
+ sendMessage(ws, {
65
+ type: 'agent_error',
66
+ message: `Failed to initialize ${msg.agent}: ${err.message}`,
67
+ agent: msg.agent
68
+ });
69
+ }
70
+ }
71
+ /**
72
+ * Handle batch message
73
+ */
74
+ export async function handleBatch(ws, state, msg, deps) {
75
+ if (!msg.data)
76
+ return;
77
+ // Validate batch data
78
+ const validation = validateBatchData(msg.data);
79
+ if (!validation.valid) {
80
+ sendMessage(ws, { type: 'error', message: validation.error || 'Invalid batch data' });
81
+ return;
82
+ }
83
+ // Use sanitized data
84
+ const batchData = validation.sanitizedData;
85
+ // Check if agent is selected
86
+ if (!state.agentName || !state.agent) {
87
+ // If agent specified in batch message, try to select it
88
+ if (msg.agent) {
89
+ const agentInfo = (await deps.detectAgents()).find(a => a.name === msg.agent);
90
+ if (!agentInfo?.available) {
91
+ sendMessage(ws, {
92
+ type: 'agent_error',
93
+ message: `Agent ${msg.agent} is not available. Please select a different agent.`
94
+ });
95
+ return;
96
+ }
97
+ try {
98
+ state.agent = await deps.getAgent(msg.agent);
99
+ state.agentName = msg.agent;
100
+ }
101
+ catch (err) {
102
+ sendMessage(ws, { type: 'error', message: err.message });
103
+ return;
104
+ }
105
+ }
106
+ else {
107
+ sendMessage(ws, { type: 'error', message: 'No agent selected. Please select an agent first.' });
108
+ return;
109
+ }
110
+ }
111
+ // Use client-specified projectDir, or fall back to server default
112
+ const projectDir = batchData.projectDir || deps.projectDir;
113
+ // Create a checkpoint before AI modifications (if git manager available)
114
+ if (state.gitManager) {
115
+ try {
116
+ const checkpoint = await state.gitManager.createCheckpoint({
117
+ annotations: batchData.annotations,
118
+ pageUrl: batchData.pageUrl,
119
+ pageTitle: batchData.pageTitle,
120
+ agentName: state.agentName,
121
+ });
122
+ state.currentCheckpointId = checkpoint.id;
123
+ sendMessage(ws, {
124
+ type: 'checkpoint_created',
125
+ checkpoint: {
126
+ id: checkpoint.id,
127
+ timestamp: checkpoint.timestamp,
128
+ annotations: checkpoint.annotations,
129
+ filesModified: 0,
130
+ linesChanged: 0,
131
+ agentName: checkpoint.agentName,
132
+ pageUrl: checkpoint.pageUrl,
133
+ status: 'pending',
134
+ canUndo: false,
135
+ },
136
+ });
137
+ }
138
+ catch (err) {
139
+ // Log but don't block - checkpoint is optional
140
+ console.warn('Failed to create checkpoint:', err.message);
141
+ }
142
+ }
143
+ if (!state.session) {
144
+ state.session = await state.agent.createSession(ws, projectDir);
145
+ }
146
+ const prompt = state.agent.formatPrompt(batchData, projectDir);
147
+ const images = state.agent.extractImages(batchData);
148
+ sendMessage(ws, { type: 'processing' });
149
+ await state.session.send({ prompt, images: images.length > 0 ? images : undefined });
150
+ // Finalize checkpoint after processing
151
+ if (state.gitManager && state.currentCheckpointId) {
152
+ try {
153
+ await state.gitManager.finalizeCheckpoint(state.currentCheckpointId);
154
+ // Send updated checkpoint info
155
+ const checkpoints = await state.gitManager.getHistory();
156
+ const updatedCheckpoint = checkpoints.find((c) => c.id === state.currentCheckpointId);
157
+ if (updatedCheckpoint) {
158
+ sendMessage(ws, {
159
+ type: 'checkpoint_created',
160
+ checkpoint: updatedCheckpoint,
161
+ });
162
+ }
163
+ }
164
+ catch (err) {
165
+ console.warn('Failed to finalize checkpoint:', err.message);
166
+ }
167
+ state.currentCheckpointId = null;
168
+ }
169
+ }
170
+ /**
171
+ * Handle message (follow-up message to agent)
172
+ */
173
+ export async function handleMessage(ws, state, msg) {
174
+ if (state.session && msg.content) {
175
+ try {
176
+ console.log(`[ZingIt] Sending follow-up message: "${msg.content.substring(0, 50)}..."`);
177
+ sendMessage(ws, { type: 'processing' });
178
+ // Add timeout to detect if SDK hangs
179
+ const timeoutMs = 120000; // 2 minutes
180
+ const sendPromise = state.session.send({ prompt: msg.content });
181
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Agent response timeout')), timeoutMs));
182
+ await Promise.race([sendPromise, timeoutPromise]);
183
+ console.log('[ZingIt] Follow-up message sent to agent');
184
+ }
185
+ catch (err) {
186
+ console.error('[ZingIt] Error sending follow-up message:', err.message);
187
+ sendMessage(ws, { type: 'error', message: `Failed to send message: ${err.message}` });
188
+ }
189
+ }
190
+ else if (!state.session) {
191
+ console.warn('[ZingIt] No active session for follow-up message');
192
+ sendMessage(ws, { type: 'error', message: 'No active session. Please create annotations first.' });
193
+ }
194
+ }
195
+ /**
196
+ * Handle reset message
197
+ */
198
+ export async function handleReset(ws, state) {
199
+ if (state.session) {
200
+ try {
201
+ await state.session.destroy();
202
+ }
203
+ catch (err) {
204
+ console.error('Error destroying session during reset:', err.message);
205
+ }
206
+ finally {
207
+ state.session = null;
208
+ }
209
+ }
210
+ sendMessage(ws, { type: 'reset_complete' });
211
+ }
212
+ /**
213
+ * Handle stop message
214
+ */
215
+ export async function handleStop(ws, state) {
216
+ // Stop current agent execution
217
+ if (state.session) {
218
+ console.log('Stopping agent execution...');
219
+ try {
220
+ await state.session.destroy();
221
+ }
222
+ catch (err) {
223
+ console.error('Error destroying session during stop:', err.message);
224
+ }
225
+ finally {
226
+ state.session = null;
227
+ }
228
+ }
229
+ sendMessage(ws, { type: 'idle' });
230
+ }
231
+ /**
232
+ * Handle get_history message
233
+ */
234
+ export async function handleGetHistory(ws, state) {
235
+ if (!state.gitManager) {
236
+ sendMessage(ws, { type: 'error', message: 'Git manager not initialized' });
237
+ return;
238
+ }
239
+ try {
240
+ const checkpoints = await state.gitManager.getHistory();
241
+ sendMessage(ws, { type: 'history', checkpoints });
242
+ }
243
+ catch (err) {
244
+ sendMessage(ws, {
245
+ type: 'error',
246
+ message: `Failed to get history: ${err.message}`,
247
+ });
248
+ }
249
+ }
250
+ /**
251
+ * Handle undo message
252
+ */
253
+ export async function handleUndo(ws, state, GitManagerError) {
254
+ if (!state.gitManager) {
255
+ sendMessage(ws, { type: 'error', message: 'Git manager not initialized' });
256
+ return;
257
+ }
258
+ try {
259
+ const result = await state.gitManager.undoLastCheckpoint();
260
+ state.currentCheckpointId = null;
261
+ sendMessage(ws, {
262
+ type: 'undo_complete',
263
+ checkpointId: result.checkpointId,
264
+ filesReverted: result.filesReverted,
265
+ });
266
+ }
267
+ catch (err) {
268
+ if (err instanceof GitManagerError) {
269
+ sendMessage(ws, { type: 'error', message: err.message });
270
+ }
271
+ else {
272
+ sendMessage(ws, {
273
+ type: 'error',
274
+ message: `Undo failed: ${err.message}`,
275
+ });
276
+ }
277
+ }
278
+ }
279
+ /**
280
+ * Handle revert_to message
281
+ */
282
+ export async function handleRevertTo(ws, state, msg, GitManagerError) {
283
+ if (!state.gitManager) {
284
+ sendMessage(ws, { type: 'error', message: 'Git manager not initialized' });
285
+ return;
286
+ }
287
+ if (!msg.checkpointId) {
288
+ sendMessage(ws, { type: 'error', message: 'No checkpoint ID specified' });
289
+ return;
290
+ }
291
+ try {
292
+ const result = await state.gitManager.revertToCheckpoint(msg.checkpointId);
293
+ sendMessage(ws, {
294
+ type: 'revert_complete',
295
+ checkpointId: msg.checkpointId,
296
+ filesReverted: result.filesReverted,
297
+ });
298
+ }
299
+ catch (err) {
300
+ if (err instanceof GitManagerError) {
301
+ sendMessage(ws, { type: 'error', message: err.message });
302
+ }
303
+ else {
304
+ sendMessage(ws, {
305
+ type: 'error',
306
+ message: `Revert failed: ${err.message}`,
307
+ });
308
+ }
309
+ }
310
+ }
311
+ /**
312
+ * Handle clear_history message
313
+ */
314
+ export async function handleClearHistory(ws, state) {
315
+ if (!state.gitManager) {
316
+ sendMessage(ws, { type: 'error', message: 'Git manager not initialized' });
317
+ return;
318
+ }
319
+ try {
320
+ await state.gitManager.clearHistory();
321
+ sendMessage(ws, { type: 'history_cleared' });
322
+ }
323
+ catch (err) {
324
+ sendMessage(ws, {
325
+ type: 'error',
326
+ message: `Failed to clear history: ${err.message}`,
327
+ });
328
+ }
329
+ }
@@ -0,0 +1 @@
1
+ export {};