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

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 (119) 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 +2 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +122 -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 +8 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +390 -106
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +11 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +31 -3
  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 +93 -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 +4 -0
  45. package/dist/skill/evolver.d.ts.map +1 -1
  46. package/dist/skill/evolver.js +59 -5
  47. package/dist/skill/evolver.js.map +1 -1
  48. package/dist/skill/generator.d.ts +2 -0
  49. package/dist/skill/generator.d.ts.map +1 -1
  50. package/dist/skill/generator.js +45 -3
  51. package/dist/skill/generator.js.map +1 -1
  52. package/dist/skill/installer.d.ts +26 -0
  53. package/dist/skill/installer.d.ts.map +1 -1
  54. package/dist/skill/installer.js +80 -4
  55. package/dist/skill/installer.js.map +1 -1
  56. package/dist/skill/upgrader.d.ts +2 -0
  57. package/dist/skill/upgrader.d.ts.map +1 -1
  58. package/dist/skill/upgrader.js +139 -1
  59. package/dist/skill/upgrader.js.map +1 -1
  60. package/dist/skill/validator.d.ts +3 -0
  61. package/dist/skill/validator.d.ts.map +1 -1
  62. package/dist/skill/validator.js +75 -0
  63. package/dist/skill/validator.js.map +1 -1
  64. package/dist/storage/ensure-binding.d.ts +12 -0
  65. package/dist/storage/ensure-binding.d.ts.map +1 -0
  66. package/dist/storage/ensure-binding.js +53 -0
  67. package/dist/storage/ensure-binding.js.map +1 -0
  68. package/dist/storage/sqlite.d.ts +89 -20
  69. package/dist/storage/sqlite.d.ts.map +1 -1
  70. package/dist/storage/sqlite.js +374 -124
  71. package/dist/storage/sqlite.js.map +1 -1
  72. package/dist/telemetry.d.ts +12 -5
  73. package/dist/telemetry.d.ts.map +1 -1
  74. package/dist/telemetry.js +156 -40
  75. package/dist/telemetry.js.map +1 -1
  76. package/dist/tools/memory-search.d.ts +3 -1
  77. package/dist/tools/memory-search.d.ts.map +1 -1
  78. package/dist/tools/memory-search.js +3 -1
  79. package/dist/tools/memory-search.js.map +1 -1
  80. package/dist/types.d.ts +11 -2
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/types.js +4 -0
  83. package/dist/types.js.map +1 -1
  84. package/dist/viewer/html.d.ts.map +1 -1
  85. package/dist/viewer/html.js +2671 -879
  86. package/dist/viewer/html.js.map +1 -1
  87. package/dist/viewer/server.d.ts +30 -8
  88. package/dist/viewer/server.d.ts.map +1 -1
  89. package/dist/viewer/server.js +990 -198
  90. package/dist/viewer/server.js.map +1 -1
  91. package/index.ts +700 -56
  92. package/openclaw.plugin.json +1 -1
  93. package/package.json +3 -2
  94. package/scripts/postinstall.cjs +1 -1
  95. package/skill/memos-memory-guide/SKILL.md +64 -26
  96. package/src/capture/index.ts +37 -1
  97. package/src/client/connector.ts +124 -28
  98. package/src/client/hub.ts +18 -0
  99. package/src/client/skill-sync.ts +14 -0
  100. package/src/config.ts +0 -2
  101. package/src/hub/server.ts +374 -97
  102. package/src/hub/user-manager.ts +48 -8
  103. package/src/index.ts +10 -2
  104. package/src/ingest/providers/index.ts +41 -7
  105. package/src/recall/engine.ts +86 -1
  106. package/src/shared/llm-call.ts +97 -9
  107. package/src/sharing/types.ts +1 -1
  108. package/src/skill/evolver.ts +63 -6
  109. package/src/skill/generator.ts +44 -5
  110. package/src/skill/installer.ts +107 -4
  111. package/src/skill/upgrader.ts +139 -1
  112. package/src/skill/validator.ts +79 -0
  113. package/src/storage/ensure-binding.ts +52 -0
  114. package/src/storage/sqlite.ts +395 -148
  115. package/src/telemetry.ts +172 -41
  116. package/src/tools/memory-search.ts +2 -1
  117. package/src/types.ts +12 -2
  118. package/src/viewer/html.ts +2671 -879
  119. package/src/viewer/server.ts +913 -182
@@ -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,13 +1925,27 @@ 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
+ const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
1933
+ if (localIPs.includes(u.hostname) && targetPort === String(this.port)) {
1934
+ return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1935
+ }
1936
+ }
1937
+ catch { }
1401
1938
  const os = await Promise.resolve().then(() => __importStar(require("os")));
1402
- const username = os.userInfo().username || "user";
1939
+ const nickname = sharing.client?.nickname;
1940
+ const username = nickname || os.userInfo().username || "user";
1403
1941
  const hostname = os.hostname() || "unknown";
1942
+ const persisted = this.store.getClientHubConnection();
1943
+ const existingIdentityKey = persisted?.identityKey || "";
1404
1944
  const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
1405
1945
  method: "POST",
1406
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1946
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
1407
1947
  });
1948
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
1408
1949
  this.store.setClientHubConnection({
1409
1950
  hubUrl,
1410
1951
  userId: String(result.userId || ""),
@@ -1412,6 +1953,8 @@ class ViewerServer {
1412
1953
  userToken: result.userToken || "",
1413
1954
  role: "member",
1414
1955
  connectedAt: Date.now(),
1956
+ identityKey: returnedIdentityKey,
1957
+ lastKnownStatus: result.status || "",
1415
1958
  });
1416
1959
  this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1417
1960
  }
@@ -1425,7 +1968,14 @@ class ViewerServer {
1425
1968
  return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1426
1969
  try {
1427
1970
  const limit = Number(url.searchParams.get("limit") || 40);
1428
- const data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
1971
+ const hub = this.resolveHubConnection();
1972
+ let data;
1973
+ if (hub) {
1974
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1975
+ }
1976
+ else {
1977
+ data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
1978
+ }
1429
1979
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1430
1980
  }
1431
1981
  catch (err) {
@@ -1437,7 +1987,14 @@ class ViewerServer {
1437
1987
  return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1438
1988
  try {
1439
1989
  const limit = Number(url.searchParams.get("limit") || 40);
1440
- const data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
1990
+ const hub = this.resolveHubConnection();
1991
+ let data;
1992
+ if (hub) {
1993
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1994
+ }
1995
+ else {
1996
+ data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
1997
+ }
1441
1998
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1442
1999
  }
1443
2000
  catch (err) {
@@ -1449,7 +2006,14 @@ class ViewerServer {
1449
2006
  return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1450
2007
  try {
1451
2008
  const limit = Number(url.searchParams.get("limit") || 40);
1452
- const data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
2009
+ const hub = this.resolveHubConnection();
2010
+ let data;
2011
+ if (hub) {
2012
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
2013
+ }
2014
+ else {
2015
+ data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
2016
+ }
1453
2017
  this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1454
2018
  }
1455
2019
  catch (err) {
@@ -1466,13 +2030,22 @@ class ViewerServer {
1466
2030
  const query = String(parsed.query || "");
1467
2031
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1468
2032
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1469
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
2033
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1470
2034
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1471
2035
  if (scope === "local") {
1472
2036
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1473
2037
  }
1474
2038
  try {
1475
- const hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2039
+ const conn = this.resolveHubConnection();
2040
+ let hub;
2041
+ if (conn) {
2042
+ hub = await (0, hub_1.hubRequestJson)(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
2043
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
2044
+ });
2045
+ }
2046
+ else {
2047
+ hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2048
+ }
1476
2049
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1477
2050
  }
1478
2051
  catch (err) {
@@ -1860,123 +2433,6 @@ class ViewerServer {
1860
2433
  }
1861
2434
  return (0, hub_1.resolveHubClient)(this.store, this.ctx);
1862
2435
  }
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
2436
  async serveSharingUsers(res) {
1981
2437
  const hub = this.resolveHubConnection();
1982
2438
  if (!hub)
@@ -1996,7 +2452,17 @@ class ViewerServer {
1996
2452
  return this.jsonResponse(res, { tasks: [], error: "not_configured" });
1997
2453
  try {
1998
2454
  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 : [] });
2455
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2456
+ for (const tk of tasks) {
2457
+ if (!tk.summary && tk.sourceTaskId) {
2458
+ const local = this.store.getTask(tk.sourceTaskId);
2459
+ if (local) {
2460
+ tk.summary = local.summary;
2461
+ tk.title = tk.title || local.title;
2462
+ }
2463
+ }
2464
+ }
2465
+ this.jsonResponse(res, { tasks });
2000
2466
  }
2001
2467
  catch (err) {
2002
2468
  this.jsonResponse(res, { tasks: [], error: String(err) });
@@ -2015,13 +2481,55 @@ class ViewerServer {
2015
2481
  this.jsonResponse(res, { ok: false, error: String(err) });
2016
2482
  }
2017
2483
  }
2484
+ async serveHubTaskDetail(res, p) {
2485
+ const hub = this.resolveHubConnection();
2486
+ if (!hub)
2487
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2488
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2489
+ if (!m)
2490
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2491
+ const taskId = decodeURIComponent(m[1]);
2492
+ try {
2493
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" });
2494
+ this.jsonResponse(res, data);
2495
+ }
2496
+ catch (err) {
2497
+ this.jsonResponse(res, { error: String(err) }, 500);
2498
+ }
2499
+ }
2500
+ async serveHubSkillDetail(res, p) {
2501
+ const hub = this.resolveHubConnection();
2502
+ if (!hub)
2503
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2504
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2505
+ if (!m)
2506
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2507
+ const skillId = decodeURIComponent(m[1]);
2508
+ try {
2509
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" });
2510
+ this.jsonResponse(res, data);
2511
+ }
2512
+ catch (err) {
2513
+ this.jsonResponse(res, { error: String(err) }, 500);
2514
+ }
2515
+ }
2018
2516
  async serveAdminSharedSkills(res) {
2019
2517
  const hub = this.resolveHubConnection();
2020
2518
  if (!hub)
2021
2519
  return this.jsonResponse(res, { skills: [], error: "not_configured" });
2022
2520
  try {
2023
2521
  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 : [] });
2522
+ const skills = Array.isArray(data?.skills) ? data.skills : [];
2523
+ for (const sk of skills) {
2524
+ if (!sk.description && sk.sourceSkillId) {
2525
+ const local = this.store.getSkill(sk.sourceSkillId);
2526
+ if (local) {
2527
+ sk.description = sk.description || local.description;
2528
+ sk.name = sk.name || local.name;
2529
+ }
2530
+ }
2531
+ }
2532
+ this.jsonResponse(res, { skills });
2025
2533
  }
2026
2534
  catch (err) {
2027
2535
  this.jsonResponse(res, { skills: [], error: String(err) });
@@ -2046,7 +2554,18 @@ class ViewerServer {
2046
2554
  return this.jsonResponse(res, { memories: [], error: "not_configured" });
2047
2555
  try {
2048
2556
  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 : [] });
2557
+ const memories = Array.isArray(data?.memories) ? data.memories : [];
2558
+ for (const m of memories) {
2559
+ if (!m.content && m.sourceChunkId) {
2560
+ const local = this.store.getChunk(m.sourceChunkId);
2561
+ if (local) {
2562
+ m.content = local.content;
2563
+ if (!m.summary && local.summary)
2564
+ m.summary = local.summary;
2565
+ }
2566
+ }
2567
+ }
2568
+ this.jsonResponse(res, { memories });
2050
2569
  }
2051
2570
  catch (err) {
2052
2571
  this.jsonResponse(res, { memories: [], error: String(err) });
@@ -2065,6 +2584,135 @@ class ViewerServer {
2065
2584
  this.jsonResponse(res, { ok: false, error: String(err) });
2066
2585
  }
2067
2586
  }
2587
+ async serveSharingNotifications(res, url) {
2588
+ const hub = this.resolveHubConnection();
2589
+ if (!hub)
2590
+ return this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2591
+ try {
2592
+ const unread = url.searchParams.get("unread") === "1" ? "?unread=1" : "";
2593
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/notifications${unread}`);
2594
+ this.jsonResponse(res, data);
2595
+ }
2596
+ catch {
2597
+ this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2598
+ }
2599
+ }
2600
+ handleSharingNotificationsRead(req, res) {
2601
+ const hub = this.resolveHubConnection();
2602
+ if (!hub)
2603
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
2604
+ this.readBody(req, async (raw) => {
2605
+ try {
2606
+ const body = JSON.parse(raw || "{}");
2607
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2608
+ this.jsonResponse(res, { ok: true });
2609
+ try {
2610
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2611
+ const count = data?.unreadCount ?? 0;
2612
+ this.lastKnownNotifCount = count;
2613
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2614
+ }
2615
+ catch { /* best effort */ }
2616
+ }
2617
+ catch (err) {
2618
+ this.jsonResponse(res, { ok: false, error: String(err) });
2619
+ }
2620
+ });
2621
+ }
2622
+ handleSharingNotificationsClear(req, res) {
2623
+ const hub = this.resolveHubConnection();
2624
+ if (!hub)
2625
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
2626
+ this.readBody(req, async () => {
2627
+ try {
2628
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2629
+ this.jsonResponse(res, { ok: true });
2630
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2631
+ }
2632
+ catch (err) {
2633
+ this.jsonResponse(res, { ok: false, error: String(err) });
2634
+ }
2635
+ });
2636
+ }
2637
+ handleNotifSSE(req, res) {
2638
+ res.writeHead(200, {
2639
+ "Content-Type": "text/event-stream",
2640
+ "Cache-Control": "no-cache",
2641
+ Connection: "keep-alive",
2642
+ "Access-Control-Allow-Origin": "*",
2643
+ });
2644
+ res.write("data: {\"type\":\"connected\"}\n\n");
2645
+ this.notifSSEClients.push(res);
2646
+ if (!this.notifPollTimer)
2647
+ this.startNotifPoll();
2648
+ req.on("close", () => {
2649
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2650
+ if (this.notifSSEClients.length === 0)
2651
+ this.stopNotifPoll();
2652
+ });
2653
+ }
2654
+ broadcastNotifSSE(data) {
2655
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2656
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2657
+ try {
2658
+ c.write(msg);
2659
+ return true;
2660
+ }
2661
+ catch {
2662
+ return false;
2663
+ }
2664
+ });
2665
+ }
2666
+ startNotifPoll() {
2667
+ this.stopNotifPoll();
2668
+ const tick = async () => {
2669
+ const hub = this.resolveHubConnection();
2670
+ if (!hub)
2671
+ return;
2672
+ try {
2673
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2674
+ const count = data?.unreadCount ?? 0;
2675
+ if (count !== this.lastKnownNotifCount) {
2676
+ this.lastKnownNotifCount = count;
2677
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2678
+ }
2679
+ }
2680
+ catch { /* ignore */ }
2681
+ };
2682
+ tick();
2683
+ this.notifPollTimer = setInterval(tick, 3000);
2684
+ }
2685
+ stopNotifPoll() {
2686
+ if (this.notifPollTimer) {
2687
+ clearInterval(this.notifPollTimer);
2688
+ this.notifPollTimer = undefined;
2689
+ }
2690
+ }
2691
+ startHubHeartbeat() {
2692
+ this.stopHubHeartbeat();
2693
+ const sendHeartbeat = async () => {
2694
+ try {
2695
+ const hub = this.resolveHubConnection();
2696
+ if (!hub) {
2697
+ const persisted = this.store.getClientHubConnection();
2698
+ if (persisted?.hubUrl && persisted?.userToken) {
2699
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2700
+ }
2701
+ return;
2702
+ }
2703
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2704
+ }
2705
+ catch { /* best-effort */ }
2706
+ };
2707
+ sendHeartbeat();
2708
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2709
+ }
2710
+ stopHubHeartbeat() {
2711
+ if (this.hubHeartbeatTimer) {
2712
+ clearInterval(this.hubHeartbeatTimer);
2713
+ this.hubHeartbeatTimer = undefined;
2714
+ }
2715
+ }
2068
2716
  getLocalIPs() {
2069
2717
  const nets = node_os_1.default.networkInterfaces();
2070
2718
  const ips = [];
@@ -2116,7 +2764,7 @@ class ViewerServer {
2116
2764
  }
2117
2765
  }
2118
2766
  handleSaveConfig(req, res) {
2119
- this.readBody(req, (body) => {
2767
+ this.readBody(req, async (body) => {
2120
2768
  try {
2121
2769
  const newCfg = JSON.parse(body);
2122
2770
  const cfgPath = this.getOpenClawConfigPath();
@@ -2141,6 +2789,10 @@ class ViewerServer {
2141
2789
  if (!entry.config)
2142
2790
  entry.config = {};
2143
2791
  const config = entry.config;
2792
+ const oldSharing = config.sharing;
2793
+ const oldSharingRole = oldSharing?.role;
2794
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2795
+ const oldClientHubAddress = String(oldSharing?.client?.hubAddress || "");
2144
2796
  if (newCfg.embedding)
2145
2797
  config.embedding = newCfg.embedding;
2146
2798
  if (newCfg.summarizer)
@@ -2165,7 +2817,8 @@ class ViewerServer {
2165
2817
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2166
2818
  try {
2167
2819
  const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2168
- if (localIPs.includes(u.hostname)) {
2820
+ const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
2821
+ if (localIPs.includes(u.hostname) && targetPort === String(this.port)) {
2169
2822
  res.writeHead(400, { "Content-Type": "application/json" });
2170
2823
  res.end(JSON.stringify({ error: "cannot_join_self" }));
2171
2824
  return;
@@ -2174,6 +2827,38 @@ class ViewerServer {
2174
2827
  catch { }
2175
2828
  }
2176
2829
  }
2830
+ const newRole = merged.role;
2831
+ const newEnabled = Boolean(merged.enabled);
2832
+ // Detect disabling sharing or switching away from hub mode
2833
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2834
+ const isHub = newEnabled && newRole === "hub";
2835
+ if (wasHub && !isHub) {
2836
+ await this.notifyHubShutdown();
2837
+ this.stopHubHeartbeat();
2838
+ this.log.info("Hub shutting down: notified connected clients");
2839
+ }
2840
+ // Detect disabling sharing or switching away from client mode
2841
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2842
+ const isClient = newEnabled && newRole === "client";
2843
+ if (wasClient && !isClient) {
2844
+ this.notifyHubLeave();
2845
+ const oldConn = this.store.getClientHubConnection();
2846
+ if (oldConn) {
2847
+ this.store.setClientHubConnection({ ...oldConn, userToken: "", lastKnownStatus: "left" });
2848
+ }
2849
+ this.log.info("Client hub connection token cleared (sharing disabled or role changed), identity preserved");
2850
+ }
2851
+ if (wasClient && isClient) {
2852
+ const newClientAddr = String(merged.client?.hubAddress || "");
2853
+ if (newClientAddr && oldClientHubAddress && (0, hub_1.normalizeHubUrl)(newClientAddr) !== (0, hub_1.normalizeHubUrl)(oldClientHubAddress)) {
2854
+ this.notifyHubLeave();
2855
+ const oldConn = this.store.getClientHubConnection();
2856
+ if (oldConn) {
2857
+ this.store.setClientHubConnection({ ...oldConn, hubUrl: (0, hub_1.normalizeHubUrl)(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
2858
+ }
2859
+ this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
2860
+ }
2861
+ }
2177
2862
  if (merged.role === "hub") {
2178
2863
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2179
2864
  }
@@ -2185,6 +2870,14 @@ class ViewerServer {
2185
2870
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(cfgPath), { recursive: true });
2186
2871
  node_fs_1.default.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2187
2872
  this.log.info("Plugin config updated via Viewer");
2873
+ this.stopHubHeartbeat();
2874
+ // When switching to client mode or re-enabling sharing as client, send join request
2875
+ const finalSharing = config.sharing;
2876
+ const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
2877
+ const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
2878
+ if (nowClient && !previouslyClient) {
2879
+ this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2880
+ }
2188
2881
  this.jsonResponse(res, { ok: true });
2189
2882
  }
2190
2883
  catch (e) {
@@ -2194,6 +2887,97 @@ class ViewerServer {
2194
2887
  }
2195
2888
  });
2196
2889
  }
2890
+ async autoJoinOnSave(sharing) {
2891
+ const clientCfg = sharing.client;
2892
+ const hubAddress = String(clientCfg?.hubAddress || "");
2893
+ const teamToken = String(clientCfg?.teamToken || "");
2894
+ if (!hubAddress || !teamToken)
2895
+ return;
2896
+ const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
2897
+ const os = await Promise.resolve().then(() => __importStar(require("os")));
2898
+ const nickname = String(clientCfg?.nickname || "");
2899
+ const username = nickname || os.userInfo().username || "user";
2900
+ const hostname = os.hostname() || "unknown";
2901
+ const persisted = this.store.getClientHubConnection();
2902
+ const existingIdentityKey = persisted?.identityKey || "";
2903
+ const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
2904
+ method: "POST",
2905
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
2906
+ });
2907
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
2908
+ this.store.setClientHubConnection({
2909
+ hubUrl,
2910
+ userId: String(result.userId || ""),
2911
+ username,
2912
+ userToken: result.userToken || "",
2913
+ role: "member",
2914
+ connectedAt: Date.now(),
2915
+ identityKey: returnedIdentityKey,
2916
+ lastKnownStatus: result.status || "",
2917
+ });
2918
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2919
+ if (result.userToken) {
2920
+ this.startHubHeartbeat();
2921
+ }
2922
+ }
2923
+ async notifyHubLeave() {
2924
+ try {
2925
+ const hub = this.resolveHubConnection();
2926
+ if (hub) {
2927
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2928
+ this.log.info("Notified Hub of voluntary leave");
2929
+ return;
2930
+ }
2931
+ const persisted = this.store.getClientHubConnection();
2932
+ if (persisted?.hubUrl && persisted?.userToken) {
2933
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2934
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2935
+ }
2936
+ }
2937
+ catch (e) {
2938
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2939
+ }
2940
+ }
2941
+ async notifyHubShutdown() {
2942
+ try {
2943
+ const sharing = this.ctx?.config.sharing;
2944
+ if (!sharing || sharing.role !== "hub")
2945
+ return;
2946
+ const hubPort = sharing.hub?.port ?? 18800;
2947
+ const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
2948
+ let adminToken;
2949
+ try {
2950
+ const authData = JSON.parse(node_fs_1.default.readFileSync(authPath, "utf8"));
2951
+ adminToken = authData?.bootstrapAdminToken;
2952
+ }
2953
+ catch {
2954
+ return;
2955
+ }
2956
+ if (!adminToken)
2957
+ return;
2958
+ const users = this.store.listHubUsers("active");
2959
+ const { v4: uuidv4 } = require("uuid");
2960
+ for (const u of users) {
2961
+ try {
2962
+ this.store.insertHubNotification({
2963
+ id: uuidv4(),
2964
+ userId: u.id,
2965
+ type: "hub_shutdown",
2966
+ resource: "hub",
2967
+ title: "Hub is shutting down",
2968
+ message: "The Hub server is shutting down. You may be disconnected.",
2969
+ });
2970
+ }
2971
+ catch (e) {
2972
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
2973
+ }
2974
+ }
2975
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
2976
+ }
2977
+ catch (e) {
2978
+ this.log.warn(`notifyHubShutdown error: ${e}`);
2979
+ }
2980
+ }
2197
2981
  handleUpdateUsername(req, res) {
2198
2982
  this.readBody(req, async (body) => {
2199
2983
  if (!this.ctx)
@@ -2224,10 +3008,10 @@ class ViewerServer {
2224
3008
  }
2225
3009
  }
2226
3010
  else {
2227
- const persisted = this.store.getClientHubConnection();
2228
- if (persisted) {
3011
+ const persistedConn = this.store.getClientHubConnection();
3012
+ if (persistedConn) {
2229
3013
  this.store.setClientHubConnection({
2230
- ...persisted,
3014
+ ...persistedConn,
2231
3015
  username: result.username,
2232
3016
  userToken: result.userToken,
2233
3017
  });
@@ -2257,7 +3041,8 @@ class ViewerServer {
2257
3041
  const localIPs = this.getLocalIPs();
2258
3042
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2259
3043
  const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2260
- if (localIPs.includes(parsed.hostname)) {
3044
+ const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
3045
+ if (localIPs.includes(parsed.hostname) && targetPort === String(this.port)) {
2261
3046
  this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2262
3047
  return;
2263
3048
  }
@@ -2668,7 +3453,7 @@ class ViewerServer {
2668
3453
  // ─── Migration: scan OpenClaw built-in memory ───
2669
3454
  getOpenClawHome() {
2670
3455
  const home = process.env.HOME || process.env.USERPROFILE || "";
2671
- return node_path_1.default.join(home, ".openclaw");
3456
+ return process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
2672
3457
  }
2673
3458
  handleCleanupPolluted(res) {
2674
3459
  try {
@@ -2696,7 +3481,7 @@ class ViewerServer {
2696
3481
  try {
2697
3482
  const ocHome = this.getOpenClawHome();
2698
3483
  const memoryDir = node_path_1.default.join(ocHome, "memory");
2699
- const sessionsDir = node_path_1.default.join(ocHome, "agents", "main", "sessions");
3484
+ const agentsDir = node_path_1.default.join(ocHome, "agents");
2700
3485
  const sqliteFiles = [];
2701
3486
  if (node_fs_1.default.existsSync(memoryDir)) {
2702
3487
  for (const f of node_fs_1.default.readdirSync(memoryDir)) {
@@ -2714,38 +3499,45 @@ class ViewerServer {
2714
3499
  }
2715
3500
  let sessionCount = 0;
2716
3501
  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++;
3502
+ if (node_fs_1.default.existsSync(agentsDir)) {
3503
+ for (const entry of node_fs_1.default.readdirSync(agentsDir, { withFileTypes: true })) {
3504
+ if (!entry.isDirectory())
3505
+ continue;
3506
+ const sessDir = node_path_1.default.join(agentsDir, entry.name, "sessions");
3507
+ if (!node_fs_1.default.existsSync(sessDir))
3508
+ continue;
3509
+ const jsonlFiles = node_fs_1.default.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3510
+ sessionCount += jsonlFiles.length;
3511
+ for (const f of jsonlFiles) {
3512
+ try {
3513
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessDir, f), "utf-8");
3514
+ const lines = content.split("\n").filter(l => l.trim());
3515
+ for (const line of lines) {
3516
+ try {
3517
+ const obj = JSON.parse(line);
3518
+ if (obj.type === "message") {
3519
+ const role = obj.message?.role ?? obj.role;
3520
+ if (role === "user" || role === "assistant") {
3521
+ const mc = obj.message?.content ?? obj.content;
3522
+ let txt = "";
3523
+ if (typeof mc === "string")
3524
+ txt = mc;
3525
+ else if (Array.isArray(mc))
3526
+ txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
3527
+ else
3528
+ txt = JSON.stringify(mc);
3529
+ if (role === "user")
3530
+ txt = (0, capture_1.stripInboundMetadata)(txt);
3531
+ if (txt && txt.length >= 10)
3532
+ messageCount++;
3533
+ }
2742
3534
  }
2743
3535
  }
3536
+ catch { /* skip bad lines */ }
2744
3537
  }
2745
- catch { /* skip bad lines */ }
2746
3538
  }
3539
+ catch { /* skip unreadable */ }
2747
3540
  }
2748
- catch { /* skip unreadable */ }
2749
3541
  }
2750
3542
  }
2751
3543
  const cfgPath = this.getOpenClawConfigPath();