@silicaclaw/cli 2026.3.20-2 → 2026.3.20-21

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 +108 -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 +1029 -92
  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 +502 -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 +438 -316
  30. package/apps/local-console/src/server.ts +1177 -90
  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,7 +223,23 @@ 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 {
235
+ const envAppRoot = String(process.env.SILICACLAW_APP_DIR || "").trim();
236
+ if (
237
+ envAppRoot &&
238
+ existsSync(resolve(envAppRoot, "apps", "local-console", "package.json")) &&
239
+ existsSync(resolve(envAppRoot, "package.json"))
240
+ ) {
241
+ return resolve(envAppRoot);
242
+ }
190
243
  if (existsSync(resolve(cwd, "apps", "local-console", "package.json"))) {
191
244
  return cwd;
192
245
  }
@@ -441,45 +494,66 @@ function readOpenClawConfiguredGateway(workspaceRoot: string) {
441
494
  } as const;
442
495
  }
443
496
 
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);
497
+ function resolveOpenClawStatusCommand(workspaceRoot: string) {
498
+ const explicitBin = String(process.env.OPENCLAW_BIN || "").trim();
499
+ if (explicitBin) {
500
+ return { cmd: explicitBin, args: ["status"] } as const;
501
+ }
454
502
 
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));
503
+ const configuredSourceDir = String(process.env.OPENCLAW_SOURCE_DIR || "").trim();
504
+ const defaultSourceDir = defaultOpenClawSourceDir(workspaceRoot);
505
+ const sourceDir = configuredSourceDir || defaultSourceDir;
506
+ const sourceEntry = existingPathOrNull(resolve(sourceDir, "openclaw.mjs"));
507
+ if (sourceEntry) {
508
+ return { cmd: process.execPath, args: [sourceEntry, "status"] } as const;
509
+ }
510
+
511
+ const commandPath = resolveExecutableInPath("openclaw");
512
+ if (commandPath) {
513
+ return { cmd: commandPath, args: ["status"] } as const;
514
+ }
477
515
 
478
- const openclawPids = new Set(processes.map((item) => item.pid));
479
- const gatewayProbe = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], {
516
+ return null;
517
+ }
518
+
519
+ function resolveOpenClawGatewayProbeCommand(workspaceRoot: string) {
520
+ const explicitBin = String(process.env.OPENCLAW_BIN || "").trim();
521
+ if (explicitBin) {
522
+ return { cmd: explicitBin, args: ["gateway", "probe"] } as const;
523
+ }
524
+
525
+ const configuredSourceDir = String(process.env.OPENCLAW_SOURCE_DIR || "").trim();
526
+ const defaultSourceDir = defaultOpenClawSourceDir(workspaceRoot);
527
+ const sourceDir = configuredSourceDir || defaultSourceDir;
528
+ const sourceEntry = existingPathOrNull(resolve(sourceDir, "openclaw.mjs"));
529
+ if (sourceEntry) {
530
+ return { cmd: process.execPath, args: [sourceEntry, "gateway", "probe"] } as const;
531
+ }
532
+
533
+ const commandPath = resolveExecutableInPath("openclaw");
534
+ if (commandPath) {
535
+ return { cmd: commandPath, args: ["gateway", "probe"] } as const;
536
+ }
537
+
538
+ return null;
539
+ }
540
+
541
+ function detectOpenClawRuntime(workspaceRoot: string) {
542
+ const configuredGateway = readOpenClawConfiguredGateway(workspaceRoot);
543
+ const statusCommand = resolveOpenClawStatusCommand(workspaceRoot);
544
+ const statusLooksConfigured = Boolean(
545
+ statusCommand ||
546
+ configuredGateway.config_path ||
547
+ detectOpenClawInstallation(workspaceRoot).detected
548
+ );
549
+ const gatewayProbeCommand = ["lsof", "-nP", `-iTCP:${configuredGateway.gateway_port}`, "-sTCP:LISTEN"];
550
+ const gatewayProbe = spawnSync(gatewayProbeCommand[0], gatewayProbeCommand.slice(1), {
480
551
  encoding: "utf8",
552
+ timeout: 1200,
481
553
  });
482
- const gatewayLines = String(gatewayProbe.stdout || "")
554
+ const gatewayStatusStdout = String(gatewayProbe.stdout || "");
555
+ const gatewayStatusStderr = String(gatewayProbe.stderr || "");
556
+ const gatewayLines = gatewayStatusStdout
483
557
  .split("\n")
484
558
  .map((line) => line.trim())
485
559
  .filter(Boolean);
@@ -489,14 +563,9 @@ function detectOpenClawRuntime(workspaceRoot: string) {
489
563
  const parts = line.split(/\s+/);
490
564
  const pid = Number(parts[1] || 0);
491
565
  const command = parts[0] || "";
492
- const lowerCommand = command.toLowerCase();
493
566
  const endpoint = parts[8] || parts[parts.length - 1] || "";
494
567
  const portMatch = endpoint.match(/:(\d+)(?:\s*\(|$)/);
495
568
  if (!pid || !command || !portMatch) return null;
496
- const isOpenClawListener =
497
- openclawPids.has(pid) ||
498
- lowerCommand.includes("openclaw");
499
- if (!isOpenClawListener) return null;
500
569
  const port = Number(portMatch[1]);
501
570
  if (!Number.isFinite(port) || port <= 0) return null;
502
571
  return {
@@ -507,46 +576,106 @@ function detectOpenClawRuntime(workspaceRoot: string) {
507
576
  };
508
577
  })
509
578
  .filter((item): item is { pid: number; ppid: number; port: number; command: string } => Boolean(item));
579
+ const gatewayProbeOk = gatewayListeners.length > 0;
580
+ let processes: Array<{ pid: number; ppid: number; command: string }> = gatewayListeners.map((item) => ({
581
+ pid: item.pid,
582
+ ppid: item.ppid,
583
+ command: item.command,
584
+ }));
585
+ let processResult: ReturnType<typeof spawnSync> | null = null;
586
+ if (!gatewayProbeOk) {
587
+ processResult = spawnSync("ps", ["-Ao", "pid=,ppid=,command="], {
588
+ encoding: "utf8",
589
+ timeout: 1200,
590
+ });
591
+ const stdout = String(processResult.stdout || "");
592
+ const lines = stdout
593
+ .split("\n")
594
+ .map((line) => line.trim())
595
+ .filter(Boolean);
596
+ processes = lines
597
+ .map((line) => {
598
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
599
+ if (!match) return null;
600
+ const command = match[3] || "";
601
+ const lower = command.toLowerCase();
602
+ const isOpenClaw =
603
+ lower.includes(" openclaw ") ||
604
+ lower.endsWith(" openclaw") ||
605
+ lower.includes("/openclaw ") ||
606
+ lower.includes("openclaw.mjs") ||
607
+ lower.includes("openclaw gateway") ||
608
+ lower.includes("openclaw agent") ||
609
+ lower.includes("openclaw message");
610
+ if (!isOpenClaw) return null;
611
+ return {
612
+ pid: Number(match[1]),
613
+ ppid: Number(match[2]),
614
+ command,
615
+ };
616
+ })
617
+ .filter((item): item is { pid: number; ppid: number; command: string } => Boolean(item));
618
+ }
619
+
510
620
  const preferredListener =
511
621
  gatewayListeners.find((item) => item.port === configuredGateway.gateway_port) ||
512
622
  gatewayListeners[0] ||
513
623
  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;
624
+ const allProcesses = processes.slice(0, 10);
625
+ const gatewayReachable = gatewayProbeOk;
528
626
  const detectionNotes = [];
529
- if (result.status !== 0) detectionNotes.push(String(result.stderr || "ps failed").trim());
530
627
  if (gatewayProbe.status !== 0 && gatewayLines.length === 0) {
531
- detectionNotes.push(String(gatewayProbe.stderr || "lsof failed").trim());
628
+ detectionNotes.push(String(gatewayStatusStderr || "openclaw gateway probe failed").trim());
629
+ }
630
+ if (processResult && processResult.status !== 0) {
631
+ detectionNotes.push(String(processResult.stderr || "ps failed").trim());
532
632
  }
533
633
  const gatewayPort = preferredListener?.port || configuredGateway.gateway_port;
534
634
  const gatewayUrl = `http://${OPENCLAW_GATEWAY_HOST}:${gatewayPort}/`;
535
635
 
536
636
  return {
537
- running: allProcesses.length > 0 || gatewayReachable,
637
+ running: gatewayProbeOk || allProcesses.length > 0 || gatewayReachable,
538
638
  process_count: allProcesses.length,
539
639
  processes: allProcesses.slice(0, 10),
540
640
  detection_error: detectionNotes.filter(Boolean).join(" | ") || null,
541
641
  gateway_url: gatewayUrl,
542
642
  gateway_port: gatewayPort,
543
643
  gateway_reachable: gatewayReachable,
644
+ status_command: statusCommand ? [statusCommand.cmd, ...statusCommand.args].join(" ") : null,
645
+ status_ok: statusLooksConfigured,
646
+ status_summary: statusLooksConfigured
647
+ ? configuredGateway.config_path
648
+ ? `configured via ${configuredGateway.config_path}`
649
+ : statusCommand
650
+ ? `command available: ${[statusCommand.cmd, ...statusCommand.args].join(" ")}`
651
+ : "OpenClaw environment detected"
652
+ : null,
653
+ gateway_probe_command: gatewayProbeCommand.join(" "),
654
+ gateway_probe_ok: gatewayProbeOk,
655
+ gateway_probe_summary: gatewayProbeOk
656
+ ? gatewayStatusStdout
657
+ .split("\n")
658
+ .map((line) => line.trim())
659
+ .filter(Boolean)
660
+ .slice(0, 4)
661
+ .join(" | ")
662
+ : null,
544
663
  configured_gateway_url: configuredGateway.gateway_url,
545
664
  configured_gateway_port: configuredGateway.gateway_port,
546
665
  configured_gateway_bind: configuredGateway.gateway_bind,
547
666
  configured_gateway_config_path: configuredGateway.config_path,
548
667
  detection_mode:
549
- processes.length > 0 && gatewayReachable
668
+ gatewayProbeOk
669
+ ? (
670
+ processes.length > 0 && gatewayReachable
671
+ ? "gateway-probe+process+gateway"
672
+ : gatewayReachable
673
+ ? "gateway-probe+gateway"
674
+ : processes.length > 0
675
+ ? "gateway-probe+process"
676
+ : "gateway-probe"
677
+ )
678
+ : processes.length > 0 && gatewayReachable
550
679
  ? "process+gateway"
551
680
  : gatewayReachable
552
681
  ? "gateway"
@@ -766,6 +895,7 @@ type IntegrationStatusSummary = {
766
895
  };
767
896
 
768
897
  type SocialMessageView = SocialMessageRecord & {
898
+ avatar_url?: string;
769
899
  is_self: boolean;
770
900
  online: boolean;
771
901
  last_seen_at: number | null;
@@ -775,6 +905,17 @@ type SocialMessageView = SocialMessageRecord & {
775
905
  delivery_status: "local-only" | "remote-observed";
776
906
  };
777
907
 
908
+ type PrivateMessageView = {
909
+ message_id: string;
910
+ conversation_id: string;
911
+ from_agent_id: string;
912
+ to_agent_id: string;
913
+ body: string;
914
+ created_at: number;
915
+ is_self: boolean;
916
+ delivery_status: "sent" | "direct-sent" | "fallback-sent" | "received" | "read";
917
+ };
918
+
778
919
  type RuntimeMessageGovernance = SocialMessageGovernanceConfig;
779
920
 
780
921
  type OpenClawBridgeStatus = {
@@ -818,11 +959,17 @@ type OpenClawBridgeStatus = {
818
959
  gateway_url: string;
819
960
  gateway_port: number;
820
961
  gateway_reachable: boolean;
962
+ status_command: string | null;
963
+ status_ok: boolean;
964
+ status_summary: string | null;
965
+ gateway_probe_command: string | null;
966
+ gateway_probe_ok: boolean;
967
+ gateway_probe_summary: string | null;
821
968
  configured_gateway_url: string;
822
969
  configured_gateway_port: number;
823
970
  configured_gateway_bind: string | null;
824
971
  configured_gateway_config_path: string | null;
825
- detection_mode: "process" | "gateway" | "process+gateway" | "not_running";
972
+ detection_mode: "gateway-probe" | "gateway-probe+process" | "gateway-probe+gateway" | "gateway-probe+process+gateway" | "process" | "gateway" | "process+gateway" | "not_running";
826
973
  };
827
974
  skill_learning: {
828
975
  available: boolean;
@@ -899,6 +1046,10 @@ export class LocalNodeService {
899
1046
  private socialMessageGovernanceRepo: SocialMessageGovernanceRepo;
900
1047
  private socialMessageRepo: SocialMessageRepo;
901
1048
  private socialMessageObservationRepo: SocialMessageObservationRepo;
1049
+ private privateMessageRepo: PrivateMessageRepo;
1050
+ private privateMessageReceiptRepo: PrivateMessageReceiptRepo;
1051
+ private privateEncryptionKeyRepo: PrivateEncryptionKeyRepo;
1052
+ private privateMessagingRuntimeRepo: PrivateMessagingRuntimeRepo;
902
1053
  private socialRuntimeRepo: SocialRuntimeRepo;
903
1054
 
904
1055
  private identity: AgentIdentity | null = null;
@@ -906,15 +1057,34 @@ export class LocalNodeService {
906
1057
  private directory: DirectoryState = createEmptyDirectoryState();
907
1058
  private socialMessages: SocialMessageRecord[] = [];
908
1059
  private socialMessageObservations: SocialMessageObservationRecord[] = [];
1060
+ private privateMessages: PrivateMessageRecord[] = [];
1061
+ private privateMessageReceipts: PrivateMessageReceiptRecord[] = [];
1062
+ private privateEncryptionKeyPair: PrivateEncryptionKeyPair | null = null;
1063
+ private privateMessagingRuntime: PrivateMessagingRuntimeState | null = null;
1064
+ private privatePeerRoutes: Record<string, string> = {};
1065
+ private privatePeerEncryptionKeys: Record<string, string> = {};
1066
+ private privateMessageBodyCache = new Map<string, string>();
1067
+ private privateMessageDeliveryStatusCache = new Map<string, PrivateMessageView["delivery_status"]>();
909
1068
  private messageGovernance: RuntimeMessageGovernance;
1069
+ private privateMessagesPersistDirty = false;
1070
+ private privateMessageReceiptsPersistDirty = false;
1071
+ private privateMessagesPersistTimer: NodeJS.Timeout | null = null;
1072
+ private privateMessageReceiptsPersistTimer: NodeJS.Timeout | null = null;
910
1073
 
911
1074
  private receivedCount = 0;
912
1075
  private broadcastCount = 0;
913
1076
  private lastMessageAt = 0;
914
1077
  private lastBroadcastAt = 0;
1078
+ private lastProfileBroadcastAt = 0;
1079
+ private lastProfileBroadcastSignature = "";
1080
+ private lastReplayBroadcastAt = 0;
1081
+ private lastReplayBroadcastSignature = "";
915
1082
  private lastBroadcastErrorAt = 0;
916
1083
  private lastBroadcastError: string | null = null;
917
1084
  private broadcastFailureCount = 0;
1085
+ private consecutiveBroadcastFailures = 0;
1086
+ private lastBroadcastRecoveryAttemptAt = 0;
1087
+ private broadcastRecoveryInFlight = false;
918
1088
  private broadcaster: NodeJS.Timeout | null = null;
919
1089
  private subscriptionsBound = false;
920
1090
  private broadcastEnabled = true;
@@ -956,6 +1126,8 @@ export class LocalNodeService {
956
1126
  private networkReconnectTimer: NodeJS.Timeout | null = null;
957
1127
  private networkReconnectDelayMs = 5_000;
958
1128
  private appVersion = "unknown";
1129
+ private openclawRuntimeCache: { value: ReturnType<typeof detectOpenClawRuntime>; expiresAt: number } | null = null;
1130
+ private openclawBridgeStatusCache: { value: OpenClawBridgeStatus; expiresAt: number } | null = null;
959
1131
 
960
1132
  constructor(options?: { workspaceRoot?: string; projectRoot?: string; storageRoot?: string }) {
961
1133
  this.workspaceRoot = options?.workspaceRoot || resolveWorkspaceRoot();
@@ -971,6 +1143,10 @@ export class LocalNodeService {
971
1143
  this.socialMessageGovernanceRepo = new SocialMessageGovernanceRepo(this.storageRoot);
972
1144
  this.socialMessageRepo = new SocialMessageRepo(this.storageRoot);
973
1145
  this.socialMessageObservationRepo = new SocialMessageObservationRepo(this.storageRoot);
1146
+ this.privateMessageRepo = new PrivateMessageRepo(this.storageRoot);
1147
+ this.privateMessageReceiptRepo = new PrivateMessageReceiptRepo(this.storageRoot);
1148
+ this.privateEncryptionKeyRepo = new PrivateEncryptionKeyRepo(this.storageRoot);
1149
+ this.privateMessagingRuntimeRepo = new PrivateMessagingRuntimeRepo(this.storageRoot);
974
1150
  this.socialRuntimeRepo = new SocialRuntimeRepo(this.storageRoot);
975
1151
  this.messageGovernance = this.defaultMessageGovernance();
976
1152
 
@@ -1001,6 +1177,24 @@ export class LocalNodeService {
1001
1177
  this.networkPort = resolved.port;
1002
1178
  }
1003
1179
 
1180
+ private getCachedOpenClawRuntime() {
1181
+ const now = Date.now();
1182
+ if (this.openclawRuntimeCache && this.openclawRuntimeCache.expiresAt > now) {
1183
+ return this.openclawRuntimeCache.value;
1184
+ }
1185
+ const value = detectOpenClawRuntime(this.projectRoot);
1186
+ this.openclawRuntimeCache = {
1187
+ value,
1188
+ expiresAt: now + OPENCLAW_RUNTIME_CACHE_MS,
1189
+ };
1190
+ return value;
1191
+ }
1192
+
1193
+ private invalidateOpenClawCaches() {
1194
+ this.openclawRuntimeCache = null;
1195
+ this.openclawBridgeStatusCache = null;
1196
+ }
1197
+
1004
1198
  async start(): Promise<void> {
1005
1199
  await this.hydrateFromDisk();
1006
1200
 
@@ -1014,6 +1208,7 @@ export class LocalNodeService {
1014
1208
  clearInterval(this.broadcaster);
1015
1209
  this.broadcaster = null;
1016
1210
  }
1211
+ await this.flushPrivatePersistence();
1017
1212
  if (this.networkStarted) {
1018
1213
  await this.network.stop();
1019
1214
  }
@@ -1034,6 +1229,9 @@ export class LocalNodeService {
1034
1229
  getOverview() {
1035
1230
  const discovered = this.search("");
1036
1231
  const onlineCount = discovered.filter((profile) => profile.online).length;
1232
+ const openclawInstallation = detectOpenClawInstallation(this.projectRoot);
1233
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1234
+ const openclawSkillInstallation = detectOpenClawSkillInstallation();
1037
1235
 
1038
1236
  return {
1039
1237
  app_version: this.appVersion,
@@ -1050,6 +1248,15 @@ export class LocalNodeService {
1050
1248
  init_state: this.initState,
1051
1249
  presence_ttl_ms: PRESENCE_TTL_MS,
1052
1250
  onboarding: this.getOnboardingSummary(),
1251
+ openclaw: {
1252
+ detected: openclawInstallation.detected,
1253
+ running: openclawRuntime.running,
1254
+ detection_mode: openclawRuntime.detection_mode,
1255
+ gateway_url: openclawRuntime.gateway_url,
1256
+ gateway_probe_ok: openclawRuntime.gateway_probe_ok,
1257
+ status_ok: openclawRuntime.status_ok,
1258
+ skill_installed: openclawSkillInstallation.installed,
1259
+ },
1053
1260
  social: {
1054
1261
  found: this.socialFound,
1055
1262
  enabled: this.socialConfig.enabled,
@@ -1197,6 +1404,7 @@ export class LocalNodeService {
1197
1404
  const relayCapable = this.adapterMode === "webrtc-preview" || this.adapterMode === "relay-preview";
1198
1405
  const peers: Array<{ status?: string }> = diagnostics?.peers?.items ?? [];
1199
1406
  const online = peers.filter((peer: { status?: string }) => peer.status === "online").length;
1407
+ const memory = process.memoryUsage();
1200
1408
 
1201
1409
  return {
1202
1410
  adapter: this.adapterMode,
@@ -1221,6 +1429,23 @@ export class LocalNodeService {
1221
1429
  adapter_stats: diagnostics?.stats ?? null,
1222
1430
  adapter_transport_stats: diagnostics?.transport_stats ?? null,
1223
1431
  adapter_discovery_stats: diagnostics?.discovery_stats ?? null,
1432
+ runtime_diagnostics: {
1433
+ memory_mib: {
1434
+ rss: formatBytesToMiB(memory.rss),
1435
+ heap_used: formatBytesToMiB(memory.heapUsed),
1436
+ heap_total: formatBytesToMiB(memory.heapTotal),
1437
+ external: formatBytesToMiB(memory.external),
1438
+ },
1439
+ directory: {
1440
+ profile_count: Object.keys(this.directory.profiles).length,
1441
+ presence_count: Object.keys(this.directory.presence).length,
1442
+ index_key_count: Object.keys(this.directory.index).length,
1443
+ },
1444
+ social: {
1445
+ message_count: this.socialMessages.length,
1446
+ observation_count: this.socialMessageObservations.length,
1447
+ },
1448
+ },
1224
1449
  adapter_diagnostics_summary: relayCapable || diagnostics
1225
1450
  ? {
1226
1451
  started: this.networkStarted,
@@ -1337,6 +1562,92 @@ export class LocalNodeService {
1337
1562
  };
1338
1563
  }
1339
1564
 
1565
+ getAppUpdateStatus() {
1566
+ const currentVersion = normalizeVersionText(this.appVersion) || "unknown";
1567
+ const fallback = {
1568
+ current_version: currentVersion,
1569
+ latest_version: currentVersion,
1570
+ update_available: false,
1571
+ channel: "latest",
1572
+ platform: process.platform,
1573
+ checked_at: Date.now(),
1574
+ can_update: true,
1575
+ check_error: null as string | null,
1576
+ };
1577
+ try {
1578
+ const result = spawnSync("npm", ["view", "@silicaclaw/cli", "dist-tags", "--json"], {
1579
+ cwd: this.projectRoot,
1580
+ encoding: "utf8",
1581
+ env: {
1582
+ ...process.env,
1583
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1584
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1585
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1586
+ },
1587
+ });
1588
+ if ((result.status ?? 1) !== 0) {
1589
+ return {
1590
+ ...fallback,
1591
+ check_error: String(result.stderr || result.stdout || "npm view failed").trim() || "npm view failed",
1592
+ };
1593
+ }
1594
+ const tags = JSON.parse(String(result.stdout || "{}").trim() || "{}") as { latest?: string };
1595
+ const latestVersion = normalizeVersionText(tags.latest || currentVersion) || currentVersion;
1596
+ return {
1597
+ ...fallback,
1598
+ latest_version: latestVersion,
1599
+ update_available: compareVersionTokens(latestVersion, currentVersion) > 0,
1600
+ };
1601
+ } catch (error) {
1602
+ return {
1603
+ ...fallback,
1604
+ check_error: error instanceof Error ? error.message : String(error),
1605
+ };
1606
+ }
1607
+ }
1608
+
1609
+ startAppUpdate(): { started: boolean; target_version: string; platform: string; reason?: string } {
1610
+ const status = this.getAppUpdateStatus();
1611
+ if (!status.update_available || !status.latest_version) {
1612
+ return {
1613
+ started: false,
1614
+ target_version: status.latest_version || status.current_version,
1615
+ platform: process.platform,
1616
+ reason: status.check_error || "already_current",
1617
+ };
1618
+ }
1619
+ const shimPath = userShimPath();
1620
+ const scriptPath = resolve(this.workspaceRoot, "scripts", "silicaclaw-cli.mjs");
1621
+ const useShim = existsSync(shimPath);
1622
+ if (!useShim && !existsSync(scriptPath)) {
1623
+ return {
1624
+ started: false,
1625
+ target_version: status.latest_version,
1626
+ platform: process.platform,
1627
+ reason: "missing_cli_script",
1628
+ };
1629
+ }
1630
+ const command = useShim ? shimPath : process.execPath;
1631
+ const args = useShim ? ["update"] : [scriptPath, "update"];
1632
+ const child = spawn(command, args, {
1633
+ cwd: this.projectRoot,
1634
+ detached: true,
1635
+ stdio: "ignore",
1636
+ env: {
1637
+ ...process.env,
1638
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1639
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1640
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1641
+ },
1642
+ });
1643
+ child.unref();
1644
+ return {
1645
+ started: true,
1646
+ target_version: status.latest_version,
1647
+ platform: process.platform,
1648
+ };
1649
+ }
1650
+
1340
1651
  getIntegrationSummary() {
1341
1652
  const status = this.getIntegrationStatus();
1342
1653
  const runtimeGenerated = Boolean(this.socialRuntime && this.socialRuntime.last_loaded_at > 0);
@@ -1635,6 +1946,7 @@ export class LocalNodeService {
1635
1946
  return {
1636
1947
  ...message,
1637
1948
  display_name: profile?.display_name || message.display_name || "Unnamed",
1949
+ avatar_url: profile?.avatar_url || "",
1638
1950
  is_self: message.agent_id === this.identity?.agent_id,
1639
1951
  online: isAgentOnline(lastSeenAt, Date.now(), PRESENCE_TTL_MS),
1640
1952
  last_seen_at: lastSeenAt || null,
@@ -1654,10 +1966,161 @@ export class LocalNodeService {
1654
1966
  };
1655
1967
  }
1656
1968
 
1969
+ getPrivateMessagingState() {
1970
+ return {
1971
+ enabled: Boolean(this.identity && this.privateEncryptionKeyPair),
1972
+ agent_id: this.identity?.agent_id || "",
1973
+ encryption_public_key: this.privateEncryptionKeyPair?.public_key || "",
1974
+ conversation_count: this.getPrivateConversations().length,
1975
+ message_count: this.privateMessages.length,
1976
+ runtime: this.privateMessagingRuntime,
1977
+ };
1978
+ }
1979
+
1980
+ getPrivateConversations(): Array<{
1981
+ conversation_id: string;
1982
+ peer_agent_id: string;
1983
+ peer_display_name: string;
1984
+ peer_avatar_url: string;
1985
+ peer_public_key: string;
1986
+ last_message_at: number | null;
1987
+ unread_count: number;
1988
+ }> {
1989
+ const conversations = new Map<string, {
1990
+ conversation_id: string;
1991
+ peer_agent_id: string;
1992
+ peer_display_name: string;
1993
+ peer_avatar_url: string;
1994
+ peer_public_key: string;
1995
+ last_message_at: number | null;
1996
+ unread_count: number;
1997
+ }>();
1998
+ for (const message of this.privateMessages) {
1999
+ if (message.from_agent_id === message.to_agent_id) {
2000
+ continue;
2001
+ }
2002
+ const peerAgentId = message.from_agent_id === this.identity?.agent_id ? message.to_agent_id : message.from_agent_id;
2003
+ if (!peerAgentId || peerAgentId === this.identity?.agent_id) {
2004
+ continue;
2005
+ }
2006
+ const peerProfile = this.directory.profiles[peerAgentId];
2007
+ const current = conversations.get(message.conversation_id);
2008
+ const nextLast = Math.max(current?.last_message_at || 0, message.created_at || 0) || null;
2009
+ const learnedPeerKey = this.privatePeerEncryptionKeys[peerAgentId] || "";
2010
+ conversations.set(message.conversation_id, {
2011
+ conversation_id: message.conversation_id,
2012
+ peer_agent_id: peerAgentId,
2013
+ peer_display_name: peerProfile?.display_name || peerAgentId,
2014
+ peer_avatar_url: peerProfile?.avatar_url || "",
2015
+ peer_public_key: learnedPeerKey || peerProfile?.private_encryption_public_key || "",
2016
+ last_message_at: nextLast,
2017
+ unread_count: current?.unread_count || 0,
2018
+ });
2019
+ }
2020
+ return Array.from(conversations.values()).sort((a, b) => (b.last_message_at || 0) - (a.last_message_at || 0));
2021
+ }
2022
+
2023
+ getPrivateMessages(conversationId: string, limit = PRIVATE_MESSAGE_QUERY_LIMIT): PrivateMessageView[] {
2024
+ const normalizedConversationId = String(conversationId || "").trim();
2025
+ const resolvedLimit = Math.max(1, Math.min(PRIVATE_MESSAGE_QUERY_LIMIT, Number(limit) || PRIVATE_MESSAGE_QUERY_LIMIT));
2026
+ const receiptsByMessageId = new Map(
2027
+ this.privateMessageReceipts.map((receipt) => [receipt.message_id, receipt.status] as const)
2028
+ );
2029
+ return this.privateMessages
2030
+ .filter((message) => {
2031
+ if (message.from_agent_id === message.to_agent_id) {
2032
+ return false;
2033
+ }
2034
+ const peerAgentId = message.from_agent_id === this.identity?.agent_id ? message.to_agent_id : message.from_agent_id;
2035
+ if (!peerAgentId || peerAgentId === this.identity?.agent_id) {
2036
+ return false;
2037
+ }
2038
+ return !normalizedConversationId || message.conversation_id === normalizedConversationId;
2039
+ })
2040
+ .sort((a, b) => b.created_at - a.created_at)
2041
+ .slice(0, resolvedLimit)
2042
+ .map((message) => ({
2043
+ message_id: message.message_id,
2044
+ conversation_id: message.conversation_id,
2045
+ from_agent_id: message.from_agent_id,
2046
+ to_agent_id: message.to_agent_id,
2047
+ body: this.decryptPrivateMessageBody(message),
2048
+ created_at: message.created_at,
2049
+ is_self: message.from_agent_id === this.identity?.agent_id,
2050
+ delivery_status:
2051
+ receiptsByMessageId.get(message.message_id) ||
2052
+ this.privateMessageDeliveryStatusCache.get(message.message_id) ||
2053
+ (message.from_agent_id === this.identity?.agent_id ? "fallback-sent" : "sent"),
2054
+ }));
2055
+ }
2056
+
2057
+ async sendPrivateMessage(input: {
2058
+ to_agent_id: string;
2059
+ recipient_encryption_public_key: string;
2060
+ body: string;
2061
+ }): Promise<{ sent: boolean; reason: string; message?: PrivateMessageView }> {
2062
+ if (!this.identity || !this.privateEncryptionKeyPair) {
2063
+ return { sent: false, reason: "missing_identity_or_private_key" };
2064
+ }
2065
+ const toAgentId = String(input.to_agent_id || "").trim();
2066
+ const learnedRecipientKey = this.privatePeerEncryptionKeys[toAgentId] || "";
2067
+ const profileRecipientKey = this.directory.profiles[toAgentId]?.private_encryption_public_key || "";
2068
+ const recipientKey = String(learnedRecipientKey || input.recipient_encryption_public_key || profileRecipientKey || "").trim();
2069
+ const body = String(input.body || "").trim();
2070
+ if (toAgentId === this.identity.agent_id) {
2071
+ return { sent: false, reason: "self_private_message_not_allowed" };
2072
+ }
2073
+ const toPeerId = this.privatePeerRoutes[toAgentId] || "";
2074
+ if (!toAgentId || !recipientKey || !body) {
2075
+ return { sent: false, reason: "invalid_private_message_input" };
2076
+ }
2077
+ const encrypted = encryptPrivatePayload({
2078
+ plaintext: body,
2079
+ recipient_public_key: recipientKey,
2080
+ sender_keypair: this.privateEncryptionKeyPair,
2081
+ });
2082
+ const message = signPrivateMessage({
2083
+ identity: this.identity,
2084
+ message_id: createHash("sha256").update(`${this.identity.agent_id}:${toAgentId}:${Date.now()}:${body}:${Math.random()}`, "utf8").digest("hex"),
2085
+ conversation_id: this.buildPrivateConversationId(this.identity.agent_id, toAgentId),
2086
+ to_agent_id: toAgentId,
2087
+ sender_encryption_public_key: encrypted.sender_encryption_public_key,
2088
+ recipient_encryption_public_key: recipientKey,
2089
+ ciphertext: encrypted.ciphertext,
2090
+ nonce: encrypted.nonce,
2091
+ created_at: Date.now(),
2092
+ });
2093
+ this.privateMessageBodyCache.set(message.message_id, body);
2094
+ this.ingestPrivateMessage(message);
2095
+ await this.persistPrivateMessages();
2096
+ let reason = "fallback-sent";
2097
+ if (toPeerId && typeof this.network.sendDirect === "function") {
2098
+ try {
2099
+ await this.network.sendDirect(toPeerId, PRIVATE_MESSAGE_TOPIC, message);
2100
+ await this.publish(PRIVATE_MESSAGE_TOPIC, message);
2101
+ reason = "direct-sent";
2102
+ } catch {
2103
+ await this.publish(PRIVATE_MESSAGE_TOPIC, message);
2104
+ }
2105
+ } else {
2106
+ await this.publish(PRIVATE_MESSAGE_TOPIC, message);
2107
+ }
2108
+ this.privateMessageDeliveryStatusCache.set(message.message_id, reason as PrivateMessageView["delivery_status"]);
2109
+ const view = this.getPrivateMessages(message.conversation_id).find((item) => item.message_id === message.message_id);
2110
+ if (view) {
2111
+ view.delivery_status = reason as PrivateMessageView["delivery_status"];
2112
+ }
2113
+ return { sent: true, reason, message: view };
2114
+ }
2115
+
1657
2116
  getOpenClawBridgeStatus(): OpenClawBridgeStatus {
2117
+ const now = Date.now();
2118
+ if (this.openclawBridgeStatusCache && this.openclawBridgeStatusCache.expiresAt > now) {
2119
+ return this.openclawBridgeStatusCache.value;
2120
+ }
1658
2121
  const integration = this.getIntegrationStatus();
1659
2122
  const openclawInstallation = detectOpenClawInstallation(this.projectRoot);
1660
- const openclawRuntime = detectOpenClawRuntime(this.projectRoot);
2123
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1661
2124
  const skillInstallation = detectOpenClawSkillInstallation();
1662
2125
  const ownerDelivery = detectOwnerDeliveryStatus({
1663
2126
  workspaceRoot: this.projectRoot,
@@ -1665,7 +2128,7 @@ export class LocalNodeService {
1665
2128
  openclawRunning: openclawRuntime.running,
1666
2129
  skillInstalled: skillInstallation.installed,
1667
2130
  });
1668
- return {
2131
+ const value: OpenClawBridgeStatus = {
1669
2132
  enabled: this.socialConfig.enabled,
1670
2133
  connected_to_silicaclaw: integration.connected_to_silicaclaw,
1671
2134
  public_enabled: integration.public_enabled,
@@ -1721,6 +2184,11 @@ export class LocalNodeService {
1721
2184
  install_skill: "/api/openclaw/bridge/skill-install",
1722
2185
  },
1723
2186
  };
2187
+ this.openclawBridgeStatusCache = {
2188
+ value,
2189
+ expiresAt: now + OPENCLAW_BRIDGE_STATUS_CACHE_MS,
2190
+ };
2191
+ return value;
1724
2192
  }
1725
2193
 
1726
2194
  async installOpenClawSkill(skillName?: string) {
@@ -1735,6 +2203,7 @@ export class LocalNodeService {
1735
2203
  maxBuffer: 1024 * 1024,
1736
2204
  });
1737
2205
  const parsed = JSON.parse(String(stdout || "{}"));
2206
+ this.invalidateOpenClawCaches();
1738
2207
  return {
1739
2208
  ...parsed,
1740
2209
  bridge: this.getOpenClawBridgeStatus(),
@@ -1756,7 +2225,7 @@ export class LocalNodeService {
1756
2225
  const workspaceSkillDir = resolve(homeDir, "workspace", "skills");
1757
2226
  const legacySkillDir = resolve(homeDir, "skills");
1758
2227
  const openclawSourceDir = defaultOpenClawSourceDir(this.projectRoot);
1759
- const openclawRuntime = detectOpenClawRuntime(this.projectRoot);
2228
+ const openclawRuntime = this.getCachedOpenClawRuntime();
1760
2229
 
1761
2230
  return {
1762
2231
  bridge_api_base: DEFAULT_BRIDGE_API_BASE,
@@ -1797,7 +2266,11 @@ export class LocalNodeService {
1797
2266
  }
1798
2267
 
1799
2268
  getSkillsView() {
1800
- const bundledRoot = resolve(this.workspaceRoot, "openclaw-skills");
2269
+ const bundledRootCandidates = [
2270
+ resolve(this.workspaceRoot, "openclaw-skills"),
2271
+ resolve(this.projectRoot, "openclaw-skills"),
2272
+ ];
2273
+ const bundledRoot = bundledRootCandidates.find((candidate) => existsSync(candidate)) || bundledRootCandidates[0];
1801
2274
  const openclawHome = resolve(process.env.HOME || "", ".openclaw");
1802
2275
  const workspaceInstallRoot = resolve(openclawHome, "workspace", "skills");
1803
2276
  const legacyInstallRoot = resolve(openclawHome, "skills");
@@ -2166,15 +2639,14 @@ export class LocalNodeService {
2166
2639
  profile: this.profile,
2167
2640
  };
2168
2641
  const presenceRecord = signPresence(this.identity, Date.now());
2169
- const indexRecords = buildIndexRecords(this.profile);
2170
- const replayMessages = this.getReplayableSelfSocialMessages();
2642
+ const shouldPublishProfile = this.shouldPublishProfileRecord(profileRecord, reason, presenceRecord.timestamp);
2643
+ const replayMessages = this.getReplayableSelfSocialMessages(reason);
2171
2644
 
2172
2645
  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);
2646
+ if (shouldPublishProfile) {
2647
+ await this.publish("profile", profileRecord);
2177
2648
  }
2649
+ await this.publish("presence", presenceRecord);
2178
2650
  for (const message of replayMessages) {
2179
2651
  await this.publish(SOCIAL_MESSAGE_TOPIC, message);
2180
2652
  }
@@ -2183,7 +2655,9 @@ export class LocalNodeService {
2183
2655
  this.lastBroadcastErrorAt = Date.now();
2184
2656
  this.lastBroadcastError = message;
2185
2657
  this.broadcastFailureCount += 1;
2658
+ this.consecutiveBroadcastFailures += 1;
2186
2659
  await this.log("error", `Broadcast failed (reason=${reason}): ${message}`);
2660
+ await this.maybeRecoverFromBroadcastFailure(reason, message);
2187
2661
  return { sent: false, reason: "publish_failed", error: message };
2188
2662
  }
2189
2663
 
@@ -2191,22 +2665,75 @@ export class LocalNodeService {
2191
2665
  this.broadcastCount += 1;
2192
2666
  this.lastBroadcastError = null;
2193
2667
  this.lastBroadcastErrorAt = 0;
2668
+ this.consecutiveBroadcastFailures = 0;
2194
2669
 
2195
2670
  this.directory = ingestProfileRecord(this.directory, profileRecord);
2196
2671
  this.directory = ingestPresenceRecord(this.directory, presenceRecord);
2197
- for (const record of indexRecords) {
2198
- this.directory = ingestIndexRecord(this.directory, record);
2199
- }
2200
2672
  this.compactCacheInMemory();
2201
2673
  await this.persistCache();
2202
2674
 
2203
2675
  await this.log(
2204
2676
  "info",
2205
- `Broadcast sent (${indexRecords.length} index refs, replayed_messages=${replayMessages.length}, reason=${reason})`
2677
+ `Broadcast sent (${shouldPublishProfile ? "profile + " : ""}presence, replayed_messages=${replayMessages.length}, reason=${reason})`
2206
2678
  );
2207
2679
  return { sent: true, reason };
2208
2680
  }
2209
2681
 
2682
+ private shouldPublishProfileRecord(
2683
+ profileRecord: SignedProfileRecord,
2684
+ reason: string,
2685
+ now = Date.now()
2686
+ ): boolean {
2687
+ if (reason !== "interval") {
2688
+ this.lastProfileBroadcastSignature = profileRecord.profile.signature;
2689
+ this.lastProfileBroadcastAt = now;
2690
+ return true;
2691
+ }
2692
+ const signature = profileRecord.profile.signature;
2693
+ const changedSinceLastPublish = signature !== this.lastProfileBroadcastSignature;
2694
+ const refreshDue = now - this.lastProfileBroadcastAt >= PROFILE_RELAY_REFRESH_INTERVAL_MS;
2695
+ if (!changedSinceLastPublish && !refreshDue) {
2696
+ return false;
2697
+ }
2698
+ this.lastProfileBroadcastSignature = signature;
2699
+ this.lastProfileBroadcastAt = now;
2700
+ return true;
2701
+ }
2702
+
2703
+ private async maybeRecoverFromBroadcastFailure(reason: string, errorMessage: string): Promise<void> {
2704
+ const recoveryThreshold = 3;
2705
+ const recoveryCooldownMs = 60_000;
2706
+ if (this.broadcastRecoveryInFlight) {
2707
+ return;
2708
+ }
2709
+ if (this.consecutiveBroadcastFailures < recoveryThreshold) {
2710
+ return;
2711
+ }
2712
+ if (Date.now() - this.lastBroadcastRecoveryAttemptAt < recoveryCooldownMs) {
2713
+ return;
2714
+ }
2715
+ if (this.adapterMode !== "relay-preview" && this.adapterMode !== "webrtc-preview" && this.adapterMode !== "real-preview") {
2716
+ return;
2717
+ }
2718
+
2719
+ this.broadcastRecoveryInFlight = true;
2720
+ this.lastBroadcastRecoveryAttemptAt = Date.now();
2721
+ try {
2722
+ await this.log(
2723
+ "warn",
2724
+ `Broadcast recovery triggered after ${this.consecutiveBroadcastFailures} consecutive failures (${reason}): ${errorMessage}`
2725
+ );
2726
+ await this.restartNetworkAdapter("broadcast_failure_recovery");
2727
+ } catch (recoveryError) {
2728
+ await this.log(
2729
+ "error",
2730
+ `Broadcast recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`
2731
+ );
2732
+ } finally {
2733
+ this.broadcastRecoveryInFlight = false;
2734
+ }
2735
+ }
2736
+
2210
2737
  private async hydrateFromDisk(): Promise<void> {
2211
2738
  this.initState = {
2212
2739
  identity_auto_created: false,
@@ -2236,6 +2763,8 @@ export class LocalNodeService {
2236
2763
  await this.log("info", `Bound existing OpenClaw identity: ${resolvedIdentity.openclaw_source_path}`);
2237
2764
  }
2238
2765
  await this.identityRepo.set(this.identity);
2766
+ this.privateEncryptionKeyPair = (await this.privateEncryptionKeyRepo.get()) || createPrivateEncryptionKeyPair();
2767
+ await this.privateEncryptionKeyRepo.set(this.privateEncryptionKeyPair);
2239
2768
 
2240
2769
  const existingProfile = await this.profileRepo.get();
2241
2770
  const profileInput = resolveProfileInputWithSocial({
@@ -2244,7 +2773,10 @@ export class LocalNodeService {
2244
2773
  existingProfile: existingProfile && existingProfile.agent_id === this.identity.agent_id ? existingProfile : null,
2245
2774
  rootDir: this.projectRoot,
2246
2775
  });
2247
- this.profile = signProfile(profileInput, this.identity);
2776
+ this.profile = signProfile({
2777
+ ...profileInput,
2778
+ private_encryption_public_key: this.privateEncryptionKeyPair?.public_key || profileInput.private_encryption_public_key || "",
2779
+ }, this.identity);
2248
2780
  if (!existingProfile || existingProfile.agent_id !== this.identity.agent_id) {
2249
2781
  this.initState.profile_auto_created = true;
2250
2782
  await this.log("info", "profile.json missing/invalid, initialized from social/default profile");
@@ -2258,6 +2790,11 @@ export class LocalNodeService {
2258
2790
  };
2259
2791
  this.socialMessages = this.normalizeSocialMessages(await this.socialMessageRepo.get());
2260
2792
  this.socialMessageObservations = this.normalizeSocialMessageObservations(await this.socialMessageObservationRepo.get());
2793
+ const storedPrivateMessages = await this.privateMessageRepo.get();
2794
+ this.hydratePrivateMessageBodyCache(storedPrivateMessages);
2795
+ this.privateMessages = this.normalizePrivateMessages(storedPrivateMessages);
2796
+ this.privateMessageReceipts = this.normalizePrivateMessageReceipts(await this.privateMessageReceiptRepo.get());
2797
+ await this.refreshPrivateMessagingRuntime();
2261
2798
  this.directory = ingestProfileRecord(this.directory, { type: "profile", profile: this.profile });
2262
2799
  this.compactCacheInMemory();
2263
2800
  await this.persistCache();
@@ -2276,7 +2813,10 @@ export class LocalNodeService {
2276
2813
  existingProfile: this.profile,
2277
2814
  rootDir: this.projectRoot,
2278
2815
  });
2279
- const nextProfile = signProfile(nextProfileInput, this.identity);
2816
+ const nextProfile = signProfile({
2817
+ ...nextProfileInput,
2818
+ private_encryption_public_key: this.privateEncryptionKeyPair?.public_key || nextProfileInput.private_encryption_public_key || "",
2819
+ }, this.identity);
2280
2820
  this.profile = nextProfile;
2281
2821
  await this.profileRepo.set(nextProfile);
2282
2822
 
@@ -2341,7 +2881,8 @@ export class LocalNodeService {
2341
2881
 
2342
2882
  private async onMessage(
2343
2883
  topic: "profile" | "presence" | "index" | "social.message" | "social.message.observation",
2344
- data: unknown
2884
+ data: unknown,
2885
+ meta?: { peerId?: string }
2345
2886
  ): Promise<void> {
2346
2887
  this.receivedCount += 1;
2347
2888
  this.receivedByTopic[topic] = (this.receivedByTopic[topic] ?? 0) + 1;
@@ -2359,6 +2900,9 @@ export class LocalNodeService {
2359
2900
  return;
2360
2901
  }
2361
2902
  }
2903
+ if (meta?.peerId && record.profile.agent_id && !this.privatePeerRoutes[record.profile.agent_id]) {
2904
+ this.privatePeerRoutes[record.profile.agent_id] = meta.peerId;
2905
+ }
2362
2906
 
2363
2907
  this.directory = ingestProfileRecord(this.directory, record);
2364
2908
  this.compactCacheInMemory();
@@ -2378,6 +2922,9 @@ export class LocalNodeService {
2378
2922
  return;
2379
2923
  }
2380
2924
  }
2925
+ if (meta?.peerId && record.agent_id && !this.privatePeerRoutes[record.agent_id]) {
2926
+ this.privatePeerRoutes[record.agent_id] = meta.peerId;
2927
+ }
2381
2928
 
2382
2929
  this.directory = ingestPresenceRecord(this.directory, record);
2383
2930
  this.compactCacheInMemory();
@@ -2394,6 +2941,9 @@ export class LocalNodeService {
2394
2941
  await this.log("warn", `Rejected social message with invalid signature (${record.message_id.slice(0, 10)})`);
2395
2942
  return;
2396
2943
  }
2944
+ if (meta?.peerId && record.agent_id && !this.privatePeerRoutes[record.agent_id]) {
2945
+ this.privatePeerRoutes[record.agent_id] = meta.peerId;
2946
+ }
2397
2947
  if (this.hasSocialMessage(record.message_id)) {
2398
2948
  await this.publishObservationForMessage(record);
2399
2949
  return;
@@ -2432,6 +2982,45 @@ export class LocalNodeService {
2432
2982
  await this.persistCache();
2433
2983
  }
2434
2984
 
2985
+ private async onDirectMessage(
2986
+ topic: "private.message" | "private.message.receipt",
2987
+ data: unknown,
2988
+ meta?: { peerId?: string }
2989
+ ): Promise<void> {
2990
+ if (topic === PRIVATE_MESSAGE_TOPIC) {
2991
+ const record = this.normalizeIncomingPrivateMessage(data);
2992
+ if (!record || !verifyPrivateMessage(record)) {
2993
+ return;
2994
+ }
2995
+ if (meta?.peerId && record.from_agent_id) {
2996
+ this.privatePeerRoutes[record.from_agent_id] = meta.peerId;
2997
+ }
2998
+ if (record.from_agent_id && record.sender_encryption_public_key) {
2999
+ this.privatePeerEncryptionKeys[record.from_agent_id] = record.sender_encryption_public_key;
3000
+ }
3001
+ if (record.to_agent_id !== this.identity?.agent_id || this.hasPrivateMessage(record.message_id)) {
3002
+ return;
3003
+ }
3004
+ this.ingestPrivateMessage(record);
3005
+ await this.persistPrivateMessages();
3006
+ await this.sendPrivateMessageReceipt(record, meta?.peerId);
3007
+ return;
3008
+ }
3009
+
3010
+ const receipt = this.normalizeIncomingPrivateMessageReceipt(data);
3011
+ if (!receipt || !verifyPrivateMessageReceipt(receipt)) {
3012
+ return;
3013
+ }
3014
+ if (meta?.peerId && receipt.from_agent_id) {
3015
+ this.privatePeerRoutes[receipt.from_agent_id] = meta.peerId;
3016
+ }
3017
+ if (receipt.to_agent_id !== this.identity?.agent_id) {
3018
+ return;
3019
+ }
3020
+ this.ingestPrivateMessageReceipt(receipt);
3021
+ await this.persistPrivateMessageReceipts();
3022
+ }
3023
+
2435
3024
  private startBroadcastLoop(): void {
2436
3025
  if (this.broadcaster) {
2437
3026
  clearInterval(this.broadcaster);
@@ -2457,21 +3046,35 @@ export class LocalNodeService {
2457
3046
  if (this.subscriptionsBound) {
2458
3047
  return;
2459
3048
  }
2460
- this.network.subscribe("profile", (data: SignedProfileRecord) => {
2461
- this.onMessage("profile", data);
3049
+ this.network.subscribe("profile", (data: SignedProfileRecord, meta?: { peerId?: string }) => {
3050
+ this.onMessage("profile", data, meta);
3051
+ });
3052
+ this.network.subscribe("presence", (data: PresenceRecord, meta?: { peerId?: string }) => {
3053
+ this.onMessage("presence", data, meta);
2462
3054
  });
2463
- this.network.subscribe("presence", (data: PresenceRecord) => {
2464
- this.onMessage("presence", data);
3055
+ this.network.subscribe("index", (data: IndexRefRecord, meta?: { peerId?: string }) => {
3056
+ this.onMessage("index", data, meta);
2465
3057
  });
2466
- this.network.subscribe("index", (data: IndexRefRecord) => {
2467
- this.onMessage("index", data);
3058
+ this.network.subscribe(SOCIAL_MESSAGE_TOPIC, (data: SocialMessageRecord, meta?: { peerId?: string }) => {
3059
+ this.onMessage(SOCIAL_MESSAGE_TOPIC, data, meta);
2468
3060
  });
2469
- this.network.subscribe(SOCIAL_MESSAGE_TOPIC, (data: SocialMessageRecord) => {
2470
- this.onMessage(SOCIAL_MESSAGE_TOPIC, data);
3061
+ this.network.subscribe(SOCIAL_MESSAGE_OBSERVATION_TOPIC, (data: SocialMessageObservationRecord, meta?: { peerId?: string }) => {
3062
+ this.onMessage(SOCIAL_MESSAGE_OBSERVATION_TOPIC, data, meta);
2471
3063
  });
2472
- this.network.subscribe(SOCIAL_MESSAGE_OBSERVATION_TOPIC, (data: SocialMessageObservationRecord) => {
2473
- this.onMessage(SOCIAL_MESSAGE_OBSERVATION_TOPIC, data);
3064
+ this.network.subscribe(PRIVATE_MESSAGE_TOPIC, (data: PrivateMessageRecord, meta?: { peerId?: string }) => {
3065
+ this.onDirectMessage(PRIVATE_MESSAGE_TOPIC, data, meta);
2474
3066
  });
3067
+ this.network.subscribe(PRIVATE_MESSAGE_RECEIPT_TOPIC, (data: PrivateMessageReceiptRecord, meta?: { peerId?: string }) => {
3068
+ this.onDirectMessage(PRIVATE_MESSAGE_RECEIPT_TOPIC, data, meta);
3069
+ });
3070
+ if (typeof this.network.subscribeDirect === "function") {
3071
+ this.network.subscribeDirect(PRIVATE_MESSAGE_TOPIC, (data: PrivateMessageRecord, meta?: { peerId?: string }) => {
3072
+ this.onDirectMessage(PRIVATE_MESSAGE_TOPIC, data, meta);
3073
+ });
3074
+ this.network.subscribeDirect(PRIVATE_MESSAGE_RECEIPT_TOPIC, (data: PrivateMessageReceiptRecord, meta?: { peerId?: string }) => {
3075
+ this.onDirectMessage(PRIVATE_MESSAGE_RECEIPT_TOPIC, data, meta);
3076
+ });
3077
+ }
2475
3078
  this.subscriptionsBound = true;
2476
3079
  }
2477
3080
 
@@ -2628,9 +3231,66 @@ export class LocalNodeService {
2628
3231
  this.networkReconnectDelayMs = Math.min(30_000, Math.max(5_000, Math.floor(delayMs * 1.5)));
2629
3232
  }
2630
3233
 
3234
+ private pruneRemoteProfilesInMemory(now = Date.now()): number {
3235
+ if (!Number.isFinite(DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) || DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT <= 0) {
3236
+ return 0;
3237
+ }
3238
+ const selfAgentId = this.profile?.agent_id || this.identity?.agent_id || "";
3239
+ const remoteProfiles = Object.values(this.directory.profiles).filter((profile) => profile.agent_id !== selfAgentId);
3240
+ if (remoteProfiles.length <= DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) {
3241
+ return 0;
3242
+ }
3243
+
3244
+ const onlineRemoteProfiles = remoteProfiles.filter((profile) =>
3245
+ isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS)
3246
+ );
3247
+ const offlineRemoteProfiles = remoteProfiles
3248
+ .filter((profile) => !isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS))
3249
+ .sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
3250
+
3251
+ const keepOfflineCount = Math.max(0, DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT - onlineRemoteProfiles.length);
3252
+ const keptRemoteProfiles = [
3253
+ ...onlineRemoteProfiles,
3254
+ ...offlineRemoteProfiles.slice(0, keepOfflineCount),
3255
+ ];
3256
+ const keptRemoteIds = new Set(keptRemoteProfiles.map((profile) => profile.agent_id));
3257
+ const removedIds = remoteProfiles
3258
+ .map((profile) => profile.agent_id)
3259
+ .filter((agentId) => !keptRemoteIds.has(agentId));
3260
+ if (removedIds.length === 0) {
3261
+ return 0;
3262
+ }
3263
+
3264
+ const next = createEmptyDirectoryState();
3265
+ const selfProfile = selfAgentId ? this.directory.profiles[selfAgentId] : null;
3266
+ if (selfProfile) {
3267
+ next.profiles[selfAgentId] = selfProfile;
3268
+ const selfPresence = this.directory.presence[selfAgentId];
3269
+ if (typeof selfPresence === "number" && Number.isFinite(selfPresence)) {
3270
+ next.presence[selfAgentId] = selfPresence;
3271
+ }
3272
+ const rebuilt = rebuildIndexForProfile(next, selfProfile);
3273
+ next.index = rebuilt.index;
3274
+ }
3275
+
3276
+ for (const profile of keptRemoteProfiles) {
3277
+ next.profiles[profile.agent_id] = profile;
3278
+ const seenAt = this.directory.presence[profile.agent_id];
3279
+ if (typeof seenAt === "number" && Number.isFinite(seenAt)) {
3280
+ next.presence[profile.agent_id] = seenAt;
3281
+ }
3282
+ const rebuilt = rebuildIndexForProfile(next, profile);
3283
+ next.index = rebuilt.index;
3284
+ }
3285
+
3286
+ this.directory = dedupeIndex(next);
3287
+ return removedIds.length;
3288
+ }
3289
+
2631
3290
  private compactCacheInMemory(): number {
2632
3291
  const cleaned = cleanupExpiredPresence(this.directory, Date.now(), PRESENCE_TTL_MS);
2633
3292
  this.directory = dedupeIndex(cleaned.state);
3293
+ this.pruneRemoteProfilesInMemory();
2634
3294
  return cleaned.removed;
2635
3295
  }
2636
3296
 
@@ -2666,6 +3326,90 @@ export class LocalNodeService {
2666
3326
  await this.socialMessageObservationRepo.set(this.socialMessageObservations);
2667
3327
  }
2668
3328
 
3329
+ private async persistPrivateMessages(): Promise<void> {
3330
+ this.privateMessagesPersistDirty = true;
3331
+ if (this.privateMessagesPersistTimer) {
3332
+ return;
3333
+ }
3334
+ this.privateMessagesPersistTimer = setTimeout(() => {
3335
+ this.flushPrivateMessagesPersist().catch(() => {});
3336
+ }, PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS);
3337
+ }
3338
+
3339
+ private async persistPrivateMessageReceipts(): Promise<void> {
3340
+ this.privateMessageReceiptsPersistDirty = true;
3341
+ if (this.privateMessageReceiptsPersistTimer) {
3342
+ return;
3343
+ }
3344
+ this.privateMessageReceiptsPersistTimer = setTimeout(() => {
3345
+ this.flushPrivateMessageReceiptsPersist().catch(() => {});
3346
+ }, PRIVATE_MESSAGE_PERSIST_DEBOUNCE_MS);
3347
+ }
3348
+
3349
+ private async flushPrivatePersistence(): Promise<void> {
3350
+ await Promise.all([
3351
+ this.flushPrivateMessagesPersist(),
3352
+ this.flushPrivateMessageReceiptsPersist(),
3353
+ ]);
3354
+ }
3355
+
3356
+ private async flushPrivateMessagesPersist(): Promise<void> {
3357
+ if (this.privateMessagesPersistTimer) {
3358
+ clearTimeout(this.privateMessagesPersistTimer);
3359
+ this.privateMessagesPersistTimer = null;
3360
+ }
3361
+ if (!this.privateMessagesPersistDirty) {
3362
+ return;
3363
+ }
3364
+ this.privateMessagesPersistDirty = false;
3365
+ await this.privateMessageRepo.set(this.buildPersistedPrivateMessages() as unknown as PrivateMessageRecord[]);
3366
+ }
3367
+
3368
+ private hydratePrivateMessageBodyCache(items: unknown): void {
3369
+ if (!Array.isArray(items)) {
3370
+ return;
3371
+ }
3372
+ for (const item of items) {
3373
+ if (typeof item !== "object" || item === null) {
3374
+ continue;
3375
+ }
3376
+ const record = item as Partial<StoredPrivateMessageRecord>;
3377
+ const messageId = String(record.message_id || "").trim();
3378
+ const localPlaintext = typeof record.local_plaintext === "string" ? record.local_plaintext : "";
3379
+ if (messageId && localPlaintext) {
3380
+ this.privateMessageBodyCache.set(messageId, localPlaintext);
3381
+ }
3382
+ }
3383
+ }
3384
+
3385
+ private buildPersistedPrivateMessages(): StoredPrivateMessageRecord[] {
3386
+ return this.privateMessages.map((message) => {
3387
+ const localPlaintext =
3388
+ message.from_agent_id === this.identity?.agent_id
3389
+ ? this.privateMessageBodyCache.get(message.message_id) || ""
3390
+ : "";
3391
+ if (!localPlaintext) {
3392
+ return { ...message };
3393
+ }
3394
+ return {
3395
+ ...message,
3396
+ local_plaintext: localPlaintext,
3397
+ };
3398
+ });
3399
+ }
3400
+
3401
+ private async flushPrivateMessageReceiptsPersist(): Promise<void> {
3402
+ if (this.privateMessageReceiptsPersistTimer) {
3403
+ clearTimeout(this.privateMessageReceiptsPersistTimer);
3404
+ this.privateMessageReceiptsPersistTimer = null;
3405
+ }
3406
+ if (!this.privateMessageReceiptsPersistDirty) {
3407
+ return;
3408
+ }
3409
+ this.privateMessageReceiptsPersistDirty = false;
3410
+ await this.privateMessageReceiptRepo.set(this.privateMessageReceipts);
3411
+ }
3412
+
2669
3413
  private async log(level: "info" | "warn" | "error", message: string): Promise<void> {
2670
3414
  await this.logRepo.append({
2671
3415
  level,
@@ -2722,6 +3466,7 @@ export class LocalNodeService {
2722
3466
 
2723
3467
  return buildPublicProfileSummary({
2724
3468
  profile,
3469
+ is_self: isSelf,
2725
3470
  online,
2726
3471
  last_seen_at: lastSeenAt || null,
2727
3472
  network_mode: isSelf ? this.networkMode : "unknown",
@@ -2775,6 +3520,7 @@ export class LocalNodeService {
2775
3520
  updated_at: message.created_at,
2776
3521
  signature: "",
2777
3522
  },
3523
+ is_self: message.agent_id === this.identity?.agent_id,
2778
3524
  online: false,
2779
3525
  last_seen_at: null,
2780
3526
  network_mode: "unknown",
@@ -2808,6 +3554,47 @@ export class LocalNodeService {
2808
3554
  return `${digest.slice(0, 12)}:${digest.slice(-8)}`;
2809
3555
  }
2810
3556
 
3557
+ private buildPrivateMessagingRuntimeState(): PrivateMessagingRuntimeState {
3558
+ const warnings: string[] = [];
3559
+ const keypair = this.privateEncryptionKeyPair;
3560
+ const selfSentMessages = this.privateMessages.filter((message) => message.from_agent_id === this.identity?.agent_id);
3561
+ let cachedPlaintextCount = 0;
3562
+ for (const message of selfSentMessages) {
3563
+ if (this.privateMessageBodyCache.get(message.message_id)) {
3564
+ cachedPlaintextCount += 1;
3565
+ }
3566
+ }
3567
+ if (!keypair?.public_key || !keypair?.private_key) {
3568
+ warnings.push("missing_private_encryption_keypair");
3569
+ }
3570
+ if (selfSentMessages.length > 0 && cachedPlaintextCount === 0) {
3571
+ warnings.push("missing_local_plaintext_cache_for_self_messages");
3572
+ }
3573
+ if (selfSentMessages.length > 0 && cachedPlaintextCount < selfSentMessages.length) {
3574
+ warnings.push("partial_local_plaintext_cache_for_self_messages");
3575
+ }
3576
+ return {
3577
+ schema_version: 1,
3578
+ app_version: this.appVersion,
3579
+ last_started_at: Date.now(),
3580
+ encryption_public_key: keypair?.public_key || "",
3581
+ encryption_public_key_fingerprint: keypair?.public_key ? this.fingerprintPublicKey(keypair.public_key) : "",
3582
+ message_count: this.privateMessages.length,
3583
+ self_sent_count: selfSentMessages.length,
3584
+ cached_plaintext_count: cachedPlaintextCount,
3585
+ warnings,
3586
+ };
3587
+ }
3588
+
3589
+ private async refreshPrivateMessagingRuntime(): Promise<void> {
3590
+ const runtime = this.buildPrivateMessagingRuntimeState();
3591
+ this.privateMessagingRuntime = runtime;
3592
+ await this.privateMessagingRuntimeRepo.set(runtime);
3593
+ for (const warning of runtime.warnings) {
3594
+ await this.log("warn", `Private messaging startup check: ${warning}`);
3595
+ }
3596
+ }
3597
+
2811
3598
  private getOnboardingSummary() {
2812
3599
  const summary = this.getIntegrationSummary();
2813
3600
  const publicEnabled = Boolean(this.profile?.public_enabled);
@@ -2980,6 +3767,34 @@ export class LocalNodeService {
2980
3767
  .trim();
2981
3768
  }
2982
3769
 
3770
+ private buildPrivateConversationId(leftAgentId: string, rightAgentId: string): string {
3771
+ return [String(leftAgentId || "").trim(), String(rightAgentId || "").trim()].sort().join(":");
3772
+ }
3773
+
3774
+ private decryptPrivateMessageBody(message: PrivateMessageRecord): string {
3775
+ const cached = this.privateMessageBodyCache.get(message.message_id);
3776
+ if (typeof cached === "string") {
3777
+ return cached;
3778
+ }
3779
+ if (!this.privateEncryptionKeyPair) {
3780
+ return "[encrypted]";
3781
+ }
3782
+ const decrypted = decryptPrivatePayload({
3783
+ ciphertext: message.ciphertext,
3784
+ nonce: message.nonce,
3785
+ sender_encryption_public_key: message.sender_encryption_public_key,
3786
+ recipient_private_key: this.privateEncryptionKeyPair.private_key,
3787
+ }) || "[encrypted]";
3788
+ this.privateMessageBodyCache.set(message.message_id, decrypted);
3789
+ if (this.privateMessageBodyCache.size > PRIVATE_MESSAGE_HISTORY_LIMIT * 2) {
3790
+ const firstKey = this.privateMessageBodyCache.keys().next().value;
3791
+ if (firstKey) {
3792
+ this.privateMessageBodyCache.delete(firstKey);
3793
+ }
3794
+ }
3795
+ return decrypted;
3796
+ }
3797
+
2983
3798
  private normalizeWindowTimestamps(timestamps: number[], windowMs: number, now = Date.now()): number[] {
2984
3799
  return timestamps.filter((timestamp) => now - timestamp <= windowMs);
2985
3800
  }
@@ -3005,18 +3820,32 @@ export class LocalNodeService {
3005
3820
  return this.socialMessages.some((item) => item.message_id === messageId);
3006
3821
  }
3007
3822
 
3008
- private getReplayableSelfSocialMessages(now = Date.now()): SocialMessageRecord[] {
3823
+ private getReplayableSelfSocialMessages(reason = "manual", now = Date.now()): SocialMessageRecord[] {
3009
3824
  const maxCount = Math.max(0, SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST);
3010
3825
  if (!this.identity || maxCount === 0) {
3011
3826
  return [];
3012
3827
  }
3013
- return this.socialMessages
3828
+ const replayable = this.socialMessages
3014
3829
  .filter((item) => (
3015
3830
  item.agent_id === this.identity?.agent_id &&
3016
3831
  now - item.created_at <= SOCIAL_MESSAGE_REPLAY_WINDOW_MS
3017
3832
  ))
3018
3833
  .sort((a, b) => a.created_at - b.created_at)
3019
3834
  .slice(-maxCount);
3835
+ if (!replayable.length) {
3836
+ this.lastReplayBroadcastSignature = "";
3837
+ return [];
3838
+ }
3839
+ const signature = replayable.map((item) => item.message_id).join(",");
3840
+ const isIntervalReplay = reason === "interval";
3841
+ const changedSinceLastReplay = signature !== this.lastReplayBroadcastSignature;
3842
+ const refreshDue = now - this.lastReplayBroadcastAt >= SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS;
3843
+ if (isIntervalReplay && !changedSinceLastReplay && !refreshDue) {
3844
+ return [];
3845
+ }
3846
+ this.lastReplayBroadcastSignature = signature;
3847
+ this.lastReplayBroadcastAt = now;
3848
+ return replayable;
3020
3849
  }
3021
3850
 
3022
3851
  private hasRecentDuplicateMessage(agentId: string, body: string, topic: string, now = Date.now()): boolean {
@@ -3091,6 +3920,190 @@ export class LocalNodeService {
3091
3920
  await this.persistSocialMessageObservations();
3092
3921
  }
3093
3922
 
3923
+ private async sendPrivateMessageReceipt(message: PrivateMessageRecord, replyPeerId?: string): Promise<void> {
3924
+ if (!this.identity || typeof this.network.sendDirect !== "function" || !replyPeerId) {
3925
+ return;
3926
+ }
3927
+ const receipt = signPrivateMessageReceipt({
3928
+ identity: this.identity,
3929
+ receipt_id: createHash("sha256").update(`${message.message_id}:${this.identity.agent_id}:${Date.now()}`, "utf8").digest("hex"),
3930
+ message_id: message.message_id,
3931
+ conversation_id: message.conversation_id,
3932
+ to_agent_id: message.from_agent_id,
3933
+ status: "received",
3934
+ created_at: Date.now(),
3935
+ });
3936
+ this.ingestPrivateMessageReceipt(receipt);
3937
+ try {
3938
+ await this.network.sendDirect(replyPeerId, PRIVATE_MESSAGE_RECEIPT_TOPIC, receipt);
3939
+ await this.publish(PRIVATE_MESSAGE_RECEIPT_TOPIC, receipt);
3940
+ } catch {
3941
+ await this.publish(PRIVATE_MESSAGE_RECEIPT_TOPIC, receipt);
3942
+ }
3943
+ await this.persistPrivateMessageReceipts();
3944
+ }
3945
+
3946
+ private normalizeIncomingPrivateMessage(value: unknown): PrivateMessageRecord | null {
3947
+ if (typeof value !== "object" || value === null) {
3948
+ return null;
3949
+ }
3950
+ const record = value as Partial<PrivateMessageRecord>;
3951
+ const createdAt = Number(record.created_at || 0);
3952
+ const fromAgentId = String(record.from_agent_id || "").trim();
3953
+ const toAgentId = String(record.to_agent_id || "").trim();
3954
+ const conversationId = String(record.conversation_id || "").trim();
3955
+ if (
3956
+ record.type !== PRIVATE_MESSAGE_TOPIC ||
3957
+ !String(record.message_id || "").trim() ||
3958
+ !conversationId ||
3959
+ !fromAgentId ||
3960
+ !toAgentId ||
3961
+ !String(record.sender_public_key || "").trim() ||
3962
+ !String(record.sender_encryption_public_key || "").trim() ||
3963
+ !String(record.recipient_encryption_public_key || "").trim() ||
3964
+ !String(record.ciphertext || "").trim() ||
3965
+ !String(record.nonce || "").trim() ||
3966
+ String(record.cipher_scheme || "") !== "nacl-box-v1" ||
3967
+ !String(record.signature || "").trim() ||
3968
+ !Number.isFinite(createdAt)
3969
+ ) {
3970
+ return null;
3971
+ }
3972
+ if (fromAgentId === toAgentId) {
3973
+ return null;
3974
+ }
3975
+ if (conversationId !== this.buildPrivateConversationId(fromAgentId, toAgentId)) {
3976
+ return null;
3977
+ }
3978
+ return {
3979
+ type: PRIVATE_MESSAGE_TOPIC,
3980
+ message_id: String(record.message_id).trim(),
3981
+ conversation_id: conversationId,
3982
+ from_agent_id: fromAgentId,
3983
+ to_agent_id: toAgentId,
3984
+ sender_public_key: String(record.sender_public_key).trim(),
3985
+ sender_encryption_public_key: String(record.sender_encryption_public_key).trim(),
3986
+ recipient_encryption_public_key: String(record.recipient_encryption_public_key).trim(),
3987
+ cipher_scheme: "nacl-box-v1",
3988
+ ciphertext: String(record.ciphertext).trim(),
3989
+ nonce: String(record.nonce).trim(),
3990
+ created_at: createdAt,
3991
+ signature: String(record.signature).trim(),
3992
+ };
3993
+ }
3994
+
3995
+ private normalizePrivateMessages(items: unknown): PrivateMessageRecord[] {
3996
+ if (!Array.isArray(items)) {
3997
+ return [];
3998
+ }
3999
+ const deduped = new Set<string>();
4000
+ return items
4001
+ .map((item) => this.normalizeIncomingPrivateMessage(item))
4002
+ .filter((item): item is PrivateMessageRecord => Boolean(item))
4003
+ .sort((a, b) => a.created_at - b.created_at)
4004
+ .filter((item) => {
4005
+ if (deduped.has(item.message_id)) {
4006
+ return false;
4007
+ }
4008
+ deduped.add(item.message_id);
4009
+ return true;
4010
+ })
4011
+ .slice(-PRIVATE_MESSAGE_HISTORY_LIMIT);
4012
+ }
4013
+
4014
+ private normalizeIncomingPrivateMessageReceipt(value: unknown): PrivateMessageReceiptRecord | null {
4015
+ if (typeof value !== "object" || value === null) {
4016
+ return null;
4017
+ }
4018
+ const record = value as Partial<PrivateMessageReceiptRecord>;
4019
+ const createdAt = Number(record.created_at || 0);
4020
+ const status = String(record.status || "").trim();
4021
+ if (
4022
+ record.type !== PRIVATE_MESSAGE_RECEIPT_TOPIC ||
4023
+ !String(record.receipt_id || "").trim() ||
4024
+ !String(record.message_id || "").trim() ||
4025
+ !String(record.conversation_id || "").trim() ||
4026
+ !String(record.from_agent_id || "").trim() ||
4027
+ !String(record.to_agent_id || "").trim() ||
4028
+ !String(record.sender_public_key || "").trim() ||
4029
+ (status !== "received" && status !== "read") ||
4030
+ !String(record.signature || "").trim() ||
4031
+ !Number.isFinite(createdAt)
4032
+ ) {
4033
+ return null;
4034
+ }
4035
+ return {
4036
+ type: PRIVATE_MESSAGE_RECEIPT_TOPIC,
4037
+ receipt_id: String(record.receipt_id).trim(),
4038
+ message_id: String(record.message_id).trim(),
4039
+ conversation_id: String(record.conversation_id).trim(),
4040
+ from_agent_id: String(record.from_agent_id).trim(),
4041
+ to_agent_id: String(record.to_agent_id).trim(),
4042
+ sender_public_key: String(record.sender_public_key).trim(),
4043
+ status: status as "received" | "read",
4044
+ created_at: createdAt,
4045
+ signature: String(record.signature).trim(),
4046
+ };
4047
+ }
4048
+
4049
+ private normalizePrivateMessageReceipts(items: unknown): PrivateMessageReceiptRecord[] {
4050
+ if (!Array.isArray(items)) {
4051
+ return [];
4052
+ }
4053
+ const deduped = new Set<string>();
4054
+ return items
4055
+ .map((item) => this.normalizeIncomingPrivateMessageReceipt(item))
4056
+ .filter((item): item is PrivateMessageReceiptRecord => Boolean(item))
4057
+ .sort((a, b) => a.created_at - b.created_at)
4058
+ .filter((item) => {
4059
+ if (deduped.has(item.receipt_id)) {
4060
+ return false;
4061
+ }
4062
+ deduped.add(item.receipt_id);
4063
+ return true;
4064
+ })
4065
+ .slice(-PRIVATE_MESSAGE_RECEIPT_HISTORY_LIMIT);
4066
+ }
4067
+
4068
+ private hasPrivateMessage(messageId: string): boolean {
4069
+ return this.privateMessages.some((item) => item.message_id === messageId);
4070
+ }
4071
+
4072
+ private ingestPrivateMessage(message: PrivateMessageRecord): void {
4073
+ const existing = this.privateMessages.findIndex((item) => item.message_id === message.message_id);
4074
+ if (existing >= 0) {
4075
+ this.privateMessages[existing] = message;
4076
+ } else {
4077
+ this.privateMessages.push(message);
4078
+ }
4079
+ this.privateMessages = this.normalizePrivateMessages(this.privateMessages);
4080
+ const validIds = new Set(this.privateMessages.map((item) => item.message_id));
4081
+ if (message.from_agent_id !== this.identity?.agent_id) {
4082
+ this.privateMessageBodyCache.delete(message.message_id);
4083
+ }
4084
+ for (const key of Array.from(this.privateMessageBodyCache.keys())) {
4085
+ if (!validIds.has(key)) {
4086
+ this.privateMessageBodyCache.delete(key);
4087
+ }
4088
+ }
4089
+ for (const key of Array.from(this.privateMessageDeliveryStatusCache.keys())) {
4090
+ if (!validIds.has(key)) {
4091
+ this.privateMessageDeliveryStatusCache.delete(key);
4092
+ }
4093
+ }
4094
+ }
4095
+
4096
+ private ingestPrivateMessageReceipt(receipt: PrivateMessageReceiptRecord): void {
4097
+ const existing = this.privateMessageReceipts.findIndex((item) => item.receipt_id === receipt.receipt_id);
4098
+ if (existing >= 0) {
4099
+ this.privateMessageReceipts[existing] = receipt;
4100
+ } else {
4101
+ this.privateMessageReceipts.push(receipt);
4102
+ }
4103
+ this.privateMessageReceipts = this.normalizePrivateMessageReceipts(this.privateMessageReceipts);
4104
+ this.privateMessageDeliveryStatusCache.set(receipt.message_id, receipt.status);
4105
+ }
4106
+
3094
4107
  private normalizeIncomingSocialMessage(value: unknown): SocialMessageRecord | null {
3095
4108
  if (typeof value !== "object" || value === null) {
3096
4109
  return null;
@@ -3369,6 +4382,48 @@ export async function main() {
3369
4382
  sendOk(res, node.getRuntimePaths());
3370
4383
  });
3371
4384
 
4385
+ app.get("/api/app/update-status", (_req, res) => {
4386
+ sendOk(res, node.getAppUpdateStatus());
4387
+ });
4388
+
4389
+ app.post(
4390
+ "/api/app/update",
4391
+ asyncRoute(async (_req, res) => {
4392
+ const status = node.getAppUpdateStatus();
4393
+ if (!status.update_available || !status.latest_version) {
4394
+ sendOk(
4395
+ res,
4396
+ {
4397
+ started: false,
4398
+ current_version: status.current_version,
4399
+ latest_version: status.latest_version,
4400
+ platform: status.platform,
4401
+ reason: status.check_error || "already_current",
4402
+ },
4403
+ { message: "Already on the latest version" }
4404
+ );
4405
+ return;
4406
+ }
4407
+ sendOk(
4408
+ res,
4409
+ {
4410
+ started: true,
4411
+ current_version: status.current_version,
4412
+ target_version: status.latest_version,
4413
+ platform: status.platform,
4414
+ },
4415
+ { message: `Updating to ${status.latest_version}` }
4416
+ );
4417
+ setTimeout(() => {
4418
+ try {
4419
+ node.startAppUpdate();
4420
+ } catch {
4421
+ // best effort after response has been sent
4422
+ }
4423
+ }, 1200);
4424
+ })
4425
+ );
4426
+
3372
4427
  app.put(
3373
4428
  "/api/profile",
3374
4429
  asyncRoute(async (req, res) => {
@@ -3511,6 +4566,38 @@ export async function main() {
3511
4566
  sendOk(res, node.getSocialMessages(limit, { agent_id: agentId || null }));
3512
4567
  });
3513
4568
 
4569
+ app.get("/api/private/state", (_req, res) => {
4570
+ sendOk(res, node.getPrivateMessagingState());
4571
+ });
4572
+
4573
+ app.get("/api/private/conversations", (_req, res) => {
4574
+ sendOk(res, node.getPrivateConversations());
4575
+ });
4576
+
4577
+ app.get("/api/private/messages", (req, res) => {
4578
+ const conversationId = String(req.query.conversation_id ?? "").trim();
4579
+ const limit = Number(req.query.limit ?? PRIVATE_MESSAGE_QUERY_LIMIT);
4580
+ sendOk(res, node.getPrivateMessages(conversationId, limit));
4581
+ });
4582
+
4583
+ app.post(
4584
+ "/api/private/messages/send",
4585
+ asyncRoute(async (req, res) => {
4586
+ const result = await node.sendPrivateMessage({
4587
+ to_agent_id: String(req.body?.to_agent_id || ""),
4588
+ recipient_encryption_public_key: String(req.body?.recipient_encryption_public_key || ""),
4589
+ body: String(req.body?.body || ""),
4590
+ });
4591
+ sendOk(res, result, {
4592
+ message: result.sent
4593
+ ? (result.reason === "direct-sent"
4594
+ ? "Private message sent directly"
4595
+ : "Private message sent via encrypted fallback")
4596
+ : `Private message skipped: ${result.reason}`,
4597
+ });
4598
+ })
4599
+ );
4600
+
3514
4601
  app.get("/api/openclaw/bridge", (_req, res) => {
3515
4602
  sendOk(res, node.getOpenClawBridgeStatus());
3516
4603
  });