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

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 (119) 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 +2 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +122 -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 +8 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +390 -106
  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/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 +93 -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 +4 -0
  45. package/dist/skill/evolver.d.ts.map +1 -1
  46. package/dist/skill/evolver.js +59 -5
  47. package/dist/skill/evolver.js.map +1 -1
  48. package/dist/skill/generator.d.ts +2 -0
  49. package/dist/skill/generator.d.ts.map +1 -1
  50. package/dist/skill/generator.js +45 -3
  51. package/dist/skill/generator.js.map +1 -1
  52. package/dist/skill/installer.d.ts +26 -0
  53. package/dist/skill/installer.d.ts.map +1 -1
  54. package/dist/skill/installer.js +80 -4
  55. package/dist/skill/installer.js.map +1 -1
  56. package/dist/skill/upgrader.d.ts +2 -0
  57. package/dist/skill/upgrader.d.ts.map +1 -1
  58. package/dist/skill/upgrader.js +139 -1
  59. package/dist/skill/upgrader.js.map +1 -1
  60. package/dist/skill/validator.d.ts +3 -0
  61. package/dist/skill/validator.d.ts.map +1 -1
  62. package/dist/skill/validator.js +75 -0
  63. package/dist/skill/validator.js.map +1 -1
  64. package/dist/storage/ensure-binding.d.ts +12 -0
  65. package/dist/storage/ensure-binding.d.ts.map +1 -0
  66. package/dist/storage/ensure-binding.js +53 -0
  67. package/dist/storage/ensure-binding.js.map +1 -0
  68. package/dist/storage/sqlite.d.ts +89 -20
  69. package/dist/storage/sqlite.d.ts.map +1 -1
  70. package/dist/storage/sqlite.js +374 -124
  71. package/dist/storage/sqlite.js.map +1 -1
  72. package/dist/telemetry.d.ts +12 -5
  73. package/dist/telemetry.d.ts.map +1 -1
  74. package/dist/telemetry.js +156 -40
  75. package/dist/telemetry.js.map +1 -1
  76. package/dist/tools/memory-search.d.ts +3 -1
  77. package/dist/tools/memory-search.d.ts.map +1 -1
  78. package/dist/tools/memory-search.js +3 -1
  79. package/dist/tools/memory-search.js.map +1 -1
  80. package/dist/types.d.ts +11 -2
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/types.js +4 -0
  83. package/dist/types.js.map +1 -1
  84. package/dist/viewer/html.d.ts.map +1 -1
  85. package/dist/viewer/html.js +2671 -879
  86. package/dist/viewer/html.js.map +1 -1
  87. package/dist/viewer/server.d.ts +30 -8
  88. package/dist/viewer/server.d.ts.map +1 -1
  89. package/dist/viewer/server.js +990 -198
  90. package/dist/viewer/server.js.map +1 -1
  91. package/index.ts +700 -56
  92. package/openclaw.plugin.json +1 -1
  93. package/package.json +3 -2
  94. package/scripts/postinstall.cjs +1 -1
  95. package/skill/memos-memory-guide/SKILL.md +64 -26
  96. package/src/capture/index.ts +37 -1
  97. package/src/client/connector.ts +124 -28
  98. package/src/client/hub.ts +18 -0
  99. package/src/client/skill-sync.ts +14 -0
  100. package/src/config.ts +0 -2
  101. package/src/hub/server.ts +374 -97
  102. package/src/hub/user-manager.ts +48 -8
  103. package/src/index.ts +10 -2
  104. package/src/ingest/providers/index.ts +41 -7
  105. package/src/recall/engine.ts +86 -1
  106. package/src/shared/llm-call.ts +97 -9
  107. package/src/sharing/types.ts +1 -1
  108. package/src/skill/evolver.ts +63 -6
  109. package/src/skill/generator.ts +44 -5
  110. package/src/skill/installer.ts +107 -4
  111. package/src/skill/upgrader.ts +139 -1
  112. package/src/skill/validator.ts +79 -0
  113. package/src/storage/ensure-binding.ts +52 -0
  114. package/src/storage/sqlite.ts +395 -148
  115. package/src/telemetry.ts +172 -41
  116. package/src/tools/memory-search.ts +2 -1
  117. package/src/types.ts +12 -2
  118. package/src/viewer/html.ts +2671 -879
  119. package/src/viewer/server.ts +913 -182
@@ -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;
@@ -163,6 +173,20 @@ class HubServer {
163
173
  this.opts.log.warn(`hub: embedding shared chunks failed: ${err}`);
164
174
  });
165
175
  }
176
+ embedSkillAsync(skillId, name, description, sourceUserId, sourceSkillId) {
177
+ const embedder = this.opts.embedder;
178
+ if (!embedder)
179
+ return;
180
+ const text = `${name}: ${description}`;
181
+ embedder.embed([text]).then((vectors) => {
182
+ if (vectors[0]) {
183
+ this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
184
+ this.opts.log.info(`hub: embedded shared skill ${skillId}`);
185
+ }
186
+ }).catch((err) => {
187
+ this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
188
+ });
189
+ }
166
190
  embedMemoryAsync(memoryId, summary, content) {
167
191
  const embedder = this.opts.embedder;
168
192
  if (!embedder)
@@ -193,29 +217,73 @@ class HubServer {
193
217
  return this.json(res, 403, { error: "invalid_team_token" });
194
218
  }
195
219
  const username = String(body.username || `user-${(0, crypto_1.randomUUID)().slice(0, 8)}`);
196
- const existingUsers = this.opts.store.listHubUsers();
197
- const existingUser = existingUsers.find(u => u.username === username);
220
+ const joinIp = (typeof body.clientIp === "string" && body.clientIp)
221
+ || req.headers["x-client-ip"]?.trim()
222
+ || req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
223
+ || req.socket.remoteAddress || "";
224
+ const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
225
+ let existingUser = identityKey
226
+ ? this.userManager.findByIdentityKey(identityKey)
227
+ : null;
228
+ if (!existingUser) {
229
+ const existingUsers = this.opts.store.listHubUsers();
230
+ existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
231
+ }
198
232
  if (existingUser) {
233
+ try {
234
+ this.opts.store.updateHubUserActivity(existingUser.id, joinIp);
235
+ }
236
+ catch { /* best-effort */ }
199
237
  if (existingUser.status === "active") {
200
238
  const token = (0, auth_1.issueUserToken)({ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" }, this.authSecret);
201
239
  this.userManager.approveUser(existingUser.id, token);
202
- return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
240
+ if (identityKey && !existingUser.identityKey) {
241
+ this.opts.store.upsertHubUser({ ...existingUser, identityKey });
242
+ }
243
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
203
244
  }
204
245
  if (existingUser.status === "pending") {
205
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
246
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
247
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
206
248
  }
207
249
  if (existingUser.status === "rejected") {
208
- this.userManager.resetToPending(existingUser.id);
209
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
210
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
250
+ if (body.reapply === true) {
251
+ this.userManager.resetToPending(existingUser.id);
252
+ this.notifyAdmins("user_join_request", "user", username, "");
253
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
254
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
255
+ }
256
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
257
+ }
258
+ if (existingUser.status === "removed") {
259
+ this.userManager.rejoinUser(existingUser.id);
260
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
261
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
262
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
263
+ }
264
+ if (existingUser.status === "left") {
265
+ this.userManager.rejoinUser(existingUser.id);
266
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
267
+ this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
268
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
269
+ }
270
+ if (existingUser.status === "blocked") {
271
+ return this.json(res, 200, { status: "blocked", userId: existingUser.id });
211
272
  }
212
273
  }
274
+ const generatedIdentityKey = identityKey || (0, crypto_1.randomUUID)();
213
275
  const user = this.userManager.createPendingUser({
214
276
  username,
215
277
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
278
+ identityKey: generatedIdentityKey,
216
279
  });
280
+ try {
281
+ this.opts.store.updateHubUserActivity(user.id, joinIp);
282
+ }
283
+ catch { /* best-effort */ }
217
284
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
218
- return this.json(res, 200, { status: "pending", userId: user.id });
285
+ this.notifyAdmins("user_join_request", "user", username, "");
286
+ return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
219
287
  }
220
288
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
221
289
  const body = await this.readJson(req);
@@ -234,6 +302,15 @@ class HubServer {
234
302
  if (user.status === "rejected") {
235
303
  return this.json(res, 200, { status: "rejected" });
236
304
  }
305
+ if (user.status === "blocked") {
306
+ return this.json(res, 200, { status: "blocked" });
307
+ }
308
+ if (user.status === "left") {
309
+ return this.json(res, 200, { status: "left" });
310
+ }
311
+ if (user.status === "removed") {
312
+ return this.json(res, 200, { status: "removed" });
313
+ }
237
314
  if (user.status === "active") {
238
315
  const token = (0, auth_1.issueUserToken)({ userId: user.id, username: user.username, role: user.role, status: user.status }, this.authSecret);
239
316
  return this.json(res, 200, { status: "active", userToken: token });
@@ -248,6 +325,16 @@ class HubServer {
248
325
  if (!this.checkRateLimit(auth.userId, endpointKey)) {
249
326
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
250
327
  }
328
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
329
+ return this.json(res, 200, { ok: true });
330
+ }
331
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
332
+ this.userManager.markUserLeft(auth.userId);
333
+ this.knownOnlineUsers.delete(auth.userId);
334
+ this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
335
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
336
+ return this.json(res, 200, { ok: true });
337
+ }
251
338
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
252
339
  const user = this.opts.store.getHubUser(auth.userId);
253
340
  if (!user)
@@ -268,7 +355,8 @@ class HubServer {
268
355
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
269
356
  if (!updated)
270
357
  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);
358
+ const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
359
+ const newToken = (0, auth_1.issueUserToken)({ userId: updated.id, username: newUsername, role: updated.role, status: updated.status }, this.authSecret, ttlMs);
272
360
  this.userManager.approveUser(updated.id, newToken);
273
361
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
274
362
  return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
@@ -286,6 +374,10 @@ class HubServer {
286
374
  const approved = this.userManager.approveUser(String(body.userId), token);
287
375
  if (!approved)
288
376
  return this.json(res, 404, { error: "not_found" });
377
+ try {
378
+ this.opts.store.updateHubUserActivity(String(body.userId), "");
379
+ }
380
+ catch { /* best-effort */ }
289
381
  return this.json(res, 200, { status: "active", token });
290
382
  }
291
383
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
@@ -301,102 +393,88 @@ class HubServer {
301
393
  if (auth.role !== "admin")
302
394
  return this.json(res, 403, { error: "forbidden" });
303
395
  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 })) });
396
+ const contribs = this.opts.store.getHubUserContributions();
397
+ const ownerId = this.authState.bootstrapAdminUserId || "";
398
+ const now = Date.now();
399
+ return this.json(res, 200, { users: users.map(u => {
400
+ const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
401
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
402
+ return {
403
+ id: u.id, username: u.username, role: u.role, status: u.status,
404
+ deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
405
+ lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
406
+ isOwner: u.id === ownerId, isOnline,
407
+ memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
408
+ };
409
+ }) });
305
410
  }
306
- // ── Group management ──
307
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
411
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
308
412
  if (auth.role !== "admin")
309
413
  return this.json(res, 403, { error: "forbidden" });
310
- const groups = this.opts.store.listHubGroups();
311
- return this.json(res, 200, { groups });
414
+ const body = await this.readJson(req);
415
+ const userId = String(body?.userId || "");
416
+ const newRole = String(body?.role || "");
417
+ if (!userId || (newRole !== "admin" && newRole !== "member"))
418
+ return this.json(res, 400, { error: "invalid_params" });
419
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
420
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
421
+ }
422
+ const user = this.opts.store.getHubUser(userId);
423
+ if (!user || user.status !== "active")
424
+ return this.json(res, 404, { error: "not_found" });
425
+ const updatedUser = { ...user, role: newRole };
426
+ this.opts.store.upsertHubUser(updatedUser);
427
+ this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
428
+ return this.json(res, 200, { ok: true, role: newRole });
312
429
  }
313
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
430
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
314
431
  if (auth.role !== "admin")
315
432
  return this.json(res, 403, { error: "forbidden" });
316
433
  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 });
434
+ const userId = String(body?.userId || "");
435
+ const newUsername = String(body?.username || "").trim();
436
+ if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
437
+ return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
393
438
  }
439
+ if (this.userManager.isUsernameTaken(newUsername, userId)) {
440
+ return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
441
+ }
442
+ const user = this.opts.store.getHubUser(userId);
443
+ if (!user || user.status !== "active")
444
+ return this.json(res, 404, { error: "not_found" });
445
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
446
+ const newToken = (0, auth_1.issueUserToken)({ userId: user.id, username: newUsername, role: user.role, status: user.status }, this.authSecret, ttlMs);
447
+ this.userManager.approveUser(user.id, newToken);
448
+ const updated = this.opts.store.getHubUser(userId);
449
+ const finalUser = { ...updated, username: newUsername };
450
+ this.opts.store.upsertHubUser(finalUser);
451
+ this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
452
+ return this.json(res, 200, { ok: true, username: newUsername });
453
+ }
454
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
455
+ if (auth.role !== "admin")
456
+ return this.json(res, 403, { error: "forbidden" });
457
+ const body = await this.readJson(req);
458
+ const userId = String(body?.userId || "");
459
+ if (!userId)
460
+ return this.json(res, 400, { error: "missing_user_id" });
461
+ if (userId === auth.userId)
462
+ return this.json(res, 400, { error: "cannot_remove_self" });
463
+ if (userId === this.authState.bootstrapAdminUserId)
464
+ return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
465
+ const cleanResources = body?.cleanResources === true;
466
+ const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
467
+ if (!deleted)
468
+ return this.json(res, 404, { error: "not_found" });
469
+ this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
470
+ return this.json(res, 200, { ok: true });
394
471
  }
395
472
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
396
473
  const body = await this.readJson(req);
397
474
  if (!body?.task)
398
475
  return this.json(res, 400, { error: "invalid_payload" });
399
476
  const task = { ...body.task, sourceUserId: auth.userId };
477
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
400
478
  this.opts.store.upsertHubTask(task);
401
479
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
402
480
  const chunkIds = [];
@@ -404,15 +482,22 @@ class HubServer {
404
482
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
405
483
  chunkIds.push(chunk.id);
406
484
  }
407
- // Async embedding: don't block the response
408
485
  if (this.opts.embedder && chunkIds.length > 0) {
409
486
  this.embedChunksAsync(chunkIds, chunks);
410
487
  }
488
+ if (!existingTask) {
489
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
490
+ }
411
491
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
412
492
  }
413
493
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
414
494
  const body = await this.readJson(req);
415
- this.opts.store.deleteHubTaskBySource(auth.userId, String(body.sourceTaskId));
495
+ const srcTaskId = String(body.sourceTaskId);
496
+ const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
497
+ this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
498
+ if (existing) {
499
+ this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
500
+ }
416
501
  return this.json(res, 200, { ok: true });
417
502
  }
418
503
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/share") {
@@ -444,6 +529,9 @@ class HubServer {
444
529
  if (this.opts.embedder) {
445
530
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
446
531
  }
532
+ if (!existing) {
533
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
534
+ }
447
535
  return this.json(res, 200, { ok: true, memoryId, visibility });
448
536
  }
449
537
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/unshare") {
@@ -451,7 +539,11 @@ class HubServer {
451
539
  const sourceChunkId = String(body?.sourceChunkId || "");
452
540
  if (!sourceChunkId)
453
541
  return this.json(res, 400, { error: "missing_source_chunk_id" });
542
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
454
543
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
544
+ if (existing) {
545
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
546
+ }
455
547
  return this.json(res, 200, { ok: true });
456
548
  }
457
549
  if (req.method === "GET" && routePath === "/api/v1/hub/memories") {
@@ -565,19 +657,73 @@ class HubServer {
565
657
  return this.json(res, 200, { hits, meta: { totalCandidates: hits.length, searchedGroups: [], includedPublic: true } });
566
658
  }
567
659
  if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
568
- const hits = this.opts.store.searchHubSkills(String(url.searchParams.get("query") || ""), {
660
+ const skillQuery = String(url.searchParams.get("query") || "");
661
+ const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
662
+ const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
569
663
  userId: auth.userId,
570
- maxResults: Number(url.searchParams.get("maxResults") || 10),
571
- }).map(({ hit }) => ({
572
- skillId: hit.id,
573
- name: hit.name,
574
- description: hit.description,
575
- version: hit.version,
576
- visibility: hit.visibility,
577
- groupName: hit.group_name,
578
- ownerName: hit.owner_name || "unknown",
579
- qualityScore: hit.quality_score,
580
- }));
664
+ maxResults: skillMaxResults * 2,
665
+ });
666
+ let mergedSkillIds;
667
+ if (this.opts.embedder && skillQuery) {
668
+ try {
669
+ const [queryVec] = await this.opts.embedder.embed([skillQuery]);
670
+ if (queryVec) {
671
+ const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
672
+ const cosineSim = (vec) => {
673
+ let dot = 0, nA = 0, nB = 0;
674
+ for (let i = 0; i < queryVec.length && i < vec.length; i++) {
675
+ dot += queryVec[i] * vec[i];
676
+ nA += queryVec[i] * queryVec[i];
677
+ nB += vec[i] * vec[i];
678
+ }
679
+ return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
680
+ };
681
+ const vecScored = skillEmbs
682
+ .map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
683
+ .filter(e => e.score > 0.3)
684
+ .sort((a, b) => b.score - a.score)
685
+ .slice(0, skillMaxResults * 2);
686
+ const K = 60;
687
+ const rrfScores = new Map();
688
+ ftsSkillHits.forEach(({ hit }, idx) => {
689
+ rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
690
+ });
691
+ vecScored.forEach(({ id }, idx) => {
692
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
693
+ });
694
+ mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
695
+ }
696
+ else {
697
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
698
+ }
699
+ }
700
+ catch {
701
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
702
+ }
703
+ }
704
+ else {
705
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
706
+ }
707
+ const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
708
+ const hits = mergedSkillIds.map(id => {
709
+ const hit = ftsSkillMap.get(id);
710
+ if (hit) {
711
+ return {
712
+ skillId: hit.id, name: hit.name, description: hit.description,
713
+ version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
714
+ ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
715
+ qualityScore: hit.quality_score,
716
+ };
717
+ }
718
+ const skill = this.opts.store.getHubSkillById(id);
719
+ if (!skill)
720
+ return null;
721
+ return {
722
+ skillId: skill.id, name: skill.name, description: skill.description,
723
+ version: skill.version, visibility: skill.visibility, groupName: "",
724
+ ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
725
+ };
726
+ }).filter(Boolean);
581
727
  return this.json(res, 200, { hits });
582
728
  }
583
729
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/publish") {
@@ -603,6 +749,10 @@ class HubServer {
603
749
  createdAt: existing?.createdAt ?? Date.now(),
604
750
  updatedAt: Date.now(),
605
751
  });
752
+ this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
753
+ if (!existing) {
754
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
755
+ }
606
756
  return this.json(res, 200, { ok: true, skillId, visibility });
607
757
  }
608
758
  const skillBundleMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/skills\/([^/]+)\/bundle$/) : null;
@@ -624,7 +774,12 @@ class HubServer {
624
774
  }
625
775
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
626
776
  const body = await this.readJson(req);
627
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
777
+ const srcSkillId = String(body?.sourceSkillId || "");
778
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
779
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
780
+ if (existing) {
781
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
782
+ }
628
783
  return this.json(res, 200, { ok: true });
629
784
  }
630
785
  // ── Admin: shared tasks & skills management ──
@@ -634,14 +789,51 @@ class HubServer {
634
789
  const tasks = this.opts.store.listAllHubTasks();
635
790
  return this.json(res, 200, { tasks });
636
791
  }
792
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
793
+ if (hubTaskDetailMatch) {
794
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
795
+ const task = this.opts.store.getHubTaskById(taskId);
796
+ if (!task)
797
+ return this.json(res, 404, { error: "not_found" });
798
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
799
+ return this.json(res, 200, {
800
+ id: task.id, title: task.title, summary: task.summary,
801
+ startedAt: task.createdAt, endedAt: task.updatedAt,
802
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
803
+ });
804
+ }
805
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
806
+ if (hubSkillDetailMatch) {
807
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
808
+ const skill = this.opts.store.getHubSkillById(skillId);
809
+ if (!skill)
810
+ return this.json(res, 404, { error: "not_found" });
811
+ let files = [];
812
+ try {
813
+ const bundle = JSON.parse(skill.bundle || "{}");
814
+ if (Array.isArray(bundle.files)) {
815
+ files = bundle.files.map((f) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
816
+ }
817
+ }
818
+ catch { /* ignore parse error */ }
819
+ return this.json(res, 200, {
820
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
821
+ files,
822
+ versions: [],
823
+ });
824
+ }
637
825
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
638
826
  if (adminTaskDeleteMatch) {
639
827
  if (auth.role !== "admin")
640
828
  return this.json(res, 403, { error: "forbidden" });
641
829
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
830
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
642
831
  const deleted = this.opts.store.deleteHubTaskById(taskId);
643
832
  if (!deleted)
644
833
  return this.json(res, 404, { error: "not_found" });
834
+ if (taskInfo) {
835
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
836
+ }
645
837
  return this.json(res, 200, { ok: true });
646
838
  }
647
839
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-skills") {
@@ -655,9 +847,13 @@ class HubServer {
655
847
  if (auth.role !== "admin")
656
848
  return this.json(res, 403, { error: "forbidden" });
657
849
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
850
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
658
851
  const deleted = this.opts.store.deleteHubSkillById(skillId);
659
852
  if (!deleted)
660
853
  return this.json(res, 404, { error: "not_found" });
854
+ if (skillInfo) {
855
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
856
+ }
661
857
  return this.json(res, 200, { ok: true });
662
858
  }
663
859
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-memories") {
@@ -671,9 +867,13 @@ class HubServer {
671
867
  if (auth.role !== "admin")
672
868
  return this.json(res, 403, { error: "forbidden" });
673
869
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
870
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
674
871
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
675
872
  if (!deleted)
676
873
  return this.json(res, 404, { error: "not_found" });
874
+ if (memInfo) {
875
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
876
+ }
677
877
  return this.json(res, 200, { ok: true });
678
878
  }
679
879
  if (req.method === "POST" && routePath === "/api/v1/hub/memory-detail") {
@@ -698,8 +898,85 @@ class HubServer {
698
898
  source: { ts: chunk.createdAt, role: chunk.role },
699
899
  });
700
900
  }
901
+ if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
902
+ const unread = (new URL(req.url, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
903
+ const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
904
+ const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
905
+ return this.json(res, 200, { notifications: list, unreadCount });
906
+ }
907
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
908
+ const body = await this.readJson(req);
909
+ const ids = Array.isArray(body.ids) ? body.ids : undefined;
910
+ this.opts.store.markHubNotificationsRead(auth.userId, ids);
911
+ return this.json(res, 200, { ok: true });
912
+ }
913
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
914
+ this.opts.store.clearHubNotifications(auth.userId);
915
+ return this.json(res, 200, { ok: true });
916
+ }
701
917
  return this.json(res, 404, { error: "not_found" });
702
918
  }
919
+ notifyAdmins(type, resource, title, fromUserId, opts) {
920
+ try {
921
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
922
+ for (const admin of admins) {
923
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
924
+ continue;
925
+ }
926
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: admin.id, type, resource, title });
927
+ }
928
+ }
929
+ catch { /* best-effort */ }
930
+ }
931
+ initOnlineTracking() {
932
+ try {
933
+ const ownerId = this.authState.bootstrapAdminUserId || "";
934
+ const users = this.opts.store.listHubUsers("active");
935
+ const now = Date.now();
936
+ for (const u of users) {
937
+ if (u.id === ownerId)
938
+ continue;
939
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
940
+ this.knownOnlineUsers.add(u.id);
941
+ }
942
+ }
943
+ }
944
+ catch { /* best-effort */ }
945
+ }
946
+ checkOfflineUsers() {
947
+ try {
948
+ const ownerId = this.authState.bootstrapAdminUserId || "";
949
+ const users = this.opts.store.listHubUsers("active");
950
+ const now = Date.now();
951
+ const currentlyOnline = new Set();
952
+ for (const u of users) {
953
+ if (u.id === ownerId)
954
+ continue;
955
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
956
+ currentlyOnline.add(u.id);
957
+ }
958
+ }
959
+ for (const uid of this.knownOnlineUsers) {
960
+ if (!currentlyOnline.has(uid)) {
961
+ const user = users.find(u => u.id === uid);
962
+ if (user) {
963
+ this.notifyAdmins("user_offline", "user", user.username, uid);
964
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
965
+ }
966
+ }
967
+ }
968
+ for (const uid of currentlyOnline) {
969
+ if (!this.knownOnlineUsers.has(uid)) {
970
+ const user = users.find(u => u.id === uid);
971
+ if (user) {
972
+ this.notifyAdmins("user_online", "user", user.username, uid);
973
+ }
974
+ }
975
+ }
976
+ this.knownOnlineUsers = currentlyOnline;
977
+ }
978
+ catch { /* best-effort */ }
979
+ }
703
980
  authenticate(req) {
704
981
  const header = req.headers.authorization;
705
982
  if (!header || !header.startsWith("Bearer "))
@@ -714,6 +991,13 @@ class HubServer {
714
991
  const hash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
715
992
  if (user.tokenHash !== hash)
716
993
  return null;
994
+ const clientIp = req.headers["x-client-ip"]?.trim()
995
+ || req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
996
+ || req.socket.remoteAddress || "";
997
+ try {
998
+ this.opts.store.updateHubUserActivity(user.id, clientIp);
999
+ }
1000
+ catch { /* best-effort */ }
717
1001
  return {
718
1002
  userId: user.id,
719
1003
  username: user.username,