diff --git a/web/src/kernel/scheduled/dreaming/facts.ts b/web/src/kernel/scheduled/dreaming/facts.ts index b5edcb3..c793260 100644 --- a/web/src/kernel/scheduled/dreaming/facts.ts +++ b/web/src/kernel/scheduled/dreaming/facts.ts @@ -2,7 +2,7 @@ // extract durable facts, routing failures, BizOps drift, preferences. import type { EdgeEnv } from '../../dispatch.js'; -import { recordMemory as recordMemoryAdapter } from '../../memory-adapter.js'; +import { recordMemory as recordMemoryAdapter, recordMemoryWithAutoTopic } from '../../memory-adapter.js'; import { validateMemoryWrite } from '../../memory-guardrails.js'; import { McpClient } from '../../../mcp-client.js'; import { operatorConfig } from '../../../operator/index.js'; @@ -197,8 +197,9 @@ export async function extractFacts(env: EdgeEnv, threadContents: string[]): Prom export async function processFacts(env: EdgeEnv, result: DreamingResult): Promise { let factsRecorded = 0; + const useAutoTopic = !!env.tarotscriptFetcher; for (const fact of (result.facts ?? []).slice(0, 5)) { - const guard = validateMemoryWrite(fact.topic, fact.fact, { enforceAllowlist: true }); + const guard = validateMemoryWrite(fact.topic, fact.fact, { enforceAllowlist: !useAutoTopic }); if (!guard.allowed) { console.log(`[dreaming] Blocked: ${guard.reason}`); continue; @@ -206,9 +207,15 @@ export async function processFacts(env: EdgeEnv, result: DreamingResult): Promis try { if (!env.memoryBinding) continue; - await recordMemoryAdapter(env.memoryBinding, fact.topic, fact.fact, fact.confidence ?? 0.8, 'dreaming_cycle'); - factsRecorded++; - console.log(`[dreaming] Fact: [${fact.topic}] ${fact.fact.slice(0, 80)}`); + if (useAutoTopic) { + const res = await recordMemoryWithAutoTopic(env.memoryBinding, env.tarotscriptFetcher!, fact.fact, fact.confidence ?? 0.8, 'dreaming_cycle'); + factsRecorded++; + console.log(`[dreaming] Fact: [${res.classification.topic}] (${res.classification.confidence}, ${res.classification.source}) ${fact.fact.slice(0, 80)}`); + } else { + await recordMemoryAdapter(env.memoryBinding, fact.topic, fact.fact, fact.confidence ?? 0.8, 'dreaming_cycle'); + factsRecorded++; + console.log(`[dreaming] Fact: [${fact.topic}] ${fact.fact.slice(0, 80)}`); + } } catch (err) { console.warn('[dreaming] Failed to record fact:', err instanceof Error ? err.message : String(err)); } @@ -229,8 +236,13 @@ export async function processFacts(env: EdgeEnv, result: DreamingResult): Promis if (!pref.preference || pref.preference.length < 15) continue; try { if (!env.memoryBinding) continue; - await recordMemoryAdapter(env.memoryBinding, 'operator_preferences', pref.preference, 0.85, 'dreaming_cycle'); - console.log(`[dreaming] Preference: ${pref.preference.slice(0, 80)}`); + if (useAutoTopic) { + const res = await recordMemoryWithAutoTopic(env.memoryBinding, env.tarotscriptFetcher!, pref.preference, 0.85, 'dreaming_cycle'); + console.log(`[dreaming] Preference: [${res.classification.topic}] (${res.classification.confidence}, ${res.classification.source}) ${pref.preference.slice(0, 80)}`); + } else { + await recordMemoryAdapter(env.memoryBinding, 'operator_preferences', pref.preference, 0.85, 'dreaming_cycle'); + console.log(`[dreaming] Preference: ${pref.preference.slice(0, 80)}`); + } } catch { /* non-fatal */ } } diff --git a/web/tests/dreaming/facts.test.ts b/web/tests/dreaming/facts.test.ts index 3b40e90..fac991d 100644 --- a/web/tests/dreaming/facts.test.ts +++ b/web/tests/dreaming/facts.test.ts @@ -1,9 +1,16 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { processFacts, type DreamingResult } from '../../src/kernel/scheduled/dreaming/facts.js'; +const mockRecordMemory = vi.fn().mockResolvedValue({ fragment_id: 'f-1' }); +const mockRecordMemoryWithAutoTopic = vi.fn().mockResolvedValue({ + fragment_id: 'f-1', + classification: { topic: 'aegis', confidence: 'high', source: 'classifier' }, +}); + // Mock memory adapter vi.mock('../../src/kernel/memory-adapter.js', () => ({ - recordMemory: vi.fn().mockResolvedValue(undefined), + recordMemory: (...args: unknown[]) => mockRecordMemory(...args), + recordMemoryWithAutoTopic: (...args: unknown[]) => mockRecordMemoryWithAutoTopic(...args), })); // Mock memory guardrails — pass through to real implementation @@ -20,6 +27,13 @@ function makeEnv(overrides?: Record) { } as any; } +function makeEnvWithAutoTopic(overrides?: Record) { + return makeEnv({ + tarotscriptFetcher: { fetch: vi.fn() }, + ...overrides, + }); +} + describe('processFacts', () => { it('records valid facts', async () => { const env = makeEnv(); @@ -107,3 +121,112 @@ describe('processFacts', () => { expect(count).toBe(0); // routing failures are logged, not counted as facts }); }); + +// ─── Auto-topic classification ────────────────────────────── + +describe('processFacts with auto-topic classification', () => { + beforeEach(() => { + mockRecordMemory.mockClear(); + mockRecordMemoryWithAutoTopic.mockClear(); + mockRecordMemoryWithAutoTopic.mockResolvedValue({ + fragment_id: 'f-auto', + classification: { topic: 'infrastructure', confidence: 'high', source: 'classifier' }, + }); + }); + + it('uses recordMemoryWithAutoTopic when tarotscriptFetcher is available', async () => { + const env = makeEnvWithAutoTopic(); + const result: DreamingResult = { + facts: [ + { topic: 'infra_stuff', fact: 'The deploy pipeline now supports canary rollouts via Cloudflare', confidence: 0.9 }, + ], + }; + const count = await processFacts(env, result); + expect(count).toBe(1); + expect(mockRecordMemoryWithAutoTopic).toHaveBeenCalledTimes(1); + expect(mockRecordMemory).not.toHaveBeenCalled(); + }); + + it('falls back to recordMemory when tarotscriptFetcher is absent', async () => { + const env = makeEnv(); + const result: DreamingResult = { + facts: [ + { topic: 'aegis', fact: 'The dispatch loop now handles 8 executor types including composite', confidence: 0.9 }, + ], + }; + const count = await processFacts(env, result); + expect(count).toBe(1); + expect(mockRecordMemory).toHaveBeenCalledTimes(1); + expect(mockRecordMemoryWithAutoTopic).not.toHaveBeenCalled(); + }); + + it('skips topic allowlist check when auto-topic is active', async () => { + const env = makeEnvWithAutoTopic(); + const result: DreamingResult = { + facts: [ + { topic: 'random_new_topic', fact: 'A fact with an unknown LLM topic that the classifier will reclassify properly', confidence: 0.8 }, + ], + }; + const count = await processFacts(env, result); + expect(count).toBe(1); + expect(mockRecordMemoryWithAutoTopic).toHaveBeenCalledTimes(1); + }); + + it('still blocks dangerous topics even with auto-topic active', async () => { + const env = makeEnvWithAutoTopic(); + const result: DreamingResult = { + facts: [ + { topic: 'synthesis_cross_domain', fact: 'Some vague synthesis observation that pollutes memory with noise', confidence: 0.8 }, + ], + }; + const count = await processFacts(env, result); + expect(count).toBe(0); + expect(mockRecordMemoryWithAutoTopic).not.toHaveBeenCalled(); + }); + + it('classifier fallback to general does not break the cycle', async () => { + mockRecordMemoryWithAutoTopic.mockResolvedValue({ + fragment_id: 'f-fallback', + classification: { topic: 'general', confidence: 'low', source: 'fallback' }, + }); + const env = makeEnvWithAutoTopic(); + const result: DreamingResult = { + facts: [ + { topic: 'aegis', fact: 'Some fact that the classifier cannot confidently classify into a topic', confidence: 0.8 }, + ], + }; + const count = await processFacts(env, result); + expect(count).toBe(1); + expect(mockRecordMemoryWithAutoTopic).toHaveBeenCalledTimes(1); + }); + + it('auto-classifies preferences when tarotscriptFetcher available', async () => { + mockRecordMemoryWithAutoTopic.mockResolvedValue({ + fragment_id: 'f-pref', + classification: { topic: 'operator', confidence: 'moderate', source: 'classifier' }, + }); + const env = makeEnvWithAutoTopic(); + const result: DreamingResult = { + preferences: [ + { preference: 'Prefers direct communication with minimal ceremony', evidence: 'conversation thread' }, + ], + }; + await processFacts(env, result); + expect(mockRecordMemoryWithAutoTopic).toHaveBeenCalledTimes(1); + expect(mockRecordMemory).not.toHaveBeenCalled(); + }); + + it('uses hardcoded operator_preferences topic when no fetcher', async () => { + const env = makeEnv(); + const result: DreamingResult = { + preferences: [ + { preference: 'Prefers direct communication with minimal ceremony', evidence: 'conversation thread' }, + ], + }; + await processFacts(env, result); + expect(mockRecordMemory).toHaveBeenCalledWith( + expect.anything(), 'operator_preferences', expect.any(String), 0.85, 'dreaming_cycle', + ); + expect(mockRecordMemoryWithAutoTopic).not.toHaveBeenCalled(); + }); +});