@usercase/mcp 0.2.9 → 0.2.10-next.1
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/agents/study.md +26 -0
- package/mcp/hub.js +266 -3
- package/mcp/server.js +21 -6
- package/package.json +1 -1
- package/src/browser/card.js +7 -3
- package/src/browser/guard.js +219 -211
- package/src/browser/open.js +60 -59
- package/src/browser/study.js +199 -0
- package/src/run/visit.js +32 -4
package/agents/study.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
**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.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- Pass `research: "{{research}}"` to `save_people`. Only people saved with it take part.
|
|
15
|
+
- Leave out `feature` spreading over the whole feature map — the research decides what the visit is about.
|
|
16
|
+
|
|
17
|
+
**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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
**Step 5 — report the research first.** From each visit's `Research:` line in `results`:
|
|
22
|
+
- 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
|
|
23
|
+
- the answers people gave to the research's questions after the tasks (from the records only)
|
|
24
|
+
- how many finished all tasks, and how many opened the closing questionnaire
|
|
25
|
+
|
|
26
|
+
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
package/src/browser/card.js
CHANGED
|
@@ -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
|
|