@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.
- package/.claude/settings.local.json +2 -1
- package/package.json +4 -2
- package/src/channel.ts +164 -9
- package/src/relay-client.ts +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstperson/firstperson",
|
|
3
|
-
"version": "2026.1.
|
|
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": [
|
|
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
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
});
|
package/src/relay-client.ts
CHANGED
|
@@ -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;
|