@firstperson/firstperson 2026.1.35 → 2026.1.37

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.35",
3
+ "version": "2026.1.37",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin for the First Person iOS app",
6
6
  "main": "index.ts",
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, sendStatusUpdate } = await import("./relay-client.js");
321
+ const { startRelayConnection, sendStatusUpdate, pushProfileToRelay } = await import("./relay-client.js");
298
322
 
299
323
  return startRelayConnection({
300
324
  relayUrl: account.relayUrl,
@@ -311,6 +335,22 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
311
335
  lastError: null,
312
336
  });
313
337
  log?.info(`[firstperson:${account.accountId}] relay connected`);
338
+
339
+ // Push agent profile to relay on connection
340
+ const globalRuntime = getFirstPersonRuntime();
341
+ const identity = (globalRuntime as any).agent?.identity?.get?.();
342
+ if (identity) {
343
+ pushProfileToRelay({
344
+ relayUrl: account.relayUrl,
345
+ token: account.token!,
346
+ identity,
347
+ log,
348
+ }).catch((err) => {
349
+ log?.warn(`[firstperson:${account.accountId}] profile push failed: ${err.message}`);
350
+ });
351
+ } else {
352
+ log?.info(`[firstperson:${account.accountId}] no agent identity available, skipping profile push`);
353
+ }
314
354
  },
315
355
  onDisconnected: (error) => {
316
356
  setStatus({
@@ -325,6 +365,20 @@ export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
325
365
  onMessage: async (message) => {
326
366
  log?.info(`[fp] 1. received: ${message.messageId} from ${message.deviceId}`);
327
367
 
368
+ // Check for duplicate message (dedupe within 20-minute window)
369
+ if (isDuplicateMessage(message.messageId)) {
370
+ log?.info(`[fp] skipping duplicate message: ${message.messageId}`);
371
+ // Send "skipped" status instead of processing
372
+ sendStatusUpdate({
373
+ to: message.deviceId,
374
+ messageId: message.messageId,
375
+ status: "completed", // Use completed but with skip indication in details
376
+ message: "Message already processed (duplicate)",
377
+ details: { skipped: true, reason: "duplicate" },
378
+ });
379
+ return;
380
+ }
381
+
328
382
  // Track timing for status updates
329
383
  const dispatchStartTime = Date.now();
330
384
 
@@ -1,5 +1,62 @@
1
1
  import WebSocket from "ws";
2
2
 
3
+ /**
4
+ * Agent identity from OpenClaw runtime
5
+ */
6
+ export interface AgentIdentity {
7
+ agentId: string;
8
+ name?: string;
9
+ avatar?: string;
10
+ }
11
+
12
+ /**
13
+ * Push the agent's profile to the relay.
14
+ * Called on startup after relay connection is established.
15
+ */
16
+ export async function pushProfileToRelay(params: {
17
+ relayUrl: string;
18
+ token: string;
19
+ identity: AgentIdentity;
20
+ log?: Logger;
21
+ }): Promise<void> {
22
+ const { relayUrl, token, identity, log } = params;
23
+
24
+ // Convert WebSocket URL to HTTP URL for API call
25
+ const httpUrl = relayUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
26
+ const profileUrl = `${httpUrl}/api/v1/profile`;
27
+
28
+ // Map OpenClaw identity to relay profile format
29
+ // Only include avatar_url if it's actually an HTTPS URL (relay validates this)
30
+ const avatarUrl = identity.avatar?.startsWith("https://") ? identity.avatar : "";
31
+
32
+ const profile = {
33
+ name: identity.name ?? identity.agentId,
34
+ bio: "",
35
+ avatar_url: avatarUrl,
36
+ };
37
+
38
+ try {
39
+ const response = await fetch(profileUrl, {
40
+ method: "PUT",
41
+ headers: {
42
+ "Content-Type": "application/json",
43
+ Authorization: `Bearer ${token}`,
44
+ },
45
+ body: JSON.stringify(profile),
46
+ });
47
+
48
+ if (!response.ok) {
49
+ const errorText = await response.text().catch(() => "unknown error");
50
+ log?.warn(`[relay-client] Failed to push profile: ${response.status} ${errorText}`);
51
+ return;
52
+ }
53
+
54
+ log?.info(`[relay-client] Profile pushed to relay: ${profile.name}`);
55
+ } catch (err) {
56
+ log?.warn(`[relay-client] Failed to push profile: ${(err as Error).message}`);
57
+ }
58
+ }
59
+
3
60
  export interface Logger {
4
61
  info: (msg: string) => void;
5
62
  warn: (msg: string) => void;