site-agent-pro 1.0.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.
Files changed (81) hide show
  1. package/README.md +689 -0
  2. package/dist/auth/credentialStore.js +62 -0
  3. package/dist/auth/inbox.js +193 -0
  4. package/dist/auth/profile.js +379 -0
  5. package/dist/auth/runner.js +1124 -0
  6. package/dist/backend/dashboardData.js +194 -0
  7. package/dist/backend/runArtifacts.js +48 -0
  8. package/dist/backend/runRepository.js +93 -0
  9. package/dist/bin.js +2 -0
  10. package/dist/cli/backfillSiteChecks.js +143 -0
  11. package/dist/cli/run.js +309 -0
  12. package/dist/cli/trade.js +69 -0
  13. package/dist/config.js +199 -0
  14. package/dist/core/agentProfiles.js +55 -0
  15. package/dist/core/aggregateReport.js +382 -0
  16. package/dist/core/audit.js +30 -0
  17. package/dist/core/customTaskSuite.js +148 -0
  18. package/dist/core/evaluator.js +217 -0
  19. package/dist/core/executor.js +788 -0
  20. package/dist/core/fallbackReport.js +335 -0
  21. package/dist/core/formHeuristics.js +411 -0
  22. package/dist/core/gameplaySummary.js +164 -0
  23. package/dist/core/interaction.js +202 -0
  24. package/dist/core/pageState.js +201 -0
  25. package/dist/core/planner.js +1669 -0
  26. package/dist/core/processSubmissionBatch.js +204 -0
  27. package/dist/core/runAuditJob.js +170 -0
  28. package/dist/core/runner.js +2352 -0
  29. package/dist/core/siteBrief.js +107 -0
  30. package/dist/core/siteChecks.js +1526 -0
  31. package/dist/core/taskDirectives.js +279 -0
  32. package/dist/core/taskHeuristics.js +263 -0
  33. package/dist/dashboard/client.js +1256 -0
  34. package/dist/dashboard/contracts.js +95 -0
  35. package/dist/dashboard/narrative.js +277 -0
  36. package/dist/dashboard/server.js +458 -0
  37. package/dist/dashboard/theme.js +888 -0
  38. package/dist/index.js +84 -0
  39. package/dist/llm/client.js +188 -0
  40. package/dist/paystack/account.js +123 -0
  41. package/dist/paystack/client.js +100 -0
  42. package/dist/paystack/index.js +13 -0
  43. package/dist/paystack/test-paystack.js +83 -0
  44. package/dist/paystack/transfer.js +138 -0
  45. package/dist/paystack/types.js +74 -0
  46. package/dist/paystack/webhook.js +121 -0
  47. package/dist/prompts/browserAgent.js +124 -0
  48. package/dist/prompts/reviewer.js +71 -0
  49. package/dist/reporting/clickReplay.js +290 -0
  50. package/dist/reporting/html.js +930 -0
  51. package/dist/reporting/markdown.js +238 -0
  52. package/dist/reporting/template.js +1141 -0
  53. package/dist/schemas/types.js +361 -0
  54. package/dist/submissions/customTasks.js +196 -0
  55. package/dist/submissions/html.js +770 -0
  56. package/dist/submissions/model.js +56 -0
  57. package/dist/submissions/publicUrl.js +76 -0
  58. package/dist/submissions/service.js +74 -0
  59. package/dist/submissions/store.js +37 -0
  60. package/dist/submissions/types.js +65 -0
  61. package/dist/trade/engine.js +241 -0
  62. package/dist/trade/evm/erc20.js +44 -0
  63. package/dist/trade/extractor.js +148 -0
  64. package/dist/trade/policy.js +35 -0
  65. package/dist/trade/session.js +31 -0
  66. package/dist/trade/types.js +107 -0
  67. package/dist/trade/validator.js +148 -0
  68. package/dist/utils/files.js +59 -0
  69. package/dist/utils/log.js +24 -0
  70. package/dist/utils/playwrightCompat.js +14 -0
  71. package/dist/utils/time.js +3 -0
  72. package/dist/wallet/provider.js +345 -0
  73. package/dist/wallet/relay.js +129 -0
  74. package/dist/wallet/wallet.js +178 -0
  75. package/docs/01-installation.md +134 -0
  76. package/docs/02-running-your-first-audit.md +136 -0
  77. package/docs/03-configuration.md +233 -0
  78. package/docs/04-how-the-agent-thinks.md +41 -0
  79. package/docs/05-extending-personas-and-tasks.md +42 -0
  80. package/docs/06-hardening-for-production.md +92 -0
  81. package/package.json +60 -0
@@ -0,0 +1,411 @@
1
+ export const CHECK_FIELD_SENTINEL = "__SITE_AGENT_CHECK_FIELD__";
2
+ const SELECT_PLACEHOLDER_PATTERN = /^(?:(?:please\s+)?(?:select|choose|pick)(?:\s+[a-z0-9][a-z0-9 -]*)?|--+|option)$/i;
3
+ const DATE_CONTEXT_BLOCKLIST = /arrival|departure|return date|check[- ]?in|check[- ]?out|appointment|meeting|delivery|pickup|reservation|booking|schedule/i;
4
+ const CODE_FIELD_PATTERN = /\b(?:coupon|promo|referral|invite|gift|discount|access)\b.*\b(?:code|token)\b|\b(?:coupon|promo|referral|invite|gift|discount)\b/;
5
+ const PAYMENT_FIELD_PATTERN = /credit card|cardholder|card number|\bcvv\b|\bcvc\b|expiry|expiration|mm\/yy|mm-yy/;
6
+ const PASSWORD_FIELD_PATTERN = /\bpassword\b|passcode|\bpin\b/i;
7
+ const EMAIL_FIELD_PATTERN = /\bemail\b|e-mail/i;
8
+ const PASSWORD_CONFIRMATION_PATTERN = /\bconfirm\b|re[- ]?(?:enter|type)|repeat|again|verify/i;
9
+ const TEST_CRYPTO_WALLET_ADDRESS = "0x1111111111111111111111111111111111111111";
10
+ const TEST_BANK_ACCOUNT_NUMBER = "0123456789";
11
+ function normalizeFormText(value) {
12
+ return value.replace(/\s+/g, " ").trim();
13
+ }
14
+ export function normalizeFormKey(value) {
15
+ return normalizeFormText(value).toLowerCase();
16
+ }
17
+ export function hasPasswordConfirmationCue(value) {
18
+ return PASSWORD_CONFIRMATION_PATTERN.test(normalizeFormText(value));
19
+ }
20
+ function normalizeLooseKey(value) {
21
+ return normalizeFormKey(value).replace(/[^a-z0-9]+/g, "");
22
+ }
23
+ function localEmailBase(email) {
24
+ return email.split("@", 1)[0] || "";
25
+ }
26
+ function buildFallbackUsername(identity) {
27
+ const emailBase = localEmailBase(identity.email).replace(/\+/g, "-").replace(/[^a-z0-9._-]+/gi, "");
28
+ if (emailBase) {
29
+ return emailBase.toLowerCase().slice(0, 24);
30
+ }
31
+ return `${identity.firstName}.${identity.lastName}`.replace(/[^a-z0-9._-]+/gi, "").toLowerCase().slice(0, 24);
32
+ }
33
+ export function buildSupplementalAccessProfile(identity) {
34
+ return {
35
+ username: identity.username?.trim() || buildFallbackUsername(identity),
36
+ age: "24",
37
+ website: "https://example.com",
38
+ occupation: "QA analyst",
39
+ bio: "Independent QA tester reviewing the signup flow.",
40
+ message: "Testing the signup flow for a product evaluation.",
41
+ birthDateIso: "1998-04-17",
42
+ birthDay: "17",
43
+ birthMonthNumber: "04",
44
+ birthMonthName: "April",
45
+ birthMonthShort: "Apr",
46
+ birthYear: "1998",
47
+ pronouns: "they/them",
48
+ gender: "Prefer not to say"
49
+ };
50
+ }
51
+ export function buildFormFieldKey(field) {
52
+ return normalizeFormKey([field.label, field.placeholder, field.name, field.id, field.autocomplete ?? "", field.inputType, field.inputMode ?? ""].join(" "));
53
+ }
54
+ export function scoreFormFieldTargetMatch(field, target) {
55
+ const normalizedTarget = normalizeFormKey(target);
56
+ if (!normalizedTarget) {
57
+ return 0;
58
+ }
59
+ const candidates = [field.label, field.placeholder, field.name, field.id, field.autocomplete ?? "", field.inputMode ?? ""]
60
+ .map((value) => normalizeFormKey(value))
61
+ .filter(Boolean);
62
+ let score = 0;
63
+ for (const candidate of candidates) {
64
+ if (candidate === normalizedTarget) {
65
+ score = Math.max(score, 120);
66
+ }
67
+ if (candidate.includes(normalizedTarget) || normalizedTarget.includes(candidate)) {
68
+ score = Math.max(score, 90);
69
+ }
70
+ }
71
+ if (field.inputType === normalizedTarget) {
72
+ score = Math.max(score, 110);
73
+ }
74
+ const fieldKey = buildFormFieldKey(field);
75
+ const targetWantsPassword = PASSWORD_FIELD_PATTERN.test(normalizedTarget);
76
+ const fieldLooksPasswordLike = field.inputType === "password" || PASSWORD_FIELD_PATTERN.test(fieldKey);
77
+ const targetWantsConfirm = hasPasswordConfirmationCue(target);
78
+ const fieldLooksLikeConfirm = hasPasswordConfirmationCue(fieldKey);
79
+ if (targetWantsPassword && fieldLooksPasswordLike) {
80
+ score = Math.max(score, targetWantsConfirm === fieldLooksLikeConfirm ? 130 : 85);
81
+ }
82
+ if (EMAIL_FIELD_PATTERN.test(normalizedTarget) && (field.inputType === "email" || EMAIL_FIELD_PATTERN.test(fieldKey))) {
83
+ score = Math.max(score, 130);
84
+ }
85
+ if (targetWantsConfirm) {
86
+ if (fieldLooksLikeConfirm) {
87
+ score += 70;
88
+ }
89
+ else if (fieldLooksPasswordLike) {
90
+ score -= 70;
91
+ }
92
+ }
93
+ if (!targetWantsConfirm && targetWantsPassword && fieldLooksLikeConfirm) {
94
+ score -= 40;
95
+ }
96
+ return score;
97
+ }
98
+ function stripLeadingZero(value) {
99
+ return value.replace(/^0+(\d)/, "$1");
100
+ }
101
+ function findOptionByCandidates(options, candidates) {
102
+ const normalizedCandidates = [...new Set(candidates.map((value) => normalizeFormKey(value)).filter(Boolean))];
103
+ const looseCandidates = [...new Set(candidates.map((value) => normalizeLooseKey(value)).filter(Boolean))];
104
+ if (normalizedCandidates.length === 0) {
105
+ return null;
106
+ }
107
+ for (const option of options) {
108
+ const normalizedOption = normalizeFormKey(option);
109
+ if (normalizedCandidates.includes(normalizedOption)) {
110
+ return option;
111
+ }
112
+ }
113
+ for (const option of options) {
114
+ const looseOption = normalizeLooseKey(option);
115
+ if (looseCandidates.includes(looseOption)) {
116
+ return option;
117
+ }
118
+ }
119
+ for (const option of options) {
120
+ const normalizedOption = normalizeFormKey(option);
121
+ if (normalizedCandidates.some((candidate) => normalizedOption.includes(candidate) || candidate.includes(normalizedOption))) {
122
+ return option;
123
+ }
124
+ }
125
+ for (const option of options) {
126
+ const looseOption = normalizeLooseKey(option);
127
+ if (looseCandidates.some((candidate) => looseOption.includes(candidate) || candidate.includes(looseOption))) {
128
+ return option;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ function findAgeOption(options, age) {
134
+ for (const option of options) {
135
+ const normalized = normalizeFormKey(option);
136
+ const exactMatch = normalized.match(/\b(\d{1,2})\b/);
137
+ if (exactMatch && Number(exactMatch[1]) === age && !/[-+]/.test(normalized)) {
138
+ return option;
139
+ }
140
+ const rangeMatch = normalized.match(/(\d{1,2})\s*[-to]+\s*(\d{1,2})/);
141
+ if (rangeMatch) {
142
+ const lowerBound = Number(rangeMatch[1]);
143
+ const upperBound = Number(rangeMatch[2]);
144
+ if (Number.isFinite(lowerBound) && Number.isFinite(upperBound) && age >= lowerBound && age <= upperBound) {
145
+ return option;
146
+ }
147
+ }
148
+ const plusMatch = normalized.match(/(\d{1,2})\s*\+/);
149
+ if (plusMatch && age >= Number(plusMatch[1])) {
150
+ return option;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ function getFirstRealOption(options) {
156
+ return options.find((option) => !SELECT_PLACEHOLDER_PATTERN.test(normalizeFormText(option))) ?? null;
157
+ }
158
+ function resolveStateCandidates(state) {
159
+ const normalized = normalizeFormKey(state);
160
+ if (normalized === "texas") {
161
+ return [state, "TX"];
162
+ }
163
+ if (normalized === "california") {
164
+ return [state, "CA"];
165
+ }
166
+ if (normalized === "new york") {
167
+ return [state, "NY"];
168
+ }
169
+ if (normalized === "florida") {
170
+ return [state, "FL"];
171
+ }
172
+ return [state];
173
+ }
174
+ export function findMatchingSelectOption(options, desiredValues) {
175
+ return findOptionByCandidates(options, desiredValues);
176
+ }
177
+ function inferSelectValue(field, identity, supplemental) {
178
+ if (field.options.length === 0) {
179
+ return null;
180
+ }
181
+ const key = buildFormFieldKey(field);
182
+ if (/country/.test(key)) {
183
+ return (findMatchingSelectOption(field.options, [identity.country, "United States of America", "United States", "USA", "US"]) ??
184
+ getFirstRealOption(field.options));
185
+ }
186
+ if (/state|province|region/.test(key)) {
187
+ return findMatchingSelectOption(field.options, resolveStateCandidates(identity.state)) ?? getFirstRealOption(field.options);
188
+ }
189
+ if (field.autocomplete === "bday-month" || /(?:birth|dob|bday).*(?:month)|month of birth|\bmonth\b/.test(key)) {
190
+ return (findMatchingSelectOption(field.options, [
191
+ supplemental.birthMonthName,
192
+ supplemental.birthMonthShort,
193
+ supplemental.birthMonthNumber,
194
+ stripLeadingZero(supplemental.birthMonthNumber)
195
+ ]) ?? getFirstRealOption(field.options));
196
+ }
197
+ if (field.autocomplete === "bday-day" || /(?:birth|dob|bday).*(?:day)|day of birth|\bday\b/.test(key)) {
198
+ return findMatchingSelectOption(field.options, [supplemental.birthDay, stripLeadingZero(supplemental.birthDay)]) ?? getFirstRealOption(field.options);
199
+ }
200
+ if (field.autocomplete === "bday-year" || /(?:birth|dob|bday).*(?:year)|year of birth|\byear\b/.test(key)) {
201
+ return findMatchingSelectOption(field.options, [supplemental.birthYear]) ?? getFirstRealOption(field.options);
202
+ }
203
+ if (/gender|sex/.test(key)) {
204
+ return (findMatchingSelectOption(field.options, [
205
+ supplemental.gender,
206
+ "Rather not say",
207
+ "Prefer not to answer",
208
+ "Other",
209
+ "Non-binary",
210
+ "Nonbinary",
211
+ "Male",
212
+ "Female"
213
+ ]) ?? getFirstRealOption(field.options));
214
+ }
215
+ if (/pronouns?/.test(key)) {
216
+ return (findMatchingSelectOption(field.options, [
217
+ supplemental.pronouns,
218
+ "Prefer not to say",
219
+ "Rather not say",
220
+ "They/them",
221
+ "They / them"
222
+ ]) ?? getFirstRealOption(field.options));
223
+ }
224
+ if (/age|age range/.test(key)) {
225
+ return findAgeOption(field.options, Number(supplemental.age)) ?? getFirstRealOption(field.options);
226
+ }
227
+ if (/occupation|job title|profession|role/.test(key)) {
228
+ return findMatchingSelectOption(field.options, [supplemental.occupation, "QA", "Tester", "Student"]) ?? getFirstRealOption(field.options);
229
+ }
230
+ return getFirstRealOption(field.options);
231
+ }
232
+ export function fitValueToField(field, value) {
233
+ const normalizedValue = normalizeFormText(value);
234
+ const maxLength = field.maxLength && field.maxLength > 0 ? field.maxLength : null;
235
+ if (!maxLength || normalizedValue.length <= maxLength) {
236
+ return normalizedValue;
237
+ }
238
+ const key = buildFormFieldKey({
239
+ label: field.label,
240
+ placeholder: field.placeholder,
241
+ name: field.name,
242
+ id: field.id,
243
+ autocomplete: field.autocomplete ?? "",
244
+ inputType: field.inputType,
245
+ inputMode: field.inputMode ?? ""
246
+ });
247
+ if (field.tag === "textarea" || ["text", "search"].includes(field.inputType) || /bio|about|summary|description|message|headline|title/.test(key)) {
248
+ return normalizedValue.slice(0, maxLength).trim();
249
+ }
250
+ return normalizedValue;
251
+ }
252
+ export function inferFormFieldValue(field, identity) {
253
+ const supplemental = buildSupplementalAccessProfile(identity);
254
+ const key = buildFormFieldKey(field);
255
+ if (PAYMENT_FIELD_PATTERN.test(key)) {
256
+ return null;
257
+ }
258
+ if (/\bwallet\b|\brecipient\b|\bcrypto\s+address\b|\baddress\s+(?:to\s+)?(?:receive|send)\b/.test(key)) {
259
+ return fitValueToField(field, TEST_CRYPTO_WALLET_ADDRESS);
260
+ }
261
+ if (/\bbank\b.*\baccount\b|\baccount\s+(?:number|no)\b|\bnuban\b/.test(key)) {
262
+ return fitValueToField(field, TEST_BANK_ACCOUNT_NUMBER);
263
+ }
264
+ if (/\b(?:token|contract)\b.*\baddress\b|\baddress\b.*\b(?:token|contract)\b/.test(key)) {
265
+ return null;
266
+ }
267
+ if (/\bnaira\b|\bngn\b|₦/.test(key) && /\bamount\b|spend|pay|receive|payout/.test(key)) {
268
+ return null;
269
+ }
270
+ if (/\bcrypto\b|\btoken\b|\busdt\b|\bbtc\b|\beth\b|\busdc\b/.test(key) && /\bamount\b|sell|receive|send/.test(key)) {
271
+ return null;
272
+ }
273
+ if (/\bamount\b/.test(key) && (field.inputType === "number" || field.inputMode === "numeric" || field.inputMode === "decimal")) {
274
+ return null;
275
+ }
276
+ if (field.inputType === "checkbox") {
277
+ if (field.checked) {
278
+ return null;
279
+ }
280
+ if (field.required || /agree|accept|terms|privacy|conditions|policy|consent|adult|human|acknowledge|confirm|over\s*(?:13|16|18|21)/.test(key)) {
281
+ return CHECK_FIELD_SENTINEL;
282
+ }
283
+ return null;
284
+ }
285
+ if (field.inputType === "radio") {
286
+ if (field.checked) {
287
+ return null;
288
+ }
289
+ // Picking the first visible radio option is a reasonable default for
290
+ // required signup groups like "experience level" when no model plan is available.
291
+ return CHECK_FIELD_SENTINEL;
292
+ }
293
+ if (field.tag === "select") {
294
+ const selectValue = inferSelectValue(field, identity, supplemental);
295
+ if (selectValue) {
296
+ return selectValue;
297
+ }
298
+ }
299
+ if (field.autocomplete === "email" || field.inputType === "email" || /\bemail\b|e-mail/.test(key)) {
300
+ return identity.email;
301
+ }
302
+ if (field.autocomplete === "username" || /\buser.?name\b|handle|screen.?name|user id/.test(key)) {
303
+ return fitValueToField(field, supplemental.username);
304
+ }
305
+ if (field.inputType === "password" || /\bpassword\b|passcode|pin\b/.test(key)) {
306
+ return identity.password;
307
+ }
308
+ if (/^names?$/.test(normalizeFormKey(field.label)) || /\bnames\b/.test(key)) {
309
+ return identity.fullName;
310
+ }
311
+ if (/first.?name|given.?name/.test(key)) {
312
+ return identity.firstName;
313
+ }
314
+ if (/last.?name|surname|family.?name/.test(key)) {
315
+ return identity.lastName;
316
+ }
317
+ if (/display.?name|profile.?name/.test(key)) {
318
+ return identity.fullName;
319
+ }
320
+ if (/full.?name|\bname\b/.test(key) && !/company|organization|business/.test(key)) {
321
+ return identity.fullName;
322
+ }
323
+ if (/pronouns?/.test(key)) {
324
+ return supplemental.pronouns;
325
+ }
326
+ if (/gender|sex/.test(key)) {
327
+ return supplemental.gender;
328
+ }
329
+ if (field.autocomplete === "bday" ||
330
+ /date of birth|dob|birthday/.test(key) ||
331
+ (field.inputType === "date" && !DATE_CONTEXT_BLOCKLIST.test(key))) {
332
+ return supplemental.birthDateIso;
333
+ }
334
+ if (field.autocomplete === "bday-year" || /(?:birth|dob|bday).*(?:year)|year of birth/.test(key)) {
335
+ return supplemental.birthYear;
336
+ }
337
+ if (field.autocomplete === "bday-month" || /(?:birth|dob|bday).*(?:month)|month of birth/.test(key)) {
338
+ return supplemental.birthMonthNumber;
339
+ }
340
+ if (field.autocomplete === "bday-day" || /(?:birth|dob|bday).*(?:day)|day of birth/.test(key)) {
341
+ return supplemental.birthDay;
342
+ }
343
+ if (/\bage\b|years?\b|how old/.test(key)) {
344
+ return supplemental.age;
345
+ }
346
+ if (field.inputType === "tel" || /phone|mobile|telephone|tel\b/.test(key)) {
347
+ return identity.phone;
348
+ }
349
+ if (field.inputType === "url" || /website|url|portfolio|linkedin|homepage/.test(key)) {
350
+ return supplemental.website;
351
+ }
352
+ if (/address.*line.*2|address 2|suite|unit|apt|apartment/.test(key)) {
353
+ return identity.addressLine2;
354
+ }
355
+ if (/street|address/.test(key)) {
356
+ return identity.addressLine1;
357
+ }
358
+ if (/city|town/.test(key)) {
359
+ return identity.city;
360
+ }
361
+ if (/state|province|region/.test(key)) {
362
+ return identity.state;
363
+ }
364
+ if (/zip|postal/.test(key)) {
365
+ return identity.postalCode;
366
+ }
367
+ if (/country/.test(key)) {
368
+ return identity.country;
369
+ }
370
+ if (/company|organization|business/.test(key)) {
371
+ return identity.company;
372
+ }
373
+ if (/occupation|job title|profession|role|what do you do/.test(key)) {
374
+ return supplemental.occupation;
375
+ }
376
+ if (/bio|about|summary|description|introduce yourself|tell us about yourself/.test(key)) {
377
+ return fitValueToField(field, supplemental.bio);
378
+ }
379
+ if (/message|why are you interested|why do you want|comment|notes?/.test(key)) {
380
+ return fitValueToField(field, supplemental.message);
381
+ }
382
+ if (field.required && CODE_FIELD_PATTERN.test(key)) {
383
+ return null;
384
+ }
385
+ if (field.required && field.tag === "textarea") {
386
+ return fitValueToField(field, supplemental.bio);
387
+ }
388
+ if (field.required && ["text", "search"].includes(field.inputType)) {
389
+ if (/headline|title/.test(key)) {
390
+ return fitValueToField(field, supplemental.occupation);
391
+ }
392
+ return fitValueToField(field, identity.fullName);
393
+ }
394
+ return null;
395
+ }
396
+ export function isPlaceholderFieldValue(field, value) {
397
+ if (field.inputType === "checkbox" || field.inputType === "radio") {
398
+ return !field.checked;
399
+ }
400
+ const normalizedValue = normalizeFormKey(value);
401
+ if (!normalizedValue) {
402
+ return true;
403
+ }
404
+ if (field.tag === "select" && SELECT_PLACEHOLDER_PATTERN.test(normalizedValue)) {
405
+ return true;
406
+ }
407
+ return false;
408
+ }
409
+ export function shouldCheckField(value) {
410
+ return value === CHECK_FIELD_SENTINEL;
411
+ }
@@ -0,0 +1,164 @@
1
+ const HOW_TO_PLAY_PATTERNS = [/\bhow to play\b/i, /\brules?\b/i, /\binstructions?\b/i, /\btutorial\b/i, /\bhow it works\b/i];
2
+ const REPLAY_PATTERNS = [/\bplay again\b/i, /\bnew game\b/i, /\brestart\b/i, /\bretry\b/i, /\bnext round\b/i];
3
+ const ROUND_CONTEXT_PATTERNS = [
4
+ /\bgame over\b/i,
5
+ /\bround\b/i,
6
+ /\bscore\b/i,
7
+ /\bresult\b/i,
8
+ /\bplay again\b/i,
9
+ /\bnew game\b/i,
10
+ /\brestart\b/i,
11
+ /\bretry\b/i
12
+ ];
13
+ const WIN_PATTERNS = [/\byou win\b/i, /\bvictory\b/i, /\bwon\b/i, /\bwinner\b/i, /\blevel complete\b/i];
14
+ const LOSS_PATTERNS = [/\byou lose\b/i, /\blost\b/i, /\bdefeat\b/i, /\bgame over\b/i, /\bbetter luck next time\b/i];
15
+ const DRAW_PATTERNS = [/\bdraw\b/i, /\btie\b/i, /\bstalemate\b/i];
16
+ function normalizeText(value) {
17
+ return value.replace(/\s+/g, " ").trim();
18
+ }
19
+ function uniqueItems(items, limit) {
20
+ return [...new Set(items.map((item) => normalizeText(item)).filter(Boolean))].slice(0, limit);
21
+ }
22
+ function buildEntryText(entry) {
23
+ return normalizeText([
24
+ entry.title,
25
+ entry.url,
26
+ entry.decision.target,
27
+ entry.result.note,
28
+ entry.result.destinationTitle ?? "",
29
+ entry.result.destinationUrl ?? "",
30
+ entry.result.visibleTextSnippet ?? ""
31
+ ].join(" "));
32
+ }
33
+ function buildEntrySignature(entry, outcome) {
34
+ const rawSignature = normalizeText(`${outcome} ${entry.result.destinationTitle ?? ""} ${entry.result.visibleTextSnippet ?? ""} ${entry.result.note}`).toLowerCase();
35
+ return rawSignature.slice(0, 280);
36
+ }
37
+ function detectOutcome(entry) {
38
+ const text = buildEntryText(entry);
39
+ if (!text) {
40
+ return null;
41
+ }
42
+ const looksLikeRulesCopy = HOW_TO_PLAY_PATTERNS.some((pattern) => pattern.test(text));
43
+ const hasReplayCue = REPLAY_PATTERNS.some((pattern) => pattern.test(text));
44
+ const hasRoundContext = ROUND_CONTEXT_PATTERNS.some((pattern) => pattern.test(text));
45
+ if (!hasRoundContext) {
46
+ return null;
47
+ }
48
+ const isWin = WIN_PATTERNS.some((pattern) => pattern.test(text));
49
+ const isLoss = LOSS_PATTERNS.some((pattern) => pattern.test(text));
50
+ const isDraw = DRAW_PATTERNS.some((pattern) => pattern.test(text));
51
+ if (looksLikeRulesCopy && !hasReplayCue && !(isWin || isLoss || isDraw)) {
52
+ return null;
53
+ }
54
+ if (isDraw) {
55
+ return "draw";
56
+ }
57
+ if (isWin && !isLoss) {
58
+ return "win";
59
+ }
60
+ if (isLoss && !isWin) {
61
+ return "loss";
62
+ }
63
+ return null;
64
+ }
65
+ export function isGameplayTask(task) {
66
+ return Boolean(task.gameplay);
67
+ }
68
+ export function summarizeGameplayHistory(history) {
69
+ const evidence = [];
70
+ let wins = 0;
71
+ let losses = 0;
72
+ let draws = 0;
73
+ let howToPlayConfirmed = false;
74
+ let replayConfirmed = false;
75
+ let lastOutcomeSignature = "";
76
+ let lastOutcomeIndex = -100;
77
+ for (const [index, entry] of history.entries()) {
78
+ const text = buildEntryText(entry);
79
+ if (!text) {
80
+ continue;
81
+ }
82
+ if (!howToPlayConfirmed && HOW_TO_PLAY_PATTERNS.some((pattern) => pattern.test(text))) {
83
+ howToPlayConfirmed = true;
84
+ evidence.push(`Visible rules or how-to-play guidance appeared during step ${entry.step}.`);
85
+ }
86
+ if (!replayConfirmed && REPLAY_PATTERNS.some((pattern) => pattern.test(text))) {
87
+ replayConfirmed = true;
88
+ evidence.push(`A replay or restart path was visible during step ${entry.step}.`);
89
+ }
90
+ const outcome = detectOutcome(entry);
91
+ if (!outcome) {
92
+ continue;
93
+ }
94
+ const signature = buildEntrySignature(entry, outcome);
95
+ if (signature === lastOutcomeSignature && index - lastOutcomeIndex <= 1) {
96
+ continue;
97
+ }
98
+ lastOutcomeSignature = signature;
99
+ lastOutcomeIndex = index;
100
+ if (outcome === "win") {
101
+ wins += 1;
102
+ }
103
+ else if (outcome === "loss") {
104
+ losses += 1;
105
+ }
106
+ else {
107
+ draws += 1;
108
+ }
109
+ const label = outcome === "win" ? "win" : outcome === "loss" ? "loss" : "draw";
110
+ evidence.push(`Step ${entry.step} showed a visible ${label} state.`);
111
+ }
112
+ return {
113
+ roundsRecorded: wins + losses + draws,
114
+ wins,
115
+ losses,
116
+ draws,
117
+ howToPlayConfirmed,
118
+ replayConfirmed: replayConfirmed || wins + losses + draws > 1,
119
+ evidence: uniqueItems(evidence, 8)
120
+ };
121
+ }
122
+ export function deriveGameplaySummary(args) {
123
+ const gameplayTasks = args.suite.tasks.filter((task) => isGameplayTask(task));
124
+ const roundsRequested = Math.max(0, ...gameplayTasks.map((task) => task.gameplay?.rounds ?? 0));
125
+ if (roundsRequested <= 0) {
126
+ return undefined;
127
+ }
128
+ const gameplayTaskNames = new Set(gameplayTasks.map((task) => task.name));
129
+ const roundTaskNames = new Set(gameplayTasks.filter((task) => (task.gameplay?.rounds ?? 0) > 0).map((task) => task.name));
130
+ const relevantResults = args.taskResults.filter((task) => gameplayTaskNames.has(task.name));
131
+ const combinedHistory = relevantResults.flatMap((task) => task.history);
132
+ const roundHistory = args.taskResults.filter((task) => roundTaskNames.has(task.name)).flatMap((task) => task.history);
133
+ const combinedSummary = summarizeGameplayHistory(combinedHistory);
134
+ const roundSummary = summarizeGameplayHistory(roundHistory);
135
+ const inconclusiveRounds = Math.max(0, roundsRequested - roundSummary.roundsRecorded);
136
+ const blockerReason = relevantResults.find((task) => task.status !== "success" && normalizeText(task.reason))?.reason ??
137
+ (inconclusiveRounds > 0 ? "The requested number of clear round outcomes was not fully observed." : "");
138
+ const narrative = roundSummary.roundsRecorded >= roundsRequested
139
+ ? `Recorded ${roundSummary.roundsRecorded}/${roundsRequested} requested rounds: ${roundSummary.wins} wins, ${roundSummary.losses} losses, and ${roundSummary.draws} draws.`
140
+ : `Recorded ${roundSummary.roundsRecorded}/${roundsRequested} requested rounds: ${roundSummary.wins} wins, ${roundSummary.losses} losses, ${roundSummary.draws} draws, and ${inconclusiveRounds} inconclusive round(s).`;
141
+ return {
142
+ roundsRequested,
143
+ roundsRecorded: roundSummary.roundsRecorded,
144
+ wins: roundSummary.wins,
145
+ losses: roundSummary.losses,
146
+ draws: roundSummary.draws,
147
+ inconclusiveRounds,
148
+ howToPlayConfirmed: combinedSummary.howToPlayConfirmed,
149
+ replayConfirmed: combinedSummary.replayConfirmed,
150
+ summary: normalizeText([
151
+ narrative,
152
+ combinedSummary.howToPlayConfirmed ? "Visible how-to-play guidance was confirmed." : "Visible how-to-play guidance was not clearly confirmed.",
153
+ combinedSummary.replayConfirmed ? "A replay or restart path was visible." : "A replay or restart path was not clearly confirmed.",
154
+ inconclusiveRounds > 0 && blockerReason ? blockerReason : ""
155
+ ].join(" ")),
156
+ evidence: uniqueItems([
157
+ ...combinedSummary.evidence,
158
+ ...roundSummary.evidence,
159
+ combinedSummary.howToPlayConfirmed ? "How-to-play guidance was visibly encountered." : "",
160
+ combinedSummary.replayConfirmed ? "Replay or restart controls were visibly encountered." : "",
161
+ inconclusiveRounds > 0 && blockerReason ? blockerReason : ""
162
+ ], 8)
163
+ };
164
+ }