diff --git a/README.md b/README.md index 972511f..7955dc4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ HTTP daemon and CLI architecture for agent-driven browser extension testing with This package provides the core infrastructure for enabling LLM agents to interact with browser extensions through Playwright. It ships a persistent HTTP daemon that manages browser lifecycle and a unified `mm` CLI that agents (and developers) use to drive sessions. -The design is **consumer-agnostic**: the core handles protocol, tooling, and knowledge — consumers provide extension-specific logic by implementing the `ISessionManager` interface and injecting capabilities. +The design is **consumer-agnostic**: the core handles protocol and tooling — consumers provide extension-specific logic by implementing the `ISessionManager` interface and injecting capabilities. ``` ┌─────────────────────────────────┐ @@ -23,11 +23,11 @@ The design is **consumer-agnostic**: the core handles protocol, tooling, and kno ┌───────────────────────────────────────────────────────────────────┐ │ HTTP Daemon (createServer) │ │ │ - │ ┌──────────┐ ┌───────────────┐ ┌────────────┐ ┌────────────┐ │ - │ │ Routes │ │ RequestQueue │ │ Tool │ │ Knowledge │ │ - │ │ /health │ │ (async mutex) │ │ Registry │ │ Store │ │ - │ │ /status │ │ │ │ 47 tools │ │ │ │ - │ │ /launch │ └───────────────┘ └─────┬──────┘ └────────────┘ │ + │ ┌──────────┐ ┌───────────────┐ ┌────────────┐ │ + │ │ Routes │ │ RequestQueue │ │ Tool │ │ + │ │ /health │ │ (async mutex) │ │ Registry │ │ + │ │ /status │ │ │ │ 43 tools │ │ + │ │ /launch │ └───────────────┘ └─────┬──────┘ │ │ │ /cleanup │ │ │ │ │ /tool/:n │ ▼ │ │ └──────────┘ ┌──────────────────┐ │ @@ -35,7 +35,6 @@ The design is **consumer-agnostic**: the core handles protocol, tooling, and kno │ │ sessionManager │ │ │ │ page / refMap │ │ │ │ workflowContext │ │ - │ │ knowledgeStore │ │ │ └────────┬─────────┘ │ └──────────────────────────────────────┼────────────────────────────┘ │ @@ -366,7 +365,6 @@ type ToolContext = { get page(): Page; // lazy — throws if no session get refMap(): Map; // lazy — returns empty map if no session workflowContext: WorkflowContext; - knowledgeStore: KnowledgeStore; toolRegistry: Map>; }; ``` @@ -379,7 +377,7 @@ The daemon routes `POST /tool/:name` requests through the registry, applies Zod | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Lifecycle** | | | `build` | Triggers an extension build using the configured `BuildCapability`. Accepts build type and force options. | -| `launch` | Launches a new browser session with the configured extension. Supports state modes (`default`, `onboarding`, `custom`), fixture presets, goal/tag metadata, and optional contract seeding on start. | +| `launch` | Launches a new browser session with the configured extension. Supports state modes (`default`, `onboarding`, `custom`), fixture presets, tag metadata, and optional contract seeding on start. | | `cleanup` | Tears down the active browser session and cleans up all resources (browser, services, fixtures). | | **Interaction** | | | `click` | Clicks an element identified by a11y ref, test ID, or CSS selector. Waits for the element to be visible before clicking. Supports `within` to scope the target inside a parent element. | @@ -402,11 +400,6 @@ The daemon routes `POST /tool/:name` requests through the registry, applies Zod | `set_context` | Switches the session environment between `e2e` and `prod` modes. Blocked while a session is active. | | **Screenshots** | | | `screenshot` | Captures a screenshot of the current page. Supports naming, full-page capture, scoping to a CSS selector, and optional base64 output. | -| **Knowledge** | | -| `knowledge_last` | Retrieves the N most recent step records from the knowledge store, with optional scope and filter parameters. | -| `knowledge_search` | Searches step records by query string with token-based matching and synonym expansion. Scores results by relevance to screen, URL, test IDs, and a11y nodes. | -| `knowledge_summarize` | Generates a recipe-style summary of a session's tool invocations, showing the step sequence with targets and outcomes. | -| `knowledge_sessions` | Lists available knowledge sessions with metadata (goal, flow tags, timestamps), with optional filtering. | | **Contracts** | | | `seed_contract` | Deploys a single smart contract to the local Anvil chain by name. Requires `ContractSeedingCapability`. | | `seed_contracts` | Deploys multiple smart contracts in sequence. Returns both successful deployments and individual failures. | @@ -450,16 +443,9 @@ mm type e3 "hello" → types into the element mapped to e3 This accessibility-first approach provides reliable element targeting that survives minor UI changes. -### Knowledge Store +### Session Metadata -The `KnowledgeStore` provides cross-session learning by recording every tool execution as a structured step record: - -- **Step Recording**: Each tool invocation captures the tool name, input, outcome, observation (extension state, visible test IDs, a11y nodes), and timing. -- **Session Metadata**: Sessions are tagged with goals, flow tags, and free-form tags for filtering. -- **Search**: Token-based search with synonym expansion across sessions, scored by relevance to screen, URL, test IDs, and a11y nodes. -- **Sensitive Data Handling**: Input text for password fields and other sensitive inputs is automatically redacted. Recorded `clipboard` and `device_clipboard` text and all CDP `params` are replaced with `[REDACTED]`, including within `run_steps`. Echoes in the same step's error messages/details, tool targets, observations, and artifact paths are also scrubbed. Raw input strings of at least eight characters match substrings; shorter strings match whole values only. JSON-escaped echoes use the original raw string length to select the matching policy. Structural step metadata and diagnostic error codes are preserved. Tools and HTTP responses retain the original values. - -Knowledge artifacts are stored on disk at `test-artifacts/llm-knowledge/` organized by session ID. +Consumers can persist launch metadata with `writeSessionMetadata(artifactsDir, metadata)`. It writes `//session.json` (for example `test-artifacts//session.json` with `artifactsDir: 'test-artifacts'`) and returns the file path. ### Environment Modes @@ -499,8 +485,6 @@ type ServerConfig = { sessionManager: ISessionManager; /** Factory function to create workflow context (may be sync or async) */ contextFactory: () => WorkflowContext | Promise; - /** Shared knowledge store instance (optional — a new instance is created if omitted) */ - knowledgeStore?: KnowledgeStore; /** Idle timeout in milliseconds (optional, defaults to 1_800_000 = 30 min) */ idleShutdownMs?: number; /** Per-request execution timeout in milliseconds (default: 30_000) */ @@ -581,7 +565,7 @@ mm describe-screen | Command | Description | | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `mm launch [--context e2e\|prod] [--state default\|onboarding\|custom] [--extension-path ] [--goal ] [--force] [--flow-tags ]` | Auto-starts the daemon if needed, then launches a headed Chrome session with the configured extension. Use `--context` to set the environment context before launching. Use `--state` to control wallet initialization. Use `--extension-path` to override the extension directory. Use `--goal` and `--flow-tags` for knowledge tagging. Use `--force` to replace an existing session. | +| `mm launch [--context e2e\|prod] [--state default\|onboarding\|custom] [--extension-path ] [--force]` | Auto-starts the daemon if needed, then launches a headed Chrome session with the configured extension. Use `--context` to set the environment context before launching. Use `--state` to control wallet initialization. Use `--extension-path` to override the extension directory. Use `--force` to replace an existing session. | | `mm launch --platform ios\|android [--device-id ] [--app-bundle ] [--metro-port ] [--reinstall] [--reset-app-data] [--allow-fox-code-mismatch]` | Launches a mobile session. Use `--device-id` to target a specific device (auto-detected when exactly one is connected). Use `--app-bundle` to install a specific app artifact before launching. Use `--metro-port` to attach to a running Metro bundler (development builds). `--reinstall` and `--reset-app-data` are **destructive** to the app container and wallet state; `--allow-fox-code-mismatch` bypasses the consumer's app-identity guard. Consumers decide how to honor these — see [Mobile launch options](#mobile-launch-options). | | `mm cleanup [--shutdown]` | Stops the browser, tears down test services (fixture server, Anvil, mock server), and releases session resources. Add `--shutdown` to also terminate the daemon process. | | `mm stop [--force]` | Stops the daemon process (symmetric to `mm serve`). Sends a best-effort cleanup before shutdown. Use `--force` to remove stale `.mm-server` state from crashed daemons. | @@ -619,15 +603,6 @@ mm describe-screen | `mm get-context` | Returns the current environment context (`e2e` or `prod`), session status, available capabilities, and whether context switching is allowed. | | `mm set-context ` | Switches the session environment between `e2e` and `prod` modes. Blocked while a session is active — run `mm cleanup` first. | -### Knowledge - -| Command | Description | -| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mm knowledge-search ` | Searches the knowledge store for past tool invocations matching the query. Results are scored by relevance to screen, URL, test IDs, and a11y nodes. | -| `mm knowledge-last` | Retrieves the most recent step records from the current session's knowledge store. | -| `mm knowledge-sessions` | Lists recent knowledge sessions with metadata (goal, flow tags, timestamps). | -| `mm knowledge-summarize [--session ]` | Generates a recipe-style summary of a session's tool invocations, showing the step sequence with targets and outcomes. | - ### Batching | Command | Description | @@ -793,8 +768,6 @@ Tool errors are classified into specific error codes for structured handling: | `MM_DISCOVERY_FAILED` | Discovery tool failure | | `MM_SCREENSHOT_FAILED` | Screenshot capture failure | | `MM_STATE_FAILED` | State retrieval failed | -| **Knowledge** | | -| `MM_KNOWLEDGE_ERROR` | Knowledge store operation failed | | **Contracts** | | | `MM_CONTRACT_NOT_FOUND` | Unknown contract name | | `MM_SEED_FAILED` | Contract deployment failure | diff --git a/SKILL.md b/SKILL.md index b6c3061..b70f170 100644 --- a/SKILL.md +++ b/SKILL.md @@ -28,7 +28,7 @@ Tool responses include different data based on the tool's category: | Category | Examples | Observations in response? | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | **Mutating** | click, type, navigate, launch, cleanup, build, clipboard, cdp, mock_network, scroll_to_element, device_swipe, long_press, tap_coordinates, dismiss_keyboard, dismiss_alert, open_app, close_app, press_button, device_context, device_clipboard | Yes — `state` + `a11y` (compacted) + `testIds` | -| **Read-only** | get_state, get_text, knowledge\_\*, get_context, set_context, get_alert_text, screen_recording, device_logs, generate_locators, hermes_targets | No — faster response | +| **Read-only** | get_state, get_text, get_context, set_context, get_alert_text, screen_recording, device_logs, generate_locators, hermes_targets | No — faster response | | **Discovery** | describe_screen, list_testids, accessibility_snapshot, screenshot | Data is already in `result` | | **Batch** | run_steps | Controlled by `includeObservations` param | @@ -116,7 +116,7 @@ The `run_steps` tool collects observations once after all steps complete. Contro Starts the daemon (if not running) and launches a headed Chrome session with the extension. ``` -mm launch [--context e2e|prod] [--state default|onboarding|custom] [--extension-path ] [--goal ] [--force] [--flow-tags ] +mm launch [--context e2e|prod] [--state default|onboarding|custom] [--extension-path ] [--force] ``` | Flag | Description | @@ -126,9 +126,7 @@ mm launch [--context e2e|prod] [--state default|onboarding|custom] [--extension- | `--state onboarding` | Fresh wallet requiring manual onboarding setup | | `--state custom` | Use a custom fixture for wallet state | | `--extension-path ` | Override the extension build directory | -| `--goal ` | Tag the session with a goal for knowledge store | | `--force` | Replace an existing active session | -| `--flow-tags ` | Comma-separated flow tags for cross-session knowledge | Returns: `sessionId`, `extensionId`, `state` (current extension state). @@ -399,42 +397,6 @@ Switches the session environment between `e2e` and `prod` modes. Blocked while a mm set-context ``` -### Knowledge Store - -The knowledge store records every tool invocation and uses past sessions to suggest actions. - -#### `mm knowledge-search ` - -Searches past sessions for steps matching the query. Matches against tool names, screen names, test IDs, and a11y node names. - -``` -mm knowledge-search "confirm transaction" -``` - -#### `mm knowledge-last` - -Gets the most recent step records from the current session. - -``` -mm knowledge-last -``` - -#### `mm knowledge-sessions` - -Lists recent sessions with metadata (goal, flow tags, timestamps). - -``` -mm knowledge-sessions -``` - -#### `mm knowledge-summarize` - -Generates a recipe-style summary of a session's tool invocations, showing the step sequence with targets and outcomes. - -``` -mm knowledge-summarize [--session ] -``` - ### Contracts (E2E only) #### `mm seed-contract ` @@ -710,7 +672,6 @@ When a command fails, the response includes `error.code`. Use this to decide wha | `MM_DISCOVERY_FAILED` | Discovery tool failure | Page may be loading; wait and retry | | `MM_SCREENSHOT_FAILED` | Screenshot capture failure | Page may be in unstable state; retry after describe-screen | | `MM_STATE_FAILED` | State retrieval failed | Session may be unstable; run `mm describe-screen` | -| `MM_KNOWLEDGE_ERROR` | Knowledge store operation failed | Retry; check that session exists | | `MM_CONTRACT_NOT_FOUND` | Unknown contract name for seeding | See available contracts below | | `MM_SEED_FAILED` | Contract deployment failure | Check Anvil chain is running; verify contract name | | `MM_CAPABILITY_NOT_AVAILABLE` | Feature requires a capability not configured | Check environment mode (e2e vs prod) | @@ -746,20 +707,6 @@ These contracts can be deployed to the local Anvil chain via `seed_contract` / ` | `simpleAccountFactory` | ERC-4337 account factory | | `verifyingPaymaster` | ERC-4337 paymaster | -## Flow Tags - -When launching, tag your session with flow tags for cross-session knowledge: - -| Tag | Use for | -| ----------------- | ------------------------------ | -| `send` | Token send flows | -| `swap` | Token swap flows | -| `connect` | dApp connection flows | -| `sign` | Message/transaction signing | -| `onboarding` | Wallet setup/onboarding | -| `settings` | Settings configuration | -| `tx-confirmation` | Transaction confirmation flows | - ## Daemon Model - Daemon runs per project, state tracked in `.mm-server` at the project root diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index 9733fa6..2252cc0 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -570,25 +570,19 @@ describe('parseLaunchArgs', () => { }); }); - it('parses --goal value', () => { - expect(parseLaunchArgs(['--goal', 'test swap'])).toStrictEqual({ - goal: 'test swap', - }); - }); - - it('parses --flow-tags as comma-separated array', () => { - expect(parseLaunchArgs(['--flow-tags', 'send, swap'])).toStrictEqual({ - flowTags: ['send', 'swap'], - }); - }); - it('parses multiple flags together', () => { expect( - parseLaunchArgs(['--state', 'default', '--force', '--goal', 'test it']), + parseLaunchArgs([ + '--state', + 'default', + '--force', + '--extension-path', + '/ext', + ]), ).toStrictEqual({ stateMode: 'default', force: true, - goal: 'test it', + extensionPath: '/ext', }); }); @@ -614,18 +608,6 @@ describe('parseLaunchArgs', () => { ); }); - it('exits for --goal without value', () => { - expect(() => parseLaunchArgs(['--goal'])).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith('Error: --goal requires a value\n'); - }); - - it('exits for --flow-tags without value', () => { - expect(() => parseLaunchArgs(['--flow-tags'])).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - 'Error: --flow-tags requires a comma-separated value\n', - ); - }); - it('parses --context value', () => { expect(parseLaunchArgs(['--context', 'prod'])).toStrictEqual({ context: 'prod', @@ -781,6 +763,18 @@ describe('parseLaunchArgs', () => { "Warning: unknown launch flag '--unknown'\n", ); }); + + it.each(['--goal', '--flow-tags'])( + 'treats removed %s flag as unknown', + (flag) => { + const result = parseLaunchArgs([flag, 'x']); + expect(stderrSpy).toHaveBeenCalledWith( + `Warning: unknown launch flag '${flag}'\n`, + ); + expect(result).not.toHaveProperty('goal'); + expect(result).not.toHaveProperty('flowTags'); + }, + ); }); describe('printHelp', () => { @@ -2045,56 +2039,6 @@ describe('routeCommand', () => { ); }); - it('routes knowledge-search', async () => { - await routeCommand('knowledge-search', ['send flow'], 3000); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:3000/tool/knowledge_search', - expect.objectContaining({ - body: JSON.stringify({ query: 'send flow' }), - }), - ); - }); - - it('exits when knowledge-search has no query', async () => { - await expect( - routeCommand('knowledge-search', [], 3000), - ).rejects.toThrowError('process.exit'); - }); - - it('routes knowledge-last', async () => { - await routeCommand('knowledge-last', [], 3000); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:3000/tool/knowledge_last', - expect.anything(), - ); - }); - - it('routes knowledge-sessions', async () => { - await routeCommand('knowledge-sessions', [], 3000); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:3000/tool/knowledge_sessions', - expect.anything(), - ); - }); - - it('routes knowledge-summarize', async () => { - await routeCommand('knowledge-summarize', [], 3000); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:3000/tool/knowledge_summarize', - expect.objectContaining({ body: JSON.stringify({}) }), - ); - }); - - it('routes knowledge-summarize with --session', async () => { - await routeCommand('knowledge-summarize', ['--session', 'sid'], 3000); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:3000/tool/knowledge_summarize', - expect.objectContaining({ - body: JSON.stringify({ scope: { sessionId: 'sid' } }), - }), - ); - }); - it('routes run-steps with JSON input', async () => { const input = JSON.stringify({ steps: [{ tool: 'click', args: { a11yRef: 'e1' } }], @@ -2716,6 +2660,21 @@ describe('routeCommand', () => { expect.stringContaining("unknown command 'unknown-cmd'"), ); }); + + it.each([ + 'knowledge-last', + 'knowledge-search', + 'knowledge-sessions', + 'knowledge-summarize', + ])('exits for removed %s command', async (command) => { + await expect(routeCommand(command, [], 3000)).rejects.toThrowError( + 'process.exit', + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining(`unknown command '${command}'`), + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); }); describe('discoverDaemon', () => { diff --git a/src/cli/mm.ts b/src/cli/mm.ts index 97e0e35..96ffc0e 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -618,28 +618,6 @@ export async function routeCommand( }); break; } - case 'knowledge-search': - if (!args[0]) { - process.stderr.write('Usage: mm knowledge-search \n'); - process.exit(1); - } - await sendRequest(port, 'POST', '/tool/knowledge_search', { - query: args[0], - }); - break; - case 'knowledge-last': - await sendRequest(port, 'POST', '/tool/knowledge_last', {}); - break; - case 'knowledge-sessions': - await sendRequest(port, 'POST', '/tool/knowledge_sessions', {}); - break; - case 'knowledge-summarize': { - const summarizeSession = parseStringFlag(args, '--session'); - await sendRequest(port, 'POST', '/tool/knowledge_summarize', { - ...(summarizeSession ? { scope: { sessionId: summarizeSession } } : {}), - }); - break; - } case 'run-steps': if (!args[0]) { process.stderr.write( @@ -1637,22 +1615,6 @@ export async function readSelfOnboardingInput( */ export function parseLaunchArgs(args: string[]): Record { const result: Record = {}; - const knownFlags = new Set([ - '--context', - '--state', - '--extension-path', - '--goal', - '--force', - '--flow-tags', - '--platform', - '--device-id', - '--app-bundle', - '--metro-port', - '--reinstall', - '--reset-app-data', - '--allow-fox-code-mismatch', - '--self-onboarding', - ]); for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -1681,22 +1643,6 @@ export function parseLaunchArgs(args: string[]): Record { process.exit(1); } result.extensionPath = args[i]; - } else if (arg === '--goal') { - i += 1; - if (!args[i] || args[i].startsWith('--')) { - process.stderr.write('Error: --goal requires a value\n'); - process.exit(1); - } - result.goal = args[i]; - } else if (arg === '--flow-tags') { - i += 1; - if (!args[i] || args[i].startsWith('--')) { - process.stderr.write( - 'Error: --flow-tags requires a comma-separated value\n', - ); - process.exit(1); - } - result.flowTags = args[i].split(',').map((tag) => tag.trim()); } else if (arg === '--platform') { i += 1; if (!args[i] || args[i].startsWith('--')) { @@ -1745,7 +1691,7 @@ export function parseLaunchArgs(args: string[]): Record { result.allowFoxCodeMismatch = true; } else if (arg === '--self-onboarding') { result.selfOnboarding = true; - } else if (arg.startsWith('--') && !knownFlags.has(arg)) { + } else if (arg.startsWith('--')) { process.stderr.write(`Warning: unknown launch flag '${arg}'\n`); } } @@ -1769,7 +1715,7 @@ Environment Variables: Falls back to the current git worktree root. Lifecycle: - mm launch [--context e2e|prod] [--state default|onboarding|custom] [--self-onboarding] [--extension-path ] [--goal ] [--force] [--flow-tags ] [--platform browser|ios|android] [--device-id ] + mm launch [--context e2e|prod] [--state default|onboarding|custom] [--self-onboarding] [--extension-path ] [--force] [--platform browser|ios|android] [--device-id ] mm launch --platform ios|android [--device-id ] [--app-bundle ] [--metro-port ] [--reinstall] [--reset-app-data] [--allow-fox-code-mismatch] mm launch --self-onboarding reads the secret recovery phrase from stdin, and an optional vault password from the next line. @@ -1805,12 +1751,6 @@ State & Context: mm get-context mm set-context -Knowledge: - mm knowledge-search - mm knowledge-last - mm knowledge-sessions - mm knowledge-summarize [--session ] - Contracts (E2E only): mm seed-contract [--hardfork ] mm seed-contracts ... [--hardfork ] diff --git a/src/index.ts b/src/index.ts index a46bb05..3699cbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,10 +23,8 @@ export type { } from './server/session-manager.js'; // Core Components -export * from './knowledge-store/knowledge-store.js'; export * from './tools/utils/discovery.js'; export * from './validation/schemas.js'; -export * from './knowledge-store/tokenization.js'; // Types export * from './tools/types'; @@ -41,6 +39,7 @@ export * from './server/request-queue.js'; export * from './server/port-allocator.js'; export * from './server/daemon-state.js'; export * from './server/create-server.js'; +export * from './server/session-metadata.js'; // Utils export * from './utils'; diff --git a/src/knowledge-store/knowledge-store.test.ts b/src/knowledge-store/knowledge-store.test.ts deleted file mode 100644 index 7e29076..0000000 --- a/src/knowledge-store/knowledge-store.test.ts +++ /dev/null @@ -1,3221 +0,0 @@ -/** - * Unit tests for KnowledgeStore core operations (Part 1). - * Part 1 focuses on lines 1-500: initialization, recordStep, session lifecycle. - */ - -import { promises as fs } from 'fs'; -import * as path from 'path'; -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; - -import { - KnowledgeStore, - createDefaultObservation, - createKnowledgeStore, - setKnowledgeStore, - hasKnowledgeStore, - knowledgeStore, -} from './knowledge-store.js'; -import type { KnowledgeStoreConfig } from './knowledge-store.js'; -import type { ExtensionState } from '../capabilities/types.js'; -import type { - SessionMetadata, - StepRecordOutcome, - StepRecordObservation, -} from '../tools/types'; - -vi.mock('fs', () => ({ - existsSync: vi.fn(), - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - readFileSync: vi.fn(), - readdirSync: vi.fn(), - statSync: vi.fn(), - promises: { - mkdir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockResolvedValue('{}'), - readdir: vi.fn().mockResolvedValue([]), - stat: vi.fn().mockResolvedValue({ isDirectory: () => true }), - }, -})); - -function createObservation( - overrides: Partial = {}, -): StepRecordObservation { - return { - state: { - isLoaded: true, - currentUrl: 'chrome-extension://test/home.html', - extensionId: 'test-ext', - isUnlocked: true, - currentScreen: 'home', - accountAddress: '0x1234', - networkName: 'Localhost 8545', - chainId: 1337, - balance: '25 ETH', - ...overrides, - }, - testIds: [], - a11y: { nodes: [] }, - }; -} - -describe('core', () => { - let originalCwd: () => string; - - beforeEach(() => { - vi.clearAllMocks(); - originalCwd = process.cwd; - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - }); - - afterEach(() => { - process.cwd = originalCwd; - vi.restoreAllMocks(); - }); - - describe('KnowledgeStore initialization', () => { - it('uses default configuration when no config provided', () => { - const store = new KnowledgeStore(); - - expect(store).toBeDefined(); - }); - - it('accepts custom rootDir configuration', () => { - const config: KnowledgeStoreConfig = { - rootDir: '/custom/knowledge/root', - }; - - const store = new KnowledgeStore(config); - - expect(store).toBeDefined(); - }); - - it('accepts custom sessionIdPrefix configuration', () => { - const config: KnowledgeStoreConfig = { - sessionIdPrefix: 'custom-', - }; - - const store = new KnowledgeStore(config); - - expect(store).toBeDefined(); - }); - - it('accepts full configuration object', () => { - const config: KnowledgeStoreConfig = { - rootDir: '/custom/root', - sessionIdPrefix: 'test-', - }; - - const store = new KnowledgeStore(config); - - expect(store).toBeDefined(); - }); - }); - - describe('writeSessionMetadata', () => { - it('creates session directory and writes metadata file', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'session-001', - createdAt: '2024-01-15T10:30:00.000Z', - flowTags: ['send'], - tags: ['test'], - launch: { stateMode: 'default' }, - }; - - const result = await store.writeSessionMetadata(metadata); - - expect(fs.mkdir).toHaveBeenCalledWith( - path.join('/test/knowledge', 'session-001'), - { recursive: true }, - ); - expect(fs.writeFile).toHaveBeenCalledWith( - path.join('/test/knowledge', 'session-001', 'session.json'), - JSON.stringify(metadata, null, 2), - ); - expect(result).toBe( - path.join('/test/knowledge', 'session-001', 'session.json'), - ); - }); - - it('includes optional goal in metadata', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'session-003', - createdAt: '2024-01-15T10:30:00.000Z', - goal: 'Test send flow', - flowTags: ['send'], - tags: [], - launch: { stateMode: 'default' }, - }; - - await store.writeSessionMetadata(metadata); - - expect(fs.writeFile).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('"goal": "Test send flow"'), - ); - }); - }); - - describe('exported helpers', () => { - it('createDefaultObservation returns base observation with defaults', () => { - const state: ExtensionState = { - isLoaded: true, - currentUrl: 'chrome-extension://test/home.html', - extensionId: 'test-ext', - isUnlocked: true, - currentScreen: 'home', - accountAddress: '0x1234', - networkName: 'Localhost 8545', - chainId: 1337, - balance: '25 ETH', - }; - - const observation = createDefaultObservation(state); - - expect(observation).toStrictEqual({ - state, - testIds: [], - a11y: { nodes: [] }, - }); - }); - - it('createKnowledgeStore returns a KnowledgeStore instance', () => { - const store = createKnowledgeStore({ rootDir: '/test/knowledge' }); - - expect(store).toBeInstanceOf(KnowledgeStore); - }); - }); - - describe('recordStep', () => { - it.each([ - { sessionId: 'session-step-001', toolName: 'click' }, - { - sessionId: 'custom_prefix-20261015-123456-001', - toolName: 'device-clipboard', - }, - { sessionId: 'custom.session_001', toolName: 'get_state' }, - ])( - 'creates steps directory and writes step file for $toolName', - async ({ sessionId, toolName }) => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation(); - - const result = await store.recordStep({ - sessionId, - toolName, - input: { testId: 'send-button' }, - outcome, - observation, - durationMs: 150, - }); - - expect(fs.mkdir).toHaveBeenCalledWith( - path.join('/test/knowledge', sessionId, 'steps'), - { recursive: true }, - ); - expect(fs.writeFile).toHaveBeenCalledWith(result, expect.any(String)); - expect(path.dirname(result)).toBe( - path.join('/test/knowledge', sessionId, 'steps'), - ); - expect(path.basename(result)).toMatch(/^\d{8}-\d{6}-\d{3}-/u); - expect(result).toContain(`-${toolName}.json`); - }, - ); - - it.each([ - '', - '.', - '..', - '../escape', - 'nested/name', - '/absolute', - '..\\escape', - 'C:\\escape', - 'name\0suffix', - 'name\n', - ])( - 'rejects unsafe path component %j before filesystem I/O', - async (value) => { - const store = new KnowledgeStore({ rootDir: '/test/knowledge' }); - - for (const name of ['sessionId', 'toolName'] as const) { - await expect( - store.recordStep({ - sessionId: 'mm-session', - toolName: 'click', - [name]: value, - outcome: { ok: true }, - observation: createObservation(), - }), - ).rejects.toThrowError( - `Invalid ${name}: expected a safe filename component`, - ); - } - - expect(fs.mkdir).not.toHaveBeenCalled(); - expect(fs.writeFile).not.toHaveBeenCalled(); - }, - ); - - it('records step with screenshot artifact', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation({ currentScreen: 'send' }); - - await store.recordStep({ - sessionId: 'session-step-002', - toolName: 'screenshot', - outcome, - observation, - screenshotPath: '/test/screenshots/screenshot-001.png', - screenshotDimensions: { width: 1280, height: 720 }, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.artifacts).toStrictEqual({ - screenshot: { - path: '/test/screenshots/screenshot-001.png', - width: 1280, - height: 720, - }, - }); - }); - - it('sanitizes sensitive input fields', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation({ - currentScreen: 'unlock', - isUnlocked: false, - }); - - await store.recordStep({ - sessionId: 'session-step-003', - toolName: 'type', - input: { testId: 'password-input', text: 'my-secret-password' }, - outcome, - observation, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.tool.textRedacted).toBe(true); - expect(writtenData.tool.textLength).toBe(18); - expect(writtenData.tool.input.text).toBe('[REDACTED]'); - }); - - it('preserves ordinary text and redacts sensitive echoes without mutating supplied data', async () => { - const store = new KnowledgeStore({ rootDir: '/test/knowledge' }); - const params = { - sessionId: 'mm-synthetic', - toolName: 'type', - input: { - testId: 'recipient', - text: '0x1234', - apiKey: 'synthetic-api-key', - }, - outcome: { - ok: false, - error: { code: 'EXAMPLE', message: 'echo synthetic-api-key' }, - }, - observation: createObservation(), - }; - const original = structuredClone(params); - await store.recordStep(params); - - const written = JSON.parse( - vi.mocked(fs.writeFile).mock.calls[0][1] as string, - ); - expect(written.tool.input).toStrictEqual({ - testId: 'recipient', - text: '0x1234', - apiKey: '[REDACTED]', - }); - expect(written.outcome.error.message).toBe('echo [REDACTED]'); - expect(params).toStrictEqual(original); - }); - - it('sanitizes sensitive fields inside run_steps steps', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const srp = Array.from({ length: 12 }, () => 'abandon').join(' '); - - await store.recordStep({ - sessionId: 'session-step-003b', - toolName: 'run_steps', - input: { - steps: [ - { tool: 'launch', args: { selfOnboarding: true, srp } }, - { - tool: 'type', - args: { testId: 'password-input', text: 'my-secret-password' }, - }, - { tool: 'click', args: { testId: 'confirm-btn' } }, - { tool: 'get_state' }, - ], - }, - outcome: { ok: true }, - observation: createObservation(), - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const written = writeCall[1] as string; - const writtenData = JSON.parse(written); - - expect(writtenData.tool.input.steps).toStrictEqual([ - { tool: 'launch', args: { selfOnboarding: true, srp: '[REDACTED]' } }, - { - tool: 'type', - args: { testId: 'password-input', text: '[REDACTED]' }, - }, - { tool: 'click', args: { testId: 'confirm-btn' } }, - { tool: 'get_state' }, - ]); - expect(writtenData.tool.textRedacted).toBe(true); - expect(written).not.toContain('abandon'); - expect(written).not.toContain('my-secret-password'); - }); - - it('preserves malformed batch entries while redacting valid inputs and echoes', async () => { - const store = new KnowledgeStore({ rootDir: '/test/knowledge' }); - const unchanged = [ - null, - 'invalid', - { tool: 2, args: {} }, - { tool: 'get_state' }, - ]; - const args = { - method: 'Input.insertText', - params: { text: 'synthetic-password' }, - }; - const params = { - sessionId: 'mm-synthetic', - toolName: 'run_steps', - input: { steps: [...unchanged, { tool: 'cdp', args }] }, - outcome: { - ok: false, - error: { code: 'EXAMPLE', message: 'echo synthetic-password' }, - }, - observation: createObservation(), - }; - const original = structuredClone(params); - await store.recordStep(params); - - const written = JSON.parse( - vi.mocked(fs.writeFile).mock.calls[0][1] as string, - ); - expect(written.tool.input.steps).toStrictEqual([ - ...unchanged, - { - tool: 'cdp', - args: { method: 'Input.insertText', params: '[REDACTED]' }, - }, - ]); - expect(written.outcome.error.message).toBe('echo [REDACTED]'); - expect(params).toStrictEqual(original); - }); - - it('records step with target information', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation(); - - await store.recordStep({ - sessionId: 'session-step-004', - toolName: 'click', - input: { testId: 'confirm-btn' }, - target: { - testId: 'confirm-btn', - selector: '[data-testid="confirm-btn"]', - }, - outcome, - observation, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.tool.target).toStrictEqual({ - testId: 'confirm-btn', - selector: '[data-testid="confirm-btn"]', - }); - }); - - it('computes discovery label for discovery tools', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation(); - - await store.recordStep({ - sessionId: 'session-step-005', - toolName: 'describe_screen', - outcome, - observation, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.labels).toContain('discovery'); - }); - - it('computes navigation label for navigation tools', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation({ currentScreen: 'settings' }); - - await store.recordStep({ - sessionId: 'session-step-006', - toolName: 'navigate', - outcome, - observation, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.labels).toContain('navigation'); - }); - - it('computes interaction label for interaction tools', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation(); - - await store.recordStep({ - sessionId: 'session-step-007', - toolName: 'click', - input: { testId: 'send-button' }, - outcome, - observation, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.labels).toContain('interaction'); - }); - - it('computes confirmation label for confirmation-related targets', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation({ - currentScreen: 'confirm-transaction', - }); - - await store.recordStep({ - sessionId: 'session-step-008', - toolName: 'click', - target: { testId: 'confirm-transaction-btn' }, - outcome, - observation, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.labels).toContain('confirmation'); - }); - - it('computes error-recovery label for failed outcomes', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { - ok: false, - error: { code: 'MM_TARGET_NOT_FOUND', message: 'Target not found' }, - }; - const observation = createObservation(); - - await store.recordStep({ - sessionId: 'session-step-009', - toolName: 'click', - input: { testId: 'nonexistent-btn' }, - outcome, - observation, - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.labels).toContain('error-recovery'); - }); - - it('records step with e2e context', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation(); - - await store.recordStep({ - sessionId: 'session-step-011', - toolName: 'click', - outcome, - observation, - context: 'e2e', - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.context).toBe('e2e'); - }); - - it('records step with prod context', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation(); - - await store.recordStep({ - sessionId: 'session-step-012', - toolName: 'click', - outcome, - observation, - context: 'prod', - }); - - const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; - const writtenData = JSON.parse(writeCall[1] as string); - - expect(writtenData.context).toBe('prod'); - }); - }); - - describe('listSessions', () => { - function createDirent(name: string, isDir = true) { - return { name, isDirectory: () => isDir }; - } - - it('returns empty array when no sessions exist', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - vi.mocked(fs.readdir).mockResolvedValueOnce([]); - - const result = await store.listSessions(10); - - expect(result).toStrictEqual([]); - }); - - it('returns sessions sorted by createdAt descending', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const oldMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-old', - createdAt: '2024-01-10T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const newMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-new', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-old'), - createDirent('mm-session-new'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(oldMetadata)) - .mockResolvedValueOnce(JSON.stringify(newMetadata)); - - const result = await store.listSessions(10); - - expect(result[0].sessionId).toBe('mm-session-new'); - expect(result[1].sessionId).toBe('mm-session-old'); - }); - - it('limits results to specified count', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - createDirent('mm-session-2'), - createDirent('mm-session-3'), - ] as any); - - const metadata = (id: string, date: string): SessionMetadata => ({ - schemaVersion: 1, - sessionId: id, - createdAt: date, - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify(metadata('mm-session-1', '2024-01-01T10:00:00.000Z')), - ) - .mockResolvedValueOnce( - JSON.stringify(metadata('mm-session-2', '2024-01-02T10:00:00.000Z')), - ) - .mockResolvedValueOnce( - JSON.stringify(metadata('mm-session-3', '2024-01-03T10:00:00.000Z')), - ); - - const result = await store.listSessions(2); - - expect(result).toHaveLength(2); - }); - - it('filters by flowTag', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const sendMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-send', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: ['send'], - tags: [], - launch: { stateMode: 'default' }, - }; - const swapMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-swap', - createdAt: '2024-01-14T10:00:00.000Z', - flowTags: ['swap'], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-send'), - createDirent('mm-session-swap'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(sendMetadata)) - .mockResolvedValueOnce(JSON.stringify(swapMetadata)); - - const result = await store.listSessions(10, { flowTag: 'send' }); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-send'); - }); - - it('filters by tag', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const testMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-test', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: ['test', 'e2e'], - launch: { stateMode: 'default' }, - }; - const prodMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-prod', - createdAt: '2024-01-14T10:00:00.000Z', - flowTags: [], - tags: ['production'], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-test'), - createDirent('mm-session-prod'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(testMetadata)) - .mockResolvedValueOnce(JSON.stringify(prodMetadata)); - - const result = await store.listSessions(10, { tag: 'e2e' }); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-test'); - }); - - it('filters by sinceHours', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const now = new Date(); - const recentDate = new Date(now.getTime() - 12 * 60 * 60 * 1000); - const oldDate = new Date(now.getTime() - 72 * 60 * 60 * 1000); - - const recentMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-recent', - createdAt: recentDate.toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const oldMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-old', - createdAt: oldDate.toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-recent'), - createDirent('mm-session-old'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(recentMetadata)) - .mockResolvedValueOnce(JSON.stringify(oldMetadata)); - - const result = await store.listSessions(10, { sinceHours: 24 }); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-recent'); - }); - }); - - describe('resolveSessionIds', () => { - function createDirent(name: string, isDir = true) { - return { name, isDirectory: () => isDir }; - } - - it('returns current session ID for scope "current"', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - const result = await store.resolveSessionIds( - 'current', - 'current-session-001', - ); - - expect(result).toStrictEqual(['current-session-001']); - }); - - it('returns empty array for scope "current" without current session', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - const result = await store.resolveSessionIds('current', undefined); - - expect(result).toStrictEqual([]); - }); - - it('returns specific session ID for scope object', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - const result = await store.resolveSessionIds( - { sessionId: 'specific-session-001' }, - 'current-session', - ); - - expect(result).toStrictEqual(['specific-session-001']); - }); - - it('returns all session IDs for scope "all"', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - createDirent('mm-session-2'), - createDirent('mm-session-3'), - ] as any); - - const result = await store.resolveSessionIds('all', 'current-session'); - - expect(result).toStrictEqual([ - 'mm-session-1', - 'mm-session-2', - 'mm-session-3', - ]); - }); - - it('filters session IDs by filters for scope "all"', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const sendMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-send', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: ['send'], - tags: [], - launch: { stateMode: 'default' }, - }; - const swapMetadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-swap', - createdAt: '2024-01-14T10:00:00.000Z', - flowTags: ['swap'], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-send'), - createDirent('mm-session-swap'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(sendMetadata)) - .mockResolvedValueOnce(JSON.stringify(swapMetadata)); - - const result = await store.resolveSessionIds('all', 'current', { - flowTag: 'send', - }); - - expect(result).toStrictEqual(['mm-session-send']); - }); - - it('includes sessions without metadata when filtering', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-with-metadata'), - createDirent('mm-session-no-metadata'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-with-metadata', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: ['send'], - tags: [], - launch: { stateMode: 'default' }, - }), - ) - .mockRejectedValueOnce(new Error('ENOENT')); - - const result = await store.resolveSessionIds('all', 'current', { - flowTag: 'send', - }); - - expect(result).toContain('mm-session-with-metadata'); - expect(result).toContain('mm-session-no-metadata'); - }); - }); - - describe('extractPathTokens', () => { - it('extracts tokens from URL hash fragment', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const outcome: StepRecordOutcome = { ok: true }; - const observation = createObservation({ - currentScreen: 'confirm-transaction', - currentUrl: 'chrome-extension://test#/confirm/send/0x1234', - }); - - await store.recordStep({ - sessionId: 'session-path-001', - toolName: 'click', - outcome, - observation, - }); - - expect(fs.writeFile).toHaveBeenCalled(); - }); - }); -}); - -describe('similarity', () => { - let originalCwd: () => string; - - beforeEach(() => { - vi.clearAllMocks(); - originalCwd = process.cwd; - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - }); - - afterEach(() => { - process.cwd = originalCwd; - vi.restoreAllMocks(); - }); - - function createStepRecord( - overrides: { - sessionId?: string; - tool?: { - name: string; - input?: Record; - target?: Record; - }; - observation?: { - state?: Record; - testIds?: { testId: string; tag: string; visible: boolean }[]; - a11y?: { - nodes: { ref: string; role: string; name: string; path: string[] }[]; - }; - }; - labels?: string[]; - } = {}, - ) { - const baseTool = { - name: 'click', - input: { testId: 'test-btn' }, - target: { testId: 'test-btn' }, - }; - const baseObservation = { - state: { - isLoaded: true, - currentUrl: 'chrome-extension://test#/home', - extensionId: 'test-ext', - isUnlocked: true, - currentScreen: 'home', - accountAddress: '0x1234', - networkName: 'Localhost 8545', - chainId: 1337, - balance: '25 ETH', - }, - testIds: [{ testId: 'test-btn', tag: 'button', visible: true }], - a11y: { - nodes: [{ ref: 'e1', role: 'button', name: 'Test Button', path: [] }], - }, - }; - - return { - schemaVersion: 1, - sessionId: overrides.sessionId ?? 'test-session', - timestamp: '2024-01-15T10:30:00.000Z', - tool: overrides.tool ? { ...baseTool, ...overrides.tool } : baseTool, - observation: overrides.observation - ? { - state: overrides.observation.state - ? { ...baseObservation.state, ...overrides.observation.state } - : baseObservation.state, - testIds: overrides.observation.testIds ?? baseObservation.testIds, - a11y: overrides.observation.a11y ?? baseObservation.a11y, - } - : baseObservation, - outcome: { ok: true }, - labels: overrides.labels ?? ['interaction'], - durationMs: 100, - environment: { platform: 'darwin', nodeVersion: 'v20.0.0' }, - }; - } - - function createDirent(name: string, isDir = true) { - return { name, isDirectory: () => isDir }; - } - - describe('searchSteps scoring', () => { - it('scores steps matching tool name in query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - tool: { name: 'click', input: {} }, - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - expect(results[0].tool).toBe('click'); - }); - - it('scores steps matching screen name in query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'send' }, - testIds: [], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps('send', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - expect(results[0].screen).toBe('send'); - }); - - it('scores steps matching target testId in query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - tool: { - name: 'click', - input: { testId: 'confirm-button' }, - target: { testId: 'confirm-button' }, - }, - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps('confirm', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('scores steps matching labels in query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - labels: ['navigation', 'confirmation'], - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps( - 'confirmation', - 10, - 'all', - undefined, - ); - - expect(results.length).toBeGreaterThan(0); - }); - - it('scores steps matching observed testIds in query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'home' }, - testIds: [ - { testId: 'amount-input', tag: 'input', visible: true }, - { testId: 'send-button', tag: 'button', visible: true }, - ], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps('amount', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('scores steps matching a11y node names in query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'home' }, - testIds: [], - a11y: { - nodes: [ - { ref: 'e1', role: 'button', name: 'Send ETH', path: [] }, - { ref: 'e2', role: 'textbox', name: 'Amount', path: [] }, - ], - }, - }, - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps('eth', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('scores steps matching a11y node roles in query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'home' }, - testIds: [], - a11y: { - nodes: [{ ref: 'e1', role: 'textbox', name: 'Search', path: [] }], - }, - }, - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps('textbox', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('returns empty results for empty query', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - const results = await store.searchSteps('', 10, 'all', undefined); - - expect(results).toStrictEqual([]); - }); - - it('calculates token coverage ratio bonus', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const stepRecord = createStepRecord({ - tool: { name: 'click', input: {} }, - observation: { - state: { currentScreen: 'send' }, - testIds: [], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }), - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const results = await store.searchSteps( - 'click send', - 10, - 'all', - undefined, - ); - - expect(results.length).toBeGreaterThan(0); - }); - }); - - describe('session scoring', () => { - it('scores sessions with matching flowTags higher', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const sendMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-send', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: ['send'], - tags: [], - launch: { stateMode: 'default' }, - }; - const swapMetadata = { - schemaVersion: 1, - sessionId: 'mm-session-swap', - createdAt: '2024-01-15T09:00:00.000Z', - flowTags: ['swap'], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-send'), - createDirent('mm-session-swap'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(sendMetadata)) - .mockResolvedValueOnce(JSON.stringify(swapMetadata)); - vi.mocked(fs.readdir) - .mockResolvedValueOnce(['step1.json'] as any) - .mockResolvedValueOnce(['step1.json'] as any); - - const sendStep = createStepRecord({ sessionId: 'mm-session-send' }); - const swapStep = createStepRecord({ sessionId: 'mm-session-swap' }); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(sendStep)) - .mockResolvedValueOnce(JSON.stringify(swapStep)); - - const results = await store.searchSteps('send', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('scores sessions with matching goal tokens', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - goal: 'Test the token swap feature', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata)); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify(createStepRecord()), - ); - - const results = await store.searchSteps('swap', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('scores sessions with matching tags', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: ['regression', 'e2e'], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata)); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify(createStepRecord()), - ); - - const results = await store.searchSteps( - 'regression', - 10, - 'all', - undefined, - ); - - expect(results.length).toBeGreaterThan(0); - }); - - it('gives recency bonus to recent sessions (< 24 hours)', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const recentDate = new Date( - Date.now() - 12 * 60 * 60 * 1000, - ).toISOString(); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-recent', - createdAt: recentDate, - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-recent'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata)); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify( - createStepRecord({ - tool: { name: 'click', input: {} }, - }), - ), - ); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('gives smaller recency bonus to moderately recent sessions (24-72 hours)', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const moderateDate = new Date( - Date.now() - 48 * 60 * 60 * 1000, - ).toISOString(); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-moderate', - createdAt: moderateDate, - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-moderate'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata)); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify( - createStepRecord({ - tool: { name: 'click', input: {} }, - }), - ), - ); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results.length).toBeGreaterThan(0); - }); - - it('sorts sessions by score then by createdAt', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata1 = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: '2024-01-15T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const metadata2 = { - schemaVersion: 1, - sessionId: 'mm-session-2', - createdAt: '2024-01-16T10:00:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - createDirent('mm-session-2'), - ] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata1)) - .mockResolvedValueOnce(JSON.stringify(metadata2)); - vi.mocked(fs.readdir) - .mockResolvedValueOnce(['step1.json'] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createStepRecord({ - sessionId: 'mm-session-1', - tool: { name: 'click', input: {} }, - }), - ), - ) - .mockResolvedValueOnce( - JSON.stringify( - createStepRecord({ - sessionId: 'mm-session-2', - tool: { name: 'click', input: {} }, - }), - ), - ); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results).toHaveLength(2); - }); - }); - - describe('generatePriorKnowledge similarity scoring', () => { - it('scores steps with same screen higher', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: ['send'], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'send' }, - testIds: [], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'send', - currentUrl: 'chrome-extension://test#/send', - visibleTestIds: [], - a11yNodes: [], - currentSessionFlowTags: ['send'], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - expect(result?.similarSteps.length).toBeGreaterThanOrEqual(0); - }); - - it('scores steps with URL path overlap', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - observation: { - state: { - currentScreen: 'home', - currentUrl: 'chrome-extension://test#/confirm/send', - }, - testIds: [], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - currentUrl: 'chrome-extension://test#/confirm/transaction', - visibleTestIds: [], - a11yNodes: [], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - }); - - it('scores steps with testId overlap', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'home' }, - testIds: [ - { testId: 'send-btn', tag: 'button', visible: true }, - { testId: 'swap-btn', tag: 'button', visible: true }, - { testId: 'bridge-btn', tag: 'button', visible: true }, - ], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [ - { testId: 'send-btn', tag: 'button', visible: true }, - { testId: 'swap-btn', tag: 'button', visible: true }, - ], - a11yNodes: [], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - }); - - it('scores steps with a11y node overlap', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'home' }, - testIds: [], - a11y: { - nodes: [ - { ref: 'e1', role: 'button', name: 'Send', path: [] }, - { ref: 'e2', role: 'button', name: 'Swap', path: [] }, - ], - }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [], - a11yNodes: [ - { ref: 'e1', role: 'button', name: 'Send', path: [] }, - { ref: 'e2', role: 'button', name: 'Swap', path: [] }, - ], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - }); - - it('scores actionable tools higher than discovery tools', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const clickStep = createStepRecord({ - tool: { name: 'click', input: { testId: 'send-btn' } }, - observation: { - state: { currentScreen: 'home' }, - testIds: [{ testId: 'send-btn', tag: 'button', visible: true }], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(clickStep)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [ - { testId: 'send-btn', tag: 'button', visible: true }, - ], - a11yNodes: [], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - if (result?.similarSteps.length) { - expect(result.similarSteps[0].tool).toBe('click'); - } - }); - - it('excludes discovery tools from similarity scoring', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const discoveryStep = createStepRecord({ - tool: { name: 'describe_screen', input: {} }, - observation: { - state: { currentScreen: 'home' }, - testIds: [{ testId: 'send-btn', tag: 'button', visible: true }], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(discoveryStep)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [ - { testId: 'send-btn', tag: 'button', visible: true }, - ], - a11yNodes: [], - }, - 'other-session', - ); - - if (result?.similarSteps.length) { - const hasDiscoveryTool = result.similarSteps.some( - (s) => s.tool === 'describe_screen', - ); - expect(hasDiscoveryTool).toBe(false); - } - }); - - it('returns undefined when no candidate sessions exist', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [], - a11yNodes: [], - }, - 'current-session', - ); - - expect(result).toBeUndefined(); - }); - - it('excludes current session from candidate sessions', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-current', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-current'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [], - a11yNodes: [], - }, - 'mm-session-current', - ); - - expect(result).toBeUndefined(); - }); - - it('caps testId overlap scoring at 3 items', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'home' }, - testIds: [ - { testId: 'btn-1', tag: 'button', visible: true }, - { testId: 'btn-2', tag: 'button', visible: true }, - { testId: 'btn-3', tag: 'button', visible: true }, - { testId: 'btn-4', tag: 'button', visible: true }, - { testId: 'btn-5', tag: 'button', visible: true }, - ], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [ - { testId: 'btn-1', tag: 'button', visible: true }, - { testId: 'btn-2', tag: 'button', visible: true }, - { testId: 'btn-3', tag: 'button', visible: true }, - { testId: 'btn-4', tag: 'button', visible: true }, - { testId: 'btn-5', tag: 'button', visible: true }, - ], - a11yNodes: [], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - }); - - it('caps a11y overlap scoring at 2 items', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'home' }, - testIds: [], - a11y: { - nodes: [ - { ref: 'e1', role: 'button', name: 'Action 1', path: [] }, - { ref: 'e2', role: 'button', name: 'Action 2', path: [] }, - { ref: 'e3', role: 'button', name: 'Action 3', path: [] }, - { ref: 'e4', role: 'button', name: 'Action 4', path: [] }, - ], - }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [], - a11yNodes: [ - { ref: 'e1', role: 'button', name: 'Action 1', path: [] }, - { ref: 'e2', role: 'button', name: 'Action 2', path: [] }, - { ref: 'e3', role: 'button', name: 'Action 3', path: [] }, - { ref: 'e4', role: 'button', name: 'Action 4', path: [] }, - ], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - }); - - it('computes confidence as ratio of score to max score', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - tool: { name: 'click', input: { testId: 'send-btn' } }, - observation: { - state: { currentScreen: 'send' }, - testIds: [{ testId: 'send-btn', tag: 'button', visible: true }], - a11y: { - nodes: [{ ref: 'e1', role: 'button', name: 'Send', path: [] }], - }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'send', - currentUrl: 'chrome-extension://test#/send', - visibleTestIds: [ - { testId: 'send-btn', tag: 'button', visible: true }, - ], - a11yNodes: [{ ref: 'e1', role: 'button', name: 'Send', path: [] }], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - if (result?.similarSteps.length) { - const { confidence } = result.similarSteps[0]; - expect(confidence).toBeGreaterThan(0); - expect(confidence).toBeLessThanOrEqual(1); - } - }); - - it('filters steps using flowTag from context', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: ['send'], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - ] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata)); - vi.mocked(fs.readdir).mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify(createStepRecord()), - ); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [], - a11yNodes: [], - currentSessionFlowTags: ['send'], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - }); - - it('does not award sameScreen bonus for unknown screens', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const stepRecord = createStepRecord({ - observation: { - state: { currentScreen: 'unknown' }, - testIds: [], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(stepRecord)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'unknown', - visibleTestIds: [], - a11yNodes: [], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - if (result?.similarSteps.length) { - expect(result.similarSteps[0].confidence).toBeLessThan(0.5); - } - }); - - it('builds avoid list only for targets meeting failure threshold', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - const makeFailedStep = (target: { - testId?: string; - selector?: string; - }) => ({ - ...createStepRecord({ - tool: { - name: 'click', - input: { testId: target.testId ?? 'unknown-btn' }, - target, - }, - observation: { - state: { currentScreen: 'home' }, - testIds: [{ testId: 'confirm-btn', tag: 'button', visible: true }], - a11y: { nodes: [] }, - }, - }), - outcome: { - ok: false, - error: { - code: 'MM_TARGET_NOT_FOUND', - message: 'Target not found', - }, - }, - }); - - const failedConfirmA = makeFailedStep({ testId: 'confirm-btn' }); - const failedConfirmB = makeFailedStep({ testId: 'confirm-btn' }); - const failedSelector = makeFailedStep({ selector: '.unstable-target' }); - const successfulStep = createStepRecord({ - tool: { - name: 'click', - input: { testId: 'confirm-btn' }, - target: { testId: 'confirm-btn' }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce([ - 'step1.json', - 'step2.json', - 'step3.json', - 'step4.json', - ] as any) - .mockResolvedValueOnce([ - 'step1.json', - 'step2.json', - 'step3.json', - 'step4.json', - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(failedConfirmA)) - .mockResolvedValueOnce(JSON.stringify(failedConfirmB)) - .mockResolvedValueOnce(JSON.stringify(failedSelector)) - .mockResolvedValueOnce(JSON.stringify(successfulStep)) - .mockResolvedValueOnce(JSON.stringify(failedConfirmA)) - .mockResolvedValueOnce(JSON.stringify(failedConfirmB)) - .mockResolvedValueOnce(JSON.stringify(failedSelector)) - .mockResolvedValueOnce(JSON.stringify(successfulStep)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [ - { testId: 'confirm-btn', tag: 'button', visible: true }, - ], - a11yNodes: [], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - expect(result?.avoid).toHaveLength(1); - expect(result?.avoid?.[0]).toMatchObject({ - target: { testId: 'confirm-btn' }, - frequency: 2, - }); - }); - - it('skips suggested action when tool is not in action map', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const unknownToolStep = createStepRecord({ - tool: { - name: 'unknown_tool', - input: { testId: 'send-btn' }, - target: { testId: 'send-btn' }, - }, - observation: { - state: { currentScreen: 'home' }, - testIds: [{ testId: 'send-btn', tag: 'button', visible: true }], - a11y: { nodes: [] }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any) - .mockResolvedValueOnce(['step1.json'] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(unknownToolStep)) - .mockResolvedValueOnce(JSON.stringify(unknownToolStep)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [ - { testId: 'send-btn', tag: 'button', visible: true }, - ], - a11yNodes: [], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - expect(result?.similarSteps.length).toBeGreaterThan(0); - expect(result?.suggestedNextActions).toStrictEqual([]); - }); - - it('includes a11y fallback target when testId text matches visible a11y name', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const metadata = { - schemaVersion: 1, - sessionId: 'mm-session-1', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - const actionableStep = createStepRecord({ - tool: { - name: 'click', - input: { testId: 'send-button' }, - target: { testId: 'send-button' }, - }, - observation: { - state: { currentScreen: 'home' }, - testIds: [{ testId: 'send-button', tag: 'button', visible: true }], - a11y: { - nodes: [{ ref: 'e1', role: 'button', name: 'Send', path: [] }], - }, - }, - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step1.json'] as any) - .mockResolvedValueOnce(['step1.json'] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata)) - .mockResolvedValueOnce(JSON.stringify(actionableStep)) - .mockResolvedValueOnce(JSON.stringify(actionableStep)); - - const result = await store.generatePriorKnowledge( - { - currentScreen: 'home', - visibleTestIds: [ - { testId: 'send-button', tag: 'button', visible: true }, - ], - a11yNodes: [ - { ref: 'e10', role: 'button', name: 'send button', path: [] }, - ], - }, - 'other-session', - ); - - expect(result).toBeDefined(); - expect( - result?.suggestedNextActions[0]?.fallbackTargets?.[0], - ).toStrictEqual({ - type: 'a11yHint', - value: { role: 'button', name: 'send button' }, - }); - }); - }); -}); - -describe('session', () => { - let originalCwd: () => string; - - beforeEach(() => { - vi.clearAllMocks(); - originalCwd = process.cwd; - vi.spyOn(process, 'cwd').mockReturnValue('/test/project'); - }); - - afterEach(() => { - process.cwd = originalCwd; - vi.restoreAllMocks(); - }); - - function createDirent(name: string, isDir = true) { - return { name, isDirectory: () => isDir }; - } - - function createSessionMetadata( - overrides: Partial = {}, - ): SessionMetadata { - return { - schemaVersion: 1, - sessionId: overrides.sessionId ?? 'mm-session-test', - createdAt: overrides.createdAt ?? '2024-01-15T10:00:00.000Z', - flowTags: overrides.flowTags ?? [], - tags: overrides.tags ?? [], - launch: overrides.launch ?? { stateMode: 'default' }, - goal: overrides.goal, - }; - } - - function createStepRecord(sessionId: string, timestamp: string) { - return { - schemaVersion: 1, - sessionId, - timestamp, - tool: { name: 'click', input: { testId: 'test-btn' } }, - observation: { - state: { - isLoaded: true, - currentUrl: 'chrome-extension://test#/home', - extensionId: 'test-ext', - isUnlocked: true, - currentScreen: 'home', - accountAddress: '0x1234', - networkName: 'Localhost 8545', - chainId: 1337, - balance: '25 ETH', - }, - testIds: [], - a11y: { nodes: [] }, - }, - outcome: { ok: true }, - labels: ['interaction'], - durationMs: 100, - environment: { platform: 'darwin', nodeVersion: 'v20.0.0' }, - }; - } - - describe('getAllSessionIds', () => { - it('returns session IDs from directories starting with mm-', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - createDirent('mm-session-2'), - createDirent('other-dir'), - createDirent('file.txt', false), - ] as any); - - const result = await store.getAllSessionIds(); - - expect(result).toStrictEqual(['mm-session-1', 'mm-session-2']); - }); - - it('returns session IDs with custom prefix', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - sessionIdPrefix: 'custom-', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('custom-session-1'), - createDirent('mm-session-2'), - createDirent('other-dir'), - ] as any); - - const result = await store.getAllSessionIds(); - - expect(result).toStrictEqual(['custom-session-1', 'mm-session-2']); - }); - - it('returns empty array when directory read fails', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT')); - - const result = await store.getAllSessionIds(); - - expect(result).toStrictEqual([]); - }); - - it('returns empty array for empty directory', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); - - const result = await store.getAllSessionIds(); - - expect(result).toStrictEqual([]); - }); - }); - - describe('session scanning limits', () => { - it('limits sessions scanned to maxSessionsToScan (20)', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - // Create 25 sessions - const sessions = Array.from({ length: 25 }, (_, i) => - createDirent(`mm-session-${i}`), - ); - - // Create metadata for each session - const metadatas = Array.from({ length: 25 }, (_, i) => - createSessionMetadata({ - sessionId: `mm-session-${i}`, - createdAt: new Date( - Date.now() - (25 - i) * 60 * 60 * 1000, - ).toISOString(), - }), - ); - - // Mock readdir for root - vi.mocked(fs.readdir).mockResolvedValueOnce(sessions as any); - - // Mock readFile for each session metadata - for (const meta of metadatas) { - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(meta)); - } - - // Mock readdir and readFile for steps (empty) - for (let i = 0; i < 20; i++) { - vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); - } - - const results = await store.searchSteps('click', 100, 'all', undefined); - - // Should only scan 20 sessions (maxSessionsToScan limit) - expect(results).toBeDefined(); - }); - - it('limits steps per session to maxStepsPerSession (500)', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - // Create 600 steps for one session - const stepFiles = Array.from({ length: 600 }, (_, i) => `step-${i}.json`); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(stepFiles as any); - - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify(createSessionMetadata({ sessionId: 'mm-session-1' })), - ); - - // Only first 500 steps should be loaded - for (let i = 0; i < 500; i++) { - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify( - createStepRecord( - 'mm-session-1', - `2024-01-15T10:${String(i).padStart(2, '0')}:00.000Z`, - ), - ), - ); - } - - const results = await store.searchSteps('click', 1000, 'all', undefined); - - expect(results).toBeDefined(); - // Total steps should be capped at 500 per session - expect(results.length).toBeLessThanOrEqual(500); - }); - - it('stops scanning when maxTotalSteps (2000) is reached', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - // Create 5 sessions with 500 steps each = 2500 total - const sessions = Array.from({ length: 5 }, (_, i) => - createDirent(`mm-session-${i}`), - ); - - vi.mocked(fs.readdir).mockResolvedValueOnce(sessions as any); - - // Mock metadata for each session - for (let i = 0; i < 5; i++) { - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: `mm-session-${i}`, - createdAt: new Date( - Date.now() - (5 - i) * 60 * 60 * 1000, - ).toISOString(), - }), - ), - ); - } - - // Mock steps dir for each session (500 steps each) - for (let i = 0; i < 5; i++) { - const stepFiles = Array.from( - { length: 500 }, - (_, j) => `step-${j}.json`, - ); - vi.mocked(fs.readdir).mockResolvedValueOnce(stepFiles as any); - - // Mock step files - for (let j = 0; j < 500; j++) { - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify( - createStepRecord( - `mm-session-${i}`, - `2024-01-15T${String(i).padStart(2, '0')}:${String(j).padStart(2, '0')}:00.000Z`, - ), - ), - ); - } - } - - const results = await store.searchSteps('click', 5000, 'all', undefined); - - expect(results).toBeDefined(); - // Should stop at 2000 total steps - }); - }); - - describe('filter parameters', () => { - it('filters sessions by flowTag', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-send'), - createDirent('mm-session-swap'), - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-send', - flowTags: ['send'], - }), - ), - ) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-swap', - flowTags: ['swap'], - }), - ), - ); - - const result = await store.listSessions(10, { flowTag: 'send' }); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-send'); - }); - - it('filters sessions by tag', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-e2e'), - createDirent('mm-session-prod'), - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-e2e', - tags: ['e2e', 'regression'], - }), - ), - ) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-prod', - tags: ['production'], - }), - ), - ); - - const result = await store.listSessions(10, { tag: 'e2e' }); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-e2e'); - }); - - it('filters sessions by sinceHours', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const now = new Date(); - const recentDate = new Date(now.getTime() - 6 * 60 * 60 * 1000); - const oldDate = new Date(now.getTime() - 48 * 60 * 60 * 1000); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-recent'), - createDirent('mm-session-old'), - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-recent', - createdAt: recentDate.toISOString(), - }), - ), - ) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-old', - createdAt: oldDate.toISOString(), - }), - ), - ); - - const result = await store.listSessions(10, { sinceHours: 24 }); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-recent'); - }); - - it('combines multiple filters', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - const recentDate = new Date(Date.now() - 6 * 60 * 60 * 1000); - const oldDate = new Date(Date.now() - 48 * 60 * 60 * 1000); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-1'), - createDirent('mm-session-2'), - createDirent('mm-session-3'), - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-1', - createdAt: recentDate.toISOString(), - flowTags: ['send'], - }), - ), - ) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-2', - createdAt: recentDate.toISOString(), - flowTags: ['swap'], - }), - ), - ) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-3', - createdAt: oldDate.toISOString(), - flowTags: ['send'], - }), - ), - ); - - const result = await store.listSessions(10, { - flowTag: 'send', - sinceHours: 24, - }); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-1'); - }); - }); - - describe('corrupted session file handling', () => { - it('skips corrupted session metadata files', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-valid'), - createDirent('mm-session-corrupted'), - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-valid', - }), - ), - ) - .mockResolvedValueOnce('invalid json {{{'); - - const result = await store.listSessions(10); - - expect(result).toHaveLength(1); - expect(result[0].sessionId).toBe('mm-session-valid'); - }); - - it('skips corrupted step files during search', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce([ - 'step-valid.json', - 'step-corrupted.json', - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-1', - }), - ), - ) - .mockResolvedValueOnce( - JSON.stringify( - createStepRecord('mm-session-1', '2024-01-15T10:00:00.000Z'), - ), - ) - .mockResolvedValueOnce('not valid json'); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results).toBeDefined(); - // Should still return results from valid step - expect(results.length).toBeGreaterThanOrEqual(0); - }); - - it('handles missing step files gracefully', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce(['step.json'] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-1', - }), - ), - ) - .mockRejectedValueOnce(new Error('ENOENT: file not found')); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results).toBeDefined(); - expect(results).toHaveLength(0); - }); - - it('handles steps directory not existing', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockRejectedValueOnce(new Error('ENOENT: no steps directory')); - - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-1', - }), - ), - ); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results).toBeDefined(); - expect(results).toHaveLength(0); - }); - }); - - describe('empty session directory', () => { - it('returns empty results for empty knowledge root', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); - - const sessions = await store.listSessions(10); - - expect(sessions).toStrictEqual([]); - }); - - it('returns empty search results for empty knowledge root', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); - - const results = await store.searchSteps('click', 10, 'all', undefined); - - expect(results).toStrictEqual([]); - }); - - it('returns empty getLastSteps for session with no steps', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir) - .mockResolvedValueOnce([createDirent('mm-session-1')] as any) - .mockResolvedValueOnce([] as any); - - const results = await store.getLastSteps( - 10, - { sessionId: 'mm-session-1' }, - undefined, - ); - - expect(results).toStrictEqual([]); - }); - - it('returns empty summarizeSession for session with no steps', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([] as any); - - const result = await store.summarizeSession('mm-session-1'); - - expect(result.stepCount).toBe(0); - expect(result.recipe).toStrictEqual([]); - }); - }); - - describe('resolveSessionIds with filters', () => { - it('includes sessions without metadata when filtering', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - vi.mocked(fs.readdir).mockResolvedValueOnce([ - createDirent('mm-session-with-metadata'), - createDirent('mm-session-no-metadata'), - ] as any); - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify( - createSessionMetadata({ - sessionId: 'mm-session-with-metadata', - flowTags: ['send'], - }), - ), - ) - .mockRejectedValueOnce(new Error('ENOENT')); - - const result = await store.resolveSessionIds('all', undefined, { - flowTag: 'send', - }); - - // Both should be included - session without metadata is not filtered out - expect(result).toContain('mm-session-with-metadata'); - expect(result).toContain('mm-session-no-metadata'); - }); - - it('returns empty array for scope current without sessionId', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - const result = await store.resolveSessionIds('current', undefined); - - expect(result).toStrictEqual([]); - }); - - it('returns specific sessionId for scope object', async () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - - const result = await store.resolveSessionIds( - { sessionId: 'specific-session' }, - 'current-session', - ); - - expect(result).toStrictEqual(['specific-session']); - }); - }); - - describe('hasKnowledgeStore', () => { - afterEach(() => { - setKnowledgeStore(undefined as any); - }); - - it('returns false when knowledge store not initialized', () => { - setKnowledgeStore(undefined as any); - - const result = hasKnowledgeStore(); - - expect(result).toBe(false); - }); - - it('returns true when knowledge store is initialized', () => { - const store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - setKnowledgeStore(store); - - const result = hasKnowledgeStore(); - - expect(result).toBe(true); - }); - }); - - describe('knowledgeStore facade', () => { - let store: KnowledgeStore; - - beforeEach(() => { - store = new KnowledgeStore({ - rootDir: '/test/knowledge', - }); - setKnowledgeStore(store); - }); - - afterEach(() => { - setKnowledgeStore(undefined as any); - }); - - it('recordStep delegates to underlying KnowledgeStore instance', async () => { - const params = { - sessionId: 'test-session', - toolName: 'click', - observation: createObservation(), - outcome: { ok: true } as StepRecordOutcome, - }; - - vi.spyOn(store, 'recordStep').mockResolvedValueOnce('/test/path'); - - const result = await knowledgeStore.recordStep(params); - - expect(store.recordStep).toHaveBeenCalledWith(params); - expect(result).toBe('/test/path'); - }); - - it('recordStep throws error when knowledge store not initialized', async () => { - setKnowledgeStore(undefined as any); - - const params = { - sessionId: 'test-session', - toolName: 'click', - observation: createObservation(), - outcome: { ok: true } as StepRecordOutcome, - }; - - await expect(knowledgeStore.recordStep(params)).rejects.toThrowError( - 'Knowledge store not initialized', - ); - }); - - it('getLastSteps delegates to underlying KnowledgeStore instance', async () => { - const mockSteps = [ - { - timestamp: '2024-01-15T10:30:00.000Z', - tool: 'click', - screen: 'home' as const, - snippet: 'Clicked button', - }, - ]; - - vi.spyOn(store, 'getLastSteps').mockResolvedValueOnce(mockSteps); - - const result = await knowledgeStore.getLastSteps( - 10, - { sessionId: 'test-session' }, - undefined, - ); - - expect(store.getLastSteps).toHaveBeenCalledWith( - 10, - { sessionId: 'test-session' }, - undefined, - ); - expect(result).toStrictEqual(mockSteps); - }); - - it('getLastSteps throws error when knowledge store not initialized', async () => { - setKnowledgeStore(undefined as any); - - await expect( - knowledgeStore.getLastSteps( - 10, - { sessionId: 'test-session' }, - undefined, - ), - ).rejects.toThrowError('Knowledge store not initialized'); - }); - - it('searchSteps delegates to underlying KnowledgeStore instance', async () => { - const mockResults = [ - { - timestamp: '2024-01-15T10:30:00.000Z', - tool: 'click', - screen: 'home' as const, - snippet: 'Clicked send button', - }, - ]; - - vi.spyOn(store, 'searchSteps').mockResolvedValueOnce(mockResults); - - const result = await knowledgeStore.searchSteps( - 'click', - 10, - 'all', - undefined, - ); - - expect(store.searchSteps).toHaveBeenCalledWith( - 'click', - 10, - 'all', - undefined, - ); - expect(result).toStrictEqual(mockResults); - }); - - it('searchSteps throws error when knowledge store not initialized', async () => { - setKnowledgeStore(undefined as any); - - await expect( - knowledgeStore.searchSteps('click', 10, 'all', undefined), - ).rejects.toThrowError('Knowledge store not initialized'); - }); - - it('summarizeSession delegates to underlying KnowledgeStore instance', async () => { - const mockSummary = { - sessionId: 'test-session', - stepCount: 5, - recipe: [{ stepNumber: 1, tool: 'click', notes: 'Clicked send' }], - }; - - vi.spyOn(store, 'summarizeSession').mockResolvedValueOnce(mockSummary); - - const result = await knowledgeStore.summarizeSession('test-session'); - - expect(store.summarizeSession).toHaveBeenCalledWith('test-session'); - expect(result).toStrictEqual(mockSummary); - }); - - it('summarizeSession throws error when knowledge store not initialized', async () => { - setKnowledgeStore(undefined as any); - - await expect( - knowledgeStore.summarizeSession('test-session'), - ).rejects.toThrowError('Knowledge store not initialized'); - }); - - it('listSessions delegates to underlying KnowledgeStore instance', async () => { - const mockSessions = [ - { - sessionId: 'test-session-1', - createdAt: '2024-01-15T10:30:00.000Z', - flowTags: ['send'], - tags: [], - }, - ]; - - vi.spyOn(store, 'listSessions').mockResolvedValueOnce(mockSessions); - - const result = await knowledgeStore.listSessions(10); - - expect(store.listSessions).toHaveBeenCalledWith(10); - expect(result).toStrictEqual(mockSessions); - }); - - it('listSessions throws error when knowledge store not initialized', async () => { - setKnowledgeStore(undefined as any); - - await expect(knowledgeStore.listSessions(10)).rejects.toThrowError( - 'Knowledge store not initialized', - ); - }); - - it('generatePriorKnowledge delegates to underlying KnowledgeStore instance', async () => { - const mockPriorKnowledge = { - schemaVersion: 1 as const, - generatedAt: '2024-01-15T10:30:00.000Z', - query: { - windowHours: 24, - usedFlowTags: [], - usedFilters: {}, - candidateSessions: 0, - candidateSteps: 0, - }, - relatedSessions: [], - similarSteps: [], - suggestedNextActions: [], - }; - - vi.spyOn(store, 'generatePriorKnowledge').mockResolvedValueOnce( - mockPriorKnowledge, - ); - - const context = { - currentScreen: 'home', - visibleTestIds: [], - a11yNodes: [], - }; - const result = await knowledgeStore.generatePriorKnowledge(context); - - expect(store.generatePriorKnowledge).toHaveBeenCalledWith(context); - expect(result).toStrictEqual(mockPriorKnowledge); - }); - - it('generatePriorKnowledge throws error when knowledge store not initialized', async () => { - setKnowledgeStore(undefined as any); - - const context = { - currentScreen: 'home', - visibleTestIds: [], - a11yNodes: [], - }; - await expect( - knowledgeStore.generatePriorKnowledge(context), - ).rejects.toThrowError('Knowledge store not initialized'); - }); - - it('writeSessionMetadata delegates to underlying KnowledgeStore instance', async () => { - const metadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'test-session', - createdAt: '2024-01-15T10:30:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - vi.spyOn(store, 'writeSessionMetadata').mockResolvedValueOnce( - '/test/path', - ); - - const result = await knowledgeStore.writeSessionMetadata(metadata); - - expect(store.writeSessionMetadata).toHaveBeenCalledWith(metadata); - expect(result).toBe('/test/path'); - }); - - it('writeSessionMetadata throws error when knowledge store not initialized', async () => { - setKnowledgeStore(undefined as any); - - const metadata: SessionMetadata = { - schemaVersion: 1, - sessionId: 'test-session', - createdAt: '2024-01-15T10:30:00.000Z', - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }; - - await expect( - knowledgeStore.writeSessionMetadata(metadata), - ).rejects.toThrowError('Knowledge store not initialized'); - }); - }); -}); diff --git a/src/knowledge-store/knowledge-store.ts b/src/knowledge-store/knowledge-store.ts deleted file mode 100644 index 3a57e96..0000000 --- a/src/knowledge-store/knowledge-store.ts +++ /dev/null @@ -1,1896 +0,0 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable id-length */ -import { promises as fs } from 'fs'; -import * as path from 'path'; - -import { - expandWithSynonyms, - tokenize, - tokenizeIdentifier, -} from './tokenization.js'; -import type { ExtensionState } from '../capabilities/types.js'; -import type { - StepRecord, - StepRecordTool, - StepRecordObservation, - StepRecordOutcome, - TestIdItem, - A11yNodeTrimmed, - KnowledgeStepSummary, - RecipeStep, - SessionMetadata, - KnowledgeScope, - KnowledgeFilters, - SessionSummary, - PriorKnowledgeV1, - PriorKnowledgeContext, - PriorKnowledgeSimilarStep, - PriorKnowledgeSuggestedAction, - PriorKnowledgeAvoid, - PriorKnowledgeRelatedSession, - PriorKnowledgeTarget, -} from '../tools/types'; -import { generateFilesafeTimestamp, debugWarn } from '../utils'; -import { isSensitiveField, redactRecordedSecrets } from './utils/redaction.js'; - -const KNOWLEDGE_ROOT = 'test-artifacts/llm-knowledge'; -const SCHEMA_VERSION = 1; - -/** - * Parse a recording path component without rewriting invalid names. - * - * @param value - Session ID or tool name used in a recording path - * @param name - Component name for the validation error - * @returns The validated filename component - */ -function parseRecordingComponent(value: string, name: string): string { - const component = /[a-zA-Z0-9_.-]+/u.exec(value)?.[0]; - // Accept only a match covering the entire input, never a truncated name. - if (component !== value || component === '.' || component === '..') { - throw new Error(`Invalid ${name}: expected a safe filename component`); - } - return component; -} - -const PRIOR_KNOWLEDGE_CONFIG = { - /** Look back 48 hours for related sessions - balances recency with having enough data */ - windowHours: 48, - /** Limit to 5 related sessions to keep context focused and token-efficient */ - maxRelatedSessions: 5, - /** Maximum 10 similar steps to include - prevents overwhelming the context */ - maxSimilarSteps: 10, - /** Limit to 5 suggested actions to keep recommendations focused and actionable */ - maxSuggestedActions: 5, - /** Maximum 5 items in avoid list to prevent excessive warnings */ - maxAvoid: 5, - /** Require at least 2 failures before suggesting avoidance - prevents false positives */ - minAvoidFailureCount: 2, -} as const; - -const SCAN_LIMITS = { - /** Scan up to 20 sessions to balance thoroughness with performance */ - maxSessionsToScan: 20, - /** Limit to 500 steps per session to avoid memory issues with large sessions */ - maxStepsPerSession: 500, - /** Hard limit of 2000 total steps across all sessions to prevent excessive scanning */ - maxTotalSteps: 2000, -} as const; - -const SIMILARITY_WEIGHTS = { - /** Highest weight - same screen is the strongest indicator of relevance */ - sameScreen: 8, - /** URL path overlap indicates similar context/feature area */ - urlPathOverlap: 6, - /** Test ID overlap shows similar UI elements are present */ - testIdOverlap: 3, - /** Accessibility node overlap provides additional context matching */ - a11yOverlap: 2, - /** Actionable tools (click, type, etc.) are more relevant than discovery tools */ - actionableTool: 2, -} as const; - -const MAX_SIMILARITY_SCORE = - SIMILARITY_WEIGHTS.sameScreen + - SIMILARITY_WEIGHTS.urlPathOverlap + - SIMILARITY_WEIGHTS.testIdOverlap * 3 + - SIMILARITY_WEIGHTS.a11yOverlap * 2 + - SIMILARITY_WEIGHTS.actionableTool; - -/** - * Configuration options for KnowledgeStore initialization - */ -export type KnowledgeStoreConfig = { - /** - * Root directory for storing knowledge artifacts - */ - rootDir?: string; - /** - * Prefix for session IDs (default: 'mm-') - */ - sessionIdPrefix?: string; -}; - -/** - * Extract path tokens from URL hash fragment - * - * @param url - The URL to extract tokens from - * @returns Array of path tokens from the hash fragment - */ -function extractPathTokens(url: string): string[] { - try { - const hashPart = url.split('#')[1] ?? ''; - return hashPart - .split('/') - .filter((t) => t.length > 0 && !t.startsWith('0x')); - } catch (error) { - debugWarn('knowledge-store.extractPathTokens', error); - return []; - } -} - -/** - * Persistent cross-session knowledge store for recording and querying tool invocations. - */ -export class KnowledgeStore { - readonly #knowledgeRoot: string; - - readonly #sessionIdPrefix: string; - - readonly #sessionMetadataCache: Map = - new Map(); - - readonly #actionableTools: string[]; - - readonly #toolActionMap: Record< - string, - PriorKnowledgeSuggestedAction['action'] - >; - - readonly #discoveryTools: string[]; - - /** - * Initialize KnowledgeStore with optional configuration - * - * @param config - Configuration options for the store - */ - constructor(config: KnowledgeStoreConfig = {}) { - this.#knowledgeRoot = - config.rootDir ?? path.join(process.cwd(), KNOWLEDGE_ROOT); - this.#sessionIdPrefix = config.sessionIdPrefix ?? 'mm-'; - - this.#actionableTools = [ - 'click', - 'type', - 'wait_for', - 'navigate', - 'wait_for_notification', - ]; - - this.#toolActionMap = { - click: 'click', - type: 'type', - wait_for: 'wait_for', - navigate: 'navigate', - wait_for_notification: 'wait_for_notification', - }; - - this.#discoveryTools = [ - 'describe_screen', - 'list_testids', - 'accessibility_snapshot', - 'get_state', - ]; - } - - /** - * Write session metadata to disk - * - * @param metadata - Session metadata to persist - * @returns Path to the written metadata file - */ - async writeSessionMetadata(metadata: SessionMetadata): Promise { - const sessionDir = path.join(this.#knowledgeRoot, metadata.sessionId); - await fs.mkdir(sessionDir, { recursive: true }); - - const filepath = path.join(sessionDir, 'session.json'); - await fs.writeFile(filepath, JSON.stringify(metadata, null, 2)); - - this.#sessionMetadataCache.set(metadata.sessionId, metadata); - - return filepath; - } - - /** - * Read session metadata from disk (private) - * - * @param sessionId - Session ID to read metadata for - * @returns Session metadata or null if not found - */ - async #readSessionMetadata( - sessionId: string, - ): Promise { - if (this.#sessionMetadataCache.has(sessionId)) { - return this.#sessionMetadataCache.get(sessionId) ?? null; - } - - const filepath = path.join(this.#knowledgeRoot, sessionId, 'session.json'); - - try { - const content = await fs.readFile(filepath, 'utf-8'); - const metadata = JSON.parse(content) as SessionMetadata; - this.#sessionMetadataCache.set(sessionId, metadata); - return metadata; - } catch (error) { - debugWarn('knowledge-store.readSessionMetadata', error); - this.#sessionMetadataCache.set(sessionId, null); - return null; - } - } - - /** - * List sessions with optional filtering and limit - * - * @param limit - Maximum number of sessions to return - * @param filters - Optional filters for sessions - * @returns Array of session summaries - */ - async listSessions( - limit: number, - filters?: KnowledgeFilters, - ): Promise { - const sessionIds = await this.getAllSessionIds(); - const sessions: { - metadata: SessionMetadata; - createdAt: Date; - }[] = []; - - for (const sid of sessionIds) { - const metadata = await this.#readSessionMetadata(sid); - if (!metadata) { - continue; - } - - if (!this.#matchesFilters(metadata, filters)) { - continue; - } - - sessions.push({ - metadata, - createdAt: new Date(metadata.createdAt), - }); - } - - sessions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - return sessions.slice(0, limit).map((s) => ({ - sessionId: s.metadata.sessionId, - createdAt: s.metadata.createdAt, - goal: s.metadata.goal, - flowTags: s.metadata.flowTags, - tags: s.metadata.tags, - })); - } - - /** - * Check if session metadata matches the given filters - * - * @param metadata - Session metadata to check - * @param filters - Filters to apply - * @returns True if metadata matches all filters - */ - #matchesFilters( - metadata: SessionMetadata, - filters?: KnowledgeFilters, - ): boolean { - if (!filters) { - return true; - } - - if (filters.flowTag && !metadata.flowTags.includes(filters.flowTag)) { - return false; - } - - if (filters.tag && !metadata.tags.includes(filters.tag)) { - return false; - } - - if (filters.sinceHours) { - const cutoff = Date.now() - filters.sinceHours * 60 * 60 * 1000; - const createdAt = new Date(metadata.createdAt).getTime(); - if (createdAt < cutoff) { - return false; - } - } - - return true; - } - - /** - * Resolve session IDs based on scope and filters - * - * @param scope - Scope for session resolution (current, all, or specific sessionId) - * @param currentSessionId - Current session ID for scope resolution - * @param filters - Optional filters to apply to sessions - * @returns Array of resolved session IDs - */ - async resolveSessionIds( - scope: KnowledgeScope, - currentSessionId: string | undefined, - filters?: KnowledgeFilters, - ): Promise { - if (scope === 'current') { - return currentSessionId ? [currentSessionId] : []; - } - - if (typeof scope === 'object' && 'sessionId' in scope) { - return [scope.sessionId]; - } - - const allIds = await this.getAllSessionIds(); - - if (!filters) { - return allIds; - } - - const filtered: string[] = []; - for (const sid of allIds) { - const metadata = await this.#readSessionMetadata(sid); - if (metadata && this.#matchesFilters(metadata, filters)) { - filtered.push(sid); - } else if (!metadata) { - filtered.push(sid); - } - } - - return filtered; - } - - /** - * Record a tool execution step with context and artifacts - * - * @param params - Step recording parameters - * @param params.sessionId - Session ID for this step - * @param params.toolName - Name of the tool executed - * @param params.input - Tool input parameters - * @param params.target - Target element information - * @param params.outcome - Execution outcome (success/failure) - * @param params.observation - Observed state after execution - * @param params.durationMs - Execution duration in milliseconds - * @param params.screenshotPath - Path to screenshot artifact - * @param params.screenshotDimensions - Screenshot dimensions - * @param params.screenshotDimensions.width - Screenshot width in pixels - * @param params.screenshotDimensions.height - Screenshot height in pixels - * @param params.context - Execution context (e2e or prod) - * @returns Path to the recorded step file - * @throws If sessionId or toolName is empty, is '.' or '..', or contains - * characters other than letters, digits, dots, underscores, or hyphens - */ - async recordStep(params: { - sessionId: string; - toolName: string; - input?: Record; - target?: StepRecordTool['target']; - outcome: StepRecordOutcome; - observation: StepRecordObservation; - durationMs?: number; - screenshotPath?: string; - screenshotDimensions?: { - /** - * Screenshot width in pixels - */ - width: number; - /** - * Screenshot height in pixels - */ - height: number; - }; - context?: 'e2e' | 'prod'; - }): Promise { - const sessionId = parseRecordingComponent(params.sessionId, 'sessionId'); - const toolName = parseRecordingComponent(params.toolName, 'toolName'); - - const timestamp = new Date(); - const filesafeTimestamp = generateFilesafeTimestamp(timestamp); - - const sessionDir = path.join(this.#knowledgeRoot, sessionId); - const stepsDir = path.join(sessionDir, 'steps'); - await fs.mkdir(stepsDir, { recursive: true }); - - const sanitizedInput = this.#sanitizeInput(toolName, params.input); - const labels = this.#computeLabels(toolName, params.target, params.outcome); - - const stepRecord: StepRecord = { - schemaVersion: SCHEMA_VERSION, - timestamp: timestamp.toISOString(), - sessionId, - context: params.context, - environment: this.#getEnvironmentInfo(), - tool: { - name: toolName, - input: sanitizedInput.input, - target: params.target, - textRedacted: sanitizedInput.textRedacted, - textLength: sanitizedInput.textLength, - }, - timing: { - durationMs: params.durationMs, - }, - outcome: params.outcome, - observation: params.observation, - labels, - }; - - if (params.screenshotPath) { - stepRecord.artifacts = { - screenshot: { - path: params.screenshotPath, - width: params.screenshotDimensions?.width, - height: params.screenshotDimensions?.height, - }, - }; - } - - const filename = `${filesafeTimestamp}-${toolName}.json`; - const filepath = path.join(stepsDir, filename); - - await fs.writeFile( - filepath, - JSON.stringify( - redactRecordedSecrets(stepRecord, sanitizedInput.redactedValues), - null, - 2, - ), - ); - - return filepath; - } - - /** - * Compute labels for a step based on tool and outcome - * - * @param toolName - Name of the tool executed - * @param target - Target element information - * @param outcome - Execution outcome - * @returns Array of labels describing the step - */ - #computeLabels( - toolName: string, - target?: StepRecordTool['target'], - outcome?: StepRecordOutcome, - ): string[] { - const labels: string[] = []; - - const navigationTools = ['navigate', 'wait_for_notification']; - const interactionTools = ['click', 'type', 'wait_for']; - - if (this.#discoveryTools.includes(toolName)) { - labels.push('discovery'); - } else if (navigationTools.includes(toolName)) { - labels.push('navigation'); - } else if (interactionTools.includes(toolName)) { - labels.push('interaction'); - - const targetStr = JSON.stringify(target ?? {}).toLowerCase(); - if ( - targetStr.includes('confirm') || - targetStr.includes('approve') || - targetStr.includes('submit') - ) { - labels.push('confirmation'); - } - } - - if (outcome && !outcome.ok) { - labels.push('error-recovery'); - } - - return labels; - } - - /** - * Get the last N steps from the knowledge store - * - * @param n - Number of steps to retrieve - * @param scope - Scope for step retrieval (current, all, or specific sessionId) - * @param currentSessionId - Current session ID for scope resolution - * @param filters - Optional filters to apply to steps - * @returns Array of step summaries - */ - async getLastSteps( - n: number, - scope: KnowledgeScope, - currentSessionId: string | undefined, - filters?: KnowledgeFilters, - ): Promise { - const sessionIds = await this.resolveSessionIds( - scope, - currentSessionId, - filters, - ); - - const allSteps: { - /** - * The step record - */ - step: StepRecord; - /** - * Path to the step file - */ - filepath: string; - }[] = []; - - for (const sid of sessionIds) { - const steps = await this.#loadSessionSteps(sid); - for (const s of steps) { - if (this.#stepMatchesFilters(s.step, filters)) { - allSteps.push(s); - } - } - } - - allSteps.sort( - (a, b) => - new Date(b.step.timestamp).getTime() - - new Date(a.step.timestamp).getTime(), - ); - - return allSteps.slice(0, n).map((item) => this.#summarizeStep(item.step)); - } - - /** - * Search steps by query across sessions - * - * @param query - Search query string - * @param limit - Maximum number of results to return - * @param scope - Scope for search (current, all, or specific sessionId) - * @param currentSessionId - Current session ID for scope resolution - * @param filters - Optional filters to apply to steps - * @returns Array of matching step summaries - */ - async searchSteps( - query: string, - limit: number, - scope: KnowledgeScope, - currentSessionId: string | undefined, - filters?: KnowledgeFilters, - ): Promise { - const queryTokens = tokenize(query); - if (queryTokens.length === 0) { - return []; - } - - const expandedTokens = expandWithSynonyms(queryTokens); - - const sessionIds = await this.resolveSessionIds( - scope, - currentSessionId, - filters, - ); - - type ScoredSession = { - sessionId: string; - score: number; - metadata?: SessionMetadata; - }; - const scoredSessions: ScoredSession[] = []; - - for (const sid of sessionIds) { - const metadata = await this.#readSessionMetadata(sid); - const sessionScore = metadata - ? this.#computeSessionScore(metadata, expandedTokens) - : 0; - scoredSessions.push({ - sessionId: sid, - score: sessionScore, - metadata: metadata ?? undefined, - }); - } - - scoredSessions.sort((a, b) => { - if (b.score !== a.score) { - return b.score - a.score; - } - const aTime = a.metadata?.createdAt - ? new Date(a.metadata.createdAt).getTime() - : 0; - const bTime = b.metadata?.createdAt - ? new Date(b.metadata.createdAt).getTime() - : 0; - if (bTime !== aTime) { - return bTime - aTime; - } - return a.sessionId.localeCompare(b.sessionId); - }); - const topSessions = scoredSessions.slice(0, SCAN_LIMITS.maxSessionsToScan); - - type StepMatch = { - step: StepRecord; - score: number; - sessionScore: number; - sessionGoal?: string; - matchedFields: string[]; - }; - const matches: StepMatch[] = []; - let totalStepsScanned = 0; - - for (const { - sessionId: sid, - score: sessionScore, - metadata, - } of topSessions) { - if (totalStepsScanned >= SCAN_LIMITS.maxTotalSteps) { - break; - } - - const steps = await this.#loadSessionSteps(sid); - const limitedSteps = steps.slice(0, SCAN_LIMITS.maxStepsPerSession); - totalStepsScanned += limitedSteps.length; - - for (const { step } of limitedSteps) { - if (!this.#stepMatchesFilters(step, filters)) { - continue; - } - - const { score: stepScore, matchedFields } = this.#computeSearchScore( - step, - expandedTokens, - ); - - const combinedScore = sessionScore + stepScore; - - if (combinedScore > 0) { - matches.push({ - step, - score: combinedScore, - sessionScore, - sessionGoal: metadata?.goal, - matchedFields, - }); - } - } - } - - matches.sort((a, b) => b.score - a.score); - - return matches - .slice(0, limit) - .map((m) => this.#summarizeStep(m.step, m.matchedFields, m.sessionGoal)); - } - - /** - * Check if a step matches the given filters - * - * @param step - Step record to check - * @param filters - Filters to apply - * @returns True if step matches all filters - */ - #stepMatchesFilters(step: StepRecord, filters?: KnowledgeFilters): boolean { - if (!filters) { - return true; - } - - if ( - filters.screen && - step.observation?.state?.currentScreen !== filters.screen - ) { - return false; - } - - return true; - } - - /** - * Generate a recipe summary of steps in a session - * - * @param sessionId - Session ID to summarize - * @returns Session summary with recipe steps - */ - async summarizeSession(sessionId: string): Promise<{ - /** - * Session ID - */ - sessionId: string; - /** - * Total number of steps in session - */ - stepCount: number; - /** - * Recipe steps describing the session flow - */ - recipe: RecipeStep[]; - }> { - const steps = await this.#loadSessionSteps(sessionId); - - steps.sort( - (a, b) => - new Date(a.step.timestamp).getTime() - - new Date(b.step.timestamp).getTime(), - ); - - const recipe: RecipeStep[] = steps.map(({ step }, index) => ({ - stepNumber: index + 1, - tool: step.tool.name, - notes: this.#generateStepNotes(step), - })); - - return { - sessionId, - stepCount: steps.length, - recipe, - }; - } - - /** - * Get all session IDs from the knowledge store - * - * @returns Array of all session IDs - */ - async getAllSessionIds(): Promise { - try { - const entries = await fs.readdir(this.#knowledgeRoot, { - withFileTypes: true, - }); - return entries - .filter( - (e) => - e.isDirectory() && - (e.name.startsWith('mm-') || - e.name.startsWith(this.#sessionIdPrefix)), - ) - .map((e) => e.name); - } catch (error) { - debugWarn('knowledge-store.getAllSessionIds', error); - return []; - } - } - - /** - * Load all step records from a session - * - * @param sessionId - Session ID to load steps from - * @returns Array of step records with file paths - */ - async #loadSessionSteps(sessionId: string): Promise< - { - /** - * The step record - */ - step: StepRecord; - /** - * Path to the step file - */ - filepath: string; - }[] - > { - const stepsDir = path.join(this.#knowledgeRoot, sessionId, 'steps'); - - try { - const files = await fs.readdir(stepsDir); - const jsonFiles = files.filter((f) => f.endsWith('.json')); - - const steps: { - /** - * The step record - */ - step: StepRecord; - /** - * Path to the step file - */ - filepath: string; - }[] = []; - - for (const file of jsonFiles) { - const filepath = path.join(stepsDir, file); - try { - const content = await fs.readFile(filepath, 'utf-8'); - const step = JSON.parse(content) as StepRecord; - steps.push({ step, filepath }); - } catch (error) { - debugWarn('knowledge-store.loadSessionSteps', error); - continue; - } - } - - return steps; - } catch (error) { - debugWarn('knowledge-store.loadSessionSteps', error); - return []; - } - } - - /** - * Sanitize tool input by redacting sensitive fields - * - * @param toolName - Name of the tool - * @param input - Input parameters to sanitize - * @returns Sanitized input with redaction metadata - */ - #sanitizeInput( - toolName: string, - input?: Record, - ): { - /** - * Sanitized input parameters - */ - input?: Record; - /** - * Whether text was redacted - */ - textRedacted?: boolean; - /** - * Length of redacted text - */ - textLength?: number; - /** - * Original values to scrub if echoed elsewhere in the step - */ - redactedValues: unknown[]; - } { - if (!input) { - return { redactedValues: [] }; - } - - const sanitized: Record = {}; - let textRedacted = false; - let textLength: number | undefined; - - const redactedValues: unknown[] = []; - - for (const [key, value] of Object.entries(input)) { - if ( - (['clipboard', 'device_clipboard', 'device-clipboard'].includes( - toolName, - ) && - key === 'text') || - (toolName === 'cdp' && key === 'params') - ) { - sanitized[key] = '[REDACTED]'; - redactedValues.push(value); - textRedacted = true; - if (typeof value === 'string') { - textLength = value.length; - } - } else if ( - toolName === 'run_steps' && - key === 'steps' && - Array.isArray(value) - ) { - const steps: unknown[] = []; - for (const step of value as unknown[]) { - const { tool, args } = (step ?? {}) as { - tool?: unknown; - args?: unknown; - }; - if (typeof tool !== 'string' || typeof args !== 'object') { - steps.push(step); - continue; - } - const stepSanitized = this.#sanitizeInput( - tool, - args as Record | undefined, - ); - redactedValues.push(...stepSanitized.redactedValues); - textRedacted ||= stepSanitized.textRedacted === true; - steps.push({ ...(step as object), args: stepSanitized.input }); - } - sanitized[key] = steps; - } else if (toolName === 'type' && key === 'text') { - const textValue = String(value); - const targetTestId = input.testId as string | undefined; - const targetSelector = input.selector as string | undefined; - - const isSensitive = - isSensitiveField(targetTestId ?? '') || - isSensitiveField(targetSelector ?? ''); - - if (isSensitive) { - textRedacted = true; - textLength = textValue.length; - sanitized[key] = '[REDACTED]'; - redactedValues.push(value); - } else { - sanitized[key] = value; - } - } else if (isSensitiveField(key)) { - sanitized[key] = '[REDACTED]'; - redactedValues.push(value); - } else { - sanitized[key] = value; - } - } - - return { - input: sanitized, - textRedacted: textRedacted || undefined, - textLength, - redactedValues, - }; - } - - /** - * Get environment information - * - * @returns Environment details (platform and Node version) - */ - #getEnvironmentInfo(): { - /** - * Operating system platform - */ - platform: string; - /** - * Node.js version - */ - nodeVersion: string; - } { - return { - platform: process.platform, - nodeVersion: process.version, - }; - } - - /** - * Create a summary of a step record - * - * @param step - Step record to summarize - * @param matchedFields - Fields that matched the search query - * @param sessionGoal - Goal of the session containing this step - * @returns Step summary for display - */ - #summarizeStep( - step: StepRecord, - matchedFields?: string[], - sessionGoal?: string, - ): KnowledgeStepSummary { - const screen = step.observation?.state?.currentScreen ?? 'unknown'; - const snippet = this.#generateSnippet(step, matchedFields); - - return { - timestamp: step.timestamp, - tool: step.tool.name, - screen, - snippet, - sessionId: step.sessionId, - matchedFields: matchedFields?.length ? matchedFields : undefined, - sessionGoal, - }; - } - - /** - * Generate a human-readable snippet from a step - * - * @param step - Step record to generate snippet from - * @param matchedFields - Fields that matched the search query - * @returns Human-readable snippet string - */ - #generateSnippet(step: StepRecord, matchedFields?: string[]): string { - const parts: string[] = []; - - if (matchedFields && matchedFields.length > 0) { - const topMatches = matchedFields.slice(0, 3).join(', '); - parts.push(`match: ${topMatches}`); - } - - if (step.tool.target?.testId) { - parts.push(`testId: ${step.tool.target.testId}`); - } else if (step.tool.target?.a11yRef) { - parts.push(`ref: ${step.tool.target.a11yRef}`); - } else if (step.tool.target?.selector) { - const shortSelector = step.tool.target.selector.substring(0, 30); - parts.push(`selector: ${shortSelector}`); - } - - if (step.labels && step.labels.length > 0) { - parts.push(`labels: ${step.labels.join(', ')}`); - } - - if (step.observation?.state?.currentScreen) { - parts.push(`screen: ${step.observation.state.currentScreen}`); - } - - if (!step.outcome.ok && step.outcome.error) { - parts.push(`error: ${step.outcome.error.code}`); - } - - return parts.join(', ') || step.tool.name; - } - - /** - * Generate notes describing a step - * - * @param step - Step record to generate notes from - * @returns Notes string describing the step - */ - #generateStepNotes(step: StepRecord): string { - const notes: string[] = []; - - if (step.tool.target?.testId) { - notes.push(`target: [data-testid="${step.tool.target.testId}"]`); - } else if (step.tool.target?.a11yRef) { - notes.push(`target: ${step.tool.target.a11yRef}`); - } - - if (step.observation?.state?.currentScreen) { - notes.push(`on screen: ${step.observation.state.currentScreen}`); - } - - if (!step.outcome.ok && step.outcome.error) { - notes.push(`FAILED: ${step.outcome.error.message}`); - } - - if (step.artifacts?.screenshot?.path) { - notes.push('screenshot captured'); - } - - return notes.join('; ') || 'executed'; - } - - /** - * Compute relevance score for a session based on query tokens - * - * @param metadata - Session metadata to score - * @param queryTokens - Tokens from the search query - * @returns Relevance score for the session - */ - #computeSessionScore( - metadata: SessionMetadata, - queryTokens: string[], - ): number { - let score = 0; - - for (const token of queryTokens) { - for (const flowTag of metadata.flowTags) { - if (flowTag.toLowerCase().includes(token)) { - score += 12; - break; - } - } - } - - const goalTokens = tokenize(metadata.goal ?? ''); - for (const token of queryTokens) { - if (goalTokens.includes(token)) { - score += 6; - } - } - - for (const token of queryTokens) { - for (const tag of metadata.tags) { - if (tag.toLowerCase().includes(token)) { - score += 4; - break; - } - } - } - - const ageHours = - (Date.now() - new Date(metadata.createdAt).getTime()) / (1000 * 60 * 60); - if (ageHours < 24) { - score += 3; - } else if (ageHours < 72) { - score += 1; - } - - return score; - } - - /** - * Compute relevance score for a step based on query tokens - * - * @param step - Step record to score - * @param queryTokens - Tokens from the search query - * @returns Score and matched field names - */ - #computeSearchScore( - step: StepRecord, - queryTokens: string[], - ): { - /** - * Relevance score for the step - */ - score: number; - /** - * Fields that matched the query - */ - matchedFields: string[]; - } { - let score = 0; - const matchedFieldsSet = new Set(); - let matchedTokens = 0; - - const targetTestIdTokens = step.tool.target?.testId - ? tokenizeIdentifier(step.tool.target.testId) - : []; - - const observedTestIdTokensMap = new Map(); - for (const testIdItem of step.observation?.testIds ?? []) { - observedTestIdTokensMap.set( - testIdItem.testId, - tokenizeIdentifier(testIdItem.testId), - ); - } - - for (const token of queryTokens) { - let tokenMatched = false; - - if (step.tool.name.toLowerCase().includes(token)) { - score += 10; - matchedFieldsSet.add(`tool:${step.tool.name}`); - tokenMatched = true; - } - - const screen = step.observation?.state?.currentScreen; - if (screen?.toLowerCase().includes(token)) { - score += 8; - matchedFieldsSet.add(`screen:${screen}`); - tokenMatched = true; - } - - if (step.tool.target?.testId && targetTestIdTokens.includes(token)) { - score += 6; - matchedFieldsSet.add(`testId:${step.tool.target.testId}`); - tokenMatched = true; - } - - for (const label of step.labels ?? []) { - if (label.toLowerCase().includes(token)) { - score += 5; - matchedFieldsSet.add(`label:${label}`); - tokenMatched = true; - break; - } - } - - for (const [testId, tokens] of observedTestIdTokensMap) { - if (tokens.includes(token)) { - score += 3; - matchedFieldsSet.add(`testId:${testId}`); - tokenMatched = true; - break; - } - } - - for (const node of step.observation?.a11y?.nodes ?? []) { - if (node.name.toLowerCase().includes(token)) { - score += 2; - matchedFieldsSet.add(`a11y:${node.role}:"${node.name}"`); - tokenMatched = true; - break; - } - if (node.role.toLowerCase().includes(token)) { - score += 2; - matchedFieldsSet.add(`a11y:${node.role}`); - tokenMatched = true; - break; - } - } - - if (tokenMatched) { - matchedTokens += 1; - } - } - - if (queryTokens.length > 0) { - const coverageRatio = matchedTokens / queryTokens.length; - score += Math.floor(coverageRatio * 5); - } - - return { score, matchedFields: [...matchedFieldsSet] }; - } - - /** - * Generate prior knowledge from historical sessions - * - * @param context - Current context for knowledge generation - * @param currentSessionId - Current session ID to exclude from results - * @returns Prior knowledge object or undefined if no relevant data found - */ - async generatePriorKnowledge( - context: PriorKnowledgeContext, - currentSessionId?: string, - ): Promise { - const { windowHours } = PRIOR_KNOWLEDGE_CONFIG; - - const filters: KnowledgeFilters = { - sinceHours: windowHours, - }; - - if (context.currentSessionFlowTags?.length) { - filters.flowTag = context.currentSessionFlowTags[0]; - } - - const sessionIds = await this.resolveSessionIds('all', undefined, filters); - const candidateSessionIds = sessionIds.filter( - (sid) => sid !== currentSessionId, - ); - - if (candidateSessionIds.length === 0) { - return undefined; - } - - const relatedSessions = await this.#getRelatedSessions( - candidateSessionIds, - filters, - PRIOR_KNOWLEDGE_CONFIG.maxRelatedSessions, - ); - - const { similarSteps, candidateStepCount } = await this.#getSimilarSteps( - context, - candidateSessionIds, - filters, - ); - - const suggestedNextActions = this.#buildSuggestedActions( - similarSteps, - context, - ); - - const avoidList = await this.#buildAvoidList(context, candidateSessionIds); - - if ( - relatedSessions.length === 0 && - similarSteps.length === 0 && - suggestedNextActions.length === 0 - ) { - return undefined; - } - - return { - schemaVersion: 1, - generatedAt: new Date().toISOString(), - query: { - windowHours, - usedFlowTags: context.currentSessionFlowTags ?? [], - usedFilters: filters, - candidateSessions: candidateSessionIds.length, - candidateSteps: candidateStepCount, - }, - relatedSessions, - similarSteps: similarSteps.slice( - 0, - PRIOR_KNOWLEDGE_CONFIG.maxSimilarSteps, - ), - suggestedNextActions: suggestedNextActions.slice( - 0, - PRIOR_KNOWLEDGE_CONFIG.maxSuggestedActions, - ), - avoid: - avoidList.length > 0 - ? avoidList.slice(0, PRIOR_KNOWLEDGE_CONFIG.maxAvoid) - : undefined, - }; - } - - /** - * Get related sessions based on filters - * - * @param sessionIds - Session IDs to filter - * @param filters - Filters to apply - * @param limit - Maximum number of sessions to return - * @returns Array of related session summaries - */ - async #getRelatedSessions( - sessionIds: string[], - filters: KnowledgeFilters, - limit: number, - ): Promise { - const sessions: PriorKnowledgeRelatedSession[] = []; - - for (const sid of sessionIds) { - if (sessions.length >= limit) { - break; - } - - const metadata = await this.#readSessionMetadata(sid); - if (!metadata) { - continue; - } - - if (!this.#matchesFilters(metadata, filters)) { - continue; - } - - sessions.push({ - sessionId: metadata.sessionId, - createdAt: metadata.createdAt, - goal: metadata.goal, - flowTags: metadata.flowTags, - tags: metadata.tags, - }); - } - - return sessions; - } - - /** - * Find similar steps from historical sessions - * - * @param context - Current context for similarity matching - * @param sessionIds - Session IDs to search - * @param filters - Filters to apply to steps - * @returns Similar steps and total candidate count - */ - async #getSimilarSteps( - context: PriorKnowledgeContext, - sessionIds: string[], - filters: KnowledgeFilters, - ): Promise<{ - /** - * Array of similar steps found - */ - similarSteps: PriorKnowledgeSimilarStep[]; - /** - * Total number of candidate steps scanned - */ - candidateStepCount: number; - }> { - const scoredSteps: { - /** - * The step record - */ - step: StepRecord; - /** - * Similarity score for the step - */ - score: number; - }[] = []; - let candidateStepCount = 0; - - const visibleTestIdSet = new Set( - context.visibleTestIds.map((t) => t.testId), - ); - const visibleA11yNames = new Set( - context.a11yNodes.map((n) => n.name.toLowerCase()), - ); - - const limitedSessionIds = sessionIds.slice( - 0, - SCAN_LIMITS.maxSessionsToScan, - ); - - for (const sid of limitedSessionIds) { - if (candidateStepCount >= SCAN_LIMITS.maxTotalSteps) { - break; - } - - const steps = await this.#loadSessionSteps(sid); - const limitedSteps = steps.slice(0, SCAN_LIMITS.maxStepsPerSession); - - for (const { step } of limitedSteps) { - candidateStepCount += 1; - - if (!this.#stepMatchesFilters(step, filters)) { - continue; - } - - if (this.#discoveryTools.includes(step.tool.name)) { - continue; - } - - const score = this.#computeSimilarityScore( - step, - context, - visibleTestIdSet, - visibleA11yNames, - ); - - if (score > 0) { - scoredSteps.push({ step, score }); - } - } - } - - scoredSteps.sort((a, b) => b.score - a.score); - - const similarSteps: PriorKnowledgeSimilarStep[] = scoredSteps - .slice(0, PRIOR_KNOWLEDGE_CONFIG.maxSimilarSteps) - .map(({ step, score }) => { - const a11yHint = this.#lookupA11yHint(step); - - return { - sessionId: step.sessionId, - timestamp: step.timestamp, - tool: step.tool.name, - screen: step.observation?.state?.currentScreen ?? 'unknown', - snippet: this.#generateSnippet(step), - labels: step.labels, - target: step.tool.target - ? { - testId: step.tool.target.testId, - selector: step.tool.target.selector, - } - : undefined, - a11yHint, - confidence: Math.min(score / MAX_SIMILARITY_SCORE, 1), - }; - }); - - return { similarSteps, candidateStepCount }; - } - - /** - * Look up accessibility hint for a step's target - * - * @param step - Step record to look up hint for - * @returns Accessibility hint with role and name, or undefined - */ - #lookupA11yHint(step: StepRecord): - | { - /** - * ARIA role of the element - */ - role: string; - /** - * Accessible name of the element - */ - name: string; - } - | undefined { - const a11yRef = step.tool.target?.a11yRef; - if (!a11yRef) { - return undefined; - } - - const nodes = step.observation?.a11y?.nodes ?? []; - const matchingNode = nodes.find((node) => node.ref === a11yRef); - if (!matchingNode?.name) { - return undefined; - } - - return { role: matchingNode.role, name: matchingNode.name }; - } - - /** - * Compute similarity score between a step and current context - * - * @param step - Step record to score - * @param context - Current context for comparison - * @param visibleTestIdSet - Set of visible test IDs in current context - * @param visibleA11yNames - Set of visible accessibility names in current context - * @returns Similarity score - */ - #computeSimilarityScore( - step: StepRecord, - context: PriorKnowledgeContext, - visibleTestIdSet: Set, - visibleA11yNames: Set, - ): number { - let score = 0; - - const stepScreen = step.observation?.state?.currentScreen; - const contextScreen = context.currentScreen; - - if (stepScreen === contextScreen && stepScreen !== 'unknown') { - score += SIMILARITY_WEIGHTS.sameScreen; - } - - if (context.currentUrl && step.observation?.state) { - const currentPathTokens = extractPathTokens(context.currentUrl); - const stepUrl = step.observation.state.currentUrl ?? ''; - const stepPathTokens = extractPathTokens(stepUrl); - - for (const token of currentPathTokens) { - if (stepPathTokens.includes(token)) { - score += SIMILARITY_WEIGHTS.urlPathOverlap; - break; - } - } - } - - let testIdOverlapCount = 0; - for (const testId of step.observation?.testIds ?? []) { - if (visibleTestIdSet.has(testId.testId)) { - testIdOverlapCount += 1; - if (testIdOverlapCount >= 3) { - break; - } - } - } - score += Math.min(testIdOverlapCount, 3) * SIMILARITY_WEIGHTS.testIdOverlap; - - let a11yOverlapCount = 0; - for (const node of step.observation?.a11y?.nodes ?? []) { - if (visibleA11yNames.has(node.name.toLowerCase())) { - a11yOverlapCount += 1; - if (a11yOverlapCount >= 2) { - break; - } - } - } - score += Math.min(a11yOverlapCount, 2) * SIMILARITY_WEIGHTS.a11yOverlap; - - if (this.#actionableTools.includes(step.tool.name)) { - score += SIMILARITY_WEIGHTS.actionableTool; - } - - return score; - } - - /** - * Build suggested actions from similar steps - * - * @param similarSteps - Similar steps to analyze - * @param context - Current context for action building - * @returns Array of suggested actions - */ - #buildSuggestedActions( - similarSteps: PriorKnowledgeSimilarStep[], - context: PriorKnowledgeContext, - ): PriorKnowledgeSuggestedAction[] { - const visibleTestIdSet = new Set( - context.visibleTestIds.map((t) => t.testId), - ); - const visibleA11yMap = new Map( - context.a11yNodes.map((n) => [n.name.toLowerCase(), n]), - ); - - const actionCounts = new Map< - string, - { - step: PriorKnowledgeSimilarStep; - count: number; - confidenceSum: number; - } - >(); - - for (const step of similarSteps) { - if (!step.target?.testId && !step.target?.selector) { - continue; - } - - const key = step.target.testId ?? step.target.selector ?? ''; - - const existing = actionCounts.get(key); - if (existing) { - existing.count += 1; - existing.confidenceSum += step.confidence; - } else { - actionCounts.set(key, { - step, - count: 1, - confidenceSum: step.confidence, - }); - } - } - - const suggestions: PriorKnowledgeSuggestedAction[] = []; - - const sortedActions = Array.from(actionCounts.entries()).sort( - ([, a], [, b]) => b.confidenceSum - a.confidenceSum, - ); - - for (const [, { step, count, confidenceSum }] of sortedActions) { - if (suggestions.length >= PRIOR_KNOWLEDGE_CONFIG.maxSuggestedActions) { - break; - } - - const preferredTarget = this.#buildPreferredTarget( - step, - visibleTestIdSet, - ); - if (!preferredTarget) { - continue; - } - - const fallbackTargets = this.#buildFallbackTargets( - preferredTarget, - visibleA11yMap, - ); - - const action = this.#toolToAction(step.tool); - if (!action) { - continue; - } - - suggestions.push({ - rank: suggestions.length + 1, - action, - rationale: - count > 1 - ? `Used ${count} times successfully on this screen` - : 'Most common next successful step on this screen', - confidence: Math.min(confidenceSum / count, 1), - preferredTarget, - fallbackTargets: - fallbackTargets.length > 0 ? fallbackTargets : undefined, - }); - } - - return suggestions; - } - - /** - * Build preferred target from a prior step - * - * @param priorStep - Prior step to extract target from - * @param visibleTestIdSet - Set of visible test IDs in current context - * @returns Preferred target or null if none found - */ - #buildPreferredTarget( - priorStep: PriorKnowledgeSimilarStep, - visibleTestIdSet: Set, - ): PriorKnowledgeTarget | null { - if ( - priorStep.target?.testId && - visibleTestIdSet.has(priorStep.target.testId) - ) { - return { type: 'testId', value: priorStep.target.testId }; - } - - if (priorStep.target?.selector) { - return { type: 'selector', value: priorStep.target.selector }; - } - - if (priorStep.a11yHint) { - return { type: 'a11yHint', value: priorStep.a11yHint }; - } - - return null; - } - - /** - * Build fallback targets for a preferred target - * - * @param preferredTarget - Preferred target to find fallbacks for - * @param visibleA11yMap - Map of visible accessibility nodes - * @returns Array of fallback targets - */ - #buildFallbackTargets( - preferredTarget: PriorKnowledgeTarget, - visibleA11yMap: Map, - ): PriorKnowledgeTarget[] { - const fallbacks: PriorKnowledgeTarget[] = []; - - if (preferredTarget.type === 'testId') { - const testId = preferredTarget.value; - - const entries = Array.from(visibleA11yMap.entries()); - for (const [name, node] of entries) { - if ( - name.includes(testId.replace(/-/gu, ' ').toLowerCase()) || - testId.toLowerCase().includes(name) - ) { - fallbacks.push({ - type: 'a11yHint', - value: { role: node.role, name: node.name }, - }); - break; - } - } - } - - return fallbacks; - } - - /** - * Convert tool name to action type - * - * @param toolName - Tool name to convert - * @returns Action type or null if not found - */ - #toolToAction( - toolName: string, - ): PriorKnowledgeSuggestedAction['action'] | null { - return this.#toolActionMap[toolName] ?? null; - } - - /** - * Build list of actions to avoid based on failure history - * - * @param context - Current context for avoid list building - * @param sessionIds - Session IDs to analyze for failures - * @returns Array of actions to avoid - */ - async #buildAvoidList( - context: PriorKnowledgeContext, - sessionIds: string[], - ): Promise { - const failureCounts = new Map< - string, - { - /** - * Error code from the failure - */ - errorCode?: string; - /** - * CSS selector of the failed target - */ - selector?: string; - /** - * Test ID of the failed target - */ - testId?: string; - /** - * Number of times this target failed - */ - count: number; - } - >(); - - for (const sid of sessionIds) { - const steps = await this.#loadSessionSteps(sid); - - for (const { step } of steps) { - if (step.outcome.ok) { - continue; - } - if (step.observation?.state?.currentScreen !== context.currentScreen) { - continue; - } - - const targetKey = - step.tool.target?.testId ?? step.tool.target?.selector ?? 'unknown'; - - const existing = failureCounts.get(targetKey); - if (existing) { - existing.count += 1; - } else { - failureCounts.set(targetKey, { - errorCode: step.outcome.error?.code, - testId: step.tool.target?.testId, - selector: step.tool.target?.selector, - count: 1, - }); - } - } - } - - const avoidList: PriorKnowledgeAvoid[] = []; - - const failureEntries = Array.from(failureCounts.values()); - for (const failure of failureEntries) { - if (failure.count < PRIOR_KNOWLEDGE_CONFIG.minAvoidFailureCount) { - continue; - } - - avoidList.push({ - rationale: 'Frequently fails due to UI churn', - target: { - testId: failure.testId, - selector: failure.selector, - }, - errorCode: failure.errorCode, - frequency: failure.count, - }); - } - - avoidList.sort((a, b) => b.frequency - a.frequency); - - return avoidList; - } -} - -/** - * Create a default observation object with state and optional artifacts - * - * @param state - Extension state snapshot - * @param testIds - Array of visible test IDs - * @param a11yNodes - Array of accessibility nodes - * @returns Observation object for step recording - */ -export function createDefaultObservation( - state: ExtensionState, - testIds: TestIdItem[] = [], - a11yNodes: A11yNodeTrimmed[] = [], -): StepRecordObservation { - return { - state, - testIds, - a11y: { nodes: a11yNodes }, - }; -} - -/** - * Create a new KnowledgeStore instance - * - * @param config - Optional configuration for the store - * @returns New KnowledgeStore instance - */ -export function createKnowledgeStore( - config?: KnowledgeStoreConfig, -): KnowledgeStore { - return new KnowledgeStore(config); -} - -let _knowledgeStore: KnowledgeStore | undefined; - -/** - * Set the global knowledge store instance - * - * @param store - KnowledgeStore instance to set as global - */ -export function setKnowledgeStore(store: KnowledgeStore): void { - _knowledgeStore = store; -} - -/** - * Get the global knowledge store instance - * - * @returns The global KnowledgeStore instance - */ -export function getKnowledgeStore(): KnowledgeStore { - if (!_knowledgeStore) { - throw new Error( - 'Knowledge store not initialized. Call setKnowledgeStore() first.', - ); - } - return _knowledgeStore; -} - -/** - * Check if a knowledge store has been initialized - * - * @returns True if knowledge store is initialized - */ -export function hasKnowledgeStore(): boolean { - return _knowledgeStore !== undefined; -} - -export const knowledgeStore = { - /** - * Record a tool execution step - * - * @param params - Step recording parameters - * @returns Path to the recorded step file - */ - recordStep: async (params: Parameters[0]) => { - return getKnowledgeStore().recordStep(params); - }, - /** - * Get the last N steps from the knowledge store - * - * @param args - Arguments for getLastSteps - * @returns Array of step summaries - */ - getLastSteps: async (...args: Parameters) => { - return getKnowledgeStore().getLastSteps(...args); - }, - /** - * Search steps by query - * - * @param args - Arguments for searchSteps - * @returns Array of matching step summaries - */ - searchSteps: async (...args: Parameters) => { - return getKnowledgeStore().searchSteps(...args); - }, - /** - * Generate a recipe summary of a session - * - * @param args - Arguments for summarizeSession - * @returns Session summary with recipe steps - */ - summarizeSession: async ( - ...args: Parameters - ) => { - return getKnowledgeStore().summarizeSession(...args); - }, - /** - * List sessions with optional filtering - * - * @param args - Arguments for listSessions - * @returns Array of session summaries - */ - listSessions: async (...args: Parameters) => { - return getKnowledgeStore().listSessions(...args); - }, - /** - * Generate prior knowledge from historical sessions - * - * @param args - Arguments for generatePriorKnowledge - * @returns Prior knowledge object or undefined - */ - generatePriorKnowledge: async ( - ...args: Parameters - ) => { - return getKnowledgeStore().generatePriorKnowledge(...args); - }, - /** - * Write session metadata to disk - * - * @param metadata - Session metadata to persist - * @returns Path to the written metadata file - */ - writeSessionMetadata: async (metadata: SessionMetadata) => { - return getKnowledgeStore().writeSessionMetadata(metadata); - }, -}; diff --git a/src/knowledge-store/tokenization.test.ts b/src/knowledge-store/tokenization.test.ts deleted file mode 100644 index 939ac1c..0000000 --- a/src/knowledge-store/tokenization.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { - tokenize, - tokenizeIdentifier, - expandWithSynonyms, -} from './tokenization.js'; - -describe('tokenization', () => { - describe('tokenize', () => { - it('returns empty array for empty string', () => { - expect(tokenize('')).toStrictEqual([]); - }); - - it('returns empty array for null/undefined', () => { - expect(tokenize(null as unknown as string)).toStrictEqual([]); - expect(tokenize(undefined as unknown as string)).toStrictEqual([]); - }); - - it('converts to lowercase', () => { - const tokens = tokenize('Hello World UPPERCASE'); - expect(tokens).toContain('hello'); - expect(tokens).toContain('world'); - expect(tokens).toContain('uppercase'); - }); - - it('splits on non-alphanumeric characters', () => { - const tokens = tokenize('click-button_send.test'); - expect(tokens).toContain('click'); - expect(tokens).toContain('button'); - expect(tokens).toContain('send'); - }); - - it('filters out stopwords', () => { - const tokens = tokenize('the user is a tester'); - expect(tokens).not.toContain('the'); - expect(tokens).not.toContain('is'); - expect(tokens).not.toContain('a'); - expect(tokens).toContain('user'); - expect(tokens).toContain('tester'); - }); - - it('filters out tokens shorter than MIN_TOKEN_LENGTH', () => { - const tokens = tokenize('a b cd ef'); - expect(tokens).not.toContain('a'); - expect(tokens).not.toContain('b'); - expect(tokens).toContain('cd'); - expect(tokens).toContain('ef'); - }); - - it('removes duplicate tokens', () => { - const tokens = tokenize('click click button button click'); - const clickCount = tokens.filter((t) => t === 'click').length; - const buttonCount = tokens.filter((t) => t === 'button').length; - expect(clickCount).toBe(1); - expect(buttonCount).toBe(1); - }); - - it('handles special tool/extension stopwords', () => { - const tokens = tokenize('mm mcp lw test flow'); - expect(tokens).not.toContain('mm'); - expect(tokens).not.toContain('mcp'); - expect(tokens).not.toContain('lw'); - expect(tokens).not.toContain('test'); - expect(tokens).not.toContain('flow'); - }); - }); - - describe('tokenizeIdentifier', () => { - it('returns empty array for empty string', () => { - expect(tokenizeIdentifier('')).toStrictEqual([]); - }); - - it('returns empty array for null/undefined', () => { - expect(tokenizeIdentifier(null as unknown as string)).toStrictEqual([]); - expect(tokenizeIdentifier(undefined as unknown as string)).toStrictEqual( - [], - ); - }); - - it('splits camelCase identifiers', () => { - const tokens = tokenizeIdentifier('handleButtonClick'); - expect(tokens).toContain('handle'); - expect(tokens).toContain('button'); - expect(tokens).toContain('click'); - }); - - it('splits PascalCase identifiers', () => { - const tokens = tokenizeIdentifier('MetaMaskController'); - expect(tokens).toContain('meta'); - expect(tokens).toContain('mask'); - expect(tokens).toContain('controller'); - }); - - it('splits consecutive uppercase letters correctly', () => { - const tokens = tokenizeIdentifier('XMLHttpRequest'); - expect(tokens).toContain('xml'); - expect(tokens).toContain('http'); - expect(tokens).toContain('request'); - }); - - it('handles snake_case', () => { - const tokens = tokenizeIdentifier('data_testid_value'); - expect(tokens).toContain('data'); - expect(tokens).toContain('testid'); - expect(tokens).toContain('value'); - }); - - it('handles kebab-case', () => { - const tokens = tokenizeIdentifier('confirm-button-primary'); - expect(tokens).toContain('confirm'); - expect(tokens).toContain('button'); - expect(tokens).toContain('primary'); - }); - - it('removes duplicates', () => { - const tokens = tokenizeIdentifier('clickClickButton'); - const clickCount = tokens.filter((t) => t === 'click').length; - expect(clickCount).toBe(1); - }); - - it('filters out short tokens', () => { - const tokens = tokenizeIdentifier('aButtonForX'); - expect(tokens).not.toContain('a'); - expect(tokens).not.toContain('x'); - expect(tokens).toContain('button'); - expect(tokens).toContain('for'); - }); - }); - - describe('expandWithSynonyms', () => { - it('returns original tokens if no synonyms match', () => { - const tokens = ['button', 'primary']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('button'); - expect(expanded).toContain('primary'); - }); - - it('expands send with transfer and pay', () => { - const tokens = ['send']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('send'); - expect(expanded).toContain('transfer'); - expect(expanded).toContain('pay'); - }); - - it('expands approve with confirm, accept, allow', () => { - const tokens = ['approve']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('approve'); - expect(expanded).toContain('confirm'); - expect(expanded).toContain('accept'); - expect(expanded).toContain('allow'); - }); - - it('expands reverse synonyms (synonym -> canonical)', () => { - const tokens = ['transfer']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('send'); - expect(expanded).toContain('transfer'); - expect(expanded).toContain('pay'); - }); - - it('expands unlock with login and signin', () => { - const tokens = ['unlock']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('unlock'); - expect(expanded).toContain('login'); - expect(expanded).toContain('signin'); - }); - - it('expands swap with exchange and trade', () => { - const tokens = ['swap']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('swap'); - expect(expanded).toContain('exchange'); - expect(expanded).toContain('trade'); - }); - - it('expands reject with deny, cancel, decline', () => { - const tokens = ['reject']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('reject'); - expect(expanded).toContain('deny'); - expect(expanded).toContain('cancel'); - expect(expanded).toContain('decline'); - }); - - it('preserves non-synonym tokens', () => { - const tokens = ['button', 'send', 'primary']; - const expanded = expandWithSynonyms(tokens); - expect(expanded).toContain('button'); - expect(expanded).toContain('primary'); - expect(expanded).toContain('send'); - expect(expanded).toContain('transfer'); - }); - - it('does not create duplicates', () => { - const tokens = ['send', 'transfer']; - const expanded = expandWithSynonyms(tokens); - const sendCount = expanded.filter((t) => t === 'send').length; - expect(sendCount).toBe(1); - }); - }); -}); diff --git a/src/knowledge-store/tokenization.ts b/src/knowledge-store/tokenization.ts deleted file mode 100644 index dfd4a83..0000000 --- a/src/knowledge-store/tokenization.ts +++ /dev/null @@ -1,126 +0,0 @@ -const STOPWORDS = new Set([ - 'a', - 'an', - 'the', - 'to', - 'from', - 'in', - 'on', - 'at', - 'for', - 'with', - 'and', - 'or', - 'but', - 'is', - 'are', - 'was', - 'were', - 'be', - 'been', - 'flow', - 'test', - 'should', - 'can', - 'will', - 'do', - 'does', - 'did', - 'have', - 'has', - 'had', - 'this', - 'that', - 'these', - 'those', - 'it', - 'mm', - 'mcp', - 'lw', -]); - -const MIN_TOKEN_LENGTH = 2; - -/** - * Tokenizes text into lowercase words, removing stopwords. - * - * @param text - The text to tokenize. - * @returns Array of unique lowercase tokens. - */ -export function tokenize(text: string): string[] { - if (!text) { - return []; - } - - const tokens = text - .toLowerCase() - .split(/[^a-z0-9]+/iu) - .filter( - (token) => token.length >= MIN_TOKEN_LENGTH && !STOPWORDS.has(token), - ); - - return [...new Set(tokens)]; -} - -/** - * Tokenizes a camelCase/PascalCase identifier into words. - * - * @param identifier - The identifier to tokenize. - * @returns Array of unique lowercase tokens. - */ -export function tokenizeIdentifier(identifier: string): string[] { - if (!identifier) { - return []; - } - - const withSpaces = identifier - .replace(/([a-z])([A-Z])/gu, '$1 $2') - .replace(/([A-Z]+)([A-Z][a-z])/gu, '$1 $2'); - - const tokens = withSpaces - .toLowerCase() - .split(/[^a-z0-9]+/iu) - .filter((token) => token.length >= MIN_TOKEN_LENGTH); - - return [...new Set(tokens)]; -} - -const ACTION_SYNONYMS: Record = { - send: ['transfer', 'pay'], - receive: ['deposit'], - approve: ['confirm', 'accept', 'allow'], - reject: ['deny', 'cancel', 'decline'], - unlock: ['login', 'signin'], - connect: ['link', 'authorize'], - swap: ['exchange', 'trade'], - sign: ['signature'], -}; - -/** - * Expands tokens by adding action-related synonyms. - * - * @param tokens - The tokens to expand. - * @returns Expanded array including synonyms. - */ -export function expandWithSynonyms(tokens: string[]): string[] { - const expanded = new Set(tokens); - - for (const token of tokens) { - if (ACTION_SYNONYMS[token]) { - for (const synonym of ACTION_SYNONYMS[token]) { - expanded.add(synonym); - } - } - - for (const [canonical, synonyms] of Object.entries(ACTION_SYNONYMS)) { - if (synonyms.includes(token)) { - expanded.add(canonical); - for (const synonym of synonyms) { - expanded.add(synonym); - } - } - } - } - - return [...expanded]; -} diff --git a/src/knowledge-store/utils/redaction.test.ts b/src/knowledge-store/utils/redaction.test.ts deleted file mode 100644 index 4aa4933..0000000 --- a/src/knowledge-store/utils/redaction.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { - isSensitiveField, - redactRecordedSecrets, - SENSITIVE_FIELD_PATTERNS, -} from './redaction.js'; -import type { StepRecord } from '../../tools/types/step-record.js'; - -describe('isSensitiveField', () => { - describe('password patterns', () => { - it('detects password field', () => { - expect(isSensitiveField('password')).toBe(true); - }); - - it('detects PASSWORD field (case insensitive)', () => { - expect(isSensitiveField('PASSWORD')).toBe(true); - }); - - it('detects userPassword field', () => { - expect(isSensitiveField('userPassword')).toBe(true); - }); - - it('detects password_hash field', () => { - expect(isSensitiveField('password_hash')).toBe(true); - }); - }); - - describe('seed phrase patterns', () => { - it('detects seed field', () => { - expect(isSensitiveField('seed')).toBe(true); - }); - - it('detects seedPhrase field', () => { - expect(isSensitiveField('seedPhrase')).toBe(true); - }); - - it('detects srp field', () => { - expect(isSensitiveField('srp')).toBe(true); - }); - - it('detects phrase field', () => { - expect(isSensitiveField('phrase')).toBe(true); - }); - - it('detects mnemonic field', () => { - expect(isSensitiveField('mnemonic')).toBe(true); - }); - - it('detects recovery_phrase field', () => { - expect(isSensitiveField('recovery_phrase')).toBe(true); - }); - }); - - describe('key patterns', () => { - it('detects privateKey field', () => { - expect(isSensitiveField('privateKey')).toBe(true); - }); - - it('detects private_key field', () => { - expect(isSensitiveField('private_key')).toBe(true); - }); - - it('detects private-key field', () => { - expect(isSensitiveField('private-key')).toBe(true); - }); - - it('detects apiKey field', () => { - expect(isSensitiveField('apiKey')).toBe(true); - }); - - it('detects api_key field', () => { - expect(isSensitiveField('api_key')).toBe(true); - }); - - it('detects api-key field', () => { - expect(isSensitiveField('api-key')).toBe(true); - }); - }); - - describe('secret patterns', () => { - it('detects secret field', () => { - expect(isSensitiveField('secret')).toBe(true); - }); - - it('detects clientSecret field', () => { - expect(isSensitiveField('clientSecret')).toBe(true); - }); - - it('detects shared_secret field', () => { - expect(isSensitiveField('shared_secret')).toBe(true); - }); - }); - - describe('non-sensitive fields', () => { - it('returns false for username', () => { - expect(isSensitiveField('username')).toBe(false); - }); - - it('returns false for email', () => { - expect(isSensitiveField('email')).toBe(false); - }); - - it('returns false for name', () => { - expect(isSensitiveField('name')).toBe(false); - }); - - it('returns false for address', () => { - expect(isSensitiveField('address')).toBe(false); - }); - - it('returns false for id', () => { - expect(isSensitiveField('id')).toBe(false); - }); - - it('returns false for empty string', () => { - expect(isSensitiveField('')).toBe(false); - }); - - it('returns false for random text', () => { - expect(isSensitiveField('randomFieldName')).toBe(false); - }); - }); - - describe('edge cases', () => { - it('handles mixed case variations', () => { - expect(isSensitiveField('PaSsWoRd')).toBe(true); - expect(isSensitiveField('SeeD')).toBe(true); - expect(isSensitiveField('PrivateKey')).toBe(true); - }); - - it('detects patterns within longer field names', () => { - expect(isSensitiveField('user_password_hash')).toBe(true); - expect(isSensitiveField('backup_seed_phrase')).toBe(true); - expect(isSensitiveField('wallet_private_key')).toBe(true); - }); - - it('handles underscores and hyphens in field names', () => { - expect(isSensitiveField('private_key')).toBe(true); - expect(isSensitiveField('private-key')).toBe(true); - expect(isSensitiveField('api_key')).toBe(true); - expect(isSensitiveField('api-key')).toBe(true); - }); - }); -}); - -describe('SENSITIVE_FIELD_PATTERNS', () => { - it('is an array of regex patterns', () => { - expect(Array.isArray(SENSITIVE_FIELD_PATTERNS)).toBe(true); - expect(SENSITIVE_FIELD_PATTERNS.length).toBeGreaterThan(0); - }); - - it('contains only RegExp objects', () => { - SENSITIVE_FIELD_PATTERNS.forEach((pattern) => { - expect(pattern).toBeInstanceOf(RegExp); - }); - }); - - it('has patterns for all sensitive field types', () => { - const patternStrings = SENSITIVE_FIELD_PATTERNS.map((p) => p.source); - - expect(patternStrings.some((s) => s.includes('password'))).toBe(true); - expect(patternStrings.some((s) => s.includes('seed'))).toBe(true); - expect(patternStrings.some((s) => s.includes('private'))).toBe(true); - expect(patternStrings.some((s) => s.includes('secret'))).toBe(true); - }); -}); - -// A minimal typed record for pure matching/scoping tests; no HTTP or filesystem. -function stepRecord(): StepRecord { - return { - schemaVersion: 1, - timestamp: '2026-10-13T12:00:00.000Z', - sessionId: 'mm-session-2026', - context: 'prod', - environment: { platform: 'darwin', nodeVersion: 'v24.13.1' }, - tool: { - name: 'cdp', - input: { method: 'Input.dispatchKeyEvent', params: '[REDACTED]' }, - }, - labels: ['error-recovery'], - outcome: { ok: false, error: { code: 'MM_CDP_FAILED' } }, - observation: { - state: { - isLoaded: true, - isUnlocked: false, - currentScreen: 'onboarding', - currentUrl: 'chrome-extension://test/home.html', - extensionId: 'test', - accountAddress: null, - networkName: null, - chainId: 2, - balance: '20 tokens', - }, - testIds: [{ testId: 'go-2', tag: 'span', text: 'go 2', visible: true }], - a11y: { - nodes: [ - { - ref: 'e2', - role: 'button', - name: 'Continue onboarding 2', - path: ['dialog'], - testId: 'onboarding-2', - textContent: 'Continue 2', - }, - ], - }, - }, - }; -} - -describe('redactRecordedSecrets', () => { - it.each(['2', 'o'])( - 'preserves metadata and short substrings while redacting exact %s echoes', - (secret) => { - const record = stepRecord(); - record.outcome.error = { code: secret, message: secret }; - record.tool.target = { selector: secret, testId: `prefix ${secret}` }; - record.observation.state.networkName = secret; - record.observation.testIds.push({ - testId: 'exact', - tag: 'span', - text: secret, - visible: true, - }); - record.observation.a11y.nodes.push({ - ref: 'e3', - role: 'textbox', - name: secret, - path: [], - }); - const result = redactRecordedSecrets(record, [{ text: secret }]); - - expect(result.timestamp).toBe(record.timestamp); - expect(new Date(result.timestamp).toISOString()).toBe(record.timestamp); - expect(result).toMatchObject({ - schemaVersion: 1, - sessionId: record.sessionId, - context: 'prod', - environment: record.environment, - labels: record.labels, - tool: { name: 'cdp' }, - }); - expect(result.outcome.error).toMatchObject({ - code: secret, - message: '[REDACTED]', - }); - expect(result.tool.target).toStrictEqual({ - selector: '[REDACTED]', - testId: `prefix ${secret}`, - }); - expect(result.observation.state).toStrictEqual({ - ...record.observation.state, - networkName: '[REDACTED]', - }); - expect(result.observation.testIds).toStrictEqual([ - record.observation.testIds[0], - { ...record.observation.testIds[1], text: '[REDACTED]' }, - ]); - expect(result.observation.a11y.nodes).toMatchObject([ - record.observation.a11y.nodes[0], - { ...record.observation.a11y.nodes[1], name: '[REDACTED]' }, - ]); - }, - ); - - it.each([7, 8])( - 'chooses whole-value versus substring matching from raw length %s, before escaping', - (length) => { - const secret = '"'.repeat(length); - const escaped = JSON.stringify(secret).slice(1, -1); - const record = stepRecord(); - record.outcome.error = { - code: 'EXAMPLE', - details: { - raw: secret, - escaped, - embedded: `prefix ${secret}`, - embeddedEscaped: `prefix ${escaped}`, - }, - }; - const result = redactRecordedSecrets(record, [secret]); - - expect(result.outcome.error?.details).toStrictEqual({ - raw: '[REDACTED]', - escaped: '[REDACTED]', - embedded: length === 7 ? `prefix ${secret}` : 'prefix [REDACTED]', - embeddedEscaped: - length === 7 ? `prefix ${escaped}` : 'prefix [REDACTED]', - }); - }, - ); - - it('scrubs every targeted free-text category, retaining structural metadata and the supplied record', () => { - const secret = 'synthetic-"password"\\path\n'; - const echo = `echo ${secret}; ${JSON.stringify(secret)}`; - const redactedEcho = 'echo [REDACTED]; "[REDACTED]"'; - const record = stepRecord(); - record.tool.target = { selector: echo, testId: echo, a11yRef: echo }; - record.outcome.error = { - code: 'MM_CDP_FAILED', - message: echo, - details: { nested: [{ [secret]: echo }] }, - }; - record.artifacts = { screenshot: { path: echo, width: 2, height: 3 } }; - const { observation } = record; - observation.state = { - ...observation.state, - currentUrl: echo, - currentScreen: echo, - extensionId: echo, - accountAddress: echo, - networkName: echo, - balance: echo, - }; - observation.state.activeTab = { - role: 'notification', - url: echo, - title: echo, - }; - observation.testIds = [ - { testId: echo, tag: 'element', text: echo, visible: true }, - ]; - observation.a11y = { - nodes: [ - { - ref: 'e2', - role: 'button', - name: echo, - path: [echo], - testId: echo, - textContent: echo, - }, - ], - diff: { added: [echo], removed: [echo], unchanged: 2 }, - }; - const original = structuredClone(record); - // Intentional exact matches prove structural metadata survives redaction. - const structuralStrings = [ - 'cdp', - 'prod', - 'button', - 'element', - 'e2', - 'testId', - 'notification', - 'MM_CDP_FAILED', - 'error-recovery', - record.sessionId, - record.timestamp, - 'v24.13.1', - ]; - const result = redactRecordedSecrets(record, [ - { nested: [secret, ...structuralStrings] }, - ]); - const serialized = JSON.stringify(result); - - expect(serialized).not.toContain(JSON.stringify(secret).slice(1, -1)); - expect(serialized).not.toContain( - JSON.stringify(JSON.stringify(secret).slice(1, -1)).slice(1, -1), - ); - expect(result.outcome.error?.details).toStrictEqual({ - nested: [{ '[REDACTED]': redactedEcho }], - }); - expect(result).toMatchObject({ - timestamp: record.timestamp, - sessionId: record.sessionId, - context: 'prod', - environment: record.environment, - labels: record.labels, - tool: { name: 'cdp', target: { selector: redactedEcho } }, - outcome: { error: { code: 'MM_CDP_FAILED', message: redactedEcho } }, - artifacts: { screenshot: { path: redactedEcho, width: 2, height: 3 } }, - }); - expect(result.observation).toMatchObject({ - state: { - networkName: redactedEcho, - activeTab: { - role: 'notification', - url: redactedEcho, - title: redactedEcho, - }, - }, - testIds: [{ tag: 'element', text: redactedEcho }], - a11y: { - nodes: [{ ref: 'e2', role: 'button', name: redactedEcho }], - diff: { added: [redactedEcho], removed: [redactedEcho], unchanged: 2 }, - }, - }); - expect(record).toStrictEqual(original); - }); - - it('preserves existing markers and is idempotent', () => { - const record = stepRecord(); - record.outcome.error = { message: '[REDACTED] and REDACTED' }; - const result = redactRecordedSecrets(record, ['REDACTED']); - - expect(result.outcome.error?.message).toBe('[REDACTED] and [REDACTED]'); - expect(redactRecordedSecrets(result, ['REDACTED'])).toStrictEqual(result); - }); - - it('preserves the record when no nonempty strings were redacted', () => { - const record = stepRecord(); - expect(redactRecordedSecrets(record, [])).toStrictEqual(record); - expect( - redactRecordedSecrets(record, ['', 2, null, { nested: [false] }]), - ).toStrictEqual(record); - }); -}); diff --git a/src/knowledge-store/utils/redaction.ts b/src/knowledge-store/utils/redaction.ts deleted file mode 100644 index 313ed6d..0000000 --- a/src/knowledge-store/utils/redaction.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { - StepRecord, - StepRecordObservation, -} from '../../tools/types/step-record.js'; - -export const SENSITIVE_FIELD_PATTERNS = [ - /password/iu, - /seed/iu, - /srp/iu, - /phrase/iu, - /mnemonic/iu, - /private.*key/iu, - /secret/iu, - /api[-_]?key/iu, -] as const; - -/** - * Checks if a field name matches sensitive data patterns. - * - * @param fieldName - The field name to check for sensitivity. - * @returns True if the field matches a sensitive pattern. - */ -export function isSensitiveField(fieldName: string): boolean { - return SENSITIVE_FIELD_PATTERNS.some((pattern) => pattern.test(fieldName)); -} - -const REDACTED = '[REDACTED]'; -const MIN_SUBSTRING_LENGTH = 8; - -type SecretMatches = { - wholeValues: Set; - substrings: Set; -}; - -type SecretRedactor = { - text: (value: string) => string; - value: (value: Value, redactKeys?: boolean) => Value; -}; - -/** - * Collect raw and JSON-escaped strings using the raw length for both variants. - * - * @param value - A secret or nested collection of secrets. - * @param matches - Whole-value and substring matches to populate. - */ -function collectSecretMatches(value: unknown, matches: SecretMatches): void { - if (typeof value === 'string' && value.length > 0) { - const bucket = - value.length < MIN_SUBSTRING_LENGTH - ? matches.wholeValues - : matches.substrings; - bucket.add(value); - bucket.add(JSON.stringify(value).slice(1, -1)); - } else if (Array.isArray(value)) { - value.forEach((entry) => collectSecretMatches(entry, matches)); - } else if (value && typeof value === 'object') { - Object.values(value).forEach((entry) => - collectSecretMatches(entry, matches), - ); - } -} - -/** - * Copy nested values while redacting strings. Dates and non-string leaves survive. - * - * @param value - The value to copy. - * @param redactText - The string-matching policy. - * @param redactKeys - Whether object keys also contain free text (error details). - * @returns The redacted value. - */ -function redactNestedValue( - value: Value, - redactText: SecretRedactor['text'], - redactKeys: boolean, -): Value { - if (typeof value === 'string') { - return redactText(value) as Value; - } - if (Array.isArray(value)) { - return value.map((entry) => - redactNestedValue(entry, redactText, redactKeys), - ) as Value; - } - if (value && typeof value === 'object' && !(value instanceof Date)) { - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [ - redactKeys ? redactText(key) : key, - redactNestedValue(entry, redactText, redactKeys), - ]), - ) as Value; - } - return value; -} - -/** - * Build matching once per record, independent of which record fields use it. - * - * @param redactedValues - Input values omitted from the recorded input. - * @returns A redactor, or undefined when there are no nonempty secret strings. - */ -function createSecretRedactor( - redactedValues: unknown[], -): SecretRedactor | undefined { - const matches: SecretMatches = { - wholeValues: new Set(), - substrings: new Set(), - }; - redactedValues.forEach((value) => collectSecretMatches(value, matches)); - if (matches.wholeValues.size === 0 && matches.substrings.size === 0) { - return undefined; - } - - const sortedSubstrings = [...matches.substrings].sort( - (first, second) => second.length - first.length, - ); - // Match existing markers first, then longer secrets before their prefixes. - const pattern = new RegExp( - [REDACTED, ...sortedSubstrings] - .map((secret) => secret.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) - .join('|'), - 'gu', - ); - /** - * Redact whole-value matches before replacing embedded longer secrets. - * - * @param value - Text that may echo a secret. - * @returns The redacted text. - */ - function redactText(value: string): string { - if (matches.wholeValues.has(value)) { - return REDACTED; - } - return value.replace(pattern, REDACTED); - } - return { - text: redactText, - value: (value, redactKeys = false) => - redactNestedValue(value, redactText, redactKeys), - }; -} - -/** - * Redact observation text while retaining accessibility refs, roles, and tags. - * - * @param observation - The post-execution observation. - * @param redactor - The secret-matching operations. - * @returns A redacted copy of the observation. - */ -function redactObservation( - observation: StepRecordObservation, - redactor: SecretRedactor, -): StepRecordObservation { - const state = { ...redactor.value(observation.state) }; - if (observation.state.activeTab) { - state.activeTab = { - ...redactor.value(observation.state.activeTab), - role: observation.state.activeTab.role, - }; - } - - const sanitized: StepRecordObservation = { - ...observation, - state, - testIds: observation.testIds.map((item) => ({ - ...item, - testId: redactor.text(item.testId), - text: redactor.value(item.text), - })), - a11y: { - ...observation.a11y, - nodes: observation.a11y.nodes.map((node) => ({ - ...node, - name: redactor.text(node.name), - path: redactor.value(node.path), - testId: redactor.value(node.testId), - textContent: redactor.value(node.textContent), - })), - }, - }; - if (observation.a11y.diff) { - sanitized.a11y.diff = redactor.value(observation.a11y.diff); - } - return sanitized; -} - -/** - * Remove raw and JSON-escaped echoes from errors, targets, observations, and - * artifact paths. Strings shorter than eight characters match whole values - * only; longer strings match substrings. Structural step metadata is retained. - * - * @param record - The step record. - * @param redactedValues - Input values omitted from the recorded input. - * @returns A copy of the record with echoed strings redacted. - */ -export function redactRecordedSecrets( - record: StepRecord, - redactedValues: unknown[], -): StepRecord { - const redactor = createSecretRedactor(redactedValues); - if (!redactor) { - return record; - } - const { error } = record.outcome; - const screenshot = record.artifacts?.screenshot; - const sanitized: StepRecord = { - ...record, - tool: { ...record.tool }, - outcome: { ...record.outcome }, - observation: redactObservation(record.observation, redactor), - }; - if (record.tool.target) { - sanitized.tool.target = redactor.value(record.tool.target); - } - if (error) { - sanitized.outcome.error = { - ...error, - message: redactor.value(error.message), - details: redactor.value(error.details, true), - }; - } - if (screenshot) { - sanitized.artifacts = { - ...record.artifacts, - screenshot: { ...screenshot, path: redactor.value(screenshot.path) }, - }; - } - return sanitized; -} diff --git a/src/server/create-server.test.ts b/src/server/create-server.test.ts index fe48670..d3ba21a 100644 --- a/src/server/create-server.test.ts +++ b/src/server/create-server.test.ts @@ -8,9 +8,6 @@ import type { MockInstance } from 'vitest'; import type { ServerInstance } from './create-server.js'; import { createServer, - extractTargetFromInput, - extractScreenshotInfo, - extractToolOutcome, buildResponseBody, shouldCollectObservations, shouldIncludeObservationsInResponse, @@ -39,33 +36,6 @@ vi.mock('../tools/utils/discovery.js', () => ({ }), })); -vi.mock('../knowledge-store/knowledge-store.js', () => { - const mockStore = { - recordStep: vi.fn().mockResolvedValue('/mock/path'), - writeSessionMetadata: vi.fn().mockResolvedValue('/mock/path'), - getLastSteps: vi.fn().mockResolvedValue([]), - searchSteps: vi.fn().mockResolvedValue([]), - summarizeSession: vi.fn().mockResolvedValue({ stepCount: 0, recipe: [] }), - listSessions: vi.fn().mockResolvedValue([]), - getAllSessionIds: vi.fn().mockResolvedValue([]), - resolveSessionIds: vi.fn().mockResolvedValue([]), - }; - return { - KnowledgeStore: vi.fn(() => mockStore), - createDefaultObservation: vi.fn( - (state: unknown, testIds?: unknown[], nodes?: unknown[]) => ({ - state: state ?? {}, - testIds: testIds ?? [], - a11y: { nodes: nodes ?? [] }, - }), - ), - createKnowledgeStore: vi.fn(() => mockStore), - setKnowledgeStore: vi.fn(), - hasKnowledgeStore: vi.fn(() => false), - knowledgeStore: mockStore, - }; -}); - function createMockSessionManager() { return { hasActiveSession: vi.fn(() => false), @@ -171,179 +141,6 @@ async function httpRequest( }); } -describe('extractTargetFromInput', () => { - it('returns undefined for null input', () => { - expect(extractTargetFromInput(null)).toBeUndefined(); - }); - - it('returns undefined for non-object input', () => { - expect(extractTargetFromInput('string')).toBeUndefined(); - expect(extractTargetFromInput(42)).toBeUndefined(); - }); - - it('returns undefined when no target fields present', () => { - expect(extractTargetFromInput({ name: 'click' })).toBeUndefined(); - }); - - it('extracts a11yRef', () => { - expect(extractTargetFromInput({ a11yRef: 'e1' })).toStrictEqual({ - a11yRef: 'e1', - testId: undefined, - selector: undefined, - }); - }); - - it('extracts testId', () => { - expect(extractTargetFromInput({ testId: 'btn' })).toStrictEqual({ - a11yRef: undefined, - testId: 'btn', - selector: undefined, - }); - }); - - it('extracts selector', () => { - expect(extractTargetFromInput({ selector: '.my-btn' })).toStrictEqual({ - a11yRef: undefined, - testId: undefined, - selector: '.my-btn', - }); - }); - - it('extracts multiple target fields', () => { - expect( - extractTargetFromInput({ a11yRef: 'e1', testId: 'btn' }), - ).toStrictEqual({ - a11yRef: 'e1', - testId: 'btn', - selector: undefined, - }); - }); - - it('ignores non-string target values', () => { - expect(extractTargetFromInput({ a11yRef: 42 })).toBeUndefined(); - }); -}); - -describe('extractScreenshotInfo', () => { - it('returns undefined for non-screenshot tools', () => { - expect(extractScreenshotInfo('click', {})).toBeUndefined(); - }); - - it('returns undefined when toolResult is not an object', () => { - expect(extractScreenshotInfo('screenshot', null)).toBeUndefined(); - expect(extractScreenshotInfo('screenshot', 'string')).toBeUndefined(); - }); - - it('returns undefined when result is not ok', () => { - expect(extractScreenshotInfo('screenshot', { ok: false })).toBeUndefined(); - }); - - it('returns undefined when result has no path', () => { - expect( - extractScreenshotInfo('screenshot', { ok: true, result: {} }), - ).toBeUndefined(); - }); - - it('extracts screenshot path from result.path', () => { - expect( - extractScreenshotInfo('screenshot', { - ok: true, - result: { path: '/img.png' }, - }), - ).toStrictEqual({ path: '/img.png' }); - }); - - it('extracts screenshot path with dimensions', () => { - expect( - extractScreenshotInfo('screenshot', { - ok: true, - result: { path: '/img.png', width: 1280, height: 720 }, - }), - ).toStrictEqual({ - path: '/img.png', - dimensions: { width: 1280, height: 720 }, - }); - }); - - it('extracts screenshot from nested screenshot object', () => { - expect( - extractScreenshotInfo('describe_screen', { - ok: true, - result: { screenshot: { path: '/ss.png', width: 800, height: 600 } }, - }), - ).toStrictEqual({ - path: '/ss.png', - dimensions: { width: 800, height: 600 }, - }); - }); - - it('extracts nested screenshot without dimensions', () => { - expect( - extractScreenshotInfo('describe_screen', { - ok: true, - result: { screenshot: { path: '/ss.png' } }, - }), - ).toStrictEqual({ path: '/ss.png' }); - }); - - it('returns undefined when result.result is null', () => { - expect( - extractScreenshotInfo('screenshot', { ok: true, result: null }), - ).toBeUndefined(); - }); - - it('returns undefined when nested screenshot has no path', () => { - expect( - extractScreenshotInfo('describe_screen', { - ok: true, - result: { screenshot: { width: 800 } }, - }), - ).toBeUndefined(); - }); - - it('returns undefined when nested screenshot is null', () => { - expect( - extractScreenshotInfo('describe_screen', { - ok: true, - result: { screenshot: null }, - }), - ).toBeUndefined(); - }); -}); - -describe('extractToolOutcome', () => { - it('returns ok:true for non-object input', () => { - expect(extractToolOutcome(null)).toStrictEqual({ ok: true }); - expect(extractToolOutcome('string')).toStrictEqual({ ok: true }); - }); - - it('returns ok:true when ok not in result', () => { - expect(extractToolOutcome({ result: 'data' })).toStrictEqual({ ok: true }); - }); - - it('returns ok:true for successful result', () => { - expect(extractToolOutcome({ ok: true, result: 'data' })).toStrictEqual({ - ok: true, - }); - }); - - it('returns ok:false with error for failed result', () => { - expect( - extractToolOutcome({ - ok: false, - error: { code: 'ERR', message: 'fail' }, - }), - ).toStrictEqual({ - ok: false, - error: { code: 'ERR', message: 'fail' }, - }); - }); - - it('returns ok:false without error when no error field', () => { - expect(extractToolOutcome({ ok: false })).toStrictEqual({ ok: false }); - }); -}); - describe('buildResponseBody', () => { it('returns toolResult as-is for non-object', () => { expect(buildResponseBody('string', undefined)).toBe('string'); @@ -370,11 +167,11 @@ describe('shouldCollectObservations', () => { expect(shouldCollectObservations('mutating')).toBe(true); }); - it('returns true for readonly (collected for knowledge store)', () => { + it('returns true for readonly', () => { expect(shouldCollectObservations('readonly')).toBe(true); }); - it('returns true for discovery (collected for knowledge store)', () => { + it('returns true for discovery', () => { expect(shouldCollectObservations('discovery')).toBe(true); }); @@ -533,34 +330,6 @@ describe('createServer integration', () => { expect(body.ok).toBe(true); }); - it('omits the recovery phrase and vault password from the recorded launch step', async () => { - const { KnowledgeStore } = - await import('../knowledge-store/knowledge-store.js'); - const mockStore = vi.mocked(KnowledgeStore).mock.results.at(-1)?.value as { - recordStep: ReturnType; - }; - mockStore.recordStep.mockClear(); - const srp = Array.from({ length: 12 }, () => 'abandon').join(' '); - const password = 'vault-password-sentinel'; - - await httpRequest(`http://127.0.0.1:${state.port}/launch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ selfOnboarding: true, srp, password }), - }); - - expect(mockStore.recordStep).toHaveBeenCalled(); - const recorded = mockStore.recordStep.mock.calls[0][0] as { - input: Record; - }; - expect(recorded.input.selfOnboarding).toBe(true); - expect(recorded.input).not.toHaveProperty('srp'); - expect(recorded.input).not.toHaveProperty('password'); - const serialized = JSON.stringify(mockStore.recordStep.mock.calls); - expect(serialized).not.toContain('abandon'); - expect(serialized).not.toContain(password); - }); - it('pOST /cleanup delegates to session manager', async () => { const res = await httpRequest(`http://127.0.0.1:${state.port}/cleanup`, { method: 'POST', @@ -970,7 +739,7 @@ describe('createServer with active session', () => { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); }); - it('collects observations and records knowledge for tool execution', async () => { + it('collects observations for tool execution', async () => { const res = await httpRequest(`http://127.0.0.1:${state.port}/cleanup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -983,7 +752,7 @@ describe('createServer with active session', () => { expect(body.observations).toBeDefined(); }); - it('records error step when tool execution throws', async () => { + it('returns 500 when tool execution throws', async () => { mockSM.cleanup.mockRejectedValueOnce(new Error('Browser crash')); const res = await httpRequest(`http://127.0.0.1:${state.port}/cleanup`, { @@ -1112,7 +881,7 @@ describe('createServer with active session', () => { } }); - it('playwright helpers called for read-only tools (knowledge store)', async () => { + it('observation Playwright helpers called for read-only tools', async () => { const { collectTestIds, collectTrimmedA11ySnapshot } = await import('../tools/utils/discovery.js'); const collectTestIdsSpy = vi.mocked(collectTestIds); @@ -1150,40 +919,6 @@ describe('createServer with active session', () => { expect(collectA11ySpy).toHaveBeenCalled(); }); - it('recordStep is called for mutating tool routes', async () => { - const { KnowledgeStore } = - await import('../knowledge-store/knowledge-store.js'); - const mockStore = vi.mocked(KnowledgeStore).mock.results.at(-1)?.value as { - recordStep: ReturnType; - }; - mockStore.recordStep.mockClear(); - - await httpRequest(`http://127.0.0.1:${state.port}/cleanup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - - expect(mockStore.recordStep).toHaveBeenCalled(); - }); - - it('recordStep is called for read-only tool routes', async () => { - const { KnowledgeStore } = - await import('../knowledge-store/knowledge-store.js'); - const mockStore = vi.mocked(KnowledgeStore).mock.results.at(-1)?.value as { - recordStep: ReturnType; - }; - mockStore.recordStep.mockClear(); - - await httpRequest(`http://127.0.0.1:${state.port}/tool/get_state`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - - expect(mockStore.recordStep).toHaveBeenCalled(); - }); - describe('post-mutation state recheck', () => { it('resolves immediately when getExtensionState returns a known screen', async () => { mockSM.getExtensionState.mockReset(); @@ -1278,7 +1013,7 @@ describe('createServer with active session', () => { }); const res = await httpRequest( - `http://127.0.0.1:${state.port}/tool/knowledge_last`, + `http://127.0.0.1:${state.port}/tool/get_context`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1325,7 +1060,7 @@ describe('createServer with active session', () => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - steps: [{ tool: 'knowledge_last', args: {} }], + steps: [{ tool: 'get_context', args: {} }], }), }, ); @@ -1852,68 +1587,6 @@ describe('observation compaction in HTTP responses', () => { expect(body.observations?.a11y.nodes).toHaveLength(10); }); - it('knowledge store always receives the full observation instead of the diff', async () => { - const { collectTrimmedA11ySnapshot } = - await import('../tools/utils/discovery.js'); - vi.mocked(collectTrimmedA11ySnapshot) - .mockResolvedValueOnce({ - nodes: initialButtons as never, - refMap: new Map(), - }) - .mockResolvedValueOnce({ - nodes: changedButtons as never, - refMap: new Map(), - }); - - const { KnowledgeStore } = - await import('../knowledge-store/knowledge-store.js'); - const mockStore = vi.mocked(KnowledgeStore).mock.results.at(-1)?.value as { - recordStep: ReturnType; - }; - mockStore.recordStep.mockClear(); - - await httpRequest(`http://127.0.0.1:${state.port}/tool/click`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ a11yRef: 'e1' }), - }); - - await httpRequest(`http://127.0.0.1:${state.port}/tool/click`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ a11yRef: 'e1' }), - }); - - expect(mockStore.recordStep).toHaveBeenCalledTimes(2); - const recorded = mockStore.recordStep.mock.calls[1][0] as { - observation: { a11y: { nodes: unknown[]; diff?: unknown } }; - }; - - expect(recorded.observation.a11y.diff).toBeUndefined(); - expect(recorded.observation.a11y.nodes).toStrictEqual(changedButtons); - }); - - it('knowledge store receives full uncompacted observations', async () => { - const { KnowledgeStore } = - await import('../knowledge-store/knowledge-store.js'); - const mockStore = vi.mocked(KnowledgeStore).mock.results.at(-1)?.value as { - recordStep: ReturnType; - }; - mockStore.recordStep.mockClear(); - - await httpRequest(`http://127.0.0.1:${state.port}/cleanup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - - expect(mockStore.recordStep).toHaveBeenCalled(); - const recorded = mockStore.recordStep.mock.calls[0][0] as { - observation: { a11y: { nodes: unknown[] } }; - }; - expect(recorded.observation.a11y.nodes).toHaveLength(12); - }); - it('batch with includeObservations=all returns compact observations', async () => { const res = await httpRequest( `http://127.0.0.1:${state.port}/tool/run_steps`, diff --git a/src/server/create-server.ts b/src/server/create-server.ts index b00fe1a..062f472 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -15,11 +15,6 @@ import { } from './response-timing.js'; import pkg from '../../package.json'; import type { PortMap, WorkflowContext } from '../capabilities/context.js'; -import type { ExtensionState } from '../capabilities/types.js'; -import { - KnowledgeStore, - createDefaultObservation, -} from '../knowledge-store/knowledge-store.js'; import { PlaywrightPlatformDriver } from '../platform/playwright-driver.js'; import { toolRegistry, @@ -27,11 +22,8 @@ import { checkPlatformGate, } from '../tools/registry.js'; import type { ToolCategory } from '../tools/registry.js'; -import type { - StepRecordObservation, - StepRecordOutcome, - StepRecordTool, -} from '../tools/types/step-record.js'; +import { createDefaultObservation } from '../tools/types/observation.js'; +import type { StepRecordObservation } from '../tools/types/observation.js'; import { ANIMATION_SETTLE_TIMEOUT_MS, GRACEFUL_SHUTDOWN_TIMEOUT_MS, @@ -54,117 +46,11 @@ import { computeTimeoutBudget } from '../utils/timeout-budget.js'; import type { ToolName } from '../validation/schemas.js'; import { toolSchemas } from '../validation/schemas.js'; -/** - * Extracts target selection fields from a tool's validated input. - * Interaction tools (click, type, wait_for) include a11yRef, testId, or selector. - * - * @param input - The validated tool input. - * @returns The target info for knowledge recording, or undefined if not applicable. - */ -export function extractTargetFromInput( - input: unknown, -): StepRecordTool['target'] | undefined { - if (typeof input !== 'object' || input === null) { - return undefined; - } - const obj = input as Record; - const a11yRef = typeof obj.a11yRef === 'string' ? obj.a11yRef : undefined; - const testId = typeof obj.testId === 'string' ? obj.testId : undefined; - const selector = typeof obj.selector === 'string' ? obj.selector : undefined; - if (!a11yRef && !testId && !selector) { - return undefined; - } - return { a11yRef, testId, selector }; -} - -/** - * Extracts screenshot artifact metadata from a successful tool result. - * Applies to `screenshot` and `describe_screen` tools. - * - * @param toolName - The name of the tool that produced the result. - * @param toolResult - The raw result from the tool execution. - * @returns Screenshot path and dimensions, or undefined if not applicable. - */ -export function extractScreenshotInfo( - toolName: string, - toolResult: unknown, -): - | { path: string; dimensions?: { width: number; height: number } } - | undefined { - if (toolName !== 'screenshot' && toolName !== 'describe_screen') { - return undefined; - } - if (typeof toolResult !== 'object' || toolResult === null) { - return undefined; - } - const result = toolResult as Record; - if ( - !result.ok || - typeof result.result !== 'object' || - result.result === null - ) { - return undefined; - } - const data = result.result as Record; - - if (typeof data.path === 'string') { - return { - path: data.path, - ...(typeof data.width === 'number' && typeof data.height === 'number' - ? { dimensions: { width: data.width, height: data.height } } - : {}), - }; - } - - if (typeof data.screenshot === 'object' && data.screenshot !== null) { - const ss = data.screenshot as Record; - if (typeof ss.path === 'string') { - return { - path: ss.path, - ...(typeof ss.width === 'number' && typeof ss.height === 'number' - ? { dimensions: { width: ss.width, height: ss.height } } - : {}), - }; - } - } - - return undefined; -} - export type ServerInstance = { start(): Promise; stop(): Promise; }; -/** - * Extracts a structured outcome from a raw tool result for knowledge recording. - * - * @param toolResult - The raw result returned by a tool function. - * @returns A normalized outcome with ok status and optional error details. - */ -export function extractToolOutcome(toolResult: unknown): { - ok: boolean; - error?: { code: string; message: string }; -} { - if ( - typeof toolResult !== 'object' || - toolResult === null || - !('ok' in toolResult) - ) { - return { ok: true }; - } - - const typed = toolResult as { - ok: boolean; - error?: { code: string; message: string }; - }; - if (typed.ok) { - return { ok: true }; - } - - return typed.error ? { ok: false, error: typed.error } : { ok: false }; -} - /** * Merges a tool result with observation data into the HTTP response body. * @@ -190,9 +76,9 @@ export function buildResponseBody( /** * Whether to run Playwright observation collection for this tool invocation. * - * Observations are always collected for the knowledge store, regardless of - * whether they appear in the HTTP response. The only exception is batch - * with `'none'` policy, which skips collection entirely for best performance. + * Observations are collected for every tool, whether or not they appear in + * the HTTP response (they also feed the next response's diff). The only + * exception is batch with `'none'` policy, which skips collection entirely. * * @param category - The tool category to check. * @param validatedInput - The validated input payload (checked for batch policy). @@ -257,7 +143,6 @@ export function createServer(config: ServerConfig): ServerInstance { const app = express(); const queue = new RequestQueue(config.requestTimeoutMs); const nonce = randomUUID(); - const knowledgeStore = config.knowledgeStore ?? new KnowledgeStore(); let httpServer: http.Server | null = null; let worktreeRoot = ''; @@ -337,71 +222,14 @@ export function createServer(config: ServerConfig): ServerInstance { : new Map(); }, workflowContext: wfCtx, - knowledgeStore, toolRegistry, driver, }; } - /** - * Records a tool execution step to the knowledge store. - * Failures are silently caught — recording must never block tool responses. - * - * @param toolName - The registered tool name. - * @param validatedInput - The validated input payload. - * @param outcome - The tool execution outcome. - * @param observation - The post-execution observation snapshot. - * @param toolResult - The raw tool result (for screenshot extraction). - * @param startTime - The epoch timestamp when execution started. - */ - async function recordToolStep( - toolName: string, - validatedInput: unknown, - outcome: StepRecordOutcome, - observation: StepRecordObservation | undefined, - toolResult: unknown, - startTime: number, - ): Promise { - try { - const sessionId = config.sessionManager.getSessionId(); - if (!sessionId) { - return; - } - - const recordedInput = omitLaunchSecret(validatedInput); - const target = extractTargetFromInput(recordedInput); - const screenshotInfo = extractScreenshotInfo(toolName, toolResult); - - let executionContext: 'e2e' | 'prod' | undefined; - try { - executionContext = config.sessionManager.getEnvironmentMode(); - } catch { - // session manager may not support environment mode - } - - await knowledgeStore.recordStep({ - sessionId, - toolName, - input: recordedInput as Record, - target, - outcome, - observation: - observation ?? createDefaultObservation({} as ExtensionState), - durationMs: Date.now() - startTime, - ...(screenshotInfo ? { screenshotPath: screenshotInfo.path } : {}), - ...(screenshotInfo?.dimensions - ? { screenshotDimensions: screenshotInfo.dimensions } - : {}), - context: executionContext, - }); - } catch { - // non-fatal: recording failure must not block tool responses - } - } - /** * Shared tool executor — validates input, runs through the queue, - * records knowledge steps, and collects observations. + * and collects observations. * * @param toolName - The registered tool name to execute. * @param rawInput - The unvalidated input payload from the request body. @@ -476,7 +304,6 @@ export function createServer(config: ServerConfig): ServerInstance { validatedInput = parsed.data; } - const startTime = Date.now(); const currentWorkflowContext = workflowContext; const category = getToolCategory(toolName); @@ -610,15 +437,6 @@ export function createServer(config: ServerConfig): ServerInstance { queueTimeoutMs ? { timeoutMs: queueTimeoutMs } : undefined, ); - await recordToolStep( - toolName, - validatedInput, - extractToolOutcome(toolResult), - observations, - toolResult, - startTime, - ); - const includeInResponse = shouldIncludeObservationsInResponse( category, toolResult, @@ -645,21 +463,6 @@ export function createServer(config: ServerConfig): ServerInstance { lastObservation = observations; } } catch (error) { - await recordToolStep( - toolName, - validatedInput, - { - ok: false, - error: { - code: 'TOOL_EXECUTION_FAILED', - message: extractErrorMessage(error), - }, - }, - undefined, - undefined, - startTime, - ); - res.status(500).json( withMeta( { @@ -918,27 +721,6 @@ export function createServer(config: ServerConfig): ServerInstance { return instance; } -/** - * Removes the secret recovery phrase and vault password from a launch payload - * before it is written to the knowledge store. - * - * @param input - The tool input about to be recorded. - * @returns A shallow copy without `srp` or `password`, or the original value when it carries neither. - */ -function omitLaunchSecret(input: unknown): unknown { - if ( - typeof input !== 'object' || - input === null || - !('srp' in input || 'password' in input) - ) { - return input; - } - const copy = { ...(input as Record) }; - delete copy.srp; - delete copy.password; - return copy; -} - /** * Appends a timestamped line to the daemon log file. * diff --git a/src/server/observation-compaction.test.ts b/src/server/observation-compaction.test.ts index a917f07..f54b5f3 100644 --- a/src/server/observation-compaction.test.ts +++ b/src/server/observation-compaction.test.ts @@ -8,7 +8,7 @@ import { observationCompactionDeps, } from './observation-compaction.js'; import type { A11yNodeTrimmed } from '../tools/types/discovery.js'; -import type { StepRecordObservation } from '../tools/types/step-record.js'; +import type { StepRecordObservation } from '../tools/types/observation.js'; function createNode( ref: string, diff --git a/src/server/observation-compaction.ts b/src/server/observation-compaction.ts index a96196d..76aa658 100644 --- a/src/server/observation-compaction.ts +++ b/src/server/observation-compaction.ts @@ -1,5 +1,5 @@ import type { A11yNodeTrimmed } from '../tools/types/discovery.js'; -import type { StepRecordObservation } from '../tools/types/step-record.js'; +import type { StepRecordObservation } from '../tools/types/observation.js'; import { OPTION_COLLAPSE_MIN_COUNT } from '../tools/utils/constants.js'; const OPTION_RANGE_PATTERN = diff --git a/src/server/recorded-secrets.test.ts b/src/server/recorded-secrets.test.ts deleted file mode 100644 index 0d6b1b7..0000000 --- a/src/server/recorded-secrets.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createServer } from './create-server.js'; -import type { ServerInstance } from './create-server.js'; -import { KnowledgeStore } from '../knowledge-store/knowledge-store.js'; -import { toolRegistry } from '../tools/registry.js'; -import { createMockSessionManager } from '../tools/test-utils/mock-factories.js'; -import type { StepRecord } from '../tools/types/step-record.js'; -import type { ServerConfig, ToolResponse } from '../types/http.js'; - -// Synthetic credentials only. Escaping exercises JSON echoes in recorded errors. -const mnemonic = 'synthetic alpha bravo charlie delta echo foxtrot golf'; -const password = 'synthetic-Password-"quoted"\\path\nAIENG-228'; -const sessionId = 'mm-onboarding-2026'; -let tmpDir: string; - -vi.mock('node:child_process', () => ({ - execSync: () => Buffer.from(`${tmpDir}\n`), -})); -vi.mock('../tools/utils/discovery.js', () => ({ - collectTestIds: async () => [], - collectTrimmedA11ySnapshot: async () => ({ nodes: [], refMap: new Map() }), -})); - -describe('secret recording through HTTP to disk', () => { - let server: ServerInstance; - let port: number; - let stepsDir: string; - const send = vi.fn(); - const cdp = vi.fn(); - const setClipboard = vi.fn(); - const getPlatform = vi.fn(); - const originalCdpTool = toolRegistry.get('cdp'); - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mm-recorded-secrets-')); - stepsDir = path.join(tmpDir, sessionId, 'steps'); - send.mockResolvedValue({ result: { value: mnemonic } }); - cdp.mockResolvedValue({ ok: true, result: { text: password } }); - setClipboard.mockResolvedValue(undefined); - getPlatform.mockReturnValue('browser'); - const sessionManager = createMockSessionManager({ - hasActive: true, - sessionId, - environmentMode: 'prod', - }); - sessionManager.getPage.mockReturnValue({ - context: () => ({ - newCDPSession: async () => ({ send, detach: async () => undefined }), - }), - waitForLoadState: async () => undefined, - waitForFunction: async () => undefined, - }); - const driver = { - getPlatform, - cdp, - setClipboard, - getClipboard: async () => mnemonic, - getAppState: sessionManager.getExtensionState, - getTestIds: async () => [], - getAccessibilityTree: async () => ({ nodes: [], refMap: new Map() }), - }; - server = createServer({ - sessionManager: { - ...sessionManager, - getPlatformDriver: () => driver, - } as unknown as ServerConfig['sessionManager'], - knowledgeStore: new KnowledgeStore({ rootDir: tmpDir }), - contextFactory: () => ({ config: { environment: 'prod' } }), - }); - ({ port } = await server.start()); - }); - - afterEach(async () => { - if (originalCdpTool) { - toolRegistry.set('cdp', originalCdpTool); - } - await server.stop(); - await fs.rm(tmpDir, { recursive: true, force: true }); - vi.clearAllMocks(); - }); - - async function execute(tool: string, input: Record) { - // fetch is available throughout the package's supported Node >=20 range. - // eslint-disable-next-line n/no-unsupported-features/node-builtins - const response = await fetch(`http://127.0.0.1:${port}/tool/${tool}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return { - status: response.status, - body: (await response.json()) as ToolResponse, - }; - } - - async function recordedSteps() { - const filenames = await fs.readdir(stepsDir); - const steps: StepRecord[] = []; - for (const filename of filenames) { - const contents = await fs.readFile( - path.join(stepsDir, filename), - 'utf-8', - ); - for (const secret of [mnemonic, password]) { - expect(filename).not.toContain(secret); - expect(contents).not.toContain(JSON.stringify(secret).slice(1, -1)); - } - const step = JSON.parse(contents) as StepRecord; - expect(step).toMatchObject({ sessionId, context: 'prod' }); - steps.push(step); - } - return steps; - } - - it('records the onboarding inputs without changing tool delivery or results', async () => { - const clipboard = await execute('clipboard', { - action: 'write', - text: mnemonic, - }); - const params = { text: password }; - const cdpResult = { text: password }; - cdp.mockResolvedValueOnce({ ok: true, result: cdpResult }); - const inserted = await execute('cdp', { - method: 'Input.insertText', - params, - }); - getPlatform.mockReturnValue('ios'); - const mobile = await execute('device_clipboard', { - action: 'write', - text: mnemonic, - }); - - expect(send).toHaveBeenCalledWith( - 'Runtime.evaluate', - expect.objectContaining({ - expression: `navigator.clipboard.writeText(${JSON.stringify(mnemonic)})`, - }), - ); - expect(cdp).toHaveBeenCalledWith(expect.objectContaining({ params })); - expect(setClipboard).toHaveBeenCalledWith(mnemonic); - expect(clipboard.body).toMatchObject({ - ok: true, - result: { text: mnemonic }, - }); - expect(inserted.body).toMatchObject({ - ok: true, - result: { result: cdpResult }, - }); - expect(mobile.body).toMatchObject({ ok: true, result: { success: true } }); - const steps = await recordedSteps(); - expect(steps.map((step) => step.tool.name).sort()).toStrictEqual([ - 'cdp', - 'clipboard', - 'device_clipboard', - ]); - for (const step of steps) { - expect(step.tool.textRedacted).toBe(true); - expect(step.outcome).toStrictEqual({ ok: true }); - if (step.tool.name === 'cdp') { - expect(step.tool.input).toMatchObject({ - method: 'Input.insertText', - params: '[REDACTED]', - }); - } else { - expect(step.tool).toMatchObject({ - input: { action: 'write', text: '[REDACTED]' }, - textLength: mnemonic.length, - }); - } - } - }); - - it.each(['clipboard', 'device_clipboard', 'device-clipboard'])( - 'redacts %s and arbitrary CDP params inside recursive batches', - async (tool) => { - getPlatform.mockReturnValue(tool === 'clipboard' ? 'browser' : 'android'); - const params = { arguments: [{ value: password }] }; - const nestedSteps = [ - { tool, args: { action: 'write', text: mnemonic } }, - { tool: 'cdp', args: { method: 'Runtime.callFunctionOn', params } }, - ]; - const response = await execute('run_steps', { - includeObservations: 'none', - steps: [{ tool: 'run_steps', args: { steps: nestedSteps } }], - }); - - expect(response.body).toMatchObject({ - ok: true, - result: { summary: { ok: true } }, - }); - expect(cdp).toHaveBeenCalledWith(expect.objectContaining({ params })); - if (tool === 'clipboard') { - expect(send).toHaveBeenCalledWith( - 'Runtime.evaluate', - expect.objectContaining({ - expression: `navigator.clipboard.writeText(${JSON.stringify(mnemonic)})`, - }), - ); - } else { - expect(setClipboard).toHaveBeenCalledWith(mnemonic); - } - const steps = await recordedSteps(); - expect(steps).toHaveLength(1); - expect(steps[0].tool.textRedacted).toBe(true); - }, - ); - - it.each(['tool-returned', 'server-exception'])( - 'scrubs raw and escaped credentials in the %s error recording path', - async (mode) => { - const message = `echo ${password}; params=${JSON.stringify({ text: password })}`; - const code = - mode === 'tool-returned' ? 'MM_CDP_FAILED' : 'TOOL_EXECUTION_FAILED'; - if (mode === 'tool-returned') { - cdp.mockResolvedValueOnce({ ok: false, code, message }); - } else { - toolRegistry.set('cdp', async () => { - throw new Error(message); - }); - } - const response = await execute('cdp', { - method: 'Input.insertText', - params: { text: password }, - }); - - expect(response.status).toBe(mode === 'tool-returned' ? 200 : 500); - expect(response.body).toMatchObject({ - ok: false, - error: { code, message }, - }); - const steps = await recordedSteps(); - expect(steps).toHaveLength(1); - expect(steps[0].outcome.error).toStrictEqual({ - code, - message: 'echo [REDACTED]; params={"text":"[REDACTED]"}', - }); - }, - ); - - it('keeps clipboard reads and parameterless CDP inputs useful', async () => { - const response = await execute('clipboard', { action: 'read' }); - await execute('cdp', { method: 'Network.enable' }); - - expect(response.body).toMatchObject({ - ok: true, - result: { text: mnemonic }, - }); - const steps = await recordedSteps(); - expect(steps).toHaveLength(2); - expect(steps.map((step) => step.tool.input)).toStrictEqual( - expect.arrayContaining([ - { action: 'read' }, - { method: 'Network.enable', timeoutMs: 30_000, target: 'hermes' }, - ]), - ); - expect(steps.every((step) => step.tool.textRedacted === undefined)).toBe( - true, - ); - }); -}); diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index b700d1b..b7fa4d4 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -45,10 +45,6 @@ export type SessionLaunchInput = { fixturePreset?: string; /** Custom fixture data when stateMode is 'custom' */ fixture?: Record; - /** Goal description for this session (used in knowledge store) */ - goal?: string; - /** Flow tags for categorizing this session */ - flowTags?: string[]; /** Free-form tags for ad-hoc filtering */ tags?: string[]; /** Path to extension directory */ diff --git a/src/server/session-metadata.test.ts b/src/server/session-metadata.test.ts new file mode 100644 index 0000000..dbb9f86 --- /dev/null +++ b/src/server/session-metadata.test.ts @@ -0,0 +1,64 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { writeSessionMetadata } from './session-metadata.js'; +import type { SessionMetadata } from '../tools/types/session.js'; + +const metadata = (sessionId: string): SessionMetadata => ({ + schemaVersion: 1, + sessionId, + createdAt: '2026-10-23T00:00:00.000Z', + tags: ['smoke'], + launch: { stateMode: 'default' }, +}); + +describe('writeSessionMetadata', () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mm-session-metadata-')); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('writes //session.json and returns its path', async () => { + const filePath = await writeSessionMetadata(dir, metadata('mm-abc')); + + expect(filePath).toBe(path.join(dir, 'mm-abc', 'session.json')); + expect(JSON.parse(await fs.readFile(filePath, 'utf8'))).toStrictEqual( + metadata('mm-abc'), + ); + }); + + it('creates missing parent directories', async () => { + const nested = path.join(dir, 'a', 'b'); + + const filePath = await writeSessionMetadata(nested, metadata('mm-x')); + + expect(filePath).toBe(path.join(nested, 'mm-x', 'session.json')); + }); + + it('overwrites an existing file', async () => { + await writeSessionMetadata(dir, metadata('mm-1')); + const updated = { ...metadata('mm-1'), tags: ['second'] }; + + const filePath = await writeSessionMetadata(dir, updated); + + expect(JSON.parse(await fs.readFile(filePath, 'utf8')).tags).toStrictEqual([ + 'second', + ]); + }); + + it.each(['', '.', '..', '../escape', 'a/b', 'a\\b'])( + 'rejects unsafe session id %j', + async (sessionId) => { + await expect( + writeSessionMetadata(dir, metadata(sessionId)), + ).rejects.toThrowError('Invalid session id'); + }, + ); +}); diff --git a/src/server/session-metadata.ts b/src/server/session-metadata.ts new file mode 100644 index 0000000..583b6fd --- /dev/null +++ b/src/server/session-metadata.ts @@ -0,0 +1,35 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { SessionMetadata } from '../tools/types/session.js'; + +/** + * Writes `session.json` for a session to `//`. + * + * @param artifactsDir - The artifacts directory (for example the workflow + * context's `config.artifactsDir`). Relative paths resolve against the + * process working directory. + * @param metadata - The session metadata to persist. + * @returns The absolute path of the written file. + */ +export async function writeSessionMetadata( + artifactsDir: string, + metadata: SessionMetadata, +): Promise { + const { sessionId } = metadata; + const root = path.resolve(artifactsDir); + const sessionDir = path.resolve(root, sessionId); + if ( + !sessionId || + sessionId.includes('/') || + sessionId.includes('\\') || + path.dirname(sessionDir) !== root || + path.basename(sessionDir) !== sessionId + ) { + throw new Error(`Invalid session id: "${sessionId}"`); + } + await fs.mkdir(sessionDir, { recursive: true }); + const filePath = path.join(sessionDir, 'session.json'); + await fs.writeFile(filePath, `${JSON.stringify(metadata, null, 2)}\n`); + return filePath; +} diff --git a/src/tools/batch.test.ts b/src/tools/batch.test.ts index d8b255a..39d3bbc 100644 --- a/src/tools/batch.test.ts +++ b/src/tools/batch.test.ts @@ -23,7 +23,6 @@ function createMockContext( page: {} as ToolContext['page'], refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, toolRegistry, driver, } as unknown as ToolContext; diff --git a/src/tools/build.test.ts b/src/tools/build.test.ts index 4429237..7b6a936 100644 --- a/src/tools/build.test.ts +++ b/src/tools/build.test.ts @@ -23,7 +23,6 @@ function createMockContext( schemaVersion: 1, sessionId: 'test-session-123', createdAt: new Date().toISOString(), - flowTags: [], tags: [], launch: { stateMode: 'default' }, }, @@ -36,7 +35,6 @@ function createMockContext( page: {}, refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, } as unknown as ToolContext; } diff --git a/src/tools/cdp.test.ts b/src/tools/cdp.test.ts index 6fd81d7..2fd5620 100644 --- a/src/tools/cdp.test.ts +++ b/src/tools/cdp.test.ts @@ -18,7 +18,6 @@ function createContext(options: { sessionManager: createMockSessionManager({ hasActive }), refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, toolRegistry: new Map(), driver: driver as IPlatformDriver | undefined, } as unknown as ToolContext; diff --git a/src/tools/cleanup.test.ts b/src/tools/cleanup.test.ts index 5348703..f96f6da 100644 --- a/src/tools/cleanup.test.ts +++ b/src/tools/cleanup.test.ts @@ -16,7 +16,6 @@ function createMockContext(hasActive = false): ToolContext { page: {}, refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, } as unknown as ToolContext; } diff --git a/src/tools/clipboard.test.ts b/src/tools/clipboard.test.ts index d067712..bcc6f69 100644 --- a/src/tools/clipboard.test.ts +++ b/src/tools/clipboard.test.ts @@ -38,7 +38,6 @@ function createMockContext( page: mockPage, refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, } as unknown as ToolContext; } diff --git a/src/tools/context.test.ts b/src/tools/context.test.ts index 2af9de5..1700f38 100644 --- a/src/tools/context.test.ts +++ b/src/tools/context.test.ts @@ -23,7 +23,6 @@ function createMockContext( page: {} as ToolContext['page'], refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, } as unknown as ToolContext; } diff --git a/src/tools/device.test.ts b/src/tools/device.test.ts index a648b40..ff3d81a 100644 --- a/src/tools/device.test.ts +++ b/src/tools/device.test.ts @@ -36,7 +36,6 @@ function createContext(options: { workflowContext: { config: artifactsDir === undefined ? {} : { artifactsDir }, }, - knowledgeStore: {}, toolRegistry: new Map(), driver: driver as IPlatformDriver | undefined, } as unknown as ToolContext; diff --git a/src/tools/discovery-tools.test.ts b/src/tools/discovery-tools.test.ts index 3ac5e66..3e5fe1b 100644 --- a/src/tools/discovery-tools.test.ts +++ b/src/tools/discovery-tools.test.ts @@ -44,7 +44,6 @@ function createMockContext( page, refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, driver: hasActive ? new PlaywrightPlatformDriver(() => page, sessionManager as any) : undefined, diff --git a/src/tools/driver-delegation.test.ts b/src/tools/driver-delegation.test.ts index a2e4099..31b15d8 100644 --- a/src/tools/driver-delegation.test.ts +++ b/src/tools/driver-delegation.test.ts @@ -79,7 +79,6 @@ function createContextWithDriver(driver: IPlatformDriver): ToolContext { page: page as any, refMap: new Map(), workflowContext: {} as any, - knowledgeStore: {} as any, toolRegistry: new Map(), driver, } as unknown as ToolContext; @@ -94,7 +93,6 @@ function createContextWithoutDriver(): ToolContext { page: page as any, refMap: new Map(), workflowContext: {} as any, - knowledgeStore: {} as any, toolRegistry: new Map(), driver: undefined, } as unknown as ToolContext; @@ -430,7 +428,6 @@ describe('tool delegation to context.driver', () => { page: {} as any, refMap: new Map(), workflowContext: {} as any, - knowledgeStore: {} as any, toolRegistry: new Map(), driver, } as unknown as ToolContext; @@ -528,7 +525,6 @@ describe('tool delegation to context.driver', () => { page: {} as any, refMap: new Map(), workflowContext: {} as any, - knowledgeStore: {} as any, toolRegistry: new Map(), driver, } as unknown as ToolContext; diff --git a/src/tools/hermes.test.ts b/src/tools/hermes.test.ts index ba95aca..6f1f876 100644 --- a/src/tools/hermes.test.ts +++ b/src/tools/hermes.test.ts @@ -16,7 +16,6 @@ function createContext(options: { sessionManager: createMockSessionManager({ hasActive }), refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, toolRegistry: new Map(), driver: driver as IPlatformDriver | undefined, } as unknown as ToolContext; diff --git a/src/tools/index.ts b/src/tools/index.ts index d451904..3fb3d40 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,7 +6,6 @@ export * from './context.js'; export * from './discovery-tools.js'; export * from './hermes.js'; export * from './interaction.js'; -export * from './knowledge.js'; export * from './launch.js'; export * from './mock-network.js'; export * from './navigation.js'; diff --git a/src/tools/interaction.test.ts b/src/tools/interaction.test.ts index 0d16c0c..2cba370 100644 --- a/src/tools/interaction.test.ts +++ b/src/tools/interaction.test.ts @@ -54,7 +54,6 @@ function createMockContext( page, refMap: options.refMap ?? new Map(), workflowContext: {}, - knowledgeStore: {}, driver: hasSession ? new PlaywrightPlatformDriver(() => page, sessionManager as any) : undefined, diff --git a/src/tools/knowledge.test.ts b/src/tools/knowledge.test.ts deleted file mode 100644 index 5eaa60c..0000000 --- a/src/tools/knowledge.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -/** - * Unit tests for knowledge tool handlers. - * - * Tests knowledge store query handlers including last N steps, search, - * summarize, and session listing with various filter combinations. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -import { - knowledgeLastTool, - knowledgeSearchTool, - knowledgeSummarizeTool, - knowledgeSessionsTool, -} from './knowledge.js'; -import { createMockSessionManager } from './test-utils/mock-factories.js'; -import { ErrorCodes } from './types/errors.js'; -import type { ToolContext } from '../types/http.js'; - -function createMockContext(): ToolContext { - return { - sessionManager: createMockSessionManager({ - hasActive: true, - sessionId: 'test-session-123', - sessionMetadata: { - schemaVersion: 1, - sessionId: 'test-session-123', - createdAt: new Date().toISOString(), - flowTags: [], - tags: [], - launch: { stateMode: 'default' }, - }, - }), - page: {}, - refMap: new Map(), - workflowContext: {}, - knowledgeStore: { - getLastSteps: vi.fn().mockResolvedValue([]), - searchSteps: vi.fn().mockResolvedValue([]), - summarizeSession: vi.fn().mockResolvedValue({ - sessionId: 'test-session-123', - stepCount: 0, - recipe: [], - }), - listSessions: vi.fn().mockResolvedValue([]), - }, - } as unknown as ToolContext; -} - -describe('knowledge', () => { - let context: ToolContext; - - beforeEach(() => { - context = createMockContext(); - }); - - describe('knowledgeLastTool', () => { - it('retrieves last N steps with default parameters', async () => { - const mockSteps = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'click', - screen: 'home', - snippet: 'Clicked send', - }, - { - timestamp: '2026-02-04T10:01:00Z', - tool: 'type', - screen: 'home', - snippet: 'Entered amount', - }, - ]; - vi.mocked(context.knowledgeStore.getLastSteps).mockResolvedValue( - mockSteps, - ); - - const result = await knowledgeLastTool({}, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.steps).toStrictEqual(mockSteps); - } - expect(context.knowledgeStore.getLastSteps).toHaveBeenCalledWith( - 20, - 'current', - 'test-session-123', - undefined, - ); - }); - - it('retrieves last N steps with custom n parameter', async () => { - const mockSteps = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'click', - screen: 'home', - snippet: 'Clicked send', - }, - ]; - vi.mocked(context.knowledgeStore.getLastSteps).mockResolvedValue( - mockSteps, - ); - - const result = await knowledgeLastTool({ n: 5 }, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.steps).toStrictEqual(mockSteps); - } - expect(context.knowledgeStore.getLastSteps).toHaveBeenCalledWith( - 5, - 'current', - 'test-session-123', - undefined, - ); - }); - - it('retrieves steps with scope "all"', async () => { - const mockSteps = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'click', - screen: 'home', - snippet: 'Clicked send', - }, - ]; - vi.mocked(context.knowledgeStore.getLastSteps).mockResolvedValue( - mockSteps, - ); - - const result = await knowledgeLastTool({ scope: 'all' }, context); - - expect(result.ok).toBe(true); - expect(context.knowledgeStore.getLastSteps).toHaveBeenCalledWith( - 20, - 'all', - 'test-session-123', - undefined, - ); - }); - - it('retrieves steps with filters', async () => { - const mockSteps = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'click', - screen: 'send', - snippet: 'Clicked confirm', - }, - ]; - const filters = { - flowTag: 'send', - screen: 'send', - sinceHours: 24, - }; - vi.mocked(context.knowledgeStore.getLastSteps).mockResolvedValue( - mockSteps, - ); - - const result = await knowledgeLastTool({ n: 10, filters }, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.steps).toStrictEqual(mockSteps); - } - expect(context.knowledgeStore.getLastSteps).toHaveBeenCalledWith( - 10, - 'current', - 'test-session-123', - filters, - ); - }); - - it('returns empty array when no steps found', async () => { - vi.mocked(context.knowledgeStore.getLastSteps).mockResolvedValue([]); - - const result = await knowledgeLastTool({ n: 10 }, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.steps).toStrictEqual([]); - } - }); - - it('returns error when knowledge store fails', async () => { - vi.mocked(context.knowledgeStore.getLastSteps).mockRejectedValue( - new Error('Database connection failed'), - ); - - const result = await knowledgeLastTool({ n: 10 }, context); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_KNOWLEDGE_ERROR); - expect(result.error.message).toContain('Failed to retrieve steps'); - expect(result.error.message).toContain('Database connection failed'); - } - }); - }); - - describe('knowledgeSearchTool', () => { - it('searches steps with default parameters', async () => { - const mockMatches = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'click', - screen: 'home', - snippet: 'Clicked send', - }, - ]; - vi.mocked(context.knowledgeStore.searchSteps).mockResolvedValue( - mockMatches, - ); - - const result = await knowledgeSearchTool({ query: 'click' }, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.matches).toStrictEqual(mockMatches); - expect(result.result.query).toBe('click'); - } - expect(context.knowledgeStore.searchSteps).toHaveBeenCalledWith( - 'click', - 20, - 'all', - 'test-session-123', - undefined, - ); - }); - - it('searches steps with custom limit', async () => { - const mockMatches = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'type', - screen: 'send', - snippet: 'Entered recipient', - }, - ]; - vi.mocked(context.knowledgeStore.searchSteps).mockResolvedValue( - mockMatches, - ); - - const result = await knowledgeSearchTool( - { query: 'type', limit: 50 }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.matches).toStrictEqual(mockMatches); - } - expect(context.knowledgeStore.searchSteps).toHaveBeenCalledWith( - 'type', - 50, - 'all', - 'test-session-123', - undefined, - ); - }); - - it('searches steps with scope "current"', async () => { - const mockMatches = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'click', - screen: 'home', - snippet: 'Clicked send', - }, - ]; - vi.mocked(context.knowledgeStore.searchSteps).mockResolvedValue( - mockMatches, - ); - - const result = await knowledgeSearchTool( - { query: 'click', scope: 'current' }, - context, - ); - - expect(result.ok).toBe(true); - expect(context.knowledgeStore.searchSteps).toHaveBeenCalledWith( - 'click', - 20, - 'current', - 'test-session-123', - undefined, - ); - }); - - it('searches steps with filters', async () => { - const mockMatches = [ - { - timestamp: '2026-02-04T10:00:00Z', - tool: 'click', - screen: 'send', - snippet: 'Confirmed transaction', - }, - ]; - const filters = { - flowTag: 'send', - tag: 'transaction', - screen: 'send', - }; - vi.mocked(context.knowledgeStore.searchSteps).mockResolvedValue( - mockMatches, - ); - - const result = await knowledgeSearchTool( - { query: 'confirm', limit: 10, filters }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.matches).toStrictEqual(mockMatches); - } - expect(context.knowledgeStore.searchSteps).toHaveBeenCalledWith( - 'confirm', - 10, - 'all', - 'test-session-123', - filters, - ); - }); - - it('returns empty array when no matches found', async () => { - vi.mocked(context.knowledgeStore.searchSteps).mockResolvedValue([]); - - const result = await knowledgeSearchTool( - { query: 'nonexistent' }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.matches).toStrictEqual([]); - expect(result.result.query).toBe('nonexistent'); - } - }); - - it('returns error when search fails', async () => { - vi.mocked(context.knowledgeStore.searchSteps).mockRejectedValue( - new Error('Search index corrupted'), - ); - - const result = await knowledgeSearchTool({ query: 'test' }, context); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_KNOWLEDGE_ERROR); - expect(result.error.message).toContain('Search failed'); - expect(result.error.message).toContain('Search index corrupted'); - } - }); - }); - - describe('knowledgeSummarizeTool', () => { - it('summarizes current session by default', async () => { - const mockSummary = { - sessionId: 'test-session-123', - stepCount: 5, - recipe: [ - { stepNumber: 1, tool: 'click', notes: 'Clicked send button' }, - { stepNumber: 2, tool: 'type', notes: 'Entered amount' }, - ], - }; - vi.mocked(context.knowledgeStore.summarizeSession).mockResolvedValue( - mockSummary, - ); - - const result = await knowledgeSummarizeTool({}, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result).toStrictEqual(mockSummary); - } - expect(context.knowledgeStore.summarizeSession).toHaveBeenCalledWith( - 'test-session-123', - ); - }); - - it('summarizes current session with scope "current"', async () => { - const mockSummary = { - sessionId: 'test-session-123', - stepCount: 3, - recipe: [], - }; - vi.mocked(context.knowledgeStore.summarizeSession).mockResolvedValue( - mockSummary, - ); - - const result = await knowledgeSummarizeTool( - { scope: 'current' }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result).toStrictEqual(mockSummary); - } - expect(context.knowledgeStore.summarizeSession).toHaveBeenCalledWith( - 'test-session-123', - ); - }); - - it('summarizes specific session by sessionId', async () => { - const mockSummary = { - sessionId: 'other-session-456', - stepCount: 10, - recipe: [{ stepNumber: 1, tool: 'launch', notes: 'Launched browser' }], - }; - vi.mocked(context.knowledgeStore.summarizeSession).mockResolvedValue( - mockSummary, - ); - - const result = await knowledgeSummarizeTool( - { sessionId: 'other-session-456' }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result).toStrictEqual(mockSummary); - } - expect(context.knowledgeStore.summarizeSession).toHaveBeenCalledWith( - 'other-session-456', - ); - }); - - it('summarizes session with scope object containing sessionId', async () => { - const mockSummary = { - sessionId: 'scoped-session-789', - stepCount: 7, - recipe: [], - }; - vi.mocked(context.knowledgeStore.summarizeSession).mockResolvedValue( - mockSummary, - ); - - const result = await knowledgeSummarizeTool( - { scope: { sessionId: 'scoped-session-789' } }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result).toStrictEqual(mockSummary); - } - expect(context.knowledgeStore.summarizeSession).toHaveBeenCalledWith( - 'scoped-session-789', - ); - }); - - it('returns error when scope is "all"', async () => { - const result = await knowledgeSummarizeTool({ scope: 'all' }, context); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_INVALID_INPUT); - expect(result.error.message).toContain('Cannot summarize all sessions'); - } - }); - - it('returns error when no sessionId can be determined', async () => { - vi.mocked(context.sessionManager.getSessionId).mockReturnValue(undefined); - - const result = await knowledgeSummarizeTool({}, context); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_INVALID_INPUT); - expect(result.error.message).toContain('No sessionId provided'); - } - }); - - it('returns error when summarize fails', async () => { - vi.mocked(context.knowledgeStore.summarizeSession).mockRejectedValue( - new Error('Session not found'), - ); - - const result = await knowledgeSummarizeTool( - { sessionId: 'nonexistent-session' }, - context, - ); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_KNOWLEDGE_ERROR); - expect(result.error.message).toContain('Summarize failed'); - expect(result.error.message).toContain('Session not found'); - } - }); - }); - - describe('knowledgeSessionsTool', () => { - it('lists sessions with default limit', async () => { - const mockSessions = [ - { - sessionId: 'session-1', - createdAt: '2026-02-04T10:00:00Z', - goal: 'Test send flow', - flowTags: ['send'], - tags: [], - }, - { - sessionId: 'session-2', - createdAt: '2026-02-04T09:00:00Z', - flowTags: [], - tags: ['test'], - }, - ]; - vi.mocked(context.knowledgeStore.listSessions).mockResolvedValue( - mockSessions, - ); - - const result = await knowledgeSessionsTool({}, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.sessions).toStrictEqual(mockSessions); - } - expect(context.knowledgeStore.listSessions).toHaveBeenCalledWith( - 10, - undefined, - ); - }); - - it('lists sessions with custom limit', async () => { - const mockSessions = [ - { - sessionId: 'session-1', - createdAt: '2026-02-04T10:00:00Z', - flowTags: [], - tags: [], - }, - ]; - vi.mocked(context.knowledgeStore.listSessions).mockResolvedValue( - mockSessions, - ); - - const result = await knowledgeSessionsTool({ limit: 25 }, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.sessions).toStrictEqual(mockSessions); - } - expect(context.knowledgeStore.listSessions).toHaveBeenCalledWith( - 25, - undefined, - ); - }); - - it('lists sessions with filters', async () => { - const mockSessions = [ - { - sessionId: 'session-1', - createdAt: '2026-02-04T10:00:00Z', - flowTags: ['send'], - tags: [], - }, - ]; - const filters = { - flowTag: 'send', - sinceHours: 48, - }; - vi.mocked(context.knowledgeStore.listSessions).mockResolvedValue( - mockSessions, - ); - - const result = await knowledgeSessionsTool( - { limit: 20, filters }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.sessions).toStrictEqual(mockSessions); - } - expect(context.knowledgeStore.listSessions).toHaveBeenCalledWith( - 20, - filters, - ); - }); - - it('returns empty array when no sessions found', async () => { - vi.mocked(context.knowledgeStore.listSessions).mockResolvedValue([]); - - const result = await knowledgeSessionsTool({ limit: 10 }, context); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.sessions).toStrictEqual([]); - } - }); - - it('returns error when listing fails', async () => { - vi.mocked(context.knowledgeStore.listSessions).mockRejectedValue( - new Error('Database unavailable'), - ); - - const result = await knowledgeSessionsTool({}, context); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_KNOWLEDGE_ERROR); - expect(result.error.message).toContain('Failed to list sessions'); - expect(result.error.message).toContain('Database unavailable'); - } - }); - }); -}); diff --git a/src/tools/knowledge.ts b/src/tools/knowledge.ts deleted file mode 100644 index 27a3939..0000000 --- a/src/tools/knowledge.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { extractErrorMessage } from '../utils'; -import type { - KnowledgeLastInput, - KnowledgeLastResult, - KnowledgeScope, - KnowledgeSearchInput, - KnowledgeSearchResult, - KnowledgeSessionsInput, - KnowledgeSessionsResult, - KnowledgeSummarizeInput, - KnowledgeSummarizeResult, -} from './types'; -import { ErrorCodes } from './types'; -import { createToolError, createToolSuccess } from './utils.js'; -import type { ToolContext, ToolResponse } from '../types/http.js'; - -/** - * Retrieves the most recent knowledge steps from the store. - * - * @param input - The step retrieval options including count and scope. - * @param context - The tool execution context. - * @returns The retrieved knowledge steps. - */ -export async function knowledgeLastTool( - input: KnowledgeLastInput, - context: ToolContext, -): Promise> { - const sessionId = context.sessionManager.getSessionId(); - const nSteps = input.n ?? 20; - const scope: KnowledgeScope = input.scope ?? 'current'; - - try { - const steps = await context.knowledgeStore.getLastSteps( - nSteps, - scope, - sessionId, - input.filters, - ); - - return createToolSuccess({ steps }); - } catch (error) { - return createToolError( - ErrorCodes.MM_KNOWLEDGE_ERROR, - `Failed to retrieve steps: ${extractErrorMessage(error)}`, - ); - } -} - -/** - * Searches knowledge steps by query string. - * - * @param input - The search query, limit, scope, and filters. - * @param context - The tool execution context. - * @returns The matching knowledge steps and query. - */ -export async function knowledgeSearchTool( - input: KnowledgeSearchInput, - context: ToolContext, -): Promise> { - const sessionId = context.sessionManager.getSessionId(); - const limit = input.limit ?? 20; - const scope: KnowledgeScope = input.scope ?? 'all'; - - try { - const matches = await context.knowledgeStore.searchSteps( - input.query, - limit, - scope, - sessionId, - input.filters, - ); - - return createToolSuccess({ - matches, - query: input.query, - }); - } catch (error) { - return createToolError( - ErrorCodes.MM_KNOWLEDGE_ERROR, - `Search failed: ${extractErrorMessage(error)}`, - ); - } -} - -/** - * Generates a summary of a knowledge session. - * - * @param input - The session ID or scope to summarize. - * @param context - The tool execution context. - * @returns The session summary. - */ -export async function knowledgeSummarizeTool( - input: KnowledgeSummarizeInput, - context: ToolContext, -): Promise> { - const currentSessionId = context.sessionManager.getSessionId(); - - let targetSessionId: string | undefined; - - if (input.sessionId) { - targetSessionId = input.sessionId; - } else if (input.scope) { - if (input.scope === 'all') { - return createToolError( - ErrorCodes.MM_INVALID_INPUT, - 'Cannot summarize all sessions. Use scope="current" or provide a specific sessionId.', - ); - } - - if (input.scope === 'current') { - targetSessionId = currentSessionId; - } else if (typeof input.scope === 'object' && 'sessionId' in input.scope) { - targetSessionId = input.scope.sessionId; - } - } else { - targetSessionId = currentSessionId; - } - - if (!targetSessionId) { - return createToolError( - ErrorCodes.MM_INVALID_INPUT, - 'No sessionId provided and no active session', - ); - } - - try { - const summary = - await context.knowledgeStore.summarizeSession(targetSessionId); - return createToolSuccess(summary); - } catch (error) { - return createToolError( - ErrorCodes.MM_KNOWLEDGE_ERROR, - `Summarize failed: ${extractErrorMessage(error)}`, - ); - } -} - -/** - * Lists available knowledge sessions with optional filters. - * - * @param input - The listing options including limit and filters. - * @param context - The tool execution context. - * @returns The list of knowledge sessions. - */ -export async function knowledgeSessionsTool( - input: KnowledgeSessionsInput, - context: ToolContext, -): Promise> { - const limit = input.limit ?? 10; - - try { - const sessions = await context.knowledgeStore.listSessions( - limit, - input.filters, - ); - - return createToolSuccess({ sessions }); - } catch (error) { - return createToolError( - ErrorCodes.MM_KNOWLEDGE_ERROR, - `Failed to list sessions: ${extractErrorMessage(error)}`, - ); - } -} diff --git a/src/tools/launch.test.ts b/src/tools/launch.test.ts index 3f9151f..4774a05 100644 --- a/src/tools/launch.test.ts +++ b/src/tools/launch.test.ts @@ -26,9 +26,6 @@ function createMockContext( page: {} as ToolContext['page'], refMap: new Map(), workflowContext: {}, - knowledgeStore: { - writeSessionMetadata: vi.fn().mockResolvedValue('test-session-123'), - }, } as unknown as ToolContext; } @@ -151,8 +148,6 @@ describe('launchTool', () => { fixturePreset: 'test-preset', autoBuild: false, slowMo: 100, - goal: 'Test send flow', - flowTags: ['send', 'transaction'], tags: ['smoke-test'], seedContracts: ['hst', 'nfts'], ports: { diff --git a/src/tools/mock-network.test.ts b/src/tools/mock-network.test.ts index f405f58..2d6863f 100644 --- a/src/tools/mock-network.test.ts +++ b/src/tools/mock-network.test.ts @@ -73,7 +73,6 @@ function createMockContext( extensionName: 'MetaMask', }, }, - knowledgeStore: {}, toolRegistry: new Map(), } as unknown as ToolContext; } diff --git a/src/tools/navigation.test.ts b/src/tools/navigation.test.ts index c3ef76b..a00b5b3 100644 --- a/src/tools/navigation.test.ts +++ b/src/tools/navigation.test.ts @@ -43,7 +43,6 @@ function createMockContext( page: page as never, refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, } as unknown as ToolContext; } diff --git a/src/tools/registry.test.ts b/src/tools/registry.test.ts index 0e972b7..3693ffa 100644 --- a/src/tools/registry.test.ts +++ b/src/tools/registry.test.ts @@ -40,7 +40,7 @@ describe('toolRegistry', () => { }); it('has the expected number of entries', () => { - expect(toolRegistry.size).toBe(47); + expect(toolRegistry.size).toBe(43); }); it('stores only functions as values', () => { @@ -77,8 +77,8 @@ describe('TOOL_CATEGORIES and getToolCategory', () => { expect(getToolCategory('click')).toBe('mutating'); }); - it('getToolCategory returns readonly for knowledge_last', () => { - expect(getToolCategory('knowledge_last')).toBe('readonly'); + it('getToolCategory returns readonly for get_context', () => { + expect(getToolCategory('get_context')).toBe('readonly'); }); it('getToolCategory returns discovery for describe_screen', () => { diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 9592bb1..ff85eaf 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -34,12 +34,6 @@ import { typeTool, waitForTool, } from './interaction.js'; -import { - knowledgeLastTool, - knowledgeSearchTool, - knowledgeSessionsTool, - knowledgeSummarizeTool, -} from './knowledge.js'; import { launchTool } from './launch.js'; import { mockNetworkTool } from './mock-network.js'; import { @@ -81,10 +75,6 @@ export const toolRegistry = new Map>([ ['type', typeTool], ['wait_for', waitForTool], ['get_text', getTextTool], - ['knowledge_last', knowledgeLastTool], - ['knowledge_search', knowledgeSearchTool], - ['knowledge_summarize', knowledgeSummarizeTool], - ['knowledge_sessions', knowledgeSessionsTool], ['seed_contract', seedContractTool], ['seed_contracts', seedContractsTool], ['get_contract_address', getContractAddressTool], @@ -151,10 +141,6 @@ export const TOOL_CATEGORIES: Record = { screen_recording: 'readonly', device_logs: 'readonly', generate_locators: 'readonly', - knowledge_last: 'readonly', - knowledge_search: 'readonly', - knowledge_summarize: 'readonly', - knowledge_sessions: 'readonly', get_text: 'readonly', get_state: 'readonly', get_context: 'readonly', diff --git a/src/tools/screenshot.test.ts b/src/tools/screenshot.test.ts index 32d395b..03c619d 100644 --- a/src/tools/screenshot.test.ts +++ b/src/tools/screenshot.test.ts @@ -27,7 +27,6 @@ function createMockContext( page, refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, driver: hasActive ? new PlaywrightPlatformDriver(() => page, sessionManager as any) : undefined, diff --git a/src/tools/seeding.test.ts b/src/tools/seeding.test.ts index 81738a1..4f1d225 100644 --- a/src/tools/seeding.test.ts +++ b/src/tools/seeding.test.ts @@ -55,7 +55,6 @@ function createMockContext( }, contractSeeding: workflowCapability, }, - knowledgeStore: {} as ToolContext['knowledgeStore'], toolRegistry: new Map(), } as unknown as ToolContext; } diff --git a/src/tools/state.test.ts b/src/tools/state.test.ts index 2b1da1a..c4e9f43 100644 --- a/src/tools/state.test.ts +++ b/src/tools/state.test.ts @@ -45,7 +45,6 @@ function createMockContext( page: activePage, refMap: new Map(), workflowContext: {}, - knowledgeStore: {}, driver: hasSession ? new PlaywrightPlatformDriver( () => activePage as any, diff --git a/src/tools/test-utils/index.ts b/src/tools/test-utils/index.ts index 76200db..17844a2 100644 --- a/src/tools/test-utils/index.ts +++ b/src/tools/test-utils/index.ts @@ -1,8 +1,6 @@ export { createMockSessionManager, - createMockKnowledgeStore, type MockSessionManagerOptions, - type MockKnowledgeStoreOptions, } from './mock-factories.js'; export { diff --git a/src/tools/test-utils/mock-factories.test.ts b/src/tools/test-utils/mock-factories.test.ts index 34a8423..03c09fa 100644 --- a/src/tools/test-utils/mock-factories.test.ts +++ b/src/tools/test-utils/mock-factories.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { createMockSessionManager, - createMockKnowledgeStore, createMockPage, createMockLocator, createMockBrowserContext, @@ -98,54 +97,6 @@ describe('mock-factories', () => { }); }); - describe('createMockKnowledgeStore', () => { - it('returns a fresh instance each call', () => { - const mock1 = createMockKnowledgeStore(); - const mock2 = createMockKnowledgeStore(); - - expect(mock1).not.toBe(mock2); - }); - - it('creates fresh vi.fn() mocks each call', () => { - const mock1 = createMockKnowledgeStore(); - const mock2 = createMockKnowledgeStore(); - - expect(mock1.recordStep).not.toBe(mock2.recordStep); - }); - - it('has all required KnowledgeStore methods', () => { - const mock = createMockKnowledgeStore(); - - expect(typeof mock.recordStep).toBe('function'); - expect(typeof mock.getLastSteps).toBe('function'); - expect(typeof mock.searchSteps).toBe('function'); - expect(typeof mock.summarizeSession).toBe('function'); - expect(typeof mock.listSessions).toBe('function'); - expect(typeof mock.writeSessionMetadata).toBe('function'); - }); - - it('returns sensible defaults', async () => { - const mock = createMockKnowledgeStore(); - - expect(await mock.getLastSteps?.(10, 'all', undefined)).toStrictEqual([]); - expect( - await mock.searchSteps?.('test', 10, 'all', undefined), - ).toStrictEqual([]); - expect(await mock.listSessions?.(10)).toStrictEqual([]); - }); - - it('allows customization via options', async () => { - const customSteps = [{ tool: 'click', screen: 'home' }]; - const mock = createMockKnowledgeStore({ - lastSteps: customSteps, - }); - - expect(await mock.getLastSteps?.(10, 'all', undefined)).toStrictEqual( - customSteps, - ); - }); - }); - describe('createMockPage', () => { it('returns a fresh instance each call', () => { const mock1 = createMockPage(); diff --git a/src/tools/test-utils/mock-factories.ts b/src/tools/test-utils/mock-factories.ts index 73df9d4..5585172 100644 --- a/src/tools/test-utils/mock-factories.ts +++ b/src/tools/test-utils/mock-factories.ts @@ -11,13 +11,11 @@ import { vi } from 'vitest'; import type { ExtensionState } from '../../capabilities/types.js'; -import type { KnowledgeStore } from '../../knowledge-store/knowledge-store.js'; import type { TrackedPage, SessionLaunchResult, } from '../../server/session-manager.js'; -import type { SessionState } from '../types/session.js'; -import type { SessionMetadata } from '../types/step-record.js'; +import type { SessionMetadata, SessionState } from '../types/session.js'; /** * Options for customizing mock session manager behavior @@ -34,16 +32,6 @@ export type MockSessionManagerOptions = { environmentMode?: 'e2e' | 'prod'; }; -/** - * Options for customizing mock knowledge store behavior - */ -export type MockKnowledgeStoreOptions = { - lastSteps?: Record[]; - searchResults?: Record[]; - sessionSummary?: Record; - sessions?: Record[]; -}; - /** * Create a fresh mock ISessionManager instance. * @@ -149,31 +137,3 @@ export function createMockSessionManager( }), }; } - -/** - * Create a fresh mock KnowledgeStore instance. - * - * Returns an in-memory mock with sensible defaults. - * Each call creates fresh vi.fn() mocks. - * - * @param options - Customize mock behavior - * @returns Fresh KnowledgeStore mock - */ -export function createMockKnowledgeStore( - options: MockKnowledgeStoreOptions = {}, -): Partial { - return { - recordStep: vi.fn().mockResolvedValue(undefined), - getLastSteps: vi.fn().mockResolvedValue(options.lastSteps ?? []), - searchSteps: vi.fn().mockResolvedValue(options.searchResults ?? []), - summarizeSession: vi.fn().mockResolvedValue( - options.sessionSummary ?? { - sessionId: 'test-session-123', - stepCount: 0, - recipe: [], - }, - ), - listSessions: vi.fn().mockResolvedValue(options.sessions ?? []), - writeSessionMetadata: vi.fn().mockResolvedValue('test-session-123'), - }; -} diff --git a/src/tools/types/errors.ts b/src/tools/types/errors.ts index d2477f2..5aac6a3 100644 --- a/src/tools/types/errors.ts +++ b/src/tools/types/errors.ts @@ -28,8 +28,6 @@ export const ErrorCodes = { MM_DISCOVERY_FAILED: 'MM_DISCOVERY_FAILED', MM_STATE_FAILED: 'MM_STATE_FAILED', - MM_KNOWLEDGE_ERROR: 'MM_KNOWLEDGE_ERROR', - MM_SEED_FAILED: 'MM_SEED_FAILED', MM_CONTRACT_NOT_FOUND: 'MM_CONTRACT_NOT_FOUND', diff --git a/src/tools/types/index.ts b/src/tools/types/index.ts index e4c7ce1..2ed53ae 100644 --- a/src/tools/types/index.ts +++ b/src/tools/types/index.ts @@ -2,7 +2,6 @@ export * from './errors.js'; export * from './seeding.js'; export type * from './tool-inputs.js'; export type * from './tool-outputs.js'; -export type * from './knowledge.js'; -export * from './step-record.js'; +export * from './observation.js'; export * from './discovery.js'; export type * from './session.js'; diff --git a/src/tools/types/knowledge.ts b/src/tools/types/knowledge.ts deleted file mode 100644 index f111004..0000000 --- a/src/tools/types/knowledge.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { TestIdItem, A11yNodeTrimmed } from './discovery.js'; - -export type PriorKnowledgeTarget = - | { - type: 'testId'; - value: string; - } - | { - type: 'selector'; - value: string; - } - | { - type: 'a11yHint'; - value: { - role: string; - name: string; - }; - }; - -export type PriorKnowledgeSuggestedAction = { - rank: number; - action: 'click' | 'type' | 'wait_for' | 'navigate' | 'wait_for_notification'; - rationale: string; - confidence: number; - preferredTarget: PriorKnowledgeTarget; - fallbackTargets?: PriorKnowledgeTarget[]; -}; - -export type PriorKnowledgeSimilarStep = { - sessionId: string; - timestamp: string; - tool: string; - screen: string; - snippet: string; - labels?: string[]; - target?: { - testId?: string; - selector?: string; - }; - a11yHint?: { - role: string; - name: string; - }; - confidence: number; -}; - -export type PriorKnowledgeAvoid = { - rationale: string; - target: { - selector?: string; - testId?: string; - }; - errorCode?: string; - frequency: number; -}; - -export type PriorKnowledgeRelatedSession = { - sessionId: string; - createdAt: string; - goal?: string; - flowTags: string[]; - tags: string[]; -}; - -export type PriorKnowledgeQuery = { - windowHours: number; - usedFlowTags: string[]; - usedFilters: Record; - candidateSessions: number; - candidateSteps: number; -}; - -export type PriorKnowledgeV1 = { - schemaVersion: 1; - generatedAt: string; - query: PriorKnowledgeQuery; - relatedSessions: PriorKnowledgeRelatedSession[]; - similarSteps: PriorKnowledgeSimilarStep[]; - suggestedNextActions: PriorKnowledgeSuggestedAction[]; - avoid?: PriorKnowledgeAvoid[]; -}; - -export type PriorKnowledgeContext = { - currentScreen: string; - currentUrl?: string; - visibleTestIds: TestIdItem[]; - a11yNodes: A11yNodeTrimmed[]; - currentSessionFlowTags?: string[]; -}; - -export type KnowledgeStepSummary = { - timestamp: string; - tool: string; - screen: string; - snippet: string; - sessionId?: string; - matchedFields?: string[]; - sessionGoal?: string; -}; - -export type KnowledgeLastResult = { - steps: KnowledgeStepSummary[]; -}; - -export type KnowledgeSearchResult = { - matches: KnowledgeStepSummary[]; - query: string; -}; - -export type RecipeStep = { - stepNumber: number; - tool: string; - notes: string; -}; - -export type KnowledgeSummarizeResult = { - sessionId: string; - stepCount: number; - recipe: RecipeStep[]; -}; - -export type SessionSummary = { - sessionId: string; - createdAt: string; - goal?: string; - flowTags: string[]; - tags: string[]; -}; - -export type KnowledgeSessionsResult = { - sessions: SessionSummary[]; -}; diff --git a/src/tools/types/observation.test.ts b/src/tools/types/observation.test.ts new file mode 100644 index 0000000..4e8cfc1 --- /dev/null +++ b/src/tools/types/observation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import type { A11yNodeTrimmed, TestIdItem } from './discovery.js'; +import { createDefaultObservation } from './observation.js'; +import type { ExtensionState } from '../../capabilities/types.js'; + +const state = { + isLoaded: true, + currentUrl: 'chrome-extension://ext/home.html', + extensionId: 'ext', + isUnlocked: true, + currentScreen: 'home', + accountAddress: null, + networkName: null, + chainId: null, + balance: null, +} as ExtensionState; + +describe('createDefaultObservation', () => { + it('defaults testIds and a11y nodes to empty arrays', () => { + expect(createDefaultObservation(state)).toStrictEqual({ + state, + testIds: [], + a11y: { nodes: [] }, + }); + }); + + it('includes the provided testIds and a11y nodes', () => { + const testIds = [ + { testId: 'send-button', tag: 'button', visible: true }, + ] as TestIdItem[]; + const nodes = [ + { ref: 'e1', role: 'button', name: 'Send', path: [] }, + ] as unknown as A11yNodeTrimmed[]; + + expect(createDefaultObservation(state, testIds, nodes)).toStrictEqual({ + state, + testIds, + a11y: { nodes }, + }); + }); +}); diff --git a/src/tools/types/observation.ts b/src/tools/types/observation.ts new file mode 100644 index 0000000..8e2682e --- /dev/null +++ b/src/tools/types/observation.ts @@ -0,0 +1,40 @@ +import type { TestIdItem, A11yNodeTrimmed } from './discovery.js'; +import type { ExtensionState } from '../../capabilities/types.js'; + +/** + * Screen snapshot collected after a tool runs: state, visible test IDs and + * the trimmed accessibility tree. + */ +export type StepRecordObservation = { + state: ExtensionState; + testIds: TestIdItem[]; + a11y: { + nodes: A11yNodeTrimmed[]; + /** Present only in diff-mode compact observations. */ + diff?: { + added: string[]; + removed: string[]; + unchanged: number; + }; + }; +}; + +/** + * Builds an observation from its parts. + * + * @param state - Extension or app state snapshot. + * @param testIds - Visible test IDs. + * @param a11yNodes - Trimmed accessibility nodes. + * @returns The observation. + */ +export function createDefaultObservation( + state: ExtensionState, + testIds: TestIdItem[] = [], + a11yNodes: A11yNodeTrimmed[] = [], +): StepRecordObservation { + return { + state, + testIds, + a11y: { nodes: a11yNodes }, + }; +} diff --git a/src/tools/types/session.ts b/src/tools/types/session.ts index 3a09de1..1fb7294 100644 --- a/src/tools/types/session.ts +++ b/src/tools/types/session.ts @@ -8,3 +8,23 @@ export type SessionState = { }; stateMode: 'default' | 'onboarding' | 'custom'; }; + +export type SessionMetadata = { + schemaVersion: 1; + sessionId: string; + createdAt: string; + tags: string[]; + build?: { + buildType?: 'build:test'; + extensionPathResolved?: string; + }; + launch: { + stateMode: 'default' | 'onboarding' | 'custom'; + fixturePreset?: string | null; + extensionPath?: string; + ports?: { + anvil?: number; + fixtureServer?: number; + }; + }; +}; diff --git a/src/tools/types/step-record.ts b/src/tools/types/step-record.ts deleted file mode 100644 index a5cc6e5..0000000 --- a/src/tools/types/step-record.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { TestIdItem, A11yNodeTrimmed } from './discovery.js'; -import type { ExtensionState } from '../../capabilities/types.js'; - -export const FLOW_TAGS = [ - 'send', - 'swap', - 'connect', - 'sign', - 'onboarding', - 'settings', - 'tx-confirmation', -] as const; - -export type FlowTag = (typeof FLOW_TAGS)[number]; - -export const STEP_LABELS = [ - 'discovery', - 'navigation', - 'interaction', - 'confirmation', - 'error-recovery', -] as const; - -export type StepLabel = (typeof STEP_LABELS)[number]; - -export type StepRecordEnvironment = { - platform?: string; - nodeVersion?: string; - yarnVersion?: string; -}; - -export type StepRecordBuild = { - buildType?: 'build:test'; - extensionPathResolved?: string; -}; - -export type StepRecordTool = { - name: string; - input?: Record; - target?: { - selector?: string; - testId?: string; - a11yRef?: string; - }; - textRedacted?: boolean; - textLength?: number; -}; - -export type StepRecordTiming = { - durationMs?: number; -}; - -export type StepRecordOutcome = { - ok: boolean; - error?: { - code?: string; - message?: string; - details?: Record; - }; -}; - -export type StepRecordObservation = { - state: ExtensionState; - testIds: TestIdItem[]; - a11y: { - nodes: A11yNodeTrimmed[]; - /** Present only in diff-mode compact observations (Phase 2). */ - diff?: { - added: string[]; - removed: string[]; - unchanged: number; - }; - }; -}; - -export type StepRecordArtifacts = { - screenshot?: { - path?: string; - width?: number; - height?: number; - }; -}; - -export type StepRecord = { - schemaVersion: 1; - timestamp: string; - sessionId: string; - context?: 'e2e' | 'prod'; - environment?: StepRecordEnvironment; - build?: StepRecordBuild; - tool: StepRecordTool; - timing?: StepRecordTiming; - outcome: StepRecordOutcome; - observation: StepRecordObservation; - artifacts?: StepRecordArtifacts; - labels?: string[]; -}; - -export type SessionMetadata = { - schemaVersion: 1; - sessionId: string; - createdAt: string; - goal?: string; - flowTags: string[]; - tags: string[]; - build?: StepRecordBuild; - launch: { - stateMode: 'default' | 'onboarding' | 'custom'; - fixturePreset?: string | null; - extensionPath?: string; - ports?: { - anvil?: number; - fixtureServer?: number; - }; - }; -}; diff --git a/src/tools/types/tool-inputs.ts b/src/tools/types/tool-inputs.ts index a27347f..58aa1ee 100644 --- a/src/tools/types/tool-inputs.ts +++ b/src/tools/types/tool-inputs.ts @@ -19,8 +19,6 @@ export type LaunchInput = { }; slowMo?: number; extensionPath?: string; - goal?: string; - flowTags?: string[]; tags?: string[]; seedContracts?: SmartContractName[]; force?: boolean; @@ -123,43 +121,6 @@ export type GetTextInput = TargetSelection & { within?: WithinTarget; }; -export type KnowledgeScope = - | 'current' - | 'all' - | { - sessionId: string; - }; - -export type KnowledgeFilters = { - flowTag?: string; - tag?: string; - screen?: string; - sinceHours?: number; -}; - -export type KnowledgeLastInput = { - n?: number; - scope?: KnowledgeScope; - filters?: KnowledgeFilters; -}; - -export type KnowledgeSearchInput = { - query: string; - limit?: number; - scope?: KnowledgeScope; - filters?: KnowledgeFilters; -}; - -export type KnowledgeSummarizeInput = { - sessionId?: string; - scope?: KnowledgeScope; -}; - -export type KnowledgeSessionsInput = { - limit?: number; - filters?: KnowledgeFilters; -}; - export type RunStepsInput = { steps: { tool: string; diff --git a/src/types/http.ts b/src/types/http.ts index 604f99d..ff73d91 100644 --- a/src/types/http.ts +++ b/src/types/http.ts @@ -7,7 +7,6 @@ import type { Page } from '@playwright/test'; import type { PortMap, WorkflowContext } from '../capabilities/context.js'; -import type { KnowledgeStore } from '../knowledge-store/knowledge-store.js'; import type { IPlatformDriver } from '../platform/types.js'; import type { ISessionManager } from '../server/session-manager.js'; @@ -15,7 +14,7 @@ import type { ISessionManager } from '../server/session-manager.js'; * Context passed to standalone tool functions. * * This context provides access to the session manager, current page, - * accessibility reference map, workflow capabilities, and knowledge store. + * accessibility reference map and workflow capabilities. */ export type ToolContext = { /** Session manager for browser session control */ @@ -26,8 +25,6 @@ export type ToolContext = { get refMap(): Map; /** Workflow context with capabilities and environment config */ workflowContext: WorkflowContext; - /** Knowledge store for session history and prior knowledge */ - knowledgeStore: KnowledgeStore; /** Tool registry for batch execution (run_steps) */ toolRegistry: Map>; /** Platform driver for cross-platform element interaction (browser, iOS, Android) */ @@ -92,8 +89,6 @@ export type ServerConfig = { sessionManager: ISessionManager; /** Factory function to create workflow context (may be sync or async) */ contextFactory: () => WorkflowContext | Promise; - /** Shared knowledge store instance (optional — a new instance is created if omitted) */ - knowledgeStore?: KnowledgeStore; /** Idle timeout for daemon auto-shutdown in milliseconds (default: 1_800_000 = 30 min) */ idleShutdownMs?: number; /** Per-request execution timeout in milliseconds (default: 30_000) */ diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index 87de885..3238fe2 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -38,42 +38,9 @@ export const targetSelectionSchema = z }, ); -export const knowledgeScopeSchema = z.union([ - z.literal('current').describe('Only search the active session'), - z.literal('all').describe('Search all sessions in the knowledge store'), - z - .object({ - sessionId: z.string().min(4).describe('Specific session ID to query'), - }) - .describe('Query a specific prior session by ID'), -]); - const smartContractNames = SMART_CONTRACT_NAMES; const hardforks = HARDFORKS; -export const knowledgeFiltersSchema = z - .object({ - flowTag: z - .string() - .min(1) - .describe('Filter by flow tag (e.g., send, swap, connect, sign)') - .optional(), - tag: z.string().min(1).describe('Filter by free-form tag').optional(), - screen: z - .string() - .min(1) - .describe('Filter by screen name (e.g., home, unlock, settings)') - .optional(), - sinceHours: z - .number() - .int() - .min(1) - .max(720) - .describe('Only include sessions/steps from the last N hours') - .optional(), - }) - .optional(); - const networkMockResponseSchema = z .object({ status: z @@ -206,19 +173,6 @@ export const launchInputSchema = z .string() .describe('Custom path to built extension directory') .optional(), - goal: z - .string() - .describe( - 'Goal or task description for this session (for knowledge store)', - ) - .optional(), - flowTags: z - .array(z.string()) - .describe( - 'Flow tags for categorization (e.g., ["send"], ["swap", "confirmation"]). ' + - 'Used for cross-session knowledge retrieval.', - ) - .optional(), tags: z .array(z.string()) .describe('Free-form tags for ad-hoc filtering') @@ -509,72 +463,6 @@ export const getTextInputSchema = targetSelectionSchema.and( }), ); -export const knowledgeLastInputSchema = z.object({ - n: z - .number() - .int() - .min(1) - .max(200) - .default(20) - .describe('Number of recent steps to retrieve'), - scope: knowledgeScopeSchema - .default('current') - .describe( - 'Scope for retrieval: current session, all sessions, or specific session', - ), - filters: knowledgeFiltersSchema, -}); - -export const knowledgeSearchInputSchema = z.object({ - query: z - .string() - .min(1) - .max(200) - .describe( - 'Search query - matches tool names, screen names, testIds, and a11y names', - ), - limit: z - .number() - .int() - .min(1) - .max(100) - .default(20) - .describe('Maximum number of results'), - scope: knowledgeScopeSchema - .default('all') - .describe( - 'Search scope (defaults to all sessions for cross-session learning)', - ), - filters: knowledgeFiltersSchema, -}); - -export const knowledgeSummarizeInputSchema = z.object({ - sessionId: z - .string() - .describe('Deprecated: use scope. Session ID to summarize.') - .optional(), - scope: z - .union([ - z.literal('current'), - z.object({ - sessionId: z.string().min(4), - }), - ]) - .default('current') - .describe('Session to summarize (cannot use "all")'), -}); - -export const knowledgeSessionsInputSchema = z.object({ - limit: z - .number() - .int() - .min(1) - .max(50) - .default(10) - .describe('Maximum number of sessions to list'), - filters: knowledgeFiltersSchema, -}); - export const seedContractInputSchema = z.object({ contractName: z.enum(smartContractNames), hardfork: z.enum(hardforks).optional(), @@ -988,10 +876,6 @@ export const toolSchemas = { type: typeInputSchema, wait_for: waitForInputSchema, get_text: getTextInputSchema, - knowledge_last: knowledgeLastInputSchema, - knowledge_search: knowledgeSearchInputSchema, - knowledge_summarize: knowledgeSummarizeInputSchema, - knowledge_sessions: knowledgeSessionsInputSchema, seed_contract: seedContractInputSchema, seed_contracts: seedContractsInputSchema, get_contract_address: getContractAddressInputSchema, @@ -1041,16 +925,6 @@ export type ClickInputZ = z.infer; export type TypeInputZ = z.infer; export type WaitForInputZ = z.infer; export type GetTextInputZ = z.infer; -export type KnowledgeLastInputZ = z.infer; -export type KnowledgeSearchInputZ = z.infer; -export type KnowledgeSummarizeInputZ = z.infer< - typeof knowledgeSummarizeInputSchema ->; -export type KnowledgeSessionsInputZ = z.infer< - typeof knowledgeSessionsInputSchema ->; -export type KnowledgeScopeZ = z.infer; -export type KnowledgeFiltersZ = z.infer; export type RunStepsInputZ = z.infer; export type SwitchToTabInputZ = z.infer; export type CloseTabInputZ = z.infer;