poi-plugin-mcp 0.2.16 → 0.2.21

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.
@@ -0,0 +1,165 @@
1
+ const crypto = require('node:crypto')
2
+
3
+ const DEFAULT_CAPACITY = 128
4
+ const DEFAULT_TOTAL_BYTES = 32 * 1024 * 1024
5
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024
6
+ const SENSITIVE_KEY = /^(?:api_token|authorization|cookie|credentials?|loginData|password|secret|ticket)$/iu
7
+
8
+ function createPoiApiResponses(options = {}) {
9
+ const now = options.now || (() => new Date())
10
+ const capacity = boundedInteger(
11
+ options.capacity == null ? DEFAULT_CAPACITY : options.capacity,
12
+ 1,
13
+ 512,
14
+ 'capacity',
15
+ )
16
+ const maxTotalBytes = boundedInteger(
17
+ options.maxTotalBytes == null ? DEFAULT_TOTAL_BYTES : options.maxTotalBytes,
18
+ 1024,
19
+ 128 * 1024 * 1024,
20
+ 'maxTotalBytes',
21
+ )
22
+ const sessionId = String(options.sessionId || crypto.randomUUID())
23
+ const responses = []
24
+ let latestGeneration = 0
25
+ let totalBytes = 0
26
+
27
+ function capture(detail, capturedAt) {
28
+ if (
29
+ !detail ||
30
+ typeof detail !== 'object' ||
31
+ Array.isArray(detail) ||
32
+ typeof detail.path !== 'string' ||
33
+ !detail.path.startsWith('/kcsapi/')
34
+ ) {
35
+ return null
36
+ }
37
+ const body = sanitize(detail.body)
38
+ const postBody = sanitize(detail.postBody)
39
+ const apiResult = readApiResult(detail)
40
+ latestGeneration += 1
41
+ const base = {
42
+ generation: latestGeneration,
43
+ capturedAt: capturedAt == null ? timestamp(now()) : timestamp(capturedAt),
44
+ path: detail.path,
45
+ apiResult,
46
+ postBody: isObject(postBody) ? postBody : {},
47
+ }
48
+ let responseBody = body
49
+ let truncated = false
50
+ let bytes = encodedBytes({ ...base, responseBody })
51
+ if (bytes > MAX_RESPONSE_BYTES) {
52
+ responseBody = null
53
+ truncated = true
54
+ bytes = encodedBytes({ ...base, responseBody, truncated })
55
+ }
56
+ const entry = Object.freeze({
57
+ ...base,
58
+ responseBody,
59
+ truncated,
60
+ bytes,
61
+ })
62
+ responses.push(entry)
63
+ totalBytes += bytes
64
+ while (responses.length > capacity || totalBytes > maxTotalBytes) {
65
+ const removed = responses.shift()
66
+ totalBytes -= removed.bytes
67
+ }
68
+ return entry
69
+ }
70
+
71
+ function read(options = {}) {
72
+ const after = options.after == null
73
+ ? 0
74
+ : boundedInteger(options.after, 0, Number.MAX_SAFE_INTEGER, 'after')
75
+ const limit = options.limit == null
76
+ ? 64
77
+ : boundedInteger(options.limit, 1, 256, 'limit')
78
+ const apiPath = options.path == null ? null : String(options.path)
79
+ return {
80
+ available: true,
81
+ sessionId,
82
+ earliestGeneration: responses.length === 0 ? 0 : responses[0].generation,
83
+ latestGeneration,
84
+ retainedBytes: totalBytes,
85
+ responses: responses
86
+ .filter((response) =>
87
+ response.generation > after &&
88
+ (apiPath === null || response.path === apiPath))
89
+ .slice(0, limit)
90
+ .map(({ bytes, ...response }) => response),
91
+ }
92
+ }
93
+
94
+ return Object.freeze({ capture, read })
95
+ }
96
+
97
+ function sanitize(value, depth = 0, seen = new WeakSet()) {
98
+ if (
99
+ value === null ||
100
+ typeof value === 'boolean' ||
101
+ (typeof value === 'number' && Number.isFinite(value))
102
+ ) {
103
+ return value
104
+ }
105
+ if (typeof value === 'string') return value.slice(0, 512 * 1024)
106
+ if (typeof value !== 'object') return undefined
107
+ if (seen.has(value)) return '[Circular]'
108
+ if (depth >= 32) return '[MaxDepth]'
109
+ seen.add(value)
110
+ if (Array.isArray(value)) {
111
+ return value.slice(0, 100_000).map((item) => sanitize(item, depth + 1, seen))
112
+ }
113
+ const output = {}
114
+ for (const [key, item] of Object.entries(value).slice(0, 100_000)) {
115
+ if (SENSITIVE_KEY.test(key)) {
116
+ output[key] = '[REDACTED]'
117
+ continue
118
+ }
119
+ const sanitized = sanitize(item, depth + 1, seen)
120
+ if (sanitized !== undefined) output[key] = sanitized
121
+ }
122
+ return output
123
+ }
124
+
125
+ function readApiResult(detail) {
126
+ const candidates = [
127
+ detail.apiResult,
128
+ detail.api_result,
129
+ detail.result,
130
+ detail.body && detail.body.api_result,
131
+ ]
132
+ for (const candidate of candidates) {
133
+ if (candidate == null || candidate === '') continue
134
+ const parsed = Number(candidate)
135
+ if (Number.isInteger(parsed)) return parsed
136
+ }
137
+ return null
138
+ }
139
+
140
+ function encodedBytes(value) {
141
+ return Buffer.byteLength(JSON.stringify(value), 'utf8')
142
+ }
143
+
144
+ function boundedInteger(value, minimum, maximum, name) {
145
+ const parsed = Number(value)
146
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
147
+ throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`)
148
+ }
149
+ return parsed
150
+ }
151
+
152
+ function timestamp(value) {
153
+ const date = value instanceof Date ? value : new Date(value)
154
+ if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
155
+ return date.toISOString()
156
+ }
157
+
158
+ function isObject(value) {
159
+ return value && typeof value === 'object' && !Array.isArray(value)
160
+ }
161
+
162
+ module.exports = {
163
+ MAX_RESPONSE_BYTES,
164
+ createPoiApiResponses,
165
+ }
@@ -0,0 +1,399 @@
1
+ const { createPoiScreenshotProvider } = require('./poi-screenshot')
2
+
3
+ const DEFAULT_ATTACH_INTERVAL_MS = 1000
4
+ const DEFAULT_IDLE_SESSION_MS = 5 * 60 * 1000
5
+ const MAX_BUFFERED_MOUSE_EVENTS = 256
6
+ const MAX_BUFFERED_RESPONSES = 128
7
+
8
+ function createPoiAutoInteractionRecorder(options = {}) {
9
+ const getStore = options.getStore || defaultGetStore
10
+ const loadElectronRemote = options.loadElectronRemote ||
11
+ (() => require('@electron/remote'))
12
+ const resolveWebContents = options.resolveWebContents || ((webContentsId) => {
13
+ const remote = loadElectronRemote()
14
+ return remote.webContents.fromId(webContentsId)
15
+ })
16
+ let captureScreenshot = options.captureScreenshot || null
17
+ const createSessionRecorder = options.createSessionRecorder
18
+ const eventTarget = options.eventTarget === undefined
19
+ ? defaultEventTarget()
20
+ : options.eventTarget
21
+ const logger = options.logger || console
22
+ const now = options.now || (() => new Date())
23
+ const nowMs = options.nowMs || (() => Date.now())
24
+ const setIntervalFn = options.setInterval || setInterval
25
+ const clearIntervalFn = options.clearInterval || clearInterval
26
+ const setTimeoutFn = options.setTimeout || setTimeout
27
+ const clearTimeoutFn = options.clearTimeout || clearTimeout
28
+ const attachIntervalMs = positiveInteger(
29
+ options.attachIntervalMs,
30
+ DEFAULT_ATTACH_INTERVAL_MS,
31
+ 'attachIntervalMs',
32
+ )
33
+ const idleSessionMs = positiveInteger(
34
+ options.idleSessionMs,
35
+ DEFAULT_IDLE_SESSION_MS,
36
+ 'idleSessionMs',
37
+ )
38
+
39
+ let armed = false
40
+ let attachedWebContents = null
41
+ let attachTimer = null
42
+ let idleTimer = null
43
+ let sessionRecorder = null
44
+ let starting = false
45
+ let pending = Promise.resolve()
46
+ let lastSessionStatus = null
47
+ let limitReached = null
48
+ let closing = false
49
+ let startAfterClose = false
50
+ let bufferedGesture = false
51
+ let bufferedMouseEvents = []
52
+ let bufferedResponses = []
53
+ let sessionStopError = null
54
+
55
+ function enqueue(action) {
56
+ const result = pending.then(action, action)
57
+ pending = result.catch((error) => {
58
+ logger.error(`[poi-plugin-mcp] Automatic recorder transition failed: ${error.message}`)
59
+ })
60
+ return result
61
+ }
62
+
63
+ function currentWebContents() {
64
+ const layout = getStore('layout.webview')
65
+ if (!layout || !layout.ref) return null
66
+ if (typeof layout.ref.getWebContents === 'function') {
67
+ return layout.ref.getWebContents()
68
+ }
69
+ if (typeof layout.ref.getWebContentsId === 'function') {
70
+ const id = layout.ref.getWebContentsId()
71
+ if (Number.isInteger(id) && id > 0) return resolveWebContents(id)
72
+ }
73
+ return null
74
+ }
75
+
76
+ function tryAttach() {
77
+ if (!armed) return false
78
+ let nextWebContents = null
79
+ try {
80
+ nextWebContents = currentWebContents()
81
+ } catch (error) {
82
+ logger.error(`[poi-plugin-mcp] Automatic recorder WebView lookup failed: ${error.message}`)
83
+ return false
84
+ }
85
+ if (!nextWebContents || typeof nextWebContents.on !== 'function') {
86
+ detachWebContents()
87
+ return false
88
+ }
89
+ if (nextWebContents === attachedWebContents) return true
90
+ detachWebContents()
91
+ attachedWebContents = nextWebContents
92
+ attachedWebContents.on('before-mouse-event', handleMouseEvent)
93
+ return true
94
+ }
95
+
96
+ function handleMouseEvent(_event, mouse) {
97
+ if (
98
+ !armed ||
99
+ !mouse ||
100
+ typeof mouse !== 'object' ||
101
+ !['mouseMove', 'mouseDown', 'mouseUp'].includes(mouse.type) ||
102
+ !Number.isFinite(mouse.x) ||
103
+ !Number.isFinite(mouse.y)
104
+ ) {
105
+ return
106
+ }
107
+ if (limitReached) return
108
+ if (closing) {
109
+ startAfterClose = true
110
+ bufferMouseEvent(mouse)
111
+ return
112
+ }
113
+ if (!sessionRecorder && !starting) beginSession()
114
+ if (starting) bufferMouseEvent(mouse)
115
+ if (sessionRecorder) scheduleIdleStop()
116
+ }
117
+
118
+ function handleGameResponse(event) {
119
+ if (
120
+ !armed ||
121
+ limitReached ||
122
+ (!starting && !(closing && startAfterClose))
123
+ ) {
124
+ return
125
+ }
126
+ if (bufferedResponses.length >= MAX_BUFFERED_RESPONSES) return
127
+ bufferedResponses.push({
128
+ detail: event && event.detail,
129
+ observedAt: now().toISOString(),
130
+ })
131
+ }
132
+
133
+ function bufferMouseEvent(mouse) {
134
+ if (mouse.type === 'mouseDown') bufferedGesture = true
135
+ if (!bufferedGesture && mouse.type !== 'mouseDown') return
136
+ if (bufferedMouseEvents.length >= MAX_BUFFERED_MOUSE_EVENTS) return
137
+ const observation = {
138
+ mouse: { ...mouse },
139
+ observedAt: now().toISOString(),
140
+ observedAtMs: nowMs(),
141
+ screenshot: null,
142
+ }
143
+ if (mouse.type === 'mouseDown') {
144
+ try {
145
+ if (!captureScreenshot) {
146
+ captureScreenshot = createPoiScreenshotProvider({ getStore })
147
+ }
148
+ observation.screenshot = Promise.resolve(captureScreenshot())
149
+ } catch (error) {
150
+ observation.screenshot = Promise.reject(error)
151
+ }
152
+ }
153
+ bufferedMouseEvents.push(observation)
154
+ if (mouse.type === 'mouseUp') bufferedGesture = false
155
+ }
156
+
157
+ function beginSession() {
158
+ if (!armed || sessionRecorder || starting) return
159
+ starting = true
160
+ enqueue(async () => {
161
+ try {
162
+ if (!armed) return
163
+ const nextRecorder = createSessionRecorder()
164
+ sessionRecorder = nextRecorder
165
+ await nextRecorder.start()
166
+ lastSessionStatus = nextRecorder.getStatus()
167
+ limitReached = lastSessionStatus.limitReached || null
168
+ if (typeof nextRecorder.observeMouseEvent === 'function') {
169
+ for (const observation of bufferedMouseEvents) {
170
+ nextRecorder.observeMouseEvent(observation)
171
+ }
172
+ }
173
+ if (typeof nextRecorder.observeGameResponse === 'function') {
174
+ for (const event of bufferedResponses) {
175
+ nextRecorder.observeGameResponse(event)
176
+ }
177
+ }
178
+ bufferedMouseEvents = []
179
+ bufferedResponses = []
180
+ bufferedGesture = false
181
+ if (armed && lastSessionStatus.running) scheduleIdleStop()
182
+ } catch (error) {
183
+ sessionRecorder = null
184
+ bufferedMouseEvents = []
185
+ bufferedResponses = []
186
+ bufferedGesture = false
187
+ limitReached = {
188
+ reason: 'session-start-failed',
189
+ message: error.message,
190
+ }
191
+ throw error
192
+ } finally {
193
+ starting = false
194
+ }
195
+ })
196
+ }
197
+
198
+ function scheduleIdleStop() {
199
+ if (idleTimer != null) clearTimeoutFn(idleTimer)
200
+ idleTimer = setTimeoutFn(() => {
201
+ idleTimer = null
202
+ endCurrentSession()
203
+ }, idleSessionMs)
204
+ }
205
+
206
+ function endCurrentSession() {
207
+ if (closing) return
208
+ closing = true
209
+ enqueue(async () => {
210
+ let stopFailed = false
211
+ try {
212
+ if (!sessionRecorder) return
213
+ const endingRecorder = sessionRecorder
214
+ try {
215
+ await endingRecorder.stop()
216
+ lastSessionStatus = endingRecorder.getStatus()
217
+ limitReached = lastSessionStatus.limitReached || limitReached
218
+ } catch (error) {
219
+ stopFailed = true
220
+ sessionStopError = error
221
+ try {
222
+ lastSessionStatus = endingRecorder.getStatus()
223
+ } catch (_statusError) {
224
+ // Preserve the original stop failure.
225
+ }
226
+ limitReached = {
227
+ reason: 'session-stop-failed',
228
+ message: error.message,
229
+ }
230
+ startAfterClose = false
231
+ bufferedMouseEvents = []
232
+ bufferedResponses = []
233
+ bufferedGesture = false
234
+ throw error
235
+ } finally {
236
+ if (sessionRecorder === endingRecorder) sessionRecorder = null
237
+ }
238
+ } finally {
239
+ closing = false
240
+ if (armed && startAfterClose && !stopFailed && !limitReached) {
241
+ startAfterClose = false
242
+ beginSession()
243
+ }
244
+ }
245
+ })
246
+ }
247
+
248
+ function clearIdleTimer() {
249
+ if (idleTimer == null) return
250
+ clearTimeoutFn(idleTimer)
251
+ idleTimer = null
252
+ }
253
+
254
+ function detachWebContents() {
255
+ if (!attachedWebContents) return
256
+ if (typeof attachedWebContents.off === 'function') {
257
+ attachedWebContents.off('before-mouse-event', handleMouseEvent)
258
+ } else if (typeof attachedWebContents.removeListener === 'function') {
259
+ attachedWebContents.removeListener('before-mouse-event', handleMouseEvent)
260
+ }
261
+ attachedWebContents = null
262
+ }
263
+
264
+ async function start() {
265
+ if (armed) return getStatus()
266
+ limitReached = null
267
+ sessionStopError = null
268
+ armed = true
269
+ try {
270
+ if (eventTarget && typeof eventTarget.addEventListener === 'function') {
271
+ eventTarget.addEventListener('game.response', handleGameResponse)
272
+ }
273
+ tryAttach()
274
+ attachTimer = setIntervalFn(tryAttach, attachIntervalMs)
275
+ return getStatus()
276
+ } catch (error) {
277
+ armed = false
278
+ if (attachTimer != null) {
279
+ clearIntervalFn(attachTimer)
280
+ attachTimer = null
281
+ }
282
+ if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
283
+ eventTarget.removeEventListener('game.response', handleGameResponse)
284
+ }
285
+ detachWebContents()
286
+ throw error
287
+ }
288
+ }
289
+
290
+ async function stop() {
291
+ armed = false
292
+ startAfterClose = false
293
+ bufferedMouseEvents = []
294
+ bufferedResponses = []
295
+ bufferedGesture = false
296
+ clearIdleTimer()
297
+ if (attachTimer != null) {
298
+ clearIntervalFn(attachTimer)
299
+ attachTimer = null
300
+ }
301
+ if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
302
+ eventTarget.removeEventListener('game.response', handleGameResponse)
303
+ }
304
+ detachWebContents()
305
+ let stopError = null
306
+ try {
307
+ await pending
308
+ if (sessionRecorder) {
309
+ const endingRecorder = sessionRecorder
310
+ try {
311
+ await endingRecorder.stop()
312
+ } catch (error) {
313
+ stopError = error
314
+ } finally {
315
+ try {
316
+ lastSessionStatus = endingRecorder.getStatus()
317
+ } catch (error) {
318
+ if (!stopError) stopError = error
319
+ }
320
+ if (sessionRecorder === endingRecorder) sessionRecorder = null
321
+ }
322
+ }
323
+ if (!stopError && sessionStopError) stopError = sessionStopError
324
+ } finally {
325
+ detachWebContents()
326
+ }
327
+ if (stopError) throw stopError
328
+ return getStatus()
329
+ }
330
+
331
+ function getStatus() {
332
+ const sessionStatus = sessionRecorder
333
+ ? sessionRecorder.getStatus()
334
+ : lastSessionStatus || {}
335
+ return {
336
+ armed,
337
+ starting,
338
+ running: sessionStatus.running === true,
339
+ acceptingEvents: sessionStatus.acceptingEvents === true,
340
+ attached: armed && (
341
+ sessionStatus.attached === true ||
342
+ attachedWebContents !== null
343
+ ),
344
+ sessionId: sessionStatus.sessionId || null,
345
+ sessionDir: sessionStatus.sessionDir || null,
346
+ sessionBytes: Number.isFinite(sessionStatus.sessionBytes)
347
+ ? sessionStatus.sessionBytes
348
+ : 0,
349
+ limitReached: sessionStatus.limitReached || limitReached,
350
+ idleSessionMs,
351
+ }
352
+ }
353
+
354
+ async function flush() {
355
+ while (true) {
356
+ const currentPending = pending
357
+ await currentPending
358
+ if (currentPending === pending) break
359
+ }
360
+ if (sessionRecorder && typeof sessionRecorder.flush === 'function') {
361
+ await sessionRecorder.flush()
362
+ }
363
+ }
364
+
365
+ if (typeof createSessionRecorder !== 'function') {
366
+ throw new TypeError('createSessionRecorder must be a function')
367
+ }
368
+
369
+ return Object.freeze({
370
+ flush,
371
+ getStatus,
372
+ start,
373
+ stop,
374
+ })
375
+ }
376
+
377
+ function positiveInteger(value, fallback, field) {
378
+ const next = value === undefined ? fallback : value
379
+ if (!Number.isSafeInteger(next) || next <= 0) {
380
+ throw new TypeError(`${field} must be a positive integer`)
381
+ }
382
+ return next
383
+ }
384
+
385
+ function defaultEventTarget() {
386
+ return typeof window !== 'undefined' ? window : null
387
+ }
388
+
389
+ function defaultGetStore(path) {
390
+ if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
391
+ return window.getStore(path)
392
+ }
393
+ return undefined
394
+ }
395
+
396
+ module.exports = {
397
+ DEFAULT_IDLE_SESSION_MS,
398
+ createPoiAutoInteractionRecorder,
399
+ }