explorbot 0.1.5 → 0.1.7

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.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { basename, dirname, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
+ import { marked, type Tokens } from 'marked';
4
5
  import type { ActionResult } from './action-result.js';
5
6
  import { ConfigParser } from './config.js';
6
7
  import { KnowledgeTracker } from './knowledge-tracker.js';
@@ -332,6 +333,133 @@ ${filteredCode}
332
333
 
333
334
  return results;
334
335
  }
336
+
337
+ getExperienceTableOfContents(state: ActionResult, options?: { includeDescendantExperience?: boolean }): ExperienceTocEntry[] {
338
+ const records = this.getRelevantExperience(state, options);
339
+ if (records.length === 0) return [];
340
+
341
+ const sorted = [...records].sort((a, b) => {
342
+ const aHash = basename(a.filePath, '.md');
343
+ const bHash = basename(b.filePath, '.md');
344
+ return aHash.localeCompare(bHash);
345
+ });
346
+
347
+ const toc: ExperienceTocEntry[] = [];
348
+ for (let i = 0; i < sorted.length; i++) {
349
+ const record = sorted[i];
350
+ const fileHash = basename(record.filePath, '.md');
351
+ const url = (record.data as WebPageState)?.url || '';
352
+ const sections = listTocHeadings(record.content);
353
+ if (sections.length === 0) continue;
354
+ toc.push({
355
+ fileTag: indexToLetters(i),
356
+ fileHash,
357
+ url,
358
+ sections,
359
+ });
360
+ }
361
+ return toc;
362
+ }
363
+
364
+ getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: { includeDescendantExperience?: boolean }): { title: string; url: string; content: string } | null {
365
+ const toc = this.getExperienceTableOfContents(state, options);
366
+ const entry = toc.find((e) => e.fileTag === fileTag);
367
+ if (!entry) return null;
368
+
369
+ const filePath = this.findExperienceFileByHash(entry.fileHash);
370
+ if (!filePath) return null;
371
+
372
+ const { content } = this.readExperienceFile(entry.fileHash);
373
+ const extracted = extractHeadingSection(content, sectionIndex);
374
+ if (!extracted) return null;
375
+
376
+ return { title: extracted.title, url: entry.url, content: extracted.body };
377
+ }
378
+
379
+ private findExperienceFileByHash(fileHash: string): string | null {
380
+ for (const dir of this.getExperienceDirectories()) {
381
+ const candidate = join(dir, `${fileHash}.md`);
382
+ if (existsSync(candidate)) return candidate;
383
+ }
384
+ return null;
385
+ }
386
+ }
387
+
388
+ function listTocHeadings(content: string): { index: number; level: 2 | 3; title: string }[] {
389
+ const tokens = marked.lexer(content);
390
+ const result: { index: number; level: 2 | 3; title: string }[] = [];
391
+ let index = 0;
392
+ for (const token of tokens) {
393
+ if (token.type !== 'heading') continue;
394
+ const heading = token as Tokens.Heading;
395
+ if (heading.depth !== 2 && heading.depth !== 3) continue;
396
+ index++;
397
+ result.push({ index, level: heading.depth as 2 | 3, title: heading.text });
398
+ }
399
+ return result;
400
+ }
401
+
402
+ function extractHeadingSection(content: string, sectionIndex: number): { title: string; body: string } | null {
403
+ const tokens = marked.lexer(content);
404
+ const matching: { tokenIdx: number; depth: number; text: string }[] = [];
405
+
406
+ for (let i = 0; i < tokens.length; i++) {
407
+ const token = tokens[i];
408
+ if (token.type !== 'heading') continue;
409
+ const heading = token as Tokens.Heading;
410
+ if (heading.depth !== 2 && heading.depth !== 3) continue;
411
+ matching.push({ tokenIdx: i, depth: heading.depth, text: heading.text });
412
+ }
413
+
414
+ if (sectionIndex < 1 || sectionIndex > matching.length) return null;
415
+
416
+ const target = matching[sectionIndex - 1];
417
+ let endTokenIdx = tokens.length;
418
+ for (let j = target.tokenIdx + 1; j < tokens.length; j++) {
419
+ const token = tokens[j];
420
+ if (token.type !== 'heading') continue;
421
+ if ((token as Tokens.Heading).depth <= target.depth) {
422
+ endTokenIdx = j;
423
+ break;
424
+ }
425
+ }
426
+
427
+ const body = tokens
428
+ .slice(target.tokenIdx, endTokenIdx)
429
+ .map((t) => (t as any).raw || '')
430
+ .join('');
431
+ return { title: target.text, body };
432
+ }
433
+
434
+ function indexToLetters(index: number): string {
435
+ let n = index;
436
+ let result = '';
437
+ while (true) {
438
+ result = String.fromCharCode(65 + (n % 26)) + result;
439
+ n = Math.floor(n / 26);
440
+ if (n === 0) break;
441
+ n -= 1;
442
+ }
443
+ return result;
444
+ }
445
+
446
+ export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
447
+ if (toc.length === 0) return '';
448
+
449
+ const lines: string[] = [];
450
+ lines.push('<experience>');
451
+ lines.push('Past experience for this page. Call learn_experience({ fileTag, sectionIndex }) to read a section.');
452
+ lines.push('');
453
+ for (const entry of toc) {
454
+ lines.push(`File ${entry.fileTag} ${entry.url}:`);
455
+ for (const section of entry.sections) {
456
+ const prefix = '#'.repeat(section.level);
457
+ lines.push(` ${entry.fileTag}.${section.index} ${prefix} ${section.title}`);
458
+ }
459
+ lines.push('');
460
+ }
461
+ lines.push('</experience>');
462
+ return lines.join('\n');
335
463
  }
336
464
 
337
465
  export interface SessionStep {
@@ -348,3 +476,10 @@ export interface SessionExperienceEntry {
348
476
  steps: SessionStep[];
349
477
  relatedUrls?: string[];
350
478
  }
479
+
480
+ export interface ExperienceTocEntry {
481
+ fileTag: string;
482
+ fileHash: string;
483
+ url: string;
484
+ sections: { index: number; level: 2 | 3; title: string }[];
485
+ }
package/src/explorbot.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { ActionResult } from './action-result.ts';
3
4
  import { ApiClient } from './api/api-client.ts';
4
5
  import { RequestStore } from './api/request-store.ts';
5
6
  import { loadSpec } from './api/spec-reader.ts';
@@ -182,8 +183,14 @@ export class ExplorBot {
182
183
  return (this.agents.pilot ||= this.createAgent(({ ai, explorer }) => {
183
184
  const researcher = this.agentResearcher();
184
185
  const navigator = this.agentNavigator();
185
- const tools = createAgentTools({ explorer, researcher, navigator });
186
- return new Pilot(ai, tools, researcher, explorer);
186
+ const stateManager = explorer.getStateManager();
187
+ const experienceTracker = stateManager.getExperienceTracker();
188
+ const getState = () => {
189
+ const state = stateManager.getCurrentState();
190
+ return state ? ActionResult.fromState(state) : null;
191
+ };
192
+ const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
193
+ return new Pilot(ai, tools, researcher, explorer, experienceTracker);
187
194
  }));
188
195
  }
189
196
 
package/src/utils/aria.ts CHANGED
@@ -355,8 +355,41 @@ export interface FocusAreaResult {
355
355
  name: string | null;
356
356
  }
357
357
 
358
+ const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
359
+
360
+ const findOverlayByCloseButton = (nodeList: AriaNode[]): FocusAreaResult | null => {
361
+ const closeIdx = nodeList.findIndex((n) => n.role === 'button' && CLOSE_OVERLAY_BUTTON_RE.test(n.name || ''));
362
+ if (closeIdx !== -1) {
363
+ let heading: AriaNode | undefined;
364
+ for (let i = closeIdx - 1; i >= 0; i--) {
365
+ if (nodeList[i].role === 'heading' && nodeList[i].name) {
366
+ heading = nodeList[i];
367
+ break;
368
+ }
369
+ }
370
+ if (!heading) {
371
+ for (let i = closeIdx + 1; i < nodeList.length; i++) {
372
+ if (nodeList[i].role === 'heading' && nodeList[i].name) {
373
+ heading = nodeList[i];
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ return {
379
+ detected: true,
380
+ type: 'dialog',
381
+ name: heading?.name || null,
382
+ };
383
+ }
384
+ for (const node of nodeList) {
385
+ const inner = findOverlayByCloseButton(node.children);
386
+ if (inner) return inner;
387
+ }
388
+ return null;
389
+ };
390
+
358
391
  export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
359
- const nodes = parseAriaSnapshot(snapshot);
392
+ const nodes = parseAriaSnapshot(snapshot, true);
360
393
 
361
394
  const findFocusArea = (nodeList: AriaNode[]): FocusAreaResult | null => {
362
395
  for (const node of nodeList) {
@@ -385,7 +418,12 @@ export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
385
418
  };
386
419
 
387
420
  const result = findFocusArea(nodes);
388
- return result || { detected: false, type: null, name: null };
421
+ if (result) return result;
422
+
423
+ const fallback = findOverlayByCloseButton(nodes);
424
+ if (fallback && fallback.name) return fallback;
425
+
426
+ return { detected: false, type: null, name: null };
389
427
  };
390
428
 
391
429
  export const collectInteractiveNodes = (snapshot: string | null): Array<Record<string, unknown>> => {