@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
@@ -84,6 +84,12 @@ export class ViewerServer {
84
84
  { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
85
85
  private ppSSEClients: http.ServerResponse[] = [];
86
86
 
87
+ private notifSSEClients: http.ServerResponse[] = [];
88
+ private notifPollTimer?: ReturnType<typeof setInterval>;
89
+ private lastKnownNotifCount = 0;
90
+ private hubHeartbeatTimer?: ReturnType<typeof setInterval>;
91
+ private static readonly HUB_HEARTBEAT_INTERVAL_MS = 45_000;
92
+
87
93
  constructor(opts: ViewerServerOptions) {
88
94
  this.store = opts.store;
89
95
  this.embedder = opts.embedder;
@@ -103,15 +109,16 @@ export class ViewerServer {
103
109
  this.server.on("error", (err: NodeJS.ErrnoException) => {
104
110
  if (err.code === "EADDRINUSE") {
105
111
  this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
106
- this.server!.listen(this.port + 1, "127.0.0.1");
112
+ this.server!.listen(this.port + 1, "0.0.0.0");
107
113
  } else {
108
114
  reject(err);
109
115
  }
110
116
  });
111
- this.server.listen(this.port, "127.0.0.1", () => {
117
+ this.server.listen(this.port, "0.0.0.0", () => {
112
118
  const addr = this.server!.address();
113
119
  const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
114
120
  this.autoCleanupPolluted();
121
+ this.startHubHeartbeat();
115
122
  resolve(`http://127.0.0.1:${actualPort}`);
116
123
  });
117
124
  });
@@ -134,6 +141,10 @@ export class ViewerServer {
134
141
  }
135
142
 
136
143
  stop(): void {
144
+ this.stopHubHeartbeat();
145
+ this.stopNotifPoll();
146
+ for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
147
+ this.notifSSEClients = [];
137
148
  this.server?.close();
138
149
  this.server = null;
139
150
  }
@@ -224,6 +235,11 @@ export class ViewerServer {
224
235
  }
225
236
 
226
237
  if (p === "/api/memories" && req.method === "GET") this.serveMemories(res, url);
238
+ else if (p === "/api/memories/share-local" && req.method === "POST") this.handleMemoryLocalShare(req, res);
239
+ else if (p === "/api/memories/unshare-local" && req.method === "POST") this.handleMemoryLocalUnshare(req, res);
240
+ else if (p.match(/^\/api\/memory\/[^/]+\/scope$/) && req.method === "PUT") this.handleMemoryScope(req, res, p);
241
+ else if (p.match(/^\/api\/task\/[^/]+\/scope$/) && req.method === "PUT") this.handleTaskScope(req, res, p);
242
+ else if (p.match(/^\/api\/skill\/[^/]+\/scope$/) && req.method === "PUT") this.handleSkillScope(req, res, p);
227
243
  else if (p === "/api/stats") this.serveStats(res, url);
228
244
  else if (p === "/api/metrics") this.serveMetrics(res, url);
229
245
  else if (p === "/api/tool-metrics") this.serveToolMetrics(res, url);
@@ -251,6 +267,8 @@ export class ViewerServer {
251
267
  else if (p === "/api/sharing/pending-users" && req.method === "GET") this.serveSharingPendingUsers(res);
252
268
  else if (p === "/api/sharing/approve-user" && req.method === "POST") this.handleSharingApproveUser(req, res);
253
269
  else if (p === "/api/sharing/reject-user" && req.method === "POST") this.handleSharingRejectUser(req, res);
270
+ else if (p === "/api/sharing/remove-user" && req.method === "POST") this.handleSharingRemoveUser(req, res);
271
+ else if (p === "/api/sharing/change-role" && req.method === "POST") this.handleSharingChangeRole(req, res);
254
272
  else if (p === "/api/sharing/retry-join" && req.method === "POST") this.handleRetryJoin(req, res);
255
273
  else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
256
274
  else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
@@ -261,23 +279,23 @@ export class ViewerServer {
261
279
  else if (p === "/api/sharing/tasks/share" && req.method === "POST") this.handleSharingTaskShare(req, res);
262
280
  else if (p === "/api/sharing/tasks/unshare" && req.method === "POST") this.handleSharingTaskUnshare(req, res);
263
281
  else if (p === "/api/sharing/update-username" && req.method === "POST") this.handleUpdateUsername(req, res);
282
+ else if (p === "/api/sharing/rename-user" && req.method === "POST") this.handleAdminRenameUser(req, res);
264
283
  else if (p === "/api/sharing/test-hub" && req.method === "POST") this.handleTestHubConnection(req, res);
265
284
  else if (p === "/api/sharing/memories/share" && req.method === "POST") this.handleSharingMemoryShare(req, res);
266
285
  else if (p === "/api/sharing/memories/unshare" && req.method === "POST") this.handleSharingMemoryUnshare(req, res);
267
286
  else if (p === "/api/sharing/skills/pull" && req.method === "POST") this.handleSharingSkillPull(req, res);
268
287
  else if (p === "/api/sharing/skills/share" && req.method === "POST") this.handleSharingSkillShare(req, res);
269
288
  else if (p === "/api/sharing/skills/unshare" && req.method === "POST") this.handleSharingSkillUnshare(req, res);
270
- else if (p === "/api/sharing/groups" && req.method === "GET") this.serveSharingGroups(res);
271
- else if (p === "/api/sharing/groups" && req.method === "POST") this.handleSharingGroupCreate(req, res);
272
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "PUT") this.handleSharingGroupUpdate(req, res, p);
273
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "DELETE") this.handleSharingGroupDelete(res, p);
274
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "GET") this.serveSharingGroupMembers(res, p);
275
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "POST") this.handleSharingGroupAddMember(req, res, p);
276
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "DELETE") this.handleSharingGroupRemoveMember(req, res, p);
277
289
  else if (p === "/api/sharing/users" && req.method === "GET") this.serveSharingUsers(res);
290
+ else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
291
+ else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
292
+ else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
293
+ else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
278
294
  else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
295
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
279
296
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteTask(res, p);
280
297
  else if (p === "/api/admin/shared-skills" && req.method === "GET") this.serveAdminSharedSkills(res);
298
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET") this.serveHubSkillDetail(res, p);
281
299
  else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteSkill(res, p);
282
300
  else if (p === "/api/admin/shared-memories" && req.method === "GET") this.serveAdminSharedMemories(res);
283
301
  else if (p.match(/^\/api\/admin\/shared-memories\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteMemory(res, p);
@@ -432,22 +450,30 @@ export class ViewerServer {
432
450
  const params: any[] = [];
433
451
  if (session) { conditions.push("session_key = ?"); params.push(session); }
434
452
  if (role) { conditions.push("role = ?"); params.push(role); }
435
- if (owner) { conditions.push("owner = ?"); params.push(owner); }
453
+ if (owner && owner.startsWith("agent:")) {
454
+ conditions.push("(owner = ? OR owner = 'public')");
455
+ params.push(owner);
456
+ } else if (owner) {
457
+ conditions.push("owner = ?"); params.push(owner);
458
+ }
436
459
  if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
437
460
  if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
438
461
 
439
462
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
440
463
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
441
- const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
464
+ 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);
442
465
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
443
466
 
444
467
  const chunkIds = rawMemories.map((m: any) => m.id);
445
468
  const sharingMap = new Map<string, { visibility: string; group_id: string | null }>();
469
+ const localShareMap = new Map<string, { original_owner: string; shared_at: number }>();
446
470
  if (chunkIds.length > 0) {
447
471
  try {
448
472
  const placeholders = chunkIds.map(() => "?").join(",");
449
473
  const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ source_chunk_id: string; visibility: string; group_id: string | null }>;
450
474
  for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
475
+ const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; original_owner: string; shared_at: number }>;
476
+ for (const r of localRows) localShareMap.set(r.chunk_id, r);
451
477
  } catch {
452
478
  }
453
479
  }
@@ -458,8 +484,12 @@ export class ViewerServer {
458
484
  out.merge_sources = sources;
459
485
  }
460
486
  const shared = sharingMap.get(m.id);
487
+ const localShared = localShareMap.get(m.id);
461
488
  out.sharingVisibility = shared?.visibility ?? null;
462
489
  out.sharingGroupId = shared?.group_id ?? null;
490
+ out.localSharing = out.owner === "public";
491
+ out.localSharingManaged = !!localShared;
492
+ out.localOriginalOwner = localShared?.original_owner ?? null;
463
493
  return out;
464
494
  });
465
495
 
@@ -477,7 +507,21 @@ export class ViewerServer {
477
507
  }
478
508
 
479
509
  private serveToolMetrics(res: http.ServerResponse, url: URL): void {
480
- const minutes = Math.min(1440, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
510
+ const fromParam = url.searchParams.get("from");
511
+ const toParam = url.searchParams.get("to");
512
+ if (fromParam) {
513
+ const fromMs = new Date(fromParam).getTime();
514
+ const toMs = toParam ? new Date(toParam).getTime() : Date.now();
515
+ if (isNaN(fromMs) || isNaN(toMs)) {
516
+ this.jsonResponse(res, { error: "Invalid date" }, 400);
517
+ return;
518
+ }
519
+ const diffMin = Math.max(10, Math.min(43200, Math.round((toMs - fromMs) / 60000)));
520
+ const data = this.store.getToolMetrics(diffMin, fromMs, toMs);
521
+ this.jsonResponse(res, data);
522
+ return;
523
+ }
524
+ const minutes = Math.min(43200, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
481
525
  const data = this.store.getToolMetrics(minutes);
482
526
  this.jsonResponse(res, data);
483
527
  }
@@ -485,13 +529,15 @@ export class ViewerServer {
485
529
  private serveTasks(res: http.ServerResponse, url: URL): void {
486
530
  this.store.recordViewerEvent("tasks_list");
487
531
  const status = url.searchParams.get("status") ?? undefined;
532
+ const owner = url.searchParams.get("owner") ?? undefined;
488
533
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
489
534
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
490
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
535
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
491
536
 
492
537
  const db = (this.store as any).db;
493
538
  const items = tasks.map((t) => {
494
- const meta = db.prepare("SELECT skill_status FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null } | undefined;
539
+ const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null; owner: string | null } | undefined;
540
+ const sharedTask = db.prepare("SELECT visibility FROM hub_tasks WHERE source_task_id = ? ORDER BY updated_at DESC LIMIT 1").get(t.id) as { visibility: string } | undefined;
495
541
  return {
496
542
  id: t.id,
497
543
  sessionKey: t.sessionKey,
@@ -502,6 +548,8 @@ export class ViewerServer {
502
548
  endedAt: t.endedAt,
503
549
  chunkCount: this.store.countChunksByTask(t.id),
504
550
  skillStatus: meta?.skill_status ?? null,
551
+ owner: meta?.owner ?? "agent:main",
552
+ sharingVisibility: sharedTask?.visibility ?? null,
505
553
  };
506
554
  });
507
555
 
@@ -544,6 +592,7 @@ export class ViewerServer {
544
592
  title: task.title,
545
593
  summary: task.summary,
546
594
  status: task.status,
595
+ owner: task.owner ?? "agent:main",
547
596
  startedAt: task.startedAt,
548
597
  endedAt: task.endedAt,
549
598
  chunks: chunkItems,
@@ -552,6 +601,7 @@ export class ViewerServer {
552
601
  skillLinks,
553
602
  sharingVisibility: sharedTask?.visibility ?? null,
554
603
  sharingGroupId: sharedTask?.group_id ?? null,
604
+ hubTaskId: sharedTask ? true : false,
555
605
  });
556
606
  }
557
607
 
@@ -586,12 +636,19 @@ export class ViewerServer {
586
636
  }
587
637
  let embCount = 0;
588
638
  try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
589
- const sessionQuery = ownerFilter
590
- ? "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"
591
- : "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";
592
- const sessionList = (ownerFilter
593
- ? db.prepare(sessionQuery).all(ownerFilter)
594
- : db.prepare(sessionQuery).all()) as any[];
639
+ let sessionQuery: string;
640
+ let sessionParams: any[];
641
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
642
+ 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";
643
+ sessionParams = [ownerFilter];
644
+ } else if (ownerFilter) {
645
+ 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";
646
+ sessionParams = [ownerFilter];
647
+ } else {
648
+ 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";
649
+ sessionParams = [];
650
+ }
651
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams) as any[];
595
652
 
596
653
  let skillCount = 0;
597
654
  try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
@@ -604,10 +661,16 @@ export class ViewerServer {
604
661
 
605
662
  let owners: string[] = [];
606
663
  try {
607
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
664
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all() as any[];
608
665
  owners = ownerRows.map((o: any) => o.owner);
609
666
  } catch { /* column may not exist yet */ }
610
667
 
668
+ let currentAgentOwner = "agent:main";
669
+ try {
670
+ 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() as any;
671
+ if (latest?.owner) currentAgentOwner = latest.owner;
672
+ } catch { /* best-effort */ }
673
+
611
674
  this.jsonResponse(res, {
612
675
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
613
676
  totalSkills: skillCount,
@@ -616,6 +679,7 @@ export class ViewerServer {
616
679
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
617
680
  sessions: sessionList,
618
681
  owners,
682
+ currentAgentOwner,
619
683
  });
620
684
  } catch (e) {
621
685
  this.log.warn(`stats error: ${e}`);
@@ -727,7 +791,12 @@ export class ViewerServer {
727
791
  if (visibility) {
728
792
  skills = skills.filter(s => s.visibility === visibility);
729
793
  }
730
- this.jsonResponse(res, { skills });
794
+ const db = (this.store as any).db;
795
+ const enriched = skills.map(s => {
796
+ const hub = db.prepare("SELECT visibility FROM hub_skills WHERE source_skill_id = ? ORDER BY updated_at DESC LIMIT 1").get(s.id) as { visibility: string } | undefined;
797
+ return { ...s, sharingVisibility: hub?.visibility ?? null };
798
+ });
799
+ this.jsonResponse(res, { skills: enriched });
731
800
  }
732
801
 
733
802
  private serveSkillDetail(res: http.ServerResponse, urlPath: string): void {
@@ -978,11 +1047,21 @@ export class ViewerServer {
978
1047
  });
979
1048
  }
980
1049
 
981
- private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
1050
+ private async handleSkillDelete(res: http.ServerResponse, urlPath: string): Promise<void> {
982
1051
  const skillId = urlPath.replace("/api/skill/", "");
983
1052
  const skill = this.store.getSkill(skillId);
984
1053
  if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
985
- // Remove skill directory from disk
1054
+ try {
1055
+ const hub = this.resolveHubConnection();
1056
+ if (hub) {
1057
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
1058
+ method: "POST",
1059
+ body: JSON.stringify({ sourceSkillId: skillId }),
1060
+ }).catch(() => {});
1061
+ }
1062
+ const db = (this.store as any).db;
1063
+ db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
1064
+ } catch (_) {}
986
1065
  try {
987
1066
  if (skill.dirPath && fs.existsSync(skill.dirPath)) {
988
1067
  fs.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1029,7 +1108,15 @@ export class ViewerServer {
1029
1108
  const cleaned = chunk.role === "user" && chunk.content
1030
1109
  ? { ...chunk, content: stripInboundMetadata(chunk.content) }
1031
1110
  : chunk;
1032
- this.jsonResponse(res, { memory: cleaned });
1111
+ const localShared = this.store.getLocalSharedMemory(chunkId);
1112
+ this.jsonResponse(res, {
1113
+ memory: {
1114
+ ...cleaned,
1115
+ localSharing: cleaned.owner === "public",
1116
+ localSharingManaged: !!localShared,
1117
+ localOriginalOwner: localShared?.originalOwner ?? null,
1118
+ },
1119
+ });
1033
1120
  }
1034
1121
 
1035
1122
  private handleUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
@@ -1058,6 +1145,349 @@ export class ViewerServer {
1058
1145
  else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
1059
1146
  }
1060
1147
 
1148
+ private handleMemoryLocalShare(req: http.IncomingMessage, res: http.ServerResponse): void {
1149
+ this.readBody(req, (body) => {
1150
+ try {
1151
+ const parsed = JSON.parse(body || "{}");
1152
+ const chunkId = String(parsed.chunkId || "");
1153
+ if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1154
+ const result = this.store.markMemorySharedLocally(chunkId);
1155
+ if (!result.ok) {
1156
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "share_failed" }, result.reason === "not_found" ? 404 : 400);
1157
+ }
1158
+ this.jsonResponse(res, {
1159
+ ok: true,
1160
+ chunkId,
1161
+ owner: result.owner,
1162
+ localSharing: true,
1163
+ localSharingManaged: true,
1164
+ localOriginalOwner: result.originalOwner ?? null,
1165
+ });
1166
+ } catch (err) {
1167
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1168
+ }
1169
+ });
1170
+ }
1171
+
1172
+ private handleMemoryLocalUnshare(req: http.IncomingMessage, res: http.ServerResponse): void {
1173
+ this.readBody(req, (body) => {
1174
+ try {
1175
+ const parsed = JSON.parse(body || "{}");
1176
+ const chunkId = String(parsed.chunkId || "");
1177
+ const privateOwner = typeof parsed.privateOwner === "string" ? parsed.privateOwner : undefined;
1178
+ if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1179
+ const result = this.store.unmarkMemorySharedLocally(chunkId, privateOwner);
1180
+ if (!result.ok) {
1181
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "unshare_failed" }, result.reason === "not_found" ? 404 : 400);
1182
+ }
1183
+ this.jsonResponse(res, {
1184
+ ok: true,
1185
+ chunkId,
1186
+ owner: result.owner,
1187
+ localSharing: false,
1188
+ localOriginalOwner: result.originalOwner ?? null,
1189
+ });
1190
+ } catch (err) {
1191
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1192
+ }
1193
+ });
1194
+ }
1195
+
1196
+ // ─── Unified scope API ───
1197
+
1198
+ private handleMemoryScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1199
+ const chunkId = urlPath.split("/")[3];
1200
+ this.readBody(req, async (body) => {
1201
+ try {
1202
+ const parsed = JSON.parse(body || "{}");
1203
+ const scope = parsed.scope as string;
1204
+ if (!["private", "local", "team"].includes(scope)) {
1205
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1206
+ }
1207
+ const db = (this.store as any).db;
1208
+ const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
1209
+ if (!chunk) return this.jsonResponse(res, { ok: false, error: "not_found" }, 404);
1210
+
1211
+ if (chunk.dedup_status && chunk.dedup_status !== "active") {
1212
+ return this.jsonResponse(res, { ok: false, error: "inactive_memory", message: "Merged/duplicate memories cannot be shared" }, 400);
1213
+ }
1214
+
1215
+ const isLocalShared = chunk.owner === "public";
1216
+ const hubMemory = this.getHubMemoryForChunk(chunkId);
1217
+ const isTeamShared = !!hubMemory;
1218
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1219
+
1220
+ if (scope === currentScope) {
1221
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1222
+ }
1223
+
1224
+ let hubSynced = false;
1225
+
1226
+ if (scope === "team") {
1227
+ if (!isTeamShared) {
1228
+ const hubClient = await this.resolveHubClientAware();
1229
+ const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
1230
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
1231
+ method: "POST",
1232
+ body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1233
+ });
1234
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1235
+ if (hubClient.userId) {
1236
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1237
+ this.store.upsertHubMemory({
1238
+ id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
1239
+ sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1240
+ role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1241
+ kind: refreshedChunk.kind, groupId: null, visibility: "public",
1242
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1243
+ });
1244
+ }
1245
+ hubSynced = true;
1246
+ } else {
1247
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1248
+ }
1249
+ } else if (scope === "local") {
1250
+ if (isTeamShared) {
1251
+ try {
1252
+ const hubClient = await this.resolveHubClientAware();
1253
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1254
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1255
+ });
1256
+ if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1257
+ hubSynced = true;
1258
+ } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1259
+ }
1260
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1261
+ } else {
1262
+ if (isTeamShared) {
1263
+ try {
1264
+ const hubClient = await this.resolveHubClientAware();
1265
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1266
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1267
+ });
1268
+ if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1269
+ hubSynced = true;
1270
+ } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1271
+ }
1272
+ if (isLocalShared) this.store.unmarkMemorySharedLocally(chunkId);
1273
+ }
1274
+
1275
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1276
+ } catch (err) {
1277
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1278
+ }
1279
+ });
1280
+ }
1281
+
1282
+ private handleTaskScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1283
+ const taskId = urlPath.split("/")[3];
1284
+ this.readBody(req, async (body) => {
1285
+ try {
1286
+ const parsed = JSON.parse(body || "{}");
1287
+ const scope = parsed.scope as string;
1288
+ if (!["private", "local", "team"].includes(scope)) {
1289
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1290
+ }
1291
+ const task = this.store.getTask(taskId);
1292
+ if (!task) return this.jsonResponse(res, { ok: false, error: "task_not_found" }, 404);
1293
+
1294
+ if (scope !== "private" && task.status !== "completed") {
1295
+ return this.jsonResponse(res, { ok: false, error: "only_completed_tasks_can_be_shared" }, 400);
1296
+ }
1297
+
1298
+ const isLocalShared = task.owner === "public";
1299
+ const hubTask = this.getHubTaskForLocal(taskId);
1300
+ const isTeamShared = !!hubTask;
1301
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1302
+
1303
+ if (scope === currentScope) {
1304
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1305
+ }
1306
+
1307
+ let hubSynced = false;
1308
+
1309
+ if (scope === "team") {
1310
+ if (!isTeamShared) {
1311
+ const chunks = this.store.getChunksByTask(taskId);
1312
+ const hubClient = await this.resolveHubClientAware();
1313
+ const refreshedTask = this.store.getTask(taskId)!;
1314
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
1315
+ method: "POST",
1316
+ body: JSON.stringify({
1317
+ 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() },
1318
+ 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() })),
1319
+ }),
1320
+ });
1321
+ if (hubClient.userId) {
1322
+ const existing = this.store.getHubTaskBySource(hubClient.userId, taskId);
1323
+ this.store.upsertHubTask({
1324
+ id: (response as any)?.taskId ?? existing?.id ?? crypto.randomUUID(),
1325
+ sourceTaskId: taskId, sourceUserId: hubClient.userId, title: refreshedTask.title ?? "",
1326
+ summary: refreshedTask.summary ?? "", groupId: null, visibility: "public",
1327
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1328
+ });
1329
+ }
1330
+ hubSynced = true;
1331
+ }
1332
+ if (!isLocalShared) {
1333
+ const originalOwner = task.owner;
1334
+ const db = (this.store as any).db;
1335
+ 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());
1336
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1337
+ }
1338
+ }
1339
+
1340
+ if (scope === "local") {
1341
+ if (!isLocalShared) {
1342
+ const originalOwner = task.owner;
1343
+ const db = (this.store as any).db;
1344
+ 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());
1345
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1346
+ }
1347
+ }
1348
+
1349
+ if (scope === "local" && isTeamShared) {
1350
+ try {
1351
+ const hubClient = await this.resolveHubClientAware();
1352
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1353
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1354
+ });
1355
+ if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1356
+ hubSynced = true;
1357
+ } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1358
+ }
1359
+
1360
+ if (scope === "private") {
1361
+ if (isTeamShared) {
1362
+ try {
1363
+ const hubClient = await this.resolveHubClientAware();
1364
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1365
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1366
+ });
1367
+ if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1368
+ hubSynced = true;
1369
+ } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1370
+ }
1371
+ if (isLocalShared) {
1372
+ const db = (this.store as any).db;
1373
+ const shared = db.prepare("SELECT original_owner FROM local_shared_tasks WHERE task_id = ?").get(taskId) as any;
1374
+ const restoreOwner = shared?.original_owner ?? task.owner;
1375
+ if (restoreOwner && restoreOwner !== "public") {
1376
+ db.prepare("UPDATE tasks SET owner = ? WHERE id = ?").run(restoreOwner, taskId);
1377
+ }
1378
+ db.prepare("DELETE FROM local_shared_tasks WHERE task_id = ?").run(taskId);
1379
+ }
1380
+ }
1381
+
1382
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1383
+ } catch (err) {
1384
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1385
+ }
1386
+ });
1387
+ }
1388
+
1389
+ private handleSkillScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1390
+ const skillId = urlPath.split("/")[3];
1391
+ this.readBody(req, async (body) => {
1392
+ try {
1393
+ const parsed = JSON.parse(body || "{}");
1394
+ const scope = parsed.scope as string;
1395
+ if (!["private", "local", "team"].includes(scope)) {
1396
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1397
+ }
1398
+ const skill = this.store.getSkill(skillId);
1399
+ if (!skill) return this.jsonResponse(res, { ok: false, error: "skill_not_found" }, 404);
1400
+
1401
+ if (scope !== "private" && skill.status !== "active") {
1402
+ return this.jsonResponse(res, { ok: false, error: "only_active_skills_can_be_shared" }, 400);
1403
+ }
1404
+
1405
+ const isLocalShared = skill.visibility === "public";
1406
+ const hubSkill = this.getHubSkillForLocal(skillId);
1407
+ const isTeamShared = !!hubSkill;
1408
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1409
+
1410
+ if (scope === currentScope) {
1411
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1412
+ }
1413
+
1414
+ let hubSynced = false;
1415
+
1416
+ if (scope === "team") {
1417
+ if (!isTeamShared) {
1418
+ const bundle = buildSkillBundleForHub(this.store, skillId);
1419
+ const hubClient = await this.resolveHubClientAware();
1420
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
1421
+ method: "POST",
1422
+ body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
1423
+ });
1424
+ if (hubClient.userId) {
1425
+ const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
1426
+ this.store.upsertHubSkill({
1427
+ id: (response as any)?.skillId ?? existing?.id ?? crypto.randomUUID(),
1428
+ sourceSkillId: skillId, sourceUserId: hubClient.userId,
1429
+ name: skill.name, description: skill.description, version: skill.version,
1430
+ groupId: null, visibility: "public",
1431
+ bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
1432
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1433
+ });
1434
+ }
1435
+ hubSynced = true;
1436
+ }
1437
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1438
+ }
1439
+
1440
+ if (scope === "local") {
1441
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1442
+ }
1443
+
1444
+ if (scope === "local" && isTeamShared) {
1445
+ try {
1446
+ const hubClient = await this.resolveHubClientAware();
1447
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1448
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1449
+ });
1450
+ if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1451
+ hubSynced = true;
1452
+ } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1453
+ }
1454
+
1455
+ if (scope === "private") {
1456
+ if (isTeamShared) {
1457
+ try {
1458
+ const hubClient = await this.resolveHubClientAware();
1459
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1460
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1461
+ });
1462
+ if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1463
+ hubSynced = true;
1464
+ } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1465
+ }
1466
+ if (isLocalShared) this.store.setSkillVisibility(skillId, "private");
1467
+ }
1468
+
1469
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1470
+ } catch (err) {
1471
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1472
+ }
1473
+ });
1474
+ }
1475
+
1476
+ private getHubMemoryForChunk(chunkId: string): any {
1477
+ const db = (this.store as any).db;
1478
+ return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1479
+ }
1480
+
1481
+ private getHubTaskForLocal(taskId: string): any {
1482
+ const db = (this.store as any).db;
1483
+ return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
1484
+ }
1485
+
1486
+ private getHubSkillForLocal(skillId: string): any {
1487
+ const db = (this.store as any).db;
1488
+ return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
1489
+ }
1490
+
1061
1491
  private handleDeleteSession(res: http.ServerResponse, url: URL): void {
1062
1492
  const key = url.searchParams.get("key");
1063
1493
  if (!key) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing key" })); return; }
@@ -1093,7 +1523,8 @@ export class ViewerServer {
1093
1523
 
1094
1524
  private getOpenClawConfigPath(): string {
1095
1525
  const home = process.env.HOME || process.env.USERPROFILE || "";
1096
- return path.join(home, ".openclaw", "openclaw.json");
1526
+ const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
1527
+ return path.join(ocHome, "openclaw.json");
1097
1528
  }
1098
1529
 
1099
1530
  private getPluginEntryConfig(raw: any): Record<string, unknown> {
@@ -1157,8 +1588,7 @@ export class ViewerServer {
1157
1588
  base.connection.connected = true;
1158
1589
  base.connection.hubUrl = resolvedHubUrl ?? undefined;
1159
1590
 
1160
- // 通过 hub API 获取 admin 用户的真实信息(含分组)
1161
- let adminUser: any = { username: "hub-admin", role: "admin", groups: [] };
1591
+ let adminUser: any = { username: "hub-admin", role: "admin" };
1162
1592
  try {
1163
1593
  const hub = this.resolveHubConnection();
1164
1594
  if (hub) {
@@ -1168,7 +1598,6 @@ export class ViewerServer {
1168
1598
  id: me.id,
1169
1599
  username: me.username ?? "hub-admin",
1170
1600
  role: me.role ?? "admin",
1171
- groups: Array.isArray(me.groups) ? me.groups : [],
1172
1601
  };
1173
1602
  }
1174
1603
  }
@@ -1201,6 +1630,9 @@ export class ViewerServer {
1201
1630
  if (status.user?.status === "rejected") {
1202
1631
  output.connection.rejected = true;
1203
1632
  }
1633
+ if (status.user?.status === "removed") {
1634
+ output.connection.removed = true;
1635
+ }
1204
1636
  if (status.connected && status.hubUrl) {
1205
1637
  try {
1206
1638
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
@@ -1269,6 +1701,67 @@ export class ViewerServer {
1269
1701
  });
1270
1702
  }
1271
1703
 
1704
+ private handleSharingChangeRole(req: http.IncomingMessage, res: http.ServerResponse): void {
1705
+ this.readBody(req, async (body) => {
1706
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1707
+ try {
1708
+ const parsed = JSON.parse(body || "{}");
1709
+ const hub = this.resolveHubConnection();
1710
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1711
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/change-role", {
1712
+ method: "POST",
1713
+ body: JSON.stringify({ userId: parsed.userId, role: parsed.role }),
1714
+ });
1715
+ this.jsonResponse(res, { ok: true, result });
1716
+ } catch (err) {
1717
+ this.jsonResponse(res, { ok: false, error: String(err) });
1718
+ }
1719
+ });
1720
+ }
1721
+
1722
+ private handleSharingRemoveUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1723
+ this.readBody(req, async (body) => {
1724
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1725
+ try {
1726
+ const parsed = JSON.parse(body || "{}");
1727
+ const hub = this.resolveHubConnection();
1728
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1729
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/remove-user", {
1730
+ method: "POST",
1731
+ body: JSON.stringify({ userId: parsed.userId, cleanResources: parsed.cleanResources === true }),
1732
+ });
1733
+ this.jsonResponse(res, { ok: true, result });
1734
+ } catch (err) {
1735
+ this.jsonResponse(res, { ok: false, error: String(err) });
1736
+ }
1737
+ });
1738
+ }
1739
+
1740
+ private handleAdminRenameUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1741
+ this.readBody(req, async (body) => {
1742
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1743
+ try {
1744
+ const parsed = JSON.parse(body || "{}");
1745
+ const hub = this.resolveHubConnection();
1746
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1747
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/rename-user", {
1748
+ method: "POST",
1749
+ body: JSON.stringify({ userId: parsed.userId, username: parsed.username }),
1750
+ });
1751
+ this.jsonResponse(res, { ok: true, result });
1752
+ } catch (err) {
1753
+ const errStr = String(err);
1754
+ if (errStr.includes("username_taken")) {
1755
+ this.jsonResponse(res, { ok: false, error: "username_taken" });
1756
+ } else if (errStr.includes("invalid_params")) {
1757
+ this.jsonResponse(res, { ok: false, error: "invalid_params" });
1758
+ } else {
1759
+ this.jsonResponse(res, { ok: false, error: errStr });
1760
+ }
1761
+ }
1762
+ });
1763
+ }
1764
+
1272
1765
  private handleRetryJoin(req: http.IncomingMessage, res: http.ServerResponse): void {
1273
1766
  this.readBody(req, async (_body) => {
1274
1767
  if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
@@ -1283,12 +1776,21 @@ export class ViewerServer {
1283
1776
  }
1284
1777
  try {
1285
1778
  const hubUrl = normalizeHubUrl(hubAddress);
1779
+ const localIPs = this.getLocalIPs();
1780
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1781
+ try {
1782
+ const u = new URL(hubUrl);
1783
+ if (localIPs.includes(u.hostname)) {
1784
+ return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1785
+ }
1786
+ } catch {}
1286
1787
  const os = await import("os");
1287
- const username = os.userInfo().username || "user";
1788
+ const nickname = sharing.client?.nickname;
1789
+ const username = nickname || os.userInfo().username || "user";
1288
1790
  const hostname = os.hostname() || "unknown";
1289
1791
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
1290
1792
  method: "POST",
1291
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1793
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
1292
1794
  }) as any;
1293
1795
  this.store.setClientHubConnection({
1294
1796
  hubUrl,
@@ -1309,7 +1811,13 @@ export class ViewerServer {
1309
1811
  if (!this.ctx) return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1310
1812
  try {
1311
1813
  const limit = Number(url.searchParams.get("limit") || 40);
1312
- const data = await hubListMemories(this.store, this.ctx, { limit });
1814
+ const hub = this.resolveHubConnection();
1815
+ let data: any;
1816
+ if (hub) {
1817
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1818
+ } else {
1819
+ data = await hubListMemories(this.store, this.ctx, { limit });
1820
+ }
1313
1821
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1314
1822
  } catch (err) {
1315
1823
  this.jsonResponse(res, { memories: [], error: String(err) });
@@ -1320,7 +1828,13 @@ export class ViewerServer {
1320
1828
  if (!this.ctx) return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1321
1829
  try {
1322
1830
  const limit = Number(url.searchParams.get("limit") || 40);
1323
- const data = await hubListTasks(this.store, this.ctx, { limit });
1831
+ const hub = this.resolveHubConnection();
1832
+ let data: any;
1833
+ if (hub) {
1834
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1835
+ } else {
1836
+ data = await hubListTasks(this.store, this.ctx, { limit });
1837
+ }
1324
1838
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1325
1839
  } catch (err) {
1326
1840
  this.jsonResponse(res, { tasks: [], error: String(err) });
@@ -1331,7 +1845,13 @@ export class ViewerServer {
1331
1845
  if (!this.ctx) return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1332
1846
  try {
1333
1847
  const limit = Number(url.searchParams.get("limit") || 40);
1334
- const data = await hubListSkills(this.store, this.ctx, { limit });
1848
+ const hub = this.resolveHubConnection();
1849
+ let data: any;
1850
+ if (hub) {
1851
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
1852
+ } else {
1853
+ data = await hubListSkills(this.store, this.ctx, { limit });
1854
+ }
1335
1855
  this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1336
1856
  } catch (err) {
1337
1857
  this.jsonResponse(res, { skills: [], error: String(err) });
@@ -1347,13 +1867,21 @@ export class ViewerServer {
1347
1867
  const query = String(parsed.query || "");
1348
1868
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1349
1869
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1350
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
1870
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1351
1871
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1352
1872
  if (scope === "local") {
1353
1873
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1354
1874
  }
1355
1875
  try {
1356
- const hub = await hubSearchMemories(this.store, this.ctx, { query, maxResults, scope });
1876
+ const conn = this.resolveHubConnection();
1877
+ let hub: any;
1878
+ if (conn) {
1879
+ hub = await hubRequestJson(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
1880
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
1881
+ });
1882
+ } else {
1883
+ hub = await hubSearchMemories(this.store, this.ctx!, { query, maxResults, scope });
1884
+ }
1357
1885
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1358
1886
  } catch (err) {
1359
1887
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub, error: String(err) });
@@ -1720,117 +2248,6 @@ export class ViewerServer {
1720
2248
  return resolveHubClient(this.store, this.ctx);
1721
2249
  }
1722
2250
 
1723
- private extractGroupId(path: string): string {
1724
- const m = path.match(/\/api\/sharing\/groups\/([^/]+)/);
1725
- return m ? decodeURIComponent(m[1]) : "";
1726
- }
1727
-
1728
- private async serveSharingGroups(res: http.ServerResponse): Promise<void> {
1729
- const hub = this.resolveHubConnection();
1730
- if (!hub) return this.jsonResponse(res, { groups: [], error: "not_configured" });
1731
- try {
1732
- const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/groups", { method: "GET" }) as any;
1733
- this.jsonResponse(res, { groups: Array.isArray(data?.groups) ? data.groups : [] });
1734
- } catch (err) {
1735
- this.jsonResponse(res, { groups: [], error: String(err) });
1736
- }
1737
- }
1738
-
1739
- private handleSharingGroupCreate(req: http.IncomingMessage, res: http.ServerResponse): void {
1740
- this.readBody(req, async (body) => {
1741
- const hub = this.resolveHubConnection();
1742
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1743
- try {
1744
- const parsed = JSON.parse(body || "{}");
1745
- const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/groups", {
1746
- method: "POST",
1747
- body: JSON.stringify({ name: parsed.name, description: parsed.description }),
1748
- }) as any;
1749
- this.jsonResponse(res, { ok: true, ...data });
1750
- } catch (err) {
1751
- this.jsonResponse(res, { ok: false, error: String(err) });
1752
- }
1753
- });
1754
- }
1755
-
1756
- private handleSharingGroupUpdate(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
1757
- this.readBody(req, async (body) => {
1758
- const hub = this.resolveHubConnection();
1759
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1760
- const groupId = this.extractGroupId(p);
1761
- try {
1762
- const parsed = JSON.parse(body || "{}");
1763
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, {
1764
- method: "PUT",
1765
- body: JSON.stringify({ name: parsed.name, description: parsed.description }),
1766
- });
1767
- this.jsonResponse(res, { ok: true });
1768
- } catch (err) {
1769
- this.jsonResponse(res, { ok: false, error: String(err) });
1770
- }
1771
- });
1772
- }
1773
-
1774
- private async handleSharingGroupDelete(res: http.ServerResponse, p: string): Promise<void> {
1775
- const hub = this.resolveHubConnection();
1776
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1777
- const groupId = this.extractGroupId(p);
1778
- try {
1779
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" });
1780
- this.jsonResponse(res, { ok: true });
1781
- } catch (err) {
1782
- this.jsonResponse(res, { ok: false, error: String(err) });
1783
- }
1784
- }
1785
-
1786
- private async serveSharingGroupMembers(res: http.ServerResponse, p: string): Promise<void> {
1787
- const hub = this.resolveHubConnection();
1788
- if (!hub) return this.jsonResponse(res, { members: [], error: "not_configured" });
1789
- const groupId = this.extractGroupId(p);
1790
- try {
1791
- const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "GET" }) as any;
1792
- this.jsonResponse(res, { members: Array.isArray(data?.members) ? data.members : [] });
1793
- } catch (err) {
1794
- this.jsonResponse(res, { members: [], error: String(err) });
1795
- }
1796
- }
1797
-
1798
- private handleSharingGroupAddMember(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
1799
- this.readBody(req, async (body) => {
1800
- const hub = this.resolveHubConnection();
1801
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1802
- const groupId = this.extractGroupId(p);
1803
- try {
1804
- const parsed = JSON.parse(body || "{}");
1805
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
1806
- method: "POST",
1807
- body: JSON.stringify({ userId: parsed.userId }),
1808
- });
1809
- this.jsonResponse(res, { ok: true });
1810
- } catch (err) {
1811
- this.jsonResponse(res, { ok: false, error: String(err) });
1812
- }
1813
- });
1814
- }
1815
-
1816
- private handleSharingGroupRemoveMember(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
1817
- this.readBody(req, async (body) => {
1818
- const hub = this.resolveHubConnection();
1819
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1820
- const groupId = this.extractGroupId(p);
1821
- try {
1822
- const parsed = JSON.parse(body || "{}");
1823
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
1824
- method: "DELETE",
1825
- body: JSON.stringify({ userId: parsed.userId }),
1826
- });
1827
- this.jsonResponse(res, { ok: true });
1828
- } catch (err) {
1829
- this.jsonResponse(res, { ok: false, error: String(err) });
1830
- }
1831
- });
1832
- }
1833
-
1834
2251
  private async serveSharingUsers(res: http.ServerResponse): Promise<void> {
1835
2252
  const hub = this.resolveHubConnection();
1836
2253
  if (!hub) return this.jsonResponse(res, { users: [], error: "not_configured" });
@@ -1849,7 +2266,14 @@ export class ViewerServer {
1849
2266
  if (!hub) return this.jsonResponse(res, { tasks: [], error: "not_configured" });
1850
2267
  try {
1851
2268
  const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-tasks", { method: "GET" }) as any;
1852
- this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
2269
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2270
+ for (const tk of tasks) {
2271
+ if (!tk.summary && tk.sourceTaskId) {
2272
+ const local = this.store.getTask(tk.sourceTaskId);
2273
+ if (local) { tk.summary = local.summary; tk.title = tk.title || local.title; }
2274
+ }
2275
+ }
2276
+ this.jsonResponse(res, { tasks });
1853
2277
  } catch (err) {
1854
2278
  this.jsonResponse(res, { tasks: [], error: String(err) });
1855
2279
  }
@@ -1867,12 +2291,47 @@ export class ViewerServer {
1867
2291
  }
1868
2292
  }
1869
2293
 
2294
+ private async serveHubTaskDetail(res: http.ServerResponse, p: string): Promise<void> {
2295
+ const hub = this.resolveHubConnection();
2296
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2297
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2298
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2299
+ const taskId = decodeURIComponent(m[1]);
2300
+ try {
2301
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" }) as any;
2302
+ this.jsonResponse(res, data);
2303
+ } catch (err) {
2304
+ this.jsonResponse(res, { error: String(err) }, 500);
2305
+ }
2306
+ }
2307
+
2308
+ private async serveHubSkillDetail(res: http.ServerResponse, p: string): Promise<void> {
2309
+ const hub = this.resolveHubConnection();
2310
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2311
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2312
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2313
+ const skillId = decodeURIComponent(m[1]);
2314
+ try {
2315
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" }) as any;
2316
+ this.jsonResponse(res, data);
2317
+ } catch (err) {
2318
+ this.jsonResponse(res, { error: String(err) }, 500);
2319
+ }
2320
+ }
2321
+
1870
2322
  private async serveAdminSharedSkills(res: http.ServerResponse): Promise<void> {
1871
2323
  const hub = this.resolveHubConnection();
1872
2324
  if (!hub) return this.jsonResponse(res, { skills: [], error: "not_configured" });
1873
2325
  try {
1874
2326
  const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-skills", { method: "GET" }) as any;
1875
- this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
2327
+ const skills = Array.isArray(data?.skills) ? data.skills : [];
2328
+ for (const sk of skills) {
2329
+ if (!sk.description && sk.sourceSkillId) {
2330
+ const local = this.store.getSkill(sk.sourceSkillId);
2331
+ if (local) { sk.description = sk.description || local.description; sk.name = sk.name || local.name; }
2332
+ }
2333
+ }
2334
+ this.jsonResponse(res, { skills });
1876
2335
  } catch (err) {
1877
2336
  this.jsonResponse(res, { skills: [], error: String(err) });
1878
2337
  }
@@ -1895,7 +2354,14 @@ export class ViewerServer {
1895
2354
  if (!hub) return this.jsonResponse(res, { memories: [], error: "not_configured" });
1896
2355
  try {
1897
2356
  const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-memories", { method: "GET" }) as any;
1898
- this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
2357
+ const memories = Array.isArray(data?.memories) ? data.memories : [];
2358
+ for (const m of memories) {
2359
+ if (!m.content && m.sourceChunkId) {
2360
+ const local = this.store.getChunk(m.sourceChunkId);
2361
+ if (local) { m.content = local.content; if (!m.summary && local.summary) m.summary = local.summary; }
2362
+ }
2363
+ }
2364
+ this.jsonResponse(res, { memories });
1899
2365
  } catch (err) {
1900
2366
  this.jsonResponse(res, { memories: [], error: String(err) });
1901
2367
  }
@@ -1913,6 +2379,120 @@ export class ViewerServer {
1913
2379
  }
1914
2380
  }
1915
2381
 
2382
+ private async serveSharingNotifications(res: http.ServerResponse, url: URL): Promise<void> {
2383
+ const hub = this.resolveHubConnection();
2384
+ if (!hub) return this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2385
+ try {
2386
+ const unread = url.searchParams.get("unread") === "1" ? "?unread=1" : "";
2387
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/notifications${unread}`) as any;
2388
+ this.jsonResponse(res, data);
2389
+ } catch {
2390
+ this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2391
+ }
2392
+ }
2393
+
2394
+ private handleSharingNotificationsRead(req: http.IncomingMessage, res: http.ServerResponse): void {
2395
+ const hub = this.resolveHubConnection();
2396
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2397
+ this.readBody(req, async (raw) => {
2398
+ try {
2399
+ const body = JSON.parse(raw || "{}");
2400
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2401
+ this.jsonResponse(res, { ok: true });
2402
+ try {
2403
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2404
+ const count = data?.unreadCount ?? 0;
2405
+ this.lastKnownNotifCount = count;
2406
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2407
+ } catch { /* best effort */ }
2408
+ } catch (err) {
2409
+ this.jsonResponse(res, { ok: false, error: String(err) });
2410
+ }
2411
+ });
2412
+ }
2413
+
2414
+ private handleSharingNotificationsClear(req: http.IncomingMessage, res: http.ServerResponse): void {
2415
+ const hub = this.resolveHubConnection();
2416
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2417
+ this.readBody(req, async () => {
2418
+ try {
2419
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2420
+ this.jsonResponse(res, { ok: true });
2421
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2422
+ } catch (err) {
2423
+ this.jsonResponse(res, { ok: false, error: String(err) });
2424
+ }
2425
+ });
2426
+ }
2427
+
2428
+ private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
2429
+ res.writeHead(200, {
2430
+ "Content-Type": "text/event-stream",
2431
+ "Cache-Control": "no-cache",
2432
+ Connection: "keep-alive",
2433
+ "Access-Control-Allow-Origin": "*",
2434
+ });
2435
+ res.write("data: {\"type\":\"connected\"}\n\n");
2436
+ this.notifSSEClients.push(res);
2437
+ if (!this.notifPollTimer) this.startNotifPoll();
2438
+ req.on("close", () => {
2439
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2440
+ if (this.notifSSEClients.length === 0) this.stopNotifPoll();
2441
+ });
2442
+ }
2443
+
2444
+ private broadcastNotifSSE(data: Record<string, unknown>): void {
2445
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2446
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2447
+ try { c.write(msg); return true; } catch { return false; }
2448
+ });
2449
+ }
2450
+
2451
+ private startNotifPoll(): void {
2452
+ this.stopNotifPoll();
2453
+ const tick = async () => {
2454
+ const hub = this.resolveHubConnection();
2455
+ if (!hub) return;
2456
+ try {
2457
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2458
+ const count = data?.unreadCount ?? 0;
2459
+ if (count !== this.lastKnownNotifCount) {
2460
+ this.lastKnownNotifCount = count;
2461
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2462
+ }
2463
+ } catch { /* ignore */ }
2464
+ };
2465
+ tick();
2466
+ this.notifPollTimer = setInterval(tick, 3000);
2467
+ }
2468
+
2469
+ private stopNotifPoll(): void {
2470
+ if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
2471
+ }
2472
+
2473
+ private startHubHeartbeat(): void {
2474
+ this.stopHubHeartbeat();
2475
+ const sendHeartbeat = async () => {
2476
+ try {
2477
+ const hub = this.resolveHubConnection();
2478
+ if (!hub) {
2479
+ const persisted = this.store.getClientHubConnection();
2480
+ if (persisted?.hubUrl && persisted?.userToken) {
2481
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2482
+ }
2483
+ return;
2484
+ }
2485
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2486
+ } catch { /* best-effort */ }
2487
+ };
2488
+ sendHeartbeat();
2489
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2490
+ }
2491
+
2492
+ private stopHubHeartbeat(): void {
2493
+ if (this.hubHeartbeatTimer) { clearInterval(this.hubHeartbeatTimer); this.hubHeartbeatTimer = undefined; }
2494
+ }
2495
+
1916
2496
  private getLocalIPs(): string[] {
1917
2497
  const nets = os.networkInterfaces();
1918
2498
  const ips: string[] = [];
@@ -1965,7 +2545,7 @@ export class ViewerServer {
1965
2545
  }
1966
2546
 
1967
2547
  private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
1968
- this.readBody(req, (body) => {
2548
+ this.readBody(req, async (body) => {
1969
2549
  try {
1970
2550
  const newCfg = JSON.parse(body);
1971
2551
  const cfgPath = this.getOpenClawConfigPath();
@@ -1988,6 +2568,11 @@ export class ViewerServer {
1988
2568
  if (!entry.config) entry.config = {};
1989
2569
  const config = entry.config as Record<string, unknown>;
1990
2570
 
2571
+ const oldSharing = config.sharing as Record<string, unknown> | undefined;
2572
+ const oldSharingRole = oldSharing?.role as string | undefined;
2573
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2574
+ const oldClientHubAddress = String((oldSharing?.client as Record<string, unknown>)?.hubAddress || "");
2575
+
1991
2576
  if (newCfg.embedding) config.embedding = newCfg.embedding;
1992
2577
  if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
1993
2578
  if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
@@ -2015,6 +2600,38 @@ export class ViewerServer {
2015
2600
  } catch {}
2016
2601
  }
2017
2602
  }
2603
+
2604
+ const newRole = merged.role as string | undefined;
2605
+ const newEnabled = Boolean(merged.enabled);
2606
+
2607
+ // Detect disabling sharing or switching away from hub mode
2608
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2609
+ const isHub = newEnabled && newRole === "hub";
2610
+ if (wasHub && !isHub) {
2611
+ await this.notifyHubShutdown();
2612
+ this.stopHubHeartbeat();
2613
+ this.log.info("Hub shutting down: notified connected clients");
2614
+ }
2615
+
2616
+ // Detect disabling sharing or switching away from client mode
2617
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2618
+ const isClient = newEnabled && newRole === "client";
2619
+ if (wasClient && !isClient) {
2620
+ this.notifyHubLeave();
2621
+ this.store.clearClientHubConnection();
2622
+ this.log.info("Cleared client hub connection (sharing disabled or role changed)");
2623
+ }
2624
+
2625
+ // Detect switching to a different Hub while still in client mode
2626
+ if (wasClient && isClient) {
2627
+ const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
2628
+ if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
2629
+ this.notifyHubLeave();
2630
+ this.store.clearClientHubConnection();
2631
+ this.log.info("Cleared client hub connection (switched to different Hub)");
2632
+ }
2633
+ }
2634
+
2018
2635
  if (merged.role === "hub") {
2019
2636
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2020
2637
  } else if (merged.role === "client") {
@@ -2026,6 +2643,14 @@ export class ViewerServer {
2026
2643
  fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
2027
2644
  fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2028
2645
  this.log.info("Plugin config updated via Viewer");
2646
+ this.stopHubHeartbeat();
2647
+
2648
+ // When switching to client mode, immediately send join request
2649
+ const finalSharing = config.sharing as Record<string, unknown> | undefined;
2650
+ if (finalSharing?.role === "client" && oldSharingRole !== "client") {
2651
+ this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2652
+ }
2653
+
2029
2654
  this.jsonResponse(res, { ok: true });
2030
2655
  } catch (e) {
2031
2656
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -2035,6 +2660,87 @@ export class ViewerServer {
2035
2660
  });
2036
2661
  }
2037
2662
 
2663
+ private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<void> {
2664
+ const clientCfg = sharing.client as Record<string, unknown> | undefined;
2665
+ const hubAddress = String(clientCfg?.hubAddress || "");
2666
+ const teamToken = String(clientCfg?.teamToken || "");
2667
+ if (!hubAddress || !teamToken) return;
2668
+ const hubUrl = normalizeHubUrl(hubAddress);
2669
+ const os = await import("os");
2670
+ const nickname = String(clientCfg?.nickname || "");
2671
+ const username = nickname || os.userInfo().username || "user";
2672
+ const hostname = os.hostname() || "unknown";
2673
+ const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
2674
+ method: "POST",
2675
+ body: JSON.stringify({ teamToken, username, deviceName: hostname }),
2676
+ }) as any;
2677
+ this.store.setClientHubConnection({
2678
+ hubUrl,
2679
+ userId: String(result.userId || ""),
2680
+ username,
2681
+ userToken: result.userToken || "",
2682
+ role: "member",
2683
+ connectedAt: Date.now(),
2684
+ });
2685
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2686
+ if (result.userToken) {
2687
+ this.startHubHeartbeat();
2688
+ }
2689
+ }
2690
+
2691
+ private async notifyHubLeave(): Promise<void> {
2692
+ try {
2693
+ const hub = this.resolveHubConnection();
2694
+ if (hub) {
2695
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2696
+ this.log.info("Notified Hub of voluntary leave");
2697
+ return;
2698
+ }
2699
+ const persisted = this.store.getClientHubConnection();
2700
+ if (persisted?.hubUrl && persisted?.userToken) {
2701
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2702
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2703
+ }
2704
+ } catch (e) {
2705
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2706
+ }
2707
+ }
2708
+
2709
+ private async notifyHubShutdown(): Promise<void> {
2710
+ try {
2711
+ const sharing = this.ctx?.config.sharing;
2712
+ if (!sharing || sharing.role !== "hub") return;
2713
+ const hubPort = sharing.hub?.port ?? 18800;
2714
+ const authPath = path.join(this.dataDir, "hub-auth.json");
2715
+ let adminToken: string | undefined;
2716
+ try {
2717
+ const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
2718
+ adminToken = authData?.bootstrapAdminToken;
2719
+ } catch { return; }
2720
+ if (!adminToken) return;
2721
+
2722
+ const users = this.store.listHubUsers("active");
2723
+ const { v4: uuidv4 } = require("uuid");
2724
+ for (const u of users) {
2725
+ try {
2726
+ this.store.insertHubNotification({
2727
+ id: uuidv4(),
2728
+ userId: u.id,
2729
+ type: "hub_shutdown",
2730
+ resource: "hub",
2731
+ title: "Hub is shutting down",
2732
+ message: "The Hub server is shutting down. You may be disconnected.",
2733
+ });
2734
+ } catch (e) {
2735
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
2736
+ }
2737
+ }
2738
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
2739
+ } catch (e) {
2740
+ this.log.warn(`notifyHubShutdown error: ${e}`);
2741
+ }
2742
+ }
2743
+
2038
2744
  private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
2039
2745
  this.readBody(req, async (body) => {
2040
2746
  if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
@@ -2488,7 +3194,7 @@ export class ViewerServer {
2488
3194
 
2489
3195
  private getOpenClawHome(): string {
2490
3196
  const home = process.env.HOME || process.env.USERPROFILE || "";
2491
- return path.join(home, ".openclaw");
3197
+ return process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
2492
3198
  }
2493
3199
 
2494
3200
  private handleCleanupPolluted(res: http.ServerResponse): void {
@@ -2517,7 +3223,7 @@ export class ViewerServer {
2517
3223
  try {
2518
3224
  const ocHome = this.getOpenClawHome();
2519
3225
  const memoryDir = path.join(ocHome, "memory");
2520
- const sessionsDir = path.join(ocHome, "agents", "main", "sessions");
3226
+ const agentsDir = path.join(ocHome, "agents");
2521
3227
 
2522
3228
  const sqliteFiles: Array<{ file: string; chunks: number }> = [];
2523
3229
  if (fs.existsSync(memoryDir)) {
@@ -2536,31 +3242,36 @@ export class ViewerServer {
2536
3242
 
2537
3243
  let sessionCount = 0;
2538
3244
  let messageCount = 0;
2539
- if (fs.existsSync(sessionsDir)) {
2540
- const jsonlFiles = fs.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
2541
- sessionCount = jsonlFiles.length;
2542
- for (const f of jsonlFiles) {
2543
- try {
2544
- const content = fs.readFileSync(path.join(sessionsDir, f), "utf-8");
2545
- const lines = content.split("\n").filter(l => l.trim());
2546
- for (const line of lines) {
2547
- try {
2548
- const obj = JSON.parse(line);
2549
- if (obj.type === "message") {
2550
- const role = obj.message?.role ?? obj.role;
2551
- if (role === "user" || role === "assistant") {
2552
- const mc = obj.message?.content ?? obj.content;
2553
- let txt = "";
2554
- if (typeof mc === "string") txt = mc;
2555
- else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
2556
- else txt = JSON.stringify(mc);
2557
- if (role === "user") txt = stripInboundMetadata(txt);
2558
- if (txt && txt.length >= 10) messageCount++;
3245
+ if (fs.existsSync(agentsDir)) {
3246
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
3247
+ if (!entry.isDirectory()) continue;
3248
+ const sessDir = path.join(agentsDir, entry.name, "sessions");
3249
+ if (!fs.existsSync(sessDir)) continue;
3250
+ const jsonlFiles = fs.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3251
+ sessionCount += jsonlFiles.length;
3252
+ for (const f of jsonlFiles) {
3253
+ try {
3254
+ const content = fs.readFileSync(path.join(sessDir, f), "utf-8");
3255
+ const lines = content.split("\n").filter(l => l.trim());
3256
+ for (const line of lines) {
3257
+ try {
3258
+ const obj = JSON.parse(line);
3259
+ if (obj.type === "message") {
3260
+ const role = obj.message?.role ?? obj.role;
3261
+ if (role === "user" || role === "assistant") {
3262
+ const mc = obj.message?.content ?? obj.content;
3263
+ let txt = "";
3264
+ if (typeof mc === "string") txt = mc;
3265
+ else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
3266
+ else txt = JSON.stringify(mc);
3267
+ if (role === "user") txt = stripInboundMetadata(txt);
3268
+ if (txt && txt.length >= 10) messageCount++;
3269
+ }
2559
3270
  }
2560
- }
2561
- } catch { /* skip bad lines */ }
2562
- }
2563
- } catch { /* skip unreadable */ }
3271
+ } catch { /* skip bad lines */ }
3272
+ }
3273
+ } catch { /* skip unreadable */ }
3274
+ }
2564
3275
  }
2565
3276
  }
2566
3277