@silicaclaw/cli 2026.3.20-3 → 2026.3.20-5

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 (31) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/INSTALL.md +2 -2
  3. package/README.md +2 -2
  4. package/VERSION +1 -1
  5. package/apps/local-console/dist/apps/local-console/src/server.d.ts +39 -0
  6. package/apps/local-console/dist/apps/local-console/src/server.js +229 -12
  7. package/apps/local-console/dist/packages/network/src/relayPreview.d.ts +4 -0
  8. package/apps/local-console/dist/packages/network/src/relayPreview.js +37 -6
  9. package/apps/local-console/public/app/app.js +293 -2
  10. package/apps/local-console/public/app/network.js +144 -32
  11. package/apps/local-console/public/app/overview.js +43 -15
  12. package/apps/local-console/public/app/social.js +135 -53
  13. package/apps/local-console/public/app/styles.css +86 -0
  14. package/apps/local-console/public/app/template.js +7 -1
  15. package/apps/local-console/public/app/translations.js +44 -0
  16. package/apps/local-console/src/server.ts +262 -14
  17. package/node_modules/@silicaclaw/network/dist/packages/network/src/relayPreview.d.ts +4 -0
  18. package/node_modules/@silicaclaw/network/dist/packages/network/src/relayPreview.js +37 -6
  19. package/node_modules/@silicaclaw/network/src/relayPreview.ts +41 -6
  20. package/openclaw-skills/silicaclaw-broadcast/VERSION +1 -1
  21. package/openclaw-skills/silicaclaw-broadcast/manifest.json +1 -1
  22. package/openclaw-skills/silicaclaw-owner-push/VERSION +1 -1
  23. package/openclaw-skills/silicaclaw-owner-push/manifest.json +1 -1
  24. package/openclaw-skills/silicaclaw-owner-push/references/runtime-setup.md +3 -0
  25. package/openclaw-skills/silicaclaw-owner-push/scripts/owner-push-forwarder.mjs +67 -8
  26. package/package.json +1 -1
  27. package/packages/network/dist/packages/network/src/relayPreview.d.ts +4 -0
  28. package/packages/network/dist/packages/network/src/relayPreview.js +37 -6
  29. package/packages/network/src/relayPreview.ts +41 -6
  30. package/scripts/silicaclaw-cli.mjs +4 -1
  31. package/scripts/silicaclaw-gateway.mjs +108 -0
@@ -56,6 +56,9 @@ export const TRANSLATIONS = {
56
56
  },
57
57
  actions: {
58
58
  broadcastNow: 'Announce Agent Now',
59
+ checkUpdate: 'Check',
60
+ updateNow: 'Update',
61
+ updateNowVersion: 'Update {version}',
59
62
  editProfile: 'Edit Profile',
60
63
  openNetwork: 'Network',
61
64
  openAgent: 'Directory',
@@ -135,6 +138,17 @@ export const TRANSLATIONS = {
135
138
  publishStatus: 'Publish Status',
136
139
  publicProfilePreview: 'Public Profile Preview',
137
140
  goPublic: 'Go public',
141
+ versionChecking: 'Checking for updates...',
142
+ versionCurrent: 'Up to date',
143
+ versionUpdateReady: 'Update {version} ready',
144
+ versionUpdating: 'Updating...',
145
+ versionCheckFailed: 'Could not check updates',
146
+ versionPlatformMac: 'macOS service will restart automatically',
147
+ versionPlatformLinux: 'Linux service will restart automatically',
148
+ versionPlatformOther: 'Local service will refresh after the update',
149
+ relayQueuesHealthy: 'Relay queues are healthy.',
150
+ relayQueuesWatch: 'Relay queues need attention.',
151
+ relayQueuesHigh: 'Relay queues are building up.',
138
152
  networkEyebrow: 'Network',
139
153
  connectionSummary: 'Connection',
140
154
  quickActions: 'Broadcast',
@@ -205,6 +219,9 @@ export const TRANSLATIONS = {
205
219
  duplicateWindowSeconds: 'Duplicate Window (seconds)',
206
220
  blockedAgentIds: 'Blocked agent IDs (agent_id, comma separated)',
207
221
  blockedTerms: 'Blocked Terms (comma separated)',
222
+ queueHealthy: 'Healthy',
223
+ queueWatch: 'Watch',
224
+ queueHigh: 'High',
208
225
  },
209
226
  hints: {
210
227
  publicDiscoverySwitch: 'Use Profile -> Public Enabled as the single public visibility switch.',
@@ -533,6 +550,11 @@ export const TRANSLATIONS = {
533
550
  templateCopied: 'Template copied to clipboard.',
534
551
  preparingDownload: 'Preparing download...',
535
552
  runtimeUpdated: 'Mode updated.',
553
+ appUpdateStarted: 'Update started. SilicaClaw will refresh shortly.',
554
+ appUpdatedTo: 'Updated to {version}.',
555
+ appUpdateLatest: 'Already on the latest version.',
556
+ appUpdateCheckFailed: 'Could not check for updates.',
557
+ appUpdateFailed: 'Could not start the update.',
536
558
  copyPreviewFailed: 'Copy preview failed',
537
559
  logsRefreshed: 'Logs refreshed',
538
560
  crossPreviewEnabled: 'Cross-network preview enabled',
@@ -642,6 +664,9 @@ export const TRANSLATIONS = {
642
664
  },
643
665
  actions: {
644
666
  broadcastNow: '立即公告代理',
667
+ checkUpdate: '检查',
668
+ updateNow: '立即更新',
669
+ updateNowVersion: '更新到 {version}',
645
670
  editProfile: '编辑资料',
646
671
  openNetwork: '打开网络页',
647
672
  openAgent: '打开代理目录',
@@ -708,6 +733,17 @@ export const TRANSLATIONS = {
708
733
  discoveredAgents: '公开代理目录',
709
734
  onlyShowOnline: '只显示在线',
710
735
  nodeSnapshot: '本机代理快照',
736
+ versionChecking: '正在检查更新...',
737
+ versionCurrent: '已是最新版本',
738
+ versionUpdateReady: '可更新到 {version}',
739
+ versionUpdating: '正在更新...',
740
+ versionCheckFailed: '暂时无法检查更新',
741
+ versionPlatformMac: 'macOS 服务会自动重启',
742
+ versionPlatformLinux: 'Linux 服务会自动重启',
743
+ versionPlatformOther: '更新后本地服务会自动刷新',
744
+ relayQueuesHealthy: 'Relay 队列正常。',
745
+ relayQueuesWatch: 'Relay 队列需要关注。',
746
+ relayQueuesHigh: 'Relay 队列正在堆积。',
711
747
  profileEyebrow: '资料',
712
748
  publicProfile: '公开资料',
713
749
  publicProfileEditor: '公开资料编辑器',
@@ -791,6 +827,9 @@ export const TRANSLATIONS = {
791
827
  duplicateWindowSeconds: '重复消息窗口(秒)',
792
828
  blockedAgentIds: '已屏蔽代理 ID(agent_id,逗号分隔)',
793
829
  blockedTerms: '已屏蔽词(逗号分隔)',
830
+ queueHealthy: '正常',
831
+ queueWatch: '注意',
832
+ queueHigh: '偏高',
794
833
  },
795
834
  hints: {
796
835
  publicDiscoverySwitch: '使用资料 -> Public Enabled 作为唯一的公开可见性开关。',
@@ -1119,6 +1158,11 @@ export const TRANSLATIONS = {
1119
1158
  templateCopied: '模板已复制到剪贴板。',
1120
1159
  preparingDownload: '正在准备下载...',
1121
1160
  runtimeUpdated: '模式已更新。',
1161
+ appUpdateStarted: '更新已开始,SilicaClaw 很快会自动刷新。',
1162
+ appUpdatedTo: '已更新到 {version}。',
1163
+ appUpdateLatest: '当前已经是最新版本。',
1164
+ appUpdateCheckFailed: '暂时无法检查更新。',
1165
+ appUpdateFailed: '无法开始更新。',
1122
1166
  copyPreviewFailed: '复制预览失败',
1123
1167
  logsRefreshed: '日志已刷新',
1124
1168
  crossPreviewEnabled: '跨网络预览已启用',
@@ -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";
@@ -17,7 +17,6 @@ import {
17
17
  PublicProfileSummary,
18
18
  SignedProfileRecord,
19
19
  buildPublicProfileSummary,
20
- buildIndexRecords,
21
20
  cleanupExpiredPresence,
22
21
  createDefaultProfileInput,
23
22
  createEmptyDirectoryState,
@@ -90,6 +89,7 @@ const DEFAULT_BRIDGE_API_BASE = defaults.bridge.api_base;
90
89
  const OPENCLAW_GATEWAY_PORT = defaults.ports.openclaw_gateway;
91
90
  const OPENCLAW_GATEWAY_URL = `http://${OPENCLAW_GATEWAY_HOST}:${OPENCLAW_GATEWAY_PORT}/`;
92
91
  const NETWORK_PEER_REMOVE_AFTER_MS = Number(process.env.NETWORK_PEER_REMOVE_AFTER_MS || 180_000);
92
+ const DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT = Number(process.env.DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT || 1000);
93
93
  const NETWORK_UDP_BIND_ADDRESS = process.env.NETWORK_UDP_BIND_ADDRESS || "0.0.0.0";
94
94
  const NETWORK_UDP_BROADCAST_ADDRESS = process.env.NETWORK_UDP_BROADCAST_ADDRESS || "255.255.255.255";
95
95
  const NETWORK_PEER_ID = process.env.NETWORK_PEER_ID;
@@ -115,6 +115,12 @@ const SOCIAL_MESSAGE_MAX_AGE_MS = Number(process.env.SOCIAL_MESSAGE_MAX_AGE_MS |
115
115
  const SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT = Number(process.env.SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT || 500);
116
116
  const SOCIAL_MESSAGE_REPLAY_WINDOW_MS = Number(process.env.SOCIAL_MESSAGE_REPLAY_WINDOW_MS || 10 * 60_000);
117
117
  const SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST = Number(process.env.SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST || 3);
118
+ const SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS = Number(
119
+ process.env.SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS || 120_000
120
+ );
121
+ const PROFILE_RELAY_REFRESH_INTERVAL_MS = Number(
122
+ process.env.PROFILE_RELAY_REFRESH_INTERVAL_MS || 120_000
123
+ );
118
124
  const SOCIAL_MESSAGE_BLOCKED_AGENT_IDS = new Set(
119
125
  dedupeStrings(parseListEnv(process.env.SOCIAL_MESSAGE_BLOCKED_AGENT_IDS || ""))
120
126
  );
@@ -157,6 +163,10 @@ function normalizeVersionText(value: unknown): string {
157
163
  return text.startsWith("v") ? text.slice(1) : text;
158
164
  }
159
165
 
166
+ function formatBytesToMiB(value: number): number {
167
+ return Math.round((value / (1024 * 1024)) * 10) / 10;
168
+ }
169
+
160
170
  function tokenizeVersion(value: unknown): Array<number | string> {
161
171
  return normalizeVersionText(value)
162
172
  .split(/[^0-9A-Za-z]+/)
@@ -186,6 +196,10 @@ function compareVersionTokens(left: unknown, right: unknown): number {
186
196
  return 0;
187
197
  }
188
198
 
199
+ function userNpmCacheDir(): string {
200
+ return resolve(homedir(), ".silicaclaw", "npm-cache");
201
+ }
202
+
189
203
  function resolveWorkspaceRoot(cwd = process.cwd()): string {
190
204
  if (existsSync(resolve(cwd, "apps", "local-console", "package.json"))) {
191
205
  return cwd;
@@ -913,6 +927,10 @@ export class LocalNodeService {
913
927
  private broadcastCount = 0;
914
928
  private lastMessageAt = 0;
915
929
  private lastBroadcastAt = 0;
930
+ private lastProfileBroadcastAt = 0;
931
+ private lastProfileBroadcastSignature = "";
932
+ private lastReplayBroadcastAt = 0;
933
+ private lastReplayBroadcastSignature = "";
916
934
  private lastBroadcastErrorAt = 0;
917
935
  private lastBroadcastError: string | null = null;
918
936
  private broadcastFailureCount = 0;
@@ -1201,6 +1219,7 @@ export class LocalNodeService {
1201
1219
  const relayCapable = this.adapterMode === "webrtc-preview" || this.adapterMode === "relay-preview";
1202
1220
  const peers: Array<{ status?: string }> = diagnostics?.peers?.items ?? [];
1203
1221
  const online = peers.filter((peer: { status?: string }) => peer.status === "online").length;
1222
+ const memory = process.memoryUsage();
1204
1223
 
1205
1224
  return {
1206
1225
  adapter: this.adapterMode,
@@ -1225,6 +1244,23 @@ export class LocalNodeService {
1225
1244
  adapter_stats: diagnostics?.stats ?? null,
1226
1245
  adapter_transport_stats: diagnostics?.transport_stats ?? null,
1227
1246
  adapter_discovery_stats: diagnostics?.discovery_stats ?? null,
1247
+ runtime_diagnostics: {
1248
+ memory_mib: {
1249
+ rss: formatBytesToMiB(memory.rss),
1250
+ heap_used: formatBytesToMiB(memory.heapUsed),
1251
+ heap_total: formatBytesToMiB(memory.heapTotal),
1252
+ external: formatBytesToMiB(memory.external),
1253
+ },
1254
+ directory: {
1255
+ profile_count: Object.keys(this.directory.profiles).length,
1256
+ presence_count: Object.keys(this.directory.presence).length,
1257
+ index_key_count: Object.keys(this.directory.index).length,
1258
+ },
1259
+ social: {
1260
+ message_count: this.socialMessages.length,
1261
+ observation_count: this.socialMessageObservations.length,
1262
+ },
1263
+ },
1228
1264
  adapter_diagnostics_summary: relayCapable || diagnostics
1229
1265
  ? {
1230
1266
  started: this.networkStarted,
@@ -1341,6 +1377,88 @@ export class LocalNodeService {
1341
1377
  };
1342
1378
  }
1343
1379
 
1380
+ getAppUpdateStatus() {
1381
+ const currentVersion = normalizeVersionText(this.appVersion) || "unknown";
1382
+ const fallback = {
1383
+ current_version: currentVersion,
1384
+ latest_version: currentVersion,
1385
+ update_available: false,
1386
+ channel: "latest",
1387
+ platform: process.platform,
1388
+ checked_at: Date.now(),
1389
+ can_update: true,
1390
+ check_error: null as string | null,
1391
+ };
1392
+ try {
1393
+ const result = spawnSync("npm", ["view", "@silicaclaw/cli", "dist-tags", "--json"], {
1394
+ cwd: this.projectRoot,
1395
+ encoding: "utf8",
1396
+ env: {
1397
+ ...process.env,
1398
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1399
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1400
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1401
+ },
1402
+ });
1403
+ if ((result.status ?? 1) !== 0) {
1404
+ return {
1405
+ ...fallback,
1406
+ check_error: String(result.stderr || result.stdout || "npm view failed").trim() || "npm view failed",
1407
+ };
1408
+ }
1409
+ const tags = JSON.parse(String(result.stdout || "{}").trim() || "{}") as { latest?: string };
1410
+ const latestVersion = normalizeVersionText(tags.latest || currentVersion) || currentVersion;
1411
+ return {
1412
+ ...fallback,
1413
+ latest_version: latestVersion,
1414
+ update_available: compareVersionTokens(latestVersion, currentVersion) > 0,
1415
+ };
1416
+ } catch (error) {
1417
+ return {
1418
+ ...fallback,
1419
+ check_error: error instanceof Error ? error.message : String(error),
1420
+ };
1421
+ }
1422
+ }
1423
+
1424
+ startAppUpdate(): { started: boolean; target_version: string; platform: string; reason?: string } {
1425
+ const status = this.getAppUpdateStatus();
1426
+ if (!status.update_available || !status.latest_version) {
1427
+ return {
1428
+ started: false,
1429
+ target_version: status.latest_version || status.current_version,
1430
+ platform: process.platform,
1431
+ reason: status.check_error || "already_current",
1432
+ };
1433
+ }
1434
+ const scriptPath = resolve(this.workspaceRoot, "scripts", "silicaclaw-cli.mjs");
1435
+ if (!existsSync(scriptPath)) {
1436
+ return {
1437
+ started: false,
1438
+ target_version: status.latest_version,
1439
+ platform: process.platform,
1440
+ reason: "missing_cli_script",
1441
+ };
1442
+ }
1443
+ const child = spawn(process.execPath, [scriptPath, "update"], {
1444
+ cwd: this.projectRoot,
1445
+ detached: true,
1446
+ stdio: "ignore",
1447
+ env: {
1448
+ ...process.env,
1449
+ SILICACLAW_WORKSPACE_DIR: this.projectRoot,
1450
+ SILICACLAW_APP_DIR: this.workspaceRoot,
1451
+ npm_config_cache: process.env.npm_config_cache || userNpmCacheDir(),
1452
+ },
1453
+ });
1454
+ child.unref();
1455
+ return {
1456
+ started: true,
1457
+ target_version: status.latest_version,
1458
+ platform: process.platform,
1459
+ };
1460
+ }
1461
+
1344
1462
  getIntegrationSummary() {
1345
1463
  const status = this.getIntegrationStatus();
1346
1464
  const runtimeGenerated = Boolean(this.socialRuntime && this.socialRuntime.last_loaded_at > 0);
@@ -2171,15 +2289,14 @@ export class LocalNodeService {
2171
2289
  profile: this.profile,
2172
2290
  };
2173
2291
  const presenceRecord = signPresence(this.identity, Date.now());
2174
- const indexRecords = buildIndexRecords(this.profile);
2175
- const replayMessages = this.getReplayableSelfSocialMessages();
2292
+ const shouldPublishProfile = this.shouldPublishProfileRecord(profileRecord, reason, presenceRecord.timestamp);
2293
+ const replayMessages = this.getReplayableSelfSocialMessages(reason);
2176
2294
 
2177
2295
  try {
2178
- await this.publish("profile", profileRecord);
2179
- await this.publish("presence", presenceRecord);
2180
- for (const record of indexRecords) {
2181
- await this.publish("index", record);
2296
+ if (shouldPublishProfile) {
2297
+ await this.publish("profile", profileRecord);
2182
2298
  }
2299
+ await this.publish("presence", presenceRecord);
2183
2300
  for (const message of replayMessages) {
2184
2301
  await this.publish(SOCIAL_MESSAGE_TOPIC, message);
2185
2302
  }
@@ -2202,19 +2319,37 @@ export class LocalNodeService {
2202
2319
 
2203
2320
  this.directory = ingestProfileRecord(this.directory, profileRecord);
2204
2321
  this.directory = ingestPresenceRecord(this.directory, presenceRecord);
2205
- for (const record of indexRecords) {
2206
- this.directory = ingestIndexRecord(this.directory, record);
2207
- }
2208
2322
  this.compactCacheInMemory();
2209
2323
  await this.persistCache();
2210
2324
 
2211
2325
  await this.log(
2212
2326
  "info",
2213
- `Broadcast sent (${indexRecords.length} index refs, replayed_messages=${replayMessages.length}, reason=${reason})`
2327
+ `Broadcast sent (${shouldPublishProfile ? "profile + " : ""}presence, replayed_messages=${replayMessages.length}, reason=${reason})`
2214
2328
  );
2215
2329
  return { sent: true, reason };
2216
2330
  }
2217
2331
 
2332
+ private shouldPublishProfileRecord(
2333
+ profileRecord: SignedProfileRecord,
2334
+ reason: string,
2335
+ now = Date.now()
2336
+ ): boolean {
2337
+ if (reason !== "interval") {
2338
+ this.lastProfileBroadcastSignature = profileRecord.profile.signature;
2339
+ this.lastProfileBroadcastAt = now;
2340
+ return true;
2341
+ }
2342
+ const signature = profileRecord.profile.signature;
2343
+ const changedSinceLastPublish = signature !== this.lastProfileBroadcastSignature;
2344
+ const refreshDue = now - this.lastProfileBroadcastAt >= PROFILE_RELAY_REFRESH_INTERVAL_MS;
2345
+ if (!changedSinceLastPublish && !refreshDue) {
2346
+ return false;
2347
+ }
2348
+ this.lastProfileBroadcastSignature = signature;
2349
+ this.lastProfileBroadcastAt = now;
2350
+ return true;
2351
+ }
2352
+
2218
2353
  private async maybeRecoverFromBroadcastFailure(reason: string, errorMessage: string): Promise<void> {
2219
2354
  const recoveryThreshold = 3;
2220
2355
  const recoveryCooldownMs = 60_000;
@@ -2670,9 +2805,66 @@ export class LocalNodeService {
2670
2805
  this.networkReconnectDelayMs = Math.min(30_000, Math.max(5_000, Math.floor(delayMs * 1.5)));
2671
2806
  }
2672
2807
 
2808
+ private pruneRemoteProfilesInMemory(now = Date.now()): number {
2809
+ if (!Number.isFinite(DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) || DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT <= 0) {
2810
+ return 0;
2811
+ }
2812
+ const selfAgentId = this.profile?.agent_id || this.identity?.agent_id || "";
2813
+ const remoteProfiles = Object.values(this.directory.profiles).filter((profile) => profile.agent_id !== selfAgentId);
2814
+ if (remoteProfiles.length <= DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT) {
2815
+ return 0;
2816
+ }
2817
+
2818
+ const onlineRemoteProfiles = remoteProfiles.filter((profile) =>
2819
+ isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS)
2820
+ );
2821
+ const offlineRemoteProfiles = remoteProfiles
2822
+ .filter((profile) => !isAgentOnline(this.directory.presence[profile.agent_id], now, PRESENCE_TTL_MS))
2823
+ .sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
2824
+
2825
+ const keepOfflineCount = Math.max(0, DIRECTORY_REMOTE_PROFILE_SOFT_LIMIT - onlineRemoteProfiles.length);
2826
+ const keptRemoteProfiles = [
2827
+ ...onlineRemoteProfiles,
2828
+ ...offlineRemoteProfiles.slice(0, keepOfflineCount),
2829
+ ];
2830
+ const keptRemoteIds = new Set(keptRemoteProfiles.map((profile) => profile.agent_id));
2831
+ const removedIds = remoteProfiles
2832
+ .map((profile) => profile.agent_id)
2833
+ .filter((agentId) => !keptRemoteIds.has(agentId));
2834
+ if (removedIds.length === 0) {
2835
+ return 0;
2836
+ }
2837
+
2838
+ const next = createEmptyDirectoryState();
2839
+ const selfProfile = selfAgentId ? this.directory.profiles[selfAgentId] : null;
2840
+ if (selfProfile) {
2841
+ next.profiles[selfAgentId] = selfProfile;
2842
+ const selfPresence = this.directory.presence[selfAgentId];
2843
+ if (typeof selfPresence === "number" && Number.isFinite(selfPresence)) {
2844
+ next.presence[selfAgentId] = selfPresence;
2845
+ }
2846
+ const rebuilt = rebuildIndexForProfile(next, selfProfile);
2847
+ next.index = rebuilt.index;
2848
+ }
2849
+
2850
+ for (const profile of keptRemoteProfiles) {
2851
+ next.profiles[profile.agent_id] = profile;
2852
+ const seenAt = this.directory.presence[profile.agent_id];
2853
+ if (typeof seenAt === "number" && Number.isFinite(seenAt)) {
2854
+ next.presence[profile.agent_id] = seenAt;
2855
+ }
2856
+ const rebuilt = rebuildIndexForProfile(next, profile);
2857
+ next.index = rebuilt.index;
2858
+ }
2859
+
2860
+ this.directory = dedupeIndex(next);
2861
+ return removedIds.length;
2862
+ }
2863
+
2673
2864
  private compactCacheInMemory(): number {
2674
2865
  const cleaned = cleanupExpiredPresence(this.directory, Date.now(), PRESENCE_TTL_MS);
2675
2866
  this.directory = dedupeIndex(cleaned.state);
2867
+ this.pruneRemoteProfilesInMemory();
2676
2868
  return cleaned.removed;
2677
2869
  }
2678
2870
 
@@ -3047,18 +3239,32 @@ export class LocalNodeService {
3047
3239
  return this.socialMessages.some((item) => item.message_id === messageId);
3048
3240
  }
3049
3241
 
3050
- private getReplayableSelfSocialMessages(now = Date.now()): SocialMessageRecord[] {
3242
+ private getReplayableSelfSocialMessages(reason = "manual", now = Date.now()): SocialMessageRecord[] {
3051
3243
  const maxCount = Math.max(0, SOCIAL_MESSAGE_REPLAY_MAX_PER_BROADCAST);
3052
3244
  if (!this.identity || maxCount === 0) {
3053
3245
  return [];
3054
3246
  }
3055
- return this.socialMessages
3247
+ const replayable = this.socialMessages
3056
3248
  .filter((item) => (
3057
3249
  item.agent_id === this.identity?.agent_id &&
3058
3250
  now - item.created_at <= SOCIAL_MESSAGE_REPLAY_WINDOW_MS
3059
3251
  ))
3060
3252
  .sort((a, b) => a.created_at - b.created_at)
3061
3253
  .slice(-maxCount);
3254
+ if (!replayable.length) {
3255
+ this.lastReplayBroadcastSignature = "";
3256
+ return [];
3257
+ }
3258
+ const signature = replayable.map((item) => item.message_id).join(",");
3259
+ const isIntervalReplay = reason === "interval";
3260
+ const changedSinceLastReplay = signature !== this.lastReplayBroadcastSignature;
3261
+ const refreshDue = now - this.lastReplayBroadcastAt >= SOCIAL_MESSAGE_REPLAY_REFRESH_INTERVAL_MS;
3262
+ if (isIntervalReplay && !changedSinceLastReplay && !refreshDue) {
3263
+ return [];
3264
+ }
3265
+ this.lastReplayBroadcastSignature = signature;
3266
+ this.lastReplayBroadcastAt = now;
3267
+ return replayable;
3062
3268
  }
3063
3269
 
3064
3270
  private hasRecentDuplicateMessage(agentId: string, body: string, topic: string, now = Date.now()): boolean {
@@ -3411,6 +3617,48 @@ export async function main() {
3411
3617
  sendOk(res, node.getRuntimePaths());
3412
3618
  });
3413
3619
 
3620
+ app.get("/api/app/update-status", (_req, res) => {
3621
+ sendOk(res, node.getAppUpdateStatus());
3622
+ });
3623
+
3624
+ app.post(
3625
+ "/api/app/update",
3626
+ asyncRoute(async (_req, res) => {
3627
+ const status = node.getAppUpdateStatus();
3628
+ if (!status.update_available || !status.latest_version) {
3629
+ sendOk(
3630
+ res,
3631
+ {
3632
+ started: false,
3633
+ current_version: status.current_version,
3634
+ latest_version: status.latest_version,
3635
+ platform: status.platform,
3636
+ reason: status.check_error || "already_current",
3637
+ },
3638
+ { message: "Already on the latest version" }
3639
+ );
3640
+ return;
3641
+ }
3642
+ sendOk(
3643
+ res,
3644
+ {
3645
+ started: true,
3646
+ current_version: status.current_version,
3647
+ target_version: status.latest_version,
3648
+ platform: status.platform,
3649
+ },
3650
+ { message: `Updating to ${status.latest_version}` }
3651
+ );
3652
+ setTimeout(() => {
3653
+ try {
3654
+ node.startAppUpdate();
3655
+ } catch {
3656
+ // best effort after response has been sent
3657
+ }
3658
+ }, 150);
3659
+ })
3660
+ );
3661
+
3414
3662
  app.put(
3415
3663
  "/api/profile",
3416
3664
  asyncRoute(async (req, res) => {
@@ -22,6 +22,10 @@ type RelayPeer = {
22
22
  last_seen_at: number;
23
23
  messages_seen: number;
24
24
  reconnect_attempts: number;
25
+ meta?: {
26
+ signal_queue_size?: number;
27
+ relay_queue_size?: number;
28
+ };
25
29
  };
26
30
  type RelayDiagnostics = {
27
31
  adapter: "relay-preview";
@@ -109,7 +109,6 @@ class RelayPreviewAdapter {
109
109
  try {
110
110
  await this.joinRoom("start");
111
111
  this.started = true;
112
- await this.refreshPeers();
113
112
  await this.pollOnce();
114
113
  this.scheduleNextPoll(this.pollIntervalMs);
115
114
  this.recordDiscovery("signaling_connected", { endpoint: this.activeEndpoint });
@@ -258,8 +257,10 @@ class RelayPreviewAdapter {
258
257
  const payload = await this.get(`/peers?room=${encodeURIComponent(this.room)}`);
259
258
  this.lastPeerRefreshAt = Date.now();
260
259
  this.stats.peers_refresh_succeeded += 1;
261
- const peerIds = Array.isArray(payload?.peers) ? payload.peers : [];
262
- this.updatePeersFromList(peerIds);
260
+ const peerItems = Array.isArray(payload?.peer_details) && payload.peer_details.length
261
+ ? payload.peer_details
262
+ : Array.isArray(payload?.peers) ? payload.peers : [];
263
+ this.updatePeersFromList(peerItems);
263
264
  }
264
265
  onEnvelope(envelope) {
265
266
  this.stats.received_total += 1;
@@ -340,9 +341,13 @@ class RelayPreviewAdapter {
340
341
  }
341
342
  async joinRoom(reason) {
342
343
  this.stats.join_attempted += 1;
343
- await this.post("/join", { room: this.room, peer_id: this.peerId });
344
+ const payload = await this.post("/join", { room: this.room, peer_id: this.peerId });
344
345
  this.lastJoinAt = Date.now();
345
346
  this.stats.join_succeeded += 1;
347
+ if (Array.isArray(payload?.peers)) {
348
+ this.updatePeersFromList(payload.peers);
349
+ this.lastPeerRefreshAt = this.lastJoinAt;
350
+ }
346
351
  this.recordDiscovery("join_ok", { endpoint: this.activeEndpoint, detail: reason });
347
352
  }
348
353
  async maybeRefreshJoin(reason) {
@@ -407,13 +412,38 @@ class RelayPreviewAdapter {
407
412
  throw new Error(errors.join(" | "));
408
413
  }
409
414
  updatePeersFromList(values) {
410
- const peerIds = values.map((value) => String(value || "").trim()).filter(Boolean);
415
+ const parsedPeers = [];
416
+ for (const value of values) {
417
+ if (typeof value === "string") {
418
+ const peerId = String(value || "").trim();
419
+ if (peerId) {
420
+ parsedPeers.push({ peer_id: peerId });
421
+ }
422
+ continue;
423
+ }
424
+ if (value && typeof value === "object") {
425
+ const raw = value;
426
+ const peerId = String(raw.peer_id || "").trim();
427
+ if (!peerId) {
428
+ continue;
429
+ }
430
+ parsedPeers.push({
431
+ peer_id: peerId,
432
+ meta: {
433
+ signal_queue_size: Number(raw.signal_queue_size ?? 0),
434
+ relay_queue_size: Number(raw.relay_queue_size ?? 0),
435
+ },
436
+ });
437
+ }
438
+ }
439
+ const peerIds = parsedPeers.map((peer) => peer.peer_id);
411
440
  if (!peerIds.includes(this.peerId)) {
412
441
  void this.joinRoom("self_missing_from_peers").catch(() => { });
413
442
  }
414
443
  const now = Date.now();
415
444
  const next = new Map();
416
- for (const peerId of peerIds) {
445
+ for (const peerInfo of parsedPeers) {
446
+ const peerId = peerInfo.peer_id;
417
447
  if (peerId === this.peerId)
418
448
  continue;
419
449
  const existing = this.peers.get(peerId);
@@ -427,6 +457,7 @@ class RelayPreviewAdapter {
427
457
  last_seen_at: now,
428
458
  messages_seen: existing?.messages_seen ?? 0,
429
459
  reconnect_attempts: existing?.reconnect_attempts ?? 0,
460
+ meta: peerInfo.meta || existing?.meta,
430
461
  });
431
462
  }
432
463
  for (const peerId of this.peers.keys()) {
@@ -34,6 +34,10 @@ type RelayPeer = {
34
34
  last_seen_at: number;
35
35
  messages_seen: number;
36
36
  reconnect_attempts: number;
37
+ meta?: {
38
+ signal_queue_size?: number;
39
+ relay_queue_size?: number;
40
+ };
37
41
  };
38
42
 
39
43
  type RelayDiagnostics = {
@@ -227,7 +231,6 @@ export class RelayPreviewAdapter implements NetworkAdapter {
227
231
  try {
228
232
  await this.joinRoom("start");
229
233
  this.started = true;
230
- await this.refreshPeers();
231
234
  await this.pollOnce();
232
235
  this.scheduleNextPoll(this.pollIntervalMs);
233
236
  this.recordDiscovery("signaling_connected", { endpoint: this.activeEndpoint });
@@ -375,8 +378,10 @@ export class RelayPreviewAdapter implements NetworkAdapter {
375
378
  const payload = await this.get(`/peers?room=${encodeURIComponent(this.room)}`);
376
379
  this.lastPeerRefreshAt = Date.now();
377
380
  this.stats.peers_refresh_succeeded += 1;
378
- const peerIds = Array.isArray(payload?.peers) ? payload.peers : [];
379
- this.updatePeersFromList(peerIds);
381
+ const peerItems = Array.isArray(payload?.peer_details) && payload.peer_details.length
382
+ ? payload.peer_details
383
+ : Array.isArray(payload?.peers) ? payload.peers : [];
384
+ this.updatePeersFromList(peerItems);
380
385
  }
381
386
 
382
387
  private onEnvelope(envelope: unknown): void {
@@ -457,9 +462,13 @@ export class RelayPreviewAdapter implements NetworkAdapter {
457
462
 
458
463
  private async joinRoom(reason: string): Promise<void> {
459
464
  this.stats.join_attempted += 1;
460
- await this.post("/join", { room: this.room, peer_id: this.peerId });
465
+ const payload = await this.post("/join", { room: this.room, peer_id: this.peerId });
461
466
  this.lastJoinAt = Date.now();
462
467
  this.stats.join_succeeded += 1;
468
+ if (Array.isArray(payload?.peers)) {
469
+ this.updatePeersFromList(payload.peers);
470
+ this.lastPeerRefreshAt = this.lastJoinAt;
471
+ }
463
472
  this.recordDiscovery("join_ok", { endpoint: this.activeEndpoint, detail: reason });
464
473
  }
465
474
 
@@ -528,13 +537,38 @@ export class RelayPreviewAdapter implements NetworkAdapter {
528
537
  }
529
538
 
530
539
  private updatePeersFromList(values: unknown[]): void {
531
- const peerIds = values.map((value) => String(value || "").trim()).filter(Boolean);
540
+ const parsedPeers: Array<{ peer_id: string; meta?: RelayPeer["meta"] }> = [];
541
+ for (const value of values) {
542
+ if (typeof value === "string") {
543
+ const peerId = String(value || "").trim();
544
+ if (peerId) {
545
+ parsedPeers.push({ peer_id: peerId });
546
+ }
547
+ continue;
548
+ }
549
+ if (value && typeof value === "object") {
550
+ const raw = value as Record<string, unknown>;
551
+ const peerId = String(raw.peer_id || "").trim();
552
+ if (!peerId) {
553
+ continue;
554
+ }
555
+ parsedPeers.push({
556
+ peer_id: peerId,
557
+ meta: {
558
+ signal_queue_size: Number(raw.signal_queue_size ?? 0),
559
+ relay_queue_size: Number(raw.relay_queue_size ?? 0),
560
+ },
561
+ });
562
+ }
563
+ }
564
+ const peerIds = parsedPeers.map((peer) => peer.peer_id);
532
565
  if (!peerIds.includes(this.peerId)) {
533
566
  void this.joinRoom("self_missing_from_peers").catch(() => {});
534
567
  }
535
568
  const now = Date.now();
536
569
  const next = new Map<string, RelayPeer>();
537
- for (const peerId of peerIds) {
570
+ for (const peerInfo of parsedPeers) {
571
+ const peerId = peerInfo.peer_id;
538
572
  if (peerId === this.peerId) continue;
539
573
  const existing = this.peers.get(peerId);
540
574
  if (!existing) {
@@ -547,6 +581,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
547
581
  last_seen_at: now,
548
582
  messages_seen: existing?.messages_seen ?? 0,
549
583
  reconnect_attempts: existing?.reconnect_attempts ?? 0,
584
+ meta: peerInfo.meta || existing?.meta,
550
585
  });
551
586
  }
552
587
  for (const peerId of this.peers.keys()) {