@eyeglass/bridge 0.1.0
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/http.d.ts +1 -0
- package/dist/http.js +98 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +338 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.js +391 -0
- package/dist/store.test.d.ts +1 -0
- package/dist/store.test.js +268 -0
- package/package.json +46 -0
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startHttpServer(): void;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import { store } from './store.js';
|
|
4
|
+
const PORT = 3300;
|
|
5
|
+
const KEEPALIVE_INTERVAL = 30000;
|
|
6
|
+
export function startHttpServer() {
|
|
7
|
+
const app = express();
|
|
8
|
+
app.use(cors());
|
|
9
|
+
app.use(express.json());
|
|
10
|
+
const sseClients = new Set();
|
|
11
|
+
// Health check
|
|
12
|
+
app.get('/health', (_req, res) => {
|
|
13
|
+
res.json({ status: 'ok', active: store.getActive() !== null });
|
|
14
|
+
});
|
|
15
|
+
// Browser posts focus payload here
|
|
16
|
+
app.post('/focus', (req, res) => {
|
|
17
|
+
const payload = req.body;
|
|
18
|
+
// Validate: must have interactionId, userNote, and either snapshot or snapshots
|
|
19
|
+
// Type validation to prevent crashes from malformed payloads
|
|
20
|
+
if (typeof payload.interactionId !== 'string' || typeof payload.userNote !== 'string') {
|
|
21
|
+
res.status(400).json({ error: 'Invalid payload: interactionId and userNote must be strings' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const hasSnapshot = payload.snapshot || (Array.isArray(payload.snapshots) && payload.snapshots.length > 0);
|
|
25
|
+
if (!payload.interactionId || !hasSnapshot || !payload.userNote) {
|
|
26
|
+
res.status(400).json({ error: 'Invalid payload' });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
store.setFocus(payload);
|
|
30
|
+
res.json({ success: true, interactionId: payload.interactionId });
|
|
31
|
+
});
|
|
32
|
+
// Browser posts answer to a question
|
|
33
|
+
app.post('/answer', (req, res) => {
|
|
34
|
+
const answer = req.body;
|
|
35
|
+
// Type validation
|
|
36
|
+
if (typeof answer.interactionId !== 'string' ||
|
|
37
|
+
typeof answer.questionId !== 'string' ||
|
|
38
|
+
typeof answer.answerId !== 'string') {
|
|
39
|
+
res.status(400).json({ error: 'Invalid answer payload: all fields must be strings' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!answer.interactionId || !answer.questionId || !answer.answerId) {
|
|
43
|
+
res.status(400).json({ error: 'Invalid answer payload' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const success = store.receiveAnswer(answer);
|
|
47
|
+
if (!success) {
|
|
48
|
+
res.status(404).json({ error: 'No pending question with that ID' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
res.json({ success: true });
|
|
52
|
+
});
|
|
53
|
+
// Undo changes for a specific interaction
|
|
54
|
+
app.post('/undo', async (req, res) => {
|
|
55
|
+
const { interactionId } = req.body;
|
|
56
|
+
// Type validation
|
|
57
|
+
if (typeof interactionId !== 'string' || !interactionId) {
|
|
58
|
+
res.status(400).json({ error: 'Missing or invalid interactionId' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const result = await store.undoInteraction(interactionId);
|
|
62
|
+
if (!result.success) {
|
|
63
|
+
res.status(400).json({ error: result.message });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.json({ success: true, message: result.message });
|
|
67
|
+
});
|
|
68
|
+
// SSE endpoint for real-time activity updates
|
|
69
|
+
app.get('/events', (req, res) => {
|
|
70
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
71
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
72
|
+
res.setHeader('Connection', 'keep-alive');
|
|
73
|
+
res.flushHeaders();
|
|
74
|
+
sseClients.add(res);
|
|
75
|
+
// Send current state if there's an active focus
|
|
76
|
+
const active = store.getActive();
|
|
77
|
+
if (active) {
|
|
78
|
+
res.write(`data: ${JSON.stringify({ type: 'focus', payload: active })}\n\n`);
|
|
79
|
+
}
|
|
80
|
+
const keepAlive = setInterval(() => {
|
|
81
|
+
res.write(': keepalive\n\n');
|
|
82
|
+
}, KEEPALIVE_INTERVAL);
|
|
83
|
+
req.on('close', () => {
|
|
84
|
+
clearInterval(keepAlive);
|
|
85
|
+
sseClients.delete(res);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
// Broadcast activity events to all SSE clients
|
|
89
|
+
store.on('activity', (event) => {
|
|
90
|
+
const message = `data: ${JSON.stringify({ type: 'activity', payload: event })}\n\n`;
|
|
91
|
+
for (const client of sseClients) {
|
|
92
|
+
client.write(message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
app.listen(PORT, () => {
|
|
96
|
+
// Server started silently
|
|
97
|
+
});
|
|
98
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startHttpServer } from './http.js';
|
|
3
|
+
import { startMcpServer } from './mcp.js';
|
|
4
|
+
async function main() {
|
|
5
|
+
// Start MCP server first (stdio) so health checks succeed immediately
|
|
6
|
+
await startMcpServer();
|
|
7
|
+
// Then start HTTP server for browser communication
|
|
8
|
+
startHttpServer();
|
|
9
|
+
}
|
|
10
|
+
main().catch((err) => {
|
|
11
|
+
console.error('[eyeglass] Fatal error:', err);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startMcpServer(): Promise<void>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { store } from './store.js';
|
|
5
|
+
export async function startMcpServer() {
|
|
6
|
+
const server = new Server({
|
|
7
|
+
name: 'eyeglass-bridge',
|
|
8
|
+
version: '0.1.0',
|
|
9
|
+
}, {
|
|
10
|
+
capabilities: {
|
|
11
|
+
tools: {},
|
|
12
|
+
resources: {},
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
// Track if client supports sampling
|
|
16
|
+
let clientSupportsSampling = false;
|
|
17
|
+
// Check client capabilities after connection
|
|
18
|
+
server.oninitialized = () => {
|
|
19
|
+
const clientCaps = server.getClientCapabilities();
|
|
20
|
+
clientSupportsSampling = !!clientCaps?.sampling;
|
|
21
|
+
// Debug: log client capabilities to stderr
|
|
22
|
+
console.error('[eyeglass] Client capabilities:', JSON.stringify(clientCaps, null, 2));
|
|
23
|
+
console.error('[eyeglass] Sampling supported:', clientSupportsSampling);
|
|
24
|
+
};
|
|
25
|
+
// When a new focus comes in, trigger sampling if supported
|
|
26
|
+
store.on('activity', async (event) => {
|
|
27
|
+
if (event.type === 'status' && event.status === 'pending' && clientSupportsSampling) {
|
|
28
|
+
try {
|
|
29
|
+
// Request the client to handle the new focus
|
|
30
|
+
await server.request({
|
|
31
|
+
method: 'sampling/createMessage',
|
|
32
|
+
params: {
|
|
33
|
+
messages: [
|
|
34
|
+
{
|
|
35
|
+
role: 'user',
|
|
36
|
+
content: {
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: `🔔 New Eyeglass request received!
|
|
39
|
+
|
|
40
|
+
A user has selected a UI element and needs your help. Please:
|
|
41
|
+
1. Call get_focused_element() to see what they selected
|
|
42
|
+
2. Use report_action() to show your progress
|
|
43
|
+
3. Use send_thought() to share your reasoning
|
|
44
|
+
4. Make the requested changes
|
|
45
|
+
5. Call update_status("success", "message") when done
|
|
46
|
+
|
|
47
|
+
Handle this request now.`,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
maxTokens: 4096,
|
|
52
|
+
systemPrompt: 'You are an AI assistant helping with UI development. The user has selected an element in their browser using Eyeglass and wants you to make changes. Use the available MCP tools to see the request and fulfill it.',
|
|
53
|
+
modelPreferences: {
|
|
54
|
+
intelligencePriority: 0.8,
|
|
55
|
+
speedPriority: 0.6,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}, { method: 'sampling/createMessage' });
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Sampling failed or was rejected - that's okay
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// List available resources
|
|
66
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
67
|
+
return {
|
|
68
|
+
resources: [
|
|
69
|
+
{
|
|
70
|
+
uri: 'eyeglass://focus',
|
|
71
|
+
name: 'Current Focus',
|
|
72
|
+
description: 'The currently focused UI element and user request.',
|
|
73
|
+
mimeType: 'text/markdown',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
// Read resource content
|
|
79
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
80
|
+
const { uri } = request.params;
|
|
81
|
+
if (uri === 'eyeglass://focus') {
|
|
82
|
+
const content = store.formatAsMarkdown();
|
|
83
|
+
return {
|
|
84
|
+
contents: [
|
|
85
|
+
{
|
|
86
|
+
uri,
|
|
87
|
+
mimeType: 'text/markdown',
|
|
88
|
+
text: content,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
94
|
+
});
|
|
95
|
+
// List tools
|
|
96
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
97
|
+
return {
|
|
98
|
+
tools: [
|
|
99
|
+
{
|
|
100
|
+
name: 'get_focused_element',
|
|
101
|
+
description: "Get the currently focused UI element with its semantic snapshot, accessibility tree, computed styles, and the user's change request.",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {},
|
|
105
|
+
required: [],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'update_status',
|
|
110
|
+
description: 'Update the status shown to the user in the browser overlay. Use this to communicate progress.',
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
status: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
enum: ['idle', 'pending', 'fixing', 'success', 'failed'],
|
|
117
|
+
description: 'The new status',
|
|
118
|
+
},
|
|
119
|
+
message: {
|
|
120
|
+
type: 'string',
|
|
121
|
+
description: 'Optional message to show the user',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ['status'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'send_thought',
|
|
129
|
+
description: 'Share your reasoning or decision-making with the user. Use this to explain what you are considering or why you made a choice.',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
content: {
|
|
134
|
+
type: 'string',
|
|
135
|
+
description: 'The thought or reasoning to share',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
required: ['content'],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'report_action',
|
|
143
|
+
description: 'Report an action you are taking (reading a file, writing code, searching). This shows progress to the user.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
action: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
enum: ['reading', 'writing', 'searching', 'thinking'],
|
|
150
|
+
description: 'The type of action',
|
|
151
|
+
},
|
|
152
|
+
target: {
|
|
153
|
+
type: 'string',
|
|
154
|
+
description: 'What you are acting on (e.g., file path, search query)',
|
|
155
|
+
},
|
|
156
|
+
complete: {
|
|
157
|
+
type: 'boolean',
|
|
158
|
+
description: 'Whether this action is complete (default: false)',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
required: ['action', 'target'],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'ask_question',
|
|
166
|
+
description: 'Ask the user a question and wait for their answer. Use this when you need clarification or want to offer choices. This tool BLOCKS until the user responds.',
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
question: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
description: 'The question to ask',
|
|
173
|
+
},
|
|
174
|
+
options: {
|
|
175
|
+
type: 'array',
|
|
176
|
+
items: { type: 'string' },
|
|
177
|
+
description: 'The options for the user to choose from (2-4 options)',
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
required: ['question', 'options'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'get_focus_history',
|
|
185
|
+
description: 'Get the history of previously focused elements (up to 5).',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {},
|
|
189
|
+
required: [],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'wait_for_request',
|
|
194
|
+
description: 'Blocks execution until the user selects a new element in the browser. Use this to enter a listening mode where you automatically react to user actions. Returns the focused element context when a request arrives.',
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
timeout_ms: {
|
|
199
|
+
type: 'number',
|
|
200
|
+
description: 'Optional timeout in milliseconds. If not provided, waits indefinitely.',
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
required: [],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
// Handle tool calls
|
|
210
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
211
|
+
const { name, arguments: args } = request.params;
|
|
212
|
+
const active = store.getActive();
|
|
213
|
+
switch (name) {
|
|
214
|
+
case 'get_focused_element': {
|
|
215
|
+
const markdown = store.formatAsMarkdown();
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: 'text', text: markdown }],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
case 'update_status': {
|
|
221
|
+
const { status, message } = args;
|
|
222
|
+
if (!active) {
|
|
223
|
+
return {
|
|
224
|
+
content: [{ type: 'text', text: 'No active focus to update.' }],
|
|
225
|
+
isError: true,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
store.updateStatus(active.interactionId, status, message);
|
|
229
|
+
return {
|
|
230
|
+
content: [
|
|
231
|
+
{ type: 'text', text: `Status updated to "${status}"${message ? `: ${message}` : ''}` },
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
case 'send_thought': {
|
|
236
|
+
const { content } = args;
|
|
237
|
+
if (!active) {
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: 'text', text: 'No active focus.' }],
|
|
240
|
+
isError: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
store.sendThought(active.interactionId, content);
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: 'text', text: 'Thought shared with user.' }],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
case 'report_action': {
|
|
249
|
+
const { action, target, complete } = args;
|
|
250
|
+
if (!active) {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: 'text', text: 'No active focus.' }],
|
|
253
|
+
isError: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
store.reportAction(active.interactionId, action, target, complete ?? false);
|
|
257
|
+
return {
|
|
258
|
+
content: [{ type: 'text', text: `Action reported: ${action} ${target}` }],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
case 'ask_question': {
|
|
262
|
+
const { question, options } = args;
|
|
263
|
+
if (!active) {
|
|
264
|
+
return {
|
|
265
|
+
content: [{ type: 'text', text: 'No active focus.' }],
|
|
266
|
+
isError: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (!options || options.length < 2 || options.length > 4) {
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: 'text', text: 'Please provide 2-4 options.' }],
|
|
272
|
+
isError: true,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const questionId = `q-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
276
|
+
const formattedOptions = options.map((label, i) => ({
|
|
277
|
+
id: `opt-${i}`,
|
|
278
|
+
label,
|
|
279
|
+
}));
|
|
280
|
+
const answer = await store.askQuestion(active.interactionId, questionId, question, formattedOptions);
|
|
281
|
+
return {
|
|
282
|
+
content: [
|
|
283
|
+
{
|
|
284
|
+
type: 'text',
|
|
285
|
+
text: `User selected: "${answer.answerLabel}"`,
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
case 'get_focus_history': {
|
|
291
|
+
const history = store.getHistory();
|
|
292
|
+
if (history.length === 0) {
|
|
293
|
+
return {
|
|
294
|
+
content: [{ type: 'text', text: 'No focus history available.' }],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const summary = history
|
|
298
|
+
.map((p, i) => {
|
|
299
|
+
const { snapshot, snapshots, userNote } = p;
|
|
300
|
+
// Handle both single and multi-select payloads
|
|
301
|
+
const firstSnapshot = snapshot || (snapshots && snapshots[0]);
|
|
302
|
+
if (!firstSnapshot)
|
|
303
|
+
return `${i + 1}. **unknown** - "${userNote}"`;
|
|
304
|
+
const elementCount = snapshots ? ` (${snapshots.length} elements)` : '';
|
|
305
|
+
return `${i + 1}. **${firstSnapshot.framework.componentName || firstSnapshot.tagName}**${elementCount} - "${userNote}"`;
|
|
306
|
+
})
|
|
307
|
+
.join('\n');
|
|
308
|
+
return {
|
|
309
|
+
content: [{ type: 'text', text: `## Focus History\n\n${summary}` }],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
case 'wait_for_request': {
|
|
313
|
+
const { timeout_ms } = args;
|
|
314
|
+
try {
|
|
315
|
+
await store.waitForFocus(timeout_ms);
|
|
316
|
+
// After waiting resolves, return the formatted markdown
|
|
317
|
+
const markdown = store.formatAsMarkdown();
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: 'text', text: markdown }],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: 'text', text: `Wait cancelled: ${err.message}` }],
|
|
325
|
+
isError: true,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
default:
|
|
330
|
+
return {
|
|
331
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
332
|
+
isError: true,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
const transport = new StdioServerTransport();
|
|
337
|
+
await server.connect(transport);
|
|
338
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { FocusPayload, InteractionStatus, AnswerPayload } from '@eyeglass/types';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
export declare class ContextStore extends EventEmitter {
|
|
4
|
+
private active;
|
|
5
|
+
private history;
|
|
6
|
+
private currentStatus;
|
|
7
|
+
private pendingQuestion;
|
|
8
|
+
private pendingWait;
|
|
9
|
+
private commitMap;
|
|
10
|
+
setFocus(payload: FocusPayload): void;
|
|
11
|
+
/**
|
|
12
|
+
* Wait for a new focus request from the browser.
|
|
13
|
+
* If there's already an active pending request, resolves immediately.
|
|
14
|
+
* @param timeoutMs - Optional timeout in milliseconds (default: no timeout)
|
|
15
|
+
*/
|
|
16
|
+
waitForFocus(timeoutMs?: number): Promise<FocusPayload>;
|
|
17
|
+
/**
|
|
18
|
+
* Check if an agent is currently waiting for a request
|
|
19
|
+
*/
|
|
20
|
+
isWaitingForFocus(): boolean;
|
|
21
|
+
getActive(): FocusPayload | null;
|
|
22
|
+
getHistory(): FocusPayload[];
|
|
23
|
+
updateStatus(interactionId: string, status: InteractionStatus, message?: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Commit all staged and unstaged changes with the interaction ID
|
|
26
|
+
*/
|
|
27
|
+
private commitChanges;
|
|
28
|
+
/**
|
|
29
|
+
* Undo changes for a specific interaction by reverting its commit
|
|
30
|
+
*/
|
|
31
|
+
undoInteraction(interactionId: string): Promise<{
|
|
32
|
+
success: boolean;
|
|
33
|
+
message: string;
|
|
34
|
+
}>;
|
|
35
|
+
private revertCommit;
|
|
36
|
+
sendThought(interactionId: string, content: string): void;
|
|
37
|
+
reportAction(interactionId: string, action: 'reading' | 'writing' | 'searching' | 'thinking', target: string, complete?: boolean): void;
|
|
38
|
+
askQuestion(interactionId: string, questionId: string, question: string, options: Array<{
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
}>): Promise<AnswerPayload>;
|
|
42
|
+
receiveAnswer(answer: AnswerPayload): boolean;
|
|
43
|
+
hasPendingQuestion(): boolean;
|
|
44
|
+
private emitActivity;
|
|
45
|
+
formatAsMarkdown(): string;
|
|
46
|
+
private formatSingleSnapshot;
|
|
47
|
+
private formatMultipleSnapshots;
|
|
48
|
+
private writeContextFile;
|
|
49
|
+
}
|
|
50
|
+
export declare const store: ContextStore;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
const MAX_HISTORY = 5;
|
|
6
|
+
const CONTEXT_FILE = '.eyeglass_context.md';
|
|
7
|
+
export class ContextStore extends EventEmitter {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(...arguments);
|
|
10
|
+
this.active = null;
|
|
11
|
+
this.history = [];
|
|
12
|
+
this.currentStatus = 'idle';
|
|
13
|
+
this.pendingQuestion = null;
|
|
14
|
+
this.pendingWait = null;
|
|
15
|
+
this.commitMap = new Map(); // interactionId -> commitHash
|
|
16
|
+
}
|
|
17
|
+
setFocus(payload) {
|
|
18
|
+
if (this.active) {
|
|
19
|
+
this.history.unshift(this.active);
|
|
20
|
+
if (this.history.length > MAX_HISTORY) {
|
|
21
|
+
this.history.pop();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
this.active = payload;
|
|
25
|
+
// If an agent is waiting for a request, resolve immediately and set status to "fixing"
|
|
26
|
+
if (this.pendingWait) {
|
|
27
|
+
const { resolve, timeoutId } = this.pendingWait;
|
|
28
|
+
if (timeoutId)
|
|
29
|
+
clearTimeout(timeoutId);
|
|
30
|
+
this.pendingWait = null;
|
|
31
|
+
this.currentStatus = 'fixing';
|
|
32
|
+
this.emitActivity({
|
|
33
|
+
type: 'status',
|
|
34
|
+
interactionId: payload.interactionId,
|
|
35
|
+
status: 'fixing',
|
|
36
|
+
message: 'Agent is working...',
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
});
|
|
39
|
+
this.writeContextFile();
|
|
40
|
+
resolve(payload);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Otherwise, set status to "pending" (waiting for agent to pick it up)
|
|
44
|
+
this.currentStatus = 'pending';
|
|
45
|
+
this.emitActivity({
|
|
46
|
+
type: 'status',
|
|
47
|
+
interactionId: payload.interactionId,
|
|
48
|
+
status: 'pending',
|
|
49
|
+
message: 'Waiting for agent...',
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
});
|
|
52
|
+
this.writeContextFile();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wait for a new focus request from the browser.
|
|
56
|
+
* If there's already an active pending request, resolves immediately.
|
|
57
|
+
* @param timeoutMs - Optional timeout in milliseconds (default: no timeout)
|
|
58
|
+
*/
|
|
59
|
+
waitForFocus(timeoutMs) {
|
|
60
|
+
// If there's already a pending request waiting for an agent, return it immediately
|
|
61
|
+
if (this.active && this.currentStatus === 'pending') {
|
|
62
|
+
// Update status to fixing since agent is now handling it
|
|
63
|
+
this.currentStatus = 'fixing';
|
|
64
|
+
this.emitActivity({
|
|
65
|
+
type: 'status',
|
|
66
|
+
interactionId: this.active.interactionId,
|
|
67
|
+
status: 'fixing',
|
|
68
|
+
message: 'Agent is working...',
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
return Promise.resolve(this.active);
|
|
72
|
+
}
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
let timeoutId;
|
|
75
|
+
if (timeoutMs) {
|
|
76
|
+
timeoutId = setTimeout(() => {
|
|
77
|
+
if (this.pendingWait) {
|
|
78
|
+
this.pendingWait = null;
|
|
79
|
+
reject(new Error('Timeout waiting for focus request'));
|
|
80
|
+
}
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
}
|
|
83
|
+
this.pendingWait = { resolve, reject, timeoutId };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Check if an agent is currently waiting for a request
|
|
88
|
+
*/
|
|
89
|
+
isWaitingForFocus() {
|
|
90
|
+
return this.pendingWait !== null;
|
|
91
|
+
}
|
|
92
|
+
getActive() {
|
|
93
|
+
return this.active;
|
|
94
|
+
}
|
|
95
|
+
getHistory() {
|
|
96
|
+
return this.history;
|
|
97
|
+
}
|
|
98
|
+
// Status update
|
|
99
|
+
updateStatus(interactionId, status, message) {
|
|
100
|
+
if (this.active?.interactionId !== interactionId)
|
|
101
|
+
return;
|
|
102
|
+
this.currentStatus = status;
|
|
103
|
+
// Auto-commit changes when marked as success
|
|
104
|
+
if (status === 'success') {
|
|
105
|
+
this.commitChanges(interactionId, message);
|
|
106
|
+
}
|
|
107
|
+
this.emitActivity({
|
|
108
|
+
type: 'status',
|
|
109
|
+
interactionId,
|
|
110
|
+
status,
|
|
111
|
+
message,
|
|
112
|
+
timestamp: Date.now(),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Commit all staged and unstaged changes with the interaction ID
|
|
117
|
+
*/
|
|
118
|
+
commitChanges(interactionId, message) {
|
|
119
|
+
try {
|
|
120
|
+
const cwd = process.cwd();
|
|
121
|
+
// Check if we're in a git repo
|
|
122
|
+
try {
|
|
123
|
+
execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Not a git repo, skip committing
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Check if there are any changes to commit
|
|
130
|
+
const status = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8' });
|
|
131
|
+
if (!status.trim()) {
|
|
132
|
+
// No changes to commit
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Stage all changes
|
|
136
|
+
execFileSync('git', ['add', '-A'], { cwd, stdio: 'pipe' });
|
|
137
|
+
// Commit with eyeglass marker
|
|
138
|
+
const commitMessage = `[eyeglass:${interactionId}] ${message || 'Eyeglass change'}`;
|
|
139
|
+
execFileSync('git', ['commit', '-m', commitMessage], { cwd, stdio: 'pipe' });
|
|
140
|
+
// Get the commit hash
|
|
141
|
+
const commitHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' }).trim();
|
|
142
|
+
this.commitMap.set(interactionId, commitHash);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
// Silently fail git operations
|
|
146
|
+
console.error('[eyeglass] Failed to commit changes:', err);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Undo changes for a specific interaction by reverting its commit
|
|
151
|
+
*/
|
|
152
|
+
async undoInteraction(interactionId) {
|
|
153
|
+
const commitHash = this.commitMap.get(interactionId);
|
|
154
|
+
if (!commitHash) {
|
|
155
|
+
// Try to find the commit by searching git log
|
|
156
|
+
try {
|
|
157
|
+
const cwd = process.cwd();
|
|
158
|
+
const log = execFileSync('git', ['log', '--oneline', `--grep=[eyeglass:${interactionId}]`, '-n', '1'], { cwd, encoding: 'utf-8' }).trim();
|
|
159
|
+
if (!log) {
|
|
160
|
+
return { success: false, message: 'No commit found for this interaction' };
|
|
161
|
+
}
|
|
162
|
+
const foundHash = log.split(' ')[0];
|
|
163
|
+
if (foundHash) {
|
|
164
|
+
this.commitMap.set(interactionId, foundHash);
|
|
165
|
+
return this.revertCommit(interactionId, foundHash);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return { success: false, message: 'Could not find commit for this interaction' };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return this.revertCommit(interactionId, commitHash);
|
|
173
|
+
}
|
|
174
|
+
revertCommit(interactionId, commitHash) {
|
|
175
|
+
try {
|
|
176
|
+
const cwd = process.cwd();
|
|
177
|
+
// Revert the commit (creates a new commit that undoes the changes)
|
|
178
|
+
execFileSync('git', ['revert', '--no-edit', commitHash], { cwd, stdio: 'pipe' });
|
|
179
|
+
// Remove from commit map
|
|
180
|
+
this.commitMap.delete(interactionId);
|
|
181
|
+
// Emit status update
|
|
182
|
+
this.emitActivity({
|
|
183
|
+
type: 'status',
|
|
184
|
+
interactionId,
|
|
185
|
+
status: 'idle',
|
|
186
|
+
message: 'Changes reverted',
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
});
|
|
189
|
+
return { success: true, message: 'Changes reverted successfully' };
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
193
|
+
return { success: false, message: `Failed to revert: ${errorMessage}` };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Send a thought/reasoning to the user
|
|
197
|
+
sendThought(interactionId, content) {
|
|
198
|
+
if (this.active?.interactionId !== interactionId)
|
|
199
|
+
return;
|
|
200
|
+
this.emitActivity({
|
|
201
|
+
type: 'thought',
|
|
202
|
+
interactionId,
|
|
203
|
+
content,
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// Report an action being taken
|
|
208
|
+
reportAction(interactionId, action, target, complete = false) {
|
|
209
|
+
if (this.active?.interactionId !== interactionId)
|
|
210
|
+
return;
|
|
211
|
+
this.emitActivity({
|
|
212
|
+
type: 'action',
|
|
213
|
+
interactionId,
|
|
214
|
+
action,
|
|
215
|
+
target,
|
|
216
|
+
complete,
|
|
217
|
+
timestamp: Date.now(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// Ask a question and wait for answer
|
|
221
|
+
async askQuestion(interactionId, questionId, question, options) {
|
|
222
|
+
if (this.active?.interactionId !== interactionId) {
|
|
223
|
+
throw new Error('No active interaction');
|
|
224
|
+
}
|
|
225
|
+
// Emit the question event
|
|
226
|
+
this.emitActivity({
|
|
227
|
+
type: 'question',
|
|
228
|
+
interactionId,
|
|
229
|
+
questionId,
|
|
230
|
+
question,
|
|
231
|
+
options,
|
|
232
|
+
timestamp: Date.now(),
|
|
233
|
+
});
|
|
234
|
+
// Wait for the answer
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
this.pendingQuestion = { questionId, resolve };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// Receive answer from the browser
|
|
240
|
+
receiveAnswer(answer) {
|
|
241
|
+
if (!this.pendingQuestion || this.pendingQuestion.questionId !== answer.questionId) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
this.pendingQuestion.resolve(answer);
|
|
245
|
+
this.pendingQuestion = null;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
hasPendingQuestion() {
|
|
249
|
+
return this.pendingQuestion !== null;
|
|
250
|
+
}
|
|
251
|
+
emitActivity(event) {
|
|
252
|
+
this.emit('activity', event);
|
|
253
|
+
}
|
|
254
|
+
formatAsMarkdown() {
|
|
255
|
+
if (!this.active) {
|
|
256
|
+
return '# No Active Focus\n\nNo element is currently focused.';
|
|
257
|
+
}
|
|
258
|
+
const { snapshot, snapshots, userNote, interactionId } = this.active;
|
|
259
|
+
// Handle both single and multi-select payloads
|
|
260
|
+
const allSnapshots = snapshots || (snapshot ? [snapshot] : []);
|
|
261
|
+
if (allSnapshots.length === 0) {
|
|
262
|
+
return '# No Active Focus\n\nNo element is currently focused.';
|
|
263
|
+
}
|
|
264
|
+
// Single element - use original format
|
|
265
|
+
if (allSnapshots.length === 1) {
|
|
266
|
+
return this.formatSingleSnapshot(allSnapshots[0], userNote, interactionId);
|
|
267
|
+
}
|
|
268
|
+
// Multiple elements - new format
|
|
269
|
+
return this.formatMultipleSnapshots(allSnapshots, userNote, interactionId);
|
|
270
|
+
}
|
|
271
|
+
formatSingleSnapshot(snapshot, userNote, interactionId) {
|
|
272
|
+
const { framework, a11y, geometry, styles } = snapshot;
|
|
273
|
+
const componentInfo = framework.componentName
|
|
274
|
+
? `\`<${framework.componentName} />\` (${framework.filePath || 'unknown file'}${framework.lineNumber ? `:${framework.lineNumber}` : ''})`
|
|
275
|
+
: `\`<${snapshot.tagName}>\` (vanilla element)`;
|
|
276
|
+
return `## User Focus Request
|
|
277
|
+
**Interaction ID:** ${interactionId}
|
|
278
|
+
**User Note:** "${userNote}"
|
|
279
|
+
**Component:** ${componentInfo}
|
|
280
|
+
|
|
281
|
+
### Element Info
|
|
282
|
+
- Tag: \`<${snapshot.tagName}>\`
|
|
283
|
+
- Role: ${snapshot.role}
|
|
284
|
+
- Name: "${snapshot.name}"
|
|
285
|
+
${snapshot.id ? `- ID: \`#${snapshot.id}\`` : ''}
|
|
286
|
+
${snapshot.className ? `- Classes: \`${snapshot.className}\`` : ''}
|
|
287
|
+
${snapshot.dataAttributes ? `- Data attrs: ${Object.entries(snapshot.dataAttributes).map(([k, v]) => `\`${k}="${v}"\``).join(', ')}` : ''}
|
|
288
|
+
|
|
289
|
+
### Accessibility Tree
|
|
290
|
+
- Label: ${a11y.label ?? 'none'}
|
|
291
|
+
- Description: ${a11y.description ?? 'none'}
|
|
292
|
+
- Disabled: ${a11y.disabled}
|
|
293
|
+
- Hidden: ${a11y.hidden}
|
|
294
|
+
${a11y.expanded !== undefined ? `- Expanded: ${a11y.expanded}` : ''}
|
|
295
|
+
${a11y.checked !== undefined ? `- Checked: ${a11y.checked}` : ''}
|
|
296
|
+
|
|
297
|
+
### Geometry
|
|
298
|
+
- Box: ${geometry.width}x${geometry.height} at (${geometry.x}, ${geometry.y})
|
|
299
|
+
- Visible: ${geometry.visible}
|
|
300
|
+
|
|
301
|
+
### Computed Styles
|
|
302
|
+
- Display: ${styles.display}
|
|
303
|
+
- Position: ${styles.position}
|
|
304
|
+
${styles.flexDirection ? `- Flex Direction: ${styles.flexDirection}` : ''}
|
|
305
|
+
${styles.gridTemplate ? `- Grid Template: ${styles.gridTemplate}` : ''}
|
|
306
|
+
- Padding: ${styles.padding}
|
|
307
|
+
- Margin: ${styles.margin}
|
|
308
|
+
- Color: ${styles.color}
|
|
309
|
+
- Background: ${styles.backgroundColor}
|
|
310
|
+
- Font: ${styles.fontFamily}
|
|
311
|
+
- Z-Index: ${styles.zIndex}
|
|
312
|
+
|
|
313
|
+
### Framework
|
|
314
|
+
- Detected: ${framework.name}
|
|
315
|
+
${framework.ancestry ? `- Component Tree: ${framework.ancestry.join(' > ')}` : ''}
|
|
316
|
+
${framework.props ? `- Props: ${JSON.stringify(framework.props, null, 2)}` : ''}
|
|
317
|
+
|
|
318
|
+
### Page Context
|
|
319
|
+
- URL: ${snapshot.url}
|
|
320
|
+
- Timestamp: ${new Date(snapshot.timestamp).toISOString()}
|
|
321
|
+
`;
|
|
322
|
+
}
|
|
323
|
+
formatMultipleSnapshots(snapshots, userNote, interactionId) {
|
|
324
|
+
const elementSections = snapshots.map((snapshot, index) => {
|
|
325
|
+
const { framework, a11y, geometry, styles } = snapshot;
|
|
326
|
+
const componentInfo = framework.componentName
|
|
327
|
+
? `\`<${framework.componentName} />\` (${framework.filePath || 'unknown file'}${framework.lineNumber ? `:${framework.lineNumber}` : ''})`
|
|
328
|
+
: `\`<${snapshot.tagName}>\` (vanilla element)`;
|
|
329
|
+
return `## Element ${index + 1}: ${componentInfo}
|
|
330
|
+
|
|
331
|
+
### Element Info
|
|
332
|
+
- Tag: \`<${snapshot.tagName}>\`
|
|
333
|
+
- Role: ${snapshot.role}
|
|
334
|
+
- Name: "${snapshot.name}"
|
|
335
|
+
${snapshot.id ? `- ID: \`#${snapshot.id}\`` : ''}
|
|
336
|
+
${snapshot.className ? `- Classes: \`${snapshot.className}\`` : ''}
|
|
337
|
+
${snapshot.dataAttributes ? `- Data attrs: ${Object.entries(snapshot.dataAttributes).map(([k, v]) => `\`${k}="${v}"\``).join(', ')}` : ''}
|
|
338
|
+
|
|
339
|
+
### Accessibility Tree
|
|
340
|
+
- Label: ${a11y.label ?? 'none'}
|
|
341
|
+
- Description: ${a11y.description ?? 'none'}
|
|
342
|
+
- Disabled: ${a11y.disabled}
|
|
343
|
+
- Hidden: ${a11y.hidden}
|
|
344
|
+
${a11y.expanded !== undefined ? `- Expanded: ${a11y.expanded}` : ''}
|
|
345
|
+
${a11y.checked !== undefined ? `- Checked: ${a11y.checked}` : ''}
|
|
346
|
+
|
|
347
|
+
### Geometry
|
|
348
|
+
- Box: ${geometry.width}x${geometry.height} at (${geometry.x}, ${geometry.y})
|
|
349
|
+
- Visible: ${geometry.visible}
|
|
350
|
+
|
|
351
|
+
### Computed Styles
|
|
352
|
+
- Display: ${styles.display}
|
|
353
|
+
- Position: ${styles.position}
|
|
354
|
+
${styles.flexDirection ? `- Flex Direction: ${styles.flexDirection}` : ''}
|
|
355
|
+
${styles.gridTemplate ? `- Grid Template: ${styles.gridTemplate}` : ''}
|
|
356
|
+
- Padding: ${styles.padding}
|
|
357
|
+
- Margin: ${styles.margin}
|
|
358
|
+
- Color: ${styles.color}
|
|
359
|
+
- Background: ${styles.backgroundColor}
|
|
360
|
+
- Font: ${styles.fontFamily}
|
|
361
|
+
- Z-Index: ${styles.zIndex}
|
|
362
|
+
|
|
363
|
+
### Framework
|
|
364
|
+
- Detected: ${framework.name}
|
|
365
|
+
${framework.ancestry ? `- Component Tree: ${framework.ancestry.join(' > ')}` : ''}
|
|
366
|
+
${framework.props ? `- Props: ${JSON.stringify(framework.props, null, 2)}` : ''}
|
|
367
|
+
`;
|
|
368
|
+
}).join('\n---\n\n');
|
|
369
|
+
return `# User Focus Request (${snapshots.length} Elements)
|
|
370
|
+
**Interaction ID:** ${interactionId}
|
|
371
|
+
**User Note:** "${userNote}"
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
${elementSections}
|
|
376
|
+
### Page Context
|
|
377
|
+
- URL: ${snapshots[0].url}
|
|
378
|
+
- Timestamp: ${new Date(snapshots[0].timestamp).toISOString()}
|
|
379
|
+
`;
|
|
380
|
+
}
|
|
381
|
+
writeContextFile() {
|
|
382
|
+
try {
|
|
383
|
+
const content = this.formatAsMarkdown();
|
|
384
|
+
fs.writeFileSync(path.resolve(process.cwd(), CONTEXT_FILE), content, 'utf-8');
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
// Silently fail file writes
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
export const store = new ContextStore();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { ContextStore } from './store.js';
|
|
3
|
+
// Mock child_process to avoid actual git operations
|
|
4
|
+
vi.mock('child_process', () => ({
|
|
5
|
+
execSync: vi.fn(() => ''),
|
|
6
|
+
}));
|
|
7
|
+
// Mock fs to avoid file writes
|
|
8
|
+
vi.mock('fs', () => ({
|
|
9
|
+
writeFileSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
function createMockSnapshot(overrides = {}) {
|
|
12
|
+
return {
|
|
13
|
+
role: 'button',
|
|
14
|
+
name: 'Test Button',
|
|
15
|
+
tagName: 'button',
|
|
16
|
+
framework: { name: 'react', componentName: 'TestButton' },
|
|
17
|
+
a11y: { label: null, description: null, disabled: false, hidden: false },
|
|
18
|
+
geometry: { x: 0, y: 0, width: 100, height: 40, visible: true },
|
|
19
|
+
styles: {
|
|
20
|
+
display: 'inline-flex',
|
|
21
|
+
position: 'relative',
|
|
22
|
+
padding: '8px 16px',
|
|
23
|
+
margin: '0px',
|
|
24
|
+
color: 'rgb(0, 0, 0)',
|
|
25
|
+
backgroundColor: 'rgb(255, 255, 255)',
|
|
26
|
+
fontFamily: 'sans-serif',
|
|
27
|
+
zIndex: 'auto',
|
|
28
|
+
},
|
|
29
|
+
timestamp: Date.now(),
|
|
30
|
+
url: 'http://localhost:3000/',
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function createMockPayload(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
interactionId: `test-${Date.now()}`,
|
|
37
|
+
snapshot: createMockSnapshot(),
|
|
38
|
+
userNote: 'Test note',
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
describe('ContextStore', () => {
|
|
43
|
+
let store;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
store = new ContextStore();
|
|
46
|
+
});
|
|
47
|
+
describe('setFocus', () => {
|
|
48
|
+
it('should set the active focus payload', () => {
|
|
49
|
+
const payload = createMockPayload();
|
|
50
|
+
store.setFocus(payload);
|
|
51
|
+
expect(store.getActive()).toEqual(payload);
|
|
52
|
+
});
|
|
53
|
+
it('should move previous active to history', () => {
|
|
54
|
+
const payload1 = createMockPayload({ interactionId: 'first' });
|
|
55
|
+
const payload2 = createMockPayload({ interactionId: 'second' });
|
|
56
|
+
store.setFocus(payload1);
|
|
57
|
+
store.setFocus(payload2);
|
|
58
|
+
expect(store.getActive()?.interactionId).toBe('second');
|
|
59
|
+
expect(store.getHistory()).toHaveLength(1);
|
|
60
|
+
expect(store.getHistory()[0].interactionId).toBe('first');
|
|
61
|
+
});
|
|
62
|
+
it('should limit history to MAX_HISTORY (5) items', () => {
|
|
63
|
+
for (let i = 0; i < 7; i++) {
|
|
64
|
+
store.setFocus(createMockPayload({ interactionId: `item-${i}` }));
|
|
65
|
+
}
|
|
66
|
+
expect(store.getHistory()).toHaveLength(5);
|
|
67
|
+
// History should have items 1-5 (item-0 was pushed out)
|
|
68
|
+
expect(store.getHistory()[0].interactionId).toBe('item-5');
|
|
69
|
+
expect(store.getHistory()[4].interactionId).toBe('item-1');
|
|
70
|
+
});
|
|
71
|
+
it('should emit status activity event', () => {
|
|
72
|
+
const events = [];
|
|
73
|
+
store.on('activity', (event) => events.push(event));
|
|
74
|
+
store.setFocus(createMockPayload());
|
|
75
|
+
expect(events).toHaveLength(1);
|
|
76
|
+
expect(events[0].type).toBe('status');
|
|
77
|
+
if (events[0].type === 'status') {
|
|
78
|
+
expect(events[0].status).toBe('pending');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('updateStatus', () => {
|
|
83
|
+
it('should emit status activity event with message', () => {
|
|
84
|
+
const payload = createMockPayload({ interactionId: 'test-123' });
|
|
85
|
+
store.setFocus(payload);
|
|
86
|
+
const events = [];
|
|
87
|
+
store.on('activity', (event) => events.push(event));
|
|
88
|
+
store.updateStatus('test-123', 'fixing', 'Working on it');
|
|
89
|
+
expect(events).toHaveLength(1);
|
|
90
|
+
expect(events[0].type).toBe('status');
|
|
91
|
+
if (events[0].type === 'status') {
|
|
92
|
+
expect(events[0].status).toBe('fixing');
|
|
93
|
+
expect(events[0].message).toBe('Working on it');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
it('should ignore updates for non-active interaction', () => {
|
|
97
|
+
const payload = createMockPayload({ interactionId: 'active-id' });
|
|
98
|
+
store.setFocus(payload);
|
|
99
|
+
const events = [];
|
|
100
|
+
store.on('activity', (event) => events.push(event));
|
|
101
|
+
store.updateStatus('wrong-id', 'fixing', 'This should be ignored');
|
|
102
|
+
expect(events).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('sendThought', () => {
|
|
106
|
+
it('should emit thought activity event', () => {
|
|
107
|
+
const payload = createMockPayload({ interactionId: 'test-123' });
|
|
108
|
+
store.setFocus(payload);
|
|
109
|
+
const events = [];
|
|
110
|
+
store.on('activity', (event) => events.push(event));
|
|
111
|
+
store.sendThought('test-123', 'I think we should change the color');
|
|
112
|
+
expect(events).toHaveLength(1);
|
|
113
|
+
expect(events[0].type).toBe('thought');
|
|
114
|
+
if (events[0].type === 'thought') {
|
|
115
|
+
expect(events[0].content).toBe('I think we should change the color');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('reportAction', () => {
|
|
120
|
+
it('should emit action activity event', () => {
|
|
121
|
+
const payload = createMockPayload({ interactionId: 'test-123' });
|
|
122
|
+
store.setFocus(payload);
|
|
123
|
+
const events = [];
|
|
124
|
+
store.on('activity', (event) => events.push(event));
|
|
125
|
+
store.reportAction('test-123', 'reading', 'src/Button.tsx');
|
|
126
|
+
expect(events).toHaveLength(1);
|
|
127
|
+
expect(events[0].type).toBe('action');
|
|
128
|
+
if (events[0].type === 'action') {
|
|
129
|
+
expect(events[0].action).toBe('reading');
|
|
130
|
+
expect(events[0].target).toBe('src/Button.tsx');
|
|
131
|
+
expect(events[0].complete).toBe(false);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
it('should support complete flag', () => {
|
|
135
|
+
const payload = createMockPayload({ interactionId: 'test-123' });
|
|
136
|
+
store.setFocus(payload);
|
|
137
|
+
const events = [];
|
|
138
|
+
store.on('activity', (event) => events.push(event));
|
|
139
|
+
store.reportAction('test-123', 'writing', 'src/Button.tsx', true);
|
|
140
|
+
if (events[0].type === 'action') {
|
|
141
|
+
expect(events[0].complete).toBe(true);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('askQuestion / receiveAnswer', () => {
|
|
146
|
+
it('should emit question event and resolve on answer', async () => {
|
|
147
|
+
const payload = createMockPayload({ interactionId: 'test-123' });
|
|
148
|
+
store.setFocus(payload);
|
|
149
|
+
const events = [];
|
|
150
|
+
store.on('activity', (event) => events.push(event));
|
|
151
|
+
const questionPromise = store.askQuestion('test-123', 'q-1', 'Which color?', [
|
|
152
|
+
{ id: 'blue', label: 'Blue' },
|
|
153
|
+
{ id: 'red', label: 'Red' },
|
|
154
|
+
]);
|
|
155
|
+
expect(events).toHaveLength(1);
|
|
156
|
+
expect(events[0].type).toBe('question');
|
|
157
|
+
expect(store.hasPendingQuestion()).toBe(true);
|
|
158
|
+
const answered = store.receiveAnswer({
|
|
159
|
+
interactionId: 'test-123',
|
|
160
|
+
questionId: 'q-1',
|
|
161
|
+
answerId: 'blue',
|
|
162
|
+
answerLabel: 'Blue',
|
|
163
|
+
});
|
|
164
|
+
expect(answered).toBe(true);
|
|
165
|
+
expect(store.hasPendingQuestion()).toBe(false);
|
|
166
|
+
const answer = await questionPromise;
|
|
167
|
+
expect(answer.answerId).toBe('blue');
|
|
168
|
+
});
|
|
169
|
+
it('should reject wrong question id', () => {
|
|
170
|
+
const payload = createMockPayload({ interactionId: 'test-123' });
|
|
171
|
+
store.setFocus(payload);
|
|
172
|
+
store.askQuestion('test-123', 'q-1', 'Which color?', []);
|
|
173
|
+
const answered = store.receiveAnswer({
|
|
174
|
+
interactionId: 'test-123',
|
|
175
|
+
questionId: 'wrong-id',
|
|
176
|
+
answerId: 'blue',
|
|
177
|
+
answerLabel: 'Blue',
|
|
178
|
+
});
|
|
179
|
+
expect(answered).toBe(false);
|
|
180
|
+
expect(store.hasPendingQuestion()).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('waitForFocus', () => {
|
|
184
|
+
it('should return immediately if pending request exists', async () => {
|
|
185
|
+
const payload = createMockPayload({ interactionId: 'test-123' });
|
|
186
|
+
store.setFocus(payload);
|
|
187
|
+
const result = await store.waitForFocus();
|
|
188
|
+
expect(result.interactionId).toBe('test-123');
|
|
189
|
+
});
|
|
190
|
+
it('should resolve when new focus arrives', async () => {
|
|
191
|
+
expect(store.isWaitingForFocus()).toBe(false);
|
|
192
|
+
const waitPromise = store.waitForFocus();
|
|
193
|
+
expect(store.isWaitingForFocus()).toBe(true);
|
|
194
|
+
const payload = createMockPayload({ interactionId: 'new-request' });
|
|
195
|
+
store.setFocus(payload);
|
|
196
|
+
const result = await waitPromise;
|
|
197
|
+
expect(result.interactionId).toBe('new-request');
|
|
198
|
+
expect(store.isWaitingForFocus()).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
it('should timeout if specified', async () => {
|
|
201
|
+
const waitPromise = store.waitForFocus(50);
|
|
202
|
+
await expect(waitPromise).rejects.toThrow('Timeout waiting for focus request');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
describe('formatAsMarkdown', () => {
|
|
206
|
+
it('should return no focus message when empty', () => {
|
|
207
|
+
const markdown = store.formatAsMarkdown();
|
|
208
|
+
expect(markdown).toContain('No Active Focus');
|
|
209
|
+
});
|
|
210
|
+
it('should format single snapshot correctly', () => {
|
|
211
|
+
const payload = createMockPayload({
|
|
212
|
+
interactionId: 'test-123',
|
|
213
|
+
userNote: 'Make it blue',
|
|
214
|
+
snapshot: createMockSnapshot({
|
|
215
|
+
role: 'button',
|
|
216
|
+
name: 'Submit',
|
|
217
|
+
framework: {
|
|
218
|
+
name: 'react',
|
|
219
|
+
componentName: 'SubmitButton',
|
|
220
|
+
filePath: 'src/components/SubmitButton.tsx',
|
|
221
|
+
lineNumber: 42,
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
store.setFocus(payload);
|
|
226
|
+
const markdown = store.formatAsMarkdown();
|
|
227
|
+
expect(markdown).toContain('test-123');
|
|
228
|
+
expect(markdown).toContain('Make it blue');
|
|
229
|
+
expect(markdown).toContain('SubmitButton');
|
|
230
|
+
expect(markdown).toContain('src/components/SubmitButton.tsx');
|
|
231
|
+
expect(markdown).toContain(':42');
|
|
232
|
+
expect(markdown).toContain('Role: button');
|
|
233
|
+
expect(markdown).toContain('Name: "Submit"');
|
|
234
|
+
});
|
|
235
|
+
it('should format multiple snapshots correctly', () => {
|
|
236
|
+
const payload = createMockPayload({
|
|
237
|
+
interactionId: 'multi-123',
|
|
238
|
+
userNote: 'Style these consistently',
|
|
239
|
+
snapshot: undefined,
|
|
240
|
+
snapshots: [
|
|
241
|
+
createMockSnapshot({ name: 'Button 1' }),
|
|
242
|
+
createMockSnapshot({ name: 'Button 2' }),
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
store.setFocus(payload);
|
|
246
|
+
const markdown = store.formatAsMarkdown();
|
|
247
|
+
expect(markdown).toContain('2 Elements');
|
|
248
|
+
expect(markdown).toContain('Element 1:');
|
|
249
|
+
expect(markdown).toContain('Element 2:');
|
|
250
|
+
expect(markdown).toContain('Button 1');
|
|
251
|
+
expect(markdown).toContain('Button 2');
|
|
252
|
+
});
|
|
253
|
+
it('should include element identifiers when present', () => {
|
|
254
|
+
const payload = createMockPayload({
|
|
255
|
+
snapshot: createMockSnapshot({
|
|
256
|
+
id: 'my-button',
|
|
257
|
+
className: 'btn btn-primary',
|
|
258
|
+
dataAttributes: { 'data-testid': 'submit-btn' },
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
store.setFocus(payload);
|
|
262
|
+
const markdown = store.formatAsMarkdown();
|
|
263
|
+
expect(markdown).toContain('#my-button');
|
|
264
|
+
expect(markdown).toContain('btn btn-primary');
|
|
265
|
+
expect(markdown).toContain('data-testid="submit-btn"');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eyeglass/bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server bridge for Eyeglass",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"eyeglass-bridge": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsx src/index.ts",
|
|
17
|
+
"test": "vitest run --project node --testNamePattern bridge",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"eyeglass",
|
|
22
|
+
"mcp",
|
|
23
|
+
"bridge",
|
|
24
|
+
"server"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/donutboyband/eyeglass.git",
|
|
30
|
+
"directory": "packages/bridge"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@eyeglass/types": "^0.1.0",
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
35
|
+
"cors": "^2.8.5",
|
|
36
|
+
"express": "^4.18.2",
|
|
37
|
+
"zod": "^3.22.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/cors": "^2.8.17",
|
|
41
|
+
"@types/express": "^4.17.21",
|
|
42
|
+
"@types/node": "^20.10.0",
|
|
43
|
+
"tsx": "^4.7.0",
|
|
44
|
+
"typescript": "^5.3.0"
|
|
45
|
+
}
|
|
46
|
+
}
|