@silicaclaw/cli 2026.3.20-1 → 2026.3.20-10

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 (125) hide show
  1. package/CHANGELOG.md +54 -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 +129 -2
  6. package/apps/local-console/dist/apps/local-console/src/server.js +887 -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 +13 -1
  21. package/apps/local-console/dist/packages/storage/src/repos.js +19 -1
  22. package/apps/local-console/public/app/app.js +465 -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 +60 -52
  26. package/apps/local-console/public/app/social.js +316 -93
  27. package/apps/local-console/public/app/styles.css +127 -1
  28. package/apps/local-console/public/app/template.js +121 -35
  29. package/apps/local-console/public/app/translations.js +430 -316
  30. package/apps/local-console/src/server.ts +1024 -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/src/index.ts +2 -0
  47. package/node_modules/@silicaclaw/core/src/privateCrypto.ts +57 -0
  48. package/node_modules/@silicaclaw/core/src/privateMessage.ts +101 -0
  49. package/node_modules/@silicaclaw/core/src/profile.ts +2 -0
  50. package/node_modules/@silicaclaw/core/src/publicProfileSummary.ts +7 -0
  51. package/node_modules/@silicaclaw/core/src/types.ts +44 -0
  52. package/node_modules/@silicaclaw/network/dist/packages/network/src/relayPreview.d.ts +12 -0
  53. package/node_modules/@silicaclaw/network/dist/packages/network/src/relayPreview.js +108 -8
  54. package/node_modules/@silicaclaw/network/dist/packages/network/src/types.d.ts +4 -0
  55. package/node_modules/@silicaclaw/network/src/relayPreview.ts +120 -10
  56. package/node_modules/@silicaclaw/network/src/types.ts +2 -0
  57. package/node_modules/@silicaclaw/storage/dist/packages/core/src/index.d.ts +2 -0
  58. package/node_modules/@silicaclaw/storage/dist/packages/core/src/index.js +2 -0
  59. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateCrypto.d.ts +17 -0
  60. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateCrypto.js +40 -0
  61. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateMessage.d.ts +23 -0
  62. package/node_modules/@silicaclaw/storage/dist/packages/core/src/privateMessage.js +74 -0
  63. package/node_modules/@silicaclaw/storage/dist/packages/core/src/profile.js +2 -0
  64. package/node_modules/@silicaclaw/storage/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  65. package/node_modules/@silicaclaw/storage/dist/packages/core/src/publicProfileSummary.js +3 -0
  66. package/node_modules/@silicaclaw/storage/dist/packages/core/src/types.d.ts +40 -0
  67. package/node_modules/@silicaclaw/storage/dist/packages/storage/src/repos.d.ts +13 -1
  68. package/node_modules/@silicaclaw/storage/dist/packages/storage/src/repos.js +19 -1
  69. package/node_modules/@silicaclaw/storage/src/repos.ts +31 -1
  70. package/openclaw-skills/silicaclaw-bridge-setup/SKILL.md +18 -0
  71. package/openclaw-skills/silicaclaw-bridge-setup/VERSION +1 -1
  72. package/openclaw-skills/silicaclaw-bridge-setup/manifest.json +2 -2
  73. package/openclaw-skills/silicaclaw-broadcast/SKILL.md +18 -0
  74. package/openclaw-skills/silicaclaw-broadcast/VERSION +1 -1
  75. package/openclaw-skills/silicaclaw-broadcast/manifest.json +2 -2
  76. package/openclaw-skills/silicaclaw-network-config/SKILL.md +158 -0
  77. package/openclaw-skills/silicaclaw-network-config/VERSION +1 -0
  78. package/openclaw-skills/silicaclaw-network-config/agents/openai.yaml +6 -0
  79. package/openclaw-skills/silicaclaw-network-config/manifest.json +27 -0
  80. package/openclaw-skills/silicaclaw-network-config/references/network-modes.md +22 -0
  81. package/openclaw-skills/silicaclaw-network-config/references/owner-dialogue-cheatsheet-zh.md +47 -0
  82. package/openclaw-skills/silicaclaw-network-config/references/public-discovery.md +22 -0
  83. package/openclaw-skills/silicaclaw-owner-push/SKILL.md +18 -0
  84. package/openclaw-skills/silicaclaw-owner-push/VERSION +1 -1
  85. package/openclaw-skills/silicaclaw-owner-push/manifest.json +2 -2
  86. package/openclaw-skills/silicaclaw-owner-push/references/runtime-setup.md +3 -0
  87. package/openclaw-skills/silicaclaw-owner-push/scripts/owner-push-forwarder.mjs +151 -9
  88. package/package.json +1 -1
  89. package/packages/core/dist/packages/core/src/index.d.ts +2 -0
  90. package/packages/core/dist/packages/core/src/index.js +2 -0
  91. package/packages/core/dist/packages/core/src/privateCrypto.d.ts +17 -0
  92. package/packages/core/dist/packages/core/src/privateCrypto.js +40 -0
  93. package/packages/core/dist/packages/core/src/privateMessage.d.ts +23 -0
  94. package/packages/core/dist/packages/core/src/privateMessage.js +74 -0
  95. package/packages/core/dist/packages/core/src/profile.js +2 -0
  96. package/packages/core/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  97. package/packages/core/dist/packages/core/src/publicProfileSummary.js +3 -0
  98. package/packages/core/dist/packages/core/src/types.d.ts +40 -0
  99. package/packages/core/src/index.ts +2 -0
  100. package/packages/core/src/privateCrypto.ts +57 -0
  101. package/packages/core/src/privateMessage.ts +101 -0
  102. package/packages/core/src/profile.ts +2 -0
  103. package/packages/core/src/publicProfileSummary.ts +7 -0
  104. package/packages/core/src/types.ts +44 -0
  105. package/packages/network/dist/packages/network/src/relayPreview.d.ts +12 -0
  106. package/packages/network/dist/packages/network/src/relayPreview.js +108 -8
  107. package/packages/network/dist/packages/network/src/types.d.ts +4 -0
  108. package/packages/network/src/relayPreview.ts +120 -10
  109. package/packages/network/src/types.ts +2 -0
  110. package/packages/storage/dist/packages/core/src/index.d.ts +2 -0
  111. package/packages/storage/dist/packages/core/src/index.js +2 -0
  112. package/packages/storage/dist/packages/core/src/privateCrypto.d.ts +17 -0
  113. package/packages/storage/dist/packages/core/src/privateCrypto.js +40 -0
  114. package/packages/storage/dist/packages/core/src/privateMessage.d.ts +23 -0
  115. package/packages/storage/dist/packages/core/src/privateMessage.js +74 -0
  116. package/packages/storage/dist/packages/core/src/profile.js +2 -0
  117. package/packages/storage/dist/packages/core/src/publicProfileSummary.d.ts +4 -0
  118. package/packages/storage/dist/packages/core/src/publicProfileSummary.js +3 -0
  119. package/packages/storage/dist/packages/core/src/types.d.ts +40 -0
  120. package/packages/storage/dist/packages/storage/src/repos.d.ts +13 -1
  121. package/packages/storage/dist/packages/storage/src/repos.js +19 -1
  122. package/packages/storage/src/repos.ts +31 -1
  123. package/scripts/silicaclaw-cli.mjs +59 -6
  124. package/scripts/silicaclaw-gateway.mjs +108 -0
  125. package/scripts/validate-openclaw-skill.mjs +19 -0
@@ -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,9 @@ import {
62
71
  CacheRepo,
63
72
  IdentityRepo,
64
73
  LogRepo,
74
+ PrivateEncryptionKeyRepo,
75
+ PrivateMessageReceiptRepo,
76
+ PrivateMessageRepo,
65
77
  ProfileRepo,
66
78
  SocialMessageGovernanceConfig,
67
79
  SocialMessageGovernanceRepo,
@@ -89,7 +101,10 @@ const DEFAULT_GLOBAL_ROOM = defaults.network.global_preview.room;
89
101
  const DEFAULT_BRIDGE_API_BASE = defaults.bridge.api_base;
90
102
  const OPENCLAW_GATEWAY_PORT = defaults.ports.openclaw_gateway;
91
103
  const OPENCLAW_GATEWAY_URL = `http://${OPENCLAW_GATEWAY_HOST}:${OPENCLAW_GATEWAY_PORT}/`;
104
+ const OPENCLAW_RUNTIME_CACHE_MS = 15_000;
105
+ const OPENCLAW_BRIDGE_STATUS_CACHE_MS = 5_000;
92
106
  const NETWORK_PEER_REMOVE_AFTER_MS = Number(process.env.NETWORK_PEER_REMOVE_AFTER_MS || 180_000);
107
+ const DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT = Number(process.env.DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT || 1000);
93
108
  const NETWORK_UDP_BIND_ADDRESS = process.env.NETWORK_UDP_BIND_ADDRESS || "0.0.0.0";
94
109
  const NETWORK_UDP_BROADCAST_ADDRESS = process.env.NETWORK_UDP_BROADCAST_ADDRESS || "255.255.255.255";
95
110
  const NETWORK_PEER_ID = process.env.NETWORK_PEER_ID;
@@ -102,6 +117,12 @@ const WEBRTC_BOOTSTRAP_HINTS = process.env.WEBRTC_BOOTSTRAP_HINTS || "";
102
117
  const PROFILE_VERSION = "v0.9";
103
118
  const SOCIAL_MESSAGE_TOPIC = "social.message";
104
119
  const SOCIAL_MESSAGE_OBSERVATION_TOPIC = "social.message.observation";
120
+ const PRIVATE_MESSAGE_TOPIC = "private.message";
121
+ const PRIVATE_MESSAGE_RECEIPT_TOPIC = "private.message.receipt";
122
+ const PRIVATE_MESSAGE_HISTORY_LIMIT = Number(process.env.PRIVATE_MESSAGE_HISTORY_LIMIT || 1000);
123
+ const PRIVATE_MESSAGE_RECEIPT_HISTORY_LIMIT = Number(process.env.PRIVATE_MESSAGE_RECEIPT_HISTORY_LIMIT || 2000);
124
+ const PRIVATE_MESSAGE_QUERY_LIMIT = Number(process.env.PRIVATE_MESSAGE_QUERY_LIMIT || 100);
125
+ const PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS = Number(process.env.PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS || 750);
105
126
  const DEFAULT_SOCIAL_MESSAGE_CHANNEL = "global";
106
127
  const SOCIAL_MESSAGE_MAX_BODY_CHARS = Number(process.env.SOCIAL_MESSAGE_MAX_BODY_CHARS || 500);
107
128
  const SOCIAL_MESSAGE_HISTORY_LIMIT = Number(process.env.SOCIAL_MESSAGE_HISTORY_LIMIT || 100);
@@ -115,6 +136,12 @@ const SOCIAL_MESSAGE_MAX_AGE_MS = Number(process.env.SOCIAL_MESSAGE_MAX_AGE_MS |
115
136
  const SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT = Number(process.env.SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT || 500);
116
137
  const SOCIAL_MESSAGE_REPLAY_WINDOW_MS = Number(process.env.SOCIAL_MESSAGE_REPLAY_WINDOW_MS || 10 * 60_000);
117
138
  const SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST = Number(process.env.SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST || 3);
139
+ const SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS = Number(
140
+ process.env.SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS || 120_000
141
+ );
142
+ const PROFILE_RELAY_REFRESH_INTERVAL_MS = Number(
143
+ process.env.PROFILE_RELAY_REFRESH_INTERVAL_MS || 120_000
144
+ );
118
145
  const SOCIAL_MESSAGE_BLOCKED_AGENT_IDS = new Set(
119
146
  dedupeStrings(parseListEnv(process.env.SOCIAL_MESSAGE_BLOCKED_AGENT_IDS || ""))
120
147
  );
@@ -157,6 +184,10 @@ function normalizeVersionText(value: unknown): string {
157
184
  return text.startsWith("v") ? text.slice(1) : text;
158
185
  }
159
186
 
187
+ function formatBytesToMiB(value: number): number {
188
+ return Math.round((value / (1024 * 1024)) * 10) / 10;
189
+ }
190
+
160
191
  function tokenizeVersion(value: unknown): Array<number | string> {
161
192
  return normalizeVersionText(value)
162
193
  .split(/[^0-9A-Za-z]+/)
@@ -186,6 +217,14 @@ function compareVersionTokens(left: unknown, right: unknown): number {
186
217
  return 0;
187
218
  }
188
219
 
220
+ function userNpmCacheDir(): string {
221
+ return resolve(homedir(), ".silicaclaw", "npm-cache");
222
+ }
223
+
224
+ function userShimPath(): string {
225
+ return resolve(homedir(), ".silicaclaw", "bin", "silicaclaw");
226
+ }
227
+
189
228
  function resolveWorkspaceRoot(cwd = process.cwd()): string {
190
229
  if (existsSync(resolve(cwd, "apps", "local-console", "package.json"))) {
191
230
  return cwd;
@@ -441,45 +480,66 @@ function readOpenClawConfiguredGateway(workspaceRoot: string) {
441
480
  } as const;
442
481
  }
443
482
 
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);
483
+ function resolveOpenClawStatusCommand(workspaceRoot: string) {
484
+ const explicitBin = String(process.env.OPENCLAW_BIN || "").trim();
485
+ if (explicitBin) {
486
+ return { cmd: explicitBin, args: ["status"] } as const;
487
+ }
454
488
 
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));
489
+ const configuredSourceDir = String(process.env.OPENCLAW_SOURCE_DIR || "").trim();
490
+ const defaultSourceDir = defaultOpenClawSourceDir(workspaceRoot);
491
+ const sourceDir = configuredSourceDir || defaultSourceDir;
492
+ const sourceEntry = existingPathOrNull(resolve(sourceDir, "openclaw.mjs"));
493
+ if (sourceEntry) {
494
+ return { cmd: process.execPath, args: [sourceEntry, "status"] } as const;
495
+ }
496
+
497
+ const commandPath = resolveExecutableInPath("openclaw");
498
+ if (commandPath) {
499
+ return { cmd: commandPath, args: ["status"] } as const;
500
+ }
501
+
502
+ return null;
503
+ }
504
+
505
+ function resolveOpenClawGatewayProbeCommand(workspaceRoot: string) {
506
+ const explicitBin = String(process.env.OPENCLAW_BIN || "").trim();
507
+ if (explicitBin) {
508
+ return { cmd: explicitBin, args: ["gateway", "probe"] } as const;
509
+ }
510
+
511
+ const configuredSourceDir = String(process.env.OPENCLAW_SOURCE_DIR || "").trim();
512
+ const defaultSourceDir = defaultOpenClawSourceDir(workspaceRoot);
513
+ const sourceDir = configuredSourceDir || defaultSourceDir;
514
+ const sourceEntry = existingPathOrNull(resolve(sourceDir, "openclaw.mjs"));
515
+ if (sourceEntry) {
516
+ return { cmd: process.execPath, args: [sourceEntry, "gateway", "probe"] } as const;
517
+ }
477
518
 
478
- const openclawPids = new Set(processes.map((item) => item.pid));
479
- const gatewayProbe = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], {
519
+ const commandPath = resolveExecutableInPath("openclaw");
520
+ if (commandPath) {
521
+ return { cmd: commandPath, args: ["gateway", "probe"] } as const;
522
+ }
523
+
524
+ return null;
525
+ }
526
+
527
+ function detectOpenClawRuntime(workspaceRoot: string) {
528
+ const configuredGateway = readOpenClawConfiguredGateway(workspaceRoot);
529
+ const statusCommand = resolveOpenClawStatusCommand(workspaceRoot);
530
+ const statusLooksConfigured = Boolean(
531
+ statusCommand ||
532
+ configuredGateway.config_path ||
533
+ detectOpenClawInstallation(workspaceRoot).detected
534
+ );
535
+ const gatewayProbeCommand = ["lsof", "-nP", `-iTCP:${configuredGateway.gateway_port}`, "-sTCP:LISTEN"];
536
+ const gatewayProbe = spawnSync(gatewayProbeCommand[0], gatewayProbeCommand.slice(1), {
480
537
  encoding: "utf8",
538
+ timeout: 1200,
481
539
  });
482
- const gatewayLines = String(gatewayProbe.stdout || "")
540
+ const gatewayStatusStdout = String(gatewayProbe.stdout || "");
541
+ const gatewayStatusStderr = String(gatewayProbe.stderr || "");
542
+ const gatewayLines = gatewayStatusStdout
483
543
  .split("\n")
484
544
  .map((line) => line.trim())
485
545
  .filter(Boolean);
@@ -489,14 +549,9 @@ function detectOpenClawRuntime(workspaceRoot: string) {
489
549
  const parts = line.split(/\s+/);
490
550
  const pid = Number(parts[1] || 0);
491
551
  const command = parts[0] || "";
492
- const lowerCommand = command.toLowerCase();
493
552
  const endpoint = parts[8] || parts[parts.length - 1] || "";
494
553
  const portMatch = endpoint.match(/:(\d+)(?:\s*\(|$)/);
495
554
  if (!pid || !command || !portMatch) return null;
496
- const isOpenClawListener =
497
- openclawPids.has(pid) ||
498
- lowerCommand.includes("openclaw");
499
- if (!isOpenClawListener) return null;
500
555
  const port = Number(portMatch[1]);
501
556
  if (!Number.isFinite(port) || port <= 0) return null;
502
557
  return {
@@ -507,46 +562,106 @@ function detectOpenClawRuntime(workspaceRoot: string) {
507
562
  };
508
563
  })
509
564
  .filter((item): item is { pid: number; ppid: number; port: number; command: string } => Boolean(item));
565
+ const gatewayProbeOk = gatewayListeners.length > 0;
566
+ let processes: Array<{ pid: number; ppid: number; command: string }> = gatewayListeners.map((item) => ({
567
+ pid: item.pid,
568
+ ppid: item.ppid,
569
+ command: item.command,
570
+ }));
571
+ let processResult: ReturnType<typeof spawnSync> | null = null;
572
+ if (!gatewayProbeOk) {
573
+ processResult = spawnSync("ps", ["-Ao", "pid=,ppid=,command="], {
574
+ encoding: "utf8",
575
+ timeout: 1200,
576
+ });
577
+ const stdout = String(processResult.stdout || "");
578
+ const lines = stdout
579
+ .split("\n")
580
+ .map((line) => line.trim())
581
+ .filter(Boolean);
582
+ processes = lines
583
+ .map((line) => {
584
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
585
+ if (!match) return null;
586
+ const command = match[3] || "";
587
+ const lower = command.toLowerCase();
588
+ const isOpenClaw =
589
+ lower.includes(" openclaw ") ||
590
+ lower.endsWith(" openclaw") ||
591
+ lower.includes("/openclaw ") ||
592
+ lower.includes("openclaw.mjs") ||
593
+ lower.includes("openclaw gateway") ||
594
+ lower.includes("openclaw agent") ||
595
+ lower.includes("openclaw message");
596
+ if (!isOpenClaw) return null;
597
+ return {
598
+ pid: Number(match[1]),
599
+ ppid: Number(match[2]),
600
+ command,
601
+ };
602
+ })
603
+ .filter((item): item is { pid: number; ppid: number; command: string } => Boolean(item));
604
+ }
605
+
510
606
  const preferredListener =
511
607
  gatewayListeners.find((item) => item.port === configuredGateway.gateway_port) ||
512
608
  gatewayListeners[0] ||
513
609
  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;
610
+ const allProcesses = processes.slice(0, 10);
611
+ const gatewayReachable = gatewayProbeOk;
528
612
  const detectionNotes = [];
529
- if (result.status !== 0) detectionNotes.push(String(result.stderr || "ps failed").trim());
530
613
  if (gatewayProbe.status !== 0 && gatewayLines.length === 0) {
531
- detectionNotes.push(String(gatewayProbe.stderr || "lsof failed").trim());
614
+ detectionNotes.push(String(gatewayStatusStderr || "openclaw gateway probe failed").trim());
615
+ }
616
+ if (processResult && processResult.status !== 0) {
617
+ detectionNotes.push(String(processResult.stderr || "ps failed").trim());
532
618
  }
533
619
  const gatewayPort = preferredListener?.port || configuredGateway.gateway_port;
534
620
  const gatewayUrl = `http://${OPENCLAW_GATEWAY_HOST}:${gatewayPort}/`;
535
621
 
536
622
  return {
537
- running: allProcesses.length > 0 || gatewayReachable,
623
+ running: gatewayProbeOk || allProcesses.length > 0 || gatewayReachable,
538
624
  process_count: allProcesses.length,
539
625
  processes: allProcesses.slice(0, 10),
540
626
  detection_error: detectionNotes.filter(Boolean).join(" | ") || null,
541
627
  gateway_url: gatewayUrl,
542
628
  gateway_port: gatewayPort,
543
629
  gateway_reachable: gatewayReachable,
630
+ status_command: statusCommand ? [statusCommand.cmd, ...statusCommand.args].join(" ") : null,
631
+ status_ok: statusLooksConfigured,
632
+ status_summary: statusLooksConfigured
633
+ ? configuredGateway.config_path
634
+ ? `configured via ${configuredGateway.config_path}`
635
+ : statusCommand
636
+ ? `command available: ${[statusCommand.cmd, ...statusCommand.args].join(" ")}`
637
+ : "OpenClaw environment detected"
638
+ : null,
639
+ gateway_probe_command: gatewayProbeCommand.join(" "),
640
+ gateway_probe_ok: gatewayProbeOk,
641
+ gateway_probe_summary: gatewayProbeOk
642
+ ? gatewayStatusStdout
643
+ .split("\n")
644
+ .map((line) => line.trim())
645
+ .filter(Boolean)
646
+ .slice(0, 4)
647
+ .join(" | ")
648
+ : null,
544
649
  configured_gateway_url: configuredGateway.gateway_url,
545
650
  configured_gateway_port: configuredGateway.gateway_port,
546
651
  configured_gateway_bind: configuredGateway.gateway_bind,
547
652
  configured_gateway_config_path: configuredGateway.config_path,
548
653
  detection_mode:
549
- processes.length > 0 && gatewayReachable
654
+ gatewayProbeOk
655
+ ? (
656
+ processes.length > 0 && gatewayReachable
657
+ ? "gateway-probe+process+gateway"
658
+ : gatewayReachable
659
+ ? "gateway-probe+gateway"
660
+ : processes.length > 0
661
+ ? "gateway-probe+process"
662
+ : "gateway-probe"
663
+ )
664
+ : processes.length > 0 && gatewayReachable
550
665
  ? "process+gateway"
551
666
  : gatewayReachable
552
667
  ? "gateway"
@@ -766,6 +881,7 @@ type IntegrationStatusSummary = {
766
881
  };
767
882
 
768
883
  type SocialMessageView = SocialMessageRecord & {
884
+ avatar_url?: string;
769
885
  is_self: boolean;
770
886
  online: boolean;
771
887
  last_seen_at: number | null;
@@ -775,6 +891,17 @@ type SocialMessageView = SocialMessageRecord & {
775
891
  delivery_status: "local-only" | "remote-observed";
776
892
  };
777
893
 
894
+ type PrivateMessageView = {
895
+ message_id: string;
896
+ conversation_id: string;
897
+ from_agent_id: string;
898
+ to_agent_id: string;
899
+ body: string;
900
+ created_at: number;
901
+ is_self: boolean;
902
+ delivery_status: "sent" | "received" | "read";
903
+ };
904
+
778
905
  type RuntimeMessageGovernance = SocialMessageGovernanceConfig;
779
906
 
780
907
  type OpenClawBridgeStatus = {
@@ -818,11 +945,17 @@ type OpenClawBridgeStatus = {
818
945
  gateway_url: string;
819
946
  gateway_port: number;
820
947
  gateway_reachable: boolean;
948
+ status_command: string | null;
949
+ status_ok: boolean;
950
+ status_summary: string | null;
951
+ gateway_probe_command: string | null;
952
+ gateway_probe_ok: boolean;
953
+ gateway_probe_summary: string | null;
821
954
  configured_gateway_url: string;
822
955
  configured_gateway_port: number;
823
956
  configured_gateway_bind: string | null;
824
957
  configured_gateway_config_path: string | null;
825
- detection_mode: "process" | "gateway" | "process+gateway" | "not_running";
958
+ detection_mode: "gateway-probe" | "gateway-probe+process" | "gateway-probe+gateway" | "gateway-probe+process+gateway" | "process" | "gateway" | "process+gateway" | "not_running";
826
959
  };
827
960
  skill_learning: {
828
961
  available: boolean;
@@ -899,6 +1032,9 @@ export class LocalNodeService {
899
1032
  private socialMessageGovernanceRepo: SocialMessageGovernanceRepo;
900
1033
  private socialMessageRepo: SocialMessageRepo;
901
1034
  private socialMessageObservationRepo: SocialMessageObservationRepo;
1035
+ private privateMessageRepo: PrivateMessageRepo;
1036
+ private privateMessageReceiptRepo: PrivateMessageReceiptRepo;
1037
+ private privateEncryptionKeyRepo: PrivateEncryptionKeyRepo;
902
1038
  private socialRuntimeRepo: SocialRuntimeRepo;
903
1039
 
904
1040
  private identity: AgentIdentity | null = null;
@@ -906,15 +1042,31 @@ export class LocalNodeService {
906
1042
  private directory: DirectoryState = createEmptyDirectoryState();
907
1043
  private socialMessages: SocialMessageRecord[] = [];
908
1044
  private socialMessageObservations: SocialMessageObservationRecord[] = [];
1045
+ private privateMessages: PrivateMessageRecord[] = [];
1046
+ private privateMessageReceipts: PrivateMessageReceiptRecord[] = [];
1047
+ private privateEncryptionKeyPair: PrivateEncryptionKeyPair | null = null;
1048
+ private privatePeerRoutes: Record<string, string> = {};
1049
+ private privateMessageBodyCache = new Map<string, string>();
909
1050
  private messageGovernance: RuntimeMessageGovernance;
1051
+ private privateMessagesPersistDirty = false;
1052
+ private privateMessageReceiptsPersistDirty = false;
1053
+ private privateMessagesPersistTimer: NodeJS.Timeout | null = null;
1054
+ private privateMessageReceiptsPersistTimer: NodeJS.Timeout | null = null;
910
1055
 
911
1056
  private receivedCount = 0;
912
1057
  private broadcastCount = 0;
913
1058
  private lastMessageAt = 0;
914
1059
  private lastBroadcastAt = 0;
1060
+ private lastProfileBroadcastAt = 0;
1061
+ private lastProfileBroadcastSignature = "";
1062
+ private lastReplayBroadcastAt = 0;
1063
+ private lastReplayBroadcastSignature = "";
915
1064
  private lastBroadcastErrorAt = 0;
916
1065
  private lastBroadcastError: string | null = null;
917
1066
  private broadcastFailureCount = 0;
1067
+ private consecutiveBroadcastFailures = 0;
1068
+ private lastBroadcastRecoveryAttemptAt = 0;
1069
+ private broadcastRecoveryInFlight = false;
918
1070
  private broadcaster: NodeJS.Timeout | null = null;
919
1071
  private subscriptionsBound = false;
920
1072
  private broadcastEnabled = true;
@@ -956,6 +1108,8 @@ export class LocalNodeService {
956
1108
  private networkReconnectTimer: NodeJS.Timeout | null = null;
957
1109
  private networkReconnectDelayMs = 5_000;
958
1110
  private appVersion = "unknown";
1111
+ private openclawRuntimeCache: { value: ReturnType<typeof detectOpenClawRuntime>; expiresAt: number } | null = null;
1112
+ private openclawBridgeStatusCache: { value: OpenClawBridgeStatus; expiresAt: number } | null = null;
959
1113
 
960
1114
  constructor(options?: { workspaceRoot?: string; projectRoot?: string; storageRoot?: string }) {
961
1115
  this.workspaceRoot = options?.workspaceRoot || resolveWorkspaceRoot();
@@ -971,6 +1125,9 @@ export class LocalNodeService {
971
1125
  this.socialMessageGovernanceRepo = new SocialMessageGovernanceRepo(this.storageRoot);
972
1126
  this.socialMessageRepo = new SocialMessageRepo(this.storageRoot);
973
1127
  this.socialMessageObservationRepo = new SocialMessageObservationRepo(this.storageRoot);
1128
+ this.privateMessageRepo = new PrivateMessageRepo(this.storageRoot);
1129
+ this.privateMessageReceiptRepo = new PrivateMessageReceiptRepo(this.storageRoot);
1130
+ this.privateEncryptionKeyRepo = new PrivateEncryptionKeyRepo(this.storageRoot);
974
1131
  this.socialRuntimeRepo = new SocialRuntimeRepo(this.storageRoot);
975
1132
  this.messageGovernance = this.defaultMessageGovernance();
976
1133
 
@@ -1001,6 +1158,24 @@ export class LocalNodeService {
1001
1158
  this.networkPort = resolved.port;
1002
1159
  }
1003
1160
 
1161
+ private getCachedOpenClawRuntime() {
1162
+ const now = Date.now();
1163
+ if (this.openclawRuntimeCache && this.openclawRuntimeCache.expiresAt > now) {
1164
+ return this.openclawRuntimeCache.value;
1165
+ }
1166
+ const value = detectOpenClawRuntime(this.projectRoot);
1167
+ this.openclawRuntimeCache = {
1168
+ value,
1169
+ expiresAt: now + OPENCLAW_RUNTIME_CACHE_MS,
1170
+ };
1171
+ return value;
1172
+ }
1173
+
1174
+ private invalidateOpenClawCaches() {
1175
+ this.openclawRuntimeCache = null;
1176
+ this.openclawBridgeStatusCache = null;
1177
+ }
1178
+
1004
1179
  async start(): Promise<void> {
1005
1180
  await this.hydrateFromDisk();
1006
1181
 
@@ -1014,6 +1189,7 @@ export class LocalNodeService {
1014
1189
  clearInterval(this.broadcaster);
1015
1190
  this.broadcaster = null;
1016
1191
  }
1192
+ await this.flushPrivatePersistence();
1017
1193
  if (this.networkStarted) {
1018
1194
  await this.network.stop();
1019
1195
  }
@@ -1034,6 +1210,9 @@ export class LocalNodeService {
1034
1210
  getOverview() {
1035
1211
  const discovered = this.search("");
1036
1212
  const onlineCount = discovered.filter((profile) => profile.online).length;
1213
+ const openclawInstallation = detectOpenClawInstallation(this.projectRoot);
1214
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1215
+ const openclawSkillInstallation = detectOpenClawSkillInstallation();
1037
1216
 
1038
1217
  return {
1039
1218
  app_version: this.appVersion,
@@ -1050,6 +1229,15 @@ export class LocalNodeService {
1050
1229
  init_state: this.initState,
1051
1230
  presence_ttl_ms: PRESENCE_TTL_MS,
1052
1231
  onboarding: this.getOnboardingSummary(),
1232
+ openclaw: {
1233
+ detected: openclawInstallation.detected,
1234
+ running: openclawRuntime.running,
1235
+ detection_mode: openclawRuntime.detection_mode,
1236
+ gateway_url: openclawRuntime.gateway_url,
1237
+ gateway_probe_ok: openclawRuntime.gateway_probe_ok,
1238
+ status_ok: openclawRuntime.status_ok,
1239
+ skill_installed: openclawSkillInstallation.installed,
1240
+ },
1053
1241
  social: {
1054
1242
  found: this.socialFound,
1055
1243
  enabled: this.socialConfig.enabled,
@@ -1197,6 +1385,7 @@ export class LocalNodeService {
1197
1385
  const relayCapable = this.adapterMode === "webrtc-preview" || this.adapterMode === "relay-preview";
1198
1386
  const peers: Array<{ status?: string }> = diagnostics?.peers?.items ?? [];
1199
1387
  const online = peers.filter((peer: { status?: string }) => peer.status === "online").length;
1388
+ const memory = process.memoryUsage();
1200
1389
 
1201
1390
  return {
1202
1391
  adapter: this.adapterMode,
@@ -1221,6 +1410,23 @@ export class LocalNodeService {
1221
1410
  adapter_stats: diagnostics?.stats ?? null,
1222
1411
  adapter_transport_stats: diagnostics?.transport_stats ?? null,
1223
1412
  adapter_discovery_stats: diagnostics?.discovery_stats ?? null,
1413
+ runtime_diagnostics: {
1414
+ memory_mib: {
1415
+ rss: formatBytesToMiB(memory.rss),
1416
+ heap_used: formatBytesToMiB(memory.heapUsed),
1417
+ heap_total: formatBytesToMiB(memory.heapTotal),
1418
+ external: formatBytesToMiB(memory.external),
1419
+ },
1420
+ directory: {
1421
+ profile_count: Object.keys(this.directory.profiles).length,
1422
+ presence_count: Object.keys(this.directory.presence).length,
1423
+ index_key_count: Object.keys(this.directory.index).length,
1424
+ },
1425
+ social: {
1426
+ message_count: this.socialMessages.length,
1427
+ observation_count: this.socialMessageObservations.length,
1428
+ },
1429
+ },
1224
1430
  adapter_diagnostics_summary: relayCapable || diagnostics
1225
1431
  ? {
1226
1432
  started: this.networkStarted,
@@ -1337,6 +1543,92 @@ export class LocalNodeService {
1337
1543
  };
1338
1544
  }
1339
1545
 
1546
+ getAppUpdateStatus() {
1547
+ const currentVersion = normalizeVersionText(this.appVersion) || "unknown";
1548
+ const fallback = {
1549
+ current_version: currentVersion,
1550
+ latest_version: currentVersion,
1551
+ update_available: false,
1552
+ channel: "latest",
1553
+ platform: process.platform,
1554
+ checked_at: Date.now(),
1555
+ can_update: true,
1556
+ check_error: null as string | null,
1557
+ };
1558
+ try {
1559
+ const result = spawnSync("npm", ["view", "@silicaclaw/cli", "dist-tags", "--json"], {
1560
+ cwd: this.projectRoot,
1561
+ encoding: "utf8",
1562
+ env: {
1563
+ ...process.env,
1564
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1565
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1566
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1567
+ },
1568
+ });
1569
+ if ((result.status ?? 1) !== 0) {
1570
+ return {
1571
+ ...fallback,
1572
+ check_error: String(result.stderr || result.stdout || "npm view failed").trim() || "npm view failed",
1573
+ };
1574
+ }
1575
+ const tags = JSON.parse(String(result.stdout || "{}").trim() || "{}") as { latest?: string };
1576
+ const latestVersion = normalizeVersionText(tags.latest || currentVersion) || currentVersion;
1577
+ return {
1578
+ ...fallback,
1579
+ latest_version: latestVersion,
1580
+ update_available: compareVersionTokens(latestVersion, currentVersion) > 0,
1581
+ };
1582
+ } catch (error) {
1583
+ return {
1584
+ ...fallback,
1585
+ check_error: error instanceof Error ? error.message : String(error),
1586
+ };
1587
+ }
1588
+ }
1589
+
1590
+ startAppUpdate(): { started: boolean; target_version: string; platform: string; reason?: string } {
1591
+ const status = this.getAppUpdateStatus();
1592
+ if (!status.update_available || !status.latest_version) {
1593
+ return {
1594
+ started: false,
1595
+ target_version: status.latest_version || status.current_version,
1596
+ platform: process.platform,
1597
+ reason: status.check_error || "already_current",
1598
+ };
1599
+ }
1600
+ const shimPath = userShimPath();
1601
+ const scriptPath = resolve(this.workspaceRoot, "scripts", "silicaclaw-cli.mjs");
1602
+ const useShim = existsSync(shimPath);
1603
+ if (!useShim && !existsSync(scriptPath)) {
1604
+ return {
1605
+ started: false,
1606
+ target_version: status.latest_version,
1607
+ platform: process.platform,
1608
+ reason: "missing_cli_script",
1609
+ };
1610
+ }
1611
+ const command = useShim ? shimPath : process.execPath;
1612
+ const args = useShim ? ["update"] : [scriptPath, "update"];
1613
+ const child = spawn(command, args, {
1614
+ cwd: this.projectRoot,
1615
+ detached: true,
1616
+ stdio: "ignore",
1617
+ env: {
1618
+ ...process.env,
1619
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1620
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1621
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1622
+ },
1623
+ });
1624
+ child.unref();
1625
+ return {
1626
+ started: true,
1627
+ target_version: status.latest_version,
1628
+ platform: process.platform,
1629
+ };
1630
+ }
1631
+
1340
1632
  getIntegrationSummary() {
1341
1633
  const status = this.getIntegrationStatus();
1342
1634
  const runtimeGenerated = Boolean(this.socialRuntime && this.socialRuntime.last_loaded_at > 0);
@@ -1635,6 +1927,7 @@ export class LocalNodeService {
1635
1927
  return {
1636
1928
  ...message,
1637
1929
  display_name: profile?.display_name || message.display_name || "Unnamed",
1930
+ avatar_url: profile?.avatar_url || "",
1638
1931
  is_self: message.agent_id === this.identity?.agent_id,
1639
1932
  online: isAgentOnline(lastSeenAt, Date.now(), PRESENCE_TTL_MS),
1640
1933
  last_seen_at: lastSeenAt || null,
@@ -1654,10 +1947,141 @@ export class LocalNodeService {
1654
1947
  };
1655
1948
  }
1656
1949
 
1950
+ getPrivateMessagingState() {
1951
+ return {
1952
+ enabled: Boolean(this.identity && this.privateEncryptionKeyPair),
1953
+ agent_id: this.identity?.agent_id || "",
1954
+ encryption_public_key: this.privateEncryptionKeyPair?.public_key || "",
1955
+ conversation_count: this.getPrivateConversations().length,
1956
+ message_count: this.privateMessages.length,
1957
+ };
1958
+ }
1959
+
1960
+ getPrivateConversations(): Array<{
1961
+ conversation_id: string;
1962
+ peer_agent_id: string;
1963
+ peer_display_name: string;
1964
+ peer_avatar_url: string;
1965
+ peer_public_key: string;
1966
+ last_message_at: number | null;
1967
+ unread_count: number;
1968
+ }> {
1969
+ const conversations = new Map<string, {
1970
+ conversation_id: string;
1971
+ peer_agent_id: string;
1972
+ peer_display_name: string;
1973
+ peer_avatar_url: string;
1974
+ peer_public_key: string;
1975
+ last_message_at: number | null;
1976
+ unread_count: number;
1977
+ }>();
1978
+ for (const message of this.privateMessages) {
1979
+ if (message.from_agent_id === message.to_agent_id) {
1980
+ continue;
1981
+ }
1982
+ const peerAgentId = message.from_agent_id === this.identity?.agent_id ? message.to_agent_id : message.from_agent_id;
1983
+ if (!peerAgentId || peerAgentId === this.identity?.agent_id) {
1984
+ continue;
1985
+ }
1986
+ const peerProfile = this.directory.profiles[peerAgentId];
1987
+ const current = conversations.get(message.conversation_id);
1988
+ const nextLast = Math.max(current?.last_message_at || 0, message.created_at || 0) || null;
1989
+ conversations.set(message.conversation_id, {
1990
+ conversation_id: message.conversation_id,
1991
+ peer_agent_id: peerAgentId,
1992
+ peer_display_name: peerProfile?.display_name || peerAgentId,
1993
+ peer_avatar_url: peerProfile?.avatar_url || "",
1994
+ peer_public_key: peerProfile?.private_encryption_public_key || "",
1995
+ last_message_at: nextLast,
1996
+ unread_count: current?.unread_count || 0,
1997
+ });
1998
+ }
1999
+ return Array.from(conversations.values()).sort((a, b) => (b.last_message_at || 0) - (a.last_message_at || 0));
2000
+ }
2001
+
2002
+ getPrivateMessages(conversationId: string, limit = PRIVATE_MESSAGE_QUERY_LIMIT): PrivateMessageView[] {
2003
+ const normalizedConversationId = String(conversationId || "").trim();
2004
+ const resolvedLimit = Math.max(1, Math.min(PRIVATE_MESSAGE_QUERY_LIMIT, Number(limit) || PRIVATE_MESSAGE_QUERY_LIMIT));
2005
+ const receiptsByMessageId = new Map(
2006
+ this.privateMessageReceipts.map((receipt) => [receipt.message_id, receipt.status] as const)
2007
+ );
2008
+ return this.privateMessages
2009
+ .filter((message) => {
2010
+ if (message.from_agent_id === message.to_agent_id) {
2011
+ return false;
2012
+ }
2013
+ const peerAgentId = message.from_agent_id === this.identity?.agent_id ? message.to_agent_id : message.from_agent_id;
2014
+ if (!peerAgentId || peerAgentId === this.identity?.agent_id) {
2015
+ return false;
2016
+ }
2017
+ return !normalizedConversationId || message.conversation_id === normalizedConversationId;
2018
+ })
2019
+ .sort((a, b) => a.created_at - b.created_at)
2020
+ .slice(-resolvedLimit)
2021
+ .map((message) => ({
2022
+ message_id: message.message_id,
2023
+ conversation_id: message.conversation_id,
2024
+ from_agent_id: message.from_agent_id,
2025
+ to_agent_id: message.to_agent_id,
2026
+ body: this.decryptPrivateMessageBody(message),
2027
+ created_at: message.created_at,
2028
+ is_self: message.from_agent_id === this.identity?.agent_id,
2029
+ delivery_status: receiptsByMessageId.get(message.message_id) || "sent",
2030
+ }));
2031
+ }
2032
+
2033
+ async sendPrivateMessage(input: {
2034
+ to_agent_id: string;
2035
+ recipient_encryption_public_key: string;
2036
+ body: string;
2037
+ }): Promise<{ sent: boolean; reason: string; message?: PrivateMessageView }> {
2038
+ if (!this.identity || !this.privateEncryptionKeyPair) {
2039
+ return { sent: false, reason: "missing_identity_or_private_key" };
2040
+ }
2041
+ const toAgentId = String(input.to_agent_id || "").trim();
2042
+ const recipientKey = String(input.recipient_encryption_public_key || "").trim();
2043
+ const body = String(input.body || "").trim();
2044
+ if (toAgentId === this.identity.agent_id) {
2045
+ return { sent: false, reason: "self_private_message_not_allowed" };
2046
+ }
2047
+ const toPeerId = this.privatePeerRoutes[toAgentId] || "";
2048
+ if (!toAgentId || !toPeerId || !recipientKey || !body) {
2049
+ return { sent: false, reason: "invalid_private_message_input" };
2050
+ }
2051
+ if (typeof this.network.sendDirect !== "function") {
2052
+ return { sent: false, reason: "direct_delivery_not_supported" };
2053
+ }
2054
+ const encrypted = encryptPrivatePayload({
2055
+ plaintext: body,
2056
+ recipient_public_key: recipientKey,
2057
+ sender_keypair: this.privateEncryptionKeyPair,
2058
+ });
2059
+ const message = signPrivateMessage({
2060
+ identity: this.identity,
2061
+ message_id: createHash("sha256").update(`${this.identity.agent_id}:${toAgentId}:${Date.now()}:${body}:${Math.random()}`, "utf8").digest("hex"),
2062
+ conversation_id: this.buildPrivateConversationId(this.identity.agent_id, toAgentId),
2063
+ to_agent_id: toAgentId,
2064
+ sender_encryption_public_key: encrypted.sender_encryption_public_key,
2065
+ recipient_encryption_public_key: recipientKey,
2066
+ ciphertext: encrypted.ciphertext,
2067
+ nonce: encrypted.nonce,
2068
+ created_at: Date.now(),
2069
+ });
2070
+ this.ingestPrivateMessage(message);
2071
+ await this.persistPrivateMessages();
2072
+ await this.network.sendDirect(toPeerId, PRIVATE_MESSAGE_TOPIC, message);
2073
+ const view = this.getPrivateMessages(message.conversation_id).find((item) => item.message_id === message.message_id);
2074
+ return { sent: true, reason: "sent", message: view };
2075
+ }
2076
+
1657
2077
  getOpenClawBridgeStatus(): OpenClawBridgeStatus {
2078
+ const now = Date.now();
2079
+ if (this.openclawBridgeStatusCache && this.openclawBridgeStatusCache.expiresAt > now) {
2080
+ return this.openclawBridgeStatusCache.value;
2081
+ }
1658
2082
  const integration = this.getIntegrationStatus();
1659
2083
  const openclawInstallation = detectOpenClawInstallation(this.projectRoot);
1660
- const openclawRuntime = detectOpenClawRuntime(this.projectRoot);
2084
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1661
2085
  const skillInstallation = detectOpenClawSkillInstallation();
1662
2086
  const ownerDelivery = detectOwnerDeliveryStatus({
1663
2087
  workspaceRoot: this.projectRoot,
@@ -1665,7 +2089,7 @@ export class LocalNodeService {
1665
2089
  openclawRunning: openclawRuntime.running,
1666
2090
  skillInstalled: skillInstallation.installed,
1667
2091
  });
1668
- return {
2092
+ const value: OpenClawBridgeStatus = {
1669
2093
  enabled: this.socialConfig.enabled,
1670
2094
  connected_to_silicaclaw: integration.connected_to_silicaclaw,
1671
2095
  public_enabled: integration.public_enabled,
@@ -1721,6 +2145,11 @@ export class LocalNodeService {
1721
2145
  install_skill: "/api/openclaw/bridge/skill-install",
1722
2146
  },
1723
2147
  };
2148
+ this.openclawBridgeStatusCache = {
2149
+ value,
2150
+ expiresAt: now + OPENCLAW_BRIDGE_STATUS_CACHE_MS,
2151
+ };
2152
+ return value;
1724
2153
  }
1725
2154
 
1726
2155
  async installOpenClawSkill(skillName?: string) {
@@ -1735,6 +2164,7 @@ export class LocalNodeService {
1735
2164
  maxBuffer: 1024 * 1024,
1736
2165
  });
1737
2166
  const parsed = JSON.parse(String(stdout || "{}"));
2167
+ this.invalidateOpenClawCaches();
1738
2168
  return {
1739
2169
  ...parsed,
1740
2170
  bridge: this.getOpenClawBridgeStatus(),
@@ -1756,7 +2186,7 @@ export class LocalNodeService {
1756
2186
  const workspaceSkillDir = resolve(homeDir, "workspace", "skills");
1757
2187
  const legacySkillDir = resolve(homeDir, "skills");
1758
2188
  const openclawSourceDir = defaultOpenClawSourceDir(this.projectRoot);
1759
- const openclawRuntime = detectOpenClawRuntime(this.projectRoot);
2189
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1760
2190
 
1761
2191
  return {
1762
2192
  bridge_api_base: DEFAULT_BRIDGE_API_BASE,
@@ -2166,15 +2596,14 @@ export class LocalNodeService {
2166
2596
  profile: this.profile,
2167
2597
  };
2168
2598
  const presenceRecord = signPresence(this.identity, Date.now());
2169
- const indexRecords = buildIndexRecords(this.profile);
2170
- const replayMessages = this.getReplayableSelfSocialMessages();
2599
+ const shouldPublishProfile = this.shouldPublishProfileRecord(profileRecord, reason, presenceRecord.timestamp);
2600
+ const replayMessages = this.getReplayableSelfSocialMessages(reason);
2171
2601
 
2172
2602
  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);
2603
+ if (shouldPublishProfile) {
2604
+ await this.publish("profile", profileRecord);
2177
2605
  }
2606
+ await this.publish("presence", presenceRecord);
2178
2607
  for (const message of replayMessages) {
2179
2608
  await this.publish(SOCIAL_MESSAGE_TOPIC, message);
2180
2609
  }
@@ -2183,7 +2612,9 @@ export class LocalNodeService {
2183
2612
  this.lastBroadcastErrorAt = Date.now();
2184
2613
  this.lastBroadcastError = message;
2185
2614
  this.broadcastFailureCount += 1;
2615
+ this.consecutiveBroadcastFailures += 1;
2186
2616
  await this.log("error", `Broadcast failed (reason=${reason}): ${message}`);
2617
+ await this.maybeRecoverFromBroadcastFailure(reason, message);
2187
2618
  return { sent: false, reason: "publish_failed", error: message };
2188
2619
  }
2189
2620
 
@@ -2191,22 +2622,75 @@ export class LocalNodeService {
2191
2622
  this.broadcastCount += 1;
2192
2623
  this.lastBroadcastError = null;
2193
2624
  this.lastBroadcastErrorAt = 0;
2625
+ this.consecutiveBroadcastFailures = 0;
2194
2626
 
2195
2627
  this.directory = ingestProfileRecord(this.directory, profileRecord);
2196
2628
  this.directory = ingestPresenceRecord(this.directory, presenceRecord);
2197
- for (const record of indexRecords) {
2198
- this.directory = ingestIndexRecord(this.directory, record);
2199
- }
2200
2629
  this.compactCacheInMemory();
2201
2630
  await this.persistCache();
2202
2631
 
2203
2632
  await this.log(
2204
2633
  "info",
2205
- `Broadcast sent (${indexRecords.length} index refs, replayed_messages=${replayMessages.length}, reason=${reason})`
2634
+ `Broadcast sent (${shouldPublishProfile ? "profile + " : ""}presence, replayed_messages=${replayMessages.length}, reason=${reason})`
2206
2635
  );
2207
2636
  return { sent: true, reason };
2208
2637
  }
2209
2638
 
2639
+ private shouldPublishProfileRecord(
2640
+ profileRecord: SignedProfileRecord,
2641
+ reason: string,
2642
+ now = Date.now()
2643
+ ): boolean {
2644
+ if (reason !== "interval") {
2645
+ this.lastProfileBroadcastSignature = profileRecord.profile.signature;
2646
+ this.lastProfileBroadcastAt = now;
2647
+ return true;
2648
+ }
2649
+ const signature = profileRecord.profile.signature;
2650
+ const changedSinceLastPublish = signature !== this.lastProfileBroadcastSignature;
2651
+ const refreshDue = now - this.lastProfileBroadcastAt >= PROFILE_RELAY_REFRESH_INTERVAL_MS;
2652
+ if (!changedSinceLastPublish && !refreshDue) {
2653
+ return false;
2654
+ }
2655
+ this.lastProfileBroadcastSignature = signature;
2656
+ this.lastProfileBroadcastAt = now;
2657
+ return true;
2658
+ }
2659
+
2660
+ private async maybeRecoverFromBroadcastFailure(reason: string, errorMessage: string): Promise<void> {
2661
+ const recoveryThreshold = 3;
2662
+ const recoveryCooldownMs = 60_000;
2663
+ if (this.broadcastRecoveryInFlight) {
2664
+ return;
2665
+ }
2666
+ if (this.consecutiveBroadcastFailures < recoveryThreshold) {
2667
+ return;
2668
+ }
2669
+ if (Date.now() - this.lastBroadcastRecoveryAttemptAt < recoveryCooldownMs) {
2670
+ return;
2671
+ }
2672
+ if (this.adapterMode !== "relay-preview" && this.adapterMode !== "webrtc-preview" && this.adapterMode !== "real-preview") {
2673
+ return;
2674
+ }
2675
+
2676
+ this.broadcastRecoveryInFlight = true;
2677
+ this.lastBroadcastRecoveryAttemptAt = Date.now();
2678
+ try {
2679
+ await this.log(
2680
+ "warn",
2681
+ `Broadcast recovery triggered after ${this.consecutiveBroadcastFailures} consecutive failures (${reason}): ${errorMessage}`
2682
+ );
2683
+ await this.restartNetworkAdapter("broadcast_failure_recovery");
2684
+ } catch (recoveryError) {
2685
+ await this.log(
2686
+ "error",
2687
+ `Broadcast recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`
2688
+ );
2689
+ } finally {
2690
+ this.broadcastRecoveryInFlight = false;
2691
+ }
2692
+ }
2693
+
2210
2694
  private async hydrateFromDisk(): Promise<void> {
2211
2695
  this.initState = {
2212
2696
  identity_auto_created: false,
@@ -2236,6 +2720,8 @@ export class LocalNodeService {
2236
2720
  await this.log("info", `Bound existing OpenClaw identity: ${resolvedIdentity.openclaw_source_path}`);
2237
2721
  }
2238
2722
  await this.identityRepo.set(this.identity);
2723
+ this.privateEncryptionKeyPair = (await this.privateEncryptionKeyRepo.get()) || createPrivateEncryptionKeyPair();
2724
+ await this.privateEncryptionKeyRepo.set(this.privateEncryptionKeyPair);
2239
2725
 
2240
2726
  const existingProfile = await this.profileRepo.get();
2241
2727
  const profileInput = resolveProfileInputWithSocial({
@@ -2244,7 +2730,10 @@ export class LocalNodeService {
2244
2730
  existingProfile: existingProfile && existingProfile.agent_id === this.identity.agent_id ? existingProfile : null,
2245
2731
  rootDir: this.projectRoot,
2246
2732
  });
2247
- this.profile = signProfile(profileInput, this.identity);
2733
+ this.profile = signProfile({
2734
+ ...profileInput,
2735
+ private_encryption_public_key: this.privateEncryptionKeyPair?.public_key || profileInput.private_encryption_public_key || "",
2736
+ }, this.identity);
2248
2737
  if (!existingProfile || existingProfile.agent_id !== this.identity.agent_id) {
2249
2738
  this.initState.profile_auto_created = true;
2250
2739
  await this.log("info", "profile.json missing/invalid, initialized from social/default profile");
@@ -2258,6 +2747,8 @@ export class LocalNodeService {
2258
2747
  };
2259
2748
  this.socialMessages = this.normalizeSocialMessages(await this.socialMessageRepo.get());
2260
2749
  this.socialMessageObservations = this.normalizeSocialMessageObservations(await this.socialMessageObservationRepo.get());
2750
+ this.privateMessages = this.normalizePrivateMessages(await this.privateMessageRepo.get());
2751
+ this.privateMessageReceipts = this.normalizePrivateMessageReceipts(await this.privateMessageReceiptRepo.get());
2261
2752
  this.directory = ingestProfileRecord(this.directory, { type: "profile", profile: this.profile });
2262
2753
  this.compactCacheInMemory();
2263
2754
  await this.persistCache();
@@ -2276,7 +2767,10 @@ export class LocalNodeService {
2276
2767
  existingProfile: this.profile,
2277
2768
  rootDir: this.projectRoot,
2278
2769
  });
2279
- const nextProfile = signProfile(nextProfileInput, this.identity);
2770
+ const nextProfile = signProfile({
2771
+ ...nextProfileInput,
2772
+ private_encryption_public_key: this.privateEncryptionKeyPair?.public_key || nextProfileInput.private_encryption_public_key || "",
2773
+ }, this.identity);
2280
2774
  this.profile = nextProfile;
2281
2775
  await this.profileRepo.set(nextProfile);
2282
2776
 
@@ -2341,7 +2835,8 @@ export class LocalNodeService {
2341
2835
 
2342
2836
  private async onMessage(
2343
2837
  topic: "profile" | "presence" | "index" | "social.message" | "social.message.observation",
2344
- data: unknown
2838
+ data: unknown,
2839
+ meta?: { peerId?: string }
2345
2840
  ): Promise<void> {
2346
2841
  this.receivedCount += 1;
2347
2842
  this.receivedByTopic[topic] = (this.receivedByTopic[topic] ?? 0) + 1;
@@ -2359,6 +2854,9 @@ export class LocalNodeService {
2359
2854
  return;
2360
2855
  }
2361
2856
  }
2857
+ if (meta?.peerId && record.profile.agent_id) {
2858
+ this.privatePeerRoutes[record.profile.agent_id] = meta.peerId;
2859
+ }
2362
2860
 
2363
2861
  this.directory = ingestProfileRecord(this.directory, record);
2364
2862
  this.compactCacheInMemory();
@@ -2378,6 +2876,9 @@ export class LocalNodeService {
2378
2876
  return;
2379
2877
  }
2380
2878
  }
2879
+ if (meta?.peerId && record.agent_id) {
2880
+ this.privatePeerRoutes[record.agent_id] = meta.peerId;
2881
+ }
2381
2882
 
2382
2883
  this.directory = ingestPresenceRecord(this.directory, record);
2383
2884
  this.compactCacheInMemory();
@@ -2394,6 +2895,9 @@ export class LocalNodeService {
2394
2895
  await this.log("warn", `Rejected social message with invalid signature (${record.message_id.slice(0, 10)})`);
2395
2896
  return;
2396
2897
  }
2898
+ if (meta?.peerId && record.agent_id) {
2899
+ this.privatePeerRoutes[record.agent_id] = meta.peerId;
2900
+ }
2397
2901
  if (this.hasSocialMessage(record.message_id)) {
2398
2902
  await this.publishObservationForMessage(record);
2399
2903
  return;
@@ -2432,6 +2936,36 @@ export class LocalNodeService {
2432
2936
  await this.persistCache();
2433
2937
  }
2434
2938
 
2939
+ private async onDirectMessage(
2940
+ topic: "private.message" | "private.message.receipt",
2941
+ data: unknown,
2942
+ meta?: { peerId?: string }
2943
+ ): Promise<void> {
2944
+ if (topic === PRIVATE_MESSAGE_TOPIC) {
2945
+ const record = this.normalizeIncomingPrivateMessage(data);
2946
+ if (!record || !verifyPrivateMessage(record)) {
2947
+ return;
2948
+ }
2949
+ if (record.to_agent_id !== this.identity?.agent_id || this.hasPrivateMessage(record.message_id)) {
2950
+ return;
2951
+ }
2952
+ this.ingestPrivateMessage(record);
2953
+ await this.persistPrivateMessages();
2954
+ await this.sendPrivateMessageReceipt(record, meta?.peerId);
2955
+ return;
2956
+ }
2957
+
2958
+ const receipt = this.normalizeIncomingPrivateMessageReceipt(data);
2959
+ if (!receipt || !verifyPrivateMessageReceipt(receipt)) {
2960
+ return;
2961
+ }
2962
+ if (receipt.to_agent_id !== this.identity?.agent_id) {
2963
+ return;
2964
+ }
2965
+ this.ingestPrivateMessageReceipt(receipt);
2966
+ await this.persistPrivateMessageReceipts();
2967
+ }
2968
+
2435
2969
  private startBroadcastLoop(): void {
2436
2970
  if (this.broadcaster) {
2437
2971
  clearInterval(this.broadcaster);
@@ -2457,21 +2991,29 @@ export class LocalNodeService {
2457
2991
  if (this.subscriptionsBound) {
2458
2992
  return;
2459
2993
  }
2460
- this.network.subscribe("profile", (data: SignedProfileRecord) => {
2461
- this.onMessage("profile", data);
2994
+ this.network.subscribe("profile", (data: SignedProfileRecord, meta?: { peerId?: string }) => {
2995
+ this.onMessage("profile", data, meta);
2462
2996
  });
2463
- this.network.subscribe("presence", (data: PresenceRecord) => {
2464
- this.onMessage("presence", data);
2997
+ this.network.subscribe("presence", (data: PresenceRecord, meta?: { peerId?: string }) => {
2998
+ this.onMessage("presence", data, meta);
2465
2999
  });
2466
- this.network.subscribe("index", (data: IndexRefRecord) => {
2467
- this.onMessage("index", data);
3000
+ this.network.subscribe("index", (data: IndexRefRecord, meta?: { peerId?: string }) => {
3001
+ this.onMessage("index", data, meta);
2468
3002
  });
2469
- this.network.subscribe(SOCIAL_MESSAGE_TOPIC, (data: SocialMessageRecord) => {
2470
- this.onMessage(SOCIAL_MESSAGE_TOPIC, data);
3003
+ this.network.subscribe(SOCIAL_MESSAGE_TOPIC, (data: SocialMessageRecord, meta?: { peerId?: string }) => {
3004
+ this.onMessage(SOCIAL_MESSAGE_TOPIC, data, meta);
2471
3005
  });
2472
- this.network.subscribe(SOCIAL_MESSAGE_OBSERVATION_TOPIC, (data: SocialMessageObservationRecord) => {
2473
- this.onMessage(SOCIAL_MESSAGE_OBSERVATION_TOPIC, data);
3006
+ this.network.subscribe(SOCIAL_MESSAGE_OBSERVATION_TOPIC, (data: SocialMessageObservationRecord, meta?: { peerId?: string }) => {
3007
+ this.onMessage(SOCIAL_MESSAGE_OBSERVATION_TOPIC, data, meta);
2474
3008
  });
3009
+ if (typeof this.network.subscribeDirect === "function") {
3010
+ this.network.subscribeDirect(PRIVATE_MESSAGE_TOPIC, (data: PrivateMessageRecord, meta?: { peerId?: string }) => {
3011
+ this.onDirectMessage(PRIVATE_MESSAGE_TOPIC, data, meta);
3012
+ });
3013
+ this.network.subscribeDirect(PRIVATE_MESSAGE_RECEIPT_TOPIC, (data: PrivateMessageReceiptRecord, meta?: { peerId?: string }) => {
3014
+ this.onDirectMessage(PRIVATE_MESSAGE_RECEIPT_TOPIC, data, meta);
3015
+ });
3016
+ }
2475
3017
  this.subscriptionsBound = true;
2476
3018
  }
2477
3019
 
@@ -2628,9 +3170,66 @@ export class LocalNodeService {
2628
3170
  this.networkReconnectDelayMs = Math.min(30_000, Math.max(5_000, Math.floor(delayMs * 1.5)));
2629
3171
  }
2630
3172
 
3173
+ private pruneRemoteProfilesInMemory(now = Date.now()): number {
3174
+ if (!Number.isFinite(DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) || DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT <= 0) {
3175
+ return 0;
3176
+ }
3177
+ const selfAgentId = this.profile?.agent_id || this.identity?.agent_id || "";
3178
+ const remoteProfiles = Object.values(this.directory.profiles).filter((profile) => profile.agent_id !== selfAgentId);
3179
+ if (remoteProfiles.length <= DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) {
3180
+ return 0;
3181
+ }
3182
+
3183
+ const onlineRemoteProfiles = remoteProfiles.filter((profile) =>
3184
+ isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS)
3185
+ );
3186
+ const offlineRemoteProfiles = remoteProfiles
3187
+ .filter((profile) => !isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS))
3188
+ .sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
3189
+
3190
+ const keepOfflineCount = Math.max(0, DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT - onlineRemoteProfiles.length);
3191
+ const keptRemoteProfiles = [
3192
+ ...onlineRemoteProfiles,
3193
+ ...offlineRemoteProfiles.slice(0, keepOfflineCount),
3194
+ ];
3195
+ const keptRemoteIds = new Set(keptRemoteProfiles.map((profile) => profile.agent_id));
3196
+ const removedIds = remoteProfiles
3197
+ .map((profile) => profile.agent_id)
3198
+ .filter((agentId) => !keptRemoteIds.has(agentId));
3199
+ if (removedIds.length === 0) {
3200
+ return 0;
3201
+ }
3202
+
3203
+ const next = createEmptyDirectoryState();
3204
+ const selfProfile = selfAgentId ? this.directory.profiles[selfAgentId] : null;
3205
+ if (selfProfile) {
3206
+ next.profiles[selfAgentId] = selfProfile;
3207
+ const selfPresence = this.directory.presence[selfAgentId];
3208
+ if (typeof selfPresence === "number" && Number.isFinite(selfPresence)) {
3209
+ next.presence[selfAgentId] = selfPresence;
3210
+ }
3211
+ const rebuilt = rebuildIndexForProfile(next, selfProfile);
3212
+ next.index = rebuilt.index;
3213
+ }
3214
+
3215
+ for (const profile of keptRemoteProfiles) {
3216
+ next.profiles[profile.agent_id] = profile;
3217
+ const seenAt = this.directory.presence[profile.agent_id];
3218
+ if (typeof seenAt === "number" && Number.isFinite(seenAt)) {
3219
+ next.presence[profile.agent_id] = seenAt;
3220
+ }
3221
+ const rebuilt = rebuildIndexForProfile(next, profile);
3222
+ next.index = rebuilt.index;
3223
+ }
3224
+
3225
+ this.directory = dedupeIndex(next);
3226
+ return removedIds.length;
3227
+ }
3228
+
2631
3229
  private compactCacheInMemory(): number {
2632
3230
  const cleaned = cleanupExpiredPresence(this.directory, Date.now(), PRESENCE_TTL_MS);
2633
3231
  this.directory = dedupeIndex(cleaned.state);
3232
+ this.pruneRemoteProfilesInMemory();
2634
3233
  return cleaned.removed;
2635
3234
  }
2636
3235
 
@@ -2666,6 +3265,57 @@ export class LocalNodeService {
2666
3265
  await this.socialMessageObservationRepo.set(this.socialMessageObservations);
2667
3266
  }
2668
3267
 
3268
+ private async persistPrivateMessages(): Promise<void> {
3269
+ this.privateMessagesPersistDirty = true;
3270
+ if (this.privateMessagesPersistTimer) {
3271
+ return;
3272
+ }
3273
+ this.privateMessagesPersistTimer = setTimeout(() => {
3274
+ this.flushPrivateMessagesPersist().catch(() => {});
3275
+ }, PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS);
3276
+ }
3277
+
3278
+ private async persistPrivateMessageReceipts(): Promise<void> {
3279
+ this.privateMessageReceiptsPersistDirty = true;
3280
+ if (this.privateMessageReceiptsPersistTimer) {
3281
+ return;
3282
+ }
3283
+ this.privateMessageReceiptsPersistTimer = setTimeout(() => {
3284
+ this.flushPrivateMessageReceiptsPersist().catch(() => {});
3285
+ }, PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS);
3286
+ }
3287
+
3288
+ private async flushPrivatePersistence(): Promise<void> {
3289
+ await Promise.all([
3290
+ this.flushPrivateMessagesPersist(),
3291
+ this.flushPrivateMessageReceiptsPersist(),
3292
+ ]);
3293
+ }
3294
+
3295
+ private async flushPrivateMessagesPersist(): Promise<void> {
3296
+ if (this.privateMessagesPersistTimer) {
3297
+ clearTimeout(this.privateMessagesPersistTimer);
3298
+ this.privateMessagesPersistTimer = null;
3299
+ }
3300
+ if (!this.privateMessagesPersistDirty) {
3301
+ return;
3302
+ }
3303
+ this.privateMessagesPersistDirty = false;
3304
+ await this.privateMessageRepo.set(this.privateMessages);
3305
+ }
3306
+
3307
+ private async flushPrivateMessageReceiptsPersist(): Promise<void> {
3308
+ if (this.privateMessageReceiptsPersistTimer) {
3309
+ clearTimeout(this.privateMessageReceiptsPersistTimer);
3310
+ this.privateMessageReceiptsPersistTimer = null;
3311
+ }
3312
+ if (!this.privateMessageReceiptsPersistDirty) {
3313
+ return;
3314
+ }
3315
+ this.privateMessageReceiptsPersistDirty = false;
3316
+ await this.privateMessageReceiptRepo.set(this.privateMessageReceipts);
3317
+ }
3318
+
2669
3319
  private async log(level: "info" | "warn" | "error", message: string): Promise<void> {
2670
3320
  await this.logRepo.append({
2671
3321
  level,
@@ -2722,6 +3372,7 @@ export class LocalNodeService {
2722
3372
 
2723
3373
  return buildPublicProfileSummary({
2724
3374
  profile,
3375
+ is_self: isSelf,
2725
3376
  online,
2726
3377
  last_seen_at: lastSeenAt || null,
2727
3378
  network_mode: isSelf ? this.networkMode : "unknown",
@@ -2775,6 +3426,7 @@ export class LocalNodeService {
2775
3426
  updated_at: message.created_at,
2776
3427
  signature: "",
2777
3428
  },
3429
+ is_self: message.agent_id === this.identity?.agent_id,
2778
3430
  online: false,
2779
3431
  last_seen_at: null,
2780
3432
  network_mode: "unknown",
@@ -2980,6 +3632,34 @@ export class LocalNodeService {
2980
3632
  .trim();
2981
3633
  }
2982
3634
 
3635
+ private buildPrivateConversationId(leftAgentId: string, rightAgentId: string): string {
3636
+ return [String(leftAgentId || "").trim(), String(rightAgentId || "").trim()].sort().join(":");
3637
+ }
3638
+
3639
+ private decryptPrivateMessageBody(message: PrivateMessageRecord): string {
3640
+ const cached = this.privateMessageBodyCache.get(message.message_id);
3641
+ if (typeof cached === "string") {
3642
+ return cached;
3643
+ }
3644
+ if (!this.privateEncryptionKeyPair) {
3645
+ return "[encrypted]";
3646
+ }
3647
+ const decrypted = decryptPrivatePayload({
3648
+ ciphertext: message.ciphertext,
3649
+ nonce: message.nonce,
3650
+ sender_encryption_public_key: message.sender_encryption_public_key,
3651
+ recipient_private_key: this.privateEncryptionKeyPair.private_key,
3652
+ }) || "[encrypted]";
3653
+ this.privateMessageBodyCache.set(message.message_id, decrypted);
3654
+ if (this.privateMessageBodyCache.size > PRIVATE_MESSAGE_HISTORY_LIMIT * 2) {
3655
+ const firstKey = this.privateMessageBodyCache.keys().next().value;
3656
+ if (firstKey) {
3657
+ this.privateMessageBodyCache.delete(firstKey);
3658
+ }
3659
+ }
3660
+ return decrypted;
3661
+ }
3662
+
2983
3663
  private normalizeWindowTimestamps(timestamps: number[], windowMs: number, now = Date.now()): number[] {
2984
3664
  return timestamps.filter((timestamp) => now - timestamp <= windowMs);
2985
3665
  }
@@ -3005,18 +3685,32 @@ export class LocalNodeService {
3005
3685
  return this.socialMessages.some((item) => item.message_id === messageId);
3006
3686
  }
3007
3687
 
3008
- private getReplayableSelfSocialMessages(now = Date.now()): SocialMessageRecord[] {
3688
+ private getReplayableSelfSocialMessages(reason = "manual", now = Date.now()): SocialMessageRecord[] {
3009
3689
  const maxCount = Math.max(0, SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST);
3010
3690
  if (!this.identity || maxCount === 0) {
3011
3691
  return [];
3012
3692
  }
3013
- return this.socialMessages
3693
+ const replayable = this.socialMessages
3014
3694
  .filter((item) => (
3015
3695
  item.agent_id === this.identity?.agent_id &&
3016
3696
  now - item.created_at <= SOCIAL_MESSAGE_REPLAY_WINDOW_MS
3017
3697
  ))
3018
3698
  .sort((a, b) => a.created_at - b.created_at)
3019
3699
  .slice(-maxCount);
3700
+ if (!replayable.length) {
3701
+ this.lastReplayBroadcastSignature = "";
3702
+ return [];
3703
+ }
3704
+ const signature = replayable.map((item) => item.message_id).join(",");
3705
+ const isIntervalReplay = reason === "interval";
3706
+ const changedSinceLastReplay = signature !== this.lastReplayBroadcastSignature;
3707
+ const refreshDue = now - this.lastReplayBroadcastAt >= SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS;
3708
+ if (isIntervalReplay && !changedSinceLastReplay && !refreshDue) {
3709
+ return [];
3710
+ }
3711
+ this.lastReplayBroadcastSignature = signature;
3712
+ this.lastReplayBroadcastAt = now;
3713
+ return replayable;
3020
3714
  }
3021
3715
 
3022
3716
  private hasRecentDuplicateMessage(agentId: string, body: string, topic: string, now = Date.now()): boolean {
@@ -3091,6 +3785,177 @@ export class LocalNodeService {
3091
3785
  await this.persistSocialMessageObservations();
3092
3786
  }
3093
3787
 
3788
+ private async sendPrivateMessageReceipt(message: PrivateMessageRecord, replyPeerId?: string): Promise<void> {
3789
+ if (!this.identity || typeof this.network.sendDirect !== "function" || !replyPeerId) {
3790
+ return;
3791
+ }
3792
+ const receipt = signPrivateMessageReceipt({
3793
+ identity: this.identity,
3794
+ receipt_id: createHash("sha256").update(`${message.message_id}:${this.identity.agent_id}:${Date.now()}`, "utf8").digest("hex"),
3795
+ message_id: message.message_id,
3796
+ conversation_id: message.conversation_id,
3797
+ to_agent_id: message.from_agent_id,
3798
+ status: "received",
3799
+ created_at: Date.now(),
3800
+ });
3801
+ this.ingestPrivateMessageReceipt(receipt);
3802
+ await this.network.sendDirect(replyPeerId, PRIVATE_MESSAGE_RECEIPT_TOPIC, receipt);
3803
+ await this.persistPrivateMessageReceipts();
3804
+ }
3805
+
3806
+ private normalizeIncomingPrivateMessage(value: unknown): PrivateMessageRecord | null {
3807
+ if (typeof value !== "object" || value === null) {
3808
+ return null;
3809
+ }
3810
+ const record = value as Partial<PrivateMessageRecord>;
3811
+ const createdAt = Number(record.created_at || 0);
3812
+ const fromAgentId = String(record.from_agent_id || "").trim();
3813
+ const toAgentId = String(record.to_agent_id || "").trim();
3814
+ const conversationId = String(record.conversation_id || "").trim();
3815
+ if (
3816
+ record.type !== PRIVATE_MESSAGE_TOPIC ||
3817
+ !String(record.message_id || "").trim() ||
3818
+ !conversationId ||
3819
+ !fromAgentId ||
3820
+ !toAgentId ||
3821
+ !String(record.sender_public_key || "").trim() ||
3822
+ !String(record.sender_encryption_public_key || "").trim() ||
3823
+ !String(record.recipient_encryption_public_key || "").trim() ||
3824
+ !String(record.ciphertext || "").trim() ||
3825
+ !String(record.nonce || "").trim() ||
3826
+ String(record.cipher_scheme || "") !== "nacl-box-v1" ||
3827
+ !String(record.signature || "").trim() ||
3828
+ !Number.isFinite(createdAt)
3829
+ ) {
3830
+ return null;
3831
+ }
3832
+ if (fromAgentId === toAgentId) {
3833
+ return null;
3834
+ }
3835
+ if (conversationId !== this.buildPrivateConversationId(fromAgentId, toAgentId)) {
3836
+ return null;
3837
+ }
3838
+ return {
3839
+ type: PRIVATE_MESSAGE_TOPIC,
3840
+ message_id: String(record.message_id).trim(),
3841
+ conversation_id: conversationId,
3842
+ from_agent_id: fromAgentId,
3843
+ to_agent_id: toAgentId,
3844
+ sender_public_key: String(record.sender_public_key).trim(),
3845
+ sender_encryption_public_key: String(record.sender_encryption_public_key).trim(),
3846
+ recipient_encryption_public_key: String(record.recipient_encryption_public_key).trim(),
3847
+ cipher_scheme: "nacl-box-v1",
3848
+ ciphertext: String(record.ciphertext).trim(),
3849
+ nonce: String(record.nonce).trim(),
3850
+ created_at: createdAt,
3851
+ signature: String(record.signature).trim(),
3852
+ };
3853
+ }
3854
+
3855
+ private normalizePrivateMessages(items: unknown): PrivateMessageRecord[] {
3856
+ if (!Array.isArray(items)) {
3857
+ return [];
3858
+ }
3859
+ const deduped = new Set<string>();
3860
+ return items
3861
+ .map((item) => this.normalizeIncomingPrivateMessage(item))
3862
+ .filter((item): item is PrivateMessageRecord => Boolean(item))
3863
+ .sort((a, b) => a.created_at - b.created_at)
3864
+ .filter((item) => {
3865
+ if (deduped.has(item.message_id)) {
3866
+ return false;
3867
+ }
3868
+ deduped.add(item.message_id);
3869
+ return true;
3870
+ })
3871
+ .slice(-PRIVATE_MESSAGE_HISTORY_LIMIT);
3872
+ }
3873
+
3874
+ private normalizeIncomingPrivateMessageReceipt(value: unknown): PrivateMessageReceiptRecord | null {
3875
+ if (typeof value !== "object" || value === null) {
3876
+ return null;
3877
+ }
3878
+ const record = value as Partial<PrivateMessageReceiptRecord>;
3879
+ const createdAt = Number(record.created_at || 0);
3880
+ const status = String(record.status || "").trim();
3881
+ if (
3882
+ record.type !== PRIVATE_MESSAGE_RECEIPT_TOPIC ||
3883
+ !String(record.receipt_id || "").trim() ||
3884
+ !String(record.message_id || "").trim() ||
3885
+ !String(record.conversation_id || "").trim() ||
3886
+ !String(record.from_agent_id || "").trim() ||
3887
+ !String(record.to_agent_id || "").trim() ||
3888
+ !String(record.sender_public_key || "").trim() ||
3889
+ (status !== "received" && status !== "read") ||
3890
+ !String(record.signature || "").trim() ||
3891
+ !Number.isFinite(createdAt)
3892
+ ) {
3893
+ return null;
3894
+ }
3895
+ return {
3896
+ type: PRIVATE_MESSAGE_RECEIPT_TOPIC,
3897
+ receipt_id: String(record.receipt_id).trim(),
3898
+ message_id: String(record.message_id).trim(),
3899
+ conversation_id: String(record.conversation_id).trim(),
3900
+ from_agent_id: String(record.from_agent_id).trim(),
3901
+ to_agent_id: String(record.to_agent_id).trim(),
3902
+ sender_public_key: String(record.sender_public_key).trim(),
3903
+ status: status as "received" | "read",
3904
+ created_at: createdAt,
3905
+ signature: String(record.signature).trim(),
3906
+ };
3907
+ }
3908
+
3909
+ private normalizePrivateMessageReceipts(items: unknown): PrivateMessageReceiptRecord[] {
3910
+ if (!Array.isArray(items)) {
3911
+ return [];
3912
+ }
3913
+ const deduped = new Set<string>();
3914
+ return items
3915
+ .map((item) => this.normalizeIncomingPrivateMessageReceipt(item))
3916
+ .filter((item): item is PrivateMessageReceiptRecord => Boolean(item))
3917
+ .sort((a, b) => a.created_at - b.created_at)
3918
+ .filter((item) => {
3919
+ if (deduped.has(item.receipt_id)) {
3920
+ return false;
3921
+ }
3922
+ deduped.add(item.receipt_id);
3923
+ return true;
3924
+ })
3925
+ .slice(-PRIVATE_MESSAGE_RECEIPT_HISTORY_LIMIT);
3926
+ }
3927
+
3928
+ private hasPrivateMessage(messageId: string): boolean {
3929
+ return this.privateMessages.some((item) => item.message_id === messageId);
3930
+ }
3931
+
3932
+ private ingestPrivateMessage(message: PrivateMessageRecord): void {
3933
+ const existing = this.privateMessages.findIndex((item) => item.message_id === message.message_id);
3934
+ if (existing >= 0) {
3935
+ this.privateMessages[existing] = message;
3936
+ } else {
3937
+ this.privateMessages.push(message);
3938
+ }
3939
+ this.privateMessages = this.normalizePrivateMessages(this.privateMessages);
3940
+ const validIds = new Set(this.privateMessages.map((item) => item.message_id));
3941
+ this.privateMessageBodyCache.delete(message.message_id);
3942
+ for (const key of Array.from(this.privateMessageBodyCache.keys())) {
3943
+ if (!validIds.has(key)) {
3944
+ this.privateMessageBodyCache.delete(key);
3945
+ }
3946
+ }
3947
+ }
3948
+
3949
+ private ingestPrivateMessageReceipt(receipt: PrivateMessageReceiptRecord): void {
3950
+ const existing = this.privateMessageReceipts.findIndex((item) => item.receipt_id === receipt.receipt_id);
3951
+ if (existing >= 0) {
3952
+ this.privateMessageReceipts[existing] = receipt;
3953
+ } else {
3954
+ this.privateMessageReceipts.push(receipt);
3955
+ }
3956
+ this.privateMessageReceipts = this.normalizePrivateMessageReceipts(this.privateMessageReceipts);
3957
+ }
3958
+
3094
3959
  private normalizeIncomingSocialMessage(value: unknown): SocialMessageRecord | null {
3095
3960
  if (typeof value !== "object" || value === null) {
3096
3961
  return null;
@@ -3369,6 +4234,48 @@ export async function main() {
3369
4234
  sendOk(res, node.getRuntimePaths());
3370
4235
  });
3371
4236
 
4237
+ app.get("/api/app/update-status", (_req, res) => {
4238
+ sendOk(res, node.getAppUpdateStatus());
4239
+ });
4240
+
4241
+ app.post(
4242
+ "/api/app/update",
4243
+ asyncRoute(async (_req, res) => {
4244
+ const status = node.getAppUpdateStatus();
4245
+ if (!status.update_available || !status.latest_version) {
4246
+ sendOk(
4247
+ res,
4248
+ {
4249
+ started: false,
4250
+ current_version: status.current_version,
4251
+ latest_version: status.latest_version,
4252
+ platform: status.platform,
4253
+ reason: status.check_error || "already_current",
4254
+ },
4255
+ { message: "Already on the latest version" }
4256
+ );
4257
+ return;
4258
+ }
4259
+ sendOk(
4260
+ res,
4261
+ {
4262
+ started: true,
4263
+ current_version: status.current_version,
4264
+ target_version: status.latest_version,
4265
+ platform: status.platform,
4266
+ },
4267
+ { message: `Updating to ${status.latest_version}` }
4268
+ );
4269
+ setTimeout(() => {
4270
+ try {
4271
+ node.startAppUpdate();
4272
+ } catch {
4273
+ // best effort after response has been sent
4274
+ }
4275
+ }, 150);
4276
+ })
4277
+ );
4278
+
3372
4279
  app.put(
3373
4280
  "/api/profile",
3374
4281
  asyncRoute(async (req, res) => {
@@ -3511,6 +4418,34 @@ export async function main() {
3511
4418
  sendOk(res, node.getSocialMessages(limit, { agent_id: agentId || null }));
3512
4419
  });
3513
4420
 
4421
+ app.get("/api/private/state", (_req, res) => {
4422
+ sendOk(res, node.getPrivateMessagingState());
4423
+ });
4424
+
4425
+ app.get("/api/private/conversations", (_req, res) => {
4426
+ sendOk(res, node.getPrivateConversations());
4427
+ });
4428
+
4429
+ app.get("/api/private/messages", (req, res) => {
4430
+ const conversationId = String(req.query.conversation_id ?? "").trim();
4431
+ const limit = Number(req.query.limit ?? PRIVATE_MESSAGE_QUERY_LIMIT);
4432
+ sendOk(res, node.getPrivateMessages(conversationId, limit));
4433
+ });
4434
+
4435
+ app.post(
4436
+ "/api/private/messages/send",
4437
+ asyncRoute(async (req, res) => {
4438
+ const result = await node.sendPrivateMessage({
4439
+ to_agent_id: String(req.body?.to_agent_id || ""),
4440
+ recipient_encryption_public_key: String(req.body?.recipient_encryption_public_key || ""),
4441
+ body: String(req.body?.body || ""),
4442
+ });
4443
+ sendOk(res, result, {
4444
+ message: result.sent ? "Private message sent" : `Private message skipped: ${result.reason}`,
4445
+ });
4446
+ })
4447
+ );
4448
+
3514
4449
  app.get("/api/openclaw/bridge", (_req, res) => {
3515
4450
  sendOk(res, node.getOpenClawBridgeStatus());
3516
4451
  });