@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/README.md +142 -2
- package/agents/install.md +26 -0
- package/agents/path.md +49 -0
- package/agents/question.md +62 -0
- package/agents/research.md +113 -0
- package/agents/usercase-visitor.md +112 -0
- package/mcp/hub.js +760 -0
- package/mcp/path.js +394 -0
- package/mcp/report.js +207 -0
- package/mcp/server.js +679 -0
- package/mcp/workspace.js +182 -0
- package/package.json +41 -4
- package/src/browser/blocked.js +180 -0
- package/src/browser/card.js +269 -0
- package/src/browser/guard.js +211 -0
- package/src/browser/hand.js +368 -0
- package/src/browser/open.js +59 -0
- package/src/browser/page.js +353 -0
- package/src/browser/policy.js +154 -0
- package/src/browser/real-chrome.js +242 -0
- package/src/browser/robots.js +73 -0
- package/src/browser/signin.js +66 -0
- package/src/persona/people.js +104 -0
- package/src/persona/persona.js +469 -0
- package/src/prompts/answer.md +50 -0
- package/src/prompts/people.md +38 -0
- package/src/prompts/step.md +36 -0
- package/src/run/answer.js +94 -0
- package/src/run/diag.js +64 -0
- package/src/run/entry.js +76 -0
- package/src/run/runner.js +205 -0
- package/src/run/visit.js +693 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 페르소나 성향과 리듬.
|
|
4
|
+
*
|
|
5
|
+
* 실제 방문 데이터를 베끼지 않는다(9/16 사용자 지시). 사람이 화면을 보고 움직일 때
|
|
6
|
+
* 무엇이 시간을 잡아먹는지를 골라 모델로 만든다: 읽을 글이 얼마나 새로 들어왔나,
|
|
7
|
+
* 그 사람이 얼마나 빨리 읽나, 지금 잘 풀리고 있나.
|
|
8
|
+
*
|
|
9
|
+
* 성향값은 밖에서 넣는 게 제 모습이다. 그 사람이 누구인지(페르소나 설명)와
|
|
10
|
+
* 그 사이트가 어떤 곳인지(글이 많은 리서치 사이트인지, 몇 번 눌러 끝나는 도구인지)를
|
|
11
|
+
* 읽고 만들어 넣는다. MCP 로 팔 때는 고객이 준 페르소나가 그대로 여기로 들어온다.
|
|
12
|
+
* 넣은 게 없으면 페르소나 이름에서 결정적으로 뽑는다 — 뜻은 없지만 세션마다 흔들리진 않는다.
|
|
13
|
+
*
|
|
14
|
+
* 한번 정해진 성향은 그 사람에게 붙는다. profiles/<프로필>/traits.json 에 남겨
|
|
15
|
+
* 재방문해도 같은 사람으로 움직이게 하고, 방문 횟수(익숙한 정도)를 쌓는다.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
// ── 씨앗 난수 ────────────────────────────────────────────
|
|
22
|
+
function seedOf(str) {
|
|
23
|
+
let h = 1779033703 ^ str.length;
|
|
24
|
+
for (let i = 0; i < str.length; i++) {
|
|
25
|
+
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
|
26
|
+
h = (h << 13) | (h >>> 19);
|
|
27
|
+
}
|
|
28
|
+
return (h ^ (h >>> 16)) >>> 0;
|
|
29
|
+
}
|
|
30
|
+
function mulberry32(a) {
|
|
31
|
+
return function () {
|
|
32
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
33
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
34
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
35
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
39
|
+
|
|
40
|
+
/** 가운데가 두꺼운 분포. 극단이 드물지만 아주 안 나오진 않는다 */
|
|
41
|
+
function centered(rnd, lo, hi) {
|
|
42
|
+
const u = (rnd() + rnd()) / 2;
|
|
43
|
+
return lo + (hi - lo) * u;
|
|
44
|
+
}
|
|
45
|
+
/** 곱셈 흔들림. 가끔 길게 끌리는 꼬리가 있다 */
|
|
46
|
+
function jitter(rnd, sigma) {
|
|
47
|
+
const u = Math.max(1e-9, rnd());
|
|
48
|
+
const v = rnd();
|
|
49
|
+
const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
50
|
+
return Math.exp(z * sigma);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── 성향값 ───────────────────────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* 성향값의 정의. 밖에서(사람이든 AI 든) 성향을 만들어 넣을 때 읽는 규격이다.
|
|
56
|
+
* 사이트를 이해한 쪽이 이 칸을 채워 넣는다. `driver.js traits` 로 그대로 꺼내 볼 수 있다.
|
|
57
|
+
*/
|
|
58
|
+
const TRAIT_SPEC = {
|
|
59
|
+
readCps: {
|
|
60
|
+
min: 6,
|
|
61
|
+
max: 60,
|
|
62
|
+
unit: "chars/s",
|
|
63
|
+
what: "Reading speed: how many characters per second of newly visible text they take in (skimming included, not only careful reading)",
|
|
64
|
+
how: "Low for someone digging for information on a dense research or docs site; high for someone who only skims lists",
|
|
65
|
+
},
|
|
66
|
+
patience: {
|
|
67
|
+
min: 0.05,
|
|
68
|
+
max: 1,
|
|
69
|
+
what: "Patience: how long they put up with clicks that do nothing or lead nowhere",
|
|
70
|
+
how: "Low means they leave after a few dead ends. High for someone who badly needs what they came for",
|
|
71
|
+
},
|
|
72
|
+
decisiveness: {
|
|
73
|
+
min: 0.05,
|
|
74
|
+
max: 1,
|
|
75
|
+
what: "Decisiveness: how quickly they click once they have looked",
|
|
76
|
+
how: "Lower on sites with many choices that are hard to undo. High for someone who knows what to click",
|
|
77
|
+
},
|
|
78
|
+
distraction: {
|
|
79
|
+
min: 0,
|
|
80
|
+
max: 0.7,
|
|
81
|
+
what: "Distraction: how often long pauses and side tasks come in",
|
|
82
|
+
how: "High for someone glancing at it during work, or lying down with a phone",
|
|
83
|
+
},
|
|
84
|
+
thoroughness: {
|
|
85
|
+
min: 0.05,
|
|
86
|
+
max: 1,
|
|
87
|
+
what: "Thoroughness: drives scroll depth and session length",
|
|
88
|
+
how: "Low means they bounce after the first screen. High for someone comparing options",
|
|
89
|
+
},
|
|
90
|
+
mobileEase: {
|
|
91
|
+
min: 0.05,
|
|
92
|
+
max: 1,
|
|
93
|
+
what: "Device habit: how comfortable they are on a small screen",
|
|
94
|
+
how: "Not used for desktop sessions. High for someone whose main device is a phone",
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const TRAIT_KEYS = Object.keys(TRAIT_SPEC);
|
|
98
|
+
|
|
99
|
+
/** 밖에서 들어온 값을 규격 안으로 들인다. 빠진 칸은 이름 씨앗으로 메운다 */
|
|
100
|
+
function coerceTraits(given, personaName) {
|
|
101
|
+
const fallback = seedTraits(personaName);
|
|
102
|
+
const out = {};
|
|
103
|
+
const notes = [];
|
|
104
|
+
for (const k of TRAIT_KEYS) {
|
|
105
|
+
const spec = TRAIT_SPEC[k];
|
|
106
|
+
const v = given ? Number(given[k]) : NaN;
|
|
107
|
+
if (!Number.isFinite(v)) {
|
|
108
|
+
out[k] = fallback[k];
|
|
109
|
+
if (given) notes.push(`${k} missing — used seed value ${fallback[k]}`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const c = Math.min(spec.max, Math.max(spec.min, v));
|
|
113
|
+
if (c !== v) notes.push(`${k} ${v} → ${c} (range ${spec.min}–${spec.max})`);
|
|
114
|
+
out[k] = r2(c);
|
|
115
|
+
}
|
|
116
|
+
if (given) {
|
|
117
|
+
const unknown = Object.keys(given).filter((k) => !TRAIT_KEYS.includes(k));
|
|
118
|
+
if (unknown.length) notes.push(`Dropped unknown fields: ${unknown.join(", ")}`);
|
|
119
|
+
}
|
|
120
|
+
return { traits: out, notes };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 아무도 성향을 안 줬을 때. 이름에서 고정으로 뽑는다 — 뜻은 없고 흔들리지만 않는다 */
|
|
124
|
+
function seedTraits(personaName) {
|
|
125
|
+
const rnd = mulberry32(seedOf(personaName));
|
|
126
|
+
return {
|
|
127
|
+
readCps: r2(centered(rnd, 9, 46)),
|
|
128
|
+
patience: r2(centered(rnd, 0.12, 0.95)),
|
|
129
|
+
decisiveness: r2(centered(rnd, 0.1, 0.95)),
|
|
130
|
+
distraction: r2(centered(rnd, 0.02, 0.55)),
|
|
131
|
+
thoroughness: r2(centered(rnd, 0.1, 0.95)),
|
|
132
|
+
mobileEase: r2(centered(rnd, 0.1, 0.95)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 프로필 폴더에 붙여 둔다.
|
|
138
|
+
* 재방문이면 저장된 성향을 그대로 쓴다 — 같은 사람이 돌아온 것이다. 방문 횟수만 올린다.
|
|
139
|
+
* 다시 정하려면 force 로 덮는다.
|
|
140
|
+
*/
|
|
141
|
+
function loadTraits(profileDir, personaName, given, force) {
|
|
142
|
+
const file = path.join(profileDir, "traits.json");
|
|
143
|
+
let saved = null;
|
|
144
|
+
try {
|
|
145
|
+
saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
146
|
+
} catch (e) {}
|
|
147
|
+
const notes = [];
|
|
148
|
+
let traits;
|
|
149
|
+
let source;
|
|
150
|
+
if (saved && saved.traits && !force) {
|
|
151
|
+
traits = coerceTraits(saved.traits, personaName).traits;
|
|
152
|
+
source = saved.source || "seed";
|
|
153
|
+
if (given) notes.push("This person already has traits; the given values were not used (use --retraits to reset)");
|
|
154
|
+
} else if (given) {
|
|
155
|
+
const c = coerceTraits(given, personaName);
|
|
156
|
+
traits = c.traits;
|
|
157
|
+
source = "given";
|
|
158
|
+
notes.push(...c.notes);
|
|
159
|
+
} else {
|
|
160
|
+
traits = seedTraits(personaName);
|
|
161
|
+
source = "seed";
|
|
162
|
+
notes.push("No traits given, so they were drawn from the persona name. Setting them from the site and persona is better");
|
|
163
|
+
}
|
|
164
|
+
const visits = (saved && Number(saved.visits)) || 0;
|
|
165
|
+
const out = { persona: (saved && saved.persona) || personaName, source, traits, visits: visits + 1 };
|
|
166
|
+
try {
|
|
167
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 2));
|
|
169
|
+
} catch (e) {}
|
|
170
|
+
return { ...out, notes };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const familiarityOf = (visits) => Math.min(0.85, Math.max(0, visits - 1) * 0.2);
|
|
174
|
+
|
|
175
|
+
// ── 이번 방문의 모양 ─────────────────────────────────────
|
|
176
|
+
/**
|
|
177
|
+
* 같은 사람도 올 때마다 다르게 온다. 한 화면 보고 나가는 방문이 가장 흔하고,
|
|
178
|
+
* 가끔 깊게 파며, 하던 중에 그냥 닫기도 한다.
|
|
179
|
+
*
|
|
180
|
+
* 비율은 **실제 방문 데이터에서 재지 않는다**(9/16 사용자 지시). 그 사람이 이 방문에
|
|
181
|
+
* 얼마나 걸 마음이 있나를 성향에서 만들고, 그 마음에서 모양이 나온다.
|
|
182
|
+
* 성향은 그 사람에게 고정이지만 모양은 세션마다 새로 뽑는다.
|
|
183
|
+
*/
|
|
184
|
+
function drawShape(rnd, t, familiarity) {
|
|
185
|
+
// 이 방문에 걸 마음. 꼼꼼할수록·참을성 있을수록 길게 보고, 익숙하면 조금 더 판다
|
|
186
|
+
const intent = Math.min(1, 0.6 * t.thoroughness + 0.25 * t.patience + 0.15 * familiarity);
|
|
187
|
+
// 튕김은 마음이 아주 약할 때만 흔하다. 0.7 에서 시작했더니 볼일이 분명한 사람도 41% 가 화면 두 개 안에 나갔다(10/14 실험실 75회).
|
|
188
|
+
// 실수로 들어온 사람·잠깐 보러 온 사람은 이 숫자가 아니라 자기 목적 때문에 금방 나간다 — 그건 방문자가 판단한다
|
|
189
|
+
const pBounce = Math.max(0.05, 0.4 - 0.45 * intent);
|
|
190
|
+
const pDig = Math.min(0.9, 0.15 + 0.5 * intent);
|
|
191
|
+
const u = rnd();
|
|
192
|
+
|
|
193
|
+
let kind;
|
|
194
|
+
let screens;
|
|
195
|
+
if (u < pBounce) {
|
|
196
|
+
kind = "bounce";
|
|
197
|
+
// 첫 화면에서 한 번은 더 움직여 볼 여지를 둔다. 1 이면 두 번째 화면에 닿자마자 "볼 만큼 봤다" 가 떴다
|
|
198
|
+
screens = 2;
|
|
199
|
+
} else if (u < pBounce + pDig) {
|
|
200
|
+
// 깊이는 미리 정하지 않는다. 한 쪽 보고 "한 쪽 더" 가 반복되고, 그 마음이 조금씩 식는다.
|
|
201
|
+
// 그래야 꼬리가 매끄럽게 길어진다(미리 정하면 중간 깊이가 통째로 비었다)
|
|
202
|
+
kind = "deep";
|
|
203
|
+
const keep = 0.45 + 0.4 * t.thoroughness;
|
|
204
|
+
screens = 5;
|
|
205
|
+
while (screens < 18 && rnd() < keep) screens++;
|
|
206
|
+
} else {
|
|
207
|
+
kind = "skim";
|
|
208
|
+
screens = 3 + Math.round(rnd() * 2);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 하던 중에 그냥 닫기. 산만한 사람에게 더 자주 온다. 튕김은 이미 한 화면이라 뺀다
|
|
212
|
+
let abandonAt = null;
|
|
213
|
+
if (kind !== "bounce" && rnd() < t.distraction * 0.45) {
|
|
214
|
+
abandonAt = Math.max(1, Math.round(screens * (0.3 + rnd() * 0.5)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { kind, screens, abandonAt, intent: r2(intent) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── 리듬 ─────────────────────────────────────────────────
|
|
221
|
+
const MIN_GAP_MS = 250;
|
|
222
|
+
const MAX_GAP_MS = 45000;
|
|
223
|
+
|
|
224
|
+
class Rhythm {
|
|
225
|
+
constructor({ traits, visits, device, seed, source }) {
|
|
226
|
+
this.t = traits;
|
|
227
|
+
this.visits = visits;
|
|
228
|
+
this.device = device;
|
|
229
|
+
this.source = source || "seed";
|
|
230
|
+
this.familiarity = familiarityOf(visits);
|
|
231
|
+
this.rnd = mulberry32(seed >>> 0);
|
|
232
|
+
// 세션 안에서 변하는 것
|
|
233
|
+
this.energy = 1; // 1 보다 크면 굼뜸(막혀서), 작으면 빨라짐(잘 풀려서)
|
|
234
|
+
this.patienceLeft = 1;
|
|
235
|
+
this.progress = 0;
|
|
236
|
+
this.stuck = 0;
|
|
237
|
+
// 화면에서 이미 읽은 띠
|
|
238
|
+
this.seen = null;
|
|
239
|
+
this.lastCmd = null;
|
|
240
|
+
// 이번 방문의 모양. 성향은 고정이지만 모양은 세션마다 새로 뽑는다
|
|
241
|
+
this.shape = drawShape(this.rnd, traits, this.familiarity);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 기기 습관. 작은 화면은 한 번에 덜 보이고 손이 빠르다 */
|
|
245
|
+
get cps() {
|
|
246
|
+
const t = this.t;
|
|
247
|
+
if (this.device === "desktop") return t.readCps;
|
|
248
|
+
const ease = 0.7 + 0.5 * t.mobileEase;
|
|
249
|
+
return t.readCps * ease * (this.device === "mobile" ? 0.85 : 0.92);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 처음 가는 화면에 닿았다 */
|
|
253
|
+
noteProgress() {
|
|
254
|
+
this.progress++;
|
|
255
|
+
this.stuck = 0;
|
|
256
|
+
this.energy = Math.max(0.55, this.energy * 0.92);
|
|
257
|
+
this.patienceLeft = Math.min(1, this.patienceLeft + 0.08);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 눌렀는데 그대로거나, 되돌아왔거나, 실패했다 */
|
|
261
|
+
noteFriction() {
|
|
262
|
+
this.stuck++;
|
|
263
|
+
const bite = 0.09 + 0.2 * (1 - this.t.patience);
|
|
264
|
+
this.patienceLeft = Math.max(0.1, this.patienceLeft - bite);
|
|
265
|
+
this.energy = Math.min(1.6, this.energy * 1.07);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 지금 화면에서 처음 보는 부분이 얼마나 되나.
|
|
270
|
+
* 스크롤은 대개 한 방향이라 읽은 범위를 띠 하나(min~max)로 본다.
|
|
271
|
+
*/
|
|
272
|
+
freshFraction(sig, top, vh) {
|
|
273
|
+
if (!this.seen || this.seen.sig !== sig) {
|
|
274
|
+
this.seen = { sig, top, bottom: top + vh };
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
const s = this.seen;
|
|
278
|
+
const bottom = top + vh;
|
|
279
|
+
const overlap = Math.max(0, Math.min(bottom, s.bottom) - Math.max(top, s.top));
|
|
280
|
+
const fresh = Math.max(0, Math.min(1, (vh - overlap) / Math.max(1, vh)));
|
|
281
|
+
s.top = Math.min(s.top, top);
|
|
282
|
+
s.bottom = Math.max(s.bottom, bottom);
|
|
283
|
+
return fresh;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 다음 조작까지의 간격.
|
|
288
|
+
* (집어 든 글 ÷ 읽는 속도 + 그림 훑기) × 지금 상태 + 누를지 정하는 시간 + 흔들림
|
|
289
|
+
*
|
|
290
|
+
* 화면에 보이는 글을 다 읽지는 않는다. 많이 깔려 있을수록 더 건너뛴다.
|
|
291
|
+
* 그래서 집어 드는 양은 글자 수에 비례하지 않고 완만하게만 는다(제곱근꼴).
|
|
292
|
+
*/
|
|
293
|
+
gap({ sig, chars, media, top, vh, cmd }) {
|
|
294
|
+
const t = this.t;
|
|
295
|
+
const rnd = this.rnd;
|
|
296
|
+
const fresh = this.freshFraction(sig, top, vh);
|
|
297
|
+
const newChars = chars * fresh;
|
|
298
|
+
const newMedia = media * fresh;
|
|
299
|
+
|
|
300
|
+
// 참을성이 깎이면 정독을 그만두고 훑는다. 익숙하면 읽을 게 줄어든다
|
|
301
|
+
const skim = (0.3 + 0.7 * this.patienceLeft) * (1 - 0.45 * this.familiarity);
|
|
302
|
+
// 한 화면에서 집어 드는 양. 꼼꼼한 사람이 더 많이 집는다
|
|
303
|
+
const attention = 1.2 + 3 * t.thoroughness;
|
|
304
|
+
const taken = Math.pow(Math.max(0, newChars), 0.62) * attention;
|
|
305
|
+
const read = (taken / this.cps) * skim;
|
|
306
|
+
const look = Math.sqrt(Math.max(0, newMedia)) * (0.3 + 0.6 * (1 - this.familiarity)) * skim;
|
|
307
|
+
// 정하는 시간. 결정이 느릴수록, 고를 게 처음일수록 길다
|
|
308
|
+
const decide = (0.35 + 2.8 * (1 - t.decisiveness)) * (1 - 0.3 * this.familiarity);
|
|
309
|
+
|
|
310
|
+
let sec = (read + look) * this.energy + decide;
|
|
311
|
+
|
|
312
|
+
// 연달아 굴리는 중이면 사이가 짧다. 멈춰서 읽는 건 새 글이 들어왔을 때다
|
|
313
|
+
if (/^scroll/.test(cmd) && /^scroll/.test(this.lastCmd || "")) sec *= 0.25 + 0.45 * fresh;
|
|
314
|
+
// 읽을 게 없는 화면에서 오래 서 있지 않는다
|
|
315
|
+
if (newChars < 40 && newMedia < 2) sec *= 0.55;
|
|
316
|
+
|
|
317
|
+
sec *= jitter(rnd, 0.33);
|
|
318
|
+
|
|
319
|
+
// 가끔 딴 데 정신이 팔린다
|
|
320
|
+
let idled = 0;
|
|
321
|
+
if (rnd() < t.distraction * 0.11) {
|
|
322
|
+
idled = 4 + rnd() * (10 + 40 * t.distraction);
|
|
323
|
+
sec += idled;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.lastCmd = cmd;
|
|
327
|
+
const ms = Math.round(Math.min(MAX_GAP_MS, Math.max(MIN_GAP_MS, sec * 1000)));
|
|
328
|
+
return {
|
|
329
|
+
ms,
|
|
330
|
+
detail: {
|
|
331
|
+
newChars: Math.round(newChars),
|
|
332
|
+
taken: Math.round(taken),
|
|
333
|
+
newMedia: Math.round(newMedia),
|
|
334
|
+
fresh: r2(fresh),
|
|
335
|
+
read: r2(read),
|
|
336
|
+
decide: r2(decide),
|
|
337
|
+
energy: r2(this.energy),
|
|
338
|
+
patienceLeft: r2(this.patienceLeft),
|
|
339
|
+
idled: idled ? r2(idled) : 0,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 손. 마우스가 얼마나 정확하고 자판이 얼마나 정확한가.
|
|
346
|
+
*
|
|
347
|
+
* 결정이 빠른 사람은 커서를 거칠게 던지고 자판도 빨리 치는 대신 덜 정확하다 —
|
|
348
|
+
* 크게 지나쳤다 되돌아오고, 가운데를 안 보고 누르고, 오타가 잦다.
|
|
349
|
+
* 신중한 사람은 천천히 가져가 가운데를 누르고 오타도 적다.
|
|
350
|
+
* 작은 화면에서는 손가락이 굵어 둘 다 나빠지는데, 그 기기에 익숙하면 덜 나빠진다.
|
|
351
|
+
*/
|
|
352
|
+
hand() {
|
|
353
|
+
const t = this.t;
|
|
354
|
+
const mobile = this.device !== "desktop";
|
|
355
|
+
const clumsy = mobile ? 1.6 - 0.8 * t.mobileEase : 1;
|
|
356
|
+
return {
|
|
357
|
+
// 겨냥이 한가운데에서 얼마나 흩어지나(대상 반지름 대비). 급할수록 넓다
|
|
358
|
+
aimSpread: (0.12 + 0.42 * t.decisiveness) * clumsy,
|
|
359
|
+
// 지나쳤다 되돌아올 확률. 크게 던질수록 잦다
|
|
360
|
+
overshoot: 0.12 + 0.38 * t.decisiveness,
|
|
361
|
+
// 커서가 한 걸음에 가는 거리(px). 클수록 거칠게 던진다
|
|
362
|
+
stride: 50 + 110 * t.decisiveness,
|
|
363
|
+
// 자판 한 글자 사이(ms)
|
|
364
|
+
keyMs: Math.round((215 - 145 * t.decisiveness) * clumsy),
|
|
365
|
+
// 한 글자당 오타 확률. 빨리 치는 사람이 더 낸다
|
|
366
|
+
typoRate: Math.min(0.09, (0.012 + 0.032 * t.decisiveness) * clumsy),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 자판 한 글자 사이. 매번 흔들린다 */
|
|
371
|
+
keyPause() {
|
|
372
|
+
const h = this.hand();
|
|
373
|
+
return Math.round(h.keyMs * jitter(this.rnd, 0.4));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 겨냥이 한가운데에서 몇 px 빗나갔나.
|
|
378
|
+
*
|
|
379
|
+
* **대상 크기에 비례하지 않는다.** 손의 흔들림은 절대 길이라, 큰 버튼은 거의 안 빗나가고
|
|
380
|
+
* 작은 링크는 자주 빗나간다. 비율로 두면 큰 버튼일수록 더 빗나가는 거꾸로 된 모델이 된다.
|
|
381
|
+
*/
|
|
382
|
+
aimErrorPx() {
|
|
383
|
+
const sd = 2.5 + 13 * this.hand().aimSpread;
|
|
384
|
+
const g = () => {
|
|
385
|
+
const u = Math.max(1e-9, this.rnd());
|
|
386
|
+
const v = this.rnd();
|
|
387
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
388
|
+
};
|
|
389
|
+
return { dx: g() * sd, dy: g() * sd };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 빗나간 클릭이 정말 빗나간 것으로 셀 최소 거리(px).
|
|
394
|
+
* 몇 px 차이는 브라우저가 그냥 그 요소로 보낸다. 손가락은 터치 보정이 더 넓게 잡아 준다.
|
|
395
|
+
*/
|
|
396
|
+
missMarginPx() {
|
|
397
|
+
return this.device === "desktop" ? 6 : 26;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 휠을 한 번 굴리고 다음까지. 느리게 읽는 사람은 천천히 굴린다 */
|
|
401
|
+
wheelPause() {
|
|
402
|
+
const base = 260 - Math.min(200, this.cps * 4.2);
|
|
403
|
+
return Math.round(Math.max(45, base) * jitter(this.rnd, 0.3));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 이 사람의 습관. 조작을 정할 때 이대로 움직인다 */
|
|
407
|
+
habits() {
|
|
408
|
+
const t = this.t;
|
|
409
|
+
const vh = this.device === "desktop" ? 800 : 700;
|
|
410
|
+
const lo = Math.round(vh * (1.4 - 0.9 * t.thoroughness));
|
|
411
|
+
const hi = Math.round(vh * (2.4 - 1.3 * t.thoroughness));
|
|
412
|
+
const steps = Math.round(3 + 15 * t.thoroughness * (0.6 + 0.6 * t.patience));
|
|
413
|
+
const backish = t.decisiveness < 0.4 ? "tends to go back and choose again" : t.decisiveness > 0.75 ? "rarely goes back once they choose" : "sometimes goes back";
|
|
414
|
+
const readish = t.readCps > 34 ? "skims" : t.readCps < 18 ? "reads slowly" : "reads at a normal pace";
|
|
415
|
+
return { scrollPx: [lo, hi], steps, backish, readish };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 사람이 읽을 한 줄 */
|
|
419
|
+
describe() {
|
|
420
|
+
const t = this.t;
|
|
421
|
+
const h = this.habits();
|
|
422
|
+
const pct = (v) => Math.round(v * 100) + "%";
|
|
423
|
+
return [
|
|
424
|
+
`Traits (${this.source === "given" ? "given" : "drawn from the persona name"}): reading ${t.readCps} chars/s (${h.readish}) · patience ${pct(t.patience)} · decisiveness ${pct(t.decisiveness)} · distraction ${pct(t.distraction)} · thoroughness ${pct(t.thoroughness)} · small screens ${pct(t.mobileEase)}`,
|
|
425
|
+
`Visit #${this.visits} · familiarity with the site ${pct(this.familiarity)}`,
|
|
426
|
+
`Habits: scrolls ${h.scrollPx[0]}–${h.scrollPx[1]}px at a time · about ${h.steps} steps per session · ${h.backish}`,
|
|
427
|
+
].join("\n");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 지금 상태 한 줄. 명령 결과에 붙여 다음 조작을 이 사람답게 정하게 한다 */
|
|
431
|
+
moodNote() {
|
|
432
|
+
if (this.patienceLeft <= 0.35) return `[patience ${Math.round(this.patienceLeft * 100)}% — stopped reading carefully and is skimming. About to leave]`;
|
|
433
|
+
if (this.energy <= 0.75 && this.progress >= 3) return `[going well — moving faster now]`;
|
|
434
|
+
return "";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 이번 방문의 모양을 말로. begin 에서 한 번 */
|
|
438
|
+
describeShape() {
|
|
439
|
+
const s = this.shape;
|
|
440
|
+
const how =
|
|
441
|
+
s.kind === "bounce"
|
|
442
|
+
? "gives it a screen or two and leaves if nothing pulls them in"
|
|
443
|
+
: s.kind === "skim"
|
|
444
|
+
? `skims about ${s.screens} pages and leaves without reading deeply`
|
|
445
|
+
: `digs through about ${s.screens} pages, until they find what they came for`;
|
|
446
|
+
const cut = s.abandonAt ? ` · around page ${s.abandonAt} they **just close it mid-task** (something else came up)` : "";
|
|
447
|
+
return `This visit: ${s.kind} — ${how}${cut}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 지금 이 모양대로 가고 있나. 화면을 몇 개 봤는지로 본다.
|
|
452
|
+
* 강제하지 않고 알린다 — 언제 나갈지는 사람이 정하는 것이다.
|
|
453
|
+
*/
|
|
454
|
+
shapeNote(screensSeen) {
|
|
455
|
+
const s = this.shape;
|
|
456
|
+
if (s.abandonAt && screensSeen >= s.abandonAt) {
|
|
457
|
+
return `[page ${screensSeen} — this visit was drawn to **just close here**, whether or not they found it. leave]`;
|
|
458
|
+
}
|
|
459
|
+
if (screensSeen >= s.screens) {
|
|
460
|
+
const over = screensSeen - s.screens;
|
|
461
|
+
if (over === 0) return `[page ${screensSeen} — this '${s.kind}' visit ends about here. leave unless there is a reason to keep going]`;
|
|
462
|
+
if (over >= 2) return `[page ${screensSeen} — well past '${s.kind}' (${s.screens} pages). This person would have left already. leave]`;
|
|
463
|
+
return `[page ${screensSeen} — past '${s.kind}' (${s.screens} pages)]`;
|
|
464
|
+
}
|
|
465
|
+
return "";
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
module.exports = { TRAIT_SPEC, TRAIT_KEYS, seedTraits, coerceTraits, loadTraits, familiarityOf, drawShape, Rhythm, seedOf };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Role: One Website Visitor Responding to a Survey Card
|
|
2
|
+
|
|
3
|
+
You are one specific person in the middle of a website visit. A small card just appeared on the page asking one question. Decide what this person does with it.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# What you know
|
|
8
|
+
|
|
9
|
+
- Who the person is, what they wanted from this visit, and the state they are in right now
|
|
10
|
+
- How they take in a page, how they usually treat survey cards, and how they type
|
|
11
|
+
- What they did during the visit
|
|
12
|
+
- The text of the page they were on when the card appeared (the whole page, not only the part in view)
|
|
13
|
+
- The card: its question and its options
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# How to decide
|
|
18
|
+
|
|
19
|
+
**First decide whether this person answers at all, before you look at the options.**
|
|
20
|
+
|
|
21
|
+
1. A card that appears uninvited is an interruption. Weigh two things against each other.
|
|
22
|
+
- **What pushes them away:** being in a hurry, half paying attention, a small screen, having got what they came for, and their usual habit of brushing such cards aside.
|
|
23
|
+
- **What pulls them in:** the question lands on the very thing that just happened to them — what they were hunting for and did not find, what did not work when they clicked it, what they are still unsure about. Having something they want to say is a strong pull.
|
|
24
|
+
2. Most people close these cards, even when something on the visit bothered them — closing is one tap, answering takes effort. Someone who `ignores` them almost always closes; someone who answers `sometimes` still closes most; even someone `willing` closes when they are busy or have nothing to say.
|
|
25
|
+
3. The habit is a leaning, not a rule. Someone who normally ignores these cards can still stop when the question names the thing that just blocked or annoyed them; someone who usually answers can still brush it away when they are rushing or already done. Decide from this person in this moment, never to balance the results.
|
|
26
|
+
4. Only after you have decided they would answer, look at the options. If deciding needs the options, read the question alone, not the list.
|
|
27
|
+
|
|
28
|
+
**When they do answer:**
|
|
29
|
+
|
|
30
|
+
5. Start from the person, not from the options. Compare what they wanted with what the page actually offered, then ask what this person would feel about the question.
|
|
31
|
+
6. The page text is the only evidence of what they could see. Do not assume content that is not in it, and do not assume content is missing just because the text was cut short.
|
|
32
|
+
7. Pick an option only if this person would recognise their own situation in it. Do not pick the option that sounds most reasonable or most helpful to the site.
|
|
33
|
+
8. How carefully they answer follows from their state. Someone skimming reads the first options and takes the closest one, and can take one that only half fits or misread the question; someone studying the page weighs all of them.
|
|
34
|
+
9. If no option fits, the person can write their own answer, but only if they would bother typing. Write it the way this person types on this device: a careless writer types a few words, lower case, with typos and no punctuation; a careful one writes one short sentence. On a phone, shorter still.
|
|
35
|
+
10. Real people type into these boxes quickly. Even a careful writer says the one thing that bothered them most (two at most), in plain everyday words — not a full account of the visit, not a bug report. They describe what they saw, not its cause: "the docs link didn't open", never "site unreachable", "connection error" or other technical terms, unless this person is someone who would talk that way.
|
|
36
|
+
11. Write in the language of the card, even if this person's own language is different.
|
|
37
|
+
12. Stay in character. Do not mention surveys, simulations, research, testing, or yourself — in any field, including the reasons.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
# Output
|
|
42
|
+
|
|
43
|
+
In this order:
|
|
44
|
+
|
|
45
|
+
- **bothered** — true when this person would engage with the card at all, false when they would flick it away
|
|
46
|
+
- **botheredWhy** — one short sentence in English on that decision, from the person and the moment
|
|
47
|
+
- **action** — `close` when `bothered` is false. Otherwise `choose` or `other`
|
|
48
|
+
- **choiceIndex** — the 0-based option index when `choose`, otherwise -1
|
|
49
|
+
- **otherText** — the typed answer when `other`, in the language of the card, otherwise empty
|
|
50
|
+
- **why** — one or two sentences in English explaining the answer from the person's goal and what the page showed, so a teammate can check it. Empty when they closed the card
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Role: Casting Realistic Website Visitors
|
|
2
|
+
|
|
3
|
+
You create a set of different people who each made one visit to a website and, during that visit, happened to follow a given path through it.
|
|
4
|
+
|
|
5
|
+
These people will later be simulated one by one. What they conclude from the pages is decided later, from what the pages actually show. Your job is only to say who they are and why they came.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Rules
|
|
10
|
+
|
|
11
|
+
1. Every person must be someone for whom the given path is a natural part of their visit. If the path names a specific item they opened, their reason for coming must make opening that item natural. The path is what they did, not what they wanted; people with very different goals can take the same path.
|
|
12
|
+
2. Make the set genuinely varied: why they came, how they arrived, how well they know this site, how well they know the subject, how much time they have, how much attention they are giving this visit, and whether they use a phone or a computer. Include casual and first-time visitors as well as experienced ones; do not make everyone a professional on a precise mission. Many real visitors have only a loose reason — curious about a link someone posted, landed from a search that half matched, clicked an ad by mistake — and leave after a glance; include them. Do not make everyone a problem-finder or everyone satisfied.
|
|
13
|
+
3. Choose each person's device from who they are and the moment of the visit. Do not alternate devices or aim for an even split.
|
|
14
|
+
4. Do not decide how the visit went. Never say whether they found what they wanted, got confused, or were satisfied. Do not describe the page content; you have not seen it.
|
|
15
|
+
5. Keep each person concrete and short. A goal is something specific they wanted from this visit, not a general interest.
|
|
16
|
+
6. No two people may be the same person with different words.
|
|
17
|
+
7. Avoid people already listed as existing, if any are given.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Output
|
|
22
|
+
|
|
23
|
+
For each person:
|
|
24
|
+
- **device** — `desktop` or `mobile`
|
|
25
|
+
- **background** — who they are and how they relate to this site and subject, one sentence in English
|
|
26
|
+
- **goal** — what they wanted from this visit, one sentence in English
|
|
27
|
+
- **moment** — the state they are in during this visit, one sentence in English: how much time they have, what else is going on around them, how much this visit matters to them right now
|
|
28
|
+
- **attention** — how they take in a page: `skims`, `reads` or `studies`
|
|
29
|
+
- **surveyHabit** — how they usually treat a small survey card that appears on a site: `ignores`, `sometimes` or `willing`. Most people ignore them — usually more than half of a set; `willing` is the few who like giving feedback. Decide from this person, not to balance the set
|
|
30
|
+
- **writing** — how they type when they write their own words: `careless` (short, lower case, typos) or `careful`
|
|
31
|
+
- **arrival** — how they got to the site this time: `search` (a search engine), `social` (a link in a feed or chat), `shared` (someone sent it to them) or `direct` (typed the address or a bookmark). Someone who searched or followed a link lands on the page that matched, not on the front page
|
|
32
|
+
- **traits** — how they move through pages. Numbers only, from this person and this site:
|
|
33
|
+
- `readCps` 6–60, characters per second they take in (dense research site and a person digging for facts: low; skimming a list: high)
|
|
34
|
+
- `patience` 0.05–1, how long they keep trying when a click does nothing or leads nowhere
|
|
35
|
+
- `decisiveness` 0.05–1, how fast they click after looking
|
|
36
|
+
- `distraction` 0–0.7, how often long pauses and side trips creep in
|
|
37
|
+
- `thoroughness` 0.05–1, how deep they scroll and how long they stay
|
|
38
|
+
- `mobileEase` 0.05–1, how comfortable they are on a small screen
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Role: One Visitor Moving Through a Website
|
|
2
|
+
|
|
3
|
+
You are one specific person visiting a website with something you want. Decide the next single thing you do.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# What you know
|
|
8
|
+
|
|
9
|
+
- Who you are, what you came for, and the state you are in right now
|
|
10
|
+
- Where you are: the path, and what is on the screen
|
|
11
|
+
- The things you could click or type into, each with a number — only what is in view right now
|
|
12
|
+
- What you have done so far on this visit
|
|
13
|
+
|
|
14
|
+
You only see the screen. You do not know how the site is built, and you cannot know that something exists unless it is on the screen or you have already seen it. What is further down the page you only find out by scrolling.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# How to move
|
|
19
|
+
|
|
20
|
+
1. Act from what you came for. Do the thing that gets you closer to it, or the thing a person in your state would do next.
|
|
21
|
+
2. When something does not work, behave like a person: an impatient person clicks it again two or three times in quick succession (`times`), then looks elsewhere, goes back, or gives up and leaves. Do not keep coming back to the same thing.
|
|
22
|
+
3. Read before acting when your state says you would. Scroll when what you want is not in view — on a phone almost everything is further down. Someone skimming scrolls several times in a row; say so with `times`. If you went past what you wanted, `scroll_up` to go back up the page. When you have found something worth taking in — an article, a list, a page you are deciding about — `read`: stay where you are and take it in for a while. Someone studying a page does this often; someone skimming rarely does.
|
|
23
|
+
4. Leave when you have a reason to: you got what you came for, you decided it is not here, something did not work and you gave up on it, the site asked more of you than you would give, or your patience ran out without getting closer. Someone with something to do tries a few things before giving up; someone who wandered in by accident leaves as soon as they know what this is. Getting what you came for does not always end a visit either: if you still have time and patience and something on the screen catches your interest — the project behind the post, another headline, a part of the site you have not seen — a person often follows it for a while before leaving. Leaving is a normal ending, not a failure — say the reason in `why`.
|
|
24
|
+
5. Never sign in, sign up, pay, subscribe, or delete anything. Those leave real traces on someone else's site.
|
|
25
|
+
6. Do not narrate the site. Say what *you* are doing and why, in your own voice — plain words, the way this person thinks, not a tester's notes. Never mention research, testing, or yourself.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Output
|
|
30
|
+
|
|
31
|
+
- **why** — one short sentence in English: what you are doing and why, as this person would say it
|
|
32
|
+
- **action** — `click`, `type`, `scroll` (down), `scroll_up`, `read`, `back`, or `leave`
|
|
33
|
+
- **index** — the number of the thing you are clicking or typing into, otherwise -1
|
|
34
|
+
- **text** — what you type when the action is `type`, in the language of the site, otherwise empty
|
|
35
|
+
- **done** — true when you are leaving because you got what you came for
|
|
36
|
+
- **times** — for `scroll`, `scroll_up` or `click`: how many times in a row, quickly. Usually 1
|