@usercase/mcp 0.2.11-next.2 → 0.2.11-next.4
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/README.md +45 -0
- package/agents/fix-issue.md +40 -0
- package/agents/fix.md +5 -1
- package/mcp/hub.js +18 -3
- package/mcp/path.js +4 -2
- package/mcp/report.js +7 -0
- package/mcp/server.js +111 -7
- package/package.json +1 -1
- package/src/browser/hand.js +3 -1
- package/src/browser/study.js +108 -5
- package/src/run/visit.js +2 -2
package/README.md
CHANGED
|
@@ -30,6 +30,21 @@ Each repository keeps its own connection, so different sites can go to different
|
|
|
30
30
|
|
|
31
31
|
Prefer a key? Create one under **Settings → Integrations → API keys** and add `-e USERCASE_API_KEY=eb_sk_...` before `--`. A key you set this way is used first.
|
|
32
32
|
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
| Command | What it does |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `/mcp__usercase__research <url> [count]` | Sends virtual users to your site. [More](#run-a-research) |
|
|
38
|
+
| `/mcp__usercase__install` | Adds the Usercase script to your site. [More](#install-the-script) |
|
|
39
|
+
| `/mcp__usercase__new-research` | Plans a research from your data and code and saves it as a draft. [More](#planning-a-research) |
|
|
40
|
+
| `/mcp__usercase__path <research id>` | Records the route each task is meant to take. [More](#record-a-tasks-expected-path) |
|
|
41
|
+
| `/mcp__usercase__fix <research id> <finding>` | Fixes a finding from a research report, or an issue from Issues, in your code. [More](#fix-a-finding-from-a-report) |
|
|
42
|
+
| `/mcp__usercase__question` | Suggests a question for a precise moment and saves it as a draft. [More](#questions-for-a-precise-moment) |
|
|
43
|
+
| `/mcp__usercase__steps <window id>` | Writes the steps for a question you are making in the dashboard. [More](#questions-for-a-precise-moment) |
|
|
44
|
+
| `/mcp__usercase__connect` | Connects this repository to another project. [More](#connect) |
|
|
45
|
+
|
|
46
|
+
Wherever a command needs an id, the dashboard shows it ready to copy. You can also just ask Claude, for example "research my staging site with 10 virtual users".
|
|
47
|
+
|
|
33
48
|
## Install the script
|
|
34
49
|
|
|
35
50
|
The survey cards come from the Usercase script on your site. If it isn't there yet, let Claude Code add it:
|
|
@@ -67,6 +82,14 @@ The number is how many people visit in total (default 5). Above 5, Claude works
|
|
|
67
82
|
|
|
68
83
|
With `unlimited` it keeps going until you press Esc. Every visit uses your Claude usage.
|
|
69
84
|
|
|
85
|
+
### Focus on one part of the site
|
|
86
|
+
|
|
87
|
+
Name a part of the site, and Claude casts only people who come for it:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
/mcp__usercase__research https://staging.example.com 5 focus=checkout
|
|
91
|
+
```
|
|
92
|
+
|
|
70
93
|
### Take part in a research
|
|
71
94
|
|
|
72
95
|
Give a research id from your dashboard and the people take part in it instead of exploring freely — they accept its invitation and do its tasks from the study panel, the way invited participants do. The research must be live.
|
|
@@ -75,6 +98,8 @@ Give a research id from your dashboard and the people take part in it instead of
|
|
|
75
98
|
/mcp__usercase__research https://staging.example.com 10 research=<research id>
|
|
76
99
|
```
|
|
77
100
|
|
|
101
|
+
If the research is moderated by voice, the people read the moderator's captions and answer out loud, with a voice already on your computer (Windows voices, macOS `say` or `espeak`). No outside voice service is used.
|
|
102
|
+
|
|
78
103
|
When a visit ends, it is sent to your dashboard (**Visits**). If the upload fails, it is kept in `.usercase/outbox.jsonl` and sent with the next one.
|
|
79
104
|
|
|
80
105
|
### Sites behind a sign-in
|
|
@@ -119,6 +144,26 @@ Claude suggests the moment, the question and its answers, and saves it only afte
|
|
|
119
144
|
|
|
120
145
|
Claude asks whether you already know what you want to learn. If you don't, it reads your project's findings and where visitors left, and suggests two or three researches, saying which evidence comes from real visitors and which from virtual users. With no data yet, it offers to send a few virtual users first. It then reads your code to write the tasks, their success conditions (the page that means done, or the exact button) and the route each task is meant to take, and shows you all of it at once. On your approval it walks each route in a browser, checks it, and saves the research with the routes — as a draft in your Inbox, where you create it. The routes move into the research when you create it. A draft you discard, or don't create within 30 days, loses its recorded paths and their screenshots.
|
|
121
146
|
|
|
147
|
+
## Fix a finding from a report
|
|
148
|
+
|
|
149
|
+
A research's **Report** lists findings: problems people hit, each backed by what they did, what the screen showed and what they wrote. Each finding has a **Fix with your agent** command:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
/mcp__usercase__fix <research id> <finding, e.g. F1>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Add `real` for the report of real people; without it, the report of virtual users is used. Claude reads the finding and any earlier fixes of it, finds the code behind it, and tells you the change it proposes before touching anything. After you approve, it makes the change, runs your checks and records the fix on the finding. It doesn't commit, push or open a pull request unless you ask.
|
|
156
|
+
|
|
157
|
+
Once the change is deployed, mark it **Live on site** on the finding: the report compares people before and after the fix only from then on. To check it with virtual users, run the same tasks again with `research=<research id>`.
|
|
158
|
+
|
|
159
|
+
Issues from your questions' answers have the same button in **Issues**:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
/mcp__usercase__fix issue <issue id>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Claude reads the question, what people chose and wrote, and any other issues with the same cause, then works the same way. Once the change is deployed, mark the issue reviewed; the question keeps running, so later answers show whether people still hit it.
|
|
166
|
+
|
|
122
167
|
## What the visitors will and won't do
|
|
123
168
|
|
|
124
169
|
- They identify themselves on every request (see below), and their answers are counted separately from real visitors. This cannot be turned off.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Fix an issue found from your visitors' answers
|
|
2
|
+
|
|
3
|
+
Issue: {{issue}}
|
|
4
|
+
|
|
5
|
+
Usercase asks short questions on this site and groups the answers into issues — problems on the site, each backed by the question, what people chose and what they wrote. You fix one issue in this repository's code.
|
|
6
|
+
|
|
7
|
+
## 1. Read the issue
|
|
8
|
+
|
|
9
|
+
Call `issue_read` with the issue id. If no issue id is given above, call `issue_list` first, show the user the open issues (title, where, whether it is a clear fix or the cause is unclear, and whether someone is already fixing it), and ask which one to fix.
|
|
10
|
+
|
|
11
|
+
Read all of it: the question and the page it was asked on, what people chose, what they wrote, the AI's reading of it and the suggested next step. The suggestion was written without seeing the code; treat it as a direction, not a spec.
|
|
12
|
+
|
|
13
|
+
If the cause is marked unclear, say so to the user: the answers show the problem but not why. Propose the change only if the code makes the cause clear; otherwise suggest running a research on it instead ({{research_cmd}}).
|
|
14
|
+
|
|
15
|
+
If it lists other issues with the same cause, tell the user — one change may fix them together.
|
|
16
|
+
|
|
17
|
+
If it lists earlier fixes, read what each changed. Do not repeat an approach that is already in place; say how your change differs. If an earlier fix is probably not deployed yet, tell the user — they may only need to deploy it.
|
|
18
|
+
|
|
19
|
+
## 2. Find the code
|
|
20
|
+
|
|
21
|
+
From the page, the question and what people wrote, find the code behind it: routes, the page components, the elements people talk about, the text shown. Confirm you found the right place by matching what people mention (a section, a button, a label) to the code.
|
|
22
|
+
|
|
23
|
+
## 3. Propose the change — before changing anything
|
|
24
|
+
|
|
25
|
+
Tell the user, briefly:
|
|
26
|
+
- what in the code causes the problem, with file paths,
|
|
27
|
+
- the change you propose and why it addresses what people chose and wrote, and
|
|
28
|
+
- anything the issue suggests that you would not do, and why (it would break something, it needs a product decision, the code already does it).
|
|
29
|
+
|
|
30
|
+
Keep the change as small as solves the problem. Do not redesign unrelated parts. Wait for the user's approval or corrections.
|
|
31
|
+
|
|
32
|
+
## 4. Change the code
|
|
33
|
+
|
|
34
|
+
When the user approves, call `issue_fix` with `status` `fixing` and the files you will change. Then make the change. Follow the repository's own conventions, and run its checks (type check, lint, tests) if it has them.
|
|
35
|
+
|
|
36
|
+
## 5. Record the fix
|
|
37
|
+
|
|
38
|
+
When the change is done, call `issue_fix` with `status` `fixed`, the files changed, a one-sentence summary written for the team (what a visitor now sees differently), and the pull request link if you opened one. Do not commit, push or open a pull request unless the user asks.
|
|
39
|
+
|
|
40
|
+
Then tell the user that the issue shows as fixed in Issues, and that once the change is deployed they mark it reviewed there. The question keeps running, so later answers show whether people still hit it.
|
package/agents/fix.md
CHANGED
|
@@ -12,6 +12,8 @@ Call `report_finding` with the research id, the finding key and the report. If n
|
|
|
12
12
|
|
|
13
13
|
Read all of it: the title and why, who it hit, the screen checks (what the page really showed — these come from the recordings, not from opinions), the paths people took, the groups of what they wrote, and the suggested changes. The suggested changes were written without seeing the code; treat them as a direction, not a spec.
|
|
14
14
|
|
|
15
|
+
If it lists earlier fixes of this finding, read what each changed and how it went. Do not repeat an approach that made things worse or changed nothing; say how your change differs. If an earlier fix is not live yet or probably not deployed, tell the user — they may only need to deploy it.
|
|
16
|
+
|
|
15
17
|
## 2. Find the code
|
|
16
18
|
|
|
17
19
|
From the pages, buttons and texts in the evidence, find the code behind them: routes, the page components, the button and its click handler, the text shown. Confirm you found the right place by matching the exact text on screen (a button label, a heading) to the code.
|
|
@@ -31,6 +33,8 @@ When the user approves, call `report_fix` with `status` `fixing` and the files y
|
|
|
31
33
|
|
|
32
34
|
## 5. Record the fix
|
|
33
35
|
|
|
34
|
-
When the change is done, call `report_fix` with `status` `fixed`, the files changed, a one-sentence summary written for the team (what a visitor now sees differently), and the pull request link if you opened one. Do not commit, push or open a pull request unless the user asks.
|
|
36
|
+
When the change is done, call `report_fix` with `status` `fixed`, the files changed, a one-sentence summary written for the team (what a visitor now sees differently — the next report and the next fix read it), and the pull request link if you opened one. Do not commit, push or open a pull request unless the user asks.
|
|
37
|
+
|
|
38
|
+
The report compares people only after the fix is live on the site. Tell the user to mark it **Live on site** on the finding once it is deployed; when a new version of their site shows up in sessions, the report also asks whether this fix is in it.
|
|
35
39
|
|
|
36
40
|
Then tell the user that the finding shows as fixed in the report, and how to check it worked: {{check}}
|
package/mcp/hub.js
CHANGED
|
@@ -450,7 +450,12 @@ class Hub {
|
|
|
450
450
|
external,
|
|
451
451
|
study: research
|
|
452
452
|
? {
|
|
453
|
-
|
|
453
|
+
// 패널 옮기기는 막혔던 자리에서 비키게 끈다. 옮기고 나면 그 항목은 목록에서 내린다
|
|
454
|
+
press: async (k) => {
|
|
455
|
+
if (k !== "move") return study.press(page, k);
|
|
456
|
+
await study.movePanel(page, rec.panelInWay);
|
|
457
|
+
rec.panelInWay = null;
|
|
458
|
+
},
|
|
454
459
|
// 과제 질문·시간 초과 창이 페이지를 덮고 있나 — 그 사이 조작은 페이지에 닿지 않는다
|
|
455
460
|
blocking: async () => {
|
|
456
461
|
const w = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
|
|
@@ -505,6 +510,9 @@ class Hub {
|
|
|
505
510
|
if (look.ended) return "This visit has ended.";
|
|
506
511
|
if (look.card) return renderCard(look.card);
|
|
507
512
|
// 과제 건너뛰기는 위젯 단추다. 화면 목록 끝에 붙인다 — 누르면 Visit 이 위젯을 누른다
|
|
513
|
+
// 우리 패널이 누르려던 것을 덮었다 — 사람은 패널을 끌어 비킨다(11/04 실험실 covered_by_panel 3건: 캘린더 Week·Month)
|
|
514
|
+
if (st && rec.panelInWay && !look.view.dialog)
|
|
515
|
+
look.view.items.push({ i: look.view.items.length, label: "Drag the study panel aside (it is covering the page)", where: "study panel, by its top bar", study: "move" });
|
|
508
516
|
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" });
|
|
509
517
|
// 아직 건너뛸 수 없는 과제 — 예전엔 "패널 기다리기" 를 줘서 막히면 몇 분씩 아무것도 안 했다(라이브 11번 × 약 5분).
|
|
510
518
|
// 사람은 막히면 계속 찾아본다. 그러다 실패 시간이 지나면 SDK 가 묻는 창을 띄우고, 다음 화면에서 그 창이 먼저 보인다
|
|
@@ -713,9 +721,15 @@ class Hub {
|
|
|
713
721
|
}
|
|
714
722
|
if (p.kind === "timeout") {
|
|
715
723
|
const skip = decision.action === "choose" && decision.choiceIndex === 1;
|
|
716
|
-
|
|
724
|
+
// 눌리지 않았으면 그대로 알린다 — 전에는 실패를 넘기고 "골랐다"고 해서 같은 창에 갇혔다(11/05)
|
|
725
|
+
let moved = null;
|
|
726
|
+
try {
|
|
727
|
+
moved = await study.press(page, skip ? "timeoutSkip" : "keepGoing");
|
|
728
|
+
} catch (e) {
|
|
729
|
+
return [`You pressed "${skip ? "Skip" : "Continue"}", but it did not go through: ${String(e.message).split("\n")[0]}. The study is still asking.`, "", await this.screen(rec)].join("\n");
|
|
730
|
+
}
|
|
717
731
|
if (skip) st.skipped.push(p.task);
|
|
718
|
-
said = skip ? `You chose to skip the task "${p.task}".` : "You chose to keep trying.";
|
|
732
|
+
said = (skip ? `You chose to skip the task "${p.task}".` : "You chose to keep trying.") + (moved ? ` (${moved} was covering the study panel, so you dragged the panel aside first.)` : "");
|
|
719
733
|
} else {
|
|
720
734
|
const q = p.q;
|
|
721
735
|
const ans = decision.action === "choose" ? { index: decision.choiceIndex } : decision.action === "other" ? { text: decision.otherText } : {};
|
|
@@ -780,6 +794,7 @@ class Hub {
|
|
|
780
794
|
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) });
|
|
781
795
|
// 패널의 건너뛰기를 눌렀다 — 그 과제를 건너뛴 것으로 남긴다
|
|
782
796
|
if (r && r.study === "skip" && !r.skipped && rec.study) rec.study.skipped.push(rec.study.lastTask);
|
|
797
|
+
if (r && r.panelAt && rec.study) rec.panelInWay = r.panelAt;
|
|
783
798
|
if (r.interrupted && r.study) {
|
|
784
799
|
rec.cutOff = true;
|
|
785
800
|
return ["Before you could do that, the study asked you something.", "", await this.screen(rec)].join("\n");
|
package/mcp/path.js
CHANGED
|
@@ -91,7 +91,9 @@ function describeInPage(n, { sel: pickSel, lift = false }) {
|
|
|
91
91
|
} catch (e) {}
|
|
92
92
|
// SDK 는 id 를 이스케이프 없이 `#id` 로 쓴다 — 평범한 id 만(React useId 의 ":r1:" 같은 건 SDK 에서 오류가 난다)
|
|
93
93
|
const ids = pick.id && /^[A-Za-z][\w-]*$/.test(pick.id) && document.querySelectorAll("#" + pick.id).length === 1 ? [pick.id] : [];
|
|
94
|
-
|
|
94
|
+
// 입력칸에 친 값은 이름으로 안 쓴다(방문자 정보·비밀번호가 들어간다). submit·button 의 value 는 버튼 글자라 쓴다.
|
|
95
|
+
// 아래 path_check 의 label 과 같은 식이어야 한다 — 다르면 같은 요소를 "이름이 다르다"로 본다
|
|
96
|
+
const label = (pick.getAttribute("aria-label") || pick.innerText || (pick.tagName === "INPUT" && /^(submit|button|reset)$/i.test(pick.type) ? pick.value : "") || pick.getAttribute("title") || pick.getAttribute("placeholder") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
95
97
|
return { xpath: xp(pick), classes, ids, label, tag: pick.tagName.toLowerCase() };
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -366,7 +368,7 @@ class PathRecorder {
|
|
|
366
368
|
if (!ok) break;
|
|
367
369
|
const h = (await page.evaluateHandle(byXPathInPage, a.xpath)).asElement();
|
|
368
370
|
const visible = h ? await h.isVisible().catch(() => false) : false;
|
|
369
|
-
const label = h ? await h.evaluate((n) => (n.getAttribute("aria-label") || n.innerText || n.value || n.getAttribute("title") || "").replace(/\s+/g, " ").trim().slice(0, 80)) : "";
|
|
371
|
+
const label = h ? await h.evaluate((n) => (n.getAttribute("aria-label") || n.innerText || (n.tagName === "INPUT" && /^(submit|button|reset)$/i.test(n.type) ? n.value : "") || n.getAttribute("title") || n.getAttribute("placeholder") || "").replace(/\s+/g, " ").trim().slice(0, 80)) : "";
|
|
370
372
|
const no = a.step !== null ? `${a.step + 1}. ` : " ";
|
|
371
373
|
if (!h || !visible) {
|
|
372
374
|
lines.push(`✗ ${no}${a.do} "${a.label || a.text || ""}" — ${h ? "found but not visible" : "not found at its recorded position"} (${a.xpath})`);
|
package/mcp/report.js
CHANGED
|
@@ -297,6 +297,10 @@ const rq = (audience) => (audience === "real" ? "?audience=real" : "?audience=vi
|
|
|
297
297
|
const getReport = (researchId, audience) => keyed("GET", `/agent/researches/${encodeURIComponent(researchId)}/report${rq(audience)}`);
|
|
298
298
|
const getFinding = (researchId, key, audience) => keyed("GET", `/agent/researches/${encodeURIComponent(researchId)}/report/findings/${encodeURIComponent(key)}${rq(audience)}`);
|
|
299
299
|
const saveFix = (researchId, key, body) => keyed("PUT", `/agent/researches/${encodeURIComponent(researchId)}/report/findings/${encodeURIComponent(key)}/fix`, body);
|
|
300
|
+
/** Issues(fix issue) — 열린 문제 목록, 문제 하나와 근거, 고친 기록 */
|
|
301
|
+
const getIssues = () => keyed("GET", "/agent/issues");
|
|
302
|
+
const getIssue = (id) => keyed("GET", `/agent/issues/${encodeURIComponent(id)}`);
|
|
303
|
+
const saveIssueFix = (id, body) => keyed("PUT", `/agent/issues/${encodeURIComponent(id)}/fix`, body);
|
|
300
304
|
/** 대시보드 주소. API 주소에서 /koa 를 뗀다 */
|
|
301
305
|
const dashboardUrl = (p) => apiBase().replace(/\/koa$/, "") + p;
|
|
302
306
|
|
|
@@ -323,5 +327,8 @@ module.exports = {
|
|
|
323
327
|
getReport,
|
|
324
328
|
getFinding,
|
|
325
329
|
saveFix,
|
|
330
|
+
getIssues,
|
|
331
|
+
getIssue,
|
|
332
|
+
saveIssueFix,
|
|
326
333
|
dashboardUrl,
|
|
327
334
|
};
|
package/mcp/server.js
CHANGED
|
@@ -609,6 +609,15 @@ server.registerTool(
|
|
|
609
609
|
if (e.words.length) L.push("", "What people wrote (grouped by meaning, with examples):", ...e.words.map((w) => `- ${w.text}`));
|
|
610
610
|
if (e.numbers.length) L.push("", "Numbers:", ...e.numbers.map((x) => `- ${x}`));
|
|
611
611
|
L.push("", "Suggested changes (written without seeing the code):", ...f.changes.map((c, i) => `${i + 1}. ${c}`), `Check after the fix: ${f.measure}`);
|
|
612
|
+
// 지난 고침 — 무엇을 바꿨고 어땠나. 같은 방법을 되풀이하지 않게
|
|
613
|
+
const VERDICT = { better: "better", worse: "worse", mixed: "mixed", same: "no change", waiting: "not compared yet", not_deployed: "probably not deployed — sessions after it still ran the old version" };
|
|
614
|
+
if (r.history && r.history.length) {
|
|
615
|
+
L.push("", "Earlier fixes of this finding (each compared with the version right before it):");
|
|
616
|
+
for (const h of r.history) {
|
|
617
|
+
L.push(`- Fix ${h.attempt} by ${h.by}: ${h.summary || "(no note)"} — ${h.liveAt ? `live since ${String(h.liveAt).slice(0, 16).replace("T", " ")}` : "not live yet"}, ${VERDICT[h.verdict] || h.verdict}`);
|
|
618
|
+
for (const row of h.rows || []) L.push(` · ${row.label}: ${row.before[0]}/${row.before[1]} → ${row.after[0]}/${row.after[1]}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
612
621
|
if (r.sessionsUrl) L.push("", `Watch these sessions: ${r.sessionsUrl}`);
|
|
613
622
|
L.push(`Report: ${r.reportUrl}`);
|
|
614
623
|
return text(L.join("\n"));
|
|
@@ -619,7 +628,7 @@ server.registerTool(
|
|
|
619
628
|
"report_fix",
|
|
620
629
|
{
|
|
621
630
|
description:
|
|
622
|
-
"Record on the research report that you are fixing a finding (status fixing, once the user approved the change) or have fixed it (status fixed, with the files, a one-sentence summary for the team, and the pull request link if any). The report
|
|
631
|
+
"Record on the research report that you are fixing a finding (status fixing, once the user approved the change) or have fixed it (status fixed, with the files, a one-sentence summary for the team, and the pull request link if any). If the last fix of this finding is already live on the site, this starts the next fix (fix 2, 3 …); otherwise it adds to the current one. The report compares people after each fix goes live with the version right before it.",
|
|
623
632
|
inputSchema: {
|
|
624
633
|
research: z.string().describe("The research id"),
|
|
625
634
|
finding: z.string().describe("Finding key, e.g. F1"),
|
|
@@ -633,7 +642,86 @@ server.registerTool(
|
|
|
633
642
|
wrap(async ({ research, finding, audience, status, files, summary, prUrl }) => {
|
|
634
643
|
const agent = CODEX ? "Codex" : OTHER ? null : "Claude Code";
|
|
635
644
|
const r = await report.saveFix(research, finding, { audience: audience || "virtual", status, agent, files, summary, prUrl });
|
|
636
|
-
return text(`${r.key} is marked ${r.status} on the report: ${r.reportUrl}`);
|
|
645
|
+
return text(`${r.key} fix ${r.attempt} is marked ${r.status} on the report: ${r.reportUrl}`);
|
|
646
|
+
}),
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// ── Issues 의 문제 고치기(fix issue) ──
|
|
650
|
+
const issueFixLine = (f) => (f ? ` — ${f.status === "fixed" ? "fixed" : "being fixed"}${f.agent ? ` by ${f.agent}` : ""}` : "");
|
|
651
|
+
|
|
652
|
+
server.registerTool(
|
|
653
|
+
"issue_list",
|
|
654
|
+
{
|
|
655
|
+
description: "List the open issues of the Usercase project (problems found from visitors' answers to questions on the site): id, title, where, whether it is a clear fix, and whether one is being fixed. Use when the fix command has no issue id.",
|
|
656
|
+
inputSchema: {},
|
|
657
|
+
},
|
|
658
|
+
wrap(async () => {
|
|
659
|
+
const r = await report.getIssues();
|
|
660
|
+
if (!r.issues.length) return text("No open issues in this project.");
|
|
661
|
+
return text(
|
|
662
|
+
[
|
|
663
|
+
...r.issues.map(
|
|
664
|
+
(i) =>
|
|
665
|
+
`${i.id} · ${i.title} — ${i.page || "any page"}, ${i.answers} ${i.from === "virtual" ? "virtual users" : "answers"}, ${i.nextStep === "fix" ? "clear fix" : "cause unclear"}${i.status === "acknowledged" ? ", reviewed" : ""}${issueFixLine(i.fix)}`,
|
|
666
|
+
),
|
|
667
|
+
"",
|
|
668
|
+
`Issues: ${r.url}`,
|
|
669
|
+
].join("\n"),
|
|
670
|
+
);
|
|
671
|
+
}),
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
server.registerTool(
|
|
675
|
+
"issue_read",
|
|
676
|
+
{
|
|
677
|
+
description:
|
|
678
|
+
"Read one issue of the Usercase project with all its evidence: the question and page, what people chose, what they wrote, the AI's reading, other issues with the same cause, and earlier fixes. Read it before looking for the code.",
|
|
679
|
+
inputSchema: { issue: z.string().describe("The issue id, as in the dashboard command") },
|
|
680
|
+
},
|
|
681
|
+
wrap(async ({ issue }) => {
|
|
682
|
+
const r = await report.getIssue(String(issue).trim());
|
|
683
|
+
const i = r.issue;
|
|
684
|
+
const e = r.evidence;
|
|
685
|
+
const L = [
|
|
686
|
+
`${i.title}${issueFixLine(i.fix)}`,
|
|
687
|
+
`From ${i.answers} ${i.from === "virtual" ? "virtual users' answers" : "visitors' answers"} · ${i.nextStep === "fix" ? "clear fix" : "cause unclear — the answers show the problem, not why"}`,
|
|
688
|
+
"",
|
|
689
|
+
`Question: "${r.question.text}" (asked on ${r.question.page || "any page"})`,
|
|
690
|
+
"",
|
|
691
|
+
`What the answers show: ${i.observation}`,
|
|
692
|
+
`Likely why: ${i.hypothesis}`,
|
|
693
|
+
];
|
|
694
|
+
if (i.nextStepNote) L.push(`Suggested next step (written without seeing the code): ${i.nextStepNote}`);
|
|
695
|
+
if (e.distribution.length) L.push("", "What people chose:", ...e.distribution.map((d) => `- ${d.label}: ${d.n}${e.focus.includes(d.label) ? " ← this issue" : ""}`));
|
|
696
|
+
if (e.words.length) L.push("", "What people wrote:", ...e.words.map((w) => `- "${w.text}"${w.n > 1 ? ` (${w.n} people)` : ""}${w.page ? ` (on ${w.page}${w.device ? `, ${w.device}` : ""})` : ""}`));
|
|
697
|
+
if (e.pages.length) L.push("", `Pages: ${e.pages.map((p) => `${p.page} (${p.n})`).join(", ")}`);
|
|
698
|
+
if (r.cause) L.push("", `Same cause as other issues — "${r.cause.title}": ${r.cause.summary}`, ...r.cause.others.map((o) => `- ${o.id} · ${o.title}${o.page ? ` (${o.page})` : ""}`));
|
|
699
|
+
if (r.history.length) {
|
|
700
|
+
L.push("", "Earlier fixes of this issue:");
|
|
701
|
+
for (const h of r.history) L.push(`- Fix ${h.attempt} by ${h.by} (${h.status}): ${h.summary || "(no note)"}${h.files.length ? ` — ${h.files.join(", ")}` : ""}`);
|
|
702
|
+
}
|
|
703
|
+
L.push("", `Issue: ${r.url}`);
|
|
704
|
+
return text(L.join("\n"));
|
|
705
|
+
}),
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
server.registerTool(
|
|
709
|
+
"issue_fix",
|
|
710
|
+
{
|
|
711
|
+
description:
|
|
712
|
+
"Record on a Usercase issue that you are fixing it (status fixing, once the user approved the change) or have fixed it (status fixed, with the files, a one-sentence summary for the team, and the pull request link if any). After a finished fix, starting again records the next fix.",
|
|
713
|
+
inputSchema: {
|
|
714
|
+
issue: z.string().describe("The issue id"),
|
|
715
|
+
status: z.enum(["fixing", "fixed"]),
|
|
716
|
+
files: z.array(z.string()).optional().describe("Repository paths of the files changed (or to be changed)"),
|
|
717
|
+
summary: z.string().optional().describe("One sentence for the team: what visitors now see differently"),
|
|
718
|
+
prUrl: z.string().optional().describe("Pull request link, if one was opened"),
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
wrap(async ({ issue, status, files, summary, prUrl }) => {
|
|
722
|
+
const agent = CODEX ? "Codex" : OTHER ? null : "Claude Code";
|
|
723
|
+
const r = await report.saveIssueFix(String(issue).trim(), { status, agent, files, summary, prUrl });
|
|
724
|
+
return text(`"${r.title}" fix ${r.attempt} is marked ${r.status}: ${r.url}`);
|
|
637
725
|
}),
|
|
638
726
|
);
|
|
639
727
|
|
|
@@ -1018,6 +1106,14 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
1018
1106
|
*/
|
|
1019
1107
|
function byName(args = {}, schema = {}) {
|
|
1020
1108
|
const names = Object.keys(schema || {});
|
|
1109
|
+
// fix 의 `issue <id>` — 띄어쓰기로 research="issue", finding="<id>" 로 나뉘어 온다. 그대로 두면 아래에서 id 가 research 로 간다
|
|
1110
|
+
if (names.includes("issue")) {
|
|
1111
|
+
const vals = Object.values(args || {}).map((v) => (v == null ? "" : String(v).trim()));
|
|
1112
|
+
if (vals.some((v) => /^issue$/i.test(v))) {
|
|
1113
|
+
const id = vals.find((v) => UUID.test(v));
|
|
1114
|
+
return id ? { issue: id } : { research: "issue" };
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1021
1117
|
const out = {};
|
|
1022
1118
|
const loose = [];
|
|
1023
1119
|
for (const [k, raw] of Object.entries(args || {})) {
|
|
@@ -1130,10 +1226,18 @@ const COMMANDS = {
|
|
|
1130
1226
|
},
|
|
1131
1227
|
// 리서치 Report 의 발견 하나를 고친다 — 대시보드 Report 의 "Fix with your agent" 명령
|
|
1132
1228
|
fix: {
|
|
1133
|
-
description: "Fix a finding from a Usercase research report in this repository's code",
|
|
1134
|
-
argsSchema: {
|
|
1135
|
-
|
|
1136
|
-
|
|
1229
|
+
description: "Fix a finding from a Usercase research report, or an issue from Issues, in this repository's code",
|
|
1230
|
+
argsSchema: {
|
|
1231
|
+
research: z.string().optional(),
|
|
1232
|
+
finding: z.string().optional(),
|
|
1233
|
+
audience: z.string().optional(),
|
|
1234
|
+
issue: z.string().optional().describe("An issue id from the dashboard's Issues — fixes that issue instead of a report finding"),
|
|
1235
|
+
},
|
|
1236
|
+
body: ({ research, finding, audience, issue }) =>
|
|
1237
|
+
// Issues 의 문제 — `fix issue <id>`. 이름 없이 issue 만 오면 목록부터
|
|
1238
|
+
issue || String(research || "").trim().toLowerCase() === "issue"
|
|
1239
|
+
? agentDoc("fix-issue.md", { issue: String(issue || "").trim() || "(not given — list the open issues and ask)", research_cmd: cmd("research") })
|
|
1240
|
+
: agentDoc("fix.md", {
|
|
1137
1241
|
research: research || "(ask the user — the dashboard Report shows the command)",
|
|
1138
1242
|
finding: finding ? String(finding).toUpperCase() : "(not given — list the findings and ask)",
|
|
1139
1243
|
audience: audience === "real" ? "real (the report of real people)" : "virtual (the report of virtual users)",
|
|
@@ -1155,7 +1259,7 @@ if (PLUGIN || CODEX || OTHER) {
|
|
|
1155
1259
|
"instructions",
|
|
1156
1260
|
{
|
|
1157
1261
|
description:
|
|
1158
|
-
"The step-by-step instructions for a Usercase command (research, install, path, question, new-research, steps, connect, fix). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
|
|
1262
|
+
"The step-by-step instructions for a Usercase command (research, install, path, question, new-research, steps, connect, fix — a report finding, or an issue with `issue`). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
|
|
1159
1263
|
inputSchema: {
|
|
1160
1264
|
command: z.enum(Object.keys(COMMANDS)),
|
|
1161
1265
|
...COMMANDS.research.argsSchema,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.11-next.
|
|
3
|
+
"version": "0.2.11-next.4",
|
|
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/browser/hand.js
CHANGED
|
@@ -133,7 +133,8 @@ async function coveredBy(page, el) {
|
|
|
133
133
|
)
|
|
134
134
|
.catch(() => null);
|
|
135
135
|
if (!hit) return null;
|
|
136
|
-
|
|
136
|
+
// 패널은 끌어 옮길 수 있다(core Widget 알약 드래그). 막힌 자리를 같이 넘겨 hub 가 "패널 옮기기" 를 화면 목록에 붙인다
|
|
137
|
+
if (hit.own === "panel") return { say: "the study panel is on top of it — drag the panel aside (it is in the list), or scroll so it is not under the panel", panelAt: { x: cx, y: cy } };
|
|
137
138
|
if (hit.own) return { say: "the survey card is on top of it" };
|
|
138
139
|
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` };
|
|
139
140
|
return { invisible: true, x: cx, y: cy };
|
|
@@ -387,6 +388,7 @@ async function clickLikePerson(page, state, el) {
|
|
|
387
388
|
if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
|
|
388
389
|
const e = new Error(cover.say);
|
|
389
390
|
e.human = cover.say;
|
|
391
|
+
if (cover.panelAt) e.panelAt = cover.panelAt;
|
|
390
392
|
throw e;
|
|
391
393
|
}
|
|
392
394
|
// 화면 가장자리 밖으로 잘려 나간 것. 굴려도 옆으로는 안 온다 — 사람은 "잘려 있다" 를 본다.
|
package/src/browser/study.js
CHANGED
|
@@ -180,11 +180,114 @@ const PRESS = {
|
|
|
180
180
|
submit: "#eb-plugin #eb-survey-action-btn",
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* 리서치 패널을 끌어 비킨다 — 알약(#eb-pill)을 잡고 끌면 패널이 같이 움직인다(core components/widget/Widget.ts setupDrag).
|
|
185
|
+
* 막혔던 자리(away)의 반대쪽 가장자리로. 그래도 덮으면 위아래도 바꾼다. 고객이 끌기를 껐으면(draggable 0) 안 움직인다
|
|
186
|
+
*/
|
|
187
|
+
async function movePanel(page, away) {
|
|
188
|
+
const vp = page.viewportSize() || { width: 1280, height: 860 };
|
|
189
|
+
const pill = page.locator("#eb-plugin #eb-pill").first();
|
|
190
|
+
const covers = (pt) =>
|
|
191
|
+
pt
|
|
192
|
+
? page
|
|
193
|
+
.evaluate(([x, y]) => {
|
|
194
|
+
const h = document.elementFromPoint(x, y);
|
|
195
|
+
return !!(h && h.closest("#eb-plugin"));
|
|
196
|
+
}, [pt.x, pt.y])
|
|
197
|
+
.catch(() => false)
|
|
198
|
+
: Promise.resolve(false);
|
|
199
|
+
const drag = async (tx, ty) => {
|
|
200
|
+
const b = await pill.boundingBox();
|
|
201
|
+
if (!b) throw new Error("the study panel is not on the screen");
|
|
202
|
+
const sx = b.x + b.width / 2;
|
|
203
|
+
const sy = b.y + b.height / 2;
|
|
204
|
+
await page.mouse.move(sx, sy, { steps: 6 });
|
|
205
|
+
await sleep(120 + Math.random() * 200);
|
|
206
|
+
await page.mouse.down();
|
|
207
|
+
await page.mouse.move(sx + (tx - sx) * 0.5 + (Math.random() - 0.5) * 30, sy + (ty - sy) * 0.5 + (Math.random() - 0.5) * 30, { steps: 10 });
|
|
208
|
+
await page.mouse.move(tx, ty, { steps: 8 });
|
|
209
|
+
await sleep(80 + Math.random() * 150);
|
|
210
|
+
await page.mouse.up();
|
|
211
|
+
await sleep(300);
|
|
212
|
+
const a = await pill.boundingBox();
|
|
213
|
+
return a && Math.hypot(a.x - b.x, a.y - b.y) > 20;
|
|
214
|
+
};
|
|
215
|
+
const b = await pill.boundingBox();
|
|
216
|
+
if (!b) throw new Error("the study panel is not on the screen");
|
|
217
|
+
const half = b.width / 2;
|
|
218
|
+
const ax = away ? away.x : b.x + half;
|
|
219
|
+
const ay = away ? away.y : b.y;
|
|
220
|
+
// 옆으로 — 막힌 자리의 반대쪽 끝
|
|
221
|
+
const tx = ax < vp.width / 2 ? vp.width - half - 24 : half + 24;
|
|
222
|
+
if (!(await drag(tx, b.y + b.height / 2))) throw new Error("the panel did not move — it stays where it is");
|
|
223
|
+
if (!(await covers(away))) return;
|
|
224
|
+
// 패널이 길어 아직 덮는다 — 위아래도 반대쪽으로
|
|
225
|
+
const ty = ay < vp.height / 2 ? vp.height - b.height - 24 : 24 + b.height / 2;
|
|
226
|
+
await drag(tx, ty);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 패널 안 요소 위에 페이지의 다른 무언가가 올라와 있나 — 그 요소 한가운데 점의 맨 위 요소로 본다.
|
|
231
|
+
* 고객 사이트의 설문 팝업 · 채팅 · 쿠키 배너가 z-index 최대값으로 패널을 덮어 클릭을 가져갔다(11/05 bluetags Maze).
|
|
232
|
+
* 특정 위젯 이름을 정해 두지 않는다 — 덮은 것의 모양(태그 · 주소의 도메인 · 글 · 크기)으로 설명한다.
|
|
233
|
+
* @returns 안 덮였으면 null, 덮였으면 { at: {x, y} 덮인 점, what: 사람이 읽을 설명 }
|
|
234
|
+
*/
|
|
235
|
+
async function coveredBy(locator) {
|
|
236
|
+
return locator
|
|
237
|
+
.first()
|
|
238
|
+
.evaluate((el) => {
|
|
239
|
+
const r = el.getBoundingClientRect();
|
|
240
|
+
if (!r.width || !r.height) return null;
|
|
241
|
+
const x = r.x + r.width / 2;
|
|
242
|
+
const y = r.y + r.height / 2;
|
|
243
|
+
const top = document.elementFromPoint(x, y);
|
|
244
|
+
const host = document.getElementById("eb-plugin");
|
|
245
|
+
if (!top || top === host || (host && host.contains(top))) return null;
|
|
246
|
+
// 덮은 덩어리 — 위로 올라가 떠 있는(fixed · absolute) 상자까지
|
|
247
|
+
let box = top;
|
|
248
|
+
for (let i = 0; i < 8 && box.parentElement && box.parentElement !== document.body; i++) {
|
|
249
|
+
const st = getComputedStyle(box);
|
|
250
|
+
if (st.position === "fixed" || st.position === "absolute") break;
|
|
251
|
+
box = box.parentElement;
|
|
252
|
+
}
|
|
253
|
+
const br = box.getBoundingClientRect();
|
|
254
|
+
const tag = box.tagName.toLowerCase();
|
|
255
|
+
let from = "";
|
|
256
|
+
if (tag === "iframe") {
|
|
257
|
+
try {
|
|
258
|
+
from = new URL(box.src).host;
|
|
259
|
+
} catch (e) {}
|
|
260
|
+
}
|
|
261
|
+
const words = (tag === "iframe" ? box.getAttribute("title") || "" : box.innerText || "").replace(/\s+/g, " ").trim().slice(0, 70);
|
|
262
|
+
const kind = tag === "iframe" ? "an embedded frame" + (from ? " from " + from : "") : "a box";
|
|
263
|
+
const what = `${kind}${words ? ` showing "${words}"` : ""}, ${Math.round(br.width)}×${Math.round(br.height)} px`;
|
|
264
|
+
return { at: { x: Math.round(x), y: Math.round(y) }, what };
|
|
265
|
+
})
|
|
266
|
+
.catch(() => null);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 패널 안 무언가를 누른다. 다른 무언가가 덮고 있으면 패널을 그 반대쪽으로 끌어 비키고 다시 본다 — 사람이 하듯이.
|
|
271
|
+
* 그래도 덮이면 던진다(조용히 넘기지 않는다 — 넘기면 방문자는 눌렀다고 믿고 같은 창에 갇힌다).
|
|
272
|
+
* @returns 비켜서 눌렀으면 덮었던 것의 설명, 아니면 null
|
|
273
|
+
*/
|
|
274
|
+
async function clickInPanel(page, locator) {
|
|
275
|
+
const cover = await coveredBy(locator);
|
|
276
|
+
if (cover) {
|
|
277
|
+
await movePanel(page, cover.at).catch(() => {});
|
|
278
|
+
await sleep(300);
|
|
279
|
+
const still = await coveredBy(locator);
|
|
280
|
+
if (still) throw new Error(`something else on the page is on top of it — ${still.what} — so the click does not reach the study panel`);
|
|
281
|
+
}
|
|
282
|
+
await locator.first().click({ timeout: 5000 });
|
|
283
|
+
return cover ? cover.what : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 위젯 단추를 누른다. @returns 덮고 있던 것을 비켜서 눌렀으면 그 설명 */
|
|
184
287
|
async function press(page, key) {
|
|
185
288
|
const sel = PRESS[key];
|
|
186
289
|
if (!sel) throw new Error(`Unknown study button ${key}`);
|
|
187
|
-
|
|
290
|
+
return clickInPanel(page, page.locator(sel));
|
|
188
291
|
}
|
|
189
292
|
|
|
190
293
|
/**
|
|
@@ -196,11 +299,11 @@ async function answerQuestion(page, q, answer) {
|
|
|
196
299
|
const root = page.locator("#eb-plugin .eb-frost-survey");
|
|
197
300
|
if (Number.isInteger(answer.index) && answer.index >= 0) {
|
|
198
301
|
const sel = q.kind === "choice" ? ".eb-sq-choice:not(:has(input))" : q.kind === "scale" ? ".eb-sq-likert-btn" : ".eb-sq-yesno-btn";
|
|
199
|
-
await root.locator(sel).nth(answer.index)
|
|
302
|
+
await clickInPanel(page, root.locator(sel).nth(answer.index));
|
|
200
303
|
} else if (answer.text) {
|
|
201
304
|
const box = q.kind === "choice" ? root.locator("input.eb-sq-choice-other") : root.locator("textarea.eb-sq-textarea, input.eb-sq-input").first();
|
|
202
305
|
if (!(await box.count())) return "there is no box to type into for this question — pick one of the options";
|
|
203
|
-
await
|
|
306
|
+
await clickInPanel(page, box);
|
|
204
307
|
await box.fill(String(answer.text).slice(0, q.multiline ? 1000 : 200), { timeout: 5000 });
|
|
205
308
|
}
|
|
206
309
|
await sleep(400);
|
|
@@ -211,4 +314,4 @@ async function answerQuestion(page, q, answer) {
|
|
|
211
314
|
return null;
|
|
212
315
|
}
|
|
213
316
|
|
|
214
|
-
module.exports = { readStudy, waitState, join, press, answerQuestion, isSurveyUrl };
|
|
317
|
+
module.exports = { readStudy, waitState, join, press, movePanel, answerQuestion, isSurveyUrl, coveredBy };
|
package/src/run/visit.js
CHANGED
|
@@ -727,7 +727,7 @@ class Visit {
|
|
|
727
727
|
// 리서치 위젯의 단추(과제 건너뛰기). 페이지 조작이 아니라 위젯을 누른다 — 화면 목록에 hub 가 붙여 둔 것이다
|
|
728
728
|
if (decision.action === "click" && item && item.study && this.study) {
|
|
729
729
|
record.study = item.study;
|
|
730
|
-
await this.study.press(item.study).catch(() => (record.skipped = "it did not respond"));
|
|
730
|
+
await this.study.press(item.study).catch((e) => (record.skipped = item.study === "move" && e && e.message ? e.message : "it did not respond"));
|
|
731
731
|
await sleep(1500);
|
|
732
732
|
this.steps.push(record);
|
|
733
733
|
this.history.push(`click "${item.label}" — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}`);
|
|
@@ -892,7 +892,7 @@ class Visit {
|
|
|
892
892
|
let ours = false;
|
|
893
893
|
const clicked = await hand
|
|
894
894
|
.clickLikePerson(page, this.handState, el)
|
|
895
|
-
.catch((e) => ((record.skipped = e.human || humanClickError(e)), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
|
|
895
|
+
.catch((e) => ((record.skipped = e.human || humanClickError(e)), (record.panelAt = e.panelAt || undefined), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
|
|
896
896
|
// 사이트의 무언가가 덮어 못 눌렀다. 우리 카드·위젯이 덮은 것은 사이트 탓이 아니다
|
|
897
897
|
if (record.skipped && /on top of it/.test(record.skipped) && !/survey card|study panel/.test(record.skipped) && !ours)
|
|
898
898
|
await this.shoot(record, "covered", await boxOf(page, el));
|