palmier 0.7.2 → 0.7.4
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 +43 -22
- package/dist/commands/serve.js +14 -1
- package/dist/device-capabilities.d.ts +9 -0
- package/dist/device-capabilities.js +36 -0
- package/dist/mcp-handler.js +4 -1
- package/dist/mcp-tools.js +414 -7
- package/dist/pwa/assets/{index-C6Lz09EY.css → index-B-ByUHPS.css} +1 -1
- package/dist/pwa/assets/index-BirmfPUC.js +118 -0
- package/dist/pwa/assets/{web-HDs03L2B.js → web-Dc9-IiRD.js} +1 -1
- package/dist/pwa/assets/{web-CBI458eN.js → web-_b3Dvcvz.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +19 -4
- 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 +465 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/server/src/index.ts +306 -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/device-capabilities.ts +55 -0
- package/src/mcp-handler.ts +4 -1
- package/src/mcp-tools.ts +473 -7
- package/src/rpc-handler.ts +19 -4
- 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/location-device.d.ts +0 -8
- package/dist/location-device.js +0 -32
- package/dist/pwa/assets/index-DLxrL0hR.js +0 -118
- package/src/location-device.ts +0 -35
package/src/mcp-tools.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { StringCodec, type NatsConnection } from "nats";
|
|
2
2
|
import { registerPending } from "./pending-requests.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getCapabilityDevice } from "./device-capabilities.js";
|
|
4
4
|
import { getNotifications } from "./notification-store.js";
|
|
5
|
+
import { getSmsMessages } from "./sms-store.js";
|
|
5
6
|
import type { HostConfig } from "./types.js";
|
|
6
7
|
|
|
7
8
|
export class ToolError extends Error {
|
|
@@ -169,14 +170,14 @@ const deviceGeolocationTool: ToolDefinition = {
|
|
|
169
170
|
async handler(_args, ctx) {
|
|
170
171
|
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
171
172
|
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
173
|
+
const device = getCapabilityDevice("location");
|
|
174
|
+
if (!device) throw new ToolError("No device has location access enabled", 400);
|
|
174
175
|
|
|
175
176
|
const sc = StringCodec();
|
|
176
177
|
|
|
177
178
|
const ackReply = await ctx.nc.request(
|
|
178
179
|
`host.${ctx.config.hostId}.fcm.geolocation`,
|
|
179
|
-
sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken:
|
|
180
|
+
sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })),
|
|
180
181
|
{ timeout: 5_000 },
|
|
181
182
|
);
|
|
182
183
|
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
@@ -203,7 +204,460 @@ const deviceGeolocationTool: ToolDefinition = {
|
|
|
203
204
|
},
|
|
204
205
|
};
|
|
205
206
|
|
|
206
|
-
|
|
207
|
+
const readContactsTool: ToolDefinition = {
|
|
208
|
+
name: "read-contacts",
|
|
209
|
+
description: [
|
|
210
|
+
"Read the contact list from the user's mobile device.",
|
|
211
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
212
|
+
'Response: `{"contacts": [{"id": ..., "name": ..., "phone": ...}]}` on success, or `{"error": "..."}` on failure.',
|
|
213
|
+
],
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: {},
|
|
217
|
+
},
|
|
218
|
+
async handler(_args, ctx) {
|
|
219
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
220
|
+
|
|
221
|
+
const device = getCapabilityDevice("contacts");
|
|
222
|
+
if (!device) throw new ToolError("No device has contacts access enabled", 400);
|
|
223
|
+
|
|
224
|
+
const sc = StringCodec();
|
|
225
|
+
|
|
226
|
+
const ackReply = await ctx.nc.request(
|
|
227
|
+
`host.${ctx.config.hostId}.fcm.contacts`,
|
|
228
|
+
sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, action: "read" })),
|
|
229
|
+
{ timeout: 5_000 },
|
|
230
|
+
);
|
|
231
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
232
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
233
|
+
|
|
234
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
235
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
|
|
236
|
+
const timer = setTimeout(() => {
|
|
237
|
+
sub.unsubscribe();
|
|
238
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
239
|
+
}, 30_000);
|
|
240
|
+
|
|
241
|
+
(async () => {
|
|
242
|
+
for await (const msg of sub) {
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
resolve(sc.decode(msg.data));
|
|
245
|
+
}
|
|
246
|
+
})();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const result = JSON.parse(await responsePromise);
|
|
250
|
+
if (result.error) return { error: result.error };
|
|
251
|
+
return result;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const createContactTool: ToolDefinition = {
|
|
256
|
+
name: "create-contact",
|
|
257
|
+
description: [
|
|
258
|
+
"Create a new contact on the user's mobile device.",
|
|
259
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
260
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
261
|
+
],
|
|
262
|
+
inputSchema: {
|
|
263
|
+
type: "object",
|
|
264
|
+
properties: {
|
|
265
|
+
name: { type: "string", description: "Contact display name" },
|
|
266
|
+
phone: { type: "string", description: "Phone number" },
|
|
267
|
+
email: { type: "string", description: "Email address" },
|
|
268
|
+
},
|
|
269
|
+
required: ["name"],
|
|
270
|
+
},
|
|
271
|
+
async handler(args, ctx) {
|
|
272
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
273
|
+
|
|
274
|
+
const device = getCapabilityDevice("contacts");
|
|
275
|
+
if (!device) throw new ToolError("No device has contacts access enabled", 400);
|
|
276
|
+
|
|
277
|
+
const { name, phone, email } = args as { name: string; phone?: string; email?: string };
|
|
278
|
+
if (!name) throw new ToolError("name is required", 400);
|
|
279
|
+
|
|
280
|
+
const sc = StringCodec();
|
|
281
|
+
|
|
282
|
+
const ackReply = await ctx.nc.request(
|
|
283
|
+
`host.${ctx.config.hostId}.fcm.contacts`,
|
|
284
|
+
sc.encode(JSON.stringify({
|
|
285
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
|
|
286
|
+
action: "create", name, phone, email,
|
|
287
|
+
})),
|
|
288
|
+
{ timeout: 5_000 },
|
|
289
|
+
);
|
|
290
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
291
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
292
|
+
|
|
293
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
294
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.sessionId}`, { max: 1 });
|
|
295
|
+
const timer = setTimeout(() => {
|
|
296
|
+
sub.unsubscribe();
|
|
297
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
298
|
+
}, 30_000);
|
|
299
|
+
|
|
300
|
+
(async () => {
|
|
301
|
+
for await (const msg of sub) {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
resolve(sc.decode(msg.data));
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const result = JSON.parse(await responsePromise);
|
|
309
|
+
if (result.error) return { error: result.error };
|
|
310
|
+
return result;
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const readCalendarTool: ToolDefinition = {
|
|
315
|
+
name: "read-calendar",
|
|
316
|
+
description: [
|
|
317
|
+
"Read calendar events from the user's mobile device.",
|
|
318
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
319
|
+
"Pass startDate and endDate as Unix timestamps in milliseconds. Defaults to next 7 days.",
|
|
320
|
+
'Response: `{"events": [{"id": ..., "title": ..., "startTime": ..., "endTime": ..., "location": ..., "description": ..., "allDay": ..., "calendar": ...}]}` on success.',
|
|
321
|
+
],
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
startDate: { type: "number", description: "Start of range (Unix ms). Defaults to now." },
|
|
326
|
+
endDate: { type: "number", description: "End of range (Unix ms). Defaults to 7 days from start." },
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
async handler(args, ctx) {
|
|
330
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
331
|
+
|
|
332
|
+
const device = getCapabilityDevice("calendar");
|
|
333
|
+
if (!device) throw new ToolError("No device has calendar access enabled", 400);
|
|
334
|
+
|
|
335
|
+
const { startDate, endDate } = args as { startDate?: number; endDate?: number };
|
|
336
|
+
const sc = StringCodec();
|
|
337
|
+
|
|
338
|
+
const ackReply = await ctx.nc.request(
|
|
339
|
+
`host.${ctx.config.hostId}.fcm.calendar`,
|
|
340
|
+
sc.encode(JSON.stringify({
|
|
341
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
|
|
342
|
+
action: "read",
|
|
343
|
+
...(startDate ? { startDate: String(startDate) } : {}),
|
|
344
|
+
...(endDate ? { endDate: String(endDate) } : {}),
|
|
345
|
+
})),
|
|
346
|
+
{ timeout: 5_000 },
|
|
347
|
+
);
|
|
348
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
349
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
350
|
+
|
|
351
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
352
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
|
|
353
|
+
const timer = setTimeout(() => {
|
|
354
|
+
sub.unsubscribe();
|
|
355
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
356
|
+
}, 30_000);
|
|
357
|
+
|
|
358
|
+
(async () => {
|
|
359
|
+
for await (const msg of sub) {
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
resolve(sc.decode(msg.data));
|
|
362
|
+
}
|
|
363
|
+
})();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const result = JSON.parse(await responsePromise);
|
|
367
|
+
if (result.error) return { error: result.error };
|
|
368
|
+
return result;
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const createCalendarEventTool: ToolDefinition = {
|
|
373
|
+
name: "create-calendar-event",
|
|
374
|
+
description: [
|
|
375
|
+
"Create a calendar event on the user's mobile device.",
|
|
376
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
377
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
378
|
+
],
|
|
379
|
+
inputSchema: {
|
|
380
|
+
type: "object",
|
|
381
|
+
properties: {
|
|
382
|
+
title: { type: "string", description: "Event title" },
|
|
383
|
+
startTime: { type: "number", description: "Start time (Unix ms)" },
|
|
384
|
+
endTime: { type: "number", description: "End time (Unix ms)" },
|
|
385
|
+
location: { type: "string", description: "Event location" },
|
|
386
|
+
description: { type: "string", description: "Event description" },
|
|
387
|
+
},
|
|
388
|
+
required: ["title", "startTime", "endTime"],
|
|
389
|
+
},
|
|
390
|
+
async handler(args, ctx) {
|
|
391
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
392
|
+
|
|
393
|
+
const device = getCapabilityDevice("calendar");
|
|
394
|
+
if (!device) throw new ToolError("No device has calendar access enabled", 400);
|
|
395
|
+
|
|
396
|
+
const { title, startTime, endTime, location, description } = args as {
|
|
397
|
+
title: string; startTime: number; endTime: number; location?: string; description?: string;
|
|
398
|
+
};
|
|
399
|
+
if (!title || !startTime || !endTime) throw new ToolError("title, startTime, and endTime are required", 400);
|
|
400
|
+
|
|
401
|
+
const sc = StringCodec();
|
|
402
|
+
|
|
403
|
+
const ackReply = await ctx.nc.request(
|
|
404
|
+
`host.${ctx.config.hostId}.fcm.calendar`,
|
|
405
|
+
sc.encode(JSON.stringify({
|
|
406
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
|
|
407
|
+
action: "create",
|
|
408
|
+
title, startTime: String(startTime), endTime: String(endTime),
|
|
409
|
+
...(location ? { location } : {}),
|
|
410
|
+
...(description ? { description } : {}),
|
|
411
|
+
})),
|
|
412
|
+
{ timeout: 5_000 },
|
|
413
|
+
);
|
|
414
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
415
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
416
|
+
|
|
417
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
418
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.sessionId}`, { max: 1 });
|
|
419
|
+
const timer = setTimeout(() => {
|
|
420
|
+
sub.unsubscribe();
|
|
421
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
422
|
+
}, 30_000);
|
|
423
|
+
|
|
424
|
+
(async () => {
|
|
425
|
+
for await (const msg of sub) {
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
resolve(sc.decode(msg.data));
|
|
428
|
+
}
|
|
429
|
+
})();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const result = JSON.parse(await responsePromise);
|
|
433
|
+
if (result.error) return { error: result.error };
|
|
434
|
+
return result;
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const sendSmsTool: ToolDefinition = {
|
|
439
|
+
name: "send-sms-message",
|
|
440
|
+
description: [
|
|
441
|
+
"Send an SMS message from the user's mobile device.",
|
|
442
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
443
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
444
|
+
],
|
|
445
|
+
inputSchema: {
|
|
446
|
+
type: "object",
|
|
447
|
+
properties: {
|
|
448
|
+
to: { type: "string", description: "Recipient phone number" },
|
|
449
|
+
body: { type: "string", description: "Message text" },
|
|
450
|
+
},
|
|
451
|
+
required: ["to", "body"],
|
|
452
|
+
},
|
|
453
|
+
async handler(args, ctx) {
|
|
454
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
455
|
+
|
|
456
|
+
const device = getCapabilityDevice("sms");
|
|
457
|
+
if (!device) throw new ToolError("No device has SMS access enabled", 400);
|
|
458
|
+
|
|
459
|
+
const { to, body } = args as { to: string; body: string };
|
|
460
|
+
if (!to || !body) throw new ToolError("to and body are required", 400);
|
|
461
|
+
|
|
462
|
+
const sc = StringCodec();
|
|
463
|
+
|
|
464
|
+
const ackReply = await ctx.nc.request(
|
|
465
|
+
`host.${ctx.config.hostId}.fcm.sms`,
|
|
466
|
+
sc.encode(JSON.stringify({
|
|
467
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
|
|
468
|
+
action: "send", to, body,
|
|
469
|
+
})),
|
|
470
|
+
{ timeout: 5_000 },
|
|
471
|
+
);
|
|
472
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
473
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
474
|
+
|
|
475
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
476
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.sms.${ctx.sessionId}`, { max: 1 });
|
|
477
|
+
const timer = setTimeout(() => {
|
|
478
|
+
sub.unsubscribe();
|
|
479
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
480
|
+
}, 30_000);
|
|
481
|
+
|
|
482
|
+
(async () => {
|
|
483
|
+
for await (const msg of sub) {
|
|
484
|
+
clearTimeout(timer);
|
|
485
|
+
resolve(sc.decode(msg.data));
|
|
486
|
+
}
|
|
487
|
+
})();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const result = JSON.parse(await responsePromise);
|
|
491
|
+
if (result.error) return { error: result.error };
|
|
492
|
+
return result;
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const sendAlertTool: ToolDefinition = {
|
|
497
|
+
name: "send-alert",
|
|
498
|
+
description: [
|
|
499
|
+
"Send an alert to the user's mobile device with an alarm sound and full-screen popup.",
|
|
500
|
+
"Use this to urgently get the user's attention. The device will play an alarm sound and show a full-screen dialog even on the lock screen.",
|
|
501
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
502
|
+
'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
|
|
503
|
+
],
|
|
504
|
+
inputSchema: {
|
|
505
|
+
type: "object",
|
|
506
|
+
properties: {
|
|
507
|
+
title: { type: "string", description: "Alert title" },
|
|
508
|
+
description: { type: "string", description: "Alert description/details" },
|
|
509
|
+
},
|
|
510
|
+
required: ["title"],
|
|
511
|
+
},
|
|
512
|
+
async handler(args, ctx) {
|
|
513
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
514
|
+
|
|
515
|
+
const device = getCapabilityDevice("alert");
|
|
516
|
+
if (!device) throw new ToolError("No device has alert access enabled", 400);
|
|
517
|
+
|
|
518
|
+
const { title, description } = args as { title: string; description?: string };
|
|
519
|
+
if (!title) throw new ToolError("title is required", 400);
|
|
520
|
+
|
|
521
|
+
const sc = StringCodec();
|
|
522
|
+
|
|
523
|
+
const payload: Record<string, string> = {
|
|
524
|
+
hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
|
|
525
|
+
title,
|
|
526
|
+
};
|
|
527
|
+
if (description) payload.description = description;
|
|
528
|
+
|
|
529
|
+
const ackReply = await ctx.nc.request(
|
|
530
|
+
`host.${ctx.config.hostId}.fcm.alert`,
|
|
531
|
+
sc.encode(JSON.stringify(payload)),
|
|
532
|
+
{ timeout: 5_000 },
|
|
533
|
+
);
|
|
534
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
535
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
536
|
+
|
|
537
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
538
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.alert.${ctx.sessionId}`, { max: 1 });
|
|
539
|
+
const timer = setTimeout(() => {
|
|
540
|
+
sub.unsubscribe();
|
|
541
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
542
|
+
}, 30_000);
|
|
543
|
+
|
|
544
|
+
(async () => {
|
|
545
|
+
for await (const msg of sub) {
|
|
546
|
+
clearTimeout(timer);
|
|
547
|
+
resolve(sc.decode(msg.data));
|
|
548
|
+
}
|
|
549
|
+
})();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const result = JSON.parse(await responsePromise);
|
|
553
|
+
if (result.error) return { error: result.error };
|
|
554
|
+
return result;
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const readBatteryTool: ToolDefinition = {
|
|
559
|
+
name: "read-battery",
|
|
560
|
+
description: [
|
|
561
|
+
"Get the battery level and charging status of the user's mobile device.",
|
|
562
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
563
|
+
'Response: `{"level": 85, "charging": true}` on success, or `{"error": "..."}` on failure.',
|
|
564
|
+
],
|
|
565
|
+
inputSchema: {
|
|
566
|
+
type: "object",
|
|
567
|
+
properties: {},
|
|
568
|
+
},
|
|
569
|
+
async handler(_args, ctx) {
|
|
570
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
571
|
+
|
|
572
|
+
const device = getCapabilityDevice("battery");
|
|
573
|
+
if (!device) throw new ToolError("No device has battery access enabled", 400);
|
|
574
|
+
|
|
575
|
+
const sc = StringCodec();
|
|
576
|
+
|
|
577
|
+
const ackReply = await ctx.nc.request(
|
|
578
|
+
`host.${ctx.config.hostId}.fcm.battery`,
|
|
579
|
+
sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })),
|
|
580
|
+
{ timeout: 5_000 },
|
|
581
|
+
);
|
|
582
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
583
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
584
|
+
|
|
585
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
586
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.battery.${ctx.sessionId}`, { max: 1 });
|
|
587
|
+
const timer = setTimeout(() => {
|
|
588
|
+
sub.unsubscribe();
|
|
589
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
590
|
+
}, 30_000);
|
|
591
|
+
|
|
592
|
+
(async () => {
|
|
593
|
+
for await (const msg of sub) {
|
|
594
|
+
clearTimeout(timer);
|
|
595
|
+
resolve(sc.decode(msg.data));
|
|
596
|
+
}
|
|
597
|
+
})();
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const result = JSON.parse(await responsePromise);
|
|
601
|
+
if (result.error) return { error: result.error };
|
|
602
|
+
return result;
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const setRingerModeTool: ToolDefinition = {
|
|
607
|
+
name: "set-ringer-mode",
|
|
608
|
+
description: [
|
|
609
|
+
"Set the phone's ringer mode. Requires Do Not Disturb access on the device.",
|
|
610
|
+
"Blocks until the device responds (up to 30 seconds).",
|
|
611
|
+
'Response: `{"ok": true, "mode": "silent"}` on success, or `{"error": "..."}` on failure.',
|
|
612
|
+
],
|
|
613
|
+
inputSchema: {
|
|
614
|
+
type: "object",
|
|
615
|
+
properties: {
|
|
616
|
+
mode: { type: "string", description: "Ringer mode: 'normal', 'vibrate', or 'silent'" },
|
|
617
|
+
},
|
|
618
|
+
required: ["mode"],
|
|
619
|
+
},
|
|
620
|
+
async handler(args, ctx) {
|
|
621
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
622
|
+
|
|
623
|
+
const device = getCapabilityDevice("dnd");
|
|
624
|
+
if (!device) throw new ToolError("No device has Do Not Disturb control enabled", 400);
|
|
625
|
+
|
|
626
|
+
const { mode } = args as { mode: string };
|
|
627
|
+
if (!["normal", "vibrate", "silent"].includes(mode)) throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
|
|
628
|
+
|
|
629
|
+
const sc = StringCodec();
|
|
630
|
+
|
|
631
|
+
const ackReply = await ctx.nc.request(
|
|
632
|
+
`host.${ctx.config.hostId}.fcm.ringer`,
|
|
633
|
+
sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, mode })),
|
|
634
|
+
{ timeout: 5_000 },
|
|
635
|
+
);
|
|
636
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
637
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
638
|
+
|
|
639
|
+
const responsePromise = new Promise<string>((resolve, reject) => {
|
|
640
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.ringer.${ctx.sessionId}`, { max: 1 });
|
|
641
|
+
const timer = setTimeout(() => {
|
|
642
|
+
sub.unsubscribe();
|
|
643
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
644
|
+
}, 30_000);
|
|
645
|
+
|
|
646
|
+
(async () => {
|
|
647
|
+
for await (const msg of sub) {
|
|
648
|
+
clearTimeout(timer);
|
|
649
|
+
resolve(sc.decode(msg.data));
|
|
650
|
+
}
|
|
651
|
+
})();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const result = JSON.parse(await responsePromise);
|
|
655
|
+
if (result.error) return { error: result.error };
|
|
656
|
+
return result;
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendAlertTool, readBatteryTool, setRingerModeTool];
|
|
207
661
|
export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
|
|
208
662
|
|
|
209
663
|
// ── MCP Resources ─────────────────────────────────────────────────────
|
|
@@ -234,7 +688,19 @@ const deviceNotificationsResource: ResourceDefinition = {
|
|
|
234
688
|
read: getNotifications,
|
|
235
689
|
};
|
|
236
690
|
|
|
237
|
-
|
|
691
|
+
const deviceSmsResource: ResourceDefinition = {
|
|
692
|
+
uri: "sms-messages://device",
|
|
693
|
+
name: "Device SMS",
|
|
694
|
+
description: [
|
|
695
|
+
"Get recent SMS messages from the user's Android device.",
|
|
696
|
+
"Response: JSON array of message objects with `id`, `sender`, `body`, `timestamp`.",
|
|
697
|
+
],
|
|
698
|
+
mimeType: "application/json",
|
|
699
|
+
restPath: "/sms-messages",
|
|
700
|
+
read: getSmsMessages,
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
export const agentResources: ResourceDefinition[] = [deviceNotificationsResource, deviceSmsResource];
|
|
238
704
|
export const agentResourceMap = new Map<string, ResourceDefinition>(agentResources.map((r) => [r.uri, r]));
|
|
239
705
|
|
|
240
706
|
/**
|
|
@@ -288,7 +754,7 @@ export function generateEndpointDocs(
|
|
|
288
754
|
|
|
289
755
|
for (const resource of resources) {
|
|
290
756
|
const [header, ...details] = resource.description;
|
|
291
|
-
lines.push(`**\`GET ${baseUrl}${resource.restPath}\`** — ${header}`);
|
|
757
|
+
lines.push(`**\`GET ${baseUrl}${resource.restPath}?taskId=${taskId}\`** — ${header}`);
|
|
292
758
|
for (const detail of details) {
|
|
293
759
|
lines.push(`- ${detail}`);
|
|
294
760
|
}
|
package/src/rpc-handler.ts
CHANGED
|
@@ -11,7 +11,7 @@ import crossSpawn from "cross-spawn";
|
|
|
11
11
|
import { getAgent } from "./agents/agent.js";
|
|
12
12
|
import { validateClient } from "./client-store.js";
|
|
13
13
|
import { publishHostEvent } from "./events.js";
|
|
14
|
-
import {
|
|
14
|
+
import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice, type DeviceCapability } from "./device-capabilities.js";
|
|
15
15
|
import { currentVersion, performUpdate } from "./update-checker.js";
|
|
16
16
|
import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
|
|
17
17
|
import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
|
|
@@ -163,7 +163,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
163
163
|
switch (request.method) {
|
|
164
164
|
case "task.list": {
|
|
165
165
|
const tasks = listTasks(config.projectRoot);
|
|
166
|
-
const locDevice =
|
|
166
|
+
const locDevice = getCapabilityDevice("location");
|
|
167
167
|
return {
|
|
168
168
|
tasks: tasks.map((task) => flattenTask(task)),
|
|
169
169
|
agents: config.agents ?? [],
|
|
@@ -652,12 +652,27 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
652
652
|
const params = request.params as { fcmToken: string };
|
|
653
653
|
if (!params.fcmToken) return { error: "fcmToken is required" };
|
|
654
654
|
const clientToken = request.clientToken ?? "";
|
|
655
|
-
|
|
655
|
+
setCapabilityDevice("location", clientToken, params.fcmToken);
|
|
656
656
|
return { ok: true };
|
|
657
657
|
}
|
|
658
658
|
|
|
659
659
|
case "device.location.disable": {
|
|
660
|
-
|
|
660
|
+
clearCapabilityDevice("location");
|
|
661
|
+
return { ok: true };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
case "device.capability.enable": {
|
|
665
|
+
const params = request.params as { capability: DeviceCapability; fcmToken: string };
|
|
666
|
+
if (!params.capability || !params.fcmToken) return { error: "capability and fcmToken are required" };
|
|
667
|
+
const clientToken = request.clientToken ?? "";
|
|
668
|
+
setCapabilityDevice(params.capability, clientToken, params.fcmToken);
|
|
669
|
+
return { ok: true };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
case "device.capability.disable": {
|
|
673
|
+
const params = request.params as { capability: DeviceCapability };
|
|
674
|
+
if (!params.capability) return { error: "capability is required" };
|
|
675
|
+
clearCapabilityDevice(params.capability);
|
|
661
676
|
return { ok: true };
|
|
662
677
|
}
|
|
663
678
|
|
package/src/sms-store.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SmsMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
sender: string;
|
|
4
|
+
body: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
receivedAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MAX_MESSAGES = 50;
|
|
10
|
+
const messages: SmsMessage[] = [];
|
|
11
|
+
const listeners = new Set<() => void>();
|
|
12
|
+
|
|
13
|
+
export function addSmsMessage(m: SmsMessage): void {
|
|
14
|
+
messages.push(m);
|
|
15
|
+
if (messages.length > MAX_MESSAGES) {
|
|
16
|
+
messages.shift();
|
|
17
|
+
}
|
|
18
|
+
for (const cb of listeners) cb();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getSmsMessages(): SmsMessage[] {
|
|
22
|
+
return [...messages];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function onSmsChanged(cb: () => void): () => void {
|
|
26
|
+
listeners.add(cb);
|
|
27
|
+
return () => { listeners.delete(cb); };
|
|
28
|
+
}
|
|
@@ -10,6 +10,7 @@ import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mc
|
|
|
10
10
|
import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
|
|
11
11
|
import { getTaskDir } from "../task.js";
|
|
12
12
|
import { onNotificationsChanged } from "../notification-store.js";
|
|
13
|
+
import { onSmsChanged } from "../sms-store.js";
|
|
13
14
|
|
|
14
15
|
// ── Bundled PWA asset serving ───────────────────────────────────────────
|
|
15
16
|
|
|
@@ -124,6 +125,7 @@ export async function startHttpTransport(
|
|
|
124
125
|
|
|
125
126
|
// Wire up resource change listeners
|
|
126
127
|
onNotificationsChanged(() => broadcastResourceUpdated("notifications://device"));
|
|
128
|
+
onSmsChanged(() => broadcastResourceUpdated("sms-messages://device"));
|
|
127
129
|
|
|
128
130
|
// If a pairing code is provided, pre-register it
|
|
129
131
|
if (pairingCode) {
|
|
@@ -262,7 +264,20 @@ export async function startHttpTransport(
|
|
|
262
264
|
const matchedResource = req.method === "GET" && agentResources.find((r) => r.restPath === pathname);
|
|
263
265
|
if (matchedResource) {
|
|
264
266
|
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
265
|
-
|
|
267
|
+
const taskId = url.searchParams.get("taskId");
|
|
268
|
+
if (!taskId) {
|
|
269
|
+
sendJson(res, 400, { error: "taskId query parameter is required" });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
273
|
+
if (!fs.existsSync(taskDir)) {
|
|
274
|
+
sendJson(res, 404, { error: `Task not found: ${taskId}` });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${matchedResource.name}`);
|
|
278
|
+
const result = matchedResource.read();
|
|
279
|
+
console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${matchedResource.name} done: ${JSON.stringify(result).slice(0, 200)}`);
|
|
280
|
+
sendJson(res, 200, result);
|
|
266
281
|
return;
|
|
267
282
|
}
|
|
268
283
|
|
|
@@ -141,7 +141,7 @@ describe("generateEndpointDocs", () => {
|
|
|
141
141
|
"- Blocks until the device responds.",
|
|
142
142
|
'- Response: `{"data": ...}` on success.',
|
|
143
143
|
"",
|
|
144
|
-
"**`GET http://localhost:9966/mock-data`** — Get mock data from the device.",
|
|
144
|
+
"**`GET http://localhost:9966/mock-data?taskId=test-id`** — Get mock data from the device.",
|
|
145
145
|
"- Response: JSON array of data objects.",
|
|
146
146
|
].join("\n");
|
|
147
147
|
assert.equal(docs, expected);
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export interface LocationDevice {
|
|
2
|
-
clientToken: string;
|
|
3
|
-
fcmToken: string;
|
|
4
|
-
}
|
|
5
|
-
export declare function getLocationDevice(): LocationDevice | null;
|
|
6
|
-
export declare function setLocationDevice(clientToken: string, fcmToken: string): void;
|
|
7
|
-
export declare function clearLocationDevice(): void;
|
|
8
|
-
//# sourceMappingURL=location-device.d.ts.map
|
package/dist/location-device.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import { CONFIG_DIR } from "./config.js";
|
|
4
|
-
const LOCATION_FILE = path.join(CONFIG_DIR, "location-device.json");
|
|
5
|
-
export function getLocationDevice() {
|
|
6
|
-
try {
|
|
7
|
-
if (!fs.existsSync(LOCATION_FILE))
|
|
8
|
-
return null;
|
|
9
|
-
const raw = fs.readFileSync(LOCATION_FILE, "utf-8");
|
|
10
|
-
const data = JSON.parse(raw);
|
|
11
|
-
if (!data.clientToken || !data.fcmToken)
|
|
12
|
-
return null;
|
|
13
|
-
return data;
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
export function setLocationDevice(clientToken, fcmToken) {
|
|
20
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
21
|
-
fs.writeFileSync(LOCATION_FILE, JSON.stringify({ clientToken, fcmToken }, null, 2), "utf-8");
|
|
22
|
-
}
|
|
23
|
-
export function clearLocationDevice() {
|
|
24
|
-
try {
|
|
25
|
-
if (fs.existsSync(LOCATION_FILE))
|
|
26
|
-
fs.unlinkSync(LOCATION_FILE);
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
// ignore
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
//# sourceMappingURL=location-device.js.map
|