@silicaclaw/cli 2026.3.18-4 → 2026.3.19-2

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 (63) hide show
  1. package/ARCHITECTURE.md +15 -0
  2. package/CHANGELOG.md +17 -2
  3. package/INSTALL.md +35 -0
  4. package/README.md +119 -10
  5. package/RELEASE_NOTES_v1.0.md +29 -2
  6. package/SOCIAL_MD_SPEC.md +2 -0
  7. package/VERSION +1 -1
  8. package/apps/local-console/public/index.html +2297 -231
  9. package/apps/local-console/src/server.ts +1120 -24
  10. package/apps/local-console/src/socialRoutes.ts +21 -0
  11. package/apps/public-explorer/public/index.html +190 -43
  12. package/docs/NEW_USER_OPERATIONS.md +35 -5
  13. package/docs/OPENCLAW_BRIDGE.md +449 -0
  14. package/docs/OPENCLAW_BRIDGE_ZH.md +445 -0
  15. package/docs/QUICK_START.md +20 -1
  16. package/docs/release/ANNOUNCEMENT_v1.0-beta.md +68 -0
  17. package/docs/release/FINAL_RELEASE_SUMMARY_v1.0-beta.md +112 -0
  18. package/docs/release/GITHUB_RELEASE_v1.0-beta.md +16 -16
  19. package/docs/release/RELEASE_COPY_v1.0-beta.md +102 -0
  20. package/openclaw-skills/silicaclaw-broadcast/SKILL.md +89 -0
  21. package/openclaw-skills/silicaclaw-broadcast/VERSION +1 -0
  22. package/openclaw-skills/silicaclaw-broadcast/agents/openai.yaml +6 -0
  23. package/openclaw-skills/silicaclaw-broadcast/manifest.json +34 -0
  24. package/openclaw-skills/silicaclaw-broadcast/references/computer-control-via-openclaw.md +41 -0
  25. package/openclaw-skills/silicaclaw-broadcast/references/owner-dispatch-adapter.md +81 -0
  26. package/openclaw-skills/silicaclaw-broadcast/references/owner-forwarding-policy.md +48 -0
  27. package/openclaw-skills/silicaclaw-broadcast/scripts/bridge-client.mjs +59 -0
  28. package/openclaw-skills/silicaclaw-broadcast/scripts/owner-dispatch-adapter-demo.mjs +12 -0
  29. package/openclaw-skills/silicaclaw-broadcast/scripts/owner-forwarder-demo.mjs +111 -0
  30. package/openclaw-skills/silicaclaw-broadcast/scripts/send-to-owner-via-openclaw.mjs +69 -0
  31. package/openclaw.social.md.example +6 -0
  32. package/package.json +2 -1
  33. package/packages/core/dist/index.d.ts +1 -0
  34. package/packages/core/dist/index.js +1 -0
  35. package/packages/core/dist/socialConfig.d.ts +1 -0
  36. package/packages/core/dist/socialConfig.js +9 -1
  37. package/packages/core/dist/socialMessage.d.ts +19 -0
  38. package/packages/core/dist/socialMessage.js +69 -0
  39. package/packages/core/dist/socialTemplate.js +3 -1
  40. package/packages/core/dist/types.d.ts +22 -0
  41. package/packages/core/src/index.ts +1 -0
  42. package/packages/core/src/socialConfig.ts +13 -1
  43. package/packages/core/src/socialMessage.ts +86 -0
  44. package/packages/core/src/socialTemplate.ts +3 -1
  45. package/packages/core/src/types.ts +24 -0
  46. package/packages/network/dist/relayPreview.js +16 -4
  47. package/packages/network/src/relayPreview.ts +17 -4
  48. package/packages/storage/dist/repos.d.ts +40 -0
  49. package/packages/storage/dist/repos.js +27 -1
  50. package/packages/storage/dist/socialRuntimeRepo.js +1 -0
  51. package/packages/storage/src/repos.ts +60 -0
  52. package/packages/storage/src/socialRuntimeRepo.ts +1 -0
  53. package/packages/storage/tsconfig.json +1 -1
  54. package/scripts/functional-check.mjs +85 -2
  55. package/scripts/install-openclaw-skill.mjs +54 -0
  56. package/scripts/openclaw-bridge-adapter.mjs +89 -0
  57. package/scripts/openclaw-bridge-client.mjs +223 -0
  58. package/scripts/openclaw-runtime-demo.mjs +202 -0
  59. package/scripts/pack-openclaw-skill.mjs +58 -0
  60. package/scripts/silicaclaw-cli.mjs +30 -0
  61. package/scripts/silicaclaw-gateway.mjs +215 -0
  62. package/scripts/validate-openclaw-skill.mjs +74 -0
  63. package/social.md.example +6 -0
@@ -1,9 +1,11 @@
1
1
  import express, { NextFunction, Request, Response } from "express";
2
2
  import cors from "cors";
3
+ import { execFile, spawnSync } from "child_process";
3
4
  import { resolve } from "path";
4
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
5
+ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
5
6
  import { createHash } from "crypto";
6
7
  import { hostname } from "os";
8
+ import { promisify } from "util";
7
9
  import {
8
10
  AgentIdentity,
9
11
  DirectoryState,
@@ -30,11 +32,17 @@ import {
30
32
  resolveIdentityWithSocial,
31
33
  resolveProfileInputWithSocial,
32
34
  searchDirectory,
35
+ signSocialMessage,
36
+ signSocialMessageObservation,
33
37
  signPresence,
34
38
  signProfile,
35
39
  SocialConfig,
40
+ SocialMessageObservationRecord,
41
+ SocialMessageRecord,
36
42
  SocialRuntimeConfig,
37
43
  generateSocialMdTemplate,
44
+ verifySocialMessage,
45
+ verifySocialMessageObservation,
38
46
  verifyPresence,
39
47
  verifyProfile,
40
48
  } from "@silicaclaw/core";
@@ -48,7 +56,17 @@ import {
48
56
  UdpLanBroadcastTransport,
49
57
  WebRTCPreviewAdapter,
50
58
  } from "@silicaclaw/network";
51
- import { CacheRepo, IdentityRepo, LogRepo, ProfileRepo, SocialRuntimeRepo } from "@silicaclaw/storage";
59
+ import {
60
+ CacheRepo,
61
+ IdentityRepo,
62
+ LogRepo,
63
+ ProfileRepo,
64
+ SocialMessageGovernanceConfig,
65
+ SocialMessageGovernanceRepo,
66
+ SocialMessageRepo,
67
+ SocialMessageObservationRepo,
68
+ SocialRuntimeRepo,
69
+ } from "@silicaclaw/storage";
52
70
  import { registerSocialRoutes } from "./socialRoutes";
53
71
 
54
72
  const BROADCAST_INTERVAL_MS = Number(process.env.BROADCAST_INTERVAL_MS || 20_000);
@@ -71,6 +89,27 @@ const WEBRTC_ROOM = process.env.WEBRTC_ROOM || "silicaclaw-global-preview";
71
89
  const WEBRTC_SEED_PEERS = process.env.WEBRTC_SEED_PEERS || "";
72
90
  const WEBRTC_BOOTSTRAP_HINTS = process.env.WEBRTC_BOOTSTRAP_HINTS || "";
73
91
  const PROFILE_VERSION = "v0.9";
92
+ const SOCIAL_MESSAGE_TOPIC = "social.message";
93
+ const SOCIAL_MESSAGE_OBSERVATION_TOPIC = "social.message.observation";
94
+ const DEFAULT_SOCIAL_MESSAGE_CHANNEL = "global";
95
+ const SOCIAL_MESSAGE_MAX_BODY_CHARS = Number(process.env.SOCIAL_MESSAGE_MAX_BODY_CHARS || 500);
96
+ const SOCIAL_MESSAGE_HISTORY_LIMIT = Number(process.env.SOCIAL_MESSAGE_HISTORY_LIMIT || 100);
97
+ const SOCIAL_MESSAGE_SEND_WINDOW_MS = Number(process.env.SOCIAL_MESSAGE_SEND_WINDOW_MS || 60_000);
98
+ const SOCIAL_MESSAGE_SEND_MAX_PER_WINDOW = Number(process.env.SOCIAL_MESSAGE_SEND_MAX_PER_WINDOW || 5);
99
+ const SOCIAL_MESSAGE_RECEIVE_WINDOW_MS = Number(process.env.SOCIAL_MESSAGE_RECEIVE_WINDOW_MS || 60_000);
100
+ const SOCIAL_MESSAGE_RECEIVE_MAX_PER_WINDOW = Number(process.env.SOCIAL_MESSAGE_RECEIVE_MAX_PER_WINDOW || 8);
101
+ const SOCIAL_MESSAGE_DUPLICATE_WINDOW_MS = Number(process.env.SOCIAL_MESSAGE_DUPLICATE_WINDOW_MS || 180_000);
102
+ const SOCIAL_MESSAGE_MAX_FUTURE_MS = Number(process.env.SOCIAL_MESSAGE_MAX_FUTURE_MS || 30_000);
103
+ const SOCIAL_MESSAGE_MAX_AGE_MS = Number(process.env.SOCIAL_MESSAGE_MAX_AGE_MS || 15 * 60_000);
104
+ const SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT = Number(process.env.SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT || 500);
105
+ const SOCIAL_MESSAGE_BLOCKED_AGENT_IDS = new Set(
106
+ dedupeStrings(parseListEnv(process.env.SOCIAL_MESSAGE_BLOCKED_AGENT_IDS || ""))
107
+ );
108
+ const SOCIAL_MESSAGE_BLOCKED_TERMS = dedupeStrings(parseListEnv(process.env.SOCIAL_MESSAGE_BLOCKED_TERMS || ""))
109
+ .map((term) => term.trim().toLowerCase())
110
+ .filter(Boolean);
111
+ const execFileAsync = promisify(execFile);
112
+ const OPENCLAW_SKILL_NAME = "silicaclaw-broadcast";
74
113
 
75
114
  function readWorkspaceVersion(workspaceRoot: string): string {
76
115
  const pkgFile = resolve(workspaceRoot, "package.json");
@@ -109,6 +148,193 @@ function resolveStorageRoot(workspaceRoot: string, cwd = process.cwd()): string
109
148
  return cwd;
110
149
  }
111
150
 
151
+ function resolveExecutableInPath(binName: string): string | null {
152
+ const pathValue = String(process.env.PATH || "").trim();
153
+ if (!pathValue) return null;
154
+ const pathEntries = pathValue.split(":").map((item) => item.trim()).filter(Boolean);
155
+ for (const entry of pathEntries) {
156
+ const candidate = resolve(entry, binName);
157
+ if (!existsSync(candidate)) continue;
158
+ try {
159
+ accessSync(candidate, constants.X_OK);
160
+ return candidate;
161
+ } catch {
162
+ // ignore non-executable matches
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function existingPathOrNull(filePath: string): string | null {
169
+ return existsSync(filePath) ? filePath : null;
170
+ }
171
+
172
+ function detectOpenClawInstallation(workspaceRoot: string) {
173
+ const workspaceDir = resolve(workspaceRoot, ".openclaw");
174
+ const homeDir = resolve(process.env.HOME || "", ".openclaw");
175
+ const commandPath = resolveExecutableInPath("openclaw");
176
+
177
+ const workspaceIdentityPath = existingPathOrNull(resolve(workspaceDir, "identity.json"));
178
+ const workspaceProfilePath = existingPathOrNull(resolve(workspaceDir, "profile.json"));
179
+ const workspaceSocialPath = existingPathOrNull(resolve(workspaceDir, "social.md"));
180
+ const workspaceSkillsPath = existingPathOrNull(resolve(workspaceDir, "skills"));
181
+ const homeIdentityPath = existingPathOrNull(resolve(homeDir, "identity.json"));
182
+ const homeProfilePath = existingPathOrNull(resolve(homeDir, "profile.json"));
183
+ const homeSocialPath = existingPathOrNull(resolve(homeDir, "social.md"));
184
+ const homeSkillsPath = existingPathOrNull(resolve(homeDir, "skills"));
185
+
186
+ const workspaceDetected = Boolean(
187
+ existsSync(workspaceDir) ||
188
+ workspaceIdentityPath ||
189
+ workspaceProfilePath ||
190
+ workspaceSocialPath ||
191
+ workspaceSkillsPath
192
+ );
193
+ const homeDetected = Boolean(
194
+ existsSync(homeDir) || homeIdentityPath || homeProfilePath || homeSocialPath || homeSkillsPath
195
+ );
196
+
197
+ return {
198
+ detected: Boolean(commandPath || workspaceDetected || homeDetected),
199
+ detection_mode: commandPath
200
+ ? "command"
201
+ : workspaceDetected
202
+ ? "workspace"
203
+ : homeDetected
204
+ ? "home"
205
+ : "not_found",
206
+ command_path: commandPath,
207
+ workspace_dir: workspaceDir,
208
+ home_dir: homeDir,
209
+ workspace_dir_exists: existsSync(workspaceDir),
210
+ home_dir_exists: existsSync(homeDir),
211
+ workspace_identity_path: workspaceIdentityPath,
212
+ workspace_profile_path: workspaceProfilePath,
213
+ workspace_social_path: workspaceSocialPath,
214
+ workspace_skills_path: workspaceSkillsPath,
215
+ home_identity_path: homeIdentityPath,
216
+ home_profile_path: homeProfilePath,
217
+ home_social_path: homeSocialPath,
218
+ home_skills_path: homeSkillsPath,
219
+ } as const;
220
+ }
221
+
222
+ function detectOpenClawRuntime() {
223
+ const result = spawnSync("ps", ["-Ao", "pid=,ppid=,command="], {
224
+ encoding: "utf8",
225
+ });
226
+ const stdout = String(result.stdout || "");
227
+ const lines = stdout
228
+ .split("\n")
229
+ .map((line) => line.trim())
230
+ .filter(Boolean);
231
+
232
+ const processes = lines
233
+ .map((line) => {
234
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
235
+ if (!match) return null;
236
+ const command = match[3] || "";
237
+ const lower = command.toLowerCase();
238
+ const isOpenClaw =
239
+ lower.includes(" openclaw ") ||
240
+ lower.endsWith(" openclaw") ||
241
+ lower.includes("/openclaw ") ||
242
+ lower.includes("openclaw.mjs") ||
243
+ lower.includes("openclaw gateway") ||
244
+ lower.includes("openclaw agent") ||
245
+ lower.includes("openclaw message");
246
+ if (!isOpenClaw) return null;
247
+ return {
248
+ pid: Number(match[1]),
249
+ ppid: Number(match[2]),
250
+ command,
251
+ };
252
+ })
253
+ .filter((item): item is { pid: number; ppid: number; command: string } => Boolean(item));
254
+
255
+ return {
256
+ running: processes.length > 0,
257
+ process_count: processes.length,
258
+ processes: processes.slice(0, 10),
259
+ detection_error: result.status === 0 ? null : String(result.stderr || "ps failed"),
260
+ } as const;
261
+ }
262
+
263
+ function detectOpenClawSkillInstallation() {
264
+ const homeDir = resolve(process.env.HOME || "", ".openclaw");
265
+ const workspaceSkillRoot = resolve(homeDir, "workspace", "skills");
266
+ const legacySkillRoot = resolve(homeDir, "skills");
267
+ const workspaceSkillPath = resolve(workspaceSkillRoot, OPENCLAW_SKILL_NAME, "SKILL.md");
268
+ const legacySkillPath = resolve(legacySkillRoot, OPENCLAW_SKILL_NAME, "SKILL.md");
269
+ const workspaceInstalled = existsSync(workspaceSkillPath);
270
+ const legacyInstalled = existsSync(legacySkillPath);
271
+
272
+ return {
273
+ installed: workspaceInstalled || legacyInstalled,
274
+ install_mode: workspaceInstalled ? "workspace" : legacyInstalled ? "legacy" : "not_installed",
275
+ workspace_skill_root: workspaceSkillRoot,
276
+ legacy_skill_root: legacySkillRoot,
277
+ workspace_skill_path: workspaceInstalled ? workspaceSkillPath : null,
278
+ legacy_skill_path: legacyInstalled ? legacySkillPath : null,
279
+ } as const;
280
+ }
281
+
282
+ function detectOwnerDeliveryStatus(params: {
283
+ workspaceRoot: string;
284
+ connectedToSilicaclaw: boolean;
285
+ openclawRunning: boolean;
286
+ skillInstalled: boolean;
287
+ }) {
288
+ const forwardCommand = String(process.env.OPENCLAW_OWNER_FORWARD_CMD || "").trim();
289
+ const ownerChannel = String(process.env.OPENCLAW_OWNER_CHANNEL || "").trim();
290
+ const ownerTarget = String(process.env.OPENCLAW_OWNER_TARGET || "").trim();
291
+ const ownerAccount = String(process.env.OPENCLAW_OWNER_ACCOUNT || "").trim();
292
+ const explicitOpenClawBin = String(process.env.OPENCLAW_BIN || "").trim();
293
+ const configuredSourceDir = String(process.env.OPENCLAW_SOURCE_DIR || "").trim();
294
+ const defaultSourceDir = resolve(params.workspaceRoot, "..", "openclaw");
295
+ const openclawSourceDir = configuredSourceDir || defaultSourceDir;
296
+ const openclawSourceEntry = existingPathOrNull(resolve(openclawSourceDir, "openclaw.mjs"));
297
+ const openclawCommandResolvable = Boolean(explicitOpenClawBin || resolveExecutableInPath("openclaw") || openclawSourceEntry);
298
+ const bridgeMessagesReadable = params.connectedToSilicaclaw && params.openclawRunning && params.skillInstalled;
299
+ const forwardCommandConfigured = Boolean(forwardCommand);
300
+ const ownerRouteConfigured = Boolean(ownerChannel && ownerTarget);
301
+ const ready =
302
+ bridgeMessagesReadable && forwardCommandConfigured && ownerRouteConfigured && openclawCommandResolvable;
303
+
304
+ let reason = "";
305
+ if (!params.connectedToSilicaclaw) {
306
+ reason = "SilicaClaw social bridge is not connected yet, so there is no broadcast stream for OpenClaw to learn.";
307
+ } else if (!params.openclawRunning) {
308
+ reason = "OpenClaw is not running on this machine yet, so broadcast learning and owner forwarding are idle.";
309
+ } else if (!params.skillInstalled) {
310
+ reason = "OpenClaw is running, but the silicaclaw-broadcast skill is not installed yet.";
311
+ } else if (!forwardCommandConfigured) {
312
+ reason = "Broadcast learning is ready, but OPENCLAW_OWNER_FORWARD_CMD is not configured yet.";
313
+ } else if (!ownerRouteConfigured) {
314
+ reason = "The owner forward command exists, but OPENCLAW_OWNER_CHANNEL and OPENCLAW_OWNER_TARGET are still missing.";
315
+ } else if (!openclawCommandResolvable) {
316
+ reason = "Owner forwarding is configured, but no runnable OpenClaw CLI or source checkout was found.";
317
+ } else {
318
+ reason = "This machine can read SilicaClaw broadcasts and route owner summaries through OpenClaw.";
319
+ }
320
+
321
+ return {
322
+ supported: bridgeMessagesReadable,
323
+ mode: "public-broadcast-via-openclaw" as const,
324
+ send_to_owner_via_openclaw: ready,
325
+ bridge_messages_readable: bridgeMessagesReadable,
326
+ forward_command_configured: forwardCommandConfigured,
327
+ openclaw_command_resolvable: openclawCommandResolvable,
328
+ ready,
329
+ forward_command: forwardCommand || null,
330
+ owner_channel: ownerChannel || null,
331
+ owner_target: ownerTarget || null,
332
+ owner_account: ownerAccount || null,
333
+ openclaw_source_dir: openclawSourceEntry ? openclawSourceDir : null,
334
+ reason,
335
+ };
336
+ }
337
+
112
338
  function hasMeaningfulJson(filePath: string): boolean {
113
339
  if (!existsSync(filePath)) return false;
114
340
  try {
@@ -128,7 +354,14 @@ function migrateLegacyDataIfNeeded(workspaceRoot: string, storageRoot: string):
128
354
  const legacyDataDir = resolve(workspaceRoot, "data");
129
355
  const targetDataDir = resolve(storageRoot, "data");
130
356
  if (legacyDataDir === targetDataDir) return;
131
- const files = ["identity.json", "profile.json", "cache.json", "logs.json"];
357
+ const files = [
358
+ "identity.json",
359
+ "profile.json",
360
+ "cache.json",
361
+ "logs.json",
362
+ "social-messages.json",
363
+ "social-message-observations.json",
364
+ ];
132
365
  for (const file of files) {
133
366
  const src = resolve(legacyDataDir, file);
134
367
  const dst = resolve(targetDataDir, file);
@@ -179,29 +412,151 @@ type IntegrationStatusSummary = {
179
412
  status_line: string;
180
413
  };
181
414
 
182
- class LocalNodeService {
415
+ type SocialMessageView = SocialMessageRecord & {
416
+ is_self: boolean;
417
+ online: boolean;
418
+ last_seen_at: number | null;
419
+ observation_count: number;
420
+ remote_observation_count: number;
421
+ last_observed_at: number | null;
422
+ delivery_status: "local-only" | "remote-observed";
423
+ };
424
+
425
+ type RuntimeMessageGovernance = SocialMessageGovernanceConfig;
426
+
427
+ type OpenClawBridgeStatus = {
428
+ enabled: boolean;
429
+ connected_to_silicaclaw: boolean;
430
+ public_enabled: boolean;
431
+ message_broadcast_enabled: boolean;
432
+ network_mode: "local" | "lan" | "global-preview";
433
+ adapter: string;
434
+ agent_id: string;
435
+ display_name: string;
436
+ identity_source: "silicaclaw-existing" | "openclaw-existing" | "silicaclaw-generated";
437
+ openclaw_identity_source_path: string | null;
438
+ social_source_path: string | null;
439
+ openclaw_installation: {
440
+ detected: boolean;
441
+ detection_mode: "command" | "workspace" | "home" | "not_found";
442
+ command_path: string | null;
443
+ workspace_dir: string;
444
+ home_dir: string;
445
+ workspace_dir_exists: boolean;
446
+ home_dir_exists: boolean;
447
+ workspace_identity_path: string | null;
448
+ workspace_profile_path: string | null;
449
+ workspace_social_path: string | null;
450
+ workspace_skills_path: string | null;
451
+ home_identity_path: string | null;
452
+ home_profile_path: string | null;
453
+ home_social_path: string | null;
454
+ home_skills_path: string | null;
455
+ };
456
+ openclaw_runtime: {
457
+ running: boolean;
458
+ process_count: number;
459
+ processes: Array<{
460
+ pid: number;
461
+ ppid: number;
462
+ command: string;
463
+ }>;
464
+ detection_error: string | null;
465
+ };
466
+ skill_learning: {
467
+ available: boolean;
468
+ installed: boolean;
469
+ install_mode: "workspace" | "legacy" | "not_installed";
470
+ installed_skill_path: string | null;
471
+ install_action: {
472
+ supported: boolean;
473
+ endpoint: string;
474
+ recommended_command: string;
475
+ };
476
+ skills: Array<{
477
+ key: "get_profile" | "list_messages" | "watch_messages" | "send_message";
478
+ summary: string;
479
+ endpoint: string;
480
+ }>;
481
+ };
482
+ owner_delivery: {
483
+ supported: boolean;
484
+ mode: "public-broadcast-via-openclaw";
485
+ send_to_owner_via_openclaw: boolean;
486
+ bridge_messages_readable: boolean;
487
+ forward_command_configured: boolean;
488
+ openclaw_command_resolvable: boolean;
489
+ ready: boolean;
490
+ forward_command: string | null;
491
+ owner_channel: string | null;
492
+ owner_target: string | null;
493
+ owner_account: string | null;
494
+ openclaw_source_dir: string | null;
495
+ reason: string;
496
+ };
497
+ endpoints: {
498
+ status: string;
499
+ profile: string;
500
+ messages: string;
501
+ send_message: string;
502
+ install_skill: string;
503
+ };
504
+ };
505
+
506
+ type OpenClawBridgeConfigView = {
507
+ bridge_api_base: string;
508
+ openclaw_detected: boolean;
509
+ openclaw_running: boolean;
510
+ openclaw_workspace_skill_dir: string;
511
+ openclaw_legacy_skill_dir: string;
512
+ silicaclaw_env_template_path: string;
513
+ recommended_skill_name: string;
514
+ recommended_install_command: string;
515
+ recommended_owner_forward_env: {
516
+ OPENCLAW_SOURCE_DIR: string;
517
+ OPENCLAW_OWNER_CHANNEL: string;
518
+ OPENCLAW_OWNER_TARGET: string;
519
+ OPENCLAW_OWNER_ACCOUNT: string;
520
+ OPENCLAW_OWNER_FORWARD_CMD: string;
521
+ };
522
+ owner_forward_command_example: string;
523
+ notes: string[];
524
+ };
525
+
526
+ export class LocalNodeService {
183
527
  private workspaceRoot: string;
184
528
  private storageRoot: string;
185
529
  private identityRepo: IdentityRepo;
186
530
  private profileRepo: ProfileRepo;
187
531
  private cacheRepo: CacheRepo;
188
532
  private logRepo: LogRepo;
533
+ private socialMessageGovernanceRepo: SocialMessageGovernanceRepo;
534
+ private socialMessageRepo: SocialMessageRepo;
535
+ private socialMessageObservationRepo: SocialMessageObservationRepo;
189
536
  private socialRuntimeRepo: SocialRuntimeRepo;
190
537
 
191
538
  private identity: AgentIdentity | null = null;
192
539
  private profile: PublicProfile | null = null;
193
540
  private directory: DirectoryState = createEmptyDirectoryState();
541
+ private socialMessages: SocialMessageRecord[] = [];
542
+ private socialMessageObservations: SocialMessageObservationRecord[] = [];
543
+ private messageGovernance: RuntimeMessageGovernance;
194
544
 
195
545
  private receivedCount = 0;
196
546
  private broadcastCount = 0;
197
547
  private lastMessageAt = 0;
198
548
  private lastBroadcastAt = 0;
549
+ private lastBroadcastErrorAt = 0;
550
+ private lastBroadcastError: string | null = null;
551
+ private broadcastFailureCount = 0;
199
552
  private broadcaster: NodeJS.Timeout | null = null;
200
553
  private subscriptionsBound = false;
201
554
  private broadcastEnabled = true;
202
555
 
203
556
  private receivedByTopic: Record<string, number> = {};
204
557
  private publishedByTopic: Record<string, number> = {};
558
+ private outboundMessageTimestamps: number[] = [];
559
+ private inboundMessageTimestampsByAgent: Record<string, number[]> = {};
205
560
 
206
561
  private initState: InitState = {
207
562
  identity_auto_created: false,
@@ -212,7 +567,7 @@ class LocalNodeService {
212
567
 
213
568
  private network: NetworkAdapter;
214
569
  private adapterMode: "mock" | "local-event-bus" | "real-preview" | "webrtc-preview" | "relay-preview";
215
- private networkMode: "local" | "lan" | "global-preview" = "lan";
570
+ private networkMode: "local" | "lan" | "global-preview" = "global-preview";
216
571
  private networkNamespace: string;
217
572
  private networkPort: number | null;
218
573
  private socialConfig: SocialConfig;
@@ -232,9 +587,9 @@ class LocalNodeService {
232
587
  private webrtcBootstrapSources: string[] = [];
233
588
  private appVersion = "unknown";
234
589
 
235
- constructor() {
236
- this.workspaceRoot = resolveWorkspaceRoot();
237
- this.storageRoot = resolveStorageRoot(this.workspaceRoot);
590
+ constructor(options?: { workspaceRoot?: string; storageRoot?: string }) {
591
+ this.workspaceRoot = options?.workspaceRoot || resolveWorkspaceRoot();
592
+ this.storageRoot = options?.storageRoot || resolveStorageRoot(this.workspaceRoot);
238
593
  this.appVersion = readWorkspaceVersion(this.workspaceRoot);
239
594
  migrateLegacyDataIfNeeded(this.workspaceRoot, this.storageRoot);
240
595
 
@@ -242,7 +597,11 @@ class LocalNodeService {
242
597
  this.profileRepo = new ProfileRepo(this.storageRoot);
243
598
  this.cacheRepo = new CacheRepo(this.storageRoot);
244
599
  this.logRepo = new LogRepo(this.storageRoot);
600
+ this.socialMessageGovernanceRepo = new SocialMessageGovernanceRepo(this.storageRoot);
601
+ this.socialMessageRepo = new SocialMessageRepo(this.storageRoot);
602
+ this.socialMessageObservationRepo = new SocialMessageObservationRepo(this.storageRoot);
245
603
  this.socialRuntimeRepo = new SocialRuntimeRepo(this.storageRoot);
604
+ this.messageGovernance = this.defaultMessageGovernance();
246
605
 
247
606
  let loadedSocial = loadSocialConfig(this.workspaceRoot);
248
607
  if (!loadedSocial.meta.found) {
@@ -282,7 +641,14 @@ class LocalNodeService {
282
641
  );
283
642
 
284
643
  if (this.profile?.public_enabled && this.broadcastEnabled) {
285
- await this.broadcastNow("adapter_start");
644
+ try {
645
+ await this.broadcastNow("adapter_start");
646
+ } catch (error) {
647
+ await this.log(
648
+ "warn",
649
+ `Initial broadcast failed: ${error instanceof Error ? error.message : String(error)}`
650
+ );
651
+ }
286
652
  }
287
653
 
288
654
  this.startBroadcastLoop();
@@ -321,6 +687,9 @@ class LocalNodeService {
321
687
  public_enabled: Boolean(this.profile?.public_enabled),
322
688
  broadcast_enabled: this.broadcastEnabled,
323
689
  last_broadcast_at: this.lastBroadcastAt,
690
+ last_broadcast_error_at: this.lastBroadcastErrorAt,
691
+ last_broadcast_error: this.lastBroadcastError,
692
+ broadcast_failure_count: this.broadcastFailureCount,
324
693
  discovered_count: profiles.length,
325
694
  online_count: onlineCount,
326
695
  offline_count: Math.max(0, profiles.length - onlineCount),
@@ -332,6 +701,14 @@ class LocalNodeService {
332
701
  enabled: this.socialConfig.enabled,
333
702
  source_path: this.socialSourcePath,
334
703
  network_mode: this.networkMode,
704
+ mode_explainer: this.getModeExplainer(),
705
+ governance: {
706
+ send_limit: { max: this.messageGovernance.send_limit_max, window_ms: this.messageGovernance.send_window_ms },
707
+ receive_limit: { max: this.messageGovernance.receive_limit_max, window_ms: this.messageGovernance.receive_window_ms },
708
+ duplicate_window_ms: this.messageGovernance.duplicate_window_ms,
709
+ blocked_agent_count: this.messageGovernance.blocked_agent_ids.length,
710
+ blocked_term_count: this.messageGovernance.blocked_terms.length,
711
+ },
335
712
  },
336
713
  };
337
714
  }
@@ -346,8 +723,11 @@ class LocalNodeService {
346
723
  mode: this.networkMode,
347
724
  received_count: this.receivedCount,
348
725
  broadcast_count: this.broadcastCount,
726
+ broadcast_failure_count: this.broadcastFailureCount,
349
727
  last_message_at: this.lastMessageAt,
350
728
  last_broadcast_at: this.lastBroadcastAt,
729
+ last_broadcast_error_at: this.lastBroadcastErrorAt,
730
+ last_broadcast_error: this.lastBroadcastError,
351
731
  received_by_topic: this.receivedByTopic,
352
732
  published_by_topic: this.publishedByTopic,
353
733
  peers_discovered: peerCount,
@@ -445,6 +825,7 @@ class LocalNodeService {
445
825
  : this.adapterMode === "webrtc-preview" || this.adapterMode === "relay-preview"
446
826
  ? "internet-preview"
447
827
  : "local-process",
828
+ mode_explainer: this.getModeExplainer(),
448
829
  };
449
830
  }
450
831
 
@@ -459,8 +840,11 @@ class LocalNodeService {
459
840
  message_counters: {
460
841
  received_total: this.receivedCount,
461
842
  broadcast_total: this.broadcastCount,
843
+ broadcast_failures_total: this.broadcastFailureCount,
462
844
  last_message_at: this.lastMessageAt,
463
845
  last_broadcast_at: this.lastBroadcastAt,
846
+ last_broadcast_error_at: this.lastBroadcastErrorAt,
847
+ last_broadcast_error: this.lastBroadcastError,
464
848
  received_by_topic: this.receivedByTopic,
465
849
  published_by_topic: this.publishedByTopic,
466
850
  },
@@ -825,6 +1209,301 @@ class LocalNodeService {
825
1209
  return this.identity;
826
1210
  }
827
1211
 
1212
+ getSocialMessages(limit = 50, options?: { agent_id?: string | null }): {
1213
+ total: number;
1214
+ items: SocialMessageView[];
1215
+ governance: {
1216
+ send_limit: { max: number; window_ms: number };
1217
+ receive_limit: { max: number; window_ms: number };
1218
+ duplicate_window_ms: number;
1219
+ blocked_agent_count: number;
1220
+ blocked_term_count: number;
1221
+ };
1222
+ } {
1223
+ const resolvedLimit = Math.max(1, Math.min(200, Number(limit) || 50));
1224
+ this.ensureLocalDirectoryBaseline();
1225
+ this.compactCacheInMemory();
1226
+ const agentId = String(options?.agent_id || "").trim();
1227
+ const filtered = agentId
1228
+ ? this.socialMessages.filter((message) => message.agent_id === agentId)
1229
+ : this.socialMessages;
1230
+ return {
1231
+ total: filtered.length,
1232
+ items: filtered.slice(0, resolvedLimit).map((message) => {
1233
+ const profile = this.directory.profiles[message.agent_id];
1234
+ const lastSeenAt = this.directory.presence[message.agent_id] ?? 0;
1235
+ const observations = this.socialMessageObservations.filter((item) => item.message_id === message.message_id);
1236
+ const remoteObservationCount = observations.filter((item) => item.observer_agent_id !== message.agent_id).length;
1237
+ const lastObservedAt = observations.length > 0 ? Math.max(...observations.map((item) => item.observed_at)) : 0;
1238
+ return {
1239
+ ...message,
1240
+ display_name: profile?.display_name || message.display_name || "Unnamed",
1241
+ is_self: message.agent_id === this.identity?.agent_id,
1242
+ online: isAgentOnline(lastSeenAt, Date.now(), PRESENCE_TTL_MS),
1243
+ last_seen_at: lastSeenAt || null,
1244
+ observation_count: observations.length,
1245
+ remote_observation_count: remoteObservationCount,
1246
+ last_observed_at: lastObservedAt || null,
1247
+ delivery_status: remoteObservationCount > 0 ? "remote-observed" : "local-only",
1248
+ };
1249
+ }),
1250
+ governance: {
1251
+ send_limit: { max: this.messageGovernance.send_limit_max, window_ms: this.messageGovernance.send_window_ms },
1252
+ receive_limit: { max: this.messageGovernance.receive_limit_max, window_ms: this.messageGovernance.receive_window_ms },
1253
+ duplicate_window_ms: this.messageGovernance.duplicate_window_ms,
1254
+ blocked_agent_count: this.messageGovernance.blocked_agent_ids.length,
1255
+ blocked_term_count: this.messageGovernance.blocked_terms.length,
1256
+ },
1257
+ };
1258
+ }
1259
+
1260
+ getOpenClawBridgeStatus(): OpenClawBridgeStatus {
1261
+ const integration = this.getIntegrationStatus();
1262
+ const openclawInstallation = detectOpenClawInstallation(this.workspaceRoot);
1263
+ const openclawRuntime = detectOpenClawRuntime();
1264
+ const skillInstallation = detectOpenClawSkillInstallation();
1265
+ const ownerDelivery = detectOwnerDeliveryStatus({
1266
+ workspaceRoot: this.workspaceRoot,
1267
+ connectedToSilicaclaw: integration.connected_to_silicaclaw,
1268
+ openclawRunning: openclawRuntime.running,
1269
+ skillInstalled: skillInstallation.installed,
1270
+ });
1271
+ return {
1272
+ enabled: this.socialConfig.enabled,
1273
+ connected_to_silicaclaw: integration.connected_to_silicaclaw,
1274
+ public_enabled: integration.public_enabled,
1275
+ message_broadcast_enabled: this.socialConfig.discovery.allow_message_broadcast && this.broadcastEnabled,
1276
+ network_mode: this.networkMode,
1277
+ adapter: this.adapterMode,
1278
+ agent_id: this.identity?.agent_id ?? "",
1279
+ display_name: this.profile?.display_name ?? "",
1280
+ identity_source: this.resolvedIdentitySource,
1281
+ openclaw_identity_source_path: this.resolvedOpenClawIdentityPath,
1282
+ social_source_path: this.socialSourcePath,
1283
+ openclaw_installation: openclawInstallation,
1284
+ openclaw_runtime: openclawRuntime,
1285
+ skill_learning: {
1286
+ available: integration.connected_to_silicaclaw && openclawRuntime.running,
1287
+ installed: skillInstallation.installed,
1288
+ install_mode: skillInstallation.install_mode,
1289
+ installed_skill_path: skillInstallation.workspace_skill_path || skillInstallation.legacy_skill_path,
1290
+ install_action: {
1291
+ supported: true,
1292
+ endpoint: "/api/openclaw/bridge/skill-install",
1293
+ recommended_command: "silicaclaw openclaw-skill-install",
1294
+ },
1295
+ skills: [
1296
+ {
1297
+ key: "get_profile",
1298
+ summary: "Read SilicaClaw identity/profile so OpenClaw can align its runtime persona.",
1299
+ endpoint: "/api/openclaw/bridge/profile",
1300
+ },
1301
+ {
1302
+ key: "list_messages",
1303
+ summary: "Read recent public broadcast messages observed by this SilicaClaw node.",
1304
+ endpoint: "/api/openclaw/bridge/messages",
1305
+ },
1306
+ {
1307
+ key: "watch_messages",
1308
+ summary: "Poll the recent broadcast feed so OpenClaw can learn from new public messages.",
1309
+ endpoint: "/api/openclaw/bridge/messages",
1310
+ },
1311
+ {
1312
+ key: "send_message",
1313
+ summary: "Publish a signed public broadcast through SilicaClaw on behalf of OpenClaw.",
1314
+ endpoint: "/api/openclaw/bridge/message",
1315
+ },
1316
+ ],
1317
+ },
1318
+ owner_delivery: ownerDelivery,
1319
+ endpoints: {
1320
+ status: "/api/openclaw/bridge",
1321
+ profile: "/api/openclaw/bridge/profile",
1322
+ messages: "/api/openclaw/bridge/messages",
1323
+ send_message: "/api/openclaw/bridge/message",
1324
+ install_skill: "/api/openclaw/bridge/skill-install",
1325
+ },
1326
+ };
1327
+ }
1328
+
1329
+ async installOpenClawSkill() {
1330
+ const scriptPath = resolve(this.workspaceRoot, "scripts", "install-openclaw-skill.mjs");
1331
+ const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
1332
+ cwd: this.workspaceRoot,
1333
+ env: process.env,
1334
+ maxBuffer: 1024 * 1024,
1335
+ });
1336
+ const parsed = JSON.parse(String(stdout || "{}"));
1337
+ return {
1338
+ ...parsed,
1339
+ bridge: this.getOpenClawBridgeStatus(),
1340
+ };
1341
+ }
1342
+
1343
+ getOpenClawBridgeProfile() {
1344
+ return {
1345
+ identity: this.getIdentity(),
1346
+ profile: this.getProfile(),
1347
+ public_profile_preview: this.getPublicProfilePreview(),
1348
+ integration: this.getIntegrationSummary(),
1349
+ bridge: this.getOpenClawBridgeStatus(),
1350
+ };
1351
+ }
1352
+
1353
+ getOpenClawBridgeConfig(): OpenClawBridgeConfigView {
1354
+ const homeDir = resolve(process.env.HOME || "", ".openclaw");
1355
+ const workspaceSkillDir = resolve(homeDir, "workspace", "skills");
1356
+ const legacySkillDir = resolve(homeDir, "skills");
1357
+ const openclawSourceDir = resolve(this.workspaceRoot, "..", "openclaw");
1358
+
1359
+ return {
1360
+ bridge_api_base: "http://localhost:4310",
1361
+ openclaw_detected: detectOpenClawInstallation(this.workspaceRoot).detected,
1362
+ openclaw_running: detectOpenClawRuntime().running,
1363
+ openclaw_workspace_skill_dir: workspaceSkillDir,
1364
+ openclaw_legacy_skill_dir: legacySkillDir,
1365
+ silicaclaw_env_template_path: resolve(this.workspaceRoot, "openclaw-owner-forward.env.example"),
1366
+ recommended_skill_name: "silicaclaw-broadcast",
1367
+ recommended_install_command: "silicaclaw openclaw-skill-install",
1368
+ recommended_owner_forward_env: {
1369
+ OPENCLAW_SOURCE_DIR: openclawSourceDir,
1370
+ OPENCLAW_OWNER_CHANNEL: "<channel>",
1371
+ OPENCLAW_OWNER_TARGET: "<target>",
1372
+ OPENCLAW_OWNER_ACCOUNT: "",
1373
+ OPENCLAW_OWNER_FORWARD_CMD: "node scripts/send-to-owner-via-openclaw.mjs",
1374
+ },
1375
+ owner_forward_command_example: [
1376
+ `OPENCLAW_SOURCE_DIR='${openclawSourceDir}'`,
1377
+ "OPENCLAW_OWNER_CHANNEL='<channel>'",
1378
+ "OPENCLAW_OWNER_TARGET='<target>'",
1379
+ "OPENCLAW_OWNER_FORWARD_CMD='node scripts/send-to-owner-via-openclaw.mjs'",
1380
+ "node scripts/owner-forwarder-demo.mjs",
1381
+ ].join(" "),
1382
+ notes: [
1383
+ "Install and maintain the skill from SilicaClaw; do not edit OpenClaw core source for this integration.",
1384
+ "OpenClaw learns broadcasts via the installed skill under ~/.openclaw/workspace/skills/.",
1385
+ "Owner delivery runs through OpenClaw's own message channel stack after the skill forwards a summary.",
1386
+ "Sensitive computer control still requires OpenClaw's own owner approval and node permission flow.",
1387
+ ],
1388
+ };
1389
+ }
1390
+
1391
+ getRuntimeMessageGovernance() {
1392
+ return this.messageGovernance;
1393
+ }
1394
+
1395
+ async getMessageGovernanceView() {
1396
+ const logs = await this.logRepo.get();
1397
+ const recentEvents = logs
1398
+ .filter((entry) => (
1399
+ entry.message.includes("Rejected social message") ||
1400
+ entry.message.includes("Social message blocked") ||
1401
+ entry.message.includes("Social message throttled")
1402
+ ))
1403
+ .slice(0, 20);
1404
+
1405
+ return {
1406
+ policy: {
1407
+ send_limit: { max: this.messageGovernance.send_limit_max, window_ms: this.messageGovernance.send_window_ms },
1408
+ receive_limit: { max: this.messageGovernance.receive_limit_max, window_ms: this.messageGovernance.receive_window_ms },
1409
+ duplicate_window_ms: this.messageGovernance.duplicate_window_ms,
1410
+ blocked_agent_ids: Array.from(this.messageGovernance.blocked_agent_ids),
1411
+ blocked_terms: Array.from(this.messageGovernance.blocked_terms),
1412
+ },
1413
+ recent_events: recentEvents,
1414
+ };
1415
+ }
1416
+
1417
+ async updateMessageGovernance(input: Partial<RuntimeMessageGovernance>) {
1418
+ const next: RuntimeMessageGovernance = {
1419
+ send_limit_max: Math.max(1, Math.min(100, Number(input.send_limit_max ?? this.messageGovernance.send_limit_max) || this.messageGovernance.send_limit_max)),
1420
+ send_window_ms: Math.max(5_000, Math.min(3_600_000, Number(input.send_window_ms ?? this.messageGovernance.send_window_ms) || this.messageGovernance.send_window_ms)),
1421
+ receive_limit_max: Math.max(1, Math.min(200, Number(input.receive_limit_max ?? this.messageGovernance.receive_limit_max) || this.messageGovernance.receive_limit_max)),
1422
+ receive_window_ms: Math.max(5_000, Math.min(3_600_000, Number(input.receive_window_ms ?? this.messageGovernance.receive_window_ms) || this.messageGovernance.receive_window_ms)),
1423
+ duplicate_window_ms: Math.max(5_000, Math.min(3_600_000, Number(input.duplicate_window_ms ?? this.messageGovernance.duplicate_window_ms) || this.messageGovernance.duplicate_window_ms)),
1424
+ blocked_agent_ids: dedupeStrings(Array.isArray(input.blocked_agent_ids) ? input.blocked_agent_ids.map((item) => String(item || "").trim()) : this.messageGovernance.blocked_agent_ids),
1425
+ blocked_terms: dedupeStrings(Array.isArray(input.blocked_terms) ? input.blocked_terms.map((item) => String(item || "").trim().toLowerCase()) : this.messageGovernance.blocked_terms),
1426
+ };
1427
+ this.messageGovernance = next;
1428
+ await this.socialMessageGovernanceRepo.set(next);
1429
+ await this.log("info", "Runtime message governance updated");
1430
+ return this.getMessageGovernanceView();
1431
+ }
1432
+
1433
+ async sendSocialMessage(body: string, topic = DEFAULT_SOCIAL_MESSAGE_CHANNEL): Promise<{ sent: boolean; reason: string; message?: SocialMessageView; error?: string }> {
1434
+ if (!this.identity || !this.profile) {
1435
+ return { sent: false, reason: "missing_identity_or_profile" };
1436
+ }
1437
+ if (!this.profile.public_enabled) {
1438
+ return { sent: false, reason: "public_disabled" };
1439
+ }
1440
+ if (!this.broadcastEnabled) {
1441
+ return { sent: false, reason: "broadcast_paused" };
1442
+ }
1443
+ if (!this.socialConfig.discovery.allow_message_broadcast) {
1444
+ return { sent: false, reason: "message_broadcast_disabled" };
1445
+ }
1446
+
1447
+ const normalizedBody = this.normalizeSocialMessageBody(body);
1448
+ const normalizedTopic = String(topic || DEFAULT_SOCIAL_MESSAGE_CHANNEL).trim() || DEFAULT_SOCIAL_MESSAGE_CHANNEL;
1449
+ if (!normalizedBody) {
1450
+ return { sent: false, reason: "empty_message" };
1451
+ }
1452
+ if (normalizedBody.length > SOCIAL_MESSAGE_MAX_BODY_CHARS) {
1453
+ return { sent: false, reason: "message_too_long" };
1454
+ }
1455
+ if (this.containsBlockedMessageTerm(normalizedBody)) {
1456
+ await this.log("warn", `Social message blocked: blocked_term (${this.identity.agent_id.slice(0, 10)})`);
1457
+ return { sent: false, reason: "blocked_term" };
1458
+ }
1459
+ if (this.isRateLimited(this.outboundMessageTimestamps, this.messageGovernance.send_window_ms, this.messageGovernance.send_limit_max)) {
1460
+ await this.log("warn", `Social message throttled: rate_limited (${this.identity.agent_id.slice(0, 10)})`);
1461
+ return { sent: false, reason: "rate_limited" };
1462
+ }
1463
+ if (this.hasRecentDuplicateMessage(this.identity.agent_id, normalizedBody, normalizedTopic)) {
1464
+ await this.log("warn", `Social message blocked: duplicate_recent_message (${this.identity.agent_id.slice(0, 10)})`);
1465
+ return { sent: false, reason: "duplicate_recent_message" };
1466
+ }
1467
+
1468
+ const message = signSocialMessage({
1469
+ identity: this.identity,
1470
+ message_id: createHash("sha256")
1471
+ .update(`${this.identity.agent_id}:${normalizedTopic}:${Date.now()}:${normalizedBody}:${Math.random()}`, "utf8")
1472
+ .digest("hex"),
1473
+ display_name: this.profile.display_name,
1474
+ topic: normalizedTopic,
1475
+ body: normalizedBody,
1476
+ created_at: Date.now(),
1477
+ });
1478
+
1479
+ this.recordTimestamp(this.outboundMessageTimestamps, this.messageGovernance.send_window_ms, message.created_at);
1480
+ this.ingestSocialMessage(message);
1481
+ try {
1482
+ await this.publish(SOCIAL_MESSAGE_TOPIC, message);
1483
+ } catch (error) {
1484
+ const messageText = error instanceof Error ? error.message : String(error);
1485
+ this.lastBroadcastErrorAt = Date.now();
1486
+ this.lastBroadcastError = messageText;
1487
+ this.broadcastFailureCount += 1;
1488
+ await this.persistSocialMessages();
1489
+ await this.log("error", `Social message broadcast failed (${message.message_id.slice(0, 10)}): ${messageText}`);
1490
+ return {
1491
+ sent: false,
1492
+ reason: "publish_failed",
1493
+ error: messageText,
1494
+ message: this.getSocialMessages(1).items[0],
1495
+ };
1496
+ }
1497
+ await this.persistSocialMessages();
1498
+ await this.log("info", `Social message broadcast (${message.message_id.slice(0, 10)})`);
1499
+
1500
+ return {
1501
+ sent: true,
1502
+ reason: "sent",
1503
+ message: this.getSocialMessages(1).items[0],
1504
+ };
1505
+ }
1506
+
828
1507
  async getLogs() {
829
1508
  return this.logRepo.get();
830
1509
  }
@@ -935,7 +1614,7 @@ class LocalNodeService {
935
1614
  return { broadcast_enabled: this.broadcastEnabled };
936
1615
  }
937
1616
 
938
- async broadcastNow(reason = "manual"): Promise<{ sent: boolean; reason: string }> {
1617
+ async broadcastNow(reason = "manual"): Promise<{ sent: boolean; reason: string; error?: string }> {
939
1618
  if (!this.identity || !this.profile) {
940
1619
  return { sent: false, reason: "missing_identity_or_profile" };
941
1620
  }
@@ -953,14 +1632,25 @@ class LocalNodeService {
953
1632
  const presenceRecord = signPresence(this.identity, Date.now());
954
1633
  const indexRecords = buildIndexRecords(this.profile);
955
1634
 
956
- await this.publish("profile", profileRecord);
957
- await this.publish("presence", presenceRecord);
958
- for (const record of indexRecords) {
959
- await this.publish("index", record);
1635
+ try {
1636
+ await this.publish("profile", profileRecord);
1637
+ await this.publish("presence", presenceRecord);
1638
+ for (const record of indexRecords) {
1639
+ await this.publish("index", record);
1640
+ }
1641
+ } catch (error) {
1642
+ const message = error instanceof Error ? error.message : String(error);
1643
+ this.lastBroadcastErrorAt = Date.now();
1644
+ this.lastBroadcastError = message;
1645
+ this.broadcastFailureCount += 1;
1646
+ await this.log("error", `Broadcast failed (reason=${reason}): ${message}`);
1647
+ return { sent: false, reason: "publish_failed", error: message };
960
1648
  }
961
1649
 
962
1650
  this.lastBroadcastAt = Date.now();
963
1651
  this.broadcastCount += 1;
1652
+ this.lastBroadcastError = null;
1653
+ this.lastBroadcastErrorAt = 0;
964
1654
 
965
1655
  this.directory = ingestProfileRecord(this.directory, profileRecord);
966
1656
  this.directory = ingestPresenceRecord(this.directory, presenceRecord);
@@ -1019,6 +1709,12 @@ class LocalNodeService {
1019
1709
  await this.profileRepo.set(this.profile);
1020
1710
 
1021
1711
  this.directory = dedupeIndex(await this.cacheRepo.get());
1712
+ this.messageGovernance = {
1713
+ ...this.defaultMessageGovernance(),
1714
+ ...(await this.socialMessageGovernanceRepo.get()),
1715
+ };
1716
+ this.socialMessages = this.normalizeSocialMessages(await this.socialMessageRepo.get());
1717
+ this.socialMessageObservations = this.normalizeSocialMessageObservations(await this.socialMessageObservationRepo.get());
1022
1718
  this.directory = ingestProfileRecord(this.directory, { type: "profile", profile: this.profile });
1023
1719
  this.compactCacheInMemory();
1024
1720
  await this.persistCache();
@@ -1100,7 +1796,10 @@ class LocalNodeService {
1100
1796
  await this.socialRuntimeRepo.set(runtime);
1101
1797
  }
1102
1798
 
1103
- private async onMessage(topic: "profile" | "presence" | "index", data: unknown): Promise<void> {
1799
+ private async onMessage(
1800
+ topic: "profile" | "presence" | "index" | "social.message" | "social.message.observation",
1801
+ data: unknown
1802
+ ): Promise<void> {
1104
1803
  this.receivedCount += 1;
1105
1804
  this.receivedByTopic[topic] = (this.receivedByTopic[topic] ?? 0) + 1;
1106
1805
  this.lastMessageAt = Date.now();
@@ -1143,6 +1842,40 @@ class LocalNodeService {
1143
1842
  return;
1144
1843
  }
1145
1844
 
1845
+ if (topic === SOCIAL_MESSAGE_TOPIC) {
1846
+ const record = this.normalizeIncomingSocialMessage(data);
1847
+ if (!record) {
1848
+ return;
1849
+ }
1850
+ if (!verifySocialMessage(record)) {
1851
+ await this.log("warn", `Rejected social message with invalid signature (${record.message_id.slice(0, 10)})`);
1852
+ return;
1853
+ }
1854
+ const governanceReason = this.getIncomingSocialMessageRejectionReason(record);
1855
+ if (governanceReason) {
1856
+ await this.log("warn", `Rejected social message (${record.message_id.slice(0, 10)}): ${governanceReason}`);
1857
+ return;
1858
+ }
1859
+ this.ingestSocialMessage(record);
1860
+ await this.persistSocialMessages();
1861
+ await this.publishObservationForMessage(record);
1862
+ return;
1863
+ }
1864
+
1865
+ if (topic === SOCIAL_MESSAGE_OBSERVATION_TOPIC) {
1866
+ const record = this.normalizeIncomingSocialMessageObservation(data);
1867
+ if (!record) {
1868
+ return;
1869
+ }
1870
+ if (!verifySocialMessageObservation(record)) {
1871
+ await this.log("warn", `Rejected message observation with invalid signature (${record.observation_id.slice(0, 10)})`);
1872
+ return;
1873
+ }
1874
+ this.ingestSocialMessageObservation(record);
1875
+ await this.persistSocialMessageObservations();
1876
+ return;
1877
+ }
1878
+
1146
1879
  const record = data as IndexRefRecord;
1147
1880
  if (!record?.key || !record?.agent_id) {
1148
1881
  return;
@@ -1162,7 +1895,14 @@ class LocalNodeService {
1162
1895
  }
1163
1896
 
1164
1897
  this.broadcaster = setInterval(async () => {
1165
- await this.broadcastNow("interval");
1898
+ try {
1899
+ await this.broadcastNow("interval");
1900
+ } catch (error) {
1901
+ await this.log(
1902
+ "warn",
1903
+ `Scheduled broadcast failed: ${error instanceof Error ? error.message : String(error)}`
1904
+ );
1905
+ }
1166
1906
  }, BROADCAST_INTERVAL_MS);
1167
1907
  }
1168
1908
 
@@ -1179,6 +1919,12 @@ class LocalNodeService {
1179
1919
  this.network.subscribe("index", (data: IndexRefRecord) => {
1180
1920
  this.onMessage("index", data);
1181
1921
  });
1922
+ this.network.subscribe(SOCIAL_MESSAGE_TOPIC, (data: SocialMessageRecord) => {
1923
+ this.onMessage(SOCIAL_MESSAGE_TOPIC, data);
1924
+ });
1925
+ this.network.subscribe(SOCIAL_MESSAGE_OBSERVATION_TOPIC, (data: SocialMessageObservationRecord) => {
1926
+ this.onMessage(SOCIAL_MESSAGE_OBSERVATION_TOPIC, data);
1927
+ });
1182
1928
  this.subscriptionsBound = true;
1183
1929
  }
1184
1930
 
@@ -1302,6 +2048,14 @@ class LocalNodeService {
1302
2048
  await this.cacheRepo.set(this.directory);
1303
2049
  }
1304
2050
 
2051
+ private async persistSocialMessages(): Promise<void> {
2052
+ await this.socialMessageRepo.set(this.socialMessages);
2053
+ }
2054
+
2055
+ private async persistSocialMessageObservations(): Promise<void> {
2056
+ await this.socialMessageObservationRepo.set(this.socialMessageObservations);
2057
+ }
2058
+
1305
2059
  private async log(level: "info" | "warn" | "error", message: string): Promise<void> {
1306
2060
  await this.logRepo.append({
1307
2061
  level,
@@ -1391,6 +2145,40 @@ class LocalNodeService {
1391
2145
  return host ? `OpenClaw @ ${host}` : "OpenClaw Agent";
1392
2146
  }
1393
2147
 
2148
+ private getModeExplainer() {
2149
+ if (this.networkMode === "local") {
2150
+ return {
2151
+ mode: "local",
2152
+ short_label: "Local only",
2153
+ summary: "Only nodes inside the same local process bus are visible.",
2154
+ };
2155
+ }
2156
+ if (this.networkMode === "lan") {
2157
+ return {
2158
+ mode: "lan",
2159
+ short_label: "LAN broadcast",
2160
+ summary: "Uses UDP LAN broadcast. Peers usually need to be on the same local network.",
2161
+ };
2162
+ }
2163
+ return {
2164
+ mode: "global-preview",
2165
+ short_label: "Relay preview",
2166
+ summary: "Uses the public relay preview room so public nodes can find each other across the internet.",
2167
+ };
2168
+ }
2169
+
2170
+ private defaultMessageGovernance(): RuntimeMessageGovernance {
2171
+ return {
2172
+ send_limit_max: SOCIAL_MESSAGE_SEND_MAX_PER_WINDOW,
2173
+ send_window_ms: SOCIAL_MESSAGE_SEND_WINDOW_MS,
2174
+ receive_limit_max: SOCIAL_MESSAGE_RECEIVE_MAX_PER_WINDOW,
2175
+ receive_window_ms: SOCIAL_MESSAGE_RECEIVE_WINDOW_MS,
2176
+ duplicate_window_ms: SOCIAL_MESSAGE_DUPLICATE_WINDOW_MS,
2177
+ blocked_agent_ids: Array.from(SOCIAL_MESSAGE_BLOCKED_AGENT_IDS),
2178
+ blocked_terms: Array.from(SOCIAL_MESSAGE_BLOCKED_TERMS),
2179
+ };
2180
+ }
2181
+
1394
2182
  private adapterForMode(mode: "local" | "lan" | "global-preview"): "local-event-bus" | "real-preview" | "webrtc-preview" | "relay-preview" {
1395
2183
  if (mode === "local") return "local-event-bus";
1396
2184
  if (mode === "lan") return "real-preview";
@@ -1400,9 +2188,10 @@ class LocalNodeService {
1400
2188
  private applyResolvedNetworkConfig(): void {
1401
2189
  const modeEnv = String(NETWORK_MODE || "").trim();
1402
2190
  const resolvedMode =
1403
- modeEnv === "local" || modeEnv === "lan" || modeEnv === "global-preview"
2191
+ this.socialConfig.network.mode ||
2192
+ (modeEnv === "local" || modeEnv === "lan" || modeEnv === "global-preview"
1404
2193
  ? modeEnv
1405
- : this.socialConfig.network.mode || "lan";
2194
+ : "global-preview");
1406
2195
 
1407
2196
  this.networkMode = resolvedMode;
1408
2197
  this.networkNamespace = this.socialConfig.network.namespace || process.env.NETWORK_NAMESPACE || "silicaclaw.preview";
@@ -1479,6 +2268,242 @@ class LocalNodeService {
1479
2268
  this.webrtcBootstrapHints = bootstrapHints;
1480
2269
  this.webrtcBootstrapSources = [signalingSource, roomSource, seedPeersSource, bootstrapHintsSource];
1481
2270
  }
2271
+
2272
+ private normalizeSocialMessageBody(body: string): string {
2273
+ return String(body || "")
2274
+ .replace(/\r\n/g, "\n")
2275
+ .split("\n")
2276
+ .map((line) => line.trimEnd())
2277
+ .join("\n")
2278
+ .trim();
2279
+ }
2280
+
2281
+ private normalizeWindowTimestamps(timestamps: number[], windowMs: number, now = Date.now()): number[] {
2282
+ return timestamps.filter((timestamp) => now - timestamp <= windowMs);
2283
+ }
2284
+
2285
+ private recordTimestamp(timestamps: number[], windowMs: number, at = Date.now()): void {
2286
+ const cleaned = this.normalizeWindowTimestamps(timestamps, windowMs, at);
2287
+ cleaned.push(at);
2288
+ timestamps.splice(0, timestamps.length, ...cleaned);
2289
+ }
2290
+
2291
+ private isRateLimited(timestamps: number[], windowMs: number, maxCount: number, now = Date.now()): boolean {
2292
+ const cleaned = this.normalizeWindowTimestamps(timestamps, windowMs, now);
2293
+ timestamps.splice(0, timestamps.length, ...cleaned);
2294
+ return cleaned.length >= maxCount;
2295
+ }
2296
+
2297
+ private containsBlockedMessageTerm(body: string): boolean {
2298
+ const normalized = String(body || "").toLowerCase();
2299
+ return this.messageGovernance.blocked_terms.some((term) => normalized.includes(term));
2300
+ }
2301
+
2302
+ private hasRecentDuplicateMessage(agentId: string, body: string, topic: string, now = Date.now()): boolean {
2303
+ return this.socialMessages.some((item) => (
2304
+ item.agent_id === agentId &&
2305
+ item.topic === topic &&
2306
+ item.body === body &&
2307
+ now - item.created_at <= this.messageGovernance.duplicate_window_ms
2308
+ ));
2309
+ }
2310
+
2311
+ private getIncomingSocialMessageRejectionReason(record: SocialMessageRecord): string | null {
2312
+ const now = Date.now();
2313
+ if (this.messageGovernance.blocked_agent_ids.includes(record.agent_id)) {
2314
+ return "blocked_agent";
2315
+ }
2316
+ if (this.containsBlockedMessageTerm(record.body)) {
2317
+ return "blocked_term";
2318
+ }
2319
+ if (record.created_at - now > SOCIAL_MESSAGE_MAX_FUTURE_MS) {
2320
+ return "message_from_future";
2321
+ }
2322
+ if (now - record.created_at > SOCIAL_MESSAGE_MAX_AGE_MS) {
2323
+ return "message_too_old";
2324
+ }
2325
+ const timestamps = this.inboundMessageTimestampsByAgent[record.agent_id] || [];
2326
+ this.inboundMessageTimestampsByAgent[record.agent_id] = timestamps;
2327
+ if (this.isRateLimited(timestamps, this.messageGovernance.receive_window_ms, this.messageGovernance.receive_limit_max, now)) {
2328
+ return "remote_rate_limited";
2329
+ }
2330
+ if (this.hasRecentDuplicateMessage(record.agent_id, record.body, record.topic, now)) {
2331
+ return "duplicate_recent_message";
2332
+ }
2333
+ this.recordTimestamp(timestamps, this.messageGovernance.receive_window_ms, now);
2334
+ return null;
2335
+ }
2336
+
2337
+ private canPublishMessageObservation(): boolean {
2338
+ return Boolean(
2339
+ this.identity &&
2340
+ this.profile?.public_enabled &&
2341
+ this.broadcastEnabled &&
2342
+ this.socialConfig.discovery.allow_message_broadcast
2343
+ );
2344
+ }
2345
+
2346
+ private async publishObservationForMessage(message: SocialMessageRecord): Promise<void> {
2347
+ if (!this.identity || !this.profile || !this.canPublishMessageObservation()) {
2348
+ return;
2349
+ }
2350
+ if (message.agent_id === this.identity.agent_id) {
2351
+ return;
2352
+ }
2353
+ const existing = this.socialMessageObservations.find((item) => (
2354
+ item.message_id === message.message_id && item.observer_agent_id === this.identity?.agent_id
2355
+ ));
2356
+ if (existing) {
2357
+ return;
2358
+ }
2359
+ const observation = signSocialMessageObservation({
2360
+ identity: this.identity,
2361
+ observation_id: createHash("sha256")
2362
+ .update(`${message.message_id}:${this.identity.agent_id}:${Date.now()}`, "utf8")
2363
+ .digest("hex"),
2364
+ message_id: message.message_id,
2365
+ observed_agent_id: message.agent_id,
2366
+ observer_display_name: this.profile.display_name,
2367
+ observed_at: Date.now(),
2368
+ });
2369
+ this.ingestSocialMessageObservation(observation);
2370
+ await this.publish(SOCIAL_MESSAGE_OBSERVATION_TOPIC, observation);
2371
+ await this.persistSocialMessageObservations();
2372
+ }
2373
+
2374
+ private normalizeIncomingSocialMessage(value: unknown): SocialMessageRecord | null {
2375
+ if (typeof value !== "object" || value === null) {
2376
+ return null;
2377
+ }
2378
+ const record = value as Partial<SocialMessageRecord>;
2379
+ const body = this.normalizeSocialMessageBody(String(record.body || ""));
2380
+ const agentId = String(record.agent_id || "").trim();
2381
+ const displayName = String(record.display_name || "").trim();
2382
+ const topic = String(record.topic || DEFAULT_SOCIAL_MESSAGE_CHANNEL).trim() || DEFAULT_SOCIAL_MESSAGE_CHANNEL;
2383
+ const messageId = String(record.message_id || "").trim();
2384
+ const createdAt = Number(record.created_at || 0);
2385
+ if (
2386
+ record.type !== SOCIAL_MESSAGE_TOPIC ||
2387
+ !messageId ||
2388
+ !agentId ||
2389
+ typeof record.public_key !== "string" ||
2390
+ !String(record.public_key).trim() ||
2391
+ !body ||
2392
+ typeof record.signature !== "string" ||
2393
+ !String(record.signature).trim() ||
2394
+ !Number.isFinite(createdAt) ||
2395
+ body.length > SOCIAL_MESSAGE_MAX_BODY_CHARS
2396
+ ) {
2397
+ return null;
2398
+ }
2399
+ return {
2400
+ type: SOCIAL_MESSAGE_TOPIC,
2401
+ message_id: messageId,
2402
+ agent_id: agentId,
2403
+ public_key: String(record.public_key).trim(),
2404
+ display_name: displayName || "Unnamed",
2405
+ topic,
2406
+ body,
2407
+ created_at: createdAt,
2408
+ signature: String(record.signature).trim(),
2409
+ };
2410
+ }
2411
+
2412
+ private normalizeSocialMessages(items: unknown): SocialMessageRecord[] {
2413
+ if (!Array.isArray(items)) {
2414
+ return [];
2415
+ }
2416
+ const deduped = new Set<string>();
2417
+ return items
2418
+ .map((item) => this.normalizeIncomingSocialMessage(item))
2419
+ .filter((item): item is SocialMessageRecord => Boolean(item))
2420
+ .sort((a, b) => b.created_at - a.created_at)
2421
+ .filter((item) => {
2422
+ if (deduped.has(item.message_id)) {
2423
+ return false;
2424
+ }
2425
+ deduped.add(item.message_id);
2426
+ return true;
2427
+ })
2428
+ .slice(0, SOCIAL_MESSAGE_HISTORY_LIMIT);
2429
+ }
2430
+
2431
+ private normalizeIncomingSocialMessageObservation(value: unknown): SocialMessageObservationRecord | null {
2432
+ if (typeof value !== "object" || value === null) {
2433
+ return null;
2434
+ }
2435
+ const record = value as Partial<SocialMessageObservationRecord>;
2436
+ const observationId = String(record.observation_id || "").trim();
2437
+ const messageId = String(record.message_id || "").trim();
2438
+ const observedAgentId = String(record.observed_agent_id || "").trim();
2439
+ const observerAgentId = String(record.observer_agent_id || "").trim();
2440
+ const observerDisplayName = String(record.observer_display_name || "").trim();
2441
+ const observedAt = Number(record.observed_at || 0);
2442
+ if (
2443
+ record.type !== SOCIAL_MESSAGE_OBSERVATION_TOPIC ||
2444
+ !observationId ||
2445
+ !messageId ||
2446
+ !observedAgentId ||
2447
+ !observerAgentId ||
2448
+ typeof record.observer_public_key !== "string" ||
2449
+ !String(record.observer_public_key).trim() ||
2450
+ typeof record.signature !== "string" ||
2451
+ !String(record.signature).trim() ||
2452
+ !Number.isFinite(observedAt)
2453
+ ) {
2454
+ return null;
2455
+ }
2456
+ return {
2457
+ type: SOCIAL_MESSAGE_OBSERVATION_TOPIC,
2458
+ observation_id: observationId,
2459
+ message_id: messageId,
2460
+ observed_agent_id: observedAgentId,
2461
+ observer_agent_id: observerAgentId,
2462
+ observer_public_key: String(record.observer_public_key).trim(),
2463
+ observer_display_name: observerDisplayName || "Unnamed",
2464
+ observed_at: observedAt,
2465
+ signature: String(record.signature).trim(),
2466
+ };
2467
+ }
2468
+
2469
+ private normalizeSocialMessageObservations(items: unknown): SocialMessageObservationRecord[] {
2470
+ if (!Array.isArray(items)) {
2471
+ return [];
2472
+ }
2473
+ const deduped = new Set<string>();
2474
+ return items
2475
+ .map((item) => this.normalizeIncomingSocialMessageObservation(item))
2476
+ .filter((item): item is SocialMessageObservationRecord => Boolean(item))
2477
+ .sort((a, b) => b.observed_at - a.observed_at)
2478
+ .filter((item) => {
2479
+ if (deduped.has(item.observation_id)) {
2480
+ return false;
2481
+ }
2482
+ deduped.add(item.observation_id);
2483
+ return true;
2484
+ })
2485
+ .slice(0, SOCIAL_MESSAGE_OBSERVATION_HISTORY_LIMIT);
2486
+ }
2487
+
2488
+ private ingestSocialMessage(message: SocialMessageRecord): void {
2489
+ const existing = this.socialMessages.findIndex((item) => item.message_id === message.message_id);
2490
+ if (existing >= 0) {
2491
+ this.socialMessages[existing] = message;
2492
+ } else {
2493
+ this.socialMessages.unshift(message);
2494
+ }
2495
+ this.socialMessages = this.normalizeSocialMessages(this.socialMessages);
2496
+ }
2497
+
2498
+ private ingestSocialMessageObservation(observation: SocialMessageObservationRecord): void {
2499
+ const existing = this.socialMessageObservations.findIndex((item) => item.observation_id === observation.observation_id);
2500
+ if (existing >= 0) {
2501
+ this.socialMessageObservations[existing] = observation;
2502
+ } else {
2503
+ this.socialMessageObservations.unshift(observation);
2504
+ }
2505
+ this.socialMessageObservations = this.normalizeSocialMessageObservations(this.socialMessageObservations);
2506
+ }
1482
2507
  }
1483
2508
 
1484
2509
  function sendOk<T>(res: Response, data: T, meta?: Record<string, unknown>) {
@@ -1589,7 +2614,7 @@ function renderBootstrapScript(payload: unknown): string {
1589
2614
  </script>`;
1590
2615
  }
1591
2616
 
1592
- async function main() {
2617
+ export async function main() {
1593
2618
  const app = express();
1594
2619
  const port = Number(process.env.PORT || 4310);
1595
2620
  const staticDir = resolveLocalConsoleStaticDir();
@@ -1695,6 +2720,8 @@ async function main() {
1695
2720
  registerSocialRoutes(app, {
1696
2721
  getSocialConfigView: () => node.getSocialConfigView(),
1697
2722
  getIntegrationSummary: () => node.getIntegrationSummary(),
2723
+ getMessageGovernanceView: () => node.getMessageGovernanceView(),
2724
+ updateMessageGovernance: (input) => node.updateMessageGovernance(input),
1698
2725
  exportSocialTemplate: () => node.exportSocialTemplate(),
1699
2726
  setNetworkModeRuntime: (mode) => node.setNetworkModeRuntime(mode),
1700
2727
  reloadSocialConfig: () => node.reloadSocialConfig(),
@@ -1743,6 +2770,73 @@ async function main() {
1743
2770
  })
1744
2771
  );
1745
2772
 
2773
+ app.post(
2774
+ "/api/messages/broadcast",
2775
+ asyncRoute(async (req, res) => {
2776
+ const body = String(req.body?.body || "");
2777
+ const topic = String(req.body?.topic || DEFAULT_SOCIAL_MESSAGE_CHANNEL);
2778
+ const result = await node.sendSocialMessage(body, topic);
2779
+ sendOk(res, result, {
2780
+ message: result.sent ? "Message broadcast published" : `Message broadcast skipped: ${result.reason}`,
2781
+ });
2782
+ })
2783
+ );
2784
+
2785
+ app.get("/api/messages", (req, res) => {
2786
+ const limit = Number(req.query.limit ?? 50);
2787
+ const agentId = String(req.query.agent_id ?? "").trim();
2788
+ sendOk(res, node.getSocialMessages(limit, { agent_id: agentId || null }));
2789
+ });
2790
+
2791
+ app.get("/api/openclaw/bridge", (_req, res) => {
2792
+ sendOk(res, node.getOpenClawBridgeStatus());
2793
+ });
2794
+
2795
+ app.get("/api/openclaw/bridge/config", (_req, res) => {
2796
+ sendOk(res, node.getOpenClawBridgeConfig());
2797
+ });
2798
+
2799
+ app.get("/api/openclaw/bridge/profile", (_req, res) => {
2800
+ sendOk(res, node.getOpenClawBridgeProfile());
2801
+ });
2802
+
2803
+ app.get("/api/openclaw/bridge/messages", (req, res) => {
2804
+ const limit = Number(req.query.limit ?? 50);
2805
+ const agentId = String(req.query.agent_id ?? "").trim();
2806
+ sendOk(res, node.getSocialMessages(limit, { agent_id: agentId || null }));
2807
+ });
2808
+
2809
+ app.post(
2810
+ "/api/openclaw/bridge/message",
2811
+ asyncRoute(async (req, res) => {
2812
+ const body = String(req.body?.body || "");
2813
+ const topic = String(req.body?.topic || DEFAULT_SOCIAL_MESSAGE_CHANNEL);
2814
+ const result = await node.sendSocialMessage(body, topic);
2815
+ sendOk(res, result, {
2816
+ message: result.sent ? "OpenClaw bridge message published" : `OpenClaw bridge message skipped: ${result.reason}`,
2817
+ });
2818
+ })
2819
+ );
2820
+
2821
+ app.post(
2822
+ "/api/openclaw/bridge/skill-install",
2823
+ asyncRoute(async (_req, res) => {
2824
+ try {
2825
+ const result = await node.installOpenClawSkill();
2826
+ sendOk(res, result, {
2827
+ message: "OpenClaw skill installed",
2828
+ });
2829
+ } catch (error) {
2830
+ sendError(
2831
+ res,
2832
+ 500,
2833
+ "OPENCLAW_SKILL_INSTALL_FAILED",
2834
+ error instanceof Error ? error.message : "OpenClaw skill install failed"
2835
+ );
2836
+ }
2837
+ })
2838
+ );
2839
+
1746
2840
  app.post(
1747
2841
  "/api/cache/refresh",
1748
2842
  asyncRoute(async (_req, res) => {
@@ -1879,8 +2973,10 @@ async function main() {
1879
2973
  });
1880
2974
  }
1881
2975
 
1882
- main().catch((error) => {
1883
- // eslint-disable-next-line no-console
1884
- console.error(error);
1885
- process.exit(1);
1886
- });
2976
+ if (require.main === module) {
2977
+ main().catch((error) => {
2978
+ // eslint-disable-next-line no-console
2979
+ console.error(error);
2980
+ process.exit(1);
2981
+ });
2982
+ }