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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.env.example +7 -0
  2. package/README.md +24 -24
  3. package/dist/capture/index.d.ts +1 -1
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +34 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +1 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +93 -26
  10. package/dist/client/connector.js.map +1 -1
  11. package/dist/client/hub.d.ts.map +1 -1
  12. package/dist/client/hub.js +22 -0
  13. package/dist/client/hub.js.map +1 -1
  14. package/dist/client/skill-sync.d.ts +7 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -1
  16. package/dist/client/skill-sync.js +10 -0
  17. package/dist/client/skill-sync.js.map +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +0 -2
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +7 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +277 -87
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +2 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +5 -1
  28. package/dist/hub/user-manager.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest/providers/index.d.ts.map +1 -1
  33. package/dist/ingest/providers/index.js +37 -6
  34. package/dist/ingest/providers/index.js.map +1 -1
  35. package/dist/recall/engine.d.ts.map +1 -1
  36. package/dist/recall/engine.js +91 -1
  37. package/dist/recall/engine.js.map +1 -1
  38. package/dist/shared/llm-call.d.ts +1 -0
  39. package/dist/shared/llm-call.d.ts.map +1 -1
  40. package/dist/shared/llm-call.js +82 -8
  41. package/dist/shared/llm-call.js.map +1 -1
  42. package/dist/sharing/types.d.ts +1 -1
  43. package/dist/sharing/types.d.ts.map +1 -1
  44. package/dist/skill/evolver.d.ts +2 -0
  45. package/dist/skill/evolver.d.ts.map +1 -1
  46. package/dist/skill/evolver.js +3 -0
  47. package/dist/skill/evolver.js.map +1 -1
  48. package/dist/storage/ensure-binding.d.ts +12 -0
  49. package/dist/storage/ensure-binding.d.ts.map +1 -0
  50. package/dist/storage/ensure-binding.js +53 -0
  51. package/dist/storage/ensure-binding.js.map +1 -0
  52. package/dist/storage/sqlite.d.ts +74 -20
  53. package/dist/storage/sqlite.d.ts.map +1 -1
  54. package/dist/storage/sqlite.js +286 -118
  55. package/dist/storage/sqlite.js.map +1 -1
  56. package/dist/telemetry.d.ts +12 -5
  57. package/dist/telemetry.d.ts.map +1 -1
  58. package/dist/telemetry.js +156 -40
  59. package/dist/telemetry.js.map +1 -1
  60. package/dist/tools/memory-search.d.ts +3 -1
  61. package/dist/tools/memory-search.d.ts.map +1 -1
  62. package/dist/tools/memory-search.js +3 -1
  63. package/dist/tools/memory-search.js.map +1 -1
  64. package/dist/types.d.ts +1 -2
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/viewer/html.d.ts.map +1 -1
  68. package/dist/viewer/html.js +2660 -889
  69. package/dist/viewer/html.js.map +1 -1
  70. package/dist/viewer/server.d.ts +30 -8
  71. package/dist/viewer/server.d.ts.map +1 -1
  72. package/dist/viewer/server.js +965 -193
  73. package/dist/viewer/server.js.map +1 -1
  74. package/index.ts +384 -43
  75. package/openclaw.plugin.json +1 -1
  76. package/package.json +3 -2
  77. package/scripts/postinstall.cjs +1 -1
  78. package/skill/memos-memory-guide/SKILL.md +64 -26
  79. package/src/capture/index.ts +37 -1
  80. package/src/client/connector.ts +91 -28
  81. package/src/client/hub.ts +18 -0
  82. package/src/client/skill-sync.ts +14 -0
  83. package/src/config.ts +0 -2
  84. package/src/hub/server.ts +259 -78
  85. package/src/hub/user-manager.ts +7 -3
  86. package/src/index.ts +10 -2
  87. package/src/ingest/providers/index.ts +41 -7
  88. package/src/recall/engine.ts +84 -1
  89. package/src/shared/llm-call.ts +97 -9
  90. package/src/sharing/types.ts +1 -1
  91. package/src/skill/evolver.ts +5 -0
  92. package/src/storage/ensure-binding.ts +52 -0
  93. package/src/storage/sqlite.ts +295 -144
  94. package/src/telemetry.ts +172 -41
  95. package/src/tools/memory-search.ts +2 -1
  96. package/src/types.ts +1 -2
  97. package/src/viewer/html.ts +2660 -889
  98. package/src/viewer/server.ts +888 -177
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,9 +201,14 @@ 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 || "";
195
208
  const existingUsers = this.opts.store.listHubUsers();
196
209
  const existingUser = existingUsers.find(u => u.username === username);
197
210
  if (existingUser) {
211
+ try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
198
212
  if (existingUser.status === "active") {
199
213
  const token = issueUserToken(
200
214
  { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
@@ -204,11 +218,22 @@ export class HubServer {
204
218
  return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
205
219
  }
206
220
  if (existingUser.status === "pending") {
221
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
207
222
  return this.json(res, 200, { status: "pending", userId: existingUser.id });
208
223
  }
209
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") {
210
234
  this.userManager.resetToPending(existingUser.id);
211
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
235
+ this.notifyAdmins("user_join_request", "user", username, "");
236
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied, reset to pending`);
212
237
  return this.json(res, 200, { status: "pending", userId: existingUser.id });
213
238
  }
214
239
  }
@@ -216,7 +241,9 @@ export class HubServer {
216
241
  username,
217
242
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
218
243
  });
244
+ try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
219
245
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
246
+ this.notifyAdmins("user_join_request", "user", username, "");
220
247
  return this.json(res, 200, { status: "pending", userId: user.id });
221
248
  }
222
249
 
@@ -254,6 +281,20 @@ export class HubServer {
254
281
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
255
282
  }
256
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
+
257
298
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
258
299
  const user = this.opts.store.getHubUser(auth.userId);
259
300
  if (!user) return this.json(res, 401, { error: "unauthorized" });
@@ -272,9 +313,11 @@ export class HubServer {
272
313
  }
273
314
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
274
315
  if (!updated) return this.json(res, 404, { error: "not_found" });
316
+ const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
275
317
  const newToken = issueUserToken(
276
318
  { userId: updated.id, username: newUsername, role: updated.role, status: updated.status },
277
319
  this.authSecret,
320
+ ttlMs,
278
321
  );
279
322
  this.userManager.approveUser(updated.id, newToken);
280
323
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
@@ -292,6 +335,7 @@ export class HubServer {
292
335
  const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
293
336
  const approved = this.userManager.approveUser(String(body.userId), token);
294
337
  if (!approved) return this.json(res, 404, { error: "not_found" });
338
+ try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
295
339
  return this.json(res, 200, { status: "active", token });
296
340
  }
297
341
 
@@ -306,97 +350,85 @@ export class HubServer {
306
350
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
307
351
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
308
352
  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 })) });
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
+ }) });
310
367
  }
311
368
 
312
- // ── Group management ──
313
-
314
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
369
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
315
370
  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 });
371
+ const body = await this.readJson(req);
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" });
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 });
318
384
  }
319
385
 
320
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
386
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
321
387
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
322
388
  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 });
345
- }
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 });
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" });
359
393
  }
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 });
394
+ if (this.userManager.isUsernameTaken(newUsername, userId)) {
395
+ return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
366
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 });
367
411
  }
368
412
 
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
- }
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 });
394
425
  }
395
426
 
396
427
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
397
428
  const body = await this.readJson(req);
398
429
  if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
399
430
  const task = { ...body.task, sourceUserId: auth.userId };
431
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
400
432
  this.opts.store.upsertHubTask(task);
401
433
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
402
434
  const chunkIds: string[] = [];
@@ -404,16 +436,23 @@ export class HubServer {
404
436
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
405
437
  chunkIds.push(chunk.id);
406
438
  }
407
- // Async embedding: don't block the response
408
439
  if (this.opts.embedder && chunkIds.length > 0) {
409
440
  this.embedChunksAsync(chunkIds, chunks);
410
441
  }
442
+ if (!existingTask) {
443
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
444
+ }
411
445
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
412
446
  }
413
447
 
414
448
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
415
449
  const body = await this.readJson(req);
416
- 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
+ }
417
456
  return this.json(res, 200, { ok: true });
418
457
  }
419
458
 
@@ -444,6 +483,9 @@ export class HubServer {
444
483
  if (this.opts.embedder) {
445
484
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
446
485
  }
486
+ if (!existing) {
487
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
488
+ }
447
489
  return this.json(res, 200, { ok: true, memoryId, visibility });
448
490
  }
449
491
 
@@ -451,7 +493,11 @@ export class HubServer {
451
493
  const body = await this.readJson(req);
452
494
  const sourceChunkId = String(body?.sourceChunkId || "");
453
495
  if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
496
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
454
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
+ }
455
501
  return this.json(res, 200, { ok: true });
456
502
  }
457
503
 
@@ -574,6 +620,7 @@ export class HubServer {
574
620
  visibility: hit.visibility,
575
621
  groupName: hit.group_name,
576
622
  ownerName: hit.owner_name || "unknown",
623
+ ownerStatus: hit.owner_status || "",
577
624
  qualityScore: hit.quality_score,
578
625
  }));
579
626
  return this.json(res, 200, { hits });
@@ -601,6 +648,9 @@ export class HubServer {
601
648
  createdAt: existing?.createdAt ?? Date.now(),
602
649
  updatedAt: Date.now(),
603
650
  });
651
+ if (!existing) {
652
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
653
+ }
604
654
  return this.json(res, 200, { ok: true, skillId, visibility });
605
655
  }
606
656
 
@@ -623,7 +673,12 @@ export class HubServer {
623
673
 
624
674
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
625
675
  const body = await this.readJson(req);
626
- 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
+ }
627
682
  return this.json(res, 200, { ok: true });
628
683
  }
629
684
 
@@ -635,12 +690,48 @@ export class HubServer {
635
690
  return this.json(res, 200, { tasks });
636
691
  }
637
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
+
638
725
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
639
726
  if (adminTaskDeleteMatch) {
640
727
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
641
728
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
729
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
642
730
  const deleted = this.opts.store.deleteHubTaskById(taskId);
643
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
+ }
644
735
  return this.json(res, 200, { ok: true });
645
736
  }
646
737
 
@@ -654,8 +745,12 @@ export class HubServer {
654
745
  if (adminSkillDeleteMatch) {
655
746
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
656
747
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
748
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
657
749
  const deleted = this.opts.store.deleteHubSkillById(skillId);
658
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
+ }
659
754
  return this.json(res, 200, { ok: true });
660
755
  }
661
756
 
@@ -669,8 +764,12 @@ export class HubServer {
669
764
  if (adminMemoryDeleteMatch) {
670
765
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
671
766
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
767
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
672
768
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
673
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
+ }
674
773
  return this.json(res, 200, { ok: true });
675
774
  }
676
775
 
@@ -693,9 +792,87 @@ export class HubServer {
693
792
  });
694
793
  }
695
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
+
696
814
  return this.json(res, 404, { error: "not_found" });
697
815
  }
698
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
+
699
876
  private authenticate(req: http.IncomingMessage) {
700
877
  const header = req.headers.authorization;
701
878
  if (!header || !header.startsWith("Bearer ")) return null;
@@ -706,6 +883,10 @@ export class HubServer {
706
883
  if (!user || user.status !== "active") return null;
707
884
  const hash = createHash("sha256").update(token).digest("hex");
708
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 */ }
709
890
  return {
710
891
  userId: user.id,
711
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 },
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { v4 as uuid } from "uuid";
2
2
  import { buildContext } from "./config";
3
+ import { ensureSqliteBinding } from "./storage/ensure-binding";
3
4
  import { SqliteStore } from "./storage/sqlite";
4
5
  import { Embedder } from "./embedding";
5
6
  import { IngestWorker } from "./ingest/worker";
@@ -56,13 +57,17 @@ export function initPlugin(opts: PluginInitOptions = {}): MemosLocalPlugin {
56
57
 
57
58
  ctx.log.info("Initializing memos-local plugin...");
58
59
 
60
+ ensureSqliteBinding(ctx.log);
61
+
59
62
  const store = new SqliteStore(ctx.config.storage!.dbPath!, ctx.log);
60
63
  const embedder = new Embedder(ctx.config.embedding, ctx.log, ctx.openclawAPI);
61
64
  const worker = new IngestWorker(store, embedder, ctx);
62
65
  const engine = new RecallEngine(store, embedder, ctx);
63
66
 
67
+ const sharedState = { lastSearchTime: 0 };
68
+
64
69
  const tools: ToolDefinition[] = [
65
- createMemorySearchTool(engine, store, ctx),
70
+ createMemorySearchTool(engine, store, ctx, sharedState),
66
71
  createMemoryTimelineTool(store),
67
72
  createMemoryGetTool(store),
68
73
  createNetworkMemoryDetailTool(store, ctx),
@@ -84,7 +89,10 @@ export function initPlugin(opts: PluginInitOptions = {}): MemosLocalPlugin {
84
89
  const turnId = uuid();
85
90
  const tag = ctx.config.capture?.evidenceWrapperTag ?? "STORED_MEMORY";
86
91
 
87
- const captured = captureMessages(messages, session, turnId, tag, ctx.log, owner);
92
+ const userSearchTime = sharedState.lastSearchTime || 0;
93
+ sharedState.lastSearchTime = 0;
94
+
95
+ const captured = captureMessages(messages, session, turnId, tag, ctx.log, owner, userSearchTime);
88
96
  if (captured.length > 0) {
89
97
  worker.enqueue(captured);
90
98
  }