@memtensor/memos-local-openclaw-plugin 1.0.4-beta.6 → 1.0.4-beta.8
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/README.md +38 -21
- package/dist/capture/index.d.ts +1 -1
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +29 -3
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts +29 -0
- package/dist/client/connector.d.ts.map +1 -0
- package/dist/client/connector.js +231 -0
- package/dist/client/connector.js.map +1 -0
- package/dist/client/hub.d.ts +61 -0
- package/dist/client/hub.d.ts.map +1 -0
- package/dist/client/hub.js +170 -0
- package/dist/client/hub.js.map +1 -0
- package/dist/client/skill-sync.d.ts +36 -0
- package/dist/client/skill-sync.d.ts.map +1 -0
- package/dist/client/skill-sync.js +226 -0
- package/dist/client/skill-sync.js.map +1 -0
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +70 -3
- package/dist/config.js.map +1 -1
- package/dist/embedding/index.d.ts +4 -2
- package/dist/embedding/index.d.ts.map +1 -1
- package/dist/embedding/index.js +17 -1
- package/dist/embedding/index.js.map +1 -1
- package/dist/hub/auth.d.ts +19 -0
- package/dist/hub/auth.d.ts.map +1 -0
- package/dist/hub/auth.js +70 -0
- package/dist/hub/auth.js.map +1 -0
- package/dist/hub/server.d.ts +48 -0
- package/dist/hub/server.d.ts.map +1 -0
- package/dist/hub/server.js +922 -0
- package/dist/hub/server.js.map +1 -0
- package/dist/hub/user-manager.d.ts +31 -0
- package/dist/hub/user-manager.d.ts.map +1 -0
- package/dist/hub/user-manager.js +129 -0
- package/dist/hub/user-manager.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/ingest/providers/index.d.ts +10 -2
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +203 -6
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/ingest/providers/openai.d.ts +1 -0
- package/dist/ingest/providers/openai.d.ts.map +1 -1
- package/dist/ingest/providers/openai.js +1 -0
- package/dist/ingest/providers/openai.js.map +1 -1
- package/dist/ingest/task-processor.js +1 -1
- package/dist/ingest/task-processor.js.map +1 -1
- package/dist/openclaw-api.d.ts +53 -0
- package/dist/openclaw-api.d.ts.map +1 -0
- package/dist/openclaw-api.js +189 -0
- package/dist/openclaw-api.js.map +1 -0
- package/dist/recall/engine.js +1 -1
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts +4 -1
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +14 -1
- package/dist/shared/llm-call.js.map +1 -1
- package/dist/sharing/types.contract.d.ts +2 -0
- package/dist/sharing/types.contract.d.ts.map +1 -0
- package/dist/sharing/types.contract.js +3 -0
- package/dist/sharing/types.contract.js.map +1 -0
- package/dist/sharing/types.d.ts +80 -0
- package/dist/sharing/types.d.ts.map +1 -0
- package/dist/sharing/types.js +3 -0
- package/dist/sharing/types.js.map +1 -0
- package/dist/skill/evaluator.d.ts.map +1 -1
- package/dist/skill/evaluator.js +2 -2
- package/dist/skill/evaluator.js.map +1 -1
- package/dist/skill/generator.d.ts.map +1 -1
- package/dist/skill/generator.js +4 -4
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/upgrader.js +1 -1
- package/dist/skill/upgrader.js.map +1 -1
- package/dist/skill/validator.js +1 -1
- package/dist/skill/validator.js.map +1 -1
- package/dist/storage/ensure-binding.d.ts.map +1 -1
- package/dist/storage/ensure-binding.js +3 -1
- package/dist/storage/ensure-binding.js.map +1 -1
- package/dist/storage/sqlite.d.ts +332 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +913 -4
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/memory-search.d.ts +5 -2
- package/dist/tools/memory-search.d.ts.map +1 -1
- package/dist/tools/memory-search.js +50 -7
- package/dist/tools/memory-search.js.map +1 -1
- package/dist/tools/network-memory-detail.d.ts +4 -0
- package/dist/tools/network-memory-detail.d.ts.map +1 -0
- package/dist/tools/network-memory-detail.js +34 -0
- package/dist/tools/network-memory-detail.js.map +1 -0
- package/dist/types.d.ts +48 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +4299 -511
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +65 -0
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +1844 -90
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +767 -41
- package/openclaw.plugin.json +3 -2
- package/package.json +3 -3
- package/scripts/postinstall.cjs +282 -45
- package/skill/memos-memory-guide/SKILL.md +82 -20
- package/src/capture/index.ts +30 -2
- package/src/client/connector.ts +225 -0
- package/src/client/hub.ts +207 -0
- package/src/client/skill-sync.ts +216 -0
- package/src/config.ts +92 -3
- package/src/embedding/index.ts +21 -1
- package/src/hub/auth.ts +78 -0
- package/src/hub/server.ts +906 -0
- package/src/hub/user-manager.ts +143 -0
- package/src/index.ts +13 -5
- package/src/ingest/providers/index.ts +240 -6
- package/src/ingest/providers/openai.ts +1 -1
- package/src/ingest/task-processor.ts +1 -1
- package/src/openclaw-api.ts +287 -0
- package/src/recall/engine.ts +1 -1
- package/src/shared/llm-call.ts +18 -2
- package/src/sharing/types.contract.ts +40 -0
- package/src/sharing/types.ts +102 -0
- package/src/skill/evaluator.ts +3 -2
- package/src/skill/generator.ts +6 -4
- package/src/skill/upgrader.ts +1 -1
- package/src/skill/validator.ts +1 -1
- package/src/storage/ensure-binding.ts +3 -1
- package/src/storage/sqlite.ts +1164 -4
- package/src/tools/index.ts +1 -0
- package/src/tools/memory-search.ts +58 -8
- package/src/tools/network-memory-detail.ts +34 -0
- package/src/types.ts +43 -2
- package/src/viewer/html.ts +4299 -511
- package/src/viewer/server.ts +1688 -73
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as http from "http";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "crypto";
|
|
5
|
+
import type { SqliteStore } from "../storage/sqlite";
|
|
6
|
+
import type { Embedder } from "../embedding";
|
|
7
|
+
import type { Logger, MemosLocalConfig } from "../types";
|
|
8
|
+
import { issueUserToken, verifyUserToken } from "./auth";
|
|
9
|
+
import { HubUserManager } from "./user-manager";
|
|
10
|
+
|
|
11
|
+
type HubServerOptions = {
|
|
12
|
+
store: SqliteStore;
|
|
13
|
+
log: Logger;
|
|
14
|
+
config: MemosLocalConfig;
|
|
15
|
+
dataDir: string;
|
|
16
|
+
embedder?: Embedder;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type HubAuthState = {
|
|
20
|
+
authSecret: string;
|
|
21
|
+
bootstrapAdminUserId?: string;
|
|
22
|
+
bootstrapAdminToken?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export class HubServer {
|
|
26
|
+
private server?: http.Server;
|
|
27
|
+
private remoteHitMap = new Map<string, { chunkId: string; type: "chunk" | "memory"; expiresAt: number; requesterUserId: string }>();
|
|
28
|
+
private readonly userManager: HubUserManager;
|
|
29
|
+
private readonly authStatePath: string;
|
|
30
|
+
private authState: HubAuthState;
|
|
31
|
+
|
|
32
|
+
private static readonly RATE_WINDOW_MS = 60_000;
|
|
33
|
+
private static readonly RATE_LIMIT_DEFAULT = 60;
|
|
34
|
+
private static readonly RATE_LIMIT_SEARCH = 30;
|
|
35
|
+
private rateBuckets = new Map<string, { count: number; windowStart: number }>();
|
|
36
|
+
|
|
37
|
+
private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
|
|
38
|
+
private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
|
|
39
|
+
private offlineCheckTimer?: ReturnType<typeof setInterval>;
|
|
40
|
+
private knownOnlineUsers = new Set<string>();
|
|
41
|
+
|
|
42
|
+
constructor(private opts: HubServerOptions) {
|
|
43
|
+
this.userManager = new HubUserManager(opts.store, opts.log);
|
|
44
|
+
this.authStatePath = path.join(opts.dataDir, "hub-auth.json");
|
|
45
|
+
this.authState = this.loadAuthState();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private checkRateLimit(userId: string, endpoint: string): boolean {
|
|
49
|
+
const key = `${userId}:${endpoint}`;
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const limit = endpoint === "search" ? HubServer.RATE_LIMIT_SEARCH : HubServer.RATE_LIMIT_DEFAULT;
|
|
52
|
+
const bucket = this.rateBuckets.get(key);
|
|
53
|
+
if (!bucket || now - bucket.windowStart > HubServer.RATE_WINDOW_MS) {
|
|
54
|
+
this.rateBuckets.set(key, { count: 1, windowStart: now });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
bucket.count++;
|
|
58
|
+
return bucket.count <= limit;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async start(): Promise<string> {
|
|
62
|
+
if (!this.teamToken) {
|
|
63
|
+
throw new Error("team token is required to start hub mode");
|
|
64
|
+
}
|
|
65
|
+
if (this.server?.listening) {
|
|
66
|
+
return `http://127.0.0.1:${this.port}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.server = http.createServer(async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
await this.handle(req, res);
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
const code = err?.statusCode ?? 500;
|
|
74
|
+
const message = code === 413 ? "request_body_too_large" : "internal_error";
|
|
75
|
+
this.opts.log.warn(`hub server error: ${String(err)}`);
|
|
76
|
+
res.statusCode = code;
|
|
77
|
+
res.setHeader("content-type", "application/json");
|
|
78
|
+
res.end(JSON.stringify({ error: message }));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await new Promise<void>((resolve, reject) => {
|
|
83
|
+
const onError = (err: Error) => {
|
|
84
|
+
this.server?.off("listening", onListening);
|
|
85
|
+
reject(err);
|
|
86
|
+
};
|
|
87
|
+
const onListening = () => {
|
|
88
|
+
this.server?.off("error", onError);
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
this.server!.once("error", onError);
|
|
92
|
+
this.server!.once("listening", onListening);
|
|
93
|
+
this.server!.listen(this.port, "0.0.0.0");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const bootstrap = this.userManager.ensureBootstrapAdmin(
|
|
97
|
+
this.authSecret,
|
|
98
|
+
"admin",
|
|
99
|
+
this.authState.bootstrapAdminUserId,
|
|
100
|
+
this.authState.bootstrapAdminToken,
|
|
101
|
+
);
|
|
102
|
+
if (bootstrap.token) {
|
|
103
|
+
this.authState.bootstrapAdminUserId = bootstrap.user.id;
|
|
104
|
+
this.authState.bootstrapAdminToken = bootstrap.token;
|
|
105
|
+
this.saveAuthState();
|
|
106
|
+
this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.initOnlineTracking();
|
|
110
|
+
this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
|
|
111
|
+
|
|
112
|
+
return `http://127.0.0.1:${this.port}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async stop(): Promise<void> {
|
|
116
|
+
if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
|
|
117
|
+
if (!this.server) return;
|
|
118
|
+
const server = this.server;
|
|
119
|
+
this.server = undefined;
|
|
120
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private get port(): number {
|
|
124
|
+
return this.opts.config.sharing?.hub?.port ?? 18800;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private get teamName(): string {
|
|
128
|
+
return this.opts.config.sharing?.hub?.teamName ?? "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private get teamToken(): string {
|
|
132
|
+
return this.opts.config.sharing?.hub?.teamToken ?? "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private get authSecret(): string {
|
|
136
|
+
return this.authState.authSecret;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private loadAuthState(): HubAuthState {
|
|
140
|
+
try {
|
|
141
|
+
const raw = fs.readFileSync(this.authStatePath, "utf8");
|
|
142
|
+
const parsed = JSON.parse(raw) as HubAuthState;
|
|
143
|
+
if (parsed.authSecret) return parsed;
|
|
144
|
+
} catch {}
|
|
145
|
+
const initial = { authSecret: randomBytes(32).toString("hex") } as HubAuthState;
|
|
146
|
+
fs.mkdirSync(path.dirname(this.authStatePath), { recursive: true });
|
|
147
|
+
fs.writeFileSync(this.authStatePath, JSON.stringify(initial, null, 2), "utf8");
|
|
148
|
+
return initial;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private saveAuthState(): void {
|
|
152
|
+
fs.mkdirSync(path.dirname(this.authStatePath), { recursive: true });
|
|
153
|
+
fs.writeFileSync(this.authStatePath, JSON.stringify(this.authState, null, 2), "utf8");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private embedChunksAsync(chunkIds: string[], chunks: Array<{ id: string; summary?: string; content?: string }>): void {
|
|
157
|
+
const embedder = this.opts.embedder;
|
|
158
|
+
if (!embedder) return;
|
|
159
|
+
const texts = chunks.map(c => c.summary || (c.content ? c.content.slice(0, 500) : ""));
|
|
160
|
+
embedder.embed(texts).then((vectors) => {
|
|
161
|
+
for (let i = 0; i < vectors.length; i++) {
|
|
162
|
+
if (vectors[i]) {
|
|
163
|
+
this.opts.store.upsertHubEmbedding(chunkIds[i], new Float32Array(vectors[i]));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.opts.log.info(`hub: embedded ${vectors.filter(Boolean).length}/${chunkIds.length} shared chunks`);
|
|
167
|
+
}).catch((err) => {
|
|
168
|
+
this.opts.log.warn(`hub: embedding shared chunks failed: ${err}`);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
|
|
173
|
+
const embedder = this.opts.embedder;
|
|
174
|
+
if (!embedder) return;
|
|
175
|
+
const text = summary || content.slice(0, 500);
|
|
176
|
+
embedder.embed([text]).then((vectors) => {
|
|
177
|
+
if (vectors[0]) {
|
|
178
|
+
this.opts.store.upsertHubMemoryEmbedding(memoryId, new Float32Array(vectors[0]));
|
|
179
|
+
this.opts.log.info(`hub: embedded shared memory ${memoryId}`);
|
|
180
|
+
}
|
|
181
|
+
}).catch((err) => {
|
|
182
|
+
this.opts.log.warn(`hub: embedding shared memory failed: ${err}`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
187
|
+
const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
|
|
188
|
+
const routePath = url.pathname;
|
|
189
|
+
|
|
190
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/info") {
|
|
191
|
+
return this.json(res, 200, {
|
|
192
|
+
teamName: this.teamName,
|
|
193
|
+
version: "0.0.0",
|
|
194
|
+
apiVersion: "v1",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/join") {
|
|
199
|
+
const body = await this.readJson(req);
|
|
200
|
+
if (!body || body.teamToken !== this.teamToken) {
|
|
201
|
+
return this.json(res, 403, { error: "invalid_team_token" });
|
|
202
|
+
}
|
|
203
|
+
const username = String(body.username || `user-${randomUUID().slice(0, 8)}`);
|
|
204
|
+
const joinIp = (typeof body.clientIp === "string" && body.clientIp)
|
|
205
|
+
|| (req.headers["x-client-ip"] as string)?.trim()
|
|
206
|
+
|| (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|
|
207
|
+
|| req.socket.remoteAddress || "";
|
|
208
|
+
const existingUsers = this.opts.store.listHubUsers();
|
|
209
|
+
const existingUser = existingUsers.find(u => u.username === username);
|
|
210
|
+
if (existingUser) {
|
|
211
|
+
try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
|
|
212
|
+
if (existingUser.status === "active") {
|
|
213
|
+
const token = issueUserToken(
|
|
214
|
+
{ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
|
|
215
|
+
this.authSecret,
|
|
216
|
+
);
|
|
217
|
+
this.userManager.approveUser(existingUser.id, token);
|
|
218
|
+
return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
|
|
219
|
+
}
|
|
220
|
+
if (existingUser.status === "pending") {
|
|
221
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id });
|
|
222
|
+
}
|
|
223
|
+
if (existingUser.status === "rejected") {
|
|
224
|
+
this.userManager.resetToPending(existingUser.id);
|
|
225
|
+
this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
|
|
226
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const user = this.userManager.createPendingUser({
|
|
230
|
+
username,
|
|
231
|
+
deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
|
|
232
|
+
});
|
|
233
|
+
try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
|
|
234
|
+
this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
|
|
235
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
236
|
+
return this.json(res, 200, { status: "pending", userId: user.id });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
|
|
240
|
+
const body = await this.readJson(req);
|
|
241
|
+
if (!body || body.teamToken !== this.teamToken) {
|
|
242
|
+
return this.json(res, 403, { error: "invalid_team_token" });
|
|
243
|
+
}
|
|
244
|
+
const userId = String(body.userId || "");
|
|
245
|
+
if (!userId) return this.json(res, 400, { error: "missing_user_id" });
|
|
246
|
+
const user = this.opts.store.getHubUser(userId);
|
|
247
|
+
if (!user) return this.json(res, 404, { error: "not_found" });
|
|
248
|
+
if (user.status === "pending") {
|
|
249
|
+
return this.json(res, 200, { status: "pending" });
|
|
250
|
+
}
|
|
251
|
+
if (user.status === "rejected") {
|
|
252
|
+
return this.json(res, 200, { status: "rejected" });
|
|
253
|
+
}
|
|
254
|
+
if (user.status === "active") {
|
|
255
|
+
const token = issueUserToken(
|
|
256
|
+
{ userId: user.id, username: user.username, role: user.role, status: user.status },
|
|
257
|
+
this.authSecret,
|
|
258
|
+
);
|
|
259
|
+
return this.json(res, 200, { status: "active", userToken: token });
|
|
260
|
+
}
|
|
261
|
+
return this.json(res, 200, { status: user.status });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// All endpoints below require authentication + rate limiting
|
|
265
|
+
const auth = this.authenticate(req);
|
|
266
|
+
if (!auth) return this.json(res, 401, { error: "unauthorized" });
|
|
267
|
+
|
|
268
|
+
const endpointKey = routePath.replace(/^\/api\/v1\/hub\//, "").replace(/\/[^/]+\/bundle$/, "/bundle");
|
|
269
|
+
if (!this.checkRateLimit(auth.userId, endpointKey)) {
|
|
270
|
+
return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
|
|
274
|
+
return this.json(res, 200, { ok: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
|
|
278
|
+
try {
|
|
279
|
+
this.opts.store.updateHubUserActivity(auth.userId, "", 0);
|
|
280
|
+
} catch { /* best-effort */ }
|
|
281
|
+
this.knownOnlineUsers.delete(auth.userId);
|
|
282
|
+
this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
|
|
283
|
+
this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
|
|
284
|
+
return this.json(res, 200, { ok: true });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/me") {
|
|
288
|
+
const user = this.opts.store.getHubUser(auth.userId);
|
|
289
|
+
if (!user) return this.json(res, 401, { error: "unauthorized" });
|
|
290
|
+
return this.json(res, 200, user);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/me/update-profile") {
|
|
294
|
+
const body = await this.readJson(req);
|
|
295
|
+
if (!body) return this.json(res, 400, { error: "invalid_body" });
|
|
296
|
+
const newUsername = String(body.username || "").trim();
|
|
297
|
+
if (!newUsername || newUsername.length < 2 || newUsername.length > 32) {
|
|
298
|
+
return this.json(res, 400, { error: "invalid_username", message: "Username must be 2-32 characters" });
|
|
299
|
+
}
|
|
300
|
+
if (this.userManager.isUsernameTaken(newUsername, auth.userId)) {
|
|
301
|
+
return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
|
|
302
|
+
}
|
|
303
|
+
const updated = this.userManager.updateUsername(auth.userId, newUsername);
|
|
304
|
+
if (!updated) return this.json(res, 404, { error: "not_found" });
|
|
305
|
+
const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
306
|
+
const newToken = issueUserToken(
|
|
307
|
+
{ userId: updated.id, username: newUsername, role: updated.role, status: updated.status },
|
|
308
|
+
this.authSecret,
|
|
309
|
+
ttlMs,
|
|
310
|
+
);
|
|
311
|
+
this.userManager.approveUser(updated.id, newToken);
|
|
312
|
+
this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
|
|
313
|
+
return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/admin/pending-users") {
|
|
317
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
318
|
+
return this.json(res, 200, { users: this.userManager.listPendingUsers() });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/approve-user") {
|
|
322
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
323
|
+
const body = await this.readJson(req);
|
|
324
|
+
const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
|
|
325
|
+
const approved = this.userManager.approveUser(String(body.userId), token);
|
|
326
|
+
if (!approved) return this.json(res, 404, { error: "not_found" });
|
|
327
|
+
try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
|
|
328
|
+
return this.json(res, 200, { status: "active", token });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
|
|
332
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
333
|
+
const body = await this.readJson(req);
|
|
334
|
+
const rejected = this.userManager.rejectUser(String(body.userId));
|
|
335
|
+
if (!rejected) return this.json(res, 404, { error: "not_found" });
|
|
336
|
+
return this.json(res, 200, { status: "rejected" });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
|
|
340
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
341
|
+
const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
|
|
342
|
+
const contribs = this.opts.store.getHubUserContributions();
|
|
343
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
return this.json(res, 200, { users: users.map(u => {
|
|
346
|
+
const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
347
|
+
const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
|
|
348
|
+
return {
|
|
349
|
+
id: u.id, username: u.username, role: u.role, status: u.status,
|
|
350
|
+
deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
|
|
351
|
+
lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
|
|
352
|
+
isOwner: u.id === ownerId, isOnline,
|
|
353
|
+
memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
|
|
354
|
+
};
|
|
355
|
+
}) });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
|
|
359
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
360
|
+
const body = await this.readJson(req);
|
|
361
|
+
const userId = String(body?.userId || "");
|
|
362
|
+
const newRole = String(body?.role || "");
|
|
363
|
+
if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
|
|
364
|
+
if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
|
|
365
|
+
return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
|
|
366
|
+
}
|
|
367
|
+
const user = this.opts.store.getHubUser(userId);
|
|
368
|
+
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
369
|
+
const updatedUser = { ...user, role: newRole as "admin" | "member" };
|
|
370
|
+
this.opts.store.upsertHubUser(updatedUser);
|
|
371
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
|
|
372
|
+
return this.json(res, 200, { ok: true, role: newRole });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
|
|
376
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
377
|
+
const body = await this.readJson(req);
|
|
378
|
+
const userId = String(body?.userId || "");
|
|
379
|
+
const newUsername = String(body?.username || "").trim();
|
|
380
|
+
if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
|
|
381
|
+
return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
|
|
382
|
+
}
|
|
383
|
+
if (this.userManager.isUsernameTaken(newUsername, userId)) {
|
|
384
|
+
return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
|
|
385
|
+
}
|
|
386
|
+
const user = this.opts.store.getHubUser(userId);
|
|
387
|
+
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
388
|
+
const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
389
|
+
const newToken = issueUserToken(
|
|
390
|
+
{ userId: user.id, username: newUsername, role: user.role, status: user.status },
|
|
391
|
+
this.authSecret,
|
|
392
|
+
ttlMs,
|
|
393
|
+
);
|
|
394
|
+
this.userManager.approveUser(user.id, newToken);
|
|
395
|
+
const updated = this.opts.store.getHubUser(userId)!;
|
|
396
|
+
const finalUser = { ...updated, username: newUsername };
|
|
397
|
+
this.opts.store.upsertHubUser(finalUser);
|
|
398
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
|
|
399
|
+
return this.json(res, 200, { ok: true, username: newUsername });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
|
|
403
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
404
|
+
const body = await this.readJson(req);
|
|
405
|
+
const userId = String(body?.userId || "");
|
|
406
|
+
if (!userId) return this.json(res, 400, { error: "missing_user_id" });
|
|
407
|
+
if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
|
|
408
|
+
if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
|
|
409
|
+
const cleanResources = body?.cleanResources === true;
|
|
410
|
+
const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
|
|
411
|
+
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
412
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
|
|
413
|
+
return this.json(res, 200, { ok: true });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
|
|
417
|
+
const body = await this.readJson(req);
|
|
418
|
+
if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
|
|
419
|
+
const task = { ...body.task, sourceUserId: auth.userId };
|
|
420
|
+
const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
|
|
421
|
+
this.opts.store.upsertHubTask(task);
|
|
422
|
+
const chunks = Array.isArray(body.chunks) ? body.chunks : [];
|
|
423
|
+
const chunkIds: string[] = [];
|
|
424
|
+
for (const chunk of chunks) {
|
|
425
|
+
this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
|
|
426
|
+
chunkIds.push(chunk.id);
|
|
427
|
+
}
|
|
428
|
+
if (this.opts.embedder && chunkIds.length > 0) {
|
|
429
|
+
this.embedChunksAsync(chunkIds, chunks);
|
|
430
|
+
}
|
|
431
|
+
if (!existingTask) {
|
|
432
|
+
this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
|
|
433
|
+
}
|
|
434
|
+
return this.json(res, 200, { ok: true, chunks: chunkIds.length });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
|
|
438
|
+
const body = await this.readJson(req);
|
|
439
|
+
const srcTaskId = String(body.sourceTaskId);
|
|
440
|
+
const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
|
|
441
|
+
this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
|
|
442
|
+
if (existing) {
|
|
443
|
+
this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
|
|
444
|
+
}
|
|
445
|
+
return this.json(res, 200, { ok: true });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/memories/share") {
|
|
449
|
+
const body = await this.readJson(req);
|
|
450
|
+
if (!body?.memory) return this.json(res, 400, { error: "invalid_payload" });
|
|
451
|
+
const m = body.memory;
|
|
452
|
+
const sourceChunkId = String(m.sourceChunkId || "");
|
|
453
|
+
if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
|
|
454
|
+
const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
|
|
455
|
+
const memoryId = existing?.id ?? randomUUID();
|
|
456
|
+
const visibility = "public";
|
|
457
|
+
const resolvedGroupId: string | null = null;
|
|
458
|
+
const now = Date.now();
|
|
459
|
+
this.opts.store.upsertHubMemory({
|
|
460
|
+
id: memoryId,
|
|
461
|
+
sourceChunkId,
|
|
462
|
+
sourceUserId: auth.userId,
|
|
463
|
+
role: String(m.role || "assistant"),
|
|
464
|
+
content: String(m.content || ""),
|
|
465
|
+
summary: String(m.summary || ""),
|
|
466
|
+
kind: String(m.kind || "paragraph"),
|
|
467
|
+
groupId: resolvedGroupId,
|
|
468
|
+
visibility,
|
|
469
|
+
createdAt: existing?.createdAt ?? now,
|
|
470
|
+
updatedAt: now,
|
|
471
|
+
});
|
|
472
|
+
if (this.opts.embedder) {
|
|
473
|
+
this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
|
|
474
|
+
}
|
|
475
|
+
if (!existing) {
|
|
476
|
+
this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
|
|
477
|
+
}
|
|
478
|
+
return this.json(res, 200, { ok: true, memoryId, visibility });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/memories/unshare") {
|
|
482
|
+
const body = await this.readJson(req);
|
|
483
|
+
const sourceChunkId = String(body?.sourceChunkId || "");
|
|
484
|
+
if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
|
|
485
|
+
const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
|
|
486
|
+
this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
|
|
487
|
+
if (existing) {
|
|
488
|
+
this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
|
|
489
|
+
}
|
|
490
|
+
return this.json(res, 200, { ok: true });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/memories") {
|
|
494
|
+
const limit = Number(url.searchParams.get("limit") || 40);
|
|
495
|
+
const memories = this.opts.store.listVisibleHubMemories(auth.userId, limit);
|
|
496
|
+
return this.json(res, 200, { memories });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/tasks") {
|
|
500
|
+
const limit = Number(url.searchParams.get("limit") || 40);
|
|
501
|
+
const tasks = this.opts.store.listVisibleHubTasks(auth.userId, limit);
|
|
502
|
+
return this.json(res, 200, { tasks });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/skills/list") {
|
|
506
|
+
const limit = Number(url.searchParams.get("limit") || 40);
|
|
507
|
+
const skills = this.opts.store.listVisibleHubSkills(auth.userId, limit);
|
|
508
|
+
return this.json(res, 200, { skills });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/search") {
|
|
512
|
+
const body = await this.readJson(req);
|
|
513
|
+
const query = String(body.query || "");
|
|
514
|
+
const maxResults = Number(body.maxResults || 10);
|
|
515
|
+
const ftsHits = this.opts.store.searchHubChunks(query, { userId: auth.userId, maxResults: maxResults * 2 });
|
|
516
|
+
const memFtsHits = this.opts.store.searchHubMemories(query, { userId: auth.userId, maxResults: maxResults * 2 });
|
|
517
|
+
|
|
518
|
+
// Track which IDs are memories vs chunks
|
|
519
|
+
const memoryIdSet = new Set(memFtsHits.map(({ hit }) => hit.id));
|
|
520
|
+
|
|
521
|
+
// Attempt vector search and RRF merge if embedder is available
|
|
522
|
+
let mergedIds: string[];
|
|
523
|
+
if (this.opts.embedder) {
|
|
524
|
+
try {
|
|
525
|
+
const [queryVec] = await this.opts.embedder.embed([query]);
|
|
526
|
+
if (queryVec) {
|
|
527
|
+
const allEmb = this.opts.store.getVisibleHubEmbeddings(auth.userId);
|
|
528
|
+
const memEmb = this.opts.store.getVisibleHubMemoryEmbeddings(auth.userId);
|
|
529
|
+
const scored: Array<{ id: string; score: number }> = [];
|
|
530
|
+
const cosineSim = (vec: Float32Array) => {
|
|
531
|
+
let dot = 0, nA = 0, nB = 0;
|
|
532
|
+
for (let i = 0; i < queryVec.length && i < vec.length; i++) {
|
|
533
|
+
dot += queryVec[i] * vec[i]; nA += queryVec[i] * queryVec[i]; nB += vec[i] * vec[i];
|
|
534
|
+
}
|
|
535
|
+
return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
|
|
536
|
+
};
|
|
537
|
+
for (const e of allEmb) scored.push({ id: e.chunkId, score: cosineSim(e.vector) });
|
|
538
|
+
for (const e of memEmb) { scored.push({ id: e.memoryId, score: cosineSim(e.vector) }); memoryIdSet.add(e.memoryId); }
|
|
539
|
+
scored.sort((a, b) => b.score - a.score);
|
|
540
|
+
const topScored = scored.slice(0, maxResults * 2);
|
|
541
|
+
|
|
542
|
+
const K = 60;
|
|
543
|
+
const rrfScores = new Map<string, number>();
|
|
544
|
+
ftsHits.forEach(({ hit }, idx) => {
|
|
545
|
+
rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
|
|
546
|
+
});
|
|
547
|
+
memFtsHits.forEach(({ hit }, idx) => {
|
|
548
|
+
rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
|
|
549
|
+
});
|
|
550
|
+
topScored.forEach(({ id }, idx) => {
|
|
551
|
+
rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
|
|
552
|
+
});
|
|
553
|
+
mergedIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, maxResults).map(([id]) => id);
|
|
554
|
+
} else {
|
|
555
|
+
mergedIds = [...ftsHits.map(({ hit }) => hit.id), ...memFtsHits.map(({ hit }) => hit.id)].slice(0, maxResults);
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
mergedIds = [...ftsHits.map(({ hit }) => hit.id), ...memFtsHits.map(({ hit }) => hit.id)].slice(0, maxResults);
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
mergedIds = [...ftsHits.map(({ hit }) => hit.id), ...memFtsHits.map(({ hit }) => hit.id)].slice(0, maxResults);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const ftsMap = new Map(ftsHits.map(({ hit }) => [hit.id, hit]));
|
|
565
|
+
const memFtsMap = new Map(memFtsHits.map(({ hit }) => [hit.id, hit]));
|
|
566
|
+
const hits = mergedIds.map((id, rank) => {
|
|
567
|
+
const isMemory = memoryIdSet.has(id);
|
|
568
|
+
if (isMemory) {
|
|
569
|
+
let mhit = memFtsMap.get(id);
|
|
570
|
+
if (!mhit) {
|
|
571
|
+
const visibleHit = this.opts.store.getVisibleHubSearchHitByMemoryId(id, auth.userId);
|
|
572
|
+
if (!visibleHit) return null;
|
|
573
|
+
mhit = visibleHit;
|
|
574
|
+
}
|
|
575
|
+
const remoteHitId = randomUUID();
|
|
576
|
+
this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "memory", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId });
|
|
577
|
+
return {
|
|
578
|
+
remoteHitId, summary: mhit.summary, excerpt: mhit.content.slice(0, 240), hubRank: rank + 1,
|
|
579
|
+
taskTitle: null, ownerName: mhit.owner_name || "unknown", groupName: mhit.group_name,
|
|
580
|
+
visibility: mhit.visibility, source: { ts: mhit.created_at, role: mhit.role },
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
let hit = ftsMap.get(id);
|
|
584
|
+
if (!hit) {
|
|
585
|
+
const visibleHit = this.opts.store.getVisibleHubSearchHitByChunkId(id, auth.userId);
|
|
586
|
+
if (!visibleHit) return null;
|
|
587
|
+
hit = visibleHit as any;
|
|
588
|
+
}
|
|
589
|
+
const remoteHitId = randomUUID();
|
|
590
|
+
this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "chunk", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId });
|
|
591
|
+
return {
|
|
592
|
+
remoteHitId, summary: hit!.summary, excerpt: hit!.content.slice(0, 240), hubRank: rank + 1,
|
|
593
|
+
taskTitle: hit!.task_title, ownerName: hit!.owner_name || "unknown", groupName: hit!.group_name,
|
|
594
|
+
visibility: hit!.visibility, source: { ts: hit!.created_at, role: hit!.role },
|
|
595
|
+
};
|
|
596
|
+
}).filter(Boolean);
|
|
597
|
+
return this.json(res, 200, { hits, meta: { totalCandidates: hits.length, searchedGroups: [], includedPublic: true } });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
|
|
601
|
+
const hits = this.opts.store.searchHubSkills(String(url.searchParams.get("query") || ""), {
|
|
602
|
+
userId: auth.userId,
|
|
603
|
+
maxResults: Number(url.searchParams.get("maxResults") || 10),
|
|
604
|
+
}).map(({ hit }) => ({
|
|
605
|
+
skillId: hit.id,
|
|
606
|
+
name: hit.name,
|
|
607
|
+
description: hit.description,
|
|
608
|
+
version: hit.version,
|
|
609
|
+
visibility: hit.visibility,
|
|
610
|
+
groupName: hit.group_name,
|
|
611
|
+
ownerName: hit.owner_name || "unknown",
|
|
612
|
+
qualityScore: hit.quality_score,
|
|
613
|
+
}));
|
|
614
|
+
return this.json(res, 200, { hits });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/skills/publish") {
|
|
618
|
+
const body = await this.readJson(req);
|
|
619
|
+
const metadata = body?.metadata ?? {};
|
|
620
|
+
const sourceSkillId = String(metadata.id || "");
|
|
621
|
+
if (!sourceSkillId) return this.json(res, 400, { error: "missing_skill_id" });
|
|
622
|
+
const existing = this.opts.store.getHubSkillBySource(auth.userId, sourceSkillId);
|
|
623
|
+
const skillId = existing?.id ?? randomUUID();
|
|
624
|
+
const visibility = "public";
|
|
625
|
+
this.opts.store.upsertHubSkill({
|
|
626
|
+
id: skillId,
|
|
627
|
+
sourceSkillId,
|
|
628
|
+
sourceUserId: auth.userId,
|
|
629
|
+
name: String(metadata.name || sourceSkillId),
|
|
630
|
+
description: String(metadata.description || ""),
|
|
631
|
+
version: Number(metadata.version || 1),
|
|
632
|
+
groupId: null,
|
|
633
|
+
visibility,
|
|
634
|
+
bundle: JSON.stringify(body?.bundle ?? {}),
|
|
635
|
+
qualityScore: metadata.qualityScore == null ? null : Number(metadata.qualityScore),
|
|
636
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
637
|
+
updatedAt: Date.now(),
|
|
638
|
+
});
|
|
639
|
+
if (!existing) {
|
|
640
|
+
this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
|
|
641
|
+
}
|
|
642
|
+
return this.json(res, 200, { ok: true, skillId, visibility });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const skillBundleMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/skills\/([^/]+)\/bundle$/) : null;
|
|
646
|
+
if (skillBundleMatch) {
|
|
647
|
+
const skill = this.opts.store.getHubSkillById(decodeURIComponent(skillBundleMatch[1]));
|
|
648
|
+
if (!skill) return this.json(res, 404, { error: "not_found" });
|
|
649
|
+
return this.json(res, 200, {
|
|
650
|
+
skillId: skill.id,
|
|
651
|
+
metadata: {
|
|
652
|
+
id: skill.sourceSkillId,
|
|
653
|
+
name: skill.name,
|
|
654
|
+
description: skill.description,
|
|
655
|
+
version: skill.version,
|
|
656
|
+
qualityScore: skill.qualityScore,
|
|
657
|
+
},
|
|
658
|
+
bundle: JSON.parse(skill.bundle),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
|
|
663
|
+
const body = await this.readJson(req);
|
|
664
|
+
const srcSkillId = String(body?.sourceSkillId || "");
|
|
665
|
+
const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
|
|
666
|
+
this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
|
|
667
|
+
if (existing) {
|
|
668
|
+
this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
|
|
669
|
+
}
|
|
670
|
+
return this.json(res, 200, { ok: true });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── Admin: shared tasks & skills management ──
|
|
674
|
+
|
|
675
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-tasks") {
|
|
676
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
677
|
+
const tasks = this.opts.store.listAllHubTasks();
|
|
678
|
+
return this.json(res, 200, { tasks });
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
|
|
682
|
+
if (hubTaskDetailMatch) {
|
|
683
|
+
const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
|
|
684
|
+
const task = this.opts.store.getHubTaskById(taskId);
|
|
685
|
+
if (!task) return this.json(res, 404, { error: "not_found" });
|
|
686
|
+
const chunks = this.opts.store.listHubChunksByTaskId(taskId);
|
|
687
|
+
return this.json(res, 200, {
|
|
688
|
+
id: task.id, title: task.title, summary: task.summary,
|
|
689
|
+
startedAt: task.createdAt, endedAt: task.updatedAt,
|
|
690
|
+
chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
|
|
695
|
+
if (hubSkillDetailMatch) {
|
|
696
|
+
const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
|
|
697
|
+
const skill = this.opts.store.getHubSkillById(skillId);
|
|
698
|
+
if (!skill) return this.json(res, 404, { error: "not_found" });
|
|
699
|
+
let files: Array<{ path: string; type: string; size: number }> = [];
|
|
700
|
+
try {
|
|
701
|
+
const bundle = JSON.parse(skill.bundle || "{}");
|
|
702
|
+
if (Array.isArray(bundle.files)) {
|
|
703
|
+
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) }));
|
|
704
|
+
}
|
|
705
|
+
} catch { /* ignore parse error */ }
|
|
706
|
+
return this.json(res, 200, {
|
|
707
|
+
skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
|
|
708
|
+
files,
|
|
709
|
+
versions: [],
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
|
|
714
|
+
if (adminTaskDeleteMatch) {
|
|
715
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
716
|
+
const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
|
|
717
|
+
const taskInfo = this.opts.store.getHubTaskById(taskId);
|
|
718
|
+
const deleted = this.opts.store.deleteHubTaskById(taskId);
|
|
719
|
+
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
720
|
+
if (taskInfo) {
|
|
721
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
|
|
722
|
+
}
|
|
723
|
+
return this.json(res, 200, { ok: true });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-skills") {
|
|
727
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
728
|
+
const skills = this.opts.store.listAllHubSkills();
|
|
729
|
+
return this.json(res, 200, { skills });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const adminSkillDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-skills\/([^/]+)$/) : null;
|
|
733
|
+
if (adminSkillDeleteMatch) {
|
|
734
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
735
|
+
const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
|
|
736
|
+
const skillInfo = this.opts.store.getHubSkillById(skillId);
|
|
737
|
+
const deleted = this.opts.store.deleteHubSkillById(skillId);
|
|
738
|
+
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
739
|
+
if (skillInfo) {
|
|
740
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
|
|
741
|
+
}
|
|
742
|
+
return this.json(res, 200, { ok: true });
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-memories") {
|
|
746
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
747
|
+
const memories = this.opts.store.listAllHubMemories();
|
|
748
|
+
return this.json(res, 200, { memories });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const adminMemoryDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-memories\/([^/]+)$/) : null;
|
|
752
|
+
if (adminMemoryDeleteMatch) {
|
|
753
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
754
|
+
const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
|
|
755
|
+
const memInfo = this.opts.store.getHubMemoryById(memoryId);
|
|
756
|
+
const deleted = this.opts.store.deleteHubMemoryById(memoryId);
|
|
757
|
+
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
758
|
+
if (memInfo) {
|
|
759
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
|
|
760
|
+
}
|
|
761
|
+
return this.json(res, 200, { ok: true });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/memory-detail") {
|
|
765
|
+
const body = await this.readJson(req);
|
|
766
|
+
const hit = this.remoteHitMap.get(String(body.remoteHitId));
|
|
767
|
+
if (!hit || hit.expiresAt < Date.now()) return this.json(res, 404, { error: "not_found" });
|
|
768
|
+
if (hit.requesterUserId !== auth.userId) return this.json(res, 403, { error: "forbidden" });
|
|
769
|
+
if (hit.type === "memory") {
|
|
770
|
+
const mem = this.opts.store.getHubMemoryById(hit.chunkId);
|
|
771
|
+
if (!mem) return this.json(res, 404, { error: "not_found" });
|
|
772
|
+
return this.json(res, 200, { content: mem.content, summary: mem.summary, source: { ts: mem.createdAt, role: mem.role } });
|
|
773
|
+
}
|
|
774
|
+
const chunk = this.opts.store.getHubChunkById(hit.chunkId);
|
|
775
|
+
if (!chunk) return this.json(res, 404, { error: "not_found" });
|
|
776
|
+
return this.json(res, 200, {
|
|
777
|
+
content: chunk.content,
|
|
778
|
+
summary: chunk.summary,
|
|
779
|
+
source: { ts: chunk.createdAt, role: chunk.role },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
|
|
784
|
+
const unread = (new URL(req.url!, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
|
|
785
|
+
const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
|
|
786
|
+
const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
|
|
787
|
+
return this.json(res, 200, { notifications: list, unreadCount });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
|
|
791
|
+
const body = await this.readJson(req);
|
|
792
|
+
const ids = Array.isArray(body.ids) ? body.ids as string[] : undefined;
|
|
793
|
+
this.opts.store.markHubNotificationsRead(auth.userId, ids);
|
|
794
|
+
return this.json(res, 200, { ok: true });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
|
|
798
|
+
this.opts.store.clearHubNotifications(auth.userId);
|
|
799
|
+
return this.json(res, 200, { ok: true });
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return this.json(res, 404, { error: "not_found" });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private notifyAdmins(type: string, resource: string, title: string, fromUserId: string): void {
|
|
806
|
+
try {
|
|
807
|
+
const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
|
|
808
|
+
for (const admin of admins) {
|
|
809
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
|
|
810
|
+
}
|
|
811
|
+
} catch { /* best-effort */ }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private initOnlineTracking(): void {
|
|
815
|
+
try {
|
|
816
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
817
|
+
const users = this.opts.store.listHubUsers("active");
|
|
818
|
+
const now = Date.now();
|
|
819
|
+
for (const u of users) {
|
|
820
|
+
if (u.id === ownerId) continue;
|
|
821
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
822
|
+
this.knownOnlineUsers.add(u.id);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
} catch { /* best-effort */ }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private checkOfflineUsers(): void {
|
|
829
|
+
try {
|
|
830
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
831
|
+
const users = this.opts.store.listHubUsers("active");
|
|
832
|
+
const now = Date.now();
|
|
833
|
+
const currentlyOnline = new Set<string>();
|
|
834
|
+
for (const u of users) {
|
|
835
|
+
if (u.id === ownerId) continue;
|
|
836
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
837
|
+
currentlyOnline.add(u.id);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
for (const uid of this.knownOnlineUsers) {
|
|
841
|
+
if (!currentlyOnline.has(uid)) {
|
|
842
|
+
const user = users.find(u => u.id === uid);
|
|
843
|
+
if (user) {
|
|
844
|
+
this.notifyAdmins("user_offline", "user", user.username, uid);
|
|
845
|
+
this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const uid of currentlyOnline) {
|
|
850
|
+
if (!this.knownOnlineUsers.has(uid)) {
|
|
851
|
+
const user = users.find(u => u.id === uid);
|
|
852
|
+
if (user) {
|
|
853
|
+
this.notifyAdmins("user_online", "user", user.username, uid);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
this.knownOnlineUsers = currentlyOnline;
|
|
858
|
+
} catch { /* best-effort */ }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private authenticate(req: http.IncomingMessage) {
|
|
862
|
+
const header = req.headers.authorization;
|
|
863
|
+
if (!header || !header.startsWith("Bearer ")) return null;
|
|
864
|
+
const token = header.slice("Bearer ".length);
|
|
865
|
+
const payload = verifyUserToken(token, this.authSecret);
|
|
866
|
+
if (!payload) return null;
|
|
867
|
+
const user = this.opts.store.getHubUser(payload.userId);
|
|
868
|
+
if (!user || user.status !== "active") return null;
|
|
869
|
+
const hash = createHash("sha256").update(token).digest("hex");
|
|
870
|
+
if (user.tokenHash !== hash) return null;
|
|
871
|
+
const clientIp = (req.headers["x-client-ip"] as string)?.trim()
|
|
872
|
+
|| (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|
|
873
|
+
|| req.socket.remoteAddress || "";
|
|
874
|
+
try { this.opts.store.updateHubUserActivity(user.id, clientIp); } catch { /* best-effort */ }
|
|
875
|
+
return {
|
|
876
|
+
userId: user.id,
|
|
877
|
+
username: user.username,
|
|
878
|
+
role: user.role,
|
|
879
|
+
status: user.status,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private static readonly MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
884
|
+
|
|
885
|
+
private async readJson(req: http.IncomingMessage): Promise<any> {
|
|
886
|
+
const chunks: Buffer[] = [];
|
|
887
|
+
let totalBytes = 0;
|
|
888
|
+
for await (const chunk of req) {
|
|
889
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
890
|
+
totalBytes += buf.length;
|
|
891
|
+
if (totalBytes > HubServer.MAX_BODY_BYTES) {
|
|
892
|
+
req.destroy();
|
|
893
|
+
throw Object.assign(new Error("request body too large"), { statusCode: 413 });
|
|
894
|
+
}
|
|
895
|
+
chunks.push(buf);
|
|
896
|
+
}
|
|
897
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
898
|
+
return raw ? JSON.parse(raw) : {};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private json(res: http.ServerResponse, statusCode: number, body: unknown): void {
|
|
902
|
+
res.statusCode = statusCode;
|
|
903
|
+
res.setHeader("content-type", "application/json");
|
|
904
|
+
res.end(JSON.stringify(body));
|
|
905
|
+
}
|
|
906
|
+
}
|