@usercase/mcp 0.2.2 → 0.2.3-next.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/mcp/hub.js +2 -1
- package/mcp/report.js +21 -2
- package/package.json +1 -1
- package/src/run/visit.js +127 -3
package/mcp/hub.js
CHANGED
|
@@ -459,6 +459,7 @@ class Hub {
|
|
|
459
459
|
if (r.sentWithEnter) said.push("You pressed Enter to send it, as you would in a chat box.");
|
|
460
460
|
if (r.heldEnter) said.push("The screen changed as you typed, so you did not press Enter — what you see is what typing alone did. If you would press Enter now, type the same text into that field again.");
|
|
461
461
|
if (r.fileWindow) said.push(r.fileWindow);
|
|
462
|
+
if (r.backOut) said.push("This is the first page you opened on this site, so going back from here would take you off the site. You are still on the page you were on — if leaving the site is what you meant, leave; otherwise carry on here.");
|
|
462
463
|
if (r.openedApp) said.push("That tried to open another app outside the browser (a share or app link). Nothing opened, and you are back on the page you were on.");
|
|
463
464
|
if (r.swiped) said.push("It was cut off at the edge, so you swiped that row sideways to bring it into view, then tapped it.");
|
|
464
465
|
// 개인정보 칸. 사이트가 막은 게 아니라 이 사람이 안 친 것이다 — 그렇게 알린다
|
|
@@ -521,7 +522,7 @@ class Hub {
|
|
|
521
522
|
};
|
|
522
523
|
ws.appendRun(row);
|
|
523
524
|
// 대시보드로 — 키가 없으면 건너뛴다. 실패해도 방문은 끝낸다
|
|
524
|
-
const uploaded = await report.sendVisit(row, rec.person, rec.startedAt).catch((e) => `⚠ Could not upload to the dashboard (${e.message})`);
|
|
525
|
+
const uploaded = await report.sendVisit(row, rec.person, rec.startedAt, rec.visit ? rec.visit.screens : []).catch((e) => `⚠ Could not upload to the dashboard (${e.message})`);
|
|
525
526
|
// 도구가 하지 못한 것들 — 우리 쪽 진단으로(사이트 글자 없음). 기본으로 켜져 있다, USERCASE_DIAGNOSTICS=off 로 끈다
|
|
526
527
|
if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device).catch(() => 0);
|
|
527
528
|
const doc = ws.loadPeople();
|
package/mcp/report.js
CHANGED
|
@@ -123,13 +123,32 @@ function payload(row, person, startedAt) {
|
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* 문제가 있던 순간의 화면을 방문 id 로 먼저 올린다(서버 R2 screens/). 못 올린 걸음은 표시를 지운다 —
|
|
128
|
+
* 대시보드가 없는 사진 자리를 남기지 않게. 사진은 다시 보내지 않는다(outbox 에 넣지 않는다)
|
|
129
|
+
*/
|
|
130
|
+
async function sendScreens(item, screens) {
|
|
131
|
+
if (!screens || !screens.length) return item;
|
|
132
|
+
const failed = new Set();
|
|
133
|
+
for (const s of screens) {
|
|
134
|
+
try {
|
|
135
|
+
await call("POST", "/agent/screens", { id: item.id, step: s.step, image: s.image.toString("base64") }, 30000);
|
|
136
|
+
} catch {
|
|
137
|
+
failed.add(s.step);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const sent = new Set(screens.map((s) => s.step).filter((n) => !failed.has(n)));
|
|
141
|
+
return { ...item, steps: (item.steps || []).map((s) => (s && s.screen && !sent.has(s.step) ? { ...s, screen: undefined } : s)) };
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
/**
|
|
127
145
|
* 방문 하나를 보내고, 전에 못 보낸 것도 같이 보낸다. 실패해도 방문은 막지 않는다 — 결과 한 줄만 돌려준다.
|
|
146
|
+
* @param screens 문제가 있던 순간의 화면 [{ step, image }] (Visit.screens)
|
|
128
147
|
* @returns 요약에 붙일 한 줄, 키가 없으면 null
|
|
129
148
|
*/
|
|
130
|
-
async function sendVisit(row, person, startedAt) {
|
|
149
|
+
async function sendVisit(row, person, startedAt, screens = []) {
|
|
131
150
|
if (!KEY_RE.test(apiKey())) return null;
|
|
132
|
-
const queue = [...readOutbox(), payload(row, person, startedAt)];
|
|
151
|
+
const queue = [...readOutbox(), await sendScreens(payload(row, person, startedAt), screens)];
|
|
133
152
|
const left = [];
|
|
134
153
|
let sent = 0;
|
|
135
154
|
let lastErr = null;
|
package/package.json
CHANGED
package/src/run/visit.js
CHANGED
|
@@ -42,6 +42,58 @@ const STEP_SCHEMA = {
|
|
|
42
42
|
/** 같은 조작을 연달아 몇 번까지. 굴리며 훑기·안 눌려서 거듭 누르기 */
|
|
43
43
|
const MAX_TIMES = { click: 4, scroll: 5, scroll_up: 5 };
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 문제가 있던 순간의 화면 — 대시보드가 그 걸음 아래에 보인다(서버 /agent/screens). **방문자(AI)에게는 안 보인다.**
|
|
47
|
+
* 걸음마다 찍지 않는다. 영상(세션 리플레이)은 봇의 움직임이 드러나 쓰지 않기로 했다(10/21)
|
|
48
|
+
* no_change 눌렀는데 화면이 그대로
|
|
49
|
+
* left_unhappy 원하던 걸 못 이루고 떠남(leave, done=false) — 마지막 화면
|
|
50
|
+
* bounced 들어오자마자 뒤로
|
|
51
|
+
* card 질문 카드가 뜬 화면 — 카드는 걸음이 아니라 바로 앞 걸음에 붙인다(첫 걸음 전이면 다음 걸음)
|
|
52
|
+
* 누른 곳(box)은 사진에 그리지 않고 걸음에 비율(0~1)로 적는다. 대시보드가 위에 그린다
|
|
53
|
+
*/
|
|
54
|
+
const MAX_SCREENS = 6;
|
|
55
|
+
// 서버 앞 nginx 의 기본 본문 한도가 1MB 다 — base64 로 커지니 원본은 600KB 아래로
|
|
56
|
+
const MAX_SCREEN_BYTES = 600 * 1024;
|
|
57
|
+
|
|
58
|
+
/** 요소가 화면에서 차지한 곳(화면 비율). 화면 밖이거나 사라졌으면 null */
|
|
59
|
+
async function boxOf(page, el) {
|
|
60
|
+
const b = el ? await el.boundingBox().catch(() => null) : null;
|
|
61
|
+
const vp = page.viewportSize();
|
|
62
|
+
if (!b || !vp || b.width <= 0 || b.height <= 0) return null;
|
|
63
|
+
const x0 = Math.max(0, b.x);
|
|
64
|
+
const y0 = Math.max(0, b.y);
|
|
65
|
+
const x1 = Math.min(vp.width, b.x + b.width);
|
|
66
|
+
const y1 = Math.min(vp.height, b.y + b.height);
|
|
67
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
68
|
+
const r = (n) => Math.round(n * 1000) / 1000;
|
|
69
|
+
return { x: r(x0 / vp.width), y: r(y0 / vp.height), w: r((x1 - x0) / vp.width), h: r((y1 - y0) / vp.height) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 누르기 전후를 견줄 지문 — 주소, 페이지 전체 글(보이는 것만, 굴려도 같다), 누른 요소의 상태(색만 바뀌는 좋아요·토글).
|
|
74
|
+
* 글이 저절로 바뀌는 사이트(넘어가는 배너)에서는 "바뀌었다" 로 본다 — 그대로라고 잘못 찍는 것보다 낫다
|
|
75
|
+
*/
|
|
76
|
+
async function clickPrint(page, el) {
|
|
77
|
+
const doc = await page
|
|
78
|
+
.evaluate(() => {
|
|
79
|
+
const t = (document.body && document.body.innerText) || "";
|
|
80
|
+
let h = 5381;
|
|
81
|
+
for (let i = 0; i < t.length; i++) h = ((h << 5) + h + t.charCodeAt(i)) | 0;
|
|
82
|
+
return `${t.length}:${h}`;
|
|
83
|
+
})
|
|
84
|
+
.catch(() => null);
|
|
85
|
+
const own = el
|
|
86
|
+
? await el
|
|
87
|
+
.evaluate((n) =>
|
|
88
|
+
n.isConnected
|
|
89
|
+
? [n.className && String(n.className), ...["aria-pressed", "aria-expanded", "aria-checked", "aria-selected", "aria-current", "data-state"].map((a) => n.getAttribute(a)), n.checked, n.value, (n.textContent || "").slice(0, 200)].join("|")
|
|
90
|
+
: null,
|
|
91
|
+
)
|
|
92
|
+
.catch(() => null)
|
|
93
|
+
: null;
|
|
94
|
+
return { url: page.url(), doc, own };
|
|
95
|
+
}
|
|
96
|
+
|
|
45
97
|
/**
|
|
46
98
|
* 클릭 뒤 화면이 자리 잡기를 기다린다. 사람도 눌렀으면 뜰 때까지 본다.
|
|
47
99
|
* 안 기다리면 느린 화면이 "눌러도 안 열린다" 로 읽힌다 — 10/13 bluetags 에서 카드가 "안 열린다 → 조금 뒤 열렸다".
|
|
@@ -301,6 +353,8 @@ class Visit {
|
|
|
301
353
|
// 손은 방문 내내 이어진다 — 커서가 있던 자리에서 다음 곳으로 간다
|
|
302
354
|
this.handState = { rhythm, mouse: null };
|
|
303
355
|
this.steps = [];
|
|
356
|
+
/** 문제가 있던 순간의 화면 { step, image(JPEG) } — 방문이 끝나면 hub 가 서버로 올린다 */
|
|
357
|
+
this.screens = [];
|
|
304
358
|
this.history = [];
|
|
305
359
|
this.seenPaths = new Set();
|
|
306
360
|
this.card = null; // 처리한 카드(한 방문에 하나)
|
|
@@ -393,6 +447,9 @@ class Visit {
|
|
|
393
447
|
async answer(decision) {
|
|
394
448
|
const shown = this.pending;
|
|
395
449
|
if (!shown) throw new Error("No card is showing");
|
|
450
|
+
// 첫 걸음 전에 뜬 카드(들어오자마자)는 붙일 걸음이 없다 — 찍어 두었다가 다음 걸음에 붙인다(10/22 실험실 w5)
|
|
451
|
+
if (this.steps.length) await this.shoot(this.steps[this.steps.length - 1], "card");
|
|
452
|
+
else this.early = { kind: "card", image: await this.snap() };
|
|
396
453
|
const act = answer.toAction(decision, shown);
|
|
397
454
|
// 카드를 읽고 정하는 시간. 닫는 건 금방, 쓰는 건 치는 만큼
|
|
398
455
|
await sleep(act.kind === "close" ? 600 + Math.random() * 1800 : 1500 + Math.random() * 3000);
|
|
@@ -443,6 +500,13 @@ class Visit {
|
|
|
443
500
|
const view = this.lastView || (await listControls(page));
|
|
444
501
|
const item = view.items[decision.index];
|
|
445
502
|
const record = { step: this.steps.length + 1, t: Date.now() - this.t0, path: view.path, action: decision.action, why: decision.why, label: item?.label ?? null };
|
|
503
|
+
if (this.early) {
|
|
504
|
+
if (this.early.image && this.screens.length < MAX_SCREENS) {
|
|
505
|
+
record.screen = { kind: this.early.kind, box: null };
|
|
506
|
+
this.screens.push({ step: record.step, image: this.early.image });
|
|
507
|
+
}
|
|
508
|
+
this.early = null;
|
|
509
|
+
}
|
|
446
510
|
const times = Math.max(1, Math.min(MAX_TIMES[decision.action] || 1, Math.round(Number(decision.times) || 1)));
|
|
447
511
|
if (times > 1) record.times = times;
|
|
448
512
|
// 한 번에 할 수 있는 수보다 많이 달라고 했다. 말없이 줄이면 "사이트가 클릭을 삼킨다" 로 읽었다(10/20 실험실 p7: 15·20·30번이 다 4번)
|
|
@@ -456,6 +520,7 @@ class Visit {
|
|
|
456
520
|
|
|
457
521
|
if (decision.action === "leave") {
|
|
458
522
|
record.done = !!decision.done;
|
|
523
|
+
if (!record.done) await this.shoot(record, "left_unhappy");
|
|
459
524
|
this.steps.push(record);
|
|
460
525
|
this.log(`${record.step}. left — ${decision.why}`);
|
|
461
526
|
// 떠나는 모양도 사람처럼. 데스크톱은 커서가 창 위로 빠져나간다
|
|
@@ -475,6 +540,7 @@ class Visit {
|
|
|
475
540
|
|
|
476
541
|
const net = watchNet(page);
|
|
477
542
|
let diagEl = null; // 진단에 생김새를 남길 요소
|
|
543
|
+
let probe = null; // 누르기 전 지문 — 눌렀는데 그대로였나
|
|
478
544
|
if (decision.action === "read") {
|
|
479
545
|
// 가만히 읽는 시간. 사람은 계속 누르지 않는다 — 이게 없으면 "N초 머묾" 같은 조건이 영영 안 걸린다.
|
|
480
546
|
// 기다리는 건 다음 조작 때(waitTurn). 그 사이 카드가 뜨면 다음 조작 전에 본다
|
|
@@ -490,6 +556,10 @@ class Visit {
|
|
|
490
556
|
}
|
|
491
557
|
await sleep(250);
|
|
492
558
|
} else if (decision.action === "back") {
|
|
559
|
+
// 이 화면에 와서 한 걸음도 채 안 하고 돌아간다 — 들어오자마자 뒤로. 돌아가기 전 화면을 찍는다
|
|
560
|
+
let here = 0;
|
|
561
|
+
for (let i = this.steps.length - 1; i >= 0 && this.steps[i].path === view.path; i--) here++;
|
|
562
|
+
if (here <= 1) await this.shoot(record, "bounced");
|
|
493
563
|
await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {});
|
|
494
564
|
if ((await settle(page, before, net)).navTimeout) this.diag.push({ kind: "nav_timeout", action: "back" });
|
|
495
565
|
} else if (decision.action === "type") {
|
|
@@ -582,6 +652,7 @@ class Visit {
|
|
|
582
652
|
// 같은 사이트를 새 탭으로 여는 링크는 이 탭에서 열게 한다. 사람은 열린 탭에서 이어 보고, 방문은 한 탭이다.
|
|
583
653
|
// 새 탭을 잡아 옮기는 건(hub) 브라우저가 새 탭을 늦게, 주소 없이 알려 줘서 놓쳤다 —
|
|
584
654
|
// 방문자는 "안 열린다" 를 보고 다시 누르면 "없어졌다" 가 됐다(10/14 실험실 p3, 첫 화면 카드)
|
|
655
|
+
probe = { el, before: await clickPrint(page, el) };
|
|
585
656
|
await el
|
|
586
657
|
.evaluate((n) => {
|
|
587
658
|
const a = n.closest("a[target]");
|
|
@@ -621,8 +692,19 @@ class Visit {
|
|
|
621
692
|
if (/^https?:/i.test(before) && !/^https?:/i.test(page.url())) {
|
|
622
693
|
await page.goto(before, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
|
|
623
694
|
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
|
|
624
|
-
|
|
625
|
-
|
|
695
|
+
// 뒤로 가기가 빈 페이지로 간 것은 앱이 아니라 방문의 시작보다 앞으로 간 것이다(바로 들어온 첫 페이지).
|
|
696
|
+
// "다른 앱을 열려 했다" 고 알리면 방문자가 사이트 탓으로 적었다(10/22 실험실 q4)
|
|
697
|
+
if (decision.action === "back") {
|
|
698
|
+
record.backOut = true;
|
|
699
|
+
// 돌아가지 못했다 — 들어오자마자 뒤로(bounced)로 찍은 것은 무른다
|
|
700
|
+
if (record.screen && record.screen.kind === "bounced") {
|
|
701
|
+
delete record.screen;
|
|
702
|
+
this.screens = this.screens.filter((s) => s.step !== record.step);
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
record.openedApp = true;
|
|
706
|
+
this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null });
|
|
707
|
+
}
|
|
626
708
|
}
|
|
627
709
|
|
|
628
710
|
// 사이트 밖으로 가는 링크였다. 끊었으니 방문자는 그대로 이 화면에 있다.
|
|
@@ -652,9 +734,29 @@ class Visit {
|
|
|
652
734
|
if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
|
|
653
735
|
await this.noteDiag(record, decision, diagEl);
|
|
654
736
|
|
|
737
|
+
// 눌렀는데 그대로. 우리 쪽 사정(빗나감·막은 요청·바깥 사이트·파일 창)으로 그대로인 것은 빼고, 같은 것은 한 번만
|
|
738
|
+
if (
|
|
739
|
+
probe &&
|
|
740
|
+
!record.skipped &&
|
|
741
|
+
!record.missed &&
|
|
742
|
+
!record.closedOverlay &&
|
|
743
|
+
!record.offsite &&
|
|
744
|
+
!record.notSent &&
|
|
745
|
+
!record.notSentQuiet &&
|
|
746
|
+
!record.external &&
|
|
747
|
+
!record.fileWindow &&
|
|
748
|
+
!record.openedApp &&
|
|
749
|
+
!record.backOut &&
|
|
750
|
+
!this.steps.some((s) => s.screen && s.screen.kind === "no_change" && s.label === record.label && s.path === record.path)
|
|
751
|
+
) {
|
|
752
|
+
const after = await clickPrint(page, probe.el);
|
|
753
|
+
if (after.url === probe.before.url && after.doc && after.doc === probe.before.doc && after.own !== null && after.own === probe.before.own)
|
|
754
|
+
await this.shoot(record, "no_change", await boxOf(page, probe.el));
|
|
755
|
+
}
|
|
756
|
+
|
|
655
757
|
this.steps.push(record);
|
|
656
758
|
this.history.push(
|
|
657
|
-
`${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}`,
|
|
759
|
+
`${decision.action}${times > 1 ? ` ×${times}` : ""}${item ? ` "${item.label}"` : ""} — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}${record.cappedTimes ? " (that is the most in one go)" : ""}${record.offsite ? ` (it leads to another website, ${record.offsite})` : ""}${record.notSent ? " (it would have sent something to the site; it was not sent)" : ""}${record.fileWindow ? " (a window to choose a file opened; you closed it without choosing)" : ""}${record.personalField !== undefined ? " (you chose not to enter personal details there)" : ""}${record.openedApp ? " (it tried to open another app; nothing opened)" : ""}${record.backOut ? " (going back from here would have taken you off the site; you stayed)" : ""}`,
|
|
658
760
|
);
|
|
659
761
|
this.log(`${record.step}. ${decision.action}${item ? ` "${String(item.label).slice(0, 30)}"` : ""}${record.skipped ? ` · ${record.skipped}` : ""} — ${decision.why}`);
|
|
660
762
|
this.lastView = null;
|
|
@@ -677,6 +779,28 @@ class Visit {
|
|
|
677
779
|
this.diag.push({ kind: "reclick_same", action, shape: await diag.shapeOf(el) });
|
|
678
780
|
}
|
|
679
781
|
|
|
782
|
+
/**
|
|
783
|
+
* 지금 화면을 찍어 이 걸음에 붙인다(record.screen). 보이는 만큼만(뷰포트). 크면 한 번 더 작게, 그래도 크면 안 찍는다
|
|
784
|
+
* 실패해도 방문은 그대로 간다
|
|
785
|
+
*/
|
|
786
|
+
async shoot(record, kind, box = null) {
|
|
787
|
+
if (!record || record.screen || this.screens.length >= MAX_SCREENS) return;
|
|
788
|
+
const image = await this.snap();
|
|
789
|
+
if (!image) return;
|
|
790
|
+
record.screen = { kind, box };
|
|
791
|
+
this.screens.push({ step: record.step, image });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** 뷰포트 JPEG. 크면 한 번 더 작게, 그래도 크면 null */
|
|
795
|
+
async snap() {
|
|
796
|
+
let image = null;
|
|
797
|
+
for (const quality of [70, 45]) {
|
|
798
|
+
image = await this.page.screenshot({ type: "jpeg", quality, scale: "css", timeout: 5000 }).catch(() => null);
|
|
799
|
+
if (!image || image.length <= MAX_SCREEN_BYTES) break;
|
|
800
|
+
}
|
|
801
|
+
return image && image.length <= MAX_SCREEN_BYTES ? image : null;
|
|
802
|
+
}
|
|
803
|
+
|
|
680
804
|
/** 카드 판단에 넘길 "이번 방문에 한 일" */
|
|
681
805
|
pathText() {
|
|
682
806
|
return this.history.length ? this.history.slice(-6).join(" / ") : "just arrived";
|