@usercase/mcp 0.2.10-next.0 → 0.2.10-next.2

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,28 @@
1
+ ## This run takes part in one research: {{research}}
2
+
3
+ The user wants the people to **take part in a research** from their Usercase dashboard — do its tasks and answer its questions, the way invited participants do — instead of exploring freely. Everything below still applies, with these changes:
4
+
5
+ **Step 1 — look at the research first.** Right after `check_site`, call `path_tasks` with research `{{research}}`. It shows the research's title, status, purpose and tasks (what the participant is told, and when each task counts as done).
6
+ - If the status is not `deployed`, stop and tell the user: the site only offers deployed researches, so the visitors could not join it. They can deploy it in the dashboard and run this again.
7
+ - If the Usercase SDK is not running on the site, stop — without it there is no research to join.
8
+
9
+ Show the user the tasks in one short list before casting, so they know what the people will be asked to do.
10
+
11
+ **The research decides what the visit is about — do not ask the user about it.** Not what the visitors should try, how far they should go, which tasks to cover or what to focus on: the participants get the research's tasks from the study panel and do what they would do. The only questions for the user are the ones the steps below always ask — demo accounts (if parts of the tasks need sign-in) and whether writes are sent — asked as those questions, in those words, and not merged with anything about the research. A task that needs a sign-in the visitors do not have is still run: what happens to a participant without an account is a result.
12
+
13
+ **Step 3 — cast participants for this research.** Each person is someone who really comes to this site and who would accept this research's invitation — people its purpose is about. Their `goal` is still their own reason for being on the site, in their own words; **do not write the tasks into the goal or anywhere else**. The visitors read the tasks themselves in the study panel, as real participants do — a visitor told the task by you knows more than a participant would, and the research loses its value.
14
+ - Vary them the way real participants vary: devices, how familiar they are with the site, patience, how carefully they read. Some will finish every task, some will get stuck — that is what the research is for.
15
+ - Give each person a `landing` page where the research's invitation would meet them: the page the first task starts from, if the tasks or the code make it clear; otherwise where they would arrive on their own.
16
+ - Pass `research: "{{research}}"` to `save_people`. Only people saved with it take part.
17
+ - Leave out `feature` spreading over the whole feature map — the research decides what the visit is about.
18
+
19
+ **Step 4 — unchanged.** Each visitor accepts the invitation and agrees to the recording automatically (they are participants who said yes), then does the tasks from the study panel. Do not tell the visitors anything about the research.
20
+
21
+ If a visitor reports it could not join the research, stop starting new ones, call `check_site`, and tell the user what the visitor said.
22
+
23
+ **Step 5 — report the research first.** From each visit's `Research:` line in `results`:
24
+ - for each task: how many reached it, how many it moved on from by itself (done), how many skipped it or left during it — and where those people were and what they said
25
+ - the answers people gave to the research's questions after the tasks (from the records only)
26
+ - how many finished all tasks, and how many opened the closing questionnaire
27
+
28
+ These participants are virtual users. Their sessions are stored in the research's results marked as virtual users, separate from real participants — say so in one line.
package/mcp/hub.js CHANGED
@@ -24,8 +24,13 @@ const { waitForSdk, agentKey, KEY_ENV, KEY_HEADER, AGENT_HEADER, uaWith } = requ
24
24
  const ws = require("./workspace.js");
25
25
  const signin = require("../src/browser/signin.js");
26
26
  const { openRealChrome } = require("../src/browser/real-chrome.js");
27
+ const study = require("../src/browser/study.js");
27
28
  const report = require("./report.js");
28
29
 
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ /** 리서치가 아직 진행 중인 위젯 상태(core StateManager WidgetState). 이 밖이면 과제가 끝난 것이다 */
32
+ const STUDY_ACTIVE = new Set(["consent", "loading", "testing", "timeout", "survey", "next", "submit"]);
33
+
29
34
  /**
30
35
  * 가상 사용자 표시 이름. 서버가 이 이름으로 가상 사용자 답을 진짜 답과 갈라 센다
31
36
  * (earningbirds-server insightFindingService.SYNTHETIC_AGENTS). 자유 탐방이라 qa-explore 와 같은 부류다.
@@ -290,13 +295,27 @@ class Hub {
290
295
  const notSent = [];
291
296
  // 로컬 사이트가 바깥 서버에 닿으려다 끊긴 것(host). 방문자에게 "이 시험 실행에서는 거기 안 닿는다" 로 알린다
292
297
  const external = [];
298
+ // 리서치에 참여하러 온 사람이면 그 리서치. SDK 가 도착하자마자 그 리서치 초대를 띄운다(study.js)
299
+ const research = p.research || null;
293
300
  const ctx = await this.context(s, {
294
301
  device: p.device,
295
302
  storageState,
296
303
  onOffsite: (u) => offsite.push(u),
297
304
  onWrite: (k) => notSent.push(k),
298
305
  onExternal: (h) => external.push(h),
299
- persona: { id: p.id, device: p.device, background: p.background, goal: p.goal, moment: p.moment, attention: p.attention, surveyHabit: p.surveyHabit, writing: p.writing, traits: p.traits, path: "free exploration" },
306
+ research,
307
+ persona: {
308
+ id: p.id,
309
+ device: p.device,
310
+ background: p.background,
311
+ goal: p.goal,
312
+ moment: p.moment,
313
+ attention: p.attention,
314
+ surveyHabit: p.surveyHabit,
315
+ writing: p.writing,
316
+ traits: p.traits,
317
+ path: research ? `research participant (${research})` : "free exploration",
318
+ },
300
319
  });
301
320
  const page = await ctx.newPage();
302
321
  s.blockWatch.watch(page, s.origin);
@@ -310,6 +329,22 @@ class Hub {
310
329
  // 그걸 갈 곳으로 적으면 "chromewebdata 라는 다른 사이트" 로 안내됐다(10/14 실험실 p22)
311
330
  if (noted || !/^https?:/i.test(u || "")) return;
312
331
  noted = true;
332
+ // 리서치의 끝난 뒤 설문. SDK 가 새 탭으로 연다(누르지 않아도, Done 을 눌러도 — 두 번 올 수 있다).
333
+ // 참여자는 그 탭으로 옮겨 가 설문을 한다 — 방문은 한 탭이라 보던 탭을 그 주소로 옮긴다(한 번만)
334
+ if (research && study.isSurveyUrl(u)) {
335
+ pop.close().catch(() => {});
336
+ if (rec.study && !rec.study.surveyAt) {
337
+ rec.study.surveyAt = Date.now();
338
+ const going = page
339
+ .goto(u, { waitUntil: "domcontentloaded", timeout: 30000 })
340
+ .catch(() => {})
341
+ .finally(() => {
342
+ if (page.__ebPendingNav === going) page.__ebPendingNav = null;
343
+ });
344
+ page.__ebPendingNav = going;
345
+ }
346
+ return;
347
+ }
313
348
  let same = false;
314
349
  try {
315
350
  same = new URL(u).origin === s.origin;
@@ -383,13 +418,49 @@ class Hub {
383
418
  }
384
419
  }
385
420
  rec.account = account ? account.account : null;
386
- rec.visit = new Visit(page, p, { rhythm, shape, policy: s.policy, offsite, notSent, external });
421
+ rec.visit = new Visit(page, p, {
422
+ rhythm,
423
+ shape,
424
+ policy: s.policy,
425
+ offsite,
426
+ notSent,
427
+ external,
428
+ study: research
429
+ ? {
430
+ press: (k) => study.press(page, k),
431
+ // 과제 질문·시간 초과 창이 페이지를 덮고 있나 — 그 사이 조작은 페이지에 닿지 않는다
432
+ blocking: async () => {
433
+ const w = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
434
+ return !!w && (w.state === "survey" || w.state === "timeout");
435
+ },
436
+ }
437
+ : null,
438
+ });
387
439
  const sdk = await rec.visit.begin();
440
+ // 리서치 참여 — 초대 수락·녹화 동의까지는 참여하기로 한 사람이 늘 하는 것이라 도구가 한다
441
+ if (research) {
442
+ rec.study = { research, answers: [], tasks: [], skipped: [], lastTask: null, over: false, surveyAt: null };
443
+ const j = await study.join(page);
444
+ if (!j.ok) {
445
+ await this.finish(rec, { study: { research, failed: j.why } });
446
+ return {
447
+ stop: true,
448
+ text: `This visit could not join the research: ${j.why}.\n\n→ Reply to the coordinator with exactly this, and stop.`,
449
+ };
450
+ }
451
+ }
388
452
  const arrived =
389
453
  how.how === "direct" ? `You typed the address and landed on ${how.path}.` : `You came from ${how.how} (${how.referer || "a link"}) and landed on ${how.path}.`;
390
454
  // **누구인지 먼저.** 이게 없으면 서브에이전트가 이름만 보고 짐작해서 걷는다(9/21 그랬다)
391
455
  const lines = [`visit_id: ${id}`, "", "## You", renderPerson(p, { card: true }), "", arrived];
392
456
  if (p.member) lines.push("You already have an account here and are signed in.");
457
+ if (research)
458
+ lines.push(
459
+ "",
460
+ "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.",
461
+ "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.",
462
+ "If you get stuck on a task, behave as you would: keep trying, use the panel's Skip when it offers one, or leave. The study may ask you a few questions along the way; answer them with answer_card.",
463
+ );
393
464
  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)");
394
465
  lines.push("", await this.screen(rec));
395
466
  return { id, text: lines.join("\n") };
@@ -398,9 +469,14 @@ class Hub {
398
469
  /** 지금 화면(또는 떠 있는 카드)을 에이전트가 읽을 글로 */
399
470
  async screen(rec) {
400
471
  const v = rec.visit;
472
+ // 리서치 참여 중이면 위젯부터 본다 — 과제 질문·시간 초과 창은 페이지를 덮고, 방문자가 먼저 답해야 한다
473
+ const st = rec.study && !v.ended ? await this.studyLook(rec) : null;
474
+ if (st && st.card) return st.card;
401
475
  const look = await v.look();
402
476
  if (look.ended) return "This visit has ended.";
403
477
  if (look.card) return renderCard(look.card);
478
+ // 과제 건너뛰기는 위젯 단추다. 화면 목록 끝에 붙인다 — 누르면 Visit 이 위젯을 누른다
479
+ 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" });
404
480
  // 브라우저 창(confirm 등)이 떠 있다. 페이지는 멈춰 있다 — 아이콘 그림·위치는 안 본다
405
481
  if (look.view.dialog) {
406
482
  this.pictures.set(rec.id, null);
@@ -418,16 +494,123 @@ class Hub {
418
494
  rec.lastY = y;
419
495
  rec.lastAt = at;
420
496
  rec.lastCover = cover;
497
+ if (st && st.note) lines.push(st.note, "");
421
498
  lines.push(renderScreen(look));
499
+ if (st && st.panel) lines.push("", st.panel);
422
500
  // 이름 없는 아이콘은 그림으로 같이 준다 — 좌표만으로는 사진 첨부와 보내기를 못 갈랐다(10/21 bluetags 2b72)
423
501
  const pic = await iconPicture(v.page, look.view.items, await this.browser().catch(() => null)).catch(() => null);
424
502
  this.pictures.set(rec.id, pic);
425
503
  if (pic) lines.push("(A picture of the unlabelled icons on this screen is attached — each is marked with its number from the list above. Look at their shapes the way a person would.)");
426
504
  // 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
427
- if (v.patienceSpent) lines.push("", "(Your patience is running low. Unless what is in front of you is getting you closer to what you came for, this is about where you would give up.)");
505
+ // 리서치 참여자는 과제를 하러 왔다 — 둘러보다 지치는 인내심 표시는 맞지 않는다
506
+ if (v.patienceSpent && !rec.study) lines.push("", "(Your patience is running low. Unless what is in front of you is getting you closer to what you came for, this is about where you would give up.)");
428
507
  return lines.join("\n");
429
508
  }
430
509
 
510
+ /**
511
+ * 리서치 위젯이 지금 무엇을 보여 주나. 다음 과제로 넘기기와 올리는 동안 기다리기는 여기서 한다 — 참여자가 늘 그냥 하는 것이다.
512
+ * @returns { card } 과제 질문·시간 초과 창(방문자가 answer_card 로 답한다) · { panel, skip, note } 과제 중 · { note } 끝남 · null
513
+ */
514
+ async studyLook(rec) {
515
+ const st = rec.study;
516
+ const page = rec.page;
517
+ rec.studyPending = null;
518
+ if (st.over) {
519
+ // 끝난 뒤 설문이 늦게 열리면(올린 뒤 서버가 알려 준다) 그때 한 번 말한다
520
+ if (st.surveyAt && !st.surveyTold) {
521
+ st.surveyTold = true;
522
+ return { note: "(The study has opened its short closing questionnaire for you — the page below. Fill it in as yourself; when you are done, you can leave.)" };
523
+ }
524
+ return null;
525
+ }
526
+ let s = null;
527
+ for (let i = 0; i < 6; i++) {
528
+ // 끝난 뒤 설문 페이지에도 #eb-plugin 이 있다(10/30 실험실) — 위젯을 읽지 않는다
529
+ s = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
530
+ if (!s) break;
531
+ if (s.state === "next") {
532
+ await sleep(1200 + Math.random() * 1800);
533
+ await study.press(page, "next").catch(() => {});
534
+ await study.waitState(page, ["testing", "survey", "submit", "done"], 15000);
535
+ continue;
536
+ }
537
+ if (s.state === "submit" || s.state === "loading") {
538
+ await study.waitState(page, ["done", "complete", "testing", "survey", "next", "idle", "invite"], 120000);
539
+ continue;
540
+ }
541
+ break;
542
+ }
543
+ if (s && s.state === "survey" && s.question) {
544
+ rec.studyPending = { kind: "question", q: s.question, task: st.lastTask };
545
+ return { card: renderStudyQuestion(s.question, st.lastTask) };
546
+ }
547
+ if (s && s.state === "timeout" && s.timeout) {
548
+ rec.studyPending = { kind: "timeout", t: s.timeout, task: st.lastTask };
549
+ return { card: renderStudyTimeout(s.timeout, st.lastTask) };
550
+ }
551
+ if (s && s.state === "testing" && s.task) {
552
+ const key = `${s.task.progress}|${s.task.title}`;
553
+ let note = null;
554
+ if (st.lastTaskKey !== key) {
555
+ if (st.lastTaskKey) note = "(The study panel has moved on to a new task.)";
556
+ st.tasks.push({ title: s.task.title, progress: s.task.progress, at: Date.now() - rec.startedAt });
557
+ }
558
+ st.lastTaskKey = key;
559
+ st.lastTask = s.task.title;
560
+ return { panel: renderStudyPanel(s.task), skip: s.task.skip, note };
561
+ }
562
+ // 과제가 다 끝났다. 끝난 뒤 설문이 있으면 SDK 가 새 탭으로 열고, 그 탭은 이 탭으로 옮겨 온다(open 의 새 탭 처리)
563
+ if (st.tasks.length && (!s || !STUDY_ACTIVE.has(s.state))) {
564
+ st.over = true;
565
+ st.overAt = Date.now() - rec.startedAt;
566
+ if (!st.surveyAt) await sleep(4000);
567
+ if (st.surveyAt) {
568
+ st.surveyTold = true;
569
+ if (page.__ebPendingNav) await page.__ebPendingNav;
570
+ return { note: "(The study's tasks are done, and it opened a short closing questionnaire for you — the page below. Fill it in as yourself; when you are done, you can leave.)" };
571
+ }
572
+ return { note: "(The study's tasks are done — the panel thanked you. There is nothing more you have to do for it; carry on as you would, or leave.)" };
573
+ }
574
+ return null;
575
+ }
576
+
577
+ /** 과제 질문·시간 초과 창에 답한다. 참여하기로 한 사람이라 닫지 않는다 — 비워 두고 넘기는 건 된다(필수가 아니면) */
578
+ async studyAnswer(rec, decision) {
579
+ const p = rec.studyPending;
580
+ const st = rec.study;
581
+ const page = rec.page;
582
+ // 읽고 정하는 시간
583
+ await sleep(1200 + Math.random() * 2500);
584
+ let said;
585
+ if (p.kind === "timeout") {
586
+ const skip = decision.action === "choose" && decision.choiceIndex === 1;
587
+ await study.press(page, skip ? "timeoutSkip" : "keepGoing").catch(() => {});
588
+ if (skip) st.skipped.push(p.task);
589
+ said = skip ? `You chose to skip the task "${p.task}".` : "You chose to keep trying.";
590
+ } else {
591
+ const q = p.q;
592
+ const ans = decision.action === "choose" ? { index: decision.choiceIndex } : decision.action === "other" ? { text: decision.otherText } : {};
593
+ if (ans.index !== undefined && !(ans.index >= 0 && ans.index < q.options.length)) return ["That option number is not on the question.", "", await this.screen(rec)].join("\n");
594
+ const err = await study.answerQuestion(page, q, ans).catch((e) => `it did not go through (${String(e.message).split("\n")[0]})`);
595
+ if (err) return [`You could not send that: ${err}.`, "", await this.screen(rec)].join("\n");
596
+ const answer = ans.index !== undefined ? q.options[ans.index] : ans.text || null;
597
+ st.answers.push({ task: p.task, question: q.title, answer, why: decision.why });
598
+ said = answer ? `You answered: ${answer}` : "You left it blank and went on.";
599
+ }
600
+ rec.studyPending = null;
601
+ rec.visit.history.push(`The study asked "${p.kind === "timeout" ? "is this task difficult?" : p.q.title}" — ${said}`);
602
+ // 질문이 가로챈 조작은 일어나지 않았다 — 질문이 다 끝나 페이지로 돌아갈 때 한 번 말한다
603
+ if (rec.cutOff) {
604
+ const next = await study.readStudy(page);
605
+ if (!next || (next.state !== "survey" && next.state !== "timeout")) {
606
+ said += " What you were about to do on the page before the study's question appeared did not happen — do it again if you still want to.";
607
+ rec.cutOff = false;
608
+ }
609
+ }
610
+ await sleep(1500);
611
+ return [said, "", await this.screen(rec)].join("\n");
612
+ }
613
+
431
614
  /** 마지막 화면에 붙일 아이콘 그림. 한 번 가져가면 지운다(다음 화면은 새로 만든다) */
432
615
  takePicture(id) {
433
616
  const pic = this.pictures.get(id) || null;
@@ -447,8 +630,17 @@ class Hub {
447
630
  async act(id, decision) {
448
631
  const rec = this.get(id);
449
632
  const v = rec.visit;
633
+ // 리서치 질문이 페이지를 덮고 있다 — 먼저 답해야 한다
634
+ 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");
450
635
  rec.lastAction = decision.action;
451
636
  const r = await v.act(decision);
637
+ // 패널의 건너뛰기를 눌렀다 — 그 과제를 건너뛴 것으로 남긴다
638
+ if (r && r.study === "skip" && !r.skipped && rec.study) rec.study.skipped.push(rec.study.lastTask);
639
+ if (r.interrupted && r.study) {
640
+ rec.cutOff = true;
641
+ return ["Before you could do that, the study asked you something.", "", await this.screen(rec)].join("\n");
642
+ }
643
+ if (r.studyCut) rec.cutOff = true;
452
644
  if (r.interrupted) {
453
645
  rec.cutOff = true;
454
646
  return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
@@ -489,6 +681,7 @@ class Hub {
489
681
  /** 카드에 답하거나 닫는다 */
490
682
  async answer(id, decision) {
491
683
  const rec = this.get(id);
684
+ if (rec.studyPending) return this.studyAnswer(rec, decision);
492
685
  const v = rec.visit;
493
686
  const c = await v.answer(decision);
494
687
  let done = c.result === "close" ? "You closed the card." : `You answered: ${c.answer}`;
@@ -521,6 +714,19 @@ class Hub {
521
714
  ms: Date.now() - rec.startedAt,
522
715
  sdkReady: rec.visit ? !!rec.visit.sdk?.ready : null,
523
716
  ...(rec.account ? { account: rec.account } : {}),
717
+ // 리서치 참여 — 어느 과제까지 갔고, 무엇을 건너뛰고, 과제 질문에 무엇을 답했나. 참여 기록 자체는 SDK 가 서버에 올린다
718
+ ...(rec.study
719
+ ? {
720
+ study: {
721
+ research: rec.study.research,
722
+ tasks: rec.study.tasks,
723
+ skipped: rec.study.skipped,
724
+ answers: rec.study.answers,
725
+ finished: rec.study.over,
726
+ questionnaire: !!rec.study.surveyAt,
727
+ },
728
+ }
729
+ : {}),
524
730
  // 도구가 하지 못한 것 — 고객도 자기 기록에서 볼 수 있게 종류만 남긴다(HTML·화면은 서버로만)
525
731
  ...(rec.visit && rec.visit.diag && rec.visit.diag.length ? { diag: rec.visit.diag.map(({ kind, action, shape, detail }) => ({ kind, action, shape, detail })) } : {}),
526
732
  ...extra,
@@ -780,6 +986,51 @@ function describeAccounts(accs, loginOpen) {
780
986
  return L.join("\n");
781
987
  }
782
988
 
989
+ /** 과제 중인 리서치 패널 */
990
+ function renderStudyPanel(task) {
991
+ return [
992
+ "## The study panel (in the corner of the screen)",
993
+ `${task.progress ? `${task.progress} — ` : ""}${task.title}`,
994
+ ...(task.about ? [task.about] : []),
995
+ ...(task.timeLeft ? [`Time left: ${task.timeLeft}`] : []),
996
+ ].join("\n");
997
+ }
998
+
999
+ /** 과제 질문(실패 이유 포함). 참여하기로 한 사람이라 답한다 */
1000
+ function renderStudyQuestion(q, task) {
1001
+ const L = [
1002
+ "## The study is asking you a question",
1003
+ `This is part of the study you agreed to take part in${task ? ` (it comes after the task "${task}")` : ""}. It covers the page until you answer.`,
1004
+ "",
1005
+ ...(q.label ? [q.label] : []),
1006
+ `Question: ${q.title}`,
1007
+ ...(q.sub ? [q.sub] : []),
1008
+ ];
1009
+ if (q.kind === "choice") L.push("Options:", ...q.options.map((o, i) => `[${i}] ${o}`), ...(q.other ? ["There is also a box to type something else."] : []));
1010
+ else if (q.kind === "scale") L.push(`A scale — pick one:${q.scaleLabels && q.scaleLabels.length ? ` (${q.scaleLabels.join(" … ")})` : ""}`, ...q.options.map((o, i) => `[${i}] ${o}`));
1011
+ else if (q.kind === "yesno") L.push("Options:", ...q.options.map((o, i) => `[${i}] ${o}`));
1012
+ else L.push(q.multiline ? "A box to write your answer in." : "A one-line box to type your answer in.");
1013
+ L.push(
1014
+ "",
1015
+ "Answer as yourself with answer_card: bothered true, then choose an option (choice_index) or write your own words (action other, other_text) the way this person writes. Close only if you would leave it blank — that works only when the question does not require an answer.",
1016
+ );
1017
+ return L.join("\n");
1018
+ }
1019
+
1020
+ /** 과제에 시간이 오래 걸리면 패널이 묻는다 */
1021
+ function renderStudyTimeout(t, task) {
1022
+ return [
1023
+ "## The study panel is asking you",
1024
+ `You have been on the task${task ? ` "${task}"` : ""} for a while.`,
1025
+ ...(t.text ? [t.text] : []),
1026
+ "Options:",
1027
+ `[0] ${t.keepGoing} — keep trying this task`,
1028
+ `[1] ${t.skip} — give up on this task`,
1029
+ "",
1030
+ "Decide as yourself with answer_card: bothered true, then choose 0 or 1.",
1031
+ ].join("\n");
1032
+ }
1033
+
783
1034
  function renderCard(card) {
784
1035
  return [
785
1036
  "## A survey card appeared on the screen",
@@ -792,6 +1043,17 @@ function renderCard(card) {
792
1043
  ].join("\n");
793
1044
  }
794
1045
 
1046
+ /** 리서치 참여 한 줄 — 어디까지 갔나 */
1047
+ function studyLine(s) {
1048
+ if (s.failed) return `Research: could not join — ${s.failed}`;
1049
+ const reached = s.tasks.map((t) => `"${t.title}"`).join(" → ") || "no task";
1050
+ const skipped = s.skipped.length ? ` · skipped ${s.skipped.map((t) => `"${t}"`).join(", ")}` : "";
1051
+ const end = s.finished ? (s.questionnaire ? " · finished the tasks, closing questionnaire opened" : " · finished the tasks") : " · left before the end";
1052
+ // 과제 질문의 답 — 보고에 쓴다(끝난 뒤 설문의 답은 그 페이지에서 쓴 것이라 걸음에 남는다)
1053
+ const answers = s.answers.map((a) => ` - "${a.question}" → ${a.answer == null ? "(left blank)" : `"${String(a.answer).slice(0, 300)}"`}`);
1054
+ return [`Research: tasks ${reached}${skipped} · ${s.answers.length} question(s) answered${end}`, ...answers].join("\n");
1055
+ }
1056
+
795
1057
  /** 방문 한 줄 요약 — 메인 에이전트가 모아서 사용자에게 보여준다 */
796
1058
  function summarize(row) {
797
1059
  const last = row.steps[row.steps.length - 1];
@@ -800,6 +1062,7 @@ function summarize(row) {
800
1062
  `${row.person} (${row.device}) · ${row.entry?.how ?? "?"} → ${row.entry?.path ?? "/"} · ${row.steps.length} steps · ${Math.round(row.ms / 1000)}s`,
801
1063
  row.pages?.length ? `Pages: ${row.pages.join(" → ")}` : "",
802
1064
  card,
1065
+ row.study ? studyLine(row.study) : "",
803
1066
  last ? `Last: ${last.action} — ${last.why}` : row.blocked ? "Stopped at a block page" : "",
804
1067
  ]
805
1068
  .filter(Boolean)
package/mcp/server.js CHANGED
@@ -117,9 +117,9 @@ server.registerTool(
117
117
  }),
118
118
  );
119
119
 
120
- /** 사람 한 줄: 기기 · 유입 → 착지 · 회원 · [기능] — 목적 */
120
+ /** 사람 한 줄: 기기 · 유입 → 착지 · 회원 · [기능] · 리서치 — 목적 */
121
121
  const personLine = (p) =>
122
- `${p.device} · ${p.arrival}${p.landing ? ` → ${p.landing}` : ""}${p.member ? " · member" : ""}${p.feature ? ` · [${p.feature}]` : ""} — ${p.goal}`;
122
+ `${p.device} · ${p.arrival}${p.landing ? ` → ${p.landing}` : ""}${p.member ? " · member" : ""}${p.feature ? ` · [${p.feature}]` : ""}${p.research ? ` · takes part in research ${p.research}` : ""} — ${p.goal}`;
123
123
 
124
124
  /** 사람들이 어느 기능에 몰려 있나 — 다음 판에 빈 곳을 채우라고 보여 준다 */
125
125
  function coverageLine(people) {
@@ -181,9 +181,13 @@ server.registerTool(
181
181
  site: z.string().url().describe("Site origin the visits go to"),
182
182
  site_about: z.string().describe("One or two English sentences on what the site is, as a visitor would understand it"),
183
183
  people: z.array(PERSON).min(1).max(50),
184
+ research: z
185
+ .string()
186
+ .optional()
187
+ .describe("Only when the run takes part in a research (the research command's research id): everyone saved in this call takes part in it"),
184
188
  },
185
189
  },
186
- wrap(async ({ site, site_about, people }) => {
190
+ wrap(async ({ site, site_about, people, research }) => {
187
191
  const doc = ws.loadPeople();
188
192
  doc.site = new URL(site).origin;
189
193
  doc.siteAbout = site_about;
@@ -198,7 +202,8 @@ server.registerTool(
198
202
  return undefined;
199
203
  }
200
204
  };
201
- const ids = peopleRules.withIds(doc.people, people.map((p) => ({ ...p, landing: landingOf(p.landing), createdAt: at, usedAt: null })));
205
+ const rid = (research || "").trim() || undefined;
206
+ const ids = peopleRules.withIds(doc.people, people.map((p) => ({ ...p, landing: landingOf(p.landing), research: rid, createdAt: at, usedAt: null })));
202
207
  for (const p of ids.incoming) p.traits = persona.coerceTraits(p.traits, peopleRules.personKey(p)).traits;
203
208
  doc.people = [...ids.existing, ...ids.incoming];
204
209
  ws.savePeople(doc);
@@ -923,8 +928,12 @@ const COMMANDS = {
923
928
  url: z.string().optional(),
924
929
  count: z.string().optional().describe('How many people (default 5), or "unlimited" to keep going until you stop it'),
925
930
  focus: z.string().optional().describe("Only people who come for this part of the site, e.g. calendar, or comments and sharing"),
931
+ research: z
932
+ .string()
933
+ .optional()
934
+ .describe("A research id from the dashboard: the people take part in that research — its tasks and questions — instead of exploring freely"),
926
935
  },
927
- body: ({ url, count, focus }) => {
936
+ body: ({ url, count, focus, research }) => {
928
937
  const plan = researchPlan(count);
929
938
  run = { total: plan.total, since: Date.now() };
930
939
  // 사람 만드는 규칙은 우리 AI 로 만들 때와 같은 원본(people.md)에서 가져온다 — 두 벌로 두지 않는다.
@@ -938,7 +947,13 @@ const COMMANDS = {
938
947
  .replace(/^#/gm, "###");
939
948
  // focus 가 있으면 그 기능에 올 사람만 — 바뀌는 단계만 따로 적은 글을 앞에 붙인다. 방문자는 모른다
940
949
  const f = (focus || "").trim();
941
- const focusPart = f ? doc("focus.md").replaceAll("{{focus}}", f).trim() + "\n\n" : "";
950
+ // 리서치에 참여시키는 판이면 그 리서치가 방문을 정한다 — focus 는 쓰지 않는다
951
+ const rid = (research || "").trim();
952
+ const focusPart = rid
953
+ ? doc("study.md").replaceAll("{{research}}", rid).trim() + "\n\n"
954
+ : f
955
+ ? doc("focus.md").replaceAll("{{focus}}", f).trim() + "\n\n"
956
+ : "";
942
957
  return doc("research.md")
943
958
  .replace("{{focus}}\n", focusPart)
944
959
  .replace("{{url}}", url || "(ask the user)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.10-next.0",
3
+ "version": "0.2.10-next.2",
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",
@@ -132,6 +132,8 @@ async function readCard(page) {
132
132
  const q = card.querySelector(".eb-ip-question");
133
133
  return {
134
134
  visible: card.classList.contains("eb-ip-visible"),
135
+ // 리서치 초대(수락 단추가 있다). 리서치에 참여 중인 방문은 이걸 질문 카드로 다루지 않는다(study.js 가 받는다)
136
+ invite: !!card.querySelector(".eb-ip-accept"),
135
137
  question: q ? q.textContent.trim() : "",
136
138
  choices: Array.from(card.querySelectorAll(".eb-ip-opt:not(.eb-ip-other):not(.eb-ip-decline)")).map((b) => b.textContent.trim()),
137
139
  };
@@ -188,7 +190,7 @@ async function answerCard(page, action) {
188
190
  * siteOrigin·key: 위조 대비 비밀값(`KEY_HEADER`). key 를 안 주면 환경변수 `USERCASE_AGENT_KEY`.
189
191
  * 비밀값이 있으면 siteOrigin 이 반드시 있어야 한다 — 그 origin 에만 보낸다.
190
192
  */
191
- async function markAgent(ctx, agent, { debugCards = true, hideDevOverlay = true, persona = null, siteOrigin = null, key } = {}) {
193
+ async function markAgent(ctx, agent, { debugCards = true, hideDevOverlay = true, persona = null, research = null, siteOrigin = null, key } = {}) {
192
194
  /**
193
195
  * **HTTP 층에서도 밝힌다.** 아래 `window.Earningbirds.agent` 는 페이지 JS 안이라 우리 SDK 만 읽는다.
194
196
  * 고객의 WAF·CDN·분석 도구는 페이지 앞에 앉아 헤더만 보므로 그것만으로는 우릴 알아볼 수 없다.
@@ -203,7 +205,7 @@ async function markAgent(ctx, agent, { debugCards = true, hideDevOverlay = true,
203
205
  // 비밀값은 고객이 정했을 때만. 정했는데 siteOrigin 을 안 주면 sendKey 가 멈춘다 — 새는 것보다 낫다
204
206
  await sendKey(ctx, siteOrigin, agentKey(key));
205
207
  await ctx.addInitScript(
206
- ({ name, debugCards: dbg, hideDevOverlay: hide, persona: who }) => {
208
+ ({ name, debugCards: dbg, hideDevOverlay: hide, persona: who, research: rid }) => {
207
209
  if (dbg) {
208
210
  try {
209
211
  localStorage.setItem("Usercase_ip_debug", "1");
@@ -216,6 +218,8 @@ async function markAgent(ctx, agent, { debugCards = true, hideDevOverlay = true,
216
218
  window.Usercase.agent = name;
217
219
  // 이 방문이 누구였는지. 닫고 나가도 남아야 해서 노출 때 같이 간다
218
220
  if (who) window.Usercase.agentPersona = who;
221
+ // 참여하러 온 리서치. SDK 가 도착하자마자 그 리서치의 초대를 조건 없이 띄운다(agent 가 있을 때만 믿는다)
222
+ if (rid) window.Usercase.agentResearch = rid;
219
223
  } catch (e) {}
220
224
  if (hide) {
221
225
  try {
@@ -234,7 +238,7 @@ async function markAgent(ctx, agent, { debugCards = true, hideDevOverlay = true,
234
238
  } catch (e) {}
235
239
  }
236
240
  },
237
- { name: agent, debugCards, hideDevOverlay, persona },
241
+ { name: agent, debugCards, hideDevOverlay, persona, research },
238
242
  );
239
243
  }
240
244