@firstperson/firstperson 2026.1.34 → 2026.1.36

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.
@@ -10,7 +10,8 @@
10
10
  "Bash(npm install:*)",
11
11
  "Bash(npm run build:*)",
12
12
  "Bash(npx tsc:*)",
13
- "Bash(npm run:*)"
13
+ "Bash(npm run:*)",
14
+ "Bash(npm test:*)"
14
15
  ]
15
16
  }
16
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstperson/firstperson",
3
- "version": "2026.1.34",
3
+ "version": "2026.1.36",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for the First Person iOS app",
6
6
  "main": "index.ts",
@@ -19,7 +19,9 @@
19
19
  "ai-assistant"
20
20
  ],
21
21
  "openclaw": {
22
- "extensions": ["./index.ts"],
22
+ "extensions": [
23
+ "./index.ts"
24
+ ],
23
25
  "channel": {
24
26
  "id": "firstperson",
25
27
  "label": "First Person",
package/src/channel.ts CHANGED
@@ -14,6 +14,30 @@ import type { FirstPersonConfig, ResolvedFirstPersonAccount, CoreConfig } from "
14
14
 
15
15
  const DEFAULT_RELAY_URL = "wss://chat.firstperson.ai";
16
16
 
17
+ // Dedupe cache to track recently processed messages (20-minute window matches OpenClaw)
18
+ const DEDUPE_WINDOW_MS = 20 * 60 * 1000; // 20 minutes
19
+ const processedMessages = new Map<string, number>(); // messageId -> timestamp
20
+
21
+ function isDuplicateMessage(messageId: string): boolean {
22
+ const now = Date.now();
23
+
24
+ // Clean up old entries
25
+ for (const [id, timestamp] of processedMessages) {
26
+ if (now - timestamp > DEDUPE_WINDOW_MS) {
27
+ processedMessages.delete(id);
28
+ }
29
+ }
30
+
31
+ // Check if this message was already processed
32
+ if (processedMessages.has(messageId)) {
33
+ return true;
34
+ }
35
+
36
+ // Mark as processed
37
+ processedMessages.set(messageId, now);
38
+ return false;
39
+ }
40
+
17
41
  const meta = {
18
42
  id: "firstperson",
19
43
  label: "First Person",
@@ -294,7 +318,7 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
294
318
  lastError: null,
295
319
  });
296
320
 
297
- const { startRelayConnection } = await import("./relay-client.js");
321
+ const { startRelayConnection, sendStatusUpdate } = await import("./relay-client.js");
298
322
 
299
323
  return startRelayConnection({
300
324
  relayUrl: account.relayUrl,
@@ -325,6 +349,23 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
325
349
  onMessage: async (message) => {
326
350
  log?.info(`[fp] 1. received: ${message.messageId} from ${message.deviceId}`);
327
351
 
352
+ // Check for duplicate message (dedupe within 20-minute window)
353
+ if (isDuplicateMessage(message.messageId)) {
354
+ log?.info(`[fp] skipping duplicate message: ${message.messageId}`);
355
+ // Send "skipped" status instead of processing
356
+ sendStatusUpdate({
357
+ to: message.deviceId,
358
+ messageId: message.messageId,
359
+ status: "completed", // Use completed but with skip indication in details
360
+ message: "Message already processed (duplicate)",
361
+ details: { skipped: true, reason: "duplicate" },
362
+ });
363
+ return;
364
+ }
365
+
366
+ // Track timing for status updates
367
+ const dispatchStartTime = Date.now();
368
+
328
369
  try {
329
370
  const globalRuntime = getFirstPersonRuntime();
330
371
  const channelApi = (globalRuntime as any).channel;
@@ -417,6 +458,13 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
417
458
 
418
459
  if (!route) {
419
460
  log?.warn(`[fp] no route - check bindings for ${message.deviceId}`);
461
+ // Send error status to client
462
+ sendStatusUpdate({
463
+ to: message.deviceId,
464
+ messageId: message.messageId,
465
+ status: "failed",
466
+ message: "No agent route configured for this device",
467
+ });
420
468
  return;
421
469
  }
422
470
 
@@ -477,6 +525,17 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
477
525
  });
478
526
  log?.info(`[fp] 8. session recorded, starting dispatch...`);
479
527
 
528
+ // ========== SEND "PROCESSING" STATUS ==========
529
+ sendStatusUpdate({
530
+ to: message.deviceId,
531
+ messageId: message.messageId,
532
+ status: "processing",
533
+ message: "Your message is being processed",
534
+ details: {
535
+ startedAt: Date.now(),
536
+ },
537
+ });
538
+
480
539
  // Create dispatcher for replies
481
540
  const { sendTextMessage } = await import("./relay-client.js");
482
541
  const { dispatcher, replyOptions } = channelApi.reply.createReplyDispatcherWithTyping({
@@ -493,23 +552,83 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
493
552
  },
494
553
  onError: (err: Error) => {
495
554
  log?.error(`[fp] reply delivery failed: ${err.message}`);
555
+ // Send error status to client
556
+ sendStatusUpdate({
557
+ to: message.deviceId,
558
+ messageId: message.messageId,
559
+ status: "failed",
560
+ message: `Failed to deliver reply: ${err.message}`,
561
+ });
496
562
  },
497
563
  });
498
564
 
499
- // Dispatch to agent
500
- await channelApi.reply.dispatchReplyFromConfig({
501
- ctx: ctxPayload,
502
- cfg,
503
- dispatcher,
504
- replyOptions,
505
- });
506
- log?.info(`[fp] 11. dispatch complete`);
565
+ // ========== DISPATCH WITH ENHANCED REPLY OPTIONS ==========
566
+ // Add callbacks to track agent progress
567
+ const enhancedReplyOptions = {
568
+ ...replyOptions,
569
+ onAgentRunStart: (runId: string) => {
570
+ log?.info(`[fp] agent run started: ${runId}`);
571
+ },
572
+ onToolResult: (payload: { name?: string }) => {
573
+ log?.info(`[fp] tool executed: ${payload.name}`);
574
+ },
575
+ };
576
+
577
+ try {
578
+ // Dispatch to agent
579
+ await channelApi.reply.dispatchReplyFromConfig({
580
+ ctx: ctxPayload,
581
+ cfg,
582
+ dispatcher,
583
+ replyOptions: enhancedReplyOptions,
584
+ });
585
+
586
+ const dispatchDuration = Date.now() - dispatchStartTime;
587
+ log?.info(`[fp] 11. dispatch complete (took ${dispatchDuration}ms)`);
588
+
589
+ // Send completion status
590
+ sendStatusUpdate({
591
+ to: message.deviceId,
592
+ messageId: message.messageId,
593
+ status: "completed",
594
+ message: "Response delivered",
595
+ details: {
596
+ durationMs: dispatchDuration,
597
+ },
598
+ });
599
+ } catch (dispatchError) {
600
+ const err = dispatchError as Error;
601
+ log?.error(`[fp] dispatch failed: ${err.message}`);
602
+
603
+ // Send error status to client
604
+ sendStatusUpdate({
605
+ to: message.deviceId,
606
+ messageId: message.messageId,
607
+ status: "failed",
608
+ message: err.message.includes("timed out")
609
+ ? "Request timed out. The AI is taking longer than expected."
610
+ : `Error: ${err.message}`,
611
+ details: {
612
+ errorType: err.message.includes("timed out") ? "timeout" : "error",
613
+ durationMs: Date.now() - dispatchStartTime,
614
+ },
615
+ });
616
+ }
507
617
  return;
508
618
  }
509
619
 
510
620
  // Fallback: agent.enqueueInbound
511
621
  if ((globalRuntime as any).agent?.enqueueInbound) {
512
622
  log?.info(`[fp] fallback: using agent.enqueueInbound`);
623
+
624
+ // Send processing status
625
+ sendStatusUpdate({
626
+ to: message.deviceId,
627
+ messageId: message.messageId,
628
+ status: "processing",
629
+ message: "Your message is being processed",
630
+ });
631
+
513
632
  const { sendTextMessage } = await import("./relay-client.js");
514
633
  await (globalRuntime as any).agent.enqueueInbound({
515
634
  channel: "firstperson",
@@ -532,6 +651,13 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
532
651
  text,
533
652
  replyTo: message.messageId,
534
653
  });
654
+ // Send completion status
655
+ sendStatusUpdate({
656
+ to: message.deviceId,
657
+ messageId: message.messageId,
658
+ status: "completed",
659
+ message: "Response delivered",
660
+ });
535
661
  },
536
662
  });
537
663
  return;
@@ -540,6 +666,15 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
540
666
  // Fallback: gateway.handleInbound
541
667
  if ((globalRuntime as any).gateway?.handleInbound) {
542
668
  log?.info(`[fp] fallback: using gateway.handleInbound`);
669
+
670
+ // Send processing status
671
+ sendStatusUpdate({
672
+ to: message.deviceId,
673
+ messageId: message.messageId,
674
+ status: "processing",
675
+ message: "Your message is being processed",
676
+ });
677
+
543
678
  const { sendTextMessage } = await import("./relay-client.js");
544
679
  await (globalRuntime as any).gateway.handleInbound({
545
680
  channel: "firstperson",
@@ -557,15 +692,35 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
557
692
  text,
558
693
  replyTo: message.messageId,
559
694
  });
695
+ // Send completion status
696
+ sendStatusUpdate({
697
+ to: message.deviceId,
698
+ messageId: message.messageId,
699
+ status: "completed",
700
+ message: "Response delivered",
701
+ });
560
702
  },
561
703
  });
562
704
  return;
563
705
  }
564
706
 
565
707
  log?.error(`[fp] ERROR: no dispatch method available on runtime`);
708
+ sendStatusUpdate({
709
+ to: message.deviceId,
710
+ messageId: message.messageId,
711
+ status: "failed",
712
+ message: "No dispatch method available",
713
+ });
566
714
 
567
715
  } catch (err) {
568
716
  log?.error(`[fp] ERROR processing message: ${err}`);
717
+ // Send error status to client
718
+ sendStatusUpdate({
719
+ to: message.deviceId,
720
+ messageId: message.messageId,
721
+ status: "failed",
722
+ message: `Error processing message: ${(err as Error).message}`,
723
+ });
569
724
  }
570
725
  },
571
726
  });
@@ -29,6 +29,30 @@ export interface SendMessageResult {
29
29
  chatId: string;
30
30
  }
31
31
 
32
+ /**
33
+ * Message status types matching the relay protocol
34
+ */
35
+ export type MessageStatus =
36
+ | "received"
37
+ | "forwarded"
38
+ | "processing"
39
+ | "completed"
40
+ | "failed"
41
+ | "gateway_offline";
42
+
43
+ /**
44
+ * Status message sent to relay for forwarding to iOS client
45
+ */
46
+ export interface StatusUpdate {
47
+ type: "message_status";
48
+ messageId: string;
49
+ to: string;
50
+ status: MessageStatus;
51
+ message?: string;
52
+ timestamp: number;
53
+ details?: Record<string, unknown>;
54
+ }
55
+
32
56
  // Store active connection and pending reply callbacks
33
57
  let activeWs: WebSocket | null = null;
34
58
  let activeLogger: Logger | null = null;
@@ -43,6 +67,45 @@ function log(level: "info" | "warn" | "error", msg: string): void {
43
67
  }
44
68
  }
45
69
 
70
+ /**
71
+ * Send a status update for a message to the relay.
72
+ * The relay will forward this to the iOS client.
73
+ */
74
+ export function sendStatusUpdate(params: {
75
+ to: string;
76
+ messageId: string;
77
+ status: MessageStatus;
78
+ message?: string;
79
+ details?: Record<string, unknown>;
80
+ }): void {
81
+ const { to, messageId, status, message, details } = params;
82
+
83
+ if (!activeWs || activeWs.readyState !== WebSocket.OPEN) {
84
+ log("warn", `[relay-client] Cannot send status update - no active connection`);
85
+ return;
86
+ }
87
+
88
+ const statusMsg: StatusUpdate = {
89
+ type: "message_status",
90
+ messageId,
91
+ to,
92
+ status,
93
+ timestamp: Date.now(),
94
+ ...(message && { message }),
95
+ ...(details && { details }),
96
+ };
97
+
98
+ activeWs.send(JSON.stringify(statusMsg));
99
+ log("info", `[relay-client] Sent status update: ${status} for message ${messageId} to ${to}`);
100
+ }
101
+
102
+ /**
103
+ * Check if the relay connection is active
104
+ */
105
+ export function isRelayConnected(): boolean {
106
+ return activeWs !== null && activeWs.readyState === WebSocket.OPEN;
107
+ }
108
+
46
109
  export async function sendTextMessage(params: {
47
110
  relayUrl: string;
48
111
  token: string;