palmier 0.7.2 → 0.7.3
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 +19 -8
- package/dist/commands/serve.js +14 -1
- package/dist/mcp-handler.js +4 -1
- package/dist/mcp-tools.js +393 -3
- package/dist/pwa/assets/{index-C6Lz09EY.css → index-B-ByUHPS.css} +1 -1
- package/dist/pwa/assets/index-CPIqbV9-.js +118 -0
- package/dist/pwa/assets/{web-HDs03L2B.js → web-Dwi8DLNK.js} +1 -1
- package/dist/pwa/assets/{web-CBI458eN.js → web-SlBB3mP3.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/sms-store.d.ts +11 -0
- package/dist/sms-store.js +19 -0
- package/dist/transports/http-transport.js +16 -1
- package/package.json +1 -1
- package/palmier-server/README.md +11 -3
- package/palmier-server/pwa/src/App.css +3 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +351 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/server/src/index.ts +301 -0
- package/palmier-server/server/src/routes/device.ts +168 -0
- package/palmier-server/spec.md +32 -3
- package/src/commands/serve.ts +14 -1
- package/src/mcp-handler.ts +4 -1
- package/src/mcp-tools.ts +451 -3
- package/src/sms-store.ts +28 -0
- package/src/transports/http-transport.ts +16 -1
- package/test/agent-instructions.test.ts +1 -1
- package/dist/pwa/assets/index-DLxrL0hR.js +0 -118
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ It runs on your machine as a background daemon and connects to a mobile-friendly
|
|
|
34
34
|
|
|
35
35
|
## How It Works
|
|
36
36
|
|
|
37
|
-
Palmier runs as a background daemon (systemd on Linux, Task Scheduler on Windows). It invokes your agent CLIs directly, schedules tasks via native OS timers, and exposes an API that the PWA connects to — either directly over HTTP or remotely through a relay server. Agents can interact with the user's mobile device during execution — requesting input, sending push notifications,
|
|
37
|
+
Palmier runs as a background daemon (systemd on Linux, Task Scheduler on Windows). It invokes your agent CLIs directly, schedules tasks via native OS timers, and exposes an API that the PWA connects to — either directly over HTTP or remotely through a relay server. Agents can interact with the user's mobile device during execution — requesting input, sending push notifications, reading SMS/notifications, managing contacts and calendar, setting alarms, and more.
|
|
38
38
|
|
|
39
39
|
### MCP Server
|
|
40
40
|
|
|
@@ -43,19 +43,30 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
|
|
|
43
43
|
**MCP server URL:** `http://localhost:<port>/mcp`
|
|
44
44
|
|
|
45
45
|
**Available tools:**
|
|
46
|
-
| Tool | Description |
|
|
47
|
-
|
|
48
|
-
| `notify` | Send a push notification to the user's device |
|
|
49
|
-
| `request-input` | Request input from the user (blocks until response) |
|
|
50
|
-
| `request-confirmation` | Request confirmation from the user (blocks until response) |
|
|
51
|
-
| `device-geolocation` | Get GPS location of the user's mobile device |
|
|
46
|
+
| Tool | Description | Permission |
|
|
47
|
+
|------|-------------|------------|
|
|
48
|
+
| `notify` | Send a push notification to the user's device | None |
|
|
49
|
+
| `request-input` | Request input from the user (blocks until response) | None |
|
|
50
|
+
| `request-confirmation` | Request confirmation from the user (blocks until response) | None |
|
|
51
|
+
| `device-geolocation` | Get GPS location of the user's mobile device | Location Access |
|
|
52
|
+
| `read-contacts` | Read the contact list from the user's device | Contacts Access |
|
|
53
|
+
| `create-contact` | Create a new contact on the user's device | Contacts Access |
|
|
54
|
+
| `read-calendar` | Read calendar events (with time range filter) | Calendar Access |
|
|
55
|
+
| `create-calendar-event` | Create a calendar event on the user's device | Calendar Access |
|
|
56
|
+
| `send-sms` | Send an SMS message from the user's device | SMS Access |
|
|
57
|
+
| `set-alarm` | Set an alarm on the user's device | None |
|
|
58
|
+
| `read-battery` | Get battery level and charging status | None |
|
|
59
|
+
| `set-ringer-mode` | Set ringer mode (normal/vibrate/silent) | Do Not Disturb Control |
|
|
52
60
|
|
|
53
61
|
**Available resources:**
|
|
54
62
|
| Resource | URI | REST | Description |
|
|
55
63
|
|----------|-----|------|-------------|
|
|
56
64
|
| Device Notifications | `notifications://device` | `GET /notifications` | Recent notifications from the user's Android device |
|
|
65
|
+
| Device SMS | `sms://device` | `GET /sms` | Recent SMS messages from the user's Android device |
|
|
57
66
|
|
|
58
|
-
Resources support MCP subscriptions — clients can subscribe via `resources/subscribe` and receive real-time `notifications/resources/updated` events via the streamable HTTP transport when the resource changes.
|
|
67
|
+
Resources support MCP subscriptions — clients can subscribe via `resources/subscribe` and receive real-time `notifications/resources/updated` events via the streamable HTTP transport when the resource changes.
|
|
68
|
+
|
|
69
|
+
All device tools work while the Palmier Android app is in the background — they communicate via FCM data messages which wake the app's service even when it's not in the foreground. Permissions listed above must be granted via toggles in the Android app's settings menu.
|
|
59
70
|
|
|
60
71
|
```
|
|
61
72
|
┌──────────────┐ HTTP ┌──────────────────┐
|
package/dist/commands/serve.js
CHANGED
|
@@ -13,6 +13,7 @@ import { saveConfig } from "../config.js";
|
|
|
13
13
|
import { CONFIG_DIR } from "../config.js";
|
|
14
14
|
import { StringCodec } from "nats";
|
|
15
15
|
import { addNotification } from "../notification-store.js";
|
|
16
|
+
import { addSmsMessage } from "../sms-store.js";
|
|
16
17
|
const POLL_INTERVAL_MS = 30_000;
|
|
17
18
|
const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
|
|
18
19
|
/**
|
|
@@ -116,7 +117,7 @@ export async function serveCommand() {
|
|
|
116
117
|
// Start NATS transport (loops forever, fire-and-forget)
|
|
117
118
|
if (nc) {
|
|
118
119
|
startNatsTransport(config, handleRpc, nc);
|
|
119
|
-
// Subscribe to device notifications from Android
|
|
120
|
+
// Subscribe to device notifications and SMS from Android
|
|
120
121
|
const sc = StringCodec();
|
|
121
122
|
const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
|
|
122
123
|
(async () => {
|
|
@@ -130,6 +131,18 @@ export async function serveCommand() {
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
})();
|
|
134
|
+
const smsSub = nc.subscribe(`host.${config.hostId}.device.sms`);
|
|
135
|
+
(async () => {
|
|
136
|
+
for await (const msg of smsSub) {
|
|
137
|
+
try {
|
|
138
|
+
const data = JSON.parse(sc.decode(msg.data));
|
|
139
|
+
addSmsMessage({ ...data, receivedAt: Date.now() });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error("[nats] Failed to parse device SMS:", err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
133
146
|
}
|
|
134
147
|
// Start HTTP transport (loops forever)
|
|
135
148
|
await startHttpTransport(config, handleRpc, httpPort, nc);
|
package/dist/mcp-handler.js
CHANGED
|
@@ -127,12 +127,15 @@ export async function handleMcpRequest(body, sessionId, ctx) {
|
|
|
127
127
|
if (!resource) {
|
|
128
128
|
return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
|
|
129
129
|
}
|
|
130
|
+
console.log(`${logPrefix} resources/read ${uri}`);
|
|
131
|
+
const content = resource.read();
|
|
132
|
+
console.log(`${logPrefix} resources/read ${uri} done: ${JSON.stringify(content).slice(0, 200)}`);
|
|
130
133
|
return {
|
|
131
134
|
body: rpcResult(id, {
|
|
132
135
|
contents: [{
|
|
133
136
|
uri: resource.uri,
|
|
134
137
|
mimeType: resource.mimeType,
|
|
135
|
-
text: JSON.stringify(
|
|
138
|
+
text: JSON.stringify(content),
|
|
136
139
|
}],
|
|
137
140
|
}),
|
|
138
141
|
};
|
package/dist/mcp-tools.js
CHANGED
|
@@ -2,6 +2,7 @@ import { StringCodec } from "nats";
|
|
|
2
2
|
import { registerPending } from "./pending-requests.js";
|
|
3
3
|
import { getLocationDevice } from "./location-device.js";
|
|
4
4
|
import { getNotifications } from "./notification-store.js";
|
|
5
|
+
import { getSmsMessages } from "./sms-store.js";
|
|
5
6
|
export class ToolError extends Error {
|
|
6
7
|
statusCode;
|
|
7
8
|
constructor(message, statusCode = 500) {
|
|
@@ -171,7 +172,385 @@ const deviceGeolocationTool = {
|
|
|
171
172
|
return locationData;
|
|
172
173
|
},
|
|
173
174
|
};
|
|
174
|
-
|
|
175
|
+
const readContactsTool = {
|
|
176
|
+
name: "read-contacts",
|
|
177
|
+
description: [
|
|
178
|
+
"Read the contact list from the user's mobile device.",
|
|
179
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
180
|
+
'Response: `{"contacts": [{"id": ..., "name": ..., "phone": ...}]}` on success, or `{"error": "..."}` on failure.',
|
|
181
|
+
],
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: "object",
|
|
184
|
+
properties: {},
|
|
185
|
+
},
|
|
186
|
+
async handler(_args, ctx) {
|
|
187
|
+
if (!ctx.nc)
|
|
188
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
189
|
+
const sc = StringCodec();
|
|
190
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, action: "read" })), { timeout: 5_000 });
|
|
191
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
192
|
+
if (ack.error)
|
|
193
|
+
throw new ToolError(ack.error, 502);
|
|
194
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
195
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
sub.unsubscribe();
|
|
198
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
199
|
+
}, 30_000);
|
|
200
|
+
(async () => {
|
|
201
|
+
for await (const msg of sub) {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
resolve(sc.decode(msg.data));
|
|
204
|
+
}
|
|
205
|
+
})();
|
|
206
|
+
});
|
|
207
|
+
const result = JSON.parse(await responsePromise);
|
|
208
|
+
if (result.error)
|
|
209
|
+
return { error: result.error };
|
|
210
|
+
return result;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
const createContactTool = {
|
|
214
|
+
name: "create-contact",
|
|
215
|
+
description: [
|
|
216
|
+
"Create a new contact on the user's mobile device.",
|
|
217
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
218
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
219
|
+
],
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
name: { type: "string", description: "Contact display name" },
|
|
224
|
+
phone: { type: "string", description: "Phone number" },
|
|
225
|
+
email: { type: "string", description: "Email address" },
|
|
226
|
+
},
|
|
227
|
+
required: ["name"],
|
|
228
|
+
},
|
|
229
|
+
async handler(args, ctx) {
|
|
230
|
+
if (!ctx.nc)
|
|
231
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
232
|
+
const { name, phone, email } = args;
|
|
233
|
+
if (!name)
|
|
234
|
+
throw new ToolError("name is required", 400);
|
|
235
|
+
const sc = StringCodec();
|
|
236
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({
|
|
237
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId,
|
|
238
|
+
action: "create", name, phone, email,
|
|
239
|
+
})), { timeout: 5_000 });
|
|
240
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
241
|
+
if (ack.error)
|
|
242
|
+
throw new ToolError(ack.error, 502);
|
|
243
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
244
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
|
|
245
|
+
const timer = setTimeout(() => {
|
|
246
|
+
sub.unsubscribe();
|
|
247
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
248
|
+
}, 30_000);
|
|
249
|
+
(async () => {
|
|
250
|
+
for await (const msg of sub) {
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
resolve(sc.decode(msg.data));
|
|
253
|
+
}
|
|
254
|
+
})();
|
|
255
|
+
});
|
|
256
|
+
const result = JSON.parse(await responsePromise);
|
|
257
|
+
if (result.error)
|
|
258
|
+
return { error: result.error };
|
|
259
|
+
return result;
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const readCalendarTool = {
|
|
263
|
+
name: "read-calendar",
|
|
264
|
+
description: [
|
|
265
|
+
"Read calendar events from the user's mobile device.",
|
|
266
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
267
|
+
"Pass startDate and endDate as Unix timestamps in milliseconds. Defaults to next 7 days.",
|
|
268
|
+
'Response: `{"events": [{"id": ..., "title": ..., "startTime": ..., "endTime": ..., "location": ..., "description": ..., "allDay": ..., "calendar": ...}]}` on success.',
|
|
269
|
+
],
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {
|
|
273
|
+
startDate: { type: "number", description: "Start of range (Unix ms). Defaults to now." },
|
|
274
|
+
endDate: { type: "number", description: "End of range (Unix ms). Defaults to 7 days from start." },
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
async handler(args, ctx) {
|
|
278
|
+
if (!ctx.nc)
|
|
279
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
280
|
+
const { startDate, endDate } = args;
|
|
281
|
+
const sc = StringCodec();
|
|
282
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
|
|
283
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId,
|
|
284
|
+
action: "read",
|
|
285
|
+
...(startDate ? { startDate: String(startDate) } : {}),
|
|
286
|
+
...(endDate ? { endDate: String(endDate) } : {}),
|
|
287
|
+
})), { timeout: 5_000 });
|
|
288
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
289
|
+
if (ack.error)
|
|
290
|
+
throw new ToolError(ack.error, 502);
|
|
291
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
292
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
|
|
293
|
+
const timer = setTimeout(() => {
|
|
294
|
+
sub.unsubscribe();
|
|
295
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
296
|
+
}, 30_000);
|
|
297
|
+
(async () => {
|
|
298
|
+
for await (const msg of sub) {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
resolve(sc.decode(msg.data));
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
});
|
|
304
|
+
const result = JSON.parse(await responsePromise);
|
|
305
|
+
if (result.error)
|
|
306
|
+
return { error: result.error };
|
|
307
|
+
return result;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
const createCalendarEventTool = {
|
|
311
|
+
name: "create-calendar-event",
|
|
312
|
+
description: [
|
|
313
|
+
"Create a calendar event on the user's mobile device.",
|
|
314
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
315
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
316
|
+
],
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: "object",
|
|
319
|
+
properties: {
|
|
320
|
+
title: { type: "string", description: "Event title" },
|
|
321
|
+
startTime: { type: "number", description: "Start time (Unix ms)" },
|
|
322
|
+
endTime: { type: "number", description: "End time (Unix ms)" },
|
|
323
|
+
location: { type: "string", description: "Event location" },
|
|
324
|
+
description: { type: "string", description: "Event description" },
|
|
325
|
+
},
|
|
326
|
+
required: ["title", "startTime", "endTime"],
|
|
327
|
+
},
|
|
328
|
+
async handler(args, ctx) {
|
|
329
|
+
if (!ctx.nc)
|
|
330
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
331
|
+
const { title, startTime, endTime, location, description } = args;
|
|
332
|
+
if (!title || !startTime || !endTime)
|
|
333
|
+
throw new ToolError("title, startTime, and endTime are required", 400);
|
|
334
|
+
const sc = StringCodec();
|
|
335
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
|
|
336
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId,
|
|
337
|
+
action: "create",
|
|
338
|
+
title, startTime: String(startTime), endTime: String(endTime),
|
|
339
|
+
...(location ? { location } : {}),
|
|
340
|
+
...(description ? { description } : {}),
|
|
341
|
+
})), { timeout: 5_000 });
|
|
342
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
343
|
+
if (ack.error)
|
|
344
|
+
throw new ToolError(ack.error, 502);
|
|
345
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
346
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
|
|
347
|
+
const timer = setTimeout(() => {
|
|
348
|
+
sub.unsubscribe();
|
|
349
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
350
|
+
}, 30_000);
|
|
351
|
+
(async () => {
|
|
352
|
+
for await (const msg of sub) {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
resolve(sc.decode(msg.data));
|
|
355
|
+
}
|
|
356
|
+
})();
|
|
357
|
+
});
|
|
358
|
+
const result = JSON.parse(await responsePromise);
|
|
359
|
+
if (result.error)
|
|
360
|
+
return { error: result.error };
|
|
361
|
+
return result;
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
const sendSmsTool = {
|
|
365
|
+
name: "send-sms",
|
|
366
|
+
description: [
|
|
367
|
+
"Send an SMS message from the user's mobile device.",
|
|
368
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
369
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
370
|
+
],
|
|
371
|
+
inputSchema: {
|
|
372
|
+
type: "object",
|
|
373
|
+
properties: {
|
|
374
|
+
to: { type: "string", description: "Recipient phone number" },
|
|
375
|
+
body: { type: "string", description: "Message text" },
|
|
376
|
+
},
|
|
377
|
+
required: ["to", "body"],
|
|
378
|
+
},
|
|
379
|
+
async handler(args, ctx) {
|
|
380
|
+
if (!ctx.nc)
|
|
381
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
382
|
+
const { to, body } = args;
|
|
383
|
+
if (!to || !body)
|
|
384
|
+
throw new ToolError("to and body are required", 400);
|
|
385
|
+
const sc = StringCodec();
|
|
386
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.sms`, sc.encode(JSON.stringify({
|
|
387
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId,
|
|
388
|
+
action: "send", to, body,
|
|
389
|
+
})), { timeout: 5_000 });
|
|
390
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
391
|
+
if (ack.error)
|
|
392
|
+
throw new ToolError(ack.error, 502);
|
|
393
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
394
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.sms.${ctx.sessionId}`, { max: 1 });
|
|
395
|
+
const timer = setTimeout(() => {
|
|
396
|
+
sub.unsubscribe();
|
|
397
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
398
|
+
}, 30_000);
|
|
399
|
+
(async () => {
|
|
400
|
+
for await (const msg of sub) {
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
resolve(sc.decode(msg.data));
|
|
403
|
+
}
|
|
404
|
+
})();
|
|
405
|
+
});
|
|
406
|
+
const result = JSON.parse(await responsePromise);
|
|
407
|
+
if (result.error)
|
|
408
|
+
return { error: result.error };
|
|
409
|
+
return result;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
const setAlarmTool = {
|
|
413
|
+
name: "set-alarm",
|
|
414
|
+
description: [
|
|
415
|
+
"Set an alarm on the user's mobile device.",
|
|
416
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
417
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
418
|
+
],
|
|
419
|
+
inputSchema: {
|
|
420
|
+
type: "object",
|
|
421
|
+
properties: {
|
|
422
|
+
hour: { type: "number", description: "Hour (0-23)" },
|
|
423
|
+
minutes: { type: "number", description: "Minutes (0-59)" },
|
|
424
|
+
label: { type: "string", description: "Alarm label" },
|
|
425
|
+
days: {
|
|
426
|
+
type: "array",
|
|
427
|
+
items: { type: "number" },
|
|
428
|
+
description: "Recurring days (1=Sun, 2=Mon, ..., 7=Sat). Omit for one-time.",
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
required: ["hour", "minutes"],
|
|
432
|
+
},
|
|
433
|
+
async handler(args, ctx) {
|
|
434
|
+
if (!ctx.nc)
|
|
435
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
436
|
+
const { hour, minutes, label, days } = args;
|
|
437
|
+
if (hour == null || minutes == null)
|
|
438
|
+
throw new ToolError("hour and minutes are required", 400);
|
|
439
|
+
const sc = StringCodec();
|
|
440
|
+
const payload = {
|
|
441
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId,
|
|
442
|
+
action: "set", hour: String(hour), minutes: String(minutes),
|
|
443
|
+
};
|
|
444
|
+
if (label)
|
|
445
|
+
payload.label = label;
|
|
446
|
+
if (days?.length)
|
|
447
|
+
payload.days = days.join(",");
|
|
448
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alarm`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
|
|
449
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
450
|
+
if (ack.error)
|
|
451
|
+
throw new ToolError(ack.error, 502);
|
|
452
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
453
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alarm.${ctx.sessionId}`, { max: 1 });
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
sub.unsubscribe();
|
|
456
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
457
|
+
}, 30_000);
|
|
458
|
+
(async () => {
|
|
459
|
+
for await (const msg of sub) {
|
|
460
|
+
clearTimeout(timer);
|
|
461
|
+
resolve(sc.decode(msg.data));
|
|
462
|
+
}
|
|
463
|
+
})();
|
|
464
|
+
});
|
|
465
|
+
const result = JSON.parse(await responsePromise);
|
|
466
|
+
if (result.error)
|
|
467
|
+
return { error: result.error };
|
|
468
|
+
return result;
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
const readBatteryTool = {
|
|
472
|
+
name: "read-battery",
|
|
473
|
+
description: [
|
|
474
|
+
"Get the battery level and charging status of the user's mobile device.",
|
|
475
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
476
|
+
'Response: `{"level": 85, "charging": true}` on success, or `{"error": "..."}` on failure.',
|
|
477
|
+
],
|
|
478
|
+
inputSchema: {
|
|
479
|
+
type: "object",
|
|
480
|
+
properties: {},
|
|
481
|
+
},
|
|
482
|
+
async handler(_args, ctx) {
|
|
483
|
+
if (!ctx.nc)
|
|
484
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
485
|
+
const sc = StringCodec();
|
|
486
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId })), { timeout: 5_000 });
|
|
487
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
488
|
+
if (ack.error)
|
|
489
|
+
throw new ToolError(ack.error, 502);
|
|
490
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
491
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.battery.${ctx.sessionId}`, { max: 1 });
|
|
492
|
+
const timer = setTimeout(() => {
|
|
493
|
+
sub.unsubscribe();
|
|
494
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
495
|
+
}, 30_000);
|
|
496
|
+
(async () => {
|
|
497
|
+
for await (const msg of sub) {
|
|
498
|
+
clearTimeout(timer);
|
|
499
|
+
resolve(sc.decode(msg.data));
|
|
500
|
+
}
|
|
501
|
+
})();
|
|
502
|
+
});
|
|
503
|
+
const result = JSON.parse(await responsePromise);
|
|
504
|
+
if (result.error)
|
|
505
|
+
return { error: result.error };
|
|
506
|
+
return result;
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
const setRingerModeTool = {
|
|
510
|
+
name: "set-ringer-mode",
|
|
511
|
+
description: [
|
|
512
|
+
"Set the phone's ringer mode. Requires Do Not Disturb access on the device.",
|
|
513
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
514
|
+
'Response: `{"ok": true, "mode": "silent"}` on success, or `{"error": "..."}` on failure.',
|
|
515
|
+
],
|
|
516
|
+
inputSchema: {
|
|
517
|
+
type: "object",
|
|
518
|
+
properties: {
|
|
519
|
+
mode: { type: "string", description: "Ringer mode: 'normal', 'vibrate', or 'silent'" },
|
|
520
|
+
},
|
|
521
|
+
required: ["mode"],
|
|
522
|
+
},
|
|
523
|
+
async handler(args, ctx) {
|
|
524
|
+
if (!ctx.nc)
|
|
525
|
+
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
526
|
+
const { mode } = args;
|
|
527
|
+
if (!["normal", "vibrate", "silent"].includes(mode))
|
|
528
|
+
throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
|
|
529
|
+
const sc = StringCodec();
|
|
530
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.ringer`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, mode })), { timeout: 5_000 });
|
|
531
|
+
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
532
|
+
if (ack.error)
|
|
533
|
+
throw new ToolError(ack.error, 502);
|
|
534
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
535
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.ringer.${ctx.sessionId}`, { max: 1 });
|
|
536
|
+
const timer = setTimeout(() => {
|
|
537
|
+
sub.unsubscribe();
|
|
538
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
539
|
+
}, 30_000);
|
|
540
|
+
(async () => {
|
|
541
|
+
for await (const msg of sub) {
|
|
542
|
+
clearTimeout(timer);
|
|
543
|
+
resolve(sc.decode(msg.data));
|
|
544
|
+
}
|
|
545
|
+
})();
|
|
546
|
+
});
|
|
547
|
+
const result = JSON.parse(await responsePromise);
|
|
548
|
+
if (result.error)
|
|
549
|
+
return { error: result.error };
|
|
550
|
+
return result;
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, setAlarmTool, readBatteryTool, setRingerModeTool];
|
|
175
554
|
export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
|
|
176
555
|
const deviceNotificationsResource = {
|
|
177
556
|
uri: "notifications://device",
|
|
@@ -184,7 +563,18 @@ const deviceNotificationsResource = {
|
|
|
184
563
|
restPath: "/notifications",
|
|
185
564
|
read: getNotifications,
|
|
186
565
|
};
|
|
187
|
-
|
|
566
|
+
const deviceSmsResource = {
|
|
567
|
+
uri: "sms://device",
|
|
568
|
+
name: "Device SMS",
|
|
569
|
+
description: [
|
|
570
|
+
"Get recent SMS messages from the user's Android device.",
|
|
571
|
+
"Response: JSON array of message objects with `id`, `sender`, `body`, `timestamp`.",
|
|
572
|
+
],
|
|
573
|
+
mimeType: "application/json",
|
|
574
|
+
restPath: "/sms",
|
|
575
|
+
read: getSmsMessages,
|
|
576
|
+
};
|
|
577
|
+
export const agentResources = [deviceNotificationsResource, deviceSmsResource];
|
|
188
578
|
export const agentResourceMap = new Map(agentResources.map((r) => [r.uri, r]));
|
|
189
579
|
/**
|
|
190
580
|
* Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
|
|
@@ -229,7 +619,7 @@ export function generateEndpointDocs(port, taskId, tools = agentTools, resources
|
|
|
229
619
|
}
|
|
230
620
|
for (const resource of resources) {
|
|
231
621
|
const [header, ...details] = resource.description;
|
|
232
|
-
lines.push(`**\`GET ${baseUrl}${resource.restPath}\`** — ${header}`);
|
|
622
|
+
lines.push(`**\`GET ${baseUrl}${resource.restPath}?taskId=${taskId}\`** — ${header}`);
|
|
233
623
|
for (const detail of details) {
|
|
234
624
|
lines.push(`- ${detail}`);
|
|
235
625
|
}
|