@memtensor/memos-local-openclaw-plugin 1.0.4-beta.9 → 1.0.5

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 (100) hide show
  1. package/.env.example +7 -0
  2. package/README.md +94 -27
  3. package/dist/capture/index.js +3 -1
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts +5 -0
  6. package/dist/client/connector.d.ts.map +1 -1
  7. package/dist/client/connector.js +89 -8
  8. package/dist/client/connector.js.map +1 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +2 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/hub/server.d.ts +2 -0
  13. package/dist/hub/server.d.ts.map +1 -1
  14. package/dist/hub/server.js +240 -35
  15. package/dist/hub/server.js.map +1 -1
  16. package/dist/hub/user-manager.d.ts +9 -0
  17. package/dist/hub/user-manager.d.ts.map +1 -1
  18. package/dist/hub/user-manager.js +26 -2
  19. package/dist/hub/user-manager.js.map +1 -1
  20. package/dist/ingest/chunker.d.ts +2 -1
  21. package/dist/ingest/chunker.d.ts.map +1 -1
  22. package/dist/ingest/chunker.js +14 -10
  23. package/dist/ingest/chunker.js.map +1 -1
  24. package/dist/ingest/providers/index.js +2 -2
  25. package/dist/ingest/providers/index.js.map +1 -1
  26. package/dist/recall/engine.d.ts.map +1 -1
  27. package/dist/recall/engine.js +22 -4
  28. package/dist/recall/engine.js.map +1 -1
  29. package/dist/shared/llm-call.d.ts.map +1 -1
  30. package/dist/shared/llm-call.js +2 -1
  31. package/dist/shared/llm-call.js.map +1 -1
  32. package/dist/sharing/types.d.ts +1 -1
  33. package/dist/sharing/types.d.ts.map +1 -1
  34. package/dist/skill/evolver.d.ts +2 -0
  35. package/dist/skill/evolver.d.ts.map +1 -1
  36. package/dist/skill/evolver.js +56 -5
  37. package/dist/skill/evolver.js.map +1 -1
  38. package/dist/skill/generator.d.ts +2 -0
  39. package/dist/skill/generator.d.ts.map +1 -1
  40. package/dist/skill/generator.js +45 -3
  41. package/dist/skill/generator.js.map +1 -1
  42. package/dist/skill/installer.d.ts +26 -0
  43. package/dist/skill/installer.d.ts.map +1 -1
  44. package/dist/skill/installer.js +80 -4
  45. package/dist/skill/installer.js.map +1 -1
  46. package/dist/skill/upgrader.d.ts +2 -0
  47. package/dist/skill/upgrader.d.ts.map +1 -1
  48. package/dist/skill/upgrader.js +139 -1
  49. package/dist/skill/upgrader.js.map +1 -1
  50. package/dist/skill/validator.d.ts +3 -0
  51. package/dist/skill/validator.d.ts.map +1 -1
  52. package/dist/skill/validator.js +75 -0
  53. package/dist/skill/validator.js.map +1 -1
  54. package/dist/storage/sqlite.d.ts +57 -0
  55. package/dist/storage/sqlite.d.ts.map +1 -1
  56. package/dist/storage/sqlite.js +290 -35
  57. package/dist/storage/sqlite.js.map +1 -1
  58. package/dist/telemetry.d.ts +4 -1
  59. package/dist/telemetry.d.ts.map +1 -1
  60. package/dist/telemetry.js +39 -12
  61. package/dist/telemetry.js.map +1 -1
  62. package/dist/types.d.ts +10 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js +4 -0
  65. package/dist/types.js.map +1 -1
  66. package/dist/viewer/html.d.ts.map +1 -1
  67. package/dist/viewer/html.js +564 -225
  68. package/dist/viewer/html.js.map +1 -1
  69. package/dist/viewer/server.d.ts +9 -0
  70. package/dist/viewer/server.d.ts.map +1 -1
  71. package/dist/viewer/server.js +357 -108
  72. package/dist/viewer/server.js.map +1 -1
  73. package/index.ts +412 -53
  74. package/openclaw.plugin.json +1 -1
  75. package/package.json +2 -1
  76. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  77. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  78. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  79. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  80. package/src/capture/index.ts +4 -1
  81. package/src/client/connector.ts +92 -8
  82. package/src/config.ts +2 -1
  83. package/src/hub/server.ts +235 -35
  84. package/src/hub/user-manager.ts +42 -6
  85. package/src/ingest/chunker.ts +19 -13
  86. package/src/ingest/providers/index.ts +2 -2
  87. package/src/recall/engine.ts +20 -4
  88. package/src/shared/llm-call.ts +2 -1
  89. package/src/sharing/types.ts +1 -1
  90. package/src/skill/evolver.ts +58 -6
  91. package/src/skill/generator.ts +44 -5
  92. package/src/skill/installer.ts +107 -4
  93. package/src/skill/upgrader.ts +139 -1
  94. package/src/skill/validator.ts +79 -0
  95. package/src/storage/sqlite.ts +318 -40
  96. package/src/telemetry.ts +39 -14
  97. package/src/types.ts +11 -0
  98. package/src/viewer/html.ts +564 -225
  99. package/src/viewer/server.ts +333 -105
  100. package/telemetry.credentials.json +5 -0
@@ -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 {
@@ -105,16 +107,31 @@ class ViewerServer {
105
107
  this.ctx = opts.ctx;
106
108
  this.authFile = node_path_1.default.join(opts.dataDir, "viewer-auth.json");
107
109
  this.auth = { passwordHash: null, sessions: new Map() };
110
+ this.cookieName = `memos_token_${opts.port}`;
111
+ this.defaultHubPort = opts.defaultHubPort ?? 18800;
108
112
  this.resetToken = node_crypto_1.default.randomBytes(16).toString("hex");
109
113
  this.loadAuth();
110
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
+ }
111
121
  start() {
122
+ const MAX_PORT_RETRIES = 5;
112
123
  return new Promise((resolve, reject) => {
124
+ let retries = 0;
113
125
  this.server = node_http_1.default.createServer((req, res) => this.handleRequest(req, res));
114
126
  this.server.on("error", (err) => {
115
- if (err.code === "EADDRINUSE") {
116
- this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
117
- this.server.listen(this.port + 1, "0.0.0.0");
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})`));
118
135
  }
119
136
  else {
120
137
  reject(err);
@@ -193,7 +210,8 @@ class ViewerServer {
193
210
  }
194
211
  isValidSession(req) {
195
212
  const cookie = req.headers.cookie ?? "";
196
- 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);
197
215
  if (!match)
198
216
  return false;
199
217
  const expiry = this.auth.sessions.get(match[1]);
@@ -313,6 +331,8 @@ class ViewerServer {
313
331
  this.handleSharingChangeRole(req, res);
314
332
  else if (p === "/api/sharing/retry-join" && req.method === "POST")
315
333
  this.handleRetryJoin(req, res);
334
+ else if (p === "/api/sharing/leave" && req.method === "POST")
335
+ this.handleLeaveTeam(req, res);
316
336
  else if (p === "/api/sharing/search/memories" && req.method === "POST")
317
337
  this.handleSharingMemorySearch(req, res);
318
338
  else if (p === "/api/sharing/memories/list" && req.method === "GET")
@@ -353,6 +373,8 @@ class ViewerServer {
353
373
  this.handleSharingNotificationsRead(req, res);
354
374
  else if (p === "/api/sharing/notifications/clear" && req.method === "POST")
355
375
  this.handleSharingNotificationsClear(req, res);
376
+ else if (p === "/api/sharing/sync-hub-removal" && req.method === "POST")
377
+ this.handleSyncHubRemoval(req, res);
356
378
  else if (p === "/api/notifications/stream" && req.method === "GET")
357
379
  this.handleNotifSSE(req, res);
358
380
  else if (p === "/api/admin/shared-tasks" && req.method === "GET")
@@ -440,7 +462,7 @@ class ViewerServer {
440
462
  const token = this.createSession();
441
463
  res.writeHead(200, {
442
464
  "Content-Type": "application/json",
443
- "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`,
444
466
  });
445
467
  res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
446
468
  }
@@ -462,7 +484,7 @@ class ViewerServer {
462
484
  const token = this.createSession();
463
485
  res.writeHead(200, {
464
486
  "Content-Type": "application/json",
465
- "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`,
466
488
  });
467
489
  res.end(JSON.stringify({ ok: true }));
468
490
  }
@@ -474,12 +496,13 @@ class ViewerServer {
474
496
  }
475
497
  handleLogout(req, res) {
476
498
  const cookie = req.headers.cookie ?? "";
477
- 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);
478
501
  if (match)
479
502
  this.auth.sessions.delete(match[1]);
480
503
  res.writeHead(200, {
481
504
  "Content-Type": "application/json",
482
- "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
505
+ "Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
483
506
  });
484
507
  res.end(JSON.stringify({ ok: true }));
485
508
  }
@@ -505,7 +528,7 @@ class ViewerServer {
505
528
  const sessionToken = this.createSession();
506
529
  res.writeHead(200, {
507
530
  "Content-Type": "application/json",
508
- "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`,
509
532
  });
510
533
  res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
511
534
  }
@@ -543,9 +566,8 @@ class ViewerServer {
543
566
  params.push(role);
544
567
  }
545
568
  if (owner && owner.startsWith("agent:")) {
546
- const agentPrefix = owner + ":";
547
- conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
548
- params.push(owner, agentPrefix + "%");
569
+ conditions.push("(owner = ? OR owner = 'public')");
570
+ params.push(owner);
549
571
  }
550
572
  else if (owner) {
551
573
  conditions.push("owner = ?");
@@ -561,7 +583,7 @@ class ViewerServer {
561
583
  }
562
584
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
563
585
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params);
564
- 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);
565
587
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
566
588
  const chunkIds = rawMemories.map((m) => m.id);
567
589
  const sharingMap = new Map();
@@ -572,6 +594,12 @@ class ViewerServer {
572
594
  const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds);
573
595
  for (const r of sharedRows)
574
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
+ }
575
603
  const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds);
576
604
  for (const r of localRows)
577
605
  localShareMap.set(r.chunk_id, r);
@@ -728,9 +756,8 @@ class ViewerServer {
728
756
  let sessionQuery;
729
757
  let sessionParams;
730
758
  if (ownerFilter && ownerFilter.startsWith("agent:")) {
731
- const agentPrefix = ownerFilter + ":";
732
- sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR (owner = 'public' AND session_key LIKE ?)) GROUP BY session_key ORDER BY latest DESC";
733
- sessionParams = [ownerFilter, agentPrefix + "%"];
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];
734
761
  }
735
762
  else if (ownerFilter) {
736
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";
@@ -1162,7 +1189,7 @@ class ViewerServer {
1162
1189
  }
1163
1190
  });
1164
1191
  }
1165
- handleSkillDelete(res, urlPath) {
1192
+ async handleSkillDelete(res, urlPath) {
1166
1193
  const skillId = urlPath.replace("/api/skill/", "");
1167
1194
  const skill = this.store.getSkill(skillId);
1168
1195
  if (!skill) {
@@ -1170,7 +1197,18 @@ class ViewerServer {
1170
1197
  res.end(JSON.stringify({ error: "Skill not found" }));
1171
1198
  return;
1172
1199
  }
1173
- // 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 (_) { }
1174
1212
  try {
1175
1213
  if (skill.dirPath && node_fs_1.default.existsSync(skill.dirPath)) {
1176
1214
  node_fs_1.default.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1339,8 +1377,6 @@ class ViewerServer {
1339
1377
  }
1340
1378
  let hubSynced = false;
1341
1379
  if (scope === "team") {
1342
- if (!isLocalShared)
1343
- this.store.markMemorySharedLocally(chunkId);
1344
1380
  if (!isTeamShared) {
1345
1381
  const hubClient = await this.resolveHubClientAware();
1346
1382
  const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId);
@@ -1348,18 +1384,29 @@ class ViewerServer {
1348
1384
  method: "POST",
1349
1385
  body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1350
1386
  });
1351
- if (hubClient.userId) {
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) {
1352
1392
  const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1353
1393
  this.store.upsertHubMemory({
1354
- id: response?.memoryId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
1394
+ id: memoryId || existing?.id || node_crypto_1.default.randomUUID(),
1355
1395
  sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1356
1396
  role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1357
1397
  kind: refreshedChunk.kind, groupId: null, visibility: "public",
1358
1398
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1359
1399
  });
1360
1400
  }
1401
+ else if (hubClient.userId) {
1402
+ this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
1403
+ }
1361
1404
  hubSynced = true;
1362
1405
  }
1406
+ else {
1407
+ if (!isLocalShared)
1408
+ this.store.markMemorySharedLocally(chunkId);
1409
+ }
1363
1410
  }
1364
1411
  else if (scope === "local") {
1365
1412
  if (isTeamShared) {
@@ -1370,6 +1417,7 @@ class ViewerServer {
1370
1417
  });
1371
1418
  if (hubClient.userId)
1372
1419
  this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1420
+ this.store.deleteTeamSharedChunk(chunkId);
1373
1421
  hubSynced = true;
1374
1422
  }
1375
1423
  catch (err) {
@@ -1388,6 +1436,7 @@ class ViewerServer {
1388
1436
  });
1389
1437
  if (hubClient.userId)
1390
1438
  this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1439
+ this.store.deleteTeamSharedChunk(chunkId);
1391
1440
  hubSynced = true;
1392
1441
  }
1393
1442
  catch (err) {
@@ -1427,14 +1476,6 @@ class ViewerServer {
1427
1476
  return this.jsonResponse(res, { ok: true, scope, changed: false });
1428
1477
  }
1429
1478
  let hubSynced = false;
1430
- if (scope === "local" || scope === "team") {
1431
- if (!isLocalShared) {
1432
- const originalOwner = task.owner;
1433
- const db = this.store.db;
1434
- 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());
1435
- db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1436
- }
1437
- }
1438
1479
  if (scope === "team") {
1439
1480
  if (!isTeamShared) {
1440
1481
  const chunks = this.store.getChunksByTask(taskId);
@@ -1458,6 +1499,20 @@ class ViewerServer {
1458
1499
  }
1459
1500
  hubSynced = true;
1460
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
+ }
1461
1516
  }
1462
1517
  if (scope === "local" && isTeamShared) {
1463
1518
  try {
@@ -1528,10 +1583,6 @@ class ViewerServer {
1528
1583
  return this.jsonResponse(res, { ok: true, scope, changed: false });
1529
1584
  }
1530
1585
  let hubSynced = false;
1531
- if (scope === "local" || scope === "team") {
1532
- if (!isLocalShared)
1533
- this.store.setSkillVisibility(skillId, "public");
1534
- }
1535
1586
  if (scope === "team") {
1536
1587
  if (!isTeamShared) {
1537
1588
  const bundle = (0, skill_sync_1.buildSkillBundleForHub)(this.store, skillId);
@@ -1553,6 +1604,12 @@ class ViewerServer {
1553
1604
  }
1554
1605
  hubSynced = true;
1555
1606
  }
1607
+ if (!isLocalShared)
1608
+ this.store.setSkillVisibility(skillId, "public");
1609
+ }
1610
+ if (scope === "local") {
1611
+ if (!isLocalShared)
1612
+ this.store.setSkillVisibility(skillId, "public");
1556
1613
  }
1557
1614
  if (scope === "local" && isTeamShared) {
1558
1615
  try {
@@ -1595,7 +1652,18 @@ class ViewerServer {
1595
1652
  }
1596
1653
  getHubMemoryForChunk(chunkId) {
1597
1654
  const db = this.store.db;
1598
- return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
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;
1599
1667
  }
1600
1668
  getHubTaskForLocal(taskId) {
1601
1669
  const db = this.store.db;
@@ -1641,6 +1709,8 @@ class ViewerServer {
1641
1709
  // ─── Helpers ───
1642
1710
  // ─── Config API ───
1643
1711
  getOpenClawConfigPath() {
1712
+ if (process.env.OPENCLAW_CONFIG_PATH)
1713
+ return process.env.OPENCLAW_CONFIG_PATH;
1644
1714
  const home = process.env.HOME || process.env.USERPROFILE || "";
1645
1715
  const ocHome = process.env.OPENCLAW_STATE_DIR || node_path_1.default.join(home, ".openclaw");
1646
1716
  return node_path_1.default.join(ocHome, "openclaw.json");
@@ -1723,7 +1793,18 @@ class ViewerServer {
1723
1793
  base.connection.apiVersion = info?.apiVersion ?? null;
1724
1794
  }
1725
1795
  catch { /* ignore */ }
1726
- 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 });
1727
1808
  return;
1728
1809
  }
1729
1810
  const hasPendingConnection = Boolean(persisted?.hubUrl && persisted?.userId && !persisted?.userToken);
@@ -1740,6 +1821,9 @@ class ViewerServer {
1740
1821
  if (status.user?.status === "rejected") {
1741
1822
  output.connection.rejected = true;
1742
1823
  }
1824
+ if (status.user?.status === "removed") {
1825
+ output.connection.removed = true;
1826
+ }
1743
1827
  if (status.connected && status.hubUrl) {
1744
1828
  try {
1745
1829
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
@@ -1873,7 +1957,16 @@ class ViewerServer {
1873
1957
  this.jsonResponse(res, { ok: true, result });
1874
1958
  }
1875
1959
  catch (err) {
1876
- this.jsonResponse(res, { ok: false, error: String(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
+ }
1877
1970
  }
1878
1971
  });
1879
1972
  }
@@ -1892,23 +1985,17 @@ class ViewerServer {
1892
1985
  }
1893
1986
  try {
1894
1987
  const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
1895
- const localIPs = this.getLocalIPs();
1896
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1897
- try {
1898
- const u = new URL(hubUrl);
1899
- if (localIPs.includes(u.hostname)) {
1900
- return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1901
- }
1902
- }
1903
- catch { }
1904
1988
  const os = await Promise.resolve().then(() => __importStar(require("os")));
1905
1989
  const nickname = sharing.client?.nickname;
1906
1990
  const username = nickname || os.userInfo().username || "user";
1907
1991
  const hostname = os.hostname() || "unknown";
1992
+ const persisted = this.store.getClientHubConnection();
1993
+ const existingIdentityKey = persisted?.identityKey || "";
1908
1994
  const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
1909
1995
  method: "POST",
1910
- body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
1996
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
1911
1997
  });
1998
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
1912
1999
  this.store.setClientHubConnection({
1913
2000
  hubUrl,
1914
2001
  userId: String(result.userId || ""),
@@ -1916,6 +2003,8 @@ class ViewerServer {
1916
2003
  userToken: result.userToken || "",
1917
2004
  role: "member",
1918
2005
  connectedAt: Date.now(),
2006
+ identityKey: returnedIdentityKey,
2007
+ lastKnownStatus: result.status || "",
1919
2008
  });
1920
2009
  this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1921
2010
  }
@@ -2211,14 +2300,14 @@ class ViewerServer {
2211
2300
  },
2212
2301
  }),
2213
2302
  });
2214
- const hubUserId = hubClient.userId;
2215
- if (hubUserId) {
2303
+ const mid = String(response?.memoryId ?? "");
2304
+ if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
2216
2305
  const now = Date.now();
2217
- const existing = this.store.getHubMemoryBySource(hubUserId, chunk.id);
2306
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
2218
2307
  this.store.upsertHubMemory({
2219
- id: response?.memoryId ?? existing?.id ?? node_crypto_1.default.randomUUID(),
2308
+ id: mid || existing?.id || node_crypto_1.default.randomUUID(),
2220
2309
  sourceChunkId: chunk.id,
2221
- sourceUserId: hubUserId,
2310
+ sourceUserId: hubClient.userId,
2222
2311
  role: chunk.role,
2223
2312
  content: chunk.content,
2224
2313
  summary: chunk.summary ?? "",
@@ -2229,6 +2318,9 @@ class ViewerServer {
2229
2318
  updatedAt: now,
2230
2319
  });
2231
2320
  }
2321
+ else if (hubClient.userId) {
2322
+ this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
2323
+ }
2232
2324
  this.jsonResponse(res, { ok: true, chunkId, visibility, response });
2233
2325
  }
2234
2326
  catch (err) {
@@ -2251,6 +2343,7 @@ class ViewerServer {
2251
2343
  const hubUserId = hubClient.userId;
2252
2344
  if (hubUserId)
2253
2345
  this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2346
+ this.store.deleteTeamSharedChunk(chunkId);
2254
2347
  this.jsonResponse(res, { ok: true, chunkId });
2255
2348
  }
2256
2349
  catch (err) {
@@ -2353,7 +2446,7 @@ class ViewerServer {
2353
2446
  // Hub 模式:连接自己,用 bootstrap admin token
2354
2447
  const sharing = this.ctx.config.sharing;
2355
2448
  if (sharing?.role === "hub") {
2356
- const hubPort = sharing.hub?.port ?? 18800;
2449
+ const hubPort = this.getHubPort();
2357
2450
  const hubUrl = `http://127.0.0.1:${hubPort}`;
2358
2451
  try {
2359
2452
  const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
@@ -2595,6 +2688,32 @@ class ViewerServer {
2595
2688
  }
2596
2689
  });
2597
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
+ const memoryIdFromNotif = parsed.memoryId != null && parsed.memoryId !== "" ? String(parsed.memoryId) : "";
2698
+ if (!sourceChunkId)
2699
+ return this.jsonResponse(res, { ok: false, error: "missing_source_chunk_id" }, 400);
2700
+ // Admin removal notifications stay in the feed; if the user re-shared, team_shared_chunks has a new hub_memory_id.
2701
+ // Only clear the badge when this notification refers to the same Hub row we still track (or no id — legacy).
2702
+ if (memoryIdFromNotif) {
2703
+ const current = this.store.getTeamSharedChunk(sourceChunkId);
2704
+ const curId = current?.hubMemoryId ? String(current.hubMemoryId) : "";
2705
+ if (curId && curId !== memoryIdFromNotif) {
2706
+ return this.jsonResponse(res, { ok: true, sourceChunkId, skipped: true, reason: "stale_notification_re_shared" });
2707
+ }
2708
+ }
2709
+ this.store.deleteTeamSharedChunk(sourceChunkId);
2710
+ this.jsonResponse(res, { ok: true, sourceChunkId });
2711
+ }
2712
+ catch (e) {
2713
+ this.jsonResponse(res, { ok: false, error: String(e) }, 500);
2714
+ }
2715
+ });
2716
+ }
2598
2717
  handleNotifSSE(req, res) {
2599
2718
  res.writeHead(200, {
2600
2719
  "Content-Type": "text/event-stream",
@@ -2606,6 +2725,8 @@ class ViewerServer {
2606
2725
  this.notifSSEClients.push(res);
2607
2726
  if (!this.notifPollTimer)
2608
2727
  this.startNotifPoll();
2728
+ else
2729
+ this.notifPollImmediate();
2609
2730
  req.on("close", () => {
2610
2731
  this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2611
2732
  if (this.notifSSEClients.length === 0)
@@ -2649,6 +2770,20 @@ class ViewerServer {
2649
2770
  this.notifPollTimer = undefined;
2650
2771
  }
2651
2772
  }
2773
+ notifPollImmediate() {
2774
+ const hub = this.resolveHubConnection();
2775
+ if (!hub)
2776
+ return;
2777
+ (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")
2778
+ .then((data) => {
2779
+ const count = data?.unreadCount ?? 0;
2780
+ if (count !== this.lastKnownNotifCount) {
2781
+ this.lastKnownNotifCount = count;
2782
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2783
+ }
2784
+ })
2785
+ .catch(() => { });
2786
+ }
2652
2787
  startHubHeartbeat() {
2653
2788
  this.stopHubHeartbeat();
2654
2789
  const sendHeartbeat = async () => {
@@ -2773,12 +2908,14 @@ class ViewerServer {
2773
2908
  if (merged.role === "client" && merged.client) {
2774
2909
  const clientCfg = merged.client;
2775
2910
  const addr = String(clientCfg.hubAddress || "");
2776
- if (addr) {
2911
+ if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
2912
+ const selfHubPort = oldSharing?.hub?.port ?? 18800;
2777
2913
  const localIPs = this.getLocalIPs();
2778
2914
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2779
2915
  try {
2780
2916
  const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2781
- if (localIPs.includes(u.hostname)) {
2917
+ const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
2918
+ if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
2782
2919
  res.writeHead(400, { "Content-Type": "application/json" });
2783
2920
  res.end(JSON.stringify({ error: "cannot_join_self" }));
2784
2921
  return;
@@ -2801,24 +2938,26 @@ class ViewerServer {
2801
2938
  const wasClient = oldSharingEnabled && oldSharingRole === "client";
2802
2939
  const isClient = newEnabled && newRole === "client";
2803
2940
  if (wasClient && !isClient) {
2804
- this.notifyHubLeave();
2941
+ await this.withdrawOrLeaveHub();
2805
2942
  this.store.clearClientHubConnection();
2806
- this.log.info("Cleared client hub connection (sharing disabled or role changed)");
2943
+ this.log.info("Client hub connection cleared (sharing disabled or role changed)");
2807
2944
  }
2808
- // Detect switching to a different Hub while still in client mode
2809
2945
  if (wasClient && isClient) {
2810
2946
  const newClientAddr = String(merged.client?.hubAddress || "");
2811
2947
  if (newClientAddr && oldClientHubAddress && (0, hub_1.normalizeHubUrl)(newClientAddr) !== (0, hub_1.normalizeHubUrl)(oldClientHubAddress)) {
2812
2948
  this.notifyHubLeave();
2813
- this.store.clearClientHubConnection();
2814
- this.log.info("Cleared client hub connection (switched to different Hub)");
2949
+ const oldConn = this.store.getClientHubConnection();
2950
+ if (oldConn) {
2951
+ this.store.setClientHubConnection({ ...oldConn, hubUrl: (0, hub_1.normalizeHubUrl)(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
2952
+ }
2953
+ this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
2815
2954
  }
2816
2955
  }
2817
2956
  if (merged.role === "hub") {
2818
2957
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2819
2958
  }
2820
2959
  else if (merged.role === "client") {
2821
- merged.hub = { port: 18800, teamName: "", teamToken: "" };
2960
+ merged.hub = { teamName: "", teamToken: "" };
2822
2961
  }
2823
2962
  config.sharing = merged;
2824
2963
  }
@@ -2826,12 +2965,29 @@ class ViewerServer {
2826
2965
  node_fs_1.default.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
2827
2966
  this.log.info("Plugin config updated via Viewer");
2828
2967
  this.stopHubHeartbeat();
2829
- // When switching to client mode, immediately send join request
2968
+ // When switching to client mode or re-enabling sharing as client, send join request
2830
2969
  const finalSharing = config.sharing;
2831
- if (finalSharing?.role === "client" && oldSharingRole !== "client") {
2832
- this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2970
+ const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
2971
+ const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
2972
+ let joinStatus;
2973
+ if (nowClient && !previouslyClient) {
2974
+ try {
2975
+ joinStatus = await this.autoJoinOnSave(finalSharing);
2976
+ }
2977
+ catch (e) {
2978
+ this.log.warn(`Auto-join on save failed: ${e}`);
2979
+ }
2833
2980
  }
2834
- this.jsonResponse(res, { ok: true });
2981
+ this.jsonResponse(res, { ok: true, joinStatus, restart: true });
2982
+ setTimeout(() => {
2983
+ this.log.info("config-save: triggering gateway restart via SIGUSR1...");
2984
+ try {
2985
+ process.kill(process.pid, "SIGUSR1");
2986
+ }
2987
+ catch (sig) {
2988
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
2989
+ }
2990
+ }, 500);
2835
2991
  }
2836
2992
  catch (e) {
2837
2993
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -2845,16 +3001,19 @@ class ViewerServer {
2845
3001
  const hubAddress = String(clientCfg?.hubAddress || "");
2846
3002
  const teamToken = String(clientCfg?.teamToken || "");
2847
3003
  if (!hubAddress || !teamToken)
2848
- return;
3004
+ return undefined;
2849
3005
  const hubUrl = (0, hub_1.normalizeHubUrl)(hubAddress);
2850
3006
  const os = await Promise.resolve().then(() => __importStar(require("os")));
2851
3007
  const nickname = String(clientCfg?.nickname || "");
2852
3008
  const username = nickname || os.userInfo().username || "user";
2853
3009
  const hostname = os.hostname() || "unknown";
3010
+ const persisted = this.store.getClientHubConnection();
3011
+ const existingIdentityKey = persisted?.identityKey || "";
2854
3012
  const result = await (0, hub_1.hubRequestJson)(hubUrl, "", "/api/v1/hub/join", {
2855
3013
  method: "POST",
2856
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
3014
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
2857
3015
  });
3016
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
2858
3017
  this.store.setClientHubConnection({
2859
3018
  hubUrl,
2860
3019
  userId: String(result.userId || ""),
@@ -2862,11 +3021,51 @@ class ViewerServer {
2862
3021
  userToken: result.userToken || "",
2863
3022
  role: "member",
2864
3023
  connectedAt: Date.now(),
3024
+ identityKey: returnedIdentityKey,
3025
+ lastKnownStatus: result.status || "",
2865
3026
  });
2866
3027
  this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2867
3028
  if (result.userToken) {
2868
3029
  this.startHubHeartbeat();
2869
3030
  }
3031
+ return result.status;
3032
+ }
3033
+ handleLeaveTeam(_req, res) {
3034
+ this.readBody(_req, async () => {
3035
+ try {
3036
+ await this.withdrawOrLeaveHub();
3037
+ this.store.clearClientHubConnection();
3038
+ const configPath = this.getOpenClawConfigPath();
3039
+ if (configPath && node_fs_1.default.existsSync(configPath)) {
3040
+ const raw = JSON.parse(node_fs_1.default.readFileSync(configPath, "utf8"));
3041
+ const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
3042
+ if (pluginKey) {
3043
+ const cfg = raw.plugins.entries[pluginKey].config ?? {};
3044
+ if (cfg.sharing) {
3045
+ cfg.sharing.enabled = false;
3046
+ cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
3047
+ }
3048
+ raw.plugins.entries[pluginKey].config = cfg;
3049
+ node_fs_1.default.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
3050
+ this.log.info("handleLeaveTeam: config updated, sharing disabled");
3051
+ }
3052
+ }
3053
+ this.jsonResponse(res, { ok: true, restart: true });
3054
+ setTimeout(() => {
3055
+ this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
3056
+ try {
3057
+ process.kill(process.pid, "SIGUSR1");
3058
+ }
3059
+ catch (sig) {
3060
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
3061
+ }
3062
+ }, 500);
3063
+ }
3064
+ catch (e) {
3065
+ this.log.warn(`handleLeaveTeam error: ${e}`);
3066
+ this.jsonResponse(res, { ok: false, error: String(e) });
3067
+ }
3068
+ });
2870
3069
  }
2871
3070
  async notifyHubLeave() {
2872
3071
  try {
@@ -2886,12 +3085,46 @@ class ViewerServer {
2886
3085
  this.log.warn(`Failed to notify Hub of leave: ${e}`);
2887
3086
  }
2888
3087
  }
3088
+ async withdrawOrLeaveHub() {
3089
+ try {
3090
+ const persisted = this.store.getClientHubConnection();
3091
+ const sharing = this.ctx?.config?.sharing;
3092
+ if (persisted?.userToken && persisted?.hubUrl) {
3093
+ await (0, hub_1.hubRequestJson)(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
3094
+ this.log.info("Notified Hub of voluntary leave (had token)");
3095
+ return;
3096
+ }
3097
+ const hub = this.resolveHubConnection();
3098
+ if (hub?.userToken) {
3099
+ await (0, hub_1.hubRequestJson)(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
3100
+ this.log.info("Notified Hub of voluntary leave (resolved connection)");
3101
+ return;
3102
+ }
3103
+ const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? (0, hub_1.normalizeHubUrl)(sharing.client.hubAddress) : null);
3104
+ const userId = persisted?.userId;
3105
+ const teamToken = sharing?.client?.teamToken;
3106
+ if (hubUrl && userId && teamToken) {
3107
+ const withdrawUrl = `${(0, hub_1.normalizeHubUrl)(hubUrl)}/api/v1/hub/withdraw-pending`;
3108
+ await fetch(withdrawUrl, {
3109
+ method: "POST",
3110
+ headers: { "content-type": "application/json" },
3111
+ body: JSON.stringify({ teamToken, userId }),
3112
+ });
3113
+ this.log.info("Withdrew pending application from Hub");
3114
+ return;
3115
+ }
3116
+ this.log.info("No hub connection to clean up (no token, no pending)");
3117
+ }
3118
+ catch (e) {
3119
+ this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
3120
+ }
3121
+ }
2889
3122
  async notifyHubShutdown() {
2890
3123
  try {
2891
3124
  const sharing = this.ctx?.config.sharing;
2892
3125
  if (!sharing || sharing.role !== "hub")
2893
3126
  return;
2894
- const hubPort = sharing.hub?.port ?? 18800;
3127
+ const hubPort = this.getHubPort();
2895
3128
  const authPath = node_path_1.default.join(this.dataDir, "hub-auth.json");
2896
3129
  let adminToken;
2897
3130
  try {
@@ -2956,10 +3189,10 @@ class ViewerServer {
2956
3189
  }
2957
3190
  }
2958
3191
  else {
2959
- const persisted = this.store.getClientHubConnection();
2960
- if (persisted) {
3192
+ const persistedConn = this.store.getClientHubConnection();
3193
+ if (persistedConn) {
2961
3194
  this.store.setClientHubConnection({
2962
- ...persisted,
3195
+ ...persistedConn,
2963
3196
  username: result.username,
2964
3197
  userToken: result.userToken,
2965
3198
  });
@@ -2986,12 +3219,17 @@ class ViewerServer {
2986
3219
  return;
2987
3220
  }
2988
3221
  try {
2989
- const localIPs = this.getLocalIPs();
2990
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2991
- const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2992
- if (localIPs.includes(parsed.hostname)) {
2993
- this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2994
- return;
3222
+ const sharing = this.ctx?.config?.sharing;
3223
+ if (sharing?.enabled && sharing.role === "hub") {
3224
+ const selfHubPort = this.getHubPort();
3225
+ const localIPs = this.getLocalIPs();
3226
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
3227
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
3228
+ const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
3229
+ if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
3230
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
3231
+ return;
3232
+ }
2995
3233
  }
2996
3234
  }
2997
3235
  catch { }
@@ -3249,10 +3487,14 @@ class ViewerServer {
3249
3487
  catch { }
3250
3488
  this.log.info(`update-install: success! Updated to ${newVersion}`);
3251
3489
  this.jsonResponse(res, { ok: true, version: newVersion });
3252
- // Trigger Gateway restart after response is sent
3253
3490
  setTimeout(() => {
3254
- this.log.info(`update-install: triggering gateway restart...`);
3255
- process.kill(process.pid, "SIGUSR1");
3491
+ this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
3492
+ try {
3493
+ process.kill(process.pid, "SIGUSR1");
3494
+ }
3495
+ catch (sig) {
3496
+ this.log.warn(`SIGUSR1 failed: ${sig}`);
3497
+ }
3256
3498
  }, 500);
3257
3499
  });
3258
3500
  });
@@ -3428,7 +3670,7 @@ class ViewerServer {
3428
3670
  try {
3429
3671
  const ocHome = this.getOpenClawHome();
3430
3672
  const memoryDir = node_path_1.default.join(ocHome, "memory");
3431
- const sessionsDir = node_path_1.default.join(ocHome, "agents", "main", "sessions");
3673
+ const agentsDir = node_path_1.default.join(ocHome, "agents");
3432
3674
  const sqliteFiles = [];
3433
3675
  if (node_fs_1.default.existsSync(memoryDir)) {
3434
3676
  for (const f of node_fs_1.default.readdirSync(memoryDir)) {
@@ -3446,38 +3688,45 @@ class ViewerServer {
3446
3688
  }
3447
3689
  let sessionCount = 0;
3448
3690
  let messageCount = 0;
3449
- if (node_fs_1.default.existsSync(sessionsDir)) {
3450
- const jsonlFiles = node_fs_1.default.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
3451
- sessionCount = jsonlFiles.length;
3452
- for (const f of jsonlFiles) {
3453
- try {
3454
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessionsDir, f), "utf-8");
3455
- const lines = content.split("\n").filter(l => l.trim());
3456
- for (const line of lines) {
3457
- try {
3458
- const obj = JSON.parse(line);
3459
- if (obj.type === "message") {
3460
- const role = obj.message?.role ?? obj.role;
3461
- if (role === "user" || role === "assistant") {
3462
- const mc = obj.message?.content ?? obj.content;
3463
- let txt = "";
3464
- if (typeof mc === "string")
3465
- txt = mc;
3466
- else if (Array.isArray(mc))
3467
- txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
3468
- else
3469
- txt = JSON.stringify(mc);
3470
- if (role === "user")
3471
- txt = (0, capture_1.stripInboundMetadata)(txt);
3472
- if (txt && txt.length >= 10)
3473
- messageCount++;
3691
+ if (node_fs_1.default.existsSync(agentsDir)) {
3692
+ for (const entry of node_fs_1.default.readdirSync(agentsDir, { withFileTypes: true })) {
3693
+ if (!entry.isDirectory())
3694
+ continue;
3695
+ const sessDir = node_path_1.default.join(agentsDir, entry.name, "sessions");
3696
+ if (!node_fs_1.default.existsSync(sessDir))
3697
+ continue;
3698
+ const jsonlFiles = node_fs_1.default.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3699
+ sessionCount += jsonlFiles.length;
3700
+ for (const f of jsonlFiles) {
3701
+ try {
3702
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(sessDir, f), "utf-8");
3703
+ const lines = content.split("\n").filter(l => l.trim());
3704
+ for (const line of lines) {
3705
+ try {
3706
+ const obj = JSON.parse(line);
3707
+ if (obj.type === "message") {
3708
+ const role = obj.message?.role ?? obj.role;
3709
+ if (role === "user" || role === "assistant") {
3710
+ const mc = obj.message?.content ?? obj.content;
3711
+ let txt = "";
3712
+ if (typeof mc === "string")
3713
+ txt = mc;
3714
+ else if (Array.isArray(mc))
3715
+ txt = mc.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
3716
+ else
3717
+ txt = JSON.stringify(mc);
3718
+ if (role === "user")
3719
+ txt = (0, capture_1.stripInboundMetadata)(txt);
3720
+ if (txt && txt.length >= 10)
3721
+ messageCount++;
3722
+ }
3474
3723
  }
3475
3724
  }
3725
+ catch { /* skip bad lines */ }
3476
3726
  }
3477
- catch { /* skip bad lines */ }
3478
3727
  }
3728
+ catch { /* skip unreadable */ }
3479
3729
  }
3480
- catch { /* skip unreadable */ }
3481
3730
  }
3482
3731
  }
3483
3732
  const cfgPath = this.getOpenClawConfigPath();