@memtensor/memos-local-openclaw-plugin 1.0.4-beta.7 → 1.0.4-beta.9

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/README.md +1 -1
  2. package/dist/capture/index.d.ts.map +1 -1
  3. package/dist/capture/index.js +6 -0
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts.map +1 -1
  6. package/dist/client/connector.js +61 -7
  7. package/dist/client/connector.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/hub/server.d.ts +7 -0
  12. package/dist/hub/server.d.ts.map +1 -1
  13. package/dist/hub/server.js +171 -8
  14. package/dist/hub/server.js.map +1 -1
  15. package/dist/ingest/providers/index.d.ts.map +1 -1
  16. package/dist/ingest/providers/index.js +37 -6
  17. package/dist/ingest/providers/index.js.map +1 -1
  18. package/dist/recall/engine.d.ts.map +1 -1
  19. package/dist/recall/engine.js +78 -1
  20. package/dist/recall/engine.js.map +1 -1
  21. package/dist/shared/llm-call.d.ts +1 -0
  22. package/dist/shared/llm-call.d.ts.map +1 -1
  23. package/dist/shared/llm-call.js +82 -8
  24. package/dist/shared/llm-call.js.map +1 -1
  25. package/dist/skill/evolver.d.ts +2 -0
  26. package/dist/skill/evolver.d.ts.map +1 -1
  27. package/dist/skill/evolver.js +3 -0
  28. package/dist/skill/evolver.js.map +1 -1
  29. package/dist/storage/sqlite.d.ts +5 -1
  30. package/dist/storage/sqlite.d.ts.map +1 -1
  31. package/dist/storage/sqlite.js +13 -4
  32. package/dist/storage/sqlite.js.map +1 -1
  33. package/dist/telemetry.d.ts +12 -5
  34. package/dist/telemetry.d.ts.map +1 -1
  35. package/dist/telemetry.js +135 -38
  36. package/dist/telemetry.js.map +1 -1
  37. package/dist/types.d.ts +1 -2
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. package/dist/viewer/html.d.ts.map +1 -1
  41. package/dist/viewer/html.js +735 -285
  42. package/dist/viewer/html.js.map +1 -1
  43. package/dist/viewer/server.d.ts +16 -0
  44. package/dist/viewer/server.d.ts.map +1 -1
  45. package/dist/viewer/server.js +349 -21
  46. package/dist/viewer/server.js.map +1 -1
  47. package/index.ts +26 -2
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +1 -2
  50. package/scripts/postinstall.cjs +1 -1
  51. package/src/capture/index.ts +8 -0
  52. package/src/client/connector.ts +62 -7
  53. package/src/config.ts +0 -2
  54. package/src/hub/server.ts +168 -8
  55. package/src/ingest/providers/index.ts +41 -7
  56. package/src/recall/engine.ts +73 -1
  57. package/src/shared/llm-call.ts +97 -9
  58. package/src/skill/evolver.ts +5 -0
  59. package/src/storage/sqlite.ts +19 -6
  60. package/src/telemetry.ts +152 -39
  61. package/src/types.ts +1 -2
  62. package/src/viewer/html.ts +735 -285
  63. package/src/viewer/server.ts +322 -21
@@ -91,6 +91,11 @@ class ViewerServer {
91
91
  ppAbort = false;
92
92
  ppState = { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
93
93
  ppSSEClients = [];
94
+ notifSSEClients = [];
95
+ notifPollTimer;
96
+ lastKnownNotifCount = 0;
97
+ hubHeartbeatTimer;
98
+ static HUB_HEARTBEAT_INTERVAL_MS = 45_000;
94
99
  constructor(opts) {
95
100
  this.store = opts.store;
96
101
  this.embedder = opts.embedder;
@@ -109,16 +114,17 @@ class ViewerServer {
109
114
  this.server.on("error", (err) => {
110
115
  if (err.code === "EADDRINUSE") {
111
116
  this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
112
- this.server.listen(this.port + 1, "127.0.0.1");
117
+ this.server.listen(this.port + 1, "0.0.0.0");
113
118
  }
114
119
  else {
115
120
  reject(err);
116
121
  }
117
122
  });
118
- this.server.listen(this.port, "127.0.0.1", () => {
123
+ this.server.listen(this.port, "0.0.0.0", () => {
119
124
  const addr = this.server.address();
120
125
  const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
121
126
  this.autoCleanupPolluted();
127
+ this.startHubHeartbeat();
122
128
  resolve(`http://127.0.0.1:${actualPort}`);
123
129
  });
124
130
  });
@@ -141,6 +147,15 @@ class ViewerServer {
141
147
  }
142
148
  }
143
149
  stop() {
150
+ this.stopHubHeartbeat();
151
+ this.stopNotifPoll();
152
+ for (const c of this.notifSSEClients) {
153
+ try {
154
+ c.end();
155
+ }
156
+ catch { }
157
+ }
158
+ this.notifSSEClients = [];
144
159
  this.server?.close();
145
160
  this.server = null;
146
161
  }
@@ -338,12 +353,18 @@ class ViewerServer {
338
353
  this.handleSharingNotificationsRead(req, res);
339
354
  else if (p === "/api/sharing/notifications/clear" && req.method === "POST")
340
355
  this.handleSharingNotificationsClear(req, res);
356
+ else if (p === "/api/notifications/stream" && req.method === "GET")
357
+ this.handleNotifSSE(req, res);
341
358
  else if (p === "/api/admin/shared-tasks" && req.method === "GET")
342
359
  this.serveAdminSharedTasks(res);
360
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET")
361
+ this.serveHubTaskDetail(res, p);
343
362
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE")
344
363
  this.handleAdminDeleteTask(res, p);
345
364
  else if (p === "/api/admin/shared-skills" && req.method === "GET")
346
365
  this.serveAdminSharedSkills(res);
366
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET")
367
+ this.serveHubSkillDetail(res, p);
347
368
  else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE")
348
369
  this.handleAdminDeleteSkill(res, p);
349
370
  else if (p === "/api/admin/shared-memories" && req.method === "GET")
@@ -521,7 +542,12 @@ class ViewerServer {
521
542
  conditions.push("role = ?");
522
543
  params.push(role);
523
544
  }
524
- if (owner) {
545
+ if (owner && owner.startsWith("agent:")) {
546
+ const agentPrefix = owner + ":";
547
+ conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
548
+ params.push(owner, agentPrefix + "%");
549
+ }
550
+ else if (owner) {
525
551
  conditions.push("owner = ?");
526
552
  params.push(owner);
527
553
  }
@@ -601,9 +627,10 @@ class ViewerServer {
601
627
  serveTasks(res, url) {
602
628
  this.store.recordViewerEvent("tasks_list");
603
629
  const status = url.searchParams.get("status") ?? undefined;
630
+ const owner = url.searchParams.get("owner") ?? undefined;
604
631
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
605
632
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
606
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
633
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
607
634
  const db = this.store.db;
608
635
  const items = tasks.map((t) => {
609
636
  const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id);
@@ -698,12 +725,22 @@ class ViewerServer {
698
725
  embCount = db.prepare("SELECT COUNT(*) as count FROM embeddings").get().count;
699
726
  }
700
727
  catch { /* table may not exist */ }
701
- const sessionQuery = ownerFilter
702
- ? "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE owner = ? GROUP BY session_key ORDER BY latest DESC"
703
- : "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC";
704
- const sessionList = (ownerFilter
705
- ? db.prepare(sessionQuery).all(ownerFilter)
706
- : db.prepare(sessionQuery).all());
728
+ let sessionQuery;
729
+ let sessionParams;
730
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
731
+ const agentPrefix = ownerFilter + ":";
732
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR (owner = 'public' AND session_key LIKE ?)) GROUP BY session_key ORDER BY latest DESC";
733
+ sessionParams = [ownerFilter, agentPrefix + "%"];
734
+ }
735
+ else if (ownerFilter) {
736
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE owner = ? GROUP BY session_key ORDER BY latest DESC";
737
+ sessionParams = [ownerFilter];
738
+ }
739
+ else {
740
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC";
741
+ sessionParams = [];
742
+ }
743
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams);
707
744
  let skillCount = 0;
708
745
  try {
709
746
  skillCount = db.prepare("SELECT COUNT(*) as count FROM skills").get().count;
@@ -717,10 +754,17 @@ class ViewerServer {
717
754
  catch { /* column may not exist yet */ }
718
755
  let owners = [];
719
756
  try {
720
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all();
757
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all();
721
758
  owners = ownerRows.map((o) => o.owner);
722
759
  }
723
760
  catch { /* column may not exist yet */ }
761
+ let currentAgentOwner = "agent:main";
762
+ try {
763
+ const latest = db.prepare("SELECT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY created_at DESC LIMIT 1").get();
764
+ if (latest?.owner)
765
+ currentAgentOwner = latest.owner;
766
+ }
767
+ catch { /* best-effort */ }
724
768
  this.jsonResponse(res, {
725
769
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
726
770
  totalSkills: skillCount,
@@ -729,6 +773,7 @@ class ViewerServer {
729
773
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
730
774
  sessions: sessionList,
731
775
  owners,
776
+ currentAgentOwner,
732
777
  });
733
778
  }
734
779
  catch (e) {
@@ -1597,7 +1642,8 @@ class ViewerServer {
1597
1642
  // ─── Config API ───
1598
1643
  getOpenClawConfigPath() {
1599
1644
  const home = process.env.HOME || process.env.USERPROFILE || "";
1600
- return node_path_1.default.join(home, ".openclaw", "openclaw.json");
1645
+ const ocHome = process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
1646
+ return node_path_1.default.join(ocHome, "openclaw.json");
1601
1647
  }
1602
1648
  getPluginEntryConfig(raw) {
1603
1649
  const entries = raw?.plugins?.entries ?? {};
@@ -1846,12 +1892,22 @@ class ViewerServer {
1846
1892
  }
1847
1893
  try {
1848
1894
  const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
1895
+ const localIPs = this.getLocalIPs();
1896
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1897
+ try {
1898
+ const u = new URL(hubUrl);
1899
+ if (localIPs.includes(u.hostname)) {
1900
+ return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1901
+ }
1902
+ }
1903
+ catch { }
1849
1904
  const os = await Promise.resolve().then(() => __importStar(require("os")));
1850
- const username = os.userInfo().username || "user";
1905
+ const nickname = sharing.client?.nickname;
1906
+ const username = nickname || os.userInfo().username || "user";
1851
1907
  const hostname = os.hostname() || "unknown";
1852
1908
  const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
1853
1909
  method: "POST",
1854
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1910
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
1855
1911
  });
1856
1912
  this.store.setClientHubConnection({
1857
1913
  hubUrl,
@@ -1873,7 +1929,14 @@ class ViewerServer {
1873
1929
  return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1874
1930
  try {
1875
1931
  const limit = Number(url.searchParams.get("limit") || 40);
1876
- const data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
1932
+ const hub = this.resolveHubConnection();
1933
+ let data;
1934
+ if (hub) {
1935
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1936
+ }
1937
+ else {
1938
+ data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
1939
+ }
1877
1940
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1878
1941
  }
1879
1942
  catch (err) {
@@ -1885,7 +1948,14 @@ class ViewerServer {
1885
1948
  return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1886
1949
  try {
1887
1950
  const limit = Number(url.searchParams.get("limit") || 40);
1888
- const data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
1951
+ const hub = this.resolveHubConnection();
1952
+ let data;
1953
+ if (hub) {
1954
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1955
+ }
1956
+ else {
1957
+ data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
1958
+ }
1889
1959
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1890
1960
  }
1891
1961
  catch (err) {
@@ -1897,7 +1967,14 @@ class ViewerServer {
1897
1967
  return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1898
1968
  try {
1899
1969
  const limit = Number(url.searchParams.get("limit") || 40);
1900
- const data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
1970
+ const hub = this.resolveHubConnection();
1971
+ let data;
1972
+ if (hub) {
1973
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
1974
+ }
1975
+ else {
1976
+ data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
1977
+ }
1901
1978
  this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1902
1979
  }
1903
1980
  catch (err) {
@@ -1914,13 +1991,22 @@ class ViewerServer {
1914
1991
  const query = String(parsed.query || "");
1915
1992
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1916
1993
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1917
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
1994
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1918
1995
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1919
1996
  if (scope === "local") {
1920
1997
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1921
1998
  }
1922
1999
  try {
1923
- const hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2000
+ const conn = this.resolveHubConnection();
2001
+ let hub;
2002
+ if (conn) {
2003
+ hub = await (0, hub_1.hubRequestJson)(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
2004
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
2005
+ });
2006
+ }
2007
+ else {
2008
+ hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2009
+ }
1924
2010
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1925
2011
  }
1926
2012
  catch (err) {
@@ -2356,6 +2442,38 @@ class ViewerServer {
2356
2442
  this.jsonResponse(res, { ok: false, error: String(err) });
2357
2443
  }
2358
2444
  }
2445
+ async serveHubTaskDetail(res, p) {
2446
+ const hub = this.resolveHubConnection();
2447
+ if (!hub)
2448
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2449
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2450
+ if (!m)
2451
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2452
+ const taskId = decodeURIComponent(m[1]);
2453
+ try {
2454
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" });
2455
+ this.jsonResponse(res, data);
2456
+ }
2457
+ catch (err) {
2458
+ this.jsonResponse(res, { error: String(err) }, 500);
2459
+ }
2460
+ }
2461
+ async serveHubSkillDetail(res, p) {
2462
+ const hub = this.resolveHubConnection();
2463
+ if (!hub)
2464
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2465
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2466
+ if (!m)
2467
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2468
+ const skillId = decodeURIComponent(m[1]);
2469
+ try {
2470
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" });
2471
+ this.jsonResponse(res, data);
2472
+ }
2473
+ catch (err) {
2474
+ this.jsonResponse(res, { error: String(err) }, 500);
2475
+ }
2476
+ }
2359
2477
  async serveAdminSharedSkills(res) {
2360
2478
  const hub = this.resolveHubConnection();
2361
2479
  if (!hub)
@@ -2449,6 +2567,13 @@ class ViewerServer {
2449
2567
  const body = JSON.parse(raw || "{}");
2450
2568
  await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2451
2569
  this.jsonResponse(res, { ok: true });
2570
+ try {
2571
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2572
+ const count = data?.unreadCount ?? 0;
2573
+ this.lastKnownNotifCount = count;
2574
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2575
+ }
2576
+ catch { /* best effort */ }
2452
2577
  }
2453
2578
  catch (err) {
2454
2579
  this.jsonResponse(res, { ok: false, error: String(err) });
@@ -2463,12 +2588,92 @@ class ViewerServer {
2463
2588
  try {
2464
2589
  await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2465
2590
  this.jsonResponse(res, { ok: true });
2591
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2466
2592
  }
2467
2593
  catch (err) {
2468
2594
  this.jsonResponse(res, { ok: false, error: String(err) });
2469
2595
  }
2470
2596
  });
2471
2597
  }
2598
+ handleNotifSSE(req, res) {
2599
+ res.writeHead(200, {
2600
+ "Content-Type": "text/event-stream",
2601
+ "Cache-Control": "no-cache",
2602
+ Connection: "keep-alive",
2603
+ "Access-Control-Allow-Origin": "*",
2604
+ });
2605
+ res.write("data: {\"type\":\"connected\"}\n\n");
2606
+ this.notifSSEClients.push(res);
2607
+ if (!this.notifPollTimer)
2608
+ this.startNotifPoll();
2609
+ req.on("close", () => {
2610
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2611
+ if (this.notifSSEClients.length === 0)
2612
+ this.stopNotifPoll();
2613
+ });
2614
+ }
2615
+ broadcastNotifSSE(data) {
2616
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2617
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2618
+ try {
2619
+ c.write(msg);
2620
+ return true;
2621
+ }
2622
+ catch {
2623
+ return false;
2624
+ }
2625
+ });
2626
+ }
2627
+ startNotifPoll() {
2628
+ this.stopNotifPoll();
2629
+ const tick = async () => {
2630
+ const hub = this.resolveHubConnection();
2631
+ if (!hub)
2632
+ return;
2633
+ try {
2634
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2635
+ const count = data?.unreadCount ?? 0;
2636
+ if (count !== this.lastKnownNotifCount) {
2637
+ this.lastKnownNotifCount = count;
2638
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2639
+ }
2640
+ }
2641
+ catch { /* ignore */ }
2642
+ };
2643
+ tick();
2644
+ this.notifPollTimer = setInterval(tick, 3000);
2645
+ }
2646
+ stopNotifPoll() {
2647
+ if (this.notifPollTimer) {
2648
+ clearInterval(this.notifPollTimer);
2649
+ this.notifPollTimer = undefined;
2650
+ }
2651
+ }
2652
+ startHubHeartbeat() {
2653
+ this.stopHubHeartbeat();
2654
+ const sendHeartbeat = async () => {
2655
+ try {
2656
+ const hub = this.resolveHubConnection();
2657
+ if (!hub) {
2658
+ const persisted = this.store.getClientHubConnection();
2659
+ if (persisted?.hubUrl && persisted?.userToken) {
2660
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2661
+ }
2662
+ return;
2663
+ }
2664
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2665
+ }
2666
+ catch { /* best-effort */ }
2667
+ };
2668
+ sendHeartbeat();
2669
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2670
+ }
2671
+ stopHubHeartbeat() {
2672
+ if (this.hubHeartbeatTimer) {
2673
+ clearInterval(this.hubHeartbeatTimer);
2674
+ this.hubHeartbeatTimer = undefined;
2675
+ }
2676
+ }
2472
2677
  getLocalIPs() {
2473
2678
  const nets = node_os_1.default.networkInterfaces();
2474
2679
  const ips = [];
@@ -2520,7 +2725,7 @@ class ViewerServer {
2520
2725
  }
2521
2726
  }
2522
2727
  handleSaveConfig(req, res) {
2523
- this.readBody(req, (body) => {
2728
+ this.readBody(req, async (body) => {
2524
2729
  try {
2525
2730
  const newCfg = JSON.parse(body);
2526
2731
  const cfgPath = this.getOpenClawConfigPath();
@@ -2545,6 +2750,10 @@ class ViewerServer {
2545
2750
  if (!entry.config)
2546
2751
  entry.config = {};
2547
2752
  const config = entry.config;
2753
+ const oldSharing = config.sharing;
2754
+ const oldSharingRole = oldSharing?.role;
2755
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2756
+ const oldClientHubAddress = String(oldSharing?.client?.hubAddress || "");
2548
2757
  if (newCfg.embedding)
2549
2758
  config.embedding = newCfg.embedding;
2550
2759
  if (newCfg.summarizer)
@@ -2578,6 +2787,33 @@ class ViewerServer {
2578
2787
  catch { }
2579
2788
  }
2580
2789
  }
2790
+ const newRole = merged.role;
2791
+ const newEnabled = Boolean(merged.enabled);
2792
+ // Detect disabling sharing or switching away from hub mode
2793
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2794
+ const isHub = newEnabled && newRole === "hub";
2795
+ if (wasHub && !isHub) {
2796
+ await this.notifyHubShutdown();
2797
+ this.stopHubHeartbeat();
2798
+ this.log.info("Hub shutting down: notified connected clients");
2799
+ }
2800
+ // Detect disabling sharing or switching away from client mode
2801
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2802
+ const isClient = newEnabled && newRole === "client";
2803
+ if (wasClient && !isClient) {
2804
+ this.notifyHubLeave();
2805
+ this.store.clearClientHubConnection();
2806
+ this.log.info("Cleared client hub connection (sharing disabled or role changed)");
2807
+ }
2808
+ // Detect switching to a different Hub while still in client mode
2809
+ if (wasClient && isClient) {
2810
+ const newClientAddr = String(merged.client?.hubAddress || "");
2811
+ if (newClientAddr && oldClientHubAddress && (0, hub_1.normalizeHubUrl)(newClientAddr) !== (0, hub_1.normalizeHubUrl)(oldClientHubAddress)) {
2812
+ this.notifyHubLeave();
2813
+ this.store.clearClientHubConnection();
2814
+ this.log.info("Cleared client hub connection (switched to different Hub)");
2815
+ }
2816
+ }
2581
2817
  if (merged.role === "hub") {
2582
2818
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2583
2819
  }
@@ -2589,6 +2825,12 @@ class ViewerServer {
2589
2825
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(cfgPath), { recursive: true });
2590
2826
  node_fs_1.default.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2591
2827
  this.log.info("Plugin config updated via Viewer");
2828
+ this.stopHubHeartbeat();
2829
+ // When switching to client mode, immediately send join request
2830
+ const finalSharing = config.sharing;
2831
+ if (finalSharing?.role === "client" && oldSharingRole !== "client") {
2832
+ this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2833
+ }
2592
2834
  this.jsonResponse(res, { ok: true });
2593
2835
  }
2594
2836
  catch (e) {
@@ -2598,6 +2840,92 @@ class ViewerServer {
2598
2840
  }
2599
2841
  });
2600
2842
  }
2843
+ async autoJoinOnSave(sharing) {
2844
+ const clientCfg = sharing.client;
2845
+ const hubAddress = String(clientCfg?.hubAddress || "");
2846
+ const teamToken = String(clientCfg?.teamToken || "");
2847
+ if (!hubAddress || !teamToken)
2848
+ return;
2849
+ const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
2850
+ const os = await Promise.resolve().then(() => __importStar(require("os")));
2851
+ const nickname = String(clientCfg?.nickname || "");
2852
+ const username = nickname || os.userInfo().username || "user";
2853
+ const hostname = os.hostname() || "unknown";
2854
+ const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
2855
+ method: "POST",
2856
+ body: JSON.stringify({ teamToken, username, deviceName: hostname }),
2857
+ });
2858
+ this.store.setClientHubConnection({
2859
+ hubUrl,
2860
+ userId: String(result.userId || ""),
2861
+ username,
2862
+ userToken: result.userToken || "",
2863
+ role: "member",
2864
+ connectedAt: Date.now(),
2865
+ });
2866
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2867
+ if (result.userToken) {
2868
+ this.startHubHeartbeat();
2869
+ }
2870
+ }
2871
+ async notifyHubLeave() {
2872
+ try {
2873
+ const hub = this.resolveHubConnection();
2874
+ if (hub) {
2875
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2876
+ this.log.info("Notified Hub of voluntary leave");
2877
+ return;
2878
+ }
2879
+ const persisted = this.store.getClientHubConnection();
2880
+ if (persisted?.hubUrl && persisted?.userToken) {
2881
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2882
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2883
+ }
2884
+ }
2885
+ catch (e) {
2886
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2887
+ }
2888
+ }
2889
+ async notifyHubShutdown() {
2890
+ try {
2891
+ const sharing = this.ctx?.config.sharing;
2892
+ if (!sharing || sharing.role !== "hub")
2893
+ return;
2894
+ const hubPort = sharing.hub?.port ?? 18800;
2895
+ const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
2896
+ let adminToken;
2897
+ try {
2898
+ const authData = JSON.parse(node_fs_1.default.readFileSync(authPath, "utf8"));
2899
+ adminToken = authData?.bootstrapAdminToken;
2900
+ }
2901
+ catch {
2902
+ return;
2903
+ }
2904
+ if (!adminToken)
2905
+ return;
2906
+ const users = this.store.listHubUsers("active");
2907
+ const { v4: uuidv4 } = require("uuid");
2908
+ for (const u of users) {
2909
+ try {
2910
+ this.store.insertHubNotification({
2911
+ id: uuidv4(),
2912
+ userId: u.id,
2913
+ type: "hub_shutdown",
2914
+ resource: "hub",
2915
+ title: "Hub is shutting down",
2916
+ message: "The Hub server is shutting down. You may be disconnected.",
2917
+ });
2918
+ }
2919
+ catch (e) {
2920
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
2921
+ }
2922
+ }
2923
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
2924
+ }
2925
+ catch (e) {
2926
+ this.log.warn(`notifyHubShutdown error: ${e}`);
2927
+ }
2928
+ }
2601
2929
  handleUpdateUsername(req, res) {
2602
2930
  this.readBody(req, async (body) => {
2603
2931
  if (!this.ctx)
@@ -3072,7 +3400,7 @@ class ViewerServer {
3072
3400
  // ─── Migration: scan OpenClaw built-in memory ───
3073
3401
  getOpenClawHome() {
3074
3402
  const home = process.env.HOME || process.env.USERPROFILE || "";
3075
- return node_path_1.default.join(home, ".openclaw");
3403
+ return process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
3076
3404
  }
3077
3405
  handleCleanupPolluted(res) {
3078
3406
  try {