@memtensor/memos-local-openclaw-plugin 1.0.4-beta.7 → 1.0.4-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +6 -0
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts.map +1 -1
- package/dist/client/connector.js +61 -7
- package/dist/client/connector.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -2
- package/dist/config.js.map +1 -1
- package/dist/hub/server.d.ts +7 -0
- package/dist/hub/server.d.ts.map +1 -1
- package/dist/hub/server.js +171 -8
- package/dist/hub/server.js.map +1 -1
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +37 -6
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +78 -1
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts +1 -0
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +82 -8
- package/dist/shared/llm-call.js.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 +3 -0
- package/dist/skill/evolver.js.map +1 -1
- package/dist/storage/sqlite.d.ts +5 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +13 -4
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts +12 -5
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +135 -38
- package/dist/telemetry.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +735 -285
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +16 -0
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +349 -21
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +26 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -2
- package/scripts/postinstall.cjs +1 -1
- package/src/capture/index.ts +8 -0
- package/src/client/connector.ts +62 -7
- package/src/config.ts +0 -2
- package/src/hub/server.ts +168 -8
- package/src/ingest/providers/index.ts +41 -7
- package/src/recall/engine.ts +73 -1
- package/src/shared/llm-call.ts +97 -9
- package/src/skill/evolver.ts +5 -0
- package/src/storage/sqlite.ts +19 -6
- package/src/telemetry.ts +152 -39
- package/src/types.ts +1 -2
- package/src/viewer/html.ts +735 -285
- package/src/viewer/server.ts +322 -21
package/src/viewer/server.ts
CHANGED
|
@@ -84,6 +84,12 @@ export class ViewerServer {
|
|
|
84
84
|
{ running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
|
|
85
85
|
private ppSSEClients: http.ServerResponse[] = [];
|
|
86
86
|
|
|
87
|
+
private notifSSEClients: http.ServerResponse[] = [];
|
|
88
|
+
private notifPollTimer?: ReturnType<typeof setInterval>;
|
|
89
|
+
private lastKnownNotifCount = 0;
|
|
90
|
+
private hubHeartbeatTimer?: ReturnType<typeof setInterval>;
|
|
91
|
+
private static readonly HUB_HEARTBEAT_INTERVAL_MS = 45_000;
|
|
92
|
+
|
|
87
93
|
constructor(opts: ViewerServerOptions) {
|
|
88
94
|
this.store = opts.store;
|
|
89
95
|
this.embedder = opts.embedder;
|
|
@@ -103,15 +109,16 @@ export class ViewerServer {
|
|
|
103
109
|
this.server.on("error", (err: NodeJS.ErrnoException) => {
|
|
104
110
|
if (err.code === "EADDRINUSE") {
|
|
105
111
|
this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
|
|
106
|
-
this.server!.listen(this.port + 1, "
|
|
112
|
+
this.server!.listen(this.port + 1, "0.0.0.0");
|
|
107
113
|
} else {
|
|
108
114
|
reject(err);
|
|
109
115
|
}
|
|
110
116
|
});
|
|
111
|
-
this.server.listen(this.port, "
|
|
117
|
+
this.server.listen(this.port, "0.0.0.0", () => {
|
|
112
118
|
const addr = this.server!.address();
|
|
113
119
|
const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
|
|
114
120
|
this.autoCleanupPolluted();
|
|
121
|
+
this.startHubHeartbeat();
|
|
115
122
|
resolve(`http://127.0.0.1:${actualPort}`);
|
|
116
123
|
});
|
|
117
124
|
});
|
|
@@ -134,6 +141,10 @@ export class ViewerServer {
|
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
stop(): void {
|
|
144
|
+
this.stopHubHeartbeat();
|
|
145
|
+
this.stopNotifPoll();
|
|
146
|
+
for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
|
|
147
|
+
this.notifSSEClients = [];
|
|
137
148
|
this.server?.close();
|
|
138
149
|
this.server = null;
|
|
139
150
|
}
|
|
@@ -279,9 +290,12 @@ export class ViewerServer {
|
|
|
279
290
|
else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
|
|
280
291
|
else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
|
|
281
292
|
else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
|
|
293
|
+
else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
|
|
282
294
|
else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
|
|
295
|
+
else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
|
|
283
296
|
else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteTask(res, p);
|
|
284
297
|
else if (p === "/api/admin/shared-skills" && req.method === "GET") this.serveAdminSharedSkills(res);
|
|
298
|
+
else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET") this.serveHubSkillDetail(res, p);
|
|
285
299
|
else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteSkill(res, p);
|
|
286
300
|
else if (p === "/api/admin/shared-memories" && req.method === "GET") this.serveAdminSharedMemories(res);
|
|
287
301
|
else if (p.match(/^\/api\/admin\/shared-memories\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteMemory(res, p);
|
|
@@ -436,7 +450,13 @@ export class ViewerServer {
|
|
|
436
450
|
const params: any[] = [];
|
|
437
451
|
if (session) { conditions.push("session_key = ?"); params.push(session); }
|
|
438
452
|
if (role) { conditions.push("role = ?"); params.push(role); }
|
|
439
|
-
if (owner
|
|
453
|
+
if (owner && owner.startsWith("agent:")) {
|
|
454
|
+
const agentPrefix = owner + ":";
|
|
455
|
+
conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
|
|
456
|
+
params.push(owner, agentPrefix + "%");
|
|
457
|
+
} else if (owner) {
|
|
458
|
+
conditions.push("owner = ?"); params.push(owner);
|
|
459
|
+
}
|
|
440
460
|
if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
|
|
441
461
|
if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
|
|
442
462
|
|
|
@@ -510,9 +530,10 @@ export class ViewerServer {
|
|
|
510
530
|
private serveTasks(res: http.ServerResponse, url: URL): void {
|
|
511
531
|
this.store.recordViewerEvent("tasks_list");
|
|
512
532
|
const status = url.searchParams.get("status") ?? undefined;
|
|
533
|
+
const owner = url.searchParams.get("owner") ?? undefined;
|
|
513
534
|
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
|
|
514
535
|
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
|
|
515
|
-
const { tasks, total } = this.store.listTasks({ status, limit, offset });
|
|
536
|
+
const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
|
|
516
537
|
|
|
517
538
|
const db = (this.store as any).db;
|
|
518
539
|
const items = tasks.map((t) => {
|
|
@@ -616,12 +637,20 @@ export class ViewerServer {
|
|
|
616
637
|
}
|
|
617
638
|
let embCount = 0;
|
|
618
639
|
try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
640
|
+
let sessionQuery: string;
|
|
641
|
+
let sessionParams: any[];
|
|
642
|
+
if (ownerFilter && ownerFilter.startsWith("agent:")) {
|
|
643
|
+
const agentPrefix = ownerFilter + ":";
|
|
644
|
+
sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR (owner = 'public' AND session_key LIKE ?)) GROUP BY session_key ORDER BY latest DESC";
|
|
645
|
+
sessionParams = [ownerFilter, agentPrefix + "%"];
|
|
646
|
+
} else if (ownerFilter) {
|
|
647
|
+
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
|
+
sessionParams = [ownerFilter];
|
|
649
|
+
} else {
|
|
650
|
+
sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC";
|
|
651
|
+
sessionParams = [];
|
|
652
|
+
}
|
|
653
|
+
const sessionList = db.prepare(sessionQuery).all(...sessionParams) as any[];
|
|
625
654
|
|
|
626
655
|
let skillCount = 0;
|
|
627
656
|
try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
|
|
@@ -634,10 +663,16 @@ export class ViewerServer {
|
|
|
634
663
|
|
|
635
664
|
let owners: string[] = [];
|
|
636
665
|
try {
|
|
637
|
-
const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
|
|
666
|
+
const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all() as any[];
|
|
638
667
|
owners = ownerRows.map((o: any) => o.owner);
|
|
639
668
|
} catch { /* column may not exist yet */ }
|
|
640
669
|
|
|
670
|
+
let currentAgentOwner = "agent:main";
|
|
671
|
+
try {
|
|
672
|
+
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;
|
|
673
|
+
if (latest?.owner) currentAgentOwner = latest.owner;
|
|
674
|
+
} catch { /* best-effort */ }
|
|
675
|
+
|
|
641
676
|
this.jsonResponse(res, {
|
|
642
677
|
totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
|
|
643
678
|
totalSkills: skillCount,
|
|
@@ -646,6 +681,7 @@ export class ViewerServer {
|
|
|
646
681
|
timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
|
|
647
682
|
sessions: sessionList,
|
|
648
683
|
owners,
|
|
684
|
+
currentAgentOwner,
|
|
649
685
|
});
|
|
650
686
|
} catch (e) {
|
|
651
687
|
this.log.warn(`stats error: ${e}`);
|
|
@@ -1470,7 +1506,8 @@ export class ViewerServer {
|
|
|
1470
1506
|
|
|
1471
1507
|
private getOpenClawConfigPath(): string {
|
|
1472
1508
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
1473
|
-
|
|
1509
|
+
const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
|
|
1510
|
+
return path.join(ocHome, "openclaw.json");
|
|
1474
1511
|
}
|
|
1475
1512
|
|
|
1476
1513
|
private getPluginEntryConfig(raw: any): Record<string, unknown> {
|
|
@@ -1712,12 +1749,21 @@ export class ViewerServer {
|
|
|
1712
1749
|
}
|
|
1713
1750
|
try {
|
|
1714
1751
|
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 {}
|
|
1715
1760
|
const os = await import("os");
|
|
1716
|
-
const
|
|
1761
|
+
const nickname = sharing.client?.nickname;
|
|
1762
|
+
const username = nickname || os.userInfo().username || "user";
|
|
1717
1763
|
const hostname = os.hostname() || "unknown";
|
|
1718
1764
|
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
1719
1765
|
method: "POST",
|
|
1720
|
-
body: JSON.stringify({ teamToken, username, deviceName: hostname }),
|
|
1766
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
|
|
1721
1767
|
}) as any;
|
|
1722
1768
|
this.store.setClientHubConnection({
|
|
1723
1769
|
hubUrl,
|
|
@@ -1738,7 +1784,13 @@ export class ViewerServer {
|
|
|
1738
1784
|
if (!this.ctx) return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
|
|
1739
1785
|
try {
|
|
1740
1786
|
const limit = Number(url.searchParams.get("limit") || 40);
|
|
1741
|
-
const
|
|
1787
|
+
const hub = this.resolveHubConnection();
|
|
1788
|
+
let data: any;
|
|
1789
|
+
if (hub) {
|
|
1790
|
+
data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
|
|
1791
|
+
} else {
|
|
1792
|
+
data = await hubListMemories(this.store, this.ctx, { limit });
|
|
1793
|
+
}
|
|
1742
1794
|
this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
|
|
1743
1795
|
} catch (err) {
|
|
1744
1796
|
this.jsonResponse(res, { memories: [], error: String(err) });
|
|
@@ -1749,7 +1801,13 @@ export class ViewerServer {
|
|
|
1749
1801
|
if (!this.ctx) return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
|
|
1750
1802
|
try {
|
|
1751
1803
|
const limit = Number(url.searchParams.get("limit") || 40);
|
|
1752
|
-
const
|
|
1804
|
+
const hub = this.resolveHubConnection();
|
|
1805
|
+
let data: any;
|
|
1806
|
+
if (hub) {
|
|
1807
|
+
data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
|
|
1808
|
+
} else {
|
|
1809
|
+
data = await hubListTasks(this.store, this.ctx, { limit });
|
|
1810
|
+
}
|
|
1753
1811
|
this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
|
|
1754
1812
|
} catch (err) {
|
|
1755
1813
|
this.jsonResponse(res, { tasks: [], error: String(err) });
|
|
@@ -1760,7 +1818,13 @@ export class ViewerServer {
|
|
|
1760
1818
|
if (!this.ctx) return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
|
|
1761
1819
|
try {
|
|
1762
1820
|
const limit = Number(url.searchParams.get("limit") || 40);
|
|
1763
|
-
const
|
|
1821
|
+
const hub = this.resolveHubConnection();
|
|
1822
|
+
let data: any;
|
|
1823
|
+
if (hub) {
|
|
1824
|
+
data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
|
|
1825
|
+
} else {
|
|
1826
|
+
data = await hubListSkills(this.store, this.ctx, { limit });
|
|
1827
|
+
}
|
|
1764
1828
|
this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
|
|
1765
1829
|
} catch (err) {
|
|
1766
1830
|
this.jsonResponse(res, { skills: [], error: String(err) });
|
|
@@ -1776,13 +1840,21 @@ export class ViewerServer {
|
|
|
1776
1840
|
const query = String(parsed.query || "");
|
|
1777
1841
|
const role = typeof parsed.role === "string" ? parsed.role : undefined;
|
|
1778
1842
|
const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
|
|
1779
|
-
const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
|
|
1843
|
+
const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
|
|
1780
1844
|
const local = this.searchLocalViewerMemories(query, { role, maxResults });
|
|
1781
1845
|
if (scope === "local") {
|
|
1782
1846
|
return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
|
|
1783
1847
|
}
|
|
1784
1848
|
try {
|
|
1785
|
-
const
|
|
1849
|
+
const conn = this.resolveHubConnection();
|
|
1850
|
+
let hub: any;
|
|
1851
|
+
if (conn) {
|
|
1852
|
+
hub = await hubRequestJson(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
|
|
1853
|
+
method: "POST", body: JSON.stringify({ query, maxResults, scope }),
|
|
1854
|
+
});
|
|
1855
|
+
} else {
|
|
1856
|
+
hub = await hubSearchMemories(this.store, this.ctx!, { query, maxResults, scope });
|
|
1857
|
+
}
|
|
1786
1858
|
this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
|
|
1787
1859
|
} catch (err) {
|
|
1788
1860
|
this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub, error: String(err) });
|
|
@@ -2192,6 +2264,34 @@ export class ViewerServer {
|
|
|
2192
2264
|
}
|
|
2193
2265
|
}
|
|
2194
2266
|
|
|
2267
|
+
private async serveHubTaskDetail(res: http.ServerResponse, p: string): Promise<void> {
|
|
2268
|
+
const hub = this.resolveHubConnection();
|
|
2269
|
+
if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
|
|
2270
|
+
const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
|
|
2271
|
+
if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
|
|
2272
|
+
const taskId = decodeURIComponent(m[1]);
|
|
2273
|
+
try {
|
|
2274
|
+
const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" }) as any;
|
|
2275
|
+
this.jsonResponse(res, data);
|
|
2276
|
+
} catch (err) {
|
|
2277
|
+
this.jsonResponse(res, { error: String(err) }, 500);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
private async serveHubSkillDetail(res: http.ServerResponse, p: string): Promise<void> {
|
|
2282
|
+
const hub = this.resolveHubConnection();
|
|
2283
|
+
if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
|
|
2284
|
+
const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
|
|
2285
|
+
if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
|
|
2286
|
+
const skillId = decodeURIComponent(m[1]);
|
|
2287
|
+
try {
|
|
2288
|
+
const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" }) as any;
|
|
2289
|
+
this.jsonResponse(res, data);
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
this.jsonResponse(res, { error: String(err) }, 500);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2195
2295
|
private async serveAdminSharedSkills(res: http.ServerResponse): Promise<void> {
|
|
2196
2296
|
const hub = this.resolveHubConnection();
|
|
2197
2297
|
if (!hub) return this.jsonResponse(res, { skills: [], error: "not_configured" });
|
|
@@ -2272,6 +2372,12 @@ export class ViewerServer {
|
|
|
2272
2372
|
const body = JSON.parse(raw || "{}");
|
|
2273
2373
|
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
|
|
2274
2374
|
this.jsonResponse(res, { ok: true });
|
|
2375
|
+
try {
|
|
2376
|
+
const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
|
|
2377
|
+
const count = data?.unreadCount ?? 0;
|
|
2378
|
+
this.lastKnownNotifCount = count;
|
|
2379
|
+
this.broadcastNotifSSE({ type: "update", unreadCount: count });
|
|
2380
|
+
} catch { /* best effort */ }
|
|
2275
2381
|
} catch (err) {
|
|
2276
2382
|
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
2277
2383
|
}
|
|
@@ -2285,12 +2391,81 @@ export class ViewerServer {
|
|
|
2285
2391
|
try {
|
|
2286
2392
|
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
|
|
2287
2393
|
this.jsonResponse(res, { ok: true });
|
|
2394
|
+
this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
|
|
2288
2395
|
} catch (err) {
|
|
2289
2396
|
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
2290
2397
|
}
|
|
2291
2398
|
});
|
|
2292
2399
|
}
|
|
2293
2400
|
|
|
2401
|
+
private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2402
|
+
res.writeHead(200, {
|
|
2403
|
+
"Content-Type": "text/event-stream",
|
|
2404
|
+
"Cache-Control": "no-cache",
|
|
2405
|
+
Connection: "keep-alive",
|
|
2406
|
+
"Access-Control-Allow-Origin": "*",
|
|
2407
|
+
});
|
|
2408
|
+
res.write("data: {\"type\":\"connected\"}\n\n");
|
|
2409
|
+
this.notifSSEClients.push(res);
|
|
2410
|
+
if (!this.notifPollTimer) this.startNotifPoll();
|
|
2411
|
+
req.on("close", () => {
|
|
2412
|
+
this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
|
|
2413
|
+
if (this.notifSSEClients.length === 0) this.stopNotifPoll();
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
private broadcastNotifSSE(data: Record<string, unknown>): void {
|
|
2418
|
+
const msg = `data: ${JSON.stringify(data)}\n\n`;
|
|
2419
|
+
this.notifSSEClients = this.notifSSEClients.filter((c) => {
|
|
2420
|
+
try { c.write(msg); return true; } catch { return false; }
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
private startNotifPoll(): void {
|
|
2425
|
+
this.stopNotifPoll();
|
|
2426
|
+
const tick = async () => {
|
|
2427
|
+
const hub = this.resolveHubConnection();
|
|
2428
|
+
if (!hub) return;
|
|
2429
|
+
try {
|
|
2430
|
+
const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
|
|
2431
|
+
const count = data?.unreadCount ?? 0;
|
|
2432
|
+
if (count !== this.lastKnownNotifCount) {
|
|
2433
|
+
this.lastKnownNotifCount = count;
|
|
2434
|
+
this.broadcastNotifSSE({ type: "update", unreadCount: count });
|
|
2435
|
+
}
|
|
2436
|
+
} catch { /* ignore */ }
|
|
2437
|
+
};
|
|
2438
|
+
tick();
|
|
2439
|
+
this.notifPollTimer = setInterval(tick, 3000);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
private stopNotifPoll(): void {
|
|
2443
|
+
if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
private startHubHeartbeat(): void {
|
|
2447
|
+
this.stopHubHeartbeat();
|
|
2448
|
+
const sendHeartbeat = async () => {
|
|
2449
|
+
try {
|
|
2450
|
+
const hub = this.resolveHubConnection();
|
|
2451
|
+
if (!hub) {
|
|
2452
|
+
const persisted = this.store.getClientHubConnection();
|
|
2453
|
+
if (persisted?.hubUrl && persisted?.userToken) {
|
|
2454
|
+
await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
|
|
2455
|
+
}
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
|
|
2459
|
+
} catch { /* best-effort */ }
|
|
2460
|
+
};
|
|
2461
|
+
sendHeartbeat();
|
|
2462
|
+
this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
private stopHubHeartbeat(): void {
|
|
2466
|
+
if (this.hubHeartbeatTimer) { clearInterval(this.hubHeartbeatTimer); this.hubHeartbeatTimer = undefined; }
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2294
2469
|
private getLocalIPs(): string[] {
|
|
2295
2470
|
const nets = os.networkInterfaces();
|
|
2296
2471
|
const ips: string[] = [];
|
|
@@ -2343,7 +2518,7 @@ export class ViewerServer {
|
|
|
2343
2518
|
}
|
|
2344
2519
|
|
|
2345
2520
|
private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2346
|
-
this.readBody(req, (body) => {
|
|
2521
|
+
this.readBody(req, async (body) => {
|
|
2347
2522
|
try {
|
|
2348
2523
|
const newCfg = JSON.parse(body);
|
|
2349
2524
|
const cfgPath = this.getOpenClawConfigPath();
|
|
@@ -2366,6 +2541,11 @@ export class ViewerServer {
|
|
|
2366
2541
|
if (!entry.config) entry.config = {};
|
|
2367
2542
|
const config = entry.config as Record<string, unknown>;
|
|
2368
2543
|
|
|
2544
|
+
const oldSharing = config.sharing as Record<string, unknown> | undefined;
|
|
2545
|
+
const oldSharingRole = oldSharing?.role as string | undefined;
|
|
2546
|
+
const oldSharingEnabled = Boolean(oldSharing?.enabled);
|
|
2547
|
+
const oldClientHubAddress = String((oldSharing?.client as Record<string, unknown>)?.hubAddress || "");
|
|
2548
|
+
|
|
2369
2549
|
if (newCfg.embedding) config.embedding = newCfg.embedding;
|
|
2370
2550
|
if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
|
|
2371
2551
|
if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
|
|
@@ -2393,6 +2573,38 @@ export class ViewerServer {
|
|
|
2393
2573
|
} catch {}
|
|
2394
2574
|
}
|
|
2395
2575
|
}
|
|
2576
|
+
|
|
2577
|
+
const newRole = merged.role as string | undefined;
|
|
2578
|
+
const newEnabled = Boolean(merged.enabled);
|
|
2579
|
+
|
|
2580
|
+
// Detect disabling sharing or switching away from hub mode
|
|
2581
|
+
const wasHub = oldSharingEnabled && oldSharingRole === "hub";
|
|
2582
|
+
const isHub = newEnabled && newRole === "hub";
|
|
2583
|
+
if (wasHub && !isHub) {
|
|
2584
|
+
await this.notifyHubShutdown();
|
|
2585
|
+
this.stopHubHeartbeat();
|
|
2586
|
+
this.log.info("Hub shutting down: notified connected clients");
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// Detect disabling sharing or switching away from client mode
|
|
2590
|
+
const wasClient = oldSharingEnabled && oldSharingRole === "client";
|
|
2591
|
+
const isClient = newEnabled && newRole === "client";
|
|
2592
|
+
if (wasClient && !isClient) {
|
|
2593
|
+
this.notifyHubLeave();
|
|
2594
|
+
this.store.clearClientHubConnection();
|
|
2595
|
+
this.log.info("Cleared client hub connection (sharing disabled or role changed)");
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// Detect switching to a different Hub while still in client mode
|
|
2599
|
+
if (wasClient && isClient) {
|
|
2600
|
+
const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
|
|
2601
|
+
if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
|
|
2602
|
+
this.notifyHubLeave();
|
|
2603
|
+
this.store.clearClientHubConnection();
|
|
2604
|
+
this.log.info("Cleared client hub connection (switched to different Hub)");
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2396
2608
|
if (merged.role === "hub") {
|
|
2397
2609
|
merged.client = { hubAddress: "", userToken: "", teamToken: "" };
|
|
2398
2610
|
} else if (merged.role === "client") {
|
|
@@ -2404,6 +2616,14 @@ export class ViewerServer {
|
|
|
2404
2616
|
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
2405
2617
|
fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
|
|
2406
2618
|
this.log.info("Plugin config updated via Viewer");
|
|
2619
|
+
this.stopHubHeartbeat();
|
|
2620
|
+
|
|
2621
|
+
// When switching to client mode, immediately send join request
|
|
2622
|
+
const finalSharing = config.sharing as Record<string, unknown> | undefined;
|
|
2623
|
+
if (finalSharing?.role === "client" && oldSharingRole !== "client") {
|
|
2624
|
+
this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2407
2627
|
this.jsonResponse(res, { ok: true });
|
|
2408
2628
|
} catch (e) {
|
|
2409
2629
|
this.log.warn(`handleSaveConfig error: ${e}`);
|
|
@@ -2413,6 +2633,87 @@ export class ViewerServer {
|
|
|
2413
2633
|
});
|
|
2414
2634
|
}
|
|
2415
2635
|
|
|
2636
|
+
private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<void> {
|
|
2637
|
+
const clientCfg = sharing.client as Record<string, unknown> | undefined;
|
|
2638
|
+
const hubAddress = String(clientCfg?.hubAddress || "");
|
|
2639
|
+
const teamToken = String(clientCfg?.teamToken || "");
|
|
2640
|
+
if (!hubAddress || !teamToken) return;
|
|
2641
|
+
const hubUrl = normalizeHubUrl(hubAddress);
|
|
2642
|
+
const os = await import("os");
|
|
2643
|
+
const nickname = String(clientCfg?.nickname || "");
|
|
2644
|
+
const username = nickname || os.userInfo().username || "user";
|
|
2645
|
+
const hostname = os.hostname() || "unknown";
|
|
2646
|
+
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
2647
|
+
method: "POST",
|
|
2648
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname }),
|
|
2649
|
+
}) as any;
|
|
2650
|
+
this.store.setClientHubConnection({
|
|
2651
|
+
hubUrl,
|
|
2652
|
+
userId: String(result.userId || ""),
|
|
2653
|
+
username,
|
|
2654
|
+
userToken: result.userToken || "",
|
|
2655
|
+
role: "member",
|
|
2656
|
+
connectedAt: Date.now(),
|
|
2657
|
+
});
|
|
2658
|
+
this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
|
|
2659
|
+
if (result.userToken) {
|
|
2660
|
+
this.startHubHeartbeat();
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
private async notifyHubLeave(): Promise<void> {
|
|
2665
|
+
try {
|
|
2666
|
+
const hub = this.resolveHubConnection();
|
|
2667
|
+
if (hub) {
|
|
2668
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2669
|
+
this.log.info("Notified Hub of voluntary leave");
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
const persisted = this.store.getClientHubConnection();
|
|
2673
|
+
if (persisted?.hubUrl && persisted?.userToken) {
|
|
2674
|
+
await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2675
|
+
this.log.info("Notified Hub of voluntary leave (persisted connection)");
|
|
2676
|
+
}
|
|
2677
|
+
} catch (e) {
|
|
2678
|
+
this.log.warn(`Failed to notify Hub of leave: ${e}`);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
private async notifyHubShutdown(): Promise<void> {
|
|
2683
|
+
try {
|
|
2684
|
+
const sharing = this.ctx?.config.sharing;
|
|
2685
|
+
if (!sharing || sharing.role !== "hub") return;
|
|
2686
|
+
const hubPort = sharing.hub?.port ?? 18800;
|
|
2687
|
+
const authPath = path.join(this.dataDir, "hub-auth.json");
|
|
2688
|
+
let adminToken: string | undefined;
|
|
2689
|
+
try {
|
|
2690
|
+
const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
|
|
2691
|
+
adminToken = authData?.bootstrapAdminToken;
|
|
2692
|
+
} catch { return; }
|
|
2693
|
+
if (!adminToken) return;
|
|
2694
|
+
|
|
2695
|
+
const users = this.store.listHubUsers("active");
|
|
2696
|
+
const { v4: uuidv4 } = require("uuid");
|
|
2697
|
+
for (const u of users) {
|
|
2698
|
+
try {
|
|
2699
|
+
this.store.insertHubNotification({
|
|
2700
|
+
id: uuidv4(),
|
|
2701
|
+
userId: u.id,
|
|
2702
|
+
type: "hub_shutdown",
|
|
2703
|
+
resource: "hub",
|
|
2704
|
+
title: "Hub is shutting down",
|
|
2705
|
+
message: "The Hub server is shutting down. You may be disconnected.",
|
|
2706
|
+
});
|
|
2707
|
+
} catch (e) {
|
|
2708
|
+
this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
|
|
2712
|
+
} catch (e) {
|
|
2713
|
+
this.log.warn(`notifyHubShutdown error: ${e}`);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2416
2717
|
private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2417
2718
|
this.readBody(req, async (body) => {
|
|
2418
2719
|
if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
|
|
@@ -2866,7 +3167,7 @@ export class ViewerServer {
|
|
|
2866
3167
|
|
|
2867
3168
|
private getOpenClawHome(): string {
|
|
2868
3169
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
2869
|
-
return path.join(home, ".openclaw");
|
|
3170
|
+
return process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
|
|
2870
3171
|
}
|
|
2871
3172
|
|
|
2872
3173
|
private handleCleanupPolluted(res: http.ServerResponse): void {
|