@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
package/src/hub/server.ts CHANGED
@@ -34,6 +34,11 @@ export class HubServer {
34
34
  private static readonly RATE_LIMIT_SEARCH = 30;
35
35
  private rateBuckets = new Map<string, { count: number; windowStart: number }>();
36
36
 
37
+ private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
38
+ private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
39
+ private offlineCheckTimer?: ReturnType<typeof setInterval>;
40
+ private knownOnlineUsers = new Set<string>();
41
+
37
42
  constructor(private opts: HubServerOptions) {
38
43
  this.userManager = new HubUserManager(opts.store, opts.log);
39
44
  this.authStatePath = path.join(opts.dataDir, "hub-auth.json");
@@ -101,10 +106,14 @@ export class HubServer {
101
106
  this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
102
107
  }
103
108
 
109
+ this.initOnlineTracking();
110
+ this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
111
+
104
112
  return `http://127.0.0.1:${this.port}`;
105
113
  }
106
114
 
107
115
  async stop(): Promise<void> {
116
+ if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
108
117
  if (!this.server) return;
109
118
  const server = this.server;
110
119
  this.server = undefined;
@@ -160,6 +169,20 @@ export class HubServer {
160
169
  });
161
170
  }
162
171
 
172
+ private embedSkillAsync(skillId: string, name: string, description: string, sourceUserId: string, sourceSkillId: string): void {
173
+ const embedder = this.opts.embedder;
174
+ if (!embedder) return;
175
+ const text = `${name}: ${description}`;
176
+ embedder.embed([text]).then((vectors) => {
177
+ if (vectors[0]) {
178
+ this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
179
+ this.opts.log.info(`hub: embedded shared skill ${skillId}`);
180
+ }
181
+ }).catch((err) => {
182
+ this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
183
+ });
184
+ }
185
+
163
186
  private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
164
187
  const embedder = this.opts.embedder;
165
188
  if (!embedder) return;
@@ -192,32 +215,74 @@ export class HubServer {
192
215
  return this.json(res, 403, { error: "invalid_team_token" });
193
216
  }
194
217
  const username = String(body.username || `user-${randomUUID().slice(0, 8)}`);
195
- const existingUsers = this.opts.store.listHubUsers();
196
- const existingUser = existingUsers.find(u => u.username === username);
218
+ const joinIp = (typeof body.clientIp === "string" && body.clientIp)
219
+ || (req.headers["x-client-ip"] as string)?.trim()
220
+ || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
221
+ || req.socket.remoteAddress || "";
222
+ const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
223
+
224
+ let existingUser = identityKey
225
+ ? this.userManager.findByIdentityKey(identityKey)
226
+ : null;
227
+ if (!existingUser) {
228
+ const existingUsers = this.opts.store.listHubUsers();
229
+ existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
230
+ }
231
+
197
232
  if (existingUser) {
233
+ try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
234
+
198
235
  if (existingUser.status === "active") {
199
236
  const token = issueUserToken(
200
237
  { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
201
238
  this.authSecret,
202
239
  );
203
240
  this.userManager.approveUser(existingUser.id, token);
204
- return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
241
+ if (identityKey && !existingUser.identityKey) {
242
+ this.opts.store.upsertHubUser({ ...existingUser, identityKey });
243
+ }
244
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
205
245
  }
206
246
  if (existingUser.status === "pending") {
207
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
247
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
248
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
208
249
  }
209
250
  if (existingUser.status === "rejected") {
210
- this.userManager.resetToPending(existingUser.id);
211
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
212
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
251
+ if (body.reapply === true) {
252
+ this.userManager.resetToPending(existingUser.id);
253
+ this.notifyAdmins("user_join_request", "user", username, "");
254
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
255
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
256
+ }
257
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
258
+ }
259
+ if (existingUser.status === "removed") {
260
+ this.userManager.rejoinUser(existingUser.id);
261
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
262
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
263
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
264
+ }
265
+ if (existingUser.status === "left") {
266
+ this.userManager.rejoinUser(existingUser.id);
267
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
268
+ this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
269
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
270
+ }
271
+ if (existingUser.status === "blocked") {
272
+ return this.json(res, 200, { status: "blocked", userId: existingUser.id });
213
273
  }
214
274
  }
275
+
276
+ const generatedIdentityKey = identityKey || randomUUID();
215
277
  const user = this.userManager.createPendingUser({
216
278
  username,
217
279
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
280
+ identityKey: generatedIdentityKey,
218
281
  });
282
+ try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
219
283
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
220
- return this.json(res, 200, { status: "pending", userId: user.id });
284
+ this.notifyAdmins("user_join_request", "user", username, "");
285
+ return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
221
286
  }
222
287
 
223
288
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
@@ -235,6 +300,15 @@ export class HubServer {
235
300
  if (user.status === "rejected") {
236
301
  return this.json(res, 200, { status: "rejected" });
237
302
  }
303
+ if (user.status === "blocked") {
304
+ return this.json(res, 200, { status: "blocked" });
305
+ }
306
+ if (user.status === "left") {
307
+ return this.json(res, 200, { status: "left" });
308
+ }
309
+ if (user.status === "removed") {
310
+ return this.json(res, 200, { status: "removed" });
311
+ }
238
312
  if (user.status === "active") {
239
313
  const token = issueUserToken(
240
314
  { userId: user.id, username: user.username, role: user.role, status: user.status },
@@ -254,6 +328,18 @@ export class HubServer {
254
328
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
255
329
  }
256
330
 
331
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
332
+ return this.json(res, 200, { ok: true });
333
+ }
334
+
335
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
336
+ this.userManager.markUserLeft(auth.userId);
337
+ this.knownOnlineUsers.delete(auth.userId);
338
+ this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
339
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
340
+ return this.json(res, 200, { ok: true });
341
+ }
342
+
257
343
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
258
344
  const user = this.opts.store.getHubUser(auth.userId);
259
345
  if (!user) return this.json(res, 401, { error: "unauthorized" });
@@ -272,9 +358,11 @@ export class HubServer {
272
358
  }
273
359
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
274
360
  if (!updated) return this.json(res, 404, { error: "not_found" });
361
+ const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
275
362
  const newToken = issueUserToken(
276
363
  { userId: updated.id, username: newUsername, role: updated.role, status: updated.status },
277
364
  this.authSecret,
365
+ ttlMs,
278
366
  );
279
367
  this.userManager.approveUser(updated.id, newToken);
280
368
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
@@ -292,6 +380,7 @@ export class HubServer {
292
380
  const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
293
381
  const approved = this.userManager.approveUser(String(body.userId), token);
294
382
  if (!approved) return this.json(res, 404, { error: "not_found" });
383
+ try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
295
384
  return this.json(res, 200, { status: "active", token });
296
385
  }
297
386
 
@@ -306,97 +395,85 @@ export class HubServer {
306
395
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
307
396
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
308
397
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
309
- return this.json(res, 200, { users: users.map(u => ({ id: u.id, username: u.username, role: u.role, status: u.status })) });
398
+ const contribs = this.opts.store.getHubUserContributions();
399
+ const ownerId = this.authState.bootstrapAdminUserId || "";
400
+ const now = Date.now();
401
+ return this.json(res, 200, { users: users.map(u => {
402
+ const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
403
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
404
+ return {
405
+ id: u.id, username: u.username, role: u.role, status: u.status,
406
+ deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
407
+ lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
408
+ isOwner: u.id === ownerId, isOnline,
409
+ memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
410
+ };
411
+ }) });
310
412
  }
311
413
 
312
- // ── Group management ──
313
-
314
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
414
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
315
415
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
316
- const groups = this.opts.store.listHubGroups();
317
- return this.json(res, 200, { groups });
416
+ const body = await this.readJson(req);
417
+ const userId = String(body?.userId || "");
418
+ const newRole = String(body?.role || "");
419
+ if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
420
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
421
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
422
+ }
423
+ const user = this.opts.store.getHubUser(userId);
424
+ if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
425
+ const updatedUser = { ...user, role: newRole as "admin" | "member" };
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 });
318
429
  }
319
430
 
320
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
431
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
321
432
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
322
433
  const body = await this.readJson(req);
323
- const name = String(body.name || "").trim();
324
- if (!name) return this.json(res, 400, { error: "name_required" });
325
- const groupId = randomUUID();
326
- this.opts.store.upsertHubGroup({
327
- id: groupId,
328
- name,
329
- description: String(body.description || ""),
330
- createdAt: Date.now(),
331
- });
332
- return this.json(res, 201, { id: groupId, name });
333
- }
334
-
335
- const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
336
- if (groupDetailMatch) {
337
- const groupId = decodeURIComponent(groupDetailMatch[1]);
338
-
339
- if (req.method === "GET") {
340
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
341
- const group = this.opts.store.getHubGroupById(groupId);
342
- if (!group) return this.json(res, 404, { error: "not_found" });
343
- const members = this.opts.store.listHubGroupMembers(groupId);
344
- return this.json(res, 200, { ...group, members });
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" });
345
438
  }
346
-
347
- if (req.method === "PUT") {
348
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
349
- const existing = this.opts.store.getHubGroupById(groupId);
350
- if (!existing) return this.json(res, 404, { error: "not_found" });
351
- const body = await this.readJson(req);
352
- this.opts.store.upsertHubGroup({
353
- id: groupId,
354
- name: String(body.name || existing.name).trim(),
355
- description: String(body.description ?? existing.description),
356
- createdAt: existing.createdAt,
357
- });
358
- return this.json(res, 200, { ok: true });
359
- }
360
-
361
- if (req.method === "DELETE") {
362
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
363
- const deleted = this.opts.store.deleteHubGroup(groupId);
364
- if (!deleted) return this.json(res, 404, { error: "not_found" });
365
- return this.json(res, 200, { ok: true });
439
+ if (this.userManager.isUsernameTaken(newUsername, userId)) {
440
+ return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
366
441
  }
442
+ const user = this.opts.store.getHubUser(userId);
443
+ if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
444
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
445
+ const newToken = issueUserToken(
446
+ { userId: user.id, username: newUsername, role: user.role, status: user.status },
447
+ this.authSecret,
448
+ ttlMs,
449
+ );
450
+ this.userManager.approveUser(user.id, newToken);
451
+ const updated = this.opts.store.getHubUser(userId)!;
452
+ const finalUser = { ...updated, username: newUsername };
453
+ this.opts.store.upsertHubUser(finalUser);
454
+ this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
455
+ return this.json(res, 200, { ok: true, username: newUsername });
367
456
  }
368
457
 
369
- const groupMembersMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)\/members$/);
370
- if (groupMembersMatch) {
371
- const groupId = decodeURIComponent(groupMembersMatch[1]);
372
-
373
- if (req.method === "POST") {
374
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
375
- const group = this.opts.store.getHubGroupById(groupId);
376
- if (!group) return this.json(res, 404, { error: "group_not_found" });
377
- const body = await this.readJson(req);
378
- const userId = String(body.userId || "");
379
- if (!userId) return this.json(res, 400, { error: "userId_required" });
380
- const user = this.opts.store.getHubUser(userId);
381
- if (!user) return this.json(res, 404, { error: "user_not_found" });
382
- this.opts.store.addHubGroupMember(groupId, userId);
383
- return this.json(res, 200, { ok: true });
384
- }
385
-
386
- if (req.method === "DELETE") {
387
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
388
- const body = await this.readJson(req);
389
- const userId = String(body.userId || "");
390
- if (!userId) return this.json(res, 400, { error: "userId_required" });
391
- this.opts.store.removeHubGroupMember(groupId, userId);
392
- return this.json(res, 200, { ok: true });
393
- }
458
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
459
+ if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
460
+ const body = await this.readJson(req);
461
+ const userId = String(body?.userId || "");
462
+ if (!userId) return this.json(res, 400, { error: "missing_user_id" });
463
+ if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
464
+ if (userId === this.authState.bootstrapAdminUserId) 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) return this.json(res, 404, { error: "not_found" });
468
+ this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
469
+ return this.json(res, 200, { ok: true });
394
470
  }
395
471
 
396
472
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
397
473
  const body = await this.readJson(req);
398
474
  if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
399
475
  const task = { ...body.task, sourceUserId: auth.userId };
476
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
400
477
  this.opts.store.upsertHubTask(task);
401
478
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
402
479
  const chunkIds: string[] = [];
@@ -404,16 +481,23 @@ export class HubServer {
404
481
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
405
482
  chunkIds.push(chunk.id);
406
483
  }
407
- // Async embedding: don't block the response
408
484
  if (this.opts.embedder && chunkIds.length > 0) {
409
485
  this.embedChunksAsync(chunkIds, chunks);
410
486
  }
487
+ if (!existingTask) {
488
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
489
+ }
411
490
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
412
491
  }
413
492
 
414
493
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
415
494
  const body = await this.readJson(req);
416
- 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
+ }
417
501
  return this.json(res, 200, { ok: true });
418
502
  }
419
503
 
@@ -444,6 +528,9 @@ export class HubServer {
444
528
  if (this.opts.embedder) {
445
529
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
446
530
  }
531
+ if (!existing) {
532
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
533
+ }
447
534
  return this.json(res, 200, { ok: true, memoryId, visibility });
448
535
  }
449
536
 
@@ -451,7 +538,11 @@ export class HubServer {
451
538
  const body = await this.readJson(req);
452
539
  const sourceChunkId = String(body?.sourceChunkId || "");
453
540
  if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
541
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
454
542
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
543
+ if (existing) {
544
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
545
+ }
455
546
  return this.json(res, 200, { ok: true });
456
547
  }
457
548
 
@@ -563,19 +654,70 @@ export class HubServer {
563
654
  }
564
655
 
565
656
  if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
566
- const hits = this.opts.store.searchHubSkills(String(url.searchParams.get("query") || ""), {
657
+ const skillQuery = String(url.searchParams.get("query") || "");
658
+ const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
659
+ const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
567
660
  userId: auth.userId,
568
- maxResults: Number(url.searchParams.get("maxResults") || 10),
569
- }).map(({ hit }) => ({
570
- skillId: hit.id,
571
- name: hit.name,
572
- description: hit.description,
573
- version: hit.version,
574
- visibility: hit.visibility,
575
- groupName: hit.group_name,
576
- ownerName: hit.owner_name || "unknown",
577
- qualityScore: hit.quality_score,
578
- }));
661
+ maxResults: skillMaxResults * 2,
662
+ });
663
+
664
+ let mergedSkillIds: string[];
665
+ if (this.opts.embedder && skillQuery) {
666
+ try {
667
+ const [queryVec] = await this.opts.embedder.embed([skillQuery]);
668
+ if (queryVec) {
669
+ const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
670
+ const cosineSim = (vec: Float32Array) => {
671
+ let dot = 0, nA = 0, nB = 0;
672
+ for (let i = 0; i < queryVec.length && i < vec.length; i++) {
673
+ dot += queryVec[i] * vec[i]; nA += queryVec[i] * queryVec[i]; nB += vec[i] * vec[i];
674
+ }
675
+ return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
676
+ };
677
+ const vecScored = skillEmbs
678
+ .map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
679
+ .filter(e => e.score > 0.3)
680
+ .sort((a, b) => b.score - a.score)
681
+ .slice(0, skillMaxResults * 2);
682
+
683
+ const K = 60;
684
+ const rrfScores = new Map<string, number>();
685
+ ftsSkillHits.forEach(({ hit }, idx) => {
686
+ rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
687
+ });
688
+ vecScored.forEach(({ id }, idx) => {
689
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
690
+ });
691
+ mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
692
+ } else {
693
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
694
+ }
695
+ } catch {
696
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
697
+ }
698
+ } else {
699
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
700
+ }
701
+
702
+ const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
703
+ const hits = mergedSkillIds.map(id => {
704
+ const hit = ftsSkillMap.get(id);
705
+ if (hit) {
706
+ return {
707
+ skillId: hit.id, name: hit.name, description: hit.description,
708
+ version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
709
+ ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
710
+ qualityScore: hit.quality_score,
711
+ };
712
+ }
713
+ const skill = this.opts.store.getHubSkillById(id);
714
+ if (!skill) return null;
715
+ return {
716
+ skillId: skill.id, name: skill.name, description: skill.description,
717
+ version: skill.version, visibility: skill.visibility, groupName: "",
718
+ ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
719
+ };
720
+ }).filter(Boolean);
579
721
  return this.json(res, 200, { hits });
580
722
  }
581
723
 
@@ -601,6 +743,10 @@ export class HubServer {
601
743
  createdAt: existing?.createdAt ?? Date.now(),
602
744
  updatedAt: Date.now(),
603
745
  });
746
+ this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
747
+ if (!existing) {
748
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
749
+ }
604
750
  return this.json(res, 200, { ok: true, skillId, visibility });
605
751
  }
606
752
 
@@ -623,7 +769,12 @@ export class HubServer {
623
769
 
624
770
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
625
771
  const body = await this.readJson(req);
626
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
772
+ const srcSkillId = String(body?.sourceSkillId || "");
773
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
774
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
775
+ if (existing) {
776
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
777
+ }
627
778
  return this.json(res, 200, { ok: true });
628
779
  }
629
780
 
@@ -635,12 +786,48 @@ export class HubServer {
635
786
  return this.json(res, 200, { tasks });
636
787
  }
637
788
 
789
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
790
+ if (hubTaskDetailMatch) {
791
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
792
+ const task = this.opts.store.getHubTaskById(taskId);
793
+ if (!task) return this.json(res, 404, { error: "not_found" });
794
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
795
+ return this.json(res, 200, {
796
+ id: task.id, title: task.title, summary: task.summary,
797
+ startedAt: task.createdAt, endedAt: task.updatedAt,
798
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
799
+ });
800
+ }
801
+
802
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
803
+ if (hubSkillDetailMatch) {
804
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
805
+ const skill = this.opts.store.getHubSkillById(skillId);
806
+ if (!skill) return this.json(res, 404, { error: "not_found" });
807
+ let files: Array<{ path: string; type: string; size: number }> = [];
808
+ try {
809
+ const bundle = JSON.parse(skill.bundle || "{}");
810
+ if (Array.isArray(bundle.files)) {
811
+ files = bundle.files.map((f: any) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
812
+ }
813
+ } catch { /* ignore parse error */ }
814
+ return this.json(res, 200, {
815
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
816
+ files,
817
+ versions: [],
818
+ });
819
+ }
820
+
638
821
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
639
822
  if (adminTaskDeleteMatch) {
640
823
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
641
824
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
825
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
642
826
  const deleted = this.opts.store.deleteHubTaskById(taskId);
643
827
  if (!deleted) return this.json(res, 404, { error: "not_found" });
828
+ if (taskInfo) {
829
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
830
+ }
644
831
  return this.json(res, 200, { ok: true });
645
832
  }
646
833
 
@@ -654,8 +841,12 @@ export class HubServer {
654
841
  if (adminSkillDeleteMatch) {
655
842
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
656
843
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
844
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
657
845
  const deleted = this.opts.store.deleteHubSkillById(skillId);
658
846
  if (!deleted) return this.json(res, 404, { error: "not_found" });
847
+ if (skillInfo) {
848
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
849
+ }
659
850
  return this.json(res, 200, { ok: true });
660
851
  }
661
852
 
@@ -669,8 +860,12 @@ export class HubServer {
669
860
  if (adminMemoryDeleteMatch) {
670
861
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
671
862
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
863
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
672
864
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
673
865
  if (!deleted) return this.json(res, 404, { error: "not_found" });
866
+ if (memInfo) {
867
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
868
+ }
674
869
  return this.json(res, 200, { ok: true });
675
870
  }
676
871
 
@@ -693,9 +888,87 @@ export class HubServer {
693
888
  });
694
889
  }
695
890
 
891
+ if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
892
+ const unread = (new URL(req.url!, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
893
+ const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
894
+ const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
895
+ return this.json(res, 200, { notifications: list, unreadCount });
896
+ }
897
+
898
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
899
+ const body = await this.readJson(req);
900
+ const ids = Array.isArray(body.ids) ? body.ids as string[] : undefined;
901
+ this.opts.store.markHubNotificationsRead(auth.userId, ids);
902
+ return this.json(res, 200, { ok: true });
903
+ }
904
+
905
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
906
+ this.opts.store.clearHubNotifications(auth.userId);
907
+ return this.json(res, 200, { ok: true });
908
+ }
909
+
696
910
  return this.json(res, 404, { error: "not_found" });
697
911
  }
698
912
 
913
+ private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
914
+ try {
915
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
916
+ for (const admin of admins) {
917
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
918
+ continue;
919
+ }
920
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
921
+ }
922
+ } catch { /* best-effort */ }
923
+ }
924
+
925
+ private initOnlineTracking(): void {
926
+ try {
927
+ const ownerId = this.authState.bootstrapAdminUserId || "";
928
+ const users = this.opts.store.listHubUsers("active");
929
+ const now = Date.now();
930
+ for (const u of users) {
931
+ if (u.id === ownerId) continue;
932
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
933
+ this.knownOnlineUsers.add(u.id);
934
+ }
935
+ }
936
+ } catch { /* best-effort */ }
937
+ }
938
+
939
+ private checkOfflineUsers(): void {
940
+ try {
941
+ const ownerId = this.authState.bootstrapAdminUserId || "";
942
+ const users = this.opts.store.listHubUsers("active");
943
+ const now = Date.now();
944
+ const currentlyOnline = new Set<string>();
945
+ for (const u of users) {
946
+ if (u.id === ownerId) continue;
947
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
948
+ currentlyOnline.add(u.id);
949
+ }
950
+ }
951
+ for (const uid of this.knownOnlineUsers) {
952
+ if (!currentlyOnline.has(uid)) {
953
+ const user = users.find(u => u.id === uid);
954
+ if (user) {
955
+ this.notifyAdmins("user_offline", "user", user.username, uid);
956
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
957
+ }
958
+ }
959
+ }
960
+ for (const uid of currentlyOnline) {
961
+ if (!this.knownOnlineUsers.has(uid)) {
962
+ const user = users.find(u => u.id === uid);
963
+ if (user) {
964
+ this.notifyAdmins("user_online", "user", user.username, uid);
965
+ }
966
+ }
967
+ }
968
+ this.knownOnlineUsers = currentlyOnline;
969
+ } catch { /* best-effort */ }
970
+ }
971
+
699
972
  private authenticate(req: http.IncomingMessage) {
700
973
  const header = req.headers.authorization;
701
974
  if (!header || !header.startsWith("Bearer ")) return null;
@@ -706,6 +979,10 @@ export class HubServer {
706
979
  if (!user || user.status !== "active") return null;
707
980
  const hash = createHash("sha256").update(token).digest("hex");
708
981
  if (user.tokenHash !== hash) return null;
982
+ const clientIp = (req.headers["x-client-ip"] as string)?.trim()
983
+ || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
984
+ || req.socket.remoteAddress || "";
985
+ try { this.opts.store.updateHubUserActivity(user.id, clientIp); } catch { /* best-effort */ }
709
986
  return {
710
987
  userId: user.id,
711
988
  username: user.username,