diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69c8e34d..05d63735 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -726,6 +726,107 @@ jobs: echo "=== API Server Logs ===" cat /tmp/api-server.log || echo "No API server log found" + test-openclaw-integration: + runs-on: ubuntu-latest + env: + HINDSIGHT_API_LLM_PROVIDER: groq + HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} + HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b + HINDSIGHT_API_URL: http://localhost:8888 + HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + prune-cache: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: ".python-version" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build API + working-directory: ./hindsight-api + run: uv build + + - name: Install API dependencies + working-directory: ./hindsight-api + run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match + + - name: Install embed dependencies + working-directory: ./hindsight-embed + run: uv sync --frozen --index-strategy unsafe-best-match + + - name: Cache HuggingFace models + uses: actions/cache@v4 + with: + path: ~/.cache/huggingface + key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-huggingface- + + - name: Pre-download models + working-directory: ./hindsight-api + run: | + uv run python -c " + from sentence_transformers import SentenceTransformer, CrossEncoder + print('Downloading embedding model...') + SentenceTransformer('BAAI/bge-small-en-v1.5') + print('Downloading cross-encoder model...') + CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2') + print('Models downloaded successfully') + " + + - name: Install openclaw integration dependencies + working-directory: ./hindsight-integrations/openclaw + run: npm ci + + - name: Create .env file + run: | + cat > .env << EOF + HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }} + HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }} + HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }} + EOF + + - name: Start API server + run: | + ./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 & + echo "Waiting for API server to be ready..." + for i in {1..60}; do + if curl -sf http://localhost:8888/health > /dev/null 2>&1; then + echo "API server is ready after ${i}s" + break + fi + if [ $i -eq 60 ]; then + echo "API server failed to start after 60s" + cat /tmp/api-server.log + exit 1 + fi + sleep 1 + done + + - name: Run openclaw integration tests + working-directory: ./hindsight-integrations/openclaw + run: npm run test:integration + + - name: Show API server logs + if: always() + run: | + echo "=== API Server Logs ===" + cat /tmp/api-server.log || echo "No API server log found" + test-integration: runs-on: ubuntu-latest env: diff --git a/hindsight-integrations/openclaw/package.json b/hindsight-integrations/openclaw/package.json index 0801e8d3..4d555cd3 100644 --- a/hindsight-integrations/openclaw/package.json +++ b/hindsight-integrations/openclaw/package.json @@ -34,8 +34,9 @@ "build": "tsc", "dev": "tsc --watch", "clean": "rm -rf dist", - "test": "vitest run", - "test:watch": "vitest", + "test": "vitest run src", + "test:watch": "vitest src", + "test:integration": "vitest run --config vitest.integration.config.ts", "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { diff --git a/hindsight-integrations/openclaw/src/client.ts b/hindsight-integrations/openclaw/src/client.ts index 2690cb85..803b5232 100644 --- a/hindsight-integrations/openclaw/src/client.ts +++ b/hindsight-integrations/openclaw/src/client.ts @@ -237,20 +237,7 @@ export class HindsightClient { throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`); } - const response = await res.json() as { results?: any[] }; - const results = response.results || []; - - return { - results: results.map((r: any) => ({ - content: r.text || r.content || '', - score: r.score ?? 1.0, - metadata: { - document_id: r.document_id, - chunk_id: r.chunk_id, - ...r.metadata, - }, - })), - }; + return res.json() as Promise; } private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise { @@ -265,21 +252,7 @@ export class HindsightClient { timeout: timeoutMs ?? 30_000, // subprocess gets a longer default }); - // Parse JSON output - returns { entities: {...}, results: [...] } - const response = JSON.parse(stdout); - const results = response.results || []; - - return { - results: results.map((r: any) => ({ - content: r.text || r.content || '', - score: 1.0, // CLI doesn't return scores - metadata: { - document_id: r.document_id, - chunk_id: r.chunk_id, - ...r.metadata, - }, - })), - }; + return JSON.parse(stdout) as RecallResponse; } catch (error) { throw new Error(`Failed to recall memories: ${error}`, { cause: error }); } diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index f0eff7bc..4d19984b 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -1,83 +1,143 @@ import { describe, it, expect } from 'vitest'; +import { stripMemoryTags, extractRecallQuery } from './index.js'; -/** - * Unit tests for the memory feedback loop fix. - * Verifies that and tags - * are stripped from content before RETAIN to prevent duplicates. - */ -describe('Memory Tag Stripping', () => { - /** - * Simulates the tag stripping logic from agent_end hook - */ - function stripMemoryTags(content: string): string { - // Strip plugin-injected memory tags to prevent feedback loop - content = content.replace(/[\s\S]*?<\/hindsight_memories>/g, ''); - content = content.replace(/[\s\S]*?<\/relevant_memories>/g, ''); - return content; - } +// --------------------------------------------------------------------------- +// stripMemoryTags +// --------------------------------------------------------------------------- - it('should strip simple hindsight_memories tags', () => { - const input = 'User: Hello\n\nRelevant memories here...\n\nAssistant: How can I help?'; - const expected = 'User: Hello\n\nAssistant: How can I help?'; - const result = stripMemoryTags(input); - expect(result).toBe(expected); +describe('stripMemoryTags', () => { + it('strips simple hindsight_memories tags', () => { + const input = + 'User: Hello\n\nRelevant memories here...\n\nAssistant: How can I help?'; + expect(stripMemoryTags(input)).toBe('User: Hello\n\nAssistant: How can I help?'); }); - it('should strip relevant_memories tags', () => { + it('strips relevant_memories tags', () => { const input = 'Before\n\nSome data\n\nAfter'; - const expected = 'Before\n\nAfter'; - const result = stripMemoryTags(input); - expect(result).toBe(expected); + expect(stripMemoryTags(input)).toBe('Before\n\nAfter'); }); - it('should strip multiple hindsight_memories blocks', () => { - const input = 'Start\n\nBlock 1\n\nMiddle\n\nBlock 2\n\nEnd'; - const expected = 'Start\n\nMiddle\n\nEnd'; - const result = stripMemoryTags(input); - expect(result).toBe(expected); + it('strips multiple hindsight_memories blocks', () => { + const input = + 'Start\n\nBlock 1\n\nMiddle\n\nBlock 2\n\nEnd'; + expect(stripMemoryTags(input)).toBe('Start\n\nMiddle\n\nEnd'); }); - it('should handle multiline memory blocks with JSON', () => { - const input = 'User: What is the weather?\n\nRelevant memories:\n{\n "memory": "User likes sunny weather"\n}\n\nAssistant: Let me check'; - const expected = 'User: What is the weather?\n\nAssistant: Let me check'; + it('handles multiline memory blocks with JSON', () => { + const input = + 'User: What is the weather?\n\n[\n {"memory": "User likes sunny weather"}\n]\n\nAssistant: Let me check'; const result = stripMemoryTags(input); - expect(result).toBe(expected); + expect(result).toBe('User: What is the weather?\n\nAssistant: Let me check'); }); - it('should preserve content without memory tags', () => { + it('preserves content without memory tags', () => { const input = 'User: Hello\nAssistant: Hi there!'; - const expected = 'User: Hello\nAssistant: Hi there!'; - const result = stripMemoryTags(input); - expect(result).toBe(expected); + expect(stripMemoryTags(input)).toBe(input); }); - it('should handle nested-like content without actual nesting', () => { - const input = 'Outer start\n\nSafe content\n\nOuter end'; - const expected = '\nSafe content\n'; - const result = stripMemoryTags(input); - expect(result).toBe(expected); + it('strips both tag types in same content', () => { + const input = + 'A\n\nH mem\n\nB\n\nR mem\n\nC'; + expect(stripMemoryTags(input)).toBe('A\n\nB\n\nC'); }); - it('should strip both tag types in same content', () => { - const input = 'A\n\nH mem\n\nB\n\nR mem\n\nC'; - const expected = 'A\n\nB\n\nC'; - const result = stripMemoryTags(input); - expect(result).toBe(expected); - }); - - it('should handle real-world agent conversation with injected memories', () => { - const input = '[role: system]\n\nRelevant memories from past conversations (score 1=highest, prioritize recent when conflicting):\n[\n {\n "content": "User prefers dark mode",\n "relevance_score": 0.95\n }\n]\n\nUser message: How do I enable dark mode?\n\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nBased on your previous preference, let me help you enable dark mode.\n[assistant:end]'; + it('strips tags from a real-world agent conversation with injected memories', () => { + const input = + '[role: system]\n\nRelevant memories:\n[{"text": "User prefers dark mode"}]\nUser message: How do I enable dark mode?\n\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nLet me help you enable dark mode.\n[assistant:end]'; const result = stripMemoryTags(input); - // Should not contain the memory tags expect(result).not.toContain(''); expect(result).not.toContain(''); - expect(result).not.toContain('Relevant memories from past conversations'); - - // Should still contain the actual conversation + expect(result).not.toContain('User prefers dark mode'); expect(result).toContain('[role: user]'); expect(result).toContain('How do I enable dark mode?'); expect(result).toContain('[role: assistant]'); }); }); + +// --------------------------------------------------------------------------- +// extractRecallQuery +// --------------------------------------------------------------------------- + +describe('extractRecallQuery', () => { + it('returns rawMessage when it is long enough', () => { + expect(extractRecallQuery('What is my favorite food?', undefined)).toBe( + 'What is my favorite food?', + ); + }); + + it('returns null when rawMessage is too short and prompt is absent', () => { + expect(extractRecallQuery('Hi', undefined)).toBeNull(); + expect(extractRecallQuery('', '')).toBeNull(); + expect(extractRecallQuery(undefined, undefined)).toBeNull(); + }); + + it('returns null when both rawMessage and prompt are too short', () => { + expect(extractRecallQuery('Hey', 'Hey')).toBeNull(); + }); + + it('falls back to prompt when rawMessage is absent', () => { + const result = extractRecallQuery(undefined, 'What programming language do I prefer?'); + expect(result).toBe('What programming language do I prefer?'); + }); + + it('strips leading System: lines from prompt', () => { + const prompt = 'System: You are an agent.\nSystem: Use tools wisely.\n\nWhat is my name?'; + const result = extractRecallQuery(undefined, prompt); + expect(result).not.toContain('System:'); + expect(result).toContain('What is my name?'); + }); + + it('strips [Channel] envelope header and returns inner message', () => { + const prompt = '[Telegram Chat]\nWhat is my favorite hobby?'; + const result = extractRecallQuery(undefined, prompt); + expect(result).toBe('What is my favorite hobby?'); + }); + + it('strips [from: SenderName] footer from group chat prompts', () => { + const prompt = '[Slack Channel #general]\nWhat should I eat for lunch?\n[from: Alice]'; + const result = extractRecallQuery(undefined, prompt); + expect(result).not.toContain('[from: Alice]'); + expect(result).toContain('What should I eat for lunch?'); + }); + + it('handles full envelope with System lines, channel header, and from footer', () => { + const prompt = + 'System: You are a helpful agent.\n\n[Discord Server]\nRemind me what I said about Python?\n[from: Bob]'; + const result = extractRecallQuery(undefined, prompt); + expect(result).not.toContain('System:'); + expect(result).not.toContain('[Discord'); + expect(result).not.toContain('[from: Bob]'); + expect(result).toContain('Remind me what I said about Python?'); + }); + + it('strips session abort hint from prompt', () => { + const prompt = + 'Note: The previous agent run was aborted by the user\n\n[Telegram]\nWhat is my cat\'s name?'; + const result = extractRecallQuery(undefined, prompt); + expect(result).not.toContain('Note: The previous agent run was aborted'); + expect(result).toContain("What is my cat's name?"); + }); + + it('returns null when prompt reduces to < 5 chars after stripping', () => { + // Envelope with almost-empty inner message + const prompt = '[Telegram Chat]\nHi'; + const result = extractRecallQuery(undefined, prompt); + expect(result).toBeNull(); + }); + + it('prefers rawMessage over prompt even when prompt is longer', () => { + const rawMessage = 'What do I like to eat?'; + const prompt = '[Telegram]\nWhat do I like to eat?\n[from: Alice]'; + const result = extractRecallQuery(rawMessage, prompt); + // Should return the clean rawMessage verbatim + expect(result).toBe(rawMessage); + expect(result).not.toContain('[from: Alice]'); + }); + + it('trims whitespace from result', () => { + const result = extractRecallQuery(' What is my job? ', undefined); + expect(result).toBe('What is my job?'); + }); +}); diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index d11da8b0..fe0f5bcf 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -138,6 +138,67 @@ const __dirname = dirname(__filename); // Default bank name (fallback when channel context not available) const DEFAULT_BANK_NAME = 'openclaw'; +/** + * Strip plugin-injected memory tags from content to prevent retain feedback loop. + * Removes and blocks that were injected + * during before_agent_start so they don't get re-stored into the memory bank. + */ +export function stripMemoryTags(content: string): string { + content = content.replace(/[\s\S]*?<\/hindsight_memories>/g, ''); + content = content.replace(/[\s\S]*?<\/relevant_memories>/g, ''); + return content; +} + +/** + * Extract a recall query from a hook event's rawMessage or prompt. + * + * Prefers rawMessage (clean user text). Falls back to prompt, stripping + * envelope formatting (System: lines, [Channel ...] headers, [from: X] footers). + * + * Returns null when no usable query (< 5 chars) can be extracted. + */ +export function extractRecallQuery( + rawMessage: string | undefined, + prompt: string | undefined, +): string | null { + let recallQuery = rawMessage; + if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) { + recallQuery = prompt; + if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) { + return null; + } + + // Strip envelope-formatted prompts from any channel + let cleaned = recallQuery; + + // Remove leading "System: ..." lines (from prependSystemEvents) + cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, ''); + + // Remove session abort hint + cleaned = cleaned.replace( + /^Note: The previous agent run was aborted[^\n]*\n\n/, + '', + ); + + // Extract message after [ChannelName ...] envelope header + const envelopeMatch = cleaned.match( + /\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/, + ); + if (envelopeMatch) { + cleaned = envelopeMatch[1]; + } + + // Remove trailing [from: SenderName] metadata (group chats) + cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, ''); + + recallQuery = cleaned.trim() || recallQuery; + } + + const trimmed = recallQuery.trim(); + if (trimmed.length < 5) return null; + return trimmed; +} + /** * Agent context passed to plugin hooks. * These fields are populated by OpenClaw when invoking hooks. @@ -671,44 +732,11 @@ export default function (api: MoltbotPluginAPI) { // Get the user's latest message for recall — only the raw user text, not the full prompt // rawMessage is clean user text; prompt includes envelope, system events, media notes, etc. - let recallQuery = event.rawMessage; - if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) { - // Fall back to prompt but strip envelope formatting - recallQuery = event.prompt; - if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) { - return; - } - - // Strip envelope-formatted prompts from any channel - let cleaned = recallQuery; - - // Remove leading "System: ..." lines (from prependSystemEvents) - cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, ''); - - // Remove session abort hint - cleaned = cleaned.replace( - /^Note: The previous agent run was aborted[^\n]*\n\n/, - '', - ); - - // Extract message after [ChannelName ...] envelope header - const envelopeMatch = cleaned.match( - /\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/, - ); - if (envelopeMatch) { - cleaned = envelopeMatch[1]; - } - - // Remove trailing [from: SenderName] metadata (group chats) - cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, ''); - - recallQuery = cleaned.trim() || recallQuery; - } - - let prompt = recallQuery.trim(); - if (prompt.length < 5) { - return; // Skip very short messages after extraction + const extracted = extractRecallQuery(event.rawMessage, event.prompt); + if (!extracted) { + return; } + let prompt = extracted; // Truncate — Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII const MAX_RECALL_QUERY_CHARS = 800; @@ -758,7 +786,7 @@ export default function (api: MoltbotPluginAPI) { const memoriesJson = JSON.stringify(response.results, null, 2); const contextMessage = ` -Relevant memories from past conversations (score 1=highest, prioritize recent when conflicting): +Relevant memories from past conversations (prioritize recent when conflicting): ${memoriesJson} User message: ${prompt} @@ -835,10 +863,7 @@ User message: ${prompt} } // Strip plugin-injected memory tags to prevent feedback loop - // Remove blocks injected during before_agent_start - content = content.replace(/[\s\S]*?<\/hindsight_memories>/g, ''); - // Remove any blocks (legacy/alternative format) - content = content.replace(/[\s\S]*?<\/relevant_memories>/g, ''); + content = stripMemoryTags(content); return `[role: ${role}]\n${content}\n[${role}:end]`; }) diff --git a/hindsight-integrations/openclaw/src/types.ts b/hindsight-integrations/openclaw/src/types.ts index d8d34b48..d6015d19 100644 --- a/hindsight-integrations/openclaw/src/types.ts +++ b/hindsight-integrations/openclaw/src/types.ts @@ -72,16 +72,24 @@ export interface RecallRequest { export interface RecallResponse { results: MemoryResult[]; + entities: Record | null; + trace: unknown | null; + chunks: unknown | null; } export interface MemoryResult { - content: string; - score: number; - metadata?: { - document_id?: string; - created_at?: string; - source?: string; - }; + id: string; + text: string; + type: string; + entities: string[]; + context: string; + occurred_start: string | null; + occurred_end: string | null; + mentioned_at: string | null; + document_id: string | null; + metadata: Record | null; + chunk_id: string | null; + tags: string[]; } export interface CreateBankRequest { diff --git a/hindsight-integrations/openclaw/tests/hooks.integration.test.ts b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts new file mode 100644 index 00000000..6cc5a199 --- /dev/null +++ b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts @@ -0,0 +1,542 @@ +/** + * Integration tests for the OpenClaw plugin hooks. + * + * Loads the plugin with a mock MoltbotPluginAPI in HTTP mode, then triggers + * `before_agent_start` and `agent_end` hooks with realistic event payloads. + * Client methods (recall / retain) are spied on to verify the plugin + * orchestrates them correctly without requiring a full LLM pipeline. + * + * Requirements: + * Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888) + * + * Run: + * npm run test:integration + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import type { HindsightClient } from '../src/client.js'; +import type { MoltbotPluginAPI, PluginConfig } from '../src/types.js'; +import type { RecallResponse, RetainResponse } from '../src/types.js'; + +const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function waitForApi(url: string, maxMs = 5000): Promise { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) }); + if (res.ok) return true; + } catch { + /* not ready yet */ + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +interface MockApiHandle { + api: MoltbotPluginAPI; + /** Trigger a registered hook and return the last handler's return value. */ + trigger(event: string, eventData: unknown, ctx?: unknown): Promise; + startServices(): Promise; + stopServices(): Promise; +} + +function createMockApi(pluginConfig: Partial = {}): MockApiHandle { + const handlers = new Map unknown)[]>(); + const services: { id: string; start(): Promise; stop(): Promise }[] = []; + + const api: MoltbotPluginAPI = { + config: { + plugins: { + entries: { + 'hindsight-openclaw': { enabled: true, config: pluginConfig as PluginConfig }, + }, + }, + }, + registerService(svc: any) { + services.push(svc); + }, + on(event: string, handler: any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + }; + + return { + api, + async trigger(event, eventData, ctx) { + const list = handlers.get(event) ?? []; + let result: unknown; + for (const h of list) result = await h(eventData, ctx); + return result; + }, + async startServices() { + for (const svc of services) await svc.start(); + }, + async stopServices() { + for (const svc of services) await svc.stop(); + }, + }; +} + +const EMPTY_RECALL: RecallResponse = { results: [], entities: null, trace: null, chunks: null }; +const OK_RETAIN: RetainResponse = { message: 'queued', document_id: 'test', memory_unit_ids: [] }; + +function makeMemoryResult(text: string) { + return { + id: `mem-${Math.random().toString(36).slice(2)}`, + text, + type: 'fact', + entities: [], + context: '', + occurred_start: null, + occurred_end: null, + mentioned_at: null, + document_id: null, + metadata: null, + chunk_id: null, + tags: [], + }; +} + +// --------------------------------------------------------------------------- +// Module-level state shared across all hook describe blocks +// --------------------------------------------------------------------------- + +let apiReachable = false; +let triggerHook: MockApiHandle['trigger']; +let stopServicesFn: () => Promise; +let recallSpy: ReturnType>; +let retainSpy: ReturnType>; + +beforeAll(async () => { + apiReachable = await waitForApi(HINDSIGHT_API_URL, 8000); + if (!apiReachable) { + console.warn( + `[Hooks Integration] Hindsight API not reachable at ${HINDSIGHT_API_URL} – skipping hook tests.`, + ); + return; + } + + // Reset module registry so we get a fresh module with clean state. + vi.resetModules(); + + // Provide LLM config — used by plugin init even in HTTP mode. + process.env.HINDSIGHT_API_LLM_PROVIDER = 'openai'; + process.env.HINDSIGHT_API_LLM_API_KEY = 'test-key-hooks'; + // Point the plugin at the running test API. + process.env.HINDSIGHT_EMBED_API_URL = HINDSIGHT_API_URL; + + const mod = await import('../src/index.js'); + const pluginFn = mod.default; + const getClient = mod.getClient; + + const handle = createMockApi({ + dynamicBankId: true, + excludeProviders: ['slack'], + // No bankMission — keeps init lean + }); + triggerHook = handle.trigger; + stopServicesFn = handle.stopServices; + + // Load the plugin — registers hooks and starts background init. + pluginFn(handle.api); + + // service.start() awaits initPromise and health-checks the external API. + await handle.startServices(); + + // After startServices the client must be ready. + const c = getClient(); + if (!c) throw new Error('[Hooks Integration] Client not initialized after service start'); + + recallSpy = vi.spyOn(c, 'recall') as ReturnType>; + retainSpy = vi.spyOn(c, 'retain') as ReturnType>; +}, 30_000); + +afterAll(async () => { + vi.restoreAllMocks(); + delete process.env.HINDSIGHT_API_LLM_PROVIDER; + delete process.env.HINDSIGHT_API_LLM_API_KEY; + delete process.env.HINDSIGHT_EMBED_API_URL; + if (stopServicesFn) await stopServicesFn().catch(() => {}); +}, 15_000); + +afterEach(() => { + // Reset spy call history between tests; don't remove the implementation. + recallSpy?.mockReset(); + retainSpy?.mockReset(); +}); + +// --------------------------------------------------------------------------- +// before_agent_start +// --------------------------------------------------------------------------- + +describe('before_agent_start hook', () => { + it('skips recall for excluded providers and returns undefined', async () => { + if (!apiReachable) return; + + const result = await triggerHook( + 'before_agent_start', + { rawMessage: 'What are my preferences?', prompt: 'What are my preferences?' }, + { messageProvider: 'slack', senderId: 'U001' }, + ); + + expect(recallSpy).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('skips recall when rawMessage is too short and returns undefined', async () => { + if (!apiReachable) return; + + const result = await triggerHook( + 'before_agent_start', + { rawMessage: 'Hi', prompt: 'Hi' }, + { messageProvider: 'telegram', senderId: 'U001' }, + ); + + expect(recallSpy).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('returns undefined when recall finds no results', async () => { + if (!apiReachable) return; + recallSpy.mockResolvedValue(EMPTY_RECALL); + + const result = await triggerHook( + 'before_agent_start', + { rawMessage: 'What programming language do I like?', prompt: '' }, + { messageProvider: 'telegram', senderId: 'U002' }, + ); + + expect(recallSpy).toHaveBeenCalledOnce(); + expect(result).toBeUndefined(); + }); + + it('returns { prependContext } with when recall returns results', async () => { + if (!apiReachable) return; + recallSpy.mockResolvedValue({ + results: [makeMemoryResult('User likes Python')], + entities: null, + trace: null, + chunks: null, + }); + + const result = (await triggerHook( + 'before_agent_start', + { rawMessage: 'What programming language do I prefer?', prompt: '' }, + { messageProvider: 'telegram', senderId: 'U003' }, + )) as { prependContext: string }; + + expect(result).toBeDefined(); + expect(result.prependContext).toContain(''); + expect(result.prependContext).toContain('User likes Python'); + expect(result.prependContext).toContain(''); + }); + + it('injects all memory result fields in the prependContext JSON', async () => { + if (!apiReachable) return; + const mem = makeMemoryResult('User prefers dark mode'); + mem.tags = ['preference']; + mem.entities = ['dark_mode']; + recallSpy.mockResolvedValue({ + results: [mem], + entities: null, + trace: null, + chunks: null, + }); + + const result = (await triggerHook( + 'before_agent_start', + { rawMessage: 'Do I prefer dark or light mode?', prompt: '' }, + { messageProvider: 'telegram', senderId: 'U004' }, + )) as { prependContext: string }; + + // The prependContext should be valid JSON containing all MemoryResult fields + const jsonStart = result.prependContext.indexOf('['); + const jsonEnd = result.prependContext.lastIndexOf(']') + 1; + const parsed = JSON.parse(result.prependContext.slice(jsonStart, jsonEnd)) as unknown[]; + expect(parsed).toHaveLength(1); + const first = parsed[0] as Record; + expect(first.id).toBe(mem.id); + expect(first.text).toBe('User prefers dark mode'); + expect(first.type).toBe('fact'); + expect(first.tags).toEqual(['preference']); + expect(first.entities).toEqual(['dark_mode']); + }); + + it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => { + if (!apiReachable) return; + recallSpy.mockResolvedValue(EMPTY_RECALL); + + const envelopePrompt = '[Telegram Chat]\nWhat is my favorite food?\n[from: Alice]'; + await triggerHook( + 'before_agent_start', + { rawMessage: '', prompt: envelopePrompt }, + { messageProvider: 'telegram', senderId: 'U005' }, + ); + + expect(recallSpy).toHaveBeenCalledOnce(); + const [callArgs] = recallSpy.mock.calls[0]; + // The query passed to recall must NOT contain envelope artifacts + expect(callArgs.query).not.toContain('[Telegram'); + expect(callArgs.query).not.toContain('[from: Alice]'); + expect(callArgs.query).toContain('What is my favorite food?'); + }); + + it('passes max_tokens to recall', async () => { + if (!apiReachable) return; + recallSpy.mockResolvedValue(EMPTY_RECALL); + + await triggerHook( + 'before_agent_start', + { rawMessage: 'Tell me about my hobbies please.', prompt: '' }, + { messageProvider: 'telegram', senderId: 'U006' }, + ); + + expect(recallSpy).toHaveBeenCalledOnce(); + const [callArgs] = recallSpy.mock.calls[0]; + expect(callArgs.max_tokens).toBeGreaterThan(0); + }); + + it('includes the user message in the prependContext block', async () => { + if (!apiReachable) return; + recallSpy.mockResolvedValue({ + results: [makeMemoryResult('User loves hiking')], + entities: null, + trace: null, + chunks: null, + }); + + const result = (await triggerHook( + 'before_agent_start', + { rawMessage: 'What outdoor activities do I enjoy?', prompt: '' }, + { messageProvider: 'telegram', senderId: 'U007' }, + )) as { prependContext: string }; + + expect(result.prependContext).toContain('What outdoor activities do I enjoy?'); + }); +}); + +// --------------------------------------------------------------------------- +// agent_end hook +// --------------------------------------------------------------------------- + +describe('agent_end hook', () => { + it('skips retain when success is false', async () => { + if (!apiReachable) return; + + await triggerHook( + 'agent_end', + { success: false, messages: [{ role: 'user', content: 'Hello there world!' }] }, + { messageProvider: 'telegram', senderId: 'U010' }, + ); + + expect(retainSpy).not.toHaveBeenCalled(); + }); + + it('skips retain when messages array is empty', async () => { + if (!apiReachable) return; + + await triggerHook( + 'agent_end', + { success: true, messages: [] }, + { messageProvider: 'telegram', senderId: 'U011' }, + ); + + expect(retainSpy).not.toHaveBeenCalled(); + }); + + it('skips retain for excluded providers', async () => { + if (!apiReachable) return; + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: 'I work as a software engineer.' }], + }, + { messageProvider: 'slack', senderId: 'U012' }, + ); + + expect(retainSpy).not.toHaveBeenCalled(); + }); + + it('calls retain with correctly formatted transcript for string content', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + await triggerHook( + 'agent_end', + { + success: true, + messages: [ + { role: 'user', content: 'I love TypeScript.' }, + { role: 'assistant', content: 'TypeScript is great!' }, + ], + }, + { messageProvider: 'telegram', senderId: 'U013', sessionKey: 'sess-ts-test' }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + const [req] = retainSpy.mock.calls[0]; + expect(req.content).toContain('[role: user]'); + expect(req.content).toContain('I love TypeScript.'); + expect(req.content).toContain('[user:end]'); + expect(req.content).toContain('[role: assistant]'); + expect(req.content).toContain('TypeScript is great!'); + expect(req.content).toContain('[assistant:end]'); + }); + + it('includes session key in document_id', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: 'My favourite colour is blue.' }], + }, + { messageProvider: 'telegram', senderId: 'U014', sessionKey: 'sess-colour' }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + const [req] = retainSpy.mock.calls[0]; + expect(req.document_id).toContain('sess-colour'); + }); + + it('populates metadata with channel_type, channel_id, and sender_id', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: 'My cat is named Whiskers.' }], + }, + { + messageProvider: 'telegram', + channelId: 'chat-999', + senderId: 'U015', + sessionKey: 'sess-cat', + }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + const [req] = retainSpy.mock.calls[0]; + expect(req.metadata?.channel_type).toBe('telegram'); + expect(req.metadata?.channel_id).toBe('chat-999'); + expect(req.metadata?.sender_id).toBe('U015'); + expect(req.metadata?.retained_at).toBeDefined(); + expect(req.metadata?.message_count).toBe('1'); + }); + + it('strips tags from content before retaining', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + const contentWithMemories = + '\nRelevant memories:\n[{"text":"old fact"}]\n\nI enjoy reading science fiction.'; + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: contentWithMemories }], + }, + { messageProvider: 'telegram', senderId: 'U016', sessionKey: 'sess-strip' }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + const [req] = retainSpy.mock.calls[0]; + expect(req.content).not.toContain(''); + expect(req.content).not.toContain(''); + expect(req.content).not.toContain('old fact'); + expect(req.content).toContain('I enjoy reading science fiction.'); + }); + + it('strips tags from content before retaining', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + const contentWithLegacyTag = + '\nSome old memories\n\nI am learning Rust.'; + + await triggerHook( + 'agent_end', + { + success: true, + messages: [{ role: 'user', content: contentWithLegacyTag }], + }, + { messageProvider: 'telegram', senderId: 'U017', sessionKey: 'sess-legacy' }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + const [req] = retainSpy.mock.calls[0]; + expect(req.content).not.toContain(''); + expect(req.content).toContain('I am learning Rust.'); + }); + + it('handles array content blocks (structured message format)', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + await triggerHook( + 'agent_end', + { + success: true, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'I prefer dark mode in all my editors.' }, + { type: 'image', source: 'data:...' }, // non-text block — should be ignored + ], + }, + ], + }, + { messageProvider: 'telegram', senderId: 'U018', sessionKey: 'sess-array' }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + const [req] = retainSpy.mock.calls[0]; + expect(req.content).toContain('I prefer dark mode in all my editors.'); + // Image block text should not appear + expect(req.content).not.toContain('data:'); + }); + + it('retains a multi-turn conversation in the correct transcript format', async () => { + if (!apiReachable) return; + retainSpy.mockResolvedValue(OK_RETAIN); + + await triggerHook( + 'agent_end', + { + success: true, + messages: [ + { role: 'user', content: 'My name is Carol.' }, + { role: 'assistant', content: 'Nice to meet you, Carol!' }, + { role: 'user', content: 'I work as a data scientist.' }, + { role: 'assistant', content: "That's a fascinating career!" }, + ], + }, + { messageProvider: 'telegram', senderId: 'U019', sessionKey: 'sess-multi' }, + ); + + expect(retainSpy).toHaveBeenCalledOnce(); + const [req] = retainSpy.mock.calls[0]; + + // Each message should appear in the correct envelope format + expect(req.content).toContain('[role: user]\nMy name is Carol.\n[user:end]'); + expect(req.content).toContain('[role: assistant]\nNice to meet you, Carol!\n[assistant:end]'); + expect(req.content).toContain('[role: user]\nI work as a data scientist.\n[user:end]'); + expect(req.metadata?.message_count).toBe('4'); + }); +}); diff --git a/hindsight-integrations/openclaw/tests/integration.test.ts b/hindsight-integrations/openclaw/tests/integration.test.ts new file mode 100644 index 00000000..c21013d6 --- /dev/null +++ b/hindsight-integrations/openclaw/tests/integration.test.ts @@ -0,0 +1,385 @@ +/** + * Integration tests for the Hindsight OpenClaw integration. + * + * Tests both HTTP mode (direct API calls) and Embed mode (subprocess/daemon). + * + * Requirements: + * HTTP mode: Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888) + * Embed mode: hindsight-embed package at HINDSIGHT_EMBED_PACKAGE_PATH + * + LLM credentials (HINDSIGHT_API_LLM_PROVIDER / HINDSIGHT_API_LLM_API_KEY) + * + * Run: + * npm run test:integration + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { HindsightClient } from '../src/client.js'; +import { HindsightEmbedManager } from '../src/embed-manager.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// --------------------------------------------------------------------------- +// Test configuration (driven by environment variables) +// --------------------------------------------------------------------------- + +const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888'; +const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || ''; +const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || ''; +const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || ''; + +// Embed package path – defaults to the sibling hindsight-embed directory in the repo +const EMBED_PACKAGE_PATH = + process.env.HINDSIGHT_EMBED_PACKAGE_PATH || + join(__dirname, '..', '..', '..', 'hindsight-embed'); + +// Port for the test embed daemon (different from production default 9077 to avoid conflicts) +const EMBED_TEST_PORT = 19077; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function randomBankId(): string { + return `openclaw_test_${Math.random().toString(36).slice(2, 14)}`; +} + +async function waitForApi(url: string, maxMs = 5000): Promise { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) }); + if (res.ok) return true; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +// --------------------------------------------------------------------------- +// HTTP Mode Tests +// --------------------------------------------------------------------------- + +describe('HindsightClient – HTTP Mode', () => { + let client: HindsightClient; + + beforeAll(async () => { + const reachable = await waitForApi(HINDSIGHT_API_URL); + if (!reachable) { + throw new Error( + `Hindsight API not reachable at ${HINDSIGHT_API_URL}. ` + + 'Start the server before running integration tests.', + ); + } + + client = new HindsightClient({ + llmProvider: LLM_PROVIDER || 'openai', + llmApiKey: LLM_API_KEY || 'test-key', + llmModel: LLM_MODEL || undefined, + apiUrl: HINDSIGHT_API_URL, + }); + }); + + it('should retain a conversation', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + const response = await client.retain({ + content: + '[role: user]\nMy name is Alice and I love hiking.\n[user:end]\n\n' + + '[role: assistant]\nNice to meet you, Alice!\n[assistant:end]', + document_id: 'http-retain-test-1', + metadata: { channel_type: 'slack', sender_id: 'U001' }, + }); + + expect(response).toBeDefined(); + expect(response.message).toBeDefined(); + expect(response.document_id).toBe('http-retain-test-1'); + }); + + it('should retain with auto-generated document id', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + const response = await client.retain({ + content: '[role: user]\nI work at TechCorp as a software engineer.\n[user:end]', + }); + + expect(response).toBeDefined(); + expect(response.document_id).toBe('conversation'); + }); + + it('should recall from an empty bank without error', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + const response = await client.recall({ query: 'What do I like?', max_tokens: 512 }); + + expect(response).toBeDefined(); + expect(Array.isArray(response.results)).toBe(true); + }); + + it('should set bank mission without throwing', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + // setBankMission on a non-existent bank logs a warning but does not throw + await expect( + client.setBankMission('You are an assistant helping users via Slack.'), + ).resolves.not.toThrow(); + }); + + it('should set bank mission after retain creates the bank', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + // Create the bank by retaining something first + await client.retain({ content: '[role: user]\nHello\n[user:end]' }); + + // Now set the mission – bank exists so this should succeed + await expect( + client.setBankMission('You are a helpful AI assistant.'), + ).resolves.not.toThrow(); + }); + + it('should retain and then recall relevant memories', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + await client.retain({ + content: + '[role: user]\nMy favorite programming language is Python.\n[user:end]\n\n' + + '[role: assistant]\nPython is a great choice!\n[assistant:end]', + document_id: `session-${Date.now()}`, + }); + + const response = await client.recall({ + query: 'What programming language do I like?', + max_tokens: 1024, + }); + + expect(response).toBeDefined(); + expect(Array.isArray(response.results)).toBe(true); + }); + + it('should silently truncate recall queries over 800 chars', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + const longQuery = 'Tell me about my interests. '.repeat(50); // > 800 chars + const response = await client.recall({ query: longQuery, max_tokens: 512 }); + + expect(response).toBeDefined(); + expect(Array.isArray(response.results)).toBe(true); + }); + + it('should use custom max_tokens in recall request', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + const response = await client.recall({ query: 'anything', max_tokens: 256 }); + + expect(response).toBeDefined(); + expect(Array.isArray(response.results)).toBe(true); + }); + + it('should map recall results to MemoryResult shape', async () => { + const bankId = randomBankId(); + client.setBankId(bankId); + + await client.retain({ + content: + '[role: user]\nI enjoy reading science fiction books.\n[user:end]\n\n' + + '[role: assistant]\nSounds like a great hobby!\n[assistant:end]', + document_id: 'mapping-test', + }); + + const response = await client.recall({ query: 'What are my hobbies?', max_tokens: 1024 }); + + for (const result of response.results) { + expect(typeof result.id).toBe('string'); + expect(typeof result.text).toBe('string'); + expect(typeof result.type).toBe('string'); + expect(Array.isArray(result.entities)).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// Embed Mode Tests (subprocess / daemon) +// --------------------------------------------------------------------------- + +describe('HindsightClient – Embed Mode (Subprocess)', () => { + let client: HindsightClient; + let embedManager: HindsightEmbedManager; + + const hasEmbedCredentials = Boolean(LLM_PROVIDER && LLM_API_KEY); + + beforeAll(async () => { + if (!hasEmbedCredentials) { + console.warn( + '[Integration] Skipping embed mode tests: ' + + 'HINDSIGHT_API_LLM_PROVIDER and HINDSIGHT_API_LLM_API_KEY must both be set.', + ); + return; + } + + embedManager = new HindsightEmbedManager( + EMBED_TEST_PORT, + LLM_PROVIDER, + LLM_API_KEY, + LLM_MODEL || undefined, + undefined, // no custom base URL + 0, // never idle-timeout + 'latest', + EMBED_PACKAGE_PATH, + ); + + await embedManager.start(); + + client = new HindsightClient({ + llmProvider: LLM_PROVIDER, + llmApiKey: LLM_API_KEY, + llmModel: LLM_MODEL || undefined, + embedPackagePath: EMBED_PACKAGE_PATH, + }); + }, 120_000); // daemon startup can take up to 2 minutes + + afterAll(async () => { + if (embedManager) { + await embedManager.stop(); + } + }, 30_000); + + it('should retain a conversation via subprocess', async () => { + if (!hasEmbedCredentials) return; + + const bankId = randomBankId(); + client.setBankId(bankId); + + const response = await client.retain({ + content: + '[role: user]\nI love hiking in the mountains.\n[user:end]\n\n' + + '[role: assistant]\nSounds adventurous!\n[assistant:end]', + document_id: 'embed-retain-test-1', + }); + + expect(response).toBeDefined(); + expect(response.message).toBeDefined(); + expect(response.document_id).toBe('embed-retain-test-1'); + }, 60_000); + + it('should retain with auto-generated document id via subprocess', async () => { + if (!hasEmbedCredentials) return; + + const bankId = randomBankId(); + client.setBankId(bankId); + + const response = await client.retain({ + content: '[role: user]\nI am a TypeScript developer.\n[user:end]', + }); + + expect(response).toBeDefined(); + expect(response.document_id).toBe('conversation'); + }, 60_000); + + it('should recall from an empty bank without error via subprocess', async () => { + if (!hasEmbedCredentials) return; + + const bankId = randomBankId(); + client.setBankId(bankId); + + const response = await client.recall({ query: 'What do I like?', max_tokens: 512 }); + + expect(response).toBeDefined(); + expect(Array.isArray(response.results)).toBe(true); + }, 60_000); + + it('should set bank mission via subprocess without throwing', async () => { + if (!hasEmbedCredentials) return; + + const bankId = randomBankId(); + client.setBankId(bankId); + + // Create bank by retaining first, then set mission + await client.retain({ content: '[role: user]\nHello\n[user:end]' }); + + await expect( + client.setBankMission('Test mission for embed integration tests.'), + ).resolves.not.toThrow(); + }, 60_000); + + it('should retain and then recall relevant memories via subprocess', async () => { + if (!hasEmbedCredentials) return; + + const bankId = randomBankId(); + client.setBankId(bankId); + + await client.retain({ + content: + '[role: user]\nMy cat is named Whiskers and she is 3 years old.\n[user:end]\n\n' + + '[role: assistant]\nWhat a lovely name!\n[assistant:end]', + document_id: `embed-e2e-${Date.now()}`, + }); + + const response = await client.recall({ + query: "What is my cat's name?", + max_tokens: 1024, + }); + + expect(response).toBeDefined(); + expect(Array.isArray(response.results)).toBe(true); + }, 60_000); + + it('should map recall results to MemoryResult shape via subprocess', async () => { + if (!hasEmbedCredentials) return; + + const bankId = randomBankId(); + client.setBankId(bankId); + + await client.retain({ + content: + '[role: user]\nI enjoy cooking Italian food.\n[user:end]\n\n' + + '[role: assistant]\nItalian cuisine is delicious!\n[assistant:end]', + document_id: 'embed-shape-test', + }); + + const response = await client.recall({ query: 'What food do I like?', max_tokens: 1024 }); + + for (const result of response.results) { + expect(typeof result.id).toBe('string'); + expect(typeof result.text).toBe('string'); + expect(typeof result.type).toBe('string'); + expect(Array.isArray(result.entities)).toBe(true); + } + }, 60_000); + + it('should handle full end-to-end workflow via subprocess', async () => { + if (!hasEmbedCredentials) return; + + const bankId = randomBankId(); + client.setBankId(bankId); + + // Step 1: Retain + const retainResp = await client.retain({ + content: + '[role: user]\nI am learning Rust programming.\n[user:end]\n\n' + + '[role: assistant]\nRust is a powerful systems language!\n[assistant:end]', + document_id: `embed-workflow-${Date.now()}`, + metadata: { channel_type: 'telegram', sender_id: '999' }, + }); + expect(retainResp).toBeDefined(); + + // Step 2: Recall + const recallResp = await client.recall({ + query: 'What am I learning?', + max_tokens: 1024, + }); + expect(recallResp).toBeDefined(); + expect(Array.isArray(recallResp.results)).toBe(true); + }, 60_000); +}); diff --git a/hindsight-integrations/openclaw/vitest.integration.config.ts b/hindsight-integrations/openclaw/vitest.integration.config.ts new file mode 100644 index 00000000..65dba22b --- /dev/null +++ b/hindsight-integrations/openclaw/vitest.integration.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + testTimeout: 120_000, + hookTimeout: 120_000, + reporters: ['verbose'], + }, +});