talon-agent 1.34.0 → 1.35.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.
@@ -0,0 +1,550 @@
1
+ /**
2
+ * MeshService — the daemon-wide device-mesh facade.
3
+ *
4
+ * One instance serves every consumer in the process:
5
+ *
6
+ * - Transports (the native bridge server) feed it registrations, location
7
+ * reports, and command results, and plug in a MeshTransport so locate
8
+ * requests and device commands reach connected companion apps. The
9
+ * service never imports a transport — dependencies point inward.
10
+ * - The model reads and drives it through the shared mesh gateway actions
11
+ * (list_devices, get_device_location, get_device_history, ring_device,
12
+ * get_device_status), so full mesh access works identically from
13
+ * Telegram, Discord, Teams, terminal, and native chats — the mesh is
14
+ * daemon state, not a native-frontend feature.
15
+ *
16
+ * Two request/response flows ride the same SSE-out / HTTP-POST-back loop:
17
+ *
18
+ * locate → `locate` event → device POSTs /location (legacy,
19
+ * kept verbatim so pre-command app builds keep working)
20
+ * command → `device_command` → device POSTs /devices/command-result with
21
+ * the command's correlation id; sendCommand resolves the
22
+ * pending promise or times out.
23
+ *
24
+ * With no transport registered (daemon running without the native bridge),
25
+ * everything degrades gracefully: locate answers from the last persisted
26
+ * fix immediately, and commands fail fast with a clear explanation.
27
+ */
28
+
29
+ import { randomUUID } from "node:crypto";
30
+ import { MeshRegistry } from "./registry.js";
31
+ import type {
32
+ DeviceCommand,
33
+ DeviceCommandResult,
34
+ DeviceInfo,
35
+ DeviceLocation,
36
+ } from "./types.js";
37
+
38
+ /** How a transport pushes mesh traffic to connected companion devices. */
39
+ export type MeshTransport = {
40
+ /** Ask one device (or all, when undefined) for a fresh location fix. */
41
+ locate(deviceId?: string): void;
42
+ /** Deliver an on-demand command to its target device. */
43
+ command(command: DeviceCommand): void;
44
+ };
45
+
46
+ export type MeshToolResult = { ok: boolean; text: string };
47
+
48
+ export type MeshServiceOptions = {
49
+ /** How long a locate waits for a fresh fix before last-known fallback. */
50
+ freshFixTimeoutMs?: number;
51
+ /** Upper bound between staleness re-checks while waiting. */
52
+ pollIntervalMs?: number;
53
+ /** How long a device command waits for its result before timing out. */
54
+ commandTimeoutMs?: number;
55
+ };
56
+
57
+ const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
58
+ const DEFAULT_POLL_INTERVAL_MS = 1_000;
59
+ const DEFAULT_COMMAND_TIMEOUT_MS = 12_000;
60
+ const MOBILE_PLATFORMS = new Set(["android", "ios"]);
61
+ const DEFAULT_HISTORY_HOURS = 24;
62
+ const MAX_HISTORY_LINES = 24;
63
+
64
+ export class MeshService {
65
+ private readonly waiters = new Set<() => void>();
66
+ private readonly transports = new Set<MeshTransport>();
67
+ private readonly pendingCommands = new Map<
68
+ string,
69
+ (result: DeviceCommandResult) => void
70
+ >();
71
+ private readonly freshFixTimeoutMs: number;
72
+ private readonly pollIntervalMs: number;
73
+ private readonly commandTimeoutMs: number;
74
+ private loading: Promise<void> | null = null;
75
+
76
+ constructor(
77
+ private readonly registry = new MeshRegistry(),
78
+ options: MeshServiceOptions = {},
79
+ ) {
80
+ this.freshFixTimeoutMs =
81
+ options.freshFixTimeoutMs ?? DEFAULT_FRESH_FIX_TIMEOUT_MS;
82
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
83
+ this.commandTimeoutMs =
84
+ options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
85
+ }
86
+
87
+ /** Hydrate persisted devices/locations. Idempotent — safe to await from
88
+ * every entry point; the first caller does the read, the rest share it. */
89
+ load(): Promise<void> {
90
+ this.loading ??= this.registry.load();
91
+ return this.loading;
92
+ }
93
+
94
+ /**
95
+ * Plug a transport in. Returns an unsubscribe so the transport detaches
96
+ * cleanly on shutdown (no stale broadcasts into a stopped server).
97
+ */
98
+ registerTransport(transport: MeshTransport): () => void {
99
+ this.transports.add(transport);
100
+ return () => this.transports.delete(transport);
101
+ }
102
+
103
+ /** Fan a locate request out to every transport. True when at least one
104
+ * transport is attached (i.e. waiting for a fresh fix can pay off). */
105
+ requestLocate(deviceId?: string): boolean {
106
+ for (const transport of this.transports) {
107
+ try {
108
+ transport.locate(deviceId);
109
+ } catch {
110
+ // One broken transport must not stop the others.
111
+ }
112
+ }
113
+ return this.transports.size > 0;
114
+ }
115
+
116
+ async register(body: Record<string, unknown>): Promise<DeviceInfo> {
117
+ await this.load();
118
+ return this.registry.register(body);
119
+ }
120
+
121
+ /** Store a reported fix and wake anyone waiting on a fresh location. */
122
+ async storeLocation(body: Record<string, unknown>): Promise<DeviceLocation> {
123
+ await this.load();
124
+ const loc = await this.registry.storeLocation(body);
125
+ for (const notify of [...this.waiters]) notify();
126
+ return loc;
127
+ }
128
+
129
+ async list(): Promise<{
130
+ devices: DeviceInfo[];
131
+ locations: DeviceLocation[];
132
+ }> {
133
+ await this.load();
134
+ return this.registry.list();
135
+ }
136
+
137
+ async getLocation(deviceId: string): Promise<DeviceLocation | undefined> {
138
+ await this.load();
139
+ return this.registry.getLocation(deviceId);
140
+ }
141
+
142
+ // ── Command channel ────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * A device answered a command (bridge route POST /devices/command-result).
146
+ * Resolves the pending sendCommand; false when nothing was waiting (late
147
+ * or unknown correlation id — harmless, just ignored).
148
+ */
149
+ completeCommand(body: Record<string, unknown>): boolean {
150
+ const commandId = typeof body.commandId === "string" ? body.commandId : "";
151
+ const resolve = this.pendingCommands.get(commandId);
152
+ if (!resolve) return false;
153
+ this.pendingCommands.delete(commandId);
154
+ resolve({
155
+ commandId,
156
+ deviceId: typeof body.deviceId === "string" ? body.deviceId : "",
157
+ ok: body.ok === true,
158
+ ...(typeof body.message === "string" && body.message.trim()
159
+ ? { message: body.message.trim().slice(0, 2_000) }
160
+ : {}),
161
+ ...(body.data &&
162
+ typeof body.data === "object" &&
163
+ !Array.isArray(body.data)
164
+ ? { data: body.data as Record<string, unknown> }
165
+ : {}),
166
+ });
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * Push one command to a device and await its result (or time out). The
172
+ * low-level primitive under every command tool; exposed for tests and
173
+ * future tools.
174
+ */
175
+ sendCommand(
176
+ device: DeviceInfo,
177
+ name: string,
178
+ params: Record<string, unknown> = {},
179
+ ): Promise<DeviceCommandResult> {
180
+ const command: DeviceCommand = {
181
+ id: randomUUID(),
182
+ deviceId: device.id,
183
+ name,
184
+ params,
185
+ };
186
+ return new Promise<DeviceCommandResult>((resolve) => {
187
+ const timer = setTimeout(() => {
188
+ this.pendingCommands.delete(command.id);
189
+ resolve({
190
+ commandId: command.id,
191
+ deviceId: device.id,
192
+ ok: false,
193
+ message: `${device.name} did not answer within ${Math.round(this.commandTimeoutMs / 1000)}s (device ${device.online ? "was online" : "appears offline"}).`,
194
+ });
195
+ }, this.commandTimeoutMs);
196
+ timer.unref?.();
197
+ this.pendingCommands.set(command.id, (result) => {
198
+ clearTimeout(timer);
199
+ resolve(result);
200
+ });
201
+ for (const transport of this.transports) {
202
+ try {
203
+ transport.command(command);
204
+ } catch {
205
+ // One broken transport must not stop the others.
206
+ }
207
+ }
208
+ });
209
+ }
210
+
211
+ // ── Model-facing tool surface ──────────────────────────────────────────────
212
+
213
+ /** `list_devices`: every mesh device with presence, battery, last-known
214
+ * position, and capabilities — the model's full view of the mesh. */
215
+ async describeDevices(): Promise<MeshToolResult> {
216
+ const { devices } = await this.list();
217
+ if (devices.length === 0) {
218
+ return { ok: true, text: "No mesh devices have registered yet." };
219
+ }
220
+ return {
221
+ ok: true,
222
+ text: devices.map((d) => this.deviceLine(d)).join("\n"),
223
+ };
224
+ }
225
+
226
+ /**
227
+ * `get_device_location`: resolve the target (id, name fragment, or the
228
+ * most recent mobile device), push a locate to connected transports, wait
229
+ * briefly for a fresh fix, then fall back to the last persisted location.
230
+ */
231
+ async locateDevice(query?: unknown): Promise<MeshToolResult> {
232
+ await this.load();
233
+ const target = this.chooseDevice(query);
234
+ if (!target) return this.noSuchDevice(query);
235
+ const requestedAt = Date.now();
236
+ // Only wait out the fresh-fix window when a transport can actually
237
+ // deliver the locate — otherwise answer from persistence immediately.
238
+ const dispatched = this.requestLocate(target.id);
239
+ const fresh = dispatched
240
+ ? await this.waitForFreshLocation(target.id, requestedAt)
241
+ : undefined;
242
+ const loc = fresh ?? this.registry.getLocation(target.id);
243
+ if (!loc) {
244
+ return {
245
+ ok: false,
246
+ text: dispatched
247
+ ? `No location is known for ${target.name}. A locate request was sent, but no fix arrived within ${Math.round(this.freshFixTimeoutMs / 1000)}s.`
248
+ : `No location is known for ${target.name}, and no companion transport is connected to request one.`,
249
+ };
250
+ }
251
+ return { ok: true, text: this.locationSummary(target, loc) };
252
+ }
253
+
254
+ /** `ring_device`: make the device sound/vibrate so it can be found. */
255
+ ringDevice(query?: unknown, message?: unknown): Promise<MeshToolResult> {
256
+ return this.commandTool(query, "ring", {
257
+ ...(typeof message === "string" && message.trim()
258
+ ? { message: message.trim().slice(0, 200) }
259
+ : {}),
260
+ });
261
+ }
262
+
263
+ /**
264
+ * `get_device_history`: the device's movement + battery over a window,
265
+ * computed from the fixes the daemon has been receiving all along —
266
+ * timeline, distance traveled, and battery trend.
267
+ */
268
+ async deviceHistory(
269
+ query?: unknown,
270
+ hours?: unknown,
271
+ ): Promise<MeshToolResult> {
272
+ await this.load();
273
+ const target = this.chooseDevice(query);
274
+ if (!target) return this.noSuchDevice(query);
275
+ const windowHours = clampHours(hours);
276
+ const sinceTs = Date.now() - windowHours * 3_600_000;
277
+ const fixes = this.registry.getHistory(target.id, sinceTs);
278
+ if (fixes.length === 0) {
279
+ return {
280
+ ok: true,
281
+ text: `No location reports from ${target.name} in the last ${windowHours}h. Enable periodic reporting in the companion app for a movement history.`,
282
+ };
283
+ }
284
+ return { ok: true, text: this.historySummary(target, fixes, windowHours) };
285
+ }
286
+
287
+ /** `get_device_status`: live telemetry straight from the device. */
288
+ getDeviceStatus(query?: unknown): Promise<MeshToolResult> {
289
+ return this.commandTool(query, "status", {});
290
+ }
291
+
292
+ /**
293
+ * Shared command-tool flow: resolve the target, check its advertised
294
+ * capabilities, require a transport, send, and render the device's answer.
295
+ */
296
+ private async commandTool(
297
+ query: unknown,
298
+ name: string,
299
+ params: Record<string, unknown>,
300
+ ): Promise<MeshToolResult> {
301
+ await this.load();
302
+ const target = this.chooseDevice(query);
303
+ if (!target) return this.noSuchDevice(query);
304
+ // Devices advertise what they can do; an explicit list that lacks the
305
+ // command is a clean "can't" — absent list means an older app build, so
306
+ // attempt it and let the timeout speak.
307
+ if (target.capabilities && !target.capabilities.includes(name)) {
308
+ return {
309
+ ok: false,
310
+ text: `${target.name} does not support "${name}" (supports: ${target.capabilities.join(", ")}).`,
311
+ };
312
+ }
313
+ if (this.transports.size === 0) {
314
+ return {
315
+ ok: false,
316
+ text: `No companion transport is connected, so "${name}" cannot reach ${target.name}.`,
317
+ };
318
+ }
319
+ const result = await this.sendCommand(target, name, params);
320
+ return {
321
+ ok: result.ok,
322
+ text: this.commandSummary(target, name, result),
323
+ };
324
+ }
325
+
326
+ /** Resolve a device by exact id, name fragment, or default (most recently
327
+ * seen mobile device, falling back to the most recent device overall). */
328
+ chooseDevice(query?: unknown): DeviceInfo | undefined {
329
+ const devices = this.registry.list().devices;
330
+ if (typeof query === "string" && query.trim()) {
331
+ const q = query.trim().toLowerCase();
332
+ return devices.find(
333
+ (d) => d.id.toLowerCase() === q || d.name.toLowerCase().includes(q),
334
+ );
335
+ }
336
+ return devices.find((d) => MOBILE_PLATFORMS.has(d.platform)) ?? devices[0];
337
+ }
338
+
339
+ // ── Formatting ─────────────────────────────────────────────────────────────
340
+
341
+ private noSuchDevice(query: unknown): MeshToolResult {
342
+ const known = this.registry.list().devices;
343
+ return {
344
+ ok: false,
345
+ text:
346
+ typeof query === "string" && query.trim() && known.length > 0
347
+ ? `No mesh device matches "${query.trim()}". Known devices:\n${known.map((d) => this.deviceLine(d)).join("\n")}`
348
+ : "No mesh devices are registered.",
349
+ };
350
+ }
351
+
352
+ private deviceLine(device: DeviceInfo): string {
353
+ const parts = [
354
+ `${device.name} [id: ${device.id}] (${device.platform})`,
355
+ device.online ? "online" : "offline",
356
+ `last seen ${age(Date.now() - device.lastSeen)}`,
357
+ ];
358
+ if (typeof device.battery === "number") {
359
+ parts.push(`${device.battery}%${device.charging ? " charging" : ""}`);
360
+ }
361
+ const loc = this.registry.getLocation(device.id);
362
+ if (loc) {
363
+ parts.push(
364
+ `at ${loc.lat.toFixed(6)},${loc.lon.toFixed(6)} (fix ${age(Date.now() - loc.ts)})`,
365
+ );
366
+ }
367
+ if (device.capabilities?.length) {
368
+ parts.push(`can: ${device.capabilities.join(", ")}`);
369
+ }
370
+ return `- ${parts.join(" · ")}`;
371
+ }
372
+
373
+ private locationSummary(device: DeviceInfo, loc: DeviceLocation): string {
374
+ const ageText = age(Date.now() - loc.ts);
375
+ const accuracy =
376
+ typeof loc.accuracyM === "number"
377
+ ? ` Accuracy ${Math.round(loc.accuracyM)}m.`
378
+ : "";
379
+ return [
380
+ `${device.name} is at ${loc.lat.toFixed(6)}, ${loc.lon.toFixed(6)}.`,
381
+ `${accuracy} Fix age ${ageText}.`,
382
+ `Reverse-geocode pair: ${loc.lat},${loc.lon}`,
383
+ ]
384
+ .join(" ")
385
+ .replace(/\s+/g, " ")
386
+ .trim();
387
+ }
388
+
389
+ private commandSummary(
390
+ device: DeviceInfo,
391
+ name: string,
392
+ result: DeviceCommandResult,
393
+ ): string {
394
+ if (!result.ok) {
395
+ return result.message ?? `${device.name} rejected "${name}".`;
396
+ }
397
+ const detail = result.message ? ` ${result.message}` : "";
398
+ switch (name) {
399
+ case "ring":
400
+ return `${device.name} is ringing.${detail}`;
401
+ case "status": {
402
+ const fields = Object.entries(result.data ?? {})
403
+ .filter(([, v]) => v !== null && v !== undefined && v !== "")
404
+ .map(([k, v]) => `${k}: ${String(v)}`);
405
+ return fields.length
406
+ ? `${device.name} status — ${fields.join(" · ")}`
407
+ : `${device.name} answered but reported no status fields.${detail}`;
408
+ }
409
+ default:
410
+ return (
411
+ result.message ??
412
+ (result.data
413
+ ? `${device.name} answered: ${JSON.stringify(result.data)}`
414
+ : `${device.name} completed "${name}".`)
415
+ );
416
+ }
417
+ }
418
+
419
+ /** Render a window of fixes: headline (count, span, distance, battery
420
+ * trend) plus a bounded, evenly-sampled timeline oldest-first. */
421
+ private historySummary(
422
+ device: DeviceInfo,
423
+ fixes: DeviceLocation[],
424
+ windowHours: number,
425
+ ): string {
426
+ let distanceM = 0;
427
+ for (let i = 1; i < fixes.length; i++) {
428
+ distanceM += haversineM(fixes[i - 1], fixes[i]);
429
+ }
430
+ const batteries = fixes
431
+ .map((f) => f.batteryPct)
432
+ .filter((b): b is number => typeof b === "number");
433
+ const headline = [
434
+ `${device.name}: ${fixes.length} fix${fixes.length === 1 ? "" : "es"} in the last ${windowHours}h`,
435
+ `moved ~${formatDistance(distanceM)}`,
436
+ ...(batteries.length >= 2
437
+ ? [`battery ${batteries[0]}% → ${batteries[batteries.length - 1]}%`]
438
+ : []),
439
+ ].join(" · ");
440
+ const lines = sampleEvenly(fixes, MAX_HISTORY_LINES).map((f) => {
441
+ const parts = [
442
+ `${formatWhen(f.ts)} — ${f.lat.toFixed(5)},${f.lon.toFixed(5)}`,
443
+ ];
444
+ if (typeof f.accuracyM === "number")
445
+ parts.push(`±${Math.round(f.accuracyM)}m`);
446
+ if (typeof f.batteryPct === "number") parts.push(`${f.batteryPct}%`);
447
+ return `- ${parts.join(" · ")}`;
448
+ });
449
+ const omitted = fixes.length - Math.min(fixes.length, MAX_HISTORY_LINES);
450
+ return [
451
+ headline,
452
+ ...lines,
453
+ ...(omitted > 0
454
+ ? [`(${omitted} more fixes omitted; timeline sampled evenly)`]
455
+ : []),
456
+ ].join("\n");
457
+ }
458
+
459
+ private async waitForFreshLocation(
460
+ deviceId: string,
461
+ requestedAt: number,
462
+ ): Promise<DeviceLocation | undefined> {
463
+ const existing = this.registry.getLocation(deviceId);
464
+ if (existing && existing.ts >= requestedAt) return existing;
465
+ const deadline = Date.now() + this.freshFixTimeoutMs;
466
+ while (Date.now() < deadline) {
467
+ const remaining = deadline - Date.now();
468
+ await new Promise<void>((resolve) => {
469
+ const done = () => {
470
+ clearTimeout(timer);
471
+ this.waiters.delete(done);
472
+ resolve();
473
+ };
474
+ const timer = setTimeout(
475
+ done,
476
+ Math.min(remaining, this.pollIntervalMs),
477
+ );
478
+ this.waiters.add(done);
479
+ });
480
+ const loc = this.registry.getLocation(deviceId);
481
+ if (loc && loc.ts >= requestedAt) return loc;
482
+ }
483
+ return undefined;
484
+ }
485
+ }
486
+
487
+ /** Clamp the requested history window to 1..168 hours (default 24). */
488
+ function clampHours(value: unknown): number {
489
+ const n = typeof value === "number" ? value : Number(value);
490
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_HISTORY_HOURS;
491
+ return Math.min(168, Math.max(1, Math.round(n)));
492
+ }
493
+
494
+ /** Great-circle distance between two fixes in meters. */
495
+ function haversineM(a: DeviceLocation, b: DeviceLocation): number {
496
+ const R = 6_371_000;
497
+ const rad = (deg: number) => (deg * Math.PI) / 180;
498
+ const dLat = rad(b.lat - a.lat);
499
+ const dLon = rad(b.lon - a.lon);
500
+ const h =
501
+ Math.sin(dLat / 2) ** 2 +
502
+ Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
503
+ return 2 * R * Math.asin(Math.sqrt(h));
504
+ }
505
+
506
+ function formatDistance(meters: number): string {
507
+ if (meters < 1_000) return `${Math.round(meters)}m`;
508
+ return `${(meters / 1_000).toFixed(1)}km`;
509
+ }
510
+
511
+ /** Local wall-clock stamp for history lines (date + HH:MM). */
512
+ function formatWhen(ts: number): string {
513
+ const d = new Date(ts);
514
+ const pad = (n: number) => String(n).padStart(2, "0");
515
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
516
+ }
517
+
518
+ /** Up to `max` items spread evenly across the list, endpoints included. */
519
+ function sampleEvenly<T>(items: T[], max: number): T[] {
520
+ if (items.length <= max) return items;
521
+ const out: T[] = [];
522
+ for (let i = 0; i < max; i++) {
523
+ out.push(items[Math.round((i * (items.length - 1)) / (max - 1))]);
524
+ }
525
+ return out;
526
+ }
527
+
528
+ function age(ms: number): string {
529
+ const sec = Math.max(0, Math.round(ms / 1000));
530
+ if (sec < 60) return `${sec}s ago`;
531
+ const min = Math.round(sec / 60);
532
+ if (min < 60) return `${min}m ago`;
533
+ const hrs = Math.round(min / 60);
534
+ return `${hrs}h ago`;
535
+ }
536
+
537
+ // ── Process-wide instance ─────────────────────────────────────────────────────
538
+
539
+ let instance: MeshService | null = null;
540
+
541
+ /** The daemon's shared mesh service (lazily created). */
542
+ export function getMeshService(): MeshService {
543
+ instance ??= new MeshService();
544
+ return instance;
545
+ }
546
+
547
+ /** Swap the shared instance — composition/test seam. Pass null to reset. */
548
+ export function setMeshService(service: MeshService | null): void {
549
+ instance = service;
550
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Device-mesh domain types + normalizers.
3
+ *
4
+ * The canonical shapes for companion devices and their reported locations.
5
+ * They live in core (not a frontend) because the mesh is daemon-wide state:
6
+ * devices register through the native bridge transport, but the model reads
7
+ * them from ANY frontend via the shared `list_devices` /
8
+ * `get_device_location` gateway actions. The bridge protocol re-exports
9
+ * these types so the wire contract is unchanged.
10
+ */
11
+
12
+ export type DevicePlatform = "android" | "macos" | "windows" | "linux" | "ios";
13
+
14
+ export type DeviceInfo = {
15
+ id: string;
16
+ name: string;
17
+ platform: DevicePlatform;
18
+ appVersion: string;
19
+ online: boolean;
20
+ /** Epoch milliseconds. */
21
+ lastSeen: number;
22
+ /** Battery percent, 0-100. */
23
+ battery?: number;
24
+ charging?: boolean;
25
+ /**
26
+ * Command names this device can execute (e.g. "locate", "ring",
27
+ * "open_url"). Reported by the device at registration; absent for app
28
+ * versions that predate the command channel — treat as unknown, not
29
+ * empty.
30
+ */
31
+ capabilities?: string[];
32
+ };
33
+
34
+ /**
35
+ * An on-demand command pushed to one companion device over the mesh
36
+ * transport. The device executes it and answers with a DeviceCommandResult
37
+ * carrying the same `id`.
38
+ */
39
+ export type DeviceCommand = {
40
+ /** Correlation id — echoed back as the result's `commandId`. */
41
+ id: string;
42
+ deviceId: string;
43
+ /** Command name (e.g. "ring", "open_url", "clipboard_get", "status"). */
44
+ name: string;
45
+ params: Record<string, unknown>;
46
+ };
47
+
48
+ /** A device's answer to a DeviceCommand. */
49
+ export type DeviceCommandResult = {
50
+ commandId: string;
51
+ deviceId: string;
52
+ ok: boolean;
53
+ /** Human-readable outcome (shown to the model). */
54
+ message?: string;
55
+ /** Structured payload (e.g. clipboard text, status fields). */
56
+ data?: Record<string, unknown>;
57
+ };
58
+
59
+ export type DeviceLocation = {
60
+ deviceId: string;
61
+ lat: number;
62
+ lon: number;
63
+ accuracyM?: number;
64
+ altitudeM?: number;
65
+ speedMps?: number;
66
+ headingDeg?: number;
67
+ /** Epoch milliseconds. */
68
+ ts: number;
69
+ provider?: string;
70
+ batteryPct?: number;
71
+ };
72
+
73
+ export function toDeviceInfo(
74
+ value: DeviceInfo,
75
+ now = Date.now(),
76
+ offlineAfterMs = 90_000,
77
+ ): DeviceInfo {
78
+ const battery =
79
+ typeof value.battery === "number"
80
+ ? Math.max(0, Math.min(100, Math.round(value.battery)))
81
+ : undefined;
82
+ return {
83
+ id: value.id,
84
+ name: value.name,
85
+ platform: value.platform,
86
+ appVersion: value.appVersion,
87
+ online: now - value.lastSeen <= offlineAfterMs && value.online,
88
+ lastSeen: value.lastSeen,
89
+ ...(battery !== undefined ? { battery } : {}),
90
+ ...(typeof value.charging === "boolean"
91
+ ? { charging: value.charging }
92
+ : {}),
93
+ ...(value.capabilities?.length
94
+ ? { capabilities: [...value.capabilities] }
95
+ : {}),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Normalize a device's advertised capability list: strings only, trimmed,
101
+ * lowercased, deduped, and bounded so a misbehaving client can't bloat the
102
+ * persisted registry. Undefined when nothing valid remains.
103
+ */
104
+ export function sanitizeCapabilities(value: unknown): string[] | undefined {
105
+ if (!Array.isArray(value)) return undefined;
106
+ const seen = new Set<string>();
107
+ for (const item of value) {
108
+ if (typeof item !== "string") continue;
109
+ const cap = item.trim().toLowerCase().slice(0, 32);
110
+ if (cap) seen.add(cap);
111
+ if (seen.size >= 16) break;
112
+ }
113
+ return seen.size ? [...seen] : undefined;
114
+ }
115
+
116
+ export function toDeviceLocation(value: DeviceLocation): DeviceLocation {
117
+ return {
118
+ deviceId: value.deviceId,
119
+ lat: value.lat,
120
+ lon: value.lon,
121
+ ...(typeof value.accuracyM === "number"
122
+ ? { accuracyM: value.accuracyM }
123
+ : {}),
124
+ ...(typeof value.altitudeM === "number"
125
+ ? { altitudeM: value.altitudeM }
126
+ : {}),
127
+ ...(typeof value.speedMps === "number" ? { speedMps: value.speedMps } : {}),
128
+ ...(typeof value.headingDeg === "number"
129
+ ? { headingDeg: value.headingDeg }
130
+ : {}),
131
+ ts: value.ts,
132
+ ...(value.provider ? { provider: value.provider } : {}),
133
+ ...(typeof value.batteryPct === "number"
134
+ ? { batteryPct: Math.max(0, Math.min(100, Math.round(value.batteryPct))) }
135
+ : {}),
136
+ };
137
+ }