@memtensor/memos-local-openclaw-plugin 1.0.4-beta.2 → 1.0.4-beta.20
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.
- package/.env.example +7 -0
- package/README.md +111 -44
- package/dist/capture/index.d.ts +1 -1
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +36 -2
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts +6 -2
- package/dist/client/connector.d.ts.map +1 -1
- package/dist/client/connector.js +160 -26
- package/dist/client/connector.js.map +1 -1
- package/dist/client/hub.d.ts.map +1 -1
- package/dist/client/hub.js +22 -0
- package/dist/client/hub.js.map +1 -1
- package/dist/client/skill-sync.d.ts +7 -0
- package/dist/client/skill-sync.d.ts.map +1 -1
- package/dist/client/skill-sync.js +10 -0
- package/dist/client/skill-sync.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -3
- package/dist/config.js.map +1 -1
- package/dist/hub/server.d.ts +9 -0
- package/dist/hub/server.d.ts.map +1 -1
- package/dist/hub/server.js +500 -112
- package/dist/hub/server.js.map +1 -1
- package/dist/hub/user-manager.d.ts +11 -0
- package/dist/hub/user-manager.d.ts.map +1 -1
- package/dist/hub/user-manager.js +31 -3
- package/dist/hub/user-manager.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/ingest/chunker.d.ts +2 -1
- package/dist/ingest/chunker.d.ts.map +1 -1
- package/dist/ingest/chunker.js +14 -10
- package/dist/ingest/chunker.js.map +1 -1
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +37 -6
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +96 -1
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts +1 -0
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +84 -9
- package/dist/shared/llm-call.js.map +1 -1
- package/dist/sharing/types.d.ts +1 -1
- package/dist/sharing/types.d.ts.map +1 -1
- package/dist/skill/evolver.d.ts +4 -0
- package/dist/skill/evolver.d.ts.map +1 -1
- package/dist/skill/evolver.js +59 -5
- package/dist/skill/evolver.js.map +1 -1
- package/dist/skill/generator.d.ts +2 -0
- package/dist/skill/generator.d.ts.map +1 -1
- package/dist/skill/generator.js +45 -3
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/installer.d.ts +26 -0
- package/dist/skill/installer.d.ts.map +1 -1
- package/dist/skill/installer.js +80 -4
- package/dist/skill/installer.js.map +1 -1
- package/dist/skill/upgrader.d.ts +2 -0
- package/dist/skill/upgrader.d.ts.map +1 -1
- package/dist/skill/upgrader.js +139 -1
- package/dist/skill/upgrader.js.map +1 -1
- package/dist/skill/validator.d.ts +3 -0
- package/dist/skill/validator.d.ts.map +1 -1
- package/dist/skill/validator.js +75 -0
- package/dist/skill/validator.js.map +1 -1
- package/dist/storage/ensure-binding.d.ts +12 -0
- package/dist/storage/ensure-binding.d.ts.map +1 -0
- package/dist/storage/ensure-binding.js +53 -0
- package/dist/storage/ensure-binding.js.map +1 -0
- package/dist/storage/sqlite.d.ts +115 -20
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +458 -110
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts +12 -5
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +156 -40
- package/dist/telemetry.js.map +1 -1
- package/dist/tools/memory-search.d.ts +3 -1
- package/dist/tools/memory-search.d.ts.map +1 -1
- package/dist/tools/memory-search.js +3 -1
- package/dist/tools/memory-search.js.map +1 -1
- package/dist/types.d.ts +11 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +2952 -910
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +39 -8
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +1198 -227
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +774 -74
- package/openclaw.plugin.json +2 -2
- package/package.json +3 -2
- package/scripts/postinstall.cjs +1 -1
- package/skill/memos-memory-guide/SKILL.md +64 -26
- package/src/capture/index.ts +40 -1
- package/src/client/connector.ts +161 -28
- package/src/client/hub.ts +18 -0
- package/src/client/skill-sync.ts +14 -0
- package/src/config.ts +2 -3
- package/src/hub/server.ts +481 -107
- package/src/hub/user-manager.ts +48 -8
- package/src/index.ts +10 -2
- package/src/ingest/chunker.ts +19 -13
- package/src/ingest/providers/index.ts +41 -7
- package/src/recall/engine.ts +89 -1
- package/src/shared/llm-call.ts +99 -10
- package/src/sharing/types.ts +1 -1
- package/src/skill/evolver.ts +63 -6
- package/src/skill/generator.ts +44 -5
- package/src/skill/installer.ts +107 -4
- package/src/skill/upgrader.ts +139 -1
- package/src/skill/validator.ts +79 -0
- package/src/storage/ensure-binding.ts +52 -0
- package/src/storage/sqlite.ts +498 -137
- package/src/telemetry.ts +172 -41
- package/src/tools/memory-search.ts +2 -1
- package/src/types.ts +12 -2
- package/src/viewer/html.ts +2952 -910
- package/src/viewer/server.ts +1109 -212
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 = {
|
|
@@ -34,6 +35,11 @@ export class HubServer {
|
|
|
34
35
|
private static readonly RATE_LIMIT_SEARCH = 30;
|
|
35
36
|
private rateBuckets = new Map<string, { count: number; windowStart: number }>();
|
|
36
37
|
|
|
38
|
+
private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
|
|
39
|
+
private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
|
|
40
|
+
private offlineCheckTimer?: ReturnType<typeof setInterval>;
|
|
41
|
+
private knownOnlineUsers = new Set<string>();
|
|
42
|
+
|
|
37
43
|
constructor(private opts: HubServerOptions) {
|
|
38
44
|
this.userManager = new HubUserManager(opts.store, opts.log);
|
|
39
45
|
this.authStatePath = path.join(opts.dataDir, "hub-auth.json");
|
|
@@ -74,18 +80,31 @@ export class HubServer {
|
|
|
74
80
|
}
|
|
75
81
|
});
|
|
76
82
|
|
|
83
|
+
const MAX_PORT_RETRIES = 3;
|
|
84
|
+
let hubPort = this.port;
|
|
77
85
|
await new Promise<void>((resolve, reject) => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
|
81
97
|
};
|
|
82
98
|
const onListening = () => {
|
|
83
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
|
+
}
|
|
84
103
|
resolve();
|
|
85
104
|
};
|
|
86
|
-
this.server!.
|
|
105
|
+
this.server!.on("error", onError);
|
|
87
106
|
this.server!.once("listening", onListening);
|
|
88
|
-
this.server!.listen(
|
|
107
|
+
this.server!.listen(hubPort, "0.0.0.0");
|
|
89
108
|
});
|
|
90
109
|
|
|
91
110
|
const bootstrap = this.userManager.ensureBootstrapAdmin(
|
|
@@ -101,18 +120,40 @@ export class HubServer {
|
|
|
101
120
|
this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
|
|
102
121
|
}
|
|
103
122
|
|
|
104
|
-
|
|
123
|
+
this.initOnlineTracking();
|
|
124
|
+
this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
|
|
125
|
+
|
|
126
|
+
return `http://127.0.0.1:${hubPort}`;
|
|
105
127
|
}
|
|
106
128
|
|
|
107
129
|
async stop(): Promise<void> {
|
|
130
|
+
if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
|
|
108
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
|
+
|
|
109
147
|
const server = this.server;
|
|
110
148
|
this.server = undefined;
|
|
111
149
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
112
150
|
}
|
|
113
151
|
|
|
114
152
|
private get port(): number {
|
|
115
|
-
|
|
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;
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
private get teamName(): string {
|
|
@@ -160,6 +201,20 @@ export class HubServer {
|
|
|
160
201
|
});
|
|
161
202
|
}
|
|
162
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
|
+
|
|
163
218
|
private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
|
|
164
219
|
const embedder = this.opts.embedder;
|
|
165
220
|
if (!embedder) return;
|
|
@@ -192,32 +247,74 @@ export class HubServer {
|
|
|
192
247
|
return this.json(res, 403, { error: "invalid_team_token" });
|
|
193
248
|
}
|
|
194
249
|
const username = String(body.username || `user-${randomUUID().slice(0, 8)}`);
|
|
195
|
-
const
|
|
196
|
-
|
|
250
|
+
const joinIp = (typeof body.clientIp === "string" && body.clientIp)
|
|
251
|
+
|| (req.headers["x-client-ip"] as string)?.trim()
|
|
252
|
+
|| (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|
|
253
|
+
|| req.socket.remoteAddress || "";
|
|
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
|
+
|
|
197
264
|
if (existingUser) {
|
|
265
|
+
try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
|
|
266
|
+
|
|
198
267
|
if (existingUser.status === "active") {
|
|
199
268
|
const token = issueUserToken(
|
|
200
269
|
{ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
|
|
201
270
|
this.authSecret,
|
|
202
271
|
);
|
|
203
272
|
this.userManager.approveUser(existingUser.id, token);
|
|
204
|
-
|
|
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 });
|
|
205
277
|
}
|
|
206
278
|
if (existingUser.status === "pending") {
|
|
207
|
-
|
|
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 });
|
|
208
281
|
}
|
|
209
282
|
if (existingUser.status === "rejected") {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 });
|
|
213
305
|
}
|
|
214
306
|
}
|
|
307
|
+
|
|
308
|
+
const generatedIdentityKey = identityKey || randomUUID();
|
|
215
309
|
const user = this.userManager.createPendingUser({
|
|
216
310
|
username,
|
|
217
311
|
deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
|
|
312
|
+
identityKey: generatedIdentityKey,
|
|
218
313
|
});
|
|
314
|
+
try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
|
|
219
315
|
this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
|
|
220
|
-
|
|
316
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
317
|
+
return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
|
|
221
318
|
}
|
|
222
319
|
|
|
223
320
|
if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
|
|
@@ -235,16 +332,42 @@ export class HubServer {
|
|
|
235
332
|
if (user.status === "rejected") {
|
|
236
333
|
return this.json(res, 200, { status: "rejected" });
|
|
237
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
|
+
}
|
|
238
344
|
if (user.status === "active") {
|
|
239
345
|
const token = issueUserToken(
|
|
240
346
|
{ userId: user.id, username: user.username, role: user.role, status: user.status },
|
|
241
347
|
this.authSecret,
|
|
242
348
|
);
|
|
349
|
+
this.userManager.approveUser(user.id, token);
|
|
243
350
|
return this.json(res, 200, { status: "active", userToken: token });
|
|
244
351
|
}
|
|
245
352
|
return this.json(res, 200, { status: user.status });
|
|
246
353
|
}
|
|
247
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
|
+
|
|
248
371
|
// All endpoints below require authentication + rate limiting
|
|
249
372
|
const auth = this.authenticate(req);
|
|
250
373
|
if (!auth) return this.json(res, 401, { error: "unauthorized" });
|
|
@@ -254,6 +377,18 @@ export class HubServer {
|
|
|
254
377
|
return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
|
|
255
378
|
}
|
|
256
379
|
|
|
380
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
|
|
381
|
+
return this.json(res, 200, { ok: true });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
|
|
385
|
+
this.userManager.markUserLeft(auth.userId);
|
|
386
|
+
this.knownOnlineUsers.delete(auth.userId);
|
|
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"`);
|
|
389
|
+
return this.json(res, 200, { ok: true });
|
|
390
|
+
}
|
|
391
|
+
|
|
257
392
|
if (req.method === "GET" && routePath === "/api/v1/hub/me") {
|
|
258
393
|
const user = this.opts.store.getHubUser(auth.userId);
|
|
259
394
|
if (!user) return this.json(res, 401, { error: "unauthorized" });
|
|
@@ -272,11 +407,17 @@ export class HubServer {
|
|
|
272
407
|
}
|
|
273
408
|
const updated = this.userManager.updateUsername(auth.userId, newUsername);
|
|
274
409
|
if (!updated) return this.json(res, 404, { error: "not_found" });
|
|
410
|
+
const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
275
411
|
const newToken = issueUserToken(
|
|
276
412
|
{ userId: updated.id, username: newUsername, role: updated.role, status: updated.status },
|
|
277
413
|
this.authSecret,
|
|
414
|
+
ttlMs,
|
|
278
415
|
);
|
|
279
416
|
this.userManager.approveUser(updated.id, newToken);
|
|
417
|
+
if (updated.id === this.authState.bootstrapAdminUserId) {
|
|
418
|
+
this.authState.bootstrapAdminToken = newToken;
|
|
419
|
+
this.saveAuthState();
|
|
420
|
+
}
|
|
280
421
|
this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
|
|
281
422
|
return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
|
|
282
423
|
}
|
|
@@ -289,114 +430,136 @@ export class HubServer {
|
|
|
289
430
|
if (req.method === "POST" && routePath === "/api/v1/hub/admin/approve-user") {
|
|
290
431
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
291
432
|
const body = await this.readJson(req);
|
|
292
|
-
const
|
|
293
|
-
const
|
|
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);
|
|
294
437
|
if (!approved) return this.json(res, 404, { error: "not_found" });
|
|
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 */ }
|
|
295
445
|
return this.json(res, 200, { status: "active", token });
|
|
296
446
|
}
|
|
297
447
|
|
|
298
448
|
if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
|
|
299
449
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
300
450
|
const body = await this.readJson(req);
|
|
301
|
-
const
|
|
451
|
+
const userId = String(body.userId);
|
|
452
|
+
const rejected = this.userManager.rejectUser(userId);
|
|
302
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 */ }
|
|
303
460
|
return this.json(res, 200, { status: "rejected" });
|
|
304
461
|
}
|
|
305
462
|
|
|
306
463
|
if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
|
|
307
464
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
308
465
|
const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
|
|
309
|
-
|
|
466
|
+
const contribs = this.opts.store.getHubUserContributions();
|
|
467
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
return this.json(res, 200, { users: users.map(u => {
|
|
470
|
+
const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
471
|
+
const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
|
|
472
|
+
return {
|
|
473
|
+
id: u.id, username: u.username, role: u.role, status: u.status,
|
|
474
|
+
deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
|
|
475
|
+
lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
|
|
476
|
+
isOwner: u.id === ownerId, isOnline,
|
|
477
|
+
memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
|
|
478
|
+
};
|
|
479
|
+
}) });
|
|
310
480
|
}
|
|
311
481
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
|
|
482
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
|
|
315
483
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
316
|
-
const
|
|
317
|
-
|
|
484
|
+
const body = await this.readJson(req);
|
|
485
|
+
const userId = String(body?.userId || "");
|
|
486
|
+
const newRole = String(body?.role || "");
|
|
487
|
+
if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
|
|
488
|
+
if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
|
|
489
|
+
return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
|
|
490
|
+
}
|
|
491
|
+
const user = this.opts.store.getHubUser(userId);
|
|
492
|
+
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
493
|
+
const updatedUser = { ...user, role: newRole as "admin" | "member" };
|
|
494
|
+
this.opts.store.upsertHubUser(updatedUser);
|
|
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 */ }
|
|
503
|
+
return this.json(res, 200, { ok: true, role: newRole });
|
|
318
504
|
}
|
|
319
505
|
|
|
320
|
-
if (req.method === "POST" && routePath === "/api/v1/hub/
|
|
506
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
|
|
321
507
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
322
508
|
const body = await this.readJson(req);
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 });
|
|
509
|
+
const userId = String(body?.userId || "");
|
|
510
|
+
const newUsername = String(body?.username || "").trim();
|
|
511
|
+
if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
|
|
512
|
+
return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
|
|
359
513
|
}
|
|
360
|
-
|
|
361
|
-
|
|
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 });
|
|
514
|
+
if (this.userManager.isUsernameTaken(newUsername, userId)) {
|
|
515
|
+
return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
|
|
366
516
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
this.opts.store.addHubGroupMember(groupId, userId);
|
|
383
|
-
return this.json(res, 200, { ok: true });
|
|
517
|
+
const user = this.opts.store.getHubUser(userId);
|
|
518
|
+
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
519
|
+
const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
520
|
+
const newToken = issueUserToken(
|
|
521
|
+
{ userId: user.id, username: newUsername, role: user.role, status: user.status },
|
|
522
|
+
this.authSecret,
|
|
523
|
+
ttlMs,
|
|
524
|
+
);
|
|
525
|
+
this.userManager.approveUser(user.id, newToken);
|
|
526
|
+
const updated = this.opts.store.getHubUser(userId)!;
|
|
527
|
+
const finalUser = { ...updated, username: newUsername };
|
|
528
|
+
this.opts.store.upsertHubUser(finalUser);
|
|
529
|
+
if (userId === this.authState.bootstrapAdminUserId) {
|
|
530
|
+
this.authState.bootstrapAdminToken = newToken;
|
|
531
|
+
this.saveAuthState();
|
|
384
532
|
}
|
|
533
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
|
|
534
|
+
return this.json(res, 200, { ok: true, username: newUsername });
|
|
535
|
+
}
|
|
385
536
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
537
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
|
|
538
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
539
|
+
const body = await this.readJson(req);
|
|
540
|
+
const userId = String(body?.userId || "");
|
|
541
|
+
if (!userId) return this.json(res, 400, { error: "missing_user_id" });
|
|
542
|
+
if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
|
|
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 */ }
|
|
550
|
+
const cleanResources = body?.cleanResources === true;
|
|
551
|
+
const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
|
|
552
|
+
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
553
|
+
this.knownOnlineUsers.delete(userId);
|
|
554
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
|
|
555
|
+
return this.json(res, 200, { ok: true });
|
|
394
556
|
}
|
|
395
557
|
|
|
396
558
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
|
|
397
559
|
const body = await this.readJson(req);
|
|
398
560
|
if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
|
|
399
561
|
const task = { ...body.task, sourceUserId: auth.userId };
|
|
562
|
+
const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
|
|
400
563
|
this.opts.store.upsertHubTask(task);
|
|
401
564
|
const chunks = Array.isArray(body.chunks) ? body.chunks : [];
|
|
402
565
|
const chunkIds: string[] = [];
|
|
@@ -404,16 +567,23 @@ export class HubServer {
|
|
|
404
567
|
this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
|
|
405
568
|
chunkIds.push(chunk.id);
|
|
406
569
|
}
|
|
407
|
-
// Async embedding: don't block the response
|
|
408
570
|
if (this.opts.embedder && chunkIds.length > 0) {
|
|
409
571
|
this.embedChunksAsync(chunkIds, chunks);
|
|
410
572
|
}
|
|
573
|
+
if (!existingTask) {
|
|
574
|
+
this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
|
|
575
|
+
}
|
|
411
576
|
return this.json(res, 200, { ok: true, chunks: chunkIds.length });
|
|
412
577
|
}
|
|
413
578
|
|
|
414
579
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
|
|
415
580
|
const body = await this.readJson(req);
|
|
416
|
-
|
|
581
|
+
const srcTaskId = String(body.sourceTaskId);
|
|
582
|
+
const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
|
|
583
|
+
this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
|
|
584
|
+
if (existing) {
|
|
585
|
+
this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
|
|
586
|
+
}
|
|
417
587
|
return this.json(res, 200, { ok: true });
|
|
418
588
|
}
|
|
419
589
|
|
|
@@ -444,6 +614,9 @@ export class HubServer {
|
|
|
444
614
|
if (this.opts.embedder) {
|
|
445
615
|
this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
|
|
446
616
|
}
|
|
617
|
+
if (!existing) {
|
|
618
|
+
this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
|
|
619
|
+
}
|
|
447
620
|
return this.json(res, 200, { ok: true, memoryId, visibility });
|
|
448
621
|
}
|
|
449
622
|
|
|
@@ -451,7 +624,11 @@ export class HubServer {
|
|
|
451
624
|
const body = await this.readJson(req);
|
|
452
625
|
const sourceChunkId = String(body?.sourceChunkId || "");
|
|
453
626
|
if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
|
|
627
|
+
const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
|
|
454
628
|
this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
|
|
629
|
+
if (existing) {
|
|
630
|
+
this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
|
|
631
|
+
}
|
|
455
632
|
return this.json(res, 200, { ok: true });
|
|
456
633
|
}
|
|
457
634
|
|
|
@@ -563,19 +740,70 @@ export class HubServer {
|
|
|
563
740
|
}
|
|
564
741
|
|
|
565
742
|
if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
|
|
566
|
-
const
|
|
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, {
|
|
567
746
|
userId: auth.userId,
|
|
568
|
-
maxResults:
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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);
|
|
579
807
|
return this.json(res, 200, { hits });
|
|
580
808
|
}
|
|
581
809
|
|
|
@@ -601,6 +829,10 @@ export class HubServer {
|
|
|
601
829
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
602
830
|
updatedAt: Date.now(),
|
|
603
831
|
});
|
|
832
|
+
this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
|
|
833
|
+
if (!existing) {
|
|
834
|
+
this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
|
|
835
|
+
}
|
|
604
836
|
return this.json(res, 200, { ok: true, skillId, visibility });
|
|
605
837
|
}
|
|
606
838
|
|
|
@@ -623,7 +855,12 @@ export class HubServer {
|
|
|
623
855
|
|
|
624
856
|
if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
|
|
625
857
|
const body = await this.readJson(req);
|
|
626
|
-
|
|
858
|
+
const srcSkillId = String(body?.sourceSkillId || "");
|
|
859
|
+
const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
|
|
860
|
+
this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
|
|
861
|
+
if (existing) {
|
|
862
|
+
this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
|
|
863
|
+
}
|
|
627
864
|
return this.json(res, 200, { ok: true });
|
|
628
865
|
}
|
|
629
866
|
|
|
@@ -635,12 +872,48 @@ export class HubServer {
|
|
|
635
872
|
return this.json(res, 200, { tasks });
|
|
636
873
|
}
|
|
637
874
|
|
|
875
|
+
const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
|
|
876
|
+
if (hubTaskDetailMatch) {
|
|
877
|
+
const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
|
|
878
|
+
const task = this.opts.store.getHubTaskById(taskId);
|
|
879
|
+
if (!task) return this.json(res, 404, { error: "not_found" });
|
|
880
|
+
const chunks = this.opts.store.listHubChunksByTaskId(taskId);
|
|
881
|
+
return this.json(res, 200, {
|
|
882
|
+
id: task.id, title: task.title, summary: task.summary,
|
|
883
|
+
startedAt: task.createdAt, endedAt: task.updatedAt,
|
|
884
|
+
chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
|
|
889
|
+
if (hubSkillDetailMatch) {
|
|
890
|
+
const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
|
|
891
|
+
const skill = this.opts.store.getHubSkillById(skillId);
|
|
892
|
+
if (!skill) return this.json(res, 404, { error: "not_found" });
|
|
893
|
+
let files: Array<{ path: string; type: string; size: number }> = [];
|
|
894
|
+
try {
|
|
895
|
+
const bundle = JSON.parse(skill.bundle || "{}");
|
|
896
|
+
if (Array.isArray(bundle.files)) {
|
|
897
|
+
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) }));
|
|
898
|
+
}
|
|
899
|
+
} catch { /* ignore parse error */ }
|
|
900
|
+
return this.json(res, 200, {
|
|
901
|
+
skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
|
|
902
|
+
files,
|
|
903
|
+
versions: [],
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
638
907
|
const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
|
|
639
908
|
if (adminTaskDeleteMatch) {
|
|
640
909
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
641
910
|
const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
|
|
911
|
+
const taskInfo = this.opts.store.getHubTaskById(taskId);
|
|
642
912
|
const deleted = this.opts.store.deleteHubTaskById(taskId);
|
|
643
913
|
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
914
|
+
if (taskInfo) {
|
|
915
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
|
|
916
|
+
}
|
|
644
917
|
return this.json(res, 200, { ok: true });
|
|
645
918
|
}
|
|
646
919
|
|
|
@@ -654,8 +927,12 @@ export class HubServer {
|
|
|
654
927
|
if (adminSkillDeleteMatch) {
|
|
655
928
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
656
929
|
const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
|
|
930
|
+
const skillInfo = this.opts.store.getHubSkillById(skillId);
|
|
657
931
|
const deleted = this.opts.store.deleteHubSkillById(skillId);
|
|
658
932
|
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
933
|
+
if (skillInfo) {
|
|
934
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
|
|
935
|
+
}
|
|
659
936
|
return this.json(res, 200, { ok: true });
|
|
660
937
|
}
|
|
661
938
|
|
|
@@ -669,8 +946,23 @@ export class HubServer {
|
|
|
669
946
|
if (adminMemoryDeleteMatch) {
|
|
670
947
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
671
948
|
const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
|
|
949
|
+
const memInfo = this.opts.store.getHubMemoryById(memoryId);
|
|
672
950
|
const deleted = this.opts.store.deleteHubMemoryById(memoryId);
|
|
673
951
|
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
952
|
+
if (memInfo) {
|
|
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
|
+
});
|
|
965
|
+
}
|
|
674
966
|
return this.json(res, 200, { ok: true });
|
|
675
967
|
}
|
|
676
968
|
|
|
@@ -693,9 +985,87 @@ export class HubServer {
|
|
|
693
985
|
});
|
|
694
986
|
}
|
|
695
987
|
|
|
988
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
|
|
989
|
+
const unread = (new URL(req.url!, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
|
|
990
|
+
const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
|
|
991
|
+
const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
|
|
992
|
+
return this.json(res, 200, { notifications: list, unreadCount });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
|
|
996
|
+
const body = await this.readJson(req);
|
|
997
|
+
const ids = Array.isArray(body.ids) ? body.ids as string[] : undefined;
|
|
998
|
+
this.opts.store.markHubNotificationsRead(auth.userId, ids);
|
|
999
|
+
return this.json(res, 200, { ok: true });
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
|
|
1003
|
+
this.opts.store.clearHubNotifications(auth.userId);
|
|
1004
|
+
return this.json(res, 200, { ok: true });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
696
1007
|
return this.json(res, 404, { error: "not_found" });
|
|
697
1008
|
}
|
|
698
1009
|
|
|
1010
|
+
private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
|
|
1011
|
+
try {
|
|
1012
|
+
const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
|
|
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
|
+
}
|
|
1017
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
|
|
1018
|
+
}
|
|
1019
|
+
} catch { /* best-effort */ }
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private initOnlineTracking(): void {
|
|
1023
|
+
try {
|
|
1024
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
1025
|
+
const users = this.opts.store.listHubUsers("active");
|
|
1026
|
+
const now = Date.now();
|
|
1027
|
+
for (const u of users) {
|
|
1028
|
+
if (u.id === ownerId) continue;
|
|
1029
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
1030
|
+
this.knownOnlineUsers.add(u.id);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
} catch { /* best-effort */ }
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private checkOfflineUsers(): void {
|
|
1037
|
+
try {
|
|
1038
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
1039
|
+
const users = this.opts.store.listHubUsers("active");
|
|
1040
|
+
const now = Date.now();
|
|
1041
|
+
const currentlyOnline = new Set<string>();
|
|
1042
|
+
for (const u of users) {
|
|
1043
|
+
if (u.id === ownerId) continue;
|
|
1044
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
1045
|
+
currentlyOnline.add(u.id);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
for (const uid of this.knownOnlineUsers) {
|
|
1049
|
+
if (!currentlyOnline.has(uid)) {
|
|
1050
|
+
const user = users.find(u => u.id === uid);
|
|
1051
|
+
if (user) {
|
|
1052
|
+
this.notifyAdmins("user_offline", "user", user.username, uid);
|
|
1053
|
+
this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
for (const uid of currentlyOnline) {
|
|
1058
|
+
if (!this.knownOnlineUsers.has(uid)) {
|
|
1059
|
+
const user = users.find(u => u.id === uid);
|
|
1060
|
+
if (user) {
|
|
1061
|
+
this.notifyAdmins("user_online", "user", user.username, uid);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
this.knownOnlineUsers = currentlyOnline;
|
|
1066
|
+
} catch { /* best-effort */ }
|
|
1067
|
+
}
|
|
1068
|
+
|
|
699
1069
|
private authenticate(req: http.IncomingMessage) {
|
|
700
1070
|
const header = req.headers.authorization;
|
|
701
1071
|
if (!header || !header.startsWith("Bearer ")) return null;
|
|
@@ -706,6 +1076,10 @@ export class HubServer {
|
|
|
706
1076
|
if (!user || user.status !== "active") return null;
|
|
707
1077
|
const hash = createHash("sha256").update(token).digest("hex");
|
|
708
1078
|
if (user.tokenHash !== hash) return null;
|
|
1079
|
+
const clientIp = (req.headers["x-client-ip"] as string)?.trim()
|
|
1080
|
+
|| (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|
|
1081
|
+
|| req.socket.remoteAddress || "";
|
|
1082
|
+
try { this.opts.store.updateHubUserActivity(user.id, clientIp); } catch { /* best-effort */ }
|
|
709
1083
|
return {
|
|
710
1084
|
userId: user.id,
|
|
711
1085
|
username: user.username,
|