@usercase/mcp 0.2.2-next.5 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.2-next.5",
3
+ "version": "0.2.3-next.0",
4
4
  "description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://usercase.io",
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
- record.openedApp = true;
625
- this.diag.push({ kind: "left_to_blank", action: decision.action, shape: null });
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";