@kata-sh/cli 0.1.0 → 0.1.2

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 (199) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +56 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +95 -0
  9. package/dist/resource-loader.d.ts +18 -0
  10. package/dist/resource-loader.js +50 -0
  11. package/dist/wizard.d.ts +15 -0
  12. package/dist/wizard.js +159 -0
  13. package/package.json +50 -21
  14. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  15. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  16. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  17. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  18. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  19. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  20. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  21. package/pkg/package.json +8 -0
  22. package/scripts/postinstall.js +45 -0
  23. package/src/resources/AGENTS.md +108 -0
  24. package/src/resources/KATA-WORKFLOW.md +661 -0
  25. package/src/resources/agents/researcher.md +29 -0
  26. package/src/resources/agents/scout.md +56 -0
  27. package/src/resources/agents/worker.md +31 -0
  28. package/src/resources/extensions/ask-user-questions.ts +200 -0
  29. package/src/resources/extensions/bg-shell/index.ts +2758 -0
  30. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  31. package/src/resources/extensions/browser-tools/core.js +1057 -0
  32. package/src/resources/extensions/browser-tools/index.ts +4916 -0
  33. package/src/resources/extensions/browser-tools/package.json +20 -0
  34. package/src/resources/extensions/context7/index.ts +428 -0
  35. package/src/resources/extensions/context7/package.json +11 -0
  36. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  37. package/src/resources/extensions/github/formatters.ts +207 -0
  38. package/src/resources/extensions/github/gh-api.ts +537 -0
  39. package/src/resources/extensions/github/index.ts +778 -0
  40. package/src/resources/extensions/kata/activity-log.ts +88 -0
  41. package/src/resources/extensions/kata/auto.ts +2786 -0
  42. package/src/resources/extensions/kata/commands.ts +355 -0
  43. package/src/resources/extensions/kata/crash-recovery.ts +85 -0
  44. package/src/resources/extensions/kata/dashboard-overlay.ts +516 -0
  45. package/src/resources/extensions/kata/docs/preferences-reference.md +103 -0
  46. package/src/resources/extensions/kata/doctor.ts +683 -0
  47. package/src/resources/extensions/kata/files.ts +730 -0
  48. package/src/resources/extensions/kata/gitignore.ts +165 -0
  49. package/src/resources/extensions/kata/guided-flow.ts +976 -0
  50. package/src/resources/extensions/kata/index.ts +556 -0
  51. package/src/resources/extensions/kata/metrics.ts +397 -0
  52. package/src/resources/extensions/kata/observability-validator.ts +408 -0
  53. package/src/resources/extensions/kata/package.json +11 -0
  54. package/src/resources/extensions/kata/paths.ts +346 -0
  55. package/src/resources/extensions/kata/preferences.ts +695 -0
  56. package/src/resources/extensions/kata/prompt-loader.ts +50 -0
  57. package/src/resources/extensions/kata/prompts/complete-milestone.md +25 -0
  58. package/src/resources/extensions/kata/prompts/complete-slice.md +27 -0
  59. package/src/resources/extensions/kata/prompts/discuss.md +151 -0
  60. package/src/resources/extensions/kata/prompts/doctor-heal.md +29 -0
  61. package/src/resources/extensions/kata/prompts/execute-task.md +64 -0
  62. package/src/resources/extensions/kata/prompts/guided-complete-slice.md +1 -0
  63. package/src/resources/extensions/kata/prompts/guided-discuss-milestone.md +3 -0
  64. package/src/resources/extensions/kata/prompts/guided-discuss-slice.md +59 -0
  65. package/src/resources/extensions/kata/prompts/guided-execute-task.md +1 -0
  66. package/src/resources/extensions/kata/prompts/guided-plan-milestone.md +23 -0
  67. package/src/resources/extensions/kata/prompts/guided-plan-slice.md +1 -0
  68. package/src/resources/extensions/kata/prompts/guided-research-slice.md +11 -0
  69. package/src/resources/extensions/kata/prompts/guided-resume-task.md +1 -0
  70. package/src/resources/extensions/kata/prompts/plan-milestone.md +47 -0
  71. package/src/resources/extensions/kata/prompts/plan-slice.md +63 -0
  72. package/src/resources/extensions/kata/prompts/queue.md +85 -0
  73. package/src/resources/extensions/kata/prompts/reassess-roadmap.md +48 -0
  74. package/src/resources/extensions/kata/prompts/replan-slice.md +39 -0
  75. package/src/resources/extensions/kata/prompts/research-milestone.md +37 -0
  76. package/src/resources/extensions/kata/prompts/research-slice.md +28 -0
  77. package/src/resources/extensions/kata/prompts/run-uat.md +109 -0
  78. package/src/resources/extensions/kata/prompts/system.md +341 -0
  79. package/src/resources/extensions/kata/session-forensics.ts +550 -0
  80. package/src/resources/extensions/kata/skill-discovery.ts +137 -0
  81. package/src/resources/extensions/kata/state.ts +509 -0
  82. package/src/resources/extensions/kata/templates/context.md +76 -0
  83. package/src/resources/extensions/kata/templates/decisions.md +8 -0
  84. package/src/resources/extensions/kata/templates/milestone-summary.md +73 -0
  85. package/src/resources/extensions/kata/templates/plan.md +133 -0
  86. package/src/resources/extensions/kata/templates/preferences.md +15 -0
  87. package/src/resources/extensions/kata/templates/project.md +31 -0
  88. package/src/resources/extensions/kata/templates/reassessment.md +28 -0
  89. package/src/resources/extensions/kata/templates/requirements.md +81 -0
  90. package/src/resources/extensions/kata/templates/research.md +46 -0
  91. package/src/resources/extensions/kata/templates/roadmap.md +118 -0
  92. package/src/resources/extensions/kata/templates/slice-context.md +58 -0
  93. package/src/resources/extensions/kata/templates/slice-summary.md +99 -0
  94. package/src/resources/extensions/kata/templates/state.md +19 -0
  95. package/src/resources/extensions/kata/templates/task-plan.md +52 -0
  96. package/src/resources/extensions/kata/templates/task-summary.md +57 -0
  97. package/src/resources/extensions/kata/templates/uat.md +54 -0
  98. package/src/resources/extensions/kata/tests/activity-log-prune.test.ts +327 -0
  99. package/src/resources/extensions/kata/tests/auto-preflight.test.ts +97 -0
  100. package/src/resources/extensions/kata/tests/auto-supervisor.test.mjs +53 -0
  101. package/src/resources/extensions/kata/tests/complete-milestone.test.ts +317 -0
  102. package/src/resources/extensions/kata/tests/cost-projection.test.ts +160 -0
  103. package/src/resources/extensions/kata/tests/derive-state-deps.test.ts +477 -0
  104. package/src/resources/extensions/kata/tests/derive-state.test.ts +1013 -0
  105. package/src/resources/extensions/kata/tests/doctor.test.ts +718 -0
  106. package/src/resources/extensions/kata/tests/idle-recovery.test.ts +490 -0
  107. package/src/resources/extensions/kata/tests/metrics-io.test.ts +254 -0
  108. package/src/resources/extensions/kata/tests/metrics.test.ts +217 -0
  109. package/src/resources/extensions/kata/tests/must-have-parser.test.ts +309 -0
  110. package/src/resources/extensions/kata/tests/parsers.test.ts +1257 -0
  111. package/src/resources/extensions/kata/tests/plan-milestone.test.ts +185 -0
  112. package/src/resources/extensions/kata/tests/plan-quality-validator.test.ts +386 -0
  113. package/src/resources/extensions/kata/tests/reassess-prompt.test.ts +208 -0
  114. package/src/resources/extensions/kata/tests/replan-slice.test.ts +686 -0
  115. package/src/resources/extensions/kata/tests/requirements.test.ts +151 -0
  116. package/src/resources/extensions/kata/tests/resolve-ts-hooks.mjs +17 -0
  117. package/src/resources/extensions/kata/tests/resolve-ts.mjs +11 -0
  118. package/src/resources/extensions/kata/tests/run-uat.test.ts +383 -0
  119. package/src/resources/extensions/kata/tests/unit-runtime.test.ts +388 -0
  120. package/src/resources/extensions/kata/tests/workspace-index.test.ts +118 -0
  121. package/src/resources/extensions/kata/tests/worktree.test.ts +222 -0
  122. package/src/resources/extensions/kata/types.ts +159 -0
  123. package/src/resources/extensions/kata/unit-runtime.ts +163 -0
  124. package/src/resources/extensions/kata/workspace-index.ts +203 -0
  125. package/src/resources/extensions/kata/worktree.ts +182 -0
  126. package/src/resources/extensions/mac-tools/index.ts +852 -0
  127. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  128. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  129. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  130. package/src/resources/extensions/search-the-web/format.ts +258 -0
  131. package/src/resources/extensions/search-the-web/http.ts +238 -0
  132. package/src/resources/extensions/search-the-web/index.ts +68 -0
  133. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  134. package/src/resources/extensions/search-the-web/tool-llm-context.ts +404 -0
  135. package/src/resources/extensions/search-the-web/tool-search.ts +503 -0
  136. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  137. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  138. package/src/resources/extensions/shared/interview-ui.ts +822 -0
  139. package/src/resources/extensions/shared/next-action-ui.ts +235 -0
  140. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  141. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  142. package/src/resources/extensions/shared/ui.ts +400 -0
  143. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  144. package/src/resources/extensions/slash-commands/audit.ts +92 -0
  145. package/src/resources/extensions/slash-commands/create-extension.ts +375 -0
  146. package/src/resources/extensions/slash-commands/create-slash-command.ts +280 -0
  147. package/src/resources/extensions/slash-commands/index.ts +12 -0
  148. package/src/resources/extensions/slash-commands/kata-run.ts +34 -0
  149. package/src/resources/extensions/subagent/agents.ts +126 -0
  150. package/src/resources/extensions/subagent/index.ts +1293 -0
  151. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  152. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  153. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  154. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  155. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  156. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  157. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  158. package/src/resources/skills/swiftui/SKILL.md +208 -0
  159. package/src/resources/skills/swiftui/references/animations.md +921 -0
  160. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  161. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  162. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  163. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  164. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  165. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  166. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  167. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  168. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  169. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  170. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  171. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  172. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  173. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  174. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  175. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
  176. package/dist/commands/task.d.ts +0 -9
  177. package/dist/commands/task.d.ts.map +0 -1
  178. package/dist/commands/task.js +0 -129
  179. package/dist/commands/task.js.map +0 -1
  180. package/dist/commands/task.test.d.ts +0 -2
  181. package/dist/commands/task.test.d.ts.map +0 -1
  182. package/dist/commands/task.test.js +0 -169
  183. package/dist/commands/task.test.js.map +0 -1
  184. package/dist/e2e/task-e2e.test.d.ts +0 -2
  185. package/dist/e2e/task-e2e.test.d.ts.map +0 -1
  186. package/dist/e2e/task-e2e.test.js +0 -173
  187. package/dist/e2e/task-e2e.test.js.map +0 -1
  188. package/dist/index.d.ts +0 -3
  189. package/dist/index.d.ts.map +0 -1
  190. package/dist/index.js +0 -93
  191. package/dist/index.js.map +0 -1
  192. package/dist/slug.d.ts +0 -2
  193. package/dist/slug.d.ts.map +0 -1
  194. package/dist/slug.js +0 -12
  195. package/dist/slug.js.map +0 -1
  196. package/dist/slug.test.d.ts +0 -2
  197. package/dist/slug.test.d.ts.map +0 -1
  198. package/dist/slug.test.js +0 -32
  199. package/dist/slug.test.js.map +0 -1
@@ -0,0 +1,1057 @@
1
+ /**
2
+ * Runtime-neutral helper logic for browser-tools.
3
+ *
4
+ * Kept free of pi-specific imports so it can be exercised with node:test.
5
+ */
6
+
7
+ export function createActionTimeline(limit = 60) {
8
+ return {
9
+ limit,
10
+ nextId: 1,
11
+ entries: [],
12
+ };
13
+ }
14
+
15
+ export function beginAction(timeline, partial) {
16
+ const entry = {
17
+ id: timeline.nextId++,
18
+ tool: partial.tool,
19
+ paramsSummary: partial.paramsSummary ?? "",
20
+ startedAt: partial.startedAt ?? Date.now(),
21
+ finishedAt: null,
22
+ status: "running",
23
+ beforeUrl: partial.beforeUrl ?? "",
24
+ afterUrl: partial.afterUrl ?? "",
25
+ verificationSummary: partial.verificationSummary,
26
+ warningSummary: partial.warningSummary,
27
+ diffSummary: partial.diffSummary,
28
+ changed: partial.changed,
29
+ error: partial.error,
30
+ };
31
+ timeline.entries.push(entry);
32
+ if (timeline.entries.length > timeline.limit) {
33
+ timeline.entries.splice(0, timeline.entries.length - timeline.limit);
34
+ }
35
+ return entry;
36
+ }
37
+
38
+ export function finishAction(timeline, actionId, updates = {}) {
39
+ const entry = timeline.entries.find((item) => item.id === actionId);
40
+ if (!entry) return null;
41
+ Object.assign(entry, updates, {
42
+ finishedAt: updates.finishedAt ?? Date.now(),
43
+ status: updates.status ?? entry.status ?? "success",
44
+ afterUrl: updates.afterUrl ?? entry.afterUrl ?? "",
45
+ verificationSummary: updates.verificationSummary ?? entry.verificationSummary,
46
+ warningSummary: updates.warningSummary ?? entry.warningSummary,
47
+ diffSummary: updates.diffSummary ?? entry.diffSummary,
48
+ changed: updates.changed ?? entry.changed,
49
+ error: updates.error ?? entry.error,
50
+ });
51
+ return entry;
52
+ }
53
+
54
+ export function findAction(timeline, actionId) {
55
+ return timeline.entries.find((item) => item.id === actionId) ?? null;
56
+ }
57
+
58
+ export function toActionParamsSummary(params) {
59
+ if (!params || typeof params !== "object") return "";
60
+ const entries = [];
61
+ for (const [key, value] of Object.entries(params)) {
62
+ if (value === undefined || value === null) continue;
63
+ if (typeof value === "string") {
64
+ entries.push(`${key}=${JSON.stringify(value.length > 60 ? `${value.slice(0, 57)}...` : value)}`);
65
+ continue;
66
+ }
67
+ if (Array.isArray(value)) {
68
+ entries.push(`${key}=[${value.length}]`);
69
+ continue;
70
+ }
71
+ if (typeof value === "object") {
72
+ entries.push(`${key}={...}`);
73
+ continue;
74
+ }
75
+ entries.push(`${key}=${String(value)}`);
76
+ }
77
+ return entries.slice(0, 6).join(", ");
78
+ }
79
+
80
+ export function diffCompactStates(before, after) {
81
+ const changes = [];
82
+ if (!before || !after) {
83
+ return {
84
+ changed: false,
85
+ changes: [],
86
+ summary: "Diff unavailable",
87
+ };
88
+ }
89
+
90
+ if (before.url !== after.url) {
91
+ changes.push({ type: "url", before: before.url, after: after.url });
92
+ }
93
+ if (before.title !== after.title) {
94
+ changes.push({ type: "title", before: before.title, after: after.title });
95
+ }
96
+ if (before.focus !== after.focus) {
97
+ changes.push({ type: "focus", before: before.focus, after: after.focus });
98
+ }
99
+ if ((before.dialog?.count ?? 0) !== (after.dialog?.count ?? 0)) {
100
+ changes.push({
101
+ type: "dialog_count",
102
+ before: before.dialog?.count ?? 0,
103
+ after: after.dialog?.count ?? 0,
104
+ });
105
+ }
106
+ if ((before.dialog?.title ?? "") !== (after.dialog?.title ?? "")) {
107
+ changes.push({
108
+ type: "dialog_title",
109
+ before: before.dialog?.title ?? "",
110
+ after: after.dialog?.title ?? "",
111
+ });
112
+ }
113
+
114
+ for (const key of ["landmarks", "buttons", "links", "inputs"]) {
115
+ const beforeValue = before.counts?.[key] ?? 0;
116
+ const afterValue = after.counts?.[key] ?? 0;
117
+ if (beforeValue !== afterValue) {
118
+ changes.push({ type: `count:${key}`, before: beforeValue, after: afterValue });
119
+ }
120
+ }
121
+
122
+ const beforeHeadings = JSON.stringify(before.headings ?? []);
123
+ const afterHeadings = JSON.stringify(after.headings ?? []);
124
+ if (beforeHeadings !== afterHeadings) {
125
+ changes.push({
126
+ type: "headings",
127
+ before: before.headings ?? [],
128
+ after: after.headings ?? [],
129
+ });
130
+ }
131
+
132
+ const beforeBody = before.bodyText ?? "";
133
+ const afterBody = after.bodyText ?? "";
134
+ if (beforeBody !== afterBody) {
135
+ changes.push({
136
+ type: "body_text",
137
+ before: beforeBody.slice(0, 120),
138
+ after: afterBody.slice(0, 120),
139
+ });
140
+ }
141
+
142
+ const changed = changes.length > 0;
143
+ const summary = changed
144
+ ? changes
145
+ .slice(0, 4)
146
+ .map((change) => {
147
+ if (change.type === "url") return `URL changed to ${change.after}`;
148
+ if (change.type === "title") return `title changed to ${change.after}`;
149
+ if (change.type === "focus") return `focus changed`;
150
+ if (change.type === "dialog_count") return `dialog count ${change.before}→${change.after}`;
151
+ if (change.type.startsWith("count:")) return `${change.type.slice(6)} ${change.before}→${change.after}`;
152
+ if (change.type === "headings") return "headings changed";
153
+ if (change.type === "body_text") return "visible text changed";
154
+ return `${change.type} changed`;
155
+ })
156
+ .join("; ")
157
+ : "No meaningful browser-state change detected";
158
+
159
+ return { changed, changes, summary };
160
+ }
161
+
162
+ function normalizeString(value) {
163
+ return String(value ?? "").trim();
164
+ }
165
+
166
+ export function includesNeedle(haystack, needle) {
167
+ return normalizeString(haystack).toLowerCase().includes(normalizeString(needle).toLowerCase());
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Threshold parsing for count-based assertions
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Parse a threshold expression like ">=3", "==0", "<5", or bare "3" (defaults to ">=").
176
+ * @param {string} value
177
+ * @returns {{ op: string, n: number } | null} — null if malformed
178
+ */
179
+ export function parseThreshold(value) {
180
+ if (value == null) return null;
181
+ const str = String(value).trim();
182
+ if (str === "") return null;
183
+ const match = str.match(/^(>=|<=|==|>|<)?\s*(\d+)$/);
184
+ if (!match) return null;
185
+ const op = match[1] || ">=";
186
+ const n = parseInt(match[2], 10);
187
+ return { op, n };
188
+ }
189
+
190
+ /**
191
+ * Evaluate whether a count meets a parsed threshold.
192
+ * @param {number} count
193
+ * @param {{ op: string, n: number }} threshold
194
+ * @returns {boolean}
195
+ */
196
+ export function meetsThreshold(count, threshold) {
197
+ switch (threshold.op) {
198
+ case ">=": return count >= threshold.n;
199
+ case "<=": return count <= threshold.n;
200
+ case "==": return count === threshold.n;
201
+ case ">": return count > threshold.n;
202
+ case "<": return count < threshold.n;
203
+ default: return false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Filter entries that occurred at or after a given action's start time.
209
+ * If sinceActionId is missing or the action isn't found, returns all entries.
210
+ * @param {Array<{ timestamp?: number }>} entries
211
+ * @param {number | undefined} sinceActionId
212
+ * @param {{ entries: Array<{ id: number, startedAt: number }> }} timeline
213
+ * @returns {Array}
214
+ */
215
+ export function getEntriesSince(entries, sinceActionId, timeline) {
216
+ if (!entries || !Array.isArray(entries)) return [];
217
+ if (sinceActionId == null || !timeline) return entries;
218
+ const action = findAction(timeline, sinceActionId);
219
+ if (!action) return entries;
220
+ const since = action.startedAt;
221
+ return entries.filter((e) => (e.timestamp ?? 0) >= since);
222
+ }
223
+
224
+ export function evaluateAssertionChecks({ checks, state }) {
225
+ const results = [];
226
+ const selectorStates = state.selectorStates ?? {};
227
+ const consoleEntries = state.consoleEntries ?? [];
228
+ const networkEntries = state.networkEntries ?? [];
229
+ const allConsoleEntries = state.allConsoleEntries ?? state.consoleEntries ?? [];
230
+ const allNetworkEntries = state.allNetworkEntries ?? state.networkEntries ?? [];
231
+ const actionTimeline = state.actionTimeline ?? null;
232
+
233
+ for (const check of checks) {
234
+ const selectorState = check.selector ? selectorStates[check.selector] ?? null : null;
235
+ let passed = false;
236
+ let actual;
237
+ let expected;
238
+
239
+ switch (check.kind) {
240
+ case "url_contains":
241
+ actual = state.url ?? "";
242
+ expected = check.value ?? "";
243
+ passed = includesNeedle(actual, expected);
244
+ break;
245
+ case "title_contains":
246
+ actual = state.title ?? "";
247
+ expected = check.value ?? "";
248
+ passed = includesNeedle(actual, expected);
249
+ break;
250
+ case "text_visible":
251
+ actual = state.bodyText ?? "";
252
+ expected = check.text ?? "";
253
+ passed = includesNeedle(actual, expected);
254
+ break;
255
+ case "text_not_visible":
256
+ actual = state.bodyText ?? "";
257
+ expected = check.text ?? "";
258
+ passed = !includesNeedle(actual, expected);
259
+ break;
260
+ case "selector_visible":
261
+ actual = selectorState?.visible ?? false;
262
+ expected = true;
263
+ passed = actual === true;
264
+ break;
265
+ case "selector_hidden":
266
+ actual = selectorState?.visible ?? false;
267
+ expected = false;
268
+ passed = actual === false;
269
+ break;
270
+ case "value_equals":
271
+ actual = selectorState?.value ?? "";
272
+ expected = check.value ?? "";
273
+ passed = actual === expected;
274
+ break;
275
+ case "value_contains":
276
+ actual = selectorState?.value ?? "";
277
+ expected = check.value ?? "";
278
+ passed = includesNeedle(actual, expected);
279
+ break;
280
+ case "focused_matches":
281
+ actual = state.focus ?? "";
282
+ expected = check.value ?? "";
283
+ passed = includesNeedle(actual, expected);
284
+ break;
285
+ case "checked_equals":
286
+ actual = selectorState?.checked ?? null;
287
+ expected = !!check.checked;
288
+ passed = actual === expected;
289
+ break;
290
+ case "no_console_errors":
291
+ actual = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror").length;
292
+ expected = 0;
293
+ passed = actual === 0;
294
+ break;
295
+ case "no_failed_requests":
296
+ actual = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400)).length;
297
+ expected = 0;
298
+ passed = actual === 0;
299
+ break;
300
+
301
+ // --- S02: New structured network/console assertion kinds ---
302
+
303
+ case "request_url_seen": {
304
+ const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
305
+ const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
306
+ actual = matches.length > 0;
307
+ expected = true;
308
+ passed = actual === true;
309
+ break;
310
+ }
311
+
312
+ case "response_status": {
313
+ const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
314
+ const statusNum = parseInt(check.value, 10);
315
+ const matches = filtered.filter(
316
+ (e) => includesNeedle(e.url ?? "", check.text ?? "") && typeof e.status === "number" && e.status === statusNum
317
+ );
318
+ actual = matches.length > 0 ? `found (status=${matches[0].status})` : `not found`;
319
+ expected = `status=${check.value ?? ""}`;
320
+ passed = matches.length > 0;
321
+ break;
322
+ }
323
+
324
+ case "console_message_matches": {
325
+ const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
326
+ const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
327
+ actual = matches.length > 0;
328
+ expected = true;
329
+ passed = actual === true;
330
+ break;
331
+ }
332
+
333
+ case "network_count": {
334
+ const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
335
+ const matches = filtered.filter((e) => includesNeedle(e.url ?? "", check.text ?? ""));
336
+ const threshold = parseThreshold(check.value);
337
+ if (!threshold) {
338
+ actual = `invalid threshold: ${check.value}`;
339
+ expected = check.value ?? "";
340
+ passed = false;
341
+ } else {
342
+ actual = `count=${matches.length}`;
343
+ expected = `${threshold.op}${threshold.n}`;
344
+ passed = meetsThreshold(matches.length, threshold);
345
+ }
346
+ break;
347
+ }
348
+
349
+ case "console_count": {
350
+ const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
351
+ const matches = filtered.filter((e) => includesNeedle(e.text ?? "", check.text ?? ""));
352
+ const threshold = parseThreshold(check.value);
353
+ if (!threshold) {
354
+ actual = `invalid threshold: ${check.value}`;
355
+ expected = check.value ?? "";
356
+ passed = false;
357
+ } else {
358
+ actual = `count=${matches.length}`;
359
+ expected = `${threshold.op}${threshold.n}`;
360
+ passed = meetsThreshold(matches.length, threshold);
361
+ }
362
+ break;
363
+ }
364
+
365
+ case "no_console_errors_since": {
366
+ const filtered = getEntriesSince(allConsoleEntries, check.sinceActionId, actionTimeline);
367
+ const errors = filtered.filter((e) => e.type === "error" || e.type === "pageerror");
368
+ actual = errors.length;
369
+ expected = 0;
370
+ passed = errors.length === 0;
371
+ break;
372
+ }
373
+
374
+ case "no_failed_requests_since": {
375
+ const filtered = getEntriesSince(allNetworkEntries, check.sinceActionId, actionTimeline);
376
+ const failures = filtered.filter((e) => e.failed || (typeof e.status === "number" && e.status >= 400));
377
+ actual = failures.length;
378
+ expected = 0;
379
+ passed = failures.length === 0;
380
+ break;
381
+ }
382
+
383
+ default:
384
+ actual = "unsupported";
385
+ expected = check.kind;
386
+ passed = false;
387
+ break;
388
+ }
389
+
390
+ results.push({
391
+ name: check.kind,
392
+ passed,
393
+ actual,
394
+ expected,
395
+ selector: check.selector,
396
+ text: check.text,
397
+ });
398
+ }
399
+
400
+ const failed = results.filter((result) => !result.passed);
401
+ const verified = failed.length === 0;
402
+ return {
403
+ verified,
404
+ checks: results,
405
+ summary: verified
406
+ ? `PASS (${results.length}/${results.length} checks)`
407
+ : `FAIL (${failed.length}/${results.length} checks failed)`,
408
+ agentHint: verified
409
+ ? "All assertion checks passed"
410
+ : failed[0]
411
+ ? `Investigate ${failed[0].name} (expected ${JSON.stringify(failed[0].expected)}, got ${JSON.stringify(failed[0].actual)})`
412
+ : "Assertion failed",
413
+ };
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Wait-condition validation
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /**
421
+ * All recognized wait conditions with their parameter requirements.
422
+ * Each entry: { needsValue: bool, valueLabel: string, needsThreshold?: bool }
423
+ */
424
+ const WAIT_CONDITIONS = {
425
+ // Existing 5 conditions
426
+ selector_visible: { needsValue: true, valueLabel: "CSS selector" },
427
+ selector_hidden: { needsValue: true, valueLabel: "CSS selector" },
428
+ url_contains: { needsValue: true, valueLabel: "URL substring" },
429
+ network_idle: { needsValue: false, valueLabel: "" },
430
+ delay: { needsValue: true, valueLabel: "milliseconds as a string (e.g. '1000')" },
431
+
432
+ // New 6 conditions (S03)
433
+ text_visible: { needsValue: true, valueLabel: "text to search for" },
434
+ text_hidden: { needsValue: true, valueLabel: "text to search for" },
435
+ request_completed: { needsValue: true, valueLabel: "URL substring to match" },
436
+ console_message: { needsValue: true, valueLabel: "message substring to match" },
437
+ element_count: { needsValue: true, valueLabel: "CSS selector", needsThreshold: true },
438
+ region_stable: { needsValue: true, valueLabel: "CSS selector" },
439
+ };
440
+
441
+ /**
442
+ * Validate parameters for a browser_wait_for condition.
443
+ * @param {{ condition: string, value?: string, threshold?: string }} params
444
+ * @returns {null | { error: string }} — null if valid, structured error otherwise
445
+ */
446
+ export function validateWaitParams(params) {
447
+ const { condition, value, threshold } = params ?? {};
448
+
449
+ if (!condition) {
450
+ return { error: "condition is required" };
451
+ }
452
+
453
+ const spec = WAIT_CONDITIONS[condition];
454
+ if (!spec) {
455
+ const known = Object.keys(WAIT_CONDITIONS).join(", ");
456
+ return { error: `unknown condition "${condition}". Known conditions: ${known}` };
457
+ }
458
+
459
+ if (spec.needsValue && (!value || String(value).trim() === "")) {
460
+ return { error: `${condition} requires a value (${spec.valueLabel})` };
461
+ }
462
+
463
+ if (spec.needsThreshold && threshold != null && String(threshold).trim() !== "") {
464
+ const parsed = parseThreshold(threshold);
465
+ if (!parsed) {
466
+ return { error: `${condition} threshold is malformed: "${threshold}". Expected format: >=N, <=N, ==N, >N, <N, or bare N` };
467
+ }
468
+ }
469
+
470
+ return null;
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Region-stable script generator
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /**
478
+ * Generate a JS expression string for page.waitForFunction() that detects
479
+ * DOM stability by comparing snapshot hashes across polling intervals.
480
+ *
481
+ * The script stores a snapshot on a namespaced window key. When the snapshot
482
+ * matches the previous value, the region is considered stable.
483
+ *
484
+ * @param {string} selector — CSS selector for the target element
485
+ * @returns {string} — self-contained JS function body suitable for waitForFunction
486
+ */
487
+ export function createRegionStableScript(selector) {
488
+ // Create a stable key from the selector (simple hash to avoid special chars)
489
+ const safeKey = Array.from(selector).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0;
490
+ const windowKey = `__pw_region_stable_${safeKey}`;
491
+
492
+ return `(() => {
493
+ const el = document.querySelector(${JSON.stringify(selector)});
494
+ if (!el) return false;
495
+ const snapshot = el.innerHTML.length + '|' + el.childElementCount + '|' + el.innerText.length;
496
+ const prev = window[${JSON.stringify(windowKey)}];
497
+ window[${JSON.stringify(windowKey)}] = snapshot;
498
+ if (prev === undefined) return false;
499
+ return snapshot === prev;
500
+ })()`;
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Page Registry — pure-logic operations for multi-page/tab management
505
+ // ---------------------------------------------------------------------------
506
+
507
+ /**
508
+ * Create a fresh page registry.
509
+ * @returns {{ pages: Array, activePageId: number | null, nextId: number }}
510
+ */
511
+ export function createPageRegistry() {
512
+ return { pages: [], activePageId: null, nextId: 1 };
513
+ }
514
+
515
+ /**
516
+ * @typedef {{ id: number, page: any, title: string, url: string, opener: number | null }} PageEntry
517
+ */
518
+
519
+ /**
520
+ * Add a page to the registry. Assigns an auto-incrementing ID.
521
+ * @param {ReturnType<typeof createPageRegistry>} registry
522
+ * @param {{ page: any, title?: string, url?: string, opener?: number | null }} info
523
+ * @returns {PageEntry}
524
+ */
525
+ export function registryAddPage(registry, { page, title = "", url = "", opener = null }) {
526
+ const entry = { id: registry.nextId++, page, title, url, opener };
527
+ registry.pages.push(entry);
528
+ return entry;
529
+ }
530
+
531
+ /**
532
+ * Remove a page from the registry by ID.
533
+ * If the removed page was active, falls back to the opener (if still present)
534
+ * or the last remaining page.
535
+ * Orphans any pages whose opener was the removed page (sets their opener to null).
536
+ * @param {ReturnType<typeof createPageRegistry>} registry
537
+ * @param {number} pageId
538
+ * @returns {{ removed: PageEntry, newActiveId: number | null }}
539
+ */
540
+ export function registryRemovePage(registry, pageId) {
541
+ const idx = registry.pages.findIndex((p) => p.id === pageId);
542
+ if (idx === -1) {
543
+ const available = registry.pages.map((p) => p.id);
544
+ throw new Error(
545
+ `registryRemovePage: page ${pageId} not found. ` +
546
+ `Available page IDs: [${available.join(", ")}]. ` +
547
+ `Registry size: ${registry.pages.length}.`
548
+ );
549
+ }
550
+ const [removed] = registry.pages.splice(idx, 1);
551
+
552
+ // Orphan any pages whose opener was the removed page
553
+ for (const entry of registry.pages) {
554
+ if (entry.opener === pageId) {
555
+ entry.opener = null;
556
+ }
557
+ }
558
+
559
+ let newActiveId = registry.activePageId;
560
+ if (registry.activePageId === pageId) {
561
+ if (registry.pages.length === 0) {
562
+ newActiveId = null;
563
+ } else if (removed.opener !== null && registry.pages.some((p) => p.id === removed.opener)) {
564
+ newActiveId = removed.opener;
565
+ } else {
566
+ newActiveId = registry.pages[registry.pages.length - 1].id;
567
+ }
568
+ registry.activePageId = newActiveId;
569
+ }
570
+
571
+ return { removed, newActiveId };
572
+ }
573
+
574
+ /**
575
+ * Set the active page by ID. Throws if the page is not in the registry.
576
+ * @param {ReturnType<typeof createPageRegistry>} registry
577
+ * @param {number} pageId
578
+ */
579
+ export function registrySetActive(registry, pageId) {
580
+ const entry = registry.pages.find((p) => p.id === pageId);
581
+ if (!entry) {
582
+ const available = registry.pages.map((p) => p.id);
583
+ throw new Error(
584
+ `registrySetActive: page ${pageId} not found. ` +
585
+ `Available page IDs: [${available.join(", ")}]. ` +
586
+ `Registry size: ${registry.pages.length}.`
587
+ );
588
+ }
589
+ registry.activePageId = pageId;
590
+ }
591
+
592
+ /**
593
+ * Get the active page entry. Throws if no active page or active page not found.
594
+ * @param {ReturnType<typeof createPageRegistry>} registry
595
+ * @returns {PageEntry}
596
+ */
597
+ export function registryGetActive(registry) {
598
+ if (registry.activePageId === null) {
599
+ throw new Error(
600
+ `registryGetActive: no active page. ` +
601
+ `Registry contains ${registry.pages.length} page(s). ` +
602
+ `Page IDs: [${registry.pages.map((p) => p.id).join(", ")}].`
603
+ );
604
+ }
605
+ const entry = registry.pages.find((p) => p.id === registry.activePageId);
606
+ if (!entry) {
607
+ throw new Error(
608
+ `registryGetActive: activePageId ${registry.activePageId} not found in registry. ` +
609
+ `Available page IDs: [${registry.pages.map((p) => p.id).join(", ")}]. ` +
610
+ `Registry size: ${registry.pages.length}. This indicates stale state.`
611
+ );
612
+ }
613
+ return entry;
614
+ }
615
+
616
+ /**
617
+ * Get a page entry by ID, or null if not found.
618
+ * @param {ReturnType<typeof createPageRegistry>} registry
619
+ * @param {number} pageId
620
+ * @returns {PageEntry | null}
621
+ */
622
+ export function registryGetPage(registry, pageId) {
623
+ return registry.pages.find((p) => p.id === pageId) ?? null;
624
+ }
625
+
626
+ /**
627
+ * List all pages (without the raw `page` reference).
628
+ * @param {ReturnType<typeof createPageRegistry>} registry
629
+ * @returns {Array<{ id: number, title: string, url: string, opener: number | null, isActive: boolean }>}
630
+ */
631
+ export function registryListPages(registry) {
632
+ return registry.pages.map((entry) => ({
633
+ id: entry.id,
634
+ title: entry.title,
635
+ url: entry.url,
636
+ opener: entry.opener,
637
+ isActive: entry.id === registry.activePageId,
638
+ }));
639
+ }
640
+
641
+ // ---------------------------------------------------------------------------
642
+ // FIFO Bounded Log Pusher
643
+ // ---------------------------------------------------------------------------
644
+
645
+ /**
646
+ * Create a push function that enforces FIFO eviction at push-time.
647
+ * @param {number} maxSize — maximum number of entries to retain
648
+ * @returns {(array: Array, entry: any) => void}
649
+ */
650
+ export function createBoundedLogPusher(maxSize) {
651
+ return function push(array, entry) {
652
+ array.push(entry);
653
+ if (array.length > maxSize) {
654
+ array.splice(0, array.length - maxSize);
655
+ }
656
+ };
657
+ }
658
+
659
+ export async function runBatchSteps({ steps, executeStep, stopOnFailure = true }) {
660
+ const results = [];
661
+ for (let i = 0; i < steps.length; i += 1) {
662
+ const step = steps[i];
663
+ const result = await executeStep(step, i);
664
+ results.push(result);
665
+ if (result.ok === false && stopOnFailure) {
666
+ return {
667
+ ok: false,
668
+ stopReason: "step_failed",
669
+ failedStepIndex: i,
670
+ stepResults: results,
671
+ summary: `Stopped at step ${i + 1} (${step.action})`,
672
+ };
673
+ }
674
+ }
675
+ return {
676
+ ok: true,
677
+ stopReason: null,
678
+ failedStepIndex: null,
679
+ stepResults: results,
680
+ summary: `Completed ${results.length} step(s)`,
681
+ };
682
+ }
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // Snapshot Modes — semantic element filtering for browser_snapshot_refs
686
+ // ---------------------------------------------------------------------------
687
+
688
+ /**
689
+ * Pre-defined snapshot modes that filter elements by semantic category.
690
+ * Each mode config defines which elements should be captured.
691
+ *
692
+ * Shape: { tags: string[], roles: string[], selectors: string[],
693
+ * ariaAttributes: string[], useInteractiveFilter: boolean,
694
+ * visibleOnly?: boolean, containerExpand?: boolean }
695
+ */
696
+ export const SNAPSHOT_MODES = {
697
+ interactive: {
698
+ tags: [],
699
+ roles: [],
700
+ selectors: [],
701
+ ariaAttributes: [],
702
+ useInteractiveFilter: true,
703
+ },
704
+ form: {
705
+ tags: ["input", "select", "textarea", "button", "fieldset", "label", "output", "datalist"],
706
+ roles: ["textbox", "searchbox", "combobox", "checkbox", "radio", "switch", "slider", "spinbutton", "listbox", "option"],
707
+ selectors: ["[contenteditable]"],
708
+ ariaAttributes: [],
709
+ useInteractiveFilter: false,
710
+ },
711
+ dialog: {
712
+ tags: ["dialog"],
713
+ roles: ["dialog", "alertdialog"],
714
+ selectors: ['[role="dialog"]', '[role="alertdialog"]'],
715
+ ariaAttributes: [],
716
+ useInteractiveFilter: false,
717
+ containerExpand: true,
718
+ },
719
+ navigation: {
720
+ tags: ["a", "nav"],
721
+ roles: ["link", "navigation", "menubar", "menu", "menuitem"],
722
+ selectors: [],
723
+ ariaAttributes: [],
724
+ useInteractiveFilter: false,
725
+ },
726
+ errors: {
727
+ tags: [],
728
+ roles: ["alert", "status"],
729
+ selectors: ['[aria-invalid="true"]', '[role="alert"]', '[role="status"]'],
730
+ ariaAttributes: ["aria-invalid", "aria-errormessage"],
731
+ useInteractiveFilter: false,
732
+ containerExpand: true,
733
+ },
734
+ headings: {
735
+ tags: ["h1", "h2", "h3", "h4", "h5", "h6"],
736
+ roles: ["heading"],
737
+ selectors: [],
738
+ ariaAttributes: [],
739
+ useInteractiveFilter: false,
740
+ },
741
+ visible_only: {
742
+ tags: [],
743
+ roles: [],
744
+ selectors: [],
745
+ ariaAttributes: [],
746
+ useInteractiveFilter: false,
747
+ visibleOnly: true,
748
+ },
749
+ };
750
+
751
+ /**
752
+ * Get the snapshot mode config by name.
753
+ * @param {string} mode — mode name (e.g. "form", "dialog", "interactive")
754
+ * @returns {{ tags: string[], roles: string[], selectors: string[], ariaAttributes: string[], useInteractiveFilter: boolean, visibleOnly?: boolean, containerExpand?: boolean } | null}
755
+ */
756
+ export function getSnapshotModeConfig(mode) {
757
+ return SNAPSHOT_MODES[mode] ?? null;
758
+ }
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // Fingerprint functions — structural identity for ref resolution
762
+ // ---------------------------------------------------------------------------
763
+
764
+ /**
765
+ * Compute a content hash from visible text using djb2.
766
+ * Caller is expected to pre-truncate to ~200 chars and normalize whitespace.
767
+ * @param {string} text — visible text content
768
+ * @returns {string} — hex string hash, or "0" for empty input
769
+ */
770
+ export function computeContentHash(text) {
771
+ if (!text) return "0";
772
+ let h = 5381;
773
+ for (let i = 0; i < text.length; i++) {
774
+ h = ((h << 5) - h + text.charCodeAt(i)) | 0;
775
+ }
776
+ return (h >>> 0).toString(16);
777
+ }
778
+
779
+ /**
780
+ * Compute a structural signature from tag, role, and immediate child tag names.
781
+ * Uses djb2 hash on the concatenated string `tag|role|child1,child2,...`.
782
+ * @param {string} tag — element tag name (lowercase)
783
+ * @param {string} role — ARIA role or empty string
784
+ * @param {string[]} childTags — array of immediate child tag names (lowercase)
785
+ * @returns {string} — hex string hash
786
+ */
787
+ export function computeStructuralSignature(tag, role, childTags) {
788
+ const input = `${tag}|${role}|${childTags.join(",")}`;
789
+ let h = 5381;
790
+ for (let i = 0; i < input.length; i++) {
791
+ h = ((h << 5) - h + input.charCodeAt(i)) | 0;
792
+ }
793
+ return (h >>> 0).toString(16);
794
+ }
795
+
796
+ /**
797
+ * Match two fingerprint objects by contentHash and structuralSignature.
798
+ * Returns true only when both fields are present on both objects and both match.
799
+ * @param {{ contentHash?: string, structuralSignature?: string }} stored
800
+ * @param {{ contentHash?: string, structuralSignature?: string }} candidate
801
+ * @returns {boolean}
802
+ */
803
+ export function matchFingerprint(stored, candidate) {
804
+ if (!stored || !candidate) return false;
805
+ if (!stored.contentHash || !stored.structuralSignature) return false;
806
+ if (!candidate.contentHash || !candidate.structuralSignature) return false;
807
+ return stored.contentHash === candidate.contentHash &&
808
+ stored.structuralSignature === candidate.structuralSignature;
809
+ }
810
+
811
+ function formatDurationMs(entry) {
812
+ const startedAt = typeof entry?.startedAt === "number" ? entry.startedAt : null;
813
+ const finishedAt = typeof entry?.finishedAt === "number" ? entry.finishedAt : null;
814
+ if (startedAt == null || finishedAt == null || finishedAt < startedAt) return null;
815
+ return finishedAt - startedAt;
816
+ }
817
+
818
+ function summarizeActionStatus(status) {
819
+ if (status === "error") return "error";
820
+ if (status === "running") return "running";
821
+ return "success";
822
+ }
823
+
824
+ function looksBoundedWarning(value) {
825
+ return /bounded .*history/i.test(String(value ?? ""));
826
+ }
827
+
828
+ function uniqueStrings(values) {
829
+ return [...new Set(values.filter(Boolean))];
830
+ }
831
+
832
+ export function formatTimelineEntries(entries = [], options = {}) {
833
+ const retained = options.retained ?? entries.length;
834
+ const totalRecorded = options.totalRecorded ?? retained;
835
+ const bounded = totalRecorded > retained;
836
+
837
+ if (!entries.length) {
838
+ return {
839
+ entries: [],
840
+ retained,
841
+ totalRecorded,
842
+ bounded,
843
+ summary: "No browser actions recorded.",
844
+ };
845
+ }
846
+
847
+ const formattedEntries = entries.map((entry) => {
848
+ const status = summarizeActionStatus(entry.status);
849
+ const durationMs = formatDurationMs(entry);
850
+ const parts = [
851
+ `#${entry.id ?? "?"}`,
852
+ entry.tool ?? "unknown_tool",
853
+ status,
854
+ ];
855
+
856
+ if (durationMs != null) parts.push(`${durationMs}ms`);
857
+ if (entry.paramsSummary) parts.push(entry.paramsSummary);
858
+ if (entry.error) parts.push(entry.error);
859
+ if (entry.verificationSummary) parts.push(entry.verificationSummary);
860
+ if (entry.diffSummary) parts.push(entry.diffSummary);
861
+ if (entry.warningSummary) parts.push(entry.warningSummary);
862
+
863
+ return {
864
+ id: entry.id ?? null,
865
+ tool: entry.tool ?? "",
866
+ status,
867
+ durationMs,
868
+ beforeUrl: entry.beforeUrl ?? "",
869
+ afterUrl: entry.afterUrl ?? "",
870
+ line: parts.join(" | "),
871
+ };
872
+ });
873
+
874
+ const summary = bounded
875
+ ? `Timeline: showing ${retained} of ${totalRecorded} recorded browser actions; older actions were discarded due to bounded history.`
876
+ : `Timeline: ${retained} browser action${retained === 1 ? "" : "s"} recorded.`;
877
+
878
+ return {
879
+ entries: formattedEntries,
880
+ retained,
881
+ totalRecorded,
882
+ bounded,
883
+ summary,
884
+ };
885
+ }
886
+
887
+ export function buildFailureHypothesis(session = {}) {
888
+ const timelineEntries = session.actionTimeline?.entries ?? [];
889
+ const consoleEntries = session.consoleEntries ?? [];
890
+ const networkEntries = session.networkEntries ?? [];
891
+ const dialogEntries = session.dialogEntries ?? [];
892
+ const signals = [];
893
+
894
+ for (const entry of timelineEntries) {
895
+ if (entry?.status !== "error") continue;
896
+ if (entry.tool === "browser_wait_for") {
897
+ signals.push({
898
+ category: "wait",
899
+ source: `action#${entry.id ?? "?"}`,
900
+ detail: entry.error || entry.warningSummary || "Wait condition failed",
901
+ });
902
+ continue;
903
+ }
904
+ if (entry.tool === "browser_assert") {
905
+ signals.push({
906
+ category: "assert",
907
+ source: `action#${entry.id ?? "?"}`,
908
+ detail: entry.error || entry.verificationSummary || "Assertion failed",
909
+ });
910
+ continue;
911
+ }
912
+ signals.push({
913
+ category: "action",
914
+ source: `action#${entry.id ?? "?"}`,
915
+ detail: entry.error || `${entry.tool ?? "browser action"} failed`,
916
+ });
917
+ }
918
+
919
+ for (const entry of consoleEntries) {
920
+ if (entry?.type !== "error" && entry?.type !== "pageerror") continue;
921
+ signals.push({
922
+ category: "console",
923
+ source: entry.type,
924
+ detail: entry.text || "Console error recorded",
925
+ });
926
+ }
927
+
928
+ for (const entry of networkEntries) {
929
+ const failed = entry?.failed || (typeof entry?.status === "number" && entry.status >= 400);
930
+ if (!failed) continue;
931
+ signals.push({
932
+ category: "network",
933
+ source: entry.url || "network request",
934
+ detail: `${entry.url || "request"} failed${typeof entry?.status === "number" ? ` with ${entry.status}` : ""}`,
935
+ });
936
+ }
937
+
938
+ for (const entry of dialogEntries) {
939
+ signals.push({
940
+ category: "dialog",
941
+ source: entry?.type || "dialog",
942
+ detail: entry?.message || "Dialog appeared during failure investigation",
943
+ });
944
+ }
945
+
946
+ const categories = uniqueStrings(signals.map((signal) => signal.category));
947
+ const hasFailures = categories.length > 0;
948
+ const summary = hasFailures
949
+ ? `Recent failure signals detected across ${categories.join(", ")}.`
950
+ : "No recent failure signals detected.";
951
+
952
+ return {
953
+ hasFailures,
954
+ categories,
955
+ summary,
956
+ signals,
957
+ };
958
+ }
959
+
960
+ export function summarizeBrowserSession(session = {}) {
961
+ const actionTimeline = session.actionTimeline ?? { limit: 0, entries: [] };
962
+ const actionEntries = actionTimeline.entries ?? [];
963
+ const retainedActionCount = session.retainedActionCount ?? actionEntries.length;
964
+ const totalActionCount = session.totalActionCount ?? retainedActionCount;
965
+ const pages = session.pages ?? [];
966
+ const consoleEntries = session.consoleEntries ?? [];
967
+ const networkEntries = session.networkEntries ?? [];
968
+ const dialogEntries = session.dialogEntries ?? [];
969
+
970
+ const actionStatusCounts = actionEntries.reduce(
971
+ (acc, entry) => {
972
+ const status = summarizeActionStatus(entry.status);
973
+ acc[status] = (acc[status] ?? 0) + 1;
974
+ return acc;
975
+ },
976
+ { success: 0, error: 0, running: 0 },
977
+ );
978
+
979
+ const waitEntries = actionEntries.filter((entry) => entry.tool === "browser_wait_for");
980
+ const assertEntries = actionEntries.filter((entry) => entry.tool === "browser_assert");
981
+ const consoleErrors = consoleEntries.filter((entry) => entry.type === "error" || entry.type === "pageerror");
982
+ const failedRequests = networkEntries.filter((entry) => entry.failed || (typeof entry.status === "number" && entry.status >= 400));
983
+ const activePage = pages.find((page) => page.isActive) ?? pages[0] ?? null;
984
+
985
+ const caveats = [];
986
+ if (totalActionCount > retainedActionCount) {
987
+ caveats.push(`Showing ${retainedActionCount} of ${totalActionCount} recorded actions; older actions were discarded due to bounded history.`);
988
+ }
989
+ if (
990
+ actionEntries.some((entry) => looksBoundedWarning(entry.warningSummary) || looksBoundedWarning(entry.error)) ||
991
+ consoleEntries.some((entry) => looksBoundedWarning(entry.text) || looksBoundedWarning(entry.message)) ||
992
+ consoleEntries.length > 0
993
+ ) {
994
+ caveats.push("bounded console history may hide older console events.");
995
+ }
996
+ if (failedRequests.length > 0 || networkEntries.length > 0) {
997
+ caveats.push("bounded network history may hide older requests.");
998
+ }
999
+
1000
+ const failureHypothesis = buildFailureHypothesis(session);
1001
+
1002
+ if (!actionEntries.length && pages.length === 0 && consoleEntries.length === 0 && networkEntries.length === 0 && dialogEntries.length === 0) {
1003
+ return {
1004
+ counts: {
1005
+ pages: 0,
1006
+ actions: { total: 0, retained: 0, success: 0, error: 0, running: 0 },
1007
+ waits: { total: 0, success: 0, error: 0, running: 0 },
1008
+ assertions: { total: 0, passed: 0, failed: 0, running: 0 },
1009
+ consoleErrors: 0,
1010
+ failedRequests: 0,
1011
+ dialogs: 0,
1012
+ },
1013
+ activePage: null,
1014
+ caveats: [],
1015
+ failureHypothesis,
1016
+ summary: "No browser session activity recorded.",
1017
+ };
1018
+ }
1019
+
1020
+ return {
1021
+ counts: {
1022
+ pages: pages.length,
1023
+ actions: {
1024
+ total: totalActionCount,
1025
+ retained: retainedActionCount,
1026
+ success: actionStatusCounts.success,
1027
+ error: actionStatusCounts.error,
1028
+ running: actionStatusCounts.running,
1029
+ },
1030
+ waits: {
1031
+ total: waitEntries.length,
1032
+ success: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length,
1033
+ error: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length,
1034
+ running: waitEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length,
1035
+ },
1036
+ assertions: {
1037
+ total: assertEntries.length,
1038
+ passed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "success").length,
1039
+ failed: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "error").length,
1040
+ running: assertEntries.filter((entry) => summarizeActionStatus(entry.status) === "running").length,
1041
+ },
1042
+ consoleErrors: consoleErrors.length,
1043
+ failedRequests: failedRequests.length,
1044
+ dialogs: dialogEntries.length,
1045
+ },
1046
+ activePage: activePage
1047
+ ? {
1048
+ id: activePage.id ?? null,
1049
+ title: activePage.title ?? "",
1050
+ url: activePage.url ?? "",
1051
+ }
1052
+ : null,
1053
+ caveats,
1054
+ failureHypothesis,
1055
+ summary: `Session: ${pages.length} page${pages.length === 1 ? "" : "s"}, ${totalActionCount} actions, ${waitEntries.length} wait${waitEntries.length === 1 ? "" : "s"}, ${assertEntries.length} assert${assertEntries.length === 1 ? "" : "s"}.${caveats.length ? ` ${caveats.join(" ")}` : ""}`,
1056
+ };
1057
+ }