lui-bridge 0.2.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 +79 -0
- package/package.json +24 -0
- package/src/cli.js +245 -0
- package/src/index.js +262 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# lui-bridge
|
|
2
|
+
|
|
3
|
+
Connect AI agents to LUI Android devices. 78 phone tools, MCP protocol, bidirectional communication.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g lui-bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Connect as an agent
|
|
15
|
+
lui bridge connect --url ws://PHONE_IP:8765 --token TOKEN --agent claude-code --mode claude-code
|
|
16
|
+
lui bridge connect --url ws://PHONE_IP:8765 --token TOKEN --agent hermes --mode hermes
|
|
17
|
+
|
|
18
|
+
# List tools
|
|
19
|
+
lui bridge tools --url ws://PHONE_IP:8765 --token TOKEN
|
|
20
|
+
|
|
21
|
+
# Call a single tool
|
|
22
|
+
lui bridge call --url ws://PHONE_IP:8765 --token TOKEN --tool battery
|
|
23
|
+
lui bridge call --url ws://PHONE_IP:8765 --token TOKEN --tool toggle_flashlight --args '{"state":"on"}'
|
|
24
|
+
|
|
25
|
+
# Check device
|
|
26
|
+
lui bridge status --url ws://PHONE_IP:8765 --token TOKEN
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## JavaScript API
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
const { LuiBridge } = require('lui-bridge');
|
|
33
|
+
|
|
34
|
+
const bridge = new LuiBridge('ws://PHONE_IP:8765', 'YOUR_TOKEN');
|
|
35
|
+
await bridge.connect();
|
|
36
|
+
|
|
37
|
+
// Call tools
|
|
38
|
+
console.log(await bridge.callTool('battery'));
|
|
39
|
+
console.log(await bridge.callTool('get_location'));
|
|
40
|
+
console.log(await bridge.callTool('toggle_flashlight', { state: 'on' }));
|
|
41
|
+
|
|
42
|
+
// Vision
|
|
43
|
+
console.log(await bridge.callTool('take_photo')); // Camera2 capture
|
|
44
|
+
console.log(await bridge.callTool('analyze_image')); // Describe last photo
|
|
45
|
+
|
|
46
|
+
// Device info
|
|
47
|
+
console.log(await bridge.getDeviceState());
|
|
48
|
+
console.log(bridge.listTools());
|
|
49
|
+
|
|
50
|
+
bridge.disconnect();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Bidirectional Agent
|
|
54
|
+
|
|
55
|
+
```javascript
|
|
56
|
+
const { LuiBridge } = require('lui-bridge');
|
|
57
|
+
|
|
58
|
+
const bridge = new LuiBridge('ws://PHONE_IP:8765', 'YOUR_TOKEN', {
|
|
59
|
+
agentName: 'my-bot',
|
|
60
|
+
onInstruction: async (instruction) => {
|
|
61
|
+
console.log(`Got: ${instruction}`);
|
|
62
|
+
return `Executed: ${instruction}`;
|
|
63
|
+
},
|
|
64
|
+
onEvent: (type, data) => {
|
|
65
|
+
if (type === 'notification_2fa') console.log(`2FA: ${data.code}`);
|
|
66
|
+
if (type === 'notification') console.log(`Notif: ${data.title}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await bridge.connect();
|
|
71
|
+
// Phone user says: "patch me to my-bot" → direct chat
|
|
72
|
+
// "@my-bot deploy" → one-off instruction
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Requirements
|
|
76
|
+
|
|
77
|
+
- Node.js 16+
|
|
78
|
+
- Phone running LUI with bridge enabled
|
|
79
|
+
- Same Wi-Fi (or relay for remote)
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lui-bridge",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Connect AI agents to LUI Android devices — 78 tools, MCP protocol, bidirectional communication",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lui": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node src/cli.js --help"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["android", "agent", "mcp", "bridge", "phone", "ai", "tool-use", "lui"],
|
|
13
|
+
"license": "GPL-3.0",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/obirije/LUI"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=16.0.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"ws": "^8.16.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LUI CLI — Connect agents to LUI devices.
|
|
5
|
+
*
|
|
6
|
+
* lui bridge connect --url ws://PHONE_IP:8765 --token TOKEN --agent NAME --mode MODE
|
|
7
|
+
* lui bridge tools --url ws://PHONE_IP:8765 --token TOKEN
|
|
8
|
+
* lui bridge call --url ws://PHONE_IP:8765 --token TOKEN --tool TOOL [--args JSON]
|
|
9
|
+
* lui bridge status --url ws://PHONE_IP:8765 --token TOKEN
|
|
10
|
+
* lui relay start [--port PORT]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { LuiBridge } = require('./index');
|
|
14
|
+
const { execSync, spawn } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
|
|
18
|
+
function getArg(name, fallback = null) {
|
|
19
|
+
const idx = args.indexOf(`--${name}`);
|
|
20
|
+
if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasArg(name) {
|
|
25
|
+
return args.includes(`--${name}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const command = args[0];
|
|
29
|
+
const subcommand = args[1];
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
if (hasArg('version')) {
|
|
33
|
+
console.log('lui-bridge 0.1.0');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (hasArg('help') || !command) {
|
|
38
|
+
console.log(`
|
|
39
|
+
lui — Android agent bridge and relay
|
|
40
|
+
|
|
41
|
+
Commands:
|
|
42
|
+
lui bridge connect --url URL --token TOKEN [--agent NAME] [--mode MODE]
|
|
43
|
+
lui bridge tools --url URL --token TOKEN
|
|
44
|
+
lui bridge call --url URL --token TOKEN --tool TOOL [--args JSON]
|
|
45
|
+
lui bridge status --url URL --token TOKEN
|
|
46
|
+
lui relay start [--port PORT]
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--url Bridge WebSocket URL (ws://PHONE_IP:8765)
|
|
50
|
+
--token Auth token (from LUI notification)
|
|
51
|
+
--agent Agent name (for connect mode)
|
|
52
|
+
--mode Execution mode: echo, shell, hermes, claude-code (default: echo)
|
|
53
|
+
--tool Tool name (for call mode)
|
|
54
|
+
--args Tool arguments as JSON (for call mode)
|
|
55
|
+
--port Relay server port (default: 9000)
|
|
56
|
+
--help Show this help
|
|
57
|
+
--version Show version
|
|
58
|
+
`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (command === 'bridge') {
|
|
63
|
+
const url = getArg('url');
|
|
64
|
+
const token = getArg('token');
|
|
65
|
+
|
|
66
|
+
if (!url || !token) {
|
|
67
|
+
console.error('Required: --url and --token');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (subcommand === 'connect') {
|
|
72
|
+
await bridgeConnect(url, token);
|
|
73
|
+
} else if (subcommand === 'tools') {
|
|
74
|
+
await bridgeTools(url, token);
|
|
75
|
+
} else if (subcommand === 'call') {
|
|
76
|
+
await bridgeCall(url, token);
|
|
77
|
+
} else if (subcommand === 'status') {
|
|
78
|
+
await bridgeStatus(url, token);
|
|
79
|
+
} else {
|
|
80
|
+
console.error(`Unknown bridge command: ${subcommand}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
} else if (command === 'relay') {
|
|
84
|
+
if (subcommand === 'start') {
|
|
85
|
+
await relayStart();
|
|
86
|
+
} else {
|
|
87
|
+
console.error(`Unknown relay command: ${subcommand}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
console.error(`Unknown command: ${command}. Use --help`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function bridgeConnect(url, token) {
|
|
97
|
+
const agentName = getArg('agent', 'node-agent');
|
|
98
|
+
const mode = getArg('mode', 'echo');
|
|
99
|
+
const executor = getExecutor(mode);
|
|
100
|
+
|
|
101
|
+
const bridge = new LuiBridge(url, token, {
|
|
102
|
+
agentName,
|
|
103
|
+
onInstruction: async (instruction) => {
|
|
104
|
+
console.log(`\n[→ ${agentName}] ${instruction}`);
|
|
105
|
+
const result = await executor(instruction);
|
|
106
|
+
console.log(`[← ${agentName}] ${result.slice(0, 120)}`);
|
|
107
|
+
return result;
|
|
108
|
+
},
|
|
109
|
+
onEvent: (type, data) => {
|
|
110
|
+
if (type === 'notification_2fa') {
|
|
111
|
+
console.log(`\n[2FA] Code: ${data.code} from ${data.app}`);
|
|
112
|
+
} else if (type === 'call_incoming' || type === 'call_missed') {
|
|
113
|
+
console.log(`\n[${type.toUpperCase()}] ${data.caller || '?'}`);
|
|
114
|
+
} else if (type === 'notification') {
|
|
115
|
+
console.log(`\n[NOTIF] ${data.title || ''}: ${(data.text || '').slice(0, 50)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const toolCount = await bridge.connect();
|
|
121
|
+
const state = await bridge.getDeviceState();
|
|
122
|
+
console.log(`Connected to LUI as '${agentName}' — ${toolCount} tools, mode=${mode}`);
|
|
123
|
+
console.log(`Device: ${state.split('\n')[0]}`);
|
|
124
|
+
console.log(`\nOn LUI say: 'patch me to ${agentName}' or '@${agentName} do something'`);
|
|
125
|
+
console.log('Listening... (Ctrl+C to exit)\n');
|
|
126
|
+
|
|
127
|
+
process.on('SIGINT', () => {
|
|
128
|
+
bridge.disconnect();
|
|
129
|
+
console.log('\nDisconnected.');
|
|
130
|
+
process.exit(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Keep alive
|
|
134
|
+
await new Promise(() => {});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function bridgeTools(url, token) {
|
|
138
|
+
const bridge = new LuiBridge(url, token);
|
|
139
|
+
const count = await bridge.connect();
|
|
140
|
+
const tools = bridge.listToolsDetailed();
|
|
141
|
+
bridge.disconnect();
|
|
142
|
+
|
|
143
|
+
console.log(`${count} tools available:\n`);
|
|
144
|
+
for (const t of tools) {
|
|
145
|
+
const params = Object.keys(t.inputSchema?.properties || {}).join(', ');
|
|
146
|
+
console.log(` ${t.name}(${params})`);
|
|
147
|
+
console.log(` ${t.description}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function bridgeCall(url, token) {
|
|
152
|
+
const tool = getArg('tool');
|
|
153
|
+
if (!tool) {
|
|
154
|
+
console.error('Required: --tool');
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const argsJson = getArg('args', '{}');
|
|
159
|
+
let toolArgs;
|
|
160
|
+
try {
|
|
161
|
+
toolArgs = JSON.parse(argsJson);
|
|
162
|
+
} catch {
|
|
163
|
+
console.error('Invalid --args JSON');
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const bridge = new LuiBridge(url, token);
|
|
168
|
+
await bridge.connect();
|
|
169
|
+
const result = await bridge.callTool(tool, toolArgs);
|
|
170
|
+
bridge.disconnect();
|
|
171
|
+
console.log(result);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function bridgeStatus(url, token) {
|
|
175
|
+
const bridge = new LuiBridge(url, token);
|
|
176
|
+
try {
|
|
177
|
+
const count = await bridge.connect();
|
|
178
|
+
const state = await bridge.getDeviceState();
|
|
179
|
+
bridge.disconnect();
|
|
180
|
+
console.log(`Connected — ${count} tools\n`);
|
|
181
|
+
console.log(state);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error(`Failed: ${err.message}`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function relayStart() {
|
|
189
|
+
const port = getArg('port', '9000');
|
|
190
|
+
console.log(`Starting LUI relay on port ${port}...`);
|
|
191
|
+
console.log('Note: The relay server requires Python. Use: lui relay start (Python CLI)');
|
|
192
|
+
console.log(`Or: python3 relay/relay_server.py (PORT=${port})`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getExecutor(mode) {
|
|
197
|
+
switch (mode) {
|
|
198
|
+
case 'shell':
|
|
199
|
+
return (instruction) => {
|
|
200
|
+
try {
|
|
201
|
+
const output = execSync(instruction, { timeout: 30000, encoding: 'utf-8', cwd: process.env.HOME });
|
|
202
|
+
return output.trim() || 'Done.';
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return err.stderr?.trim() || err.message || 'Command failed.';
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
case 'hermes':
|
|
208
|
+
return (instruction) => {
|
|
209
|
+
try {
|
|
210
|
+
const output = execSync(`hermes chat -q "${instruction.replace(/"/g, '\\"')}" --yolo`, {
|
|
211
|
+
timeout: 120000, encoding: 'utf-8', env: { ...process.env, TERM: 'dumb', NO_COLOR: '1' }
|
|
212
|
+
});
|
|
213
|
+
// Strip ANSI and box drawing
|
|
214
|
+
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
215
|
+
.replace(/[╭╮╰╯│─┃━┏┓┗┛┡┩┣┫╌╍⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
|
|
216
|
+
.split('\n')
|
|
217
|
+
.map(l => l.trim())
|
|
218
|
+
.filter(l => l && !l.startsWith('Hermes Agent') && !l.includes('──────'))
|
|
219
|
+
.slice(-15)
|
|
220
|
+
.join('\n');
|
|
221
|
+
return clean || 'Hermes returned no output.';
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return `Hermes error: ${err.message}`;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
case 'claude-code':
|
|
227
|
+
return (instruction) => {
|
|
228
|
+
try {
|
|
229
|
+
const output = execSync(`claude --print --dangerously-skip-permissions "${instruction.replace(/"/g, '\\"')}"`, {
|
|
230
|
+
timeout: 120000, encoding: 'utf-8'
|
|
231
|
+
});
|
|
232
|
+
return output.replace(/\x1b\[[0-9;]*m/g, '').trim().slice(0, 1000) || 'Claude Code returned no output.';
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return `Claude Code error: ${err.message}`;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
default:
|
|
238
|
+
return (instruction) => `Received: ${instruction}`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
main().catch(err => {
|
|
243
|
+
console.error(err.message);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LUI Bridge — Connect AI agents to Android devices.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { LuiBridge } = require('lui-bridge');
|
|
6
|
+
*
|
|
7
|
+
* const bridge = new LuiBridge('ws://PHONE_IP:8765', 'YOUR_TOKEN');
|
|
8
|
+
* await bridge.connect();
|
|
9
|
+
* console.log(await bridge.callTool('battery'));
|
|
10
|
+
* bridge.disconnect();
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const WebSocket = require('ws');
|
|
14
|
+
|
|
15
|
+
class LuiBridge {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} url - WebSocket URL (e.g., ws://PHONE_IP:8765)
|
|
18
|
+
* @param {string} token - Bridge auth token
|
|
19
|
+
* @param {Object} [options]
|
|
20
|
+
* @param {string} [options.agentName] - Register as named agent for bidirectional comms
|
|
21
|
+
* @param {Function} [options.onInstruction] - async (instruction) => response string
|
|
22
|
+
* @param {Function} [options.onEvent] - (eventType, data) => void
|
|
23
|
+
*/
|
|
24
|
+
constructor(url, token, options = {}) {
|
|
25
|
+
this.url = url;
|
|
26
|
+
this.token = token;
|
|
27
|
+
this.agentName = options.agentName || null;
|
|
28
|
+
this.onInstruction = options.onInstruction || null;
|
|
29
|
+
this.onEvent = options.onEvent || null;
|
|
30
|
+
this.ws = null;
|
|
31
|
+
this.connected = false;
|
|
32
|
+
this.tools = [];
|
|
33
|
+
this._requestId = 0;
|
|
34
|
+
this._pending = new Map();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Connect, authenticate, initialize MCP, optionally register as agent.
|
|
39
|
+
* @returns {Promise<number>} Number of available tools
|
|
40
|
+
*/
|
|
41
|
+
async connect() {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
this.ws = new WebSocket(this.url);
|
|
44
|
+
|
|
45
|
+
this.ws.on('error', (err) => {
|
|
46
|
+
if (!this.connected) reject(err);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.ws.on('open', async () => {
|
|
50
|
+
try {
|
|
51
|
+
// Auth
|
|
52
|
+
this._send({ method: 'auth', params: { token: this.token } });
|
|
53
|
+
const auth = await this._readOne();
|
|
54
|
+
if (!auth?.result?.authenticated) {
|
|
55
|
+
throw new Error(`Auth failed: ${JSON.stringify(auth)}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Initialize MCP
|
|
59
|
+
await this._call('initialize', {
|
|
60
|
+
protocolVersion: '2025-03-26',
|
|
61
|
+
clientInfo: { name: this.agentName || 'lui-node', version: '0.1.0' }
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Register as agent
|
|
65
|
+
if (this.agentName) {
|
|
66
|
+
this._send({
|
|
67
|
+
jsonrpc: '2.0', id: this._nextId(),
|
|
68
|
+
method: 'lui/register',
|
|
69
|
+
params: {
|
|
70
|
+
name: this.agentName,
|
|
71
|
+
description: `${this.agentName} agent`,
|
|
72
|
+
capabilities: []
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await this._drain();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Get tools
|
|
79
|
+
const toolsResp = await this._call('tools/list');
|
|
80
|
+
this.tools = toolsResp?.tools || [];
|
|
81
|
+
|
|
82
|
+
this.connected = true;
|
|
83
|
+
this._startListener();
|
|
84
|
+
resolve(this.tools.length);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
reject(err);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.ws.on('close', () => {
|
|
91
|
+
this.connected = false;
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Disconnect from the bridge. */
|
|
97
|
+
disconnect() {
|
|
98
|
+
this.connected = false;
|
|
99
|
+
if (this.ws) {
|
|
100
|
+
this.ws.close();
|
|
101
|
+
this.ws = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Call a LUI tool.
|
|
107
|
+
* @param {string} name - Tool name
|
|
108
|
+
* @param {Object} [args] - Tool arguments
|
|
109
|
+
* @returns {Promise<string>} Result text
|
|
110
|
+
*/
|
|
111
|
+
async callTool(name, args = {}) {
|
|
112
|
+
const resp = await this._call('tools/call', { name, arguments: args });
|
|
113
|
+
const content = resp?.content || [];
|
|
114
|
+
if (content.length > 0) {
|
|
115
|
+
const text = content[0].text || '';
|
|
116
|
+
if (resp.isError) throw new ToolError(text);
|
|
117
|
+
return text;
|
|
118
|
+
}
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get current device state.
|
|
124
|
+
* @returns {Promise<string>}
|
|
125
|
+
*/
|
|
126
|
+
async getDeviceState() {
|
|
127
|
+
const resp = await this._call('resources/read', { uri: 'lui://device/state' });
|
|
128
|
+
const contents = resp?.contents || [];
|
|
129
|
+
return contents.length > 0 ? contents[0].text || '' : '';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @returns {string[]} Available tool names */
|
|
133
|
+
listTools() {
|
|
134
|
+
return this.tools.map(t => t.name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @returns {Object[]} Full tool definitions */
|
|
138
|
+
listToolsDetailed() {
|
|
139
|
+
return this.tools;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @returns {Promise<boolean>} */
|
|
143
|
+
async ping() {
|
|
144
|
+
try {
|
|
145
|
+
await this._call('ping');
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Internal ──
|
|
153
|
+
|
|
154
|
+
_nextId() {
|
|
155
|
+
return String(++this._requestId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_send(msg) {
|
|
159
|
+
this.ws.send(JSON.stringify(msg));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_readOne() {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const timeout = setTimeout(() => reject(new Error('Timeout')), 10000);
|
|
165
|
+
this.ws.once('message', (data) => {
|
|
166
|
+
clearTimeout(timeout);
|
|
167
|
+
resolve(JSON.parse(data.toString()));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async _drain() {
|
|
173
|
+
await new Promise(r => setTimeout(r, 500));
|
|
174
|
+
// Read and discard buffered messages
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
const handler = () => {};
|
|
177
|
+
this.ws.on('message', handler);
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
this.ws.removeListener('message', handler);
|
|
180
|
+
resolve();
|
|
181
|
+
}, 300);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_call(method, params) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const id = this._nextId();
|
|
188
|
+
const msg = { jsonrpc: '2.0', id, method };
|
|
189
|
+
if (params) msg.params = params;
|
|
190
|
+
|
|
191
|
+
const timeout = setTimeout(() => {
|
|
192
|
+
this._pending.delete(id);
|
|
193
|
+
reject(new Error(`Timeout waiting for ${method}`));
|
|
194
|
+
}, 30000);
|
|
195
|
+
|
|
196
|
+
this._pending.set(id, { resolve, reject, timeout });
|
|
197
|
+
this._send(msg);
|
|
198
|
+
|
|
199
|
+
// If listener not running, read directly
|
|
200
|
+
if (!this._listening) {
|
|
201
|
+
const directHandler = (data) => {
|
|
202
|
+
const resp = JSON.parse(data.toString());
|
|
203
|
+
if (String(resp.id) === id) {
|
|
204
|
+
this.ws.removeListener('message', directHandler);
|
|
205
|
+
clearTimeout(timeout);
|
|
206
|
+
this._pending.delete(id);
|
|
207
|
+
resolve(resp.result || {});
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
this.ws.on('message', directHandler);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_startListener() {
|
|
216
|
+
this._listening = true;
|
|
217
|
+
this.ws.on('message', (data) => {
|
|
218
|
+
try {
|
|
219
|
+
const msg = JSON.parse(data.toString());
|
|
220
|
+
const id = String(msg.id || '');
|
|
221
|
+
|
|
222
|
+
// Pending response
|
|
223
|
+
if (this._pending.has(id)) {
|
|
224
|
+
const p = this._pending.get(id);
|
|
225
|
+
clearTimeout(p.timeout);
|
|
226
|
+
this._pending.delete(id);
|
|
227
|
+
p.resolve(msg.result || {});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Instruction from user
|
|
232
|
+
if (msg.method === 'lui/instruction' && this.onInstruction) {
|
|
233
|
+
const instruction = msg.params?.instruction || '';
|
|
234
|
+
const instrId = msg.id || '';
|
|
235
|
+
|
|
236
|
+
Promise.resolve(this.onInstruction(instruction)).then((response) => {
|
|
237
|
+
this._send({
|
|
238
|
+
jsonrpc: '2.0', id: 'resp',
|
|
239
|
+
method: 'lui/response',
|
|
240
|
+
params: { instruction_id: String(instrId), result: String(response).slice(0, 1000) }
|
|
241
|
+
});
|
|
242
|
+
}).catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Event
|
|
246
|
+
if (msg.method === 'notifications/lui/event' && this.onEvent) {
|
|
247
|
+
const event = msg.params || {};
|
|
248
|
+
this.onEvent(event.type, event.data || {});
|
|
249
|
+
}
|
|
250
|
+
} catch {}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
class ToolError extends Error {
|
|
256
|
+
constructor(message) {
|
|
257
|
+
super(message);
|
|
258
|
+
this.name = 'ToolError';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { LuiBridge, ToolError };
|