openclaw-navigator 4.4.0 → 4.6.0
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/cli.mjs +751 -35
- package/mcp.mjs +127 -3
- package/package.json +1 -1
package/cli.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* openclaw-navigator v4.
|
|
4
|
+
* openclaw-navigator v4.1.0
|
|
5
5
|
*
|
|
6
6
|
* One-command bridge + tunnel for the Navigator browser.
|
|
7
7
|
* Starts a local bridge, creates a Cloudflare tunnel automatically,
|
|
8
8
|
* and gives you a 6-digit pairing code. Works on any OS.
|
|
9
|
+
* Auto-installs, builds, and starts the OC Web UI on first run.
|
|
9
10
|
*
|
|
10
11
|
* Usage:
|
|
11
12
|
* npx openclaw-navigator Auto-tunnel (default)
|
|
@@ -15,10 +16,10 @@
|
|
|
15
16
|
|
|
16
17
|
import { spawn } from "node:child_process";
|
|
17
18
|
import { randomUUID } from "node:crypto";
|
|
18
|
-
import { existsSync } from "node:fs";
|
|
19
|
+
import { existsSync, appendFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
20
|
import { createServer, request as httpRequest } from "node:http";
|
|
20
21
|
import { connect as netConnect } from "node:net";
|
|
21
|
-
import { networkInterfaces, hostname, userInfo } from "node:os";
|
|
22
|
+
import { networkInterfaces, hostname, userInfo, homedir } from "node:os";
|
|
22
23
|
import { dirname, join } from "node:path";
|
|
23
24
|
import { fileURLToPath } from "node:url";
|
|
24
25
|
// readline reserved for future interactive mode
|
|
@@ -68,6 +69,209 @@ let bridgePort = 18790;
|
|
|
68
69
|
// Tunnel URL — set once the tunnel is active, exposed via /navigator/status
|
|
69
70
|
let activeTunnelURL = null;
|
|
70
71
|
|
|
72
|
+
// ── Persistent bridge identity ────────────────────────────────────────────
|
|
73
|
+
// Survives restarts — same pairing code + token across tunnel reconnects.
|
|
74
|
+
// Navigator re-resolves the code to get the new tunnel URL automatically.
|
|
75
|
+
const BRIDGE_IDENTITY_PATH = join(homedir(), ".openclaw", "bridge-identity.json");
|
|
76
|
+
|
|
77
|
+
function loadBridgeIdentity() {
|
|
78
|
+
try {
|
|
79
|
+
const data = JSON.parse(readFileSync(BRIDGE_IDENTITY_PATH, "utf8"));
|
|
80
|
+
if (data.pairingCode && data.token) {
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
} catch { /* first run */ }
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function saveBridgeIdentity(code, token, name) {
|
|
88
|
+
mkdirSync(join(homedir(), ".openclaw"), { recursive: true });
|
|
89
|
+
writeFileSync(
|
|
90
|
+
BRIDGE_IDENTITY_PATH,
|
|
91
|
+
JSON.stringify({ pairingCode: code, token, name, createdAt: Date.now() }, null, 2) + "\n",
|
|
92
|
+
"utf8",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── User profiling storage ─────────────────────────────────────────────────
|
|
97
|
+
// Files stored in ~/.openclaw/ as JSONL (append-only) and JSON (overwrite)
|
|
98
|
+
const OPENCLAW_DIR = join(homedir(), ".openclaw");
|
|
99
|
+
const PAGE_VISITS_PATH = join(OPENCLAW_DIR, "page-visits.jsonl");
|
|
100
|
+
const PAGE_SUMMARIES_PATH = join(OPENCLAW_DIR, "page-summaries.jsonl");
|
|
101
|
+
const USER_PROFILE_PATH = join(OPENCLAW_DIR, "user-profile.json");
|
|
102
|
+
|
|
103
|
+
function ensureStorageDir() {
|
|
104
|
+
mkdirSync(OPENCLAW_DIR, { recursive: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function appendJSONL(filePath, record) {
|
|
108
|
+
ensureStorageDir();
|
|
109
|
+
appendFileSync(filePath, JSON.stringify(record) + "\n", "utf8");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readJSONL(filePath, limit = 200) {
|
|
113
|
+
try {
|
|
114
|
+
const lines = readFileSync(filePath, "utf8").trim().split("\n").filter(Boolean);
|
|
115
|
+
const parsed = lines.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
116
|
+
return limit > 0 ? parsed.slice(-limit) : parsed;
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readJSON(filePath) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeJSON(filePath, data) {
|
|
131
|
+
ensureStorageDir();
|
|
132
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── OC Web UI lifecycle ──────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const UI_DIR = join(homedir(), ".openclaw", "ui");
|
|
138
|
+
const UI_REPO = "https://github.com/sandman66666/openclaw-ui.git";
|
|
139
|
+
let uiProcess = null;
|
|
140
|
+
|
|
141
|
+
async function isUIInstalled() {
|
|
142
|
+
return existsSync(join(UI_DIR, "package.json")) && existsSync(join(UI_DIR, ".next"));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function setupUI() {
|
|
146
|
+
const { execSync } = await import("node:child_process");
|
|
147
|
+
|
|
148
|
+
if (existsSync(join(UI_DIR, "package.json"))) {
|
|
149
|
+
info(" UI directory exists, reinstalling...");
|
|
150
|
+
return await buildUI();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
heading("Setting up OC Web UI (first time)");
|
|
154
|
+
info(" Cloning from GitHub...");
|
|
155
|
+
|
|
156
|
+
mkdirSync(join(homedir(), ".openclaw"), { recursive: true });
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
execSync(`git clone --depth 1 ${UI_REPO} "${UI_DIR}"`, {
|
|
160
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
161
|
+
timeout: 60000,
|
|
162
|
+
});
|
|
163
|
+
ok("Repository cloned");
|
|
164
|
+
} catch (err) {
|
|
165
|
+
fail(`Failed to clone UI repo: ${err.message}`);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return await buildUI();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function buildUI() {
|
|
173
|
+
const { execSync } = await import("node:child_process");
|
|
174
|
+
|
|
175
|
+
process.stdout.write(` ${DIM}Installing dependencies (this may take a minute)...${RESET}`);
|
|
176
|
+
try {
|
|
177
|
+
execSync("npm install --production=false", {
|
|
178
|
+
cwd: UI_DIR,
|
|
179
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
180
|
+
timeout: 120000,
|
|
181
|
+
env: { ...process.env, NODE_ENV: "development" },
|
|
182
|
+
});
|
|
183
|
+
process.stdout.write(`\r${" ".repeat(70)}\r`);
|
|
184
|
+
ok("Dependencies installed");
|
|
185
|
+
} catch (err) {
|
|
186
|
+
process.stdout.write(`\r${" ".repeat(70)}\r`);
|
|
187
|
+
fail(`npm install failed: ${err.stderr?.toString()?.slice(-200) || err.message}`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
process.stdout.write(` ${DIM}Building web UI...${RESET}`);
|
|
192
|
+
try {
|
|
193
|
+
execSync("npx next build", {
|
|
194
|
+
cwd: UI_DIR,
|
|
195
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
196
|
+
timeout: 180000,
|
|
197
|
+
});
|
|
198
|
+
process.stdout.write(`\r${" ".repeat(70)}\r`);
|
|
199
|
+
ok("Web UI built successfully");
|
|
200
|
+
return true;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
process.stdout.write(`\r${" ".repeat(70)}\r`);
|
|
203
|
+
fail(`Build failed: ${err.stderr?.toString()?.slice(-200) || err.message}`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function updateUI() {
|
|
209
|
+
const { execSync } = await import("node:child_process");
|
|
210
|
+
|
|
211
|
+
if (!existsSync(join(UI_DIR, ".git"))) {
|
|
212
|
+
warn("UI not installed yet — running full setup instead");
|
|
213
|
+
return await setupUI();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
heading("Updating OC Web UI");
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const result = execSync("git pull --rebase origin main", {
|
|
220
|
+
cwd: UI_DIR,
|
|
221
|
+
encoding: "utf8",
|
|
222
|
+
timeout: 30000,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (result.includes("Already up to date")) {
|
|
226
|
+
ok("Already up to date");
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
ok("Pulled latest changes");
|
|
231
|
+
return await buildUI();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
fail(`Update failed: ${err.message}`);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function startUIServer(port) {
|
|
239
|
+
if (uiProcess) {
|
|
240
|
+
uiProcess.kill();
|
|
241
|
+
uiProcess = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
uiProcess = spawn("npx", ["next", "start", "-p", String(port)], {
|
|
245
|
+
cwd: UI_DIR,
|
|
246
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
247
|
+
env: { ...process.env, PORT: String(port), NODE_ENV: "production" },
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
uiProcess.on("error", (err) => {
|
|
251
|
+
warn(`OC Web UI failed to start: ${err.message}`);
|
|
252
|
+
uiProcess = null;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
uiProcess.on("exit", (code) => {
|
|
256
|
+
if (code !== null && code !== 0) {
|
|
257
|
+
warn(`OC Web UI exited with code ${code}`);
|
|
258
|
+
}
|
|
259
|
+
uiProcess = null;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return new Promise((resolve) => {
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
ok(`OC Web UI starting on port ${port} (PID ${uiProcess?.pid})`);
|
|
265
|
+
resolve(true);
|
|
266
|
+
}, 1500);
|
|
267
|
+
|
|
268
|
+
uiProcess.on("exit", () => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
resolve(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
71
275
|
// Pairing code state
|
|
72
276
|
let pairingCode = null;
|
|
73
277
|
let pairingData = null;
|
|
@@ -202,6 +406,13 @@ function sendJSON(res, status, body) {
|
|
|
202
406
|
res.end(JSON.stringify(body));
|
|
203
407
|
}
|
|
204
408
|
|
|
409
|
+
function validateBridgeAuth(req) {
|
|
410
|
+
const authHeader = req.headers["authorization"];
|
|
411
|
+
if (!authHeader) return false;
|
|
412
|
+
const token = authHeader.replace(/^Bearer\s+/i, "");
|
|
413
|
+
return validTokens.has(token);
|
|
414
|
+
}
|
|
415
|
+
|
|
205
416
|
function handleRequest(req, res) {
|
|
206
417
|
// CORS preflight
|
|
207
418
|
if (req.method === "OPTIONS") {
|
|
@@ -251,6 +462,10 @@ function handleRequest(req, res) {
|
|
|
251
462
|
|
|
252
463
|
// ── GET /navigator/commands ──
|
|
253
464
|
if (req.method === "GET" && path === "/navigator/commands") {
|
|
465
|
+
if (!validateBridgeAuth(req)) {
|
|
466
|
+
sendJSON(res, 401, { ok: false, error: "unauthorized", hint: "Include Authorization: Bearer <token> header" });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
254
469
|
if (!bridgeState.connected) {
|
|
255
470
|
bridgeState.connected = true;
|
|
256
471
|
bridgeState.connectedAt = Date.now();
|
|
@@ -266,6 +481,10 @@ function handleRequest(req, res) {
|
|
|
266
481
|
|
|
267
482
|
// ── POST /navigator/events ──
|
|
268
483
|
if (req.method === "POST" && path === "/navigator/events") {
|
|
484
|
+
if (!validateBridgeAuth(req)) {
|
|
485
|
+
sendJSON(res, 401, { ok: false, error: "unauthorized", hint: "Include Authorization: Bearer <token> header" });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
269
488
|
readBody(req)
|
|
270
489
|
.then((bodyStr) => {
|
|
271
490
|
try {
|
|
@@ -292,6 +511,22 @@ function handleRequest(req, res) {
|
|
|
292
511
|
if (body.type === "page.navigated") {
|
|
293
512
|
bridgeState.currentURL = body.url;
|
|
294
513
|
console.log(` ${DIM}📄 ${body.title || body.url}${RESET}`);
|
|
514
|
+
|
|
515
|
+
// ── Auto-persist page visit for user profiling ──
|
|
516
|
+
// Stores URL, title, and content snippet for later AI summarization
|
|
517
|
+
try {
|
|
518
|
+
const visitRecord = {
|
|
519
|
+
url: body.url,
|
|
520
|
+
title: body.title ?? null,
|
|
521
|
+
content: body.content ? String(body.content).slice(0, 8000) : null,
|
|
522
|
+
tabId: body.tabId ?? null,
|
|
523
|
+
timestamp: Date.now(),
|
|
524
|
+
date: new Date().toISOString().slice(0, 10),
|
|
525
|
+
};
|
|
526
|
+
appendJSONL(PAGE_VISITS_PATH, visitRecord);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
// Silent — never block event flow for storage errors
|
|
529
|
+
}
|
|
295
530
|
}
|
|
296
531
|
|
|
297
532
|
sendJSON(res, 200, { ok: true, received: event.type });
|
|
@@ -333,6 +568,10 @@ function handleRequest(req, res) {
|
|
|
333
568
|
|
|
334
569
|
// ── POST /navigator/command ──
|
|
335
570
|
if (req.method === "POST" && path === "/navigator/command") {
|
|
571
|
+
if (!validateBridgeAuth(req)) {
|
|
572
|
+
sendJSON(res, 401, { ok: false, error: "unauthorized", hint: "Include Authorization: Bearer <token> header" });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
336
575
|
readBody(req)
|
|
337
576
|
.then((bodyStr) => {
|
|
338
577
|
try {
|
|
@@ -479,6 +718,151 @@ function handleRequest(req, res) {
|
|
|
479
718
|
return;
|
|
480
719
|
}
|
|
481
720
|
|
|
721
|
+
// ── GET /navigator/page-visits ──
|
|
722
|
+
// Returns recent page visits (last N, default 100). Supports ?date=YYYY-MM-DD filter.
|
|
723
|
+
if (req.method === "GET" && path === "/navigator/page-visits") {
|
|
724
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
|
|
725
|
+
const dateFilter = url.searchParams.get("date"); // YYYY-MM-DD
|
|
726
|
+
const sinceFilter = url.searchParams.get("since"); // timestamp ms
|
|
727
|
+
let visits = readJSONL(PAGE_VISITS_PATH, 0); // read all
|
|
728
|
+
|
|
729
|
+
if (dateFilter) {
|
|
730
|
+
visits = visits.filter((v) => v.date === dateFilter);
|
|
731
|
+
}
|
|
732
|
+
if (sinceFilter) {
|
|
733
|
+
const sinceTs = parseInt(sinceFilter, 10);
|
|
734
|
+
visits = visits.filter((v) => v.timestamp > sinceTs);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Return newest last, capped at limit
|
|
738
|
+
visits = visits.slice(-limit);
|
|
739
|
+
sendJSON(res, 200, { ok: true, visits, total: visits.length });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ── GET /navigator/page-visits/unsummarized ──
|
|
744
|
+
// Returns page visits that don't have a corresponding summary yet.
|
|
745
|
+
// The agent calls this to find pages that need Haiku summarization.
|
|
746
|
+
if (req.method === "GET" && path === "/navigator/page-visits/unsummarized") {
|
|
747
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
748
|
+
const visits = readJSONL(PAGE_VISITS_PATH, 0);
|
|
749
|
+
const summaries = readJSONL(PAGE_SUMMARIES_PATH, 0);
|
|
750
|
+
const summarizedURLs = new Set(summaries.map((s) => s.url));
|
|
751
|
+
|
|
752
|
+
// De-duplicate by URL (keep latest visit) and exclude already-summarized
|
|
753
|
+
const urlMap = new Map();
|
|
754
|
+
for (const v of visits) {
|
|
755
|
+
if (!summarizedURLs.has(v.url)) {
|
|
756
|
+
urlMap.set(v.url, v);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const unsummarized = [...urlMap.values()].slice(-limit);
|
|
761
|
+
sendJSON(res, 200, { ok: true, visits: unsummarized, total: unsummarized.length });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ── POST /navigator/page-summary ──
|
|
766
|
+
// Agent saves an AI-generated summary for a page visit.
|
|
767
|
+
if (req.method === "POST" && path === "/navigator/page-summary") {
|
|
768
|
+
readBody(req)
|
|
769
|
+
.then((bodyStr) => {
|
|
770
|
+
try {
|
|
771
|
+
const body = JSON.parse(bodyStr);
|
|
772
|
+
if (!body.url || !body.summary) {
|
|
773
|
+
sendJSON(res, 400, { ok: false, error: "Missing 'url' and 'summary' fields" });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const summaryRecord = {
|
|
778
|
+
url: body.url,
|
|
779
|
+
title: body.title ?? null,
|
|
780
|
+
summary: body.summary,
|
|
781
|
+
signals: body.signals ?? null, // { names, interests, services, purchases, intent, ... }
|
|
782
|
+
model: body.model ?? "haiku",
|
|
783
|
+
timestamp: Date.now(),
|
|
784
|
+
date: new Date().toISOString().slice(0, 10),
|
|
785
|
+
};
|
|
786
|
+
appendJSONL(PAGE_SUMMARIES_PATH, summaryRecord);
|
|
787
|
+
sendJSON(res, 200, { ok: true, saved: summaryRecord.url });
|
|
788
|
+
} catch {
|
|
789
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
.catch(() => {
|
|
793
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── GET /navigator/page-summaries ──
|
|
799
|
+
// Returns page summaries, optionally filtered by date.
|
|
800
|
+
if (req.method === "GET" && path === "/navigator/page-summaries") {
|
|
801
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
|
|
802
|
+
const dateFilter = url.searchParams.get("date");
|
|
803
|
+
const sinceFilter = url.searchParams.get("since");
|
|
804
|
+
let summaries = readJSONL(PAGE_SUMMARIES_PATH, 0);
|
|
805
|
+
|
|
806
|
+
if (dateFilter) {
|
|
807
|
+
summaries = summaries.filter((s) => s.date === dateFilter);
|
|
808
|
+
}
|
|
809
|
+
if (sinceFilter) {
|
|
810
|
+
const sinceTs = parseInt(sinceFilter, 10);
|
|
811
|
+
summaries = summaries.filter((s) => s.timestamp > sinceTs);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
summaries = summaries.slice(-limit);
|
|
815
|
+
sendJSON(res, 200, { ok: true, summaries, total: summaries.length });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ── GET /navigator/profile ──
|
|
820
|
+
// Returns the current user profile (created by Opus daily synthesis).
|
|
821
|
+
if (req.method === "GET" && path === "/navigator/profile") {
|
|
822
|
+
const profile = readJSON(USER_PROFILE_PATH);
|
|
823
|
+
if (profile) {
|
|
824
|
+
sendJSON(res, 200, { ok: true, profile });
|
|
825
|
+
} else {
|
|
826
|
+
sendJSON(res, 200, {
|
|
827
|
+
ok: true,
|
|
828
|
+
profile: null,
|
|
829
|
+
hint: "No profile yet. The agent should create one by summarizing page visits.",
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ── POST /navigator/profile ──
|
|
836
|
+
// Agent saves/updates the user profile (Opus daily synthesis result).
|
|
837
|
+
if (req.method === "POST" && path === "/navigator/profile") {
|
|
838
|
+
readBody(req)
|
|
839
|
+
.then((bodyStr) => {
|
|
840
|
+
try {
|
|
841
|
+
const body = JSON.parse(bodyStr);
|
|
842
|
+
if (!body.profile) {
|
|
843
|
+
sendJSON(res, 400, { ok: false, error: "Missing 'profile' field" });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const profileData = {
|
|
848
|
+
profile: body.profile,
|
|
849
|
+
updatedAt: Date.now(),
|
|
850
|
+
updatedDate: new Date().toISOString().slice(0, 10),
|
|
851
|
+
model: body.model ?? "opus",
|
|
852
|
+
version: (readJSON(USER_PROFILE_PATH)?.version ?? 0) + 1,
|
|
853
|
+
};
|
|
854
|
+
writeJSON(USER_PROFILE_PATH, profileData);
|
|
855
|
+
sendJSON(res, 200, { ok: true, version: profileData.version });
|
|
856
|
+
} catch {
|
|
857
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
858
|
+
}
|
|
859
|
+
})
|
|
860
|
+
.catch(() => {
|
|
861
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
862
|
+
});
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
482
866
|
// ── Reverse proxy: /ui/* → OC Web UI (localhost:ocUIPort) ──────────────
|
|
483
867
|
// Strips /ui prefix so /ui/dashboard → localhost:4000/dashboard
|
|
484
868
|
if (path === "/ui" || path.startsWith("/ui/")) {
|
|
@@ -703,6 +1087,13 @@ async function main() {
|
|
|
703
1087
|
let bindHost = "127.0.0.1";
|
|
704
1088
|
let noTunnel = false;
|
|
705
1089
|
let withMcp = false;
|
|
1090
|
+
let pm2Setup = false;
|
|
1091
|
+
let tunnelToken = null; // For named tunnels (Cloudflare)
|
|
1092
|
+
let tunnelHostname = null; // For named tunnels (stable URL)
|
|
1093
|
+
let freshIdentity = false; // --new-code: force new pairing code
|
|
1094
|
+
let setupUIFlag = false;
|
|
1095
|
+
let updateUIFlag = false;
|
|
1096
|
+
let noUIFlag = false;
|
|
706
1097
|
|
|
707
1098
|
for (let i = 0; i < args.length; i++) {
|
|
708
1099
|
if (args[i] === "--port" && args[i + 1]) {
|
|
@@ -723,6 +1114,27 @@ async function main() {
|
|
|
723
1114
|
if (args[i] === "--gateway-port" && args[i + 1]) {
|
|
724
1115
|
ocGatewayPort = parseInt(args[i + 1], 10);
|
|
725
1116
|
}
|
|
1117
|
+
if (args[i] === "--pm2-setup") {
|
|
1118
|
+
pm2Setup = true;
|
|
1119
|
+
}
|
|
1120
|
+
if (args[i] === "--tunnel-token" && args[i + 1]) {
|
|
1121
|
+
tunnelToken = args[i + 1];
|
|
1122
|
+
}
|
|
1123
|
+
if (args[i] === "--tunnel-hostname" && args[i + 1]) {
|
|
1124
|
+
tunnelHostname = args[i + 1];
|
|
1125
|
+
}
|
|
1126
|
+
if (args[i] === "--new-code") {
|
|
1127
|
+
freshIdentity = true;
|
|
1128
|
+
}
|
|
1129
|
+
if (args[i] === "--setup-ui") {
|
|
1130
|
+
setupUIFlag = true;
|
|
1131
|
+
}
|
|
1132
|
+
if (args[i] === "--update-ui") {
|
|
1133
|
+
updateUIFlag = true;
|
|
1134
|
+
}
|
|
1135
|
+
if (args[i] === "--no-ui") {
|
|
1136
|
+
noUIFlag = true;
|
|
1137
|
+
}
|
|
726
1138
|
if (args[i] === "--help" || args[i] === "-h") {
|
|
727
1139
|
console.log(`
|
|
728
1140
|
${BOLD}openclaw-navigator${RESET} — One-command bridge + tunnel for Navigator
|
|
@@ -734,13 +1146,22 @@ ${BOLD}Usage:${RESET}
|
|
|
734
1146
|
npx openclaw-navigator --port 18790 Custom port
|
|
735
1147
|
|
|
736
1148
|
${BOLD}Options:${RESET}
|
|
737
|
-
--port <port>
|
|
738
|
-
--mcp
|
|
739
|
-
--ui-port <port>
|
|
740
|
-
--gateway-port <port>
|
|
741
|
-
--no-tunnel
|
|
742
|
-
--bind <host>
|
|
743
|
-
--
|
|
1149
|
+
--port <port> Bridge server port (default: 18790)
|
|
1150
|
+
--mcp Also start the MCP server (stdio) for OpenClaw agent
|
|
1151
|
+
--ui-port <port> OC web UI port to reverse-proxy (default: 4000)
|
|
1152
|
+
--gateway-port <port> OC gateway port for API + WebSocket (default: 18789)
|
|
1153
|
+
--no-tunnel Skip auto-tunnel, use SSH or LAN instead
|
|
1154
|
+
--bind <host> Bind address (default: 127.0.0.1)
|
|
1155
|
+
--new-code Force a new pairing code (discard saved identity)
|
|
1156
|
+
--setup-ui Force (re)install + build OC Web UI
|
|
1157
|
+
--update-ui Pull latest UI changes and rebuild
|
|
1158
|
+
--no-ui Don't auto-start the web UI
|
|
1159
|
+
--help Show this help
|
|
1160
|
+
|
|
1161
|
+
${BOLD}Stability (recommended for production):${RESET}
|
|
1162
|
+
--pm2-setup Generate PM2 ecosystem.config.cjs and exit
|
|
1163
|
+
--tunnel-token <token> Use a named Cloudflare tunnel (stable URL)
|
|
1164
|
+
--tunnel-hostname <host> Hostname for named tunnel (e.g. nav.yourdomain.com)
|
|
744
1165
|
|
|
745
1166
|
${BOLD}Routing (through Cloudflare tunnel):${RESET}
|
|
746
1167
|
/ui/* → localhost:<ui-port> Web UI (login page, dashboard)
|
|
@@ -766,6 +1187,56 @@ ${BOLD}How it works:${RESET}
|
|
|
766
1187
|
|
|
767
1188
|
bridgePort = port; // Expose for status endpoint
|
|
768
1189
|
|
|
1190
|
+
// ── PM2 setup mode ──────────────────────────────────────────────────────
|
|
1191
|
+
if (pm2Setup) {
|
|
1192
|
+
const { execSync: findNode } = await import("node:child_process");
|
|
1193
|
+
let npxPath;
|
|
1194
|
+
try { npxPath = findNode("which npx", { encoding: "utf8" }).trim(); } catch { npxPath = "npx"; }
|
|
1195
|
+
|
|
1196
|
+
const ecosystemContent = `// PM2 ecosystem config for openclaw-navigator bridge
|
|
1197
|
+
// Generated by: npx openclaw-navigator --pm2-setup
|
|
1198
|
+
// Start with: pm2 start ecosystem.config.cjs
|
|
1199
|
+
module.exports = {
|
|
1200
|
+
apps: [{
|
|
1201
|
+
name: "openclaw-navigator",
|
|
1202
|
+
script: "${npxPath}",
|
|
1203
|
+
args: "openclaw-navigator@latest --mcp --no-ui --port ${port}",
|
|
1204
|
+
cwd: "${homedir()}",
|
|
1205
|
+
autorestart: true,
|
|
1206
|
+
max_restarts: 50,
|
|
1207
|
+
restart_delay: 3000,
|
|
1208
|
+
exp_backoff_restart_delay: 1000,
|
|
1209
|
+
watch: false,
|
|
1210
|
+
env: {
|
|
1211
|
+
OPENCLAW_UI_PORT: "${ocUIPort}",
|
|
1212
|
+
OPENCLAW_GATEWAY_PORT: "${ocGatewayPort}",
|
|
1213
|
+
NODE_ENV: "production",
|
|
1214
|
+
},
|
|
1215
|
+
log_file: "${join(homedir(), ".openclaw/navigator-bridge.log")}",
|
|
1216
|
+
error_file: "${join(homedir(), ".openclaw/navigator-bridge-error.log")}",
|
|
1217
|
+
merge_logs: true,
|
|
1218
|
+
time: true,
|
|
1219
|
+
}]
|
|
1220
|
+
};
|
|
1221
|
+
`;
|
|
1222
|
+
const ecosystemPath = join(process.cwd(), "ecosystem.config.cjs");
|
|
1223
|
+
writeFileSync(ecosystemPath, ecosystemContent, "utf8");
|
|
1224
|
+
ok(`PM2 ecosystem config written to: ${ecosystemPath}`);
|
|
1225
|
+
console.log("");
|
|
1226
|
+
console.log(`${BOLD}To start with PM2:${RESET}`);
|
|
1227
|
+
console.log(` ${CYAN}npm install -g pm2${RESET} ${DIM}(if not installed)${RESET}`);
|
|
1228
|
+
console.log(` ${CYAN}pm2 start ecosystem.config.cjs${RESET}`);
|
|
1229
|
+
console.log(` ${CYAN}pm2 save${RESET} ${DIM}(auto-start on boot)${RESET}`);
|
|
1230
|
+
console.log(` ${CYAN}pm2 startup${RESET} ${DIM}(install system startup hook)${RESET}`);
|
|
1231
|
+
console.log("");
|
|
1232
|
+
console.log(`${BOLD}Useful PM2 commands:${RESET}`);
|
|
1233
|
+
console.log(` ${CYAN}pm2 logs openclaw-navigator${RESET} ${DIM}(view logs)${RESET}`);
|
|
1234
|
+
console.log(` ${CYAN}pm2 restart openclaw-navigator${RESET}`);
|
|
1235
|
+
console.log(` ${CYAN}pm2 stop openclaw-navigator${RESET}`);
|
|
1236
|
+
console.log("");
|
|
1237
|
+
process.exit(0);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
769
1240
|
heading("🧭 Navigator Bridge");
|
|
770
1241
|
info("One-command bridge + tunnel for the Navigator browser\n");
|
|
771
1242
|
|
|
@@ -830,30 +1301,128 @@ ${BOLD}How it works:${RESET}
|
|
|
830
1301
|
|
|
831
1302
|
ok(`Bridge server running on ${bindHost}:${port}`);
|
|
832
1303
|
|
|
833
|
-
// ── Step 2:
|
|
1304
|
+
// ── Step 2: Persistent identity ─────────────────────────────────────
|
|
1305
|
+
// Reuse the same pairing code + token across restarts so Navigator
|
|
1306
|
+
// doesn't need to re-pair. The code resolves to the NEW tunnel URL
|
|
1307
|
+
// via the relay, so everything reconnects automatically.
|
|
834
1308
|
const displayName = hostname().replace(/\.local$/, "");
|
|
1309
|
+
let token;
|
|
1310
|
+
|
|
1311
|
+
const savedIdentity = freshIdentity ? null : loadBridgeIdentity();
|
|
1312
|
+
if (savedIdentity) {
|
|
1313
|
+
pairingCode = savedIdentity.pairingCode;
|
|
1314
|
+
token = savedIdentity.token;
|
|
1315
|
+
validTokens.add(token);
|
|
1316
|
+
ok(`Restored pairing code: ${BOLD}${GREEN}${pairingCode}${RESET} (same as last session)`);
|
|
1317
|
+
} else {
|
|
1318
|
+
token = randomUUID().replace(/-/g, "");
|
|
1319
|
+
validTokens.add(token);
|
|
1320
|
+
pairingCode = generatePairingCode();
|
|
1321
|
+
saveBridgeIdentity(pairingCode, token, displayName);
|
|
1322
|
+
ok(`New pairing code generated: ${BOLD}${GREEN}${pairingCode}${RESET}`);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
835
1325
|
let gatewayURL = `http://localhost:${port}`;
|
|
836
1326
|
let tunnelURL = null;
|
|
837
1327
|
let tunnelProcess = null;
|
|
1328
|
+
let cloudflaredBin = null;
|
|
1329
|
+
pairingData = { url: gatewayURL, token, name: displayName };
|
|
1330
|
+
|
|
1331
|
+
// ── Step 3: Set up connectivity ───────────────────────────────────────
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Start (or restart) the tunnel. Updates all references to the new URL
|
|
1335
|
+
* and re-registers with the relay so Navigator can re-resolve the code.
|
|
1336
|
+
*/
|
|
1337
|
+
let tunnelReconnectAttempts = 0;
|
|
1338
|
+
const MAX_TUNNEL_RECONNECT_DELAY = 60_000; // cap at 60s
|
|
1339
|
+
|
|
1340
|
+
async function startOrReconnectTunnel() {
|
|
1341
|
+
if (!cloudflaredBin) return null;
|
|
1342
|
+
|
|
1343
|
+
// Named tunnel mode (stable URL, no reconnect gymnastics needed)
|
|
1344
|
+
if (tunnelToken) {
|
|
1345
|
+
const tunnelArgs = ["tunnel", "run", "--token", tunnelToken];
|
|
1346
|
+
if (tunnelHostname) {
|
|
1347
|
+
tunnelArgs.push("--url", `http://localhost:${port}`);
|
|
1348
|
+
}
|
|
1349
|
+
const child = spawn(cloudflaredBin, tunnelArgs, {
|
|
1350
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
child.on("exit", (code) => {
|
|
1354
|
+
warn(`Named tunnel exited (code ${code}) — restarting in 5s...`);
|
|
1355
|
+
tunnelProcess = null;
|
|
1356
|
+
setTimeout(startOrReconnectTunnel, 5000);
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
tunnelProcess = child;
|
|
1360
|
+
const namedURL = tunnelHostname ? `https://${tunnelHostname}` : null;
|
|
1361
|
+
if (namedURL) {
|
|
1362
|
+
tunnelURL = namedURL;
|
|
1363
|
+
activeTunnelURL = namedURL;
|
|
1364
|
+
gatewayURL = namedURL;
|
|
1365
|
+
pairingData.url = namedURL;
|
|
1366
|
+
ok(`Named tunnel active: ${CYAN}${namedURL}${RESET}`);
|
|
1367
|
+
}
|
|
1368
|
+
return child;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Quick Tunnel mode — URL changes on every start
|
|
1372
|
+
const result = await startTunnel(cloudflaredBin, port);
|
|
1373
|
+
if (!result) {
|
|
1374
|
+
const delay = Math.min(2000 * Math.pow(2, tunnelReconnectAttempts), MAX_TUNNEL_RECONNECT_DELAY);
|
|
1375
|
+
tunnelReconnectAttempts++;
|
|
1376
|
+
warn(`Tunnel failed — retrying in ${Math.round(delay / 1000)}s (attempt ${tunnelReconnectAttempts})...`);
|
|
1377
|
+
setTimeout(startOrReconnectTunnel, delay);
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
tunnelReconnectAttempts = 0; // reset on success
|
|
1382
|
+
tunnelURL = result.url;
|
|
1383
|
+
activeTunnelURL = tunnelURL;
|
|
1384
|
+
gatewayURL = tunnelURL;
|
|
1385
|
+
pairingData.url = tunnelURL;
|
|
1386
|
+
tunnelProcess = result.process;
|
|
1387
|
+
|
|
1388
|
+
ok(`Tunnel active: ${CYAN}${tunnelURL}${RESET}`);
|
|
1389
|
+
|
|
1390
|
+
// Re-register with relay using the SAME pairing code
|
|
1391
|
+
const relayOk = await registerWithRelay(pairingCode, gatewayURL, token, displayName);
|
|
1392
|
+
if (relayOk) {
|
|
1393
|
+
ok("Code re-registered with relay — Navigator will auto-reconnect");
|
|
1394
|
+
} else {
|
|
1395
|
+
warn("Relay unavailable — Navigator may need manual reconnect");
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Monitor tunnel process — restart on crash
|
|
1399
|
+
result.process.on("exit", (code) => {
|
|
1400
|
+
if (code !== null && code !== 0) {
|
|
1401
|
+
warn(`Tunnel crashed (code ${code}) — auto-reconnecting...`);
|
|
1402
|
+
} else {
|
|
1403
|
+
info("Tunnel process exited — reconnecting...");
|
|
1404
|
+
}
|
|
1405
|
+
tunnelProcess = null;
|
|
1406
|
+
activeTunnelURL = null;
|
|
1407
|
+
// Exponential backoff restart
|
|
1408
|
+
const delay = Math.min(3000 * Math.pow(2, tunnelReconnectAttempts), MAX_TUNNEL_RECONNECT_DELAY);
|
|
1409
|
+
tunnelReconnectAttempts++;
|
|
1410
|
+
setTimeout(startOrReconnectTunnel, delay);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
return result;
|
|
1414
|
+
}
|
|
838
1415
|
|
|
839
1416
|
if (!noTunnel) {
|
|
840
1417
|
// ── Auto-tunnel mode (default) ──────────────────────────────────
|
|
841
1418
|
process.stdout.write(` ${DIM}Setting up tunnel...${RESET}`);
|
|
842
|
-
|
|
1419
|
+
cloudflaredBin = await ensureCloudflared();
|
|
843
1420
|
|
|
844
1421
|
if (cloudflaredBin) {
|
|
845
|
-
const result = await
|
|
1422
|
+
const result = await startOrReconnectTunnel();
|
|
846
1423
|
process.stdout.write(`\r${" ".repeat(60)}\r`);
|
|
847
1424
|
|
|
848
|
-
if (result) {
|
|
849
|
-
tunnelURL = result.url;
|
|
850
|
-
activeTunnelURL = tunnelURL; // Expose via /navigator/status
|
|
851
|
-
tunnelProcess = result.process;
|
|
852
|
-
gatewayURL = tunnelURL; // Use tunnel URL as the gateway URL
|
|
853
|
-
|
|
854
|
-
ok(`Tunnel active: ${CYAN}${tunnelURL}${RESET}`);
|
|
855
|
-
} else {
|
|
856
|
-
process.stdout.write(`\r${" ".repeat(60)}\r`);
|
|
1425
|
+
if (!result && !tunnelToken) {
|
|
857
1426
|
warn("Tunnel failed to start. Falling back to local-only mode.");
|
|
858
1427
|
warn("Navigator must be on the same machine, or use --no-tunnel + SSH.");
|
|
859
1428
|
}
|
|
@@ -861,6 +1430,11 @@ ${BOLD}How it works:${RESET}
|
|
|
861
1430
|
process.stdout.write(`\r${" ".repeat(60)}\r`);
|
|
862
1431
|
warn("Could not install tunnel tool. Falling back to local-only mode.");
|
|
863
1432
|
}
|
|
1433
|
+
|
|
1434
|
+
// Show named tunnel tip if using Quick Tunnel
|
|
1435
|
+
if (tunnelURL && !tunnelToken) {
|
|
1436
|
+
info(` ${DIM}💡 For a stable URL that never changes: npx openclaw-navigator --help${RESET}`);
|
|
1437
|
+
}
|
|
864
1438
|
} else {
|
|
865
1439
|
// ── No-tunnel mode (SSH/LAN) ────────────────────────────────────
|
|
866
1440
|
const tailscaleIP = getTailscaleIP();
|
|
@@ -880,27 +1454,40 @@ ${BOLD}How it works:${RESET}
|
|
|
880
1454
|
console.log("");
|
|
881
1455
|
}
|
|
882
1456
|
|
|
883
|
-
// ──
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1457
|
+
// ── OC Web UI: auto-setup + start ─────────────────────────────────────
|
|
1458
|
+
if (!noUIFlag) {
|
|
1459
|
+
if (setupUIFlag) {
|
|
1460
|
+
await setupUI();
|
|
1461
|
+
} else if (updateUIFlag) {
|
|
1462
|
+
await updateUI();
|
|
1463
|
+
}
|
|
888
1464
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1465
|
+
if (await isUIInstalled()) {
|
|
1466
|
+
await startUIServer(ocUIPort);
|
|
1467
|
+
} else if (!setupUIFlag && !noUIFlag) {
|
|
1468
|
+
heading("OC Web UI not found — setting up automatically");
|
|
1469
|
+
const setupOk = await setupUI();
|
|
1470
|
+
if (setupOk) {
|
|
1471
|
+
await startUIServer(ocUIPort);
|
|
1472
|
+
} else {
|
|
1473
|
+
warn("Web UI setup failed — you can retry with: npx openclaw-navigator --setup-ui");
|
|
1474
|
+
warn("The bridge will still work, but /ui/* won't serve the dashboard");
|
|
1475
|
+
}
|
|
896
1476
|
}
|
|
897
1477
|
}
|
|
898
1478
|
|
|
1479
|
+
// ── Step 4: Register initial pairing code with relay ────────────────
|
|
1480
|
+
if (tunnelURL && !tunnelToken) {
|
|
1481
|
+
// Already registered inside startOrReconnectTunnel()
|
|
1482
|
+
} else if (!tunnelURL) {
|
|
1483
|
+
// Local mode — register code for local resolution
|
|
1484
|
+
}
|
|
1485
|
+
|
|
899
1486
|
// ── Step 5: Show connection info ──────────────────────────────────────
|
|
900
1487
|
showPairingCode(pairingCode);
|
|
901
1488
|
|
|
902
1489
|
// Deep link (always show as fallback)
|
|
903
|
-
const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
|
|
1490
|
+
const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}&code=${pairingCode}`;
|
|
904
1491
|
|
|
905
1492
|
if (tunnelURL) {
|
|
906
1493
|
console.log(` ${DIM}Or paste the deep link in Navigator's address bar:${RESET}`);
|
|
@@ -915,6 +1502,7 @@ ${BOLD}How it works:${RESET}
|
|
|
915
1502
|
info(` URL: ${gatewayURL}`);
|
|
916
1503
|
info(` Token: ${token}`);
|
|
917
1504
|
}
|
|
1505
|
+
info(` Pairing code: ${pairingCode} (persisted across restarts)`);
|
|
918
1506
|
|
|
919
1507
|
// ── Show OC Web UI access + routing info ─────────────────────────────
|
|
920
1508
|
const uiURL = tunnelURL ? `${tunnelURL}/ui/` : `http://localhost:${port}/ui/`;
|
|
@@ -1154,6 +1742,130 @@ ${BOLD}How it works:${RESET}
|
|
|
1154
1742
|
writeFileSync(skillPath, skillContent, "utf8");
|
|
1155
1743
|
ok("Installed navigator-bridge skill for OC agent");
|
|
1156
1744
|
info(` Skill: ${skillPath}`);
|
|
1745
|
+
|
|
1746
|
+
// ── Install user-profiler skill ────────────────────────────────────
|
|
1747
|
+
// Instructs the OC agent to build a user profile from browsing data.
|
|
1748
|
+
const profilerSkillLocations = [
|
|
1749
|
+
"/opt/homebrew/lib/node_modules/openclaw/skills/user-profiler",
|
|
1750
|
+
join(homedir(), ".openclaw/skills/user-profiler"),
|
|
1751
|
+
];
|
|
1752
|
+
let profilerSkillDir = profilerSkillLocations[0];
|
|
1753
|
+
for (const loc of profilerSkillLocations) {
|
|
1754
|
+
const parent = dirname(loc);
|
|
1755
|
+
if (existsSync(parent)) {
|
|
1756
|
+
profilerSkillDir = loc;
|
|
1757
|
+
break;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
const profilerSkillPath = join(profilerSkillDir, "SKILL.md");
|
|
1761
|
+
const profilerSkillContent = [
|
|
1762
|
+
"---",
|
|
1763
|
+
"name: user-profiler",
|
|
1764
|
+
"description: Build and maintain a rich user profile from Navigator browsing data. Summarizes every page visit with Haiku, creates daily profile synthesis with Opus.",
|
|
1765
|
+
"metadata:",
|
|
1766
|
+
' { "openclaw": { "emoji": "🧠", "schedule": { "summarize": "every_10_minutes", "profile": "daily" } } }',
|
|
1767
|
+
"---",
|
|
1768
|
+
"",
|
|
1769
|
+
"# User Profiler — Browsing Intelligence System",
|
|
1770
|
+
"",
|
|
1771
|
+
"You have access to the user's browsing activity through Navigator. Your job is to build and maintain a comprehensive user profile by analyzing what they browse.",
|
|
1772
|
+
"",
|
|
1773
|
+
"## How It Works",
|
|
1774
|
+
"",
|
|
1775
|
+
"### 1. Page Visit Storage (Automatic)",
|
|
1776
|
+
"Every page the user visits is automatically stored with URL, title, and content.",
|
|
1777
|
+
"You don't need to do anything for this — it happens in the bridge.",
|
|
1778
|
+
"",
|
|
1779
|
+
"### 2. Page Summarization (You Do This — Use Haiku 4.6)",
|
|
1780
|
+
"",
|
|
1781
|
+
"Periodically (every ~10 minutes or when prompted), check for unsummarized page visits:",
|
|
1782
|
+
"",
|
|
1783
|
+
BT + "bash",
|
|
1784
|
+
"# Get pages that haven't been summarized yet",
|
|
1785
|
+
"mcporter call navigator.navigator_get_page_visits unsummarized=true limit=20",
|
|
1786
|
+
BT,
|
|
1787
|
+
"",
|
|
1788
|
+
"For each unsummarized page, use **Claude Haiku 4.6** to extract a concise summary with these signals:",
|
|
1789
|
+
"",
|
|
1790
|
+
"- **Names**: People, companies, brands mentioned",
|
|
1791
|
+
"- **Interests**: Topics, hobbies, categories the user is exploring",
|
|
1792
|
+
"- **Services**: Tools, platforms, SaaS products the user uses or is evaluating",
|
|
1793
|
+
"- **Purchases**: Things bought, wishlisted, or actively shopping for",
|
|
1794
|
+
"- **Intent**: What the user appears to be trying to accomplish",
|
|
1795
|
+
"- **Topics**: Key themes, industries, domains",
|
|
1796
|
+
"- **Sentiment**: Positive/negative signals about products or services",
|
|
1797
|
+
"",
|
|
1798
|
+
"Save each summary:",
|
|
1799
|
+
"",
|
|
1800
|
+
BT + "bash",
|
|
1801
|
+
"mcporter call navigator.navigator_save_page_summary \\",
|
|
1802
|
+
' url="<page_url>" \\',
|
|
1803
|
+
' title="<page_title>" \\',
|
|
1804
|
+
' summary="<haiku_generated_summary>" \\',
|
|
1805
|
+
" signals='{\"names\":[],\"interests\":[],\"services\":[],\"purchases\":[],\"intent\":[],\"topics\":[]}'",
|
|
1806
|
+
BT,
|
|
1807
|
+
"",
|
|
1808
|
+
"### 3. Daily Profile Synthesis (You Do This — Use Opus 4.6)",
|
|
1809
|
+
"",
|
|
1810
|
+
"Once daily (or when prompted), synthesize a comprehensive user profile:",
|
|
1811
|
+
"",
|
|
1812
|
+
"1. **Read the current profile** (may be null if first time):",
|
|
1813
|
+
"",
|
|
1814
|
+
BT + "bash",
|
|
1815
|
+
"mcporter call navigator.navigator_get_user_profile",
|
|
1816
|
+
BT,
|
|
1817
|
+
"",
|
|
1818
|
+
"2. **Read today's page summaries** (and optionally recent days):",
|
|
1819
|
+
"",
|
|
1820
|
+
BT + "bash",
|
|
1821
|
+
"mcporter call navigator.navigator_get_page_summaries date=$(date +%Y-%m-%d)",
|
|
1822
|
+
BT,
|
|
1823
|
+
"",
|
|
1824
|
+
"3. **Use Opus 4.6** to merge the previous profile with new data. The profile should:",
|
|
1825
|
+
" - Reinforce patterns that appear repeatedly (high confidence)",
|
|
1826
|
+
" - Add new observations from today's browsing",
|
|
1827
|
+
" - Decay stale signals that haven't been reinforced",
|
|
1828
|
+
" - Include confidence levels (low/medium/high) for each signal",
|
|
1829
|
+
" - Be structured and machine-readable",
|
|
1830
|
+
"",
|
|
1831
|
+
"4. **Save the updated profile:**",
|
|
1832
|
+
"",
|
|
1833
|
+
BT + "bash",
|
|
1834
|
+
"mcporter call navigator.navigator_save_user_profile \\",
|
|
1835
|
+
" profile='{",
|
|
1836
|
+
' "summary": "Overall description of the user",',
|
|
1837
|
+
' "interests": [{"topic": "...", "confidence": "high", "lastSeen": "2025-01-15"}],',
|
|
1838
|
+
' "services": [{"name": "...", "usage": "active|evaluating|former"}],',
|
|
1839
|
+
' "purchases": [{"item": "...", "status": "bought|considering|wishlisted"}],',
|
|
1840
|
+
' "patterns": ["Browses tech news in morning", "Shops on weekends"],',
|
|
1841
|
+
' "demographics": {"profession": "...", "location_hints": []},',
|
|
1842
|
+
' "relationships": [{"name": "...", "context": "colleague|friend|vendor"}],',
|
|
1843
|
+
' "preferences": {"brands": [], "categories": []},',
|
|
1844
|
+
' "lastUpdated": "2025-01-15",',
|
|
1845
|
+
' "version": 1',
|
|
1846
|
+
" }'",
|
|
1847
|
+
BT,
|
|
1848
|
+
"",
|
|
1849
|
+
"## Profile Quality Guidelines",
|
|
1850
|
+
"",
|
|
1851
|
+
"- **Be smart about noise**: Ignore login pages, error pages, redirects",
|
|
1852
|
+
"- **Respect privacy**: Don't store passwords, tokens, or PII like SSNs/credit cards",
|
|
1853
|
+
"- **Extract signal from noise**: A visit to \"Nike Air Max\" tells you about shopping interest, not just a URL",
|
|
1854
|
+
"- **Cross-reference**: If user visits Stripe docs AND Vercel, they're likely a developer building a SaaS",
|
|
1855
|
+
"- **Temporal awareness**: Morning habits vs evening habits, weekday vs weekend",
|
|
1856
|
+
"- **Don't hallucinate**: Only include signals backed by actual browsing data",
|
|
1857
|
+
"",
|
|
1858
|
+
"## When to Run",
|
|
1859
|
+
"",
|
|
1860
|
+
"- **Summarization**: Every 10 minutes while the user is actively browsing",
|
|
1861
|
+
"- **Profile synthesis**: Once daily, preferably at end of day",
|
|
1862
|
+
"- **On demand**: User can ask \"update my profile\" or \"what do you know about me\"",
|
|
1863
|
+
"",
|
|
1864
|
+
].join("\n");
|
|
1865
|
+
mkdirSync(profilerSkillDir, { recursive: true });
|
|
1866
|
+
writeFileSync(profilerSkillPath, profilerSkillContent, "utf8");
|
|
1867
|
+
ok("Installed user-profiler skill for OC agent");
|
|
1868
|
+
info(` Skill: ${profilerSkillPath}`);
|
|
1157
1869
|
} catch (err) {
|
|
1158
1870
|
warn(`mcporter registration failed: ${err.message}`);
|
|
1159
1871
|
info(" You can manually configure mcporter for Navigator MCP");
|
|
@@ -1248,6 +1960,10 @@ ${BOLD}How it works:${RESET}
|
|
|
1248
1960
|
// ── Graceful shutdown ─────────────────────────────────────────────────
|
|
1249
1961
|
const shutdown = () => {
|
|
1250
1962
|
console.log(`\n${DIM}Shutting down bridge...${RESET}`);
|
|
1963
|
+
if (uiProcess) {
|
|
1964
|
+
uiProcess.kill();
|
|
1965
|
+
uiProcess = null;
|
|
1966
|
+
}
|
|
1251
1967
|
if (mcpProcess) {
|
|
1252
1968
|
mcpProcess.kill();
|
|
1253
1969
|
}
|
package/mcp.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* openclaw-navigator MCP server v4.
|
|
4
|
+
* openclaw-navigator MCP server v4.4.0
|
|
5
5
|
*
|
|
6
6
|
* Exposes the Navigator bridge HTTP API as MCP tools so the OpenClaw agent
|
|
7
7
|
* can control the browser natively via its tool schema.
|
|
@@ -149,7 +149,7 @@ async function sendCommand(command, payload, poll) {
|
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
// ── Tool definitions (
|
|
152
|
+
// ── Tool definitions (21 tools: 16 browser + 5 profiling) ────────────────
|
|
153
153
|
|
|
154
154
|
const TOOLS = [
|
|
155
155
|
// ── Direct HTTP ──
|
|
@@ -338,6 +338,92 @@ const TOOLS = [
|
|
|
338
338
|
},
|
|
339
339
|
},
|
|
340
340
|
},
|
|
341
|
+
|
|
342
|
+
// ── User Profiling Tools ──
|
|
343
|
+
{
|
|
344
|
+
name: "navigator_get_page_visits",
|
|
345
|
+
description:
|
|
346
|
+
"Get recent page visits from the user's browsing history. Each visit includes URL, title, content snippet, and timestamp. Use 'unsummarized=true' to only get visits that haven't been summarized yet (for Haiku batch processing). Supports date filter (YYYY-MM-DD) and limit.",
|
|
347
|
+
inputSchema: {
|
|
348
|
+
type: "object",
|
|
349
|
+
properties: {
|
|
350
|
+
limit: {
|
|
351
|
+
type: "number",
|
|
352
|
+
description: "Maximum visits to return (default: 50)",
|
|
353
|
+
},
|
|
354
|
+
date: {
|
|
355
|
+
type: "string",
|
|
356
|
+
description: "Filter by date (YYYY-MM-DD)",
|
|
357
|
+
},
|
|
358
|
+
unsummarized: {
|
|
359
|
+
type: "boolean",
|
|
360
|
+
description: "If true, only return visits that haven't been summarized yet",
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: "navigator_save_page_summary",
|
|
367
|
+
description:
|
|
368
|
+
"Save an AI-generated summary for a page visit. Call this after using Haiku to summarize a page. Include the extracted signals: names mentioned, interests, services used, purchases/intent, and any other useful profile data.",
|
|
369
|
+
inputSchema: {
|
|
370
|
+
type: "object",
|
|
371
|
+
properties: {
|
|
372
|
+
url: { type: "string", description: "URL of the page that was summarized" },
|
|
373
|
+
title: { type: "string", description: "Page title" },
|
|
374
|
+
summary: {
|
|
375
|
+
type: "string",
|
|
376
|
+
description: "AI-generated summary of the page content",
|
|
377
|
+
},
|
|
378
|
+
signals: {
|
|
379
|
+
type: "object",
|
|
380
|
+
description:
|
|
381
|
+
"Extracted profile signals: { names: [], interests: [], services: [], purchases: [], intent: [], topics: [], other: {} }",
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
required: ["url", "summary"],
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "navigator_get_page_summaries",
|
|
389
|
+
description:
|
|
390
|
+
"Get AI-generated page summaries. Use this to review what the user has been browsing and the extracted signals. Supports date filter.",
|
|
391
|
+
inputSchema: {
|
|
392
|
+
type: "object",
|
|
393
|
+
properties: {
|
|
394
|
+
limit: {
|
|
395
|
+
type: "number",
|
|
396
|
+
description: "Maximum summaries to return (default: 100)",
|
|
397
|
+
},
|
|
398
|
+
date: {
|
|
399
|
+
type: "string",
|
|
400
|
+
description: "Filter by date (YYYY-MM-DD)",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: "navigator_get_user_profile",
|
|
407
|
+
description:
|
|
408
|
+
"Get the current user profile — a comprehensive, AI-synthesized understanding of the user based on their browsing patterns, interests, services, purchases, and behavior. Returns null if no profile exists yet.",
|
|
409
|
+
inputSchema: { type: "object", properties: {} },
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "navigator_save_user_profile",
|
|
413
|
+
description:
|
|
414
|
+
"Save or update the user profile. Call this after synthesizing a new profile from page summaries. The profile should merge previous profile data with new observations to build an increasingly accurate picture of the user.",
|
|
415
|
+
inputSchema: {
|
|
416
|
+
type: "object",
|
|
417
|
+
properties: {
|
|
418
|
+
profile: {
|
|
419
|
+
type: "object",
|
|
420
|
+
description:
|
|
421
|
+
"The complete user profile object. Should include: { summary: 'overall description', interests: [], services: [], purchases: [], intent: [], patterns: [], demographics: {}, preferences: {}, relationships: [], ... }",
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
required: ["profile"],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
341
427
|
];
|
|
342
428
|
|
|
343
429
|
// ── Tool handler dispatch ─────────────────────────────────────────────────
|
|
@@ -460,6 +546,44 @@ async function handleTool(name, args) {
|
|
|
460
546
|
),
|
|
461
547
|
);
|
|
462
548
|
|
|
549
|
+
// ── User Profiling Tools ──
|
|
550
|
+
case "navigator_get_page_visits": {
|
|
551
|
+
const limit = args.limit ?? 50;
|
|
552
|
+
const unsummarized = args.unsummarized ?? false;
|
|
553
|
+
const endpoint = unsummarized
|
|
554
|
+
? `/navigator/page-visits/unsummarized?limit=${limit}`
|
|
555
|
+
: `/navigator/page-visits?limit=${limit}${args.date ? `&date=${args.date}` : ""}`;
|
|
556
|
+
return jsonResult(await bridgeGet(endpoint));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
case "navigator_save_page_summary":
|
|
560
|
+
return jsonResult(
|
|
561
|
+
await bridgePost("/navigator/page-summary", {
|
|
562
|
+
url: args.url,
|
|
563
|
+
title: args.title,
|
|
564
|
+
summary: args.summary,
|
|
565
|
+
signals: args.signals,
|
|
566
|
+
model: "haiku-4.6",
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
case "navigator_get_page_summaries": {
|
|
571
|
+
const limit = args.limit ?? 100;
|
|
572
|
+
const dateParam = args.date ? `&date=${args.date}` : "";
|
|
573
|
+
return jsonResult(await bridgeGet(`/navigator/page-summaries?limit=${limit}${dateParam}`));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
case "navigator_get_user_profile":
|
|
577
|
+
return jsonResult(await bridgeGet("/navigator/profile"));
|
|
578
|
+
|
|
579
|
+
case "navigator_save_user_profile":
|
|
580
|
+
return jsonResult(
|
|
581
|
+
await bridgePost("/navigator/profile", {
|
|
582
|
+
profile: args.profile,
|
|
583
|
+
model: "opus-4.6",
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
|
|
463
587
|
default:
|
|
464
588
|
return errorResult(`Unknown tool: ${name}`);
|
|
465
589
|
}
|
|
@@ -472,7 +596,7 @@ async function handleTool(name, args) {
|
|
|
472
596
|
// ── MCP server wiring ─────────────────────────────────────────────────────
|
|
473
597
|
|
|
474
598
|
const server = new Server(
|
|
475
|
-
{ name: "openclaw-navigator", version: "4.
|
|
599
|
+
{ name: "openclaw-navigator", version: "4.4.0" },
|
|
476
600
|
{ capabilities: { tools: {} } },
|
|
477
601
|
);
|
|
478
602
|
|