@memtensor/memos-local-openclaw-plugin 1.0.4-beta.0 → 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 +5 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +173 -14
  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 +9 -11
  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 +301 -106
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +3 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +18 -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 +301 -207
  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 +2991 -1041
  69. package/dist/viewer/html.js.map +1 -1
  70. package/dist/viewer/server.d.ts +32 -8
  71. package/dist/viewer/server.d.ts.map +1 -1
  72. package/dist/viewer/server.js +1122 -261
  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 +173 -16
  81. package/src/client/hub.ts +18 -0
  82. package/src/client/skill-sync.ts +14 -0
  83. package/src/config.ts +9 -11
  84. package/src/hub/server.ts +285 -98
  85. package/src/hub/user-manager.ts +20 -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 +310 -233
  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 +2991 -1041
  98. package/src/viewer/server.ts +984 -190
@@ -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,14 +203,53 @@ 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 || "";
210
+ const existingUsers = this.opts.store.listHubUsers();
211
+ const existingUser = existingUsers.find(u => u.username === username);
212
+ if (existingUser) {
213
+ try {
214
+ this.opts.store.updateHubUserActivity(existingUser.id, joinIp);
215
+ }
216
+ catch { /* best-effort */ }
217
+ if (existingUser.status === "active") {
218
+ const token = (0, auth_1.issueUserToken)({ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" }, this.authSecret);
219
+ this.userManager.approveUser(existingUser.id, token);
220
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
221
+ }
222
+ if (existingUser.status === "pending") {
223
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
224
+ return this.json(res, 200, { status: "pending", userId: existingUser.id });
225
+ }
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") {
236
+ this.userManager.resetToPending(existingUser.id);
237
+ this.notifyAdmins("user_join_request", "user", username, "");
238
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied, reset to pending`);
239
+ return this.json(res, 200, { status: "pending", userId: existingUser.id });
240
+ }
241
+ }
196
242
  const user = this.userManager.createPendingUser({
197
243
  username,
198
244
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
199
245
  });
200
- const token = (0, auth_1.issueUserToken)({ userId: user.id, username, role: "member", status: "active" }, this.authSecret);
201
- this.userManager.approveUser(user.id, token);
202
- this.opts.log.info(`Hub: auto-approved user "${username}" (${user.id})`);
203
- return this.json(res, 200, { status: "active", userId: user.id, userToken: token });
246
+ try {
247
+ this.opts.store.updateHubUserActivity(user.id, joinIp);
248
+ }
249
+ catch { /* best-effort */ }
250
+ this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
251
+ this.notifyAdmins("user_join_request", "user", username, "");
252
+ return this.json(res, 200, { status: "pending", userId: user.id });
204
253
  }
205
254
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
206
255
  const body = await this.readJson(req);
@@ -233,6 +282,19 @@ class HubServer {
233
282
  if (!this.checkRateLimit(auth.userId, endpointKey)) {
234
283
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
235
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
+ }
236
298
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
237
299
  const user = this.opts.store.getHubUser(auth.userId);
238
300
  if (!user)
@@ -253,7 +315,8 @@ class HubServer {
253
315
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
254
316
  if (!updated)
255
317
  return this.json(res, 404, { error: "not_found" });
256
- 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);
257
320
  this.userManager.approveUser(updated.id, newToken);
258
321
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
259
322
  return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
@@ -271,6 +334,10 @@ class HubServer {
271
334
  const approved = this.userManager.approveUser(String(body.userId), token);
272
335
  if (!approved)
273
336
  return this.json(res, 404, { error: "not_found" });
337
+ try {
338
+ this.opts.store.updateHubUserActivity(String(body.userId), "");
339
+ }
340
+ catch { /* best-effort */ }
274
341
  return this.json(res, 200, { status: "active", token });
275
342
  }
276
343
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
@@ -286,98 +353,88 @@ class HubServer {
286
353
  if (auth.role !== "admin")
287
354
  return this.json(res, 403, { error: "forbidden" });
288
355
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
289
- 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
+ }) });
290
370
  }
291
- // ── Group management ──
292
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
293
- const groups = this.opts.store.listHubGroups();
294
- return this.json(res, 200, { groups });
371
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
372
+ if (auth.role !== "admin")
373
+ return this.json(res, 403, { error: "forbidden" });
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 });
295
389
  }
296
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
390
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
297
391
  if (auth.role !== "admin")
298
392
  return this.json(res, 403, { error: "forbidden" });
299
393
  const body = await this.readJson(req);
300
- const name = String(body.name || "").trim();
301
- if (!name)
302
- return this.json(res, 400, { error: "name_required" });
303
- const groupId = (0, crypto_1.randomUUID)();
304
- this.opts.store.upsertHubGroup({
305
- id: groupId,
306
- name,
307
- description: String(body.description || ""),
308
- createdAt: Date.now(),
309
- });
310
- return this.json(res, 201, { id: groupId, name });
311
- }
312
- const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
313
- if (groupDetailMatch) {
314
- const groupId = decodeURIComponent(groupDetailMatch[1]);
315
- if (req.method === "GET") {
316
- const group = this.opts.store.getHubGroupById(groupId);
317
- if (!group)
318
- return this.json(res, 404, { error: "not_found" });
319
- const members = this.opts.store.listHubGroupMembers(groupId);
320
- return this.json(res, 200, { ...group, members });
321
- }
322
- if (req.method === "PUT") {
323
- if (auth.role !== "admin")
324
- return this.json(res, 403, { error: "forbidden" });
325
- const existing = this.opts.store.getHubGroupById(groupId);
326
- if (!existing)
327
- return this.json(res, 404, { error: "not_found" });
328
- const body = await this.readJson(req);
329
- this.opts.store.upsertHubGroup({
330
- id: groupId,
331
- name: String(body.name || existing.name).trim(),
332
- description: String(body.description ?? existing.description),
333
- createdAt: existing.createdAt,
334
- });
335
- return this.json(res, 200, { ok: true });
336
- }
337
- if (req.method === "DELETE") {
338
- if (auth.role !== "admin")
339
- return this.json(res, 403, { error: "forbidden" });
340
- const deleted = this.opts.store.deleteHubGroup(groupId);
341
- if (!deleted)
342
- return this.json(res, 404, { error: "not_found" });
343
- return this.json(res, 200, { ok: true });
344
- }
345
- }
346
- const groupMembersMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)\/members$/);
347
- if (groupMembersMatch) {
348
- const groupId = decodeURIComponent(groupMembersMatch[1]);
349
- if (req.method === "POST") {
350
- if (auth.role !== "admin")
351
- return this.json(res, 403, { error: "forbidden" });
352
- const group = this.opts.store.getHubGroupById(groupId);
353
- if (!group)
354
- return this.json(res, 404, { error: "group_not_found" });
355
- const body = await this.readJson(req);
356
- const userId = String(body.userId || "");
357
- if (!userId)
358
- return this.json(res, 400, { error: "userId_required" });
359
- const user = this.opts.store.getHubUser(userId);
360
- if (!user)
361
- return this.json(res, 404, { error: "user_not_found" });
362
- this.opts.store.addHubGroupMember(groupId, userId);
363
- return this.json(res, 200, { ok: true });
364
- }
365
- if (req.method === "DELETE") {
366
- if (auth.role !== "admin")
367
- return this.json(res, 403, { error: "forbidden" });
368
- const body = await this.readJson(req);
369
- const userId = String(body.userId || "");
370
- if (!userId)
371
- return this.json(res, 400, { error: "userId_required" });
372
- this.opts.store.removeHubGroupMember(groupId, userId);
373
- 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" });
374
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 });
375
431
  }
376
432
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
377
433
  const body = await this.readJson(req);
378
434
  if (!body?.task)
379
435
  return this.json(res, 400, { error: "invalid_payload" });
380
436
  const task = { ...body.task, sourceUserId: auth.userId };
437
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
381
438
  this.opts.store.upsertHubTask(task);
382
439
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
383
440
  const chunkIds = [];
@@ -385,15 +442,22 @@ class HubServer {
385
442
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
386
443
  chunkIds.push(chunk.id);
387
444
  }
388
- // Async embedding: don't block the response
389
445
  if (this.opts.embedder && chunkIds.length > 0) {
390
446
  this.embedChunksAsync(chunkIds, chunks);
391
447
  }
448
+ if (!existingTask) {
449
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
450
+ }
392
451
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
393
452
  }
394
453
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
395
454
  const body = await this.readJson(req);
396
- 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
+ }
397
461
  return this.json(res, 200, { ok: true });
398
462
  }
399
463
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/share") {
@@ -406,17 +470,8 @@ class HubServer {
406
470
  return this.json(res, 400, { error: "missing_source_chunk_id" });
407
471
  const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
408
472
  const memoryId = existing?.id ?? (0, crypto_1.randomUUID)();
409
- const visibility = m.visibility === "group" ? "group" : "public";
410
- let resolvedGroupId = null;
411
- if (visibility === "group") {
412
- const gid = String(m.groupId || "");
413
- if (!gid)
414
- return this.json(res, 400, { error: "missing_group_id" });
415
- const group = this.opts.store.getHubGroupById(gid);
416
- if (!group)
417
- return this.json(res, 404, { error: "group_not_found" });
418
- resolvedGroupId = gid;
419
- }
473
+ const visibility = "public";
474
+ const resolvedGroupId = null;
420
475
  const now = Date.now();
421
476
  this.opts.store.upsertHubMemory({
422
477
  id: memoryId,
@@ -434,6 +489,9 @@ class HubServer {
434
489
  if (this.opts.embedder) {
435
490
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
436
491
  }
492
+ if (!existing) {
493
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
494
+ }
437
495
  return this.json(res, 200, { ok: true, memoryId, visibility });
438
496
  }
439
497
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/unshare") {
@@ -441,7 +499,11 @@ class HubServer {
441
499
  const sourceChunkId = String(body?.sourceChunkId || "");
442
500
  if (!sourceChunkId)
443
501
  return this.json(res, 400, { error: "missing_source_chunk_id" });
502
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
444
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
+ }
445
507
  return this.json(res, 200, { ok: true });
446
508
  }
447
509
  if (req.method === "GET" && routePath === "/api/v1/hub/memories") {
@@ -566,6 +628,7 @@ class HubServer {
566
628
  visibility: hit.visibility,
567
629
  groupName: hit.group_name,
568
630
  ownerName: hit.owner_name || "unknown",
631
+ ownerStatus: hit.owner_status || "",
569
632
  qualityScore: hit.quality_score,
570
633
  }));
571
634
  return this.json(res, 200, { hits });
@@ -578,7 +641,7 @@ class HubServer {
578
641
  return this.json(res, 400, { error: "missing_skill_id" });
579
642
  const existing = this.opts.store.getHubSkillBySource(auth.userId, sourceSkillId);
580
643
  const skillId = existing?.id ?? (0, crypto_1.randomUUID)();
581
- const visibility = body?.visibility === "group" ? "group" : "public";
644
+ const visibility = "public";
582
645
  this.opts.store.upsertHubSkill({
583
646
  id: skillId,
584
647
  sourceSkillId,
@@ -586,13 +649,16 @@ class HubServer {
586
649
  name: String(metadata.name || sourceSkillId),
587
650
  description: String(metadata.description || ""),
588
651
  version: Number(metadata.version || 1),
589
- groupId: visibility === "group" ? String(body?.groupId || "") || null : null,
652
+ groupId: null,
590
653
  visibility,
591
654
  bundle: JSON.stringify(body?.bundle ?? {}),
592
655
  qualityScore: metadata.qualityScore == null ? null : Number(metadata.qualityScore),
593
656
  createdAt: existing?.createdAt ?? Date.now(),
594
657
  updatedAt: Date.now(),
595
658
  });
659
+ if (!existing) {
660
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
661
+ }
596
662
  return this.json(res, 200, { ok: true, skillId, visibility });
597
663
  }
598
664
  const skillBundleMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/skills\/([^/]+)\/bundle$/) : null;
@@ -600,11 +666,6 @@ class HubServer {
600
666
  const skill = this.opts.store.getHubSkillById(decodeURIComponent(skillBundleMatch[1]));
601
667
  if (!skill)
602
668
  return this.json(res, 404, { error: "not_found" });
603
- const user = this.opts.store.getHubUser(auth.userId);
604
- const groups = new Set((user?.groups ?? []).map((group) => group.id));
605
- const allowed = skill.visibility === "public" || (skill.groupId != null && groups.has(skill.groupId));
606
- if (!allowed)
607
- return this.json(res, 403, { error: "forbidden" });
608
669
  return this.json(res, 200, {
609
670
  skillId: skill.id,
610
671
  metadata: {
@@ -619,7 +680,12 @@ class HubServer {
619
680
  }
620
681
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
621
682
  const body = await this.readJson(req);
622
- 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
+ }
623
689
  return this.json(res, 200, { ok: true });
624
690
  }
625
691
  // ── Admin: shared tasks & skills management ──
@@ -629,14 +695,51 @@ class HubServer {
629
695
  const tasks = this.opts.store.listAllHubTasks();
630
696
  return this.json(res, 200, { tasks });
631
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
+ }
632
731
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
633
732
  if (adminTaskDeleteMatch) {
634
733
  if (auth.role !== "admin")
635
734
  return this.json(res, 403, { error: "forbidden" });
636
735
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
736
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
637
737
  const deleted = this.opts.store.deleteHubTaskById(taskId);
638
738
  if (!deleted)
639
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
+ }
640
743
  return this.json(res, 200, { ok: true });
641
744
  }
642
745
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-skills") {
@@ -650,9 +753,13 @@ class HubServer {
650
753
  if (auth.role !== "admin")
651
754
  return this.json(res, 403, { error: "forbidden" });
652
755
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
756
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
653
757
  const deleted = this.opts.store.deleteHubSkillById(skillId);
654
758
  if (!deleted)
655
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
+ }
656
763
  return this.json(res, 200, { ok: true });
657
764
  }
658
765
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-memories") {
@@ -666,9 +773,13 @@ class HubServer {
666
773
  if (auth.role !== "admin")
667
774
  return this.json(res, 403, { error: "forbidden" });
668
775
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
776
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
669
777
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
670
778
  if (!deleted)
671
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
+ }
672
783
  return this.json(res, 200, { ok: true });
673
784
  }
674
785
  if (req.method === "POST" && routePath === "/api/v1/hub/memory-detail") {
@@ -693,8 +804,85 @@ class HubServer {
693
804
  source: { ts: chunk.createdAt, role: chunk.role },
694
805
  });
695
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
+ }
696
823
  return this.json(res, 404, { error: "not_found" });
697
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
+ }
698
886
  authenticate(req) {
699
887
  const header = req.headers.authorization;
700
888
  if (!header || !header.startsWith("Bearer "))
@@ -709,6 +897,13 @@ class HubServer {
709
897
  const hash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
710
898
  if (user.tokenHash !== hash)
711
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 */ }
712
907
  return {
713
908
  userId: user.id,
714
909
  username: user.username,