@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/dist/hub/server.js
CHANGED
|
@@ -51,6 +51,10 @@ class HubServer {
|
|
|
51
51
|
static RATE_LIMIT_DEFAULT = 60;
|
|
52
52
|
static RATE_LIMIT_SEARCH = 30;
|
|
53
53
|
rateBuckets = new Map();
|
|
54
|
+
static OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
|
|
55
|
+
static OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
|
|
56
|
+
offlineCheckTimer;
|
|
57
|
+
knownOnlineUsers = new Set();
|
|
54
58
|
constructor(opts) {
|
|
55
59
|
this.opts = opts;
|
|
56
60
|
this.userManager = new user_manager_1.HubUserManager(opts.store, opts.log);
|
|
@@ -89,18 +93,32 @@ class HubServer {
|
|
|
89
93
|
res.end(JSON.stringify({ error: message }));
|
|
90
94
|
}
|
|
91
95
|
});
|
|
96
|
+
const MAX_PORT_RETRIES = 3;
|
|
97
|
+
let hubPort = this.port;
|
|
92
98
|
await new Promise((resolve, reject) => {
|
|
99
|
+
let retries = 0;
|
|
93
100
|
const onError = (err) => {
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
|
|
102
|
+
retries++;
|
|
103
|
+
hubPort = this.port + retries;
|
|
104
|
+
this.opts.log.warn(`Hub port ${hubPort - 1} in use, trying ${hubPort}`);
|
|
105
|
+
this.server.listen(hubPort, "0.0.0.0");
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
this.server?.off("listening", onListening);
|
|
109
|
+
reject(err);
|
|
110
|
+
}
|
|
96
111
|
};
|
|
97
112
|
const onListening = () => {
|
|
98
113
|
this.server?.off("error", onError);
|
|
114
|
+
if (hubPort !== this.port) {
|
|
115
|
+
this.opts.log.info(`Hub started on fallback port ${hubPort} (configured: ${this.port})`);
|
|
116
|
+
}
|
|
99
117
|
resolve();
|
|
100
118
|
};
|
|
101
|
-
this.server.
|
|
119
|
+
this.server.on("error", onError);
|
|
102
120
|
this.server.once("listening", onListening);
|
|
103
|
-
this.server.listen(
|
|
121
|
+
this.server.listen(hubPort, "0.0.0.0");
|
|
104
122
|
});
|
|
105
123
|
const bootstrap = this.userManager.ensureBootstrapAdmin(this.authSecret, "admin", this.authState.bootstrapAdminUserId, this.authState.bootstrapAdminToken);
|
|
106
124
|
if (bootstrap.token) {
|
|
@@ -109,17 +127,43 @@ class HubServer {
|
|
|
109
127
|
this.saveAuthState();
|
|
110
128
|
this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
|
|
111
129
|
}
|
|
112
|
-
|
|
130
|
+
this.initOnlineTracking();
|
|
131
|
+
this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
|
|
132
|
+
return `http://127.0.0.1:${hubPort}`;
|
|
113
133
|
}
|
|
114
134
|
async stop() {
|
|
135
|
+
if (this.offlineCheckTimer) {
|
|
136
|
+
clearInterval(this.offlineCheckTimer);
|
|
137
|
+
this.offlineCheckTimer = undefined;
|
|
138
|
+
}
|
|
115
139
|
if (!this.server)
|
|
116
140
|
return;
|
|
141
|
+
try {
|
|
142
|
+
const activeUsers = this.opts.store.listHubUsers("active");
|
|
143
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
144
|
+
for (const u of activeUsers) {
|
|
145
|
+
if (u.id === ownerId)
|
|
146
|
+
continue;
|
|
147
|
+
try {
|
|
148
|
+
this.opts.store.insertHubNotification({
|
|
149
|
+
id: (0, crypto_1.randomUUID)(), userId: u.id, type: "hub_shutdown",
|
|
150
|
+
resource: "system", title: `Team server "${this.teamName}" has been shut down by the admin.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch { /* best-effort */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch { /* best-effort */ }
|
|
117
157
|
const server = this.server;
|
|
118
158
|
this.server = undefined;
|
|
119
159
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
120
160
|
}
|
|
121
161
|
get port() {
|
|
122
|
-
|
|
162
|
+
const configured = this.opts.config.sharing?.hub?.port;
|
|
163
|
+
const derived = this.opts.defaultHubPort;
|
|
164
|
+
if (derived && (!configured || configured === 18800))
|
|
165
|
+
return derived;
|
|
166
|
+
return configured ?? 18800;
|
|
123
167
|
}
|
|
124
168
|
get teamName() {
|
|
125
169
|
return this.opts.config.sharing?.hub?.teamName ?? "";
|
|
@@ -163,6 +207,20 @@ class HubServer {
|
|
|
163
207
|
this.opts.log.warn(`hub: embedding shared chunks failed: ${err}`);
|
|
164
208
|
});
|
|
165
209
|
}
|
|
210
|
+
embedSkillAsync(skillId, name, description, sourceUserId, sourceSkillId) {
|
|
211
|
+
const embedder = this.opts.embedder;
|
|
212
|
+
if (!embedder)
|
|
213
|
+
return;
|
|
214
|
+
const text = `${name}: ${description}`;
|
|
215
|
+
embedder.embed([text]).then((vectors) => {
|
|
216
|
+
if (vectors[0]) {
|
|
217
|
+
this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
|
|
218
|
+
this.opts.log.info(`hub: embedded shared skill ${skillId}`);
|
|
219
|
+
}
|
|
220
|
+
}).catch((err) => {
|
|
221
|
+
this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
166
224
|
embedMemoryAsync(memoryId, summary, content) {
|
|
167
225
|
const embedder = this.opts.embedder;
|
|
168
226
|
if (!embedder)
|
|
@@ -193,29 +251,73 @@ class HubServer {
|
|
|
193
251
|
return this.json(res, 403, { error: "invalid_team_token" });
|
|
194
252
|
}
|
|
195
253
|
const username = String(body.username || `user-${(0, crypto_1.randomUUID)().slice(0, 8)}`);
|
|
196
|
-
const
|
|
197
|
-
|
|
254
|
+
const joinIp = (typeof body.clientIp === "string" && body.clientIp)
|
|
255
|
+
|| req.headers["x-client-ip"]?.trim()
|
|
256
|
+
|| req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
|
|
257
|
+
|| req.socket.remoteAddress || "";
|
|
258
|
+
const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
|
|
259
|
+
let existingUser = identityKey
|
|
260
|
+
? this.userManager.findByIdentityKey(identityKey)
|
|
261
|
+
: null;
|
|
262
|
+
if (!existingUser) {
|
|
263
|
+
const existingUsers = this.opts.store.listHubUsers();
|
|
264
|
+
existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
|
|
265
|
+
}
|
|
198
266
|
if (existingUser) {
|
|
267
|
+
try {
|
|
268
|
+
this.opts.store.updateHubUserActivity(existingUser.id, joinIp);
|
|
269
|
+
}
|
|
270
|
+
catch { /* best-effort */ }
|
|
199
271
|
if (existingUser.status === "active") {
|
|
200
272
|
const token = (0, auth_1.issueUserToken)({ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" }, this.authSecret);
|
|
201
273
|
this.userManager.approveUser(existingUser.id, token);
|
|
202
|
-
|
|
274
|
+
if (identityKey && !existingUser.identityKey) {
|
|
275
|
+
this.opts.store.upsertHubUser({ ...existingUser, identityKey });
|
|
276
|
+
}
|
|
277
|
+
return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
|
|
203
278
|
}
|
|
204
279
|
if (existingUser.status === "pending") {
|
|
205
|
-
|
|
280
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
281
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
206
282
|
}
|
|
207
283
|
if (existingUser.status === "rejected") {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
284
|
+
if (body.reapply === true) {
|
|
285
|
+
this.userManager.resetToPending(existingUser.id);
|
|
286
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
287
|
+
this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
|
|
288
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
289
|
+
}
|
|
290
|
+
return this.json(res, 200, { status: "rejected", userId: existingUser.id });
|
|
291
|
+
}
|
|
292
|
+
if (existingUser.status === "removed") {
|
|
293
|
+
this.userManager.rejoinUser(existingUser.id);
|
|
294
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
295
|
+
this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
|
|
296
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
297
|
+
}
|
|
298
|
+
if (existingUser.status === "left") {
|
|
299
|
+
this.userManager.rejoinUser(existingUser.id);
|
|
300
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
301
|
+
this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
|
|
302
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
303
|
+
}
|
|
304
|
+
if (existingUser.status === "blocked") {
|
|
305
|
+
return this.json(res, 200, { status: "blocked", userId: existingUser.id });
|
|
211
306
|
}
|
|
212
307
|
}
|
|
308
|
+
const generatedIdentityKey = identityKey || (0, crypto_1.randomUUID)();
|
|
213
309
|
const user = this.userManager.createPendingUser({
|
|
214
310
|
username,
|
|
215
311
|
deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
|
|
312
|
+
identityKey: generatedIdentityKey,
|
|
216
313
|
});
|
|
314
|
+
try {
|
|
315
|
+
this.opts.store.updateHubUserActivity(user.id, joinIp);
|
|
316
|
+
}
|
|
317
|
+
catch { /* best-effort */ }
|
|
217
318
|
this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
|
|
218
|
-
|
|
319
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
320
|
+
return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
|
|
219
321
|
}
|
|
220
322
|
if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
|
|
221
323
|
const body = await this.readJson(req);
|
|
@@ -234,12 +336,39 @@ class HubServer {
|
|
|
234
336
|
if (user.status === "rejected") {
|
|
235
337
|
return this.json(res, 200, { status: "rejected" });
|
|
236
338
|
}
|
|
339
|
+
if (user.status === "blocked") {
|
|
340
|
+
return this.json(res, 200, { status: "blocked" });
|
|
341
|
+
}
|
|
342
|
+
if (user.status === "left") {
|
|
343
|
+
return this.json(res, 200, { status: "left" });
|
|
344
|
+
}
|
|
345
|
+
if (user.status === "removed") {
|
|
346
|
+
return this.json(res, 200, { status: "removed" });
|
|
347
|
+
}
|
|
237
348
|
if (user.status === "active") {
|
|
238
349
|
const token = (0, auth_1.issueUserToken)({ userId: user.id, username: user.username, role: user.role, status: user.status }, this.authSecret);
|
|
350
|
+
this.userManager.approveUser(user.id, token);
|
|
239
351
|
return this.json(res, 200, { status: "active", userToken: token });
|
|
240
352
|
}
|
|
241
353
|
return this.json(res, 200, { status: user.status });
|
|
242
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)
|
|
362
|
+
return this.json(res, 400, { error: "missing_user_id" });
|
|
363
|
+
const user = this.opts.store.getHubUser(userId);
|
|
364
|
+
if (!user)
|
|
365
|
+
return this.json(res, 200, { ok: true });
|
|
366
|
+
if (user.status === "pending") {
|
|
367
|
+
this.userManager.markUserLeft(userId);
|
|
368
|
+
this.opts.log.info(`Hub: user "${user.username}" (${userId}) withdrew pending application`);
|
|
369
|
+
}
|
|
370
|
+
return this.json(res, 200, { ok: true });
|
|
371
|
+
}
|
|
243
372
|
// All endpoints below require authentication + rate limiting
|
|
244
373
|
const auth = this.authenticate(req);
|
|
245
374
|
if (!auth)
|
|
@@ -248,6 +377,16 @@ class HubServer {
|
|
|
248
377
|
if (!this.checkRateLimit(auth.userId, endpointKey)) {
|
|
249
378
|
return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
|
|
250
379
|
}
|
|
380
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
|
|
381
|
+
return this.json(res, 200, { ok: true });
|
|
382
|
+
}
|
|
383
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
|
|
384
|
+
this.userManager.markUserLeft(auth.userId);
|
|
385
|
+
this.knownOnlineUsers.delete(auth.userId);
|
|
386
|
+
this.notifyAdmins("user_left", "user", auth.username, auth.userId);
|
|
387
|
+
this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
|
|
388
|
+
return this.json(res, 200, { ok: true });
|
|
389
|
+
}
|
|
251
390
|
if (req.method === "GET" && routePath === "/api/v1/hub/me") {
|
|
252
391
|
const user = this.opts.store.getHubUser(auth.userId);
|
|
253
392
|
if (!user)
|
|
@@ -268,8 +407,13 @@ class HubServer {
|
|
|
268
407
|
const updated = this.userManager.updateUsername(auth.userId, newUsername);
|
|
269
408
|
if (!updated)
|
|
270
409
|
return this.json(res, 404, { error: "not_found" });
|
|
271
|
-
const
|
|
410
|
+
const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
411
|
+
const newToken = (0, auth_1.issueUserToken)({ userId: updated.id, username: newUsername, role: updated.role, status: updated.status }, this.authSecret, ttlMs);
|
|
272
412
|
this.userManager.approveUser(updated.id, newToken);
|
|
413
|
+
if (updated.id === this.authState.bootstrapAdminUserId) {
|
|
414
|
+
this.authState.bootstrapAdminToken = newToken;
|
|
415
|
+
this.saveAuthState();
|
|
416
|
+
}
|
|
273
417
|
this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
|
|
274
418
|
return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
|
|
275
419
|
}
|
|
@@ -282,121 +426,148 @@ class HubServer {
|
|
|
282
426
|
if (auth.role !== "admin")
|
|
283
427
|
return this.json(res, 403, { error: "forbidden" });
|
|
284
428
|
const body = await this.readJson(req);
|
|
285
|
-
const
|
|
286
|
-
const
|
|
429
|
+
const userId = String(body.userId);
|
|
430
|
+
const username = String(body.username || "");
|
|
431
|
+
const token = (0, auth_1.issueUserToken)({ userId, username, role: "member", status: "active" }, this.authSecret);
|
|
432
|
+
const approved = this.userManager.approveUser(userId, token);
|
|
287
433
|
if (!approved)
|
|
288
434
|
return this.json(res, 404, { error: "not_found" });
|
|
435
|
+
try {
|
|
436
|
+
this.opts.store.updateHubUserActivity(userId, "");
|
|
437
|
+
}
|
|
438
|
+
catch { /* best-effort */ }
|
|
439
|
+
try {
|
|
440
|
+
this.opts.store.insertHubNotification({
|
|
441
|
+
id: (0, crypto_1.randomUUID)(), userId, type: "membership_approved",
|
|
442
|
+
resource: "user", title: `Your request to join team "${this.teamName}" has been approved. Welcome!`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch { /* best-effort */ }
|
|
289
446
|
return this.json(res, 200, { status: "active", token });
|
|
290
447
|
}
|
|
291
448
|
if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
|
|
292
449
|
if (auth.role !== "admin")
|
|
293
450
|
return this.json(res, 403, { error: "forbidden" });
|
|
294
451
|
const body = await this.readJson(req);
|
|
295
|
-
const
|
|
452
|
+
const userId = String(body.userId);
|
|
453
|
+
const rejected = this.userManager.rejectUser(userId);
|
|
296
454
|
if (!rejected)
|
|
297
455
|
return this.json(res, 404, { error: "not_found" });
|
|
456
|
+
try {
|
|
457
|
+
this.opts.store.insertHubNotification({
|
|
458
|
+
id: (0, crypto_1.randomUUID)(), userId, type: "membership_rejected",
|
|
459
|
+
resource: "user", title: `Your request to join team "${this.teamName}" has been declined.`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
catch { /* best-effort */ }
|
|
298
463
|
return this.json(res, 200, { status: "rejected" });
|
|
299
464
|
}
|
|
300
465
|
if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
|
|
301
466
|
if (auth.role !== "admin")
|
|
302
467
|
return this.json(res, 403, { error: "forbidden" });
|
|
303
468
|
const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
|
|
304
|
-
|
|
469
|
+
const contribs = this.opts.store.getHubUserContributions();
|
|
470
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
return this.json(res, 200, { users: users.map(u => {
|
|
473
|
+
const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
474
|
+
const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
|
|
475
|
+
return {
|
|
476
|
+
id: u.id, username: u.username, role: u.role, status: u.status,
|
|
477
|
+
deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
|
|
478
|
+
lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
|
|
479
|
+
isOwner: u.id === ownerId, isOnline,
|
|
480
|
+
memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
|
|
481
|
+
};
|
|
482
|
+
}) });
|
|
305
483
|
}
|
|
306
|
-
|
|
307
|
-
if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
|
|
484
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
|
|
308
485
|
if (auth.role !== "admin")
|
|
309
486
|
return this.json(res, 403, { error: "forbidden" });
|
|
310
|
-
const
|
|
311
|
-
|
|
487
|
+
const body = await this.readJson(req);
|
|
488
|
+
const userId = String(body?.userId || "");
|
|
489
|
+
const newRole = String(body?.role || "");
|
|
490
|
+
if (!userId || (newRole !== "admin" && newRole !== "member"))
|
|
491
|
+
return this.json(res, 400, { error: "invalid_params" });
|
|
492
|
+
if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
|
|
493
|
+
return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
|
|
494
|
+
}
|
|
495
|
+
const user = this.opts.store.getHubUser(userId);
|
|
496
|
+
if (!user || user.status !== "active")
|
|
497
|
+
return this.json(res, 404, { error: "not_found" });
|
|
498
|
+
const updatedUser = { ...user, role: newRole };
|
|
499
|
+
this.opts.store.upsertHubUser(updatedUser);
|
|
500
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
|
|
501
|
+
try {
|
|
502
|
+
const notifType = newRole === "admin" ? "role_promoted" : "role_demoted";
|
|
503
|
+
this.opts.store.insertHubNotification({
|
|
504
|
+
id: (0, crypto_1.randomUUID)(), userId, type: notifType,
|
|
505
|
+
resource: "user", title: `Your role in team "${this.teamName}" has been changed to ${newRole}.`,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
catch { /* best-effort */ }
|
|
509
|
+
return this.json(res, 200, { ok: true, role: newRole });
|
|
312
510
|
}
|
|
313
|
-
if (req.method === "POST" && routePath === "/api/v1/hub/
|
|
511
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
|
|
314
512
|
if (auth.role !== "admin")
|
|
315
513
|
return this.json(res, 403, { error: "forbidden" });
|
|
316
514
|
const body = await this.readJson(req);
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this.opts.store.upsertHubGroup({
|
|
322
|
-
id: groupId,
|
|
323
|
-
name,
|
|
324
|
-
description: String(body.description || ""),
|
|
325
|
-
createdAt: Date.now(),
|
|
326
|
-
});
|
|
327
|
-
return this.json(res, 201, { id: groupId, name });
|
|
328
|
-
}
|
|
329
|
-
const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
|
|
330
|
-
if (groupDetailMatch) {
|
|
331
|
-
const groupId = decodeURIComponent(groupDetailMatch[1]);
|
|
332
|
-
if (req.method === "GET") {
|
|
333
|
-
if (auth.role !== "admin")
|
|
334
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
335
|
-
const group = this.opts.store.getHubGroupById(groupId);
|
|
336
|
-
if (!group)
|
|
337
|
-
return this.json(res, 404, { error: "not_found" });
|
|
338
|
-
const members = this.opts.store.listHubGroupMembers(groupId);
|
|
339
|
-
return this.json(res, 200, { ...group, members });
|
|
340
|
-
}
|
|
341
|
-
if (req.method === "PUT") {
|
|
342
|
-
if (auth.role !== "admin")
|
|
343
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
344
|
-
const existing = this.opts.store.getHubGroupById(groupId);
|
|
345
|
-
if (!existing)
|
|
346
|
-
return this.json(res, 404, { error: "not_found" });
|
|
347
|
-
const body = await this.readJson(req);
|
|
348
|
-
this.opts.store.upsertHubGroup({
|
|
349
|
-
id: groupId,
|
|
350
|
-
name: String(body.name || existing.name).trim(),
|
|
351
|
-
description: String(body.description ?? existing.description),
|
|
352
|
-
createdAt: existing.createdAt,
|
|
353
|
-
});
|
|
354
|
-
return this.json(res, 200, { ok: true });
|
|
515
|
+
const userId = String(body?.userId || "");
|
|
516
|
+
const newUsername = String(body?.username || "").trim();
|
|
517
|
+
if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
|
|
518
|
+
return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
|
|
355
519
|
}
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
359
|
-
const deleted = this.opts.store.deleteHubGroup(groupId);
|
|
360
|
-
if (!deleted)
|
|
361
|
-
return this.json(res, 404, { error: "not_found" });
|
|
362
|
-
return this.json(res, 200, { ok: true });
|
|
520
|
+
if (this.userManager.isUsernameTaken(newUsername, userId)) {
|
|
521
|
+
return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
|
|
363
522
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (!userId)
|
|
377
|
-
return this.json(res, 400, { error: "userId_required" });
|
|
378
|
-
const user = this.opts.store.getHubUser(userId);
|
|
379
|
-
if (!user)
|
|
380
|
-
return this.json(res, 404, { error: "user_not_found" });
|
|
381
|
-
this.opts.store.addHubGroupMember(groupId, userId);
|
|
382
|
-
return this.json(res, 200, { ok: true });
|
|
523
|
+
const user = this.opts.store.getHubUser(userId);
|
|
524
|
+
if (!user || user.status !== "active")
|
|
525
|
+
return this.json(res, 404, { error: "not_found" });
|
|
526
|
+
const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
527
|
+
const newToken = (0, auth_1.issueUserToken)({ userId: user.id, username: newUsername, role: user.role, status: user.status }, this.authSecret, ttlMs);
|
|
528
|
+
this.userManager.approveUser(user.id, newToken);
|
|
529
|
+
const updated = this.opts.store.getHubUser(userId);
|
|
530
|
+
const finalUser = { ...updated, username: newUsername };
|
|
531
|
+
this.opts.store.upsertHubUser(finalUser);
|
|
532
|
+
if (userId === this.authState.bootstrapAdminUserId) {
|
|
533
|
+
this.authState.bootstrapAdminToken = newToken;
|
|
534
|
+
this.saveAuthState();
|
|
383
535
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
536
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
|
|
537
|
+
return this.json(res, 200, { ok: true, username: newUsername });
|
|
538
|
+
}
|
|
539
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
|
|
540
|
+
if (auth.role !== "admin")
|
|
541
|
+
return this.json(res, 403, { error: "forbidden" });
|
|
542
|
+
const body = await this.readJson(req);
|
|
543
|
+
const userId = String(body?.userId || "");
|
|
544
|
+
if (!userId)
|
|
545
|
+
return this.json(res, 400, { error: "missing_user_id" });
|
|
546
|
+
if (userId === auth.userId)
|
|
547
|
+
return this.json(res, 400, { error: "cannot_remove_self" });
|
|
548
|
+
if (userId === this.authState.bootstrapAdminUserId)
|
|
549
|
+
return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
|
|
550
|
+
try {
|
|
551
|
+
this.opts.store.insertHubNotification({
|
|
552
|
+
id: (0, crypto_1.randomUUID)(), userId, type: "membership_removed",
|
|
553
|
+
resource: "user", title: `You have been removed from team "${this.teamName}" by the admin.`,
|
|
554
|
+
});
|
|
393
555
|
}
|
|
556
|
+
catch { /* best-effort */ }
|
|
557
|
+
const cleanResources = body?.cleanResources === true;
|
|
558
|
+
const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
|
|
559
|
+
if (!deleted)
|
|
560
|
+
return this.json(res, 404, { error: "not_found" });
|
|
561
|
+
this.knownOnlineUsers.delete(userId);
|
|
562
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
|
|
563
|
+
return this.json(res, 200, { ok: true });
|
|
394
564
|
}
|
|
395
565
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
|
|
396
566
|
const body = await this.readJson(req);
|
|
397
567
|
if (!body?.task)
|
|
398
568
|
return this.json(res, 400, { error: "invalid_payload" });
|
|
399
569
|
const task = { ...body.task, sourceUserId: auth.userId };
|
|
570
|
+
const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
|
|
400
571
|
this.opts.store.upsertHubTask(task);
|
|
401
572
|
const chunks = Array.isArray(body.chunks) ? body.chunks : [];
|
|
402
573
|
const chunkIds = [];
|
|
@@ -404,15 +575,22 @@ class HubServer {
|
|
|
404
575
|
this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
|
|
405
576
|
chunkIds.push(chunk.id);
|
|
406
577
|
}
|
|
407
|
-
// Async embedding: don't block the response
|
|
408
578
|
if (this.opts.embedder && chunkIds.length > 0) {
|
|
409
579
|
this.embedChunksAsync(chunkIds, chunks);
|
|
410
580
|
}
|
|
581
|
+
if (!existingTask) {
|
|
582
|
+
this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
|
|
583
|
+
}
|
|
411
584
|
return this.json(res, 200, { ok: true, chunks: chunkIds.length });
|
|
412
585
|
}
|
|
413
586
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
|
|
414
587
|
const body = await this.readJson(req);
|
|
415
|
-
|
|
588
|
+
const srcTaskId = String(body.sourceTaskId);
|
|
589
|
+
const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
|
|
590
|
+
this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
|
|
591
|
+
if (existing) {
|
|
592
|
+
this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
|
|
593
|
+
}
|
|
416
594
|
return this.json(res, 200, { ok: true });
|
|
417
595
|
}
|
|
418
596
|
if (req.method === "POST" && routePath === "/api/v1/hub/memories/share") {
|
|
@@ -444,6 +622,9 @@ class HubServer {
|
|
|
444
622
|
if (this.opts.embedder) {
|
|
445
623
|
this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
|
|
446
624
|
}
|
|
625
|
+
if (!existing) {
|
|
626
|
+
this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
|
|
627
|
+
}
|
|
447
628
|
return this.json(res, 200, { ok: true, memoryId, visibility });
|
|
448
629
|
}
|
|
449
630
|
if (req.method === "POST" && routePath === "/api/v1/hub/memories/unshare") {
|
|
@@ -451,7 +632,11 @@ class HubServer {
|
|
|
451
632
|
const sourceChunkId = String(body?.sourceChunkId || "");
|
|
452
633
|
if (!sourceChunkId)
|
|
453
634
|
return this.json(res, 400, { error: "missing_source_chunk_id" });
|
|
635
|
+
const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
|
|
454
636
|
this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
|
|
637
|
+
if (existing) {
|
|
638
|
+
this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
|
|
639
|
+
}
|
|
455
640
|
return this.json(res, 200, { ok: true });
|
|
456
641
|
}
|
|
457
642
|
if (req.method === "GET" && routePath === "/api/v1/hub/memories") {
|
|
@@ -565,19 +750,73 @@ class HubServer {
|
|
|
565
750
|
return this.json(res, 200, { hits, meta: { totalCandidates: hits.length, searchedGroups: [], includedPublic: true } });
|
|
566
751
|
}
|
|
567
752
|
if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
|
|
568
|
-
const
|
|
753
|
+
const skillQuery = String(url.searchParams.get("query") || "");
|
|
754
|
+
const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
|
|
755
|
+
const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
|
|
569
756
|
userId: auth.userId,
|
|
570
|
-
maxResults:
|
|
571
|
-
})
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
757
|
+
maxResults: skillMaxResults * 2,
|
|
758
|
+
});
|
|
759
|
+
let mergedSkillIds;
|
|
760
|
+
if (this.opts.embedder && skillQuery) {
|
|
761
|
+
try {
|
|
762
|
+
const [queryVec] = await this.opts.embedder.embed([skillQuery]);
|
|
763
|
+
if (queryVec) {
|
|
764
|
+
const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
|
|
765
|
+
const cosineSim = (vec) => {
|
|
766
|
+
let dot = 0, nA = 0, nB = 0;
|
|
767
|
+
for (let i = 0; i < queryVec.length && i < vec.length; i++) {
|
|
768
|
+
dot += queryVec[i] * vec[i];
|
|
769
|
+
nA += queryVec[i] * queryVec[i];
|
|
770
|
+
nB += vec[i] * vec[i];
|
|
771
|
+
}
|
|
772
|
+
return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
|
|
773
|
+
};
|
|
774
|
+
const vecScored = skillEmbs
|
|
775
|
+
.map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
|
|
776
|
+
.filter(e => e.score > 0.3)
|
|
777
|
+
.sort((a, b) => b.score - a.score)
|
|
778
|
+
.slice(0, skillMaxResults * 2);
|
|
779
|
+
const K = 60;
|
|
780
|
+
const rrfScores = new Map();
|
|
781
|
+
ftsSkillHits.forEach(({ hit }, idx) => {
|
|
782
|
+
rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
|
|
783
|
+
});
|
|
784
|
+
vecScored.forEach(({ id }, idx) => {
|
|
785
|
+
rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
|
|
786
|
+
});
|
|
787
|
+
mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
799
|
+
}
|
|
800
|
+
const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
|
|
801
|
+
const hits = mergedSkillIds.map(id => {
|
|
802
|
+
const hit = ftsSkillMap.get(id);
|
|
803
|
+
if (hit) {
|
|
804
|
+
return {
|
|
805
|
+
skillId: hit.id, name: hit.name, description: hit.description,
|
|
806
|
+
version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
|
|
807
|
+
ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
|
|
808
|
+
qualityScore: hit.quality_score,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const skill = this.opts.store.getHubSkillById(id);
|
|
812
|
+
if (!skill)
|
|
813
|
+
return null;
|
|
814
|
+
return {
|
|
815
|
+
skillId: skill.id, name: skill.name, description: skill.description,
|
|
816
|
+
version: skill.version, visibility: skill.visibility, groupName: "",
|
|
817
|
+
ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
|
|
818
|
+
};
|
|
819
|
+
}).filter(Boolean);
|
|
581
820
|
return this.json(res, 200, { hits });
|
|
582
821
|
}
|
|
583
822
|
if (req.method === "POST" && routePath === "/api/v1/hub/skills/publish") {
|
|
@@ -603,6 +842,10 @@ class HubServer {
|
|
|
603
842
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
604
843
|
updatedAt: Date.now(),
|
|
605
844
|
});
|
|
845
|
+
this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
|
|
846
|
+
if (!existing) {
|
|
847
|
+
this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
|
|
848
|
+
}
|
|
606
849
|
return this.json(res, 200, { ok: true, skillId, visibility });
|
|
607
850
|
}
|
|
608
851
|
const skillBundleMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/skills\/([^/]+)\/bundle$/) : null;
|
|
@@ -624,7 +867,12 @@ class HubServer {
|
|
|
624
867
|
}
|
|
625
868
|
if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
|
|
626
869
|
const body = await this.readJson(req);
|
|
627
|
-
|
|
870
|
+
const srcSkillId = String(body?.sourceSkillId || "");
|
|
871
|
+
const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
|
|
872
|
+
this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
|
|
873
|
+
if (existing) {
|
|
874
|
+
this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
|
|
875
|
+
}
|
|
628
876
|
return this.json(res, 200, { ok: true });
|
|
629
877
|
}
|
|
630
878
|
// ── Admin: shared tasks & skills management ──
|
|
@@ -634,14 +882,51 @@ class HubServer {
|
|
|
634
882
|
const tasks = this.opts.store.listAllHubTasks();
|
|
635
883
|
return this.json(res, 200, { tasks });
|
|
636
884
|
}
|
|
885
|
+
const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
|
|
886
|
+
if (hubTaskDetailMatch) {
|
|
887
|
+
const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
|
|
888
|
+
const task = this.opts.store.getHubTaskById(taskId);
|
|
889
|
+
if (!task)
|
|
890
|
+
return this.json(res, 404, { error: "not_found" });
|
|
891
|
+
const chunks = this.opts.store.listHubChunksByTaskId(taskId);
|
|
892
|
+
return this.json(res, 200, {
|
|
893
|
+
id: task.id, title: task.title, summary: task.summary,
|
|
894
|
+
startedAt: task.createdAt, endedAt: task.updatedAt,
|
|
895
|
+
chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
|
|
899
|
+
if (hubSkillDetailMatch) {
|
|
900
|
+
const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
|
|
901
|
+
const skill = this.opts.store.getHubSkillById(skillId);
|
|
902
|
+
if (!skill)
|
|
903
|
+
return this.json(res, 404, { error: "not_found" });
|
|
904
|
+
let files = [];
|
|
905
|
+
try {
|
|
906
|
+
const bundle = JSON.parse(skill.bundle || "{}");
|
|
907
|
+
if (Array.isArray(bundle.files)) {
|
|
908
|
+
files = bundle.files.map((f) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
catch { /* ignore parse error */ }
|
|
912
|
+
return this.json(res, 200, {
|
|
913
|
+
skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
|
|
914
|
+
files,
|
|
915
|
+
versions: [],
|
|
916
|
+
});
|
|
917
|
+
}
|
|
637
918
|
const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
|
|
638
919
|
if (adminTaskDeleteMatch) {
|
|
639
920
|
if (auth.role !== "admin")
|
|
640
921
|
return this.json(res, 403, { error: "forbidden" });
|
|
641
922
|
const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
|
|
923
|
+
const taskInfo = this.opts.store.getHubTaskById(taskId);
|
|
642
924
|
const deleted = this.opts.store.deleteHubTaskById(taskId);
|
|
643
925
|
if (!deleted)
|
|
644
926
|
return this.json(res, 404, { error: "not_found" });
|
|
927
|
+
if (taskInfo) {
|
|
928
|
+
this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
|
|
929
|
+
}
|
|
645
930
|
return this.json(res, 200, { ok: true });
|
|
646
931
|
}
|
|
647
932
|
if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-skills") {
|
|
@@ -655,9 +940,13 @@ class HubServer {
|
|
|
655
940
|
if (auth.role !== "admin")
|
|
656
941
|
return this.json(res, 403, { error: "forbidden" });
|
|
657
942
|
const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
|
|
943
|
+
const skillInfo = this.opts.store.getHubSkillById(skillId);
|
|
658
944
|
const deleted = this.opts.store.deleteHubSkillById(skillId);
|
|
659
945
|
if (!deleted)
|
|
660
946
|
return this.json(res, 404, { error: "not_found" });
|
|
947
|
+
if (skillInfo) {
|
|
948
|
+
this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
|
|
949
|
+
}
|
|
661
950
|
return this.json(res, 200, { ok: true });
|
|
662
951
|
}
|
|
663
952
|
if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-memories") {
|
|
@@ -671,9 +960,24 @@ class HubServer {
|
|
|
671
960
|
if (auth.role !== "admin")
|
|
672
961
|
return this.json(res, 403, { error: "forbidden" });
|
|
673
962
|
const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
|
|
963
|
+
const memInfo = this.opts.store.getHubMemoryById(memoryId);
|
|
674
964
|
const deleted = this.opts.store.deleteHubMemoryById(memoryId);
|
|
675
965
|
if (!deleted)
|
|
676
966
|
return this.json(res, 404, { error: "not_found" });
|
|
967
|
+
if (memInfo) {
|
|
968
|
+
const payload = JSON.stringify({
|
|
969
|
+
memoryId,
|
|
970
|
+
sourceChunkId: memInfo.sourceChunkId,
|
|
971
|
+
});
|
|
972
|
+
this.opts.store.insertHubNotification({
|
|
973
|
+
id: (0, crypto_1.randomUUID)(),
|
|
974
|
+
userId: memInfo.sourceUserId,
|
|
975
|
+
type: "resource_removed",
|
|
976
|
+
resource: "memory",
|
|
977
|
+
title: memInfo.summary || memInfo.id,
|
|
978
|
+
message: payload,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
677
981
|
return this.json(res, 200, { ok: true });
|
|
678
982
|
}
|
|
679
983
|
if (req.method === "POST" && routePath === "/api/v1/hub/memory-detail") {
|
|
@@ -698,8 +1002,85 @@ class HubServer {
|
|
|
698
1002
|
source: { ts: chunk.createdAt, role: chunk.role },
|
|
699
1003
|
});
|
|
700
1004
|
}
|
|
1005
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
|
|
1006
|
+
const unread = (new URL(req.url, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
|
|
1007
|
+
const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
|
|
1008
|
+
const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
|
|
1009
|
+
return this.json(res, 200, { notifications: list, unreadCount });
|
|
1010
|
+
}
|
|
1011
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
|
|
1012
|
+
const body = await this.readJson(req);
|
|
1013
|
+
const ids = Array.isArray(body.ids) ? body.ids : undefined;
|
|
1014
|
+
this.opts.store.markHubNotificationsRead(auth.userId, ids);
|
|
1015
|
+
return this.json(res, 200, { ok: true });
|
|
1016
|
+
}
|
|
1017
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
|
|
1018
|
+
this.opts.store.clearHubNotifications(auth.userId);
|
|
1019
|
+
return this.json(res, 200, { ok: true });
|
|
1020
|
+
}
|
|
701
1021
|
return this.json(res, 404, { error: "not_found" });
|
|
702
1022
|
}
|
|
1023
|
+
notifyAdmins(type, resource, title, fromUserId, opts) {
|
|
1024
|
+
try {
|
|
1025
|
+
const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
|
|
1026
|
+
for (const admin of admins) {
|
|
1027
|
+
if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: admin.id, type, resource, title });
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
catch { /* best-effort */ }
|
|
1034
|
+
}
|
|
1035
|
+
initOnlineTracking() {
|
|
1036
|
+
try {
|
|
1037
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
1038
|
+
const users = this.opts.store.listHubUsers("active");
|
|
1039
|
+
const now = Date.now();
|
|
1040
|
+
for (const u of users) {
|
|
1041
|
+
if (u.id === ownerId)
|
|
1042
|
+
continue;
|
|
1043
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
1044
|
+
this.knownOnlineUsers.add(u.id);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
catch { /* best-effort */ }
|
|
1049
|
+
}
|
|
1050
|
+
checkOfflineUsers() {
|
|
1051
|
+
try {
|
|
1052
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
1053
|
+
const users = this.opts.store.listHubUsers("active");
|
|
1054
|
+
const now = Date.now();
|
|
1055
|
+
const currentlyOnline = new Set();
|
|
1056
|
+
for (const u of users) {
|
|
1057
|
+
if (u.id === ownerId)
|
|
1058
|
+
continue;
|
|
1059
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
1060
|
+
currentlyOnline.add(u.id);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
for (const uid of this.knownOnlineUsers) {
|
|
1064
|
+
if (!currentlyOnline.has(uid)) {
|
|
1065
|
+
const user = users.find(u => u.id === uid);
|
|
1066
|
+
if (user) {
|
|
1067
|
+
this.notifyAdmins("user_offline", "user", user.username, uid);
|
|
1068
|
+
this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
for (const uid of currentlyOnline) {
|
|
1073
|
+
if (!this.knownOnlineUsers.has(uid)) {
|
|
1074
|
+
const user = users.find(u => u.id === uid);
|
|
1075
|
+
if (user) {
|
|
1076
|
+
this.notifyAdmins("user_online", "user", user.username, uid);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
this.knownOnlineUsers = currentlyOnline;
|
|
1081
|
+
}
|
|
1082
|
+
catch { /* best-effort */ }
|
|
1083
|
+
}
|
|
703
1084
|
authenticate(req) {
|
|
704
1085
|
const header = req.headers.authorization;
|
|
705
1086
|
if (!header || !header.startsWith("Bearer "))
|
|
@@ -714,6 +1095,13 @@ class HubServer {
|
|
|
714
1095
|
const hash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
|
|
715
1096
|
if (user.tokenHash !== hash)
|
|
716
1097
|
return null;
|
|
1098
|
+
const clientIp = req.headers["x-client-ip"]?.trim()
|
|
1099
|
+
|| req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
|
|
1100
|
+
|| req.socket.remoteAddress || "";
|
|
1101
|
+
try {
|
|
1102
|
+
this.opts.store.updateHubUserActivity(user.id, clientIp);
|
|
1103
|
+
}
|
|
1104
|
+
catch { /* best-effort */ }
|
|
717
1105
|
return {
|
|
718
1106
|
userId: user.id,
|
|
719
1107
|
username: user.username,
|