@memtensor/memos-local-openclaw-plugin 1.0.4-beta.8 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +132 -10
- 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 +251 -38
- 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 +96 -1
- 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 +58 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +295 -35
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +27 -8
- 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 +796 -289
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +11 -0
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +456 -92
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +411 -52
- 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 +136 -10
- package/src/config.ts +2 -1
- package/src/hub/server.ts +246 -38
- 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 +89 -1
- 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 +326 -40
- package/src/telemetry.ts +27 -9
- package/src/types.ts +11 -0
- package/src/viewer/html.ts +796 -289
- package/src/viewer/server.ts +430 -89
- 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];
|
|
@@ -667,6 +692,12 @@ export class ViewerServer {
|
|
|
667
692
|
owners = ownerRows.map((o: any) => o.owner);
|
|
668
693
|
} catch { /* column may not exist yet */ }
|
|
669
694
|
|
|
695
|
+
let currentAgentOwner = "agent:main";
|
|
696
|
+
try {
|
|
697
|
+
const latest = db.prepare("SELECT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY created_at DESC LIMIT 1").get() as any;
|
|
698
|
+
if (latest?.owner) currentAgentOwner = latest.owner;
|
|
699
|
+
} catch { /* best-effort */ }
|
|
700
|
+
|
|
670
701
|
this.jsonResponse(res, {
|
|
671
702
|
totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
|
|
672
703
|
totalSkills: skillCount,
|
|
@@ -675,6 +706,7 @@ export class ViewerServer {
|
|
|
675
706
|
timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
|
|
676
707
|
sessions: sessionList,
|
|
677
708
|
owners,
|
|
709
|
+
currentAgentOwner,
|
|
678
710
|
});
|
|
679
711
|
} catch (e) {
|
|
680
712
|
this.log.warn(`stats error: ${e}`);
|
|
@@ -1042,11 +1074,21 @@ export class ViewerServer {
|
|
|
1042
1074
|
});
|
|
1043
1075
|
}
|
|
1044
1076
|
|
|
1045
|
-
private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
|
|
1077
|
+
private async handleSkillDelete(res: http.ServerResponse, urlPath: string): Promise<void> {
|
|
1046
1078
|
const skillId = urlPath.replace("/api/skill/", "");
|
|
1047
1079
|
const skill = this.store.getSkill(skillId);
|
|
1048
1080
|
if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
|
|
1049
|
-
|
|
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 (_) {}
|
|
1050
1092
|
try {
|
|
1051
1093
|
if (skill.dirPath && fs.existsSync(skill.dirPath)) {
|
|
1052
1094
|
fs.rmSync(skill.dirPath, { recursive: true, force: true });
|
|
@@ -1209,7 +1251,6 @@ export class ViewerServer {
|
|
|
1209
1251
|
let hubSynced = false;
|
|
1210
1252
|
|
|
1211
1253
|
if (scope === "team") {
|
|
1212
|
-
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1213
1254
|
if (!isTeamShared) {
|
|
1214
1255
|
const hubClient = await this.resolveHubClientAware();
|
|
1215
1256
|
const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
|
|
@@ -1217,17 +1258,24 @@ export class ViewerServer {
|
|
|
1217
1258
|
method: "POST",
|
|
1218
1259
|
body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
|
|
1219
1260
|
});
|
|
1220
|
-
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) {
|
|
1221
1265
|
const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
|
|
1222
1266
|
this.store.upsertHubMemory({
|
|
1223
|
-
id:
|
|
1267
|
+
id: memoryId || existing?.id || crypto.randomUUID(),
|
|
1224
1268
|
sourceChunkId: chunkId, sourceUserId: hubClient.userId,
|
|
1225
1269
|
role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
|
|
1226
1270
|
kind: refreshedChunk.kind, groupId: null, visibility: "public",
|
|
1227
1271
|
createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
|
|
1228
1272
|
});
|
|
1273
|
+
} else if (hubClient.userId) {
|
|
1274
|
+
this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
|
|
1229
1275
|
}
|
|
1230
1276
|
hubSynced = true;
|
|
1277
|
+
} else {
|
|
1278
|
+
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1231
1279
|
}
|
|
1232
1280
|
} else if (scope === "local") {
|
|
1233
1281
|
if (isTeamShared) {
|
|
@@ -1237,6 +1285,7 @@ export class ViewerServer {
|
|
|
1237
1285
|
method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
|
|
1238
1286
|
});
|
|
1239
1287
|
if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
|
|
1288
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
1240
1289
|
hubSynced = true;
|
|
1241
1290
|
} catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
|
|
1242
1291
|
}
|
|
@@ -1249,6 +1298,7 @@ export class ViewerServer {
|
|
|
1249
1298
|
method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
|
|
1250
1299
|
});
|
|
1251
1300
|
if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
|
|
1301
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
1252
1302
|
hubSynced = true;
|
|
1253
1303
|
} catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
|
|
1254
1304
|
}
|
|
@@ -1289,15 +1339,6 @@ export class ViewerServer {
|
|
|
1289
1339
|
|
|
1290
1340
|
let hubSynced = false;
|
|
1291
1341
|
|
|
1292
|
-
if (scope === "local" || scope === "team") {
|
|
1293
|
-
if (!isLocalShared) {
|
|
1294
|
-
const originalOwner = task.owner;
|
|
1295
|
-
const db = (this.store as any).db;
|
|
1296
|
-
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());
|
|
1297
|
-
db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
1342
|
if (scope === "team") {
|
|
1302
1343
|
if (!isTeamShared) {
|
|
1303
1344
|
const chunks = this.store.getChunksByTask(taskId);
|
|
@@ -1321,6 +1362,21 @@ export class ViewerServer {
|
|
|
1321
1362
|
}
|
|
1322
1363
|
hubSynced = true;
|
|
1323
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
|
+
}
|
|
1324
1380
|
}
|
|
1325
1381
|
|
|
1326
1382
|
if (scope === "local" && isTeamShared) {
|
|
@@ -1390,10 +1446,6 @@ export class ViewerServer {
|
|
|
1390
1446
|
|
|
1391
1447
|
let hubSynced = false;
|
|
1392
1448
|
|
|
1393
|
-
if (scope === "local" || scope === "team") {
|
|
1394
|
-
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
1449
|
if (scope === "team") {
|
|
1398
1450
|
if (!isTeamShared) {
|
|
1399
1451
|
const bundle = buildSkillBundleForHub(this.store, skillId);
|
|
@@ -1415,6 +1467,11 @@ export class ViewerServer {
|
|
|
1415
1467
|
}
|
|
1416
1468
|
hubSynced = true;
|
|
1417
1469
|
}
|
|
1470
|
+
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (scope === "local") {
|
|
1474
|
+
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1418
1475
|
}
|
|
1419
1476
|
|
|
1420
1477
|
if (scope === "local" && isTeamShared) {
|
|
@@ -1451,7 +1508,17 @@ export class ViewerServer {
|
|
|
1451
1508
|
|
|
1452
1509
|
private getHubMemoryForChunk(chunkId: string): any {
|
|
1453
1510
|
const db = (this.store as any).db;
|
|
1454
|
-
|
|
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;
|
|
1455
1522
|
}
|
|
1456
1523
|
|
|
1457
1524
|
private getHubTaskForLocal(taskId: string): any {
|
|
@@ -1498,6 +1565,7 @@ export class ViewerServer {
|
|
|
1498
1565
|
// ─── Config API ───
|
|
1499
1566
|
|
|
1500
1567
|
private getOpenClawConfigPath(): string {
|
|
1568
|
+
if (process.env.OPENCLAW_CONFIG_PATH) return process.env.OPENCLAW_CONFIG_PATH;
|
|
1501
1569
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
1502
1570
|
const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
|
|
1503
1571
|
return path.join(ocHome, "openclaw.json");
|
|
@@ -1587,7 +1655,20 @@ export class ViewerServer {
|
|
|
1587
1655
|
base.connection.teamName = info?.teamName ?? sharing.hub?.teamName ?? null;
|
|
1588
1656
|
base.connection.apiVersion = info?.apiVersion ?? null;
|
|
1589
1657
|
} catch { /* ignore */ }
|
|
1590
|
-
|
|
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 });
|
|
1591
1672
|
return;
|
|
1592
1673
|
}
|
|
1593
1674
|
|
|
@@ -1606,6 +1687,9 @@ export class ViewerServer {
|
|
|
1606
1687
|
if (status.user?.status === "rejected") {
|
|
1607
1688
|
output.connection.rejected = true;
|
|
1608
1689
|
}
|
|
1690
|
+
if (status.user?.status === "removed") {
|
|
1691
|
+
output.connection.removed = true;
|
|
1692
|
+
}
|
|
1609
1693
|
if (status.connected && status.hubUrl) {
|
|
1610
1694
|
try {
|
|
1611
1695
|
const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
|
|
@@ -1723,7 +1807,14 @@ export class ViewerServer {
|
|
|
1723
1807
|
});
|
|
1724
1808
|
this.jsonResponse(res, { ok: true, result });
|
|
1725
1809
|
} catch (err) {
|
|
1726
|
-
|
|
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
|
+
}
|
|
1727
1818
|
}
|
|
1728
1819
|
});
|
|
1729
1820
|
}
|
|
@@ -1746,10 +1837,13 @@ export class ViewerServer {
|
|
|
1746
1837
|
const nickname = sharing.client?.nickname;
|
|
1747
1838
|
const username = nickname || os.userInfo().username || "user";
|
|
1748
1839
|
const hostname = os.hostname() || "unknown";
|
|
1840
|
+
const persisted = this.store.getClientHubConnection();
|
|
1841
|
+
const existingIdentityKey = persisted?.identityKey || "";
|
|
1749
1842
|
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
1750
1843
|
method: "POST",
|
|
1751
|
-
body: JSON.stringify({ teamToken, username, deviceName: hostname }),
|
|
1844
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
|
|
1752
1845
|
}) as any;
|
|
1846
|
+
const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
|
|
1753
1847
|
this.store.setClientHubConnection({
|
|
1754
1848
|
hubUrl,
|
|
1755
1849
|
userId: String(result.userId || ""),
|
|
@@ -1757,6 +1851,8 @@ export class ViewerServer {
|
|
|
1757
1851
|
userToken: result.userToken || "",
|
|
1758
1852
|
role: "member",
|
|
1759
1853
|
connectedAt: Date.now(),
|
|
1854
|
+
identityKey: returnedIdentityKey,
|
|
1855
|
+
lastKnownStatus: result.status || "",
|
|
1760
1856
|
});
|
|
1761
1857
|
this.jsonResponse(res, { ok: true, status: result.status || "pending" });
|
|
1762
1858
|
} catch (err) {
|
|
@@ -2032,14 +2128,14 @@ export class ViewerServer {
|
|
|
2032
2128
|
},
|
|
2033
2129
|
}),
|
|
2034
2130
|
});
|
|
2035
|
-
const
|
|
2036
|
-
if (
|
|
2131
|
+
const mid = String((response as any)?.memoryId ?? "");
|
|
2132
|
+
if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
|
|
2037
2133
|
const now = Date.now();
|
|
2038
|
-
const existing = this.store.getHubMemoryBySource(
|
|
2134
|
+
const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
|
|
2039
2135
|
this.store.upsertHubMemory({
|
|
2040
|
-
id:
|
|
2136
|
+
id: mid || existing?.id || crypto.randomUUID(),
|
|
2041
2137
|
sourceChunkId: chunk.id,
|
|
2042
|
-
sourceUserId:
|
|
2138
|
+
sourceUserId: hubClient.userId,
|
|
2043
2139
|
role: chunk.role,
|
|
2044
2140
|
content: chunk.content,
|
|
2045
2141
|
summary: chunk.summary ?? "",
|
|
@@ -2049,6 +2145,8 @@ export class ViewerServer {
|
|
|
2049
2145
|
createdAt: existing?.createdAt ?? now,
|
|
2050
2146
|
updatedAt: now,
|
|
2051
2147
|
});
|
|
2148
|
+
} else if (hubClient.userId) {
|
|
2149
|
+
this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
|
|
2052
2150
|
}
|
|
2053
2151
|
this.jsonResponse(res, { ok: true, chunkId, visibility, response });
|
|
2054
2152
|
} catch (err) {
|
|
@@ -2070,6 +2168,7 @@ export class ViewerServer {
|
|
|
2070
2168
|
});
|
|
2071
2169
|
const hubUserId = hubClient.userId;
|
|
2072
2170
|
if (hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
|
|
2171
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
2073
2172
|
this.jsonResponse(res, { ok: true, chunkId });
|
|
2074
2173
|
} catch (err) {
|
|
2075
2174
|
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
@@ -2166,7 +2265,7 @@ export class ViewerServer {
|
|
|
2166
2265
|
// Hub 模式:连接自己,用 bootstrap admin token
|
|
2167
2266
|
const sharing = this.ctx.config.sharing;
|
|
2168
2267
|
if (sharing?.role === "hub") {
|
|
2169
|
-
const hubPort =
|
|
2268
|
+
const hubPort = this.getHubPort();
|
|
2170
2269
|
const hubUrl = `http://127.0.0.1:${hubPort}`;
|
|
2171
2270
|
try {
|
|
2172
2271
|
const authPath = path.join(this.dataDir, "hub-auth.json");
|
|
@@ -2383,6 +2482,31 @@ export class ViewerServer {
|
|
|
2383
2482
|
});
|
|
2384
2483
|
}
|
|
2385
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
|
+
|
|
2386
2510
|
private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2387
2511
|
res.writeHead(200, {
|
|
2388
2512
|
"Content-Type": "text/event-stream",
|
|
@@ -2393,6 +2517,7 @@ export class ViewerServer {
|
|
|
2393
2517
|
res.write("data: {\"type\":\"connected\"}\n\n");
|
|
2394
2518
|
this.notifSSEClients.push(res);
|
|
2395
2519
|
if (!this.notifPollTimer) this.startNotifPoll();
|
|
2520
|
+
else this.notifPollImmediate();
|
|
2396
2521
|
req.on("close", () => {
|
|
2397
2522
|
this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
|
|
2398
2523
|
if (this.notifSSEClients.length === 0) this.stopNotifPoll();
|
|
@@ -2428,6 +2553,20 @@ export class ViewerServer {
|
|
|
2428
2553
|
if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
|
|
2429
2554
|
}
|
|
2430
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
|
+
|
|
2431
2570
|
private startHubHeartbeat(): void {
|
|
2432
2571
|
this.stopHubHeartbeat();
|
|
2433
2572
|
const sendHeartbeat = async () => {
|
|
@@ -2526,7 +2665,10 @@ export class ViewerServer {
|
|
|
2526
2665
|
if (!entry.config) entry.config = {};
|
|
2527
2666
|
const config = entry.config as Record<string, unknown>;
|
|
2528
2667
|
|
|
2529
|
-
const
|
|
2668
|
+
const oldSharing = config.sharing as Record<string, unknown> | undefined;
|
|
2669
|
+
const oldSharingRole = oldSharing?.role as string | undefined;
|
|
2670
|
+
const oldSharingEnabled = Boolean(oldSharing?.enabled);
|
|
2671
|
+
const oldClientHubAddress = String((oldSharing?.client as Record<string, unknown>)?.hubAddress || "");
|
|
2530
2672
|
|
|
2531
2673
|
if (newCfg.embedding) config.embedding = newCfg.embedding;
|
|
2532
2674
|
if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
|
|
@@ -2542,12 +2684,14 @@ export class ViewerServer {
|
|
|
2542
2684
|
if (merged.role === "client" && merged.client) {
|
|
2543
2685
|
const clientCfg = merged.client as Record<string, unknown>;
|
|
2544
2686
|
const addr = String(clientCfg.hubAddress || "");
|
|
2545
|
-
if (addr) {
|
|
2687
|
+
if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
|
|
2688
|
+
const selfHubPort = (oldSharing?.hub as Record<string, unknown>)?.port ?? 18800;
|
|
2546
2689
|
const localIPs = this.getLocalIPs();
|
|
2547
2690
|
localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
|
|
2548
2691
|
try {
|
|
2549
2692
|
const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
|
|
2550
|
-
|
|
2693
|
+
const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
|
|
2694
|
+
if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
|
|
2551
2695
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2552
2696
|
res.end(JSON.stringify({ error: "cannot_join_self" }));
|
|
2553
2697
|
return;
|
|
@@ -2556,16 +2700,43 @@ export class ViewerServer {
|
|
|
2556
2700
|
}
|
|
2557
2701
|
}
|
|
2558
2702
|
|
|
2559
|
-
// When switching away from client mode, notify Hub that we're leaving
|
|
2560
2703
|
const newRole = merged.role as string | undefined;
|
|
2561
|
-
|
|
2562
|
-
|
|
2704
|
+
const newEnabled = Boolean(merged.enabled);
|
|
2705
|
+
|
|
2706
|
+
// Detect disabling sharing or switching away from hub mode
|
|
2707
|
+
const wasHub = oldSharingEnabled && oldSharingRole === "hub";
|
|
2708
|
+
const isHub = newEnabled && newRole === "hub";
|
|
2709
|
+
if (wasHub && !isHub) {
|
|
2710
|
+
await this.notifyHubShutdown();
|
|
2711
|
+
this.stopHubHeartbeat();
|
|
2712
|
+
this.log.info("Hub shutting down: notified connected clients");
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// Detect disabling sharing or switching away from client mode
|
|
2716
|
+
const wasClient = oldSharingEnabled && oldSharingRole === "client";
|
|
2717
|
+
const isClient = newEnabled && newRole === "client";
|
|
2718
|
+
if (wasClient && !isClient) {
|
|
2719
|
+
await this.withdrawOrLeaveHub();
|
|
2720
|
+
this.store.clearClientHubConnection();
|
|
2721
|
+
this.log.info("Client hub connection cleared (sharing disabled or role changed)");
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
if (wasClient && isClient) {
|
|
2725
|
+
const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
|
|
2726
|
+
if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
|
|
2727
|
+
this.notifyHubLeave();
|
|
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");
|
|
2733
|
+
}
|
|
2563
2734
|
}
|
|
2564
2735
|
|
|
2565
2736
|
if (merged.role === "hub") {
|
|
2566
2737
|
merged.client = { hubAddress: "", userToken: "", teamToken: "" };
|
|
2567
2738
|
} else if (merged.role === "client") {
|
|
2568
|
-
merged.hub = {
|
|
2739
|
+
merged.hub = { teamName: "", teamToken: "" };
|
|
2569
2740
|
}
|
|
2570
2741
|
config.sharing = merged;
|
|
2571
2742
|
}
|
|
@@ -2574,7 +2745,26 @@ export class ViewerServer {
|
|
|
2574
2745
|
fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
|
|
2575
2746
|
this.log.info("Plugin config updated via Viewer");
|
|
2576
2747
|
this.stopHubHeartbeat();
|
|
2577
|
-
|
|
2748
|
+
|
|
2749
|
+
// When switching to client mode or re-enabling sharing as client, send join request
|
|
2750
|
+
const finalSharing = config.sharing as Record<string, unknown> | undefined;
|
|
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
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
|
|
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);
|
|
2578
2768
|
} catch (e) {
|
|
2579
2769
|
this.log.warn(`handleSaveConfig error: ${e}`);
|
|
2580
2770
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -2583,6 +2773,75 @@ export class ViewerServer {
|
|
|
2583
2773
|
});
|
|
2584
2774
|
}
|
|
2585
2775
|
|
|
2776
|
+
private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<string | undefined> {
|
|
2777
|
+
const clientCfg = sharing.client as Record<string, unknown> | undefined;
|
|
2778
|
+
const hubAddress = String(clientCfg?.hubAddress || "");
|
|
2779
|
+
const teamToken = String(clientCfg?.teamToken || "");
|
|
2780
|
+
if (!hubAddress || !teamToken) return undefined;
|
|
2781
|
+
const hubUrl = normalizeHubUrl(hubAddress);
|
|
2782
|
+
const os = await import("os");
|
|
2783
|
+
const nickname = String(clientCfg?.nickname || "");
|
|
2784
|
+
const username = nickname || os.userInfo().username || "user";
|
|
2785
|
+
const hostname = os.hostname() || "unknown";
|
|
2786
|
+
const persisted = this.store.getClientHubConnection();
|
|
2787
|
+
const existingIdentityKey = persisted?.identityKey || "";
|
|
2788
|
+
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
2789
|
+
method: "POST",
|
|
2790
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
|
|
2791
|
+
}) as any;
|
|
2792
|
+
const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
|
|
2793
|
+
this.store.setClientHubConnection({
|
|
2794
|
+
hubUrl,
|
|
2795
|
+
userId: String(result.userId || ""),
|
|
2796
|
+
username,
|
|
2797
|
+
userToken: result.userToken || "",
|
|
2798
|
+
role: "member",
|
|
2799
|
+
connectedAt: Date.now(),
|
|
2800
|
+
identityKey: returnedIdentityKey,
|
|
2801
|
+
lastKnownStatus: result.status || "",
|
|
2802
|
+
});
|
|
2803
|
+
this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
|
|
2804
|
+
if (result.userToken) {
|
|
2805
|
+
this.startHubHeartbeat();
|
|
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
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2586
2845
|
private async notifyHubLeave(): Promise<void> {
|
|
2587
2846
|
try {
|
|
2588
2847
|
const hub = this.resolveHubConnection();
|
|
@@ -2601,6 +2860,79 @@ export class ViewerServer {
|
|
|
2601
2860
|
}
|
|
2602
2861
|
}
|
|
2603
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
|
+
|
|
2901
|
+
private async notifyHubShutdown(): Promise<void> {
|
|
2902
|
+
try {
|
|
2903
|
+
const sharing = this.ctx?.config.sharing;
|
|
2904
|
+
if (!sharing || sharing.role !== "hub") return;
|
|
2905
|
+
const hubPort = this.getHubPort();
|
|
2906
|
+
const authPath = path.join(this.dataDir, "hub-auth.json");
|
|
2907
|
+
let adminToken: string | undefined;
|
|
2908
|
+
try {
|
|
2909
|
+
const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
|
|
2910
|
+
adminToken = authData?.bootstrapAdminToken;
|
|
2911
|
+
} catch { return; }
|
|
2912
|
+
if (!adminToken) return;
|
|
2913
|
+
|
|
2914
|
+
const users = this.store.listHubUsers("active");
|
|
2915
|
+
const { v4: uuidv4 } = require("uuid");
|
|
2916
|
+
for (const u of users) {
|
|
2917
|
+
try {
|
|
2918
|
+
this.store.insertHubNotification({
|
|
2919
|
+
id: uuidv4(),
|
|
2920
|
+
userId: u.id,
|
|
2921
|
+
type: "hub_shutdown",
|
|
2922
|
+
resource: "hub",
|
|
2923
|
+
title: "Hub is shutting down",
|
|
2924
|
+
message: "The Hub server is shutting down. You may be disconnected.",
|
|
2925
|
+
});
|
|
2926
|
+
} catch (e) {
|
|
2927
|
+
this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
|
|
2931
|
+
} catch (e) {
|
|
2932
|
+
this.log.warn(`notifyHubShutdown error: ${e}`);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2604
2936
|
private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2605
2937
|
this.readBody(req, async (body) => {
|
|
2606
2938
|
if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
|
|
@@ -2628,10 +2960,10 @@ export class ViewerServer {
|
|
|
2628
2960
|
this.log.warn(`Failed to update hub-auth.json: ${e}`);
|
|
2629
2961
|
}
|
|
2630
2962
|
} else {
|
|
2631
|
-
const
|
|
2632
|
-
if (
|
|
2963
|
+
const persistedConn = this.store.getClientHubConnection();
|
|
2964
|
+
if (persistedConn) {
|
|
2633
2965
|
this.store.setClientHubConnection({
|
|
2634
|
-
...
|
|
2966
|
+
...persistedConn,
|
|
2635
2967
|
username: result.username,
|
|
2636
2968
|
userToken: result.userToken,
|
|
2637
2969
|
});
|
|
@@ -2655,12 +2987,17 @@ export class ViewerServer {
|
|
|
2655
2987
|
const { hubUrl } = JSON.parse(body);
|
|
2656
2988
|
if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
|
|
2657
2989
|
try {
|
|
2658
|
-
const
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
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
|
+
}
|
|
2664
3001
|
}
|
|
2665
3002
|
} catch {}
|
|
2666
3003
|
const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
|
|
@@ -2898,10 +3235,9 @@ export class ViewerServer {
|
|
|
2898
3235
|
this.log.info(`update-install: success! Updated to ${newVersion}`);
|
|
2899
3236
|
this.jsonResponse(res, { ok: true, version: newVersion });
|
|
2900
3237
|
|
|
2901
|
-
// Trigger Gateway restart after response is sent
|
|
2902
3238
|
setTimeout(() => {
|
|
2903
|
-
this.log.info(`update-install: triggering gateway restart...`);
|
|
2904
|
-
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}`); }
|
|
2905
3241
|
}, 500);
|
|
2906
3242
|
});
|
|
2907
3243
|
});
|
|
@@ -3083,7 +3419,7 @@ export class ViewerServer {
|
|
|
3083
3419
|
try {
|
|
3084
3420
|
const ocHome = this.getOpenClawHome();
|
|
3085
3421
|
const memoryDir = path.join(ocHome, "memory");
|
|
3086
|
-
const
|
|
3422
|
+
const agentsDir = path.join(ocHome, "agents");
|
|
3087
3423
|
|
|
3088
3424
|
const sqliteFiles: Array<{ file: string; chunks: number }> = [];
|
|
3089
3425
|
if (fs.existsSync(memoryDir)) {
|
|
@@ -3102,31 +3438,36 @@ export class ViewerServer {
|
|
|
3102
3438
|
|
|
3103
3439
|
let sessionCount = 0;
|
|
3104
3440
|
let messageCount = 0;
|
|
3105
|
-
if (fs.existsSync(
|
|
3106
|
-
const
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
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
|
+
}
|
|
3125
3466
|
}
|
|
3126
|
-
}
|
|
3127
|
-
}
|
|
3128
|
-
}
|
|
3129
|
-
}
|
|
3467
|
+
} catch { /* skip bad lines */ }
|
|
3468
|
+
}
|
|
3469
|
+
} catch { /* skip unreadable */ }
|
|
3470
|
+
}
|
|
3130
3471
|
}
|
|
3131
3472
|
}
|
|
3132
3473
|
|