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.
Files changed (3) hide show
  1. package/cli.mjs +751 -35
  2. package/mcp.mjs +127 -3
  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.0.0
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> Bridge server port (default: 18790)
738
- --mcp Also start the MCP server (stdio) for OpenClaw agent
739
- --ui-port <port> OC web UI port to reverse-proxy (default: 4000)
740
- --gateway-port <port> OC gateway port for API + WebSocket (default: 18789)
741
- --no-tunnel Skip auto-tunnel, use SSH or LAN instead
742
- --bind <host> Bind address (default: 127.0.0.1)
743
- --help Show this help
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: Set up connectivity ───────────────────────────────────────
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
- const cloudflaredBin = await ensureCloudflared();
1419
+ cloudflaredBin = await ensureCloudflared();
843
1420
 
844
1421
  if (cloudflaredBin) {
845
- const result = await startTunnel(cloudflaredBin, port);
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
- // ── Step 3: Generate pairing token + code ─────────────────────────────
884
- const token = randomUUID().replace(/-/g, "");
885
- validTokens.add(token);
886
- pairingCode = generatePairingCode();
887
- pairingData = { url: gatewayURL, token, name: displayName };
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
- // ── Step 4: Register with relay (for remote pairing) ────────────────
890
- if (tunnelURL) {
891
- const relayOk = await registerWithRelay(pairingCode, gatewayURL, token, displayName);
892
- if (relayOk) {
893
- ok("Code registered with relay — works from any device");
894
- } else {
895
- warn("Relay unavailable — code works on same machine only");
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.3.0
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 (15 tools) ───────────────────────────────────────────
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.3.0" },
599
+ { name: "openclaw-navigator", version: "4.4.0" },
476
600
  { capabilities: { tools: {} } },
477
601
  );
478
602
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "4.4.0",
3
+ "version": "4.6.0",
4
4
  "description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
5
5
  "keywords": [
6
6
  "browser",