explorbot 0.2.4 → 0.3.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 (113) hide show
  1. package/bin/explorbot-cli.ts +19 -7
  2. package/boat/api-tester/src/cli.ts +17 -0
  3. package/boat/doc-collector/src/cli.ts +14 -1
  4. package/boat/prima/README.md +96 -0
  5. package/boat/prima/package.json +14 -10
  6. package/boat/prima/src/cli.ts +29 -12
  7. package/boat/prima/src/envelope.ts +35 -13
  8. package/boat/prima/src/prima.ts +78 -45
  9. package/dist/bin/explorbot-cli.js +19 -7
  10. package/dist/boat/api-tester/src/cli.js +17 -0
  11. package/dist/boat/doc-collector/src/cli.js +14 -1
  12. package/dist/boat/prima/src/cli.js +26 -7
  13. package/dist/boat/prima/src/envelope.js +32 -8
  14. package/dist/boat/prima/src/prima.js +75 -43
  15. package/dist/models.json +4 -4
  16. package/dist/package.json +6 -2
  17. package/dist/src/action-result.d.ts +13 -0
  18. package/dist/src/action-result.js +46 -15
  19. package/dist/src/action.d.ts +5 -2
  20. package/dist/src/action.js +53 -18
  21. package/dist/src/ai/captain/web-mode.js +1 -2
  22. package/dist/src/ai/captain.d.ts +20 -0
  23. package/dist/src/ai/captain.js +10 -1
  24. package/dist/src/ai/driller.js +6 -2
  25. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  26. package/dist/src/ai/fisherman-tools.js +39 -0
  27. package/dist/src/ai/fisherman.js +2 -1
  28. package/dist/src/ai/navigator.d.ts +28 -0
  29. package/dist/src/ai/navigator.js +223 -175
  30. package/dist/src/ai/pilot.d.ts +7 -4
  31. package/dist/src/ai/pilot.js +89 -30
  32. package/dist/src/ai/planner/subpages.js +2 -16
  33. package/dist/src/ai/planner.js +1 -1
  34. package/dist/src/ai/provider.d.ts +2 -2
  35. package/dist/src/ai/provider.js +28 -22
  36. package/dist/src/ai/researcher/cache.d.ts +10 -3
  37. package/dist/src/ai/researcher/cache.js +23 -10
  38. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  39. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  40. package/dist/src/ai/researcher.js +6 -4
  41. package/dist/src/ai/rules.js +1 -5
  42. package/dist/src/ai/session-analyst.js +2 -0
  43. package/dist/src/ai/tester.d.ts +6 -3
  44. package/dist/src/ai/tester.js +30 -35
  45. package/dist/src/ai/tools.d.ts +8 -5
  46. package/dist/src/ai/tools.js +83 -57
  47. package/dist/src/commands/config-command.d.ts +51 -0
  48. package/dist/src/commands/config-command.js +117 -0
  49. package/dist/src/commands/index.js +2 -0
  50. package/dist/src/commands/init-command.js +13 -20
  51. package/dist/src/config.d.ts +8 -1
  52. package/dist/src/config.js +43 -1
  53. package/dist/src/experience-tracker.d.ts +2 -0
  54. package/dist/src/experience-tracker.js +12 -0
  55. package/dist/src/explorbot.js +5 -2
  56. package/dist/src/playwright-recorder.js +6 -12
  57. package/dist/src/remote.d.ts +3 -2
  58. package/dist/src/remote.js +8 -2
  59. package/dist/src/state-manager.d.ts +1 -1
  60. package/dist/src/state-manager.js +3 -1
  61. package/dist/src/test-plan.d.ts +9 -0
  62. package/dist/src/test-plan.js +30 -0
  63. package/dist/src/utils/html-diff.d.ts +5 -0
  64. package/dist/src/utils/html-diff.js +65 -6
  65. package/dist/src/utils/logger.d.ts +1 -1
  66. package/dist/src/utils/logger.js +8 -0
  67. package/dist/src/utils/strings.d.ts +2 -0
  68. package/dist/src/utils/strings.js +32 -0
  69. package/dist/src/utils/url-matcher.d.ts +1 -0
  70. package/dist/src/utils/url-matcher.js +31 -2
  71. package/docs/basics/getting-started.md +33 -10
  72. package/docs/basics/providers.md +6 -4
  73. package/docs/contributing/npm-package.md +73 -4
  74. package/docs/index.json +2 -1
  75. package/docs/reference/commands.md +3 -0
  76. package/docs/reference/websocket.md +50 -0
  77. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  78. package/models.json +4 -4
  79. package/package.json +6 -2
  80. package/src/action-result.ts +61 -16
  81. package/src/action.ts +56 -18
  82. package/src/ai/captain/web-mode.ts +1 -2
  83. package/src/ai/captain.ts +9 -1
  84. package/src/ai/driller.ts +6 -2
  85. package/src/ai/fisherman-tools.ts +35 -0
  86. package/src/ai/fisherman.ts +2 -1
  87. package/src/ai/navigator.ts +238 -179
  88. package/src/ai/pilot.ts +104 -36
  89. package/src/ai/planner/subpages.ts +2 -13
  90. package/src/ai/planner.ts +1 -1
  91. package/src/ai/provider.ts +29 -21
  92. package/src/ai/researcher/cache.ts +29 -11
  93. package/src/ai/researcher/deep-analysis.ts +1 -1
  94. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  95. package/src/ai/researcher.ts +6 -4
  96. package/src/ai/rules.ts +1 -5
  97. package/src/ai/session-analyst.ts +2 -0
  98. package/src/ai/tester.ts +33 -34
  99. package/src/ai/tools.ts +88 -61
  100. package/src/commands/config-command.ts +146 -0
  101. package/src/commands/index.ts +2 -0
  102. package/src/commands/init-command.ts +14 -20
  103. package/src/config.ts +47 -2
  104. package/src/experience-tracker.ts +13 -0
  105. package/src/explorbot.ts +4 -2
  106. package/src/playwright-recorder.ts +6 -11
  107. package/src/remote.ts +8 -2
  108. package/src/state-manager.ts +5 -2
  109. package/src/test-plan.ts +38 -0
  110. package/src/utils/html-diff.ts +72 -7
  111. package/src/utils/logger.ts +9 -1
  112. package/src/utils/strings.ts +36 -0
  113. package/src/utils/url-matcher.ts +27 -2
@@ -17,6 +17,7 @@ export interface HtmlDiffResult {
17
17
  removed: string[];
18
18
  similarity: number;
19
19
  summary: string;
20
+ messages: string[];
20
21
  }
21
22
 
22
23
  interface HtmlNode {
@@ -29,6 +30,11 @@ interface HtmlNode {
29
30
 
30
31
  const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
31
32
 
33
+ const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
34
+ const TEXT_LINE_PREFIX = 'TEXT:';
35
+ const MESSAGE_MAX_LENGTH = 200;
36
+ const MESSAGE_LIMIT = 8;
37
+
32
38
  type DocumentNode = parse5TreeAdapter.Document;
33
39
  type ElementNode = parse5TreeAdapter.Element;
34
40
  type ParentNode = parse5TreeAdapter.Document | parse5TreeAdapter.Element;
@@ -203,7 +209,9 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
203
209
  const similarity = calculateSimilarity(originalLines, modifiedLines);
204
210
  const { added, removed } = findDifferences(originalLines, modifiedLines);
205
211
 
206
- const parts = await buildDiffParts(originalDocument, modifiedDocument);
212
+ const originalMap = collectElementMap(originalDocument);
213
+ const modifiedMap = collectElementMap(modifiedDocument);
214
+ const parts = await buildDiffParts(originalMap, modifiedMap);
207
215
 
208
216
  const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
209
217
  const allAdded = [...added, ...structuralAdditions];
@@ -216,9 +224,69 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
216
224
  removed,
217
225
  similarity,
218
226
  summary,
227
+ messages: collectMessages(originalMap, modifiedMap, allAdded),
219
228
  };
220
229
  }
221
230
 
231
+ /**
232
+ * Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
233
+ */
234
+ function collectMessages(originalMap: NodeMap, modifiedMap: NodeMap, added: string[]): string[] {
235
+ const appearedText = added.filter((line) => line.startsWith(TEXT_LINE_PREFIX)).map((line) => line.slice(TEXT_LINE_PREFIX.length));
236
+
237
+ return limitMessages([...collectLiveRegionTexts(originalMap, modifiedMap), ...appearedText]);
238
+ }
239
+
240
+ /**
241
+ * Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
242
+ */
243
+ export function liveRegionMessages(originalHtml: string, modifiedHtml: string): string[] {
244
+ const originalMap = collectElementMap(parseDocument(originalHtml));
245
+ const modifiedMap = collectElementMap(parseDocument(modifiedHtml));
246
+
247
+ return limitMessages(collectLiveRegionTexts(originalMap, modifiedMap));
248
+ }
249
+
250
+ function limitMessages(candidates: string[]): string[] {
251
+ const messages: string[] = [];
252
+
253
+ for (const candidate of candidates) {
254
+ const text = candidate.replace(/\s+/g, ' ').trim().slice(0, MESSAGE_MAX_LENGTH);
255
+ if (!text) continue;
256
+ if (messages.some((message) => message.includes(text))) continue;
257
+ messages.push(text);
258
+ if (messages.length === MESSAGE_LIMIT) break;
259
+ }
260
+
261
+ return messages;
262
+ }
263
+
264
+ function collectLiveRegionTexts(originalMap: NodeMap, modifiedMap: NodeMap): string[] {
265
+ const texts: string[] = [];
266
+
267
+ for (const [path, element] of modifiedMap) {
268
+ if (!isLiveRegion(element)) continue;
269
+ const text = getTextContent(element).trim();
270
+ if (!text) continue;
271
+ const previous = originalMap.get(path);
272
+ if (previous && getTextContent(previous).trim() === text) continue;
273
+ texts.push(text);
274
+ }
275
+
276
+ return texts;
277
+ }
278
+
279
+ function isLiveRegion(element: ElementNode): boolean {
280
+ if (element.tagName?.toLowerCase() === 'output') return true;
281
+
282
+ const attrs = element.attrs ?? [];
283
+ const role = attrs.find((attr) => attr.name === 'role')?.value.toLowerCase();
284
+ if (role && LIVE_REGION_ROLES.has(role)) return true;
285
+
286
+ const live = attrs.find((attr) => attr.name === 'aria-live')?.value.toLowerCase();
287
+ return live === 'polite' || live === 'assertive';
288
+ }
289
+
222
290
  /**
223
291
  * Parse HTML into a document, wrapping fragments with html/body for consistency.
224
292
  * Uses custom sanitization that removes iframes for diff purposes.
@@ -448,10 +516,7 @@ function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifie
448
516
  return { path: 'html[1]/body[1]', selector: 'body' };
449
517
  }
450
518
 
451
- async function buildDiffParts(originalDocument: DocumentNode, modifiedDocument: DocumentNode): Promise<HtmlDiffPart[]> {
452
- const originalMap = collectElementMap(originalDocument);
453
- const modifiedMap = collectElementMap(modifiedDocument);
454
-
519
+ async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap): Promise<HtmlDiffPart[]> {
455
520
  const addedPaths: string[] = [];
456
521
  const changedPaths: string[] = [];
457
522
 
@@ -774,7 +839,7 @@ function flattenHtml(node: HtmlNode): string[] {
774
839
  function process(n: HtmlNode): void {
775
840
  if (n.type === 'text' && n.content) {
776
841
  if (n.content.length >= 5) {
777
- lines.push(`TEXT:${n.content}`);
842
+ lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
778
843
  }
779
844
  return;
780
845
  }
@@ -795,7 +860,7 @@ function flattenHtml(node: HtmlNode): string[] {
795
860
  }
796
861
 
797
862
  if (n.content && n.content.length >= 5) {
798
- lines.push(`TEXT:${n.content}`);
863
+ lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
799
864
  }
800
865
 
801
866
  if (n.children) {
@@ -11,7 +11,7 @@ import { Observability } from '../observability.ts';
11
11
  import { RecentStepFilter } from './log-filters.ts';
12
12
  import { parseMarkdownToTerminal } from './markdown-terminal.ts';
13
13
 
14
- export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input';
14
+ export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input' | 'data';
15
15
 
16
16
  export interface TaggedLogEntry {
17
17
  type: LogType;
@@ -458,6 +458,14 @@ class Logger {
458
458
  return;
459
459
  }
460
460
 
461
+ if (type === 'data') {
462
+ const entry: TaggedLogEntry = { type, content: String(args[0]), timestamp: new Date(), originalArgs: args };
463
+ for (const destination of this.extra) {
464
+ if (destination.isEnabled()) destination.write(entry);
465
+ }
466
+ return;
467
+ }
468
+
461
469
  const options = this.extractLogOptions(type, args);
462
470
  let content = this.processArgs(args);
463
471
  if (type === 'step' && args[0]?.toCode) {
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import stripAnsi from 'strip-ansi';
2
3
 
3
4
  export function truncateJson(input: any): string {
4
5
  if (!input) return '';
@@ -40,3 +41,38 @@ export function safeFilename(name: string, ext = '', maxBytes = 240): string {
40
41
  }
41
42
  return truncated + suffix + ext;
42
43
  }
44
+
45
+ export function truncate(text: string, max: number): string {
46
+ if (text.length <= max) return text;
47
+ return `${text.slice(0, max - 3)}...`;
48
+ }
49
+
50
+ const MAX_COMPACT_ERROR = 400;
51
+
52
+ export function compactErrorMessage(error: unknown): string {
53
+ let text = stripAnsi(String(error));
54
+ for (const strip of STRIP_STRATEGIES) {
55
+ text = strip(text);
56
+ }
57
+ return truncate(text, MAX_COMPACT_ERROR);
58
+ }
59
+
60
+ function stripCallLog(text: string): string {
61
+ const CALL_LOG = 'Call log:';
62
+ const NOISE = ['attempting', 'retrying', 'waiting'];
63
+
64
+ const [headline, ...log] = text.split(CALL_LOG);
65
+ if (!log.length) return text;
66
+
67
+ const lines = new Set<string>();
68
+ for (const line of log.join(CALL_LOG).split('\n')) {
69
+ const cleaned = normalizeInlineText(line);
70
+ if (!cleaned) continue;
71
+ if (NOISE.some((noise) => cleaned.includes(noise))) continue;
72
+ lines.add(cleaned);
73
+ }
74
+
75
+ return [headline.trim(), ...lines].join(' ');
76
+ }
77
+
78
+ const STRIP_STRATEGIES = [stripCallLog];
@@ -28,6 +28,21 @@ export function hasDynamicUrlSegment(url: string): boolean {
28
28
  return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
29
29
  }
30
30
 
31
+ export function isSamePageFamily(urlA: string, urlB: string): boolean {
32
+ const partsA = new URL(urlA, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
33
+ const partsB = new URL(urlB, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
34
+ if (partsA.length !== partsB.length) return false;
35
+
36
+ let diffCount = 0;
37
+ for (let i = 0; i < partsA.length; i++) {
38
+ if (partsA[i] === partsB[i]) continue;
39
+ diffCount++;
40
+ if (diffCount > 1) return false;
41
+ if (!isDynamicSegment(partsA[i]) || !isDynamicSegment(partsB[i])) return false;
42
+ }
43
+ return true;
44
+ }
45
+
31
46
  export function generalizeSegment(segment: string): string {
32
47
  if (/^\d+$/.test(segment)) return '\\d+';
33
48
  if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment)) return '[a-f0-9-]+';
@@ -103,6 +118,16 @@ export function matchesNavigationUrl(expected: string, current: string): boolean
103
118
  if (!expectedPath.includes('?')) {
104
119
  currentPath = currentPath.split('?')[0];
105
120
  }
106
- const normalize = (value: string) => value.replace(/^\/+|\/+$/g, '').toLowerCase();
107
- return normalize(expectedPath) === normalize(currentPath);
121
+ const normalize = (value: string) => value.replace(/^\/+|\/+$/g, '');
122
+ const expectedNormalized = normalize(expectedPath);
123
+ const currentNormalized = normalize(currentPath);
124
+ const expectedKey = expectedNormalized.toLowerCase();
125
+ const currentKey = currentNormalized.toLowerCase();
126
+ if (expectedKey === currentKey) return true;
127
+ if (!currentKey.startsWith(`${expectedKey}/`)) return false;
128
+ const recordSegments = currentNormalized
129
+ .slice(expectedKey.length + 1)
130
+ .split('/')
131
+ .filter(Boolean);
132
+ return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
108
133
  }