From be2662b686ee5de26ccea28944e688e2504633c4 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 12 Jul 2026 12:14:46 +0300 Subject: [PATCH 1/5] refactor: delegate StateManager knowledge loading to KnowledgeTracker (plan 010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StateManager duplicated the entire knowledge directory scan (readdir + gray-matter parse + URL match) that KnowledgeTracker already owns — a second scanner with its own 30s cache, drifting independently. Per the code-ownership map, KnowledgeTracker is the single owner of reading ./knowledge. - StateManager takes an injected KnowledgeTracker and delegates getRelevantKnowledge to it; deletes scanKnowledgeFiles, knowledgeCache, lastKnowledgeScan, knowledgeDir and the config-relative dir resolution. Explorer now constructs the KnowledgeTracker and passes the same instance in (one owner, one cache). - KnowledgeTracker: recursive .md scan (matches the removed scanner), a 30s refresh TTL so long sessions see edits, and addKnowledge() invalidates the cache. Knowledge interface is exported from KnowledgeTracker and re-exported from StateManager for back-compat. The constructor accepts (but does not act on) `incognito` so Explorer keeps passing it; the actual incognito wiring is plan 009's ExperienceTracker({ disabled }) change. This keeps the two PRs independent off main and regression-free in either merge order. Co-Authored-By: Claude Opus 4.8 --- src/explorer.ts | 2 +- src/knowledge-tracker.ts | 16 +++-- src/state-manager.ts | 87 +++------------------------- tests/unit/knowledge-tracker.test.ts | 44 +++++++++++++- tests/unit/state-manager.test.ts | 31 ++++++++++ 5 files changed, 94 insertions(+), 86 deletions(-) diff --git a/src/explorer.ts b/src/explorer.ts index 0dc83ba..2350cc6 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -82,8 +82,8 @@ class Explorer { this.aiProvider = aiProvider; this.options = options; this.initializeContainer(); - this.stateManager = new StateManager({ incognito: this.options?.incognito }); this.knowledgeTracker = new KnowledgeTracker(); + this.stateManager = new StateManager({ knowledgeTracker: this.knowledgeTracker }); this.reporter = new Reporter(config.reporter, this.stateManager); } diff --git a/src/knowledge-tracker.ts b/src/knowledge-tracker.ts index d37ed9c..7385fd2 100644 --- a/src/knowledge-tracker.ts +++ b/src/knowledge-tracker.ts @@ -8,17 +8,20 @@ import { createDebug } from './utils/logger.js'; const debugLog = createDebug('explorbot:knowledge-tracker'); -interface Knowledge { +export interface Knowledge { filePath: string; url: string; content: string; [key: string]: any; } +const KNOWLEDGE_REFRESH_MS = 30000; + export class KnowledgeTracker { private knowledgeDir: string; private knowledgeFiles: Knowledge[] = []; private isLoaded = false; + private lastLoadedAt = 0; constructor() { const configParser = ConfigParser.getInstance(); @@ -38,7 +41,7 @@ export class KnowledgeTracker { } private loadKnowledgeFiles(): void { - if (this.isLoaded) return; + if (this.isLoaded && Date.now() - this.lastLoadedAt < KNOWLEDGE_REFRESH_MS) return; this.knowledgeFiles = []; @@ -46,9 +49,9 @@ export class KnowledgeTracker { return; } - const files = readdirSync(this.knowledgeDir) - .filter((file) => file.endsWith('.md')) - .map((file) => join(this.knowledgeDir, file)); + const files = readdirSync(this.knowledgeDir, { recursive: true }) + .filter((file) => typeof file === 'string' && file.endsWith('.md')) + .map((file) => join(this.knowledgeDir, file as string)); for (const filePath of files) { try { @@ -68,6 +71,7 @@ export class KnowledgeTracker { } this.isLoaded = true; + this.lastLoadedAt = Date.now(); } getRelevantKnowledge(state: ActionResult): Knowledge[] { @@ -127,6 +131,8 @@ export class KnowledgeTracker { writeFileSync(filePath, fileContent, 'utf8'); } + this.isLoaded = false; + return { filename, filePath, isNewFile }; } diff --git a/src/state-manager.ts b/src/state-manager.ts index b1dba9d..cba6b4d 100644 --- a/src/state-manager.ts +++ b/src/state-manager.ts @@ -1,9 +1,8 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import matter from 'gray-matter'; +import { existsSync, readFileSync } from 'node:fs'; import { ActionResult } from './action-result.js'; -import { ConfigParser, outputPath } from './config.js'; +import { outputPath } from './config.js'; import { ExperienceTracker } from './experience-tracker.js'; +import { KnowledgeTracker, type Knowledge } from './knowledge-tracker.js'; import { detectFocusArea } from './utils/aria.js'; import { createDebug, tag } from './utils/logger.js'; import { extractStatePath } from './utils/url-matcher.js'; @@ -69,37 +68,20 @@ export interface StateTransition { export type StateChangeListener = (event: StateTransition) => void; -export interface Knowledge extends WebPageState { - /** File path */ - filePath: string; - /** Markdown content */ - content: string; -} +export type { Knowledge }; export class StateManager { private currentState: WebPageState | null = null; private stateHistory: StateTransition[] = []; private allVisitedUrls: Set = new Set(); - private knowledgeCache: Knowledge[] = []; - private lastKnowledgeScan: Date | null = null; private stateChangeListeners: StateChangeListener[] = []; private experienceTracker!: ExperienceTracker; - private knowledgeDir: string; + private knowledgeTracker: KnowledgeTracker; private nextStateId = 1; - constructor() { + constructor(options: { knowledgeTracker?: KnowledgeTracker } = {}) { this.experienceTracker = new ExperienceTracker(); - const configParser = ConfigParser.getInstance(); - const config = configParser.getConfig(); - const configPath = configParser.getConfigPath(); - - // Resolve knowledge directory relative to the config file location (project root) - if (configPath) { - const projectRoot = dirname(configPath); - this.knowledgeDir = join(projectRoot, config.dirs?.knowledge || 'knowledge'); - } else { - this.knowledgeDir = config.dirs?.knowledge || 'knowledge'; - } + this.knowledgeTracker = options.knowledgeTracker || new KnowledgeTracker(); } getExperienceTracker(): ExperienceTracker { @@ -327,65 +309,14 @@ export class StateManager { return this.stateHistory.slice(-count); } - /** - * Scan knowledge directory for .md files and cache them - */ - private scanKnowledgeFiles(): void { - const now = new Date(); - - // Only rescan every 30 seconds to avoid excessive file I/O - if (this.lastKnowledgeScan && now.getTime() - this.lastKnowledgeScan.getTime() < 30000) { - return; - } - - this.knowledgeCache = []; - - if (!existsSync(this.knowledgeDir)) { - debugLog(`Knowledge directory not found: ${this.knowledgeDir}`); - return; - } - - try { - const files = readdirSync(this.knowledgeDir, { recursive: true }) - .filter((file) => typeof file === 'string' && file.endsWith('.md')) - .map((file) => join(this.knowledgeDir, file as string)); - - for (const filePath of files) { - try { - const fileContent = readFileSync(filePath, 'utf8'); - const parsed = matter(fileContent); - - const urlPattern = parsed.data.url || parsed.data.path || '*'; - - this.knowledgeCache.push({ - filePath, - url: urlPattern, - ...parsed.data, - content: parsed.content, - }); - - debugLog(`Loaded knowledge file: ${filePath} (pattern: ${urlPattern})`); - } catch (error) { - debugLog(`Failed to load knowledge file ${filePath}:`, error); - } - } - - this.lastKnowledgeScan = now; - debugLog(`Scanned ${this.knowledgeCache.length} knowledge files`); - } catch (error) { - debugLog('Failed to scan knowledge directory:', error); - } - } /** * Get relevant knowledge files for current state */ getRelevantKnowledge(): Knowledge[] { if (!this.currentState) return []; - this.scanKnowledgeFiles(); - const actionResult = ActionResult.fromState(this.currentState); - return this.knowledgeCache.filter((knowledge) => actionResult.isMatchedBy(knowledge)); + return this.knowledgeTracker.getRelevantKnowledge(actionResult); } /** @@ -527,8 +458,6 @@ export class StateManager { this.stateHistory = []; this.allVisitedUrls.clear(); this.stateChangeListeners = []; - this.knowledgeCache = []; - this.lastKnowledgeScan = null; this.nextStateId = 1; // Clean up experience tracker if it has cleanup method diff --git a/tests/unit/knowledge-tracker.test.ts b/tests/unit/knowledge-tracker.test.ts index 6626096..27c2da1 100644 --- a/tests/unit/knowledge-tracker.test.ts +++ b/tests/unit/knowledge-tracker.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, setSystemTime } from 'bun:test'; import { existsSync, rmSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs'; import matter from 'gray-matter'; @@ -125,4 +125,46 @@ describe('KnowledgeTracker', () => { expect(content[0]).not.toContain('${config.'); }); }); + + describe('recursive scan', () => { + it('loads knowledge from nested subdirectories', () => { + mkdirSync(`${knowledgeDir}/subdir`, { recursive: true }); + writeFileSync(`${knowledgeDir}/subdir/nested.md`, matter.stringify('Nested note', { url: '/nested-page' }), 'utf8'); + + const tracker = new KnowledgeTracker(); + const content = tracker.getKnowledgeForUrl('/nested-page'); + + expect(content[0]).toContain('Nested note'); + }); + }); + + describe('addKnowledge cache invalidation', () => { + it('sees knowledge added via addKnowledge on the same instance', () => { + const tracker = new KnowledgeTracker(); + expect(tracker.getMatchingKnowledge('/login')).toHaveLength(0); + + tracker.addKnowledge('/login', 'Use admin credentials'); + + const matched = tracker.getMatchingKnowledge('/login'); + expect(matched.length).toBeGreaterThan(0); + expect(matched[0].content).toContain('Use admin credentials'); + }); + + it('refreshes files written to disk by another instance after the TTL elapses', () => { + const tracker = new KnowledgeTracker(); + expect(tracker.getMatchingKnowledge('/ttl-page')).toHaveLength(0); + + writeFileSync(`${knowledgeDir}/ttl.md`, matter.stringify('Fresh note', { url: '/ttl-page' }), 'utf8'); + expect(tracker.getMatchingKnowledge('/ttl-page')).toHaveLength(0); + + setSystemTime(new Date(Date.now() + 31000)); + try { + const matched = tracker.getMatchingKnowledge('/ttl-page'); + expect(matched.length).toBeGreaterThan(0); + expect(matched[0].content).toContain('Fresh note'); + } finally { + setSystemTime(); + } + }); + }); }); diff --git a/tests/unit/state-manager.test.ts b/tests/unit/state-manager.test.ts index fd3bce7..5ee524a 100644 --- a/tests/unit/state-manager.test.ts +++ b/tests/unit/state-manager.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import matter from 'gray-matter'; import { ActionResult } from '../../src/action-result'; import { ConfigParser } from '../../src/config'; import { StateManager, type StateTransition, type WebPageState } from '../../src/state-manager'; @@ -42,6 +44,35 @@ describe('StateManager', () => { }); }); + describe('getRelevantKnowledge', () => { + const smKnowledgeDir = '/tmp/explorbot-sm-knowledge'; + const savedVar = process.env.SM_TEST_VAR; + + afterEach(() => { + process.env.SM_TEST_VAR = savedVar; + rmSync(smKnowledgeDir, { recursive: true, force: true }); + }); + + it('interpolates ${env.*} in knowledge via the delegated KnowledgeTracker', () => { + rmSync(smKnowledgeDir, { recursive: true, force: true }); + mkdirSync(smKnowledgeDir, { recursive: true }); + + const configParser = ConfigParser.getInstance(); + (configParser as any).config = { playwright: { browser: 'chromium' }, ai: { model: 'test' }, dirs: { knowledge: 'explorbot-sm-knowledge' } }; + (configParser as any).configPath = '/tmp/config.js'; + process.env.SM_TEST_VAR = 'interpolated-value'; + + writeFileSync(`${smKnowledgeDir}/page.md`, matter.stringify('Secret: ${env.SM_TEST_VAR}', { url: '/sm-page' }), 'utf8'); + + const sm = new StateManager(); + sm.updateState(new ActionResult({ url: '/sm-page', html: 'x' })); + + const knowledge = sm.getRelevantKnowledge(); + expect(knowledge[0].content).toContain('interpolated-value'); + expect(knowledge[0].content).not.toContain('${env.'); + }); + }); + describe('updateState', () => { it('should update current state from ActionResult', () => { const actionResult = new ActionResult({ From dc2816c2398791b6409e805dd2686eba8200bfd1 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 12 Jul 2026 12:55:23 +0300 Subject: [PATCH 2/5] refactor: drop unnecessary knowledge refresh TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knowledge is persistent. The 30s TTL mirrored the old StateManager scanner's throttle, but that throttle only existed because the scanner naively re-read the directory on every getRelevantKnowledge call. KnowledgeTracker loads once (isLoaded) and addKnowledge() invalidates the single shared instance's cache on writes, so there is nothing to poll for — the TTL just re-read disk every 30s for data that doesn't change mid-session. Co-Authored-By: Claude Opus 4.8 --- src/knowledge-tracker.ts | 6 +----- tests/unit/knowledge-tracker.test.ts | 19 +------------------ 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/src/knowledge-tracker.ts b/src/knowledge-tracker.ts index 7385fd2..02a37c7 100644 --- a/src/knowledge-tracker.ts +++ b/src/knowledge-tracker.ts @@ -15,13 +15,10 @@ export interface Knowledge { [key: string]: any; } -const KNOWLEDGE_REFRESH_MS = 30000; - export class KnowledgeTracker { private knowledgeDir: string; private knowledgeFiles: Knowledge[] = []; private isLoaded = false; - private lastLoadedAt = 0; constructor() { const configParser = ConfigParser.getInstance(); @@ -41,7 +38,7 @@ export class KnowledgeTracker { } private loadKnowledgeFiles(): void { - if (this.isLoaded && Date.now() - this.lastLoadedAt < KNOWLEDGE_REFRESH_MS) return; + if (this.isLoaded) return; this.knowledgeFiles = []; @@ -71,7 +68,6 @@ export class KnowledgeTracker { } this.isLoaded = true; - this.lastLoadedAt = Date.now(); } getRelevantKnowledge(state: ActionResult): Knowledge[] { diff --git a/tests/unit/knowledge-tracker.test.ts b/tests/unit/knowledge-tracker.test.ts index 27c2da1..4c0e43e 100644 --- a/tests/unit/knowledge-tracker.test.ts +++ b/tests/unit/knowledge-tracker.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, setSystemTime } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, rmSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs'; import matter from 'gray-matter'; @@ -149,22 +149,5 @@ describe('KnowledgeTracker', () => { expect(matched.length).toBeGreaterThan(0); expect(matched[0].content).toContain('Use admin credentials'); }); - - it('refreshes files written to disk by another instance after the TTL elapses', () => { - const tracker = new KnowledgeTracker(); - expect(tracker.getMatchingKnowledge('/ttl-page')).toHaveLength(0); - - writeFileSync(`${knowledgeDir}/ttl.md`, matter.stringify('Fresh note', { url: '/ttl-page' }), 'utf8'); - expect(tracker.getMatchingKnowledge('/ttl-page')).toHaveLength(0); - - setSystemTime(new Date(Date.now() + 31000)); - try { - const matched = tracker.getMatchingKnowledge('/ttl-page'); - expect(matched.length).toBeGreaterThan(0); - expect(matched[0].content).toContain('Fresh note'); - } finally { - setSystemTime(); - } - }); }); }); From 50e8ab3c33dde19de674f91a9ccb40854c982410 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 12 Jul 2026 13:09:53 +0300 Subject: [PATCH 3/5] refactor: make KnowledgeTracker a singleton (getInstance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KnowledgeTracker derives everything from ConfigParser (app-global) and reads the same persistent ./knowledge dir, so there is never a reason for more than one. The app had ~10 `new KnowledgeTracker()` sites, each with its own cache — the exact duplication plan 010 set out to remove, and the injected-param on StateManager was a footgun (a missing arg silently spawned a divergent instance). Mirror ConfigParser: private constructor + static getInstance() + resetForTesting(). StateManager drops the constructor param and just calls KnowledgeTracker.getInstance(); every other call site (Explorer, ExperienceTracker, Navigator, historians, AddKnowledge, explorbot, CLI) does the same. One instance, one cache app-wide; addKnowledge() invalidation is now global. Tests reset the singleton in beforeEach. Co-Authored-By: Claude Opus 4.8 --- bin/explorbot-cli.ts | 2 +- src/ai/historian/codeceptjs.ts | 2 +- src/ai/historian/playwright.ts | 2 +- src/ai/navigator.ts | 2 +- src/components/AddKnowledge.tsx | 6 +++--- src/experience-tracker.ts | 2 +- src/explorbot.ts | 2 +- src/explorer.ts | 4 ++-- src/knowledge-tracker.ts | 14 +++++++++++++- src/state-manager.ts | 4 ++-- tests/unit/knowledge-tracker.test.ts | 21 +++++++++++---------- tests/unit/state-manager-events.test.ts | 2 ++ tests/unit/state-manager.test.ts | 3 +++ 13 files changed, 42 insertions(+), 24 deletions(-) diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 6da67d7..e7378ab 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -511,7 +511,7 @@ program if (url && description) { const { KnowledgeTracker } = await import('../src/knowledge-tracker.js'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const result = tracker.addKnowledge(url, description); const action = result.isNewFile ? 'Created' : 'Updated'; console.log(`Knowledge ${action} in: ${result.filename}`); diff --git a/src/ai/historian/codeceptjs.ts b/src/ai/historian/codeceptjs.ts index 2c9782d..9c52611 100644 --- a/src/ai/historian/codeceptjs.ts +++ b/src/ai/historian/codeceptjs.ts @@ -108,7 +108,7 @@ export function WithCodeceptJS(Base: T) { } private getKnowledgeLines(url: string, indent = ' '): string[] { - const knowledgeTracker = new KnowledgeTracker(); + const knowledgeTracker = KnowledgeTracker.getInstance(); const state = new ActionResult({ url }); const { wait, waitForElement, code } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement', 'code']); diff --git a/src/ai/historian/playwright.ts b/src/ai/historian/playwright.ts index 6e0aac3..526b9ad 100644 --- a/src/ai/historian/playwright.ts +++ b/src/ai/historian/playwright.ts @@ -146,7 +146,7 @@ export function WithPlaywright(Base: T) { } private getPlaywrightKnowledgeLines(url: string, indent = ' '): string[] { - const knowledgeTracker = new KnowledgeTracker(); + const knowledgeTracker = KnowledgeTracker.getInstance(); const state = new ActionResult({ url }); const { wait, waitForElement } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement']); diff --git a/src/ai/navigator.ts b/src/ai/navigator.ts index 793305e..f8eb46f 100644 --- a/src/ai/navigator.ts +++ b/src/ai/navigator.ts @@ -74,7 +74,7 @@ class Navigator implements Agent { constructor(explorer: Explorer, provider: Provider, experienceTracker?: ExperienceTracker) { this.provider = provider; this.explorer = explorer; - this.knowledgeTracker = new KnowledgeTracker(); + this.knowledgeTracker = KnowledgeTracker.getInstance(); this.experienceTracker = experienceTracker || new ExperienceTracker(); this.hooksRunner = new HooksRunner(explorer, explorer.getConfig()); } diff --git a/src/components/AddKnowledge.tsx b/src/components/AddKnowledge.tsx index 5d0bdff..7e907b3 100644 --- a/src/components/AddKnowledge.tsx +++ b/src/components/AddKnowledge.tsx @@ -20,7 +20,7 @@ const AddKnowledge: React.FC = ({ initialUrl = '', onComplete useEffect(() => { try { - const knowledgeTracker = new KnowledgeTracker(); + const knowledgeTracker = KnowledgeTracker.getInstance(); const urls = knowledgeTracker.getExistingUrls(); setSuggestedUrls(urls); } catch (error) { @@ -31,7 +31,7 @@ const AddKnowledge: React.FC = ({ initialUrl = '', onComplete useEffect(() => { if (urlPattern.trim()) { try { - const knowledgeTracker = new KnowledgeTracker(); + const knowledgeTracker = KnowledgeTracker.getInstance(); const knowledge = knowledgeTracker.getKnowledgeForUrl(urlPattern); setExistingKnowledge(knowledge); } catch (error) { @@ -49,7 +49,7 @@ const AddKnowledge: React.FC = ({ initialUrl = '', onComplete } try { - const knowledgeTracker = new KnowledgeTracker(); + const knowledgeTracker = KnowledgeTracker.getInstance(); const result = knowledgeTracker.addKnowledge(urlPattern.trim(), description.trim()); const action = result.isNewFile ? 'Created' : 'Updated'; console.log(`\nKnowledge ${action} in: ${result.filename}`); diff --git a/src/experience-tracker.ts b/src/experience-tracker.ts index 4dbf8e6..1a334bd 100644 --- a/src/experience-tracker.ts +++ b/src/experience-tracker.ts @@ -43,7 +43,7 @@ export class ExperienceTracker { const config = configParser.getConfig(); const configPath = configParser.getConfigPath(); this.disabled = options.disabled ?? false; - this.knowledgeTracker = new KnowledgeTracker(); + this.knowledgeTracker = KnowledgeTracker.getInstance(); // Resolve experience directory relative to the config file location (project root) if (configPath) { diff --git a/src/explorbot.ts b/src/explorbot.ts index 9f7e06a..9f8a753 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -135,7 +135,7 @@ export class ExplorBot { if (this.explorer) { return this.explorer.getKnowledgeTracker(); } - return new KnowledgeTracker(); + return KnowledgeTracker.getInstance(); } getExperienceTracker(): ExperienceTracker { diff --git a/src/explorer.ts b/src/explorer.ts index 7096192..8b98eb8 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -80,8 +80,8 @@ class Explorer { this.aiProvider = aiProvider; this.options = options; this.initializeContainer(); - this.knowledgeTracker = new KnowledgeTracker(); - this.stateManager = new StateManager({ knowledgeTracker: this.knowledgeTracker }); + this.knowledgeTracker = KnowledgeTracker.getInstance(); + this.stateManager = new StateManager(); this.reporter = new Reporter(config.reporter, this.stateManager); } diff --git a/src/knowledge-tracker.ts b/src/knowledge-tracker.ts index 02a37c7..596ae4f 100644 --- a/src/knowledge-tracker.ts +++ b/src/knowledge-tracker.ts @@ -16,11 +16,12 @@ export interface Knowledge { } export class KnowledgeTracker { + private static instance: KnowledgeTracker | undefined; private knowledgeDir: string; private knowledgeFiles: Knowledge[] = []; private isLoaded = false; - constructor() { + private constructor() { const configParser = ConfigParser.getInstance(); const config = configParser.getConfig(); const configPath = configParser.getConfigPath(); @@ -37,6 +38,17 @@ export class KnowledgeTracker { } } + static getInstance(): KnowledgeTracker { + if (!KnowledgeTracker.instance) { + KnowledgeTracker.instance = new KnowledgeTracker(); + } + return KnowledgeTracker.instance; + } + + static resetForTesting(): void { + KnowledgeTracker.instance = undefined; + } + private loadKnowledgeFiles(): void { if (this.isLoaded) return; diff --git a/src/state-manager.ts b/src/state-manager.ts index 6556efe..62f15ad 100644 --- a/src/state-manager.ts +++ b/src/state-manager.ts @@ -77,9 +77,9 @@ export class StateManager { private knowledgeTracker: KnowledgeTracker; private nextStateId = 1; - constructor(options: { knowledgeTracker?: KnowledgeTracker } = {}) { + constructor() { this.experienceTracker = new ExperienceTracker(); - this.knowledgeTracker = options.knowledgeTracker || new KnowledgeTracker(); + this.knowledgeTracker = KnowledgeTracker.getInstance(); } getExperienceTracker(): ExperienceTracker { diff --git a/tests/unit/knowledge-tracker.test.ts b/tests/unit/knowledge-tracker.test.ts index 4c0e43e..7db6f06 100644 --- a/tests/unit/knowledge-tracker.test.ts +++ b/tests/unit/knowledge-tracker.test.ts @@ -21,6 +21,7 @@ describe('KnowledgeTracker', () => { dirs: { knowledge: 'explorbot-test-knowledge' }, }; (configParser as any).configPath = '/tmp/config.js'; + KnowledgeTracker.resetForTesting(); }); afterEach(() => { @@ -41,7 +42,7 @@ describe('KnowledgeTracker', () => { writeKnowledgeFile('login.md', '/login', 'email: ${env.TEST_LOGIN}\npassword: ${env.TEST_PASSWORD}'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/login'); expect(content[0]).toContain('email: admin@example.com'); @@ -56,7 +57,7 @@ describe('KnowledgeTracker', () => { writeKnowledgeFile('login.md', '/login', 'token: ${env.NONEXISTENT_VAR}'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/login'); expect(content[0]).toContain('token:'); @@ -66,7 +67,7 @@ describe('KnowledgeTracker', () => { it('should leave unknown namespaces untouched', () => { writeKnowledgeFile('page.md', '/page', 'value: ${custom.baseUrl}'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('${custom.baseUrl}'); @@ -75,7 +76,7 @@ describe('KnowledgeTracker', () => { it('should leave expressions without namespace untouched', () => { writeKnowledgeFile('page.md', '/page', 'value: ${somevar}'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('${somevar}'); @@ -86,7 +87,7 @@ describe('KnowledgeTracker', () => { writeKnowledgeFile('login.md', '/login', 'Login as ${env.TEST_USER} on the main page\nThen check dashboard'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/login'); expect(content[0]).toContain('Login as testuser on the main page'); @@ -98,7 +99,7 @@ describe('KnowledgeTracker', () => { it('should replace ${config.*} with config values', () => { writeKnowledgeFile('page.md', '/page', 'Base URL: ${config.playwright.url}\nBrowser: ${config.playwright.browser}'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('Base URL: http://localhost:3000'); @@ -108,7 +109,7 @@ describe('KnowledgeTracker', () => { it('should replace missing config paths with empty string', () => { writeKnowledgeFile('page.md', '/page', 'value: ${config.nonexistent.path}'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('value:'); @@ -118,7 +119,7 @@ describe('KnowledgeTracker', () => { it('should replace object config values with empty string', () => { writeKnowledgeFile('page.md', '/page', 'value: ${config.playwright}'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('value:'); @@ -131,7 +132,7 @@ describe('KnowledgeTracker', () => { mkdirSync(`${knowledgeDir}/subdir`, { recursive: true }); writeFileSync(`${knowledgeDir}/subdir/nested.md`, matter.stringify('Nested note', { url: '/nested-page' }), 'utf8'); - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); const content = tracker.getKnowledgeForUrl('/nested-page'); expect(content[0]).toContain('Nested note'); @@ -140,7 +141,7 @@ describe('KnowledgeTracker', () => { describe('addKnowledge cache invalidation', () => { it('sees knowledge added via addKnowledge on the same instance', () => { - const tracker = new KnowledgeTracker(); + const tracker = KnowledgeTracker.getInstance(); expect(tracker.getMatchingKnowledge('/login')).toHaveLength(0); tracker.addKnowledge('/login', 'Use admin credentials'); diff --git a/tests/unit/state-manager-events.test.ts b/tests/unit/state-manager-events.test.ts index a56439f..3cc3280 100644 --- a/tests/unit/state-manager-events.test.ts +++ b/tests/unit/state-manager-events.test.ts @@ -3,6 +3,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { ActionResult } from '../../src/action-result.js'; import { ConfigParser } from '../../src/config.js'; +import { KnowledgeTracker } from '../../src/knowledge-tracker.js'; import { StateManager } from '../../src/state-manager.js'; describe('StateManager Events', () => { @@ -14,6 +15,7 @@ describe('StateManager Events', () => { // Set up test config ConfigParser.setupTestConfig(); + KnowledgeTracker.resetForTesting(); stateManager = new StateManager(); }); diff --git a/tests/unit/state-manager.test.ts b/tests/unit/state-manager.test.ts index 5ee524a..975d3d2 100644 --- a/tests/unit/state-manager.test.ts +++ b/tests/unit/state-manager.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import matter from 'gray-matter'; import { ActionResult } from '../../src/action-result'; import { ConfigParser } from '../../src/config'; +import { KnowledgeTracker } from '../../src/knowledge-tracker'; import { StateManager, type StateTransition, type WebPageState } from '../../src/state-manager'; describe('StateManager', () => { @@ -22,6 +23,7 @@ describe('StateManager', () => { const configParser = ConfigParser.getInstance(); (configParser as any).config = mockConfig; (configParser as any).configPath = '/tmp/explorbot-test/config.js'; + KnowledgeTracker.resetForTesting(); stateManager = new StateManager(); }); @@ -64,6 +66,7 @@ describe('StateManager', () => { writeFileSync(`${smKnowledgeDir}/page.md`, matter.stringify('Secret: ${env.SM_TEST_VAR}', { url: '/sm-page' }), 'utf8'); + KnowledgeTracker.resetForTesting(); const sm = new StateManager(); sm.updateState(new ActionResult({ url: '/sm-page', html: 'x' })); From aed8ea25813784f7ea8a07152587a046de4ccc11 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 12 Jul 2026 14:27:33 +0300 Subject: [PATCH 4/5] refactor: ExplorBot owns both trackers via instance accessors (DI, no singleton) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the KnowledgeTracker.getInstance() singleton with ownership in the DI container. ExplorBot exposes experienceTracker() / knowledgeTracker() (lazy ||=-cached, matching the agentXxx() factory pattern) and injects both DOWN through Explorer into new StateManager(experienceTracker, knowledgeTracker) — lower layers never import the container, so no explorbot<->state-manager import cycle and no process-global static state. - KnowledgeTracker reverts to a plain class (no static instance/getInstance/reset). - ExperienceTracker takes the shared KnowledgeTracker as a constructor arg. - StateManager takes both trackers injected; keeps its get*Tracker() getters so the ~25 consumers reading stateManager.getExperienceTracker() are unchanged. Adds getKnowledgeTracker(). - Consumers that used to new-up or getInstance() a tracker now route through the shared instance: Navigator/Historian mixins via explorer/stateManager, Captain via explorBot.experienceTracker(). Standalone one-shot contexts with no ExplorBot (CLI `learn`, standalone AddKnowledge which now takes a knowledgeTracker prop) construct their own. - ExplorBot get{Experience,Knowledge}Tracker() renamed to {experience,knowledge}Tracker(). Co-Authored-By: Claude Opus 4.8 --- bin/explorbot-cli.ts | 7 ++++--- src/ai/captain.ts | 2 +- src/ai/historian.ts | 4 ++-- src/ai/historian/codeceptjs.ts | 6 ++++-- src/ai/historian/playwright.ts | 6 ++++-- src/ai/navigator.ts | 4 ++-- src/commands/compact-command.ts | 4 ++-- src/commands/context-command.ts | 2 +- src/commands/context-knowledge-command.ts | 2 +- src/commands/experience-command.ts | 2 +- src/commands/knows-command.ts | 2 +- src/commands/learn-command.ts | 4 +++- src/components/AddKnowledge.tsx | 12 +++++------ src/components/App.tsx | 4 ++-- src/experience-tracker.ts | 4 ++-- src/explorbot.ts | 20 +++++++----------- src/explorer.ts | 7 ++++--- src/knowledge-tracker.ts | 14 +------------ src/state-manager.ts | 10 ++++++--- .../integration/experience-compactor.test.ts | 3 ++- tests/unit/experience-compactor.test.ts | 3 ++- tests/unit/experience-tracker.test.ts | 5 +++-- tests/unit/explorer.test.ts | 5 ++++- tests/unit/knowledge-tracker.test.ts | 21 +++++++++---------- tests/unit/state-manager-events.test.ts | 5 +++-- tests/unit/state-manager.test.ts | 9 ++++---- 26 files changed, 84 insertions(+), 83 deletions(-) diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index e7378ab..4a4d701 100755 --- a/bin/explorbot-cli.ts +++ b/bin/explorbot-cli.ts @@ -509,9 +509,10 @@ program path: options.path || process.cwd(), }); + const { KnowledgeTracker } = await import('../src/knowledge-tracker.js'); + const tracker = new KnowledgeTracker(); + if (url && description) { - const { KnowledgeTracker } = await import('../src/knowledge-tracker.js'); - const tracker = KnowledgeTracker.getInstance(); const result = tracker.addKnowledge(url, description); const action = result.isNewFile ? 'Created' : 'Updated'; console.log(`Knowledge ${action} in: ${result.filename}`); @@ -519,7 +520,7 @@ program } const AddKnowledge = (await import('../src/components/AddKnowledge.js')).default; - render(React.createElement(AddKnowledge, { initialUrl: url || '' }), { + render(React.createElement(AddKnowledge, { initialUrl: url || '', knowledgeTracker: tracker }), { exitOnCtrlC: false, patchConsole: false, }); diff --git a/src/ai/captain.ts b/src/ai/captain.ts index bb5756b..a51346c 100644 --- a/src/ai/captain.ts +++ b/src/ai/captain.ts @@ -37,7 +37,7 @@ export class Captain extends CaptainBase implements Agent { constructor(explorBot: ExplorBot) { super(); this.explorBot = explorBot; - this.experienceTracker = new ExperienceTracker(); + this.experienceTracker = explorBot.experienceTracker(); } setCommandExecutor(fn: (cmd: string) => Promise, descriptions: { name: string; description: string; options: string }[]): void { diff --git a/src/ai/historian.ts b/src/ai/historian.ts index 3ce1b6d..1c09805 100644 --- a/src/ai/historian.ts +++ b/src/ai/historian.ts @@ -28,10 +28,10 @@ export class Historian extends HistorianBase { declare playwright: { recorder: PlaywrightRecorder; helper: any } | undefined; declare savedFiles: Set; - constructor(provider: Provider, experienceTracker?: ExperienceTracker, reporter?: Reporter, stateManager?: StateManager, config?: ExplorbotConfig, playwright?: { recorder: PlaywrightRecorder; helper: any }) { + constructor(provider: Provider, experienceTracker: ExperienceTracker, reporter?: Reporter, stateManager?: StateManager, config?: ExplorbotConfig, playwright?: { recorder: PlaywrightRecorder; helper: any }) { super(); this.provider = provider; - this.experienceTracker = experienceTracker || new ExperienceTracker(); + this.experienceTracker = experienceTracker; this.reporter = reporter; this.stateManager = stateManager; this.config = config; diff --git a/src/ai/historian/codeceptjs.ts b/src/ai/historian/codeceptjs.ts index 9c52611..c313a15 100644 --- a/src/ai/historian/codeceptjs.ts +++ b/src/ai/historian/codeceptjs.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { ActionResult } from '../../action-result.ts'; import { ConfigParser } from '../../config.ts'; -import { KnowledgeTracker } from '../../knowledge-tracker.ts'; +import type { StateManager } from '../../state-manager.ts'; import type { Plan } from '../../test-plan.ts'; import { tag } from '../../utils/logger.ts'; import { relativeToCwd } from '../../utils/next-steps.ts'; @@ -21,6 +21,7 @@ export interface CodeceptJSMethods { export function WithCodeceptJS(Base: T) { return class extends Base { declare savedFiles: Set; + declare stateManager?: StateManager; toCode(conversation: Conversation, scenario: string): string { const toolExecutions = conversation.getToolExecutions(); @@ -108,7 +109,8 @@ export function WithCodeceptJS(Base: T) { } private getKnowledgeLines(url: string, indent = ' '): string[] { - const knowledgeTracker = KnowledgeTracker.getInstance(); + const knowledgeTracker = this.stateManager?.getKnowledgeTracker(); + if (!knowledgeTracker) return []; const state = new ActionResult({ url }); const { wait, waitForElement, code } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement', 'code']); diff --git a/src/ai/historian/playwright.ts b/src/ai/historian/playwright.ts index 526b9ad..7386ee8 100644 --- a/src/ai/historian/playwright.ts +++ b/src/ai/historian/playwright.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { ActionResult } from '../../action-result.ts'; import { ConfigParser } from '../../config.ts'; -import { KnowledgeTracker } from '../../knowledge-tracker.ts'; +import type { StateManager } from '../../state-manager.ts'; import { type PlaywrightRecorder, type TraceCall, renderAssertion, renderCall } from '../../playwright-recorder.ts'; import type { Plan } from '../../test-plan.ts'; import { tag } from '../../utils/logger.ts'; @@ -25,6 +25,7 @@ export function WithPlaywright(Base: T) { return class extends Base { declare playwright: { recorder: PlaywrightRecorder; helper: any } | undefined; declare savedFiles: Set; + declare stateManager?: StateManager; async toPlaywrightCode(conversation: Conversation, scenario: string): Promise { const toolExecutions = conversation.getToolExecutions(); @@ -146,7 +147,8 @@ export function WithPlaywright(Base: T) { } private getPlaywrightKnowledgeLines(url: string, indent = ' '): string[] { - const knowledgeTracker = KnowledgeTracker.getInstance(); + const knowledgeTracker = this.stateManager?.getKnowledgeTracker(); + if (!knowledgeTracker) return []; const state = new ActionResult({ url }); const { wait, waitForElement } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement']); diff --git a/src/ai/navigator.ts b/src/ai/navigator.ts index f8eb46f..1de5b12 100644 --- a/src/ai/navigator.ts +++ b/src/ai/navigator.ts @@ -74,8 +74,8 @@ class Navigator implements Agent { constructor(explorer: Explorer, provider: Provider, experienceTracker?: ExperienceTracker) { this.provider = provider; this.explorer = explorer; - this.knowledgeTracker = KnowledgeTracker.getInstance(); - this.experienceTracker = experienceTracker || new ExperienceTracker(); + this.knowledgeTracker = explorer.getKnowledgeTracker(); + this.experienceTracker = experienceTracker || explorer.getStateManager().getExperienceTracker(); this.hooksRunner = new HooksRunner(explorer, explorer.getConfig()); } diff --git a/src/commands/compact-command.ts b/src/commands/compact-command.ts index 6fe9b53..e9b479e 100644 --- a/src/commands/compact-command.ts +++ b/src/commands/compact-command.ts @@ -22,7 +22,7 @@ export class CompactCommand extends BaseCommand { const dryRun = !!opts.dryRun; const merge = opts.merge !== false; - const tracker = this.explorBot.getExperienceTracker(); + const tracker = this.explorBot.experienceTracker(); const files = this.resolveTarget(tracker, target); if (files === null) { @@ -73,7 +73,7 @@ export class CompactCommand extends BaseCommand { private async runFullSweep(compactor: ReturnType, merge: boolean): Promise<{ merged: number; compacted: number }> { if (!merge) { tag('info').log('Compacting all experience files (merge skipped)…'); - const compacted = await compactor.compactFiles(this.explorBot.getExperienceTracker().getAllExperience()); + const compacted = await compactor.compactFiles(this.explorBot.experienceTracker().getAllExperience()); return { merged: 0, compacted }; } diff --git a/src/commands/context-command.ts b/src/commands/context-command.ts index d0a0db2..d76b51c 100644 --- a/src/commands/context-command.ts +++ b/src/commands/context-command.ts @@ -44,7 +44,7 @@ export class ContextCommand extends BaseCommand { const actionResult = await explorer.createAction().capturePageState({ includeScreenshot: isVisual }); const experienceTracker = explorer.getStateManager().getExperienceTracker(); - const knowledgeTracker = this.explorBot.getKnowledgeTracker(); + const knowledgeTracker = this.explorBot.knowledgeTracker(); let mode: ContextMode = 'compact'; if (opts.full) { diff --git a/src/commands/context-knowledge-command.ts b/src/commands/context-knowledge-command.ts index c4cf7be..4054d62 100644 --- a/src/commands/context-knowledge-command.ts +++ b/src/commands/context-knowledge-command.ts @@ -17,7 +17,7 @@ export class ContextKnowledgeCommand extends BaseCommand { } const actionResult = ActionResult.fromState(state); - const knowledgeTracker = this.explorBot.getKnowledgeTracker(); + const knowledgeTracker = this.explorBot.knowledgeTracker(); const knowledge = knowledgeTracker.getRelevantKnowledge(actionResult); if (knowledge.length === 0) { diff --git a/src/commands/experience-command.ts b/src/commands/experience-command.ts index 8609e8f..32fc029 100644 --- a/src/commands/experience-command.ts +++ b/src/commands/experience-command.ts @@ -20,7 +20,7 @@ export class ExperienceCommand extends BaseCommand { } const recency = opts.recent ? 'recent' : opts.old ? 'old' : undefined; - const tracker = this.explorBot.getExperienceTracker(); + const tracker = this.explorBot.experienceTracker(); const [first, second] = remaining; const combinedRef = first?.match(/^([A-Z]+)[.\-]?(\d+)$/i); diff --git a/src/commands/knows-command.ts b/src/commands/knows-command.ts index 6bedf50..1b2dbb7 100644 --- a/src/commands/knows-command.ts +++ b/src/commands/knows-command.ts @@ -23,7 +23,7 @@ export class KnowsCommand extends BaseCommand { async execute(args: string, limit = 0): Promise { const url = args.trim(); - const tracker = this.explorBot.getKnowledgeTracker(); + const tracker = this.explorBot.knowledgeTracker(); if (!url) { const allKnowledge = tracker.listAllKnowledge(); diff --git a/src/commands/learn-command.ts b/src/commands/learn-command.ts index 3df92a7..b4dcbd2 100644 --- a/src/commands/learn-command.ts +++ b/src/commands/learn-command.ts @@ -13,12 +13,14 @@ export class LearnCommand extends BaseCommand { if (!note) { const AddKnowledge = (await import('../components/AddKnowledge.js')).default; - const state = this.explorBot.getExplorer().getStateManager().getCurrentState(); + const explorer = this.explorBot.getExplorer(); + const state = explorer.getStateManager().getCurrentState(); const initialUrl = state?.url || ''; const { unmount } = render( React.createElement(AddKnowledge, { initialUrl, + knowledgeTracker: explorer.getKnowledgeTracker(), onComplete: () => unmount(), onCancel: () => unmount(), }), diff --git a/src/components/AddKnowledge.tsx b/src/components/AddKnowledge.tsx index 7e907b3..be8c3ca 100644 --- a/src/components/AddKnowledge.tsx +++ b/src/components/AddKnowledge.tsx @@ -1,15 +1,16 @@ import { Box, Text, useInput } from 'ink'; import React, { useEffect, useState } from 'react'; -import { KnowledgeTracker } from '../knowledge-tracker.js'; +import type { KnowledgeTracker } from '../knowledge-tracker.js'; import InputReadline from './InputReadline.js'; interface AddKnowledgeProps { initialUrl?: string; + knowledgeTracker: KnowledgeTracker; onComplete?: () => void; onCancel?: () => void; } -const AddKnowledge: React.FC = ({ initialUrl = '', onComplete, onCancel }) => { +const AddKnowledge: React.FC = ({ initialUrl = '', knowledgeTracker, onComplete, onCancel }) => { const [urlPattern, setUrlPattern] = useState(initialUrl); const [description, setDescription] = useState(''); const [activeField, setActiveField] = useState<'url' | 'description'>(initialUrl ? 'description' : 'url'); @@ -20,18 +21,16 @@ const AddKnowledge: React.FC = ({ initialUrl = '', onComplete useEffect(() => { try { - const knowledgeTracker = KnowledgeTracker.getInstance(); const urls = knowledgeTracker.getExistingUrls(); setSuggestedUrls(urls); } catch (error) { console.error('Failed to load suggestions:', error); } - }, []); + }, [knowledgeTracker]); useEffect(() => { if (urlPattern.trim()) { try { - const knowledgeTracker = KnowledgeTracker.getInstance(); const knowledge = knowledgeTracker.getKnowledgeForUrl(urlPattern); setExistingKnowledge(knowledge); } catch (error) { @@ -41,7 +40,7 @@ const AddKnowledge: React.FC = ({ initialUrl = '', onComplete } else { setExistingKnowledge([]); } - }, [urlPattern]); + }, [urlPattern, knowledgeTracker]); const handleSave = () => { if (!urlPattern.trim() || !description.trim()) { @@ -49,7 +48,6 @@ const AddKnowledge: React.FC = ({ initialUrl = '', onComplete } try { - const knowledgeTracker = KnowledgeTracker.getInstance(); const result = knowledgeTracker.addKnowledge(urlPattern.trim(), description.trim()); const action = result.isNewFile ? 'Created' : 'Updated'; console.log(`\nKnowledge ${action} in: ${result.filename}`); diff --git a/src/components/App.tsx b/src/components/App.tsx index d324ec1..785b36d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -142,7 +142,7 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa if (!mounted) return; setChecklistData({ config: explorBot.getConfig(), - knowledgeTracker: explorBot.getKnowledgeTracker(), + knowledgeTracker: explorBot.knowledgeTracker(), }); setShowWelcome(true); setShowInput(true); @@ -334,7 +334,7 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa {showSessionTimer && } - {showWelcome && 0} />} + {showWelcome && 0} />} {showInput && } {interruptPrompt && showInput && ( diff --git a/src/experience-tracker.ts b/src/experience-tracker.ts index 1a334bd..6ccd171 100644 --- a/src/experience-tracker.ts +++ b/src/experience-tracker.ts @@ -38,12 +38,12 @@ export class ExperienceTracker { private disabled: boolean; private knowledgeTracker: KnowledgeTracker; - constructor(options: { disabled?: boolean } = {}) { + constructor(knowledgeTracker: KnowledgeTracker, options: { disabled?: boolean } = {}) { const configParser = ConfigParser.getInstance(); const config = configParser.getConfig(); const configPath = configParser.getConfigPath(); this.disabled = options.disabled ?? false; - this.knowledgeTracker = KnowledgeTracker.getInstance(); + this.knowledgeTracker = knowledgeTracker; // Resolve experience directory relative to the config file location (project root) if (configPath) { diff --git a/src/explorbot.ts b/src/explorbot.ts index 9f8a753..551b2aa 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -61,6 +61,8 @@ export class ExplorBot { private agents: Record = {}; private sessionPlans: Plan[] = []; private lastReportedTestCount = 0; + private _experienceTracker?: ExperienceTracker; + private _knowledgeTracker?: KnowledgeTracker; constructor(options: ExplorBotOptions = {}) { this.options = options; @@ -86,7 +88,7 @@ export class ExplorBot { try { await this.startProviderOnly(); - this.explorer = new Explorer(this.config, this.provider, this.options); + this.explorer = new Explorer(this.config, this.provider, this.options, this.experienceTracker(), this.knowledgeTracker()); await this.explorer.start(); if (!this.options.incognito) { await this.agentExperienceCompactor().autocompact(); @@ -131,18 +133,12 @@ export class ExplorBot { return this.explorer; } - getKnowledgeTracker(): KnowledgeTracker { - if (this.explorer) { - return this.explorer.getKnowledgeTracker(); - } - return KnowledgeTracker.getInstance(); + knowledgeTracker(): KnowledgeTracker { + return (this._knowledgeTracker ||= new KnowledgeTracker()); } - getExperienceTracker(): ExperienceTracker { - if (this.explorer) { - return this.explorer.getStateManager().getExperienceTracker(); - } - return new ExperienceTracker(); + experienceTracker(): ExperienceTracker { + return (this._experienceTracker ||= new ExperienceTracker(this.knowledgeTracker())); } getConfig(): ExplorbotConfig { @@ -239,7 +235,7 @@ export class ExplorBot { } agentExperienceCompactor(): ExperienceCompactor { - return (this.agents.experienceCompactor ||= new ExperienceCompactor(this.provider, this.getExperienceTracker())); + return (this.agents.experienceCompactor ||= new ExperienceCompactor(this.provider, this.experienceTracker())); } agentQuartermaster(): Quartermaster | null { diff --git a/src/explorer.ts b/src/explorer.ts index 8b98eb8..6fd0803 100644 --- a/src/explorer.ts +++ b/src/explorer.ts @@ -15,6 +15,7 @@ import { RequestStore } from './api/request-store.ts'; import { XhrCapture } from './api/xhr-capture.ts'; import type { ExplorbotConfig } from './config.js'; import { ConfigParser } from './config.js'; +import type { ExperienceTracker } from './experience-tracker.js'; import { KnowledgeTracker } from './knowledge-tracker.js'; import { PlaywrightRecorder } from './playwright-recorder.ts'; import { Reporter } from './reporter.ts'; @@ -75,13 +76,13 @@ class Explorer { private testConsoleHandler: ((message: any) => void) | null = null; private testDialogHandler: ((dialog: any) => void) | null = null; - constructor(config: ExplorbotConfig, aiProvider: AIProvider, options?: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string }) { + constructor(config: ExplorbotConfig, aiProvider: AIProvider, options: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string } | undefined, experienceTracker: ExperienceTracker, knowledgeTracker: KnowledgeTracker) { this.config = config; this.aiProvider = aiProvider; this.options = options; this.initializeContainer(); - this.knowledgeTracker = KnowledgeTracker.getInstance(); - this.stateManager = new StateManager(); + this.knowledgeTracker = knowledgeTracker; + this.stateManager = new StateManager(experienceTracker, knowledgeTracker); this.reporter = new Reporter(config.reporter, this.stateManager); } diff --git a/src/knowledge-tracker.ts b/src/knowledge-tracker.ts index 596ae4f..02a37c7 100644 --- a/src/knowledge-tracker.ts +++ b/src/knowledge-tracker.ts @@ -16,12 +16,11 @@ export interface Knowledge { } export class KnowledgeTracker { - private static instance: KnowledgeTracker | undefined; private knowledgeDir: string; private knowledgeFiles: Knowledge[] = []; private isLoaded = false; - private constructor() { + constructor() { const configParser = ConfigParser.getInstance(); const config = configParser.getConfig(); const configPath = configParser.getConfigPath(); @@ -38,17 +37,6 @@ export class KnowledgeTracker { } } - static getInstance(): KnowledgeTracker { - if (!KnowledgeTracker.instance) { - KnowledgeTracker.instance = new KnowledgeTracker(); - } - return KnowledgeTracker.instance; - } - - static resetForTesting(): void { - KnowledgeTracker.instance = undefined; - } - private loadKnowledgeFiles(): void { if (this.isLoaded) return; diff --git a/src/state-manager.ts b/src/state-manager.ts index 62f15ad..908886b 100644 --- a/src/state-manager.ts +++ b/src/state-manager.ts @@ -77,15 +77,19 @@ export class StateManager { private knowledgeTracker: KnowledgeTracker; private nextStateId = 1; - constructor() { - this.experienceTracker = new ExperienceTracker(); - this.knowledgeTracker = KnowledgeTracker.getInstance(); + constructor(experienceTracker: ExperienceTracker, knowledgeTracker: KnowledgeTracker) { + this.experienceTracker = experienceTracker; + this.knowledgeTracker = knowledgeTracker; } getExperienceTracker(): ExperienceTracker { return this.experienceTracker; } + getKnowledgeTracker(): KnowledgeTracker { + return this.knowledgeTracker; + } + /** * Subscribe to state change events */ diff --git a/tests/integration/experience-compactor.test.ts b/tests/integration/experience-compactor.test.ts index 204ed1d..6be8e82 100644 --- a/tests/integration/experience-compactor.test.ts +++ b/tests/integration/experience-compactor.test.ts @@ -6,6 +6,7 @@ import { ExperienceCompactor } from '../../src/ai/experience-compactor.ts'; import { Provider } from '../../src/ai/provider.ts'; import { ConfigParser } from '../../src/config.ts'; import { ExperienceTracker, RECENT_WINDOW_DAYS } from '../../src/experience-tracker.ts'; +import { KnowledgeTracker } from '../../src/knowledge-tracker.ts'; function extractPromptText(entry: any): string { if (!entry?.body?.messages) return ''; @@ -55,7 +56,7 @@ describe('ExperienceCompactor with aimock', () => { mock.resetMatchCounts(); mock.clearFixtures(); - tracker = new ExperienceTracker(); + tracker = new ExperienceTracker(new KnowledgeTracker()); experienceDir = (tracker as any).experienceDir as string; if (existsSync(experienceDir)) { rmSync(experienceDir, { recursive: true, force: true }); diff --git a/tests/unit/experience-compactor.test.ts b/tests/unit/experience-compactor.test.ts index fbe1dfb..d62f4de 100644 --- a/tests/unit/experience-compactor.test.ts +++ b/tests/unit/experience-compactor.test.ts @@ -5,6 +5,7 @@ import { ExperienceCompactor } from '../../src/ai/experience-compactor'; import type { Provider } from '../../src/ai/provider'; import { ConfigParser } from '../../src/config'; import { ExperienceTracker } from '../../src/experience-tracker'; +import { KnowledgeTracker } from '../../src/knowledge-tracker'; class MockProvider { private responses: any[] = []; @@ -69,7 +70,7 @@ describe('ExperienceCompactor', () => { (configParser as any).config = mockConfig; (configParser as any).configPath = '/tmp/config.js'; - experienceTracker = new ExperienceTracker(); + experienceTracker = new ExperienceTracker(new KnowledgeTracker()); mockProvider = new MockProvider(); compactor = new ExperienceCompactor(mockProvider as unknown as Provider, experienceTracker); }); diff --git a/tests/unit/experience-tracker.test.ts b/tests/unit/experience-tracker.test.ts index e80bdb2..e065aeb 100644 --- a/tests/unit/experience-tracker.test.ts +++ b/tests/unit/experience-tracker.test.ts @@ -5,6 +5,7 @@ import matter from 'gray-matter'; import { ActionResult } from '../../src/action-result'; import { ConfigParser } from '../../src/config'; import { ExperienceTracker } from '../../src/experience-tracker'; +import { KnowledgeTracker } from '../../src/knowledge-tracker'; describe('ExperienceTracker', () => { let experienceTracker: ExperienceTracker; @@ -28,7 +29,7 @@ describe('ExperienceTracker', () => { (configParser as any).config = mockConfig; (configParser as any).configPath = '/tmp/config.js'; - experienceTracker = new ExperienceTracker(); + experienceTracker = new ExperienceTracker(new KnowledgeTracker()); }); afterEach(() => { @@ -264,7 +265,7 @@ describe('ExperienceTracker', () => { }); it('respects disabled flag', () => { - const disabledTracker = new ExperienceTracker({ disabled: true }); + const disabledTracker = new ExperienceTracker(new KnowledgeTracker(), { disabled: true }); const state = makeState(); disabledTracker.writeFlow(state, sampleBody); const stateHash = state.getStateHash(); diff --git a/tests/unit/explorer.test.ts b/tests/unit/explorer.test.ts index 11902f3..f18b1ba 100644 --- a/tests/unit/explorer.test.ts +++ b/tests/unit/explorer.test.ts @@ -208,7 +208,9 @@ mock.module('codeceptjs/lib/listener/store.js', () => ({ import { isTemplateMatch } from '../../src/ai/planner/subpages.ts'; import { AIProvider } from '../../src/ai/provider.ts'; import { ConfigParser } from '../../src/config.ts'; +import { ExperienceTracker } from '../../src/experience-tracker.ts'; import Explorer from '../../src/explorer.ts'; +import { KnowledgeTracker } from '../../src/knowledge-tracker.ts'; import { Reporter } from '../../src/reporter.ts'; import { isDynamicSegment } from '../../src/utils/url-matcher.ts'; @@ -320,7 +322,8 @@ describe('Explorer', () => { vi.spyOn(Reporter.prototype, 'finishRun').mockResolvedValue(); vi.spyOn(Reporter.prototype, 'reportTest').mockResolvedValue(); - explorer = new Explorer(config, new AIProvider(config.ai), { headless: true }); + const knowledgeTracker = new KnowledgeTracker(); + explorer = new Explorer(config, new AIProvider(config.ai), { headless: true }, new ExperienceTracker(knowledgeTracker), knowledgeTracker); await explorer.start(); }); diff --git a/tests/unit/knowledge-tracker.test.ts b/tests/unit/knowledge-tracker.test.ts index 7db6f06..4c0e43e 100644 --- a/tests/unit/knowledge-tracker.test.ts +++ b/tests/unit/knowledge-tracker.test.ts @@ -21,7 +21,6 @@ describe('KnowledgeTracker', () => { dirs: { knowledge: 'explorbot-test-knowledge' }, }; (configParser as any).configPath = '/tmp/config.js'; - KnowledgeTracker.resetForTesting(); }); afterEach(() => { @@ -42,7 +41,7 @@ describe('KnowledgeTracker', () => { writeKnowledgeFile('login.md', '/login', 'email: ${env.TEST_LOGIN}\npassword: ${env.TEST_PASSWORD}'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/login'); expect(content[0]).toContain('email: admin@example.com'); @@ -57,7 +56,7 @@ describe('KnowledgeTracker', () => { writeKnowledgeFile('login.md', '/login', 'token: ${env.NONEXISTENT_VAR}'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/login'); expect(content[0]).toContain('token:'); @@ -67,7 +66,7 @@ describe('KnowledgeTracker', () => { it('should leave unknown namespaces untouched', () => { writeKnowledgeFile('page.md', '/page', 'value: ${custom.baseUrl}'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('${custom.baseUrl}'); @@ -76,7 +75,7 @@ describe('KnowledgeTracker', () => { it('should leave expressions without namespace untouched', () => { writeKnowledgeFile('page.md', '/page', 'value: ${somevar}'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('${somevar}'); @@ -87,7 +86,7 @@ describe('KnowledgeTracker', () => { writeKnowledgeFile('login.md', '/login', 'Login as ${env.TEST_USER} on the main page\nThen check dashboard'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/login'); expect(content[0]).toContain('Login as testuser on the main page'); @@ -99,7 +98,7 @@ describe('KnowledgeTracker', () => { it('should replace ${config.*} with config values', () => { writeKnowledgeFile('page.md', '/page', 'Base URL: ${config.playwright.url}\nBrowser: ${config.playwright.browser}'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('Base URL: http://localhost:3000'); @@ -109,7 +108,7 @@ describe('KnowledgeTracker', () => { it('should replace missing config paths with empty string', () => { writeKnowledgeFile('page.md', '/page', 'value: ${config.nonexistent.path}'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('value:'); @@ -119,7 +118,7 @@ describe('KnowledgeTracker', () => { it('should replace object config values with empty string', () => { writeKnowledgeFile('page.md', '/page', 'value: ${config.playwright}'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/page'); expect(content[0]).toContain('value:'); @@ -132,7 +131,7 @@ describe('KnowledgeTracker', () => { mkdirSync(`${knowledgeDir}/subdir`, { recursive: true }); writeFileSync(`${knowledgeDir}/subdir/nested.md`, matter.stringify('Nested note', { url: '/nested-page' }), 'utf8'); - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); const content = tracker.getKnowledgeForUrl('/nested-page'); expect(content[0]).toContain('Nested note'); @@ -141,7 +140,7 @@ describe('KnowledgeTracker', () => { describe('addKnowledge cache invalidation', () => { it('sees knowledge added via addKnowledge on the same instance', () => { - const tracker = KnowledgeTracker.getInstance(); + const tracker = new KnowledgeTracker(); expect(tracker.getMatchingKnowledge('/login')).toHaveLength(0); tracker.addKnowledge('/login', 'Use admin credentials'); diff --git a/tests/unit/state-manager-events.test.ts b/tests/unit/state-manager-events.test.ts index 3cc3280..d4bfd51 100644 --- a/tests/unit/state-manager-events.test.ts +++ b/tests/unit/state-manager-events.test.ts @@ -3,6 +3,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { ActionResult } from '../../src/action-result.js'; import { ConfigParser } from '../../src/config.js'; +import { ExperienceTracker } from '../../src/experience-tracker.js'; import { KnowledgeTracker } from '../../src/knowledge-tracker.js'; import { StateManager } from '../../src/state-manager.js'; @@ -15,8 +16,8 @@ describe('StateManager Events', () => { // Set up test config ConfigParser.setupTestConfig(); - KnowledgeTracker.resetForTesting(); - stateManager = new StateManager(); + const knowledgeTracker = new KnowledgeTracker(); + stateManager = new StateManager(new ExperienceTracker(knowledgeTracker), knowledgeTracker); }); afterEach(() => { diff --git a/tests/unit/state-manager.test.ts b/tests/unit/state-manager.test.ts index 975d3d2..c956a2d 100644 --- a/tests/unit/state-manager.test.ts +++ b/tests/unit/state-manager.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import matter from 'gray-matter'; import { ActionResult } from '../../src/action-result'; import { ConfigParser } from '../../src/config'; +import { ExperienceTracker } from '../../src/experience-tracker'; import { KnowledgeTracker } from '../../src/knowledge-tracker'; import { StateManager, type StateTransition, type WebPageState } from '../../src/state-manager'; @@ -23,9 +24,9 @@ describe('StateManager', () => { const configParser = ConfigParser.getInstance(); (configParser as any).config = mockConfig; (configParser as any).configPath = '/tmp/explorbot-test/config.js'; - KnowledgeTracker.resetForTesting(); - stateManager = new StateManager(); + const knowledgeTracker = new KnowledgeTracker(); + stateManager = new StateManager(new ExperienceTracker(knowledgeTracker), knowledgeTracker); }); afterEach(() => { @@ -66,8 +67,8 @@ describe('StateManager', () => { writeFileSync(`${smKnowledgeDir}/page.md`, matter.stringify('Secret: ${env.SM_TEST_VAR}', { url: '/sm-page' }), 'utf8'); - KnowledgeTracker.resetForTesting(); - const sm = new StateManager(); + const knowledgeTracker = new KnowledgeTracker(); + const sm = new StateManager(new ExperienceTracker(knowledgeTracker), knowledgeTracker); sm.updateState(new ActionResult({ url: '/sm-page', html: 'x' })); const knowledge = sm.getRelevantKnowledge(); From 200f23836db1c8d0d2608ce29b56a1a2c583868b Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 12 Jul 2026 14:41:07 +0300 Subject: [PATCH 5/5] refactor: drop redundant experienceTracker param from Navigator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigator already holds `explorer`, and its only caller passed exactly `explorer.getStateManager().getExperienceTracker()` — so the optional param plus `|| fallback` was pure redundancy (and the same divergent-instance footgun). Get it from explorer directly, matching the knowledgeTracker line right above it. Co-Authored-By: Claude Opus 4.8 --- src/ai/navigator.ts | 4 ++-- src/explorbot.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ai/navigator.ts b/src/ai/navigator.ts index 1de5b12..f385cdb 100644 --- a/src/ai/navigator.ts +++ b/src/ai/navigator.ts @@ -71,11 +71,11 @@ class Navigator implements Agent { `; private explorer: Explorer; - constructor(explorer: Explorer, provider: Provider, experienceTracker?: ExperienceTracker) { + constructor(explorer: Explorer, provider: Provider) { this.provider = provider; this.explorer = explorer; this.knowledgeTracker = explorer.getKnowledgeTracker(); - this.experienceTracker = experienceTracker || explorer.getStateManager().getExperienceTracker(); + this.experienceTracker = explorer.getStateManager().getExperienceTracker(); this.hooksRunner = new HooksRunner(explorer, explorer.getConfig()); } diff --git a/src/explorbot.ts b/src/explorbot.ts index 551b2aa..7b2f32c 100644 --- a/src/explorbot.ts +++ b/src/explorbot.ts @@ -172,7 +172,7 @@ export class ExplorBot { agentNavigator(): Navigator { return (this.agents.navigator ||= this.createAgent(({ ai, explorer }) => { - return new Navigator(explorer, ai, explorer.getStateManager().getExperienceTracker()); + return new Navigator(explorer, ai); })); }