diff --git a/bin/explorbot-cli.ts b/bin/explorbot-cli.ts index 6da67d7..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 = new KnowledgeTracker(); 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 2c9782d..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 = new KnowledgeTracker(); + 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 6e0aac3..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 = new KnowledgeTracker(); + 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 793305e..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 = new KnowledgeTracker(); - this.experienceTracker = experienceTracker || new ExperienceTracker(); + this.knowledgeTracker = explorer.getKnowledgeTracker(); + this.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 5d0bdff..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 = new KnowledgeTracker(); const urls = knowledgeTracker.getExistingUrls(); setSuggestedUrls(urls); } catch (error) { console.error('Failed to load suggestions:', error); } - }, []); + }, [knowledgeTracker]); useEffect(() => { if (urlPattern.trim()) { try { - const knowledgeTracker = new KnowledgeTracker(); 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 = new KnowledgeTracker(); 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 4dbf8e6..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 = new KnowledgeTracker(); + 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 9f7e06a..7b2f32c 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 new KnowledgeTracker(); + 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 { @@ -176,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); })); } @@ -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 5cb7eed..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.stateManager = new StateManager({ incognito: this.options?.incognito }); - this.knowledgeTracker = new KnowledgeTracker(); + 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 d37ed9c..02a37c7 100644 --- a/src/knowledge-tracker.ts +++ b/src/knowledge-tracker.ts @@ -8,7 +8,7 @@ import { createDebug } from './utils/logger.js'; const debugLog = createDebug('explorbot:knowledge-tracker'); -interface Knowledge { +export interface Knowledge { filePath: string; url: string; content: string; @@ -46,9 +46,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 { @@ -127,6 +127,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 20dc1d7..908886b 100644 --- a/src/state-manager.ts +++ b/src/state-manager.ts @@ -1,9 +1,6 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import matter from 'gray-matter'; import { ActionResult } from './action-result.js'; -import { ConfigParser } 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 } from './utils/logger.js'; import { extractStatePath } from './utils/url-matcher.js'; @@ -69,43 +66,30 @@ 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() { - 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'; - } + 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 */ @@ -322,65 +306,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); } /** @@ -465,8 +398,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/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 6626096..4c0e43e 100644 --- a/tests/unit/knowledge-tracker.test.ts +++ b/tests/unit/knowledge-tracker.test.ts @@ -125,4 +125,29 @@ 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'); + }); + }); }); diff --git a/tests/unit/state-manager-events.test.ts b/tests/unit/state-manager-events.test.ts index a56439f..d4bfd51 100644 --- a/tests/unit/state-manager-events.test.ts +++ b/tests/unit/state-manager-events.test.ts @@ -3,6 +3,8 @@ 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'; describe('StateManager Events', () => { @@ -14,7 +16,8 @@ describe('StateManager Events', () => { // Set up test config ConfigParser.setupTestConfig(); - 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 fd3bce7..c956a2d 100644 --- a/tests/unit/state-manager.test.ts +++ b/tests/unit/state-manager.test.ts @@ -1,6 +1,10 @@ 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 { ExperienceTracker } from '../../src/experience-tracker'; +import { KnowledgeTracker } from '../../src/knowledge-tracker'; import { StateManager, type StateTransition, type WebPageState } from '../../src/state-manager'; describe('StateManager', () => { @@ -21,7 +25,8 @@ describe('StateManager', () => { (configParser as any).config = mockConfig; (configParser as any).configPath = '/tmp/explorbot-test/config.js'; - stateManager = new StateManager(); + const knowledgeTracker = new KnowledgeTracker(); + stateManager = new StateManager(new ExperienceTracker(knowledgeTracker), knowledgeTracker); }); afterEach(() => { @@ -42,6 +47,36 @@ 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 knowledgeTracker = new KnowledgeTracker(); + const sm = new StateManager(new ExperienceTracker(knowledgeTracker), knowledgeTracker); + 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({