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

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 (59) 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 +17 -4
  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 +160 -5
  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/shared/llm-call.d.ts +1 -0
  19. package/dist/shared/llm-call.d.ts.map +1 -1
  20. package/dist/shared/llm-call.js +82 -8
  21. package/dist/shared/llm-call.js.map +1 -1
  22. package/dist/skill/evolver.d.ts +2 -0
  23. package/dist/skill/evolver.d.ts.map +1 -1
  24. package/dist/skill/evolver.js +3 -0
  25. package/dist/skill/evolver.js.map +1 -1
  26. package/dist/storage/sqlite.d.ts +4 -1
  27. package/dist/storage/sqlite.d.ts.map +1 -1
  28. package/dist/storage/sqlite.js +8 -4
  29. package/dist/storage/sqlite.js.map +1 -1
  30. package/dist/telemetry.d.ts +12 -5
  31. package/dist/telemetry.d.ts.map +1 -1
  32. package/dist/telemetry.js +135 -38
  33. package/dist/telemetry.js.map +1 -1
  34. package/dist/types.d.ts +1 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js.map +1 -1
  37. package/dist/viewer/html.d.ts.map +1 -1
  38. package/dist/viewer/html.js +473 -191
  39. package/dist/viewer/html.js.map +1 -1
  40. package/dist/viewer/server.d.ts +14 -0
  41. package/dist/viewer/server.d.ts.map +1 -1
  42. package/dist/viewer/server.js +233 -20
  43. package/dist/viewer/server.js.map +1 -1
  44. package/index.ts +26 -2
  45. package/openclaw.plugin.json +1 -1
  46. package/package.json +1 -2
  47. package/scripts/postinstall.cjs +1 -1
  48. package/src/capture/index.ts +8 -0
  49. package/src/client/connector.ts +17 -4
  50. package/src/config.ts +0 -2
  51. package/src/hub/server.ts +157 -5
  52. package/src/ingest/providers/index.ts +41 -7
  53. package/src/shared/llm-call.ts +97 -9
  54. package/src/skill/evolver.ts +5 -0
  55. package/src/storage/sqlite.ts +11 -6
  56. package/src/telemetry.ts +152 -39
  57. package/src/types.ts +1 -2
  58. package/src/viewer/html.ts +473 -191
  59. package/src/viewer/server.ts +208 -20
@@ -84,6 +84,12 @@ export class ViewerServer {
84
84
  { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
85
85
  private ppSSEClients: http.ServerResponse[] = [];
86
86
 
87
+ private notifSSEClients: http.ServerResponse[] = [];
88
+ private notifPollTimer?: ReturnType<typeof setInterval>;
89
+ private lastKnownNotifCount = 0;
90
+ private hubHeartbeatTimer?: ReturnType<typeof setInterval>;
91
+ private static readonly HUB_HEARTBEAT_INTERVAL_MS = 45_000;
92
+
87
93
  constructor(opts: ViewerServerOptions) {
88
94
  this.store = opts.store;
89
95
  this.embedder = opts.embedder;
@@ -103,15 +109,16 @@ export class ViewerServer {
103
109
  this.server.on("error", (err: NodeJS.ErrnoException) => {
104
110
  if (err.code === "EADDRINUSE") {
105
111
  this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
106
- this.server!.listen(this.port + 1, "127.0.0.1");
112
+ this.server!.listen(this.port + 1, "0.0.0.0");
107
113
  } else {
108
114
  reject(err);
109
115
  }
110
116
  });
111
- this.server.listen(this.port, "127.0.0.1", () => {
117
+ this.server.listen(this.port, "0.0.0.0", () => {
112
118
  const addr = this.server!.address();
113
119
  const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
114
120
  this.autoCleanupPolluted();
121
+ this.startHubHeartbeat();
115
122
  resolve(`http://127.0.0.1:${actualPort}`);
116
123
  });
117
124
  });
@@ -134,6 +141,10 @@ export class ViewerServer {
134
141
  }
135
142
 
136
143
  stop(): void {
144
+ this.stopHubHeartbeat();
145
+ this.stopNotifPoll();
146
+ for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
147
+ this.notifSSEClients = [];
137
148
  this.server?.close();
138
149
  this.server = null;
139
150
  }
@@ -279,9 +290,12 @@ export class ViewerServer {
279
290
  else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
280
291
  else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
281
292
  else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
293
+ else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
282
294
  else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
295
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
283
296
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteTask(res, p);
284
297
  else if (p === "/api/admin/shared-skills" && req.method === "GET") this.serveAdminSharedSkills(res);
298
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET") this.serveHubSkillDetail(res, p);
285
299
  else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteSkill(res, p);
286
300
  else if (p === "/api/admin/shared-memories" && req.method === "GET") this.serveAdminSharedMemories(res);
287
301
  else if (p.match(/^\/api\/admin\/shared-memories\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteMemory(res, p);
@@ -436,7 +450,13 @@ export class ViewerServer {
436
450
  const params: any[] = [];
437
451
  if (session) { conditions.push("session_key = ?"); params.push(session); }
438
452
  if (role) { conditions.push("role = ?"); params.push(role); }
439
- if (owner) { conditions.push("owner = ?"); params.push(owner); }
453
+ if (owner && owner.startsWith("agent:")) {
454
+ const agentPrefix = owner + ":";
455
+ conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
456
+ params.push(owner, agentPrefix + "%");
457
+ } else if (owner) {
458
+ conditions.push("owner = ?"); params.push(owner);
459
+ }
440
460
  if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
441
461
  if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
442
462
 
@@ -510,9 +530,10 @@ export class ViewerServer {
510
530
  private serveTasks(res: http.ServerResponse, url: URL): void {
511
531
  this.store.recordViewerEvent("tasks_list");
512
532
  const status = url.searchParams.get("status") ?? undefined;
533
+ const owner = url.searchParams.get("owner") ?? undefined;
513
534
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
514
535
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
515
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
536
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
516
537
 
517
538
  const db = (this.store as any).db;
518
539
  const items = tasks.map((t) => {
@@ -616,12 +637,20 @@ export class ViewerServer {
616
637
  }
617
638
  let embCount = 0;
618
639
  try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
619
- const sessionQuery = ownerFilter
620
- ? "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"
621
- : "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";
622
- const sessionList = (ownerFilter
623
- ? db.prepare(sessionQuery).all(ownerFilter)
624
- : db.prepare(sessionQuery).all()) as any[];
640
+ let sessionQuery: string;
641
+ let sessionParams: any[];
642
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
643
+ const agentPrefix = ownerFilter + ":";
644
+ 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";
645
+ sessionParams = [ownerFilter, agentPrefix + "%"];
646
+ } else if (ownerFilter) {
647
+ 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";
648
+ sessionParams = [ownerFilter];
649
+ } else {
650
+ 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";
651
+ sessionParams = [];
652
+ }
653
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams) as any[];
625
654
 
626
655
  let skillCount = 0;
627
656
  try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
@@ -634,7 +663,7 @@ export class ViewerServer {
634
663
 
635
664
  let owners: string[] = [];
636
665
  try {
637
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
666
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all() as any[];
638
667
  owners = ownerRows.map((o: any) => o.owner);
639
668
  } catch { /* column may not exist yet */ }
640
669
 
@@ -1470,7 +1499,8 @@ export class ViewerServer {
1470
1499
 
1471
1500
  private getOpenClawConfigPath(): string {
1472
1501
  const home = process.env.HOME || process.env.USERPROFILE || "";
1473
- return path.join(home, ".openclaw", "openclaw.json");
1502
+ const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
1503
+ return path.join(ocHome, "openclaw.json");
1474
1504
  }
1475
1505
 
1476
1506
  private getPluginEntryConfig(raw: any): Record<string, unknown> {
@@ -1713,7 +1743,8 @@ export class ViewerServer {
1713
1743
  try {
1714
1744
  const hubUrl = normalizeHubUrl(hubAddress);
1715
1745
  const os = await import("os");
1716
- const username = os.userInfo().username || "user";
1746
+ const nickname = sharing.client?.nickname;
1747
+ const username = nickname || os.userInfo().username || "user";
1717
1748
  const hostname = os.hostname() || "unknown";
1718
1749
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
1719
1750
  method: "POST",
@@ -1738,7 +1769,13 @@ export class ViewerServer {
1738
1769
  if (!this.ctx) return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1739
1770
  try {
1740
1771
  const limit = Number(url.searchParams.get("limit") || 40);
1741
- const data = await hubListMemories(this.store, this.ctx, { limit });
1772
+ const hub = this.resolveHubConnection();
1773
+ let data: any;
1774
+ if (hub) {
1775
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1776
+ } else {
1777
+ data = await hubListMemories(this.store, this.ctx, { limit });
1778
+ }
1742
1779
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1743
1780
  } catch (err) {
1744
1781
  this.jsonResponse(res, { memories: [], error: String(err) });
@@ -1749,7 +1786,13 @@ export class ViewerServer {
1749
1786
  if (!this.ctx) return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1750
1787
  try {
1751
1788
  const limit = Number(url.searchParams.get("limit") || 40);
1752
- const data = await hubListTasks(this.store, this.ctx, { limit });
1789
+ const hub = this.resolveHubConnection();
1790
+ let data: any;
1791
+ if (hub) {
1792
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1793
+ } else {
1794
+ data = await hubListTasks(this.store, this.ctx, { limit });
1795
+ }
1753
1796
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1754
1797
  } catch (err) {
1755
1798
  this.jsonResponse(res, { tasks: [], error: String(err) });
@@ -1760,7 +1803,13 @@ export class ViewerServer {
1760
1803
  if (!this.ctx) return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1761
1804
  try {
1762
1805
  const limit = Number(url.searchParams.get("limit") || 40);
1763
- const data = await hubListSkills(this.store, this.ctx, { limit });
1806
+ const hub = this.resolveHubConnection();
1807
+ let data: any;
1808
+ if (hub) {
1809
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
1810
+ } else {
1811
+ data = await hubListSkills(this.store, this.ctx, { limit });
1812
+ }
1764
1813
  this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1765
1814
  } catch (err) {
1766
1815
  this.jsonResponse(res, { skills: [], error: String(err) });
@@ -1776,13 +1825,21 @@ export class ViewerServer {
1776
1825
  const query = String(parsed.query || "");
1777
1826
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1778
1827
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1779
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
1828
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1780
1829
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1781
1830
  if (scope === "local") {
1782
1831
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1783
1832
  }
1784
1833
  try {
1785
- const hub = await hubSearchMemories(this.store, this.ctx, { query, maxResults, scope });
1834
+ const conn = this.resolveHubConnection();
1835
+ let hub: any;
1836
+ if (conn) {
1837
+ hub = await hubRequestJson(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
1838
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
1839
+ });
1840
+ } else {
1841
+ hub = await hubSearchMemories(this.store, this.ctx!, { query, maxResults, scope });
1842
+ }
1786
1843
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1787
1844
  } catch (err) {
1788
1845
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub, error: String(err) });
@@ -2192,6 +2249,34 @@ export class ViewerServer {
2192
2249
  }
2193
2250
  }
2194
2251
 
2252
+ private async serveHubTaskDetail(res: http.ServerResponse, p: string): Promise<void> {
2253
+ const hub = this.resolveHubConnection();
2254
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2255
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2256
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2257
+ const taskId = decodeURIComponent(m[1]);
2258
+ try {
2259
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" }) as any;
2260
+ this.jsonResponse(res, data);
2261
+ } catch (err) {
2262
+ this.jsonResponse(res, { error: String(err) }, 500);
2263
+ }
2264
+ }
2265
+
2266
+ private async serveHubSkillDetail(res: http.ServerResponse, p: string): Promise<void> {
2267
+ const hub = this.resolveHubConnection();
2268
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2269
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2270
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2271
+ const skillId = decodeURIComponent(m[1]);
2272
+ try {
2273
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" }) as any;
2274
+ this.jsonResponse(res, data);
2275
+ } catch (err) {
2276
+ this.jsonResponse(res, { error: String(err) }, 500);
2277
+ }
2278
+ }
2279
+
2195
2280
  private async serveAdminSharedSkills(res: http.ServerResponse): Promise<void> {
2196
2281
  const hub = this.resolveHubConnection();
2197
2282
  if (!hub) return this.jsonResponse(res, { skills: [], error: "not_configured" });
@@ -2272,6 +2357,12 @@ export class ViewerServer {
2272
2357
  const body = JSON.parse(raw || "{}");
2273
2358
  await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2274
2359
  this.jsonResponse(res, { ok: true });
2360
+ try {
2361
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2362
+ const count = data?.unreadCount ?? 0;
2363
+ this.lastKnownNotifCount = count;
2364
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2365
+ } catch { /* best effort */ }
2275
2366
  } catch (err) {
2276
2367
  this.jsonResponse(res, { ok: false, error: String(err) });
2277
2368
  }
@@ -2285,12 +2376,81 @@ export class ViewerServer {
2285
2376
  try {
2286
2377
  await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2287
2378
  this.jsonResponse(res, { ok: true });
2379
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2288
2380
  } catch (err) {
2289
2381
  this.jsonResponse(res, { ok: false, error: String(err) });
2290
2382
  }
2291
2383
  });
2292
2384
  }
2293
2385
 
2386
+ private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
2387
+ res.writeHead(200, {
2388
+ "Content-Type": "text/event-stream",
2389
+ "Cache-Control": "no-cache",
2390
+ Connection: "keep-alive",
2391
+ "Access-Control-Allow-Origin": "*",
2392
+ });
2393
+ res.write("data: {\"type\":\"connected\"}\n\n");
2394
+ this.notifSSEClients.push(res);
2395
+ if (!this.notifPollTimer) this.startNotifPoll();
2396
+ req.on("close", () => {
2397
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2398
+ if (this.notifSSEClients.length === 0) this.stopNotifPoll();
2399
+ });
2400
+ }
2401
+
2402
+ private broadcastNotifSSE(data: Record<string, unknown>): void {
2403
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2404
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2405
+ try { c.write(msg); return true; } catch { return false; }
2406
+ });
2407
+ }
2408
+
2409
+ private startNotifPoll(): void {
2410
+ this.stopNotifPoll();
2411
+ const tick = async () => {
2412
+ const hub = this.resolveHubConnection();
2413
+ if (!hub) return;
2414
+ try {
2415
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2416
+ const count = data?.unreadCount ?? 0;
2417
+ if (count !== this.lastKnownNotifCount) {
2418
+ this.lastKnownNotifCount = count;
2419
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2420
+ }
2421
+ } catch { /* ignore */ }
2422
+ };
2423
+ tick();
2424
+ this.notifPollTimer = setInterval(tick, 3000);
2425
+ }
2426
+
2427
+ private stopNotifPoll(): void {
2428
+ if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
2429
+ }
2430
+
2431
+ private startHubHeartbeat(): void {
2432
+ this.stopHubHeartbeat();
2433
+ const sendHeartbeat = async () => {
2434
+ try {
2435
+ const hub = this.resolveHubConnection();
2436
+ if (!hub) {
2437
+ const persisted = this.store.getClientHubConnection();
2438
+ if (persisted?.hubUrl && persisted?.userToken) {
2439
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2440
+ }
2441
+ return;
2442
+ }
2443
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2444
+ } catch { /* best-effort */ }
2445
+ };
2446
+ sendHeartbeat();
2447
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2448
+ }
2449
+
2450
+ private stopHubHeartbeat(): void {
2451
+ if (this.hubHeartbeatTimer) { clearInterval(this.hubHeartbeatTimer); this.hubHeartbeatTimer = undefined; }
2452
+ }
2453
+
2294
2454
  private getLocalIPs(): string[] {
2295
2455
  const nets = os.networkInterfaces();
2296
2456
  const ips: string[] = [];
@@ -2343,7 +2503,7 @@ export class ViewerServer {
2343
2503
  }
2344
2504
 
2345
2505
  private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
2346
- this.readBody(req, (body) => {
2506
+ this.readBody(req, async (body) => {
2347
2507
  try {
2348
2508
  const newCfg = JSON.parse(body);
2349
2509
  const cfgPath = this.getOpenClawConfigPath();
@@ -2366,6 +2526,8 @@ export class ViewerServer {
2366
2526
  if (!entry.config) entry.config = {};
2367
2527
  const config = entry.config as Record<string, unknown>;
2368
2528
 
2529
+ const oldSharingRole = (config.sharing as Record<string, unknown>)?.role as string | undefined;
2530
+
2369
2531
  if (newCfg.embedding) config.embedding = newCfg.embedding;
2370
2532
  if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
2371
2533
  if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
@@ -2393,6 +2555,13 @@ export class ViewerServer {
2393
2555
  } catch {}
2394
2556
  }
2395
2557
  }
2558
+
2559
+ // When switching away from client mode, notify Hub that we're leaving
2560
+ const newRole = merged.role as string | undefined;
2561
+ if (oldSharingRole === "client" && newRole !== "client") {
2562
+ this.notifyHubLeave();
2563
+ }
2564
+
2396
2565
  if (merged.role === "hub") {
2397
2566
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2398
2567
  } else if (merged.role === "client") {
@@ -2404,6 +2573,7 @@ export class ViewerServer {
2404
2573
  fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
2405
2574
  fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2406
2575
  this.log.info("Plugin config updated via Viewer");
2576
+ this.stopHubHeartbeat();
2407
2577
  this.jsonResponse(res, { ok: true });
2408
2578
  } catch (e) {
2409
2579
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -2413,6 +2583,24 @@ export class ViewerServer {
2413
2583
  });
2414
2584
  }
2415
2585
 
2586
+ private async notifyHubLeave(): Promise<void> {
2587
+ try {
2588
+ const hub = this.resolveHubConnection();
2589
+ if (hub) {
2590
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2591
+ this.log.info("Notified Hub of voluntary leave");
2592
+ return;
2593
+ }
2594
+ const persisted = this.store.getClientHubConnection();
2595
+ if (persisted?.hubUrl && persisted?.userToken) {
2596
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2597
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2598
+ }
2599
+ } catch (e) {
2600
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2601
+ }
2602
+ }
2603
+
2416
2604
  private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
2417
2605
  this.readBody(req, async (body) => {
2418
2606
  if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
@@ -2866,7 +3054,7 @@ export class ViewerServer {
2866
3054
 
2867
3055
  private getOpenClawHome(): string {
2868
3056
  const home = process.env.HOME || process.env.USERPROFILE || "";
2869
- return path.join(home, ".openclaw");
3057
+ return process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
2870
3058
  }
2871
3059
 
2872
3060
  private handleCleanupPolluted(res: http.ServerResponse): void {