yunti-browser-runtime 0.1.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/LICENSE +21 -0
- package/README.md +256 -0
- package/bin/yunti-browser-runtime.js +86 -0
- package/docs/EXECUTION_PLAN.md +1278 -0
- package/docs/INSTALL.md +205 -0
- package/docs/PROJECT_INTENT.md +44 -0
- package/docs/PROJECT_STATUS.md +263 -0
- package/docs/PUBLISHING_BLOCKERS.md +110 -0
- package/docs/RELEASE.md +148 -0
- package/docs/ROADMAP.md +42 -0
- package/docs/SECURITY.md +56 -0
- package/docs/TOOL_GUIDE.md +69 -0
- package/extension/background.js +55 -0
- package/extension/cdp.js +582 -0
- package/extension/content.css +9 -0
- package/extension/content.js +946 -0
- package/extension/manifest.json +35 -0
- package/extension/network-monitor.js +140 -0
- package/extension/popup.css +66 -0
- package/extension/popup.html +30 -0
- package/extension/popup.js +55 -0
- package/extension/session-manager.js +332 -0
- package/extension/settings.js +94 -0
- package/extension/tool-handlers.js +1158 -0
- package/lib/runtime-paths.js +39 -0
- package/mcp/bridge-hub.js +604 -0
- package/mcp/http-server.js +326 -0
- package/mcp/json-rpc.js +35 -0
- package/mcp/memory.js +126 -0
- package/mcp/redaction.js +94 -0
- package/mcp/server.js +269 -0
- package/mcp/tools.js +1092 -0
- package/package.json +60 -0
- package/scripts/check-package-metadata.js +131 -0
- package/scripts/check-published-package.js +113 -0
- package/scripts/doctor.js +163 -0
- package/scripts/package-extension.js +137 -0
- package/scripts/print-config.js +116 -0
- package/scripts/release-check.js +472 -0
- package/skills/yunti-browser-runtime/SKILL.md +77 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
export function yuntiHome() {
|
|
5
|
+
return expandHome(process.env.YUNTI_HOME || "~/.yunti_agent")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function yuntiMemoryDir(userId = "") {
|
|
9
|
+
const userSegment = yuntiUserIdPathSegment(userId)
|
|
10
|
+
if (userSegment) {
|
|
11
|
+
return join(yuntiHome(), "users", userSegment, "memory")
|
|
12
|
+
}
|
|
13
|
+
return expandHome(
|
|
14
|
+
process.env.YUNTI_BROWSER_DATA_DIR || join(yuntiHome(), "memory")
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function yuntiMemoryFile(userId = "") {
|
|
19
|
+
return join(yuntiMemoryDir(userId), "learning-memory.json")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function yuntiUserIdPathSegment(value) {
|
|
23
|
+
const userId = String(value || "").trim()
|
|
24
|
+
if (!userId || userId === "anonymous") return ""
|
|
25
|
+
return encodeURIComponent(userId)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function yuntiVendorRoot() {
|
|
29
|
+
return expandHome(
|
|
30
|
+
process.env.YUNTI_VENDOR_DIR || join(yuntiHome(), "vendor")
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function expandHome(value) {
|
|
35
|
+
const raw = String(value || "").trim()
|
|
36
|
+
if (!raw || raw === "~") return homedir()
|
|
37
|
+
if (raw.startsWith("~/")) return join(homedir(), raw.slice(2))
|
|
38
|
+
return resolve(raw)
|
|
39
|
+
}
|
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { forgetLearningMemory, getLearningMemory, rememberLearning } from "./memory.js"
|
|
3
|
+
import {
|
|
4
|
+
clampNumber,
|
|
5
|
+
normalizeCdpEvent,
|
|
6
|
+
sanitizeNetworkEvent,
|
|
7
|
+
sanitizeUrl,
|
|
8
|
+
truncateText,
|
|
9
|
+
} from "./redaction.js"
|
|
10
|
+
import { toolUsageHints } from "./tools.js"
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_TOOL_TIMEOUT_MS = 30_000
|
|
13
|
+
const MAX_NETWORK_EVENTS = 1000
|
|
14
|
+
const MAX_CDP_EVENTS = 2000
|
|
15
|
+
const MAX_CONSOLE_MESSAGES = 1000
|
|
16
|
+
const MAX_MEMORY_ITEMS = 500
|
|
17
|
+
export const DEFAULT_SESSION_TTL_MS = Number(
|
|
18
|
+
process.env.YUNTI_BROWSER_SESSION_TTL_MS || 90_000
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
function isoNow(ms = Date.now()) {
|
|
22
|
+
return new Date(ms).toISOString()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sessionRecoveryHint() {
|
|
26
|
+
return "Call yunti_list_browser_targets to refresh the live route inventory and use the latest browserSessionId."
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function staleSessionError(browserSessionId, reason = "stale or disconnected") {
|
|
30
|
+
return new Error(
|
|
31
|
+
`Yunti browser session is stale or disconnected: ${browserSessionId}. Reason: ${reason}. ${sessionRecoveryHint()}`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripBrowserSessionId(args) {
|
|
36
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return args ?? {}
|
|
37
|
+
const next = { ...args }
|
|
38
|
+
delete next.browserSessionId
|
|
39
|
+
delete next.userId
|
|
40
|
+
delete next.userName
|
|
41
|
+
return next
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeRouteUserId(value) {
|
|
45
|
+
const userId = String(value || "").trim()
|
|
46
|
+
return userId && userId !== "anonymous" ? userId : ""
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function requireRouteUserId(args = {}, operation = "Yunti browser tool") {
|
|
50
|
+
const userId = normalizeRouteUserId(args?.userId)
|
|
51
|
+
if (!userId) {
|
|
52
|
+
throw new Error(`${operation}: userId is required for Yunti browser isolation`)
|
|
53
|
+
}
|
|
54
|
+
return userId
|
|
55
|
+
}
|
|
56
|
+
export class BridgeHub {
|
|
57
|
+
constructor({ sessionTtlMs = DEFAULT_SESSION_TTL_MS } = {}) {
|
|
58
|
+
this.sessions = new Map()
|
|
59
|
+
this.pendingRequests = new Map()
|
|
60
|
+
this.networkEvents = []
|
|
61
|
+
this.cdpEvents = []
|
|
62
|
+
this.consoleMessages = []
|
|
63
|
+
this.nextNetworkEventId = 1
|
|
64
|
+
this.nextCdpEventId = 1
|
|
65
|
+
this.nextConsoleMsgId = 1
|
|
66
|
+
this.activeSessionId = null
|
|
67
|
+
this.activeSessionByUser = new Map()
|
|
68
|
+
this.sessionTtlMs = Math.max(5_000, Number(sessionTtlMs) || DEFAULT_SESSION_TTL_MS)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
sessionExpiresAt(now = Date.now()) {
|
|
72
|
+
return now + this.sessionTtlMs
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
refreshSession(session, now = Date.now(), patch = {}) {
|
|
76
|
+
const meta = session.meta || {}
|
|
77
|
+
session.updatedAt = now
|
|
78
|
+
session.expiresAt = this.sessionExpiresAt(now)
|
|
79
|
+
session.staleReason = ""
|
|
80
|
+
session.meta = {
|
|
81
|
+
...meta,
|
|
82
|
+
...patch,
|
|
83
|
+
lastSeenAt: isoNow(now),
|
|
84
|
+
expiresAt: isoNow(session.expiresAt),
|
|
85
|
+
staleReason: "",
|
|
86
|
+
updatedAt: isoNow(now),
|
|
87
|
+
}
|
|
88
|
+
return session
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
markSessionStale(browserSessionId, reason = "disconnected") {
|
|
92
|
+
const session = this.sessions.get(browserSessionId)
|
|
93
|
+
if (!session) return null
|
|
94
|
+
session.staleReason = reason
|
|
95
|
+
session.meta = {
|
|
96
|
+
...session.meta,
|
|
97
|
+
staleReason: reason,
|
|
98
|
+
staleAt: isoNow(),
|
|
99
|
+
}
|
|
100
|
+
this.sessions.delete(browserSessionId)
|
|
101
|
+
if (this.activeSessionId === browserSessionId) this.activeSessionId = null
|
|
102
|
+
for (const [userId, activeSessionId] of this.activeSessionByUser.entries()) {
|
|
103
|
+
if (activeSessionId === browserSessionId) this.activeSessionByUser.delete(userId)
|
|
104
|
+
}
|
|
105
|
+
for (const poller of session.pollers.splice(0)) {
|
|
106
|
+
poller({ type: "noop", id: randomUUID(), stale: true, reason })
|
|
107
|
+
}
|
|
108
|
+
return session.meta
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
cleanupExpiredSessions(now = Date.now()) {
|
|
112
|
+
const expired = []
|
|
113
|
+
for (const [browserSessionId, session] of this.sessions.entries()) {
|
|
114
|
+
if (session.expiresAt && session.expiresAt <= now) {
|
|
115
|
+
expired.push({
|
|
116
|
+
browserSessionId,
|
|
117
|
+
reason: "session heartbeat expired",
|
|
118
|
+
meta: this.markSessionStale(browserSessionId, "session heartbeat expired"),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return expired
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getLiveSession(browserSessionId) {
|
|
126
|
+
const session = this.sessions.get(browserSessionId)
|
|
127
|
+
if (!session) return null
|
|
128
|
+
if (session.expiresAt && session.expiresAt <= Date.now()) {
|
|
129
|
+
this.markSessionStale(browserSessionId, "session heartbeat expired")
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
return session
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
registerSession(meta) {
|
|
136
|
+
this.cleanupExpiredSessions()
|
|
137
|
+
const browserSessionId = String(meta.browserSessionId || "").trim()
|
|
138
|
+
if (!browserSessionId) throw new Error("browserSessionId is required")
|
|
139
|
+
const routeUserId = requireRouteUserId(meta, "browser session registration")
|
|
140
|
+
const existing = this.sessions.get(browserSessionId) || {
|
|
141
|
+
browserSessionId,
|
|
142
|
+
queue: [],
|
|
143
|
+
pollers: [],
|
|
144
|
+
meta: {},
|
|
145
|
+
updatedAt: 0,
|
|
146
|
+
expiresAt: 0,
|
|
147
|
+
staleReason: "",
|
|
148
|
+
}
|
|
149
|
+
const now = Date.now()
|
|
150
|
+
this.refreshSession(existing, now, {
|
|
151
|
+
...meta,
|
|
152
|
+
browserSessionId,
|
|
153
|
+
userId: routeUserId,
|
|
154
|
+
registeredAt: meta.registeredAt || existing.meta.registeredAt || isoNow(now),
|
|
155
|
+
})
|
|
156
|
+
this.sessions.set(browserSessionId, existing)
|
|
157
|
+
this.activeSessionId = browserSessionId
|
|
158
|
+
const userId = normalizeRouteUserId(existing.meta.userId)
|
|
159
|
+
if (userId) this.activeSessionByUser.set(userId, browserSessionId)
|
|
160
|
+
return existing.meta
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
activateSession(browserSessionId, args = {}) {
|
|
164
|
+
this.cleanupExpiredSessions()
|
|
165
|
+
const session = this.getLiveSession(browserSessionId)
|
|
166
|
+
if (!session) {
|
|
167
|
+
throw staleSessionError(browserSessionId, "not registered or heartbeat expired")
|
|
168
|
+
}
|
|
169
|
+
const meta = session.meta
|
|
170
|
+
const routeUserId = requireRouteUserId(args, "browser session activation")
|
|
171
|
+
if (normalizeRouteUserId(meta.userId) !== routeUserId) {
|
|
172
|
+
throw new Error(`browser session is not owned by userId: ${routeUserId}`)
|
|
173
|
+
}
|
|
174
|
+
this.refreshSession(session, Date.now(), {
|
|
175
|
+
lastActivatedAt: isoNow(),
|
|
176
|
+
})
|
|
177
|
+
this.activeSessionId = browserSessionId
|
|
178
|
+
const userId = normalizeRouteUserId(meta.userId)
|
|
179
|
+
if (userId) this.activeSessionByUser.set(userId, browserSessionId)
|
|
180
|
+
return session.meta
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
listSessions(args = {}) {
|
|
184
|
+
this.cleanupExpiredSessions()
|
|
185
|
+
const userId = normalizeRouteUserId(args.userId)
|
|
186
|
+
const sessions = [...this.sessions.values()].filter((session) => {
|
|
187
|
+
if (!userId) return false
|
|
188
|
+
return normalizeRouteUserId(session.meta?.userId) === userId
|
|
189
|
+
})
|
|
190
|
+
return sessions.map((session) => ({
|
|
191
|
+
...session.meta,
|
|
192
|
+
queuedRequests: session.queue.length,
|
|
193
|
+
pollers: session.pollers.length,
|
|
194
|
+
networkEvents: this.networkEvents.filter(
|
|
195
|
+
(event) => event.browserSessionId === session.browserSessionId
|
|
196
|
+
).length,
|
|
197
|
+
cdpEvents: this.cdpEvents.filter(
|
|
198
|
+
(event) => event.browserSessionId === session.browserSessionId
|
|
199
|
+
).length,
|
|
200
|
+
active: session.browserSessionId === this.activeSessionId,
|
|
201
|
+
}))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
health(args = {}) {
|
|
205
|
+
this.cleanupExpiredSessions()
|
|
206
|
+
const userId = normalizeRouteUserId(args.userId)
|
|
207
|
+
const sessions = this.listSessions({ userId })
|
|
208
|
+
const activeSessionId = userId
|
|
209
|
+
? this.activeSessionByUser.get(userId) || null
|
|
210
|
+
: null
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
name: "yunti-browser-runtime-bridge",
|
|
214
|
+
activeSessionId,
|
|
215
|
+
sessions,
|
|
216
|
+
sessionCount: this.sessions.size,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
sessionIdsForUser(userId) {
|
|
221
|
+
this.cleanupExpiredSessions()
|
|
222
|
+
const routeUserId = normalizeRouteUserId(userId)
|
|
223
|
+
if (!routeUserId) return new Set()
|
|
224
|
+
return new Set(
|
|
225
|
+
[...this.sessions.values()]
|
|
226
|
+
.filter((session) => normalizeRouteUserId(session.meta?.userId) === routeUserId)
|
|
227
|
+
.map((session) => session.browserSessionId)
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
listPages(args = {}) {
|
|
232
|
+
this.cleanupExpiredSessions()
|
|
233
|
+
const userId = requireRouteUserId(args, "yunti_list_pages")
|
|
234
|
+
const activeSessionId = this.activeSessionByUser.get(userId) || null
|
|
235
|
+
const sessions = [...this.sessions.values()].filter((session) => {
|
|
236
|
+
return normalizeRouteUserId(session.meta?.userId) === userId
|
|
237
|
+
})
|
|
238
|
+
const pages = sessions.map((session) => {
|
|
239
|
+
const meta = session.meta || {}
|
|
240
|
+
return {
|
|
241
|
+
browserSessionId: session.browserSessionId,
|
|
242
|
+
url: meta.url || "",
|
|
243
|
+
title: meta.title || "",
|
|
244
|
+
tabId: meta.tabId ?? null,
|
|
245
|
+
windowId: meta.windowId ?? null,
|
|
246
|
+
active: session.browserSessionId === activeSessionId,
|
|
247
|
+
userId: meta.userId || "",
|
|
248
|
+
displayName: meta.displayName || "",
|
|
249
|
+
registeredAt: meta.registeredAt || meta.updatedAt || "",
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
return { pages, activeSessionId }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
selectPage(browserSessionId, args = {}) {
|
|
256
|
+
this.cleanupExpiredSessions()
|
|
257
|
+
const session = this.getLiveSession(browserSessionId)
|
|
258
|
+
if (!browserSessionId || !session) {
|
|
259
|
+
throw staleSessionError(browserSessionId, "not registered or heartbeat expired")
|
|
260
|
+
}
|
|
261
|
+
const meta = session.meta
|
|
262
|
+
const routeUserId = requireRouteUserId(args, "yunti_select_page")
|
|
263
|
+
if (normalizeRouteUserId(meta.userId) !== routeUserId) {
|
|
264
|
+
throw new Error(`browser session is not owned by userId: ${routeUserId}`)
|
|
265
|
+
}
|
|
266
|
+
this.refreshSession(session, Date.now(), {
|
|
267
|
+
lastActivatedAt: isoNow(),
|
|
268
|
+
})
|
|
269
|
+
this.activeSessionId = browserSessionId
|
|
270
|
+
const userId = normalizeRouteUserId(meta.userId)
|
|
271
|
+
if (userId) this.activeSessionByUser.set(userId, browserSessionId)
|
|
272
|
+
return {
|
|
273
|
+
browserSessionId,
|
|
274
|
+
url: meta.url || "",
|
|
275
|
+
title: meta.title || "",
|
|
276
|
+
tabId: meta.tabId ?? null,
|
|
277
|
+
active: true,
|
|
278
|
+
userId: meta.userId || "",
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
resolveSessionId(args) {
|
|
283
|
+
this.cleanupExpiredSessions()
|
|
284
|
+
const userId =
|
|
285
|
+
args && typeof args === "object" && !Array.isArray(args)
|
|
286
|
+
? requireRouteUserId(args, "Yunti browser session routing")
|
|
287
|
+
: requireRouteUserId({}, "Yunti browser session routing")
|
|
288
|
+
const explicit =
|
|
289
|
+
args && typeof args === "object" && !Array.isArray(args)
|
|
290
|
+
? String(args.browserSessionId || "").trim()
|
|
291
|
+
: ""
|
|
292
|
+
if (explicit) {
|
|
293
|
+
const session = this.getLiveSession(explicit)
|
|
294
|
+
if (!session) {
|
|
295
|
+
throw staleSessionError(explicit, "not registered or heartbeat expired")
|
|
296
|
+
}
|
|
297
|
+
if (normalizeRouteUserId(session.meta?.userId) !== userId) {
|
|
298
|
+
throw new Error(`browser session is not owned by userId: ${userId}`)
|
|
299
|
+
}
|
|
300
|
+
return explicit
|
|
301
|
+
}
|
|
302
|
+
const userSessionId = this.activeSessionByUser.get(userId)
|
|
303
|
+
if (!userSessionId) {
|
|
304
|
+
throw new Error(`No Yunti browser tab is connected for userId: ${userId}. ${sessionRecoveryHint()}`)
|
|
305
|
+
}
|
|
306
|
+
const session = this.getLiveSession(userSessionId)
|
|
307
|
+
if (!session) {
|
|
308
|
+
throw staleSessionError(userSessionId, "active route heartbeat expired")
|
|
309
|
+
}
|
|
310
|
+
if (normalizeRouteUserId(session.meta?.userId) !== userId) {
|
|
311
|
+
throw new Error(`browser session is not owned by userId: ${userId}`)
|
|
312
|
+
}
|
|
313
|
+
return userSessionId
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async callTool(tool, args = {}, timeoutMs = DEFAULT_TOOL_TIMEOUT_MS) {
|
|
317
|
+
const browserSessionId = this.resolveSessionId(args)
|
|
318
|
+
const session = this.sessions.get(browserSessionId)
|
|
319
|
+
const requestId = randomUUID()
|
|
320
|
+
const payload = {
|
|
321
|
+
type: "tool_request",
|
|
322
|
+
id: requestId,
|
|
323
|
+
tool,
|
|
324
|
+
arguments: stripBrowserSessionId(args),
|
|
325
|
+
createdAt: new Date().toISOString(),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const promise = new Promise((resolve, reject) => {
|
|
329
|
+
const timer = setTimeout(() => {
|
|
330
|
+
this.pendingRequests.delete(requestId)
|
|
331
|
+
reject(new Error(`Timed out waiting for browser tool result: ${tool}`))
|
|
332
|
+
}, timeoutMs)
|
|
333
|
+
this.pendingRequests.set(requestId, { resolve, reject, timer, browserSessionId, tool })
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const poller = session.pollers.shift()
|
|
337
|
+
if (poller) {
|
|
338
|
+
poller(payload)
|
|
339
|
+
} else {
|
|
340
|
+
session.queue.push(payload)
|
|
341
|
+
}
|
|
342
|
+
return promise
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
recordNetworkEvent(input) {
|
|
346
|
+
this.cleanupExpiredSessions()
|
|
347
|
+
const event = sanitizeNetworkEvent(input || {}, this.nextNetworkEventId++)
|
|
348
|
+
if (!event.browserSessionId) return { accepted: false, error: "browserSessionId is required" }
|
|
349
|
+
if (!this.getLiveSession(event.browserSessionId)) {
|
|
350
|
+
return { accepted: false, error: staleSessionError(event.browserSessionId).message }
|
|
351
|
+
}
|
|
352
|
+
this.networkEvents.push(event)
|
|
353
|
+
if (this.networkEvents.length > MAX_NETWORK_EVENTS) {
|
|
354
|
+
this.networkEvents.splice(0, this.networkEvents.length - MAX_NETWORK_EVENTS)
|
|
355
|
+
}
|
|
356
|
+
return { accepted: true, event }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
recordCdpEvent(input) {
|
|
360
|
+
this.cleanupExpiredSessions()
|
|
361
|
+
const event = normalizeCdpEvent(input || {}, this.nextCdpEventId++)
|
|
362
|
+
if (!event.browserSessionId) return { accepted: false, error: "browserSessionId is required" }
|
|
363
|
+
if (!event.method) return { accepted: false, error: "method is required" }
|
|
364
|
+
if (!this.getLiveSession(event.browserSessionId)) {
|
|
365
|
+
return { accepted: false, error: staleSessionError(event.browserSessionId).message }
|
|
366
|
+
}
|
|
367
|
+
this.cdpEvents.push(event)
|
|
368
|
+
if (this.cdpEvents.length > MAX_CDP_EVENTS) {
|
|
369
|
+
this.cdpEvents.splice(0, this.cdpEvents.length - MAX_CDP_EVENTS)
|
|
370
|
+
}
|
|
371
|
+
return { accepted: true, event }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
listNetworkEvents(args = {}) {
|
|
375
|
+
const limit = clampNumber(args.limit || 100, 1, 500, 100)
|
|
376
|
+
const sinceId = Number(args.sinceId || 0)
|
|
377
|
+
const method = String(args.method || "").trim().toUpperCase()
|
|
378
|
+
const urlContains = String(args.urlContains || "").trim().toLowerCase()
|
|
379
|
+
const browserSessionIds = args.allSessions
|
|
380
|
+
? this.sessionIdsForUser(requireRouteUserId(args, "yunti_get_network_log"))
|
|
381
|
+
: new Set([this.resolveSessionId(args)])
|
|
382
|
+
let events = this.networkEvents
|
|
383
|
+
events = events.filter((event) => browserSessionIds.has(event.browserSessionId))
|
|
384
|
+
if (Number.isFinite(sinceId) && sinceId > 0) {
|
|
385
|
+
events = events.filter((event) => event.id > sinceId)
|
|
386
|
+
}
|
|
387
|
+
if (method) events = events.filter((event) => event.method === method)
|
|
388
|
+
if (urlContains) {
|
|
389
|
+
events = events.filter((event) => String(event.url || "").toLowerCase().includes(urlContains))
|
|
390
|
+
}
|
|
391
|
+
const total = events.length
|
|
392
|
+
events = events.slice(-limit)
|
|
393
|
+
return { events, total, returned: events.length }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
clearNetworkEvents(args = {}) {
|
|
397
|
+
if (args.allSessions) {
|
|
398
|
+
const browserSessionIds = this.sessionIdsForUser(requireRouteUserId(args, "yunti_clear_network_log"))
|
|
399
|
+
const before = this.networkEvents.length
|
|
400
|
+
this.networkEvents = this.networkEvents.filter(
|
|
401
|
+
(event) => !browserSessionIds.has(event.browserSessionId)
|
|
402
|
+
)
|
|
403
|
+
return { deleted: before - this.networkEvents.length }
|
|
404
|
+
}
|
|
405
|
+
const browserSessionId = this.resolveSessionId(args)
|
|
406
|
+
const before = this.networkEvents.length
|
|
407
|
+
this.networkEvents = this.networkEvents.filter((event) => event.browserSessionId !== browserSessionId)
|
|
408
|
+
return { deleted: before - this.networkEvents.length }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
getNetworkRequest(args = {}) {
|
|
412
|
+
const id = Number(args.eventId)
|
|
413
|
+
if (!Number.isFinite(id)) throw new Error("eventId is required")
|
|
414
|
+
const browserSessionId = this.resolveSessionId(args)
|
|
415
|
+
const event = this.networkEvents.find(e => e.id === id)
|
|
416
|
+
if (!event) throw new Error(`network request not found: ${id}`)
|
|
417
|
+
if (event.browserSessionId !== browserSessionId) {
|
|
418
|
+
throw new Error(`network request not found for browserSessionId: ${browserSessionId}`)
|
|
419
|
+
}
|
|
420
|
+
return event
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
listCdpEvents(args = {}) {
|
|
424
|
+
const limit = clampNumber(args.limit || 100, 1, 500, 100)
|
|
425
|
+
const sinceId = Number(args.sinceId || 0)
|
|
426
|
+
const method = String(args.method || "").trim()
|
|
427
|
+
const browserSessionIds = args.allSessions
|
|
428
|
+
? this.sessionIdsForUser(requireRouteUserId(args, "yunti_get_cdp_events"))
|
|
429
|
+
: new Set([this.resolveSessionId(args)])
|
|
430
|
+
let events = this.cdpEvents
|
|
431
|
+
events = events.filter((event) => browserSessionIds.has(event.browserSessionId))
|
|
432
|
+
if (Number.isFinite(sinceId) && sinceId > 0) {
|
|
433
|
+
events = events.filter((event) => event.id > sinceId)
|
|
434
|
+
}
|
|
435
|
+
if (method) events = events.filter((event) => event.method === method)
|
|
436
|
+
const total = events.length
|
|
437
|
+
events = events.slice(-limit)
|
|
438
|
+
return { events, total, returned: events.length }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
clearCdpEvents(args = {}) {
|
|
442
|
+
if (args.allSessions) {
|
|
443
|
+
const browserSessionIds = this.sessionIdsForUser(requireRouteUserId(args, "yunti_clear_cdp_events"))
|
|
444
|
+
const before = this.cdpEvents.length
|
|
445
|
+
this.cdpEvents = this.cdpEvents.filter(
|
|
446
|
+
(event) => !browserSessionIds.has(event.browserSessionId)
|
|
447
|
+
)
|
|
448
|
+
return { deleted: before - this.cdpEvents.length }
|
|
449
|
+
}
|
|
450
|
+
const browserSessionId = this.resolveSessionId(args)
|
|
451
|
+
const before = this.cdpEvents.length
|
|
452
|
+
this.cdpEvents = this.cdpEvents.filter((event) => event.browserSessionId !== browserSessionId)
|
|
453
|
+
return { deleted: before - this.cdpEvents.length }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
recordConsoleEvent(input) {
|
|
457
|
+
this.cleanupExpiredSessions()
|
|
458
|
+
const event = {
|
|
459
|
+
id: this.nextConsoleMsgId++,
|
|
460
|
+
browserSessionId: truncateText(input.browserSessionId || "", 160),
|
|
461
|
+
tabId: Number.isFinite(Number(input.tabId)) ? Number(input.tabId) : null,
|
|
462
|
+
level: ["error", "warning", "info", "debug", "log", "verbose"].includes(input.level) ? input.level : "log",
|
|
463
|
+
text: truncateText(String(input.text || ""), 2000),
|
|
464
|
+
source: ["console-api", "javascript", "network", "other"].includes(input.source) ? input.source : "other",
|
|
465
|
+
url: input.url ? sanitizeUrl(input.url) : "",
|
|
466
|
+
lineNumber: Number.isFinite(Number(input.lineNumber)) ? Number(input.lineNumber) : null,
|
|
467
|
+
columnNumber: Number.isFinite(Number(input.columnNumber)) ? Number(input.columnNumber) : null,
|
|
468
|
+
stackTrace: input.stackTrace ? truncateText(String(input.stackTrace), 4000) : "",
|
|
469
|
+
args: Array.isArray(input.args) ? input.args.slice(0, 20).map(a => truncateText(String(a), 500)) : [],
|
|
470
|
+
timestamp: input.timestamp || new Date().toISOString(),
|
|
471
|
+
}
|
|
472
|
+
if (!event.browserSessionId) return { accepted: false, error: "browserSessionId is required" }
|
|
473
|
+
if (!this.getLiveSession(event.browserSessionId)) {
|
|
474
|
+
return { accepted: false, error: staleSessionError(event.browserSessionId).message }
|
|
475
|
+
}
|
|
476
|
+
this.consoleMessages.push(event)
|
|
477
|
+
if (this.consoleMessages.length > MAX_CONSOLE_MESSAGES) {
|
|
478
|
+
this.consoleMessages.splice(0, this.consoleMessages.length - MAX_CONSOLE_MESSAGES)
|
|
479
|
+
}
|
|
480
|
+
return { accepted: true, event }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
listConsoleMessages(args = {}) {
|
|
484
|
+
const limit = clampNumber(args.limit || 100, 1, 500, 100)
|
|
485
|
+
const sinceId = Number(args.sinceId || 0)
|
|
486
|
+
const level = String(args.level || "").trim().toLowerCase()
|
|
487
|
+
const source = String(args.source || "").trim().toLowerCase()
|
|
488
|
+
const browserSessionIds = args.allSessions
|
|
489
|
+
? this.sessionIdsForUser(requireRouteUserId(args, "yunti_list_console_messages"))
|
|
490
|
+
: new Set([this.resolveSessionId(args)])
|
|
491
|
+
|
|
492
|
+
let events = this.consoleMessages
|
|
493
|
+
events = events.filter(e => browserSessionIds.has(e.browserSessionId))
|
|
494
|
+
if (Number.isFinite(sinceId) && sinceId > 0) {
|
|
495
|
+
events = events.filter(e => e.id > sinceId)
|
|
496
|
+
}
|
|
497
|
+
if (level) events = events.filter(e => e.level === level)
|
|
498
|
+
if (source) events = events.filter(e => e.source === source)
|
|
499
|
+
const total = events.length
|
|
500
|
+
events = events.slice(-limit).reverse()
|
|
501
|
+
return { events, total, returned: events.length }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
getConsoleMessage(args = {}) {
|
|
505
|
+
const id = Number(args.msgId)
|
|
506
|
+
if (!Number.isFinite(id)) throw new Error("msgId is required")
|
|
507
|
+
const browserSessionId = this.resolveSessionId(args)
|
|
508
|
+
const msg = this.consoleMessages.find(e => e.id === id)
|
|
509
|
+
if (!msg) throw new Error(`console message not found: ${id}`)
|
|
510
|
+
if (msg.browserSessionId !== browserSessionId) {
|
|
511
|
+
throw new Error(`console message not found for browserSessionId: ${browserSessionId}`)
|
|
512
|
+
}
|
|
513
|
+
return msg
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
clearConsoleMessages(args = {}) {
|
|
517
|
+
if (args.allSessions) {
|
|
518
|
+
const browserSessionIds = this.sessionIdsForUser(requireRouteUserId(args, "yunti_clear_console_messages"))
|
|
519
|
+
const before = this.consoleMessages.length
|
|
520
|
+
this.consoleMessages = this.consoleMessages.filter(
|
|
521
|
+
e => !browserSessionIds.has(e.browserSessionId)
|
|
522
|
+
)
|
|
523
|
+
return { deleted: before - this.consoleMessages.length }
|
|
524
|
+
}
|
|
525
|
+
const browserSessionId = this.resolveSessionId(args)
|
|
526
|
+
const before = this.consoleMessages.length
|
|
527
|
+
this.consoleMessages = this.consoleMessages.filter(e => e.browserSessionId !== browserSessionId)
|
|
528
|
+
return { deleted: before - this.consoleMessages.length }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async callBridgeLocalTool(tool, args = {}) {
|
|
532
|
+
switch (tool) {
|
|
533
|
+
case "yunti_select_page":
|
|
534
|
+
return this.selectPage(String(args.browserSessionId || ""), args)
|
|
535
|
+
case "yunti_get_network_log":
|
|
536
|
+
return this.listNetworkEvents(args)
|
|
537
|
+
case "yunti_list_network_requests":
|
|
538
|
+
return this.listNetworkEvents(args)
|
|
539
|
+
case "yunti_get_network_request":
|
|
540
|
+
return this.getNetworkRequest(args)
|
|
541
|
+
case "yunti_clear_network_log":
|
|
542
|
+
case "yunti_clear_network_requests":
|
|
543
|
+
return this.clearNetworkEvents(args)
|
|
544
|
+
case "yunti_get_cdp_events":
|
|
545
|
+
return this.listCdpEvents(args)
|
|
546
|
+
case "yunti_clear_cdp_events":
|
|
547
|
+
return this.clearCdpEvents(args)
|
|
548
|
+
case "yunti_list_console_messages":
|
|
549
|
+
return this.listConsoleMessages(args)
|
|
550
|
+
case "yunti_get_console_message":
|
|
551
|
+
return this.getConsoleMessage(args)
|
|
552
|
+
case "yunti_clear_console_messages":
|
|
553
|
+
return this.clearConsoleMessages(args)
|
|
554
|
+
case "yunti_remember_learning":
|
|
555
|
+
return rememberLearning(args)
|
|
556
|
+
case "yunti_get_learning_memory":
|
|
557
|
+
return getLearningMemory(args)
|
|
558
|
+
case "yunti_forget_learning_memory":
|
|
559
|
+
return forgetLearningMemory(args)
|
|
560
|
+
case "yunti_get_tool_usage_hints":
|
|
561
|
+
return toolUsageHints(args)
|
|
562
|
+
default:
|
|
563
|
+
throw new Error(`unknown bridge-local tool: ${tool}`)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async poll(browserSessionId, timeoutMs = 25_000) {
|
|
568
|
+
this.cleanupExpiredSessions()
|
|
569
|
+
const session = this.getLiveSession(browserSessionId)
|
|
570
|
+
if (!session) {
|
|
571
|
+
throw staleSessionError(browserSessionId, "not registered or heartbeat expired")
|
|
572
|
+
}
|
|
573
|
+
this.refreshSession(session)
|
|
574
|
+
if (session.queue.length > 0) return session.queue.shift()
|
|
575
|
+
|
|
576
|
+
return new Promise((resolve) => {
|
|
577
|
+
const timer = setTimeout(() => {
|
|
578
|
+
session.pollers = session.pollers.filter((poller) => poller !== finish)
|
|
579
|
+
resolve({ type: "noop", id: randomUUID() })
|
|
580
|
+
}, Math.max(1000, Math.min(timeoutMs, 30_000)))
|
|
581
|
+
const finish = (value) => {
|
|
582
|
+
clearTimeout(timer)
|
|
583
|
+
resolve(value)
|
|
584
|
+
}
|
|
585
|
+
session.pollers.push(finish)
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
submitResult({ browserSessionId, requestId, ok, result, error }) {
|
|
590
|
+
const pending = this.pendingRequests.get(requestId)
|
|
591
|
+
if (!pending) return { accepted: false }
|
|
592
|
+
if (browserSessionId && pending.browserSessionId !== browserSessionId) {
|
|
593
|
+
return { accepted: false, error: "browserSessionId mismatch" }
|
|
594
|
+
}
|
|
595
|
+
clearTimeout(pending.timer)
|
|
596
|
+
this.pendingRequests.delete(requestId)
|
|
597
|
+
if (ok) {
|
|
598
|
+
pending.resolve(result ?? null)
|
|
599
|
+
} else {
|
|
600
|
+
pending.reject(new Error(error || "Browser tool failed"))
|
|
601
|
+
}
|
|
602
|
+
return { accepted: true }
|
|
603
|
+
}
|
|
604
|
+
}
|