@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
@@ -34,6 +34,7 @@ export interface ViewerServerOptions {
34
34
  log: Logger;
35
35
  dataDir: string;
36
36
  ctx?: PluginContext;
37
+ defaultHubPort?: number;
37
38
  }
38
39
 
39
40
  interface AuthState {
@@ -51,6 +52,8 @@ export class ViewerServer {
51
52
  private readonly authFile: string;
52
53
  private readonly auth: AuthState;
53
54
  private readonly ctx?: PluginContext;
55
+ private readonly cookieName: string;
56
+ private readonly defaultHubPort: number;
54
57
 
55
58
  private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
56
59
  private static readonly PLUGIN_VERSION: string = (() => {
@@ -99,17 +102,31 @@ export class ViewerServer {
99
102
  this.ctx = opts.ctx;
100
103
  this.authFile = path.join(opts.dataDir, "viewer-auth.json");
101
104
  this.auth = { passwordHash: null, sessions: new Map() };
105
+ this.cookieName = `memos_token_${opts.port}`;
106
+ this.defaultHubPort = opts.defaultHubPort ?? 18800;
102
107
  this.resetToken = crypto.randomBytes(16).toString("hex");
103
108
  this.loadAuth();
104
109
  }
105
110
 
111
+ private getHubPort(): number {
112
+ const configured = this.ctx?.config?.sharing?.hub?.port;
113
+ if (configured && configured !== 18800) return configured;
114
+ return this.defaultHubPort;
115
+ }
116
+
106
117
  start(): Promise<string> {
118
+ const MAX_PORT_RETRIES = 5;
107
119
  return new Promise((resolve, reject) => {
120
+ let retries = 0;
108
121
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
109
122
  this.server.on("error", (err: NodeJS.ErrnoException) => {
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, "0.0.0.0");
123
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
124
+ retries++;
125
+ const nextPort = this.port + retries;
126
+ this.log.warn(`Viewer port ${this.port + retries - 1} in use, trying ${nextPort}`);
127
+ this.server!.listen(nextPort, "0.0.0.0");
128
+ } else if (err.code === "EADDRINUSE") {
129
+ reject(new Error(`Viewer failed to find open port after ${MAX_PORT_RETRIES} retries (tried ${this.port}–${this.port + MAX_PORT_RETRIES})`));
113
130
  } else {
114
131
  reject(err);
115
132
  }
@@ -187,7 +204,8 @@ export class ViewerServer {
187
204
 
188
205
  private isValidSession(req: http.IncomingMessage): boolean {
189
206
  const cookie = req.headers.cookie ?? "";
190
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
207
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
208
+ const match = cookie.match(re);
191
209
  if (!match) return false;
192
210
  const expiry = this.auth.sessions.get(match[1]);
193
211
  if (!expiry) return false;
@@ -270,6 +288,7 @@ export class ViewerServer {
270
288
  else if (p === "/api/sharing/remove-user" && req.method === "POST") this.handleSharingRemoveUser(req, res);
271
289
  else if (p === "/api/sharing/change-role" && req.method === "POST") this.handleSharingChangeRole(req, res);
272
290
  else if (p === "/api/sharing/retry-join" && req.method === "POST") this.handleRetryJoin(req, res);
291
+ else if (p === "/api/sharing/leave" && req.method === "POST") this.handleLeaveTeam(req, res);
273
292
  else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
274
293
  else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
275
294
  else if (p === "/api/sharing/tasks/list" && req.method === "GET") this.serveSharingTaskList(res, url);
@@ -290,6 +309,7 @@ export class ViewerServer {
290
309
  else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
291
310
  else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
292
311
  else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
312
+ else if (p === "/api/sharing/sync-hub-removal" && req.method === "POST") this.handleSyncHubRemoval(req, res);
293
313
  else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
294
314
  else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
295
315
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
@@ -350,7 +370,7 @@ export class ViewerServer {
350
370
  const token = this.createSession();
351
371
  res.writeHead(200, {
352
372
  "Content-Type": "application/json",
353
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
373
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
354
374
  });
355
375
  res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
356
376
  } catch (err) {
@@ -372,7 +392,7 @@ export class ViewerServer {
372
392
  const token = this.createSession();
373
393
  res.writeHead(200, {
374
394
  "Content-Type": "application/json",
375
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
395
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
376
396
  });
377
397
  res.end(JSON.stringify({ ok: true }));
378
398
  } catch (err) {
@@ -384,11 +404,12 @@ export class ViewerServer {
384
404
 
385
405
  private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
386
406
  const cookie = req.headers.cookie ?? "";
387
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
407
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
408
+ const match = cookie.match(re);
388
409
  if (match) this.auth.sessions.delete(match[1]);
389
410
  res.writeHead(200, {
390
411
  "Content-Type": "application/json",
391
- "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
412
+ "Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
392
413
  });
393
414
  res.end(JSON.stringify({ ok: true }));
394
415
  }
@@ -415,7 +436,7 @@ export class ViewerServer {
415
436
  const sessionToken = this.createSession();
416
437
  res.writeHead(200, {
417
438
  "Content-Type": "application/json",
418
- "Set-Cookie": `memos_token=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
439
+ "Set-Cookie": `${this.cookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
419
440
  });
420
441
  res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
421
442
  } catch (err) {
@@ -451,9 +472,8 @@ export class ViewerServer {
451
472
  if (session) { conditions.push("session_key = ?"); params.push(session); }
452
473
  if (role) { conditions.push("role = ?"); params.push(role); }
453
474
  if (owner && owner.startsWith("agent:")) {
454
- const agentPrefix = owner + ":";
455
- conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
456
- params.push(owner, agentPrefix + "%");
475
+ conditions.push("(owner = ? OR owner = 'public')");
476
+ params.push(owner);
457
477
  } else if (owner) {
458
478
  conditions.push("owner = ?"); params.push(owner);
459
479
  }
@@ -462,7 +482,7 @@ export class ViewerServer {
462
482
 
463
483
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
464
484
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
465
- const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
485
+ 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);
466
486
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
467
487
 
468
488
  const chunkIds = rawMemories.map((m: any) => m.id);
@@ -473,6 +493,12 @@ export class ViewerServer {
473
493
  const placeholders = chunkIds.map(() => "?").join(",");
474
494
  const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ source_chunk_id: string; visibility: string; group_id: string | null }>;
475
495
  for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
496
+ const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; visibility: string; group_id: string | null }>;
497
+ for (const r of teamMetaRows) {
498
+ if (!sharingMap.has(r.chunk_id)) {
499
+ sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
500
+ }
501
+ }
476
502
  const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; original_owner: string; shared_at: number }>;
477
503
  for (const r of localRows) localShareMap.set(r.chunk_id, r);
478
504
  } catch {
@@ -640,9 +666,8 @@ export class ViewerServer {
640
666
  let sessionQuery: string;
641
667
  let sessionParams: any[];
642
668
  if (ownerFilter && ownerFilter.startsWith("agent:")) {
643
- const agentPrefix = ownerFilter + ":";
644
- 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";
645
- sessionParams = [ownerFilter, agentPrefix + "%"];
669
+ 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";
670
+ sessionParams = [ownerFilter];
646
671
  } else if (ownerFilter) {
647
672
  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";
648
673
  sessionParams = [ownerFilter];
@@ -1049,11 +1074,21 @@ export class ViewerServer {
1049
1074
  });
1050
1075
  }
1051
1076
 
1052
- private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
1077
+ private async handleSkillDelete(res: http.ServerResponse, urlPath: string): Promise<void> {
1053
1078
  const skillId = urlPath.replace("/api/skill/", "");
1054
1079
  const skill = this.store.getSkill(skillId);
1055
1080
  if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
1056
- // Remove skill directory from disk
1081
+ try {
1082
+ const hub = this.resolveHubConnection();
1083
+ if (hub) {
1084
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
1085
+ method: "POST",
1086
+ body: JSON.stringify({ sourceSkillId: skillId }),
1087
+ }).catch(() => {});
1088
+ }
1089
+ const db = (this.store as any).db;
1090
+ db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
1091
+ } catch (_) {}
1057
1092
  try {
1058
1093
  if (skill.dirPath && fs.existsSync(skill.dirPath)) {
1059
1094
  fs.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1216,7 +1251,6 @@ export class ViewerServer {
1216
1251
  let hubSynced = false;
1217
1252
 
1218
1253
  if (scope === "team") {
1219
- if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1220
1254
  if (!isTeamShared) {
1221
1255
  const hubClient = await this.resolveHubClientAware();
1222
1256
  const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
@@ -1224,17 +1258,24 @@ export class ViewerServer {
1224
1258
  method: "POST",
1225
1259
  body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1226
1260
  });
1227
- if (hubClient.userId) {
1261
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1262
+ const memoryId = String((response as any)?.memoryId ?? "");
1263
+ const isHubRole = this.ctx?.config?.sharing?.role === "hub";
1264
+ if (hubClient.userId && isHubRole) {
1228
1265
  const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1229
1266
  this.store.upsertHubMemory({
1230
- id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
1267
+ id: memoryId || existing?.id || crypto.randomUUID(),
1231
1268
  sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1232
1269
  role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1233
1270
  kind: refreshedChunk.kind, groupId: null, visibility: "public",
1234
1271
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1235
1272
  });
1273
+ } else if (hubClient.userId) {
1274
+ this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
1236
1275
  }
1237
1276
  hubSynced = true;
1277
+ } else {
1278
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1238
1279
  }
1239
1280
  } else if (scope === "local") {
1240
1281
  if (isTeamShared) {
@@ -1244,6 +1285,7 @@ export class ViewerServer {
1244
1285
  method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1245
1286
  });
1246
1287
  if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1288
+ this.store.deleteTeamSharedChunk(chunkId);
1247
1289
  hubSynced = true;
1248
1290
  } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1249
1291
  }
@@ -1256,6 +1298,7 @@ export class ViewerServer {
1256
1298
  method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1257
1299
  });
1258
1300
  if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1301
+ this.store.deleteTeamSharedChunk(chunkId);
1259
1302
  hubSynced = true;
1260
1303
  } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1261
1304
  }
@@ -1296,15 +1339,6 @@ export class ViewerServer {
1296
1339
 
1297
1340
  let hubSynced = false;
1298
1341
 
1299
- if (scope === "local" || scope === "team") {
1300
- if (!isLocalShared) {
1301
- const originalOwner = task.owner;
1302
- const db = (this.store as any).db;
1303
- 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());
1304
- db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1305
- }
1306
- }
1307
-
1308
1342
  if (scope === "team") {
1309
1343
  if (!isTeamShared) {
1310
1344
  const chunks = this.store.getChunksByTask(taskId);
@@ -1328,6 +1362,21 @@ export class ViewerServer {
1328
1362
  }
1329
1363
  hubSynced = true;
1330
1364
  }
1365
+ if (!isLocalShared) {
1366
+ const originalOwner = task.owner;
1367
+ const db = (this.store as any).db;
1368
+ 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());
1369
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1370
+ }
1371
+ }
1372
+
1373
+ if (scope === "local") {
1374
+ if (!isLocalShared) {
1375
+ const originalOwner = task.owner;
1376
+ const db = (this.store as any).db;
1377
+ 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());
1378
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1379
+ }
1331
1380
  }
1332
1381
 
1333
1382
  if (scope === "local" && isTeamShared) {
@@ -1397,10 +1446,6 @@ export class ViewerServer {
1397
1446
 
1398
1447
  let hubSynced = false;
1399
1448
 
1400
- if (scope === "local" || scope === "team") {
1401
- if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1402
- }
1403
-
1404
1449
  if (scope === "team") {
1405
1450
  if (!isTeamShared) {
1406
1451
  const bundle = buildSkillBundleForHub(this.store, skillId);
@@ -1422,6 +1467,11 @@ export class ViewerServer {
1422
1467
  }
1423
1468
  hubSynced = true;
1424
1469
  }
1470
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1471
+ }
1472
+
1473
+ if (scope === "local") {
1474
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1425
1475
  }
1426
1476
 
1427
1477
  if (scope === "local" && isTeamShared) {
@@ -1458,7 +1508,17 @@ export class ViewerServer {
1458
1508
 
1459
1509
  private getHubMemoryForChunk(chunkId: string): any {
1460
1510
  const db = (this.store as any).db;
1461
- return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1511
+ const hub = db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1512
+ if (hub) return hub;
1513
+ const ts = this.store.getTeamSharedChunk(chunkId);
1514
+ if (ts) {
1515
+ return {
1516
+ source_chunk_id: chunkId,
1517
+ visibility: ts.visibility,
1518
+ group_id: ts.groupId,
1519
+ };
1520
+ }
1521
+ return undefined;
1462
1522
  }
1463
1523
 
1464
1524
  private getHubTaskForLocal(taskId: string): any {
@@ -1505,6 +1565,7 @@ export class ViewerServer {
1505
1565
  // ─── Config API ───
1506
1566
 
1507
1567
  private getOpenClawConfigPath(): string {
1568
+ if (process.env.OPENCLAW_CONFIG_PATH) return process.env.OPENCLAW_CONFIG_PATH;
1508
1569
  const home = process.env.HOME || process.env.USERPROFILE || "";
1509
1570
  const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
1510
1571
  return path.join(ocHome, "openclaw.json");
@@ -1594,7 +1655,20 @@ export class ViewerServer {
1594
1655
  base.connection.teamName = info?.teamName ?? sharing.hub?.teamName ?? null;
1595
1656
  base.connection.apiVersion = info?.apiVersion ?? null;
1596
1657
  } catch { /* ignore */ }
1597
- this.jsonResponse(res, base);
1658
+
1659
+ const hubStats: any = { totalMembers: 0, onlineMembers: 0, pendingMembers: 0 };
1660
+ try {
1661
+ const activeUsers = this.store.listHubUsers("active");
1662
+ const pendingUsers = this.store.listHubUsers("pending");
1663
+ const now = Date.now();
1664
+ const OFFLINE_THRESHOLD = 120_000;
1665
+ hubStats.totalMembers = activeUsers.length;
1666
+ hubStats.onlineMembers = activeUsers.filter(u =>
1667
+ u.lastActiveAt && (now - u.lastActiveAt < OFFLINE_THRESHOLD),
1668
+ ).length;
1669
+ hubStats.pendingMembers = pendingUsers.length;
1670
+ } catch { /* best-effort */ }
1671
+ this.jsonResponse(res, { ...base, hubStats });
1598
1672
  return;
1599
1673
  }
1600
1674
 
@@ -1613,6 +1687,9 @@ export class ViewerServer {
1613
1687
  if (status.user?.status === "rejected") {
1614
1688
  output.connection.rejected = true;
1615
1689
  }
1690
+ if (status.user?.status === "removed") {
1691
+ output.connection.removed = true;
1692
+ }
1616
1693
  if (status.connected && status.hubUrl) {
1617
1694
  try {
1618
1695
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
@@ -1730,7 +1807,14 @@ export class ViewerServer {
1730
1807
  });
1731
1808
  this.jsonResponse(res, { ok: true, result });
1732
1809
  } catch (err) {
1733
- this.jsonResponse(res, { ok: false, error: String(err) });
1810
+ const errStr = String(err);
1811
+ if (errStr.includes("username_taken")) {
1812
+ this.jsonResponse(res, { ok: false, error: "username_taken" });
1813
+ } else if (errStr.includes("invalid_params")) {
1814
+ this.jsonResponse(res, { ok: false, error: "invalid_params" });
1815
+ } else {
1816
+ this.jsonResponse(res, { ok: false, error: errStr });
1817
+ }
1734
1818
  }
1735
1819
  });
1736
1820
  }
@@ -1749,22 +1833,17 @@ export class ViewerServer {
1749
1833
  }
1750
1834
  try {
1751
1835
  const hubUrl = normalizeHubUrl(hubAddress);
1752
- const localIPs = this.getLocalIPs();
1753
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1754
- try {
1755
- const u = new URL(hubUrl);
1756
- if (localIPs.includes(u.hostname)) {
1757
- return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1758
- }
1759
- } catch {}
1760
1836
  const os = await import("os");
1761
1837
  const nickname = sharing.client?.nickname;
1762
1838
  const username = nickname || os.userInfo().username || "user";
1763
1839
  const hostname = os.hostname() || "unknown";
1840
+ const persisted = this.store.getClientHubConnection();
1841
+ const existingIdentityKey = persisted?.identityKey || "";
1764
1842
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
1765
1843
  method: "POST",
1766
- body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
1844
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
1767
1845
  }) as any;
1846
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
1768
1847
  this.store.setClientHubConnection({
1769
1848
  hubUrl,
1770
1849
  userId: String(result.userId || ""),
@@ -1772,6 +1851,8 @@ export class ViewerServer {
1772
1851
  userToken: result.userToken || "",
1773
1852
  role: "member",
1774
1853
  connectedAt: Date.now(),
1854
+ identityKey: returnedIdentityKey,
1855
+ lastKnownStatus: result.status || "",
1775
1856
  });
1776
1857
  this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1777
1858
  } catch (err) {
@@ -2047,14 +2128,14 @@ export class ViewerServer {
2047
2128
  },
2048
2129
  }),
2049
2130
  });
2050
- const hubUserId = hubClient.userId;
2051
- if (hubUserId) {
2131
+ const mid = String((response as any)?.memoryId ?? "");
2132
+ if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
2052
2133
  const now = Date.now();
2053
- const existing = this.store.getHubMemoryBySource(hubUserId, chunk.id);
2134
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
2054
2135
  this.store.upsertHubMemory({
2055
- id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
2136
+ id: mid || existing?.id || crypto.randomUUID(),
2056
2137
  sourceChunkId: chunk.id,
2057
- sourceUserId: hubUserId,
2138
+ sourceUserId: hubClient.userId,
2058
2139
  role: chunk.role,
2059
2140
  content: chunk.content,
2060
2141
  summary: chunk.summary ?? "",
@@ -2064,6 +2145,8 @@ export class ViewerServer {
2064
2145
  createdAt: existing?.createdAt ?? now,
2065
2146
  updatedAt: now,
2066
2147
  });
2148
+ } else if (hubClient.userId) {
2149
+ this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
2067
2150
  }
2068
2151
  this.jsonResponse(res, { ok: true, chunkId, visibility, response });
2069
2152
  } catch (err) {
@@ -2085,6 +2168,7 @@ export class ViewerServer {
2085
2168
  });
2086
2169
  const hubUserId = hubClient.userId;
2087
2170
  if (hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2171
+ this.store.deleteTeamSharedChunk(chunkId);
2088
2172
  this.jsonResponse(res, { ok: true, chunkId });
2089
2173
  } catch (err) {
2090
2174
  this.jsonResponse(res, { ok: false, error: String(err) });
@@ -2181,7 +2265,7 @@ export class ViewerServer {
2181
2265
  // Hub 模式:连接自己,用 bootstrap admin token
2182
2266
  const sharing = this.ctx.config.sharing;
2183
2267
  if (sharing?.role === "hub") {
2184
- const hubPort = sharing.hub?.port ?? 18800;
2268
+ const hubPort = this.getHubPort();
2185
2269
  const hubUrl = `http://127.0.0.1:${hubPort}`;
2186
2270
  try {
2187
2271
  const authPath = path.join(this.dataDir, "hub-auth.json");
@@ -2398,6 +2482,31 @@ export class ViewerServer {
2398
2482
  });
2399
2483
  }
2400
2484
 
2485
+ /** Badge-only: clear Client team-share UI metadata when Hub admin removes that memory. Does NOT touch chunks, embeddings, or hub_memories (recall paths). */
2486
+ private handleSyncHubRemoval(req: http.IncomingMessage, res: http.ServerResponse): void {
2487
+ this.readBody(req, (body) => {
2488
+ try {
2489
+ const parsed = JSON.parse(body || "{}");
2490
+ const sourceChunkId = String(parsed.sourceChunkId || "");
2491
+ const memoryIdFromNotif = parsed.memoryId != null && parsed.memoryId !== "" ? String(parsed.memoryId) : "";
2492
+ if (!sourceChunkId) return this.jsonResponse(res, { ok: false, error: "missing_source_chunk_id" }, 400);
2493
+ // Admin removal notifications stay in the feed; if the user re-shared, team_shared_chunks has a new hub_memory_id.
2494
+ // Only clear the badge when this notification refers to the same Hub row we still track (or no id — legacy).
2495
+ if (memoryIdFromNotif) {
2496
+ const current = this.store.getTeamSharedChunk(sourceChunkId);
2497
+ const curId = current?.hubMemoryId ? String(current.hubMemoryId) : "";
2498
+ if (curId && curId !== memoryIdFromNotif) {
2499
+ return this.jsonResponse(res, { ok: true, sourceChunkId, skipped: true, reason: "stale_notification_re_shared" });
2500
+ }
2501
+ }
2502
+ this.store.deleteTeamSharedChunk(sourceChunkId);
2503
+ this.jsonResponse(res, { ok: true, sourceChunkId });
2504
+ } catch (e) {
2505
+ this.jsonResponse(res, { ok: false, error: String(e) }, 500);
2506
+ }
2507
+ });
2508
+ }
2509
+
2401
2510
  private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
2402
2511
  res.writeHead(200, {
2403
2512
  "Content-Type": "text/event-stream",
@@ -2408,6 +2517,7 @@ export class ViewerServer {
2408
2517
  res.write("data: {\"type\":\"connected\"}\n\n");
2409
2518
  this.notifSSEClients.push(res);
2410
2519
  if (!this.notifPollTimer) this.startNotifPoll();
2520
+ else this.notifPollImmediate();
2411
2521
  req.on("close", () => {
2412
2522
  this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2413
2523
  if (this.notifSSEClients.length === 0) this.stopNotifPoll();
@@ -2443,6 +2553,20 @@ export class ViewerServer {
2443
2553
  if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
2444
2554
  }
2445
2555
 
2556
+ private notifPollImmediate(): void {
2557
+ const hub = this.resolveHubConnection();
2558
+ if (!hub) return;
2559
+ hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")
2560
+ .then((data: any) => {
2561
+ const count = data?.unreadCount ?? 0;
2562
+ if (count !== this.lastKnownNotifCount) {
2563
+ this.lastKnownNotifCount = count;
2564
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2565
+ }
2566
+ })
2567
+ .catch(() => {});
2568
+ }
2569
+
2446
2570
  private startHubHeartbeat(): void {
2447
2571
  this.stopHubHeartbeat();
2448
2572
  const sendHeartbeat = async () => {
@@ -2560,12 +2684,14 @@ export class ViewerServer {
2560
2684
  if (merged.role === "client" && merged.client) {
2561
2685
  const clientCfg = merged.client as Record<string, unknown>;
2562
2686
  const addr = String(clientCfg.hubAddress || "");
2563
- if (addr) {
2687
+ if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
2688
+ const selfHubPort = (oldSharing?.hub as Record<string, unknown>)?.port ?? 18800;
2564
2689
  const localIPs = this.getLocalIPs();
2565
2690
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2566
2691
  try {
2567
2692
  const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2568
- if (localIPs.includes(u.hostname)) {
2693
+ const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
2694
+ if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
2569
2695
  res.writeHead(400, { "Content-Type": "application/json" });
2570
2696
  res.end(JSON.stringify({ error: "cannot_join_self" }));
2571
2697
  return;
@@ -2590,25 +2716,27 @@ export class ViewerServer {
2590
2716
  const wasClient = oldSharingEnabled && oldSharingRole === "client";
2591
2717
  const isClient = newEnabled && newRole === "client";
2592
2718
  if (wasClient && !isClient) {
2593
- this.notifyHubLeave();
2719
+ await this.withdrawOrLeaveHub();
2594
2720
  this.store.clearClientHubConnection();
2595
- this.log.info("Cleared client hub connection (sharing disabled or role changed)");
2721
+ this.log.info("Client hub connection cleared (sharing disabled or role changed)");
2596
2722
  }
2597
2723
 
2598
- // Detect switching to a different Hub while still in client mode
2599
2724
  if (wasClient && isClient) {
2600
2725
  const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
2601
2726
  if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
2602
2727
  this.notifyHubLeave();
2603
- this.store.clearClientHubConnection();
2604
- this.log.info("Cleared client hub connection (switched to different Hub)");
2728
+ const oldConn = this.store.getClientHubConnection();
2729
+ if (oldConn) {
2730
+ this.store.setClientHubConnection({ ...oldConn, hubUrl: normalizeHubUrl(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
2731
+ }
2732
+ this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
2605
2733
  }
2606
2734
  }
2607
2735
 
2608
2736
  if (merged.role === "hub") {
2609
2737
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2610
2738
  } else if (merged.role === "client") {
2611
- merged.hub = { port: 18800, teamName: "", teamToken: "" };
2739
+ merged.hub = { teamName: "", teamToken: "" };
2612
2740
  }
2613
2741
  config.sharing = merged;
2614
2742
  }
@@ -2618,13 +2746,25 @@ export class ViewerServer {
2618
2746
  this.log.info("Plugin config updated via Viewer");
2619
2747
  this.stopHubHeartbeat();
2620
2748
 
2621
- // When switching to client mode, immediately send join request
2749
+ // When switching to client mode or re-enabling sharing as client, send join request
2622
2750
  const finalSharing = config.sharing as Record<string, unknown> | undefined;
2623
- if (finalSharing?.role === "client" && oldSharingRole !== "client") {
2624
- this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2751
+ const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
2752
+ const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
2753
+ let joinStatus: string | undefined;
2754
+ if (nowClient && !previouslyClient) {
2755
+ try {
2756
+ joinStatus = await this.autoJoinOnSave(finalSharing);
2757
+ } catch (e) {
2758
+ this.log.warn(`Auto-join on save failed: ${e}`);
2759
+ }
2625
2760
  }
2626
2761
 
2627
- this.jsonResponse(res, { ok: true });
2762
+ this.jsonResponse(res, { ok: true, joinStatus, restart: true });
2763
+
2764
+ setTimeout(() => {
2765
+ this.log.info("config-save: triggering gateway restart via SIGUSR1...");
2766
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2767
+ }, 500);
2628
2768
  } catch (e) {
2629
2769
  this.log.warn(`handleSaveConfig error: ${e}`);
2630
2770
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2633,20 +2773,23 @@ export class ViewerServer {
2633
2773
  });
2634
2774
  }
2635
2775
 
2636
- private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<void> {
2776
+ private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<string | undefined> {
2637
2777
  const clientCfg = sharing.client as Record<string, unknown> | undefined;
2638
2778
  const hubAddress = String(clientCfg?.hubAddress || "");
2639
2779
  const teamToken = String(clientCfg?.teamToken || "");
2640
- if (!hubAddress || !teamToken) return;
2780
+ if (!hubAddress || !teamToken) return undefined;
2641
2781
  const hubUrl = normalizeHubUrl(hubAddress);
2642
2782
  const os = await import("os");
2643
2783
  const nickname = String(clientCfg?.nickname || "");
2644
2784
  const username = nickname || os.userInfo().username || "user";
2645
2785
  const hostname = os.hostname() || "unknown";
2786
+ const persisted = this.store.getClientHubConnection();
2787
+ const existingIdentityKey = persisted?.identityKey || "";
2646
2788
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
2647
2789
  method: "POST",
2648
- body: JSON.stringify({ teamToken, username, deviceName: hostname }),
2790
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
2649
2791
  }) as any;
2792
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
2650
2793
  this.store.setClientHubConnection({
2651
2794
  hubUrl,
2652
2795
  userId: String(result.userId || ""),
@@ -2654,11 +2797,49 @@ export class ViewerServer {
2654
2797
  userToken: result.userToken || "",
2655
2798
  role: "member",
2656
2799
  connectedAt: Date.now(),
2800
+ identityKey: returnedIdentityKey,
2801
+ lastKnownStatus: result.status || "",
2657
2802
  });
2658
2803
  this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2659
2804
  if (result.userToken) {
2660
2805
  this.startHubHeartbeat();
2661
2806
  }
2807
+ return result.status;
2808
+ }
2809
+
2810
+ private handleLeaveTeam(_req: http.IncomingMessage, res: http.ServerResponse): void {
2811
+ this.readBody(_req, async () => {
2812
+ try {
2813
+ await this.withdrawOrLeaveHub();
2814
+ this.store.clearClientHubConnection();
2815
+
2816
+ const configPath = this.getOpenClawConfigPath();
2817
+ if (configPath && fs.existsSync(configPath)) {
2818
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
2819
+ const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
2820
+ if (pluginKey) {
2821
+ const cfg = raw.plugins.entries[pluginKey].config ?? {};
2822
+ if (cfg.sharing) {
2823
+ cfg.sharing.enabled = false;
2824
+ cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
2825
+ }
2826
+ raw.plugins.entries[pluginKey].config = cfg;
2827
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
2828
+ this.log.info("handleLeaveTeam: config updated, sharing disabled");
2829
+ }
2830
+ }
2831
+
2832
+ this.jsonResponse(res, { ok: true, restart: true });
2833
+
2834
+ setTimeout(() => {
2835
+ this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
2836
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2837
+ }, 500);
2838
+ } catch (e) {
2839
+ this.log.warn(`handleLeaveTeam error: ${e}`);
2840
+ this.jsonResponse(res, { ok: false, error: String(e) });
2841
+ }
2842
+ });
2662
2843
  }
2663
2844
 
2664
2845
  private async notifyHubLeave(): Promise<void> {
@@ -2679,11 +2860,49 @@ export class ViewerServer {
2679
2860
  }
2680
2861
  }
2681
2862
 
2863
+ private async withdrawOrLeaveHub(): Promise<void> {
2864
+ try {
2865
+ const persisted = this.store.getClientHubConnection();
2866
+ const sharing = this.ctx?.config?.sharing;
2867
+
2868
+ if (persisted?.userToken && persisted?.hubUrl) {
2869
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2870
+ this.log.info("Notified Hub of voluntary leave (had token)");
2871
+ return;
2872
+ }
2873
+
2874
+ const hub = this.resolveHubConnection();
2875
+ if (hub?.userToken) {
2876
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2877
+ this.log.info("Notified Hub of voluntary leave (resolved connection)");
2878
+ return;
2879
+ }
2880
+
2881
+ const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? normalizeHubUrl(sharing.client.hubAddress) : null);
2882
+ const userId = persisted?.userId;
2883
+ const teamToken = sharing?.client?.teamToken;
2884
+ if (hubUrl && userId && teamToken) {
2885
+ const withdrawUrl = `${normalizeHubUrl(hubUrl)}/api/v1/hub/withdraw-pending`;
2886
+ await fetch(withdrawUrl, {
2887
+ method: "POST",
2888
+ headers: { "content-type": "application/json" },
2889
+ body: JSON.stringify({ teamToken, userId }),
2890
+ });
2891
+ this.log.info("Withdrew pending application from Hub");
2892
+ return;
2893
+ }
2894
+
2895
+ this.log.info("No hub connection to clean up (no token, no pending)");
2896
+ } catch (e) {
2897
+ this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
2898
+ }
2899
+ }
2900
+
2682
2901
  private async notifyHubShutdown(): Promise<void> {
2683
2902
  try {
2684
2903
  const sharing = this.ctx?.config.sharing;
2685
2904
  if (!sharing || sharing.role !== "hub") return;
2686
- const hubPort = sharing.hub?.port ?? 18800;
2905
+ const hubPort = this.getHubPort();
2687
2906
  const authPath = path.join(this.dataDir, "hub-auth.json");
2688
2907
  let adminToken: string | undefined;
2689
2908
  try {
@@ -2741,10 +2960,10 @@ export class ViewerServer {
2741
2960
  this.log.warn(`Failed to update hub-auth.json: ${e}`);
2742
2961
  }
2743
2962
  } else {
2744
- const persisted = this.store.getClientHubConnection();
2745
- if (persisted) {
2963
+ const persistedConn = this.store.getClientHubConnection();
2964
+ if (persistedConn) {
2746
2965
  this.store.setClientHubConnection({
2747
- ...persisted,
2966
+ ...persistedConn,
2748
2967
  username: result.username,
2749
2968
  userToken: result.userToken,
2750
2969
  });
@@ -2768,12 +2987,17 @@ export class ViewerServer {
2768
2987
  const { hubUrl } = JSON.parse(body);
2769
2988
  if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
2770
2989
  try {
2771
- const localIPs = this.getLocalIPs();
2772
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2773
- const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2774
- if (localIPs.includes(parsed.hostname)) {
2775
- this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2776
- return;
2990
+ const sharing = this.ctx?.config?.sharing;
2991
+ if (sharing?.enabled && sharing.role === "hub") {
2992
+ const selfHubPort = this.getHubPort();
2993
+ const localIPs = this.getLocalIPs();
2994
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2995
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2996
+ const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
2997
+ if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
2998
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2999
+ return;
3000
+ }
2777
3001
  }
2778
3002
  } catch {}
2779
3003
  const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
@@ -3011,10 +3235,9 @@ export class ViewerServer {
3011
3235
  this.log.info(`update-install: success! Updated to ${newVersion}`);
3012
3236
  this.jsonResponse(res, { ok: true, version: newVersion });
3013
3237
 
3014
- // Trigger Gateway restart after response is sent
3015
3238
  setTimeout(() => {
3016
- this.log.info(`update-install: triggering gateway restart...`);
3017
- process.kill(process.pid, "SIGUSR1");
3239
+ this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
3240
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
3018
3241
  }, 500);
3019
3242
  });
3020
3243
  });
@@ -3196,7 +3419,7 @@ export class ViewerServer {
3196
3419
  try {
3197
3420
  const ocHome = this.getOpenClawHome();
3198
3421
  const memoryDir = path.join(ocHome, "memory");
3199
- const sessionsDir = path.join(ocHome, "agents", "main", "sessions");
3422
+ const agentsDir = path.join(ocHome, "agents");
3200
3423
 
3201
3424
  const sqliteFiles: Array<{ file: string; chunks: number }> = [];
3202
3425
  if (fs.existsSync(memoryDir)) {
@@ -3215,31 +3438,36 @@ export class ViewerServer {
3215
3438
 
3216
3439
  let sessionCount = 0;
3217
3440
  let messageCount = 0;
3218
- if (fs.existsSync(sessionsDir)) {
3219
- const jsonlFiles = fs.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
3220
- sessionCount = jsonlFiles.length;
3221
- for (const f of jsonlFiles) {
3222
- try {
3223
- const content = fs.readFileSync(path.join(sessionsDir, f), "utf-8");
3224
- const lines = content.split("\n").filter(l => l.trim());
3225
- for (const line of lines) {
3226
- try {
3227
- const obj = JSON.parse(line);
3228
- if (obj.type === "message") {
3229
- const role = obj.message?.role ?? obj.role;
3230
- if (role === "user" || role === "assistant") {
3231
- const mc = obj.message?.content ?? obj.content;
3232
- let txt = "";
3233
- if (typeof mc === "string") txt = mc;
3234
- else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
3235
- else txt = JSON.stringify(mc);
3236
- if (role === "user") txt = stripInboundMetadata(txt);
3237
- if (txt && txt.length >= 10) messageCount++;
3441
+ if (fs.existsSync(agentsDir)) {
3442
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
3443
+ if (!entry.isDirectory()) continue;
3444
+ const sessDir = path.join(agentsDir, entry.name, "sessions");
3445
+ if (!fs.existsSync(sessDir)) continue;
3446
+ const jsonlFiles = fs.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3447
+ sessionCount += jsonlFiles.length;
3448
+ for (const f of jsonlFiles) {
3449
+ try {
3450
+ const content = fs.readFileSync(path.join(sessDir, f), "utf-8");
3451
+ const lines = content.split("\n").filter(l => l.trim());
3452
+ for (const line of lines) {
3453
+ try {
3454
+ const obj = JSON.parse(line);
3455
+ if (obj.type === "message") {
3456
+ const role = obj.message?.role ?? obj.role;
3457
+ if (role === "user" || role === "assistant") {
3458
+ const mc = obj.message?.content ?? obj.content;
3459
+ let txt = "";
3460
+ if (typeof mc === "string") txt = mc;
3461
+ else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
3462
+ else txt = JSON.stringify(mc);
3463
+ if (role === "user") txt = stripInboundMetadata(txt);
3464
+ if (txt && txt.length >= 10) messageCount++;
3465
+ }
3238
3466
  }
3239
- }
3240
- } catch { /* skip bad lines */ }
3241
- }
3242
- } catch { /* skip unreadable */ }
3467
+ } catch { /* skip bad lines */ }
3468
+ }
3469
+ } catch { /* skip unreadable */ }
3470
+ }
3243
3471
  }
3244
3472
  }
3245
3473