explorbot 0.3.5 → 0.4.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 (155) hide show
  1. package/bin/explorbot-cli.ts +23 -16
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler.ts +7 -1
  4. package/boat/api-tester/src/apibot.ts +10 -4
  5. package/boat/api-tester/src/cli.ts +12 -2
  6. package/boat/api-tester/src/config.ts +28 -8
  7. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  8. package/boat/doc-collector/src/cli.ts +6 -1
  9. package/boat/doc-collector/src/docbot.ts +4 -2
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +23 -8
  12. package/boat/prima/src/envelope.ts +35 -9
  13. package/boat/prima/src/prima.ts +23 -10
  14. package/dist/bin/explorbot-cli.js +24 -16
  15. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  16. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  17. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  18. package/dist/boat/api-tester/src/apibot.js +7 -3
  19. package/dist/boat/api-tester/src/cli.js +12 -2
  20. package/dist/boat/api-tester/src/config.js +31 -8
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  22. package/dist/boat/doc-collector/src/cli.js +6 -1
  23. package/dist/boat/doc-collector/src/docbot.js +4 -2
  24. package/dist/boat/prima/bin/prima-cli.js +2 -0
  25. package/dist/boat/prima/src/cli.js +22 -8
  26. package/dist/boat/prima/src/envelope.js +24 -6
  27. package/dist/boat/prima/src/prima.js +23 -11
  28. package/dist/package.json +2 -2
  29. package/dist/rules/planner/styles/normal.md +1 -1
  30. package/dist/src/action-result.d.ts +9 -1
  31. package/dist/src/action-result.js +57 -18
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +87 -12
  34. package/dist/src/ai/captain.js +1 -1
  35. package/dist/src/ai/driller.d.ts +0 -1
  36. package/dist/src/ai/driller.js +8 -20
  37. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  38. package/dist/src/ai/fisherman-tools.js +52 -6
  39. package/dist/src/ai/fisherman.d.ts +4 -2
  40. package/dist/src/ai/fisherman.js +48 -27
  41. package/dist/src/ai/historian/codeceptjs.js +1 -1
  42. package/dist/src/ai/historian/playwright.js +1 -1
  43. package/dist/src/ai/navigator.js +1 -1
  44. package/dist/src/ai/pilot.d.ts +1 -0
  45. package/dist/src/ai/pilot.js +13 -1
  46. package/dist/src/ai/planner.js +9 -7
  47. package/dist/src/ai/provider.js +20 -3
  48. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  49. package/dist/src/ai/researcher.js +5 -3
  50. package/dist/src/ai/rules.js +3 -3
  51. package/dist/src/ai/tester.d.ts +3 -0
  52. package/dist/src/ai/tester.js +40 -3
  53. package/dist/src/ai/tools.d.ts +1 -0
  54. package/dist/src/ai/tools.js +13 -6
  55. package/dist/src/api/request-result.d.ts +2 -0
  56. package/dist/src/api/request-result.js +8 -2
  57. package/dist/src/api/request-store.d.ts +3 -2
  58. package/dist/src/api/request-store.js +66 -14
  59. package/dist/src/api/spec-reader.js +1 -1
  60. package/dist/src/commands/config-command.js +1 -1
  61. package/dist/src/commands/drill-command.js +1 -1
  62. package/dist/src/commands/explore-command.d.ts +6 -0
  63. package/dist/src/commands/explore-command.js +39 -3
  64. package/dist/src/commands/freesail-command.js +10 -1
  65. package/dist/src/commands/options/base-option.d.ts +8 -0
  66. package/dist/src/commands/options/base-option.js +12 -0
  67. package/dist/src/commands/options/index.d.ts +5 -0
  68. package/dist/src/commands/options/index.js +5 -0
  69. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  70. package/dist/src/commands/options/knowledge-option.js +12 -0
  71. package/dist/src/commands/options/ws-option.d.ts +7 -0
  72. package/dist/src/commands/options/ws-option.js +21 -0
  73. package/dist/src/commands/plans-command.js +6 -6
  74. package/dist/src/config.d.ts +1 -0
  75. package/dist/src/config.js +12 -0
  76. package/dist/src/experience-tracker.js +5 -0
  77. package/dist/src/explorbot.d.ts +0 -1
  78. package/dist/src/explorbot.js +24 -37
  79. package/dist/src/knowledge-tracker.d.ts +20 -7
  80. package/dist/src/knowledge-tracker.js +69 -31
  81. package/dist/src/remote.d.ts +0 -3
  82. package/dist/src/remote.js +0 -18
  83. package/dist/src/state-manager.d.ts +5 -1
  84. package/dist/src/state-manager.js +10 -7
  85. package/dist/src/test-plan.d.ts +3 -0
  86. package/dist/src/test-plan.js +27 -0
  87. package/dist/src/utils/aria.d.ts +1 -1
  88. package/dist/src/utils/aria.js +6 -42
  89. package/dist/src/utils/html-diff.d.ts +4 -0
  90. package/dist/src/utils/html-diff.js +62 -7
  91. package/dist/src/utils/html.d.ts +5 -15
  92. package/dist/src/utils/html.js +14 -85
  93. package/dist/src/utils/overlay.d.ts +56 -11
  94. package/dist/src/utils/overlay.js +191 -21
  95. package/dist/src/utils/request-map.d.ts +7 -0
  96. package/dist/src/utils/request-map.js +16 -0
  97. package/dist/src/utils/url-matcher.js +4 -2
  98. package/docs/api-testing/basics.md +15 -0
  99. package/docs/api-testing/planning.md +10 -1
  100. package/docs/reference/commands.md +32 -5
  101. package/docs/reference/websocket.md +1 -0
  102. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  103. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  104. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  105. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  106. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  107. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  108. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  109. package/docs/workflow/agentic-usage.md +12 -2
  110. package/docs/workflow/ci.md +1 -0
  111. package/docs/workflow/knowledge.md +46 -2
  112. package/package.json +2 -2
  113. package/rules/planner/styles/normal.md +1 -1
  114. package/src/action-result.ts +61 -22
  115. package/src/action.ts +87 -14
  116. package/src/ai/captain.ts +1 -1
  117. package/src/ai/driller.ts +7 -39
  118. package/src/ai/fisherman-tools.ts +56 -7
  119. package/src/ai/fisherman.ts +48 -28
  120. package/src/ai/historian/codeceptjs.ts +1 -1
  121. package/src/ai/historian/playwright.ts +1 -1
  122. package/src/ai/navigator.ts +1 -1
  123. package/src/ai/pilot.ts +9 -1
  124. package/src/ai/planner.ts +9 -8
  125. package/src/ai/provider.ts +21 -3
  126. package/src/ai/researcher/deep-analysis.ts +1 -2
  127. package/src/ai/researcher.ts +4 -3
  128. package/src/ai/rules.ts +3 -3
  129. package/src/ai/tester.ts +40 -3
  130. package/src/ai/tools.ts +17 -9
  131. package/src/api/request-result.ts +10 -2
  132. package/src/api/request-store.ts +60 -13
  133. package/src/api/spec-reader.ts +1 -1
  134. package/src/commands/config-command.ts +1 -1
  135. package/src/commands/drill-command.ts +1 -1
  136. package/src/commands/explore-command.ts +37 -3
  137. package/src/commands/freesail-command.ts +7 -1
  138. package/src/commands/options/base-option.ts +18 -0
  139. package/src/commands/options/index.ts +7 -0
  140. package/src/commands/options/knowledge-option.ts +14 -0
  141. package/src/commands/options/ws-option.ts +24 -0
  142. package/src/commands/plans-command.ts +6 -6
  143. package/src/config.ts +12 -0
  144. package/src/experience-tracker.ts +5 -1
  145. package/src/explorbot.ts +21 -37
  146. package/src/knowledge-tracker.ts +94 -36
  147. package/src/remote.ts +0 -20
  148. package/src/state-manager.ts +13 -7
  149. package/src/test-plan.ts +29 -0
  150. package/src/utils/aria.ts +7 -44
  151. package/src/utils/html-diff.ts +62 -7
  152. package/src/utils/html.ts +14 -91
  153. package/src/utils/overlay.ts +226 -23
  154. package/src/utils/request-map.ts +19 -0
  155. package/src/utils/url-matcher.ts +3 -2
@@ -11,36 +11,54 @@ import { loadMarkdownFiles } from './utils/markdown-files.js';
11
11
  import { mdq } from './utils/markdown-query.js';
12
12
  import { isSecretName, registerSecret } from './utils/secrets.js';
13
13
  import { slugify } from './utils/strings.js';
14
+ import { extractStatePath, matchesUrl } from './utils/url-matcher.js';
14
15
 
15
16
  const debugLog = createDebug('explorbot:knowledge-tracker');
16
17
 
17
- export interface Knowledge {
18
- filePath: string;
19
- url: string;
20
- content: string;
21
- [key: string]: any;
22
- }
18
+ const sessionEntries: string[] = [];
23
19
 
24
20
  export class KnowledgeTracker {
25
21
  private knowledgeDir: string;
26
22
  private knowledgeFiles: Knowledge[] = [];
23
+ private sessionKnowledge: Knowledge[] = [];
27
24
  private isLoaded = false;
28
25
  private applicationSpec?: ApplicationSpec;
29
26
 
30
- constructor(applicationSpecPath?: string) {
31
- const configParser = ConfigParser.getInstance();
32
- const config = configParser.getConfig();
33
- this.knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
27
+ static appendSessionKnowledge(text: string): void {
28
+ sessionEntries.push(text);
29
+ }
30
+
31
+ static resetSessionKnowledge(): void {
32
+ sessionEntries.length = 0;
33
+ }
34
+
35
+ constructor(options: KnowledgeTrackerOptions = {}) {
36
+ let knowledgeDir = options.knowledgeDir;
37
+ let specPath = options.applicationSpec;
38
+
39
+ if (!knowledgeDir) {
40
+ const configParser = ConfigParser.getInstance();
41
+ const config = configParser.getConfig();
42
+ knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
43
+ specPath ||= config.dirs?.spec;
44
+ }
45
+
46
+ this.knowledgeDir = knowledgeDir;
34
47
 
35
48
  if (!existsSync(this.knowledgeDir)) {
36
49
  mkdirSync(this.knowledgeDir, { recursive: true });
37
50
  }
38
51
 
39
- const specPath = applicationSpecPath || config.dirs?.spec;
40
52
  if (specPath) {
41
53
  this.applicationSpec = new ApplicationSpec(specPath);
42
54
  tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
43
55
  }
56
+
57
+ this.sessionKnowledge = sessionEntries.map((entry, index) => {
58
+ const parsed = matter(entry);
59
+ debugLog(`Session knowledge #${index + 1}`);
60
+ return this.toKnowledge(`--knowledge #${index + 1}`, parsed.data, parsed.content.trim());
61
+ });
44
62
  }
45
63
 
46
64
  private loadKnowledgeFiles(): void {
@@ -49,12 +67,7 @@ export class KnowledgeTracker {
49
67
  this.knowledgeFiles = [];
50
68
 
51
69
  for (const entry of loadMarkdownFiles(this.knowledgeDir, { recursive: true })) {
52
- this.knowledgeFiles.push({
53
- filePath: entry.filePath,
54
- url: entry.data.url || entry.data.path || '*',
55
- content: this.interpolateVars(entry.content),
56
- ...entry.data,
57
- });
70
+ this.knowledgeFiles.push(this.toKnowledge(entry.filePath, entry.data, entry.content));
58
71
  }
59
72
 
60
73
  this.isLoaded = true;
@@ -63,28 +76,22 @@ export class KnowledgeTracker {
63
76
  getRelevantKnowledge(state: ActionResult): Knowledge[] {
64
77
  this.loadKnowledgeFiles();
65
78
 
66
- return this.knowledgeFiles.filter((knowledge) => {
67
- return state.isMatchedBy(knowledge);
68
- });
79
+ return this.allKnowledge().filter((knowledge) => knowledge.url && state.isMatchedBy(knowledge));
69
80
  }
70
81
 
71
- renderRelevantKnowledge(state: ActionResult): string {
72
- const knowledgeFiles = this.getRelevantKnowledge(state);
73
- if (knowledgeFiles.length === 0) return '';
82
+ getEndpointKnowledge(endpoint: string): Knowledge[] {
83
+ this.loadKnowledgeFiles();
84
+ const path = extractStatePath(endpoint);
74
85
 
75
- const knowledgeContent = knowledgeFiles
76
- .map((k) => k.content)
77
- .filter((k) => !!k)
78
- .join('\n\n');
86
+ return this.allKnowledge().filter((knowledge) => knowledge.endpoint && matchesUrl(knowledge.endpoint, path));
87
+ }
79
88
 
80
- tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
81
- return dedent`
82
- <knowledge>
83
- Here is relevant knowledge for this page:
89
+ renderRelevantKnowledge(state: ActionResult): string {
90
+ return this.renderKnowledge(this.getRelevantKnowledge(state), 'page');
91
+ }
84
92
 
85
- ${knowledgeContent}
86
- </knowledge>
87
- `;
93
+ renderEndpointKnowledge(endpoint: string): string {
94
+ return this.renderKnowledge(this.getEndpointKnowledge(endpoint), 'endpoint');
88
95
  }
89
96
 
90
97
  renderRelevantContext(state: ActionResult): string {
@@ -188,7 +195,7 @@ export class KnowledgeTracker {
188
195
  getExistingUrls(): string[] {
189
196
  this.loadKnowledgeFiles();
190
197
 
191
- return this.knowledgeFiles.map((knowledge) => knowledge.url).filter((url) => url && url !== '*');
198
+ return this.knowledgeFiles.map((knowledge) => knowledge.url || '').filter((url) => url && url !== '*');
192
199
  }
193
200
 
194
201
  getKnowledgeForUrl(urlPattern: string): string[] {
@@ -205,7 +212,7 @@ export class KnowledgeTracker {
205
212
  const content = knowledge.content.trim();
206
213
  const firstLine = mdq(content).meta()[0]?.text.split('\n')[0]?.trim() || '';
207
214
  return {
208
- url: knowledge.url,
215
+ url: knowledge.url || knowledge.endpoint || '',
209
216
  firstLine,
210
217
  filePath: knowledge.filePath,
211
218
  };
@@ -242,4 +249,55 @@ export class KnowledgeTracker {
242
249
 
243
250
  return result;
244
251
  }
252
+
253
+ private allKnowledge(): Knowledge[] {
254
+ return [...this.knowledgeFiles, ...this.sessionKnowledge];
255
+ }
256
+
257
+ private toKnowledge(filePath: string, data: Record<string, any>, content: string): Knowledge {
258
+ const knowledge: Knowledge = {
259
+ ...data,
260
+ filePath,
261
+ url: data.url || data.path,
262
+ content: this.interpolateVars(content),
263
+ };
264
+
265
+ if (!data.url && !data.path && !data.endpoint) {
266
+ knowledge.url = '*';
267
+ knowledge.endpoint = '*';
268
+ }
269
+
270
+ return knowledge;
271
+ }
272
+
273
+ private renderKnowledge(knowledgeFiles: Knowledge[], scope: string): string {
274
+ if (knowledgeFiles.length === 0) return '';
275
+
276
+ const knowledgeContent = knowledgeFiles
277
+ .map((k) => k.content)
278
+ .filter((k) => !!k)
279
+ .join('\n\n');
280
+
281
+ tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
282
+ return dedent`
283
+ <knowledge>
284
+ Here is relevant knowledge for this ${scope}:
285
+
286
+ ${knowledgeContent}
287
+ </knowledge>
288
+ `;
289
+ }
290
+ }
291
+
292
+ export interface Knowledge {
293
+ filePath: string;
294
+ url?: string;
295
+ endpoint?: string;
296
+ content: string;
297
+ [key: string]: any;
298
+ }
299
+
300
+ export interface KnowledgeTrackerOptions {
301
+ applicationSpec?: string;
302
+ knowledgeDir?: string;
245
303
  }
package/src/remote.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Command } from 'commander';
2
1
  import stripAnsi from 'strip-ansi';
3
2
  import { type ActivityEntry, addActivityListener } from './activity.ts';
4
3
  import { executionController } from './execution-controller.ts';
@@ -30,15 +29,6 @@ export class Remote implements LogDestination {
30
29
  private askCounter = 0;
31
30
  private lastActivity: string | null = null;
32
31
 
33
- registerOption(program: Command): void {
34
- program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
35
- program.hook('preAction', (_thisCommand, actionCommand) => {
36
- const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
37
- if (!url) return;
38
- this.attach(String(url), this.commandPath(actionCommand));
39
- });
40
- }
41
-
42
32
  attach(url: string, command: string): void {
43
33
  if (this.url) return;
44
34
  this.url = url;
@@ -225,16 +215,6 @@ export class Remote implements LogDestination {
225
215
  if (typeof failure.message === 'string') return failure.message;
226
216
  return undefined;
227
217
  }
228
-
229
- private commandPath(command: Command): string {
230
- const parts: string[] = [];
231
- let node: Command | null = command;
232
- while (node) {
233
- parts.unshift(node.name());
234
- node = node.parent;
235
- }
236
- return parts.slice(1).join(' ') || parts.join(' ');
237
- }
238
218
  }
239
219
 
240
220
  export const remote = new Remote();
@@ -50,6 +50,10 @@ export interface WebPageState {
50
50
  links?: Link[];
51
51
  verifications?: Record<string, boolean>;
52
52
  overlay?: Overlay;
53
+ /** Region name, the persisted scalar form of overlay.name used in experience frontmatter */
54
+ region?: string;
55
+ /** Region root selector, the persisted scalar form of overlay.root used in experience frontmatter */
56
+ root?: string;
53
57
  }
54
58
 
55
59
  export interface StateTransition {
@@ -119,7 +123,9 @@ export class StateManager {
119
123
  */
120
124
  private emitStateChange(event: StateTransition): void {
121
125
  const state = event.toState;
122
- tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
126
+ const payload: Record<string, unknown> = { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 };
127
+ if (state.overlay?.present) payload.region = state.overlay.name || state.overlay.type;
128
+ tag('data').log('state', payload);
123
129
 
124
130
  this.stateChangeListeners.forEach((listener) => {
125
131
  try {
@@ -143,9 +149,9 @@ export class StateManager {
143
149
  if (newState.url) this.allVisitedUrls.add(normalizeUrl(newState.url));
144
150
 
145
151
  const hashChanged = actionResult.hash !== previousHash;
146
- const dialogOpened = !hashChanged && this.hasDialogAppeared(previousState, newState);
152
+ const regionAppeared = !hashChanged && this.hasRegionAppeared(previousState, newState);
147
153
 
148
- if (hashChanged || dialogOpened) {
154
+ if (hashChanged || regionAppeared) {
149
155
  const transition: StateTransition = {
150
156
  fromState: previousState,
151
157
  toState: newState,
@@ -156,8 +162,8 @@ export class StateManager {
156
162
  this.stateHistory.push(transition);
157
163
  this.emitStateChange(transition);
158
164
 
159
- if (dialogOpened) {
160
- debugLog('State change detected: modal dialog appeared');
165
+ if (regionAppeared) {
166
+ debugLog('State change detected: region of interest appeared');
161
167
  }
162
168
  }
163
169
 
@@ -206,10 +212,10 @@ export class StateManager {
206
212
  return newState;
207
213
  }
208
214
 
209
- private hasDialogAppeared(previousState: WebPageState | null, newState: WebPageState): boolean {
215
+ private hasRegionAppeared(previousState: WebPageState | null, newState: WebPageState): boolean {
210
216
  const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
211
217
  const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
212
- return !prevFocus.detected && newFocus.detected;
218
+ return !prevFocus.present && newFocus.present;
213
219
  }
214
220
 
215
221
  /**
package/src/test-plan.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
2
4
  import figures from 'figures';
3
5
  import type { ActionResult } from './action-result.ts';
6
+ import { listSites } from './global-config.ts';
4
7
  import { WebPageState } from './state-manager.ts';
5
8
  import { tag } from './utils/logger.ts';
6
9
  import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
@@ -388,12 +391,15 @@ export class Test extends Task {
388
391
  }
389
392
  }
390
393
 
394
+ const SITE_PLANS_DIR = ['output', 'plans'];
395
+
391
396
  type PlanChangeListener = (tests: Test[]) => void;
392
397
 
393
398
  export class Plan {
394
399
  title: string;
395
400
  tests: Test[] = [];
396
401
  url?: string;
402
+ filePath?: string;
397
403
  iteration = 0;
398
404
  parentPlan?: Plan;
399
405
  private changeListeners: PlanChangeListener[] = [];
@@ -455,6 +461,10 @@ export class Plan {
455
461
  return this.tests.filter((test) => test.status === 'pending' && test.enabled);
456
462
  }
457
463
 
464
+ get startUrl(): string | undefined {
465
+ return this.url || this.tests[0]?.startUrl;
466
+ }
467
+
458
468
  get isComplete(): boolean {
459
469
  return this.tests.length > 0 && this.tests.every((test) => test.hasFinished);
460
470
  }
@@ -469,6 +479,25 @@ export class Plan {
469
479
 
470
480
  updateStatus(): void {}
471
481
 
482
+ static loadFromFile(file: string, plansDir?: string): Plan | null {
483
+ const names = [file];
484
+ if (!file.endsWith('.md')) names.push(`${file}.md`);
485
+
486
+ const dirs = [process.cwd()];
487
+ if (plansDir) dirs.push(plansDir);
488
+ if (!plansDir) dirs.push(...listSites().map((site) => path.join(site.dir, ...SITE_PLANS_DIR)));
489
+
490
+ for (const dir of dirs) {
491
+ const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync);
492
+ if (!filePath) continue;
493
+ const loaded = parsePlanFromMarkdown(filePath);
494
+ loaded.filePath = filePath;
495
+ return loaded;
496
+ }
497
+
498
+ return null;
499
+ }
500
+
472
501
  static fromMarkdown(filePath: string): Plan {
473
502
  return parsePlanFromMarkdown(filePath);
474
503
  }
package/src/utils/aria.ts CHANGED
@@ -470,51 +470,20 @@ const formatDiff = (added: string[], removed: string[], toggled: string[], typed
470
470
 
471
471
  export interface FocusAreaResult {
472
472
  detected: boolean;
473
- type: 'dialog' | 'modal' | null;
473
+ type: 'modal' | null;
474
474
  name: string | null;
475
475
  }
476
476
 
477
- const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
478
-
479
- const findOverlayByCloseButton = (nodeList: AriaNode[]): FocusAreaResult | null => {
480
- const closeIdx = nodeList.findIndex((n) => n.role === 'button' && CLOSE_OVERLAY_BUTTON_RE.test(n.name || ''));
481
- if (closeIdx !== -1) {
482
- let heading: AriaNode | undefined;
483
- for (let i = closeIdx - 1; i >= 0; i--) {
484
- if (nodeList[i].role === 'heading' && nodeList[i].name) {
485
- heading = nodeList[i];
486
- break;
487
- }
488
- }
489
- if (!heading) {
490
- for (let i = closeIdx + 1; i < nodeList.length; i++) {
491
- if (nodeList[i].role === 'heading' && nodeList[i].name) {
492
- heading = nodeList[i];
493
- break;
494
- }
495
- }
496
- }
497
- return { detected: true, type: 'dialog', name: heading?.name || null };
498
- }
499
- for (const node of nodeList) {
500
- const inner = findOverlayByCloseButton(node.children);
501
- if (inner) return inner;
502
- }
503
- return null;
504
- };
505
-
506
477
  const findDialogOrModal = (nodes: AriaNode[]): FocusAreaResult | null => {
478
+ let topmost: FocusAreaResult | null = null;
507
479
  for (const node of nodes) {
508
- if (node.role === 'dialog' || node.role === 'alertdialog') {
509
- return { detected: true, type: 'dialog', name: node.name || null };
510
- }
511
- if (node.attributes.modal === true || node.attributes.modal === 'true') {
512
- return { detected: true, type: 'modal', name: node.name || null };
480
+ if (node.role === 'dialog' || node.role === 'alertdialog' || node.attributes.modal === true || node.attributes.modal === 'true') {
481
+ topmost = { detected: true, type: 'modal', name: node.name || null };
513
482
  }
514
483
  const child = findDialogOrModal(node.children);
515
- if (child) return child;
484
+ if (child) topmost = child;
516
485
  }
517
- return null;
486
+ return topmost;
518
487
  };
519
488
 
520
489
  // ─────────────────────────────────────────────────────────────────
@@ -561,13 +530,7 @@ export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
561
530
  tree = unwrapIgnored(tree);
562
531
  tree = dropEmpty(tree, { keepNamed: true });
563
532
 
564
- const direct = findDialogOrModal(tree);
565
- if (direct) return direct;
566
-
567
- const fallback = findOverlayByCloseButton(tree);
568
- if (fallback?.name) return fallback;
569
-
570
- return { detected: false, type: null, name: null };
533
+ return findDialogOrModal(tree) ?? { detected: false, type: null, name: null };
571
534
  };
572
535
 
573
536
  export const collectInteractiveNodes = (snapshot: string | null): Array<Record<string, unknown>> => {
@@ -7,8 +7,10 @@ import { isDynamicId, isGenericClass } from './xpath.ts';
7
7
  export interface HtmlDiffPart {
8
8
  container: string;
9
9
  subtree: string;
10
+ rawSize: number;
10
11
  added: string[];
11
12
  removed: string[];
13
+ appearedSelector?: string;
12
14
  }
13
15
 
14
16
  export interface HtmlDiffResult {
@@ -16,6 +18,7 @@ export interface HtmlDiffResult {
16
18
  added: string[];
17
19
  removed: string[];
18
20
  similarity: number;
21
+ pageSize: number;
19
22
  summary: string;
20
23
  messages: string[];
21
24
  }
@@ -30,6 +33,9 @@ interface HtmlNode {
30
33
 
31
34
  const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
32
35
 
36
+ const SHELL_RATIO = 0.8;
37
+ const ROOT_CONTENT_RATIO = 0.8;
38
+
33
39
  const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
34
40
  const TEXT_LINE_PREFIX = 'TEXT:';
35
41
  const MESSAGE_MAX_LENGTH = 200;
@@ -211,7 +217,12 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
211
217
 
212
218
  const originalMap = collectElementMap(originalDocument);
213
219
  const modifiedMap = collectElementMap(modifiedDocument);
214
- const parts = await buildDiffParts(originalMap, modifiedMap);
220
+
221
+ const modifiedBody = findBodyElement(modifiedDocument);
222
+ let pageSize = modifiedHtml.length;
223
+ if (modifiedBody) pageSize = serializeNode(modifiedBody).length;
224
+
225
+ const parts = await buildDiffParts(originalMap, modifiedMap, pageSize);
215
226
 
216
227
  const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
217
228
  const allAdded = [...added, ...structuralAdditions];
@@ -223,11 +234,16 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
223
234
  added: allAdded,
224
235
  removed,
225
236
  similarity,
237
+ pageSize,
226
238
  summary,
227
239
  messages: collectMessages(originalMap, modifiedMap, allAdded),
228
240
  };
229
241
  }
230
242
 
243
+ function serializeNode(node: parse5TreeAdapter.Node): string {
244
+ return serialize({ childNodes: [node], nodeName: '#document-fragment' } as any);
245
+ }
246
+
231
247
  /**
232
248
  * Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
233
249
  */
@@ -492,14 +508,14 @@ function buildContainerSelector(element: ElementNode, allElements: NodeMap): str
492
508
  return matchCount === 1 ? selector : null;
493
509
  }
494
510
 
495
- function pathToXPath(treePath: string): string {
511
+ export function pathToXPath(treePath: string): string {
496
512
  const parts = treePath.split('/');
497
513
  const bodyIdx = parts.findIndex((p) => p.startsWith('body'));
498
514
  if (bodyIdx === -1) return `//${parts.join('/')}`;
499
515
  return `//body/${parts.slice(bodyIdx + 1).join('/')}`;
500
516
  }
501
517
 
502
- function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifiedMap: NodeMap): { path: string; selector: string } {
518
+ function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifiedMap: NodeMap, pageSize: number): { path: string; selector: string } {
503
519
  const segments = topLevelPath.split('/');
504
520
 
505
521
  for (let i = segments.length - 2; i >= 1; i--) {
@@ -508,6 +524,7 @@ function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifie
508
524
  if (!originalMap.has(candidatePath) || !modifiedMap.has(candidatePath)) continue;
509
525
 
510
526
  const element = modifiedMap.get(candidatePath)!;
527
+ if (pageSize > 0 && serializeNode(element).length >= SHELL_RATIO * pageSize) break;
511
528
  const css = buildContainerSelector(element, modifiedMap);
512
529
  if (css) return { path: candidatePath, selector: css };
513
530
  return { path: candidatePath, selector: pathToXPath(candidatePath) };
@@ -516,7 +533,29 @@ function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifie
516
533
  return { path: 'html[1]/body[1]', selector: 'body' };
517
534
  }
518
535
 
519
- async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap): Promise<HtmlDiffPart[]> {
536
+ function dominantChild(element: ElementNode): ElementNode | null {
537
+ const children = (element.childNodes ?? []).filter((child): child is ElementNode => 'tagName' in child && !!child.tagName);
538
+ if (children.length === 0) return null;
539
+ if (children.length === 1) return children[0];
540
+ const parentSize = serializeNode(element).length;
541
+ if (!parentSize) return null;
542
+ for (const child of children) {
543
+ if (serializeNode(child).length >= ROOT_CONTENT_RATIO * parentSize) return child;
544
+ }
545
+ return null;
546
+ }
547
+
548
+ function semanticSelectorFor(element: ElementNode, allElements: NodeMap): string | undefined {
549
+ let current: ElementNode | null = element;
550
+ while (current) {
551
+ const selector = buildContainerSelector(current, allElements);
552
+ if (selector) return selector;
553
+ current = dominantChild(current);
554
+ }
555
+ return undefined;
556
+ }
557
+
558
+ async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap, pageSize: number): Promise<HtmlDiffPart[]> {
520
559
  const addedPaths: string[] = [];
521
560
  const changedPaths: string[] = [];
522
561
 
@@ -546,7 +585,7 @@ async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap): Promi
546
585
  const grouped = new Map<string, { selector: string; paths: string[] }>();
547
586
 
548
587
  for (const path of allTopLevel) {
549
- const { path: containerPath, selector } = findStableContainer(path, originalMap, modifiedMap);
588
+ const { path: containerPath, selector } = findStableContainer(path, originalMap, modifiedMap, pageSize);
550
589
  const existing = grouped.get(containerPath);
551
590
  if (existing) {
552
591
  existing.paths.push(path);
@@ -592,10 +631,26 @@ async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap): Promi
592
631
  const subtree = serialized ? await minifyHtml(serialized) : '';
593
632
  if (!subtree) continue;
594
633
 
595
- const addedLines = paths.filter((p) => addedTopLevel.includes(p)).map((p) => `ELEMENT:${p}`);
634
+ const appearedPaths = paths.filter((p) => addedTopLevel.includes(p));
635
+ const sizeOf = (p: string) => {
636
+ const node = modifiedMap.get(p);
637
+ if (!node) return 0;
638
+ return serializeNode(node).length;
639
+ };
640
+ appearedPaths.sort((a, b) => sizeOf(b) - sizeOf(a));
641
+ const addedLines = appearedPaths.map((p) => `ELEMENT:${p}`);
596
642
  const removedLines: string[] = [];
597
643
 
598
- parts.push({ container: selector, subtree, added: addedLines, removed: removedLines });
644
+ const part: HtmlDiffPart = { container: selector, subtree, rawSize: serialized.length, added: addedLines, removed: removedLines };
645
+ const appearedPath = appearedPaths[0];
646
+ if (appearedPath) {
647
+ const appearedElement = modifiedMap.get(appearedPath);
648
+ if (appearedElement) {
649
+ const appearedSelector = semanticSelectorFor(appearedElement, modifiedMap);
650
+ if (appearedSelector) part.appearedSelector = appearedSelector;
651
+ }
652
+ }
653
+ parts.push(part);
599
654
  }
600
655
 
601
656
  return parts;