Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions web/src/kernel/scheduled/dreaming/facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -197,18 +197,25 @@ export async function extractFacts(env: EdgeEnv, threadContents: string[]): Prom
export async function processFacts(env: EdgeEnv, result: DreamingResult): Promise<number> {
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;
}

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));
}
Expand All @@ -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 */ }
}

Expand Down
127 changes: 125 additions & 2 deletions web/tests/dreaming/facts.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +27,13 @@ function makeEnv(overrides?: Record<string, unknown>) {
} as any;
}

function makeEnvWithAutoTopic(overrides?: Record<string, unknown>) {
return makeEnv({
tarotscriptFetcher: { fetch: vi.fn() },
...overrides,
});
}

describe('processFacts', () => {
it('records valid facts', async () => {
const env = makeEnv();
Expand Down Expand Up @@ -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();
});
});
Loading