@memtensor/memos-local-openclaw-plugin 1.0.4-beta.1 → 1.0.4-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.env.example +7 -0
  2. package/README.md +24 -24
  3. package/dist/capture/index.d.ts +1 -1
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +34 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +1 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +93 -26
  10. package/dist/client/connector.js.map +1 -1
  11. package/dist/client/hub.d.ts.map +1 -1
  12. package/dist/client/hub.js +22 -0
  13. package/dist/client/hub.js.map +1 -1
  14. package/dist/client/skill-sync.d.ts +7 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -1
  16. package/dist/client/skill-sync.js +10 -0
  17. package/dist/client/skill-sync.js.map +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +0 -2
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +7 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +277 -87
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +2 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +5 -1
  28. package/dist/hub/user-manager.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest/providers/index.d.ts.map +1 -1
  33. package/dist/ingest/providers/index.js +37 -6
  34. package/dist/ingest/providers/index.js.map +1 -1
  35. package/dist/recall/engine.d.ts.map +1 -1
  36. package/dist/recall/engine.js +91 -1
  37. package/dist/recall/engine.js.map +1 -1
  38. package/dist/shared/llm-call.d.ts +1 -0
  39. package/dist/shared/llm-call.d.ts.map +1 -1
  40. package/dist/shared/llm-call.js +82 -8
  41. package/dist/shared/llm-call.js.map +1 -1
  42. package/dist/sharing/types.d.ts +1 -1
  43. package/dist/sharing/types.d.ts.map +1 -1
  44. package/dist/skill/evolver.d.ts +2 -0
  45. package/dist/skill/evolver.d.ts.map +1 -1
  46. package/dist/skill/evolver.js +3 -0
  47. package/dist/skill/evolver.js.map +1 -1
  48. package/dist/storage/ensure-binding.d.ts +12 -0
  49. package/dist/storage/ensure-binding.d.ts.map +1 -0
  50. package/dist/storage/ensure-binding.js +53 -0
  51. package/dist/storage/ensure-binding.js.map +1 -0
  52. package/dist/storage/sqlite.d.ts +74 -20
  53. package/dist/storage/sqlite.d.ts.map +1 -1
  54. package/dist/storage/sqlite.js +286 -118
  55. package/dist/storage/sqlite.js.map +1 -1
  56. package/dist/telemetry.d.ts +12 -5
  57. package/dist/telemetry.d.ts.map +1 -1
  58. package/dist/telemetry.js +156 -40
  59. package/dist/telemetry.js.map +1 -1
  60. package/dist/tools/memory-search.d.ts +3 -1
  61. package/dist/tools/memory-search.d.ts.map +1 -1
  62. package/dist/tools/memory-search.js +3 -1
  63. package/dist/tools/memory-search.js.map +1 -1
  64. package/dist/types.d.ts +1 -2
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/viewer/html.d.ts.map +1 -1
  68. package/dist/viewer/html.js +2660 -889
  69. package/dist/viewer/html.js.map +1 -1
  70. package/dist/viewer/server.d.ts +30 -8
  71. package/dist/viewer/server.d.ts.map +1 -1
  72. package/dist/viewer/server.js +965 -193
  73. package/dist/viewer/server.js.map +1 -1
  74. package/index.ts +384 -43
  75. package/openclaw.plugin.json +1 -1
  76. package/package.json +3 -2
  77. package/scripts/postinstall.cjs +1 -1
  78. package/skill/memos-memory-guide/SKILL.md +64 -26
  79. package/src/capture/index.ts +37 -1
  80. package/src/client/connector.ts +91 -28
  81. package/src/client/hub.ts +18 -0
  82. package/src/client/skill-sync.ts +14 -0
  83. package/src/config.ts +0 -2
  84. package/src/hub/server.ts +259 -78
  85. package/src/hub/user-manager.ts +7 -3
  86. package/src/index.ts +10 -2
  87. package/src/ingest/providers/index.ts +41 -7
  88. package/src/recall/engine.ts +84 -1
  89. package/src/shared/llm-call.ts +97 -9
  90. package/src/sharing/types.ts +1 -1
  91. package/src/skill/evolver.ts +5 -0
  92. package/src/storage/ensure-binding.ts +52 -0
  93. package/src/storage/sqlite.ts +295 -144
  94. package/src/telemetry.ts +172 -41
  95. package/src/tools/memory-search.ts +2 -1
  96. package/src/types.ts +1 -2
  97. package/src/viewer/html.ts +2660 -889
  98. package/src/viewer/server.ts +888 -177
@@ -51,6 +51,10 @@ class HubServer {
51
51
  static RATE_LIMIT_DEFAULT = 60;
52
52
  static RATE_LIMIT_SEARCH = 30;
53
53
  rateBuckets = new Map();
54
+ static OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
55
+ static OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
56
+ offlineCheckTimer;
57
+ knownOnlineUsers = new Set();
54
58
  constructor(opts) {
55
59
  this.opts = opts;
56
60
  this.userManager = new user_manager_1.HubUserManager(opts.store, opts.log);
@@ -109,9 +113,15 @@ class HubServer {
109
113
  this.saveAuthState();
110
114
  this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
111
115
  }
116
+ this.initOnlineTracking();
117
+ this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
112
118
  return `http://127.0.0.1:${this.port}`;
113
119
  }
114
120
  async stop() {
121
+ if (this.offlineCheckTimer) {
122
+ clearInterval(this.offlineCheckTimer);
123
+ this.offlineCheckTimer = undefined;
124
+ }
115
125
  if (!this.server)
116
126
  return;
117
127
  const server = this.server;
@@ -193,20 +203,39 @@ class HubServer {
193
203
  return this.json(res, 403, { error: "invalid_team_token" });
194
204
  }
195
205
  const username = String(body.username || `user-${(0, crypto_1.randomUUID)().slice(0, 8)}`);
206
+ const joinIp = (typeof body.clientIp === "string" && body.clientIp)
207
+ || req.headers["x-client-ip"]?.trim()
208
+ || req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
209
+ || req.socket.remoteAddress || "";
196
210
  const existingUsers = this.opts.store.listHubUsers();
197
211
  const existingUser = existingUsers.find(u => u.username === username);
198
212
  if (existingUser) {
213
+ try {
214
+ this.opts.store.updateHubUserActivity(existingUser.id, joinIp);
215
+ }
216
+ catch { /* best-effort */ }
199
217
  if (existingUser.status === "active") {
200
218
  const token = (0, auth_1.issueUserToken)({ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" }, this.authSecret);
201
219
  this.userManager.approveUser(existingUser.id, token);
202
220
  return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
203
221
  }
204
222
  if (existingUser.status === "pending") {
223
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
205
224
  return this.json(res, 200, { status: "pending", userId: existingUser.id });
206
225
  }
207
226
  if (existingUser.status === "rejected") {
227
+ if (body.reapply === true) {
228
+ this.userManager.resetToPending(existingUser.id);
229
+ this.notifyAdmins("user_join_request", "user", username, "");
230
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
231
+ return this.json(res, 200, { status: "pending", userId: existingUser.id });
232
+ }
233
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
234
+ }
235
+ if (existingUser.status === "removed") {
208
236
  this.userManager.resetToPending(existingUser.id);
209
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
237
+ this.notifyAdmins("user_join_request", "user", username, "");
238
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied, reset to pending`);
210
239
  return this.json(res, 200, { status: "pending", userId: existingUser.id });
211
240
  }
212
241
  }
@@ -214,7 +243,12 @@ class HubServer {
214
243
  username,
215
244
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
216
245
  });
246
+ try {
247
+ this.opts.store.updateHubUserActivity(user.id, joinIp);
248
+ }
249
+ catch { /* best-effort */ }
217
250
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
251
+ this.notifyAdmins("user_join_request", "user", username, "");
218
252
  return this.json(res, 200, { status: "pending", userId: user.id });
219
253
  }
220
254
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
@@ -248,6 +282,19 @@ class HubServer {
248
282
  if (!this.checkRateLimit(auth.userId, endpointKey)) {
249
283
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
250
284
  }
285
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
286
+ return this.json(res, 200, { ok: true });
287
+ }
288
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
289
+ try {
290
+ this.opts.store.updateHubUserActivity(auth.userId, "", 0);
291
+ }
292
+ catch { /* best-effort */ }
293
+ this.knownOnlineUsers.delete(auth.userId);
294
+ this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
295
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
296
+ return this.json(res, 200, { ok: true });
297
+ }
251
298
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
252
299
  const user = this.opts.store.getHubUser(auth.userId);
253
300
  if (!user)
@@ -268,7 +315,8 @@ class HubServer {
268
315
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
269
316
  if (!updated)
270
317
  return this.json(res, 404, { error: "not_found" });
271
- const newToken = (0, auth_1.issueUserToken)({ userId: updated.id, username: newUsername, role: updated.role, status: updated.status }, this.authSecret);
318
+ const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
319
+ const newToken = (0, auth_1.issueUserToken)({ userId: updated.id, username: newUsername, role: updated.role, status: updated.status }, this.authSecret, ttlMs);
272
320
  this.userManager.approveUser(updated.id, newToken);
273
321
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
274
322
  return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
@@ -286,6 +334,10 @@ class HubServer {
286
334
  const approved = this.userManager.approveUser(String(body.userId), token);
287
335
  if (!approved)
288
336
  return this.json(res, 404, { error: "not_found" });
337
+ try {
338
+ this.opts.store.updateHubUserActivity(String(body.userId), "");
339
+ }
340
+ catch { /* best-effort */ }
289
341
  return this.json(res, 200, { status: "active", token });
290
342
  }
291
343
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
@@ -301,102 +353,88 @@ class HubServer {
301
353
  if (auth.role !== "admin")
302
354
  return this.json(res, 403, { error: "forbidden" });
303
355
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
304
- return this.json(res, 200, { users: users.map(u => ({ id: u.id, username: u.username, role: u.role, status: u.status })) });
356
+ const contribs = this.opts.store.getHubUserContributions();
357
+ const ownerId = this.authState.bootstrapAdminUserId || "";
358
+ const now = Date.now();
359
+ return this.json(res, 200, { users: users.map(u => {
360
+ const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
361
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
362
+ return {
363
+ id: u.id, username: u.username, role: u.role, status: u.status,
364
+ deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
365
+ lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
366
+ isOwner: u.id === ownerId, isOnline,
367
+ memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
368
+ };
369
+ }) });
305
370
  }
306
- // ── Group management ──
307
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
371
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
308
372
  if (auth.role !== "admin")
309
373
  return this.json(res, 403, { error: "forbidden" });
310
- const groups = this.opts.store.listHubGroups();
311
- return this.json(res, 200, { groups });
374
+ const body = await this.readJson(req);
375
+ const userId = String(body?.userId || "");
376
+ const newRole = String(body?.role || "");
377
+ if (!userId || (newRole !== "admin" && newRole !== "member"))
378
+ return this.json(res, 400, { error: "invalid_params" });
379
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
380
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
381
+ }
382
+ const user = this.opts.store.getHubUser(userId);
383
+ if (!user || user.status !== "active")
384
+ return this.json(res, 404, { error: "not_found" });
385
+ const updatedUser = { ...user, role: newRole };
386
+ this.opts.store.upsertHubUser(updatedUser);
387
+ this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
388
+ return this.json(res, 200, { ok: true, role: newRole });
312
389
  }
313
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
390
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
314
391
  if (auth.role !== "admin")
315
392
  return this.json(res, 403, { error: "forbidden" });
316
393
  const body = await this.readJson(req);
317
- const name = String(body.name || "").trim();
318
- if (!name)
319
- return this.json(res, 400, { error: "name_required" });
320
- const groupId = (0, crypto_1.randomUUID)();
321
- this.opts.store.upsertHubGroup({
322
- id: groupId,
323
- name,
324
- description: String(body.description || ""),
325
- createdAt: Date.now(),
326
- });
327
- return this.json(res, 201, { id: groupId, name });
328
- }
329
- const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
330
- if (groupDetailMatch) {
331
- const groupId = decodeURIComponent(groupDetailMatch[1]);
332
- if (req.method === "GET") {
333
- if (auth.role !== "admin")
334
- return this.json(res, 403, { error: "forbidden" });
335
- const group = this.opts.store.getHubGroupById(groupId);
336
- if (!group)
337
- return this.json(res, 404, { error: "not_found" });
338
- const members = this.opts.store.listHubGroupMembers(groupId);
339
- return this.json(res, 200, { ...group, members });
340
- }
341
- if (req.method === "PUT") {
342
- if (auth.role !== "admin")
343
- return this.json(res, 403, { error: "forbidden" });
344
- const existing = this.opts.store.getHubGroupById(groupId);
345
- if (!existing)
346
- return this.json(res, 404, { error: "not_found" });
347
- const body = await this.readJson(req);
348
- this.opts.store.upsertHubGroup({
349
- id: groupId,
350
- name: String(body.name || existing.name).trim(),
351
- description: String(body.description ?? existing.description),
352
- createdAt: existing.createdAt,
353
- });
354
- return this.json(res, 200, { ok: true });
355
- }
356
- if (req.method === "DELETE") {
357
- if (auth.role !== "admin")
358
- return this.json(res, 403, { error: "forbidden" });
359
- const deleted = this.opts.store.deleteHubGroup(groupId);
360
- if (!deleted)
361
- return this.json(res, 404, { error: "not_found" });
362
- return this.json(res, 200, { ok: true });
363
- }
364
- }
365
- const groupMembersMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)\/members$/);
366
- if (groupMembersMatch) {
367
- const groupId = decodeURIComponent(groupMembersMatch[1]);
368
- if (req.method === "POST") {
369
- if (auth.role !== "admin")
370
- return this.json(res, 403, { error: "forbidden" });
371
- const group = this.opts.store.getHubGroupById(groupId);
372
- if (!group)
373
- return this.json(res, 404, { error: "group_not_found" });
374
- const body = await this.readJson(req);
375
- const userId = String(body.userId || "");
376
- if (!userId)
377
- return this.json(res, 400, { error: "userId_required" });
378
- const user = this.opts.store.getHubUser(userId);
379
- if (!user)
380
- return this.json(res, 404, { error: "user_not_found" });
381
- this.opts.store.addHubGroupMember(groupId, userId);
382
- return this.json(res, 200, { ok: true });
383
- }
384
- if (req.method === "DELETE") {
385
- if (auth.role !== "admin")
386
- return this.json(res, 403, { error: "forbidden" });
387
- const body = await this.readJson(req);
388
- const userId = String(body.userId || "");
389
- if (!userId)
390
- return this.json(res, 400, { error: "userId_required" });
391
- this.opts.store.removeHubGroupMember(groupId, userId);
392
- return this.json(res, 200, { ok: true });
394
+ const userId = String(body?.userId || "");
395
+ const newUsername = String(body?.username || "").trim();
396
+ if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
397
+ return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
393
398
  }
399
+ if (this.userManager.isUsernameTaken(newUsername, userId)) {
400
+ return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
401
+ }
402
+ const user = this.opts.store.getHubUser(userId);
403
+ if (!user || user.status !== "active")
404
+ return this.json(res, 404, { error: "not_found" });
405
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
406
+ const newToken = (0, auth_1.issueUserToken)({ userId: user.id, username: newUsername, role: user.role, status: user.status }, this.authSecret, ttlMs);
407
+ this.userManager.approveUser(user.id, newToken);
408
+ const updated = this.opts.store.getHubUser(userId);
409
+ const finalUser = { ...updated, username: newUsername };
410
+ this.opts.store.upsertHubUser(finalUser);
411
+ this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
412
+ return this.json(res, 200, { ok: true, username: newUsername });
413
+ }
414
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
415
+ if (auth.role !== "admin")
416
+ return this.json(res, 403, { error: "forbidden" });
417
+ const body = await this.readJson(req);
418
+ const userId = String(body?.userId || "");
419
+ if (!userId)
420
+ return this.json(res, 400, { error: "missing_user_id" });
421
+ if (userId === auth.userId)
422
+ return this.json(res, 400, { error: "cannot_remove_self" });
423
+ if (userId === this.authState.bootstrapAdminUserId)
424
+ return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
425
+ const cleanResources = body?.cleanResources === true;
426
+ const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
427
+ if (!deleted)
428
+ return this.json(res, 404, { error: "not_found" });
429
+ this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
430
+ return this.json(res, 200, { ok: true });
394
431
  }
395
432
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
396
433
  const body = await this.readJson(req);
397
434
  if (!body?.task)
398
435
  return this.json(res, 400, { error: "invalid_payload" });
399
436
  const task = { ...body.task, sourceUserId: auth.userId };
437
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
400
438
  this.opts.store.upsertHubTask(task);
401
439
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
402
440
  const chunkIds = [];
@@ -404,15 +442,22 @@ class HubServer {
404
442
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
405
443
  chunkIds.push(chunk.id);
406
444
  }
407
- // Async embedding: don't block the response
408
445
  if (this.opts.embedder && chunkIds.length > 0) {
409
446
  this.embedChunksAsync(chunkIds, chunks);
410
447
  }
448
+ if (!existingTask) {
449
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
450
+ }
411
451
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
412
452
  }
413
453
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
414
454
  const body = await this.readJson(req);
415
- this.opts.store.deleteHubTaskBySource(auth.userId, String(body.sourceTaskId));
455
+ const srcTaskId = String(body.sourceTaskId);
456
+ const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
457
+ this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
458
+ if (existing) {
459
+ this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
460
+ }
416
461
  return this.json(res, 200, { ok: true });
417
462
  }
418
463
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/share") {
@@ -444,6 +489,9 @@ class HubServer {
444
489
  if (this.opts.embedder) {
445
490
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
446
491
  }
492
+ if (!existing) {
493
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
494
+ }
447
495
  return this.json(res, 200, { ok: true, memoryId, visibility });
448
496
  }
449
497
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/unshare") {
@@ -451,7 +499,11 @@ class HubServer {
451
499
  const sourceChunkId = String(body?.sourceChunkId || "");
452
500
  if (!sourceChunkId)
453
501
  return this.json(res, 400, { error: "missing_source_chunk_id" });
502
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
454
503
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
504
+ if (existing) {
505
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
506
+ }
455
507
  return this.json(res, 200, { ok: true });
456
508
  }
457
509
  if (req.method === "GET" && routePath === "/api/v1/hub/memories") {
@@ -576,6 +628,7 @@ class HubServer {
576
628
  visibility: hit.visibility,
577
629
  groupName: hit.group_name,
578
630
  ownerName: hit.owner_name || "unknown",
631
+ ownerStatus: hit.owner_status || "",
579
632
  qualityScore: hit.quality_score,
580
633
  }));
581
634
  return this.json(res, 200, { hits });
@@ -603,6 +656,9 @@ class HubServer {
603
656
  createdAt: existing?.createdAt ?? Date.now(),
604
657
  updatedAt: Date.now(),
605
658
  });
659
+ if (!existing) {
660
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
661
+ }
606
662
  return this.json(res, 200, { ok: true, skillId, visibility });
607
663
  }
608
664
  const skillBundleMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/skills\/([^/]+)\/bundle$/) : null;
@@ -624,7 +680,12 @@ class HubServer {
624
680
  }
625
681
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
626
682
  const body = await this.readJson(req);
627
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
683
+ const srcSkillId = String(body?.sourceSkillId || "");
684
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
685
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
686
+ if (existing) {
687
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
688
+ }
628
689
  return this.json(res, 200, { ok: true });
629
690
  }
630
691
  // ── Admin: shared tasks & skills management ──
@@ -634,14 +695,51 @@ class HubServer {
634
695
  const tasks = this.opts.store.listAllHubTasks();
635
696
  return this.json(res, 200, { tasks });
636
697
  }
698
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
699
+ if (hubTaskDetailMatch) {
700
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
701
+ const task = this.opts.store.getHubTaskById(taskId);
702
+ if (!task)
703
+ return this.json(res, 404, { error: "not_found" });
704
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
705
+ return this.json(res, 200, {
706
+ id: task.id, title: task.title, summary: task.summary,
707
+ startedAt: task.createdAt, endedAt: task.updatedAt,
708
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
709
+ });
710
+ }
711
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
712
+ if (hubSkillDetailMatch) {
713
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
714
+ const skill = this.opts.store.getHubSkillById(skillId);
715
+ if (!skill)
716
+ return this.json(res, 404, { error: "not_found" });
717
+ let files = [];
718
+ try {
719
+ const bundle = JSON.parse(skill.bundle || "{}");
720
+ if (Array.isArray(bundle.files)) {
721
+ files = bundle.files.map((f) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
722
+ }
723
+ }
724
+ catch { /* ignore parse error */ }
725
+ return this.json(res, 200, {
726
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
727
+ files,
728
+ versions: [],
729
+ });
730
+ }
637
731
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
638
732
  if (adminTaskDeleteMatch) {
639
733
  if (auth.role !== "admin")
640
734
  return this.json(res, 403, { error: "forbidden" });
641
735
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
736
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
642
737
  const deleted = this.opts.store.deleteHubTaskById(taskId);
643
738
  if (!deleted)
644
739
  return this.json(res, 404, { error: "not_found" });
740
+ if (taskInfo) {
741
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
742
+ }
645
743
  return this.json(res, 200, { ok: true });
646
744
  }
647
745
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-skills") {
@@ -655,9 +753,13 @@ class HubServer {
655
753
  if (auth.role !== "admin")
656
754
  return this.json(res, 403, { error: "forbidden" });
657
755
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
756
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
658
757
  const deleted = this.opts.store.deleteHubSkillById(skillId);
659
758
  if (!deleted)
660
759
  return this.json(res, 404, { error: "not_found" });
760
+ if (skillInfo) {
761
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
762
+ }
661
763
  return this.json(res, 200, { ok: true });
662
764
  }
663
765
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-memories") {
@@ -671,9 +773,13 @@ class HubServer {
671
773
  if (auth.role !== "admin")
672
774
  return this.json(res, 403, { error: "forbidden" });
673
775
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
776
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
674
777
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
675
778
  if (!deleted)
676
779
  return this.json(res, 404, { error: "not_found" });
780
+ if (memInfo) {
781
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
782
+ }
677
783
  return this.json(res, 200, { ok: true });
678
784
  }
679
785
  if (req.method === "POST" && routePath === "/api/v1/hub/memory-detail") {
@@ -698,8 +804,85 @@ class HubServer {
698
804
  source: { ts: chunk.createdAt, role: chunk.role },
699
805
  });
700
806
  }
807
+ if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
808
+ const unread = (new URL(req.url, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
809
+ const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
810
+ const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
811
+ return this.json(res, 200, { notifications: list, unreadCount });
812
+ }
813
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
814
+ const body = await this.readJson(req);
815
+ const ids = Array.isArray(body.ids) ? body.ids : undefined;
816
+ this.opts.store.markHubNotificationsRead(auth.userId, ids);
817
+ return this.json(res, 200, { ok: true });
818
+ }
819
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
820
+ this.opts.store.clearHubNotifications(auth.userId);
821
+ return this.json(res, 200, { ok: true });
822
+ }
701
823
  return this.json(res, 404, { error: "not_found" });
702
824
  }
825
+ notifyAdmins(type, resource, title, fromUserId, opts) {
826
+ try {
827
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
828
+ for (const admin of admins) {
829
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
830
+ continue;
831
+ }
832
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: admin.id, type, resource, title });
833
+ }
834
+ }
835
+ catch { /* best-effort */ }
836
+ }
837
+ initOnlineTracking() {
838
+ try {
839
+ const ownerId = this.authState.bootstrapAdminUserId || "";
840
+ const users = this.opts.store.listHubUsers("active");
841
+ const now = Date.now();
842
+ for (const u of users) {
843
+ if (u.id === ownerId)
844
+ continue;
845
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
846
+ this.knownOnlineUsers.add(u.id);
847
+ }
848
+ }
849
+ }
850
+ catch { /* best-effort */ }
851
+ }
852
+ checkOfflineUsers() {
853
+ try {
854
+ const ownerId = this.authState.bootstrapAdminUserId || "";
855
+ const users = this.opts.store.listHubUsers("active");
856
+ const now = Date.now();
857
+ const currentlyOnline = new Set();
858
+ for (const u of users) {
859
+ if (u.id === ownerId)
860
+ continue;
861
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
862
+ currentlyOnline.add(u.id);
863
+ }
864
+ }
865
+ for (const uid of this.knownOnlineUsers) {
866
+ if (!currentlyOnline.has(uid)) {
867
+ const user = users.find(u => u.id === uid);
868
+ if (user) {
869
+ this.notifyAdmins("user_offline", "user", user.username, uid);
870
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
871
+ }
872
+ }
873
+ }
874
+ for (const uid of currentlyOnline) {
875
+ if (!this.knownOnlineUsers.has(uid)) {
876
+ const user = users.find(u => u.id === uid);
877
+ if (user) {
878
+ this.notifyAdmins("user_online", "user", user.username, uid);
879
+ }
880
+ }
881
+ }
882
+ this.knownOnlineUsers = currentlyOnline;
883
+ }
884
+ catch { /* best-effort */ }
885
+ }
703
886
  authenticate(req) {
704
887
  const header = req.headers.authorization;
705
888
  if (!header || !header.startsWith("Bearer "))
@@ -714,6 +897,13 @@ class HubServer {
714
897
  const hash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
715
898
  if (user.tokenHash !== hash)
716
899
  return null;
900
+ const clientIp = req.headers["x-client-ip"]?.trim()
901
+ || req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
902
+ || req.socket.remoteAddress || "";
903
+ try {
904
+ this.opts.store.updateHubUserActivity(user.id, clientIp);
905
+ }
906
+ catch { /* best-effort */ }
717
907
  return {
718
908
  userId: user.id,
719
909
  username: user.username,