@memtensor/memos-local-openclaw-plugin 1.0.4-beta.9 → 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 +89 -8
  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 +240 -35
  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 +22 -4
  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 +57 -0
  55. package/dist/storage/sqlite.d.ts.map +1 -1
  56. package/dist/storage/sqlite.js +290 -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 +564 -225
  67. package/dist/viewer/html.js.map +1 -1
  68. package/dist/viewer/server.d.ts +9 -0
  69. package/dist/viewer/server.d.ts.map +1 -1
  70. package/dist/viewer/server.js +357 -108
  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 +92 -8
  81. package/src/config.ts +2 -1
  82. package/src/hub/server.ts +235 -35
  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 +20 -4
  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 +318 -40
  95. package/src/telemetry.ts +27 -9
  96. package/src/types.ts +11 -0
  97. package/src/viewer/html.ts +564 -225
  98. package/src/viewer/server.ts +333 -105
  99. package/telemetry.credentials.json +5 -0
@@ -167,8 +167,11 @@ export function stripInboundMetadata(text: string): string {
167
167
  /** Strip <think…>…</think> blocks emitted by DeepSeek-style reasoning models. */
168
168
  const THINKING_TAG_RE = /<think[\s>][\s\S]*?<\/think>\s*/gi;
169
169
 
170
+ /** Unwrap <final>…</final> tags from MiniMax-style models (keep content, strip tags). */
171
+ const FINAL_TAG_RE = /<\/?final\s*>/gi;
172
+
170
173
  function stripThinkingTags(text: string): string {
171
- return text.replace(THINKING_TAG_RE, "");
174
+ return text.replace(THINKING_TAG_RE, "").replace(FINAL_TAG_RE, "").trim();
172
175
  }
173
176
 
174
177
  function extractEnvelopeTimestamp(text: string): number | null {
@@ -10,6 +10,7 @@ export interface HubSessionInfo {
10
10
  userToken: string;
11
11
  role: UserRole;
12
12
  connectedAt: number;
13
+ identityKey?: string;
13
14
  }
14
15
 
15
16
  export interface HubStatusInfo {
@@ -20,6 +21,7 @@ export interface HubStatusInfo {
20
21
  username: string;
21
22
  role: UserRole;
22
23
  status: UserStatus | string;
24
+ groups?: Array<{ id: string; name: string }>;
23
25
  };
24
26
  }
25
27
 
@@ -54,6 +56,8 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
54
56
  userToken: result.userToken,
55
57
  role: "member",
56
58
  connectedAt: Date.now(),
59
+ identityKey: persisted.identityKey || "",
60
+ lastKnownStatus: "active",
57
61
  });
58
62
  return store.getClientHubConnection()!;
59
63
  }
@@ -63,6 +67,12 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
63
67
  if (result.status === "rejected") {
64
68
  throw new Error("Join request was rejected by the Hub admin.");
65
69
  }
70
+ if (result.status === "blocked") {
71
+ throw new Error("Your account has been blocked by the Hub admin.");
72
+ }
73
+ if (result.status === "left" || result.status === "removed") {
74
+ log.info(`User status is "${result.status}", will try to rejoin.`);
75
+ }
66
76
  } catch (err) {
67
77
  if (err instanceof PendingApprovalError) throw err;
68
78
  log.warn(`registration-status check failed, falling back to autoJoinHub: ${err}`);
@@ -78,6 +88,7 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
78
88
 
79
89
  const hubUrl = normalizeHubUrl(hubAddress);
80
90
  const me = await hubRequestJson(hubUrl, userToken, "/api/v1/hub/me", { method: "GET" }) as any;
91
+ const persisted = store.getClientHubConnection();
81
92
  store.setClientHubConnection({
82
93
  hubUrl,
83
94
  userId: String(me.id),
@@ -85,6 +96,8 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
85
96
  userToken,
86
97
  role: String(me.role ?? "member") as UserRole,
87
98
  connectedAt: Date.now(),
99
+ identityKey: persisted?.identityKey || String(me.identityKey ?? ""),
100
+ lastKnownStatus: "active",
88
101
  });
89
102
  return store.getClientHubConnection()!;
90
103
  }
@@ -95,9 +108,13 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
95
108
  const hubAddress = conn?.hubUrl || (configHubAddress ? normalizeHubUrl(configHubAddress) : "");
96
109
  const userToken = conn?.userToken || config.sharing?.client?.userToken || "";
97
110
 
98
- // If DB has a connection to a different Hub than config, the DB data is stale
99
111
  if (conn && configHubAddress && conn.hubUrl && normalizeHubUrl(configHubAddress) !== conn.hubUrl) {
100
- store.clearClientHubConnection();
112
+ store.setClientHubConnection({
113
+ ...conn,
114
+ hubUrl: normalizeHubUrl(configHubAddress),
115
+ userToken: "",
116
+ lastKnownStatus: "hub_changed",
117
+ });
101
118
  return { connected: false, user: null };
102
119
  }
103
120
 
@@ -129,6 +146,8 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
129
146
  userToken: result.userToken,
130
147
  role: "member",
131
148
  connectedAt: Date.now(),
149
+ identityKey: conn.identityKey || "",
150
+ lastKnownStatus: "active",
132
151
  });
133
152
  const me = await hubRequestJson(normalizeHubUrl(hubAddress), result.userToken, "/api/v1/hub/me", { method: "GET" }) as any;
134
153
  return {
@@ -169,12 +188,10 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
169
188
  const latestRole = String(me.role ?? "member") as UserRole;
170
189
  if (conn && (conn.username !== latestUsername || conn.role !== latestRole)) {
171
190
  store.setClientHubConnection({
172
- hubUrl: conn.hubUrl,
173
- userId: conn.userId,
191
+ ...conn,
174
192
  username: latestUsername,
175
- userToken: conn.userToken,
176
193
  role: latestRole,
177
- connectedAt: conn.connectedAt,
194
+ lastKnownStatus: "active",
178
195
  });
179
196
  }
180
197
  return {
@@ -185,9 +202,63 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
185
202
  username: latestUsername,
186
203
  role: latestRole,
187
204
  status: String(me.status ?? "active"),
205
+ groups: Array.isArray(me.groups) ? me.groups : [],
188
206
  },
189
207
  };
190
- } catch {
208
+ } catch (err: any) {
209
+ const is401 = typeof err?.message === "string" && err.message.includes("(401)");
210
+ if (is401 && conn) {
211
+ const teamToken = config.sharing?.client?.teamToken ?? "";
212
+ if (hubAddress && teamToken) {
213
+ try {
214
+ const regResult = await hubRequestJson(normalizeHubUrl(hubAddress), "", "/api/v1/hub/registration-status", {
215
+ method: "POST",
216
+ body: JSON.stringify({ teamToken, userId: conn.userId }),
217
+ }) as any;
218
+ if (regResult.status === "active" && regResult.userToken) {
219
+ store.setClientHubConnection({
220
+ ...conn,
221
+ hubUrl: normalizeHubUrl(hubAddress),
222
+ userToken: regResult.userToken,
223
+ connectedAt: Date.now(),
224
+ lastKnownStatus: "active",
225
+ });
226
+ try {
227
+ const me = await hubRequestJson(normalizeHubUrl(hubAddress), regResult.userToken, "/api/v1/hub/me", { method: "GET" }) as any;
228
+ return {
229
+ connected: true,
230
+ hubUrl: normalizeHubUrl(hubAddress),
231
+ user: {
232
+ id: String(me.id),
233
+ username: String(me.username ?? ""),
234
+ role: String(me.role ?? "member") as UserRole,
235
+ status: String(me.status ?? "active"),
236
+ groups: Array.isArray(me.groups) ? me.groups : [],
237
+ },
238
+ };
239
+ } catch { /* fall through to token-only return */ }
240
+ return {
241
+ connected: true,
242
+ hubUrl: normalizeHubUrl(hubAddress),
243
+ user: { id: conn.userId, username: conn.username || "", role: conn.role as UserRole || "member", status: "active" },
244
+ };
245
+ }
246
+ const realStatus = regResult.status as string;
247
+ store.setClientHubConnection({ ...conn, userToken: "", lastKnownStatus: realStatus });
248
+ return {
249
+ connected: false,
250
+ hubUrl: normalizeHubUrl(hubAddress),
251
+ user: { id: conn.userId, username: conn.username || "", role: "member", status: realStatus },
252
+ };
253
+ } catch { /* registration-status also failed, fall through */ }
254
+ }
255
+ store.setClientHubConnection({ ...conn, userToken: "", lastKnownStatus: "token_expired" });
256
+ return {
257
+ connected: false,
258
+ hubUrl: normalizeHubUrl(hubAddress),
259
+ user: { id: conn.userId, username: conn.username || "", role: "member", status: "token_expired" },
260
+ };
261
+ }
191
262
  return { connected: false, user: null };
192
263
  }
193
264
  }
@@ -218,12 +289,17 @@ export async function autoJoinHub(
218
289
  }
219
290
  }
220
291
 
292
+ const persisted = store.getClientHubConnection();
293
+ const existingIdentityKey = persisted?.identityKey || "";
294
+
221
295
  log.info(`Joining Hub at ${hubUrl} as "${username}"...`);
222
296
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
223
297
  method: "POST",
224
- body: JSON.stringify({ teamToken, username, deviceName: hostname, clientIp }),
298
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, clientIp, identityKey: existingIdentityKey }),
225
299
  }) as any;
226
300
 
301
+ const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
302
+
227
303
  if (result.status === "pending") {
228
304
  log.info(`Join request submitted, awaiting admin approval. userId=${result.userId}`);
229
305
  store.setClientHubConnection({
@@ -233,6 +309,8 @@ export async function autoJoinHub(
233
309
  userToken: "",
234
310
  role: "member",
235
311
  connectedAt: Date.now(),
312
+ identityKey: returnedIdentityKey,
313
+ lastKnownStatus: "pending",
236
314
  });
237
315
  throw new PendingApprovalError(result.userId);
238
316
  }
@@ -241,6 +319,10 @@ export async function autoJoinHub(
241
319
  throw new Error(`Join request was rejected by the Hub admin.`);
242
320
  }
243
321
 
322
+ if (result.status === "blocked") {
323
+ throw new Error(`Your account has been blocked by the Hub admin.`);
324
+ }
325
+
244
326
  if (!result.userToken) {
245
327
  throw new Error(`Hub join failed: ${JSON.stringify(result)}`);
246
328
  }
@@ -253,6 +335,8 @@ export async function autoJoinHub(
253
335
  userToken: result.userToken,
254
336
  role: "member",
255
337
  connectedAt: Date.now(),
338
+ identityKey: returnedIdentityKey,
339
+ lastKnownStatus: "active",
256
340
  });
257
341
  return store.getClientHubConnection()!;
258
342
  }
package/src/config.ts CHANGED
@@ -128,7 +128,8 @@ export function resolveConfig(raw: Partial<MemosLocalConfig> | undefined, stateD
128
128
  userToken: cfg.sharing?.client?.userToken ?? "",
129
129
  teamToken: cfg.sharing?.client?.teamToken ?? "",
130
130
  pendingUserId: cfg.sharing?.client?.pendingUserId ?? "",
131
- } : { hubAddress: "", userToken: "", teamToken: "", pendingUserId: "" };
131
+ nickname: cfg.sharing?.client?.nickname ?? "",
132
+ } : { hubAddress: "", userToken: "", teamToken: "", pendingUserId: "", nickname: "" };
132
133
  return { enabled, role, hub, client, capabilities: sharingCapabilities };
133
134
  })(),
134
135
  };
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,40 +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
279
  this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
222
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
280
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
223
281
  }
224
282
  if (existingUser.status === "rejected") {
225
283
  if (body.reapply === true) {
226
284
  this.userManager.resetToPending(existingUser.id);
227
285
  this.notifyAdmins("user_join_request", "user", username, "");
228
286
  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 });
287
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
230
288
  }
231
289
  return this.json(res, 200, { status: "rejected", userId: existingUser.id });
232
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 });
305
+ }
233
306
  }
307
+
308
+ const generatedIdentityKey = identityKey || randomUUID();
234
309
  const user = this.userManager.createPendingUser({
235
310
  username,
236
311
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
312
+ identityKey: generatedIdentityKey,
237
313
  });
238
314
  try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
239
315
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
240
316
  this.notifyAdmins("user_join_request", "user", username, "");
241
- return this.json(res, 200, { status: "pending", userId: user.id });
317
+ return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
242
318
  }
243
319
 
244
320
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
@@ -256,16 +332,42 @@ export class HubServer {
256
332
  if (user.status === "rejected") {
257
333
  return this.json(res, 200, { status: "rejected" });
258
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
+ }
259
344
  if (user.status === "active") {
260
345
  const token = issueUserToken(
261
346
  { userId: user.id, username: user.username, role: user.role, status: user.status },
262
347
  this.authSecret,
263
348
  );
349
+ this.userManager.approveUser(user.id, token);
264
350
  return this.json(res, 200, { status: "active", userToken: token });
265
351
  }
266
352
  return this.json(res, 200, { status: user.status });
267
353
  }
268
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
+
269
371
  // All endpoints below require authentication + rate limiting
270
372
  const auth = this.authenticate(req);
271
373
  if (!auth) return this.json(res, 401, { error: "unauthorized" });
@@ -280,12 +382,10 @@ export class HubServer {
280
382
  }
281
383
 
282
384
  if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
283
- try {
284
- this.opts.store.updateHubUserActivity(auth.userId, "", 0);
285
- } catch { /* best-effort */ }
385
+ this.userManager.markUserLeft(auth.userId);
286
386
  this.knownOnlineUsers.delete(auth.userId);
287
- this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
288
- 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"`);
289
389
  return this.json(res, 200, { ok: true });
290
390
  }
291
391
 
@@ -314,6 +414,10 @@ export class HubServer {
314
414
  ttlMs,
315
415
  );
316
416
  this.userManager.approveUser(updated.id, newToken);
417
+ if (updated.id === this.authState.bootstrapAdminUserId) {
418
+ this.authState.bootstrapAdminToken = newToken;
419
+ this.saveAuthState();
420
+ }
317
421
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
318
422
  return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
319
423
  }
@@ -326,18 +430,33 @@ export class HubServer {
326
430
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/approve-user") {
327
431
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
328
432
  const body = await this.readJson(req);
329
- const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
330
- 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);
331
437
  if (!approved) return this.json(res, 404, { error: "not_found" });
332
- 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 */ }
333
445
  return this.json(res, 200, { status: "active", token });
334
446
  }
335
447
 
336
448
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
337
449
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
338
450
  const body = await this.readJson(req);
339
- const rejected = this.userManager.rejectUser(String(body.userId));
451
+ const userId = String(body.userId);
452
+ const rejected = this.userManager.rejectUser(userId);
340
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 */ }
341
460
  return this.json(res, 200, { status: "rejected" });
342
461
  }
343
462
 
@@ -374,6 +493,13 @@ export class HubServer {
374
493
  const updatedUser = { ...user, role: newRole as "admin" | "member" };
375
494
  this.opts.store.upsertHubUser(updatedUser);
376
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 */ }
377
503
  return this.json(res, 200, { ok: true, role: newRole });
378
504
  }
379
505
 
@@ -400,6 +526,10 @@ export class HubServer {
400
526
  const updated = this.opts.store.getHubUser(userId)!;
401
527
  const finalUser = { ...updated, username: newUsername };
402
528
  this.opts.store.upsertHubUser(finalUser);
529
+ if (userId === this.authState.bootstrapAdminUserId) {
530
+ this.authState.bootstrapAdminToken = newToken;
531
+ this.saveAuthState();
532
+ }
403
533
  this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
404
534
  return this.json(res, 200, { ok: true, username: newUsername });
405
535
  }
@@ -411,9 +541,16 @@ export class HubServer {
411
541
  if (!userId) return this.json(res, 400, { error: "missing_user_id" });
412
542
  if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
413
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 */ }
414
550
  const cleanResources = body?.cleanResources === true;
415
551
  const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
416
552
  if (!deleted) return this.json(res, 404, { error: "not_found" });
553
+ this.knownOnlineUsers.delete(userId);
417
554
  this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
418
555
  return this.json(res, 200, { ok: true });
419
556
  }
@@ -603,19 +740,70 @@ export class HubServer {
603
740
  }
604
741
 
605
742
  if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
606
- 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, {
607
746
  userId: auth.userId,
608
- maxResults: Number(url.searchParams.get("maxResults") || 10),
609
- }).map(({ hit }) => ({
610
- skillId: hit.id,
611
- name: hit.name,
612
- description: hit.description,
613
- version: hit.version,
614
- visibility: hit.visibility,
615
- groupName: hit.group_name,
616
- ownerName: hit.owner_name || "unknown",
617
- qualityScore: hit.quality_score,
618
- }));
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);
619
807
  return this.json(res, 200, { hits });
620
808
  }
621
809
 
@@ -641,6 +829,7 @@ export class HubServer {
641
829
  createdAt: existing?.createdAt ?? Date.now(),
642
830
  updatedAt: Date.now(),
643
831
  });
832
+ this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
644
833
  if (!existing) {
645
834
  this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
646
835
  }
@@ -761,7 +950,18 @@ export class HubServer {
761
950
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
762
951
  if (!deleted) return this.json(res, 404, { error: "not_found" });
763
952
  if (memInfo) {
764
- 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
+ });
765
965
  }
766
966
  return this.json(res, 200, { ok: true });
767
967
  }