@usercase/mcp 0.2.11 → 0.2.12

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.
@@ -2,7 +2,7 @@ A condition has a `page` (the screen the visitor is on when the card appears), `
2
2
 
3
3
  Step kinds (`do`): `arrive` · `back` · `click` · `input` · `submit` · `scroll` · `idle` · `hide` (tab hidden) · `error` (a JavaScript error).
4
4
  Options on a step: `page`, `from` (arrive/back), `target` (click/input/submit), `atLeast`, `percent` (scroll), `seconds` (idle: a pause between two actions), `cleared` (input: typed then deleted), `absent` (this did not happen between the previous step and the moment).
5
- Moment: `on_match` (as soon as the steps are met) · `idle` with `seconds` (then that long with no click, typing or page change; scrolling and reading still count as a pause) · `leave` (about to leave; on phones, a long pause instead).
5
+ Moment: `on_match` (as soon as the steps are met) · `idle` with `seconds` (then that long with no click, typing or page change; scrolling and reading still count as a pause) · `leave` (when they leave that screen: moving to another page, closing the tab on a computer, or coming back from another tab; phones cannot tell when a tab closes). When the card shows on the next screen it carries the line "About the previous page", so a leave question should not say "this page" — name what they were looking at. With an absent click step, moving to another page never matches, because moving is a click.
6
6
 
7
7
  Globs: `*` matches anything including `/`; `:name` matches one path segment (`/project/:slug`). Use the route parameter names from the code. Never put `?` or `#` in a glob.
8
8
 
package/agents/fix.md CHANGED
@@ -8,7 +8,7 @@ A Usercase research watched people (or virtual users) do tasks on this site. Its
8
8
 
9
9
  ## 1. Read the finding
10
10
 
11
- Call `report_finding` with the research id, the finding key and the report. If no finding key is given above, call `report_findings` first, show the user the list (key, severity, title, and whether someone is already fixing it), and ask which one to fix.
11
+ Call `report_finding` with the research (its id, or the name the user gave — if the server lists several or none, ask the user which one), the finding key and the report. If no finding key is given above, call `report_findings` first, show the user the list (key, severity, title, and whether someone is already fixing it), and ask which one to fix.
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
 
@@ -40,6 +40,10 @@ Let the user pick one or change it.
40
40
 
41
41
  One research is **one flow a participant can walk in one sitting**: 1–4 tasks, about 5–10 minutes in all.
42
42
 
43
+ **Purpose and hypotheses**
44
+ - `purpose` is read by participants on the invite card and at the start. One or two sentences in their words on what they will help with. It never carries the evidence, the problem you suspect, or anything that hints at an answer.
45
+ - `hypotheses` are for the team only. Write what you expect to go wrong, one per item, usually 1–3, each grounded in the evidence. Each must be something the tasks can confirm or rule out; if no task would show it, change the tasks or drop it. The report judges each one, and they lock once the first real person takes part.
46
+
43
47
  **Tasks**
44
48
  - The title is a goal in the participant's words: what they want to get done, not how. Never name a button, a menu, a page or a route — that hands them the answer.
45
49
  - Every task has `info_text`: one or two sentences, in the participant's words, on the situation they are in and why they would do this. It follows on from the previous task. Like the title, it never names a button, a menu, a page or a route.
@@ -75,7 +79,7 @@ The results tell who went straight from who took a detour only when a task has a
75
79
 
76
80
  ## 7. Show the user everything at once
77
81
 
78
- Show the whole research compactly: the purpose and its evidence, each task with how it counts as done (in plain words, no XPath) and its route in one line, the questions, the invite, and the minutes. Say that on their approval you will walk each route in a browser and save the draft with the routes. Change whatever they ask for.
82
+ Show the whole research compactly: the evidence, the hypotheses, the purpose participants will read, each task with how it counts as done (in plain words, no XPath) and its route in one line, the questions, the invite, and the minutes. Say that on their approval you will walk each route in a browser and save the draft with the routes. Change whatever they ask for.
79
83
 
80
84
  ## 8. On approval: record the routes, then save
81
85
 
package/agents/path.md CHANGED
@@ -9,7 +9,7 @@ You record that path by walking it yourself in a browser. Unlike the virtual vis
9
9
 
10
10
  ## 1. See the tasks
11
11
 
12
- Call `path_tasks` with the research id. For each task you get what the participant is told, how it counts as done, and whether a path exists. If a task is named above, record only that one, even if it already has a path. Otherwise record paths for the tasks that have none, and if every task already has one, ask the user which to redo.
12
+ Call `path_tasks` with the research — its id, or the name the user gave (the server finds it by name; if it lists several or none, ask the user which one). For each task you get what the participant is told, how it counts as done, and whether a path exists. If a task is named above, record only that one, even if it already has a path. Otherwise record paths for the tasks that have none, and if every task already has one, ask the user which to redo.
13
13
 
14
14
  ## 2. Work out the intended route — before opening the browser
15
15
 
@@ -0,0 +1,8 @@
1
+ ## What you send to Usercase
2
+
3
+ Everything you put in a Usercase tool's arguments is sent to the Usercase server and shown in the user's dashboard. You read this repository's code; Usercase gets only what you write into those arguments.
4
+
5
+ - Never put a secret into a Usercase tool argument: no API keys, tokens, passwords, private keys, connection strings, cookies or session values, and no values from `.env` files, config files holding credentials, or environment variables. This holds even when the user asks for it.
6
+ - Do not open `.env` files or other files that hold credentials for Usercase work. You never need them: describe what the site does, not how it is configured.
7
+ - Do not paste source code into arguments. Name the file, component or route and say in plain words what it does or what you changed.
8
+ - If something you were about to send turns out to contain a secret, leave that part out and carry on.
package/agents/steps.md CHANGED
@@ -31,7 +31,7 @@ Use their word for the card: a question when it is a question, an invite when it
31
31
  Make sure the moment fits the card:
32
32
 
33
33
  - **A question** appears when the visitor can answer it, right where the thing it asks about happened.
34
- - **A research invite** asks for a few minutes of someone's time. It should reach the people the research is about — visitors who just went through the part of the site it covers — at a moment when they are free to say yes: right after they finished the action the research is about (`on_match` on that step), or as they are about to leave (`leave`). Do not wait for a long pause after the action; people keep scrolling and moving on, so such an invite rarely appears. Never in the middle of typing, paying or signing in.
34
+ - **A research invite** asks for a few minutes of someone's time. It should reach the people the research is about — visitors who just went through the part of the site it covers — at a moment when they are free to say yes: right after they finished the action the research is about (`on_match` on that step), or as they leave that screen (`leave`). Do not wait for a long pause after the action; people keep scrolling and moving on, so such an invite rarely appears. Never in the middle of typing, paying or signing in.
35
35
 
36
36
  When a research invite fires at the same moment as a live question, that is expected, not a conflict. A visitor sees only one card at a time and the question comes first; anyone who answers it is offered the research right after, in the same card, and the invite card reaches the visitors the question no longer reaches. Mention the overlap as information only. Never suggest turning a question off to make room for an invite.
37
37
 
package/agents/study.md CHANGED
@@ -15,7 +15,7 @@ Show the user the tasks in one short list before casting, so they know what the
15
15
  **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.
16
16
  - 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.
17
17
  - 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.
18
- - Pass `research: "{{research}}"` to `save_people`. Only people saved with it take part.
18
+ - Pass `research` to `save_people` as the research **id** `path_tasks` showed (not a name). Only people saved with it take part.
19
19
  - Leave out `feature` spreading over the whole feature map — the research decides what the visit is about.
20
20
 
21
21
  **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.
package/mcp/hub.js CHANGED
@@ -14,8 +14,9 @@ const persona = require("../src/persona/persona.js");
14
14
  const { personKey } = require("../src/persona/people.js");
15
15
  const entry = require("../src/run/entry.js");
16
16
  const { Visit, renderScreen, renderPerson } = require("../src/run/visit.js");
17
- const { iconPicture } = require("../src/browser/page.js");
18
- const { createGuard } = require("../src/browser/guard.js");
17
+ const { iconPicture, screenPicture } = require("../src/browser/page.js");
18
+ const crypto = require("crypto");
19
+ const { createGuard, isSameSite } = require("../src/browser/guard.js");
19
20
  const policyMod = require("../src/browser/policy.js");
20
21
  const { loadRobots } = require("../src/browser/robots.js");
21
22
  const { launchBrowser, openVisit } = require("../src/browser/open.js");
@@ -63,6 +64,59 @@ const AGENT = "qa-explore";
63
64
  const MAX_OPEN = 5;
64
65
  /** 서브에이전트가 죽으면 방문이 열린 채 남는다. 이만큼 조용하면 닫는다 */
65
66
  const IDLE_MS = 10 * 60 * 1000;
67
+ // 페이지를 새로 불러온 뒤 SDK 를 기다리는 시간. 스니펫은 load 뒤에 SDK 를 부른다
68
+ const SDK_PAGE_WAIT_MS = 8000;
69
+
70
+ /**
71
+ * 이 페이지에 Usercase SDK 가 있나 — 페이지마다 본다. waitForSdk(card.js)의 sessionStorage 는 같은 사이트의
72
+ * 앞 페이지에서 남아 있어 SDK 없는 페이지에서도 "있다" 가 된다. 스니펫이 세우는 표시(UsercaseInitialized,
73
+ * 이름 바꾸기 전 EarningbirdsInitialized)나 SDK 가 띄운 패널을 본다
74
+ */
75
+ // 새 페이지에서 리서치 패널이 다시 뜨기를 기다리는 시간(같은 세션으로 다시 붙는 데 보통 1~2초)
76
+ const STUDY_PANEL_WAIT_MS = 10000;
77
+ /** 리서치 패널이 진행 중 상태로 뜨나 */
78
+ async function waitStudyActive(page, ms) {
79
+ for (let t = 0; ; t += 400) {
80
+ const s = await study.readStudy(page).catch(() => null);
81
+ if (s && STUDY_ACTIVE.has(s.state)) return true;
82
+ if (t >= ms) return false;
83
+ await sleep(400);
84
+ }
85
+ }
86
+
87
+ async function pageHasSdk(page, ms = SDK_PAGE_WAIT_MS) {
88
+ for (let t = 0; ; t += 300) {
89
+ const ok = await page
90
+ .evaluate(() => !!(window.UsercaseInitialized || window.EarningbirdsInitialized || document.getElementById("eb-plugin")))
91
+ .catch(() => null);
92
+ if (ok) return true;
93
+ if (t >= ms) return ok === null ? null : false;
94
+ await sleep(300);
95
+ }
96
+ }
97
+ /** SDK 가 없던(missing) · 있는데 서버가 막은(not_connected) 페이지 주소를 방문에 남긴다. 쿼리 · 해시는 뺀다 */
98
+ function noSdkPage(rec, kind) {
99
+ let url;
100
+ try {
101
+ const u = new URL(rec.page.url());
102
+ if (!/^https?:$/.test(u.protocol)) return;
103
+ url = u.origin + u.pathname;
104
+ } catch {
105
+ return;
106
+ }
107
+ rec.noSdkPages = rec.noSdkPages || [];
108
+ if (rec.noSdkPages.length < 20 && !rec.noSdkPages.some((p) => p.url === url)) rec.noSdkPages.push({ url, kind });
109
+ }
110
+ // 진행 중인 방문 — 걸음마다 디스크에 적는다. MCP 가 강제로 꺼져도(세션 종료) 다음에 켜질 때 끝나지 않은 방문으로 올린다
111
+ const OPEN_DIR = require("path").join(ws.DIR, "open-visits");
112
+ const pidAlive = (pid) => {
113
+ try {
114
+ process.kill(pid, 0);
115
+ return true;
116
+ } catch (e) {
117
+ return e.code === "EPERM";
118
+ }
119
+ };
66
120
 
67
121
  /**
68
122
  * 쓰기를 허용할 경로(정규식, 쉼표로). 로그인 뒤 화면은 조회도 POST 로 하는 사이트가 많다(GraphQL 등).
@@ -104,6 +158,52 @@ class Hub {
104
158
  // 시작할 때 비밀값을 한 번 검사한다 — 잘못된 값이 방문 도중 조용히 넘어가지 않게
105
159
  this.keySet = !!agentKey();
106
160
  setInterval(() => this.sweep(), 60 * 1000).unref();
161
+ this.recoverOpen();
162
+ }
163
+
164
+ journalPath(rec) {
165
+ return require("path").join(OPEN_DIR, `${process.pid}-${rec.startedAt}-${rec.id}.json`);
166
+ }
167
+
168
+ /** 이 방문이 지금까지 한 것을 디스크에 적는다. 끝난 방문이면 지운다 */
169
+ journal(id) {
170
+ const rec = this.visits.get(id);
171
+ if (!rec) return;
172
+ try {
173
+ const file = this.journalPath(rec);
174
+ if (rec.closed) {
175
+ if (fs.existsSync(file)) fs.unlinkSync(file);
176
+ return;
177
+ }
178
+ fs.mkdirSync(OPEN_DIR, { recursive: true });
179
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, row: this.visitRow(rec, { abandoned: true }), person: rec.person, startedAt: rec.startedAt }));
180
+ } catch {}
181
+ }
182
+
183
+ /** 지난번 MCP 가 끝내지 못한 방문(그 프로세스가 이미 없다) — 로컬 기록과 outbox 에 넣는다. 다음 방문이 끝날 때 대시보드로 간다 */
184
+ recoverOpen() {
185
+ let n = 0;
186
+ try {
187
+ if (!fs.existsSync(OPEN_DIR)) return;
188
+ for (const name of fs.readdirSync(OPEN_DIR)) {
189
+ const file = require("path").join(OPEN_DIR, name);
190
+ try {
191
+ const j = JSON.parse(fs.readFileSync(file, "utf8"));
192
+ if (j.pid && j.pid !== process.pid && pidAlive(j.pid)) continue;
193
+ ws.appendRun(j.row);
194
+ report.queueVisit(j.row, j.person, j.startedAt);
195
+ n++;
196
+ } catch {}
197
+ try {
198
+ fs.unlinkSync(file);
199
+ } catch {}
200
+ }
201
+ } catch {}
202
+ if (n) {
203
+ process.stderr.write(`Recovered ${n} visit(s) the last session did not finish — kept in the outbox for the dashboard
204
+ `);
205
+ setTimeout(() => report.flushOutbox().catch(() => {}), 0);
206
+ }
107
207
  }
108
208
 
109
209
  browser() {
@@ -132,7 +232,7 @@ class Hub {
132
232
  arm(s) {
133
233
  s.policy = policyNow();
134
234
  // robots 는 나중에 읽는다(네트워크). 함수로 넘겨 읽히는 대로 반영되게
135
- s.guard = createGuard({ site: s.origin, inject: this.inject, allowWritePaths: ALLOW_WRITE_PATHS, policy: s.policy, robots: () => s.robots });
235
+ s.guard = createGuard({ site: s.origin, inject: this.inject, allowWritePaths: ALLOW_WRITE_PATHS, policy: s.policy, robots: () => s.robots, robotsFor: (o) => this.robotsFor(s, o) });
136
236
  }
137
237
 
138
238
  /** 사이트에 쓰는 것을 보낼지. 사용자가 고른다 */
@@ -158,6 +258,23 @@ class Hub {
158
258
  return s.robots;
159
259
  }
160
260
 
261
+ /** 하위 도메인의 robots.txt — 처음 갈 때 한 번 읽는다. 사이트 것과 같은 머리로(자기를 밝힌다) */
262
+ robotsFor(s, origin) {
263
+ if (!s.subRobots) s.subRobots = new Map();
264
+ if (!s.subRobots.has(origin)) {
265
+ const key = agentKey();
266
+ const headers = { [AGENT_HEADER]: AGENT, "User-Agent": uaWith(AGENT, "Mozilla/5.0 (compatible)"), ...(key ? { [KEY_HEADER]: key } : {}) };
267
+ s.subRobots.set(
268
+ origin,
269
+ loadRobots(origin, { headers }).then((rb) => {
270
+ process.stderr.write(`${origin} ${rb.describe()}\n`);
271
+ return rb;
272
+ }),
273
+ );
274
+ }
275
+ return s.subRobots.get(origin);
276
+ }
277
+
161
278
  async context(s, opts = {}) {
162
279
  return openVisit(await this.browser(), playwright.devices, { agent: AGENT, site: s.origin, guard: s.guard, ...opts });
163
280
  }
@@ -290,6 +407,9 @@ class Hub {
290
407
 
291
408
  /** 가상 사용자 한 명의 방문을 연다. 그 사람이 누구인지와 첫 화면을 돌려준다 */
292
409
  async open(who) {
410
+ // 방문 기록이 요금제로 막혔다 — 브라우저를 더 띄우지 않는다(report.js)
411
+ const plan = report.planBlocked("visits");
412
+ if (plan) return { stop: true, text: report.planStopText(plan) };
293
413
  const doc = ws.loadPeople();
294
414
  if (!doc.site) throw new Error("people.json has no site. Create people with save_people first.");
295
415
  const p = doc.people.find((x) => x.id === who);
@@ -368,10 +488,8 @@ class Hub {
368
488
  }
369
489
  return;
370
490
  }
371
- let same = false;
372
- try {
373
- same = new URL(u).origin === s.origin;
374
- } catch {}
491
+ // 같은 사이트 — www · 하위 도메인까지(guard 와 같은 기준). 전에는 origin 이 똑같아야 해서 사이트 주소가 bluetags.app 이면 www 새 탭도 "다른 사이트" 였다
492
+ const same = isSameSite(s.origin, u);
375
493
  if (!same) return void offsite.push(u);
376
494
  // 같은 사이트를 새 탭으로 여는 링크. 사람은 열린 탭에서 이어 본다 — 닫기만 하면 "눌러도 안 열린다" 가 된다
377
495
  // (10/14 0.1.9 bluetags 프로젝트 카드 "See more" 가 target=_blank). 탭을 하나로 두려고 보던 탭을 그 주소로 옮긴다
@@ -408,8 +526,10 @@ class Hub {
408
526
  }
409
527
  const how = entry.chooseEntry(landingOff ? { ...p, landing: undefined } : p, links);
410
528
  if (landingOff) process.stderr.write(`${p.id}: landing ${p.landing} is off limits (${landingOff}) — entering elsewhere\n`);
411
- const rec = { id, person: p, site: s, ctx, page, how, startedAt: Date.now(), touched: Date.now(), closed: false, visit: null };
529
+ const rec = { id, person: p, site: s, ctx, page, how, startedAt: Date.now(), touched: Date.now(), closed: false, visit: null, loads: 0, noSdk: [] };
412
530
  this.visits.set(id, rec);
531
+ // 페이지를 새로 불러올 때마다 센다 — 그 뒤에 SDK 가 그 페이지에도 있는지 본다(sdkGone)
532
+ page.on("load", () => rec.loads++);
413
533
 
414
534
  await page.goto(s.origin + how.path, { waitUntil: "domcontentloaded", referer: how.referer || undefined, timeout: 60000 }).catch(() => {});
415
535
  // 화면이 보여도 사이트 코드가 아직 안 붙었으면 버튼이 안 먹는다. 바로 상세·달력으로 들어오면 첫 클릭이 그렇게 사라졌다
@@ -442,6 +562,7 @@ class Hub {
442
562
  }
443
563
  rec.account = account ? account.account : null;
444
564
  rec.visit = new Visit(page, p, {
565
+ siteOrigin: s.origin,
445
566
  rhythm,
446
567
  shape,
447
568
  policy: s.policy,
@@ -495,8 +616,14 @@ class Hub {
495
616
  lines.push(
496
617
  "This study has a voice: you allowed your microphone, and a voice may talk to you during the study (you see its words as a caption). When it asks you something, you answer out loud — with answer_card.",
497
618
  );
498
- 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)");
619
+ if (!sdk.ready) {
620
+ lines.push("(note for whoever runs this: the Usercase SDK did not start on this page — this visit may not be recorded)");
621
+ rec.noSdk.push(pageName(rec));
622
+ const has = await pageHasSdk(page, 0);
623
+ if (has !== null) noSdkPage(rec, has ? "not_connected" : "missing");
624
+ }
499
625
  lines.push("", await this.screen(rec));
626
+ this.journal(id);
500
627
  return { id, text: lines.join("\n") };
501
628
  }
502
629
 
@@ -518,7 +645,7 @@ class Hub {
518
645
  // 사람은 막히면 계속 찾아본다. 그러다 실패 시간이 지나면 SDK 가 묻는 창을 띄우고, 다음 화면에서 그 창이 먼저 보인다
519
646
  // 브라우저 창(confirm 등)이 떠 있다. 페이지는 멈춰 있다 — 아이콘 그림·위치는 안 본다
520
647
  if (look.view.dialog) {
521
- this.pictures.set(rec.id, null);
648
+ this.pictures.set(rec.id, []);
522
649
  return renderScreen(look);
523
650
  }
524
651
  const lines = [];
@@ -537,8 +664,23 @@ class Hub {
537
664
  lines.push(renderScreen(look));
538
665
  if (st && st.panel) lines.push("", st.panel);
539
666
  // 이름 없는 아이콘은 그림으로 같이 준다 — 좌표만으로는 사진 첨부와 보내기를 못 갈랐다(10/21 bluetags 2b72)
540
- const pic = await iconPicture(v.page, look.view.items, await this.browser().catch(() => null)).catch(() => null);
541
- this.pictures.set(rec.id, pic);
667
+ const browser = await this.browser().catch(() => null);
668
+ const pics = [];
669
+ // 화면 전체 그림. 글자 목록만으로는 사진·색·배치를 못 봐 "그림이 없다"·"색이 안 적혀 있다" 를 지어내고
670
+ // 생김새로 고르는 사람은 포기했다. 그림을 주니 사람처럼 골랐고 깨진 상품 이미지도 찾았다 — 걸음당 비용 +20%쯤(11/11 bluetags·데모 쇼핑몰 48방문).
671
+ // 지난번에 준 그림과 똑같으면 다시 안 준다(눌렀는데 그대로인 화면). 걸음마다 쌓여 다시 읽히는 몫이 크다
672
+ const shot = await screenPicture(v.page, browser).catch(() => null);
673
+ const sum = shot && crypto.createHash("sha1").update(shot).digest("hex");
674
+ if (shot && sum !== rec.lastShot) {
675
+ rec.lastShot = sum;
676
+ pics.push({ data: shot, mime: "image/jpeg" });
677
+ lines.push("(A small picture of the screen as you see it is attached. Use it to see the images, the layout and what stands out — the list above is still how you click.)");
678
+ } else if (shot) {
679
+ lines.push("(The screen looks the same as in your last picture.)");
680
+ }
681
+ const pic = await iconPicture(v.page, look.view.items, browser).catch(() => null);
682
+ if (pic) pics.push({ data: pic, mime: "image/png" });
683
+ this.pictures.set(rec.id, pics);
542
684
  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.)");
543
685
  // 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
544
686
  // 리서치 참여자는 과제를 하러 왔다 — 둘러보다 지치는 인내심 표시는 맞지 않는다
@@ -755,10 +897,10 @@ class Hub {
755
897
  }
756
898
 
757
899
  /** 마지막 화면에 붙일 아이콘 그림. 한 번 가져가면 지운다(다음 화면은 새로 만든다) */
758
- takePicture(id) {
759
- const pic = this.pictures.get(id) || null;
900
+ takePictures(id) {
901
+ const pics = this.pictures.get(id) || [];
760
902
  this.pictures.delete(id);
761
- return pic;
903
+ return pics;
762
904
  }
763
905
 
764
906
  get(id) {
@@ -771,6 +913,14 @@ class Hub {
771
913
 
772
914
  /** 한 걸음. 끝나면 기록하고 닫는다 */
773
915
  async act(id, decision) {
916
+ try {
917
+ return await this.step(id, decision);
918
+ } finally {
919
+ this.journal(id);
920
+ }
921
+ }
922
+
923
+ async step(id, decision) {
774
924
  const rec = this.get(id);
775
925
  const v = rec.visit;
776
926
  // 리서치 질문이 페이지를 덮고 있다 — 먼저 답해야 한다
@@ -790,7 +940,13 @@ class Hub {
790
940
  rec.lastAction = decision.action;
791
941
  const a0 = Date.now();
792
942
  v.lastWaitMs = 0;
943
+ const loadsBefore = rec.loads;
793
944
  const r = await v.act(decision);
945
+ // 새 페이지로 왔다 — 거기에도 SDK 가 있나. 리서치 참여 중인데 없으면 방문을 멈추고 알린다
946
+ if (rec.loads !== loadsBefore && !v.ended) {
947
+ const gone = await this.sdkGone(rec);
948
+ if (gone) return gone;
949
+ }
794
950
  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) });
795
951
  // 패널의 건너뛰기를 눌렀다 — 그 과제를 건너뛴 것으로 남긴다
796
952
  if (r && r.study === "skip" && !r.skipped && rec.study) rec.study.skipped.push(rec.study.lastTask);
@@ -837,8 +993,49 @@ class Hub {
837
993
  return [...said, ...(said.length ? [""] : []), shown].join("\n");
838
994
  }
839
995
 
996
+ /**
997
+ * 새로 불러온 페이지에 SDK 가 없다 — 그 페이지를 적는다. 리서치에 참여 중이면 거기서는 녹화도 과제 패널도 없어
998
+ * 더 다녀도 쓸 데이터가 안 나온다 → 방문을 멈추고 코디네이터가 사용자에게 알리게 한다. 리서치가 아니면 적기만 한다
999
+ * (걸음은 MCP 가 기록한다). @returns 방문자에게 줄 글(멈췄을 때), 아니면 null
1000
+ */
1001
+ async sdkGone(rec) {
1002
+ const url = rec.page.url();
1003
+ // 끝난 뒤 설문은 우리 설문 사이트 — SDK 가 없는 게 맞다. 시작 주소만이 아니라 그 사이트 전체를 뺀다
1004
+ if (!/^https?:/.test(url) || study.isSurveyUrl(url) || /^https?:\/\/survey\.(usercase\.io|localhost)(:\d+)?\//i.test(url)) return null;
1005
+ const has = await pageHasSdk(rec.page);
1006
+ if (has === null) return null; // 페이지를 못 읽었다(이동 중) — 모르면 멈추지 않는다
1007
+ const inStudy = !!rec.study && !rec.study.over;
1008
+ // SDK 는 있는데 리서치 패널이 안 뜬다 — 서버가 이 도메인을 거절했다(프로젝트 도메인 설정에 없는 하위 도메인, 4003).
1009
+ // 서버는 이유 없이 연결만 닫아서 페이지에서는 알 수 없다. 패널이 안 뜨면 studyLook 이 "과제가 다 끝났다" 로 잘못 읽었다(11/10 about.bluetags.app)
1010
+ const notConnected = has === true && inStudy && !(await waitStudyActive(rec.page, STUDY_PANEL_WAIT_MS));
1011
+ if (has === true && !notConnected) return null;
1012
+ const where = pageName(rec);
1013
+ const label = notConnected ? `${where} (the SDK is there but did not connect — is this domain allowed in the project's settings?)` : where;
1014
+ if (!rec.noSdk.includes(label)) rec.noSdk.push(label);
1015
+ noSdkPage(rec, notConnected ? "not_connected" : "missing");
1016
+ if (!inStudy) return null;
1017
+ const sum = await this.finish(rec, { stopped: notConnected ? "sdk_not_connected" : "no_sdk" });
1018
+ return [
1019
+ notConnected
1020
+ ? `The Usercase SDK on this page (${where}) did not connect to the study — the study panel did not come back, so nothing is recorded here. This domain is probably not allowed in the project's settings. The visit was stopped.`
1021
+ : `The Usercase SDK is not on this page (${where}), so the study cannot go on here — nothing is recorded and the study panel is gone. The visit was stopped.`,
1022
+ "",
1023
+ sum,
1024
+ "",
1025
+ "→ Reply to the coordinator with exactly the first line above, and stop.",
1026
+ ].join("\n");
1027
+ }
1028
+
840
1029
  /** 카드에 답하거나 닫는다 */
841
1030
  async answer(id, decision) {
1031
+ try {
1032
+ return await this.answerCard(id, decision);
1033
+ } finally {
1034
+ this.journal(id);
1035
+ }
1036
+ }
1037
+
1038
+ async answerCard(id, decision) {
842
1039
  const rec = this.get(id);
843
1040
  if (rec.studyPending) return this.studyAnswer(rec, decision);
844
1041
  const v = rec.visit;
@@ -854,10 +1051,8 @@ class Hub {
854
1051
  return [done, "", await this.screen(rec)].join("\n");
855
1052
  }
856
1053
 
857
- /** 방문을 닫고 기록을 남긴다. 사람 파일에 쓴 날을 적는다 */
858
- async finish(rec, extra = {}) {
859
- if (rec.closed) return "";
860
- rec.closed = true;
1054
+ /** 방문 기록 한 줄(runs · 대시보드) */
1055
+ visitRow(rec, extra = {}) {
861
1056
  const res = rec.visit ? rec.visit.result() : { steps: [], card: null, shape: null };
862
1057
  const row = {
863
1058
  at: new Date().toISOString(),
@@ -872,6 +1067,10 @@ class Hub {
872
1067
  card: res.card,
873
1068
  ms: Date.now() - rec.startedAt,
874
1069
  sdkReady: rec.visit ? !!rec.visit.sdk?.ready : null,
1070
+ // SDK 가 없던 페이지 — 거기서는 녹화도 리서치 패널도 없다. results 가 사용자에게 알리게 한다
1071
+ ...(rec.noSdk && rec.noSdk.length ? { noSdk: rec.noSdk } : {}),
1072
+ // 같은 것을 주소로 — 서버가 대시보드 Connection 체크리스트("스크립트 없음")에 쓴다
1073
+ ...(rec.noSdkPages && rec.noSdkPages.length ? { noSdkPages: rec.noSdkPages } : {}),
875
1074
  ...(rec.account ? { account: rec.account } : {}),
876
1075
  // 리서치 참여 — 어느 과제까지 갔고, 무엇을 건너뛰고, 과제 질문에 무엇을 답했나. 참여 기록 자체는 SDK 가 서버에 올린다
877
1076
  ...(rec.study
@@ -890,17 +1089,31 @@ class Hub {
890
1089
  ...(rec.visit && rec.visit.diag && rec.visit.diag.length ? { diag: rec.visit.diag.map(({ kind, action, shape, detail }) => ({ kind, action, shape, detail })) } : {}),
891
1090
  ...extra,
892
1091
  };
893
- ws.appendRun(row);
894
- // 대시보드로 — 키가 없으면 건너뛴다. 실패해도 방문은 끝낸다
895
- const uploaded = await report.sendVisit(row, rec.person, rec.startedAt, rec.visit ? rec.visit.screens : []).catch((e) => `⚠ Could not upload to the dashboard (${e.message})`);
896
- // 도구가 하지 못한 것들 — 우리 쪽 진단으로(화면·HTML 포함, 고객 대시보드에는 안 보인다). 기본으로 켜져 있다, USERCASE_DIAGNOSTICS=off 로 끈다
897
- if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device, rec.visit.diagShots).catch(() => 0);
1092
+ return row;
1093
+ }
1094
+
1095
+ /** 사람 파일에 이 사람을 쓴 날을 적는다 */
1096
+ markUsed(rec, at) {
898
1097
  const doc = ws.loadPeople();
899
1098
  const p = doc.people.find((x) => x.id === rec.person.id);
900
1099
  if (p) {
901
- p.usedAt = row.at;
1100
+ p.usedAt = at;
902
1101
  ws.savePeople(doc);
903
1102
  }
1103
+ }
1104
+
1105
+ /** 방문을 닫고 기록을 남긴다. 사람 파일에 쓴 날을 적는다 */
1106
+ async finish(rec, extra = {}) {
1107
+ if (rec.closed) return "";
1108
+ rec.closed = true;
1109
+ const row = this.visitRow(rec, extra);
1110
+ ws.appendRun(row);
1111
+ this.journal(rec.id);
1112
+ // 대시보드로 — 키가 없으면 건너뛴다. 실패해도 방문은 끝낸다
1113
+ const uploaded = await report.sendVisit(row, rec.person, rec.startedAt, rec.visit ? rec.visit.screens : []).catch((e) => `⚠ Could not upload to the dashboard (${e.message})`);
1114
+ // 도구가 하지 못한 것들 — 우리 쪽 진단으로(화면·HTML 포함, 고객 대시보드에는 안 보인다). 기본으로 켜져 있다, USERCASE_DIAGNOSTICS=off 로 끈다
1115
+ if (rec.visit && rec.visit.diag && rec.visit.diag.length) await report.sendDiagnostics(rec.visit.diag, rec.person.device, rec.visit.diagShots).catch(() => 0);
1116
+ this.markUsed(rec, row.at);
904
1117
  await new Promise((r) => setTimeout(r, 1500));
905
1118
  await rec.ctx.close().catch(() => {});
906
1119
  return [summarize(row), uploaded].filter(Boolean).join("\n");
@@ -1091,8 +1304,24 @@ class Hub {
1091
1304
  return lines.join("\n");
1092
1305
  }
1093
1306
 
1307
+ /**
1308
+ * MCP 가 꺼진다(세션 끝) — 곧 프로세스가 끝날 수 있다. 끝나지 않은 방문을 하나씩 finish 하면(올리기 · 1.5초 · 창 닫기)
1309
+ * 첫 방문을 하는 사이에 끊겨 나머지는 기록조차 안 남았다(11/05 bluetags 74). 그래서 먼저 전부 디스크에 적고(runs · outbox),
1310
+ * 그다음 한꺼번에 보낸다. 못 보낸 것은 다음 방문이 끝날 때 간다
1311
+ */
1094
1312
  async close() {
1095
- for (const rec of this.visits.values()) if (!rec.closed) await this.finish(rec, { abandoned: true }).catch(() => {});
1313
+ const open = [...this.visits.values()].filter((r) => !r.closed);
1314
+ for (const rec of open) {
1315
+ try {
1316
+ rec.closed = true;
1317
+ const row = this.visitRow(rec, { abandoned: true });
1318
+ ws.appendRun(row);
1319
+ report.queueVisit(row, rec.person, rec.startedAt);
1320
+ this.journal(rec.id);
1321
+ this.markUsed(rec, row.at);
1322
+ } catch {}
1323
+ }
1324
+ if (open.length) await Promise.race([report.flushOutbox().catch(() => null), sleep(8000)]);
1096
1325
  if (this.browserP) await (await this.browserP).close().catch(() => {});
1097
1326
  if (this.loginBrowser) await this.loginBrowser.close().catch(() => {});
1098
1327
  if (this.loginReal) await this.loginReal.close().catch(() => {});
@@ -1228,6 +1457,17 @@ function studyLine(s) {
1228
1457
  }
1229
1458
 
1230
1459
  /** 방문 한 줄 요약 — 메인 에이전트가 모아서 사용자에게 보여준다 */
1460
+ /** 지금 페이지 — 사이트 안이면 경로, 하위 도메인이면 호스트까지 */
1461
+ function pageName(rec) {
1462
+ try {
1463
+ const u = new URL(rec.page.url());
1464
+ const bare = (h) => h.replace(/^www\./, "");
1465
+ return bare(u.host) === bare(new URL(rec.site.origin).host) ? u.pathname || "/" : u.host + u.pathname;
1466
+ } catch {
1467
+ return rec.page.url();
1468
+ }
1469
+ }
1470
+
1231
1471
  function summarize(row) {
1232
1472
  const last = row.steps[row.steps.length - 1];
1233
1473
  const card = row.card ? `Card "${row.card.question}" → ${row.card.result === "close" ? "closed" : row.card.answer}` : "No card";
@@ -1236,6 +1476,7 @@ function summarize(row) {
1236
1476
  row.pages?.length ? `Pages: ${row.pages.join(" → ")}` : "",
1237
1477
  card,
1238
1478
  row.study ? studyLine(row.study) : "",
1479
+ row.noSdk && row.noSdk.length ? `⚠ Usercase SDK missing or not connected on: ${row.noSdk.join(", ")}${/^(no_sdk|sdk_not_connected)$/.test(row.stopped || "") ? " — the visit was stopped there" : " — not recorded there"}` : "",
1239
1480
  last ? `Last: ${last.action} — ${last.why}` : row.blocked ? "Stopped at a block page" : "",
1240
1481
  ]
1241
1482
  .filter(Boolean)
package/mcp/path.js CHANGED
@@ -151,7 +151,7 @@ class PathRecorder {
151
151
  const cond = (t) =>
152
152
  t.condition === "url_entry" ? `done on reaching ${t.targetUrl || "(no page set)"}` : t.condition === "element_click" ? `done on clicking an element on ${t.xpathUrl || "?"}` : `done after ${t.timer}s`;
153
153
  return [
154
- `Research "${r.research.title}" (${r.research.status})${r.research.website ? ` — site ${r.research.website}` : ""}`,
154
+ `Research "${r.research.title}" (${r.research.status}) — id ${r.research.id}${r.research.website ? ` — site ${r.research.website}` : ""}`,
155
155
  r.research.purpose ? `Purpose: ${r.research.purpose}` : "",
156
156
  "",
157
157
  ...r.tasks.map((t) =>
package/mcp/report.js CHANGED
@@ -40,6 +40,43 @@ const setClient = (name) => {
40
40
  };
41
41
  const toolName = () => client || (process.env.USERCASE_CLIENT === "codex" ? "codex" : "claude-code");
42
42
 
43
+ /**
44
+ * 요금제 한도 — 서버가 402 와 { code: "plan_limit", error, feature, upgradeUrl } 로 막는다.
45
+ * 401 은 키 문제(다시 연결)라 쓰지 않는다. 지금(11/10)은 다 무료라 아무것도 402 를 보내지 않지만,
46
+ * 고객 PC 의 MCP 는 오래 남으니 받는 쪽을 미리 둔다.
47
+ * 막힌 기능(feature, 없으면 "all")은 10분 동안 기억한다 — 그동안 새 방문을 열지 않는다. 결제하면 10분 뒤 다시 된다
48
+ */
49
+ const PLAN_MEMORY_MS = 10 * 60 * 1000;
50
+ const planBlocks = new Map();
51
+ function planLimitError(status, json) {
52
+ const feature = (json && typeof json.feature === "string" && json.feature) || "all";
53
+ const info = {
54
+ feature,
55
+ message: (json && json.error) || "This needs a paid Usercase plan.",
56
+ upgradeUrl: (json && typeof json.upgradeUrl === "string" && json.upgradeUrl) || dashboardUrl("/"),
57
+ at: Date.now(),
58
+ };
59
+ planBlocks.set(feature, info);
60
+ return Object.assign(new Error(info.message), { status, planLimit: info });
61
+ }
62
+ /** 이 기능(또는 전체)이 최근에 요금제로 막혔으면 그 내용 */
63
+ function planBlocked(feature) {
64
+ for (const k of [feature, "all"]) {
65
+ const b = planBlocks.get(k);
66
+ if (b && Date.now() - b.at < PLAN_MEMORY_MS) return b;
67
+ if (b) planBlocks.delete(k);
68
+ }
69
+ return null;
70
+ }
71
+ /** 에이전트에게 — 오류가 아니라 멈출 일이다. 우회 · 재시도 금지, 사용자에게 그대로 */
72
+ const planStopText = (b) =>
73
+ [
74
+ `STOP — Usercase plan limit: ${b.message}`,
75
+ `Upgrade: ${b.upgradeUrl}`,
76
+ "",
77
+ "This is not an error to work around. Do not retry, do not try other tools, other ids or a different approach to get around it, and do not run more visits. Tell the user the message and the link above in your own words, then stop the current command.",
78
+ ].join("\n");
79
+
43
80
  /** @param key 이 키로 부른다(연결 API). 없으면 지금 쓰는 키 */
44
81
  async function call(method, route, body, timeoutMs = 15000, key) {
45
82
  const cur = key === undefined ? conn.current() : null;
@@ -56,6 +93,7 @@ async function call(method, route, body, timeoutMs = 15000, key) {
56
93
  json = JSON.parse(text);
57
94
  } catch {}
58
95
  if (!res.ok) {
96
+ if (res.status === 402 || (json && json.code === "plan_limit")) throw planLimitError(res.status, json);
59
97
  // 키가 거절됐다 — 연결로 받은 키면 지우고, 직접 넣은 키면 이번 세션엔 안 쓴다. 다음 호출이 브라우저 연결로 간다
60
98
  if (res.status === 401 && cur && cur.source !== "none") {
61
99
  conn.rejected(cur.source);
@@ -104,6 +142,8 @@ async function describeKey(sdkProject, retried = false) {
104
142
  const m = await getMe();
105
143
  if (sdkProject && m.projectId !== sdkProject)
106
144
  return `⚠ Connected to the "${m.title}" project, but the SDK on this site sends to another project (${sdkProject}). Visits and card answers would land in different projects. Call the \`connect\` tool to pick the right project`;
145
+ // 연결 전에 돈 방문이 남아 있으면 지금 올린다
146
+ if (readOutbox().length) await flushOutbox(m.projectId).catch(() => null);
107
147
  return connected || `✓ Connected — visits are sent to the "${m.title}" project. To use another project, call the \`connect\` tool`;
108
148
  } catch (e) {
109
149
  // 키가 거절됐다 — call 이 지웠으니 한 번만 다시(브라우저 연결로)
@@ -197,11 +237,42 @@ async function sendScreens(item, screens) {
197
237
  * @returns 요약에 붙일 한 줄, 키가 없으면 null
198
238
  */
199
239
  async function sendVisit(row, person, startedAt, screens = []) {
200
- if (!KEY_RE.test(apiKey())) return null;
240
+ // 이 폴더가 아직 Usercase 프로젝트에 연결되지 않았다 — 전에는 말없이 건너뛰어 대시보드에 없는 줄 몰랐다(11/10).
241
+ // 버리지 않고 outbox 에 둔다. check_site 로 연결하면 그때 올라간다(사진은 다시 안 보낸다)
242
+ if (!KEY_RE.test(apiKey())) {
243
+ writeOutbox([...readOutbox(), { ...payload(row, person, startedAt), _project: null }]);
244
+ return NOT_CONNECTED_LINE;
245
+ }
201
246
  // 기록마다 어느 프로젝트로 가야 했는지 붙여 둔다 — 다시 연결하며 다른 프로젝트를 골라도 엉뚱한 곳에 들어가지 않게
202
247
  const project = await getMe().then((m) => m.projectId).catch(() => null);
203
248
  const fresh = { ...(await sendScreens(payload(row, person, startedAt), screens)), _project: project };
204
- const queue = [...readOutbox(), fresh];
249
+ // 보내기 전에 outbox 에 먼저 적는다 — 보내는 도중 MCP 가 꺼져도 다음에 간다(서버는 같은 id 를 한 번만 받는다)
250
+ writeOutbox([...readOutbox(), fresh]);
251
+ return flushOutbox(project);
252
+ }
253
+
254
+ /**
255
+ * 방문 하나를 지금 바로 outbox 에 적는다(기다리지 않는다) — MCP 가 꺼지는 중이라 보낼 시간이 없을 수 있다.
256
+ * 프로젝트는 이미 알면 붙인다. 키가 없으면 false
257
+ */
258
+ function queueVisit(row, person, startedAt) {
259
+ // 연결 전이어도 남긴다 — 연결하면 올라간다
260
+ writeOutbox([...readOutbox(), { ...payload(row, person, startedAt), _project: me ? me.projectId : null }]);
261
+ return KEY_RE.test(apiKey());
262
+ }
263
+
264
+ const NOT_CONNECTED_LINE = `⚠ Not sent to the dashboard — this folder is not connected to a Usercase project. The visit is kept in ${OUTBOX} and uploads once check_site connects this folder.`;
265
+
266
+ /** 이 폴더가 Usercase 프로젝트에 연결돼 있나(키가 있나) */
267
+ const connected = () => KEY_RE.test(apiKey());
268
+ /** 아직 못 올린 방문 수 */
269
+ const pendingCount = () => readOutbox().length;
270
+
271
+ /** outbox 를 보낸다. 다른 프로젝트 것은 남긴다. @returns 요약에 붙일 한 줄, 키가 없으면 null */
272
+ async function flushOutbox(project) {
273
+ if (!KEY_RE.test(apiKey())) return null;
274
+ if (project === undefined) project = await getMe().then((m) => m.projectId).catch(() => null);
275
+ const queue = readOutbox();
205
276
  const left = [];
206
277
  let sent = 0;
207
278
  let other = 0;
@@ -225,6 +296,7 @@ async function sendVisit(row, person, startedAt, screens = []) {
225
296
  writeOutbox(left);
226
297
  const otherLine = other ? ` · ${other} from another project kept in ${OUTBOX} until you connect that project again` : "";
227
298
  if (left.length === other) return `· Sent to the dashboard${sent > 1 ? ` (${sent} including earlier ones)` : ""}${otherLine}`;
299
+ if (lastErr && lastErr.planLimit) return `⛔ Not uploaded — ${planStopText(lastErr.planLimit)}\n(${left.length - other} kept in ${OUTBOX}; they upload once the plan allows it.)`;
228
300
  return `⚠ Could not upload to the dashboard (${lastErr?.status === 401 ? "connection rejected — run check_site to connect again" : lastErr?.message}) — ${left.length - other} kept in ${OUTBOX}, will retry next time${otherLine}`;
229
301
  }
230
302
 
@@ -313,6 +385,10 @@ module.exports = {
313
385
  reconnect,
314
386
  getInstall,
315
387
  sendVisit,
388
+ connected,
389
+ pendingCount,
390
+ queueVisit,
391
+ flushOutbox,
316
392
  sendDiagnostics,
317
393
  getResearchTasks,
318
394
  savePath,
@@ -330,5 +406,7 @@ module.exports = {
330
406
  getIssues,
331
407
  getIssue,
332
408
  saveIssueFix,
409
+ planBlocked,
410
+ planStopText,
333
411
  dashboardUrl,
334
412
  };
package/mcp/server.js CHANGED
@@ -26,7 +26,19 @@ const peopleRules = require("../src/persona/people.js");
26
26
  const { PathRecorder, patternOf } = require("./path.js");
27
27
 
28
28
  const hub = new Hub();
29
- const server = new McpServer({ name: "usercase", version: require("../package.json").version });
29
+ // 서버 안내문 — 도구 설명과 달리 AI 에게 처음부터 보인다(Claude Code 는 MCP 도구를 이름만 보여 주고 설명은 찾아야 보인다).
30
+ // 말로 부탁받았을 때 지시문(instructions 도구)부터 받게 한다. 11/10 시험: 안내문 없이 "방문자한테 물어볼 질문 만들어줘" → Usercase 를 안 쓰고 대화로 질문만 썼다
31
+ const server = new McpServer(
32
+ { name: "usercase", version: require("../package.json").version },
33
+ {
34
+ instructions:
35
+ "Usercase is connected: virtual users that visit this site, research with real visitors, and questions shown to visitors on the site. " +
36
+ "When the user asks for any of these — in a command or in their own words — call the Usercase `instructions` tool first with the matching command and follow what it returns: " +
37
+ "test or get feedback on the site with virtual users (research); plan or create a research or usability study (new-research); add or install Usercase on the site (install); " +
38
+ "make a question to ask visitors (question); write the steps for the question open in the dashboard (steps); record the paths of a research's tasks (path); " +
39
+ "fix a finding from a research report or an issue from Issues (fix); connect this repository to another Usercase project (connect).",
40
+ },
41
+ );
30
42
 
31
43
  // Claude Code 플러그인(plugin/)으로 떴는지. 플러그인의 .mcp.json 이 USERCASE_PLUGIN=1 을 준다.
32
44
  // 플러그인에서는 방문자 에이전트가 플러그인에 들어 있어 이름이 usercase:usercase-visitor 이고(도구도 mcp__plugin_usercase_usercase__*),
@@ -45,14 +57,18 @@ const cmd = (name) => (CODEX ? `$usercase:${name}` : PLUGIN ? `/usercase:${name}
45
57
  const SPAWN = CODEX ? " (with `fork_turns` set to `none`, so it starts without this conversation)" : "";
46
58
 
47
59
  const text = (t) => ({ content: [{ type: "text", text: String(t) }] });
48
- /** 방문 화면 + 그 화면의 이름 없는 아이콘 그림(있으면). 방문자는 그림을 보고 모양으로 안다 */
60
+ /** 방문 화면 + 그 화면의 그림들(화면 전체·이름 없는 아이콘, 있으면). 방문자는 그림을 보고 사람처럼 안다 */
49
61
  const screen = (t, visitId) => {
50
- const pic = visitId ? hub.takePicture(visitId) : null;
51
62
  const out = text(t);
52
- if (pic) out.content.push({ type: "image", data: pic.toString("base64"), mimeType: "image/png" });
63
+ for (const p of visitId ? hub.takePictures(visitId) : []) out.content.push({ type: "image", data: p.data.toString("base64"), mimeType: p.mime });
53
64
  return out;
54
65
  };
55
- const fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message}` }], isError: true });
66
+ // 요금제 한도(402)는 오류가 아니라 멈출 일 — 우회하지 말고 사용자에게 그대로 (report.js planLimitError)
67
+ /** 말로 부탁받은 AI 가 지시문을 건너뛰고 바로 부르는 도구들 — 지시문(instructions)부터 읽게 한다(11/10 시험: 고쳐줘 → issue_read 로 바로 갔다) */
68
+ const PROC = (c) => ` If you have not read the ${c} procedure yet, call the instructions tool with command ${c} first.`;
69
+ /** 리서치는 id 또는 이름 — 말로 부탁하면 이름이 온다. 서버가 이름으로 찾는다(11/10) */
70
+ const RESEARCH_REF = "The research id, or its name as the user said it (the server finds it by name; if several or none match, it lists them — ask the user which one)";
71
+ const fail = (e) => ({ content: [{ type: "text", text: e && e.planLimit ? report.planStopText(e.planLimit) : `Error: ${e.message}` }], isError: true });
56
72
  const wrap = (fn) => async (args) => {
57
73
  try {
58
74
  return await fn(args);
@@ -67,7 +83,7 @@ server.registerTool(
67
83
  "check_site",
68
84
  {
69
85
  description:
70
- "Open the site once as a virtual user and report: does it load, is the Usercase SDK running (which project), is a firewall/bot protection blocking us (and how to unblock, per vendor), is the secret key set. Always run this before starting visits. If it reports a block, stop and tell the user.",
86
+ "Open the site once as a virtual user and report: does it load, is the Usercase SDK running (which project), is a firewall/bot protection blocking us (and how to unblock, per vendor), is the secret key set. Always run this before starting visits. If it reports a block, stop and tell the user." + PROC("research"),
71
87
  inputSchema: { url: z.string().url().describe("The site to test, e.g. https://staging.example.com") },
72
88
  },
73
89
  wrap(async ({ url }) => text(await hub.checkSite(url))),
@@ -90,7 +106,7 @@ server.registerTool(
90
106
  "install_snippet",
91
107
  {
92
108
  description:
93
- "Get the Usercase script for the connected project, with where to put it per framework. If nothing is connected yet, it opens the browser to connect and says what to tell the user. Use when check_site says the SDK is not running and you are in the site's repository: add the script once to the root layout/document, show the user the diff, then run check_site on the local site to confirm. Do not commit or deploy unless asked.",
109
+ "Get the Usercase script for the connected project, with where to put it per framework. If nothing is connected yet, it opens the browser to connect and says what to tell the user. Use when check_site says the SDK is not running and you are in the site's repository: add the script once to the root layout/document, show the user the diff, then run check_site on the local site to confirm. Do not commit or deploy unless asked." + PROC("install"),
94
110
  inputSchema: {},
95
111
  },
96
112
  wrap(async () => {
@@ -184,7 +200,7 @@ server.registerTool(
184
200
  research: z
185
201
  .string()
186
202
  .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"),
203
+ .describe("Only when the run takes part in a research — its id, as path_tasks shows it: everyone saved in this call takes part in it"),
188
204
  },
189
205
  },
190
206
  wrap(async ({ site, site_about, people, research }) => {
@@ -242,9 +258,18 @@ server.registerTool(
242
258
  const rows = ws.recentRuns(limit);
243
259
  const { summarize } = require("./hub.js");
244
260
  const lines = rows.map((r) => summarize(r)).join("\n\n");
261
+ // SDK 가 없던 페이지 — 사이트 설정 문제라 사용자에게 알린다(도구 문제가 아니다)
262
+ const noSdk = [...new Set(rows.flatMap((r) => r.noSdk || []))];
263
+ const sdkLine = noSdk.length
264
+ ? `⚠ Pages where the Usercase SDK was missing or did not connect: ${noSdk.join(", ")}. Visits are not recorded there, and research visits that reached one were stopped. Tell the user this in plain words: where the script is missing, adding the Usercase script to those pages (or that subdomain) fixes it; where it did not connect, add that domain to the project's allowed domains in Usercase settings (a wildcard such as *.example.com covers every subdomain).`
265
+ : "";
266
+ // 이 폴더가 연결되지 않아 대시보드에 안 올라간 방문 — 사용자는 대시보드에서 찾다가 없는 줄 안다(11/10)
267
+ const keyLine = !report.connected() && report.pendingCount()
268
+ ? `⚠ This folder is not connected to a Usercase project, so ${report.pendingCount()} visit(s) are not on the dashboard yet (kept locally). Tell the user, and run check_site to connect — they upload as soon as it is connected.`
269
+ : "";
245
270
  const st = hub.status();
246
271
  const next = runStatus();
247
- return text([lines || "No visits yet.", st ? `\n---\n${st}` : "", next ? `\n---\n${next}` : ""].join("\n"));
272
+ return text([lines || "No visits yet.", keyLine ? `\n---\n${keyLine}` : "", sdkLine ? `\n---\n${sdkLine}` : "", st ? `\n---\n${st}` : "", next ? `\n---\n${next}` : ""].join("\n"));
248
273
  }),
249
274
  );
250
275
 
@@ -409,7 +434,7 @@ const QCondition = z.object({
409
434
  page: z.string().describe("Path glob of the screen the visitor is on when the question appears"),
410
435
  steps: z.array(QStep).describe("What happened, in order. Other actions may happen in between"),
411
436
  moment: z.object({
412
- type: z.enum(["on_match", "idle", "leave"]).describe("on_match = as soon as the steps are met · idle = after that, N seconds with no click, typing or page change (scrolling still counts as a pause) · leave = when about to leave"),
437
+ type: z.enum(["on_match", "idle", "leave"]).describe("on_match = as soon as the steps are met · idle = after that, N seconds with no click, typing or page change (scrolling still counts as a pause) · leave = when they leave that screen: moving to another page, closing the tab on a computer, or coming back from another tab (phones cannot tell a tab closing)"),
413
438
  seconds: z.number().optional(),
414
439
  }),
415
440
  });
@@ -438,7 +463,7 @@ server.registerTool(
438
463
  "question_context",
439
464
  {
440
465
  description:
441
- "Screens and element words from this project's recent real visits. Call first in the question procedure, before writing a condition — page globs and click targets must match what visitors actually saw.",
466
+ "Screens and element words from this project's recent real visits. Call first in the question procedure, before writing a condition — page globs and click targets must match what visitors actually saw." + PROC("question"),
442
467
  inputSchema: {},
443
468
  },
444
469
  wrap(async () => {
@@ -507,7 +532,7 @@ server.registerTool(
507
532
  server.registerTool(
508
533
  "steps_brief",
509
534
  {
510
- description: "What is in the user's Several steps window: whether the card is a question or a research invite, the page under Where, and the words they wrote. Call first in the steps procedure.",
535
+ description: "What is in the user's Several steps window: whether the card is a question or a research invite, the page under Where, and the words they wrote. Call first in the steps procedure." + PROC("steps"),
511
536
  inputSchema: { session: z.string().describe("The window id from the dashboard command") },
512
537
  },
513
538
  wrap(async ({ session }) => {
@@ -565,8 +590,8 @@ server.registerTool(
565
590
  "report_findings",
566
591
  {
567
592
  description:
568
- "List the findings of a Usercase research's latest report (key, severity, title, who, and whether one is being fixed). Use when the fix command has no finding key.",
569
- inputSchema: { research: z.string().describe("The research id"), audience: audienceArg },
593
+ "List the findings of a Usercase research's latest report (key, severity, title, who, and whether one is being fixed). Use when the fix command has no finding key." + PROC("fix"),
594
+ inputSchema: { research: z.string().describe(RESEARCH_REF), audience: audienceArg },
570
595
  },
571
596
  wrap(async ({ research, audience }) => {
572
597
  const r = await report.getReport(research, audience);
@@ -589,8 +614,8 @@ server.registerTool(
589
614
  "report_finding",
590
615
  {
591
616
  description:
592
- "Read one finding of a Usercase research report with all its evidence: why, who, the screen checks from the recordings, the paths people took, what they wrote, and the suggested changes. Read it before looking for the code.",
593
- inputSchema: { research: z.string().describe("The research id"), finding: z.string().describe("Finding key, e.g. F1"), audience: audienceArg },
617
+ "Read one finding of a Usercase research report with all its evidence: why, who, the screen checks from the recordings, the paths people took, what they wrote, and the suggested changes. Read it before looking for the code." + PROC("fix"),
618
+ inputSchema: { research: z.string().describe(RESEARCH_REF), finding: z.string().describe("Finding key, e.g. F1"), audience: audienceArg },
594
619
  },
595
620
  wrap(async ({ research, finding, audience }) => {
596
621
  const r = await report.getFinding(research, finding, audience);
@@ -630,7 +655,7 @@ server.registerTool(
630
655
  description:
631
656
  "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.",
632
657
  inputSchema: {
633
- research: z.string().describe("The research id"),
658
+ research: z.string().describe(RESEARCH_REF),
634
659
  finding: z.string().describe("Finding key, e.g. F1"),
635
660
  audience: audienceArg,
636
661
  status: z.enum(["fixing", "fixed"]),
@@ -652,7 +677,7 @@ const issueFixLine = (f) => (f ? ` — ${f.status === "fixed" ? "fixed" : "being
652
677
  server.registerTool(
653
678
  "issue_list",
654
679
  {
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.",
680
+ 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." + PROC("fix"),
656
681
  inputSchema: {},
657
682
  },
658
683
  wrap(async () => {
@@ -675,7 +700,7 @@ server.registerTool(
675
700
  "issue_read",
676
701
  {
677
702
  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.",
703
+ "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." + PROC("fix"),
679
704
  inputSchema: { issue: z.string().describe("The issue id, as in the dashboard command") },
680
705
  },
681
706
  wrap(async ({ issue }) => {
@@ -728,8 +753,8 @@ server.registerTool(
728
753
  server.registerTool(
729
754
  "path_tasks",
730
755
  {
731
- description: "List the tasks of a Usercase research (from the dashboard) and whether each already has an expected path. Call first in the path procedure.",
732
- inputSchema: { research: z.string().describe("The research id, as shown in the dashboard command") },
756
+ description: "List the tasks of a Usercase research (from the dashboard) and whether each already has an expected path. Call first in the path procedure." + PROC("path"),
757
+ inputSchema: { research: z.string().describe(RESEARCH_REF) },
733
758
  },
734
759
  wrap(async ({ research }) => {
735
760
  const out = await recorder.tasks(research);
@@ -829,7 +854,7 @@ server.registerTool(
829
854
  "research_context",
830
855
  {
831
856
  description:
832
- "What this project's data says is worth researching: open findings, where real visitors and virtual users left, what virtual users said when they left without getting what they came for, and the researches and drafts that already exist. Call first in the new-research procedure.",
857
+ "What this project's data says is worth researching: open findings, where real visitors and virtual users left, what virtual users said when they left without getting what they came for, and the researches and drafts that already exist. Call first in the new-research procedure." + PROC("new-research"),
833
858
  inputSchema: {},
834
859
  },
835
860
  wrap(async () => {
@@ -873,7 +898,8 @@ server.registerTool(
873
898
  "Save the research as a DRAFT in the dashboard Inbox, after the user approved it. It becomes a research only when someone creates it there. Paths are page paths starting with / (no host), with :name for the parts that change. Expected paths recorded and saved beforehand as plan-<n> (n = the task's position in tasks, from 1) are uploaded with it.",
874
899
  inputSchema: {
875
900
  title: z.string().describe("Short, for the team"),
876
- purpose: z.string().describe("What the team wants to learn and why — the evidence it came from"),
901
+ purpose: z.string().describe("Shown to participants (invite card and start of the study): one or two sentences, in their words, on what they will help with. Never the team's evidence, suspicions or hypotheses"),
902
+ hypotheses: z.array(z.string()).max(5).optional().describe("Team only, never shown to participants: what the team expects to go wrong, one per item, 1–3 usually. The report judges each one. Each must be something these tasks can confirm or rule out"),
877
903
  start_screen: z.object({ title: z.string(), subtitle: z.string() }).optional().describe("What participants read before the first task"),
878
904
  tasks: z
879
905
  .array(
@@ -1143,7 +1169,7 @@ const COMMANDS = {
1143
1169
  research: z
1144
1170
  .string()
1145
1171
  .optional()
1146
- .describe("A research id from the dashboard: the people take part in that research — its tasks and questions — instead of exploring freely"),
1172
+ .describe("A research from the dashboard (its id, or its name): the people take part in that research — its tasks and questions — instead of exploring freely"),
1147
1173
  },
1148
1174
  body: ({ url, count, focus, research }) => {
1149
1175
  const plan = researchPlan(count);
@@ -1193,10 +1219,10 @@ const COMMANDS = {
1193
1219
  path: {
1194
1220
  description: "Record the expected path of a Usercase research's tasks",
1195
1221
  // 대시보드 과제 화면의 명령은 과제 id 를 붙인다 — 그 과제만, 경로가 있어도 다시
1196
- argsSchema: { research: z.string().optional(), task: z.string().optional() },
1222
+ argsSchema: { research: z.string().optional().describe(RESEARCH_REF), task: z.string().optional() },
1197
1223
  body: ({ research, task }) =>
1198
1224
  doc("path.md")
1199
- .replace("{{research}}", research || "(ask the user — the dashboard shows it)")
1225
+ .replace("{{research}}", research || "(not given — ask the user which research; its name is enough)")
1200
1226
  .replace("{{task}}", task ? `Task: ${task} — record this task only, and record it again even if it already has a path.` : "Task: all tasks of the research"),
1201
1227
  },
1202
1228
  // 인자를 두지 않는다 — Claude Code 는 명령어 인자를 공백으로 나눠 문장을 못 싣는다. 무엇을 물을지는 대화로 묻는다
@@ -1228,7 +1254,7 @@ const COMMANDS = {
1228
1254
  fix: {
1229
1255
  description: "Fix a finding from a Usercase research report, or an issue from Issues, in this repository's code",
1230
1256
  argsSchema: {
1231
- research: z.string().optional(),
1257
+ research: z.string().optional().describe(RESEARCH_REF),
1232
1258
  finding: z.string().optional(),
1233
1259
  audience: z.string().optional(),
1234
1260
  issue: z.string().optional().describe("An issue id from the dashboard's Issues — fixes that issue instead of a report finding"),
@@ -1238,7 +1264,7 @@ const COMMANDS = {
1238
1264
  issue || String(research || "").trim().toLowerCase() === "issue"
1239
1265
  ? agentDoc("fix-issue.md", { issue: String(issue || "").trim() || "(not given — list the open issues and ask)", research_cmd: cmd("research") })
1240
1266
  : agentDoc("fix.md", {
1241
- research: research || "(ask the user — the dashboard Report shows the command)",
1267
+ research: research || "(not given — ask the user which research; its name is enough)",
1242
1268
  finding: finding ? String(finding).toUpperCase() : "(not given — list the findings and ask)",
1243
1269
  audience: audience === "real" ? "real (the report of real people)" : "virtual (the report of virtual users)",
1244
1270
  check:
@@ -1254,12 +1280,31 @@ const COMMANDS = {
1254
1280
  },
1255
1281
  };
1256
1282
 
1257
- if (PLUGIN || CODEX || OTHER) {
1283
+ // 코드를 읽는 명령은 모두 끝에 "Usercase 로 무엇을 보내는가" 규칙을 붙인다 — 에이전트가 읽은 키·.env 값을 도구 인자에 실어
1284
+ // 우리 서버로 보내지 않게. 명령마다 따로 쓰면 빠지는 데가 생겨 한 벌(agents/secrets.md)만 둔다. connect 는 코드를 안 읽는다
1285
+ const NO_SECRETS = new Set(["connect"]);
1286
+ const commandText = (name, args) => {
1287
+ const c = COMMANDS[name];
1288
+ const body = c.body(byName(args, c.argsSchema)).trimEnd();
1289
+ return NO_SECRETS.has(name) ? body : `${body}\n\n${doc("secrets.md").trim()}\n`;
1290
+ };
1291
+
1292
+ // 지시문은 어느 연결에서든 도구로도 준다. 사용자는 명령 대신 말로("새 리서치 만들어줘") 부탁하는데, AI 는 프롬프트를 스스로 열지 못한다 —
1293
+ // 플러그인 없이 붙인 Claude Code 에서 말로 부탁하면 지시문 없이 도구를 짐작으로 불렀다(11/10 시험). 프롬프트(/mcp__usercase__이름)는 그대로 둔다
1294
+ {
1258
1295
  server.registerTool(
1259
1296
  "instructions",
1260
1297
  {
1261
1298
  description:
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.",
1299
+ "Start here for any Usercase work — whether the user typed a command or just asked in their own words. Returns the step-by-step procedure; follow it as the user's request, and call this before the other Usercase tools. Commands: " +
1300
+ "research (test or get feedback on the site with virtual users; `research` id to have them take part in a research), " +
1301
+ "new-research (plan or create a research / usability study), " +
1302
+ "install (add or install the Usercase script on the site), " +
1303
+ "question (make a question to ask visitors at a moment on the site), " +
1304
+ "steps (write the steps for the question open in the dashboard; `session` = its window id), " +
1305
+ "path (record the expected paths of a research's tasks; `research` id, optional `task`), " +
1306
+ "fix (fix a finding from a research report: `research` id + `finding` key like F1, `audience` real for the real-people report; or an issue from Issues: `issue` id), " +
1307
+ "connect (connect this repository to another Usercase project).",
1263
1308
  inputSchema: {
1264
1309
  command: z.enum(Object.keys(COMMANDS)),
1265
1310
  ...COMMANDS.research.argsSchema,
@@ -1268,12 +1313,13 @@ if (PLUGIN || CODEX || OTHER) {
1268
1313
  ...COMMANDS.fix.argsSchema,
1269
1314
  },
1270
1315
  },
1271
- wrap(async ({ command, ...args }) => text(COMMANDS[command].body(byName(args, COMMANDS[command].argsSchema)))),
1316
+ wrap(async ({ command, ...args }) => text(commandText(command, args))),
1272
1317
  );
1273
- } else {
1318
+ }
1319
+ if (!(PLUGIN || CODEX || OTHER)) {
1274
1320
  for (const [name, c] of Object.entries(COMMANDS)) {
1275
1321
  const meta = c.argsSchema ? { description: c.description, argsSchema: c.argsSchema } : { description: c.description };
1276
- server.registerPrompt(name, meta, (args = {}) => ({ messages: [{ role: "user", content: { type: "text", text: c.body(byName(args, c.argsSchema)) } }] }));
1322
+ server.registerPrompt(name, meta, (args = {}) => ({ messages: [{ role: "user", content: { type: "text", text: commandText(name, args) } }] }));
1277
1323
  }
1278
1324
  }
1279
1325
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
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",
@@ -35,6 +35,7 @@
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.30.0",
37
37
  "playwright": "^1.58.2",
38
+ "tldts": "^7.4.19",
38
39
  "zod": "^4.6.5"
39
40
  },
40
41
  "devDependencies": {
@@ -31,6 +31,14 @@ const SURVEY = /^https?:\/\/survey\.(usercase\.io|localhost)(:\d+)?$/;
31
31
 
32
32
  const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
33
33
 
34
+ const { getDomain } = require("tldts");
35
+ /**
36
+ * 같은 사이트의 뿌리 도메인. www.about.bluetags.app → bluetags.app.
37
+ * 공용 접미사 목록의 개인 도메인(vercel.app · github.io 등)까지 본다 — foo.vercel.app 과 bar.vercel.app 은 남이다.
38
+ * IP · localhost 는 뿌리가 없다(null) — 그 주소만 같은 사이트다
39
+ */
40
+ const rootOf = (host) => getDomain(host, { allowPrivateDomains: true }) || null;
41
+
34
42
  /** 새 탭·창도 그 자체로 최상위다. 페이지 안 iframe(영상·지도)은 사이트 밖이어도 그냥 둔다 */
35
43
  function isMainFrame(req) {
36
44
  try {
@@ -48,7 +56,10 @@ const isLocal = (site) => /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(si
48
56
  * @param blockTrackers 분석 도구를 막나(기본 true)
49
57
  * @param allowWritePaths 쓰기를 허용할 경로 정규식들. 로그인 뒤 조회를 POST 로 하는 사이트용(PLAN.md "로그인")
50
58
  */
51
- function createGuard({ site, inject = null, blockTrackers = true, allowWritePaths = [], policy = null, robots = null } = {}) {
59
+ /**
60
+ * @param robotsFor 하위 도메인 origin → 그 robots(비동기, 한 번 읽어 둔다). 없으면 하위 도메인은 robots 를 안 본다
61
+ */
62
+ function createGuard({ site, inject = null, blockTrackers = true, allowWritePaths = [], policy = null, robots = null, robotsFor = null } = {}) {
52
63
  const origin = new URL(site).origin;
53
64
  const local = isLocal(site);
54
65
  /** 우리가 심은 SDK 와 그 서버. 이건 막지 않는다 */
@@ -69,19 +80,26 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
69
80
  */
70
81
  const bare = (h) => h.replace(/^www\./, "");
71
82
  const siteHost = bare(new URL(origin).host);
72
- const sameSite = (u) => u.origin === origin || bare(u.host) === siteHost;
83
+ /**
84
+ * 같은 사이트 — 같은 뿌리 도메인의 하위 도메인까지(www · app · about …). 사람처럼 사이트의 다른 하위 도메인으로 넘어간다.
85
+ * 전에는 www 만 같은 사이트로 봐서 소개는 www, 제품은 app 인 사이트에서 "시작하기" 가 막혔다.
86
+ * 하위 도메인에 SDK 가 없으면 hub 가 그 페이지를 알린다(리서치 방문은 멈춘다, sdkGone)
87
+ */
88
+ const siteRoot = local ? null : rootOf(new URL(origin).hostname);
89
+ const sameSite = (u) => u.origin === origin || bare(u.host) === siteHost || (!!siteRoot && /^https?:$/.test(u.protocol) && rootOf(u.hostname) === siteRoot);
73
90
 
74
91
  function writeAllowed(pathname) {
75
92
  if (writeOk.some((rx) => rx.test(pathname))) return true;
76
93
  return !!(policy && policy.nonGetAllowed(pathname));
77
94
  }
78
95
 
79
- function navBlocked(u) {
80
- if (u.origin !== origin) return null; // 사이트 밖은 아래에서 따로 다룬다
96
+ async function navBlocked(u) {
97
+ if (!sameSite(u)) return null; // 사이트 밖은 아래에서 따로 다룬다
98
+ // 고객이 정한 금지 경로는 하위 도메인에도 같다
81
99
  const byPolicy = policy ? policy.pathReason(u.pathname) : null;
82
100
  if (byPolicy) return byPolicy;
83
- // robots 는 나중에 읽혀 들어올 수 있다(네트워크). 함수로 받으면 그때그때 최신을 본다
84
- const rb = typeof robots === "function" ? robots() : robots;
101
+ // robots 는 나중에 읽혀 들어올 수 있다(네트워크). 함수로 받으면 그때그때 최신을 본다. 하위 도메인은 그 도메인의 robots.txt
102
+ const rb = u.origin === origin ? (typeof robots === "function" ? robots() : robots) : robotsFor ? await robotsFor(u.origin).catch(() => null) : null;
85
103
  const byRobots = rb ? rb.blocked(u.pathname) : null;
86
104
  return byRobots ? `robots.txt Disallow ${byRobots}` : null;
87
105
  }
@@ -91,7 +109,7 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
91
109
  * @param onWrite 사이트에 쓰는 요청을 끊었을 때 "METHOD /path" 로 부른다. 방문자가 "안 된다" 로 읽지 않게 알린다
92
110
  */
93
111
  async function apply(ctx, { onOffsite = null, onWrite = null, onExternal = null } = {}) {
94
- await ctx.route("**/*", (r) => {
112
+ await ctx.route("**/*", async (r) => {
95
113
  const req = r.request();
96
114
  let u;
97
115
  try {
@@ -121,8 +139,8 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
121
139
  * 금지 구역으로 **옮겨 가는 것**만 막는다(클릭은 막지 않는다 — SDK 가 그 클릭을 봐야 한다).
122
140
  * 화면 안에서 쓰는 요청(XHR 등)까지 이걸로 막으면 멀쩡한 사이트가 안 돈다.
123
141
  */
124
- if (u.origin === origin && req.isNavigationRequest()) {
125
- const why = navBlocked(u);
142
+ if (sameSite(u) && req.isNavigationRequest()) {
143
+ const why = await navBlocked(u);
126
144
  if (why) {
127
145
  const k = `${u.pathname} — ${why}`;
128
146
  stats.paths.set(k, (stats.paths.get(k) || 0) + 1);
@@ -135,7 +153,8 @@ function createGuard({ site, inject = null, blockTrackers = true, allowWritePath
135
153
  * 전에는 아래 로컬 분기가 localhost 를 먼저 통과시켜 로컬에서는 쓰기가 한 번도 안 막혔다(10/14 발견).
136
154
  * 개발 서버 자체 요청(/_next/, /__nextjs)은 사이트 데이터가 아니다
137
155
  */
138
- const siteReq = u.origin === origin || (local && /^https?:\/\/(localhost|127\.0\.0\.1)/.test(u.origin) && !/^\/(_next|__nextjs)/.test(u.pathname));
156
+ // 하위 도메인도 그 사이트다 — app. 에서 가입 · 글쓰기가 실제로 나가면 안 된다
157
+ const siteReq = sameSite(u) || (local && /^https?:\/\/(localhost|127\.0\.0\.1)/.test(u.origin) && !/^\/(_next|__nextjs)/.test(u.pathname));
139
158
  if (siteReq && !READ_METHODS.has(req.method()) && !writeAllowed(u.pathname)) {
140
159
  const k = `${req.method()} ${u.pathname}`;
141
160
  stats.writes.set(k, (stats.writes.get(k) || 0) + 1);
@@ -216,4 +235,22 @@ function injectSdk({ project, src }) {
216
235
  }
217
236
  }
218
237
 
219
- module.exports = { createGuard, isLocal, TRACKERS };
238
+ /**
239
+ * 두 주소가 같은 사이트인가 — guard 의 sameSite 와 같은 기준(www 무시, 같은 뿌리 도메인의 하위 도메인까지).
240
+ * 방문(visit.js "다른 사이트로 가는 링크" 알림)과 새 탭 처리(hub)가 같이 쓴다. 로컬 · IP 는 호스트가 같아야 한다
241
+ */
242
+ function isSameSite(a, b) {
243
+ try {
244
+ const ua = new URL(a);
245
+ const ub = new URL(b);
246
+ const bare = (h) => h.replace(/^www\./, "");
247
+ if (bare(ua.host) === bare(ub.host)) return true;
248
+ if (!/^https?:$/.test(ub.protocol) || isLocal(ua.origin) || isLocal(ub.origin)) return false;
249
+ const ra = rootOf(ua.hostname);
250
+ return !!ra && ra === rootOf(ub.hostname);
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ module.exports = { createGuard, isLocal, isSameSite, TRACKERS };
@@ -644,4 +644,29 @@ async function iconPicture(page, items, browser, max = 16) {
644
644
  }
645
645
  }
646
646
 
647
- module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked, iconPicture };
647
+ /**
648
+ * 지금 보이는 화면 전체를 작게 한 장. 사람은 사진·배너·배치·눈에 띄는 것을 보고 고르는데, 방문자는 글자 목록만 받아
649
+ * 이미지 속 내용을 몰랐다(11/11). 글자는 목록으로 이미 받으니 긴 변 640px 로 줄여 사진과 배치만 알아볼 만큼 — 한 장 300 토큰쯤.
650
+ * 줄이는 판은 방문과 따로 연 빈 창이라 사이트에 아무것도 안 보낸다. 못 찍으면 null
651
+ */
652
+ async function screenPicture(page, browser, long = 640) {
653
+ if (!browser) return null;
654
+ const vp = page.viewportSize() || { width: 1280, height: 720 };
655
+ const png = await page.screenshot({ type: "png", scale: "css", animations: "disabled", timeout: 5000 }).catch(() => null);
656
+ if (!png) return null;
657
+ const k = Math.min(1, long / Math.max(vp.width, vp.height));
658
+ const w = Math.round(vp.width * k);
659
+ const h = Math.round(vp.height * k);
660
+ const ctx = await browser.newContext({ viewport: { width: w, height: h } });
661
+ try {
662
+ const sheet = await ctx.newPage();
663
+ await sheet.setContent(`<!doctype html><html><body style="margin:0"><img style="display:block" width="${w}" height="${h}" src="data:image/png;base64,${png.toString("base64")}"></body></html>`);
664
+ return await sheet.screenshot({ type: "jpeg", quality: 70, timeout: 5000 });
665
+ } catch {
666
+ return null;
667
+ } finally {
668
+ await ctx.close().catch(() => {});
669
+ }
670
+ }
671
+
672
+ module.exports = { OWN_SEL, listControls, visibleText, handleAt, clickBlocked, iconPicture, screenPicture };
package/src/run/visit.js CHANGED
@@ -22,6 +22,7 @@ const { randomUUID } = require("crypto");
22
22
  const { listControls, visibleText, handleAt, clickBlocked } = require("../browser/page.js");
23
23
  const { readCard, waitCard, screenText, answerCard, noteReason, sleep, waitForSdk } = require("../browser/card.js");
24
24
  const hand = require("../browser/hand.js");
25
+ const { isSameSite } = require("../browser/guard.js");
25
26
  const answer = require("./answer.js");
26
27
  const diag = require("./diag.js");
27
28
 
@@ -240,13 +241,15 @@ function watchNet(page) {
240
241
  };
241
242
  }
242
243
 
243
- /** 링크가 사이트 밖이면 그 호스트. www 만 다른 것은 같은 사이트다 */
244
- function offsiteHost(href, here) {
244
+ /**
245
+ * 링크가 사이트 밖이면 그 호스트. www 만 다른 것, 같은 뿌리 도메인의 하위 도메인은 같은 사이트다(guard.js isSameSite).
246
+ * @param site 사이트 주소. 없으면 지금 페이지 기준
247
+ */
248
+ function offsiteHost(href, here, site) {
245
249
  if (!href || !/^https?:\/\//i.test(href)) return null;
246
250
  try {
247
- const bare = (h) => h.replace(/^www\./, "");
248
251
  const to = new URL(href);
249
- return bare(to.host) === bare(new URL(here).host) ? null : to.host;
252
+ return isSameSite(site || here, href) || isSameSite(here, href) ? null : to.host;
250
253
  } catch {
251
254
  return null;
252
255
  }
@@ -409,8 +412,10 @@ class Visit {
409
412
  * @param study 리서치에 참여 중인 방문이면 { press(key) } — 위젯 단추(과제 건너뛰기)를 누른다(hub · browser/study.js).
410
413
  * 그 방문에서 리서치 초대 카드는 질문 카드로 다루지 않는다
411
414
  */
412
- constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], study = null, log = () => {} } = {}) {
415
+ constructor(page, person, { rhythm = null, shape = null, allowAccounts = false, policy = null, offsite = [], notSent = [], external = [], study = null, siteOrigin = null, log = () => {} } = {}) {
413
416
  this.page = page;
417
+ /** 사이트 주소 — 하위 도메인 페이지는 경로 앞에 호스트를 붙여 적는다(about.bluetags.app/docs) */
418
+ this.siteOrigin = siteOrigin;
414
419
  this.study = study;
415
420
  this.person = person;
416
421
  /** 방문 시작 — 걸음마다 몇 초째인지(t) 적는다 */
@@ -509,10 +514,22 @@ class Visit {
509
514
  return c && !(this.study && c.invite) ? c : null;
510
515
  }
511
516
 
517
+ /** 하위 도메인이면 호스트를 붙인 경로 — 사이트의 "/" 와 섞이지 않게 */
518
+ hostPath(p) {
519
+ try {
520
+ const u = new URL(this.page.url());
521
+ // www 는 같은 곳이다(사이트 주소가 bluetags.app 이어도 페이지는 www.bluetags.app)
522
+ const bare = (h) => h.replace(/^www\./, "");
523
+ return this.siteOrigin && /^https?:$/.test(u.protocol) && bare(u.host) !== bare(new URL(this.siteOrigin).host) ? u.host + p : p;
524
+ } catch {
525
+ return p;
526
+ }
527
+ }
528
+
512
529
  pathNow() {
513
530
  try {
514
531
  const u = new URL(this.page.url());
515
- return u.pathname + u.search;
532
+ return this.hostPath(u.pathname + u.search);
516
533
  } catch {
517
534
  return "";
518
535
  }
@@ -647,6 +664,7 @@ class Visit {
647
664
  // 카드 판단에는 페이지 전체를 준다 — 이 방문에서 본 것을 다 기억하는 사람이다
648
665
  if (this.pending) return { card: this.pending, text: await screenText(this.page) };
649
666
  const view = await listControls(this.page);
667
+ view.path = this.hostPath(view.path);
650
668
  // 위쪽만 빠진 것은 넘침이 아니다 — 굴려 올리면 다시 나온다
651
669
  if (view.droppedInView > (view.droppedIcons || 0)) this.diag.push({ kind: "controls_overflow", detail: `dropped ${view.droppedInView - (view.droppedIcons || 0)}` });
652
670
  const text = await visibleText(this.page, 2500);
@@ -679,7 +697,7 @@ class Visit {
679
697
  t: Date.now() - this.t0,
680
698
  path: (() => {
681
699
  try {
682
- return new URL(this.page.url()).pathname;
700
+ return this.hostPath(new URL(this.page.url()).pathname);
683
701
  } catch {
684
702
  return null;
685
703
  }
@@ -761,9 +779,10 @@ class Visit {
761
779
  await page.mouse.move(600, -8, { steps: 2 }).catch(() => {});
762
780
  }
763
781
  this.ended = true;
764
- // 떠나려는 순간에 뜨는 카드가 있다. 모바일은 SDK 가 멈춤으로 대신 보므로 더 기다린다
765
- if (!this.card) {
766
- const parting = this.asCard(await waitCard(page, this.person.device === "mobile" ? 16000 : 6000));
782
+ // 떠나려는 순간에 뜨는 카드가 있다(데스크톱 커서). 휴대폰은 사이트 밖으로 나가는 순간을 SDK 가 못 잡는다 —
783
+ // 예전엔 15초 멈춤으로 대신해 16초를 기다렸다(core #68 에서 뺐다). 화면을 옮길 때 뜨는 카드는 다음 조작에서 본다
784
+ if (!this.card && this.person.device !== "mobile") {
785
+ const parting = this.asCard(await waitCard(page, 6000));
767
786
  if (parting) this.pending = parting;
768
787
  }
769
788
  return record;
@@ -945,7 +964,7 @@ class Visit {
945
964
 
946
965
  // 사이트 밖으로 가는 링크였다. 끊었으니 방문자는 그대로 이 화면에 있다.
947
966
  // 새 탭으로 여는 링크(target=_blank)는 방어선을 안 지나고 열린다 — 누른 링크 주소로도 본다(탭은 hub 가 닫는다)
948
- const hrefOff = decision.action === "click" && !record.skipped ? offsiteHost(item?.href, before) : null;
967
+ const hrefOff = decision.action === "click" && !record.skipped ? offsiteHost(item?.href, before, this.siteOrigin) : null;
949
968
  if (this.offsite.length > offsiteBefore) {
950
969
  try {
951
970
  record.offsite = new URL(this.offsite[this.offsite.length - 1]).host;