@usercase/mcp 0.0.0-stage → 0.2.1-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/path.js ADDED
@@ -0,0 +1,394 @@
1
+ "use strict";
2
+ /**
3
+ * 과제의 정상 경로를 기록한다 — 대시보드 리서치 과제의 "바로 성공 / 돌아서 성공" 기준.
4
+ *
5
+ * 가상 사용자 방문과 다르다. 여기서 걷는 건 **메인 에이전트**다: 코드를 읽어 만든 사람이 의도한 길을 알고,
6
+ * 그 길만 빠르게 걷는다(사람 손·머뭇거림 없음). 걸음마다 SDK 의 경로 기록 화면(earningbirds-core scripts/path-setting.ts)이
7
+ * 남기는 것과 같은 것을 남긴다: 페이지 = 경로 + 화면, 누른 것 = XPath + 요소 화면.
8
+ * 그다음 새 브라우저로 같은 길을 다시 걸어(check) 요소가 그대로 찾아지는지 보고, 사용자가 승인하면 올린다(save).
9
+ *
10
+ * SDK 가 참여자의 클릭을 경로 단계에 맞추는 규칙(core/SequenceTracker.ts):
11
+ * - XPath 로 찾은 요소가 클릭된 요소를 **품으면** 맞다 → 버튼 안쪽 글자를 눌러도 맞게, 버튼·링크 자체를 기록한다
12
+ * - class 목록은 **하나라도** 맞으면(`.a,.b`) 맞다 → 흔한 class(flex 등)를 넣으면 아무 데나 눌러도 맞는다.
13
+ * 그래서 class·id 는 그 요소 하나만 가리킬 때만 남긴다
14
+ * - 호스트는 비운다(모든 호스트). 로컬 개발 서버에서 걸어도 운영에서 맞아야 한다
15
+ */
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const ws = require("./workspace.js");
19
+ const report = require("./report.js");
20
+ const { listControls, visibleText, handleAt, clickBlocked } = require("../src/browser/page.js");
21
+ const { renderScreen, settle, watchNet } = require("../src/run/visit.js");
22
+
23
+ /** 화면을 찍을 때 우리 것(질문 카드·위젯)은 가린다 — 참여자 화면에는 없는 것이다 */
24
+ const HIDE_OWN = "#eb-insight,#eb-plugin,#eb-plugin-entry,#usercase_container,#usercase-overlay,#earningbirds_container,#earningbirds-overlay{visibility:hidden !important}";
25
+ /** 기록할 요소 — 누른 자리에서 가장 가까운 누를 수 있는 것 */
26
+ const PICK_SEL = 'a[href],button,[role="button"],[role="link"],[role="tab"],[role="menuitem"],summary,label,input,select,textarea';
27
+ const MAX_STEPS = 30;
28
+
29
+ /** SDK 와 같은 경로 패턴 — `:id` 는 한 조각, 뒤에 /·?·# 허용 */
30
+ const patternOf = (p) => new RegExp(`^${p.replace(/:\w+/g, "([^\\/]+)")}/?(\\?.*|#.*)?$`);
31
+ const pathOnly = (u) => {
32
+ const p = new URL(u).pathname;
33
+ return p.length > 1 ? p.replace(/\/+$/, "") : "/";
34
+ };
35
+
36
+ /**
37
+ * 요소 하나를 SDK 가 맞출 수 있는 모양으로. 브라우저 안에서 돈다.
38
+ * XPath 는 SDK main/utils.ts getElementXPath 와 같은 방식(같은 이름 형제 사이 순번, svg 는 name())
39
+ */
40
+ function describeInPage(n, pickSel) {
41
+ const pick = (n.closest && n.closest(pickSel)) || n;
42
+ const xp = (e) => {
43
+ const parts = [];
44
+ while (e && e.nodeType === 1) {
45
+ let idx = 0;
46
+ let has = false;
47
+ for (let s = e.previousSibling; s; s = s.previousSibling) if (s.nodeType === 1 && s.nodeName === e.nodeName) idx++;
48
+ for (let s = e.nextSibling; s; s = s.nextSibling)
49
+ if (s.nodeType === 1 && s.nodeName === e.nodeName) {
50
+ has = true;
51
+ break;
52
+ }
53
+ const tag = e.namespaceURI === "http://www.w3.org/2000/svg" ? '*[name()="svg"]' : e.nodeName.toLowerCase();
54
+ parts.unshift(tag + (has || idx > 0 ? `[${idx + 1}]` : ""));
55
+ e = e.parentNode;
56
+ }
57
+ return parts.length ? (parts[0].startsWith("//*") ? parts.join("/") : `/${parts.join("/")}`) : null;
58
+ };
59
+ const cls = Array.from(pick.classList || []).filter(Boolean);
60
+ let classes = [];
61
+ try {
62
+ const sel = cls.map((c) => "." + CSS.escape(c)).join(",");
63
+ if (sel && document.querySelectorAll(sel).length === 1) classes = cls;
64
+ } catch (e) {}
65
+ // SDK 는 id 를 이스케이프 없이 `#id` 로 쓴다 — 평범한 id 만(React useId 의 ":r1:" 같은 건 SDK 에서 오류가 난다)
66
+ const ids = pick.id && /^[A-Za-z][\w-]*$/.test(pick.id) && document.querySelectorAll("#" + pick.id).length === 1 ? [pick.id] : [];
67
+ const label = (pick.getAttribute("aria-label") || pick.innerText || pick.value || pick.getAttribute("title") || "").replace(/\s+/g, " ").trim().slice(0, 80);
68
+ return { xpath: xp(pick), classes, ids, label, tag: pick.tagName.toLowerCase() };
69
+ }
70
+
71
+ /** XPath 로 요소를 찾는다(SDK 처럼 첫 번째) */
72
+ function byXPathInPage(xpath) {
73
+ try {
74
+ return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
75
+ } catch (e) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 화면이 다 떴나. 조용해질 때까지(최대 10초) 보고, 그래도 글자가 하나도 없으면 30초까지 기다린다 —
82
+ * 개발 서버는 처음 여는 화면을 만드는 동안 빈 문서를 준다. 빈 화면을 단계로 찍으면 사용자에게 보여 줄 그림이 없다(10/15 실험실)
83
+ */
84
+ async function ready(page) {
85
+ await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
86
+ await page.waitForFunction(() => document.body && document.body.innerText.trim().length > 0, null, { timeout: 30000 }).catch(() => {});
87
+ await page.waitForTimeout(300);
88
+ }
89
+
90
+ const sameLabel = (a, b) => {
91
+ const n = (s) => (s || "").replace(/\s+/g, " ").trim().toLowerCase();
92
+ return n(a) === n(b) || (n(a) && n(b) && (n(a).includes(n(b)) || n(b).includes(n(a))));
93
+ };
94
+
95
+ class PathRecorder {
96
+ constructor(hub) {
97
+ this.hub = hub;
98
+ /** taskId → { research, task } — path_tasks 로 받아 둔다 */
99
+ this.known = new Map();
100
+ this.cur = null;
101
+ }
102
+
103
+ /** 리서치의 과제들 */
104
+ async tasks(researchId) {
105
+ const r = await report.getResearchTasks(researchId);
106
+ for (const t of r.tasks) this.known.set(String(t.taskId), { research: r.research, task: t });
107
+ const cond = (t) =>
108
+ 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`;
109
+ return [
110
+ `Research "${r.research.title}" (${r.research.status})${r.research.website ? ` — site ${r.research.website}` : ""}`,
111
+ r.research.purpose ? `Purpose: ${r.research.purpose}` : "",
112
+ "",
113
+ ...r.tasks.map((t) =>
114
+ [
115
+ `### Task ${t.taskId} — ${t.title}`,
116
+ `- what the participant is told: ${t.infoText || "(nothing)"}`,
117
+ `- success: ${cond(t)}`,
118
+ `- expected path: ${t.pathSteps ? `${t.pathSteps} steps recorded (recording again replaces them)` : "NONE — results cannot tell going straight from a detour"}`,
119
+ ].join("\n"),
120
+ ),
121
+ ]
122
+ .filter((x) => x !== "")
123
+ .join("\n");
124
+ }
125
+
126
+ async open(s, { device, account }) {
127
+ let storageState;
128
+ if (account) {
129
+ const a = ws.accounts().find((x) => x.account === account);
130
+ if (!a) throw new Error(`No signed-in demo account "${account}". Run login first, or leave account out.`);
131
+ storageState = a.path;
132
+ }
133
+ await this.hub.ensureRobots(s);
134
+ return this.hub.context(s, { device, storageState, debugCards: false });
135
+ }
136
+
137
+ /** 새로 기록을 시작한다. 같은 과제를 다시 하면 처음부터 */
138
+ async start({ task, url, device = "desktop", account = null }) {
139
+ const k = this.known.get(String(task));
140
+ if (!k) throw new Error(`Task ${task} is not known yet. Call path_tasks with the research id first.`);
141
+ const full = /^https?:\/\//i.test(url) ? url : new URL(url, k.research.website || undefined).toString();
142
+ await this.stop();
143
+ const s = this.hub.site(full);
144
+ const ctx = await this.open(s, { device, account });
145
+ const page = await ctx.newPage();
146
+ ws.ensure();
147
+ const dir = path.join(ws.DIR, "paths", String(task));
148
+ fs.rmSync(dir, { recursive: true, force: true });
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ this.cur = { taskId: String(task), title: k.task.title, s, ctx, page, net: watchNet(page), dir, startUrl: full, device, account, steps: [], actions: [], view: null, checked: null };
151
+ await page.goto(full, { waitUntil: "domcontentloaded", timeout: 90000 });
152
+ await ready(page);
153
+ await this.recordPage();
154
+ return this.screen(`Recording the expected path for task ${task} ("${k.task.title}"). Step 1 is this page.`);
155
+ }
156
+
157
+ need() {
158
+ if (!this.cur) throw new Error("No path is being recorded. Call path_start first.");
159
+ return this.cur;
160
+ }
161
+
162
+ async shot(file, handle = null) {
163
+ const c = this.cur;
164
+ const opts = { type: "jpeg", quality: 65, style: HIDE_OWN, timeout: 15000 };
165
+ try {
166
+ if (handle) return await handle.screenshot({ ...opts, path: file });
167
+ } catch {}
168
+ return c.page.screenshot({ ...opts, path: file });
169
+ }
170
+
171
+ /** 지금 페이지를 경로 단계로 — 바로 앞 페이지 단계와 같은 경로면 넘긴다 */
172
+ async recordPage() {
173
+ const c = this.cur;
174
+ const p = pathOnly(c.page.url());
175
+ const lastPage = [...c.steps].reverse().find((x) => x.type === "URL");
176
+ if (lastPage && lastPage.concrete === p) return null;
177
+ const file = path.join(c.dir, `${String(c.steps.length + 1).padStart(2, "0")}-page.jpg`);
178
+ await this.shot(file);
179
+ const step = { type: "URL", url: p, concrete: p, image: file };
180
+ c.steps.push(step);
181
+ c.checked = null;
182
+ return step;
183
+ }
184
+
185
+ async screen(head) {
186
+ const c = this.cur;
187
+ const view = await listControls(c.page);
188
+ c.view = view;
189
+ const text = await visibleText(c.page);
190
+ // 빈 화면을 단계로 남기면 사용자에게 보여 줄 그림이 비고, 다시 걸어도 같은 걸 못 찾는다
191
+ const empty = !text.trim() && !view.items.length;
192
+ const warn = empty
193
+ ? "⚠ The page shows nothing — it may still be loading or the site returned an error. Do not build the path on this: call path_start again (or open the page in a browser to see what is wrong)."
194
+ : "";
195
+ return [head, warn, "", this.stepsText(), "", renderScreen({ view, text })].filter((x, i) => i !== 1 || x).join("\n");
196
+ }
197
+
198
+ stepsText() {
199
+ const c = this.cur;
200
+ return [
201
+ `## Path so far (${c.steps.length} steps)`,
202
+ ...c.steps.map((s, i) => `${i + 1}. ${s.type === "URL" ? `page ${s.url}` : `${s.pressed === false ? "final click (not pressed)" : "click"} "${s.label || s.tag}" on ${s.url}${s.classes.length || s.ids.length ? "" : " (matched by position only)"}`}`),
203
+ ].join("\n");
204
+ }
205
+
206
+ /** 누른다 — 기본은 경로 단계로 남긴다. record=false 는 길에 끼지 않는 것(쿠키 배너 닫기 등) */
207
+ async click({ index, record = true, press = true }) {
208
+ const c = this.need();
209
+ if (c.steps.length >= MAX_STEPS) throw new Error(`A path has at most ${MAX_STEPS} steps.`);
210
+ const item = c.view && c.view.items[index];
211
+ const el = item ? await handleAt(c.page, index) : null;
212
+ if (!el) return this.screen(`[${index}] is not on the screen any more. Here is the current screen.`);
213
+ const blocked = press ? clickBlocked(item, { policy: c.s.policy }) : null;
214
+ if (blocked)
215
+ return this.screen(
216
+ `Not pressed — ${blocked}. If this is where the participant's task ends (e.g. the final "Pay" or "Submit" button), call path_click again with press=false: it is recorded as the final click without pressing it.`,
217
+ );
218
+ const pickH = await el.evaluateHandle((n, sel) => (n.closest && n.closest(sel)) || n, PICK_SEL);
219
+ const info = await el.evaluate(describeInPage, PICK_SEL);
220
+ if (!info.xpath) return this.screen(`Could not work out where [${index}] is in the page. Try another element.`);
221
+ const at = pathOnly(c.page.url());
222
+ let step = null;
223
+ if (record) {
224
+ const file = path.join(c.dir, `${String(c.steps.length + 1).padStart(2, "0")}-click.jpg`);
225
+ await pickH.asElement().scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
226
+ await this.shot(file, pickH.asElement());
227
+ step = { type: "XPATH", url: at, concrete: at, xpath: info.xpath, classes: info.classes, ids: info.ids, label: info.label, tag: info.tag, image: file, pressed: press };
228
+ c.steps.push(step);
229
+ }
230
+ c.actions.push({ do: "click", xpath: info.xpath, label: info.label, press, step: step ? c.steps.length - 1 : null });
231
+ c.checked = null;
232
+ if (!press) return this.screen(`Recorded "${info.label || info.tag}" as the final click, without pressing it. The path ends here — call path_check next.`);
233
+ const before = c.page.url();
234
+ try {
235
+ await pickH.asElement().click({ timeout: 10000 });
236
+ } catch (e) {
237
+ if (step) c.steps.pop();
238
+ c.actions.pop();
239
+ return this.screen(`Clicking "${info.label || info.tag}" failed (${String(e.message).split("\n")[0]}). Nothing was recorded.`);
240
+ }
241
+ await settle(c.page, before, c.net);
242
+ await ready(c.page);
243
+ const page = await this.recordPage();
244
+ // 다시 걸을 때 이 클릭 뒤에 닿아야 할 페이지(길에 끼지 않는 클릭이 페이지를 바꿨어도)
245
+ c.actions[c.actions.length - 1].pageStep = page ? c.steps.length - 1 : null;
246
+ return this.screen(`Clicked "${info.label || info.tag}"${record ? "" : " (not part of the path)"}.${page ? ` Now on ${page.url}.` : ""}`);
247
+ }
248
+
249
+ /** 칸에 친다 — 경로 단계는 아니지만 다시 걸을 때 똑같이 친다(검색어 등) */
250
+ async type({ index, text }) {
251
+ const c = this.need();
252
+ const el = c.view && c.view.items[index] ? await handleAt(c.page, index) : null;
253
+ if (!el) return this.screen(`[${index}] is not on the screen any more.`);
254
+ const info = await el.evaluate(describeInPage, "input,textarea,select,[contenteditable]");
255
+ await el.fill(text, { timeout: 10000 });
256
+ c.actions.push({ do: "type", xpath: info.xpath, text });
257
+ c.checked = null;
258
+ return this.screen(`Typed into "${info.label || info.tag}" (not a path step; it is repeated when the path is checked).`);
259
+ }
260
+
261
+ async scroll({ up = false }) {
262
+ const c = this.need();
263
+ await c.page.mouse.wheel(0, (up ? -1 : 1) * Math.round((c.view?.viewport || 800) * 0.8));
264
+ await c.page.waitForTimeout(400);
265
+ return this.screen(`Scrolled ${up ? "up" : "down"}.`);
266
+ }
267
+
268
+ /** 페이지 단계의 경로를 패턴으로(`/notice/123` → `/notice/:id`) — 참여자마다 다른 글을 열어도 맞게 */
269
+ async pattern({ step, url }) {
270
+ const c = this.need();
271
+ const s = c.steps[step - 1];
272
+ if (!s) throw new Error(`There is no step ${step}.`);
273
+ if (!url.startsWith("/")) throw new Error("The pattern must be a path starting with /");
274
+ if (!patternOf(url).test(s.concrete)) throw new Error(`"${url}" does not match the page that was recorded (${s.concrete}).`);
275
+ let n = 0;
276
+ for (const x of c.steps)
277
+ if (x.concrete === s.concrete) {
278
+ x.url = url;
279
+ n++;
280
+ }
281
+ c.checked = null;
282
+ return `${n} step(s) on ${s.concrete} now use ${url}.\n\n${this.stepsText()}`;
283
+ }
284
+
285
+ /** 새 브라우저로 같은 길을 다시 걷는다. 요소가 그대로 찾아지고 같은 페이지에 닿아야 통과 */
286
+ async check() {
287
+ const c = this.need();
288
+ if (!c.steps.length) throw new Error("Nothing recorded yet.");
289
+ const ctx = await this.open(c.s, { device: c.device, account: c.account });
290
+ const lines = [];
291
+ let ok = true;
292
+ try {
293
+ const page = await ctx.newPage();
294
+ const net = watchNet(page);
295
+ await page.goto(c.startUrl, { waitUntil: "domcontentloaded", timeout: 90000 });
296
+ await ready(page);
297
+ const pageCheck = (k) => {
298
+ const s = c.steps[k];
299
+ const here = pathOnly(page.url());
300
+ const hit = patternOf(s.url).test(here);
301
+ lines.push(`${hit ? "✓" : "✗"} ${k + 1}. page ${s.url}${hit ? "" : ` — landed on ${here} instead`}`);
302
+ return hit;
303
+ };
304
+ ok = pageCheck(0);
305
+ for (const a of c.actions) {
306
+ if (!ok) break;
307
+ const h = (await page.evaluateHandle(byXPathInPage, a.xpath)).asElement();
308
+ const visible = h ? await h.isVisible().catch(() => false) : false;
309
+ 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)) : "";
310
+ const no = a.step !== null ? `${a.step + 1}. ` : " ";
311
+ if (!h || !visible) {
312
+ lines.push(`✗ ${no}${a.do} "${a.label || a.text || ""}" — ${h ? "found but not visible" : "not found at its recorded position"} (${a.xpath})`);
313
+ ok = false;
314
+ break;
315
+ }
316
+ if (a.do === "type") {
317
+ await h.fill(a.text, { timeout: 10000 });
318
+ lines.push(`✓ typed "${a.text}"`);
319
+ continue;
320
+ }
321
+ if (!sameLabel(label, a.label)) {
322
+ // 길에 끼지 않는 클릭(목록에서 하나 고르기)은 다른 항목이어도 된다 — 뒤의 페이지 패턴이 맞는지 본다
323
+ if (a.step === null) lines.push(`· picked "${label}" this time (recorded "${a.label}") — fine for a pick that is not a step`);
324
+ else {
325
+ lines.push(`✗ ${no}click "${a.label}" — that position now holds "${label}" (the page differs between visits, e.g. list order)`);
326
+ ok = false;
327
+ break;
328
+ }
329
+ }
330
+ if (!a.press) {
331
+ lines.push(`✓ ${no}final click "${a.label}" is there (not pressed)`);
332
+ continue;
333
+ }
334
+ const before = page.url();
335
+ try {
336
+ await h.click({ timeout: 10000 });
337
+ } catch (e) {
338
+ lines.push(`✗ ${no}click "${a.label}" — ${String(e.message).split("\n")[0]}`);
339
+ ok = false;
340
+ break;
341
+ }
342
+ await settle(page, before, net);
343
+ await ready(page);
344
+ lines.push(`✓ ${no}click "${a.step === null ? label : a.label}"`);
345
+ // 기록할 때 이 클릭 뒤에 새 페이지에 닿았으면 다시 걸어도 거기 닿아야 한다
346
+ if (a.pageStep != null) ok = pageCheck(a.pageStep);
347
+ }
348
+ } finally {
349
+ await ctx.close().catch(() => {});
350
+ }
351
+ c.checked = ok ? this.signature() : null;
352
+ return [
353
+ ok ? "PASSED — the path walks the same way in a fresh browser." : "FAILED — fix the path (record it again, or use path_pattern for pages whose address changes) before saving.",
354
+ ...lines,
355
+ "",
356
+ "Screenshots to show the user (in order):",
357
+ ...c.steps.map((s, i) => `${i + 1}. ${s.image}`),
358
+ ].join("\n");
359
+ }
360
+
361
+ signature() {
362
+ return JSON.stringify(this.cur.steps.map((s) => [s.type, s.url, s.xpath || ""]).concat(this.cur.actions.map((a) => [a.do, a.xpath, a.text || ""])));
363
+ }
364
+
365
+ /** 올린다. 다시 걸어 통과한 그대로여야 한다 */
366
+ async save({ success = "keep" }) {
367
+ const c = this.need();
368
+ if (!c.checked || c.checked !== this.signature()) throw new Error("Run path_check first — only a path that passed, unchanged since, can be saved.");
369
+ const body = {
370
+ steps: c.steps.map((s) => ({ type: s.type, url: s.url, xpath: s.xpath || "", classes: s.classes || [], ids: s.ids || [], image: fs.readFileSync(s.image).toString("base64") })),
371
+ };
372
+ if (success === "page") {
373
+ const last = [...c.steps].reverse().find((s) => s.type === "URL");
374
+ body.success = { condition: "url_entry", targetUrl: last.url };
375
+ } else if (success === "click") {
376
+ const last = [...c.steps].reverse().find((s) => s.type === "XPATH");
377
+ if (!last) throw new Error("There is no recorded click to use as the success condition.");
378
+ body.success = { condition: "element_click", xpath: last.xpath, xpathUrl: last.url, classes: last.classes, ids: last.ids };
379
+ }
380
+ const r = await report.savePath(c.taskId, body);
381
+ const done = `Saved the expected path for task ${c.taskId} ("${c.title}"): ${r.steps} steps${r.success ? `, success condition set to ${r.success === "url_entry" ? `reaching ${body.success.targetUrl}` : `clicking "${[...c.steps].reverse().find((s) => s.type === "XPATH").label}"`}` : ""}. The dashboard's task results now separate going straight from detours.`;
382
+ await this.stop();
383
+ return done;
384
+ }
385
+
386
+ async stop() {
387
+ if (!this.cur) return;
388
+ this.cur.net.stop();
389
+ await this.cur.ctx.close().catch(() => {});
390
+ this.cur = null;
391
+ }
392
+ }
393
+
394
+ module.exports = { PathRecorder, patternOf };
package/mcp/report.js ADDED
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ /**
3
+ * 방문 기록을 어닝버드 서버로 올린다 — 대시보드 "방문"·"가상 사용자 실행" 화면이 이걸 읽는다.
4
+ *
5
+ * 프로젝트 API 키(`USERCASE_API_KEY`, 대시보드 설정 → 연동 → API 키)가 있을 때만 보낸다. 키가 프로젝트를 정한다.
6
+ * 키가 없으면 기록은 지금처럼 고객 컴퓨터(.usercase/runs)에만 남는다.
7
+ * 못 보낸 기록은 .usercase/outbox.jsonl 에 두었다가 다음 방문이 끝날 때 다시 보낸다(서버는 같은 id 를 한 번만 받는다).
8
+ */
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const crypto = require("crypto");
12
+ const ws = require("./workspace.js");
13
+
14
+ const KEY_ENV = "USERCASE_API_KEY";
15
+ const URL_ENV = "USERCASE_API_URL";
16
+ const DEFAULT_API = "https://app.usercase.io/koa";
17
+ const KEY_RE = /^eb_sk_[A-Za-z0-9]{32}$/;
18
+ const OUTBOX = path.join(ws.DIR, "outbox.jsonl");
19
+
20
+ const apiKey = () => (process.env[KEY_ENV] || "").trim();
21
+ const apiBase = () => (process.env[URL_ENV] || DEFAULT_API).replace(/\/$/, "");
22
+
23
+ /** 이 MCP 세션 하나 = 실행 하나. 대시보드가 실행별로 묶는다 */
24
+ const RUN_REF = `run_${new Date().toISOString().slice(0, 10)}_${crypto.randomBytes(6).toString("hex")}`;
25
+
26
+ async function call(method, route, body, timeoutMs = 15000) {
27
+ const res = await fetch(`${apiBase()}${route}`, {
28
+ method,
29
+ headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" },
30
+ body: body ? JSON.stringify(body) : undefined,
31
+ signal: AbortSignal.timeout(timeoutMs),
32
+ });
33
+ const text = await res.text();
34
+ let json = null;
35
+ try {
36
+ json = JSON.parse(text);
37
+ } catch {}
38
+ if (!res.ok) throw Object.assign(new Error((json && json.error) || `HTTP ${res.status}`), { status: res.status });
39
+ return json;
40
+ }
41
+
42
+ /** 키 상태 한 줄 — check_site 가 보여 준다. sdkProject 가 있으면 키의 프로젝트와 맞는지 본다 */
43
+ async function describeKey(sdkProject) {
44
+ const key = apiKey();
45
+ if (!key) return `· No API key (${KEY_ENV}). Visits stay on this computer and are not sent to the dashboard. Create one in the dashboard: Settings → Integrations → API keys`;
46
+ if (!KEY_RE.test(key)) return `⚠ ${KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard`;
47
+ try {
48
+ const me = await call("GET", "/agent/me");
49
+ if (sdkProject && me.projectId !== sdkProject)
50
+ return `⚠ The API key belongs to the "${me.title}" project, but the SDK on this site sends to another project (${sdkProject}). Visits and card answers would land in different projects. Use the key for the right project`;
51
+ return `✓ API key — visits are sent to the "${me.title}" project`;
52
+ } catch (e) {
53
+ if (e.status === 401) return `⚠ The API key is wrong or revoked. Create a new one in the dashboard`;
54
+ return `⚠ Could not reach the Usercase server (${e.message}). Visits can still run; records will be sent later`;
55
+ }
56
+ }
57
+
58
+ /** 키의 프로젝트와 그 설치 스크립트(서버가 대시보드 설치 화면과 같은 글을 준다) — install_snippet 이 쓴다 */
59
+ async function getInstall() {
60
+ const key = apiKey();
61
+ if (!key) throw new Error(`No API key (${KEY_ENV}). Create one in the dashboard (Settings → Integrations → API keys), then add this MCP server again with -e ${KEY_ENV}=<key>`);
62
+ if (!KEY_RE.test(key)) throw new Error(`${KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard`);
63
+ try {
64
+ const me = await call("GET", "/agent/me");
65
+ if (!me.snippet) throw new Error("The Usercase server did not return a script. It may be an older server version");
66
+ return me;
67
+ } catch (e) {
68
+ if (e.status === 401) throw new Error("The API key is wrong or revoked. Create a new one in the dashboard");
69
+ throw e;
70
+ }
71
+ }
72
+
73
+ function readOutbox() {
74
+ if (!fs.existsSync(OUTBOX)) return [];
75
+ return fs
76
+ .readFileSync(OUTBOX, "utf8")
77
+ .split("\n")
78
+ .filter(Boolean)
79
+ .map((l) => {
80
+ try {
81
+ return JSON.parse(l);
82
+ } catch {
83
+ return null;
84
+ }
85
+ })
86
+ .filter(Boolean);
87
+ }
88
+ function writeOutbox(items) {
89
+ ws.ensure();
90
+ if (!items.length) {
91
+ if (fs.existsSync(OUTBOX)) fs.unlinkSync(OUTBOX);
92
+ return;
93
+ }
94
+ fs.writeFileSync(OUTBOX, items.map((x) => JSON.stringify(x)).join("\n") + "\n");
95
+ }
96
+
97
+ /** 방문 하나를 서버로 보낼 모양 — 사람의 이름은 없다(번호만) */
98
+ function payload(row, person, startedAt) {
99
+ return {
100
+ id: crypto.randomUUID(),
101
+ run: RUN_REF,
102
+ tool: "claude-code",
103
+ site: row.site,
104
+ person: person.id,
105
+ personUid: person.uid,
106
+ persona: {
107
+ background: person.background,
108
+ goal: person.goal,
109
+ moment: person.moment,
110
+ attention: person.attention,
111
+ surveyHabit: person.surveyHabit,
112
+ writing: person.writing,
113
+ arrival: person.arrival,
114
+ member: !!person.member,
115
+ traits: person.traits,
116
+ },
117
+ device: person.device,
118
+ entry: row.entry,
119
+ steps: row.steps,
120
+ card: row.card,
121
+ ms: row.ms,
122
+ startedAt: new Date(startedAt).toISOString(),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * 방문 하나를 보내고, 전에 못 보낸 것도 같이 보낸다. 실패해도 방문은 막지 않는다 — 결과 한 줄만 돌려준다.
128
+ * @returns 요약에 붙일 한 줄, 키가 없으면 null
129
+ */
130
+ async function sendVisit(row, person, startedAt) {
131
+ if (!KEY_RE.test(apiKey())) return null;
132
+ const queue = [...readOutbox(), payload(row, person, startedAt)];
133
+ const left = [];
134
+ let sent = 0;
135
+ let lastErr = null;
136
+ for (const item of queue) {
137
+ try {
138
+ await call("POST", "/agent/visits", item);
139
+ sent++;
140
+ } catch (e) {
141
+ lastErr = e;
142
+ // 키가 틀렸으면 다시 보내도 소용없다 — 그래도 버리지 않고 남겨 둔다(키를 고치면 다음에 간다)
143
+ left.push(item);
144
+ }
145
+ }
146
+ writeOutbox(left);
147
+ if (!left.length) return `· Sent to the dashboard${sent > 1 ? ` (${sent} including earlier ones)` : ""}`;
148
+ return `⚠ Could not upload to the dashboard (${lastErr?.status === 401 ? "API key rejected" : lastErr?.message}) — ${left.length} kept in ${OUTBOX}, will retry next time`;
149
+ }
150
+
151
+ /**
152
+ * 도구가 하지 못한 것들(진단)을 보낸다 — 우리 도구를 고치려고. 사이트 글자·주소는 없다(src/run/diag.js).
153
+ * 키가 있고 꺼 두지 않았을 때만. 실패해도 조용히 넘어간다 — 방문과는 상관없다
154
+ */
155
+ async function sendDiagnostics(events, device) {
156
+ const diag = require("../src/run/diag.js");
157
+ if (!events || !events.length || !diag.enabled() || !KEY_RE.test(apiKey())) return 0;
158
+ try {
159
+ const out = await call("POST", "/agent/diagnostics", { run: RUN_REF, version: require("../package.json").version, device, events: events.slice(0, 100) });
160
+ return (out && out.saved) || 0;
161
+ } catch {
162
+ return 0;
163
+ }
164
+ }
165
+
166
+ /** 키가 있어야 하는 호출 — 없거나 틀렸으면 사용자에게 할 말로 바꿔 던진다 */
167
+ async function keyed(method, route, body, timeoutMs) {
168
+ const key = apiKey();
169
+ if (!key) throw new Error(`No API key (${KEY_ENV}). Create one in the dashboard (Settings → Integrations → API keys), then add this MCP server again with -e ${KEY_ENV}=<key>`);
170
+ if (!KEY_RE.test(key)) throw new Error(`${KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard`);
171
+ try {
172
+ return await call(method, route, body, timeoutMs);
173
+ } catch (e) {
174
+ if (e.status === 401) throw new Error("The API key is wrong or revoked. Create a new one in the dashboard");
175
+ if (e.status === 404) throw new Error(`${e.message}. Check the id — it must belong to the project of this API key`);
176
+ throw e;
177
+ }
178
+ }
179
+
180
+ /** 리서치의 과제들과 지금 경로가 있는지 — 경로 설정(path)이 쓴다 */
181
+ const getResearchTasks = (researchId) => keyed("GET", `/agent/researches/${encodeURIComponent(researchId)}/tasks`);
182
+
183
+ /** 과제의 정상 경로를 통째로 바꾼다. 화면을 올리느라 오래 걸릴 수 있다 */
184
+ const savePath = (taskId, body) => keyed("PUT", `/agent/tasks/${encodeURIComponent(taskId)}/path`, body, 90000);
185
+
186
+ /** 질문 만들기(question) — 최근 방문을 읽어야 해서 조금 걸린다 */
187
+ const getQuestionContext = () => keyed("GET", "/agent/questions/context", null, 60000);
188
+ const checkQuestion = (condition) => keyed("POST", "/agent/questions/check", { condition }, 60000);
189
+ const saveQuestion = (body) => keyed("POST", "/agent/questions", body, 60000);
190
+ /** 대시보드 주소. API 주소에서 /koa 를 뗀다 */
191
+ const dashboardUrl = (p) => apiBase().replace(/\/koa$/, "") + p;
192
+
193
+ module.exports = {
194
+ KEY_ENV,
195
+ URL_ENV,
196
+ RUN_REF,
197
+ describeKey,
198
+ getInstall,
199
+ sendVisit,
200
+ sendDiagnostics,
201
+ getResearchTasks,
202
+ savePath,
203
+ getQuestionContext,
204
+ checkQuestion,
205
+ saveQuestion,
206
+ dashboardUrl,
207
+ };