@usercase/mcp 0.2.10-next.8 → 0.2.10-next.9

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
@@ -28,9 +28,27 @@ const study = require("../src/browser/study.js");
28
28
  const report = require("./report.js");
29
29
 
30
30
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ /** 한 과제에서 떠나지 못하게 붙잡는 가장 긴 시간 — 실패 시간(대시보드 기본 5분)이 없는 과제에 갇히지 않게 */
32
+ const STUDY_TASK_MAX_MS = 8 * 60 * 1000;
31
33
  /** 리서치가 아직 진행 중인 위젯 상태(core StateManager WidgetState). 이 밖이면 과제가 끝난 것이다 */
32
- /** 패널을 기다리는 가장 긴 시간 — 과제의 실패 시간은 대개 5분(대시보드 기본) */
33
- const STUDY_WAIT_MS = 5 * 60 * 1000;
34
+
35
+ // ── 계측(우리 내부 검증용). USERCASE_TRACE=<파일> 일 때만 한 줄씩 남긴다 — 도구 안에서 기다린 시간과 SDK 위젯 상태
36
+ const TRACE = process.env.USERCASE_TRACE || null;
37
+ async function sdkSnap(page) {
38
+ return page
39
+ .evaluate(() => {
40
+ const ss = (k) => sessionStorage.getItem(k);
41
+ const ck = document.cookie.split(";").map((c) => c.trim()).find((c) => c.startsWith("eb_sid_"));
42
+ return { failTimer: ss("Usercase_failtimer"), failOver: ss("Usercase_isFailOver"), group: ck ? ck.split("=")[1].slice(-8) : null, path: location.pathname };
43
+ })
44
+ .catch(() => null);
45
+ }
46
+ function trace(rec, o) {
47
+ if (!TRACE) return;
48
+ try {
49
+ fs.appendFileSync(TRACE, JSON.stringify({ at: Date.now(), person: rec && rec.person ? rec.person.id : null, ...o }) + "\n");
50
+ } catch {}
51
+ }
34
52
  const STUDY_ACTIVE = new Set(["consent", "loading", "testing", "timeout", "survey", "next", "submit"]);
35
53
 
36
54
  /**
@@ -429,8 +447,7 @@ class Hub {
429
447
  external,
430
448
  study: research
431
449
  ? {
432
- // wait — 과제를 그만 두고 패널을 기다린다. 위젯 단추가 아니라 도구가 기다린다(그동안 방문자는 아무것도 안 한다)
433
- press: (k) => (k === "wait" ? this.studyWait(rec) : study.press(page, k)),
450
+ press: (k) => study.press(page, k),
434
451
  // 과제 질문·시간 초과 창이 페이지를 덮고 있나 — 그 사이 조작은 페이지에 닿지 않는다
435
452
  blocking: async () => {
436
453
  const w = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
@@ -463,7 +480,7 @@ class Hub {
463
480
  "A small invitation popped up asking you to take part in a short study on this site, and you agreed: you accepted it and allowed your screen to be recorded.",
464
481
  "The study panel in the corner of the screen now shows your first task. Do the tasks as yourself, on the site, in your own way — the panel knows when a task is done and moves on by itself.",
465
482
  "You agreed to take part, so you stay with the study until its tasks are done — you do not leave the site in the middle of it over a small thing. The study may ask you a few questions along the way; answer them with answer_card.",
466
- "If you get stuck on a task, keep trying in your own way for as long as you would. When you would give up on that task, wait on the study panel (it is in the list): studies often offer a way to give up on a task after a while, and then you go on to the next task. Leave in the middle only if you really would — the study never offers a way on, or something about you or your moment makes you stop.",
483
+ "If you get stuck on a task, do what a person does: keep looking for it — try other menus and pages, go back, scroll through lists, try what you have not tried yet. Studies usually offer a way to give up on a task after a while; when the panel offers it and you would give up, take it and go on to the next task. Leave in the middle only if you really would — something about you or your moment makes you stop.",
467
484
  );
468
485
  if (!sdk.ready) lines.push("(note for whoever runs this: the Usercase SDK did not start on this page — this visit may not be recorded)");
469
486
  lines.push("", await this.screen(rec));
@@ -481,8 +498,8 @@ class Hub {
481
498
  if (look.card) return renderCard(look.card);
482
499
  // 과제 건너뛰기는 위젯 단추다. 화면 목록 끝에 붙인다 — 누르면 Visit 이 위젯을 누른다
483
500
  if (st && st.skip && !look.view.dialog) look.view.items.push({ i: look.view.items.length, label: `${st.skip} (in the study panel — gives up on this task)`, where: "study panel, corner of the screen", study: "skip" });
484
- // 아직 건너뛸 수 없는 과제 — 그만 두고 패널을 기다릴 수 있다. 실패 시간이 있는 과제면 그 사이 패널이 포기를 받아 준다
485
- else if (st && st.panel && !look.view.dialog) look.view.items.push({ i: look.view.items.length, label: "Wait on the study panel (stop trying this task for now)", where: "study panel, corner of the screen", study: "wait" });
501
+ // 아직 건너뛸 수 없는 과제 — 예전엔 "패널 기다리기" 를 줘서 막히면 몇 분씩 아무것도 안 했다(라이브 11번 × 약 5분).
502
+ // 사람은 막히면 계속 찾아본다. 그러다 실패 시간이 지나면 SDK 가 묻는 창을 띄우고, 다음 화면에서 그 창이 먼저 보인다
486
503
  // 브라우저 창(confirm 등)이 떠 있다. 페이지는 멈춰 있다 — 아이콘 그림·위치는 안 본다
487
504
  if (look.view.dialog) {
488
505
  this.pictures.set(rec.id, null);
@@ -535,7 +552,8 @@ class Hub {
535
552
  s = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
536
553
  if (!s) break;
537
554
  if (s.state === "next") {
538
- await sleep(1200 + Math.random() * 1800);
555
+ // 다음 과제 단추를 보고 누르는 손. AI 가 정할 것은 없다(11/02: 1.2~3초 기다렸다)
556
+ await sleep(400 + Math.random() * 600);
539
557
  await study.press(page, "next").catch(() => {});
540
558
  await study.waitState(page, ["testing", "survey", "submit", "done"], 15000);
541
559
  continue;
@@ -563,11 +581,11 @@ class Hub {
563
581
  }
564
582
  st.lastTaskKey = key;
565
583
  st.lastTask = s.task.title;
566
- if (st.waitNote) {
567
- note = note ? `${note} ${st.waitNote}` : st.waitNote;
568
- st.waitNote = null;
569
- }
570
- return { panel: renderStudyPanel(s.task), skip: s.task.skip, note };
584
+ // 패널에는 지금 과제에 머문 시간이 보인다(SDK 알약의 타이머) — 방문자도 같은 걸 본다
585
+ const onTaskMs = Date.now() - rec.startedAt - st.tasks[st.tasks.length - 1].at;
586
+ st.canSkip = !!s.task.skip;
587
+ st.onTaskMs = onTaskMs;
588
+ return { panel: renderStudyPanel(s.task, onTaskMs), skip: s.task.skip, note };
571
589
  }
572
590
  // 과제가 다 끝났다. 끝난 뒤 설문이 있으면 SDK 가 새 탭으로 열고, 그 탭은 이 탭으로 옮겨 온다(open 의 새 탭 처리)
573
591
  if (st.tasks.length && (!s || !STUDY_ACTIVE.has(s.state))) {
@@ -584,27 +602,13 @@ class Hub {
584
602
  return null;
585
603
  }
586
604
 
587
- /**
588
- * 과제를 그만 두고 패널을 기다린다 — 실패 시간이 있는 과제면 SDK 가 "어렵나요?" 창(timeout)을 띄우고 건너뛰기를 열어 준다.
589
- * 실패 시간이 없는 과제는 아무것도 안 온다. 다 기다리고도 그대로면 방문자에게 그렇게 알린다(그다음은 방문자가 정한다).
590
- * 막힌 참여자가 패널이 열어 주기 전에 사이트를 떠나, 30명 중 27명이 과제 2에서 기록 없이 사라졌다(10/30 라이브 bluetags)
591
- */
592
- async studyWait(rec) {
593
- const st = rec.study;
594
- const s = await study.waitState(rec.page, ["timeout", "survey", "next", "submit", "done", "complete", "idle"], STUDY_WAIT_MS);
595
- if (!s || s.state === "testing") {
596
- st.waited = (st.waited || 0) + 1;
597
- st.waitNote = "(You waited about five minutes on the study panel. It has not offered a way to give up on this task yet.)";
598
- }
599
- }
600
-
601
605
  /** 과제 질문·시간 초과 창에 답한다. 참여하기로 한 사람이라 닫지 않는다 — 비워 두고 넘기는 건 된다(필수가 아니면) */
602
606
  async studyAnswer(rec, decision) {
603
607
  const p = rec.studyPending;
604
608
  const st = rec.study;
605
609
  const page = rec.page;
606
- // 읽고 정하는 시간
607
- await sleep(1200 + Math.random() * 2500);
610
+ // 손이 고른 답으로 가는 시간. 읽고 정하는 건 AI 가 이미 했다(11/02: 1.2~3.7초를 또 기다렸다)
611
+ await sleep(300 + Math.random() * 500);
608
612
  let said;
609
613
  if (p.kind === "timeout") {
610
614
  const skip = decision.action === "choose" && decision.choiceIndex === 1;
@@ -631,7 +635,7 @@ class Hub {
631
635
  rec.cutOff = false;
632
636
  }
633
637
  }
634
- await sleep(1500);
638
+ await sleep(600);
635
639
  return [said, "", await this.screen(rec)].join("\n");
636
640
  }
637
641
 
@@ -656,8 +660,23 @@ class Hub {
656
660
  const v = rec.visit;
657
661
  // 리서치 질문이 페이지를 덮고 있다 — 먼저 답해야 한다
658
662
  if (rec.studyPending) return ["The study's question is still open on top of the page — answer it with answer_card first.", "", await this.screen(rec)].join("\n");
663
+ // 과제 도중에 떠나지 않는다 — 참여하기로 한 사람이다. 막히면 패널이 건너뛰기를 열어 줄 때까지 계속 찾아본다.
664
+ // "패널 기다리기" 를 없앴더니 5명 중 3명이 Skip 이 열리기 전(2~3분)에 떠났다(11/02 실험실, 10/30 라이브 27/30 과 같은 일).
665
+ // 실패 시간이 없는 과제에 갇히지 않게 한 과제에 STUDY_TASK_MAX_MS 가 지나면 놓아 준다
666
+ const st = rec.study;
667
+ if (decision.action === "leave" && st && !st.over && st.lastTaskKey && !st.canSkip && (st.onTaskMs || 0) < STUDY_TASK_MAX_MS) {
668
+ rec.visit.history.push(`(tried to leave in the middle of the task "${st.lastTask}" — stayed)`);
669
+ return [
670
+ `You agreed to take part in this study, so you do not walk out in the middle of a task. The study has not offered a way to give up on "${st.lastTask}" yet — studies usually do after a while. Until then, keep looking for it in ways you have not tried yet, as a person who is stuck does.`,
671
+ "",
672
+ await this.screen(rec),
673
+ ].join("\n");
674
+ }
659
675
  rec.lastAction = decision.action;
676
+ const a0 = Date.now();
677
+ v.lastWaitMs = 0;
660
678
  const r = await v.act(decision);
679
+ if (TRACE) trace(rec, { ev: "act", action: decision.action, study: r && r.study, actMs: Date.now() - a0, waitTurnMs: v.lastWaitMs || 0, sdk: await sdkSnap(rec.page) });
661
680
  // 패널의 건너뛰기를 눌렀다 — 그 과제를 건너뛴 것으로 남긴다
662
681
  if (r && r.study === "skip" && !r.skipped && rec.study) rec.study.skipped.push(rec.study.lastTask);
663
682
  if (r.interrupted && r.study) {
@@ -1011,12 +1030,14 @@ function describeAccounts(accs, loginOpen) {
1011
1030
  }
1012
1031
 
1013
1032
  /** 과제 중인 리서치 패널 */
1014
- function renderStudyPanel(task) {
1033
+ function renderStudyPanel(task, onTaskMs) {
1034
+ const mmss = (ms) => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, "0")}`;
1015
1035
  return [
1016
1036
  "## The study panel (in the corner of the screen)",
1017
1037
  `${task.progress ? `${task.progress} — ` : ""}${task.title}`,
1018
1038
  ...(task.about ? [task.about] : []),
1019
1039
  ...(task.timeLeft ? [`Time left: ${task.timeLeft}`] : []),
1040
+ ...(onTaskMs > 0 ? [`On this task for ${mmss(onTaskMs)}`] : []),
1020
1041
  ].join("\n");
1021
1042
  }
1022
1043
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.10-next.8",
3
+ "version": "0.2.10-next.9",
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",
@@ -119,7 +119,8 @@ async function coveredBy(page, el) {
119
119
  (node, [qx, qy, ownSel]) => {
120
120
  const h = document.elementFromPoint(qx, qy);
121
121
  if (!h || node === h || node.contains(h) || h.contains(node)) return null;
122
- if (ownSel && h.closest(ownSel)) return { own: true };
122
+ // 우리 것 — 리서치 패널(#eb-plugin)인지 설문 카드인지 가른다. 막혀서 돌아다니다 패널 밑 단추를 누르는 일이 잦다(11/02 실험실 5번)
123
+ if (ownSel && h.closest(ownSel)) return { own: h.closest("#eb-plugin") ? "panel" : "card" };
123
124
  // 덮은 덩어리 — 대화상자·팝업이면 그 전체, 아니면 맞은 요소
124
125
  // 덮은 것이 링크·버튼 안이면 눈에 보이는 조작이다. 그 안의 그림(아이콘)만 맞으면 글자가 없어 "투명한 막" 으로 보고
125
126
  // 그냥 눌렀다 — 아래 고정 메뉴의 Watchlist 아이콘이 눌려 관심목록으로 갔다(10/15 실험실 y57, 정보 창의 Join)
@@ -132,6 +133,7 @@ async function coveredBy(page, el) {
132
133
  )
133
134
  .catch(() => null);
134
135
  if (!hit) return null;
136
+ if (hit.own === "panel") return { say: "the study panel is on top of it — scroll or move so it is not under the panel" };
135
137
  if (hit.own) return { say: "the survey card is on top of it" };
136
138
  if (hit.text) return { say: `something else on the screen is on top of it ("${hit.text}") — you cannot reach it until that is out of the way` };
137
139
  return { invisible: true, x: cx, y: cy };
@@ -368,7 +370,7 @@ async function clickLikePerson(page, state, el) {
368
370
  }
369
371
  // 덮은 것을 못 찾아도 본다 — 틀 밖 자리를 찍으면 배너 틀 자신이 맞아 "안 덮였다" 가 되고, 끝에 Playwright 클릭이
370
372
  // "무언가 위에 있다" 로 끝났다(10/23 실험실 P1, 20초 뒤 누르기)
371
- if (!(cover && /survey card/.test(cover.say || "")) && (await outOfRow(el))) {
373
+ if (!(cover && /survey card|study panel/.test(cover.say || "")) && (await outOfRow(el))) {
372
374
  // 배너가 그 사이 넘어가 카드가 틀 밖으로 나갔다. 사람은 화면에 보이는 그 카드(복사본)를 누르거나 돌아오길 기다린다.
373
375
  // 틀 밖 자리에 마침 사이드바가 있어 "사이드바가 덮었다" 로 끝났다 — PC 홈 배너가 처음 몇 초 안에 네 칸을 넘긴다(10/23 실험실 O2)
374
376
  const vw = (page.viewportSize() || { width: 1200 }).width;
package/src/run/diag.js CHANGED
@@ -21,6 +21,7 @@ function kindOf(skipped) {
21
21
  if (/cut off at the edge|off the edge of the screen/.test(s)) return "cut_off";
22
22
  if (/slid (away|out of sight) as you went to tap it/.test(s)) return "moving";
23
23
  if (/survey card is on top/.test(s)) return "covered_by_card";
24
+ if (/study panel is on top/.test(s)) return "covered_by_panel";
24
25
  if (/on top of it/.test(s)) return "covered";
25
26
  if (/greyed out/.test(s)) return "disabled";
26
27
  if (/not visible on the screen/.test(s)) return "not_visible";
package/src/run/visit.js CHANGED
@@ -274,13 +274,20 @@ function fileWindowNote(fc) {
274
274
  }
275
275
 
276
276
  function firstLookMs(attention) {
277
- const [lo, hi] = { skims: [800, 6000], reads: [2500, 15000], studies: [5000, 25000] }[attention] || [2500, 15000];
277
+ // AI 도 이 화면을 읽느라 몇 초 쓴다 — 그 위에 길게 얹으면 첫 동작까지 30초가 넘었다(11/02 라이브 평균 37초)
278
+ const [lo, hi] = { skims: [500, 2500], reads: [1000, 5000], studies: [2000, 8000] }[attention] || [1000, 5000];
278
279
  // 짧은 쪽에 몰리고 가끔 길다
279
280
  return Math.round(lo + (hi - lo) * Math.pow(Math.random(), 1.8));
280
281
  }
281
282
 
283
+ /** 화면을 읽는 시간(Rhythm.gap)에 곱하는 값과 상한 — 실제 방문자 간격에 맞췄다(gapFor) */
284
+ const READ_GAP_SCALE = 0.5;
285
+ const READ_GAP_CAP_MS = 20000;
286
+
282
287
  /** 한 방문 걸음 수의 절대 상한. 판단을 누가 하든 이 이상은 안 걷는다 */
283
288
  const HARD_STEP_CAP = 60;
289
+ /** 리서치 참여 방문의 상한 — 과제가 여러 개고, 막히면 실패 시간까지 계속 찾아본다 */
290
+ const STUDY_STEP_CAP = 160;
284
291
 
285
292
  function stepPrompt() {
286
293
  return fs.readFileSync(STEP_PROMPT_FILE, "utf8");
@@ -603,12 +610,17 @@ class Visit {
603
610
  gapFor(view, text) {
604
611
  if (!this.rhythm) return 1200;
605
612
  const g = this.rhythm.gap({ sig: view.path, chars: text.length, media: 1, top: view.scrollY, vh: view.viewport, cmd: this.lastAction || "look" });
606
- return Math.min(45000, Math.max(250, g.ms));
613
+ // 실제 사람에 맞춘 배율. 11/02 bluetags: 실제 방문자의 동작 사이 중간값 2.5초·90% 13초인데
614
+ // 가상 사용자는 5.7초·35초였다(읽는 시간 모델이 길게 잡고, 그동안 화면이 멈춰 있었다)
615
+ // "딴 데 정신 팔림"(Rhythm.gap 의 idled, 4~54초)은 뺀다 — 그동안 화면이 통째로 멈춰 있을 뿐 아무것도 안 했다
616
+ const ms = g.ms - (g.detail && g.detail.idled ? g.detail.idled * 1000 : 0);
617
+ return Math.min(READ_GAP_CAP_MS, Math.max(250, ms * READ_GAP_SCALE));
607
618
  }
608
619
 
609
620
  /** 정해 둔 때까지 기다린다. AI 가 그보다 오래 생각했으면 바로 간다 */
610
621
  async waitTurn() {
611
622
  const ms = this.nextAt - Date.now();
623
+ this.lastWaitMs = (this.lastWaitMs || 0) + Math.max(0, ms);
612
624
  if (ms > 0) await sleep(ms);
613
625
  }
614
626
 
@@ -627,7 +639,9 @@ class Visit {
627
639
  // 브라우저 창이 떠 있다. 페이지는 멈춰 있어 읽으면 걸린다 — 창만 보여 준다
628
640
  if (this.dialog) return this.dialogLook();
629
641
  if (!this.card && !this.pending) {
630
- const shown = this.asCard(await waitCard(this.page, 500));
642
+ // 한 번 본다. 카드가 막 뜨는 중(보이기 전)일 때만 조금 기다린다 — 예전엔 카드가 없어도 매 화면 0.5초를 그냥 기다렸다(11/02 계측)
643
+ const now = await readCard(this.page);
644
+ const shown = this.asCard(now && !now.visible ? await waitCard(this.page, 500) : now);
631
645
  if (shown) this.pending = shown;
632
646
  }
633
647
  // 카드 판단에는 페이지 전체를 준다 — 이 방문에서 본 것을 다 기억하는 사람이다
@@ -650,8 +664,8 @@ class Visit {
650
664
  const shown = this.pending;
651
665
  if (!shown) throw new Error("No card is showing");
652
666
  const act = answer.toAction(decision, shown);
653
- // 카드를 읽고 정하는 시간. 닫는 건 금방, 쓰는 건 치는 만큼
654
- await sleep(act.kind === "close" ? 600 + Math.random() * 1800 : 1500 + Math.random() * 3000);
667
+ // 읽고 정하는 건 AI 가 이미 했다 — 손이 가는 시간만. 쓰는 답은 치는 만큼(11/02: 0.6~4.5초를 또 기다렸다)
668
+ await sleep(act.kind === "close" ? 300 + Math.random() * 600 : act.kind === "other" ? 600 + Math.min(5000, String(act.text || "").length * 40) : 400 + Math.random() * 800);
655
669
  await noteReason(this.page, decision.bothered ? decision.why : decision.botheredWhy);
656
670
  await answerCard(this.page, act).catch(() => {});
657
671
  this.card = {
@@ -702,7 +716,9 @@ class Visit {
702
716
  }
703
717
  // 보는 사이 리서치가 질문을 띄웠다(과제 타이머가 끝났다 등). 페이지를 덮으니 하려던 걸 하지 않는다 — 질문부터
704
718
  if (this.study && this.study.blocking && (await this.study.blocking())) return { interrupted: true, study: true };
705
- if (this.steps.length >= HARD_STEP_CAP) decision = { ...decision, action: "leave", why: `(${HARD_STEP_CAP}-step limit) ${decision.why}` };
719
+ // 리서치 참여자는 막히면 기다리지 않고 계속 찾아본다(실패 시간 5분이면 50걸음 남짓) — 상한을 넉넉히
720
+ const cap = this.study ? STUDY_STEP_CAP : HARD_STEP_CAP;
721
+ if (this.steps.length >= cap) decision = { ...decision, action: "leave", why: `(${cap}-step limit) ${decision.why}` };
706
722
  const page = this.page;
707
723
  const view = this.lastView || (await listControls(page));
708
724
  const item = view.items[decision.index];
@@ -878,7 +894,7 @@ class Visit {
878
894
  .clickLikePerson(page, this.handState, el)
879
895
  .catch((e) => ((record.skipped = e.human || humanClickError(e)), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
880
896
  // 사이트의 무언가가 덮어 못 눌렀다. 우리 카드·위젯이 덮은 것은 사이트 탓이 아니다
881
- if (record.skipped && /on top of it/.test(record.skipped) && !/survey card/.test(record.skipped) && !ours)
897
+ if (record.skipped && /on top of it/.test(record.skipped) && !/survey card|study panel/.test(record.skipped) && !ours)
882
898
  await this.shoot(record, "covered", await boxOf(page, el));
883
899
  if (clicked.missed) {
884
900
  record.missed = true;
@@ -953,7 +969,7 @@ class Visit {
953
969
  }
954
970
  if (this.fileWindow) record.fileWindow = fileWindowNote(this.fileWindow);
955
971
  // 누르는 순간 리서치 질문이 페이지를 덮었다 — 도구가 못 한 게 아니라 리서치가 끼어든 것이다(10/30 실험실: 과제 타이머가 클릭과 겹쳤다)
956
- if (record.skipped && /survey card is on top/.test(record.skipped) && this.study && this.study.blocking && (await this.study.blocking())) {
972
+ if (record.skipped && /(survey card|study panel) is on top/.test(record.skipped) && this.study && this.study.blocking && (await this.study.blocking())) {
957
973
  record.skipped = "the study's question came up just as you clicked, so the click did not reach the page";
958
974
  record.studyCut = true;
959
975
  }