@memtensor/memos-local-openclaw-plugin 1.0.4-beta.1 → 1.0.4-beta.10

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 (98) hide show
  1. package/.env.example +7 -0
  2. package/README.md +24 -24
  3. package/dist/capture/index.d.ts +1 -1
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +34 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +1 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +93 -26
  10. package/dist/client/connector.js.map +1 -1
  11. package/dist/client/hub.d.ts.map +1 -1
  12. package/dist/client/hub.js +22 -0
  13. package/dist/client/hub.js.map +1 -1
  14. package/dist/client/skill-sync.d.ts +7 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -1
  16. package/dist/client/skill-sync.js +10 -0
  17. package/dist/client/skill-sync.js.map +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +0 -2
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +7 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +277 -87
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +2 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +5 -1
  28. package/dist/hub/user-manager.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest/providers/index.d.ts.map +1 -1
  33. package/dist/ingest/providers/index.js +37 -6
  34. package/dist/ingest/providers/index.js.map +1 -1
  35. package/dist/recall/engine.d.ts.map +1 -1
  36. package/dist/recall/engine.js +91 -1
  37. package/dist/recall/engine.js.map +1 -1
  38. package/dist/shared/llm-call.d.ts +1 -0
  39. package/dist/shared/llm-call.d.ts.map +1 -1
  40. package/dist/shared/llm-call.js +82 -8
  41. package/dist/shared/llm-call.js.map +1 -1
  42. package/dist/sharing/types.d.ts +1 -1
  43. package/dist/sharing/types.d.ts.map +1 -1
  44. package/dist/skill/evolver.d.ts +2 -0
  45. package/dist/skill/evolver.d.ts.map +1 -1
  46. package/dist/skill/evolver.js +3 -0
  47. package/dist/skill/evolver.js.map +1 -1
  48. package/dist/storage/ensure-binding.d.ts +12 -0
  49. package/dist/storage/ensure-binding.d.ts.map +1 -0
  50. package/dist/storage/ensure-binding.js +53 -0
  51. package/dist/storage/ensure-binding.js.map +1 -0
  52. package/dist/storage/sqlite.d.ts +74 -20
  53. package/dist/storage/sqlite.d.ts.map +1 -1
  54. package/dist/storage/sqlite.js +286 -118
  55. package/dist/storage/sqlite.js.map +1 -1
  56. package/dist/telemetry.d.ts +12 -5
  57. package/dist/telemetry.d.ts.map +1 -1
  58. package/dist/telemetry.js +156 -40
  59. package/dist/telemetry.js.map +1 -1
  60. package/dist/tools/memory-search.d.ts +3 -1
  61. package/dist/tools/memory-search.d.ts.map +1 -1
  62. package/dist/tools/memory-search.js +3 -1
  63. package/dist/tools/memory-search.js.map +1 -1
  64. package/dist/types.d.ts +1 -2
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/viewer/html.d.ts.map +1 -1
  68. package/dist/viewer/html.js +2660 -889
  69. package/dist/viewer/html.js.map +1 -1
  70. package/dist/viewer/server.d.ts +30 -8
  71. package/dist/viewer/server.d.ts.map +1 -1
  72. package/dist/viewer/server.js +965 -193
  73. package/dist/viewer/server.js.map +1 -1
  74. package/index.ts +384 -43
  75. package/openclaw.plugin.json +1 -1
  76. package/package.json +3 -2
  77. package/scripts/postinstall.cjs +1 -1
  78. package/skill/memos-memory-guide/SKILL.md +64 -26
  79. package/src/capture/index.ts +37 -1
  80. package/src/client/connector.ts +91 -28
  81. package/src/client/hub.ts +18 -0
  82. package/src/client/skill-sync.ts +14 -0
  83. package/src/config.ts +0 -2
  84. package/src/hub/server.ts +259 -78
  85. package/src/hub/user-manager.ts +7 -3
  86. package/src/index.ts +10 -2
  87. package/src/ingest/providers/index.ts +41 -7
  88. package/src/recall/engine.ts +84 -1
  89. package/src/shared/llm-call.ts +97 -9
  90. package/src/sharing/types.ts +1 -1
  91. package/src/skill/evolver.ts +5 -0
  92. package/src/storage/ensure-binding.ts +52 -0
  93. package/src/storage/sqlite.ts +295 -144
  94. package/src/telemetry.ts +172 -41
  95. package/src/tools/memory-search.ts +2 -1
  96. package/src/types.ts +1 -2
  97. package/src/viewer/html.ts +2660 -889
  98. package/src/viewer/server.ts +888 -177
@@ -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
  }
@@ -228,6 +243,16 @@ class ViewerServer {
228
243
  }
229
244
  if (p === "/api/memories" && req.method === "GET")
230
245
  this.serveMemories(res, url);
246
+ else if (p === "/api/memories/share-local" && req.method === "POST")
247
+ this.handleMemoryLocalShare(req, res);
248
+ else if (p === "/api/memories/unshare-local" && req.method === "POST")
249
+ this.handleMemoryLocalUnshare(req, res);
250
+ else if (p.match(/^\/api\/memory\/[^/]+\/scope$/) && req.method === "PUT")
251
+ this.handleMemoryScope(req, res, p);
252
+ else if (p.match(/^\/api\/task\/[^/]+\/scope$/) && req.method === "PUT")
253
+ this.handleTaskScope(req, res, p);
254
+ else if (p.match(/^\/api\/skill\/[^/]+\/scope$/) && req.method === "PUT")
255
+ this.handleSkillScope(req, res, p);
231
256
  else if (p === "/api/stats")
232
257
  this.serveStats(res, url);
233
258
  else if (p === "/api/metrics")
@@ -282,6 +307,10 @@ class ViewerServer {
282
307
  this.handleSharingApproveUser(req, res);
283
308
  else if (p === "/api/sharing/reject-user" && req.method === "POST")
284
309
  this.handleSharingRejectUser(req, res);
310
+ else if (p === "/api/sharing/remove-user" && req.method === "POST")
311
+ this.handleSharingRemoveUser(req, res);
312
+ else if (p === "/api/sharing/change-role" && req.method === "POST")
313
+ this.handleSharingChangeRole(req, res);
285
314
  else if (p === "/api/sharing/retry-join" && req.method === "POST")
286
315
  this.handleRetryJoin(req, res);
287
316
  else if (p === "/api/sharing/search/memories" && req.method === "POST")
@@ -302,6 +331,8 @@ class ViewerServer {
302
331
  this.handleSharingTaskUnshare(req, res);
303
332
  else if (p === "/api/sharing/update-username" && req.method === "POST")
304
333
  this.handleUpdateUsername(req, res);
334
+ else if (p === "/api/sharing/rename-user" && req.method === "POST")
335
+ this.handleAdminRenameUser(req, res);
305
336
  else if (p === "/api/sharing/test-hub" && req.method === "POST")
306
337
  this.handleTestHubConnection(req, res);
307
338
  else if (p === "/api/sharing/memories/share" && req.method === "POST")
@@ -314,28 +345,26 @@ class ViewerServer {
314
345
  this.handleSharingSkillShare(req, res);
315
346
  else if (p === "/api/sharing/skills/unshare" && req.method === "POST")
316
347
  this.handleSharingSkillUnshare(req, res);
317
- else if (p === "/api/sharing/groups" && req.method === "GET")
318
- this.serveSharingGroups(res);
319
- else if (p === "/api/sharing/groups" && req.method === "POST")
320
- this.handleSharingGroupCreate(req, res);
321
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "PUT")
322
- this.handleSharingGroupUpdate(req, res, p);
323
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "DELETE")
324
- this.handleSharingGroupDelete(res, p);
325
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "GET")
326
- this.serveSharingGroupMembers(res, p);
327
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "POST")
328
- this.handleSharingGroupAddMember(req, res, p);
329
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "DELETE")
330
- this.handleSharingGroupRemoveMember(req, res, p);
331
348
  else if (p === "/api/sharing/users" && req.method === "GET")
332
349
  this.serveSharingUsers(res);
350
+ else if (p === "/api/sharing/notifications" && req.method === "GET")
351
+ this.serveSharingNotifications(res, url);
352
+ else if (p === "/api/sharing/notifications/read" && req.method === "POST")
353
+ this.handleSharingNotificationsRead(req, res);
354
+ else if (p === "/api/sharing/notifications/clear" && req.method === "POST")
355
+ this.handleSharingNotificationsClear(req, res);
356
+ else if (p === "/api/notifications/stream" && req.method === "GET")
357
+ this.handleNotifSSE(req, res);
333
358
  else if (p === "/api/admin/shared-tasks" && req.method === "GET")
334
359
  this.serveAdminSharedTasks(res);
360
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET")
361
+ this.serveHubTaskDetail(res, p);
335
362
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE")
336
363
  this.handleAdminDeleteTask(res, p);
337
364
  else if (p === "/api/admin/shared-skills" && req.method === "GET")
338
365
  this.serveAdminSharedSkills(res);
366
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET")
367
+ this.serveHubSkillDetail(res, p);
339
368
  else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE")
340
369
  this.handleAdminDeleteSkill(res, p);
341
370
  else if (p === "/api/admin/shared-memories" && req.method === "GET")
@@ -513,7 +542,11 @@ class ViewerServer {
513
542
  conditions.push("role = ?");
514
543
  params.push(role);
515
544
  }
516
- if (owner) {
545
+ if (owner && owner.startsWith("agent:")) {
546
+ conditions.push("(owner = ? OR owner = 'public')");
547
+ params.push(owner);
548
+ }
549
+ else if (owner) {
517
550
  conditions.push("owner = ?");
518
551
  params.push(owner);
519
552
  }
@@ -527,16 +560,20 @@ class ViewerServer {
527
560
  }
528
561
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
529
562
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params);
530
- const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
563
+ const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY CASE WHEN dedup_status IN ('duplicate','merged') THEN 1 ELSE 0 END ASC, created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
531
564
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
532
565
  const chunkIds = rawMemories.map((m) => m.id);
533
566
  const sharingMap = new Map();
567
+ const localShareMap = new Map();
534
568
  if (chunkIds.length > 0) {
535
569
  try {
536
570
  const placeholders = chunkIds.map(() => "?").join(",");
537
571
  const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds);
538
572
  for (const r of sharedRows)
539
573
  sharingMap.set(r.source_chunk_id, r);
574
+ const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds);
575
+ for (const r of localRows)
576
+ localShareMap.set(r.chunk_id, r);
540
577
  }
541
578
  catch {
542
579
  }
@@ -548,8 +585,12 @@ class ViewerServer {
548
585
  out.merge_sources = sources;
549
586
  }
550
587
  const shared = sharingMap.get(m.id);
588
+ const localShared = localShareMap.get(m.id);
551
589
  out.sharingVisibility = shared?.visibility ?? null;
552
590
  out.sharingGroupId = shared?.group_id ?? null;
591
+ out.localSharing = out.owner === "public";
592
+ out.localSharingManaged = !!localShared;
593
+ out.localOriginalOwner = localShared?.original_owner ?? null;
553
594
  return out;
554
595
  });
555
596
  this.store.recordViewerEvent("list");
@@ -564,19 +605,35 @@ class ViewerServer {
564
605
  this.jsonResponse(res, data);
565
606
  }
566
607
  serveToolMetrics(res, url) {
567
- const minutes = Math.min(1440, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
608
+ const fromParam = url.searchParams.get("from");
609
+ const toParam = url.searchParams.get("to");
610
+ if (fromParam) {
611
+ const fromMs = new Date(fromParam).getTime();
612
+ const toMs = toParam ? new Date(toParam).getTime() : Date.now();
613
+ if (isNaN(fromMs) || isNaN(toMs)) {
614
+ this.jsonResponse(res, { error: "Invalid date" }, 400);
615
+ return;
616
+ }
617
+ const diffMin = Math.max(10, Math.min(43200, Math.round((toMs - fromMs) / 60000)));
618
+ const data = this.store.getToolMetrics(diffMin, fromMs, toMs);
619
+ this.jsonResponse(res, data);
620
+ return;
621
+ }
622
+ const minutes = Math.min(43200, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
568
623
  const data = this.store.getToolMetrics(minutes);
569
624
  this.jsonResponse(res, data);
570
625
  }
571
626
  serveTasks(res, url) {
572
627
  this.store.recordViewerEvent("tasks_list");
573
628
  const status = url.searchParams.get("status") ?? undefined;
629
+ const owner = url.searchParams.get("owner") ?? undefined;
574
630
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
575
631
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
576
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
632
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
577
633
  const db = this.store.db;
578
634
  const items = tasks.map((t) => {
579
- const meta = db.prepare("SELECT skill_status FROM tasks WHERE id = ?").get(t.id);
635
+ const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id);
636
+ const sharedTask = db.prepare("SELECT visibility FROM hub_tasks WHERE source_task_id = ? ORDER BY updated_at DESC LIMIT 1").get(t.id);
580
637
  return {
581
638
  id: t.id,
582
639
  sessionKey: t.sessionKey,
@@ -587,6 +644,8 @@ class ViewerServer {
587
644
  endedAt: t.endedAt,
588
645
  chunkCount: this.store.countChunksByTask(t.id),
589
646
  skillStatus: meta?.skill_status ?? null,
647
+ owner: meta?.owner ?? "agent:main",
648
+ sharingVisibility: sharedTask?.visibility ?? null,
590
649
  };
591
650
  });
592
651
  this.jsonResponse(res, { tasks: items, total, limit, offset });
@@ -622,6 +681,7 @@ class ViewerServer {
622
681
  title: task.title,
623
682
  summary: task.summary,
624
683
  status: task.status,
684
+ owner: task.owner ?? "agent:main",
625
685
  startedAt: task.startedAt,
626
686
  endedAt: task.endedAt,
627
687
  chunks: chunkItems,
@@ -630,6 +690,7 @@ class ViewerServer {
630
690
  skillLinks,
631
691
  sharingVisibility: sharedTask?.visibility ?? null,
632
692
  sharingGroupId: sharedTask?.group_id ?? null,
693
+ hubTaskId: sharedTask ? true : false,
633
694
  });
634
695
  }
635
696
  serveStats(res, url) {
@@ -663,12 +724,21 @@ class ViewerServer {
663
724
  embCount = db.prepare("SELECT COUNT(*) as count FROM embeddings").get().count;
664
725
  }
665
726
  catch { /* table may not exist */ }
666
- const sessionQuery = ownerFilter
667
- ? "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"
668
- : "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";
669
- const sessionList = (ownerFilter
670
- ? db.prepare(sessionQuery).all(ownerFilter)
671
- : db.prepare(sessionQuery).all());
727
+ let sessionQuery;
728
+ let sessionParams;
729
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
730
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR owner = 'public') GROUP BY session_key ORDER BY latest DESC";
731
+ sessionParams = [ownerFilter];
732
+ }
733
+ else if (ownerFilter) {
734
+ 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";
735
+ sessionParams = [ownerFilter];
736
+ }
737
+ else {
738
+ 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";
739
+ sessionParams = [];
740
+ }
741
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams);
672
742
  let skillCount = 0;
673
743
  try {
674
744
  skillCount = db.prepare("SELECT COUNT(*) as count FROM skills").get().count;
@@ -682,10 +752,17 @@ class ViewerServer {
682
752
  catch { /* column may not exist yet */ }
683
753
  let owners = [];
684
754
  try {
685
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all();
755
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all();
686
756
  owners = ownerRows.map((o) => o.owner);
687
757
  }
688
758
  catch { /* column may not exist yet */ }
759
+ let currentAgentOwner = "agent:main";
760
+ try {
761
+ 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();
762
+ if (latest?.owner)
763
+ currentAgentOwner = latest.owner;
764
+ }
765
+ catch { /* best-effort */ }
689
766
  this.jsonResponse(res, {
690
767
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
691
768
  totalSkills: skillCount,
@@ -694,6 +771,7 @@ class ViewerServer {
694
771
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
695
772
  sessions: sessionList,
696
773
  owners,
774
+ currentAgentOwner,
697
775
  });
698
776
  }
699
777
  catch (e) {
@@ -817,7 +895,12 @@ class ViewerServer {
817
895
  if (visibility) {
818
896
  skills = skills.filter(s => s.visibility === visibility);
819
897
  }
820
- this.jsonResponse(res, { skills });
898
+ const db = this.store.db;
899
+ const enriched = skills.map(s => {
900
+ const hub = db.prepare("SELECT visibility FROM hub_skills WHERE source_skill_id = ? ORDER BY updated_at DESC LIMIT 1").get(s.id);
901
+ return { ...s, sharingVisibility: hub?.visibility ?? null };
902
+ });
903
+ this.jsonResponse(res, { skills: enriched });
821
904
  }
822
905
  serveSkillDetail(res, urlPath) {
823
906
  const skillId = urlPath.replace("/api/skill/", "");
@@ -1077,7 +1160,7 @@ class ViewerServer {
1077
1160
  }
1078
1161
  });
1079
1162
  }
1080
- handleSkillDelete(res, urlPath) {
1163
+ async handleSkillDelete(res, urlPath) {
1081
1164
  const skillId = urlPath.replace("/api/skill/", "");
1082
1165
  const skill = this.store.getSkill(skillId);
1083
1166
  if (!skill) {
@@ -1085,7 +1168,18 @@ class ViewerServer {
1085
1168
  res.end(JSON.stringify({ error: "Skill not found" }));
1086
1169
  return;
1087
1170
  }
1088
- // Remove skill directory from disk
1171
+ try {
1172
+ const hub = this.resolveHubConnection();
1173
+ if (hub) {
1174
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
1175
+ method: "POST",
1176
+ body: JSON.stringify({ sourceSkillId: skillId }),
1177
+ }).catch(() => { });
1178
+ }
1179
+ const db = this.store.db;
1180
+ db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
1181
+ }
1182
+ catch (_) { }
1089
1183
  try {
1090
1184
  if (skill.dirPath && node_fs_1.default.existsSync(skill.dirPath)) {
1091
1185
  node_fs_1.default.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1135,7 +1229,15 @@ class ViewerServer {
1135
1229
  const cleaned = chunk.role === "user" && chunk.content
1136
1230
  ? { ...chunk, content: (0, capture_1.stripInboundMetadata)(chunk.content) }
1137
1231
  : chunk;
1138
- this.jsonResponse(res, { memory: cleaned });
1232
+ const localShared = this.store.getLocalSharedMemory(chunkId);
1233
+ this.jsonResponse(res, {
1234
+ memory: {
1235
+ ...cleaned,
1236
+ localSharing: cleaned.owner === "public",
1237
+ localSharingManaged: !!localShared,
1238
+ localOriginalOwner: localShared?.originalOwner ?? null,
1239
+ },
1240
+ });
1139
1241
  }
1140
1242
  handleUpdate(req, res, urlPath) {
1141
1243
  const chunkId = urlPath.replace("/api/memory/", "");
@@ -1170,6 +1272,360 @@ class ViewerServer {
1170
1272
  res.end(JSON.stringify({ error: "Not found" }));
1171
1273
  }
1172
1274
  }
1275
+ handleMemoryLocalShare(req, res) {
1276
+ this.readBody(req, (body) => {
1277
+ try {
1278
+ const parsed = JSON.parse(body || "{}");
1279
+ const chunkId = String(parsed.chunkId || "");
1280
+ if (!chunkId)
1281
+ return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1282
+ const result = this.store.markMemorySharedLocally(chunkId);
1283
+ if (!result.ok) {
1284
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "share_failed" }, result.reason === "not_found" ? 404 : 400);
1285
+ }
1286
+ this.jsonResponse(res, {
1287
+ ok: true,
1288
+ chunkId,
1289
+ owner: result.owner,
1290
+ localSharing: true,
1291
+ localSharingManaged: true,
1292
+ localOriginalOwner: result.originalOwner ?? null,
1293
+ });
1294
+ }
1295
+ catch (err) {
1296
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1297
+ }
1298
+ });
1299
+ }
1300
+ handleMemoryLocalUnshare(req, res) {
1301
+ this.readBody(req, (body) => {
1302
+ try {
1303
+ const parsed = JSON.parse(body || "{}");
1304
+ const chunkId = String(parsed.chunkId || "");
1305
+ const privateOwner = typeof parsed.privateOwner === "string" ? parsed.privateOwner : undefined;
1306
+ if (!chunkId)
1307
+ return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1308
+ const result = this.store.unmarkMemorySharedLocally(chunkId, privateOwner);
1309
+ if (!result.ok) {
1310
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "unshare_failed" }, result.reason === "not_found" ? 404 : 400);
1311
+ }
1312
+ this.jsonResponse(res, {
1313
+ ok: true,
1314
+ chunkId,
1315
+ owner: result.owner,
1316
+ localSharing: false,
1317
+ localOriginalOwner: result.originalOwner ?? null,
1318
+ });
1319
+ }
1320
+ catch (err) {
1321
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1322
+ }
1323
+ });
1324
+ }
1325
+ // ─── Unified scope API ───
1326
+ handleMemoryScope(req, res, urlPath) {
1327
+ const chunkId = urlPath.split("/")[3];
1328
+ this.readBody(req, async (body) => {
1329
+ try {
1330
+ const parsed = JSON.parse(body || "{}");
1331
+ const scope = parsed.scope;
1332
+ if (!["private", "local", "team"].includes(scope)) {
1333
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1334
+ }
1335
+ const db = this.store.db;
1336
+ const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId);
1337
+ if (!chunk)
1338
+ return this.jsonResponse(res, { ok: false, error: "not_found" }, 404);
1339
+ if (chunk.dedup_status && chunk.dedup_status !== "active") {
1340
+ return this.jsonResponse(res, { ok: false, error: "inactive_memory", message: "Merged/duplicate memories cannot be shared" }, 400);
1341
+ }
1342
+ const isLocalShared = chunk.owner === "public";
1343
+ const hubMemory = this.getHubMemoryForChunk(chunkId);
1344
+ const isTeamShared = !!hubMemory;
1345
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1346
+ if (scope === currentScope) {
1347
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1348
+ }
1349
+ let hubSynced = false;
1350
+ if (scope === "team") {
1351
+ if (!isTeamShared) {
1352
+ const hubClient = await this.resolveHubClientAware();
1353
+ const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId);
1354
+ const response = await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
1355
+ method: "POST",
1356
+ body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1357
+ });
1358
+ if (!isLocalShared)
1359
+ this.store.markMemorySharedLocally(chunkId);
1360
+ if (hubClient.userId) {
1361
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1362
+ this.store.upsertHubMemory({
1363
+ id: response?.memoryId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
1364
+ sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1365
+ role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1366
+ kind: refreshedChunk.kind, groupId: null, visibility: "public",
1367
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1368
+ });
1369
+ }
1370
+ hubSynced = true;
1371
+ }
1372
+ else {
1373
+ if (!isLocalShared)
1374
+ this.store.markMemorySharedLocally(chunkId);
1375
+ }
1376
+ }
1377
+ else if (scope === "local") {
1378
+ if (isTeamShared) {
1379
+ try {
1380
+ const hubClient = await this.resolveHubClientAware();
1381
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1382
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1383
+ });
1384
+ if (hubClient.userId)
1385
+ this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1386
+ hubSynced = true;
1387
+ }
1388
+ catch (err) {
1389
+ this.log.warn(`Failed to unshare memory from team: ${err}`);
1390
+ }
1391
+ }
1392
+ if (!isLocalShared)
1393
+ this.store.markMemorySharedLocally(chunkId);
1394
+ }
1395
+ else {
1396
+ if (isTeamShared) {
1397
+ try {
1398
+ const hubClient = await this.resolveHubClientAware();
1399
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1400
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1401
+ });
1402
+ if (hubClient.userId)
1403
+ this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1404
+ hubSynced = true;
1405
+ }
1406
+ catch (err) {
1407
+ this.log.warn(`Failed to unshare memory from team: ${err}`);
1408
+ }
1409
+ }
1410
+ if (isLocalShared)
1411
+ this.store.unmarkMemorySharedLocally(chunkId);
1412
+ }
1413
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1414
+ }
1415
+ catch (err) {
1416
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1417
+ }
1418
+ });
1419
+ }
1420
+ handleTaskScope(req, res, urlPath) {
1421
+ const taskId = urlPath.split("/")[3];
1422
+ this.readBody(req, async (body) => {
1423
+ try {
1424
+ const parsed = JSON.parse(body || "{}");
1425
+ const scope = parsed.scope;
1426
+ if (!["private", "local", "team"].includes(scope)) {
1427
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1428
+ }
1429
+ const task = this.store.getTask(taskId);
1430
+ if (!task)
1431
+ return this.jsonResponse(res, { ok: false, error: "task_not_found" }, 404);
1432
+ if (scope !== "private" && task.status !== "completed") {
1433
+ return this.jsonResponse(res, { ok: false, error: "only_completed_tasks_can_be_shared" }, 400);
1434
+ }
1435
+ const isLocalShared = task.owner === "public";
1436
+ const hubTask = this.getHubTaskForLocal(taskId);
1437
+ const isTeamShared = !!hubTask;
1438
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1439
+ if (scope === currentScope) {
1440
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1441
+ }
1442
+ let hubSynced = false;
1443
+ if (scope === "team") {
1444
+ if (!isTeamShared) {
1445
+ const chunks = this.store.getChunksByTask(taskId);
1446
+ const hubClient = await this.resolveHubClientAware();
1447
+ const refreshedTask = this.store.getTask(taskId);
1448
+ const response = await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
1449
+ method: "POST",
1450
+ body: JSON.stringify({
1451
+ task: { id: refreshedTask.id, sourceTaskId: refreshedTask.id, title: refreshedTask.title, summary: refreshedTask.summary, groupId: null, visibility: "public", createdAt: refreshedTask.startedAt ?? Date.now(), updatedAt: refreshedTask.updatedAt ?? Date.now() },
1452
+ chunks: chunks.map((c) => ({ id: c.id, hubTaskId: refreshedTask.id, sourceTaskId: refreshedTask.id, sourceChunkId: c.id, role: c.role, content: c.content, summary: c.summary, kind: c.kind, groupId: null, visibility: "public", createdAt: c.createdAt ?? Date.now() })),
1453
+ }),
1454
+ });
1455
+ if (hubClient.userId) {
1456
+ const existing = this.store.getHubTaskBySource(hubClient.userId, taskId);
1457
+ this.store.upsertHubTask({
1458
+ id: response?.taskId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
1459
+ sourceTaskId: taskId, sourceUserId: hubClient.userId, title: refreshedTask.title ?? "",
1460
+ summary: refreshedTask.summary ?? "", groupId: null, visibility: "public",
1461
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1462
+ });
1463
+ }
1464
+ hubSynced = true;
1465
+ }
1466
+ if (!isLocalShared) {
1467
+ const originalOwner = task.owner;
1468
+ const db = this.store.db;
1469
+ db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
1470
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1471
+ }
1472
+ }
1473
+ if (scope === "local") {
1474
+ if (!isLocalShared) {
1475
+ const originalOwner = task.owner;
1476
+ const db = this.store.db;
1477
+ db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
1478
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1479
+ }
1480
+ }
1481
+ if (scope === "local" && isTeamShared) {
1482
+ try {
1483
+ const hubClient = await this.resolveHubClientAware();
1484
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1485
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1486
+ });
1487
+ if (hubClient.userId)
1488
+ this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1489
+ hubSynced = true;
1490
+ }
1491
+ catch (err) {
1492
+ this.log.warn(`Failed to unshare task from team: ${err}`);
1493
+ }
1494
+ }
1495
+ if (scope === "private") {
1496
+ if (isTeamShared) {
1497
+ try {
1498
+ const hubClient = await this.resolveHubClientAware();
1499
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1500
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1501
+ });
1502
+ if (hubClient.userId)
1503
+ this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1504
+ hubSynced = true;
1505
+ }
1506
+ catch (err) {
1507
+ this.log.warn(`Failed to unshare task from team: ${err}`);
1508
+ }
1509
+ }
1510
+ if (isLocalShared) {
1511
+ const db = this.store.db;
1512
+ const shared = db.prepare("SELECT original_owner FROM local_shared_tasks WHERE task_id = ?").get(taskId);
1513
+ const restoreOwner = shared?.original_owner ?? task.owner;
1514
+ if (restoreOwner && restoreOwner !== "public") {
1515
+ db.prepare("UPDATE tasks SET owner = ? WHERE id = ?").run(restoreOwner, taskId);
1516
+ }
1517
+ db.prepare("DELETE FROM local_shared_tasks WHERE task_id = ?").run(taskId);
1518
+ }
1519
+ }
1520
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1521
+ }
1522
+ catch (err) {
1523
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1524
+ }
1525
+ });
1526
+ }
1527
+ handleSkillScope(req, res, urlPath) {
1528
+ const skillId = urlPath.split("/")[3];
1529
+ this.readBody(req, async (body) => {
1530
+ try {
1531
+ const parsed = JSON.parse(body || "{}");
1532
+ const scope = parsed.scope;
1533
+ if (!["private", "local", "team"].includes(scope)) {
1534
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1535
+ }
1536
+ const skill = this.store.getSkill(skillId);
1537
+ if (!skill)
1538
+ return this.jsonResponse(res, { ok: false, error: "skill_not_found" }, 404);
1539
+ if (scope !== "private" && skill.status !== "active") {
1540
+ return this.jsonResponse(res, { ok: false, error: "only_active_skills_can_be_shared" }, 400);
1541
+ }
1542
+ const isLocalShared = skill.visibility === "public";
1543
+ const hubSkill = this.getHubSkillForLocal(skillId);
1544
+ const isTeamShared = !!hubSkill;
1545
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1546
+ if (scope === currentScope) {
1547
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1548
+ }
1549
+ let hubSynced = false;
1550
+ if (scope === "team") {
1551
+ if (!isTeamShared) {
1552
+ const bundle = (0, skill_sync_1.buildSkillBundleForHub)(this.store, skillId);
1553
+ const hubClient = await this.resolveHubClientAware();
1554
+ const response = await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
1555
+ method: "POST",
1556
+ body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
1557
+ });
1558
+ if (hubClient.userId) {
1559
+ const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
1560
+ this.store.upsertHubSkill({
1561
+ id: response?.skillId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
1562
+ sourceSkillId: skillId, sourceUserId: hubClient.userId,
1563
+ name: skill.name, description: skill.description, version: skill.version,
1564
+ groupId: null, visibility: "public",
1565
+ bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
1566
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1567
+ });
1568
+ }
1569
+ hubSynced = true;
1570
+ }
1571
+ if (!isLocalShared)
1572
+ this.store.setSkillVisibility(skillId, "public");
1573
+ }
1574
+ if (scope === "local") {
1575
+ if (!isLocalShared)
1576
+ this.store.setSkillVisibility(skillId, "public");
1577
+ }
1578
+ if (scope === "local" && isTeamShared) {
1579
+ try {
1580
+ const hubClient = await this.resolveHubClientAware();
1581
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1582
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1583
+ });
1584
+ if (hubClient.userId)
1585
+ this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1586
+ hubSynced = true;
1587
+ }
1588
+ catch (err) {
1589
+ this.log.warn(`Failed to unpublish skill from team: ${err}`);
1590
+ }
1591
+ }
1592
+ if (scope === "private") {
1593
+ if (isTeamShared) {
1594
+ try {
1595
+ const hubClient = await this.resolveHubClientAware();
1596
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1597
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1598
+ });
1599
+ if (hubClient.userId)
1600
+ this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1601
+ hubSynced = true;
1602
+ }
1603
+ catch (err) {
1604
+ this.log.warn(`Failed to unpublish skill from team: ${err}`);
1605
+ }
1606
+ }
1607
+ if (isLocalShared)
1608
+ this.store.setSkillVisibility(skillId, "private");
1609
+ }
1610
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1611
+ }
1612
+ catch (err) {
1613
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1614
+ }
1615
+ });
1616
+ }
1617
+ getHubMemoryForChunk(chunkId) {
1618
+ const db = this.store.db;
1619
+ return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1620
+ }
1621
+ getHubTaskForLocal(taskId) {
1622
+ const db = this.store.db;
1623
+ return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
1624
+ }
1625
+ getHubSkillForLocal(skillId) {
1626
+ const db = this.store.db;
1627
+ return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
1628
+ }
1173
1629
  handleDeleteSession(res, url) {
1174
1630
  const key = url.searchParams.get("key");
1175
1631
  if (!key) {
@@ -1207,7 +1663,8 @@ class ViewerServer {
1207
1663
  // ─── Config API ───
1208
1664
  getOpenClawConfigPath() {
1209
1665
  const home = process.env.HOME || process.env.USERPROFILE || "";
1210
- return node_path_1.default.join(home, ".openclaw", "openclaw.json");
1666
+ const ocHome = process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
1667
+ return node_path_1.default.join(ocHome, "openclaw.json");
1211
1668
  }
1212
1669
  getPluginEntryConfig(raw) {
1213
1670
  const entries = raw?.plugins?.entries ?? {};
@@ -1263,8 +1720,7 @@ class ViewerServer {
1263
1720
  base.admin.rejectSupported = true;
1264
1721
  base.connection.connected = true;
1265
1722
  base.connection.hubUrl = resolvedHubUrl ?? undefined;
1266
- // 通过 hub API 获取 admin 用户的真实信息(含分组)
1267
- let adminUser = { username: "hub-admin", role: "admin", groups: [] };
1723
+ let adminUser = { username: "hub-admin", role: "admin" };
1268
1724
  try {
1269
1725
  const hub = this.resolveHubConnection();
1270
1726
  if (hub) {
@@ -1274,7 +1730,6 @@ class ViewerServer {
1274
1730
  id: me.id,
1275
1731
  username: me.username ?? "hub-admin",
1276
1732
  role: me.role ?? "admin",
1277
- groups: Array.isArray(me.groups) ? me.groups : [],
1278
1733
  };
1279
1734
  }
1280
1735
  }
@@ -1306,6 +1761,9 @@ class ViewerServer {
1306
1761
  if (status.user?.status === "rejected") {
1307
1762
  output.connection.rejected = true;
1308
1763
  }
1764
+ if (status.user?.status === "removed") {
1765
+ output.connection.removed = true;
1766
+ }
1309
1767
  if (status.connected && status.hubUrl) {
1310
1768
  try {
1311
1769
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
@@ -1383,6 +1841,75 @@ class ViewerServer {
1383
1841
  }
1384
1842
  });
1385
1843
  }
1844
+ handleSharingChangeRole(req, res) {
1845
+ this.readBody(req, async (body) => {
1846
+ if (!this.ctx)
1847
+ return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1848
+ try {
1849
+ const parsed = JSON.parse(body || "{}");
1850
+ const hub = this.resolveHubConnection();
1851
+ if (!hub)
1852
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
1853
+ const result = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/change-role", {
1854
+ method: "POST",
1855
+ body: JSON.stringify({ userId: parsed.userId, role: parsed.role }),
1856
+ });
1857
+ this.jsonResponse(res, { ok: true, result });
1858
+ }
1859
+ catch (err) {
1860
+ this.jsonResponse(res, { ok: false, error: String(err) });
1861
+ }
1862
+ });
1863
+ }
1864
+ handleSharingRemoveUser(req, res) {
1865
+ this.readBody(req, async (body) => {
1866
+ if (!this.ctx)
1867
+ return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1868
+ try {
1869
+ const parsed = JSON.parse(body || "{}");
1870
+ const hub = this.resolveHubConnection();
1871
+ if (!hub)
1872
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
1873
+ const result = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/remove-user", {
1874
+ method: "POST",
1875
+ body: JSON.stringify({ userId: parsed.userId, cleanResources: parsed.cleanResources === true }),
1876
+ });
1877
+ this.jsonResponse(res, { ok: true, result });
1878
+ }
1879
+ catch (err) {
1880
+ this.jsonResponse(res, { ok: false, error: String(err) });
1881
+ }
1882
+ });
1883
+ }
1884
+ handleAdminRenameUser(req, res) {
1885
+ this.readBody(req, async (body) => {
1886
+ if (!this.ctx)
1887
+ return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1888
+ try {
1889
+ const parsed = JSON.parse(body || "{}");
1890
+ const hub = this.resolveHubConnection();
1891
+ if (!hub)
1892
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
1893
+ const result = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/rename-user", {
1894
+ method: "POST",
1895
+ body: JSON.stringify({ userId: parsed.userId, username: parsed.username }),
1896
+ });
1897
+ this.jsonResponse(res, { ok: true, result });
1898
+ }
1899
+ catch (err) {
1900
+ const errStr = String(err);
1901
+ if (errStr.includes("username_taken")) {
1902
+ this.jsonResponse(res, { ok: false, error: "username_taken" });
1903
+ }
1904
+ else if (errStr.includes("invalid_params")) {
1905
+ this.jsonResponse(res, { ok: false, error: "invalid_params" });
1906
+ }
1907
+ else {
1908
+ this.jsonResponse(res, { ok: false, error: errStr });
1909
+ }
1910
+ }
1911
+ });
1912
+ }
1386
1913
  handleRetryJoin(req, res) {
1387
1914
  this.readBody(req, async (_body) => {
1388
1915
  if (!this.ctx)
@@ -1398,12 +1925,22 @@ class ViewerServer {
1398
1925
  }
1399
1926
  try {
1400
1927
  const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
1928
+ const localIPs = this.getLocalIPs();
1929
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1930
+ try {
1931
+ const u = new URL(hubUrl);
1932
+ if (localIPs.includes(u.hostname)) {
1933
+ return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1934
+ }
1935
+ }
1936
+ catch { }
1401
1937
  const os = await Promise.resolve().then(() => __importStar(require("os")));
1402
- const username = os.userInfo().username || "user";
1938
+ const nickname = sharing.client?.nickname;
1939
+ const username = nickname || os.userInfo().username || "user";
1403
1940
  const hostname = os.hostname() || "unknown";
1404
1941
  const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
1405
1942
  method: "POST",
1406
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1943
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
1407
1944
  });
1408
1945
  this.store.setClientHubConnection({
1409
1946
  hubUrl,
@@ -1425,7 +1962,14 @@ class ViewerServer {
1425
1962
  return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1426
1963
  try {
1427
1964
  const limit = Number(url.searchParams.get("limit") || 40);
1428
- const data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
1965
+ const hub = this.resolveHubConnection();
1966
+ let data;
1967
+ if (hub) {
1968
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1969
+ }
1970
+ else {
1971
+ data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
1972
+ }
1429
1973
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1430
1974
  }
1431
1975
  catch (err) {
@@ -1437,7 +1981,14 @@ class ViewerServer {
1437
1981
  return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1438
1982
  try {
1439
1983
  const limit = Number(url.searchParams.get("limit") || 40);
1440
- const data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
1984
+ const hub = this.resolveHubConnection();
1985
+ let data;
1986
+ if (hub) {
1987
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1988
+ }
1989
+ else {
1990
+ data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
1991
+ }
1441
1992
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1442
1993
  }
1443
1994
  catch (err) {
@@ -1449,7 +2000,14 @@ class ViewerServer {
1449
2000
  return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1450
2001
  try {
1451
2002
  const limit = Number(url.searchParams.get("limit") || 40);
1452
- const data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
2003
+ const hub = this.resolveHubConnection();
2004
+ let data;
2005
+ if (hub) {
2006
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
2007
+ }
2008
+ else {
2009
+ data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
2010
+ }
1453
2011
  this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1454
2012
  }
1455
2013
  catch (err) {
@@ -1466,13 +2024,22 @@ class ViewerServer {
1466
2024
  const query = String(parsed.query || "");
1467
2025
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1468
2026
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1469
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
2027
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1470
2028
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1471
2029
  if (scope === "local") {
1472
2030
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1473
2031
  }
1474
2032
  try {
1475
- const hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2033
+ const conn = this.resolveHubConnection();
2034
+ let hub;
2035
+ if (conn) {
2036
+ hub = await (0, hub_1.hubRequestJson)(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
2037
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
2038
+ });
2039
+ }
2040
+ else {
2041
+ hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2042
+ }
1476
2043
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1477
2044
  }
1478
2045
  catch (err) {
@@ -1860,123 +2427,6 @@ class ViewerServer {
1860
2427
  }
1861
2428
  return (0, hub_1.resolveHubClient)(this.store, this.ctx);
1862
2429
  }
1863
- extractGroupId(path) {
1864
- const m = path.match(/\/api\/sharing\/groups\/([^/]+)/);
1865
- return m ? decodeURIComponent(m[1]) : "";
1866
- }
1867
- async serveSharingGroups(res) {
1868
- const hub = this.resolveHubConnection();
1869
- if (!hub)
1870
- return this.jsonResponse(res, { groups: [], error: "not_configured" });
1871
- try {
1872
- const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/groups", { method: "GET" });
1873
- this.jsonResponse(res, { groups: Array.isArray(data?.groups) ? data.groups : [] });
1874
- }
1875
- catch (err) {
1876
- this.jsonResponse(res, { groups: [], error: String(err) });
1877
- }
1878
- }
1879
- handleSharingGroupCreate(req, res) {
1880
- this.readBody(req, async (body) => {
1881
- const hub = this.resolveHubConnection();
1882
- if (!hub)
1883
- return this.jsonResponse(res, { ok: false, error: "not_configured" });
1884
- try {
1885
- const parsed = JSON.parse(body || "{}");
1886
- const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/groups", {
1887
- method: "POST",
1888
- body: JSON.stringify({ name: parsed.name, description: parsed.description }),
1889
- });
1890
- this.jsonResponse(res, { ok: true, ...data });
1891
- }
1892
- catch (err) {
1893
- this.jsonResponse(res, { ok: false, error: String(err) });
1894
- }
1895
- });
1896
- }
1897
- handleSharingGroupUpdate(req, res, p) {
1898
- this.readBody(req, async (body) => {
1899
- const hub = this.resolveHubConnection();
1900
- if (!hub)
1901
- return this.jsonResponse(res, { ok: false, error: "not_configured" });
1902
- const groupId = this.extractGroupId(p);
1903
- try {
1904
- const parsed = JSON.parse(body || "{}");
1905
- await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, {
1906
- method: "PUT",
1907
- body: JSON.stringify({ name: parsed.name, description: parsed.description }),
1908
- });
1909
- this.jsonResponse(res, { ok: true });
1910
- }
1911
- catch (err) {
1912
- this.jsonResponse(res, { ok: false, error: String(err) });
1913
- }
1914
- });
1915
- }
1916
- async handleSharingGroupDelete(res, p) {
1917
- const hub = this.resolveHubConnection();
1918
- if (!hub)
1919
- return this.jsonResponse(res, { ok: false, error: "not_configured" });
1920
- const groupId = this.extractGroupId(p);
1921
- try {
1922
- await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" });
1923
- this.jsonResponse(res, { ok: true });
1924
- }
1925
- catch (err) {
1926
- this.jsonResponse(res, { ok: false, error: String(err) });
1927
- }
1928
- }
1929
- async serveSharingGroupMembers(res, p) {
1930
- const hub = this.resolveHubConnection();
1931
- if (!hub)
1932
- return this.jsonResponse(res, { members: [], error: "not_configured" });
1933
- const groupId = this.extractGroupId(p);
1934
- try {
1935
- const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "GET" });
1936
- this.jsonResponse(res, { members: Array.isArray(data?.members) ? data.members : [] });
1937
- }
1938
- catch (err) {
1939
- this.jsonResponse(res, { members: [], error: String(err) });
1940
- }
1941
- }
1942
- handleSharingGroupAddMember(req, res, p) {
1943
- this.readBody(req, async (body) => {
1944
- const hub = this.resolveHubConnection();
1945
- if (!hub)
1946
- return this.jsonResponse(res, { ok: false, error: "not_configured" });
1947
- const groupId = this.extractGroupId(p);
1948
- try {
1949
- const parsed = JSON.parse(body || "{}");
1950
- await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
1951
- method: "POST",
1952
- body: JSON.stringify({ userId: parsed.userId }),
1953
- });
1954
- this.jsonResponse(res, { ok: true });
1955
- }
1956
- catch (err) {
1957
- this.jsonResponse(res, { ok: false, error: String(err) });
1958
- }
1959
- });
1960
- }
1961
- handleSharingGroupRemoveMember(req, res, p) {
1962
- this.readBody(req, async (body) => {
1963
- const hub = this.resolveHubConnection();
1964
- if (!hub)
1965
- return this.jsonResponse(res, { ok: false, error: "not_configured" });
1966
- const groupId = this.extractGroupId(p);
1967
- try {
1968
- const parsed = JSON.parse(body || "{}");
1969
- await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
1970
- method: "DELETE",
1971
- body: JSON.stringify({ userId: parsed.userId }),
1972
- });
1973
- this.jsonResponse(res, { ok: true });
1974
- }
1975
- catch (err) {
1976
- this.jsonResponse(res, { ok: false, error: String(err) });
1977
- }
1978
- });
1979
- }
1980
2430
  async serveSharingUsers(res) {
1981
2431
  const hub = this.resolveHubConnection();
1982
2432
  if (!hub)
@@ -1996,7 +2446,17 @@ class ViewerServer {
1996
2446
  return this.jsonResponse(res, { tasks: [], error: "not_configured" });
1997
2447
  try {
1998
2448
  const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-tasks", { method: "GET" });
1999
- this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
2449
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2450
+ for (const tk of tasks) {
2451
+ if (!tk.summary && tk.sourceTaskId) {
2452
+ const local = this.store.getTask(tk.sourceTaskId);
2453
+ if (local) {
2454
+ tk.summary = local.summary;
2455
+ tk.title = tk.title || local.title;
2456
+ }
2457
+ }
2458
+ }
2459
+ this.jsonResponse(res, { tasks });
2000
2460
  }
2001
2461
  catch (err) {
2002
2462
  this.jsonResponse(res, { tasks: [], error: String(err) });
@@ -2015,13 +2475,55 @@ class ViewerServer {
2015
2475
  this.jsonResponse(res, { ok: false, error: String(err) });
2016
2476
  }
2017
2477
  }
2478
+ async serveHubTaskDetail(res, p) {
2479
+ const hub = this.resolveHubConnection();
2480
+ if (!hub)
2481
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2482
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2483
+ if (!m)
2484
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2485
+ const taskId = decodeURIComponent(m[1]);
2486
+ try {
2487
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" });
2488
+ this.jsonResponse(res, data);
2489
+ }
2490
+ catch (err) {
2491
+ this.jsonResponse(res, { error: String(err) }, 500);
2492
+ }
2493
+ }
2494
+ async serveHubSkillDetail(res, p) {
2495
+ const hub = this.resolveHubConnection();
2496
+ if (!hub)
2497
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2498
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2499
+ if (!m)
2500
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2501
+ const skillId = decodeURIComponent(m[1]);
2502
+ try {
2503
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" });
2504
+ this.jsonResponse(res, data);
2505
+ }
2506
+ catch (err) {
2507
+ this.jsonResponse(res, { error: String(err) }, 500);
2508
+ }
2509
+ }
2018
2510
  async serveAdminSharedSkills(res) {
2019
2511
  const hub = this.resolveHubConnection();
2020
2512
  if (!hub)
2021
2513
  return this.jsonResponse(res, { skills: [], error: "not_configured" });
2022
2514
  try {
2023
2515
  const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-skills", { method: "GET" });
2024
- this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
2516
+ const skills = Array.isArray(data?.skills) ? data.skills : [];
2517
+ for (const sk of skills) {
2518
+ if (!sk.description && sk.sourceSkillId) {
2519
+ const local = this.store.getSkill(sk.sourceSkillId);
2520
+ if (local) {
2521
+ sk.description = sk.description || local.description;
2522
+ sk.name = sk.name || local.name;
2523
+ }
2524
+ }
2525
+ }
2526
+ this.jsonResponse(res, { skills });
2025
2527
  }
2026
2528
  catch (err) {
2027
2529
  this.jsonResponse(res, { skills: [], error: String(err) });
@@ -2046,7 +2548,18 @@ class ViewerServer {
2046
2548
  return this.jsonResponse(res, { memories: [], error: "not_configured" });
2047
2549
  try {
2048
2550
  const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-memories", { method: "GET" });
2049
- this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
2551
+ const memories = Array.isArray(data?.memories) ? data.memories : [];
2552
+ for (const m of memories) {
2553
+ if (!m.content && m.sourceChunkId) {
2554
+ const local = this.store.getChunk(m.sourceChunkId);
2555
+ if (local) {
2556
+ m.content = local.content;
2557
+ if (!m.summary && local.summary)
2558
+ m.summary = local.summary;
2559
+ }
2560
+ }
2561
+ }
2562
+ this.jsonResponse(res, { memories });
2050
2563
  }
2051
2564
  catch (err) {
2052
2565
  this.jsonResponse(res, { memories: [], error: String(err) });
@@ -2065,6 +2578,135 @@ class ViewerServer {
2065
2578
  this.jsonResponse(res, { ok: false, error: String(err) });
2066
2579
  }
2067
2580
  }
2581
+ async serveSharingNotifications(res, url) {
2582
+ const hub = this.resolveHubConnection();
2583
+ if (!hub)
2584
+ return this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2585
+ try {
2586
+ const unread = url.searchParams.get("unread") === "1" ? "?unread=1" : "";
2587
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/notifications${unread}`);
2588
+ this.jsonResponse(res, data);
2589
+ }
2590
+ catch {
2591
+ this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2592
+ }
2593
+ }
2594
+ handleSharingNotificationsRead(req, res) {
2595
+ const hub = this.resolveHubConnection();
2596
+ if (!hub)
2597
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
2598
+ this.readBody(req, async (raw) => {
2599
+ try {
2600
+ const body = JSON.parse(raw || "{}");
2601
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2602
+ this.jsonResponse(res, { ok: true });
2603
+ try {
2604
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2605
+ const count = data?.unreadCount ?? 0;
2606
+ this.lastKnownNotifCount = count;
2607
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2608
+ }
2609
+ catch { /* best effort */ }
2610
+ }
2611
+ catch (err) {
2612
+ this.jsonResponse(res, { ok: false, error: String(err) });
2613
+ }
2614
+ });
2615
+ }
2616
+ handleSharingNotificationsClear(req, res) {
2617
+ const hub = this.resolveHubConnection();
2618
+ if (!hub)
2619
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
2620
+ this.readBody(req, async () => {
2621
+ try {
2622
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2623
+ this.jsonResponse(res, { ok: true });
2624
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2625
+ }
2626
+ catch (err) {
2627
+ this.jsonResponse(res, { ok: false, error: String(err) });
2628
+ }
2629
+ });
2630
+ }
2631
+ handleNotifSSE(req, res) {
2632
+ res.writeHead(200, {
2633
+ "Content-Type": "text/event-stream",
2634
+ "Cache-Control": "no-cache",
2635
+ Connection: "keep-alive",
2636
+ "Access-Control-Allow-Origin": "*",
2637
+ });
2638
+ res.write("data: {\"type\":\"connected\"}\n\n");
2639
+ this.notifSSEClients.push(res);
2640
+ if (!this.notifPollTimer)
2641
+ this.startNotifPoll();
2642
+ req.on("close", () => {
2643
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2644
+ if (this.notifSSEClients.length === 0)
2645
+ this.stopNotifPoll();
2646
+ });
2647
+ }
2648
+ broadcastNotifSSE(data) {
2649
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2650
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2651
+ try {
2652
+ c.write(msg);
2653
+ return true;
2654
+ }
2655
+ catch {
2656
+ return false;
2657
+ }
2658
+ });
2659
+ }
2660
+ startNotifPoll() {
2661
+ this.stopNotifPoll();
2662
+ const tick = async () => {
2663
+ const hub = this.resolveHubConnection();
2664
+ if (!hub)
2665
+ return;
2666
+ try {
2667
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2668
+ const count = data?.unreadCount ?? 0;
2669
+ if (count !== this.lastKnownNotifCount) {
2670
+ this.lastKnownNotifCount = count;
2671
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2672
+ }
2673
+ }
2674
+ catch { /* ignore */ }
2675
+ };
2676
+ tick();
2677
+ this.notifPollTimer = setInterval(tick, 3000);
2678
+ }
2679
+ stopNotifPoll() {
2680
+ if (this.notifPollTimer) {
2681
+ clearInterval(this.notifPollTimer);
2682
+ this.notifPollTimer = undefined;
2683
+ }
2684
+ }
2685
+ startHubHeartbeat() {
2686
+ this.stopHubHeartbeat();
2687
+ const sendHeartbeat = async () => {
2688
+ try {
2689
+ const hub = this.resolveHubConnection();
2690
+ if (!hub) {
2691
+ const persisted = this.store.getClientHubConnection();
2692
+ if (persisted?.hubUrl && persisted?.userToken) {
2693
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2694
+ }
2695
+ return;
2696
+ }
2697
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2698
+ }
2699
+ catch { /* best-effort */ }
2700
+ };
2701
+ sendHeartbeat();
2702
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2703
+ }
2704
+ stopHubHeartbeat() {
2705
+ if (this.hubHeartbeatTimer) {
2706
+ clearInterval(this.hubHeartbeatTimer);
2707
+ this.hubHeartbeatTimer = undefined;
2708
+ }
2709
+ }
2068
2710
  getLocalIPs() {
2069
2711
  const nets = node_os_1.default.networkInterfaces();
2070
2712
  const ips = [];
@@ -2116,7 +2758,7 @@ class ViewerServer {
2116
2758
  }
2117
2759
  }
2118
2760
  handleSaveConfig(req, res) {
2119
- this.readBody(req, (body) => {
2761
+ this.readBody(req, async (body) => {
2120
2762
  try {
2121
2763
  const newCfg = JSON.parse(body);
2122
2764
  const cfgPath = this.getOpenClawConfigPath();
@@ -2141,6 +2783,10 @@ class ViewerServer {
2141
2783
  if (!entry.config)
2142
2784
  entry.config = {};
2143
2785
  const config = entry.config;
2786
+ const oldSharing = config.sharing;
2787
+ const oldSharingRole = oldSharing?.role;
2788
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2789
+ const oldClientHubAddress = String(oldSharing?.client?.hubAddress || "");
2144
2790
  if (newCfg.embedding)
2145
2791
  config.embedding = newCfg.embedding;
2146
2792
  if (newCfg.summarizer)
@@ -2174,6 +2820,33 @@ class ViewerServer {
2174
2820
  catch { }
2175
2821
  }
2176
2822
  }
2823
+ const newRole = merged.role;
2824
+ const newEnabled = Boolean(merged.enabled);
2825
+ // Detect disabling sharing or switching away from hub mode
2826
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2827
+ const isHub = newEnabled && newRole === "hub";
2828
+ if (wasHub && !isHub) {
2829
+ await this.notifyHubShutdown();
2830
+ this.stopHubHeartbeat();
2831
+ this.log.info("Hub shutting down: notified connected clients");
2832
+ }
2833
+ // Detect disabling sharing or switching away from client mode
2834
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2835
+ const isClient = newEnabled && newRole === "client";
2836
+ if (wasClient && !isClient) {
2837
+ this.notifyHubLeave();
2838
+ this.store.clearClientHubConnection();
2839
+ this.log.info("Cleared client hub connection (sharing disabled or role changed)");
2840
+ }
2841
+ // Detect switching to a different Hub while still in client mode
2842
+ if (wasClient && isClient) {
2843
+ const newClientAddr = String(merged.client?.hubAddress || "");
2844
+ if (newClientAddr && oldClientHubAddress && (0, hub_1.normalizeHubUrl)(newClientAddr) !== (0, hub_1.normalizeHubUrl)(oldClientHubAddress)) {
2845
+ this.notifyHubLeave();
2846
+ this.store.clearClientHubConnection();
2847
+ this.log.info("Cleared client hub connection (switched to different Hub)");
2848
+ }
2849
+ }
2177
2850
  if (merged.role === "hub") {
2178
2851
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2179
2852
  }
@@ -2185,6 +2858,12 @@ class ViewerServer {
2185
2858
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(cfgPath), { recursive: true });
2186
2859
  node_fs_1.default.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2187
2860
  this.log.info("Plugin config updated via Viewer");
2861
+ this.stopHubHeartbeat();
2862
+ // When switching to client mode, immediately send join request
2863
+ const finalSharing = config.sharing;
2864
+ if (finalSharing?.role === "client" && oldSharingRole !== "client") {
2865
+ this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2866
+ }
2188
2867
  this.jsonResponse(res, { ok: true });
2189
2868
  }
2190
2869
  catch (e) {
@@ -2194,6 +2873,92 @@ class ViewerServer {
2194
2873
  }
2195
2874
  });
2196
2875
  }
2876
+ async autoJoinOnSave(sharing) {
2877
+ const clientCfg = sharing.client;
2878
+ const hubAddress = String(clientCfg?.hubAddress || "");
2879
+ const teamToken = String(clientCfg?.teamToken || "");
2880
+ if (!hubAddress || !teamToken)
2881
+ return;
2882
+ const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
2883
+ const os = await Promise.resolve().then(() => __importStar(require("os")));
2884
+ const nickname = String(clientCfg?.nickname || "");
2885
+ const username = nickname || os.userInfo().username || "user";
2886
+ const hostname = os.hostname() || "unknown";
2887
+ const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
2888
+ method: "POST",
2889
+ body: JSON.stringify({ teamToken, username, deviceName: hostname }),
2890
+ });
2891
+ this.store.setClientHubConnection({
2892
+ hubUrl,
2893
+ userId: String(result.userId || ""),
2894
+ username,
2895
+ userToken: result.userToken || "",
2896
+ role: "member",
2897
+ connectedAt: Date.now(),
2898
+ });
2899
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2900
+ if (result.userToken) {
2901
+ this.startHubHeartbeat();
2902
+ }
2903
+ }
2904
+ async notifyHubLeave() {
2905
+ try {
2906
+ const hub = this.resolveHubConnection();
2907
+ if (hub) {
2908
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2909
+ this.log.info("Notified Hub of voluntary leave");
2910
+ return;
2911
+ }
2912
+ const persisted = this.store.getClientHubConnection();
2913
+ if (persisted?.hubUrl && persisted?.userToken) {
2914
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2915
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2916
+ }
2917
+ }
2918
+ catch (e) {
2919
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2920
+ }
2921
+ }
2922
+ async notifyHubShutdown() {
2923
+ try {
2924
+ const sharing = this.ctx?.config.sharing;
2925
+ if (!sharing || sharing.role !== "hub")
2926
+ return;
2927
+ const hubPort = sharing.hub?.port ?? 18800;
2928
+ const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
2929
+ let adminToken;
2930
+ try {
2931
+ const authData = JSON.parse(node_fs_1.default.readFileSync(authPath, "utf8"));
2932
+ adminToken = authData?.bootstrapAdminToken;
2933
+ }
2934
+ catch {
2935
+ return;
2936
+ }
2937
+ if (!adminToken)
2938
+ return;
2939
+ const users = this.store.listHubUsers("active");
2940
+ const { v4: uuidv4 } = require("uuid");
2941
+ for (const u of users) {
2942
+ try {
2943
+ this.store.insertHubNotification({
2944
+ id: uuidv4(),
2945
+ userId: u.id,
2946
+ type: "hub_shutdown",
2947
+ resource: "hub",
2948
+ title: "Hub is shutting down",
2949
+ message: "The Hub server is shutting down. You may be disconnected.",
2950
+ });
2951
+ }
2952
+ catch (e) {
2953
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
2954
+ }
2955
+ }
2956
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
2957
+ }
2958
+ catch (e) {
2959
+ this.log.warn(`notifyHubShutdown error: ${e}`);
2960
+ }
2961
+ }
2197
2962
  handleUpdateUsername(req, res) {
2198
2963
  this.readBody(req, async (body) => {
2199
2964
  if (!this.ctx)
@@ -2668,7 +3433,7 @@ class ViewerServer {
2668
3433
  // ─── Migration: scan OpenClaw built-in memory ───
2669
3434
  getOpenClawHome() {
2670
3435
  const home = process.env.HOME || process.env.USERPROFILE || "";
2671
- return node_path_1.default.join(home, ".openclaw");
3436
+ return process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
2672
3437
  }
2673
3438
  handleCleanupPolluted(res) {
2674
3439
  try {
@@ -2696,7 +3461,7 @@ class ViewerServer {
2696
3461
  try {
2697
3462
  const ocHome = this.getOpenClawHome();
2698
3463
  const memoryDir = node_path_1.default.join(ocHome, "memory");
2699
- const sessionsDir = node_path_1.default.join(ocHome, "agents", "main", "sessions");
3464
+ const agentsDir = node_path_1.default.join(ocHome, "agents");
2700
3465
  const sqliteFiles = [];
2701
3466
  if (node_fs_1.default.existsSync(memoryDir)) {
2702
3467
  for (const f of node_fs_1.default.readdirSync(memoryDir)) {
@@ -2714,38 +3479,45 @@ class ViewerServer {
2714
3479
  }
2715
3480
  let sessionCount = 0;
2716
3481
  let messageCount = 0;
2717
- if (node_fs_1.default.existsSync(sessionsDir)) {
2718
- const jsonlFiles = node_fs_1.default.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
2719
- sessionCount = jsonlFiles.length;
2720
- for (const f of jsonlFiles) {
2721
- try {
2722
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessionsDir, f), "utf-8");
2723
- const lines = content.split("\n").filter(l => l.trim());
2724
- for (const line of lines) {
2725
- try {
2726
- const obj = JSON.parse(line);
2727
- if (obj.type === "message") {
2728
- const role = obj.message?.role ?? obj.role;
2729
- if (role === "user" || role === "assistant") {
2730
- const mc = obj.message?.content ?? obj.content;
2731
- let txt = "";
2732
- if (typeof mc === "string")
2733
- txt = mc;
2734
- else if (Array.isArray(mc))
2735
- txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
2736
- else
2737
- txt = JSON.stringify(mc);
2738
- if (role === "user")
2739
- txt = (0, capture_1.stripInboundMetadata)(txt);
2740
- if (txt && txt.length >= 10)
2741
- messageCount++;
3482
+ if (node_fs_1.default.existsSync(agentsDir)) {
3483
+ for (const entry of node_fs_1.default.readdirSync(agentsDir, { withFileTypes: true })) {
3484
+ if (!entry.isDirectory())
3485
+ continue;
3486
+ const sessDir = node_path_1.default.join(agentsDir, entry.name, "sessions");
3487
+ if (!node_fs_1.default.existsSync(sessDir))
3488
+ continue;
3489
+ const jsonlFiles = node_fs_1.default.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3490
+ sessionCount += jsonlFiles.length;
3491
+ for (const f of jsonlFiles) {
3492
+ try {
3493
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessDir, f), "utf-8");
3494
+ const lines = content.split("\n").filter(l => l.trim());
3495
+ for (const line of lines) {
3496
+ try {
3497
+ const obj = JSON.parse(line);
3498
+ if (obj.type === "message") {
3499
+ const role = obj.message?.role ?? obj.role;
3500
+ if (role === "user" || role === "assistant") {
3501
+ const mc = obj.message?.content ?? obj.content;
3502
+ let txt = "";
3503
+ if (typeof mc === "string")
3504
+ txt = mc;
3505
+ else if (Array.isArray(mc))
3506
+ txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
3507
+ else
3508
+ txt = JSON.stringify(mc);
3509
+ if (role === "user")
3510
+ txt = (0, capture_1.stripInboundMetadata)(txt);
3511
+ if (txt && txt.length >= 10)
3512
+ messageCount++;
3513
+ }
2742
3514
  }
2743
3515
  }
3516
+ catch { /* skip bad lines */ }
2744
3517
  }
2745
- catch { /* skip bad lines */ }
2746
3518
  }
3519
+ catch { /* skip unreadable */ }
2747
3520
  }
2748
- catch { /* skip unreadable */ }
2749
3521
  }
2750
3522
  }
2751
3523
  const cfgPath = this.getOpenClawConfigPath();