explorbot 0.2.5 → 0.3.1

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 (89) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/conversation.d.ts +1 -0
  17. package/dist/src/ai/conversation.js +3 -0
  18. package/dist/src/ai/driller.js +6 -2
  19. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  20. package/dist/src/ai/fisherman-tools.js +39 -0
  21. package/dist/src/ai/fisherman.js +3 -2
  22. package/dist/src/ai/navigator.d.ts +2 -1
  23. package/dist/src/ai/navigator.js +5 -9
  24. package/dist/src/ai/pilot.js +51 -29
  25. package/dist/src/ai/planner/subpages.js +2 -16
  26. package/dist/src/ai/planner.js +1 -1
  27. package/dist/src/ai/provider.d.ts +3 -0
  28. package/dist/src/ai/provider.js +80 -17
  29. package/dist/src/ai/researcher/cache.d.ts +8 -3
  30. package/dist/src/ai/researcher/cache.js +13 -8
  31. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  32. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  33. package/dist/src/ai/researcher.js +4 -3
  34. package/dist/src/ai/rules.js +1 -5
  35. package/dist/src/ai/tester.d.ts +1 -1
  36. package/dist/src/ai/tester.js +34 -26
  37. package/dist/src/ai/tools.d.ts +8 -5
  38. package/dist/src/ai/tools.js +79 -56
  39. package/dist/src/commands/explore-command.js +22 -17
  40. package/dist/src/commands/init-command.js +13 -20
  41. package/dist/src/config.d.ts +2 -1
  42. package/dist/src/config.js +3 -1
  43. package/dist/src/experience-tracker.d.ts +2 -0
  44. package/dist/src/experience-tracker.js +12 -0
  45. package/dist/src/explorbot.js +1 -1
  46. package/dist/src/playwright-recorder.js +6 -12
  47. package/dist/src/test-plan.d.ts +8 -0
  48. package/dist/src/test-plan.js +11 -0
  49. package/dist/src/utils/html-diff.d.ts +5 -0
  50. package/dist/src/utils/html-diff.js +65 -6
  51. package/dist/src/utils/strings.d.ts +2 -0
  52. package/dist/src/utils/strings.js +32 -0
  53. package/dist/src/utils/url-matcher.d.ts +1 -0
  54. package/dist/src/utils/url-matcher.js +31 -2
  55. package/docs/basics/getting-started.md +33 -10
  56. package/docs/basics/providers.md +6 -4
  57. package/docs/contributing/npm-package.md +73 -4
  58. package/models.json +4 -4
  59. package/package.json +6 -2
  60. package/src/action-result.ts +61 -16
  61. package/src/action.ts +51 -17
  62. package/src/ai/captain/web-mode.ts +1 -2
  63. package/src/ai/captain.ts +9 -1
  64. package/src/ai/conversation.ts +3 -0
  65. package/src/ai/driller.ts +6 -2
  66. package/src/ai/fisherman-tools.ts +35 -0
  67. package/src/ai/fisherman.ts +3 -2
  68. package/src/ai/navigator.ts +6 -10
  69. package/src/ai/pilot.ts +54 -32
  70. package/src/ai/planner/subpages.ts +2 -13
  71. package/src/ai/planner.ts +1 -1
  72. package/src/ai/provider.ts +111 -41
  73. package/src/ai/researcher/cache.ts +17 -9
  74. package/src/ai/researcher/deep-analysis.ts +1 -1
  75. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  76. package/src/ai/researcher.ts +4 -3
  77. package/src/ai/rules.ts +1 -5
  78. package/src/ai/tester.ts +32 -27
  79. package/src/ai/tools.ts +84 -60
  80. package/src/commands/explore-command.ts +17 -14
  81. package/src/commands/init-command.ts +14 -20
  82. package/src/config.ts +4 -2
  83. package/src/experience-tracker.ts +13 -0
  84. package/src/explorbot.ts +1 -1
  85. package/src/playwright-recorder.ts +6 -11
  86. package/src/test-plan.ts +18 -0
  87. package/src/utils/html-diff.ts +72 -7
  88. package/src/utils/strings.ts +36 -0
  89. package/src/utils/url-matcher.ts +27 -2
@@ -8,15 +8,10 @@ import { getCliName } from '../utils/cli-name.ts';
8
8
  import { log, tag } from '../utils/logger.js';
9
9
  import { relativeToCwd } from '../utils/next-steps.ts';
10
10
 
11
- const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
12
- // import { '<your provider here>' } from '<your provider package here>';
13
-
14
- // Vercel AI SDK is used to connect to AI providers.
15
- // Bring your own provider or use OpenRouter (one API key, many providers).
16
- // https://github.com/testomatio/explorbot/blob/main/docs/providers.md
17
- const openrouter = createOpenRouter({
18
- apiKey: process.env.OPENROUTER_API_KEY,
19
- });
11
+ function defaultConfigTemplate(): string {
12
+ return `// 'provider/model-id' uses a bundled provider.
13
+ // It is also possible to import provider as a module from Vercel AI SDK.
14
+ // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
20
15
 
21
16
  const config = {
22
17
  web: {
@@ -25,12 +20,7 @@ const config = {
25
20
  },
26
21
 
27
22
  ai: {
28
- // fast model with tool calling capabilities
29
- model: openrouter('openai/gpt-oss-20b:nitro'),
30
- // vision model for screenshot analysis
31
- visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
32
- // agentic model for decision making
33
- agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
23
+ ${modelLines('openrouter')}
34
24
  },
35
25
 
36
26
  reporter: {
@@ -45,6 +35,7 @@ const config = {
45
35
 
46
36
  export default config;
47
37
  `;
38
+ }
48
39
 
49
40
  const DEFAULT_ENV_TEMPLATE = dedent`
50
41
  # AI provider API keys
@@ -141,7 +132,7 @@ export function runInitCommand(options: InitCommandOptions): void {
141
132
  process.exit(1);
142
133
  }
143
134
 
144
- writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
135
+ writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
145
136
  log(`Created config file: ${relativeToCwd(outPath)}`);
146
137
 
147
138
  const envPath = resolve(process.cwd(), '.env');
@@ -222,8 +213,7 @@ async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'g
222
213
  });
223
214
  }
224
215
 
225
- function globalConfigTemplate(provider: string): string {
226
- const { envKey } = PROVIDERS[provider];
216
+ function modelLines(provider: string): string {
227
217
  const recommended = ConfigParser.recommendedModels()[provider] || {};
228
218
  const roles: Array<[ModelRoleName, string]> = [
229
219
  ['model', 'fast model with tool calling capabilities'],
@@ -231,7 +221,11 @@ function globalConfigTemplate(provider: string): string {
231
221
  ['agenticModel', 'agentic model for decision making'],
232
222
  ];
233
223
 
234
- const models = roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
224
+ return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
225
+ }
226
+
227
+ function globalConfigTemplate(provider: string): string {
228
+ const { envKey } = PROVIDERS[provider];
235
229
 
236
230
  return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
237
231
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
@@ -240,7 +234,7 @@ function globalConfigTemplate(provider: string): string {
240
234
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
241
235
  const config = {
242
236
  ai: {
243
- ${models}
237
+ ${modelLines(provider)}
244
238
  },
245
239
 
246
240
  reporter: {
package/src/config.ts CHANGED
@@ -175,8 +175,9 @@ interface AIConfig {
175
175
  vision?: boolean;
176
176
  visionModel?: any;
177
177
  agenticModel?: any;
178
- maxAttempts?: number;
178
+ retryAttempts?: number;
179
179
  retryDelay?: number;
180
+ maxParallelRequests?: number;
180
181
  agents?: AgentsConfig;
181
182
  }
182
183
 
@@ -433,7 +434,7 @@ export class ConfigParser {
433
434
  public getOutputDir(): string {
434
435
  const config = this.getConfig();
435
436
  if (!this.configPath) throw new Error('Config path not found');
436
- return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
437
+ return this.resolveProjectDir(config.dirs?.output || 'output');
437
438
  }
438
439
 
439
440
  public getProjectRoot(): string {
@@ -444,6 +445,7 @@ export class ConfigParser {
444
445
  }
445
446
 
446
447
  public resolveProjectDir(relativeDir: string): string {
448
+ if (path.isAbsolute(relativeDir)) return relativeDir;
447
449
  if (!this.configPath) return relativeDir;
448
450
  return path.join(this.getProjectRoot(), relativeDir);
449
451
  }
@@ -304,6 +304,14 @@ export class ExperienceTracker {
304
304
  return this.buildToc(sorted);
305
305
  }
306
306
 
307
+ renderExperienceFor(state: ActionResult): string {
308
+ const successful = this.getSuccessfulExperience(state);
309
+ if (!successful.length) return '';
310
+
311
+ tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
312
+ return renderExperienceRecipes(successful);
313
+ }
314
+
307
315
  renderExperienceTocFor(state: ActionResult): string {
308
316
  const toc = this.getExperienceTableOfContents(state);
309
317
  if (toc.length === 0) return '';
@@ -437,6 +445,11 @@ function indexToLetters(index: number): string {
437
445
  return result;
438
446
  }
439
447
 
448
+ export function renderExperienceRecipes(recipes: string[]): string {
449
+ if (recipes.length === 0) return '';
450
+ return `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${recipes.join('\n\n')}\n</experience>`;
451
+ }
452
+
440
453
  export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
441
454
  if (toc.length === 0) return '';
442
455
 
package/src/explorbot.ts CHANGED
@@ -248,7 +248,7 @@ export class ExplorBot {
248
248
  this.agents.tester = this.createAgent((deps) => {
249
249
  const researcher = this.agentResearcher();
250
250
  const navigator = this.agentNavigator();
251
- const tools = createAgentTools({ ...deps, researcher, navigator });
251
+ const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
252
252
  return new Tester(deps, researcher, navigator, tools);
253
253
  });
254
254
 
@@ -9,7 +9,7 @@ const RECORDABLE: Record<string, Set<string>> = {
9
9
  Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
10
10
  };
11
11
 
12
- const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
12
+ const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
13
13
 
14
14
  let cachedAsLocator: ((lang: string, selector: string) => string) | null = null;
15
15
  let asLocatorLoadAttempted = false;
@@ -20,16 +20,11 @@ function getAsLocator(): (lang: string, selector: string) => string {
20
20
  if (asLocatorLoadAttempted) throw new Error(PLAYWRIGHT_INCOMPATIBLE);
21
21
 
22
22
  asLocatorLoadAttempted = true;
23
- try {
24
- const mod = nodeRequire('playwright-core/lib/utils');
25
- if (typeof (mod as any)?.asLocator === 'function') {
26
- cachedAsLocator = (mod as any).asLocator;
27
- return cachedAsLocator!;
28
- }
29
- } catch {
30
- // Module not exported or not found
31
- }
32
- throw new Error(PLAYWRIGHT_INCOMPATIBLE);
23
+ const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
24
+ if (typeof asLocator !== 'function') throw new Error(PLAYWRIGHT_INCOMPATIBLE);
25
+
26
+ cachedAsLocator = asLocator;
27
+ return cachedAsLocator;
33
28
  }
34
29
 
35
30
  export interface TraceCall {
package/src/test-plan.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import figures from 'figures';
3
+ import type { ActionResult } from './action-result.ts';
3
4
  import { WebPageState } from './state-manager.ts';
4
5
  import { tag } from './utils/logger.ts';
5
6
  import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
@@ -236,6 +237,7 @@ export class Test extends Task {
236
237
  startTime?: number;
237
238
  endTime?: number;
238
239
  resetCount = 0;
240
+ appliedExperience: AppliedExperience[] = [];
239
241
 
240
242
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps: string[] = []) {
241
243
  super(scenario, startUrl);
@@ -257,6 +259,17 @@ export class Test extends Task {
257
259
  return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value): value is string => Boolean(value) && value.trim() !== ''))];
258
260
  }
259
261
 
262
+ applyExperience(recipes: AppliedExperience[]): void {
263
+ for (const recipe of recipes) {
264
+ if (this.appliedExperience.some((applied) => applied.content === recipe.content)) continue;
265
+ this.appliedExperience.push(recipe);
266
+ }
267
+ }
268
+
269
+ getAppliedExperience(state: ActionResult): string[] {
270
+ return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
271
+ }
272
+
260
273
  addArtifact(artifact?: string): void {
261
274
  if (!artifact) return;
262
275
  const timestamp = `${performance.now()}_${this.timestampCounter++}`;
@@ -530,3 +543,8 @@ interface UrlNoteState {
530
543
  h2?: string;
531
544
  screenshotFile?: string;
532
545
  }
546
+
547
+ interface AppliedExperience {
548
+ url: string;
549
+ content: string;
550
+ }
@@ -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) {
@@ -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
  }