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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/.env.example +7 -0
  2. package/README.md +111 -44
  3. package/dist/capture/index.d.ts +1 -1
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +36 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +6 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +160 -26
  10. package/dist/client/connector.js.map +1 -1
  11. package/dist/client/hub.d.ts.map +1 -1
  12. package/dist/client/hub.js +22 -0
  13. package/dist/client/hub.js.map +1 -1
  14. package/dist/client/skill-sync.d.ts +7 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -1
  16. package/dist/client/skill-sync.js +10 -0
  17. package/dist/client/skill-sync.js.map +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +2 -3
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +9 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +500 -112
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +11 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +31 -3
  28. package/dist/hub/user-manager.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest/chunker.d.ts +2 -1
  33. package/dist/ingest/chunker.d.ts.map +1 -1
  34. package/dist/ingest/chunker.js +14 -10
  35. package/dist/ingest/chunker.js.map +1 -1
  36. package/dist/ingest/providers/index.d.ts.map +1 -1
  37. package/dist/ingest/providers/index.js +37 -6
  38. package/dist/ingest/providers/index.js.map +1 -1
  39. package/dist/recall/engine.d.ts.map +1 -1
  40. package/dist/recall/engine.js +96 -1
  41. package/dist/recall/engine.js.map +1 -1
  42. package/dist/shared/llm-call.d.ts +1 -0
  43. package/dist/shared/llm-call.d.ts.map +1 -1
  44. package/dist/shared/llm-call.js +84 -9
  45. package/dist/shared/llm-call.js.map +1 -1
  46. package/dist/sharing/types.d.ts +1 -1
  47. package/dist/sharing/types.d.ts.map +1 -1
  48. package/dist/skill/evolver.d.ts +4 -0
  49. package/dist/skill/evolver.d.ts.map +1 -1
  50. package/dist/skill/evolver.js +59 -5
  51. package/dist/skill/evolver.js.map +1 -1
  52. package/dist/skill/generator.d.ts +2 -0
  53. package/dist/skill/generator.d.ts.map +1 -1
  54. package/dist/skill/generator.js +45 -3
  55. package/dist/skill/generator.js.map +1 -1
  56. package/dist/skill/installer.d.ts +26 -0
  57. package/dist/skill/installer.d.ts.map +1 -1
  58. package/dist/skill/installer.js +80 -4
  59. package/dist/skill/installer.js.map +1 -1
  60. package/dist/skill/upgrader.d.ts +2 -0
  61. package/dist/skill/upgrader.d.ts.map +1 -1
  62. package/dist/skill/upgrader.js +139 -1
  63. package/dist/skill/upgrader.js.map +1 -1
  64. package/dist/skill/validator.d.ts +3 -0
  65. package/dist/skill/validator.d.ts.map +1 -1
  66. package/dist/skill/validator.js +75 -0
  67. package/dist/skill/validator.js.map +1 -1
  68. package/dist/storage/ensure-binding.d.ts +12 -0
  69. package/dist/storage/ensure-binding.d.ts.map +1 -0
  70. package/dist/storage/ensure-binding.js +53 -0
  71. package/dist/storage/ensure-binding.js.map +1 -0
  72. package/dist/storage/sqlite.d.ts +115 -20
  73. package/dist/storage/sqlite.d.ts.map +1 -1
  74. package/dist/storage/sqlite.js +458 -110
  75. package/dist/storage/sqlite.js.map +1 -1
  76. package/dist/telemetry.d.ts +12 -5
  77. package/dist/telemetry.d.ts.map +1 -1
  78. package/dist/telemetry.js +156 -40
  79. package/dist/telemetry.js.map +1 -1
  80. package/dist/tools/memory-search.d.ts +3 -1
  81. package/dist/tools/memory-search.d.ts.map +1 -1
  82. package/dist/tools/memory-search.js +3 -1
  83. package/dist/tools/memory-search.js.map +1 -1
  84. package/dist/types.d.ts +11 -2
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js +4 -0
  87. package/dist/types.js.map +1 -1
  88. package/dist/viewer/html.d.ts.map +1 -1
  89. package/dist/viewer/html.js +2952 -910
  90. package/dist/viewer/html.js.map +1 -1
  91. package/dist/viewer/server.d.ts +39 -8
  92. package/dist/viewer/server.d.ts.map +1 -1
  93. package/dist/viewer/server.js +1198 -227
  94. package/dist/viewer/server.js.map +1 -1
  95. package/index.ts +774 -74
  96. package/openclaw.plugin.json +2 -2
  97. package/package.json +3 -2
  98. package/scripts/postinstall.cjs +1 -1
  99. package/skill/memos-memory-guide/SKILL.md +64 -26
  100. package/src/capture/index.ts +40 -1
  101. package/src/client/connector.ts +161 -28
  102. package/src/client/hub.ts +18 -0
  103. package/src/client/skill-sync.ts +14 -0
  104. package/src/config.ts +2 -3
  105. package/src/hub/server.ts +481 -107
  106. package/src/hub/user-manager.ts +48 -8
  107. package/src/index.ts +10 -2
  108. package/src/ingest/chunker.ts +19 -13
  109. package/src/ingest/providers/index.ts +41 -7
  110. package/src/recall/engine.ts +89 -1
  111. package/src/shared/llm-call.ts +99 -10
  112. package/src/sharing/types.ts +1 -1
  113. package/src/skill/evolver.ts +63 -6
  114. package/src/skill/generator.ts +44 -5
  115. package/src/skill/installer.ts +107 -4
  116. package/src/skill/upgrader.ts +139 -1
  117. package/src/skill/validator.ts +79 -0
  118. package/src/storage/ensure-binding.ts +52 -0
  119. package/src/storage/sqlite.ts +498 -137
  120. package/src/telemetry.ts +172 -41
  121. package/src/tools/memory-search.ts +2 -1
  122. package/src/types.ts +12 -2
  123. package/src/viewer/html.ts +2952 -910
  124. package/src/viewer/server.ts +1109 -212
package/src/hub/server.ts CHANGED
@@ -14,6 +14,7 @@ type HubServerOptions = {
14
14
  config: MemosLocalConfig;
15
15
  dataDir: string;
16
16
  embedder?: Embedder;
17
+ defaultHubPort?: number;
17
18
  };
18
19
 
19
20
  type HubAuthState = {
@@ -34,6 +35,11 @@ export class HubServer {
34
35
  private static readonly RATE_LIMIT_SEARCH = 30;
35
36
  private rateBuckets = new Map<string, { count: number; windowStart: number }>();
36
37
 
38
+ private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
39
+ private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
40
+ private offlineCheckTimer?: ReturnType<typeof setInterval>;
41
+ private knownOnlineUsers = new Set<string>();
42
+
37
43
  constructor(private opts: HubServerOptions) {
38
44
  this.userManager = new HubUserManager(opts.store, opts.log);
39
45
  this.authStatePath = path.join(opts.dataDir, "hub-auth.json");
@@ -74,18 +80,31 @@ export class HubServer {
74
80
  }
75
81
  });
76
82
 
83
+ const MAX_PORT_RETRIES = 3;
84
+ let hubPort = this.port;
77
85
  await new Promise<void>((resolve, reject) => {
78
- const onError = (err: Error) => {
79
- this.server?.off("listening", onListening);
80
- reject(err);
86
+ let retries = 0;
87
+ const onError = (err: NodeJS.ErrnoException) => {
88
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
89
+ retries++;
90
+ hubPort = this.port + retries;
91
+ this.opts.log.warn(`Hub port ${hubPort - 1} in use, trying ${hubPort}`);
92
+ this.server!.listen(hubPort, "0.0.0.0");
93
+ } else {
94
+ this.server?.off("listening", onListening);
95
+ reject(err);
96
+ }
81
97
  };
82
98
  const onListening = () => {
83
99
  this.server?.off("error", onError);
100
+ if (hubPort !== this.port) {
101
+ this.opts.log.info(`Hub started on fallback port ${hubPort} (configured: ${this.port})`);
102
+ }
84
103
  resolve();
85
104
  };
86
- this.server!.once("error", onError);
105
+ this.server!.on("error", onError);
87
106
  this.server!.once("listening", onListening);
88
- this.server!.listen(this.port, "0.0.0.0");
107
+ this.server!.listen(hubPort, "0.0.0.0");
89
108
  });
90
109
 
91
110
  const bootstrap = this.userManager.ensureBootstrapAdmin(
@@ -101,18 +120,40 @@ export class HubServer {
101
120
  this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
102
121
  }
103
122
 
104
- return `http://127.0.0.1:${this.port}`;
123
+ this.initOnlineTracking();
124
+ this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
125
+
126
+ return `http://127.0.0.1:${hubPort}`;
105
127
  }
106
128
 
107
129
  async stop(): Promise<void> {
130
+ if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
108
131
  if (!this.server) return;
132
+
133
+ try {
134
+ const activeUsers = this.opts.store.listHubUsers("active");
135
+ const ownerId = this.authState.bootstrapAdminUserId || "";
136
+ for (const u of activeUsers) {
137
+ if (u.id === ownerId) continue;
138
+ try {
139
+ this.opts.store.insertHubNotification({
140
+ id: randomUUID(), userId: u.id, type: "hub_shutdown",
141
+ resource: "system", title: `Team server "${this.teamName}" has been shut down by the admin.`,
142
+ });
143
+ } catch { /* best-effort */ }
144
+ }
145
+ } catch { /* best-effort */ }
146
+
109
147
  const server = this.server;
110
148
  this.server = undefined;
111
149
  await new Promise<void>((resolve) => server.close(() => resolve()));
112
150
  }
113
151
 
114
152
  private get port(): number {
115
- return this.opts.config.sharing?.hub?.port ?? 18800;
153
+ const configured = this.opts.config.sharing?.hub?.port;
154
+ const derived = this.opts.defaultHubPort;
155
+ if (derived && (!configured || configured === 18800)) return derived;
156
+ return configured ?? 18800;
116
157
  }
117
158
 
118
159
  private get teamName(): string {
@@ -160,6 +201,20 @@ export class HubServer {
160
201
  });
161
202
  }
162
203
 
204
+ private embedSkillAsync(skillId: string, name: string, description: string, sourceUserId: string, sourceSkillId: string): void {
205
+ const embedder = this.opts.embedder;
206
+ if (!embedder) return;
207
+ const text = `${name}: ${description}`;
208
+ embedder.embed([text]).then((vectors) => {
209
+ if (vectors[0]) {
210
+ this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
211
+ this.opts.log.info(`hub: embedded shared skill ${skillId}`);
212
+ }
213
+ }).catch((err) => {
214
+ this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
215
+ });
216
+ }
217
+
163
218
  private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
164
219
  const embedder = this.opts.embedder;
165
220
  if (!embedder) return;
@@ -192,32 +247,74 @@ export class HubServer {
192
247
  return this.json(res, 403, { error: "invalid_team_token" });
193
248
  }
194
249
  const username = String(body.username || `user-${randomUUID().slice(0, 8)}`);
195
- const existingUsers = this.opts.store.listHubUsers();
196
- const existingUser = existingUsers.find(u => u.username === username);
250
+ const joinIp = (typeof body.clientIp === "string" && body.clientIp)
251
+ || (req.headers["x-client-ip"] as string)?.trim()
252
+ || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
253
+ || req.socket.remoteAddress || "";
254
+ const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
255
+
256
+ let existingUser = identityKey
257
+ ? this.userManager.findByIdentityKey(identityKey)
258
+ : null;
259
+ if (!existingUser) {
260
+ const existingUsers = this.opts.store.listHubUsers();
261
+ existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
262
+ }
263
+
197
264
  if (existingUser) {
265
+ try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
266
+
198
267
  if (existingUser.status === "active") {
199
268
  const token = issueUserToken(
200
269
  { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
201
270
  this.authSecret,
202
271
  );
203
272
  this.userManager.approveUser(existingUser.id, token);
204
- return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
273
+ if (identityKey && !existingUser.identityKey) {
274
+ this.opts.store.upsertHubUser({ ...existingUser, identityKey });
275
+ }
276
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
205
277
  }
206
278
  if (existingUser.status === "pending") {
207
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
279
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
280
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
208
281
  }
209
282
  if (existingUser.status === "rejected") {
210
- this.userManager.resetToPending(existingUser.id);
211
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
212
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
283
+ if (body.reapply === true) {
284
+ this.userManager.resetToPending(existingUser.id);
285
+ this.notifyAdmins("user_join_request", "user", username, "");
286
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
287
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
288
+ }
289
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
290
+ }
291
+ if (existingUser.status === "removed") {
292
+ this.userManager.rejoinUser(existingUser.id);
293
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
294
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
295
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
296
+ }
297
+ if (existingUser.status === "left") {
298
+ this.userManager.rejoinUser(existingUser.id);
299
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
300
+ this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
301
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
302
+ }
303
+ if (existingUser.status === "blocked") {
304
+ return this.json(res, 200, { status: "blocked", userId: existingUser.id });
213
305
  }
214
306
  }
307
+
308
+ const generatedIdentityKey = identityKey || randomUUID();
215
309
  const user = this.userManager.createPendingUser({
216
310
  username,
217
311
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
312
+ identityKey: generatedIdentityKey,
218
313
  });
314
+ try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
219
315
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
220
- return this.json(res, 200, { status: "pending", userId: user.id });
316
+ this.notifyAdmins("user_join_request", "user", username, "");
317
+ return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
221
318
  }
222
319
 
223
320
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
@@ -235,16 +332,42 @@ export class HubServer {
235
332
  if (user.status === "rejected") {
236
333
  return this.json(res, 200, { status: "rejected" });
237
334
  }
335
+ if (user.status === "blocked") {
336
+ return this.json(res, 200, { status: "blocked" });
337
+ }
338
+ if (user.status === "left") {
339
+ return this.json(res, 200, { status: "left" });
340
+ }
341
+ if (user.status === "removed") {
342
+ return this.json(res, 200, { status: "removed" });
343
+ }
238
344
  if (user.status === "active") {
239
345
  const token = issueUserToken(
240
346
  { userId: user.id, username: user.username, role: user.role, status: user.status },
241
347
  this.authSecret,
242
348
  );
349
+ this.userManager.approveUser(user.id, token);
243
350
  return this.json(res, 200, { status: "active", userToken: token });
244
351
  }
245
352
  return this.json(res, 200, { status: user.status });
246
353
  }
247
354
 
355
+ if (req.method === "POST" && routePath === "/api/v1/hub/withdraw-pending") {
356
+ const body = await this.readJson(req);
357
+ if (!body || body.teamToken !== this.teamToken) {
358
+ return this.json(res, 403, { error: "invalid_team_token" });
359
+ }
360
+ const userId = String(body.userId || "");
361
+ if (!userId) return this.json(res, 400, { error: "missing_user_id" });
362
+ const user = this.opts.store.getHubUser(userId);
363
+ if (!user) return this.json(res, 200, { ok: true });
364
+ if (user.status === "pending") {
365
+ this.userManager.markUserLeft(userId);
366
+ this.opts.log.info(`Hub: user "${user.username}" (${userId}) withdrew pending application`);
367
+ }
368
+ return this.json(res, 200, { ok: true });
369
+ }
370
+
248
371
  // All endpoints below require authentication + rate limiting
249
372
  const auth = this.authenticate(req);
250
373
  if (!auth) return this.json(res, 401, { error: "unauthorized" });
@@ -254,6 +377,18 @@ export class HubServer {
254
377
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
255
378
  }
256
379
 
380
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
381
+ return this.json(res, 200, { ok: true });
382
+ }
383
+
384
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
385
+ this.userManager.markUserLeft(auth.userId);
386
+ this.knownOnlineUsers.delete(auth.userId);
387
+ this.notifyAdmins("user_left", "user", auth.username, auth.userId);
388
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
389
+ return this.json(res, 200, { ok: true });
390
+ }
391
+
257
392
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
258
393
  const user = this.opts.store.getHubUser(auth.userId);
259
394
  if (!user) return this.json(res, 401, { error: "unauthorized" });
@@ -272,11 +407,17 @@ export class HubServer {
272
407
  }
273
408
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
274
409
  if (!updated) return this.json(res, 404, { error: "not_found" });
410
+ const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
275
411
  const newToken = issueUserToken(
276
412
  { userId: updated.id, username: newUsername, role: updated.role, status: updated.status },
277
413
  this.authSecret,
414
+ ttlMs,
278
415
  );
279
416
  this.userManager.approveUser(updated.id, newToken);
417
+ if (updated.id === this.authState.bootstrapAdminUserId) {
418
+ this.authState.bootstrapAdminToken = newToken;
419
+ this.saveAuthState();
420
+ }
280
421
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
281
422
  return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
282
423
  }
@@ -289,114 +430,136 @@ export class HubServer {
289
430
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/approve-user") {
290
431
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
291
432
  const body = await this.readJson(req);
292
- const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
293
- const approved = this.userManager.approveUser(String(body.userId), token);
433
+ const userId = String(body.userId);
434
+ const username = String(body.username || "");
435
+ const token = issueUserToken({ userId, username, role: "member", status: "active" }, this.authSecret);
436
+ const approved = this.userManager.approveUser(userId, token);
294
437
  if (!approved) return this.json(res, 404, { error: "not_found" });
438
+ try { this.opts.store.updateHubUserActivity(userId, ""); } catch { /* best-effort */ }
439
+ try {
440
+ this.opts.store.insertHubNotification({
441
+ id: randomUUID(), userId, type: "membership_approved",
442
+ resource: "user", title: `Your request to join team "${this.teamName}" has been approved. Welcome!`,
443
+ });
444
+ } catch { /* best-effort */ }
295
445
  return this.json(res, 200, { status: "active", token });
296
446
  }
297
447
 
298
448
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
299
449
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
300
450
  const body = await this.readJson(req);
301
- const rejected = this.userManager.rejectUser(String(body.userId));
451
+ const userId = String(body.userId);
452
+ const rejected = this.userManager.rejectUser(userId);
302
453
  if (!rejected) return this.json(res, 404, { error: "not_found" });
454
+ try {
455
+ this.opts.store.insertHubNotification({
456
+ id: randomUUID(), userId, type: "membership_rejected",
457
+ resource: "user", title: `Your request to join team "${this.teamName}" has been declined.`,
458
+ });
459
+ } catch { /* best-effort */ }
303
460
  return this.json(res, 200, { status: "rejected" });
304
461
  }
305
462
 
306
463
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
307
464
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
308
465
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
309
- return this.json(res, 200, { users: users.map(u => ({ id: u.id, username: u.username, role: u.role, status: u.status })) });
466
+ const contribs = this.opts.store.getHubUserContributions();
467
+ const ownerId = this.authState.bootstrapAdminUserId || "";
468
+ const now = Date.now();
469
+ return this.json(res, 200, { users: users.map(u => {
470
+ const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
471
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
472
+ return {
473
+ id: u.id, username: u.username, role: u.role, status: u.status,
474
+ deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
475
+ lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
476
+ isOwner: u.id === ownerId, isOnline,
477
+ memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
478
+ };
479
+ }) });
310
480
  }
311
481
 
312
- // ── Group management ──
313
-
314
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
482
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
315
483
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
316
- const groups = this.opts.store.listHubGroups();
317
- return this.json(res, 200, { groups });
484
+ const body = await this.readJson(req);
485
+ const userId = String(body?.userId || "");
486
+ const newRole = String(body?.role || "");
487
+ if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
488
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
489
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
490
+ }
491
+ const user = this.opts.store.getHubUser(userId);
492
+ if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
493
+ const updatedUser = { ...user, role: newRole as "admin" | "member" };
494
+ this.opts.store.upsertHubUser(updatedUser);
495
+ this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
496
+ try {
497
+ const notifType = newRole === "admin" ? "role_promoted" : "role_demoted";
498
+ this.opts.store.insertHubNotification({
499
+ id: randomUUID(), userId, type: notifType,
500
+ resource: "user", title: `Your role in team "${this.teamName}" has been changed to ${newRole}.`,
501
+ });
502
+ } catch { /* best-effort */ }
503
+ return this.json(res, 200, { ok: true, role: newRole });
318
504
  }
319
505
 
320
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
506
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
321
507
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
322
508
  const body = await this.readJson(req);
323
- const name = String(body.name || "").trim();
324
- if (!name) return this.json(res, 400, { error: "name_required" });
325
- const groupId = randomUUID();
326
- this.opts.store.upsertHubGroup({
327
- id: groupId,
328
- name,
329
- description: String(body.description || ""),
330
- createdAt: Date.now(),
331
- });
332
- return this.json(res, 201, { id: groupId, name });
333
- }
334
-
335
- const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
336
- if (groupDetailMatch) {
337
- const groupId = decodeURIComponent(groupDetailMatch[1]);
338
-
339
- if (req.method === "GET") {
340
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
341
- const group = this.opts.store.getHubGroupById(groupId);
342
- if (!group) return this.json(res, 404, { error: "not_found" });
343
- const members = this.opts.store.listHubGroupMembers(groupId);
344
- return this.json(res, 200, { ...group, members });
345
- }
346
-
347
- if (req.method === "PUT") {
348
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
349
- const existing = this.opts.store.getHubGroupById(groupId);
350
- if (!existing) return this.json(res, 404, { error: "not_found" });
351
- const body = await this.readJson(req);
352
- this.opts.store.upsertHubGroup({
353
- id: groupId,
354
- name: String(body.name || existing.name).trim(),
355
- description: String(body.description ?? existing.description),
356
- createdAt: existing.createdAt,
357
- });
358
- return this.json(res, 200, { ok: true });
509
+ const userId = String(body?.userId || "");
510
+ const newUsername = String(body?.username || "").trim();
511
+ if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
512
+ return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
359
513
  }
360
-
361
- if (req.method === "DELETE") {
362
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
363
- const deleted = this.opts.store.deleteHubGroup(groupId);
364
- if (!deleted) return this.json(res, 404, { error: "not_found" });
365
- return this.json(res, 200, { ok: true });
514
+ if (this.userManager.isUsernameTaken(newUsername, userId)) {
515
+ return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
366
516
  }
367
- }
368
-
369
- const groupMembersMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)\/members$/);
370
- if (groupMembersMatch) {
371
- const groupId = decodeURIComponent(groupMembersMatch[1]);
372
-
373
- if (req.method === "POST") {
374
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
375
- const group = this.opts.store.getHubGroupById(groupId);
376
- if (!group) return this.json(res, 404, { error: "group_not_found" });
377
- const body = await this.readJson(req);
378
- const userId = String(body.userId || "");
379
- if (!userId) return this.json(res, 400, { error: "userId_required" });
380
- const user = this.opts.store.getHubUser(userId);
381
- if (!user) return this.json(res, 404, { error: "user_not_found" });
382
- this.opts.store.addHubGroupMember(groupId, userId);
383
- return this.json(res, 200, { ok: true });
517
+ const user = this.opts.store.getHubUser(userId);
518
+ if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
519
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
520
+ const newToken = issueUserToken(
521
+ { userId: user.id, username: newUsername, role: user.role, status: user.status },
522
+ this.authSecret,
523
+ ttlMs,
524
+ );
525
+ this.userManager.approveUser(user.id, newToken);
526
+ const updated = this.opts.store.getHubUser(userId)!;
527
+ const finalUser = { ...updated, username: newUsername };
528
+ this.opts.store.upsertHubUser(finalUser);
529
+ if (userId === this.authState.bootstrapAdminUserId) {
530
+ this.authState.bootstrapAdminToken = newToken;
531
+ this.saveAuthState();
384
532
  }
533
+ this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
534
+ return this.json(res, 200, { ok: true, username: newUsername });
535
+ }
385
536
 
386
- if (req.method === "DELETE") {
387
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
388
- const body = await this.readJson(req);
389
- const userId = String(body.userId || "");
390
- if (!userId) return this.json(res, 400, { error: "userId_required" });
391
- this.opts.store.removeHubGroupMember(groupId, userId);
392
- return this.json(res, 200, { ok: true });
393
- }
537
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
538
+ if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
539
+ const body = await this.readJson(req);
540
+ const userId = String(body?.userId || "");
541
+ if (!userId) return this.json(res, 400, { error: "missing_user_id" });
542
+ if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
543
+ if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
544
+ try {
545
+ this.opts.store.insertHubNotification({
546
+ id: randomUUID(), userId, type: "membership_removed",
547
+ resource: "user", title: `You have been removed from team "${this.teamName}" by the admin.`,
548
+ });
549
+ } catch { /* best-effort */ }
550
+ const cleanResources = body?.cleanResources === true;
551
+ const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
552
+ if (!deleted) return this.json(res, 404, { error: "not_found" });
553
+ this.knownOnlineUsers.delete(userId);
554
+ this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
555
+ return this.json(res, 200, { ok: true });
394
556
  }
395
557
 
396
558
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
397
559
  const body = await this.readJson(req);
398
560
  if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
399
561
  const task = { ...body.task, sourceUserId: auth.userId };
562
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
400
563
  this.opts.store.upsertHubTask(task);
401
564
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
402
565
  const chunkIds: string[] = [];
@@ -404,16 +567,23 @@ export class HubServer {
404
567
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
405
568
  chunkIds.push(chunk.id);
406
569
  }
407
- // Async embedding: don't block the response
408
570
  if (this.opts.embedder && chunkIds.length > 0) {
409
571
  this.embedChunksAsync(chunkIds, chunks);
410
572
  }
573
+ if (!existingTask) {
574
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
575
+ }
411
576
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
412
577
  }
413
578
 
414
579
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
415
580
  const body = await this.readJson(req);
416
- this.opts.store.deleteHubTaskBySource(auth.userId, String(body.sourceTaskId));
581
+ const srcTaskId = String(body.sourceTaskId);
582
+ const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
583
+ this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
584
+ if (existing) {
585
+ this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
586
+ }
417
587
  return this.json(res, 200, { ok: true });
418
588
  }
419
589
 
@@ -444,6 +614,9 @@ export class HubServer {
444
614
  if (this.opts.embedder) {
445
615
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
446
616
  }
617
+ if (!existing) {
618
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
619
+ }
447
620
  return this.json(res, 200, { ok: true, memoryId, visibility });
448
621
  }
449
622
 
@@ -451,7 +624,11 @@ export class HubServer {
451
624
  const body = await this.readJson(req);
452
625
  const sourceChunkId = String(body?.sourceChunkId || "");
453
626
  if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
627
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
454
628
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
629
+ if (existing) {
630
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
631
+ }
455
632
  return this.json(res, 200, { ok: true });
456
633
  }
457
634
 
@@ -563,19 +740,70 @@ export class HubServer {
563
740
  }
564
741
 
565
742
  if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
566
- const hits = this.opts.store.searchHubSkills(String(url.searchParams.get("query") || ""), {
743
+ const skillQuery = String(url.searchParams.get("query") || "");
744
+ const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
745
+ const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
567
746
  userId: auth.userId,
568
- maxResults: Number(url.searchParams.get("maxResults") || 10),
569
- }).map(({ hit }) => ({
570
- skillId: hit.id,
571
- name: hit.name,
572
- description: hit.description,
573
- version: hit.version,
574
- visibility: hit.visibility,
575
- groupName: hit.group_name,
576
- ownerName: hit.owner_name || "unknown",
577
- qualityScore: hit.quality_score,
578
- }));
747
+ maxResults: skillMaxResults * 2,
748
+ });
749
+
750
+ let mergedSkillIds: string[];
751
+ if (this.opts.embedder && skillQuery) {
752
+ try {
753
+ const [queryVec] = await this.opts.embedder.embed([skillQuery]);
754
+ if (queryVec) {
755
+ const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
756
+ const cosineSim = (vec: Float32Array) => {
757
+ let dot = 0, nA = 0, nB = 0;
758
+ for (let i = 0; i < queryVec.length && i < vec.length; i++) {
759
+ dot += queryVec[i] * vec[i]; nA += queryVec[i] * queryVec[i]; nB += vec[i] * vec[i];
760
+ }
761
+ return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
762
+ };
763
+ const vecScored = skillEmbs
764
+ .map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
765
+ .filter(e => e.score > 0.3)
766
+ .sort((a, b) => b.score - a.score)
767
+ .slice(0, skillMaxResults * 2);
768
+
769
+ const K = 60;
770
+ const rrfScores = new Map<string, number>();
771
+ ftsSkillHits.forEach(({ hit }, idx) => {
772
+ rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
773
+ });
774
+ vecScored.forEach(({ id }, idx) => {
775
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
776
+ });
777
+ mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
778
+ } else {
779
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
780
+ }
781
+ } catch {
782
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
783
+ }
784
+ } else {
785
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
786
+ }
787
+
788
+ const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
789
+ const hits = mergedSkillIds.map(id => {
790
+ const hit = ftsSkillMap.get(id);
791
+ if (hit) {
792
+ return {
793
+ skillId: hit.id, name: hit.name, description: hit.description,
794
+ version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
795
+ ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
796
+ qualityScore: hit.quality_score,
797
+ };
798
+ }
799
+ const skill = this.opts.store.getHubSkillById(id);
800
+ if (!skill) return null;
801
+ return {
802
+ skillId: skill.id, name: skill.name, description: skill.description,
803
+ version: skill.version, visibility: skill.visibility, groupName: "",
804
+ ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
805
+ };
806
+ }).filter(Boolean);
579
807
  return this.json(res, 200, { hits });
580
808
  }
581
809
 
@@ -601,6 +829,10 @@ export class HubServer {
601
829
  createdAt: existing?.createdAt ?? Date.now(),
602
830
  updatedAt: Date.now(),
603
831
  });
832
+ this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
833
+ if (!existing) {
834
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
835
+ }
604
836
  return this.json(res, 200, { ok: true, skillId, visibility });
605
837
  }
606
838
 
@@ -623,7 +855,12 @@ export class HubServer {
623
855
 
624
856
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
625
857
  const body = await this.readJson(req);
626
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
858
+ const srcSkillId = String(body?.sourceSkillId || "");
859
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
860
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
861
+ if (existing) {
862
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
863
+ }
627
864
  return this.json(res, 200, { ok: true });
628
865
  }
629
866
 
@@ -635,12 +872,48 @@ export class HubServer {
635
872
  return this.json(res, 200, { tasks });
636
873
  }
637
874
 
875
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
876
+ if (hubTaskDetailMatch) {
877
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
878
+ const task = this.opts.store.getHubTaskById(taskId);
879
+ if (!task) return this.json(res, 404, { error: "not_found" });
880
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
881
+ return this.json(res, 200, {
882
+ id: task.id, title: task.title, summary: task.summary,
883
+ startedAt: task.createdAt, endedAt: task.updatedAt,
884
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
885
+ });
886
+ }
887
+
888
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
889
+ if (hubSkillDetailMatch) {
890
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
891
+ const skill = this.opts.store.getHubSkillById(skillId);
892
+ if (!skill) return this.json(res, 404, { error: "not_found" });
893
+ let files: Array<{ path: string; type: string; size: number }> = [];
894
+ try {
895
+ const bundle = JSON.parse(skill.bundle || "{}");
896
+ if (Array.isArray(bundle.files)) {
897
+ files = bundle.files.map((f: any) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
898
+ }
899
+ } catch { /* ignore parse error */ }
900
+ return this.json(res, 200, {
901
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
902
+ files,
903
+ versions: [],
904
+ });
905
+ }
906
+
638
907
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
639
908
  if (adminTaskDeleteMatch) {
640
909
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
641
910
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
911
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
642
912
  const deleted = this.opts.store.deleteHubTaskById(taskId);
643
913
  if (!deleted) return this.json(res, 404, { error: "not_found" });
914
+ if (taskInfo) {
915
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
916
+ }
644
917
  return this.json(res, 200, { ok: true });
645
918
  }
646
919
 
@@ -654,8 +927,12 @@ export class HubServer {
654
927
  if (adminSkillDeleteMatch) {
655
928
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
656
929
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
930
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
657
931
  const deleted = this.opts.store.deleteHubSkillById(skillId);
658
932
  if (!deleted) return this.json(res, 404, { error: "not_found" });
933
+ if (skillInfo) {
934
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
935
+ }
659
936
  return this.json(res, 200, { ok: true });
660
937
  }
661
938
 
@@ -669,8 +946,23 @@ export class HubServer {
669
946
  if (adminMemoryDeleteMatch) {
670
947
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
671
948
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
949
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
672
950
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
673
951
  if (!deleted) return this.json(res, 404, { error: "not_found" });
952
+ if (memInfo) {
953
+ const payload = JSON.stringify({
954
+ memoryId,
955
+ sourceChunkId: memInfo.sourceChunkId,
956
+ });
957
+ this.opts.store.insertHubNotification({
958
+ id: randomUUID(),
959
+ userId: memInfo.sourceUserId,
960
+ type: "resource_removed",
961
+ resource: "memory",
962
+ title: memInfo.summary || memInfo.id,
963
+ message: payload,
964
+ });
965
+ }
674
966
  return this.json(res, 200, { ok: true });
675
967
  }
676
968
 
@@ -693,9 +985,87 @@ export class HubServer {
693
985
  });
694
986
  }
695
987
 
988
+ if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
989
+ const unread = (new URL(req.url!, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
990
+ const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
991
+ const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
992
+ return this.json(res, 200, { notifications: list, unreadCount });
993
+ }
994
+
995
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
996
+ const body = await this.readJson(req);
997
+ const ids = Array.isArray(body.ids) ? body.ids as string[] : undefined;
998
+ this.opts.store.markHubNotificationsRead(auth.userId, ids);
999
+ return this.json(res, 200, { ok: true });
1000
+ }
1001
+
1002
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
1003
+ this.opts.store.clearHubNotifications(auth.userId);
1004
+ return this.json(res, 200, { ok: true });
1005
+ }
1006
+
696
1007
  return this.json(res, 404, { error: "not_found" });
697
1008
  }
698
1009
 
1010
+ private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
1011
+ try {
1012
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
1013
+ for (const admin of admins) {
1014
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
1015
+ continue;
1016
+ }
1017
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
1018
+ }
1019
+ } catch { /* best-effort */ }
1020
+ }
1021
+
1022
+ private initOnlineTracking(): void {
1023
+ try {
1024
+ const ownerId = this.authState.bootstrapAdminUserId || "";
1025
+ const users = this.opts.store.listHubUsers("active");
1026
+ const now = Date.now();
1027
+ for (const u of users) {
1028
+ if (u.id === ownerId) continue;
1029
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
1030
+ this.knownOnlineUsers.add(u.id);
1031
+ }
1032
+ }
1033
+ } catch { /* best-effort */ }
1034
+ }
1035
+
1036
+ private checkOfflineUsers(): void {
1037
+ try {
1038
+ const ownerId = this.authState.bootstrapAdminUserId || "";
1039
+ const users = this.opts.store.listHubUsers("active");
1040
+ const now = Date.now();
1041
+ const currentlyOnline = new Set<string>();
1042
+ for (const u of users) {
1043
+ if (u.id === ownerId) continue;
1044
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
1045
+ currentlyOnline.add(u.id);
1046
+ }
1047
+ }
1048
+ for (const uid of this.knownOnlineUsers) {
1049
+ if (!currentlyOnline.has(uid)) {
1050
+ const user = users.find(u => u.id === uid);
1051
+ if (user) {
1052
+ this.notifyAdmins("user_offline", "user", user.username, uid);
1053
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
1054
+ }
1055
+ }
1056
+ }
1057
+ for (const uid of currentlyOnline) {
1058
+ if (!this.knownOnlineUsers.has(uid)) {
1059
+ const user = users.find(u => u.id === uid);
1060
+ if (user) {
1061
+ this.notifyAdmins("user_online", "user", user.username, uid);
1062
+ }
1063
+ }
1064
+ }
1065
+ this.knownOnlineUsers = currentlyOnline;
1066
+ } catch { /* best-effort */ }
1067
+ }
1068
+
699
1069
  private authenticate(req: http.IncomingMessage) {
700
1070
  const header = req.headers.authorization;
701
1071
  if (!header || !header.startsWith("Bearer ")) return null;
@@ -706,6 +1076,10 @@ export class HubServer {
706
1076
  if (!user || user.status !== "active") return null;
707
1077
  const hash = createHash("sha256").update(token).digest("hex");
708
1078
  if (user.tokenHash !== hash) return null;
1079
+ const clientIp = (req.headers["x-client-ip"] as string)?.trim()
1080
+ || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
1081
+ || req.socket.remoteAddress || "";
1082
+ try { this.opts.store.updateHubUserActivity(user.id, clientIp); } catch { /* best-effort */ }
709
1083
  return {
710
1084
  userId: user.id,
711
1085
  username: user.username,