@silicaclaw/cli 2026.3.20-2 → 2026.3.20-20

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.
Files changed (145) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/INSTALL.md +13 -7
  3. package/README.md +60 -12
  4. package/VERSION +1 -1
  5. package/apps/local-console/dist/apps/local-console/src/server.d.ts +139 -3
  6. package/apps/local-console/dist/apps/local-console/src/server.js +1018 -91
  7. package/apps/local-console/dist/packages/core/src/index.d.ts +2 -0
  8. package/apps/local-console/dist/packages/core/src/index.js +2 -0
  9. package/apps/local-console/dist/packages/core/src/privateCrypto.d.ts +17 -0
  10. package/apps/local-console/dist/packages/core/src/privateCrypto.js +40 -0
  11. package/apps/local-console/dist/packages/core/src/privateMessage.d.ts +23 -0
  12. package/apps/local-console/dist/packages/core/src/privateMessage.js +74 -0
  13. package/apps/local-console/dist/packages/core/src/profile.js +2 -0
  14. package/apps/local-console/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  15. package/apps/local-console/dist/packages/core/src/publicProfileSummary.js +3 -0
  16. package/apps/local-console/dist/packages/core/src/types.d.ts +40 -0
  17. package/apps/local-console/dist/packages/network/src/relayPreview.d.ts +12 -0
  18. package/apps/local-console/dist/packages/network/src/relayPreview.js +108 -8
  19. package/apps/local-console/dist/packages/network/src/types.d.ts +4 -0
  20. package/apps/local-console/dist/packages/storage/src/repos.d.ts +27 -1
  21. package/apps/local-console/dist/packages/storage/src/repos.js +35 -1
  22. package/apps/local-console/public/app/app.js +472 -11
  23. package/apps/local-console/public/app/events.js +21 -0
  24. package/apps/local-console/public/app/network.js +144 -32
  25. package/apps/local-console/public/app/overview.js +57 -27
  26. package/apps/local-console/public/app/social.js +342 -105
  27. package/apps/local-console/public/app/styles.css +149 -43
  28. package/apps/local-console/public/app/template.js +196 -100
  29. package/apps/local-console/public/app/translations.js +430 -316
  30. package/apps/local-console/src/server.ts +1164 -89
  31. package/apps/public-explorer/public/app/template.js +2 -2
  32. package/apps/public-explorer/public/app/translations.js +36 -36
  33. package/docs/NEW_USER_OPERATIONS.md +5 -5
  34. package/docs/OPENCLAW_BRIDGE.md +7 -7
  35. package/docs/OPENCLAW_BRIDGE_ZH.md +6 -6
  36. package/node_modules/@silicaclaw/core/dist/packages/core/src/index.d.ts +2 -0
  37. package/node_modules/@silicaclaw/core/dist/packages/core/src/index.js +2 -0
  38. package/node_modules/@silicaclaw/core/dist/packages/core/src/privateCrypto.d.ts +17 -0
  39. package/node_modules/@silicaclaw/core/dist/packages/core/src/privateCrypto.js +40 -0
  40. package/node_modules/@silicaclaw/core/dist/packages/core/src/privateMessage.d.ts +23 -0
  41. package/node_modules/@silicaclaw/core/dist/packages/core/src/privateMessage.js +74 -0
  42. package/node_modules/@silicaclaw/core/dist/packages/core/src/profile.js +2 -0
  43. package/node_modules/@silicaclaw/core/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  44. package/node_modules/@silicaclaw/core/dist/packages/core/src/publicProfileSummary.js +3 -0
  45. package/node_modules/@silicaclaw/core/dist/packages/core/src/types.d.ts +40 -0
  46. package/node_modules/@silicaclaw/core/package.json +2 -2
  47. package/node_modules/@silicaclaw/core/src/index.ts +2 -0
  48. package/node_modules/@silicaclaw/core/src/privateCrypto.ts +57 -0
  49. package/node_modules/@silicaclaw/core/src/privateMessage.ts +101 -0
  50. package/node_modules/@silicaclaw/core/src/profile.ts +2 -0
  51. package/node_modules/@silicaclaw/core/src/publicProfileSummary.ts +7 -0
  52. package/node_modules/@silicaclaw/core/src/types.ts +44 -0
  53. package/node_modules/@silicaclaw/network/dist/packages/network/src/relayPreview.d.ts +12 -0
  54. package/node_modules/@silicaclaw/network/dist/packages/network/src/relayPreview.js +108 -8
  55. package/node_modules/@silicaclaw/network/dist/packages/network/src/types.d.ts +4 -0
  56. package/node_modules/@silicaclaw/network/src/relayPreview.ts +120 -10
  57. package/node_modules/@silicaclaw/network/src/types.ts +2 -0
  58. package/node_modules/@silicaclaw/storage/dist/packages/core/src/index.d.ts +2 -0
  59. package/node_modules/@silicaclaw/storage/dist/packages/core/src/index.js +2 -0
  60. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateCrypto.d.ts +17 -0
  61. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateCrypto.js +40 -0
  62. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateMessage.d.ts +23 -0
  63. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateMessage.js +74 -0
  64. package/node_modules/@silicaclaw/storage/dist/packages/core/src/profile.js +2 -0
  65. package/node_modules/@silicaclaw/storage/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  66. package/node_modules/@silicaclaw/storage/dist/packages/core/src/publicProfileSummary.js +3 -0
  67. package/node_modules/@silicaclaw/storage/dist/packages/core/src/types.d.ts +40 -0
  68. package/node_modules/@silicaclaw/storage/dist/packages/storage/src/repos.d.ts +27 -1
  69. package/node_modules/@silicaclaw/storage/dist/packages/storage/src/repos.js +35 -1
  70. package/node_modules/@silicaclaw/storage/package.json +2 -2
  71. package/node_modules/@silicaclaw/storage/src/repos.ts +59 -1
  72. package/openclaw-skills/silicaclaw-bridge-setup/SKILL.md +18 -0
  73. package/openclaw-skills/silicaclaw-bridge-setup/VERSION +1 -1
  74. package/openclaw-skills/silicaclaw-bridge-setup/manifest.json +2 -2
  75. package/openclaw-skills/silicaclaw-broadcast/SKILL.md +18 -0
  76. package/openclaw-skills/silicaclaw-broadcast/VERSION +1 -1
  77. package/openclaw-skills/silicaclaw-broadcast/manifest.json +2 -2
  78. package/openclaw-skills/silicaclaw-network-config/SKILL.md +158 -0
  79. package/openclaw-skills/silicaclaw-network-config/VERSION +1 -0
  80. package/openclaw-skills/silicaclaw-network-config/agents/openai.yaml +6 -0
  81. package/openclaw-skills/silicaclaw-network-config/manifest.json +27 -0
  82. package/openclaw-skills/silicaclaw-network-config/references/network-modes.md +22 -0
  83. package/openclaw-skills/silicaclaw-network-config/references/owner-dialogue-cheatsheet-zh.md +47 -0
  84. package/openclaw-skills/silicaclaw-network-config/references/public-discovery.md +22 -0
  85. package/openclaw-skills/silicaclaw-owner-push/SKILL.md +18 -0
  86. package/openclaw-skills/silicaclaw-owner-push/VERSION +1 -1
  87. package/openclaw-skills/silicaclaw-owner-push/manifest.json +2 -2
  88. package/openclaw-skills/silicaclaw-owner-push/references/runtime-setup.md +3 -0
  89. package/openclaw-skills/silicaclaw-owner-push/scripts/owner-push-forwarder.mjs +151 -9
  90. package/package.json +1 -1
  91. package/packages/core/dist/packages/core/src/index.d.ts +2 -0
  92. package/packages/core/dist/packages/core/src/index.js +2 -0
  93. package/packages/core/dist/packages/core/src/privateCrypto.d.ts +17 -0
  94. package/packages/core/dist/packages/core/src/privateCrypto.js +40 -0
  95. package/packages/core/dist/packages/core/src/privateMessage.d.ts +23 -0
  96. package/packages/core/dist/packages/core/src/privateMessage.js +74 -0
  97. package/packages/core/dist/packages/core/src/profile.js +2 -0
  98. package/packages/core/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  99. package/packages/core/dist/packages/core/src/publicProfileSummary.js +3 -0
  100. package/packages/core/dist/packages/core/src/types.d.ts +40 -0
  101. package/packages/core/package.json +2 -2
  102. package/packages/core/src/index.ts +2 -0
  103. package/packages/core/src/privateCrypto.ts +57 -0
  104. package/packages/core/src/privateMessage.ts +101 -0
  105. package/packages/core/src/profile.ts +2 -0
  106. package/packages/core/src/publicProfileSummary.ts +7 -0
  107. package/packages/core/src/types.ts +44 -0
  108. package/packages/network/dist/packages/network/src/relayPreview.d.ts +12 -0
  109. package/packages/network/dist/packages/network/src/relayPreview.js +108 -8
  110. package/packages/network/dist/packages/network/src/types.d.ts +4 -0
  111. package/packages/network/src/relayPreview.ts +120 -10
  112. package/packages/network/src/types.ts +2 -0
  113. package/packages/storage/dist/packages/core/src/index.d.ts +2 -0
  114. package/packages/storage/dist/packages/core/src/index.js +2 -0
  115. package/packages/storage/dist/packages/core/src/privateCrypto.d.ts +17 -0
  116. package/packages/storage/dist/packages/core/src/privateCrypto.js +40 -0
  117. package/packages/storage/dist/packages/core/src/privateMessage.d.ts +23 -0
  118. package/packages/storage/dist/packages/core/src/privateMessage.js +74 -0
  119. package/packages/storage/dist/packages/core/src/profile.js +2 -0
  120. package/packages/storage/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  121. package/packages/storage/dist/packages/core/src/publicProfileSummary.js +3 -0
  122. package/packages/storage/dist/packages/core/src/types.d.ts +40 -0
  123. package/packages/storage/dist/packages/storage/src/repos.d.ts +27 -1
  124. package/packages/storage/dist/packages/storage/src/repos.js +35 -1
  125. package/packages/storage/package.json +2 -2
  126. package/packages/storage/src/repos.ts +59 -1
  127. package/scripts/silicaclaw-cli.mjs +4 -1
  128. package/scripts/silicaclaw-gateway.mjs +114 -2
  129. package/scripts/validate-openclaw-skill.mjs +19 -0
  130. package/node_modules/@silicaclaw/storage/dist/index.d.ts +0 -3
  131. package/node_modules/@silicaclaw/storage/dist/index.js +0 -19
  132. package/node_modules/@silicaclaw/storage/dist/jsonRepo.d.ts +0 -7
  133. package/node_modules/@silicaclaw/storage/dist/jsonRepo.js +0 -29
  134. package/node_modules/@silicaclaw/storage/dist/repos.d.ts +0 -61
  135. package/node_modules/@silicaclaw/storage/dist/repos.js +0 -67
  136. package/node_modules/@silicaclaw/storage/dist/socialRuntimeRepo.d.ts +0 -5
  137. package/node_modules/@silicaclaw/storage/dist/socialRuntimeRepo.js +0 -57
  138. package/packages/storage/dist/index.d.ts +0 -3
  139. package/packages/storage/dist/index.js +0 -19
  140. package/packages/storage/dist/jsonRepo.d.ts +0 -7
  141. package/packages/storage/dist/jsonRepo.js +0 -29
  142. package/packages/storage/dist/repos.d.ts +0 -61
  143. package/packages/storage/dist/repos.js +0 -67
  144. package/packages/storage/dist/socialRuntimeRepo.d.ts +0 -5
  145. package/packages/storage/dist/socialRuntimeRepo.js +0 -57
@@ -1,6 +1,6 @@
1
1
  import express, { NextFunction, Request, Response } from "express";
2
2
  import cors from "cors";
3
- import { execFile, spawnSync } from "child_process";
3
+ import { execFile, spawn, spawnSync } from "child_process";
4
4
  import { resolve } from "path";
5
5
  import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "fs";
6
6
  import { createHash } from "crypto";
@@ -11,19 +11,24 @@ import {
11
11
  AgentIdentity,
12
12
  DirectoryState,
13
13
  IndexRefRecord,
14
+ PrivateEncryptionKeyPair,
15
+ PrivateMessageReceiptRecord,
16
+ PrivateMessageRecord,
14
17
  PresenceRecord,
15
18
  ProfileInput,
16
19
  PublicProfile,
17
20
  PublicProfileSummary,
18
21
  SignedProfileRecord,
19
22
  buildPublicProfileSummary,
20
- buildIndexRecords,
21
23
  cleanupExpiredPresence,
22
24
  createDefaultProfileInput,
23
25
  createEmptyDirectoryState,
24
26
  createIdentity,
27
+ createPrivateEncryptionKeyPair,
25
28
  dedupeIndex,
29
+ decryptPrivatePayload,
26
30
  ensureDefaultSocialMd,
31
+ encryptPrivatePayload,
27
32
  ingestIndexRecord,
28
33
  ingestPresenceRecord,
29
34
  ingestProfileRecord,
@@ -34,6 +39,8 @@ import {
34
39
  resolveIdentityWithSocial,
35
40
  resolveProfileInputWithSocial,
36
41
  searchDirectory,
42
+ signPrivateMessage,
43
+ signPrivateMessageReceipt,
37
44
  signSocialMessage,
38
45
  signSocialMessageObservation,
39
46
  signPresence,
@@ -46,6 +53,8 @@ import {
46
53
  verifySocialMessage,
47
54
  verifySocialMessageObservation,
48
55
  verifyPresence,
56
+ verifyPrivateMessage,
57
+ verifyPrivateMessageReceipt,
49
58
  verifyProfile,
50
59
  } from "@silicaclaw/core";
51
60
  import {
@@ -62,6 +71,11 @@ import {
62
71
  CacheRepo,
63
72
  IdentityRepo,
64
73
  LogRepo,
74
+ PrivateEncryptionKeyRepo,
75
+ PrivateMessagingRuntimeRepo,
76
+ PrivateMessagingRuntimeState,
77
+ PrivateMessageReceiptRepo,
78
+ PrivateMessageRepo,
65
79
  ProfileRepo,
66
80
  SocialMessageGovernanceConfig,
67
81
  SocialMessageGovernanceRepo,
@@ -89,7 +103,10 @@ const DEFAULT_GLOBAL_ROOM = defaults.network.global_preview.room;
89
103
  const DEFAULT_BRIDGE_API_BASE = defaults.bridge.api_base;
90
104
  const OPENCLAW_GATEWAY_PORT = defaults.ports.openclaw_gateway;
91
105
  const OPENCLAW_GATEWAY_URL = `http://${OPENCLAW_GATEWAY_HOST}:${OPENCLAW_GATEWAY_PORT}/`;
106
+ const OPENCLAW_RUNTIME_CACHE_MS = 15_000;
107
+ const OPENCLAW_BRIDGE_STATUS_CACHE_MS = 5_000;
92
108
  const NETWORK_PEER_REMOVE_AFTER_MS = Number(process.env.NETWORK_PEER_REMOVE_AFTER_MS || 180_000);
109
+ const DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT = Number(process.env.DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT || 1000);
93
110
  const NETWORK_UDP_BIND_ADDRESS = process.env.NETWORK_UDP_BIND_ADDRESS || "0.0.0.0";
94
111
  const NETWORK_UDP_BROADCAST_ADDRESS = process.env.NETWORK_UDP_BROADCAST_ADDRESS || "255.255.255.255";
95
112
  const NETWORK_PEER_ID = process.env.NETWORK_PEER_ID;
@@ -102,6 +119,12 @@ const WEBRTC_BOOTSTRAP_HINTS = process.env.WEBRTC_BOOTSTRAP_HINTS || "";
102
119
  const PROFILE_VERSION = "v0.9";
103
120
  const SOCIAL_MESSAGE_TOPIC = "social.message";
104
121
  const SOCIAL_MESSAGE_OBSERVATION_TOPIC = "social.message.observation";
122
+ const PRIVATE_MESSAGE_TOPIC = "private.message";
123
+ const PRIVATE_MESSAGE_RECEIPT_TOPIC = "private.message.receipt";
124
+ const PRIVATE_MESSAGE_HISTORY_LIMIT = Number(process.env.PRIVATE_MESSAGE_HISTORY_LIMIT || 1000);
125
+ const PRIVATE_MESSAGE_RECEIPT_HISTORY_LIMIT = Number(process.env.PRIVATE_MESSAGE_RECEIPT_HISTORY_LIMIT || 2000);
126
+ const PRIVATE_MESSAGE_QUERY_LIMIT = Number(process.env.PRIVATE_MESSAGE_QUERY_LIMIT || 100);
127
+ const PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS = Number(process.env.PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS || 750);
105
128
  const DEFAULT_SOCIAL_MESSAGE_CHANNEL = "global";
106
129
  const SOCIAL_MESSAGE_MAX_BODY_CHARS = Number(process.env.SOCIAL_MESSAGE_MAX_BODY_CHARS || 500);
107
130
  const SOCIAL_MESSAGE_HISTORY_LIMIT = Number(process.env.SOCIAL_MESSAGE_HISTORY_LIMIT || 100);
@@ -115,6 +138,16 @@ const SOCIAL_MESSAGE_MAX_AGE_MS = Number(process.env.SOCIAL_MESSAGE_MAX_AGE_MS |
115
138
  const SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT = Number(process.env.SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT || 500);
116
139
  const SOCIAL_MESSAGE_REPLAY_WINDOW_MS = Number(process.env.SOCIAL_MESSAGE_REPLAY_WINDOW_MS || 10 * 60_000);
117
140
  const SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST = Number(process.env.SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST || 3);
141
+ const SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS = Number(
142
+ process.env.SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS || 120_000
143
+ );
144
+
145
+ type StoredPrivateMessageRecord = PrivateMessageRecord & {
146
+ local_plaintext?: string;
147
+ };
148
+ const PROFILE_RELAY_REFRESH_INTERVAL_MS = Number(
149
+ process.env.PROFILE_RELAY_REFRESH_INTERVAL_MS || 120_000
150
+ );
118
151
  const SOCIAL_MESSAGE_BLOCKED_AGENT_IDS = new Set(
119
152
  dedupeStrings(parseListEnv(process.env.SOCIAL_MESSAGE_BLOCKED_AGENT_IDS || ""))
120
153
  );
@@ -157,6 +190,10 @@ function normalizeVersionText(value: unknown): string {
157
190
  return text.startsWith("v") ? text.slice(1) : text;
158
191
  }
159
192
 
193
+ function formatBytesToMiB(value: number): number {
194
+ return Math.round((value / (1024 * 1024)) * 10) / 10;
195
+ }
196
+
160
197
  function tokenizeVersion(value: unknown): Array<number | string> {
161
198
  return normalizeVersionText(value)
162
199
  .split(/[^0-9A-Za-z]+/)
@@ -186,6 +223,14 @@ function compareVersionTokens(left: unknown, right: unknown): number {
186
223
  return 0;
187
224
  }
188
225
 
226
+ function userNpmCacheDir(): string {
227
+ return resolve(homedir(), ".silicaclaw", "npm-cache");
228
+ }
229
+
230
+ function userShimPath(): string {
231
+ return resolve(homedir(), ".silicaclaw", "bin", "silicaclaw");
232
+ }
233
+
189
234
  function resolveWorkspaceRoot(cwd = process.cwd()): string {
190
235
  if (existsSync(resolve(cwd, "apps", "local-console", "package.json"))) {
191
236
  return cwd;
@@ -441,45 +486,66 @@ function readOpenClawConfiguredGateway(workspaceRoot: string) {
441
486
  } as const;
442
487
  }
443
488
 
444
- function detectOpenClawRuntime(workspaceRoot: string) {
445
- const configuredGateway = readOpenClawConfiguredGateway(workspaceRoot);
446
- const result = spawnSync("ps", ["-Ao", "pid=,ppid=,command="], {
447
- encoding: "utf8",
448
- });
449
- const stdout = String(result.stdout || "");
450
- const lines = stdout
451
- .split("\n")
452
- .map((line) => line.trim())
453
- .filter(Boolean);
489
+ function resolveOpenClawStatusCommand(workspaceRoot: string) {
490
+ const explicitBin = String(process.env.OPENCLAW_BIN || "").trim();
491
+ if (explicitBin) {
492
+ return { cmd: explicitBin, args: ["status"] } as const;
493
+ }
454
494
 
455
- const processes = lines
456
- .map((line) => {
457
- const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
458
- if (!match) return null;
459
- const command = match[3] || "";
460
- const lower = command.toLowerCase();
461
- const isOpenClaw =
462
- lower.includes(" openclaw ") ||
463
- lower.endsWith(" openclaw") ||
464
- lower.includes("/openclaw ") ||
465
- lower.includes("openclaw.mjs") ||
466
- lower.includes("openclaw gateway") ||
467
- lower.includes("openclaw agent") ||
468
- lower.includes("openclaw message");
469
- if (!isOpenClaw) return null;
470
- return {
471
- pid: Number(match[1]),
472
- ppid: Number(match[2]),
473
- command,
474
- };
475
- })
476
- .filter((item): item is { pid: number; ppid: number; command: string } => Boolean(item));
495
+ const configuredSourceDir = String(process.env.OPENCLAW_SOURCE_DIR || "").trim();
496
+ const defaultSourceDir = defaultOpenClawSourceDir(workspaceRoot);
497
+ const sourceDir = configuredSourceDir || defaultSourceDir;
498
+ const sourceEntry = existingPathOrNull(resolve(sourceDir, "openclaw.mjs"));
499
+ if (sourceEntry) {
500
+ return { cmd: process.execPath, args: [sourceEntry, "status"] } as const;
501
+ }
502
+
503
+ const commandPath = resolveExecutableInPath("openclaw");
504
+ if (commandPath) {
505
+ return { cmd: commandPath, args: ["status"] } as const;
506
+ }
507
+
508
+ return null;
509
+ }
477
510
 
478
- const openclawPids = new Set(processes.map((item) => item.pid));
479
- const gatewayProbe = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], {
511
+ function resolveOpenClawGatewayProbeCommand(workspaceRoot: string) {
512
+ const explicitBin = String(process.env.OPENCLAW_BIN || "").trim();
513
+ if (explicitBin) {
514
+ return { cmd: explicitBin, args: ["gateway", "probe"] } as const;
515
+ }
516
+
517
+ const configuredSourceDir = String(process.env.OPENCLAW_SOURCE_DIR || "").trim();
518
+ const defaultSourceDir = defaultOpenClawSourceDir(workspaceRoot);
519
+ const sourceDir = configuredSourceDir || defaultSourceDir;
520
+ const sourceEntry = existingPathOrNull(resolve(sourceDir, "openclaw.mjs"));
521
+ if (sourceEntry) {
522
+ return { cmd: process.execPath, args: [sourceEntry, "gateway", "probe"] } as const;
523
+ }
524
+
525
+ const commandPath = resolveExecutableInPath("openclaw");
526
+ if (commandPath) {
527
+ return { cmd: commandPath, args: ["gateway", "probe"] } as const;
528
+ }
529
+
530
+ return null;
531
+ }
532
+
533
+ function detectOpenClawRuntime(workspaceRoot: string) {
534
+ const configuredGateway = readOpenClawConfiguredGateway(workspaceRoot);
535
+ const statusCommand = resolveOpenClawStatusCommand(workspaceRoot);
536
+ const statusLooksConfigured = Boolean(
537
+ statusCommand ||
538
+ configuredGateway.config_path ||
539
+ detectOpenClawInstallation(workspaceRoot).detected
540
+ );
541
+ const gatewayProbeCommand = ["lsof", "-nP", `-iTCP:${configuredGateway.gateway_port}`, "-sTCP:LISTEN"];
542
+ const gatewayProbe = spawnSync(gatewayProbeCommand[0], gatewayProbeCommand.slice(1), {
480
543
  encoding: "utf8",
544
+ timeout: 1200,
481
545
  });
482
- const gatewayLines = String(gatewayProbe.stdout || "")
546
+ const gatewayStatusStdout = String(gatewayProbe.stdout || "");
547
+ const gatewayStatusStderr = String(gatewayProbe.stderr || "");
548
+ const gatewayLines = gatewayStatusStdout
483
549
  .split("\n")
484
550
  .map((line) => line.trim())
485
551
  .filter(Boolean);
@@ -489,14 +555,9 @@ function detectOpenClawRuntime(workspaceRoot: string) {
489
555
  const parts = line.split(/\s+/);
490
556
  const pid = Number(parts[1] || 0);
491
557
  const command = parts[0] || "";
492
- const lowerCommand = command.toLowerCase();
493
558
  const endpoint = parts[8] || parts[parts.length - 1] || "";
494
559
  const portMatch = endpoint.match(/:(\d+)(?:\s*\(|$)/);
495
560
  if (!pid || !command || !portMatch) return null;
496
- const isOpenClawListener =
497
- openclawPids.has(pid) ||
498
- lowerCommand.includes("openclaw");
499
- if (!isOpenClawListener) return null;
500
561
  const port = Number(portMatch[1]);
501
562
  if (!Number.isFinite(port) || port <= 0) return null;
502
563
  return {
@@ -507,46 +568,106 @@ function detectOpenClawRuntime(workspaceRoot: string) {
507
568
  };
508
569
  })
509
570
  .filter((item): item is { pid: number; ppid: number; port: number; command: string } => Boolean(item));
571
+ const gatewayProbeOk = gatewayListeners.length > 0;
572
+ let processes: Array<{ pid: number; ppid: number; command: string }> = gatewayListeners.map((item) => ({
573
+ pid: item.pid,
574
+ ppid: item.ppid,
575
+ command: item.command,
576
+ }));
577
+ let processResult: ReturnType<typeof spawnSync> | null = null;
578
+ if (!gatewayProbeOk) {
579
+ processResult = spawnSync("ps", ["-Ao", "pid=,ppid=,command="], {
580
+ encoding: "utf8",
581
+ timeout: 1200,
582
+ });
583
+ const stdout = String(processResult.stdout || "");
584
+ const lines = stdout
585
+ .split("\n")
586
+ .map((line) => line.trim())
587
+ .filter(Boolean);
588
+ processes = lines
589
+ .map((line) => {
590
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
591
+ if (!match) return null;
592
+ const command = match[3] || "";
593
+ const lower = command.toLowerCase();
594
+ const isOpenClaw =
595
+ lower.includes(" openclaw ") ||
596
+ lower.endsWith(" openclaw") ||
597
+ lower.includes("/openclaw ") ||
598
+ lower.includes("openclaw.mjs") ||
599
+ lower.includes("openclaw gateway") ||
600
+ lower.includes("openclaw agent") ||
601
+ lower.includes("openclaw message");
602
+ if (!isOpenClaw) return null;
603
+ return {
604
+ pid: Number(match[1]),
605
+ ppid: Number(match[2]),
606
+ command,
607
+ };
608
+ })
609
+ .filter((item): item is { pid: number; ppid: number; command: string } => Boolean(item));
610
+ }
611
+
510
612
  const preferredListener =
511
613
  gatewayListeners.find((item) => item.port === configuredGateway.gateway_port) ||
512
614
  gatewayListeners[0] ||
513
615
  null;
514
-
515
- const combinedProcesses = new Map<number, { pid: number; ppid: number; command: string }>();
516
- for (const process of [...processes, ...gatewayListeners]) {
517
- if (!combinedProcesses.has(process.pid)) {
518
- combinedProcesses.set(process.pid, process);
519
- continue;
520
- }
521
- const current = combinedProcesses.get(process.pid);
522
- if (current && current.command.length < process.command.length) {
523
- combinedProcesses.set(process.pid, process);
524
- }
525
- }
526
- const allProcesses = Array.from(combinedProcesses.values());
527
- const gatewayReachable = gatewayListeners.length > 0;
616
+ const allProcesses = processes.slice(0, 10);
617
+ const gatewayReachable = gatewayProbeOk;
528
618
  const detectionNotes = [];
529
- if (result.status !== 0) detectionNotes.push(String(result.stderr || "ps failed").trim());
530
619
  if (gatewayProbe.status !== 0 && gatewayLines.length === 0) {
531
- detectionNotes.push(String(gatewayProbe.stderr || "lsof failed").trim());
620
+ detectionNotes.push(String(gatewayStatusStderr || "openclaw gateway probe failed").trim());
621
+ }
622
+ if (processResult && processResult.status !== 0) {
623
+ detectionNotes.push(String(processResult.stderr || "ps failed").trim());
532
624
  }
533
625
  const gatewayPort = preferredListener?.port || configuredGateway.gateway_port;
534
626
  const gatewayUrl = `http://${OPENCLAW_GATEWAY_HOST}:${gatewayPort}/`;
535
627
 
536
628
  return {
537
- running: allProcesses.length > 0 || gatewayReachable,
629
+ running: gatewayProbeOk || allProcesses.length > 0 || gatewayReachable,
538
630
  process_count: allProcesses.length,
539
631
  processes: allProcesses.slice(0, 10),
540
632
  detection_error: detectionNotes.filter(Boolean).join(" | ") || null,
541
633
  gateway_url: gatewayUrl,
542
634
  gateway_port: gatewayPort,
543
635
  gateway_reachable: gatewayReachable,
636
+ status_command: statusCommand ? [statusCommand.cmd, ...statusCommand.args].join(" ") : null,
637
+ status_ok: statusLooksConfigured,
638
+ status_summary: statusLooksConfigured
639
+ ? configuredGateway.config_path
640
+ ? `configured via ${configuredGateway.config_path}`
641
+ : statusCommand
642
+ ? `command available: ${[statusCommand.cmd, ...statusCommand.args].join(" ")}`
643
+ : "OpenClaw environment detected"
644
+ : null,
645
+ gateway_probe_command: gatewayProbeCommand.join(" "),
646
+ gateway_probe_ok: gatewayProbeOk,
647
+ gateway_probe_summary: gatewayProbeOk
648
+ ? gatewayStatusStdout
649
+ .split("\n")
650
+ .map((line) => line.trim())
651
+ .filter(Boolean)
652
+ .slice(0, 4)
653
+ .join(" | ")
654
+ : null,
544
655
  configured_gateway_url: configuredGateway.gateway_url,
545
656
  configured_gateway_port: configuredGateway.gateway_port,
546
657
  configured_gateway_bind: configuredGateway.gateway_bind,
547
658
  configured_gateway_config_path: configuredGateway.config_path,
548
659
  detection_mode:
549
- processes.length > 0 && gatewayReachable
660
+ gatewayProbeOk
661
+ ? (
662
+ processes.length > 0 && gatewayReachable
663
+ ? "gateway-probe+process+gateway"
664
+ : gatewayReachable
665
+ ? "gateway-probe+gateway"
666
+ : processes.length > 0
667
+ ? "gateway-probe+process"
668
+ : "gateway-probe"
669
+ )
670
+ : processes.length > 0 && gatewayReachable
550
671
  ? "process+gateway"
551
672
  : gatewayReachable
552
673
  ? "gateway"
@@ -766,6 +887,7 @@ type IntegrationStatusSummary = {
766
887
  };
767
888
 
768
889
  type SocialMessageView = SocialMessageRecord & {
890
+ avatar_url?: string;
769
891
  is_self: boolean;
770
892
  online: boolean;
771
893
  last_seen_at: number | null;
@@ -775,6 +897,17 @@ type SocialMessageView = SocialMessageRecord & {
775
897
  delivery_status: "local-only" | "remote-observed";
776
898
  };
777
899
 
900
+ type PrivateMessageView = {
901
+ message_id: string;
902
+ conversation_id: string;
903
+ from_agent_id: string;
904
+ to_agent_id: string;
905
+ body: string;
906
+ created_at: number;
907
+ is_self: boolean;
908
+ delivery_status: "sent" | "direct-sent" | "fallback-sent" | "received" | "read";
909
+ };
910
+
778
911
  type RuntimeMessageGovernance = SocialMessageGovernanceConfig;
779
912
 
780
913
  type OpenClawBridgeStatus = {
@@ -818,11 +951,17 @@ type OpenClawBridgeStatus = {
818
951
  gateway_url: string;
819
952
  gateway_port: number;
820
953
  gateway_reachable: boolean;
954
+ status_command: string | null;
955
+ status_ok: boolean;
956
+ status_summary: string | null;
957
+ gateway_probe_command: string | null;
958
+ gateway_probe_ok: boolean;
959
+ gateway_probe_summary: string | null;
821
960
  configured_gateway_url: string;
822
961
  configured_gateway_port: number;
823
962
  configured_gateway_bind: string | null;
824
963
  configured_gateway_config_path: string | null;
825
- detection_mode: "process" | "gateway" | "process+gateway" | "not_running";
964
+ detection_mode: "gateway-probe" | "gateway-probe+process" | "gateway-probe+gateway" | "gateway-probe+process+gateway" | "process" | "gateway" | "process+gateway" | "not_running";
826
965
  };
827
966
  skill_learning: {
828
967
  available: boolean;
@@ -899,6 +1038,10 @@ export class LocalNodeService {
899
1038
  private socialMessageGovernanceRepo: SocialMessageGovernanceRepo;
900
1039
  private socialMessageRepo: SocialMessageRepo;
901
1040
  private socialMessageObservationRepo: SocialMessageObservationRepo;
1041
+ private privateMessageRepo: PrivateMessageRepo;
1042
+ private privateMessageReceiptRepo: PrivateMessageReceiptRepo;
1043
+ private privateEncryptionKeyRepo: PrivateEncryptionKeyRepo;
1044
+ private privateMessagingRuntimeRepo: PrivateMessagingRuntimeRepo;
902
1045
  private socialRuntimeRepo: SocialRuntimeRepo;
903
1046
 
904
1047
  private identity: AgentIdentity | null = null;
@@ -906,15 +1049,34 @@ export class LocalNodeService {
906
1049
  private directory: DirectoryState = createEmptyDirectoryState();
907
1050
  private socialMessages: SocialMessageRecord[] = [];
908
1051
  private socialMessageObservations: SocialMessageObservationRecord[] = [];
1052
+ private privateMessages: PrivateMessageRecord[] = [];
1053
+ private privateMessageReceipts: PrivateMessageReceiptRecord[] = [];
1054
+ private privateEncryptionKeyPair: PrivateEncryptionKeyPair | null = null;
1055
+ private privateMessagingRuntime: PrivateMessagingRuntimeState | null = null;
1056
+ private privatePeerRoutes: Record<string, string> = {};
1057
+ private privatePeerEncryptionKeys: Record<string, string> = {};
1058
+ private privateMessageBodyCache = new Map<string, string>();
1059
+ private privateMessageDeliveryStatusCache = new Map<string, PrivateMessageView["delivery_status"]>();
909
1060
  private messageGovernance: RuntimeMessageGovernance;
1061
+ private privateMessagesPersistDirty = false;
1062
+ private privateMessageReceiptsPersistDirty = false;
1063
+ private privateMessagesPersistTimer: NodeJS.Timeout | null = null;
1064
+ private privateMessageReceiptsPersistTimer: NodeJS.Timeout | null = null;
910
1065
 
911
1066
  private receivedCount = 0;
912
1067
  private broadcastCount = 0;
913
1068
  private lastMessageAt = 0;
914
1069
  private lastBroadcastAt = 0;
1070
+ private lastProfileBroadcastAt = 0;
1071
+ private lastProfileBroadcastSignature = "";
1072
+ private lastReplayBroadcastAt = 0;
1073
+ private lastReplayBroadcastSignature = "";
915
1074
  private lastBroadcastErrorAt = 0;
916
1075
  private lastBroadcastError: string | null = null;
917
1076
  private broadcastFailureCount = 0;
1077
+ private consecutiveBroadcastFailures = 0;
1078
+ private lastBroadcastRecoveryAttemptAt = 0;
1079
+ private broadcastRecoveryInFlight = false;
918
1080
  private broadcaster: NodeJS.Timeout | null = null;
919
1081
  private subscriptionsBound = false;
920
1082
  private broadcastEnabled = true;
@@ -956,6 +1118,8 @@ export class LocalNodeService {
956
1118
  private networkReconnectTimer: NodeJS.Timeout | null = null;
957
1119
  private networkReconnectDelayMs = 5_000;
958
1120
  private appVersion = "unknown";
1121
+ private openclawRuntimeCache: { value: ReturnType<typeof detectOpenClawRuntime>; expiresAt: number } | null = null;
1122
+ private openclawBridgeStatusCache: { value: OpenClawBridgeStatus; expiresAt: number } | null = null;
959
1123
 
960
1124
  constructor(options?: { workspaceRoot?: string; projectRoot?: string; storageRoot?: string }) {
961
1125
  this.workspaceRoot = options?.workspaceRoot || resolveWorkspaceRoot();
@@ -971,6 +1135,10 @@ export class LocalNodeService {
971
1135
  this.socialMessageGovernanceRepo = new SocialMessageGovernanceRepo(this.storageRoot);
972
1136
  this.socialMessageRepo = new SocialMessageRepo(this.storageRoot);
973
1137
  this.socialMessageObservationRepo = new SocialMessageObservationRepo(this.storageRoot);
1138
+ this.privateMessageRepo = new PrivateMessageRepo(this.storageRoot);
1139
+ this.privateMessageReceiptRepo = new PrivateMessageReceiptRepo(this.storageRoot);
1140
+ this.privateEncryptionKeyRepo = new PrivateEncryptionKeyRepo(this.storageRoot);
1141
+ this.privateMessagingRuntimeRepo = new PrivateMessagingRuntimeRepo(this.storageRoot);
974
1142
  this.socialRuntimeRepo = new SocialRuntimeRepo(this.storageRoot);
975
1143
  this.messageGovernance = this.defaultMessageGovernance();
976
1144
 
@@ -1001,6 +1169,24 @@ export class LocalNodeService {
1001
1169
  this.networkPort = resolved.port;
1002
1170
  }
1003
1171
 
1172
+ private getCachedOpenClawRuntime() {
1173
+ const now = Date.now();
1174
+ if (this.openclawRuntimeCache && this.openclawRuntimeCache.expiresAt > now) {
1175
+ return this.openclawRuntimeCache.value;
1176
+ }
1177
+ const value = detectOpenClawRuntime(this.projectRoot);
1178
+ this.openclawRuntimeCache = {
1179
+ value,
1180
+ expiresAt: now + OPENCLAW_RUNTIME_CACHE_MS,
1181
+ };
1182
+ return value;
1183
+ }
1184
+
1185
+ private invalidateOpenClawCaches() {
1186
+ this.openclawRuntimeCache = null;
1187
+ this.openclawBridgeStatusCache = null;
1188
+ }
1189
+
1004
1190
  async start(): Promise<void> {
1005
1191
  await this.hydrateFromDisk();
1006
1192
 
@@ -1014,6 +1200,7 @@ export class LocalNodeService {
1014
1200
  clearInterval(this.broadcaster);
1015
1201
  this.broadcaster = null;
1016
1202
  }
1203
+ await this.flushPrivatePersistence();
1017
1204
  if (this.networkStarted) {
1018
1205
  await this.network.stop();
1019
1206
  }
@@ -1034,6 +1221,9 @@ export class LocalNodeService {
1034
1221
  getOverview() {
1035
1222
  const discovered = this.search("");
1036
1223
  const onlineCount = discovered.filter((profile) => profile.online).length;
1224
+ const openclawInstallation = detectOpenClawInstallation(this.projectRoot);
1225
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1226
+ const openclawSkillInstallation = detectOpenClawSkillInstallation();
1037
1227
 
1038
1228
  return {
1039
1229
  app_version: this.appVersion,
@@ -1050,6 +1240,15 @@ export class LocalNodeService {
1050
1240
  init_state: this.initState,
1051
1241
  presence_ttl_ms: PRESENCE_TTL_MS,
1052
1242
  onboarding: this.getOnboardingSummary(),
1243
+ openclaw: {
1244
+ detected: openclawInstallation.detected,
1245
+ running: openclawRuntime.running,
1246
+ detection_mode: openclawRuntime.detection_mode,
1247
+ gateway_url: openclawRuntime.gateway_url,
1248
+ gateway_probe_ok: openclawRuntime.gateway_probe_ok,
1249
+ status_ok: openclawRuntime.status_ok,
1250
+ skill_installed: openclawSkillInstallation.installed,
1251
+ },
1053
1252
  social: {
1054
1253
  found: this.socialFound,
1055
1254
  enabled: this.socialConfig.enabled,
@@ -1197,6 +1396,7 @@ export class LocalNodeService {
1197
1396
  const relayCapable = this.adapterMode === "webrtc-preview" || this.adapterMode === "relay-preview";
1198
1397
  const peers: Array<{ status?: string }> = diagnostics?.peers?.items ?? [];
1199
1398
  const online = peers.filter((peer: { status?: string }) => peer.status === "online").length;
1399
+ const memory = process.memoryUsage();
1200
1400
 
1201
1401
  return {
1202
1402
  adapter: this.adapterMode,
@@ -1221,6 +1421,23 @@ export class LocalNodeService {
1221
1421
  adapter_stats: diagnostics?.stats ?? null,
1222
1422
  adapter_transport_stats: diagnostics?.transport_stats ?? null,
1223
1423
  adapter_discovery_stats: diagnostics?.discovery_stats ?? null,
1424
+ runtime_diagnostics: {
1425
+ memory_mib: {
1426
+ rss: formatBytesToMiB(memory.rss),
1427
+ heap_used: formatBytesToMiB(memory.heapUsed),
1428
+ heap_total: formatBytesToMiB(memory.heapTotal),
1429
+ external: formatBytesToMiB(memory.external),
1430
+ },
1431
+ directory: {
1432
+ profile_count: Object.keys(this.directory.profiles).length,
1433
+ presence_count: Object.keys(this.directory.presence).length,
1434
+ index_key_count: Object.keys(this.directory.index).length,
1435
+ },
1436
+ social: {
1437
+ message_count: this.socialMessages.length,
1438
+ observation_count: this.socialMessageObservations.length,
1439
+ },
1440
+ },
1224
1441
  adapter_diagnostics_summary: relayCapable || diagnostics
1225
1442
  ? {
1226
1443
  started: this.networkStarted,
@@ -1337,6 +1554,92 @@ export class LocalNodeService {
1337
1554
  };
1338
1555
  }
1339
1556
 
1557
+ getAppUpdateStatus() {
1558
+ const currentVersion = normalizeVersionText(this.appVersion) || "unknown";
1559
+ const fallback = {
1560
+ current_version: currentVersion,
1561
+ latest_version: currentVersion,
1562
+ update_available: false,
1563
+ channel: "latest",
1564
+ platform: process.platform,
1565
+ checked_at: Date.now(),
1566
+ can_update: true,
1567
+ check_error: null as string | null,
1568
+ };
1569
+ try {
1570
+ const result = spawnSync("npm", ["view", "@silicaclaw/cli", "dist-tags", "--json"], {
1571
+ cwd: this.projectRoot,
1572
+ encoding: "utf8",
1573
+ env: {
1574
+ ...process.env,
1575
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1576
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1577
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1578
+ },
1579
+ });
1580
+ if ((result.status ?? 1) !== 0) {
1581
+ return {
1582
+ ...fallback,
1583
+ check_error: String(result.stderr || result.stdout || "npm view failed").trim() || "npm view failed",
1584
+ };
1585
+ }
1586
+ const tags = JSON.parse(String(result.stdout || "{}").trim() || "{}") as { latest?: string };
1587
+ const latestVersion = normalizeVersionText(tags.latest || currentVersion) || currentVersion;
1588
+ return {
1589
+ ...fallback,
1590
+ latest_version: latestVersion,
1591
+ update_available: compareVersionTokens(latestVersion, currentVersion) > 0,
1592
+ };
1593
+ } catch (error) {
1594
+ return {
1595
+ ...fallback,
1596
+ check_error: error instanceof Error ? error.message : String(error),
1597
+ };
1598
+ }
1599
+ }
1600
+
1601
+ startAppUpdate(): { started: boolean; target_version: string; platform: string; reason?: string } {
1602
+ const status = this.getAppUpdateStatus();
1603
+ if (!status.update_available || !status.latest_version) {
1604
+ return {
1605
+ started: false,
1606
+ target_version: status.latest_version || status.current_version,
1607
+ platform: process.platform,
1608
+ reason: status.check_error || "already_current",
1609
+ };
1610
+ }
1611
+ const shimPath = userShimPath();
1612
+ const scriptPath = resolve(this.workspaceRoot, "scripts", "silicaclaw-cli.mjs");
1613
+ const useShim = existsSync(shimPath);
1614
+ if (!useShim && !existsSync(scriptPath)) {
1615
+ return {
1616
+ started: false,
1617
+ target_version: status.latest_version,
1618
+ platform: process.platform,
1619
+ reason: "missing_cli_script",
1620
+ };
1621
+ }
1622
+ const command = useShim ? shimPath : process.execPath;
1623
+ const args = useShim ? ["update"] : [scriptPath, "update"];
1624
+ const child = spawn(command, args, {
1625
+ cwd: this.projectRoot,
1626
+ detached: true,
1627
+ stdio: "ignore",
1628
+ env: {
1629
+ ...process.env,
1630
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1631
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1632
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1633
+ },
1634
+ });
1635
+ child.unref();
1636
+ return {
1637
+ started: true,
1638
+ target_version: status.latest_version,
1639
+ platform: process.platform,
1640
+ };
1641
+ }
1642
+
1340
1643
  getIntegrationSummary() {
1341
1644
  const status = this.getIntegrationStatus();
1342
1645
  const runtimeGenerated = Boolean(this.socialRuntime && this.socialRuntime.last_loaded_at > 0);
@@ -1635,6 +1938,7 @@ export class LocalNodeService {
1635
1938
  return {
1636
1939
  ...message,
1637
1940
  display_name: profile?.display_name || message.display_name || "Unnamed",
1941
+ avatar_url: profile?.avatar_url || "",
1638
1942
  is_self: message.agent_id === this.identity?.agent_id,
1639
1943
  online: isAgentOnline(lastSeenAt, Date.now(), PRESENCE_TTL_MS),
1640
1944
  last_seen_at: lastSeenAt || null,
@@ -1654,10 +1958,161 @@ export class LocalNodeService {
1654
1958
  };
1655
1959
  }
1656
1960
 
1961
+ getPrivateMessagingState() {
1962
+ return {
1963
+ enabled: Boolean(this.identity && this.privateEncryptionKeyPair),
1964
+ agent_id: this.identity?.agent_id || "",
1965
+ encryption_public_key: this.privateEncryptionKeyPair?.public_key || "",
1966
+ conversation_count: this.getPrivateConversations().length,
1967
+ message_count: this.privateMessages.length,
1968
+ runtime: this.privateMessagingRuntime,
1969
+ };
1970
+ }
1971
+
1972
+ getPrivateConversations(): Array<{
1973
+ conversation_id: string;
1974
+ peer_agent_id: string;
1975
+ peer_display_name: string;
1976
+ peer_avatar_url: string;
1977
+ peer_public_key: string;
1978
+ last_message_at: number | null;
1979
+ unread_count: number;
1980
+ }> {
1981
+ const conversations = new Map<string, {
1982
+ conversation_id: string;
1983
+ peer_agent_id: string;
1984
+ peer_display_name: string;
1985
+ peer_avatar_url: string;
1986
+ peer_public_key: string;
1987
+ last_message_at: number | null;
1988
+ unread_count: number;
1989
+ }>();
1990
+ for (const message of this.privateMessages) {
1991
+ if (message.from_agent_id === message.to_agent_id) {
1992
+ continue;
1993
+ }
1994
+ const peerAgentId = message.from_agent_id === this.identity?.agent_id ? message.to_agent_id : message.from_agent_id;
1995
+ if (!peerAgentId || peerAgentId === this.identity?.agent_id) {
1996
+ continue;
1997
+ }
1998
+ const peerProfile = this.directory.profiles[peerAgentId];
1999
+ const current = conversations.get(message.conversation_id);
2000
+ const nextLast = Math.max(current?.last_message_at || 0, message.created_at || 0) || null;
2001
+ const learnedPeerKey = this.privatePeerEncryptionKeys[peerAgentId] || "";
2002
+ conversations.set(message.conversation_id, {
2003
+ conversation_id: message.conversation_id,
2004
+ peer_agent_id: peerAgentId,
2005
+ peer_display_name: peerProfile?.display_name || peerAgentId,
2006
+ peer_avatar_url: peerProfile?.avatar_url || "",
2007
+ peer_public_key: learnedPeerKey || peerProfile?.private_encryption_public_key || "",
2008
+ last_message_at: nextLast,
2009
+ unread_count: current?.unread_count || 0,
2010
+ });
2011
+ }
2012
+ return Array.from(conversations.values()).sort((a, b) => (b.last_message_at || 0) - (a.last_message_at || 0));
2013
+ }
2014
+
2015
+ getPrivateMessages(conversationId: string, limit = PRIVATE_MESSAGE_QUERY_LIMIT): PrivateMessageView[] {
2016
+ const normalizedConversationId = String(conversationId || "").trim();
2017
+ const resolvedLimit = Math.max(1, Math.min(PRIVATE_MESSAGE_QUERY_LIMIT, Number(limit) || PRIVATE_MESSAGE_QUERY_LIMIT));
2018
+ const receiptsByMessageId = new Map(
2019
+ this.privateMessageReceipts.map((receipt) => [receipt.message_id, receipt.status] as const)
2020
+ );
2021
+ return this.privateMessages
2022
+ .filter((message) => {
2023
+ if (message.from_agent_id === message.to_agent_id) {
2024
+ return false;
2025
+ }
2026
+ const peerAgentId = message.from_agent_id === this.identity?.agent_id ? message.to_agent_id : message.from_agent_id;
2027
+ if (!peerAgentId || peerAgentId === this.identity?.agent_id) {
2028
+ return false;
2029
+ }
2030
+ return !normalizedConversationId || message.conversation_id === normalizedConversationId;
2031
+ })
2032
+ .sort((a, b) => b.created_at - a.created_at)
2033
+ .slice(0, resolvedLimit)
2034
+ .map((message) => ({
2035
+ message_id: message.message_id,
2036
+ conversation_id: message.conversation_id,
2037
+ from_agent_id: message.from_agent_id,
2038
+ to_agent_id: message.to_agent_id,
2039
+ body: this.decryptPrivateMessageBody(message),
2040
+ created_at: message.created_at,
2041
+ is_self: message.from_agent_id === this.identity?.agent_id,
2042
+ delivery_status:
2043
+ receiptsByMessageId.get(message.message_id) ||
2044
+ this.privateMessageDeliveryStatusCache.get(message.message_id) ||
2045
+ (message.from_agent_id === this.identity?.agent_id ? "fallback-sent" : "sent"),
2046
+ }));
2047
+ }
2048
+
2049
+ async sendPrivateMessage(input: {
2050
+ to_agent_id: string;
2051
+ recipient_encryption_public_key: string;
2052
+ body: string;
2053
+ }): Promise<{ sent: boolean; reason: string; message?: PrivateMessageView }> {
2054
+ if (!this.identity || !this.privateEncryptionKeyPair) {
2055
+ return { sent: false, reason: "missing_identity_or_private_key" };
2056
+ }
2057
+ const toAgentId = String(input.to_agent_id || "").trim();
2058
+ const learnedRecipientKey = this.privatePeerEncryptionKeys[toAgentId] || "";
2059
+ const profileRecipientKey = this.directory.profiles[toAgentId]?.private_encryption_public_key || "";
2060
+ const recipientKey = String(learnedRecipientKey || input.recipient_encryption_public_key || profileRecipientKey || "").trim();
2061
+ const body = String(input.body || "").trim();
2062
+ if (toAgentId === this.identity.agent_id) {
2063
+ return { sent: false, reason: "self_private_message_not_allowed" };
2064
+ }
2065
+ const toPeerId = this.privatePeerRoutes[toAgentId] || "";
2066
+ if (!toAgentId || !recipientKey || !body) {
2067
+ return { sent: false, reason: "invalid_private_message_input" };
2068
+ }
2069
+ const encrypted = encryptPrivatePayload({
2070
+ plaintext: body,
2071
+ recipient_public_key: recipientKey,
2072
+ sender_keypair: this.privateEncryptionKeyPair,
2073
+ });
2074
+ const message = signPrivateMessage({
2075
+ identity: this.identity,
2076
+ message_id: createHash("sha256").update(`${this.identity.agent_id}:${toAgentId}:${Date.now()}:${body}:${Math.random()}`, "utf8").digest("hex"),
2077
+ conversation_id: this.buildPrivateConversationId(this.identity.agent_id, toAgentId),
2078
+ to_agent_id: toAgentId,
2079
+ sender_encryption_public_key: encrypted.sender_encryption_public_key,
2080
+ recipient_encryption_public_key: recipientKey,
2081
+ ciphertext: encrypted.ciphertext,
2082
+ nonce: encrypted.nonce,
2083
+ created_at: Date.now(),
2084
+ });
2085
+ this.privateMessageBodyCache.set(message.message_id, body);
2086
+ this.ingestPrivateMessage(message);
2087
+ await this.persistPrivateMessages();
2088
+ let reason = "fallback-sent";
2089
+ if (toPeerId && typeof this.network.sendDirect === "function") {
2090
+ try {
2091
+ await this.network.sendDirect(toPeerId, PRIVATE_MESSAGE_TOPIC, message);
2092
+ await this.publish(PRIVATE_MESSAGE_TOPIC, message);
2093
+ reason = "direct-sent";
2094
+ } catch {
2095
+ await this.publish(PRIVATE_MESSAGE_TOPIC, message);
2096
+ }
2097
+ } else {
2098
+ await this.publish(PRIVATE_MESSAGE_TOPIC, message);
2099
+ }
2100
+ this.privateMessageDeliveryStatusCache.set(message.message_id, reason as PrivateMessageView["delivery_status"]);
2101
+ const view = this.getPrivateMessages(message.conversation_id).find((item) => item.message_id === message.message_id);
2102
+ if (view) {
2103
+ view.delivery_status = reason as PrivateMessageView["delivery_status"];
2104
+ }
2105
+ return { sent: true, reason, message: view };
2106
+ }
2107
+
1657
2108
  getOpenClawBridgeStatus(): OpenClawBridgeStatus {
2109
+ const now = Date.now();
2110
+ if (this.openclawBridgeStatusCache && this.openclawBridgeStatusCache.expiresAt > now) {
2111
+ return this.openclawBridgeStatusCache.value;
2112
+ }
1658
2113
  const integration = this.getIntegrationStatus();
1659
2114
  const openclawInstallation = detectOpenClawInstallation(this.projectRoot);
1660
- const openclawRuntime = detectOpenClawRuntime(this.projectRoot);
2115
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1661
2116
  const skillInstallation = detectOpenClawSkillInstallation();
1662
2117
  const ownerDelivery = detectOwnerDeliveryStatus({
1663
2118
  workspaceRoot: this.projectRoot,
@@ -1665,7 +2120,7 @@ export class LocalNodeService {
1665
2120
  openclawRunning: openclawRuntime.running,
1666
2121
  skillInstalled: skillInstallation.installed,
1667
2122
  });
1668
- return {
2123
+ const value: OpenClawBridgeStatus = {
1669
2124
  enabled: this.socialConfig.enabled,
1670
2125
  connected_to_silicaclaw: integration.connected_to_silicaclaw,
1671
2126
  public_enabled: integration.public_enabled,
@@ -1721,6 +2176,11 @@ export class LocalNodeService {
1721
2176
  install_skill: "/api/openclaw/bridge/skill-install",
1722
2177
  },
1723
2178
  };
2179
+ this.openclawBridgeStatusCache = {
2180
+ value,
2181
+ expiresAt: now + OPENCLAW_BRIDGE_STATUS_CACHE_MS,
2182
+ };
2183
+ return value;
1724
2184
  }
1725
2185
 
1726
2186
  async installOpenClawSkill(skillName?: string) {
@@ -1735,6 +2195,7 @@ export class LocalNodeService {
1735
2195
  maxBuffer: 1024 * 1024,
1736
2196
  });
1737
2197
  const parsed = JSON.parse(String(stdout || "{}"));
2198
+ this.invalidateOpenClawCaches();
1738
2199
  return {
1739
2200
  ...parsed,
1740
2201
  bridge: this.getOpenClawBridgeStatus(),
@@ -1756,7 +2217,7 @@ export class LocalNodeService {
1756
2217
  const workspaceSkillDir = resolve(homeDir, "workspace", "skills");
1757
2218
  const legacySkillDir = resolve(homeDir, "skills");
1758
2219
  const openclawSourceDir = defaultOpenClawSourceDir(this.projectRoot);
1759
- const openclawRuntime = detectOpenClawRuntime(this.projectRoot);
2220
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1760
2221
 
1761
2222
  return {
1762
2223
  bridge_api_base: DEFAULT_BRIDGE_API_BASE,
@@ -2166,15 +2627,14 @@ export class LocalNodeService {
2166
2627
  profile: this.profile,
2167
2628
  };
2168
2629
  const presenceRecord = signPresence(this.identity, Date.now());
2169
- const indexRecords = buildIndexRecords(this.profile);
2170
- const replayMessages = this.getReplayableSelfSocialMessages();
2630
+ const shouldPublishProfile = this.shouldPublishProfileRecord(profileRecord, reason, presenceRecord.timestamp);
2631
+ const replayMessages = this.getReplayableSelfSocialMessages(reason);
2171
2632
 
2172
2633
  try {
2173
- await this.publish("profile", profileRecord);
2174
- await this.publish("presence", presenceRecord);
2175
- for (const record of indexRecords) {
2176
- await this.publish("index", record);
2634
+ if (shouldPublishProfile) {
2635
+ await this.publish("profile", profileRecord);
2177
2636
  }
2637
+ await this.publish("presence", presenceRecord);
2178
2638
  for (const message of replayMessages) {
2179
2639
  await this.publish(SOCIAL_MESSAGE_TOPIC, message);
2180
2640
  }
@@ -2183,7 +2643,9 @@ export class LocalNodeService {
2183
2643
  this.lastBroadcastErrorAt = Date.now();
2184
2644
  this.lastBroadcastError = message;
2185
2645
  this.broadcastFailureCount += 1;
2646
+ this.consecutiveBroadcastFailures += 1;
2186
2647
  await this.log("error", `Broadcast failed (reason=${reason}): ${message}`);
2648
+ await this.maybeRecoverFromBroadcastFailure(reason, message);
2187
2649
  return { sent: false, reason: "publish_failed", error: message };
2188
2650
  }
2189
2651
 
@@ -2191,22 +2653,75 @@ export class LocalNodeService {
2191
2653
  this.broadcastCount += 1;
2192
2654
  this.lastBroadcastError = null;
2193
2655
  this.lastBroadcastErrorAt = 0;
2656
+ this.consecutiveBroadcastFailures = 0;
2194
2657
 
2195
2658
  this.directory = ingestProfileRecord(this.directory, profileRecord);
2196
2659
  this.directory = ingestPresenceRecord(this.directory, presenceRecord);
2197
- for (const record of indexRecords) {
2198
- this.directory = ingestIndexRecord(this.directory, record);
2199
- }
2200
2660
  this.compactCacheInMemory();
2201
2661
  await this.persistCache();
2202
2662
 
2203
2663
  await this.log(
2204
2664
  "info",
2205
- `Broadcast sent (${indexRecords.length} index refs, replayed_messages=${replayMessages.length}, reason=${reason})`
2665
+ `Broadcast sent (${shouldPublishProfile ? "profile + " : ""}presence, replayed_messages=${replayMessages.length}, reason=${reason})`
2206
2666
  );
2207
2667
  return { sent: true, reason };
2208
2668
  }
2209
2669
 
2670
+ private shouldPublishProfileRecord(
2671
+ profileRecord: SignedProfileRecord,
2672
+ reason: string,
2673
+ now = Date.now()
2674
+ ): boolean {
2675
+ if (reason !== "interval") {
2676
+ this.lastProfileBroadcastSignature = profileRecord.profile.signature;
2677
+ this.lastProfileBroadcastAt = now;
2678
+ return true;
2679
+ }
2680
+ const signature = profileRecord.profile.signature;
2681
+ const changedSinceLastPublish = signature !== this.lastProfileBroadcastSignature;
2682
+ const refreshDue = now - this.lastProfileBroadcastAt >= PROFILE_RELAY_REFRESH_INTERVAL_MS;
2683
+ if (!changedSinceLastPublish && !refreshDue) {
2684
+ return false;
2685
+ }
2686
+ this.lastProfileBroadcastSignature = signature;
2687
+ this.lastProfileBroadcastAt = now;
2688
+ return true;
2689
+ }
2690
+
2691
+ private async maybeRecoverFromBroadcastFailure(reason: string, errorMessage: string): Promise<void> {
2692
+ const recoveryThreshold = 3;
2693
+ const recoveryCooldownMs = 60_000;
2694
+ if (this.broadcastRecoveryInFlight) {
2695
+ return;
2696
+ }
2697
+ if (this.consecutiveBroadcastFailures < recoveryThreshold) {
2698
+ return;
2699
+ }
2700
+ if (Date.now() - this.lastBroadcastRecoveryAttemptAt < recoveryCooldownMs) {
2701
+ return;
2702
+ }
2703
+ if (this.adapterMode !== "relay-preview" && this.adapterMode !== "webrtc-preview" && this.adapterMode !== "real-preview") {
2704
+ return;
2705
+ }
2706
+
2707
+ this.broadcastRecoveryInFlight = true;
2708
+ this.lastBroadcastRecoveryAttemptAt = Date.now();
2709
+ try {
2710
+ await this.log(
2711
+ "warn",
2712
+ `Broadcast recovery triggered after ${this.consecutiveBroadcastFailures} consecutive failures (${reason}): ${errorMessage}`
2713
+ );
2714
+ await this.restartNetworkAdapter("broadcast_failure_recovery");
2715
+ } catch (recoveryError) {
2716
+ await this.log(
2717
+ "error",
2718
+ `Broadcast recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`
2719
+ );
2720
+ } finally {
2721
+ this.broadcastRecoveryInFlight = false;
2722
+ }
2723
+ }
2724
+
2210
2725
  private async hydrateFromDisk(): Promise<void> {
2211
2726
  this.initState = {
2212
2727
  identity_auto_created: false,
@@ -2236,6 +2751,8 @@ export class LocalNodeService {
2236
2751
  await this.log("info", `Bound existing OpenClaw identity: ${resolvedIdentity.openclaw_source_path}`);
2237
2752
  }
2238
2753
  await this.identityRepo.set(this.identity);
2754
+ this.privateEncryptionKeyPair = (await this.privateEncryptionKeyRepo.get()) || createPrivateEncryptionKeyPair();
2755
+ await this.privateEncryptionKeyRepo.set(this.privateEncryptionKeyPair);
2239
2756
 
2240
2757
  const existingProfile = await this.profileRepo.get();
2241
2758
  const profileInput = resolveProfileInputWithSocial({
@@ -2244,7 +2761,10 @@ export class LocalNodeService {
2244
2761
  existingProfile: existingProfile && existingProfile.agent_id === this.identity.agent_id ? existingProfile : null,
2245
2762
  rootDir: this.projectRoot,
2246
2763
  });
2247
- this.profile = signProfile(profileInput, this.identity);
2764
+ this.profile = signProfile({
2765
+ ...profileInput,
2766
+ private_encryption_public_key: this.privateEncryptionKeyPair?.public_key || profileInput.private_encryption_public_key || "",
2767
+ }, this.identity);
2248
2768
  if (!existingProfile || existingProfile.agent_id !== this.identity.agent_id) {
2249
2769
  this.initState.profile_auto_created = true;
2250
2770
  await this.log("info", "profile.json missing/invalid, initialized from social/default profile");
@@ -2258,6 +2778,11 @@ export class LocalNodeService {
2258
2778
  };
2259
2779
  this.socialMessages = this.normalizeSocialMessages(await this.socialMessageRepo.get());
2260
2780
  this.socialMessageObservations = this.normalizeSocialMessageObservations(await this.socialMessageObservationRepo.get());
2781
+ const storedPrivateMessages = await this.privateMessageRepo.get();
2782
+ this.hydratePrivateMessageBodyCache(storedPrivateMessages);
2783
+ this.privateMessages = this.normalizePrivateMessages(storedPrivateMessages);
2784
+ this.privateMessageReceipts = this.normalizePrivateMessageReceipts(await this.privateMessageReceiptRepo.get());
2785
+ await this.refreshPrivateMessagingRuntime();
2261
2786
  this.directory = ingestProfileRecord(this.directory, { type: "profile", profile: this.profile });
2262
2787
  this.compactCacheInMemory();
2263
2788
  await this.persistCache();
@@ -2276,7 +2801,10 @@ export class LocalNodeService {
2276
2801
  existingProfile: this.profile,
2277
2802
  rootDir: this.projectRoot,
2278
2803
  });
2279
- const nextProfile = signProfile(nextProfileInput, this.identity);
2804
+ const nextProfile = signProfile({
2805
+ ...nextProfileInput,
2806
+ private_encryption_public_key: this.privateEncryptionKeyPair?.public_key || nextProfileInput.private_encryption_public_key || "",
2807
+ }, this.identity);
2280
2808
  this.profile = nextProfile;
2281
2809
  await this.profileRepo.set(nextProfile);
2282
2810
 
@@ -2341,7 +2869,8 @@ export class LocalNodeService {
2341
2869
 
2342
2870
  private async onMessage(
2343
2871
  topic: "profile" | "presence" | "index" | "social.message" | "social.message.observation",
2344
- data: unknown
2872
+ data: unknown,
2873
+ meta?: { peerId?: string }
2345
2874
  ): Promise<void> {
2346
2875
  this.receivedCount += 1;
2347
2876
  this.receivedByTopic[topic] = (this.receivedByTopic[topic] ?? 0) + 1;
@@ -2359,6 +2888,9 @@ export class LocalNodeService {
2359
2888
  return;
2360
2889
  }
2361
2890
  }
2891
+ if (meta?.peerId && record.profile.agent_id && !this.privatePeerRoutes[record.profile.agent_id]) {
2892
+ this.privatePeerRoutes[record.profile.agent_id] = meta.peerId;
2893
+ }
2362
2894
 
2363
2895
  this.directory = ingestProfileRecord(this.directory, record);
2364
2896
  this.compactCacheInMemory();
@@ -2378,6 +2910,9 @@ export class LocalNodeService {
2378
2910
  return;
2379
2911
  }
2380
2912
  }
2913
+ if (meta?.peerId && record.agent_id && !this.privatePeerRoutes[record.agent_id]) {
2914
+ this.privatePeerRoutes[record.agent_id] = meta.peerId;
2915
+ }
2381
2916
 
2382
2917
  this.directory = ingestPresenceRecord(this.directory, record);
2383
2918
  this.compactCacheInMemory();
@@ -2394,6 +2929,9 @@ export class LocalNodeService {
2394
2929
  await this.log("warn", `Rejected social message with invalid signature (${record.message_id.slice(0, 10)})`);
2395
2930
  return;
2396
2931
  }
2932
+ if (meta?.peerId && record.agent_id && !this.privatePeerRoutes[record.agent_id]) {
2933
+ this.privatePeerRoutes[record.agent_id] = meta.peerId;
2934
+ }
2397
2935
  if (this.hasSocialMessage(record.message_id)) {
2398
2936
  await this.publishObservationForMessage(record);
2399
2937
  return;
@@ -2432,6 +2970,45 @@ export class LocalNodeService {
2432
2970
  await this.persistCache();
2433
2971
  }
2434
2972
 
2973
+ private async onDirectMessage(
2974
+ topic: "private.message" | "private.message.receipt",
2975
+ data: unknown,
2976
+ meta?: { peerId?: string }
2977
+ ): Promise<void> {
2978
+ if (topic === PRIVATE_MESSAGE_TOPIC) {
2979
+ const record = this.normalizeIncomingPrivateMessage(data);
2980
+ if (!record || !verifyPrivateMessage(record)) {
2981
+ return;
2982
+ }
2983
+ if (meta?.peerId && record.from_agent_id) {
2984
+ this.privatePeerRoutes[record.from_agent_id] = meta.peerId;
2985
+ }
2986
+ if (record.from_agent_id && record.sender_encryption_public_key) {
2987
+ this.privatePeerEncryptionKeys[record.from_agent_id] = record.sender_encryption_public_key;
2988
+ }
2989
+ if (record.to_agent_id !== this.identity?.agent_id || this.hasPrivateMessage(record.message_id)) {
2990
+ return;
2991
+ }
2992
+ this.ingestPrivateMessage(record);
2993
+ await this.persistPrivateMessages();
2994
+ await this.sendPrivateMessageReceipt(record, meta?.peerId);
2995
+ return;
2996
+ }
2997
+
2998
+ const receipt = this.normalizeIncomingPrivateMessageReceipt(data);
2999
+ if (!receipt || !verifyPrivateMessageReceipt(receipt)) {
3000
+ return;
3001
+ }
3002
+ if (meta?.peerId && receipt.from_agent_id) {
3003
+ this.privatePeerRoutes[receipt.from_agent_id] = meta.peerId;
3004
+ }
3005
+ if (receipt.to_agent_id !== this.identity?.agent_id) {
3006
+ return;
3007
+ }
3008
+ this.ingestPrivateMessageReceipt(receipt);
3009
+ await this.persistPrivateMessageReceipts();
3010
+ }
3011
+
2435
3012
  private startBroadcastLoop(): void {
2436
3013
  if (this.broadcaster) {
2437
3014
  clearInterval(this.broadcaster);
@@ -2457,21 +3034,35 @@ export class LocalNodeService {
2457
3034
  if (this.subscriptionsBound) {
2458
3035
  return;
2459
3036
  }
2460
- this.network.subscribe("profile", (data: SignedProfileRecord) => {
2461
- this.onMessage("profile", data);
3037
+ this.network.subscribe("profile", (data: SignedProfileRecord, meta?: { peerId?: string }) => {
3038
+ this.onMessage("profile", data, meta);
3039
+ });
3040
+ this.network.subscribe("presence", (data: PresenceRecord, meta?: { peerId?: string }) => {
3041
+ this.onMessage("presence", data, meta);
3042
+ });
3043
+ this.network.subscribe("index", (data: IndexRefRecord, meta?: { peerId?: string }) => {
3044
+ this.onMessage("index", data, meta);
2462
3045
  });
2463
- this.network.subscribe("presence", (data: PresenceRecord) => {
2464
- this.onMessage("presence", data);
3046
+ this.network.subscribe(SOCIAL_MESSAGE_TOPIC, (data: SocialMessageRecord, meta?: { peerId?: string }) => {
3047
+ this.onMessage(SOCIAL_MESSAGE_TOPIC, data, meta);
2465
3048
  });
2466
- this.network.subscribe("index", (data: IndexRefRecord) => {
2467
- this.onMessage("index", data);
3049
+ this.network.subscribe(SOCIAL_MESSAGE_OBSERVATION_TOPIC, (data: SocialMessageObservationRecord, meta?: { peerId?: string }) => {
3050
+ this.onMessage(SOCIAL_MESSAGE_OBSERVATION_TOPIC, data, meta);
2468
3051
  });
2469
- this.network.subscribe(SOCIAL_MESSAGE_TOPIC, (data: SocialMessageRecord) => {
2470
- this.onMessage(SOCIAL_MESSAGE_TOPIC, data);
3052
+ this.network.subscribe(PRIVATE_MESSAGE_TOPIC, (data: PrivateMessageRecord, meta?: { peerId?: string }) => {
3053
+ this.onDirectMessage(PRIVATE_MESSAGE_TOPIC, data, meta);
2471
3054
  });
2472
- this.network.subscribe(SOCIAL_MESSAGE_OBSERVATION_TOPIC, (data: SocialMessageObservationRecord) => {
2473
- this.onMessage(SOCIAL_MESSAGE_OBSERVATION_TOPIC, data);
3055
+ this.network.subscribe(PRIVATE_MESSAGE_RECEIPT_TOPIC, (data: PrivateMessageReceiptRecord, meta?: { peerId?: string }) => {
3056
+ this.onDirectMessage(PRIVATE_MESSAGE_RECEIPT_TOPIC, data, meta);
2474
3057
  });
3058
+ if (typeof this.network.subscribeDirect === "function") {
3059
+ this.network.subscribeDirect(PRIVATE_MESSAGE_TOPIC, (data: PrivateMessageRecord, meta?: { peerId?: string }) => {
3060
+ this.onDirectMessage(PRIVATE_MESSAGE_TOPIC, data, meta);
3061
+ });
3062
+ this.network.subscribeDirect(PRIVATE_MESSAGE_RECEIPT_TOPIC, (data: PrivateMessageReceiptRecord, meta?: { peerId?: string }) => {
3063
+ this.onDirectMessage(PRIVATE_MESSAGE_RECEIPT_TOPIC, data, meta);
3064
+ });
3065
+ }
2475
3066
  this.subscriptionsBound = true;
2476
3067
  }
2477
3068
 
@@ -2628,9 +3219,66 @@ export class LocalNodeService {
2628
3219
  this.networkReconnectDelayMs = Math.min(30_000, Math.max(5_000, Math.floor(delayMs * 1.5)));
2629
3220
  }
2630
3221
 
3222
+ private pruneRemoteProfilesInMemory(now = Date.now()): number {
3223
+ if (!Number.isFinite(DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) || DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT <= 0) {
3224
+ return 0;
3225
+ }
3226
+ const selfAgentId = this.profile?.agent_id || this.identity?.agent_id || "";
3227
+ const remoteProfiles = Object.values(this.directory.profiles).filter((profile) => profile.agent_id !== selfAgentId);
3228
+ if (remoteProfiles.length <= DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) {
3229
+ return 0;
3230
+ }
3231
+
3232
+ const onlineRemoteProfiles = remoteProfiles.filter((profile) =>
3233
+ isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS)
3234
+ );
3235
+ const offlineRemoteProfiles = remoteProfiles
3236
+ .filter((profile) => !isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS))
3237
+ .sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
3238
+
3239
+ const keepOfflineCount = Math.max(0, DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT - onlineRemoteProfiles.length);
3240
+ const keptRemoteProfiles = [
3241
+ ...onlineRemoteProfiles,
3242
+ ...offlineRemoteProfiles.slice(0, keepOfflineCount),
3243
+ ];
3244
+ const keptRemoteIds = new Set(keptRemoteProfiles.map((profile) => profile.agent_id));
3245
+ const removedIds = remoteProfiles
3246
+ .map((profile) => profile.agent_id)
3247
+ .filter((agentId) => !keptRemoteIds.has(agentId));
3248
+ if (removedIds.length === 0) {
3249
+ return 0;
3250
+ }
3251
+
3252
+ const next = createEmptyDirectoryState();
3253
+ const selfProfile = selfAgentId ? this.directory.profiles[selfAgentId] : null;
3254
+ if (selfProfile) {
3255
+ next.profiles[selfAgentId] = selfProfile;
3256
+ const selfPresence = this.directory.presence[selfAgentId];
3257
+ if (typeof selfPresence === "number" && Number.isFinite(selfPresence)) {
3258
+ next.presence[selfAgentId] = selfPresence;
3259
+ }
3260
+ const rebuilt = rebuildIndexForProfile(next, selfProfile);
3261
+ next.index = rebuilt.index;
3262
+ }
3263
+
3264
+ for (const profile of keptRemoteProfiles) {
3265
+ next.profiles[profile.agent_id] = profile;
3266
+ const seenAt = this.directory.presence[profile.agent_id];
3267
+ if (typeof seenAt === "number" && Number.isFinite(seenAt)) {
3268
+ next.presence[profile.agent_id] = seenAt;
3269
+ }
3270
+ const rebuilt = rebuildIndexForProfile(next, profile);
3271
+ next.index = rebuilt.index;
3272
+ }
3273
+
3274
+ this.directory = dedupeIndex(next);
3275
+ return removedIds.length;
3276
+ }
3277
+
2631
3278
  private compactCacheInMemory(): number {
2632
3279
  const cleaned = cleanupExpiredPresence(this.directory, Date.now(), PRESENCE_TTL_MS);
2633
3280
  this.directory = dedupeIndex(cleaned.state);
3281
+ this.pruneRemoteProfilesInMemory();
2634
3282
  return cleaned.removed;
2635
3283
  }
2636
3284
 
@@ -2666,6 +3314,90 @@ export class LocalNodeService {
2666
3314
  await this.socialMessageObservationRepo.set(this.socialMessageObservations);
2667
3315
  }
2668
3316
 
3317
+ private async persistPrivateMessages(): Promise<void> {
3318
+ this.privateMessagesPersistDirty = true;
3319
+ if (this.privateMessagesPersistTimer) {
3320
+ return;
3321
+ }
3322
+ this.privateMessagesPersistTimer = setTimeout(() => {
3323
+ this.flushPrivateMessagesPersist().catch(() => {});
3324
+ }, PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS);
3325
+ }
3326
+
3327
+ private async persistPrivateMessageReceipts(): Promise<void> {
3328
+ this.privateMessageReceiptsPersistDirty = true;
3329
+ if (this.privateMessageReceiptsPersistTimer) {
3330
+ return;
3331
+ }
3332
+ this.privateMessageReceiptsPersistTimer = setTimeout(() => {
3333
+ this.flushPrivateMessageReceiptsPersist().catch(() => {});
3334
+ }, PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS);
3335
+ }
3336
+
3337
+ private async flushPrivatePersistence(): Promise<void> {
3338
+ await Promise.all([
3339
+ this.flushPrivateMessagesPersist(),
3340
+ this.flushPrivateMessageReceiptsPersist(),
3341
+ ]);
3342
+ }
3343
+
3344
+ private async flushPrivateMessagesPersist(): Promise<void> {
3345
+ if (this.privateMessagesPersistTimer) {
3346
+ clearTimeout(this.privateMessagesPersistTimer);
3347
+ this.privateMessagesPersistTimer = null;
3348
+ }
3349
+ if (!this.privateMessagesPersistDirty) {
3350
+ return;
3351
+ }
3352
+ this.privateMessagesPersistDirty = false;
3353
+ await this.privateMessageRepo.set(this.buildPersistedPrivateMessages() as unknown as PrivateMessageRecord[]);
3354
+ }
3355
+
3356
+ private hydratePrivateMessageBodyCache(items: unknown): void {
3357
+ if (!Array.isArray(items)) {
3358
+ return;
3359
+ }
3360
+ for (const item of items) {
3361
+ if (typeof item !== "object" || item === null) {
3362
+ continue;
3363
+ }
3364
+ const record = item as Partial<StoredPrivateMessageRecord>;
3365
+ const messageId = String(record.message_id || "").trim();
3366
+ const localPlaintext = typeof record.local_plaintext === "string" ? record.local_plaintext : "";
3367
+ if (messageId && localPlaintext) {
3368
+ this.privateMessageBodyCache.set(messageId, localPlaintext);
3369
+ }
3370
+ }
3371
+ }
3372
+
3373
+ private buildPersistedPrivateMessages(): StoredPrivateMessageRecord[] {
3374
+ return this.privateMessages.map((message) => {
3375
+ const localPlaintext =
3376
+ message.from_agent_id === this.identity?.agent_id
3377
+ ? this.privateMessageBodyCache.get(message.message_id) || ""
3378
+ : "";
3379
+ if (!localPlaintext) {
3380
+ return { ...message };
3381
+ }
3382
+ return {
3383
+ ...message,
3384
+ local_plaintext: localPlaintext,
3385
+ };
3386
+ });
3387
+ }
3388
+
3389
+ private async flushPrivateMessageReceiptsPersist(): Promise<void> {
3390
+ if (this.privateMessageReceiptsPersistTimer) {
3391
+ clearTimeout(this.privateMessageReceiptsPersistTimer);
3392
+ this.privateMessageReceiptsPersistTimer = null;
3393
+ }
3394
+ if (!this.privateMessageReceiptsPersistDirty) {
3395
+ return;
3396
+ }
3397
+ this.privateMessageReceiptsPersistDirty = false;
3398
+ await this.privateMessageReceiptRepo.set(this.privateMessageReceipts);
3399
+ }
3400
+
2669
3401
  private async log(level: "info" | "warn" | "error", message: string): Promise<void> {
2670
3402
  await this.logRepo.append({
2671
3403
  level,
@@ -2722,6 +3454,7 @@ export class LocalNodeService {
2722
3454
 
2723
3455
  return buildPublicProfileSummary({
2724
3456
  profile,
3457
+ is_self: isSelf,
2725
3458
  online,
2726
3459
  last_seen_at: lastSeenAt || null,
2727
3460
  network_mode: isSelf ? this.networkMode : "unknown",
@@ -2775,6 +3508,7 @@ export class LocalNodeService {
2775
3508
  updated_at: message.created_at,
2776
3509
  signature: "",
2777
3510
  },
3511
+ is_self: message.agent_id === this.identity?.agent_id,
2778
3512
  online: false,
2779
3513
  last_seen_at: null,
2780
3514
  network_mode: "unknown",
@@ -2808,6 +3542,47 @@ export class LocalNodeService {
2808
3542
  return `${digest.slice(0, 12)}:${digest.slice(-8)}`;
2809
3543
  }
2810
3544
 
3545
+ private buildPrivateMessagingRuntimeState(): PrivateMessagingRuntimeState {
3546
+ const warnings: string[] = [];
3547
+ const keypair = this.privateEncryptionKeyPair;
3548
+ const selfSentMessages = this.privateMessages.filter((message) => message.from_agent_id === this.identity?.agent_id);
3549
+ let cachedPlaintextCount = 0;
3550
+ for (const message of selfSentMessages) {
3551
+ if (this.privateMessageBodyCache.get(message.message_id)) {
3552
+ cachedPlaintextCount += 1;
3553
+ }
3554
+ }
3555
+ if (!keypair?.public_key || !keypair?.private_key) {
3556
+ warnings.push("missing_private_encryption_keypair");
3557
+ }
3558
+ if (selfSentMessages.length > 0 && cachedPlaintextCount === 0) {
3559
+ warnings.push("missing_local_plaintext_cache_for_self_messages");
3560
+ }
3561
+ if (selfSentMessages.length > 0 && cachedPlaintextCount < selfSentMessages.length) {
3562
+ warnings.push("partial_local_plaintext_cache_for_self_messages");
3563
+ }
3564
+ return {
3565
+ schema_version: 1,
3566
+ app_version: this.appVersion,
3567
+ last_started_at: Date.now(),
3568
+ encryption_public_key: keypair?.public_key || "",
3569
+ encryption_public_key_fingerprint: keypair?.public_key ? this.fingerprintPublicKey(keypair.public_key) : "",
3570
+ message_count: this.privateMessages.length,
3571
+ self_sent_count: selfSentMessages.length,
3572
+ cached_plaintext_count: cachedPlaintextCount,
3573
+ warnings,
3574
+ };
3575
+ }
3576
+
3577
+ private async refreshPrivateMessagingRuntime(): Promise<void> {
3578
+ const runtime = this.buildPrivateMessagingRuntimeState();
3579
+ this.privateMessagingRuntime = runtime;
3580
+ await this.privateMessagingRuntimeRepo.set(runtime);
3581
+ for (const warning of runtime.warnings) {
3582
+ await this.log("warn", `Private messaging startup check: ${warning}`);
3583
+ }
3584
+ }
3585
+
2811
3586
  private getOnboardingSummary() {
2812
3587
  const summary = this.getIntegrationSummary();
2813
3588
  const publicEnabled = Boolean(this.profile?.public_enabled);
@@ -2980,6 +3755,34 @@ export class LocalNodeService {
2980
3755
  .trim();
2981
3756
  }
2982
3757
 
3758
+ private buildPrivateConversationId(leftAgentId: string, rightAgentId: string): string {
3759
+ return [String(leftAgentId || "").trim(), String(rightAgentId || "").trim()].sort().join(":");
3760
+ }
3761
+
3762
+ private decryptPrivateMessageBody(message: PrivateMessageRecord): string {
3763
+ const cached = this.privateMessageBodyCache.get(message.message_id);
3764
+ if (typeof cached === "string") {
3765
+ return cached;
3766
+ }
3767
+ if (!this.privateEncryptionKeyPair) {
3768
+ return "[encrypted]";
3769
+ }
3770
+ const decrypted = decryptPrivatePayload({
3771
+ ciphertext: message.ciphertext,
3772
+ nonce: message.nonce,
3773
+ sender_encryption_public_key: message.sender_encryption_public_key,
3774
+ recipient_private_key: this.privateEncryptionKeyPair.private_key,
3775
+ }) || "[encrypted]";
3776
+ this.privateMessageBodyCache.set(message.message_id, decrypted);
3777
+ if (this.privateMessageBodyCache.size > PRIVATE_MESSAGE_HISTORY_LIMIT * 2) {
3778
+ const firstKey = this.privateMessageBodyCache.keys().next().value;
3779
+ if (firstKey) {
3780
+ this.privateMessageBodyCache.delete(firstKey);
3781
+ }
3782
+ }
3783
+ return decrypted;
3784
+ }
3785
+
2983
3786
  private normalizeWindowTimestamps(timestamps: number[], windowMs: number, now = Date.now()): number[] {
2984
3787
  return timestamps.filter((timestamp) => now - timestamp <= windowMs);
2985
3788
  }
@@ -3005,18 +3808,32 @@ export class LocalNodeService {
3005
3808
  return this.socialMessages.some((item) => item.message_id === messageId);
3006
3809
  }
3007
3810
 
3008
- private getReplayableSelfSocialMessages(now = Date.now()): SocialMessageRecord[] {
3811
+ private getReplayableSelfSocialMessages(reason = "manual", now = Date.now()): SocialMessageRecord[] {
3009
3812
  const maxCount = Math.max(0, SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST);
3010
3813
  if (!this.identity || maxCount === 0) {
3011
3814
  return [];
3012
3815
  }
3013
- return this.socialMessages
3816
+ const replayable = this.socialMessages
3014
3817
  .filter((item) => (
3015
3818
  item.agent_id === this.identity?.agent_id &&
3016
3819
  now - item.created_at <= SOCIAL_MESSAGE_REPLAY_WINDOW_MS
3017
3820
  ))
3018
3821
  .sort((a, b) => a.created_at - b.created_at)
3019
3822
  .slice(-maxCount);
3823
+ if (!replayable.length) {
3824
+ this.lastReplayBroadcastSignature = "";
3825
+ return [];
3826
+ }
3827
+ const signature = replayable.map((item) => item.message_id).join(",");
3828
+ const isIntervalReplay = reason === "interval";
3829
+ const changedSinceLastReplay = signature !== this.lastReplayBroadcastSignature;
3830
+ const refreshDue = now - this.lastReplayBroadcastAt >= SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS;
3831
+ if (isIntervalReplay && !changedSinceLastReplay && !refreshDue) {
3832
+ return [];
3833
+ }
3834
+ this.lastReplayBroadcastSignature = signature;
3835
+ this.lastReplayBroadcastAt = now;
3836
+ return replayable;
3020
3837
  }
3021
3838
 
3022
3839
  private hasRecentDuplicateMessage(agentId: string, body: string, topic: string, now = Date.now()): boolean {
@@ -3091,6 +3908,190 @@ export class LocalNodeService {
3091
3908
  await this.persistSocialMessageObservations();
3092
3909
  }
3093
3910
 
3911
+ private async sendPrivateMessageReceipt(message: PrivateMessageRecord, replyPeerId?: string): Promise<void> {
3912
+ if (!this.identity || typeof this.network.sendDirect !== "function" || !replyPeerId) {
3913
+ return;
3914
+ }
3915
+ const receipt = signPrivateMessageReceipt({
3916
+ identity: this.identity,
3917
+ receipt_id: createHash("sha256").update(`${message.message_id}:${this.identity.agent_id}:${Date.now()}`, "utf8").digest("hex"),
3918
+ message_id: message.message_id,
3919
+ conversation_id: message.conversation_id,
3920
+ to_agent_id: message.from_agent_id,
3921
+ status: "received",
3922
+ created_at: Date.now(),
3923
+ });
3924
+ this.ingestPrivateMessageReceipt(receipt);
3925
+ try {
3926
+ await this.network.sendDirect(replyPeerId, PRIVATE_MESSAGE_RECEIPT_TOPIC, receipt);
3927
+ await this.publish(PRIVATE_MESSAGE_RECEIPT_TOPIC, receipt);
3928
+ } catch {
3929
+ await this.publish(PRIVATE_MESSAGE_RECEIPT_TOPIC, receipt);
3930
+ }
3931
+ await this.persistPrivateMessageReceipts();
3932
+ }
3933
+
3934
+ private normalizeIncomingPrivateMessage(value: unknown): PrivateMessageRecord | null {
3935
+ if (typeof value !== "object" || value === null) {
3936
+ return null;
3937
+ }
3938
+ const record = value as Partial<PrivateMessageRecord>;
3939
+ const createdAt = Number(record.created_at || 0);
3940
+ const fromAgentId = String(record.from_agent_id || "").trim();
3941
+ const toAgentId = String(record.to_agent_id || "").trim();
3942
+ const conversationId = String(record.conversation_id || "").trim();
3943
+ if (
3944
+ record.type !== PRIVATE_MESSAGE_TOPIC ||
3945
+ !String(record.message_id || "").trim() ||
3946
+ !conversationId ||
3947
+ !fromAgentId ||
3948
+ !toAgentId ||
3949
+ !String(record.sender_public_key || "").trim() ||
3950
+ !String(record.sender_encryption_public_key || "").trim() ||
3951
+ !String(record.recipient_encryption_public_key || "").trim() ||
3952
+ !String(record.ciphertext || "").trim() ||
3953
+ !String(record.nonce || "").trim() ||
3954
+ String(record.cipher_scheme || "") !== "nacl-box-v1" ||
3955
+ !String(record.signature || "").trim() ||
3956
+ !Number.isFinite(createdAt)
3957
+ ) {
3958
+ return null;
3959
+ }
3960
+ if (fromAgentId === toAgentId) {
3961
+ return null;
3962
+ }
3963
+ if (conversationId !== this.buildPrivateConversationId(fromAgentId, toAgentId)) {
3964
+ return null;
3965
+ }
3966
+ return {
3967
+ type: PRIVATE_MESSAGE_TOPIC,
3968
+ message_id: String(record.message_id).trim(),
3969
+ conversation_id: conversationId,
3970
+ from_agent_id: fromAgentId,
3971
+ to_agent_id: toAgentId,
3972
+ sender_public_key: String(record.sender_public_key).trim(),
3973
+ sender_encryption_public_key: String(record.sender_encryption_public_key).trim(),
3974
+ recipient_encryption_public_key: String(record.recipient_encryption_public_key).trim(),
3975
+ cipher_scheme: "nacl-box-v1",
3976
+ ciphertext: String(record.ciphertext).trim(),
3977
+ nonce: String(record.nonce).trim(),
3978
+ created_at: createdAt,
3979
+ signature: String(record.signature).trim(),
3980
+ };
3981
+ }
3982
+
3983
+ private normalizePrivateMessages(items: unknown): PrivateMessageRecord[] {
3984
+ if (!Array.isArray(items)) {
3985
+ return [];
3986
+ }
3987
+ const deduped = new Set<string>();
3988
+ return items
3989
+ .map((item) => this.normalizeIncomingPrivateMessage(item))
3990
+ .filter((item): item is PrivateMessageRecord => Boolean(item))
3991
+ .sort((a, b) => a.created_at - b.created_at)
3992
+ .filter((item) => {
3993
+ if (deduped.has(item.message_id)) {
3994
+ return false;
3995
+ }
3996
+ deduped.add(item.message_id);
3997
+ return true;
3998
+ })
3999
+ .slice(-PRIVATE_MESSAGE_HISTORY_LIMIT);
4000
+ }
4001
+
4002
+ private normalizeIncomingPrivateMessageReceipt(value: unknown): PrivateMessageReceiptRecord | null {
4003
+ if (typeof value !== "object" || value === null) {
4004
+ return null;
4005
+ }
4006
+ const record = value as Partial<PrivateMessageReceiptRecord>;
4007
+ const createdAt = Number(record.created_at || 0);
4008
+ const status = String(record.status || "").trim();
4009
+ if (
4010
+ record.type !== PRIVATE_MESSAGE_RECEIPT_TOPIC ||
4011
+ !String(record.receipt_id || "").trim() ||
4012
+ !String(record.message_id || "").trim() ||
4013
+ !String(record.conversation_id || "").trim() ||
4014
+ !String(record.from_agent_id || "").trim() ||
4015
+ !String(record.to_agent_id || "").trim() ||
4016
+ !String(record.sender_public_key || "").trim() ||
4017
+ (status !== "received" && status !== "read") ||
4018
+ !String(record.signature || "").trim() ||
4019
+ !Number.isFinite(createdAt)
4020
+ ) {
4021
+ return null;
4022
+ }
4023
+ return {
4024
+ type: PRIVATE_MESSAGE_RECEIPT_TOPIC,
4025
+ receipt_id: String(record.receipt_id).trim(),
4026
+ message_id: String(record.message_id).trim(),
4027
+ conversation_id: String(record.conversation_id).trim(),
4028
+ from_agent_id: String(record.from_agent_id).trim(),
4029
+ to_agent_id: String(record.to_agent_id).trim(),
4030
+ sender_public_key: String(record.sender_public_key).trim(),
4031
+ status: status as "received" | "read",
4032
+ created_at: createdAt,
4033
+ signature: String(record.signature).trim(),
4034
+ };
4035
+ }
4036
+
4037
+ private normalizePrivateMessageReceipts(items: unknown): PrivateMessageReceiptRecord[] {
4038
+ if (!Array.isArray(items)) {
4039
+ return [];
4040
+ }
4041
+ const deduped = new Set<string>();
4042
+ return items
4043
+ .map((item) => this.normalizeIncomingPrivateMessageReceipt(item))
4044
+ .filter((item): item is PrivateMessageReceiptRecord => Boolean(item))
4045
+ .sort((a, b) => a.created_at - b.created_at)
4046
+ .filter((item) => {
4047
+ if (deduped.has(item.receipt_id)) {
4048
+ return false;
4049
+ }
4050
+ deduped.add(item.receipt_id);
4051
+ return true;
4052
+ })
4053
+ .slice(-PRIVATE_MESSAGE_RECEIPT_HISTORY_LIMIT);
4054
+ }
4055
+
4056
+ private hasPrivateMessage(messageId: string): boolean {
4057
+ return this.privateMessages.some((item) => item.message_id === messageId);
4058
+ }
4059
+
4060
+ private ingestPrivateMessage(message: PrivateMessageRecord): void {
4061
+ const existing = this.privateMessages.findIndex((item) => item.message_id === message.message_id);
4062
+ if (existing >= 0) {
4063
+ this.privateMessages[existing] = message;
4064
+ } else {
4065
+ this.privateMessages.push(message);
4066
+ }
4067
+ this.privateMessages = this.normalizePrivateMessages(this.privateMessages);
4068
+ const validIds = new Set(this.privateMessages.map((item) => item.message_id));
4069
+ if (message.from_agent_id !== this.identity?.agent_id) {
4070
+ this.privateMessageBodyCache.delete(message.message_id);
4071
+ }
4072
+ for (const key of Array.from(this.privateMessageBodyCache.keys())) {
4073
+ if (!validIds.has(key)) {
4074
+ this.privateMessageBodyCache.delete(key);
4075
+ }
4076
+ }
4077
+ for (const key of Array.from(this.privateMessageDeliveryStatusCache.keys())) {
4078
+ if (!validIds.has(key)) {
4079
+ this.privateMessageDeliveryStatusCache.delete(key);
4080
+ }
4081
+ }
4082
+ }
4083
+
4084
+ private ingestPrivateMessageReceipt(receipt: PrivateMessageReceiptRecord): void {
4085
+ const existing = this.privateMessageReceipts.findIndex((item) => item.receipt_id === receipt.receipt_id);
4086
+ if (existing >= 0) {
4087
+ this.privateMessageReceipts[existing] = receipt;
4088
+ } else {
4089
+ this.privateMessageReceipts.push(receipt);
4090
+ }
4091
+ this.privateMessageReceipts = this.normalizePrivateMessageReceipts(this.privateMessageReceipts);
4092
+ this.privateMessageDeliveryStatusCache.set(receipt.message_id, receipt.status);
4093
+ }
4094
+
3094
4095
  private normalizeIncomingSocialMessage(value: unknown): SocialMessageRecord | null {
3095
4096
  if (typeof value !== "object" || value === null) {
3096
4097
  return null;
@@ -3369,6 +4370,48 @@ export async function main() {
3369
4370
  sendOk(res, node.getRuntimePaths());
3370
4371
  });
3371
4372
 
4373
+ app.get("/api/app/update-status", (_req, res) => {
4374
+ sendOk(res, node.getAppUpdateStatus());
4375
+ });
4376
+
4377
+ app.post(
4378
+ "/api/app/update",
4379
+ asyncRoute(async (_req, res) => {
4380
+ const status = node.getAppUpdateStatus();
4381
+ if (!status.update_available || !status.latest_version) {
4382
+ sendOk(
4383
+ res,
4384
+ {
4385
+ started: false,
4386
+ current_version: status.current_version,
4387
+ latest_version: status.latest_version,
4388
+ platform: status.platform,
4389
+ reason: status.check_error || "already_current",
4390
+ },
4391
+ { message: "Already on the latest version" }
4392
+ );
4393
+ return;
4394
+ }
4395
+ sendOk(
4396
+ res,
4397
+ {
4398
+ started: true,
4399
+ current_version: status.current_version,
4400
+ target_version: status.latest_version,
4401
+ platform: status.platform,
4402
+ },
4403
+ { message: `Updating to ${status.latest_version}` }
4404
+ );
4405
+ setTimeout(() => {
4406
+ try {
4407
+ node.startAppUpdate();
4408
+ } catch {
4409
+ // best effort after response has been sent
4410
+ }
4411
+ }, 1200);
4412
+ })
4413
+ );
4414
+
3372
4415
  app.put(
3373
4416
  "/api/profile",
3374
4417
  asyncRoute(async (req, res) => {
@@ -3511,6 +4554,38 @@ export async function main() {
3511
4554
  sendOk(res, node.getSocialMessages(limit, { agent_id: agentId || null }));
3512
4555
  });
3513
4556
 
4557
+ app.get("/api/private/state", (_req, res) => {
4558
+ sendOk(res, node.getPrivateMessagingState());
4559
+ });
4560
+
4561
+ app.get("/api/private/conversations", (_req, res) => {
4562
+ sendOk(res, node.getPrivateConversations());
4563
+ });
4564
+
4565
+ app.get("/api/private/messages", (req, res) => {
4566
+ const conversationId = String(req.query.conversation_id ?? "").trim();
4567
+ const limit = Number(req.query.limit ?? PRIVATE_MESSAGE_QUERY_LIMIT);
4568
+ sendOk(res, node.getPrivateMessages(conversationId, limit));
4569
+ });
4570
+
4571
+ app.post(
4572
+ "/api/private/messages/send",
4573
+ asyncRoute(async (req, res) => {
4574
+ const result = await node.sendPrivateMessage({
4575
+ to_agent_id: String(req.body?.to_agent_id || ""),
4576
+ recipient_encryption_public_key: String(req.body?.recipient_encryption_public_key || ""),
4577
+ body: String(req.body?.body || ""),
4578
+ });
4579
+ sendOk(res, result, {
4580
+ message: result.sent
4581
+ ? (result.reason === "direct-sent"
4582
+ ? "Private message sent directly"
4583
+ : "Private message sent via encrypted fallback")
4584
+ : `Private message skipped: ${result.reason}`,
4585
+ });
4586
+ })
4587
+ );
4588
+
3514
4589
  app.get("/api/openclaw/bridge", (_req, res) => {
3515
4590
  sendOk(res, node.getOpenClawBridgeStatus());
3516
4591
  });