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,1693 @@
1
+ const crypto = require('node:crypto')
2
+ const fs = require('node:fs')
3
+ const os = require('node:os')
4
+ const path = require('node:path')
5
+
6
+ const { createPoiScreenshotProvider } = require('./poi-screenshot')
7
+
8
+ const CANONICAL_WIDTH = 1200
9
+ const CANONICAL_HEIGHT = 720
10
+ const DEFAULT_HOLD_THRESHOLD_MS = 200
11
+ const DEFAULT_DRAG_THRESHOLD_PX = 6
12
+ const DEFAULT_ATTACH_INTERVAL_MS = 1000
13
+ const DEFAULT_MAX_PATH_POINTS = 256
14
+ const DEFAULT_MAX_JSON_BYTES = 16 * 1024 * 1024
15
+ const DEFAULT_MAX_SESSION_BYTES = 8 * 1024 * 1024 * 1024
16
+ const DEFAULT_MAX_TIMELINE_EVENTS = 20000
17
+ const DEFAULT_MAX_SESSION_DURATION_MS = 4 * 60 * 60 * 1000
18
+ const DEFAULT_MAX_SCREENSHOT_BYTES = 16 * 1024 * 1024
19
+ const DEFAULT_MAX_STORED_SESSIONS = 200
20
+ const DEFAULT_MAX_TOTAL_RECORDING_BYTES = 64 * 1024 * 1024 * 1024
21
+ const DEFAULT_FINAL_MANIFEST_RESERVE_BYTES = 64 * 1024
22
+ const DEFAULT_CHECKPOINT_DELAYS_MS = Object.freeze([0, 250, 1000])
23
+ const DEFAULT_OUTPUT_ROOT = process.platform === 'win32'
24
+ ? 'D:\\poi-mcp\\recordings'
25
+ : path.join(os.homedir(), '.poi-mcp', 'recordings')
26
+ const SENSITIVE_KEY = /(auth|authorization|cookie|credential|key|login(?:data)?|password|secret|session|sid|ticket|token)/iu
27
+ const SENSITIVE_VALUE = /\b(?:basic|bearer|api[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?id)\b/iu
28
+ const GAME_BUSINESS_SORT_KEYS = new Set([
29
+ 'api_sort_key',
30
+ 'shipSortKeyType',
31
+ 'sort_key',
32
+ 'sortKey',
33
+ ])
34
+ const EQUIPMENT_UI_STORAGE_KEYS = new Set([
35
+ 'listMode',
36
+ 'slotItemFilter',
37
+ 'slotItemFilterDetail',
38
+ 'slotItemPage',
39
+ ])
40
+
41
+ function createPoiInteractionRecorder(options = {}) {
42
+ const getStore = options.getStore || defaultGetStore
43
+ const captureScreenshot = options.captureScreenshot ||
44
+ createPoiScreenshotProvider({ getStore })
45
+ const captureWebStorage = options.captureWebStorage || defaultCaptureWebStorage
46
+ const captureEquipmentUiState = options.captureEquipmentUiState ||
47
+ defaultCaptureEquipmentUiState
48
+ const eventTarget = options.eventTarget === undefined
49
+ ? defaultEventTarget()
50
+ : options.eventTarget
51
+ const resolveWebContents = options.resolveWebContents || defaultResolveWebContents
52
+ const outputRoot = options.outputRoot || DEFAULT_OUTPUT_ROOT
53
+ const logger = options.logger || console
54
+ const now = options.now || (() => new Date())
55
+ const nowMs = options.nowMs || (() => Date.now())
56
+ const setIntervalFn = options.setInterval || setInterval
57
+ const clearIntervalFn = options.clearInterval || clearInterval
58
+ const setTimeoutFn = options.setTimeout || setTimeout
59
+ const clearTimeoutFn = options.clearTimeout || clearTimeout
60
+ const attachIntervalMs = positiveInteger(
61
+ options.attachIntervalMs,
62
+ DEFAULT_ATTACH_INTERVAL_MS,
63
+ 'attachIntervalMs',
64
+ )
65
+ const holdThresholdMs = positiveInteger(
66
+ options.holdThresholdMs,
67
+ DEFAULT_HOLD_THRESHOLD_MS,
68
+ 'holdThresholdMs',
69
+ )
70
+ const dragThresholdPx = positiveNumber(
71
+ options.dragThresholdPx,
72
+ DEFAULT_DRAG_THRESHOLD_PX,
73
+ 'dragThresholdPx',
74
+ )
75
+ const maxPathPoints = positiveInteger(
76
+ options.maxPathPoints,
77
+ DEFAULT_MAX_PATH_POINTS,
78
+ 'maxPathPoints',
79
+ )
80
+ const maxJsonBytes = positiveInteger(
81
+ options.maxJsonBytes,
82
+ DEFAULT_MAX_JSON_BYTES,
83
+ 'maxJsonBytes',
84
+ )
85
+ const maxSessionBytes = positiveInteger(
86
+ options.maxSessionBytes,
87
+ DEFAULT_MAX_SESSION_BYTES,
88
+ 'maxSessionBytes',
89
+ )
90
+ const maxTimelineEvents = positiveInteger(
91
+ options.maxTimelineEvents,
92
+ DEFAULT_MAX_TIMELINE_EVENTS,
93
+ 'maxTimelineEvents',
94
+ )
95
+ const maxSessionDurationMs = positiveInteger(
96
+ options.maxSessionDurationMs,
97
+ DEFAULT_MAX_SESSION_DURATION_MS,
98
+ 'maxSessionDurationMs',
99
+ )
100
+ const maxScreenshotBytes = positiveInteger(
101
+ options.maxScreenshotBytes,
102
+ DEFAULT_MAX_SCREENSHOT_BYTES,
103
+ 'maxScreenshotBytes',
104
+ )
105
+ const maxStoredSessions = positiveInteger(
106
+ options.maxStoredSessions,
107
+ DEFAULT_MAX_STORED_SESSIONS,
108
+ 'maxStoredSessions',
109
+ )
110
+ const maxTotalRecordingBytes = positiveInteger(
111
+ options.maxTotalRecordingBytes,
112
+ DEFAULT_MAX_TOTAL_RECORDING_BYTES,
113
+ 'maxTotalRecordingBytes',
114
+ )
115
+ const checkpointDelaysMs = normalizeCheckpointDelays(
116
+ options.checkpointDelaysMs,
117
+ )
118
+
119
+ let running = false
120
+ let attachedWebContents = null
121
+ let attachTimer = null
122
+ let sessionDeadlineTimer = null
123
+ let activeGesture = null
124
+ let gestureSequence = 0
125
+ let responseSequence = 0
126
+ let timelineSequence = 0
127
+ let sessionId = null
128
+ let sessionDir = null
129
+ let manifest = null
130
+ let pending = Promise.resolve()
131
+ let lastStorageEntries = new Map()
132
+ let acceptingEvents = false
133
+ let limitReached = null
134
+ let sessionBytes = 0
135
+ let activeSessionByteLimit = maxSessionBytes
136
+ let finalManifestReserveBytes = 0
137
+ let sessionStartedAtMs = 0
138
+ let storageHashKey = null
139
+ let sessionFileSizes = new Map()
140
+ let fileWritePending = Promise.resolve()
141
+ const checkpointTimers = new Map()
142
+
143
+ function enqueue(action) {
144
+ const result = pending.then(action, action)
145
+ pending = result.catch((error) => {
146
+ logger.error(`[poi-plugin-mcp] Recorder write failed: ${error.message}`)
147
+ })
148
+ return result
149
+ }
150
+
151
+ function withinDurationBudget() {
152
+ if (!acceptingEvents) return false
153
+ if (nowMs() - sessionStartedAtMs <= maxSessionDurationMs) return true
154
+ reachLimit('session-duration-limit')
155
+ return false
156
+ }
157
+
158
+ function reachLimit(reason) {
159
+ if (limitReached) return
160
+ limitReached = {
161
+ reason,
162
+ reachedAt: timestamp(now()),
163
+ }
164
+ acceptingEvents = false
165
+ running = false
166
+ clearAttachTimer()
167
+ clearSessionDeadlineTimer()
168
+ cancelCheckpointTimers()
169
+ activeGesture = null
170
+ if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
171
+ eventTarget.removeEventListener('game.response', handleGameResponse)
172
+ }
173
+ detachWebContents()
174
+ logger.error(`[poi-plugin-mcp] Play recording paused: ${reason}`)
175
+ enqueue(finalizeSession)
176
+ }
177
+
178
+ function writeSessionFile(filePath, data, options = {}) {
179
+ const operation = fileWritePending.then(
180
+ () => writeSessionFileNow(filePath, data, options),
181
+ () => writeSessionFileNow(filePath, data, options),
182
+ )
183
+ fileWritePending = operation.catch(() => {})
184
+ return operation
185
+ }
186
+
187
+ async function writeSessionFileNow(filePath, data, options = {}) {
188
+ if (!acceptingEvents && !options.finalizing) return false
189
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8')
190
+ const fileKey = path.resolve(filePath)
191
+ const previousSize = sessionFileSizes.get(fileKey) || 0
192
+ const nextSize = options.append
193
+ ? previousSize + buffer.length
194
+ : buffer.length
195
+ const byteDelta = nextSize - previousSize
196
+ const byteLimit = options.finalizing
197
+ ? activeSessionByteLimit
198
+ : activeSessionByteLimit - finalManifestReserveBytes
199
+ if (
200
+ sessionBytes + byteDelta > byteLimit
201
+ ) {
202
+ reachLimit('session-byte-limit')
203
+ return false
204
+ }
205
+ try {
206
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true })
207
+ if (options.append) {
208
+ await fs.promises.appendFile(filePath, buffer)
209
+ } else {
210
+ await fs.promises.writeFile(filePath, buffer)
211
+ }
212
+ sessionFileSizes.set(fileKey, nextSize)
213
+ sessionBytes += byteDelta
214
+ return true
215
+ } catch (error) {
216
+ throw error
217
+ }
218
+ }
219
+
220
+ async function writeSessionJson(filePath, value, options = {}) {
221
+ const json = serializeBoundedJson(value, maxJsonBytes)
222
+ return writeSessionFile(filePath, json, options)
223
+ }
224
+
225
+ function currentLayout() {
226
+ const layout = getStore('layout.webview')
227
+ if (
228
+ !layout ||
229
+ !Number.isFinite(layout.width) ||
230
+ layout.width <= 0 ||
231
+ !Number.isFinite(layout.height) ||
232
+ layout.height <= 0
233
+ ) {
234
+ return null
235
+ }
236
+ return layout
237
+ }
238
+
239
+ function resolveCurrentWebContents() {
240
+ const layout = currentLayout()
241
+ if (!layout || !layout.ref) return null
242
+ if (typeof layout.ref.getWebContents === 'function') {
243
+ return layout.ref.getWebContents()
244
+ }
245
+ if (typeof layout.ref.getWebContentsId === 'function') {
246
+ const id = layout.ref.getWebContentsId()
247
+ if (Number.isInteger(id) && id > 0) return resolveWebContents(id)
248
+ }
249
+ return null
250
+ }
251
+
252
+ function tryAttach() {
253
+ if (!running || !acceptingEvents) return false
254
+ let nextWebContents = null
255
+ try {
256
+ nextWebContents = resolveCurrentWebContents()
257
+ } catch (error) {
258
+ logger.error(`[poi-plugin-mcp] Recorder WebView lookup failed: ${error.message}`)
259
+ return false
260
+ }
261
+ if (
262
+ !nextWebContents ||
263
+ typeof nextWebContents.on !== 'function'
264
+ ) {
265
+ interruptActiveGesture()
266
+ detachWebContents()
267
+ return false
268
+ }
269
+ if (nextWebContents === attachedWebContents) return true
270
+
271
+ interruptActiveGesture()
272
+ detachWebContents()
273
+ attachedWebContents = nextWebContents
274
+ attachedWebContents.on('before-mouse-event', handleMouseEvent)
275
+ attachedWebContents.on('did-start-navigation', handleWebContentsNavigation)
276
+ if (typeof attachedWebContents.once === 'function') {
277
+ attachedWebContents.once('destroyed', handleWebContentsDestroyed)
278
+ }
279
+ return true
280
+ }
281
+
282
+ function handleWebContentsDestroyed() {
283
+ interruptActiveGesture()
284
+ detachWebContents()
285
+ }
286
+
287
+ function handleWebContentsNavigation(
288
+ _event,
289
+ _url,
290
+ _isInPlace,
291
+ isMainFrame,
292
+ ) {
293
+ if (isMainFrame === false) return
294
+ interruptActiveGesture()
295
+ detachWebContents()
296
+ }
297
+
298
+ function detachWebContents() {
299
+ if (!attachedWebContents) return
300
+ const remove = typeof attachedWebContents.off === 'function'
301
+ ? attachedWebContents.off.bind(attachedWebContents)
302
+ : typeof attachedWebContents.removeListener === 'function'
303
+ ? attachedWebContents.removeListener.bind(attachedWebContents)
304
+ : null
305
+ if (remove) {
306
+ remove('before-mouse-event', handleMouseEvent)
307
+ remove('did-start-navigation', handleWebContentsNavigation)
308
+ remove('destroyed', handleWebContentsDestroyed)
309
+ }
310
+ attachedWebContents = null
311
+ }
312
+
313
+ function interruptActiveGesture() {
314
+ if (!activeGesture) return
315
+ finishGesture({
316
+ x: activeGesture.last.webview.x,
317
+ y: activeGesture.last.webview.y,
318
+ button: activeGesture.button,
319
+ }, true)
320
+ }
321
+
322
+ function handleMouseEvent(_event, mouse) {
323
+ observeMouseEvent({
324
+ mouse,
325
+ observedAt: timestamp(now()),
326
+ observedAtMs: nowMs(),
327
+ screenshot: null,
328
+ })
329
+ }
330
+
331
+ function observeMouseEvent(observation) {
332
+ const mouse = observation && observation.mouse
333
+ if (
334
+ !running ||
335
+ !acceptingEvents ||
336
+ !mouse ||
337
+ typeof mouse !== 'object' ||
338
+ !Number.isFinite(mouse.x) ||
339
+ !Number.isFinite(mouse.y)
340
+ ) {
341
+ return
342
+ }
343
+ if (!withinDurationBudget()) return
344
+ const timing = {
345
+ observedAt: normalizedObservedAt(observation.observedAt, now),
346
+ observedAtMs: Number.isFinite(observation.observedAtMs)
347
+ ? observation.observedAtMs
348
+ : nowMs(),
349
+ screenshot: observation.screenshot || null,
350
+ }
351
+
352
+ if (mouse.type === 'mouseDown') {
353
+ beginGesture(mouse, timing)
354
+ return
355
+ }
356
+ if (mouse.type === 'mouseMove' && activeGesture) {
357
+ if (!reportsHeldButton(mouse, activeGesture.button)) {
358
+ interruptActiveGesture()
359
+ return
360
+ }
361
+ updateGesture(mouse, timing)
362
+ return
363
+ }
364
+ if (mouse.type === 'mouseUp' && activeGesture) {
365
+ if (mouse.button === activeGesture.button) finishGesture(mouse, false, timing)
366
+ }
367
+ }
368
+
369
+ function beginGesture(mouse, timing) {
370
+ if (activeGesture) finishGesture(mouse, true)
371
+ const layout = currentLayout()
372
+ if (!layout) return
373
+
374
+ gestureSequence += 1
375
+ const id = `gesture-${String(gestureSequence).padStart(6, '0')}`
376
+ const startedAtMs = timing.observedAtMs
377
+ const start = pointerPoint(mouse, layout, 0)
378
+ const startRelativePath = `frames/${id}-start.png`
379
+ activeGesture = {
380
+ id,
381
+ button: supportedButton(mouse.button),
382
+ startedAt: timing.observedAt,
383
+ startedAtMs,
384
+ layout: { width: layout.width, height: layout.height },
385
+ start,
386
+ last: start,
387
+ maxDisplacementPx: 0,
388
+ observedMovement: false,
389
+ path: [pathPoint(start)],
390
+ startScreenshot: requestScreenshot(
391
+ startRelativePath,
392
+ 'start',
393
+ timing.screenshot,
394
+ ),
395
+ }
396
+ }
397
+
398
+ function updateGesture(mouse, timing) {
399
+ const gesture = activeGesture
400
+ gesture.observedMovement = true
401
+ const point = pointerPoint(
402
+ mouse,
403
+ gesture.layout,
404
+ timing.observedAtMs - gesture.startedAtMs,
405
+ )
406
+ gesture.last = point
407
+ gesture.maxDisplacementPx = Math.max(
408
+ gesture.maxDisplacementPx,
409
+ distance(gesture.start.webview, point.webview),
410
+ )
411
+ appendPathPoint(gesture.path, pathPoint(point), maxPathPoints)
412
+ }
413
+
414
+ function finishGesture(mouse, interrupted = false, timing = null) {
415
+ const gesture = activeGesture
416
+ if (!gesture) return
417
+ activeGesture = null
418
+
419
+ const endedAtMs = timing && Number.isFinite(timing.observedAtMs)
420
+ ? timing.observedAtMs
421
+ : nowMs()
422
+ const endedAt = timing && typeof timing.observedAt === 'string'
423
+ ? timing.observedAt
424
+ : timestamp(now())
425
+ const end = pointerPoint(
426
+ mouse,
427
+ gesture.layout,
428
+ Math.max(0, endedAtMs - gesture.startedAtMs),
429
+ )
430
+ appendPathPoint(gesture.path, pathPoint(end), maxPathPoints)
431
+ const displacementPx = distance(gesture.start.webview, end.webview)
432
+ gesture.maxDisplacementPx = Math.max(
433
+ gesture.maxDisplacementPx,
434
+ displacementPx,
435
+ )
436
+ const durationMs = Math.max(0, Math.round(endedAtMs - gesture.startedAtMs))
437
+ const gestureType = interrupted
438
+ ? 'interrupted'
439
+ : gesture.maxDisplacementPx >= dragThresholdPx
440
+ ? 'drag'
441
+ : durationMs > holdThresholdMs && gesture.observedMovement
442
+ ? 'hold'
443
+ : 'click'
444
+ const screenshots = [gesture.startScreenshot]
445
+ if (gestureType !== 'click') {
446
+ screenshots.push(requestScreenshot(`frames/${gesture.id}-end.png`, 'end'))
447
+ }
448
+
449
+ enqueue(async () => {
450
+ const screenshotResults = await Promise.all(screenshots)
451
+ await appendTimeline({
452
+ kind: 'pointer',
453
+ gesture: gestureType,
454
+ gestureId: gesture.id,
455
+ occurredAt: gesture.startedAt,
456
+ endedAt,
457
+ button: gesture.button,
458
+ durationMs,
459
+ displacementPx: round3(displacementPx),
460
+ maxDisplacementPx: round3(gesture.maxDisplacementPx),
461
+ start: stripElapsed(gesture.start),
462
+ end: stripElapsed(end),
463
+ path: gesture.path,
464
+ screenshots: screenshotResults,
465
+ })
466
+ })
467
+ if (!interrupted) scheduleGestureCheckpoints(gesture.id)
468
+ }
469
+
470
+ function requestScreenshot(relativePath, phase, prefetchedScreenshot = null) {
471
+ let requested
472
+ if (prefetchedScreenshot) {
473
+ requested = Promise.resolve(prefetchedScreenshot)
474
+ } else {
475
+ try {
476
+ requested = Promise.resolve(captureScreenshot())
477
+ } catch (error) {
478
+ requested = Promise.reject(error)
479
+ }
480
+ }
481
+ return requested.then(async (capture) => {
482
+ if (
483
+ !capture ||
484
+ capture.mimeType !== 'image/png' ||
485
+ typeof capture.dataBase64 !== 'string' ||
486
+ capture.dataBase64.length === 0
487
+ ) {
488
+ throw new Error('Recorder screenshot must be a PNG base64 payload')
489
+ }
490
+ const data = Buffer.from(capture.dataBase64, 'base64')
491
+ if (data.length > maxScreenshotBytes) {
492
+ throw new Error(`Recorder screenshot exceeds ${maxScreenshotBytes} bytes`)
493
+ }
494
+ const absolutePath = path.join(sessionDir, ...relativePath.split('/'))
495
+ const written = await writeSessionFile(absolutePath, data)
496
+ if (!written) throw new Error('Recorder session byte limit reached')
497
+ return {
498
+ phase,
499
+ file: relativePath,
500
+ capturedAt: capture.capturedAt || timestamp(now()),
501
+ }
502
+ }).catch((error) => {
503
+ logger.error(`[poi-plugin-mcp] Recorder screenshot failed: ${error.message}`)
504
+ return {
505
+ phase,
506
+ file: null,
507
+ error: error.message,
508
+ }
509
+ })
510
+ }
511
+
512
+ function handleGameResponse(event) {
513
+ observeGameResponse({
514
+ detail: event && event.detail,
515
+ observedAt: timestamp(now()),
516
+ })
517
+ }
518
+
519
+ function observeGameResponse(observation) {
520
+ if (!running || !acceptingEvents || !withinDurationBudget()) return
521
+ const detail = observation && observation.detail
522
+ if (!detail || typeof detail.path !== 'string') return
523
+
524
+ responseSequence += 1
525
+ const responseId = `response-${String(responseSequence).padStart(6, '0')}`
526
+ const apiPath = safeApiPath(detail.path)
527
+ const leaf = safeFilename(path.posix.basename(apiPath) || 'response')
528
+ const relativePath = `responses/${responseId}-${leaf}.json`
529
+ const capturedAt = normalizedObservedAt(observation.observedAt, now)
530
+ const sanitized = sanitizeDump(detail)
531
+ const responseDocument = {
532
+ schemaVersion: 1,
533
+ responseId,
534
+ capturedAt,
535
+ ...sanitized,
536
+ path: apiPath,
537
+ reportedTimeMs: Number.isFinite(detail.time) ? detail.time : null,
538
+ localState: snapshotInteractionState(getStore()),
539
+ }
540
+
541
+ enqueue(async () => {
542
+ await writeSessionJson(
543
+ path.join(sessionDir, ...relativePath.split('/')),
544
+ responseDocument,
545
+ )
546
+ await appendTimeline({
547
+ kind: 'game.response',
548
+ responseId,
549
+ occurredAt: capturedAt,
550
+ path: apiPath,
551
+ reportedTimeMs: responseDocument.reportedTimeMs,
552
+ responseFile: relativePath,
553
+ })
554
+ })
555
+ }
556
+
557
+ async function appendTimeline(event) {
558
+ if (!acceptingEvents) return false
559
+ if (timelineSequence >= maxTimelineEvents) {
560
+ reachLimit('timeline-event-limit')
561
+ return false
562
+ }
563
+ timelineSequence += 1
564
+ const line = JSON.stringify({
565
+ sequence: timelineSequence,
566
+ ...event,
567
+ })
568
+ if (Buffer.byteLength(line) > maxJsonBytes) {
569
+ timelineSequence -= 1
570
+ reachLimit('timeline-line-limit')
571
+ return false
572
+ }
573
+ const written = await writeSessionFile(
574
+ path.join(sessionDir, 'events.jsonl'),
575
+ `${line}\n`,
576
+ { append: true },
577
+ )
578
+ if (!written) timelineSequence -= 1
579
+ return written
580
+ }
581
+
582
+ async function writeStateSnapshot(filename, capturedAt) {
583
+ const store = getStore()
584
+ const snapshot = {
585
+ schemaVersion: 1,
586
+ capturedAt,
587
+ ...sanitizeDump(selectStoreState(store)),
588
+ }
589
+ await writeSessionJson(
590
+ path.join(sessionDir, 'states', filename),
591
+ snapshot,
592
+ )
593
+ }
594
+
595
+ function scheduleGestureCheckpoints(gestureId) {
596
+ for (const delayMs of checkpointDelaysMs) {
597
+ let resolveCompletion
598
+ const completion = new Promise((resolve) => {
599
+ resolveCompletion = resolve
600
+ })
601
+ let timerHandle = null
602
+ const callback = () => {
603
+ checkpointTimers.delete(timerHandle)
604
+ enqueue(() => writeGestureCheckpoint(gestureId, delayMs))
605
+ .finally(resolveCompletion)
606
+ }
607
+ timerHandle = setTimeoutFn(callback, delayMs)
608
+ checkpointTimers.set(timerHandle, { completion, resolveCompletion })
609
+ }
610
+ }
611
+
612
+ async function writeGestureCheckpoint(gestureId, delayMs) {
613
+ const delayLabel = String(delayMs).padStart(4, '0')
614
+ const basename = `${gestureId.replace('gesture-', 'gesture-')}-${delayLabel}ms.json`
615
+ const capturedAt = timestamp(now())
616
+ const stateRelativePath = `checkpoints/${basename}`
617
+ const storageRelativePath = `storage/${basename}`
618
+ const equipmentUi = await readEquipmentUiState()
619
+ await writeSessionJson(
620
+ path.join(sessionDir, ...stateRelativePath.split('/')),
621
+ {
622
+ schemaVersion: 1,
623
+ gestureId,
624
+ delayMs,
625
+ capturedAt,
626
+ ...snapshotInteractionState(getStore()),
627
+ equipmentUi,
628
+ },
629
+ )
630
+ await writeStorageSnapshot(storageRelativePath, {
631
+ gestureId,
632
+ delayMs,
633
+ capturedAt,
634
+ })
635
+ await appendTimeline({
636
+ kind: 'checkpoint',
637
+ gestureId,
638
+ delayMs,
639
+ occurredAt: capturedAt,
640
+ stateFile: stateRelativePath,
641
+ storageFile: storageRelativePath,
642
+ })
643
+ }
644
+
645
+ async function readEquipmentUiState() {
646
+ const webContents = attachedWebContents || resolveCurrentWebContents()
647
+ try {
648
+ if (!webContents) throw new Error('Poi game WebView is not ready')
649
+ return {
650
+ available: true,
651
+ error: null,
652
+ frames: sanitizeDump(await captureEquipmentUiState(webContents)),
653
+ }
654
+ } catch (error) {
655
+ return {
656
+ available: false,
657
+ error: error.message,
658
+ frames: [],
659
+ }
660
+ }
661
+ }
662
+
663
+ async function writeStorageSnapshot(relativePath, metadata = {}) {
664
+ let frames = []
665
+ let available = false
666
+ let error = null
667
+ const webContents = attachedWebContents || resolveCurrentWebContents()
668
+ try {
669
+ if (!webContents) throw new Error('Poi game WebView is not ready')
670
+ const rawFrames = await captureWebStorage(webContents)
671
+ frames = normalizeStorageFrames(rawFrames, storageHashKey)
672
+ available = true
673
+ } catch (captureError) {
674
+ error = captureError.message
675
+ }
676
+ const { entries, changes } = diffStorageFrames(frames, lastStorageEntries)
677
+ lastStorageEntries = entries
678
+ await writeSessionJson(
679
+ path.join(sessionDir, ...relativePath.split('/')),
680
+ {
681
+ schemaVersion: 1,
682
+ available,
683
+ error,
684
+ capturedAt: metadata.capturedAt || timestamp(now()),
685
+ ...metadata,
686
+ frames,
687
+ changes,
688
+ },
689
+ )
690
+ }
691
+
692
+ function cancelCheckpointTimers() {
693
+ for (const [timerHandle, item] of checkpointTimers) {
694
+ clearTimeoutFn(timerHandle)
695
+ item.resolveCompletion()
696
+ }
697
+ checkpointTimers.clear()
698
+ }
699
+
700
+ function clearAttachTimer() {
701
+ if (attachTimer == null) return
702
+ clearIntervalFn(attachTimer)
703
+ attachTimer = null
704
+ }
705
+
706
+ function clearSessionDeadlineTimer() {
707
+ if (sessionDeadlineTimer == null) return
708
+ clearTimeoutFn(sessionDeadlineTimer)
709
+ sessionDeadlineTimer = null
710
+ }
711
+
712
+ async function start() {
713
+ if (running) return getStatus()
714
+ try {
715
+ const recordingRoot = await inspectRecordingRoot(outputRoot)
716
+ if (recordingRoot.sessionCount >= maxStoredSessions) {
717
+ const overflow = recordingRoot.sessionCount - maxStoredSessions + 1
718
+ await pruneOldestSessions(recordingRoot.sessionDirectories, overflow, logger)
719
+ }
720
+ const afterPrune = await inspectRecordingRoot(outputRoot)
721
+ if (afterPrune.byteCount >= maxTotalRecordingBytes) {
722
+ throw new Error(
723
+ `Recorder aggregate recording byte limit reached (${maxTotalRecordingBytes}); archive or remove old recordings before starting`,
724
+ )
725
+ }
726
+ activeSessionByteLimit = Math.min(
727
+ maxSessionBytes,
728
+ maxTotalRecordingBytes - afterPrune.byteCount,
729
+ )
730
+ finalManifestReserveBytes = Math.min(
731
+ DEFAULT_FINAL_MANIFEST_RESERVE_BYTES,
732
+ Math.floor(activeSessionByteLimit / 4),
733
+ )
734
+ const startedAt = timestamp(now())
735
+ sessionId = boundedSessionId(
736
+ typeof options.sessionId === 'string'
737
+ ? options.sessionId
738
+ : crypto.randomUUID(),
739
+ )
740
+ const directoryName = `${compactTimestamp(startedAt)}-${sessionId}`
741
+ sessionDir = path.join(outputRoot, directoryName)
742
+ manifest = {
743
+ schemaVersion: 1,
744
+ sessionId,
745
+ startedAt,
746
+ endedAt: null,
747
+ holdThresholdMs,
748
+ dragThresholdPx,
749
+ canonicalSize: {
750
+ width: CANONICAL_WIDTH,
751
+ height: CANONICAL_HEIGHT,
752
+ },
753
+ privacy: {
754
+ sensitiveKeysRedacted: true,
755
+ localStorageCaptured: true,
756
+ sessionStorageCaptured: true,
757
+ cookiesCaptured: false,
758
+ cacheStorageCaptured: false,
759
+ indexedDbCaptured: false,
760
+ genericStorageValuesPlaintext: false,
761
+ genericStorageHashesSessionScoped: true,
762
+ },
763
+ budgets: {
764
+ maxSessionBytes,
765
+ maxTimelineEvents,
766
+ maxSessionDurationMs,
767
+ maxScreenshotBytes,
768
+ maxStoredSessions,
769
+ maxTotalRecordingBytes,
770
+ activeSessionByteLimit,
771
+ finalManifestReserveBytes,
772
+ },
773
+ }
774
+ gestureSequence = 0
775
+ responseSequence = 0
776
+ timelineSequence = 0
777
+ activeGesture = null
778
+ pending = Promise.resolve()
779
+ lastStorageEntries = new Map()
780
+ acceptingEvents = true
781
+ limitReached = null
782
+ sessionBytes = 0
783
+ sessionFileSizes = new Map()
784
+ fileWritePending = Promise.resolve()
785
+ sessionStartedAtMs = nowMs()
786
+ storageHashKey = crypto.randomBytes(32)
787
+ running = true
788
+ cancelCheckpointTimers()
789
+
790
+ await fs.promises.mkdir(path.join(sessionDir, 'frames'), { recursive: true })
791
+ await fs.promises.mkdir(path.join(sessionDir, 'responses'), { recursive: true })
792
+ await fs.promises.mkdir(path.join(sessionDir, 'states'), { recursive: true })
793
+ await fs.promises.mkdir(path.join(sessionDir, 'storage'), { recursive: true })
794
+ await fs.promises.mkdir(path.join(sessionDir, 'checkpoints'), { recursive: true })
795
+ await writeSessionJson(
796
+ path.join(sessionDir, 'manifest.json'),
797
+ manifest,
798
+ )
799
+ await writeStateSnapshot('session-start.json', startedAt)
800
+
801
+ await writeSessionJson(
802
+ path.join(sessionDir, 'checkpoints', 'session-start-ui.json'),
803
+ {
804
+ schemaVersion: 1,
805
+ capturedAt: startedAt,
806
+ equipmentUi: await readEquipmentUiState(),
807
+ },
808
+ )
809
+ await writeStorageSnapshot('storage/session-start.json', {
810
+ capturedAt: startedAt,
811
+ phase: 'session-start',
812
+ })
813
+ if (!acceptingEvents) {
814
+ await pending
815
+ return getStatus()
816
+ }
817
+ sessionDeadlineTimer = setTimeoutFn(() => {
818
+ sessionDeadlineTimer = null
819
+ reachLimit('session-duration-limit')
820
+ }, maxSessionDurationMs)
821
+ attachTimer = setIntervalFn(tryAttach, attachIntervalMs)
822
+ if (eventTarget && typeof eventTarget.addEventListener === 'function') {
823
+ eventTarget.addEventListener('game.response', handleGameResponse)
824
+ }
825
+ tryAttach()
826
+ logger.log(`[poi-plugin-mcp] Play recording started: ${sessionDir}`)
827
+ return getStatus()
828
+ } catch (error) {
829
+ running = false
830
+ acceptingEvents = false
831
+ clearAttachTimer()
832
+ clearSessionDeadlineTimer()
833
+ cancelCheckpointTimers()
834
+ activeGesture = null
835
+ if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
836
+ eventTarget.removeEventListener('game.response', handleGameResponse)
837
+ }
838
+ detachWebContents()
839
+ throw error
840
+ }
841
+ }
842
+
843
+ async function stop() {
844
+ if (!running) {
845
+ await pending
846
+ return getStatus()
847
+ }
848
+ running = false
849
+ clearAttachTimer()
850
+ clearSessionDeadlineTimer()
851
+ if (eventTarget && typeof eventTarget.removeEventListener === 'function') {
852
+ eventTarget.removeEventListener('game.response', handleGameResponse)
853
+ }
854
+ cancelCheckpointTimers()
855
+ if (activeGesture) {
856
+ finishGesture({
857
+ x: activeGesture.last.webview.x,
858
+ y: activeGesture.last.webview.y,
859
+ button: activeGesture.button,
860
+ }, true)
861
+ }
862
+ await pending
863
+
864
+ const endedAt = timestamp(now())
865
+ if (!limitReached) {
866
+ await writeStorageSnapshot('storage/session-stop.json', {
867
+ capturedAt: endedAt,
868
+ phase: 'session-stop',
869
+ })
870
+ await writeStateSnapshot('session-stop.json', endedAt)
871
+ }
872
+ detachWebContents()
873
+ await finalizeSession()
874
+ return getStatus()
875
+ }
876
+
877
+ async function finalizeSession() {
878
+ if (!manifest || manifest.endedAt) return
879
+ running = false
880
+ acceptingEvents = false
881
+ clearAttachTimer()
882
+ clearSessionDeadlineTimer()
883
+ detachWebContents()
884
+ manifest = {
885
+ ...manifest,
886
+ endedAt: timestamp(now()),
887
+ pointerGestureCount: gestureSequence,
888
+ gameResponseCount: responseSequence,
889
+ timelineEventCount: timelineSequence,
890
+ sessionBytes,
891
+ limitReached,
892
+ }
893
+ let manifestWritten = false
894
+ for (let attempt = 0; attempt < 4; attempt += 1) {
895
+ manifest.sessionBytes = sessionBytes
896
+ manifestWritten = await writeSessionJson(
897
+ path.join(sessionDir, 'manifest.json'),
898
+ manifest,
899
+ { finalizing: true },
900
+ )
901
+ if (!manifestWritten || manifest.sessionBytes === sessionBytes) break
902
+ }
903
+ if (!manifestWritten) {
904
+ logger.error('[poi-plugin-mcp] Recorder could not write its final manifest within the session byte limit')
905
+ }
906
+ logger.log(`[poi-plugin-mcp] Play recording stopped: ${sessionDir}`)
907
+ }
908
+
909
+ async function flush() {
910
+ const scheduled = [...checkpointTimers.values()]
911
+ .map((item) => item.completion)
912
+ if (scheduled.length > 0) await Promise.all(scheduled)
913
+ await pending
914
+ }
915
+
916
+ function getStatus() {
917
+ return {
918
+ running: running && acceptingEvents,
919
+ acceptingEvents,
920
+ attached: running && acceptingEvents && attachedWebContents !== null,
921
+ sessionId,
922
+ sessionDir,
923
+ sessionBytes,
924
+ limitReached,
925
+ }
926
+ }
927
+
928
+ return Object.freeze({
929
+ flush,
930
+ getStatus,
931
+ observeGameResponse,
932
+ observeMouseEvent,
933
+ start,
934
+ stop,
935
+ })
936
+ }
937
+
938
+ function normalizedObservedAt(value, now) {
939
+ if (typeof value === 'string' && Number.isFinite(Date.parse(value))) {
940
+ return new Date(value).toISOString()
941
+ }
942
+ return timestamp(now())
943
+ }
944
+
945
+ const SESSION_DIRECTORY_PATTERN = /^\d{8}-\d{9}Z-/u
946
+
947
+ async function inspectRecordingRoot(outputRoot) {
948
+ let entries
949
+ try {
950
+ entries = await fs.promises.readdir(outputRoot, { withFileTypes: true })
951
+ } catch (error) {
952
+ if (error && error.code === 'ENOENT') {
953
+ return { sessionCount: 0, byteCount: 0, sessionDirectories: [] }
954
+ }
955
+ throw error
956
+ }
957
+ let byteCount = 0
958
+ const directories = entries
959
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
960
+ .map((entry) => path.join(outputRoot, entry.name))
961
+ .sort()
962
+ const files = entries
963
+ .filter((entry) => entry.isFile() && !entry.isSymbolicLink())
964
+ .map((entry) => path.join(outputRoot, entry.name))
965
+ for (const filePath of files) {
966
+ try {
967
+ byteCount += (await fs.promises.stat(filePath)).size
968
+ } catch (error) {
969
+ if (!error || error.code !== 'ENOENT') throw error
970
+ }
971
+ }
972
+ const stack = [...directories]
973
+ while (stack.length > 0) {
974
+ const directory = stack.pop()
975
+ let children
976
+ try {
977
+ children = await fs.promises.readdir(directory, { withFileTypes: true })
978
+ } catch (error) {
979
+ if (error && error.code === 'ENOENT') continue
980
+ throw error
981
+ }
982
+ for (const child of children) {
983
+ if (child.isSymbolicLink()) continue
984
+ const childPath = path.join(directory, child.name)
985
+ if (child.isDirectory()) {
986
+ stack.push(childPath)
987
+ } else if (child.isFile()) {
988
+ try {
989
+ byteCount += (await fs.promises.stat(childPath)).size
990
+ } catch (error) {
991
+ if (!error || error.code !== 'ENOENT') throw error
992
+ }
993
+ }
994
+ }
995
+ }
996
+ const sessionDirectories = directories.filter((directory) =>
997
+ SESSION_DIRECTORY_PATTERN.test(path.basename(directory)),
998
+ )
999
+ return {
1000
+ sessionCount: sessionDirectories.length,
1001
+ byteCount,
1002
+ sessionDirectories,
1003
+ }
1004
+ }
1005
+
1006
+ async function pruneOldestSessions(sessionDirectories, count, logger) {
1007
+ if (count <= 0) return
1008
+ for (const directory of sessionDirectories.slice(0, count)) {
1009
+ await fs.promises.rm(directory, { recursive: true, force: true })
1010
+ logger.log(
1011
+ `[poi-plugin-mcp] Pruned oldest recording session: ${path.basename(directory)}`,
1012
+ )
1013
+ }
1014
+ }
1015
+
1016
+ function pointerPoint(mouse, layout, elapsedMs) {
1017
+ const webview = {
1018
+ x: round3(mouse.x),
1019
+ y: round3(mouse.y),
1020
+ }
1021
+ return {
1022
+ elapsedMs: Math.max(0, Math.round(elapsedMs)),
1023
+ webview,
1024
+ canonical: {
1025
+ x: round3((webview.x * CANONICAL_WIDTH) / layout.width),
1026
+ y: round3((webview.y * CANONICAL_HEIGHT) / layout.height),
1027
+ },
1028
+ normalized: {
1029
+ x: round6(webview.x / layout.width),
1030
+ y: round6(webview.y / layout.height),
1031
+ },
1032
+ }
1033
+ }
1034
+
1035
+ function pathPoint(point) {
1036
+ return {
1037
+ elapsedMs: point.elapsedMs,
1038
+ webview: point.webview,
1039
+ canonical: point.canonical,
1040
+ }
1041
+ }
1042
+
1043
+ function stripElapsed(point) {
1044
+ const { elapsedMs: _elapsedMs, ...rest } = point
1045
+ return rest
1046
+ }
1047
+
1048
+ function appendPathPoint(points, point, maximum) {
1049
+ const previous = points[points.length - 1]
1050
+ if (
1051
+ previous &&
1052
+ previous.webview.x === point.webview.x &&
1053
+ previous.webview.y === point.webview.y &&
1054
+ previous.elapsedMs === point.elapsedMs
1055
+ ) {
1056
+ return
1057
+ }
1058
+ if (points.length < maximum) {
1059
+ points.push(point)
1060
+ } else {
1061
+ points[points.length - 1] = point
1062
+ }
1063
+ }
1064
+
1065
+ function distance(a, b) {
1066
+ return Math.hypot(b.x - a.x, b.y - a.y)
1067
+ }
1068
+
1069
+ function selectStoreState(store) {
1070
+ if (!store || typeof store !== 'object') {
1071
+ return {
1072
+ info: null,
1073
+ sortie: null,
1074
+ equipmentSortReference: {},
1075
+ }
1076
+ }
1077
+ return {
1078
+ info: store.info == null ? null : store.info,
1079
+ sortie: store.sortie == null ? null : store.sortie,
1080
+ equipmentSortReference: selectEquipmentSortReference(store),
1081
+ }
1082
+ }
1083
+
1084
+ function selectEquipmentSortReference(store) {
1085
+ const masters = store && store.const && store.const.$equips
1086
+ if (!masters || typeof masters !== 'object') return {}
1087
+ const reference = {}
1088
+ for (const [key, item] of Object.entries(masters).slice(0, 5000)) {
1089
+ if (!item || typeof item !== 'object') continue
1090
+ const keyId = Number(key)
1091
+ const masterId = Number.isInteger(item.api_id)
1092
+ ? item.api_id
1093
+ : Number.isInteger(keyId)
1094
+ ? keyId
1095
+ : null
1096
+ if (masterId == null) continue
1097
+ reference[masterId] = {
1098
+ masterId,
1099
+ name: typeof item.api_name === 'string'
1100
+ ? item.api_name.slice(0, 256)
1101
+ : '',
1102
+ type: Array.isArray(item.api_type)
1103
+ ? item.api_type.slice(0, 16).filter(Number.isFinite)
1104
+ : [],
1105
+ sortNo: Number.isFinite(item.api_sortno) ? item.api_sortno : null,
1106
+ }
1107
+ }
1108
+ return reference
1109
+ }
1110
+
1111
+ function snapshotInteractionState(store) {
1112
+ const info = store && typeof store === 'object' && store.info
1113
+ ? store.info
1114
+ : {}
1115
+ const equipment = info.equips && typeof info.equips === 'object'
1116
+ ? info.equips
1117
+ : {}
1118
+ const ships = info.ships && typeof info.ships === 'object'
1119
+ ? info.ships
1120
+ : {}
1121
+ const fleets = Array.isArray(info.fleets) ? info.fleets : []
1122
+ const shipSlotOrder = {}
1123
+ for (const [shipId, ship] of Object.entries(ships)) {
1124
+ if (ship && Array.isArray(ship.api_slot)) {
1125
+ shipSlotOrder[shipId] = ship.api_slot.slice()
1126
+ }
1127
+ }
1128
+ return {
1129
+ equipmentMembershipIds: Object.keys(equipment),
1130
+ shipSlotOrder,
1131
+ fleetShipOrder: fleets.map((fleet) => (
1132
+ fleet && Array.isArray(fleet.api_ship) ? fleet.api_ship.slice() : []
1133
+ )),
1134
+ }
1135
+ }
1136
+
1137
+ function normalizeStorageFrames(rawFrames, hashKey) {
1138
+ if (!Array.isArray(rawFrames)) return []
1139
+ return rawFrames.slice(0, 64).map((frame, frameIndex) => {
1140
+ const source = frame && typeof frame === 'object' ? frame : {}
1141
+ return {
1142
+ frameIndex,
1143
+ origin: boundedStorageText(source.origin, 2048),
1144
+ pathname: boundedStorageText(source.pathname, 4096),
1145
+ ...(source.error ? { error: boundedStorageText(source.error, 4096) } : {}),
1146
+ localStorage: normalizeStorageEntries(source.localStorage, hashKey),
1147
+ sessionStorage: normalizeStorageEntries(source.sessionStorage, hashKey),
1148
+ }
1149
+ })
1150
+ }
1151
+
1152
+ function normalizeStorageEntries(rawEntries, hashKey) {
1153
+ if (!Array.isArray(rawEntries)) return []
1154
+ return rawEntries.slice(0, 10000).flatMap((item) => {
1155
+ const key = Array.isArray(item) ? item[0] : item && item.key
1156
+ const value = Array.isArray(item) ? item[1] : item && item.value
1157
+ if (typeof key !== 'string') return []
1158
+ const rawValue = value == null ? '' : String(value)
1159
+ if (isSensitiveKey(key)) {
1160
+ return [{
1161
+ key: '[REDACTED]',
1162
+ sensitive: true,
1163
+ }]
1164
+ }
1165
+ const boundedKey = key.slice(0, 4096)
1166
+ const sensitiveValue = storageValueContainsSensitiveData(rawValue)
1167
+ if (EQUIPMENT_UI_STORAGE_KEYS.has(key)) {
1168
+ if (sensitiveValue) {
1169
+ return [{
1170
+ key: '[REDACTED]',
1171
+ sensitive: true,
1172
+ }]
1173
+ }
1174
+ return [{
1175
+ key: boundedKey,
1176
+ hash: hashText(rawValue, hashKey),
1177
+ value: boundedStorageText(rawValue, 1024),
1178
+ }]
1179
+ }
1180
+ const selected = selectEquipmentUiStorageFields(rawValue)
1181
+ if (selected && Object.keys(selected).length > 0) {
1182
+ const selectedText = JSON.stringify(selected)
1183
+ return [{
1184
+ key: boundedKey,
1185
+ hash: hashText(selectedText, hashKey),
1186
+ selected,
1187
+ }]
1188
+ }
1189
+ if (sensitiveValue) {
1190
+ return [{
1191
+ key: '[REDACTED]',
1192
+ sensitive: true,
1193
+ }]
1194
+ }
1195
+ return [{
1196
+ key: boundedKey,
1197
+ hash: hashText(rawValue, hashKey),
1198
+ }]
1199
+ })
1200
+ }
1201
+
1202
+ function storageValueContainsSensitiveData(rawValue) {
1203
+ if (
1204
+ SENSITIVE_VALUE.test(rawValue) ||
1205
+ looksLikeSensitiveHeaderLine(rawValue) ||
1206
+ /(?:^|[?&;\s])(?:api[_-]?key|auth|authorization|cookie|credential|key|login(?:data)?|password|secret|session|sid|ticket|token)=[^&;\s]+/iu.test(rawValue) ||
1207
+ looksLikeOpaqueCredential(rawValue)
1208
+ ) {
1209
+ return true
1210
+ }
1211
+ let parsed
1212
+ try {
1213
+ parsed = JSON.parse(rawValue)
1214
+ } catch (_) {
1215
+ return false
1216
+ }
1217
+ const queue = [{ value: parsed, depth: 0 }]
1218
+ let visited = 0
1219
+ while (queue.length > 0 && visited < 10000) {
1220
+ const { value, depth } = queue.shift()
1221
+ visited += 1
1222
+ if (typeof value === 'string') {
1223
+ if (
1224
+ SENSITIVE_VALUE.test(value) ||
1225
+ looksLikeSensitiveHeaderLine(value) ||
1226
+ looksLikeOpaqueCredential(value)
1227
+ ) {
1228
+ return true
1229
+ }
1230
+ continue
1231
+ }
1232
+ if (!value || typeof value !== 'object' || depth >= 8) continue
1233
+ for (const [key, item] of Object.entries(value).slice(0, 1000)) {
1234
+ if (isSensitiveKey(key)) return true
1235
+ queue.push({ value: item, depth: depth + 1 })
1236
+ }
1237
+ }
1238
+ return false
1239
+ }
1240
+
1241
+ function diffStorageFrames(frames, previousEntries) {
1242
+ const entries = new Map()
1243
+ const changes = []
1244
+ for (const frame of frames) {
1245
+ for (const storageName of ['localStorage', 'sessionStorage']) {
1246
+ for (const entry of frame[storageName]) {
1247
+ if (entry.sensitive) continue
1248
+ const identity = [
1249
+ frame.frameIndex,
1250
+ frame.origin,
1251
+ frame.pathname,
1252
+ storageName,
1253
+ entry.key,
1254
+ ].join('\u0000')
1255
+ const next = {
1256
+ origin: frame.origin,
1257
+ pathname: frame.pathname,
1258
+ frameIndex: frame.frameIndex,
1259
+ storage: storageName,
1260
+ ...entry,
1261
+ }
1262
+ entries.set(identity, next)
1263
+ const previous = previousEntries.get(identity)
1264
+ if (!previous) {
1265
+ changes.push({
1266
+ type: 'added',
1267
+ ...next,
1268
+ })
1269
+ } else if (previous.hash !== next.hash) {
1270
+ changes.push({
1271
+ type: 'updated',
1272
+ origin: next.origin,
1273
+ pathname: next.pathname,
1274
+ frameIndex: next.frameIndex,
1275
+ storage: next.storage,
1276
+ key: next.key,
1277
+ oldHash: previous.hash,
1278
+ newHash: next.hash,
1279
+ oldValue: previous.value,
1280
+ newValue: next.value,
1281
+ oldSelected: previous.selected,
1282
+ newSelected: next.selected,
1283
+ })
1284
+ }
1285
+ }
1286
+ }
1287
+ }
1288
+ for (const [identity, previous] of previousEntries) {
1289
+ if (!entries.has(identity)) {
1290
+ changes.push({
1291
+ type: 'removed',
1292
+ ...previous,
1293
+ })
1294
+ }
1295
+ }
1296
+ return { entries, changes }
1297
+ }
1298
+
1299
+ function selectEquipmentUiStorageFields(rawValue) {
1300
+ if (
1301
+ typeof rawValue !== 'string' ||
1302
+ rawValue.length === 0 ||
1303
+ rawValue.length > 1024 * 1024
1304
+ ) {
1305
+ return null
1306
+ }
1307
+ let parsed
1308
+ try {
1309
+ parsed = JSON.parse(rawValue)
1310
+ } catch (_) {
1311
+ return null
1312
+ }
1313
+ const output = {}
1314
+ const queue = [{ value: parsed, depth: 0 }]
1315
+ let visited = 0
1316
+ while (queue.length > 0 && visited < 1000) {
1317
+ const { value, depth } = queue.shift()
1318
+ visited += 1
1319
+ if (!value || typeof value !== 'object' || depth > 6) continue
1320
+ for (const [key, item] of Object.entries(value).slice(0, 256)) {
1321
+ if (
1322
+ EQUIPMENT_UI_STORAGE_KEYS.has(key) &&
1323
+ (
1324
+ typeof item === 'string' ||
1325
+ typeof item === 'boolean' ||
1326
+ (typeof item === 'number' && Number.isFinite(item))
1327
+ )
1328
+ ) {
1329
+ output[key] = item
1330
+ } else if (item && typeof item === 'object') {
1331
+ queue.push({ value: item, depth: depth + 1 })
1332
+ }
1333
+ }
1334
+ }
1335
+ return output
1336
+ }
1337
+
1338
+ function hashText(value, hashKey) {
1339
+ if (!Buffer.isBuffer(hashKey) || hashKey.length < 16) {
1340
+ throw new Error('Recorder storage hash key is unavailable')
1341
+ }
1342
+ return crypto.createHmac('sha256', hashKey).update(value, 'utf8').digest('hex')
1343
+ }
1344
+
1345
+ function boundedStorageText(value, maximum) {
1346
+ if (typeof value !== 'string') return ''
1347
+ return value.length <= maximum
1348
+ ? value
1349
+ : `${value.slice(0, maximum)}[TRUNCATED]`
1350
+ }
1351
+
1352
+ function sanitizeDump(value) {
1353
+ const state = {
1354
+ seen: new WeakSet(),
1355
+ nodes: 0,
1356
+ maxNodes: 200000,
1357
+ maxDepth: 12,
1358
+ maxArrayItems: 10000,
1359
+ maxObjectKeys: 10000,
1360
+ maxStringLength: 65536,
1361
+ }
1362
+ return sanitizeValue(value, state, 0)
1363
+ }
1364
+
1365
+ function sanitizeValue(value, state, depth) {
1366
+ state.nodes += 1
1367
+ if (state.nodes > state.maxNodes) return '[TRUNCATED: node limit]'
1368
+ if (value === null || typeof value === 'boolean') return value
1369
+ if (typeof value === 'number') return Number.isFinite(value) ? value : String(value)
1370
+ if (typeof value === 'string') {
1371
+ return sanitizeString(value, state.maxStringLength)
1372
+ }
1373
+ if (typeof value === 'bigint') return value.toString()
1374
+ if (typeof value !== 'object') return undefined
1375
+ if (depth >= state.maxDepth) return '[TRUNCATED: depth limit]'
1376
+ if (state.seen.has(value)) return '[CIRCULAR]'
1377
+ state.seen.add(value)
1378
+
1379
+ if (value instanceof Date) return timestamp(value)
1380
+ if (Buffer.isBuffer(value)) return `[BINARY: ${value.length} bytes]`
1381
+ if (Array.isArray(value)) {
1382
+ if (
1383
+ value.length === 2 &&
1384
+ typeof value[0] === 'string' &&
1385
+ isSensitiveKey(value[0])
1386
+ ) {
1387
+ return [
1388
+ sanitizeString(value[0], state.maxStringLength),
1389
+ '[REDACTED]',
1390
+ ]
1391
+ }
1392
+ const limited = value.slice(0, state.maxArrayItems)
1393
+ const result = []
1394
+ for (let index = 0; index < limited.length; index += 1) {
1395
+ const previous = limited[index - 1]
1396
+ const item = (
1397
+ index % 2 === 1 &&
1398
+ typeof previous === 'string' &&
1399
+ isSensitiveKey(previous)
1400
+ )
1401
+ ? '[REDACTED]'
1402
+ : sanitizeValue(limited[index], state, depth + 1)
1403
+ if (item !== undefined) result.push(item)
1404
+ }
1405
+ if (value.length > state.maxArrayItems) {
1406
+ result.push(`[TRUNCATED: ${value.length - state.maxArrayItems} items]`)
1407
+ }
1408
+ return result
1409
+ }
1410
+
1411
+ const output = {}
1412
+ const entries = Object.entries(value)
1413
+ for (const [key, item] of entries.slice(0, state.maxObjectKeys)) {
1414
+ if (isSensitiveKey(key)) {
1415
+ output[key] = '[REDACTED]'
1416
+ continue
1417
+ }
1418
+ const sanitized = sanitizeValue(item, state, depth + 1)
1419
+ if (sanitized !== undefined) output[key] = sanitized
1420
+ }
1421
+ if (entries.length > state.maxObjectKeys) {
1422
+ output.__truncatedKeys = entries.length - state.maxObjectKeys
1423
+ }
1424
+ return output
1425
+ }
1426
+
1427
+ function sanitizeString(value, maximum) {
1428
+ if (looksLikeSensitiveHeaderLine(value)) return '[REDACTED]'
1429
+ const withoutQuery = stripUrlQuery(value)
1430
+ if (
1431
+ SENSITIVE_VALUE.test(withoutQuery) ||
1432
+ /(?:^|[?&;\s])(?:api[_-]?key|auth|authorization|cookie|credential|key|login(?:data)?|password|secret|session|sid|ticket|token)=[^&;\s]+/iu.test(withoutQuery) ||
1433
+ looksLikeOpaqueCredential(withoutQuery)
1434
+ ) {
1435
+ return '[REDACTED]'
1436
+ }
1437
+ return withoutQuery.length <= maximum
1438
+ ? withoutQuery
1439
+ : `${withoutQuery.slice(0, maximum)}[TRUNCATED]`
1440
+ }
1441
+
1442
+ function isSensitiveKey(value) {
1443
+ return (
1444
+ typeof value === 'string' &&
1445
+ !GAME_BUSINESS_SORT_KEYS.has(value) &&
1446
+ SENSITIVE_KEY.test(value)
1447
+ )
1448
+ }
1449
+
1450
+ function looksLikeSensitiveHeaderLine(value) {
1451
+ return /(?:^|[\r\n])\s*(?:authorization|proxy-authorization|cookie|set-cookie|credential|login(?:data)?|password|secret|session|sid|ticket|token|x-api-key)\s*:/iu.test(value)
1452
+ }
1453
+
1454
+ function stripUrlQuery(value) {
1455
+ if (
1456
+ !/^(?:https?:\/\/|\/)/iu.test(value) ||
1457
+ (!value.includes('?') && !value.includes('#'))
1458
+ ) {
1459
+ return value
1460
+ }
1461
+ const queryIndex = value.indexOf('?')
1462
+ const fragmentIndex = value.indexOf('#')
1463
+ const indexes = [queryIndex, fragmentIndex].filter((index) => index >= 0)
1464
+ return indexes.length === 0 ? value : value.slice(0, Math.min(...indexes))
1465
+ }
1466
+
1467
+ function looksLikeOpaqueCredential(value) {
1468
+ const trimmed = value.trim()
1469
+ if (
1470
+ trimmed.length < 32 ||
1471
+ trimmed.length > 4096 ||
1472
+ !/^[A-Za-z0-9+/_=-]+$/u.test(trimmed)
1473
+ ) {
1474
+ return false
1475
+ }
1476
+ return /[A-Za-z]/u.test(trimmed) && /\d/u.test(trimmed)
1477
+ }
1478
+
1479
+ function serializeBoundedJson(value, maxBytes) {
1480
+ const json = `${JSON.stringify(value, null, 2)}\n`
1481
+ const bytes = Buffer.byteLength(json)
1482
+ if (bytes > maxBytes) {
1483
+ const fallback = {
1484
+ schemaVersion: 1,
1485
+ truncated: true,
1486
+ originalBytes: bytes,
1487
+ maxBytes,
1488
+ path: value && value.path,
1489
+ capturedAt: value && value.capturedAt,
1490
+ }
1491
+ return `${JSON.stringify(fallback, null, 2)}\n`
1492
+ }
1493
+ return json
1494
+ }
1495
+
1496
+ function supportedButton(value) {
1497
+ return ['left', 'middle', 'right'].includes(value) ? value : 'left'
1498
+ }
1499
+
1500
+ function reportsHeldButton(mouse, button) {
1501
+ if (typeof mouse.button === 'string') return mouse.button === button
1502
+ if (!Array.isArray(mouse.modifiers)) return false
1503
+ const expected = `${button}buttondown`
1504
+ return mouse.modifiers.some((modifier) => (
1505
+ typeof modifier === 'string' && modifier.toLowerCase() === expected
1506
+ ))
1507
+ }
1508
+
1509
+ function safeFilename(value) {
1510
+ const sanitized = String(value).replace(/[^A-Za-z0-9._-]+/gu, '-')
1511
+ return sanitized.slice(0, 80) || 'response'
1512
+ }
1513
+
1514
+ function safeApiPath(value) {
1515
+ const source = String(value)
1516
+ try {
1517
+ return new URL(source, 'http://127.0.0.1').pathname
1518
+ } catch (_) {
1519
+ return source.split(/[?#]/u, 1)[0]
1520
+ }
1521
+ }
1522
+
1523
+ function compactTimestamp(value) {
1524
+ return value.replace(/[-:.]/gu, '').replace('T', '-').replace('Z', 'Z')
1525
+ }
1526
+
1527
+ function timestamp(value) {
1528
+ const date = value instanceof Date ? value : new Date(value)
1529
+ if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
1530
+ return date.toISOString()
1531
+ }
1532
+
1533
+ function boundedSessionId(value) {
1534
+ if (
1535
+ typeof value !== 'string' ||
1536
+ value.length === 0 ||
1537
+ value.length > 128 ||
1538
+ !/^[A-Za-z0-9._-]+$/u.test(value)
1539
+ ) {
1540
+ throw new Error('sessionId must contain only letters, numbers, dot, underscore, or dash')
1541
+ }
1542
+ return value
1543
+ }
1544
+
1545
+ function positiveInteger(value, fallback, name) {
1546
+ const selected = value == null ? fallback : value
1547
+ if (!Number.isInteger(selected) || selected <= 0) {
1548
+ throw new Error(`${name} must be a positive integer`)
1549
+ }
1550
+ return selected
1551
+ }
1552
+
1553
+ function positiveNumber(value, fallback, name) {
1554
+ const selected = value == null ? fallback : value
1555
+ if (!Number.isFinite(selected) || selected <= 0) {
1556
+ throw new Error(`${name} must be a positive finite number`)
1557
+ }
1558
+ return selected
1559
+ }
1560
+
1561
+ function normalizeCheckpointDelays(value) {
1562
+ const selected = value == null ? DEFAULT_CHECKPOINT_DELAYS_MS : value
1563
+ if (
1564
+ !Array.isArray(selected) ||
1565
+ selected.length === 0 ||
1566
+ selected.length > 10 ||
1567
+ selected.some((item) => (
1568
+ !Number.isInteger(item) ||
1569
+ item < 0 ||
1570
+ item > 60000
1571
+ ))
1572
+ ) {
1573
+ throw new Error('checkpointDelaysMs must contain 1 to 10 integers from 0 to 60000')
1574
+ }
1575
+ return [...new Set(selected)].sort((a, b) => a - b)
1576
+ }
1577
+
1578
+ function round3(value) {
1579
+ return Math.round(value * 1000) / 1000
1580
+ }
1581
+
1582
+ function round6(value) {
1583
+ return Math.round(value * 1000000) / 1000000
1584
+ }
1585
+
1586
+ function defaultGetStore(storePath) {
1587
+ if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
1588
+ return window.getStore(storePath)
1589
+ }
1590
+ return null
1591
+ }
1592
+
1593
+ function defaultEventTarget() {
1594
+ return typeof window !== 'undefined' ? window : null
1595
+ }
1596
+
1597
+ function defaultResolveWebContents(webContentsId) {
1598
+ const { webContents } = require('@electron/remote')
1599
+ return webContents.fromId(webContentsId)
1600
+ }
1601
+
1602
+ async function defaultCaptureWebStorage(webContents) {
1603
+ const mainFrame = webContents && webContents.mainFrame
1604
+ const frames = mainFrame && Array.isArray(mainFrame.framesInSubtree)
1605
+ ? mainFrame.framesInSubtree
1606
+ : mainFrame
1607
+ ? [mainFrame]
1608
+ : []
1609
+ if (frames.length === 0) {
1610
+ throw new Error('Poi game WebView frames are not ready')
1611
+ }
1612
+ const script = `(() => {
1613
+ const readStorage = (storage) => {
1614
+ const entries = []
1615
+ const limit = Math.min(storage.length, 512)
1616
+ for (let index = 0; index < limit; index += 1) {
1617
+ const key = storage.key(index)
1618
+ if (typeof key === 'string') {
1619
+ const value = storage.getItem(key)
1620
+ entries.push([
1621
+ key.slice(0, 4096),
1622
+ typeof value === 'string' ? value.slice(0, 65536) : value,
1623
+ ])
1624
+ }
1625
+ }
1626
+ return entries
1627
+ }
1628
+ try {
1629
+ return {
1630
+ origin: location.origin,
1631
+ pathname: location.pathname,
1632
+ localStorage: readStorage(window.localStorage),
1633
+ sessionStorage: readStorage(window.sessionStorage),
1634
+ }
1635
+ } catch (error) {
1636
+ return {
1637
+ origin: location.origin,
1638
+ pathname: location.pathname,
1639
+ localStorage: [],
1640
+ sessionStorage: [],
1641
+ error: error && error.message ? error.message : String(error),
1642
+ }
1643
+ }
1644
+ })()`
1645
+ const captured = await Promise.all(frames.slice(0, 64).map(async (frame) => {
1646
+ try {
1647
+ return await frame.executeJavaScript(script)
1648
+ } catch (error) {
1649
+ return {
1650
+ origin: '',
1651
+ pathname: '',
1652
+ localStorage: [],
1653
+ sessionStorage: [],
1654
+ error: error.message,
1655
+ }
1656
+ }
1657
+ }))
1658
+ return captured.filter((frame) => isKanColleGameOrigin(frame && frame.origin))
1659
+ }
1660
+
1661
+ function isKanColleGameOrigin(value) {
1662
+ if (typeof value !== 'string' || value.length === 0) return false
1663
+ try {
1664
+ const url = new URL(value)
1665
+ return (
1666
+ url.protocol === 'https:' &&
1667
+ (
1668
+ url.hostname === 'kancolle-server.com' ||
1669
+ url.hostname.endsWith('.kancolle-server.com')
1670
+ )
1671
+ )
1672
+ } catch (_) {
1673
+ return false
1674
+ }
1675
+ }
1676
+
1677
+ async function defaultCaptureEquipmentUiState(_webContents) {
1678
+ throw new Error(
1679
+ 'Equipment UI sort probe unavailable: a direct runtime target is not configured',
1680
+ )
1681
+ }
1682
+
1683
+ module.exports = {
1684
+ CANONICAL_HEIGHT,
1685
+ CANONICAL_WIDTH,
1686
+ DEFAULT_MAX_SESSION_BYTES,
1687
+ DEFAULT_MAX_STORED_SESSIONS,
1688
+ DEFAULT_MAX_TOTAL_RECORDING_BYTES,
1689
+ DEFAULT_OUTPUT_ROOT,
1690
+ captureEquipmentUiStateFromWebContents: defaultCaptureEquipmentUiState,
1691
+ captureWebStorageFromWebContents: defaultCaptureWebStorage,
1692
+ createPoiInteractionRecorder,
1693
+ }