@memtensor/memos-local-openclaw-plugin 1.0.4-beta.2 → 1.0.4-beta.20

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 (124) hide show
  1. package/.env.example +7 -0
  2. package/README.md +111 -44
  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 +36 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +6 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +160 -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 +2 -3
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +9 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +500 -112
  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/chunker.d.ts +2 -1
  33. package/dist/ingest/chunker.d.ts.map +1 -1
  34. package/dist/ingest/chunker.js +14 -10
  35. package/dist/ingest/chunker.js.map +1 -1
  36. package/dist/ingest/providers/index.d.ts.map +1 -1
  37. package/dist/ingest/providers/index.js +37 -6
  38. package/dist/ingest/providers/index.js.map +1 -1
  39. package/dist/recall/engine.d.ts.map +1 -1
  40. package/dist/recall/engine.js +96 -1
  41. package/dist/recall/engine.js.map +1 -1
  42. package/dist/shared/llm-call.d.ts +1 -0
  43. package/dist/shared/llm-call.d.ts.map +1 -1
  44. package/dist/shared/llm-call.js +84 -9
  45. package/dist/shared/llm-call.js.map +1 -1
  46. package/dist/sharing/types.d.ts +1 -1
  47. package/dist/sharing/types.d.ts.map +1 -1
  48. package/dist/skill/evolver.d.ts +4 -0
  49. package/dist/skill/evolver.d.ts.map +1 -1
  50. package/dist/skill/evolver.js +59 -5
  51. package/dist/skill/evolver.js.map +1 -1
  52. package/dist/skill/generator.d.ts +2 -0
  53. package/dist/skill/generator.d.ts.map +1 -1
  54. package/dist/skill/generator.js +45 -3
  55. package/dist/skill/generator.js.map +1 -1
  56. package/dist/skill/installer.d.ts +26 -0
  57. package/dist/skill/installer.d.ts.map +1 -1
  58. package/dist/skill/installer.js +80 -4
  59. package/dist/skill/installer.js.map +1 -1
  60. package/dist/skill/upgrader.d.ts +2 -0
  61. package/dist/skill/upgrader.d.ts.map +1 -1
  62. package/dist/skill/upgrader.js +139 -1
  63. package/dist/skill/upgrader.js.map +1 -1
  64. package/dist/skill/validator.d.ts +3 -0
  65. package/dist/skill/validator.d.ts.map +1 -1
  66. package/dist/skill/validator.js +75 -0
  67. package/dist/skill/validator.js.map +1 -1
  68. package/dist/storage/ensure-binding.d.ts +12 -0
  69. package/dist/storage/ensure-binding.d.ts.map +1 -0
  70. package/dist/storage/ensure-binding.js +53 -0
  71. package/dist/storage/ensure-binding.js.map +1 -0
  72. package/dist/storage/sqlite.d.ts +115 -20
  73. package/dist/storage/sqlite.d.ts.map +1 -1
  74. package/dist/storage/sqlite.js +458 -110
  75. package/dist/storage/sqlite.js.map +1 -1
  76. package/dist/telemetry.d.ts +12 -5
  77. package/dist/telemetry.d.ts.map +1 -1
  78. package/dist/telemetry.js +156 -40
  79. package/dist/telemetry.js.map +1 -1
  80. package/dist/tools/memory-search.d.ts +3 -1
  81. package/dist/tools/memory-search.d.ts.map +1 -1
  82. package/dist/tools/memory-search.js +3 -1
  83. package/dist/tools/memory-search.js.map +1 -1
  84. package/dist/types.d.ts +11 -2
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js +4 -0
  87. package/dist/types.js.map +1 -1
  88. package/dist/viewer/html.d.ts.map +1 -1
  89. package/dist/viewer/html.js +2952 -910
  90. package/dist/viewer/html.js.map +1 -1
  91. package/dist/viewer/server.d.ts +39 -8
  92. package/dist/viewer/server.d.ts.map +1 -1
  93. package/dist/viewer/server.js +1198 -227
  94. package/dist/viewer/server.js.map +1 -1
  95. package/index.ts +774 -74
  96. package/openclaw.plugin.json +2 -2
  97. package/package.json +3 -2
  98. package/scripts/postinstall.cjs +1 -1
  99. package/skill/memos-memory-guide/SKILL.md +64 -26
  100. package/src/capture/index.ts +40 -1
  101. package/src/client/connector.ts +161 -28
  102. package/src/client/hub.ts +18 -0
  103. package/src/client/skill-sync.ts +14 -0
  104. package/src/config.ts +2 -3
  105. package/src/hub/server.ts +481 -107
  106. package/src/hub/user-manager.ts +48 -8
  107. package/src/index.ts +10 -2
  108. package/src/ingest/chunker.ts +19 -13
  109. package/src/ingest/providers/index.ts +41 -7
  110. package/src/recall/engine.ts +89 -1
  111. package/src/shared/llm-call.ts +99 -10
  112. package/src/sharing/types.ts +1 -1
  113. package/src/skill/evolver.ts +63 -6
  114. package/src/skill/generator.ts +44 -5
  115. package/src/skill/installer.ts +107 -4
  116. package/src/skill/upgrader.ts +139 -1
  117. package/src/skill/validator.ts +79 -0
  118. package/src/storage/ensure-binding.ts +52 -0
  119. package/src/storage/sqlite.ts +498 -137
  120. package/src/telemetry.ts +172 -41
  121. package/src/tools/memory-search.ts +2 -1
  122. package/src/types.ts +12 -2
  123. package/src/viewer/html.ts +2952 -910
  124. package/src/viewer/server.ts +1109 -212
@@ -72,6 +72,8 @@ class ViewerServer {
72
72
  authFile;
73
73
  auth;
74
74
  ctx;
75
+ cookieName;
76
+ defaultHubPort;
75
77
  static SESSION_TTL = 24 * 60 * 60 * 1000;
76
78
  static PLUGIN_VERSION = (() => {
77
79
  try {
@@ -91,6 +93,11 @@ class ViewerServer {
91
93
  ppAbort = false;
92
94
  ppState = { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
93
95
  ppSSEClients = [];
96
+ notifSSEClients = [];
97
+ notifPollTimer;
98
+ lastKnownNotifCount = 0;
99
+ hubHeartbeatTimer;
100
+ static HUB_HEARTBEAT_INTERVAL_MS = 45_000;
94
101
  constructor(opts) {
95
102
  this.store = opts.store;
96
103
  this.embedder = opts.embedder;
@@ -100,25 +107,41 @@ class ViewerServer {
100
107
  this.ctx = opts.ctx;
101
108
  this.authFile = node_path_1.default.join(opts.dataDir, "viewer-auth.json");
102
109
  this.auth = { passwordHash: null, sessions: new Map() };
110
+ this.cookieName = `memos_token_${opts.port}`;
111
+ this.defaultHubPort = opts.defaultHubPort ?? 18800;
103
112
  this.resetToken = node_crypto_1.default.randomBytes(16).toString("hex");
104
113
  this.loadAuth();
105
114
  }
115
+ getHubPort() {
116
+ const configured = this.ctx?.config?.sharing?.hub?.port;
117
+ if (configured && configured !== 18800)
118
+ return configured;
119
+ return this.defaultHubPort;
120
+ }
106
121
  start() {
122
+ const MAX_PORT_RETRIES = 5;
107
123
  return new Promise((resolve, reject) => {
124
+ let retries = 0;
108
125
  this.server = node_http_1.default.createServer((req, res) => this.handleRequest(req, res));
109
126
  this.server.on("error", (err) => {
110
- if (err.code === "EADDRINUSE") {
111
- this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
112
- this.server.listen(this.port + 1, "127.0.0.1");
127
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
128
+ retries++;
129
+ const nextPort = this.port + retries;
130
+ this.log.warn(`Viewer port ${this.port + retries - 1} in use, trying ${nextPort}`);
131
+ this.server.listen(nextPort, "0.0.0.0");
132
+ }
133
+ else if (err.code === "EADDRINUSE") {
134
+ reject(new Error(`Viewer failed to find open port after ${MAX_PORT_RETRIES} retries (tried ${this.port}–${this.port + MAX_PORT_RETRIES})`));
113
135
  }
114
136
  else {
115
137
  reject(err);
116
138
  }
117
139
  });
118
- this.server.listen(this.port, "127.0.0.1", () => {
140
+ this.server.listen(this.port, "0.0.0.0", () => {
119
141
  const addr = this.server.address();
120
142
  const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
121
143
  this.autoCleanupPolluted();
144
+ this.startHubHeartbeat();
122
145
  resolve(`http://127.0.0.1:${actualPort}`);
123
146
  });
124
147
  });
@@ -141,6 +164,15 @@ class ViewerServer {
141
164
  }
142
165
  }
143
166
  stop() {
167
+ this.stopHubHeartbeat();
168
+ this.stopNotifPoll();
169
+ for (const c of this.notifSSEClients) {
170
+ try {
171
+ c.end();
172
+ }
173
+ catch { }
174
+ }
175
+ this.notifSSEClients = [];
144
176
  this.server?.close();
145
177
  this.server = null;
146
178
  }
@@ -178,7 +210,8 @@ class ViewerServer {
178
210
  }
179
211
  isValidSession(req) {
180
212
  const cookie = req.headers.cookie ?? "";
181
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
213
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
214
+ const match = cookie.match(re);
182
215
  if (!match)
183
216
  return false;
184
217
  const expiry = this.auth.sessions.get(match[1]);
@@ -228,6 +261,16 @@ class ViewerServer {
228
261
  }
229
262
  if (p === "/api/memories" && req.method === "GET")
230
263
  this.serveMemories(res, url);
264
+ else if (p === "/api/memories/share-local" && req.method === "POST")
265
+ this.handleMemoryLocalShare(req, res);
266
+ else if (p === "/api/memories/unshare-local" && req.method === "POST")
267
+ this.handleMemoryLocalUnshare(req, res);
268
+ else if (p.match(/^\/api\/memory\/[^/]+\/scope$/) && req.method === "PUT")
269
+ this.handleMemoryScope(req, res, p);
270
+ else if (p.match(/^\/api\/task\/[^/]+\/scope$/) && req.method === "PUT")
271
+ this.handleTaskScope(req, res, p);
272
+ else if (p.match(/^\/api\/skill\/[^/]+\/scope$/) && req.method === "PUT")
273
+ this.handleSkillScope(req, res, p);
231
274
  else if (p === "/api/stats")
232
275
  this.serveStats(res, url);
233
276
  else if (p === "/api/metrics")
@@ -282,8 +325,14 @@ class ViewerServer {
282
325
  this.handleSharingApproveUser(req, res);
283
326
  else if (p === "/api/sharing/reject-user" && req.method === "POST")
284
327
  this.handleSharingRejectUser(req, res);
328
+ else if (p === "/api/sharing/remove-user" && req.method === "POST")
329
+ this.handleSharingRemoveUser(req, res);
330
+ else if (p === "/api/sharing/change-role" && req.method === "POST")
331
+ this.handleSharingChangeRole(req, res);
285
332
  else if (p === "/api/sharing/retry-join" && req.method === "POST")
286
333
  this.handleRetryJoin(req, res);
334
+ else if (p === "/api/sharing/leave" && req.method === "POST")
335
+ this.handleLeaveTeam(req, res);
287
336
  else if (p === "/api/sharing/search/memories" && req.method === "POST")
288
337
  this.handleSharingMemorySearch(req, res);
289
338
  else if (p === "/api/sharing/memories/list" && req.method === "GET")
@@ -302,6 +351,8 @@ class ViewerServer {
302
351
  this.handleSharingTaskUnshare(req, res);
303
352
  else if (p === "/api/sharing/update-username" && req.method === "POST")
304
353
  this.handleUpdateUsername(req, res);
354
+ else if (p === "/api/sharing/rename-user" && req.method === "POST")
355
+ this.handleAdminRenameUser(req, res);
305
356
  else if (p === "/api/sharing/test-hub" && req.method === "POST")
306
357
  this.handleTestHubConnection(req, res);
307
358
  else if (p === "/api/sharing/memories/share" && req.method === "POST")
@@ -314,28 +365,28 @@ class ViewerServer {
314
365
  this.handleSharingSkillShare(req, res);
315
366
  else if (p === "/api/sharing/skills/unshare" && req.method === "POST")
316
367
  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
368
  else if (p === "/api/sharing/users" && req.method === "GET")
332
369
  this.serveSharingUsers(res);
370
+ else if (p === "/api/sharing/notifications" && req.method === "GET")
371
+ this.serveSharingNotifications(res, url);
372
+ else if (p === "/api/sharing/notifications/read" && req.method === "POST")
373
+ this.handleSharingNotificationsRead(req, res);
374
+ else if (p === "/api/sharing/notifications/clear" && req.method === "POST")
375
+ this.handleSharingNotificationsClear(req, res);
376
+ else if (p === "/api/sharing/sync-hub-removal" && req.method === "POST")
377
+ this.handleSyncHubRemoval(req, res);
378
+ else if (p === "/api/notifications/stream" && req.method === "GET")
379
+ this.handleNotifSSE(req, res);
333
380
  else if (p === "/api/admin/shared-tasks" && req.method === "GET")
334
381
  this.serveAdminSharedTasks(res);
382
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET")
383
+ this.serveHubTaskDetail(res, p);
335
384
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE")
336
385
  this.handleAdminDeleteTask(res, p);
337
386
  else if (p === "/api/admin/shared-skills" && req.method === "GET")
338
387
  this.serveAdminSharedSkills(res);
388
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET")
389
+ this.serveHubSkillDetail(res, p);
339
390
  else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE")
340
391
  this.handleAdminDeleteSkill(res, p);
341
392
  else if (p === "/api/admin/shared-memories" && req.method === "GET")
@@ -411,7 +462,7 @@ class ViewerServer {
411
462
  const token = this.createSession();
412
463
  res.writeHead(200, {
413
464
  "Content-Type": "application/json",
414
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
465
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
415
466
  });
416
467
  res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
417
468
  }
@@ -433,7 +484,7 @@ class ViewerServer {
433
484
  const token = this.createSession();
434
485
  res.writeHead(200, {
435
486
  "Content-Type": "application/json",
436
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
487
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
437
488
  });
438
489
  res.end(JSON.stringify({ ok: true }));
439
490
  }
@@ -445,12 +496,13 @@ class ViewerServer {
445
496
  }
446
497
  handleLogout(req, res) {
447
498
  const cookie = req.headers.cookie ?? "";
448
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
499
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
500
+ const match = cookie.match(re);
449
501
  if (match)
450
502
  this.auth.sessions.delete(match[1]);
451
503
  res.writeHead(200, {
452
504
  "Content-Type": "application/json",
453
- "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
505
+ "Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
454
506
  });
455
507
  res.end(JSON.stringify({ ok: true }));
456
508
  }
@@ -476,7 +528,7 @@ class ViewerServer {
476
528
  const sessionToken = this.createSession();
477
529
  res.writeHead(200, {
478
530
  "Content-Type": "application/json",
479
- "Set-Cookie": `memos_token=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
531
+ "Set-Cookie": `${this.cookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
480
532
  });
481
533
  res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
482
534
  }
@@ -513,7 +565,11 @@ class ViewerServer {
513
565
  conditions.push("role = ?");
514
566
  params.push(role);
515
567
  }
516
- if (owner) {
568
+ if (owner && owner.startsWith("agent:")) {
569
+ conditions.push("(owner = ? OR owner = 'public')");
570
+ params.push(owner);
571
+ }
572
+ else if (owner) {
517
573
  conditions.push("owner = ?");
518
574
  params.push(owner);
519
575
  }
@@ -527,16 +583,26 @@ class ViewerServer {
527
583
  }
528
584
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
529
585
  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);
586
+ 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
587
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
532
588
  const chunkIds = rawMemories.map((m) => m.id);
533
589
  const sharingMap = new Map();
590
+ const localShareMap = new Map();
534
591
  if (chunkIds.length > 0) {
535
592
  try {
536
593
  const placeholders = chunkIds.map(() => "?").join(",");
537
594
  const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds);
538
595
  for (const r of sharedRows)
539
596
  sharingMap.set(r.source_chunk_id, r);
597
+ const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds);
598
+ for (const r of teamMetaRows) {
599
+ if (!sharingMap.has(r.chunk_id)) {
600
+ sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
601
+ }
602
+ }
603
+ const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds);
604
+ for (const r of localRows)
605
+ localShareMap.set(r.chunk_id, r);
540
606
  }
541
607
  catch {
542
608
  }
@@ -548,8 +614,12 @@ class ViewerServer {
548
614
  out.merge_sources = sources;
549
615
  }
550
616
  const shared = sharingMap.get(m.id);
617
+ const localShared = localShareMap.get(m.id);
551
618
  out.sharingVisibility = shared?.visibility ?? null;
552
619
  out.sharingGroupId = shared?.group_id ?? null;
620
+ out.localSharing = out.owner === "public";
621
+ out.localSharingManaged = !!localShared;
622
+ out.localOriginalOwner = localShared?.original_owner ?? null;
553
623
  return out;
554
624
  });
555
625
  this.store.recordViewerEvent("list");
@@ -564,19 +634,35 @@ class ViewerServer {
564
634
  this.jsonResponse(res, data);
565
635
  }
566
636
  serveToolMetrics(res, url) {
567
- const minutes = Math.min(1440, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
637
+ const fromParam = url.searchParams.get("from");
638
+ const toParam = url.searchParams.get("to");
639
+ if (fromParam) {
640
+ const fromMs = new Date(fromParam).getTime();
641
+ const toMs = toParam ? new Date(toParam).getTime() : Date.now();
642
+ if (isNaN(fromMs) || isNaN(toMs)) {
643
+ this.jsonResponse(res, { error: "Invalid date" }, 400);
644
+ return;
645
+ }
646
+ const diffMin = Math.max(10, Math.min(43200, Math.round((toMs - fromMs) / 60000)));
647
+ const data = this.store.getToolMetrics(diffMin, fromMs, toMs);
648
+ this.jsonResponse(res, data);
649
+ return;
650
+ }
651
+ const minutes = Math.min(43200, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
568
652
  const data = this.store.getToolMetrics(minutes);
569
653
  this.jsonResponse(res, data);
570
654
  }
571
655
  serveTasks(res, url) {
572
656
  this.store.recordViewerEvent("tasks_list");
573
657
  const status = url.searchParams.get("status") ?? undefined;
658
+ const owner = url.searchParams.get("owner") ?? undefined;
574
659
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
575
660
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
576
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
661
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
577
662
  const db = this.store.db;
578
663
  const items = tasks.map((t) => {
579
- const meta = db.prepare("SELECT skill_status FROM tasks WHERE id = ?").get(t.id);
664
+ const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id);
665
+ const sharedTask = db.prepare("SELECT visibility FROM hub_tasks WHERE source_task_id = ? ORDER BY updated_at DESC LIMIT 1").get(t.id);
580
666
  return {
581
667
  id: t.id,
582
668
  sessionKey: t.sessionKey,
@@ -587,6 +673,8 @@ class ViewerServer {
587
673
  endedAt: t.endedAt,
588
674
  chunkCount: this.store.countChunksByTask(t.id),
589
675
  skillStatus: meta?.skill_status ?? null,
676
+ owner: meta?.owner ?? "agent:main",
677
+ sharingVisibility: sharedTask?.visibility ?? null,
590
678
  };
591
679
  });
592
680
  this.jsonResponse(res, { tasks: items, total, limit, offset });
@@ -622,6 +710,7 @@ class ViewerServer {
622
710
  title: task.title,
623
711
  summary: task.summary,
624
712
  status: task.status,
713
+ owner: task.owner ?? "agent:main",
625
714
  startedAt: task.startedAt,
626
715
  endedAt: task.endedAt,
627
716
  chunks: chunkItems,
@@ -630,6 +719,7 @@ class ViewerServer {
630
719
  skillLinks,
631
720
  sharingVisibility: sharedTask?.visibility ?? null,
632
721
  sharingGroupId: sharedTask?.group_id ?? null,
722
+ hubTaskId: sharedTask ? true : false,
633
723
  });
634
724
  }
635
725
  serveStats(res, url) {
@@ -663,12 +753,21 @@ class ViewerServer {
663
753
  embCount = db.prepare("SELECT COUNT(*) as count FROM embeddings").get().count;
664
754
  }
665
755
  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());
756
+ let sessionQuery;
757
+ let sessionParams;
758
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
759
+ 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";
760
+ sessionParams = [ownerFilter];
761
+ }
762
+ else if (ownerFilter) {
763
+ 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";
764
+ sessionParams = [ownerFilter];
765
+ }
766
+ else {
767
+ 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";
768
+ sessionParams = [];
769
+ }
770
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams);
672
771
  let skillCount = 0;
673
772
  try {
674
773
  skillCount = db.prepare("SELECT COUNT(*) as count FROM skills").get().count;
@@ -682,10 +781,17 @@ class ViewerServer {
682
781
  catch { /* column may not exist yet */ }
683
782
  let owners = [];
684
783
  try {
685
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all();
784
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all();
686
785
  owners = ownerRows.map((o) => o.owner);
687
786
  }
688
787
  catch { /* column may not exist yet */ }
788
+ let currentAgentOwner = "agent:main";
789
+ try {
790
+ 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();
791
+ if (latest?.owner)
792
+ currentAgentOwner = latest.owner;
793
+ }
794
+ catch { /* best-effort */ }
689
795
  this.jsonResponse(res, {
690
796
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
691
797
  totalSkills: skillCount,
@@ -694,6 +800,7 @@ class ViewerServer {
694
800
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
695
801
  sessions: sessionList,
696
802
  owners,
803
+ currentAgentOwner,
697
804
  });
698
805
  }
699
806
  catch (e) {
@@ -817,7 +924,12 @@ class ViewerServer {
817
924
  if (visibility) {
818
925
  skills = skills.filter(s => s.visibility === visibility);
819
926
  }
820
- this.jsonResponse(res, { skills });
927
+ const db = this.store.db;
928
+ const enriched = skills.map(s => {
929
+ const hub = db.prepare("SELECT visibility FROM hub_skills WHERE source_skill_id = ? ORDER BY updated_at DESC LIMIT 1").get(s.id);
930
+ return { ...s, sharingVisibility: hub?.visibility ?? null };
931
+ });
932
+ this.jsonResponse(res, { skills: enriched });
821
933
  }
822
934
  serveSkillDetail(res, urlPath) {
823
935
  const skillId = urlPath.replace("/api/skill/", "");
@@ -1077,7 +1189,7 @@ class ViewerServer {
1077
1189
  }
1078
1190
  });
1079
1191
  }
1080
- handleSkillDelete(res, urlPath) {
1192
+ async handleSkillDelete(res, urlPath) {
1081
1193
  const skillId = urlPath.replace("/api/skill/", "");
1082
1194
  const skill = this.store.getSkill(skillId);
1083
1195
  if (!skill) {
@@ -1085,7 +1197,18 @@ class ViewerServer {
1085
1197
  res.end(JSON.stringify({ error: "Skill not found" }));
1086
1198
  return;
1087
1199
  }
1088
- // Remove skill directory from disk
1200
+ try {
1201
+ const hub = this.resolveHubConnection();
1202
+ if (hub) {
1203
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
1204
+ method: "POST",
1205
+ body: JSON.stringify({ sourceSkillId: skillId }),
1206
+ }).catch(() => { });
1207
+ }
1208
+ const db = this.store.db;
1209
+ db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
1210
+ }
1211
+ catch (_) { }
1089
1212
  try {
1090
1213
  if (skill.dirPath && node_fs_1.default.existsSync(skill.dirPath)) {
1091
1214
  node_fs_1.default.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1135,7 +1258,15 @@ class ViewerServer {
1135
1258
  const cleaned = chunk.role === "user" && chunk.content
1136
1259
  ? { ...chunk, content: (0, capture_1.stripInboundMetadata)(chunk.content) }
1137
1260
  : chunk;
1138
- this.jsonResponse(res, { memory: cleaned });
1261
+ const localShared = this.store.getLocalSharedMemory(chunkId);
1262
+ this.jsonResponse(res, {
1263
+ memory: {
1264
+ ...cleaned,
1265
+ localSharing: cleaned.owner === "public",
1266
+ localSharingManaged: !!localShared,
1267
+ localOriginalOwner: localShared?.originalOwner ?? null,
1268
+ },
1269
+ });
1139
1270
  }
1140
1271
  handleUpdate(req, res, urlPath) {
1141
1272
  const chunkId = urlPath.replace("/api/memory/", "");
@@ -1170,6 +1301,378 @@ class ViewerServer {
1170
1301
  res.end(JSON.stringify({ error: "Not found" }));
1171
1302
  }
1172
1303
  }
1304
+ handleMemoryLocalShare(req, res) {
1305
+ this.readBody(req, (body) => {
1306
+ try {
1307
+ const parsed = JSON.parse(body || "{}");
1308
+ const chunkId = String(parsed.chunkId || "");
1309
+ if (!chunkId)
1310
+ return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1311
+ const result = this.store.markMemorySharedLocally(chunkId);
1312
+ if (!result.ok) {
1313
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "share_failed" }, result.reason === "not_found" ? 404 : 400);
1314
+ }
1315
+ this.jsonResponse(res, {
1316
+ ok: true,
1317
+ chunkId,
1318
+ owner: result.owner,
1319
+ localSharing: true,
1320
+ localSharingManaged: true,
1321
+ localOriginalOwner: result.originalOwner ?? null,
1322
+ });
1323
+ }
1324
+ catch (err) {
1325
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1326
+ }
1327
+ });
1328
+ }
1329
+ handleMemoryLocalUnshare(req, res) {
1330
+ this.readBody(req, (body) => {
1331
+ try {
1332
+ const parsed = JSON.parse(body || "{}");
1333
+ const chunkId = String(parsed.chunkId || "");
1334
+ const privateOwner = typeof parsed.privateOwner === "string" ? parsed.privateOwner : undefined;
1335
+ if (!chunkId)
1336
+ return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1337
+ const result = this.store.unmarkMemorySharedLocally(chunkId, privateOwner);
1338
+ if (!result.ok) {
1339
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "unshare_failed" }, result.reason === "not_found" ? 404 : 400);
1340
+ }
1341
+ this.jsonResponse(res, {
1342
+ ok: true,
1343
+ chunkId,
1344
+ owner: result.owner,
1345
+ localSharing: false,
1346
+ localOriginalOwner: result.originalOwner ?? null,
1347
+ });
1348
+ }
1349
+ catch (err) {
1350
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1351
+ }
1352
+ });
1353
+ }
1354
+ // ─── Unified scope API ───
1355
+ handleMemoryScope(req, res, urlPath) {
1356
+ const chunkId = urlPath.split("/")[3];
1357
+ this.readBody(req, async (body) => {
1358
+ try {
1359
+ const parsed = JSON.parse(body || "{}");
1360
+ const scope = parsed.scope;
1361
+ if (!["private", "local", "team"].includes(scope)) {
1362
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1363
+ }
1364
+ const db = this.store.db;
1365
+ const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId);
1366
+ if (!chunk)
1367
+ return this.jsonResponse(res, { ok: false, error: "not_found" }, 404);
1368
+ if (chunk.dedup_status && chunk.dedup_status !== "active") {
1369
+ return this.jsonResponse(res, { ok: false, error: "inactive_memory", message: "Merged/duplicate memories cannot be shared" }, 400);
1370
+ }
1371
+ const isLocalShared = chunk.owner === "public";
1372
+ const hubMemory = this.getHubMemoryForChunk(chunkId);
1373
+ const isTeamShared = !!hubMemory;
1374
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1375
+ if (scope === currentScope) {
1376
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1377
+ }
1378
+ let hubSynced = false;
1379
+ if (scope === "team") {
1380
+ if (!isTeamShared) {
1381
+ const hubClient = await this.resolveHubClientAware();
1382
+ const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId);
1383
+ const response = await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
1384
+ method: "POST",
1385
+ body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1386
+ });
1387
+ if (!isLocalShared)
1388
+ this.store.markMemorySharedLocally(chunkId);
1389
+ const memoryId = String(response?.memoryId ?? "");
1390
+ const isHubRole = this.ctx?.config?.sharing?.role === "hub";
1391
+ if (hubClient.userId && isHubRole) {
1392
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1393
+ this.store.upsertHubMemory({
1394
+ id: memoryId || existing?.id || node_crypto_1.default.randomUUID(),
1395
+ sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1396
+ role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1397
+ kind: refreshedChunk.kind, groupId: null, visibility: "public",
1398
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1399
+ });
1400
+ }
1401
+ else if (hubClient.userId) {
1402
+ this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
1403
+ }
1404
+ hubSynced = true;
1405
+ }
1406
+ else {
1407
+ if (!isLocalShared)
1408
+ this.store.markMemorySharedLocally(chunkId);
1409
+ }
1410
+ }
1411
+ else if (scope === "local") {
1412
+ if (isTeamShared) {
1413
+ try {
1414
+ const hubClient = await this.resolveHubClientAware();
1415
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1416
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1417
+ });
1418
+ if (hubClient.userId)
1419
+ this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1420
+ this.store.deleteTeamSharedChunk(chunkId);
1421
+ hubSynced = true;
1422
+ }
1423
+ catch (err) {
1424
+ this.log.warn(`Failed to unshare memory from team: ${err}`);
1425
+ }
1426
+ }
1427
+ if (!isLocalShared)
1428
+ this.store.markMemorySharedLocally(chunkId);
1429
+ }
1430
+ else {
1431
+ if (isTeamShared) {
1432
+ try {
1433
+ const hubClient = await this.resolveHubClientAware();
1434
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1435
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1436
+ });
1437
+ if (hubClient.userId)
1438
+ this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1439
+ this.store.deleteTeamSharedChunk(chunkId);
1440
+ hubSynced = true;
1441
+ }
1442
+ catch (err) {
1443
+ this.log.warn(`Failed to unshare memory from team: ${err}`);
1444
+ }
1445
+ }
1446
+ if (isLocalShared)
1447
+ this.store.unmarkMemorySharedLocally(chunkId);
1448
+ }
1449
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1450
+ }
1451
+ catch (err) {
1452
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1453
+ }
1454
+ });
1455
+ }
1456
+ handleTaskScope(req, res, urlPath) {
1457
+ const taskId = urlPath.split("/")[3];
1458
+ this.readBody(req, async (body) => {
1459
+ try {
1460
+ const parsed = JSON.parse(body || "{}");
1461
+ const scope = parsed.scope;
1462
+ if (!["private", "local", "team"].includes(scope)) {
1463
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1464
+ }
1465
+ const task = this.store.getTask(taskId);
1466
+ if (!task)
1467
+ return this.jsonResponse(res, { ok: false, error: "task_not_found" }, 404);
1468
+ if (scope !== "private" && task.status !== "completed") {
1469
+ return this.jsonResponse(res, { ok: false, error: "only_completed_tasks_can_be_shared" }, 400);
1470
+ }
1471
+ const isLocalShared = task.owner === "public";
1472
+ const hubTask = this.getHubTaskForLocal(taskId);
1473
+ const isTeamShared = !!hubTask;
1474
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1475
+ if (scope === currentScope) {
1476
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1477
+ }
1478
+ let hubSynced = false;
1479
+ if (scope === "team") {
1480
+ if (!isTeamShared) {
1481
+ const chunks = this.store.getChunksByTask(taskId);
1482
+ const hubClient = await this.resolveHubClientAware();
1483
+ const refreshedTask = this.store.getTask(taskId);
1484
+ const response = await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
1485
+ method: "POST",
1486
+ body: JSON.stringify({
1487
+ 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() },
1488
+ 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() })),
1489
+ }),
1490
+ });
1491
+ if (hubClient.userId) {
1492
+ const existing = this.store.getHubTaskBySource(hubClient.userId, taskId);
1493
+ this.store.upsertHubTask({
1494
+ id: response?.taskId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
1495
+ sourceTaskId: taskId, sourceUserId: hubClient.userId, title: refreshedTask.title ?? "",
1496
+ summary: refreshedTask.summary ?? "", groupId: null, visibility: "public",
1497
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1498
+ });
1499
+ }
1500
+ hubSynced = true;
1501
+ }
1502
+ if (!isLocalShared) {
1503
+ const originalOwner = task.owner;
1504
+ const db = this.store.db;
1505
+ 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());
1506
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1507
+ }
1508
+ }
1509
+ if (scope === "local") {
1510
+ if (!isLocalShared) {
1511
+ const originalOwner = task.owner;
1512
+ const db = this.store.db;
1513
+ 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());
1514
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1515
+ }
1516
+ }
1517
+ if (scope === "local" && isTeamShared) {
1518
+ try {
1519
+ const hubClient = await this.resolveHubClientAware();
1520
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1521
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1522
+ });
1523
+ if (hubClient.userId)
1524
+ this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1525
+ hubSynced = true;
1526
+ }
1527
+ catch (err) {
1528
+ this.log.warn(`Failed to unshare task from team: ${err}`);
1529
+ }
1530
+ }
1531
+ if (scope === "private") {
1532
+ if (isTeamShared) {
1533
+ try {
1534
+ const hubClient = await this.resolveHubClientAware();
1535
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1536
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1537
+ });
1538
+ if (hubClient.userId)
1539
+ this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1540
+ hubSynced = true;
1541
+ }
1542
+ catch (err) {
1543
+ this.log.warn(`Failed to unshare task from team: ${err}`);
1544
+ }
1545
+ }
1546
+ if (isLocalShared) {
1547
+ const db = this.store.db;
1548
+ const shared = db.prepare("SELECT original_owner FROM local_shared_tasks WHERE task_id = ?").get(taskId);
1549
+ const restoreOwner = shared?.original_owner ?? task.owner;
1550
+ if (restoreOwner && restoreOwner !== "public") {
1551
+ db.prepare("UPDATE tasks SET owner = ? WHERE id = ?").run(restoreOwner, taskId);
1552
+ }
1553
+ db.prepare("DELETE FROM local_shared_tasks WHERE task_id = ?").run(taskId);
1554
+ }
1555
+ }
1556
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1557
+ }
1558
+ catch (err) {
1559
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1560
+ }
1561
+ });
1562
+ }
1563
+ handleSkillScope(req, res, urlPath) {
1564
+ const skillId = urlPath.split("/")[3];
1565
+ this.readBody(req, async (body) => {
1566
+ try {
1567
+ const parsed = JSON.parse(body || "{}");
1568
+ const scope = parsed.scope;
1569
+ if (!["private", "local", "team"].includes(scope)) {
1570
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1571
+ }
1572
+ const skill = this.store.getSkill(skillId);
1573
+ if (!skill)
1574
+ return this.jsonResponse(res, { ok: false, error: "skill_not_found" }, 404);
1575
+ if (scope !== "private" && skill.status !== "active") {
1576
+ return this.jsonResponse(res, { ok: false, error: "only_active_skills_can_be_shared" }, 400);
1577
+ }
1578
+ const isLocalShared = skill.visibility === "public";
1579
+ const hubSkill = this.getHubSkillForLocal(skillId);
1580
+ const isTeamShared = !!hubSkill;
1581
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1582
+ if (scope === currentScope) {
1583
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1584
+ }
1585
+ let hubSynced = false;
1586
+ if (scope === "team") {
1587
+ if (!isTeamShared) {
1588
+ const bundle = (0, skill_sync_1.buildSkillBundleForHub)(this.store, skillId);
1589
+ const hubClient = await this.resolveHubClientAware();
1590
+ const response = await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
1591
+ method: "POST",
1592
+ body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
1593
+ });
1594
+ if (hubClient.userId) {
1595
+ const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
1596
+ this.store.upsertHubSkill({
1597
+ id: response?.skillId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
1598
+ sourceSkillId: skillId, sourceUserId: hubClient.userId,
1599
+ name: skill.name, description: skill.description, version: skill.version,
1600
+ groupId: null, visibility: "public",
1601
+ bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
1602
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1603
+ });
1604
+ }
1605
+ hubSynced = true;
1606
+ }
1607
+ if (!isLocalShared)
1608
+ this.store.setSkillVisibility(skillId, "public");
1609
+ }
1610
+ if (scope === "local") {
1611
+ if (!isLocalShared)
1612
+ this.store.setSkillVisibility(skillId, "public");
1613
+ }
1614
+ if (scope === "local" && isTeamShared) {
1615
+ try {
1616
+ const hubClient = await this.resolveHubClientAware();
1617
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1618
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1619
+ });
1620
+ if (hubClient.userId)
1621
+ this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1622
+ hubSynced = true;
1623
+ }
1624
+ catch (err) {
1625
+ this.log.warn(`Failed to unpublish skill from team: ${err}`);
1626
+ }
1627
+ }
1628
+ if (scope === "private") {
1629
+ if (isTeamShared) {
1630
+ try {
1631
+ const hubClient = await this.resolveHubClientAware();
1632
+ await (0, hub_1.hubRequestJson)(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1633
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1634
+ });
1635
+ if (hubClient.userId)
1636
+ this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1637
+ hubSynced = true;
1638
+ }
1639
+ catch (err) {
1640
+ this.log.warn(`Failed to unpublish skill from team: ${err}`);
1641
+ }
1642
+ }
1643
+ if (isLocalShared)
1644
+ this.store.setSkillVisibility(skillId, "private");
1645
+ }
1646
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1647
+ }
1648
+ catch (err) {
1649
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1650
+ }
1651
+ });
1652
+ }
1653
+ getHubMemoryForChunk(chunkId) {
1654
+ const db = this.store.db;
1655
+ const hub = db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1656
+ if (hub)
1657
+ return hub;
1658
+ const ts = this.store.getTeamSharedChunk(chunkId);
1659
+ if (ts) {
1660
+ return {
1661
+ source_chunk_id: chunkId,
1662
+ visibility: ts.visibility,
1663
+ group_id: ts.groupId,
1664
+ };
1665
+ }
1666
+ return undefined;
1667
+ }
1668
+ getHubTaskForLocal(taskId) {
1669
+ const db = this.store.db;
1670
+ return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
1671
+ }
1672
+ getHubSkillForLocal(skillId) {
1673
+ const db = this.store.db;
1674
+ return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
1675
+ }
1173
1676
  handleDeleteSession(res, url) {
1174
1677
  const key = url.searchParams.get("key");
1175
1678
  if (!key) {
@@ -1206,8 +1709,11 @@ class ViewerServer {
1206
1709
  // ─── Helpers ───
1207
1710
  // ─── Config API ───
1208
1711
  getOpenClawConfigPath() {
1712
+ if (process.env.OPENCLAW_CONFIG_PATH)
1713
+ return process.env.OPENCLAW_CONFIG_PATH;
1209
1714
  const home = process.env.HOME || process.env.USERPROFILE || "";
1210
- return node_path_1.default.join(home, ".openclaw", "openclaw.json");
1715
+ const ocHome = process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
1716
+ return node_path_1.default.join(ocHome, "openclaw.json");
1211
1717
  }
1212
1718
  getPluginEntryConfig(raw) {
1213
1719
  const entries = raw?.plugins?.entries ?? {};
@@ -1263,8 +1769,7 @@ class ViewerServer {
1263
1769
  base.admin.rejectSupported = true;
1264
1770
  base.connection.connected = true;
1265
1771
  base.connection.hubUrl = resolvedHubUrl ?? undefined;
1266
- // 通过 hub API 获取 admin 用户的真实信息(含分组)
1267
- let adminUser = { username: "hub-admin", role: "admin", groups: [] };
1772
+ let adminUser = { username: "hub-admin", role: "admin" };
1268
1773
  try {
1269
1774
  const hub = this.resolveHubConnection();
1270
1775
  if (hub) {
@@ -1274,7 +1779,6 @@ class ViewerServer {
1274
1779
  id: me.id,
1275
1780
  username: me.username ?? "hub-admin",
1276
1781
  role: me.role ?? "admin",
1277
- groups: Array.isArray(me.groups) ? me.groups : [],
1278
1782
  };
1279
1783
  }
1280
1784
  }
@@ -1289,7 +1793,18 @@ class ViewerServer {
1289
1793
  base.connection.apiVersion = info?.apiVersion ?? null;
1290
1794
  }
1291
1795
  catch { /* ignore */ }
1292
- this.jsonResponse(res, base);
1796
+ const hubStats = { totalMembers: 0, onlineMembers: 0, pendingMembers: 0 };
1797
+ try {
1798
+ const activeUsers = this.store.listHubUsers("active");
1799
+ const pendingUsers = this.store.listHubUsers("pending");
1800
+ const now = Date.now();
1801
+ const OFFLINE_THRESHOLD = 120_000;
1802
+ hubStats.totalMembers = activeUsers.length;
1803
+ hubStats.onlineMembers = activeUsers.filter(u => u.lastActiveAt && (now - u.lastActiveAt < OFFLINE_THRESHOLD)).length;
1804
+ hubStats.pendingMembers = pendingUsers.length;
1805
+ }
1806
+ catch { /* best-effort */ }
1807
+ this.jsonResponse(res, { ...base, hubStats });
1293
1808
  return;
1294
1809
  }
1295
1810
  const hasPendingConnection = Boolean(persisted?.hubUrl && persisted?.userId && !persisted?.userToken);
@@ -1306,6 +1821,9 @@ class ViewerServer {
1306
1821
  if (status.user?.status === "rejected") {
1307
1822
  output.connection.rejected = true;
1308
1823
  }
1824
+ if (status.user?.status === "removed") {
1825
+ output.connection.removed = true;
1826
+ }
1309
1827
  if (status.connected && status.hubUrl) {
1310
1828
  try {
1311
1829
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
@@ -1383,6 +1901,75 @@ class ViewerServer {
1383
1901
  }
1384
1902
  });
1385
1903
  }
1904
+ handleSharingChangeRole(req, res) {
1905
+ this.readBody(req, async (body) => {
1906
+ if (!this.ctx)
1907
+ return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1908
+ try {
1909
+ const parsed = JSON.parse(body || "{}");
1910
+ const hub = this.resolveHubConnection();
1911
+ if (!hub)
1912
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
1913
+ const result = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/change-role", {
1914
+ method: "POST",
1915
+ body: JSON.stringify({ userId: parsed.userId, role: parsed.role }),
1916
+ });
1917
+ this.jsonResponse(res, { ok: true, result });
1918
+ }
1919
+ catch (err) {
1920
+ this.jsonResponse(res, { ok: false, error: String(err) });
1921
+ }
1922
+ });
1923
+ }
1924
+ handleSharingRemoveUser(req, res) {
1925
+ this.readBody(req, async (body) => {
1926
+ if (!this.ctx)
1927
+ return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1928
+ try {
1929
+ const parsed = JSON.parse(body || "{}");
1930
+ const hub = this.resolveHubConnection();
1931
+ if (!hub)
1932
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
1933
+ const result = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/remove-user", {
1934
+ method: "POST",
1935
+ body: JSON.stringify({ userId: parsed.userId, cleanResources: parsed.cleanResources === true }),
1936
+ });
1937
+ this.jsonResponse(res, { ok: true, result });
1938
+ }
1939
+ catch (err) {
1940
+ this.jsonResponse(res, { ok: false, error: String(err) });
1941
+ }
1942
+ });
1943
+ }
1944
+ handleAdminRenameUser(req, res) {
1945
+ this.readBody(req, async (body) => {
1946
+ if (!this.ctx)
1947
+ return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1948
+ try {
1949
+ const parsed = JSON.parse(body || "{}");
1950
+ const hub = this.resolveHubConnection();
1951
+ if (!hub)
1952
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
1953
+ const result = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/rename-user", {
1954
+ method: "POST",
1955
+ body: JSON.stringify({ userId: parsed.userId, username: parsed.username }),
1956
+ });
1957
+ this.jsonResponse(res, { ok: true, result });
1958
+ }
1959
+ catch (err) {
1960
+ const errStr = String(err);
1961
+ if (errStr.includes("username_taken")) {
1962
+ this.jsonResponse(res, { ok: false, error: "username_taken" });
1963
+ }
1964
+ else if (errStr.includes("invalid_params")) {
1965
+ this.jsonResponse(res, { ok: false, error: "invalid_params" });
1966
+ }
1967
+ else {
1968
+ this.jsonResponse(res, { ok: false, error: errStr });
1969
+ }
1970
+ }
1971
+ });
1972
+ }
1386
1973
  handleRetryJoin(req, res) {
1387
1974
  this.readBody(req, async (_body) => {
1388
1975
  if (!this.ctx)
@@ -1399,12 +1986,16 @@ class ViewerServer {
1399
1986
  try {
1400
1987
  const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
1401
1988
  const os = await Promise.resolve().then(() => __importStar(require("os")));
1402
- const username = os.userInfo().username || "user";
1989
+ const nickname = sharing.client?.nickname;
1990
+ const username = nickname || os.userInfo().username || "user";
1403
1991
  const hostname = os.hostname() || "unknown";
1992
+ const persisted = this.store.getClientHubConnection();
1993
+ const existingIdentityKey = persisted?.identityKey || "";
1404
1994
  const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
1405
1995
  method: "POST",
1406
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1996
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
1407
1997
  });
1998
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
1408
1999
  this.store.setClientHubConnection({
1409
2000
  hubUrl,
1410
2001
  userId: String(result.userId || ""),
@@ -1412,6 +2003,8 @@ class ViewerServer {
1412
2003
  userToken: result.userToken || "",
1413
2004
  role: "member",
1414
2005
  connectedAt: Date.now(),
2006
+ identityKey: returnedIdentityKey,
2007
+ lastKnownStatus: result.status || "",
1415
2008
  });
1416
2009
  this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1417
2010
  }
@@ -1425,7 +2018,14 @@ class ViewerServer {
1425
2018
  return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1426
2019
  try {
1427
2020
  const limit = Number(url.searchParams.get("limit") || 40);
1428
- const data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
2021
+ const hub = this.resolveHubConnection();
2022
+ let data;
2023
+ if (hub) {
2024
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
2025
+ }
2026
+ else {
2027
+ data = await (0, hub_1.hubListMemories)(this.store, this.ctx, { limit });
2028
+ }
1429
2029
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1430
2030
  }
1431
2031
  catch (err) {
@@ -1437,7 +2037,14 @@ class ViewerServer {
1437
2037
  return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1438
2038
  try {
1439
2039
  const limit = Number(url.searchParams.get("limit") || 40);
1440
- const data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
2040
+ const hub = this.resolveHubConnection();
2041
+ let data;
2042
+ if (hub) {
2043
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
2044
+ }
2045
+ else {
2046
+ data = await (0, hub_1.hubListTasks)(this.store, this.ctx, { limit });
2047
+ }
1441
2048
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1442
2049
  }
1443
2050
  catch (err) {
@@ -1449,10 +2056,17 @@ class ViewerServer {
1449
2056
  return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1450
2057
  try {
1451
2058
  const limit = Number(url.searchParams.get("limit") || 40);
1452
- const data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
1453
- this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1454
- }
1455
- catch (err) {
2059
+ const hub = this.resolveHubConnection();
2060
+ let data;
2061
+ if (hub) {
2062
+ data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
2063
+ }
2064
+ else {
2065
+ data = await (0, hub_1.hubListSkills)(this.store, this.ctx, { limit });
2066
+ }
2067
+ this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
2068
+ }
2069
+ catch (err) {
1456
2070
  this.jsonResponse(res, { skills: [], error: String(err) });
1457
2071
  }
1458
2072
  }
@@ -1466,13 +2080,22 @@ class ViewerServer {
1466
2080
  const query = String(parsed.query || "");
1467
2081
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1468
2082
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1469
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
2083
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1470
2084
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1471
2085
  if (scope === "local") {
1472
2086
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1473
2087
  }
1474
2088
  try {
1475
- const hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2089
+ const conn = this.resolveHubConnection();
2090
+ let hub;
2091
+ if (conn) {
2092
+ hub = await (0, hub_1.hubRequestJson)(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
2093
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
2094
+ });
2095
+ }
2096
+ else {
2097
+ hub = await (0, hub_1.hubSearchMemories)(this.store, this.ctx, { query, maxResults, scope });
2098
+ }
1476
2099
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1477
2100
  }
1478
2101
  catch (err) {
@@ -1677,14 +2300,14 @@ class ViewerServer {
1677
2300
  },
1678
2301
  }),
1679
2302
  });
1680
- const hubUserId = hubClient.userId;
1681
- if (hubUserId) {
2303
+ const mid = String(response?.memoryId ?? "");
2304
+ if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
1682
2305
  const now = Date.now();
1683
- const existing = this.store.getHubMemoryBySource(hubUserId, chunk.id);
2306
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
1684
2307
  this.store.upsertHubMemory({
1685
- id: response?.memoryId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
2308
+ id: mid || existing?.id || node_crypto_1.default.randomUUID(),
1686
2309
  sourceChunkId: chunk.id,
1687
- sourceUserId: hubUserId,
2310
+ sourceUserId: hubClient.userId,
1688
2311
  role: chunk.role,
1689
2312
  content: chunk.content,
1690
2313
  summary: chunk.summary ?? "",
@@ -1695,6 +2318,9 @@ class ViewerServer {
1695
2318
  updatedAt: now,
1696
2319
  });
1697
2320
  }
2321
+ else if (hubClient.userId) {
2322
+ this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
2323
+ }
1698
2324
  this.jsonResponse(res, { ok: true, chunkId, visibility, response });
1699
2325
  }
1700
2326
  catch (err) {
@@ -1717,6 +2343,7 @@ class ViewerServer {
1717
2343
  const hubUserId = hubClient.userId;
1718
2344
  if (hubUserId)
1719
2345
  this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2346
+ this.store.deleteTeamSharedChunk(chunkId);
1720
2347
  this.jsonResponse(res, { ok: true, chunkId });
1721
2348
  }
1722
2349
  catch (err) {
@@ -1819,7 +2446,7 @@ class ViewerServer {
1819
2446
  // Hub 模式:连接自己,用 bootstrap admin token
1820
2447
  const sharing = this.ctx.config.sharing;
1821
2448
  if (sharing?.role === "hub") {
1822
- const hubPort = sharing.hub?.port ?? 18800;
2449
+ const hubPort = this.getHubPort();
1823
2450
  const hubUrl = `http://127.0.0.1:${hubPort}`;
1824
2451
  try {
1825
2452
  const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
@@ -1860,123 +2487,6 @@ class ViewerServer {
1860
2487
  }
1861
2488
  return (0, hub_1.resolveHubClient)(this.store, this.ctx);
1862
2489
  }
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
2490
  async serveSharingUsers(res) {
1981
2491
  const hub = this.resolveHubConnection();
1982
2492
  if (!hub)
@@ -1996,7 +2506,17 @@ class ViewerServer {
1996
2506
  return this.jsonResponse(res, { tasks: [], error: "not_configured" });
1997
2507
  try {
1998
2508
  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 : [] });
2509
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2510
+ for (const tk of tasks) {
2511
+ if (!tk.summary && tk.sourceTaskId) {
2512
+ const local = this.store.getTask(tk.sourceTaskId);
2513
+ if (local) {
2514
+ tk.summary = local.summary;
2515
+ tk.title = tk.title || local.title;
2516
+ }
2517
+ }
2518
+ }
2519
+ this.jsonResponse(res, { tasks });
2000
2520
  }
2001
2521
  catch (err) {
2002
2522
  this.jsonResponse(res, { tasks: [], error: String(err) });
@@ -2015,13 +2535,55 @@ class ViewerServer {
2015
2535
  this.jsonResponse(res, { ok: false, error: String(err) });
2016
2536
  }
2017
2537
  }
2538
+ async serveHubTaskDetail(res, p) {
2539
+ const hub = this.resolveHubConnection();
2540
+ if (!hub)
2541
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2542
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2543
+ if (!m)
2544
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2545
+ const taskId = decodeURIComponent(m[1]);
2546
+ try {
2547
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" });
2548
+ this.jsonResponse(res, data);
2549
+ }
2550
+ catch (err) {
2551
+ this.jsonResponse(res, { error: String(err) }, 500);
2552
+ }
2553
+ }
2554
+ async serveHubSkillDetail(res, p) {
2555
+ const hub = this.resolveHubConnection();
2556
+ if (!hub)
2557
+ return this.jsonResponse(res, { error: "not_configured" }, 500);
2558
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2559
+ if (!m)
2560
+ return this.jsonResponse(res, { error: "bad_request" }, 400);
2561
+ const skillId = decodeURIComponent(m[1]);
2562
+ try {
2563
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" });
2564
+ this.jsonResponse(res, data);
2565
+ }
2566
+ catch (err) {
2567
+ this.jsonResponse(res, { error: String(err) }, 500);
2568
+ }
2569
+ }
2018
2570
  async serveAdminSharedSkills(res) {
2019
2571
  const hub = this.resolveHubConnection();
2020
2572
  if (!hub)
2021
2573
  return this.jsonResponse(res, { skills: [], error: "not_configured" });
2022
2574
  try {
2023
2575
  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 : [] });
2576
+ const skills = Array.isArray(data?.skills) ? data.skills : [];
2577
+ for (const sk of skills) {
2578
+ if (!sk.description && sk.sourceSkillId) {
2579
+ const local = this.store.getSkill(sk.sourceSkillId);
2580
+ if (local) {
2581
+ sk.description = sk.description || local.description;
2582
+ sk.name = sk.name || local.name;
2583
+ }
2584
+ }
2585
+ }
2586
+ this.jsonResponse(res, { skills });
2025
2587
  }
2026
2588
  catch (err) {
2027
2589
  this.jsonResponse(res, { skills: [], error: String(err) });
@@ -2046,7 +2608,18 @@ class ViewerServer {
2046
2608
  return this.jsonResponse(res, { memories: [], error: "not_configured" });
2047
2609
  try {
2048
2610
  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 : [] });
2611
+ const memories = Array.isArray(data?.memories) ? data.memories : [];
2612
+ for (const m of memories) {
2613
+ if (!m.content && m.sourceChunkId) {
2614
+ const local = this.store.getChunk(m.sourceChunkId);
2615
+ if (local) {
2616
+ m.content = local.content;
2617
+ if (!m.summary && local.summary)
2618
+ m.summary = local.summary;
2619
+ }
2620
+ }
2621
+ }
2622
+ this.jsonResponse(res, { memories });
2050
2623
  }
2051
2624
  catch (err) {
2052
2625
  this.jsonResponse(res, { memories: [], error: String(err) });
@@ -2065,6 +2638,167 @@ class ViewerServer {
2065
2638
  this.jsonResponse(res, { ok: false, error: String(err) });
2066
2639
  }
2067
2640
  }
2641
+ async serveSharingNotifications(res, url) {
2642
+ const hub = this.resolveHubConnection();
2643
+ if (!hub)
2644
+ return this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2645
+ try {
2646
+ const unread = url.searchParams.get("unread") === "1" ? "?unread=1" : "";
2647
+ const data = await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, `/api/v1/hub/notifications${unread}`);
2648
+ this.jsonResponse(res, data);
2649
+ }
2650
+ catch {
2651
+ this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2652
+ }
2653
+ }
2654
+ handleSharingNotificationsRead(req, res) {
2655
+ const hub = this.resolveHubConnection();
2656
+ if (!hub)
2657
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
2658
+ this.readBody(req, async (raw) => {
2659
+ try {
2660
+ const body = JSON.parse(raw || "{}");
2661
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2662
+ this.jsonResponse(res, { ok: true });
2663
+ try {
2664
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2665
+ const count = data?.unreadCount ?? 0;
2666
+ this.lastKnownNotifCount = count;
2667
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2668
+ }
2669
+ catch { /* best effort */ }
2670
+ }
2671
+ catch (err) {
2672
+ this.jsonResponse(res, { ok: false, error: String(err) });
2673
+ }
2674
+ });
2675
+ }
2676
+ handleSharingNotificationsClear(req, res) {
2677
+ const hub = this.resolveHubConnection();
2678
+ if (!hub)
2679
+ return this.jsonResponse(res, { ok: false, error: "not_configured" });
2680
+ this.readBody(req, async () => {
2681
+ try {
2682
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2683
+ this.jsonResponse(res, { ok: true });
2684
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2685
+ }
2686
+ catch (err) {
2687
+ this.jsonResponse(res, { ok: false, error: String(err) });
2688
+ }
2689
+ });
2690
+ }
2691
+ /** Badge-only: clear Client team-share UI metadata when Hub admin removes that memory. Does NOT touch chunks, embeddings, or hub_memories (recall paths). */
2692
+ handleSyncHubRemoval(req, res) {
2693
+ this.readBody(req, (body) => {
2694
+ try {
2695
+ const parsed = JSON.parse(body || "{}");
2696
+ const sourceChunkId = String(parsed.sourceChunkId || "");
2697
+ if (!sourceChunkId)
2698
+ return this.jsonResponse(res, { ok: false, error: "missing_source_chunk_id" }, 400);
2699
+ this.store.deleteTeamSharedChunk(sourceChunkId);
2700
+ this.jsonResponse(res, { ok: true, sourceChunkId });
2701
+ }
2702
+ catch (e) {
2703
+ this.jsonResponse(res, { ok: false, error: String(e) }, 500);
2704
+ }
2705
+ });
2706
+ }
2707
+ handleNotifSSE(req, res) {
2708
+ res.writeHead(200, {
2709
+ "Content-Type": "text/event-stream",
2710
+ "Cache-Control": "no-cache",
2711
+ Connection: "keep-alive",
2712
+ "Access-Control-Allow-Origin": "*",
2713
+ });
2714
+ res.write("data: {\"type\":\"connected\"}\n\n");
2715
+ this.notifSSEClients.push(res);
2716
+ if (!this.notifPollTimer)
2717
+ this.startNotifPoll();
2718
+ else
2719
+ this.notifPollImmediate();
2720
+ req.on("close", () => {
2721
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2722
+ if (this.notifSSEClients.length === 0)
2723
+ this.stopNotifPoll();
2724
+ });
2725
+ }
2726
+ broadcastNotifSSE(data) {
2727
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2728
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2729
+ try {
2730
+ c.write(msg);
2731
+ return true;
2732
+ }
2733
+ catch {
2734
+ return false;
2735
+ }
2736
+ });
2737
+ }
2738
+ startNotifPoll() {
2739
+ this.stopNotifPoll();
2740
+ const tick = async () => {
2741
+ const hub = this.resolveHubConnection();
2742
+ if (!hub)
2743
+ return;
2744
+ try {
2745
+ const data = (await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1"));
2746
+ const count = data?.unreadCount ?? 0;
2747
+ if (count !== this.lastKnownNotifCount) {
2748
+ this.lastKnownNotifCount = count;
2749
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2750
+ }
2751
+ }
2752
+ catch { /* ignore */ }
2753
+ };
2754
+ tick();
2755
+ this.notifPollTimer = setInterval(tick, 3000);
2756
+ }
2757
+ stopNotifPoll() {
2758
+ if (this.notifPollTimer) {
2759
+ clearInterval(this.notifPollTimer);
2760
+ this.notifPollTimer = undefined;
2761
+ }
2762
+ }
2763
+ notifPollImmediate() {
2764
+ const hub = this.resolveHubConnection();
2765
+ if (!hub)
2766
+ return;
2767
+ (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")
2768
+ .then((data) => {
2769
+ const count = data?.unreadCount ?? 0;
2770
+ if (count !== this.lastKnownNotifCount) {
2771
+ this.lastKnownNotifCount = count;
2772
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2773
+ }
2774
+ })
2775
+ .catch(() => { });
2776
+ }
2777
+ startHubHeartbeat() {
2778
+ this.stopHubHeartbeat();
2779
+ const sendHeartbeat = async () => {
2780
+ try {
2781
+ const hub = this.resolveHubConnection();
2782
+ if (!hub) {
2783
+ const persisted = this.store.getClientHubConnection();
2784
+ if (persisted?.hubUrl && persisted?.userToken) {
2785
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2786
+ }
2787
+ return;
2788
+ }
2789
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2790
+ }
2791
+ catch { /* best-effort */ }
2792
+ };
2793
+ sendHeartbeat();
2794
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2795
+ }
2796
+ stopHubHeartbeat() {
2797
+ if (this.hubHeartbeatTimer) {
2798
+ clearInterval(this.hubHeartbeatTimer);
2799
+ this.hubHeartbeatTimer = undefined;
2800
+ }
2801
+ }
2068
2802
  getLocalIPs() {
2069
2803
  const nets = node_os_1.default.networkInterfaces();
2070
2804
  const ips = [];
@@ -2116,7 +2850,7 @@ class ViewerServer {
2116
2850
  }
2117
2851
  }
2118
2852
  handleSaveConfig(req, res) {
2119
- this.readBody(req, (body) => {
2853
+ this.readBody(req, async (body) => {
2120
2854
  try {
2121
2855
  const newCfg = JSON.parse(body);
2122
2856
  const cfgPath = this.getOpenClawConfigPath();
@@ -2141,6 +2875,10 @@ class ViewerServer {
2141
2875
  if (!entry.config)
2142
2876
  entry.config = {};
2143
2877
  const config = entry.config;
2878
+ const oldSharing = config.sharing;
2879
+ const oldSharingRole = oldSharing?.role;
2880
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2881
+ const oldClientHubAddress = String(oldSharing?.client?.hubAddress || "");
2144
2882
  if (newCfg.embedding)
2145
2883
  config.embedding = newCfg.embedding;
2146
2884
  if (newCfg.summarizer)
@@ -2160,12 +2898,14 @@ class ViewerServer {
2160
2898
  if (merged.role === "client" && merged.client) {
2161
2899
  const clientCfg = merged.client;
2162
2900
  const addr = String(clientCfg.hubAddress || "");
2163
- if (addr) {
2901
+ if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
2902
+ const selfHubPort = oldSharing?.hub?.port ?? 18800;
2164
2903
  const localIPs = this.getLocalIPs();
2165
2904
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2166
2905
  try {
2167
2906
  const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2168
- if (localIPs.includes(u.hostname)) {
2907
+ const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
2908
+ if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
2169
2909
  res.writeHead(400, { "Content-Type": "application/json" });
2170
2910
  res.end(JSON.stringify({ error: "cannot_join_self" }));
2171
2911
  return;
@@ -2174,18 +2914,70 @@ class ViewerServer {
2174
2914
  catch { }
2175
2915
  }
2176
2916
  }
2917
+ const newRole = merged.role;
2918
+ const newEnabled = Boolean(merged.enabled);
2919
+ // Detect disabling sharing or switching away from hub mode
2920
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2921
+ const isHub = newEnabled && newRole === "hub";
2922
+ if (wasHub && !isHub) {
2923
+ await this.notifyHubShutdown();
2924
+ this.stopHubHeartbeat();
2925
+ this.log.info("Hub shutting down: notified connected clients");
2926
+ }
2927
+ // Detect disabling sharing or switching away from client mode
2928
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2929
+ const isClient = newEnabled && newRole === "client";
2930
+ if (wasClient && !isClient) {
2931
+ await this.withdrawOrLeaveHub();
2932
+ this.store.clearClientHubConnection();
2933
+ this.log.info("Client hub connection cleared (sharing disabled or role changed)");
2934
+ }
2935
+ if (wasClient && isClient) {
2936
+ const newClientAddr = String(merged.client?.hubAddress || "");
2937
+ if (newClientAddr && oldClientHubAddress && (0, hub_1.normalizeHubUrl)(newClientAddr) !== (0, hub_1.normalizeHubUrl)(oldClientHubAddress)) {
2938
+ this.notifyHubLeave();
2939
+ const oldConn = this.store.getClientHubConnection();
2940
+ if (oldConn) {
2941
+ this.store.setClientHubConnection({ ...oldConn, hubUrl: (0, hub_1.normalizeHubUrl)(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
2942
+ }
2943
+ this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
2944
+ }
2945
+ }
2177
2946
  if (merged.role === "hub") {
2178
2947
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2179
2948
  }
2180
2949
  else if (merged.role === "client") {
2181
- merged.hub = { port: 18800, teamName: "", teamToken: "" };
2950
+ merged.hub = { teamName: "", teamToken: "" };
2182
2951
  }
2183
2952
  config.sharing = merged;
2184
2953
  }
2185
2954
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(cfgPath), { recursive: true });
2186
2955
  node_fs_1.default.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2187
2956
  this.log.info("Plugin config updated via Viewer");
2188
- this.jsonResponse(res, { ok: true });
2957
+ this.stopHubHeartbeat();
2958
+ // When switching to client mode or re-enabling sharing as client, send join request
2959
+ const finalSharing = config.sharing;
2960
+ const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
2961
+ const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
2962
+ let joinStatus;
2963
+ if (nowClient && !previouslyClient) {
2964
+ try {
2965
+ joinStatus = await this.autoJoinOnSave(finalSharing);
2966
+ }
2967
+ catch (e) {
2968
+ this.log.warn(`Auto-join on save failed: ${e}`);
2969
+ }
2970
+ }
2971
+ this.jsonResponse(res, { ok: true, joinStatus, restart: true });
2972
+ setTimeout(() => {
2973
+ this.log.info("config-save: triggering gateway restart via SIGUSR1...");
2974
+ try {
2975
+ process.kill(process.pid, "SIGUSR1");
2976
+ }
2977
+ catch (sig) {
2978
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
2979
+ }
2980
+ }, 500);
2189
2981
  }
2190
2982
  catch (e) {
2191
2983
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -2194,6 +2986,169 @@ class ViewerServer {
2194
2986
  }
2195
2987
  });
2196
2988
  }
2989
+ async autoJoinOnSave(sharing) {
2990
+ const clientCfg = sharing.client;
2991
+ const hubAddress = String(clientCfg?.hubAddress || "");
2992
+ const teamToken = String(clientCfg?.teamToken || "");
2993
+ if (!hubAddress || !teamToken)
2994
+ return undefined;
2995
+ const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
2996
+ const os = await Promise.resolve().then(() => __importStar(require("os")));
2997
+ const nickname = String(clientCfg?.nickname || "");
2998
+ const username = nickname || os.userInfo().username || "user";
2999
+ const hostname = os.hostname() || "unknown";
3000
+ const persisted = this.store.getClientHubConnection();
3001
+ const existingIdentityKey = persisted?.identityKey || "";
3002
+ const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
3003
+ method: "POST",
3004
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
3005
+ });
3006
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
3007
+ this.store.setClientHubConnection({
3008
+ hubUrl,
3009
+ userId: String(result.userId || ""),
3010
+ username,
3011
+ userToken: result.userToken || "",
3012
+ role: "member",
3013
+ connectedAt: Date.now(),
3014
+ identityKey: returnedIdentityKey,
3015
+ lastKnownStatus: result.status || "",
3016
+ });
3017
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
3018
+ if (result.userToken) {
3019
+ this.startHubHeartbeat();
3020
+ }
3021
+ return result.status;
3022
+ }
3023
+ handleLeaveTeam(_req, res) {
3024
+ this.readBody(_req, async () => {
3025
+ try {
3026
+ await this.withdrawOrLeaveHub();
3027
+ this.store.clearClientHubConnection();
3028
+ const configPath = this.getOpenClawConfigPath();
3029
+ if (configPath && node_fs_1.default.existsSync(configPath)) {
3030
+ const raw = JSON.parse(node_fs_1.default.readFileSync(configPath, "utf8"));
3031
+ const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
3032
+ if (pluginKey) {
3033
+ const cfg = raw.plugins.entries[pluginKey].config ?? {};
3034
+ if (cfg.sharing) {
3035
+ cfg.sharing.enabled = false;
3036
+ cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
3037
+ }
3038
+ raw.plugins.entries[pluginKey].config = cfg;
3039
+ node_fs_1.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
3040
+ this.log.info("handleLeaveTeam: config updated, sharing disabled");
3041
+ }
3042
+ }
3043
+ this.jsonResponse(res, { ok: true, restart: true });
3044
+ setTimeout(() => {
3045
+ this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
3046
+ try {
3047
+ process.kill(process.pid, "SIGUSR1");
3048
+ }
3049
+ catch (sig) {
3050
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
3051
+ }
3052
+ }, 500);
3053
+ }
3054
+ catch (e) {
3055
+ this.log.warn(`handleLeaveTeam error: ${e}`);
3056
+ this.jsonResponse(res, { ok: false, error: String(e) });
3057
+ }
3058
+ });
3059
+ }
3060
+ async notifyHubLeave() {
3061
+ try {
3062
+ const hub = this.resolveHubConnection();
3063
+ if (hub) {
3064
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
3065
+ this.log.info("Notified Hub of voluntary leave");
3066
+ return;
3067
+ }
3068
+ const persisted = this.store.getClientHubConnection();
3069
+ if (persisted?.hubUrl && persisted?.userToken) {
3070
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
3071
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
3072
+ }
3073
+ }
3074
+ catch (e) {
3075
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
3076
+ }
3077
+ }
3078
+ async withdrawOrLeaveHub() {
3079
+ try {
3080
+ const persisted = this.store.getClientHubConnection();
3081
+ const sharing = this.ctx?.config?.sharing;
3082
+ if (persisted?.userToken && persisted?.hubUrl) {
3083
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
3084
+ this.log.info("Notified Hub of voluntary leave (had token)");
3085
+ return;
3086
+ }
3087
+ const hub = this.resolveHubConnection();
3088
+ if (hub?.userToken) {
3089
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
3090
+ this.log.info("Notified Hub of voluntary leave (resolved connection)");
3091
+ return;
3092
+ }
3093
+ const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? (0, hub_1.normalizeHubUrl)(sharing.client.hubAddress) : null);
3094
+ const userId = persisted?.userId;
3095
+ const teamToken = sharing?.client?.teamToken;
3096
+ if (hubUrl && userId && teamToken) {
3097
+ const withdrawUrl = `${(0, hub_1.normalizeHubUrl)(hubUrl)}/api/v1/hub/withdraw-pending`;
3098
+ await fetch(withdrawUrl, {
3099
+ method: "POST",
3100
+ headers: { "content-type": "application/json" },
3101
+ body: JSON.stringify({ teamToken, userId }),
3102
+ });
3103
+ this.log.info("Withdrew pending application from Hub");
3104
+ return;
3105
+ }
3106
+ this.log.info("No hub connection to clean up (no token, no pending)");
3107
+ }
3108
+ catch (e) {
3109
+ this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
3110
+ }
3111
+ }
3112
+ async notifyHubShutdown() {
3113
+ try {
3114
+ const sharing = this.ctx?.config.sharing;
3115
+ if (!sharing || sharing.role !== "hub")
3116
+ return;
3117
+ const hubPort = this.getHubPort();
3118
+ const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
3119
+ let adminToken;
3120
+ try {
3121
+ const authData = JSON.parse(node_fs_1.default.readFileSync(authPath, "utf8"));
3122
+ adminToken = authData?.bootstrapAdminToken;
3123
+ }
3124
+ catch {
3125
+ return;
3126
+ }
3127
+ if (!adminToken)
3128
+ return;
3129
+ const users = this.store.listHubUsers("active");
3130
+ const { v4: uuidv4 } = require("uuid");
3131
+ for (const u of users) {
3132
+ try {
3133
+ this.store.insertHubNotification({
3134
+ id: uuidv4(),
3135
+ userId: u.id,
3136
+ type: "hub_shutdown",
3137
+ resource: "hub",
3138
+ title: "Hub is shutting down",
3139
+ message: "The Hub server is shutting down. You may be disconnected.",
3140
+ });
3141
+ }
3142
+ catch (e) {
3143
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
3144
+ }
3145
+ }
3146
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
3147
+ }
3148
+ catch (e) {
3149
+ this.log.warn(`notifyHubShutdown error: ${e}`);
3150
+ }
3151
+ }
2197
3152
  handleUpdateUsername(req, res) {
2198
3153
  this.readBody(req, async (body) => {
2199
3154
  if (!this.ctx)
@@ -2224,10 +3179,10 @@ class ViewerServer {
2224
3179
  }
2225
3180
  }
2226
3181
  else {
2227
- const persisted = this.store.getClientHubConnection();
2228
- if (persisted) {
3182
+ const persistedConn = this.store.getClientHubConnection();
3183
+ if (persistedConn) {
2229
3184
  this.store.setClientHubConnection({
2230
- ...persisted,
3185
+ ...persistedConn,
2231
3186
  username: result.username,
2232
3187
  userToken: result.userToken,
2233
3188
  });
@@ -2254,12 +3209,17 @@ class ViewerServer {
2254
3209
  return;
2255
3210
  }
2256
3211
  try {
2257
- const localIPs = this.getLocalIPs();
2258
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2259
- const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2260
- if (localIPs.includes(parsed.hostname)) {
2261
- this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2262
- return;
3212
+ const sharing = this.ctx?.config?.sharing;
3213
+ if (sharing?.enabled && sharing.role === "hub") {
3214
+ const selfHubPort = this.getHubPort();
3215
+ const localIPs = this.getLocalIPs();
3216
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
3217
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
3218
+ const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
3219
+ if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
3220
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
3221
+ return;
3222
+ }
2263
3223
  }
2264
3224
  }
2265
3225
  catch { }
@@ -2517,10 +3477,14 @@ class ViewerServer {
2517
3477
  catch { }
2518
3478
  this.log.info(`update-install: success! Updated to ${newVersion}`);
2519
3479
  this.jsonResponse(res, { ok: true, version: newVersion });
2520
- // Trigger Gateway restart after response is sent
2521
3480
  setTimeout(() => {
2522
- this.log.info(`update-install: triggering gateway restart...`);
2523
- process.kill(process.pid, "SIGUSR1");
3481
+ this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
3482
+ try {
3483
+ process.kill(process.pid, "SIGUSR1");
3484
+ }
3485
+ catch (sig) {
3486
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
3487
+ }
2524
3488
  }, 500);
2525
3489
  });
2526
3490
  });
@@ -2668,7 +3632,7 @@ class ViewerServer {
2668
3632
  // ─── Migration: scan OpenClaw built-in memory ───
2669
3633
  getOpenClawHome() {
2670
3634
  const home = process.env.HOME || process.env.USERPROFILE || "";
2671
- return node_path_1.default.join(home, ".openclaw");
3635
+ return process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
2672
3636
  }
2673
3637
  handleCleanupPolluted(res) {
2674
3638
  try {
@@ -2696,7 +3660,7 @@ class ViewerServer {
2696
3660
  try {
2697
3661
  const ocHome = this.getOpenClawHome();
2698
3662
  const memoryDir = node_path_1.default.join(ocHome, "memory");
2699
- const sessionsDir = node_path_1.default.join(ocHome, "agents", "main", "sessions");
3663
+ const agentsDir = node_path_1.default.join(ocHome, "agents");
2700
3664
  const sqliteFiles = [];
2701
3665
  if (node_fs_1.default.existsSync(memoryDir)) {
2702
3666
  for (const f of node_fs_1.default.readdirSync(memoryDir)) {
@@ -2714,38 +3678,45 @@ class ViewerServer {
2714
3678
  }
2715
3679
  let sessionCount = 0;
2716
3680
  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++;
3681
+ if (node_fs_1.default.existsSync(agentsDir)) {
3682
+ for (const entry of node_fs_1.default.readdirSync(agentsDir, { withFileTypes: true })) {
3683
+ if (!entry.isDirectory())
3684
+ continue;
3685
+ const sessDir = node_path_1.default.join(agentsDir, entry.name, "sessions");
3686
+ if (!node_fs_1.default.existsSync(sessDir))
3687
+ continue;
3688
+ const jsonlFiles = node_fs_1.default.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3689
+ sessionCount += jsonlFiles.length;
3690
+ for (const f of jsonlFiles) {
3691
+ try {
3692
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessDir, f), "utf-8");
3693
+ const lines = content.split("\n").filter(l => l.trim());
3694
+ for (const line of lines) {
3695
+ try {
3696
+ const obj = JSON.parse(line);
3697
+ if (obj.type === "message") {
3698
+ const role = obj.message?.role ?? obj.role;
3699
+ if (role === "user" || role === "assistant") {
3700
+ const mc = obj.message?.content ?? obj.content;
3701
+ let txt = "";
3702
+ if (typeof mc === "string")
3703
+ txt = mc;
3704
+ else if (Array.isArray(mc))
3705
+ txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
3706
+ else
3707
+ txt = JSON.stringify(mc);
3708
+ if (role === "user")
3709
+ txt = (0, capture_1.stripInboundMetadata)(txt);
3710
+ if (txt && txt.length >= 10)
3711
+ messageCount++;
3712
+ }
2742
3713
  }
2743
3714
  }
3715
+ catch { /* skip bad lines */ }
2744
3716
  }
2745
- catch { /* skip bad lines */ }
2746
3717
  }
3718
+ catch { /* skip unreadable */ }
2747
3719
  }
2748
- catch { /* skip unreadable */ }
2749
3720
  }
2750
3721
  }
2751
3722
  const cfgPath = this.getOpenClawConfigPath();