@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
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;
@@ -192,17 +201,50 @@ export class HubServer {
192
201
  return this.json(res, 403, { error: "invalid_team_token" });
193
202
  }
194
203
  const username = String(body.username || `user-${randomUUID().slice(0, 8)}`);
204
+ const joinIp = (typeof body.clientIp === "string" && body.clientIp)
205
+ || (req.headers["x-client-ip"] as string)?.trim()
206
+ || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
207
+ || req.socket.remoteAddress || "";
208
+ const existingUsers = this.opts.store.listHubUsers();
209
+ const existingUser = existingUsers.find(u => u.username === username);
210
+ if (existingUser) {
211
+ try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
212
+ if (existingUser.status === "active") {
213
+ const token = issueUserToken(
214
+ { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
215
+ this.authSecret,
216
+ );
217
+ this.userManager.approveUser(existingUser.id, token);
218
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
219
+ }
220
+ if (existingUser.status === "pending") {
221
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
222
+ return this.json(res, 200, { status: "pending", userId: existingUser.id });
223
+ }
224
+ if (existingUser.status === "rejected") {
225
+ if (body.reapply === true) {
226
+ this.userManager.resetToPending(existingUser.id);
227
+ this.notifyAdmins("user_join_request", "user", username, "");
228
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
229
+ return this.json(res, 200, { status: "pending", userId: existingUser.id });
230
+ }
231
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
232
+ }
233
+ if (existingUser.status === "removed") {
234
+ this.userManager.resetToPending(existingUser.id);
235
+ this.notifyAdmins("user_join_request", "user", username, "");
236
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied, reset to pending`);
237
+ return this.json(res, 200, { status: "pending", userId: existingUser.id });
238
+ }
239
+ }
195
240
  const user = this.userManager.createPendingUser({
196
241
  username,
197
242
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
198
243
  });
199
- const token = issueUserToken(
200
- { userId: user.id, username, role: "member", status: "active" },
201
- this.authSecret,
202
- );
203
- this.userManager.approveUser(user.id, token);
204
- this.opts.log.info(`Hub: auto-approved user "${username}" (${user.id})`);
205
- return this.json(res, 200, { status: "active", userId: user.id, userToken: token });
244
+ try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
245
+ this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
246
+ this.notifyAdmins("user_join_request", "user", username, "");
247
+ return this.json(res, 200, { status: "pending", userId: user.id });
206
248
  }
207
249
 
208
250
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
@@ -239,6 +281,20 @@ export class HubServer {
239
281
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
240
282
  }
241
283
 
284
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
285
+ return this.json(res, 200, { ok: true });
286
+ }
287
+
288
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
289
+ try {
290
+ this.opts.store.updateHubUserActivity(auth.userId, "", 0);
291
+ } catch { /* best-effort */ }
292
+ this.knownOnlineUsers.delete(auth.userId);
293
+ this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
294
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
295
+ return this.json(res, 200, { ok: true });
296
+ }
297
+
242
298
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
243
299
  const user = this.opts.store.getHubUser(auth.userId);
244
300
  if (!user) return this.json(res, 401, { error: "unauthorized" });
@@ -257,9 +313,11 @@ export class HubServer {
257
313
  }
258
314
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
259
315
  if (!updated) return this.json(res, 404, { error: "not_found" });
316
+ const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
260
317
  const newToken = issueUserToken(
261
318
  { userId: updated.id, username: newUsername, role: updated.role, status: updated.status },
262
319
  this.authSecret,
320
+ ttlMs,
263
321
  );
264
322
  this.userManager.approveUser(updated.id, newToken);
265
323
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
@@ -277,6 +335,7 @@ export class HubServer {
277
335
  const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
278
336
  const approved = this.userManager.approveUser(String(body.userId), token);
279
337
  if (!approved) return this.json(res, 404, { error: "not_found" });
338
+ try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
280
339
  return this.json(res, 200, { status: "active", token });
281
340
  }
282
341
 
@@ -291,95 +350,85 @@ export class HubServer {
291
350
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
292
351
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
293
352
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
294
- return this.json(res, 200, { users: users.map(u => ({ id: u.id, username: u.username, role: u.role, status: u.status })) });
295
- }
296
-
297
- // ── Group management ──
298
-
299
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
300
- const groups = this.opts.store.listHubGroups();
301
- return this.json(res, 200, { groups });
353
+ const contribs = this.opts.store.getHubUserContributions();
354
+ const ownerId = this.authState.bootstrapAdminUserId || "";
355
+ const now = Date.now();
356
+ return this.json(res, 200, { users: users.map(u => {
357
+ const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
358
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
359
+ return {
360
+ id: u.id, username: u.username, role: u.role, status: u.status,
361
+ deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
362
+ lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
363
+ isOwner: u.id === ownerId, isOnline,
364
+ memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
365
+ };
366
+ }) });
302
367
  }
303
368
 
304
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
369
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
305
370
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
306
371
  const body = await this.readJson(req);
307
- const name = String(body.name || "").trim();
308
- if (!name) return this.json(res, 400, { error: "name_required" });
309
- const groupId = randomUUID();
310
- this.opts.store.upsertHubGroup({
311
- id: groupId,
312
- name,
313
- description: String(body.description || ""),
314
- createdAt: Date.now(),
315
- });
316
- return this.json(res, 201, { id: groupId, name });
317
- }
318
-
319
- const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
320
- if (groupDetailMatch) {
321
- const groupId = decodeURIComponent(groupDetailMatch[1]);
322
-
323
- if (req.method === "GET") {
324
- const group = this.opts.store.getHubGroupById(groupId);
325
- if (!group) return this.json(res, 404, { error: "not_found" });
326
- const members = this.opts.store.listHubGroupMembers(groupId);
327
- return this.json(res, 200, { ...group, members });
372
+ const userId = String(body?.userId || "");
373
+ const newRole = String(body?.role || "");
374
+ if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
375
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
376
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
328
377
  }
378
+ const user = this.opts.store.getHubUser(userId);
379
+ if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
380
+ const updatedUser = { ...user, role: newRole as "admin" | "member" };
381
+ this.opts.store.upsertHubUser(updatedUser);
382
+ this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
383
+ return this.json(res, 200, { ok: true, role: newRole });
384
+ }
329
385
 
330
- if (req.method === "PUT") {
331
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
332
- const existing = this.opts.store.getHubGroupById(groupId);
333
- if (!existing) return this.json(res, 404, { error: "not_found" });
334
- const body = await this.readJson(req);
335
- this.opts.store.upsertHubGroup({
336
- id: groupId,
337
- name: String(body.name || existing.name).trim(),
338
- description: String(body.description ?? existing.description),
339
- createdAt: existing.createdAt,
340
- });
341
- return this.json(res, 200, { ok: true });
386
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
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
+ const newUsername = String(body?.username || "").trim();
391
+ if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
392
+ return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
342
393
  }
343
-
344
- if (req.method === "DELETE") {
345
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
346
- const deleted = this.opts.store.deleteHubGroup(groupId);
347
- if (!deleted) return this.json(res, 404, { error: "not_found" });
348
- return this.json(res, 200, { ok: true });
394
+ if (this.userManager.isUsernameTaken(newUsername, userId)) {
395
+ return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
349
396
  }
397
+ const user = this.opts.store.getHubUser(userId);
398
+ if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
399
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
400
+ const newToken = issueUserToken(
401
+ { userId: user.id, username: newUsername, role: user.role, status: user.status },
402
+ this.authSecret,
403
+ ttlMs,
404
+ );
405
+ this.userManager.approveUser(user.id, newToken);
406
+ const updated = this.opts.store.getHubUser(userId)!;
407
+ const finalUser = { ...updated, username: newUsername };
408
+ this.opts.store.upsertHubUser(finalUser);
409
+ this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
410
+ return this.json(res, 200, { ok: true, username: newUsername });
350
411
  }
351
412
 
352
- const groupMembersMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)\/members$/);
353
- if (groupMembersMatch) {
354
- const groupId = decodeURIComponent(groupMembersMatch[1]);
355
-
356
- if (req.method === "POST") {
357
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
358
- const group = this.opts.store.getHubGroupById(groupId);
359
- if (!group) return this.json(res, 404, { error: "group_not_found" });
360
- const body = await this.readJson(req);
361
- const userId = String(body.userId || "");
362
- if (!userId) return this.json(res, 400, { error: "userId_required" });
363
- const user = this.opts.store.getHubUser(userId);
364
- if (!user) return this.json(res, 404, { error: "user_not_found" });
365
- this.opts.store.addHubGroupMember(groupId, userId);
366
- return this.json(res, 200, { ok: true });
367
- }
368
-
369
- if (req.method === "DELETE") {
370
- if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
371
- const body = await this.readJson(req);
372
- const userId = String(body.userId || "");
373
- if (!userId) return this.json(res, 400, { error: "userId_required" });
374
- this.opts.store.removeHubGroupMember(groupId, userId);
375
- return this.json(res, 200, { ok: true });
376
- }
413
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
414
+ if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
415
+ const body = await this.readJson(req);
416
+ const userId = String(body?.userId || "");
417
+ if (!userId) return this.json(res, 400, { error: "missing_user_id" });
418
+ if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
419
+ if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
420
+ const cleanResources = body?.cleanResources === true;
421
+ const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
422
+ if (!deleted) return this.json(res, 404, { error: "not_found" });
423
+ this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
424
+ return this.json(res, 200, { ok: true });
377
425
  }
378
426
 
379
427
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
380
428
  const body = await this.readJson(req);
381
429
  if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
382
430
  const task = { ...body.task, sourceUserId: auth.userId };
431
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
383
432
  this.opts.store.upsertHubTask(task);
384
433
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
385
434
  const chunkIds: string[] = [];
@@ -387,16 +436,23 @@ export class HubServer {
387
436
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
388
437
  chunkIds.push(chunk.id);
389
438
  }
390
- // Async embedding: don't block the response
391
439
  if (this.opts.embedder && chunkIds.length > 0) {
392
440
  this.embedChunksAsync(chunkIds, chunks);
393
441
  }
442
+ if (!existingTask) {
443
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
444
+ }
394
445
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
395
446
  }
396
447
 
397
448
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
398
449
  const body = await this.readJson(req);
399
- this.opts.store.deleteHubTaskBySource(auth.userId, String(body.sourceTaskId));
450
+ const srcTaskId = String(body.sourceTaskId);
451
+ const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
452
+ this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
453
+ if (existing) {
454
+ this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
455
+ }
400
456
  return this.json(res, 200, { ok: true });
401
457
  }
402
458
 
@@ -408,15 +464,8 @@ export class HubServer {
408
464
  if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
409
465
  const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
410
466
  const memoryId = existing?.id ?? randomUUID();
411
- const visibility = m.visibility === "group" ? "group" : "public";
412
- let resolvedGroupId: string | null = null;
413
- if (visibility === "group") {
414
- const gid = String(m.groupId || "");
415
- if (!gid) return this.json(res, 400, { error: "missing_group_id" });
416
- const group = this.opts.store.getHubGroupById(gid);
417
- if (!group) return this.json(res, 404, { error: "group_not_found" });
418
- resolvedGroupId = gid;
419
- }
467
+ const visibility = "public";
468
+ const resolvedGroupId: string | null = null;
420
469
  const now = Date.now();
421
470
  this.opts.store.upsertHubMemory({
422
471
  id: memoryId,
@@ -434,6 +483,9 @@ export class HubServer {
434
483
  if (this.opts.embedder) {
435
484
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
436
485
  }
486
+ if (!existing) {
487
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
488
+ }
437
489
  return this.json(res, 200, { ok: true, memoryId, visibility });
438
490
  }
439
491
 
@@ -441,7 +493,11 @@ export class HubServer {
441
493
  const body = await this.readJson(req);
442
494
  const sourceChunkId = String(body?.sourceChunkId || "");
443
495
  if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
496
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
444
497
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
498
+ if (existing) {
499
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
500
+ }
445
501
  return this.json(res, 200, { ok: true });
446
502
  }
447
503
 
@@ -564,6 +620,7 @@ export class HubServer {
564
620
  visibility: hit.visibility,
565
621
  groupName: hit.group_name,
566
622
  ownerName: hit.owner_name || "unknown",
623
+ ownerStatus: hit.owner_status || "",
567
624
  qualityScore: hit.quality_score,
568
625
  }));
569
626
  return this.json(res, 200, { hits });
@@ -576,7 +633,7 @@ export class HubServer {
576
633
  if (!sourceSkillId) return this.json(res, 400, { error: "missing_skill_id" });
577
634
  const existing = this.opts.store.getHubSkillBySource(auth.userId, sourceSkillId);
578
635
  const skillId = existing?.id ?? randomUUID();
579
- const visibility = body?.visibility === "group" ? "group" : "public";
636
+ const visibility = "public";
580
637
  this.opts.store.upsertHubSkill({
581
638
  id: skillId,
582
639
  sourceSkillId,
@@ -584,13 +641,16 @@ export class HubServer {
584
641
  name: String(metadata.name || sourceSkillId),
585
642
  description: String(metadata.description || ""),
586
643
  version: Number(metadata.version || 1),
587
- groupId: visibility === "group" ? String(body?.groupId || "") || null : null,
644
+ groupId: null,
588
645
  visibility,
589
646
  bundle: JSON.stringify(body?.bundle ?? {}),
590
647
  qualityScore: metadata.qualityScore == null ? null : Number(metadata.qualityScore),
591
648
  createdAt: existing?.createdAt ?? Date.now(),
592
649
  updatedAt: Date.now(),
593
650
  });
651
+ if (!existing) {
652
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
653
+ }
594
654
  return this.json(res, 200, { ok: true, skillId, visibility });
595
655
  }
596
656
 
@@ -598,10 +658,6 @@ export class HubServer {
598
658
  if (skillBundleMatch) {
599
659
  const skill = this.opts.store.getHubSkillById(decodeURIComponent(skillBundleMatch[1]));
600
660
  if (!skill) return this.json(res, 404, { error: "not_found" });
601
- const user = this.opts.store.getHubUser(auth.userId);
602
- const groups = new Set((user?.groups ?? []).map((group) => group.id));
603
- const allowed = skill.visibility === "public" || (skill.groupId != null && groups.has(skill.groupId));
604
- if (!allowed) return this.json(res, 403, { error: "forbidden" });
605
661
  return this.json(res, 200, {
606
662
  skillId: skill.id,
607
663
  metadata: {
@@ -617,7 +673,12 @@ export class HubServer {
617
673
 
618
674
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
619
675
  const body = await this.readJson(req);
620
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
676
+ const srcSkillId = String(body?.sourceSkillId || "");
677
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
678
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
679
+ if (existing) {
680
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
681
+ }
621
682
  return this.json(res, 200, { ok: true });
622
683
  }
623
684
 
@@ -629,12 +690,48 @@ export class HubServer {
629
690
  return this.json(res, 200, { tasks });
630
691
  }
631
692
 
693
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
694
+ if (hubTaskDetailMatch) {
695
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
696
+ const task = this.opts.store.getHubTaskById(taskId);
697
+ if (!task) return this.json(res, 404, { error: "not_found" });
698
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
699
+ return this.json(res, 200, {
700
+ id: task.id, title: task.title, summary: task.summary,
701
+ startedAt: task.createdAt, endedAt: task.updatedAt,
702
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
703
+ });
704
+ }
705
+
706
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
707
+ if (hubSkillDetailMatch) {
708
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
709
+ const skill = this.opts.store.getHubSkillById(skillId);
710
+ if (!skill) return this.json(res, 404, { error: "not_found" });
711
+ let files: Array<{ path: string; type: string; size: number }> = [];
712
+ try {
713
+ const bundle = JSON.parse(skill.bundle || "{}");
714
+ if (Array.isArray(bundle.files)) {
715
+ 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) }));
716
+ }
717
+ } catch { /* ignore parse error */ }
718
+ return this.json(res, 200, {
719
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
720
+ files,
721
+ versions: [],
722
+ });
723
+ }
724
+
632
725
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
633
726
  if (adminTaskDeleteMatch) {
634
727
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
635
728
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
729
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
636
730
  const deleted = this.opts.store.deleteHubTaskById(taskId);
637
731
  if (!deleted) return this.json(res, 404, { error: "not_found" });
732
+ if (taskInfo) {
733
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
734
+ }
638
735
  return this.json(res, 200, { ok: true });
639
736
  }
640
737
 
@@ -648,8 +745,12 @@ export class HubServer {
648
745
  if (adminSkillDeleteMatch) {
649
746
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
650
747
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
748
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
651
749
  const deleted = this.opts.store.deleteHubSkillById(skillId);
652
750
  if (!deleted) return this.json(res, 404, { error: "not_found" });
751
+ if (skillInfo) {
752
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
753
+ }
653
754
  return this.json(res, 200, { ok: true });
654
755
  }
655
756
 
@@ -663,8 +764,12 @@ export class HubServer {
663
764
  if (adminMemoryDeleteMatch) {
664
765
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
665
766
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
767
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
666
768
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
667
769
  if (!deleted) return this.json(res, 404, { error: "not_found" });
770
+ if (memInfo) {
771
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
772
+ }
668
773
  return this.json(res, 200, { ok: true });
669
774
  }
670
775
 
@@ -687,9 +792,87 @@ export class HubServer {
687
792
  });
688
793
  }
689
794
 
795
+ if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
796
+ const unread = (new URL(req.url!, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
797
+ const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
798
+ const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
799
+ return this.json(res, 200, { notifications: list, unreadCount });
800
+ }
801
+
802
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
803
+ const body = await this.readJson(req);
804
+ const ids = Array.isArray(body.ids) ? body.ids as string[] : undefined;
805
+ this.opts.store.markHubNotificationsRead(auth.userId, ids);
806
+ return this.json(res, 200, { ok: true });
807
+ }
808
+
809
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
810
+ this.opts.store.clearHubNotifications(auth.userId);
811
+ return this.json(res, 200, { ok: true });
812
+ }
813
+
690
814
  return this.json(res, 404, { error: "not_found" });
691
815
  }
692
816
 
817
+ private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
818
+ try {
819
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
820
+ for (const admin of admins) {
821
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
822
+ continue;
823
+ }
824
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
825
+ }
826
+ } catch { /* best-effort */ }
827
+ }
828
+
829
+ private initOnlineTracking(): void {
830
+ try {
831
+ const ownerId = this.authState.bootstrapAdminUserId || "";
832
+ const users = this.opts.store.listHubUsers("active");
833
+ const now = Date.now();
834
+ for (const u of users) {
835
+ if (u.id === ownerId) continue;
836
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
837
+ this.knownOnlineUsers.add(u.id);
838
+ }
839
+ }
840
+ } catch { /* best-effort */ }
841
+ }
842
+
843
+ private checkOfflineUsers(): void {
844
+ try {
845
+ const ownerId = this.authState.bootstrapAdminUserId || "";
846
+ const users = this.opts.store.listHubUsers("active");
847
+ const now = Date.now();
848
+ const currentlyOnline = new Set<string>();
849
+ for (const u of users) {
850
+ if (u.id === ownerId) continue;
851
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
852
+ currentlyOnline.add(u.id);
853
+ }
854
+ }
855
+ for (const uid of this.knownOnlineUsers) {
856
+ if (!currentlyOnline.has(uid)) {
857
+ const user = users.find(u => u.id === uid);
858
+ if (user) {
859
+ this.notifyAdmins("user_offline", "user", user.username, uid);
860
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
861
+ }
862
+ }
863
+ }
864
+ for (const uid of currentlyOnline) {
865
+ if (!this.knownOnlineUsers.has(uid)) {
866
+ const user = users.find(u => u.id === uid);
867
+ if (user) {
868
+ this.notifyAdmins("user_online", "user", user.username, uid);
869
+ }
870
+ }
871
+ }
872
+ this.knownOnlineUsers = currentlyOnline;
873
+ } catch { /* best-effort */ }
874
+ }
875
+
693
876
  private authenticate(req: http.IncomingMessage) {
694
877
  const header = req.headers.authorization;
695
878
  if (!header || !header.startsWith("Bearer ")) return null;
@@ -700,6 +883,10 @@ export class HubServer {
700
883
  if (!user || user.status !== "active") return null;
701
884
  const hash = createHash("sha256").update(token).digest("hex");
702
885
  if (user.tokenHash !== hash) return null;
886
+ const clientIp = (req.headers["x-client-ip"] as string)?.trim()
887
+ || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
888
+ || req.socket.remoteAddress || "";
889
+ try { this.opts.store.updateHubUserActivity(user.id, clientIp); } catch { /* best-effort */ }
703
890
  return {
704
891
  userId: user.id,
705
892
  username: user.username,
@@ -1,10 +1,10 @@
1
1
  import { randomUUID, createHash } from "crypto";
2
- import { issueUserToken } from "./auth";
2
+ import { issueUserToken, verifyUserToken } from "./auth";
3
3
  import type { Logger } from "../types";
4
4
  import type { UserInfo } from "../sharing/types";
5
5
  import type { SqliteStore } from "../storage/sqlite";
6
6
 
7
- type ManagedHubUser = UserInfo & { tokenHash: string; createdAt: number; approvedAt: number | null };
7
+ type ManagedHubUser = UserInfo & { tokenHash: string; createdAt: number; approvedAt: number | null; lastIp: string; lastActiveAt: number | null };
8
8
 
9
9
  export class HubUserManager {
10
10
  constructor(private store: SqliteStore, private log: Logger) {}
@@ -20,6 +20,8 @@ export class HubUserManager {
20
20
  tokenHash: "",
21
21
  createdAt: Date.now(),
22
22
  approvedAt: null,
23
+ lastIp: "",
24
+ lastActiveAt: null,
23
25
  };
24
26
  this.store.upsertHubUser(user);
25
27
  return user;
@@ -46,7 +48,7 @@ export class HubUserManager {
46
48
  if (bootstrapUserId) {
47
49
  const bootstrapUser = this.store.getHubUser(bootstrapUserId);
48
50
  if (bootstrapUser && bootstrapUser.role === "admin" && bootstrapUser.status === "active") {
49
- if (bootstrapToken && bootstrapUser.tokenHash === createHash("sha256").update(bootstrapToken).digest("hex")) {
51
+ if (bootstrapToken && bootstrapUser.tokenHash === createHash("sha256").update(bootstrapToken).digest("hex") && verifyUserToken(bootstrapToken, secret)) {
50
52
  return { user: bootstrapUser, token: bootstrapToken };
51
53
  }
52
54
  const refreshedToken = issueUserToken(
@@ -88,6 +90,8 @@ export class HubUserManager {
88
90
  tokenHash: "",
89
91
  createdAt: Date.now(),
90
92
  approvedAt: Date.now(),
93
+ lastIp: "",
94
+ lastActiveAt: null,
91
95
  };
92
96
  const token = issueUserToken(
93
97
  { userId: user.id, username: user.username, role: user.role, status: user.status },
@@ -123,4 +127,17 @@ export class HubUserManager {
123
127
  this.store.upsertHubUser(updated);
124
128
  return updated;
125
129
  }
130
+
131
+ resetToPending(userId: string): ManagedHubUser | null {
132
+ const user = this.store.getHubUser(userId);
133
+ if (!user) return null;
134
+ const updated = {
135
+ ...user,
136
+ status: "pending" as const,
137
+ tokenHash: "",
138
+ approvedAt: null,
139
+ };
140
+ this.store.upsertHubUser(updated);
141
+ return updated;
142
+ }
126
143
  }