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

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 (99) hide show
  1. package/.env.example +7 -0
  2. package/README.md +94 -27
  3. package/dist/capture/index.js +3 -1
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts +5 -0
  6. package/dist/client/connector.d.ts.map +1 -1
  7. package/dist/client/connector.js +132 -10
  8. package/dist/client/connector.js.map +1 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +2 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/hub/server.d.ts +2 -0
  13. package/dist/hub/server.d.ts.map +1 -1
  14. package/dist/hub/server.js +251 -38
  15. package/dist/hub/server.js.map +1 -1
  16. package/dist/hub/user-manager.d.ts +9 -0
  17. package/dist/hub/user-manager.d.ts.map +1 -1
  18. package/dist/hub/user-manager.js +26 -2
  19. package/dist/hub/user-manager.js.map +1 -1
  20. package/dist/ingest/chunker.d.ts +2 -1
  21. package/dist/ingest/chunker.d.ts.map +1 -1
  22. package/dist/ingest/chunker.js +14 -10
  23. package/dist/ingest/chunker.js.map +1 -1
  24. package/dist/ingest/providers/index.js +2 -2
  25. package/dist/ingest/providers/index.js.map +1 -1
  26. package/dist/recall/engine.d.ts.map +1 -1
  27. package/dist/recall/engine.js +96 -1
  28. package/dist/recall/engine.js.map +1 -1
  29. package/dist/shared/llm-call.d.ts.map +1 -1
  30. package/dist/shared/llm-call.js +2 -1
  31. package/dist/shared/llm-call.js.map +1 -1
  32. package/dist/sharing/types.d.ts +1 -1
  33. package/dist/sharing/types.d.ts.map +1 -1
  34. package/dist/skill/evolver.d.ts +2 -0
  35. package/dist/skill/evolver.d.ts.map +1 -1
  36. package/dist/skill/evolver.js +56 -5
  37. package/dist/skill/evolver.js.map +1 -1
  38. package/dist/skill/generator.d.ts +2 -0
  39. package/dist/skill/generator.d.ts.map +1 -1
  40. package/dist/skill/generator.js +45 -3
  41. package/dist/skill/generator.js.map +1 -1
  42. package/dist/skill/installer.d.ts +26 -0
  43. package/dist/skill/installer.d.ts.map +1 -1
  44. package/dist/skill/installer.js +80 -4
  45. package/dist/skill/installer.js.map +1 -1
  46. package/dist/skill/upgrader.d.ts +2 -0
  47. package/dist/skill/upgrader.d.ts.map +1 -1
  48. package/dist/skill/upgrader.js +139 -1
  49. package/dist/skill/upgrader.js.map +1 -1
  50. package/dist/skill/validator.d.ts +3 -0
  51. package/dist/skill/validator.d.ts.map +1 -1
  52. package/dist/skill/validator.js +75 -0
  53. package/dist/skill/validator.js.map +1 -1
  54. package/dist/storage/sqlite.d.ts +58 -0
  55. package/dist/storage/sqlite.d.ts.map +1 -1
  56. package/dist/storage/sqlite.js +295 -35
  57. package/dist/storage/sqlite.js.map +1 -1
  58. package/dist/telemetry.d.ts.map +1 -1
  59. package/dist/telemetry.js +27 -8
  60. package/dist/telemetry.js.map +1 -1
  61. package/dist/types.d.ts +10 -0
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +4 -0
  64. package/dist/types.js.map +1 -1
  65. package/dist/viewer/html.d.ts.map +1 -1
  66. package/dist/viewer/html.js +796 -289
  67. package/dist/viewer/html.js.map +1 -1
  68. package/dist/viewer/server.d.ts +11 -0
  69. package/dist/viewer/server.d.ts.map +1 -1
  70. package/dist/viewer/server.js +456 -92
  71. package/dist/viewer/server.js.map +1 -1
  72. package/index.ts +411 -52
  73. package/openclaw.plugin.json +1 -1
  74. package/package.json +2 -1
  75. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  76. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  77. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  78. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  79. package/src/capture/index.ts +4 -1
  80. package/src/client/connector.ts +136 -10
  81. package/src/config.ts +2 -1
  82. package/src/hub/server.ts +246 -38
  83. package/src/hub/user-manager.ts +42 -6
  84. package/src/ingest/chunker.ts +19 -13
  85. package/src/ingest/providers/index.ts +2 -2
  86. package/src/recall/engine.ts +89 -1
  87. package/src/shared/llm-call.ts +2 -1
  88. package/src/sharing/types.ts +1 -1
  89. package/src/skill/evolver.ts +58 -6
  90. package/src/skill/generator.ts +44 -5
  91. package/src/skill/installer.ts +107 -4
  92. package/src/skill/upgrader.ts +139 -1
  93. package/src/skill/validator.ts +79 -0
  94. package/src/storage/sqlite.ts +326 -40
  95. package/src/telemetry.ts +27 -9
  96. package/src/types.ts +11 -0
  97. package/src/viewer/html.ts +796 -289
  98. package/src/viewer/server.ts +430 -89
  99. package/telemetry.credentials.json +5 -0
package/src/hub/server.ts CHANGED
@@ -14,6 +14,7 @@ type HubServerOptions = {
14
14
  config: MemosLocalConfig;
15
15
  dataDir: string;
16
16
  embedder?: Embedder;
17
+ defaultHubPort?: number;
17
18
  };
18
19
 
19
20
  type HubAuthState = {
@@ -79,18 +80,31 @@ export class HubServer {
79
80
  }
80
81
  });
81
82
 
83
+ const MAX_PORT_RETRIES = 3;
84
+ let hubPort = this.port;
82
85
  await new Promise<void>((resolve, reject) => {
83
- const onError = (err: Error) => {
84
- this.server?.off("listening", onListening);
85
- reject(err);
86
+ let retries = 0;
87
+ const onError = (err: NodeJS.ErrnoException) => {
88
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
89
+ retries++;
90
+ hubPort = this.port + retries;
91
+ this.opts.log.warn(`Hub port ${hubPort - 1} in use, trying ${hubPort}`);
92
+ this.server!.listen(hubPort, "0.0.0.0");
93
+ } else {
94
+ this.server?.off("listening", onListening);
95
+ reject(err);
96
+ }
86
97
  };
87
98
  const onListening = () => {
88
99
  this.server?.off("error", onError);
100
+ if (hubPort !== this.port) {
101
+ this.opts.log.info(`Hub started on fallback port ${hubPort} (configured: ${this.port})`);
102
+ }
89
103
  resolve();
90
104
  };
91
- this.server!.once("error", onError);
105
+ this.server!.on("error", onError);
92
106
  this.server!.once("listening", onListening);
93
- this.server!.listen(this.port, "0.0.0.0");
107
+ this.server!.listen(hubPort, "0.0.0.0");
94
108
  });
95
109
 
96
110
  const bootstrap = this.userManager.ensureBootstrapAdmin(
@@ -109,19 +123,37 @@ export class HubServer {
109
123
  this.initOnlineTracking();
110
124
  this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
111
125
 
112
- return `http://127.0.0.1:${this.port}`;
126
+ return `http://127.0.0.1:${hubPort}`;
113
127
  }
114
128
 
115
129
  async stop(): Promise<void> {
116
130
  if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
117
131
  if (!this.server) return;
132
+
133
+ try {
134
+ const activeUsers = this.opts.store.listHubUsers("active");
135
+ const ownerId = this.authState.bootstrapAdminUserId || "";
136
+ for (const u of activeUsers) {
137
+ if (u.id === ownerId) continue;
138
+ try {
139
+ this.opts.store.insertHubNotification({
140
+ id: randomUUID(), userId: u.id, type: "hub_shutdown",
141
+ resource: "system", title: `Team server "${this.teamName}" has been shut down by the admin.`,
142
+ });
143
+ } catch { /* best-effort */ }
144
+ }
145
+ } catch { /* best-effort */ }
146
+
118
147
  const server = this.server;
119
148
  this.server = undefined;
120
149
  await new Promise<void>((resolve) => server.close(() => resolve()));
121
150
  }
122
151
 
123
152
  private get port(): number {
124
- return this.opts.config.sharing?.hub?.port ?? 18800;
153
+ const configured = this.opts.config.sharing?.hub?.port;
154
+ const derived = this.opts.defaultHubPort;
155
+ if (derived && (!configured || configured === 18800)) return derived;
156
+ return configured ?? 18800;
125
157
  }
126
158
 
127
159
  private get teamName(): string {
@@ -169,6 +201,20 @@ export class HubServer {
169
201
  });
170
202
  }
171
203
 
204
+ private embedSkillAsync(skillId: string, name: string, description: string, sourceUserId: string, sourceSkillId: string): void {
205
+ const embedder = this.opts.embedder;
206
+ if (!embedder) return;
207
+ const text = `${name}: ${description}`;
208
+ embedder.embed([text]).then((vectors) => {
209
+ if (vectors[0]) {
210
+ this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
211
+ this.opts.log.info(`hub: embedded shared skill ${skillId}`);
212
+ }
213
+ }).catch((err) => {
214
+ this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
215
+ });
216
+ }
217
+
172
218
  private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
173
219
  const embedder = this.opts.embedder;
174
220
  if (!embedder) return;
@@ -205,35 +251,70 @@ export class HubServer {
205
251
  || (req.headers["x-client-ip"] as string)?.trim()
206
252
  || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
207
253
  || req.socket.remoteAddress || "";
208
- const existingUsers = this.opts.store.listHubUsers();
209
- const existingUser = existingUsers.find(u => u.username === username);
254
+ const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
255
+
256
+ let existingUser = identityKey
257
+ ? this.userManager.findByIdentityKey(identityKey)
258
+ : null;
259
+ if (!existingUser) {
260
+ const existingUsers = this.opts.store.listHubUsers();
261
+ existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
262
+ }
263
+
210
264
  if (existingUser) {
211
265
  try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
266
+
212
267
  if (existingUser.status === "active") {
213
268
  const token = issueUserToken(
214
269
  { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
215
270
  this.authSecret,
216
271
  );
217
272
  this.userManager.approveUser(existingUser.id, token);
218
- return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
273
+ if (identityKey && !existingUser.identityKey) {
274
+ this.opts.store.upsertHubUser({ ...existingUser, identityKey });
275
+ }
276
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
219
277
  }
220
278
  if (existingUser.status === "pending") {
221
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
279
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
280
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
222
281
  }
223
282
  if (existingUser.status === "rejected") {
224
- this.userManager.resetToPending(existingUser.id);
225
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
226
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
283
+ if (body.reapply === true) {
284
+ this.userManager.resetToPending(existingUser.id);
285
+ this.notifyAdmins("user_join_request", "user", username, "");
286
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
287
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
288
+ }
289
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
290
+ }
291
+ if (existingUser.status === "removed") {
292
+ this.userManager.rejoinUser(existingUser.id);
293
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
294
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
295
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
296
+ }
297
+ if (existingUser.status === "left") {
298
+ this.userManager.rejoinUser(existingUser.id);
299
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
300
+ this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
301
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
302
+ }
303
+ if (existingUser.status === "blocked") {
304
+ return this.json(res, 200, { status: "blocked", userId: existingUser.id });
227
305
  }
228
306
  }
307
+
308
+ const generatedIdentityKey = identityKey || randomUUID();
229
309
  const user = this.userManager.createPendingUser({
230
310
  username,
231
311
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
312
+ identityKey: generatedIdentityKey,
232
313
  });
233
314
  try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
234
315
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
235
316
  this.notifyAdmins("user_join_request", "user", username, "");
236
- return this.json(res, 200, { status: "pending", userId: user.id });
317
+ return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
237
318
  }
238
319
 
239
320
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
@@ -251,16 +332,42 @@ export class HubServer {
251
332
  if (user.status === "rejected") {
252
333
  return this.json(res, 200, { status: "rejected" });
253
334
  }
335
+ if (user.status === "blocked") {
336
+ return this.json(res, 200, { status: "blocked" });
337
+ }
338
+ if (user.status === "left") {
339
+ return this.json(res, 200, { status: "left" });
340
+ }
341
+ if (user.status === "removed") {
342
+ return this.json(res, 200, { status: "removed" });
343
+ }
254
344
  if (user.status === "active") {
255
345
  const token = issueUserToken(
256
346
  { userId: user.id, username: user.username, role: user.role, status: user.status },
257
347
  this.authSecret,
258
348
  );
349
+ this.userManager.approveUser(user.id, token);
259
350
  return this.json(res, 200, { status: "active", userToken: token });
260
351
  }
261
352
  return this.json(res, 200, { status: user.status });
262
353
  }
263
354
 
355
+ if (req.method === "POST" && routePath === "/api/v1/hub/withdraw-pending") {
356
+ const body = await this.readJson(req);
357
+ if (!body || body.teamToken !== this.teamToken) {
358
+ return this.json(res, 403, { error: "invalid_team_token" });
359
+ }
360
+ const userId = String(body.userId || "");
361
+ if (!userId) return this.json(res, 400, { error: "missing_user_id" });
362
+ const user = this.opts.store.getHubUser(userId);
363
+ if (!user) return this.json(res, 200, { ok: true });
364
+ if (user.status === "pending") {
365
+ this.userManager.markUserLeft(userId);
366
+ this.opts.log.info(`Hub: user "${user.username}" (${userId}) withdrew pending application`);
367
+ }
368
+ return this.json(res, 200, { ok: true });
369
+ }
370
+
264
371
  // All endpoints below require authentication + rate limiting
265
372
  const auth = this.authenticate(req);
266
373
  if (!auth) return this.json(res, 401, { error: "unauthorized" });
@@ -275,12 +382,10 @@ export class HubServer {
275
382
  }
276
383
 
277
384
  if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
278
- try {
279
- this.opts.store.updateHubUserActivity(auth.userId, "", 0);
280
- } catch { /* best-effort */ }
385
+ this.userManager.markUserLeft(auth.userId);
281
386
  this.knownOnlineUsers.delete(auth.userId);
282
- this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
283
- this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
387
+ this.notifyAdmins("user_left", "user", auth.username, auth.userId);
388
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
284
389
  return this.json(res, 200, { ok: true });
285
390
  }
286
391
 
@@ -309,6 +414,10 @@ export class HubServer {
309
414
  ttlMs,
310
415
  );
311
416
  this.userManager.approveUser(updated.id, newToken);
417
+ if (updated.id === this.authState.bootstrapAdminUserId) {
418
+ this.authState.bootstrapAdminToken = newToken;
419
+ this.saveAuthState();
420
+ }
312
421
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
313
422
  return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
314
423
  }
@@ -321,18 +430,33 @@ export class HubServer {
321
430
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/approve-user") {
322
431
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
323
432
  const body = await this.readJson(req);
324
- const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
325
- const approved = this.userManager.approveUser(String(body.userId), token);
433
+ const userId = String(body.userId);
434
+ const username = String(body.username || "");
435
+ const token = issueUserToken({ userId, username, role: "member", status: "active" }, this.authSecret);
436
+ const approved = this.userManager.approveUser(userId, token);
326
437
  if (!approved) return this.json(res, 404, { error: "not_found" });
327
- try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
438
+ try { this.opts.store.updateHubUserActivity(userId, ""); } catch { /* best-effort */ }
439
+ try {
440
+ this.opts.store.insertHubNotification({
441
+ id: randomUUID(), userId, type: "membership_approved",
442
+ resource: "user", title: `Your request to join team "${this.teamName}" has been approved. Welcome!`,
443
+ });
444
+ } catch { /* best-effort */ }
328
445
  return this.json(res, 200, { status: "active", token });
329
446
  }
330
447
 
331
448
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
332
449
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
333
450
  const body = await this.readJson(req);
334
- const rejected = this.userManager.rejectUser(String(body.userId));
451
+ const userId = String(body.userId);
452
+ const rejected = this.userManager.rejectUser(userId);
335
453
  if (!rejected) return this.json(res, 404, { error: "not_found" });
454
+ try {
455
+ this.opts.store.insertHubNotification({
456
+ id: randomUUID(), userId, type: "membership_rejected",
457
+ resource: "user", title: `Your request to join team "${this.teamName}" has been declined.`,
458
+ });
459
+ } catch { /* best-effort */ }
336
460
  return this.json(res, 200, { status: "rejected" });
337
461
  }
338
462
 
@@ -369,6 +493,13 @@ export class HubServer {
369
493
  const updatedUser = { ...user, role: newRole as "admin" | "member" };
370
494
  this.opts.store.upsertHubUser(updatedUser);
371
495
  this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
496
+ try {
497
+ const notifType = newRole === "admin" ? "role_promoted" : "role_demoted";
498
+ this.opts.store.insertHubNotification({
499
+ id: randomUUID(), userId, type: notifType,
500
+ resource: "user", title: `Your role in team "${this.teamName}" has been changed to ${newRole}.`,
501
+ });
502
+ } catch { /* best-effort */ }
372
503
  return this.json(res, 200, { ok: true, role: newRole });
373
504
  }
374
505
 
@@ -395,6 +526,10 @@ export class HubServer {
395
526
  const updated = this.opts.store.getHubUser(userId)!;
396
527
  const finalUser = { ...updated, username: newUsername };
397
528
  this.opts.store.upsertHubUser(finalUser);
529
+ if (userId === this.authState.bootstrapAdminUserId) {
530
+ this.authState.bootstrapAdminToken = newToken;
531
+ this.saveAuthState();
532
+ }
398
533
  this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
399
534
  return this.json(res, 200, { ok: true, username: newUsername });
400
535
  }
@@ -406,9 +541,16 @@ export class HubServer {
406
541
  if (!userId) return this.json(res, 400, { error: "missing_user_id" });
407
542
  if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
408
543
  if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
544
+ try {
545
+ this.opts.store.insertHubNotification({
546
+ id: randomUUID(), userId, type: "membership_removed",
547
+ resource: "user", title: `You have been removed from team "${this.teamName}" by the admin.`,
548
+ });
549
+ } catch { /* best-effort */ }
409
550
  const cleanResources = body?.cleanResources === true;
410
551
  const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
411
552
  if (!deleted) return this.json(res, 404, { error: "not_found" });
553
+ this.knownOnlineUsers.delete(userId);
412
554
  this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
413
555
  return this.json(res, 200, { ok: true });
414
556
  }
@@ -598,19 +740,70 @@ export class HubServer {
598
740
  }
599
741
 
600
742
  if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
601
- const hits = this.opts.store.searchHubSkills(String(url.searchParams.get("query") || ""), {
743
+ const skillQuery = String(url.searchParams.get("query") || "");
744
+ const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
745
+ const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
602
746
  userId: auth.userId,
603
- maxResults: Number(url.searchParams.get("maxResults") || 10),
604
- }).map(({ hit }) => ({
605
- skillId: hit.id,
606
- name: hit.name,
607
- description: hit.description,
608
- version: hit.version,
609
- visibility: hit.visibility,
610
- groupName: hit.group_name,
611
- ownerName: hit.owner_name || "unknown",
612
- qualityScore: hit.quality_score,
613
- }));
747
+ maxResults: skillMaxResults * 2,
748
+ });
749
+
750
+ let mergedSkillIds: string[];
751
+ if (this.opts.embedder && skillQuery) {
752
+ try {
753
+ const [queryVec] = await this.opts.embedder.embed([skillQuery]);
754
+ if (queryVec) {
755
+ const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
756
+ const cosineSim = (vec: Float32Array) => {
757
+ let dot = 0, nA = 0, nB = 0;
758
+ for (let i = 0; i < queryVec.length && i < vec.length; i++) {
759
+ dot += queryVec[i] * vec[i]; nA += queryVec[i] * queryVec[i]; nB += vec[i] * vec[i];
760
+ }
761
+ return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
762
+ };
763
+ const vecScored = skillEmbs
764
+ .map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
765
+ .filter(e => e.score > 0.3)
766
+ .sort((a, b) => b.score - a.score)
767
+ .slice(0, skillMaxResults * 2);
768
+
769
+ const K = 60;
770
+ const rrfScores = new Map<string, number>();
771
+ ftsSkillHits.forEach(({ hit }, idx) => {
772
+ rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
773
+ });
774
+ vecScored.forEach(({ id }, idx) => {
775
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
776
+ });
777
+ mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
778
+ } else {
779
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
780
+ }
781
+ } catch {
782
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
783
+ }
784
+ } else {
785
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
786
+ }
787
+
788
+ const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
789
+ const hits = mergedSkillIds.map(id => {
790
+ const hit = ftsSkillMap.get(id);
791
+ if (hit) {
792
+ return {
793
+ skillId: hit.id, name: hit.name, description: hit.description,
794
+ version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
795
+ ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
796
+ qualityScore: hit.quality_score,
797
+ };
798
+ }
799
+ const skill = this.opts.store.getHubSkillById(id);
800
+ if (!skill) return null;
801
+ return {
802
+ skillId: skill.id, name: skill.name, description: skill.description,
803
+ version: skill.version, visibility: skill.visibility, groupName: "",
804
+ ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
805
+ };
806
+ }).filter(Boolean);
614
807
  return this.json(res, 200, { hits });
615
808
  }
616
809
 
@@ -636,6 +829,7 @@ export class HubServer {
636
829
  createdAt: existing?.createdAt ?? Date.now(),
637
830
  updatedAt: Date.now(),
638
831
  });
832
+ this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
639
833
  if (!existing) {
640
834
  this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
641
835
  }
@@ -756,7 +950,18 @@ export class HubServer {
756
950
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
757
951
  if (!deleted) return this.json(res, 404, { error: "not_found" });
758
952
  if (memInfo) {
759
- this.opts.store.insertHubNotification({ id: randomUUID(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
953
+ const payload = JSON.stringify({
954
+ memoryId,
955
+ sourceChunkId: memInfo.sourceChunkId,
956
+ });
957
+ this.opts.store.insertHubNotification({
958
+ id: randomUUID(),
959
+ userId: memInfo.sourceUserId,
960
+ type: "resource_removed",
961
+ resource: "memory",
962
+ title: memInfo.summary || memInfo.id,
963
+ message: payload,
964
+ });
760
965
  }
761
966
  return this.json(res, 200, { ok: true });
762
967
  }
@@ -802,10 +1007,13 @@ export class HubServer {
802
1007
  return this.json(res, 404, { error: "not_found" });
803
1008
  }
804
1009
 
805
- private notifyAdmins(type: string, resource: string, title: string, fromUserId: string): void {
1010
+ private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
806
1011
  try {
807
1012
  const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
808
1013
  for (const admin of admins) {
1014
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
1015
+ continue;
1016
+ }
809
1017
  this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
810
1018
  }
811
1019
  } catch { /* best-effort */ }
@@ -4,13 +4,24 @@ 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; lastIp: string; lastActiveAt: number | null };
7
+ type ManagedHubUser = UserInfo & {
8
+ tokenHash: string;
9
+ createdAt: number;
10
+ approvedAt: number | null;
11
+ lastIp: string;
12
+ lastActiveAt: number | null;
13
+ identityKey?: string;
14
+ leftAt?: number | null;
15
+ removedAt?: number | null;
16
+ rejectedAt?: number | null;
17
+ rejoinRequestedAt?: number | null;
18
+ };
8
19
 
9
20
  export class HubUserManager {
10
21
  constructor(private store: SqliteStore, private log: Logger) {}
11
22
 
12
- createPendingUser(input: { username: string; deviceName?: string }): ManagedHubUser {
13
- const user = {
23
+ createPendingUser(input: { username: string; deviceName?: string; identityKey?: string }): ManagedHubUser {
24
+ const user: ManagedHubUser = {
14
25
  id: randomUUID(),
15
26
  username: input.username,
16
27
  deviceName: input.deviceName,
@@ -22,11 +33,36 @@ export class HubUserManager {
22
33
  approvedAt: null,
23
34
  lastIp: "",
24
35
  lastActiveAt: null,
36
+ identityKey: input.identityKey || "",
25
37
  };
26
38
  this.store.upsertHubUser(user);
27
39
  return user;
28
40
  }
29
41
 
42
+ findByIdentityKey(identityKey: string): ManagedHubUser | null {
43
+ if (!identityKey) return null;
44
+ return this.store.findHubUserByIdentityKey(identityKey);
45
+ }
46
+
47
+ markUserLeft(userId: string): boolean {
48
+ this.log.info(`Hub: user "${userId}" marked as left`);
49
+ return this.store.markHubUserLeft(userId);
50
+ }
51
+
52
+ rejoinUser(userId: string): ManagedHubUser | null {
53
+ const user = this.store.getHubUser(userId);
54
+ if (!user) return null;
55
+ const updated: ManagedHubUser = {
56
+ ...user,
57
+ status: "pending" as const,
58
+ tokenHash: "",
59
+ rejoinRequestedAt: Date.now(),
60
+ };
61
+ this.store.upsertHubUser(updated);
62
+ this.log.info(`Hub: user "${userId}" (${user.username}) requested rejoin, previous status: ${user.status}`);
63
+ return updated;
64
+ }
65
+
30
66
  listPendingUsers(): ManagedHubUser[] {
31
67
  return this.store.listHubUsers("pending");
32
68
  }
@@ -105,7 +141,7 @@ export class HubUserManager {
105
141
 
106
142
  isUsernameTaken(username: string, excludeUserId?: string): boolean {
107
143
  const users = this.store.listHubUsers();
108
- return users.some(u => u.username === username && u.id !== excludeUserId);
144
+ return users.some(u => u.username === username && u.id !== excludeUserId && u.status !== "left" && u.status !== "removed");
109
145
  }
110
146
 
111
147
  updateUsername(userId: string, newUsername: string): ManagedHubUser | null {
@@ -119,10 +155,10 @@ export class HubUserManager {
119
155
  rejectUser(userId: string): ManagedHubUser | null {
120
156
  const user = this.store.getHubUser(userId);
121
157
  if (!user) return null;
122
- const updated = {
158
+ const updated: ManagedHubUser = {
123
159
  ...user,
124
160
  status: "rejected" as const,
125
- approvedAt: Date.now(),
161
+ rejectedAt: Date.now(),
126
162
  };
127
163
  this.store.upsertHubUser(updated);
128
164
  return updated;
@@ -1,6 +1,8 @@
1
+ export type ChunkKind = "paragraph" | "code_block" | "error_stack" | "list" | "command";
2
+
1
3
  export interface RawChunk {
2
4
  content: string;
3
- kind: "paragraph";
5
+ kind: ChunkKind;
4
6
  }
5
7
 
6
8
  const MAX_CHUNK_CHARS = 3000;
@@ -28,21 +30,25 @@ const COMMAND_LINE_RE = /^(?:\$|>|#)\s+.+$/gm;
28
30
  */
29
31
  export function chunkText(text: string): RawChunk[] {
30
32
  let remaining = text;
31
- const slots: Array<{ placeholder: string; content: string }> = [];
33
+ const slots: Array<{ placeholder: string; content: string; kind: ChunkKind }> = [];
32
34
  let counter = 0;
33
35
 
34
- function ph(content: string): string {
36
+ function ph(content: string, kind: ChunkKind = "paragraph"): string {
35
37
  const tag = `\x00SLOT_${counter++}\x00`;
36
- slots.push({ placeholder: tag, content: content.trim() });
38
+ slots.push({ placeholder: tag, content: content.trim(), kind });
37
39
  return tag;
38
40
  }
39
41
 
40
- remaining = remaining.replace(FENCED_CODE_RE, (m) => ph(m));
42
+ remaining = remaining.replace(FENCED_CODE_RE, (m) => ph(m, "code_block"));
41
43
  remaining = extractBraceBlocks(remaining, ph);
42
44
 
43
- const structural: RegExp[] = [ERROR_STACK_RE, LIST_BLOCK_RE, COMMAND_LINE_RE];
44
- for (const re of structural) {
45
- remaining = remaining.replace(re, (m) => ph(m));
45
+ const structuralKinds: Array<[RegExp, ChunkKind]> = [
46
+ [ERROR_STACK_RE, "error_stack"],
47
+ [LIST_BLOCK_RE, "list"],
48
+ [COMMAND_LINE_RE, "command"],
49
+ ];
50
+ for (const [re, kind] of structuralKinds) {
51
+ remaining = remaining.replace(re, (m) => ph(m, kind));
46
52
  }
47
53
 
48
54
  const raw: RawChunk[] = [];
@@ -57,7 +63,7 @@ export function chunkText(text: string): RawChunk[] {
57
63
  for (const part of parts) {
58
64
  const slot = slots.find((s) => s.placeholder === part);
59
65
  if (slot) {
60
- raw.push({ content: slot.content, kind: "paragraph" });
66
+ raw.push({ content: slot.content, kind: slot.kind });
61
67
  } else if (part.trim().length >= MIN_CHUNK_CHARS) {
62
68
  raw.push({ content: part.trim(), kind: "paragraph" });
63
69
  }
@@ -69,7 +75,7 @@ export function chunkText(text: string): RawChunk[] {
69
75
 
70
76
  for (const s of slots) {
71
77
  if (!raw.some((c) => c.content === s.content)) {
72
- raw.push({ content: s.content, kind: "paragraph" });
78
+ raw.push({ content: s.content, kind: s.kind });
73
79
  }
74
80
  }
75
81
 
@@ -85,7 +91,7 @@ export function chunkText(text: string): RawChunk[] {
85
91
  */
86
92
  function extractBraceBlocks(
87
93
  text: string,
88
- ph: (content: string) => string,
94
+ ph: (content: string, kind?: ChunkKind) => string,
89
95
  ): string {
90
96
  const lines = text.split("\n");
91
97
  const result: string[] = [];
@@ -119,7 +125,7 @@ function extractBraceBlocks(
119
125
  if (depth <= 0 || (BLOCK_CLOSE_RE.test(line) && depth <= 0)) {
120
126
  const block = blockLines.join("\n");
121
127
  if (block.trim().length >= MIN_CHUNK_CHARS) {
122
- result.push(ph(block));
128
+ result.push(ph(block, "code_block"));
123
129
  } else {
124
130
  result.push(block);
125
131
  }
@@ -135,7 +141,7 @@ function extractBraceBlocks(
135
141
  if (blockLines.length > 0) {
136
142
  const block = blockLines.join("\n");
137
143
  if (block.trim().length >= MIN_CHUNK_CHARS) {
138
- result.push(ph(block));
144
+ result.push(ph(block, "code_block"));
139
145
  } else {
140
146
  result.push(block);
141
147
  }
@@ -49,8 +49,8 @@ function normalizeEndpointForProvider(
49
49
  function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
50
50
  try {
51
51
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
52
- const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
53
- const cfgPath = path.join(ocHome, "openclaw.json");
52
+ const cfgPath = process.env.OPENCLAW_CONFIG_PATH
53
+ || path.join(process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw"), "openclaw.json");
54
54
  if (!fs.existsSync(cfgPath)) return undefined;
55
55
 
56
56
  const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));