crossmem-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/README.md +51 -0
- package/bin/crossmem-bridge.js +251 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# crossmem-bridge
|
|
2
|
+
|
|
3
|
+
WebSocket bridge between CLI/AI agents and the [crossmem Chrome extension](https://chromewebstore.google.com/detail/crossmem/kmpfhoimimgfdglaglpjegjiahkfolpa).
|
|
4
|
+
|
|
5
|
+
Control your browser from the terminal. No Puppeteer, no headless Chrome — uses your real browser with your real login sessions.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx crossmem-bridge
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then install the [crossmem extension](https://chromewebstore.google.com/detail/crossmem/kmpfhoimimgfdglaglpjegjiahkfolpa) if you haven't already.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
- Bridges CLI commands to your Chrome browser via the crossmem extension
|
|
18
|
+
- Routes LLM requests to your local Claude Code (uses your subscription, not API credits)
|
|
19
|
+
- Saves snapshots and summaries to `~/crossmem/{raw,wiki}`
|
|
20
|
+
- Downloads arxiv PDFs automatically
|
|
21
|
+
|
|
22
|
+
## API
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Check status
|
|
26
|
+
curl http://127.0.0.1:7600/status
|
|
27
|
+
|
|
28
|
+
# Send commands
|
|
29
|
+
curl -X POST http://127.0.0.1:7600/command \
|
|
30
|
+
-H 'Content-Type: application/json' \
|
|
31
|
+
-d '{"action":"navigate","params":{"url":"https://example.com"}}'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Available actions
|
|
35
|
+
|
|
36
|
+
| Action | Params | Description |
|
|
37
|
+
|--------|--------|-------------|
|
|
38
|
+
| `navigate` | `{url}` | Open a URL |
|
|
39
|
+
| `click` | `{selector}` | Click an element |
|
|
40
|
+
| `type` | `{selector, text}` | Type into an element |
|
|
41
|
+
| `wait` | `{selector, timeout?}` | Wait for element |
|
|
42
|
+
| `extract` | `{selector, attr?}` | Extract text/attributes |
|
|
43
|
+
| `screenshot` | — | Capture visible tab |
|
|
44
|
+
| `summarize` | — | Snapshot & Summary current page |
|
|
45
|
+
| `ping` | — | Health check |
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- Node.js >= 18
|
|
50
|
+
- Chrome with crossmem extension installed
|
|
51
|
+
- (Optional) Claude Code CLI for LLM features via subscription
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// crossmem-bridge — WebSocket relay between CLI and crossmem Chrome extension
|
|
3
|
+
// Usage: npx crossmem-bridge
|
|
4
|
+
|
|
5
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
6
|
+
import { createServer } from 'http';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { homedir, platform } from 'os';
|
|
13
|
+
|
|
14
|
+
const PORT = process.env.BRIDGE_PORT || 7600;
|
|
15
|
+
const CWS_URL = 'https://chromewebstore.google.com/detail/crossmem/kmpfhoimimgfdglaglpjegjiahkfolpa';
|
|
16
|
+
let extensionWs = null;
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
|
|
19
|
+
// HTTP server for CLI commands
|
|
20
|
+
const httpServer = createServer((req, res) => {
|
|
21
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
22
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
23
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
24
|
+
|
|
25
|
+
if (req.method === 'OPTIONS') {
|
|
26
|
+
res.writeHead(204);
|
|
27
|
+
res.end();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (req.method === 'GET' && req.url === '/status') {
|
|
32
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
33
|
+
res.end(JSON.stringify({
|
|
34
|
+
connected: extensionWs?.readyState === WebSocket.OPEN,
|
|
35
|
+
pending: pending.size,
|
|
36
|
+
}));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (req.method === 'POST' && req.url === '/command') {
|
|
41
|
+
let body = '';
|
|
42
|
+
req.on('data', chunk => body += chunk);
|
|
43
|
+
req.on('end', async () => {
|
|
44
|
+
try {
|
|
45
|
+
const cmd = JSON.parse(body);
|
|
46
|
+
if (!cmd.id) cmd.id = randomUUID();
|
|
47
|
+
|
|
48
|
+
if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
|
|
49
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
50
|
+
res.end(JSON.stringify({ success: false, error: 'Extension not connected. Is Chrome running with crossmem installed?' }));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const timeout = cmd.timeout || 30000;
|
|
55
|
+
const result = await sendToExtension(cmd, timeout);
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
57
|
+
res.end(JSON.stringify(result));
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
60
|
+
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
res.writeHead(404);
|
|
67
|
+
res.end('Not found');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// WebSocket server for extension
|
|
71
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
72
|
+
|
|
73
|
+
wss.on('connection', (ws) => {
|
|
74
|
+
console.log('[bridge] ✅ extension connected');
|
|
75
|
+
extensionWs = ws;
|
|
76
|
+
|
|
77
|
+
ws.on('message', (data) => {
|
|
78
|
+
try {
|
|
79
|
+
const msg = JSON.parse(data);
|
|
80
|
+
|
|
81
|
+
if (msg.type === 'llm') {
|
|
82
|
+
handleLlmRequest(msg, ws);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (msg.type === 'save_memory') {
|
|
87
|
+
handleSaveMemory(msg, ws);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (msg.type === 'open_file') {
|
|
92
|
+
const filePath = msg.path.replace(/^~/, homedir());
|
|
93
|
+
if (existsSync(filePath)) {
|
|
94
|
+
spawn('open', ['-R', filePath]);
|
|
95
|
+
} else {
|
|
96
|
+
for (const ext of ['.pdf', '.png', '.jpg']) {
|
|
97
|
+
if (existsSync(filePath + ext)) {
|
|
98
|
+
spawn('open', ['-R', filePath + ext]);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
103
|
+
if (existsSync(dir)) spawn('open', [dir]);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const p = pending.get(msg.id);
|
|
109
|
+
if (p) {
|
|
110
|
+
clearTimeout(p.timer);
|
|
111
|
+
pending.delete(msg.id);
|
|
112
|
+
p.resolve(msg);
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
ws.on('close', () => {
|
|
118
|
+
console.log('[bridge] extension disconnected');
|
|
119
|
+
if (extensionWs === ws) extensionWs = null;
|
|
120
|
+
for (const [id, p] of pending) {
|
|
121
|
+
clearTimeout(p.timer);
|
|
122
|
+
p.resolve({ id, success: false, error: 'Extension disconnected' });
|
|
123
|
+
}
|
|
124
|
+
pending.clear();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
function sendToExtension(cmd, timeout) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
pending.delete(cmd.id);
|
|
132
|
+
resolve({ id: cmd.id, success: false, error: `Timeout after ${timeout}ms` });
|
|
133
|
+
}, timeout);
|
|
134
|
+
pending.set(cmd.id, { resolve, timer });
|
|
135
|
+
extensionWs.send(JSON.stringify(cmd));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Reverse channel: extension → local Claude Code
|
|
140
|
+
function handleLlmRequest(msg, ws) {
|
|
141
|
+
const { id, prompt } = msg;
|
|
142
|
+
console.log(`[bridge:llm] request: ${prompt.slice(0, 80)}...`);
|
|
143
|
+
|
|
144
|
+
const proc = spawn('claude', ['-p', prompt], {
|
|
145
|
+
env: { ...process.env, ANTHROPIC_API_KEY: '' },
|
|
146
|
+
timeout: 120000,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let stdout = '';
|
|
150
|
+
let stderr = '';
|
|
151
|
+
proc.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
152
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
153
|
+
|
|
154
|
+
proc.on('close', (code) => {
|
|
155
|
+
if (code === 0) {
|
|
156
|
+
console.log(`[bridge:llm] done (${stdout.length} chars)`);
|
|
157
|
+
ws.send(JSON.stringify({ type: 'llm_response', id, success: true, data: stdout.trim() }));
|
|
158
|
+
} else {
|
|
159
|
+
console.error(`[bridge:llm] failed: ${stderr}`);
|
|
160
|
+
ws.send(JSON.stringify({ type: 'llm_response', id, success: false, error: stderr || `exit ${code}` }));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
proc.on('error', (err) => {
|
|
165
|
+
ws.send(JSON.stringify({ type: 'llm_response', id, success: false, error: err.message }));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Reverse channel: extension → save memory to ~/crossmem/{raw,wiki}
|
|
170
|
+
const CROSSMEM_DIR = join(homedir(), 'crossmem');
|
|
171
|
+
|
|
172
|
+
async function handleSaveMemory(msg, ws) {
|
|
173
|
+
const { id, data } = msg;
|
|
174
|
+
const { slug, date, screenshot, markdown, arxivId } = data;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const rawDir = join(CROSSMEM_DIR, 'raw');
|
|
178
|
+
const wikiDir = join(CROSSMEM_DIR, 'wiki');
|
|
179
|
+
await mkdir(rawDir, { recursive: true });
|
|
180
|
+
await mkdir(wikiDir, { recursive: true });
|
|
181
|
+
|
|
182
|
+
const baseName = `${date}_${slug}`;
|
|
183
|
+
const files = [];
|
|
184
|
+
|
|
185
|
+
if (arxivId) {
|
|
186
|
+
const pdfPath = join(rawDir, `${baseName}.pdf`);
|
|
187
|
+
try {
|
|
188
|
+
const pdfUrl = `https://arxiv.org/pdf/${arxivId}`;
|
|
189
|
+
console.log(`[bridge:save] downloading arxiv PDF: ${pdfUrl}`);
|
|
190
|
+
const res = await fetch(pdfUrl);
|
|
191
|
+
if (res.ok) {
|
|
192
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
193
|
+
if (buf.length > 50 * 1024 * 1024) {
|
|
194
|
+
console.warn(`[bridge:save] PDF too large (${(buf.length / 1024 / 1024).toFixed(1)}MB), skipping`);
|
|
195
|
+
} else {
|
|
196
|
+
await writeFile(pdfPath, buf);
|
|
197
|
+
files.push(pdfPath);
|
|
198
|
+
console.log(`[bridge:save] raw (pdf): ${pdfPath} (${(buf.length / 1024).toFixed(0)}KB)`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (pdfErr) {
|
|
202
|
+
console.warn(`[bridge:save] arxiv PDF download error:`, pdfErr.message);
|
|
203
|
+
}
|
|
204
|
+
} else if (screenshot) {
|
|
205
|
+
const pngPath = join(rawDir, `${baseName}.png`);
|
|
206
|
+
const base64 = screenshot.replace(/^data:image\/png;base64,/, '');
|
|
207
|
+
await writeFile(pngPath, Buffer.from(base64, 'base64'));
|
|
208
|
+
files.push(pngPath);
|
|
209
|
+
console.log(`[bridge:save] raw (png): ${pngPath}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (markdown) {
|
|
213
|
+
const mdPath = join(wikiDir, `${baseName}.md`);
|
|
214
|
+
await writeFile(mdPath, markdown, 'utf-8');
|
|
215
|
+
files.push(mdPath);
|
|
216
|
+
console.log(`[bridge:save] wiki: ${mdPath}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const wikiPath = join(wikiDir, `${baseName}.md`);
|
|
220
|
+
const rawPath = files.length > 0 ? files[0] : null;
|
|
221
|
+
ws.send(JSON.stringify({ type: 'save_memory_response', id, success: true, data: { files, wikiPath, rawPath } }));
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error(`[bridge:save] failed:`, err);
|
|
224
|
+
ws.send(JSON.stringify({ type: 'save_memory_response', id, success: false, error: err.message }));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Auto-open CWS if extension doesn't connect within 10s
|
|
229
|
+
let cwsOpened = false;
|
|
230
|
+
setTimeout(() => {
|
|
231
|
+
if (!extensionWs && !cwsOpened) {
|
|
232
|
+
cwsOpened = true;
|
|
233
|
+
console.log('[bridge] Extension not detected. Install it:');
|
|
234
|
+
console.log(`[bridge] → ${CWS_URL}`);
|
|
235
|
+
if (platform() === 'darwin') {
|
|
236
|
+
spawn('open', [CWS_URL]);
|
|
237
|
+
} else if (platform() === 'win32') {
|
|
238
|
+
spawn('start', [CWS_URL], { shell: true });
|
|
239
|
+
} else {
|
|
240
|
+
spawn('xdg-open', [CWS_URL]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}, 10000);
|
|
244
|
+
|
|
245
|
+
httpServer.listen(PORT, '127.0.0.1', () => {
|
|
246
|
+
console.log(`[bridge] crossmem-bridge v${process.env.npm_package_version || '0.1.0'}`);
|
|
247
|
+
console.log(`[bridge] relay server on http://127.0.0.1:${PORT}`);
|
|
248
|
+
console.log('[bridge] POST /command — send commands to extension');
|
|
249
|
+
console.log('[bridge] GET /status — check connection status');
|
|
250
|
+
console.log('[bridge] waiting for extension...');
|
|
251
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crossmem-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WebSocket bridge between CLI and crossmem Chrome extension. Enables AI agents to control your browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"crossmem-bridge": "./bin/crossmem-bridge.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/crossmem-bridge.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"crossmem",
|
|
14
|
+
"chrome-extension",
|
|
15
|
+
"browser-automation",
|
|
16
|
+
"ai-agent",
|
|
17
|
+
"websocket",
|
|
18
|
+
"bridge"
|
|
19
|
+
],
|
|
20
|
+
"author": "crossmem",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/crossmem/crossmem-bridge"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"ws": "^8.18.0"
|
|
31
|
+
}
|
|
32
|
+
}
|