@memtensor/memos-local-openclaw-plugin 1.0.4-beta.9 → 1.0.5
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 +94 -27
- package/dist/capture/index.js +3 -1
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts +5 -0
- package/dist/client/connector.d.ts.map +1 -1
- package/dist/client/connector.js +89 -8
- package/dist/client/connector.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/hub/server.d.ts +2 -0
- package/dist/hub/server.d.ts.map +1 -1
- package/dist/hub/server.js +240 -35
- package/dist/hub/server.js.map +1 -1
- package/dist/hub/user-manager.d.ts +9 -0
- package/dist/hub/user-manager.d.ts.map +1 -1
- package/dist/hub/user-manager.js +26 -2
- package/dist/hub/user-manager.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.js +2 -2
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +22 -4
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +2 -1
- 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 +2 -0
- package/dist/skill/evolver.d.ts.map +1 -1
- package/dist/skill/evolver.js +56 -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/sqlite.d.ts +57 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +290 -35
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts +4 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +39 -12
- package/dist/telemetry.js.map +1 -1
- package/dist/types.d.ts +10 -0
- 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 +564 -225
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +9 -0
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +357 -108
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +412 -53
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
- package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
- package/prebuilds/linux-x64/better_sqlite3.node +0 -0
- package/prebuilds/win32-x64/better_sqlite3.node +0 -0
- package/src/capture/index.ts +4 -1
- package/src/client/connector.ts +92 -8
- package/src/config.ts +2 -1
- package/src/hub/server.ts +235 -35
- package/src/hub/user-manager.ts +42 -6
- package/src/ingest/chunker.ts +19 -13
- package/src/ingest/providers/index.ts +2 -2
- package/src/recall/engine.ts +20 -4
- package/src/shared/llm-call.ts +2 -1
- package/src/sharing/types.ts +1 -1
- package/src/skill/evolver.ts +58 -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/sqlite.ts +318 -40
- package/src/telemetry.ts +39 -14
- package/src/types.ts +11 -0
- package/src/viewer/html.ts +564 -225
- package/src/viewer/server.ts +333 -105
- package/telemetry.credentials.json +5 -0
package/src/viewer/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface ViewerServerOptions {
|
|
|
34
34
|
log: Logger;
|
|
35
35
|
dataDir: string;
|
|
36
36
|
ctx?: PluginContext;
|
|
37
|
+
defaultHubPort?: number;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
interface AuthState {
|
|
@@ -51,6 +52,8 @@ export class ViewerServer {
|
|
|
51
52
|
private readonly authFile: string;
|
|
52
53
|
private readonly auth: AuthState;
|
|
53
54
|
private readonly ctx?: PluginContext;
|
|
55
|
+
private readonly cookieName: string;
|
|
56
|
+
private readonly defaultHubPort: number;
|
|
54
57
|
|
|
55
58
|
private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
|
|
56
59
|
private static readonly PLUGIN_VERSION: string = (() => {
|
|
@@ -99,17 +102,31 @@ export class ViewerServer {
|
|
|
99
102
|
this.ctx = opts.ctx;
|
|
100
103
|
this.authFile = path.join(opts.dataDir, "viewer-auth.json");
|
|
101
104
|
this.auth = { passwordHash: null, sessions: new Map() };
|
|
105
|
+
this.cookieName = `memos_token_${opts.port}`;
|
|
106
|
+
this.defaultHubPort = opts.defaultHubPort ?? 18800;
|
|
102
107
|
this.resetToken = crypto.randomBytes(16).toString("hex");
|
|
103
108
|
this.loadAuth();
|
|
104
109
|
}
|
|
105
110
|
|
|
111
|
+
private getHubPort(): number {
|
|
112
|
+
const configured = this.ctx?.config?.sharing?.hub?.port;
|
|
113
|
+
if (configured && configured !== 18800) return configured;
|
|
114
|
+
return this.defaultHubPort;
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
start(): Promise<string> {
|
|
118
|
+
const MAX_PORT_RETRIES = 5;
|
|
107
119
|
return new Promise((resolve, reject) => {
|
|
120
|
+
let retries = 0;
|
|
108
121
|
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
109
122
|
this.server.on("error", (err: NodeJS.ErrnoException) => {
|
|
110
|
-
if (err.code === "EADDRINUSE") {
|
|
111
|
-
|
|
112
|
-
this.
|
|
123
|
+
if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
|
|
124
|
+
retries++;
|
|
125
|
+
const nextPort = this.port + retries;
|
|
126
|
+
this.log.warn(`Viewer port ${this.port + retries - 1} in use, trying ${nextPort}`);
|
|
127
|
+
this.server!.listen(nextPort, "0.0.0.0");
|
|
128
|
+
} else if (err.code === "EADDRINUSE") {
|
|
129
|
+
reject(new Error(`Viewer failed to find open port after ${MAX_PORT_RETRIES} retries (tried ${this.port}–${this.port + MAX_PORT_RETRIES})`));
|
|
113
130
|
} else {
|
|
114
131
|
reject(err);
|
|
115
132
|
}
|
|
@@ -187,7 +204,8 @@ export class ViewerServer {
|
|
|
187
204
|
|
|
188
205
|
private isValidSession(req: http.IncomingMessage): boolean {
|
|
189
206
|
const cookie = req.headers.cookie ?? "";
|
|
190
|
-
const
|
|
207
|
+
const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
|
|
208
|
+
const match = cookie.match(re);
|
|
191
209
|
if (!match) return false;
|
|
192
210
|
const expiry = this.auth.sessions.get(match[1]);
|
|
193
211
|
if (!expiry) return false;
|
|
@@ -270,6 +288,7 @@ export class ViewerServer {
|
|
|
270
288
|
else if (p === "/api/sharing/remove-user" && req.method === "POST") this.handleSharingRemoveUser(req, res);
|
|
271
289
|
else if (p === "/api/sharing/change-role" && req.method === "POST") this.handleSharingChangeRole(req, res);
|
|
272
290
|
else if (p === "/api/sharing/retry-join" && req.method === "POST") this.handleRetryJoin(req, res);
|
|
291
|
+
else if (p === "/api/sharing/leave" && req.method === "POST") this.handleLeaveTeam(req, res);
|
|
273
292
|
else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
|
|
274
293
|
else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
|
|
275
294
|
else if (p === "/api/sharing/tasks/list" && req.method === "GET") this.serveSharingTaskList(res, url);
|
|
@@ -290,6 +309,7 @@ export class ViewerServer {
|
|
|
290
309
|
else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
|
|
291
310
|
else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
|
|
292
311
|
else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
|
|
312
|
+
else if (p === "/api/sharing/sync-hub-removal" && req.method === "POST") this.handleSyncHubRemoval(req, res);
|
|
293
313
|
else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
|
|
294
314
|
else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
|
|
295
315
|
else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
|
|
@@ -350,7 +370,7 @@ export class ViewerServer {
|
|
|
350
370
|
const token = this.createSession();
|
|
351
371
|
res.writeHead(200, {
|
|
352
372
|
"Content-Type": "application/json",
|
|
353
|
-
"Set-Cookie":
|
|
373
|
+
"Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
354
374
|
});
|
|
355
375
|
res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
|
|
356
376
|
} catch (err) {
|
|
@@ -372,7 +392,7 @@ export class ViewerServer {
|
|
|
372
392
|
const token = this.createSession();
|
|
373
393
|
res.writeHead(200, {
|
|
374
394
|
"Content-Type": "application/json",
|
|
375
|
-
"Set-Cookie":
|
|
395
|
+
"Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
376
396
|
});
|
|
377
397
|
res.end(JSON.stringify({ ok: true }));
|
|
378
398
|
} catch (err) {
|
|
@@ -384,11 +404,12 @@ export class ViewerServer {
|
|
|
384
404
|
|
|
385
405
|
private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
386
406
|
const cookie = req.headers.cookie ?? "";
|
|
387
|
-
const
|
|
407
|
+
const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
|
|
408
|
+
const match = cookie.match(re);
|
|
388
409
|
if (match) this.auth.sessions.delete(match[1]);
|
|
389
410
|
res.writeHead(200, {
|
|
390
411
|
"Content-Type": "application/json",
|
|
391
|
-
"Set-Cookie":
|
|
412
|
+
"Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
|
|
392
413
|
});
|
|
393
414
|
res.end(JSON.stringify({ ok: true }));
|
|
394
415
|
}
|
|
@@ -415,7 +436,7 @@ export class ViewerServer {
|
|
|
415
436
|
const sessionToken = this.createSession();
|
|
416
437
|
res.writeHead(200, {
|
|
417
438
|
"Content-Type": "application/json",
|
|
418
|
-
"Set-Cookie":
|
|
439
|
+
"Set-Cookie": `${this.cookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
419
440
|
});
|
|
420
441
|
res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
|
|
421
442
|
} catch (err) {
|
|
@@ -451,9 +472,8 @@ export class ViewerServer {
|
|
|
451
472
|
if (session) { conditions.push("session_key = ?"); params.push(session); }
|
|
452
473
|
if (role) { conditions.push("role = ?"); params.push(role); }
|
|
453
474
|
if (owner && owner.startsWith("agent:")) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
params.push(owner, agentPrefix + "%");
|
|
475
|
+
conditions.push("(owner = ? OR owner = 'public')");
|
|
476
|
+
params.push(owner);
|
|
457
477
|
} else if (owner) {
|
|
458
478
|
conditions.push("owner = ?"); params.push(owner);
|
|
459
479
|
}
|
|
@@ -462,7 +482,7 @@ export class ViewerServer {
|
|
|
462
482
|
|
|
463
483
|
const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
|
|
464
484
|
const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
|
|
465
|
-
const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
485
|
+
const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY CASE WHEN dedup_status IN ('duplicate','merged') THEN 1 ELSE 0 END ASC, created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
466
486
|
const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
|
|
467
487
|
|
|
468
488
|
const chunkIds = rawMemories.map((m: any) => m.id);
|
|
@@ -473,6 +493,12 @@ export class ViewerServer {
|
|
|
473
493
|
const placeholders = chunkIds.map(() => "?").join(",");
|
|
474
494
|
const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ source_chunk_id: string; visibility: string; group_id: string | null }>;
|
|
475
495
|
for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
|
|
496
|
+
const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; visibility: string; group_id: string | null }>;
|
|
497
|
+
for (const r of teamMetaRows) {
|
|
498
|
+
if (!sharingMap.has(r.chunk_id)) {
|
|
499
|
+
sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
476
502
|
const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; original_owner: string; shared_at: number }>;
|
|
477
503
|
for (const r of localRows) localShareMap.set(r.chunk_id, r);
|
|
478
504
|
} catch {
|
|
@@ -640,9 +666,8 @@ export class ViewerServer {
|
|
|
640
666
|
let sessionQuery: string;
|
|
641
667
|
let sessionParams: any[];
|
|
642
668
|
if (ownerFilter && ownerFilter.startsWith("agent:")) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
sessionParams = [ownerFilter, agentPrefix + "%"];
|
|
669
|
+
sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR owner = 'public') GROUP BY session_key ORDER BY latest DESC";
|
|
670
|
+
sessionParams = [ownerFilter];
|
|
646
671
|
} else if (ownerFilter) {
|
|
647
672
|
sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE owner = ? GROUP BY session_key ORDER BY latest DESC";
|
|
648
673
|
sessionParams = [ownerFilter];
|
|
@@ -1049,11 +1074,21 @@ export class ViewerServer {
|
|
|
1049
1074
|
});
|
|
1050
1075
|
}
|
|
1051
1076
|
|
|
1052
|
-
private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
|
|
1077
|
+
private async handleSkillDelete(res: http.ServerResponse, urlPath: string): Promise<void> {
|
|
1053
1078
|
const skillId = urlPath.replace("/api/skill/", "");
|
|
1054
1079
|
const skill = this.store.getSkill(skillId);
|
|
1055
1080
|
if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
|
|
1056
|
-
|
|
1081
|
+
try {
|
|
1082
|
+
const hub = this.resolveHubConnection();
|
|
1083
|
+
if (hub) {
|
|
1084
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
body: JSON.stringify({ sourceSkillId: skillId }),
|
|
1087
|
+
}).catch(() => {});
|
|
1088
|
+
}
|
|
1089
|
+
const db = (this.store as any).db;
|
|
1090
|
+
db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
|
|
1091
|
+
} catch (_) {}
|
|
1057
1092
|
try {
|
|
1058
1093
|
if (skill.dirPath && fs.existsSync(skill.dirPath)) {
|
|
1059
1094
|
fs.rmSync(skill.dirPath, { recursive: true, force: true });
|
|
@@ -1216,7 +1251,6 @@ export class ViewerServer {
|
|
|
1216
1251
|
let hubSynced = false;
|
|
1217
1252
|
|
|
1218
1253
|
if (scope === "team") {
|
|
1219
|
-
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1220
1254
|
if (!isTeamShared) {
|
|
1221
1255
|
const hubClient = await this.resolveHubClientAware();
|
|
1222
1256
|
const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
|
|
@@ -1224,17 +1258,24 @@ export class ViewerServer {
|
|
|
1224
1258
|
method: "POST",
|
|
1225
1259
|
body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
|
|
1226
1260
|
});
|
|
1227
|
-
if (
|
|
1261
|
+
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1262
|
+
const memoryId = String((response as any)?.memoryId ?? "");
|
|
1263
|
+
const isHubRole = this.ctx?.config?.sharing?.role === "hub";
|
|
1264
|
+
if (hubClient.userId && isHubRole) {
|
|
1228
1265
|
const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
|
|
1229
1266
|
this.store.upsertHubMemory({
|
|
1230
|
-
id:
|
|
1267
|
+
id: memoryId || existing?.id || crypto.randomUUID(),
|
|
1231
1268
|
sourceChunkId: chunkId, sourceUserId: hubClient.userId,
|
|
1232
1269
|
role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
|
|
1233
1270
|
kind: refreshedChunk.kind, groupId: null, visibility: "public",
|
|
1234
1271
|
createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
|
|
1235
1272
|
});
|
|
1273
|
+
} else if (hubClient.userId) {
|
|
1274
|
+
this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
|
|
1236
1275
|
}
|
|
1237
1276
|
hubSynced = true;
|
|
1277
|
+
} else {
|
|
1278
|
+
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1238
1279
|
}
|
|
1239
1280
|
} else if (scope === "local") {
|
|
1240
1281
|
if (isTeamShared) {
|
|
@@ -1244,6 +1285,7 @@ export class ViewerServer {
|
|
|
1244
1285
|
method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
|
|
1245
1286
|
});
|
|
1246
1287
|
if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
|
|
1288
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
1247
1289
|
hubSynced = true;
|
|
1248
1290
|
} catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
|
|
1249
1291
|
}
|
|
@@ -1256,6 +1298,7 @@ export class ViewerServer {
|
|
|
1256
1298
|
method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
|
|
1257
1299
|
});
|
|
1258
1300
|
if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
|
|
1301
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
1259
1302
|
hubSynced = true;
|
|
1260
1303
|
} catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
|
|
1261
1304
|
}
|
|
@@ -1296,15 +1339,6 @@ export class ViewerServer {
|
|
|
1296
1339
|
|
|
1297
1340
|
let hubSynced = false;
|
|
1298
1341
|
|
|
1299
|
-
if (scope === "local" || scope === "team") {
|
|
1300
|
-
if (!isLocalShared) {
|
|
1301
|
-
const originalOwner = task.owner;
|
|
1302
|
-
const db = (this.store as any).db;
|
|
1303
|
-
db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
|
|
1304
|
-
db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
1342
|
if (scope === "team") {
|
|
1309
1343
|
if (!isTeamShared) {
|
|
1310
1344
|
const chunks = this.store.getChunksByTask(taskId);
|
|
@@ -1328,6 +1362,21 @@ export class ViewerServer {
|
|
|
1328
1362
|
}
|
|
1329
1363
|
hubSynced = true;
|
|
1330
1364
|
}
|
|
1365
|
+
if (!isLocalShared) {
|
|
1366
|
+
const originalOwner = task.owner;
|
|
1367
|
+
const db = (this.store as any).db;
|
|
1368
|
+
db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
|
|
1369
|
+
db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (scope === "local") {
|
|
1374
|
+
if (!isLocalShared) {
|
|
1375
|
+
const originalOwner = task.owner;
|
|
1376
|
+
const db = (this.store as any).db;
|
|
1377
|
+
db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
|
|
1378
|
+
db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
|
|
1379
|
+
}
|
|
1331
1380
|
}
|
|
1332
1381
|
|
|
1333
1382
|
if (scope === "local" && isTeamShared) {
|
|
@@ -1397,10 +1446,6 @@ export class ViewerServer {
|
|
|
1397
1446
|
|
|
1398
1447
|
let hubSynced = false;
|
|
1399
1448
|
|
|
1400
|
-
if (scope === "local" || scope === "team") {
|
|
1401
|
-
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
1449
|
if (scope === "team") {
|
|
1405
1450
|
if (!isTeamShared) {
|
|
1406
1451
|
const bundle = buildSkillBundleForHub(this.store, skillId);
|
|
@@ -1422,6 +1467,11 @@ export class ViewerServer {
|
|
|
1422
1467
|
}
|
|
1423
1468
|
hubSynced = true;
|
|
1424
1469
|
}
|
|
1470
|
+
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (scope === "local") {
|
|
1474
|
+
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1425
1475
|
}
|
|
1426
1476
|
|
|
1427
1477
|
if (scope === "local" && isTeamShared) {
|
|
@@ -1458,7 +1508,17 @@ export class ViewerServer {
|
|
|
1458
1508
|
|
|
1459
1509
|
private getHubMemoryForChunk(chunkId: string): any {
|
|
1460
1510
|
const db = (this.store as any).db;
|
|
1461
|
-
|
|
1511
|
+
const hub = db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
|
|
1512
|
+
if (hub) return hub;
|
|
1513
|
+
const ts = this.store.getTeamSharedChunk(chunkId);
|
|
1514
|
+
if (ts) {
|
|
1515
|
+
return {
|
|
1516
|
+
source_chunk_id: chunkId,
|
|
1517
|
+
visibility: ts.visibility,
|
|
1518
|
+
group_id: ts.groupId,
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
return undefined;
|
|
1462
1522
|
}
|
|
1463
1523
|
|
|
1464
1524
|
private getHubTaskForLocal(taskId: string): any {
|
|
@@ -1505,6 +1565,7 @@ export class ViewerServer {
|
|
|
1505
1565
|
// ─── Config API ───
|
|
1506
1566
|
|
|
1507
1567
|
private getOpenClawConfigPath(): string {
|
|
1568
|
+
if (process.env.OPENCLAW_CONFIG_PATH) return process.env.OPENCLAW_CONFIG_PATH;
|
|
1508
1569
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
1509
1570
|
const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
|
|
1510
1571
|
return path.join(ocHome, "openclaw.json");
|
|
@@ -1594,7 +1655,20 @@ export class ViewerServer {
|
|
|
1594
1655
|
base.connection.teamName = info?.teamName ?? sharing.hub?.teamName ?? null;
|
|
1595
1656
|
base.connection.apiVersion = info?.apiVersion ?? null;
|
|
1596
1657
|
} catch { /* ignore */ }
|
|
1597
|
-
|
|
1658
|
+
|
|
1659
|
+
const hubStats: any = { totalMembers: 0, onlineMembers: 0, pendingMembers: 0 };
|
|
1660
|
+
try {
|
|
1661
|
+
const activeUsers = this.store.listHubUsers("active");
|
|
1662
|
+
const pendingUsers = this.store.listHubUsers("pending");
|
|
1663
|
+
const now = Date.now();
|
|
1664
|
+
const OFFLINE_THRESHOLD = 120_000;
|
|
1665
|
+
hubStats.totalMembers = activeUsers.length;
|
|
1666
|
+
hubStats.onlineMembers = activeUsers.filter(u =>
|
|
1667
|
+
u.lastActiveAt && (now - u.lastActiveAt < OFFLINE_THRESHOLD),
|
|
1668
|
+
).length;
|
|
1669
|
+
hubStats.pendingMembers = pendingUsers.length;
|
|
1670
|
+
} catch { /* best-effort */ }
|
|
1671
|
+
this.jsonResponse(res, { ...base, hubStats });
|
|
1598
1672
|
return;
|
|
1599
1673
|
}
|
|
1600
1674
|
|
|
@@ -1613,6 +1687,9 @@ export class ViewerServer {
|
|
|
1613
1687
|
if (status.user?.status === "rejected") {
|
|
1614
1688
|
output.connection.rejected = true;
|
|
1615
1689
|
}
|
|
1690
|
+
if (status.user?.status === "removed") {
|
|
1691
|
+
output.connection.removed = true;
|
|
1692
|
+
}
|
|
1616
1693
|
if (status.connected && status.hubUrl) {
|
|
1617
1694
|
try {
|
|
1618
1695
|
const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
|
|
@@ -1730,7 +1807,14 @@ export class ViewerServer {
|
|
|
1730
1807
|
});
|
|
1731
1808
|
this.jsonResponse(res, { ok: true, result });
|
|
1732
1809
|
} catch (err) {
|
|
1733
|
-
|
|
1810
|
+
const errStr = String(err);
|
|
1811
|
+
if (errStr.includes("username_taken")) {
|
|
1812
|
+
this.jsonResponse(res, { ok: false, error: "username_taken" });
|
|
1813
|
+
} else if (errStr.includes("invalid_params")) {
|
|
1814
|
+
this.jsonResponse(res, { ok: false, error: "invalid_params" });
|
|
1815
|
+
} else {
|
|
1816
|
+
this.jsonResponse(res, { ok: false, error: errStr });
|
|
1817
|
+
}
|
|
1734
1818
|
}
|
|
1735
1819
|
});
|
|
1736
1820
|
}
|
|
@@ -1749,22 +1833,17 @@ export class ViewerServer {
|
|
|
1749
1833
|
}
|
|
1750
1834
|
try {
|
|
1751
1835
|
const hubUrl = normalizeHubUrl(hubAddress);
|
|
1752
|
-
const localIPs = this.getLocalIPs();
|
|
1753
|
-
localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
|
|
1754
|
-
try {
|
|
1755
|
-
const u = new URL(hubUrl);
|
|
1756
|
-
if (localIPs.includes(u.hostname)) {
|
|
1757
|
-
return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
|
|
1758
|
-
}
|
|
1759
|
-
} catch {}
|
|
1760
1836
|
const os = await import("os");
|
|
1761
1837
|
const nickname = sharing.client?.nickname;
|
|
1762
1838
|
const username = nickname || os.userInfo().username || "user";
|
|
1763
1839
|
const hostname = os.hostname() || "unknown";
|
|
1840
|
+
const persisted = this.store.getClientHubConnection();
|
|
1841
|
+
const existingIdentityKey = persisted?.identityKey || "";
|
|
1764
1842
|
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
1765
1843
|
method: "POST",
|
|
1766
|
-
body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
|
|
1844
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
|
|
1767
1845
|
}) as any;
|
|
1846
|
+
const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
|
|
1768
1847
|
this.store.setClientHubConnection({
|
|
1769
1848
|
hubUrl,
|
|
1770
1849
|
userId: String(result.userId || ""),
|
|
@@ -1772,6 +1851,8 @@ export class ViewerServer {
|
|
|
1772
1851
|
userToken: result.userToken || "",
|
|
1773
1852
|
role: "member",
|
|
1774
1853
|
connectedAt: Date.now(),
|
|
1854
|
+
identityKey: returnedIdentityKey,
|
|
1855
|
+
lastKnownStatus: result.status || "",
|
|
1775
1856
|
});
|
|
1776
1857
|
this.jsonResponse(res, { ok: true, status: result.status || "pending" });
|
|
1777
1858
|
} catch (err) {
|
|
@@ -2047,14 +2128,14 @@ export class ViewerServer {
|
|
|
2047
2128
|
},
|
|
2048
2129
|
}),
|
|
2049
2130
|
});
|
|
2050
|
-
const
|
|
2051
|
-
if (
|
|
2131
|
+
const mid = String((response as any)?.memoryId ?? "");
|
|
2132
|
+
if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
|
|
2052
2133
|
const now = Date.now();
|
|
2053
|
-
const existing = this.store.getHubMemoryBySource(
|
|
2134
|
+
const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
|
|
2054
2135
|
this.store.upsertHubMemory({
|
|
2055
|
-
id:
|
|
2136
|
+
id: mid || existing?.id || crypto.randomUUID(),
|
|
2056
2137
|
sourceChunkId: chunk.id,
|
|
2057
|
-
sourceUserId:
|
|
2138
|
+
sourceUserId: hubClient.userId,
|
|
2058
2139
|
role: chunk.role,
|
|
2059
2140
|
content: chunk.content,
|
|
2060
2141
|
summary: chunk.summary ?? "",
|
|
@@ -2064,6 +2145,8 @@ export class ViewerServer {
|
|
|
2064
2145
|
createdAt: existing?.createdAt ?? now,
|
|
2065
2146
|
updatedAt: now,
|
|
2066
2147
|
});
|
|
2148
|
+
} else if (hubClient.userId) {
|
|
2149
|
+
this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
|
|
2067
2150
|
}
|
|
2068
2151
|
this.jsonResponse(res, { ok: true, chunkId, visibility, response });
|
|
2069
2152
|
} catch (err) {
|
|
@@ -2085,6 +2168,7 @@ export class ViewerServer {
|
|
|
2085
2168
|
});
|
|
2086
2169
|
const hubUserId = hubClient.userId;
|
|
2087
2170
|
if (hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
|
|
2171
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
2088
2172
|
this.jsonResponse(res, { ok: true, chunkId });
|
|
2089
2173
|
} catch (err) {
|
|
2090
2174
|
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
@@ -2181,7 +2265,7 @@ export class ViewerServer {
|
|
|
2181
2265
|
// Hub 模式:连接自己,用 bootstrap admin token
|
|
2182
2266
|
const sharing = this.ctx.config.sharing;
|
|
2183
2267
|
if (sharing?.role === "hub") {
|
|
2184
|
-
const hubPort =
|
|
2268
|
+
const hubPort = this.getHubPort();
|
|
2185
2269
|
const hubUrl = `http://127.0.0.1:${hubPort}`;
|
|
2186
2270
|
try {
|
|
2187
2271
|
const authPath = path.join(this.dataDir, "hub-auth.json");
|
|
@@ -2398,6 +2482,31 @@ export class ViewerServer {
|
|
|
2398
2482
|
});
|
|
2399
2483
|
}
|
|
2400
2484
|
|
|
2485
|
+
/** Badge-only: clear Client team-share UI metadata when Hub admin removes that memory. Does NOT touch chunks, embeddings, or hub_memories (recall paths). */
|
|
2486
|
+
private handleSyncHubRemoval(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2487
|
+
this.readBody(req, (body) => {
|
|
2488
|
+
try {
|
|
2489
|
+
const parsed = JSON.parse(body || "{}");
|
|
2490
|
+
const sourceChunkId = String(parsed.sourceChunkId || "");
|
|
2491
|
+
const memoryIdFromNotif = parsed.memoryId != null && parsed.memoryId !== "" ? String(parsed.memoryId) : "";
|
|
2492
|
+
if (!sourceChunkId) return this.jsonResponse(res, { ok: false, error: "missing_source_chunk_id" }, 400);
|
|
2493
|
+
// Admin removal notifications stay in the feed; if the user re-shared, team_shared_chunks has a new hub_memory_id.
|
|
2494
|
+
// Only clear the badge when this notification refers to the same Hub row we still track (or no id — legacy).
|
|
2495
|
+
if (memoryIdFromNotif) {
|
|
2496
|
+
const current = this.store.getTeamSharedChunk(sourceChunkId);
|
|
2497
|
+
const curId = current?.hubMemoryId ? String(current.hubMemoryId) : "";
|
|
2498
|
+
if (curId && curId !== memoryIdFromNotif) {
|
|
2499
|
+
return this.jsonResponse(res, { ok: true, sourceChunkId, skipped: true, reason: "stale_notification_re_shared" });
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
this.store.deleteTeamSharedChunk(sourceChunkId);
|
|
2503
|
+
this.jsonResponse(res, { ok: true, sourceChunkId });
|
|
2504
|
+
} catch (e) {
|
|
2505
|
+
this.jsonResponse(res, { ok: false, error: String(e) }, 500);
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2401
2510
|
private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2402
2511
|
res.writeHead(200, {
|
|
2403
2512
|
"Content-Type": "text/event-stream",
|
|
@@ -2408,6 +2517,7 @@ export class ViewerServer {
|
|
|
2408
2517
|
res.write("data: {\"type\":\"connected\"}\n\n");
|
|
2409
2518
|
this.notifSSEClients.push(res);
|
|
2410
2519
|
if (!this.notifPollTimer) this.startNotifPoll();
|
|
2520
|
+
else this.notifPollImmediate();
|
|
2411
2521
|
req.on("close", () => {
|
|
2412
2522
|
this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
|
|
2413
2523
|
if (this.notifSSEClients.length === 0) this.stopNotifPoll();
|
|
@@ -2443,6 +2553,20 @@ export class ViewerServer {
|
|
|
2443
2553
|
if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
|
|
2444
2554
|
}
|
|
2445
2555
|
|
|
2556
|
+
private notifPollImmediate(): void {
|
|
2557
|
+
const hub = this.resolveHubConnection();
|
|
2558
|
+
if (!hub) return;
|
|
2559
|
+
hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")
|
|
2560
|
+
.then((data: any) => {
|
|
2561
|
+
const count = data?.unreadCount ?? 0;
|
|
2562
|
+
if (count !== this.lastKnownNotifCount) {
|
|
2563
|
+
this.lastKnownNotifCount = count;
|
|
2564
|
+
this.broadcastNotifSSE({ type: "update", unreadCount: count });
|
|
2565
|
+
}
|
|
2566
|
+
})
|
|
2567
|
+
.catch(() => {});
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2446
2570
|
private startHubHeartbeat(): void {
|
|
2447
2571
|
this.stopHubHeartbeat();
|
|
2448
2572
|
const sendHeartbeat = async () => {
|
|
@@ -2560,12 +2684,14 @@ export class ViewerServer {
|
|
|
2560
2684
|
if (merged.role === "client" && merged.client) {
|
|
2561
2685
|
const clientCfg = merged.client as Record<string, unknown>;
|
|
2562
2686
|
const addr = String(clientCfg.hubAddress || "");
|
|
2563
|
-
if (addr) {
|
|
2687
|
+
if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
|
|
2688
|
+
const selfHubPort = (oldSharing?.hub as Record<string, unknown>)?.port ?? 18800;
|
|
2564
2689
|
const localIPs = this.getLocalIPs();
|
|
2565
2690
|
localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
|
|
2566
2691
|
try {
|
|
2567
2692
|
const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
|
|
2568
|
-
|
|
2693
|
+
const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
|
|
2694
|
+
if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
|
|
2569
2695
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2570
2696
|
res.end(JSON.stringify({ error: "cannot_join_self" }));
|
|
2571
2697
|
return;
|
|
@@ -2590,25 +2716,27 @@ export class ViewerServer {
|
|
|
2590
2716
|
const wasClient = oldSharingEnabled && oldSharingRole === "client";
|
|
2591
2717
|
const isClient = newEnabled && newRole === "client";
|
|
2592
2718
|
if (wasClient && !isClient) {
|
|
2593
|
-
this.
|
|
2719
|
+
await this.withdrawOrLeaveHub();
|
|
2594
2720
|
this.store.clearClientHubConnection();
|
|
2595
|
-
this.log.info("
|
|
2721
|
+
this.log.info("Client hub connection cleared (sharing disabled or role changed)");
|
|
2596
2722
|
}
|
|
2597
2723
|
|
|
2598
|
-
// Detect switching to a different Hub while still in client mode
|
|
2599
2724
|
if (wasClient && isClient) {
|
|
2600
2725
|
const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
|
|
2601
2726
|
if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
|
|
2602
2727
|
this.notifyHubLeave();
|
|
2603
|
-
this.store.
|
|
2604
|
-
|
|
2728
|
+
const oldConn = this.store.getClientHubConnection();
|
|
2729
|
+
if (oldConn) {
|
|
2730
|
+
this.store.setClientHubConnection({ ...oldConn, hubUrl: normalizeHubUrl(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
|
|
2731
|
+
}
|
|
2732
|
+
this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
|
|
2605
2733
|
}
|
|
2606
2734
|
}
|
|
2607
2735
|
|
|
2608
2736
|
if (merged.role === "hub") {
|
|
2609
2737
|
merged.client = { hubAddress: "", userToken: "", teamToken: "" };
|
|
2610
2738
|
} else if (merged.role === "client") {
|
|
2611
|
-
merged.hub = {
|
|
2739
|
+
merged.hub = { teamName: "", teamToken: "" };
|
|
2612
2740
|
}
|
|
2613
2741
|
config.sharing = merged;
|
|
2614
2742
|
}
|
|
@@ -2618,13 +2746,25 @@ export class ViewerServer {
|
|
|
2618
2746
|
this.log.info("Plugin config updated via Viewer");
|
|
2619
2747
|
this.stopHubHeartbeat();
|
|
2620
2748
|
|
|
2621
|
-
// When switching to client mode,
|
|
2749
|
+
// When switching to client mode or re-enabling sharing as client, send join request
|
|
2622
2750
|
const finalSharing = config.sharing as Record<string, unknown> | undefined;
|
|
2623
|
-
|
|
2624
|
-
|
|
2751
|
+
const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
|
|
2752
|
+
const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
|
|
2753
|
+
let joinStatus: string | undefined;
|
|
2754
|
+
if (nowClient && !previouslyClient) {
|
|
2755
|
+
try {
|
|
2756
|
+
joinStatus = await this.autoJoinOnSave(finalSharing);
|
|
2757
|
+
} catch (e) {
|
|
2758
|
+
this.log.warn(`Auto-join on save failed: ${e}`);
|
|
2759
|
+
}
|
|
2625
2760
|
}
|
|
2626
2761
|
|
|
2627
|
-
this.jsonResponse(res, { ok: true });
|
|
2762
|
+
this.jsonResponse(res, { ok: true, joinStatus, restart: true });
|
|
2763
|
+
|
|
2764
|
+
setTimeout(() => {
|
|
2765
|
+
this.log.info("config-save: triggering gateway restart via SIGUSR1...");
|
|
2766
|
+
try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
|
|
2767
|
+
}, 500);
|
|
2628
2768
|
} catch (e) {
|
|
2629
2769
|
this.log.warn(`handleSaveConfig error: ${e}`);
|
|
2630
2770
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -2633,20 +2773,23 @@ export class ViewerServer {
|
|
|
2633
2773
|
});
|
|
2634
2774
|
}
|
|
2635
2775
|
|
|
2636
|
-
private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<
|
|
2776
|
+
private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<string | undefined> {
|
|
2637
2777
|
const clientCfg = sharing.client as Record<string, unknown> | undefined;
|
|
2638
2778
|
const hubAddress = String(clientCfg?.hubAddress || "");
|
|
2639
2779
|
const teamToken = String(clientCfg?.teamToken || "");
|
|
2640
|
-
if (!hubAddress || !teamToken) return;
|
|
2780
|
+
if (!hubAddress || !teamToken) return undefined;
|
|
2641
2781
|
const hubUrl = normalizeHubUrl(hubAddress);
|
|
2642
2782
|
const os = await import("os");
|
|
2643
2783
|
const nickname = String(clientCfg?.nickname || "");
|
|
2644
2784
|
const username = nickname || os.userInfo().username || "user";
|
|
2645
2785
|
const hostname = os.hostname() || "unknown";
|
|
2786
|
+
const persisted = this.store.getClientHubConnection();
|
|
2787
|
+
const existingIdentityKey = persisted?.identityKey || "";
|
|
2646
2788
|
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
2647
2789
|
method: "POST",
|
|
2648
|
-
body: JSON.stringify({ teamToken, username, deviceName: hostname }),
|
|
2790
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
|
|
2649
2791
|
}) as any;
|
|
2792
|
+
const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
|
|
2650
2793
|
this.store.setClientHubConnection({
|
|
2651
2794
|
hubUrl,
|
|
2652
2795
|
userId: String(result.userId || ""),
|
|
@@ -2654,11 +2797,49 @@ export class ViewerServer {
|
|
|
2654
2797
|
userToken: result.userToken || "",
|
|
2655
2798
|
role: "member",
|
|
2656
2799
|
connectedAt: Date.now(),
|
|
2800
|
+
identityKey: returnedIdentityKey,
|
|
2801
|
+
lastKnownStatus: result.status || "",
|
|
2657
2802
|
});
|
|
2658
2803
|
this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
|
|
2659
2804
|
if (result.userToken) {
|
|
2660
2805
|
this.startHubHeartbeat();
|
|
2661
2806
|
}
|
|
2807
|
+
return result.status;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
private handleLeaveTeam(_req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2811
|
+
this.readBody(_req, async () => {
|
|
2812
|
+
try {
|
|
2813
|
+
await this.withdrawOrLeaveHub();
|
|
2814
|
+
this.store.clearClientHubConnection();
|
|
2815
|
+
|
|
2816
|
+
const configPath = this.getOpenClawConfigPath();
|
|
2817
|
+
if (configPath && fs.existsSync(configPath)) {
|
|
2818
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
2819
|
+
const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
|
|
2820
|
+
if (pluginKey) {
|
|
2821
|
+
const cfg = raw.plugins.entries[pluginKey].config ?? {};
|
|
2822
|
+
if (cfg.sharing) {
|
|
2823
|
+
cfg.sharing.enabled = false;
|
|
2824
|
+
cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
|
|
2825
|
+
}
|
|
2826
|
+
raw.plugins.entries[pluginKey].config = cfg;
|
|
2827
|
+
fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
|
|
2828
|
+
this.log.info("handleLeaveTeam: config updated, sharing disabled");
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
this.jsonResponse(res, { ok: true, restart: true });
|
|
2833
|
+
|
|
2834
|
+
setTimeout(() => {
|
|
2835
|
+
this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
|
|
2836
|
+
try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
|
|
2837
|
+
}, 500);
|
|
2838
|
+
} catch (e) {
|
|
2839
|
+
this.log.warn(`handleLeaveTeam error: ${e}`);
|
|
2840
|
+
this.jsonResponse(res, { ok: false, error: String(e) });
|
|
2841
|
+
}
|
|
2842
|
+
});
|
|
2662
2843
|
}
|
|
2663
2844
|
|
|
2664
2845
|
private async notifyHubLeave(): Promise<void> {
|
|
@@ -2679,11 +2860,49 @@ export class ViewerServer {
|
|
|
2679
2860
|
}
|
|
2680
2861
|
}
|
|
2681
2862
|
|
|
2863
|
+
private async withdrawOrLeaveHub(): Promise<void> {
|
|
2864
|
+
try {
|
|
2865
|
+
const persisted = this.store.getClientHubConnection();
|
|
2866
|
+
const sharing = this.ctx?.config?.sharing;
|
|
2867
|
+
|
|
2868
|
+
if (persisted?.userToken && persisted?.hubUrl) {
|
|
2869
|
+
await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2870
|
+
this.log.info("Notified Hub of voluntary leave (had token)");
|
|
2871
|
+
return;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
const hub = this.resolveHubConnection();
|
|
2875
|
+
if (hub?.userToken) {
|
|
2876
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2877
|
+
this.log.info("Notified Hub of voluntary leave (resolved connection)");
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? normalizeHubUrl(sharing.client.hubAddress) : null);
|
|
2882
|
+
const userId = persisted?.userId;
|
|
2883
|
+
const teamToken = sharing?.client?.teamToken;
|
|
2884
|
+
if (hubUrl && userId && teamToken) {
|
|
2885
|
+
const withdrawUrl = `${normalizeHubUrl(hubUrl)}/api/v1/hub/withdraw-pending`;
|
|
2886
|
+
await fetch(withdrawUrl, {
|
|
2887
|
+
method: "POST",
|
|
2888
|
+
headers: { "content-type": "application/json" },
|
|
2889
|
+
body: JSON.stringify({ teamToken, userId }),
|
|
2890
|
+
});
|
|
2891
|
+
this.log.info("Withdrew pending application from Hub");
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
this.log.info("No hub connection to clean up (no token, no pending)");
|
|
2896
|
+
} catch (e) {
|
|
2897
|
+
this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2682
2901
|
private async notifyHubShutdown(): Promise<void> {
|
|
2683
2902
|
try {
|
|
2684
2903
|
const sharing = this.ctx?.config.sharing;
|
|
2685
2904
|
if (!sharing || sharing.role !== "hub") return;
|
|
2686
|
-
const hubPort =
|
|
2905
|
+
const hubPort = this.getHubPort();
|
|
2687
2906
|
const authPath = path.join(this.dataDir, "hub-auth.json");
|
|
2688
2907
|
let adminToken: string | undefined;
|
|
2689
2908
|
try {
|
|
@@ -2741,10 +2960,10 @@ export class ViewerServer {
|
|
|
2741
2960
|
this.log.warn(`Failed to update hub-auth.json: ${e}`);
|
|
2742
2961
|
}
|
|
2743
2962
|
} else {
|
|
2744
|
-
const
|
|
2745
|
-
if (
|
|
2963
|
+
const persistedConn = this.store.getClientHubConnection();
|
|
2964
|
+
if (persistedConn) {
|
|
2746
2965
|
this.store.setClientHubConnection({
|
|
2747
|
-
...
|
|
2966
|
+
...persistedConn,
|
|
2748
2967
|
username: result.username,
|
|
2749
2968
|
userToken: result.userToken,
|
|
2750
2969
|
});
|
|
@@ -2768,12 +2987,17 @@ export class ViewerServer {
|
|
|
2768
2987
|
const { hubUrl } = JSON.parse(body);
|
|
2769
2988
|
if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
|
|
2770
2989
|
try {
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2990
|
+
const sharing = this.ctx?.config?.sharing;
|
|
2991
|
+
if (sharing?.enabled && sharing.role === "hub") {
|
|
2992
|
+
const selfHubPort = this.getHubPort();
|
|
2993
|
+
const localIPs = this.getLocalIPs();
|
|
2994
|
+
localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
|
|
2995
|
+
const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
|
|
2996
|
+
const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
2997
|
+
if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
|
|
2998
|
+
this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
2777
3001
|
}
|
|
2778
3002
|
} catch {}
|
|
2779
3003
|
const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
|
|
@@ -3011,10 +3235,9 @@ export class ViewerServer {
|
|
|
3011
3235
|
this.log.info(`update-install: success! Updated to ${newVersion}`);
|
|
3012
3236
|
this.jsonResponse(res, { ok: true, version: newVersion });
|
|
3013
3237
|
|
|
3014
|
-
// Trigger Gateway restart after response is sent
|
|
3015
3238
|
setTimeout(() => {
|
|
3016
|
-
this.log.info(`update-install: triggering gateway restart...`);
|
|
3017
|
-
process.kill(process.pid, "SIGUSR1");
|
|
3239
|
+
this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
|
|
3240
|
+
try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
|
|
3018
3241
|
}, 500);
|
|
3019
3242
|
});
|
|
3020
3243
|
});
|
|
@@ -3196,7 +3419,7 @@ export class ViewerServer {
|
|
|
3196
3419
|
try {
|
|
3197
3420
|
const ocHome = this.getOpenClawHome();
|
|
3198
3421
|
const memoryDir = path.join(ocHome, "memory");
|
|
3199
|
-
const
|
|
3422
|
+
const agentsDir = path.join(ocHome, "agents");
|
|
3200
3423
|
|
|
3201
3424
|
const sqliteFiles: Array<{ file: string; chunks: number }> = [];
|
|
3202
3425
|
if (fs.existsSync(memoryDir)) {
|
|
@@ -3215,31 +3438,36 @@ export class ViewerServer {
|
|
|
3215
3438
|
|
|
3216
3439
|
let sessionCount = 0;
|
|
3217
3440
|
let messageCount = 0;
|
|
3218
|
-
if (fs.existsSync(
|
|
3219
|
-
const
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3441
|
+
if (fs.existsSync(agentsDir)) {
|
|
3442
|
+
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
|
3443
|
+
if (!entry.isDirectory()) continue;
|
|
3444
|
+
const sessDir = path.join(agentsDir, entry.name, "sessions");
|
|
3445
|
+
if (!fs.existsSync(sessDir)) continue;
|
|
3446
|
+
const jsonlFiles = fs.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
|
|
3447
|
+
sessionCount += jsonlFiles.length;
|
|
3448
|
+
for (const f of jsonlFiles) {
|
|
3449
|
+
try {
|
|
3450
|
+
const content = fs.readFileSync(path.join(sessDir, f), "utf-8");
|
|
3451
|
+
const lines = content.split("\n").filter(l => l.trim());
|
|
3452
|
+
for (const line of lines) {
|
|
3453
|
+
try {
|
|
3454
|
+
const obj = JSON.parse(line);
|
|
3455
|
+
if (obj.type === "message") {
|
|
3456
|
+
const role = obj.message?.role ?? obj.role;
|
|
3457
|
+
if (role === "user" || role === "assistant") {
|
|
3458
|
+
const mc = obj.message?.content ?? obj.content;
|
|
3459
|
+
let txt = "";
|
|
3460
|
+
if (typeof mc === "string") txt = mc;
|
|
3461
|
+
else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
|
|
3462
|
+
else txt = JSON.stringify(mc);
|
|
3463
|
+
if (role === "user") txt = stripInboundMetadata(txt);
|
|
3464
|
+
if (txt && txt.length >= 10) messageCount++;
|
|
3465
|
+
}
|
|
3238
3466
|
}
|
|
3239
|
-
}
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3467
|
+
} catch { /* skip bad lines */ }
|
|
3468
|
+
}
|
|
3469
|
+
} catch { /* skip unreadable */ }
|
|
3470
|
+
}
|
|
3243
3471
|
}
|
|
3244
3472
|
}
|
|
3245
3473
|
|