@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 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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }
@@ -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
+ }