diff --git a/.env.example b/.env.example index 79aa6a523..d7c2f25b1 100644 --- a/.env.example +++ b/.env.example @@ -418,6 +418,12 @@ OPENHUMAN_ANALYTICS_ENABLED=true # the UI; this var is for headless / fleet deployments. `0` means "Manual only" # (periodic auto-sync disabled). Unset → 24h default. # OPENHUMAN_MEMORY_SYNC_INTERVAL_SECS=86400 +# +# Cadence (minutes) of the tiny.place "subconscious" steering review — the +# offline reflection over the orchestration layer's compressed execution history +# that emits STEERING_DIRECTIVEs. Unset → follows the heartbeat interval (the +# pre-factory behaviour, where the review ran once per memory tick). +# OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES=15 # --------------------------------------------------------------------------- # Logging diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx index 244191ef2..a03b05198 100644 --- a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -1,11 +1,15 @@ import { useCallback, useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import type { TriggerKind } from '../../hooks/useSubconscious'; import { useT } from '../../lib/i18n/I18nContext'; import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat'; -import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious'; +import type { + SubconsciousInstanceStatus, + SubconsciousStatus, +} from '../../utils/tauriCommands/subconscious'; import { settingsNavState } from '../settings/modal/settingsOverlay'; -import Button from '../ui/Button'; +import SubconsciousInstanceCard from './SubconsciousInstanceCard'; interface ModeOption { id: SubconsciousMode; @@ -48,34 +52,51 @@ function sliderToMinutes(value: number): number { interface IntelligenceSubconsciousTabProps { status: SubconsciousStatus | null; + /** Per-world status rows (falls back to [memory] on an older core). */ + instances?: SubconsciousInstanceStatus[]; mode: SubconsciousMode; intervalMinutes: number; - triggerTick: () => Promise; + triggerTick: (kind?: TriggerKind) => Promise; triggering: boolean; + /** Per-kind in-flight state (two Run buttons must not share one spinner). */ + isTriggering?: (kind: TriggerKind) => boolean; settingMode: boolean; setMode: (mode: SubconsciousMode) => Promise; setIntervalMinutes: (minutes: number) => Promise; + /** Navigate to the TinyPlace Orchestration tab's Subconscious window. */ + onViewDirectives?: () => void; } export default function IntelligenceSubconsciousTab({ status, + instances, mode, intervalMinutes, triggerTick, triggering, + isTriggering, settingMode, setMode, setIntervalMinutes, + onViewDirectives, }: IntelligenceSubconsciousTabProps) { const { t } = useT(); const navigate = useNavigate(); const location = useLocation(); - const providerUnavailable = status?.provider_available === false; - const providerUnavailableReason = providerUnavailable - ? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle')) - : null; const isEnabled = mode !== 'off'; + // Derive the per-world rows, tolerating an older core (no `instances`). + const rows: SubconsciousInstanceStatus[] = + instances && instances.length > 0 + ? instances + : status + ? [{ ...status, instance: status.instance ?? 'memory' }] + : []; + const memoryRow = rows.find(r => r.instance === 'memory') ?? status ?? undefined; + const tinyplaceRow = rows.find(r => r.instance === 'tinyplace'); + const running = (kind: TriggerKind) => (isTriggering ? isTriggering(kind) : triggering); + const openProviderSettings = () => navigate('/settings/llm', settingsNavState(location)); + const [localSlider, setLocalSlider] = useState(() => minutesToSlider(intervalMinutes)); // Keep the local slider in sync when the prop changes from outside (e.g. after a refresh). @@ -95,14 +116,13 @@ export default function IntelligenceSubconsciousTab({ } }, [localSlider, intervalMinutes, setIntervalMinutes]); - const handleRunTick = async () => { - try { - await triggerTick(); - } catch (error) { + const runTick = (kind: TriggerKind) => { + Promise.resolve(triggerTick(kind)).catch(error => { console.debug('[subconscious-ui] run tick:error', { + kind, error: error instanceof Error ? error.message : String(error), }); - } + }); }; return ( @@ -171,76 +191,39 @@ export default function IntelligenceSubconsciousTab({ )} - {/* Status bar + Run Now */} + {/* Per-world instance cards */} {isEnabled && ( -
-
- {status && ( - <> - - {status.total_ticks} {t('subconscious.ticks')} - - {status.last_tick_at && ( - <> - | - - {t('subconscious.last')}:{' '} - {new Date(status.last_tick_at * 1000).toLocaleTimeString()} - - - )} - {status.consecutive_failures > 0 && ( - <> - | - - {status.consecutive_failures} {t('subconscious.failed')} - - - )} - - )} -
- -
- )} - - {isEnabled && providerUnavailable && ( -
-
-
-

- {t('subconscious.providerUnavailableTitle')} -

-

- {providerUnavailableReason} -

-
- -
+
+ runTick('memory')} + onProviderSettings={openProviderSettings} + /> + runTick('tinyplace')} + onProviderSettings={openProviderSettings} + footer={ + onViewDirectives ? ( + + ) : undefined + } + />
)} diff --git a/app/src/components/intelligence/SubconsciousInstanceCard.tsx b/app/src/components/intelligence/SubconsciousInstanceCard.tsx new file mode 100644 index 000000000..5f8ec1d5f --- /dev/null +++ b/app/src/components/intelligence/SubconsciousInstanceCard.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { SubconsciousInstanceStatus } from '../../utils/tauriCommands/subconscious'; +import Button from '../ui/Button'; + +interface SubconsciousInstanceCardProps { + title: string; + subtitle: string; + /** The status row for this world, or undefined when the core hasn't + * registered it (older core / disabled / not bootstrapped). */ + status: SubconsciousInstanceStatus | undefined; + /** Shown as a disabled state with `disabledHint` when true. */ + disabled?: boolean; + disabledHint?: string; + runLabel: string; + triggering: boolean; + onRun: () => void; + /** Navigate to the provider (LLM) settings from the unavailable banner. */ + onProviderSettings?: () => void; + /** Optional footer, e.g. a "View directives →" cross-link. */ + footer?: ReactNode; +} + +/** + * One subconscious world's health card — total ticks, last tick, failures, a + * provider-unavailable banner, and a per-world "Run now". Shared so a third + * world later is a data change, not new JSX. + */ +export default function SubconsciousInstanceCard({ + title, + subtitle, + status, + disabled = false, + disabledHint, + runLabel, + triggering, + onRun, + onProviderSettings, + footer, +}: SubconsciousInstanceCardProps) { + const { t } = useT(); + const providerUnavailable = status?.provider_available === false; + const providerUnavailableReason = providerUnavailable + ? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle')) + : null; + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ + {disabled ? t('subconscious.instance.off') : t('subconscious.instance.on')} + +
+ + {disabled ? ( +

{disabledHint}

+ ) : ( + <> +
+ + {status?.total_ticks ?? 0} {t('subconscious.ticks')} + + {status?.last_tick_at != null && ( + <> + | + + {t('subconscious.last')}:{' '} + {new Date(status.last_tick_at * 1000).toLocaleTimeString()} + + + )} + {(status?.consecutive_failures ?? 0) > 0 && ( + <> + | + + {status?.consecutive_failures} {t('subconscious.failed')} + + + )} +
+ + {providerUnavailable && ( +
+
+

+ {providerUnavailableReason} +

+ {onProviderSettings && ( + + )} +
+
+ )} + +
+
{footer}
+ +
+ + )} +
+ ); +} diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx index d20734ffa..49c0b66b9 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx @@ -40,6 +40,10 @@ vi.mock('../../services/socketService', () => { vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +vi.mock('../../utils/tauriCommands/subconscious', () => ({ + subconsciousTrigger: vi.fn(async () => ({ result: { triggered: true }, logs: [] })), +})); + const sessionsListMock = vi.mocked(orchestrationClient.sessionsList); const sessionsCreateMock = vi.mocked(orchestrationClient.sessionsCreate); const messagesListMock = vi.mocked(orchestrationClient.messagesList); @@ -134,6 +138,29 @@ describe('TinyPlaceOrchestrationTab', () => { }); }); + it('steering header shows the directive and Run review triggers the tinyplace kind', async () => { + const { subconsciousTrigger } = await import('../../utils/tauriCommands/subconscious'); + statusMock.mockResolvedValue({ + steering: { + text: 'prioritize the billing migration', + createdAt: '2026-07-04T00:00:00.000Z', + expiresAfterCycles: 12, + }, + lastTickAt: 1_700_000_000, + }); + + render(); + + // Open the pinned Subconscious window. + fireEvent.click(await screen.findByTestId('tinyplace-chat-subconscious')); + + const header = await screen.findByTestId('tinyplace-steering-header'); + expect(within(header).getByText('prioritize the billing migration')).toBeInTheDocument(); + + fireEvent.click(within(header).getByText('tinyplaceOrchestration.steeringHeader.runReview')); + await waitFor(() => expect(subconsciousTrigger).toHaveBeenCalledWith('tinyplace')); + }); + it('renders pinned master and subconscious chats plus app sessions', async () => { sessionsListMock.mockResolvedValue({ sessions: [ diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx index af9380bff..686d8b36c 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx @@ -15,6 +15,7 @@ import { MASTER_CHAT_KEY, useOrchestrationChats, } from '../../lib/orchestration/useOrchestrationChats'; +import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious'; import Button from '../ui/Button'; const debug = debugFactory('brain:tinyplace-orchestration'); @@ -96,7 +97,9 @@ function ChatListButton({ - {chat.subtitle} + {chat.kind === 'subconscious' + ? t('tinyplaceOrchestration.subconsciousBadge') + : chat.subtitle} {chat.preview} @@ -391,6 +394,17 @@ export default function TinyPlaceOrchestrationTab() { }, [directoryIdsKey]); const steeringText = status?.steering?.text?.trim() || null; + const [runningReview, setRunningReview] = useState(false); + const runSteeringReview = useCallback(async () => { + setRunningReview(true); + try { + await subconsciousTrigger('tinyplace'); + } catch (err) { + debug('steering review trigger failed: %o', err); + } finally { + setRunningReview(false); + } + }, []); const isMasterSelected = selected?.id === MASTER_CHAT_KEY; // The composer is available for the Master chat and for any per-contact // session (session sends thread under that session id). @@ -677,6 +691,46 @@ export default function TinyPlaceOrchestrationTab() { ) : null}
+ {/* Steering status header — the tinyplace subconscious instance's output. */} + {selected?.kind === 'subconscious' ? ( +
+
+

+ {steeringText + ? t('tinyplaceOrchestration.steeringHeader.current') + : t('tinyplaceOrchestration.steeringHeader.none')} +

+ {steeringText ? ( +

{steeringText}

+ ) : null} +

+ {status?.steering + ? t('tinyplaceOrchestration.steeringHeader.expires').replace( + '{n}', + String(status.steering.expiresAfterCycles) + ) + : ''} + {status?.lastTickAt + ? `${status?.steering ? ' · ' : ''}${t( + 'tinyplaceOrchestration.steeringHeader.lastReview' + )}: ${new Date(status.lastTickAt * 1000).toLocaleTimeString()}` + : ''} +

+
+ +
+ ) : null} + {sessionsState.status === 'loading' ? (
{t('tinyplaceOrchestration.loading')} diff --git a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx index c51dbaad4..85cac0c65 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx @@ -5,10 +5,26 @@ import { fireEvent, render, screen } from '@testing-library/react'; import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SubconsciousInstanceStatus } from '../../../utils/tauriCommands/subconscious'; import IntelligenceSubconsciousTab from '../IntelligenceSubconsciousTab'; const mockNavigate = vi.fn(); +function row(instance: 'memory' | 'tinyplace', over: Partial = {}) { + return { + instance, + enabled: true, + mode: instance === 'memory' ? 'simple' : 'steering', + provider_available: true, + provider_unavailable_reason: null, + interval_minutes: 5, + last_tick_at: null, + total_ticks: 3, + consecutive_failures: 0, + ...over, + } as SubconsciousInstanceStatus; +} + vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, useLocation: () => ({ @@ -70,4 +86,61 @@ describe('IntelligenceSubconsciousTab', () => { render(); expect(screen.getByText(/full tool access including writes/)).toBeInTheDocument(); }); + + it('renders both instance cards from instances', () => { + render( + + ); + expect(screen.getByText('Your world')).toBeInTheDocument(); + expect(screen.getByText('Orchestration steering')).toBeInTheDocument(); + expect(screen.getByText('Run Now')).toBeInTheDocument(); + expect(screen.getByText('Run review now')).toBeInTheDocument(); + }); + + it('each card Run button dispatches its own kind', () => { + const triggerTick = vi.fn().mockResolvedValue(undefined); + render( + + ); + fireEvent.click(screen.getByText('Run Now')); + expect(triggerTick).toHaveBeenCalledWith('memory'); + fireEvent.click(screen.getByText('Run review now')); + expect(triggerTick).toHaveBeenCalledWith('tinyplace'); + }); + + it('tinyplace card shows a disabled hint when orchestration is off', () => { + render( + + ); + expect(screen.getByText(/Enable Orchestration/)).toBeInTheDocument(); + // A disabled card exposes no run button. + expect(screen.queryByText('Run review now')).not.toBeInTheDocument(); + }); + + it('per-kind spinner: only the triggering kind spins', () => { + render( + kind === 'tinyplace'} + /> + ); + // Memory card's Run button stays enabled; only the tinyplace one is busy. + expect(screen.getByText('Run Now').closest('button')).toBeEnabled(); + expect(screen.getByText('Run review now').closest('button')).toBeDisabled(); + }); }); diff --git a/app/src/hooks/__tests__/useSubconscious.test.ts b/app/src/hooks/__tests__/useSubconscious.test.ts index 21d43c524..47bd4b951 100644 --- a/app/src/hooks/__tests__/useSubconscious.test.ts +++ b/app/src/hooks/__tests__/useSubconscious.test.ts @@ -5,6 +5,7 @@ import { useSubconscious } from '../useSubconscious'; const mockStatus = { result: { + instance: 'memory', enabled: true, mode: 'simple', provider_available: true, @@ -125,4 +126,38 @@ describe('useSubconscious', () => { expect(subconsciousTrigger).toHaveBeenCalled(); }); + + it('triggerTick passes the kind through (no-arg = memory)', async () => { + const { subconsciousTrigger } = await import('../../utils/tauriCommands'); + const { result } = renderHook(() => useSubconscious()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + await act(async () => { + await result.current.triggerTick('tinyplace'); + }); + expect(subconsciousTrigger).toHaveBeenLastCalledWith('tinyplace'); + + await act(async () => { + await result.current.triggerTick(); + }); + // A no-arg call maps to the legacy memory-only trigger. + expect(subconsciousTrigger).toHaveBeenLastCalledWith(undefined); + }); + + it('derives a memory instance row when the core omits instances[]', async () => { + const { result } = renderHook(() => useSubconscious()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.instances).toHaveLength(1); + expect(result.current.instances[0].instance).toBe('memory'); + // No kind is in flight initially. + expect(result.current.isTriggering('memory')).toBe(false); + expect(result.current.isTriggering('tinyplace')).toBe(false); + }); }); diff --git a/app/src/hooks/useSubconscious.ts b/app/src/hooks/useSubconscious.ts index aff5ecd4d..3849c9005 100644 --- a/app/src/hooks/useSubconscious.ts +++ b/app/src/hooks/useSubconscious.ts @@ -14,28 +14,49 @@ import { subconsciousTrigger, } from '../utils/tauriCommands'; import type { SubconsciousMode } from '../utils/tauriCommands/heartbeat'; -import type { SubconsciousStatus } from '../utils/tauriCommands/subconscious'; +import type { + SubconsciousInstanceStatus, + SubconsciousKind, + SubconsciousStatus, +} from '../utils/tauriCommands/subconscious'; + +export type TriggerKind = SubconsciousKind | 'all'; export interface UseSubconsciousResult { status: SubconsciousStatus | null; + /** One row per registered world, tolerant of an older core (falls back to + * the top-level memory fields when `instances` is absent). */ + instances: SubconsciousInstanceStatus[]; mode: SubconsciousMode; intervalMinutes: number; loading: boolean; + /** True when the memory (default) tick is in flight — back-compat. */ triggering: boolean; + /** True when a tick for `kind` is in flight (two buttons ≠ one spinner). */ + isTriggering: (kind: TriggerKind) => boolean; settingMode: boolean; refresh: () => Promise; - triggerTick: () => Promise; + triggerTick: (kind?: TriggerKind) => Promise; setMode: (mode: SubconsciousMode) => Promise; setIntervalMinutes: (minutes: number) => Promise; error: string | null; } +/** Derive per-world rows, tolerating an older core that omits `instances`. */ +function deriveInstances(status: SubconsciousStatus | null): SubconsciousInstanceStatus[] { + if (!status) return []; + if (status.instances && status.instances.length > 0) return status.instances; + // Older core: the top-level fields are the memory instance. + const { instances: _omit, ...row } = status; + return [{ ...row, instance: row.instance ?? 'memory' }]; +} + export function useSubconscious(): UseSubconsciousResult { const [status, setStatus] = useState(null); const [mode, setModeState] = useState('off'); const [intervalMinutes, setIntervalState] = useState(30); const [loading, setLoading] = useState(false); - const [triggering, setTriggering] = useState(false); + const [triggeringKinds, setTriggeringKinds] = useState>(new Set()); const [settingMode, setSettingMode] = useState(false); const [error, setError] = useState(null); const fetchingRef = useRef(false); @@ -72,17 +93,32 @@ export function useSubconscious(): UseSubconsciousResult { } }, []); - const triggerTick = useCallback(async () => { - if (!isTauri() || triggering) return; - setTriggering(true); + const triggerTick = useCallback(async (kind: TriggerKind = 'memory') => { + if (!isTauri()) return; + let alreadyInFlight = false; + setTriggeringKinds(prev => { + if (prev.has(kind)) { + alreadyInFlight = true; + return prev; + } + const next = new Set(prev); + next.add(kind); + return next; + }); + if (alreadyInFlight) return; try { - await subconsciousTrigger(); + // A no-arg legacy call maps to memory; pass the kind through otherwise. + await subconsciousTrigger(kind === 'memory' ? undefined : kind); } catch (err) { console.warn('[subconscious] trigger failed:', err); } finally { - setTriggering(false); + setTriggeringKinds(prev => { + const next = new Set(prev); + next.delete(kind); + return next; + }); } - }, [triggering]); + }, []); const setMode = useCallback( async (newMode: SubconsciousMode) => { @@ -121,12 +157,19 @@ export function useSubconscious(): UseSubconsciousResult { }; }, [refresh]); + const isTriggering = useCallback( + (kind: TriggerKind) => triggeringKinds.has(kind), + [triggeringKinds] + ); + return { status, + instances: deriveInstances(status), mode, intervalMinutes, loading, - triggering, + triggering: triggeringKinds.has('memory'), + isTriggering, settingMode, refresh, triggerTick, diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c15190b6e..296e95bf0 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2673,6 +2673,22 @@ const messages: TranslationMap = { 'subconscious.failed': 'فشل', 'subconscious.tickInterval': 'فترة الدورة', 'subconscious.runNow': 'تشغيل الآن', + 'subconscious.instance.on': 'مُفعّل', + 'subconscious.instance.off': 'مُعطّل', + 'subconscious.instance.memory.title': 'عالمك', + 'subconscious.instance.memory.subtitle': 'مصادر الذاكرة المتصلة', + 'subconscious.instance.tinyplace.title': 'توجيه التنسيق', + 'subconscious.instance.tinyplace.subtitle': 'مراجعة جلسات tiny.place', + 'subconscious.instance.tinyplace.disabledHint': 'فعّل التنسيق لتوجيه الجلسات المُدارة.', + 'subconscious.instance.tinyplace.viewDirectives': 'عرض التوجيهات ←', + 'subconscious.runReviewNow': 'شغّل المراجعة الآن', + 'tinyplaceOrchestration.subconsciousBadge': 'اللاوعي · التوجيه', + 'tinyplaceOrchestration.steeringHeader.current': 'توجيه نشط', + 'tinyplaceOrchestration.steeringHeader.none': 'لا يوجد توجيه نشط', + 'tinyplaceOrchestration.steeringHeader.expires': 'ينتهي بعد {n} دورات', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'آخر مراجعة', + 'tinyplaceOrchestration.steeringHeader.runReview': 'شغّل المراجعة الآن', + 'tinyplaceOrchestration.steeringHeader.running': 'جارٍ التشغيل…', 'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا', 'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a672a9ad9..1928a178a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2733,6 +2733,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'ব্যর্থ', 'subconscious.tickInterval': 'টিক ইন্টারভাল', 'subconscious.runNow': 'এখনই চালান', + 'subconscious.instance.on': 'চালু', + 'subconscious.instance.off': 'বন্ধ', + 'subconscious.instance.memory.title': 'আপনার জগৎ', + 'subconscious.instance.memory.subtitle': 'সংযুক্ত মেমরি উৎস', + 'subconscious.instance.tinyplace.title': 'অর্কেস্ট্রেশন স্টিয়ারিং', + 'subconscious.instance.tinyplace.subtitle': 'tiny.place সেশন পর্যালোচনা', + 'subconscious.instance.tinyplace.disabledHint': + 'র‍্যাপড সেশন পরিচালনায় অর্কেস্ট্রেশন সক্ষম করুন।', + 'subconscious.instance.tinyplace.viewDirectives': 'নির্দেশাবলী দেখুন →', + 'subconscious.runReviewNow': 'এখন পর্যালোচনা চালান', + 'tinyplaceOrchestration.subconsciousBadge': 'অবচেতন · স্টিয়ারিং', + 'tinyplaceOrchestration.steeringHeader.current': 'সক্রিয় নির্দেশ', + 'tinyplaceOrchestration.steeringHeader.none': 'কোনো সক্রিয় নির্দেশ নেই', + 'tinyplaceOrchestration.steeringHeader.expires': '{n} চক্র পরে মেয়াদ শেষ', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'শেষ পর্যালোচনা', + 'tinyplaceOrchestration.steeringHeader.runReview': 'এখন পর্যালোচনা চালান', + 'tinyplaceOrchestration.steeringHeader.running': 'চলছে…', 'subconscious.providerUnavailableTitle': 'Subconscious বিরত আছে', 'subconscious.providerSettings': 'AI সেটিংস', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5bc8f26fb..399fb2ecf 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2801,6 +2801,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'gescheitert', 'subconscious.tickInterval': 'Tick-Intervall', 'subconscious.runNow': 'Jetzt ausführen', + 'subconscious.instance.on': 'An', + 'subconscious.instance.off': 'Aus', + 'subconscious.instance.memory.title': 'Deine Welt', + 'subconscious.instance.memory.subtitle': 'Verbundene Speicherquellen', + 'subconscious.instance.tinyplace.title': 'Orchestrierungssteuerung', + 'subconscious.instance.tinyplace.subtitle': 'tiny.place-Sitzungsprüfung', + 'subconscious.instance.tinyplace.disabledHint': + 'Aktiviere die Orchestrierung, um umschlossene Sitzungen zu steuern.', + 'subconscious.instance.tinyplace.viewDirectives': 'Anweisungen ansehen →', + 'subconscious.runReviewNow': 'Prüfung jetzt ausführen', + 'tinyplaceOrchestration.subconsciousBadge': 'Unterbewusstsein · Steuerung', + 'tinyplaceOrchestration.steeringHeader.current': 'Aktive Anweisung', + 'tinyplaceOrchestration.steeringHeader.none': 'Keine aktive Anweisung', + 'tinyplaceOrchestration.steeringHeader.expires': 'läuft nach {n} Zyklen ab', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Letzte Prüfung', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Prüfung jetzt ausführen', + 'tinyplaceOrchestration.steeringHeader.running': 'Läuft…', 'subconscious.providerUnavailableTitle': 'Unterbewusstsein ist pausiert', 'subconscious.providerSettings': 'KI-Einstellungen', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0af06d66e..16d5611d8 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3145,6 +3145,22 @@ const en: TranslationMap = { 'subconscious.failed': 'failed', 'subconscious.tickInterval': 'Tick Interval', 'subconscious.runNow': 'Run Now', + 'subconscious.instance.on': 'On', + 'subconscious.instance.off': 'Off', + 'subconscious.instance.memory.title': 'Your world', + 'subconscious.instance.memory.subtitle': 'Connected memory sources', + 'subconscious.instance.tinyplace.title': 'Orchestration steering', + 'subconscious.instance.tinyplace.subtitle': 'tiny.place session review', + 'subconscious.instance.tinyplace.disabledHint': 'Enable Orchestration to steer wrapped sessions.', + 'subconscious.instance.tinyplace.viewDirectives': 'View directives →', + 'subconscious.runReviewNow': 'Run review now', + 'tinyplaceOrchestration.subconsciousBadge': 'Subconscious · steering', + 'tinyplaceOrchestration.steeringHeader.current': 'Active directive', + 'tinyplaceOrchestration.steeringHeader.none': 'No active directive', + 'tinyplaceOrchestration.steeringHeader.expires': 'expires after {n} cycles', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Last review', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Run review now', + 'tinyplaceOrchestration.steeringHeader.running': 'Running…', 'subconscious.providerUnavailableTitle': 'Subconscious is paused', 'subconscious.providerSettings': 'AI settings', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 6456390a3..3df734087 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2785,6 +2785,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'fallido', 'subconscious.tickInterval': 'Intervalo de tick', 'subconscious.runNow': 'Ejecutar ahora', + 'subconscious.instance.on': 'Activado', + 'subconscious.instance.off': 'Desactivado', + 'subconscious.instance.memory.title': 'Tu mundo', + 'subconscious.instance.memory.subtitle': 'Fuentes de memoria conectadas', + 'subconscious.instance.tinyplace.title': 'Dirección de orquestación', + 'subconscious.instance.tinyplace.subtitle': 'Revisión de sesiones de tiny.place', + 'subconscious.instance.tinyplace.disabledHint': + 'Activa la Orquestación para dirigir las sesiones envueltas.', + 'subconscious.instance.tinyplace.viewDirectives': 'Ver directivas →', + 'subconscious.runReviewNow': 'Ejecutar revisión ahora', + 'tinyplaceOrchestration.subconsciousBadge': 'Subconsciente · dirección', + 'tinyplaceOrchestration.steeringHeader.current': 'Directiva activa', + 'tinyplaceOrchestration.steeringHeader.none': 'Sin directiva activa', + 'tinyplaceOrchestration.steeringHeader.expires': 'caduca tras {n} ciclos', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Última revisión', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Ejecutar revisión ahora', + 'tinyplaceOrchestration.steeringHeader.running': 'Ejecutando…', 'subconscious.providerUnavailableTitle': 'Subconsciente en pausa', 'subconscious.providerSettings': 'Ajustes de IA', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 0569a5edf..932414cbd 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2799,6 +2799,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'échoué', 'subconscious.tickInterval': 'Intervalle de tick', 'subconscious.runNow': 'Exécuter maintenant', + 'subconscious.instance.on': 'Activé', + 'subconscious.instance.off': 'Désactivé', + 'subconscious.instance.memory.title': 'Votre monde', + 'subconscious.instance.memory.subtitle': 'Sources de mémoire connectées', + 'subconscious.instance.tinyplace.title': 'Pilotage de l’orchestration', + 'subconscious.instance.tinyplace.subtitle': 'Revue des sessions tiny.place', + 'subconscious.instance.tinyplace.disabledHint': + 'Activez l’Orchestration pour piloter les sessions encapsulées.', + 'subconscious.instance.tinyplace.viewDirectives': 'Voir les directives →', + 'subconscious.runReviewNow': 'Lancer la revue', + 'tinyplaceOrchestration.subconsciousBadge': 'Subconscient · pilotage', + 'tinyplaceOrchestration.steeringHeader.current': 'Directive active', + 'tinyplaceOrchestration.steeringHeader.none': 'Aucune directive active', + 'tinyplaceOrchestration.steeringHeader.expires': 'expire après {n} cycles', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Dernière revue', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Lancer la revue', + 'tinyplaceOrchestration.steeringHeader.running': 'En cours…', 'subconscious.providerUnavailableTitle': 'Subconscient en pause', 'subconscious.providerSettings': 'Paramètres IA', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 18aea7dd5..68ebe3687 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2730,6 +2730,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'विफल', 'subconscious.tickInterval': 'टिक इंटरवल', 'subconscious.runNow': 'अभी चलाएं', + 'subconscious.instance.on': 'चालू', + 'subconscious.instance.off': 'बंद', + 'subconscious.instance.memory.title': 'आपकी दुनिया', + 'subconscious.instance.memory.subtitle': 'जुड़े हुए मेमोरी स्रोत', + 'subconscious.instance.tinyplace.title': 'ऑर्केस्ट्रेशन स्टीयरिंग', + 'subconscious.instance.tinyplace.subtitle': 'tiny.place सत्र समीक्षा', + 'subconscious.instance.tinyplace.disabledHint': + 'रैप किए गए सत्रों को नियंत्रित करने के लिए ऑर्केस्ट्रेशन सक्षम करें।', + 'subconscious.instance.tinyplace.viewDirectives': 'निर्देश देखें →', + 'subconscious.runReviewNow': 'अभी समीक्षा चलाएँ', + 'tinyplaceOrchestration.subconsciousBadge': 'अवचेतन · स्टीयरिंग', + 'tinyplaceOrchestration.steeringHeader.current': 'सक्रिय निर्देश', + 'tinyplaceOrchestration.steeringHeader.none': 'कोई सक्रिय निर्देश नहीं', + 'tinyplaceOrchestration.steeringHeader.expires': '{n} चक्रों के बाद समाप्त', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'अंतिम समीक्षा', + 'tinyplaceOrchestration.steeringHeader.runReview': 'अभी समीक्षा चलाएँ', + 'tinyplaceOrchestration.steeringHeader.running': 'चल रहा है…', 'subconscious.providerUnavailableTitle': 'Subconscious रुका हुआ है', 'subconscious.providerSettings': 'AI सेटिंग्स', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e776a282d..be25088d7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2739,6 +2739,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'gagal', 'subconscious.tickInterval': 'Interval Tick', 'subconscious.runNow': 'Jalankan Sekarang', + 'subconscious.instance.on': 'Aktif', + 'subconscious.instance.off': 'Nonaktif', + 'subconscious.instance.memory.title': 'Dunia Anda', + 'subconscious.instance.memory.subtitle': 'Sumber memori terhubung', + 'subconscious.instance.tinyplace.title': 'Pengarahan orkestrasi', + 'subconscious.instance.tinyplace.subtitle': 'Tinjauan sesi tiny.place', + 'subconscious.instance.tinyplace.disabledHint': + 'Aktifkan Orkestrasi untuk mengarahkan sesi terbungkus.', + 'subconscious.instance.tinyplace.viewDirectives': 'Lihat arahan →', + 'subconscious.runReviewNow': 'Jalankan tinjauan sekarang', + 'tinyplaceOrchestration.subconsciousBadge': 'Bawah sadar · pengarahan', + 'tinyplaceOrchestration.steeringHeader.current': 'Arahan aktif', + 'tinyplaceOrchestration.steeringHeader.none': 'Tidak ada arahan aktif', + 'tinyplaceOrchestration.steeringHeader.expires': 'kedaluwarsa setelah {n} siklus', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Tinjauan terakhir', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Jalankan tinjauan sekarang', + 'tinyplaceOrchestration.steeringHeader.running': 'Menjalankan…', 'subconscious.providerUnavailableTitle': 'Subconscious dijeda', 'subconscious.providerSettings': 'Pengaturan AI', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 79bbaa5db..0a82d11f9 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2781,6 +2781,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'fallito', 'subconscious.tickInterval': 'Intervallo di tick', 'subconscious.runNow': 'Esegui ora', + 'subconscious.instance.on': 'Attivo', + 'subconscious.instance.off': 'Disattivato', + 'subconscious.instance.memory.title': 'Il tuo mondo', + 'subconscious.instance.memory.subtitle': 'Fonti di memoria collegate', + 'subconscious.instance.tinyplace.title': 'Guida dell’orchestrazione', + 'subconscious.instance.tinyplace.subtitle': 'Revisione delle sessioni tiny.place', + 'subconscious.instance.tinyplace.disabledHint': + 'Abilita l’Orchestrazione per guidare le sessioni incapsulate.', + 'subconscious.instance.tinyplace.viewDirectives': 'Vedi direttive →', + 'subconscious.runReviewNow': 'Esegui revisione ora', + 'tinyplaceOrchestration.subconsciousBadge': 'Subconscio · guida', + 'tinyplaceOrchestration.steeringHeader.current': 'Direttiva attiva', + 'tinyplaceOrchestration.steeringHeader.none': 'Nessuna direttiva attiva', + 'tinyplaceOrchestration.steeringHeader.expires': 'scade dopo {n} cicli', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Ultima revisione', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Esegui revisione ora', + 'tinyplaceOrchestration.steeringHeader.running': 'In corso…', 'subconscious.providerUnavailableTitle': 'Subconscio in pausa', 'subconscious.providerSettings': 'Impostazioni IA', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 22fcf7d69..88fba163f 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2701,6 +2701,23 @@ const messages: TranslationMap = { 'subconscious.failed': '실패', 'subconscious.tickInterval': '틱 간격', 'subconscious.runNow': '지금 실행', + 'subconscious.instance.on': '켜짐', + 'subconscious.instance.off': '꺼짐', + 'subconscious.instance.memory.title': '당신의 세계', + 'subconscious.instance.memory.subtitle': '연결된 메모리 소스', + 'subconscious.instance.tinyplace.title': '오케스트레이션 조정', + 'subconscious.instance.tinyplace.subtitle': 'tiny.place 세션 검토', + 'subconscious.instance.tinyplace.disabledHint': + '래핑된 세션을 조정하려면 오케스트레이션을 활성화하세요.', + 'subconscious.instance.tinyplace.viewDirectives': '지시문 보기 →', + 'subconscious.runReviewNow': '지금 검토 실행', + 'tinyplaceOrchestration.subconsciousBadge': '잠재의식 · 조정', + 'tinyplaceOrchestration.steeringHeader.current': '활성 지시문', + 'tinyplaceOrchestration.steeringHeader.none': '활성 지시문 없음', + 'tinyplaceOrchestration.steeringHeader.expires': '{n}주기 후 만료', + 'tinyplaceOrchestration.steeringHeader.lastReview': '마지막 검토', + 'tinyplaceOrchestration.steeringHeader.runReview': '지금 검토 실행', + 'tinyplaceOrchestration.steeringHeader.running': '실행 중…', 'subconscious.providerUnavailableTitle': 'Subconscious 일시 중지됨', 'subconscious.providerSettings': 'AI 설정', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index e7a171524..e8a16045b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2763,6 +2763,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'nieudane', 'subconscious.tickInterval': 'Interwał tików', 'subconscious.runNow': 'Uruchom teraz', + 'subconscious.instance.on': 'Wł.', + 'subconscious.instance.off': 'Wył.', + 'subconscious.instance.memory.title': 'Twój świat', + 'subconscious.instance.memory.subtitle': 'Połączone źródła pamięci', + 'subconscious.instance.tinyplace.title': 'Sterowanie orkiestracją', + 'subconscious.instance.tinyplace.subtitle': 'Przegląd sesji tiny.place', + 'subconscious.instance.tinyplace.disabledHint': + 'Włącz Orkiestrację, aby sterować opakowanymi sesjami.', + 'subconscious.instance.tinyplace.viewDirectives': 'Zobacz dyrektywy →', + 'subconscious.runReviewNow': 'Uruchom przegląd teraz', + 'tinyplaceOrchestration.subconsciousBadge': 'Podświadomość · sterowanie', + 'tinyplaceOrchestration.steeringHeader.current': 'Aktywna dyrektywa', + 'tinyplaceOrchestration.steeringHeader.none': 'Brak aktywnej dyrektywy', + 'tinyplaceOrchestration.steeringHeader.expires': 'wygasa po {n} cyklach', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Ostatni przegląd', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Uruchom przegląd teraz', + 'tinyplaceOrchestration.steeringHeader.running': 'Trwa…', 'subconscious.providerUnavailableTitle': 'Podświadomość wstrzymana', 'subconscious.providerSettings': 'Ustawienia AI', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 448501dfd..5311f4017 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2784,6 +2784,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'falhou', 'subconscious.tickInterval': 'Intervalo de Tick', 'subconscious.runNow': 'Executar Agora', + 'subconscious.instance.on': 'Ativado', + 'subconscious.instance.off': 'Desativado', + 'subconscious.instance.memory.title': 'Seu mundo', + 'subconscious.instance.memory.subtitle': 'Fontes de memória conectadas', + 'subconscious.instance.tinyplace.title': 'Direção da orquestração', + 'subconscious.instance.tinyplace.subtitle': 'Revisão de sessões do tiny.place', + 'subconscious.instance.tinyplace.disabledHint': + 'Ative a Orquestração para direcionar as sessões encapsuladas.', + 'subconscious.instance.tinyplace.viewDirectives': 'Ver diretrizes →', + 'subconscious.runReviewNow': 'Executar revisão agora', + 'tinyplaceOrchestration.subconsciousBadge': 'Subconsciente · direção', + 'tinyplaceOrchestration.steeringHeader.current': 'Diretriz ativa', + 'tinyplaceOrchestration.steeringHeader.none': 'Nenhuma diretriz ativa', + 'tinyplaceOrchestration.steeringHeader.expires': 'expira após {n} ciclos', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Última revisão', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Executar revisão agora', + 'tinyplaceOrchestration.steeringHeader.running': 'Executando…', 'subconscious.providerUnavailableTitle': 'Subconsciente pausado', 'subconscious.providerSettings': 'Configurações de IA', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5bd10891f..a2e046d81 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2758,6 +2758,23 @@ const messages: TranslationMap = { 'subconscious.failed': 'ошибка', 'subconscious.tickInterval': 'Интервал тика', 'subconscious.runNow': 'Запустить сейчас', + 'subconscious.instance.on': 'Вкл.', + 'subconscious.instance.off': 'Выкл.', + 'subconscious.instance.memory.title': 'Ваш мир', + 'subconscious.instance.memory.subtitle': 'Подключённые источники памяти', + 'subconscious.instance.tinyplace.title': 'Управление оркестрацией', + 'subconscious.instance.tinyplace.subtitle': 'Обзор сессий tiny.place', + 'subconscious.instance.tinyplace.disabledHint': + 'Включите Оркестрацию, чтобы управлять обёрнутыми сессиями.', + 'subconscious.instance.tinyplace.viewDirectives': 'Показать директивы →', + 'subconscious.runReviewNow': 'Запустить обзор', + 'tinyplaceOrchestration.subconsciousBadge': 'Подсознание · управление', + 'tinyplaceOrchestration.steeringHeader.current': 'Активная директива', + 'tinyplaceOrchestration.steeringHeader.none': 'Нет активной директивы', + 'tinyplaceOrchestration.steeringHeader.expires': 'истекает через {n} циклов', + 'tinyplaceOrchestration.steeringHeader.lastReview': 'Последний обзор', + 'tinyplaceOrchestration.steeringHeader.runReview': 'Запустить обзор', + 'tinyplaceOrchestration.steeringHeader.running': 'Выполняется…', 'subconscious.providerUnavailableTitle': 'Подсознание приостановлено', 'subconscious.providerSettings': 'Настройки ИИ', 'subconscious.scratchpadInfo': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e7090310f..1911d0255 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2586,6 +2586,22 @@ const messages: TranslationMap = { 'subconscious.failed': '失败', 'subconscious.tickInterval': '滴答间隔', 'subconscious.runNow': '立即运行', + 'subconscious.instance.on': '开启', + 'subconscious.instance.off': '关闭', + 'subconscious.instance.memory.title': '你的世界', + 'subconscious.instance.memory.subtitle': '已连接的记忆来源', + 'subconscious.instance.tinyplace.title': '编排引导', + 'subconscious.instance.tinyplace.subtitle': 'tiny.place 会话审查', + 'subconscious.instance.tinyplace.disabledHint': '启用编排以引导封装的会话。', + 'subconscious.instance.tinyplace.viewDirectives': '查看指令 →', + 'subconscious.runReviewNow': '立即运行审查', + 'tinyplaceOrchestration.subconsciousBadge': '潜意识 · 引导', + 'tinyplaceOrchestration.steeringHeader.current': '当前指令', + 'tinyplaceOrchestration.steeringHeader.none': '无活动指令', + 'tinyplaceOrchestration.steeringHeader.expires': '{n} 个周期后过期', + 'tinyplaceOrchestration.steeringHeader.lastReview': '上次审查', + 'tinyplaceOrchestration.steeringHeader.runReview': '立即运行审查', + 'tinyplaceOrchestration.steeringHeader.running': '运行中…', 'subconscious.providerUnavailableTitle': '潜意识已暂停', 'subconscious.providerSettings': 'AI 设置', 'subconscious.scratchpadInfo': diff --git a/app/src/pages/Activity.tsx b/app/src/pages/Activity.tsx index 44fe49e93..6a0591a00 100644 --- a/app/src/pages/Activity.tsx +++ b/app/src/pages/Activity.tsx @@ -59,9 +59,11 @@ export default function Activity() { // Subconscious engine data (used by the Background Activity tab). const { status: subconsciousEngineStatus, + instances: subconsciousInstances, mode: subconsciousMode, intervalMinutes: subconsciousInterval, triggering: subconsciousTriggering, + isTriggering: subconsciousIsTriggering, settingMode: subconsciousSettingMode, triggerTick, setMode: setSubconsciousMode, @@ -154,10 +156,12 @@ export default function Activity() { {activeTab === 'backgroundActivity' && ( setActiveTab('orchestration')} /> )} diff --git a/app/src/pages/conversations/hooks/useBackgroundActivity.test.ts b/app/src/pages/conversations/hooks/useBackgroundActivity.test.ts index b5bf3a68e..fac8cc863 100644 --- a/app/src/pages/conversations/hooks/useBackgroundActivity.test.ts +++ b/app/src/pages/conversations/hooks/useBackgroundActivity.test.ts @@ -48,6 +48,7 @@ function fixtures() { }); mockSubStatus.mockResolvedValue({ result: { + instance: 'memory', enabled: true, mode: 'event_driven', provider_available: true, diff --git a/app/src/utils/tauriCommands/__tests__/subconscious.test.ts b/app/src/utils/tauriCommands/__tests__/subconscious.test.ts new file mode 100644 index 000000000..0587f8ba2 --- /dev/null +++ b/app/src/utils/tauriCommands/__tests__/subconscious.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { callCoreRpc } from '../../../services/coreRpcClient'; +import { isTauri } from '../common'; +import { subconsciousStatus, subconsciousTrigger } from '../subconscious'; + +vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); +vi.mock('../common', () => ({ isTauri: vi.fn(() => true), CommandResponse: undefined })); + +describe('subconscious client', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isTauri).mockReturnValue(true); + }); + + it('trigger with no kind omits params (legacy memory behavior)', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { triggered: true } } as never); + await subconsciousTrigger(); + expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.subconscious_trigger' }); + }); + + it('trigger passes the kind through as params', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { triggered: true } } as never); + await subconsciousTrigger('tinyplace'); + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.subconscious_trigger', + params: { kind: 'tinyplace' }, + }); + }); + + it('status parses instances[] and tolerates its absence', async () => { + // With instances (new core). + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + result: { + instance: 'memory', + enabled: true, + instances: [ + { instance: 'memory', enabled: true }, + { instance: 'tinyplace', enabled: false }, + ], + }, + } as never); + const withInstances = await subconsciousStatus(); + expect(withInstances.result?.instances).toHaveLength(2); + + // Without instances (older core) — the top-level fields still parse. + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + result: { instance: 'memory', enabled: true }, + } as never); + const legacy = await subconsciousStatus(); + expect(legacy.result?.instances).toBeUndefined(); + expect(legacy.result?.instance).toBe('memory'); + }); +}); diff --git a/app/src/utils/tauriCommands/subconscious.ts b/app/src/utils/tauriCommands/subconscious.ts index 103bbbf72..98f2e6528 100644 --- a/app/src/utils/tauriCommands/subconscious.ts +++ b/app/src/utils/tauriCommands/subconscious.ts @@ -11,9 +11,18 @@ import type { HeartbeatSettings } from './heartbeat'; // ── Types ──────────────────────────────────────────────────────────────────── -export interface SubconsciousStatus { +/** Which subconscious world a status row / trigger targets. */ +export type SubconsciousKind = 'memory' | 'tinyplace'; + +/** One subconscious world's health row. */ +export interface SubconsciousInstanceStatus { + /** + * Which world this row describes. Defaulted to 'memory' by the core when + * absent, so an older core during rollout still parses. + */ + instance: SubconsciousKind; enabled: boolean; - mode: 'off' | 'simple' | 'aggressive' | 'event_driven'; + mode: 'off' | 'simple' | 'aggressive' | 'event_driven' | 'steering'; provider_available: boolean; provider_unavailable_reason: string | null; interval_minutes: number; @@ -22,6 +31,16 @@ export interface SubconsciousStatus { consecutive_failures: number; } +/** + * The `subconscious.status` response. The legacy top-level fields mirror the + * memory instance (backward compatible); `instances` lists every registered + * world. `instances` may be absent when talking to an older core — treat it as + * `[]` and fall back to the top-level (memory) fields. + */ +export interface SubconsciousStatus extends SubconsciousInstanceStatus { + instances?: SubconsciousInstanceStatus[]; +} + export interface TickResult { tick_at: number; duration_ms: number; @@ -48,10 +67,18 @@ export async function subconsciousStatus(): Promise> { +/** + * Manually trigger a subconscious tick. `kind` selects the world: 'memory' + * (default — today's behavior), 'tinyplace', or 'all'. A no-arg call keeps the + * legacy memory-only behavior. + */ +export async function subconsciousTrigger( + kind?: SubconsciousKind | 'all' +): Promise> { if (!isTauri()) throw new Error('Not running in Tauri'); return await callCoreRpc>({ method: 'openhuman.subconscious_trigger', + ...(kind ? { params: { kind } } : {}), }); } diff --git a/docs/plans/subconscious-factory/README.md b/docs/plans/subconscious-factory/README.md new file mode 100644 index 000000000..d2d04ae7d --- /dev/null +++ b/docs/plans/subconscious-factory/README.md @@ -0,0 +1,129 @@ +# Subconscious factory — one reflection engine, many worlds + +Redesign `src/openhuman/subconscious/` around the split-brain spec +(`state (1).md`, "Autonomous Closed-Loop LangGraph Harness"): the subconscious +is the **Deep Reflection Layer** — an offline, cron-driven loop that consumes a +compressed view of how a world changed and emits short, dense outputs that +steer the rest of the system. Today that layer exists twice, fused together +inside one engine. This plan pulls it apart into a **factory** that can +instantiate a subconscious per *world*: + +- **`memory`** — OpenHuman's internal high-level world: the user's connected + memory sources (Gmail/Slack/Notion/folders). Observes a `memory_diff` + against a baseline checkpoint; reflects with the slim decision agent + (to-dos, goals, `notify_user`, delegation). +- **`tinyplace`** — the tiny.place orchestration world: harness-session + interactions flowing through the `orchestration` domain. Observes the + 20:1 compressed execution history + cumulative world-state diff; reflects + with a tool-free steering synthesis that emits `STEERING_DIRECTIVE`s for the + reasoning core. + +New kinds (e.g. a per-team world, a channels world) become one new profile +file, not another engine. + +## Where we are today + +| Spec concept (state (1).md) | Existing implementation | +| --- | --- | +| Quick LLM / Front-End Agent | `orchestration/frontend_agent/` (`hint:chat`, two-pass) | +| Reasoning LLM / orchestration core | `orchestration/reasoning_agent/` (`hint:reasoning`), wake graph in `orchestration/graph/` | +| Bi-directional loop routing | `orchestration/graph/build.rs` + `ops::invoke_with_runtime` (debounce, idempotence cursor, dm_sent latch) | +| 20:1 compression engine | `orchestration/graph/compress.rs` + `ProductionRuntime::compress` | +| Cumulative world-state diff | `orchestration/graph/world_diff.rs` + `store::append_world_diff` | +| Context lifecycle hooks (80–90%) | `context_guard`/`evict` nodes → memory-RAG eviction | +| Subconscious LLM / steering | `orchestration/steering.rs` + `ops::run_orchestration_review` — **but invoked inline from `subconscious::engine::tick_inner`** | +| Cron trigger | `subconscious/heartbeat/` driving `SubconsciousEngine::tick` | + +The problem is confined to `subconscious/engine.rs`: `tick_inner` is a +hard-wired composite — stage 0 calls `orchestration::ops::run_orchestration_review` +(the tiny.place world), then stages 1–3 run the memory world (memory_diff → +context scout → decision agent). One tick lock, one circuit breaker, one +status object, one baseline store — for two unrelated worlds. Neither can get +its own cadence, provider signature, halt state, or status, and a third world +cannot be added without growing the composite further. + +## Target shape + +``` +subconscious/ +├── mod.rs exports (unchanged public names + new factory surface) +├── profile.rs SubconsciousProfile trait + Observation/Reflection types +├── engine.rs generic SubconsciousInstance: tick graph (tinyagents CompiledGraph) + scheduler shell +├── factory.rs SubconsciousKind + make_subconscious(kind, config) +├── registry.rs (grown from global.rs) kind → instance map, lifecycle +├── profiles/ +│ ├── memory.rs today's stages 1–3, extracted verbatim +│ └── tinyplace.rs wraps orchestration::ops::run_orchestration_review +├── store.rs instance-namespaced KV + legacy-key migration +├── heartbeat/ unchanged scheduler; drives every registered instance +├── agent/ unchanged (memory profile's decision agent) +├── session.rs, user_thread.rs, source_chunk.rs, schemas.rs unchanged roles +``` + +The generic tick is a **`tinyagents` `CompiledGraph`** (the same runtime the +orchestration wake path runs on — see phase 6), wrapped by scheduler concerns +in `engine.rs`, identical for every kind: + +``` +lock → timeout guard → config load → provider gate + rate-cap halt (per-instance signature) + → run graph: START ─► observe ─┬─(quiet)──────────────────────► commit ─► END + └─► prepare_context ─► reflect ─► commit ─► END + · nodes delegate to the profile (the profile IS the injected runtime) + · SqliteCheckpointer → a killed tick resumes instead of losing its window + → superseded-generation check + → per-instance state/status update +``` + +Everything that is currently *good* about the engine — tick lock, generation +counter, `TICK_TIMEOUT`, rate-cap circuit breaker (TAURI-RUST-HXF), tool-capability +detection (TAURI-RUST-ADC), only-advance-on-success baselines, quiet-tick +short-circuit — moves into the generic runner **once** and every world gets it +for free. + +## The factory + +```rust +pub enum SubconsciousKind { Memory, TinyPlace } + +pub fn make_subconscious(kind: SubconsciousKind, config: &Config) -> SubconsciousInstance { + let profile: Arc = match kind { + SubconsciousKind::Memory => Arc::new(profiles::memory::MemoryProfile::new(config)), + SubconsciousKind::TinyPlace => Arc::new(profiles::tinyplace::TinyPlaceProfile::new(config)), + }; + SubconsciousInstance::new(profile, config) +} +``` + +`registry.rs` (today's `global.rs`) holds the instances, bootstraps the enabled +set after login (`Memory` when `heartbeat.enabled`; `TinyPlace` when +`orchestration.enabled`), and tears all of them down on user switch. + +## Invariants that must survive the refactor + +1. **Isolation** — the subconscious never contacts anyone. The tinyplace + profile stays a tool-free provider chat; the memory profile's agent toolset + keeps the no-channel/no-outbound test (`subconscious_agent_tool_surface_has_no_channel_or_effect_tools`). +2. **Taint** — any tick that reacted to external content runs + `SubconsciousTainted` (memory: diff-driven ticks; tinyplace: always). +3. **State only advances on success**; a superseded tick discards its result. +4. **Quiet ticks cost nothing** — no LLM call when `observe()` is empty. +5. **`subconscious.status` never touches the tick mutex** — reads from SQLite. +6. **Back-compat** — existing DB keys (`last_tick_at`, `baseline_checkpoint_id`) + migrate to the `memory:`-namespaced keys; the RPC keeps its legacy top-level + fields mirroring the memory instance. + +## Phases + +| Phase | File | Deliverable | +| --- | --- | --- | +| 1 | [phase-1-profile-and-engine.md](phase-1-profile-and-engine.md) | `SubconsciousProfile` trait, generic `SubconsciousInstance`, namespaced store | +| 2 | [phase-2-memory-profile.md](phase-2-memory-profile.md) | Extract the memory flow into `profiles/memory.rs`, behavior-identical | +| 3 | [phase-3-tinyplace-profile.md](phase-3-tinyplace-profile.md) | Wrap the orchestration review as `profiles/tinyplace.rs`; delete the inline call | +| 4 | [phase-4-factory-registry-rpc.md](phase-4-factory-registry-rpc.md) | Factory + registry lifecycle, heartbeat fan-out, per-instance RPC | +| 5 | [phase-5-tests-and-docs.md](phase-5-tests-and-docs.md) | Test matrix, migration tests, README/docs updates, rollout | +| 6 | [phase-6-tinyagents-reuse.md](phase-6-tinyagents-reuse.md) | TinyAgents graph reuse map + upstream PRs to `tinyhumansai/tinyagents` (deadline, cancel token, checkpoint GC) | +| 7 | [phase-7-ui.md](phase-7-ui.md) | UI: both kinds visible + triggerable — instance cards in the Subconscious tab, steering header on the orchestration tab's Subconscious window | + +Each phase is a separately compilable, committable slice; phases 2 and 3 are +pure extractions (no behavior change), which keeps the diffs reviewable and +the coverage gate satisfiable per-slice. diff --git a/docs/plans/subconscious-factory/phase-1-profile-and-engine.md b/docs/plans/subconscious-factory/phase-1-profile-and-engine.md new file mode 100644 index 000000000..53b618522 --- /dev/null +++ b/docs/plans/subconscious-factory/phase-1-profile-and-engine.md @@ -0,0 +1,188 @@ +# Phase 1 — `SubconsciousProfile` trait + generic instance runner + +Goal: introduce the abstraction without moving any behavior yet. At the end of +this phase the existing `SubconsciousEngine` still works exactly as today; the +new types compile alongside it and are exercised only by unit tests. + +## 1.1 `profile.rs` — the world contract + +```rust +/// One "world" a subconscious can be instantiated over. +#[async_trait::async_trait] +pub trait SubconsciousProfile: Send + Sync { + /// Stable instance id — store-key namespace, log prefix, RPC name. + fn id(&self) -> &'static str; // "memory" | "tinyplace" + + /// Tick cadence for this world (heartbeat multiplies its base interval). + fn cadence(&self, config: &Config) -> std::time::Duration; + + /// Stage 1: what changed in this world since my baseline? + /// Errors and first-ever ticks surface as an empty observation + /// (`has_changes == false`) so the runner's quiet-path handles them. + async fn observe(&self, config: &Config) -> Observation; + + /// Stage 2 (optional): grounding context for the reflection turn. + /// Default impl returns "" (the tinyplace profile skips this stage). + async fn prepare_context(&self, _config: &Config, _obs: &Observation) -> String { + String::new() + } + + /// Stage 3: the reflection turn. Runs only when `obs.has_changes`. + async fn reflect( + &self, + config: &Config, + obs: &Observation, + prepared_context: &str, + ) -> Result; + + /// Advance this world's baseline/cursor. Called by the runner: + /// - after a quiet tick (refresh the baseline), and + /// - after a successful reflect (never after a failed or superseded one). + async fn commit(&self, config: &Config, obs: &Observation); + + /// Turn-origin taint for this observation (memory: tainted iff the diff + /// carried external content; tinyplace: always tainted). + fn origin(&self, obs: &Observation) -> TrustedAutomationSource; +} + +pub struct Observation { + /// Rendered world diff handed to `reflect` (empty when quiet). + pub rendered: String, + pub has_changes: bool, + /// Whether the change window contains third-party content (taint input). + pub has_external_content: bool, + /// Opaque token `commit` uses to advance exactly the observed window + /// (memory: none needed — it re-checkpoints; tinyplace: newest reviewed + /// compressed-row created_at for the review cursor). + pub commit_token: Option, +} + +pub enum Reflection { + /// The decision agent acted through tools (memory profile). + Acted { response_chars: usize }, + /// A steering directive was emitted (tinyplace profile). + Steered { directive_id: i64 }, + /// The model looked and correctly chose to do nothing. + Idle, +} +``` + +Design notes: + +- `observe` is **infallible by signature**: today `tick_inner` already treats a + diff error as "quiet tick, refresh baseline"; encoding that in the type keeps + the runner branch-free. The profile logs its own errors. +- `reflect` returns `Err(String)` (matching today's `run_agent` error channel) + so the runner can keep the existing error classifiers — the string is fed to + `is_tool_capability_error` / `is_permanent_rate_cap_error` unchanged. +- `commit_token` exists because the tinyplace world advances a *cursor over + reviewed rows* (must not skip rows that arrived mid-tick), while the memory + world re-checkpoints the whole world. Both fit one optional string. + +## 1.2 `engine.rs` — `SubconsciousInstance` (generic runner **as a tinyagents graph**) + +Rename the struct conceptually: `SubconsciousEngine` → `SubconsciousInstance` +(keep a `pub type SubconsciousEngine = SubconsciousInstance;` alias until +phase 4 updates the callers). The instance owns exactly what the engine owns +today, minus every memory-specific line: + +```rust +pub struct SubconsciousInstance { + profile: Arc, + graph: Arc>, // built once at construction + workspace_dir: PathBuf, + mode: SubconsciousMode, + interval_minutes: u32, + enabled: bool, + state: Mutex, // unchanged fields + tick_generation: AtomicU64, + tick_lock: Mutex<()>, +} +``` + +The tick body is **not** hand-rolled control flow: it is one `tinyagents` +[`CompiledGraph`] built with `GraphBuilder`, exactly the pattern +`orchestration/graph/build.rs` established (nodes call an injected +`dyn` runtime; the graph owns routing/termination; a stub runtime makes the +mechanics unit-testable). Topology: + +```text +START ─► observe ─┬─(quiet)────────────────────────────► commit ─► END + └─(changed)─► prepare_context ─► reflect ─► commit ─► END +``` + +- `SubconsciousState` (serde, like `OrchestrationState`): the `Observation`, + `prepared_context`, `Reflection` outcome, tick metadata (`tick_id`, + generation). Channels are plain `LastValue`s — no message reducers needed. +- The node handlers delegate 1:1 to the `SubconsciousProfile` methods — the + profile trait **is** the injected runtime (mirroring + `OrchestrationRuntime`); no second abstraction. +- Conditional edge out of `observe` on `has_changes` (command-routing, same + mechanism as the `frontend` router). +- Checkpointing: `SqliteCheckpointer` at + `/subconscious/graph_checkpoints.db`, thread id + `subconscious::`. A tick killed mid-reflect resumes (or + is superseded cleanly) instead of losing the window — same durability story + the wake graph already has. Baseline advancement stays in `commit`, so a + resumed tick can never double-advance. +- Observability for free: graph stream events / status snapshots + (`tinyagents::graph::stream`/`status`) give per-node timing without bespoke + logging (keep the `[subconscious]` log lines anyway per repo logging rules). + +What deliberately stays *outside* the graph, in `SubconsciousInstance`: +the cadence loop trigger (heartbeat), the tick lock + 5s acquisition skip, +the generation counter (supersede), `TICK_TIMEOUT`, provider gate + rate-cap +halt, and status. Rationale: these are scheduler/circuit-breaker concerns +identical to what `ops::invoke_with_runtime` keeps outside the wake graph — +see phase 6 for the tinyagents-side gaps that would let some of them move +into the runtime later. + +The pieces that stay verbatim in the runner: + +- 5s tick-lock acquisition timeout + skip log +- `TICK_TIMEOUT` wall clock (30 min) — consider making it a profile constant + later; not in this phase +- config-load failure path +- provider gate (`subconscious_provider_unavailable_reason`) and the rate-cap + halt (`should_skip_for_rate_cap_halt` / `arm_rate_cap_halt`) — the halt + signature gains the instance id prefix (`"memory|cloud"`) so one world's + halt never silences another +- tool-capability / rate-cap error classification on `reflect` failure +- superseded-generation discard +- `status()` — gains `instance: String` (the profile id) + +## 1.3 `store.rs` — instance-namespaced KV + migration + +Today: `subconscious_state` (REAL KV: `last_tick_at`) and +`subconscious_state_text` (TEXT KV: `baseline_checkpoint_id`), one row each. + +Change the accessors to take the instance id and prefix the key +(`"memory:last_tick_at"`, `"tinyplace:last_tick_at"`, …). One-time migration in +the DDL block of `with_connection` (idempotent, like all our DDL): + +```sql +UPDATE subconscious_state SET key = 'memory:' || key + WHERE key IN ('last_tick_at') AND NOT EXISTS (SELECT 1 FROM subconscious_state WHERE key = 'memory:last_tick_at'); +UPDATE subconscious_state_text SET key = 'memory:' || key + WHERE key IN ('baseline_checkpoint_id') AND NOT EXISTS (SELECT 1 FROM subconscious_state_text WHERE key = 'memory:baseline_checkpoint_id'); +``` + +(Exact guard shape to be settled in implementation — requirement is: running +old→new→old… never loses `last_tick_at`, and the migration is a no-op on a +fresh DB. The tinyplace profile's *review cursor* stays where it lives today, +in the orchestration store — the profile owns it via `commit`.) + +## 1.4 Tests (this phase) + +- A `FakeProfile` (scripted observations/reflections, call recorder) driving + `SubconsciousInstance` directly: + - quiet observation → no `reflect`, `commit` called, `last_tick_at` advanced + - failing `reflect` → no `commit`, `consecutive_failures` bumped, baseline untouched + - rate-cap error string → halt armed under `"|"`; second + tick skips; signature change resumes + - superseded generation → result discarded, no `commit` +- Store: namespaced get/set round-trip; legacy-key migration (seed old keys, + open, assert `memory:`-keys carry the values). + +Existing `engine_tests.rs` keeps passing untouched (the old engine still +exists in this phase). diff --git a/docs/plans/subconscious-factory/phase-2-memory-profile.md b/docs/plans/subconscious-factory/phase-2-memory-profile.md new file mode 100644 index 000000000..6b931b39e --- /dev/null +++ b/docs/plans/subconscious-factory/phase-2-memory-profile.md @@ -0,0 +1,58 @@ +# Phase 2 — Extract the memory profile (behavior-identical) + +Goal: move today's stages 1–3 out of `engine.rs` into +`profiles/memory.rs`, switch the live engine to +`SubconsciousInstance::new(MemoryProfile, config)`, and delete the old +monolithic `tick_inner`. **No behavior change** — this phase is a pure +extraction, verified by keeping `engine_tests.rs` green against the new +composition. + +## 2.1 What moves where + +| Today (`engine.rs`) | Destination | +| --- | --- | +| baseline load + `diff_since_checkpoint` + `world_diff_change_count` | `MemoryProfile::observe` | +| `render_world_diff` + `MAX_ITEMS_PER_SOURCE` | `profiles/memory.rs` (private) | +| `prepare_context` (context_scout + `with_root_parent`, TAURI-RUST-HMW) | `MemoryProfile::prepare_context` | +| `SUBCONSCIOUS_TOOL_CATALOG` | `profiles/memory.rs` | +| `run_agent` (slim agent, `hint:subconscious`, Full autonomy, mode → iteration caps, user-message contract) | `MemoryProfile::reflect` | +| `refresh_baseline` (`create_checkpoint` + persist id) | `MemoryProfile::commit` | +| `tick_origin_source` | `MemoryProfile::origin` (keep the free fn for its unit test) | +| **the inline `run_orchestration_review` call (stage 0)** | **stays temporarily** — moved in phase 3 | + +Note on the stage-0 call: during phase 2 it lives in a small shim at the top +of the runner (`if profile.id() == "memory" { run_orchestration_review(...) }`) +or equivalently stays as a pre-hook closure passed by the bootstrap. Ugly on +purpose and clearly marked `// phase-3 removes this`; it keeps phase 2 purely +mechanical. + +## 2.2 `MemoryProfile` specifics + +- `id()` → `"memory"`; `cadence` → `mode.default_interval_minutes().max(5)` + minutes (today's value). +- `observe`: returns `has_changes == false` for first-tick/no-baseline, quiet + window, or diff error (each with today's log lines). Sets + `has_external_content = true` whenever there are changes (every change + originates from a source sync — today's comment carries over). +- `reflect`: returns `Reflection::Acted { response_chars }`; the SubconsciousMode + → `max_tool_iterations` mapping (15 / 30 / Off→short-circuit) is unchanged. +- `commit`: re-checkpoint + persist under the namespaced key + (`memory:baseline_checkpoint_id`). Best-effort, warn on failure (unchanged). +- `origin`: `tick_origin_source(obs.has_external_content)`. + +## 2.3 Public-surface continuity + +`mod.rs` keeps exporting `SubconsciousEngine` (alias), `SubconsciousStatus`, +`TickResult`, `notify_user`, session/source_chunk items — nothing outside the +domain changes in this phase. `global.rs` swaps its construction call only. + +## 2.4 Tests + +- Port `engine_tests.rs` to construct `SubconsciousInstance` with + `MemoryProfile` — assertions unchanged (rate-cap halt lifecycle, provider + routing/signature, origin escalation, tool-capability detection). +- New: `MemoryProfile::observe` unit tests over a seeded memory_diff store + (quiet vs changed windows), reusing whatever fixture `memory_diff` tests use. +- The agent-toolset isolation test in `orchestration/ops.rs` + (`subconscious_agent_tool_surface_has_no_channel_or_effect_tools`) keeps + compiling — `agent/agent.toml` does not move. diff --git a/docs/plans/subconscious-factory/phase-3-tinyplace-profile.md b/docs/plans/subconscious-factory/phase-3-tinyplace-profile.md new file mode 100644 index 000000000..d92b0c75f --- /dev/null +++ b/docs/plans/subconscious-factory/phase-3-tinyplace-profile.md @@ -0,0 +1,85 @@ +# Phase 3 — The tiny.place profile (orchestration steering) + +Goal: make the tiny.place/orchestration subconscious a first-class instance +and delete the stage-0 shim from the runner. After this phase the two worlds +tick independently: separate locks, cadences, circuit breakers, failure +counters, and status rows. + +## 3.1 `profiles/tinyplace.rs` + +Wraps the machinery that already exists in `orchestration/`: + +- `id()` → `"tinyplace"`. +- `cadence`: its own knob (see 3.3); default = the heartbeat interval, so the + merged behavior matches today (review ran once per memory tick). +- `observe`: the load half of `run_orchestration_review` — + `review_cursor` → `list_unreviewed_compressed(REVIEW_BATCH)` + + `list_recent_world_mutations(REVIEW_BATCH)` + `current_cycle_counter`. + `has_changes = !compressed.is_empty()` (today's idle gate). + `rendered = build_steering_prompt(&summaries, &mutations)`. + `commit_token = newest reviewed created_at`. + `has_external_content = true` always — harness DMs are third-party content. +- `prepare_context`: default no-op (steering is deliberately tool-free; no + scout). +- `reflect`: the synth half — `synthesize_steering` (tool-free + `create_chat_provider("subconscious")` chat, `SubconsciousTainted` origin, + one retry on contract violation) + persist + (`insert_steering_directive`, supersede prior, `record_subconscious_directive` + into the local Subconscious window + event publish). Returns + `Steered { directive_id }` or `Idle` (clean `NONE` / twice-failed — + today both still advance the cursor; preserve that by returning `Idle`, + not `Err`, for those cases). +- `commit`: `set_review_cursor(commit_token)`. This is where the phase pays + off: today the cursor advance is tangled into `run_orchestration_review`'s + three exit paths; splitting observe/reflect/commit lets the runner enforce + "advance only when the tick wasn't superseded" uniformly. +- `origin`: always `SubconsciousTainted`. + +## 3.2 Refactor of `orchestration::ops` + +`run_orchestration_review` is currently the public entry called by the +subconscious engine. Plan: + +- Split it into store-facing pieces the profile calls + (`load_review_window(config) -> ReviewWindow`, + `synthesize_and_persist(config, window, tick_id) -> Option`), keeping + them in `orchestration::ops` — the orchestration domain still owns its + store shapes and the steering contract; the subconscious profile is just + the *scheduler + policy* around them. +- Keep a thin `run_orchestration_review` wrapper for the two existing tests + (or port those tests to drive the profile; implementor's choice — the + invariants asserted must survive: emit-then-inject-next-cycle, idempotent + re-tick, exactly-one-directive). +- Delete the stage-0 shim in the subconscious runner (`// phase-3 removes + this` marker from phase 2). + +## 3.3 Config + +- `orchestration.enabled` remains the master gate for the tinyplace instance + (profile `observe` returns quiet when disabled — same as today's early + `Ok(false)`). +- Add `orchestration.review_interval_minutes: Option` (None = heartbeat + interval) consumed by `cadence`. Schema change in + `config/schema/orchestration.rs` + env override in `load.rs` + + `.env.example` note. + +## 3.4 Isolation invariants (unchanged, now testable per-instance) + +- The tinyplace reflect path constructs **no Agent and no toolset** — it is a + provider chat. Add a compile-level guard mirroring the existing agent-toml + test: the profile module must not import `tinyplace::agent_tools` or any + `send_message`-family symbol (source-scan test like + `orchestration_logs_never_reference_message_bodies`). +- The steering directive remains an out-of-band writer: it is read by + `apply_cycle_steering` at the next wake, never edges into the graph. + +## 3.5 Tests + +- Profile-level: seeded orchestration store → observe has_changes; scripted + provider (existing `test_provider_override`) → `Steered`; cursor advanced + via runner commit; re-tick idle. +- Runner-level: tinyplace + memory instances ticking concurrently against one + workspace — no lock contention between them, independent `last_tick_at` + keys, one instance's rate-cap halt does not gate the other. +- Regression: the full-cycle orchestration graph test keeps passing (steering + injection path untouched). diff --git a/docs/plans/subconscious-factory/phase-4-factory-registry-rpc.md b/docs/plans/subconscious-factory/phase-4-factory-registry-rpc.md new file mode 100644 index 000000000..9f6ed394f --- /dev/null +++ b/docs/plans/subconscious-factory/phase-4-factory-registry-rpc.md @@ -0,0 +1,90 @@ +# Phase 4 — Factory, registry lifecycle, heartbeat fan-out, RPC + +Goal: the "make subconscious" surface — instantiate any set of worlds, drive +them from the heartbeat, expose per-instance status/trigger over JSON-RPC. + +## 4.1 `factory.rs` + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubconsciousKind { Memory, TinyPlace } + +impl SubconsciousKind { + pub fn id(self) -> &'static str; // "memory" | "tinyplace" + pub fn parse(s: &str) -> Option; + /// Which kinds should run for this config (bootstrap set). + pub fn enabled_kinds(config: &Config) -> Vec { + // Memory ⇐ heartbeat.enabled && mode != Off (today's gate) + // TinyPlace ⇐ orchestration.enabled (today's gate) + } +} + +pub fn make_subconscious(kind: SubconsciousKind, config: &Config) -> SubconsciousInstance; +``` + +`make_subconscious` is the *only* place profiles are constructed — tests and +the trigger RPC go through it too, so a new kind is: profile file + one match +arm + one `enabled_kinds` line. + +## 4.2 `registry.rs` (grown from `global.rs`) + +Replace the single `OnceLock>>>` with a +keyed registry: + +```rust +static REGISTRY: OnceLock>>>; +``` + +- `get_or_init_instance(kind)` — lazy per-kind construction (same + load-config-then-insert dance as today's `get_or_init_engine`). +- `bootstrap_after_login()` — unchanged guard (`BOOTSTRAPPED` swap), then + initializes every `enabled_kinds(config)` member, spawns the heartbeat, and + (unchanged) the opt-in trigger orchestrator. +- `stop_heartbeat_loop()` / `reset_engine_for_user_switch()` — abort the + heartbeat, shut down the trigger orchestrator, then clear the whole map so + a user switch rebuilds every instance against the new workspace. +- Keep `get_or_init_engine()` as a deprecated alias for + `get_or_init_instance(Memory)` until the RPC handlers are ported (below), + then delete it. + +Instances stay `Arc` (not `Mutex>`): the instance's own +`tick_lock`/`state` mutexes already serialize what needs serializing, and the +status path must remain lock-free per invariant 5. + +## 4.3 Heartbeat fan-out + +`heartbeat/engine.rs` currently calls the single engine's `tick()` on its +interval. Change: on each heartbeat interval, iterate the registry and tick +every instance whose `cadence` has elapsed since its `last_tick_at` +(per-instance keys from phase 1 make this a pure read). Instances tick +**concurrently** (`tokio::spawn` per instance, joined with the existing +cancel/abort semantics) — a slow memory tick must not delay a tinyplace +review. The heartbeat's event-planner duties (meetings/reminders) are +untouched. + +## 4.4 RPC surface (`schemas.rs`) + +Backward-compatible extension of the `subconscious` namespace: + +- `subconscious.status` → today's top-level fields stay, populated from the + **memory** instance (existing UI keeps working), plus + `instances: [SubconsciousStatus]` with one row per registered kind + (each row gains `instance: "memory" | "tinyplace"`). +- `subconscious.trigger` → optional `kind` param (`"memory"` default — + today's behavior; `"tinyplace"`; `"all"`). Still fire-and-forget + (spawned, returns immediately). +- Status reads stay SQLite-only: per-instance `last_tick_at` comes from the + namespaced KV; in-memory counters (failures, halt reason) come from the + instance's `status()` which takes only the small `state` mutex, never + `tick_lock` — same as today. + +Frontend consumption of these fields is phase 7 (instance cards in the +Subconscious tab, steering header in the TinyPlace Orchestration tab). Not a +blocker for the Rust work — the additions are backward-compatible. + +## 4.5 `about_app` + +Update `src/openhuman/about_app/` copy: the subconscious is now described as +per-world instances (memory awareness + tiny.place orchestration steering), +per the repo rule that user-facing feature changes update about_app. diff --git a/docs/plans/subconscious-factory/phase-5-tests-and-docs.md b/docs/plans/subconscious-factory/phase-5-tests-and-docs.md new file mode 100644 index 000000000..25121f182 --- /dev/null +++ b/docs/plans/subconscious-factory/phase-5-tests-and-docs.md @@ -0,0 +1,65 @@ +# Phase 5 — Test matrix, docs, rollout + +## 5.1 Test matrix (cumulative — most land inside phases 1–4) + +| Layer | Test | Phase | +| --- | --- | --- | +| runner | FakeProfile: quiet / fail / rate-cap-halt / superseded lifecycles | 1 | +| store | key namespacing + legacy→`memory:` migration round-trip | 1 | +| memory profile | observe over seeded diff store; origin escalation; mode→iteration caps | 2 | +| ported | all of today's `engine_tests.rs` against `SubconsciousInstance` | 2 | +| tinyplace profile | observe/reflect/commit against scripted provider; idle-NONE advances cursor | 3 | +| cross-instance | two instances, one workspace: independent state keys, halts, no lock coupling | 3 | +| factory | `enabled_kinds` gating per config; `make_subconscious` per kind | 4 | +| registry | bootstrap set, user-switch reset clears map, deprecated alias | 4 | +| heartbeat | fan-out ticks only elapsed cadences; concurrent tick spawn | 4 | +| RPC | `status` legacy fields mirror memory; `instances` rows; `trigger kind=…` | 4 | +| json_rpc_e2e | extend `tests/json_rpc_e2e.rs`: `subconscious.status` shape incl. `instances` | 4 | +| isolation | tinyplace source-scan (no agent/toolset imports); agent.toml scan (exists) | 3 | + +Coverage gate: each phase is its own PR-sized slice with ≥80% diff coverage; +the extraction phases (2, 3) inherit most coverage from ported tests. + +## 5.2 Docs + +- `src/openhuman/subconscious/README.md` — rewrite around the factory: the + generic tick skeleton, the profile table (memory / tinyplace), per-instance + persistence keys, RPC additions. Keep the gotchas list (post-login + bootstrap, status-never-locks, state-advances-on-success, quiet-tick + short-circuit, taint) — all still true, now per instance. +- `orchestration/mod.rs` module docs — stage 6 wording: the review is now + driven by the tinyplace subconscious instance, not inlined in the memory + tick. +- `gitbooks/developing/architecture/agent-harness.md` — if it names the + subconscious loop, reflect the split. Run `pnpm docs:generate` + + `pnpm docs:check` (Docs Drift lane) in the final slice. +- `about_app` copy (phase 4.5). + +## 5.3 Rollout & risk + +- **No data-shape risk**: the only migration is KV key renaming inside + `subconscious.db` (phase 1.3), designed to be old-version-tolerant. The + orchestration store is untouched. +- **Behavioral deltas to call out in the PR series** (all intentional): + 1. The orchestration review no longer piggybacks on the memory tick — it + runs on its own cadence, so it also runs when the memory world is quiet + (today a memory-provider outage silently starves steering; after phase 3 + it doesn't). + 2. A rate-cap halt on one world no longer pauses the other. + 3. `subconscious.status` gains fields (additive only). +- **Branch/PR sequencing**: one branch per phase off `upstream/main` + (`feat/subconscious-profile-core`, `feat/subconscious-memory-profile`, …), + stacked; each independently green on ci-lite. Phase 2+3 could merge as one + PR if review prefers seeing the shim appear and disappear together. + +## 5.4 Explicit non-goals (this plan) + +- No change to the orchestration wake graph, compression ratio, context-guard + thresholds, or steering contract — those already implement the spec. +- No new subconscious kinds beyond the two (the factory makes them cheap + later; e.g. a `team` world or a `channels` world). +- No frontend redesign beyond phase 7's scoped additions (instance cards + + steering header) — no new pages, routes, or Redux slices. +- The opt-in event-driven trigger pipeline (`subconscious_triggers` + + `LongLivedSession`) keeps its current shape; folding it into a profile is a + possible follow-up once the factory exists. diff --git a/docs/plans/subconscious-factory/phase-6-tinyagents-reuse.md b/docs/plans/subconscious-factory/phase-6-tinyagents-reuse.md new file mode 100644 index 000000000..46eec0542 --- /dev/null +++ b/docs/plans/subconscious-factory/phase-6-tinyagents-reuse.md @@ -0,0 +1,83 @@ +# Phase 6 — TinyAgents graph reuse & upstream changes + +The subconscious runner (phase 1.2) is built on `tinyagents::graph` — the same +durable-workflow runtime the orchestration wake path uses. This phase tracks +(a) what we reuse as-is, and (b) the gaps that warrant changes **inside +`vendor/tinyagents`**, which is a git submodule of the separate repo +`tinyhumansai/tinyagents`: those changes go on their own branch there, raised +as their **own PR**, and the openhuman PR bumps the submodule pointer to the +merged commit (same workflow as `docs/plans/rlm-workflows/phase-2-tinyagents.md`). + +## 6.1 Reused as-is (no upstream change) + +| Need | tinyagents surface | +| --- | --- | +| tick topology + conditional quiet/changed routing | `GraphBuilder`, `Route`/command-routing, `START`/`END` | +| typed per-tick state | serde state + `LastValue` channels | +| crash-safe resume of an interrupted tick | `SqliteCheckpointer`, `CheckpointConfig`/`DurabilityMode` | +| injected behavior (profile = runtime) | the `dyn`-runtime node pattern from `orchestration/graph/build.rs` | +| stub-driven mechanics tests | `graph::testkit` | +| per-node timing / run snapshots | `graph::stream`, `graph::status` | +| bounded loops (none today, future kinds may loop) | `max_supersteps` backstop | + +## 6.2 Candidate upstream changes (each = one small PR to `tinyhumansai/tinyagents`) + +Confirm each gap against the vendored source before writing code; drop any +that turn out to already exist. Ordered by how much openhuman scaffolding +they delete: + +1. **Wall-clock graph deadline.** Today `TICK_TIMEOUT` wraps the whole run in + `tokio::time::timeout` from the outside, which cannot leave a clean + checkpoint. Add an optional per-run deadline to `GraphExecution` (abort + between supersteps + best-effort in-flight cancellation, surfaced as a + typed `GraphTimeout` error with the last checkpoint intact). Benefits the + orchestration wake graph too. +2. **External cancellation/supersede token.** The generation-counter + supersede (`tick_generation`) currently only takes effect at the *end* of + a tick (result discarded after the LLM spend). A shared cancel flag + checked between supersteps — the graph twin of the REPL's `ReplCancelFlag` + from the rlm-workflows plan — lets a newer tick abort an in-flight one + before its reflect call completes. If the REPL flag work landed, reuse its + primitive rather than adding a second one. +3. **Checkpoint GC / retention.** Ticks are frequent (every N minutes, + forever), so `graph_checkpoints.db` grows unboundedly. Add a retention + policy to `SqliteCheckpointer` (keep last K threads / prune completed + threads older than T). The wake graph has the same latent issue. +4. **(Stretch) periodic-trigger helper.** A `graph::orchestration`-level + "run this compiled graph every interval with jitter + overlap policy" + would absorb our heartbeat fan-out (phase 4.3). Only worth it if the + upstream maintainers want it — the openhuman heartbeat already works, so + this is explicitly optional and last. + +Items 1–3 are independent; none blocks phases 1–5. Phase 1 ships with the +outer `tokio::time::timeout` + end-of-tick supersede exactly as today, and +swaps to the upstream primitives when the submodule bump lands — each swap is +a small follow-up PR that deletes code. + +## 6.3 Sequencing with the vendor submodule + +1. Branch in `vendor/tinyagents` (e.g. `feat/graph-run-deadline`), implement + with unit tests in the tinyagents style (`types.rs`/`mod.rs`/`test.rs`), + PR against `tinyhumansai/tinyagents`. +2. Meanwhile openhuman phases 1–5 proceed against the current pin. +3. After the upstream merge: one openhuman PR per adopted primitive — bump + the submodule pointer, replace the scaffolding (external timeout → + deadline; end-of-tick supersede → cancel flag; add GC config), keep the + behavior tests green. +4. Never point the openhuman submodule at an unmerged tinyagents branch on + `main`-bound PRs. + +## 6.4 Confirmation findings (checked against the current vendored pin) + +Each candidate from §6.2 was checked against `vendor/tinyagents/src/graph/`: + +| # | Candidate | Status against the current pin | +| --- | --- | --- | +| 1 | Wall-clock graph deadline | **Gap confirmed.** Only a *per-node* timeout exists (`ExecutorConfig::node_timeout` → `TinyAgentsError::Timeout` in `compiled/executor.rs`) and `total_timeout`/`item_timeout` on `parallel`. There is no whole-run deadline on `run_with_thread`. Still a valid upstream PR. Openhuman keeps the outer `tokio::time::timeout(TICK_TIMEOUT, …)` for now. | +| 2 | External cancel/supersede token | **Gap confirmed (primitive exists, not wired).** `harness::cancel::CancellationToken` exists and is honoured by `parallel` (`with_cancellation`), but the main `CompiledGraph` superstep loop (`compiled/executor.rs`) does not check it between supersteps. Upstream work is "thread the existing token into the graph executor", not a new primitive. Openhuman keeps the end-of-tick + `commit`-node generation check for now. | +| 3 | Checkpoint GC / retention | **Already exists — adopted now.** `Checkpointer` already exposes `prune(thread_id, keep_last)` and `delete_thread(thread_id)` (default trait methods + sqlite/file impls, `checkpoint/mod.rs`). No upstream PR needed. `SubconsciousInstance::run_graph` now calls `delete_thread` on the tick's unique thread after the run returns, so `graph_checkpoints.db` stays bounded (test: `completed_ticks_leave_no_checkpoint_threads`). | + +Net: candidate #3 is done in-tree using an existing primitive; candidates #1 +and #2 remain genuine, independent follow-up PRs against +`tinyhumansai/tinyagents` and do **not** block this plan — phase 1 already ships +the outer timeout + end-of-tick supersede they would later replace. diff --git a/docs/plans/subconscious-factory/phase-7-ui.md b/docs/plans/subconscious-factory/phase-7-ui.md new file mode 100644 index 000000000..5113001c0 --- /dev/null +++ b/docs/plans/subconscious-factory/phase-7-ui.md @@ -0,0 +1,95 @@ +# Phase 7 — UI: see and interact with both subconscious kinds + +Goal: the two instances become visible, distinguishable, and individually +triggerable in the app — in the **Subconscious tab** (both kinds' health and +controls) and in the **TinyPlace Orchestration tab** (the tinyplace kind in +its natural habitat, next to the steering it produces). Depends on the +phase-4 RPC additions (`instances[]` on status, `kind` on trigger); pure +`app/src` work otherwise. Existing building blocks are reused — notably the +orchestration tab already renders the pinned Subconscious chat window +(`chat.kind === 'subconscious'` in `TinyPlaceOrchestrationTab.tsx`), which is +where directives stream in today via `useOrchestrationChats`. + +## 7.1 Types + clients (`app/src/utils/tauriCommands/subconscious.ts`) + +- Extend `SubconsciousStatus` with the additive phase-4 fields: + `instances: SubconsciousInstanceStatus[]`, where each row is today's status + shape plus `instance: 'memory' | 'tinyplace'`. Legacy top-level fields keep + mirroring the memory instance, so nothing breaks while the UI migrates. +- `subconsciousTrigger(kind?: 'memory' | 'tinyplace' | 'all')` — optional + param passed through to `openhuman.subconscious_trigger`; no-arg call keeps + today's behavior (memory). +- All calls stay on `callCoreRpc` / `core_rpc_relay` per the frontend rules. + +## 7.2 Hook (`app/src/hooks/useSubconscious.ts`) + +- Surface `instances` alongside the existing fields; add + `triggerTick(kind?)` and per-kind `triggering` state (two buttons must not + share one spinner). +- Keep polling/refresh cadence as-is; one status call still feeds everything. + +## 7.3 Subconscious tab (`IntelligenceSubconsciousTab.tsx`) + +Rework the status area from "one engine" to **two instance cards** under the +existing mode/interval controls (which continue to govern the memory +instance — mode semantics don't apply to tinyplace): + +- **Memory card** ("Your world" / connected sources): today's status row — + total ticks, last tick, consecutive failures, provider-unavailable banner + with the Settings deep-link — plus its own *Run now*. +- **TinyPlace card** ("Orchestration steering"): enabled state (mirrors + `orchestration.enabled`; show a disabled-with-hint state when off), last + review tick, failures/halt reason, *Run review now* + (`triggerTick('tinyplace')`), and a "View directives →" link that navigates + to the TinyPlace Orchestration tab with the Subconscious window selected. +- Shared `SubconsciousInstanceCard` component + (`app/src/components/intelligence/SubconsciousInstanceCard.tsx`) so a third + kind later is a data change, not new JSX. + +## 7.4 TinyPlace Orchestration tab (`TinyPlaceOrchestrationTab.tsx`) + +The pinned Subconscious chat window already shows emitted directives (built +in an earlier slice — reuse, don't rebuild). Add the *instance* dimension on +top of it: + +- A compact **steering status header** above that window's transcript: + current directive (or "no active directive"), `expires_after_cycles` + countdown against the live cycle counter, last review time, and a + *Run review now* button — same `triggerTick('tinyplace')` path as 7.3. + Data comes from the phase-4 status row plus a small read RPC if needed + (`orchestration.status` already exists as the stage-7 read surface; extend + it with `current_directive` rather than inventing a new method). +- Badge the window in the chat list ("Subconscious · steering") so the two + meanings of "subconscious" in the product read as one system: the tab shows + the tinyplace instance *output*, the Subconscious tab shows both instances' + *health*. +- Cross-link back: the header links to the Subconscious tab for controls. + +## 7.5 i18n + +New keys (instance card titles/descriptions, run-review, steering header, +badge) go into `en.ts` **and real translations in all locale files** +(`ar bn de es fr hi id it ko pl pt ru zh-CN`). Gate: `pnpm i18n:check` + +`pnpm i18n:english:check`. + +## 7.6 Tests + +- Vitest, co-located: + - `subconscious.ts` client: trigger passes `kind`; status parses + `instances` and tolerates its absence (older core during rollout). + - `useSubconscious`: per-kind triggering state; instances plumbed through. + - `IntelligenceSubconsciousTab`: renders two cards from a stubbed status; + tinyplace card disabled state when orchestration is off; per-card Run now + dispatches the right kind. + - `TinyPlaceOrchestrationTab`: steering header renders directive/empty + states; run-review calls trigger with `tinyplace`. +- E2E (desktop spec, mock backend): Subconscious tab shows both cards; + triggering the tinyplace review surfaces a directive message in the + orchestration Subconscious window (mock `__admin/behavior` scripted). + +## 7.7 Slicing + +Two commits minimum: (a) types/client/hook + Subconscious tab cards, +(b) orchestration tab steering header + cross-links. i18n rides with each. +This phase can start as soon as phase 4's RPC shape is merged; it does not +depend on phases 5–6. diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 56bfddae0..879645ecd 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2088,7 +2088,8 @@ async fn run_server_inner( if !config.heartbeat.enabled { log::info!("[subconscious] disabled by config (heartbeat.enabled = false)"); } else { - match crate::openhuman::subconscious::global::bootstrap_after_login().await + match crate::openhuman::subconscious::registry::bootstrap_after_login() + .await { Ok(()) => log::info!( "[subconscious] bootstrapped on startup (existing session)" diff --git a/src/core/subconscious_cli.rs b/src/core/subconscious_cli.rs index 4829f72cc..4da8ce237 100644 --- a/src/core/subconscious_cli.rs +++ b/src/core/subconscious_cli.rs @@ -133,7 +133,7 @@ fn run_tick(args: &[String]) -> Result<()> { // Check provider availability if let Some(reason) = - crate::openhuman::subconscious::engine::subconscious_provider_unavailable_reason( + crate::openhuman::subconscious::provider::subconscious_provider_unavailable_reason( &config, ) { @@ -143,7 +143,7 @@ fn run_tick(args: &[String]) -> Result<()> { // Create engine and run tick. The engine pulls its own memory_diff / // context state from the workspace — no memory client to pass in. - let engine = crate::openhuman::subconscious::SubconsciousEngine::new(&config); + let engine = crate::openhuman::subconscious::memory_instance(&config); eprintln!("[subconscious] running tick..."); let result = engine @@ -160,7 +160,11 @@ fn run_tick(args: &[String]) -> Result<()> { // Print the world baseline the next tick will diff against. let baseline = crate::openhuman::subconscious::store::with_connection( &config.workspace_dir, - crate::openhuman::subconscious::store::get_baseline_checkpoint_id, + |conn| { + crate::openhuman::subconscious::store::get_baseline_checkpoint_id( + conn, "memory", + ) + }, ) .unwrap_or(None); match baseline { @@ -189,18 +193,18 @@ fn run_status(args: &[String]) -> Result<()> { let mode = config.heartbeat.effective_subconscious_mode(); let provider_reason = if mode.is_enabled() { - crate::openhuman::subconscious::engine::subconscious_provider_unavailable_reason( + crate::openhuman::subconscious::provider::subconscious_provider_unavailable_reason( &config, ) } else { None }; - let last_tick = crate::openhuman::subconscious::store::with_connection( - &config.workspace_dir, - crate::openhuman::subconscious::store::get_last_tick_at, - ) - .ok(); + let last_tick = + crate::openhuman::subconscious::store::with_connection(&config.workspace_dir, |conn| { + crate::openhuman::subconscious::store::get_last_tick_at(conn, "memory") + }) + .ok(); let status = serde_json::json!({ "mode": mode.as_str(), diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index b784b5dae..fff46e414 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -1798,8 +1798,10 @@ pub(super) const CAPABILITIES: &[Capability] = &[ category: CapabilityCategory::Intelligence, description: "Coordinate wrapped Claude Code / Codex sessions over tiny.place: a \ split-brain wake graph (quick front end + reasoning core) replies to \ - session DMs, and an offline subconscious reflects on the world diff to \ - steer later cycles.", + session DMs, and a dedicated tiny.place subconscious instance reflects on \ + the compressed history + world diff on its own cadence to steer later \ + cycles — separate from the memory subconscious that watches your \ + connected sources.", how_to: "Intelligence > Orchestration (pair a wrapped session, then chat via the Master \ window).", status: CapabilityStatus::Beta, diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index d5412bc70..419c41c57 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -529,7 +529,7 @@ async fn finish_revalidated_user_activation( crate::openhuman::memory_conversations::register_conversation_persistence_subscriber( target_config.workspace_dir.clone(), ); - if let Err(error) = crate::openhuman::subconscious::global::bootstrap_after_login().await { + if let Err(error) = crate::openhuman::subconscious::registry::bootstrap_after_login().await { warn!("{LOG_PREFIX} subconscious bootstrap failed after pending session revalidation: {error}"); } if let Some(source_config) = service_rebind_source { @@ -672,7 +672,7 @@ async fn clear_deferred_session_after_backend_rejection( Err(_) => {} } crate::openhuman::credentials::stop_login_gated_services(config).await; - crate::openhuman::subconscious::global::reset_engine_for_user_switch().await; + crate::openhuman::subconscious::registry::reset_engine_for_user_switch().await; crate::openhuman::credentials::sentry_scope::clear(); clear_result diff --git a/src/openhuman/config/schema/load/env_overlay.rs b/src/openhuman/config/schema/load/env_overlay.rs index ca759edea..181a331c0 100644 --- a/src/openhuman/config/schema/load/env_overlay.rs +++ b/src/openhuman/config/schema/load/env_overlay.rs @@ -102,6 +102,20 @@ impl Config { } } + if let Some(raw) = env.get("OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + match trimmed.parse::() { + Ok(mins) => self.orchestration.review_interval_minutes = Some(mins), + Err(_) => tracing::warn!( + env = "OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES", + value = %raw, + "invalid tinyplace review interval ignored; expected an unsigned integer (minutes)" + ), + } + } + } + if let Some(language) = env.get("OPENHUMAN_OUTPUT_LANGUAGE") { let language = language.trim(); if !language.is_empty() { diff --git a/src/openhuman/config/schema/orchestration.rs b/src/openhuman/config/schema/orchestration.rs index 65096aa8d..43f6514d6 100644 --- a/src/openhuman/config/schema/orchestration.rs +++ b/src/openhuman/config/schema/orchestration.rs @@ -66,6 +66,14 @@ pub struct OrchestrationConfig { /// spawn per cycle. Default: `2`. #[serde(default = "default_subagent_concurrency")] pub subagent_concurrency: u32, + + /// Cadence (minutes) of the `tinyplace` subconscious steering review — the + /// offline reflection that emits `STEERING_DIRECTIVE`s over the compressed + /// execution history. `None` (the default) means "use the heartbeat + /// interval", so the merged behaviour matches the pre-factory build where + /// the review ran once per memory tick. Env: `OPENHUMAN_ORCH_REVIEW_INTERVAL_MINUTES`. + #[serde(default)] + pub review_interval_minutes: Option, } impl OrchestrationConfig { @@ -73,6 +81,15 @@ impl OrchestrationConfig { pub fn effective_evict_threshold(&self) -> f32 { self.context_evict_threshold.clamp(0.8, 0.9) } + + /// The steering-review cadence in minutes: the explicit + /// `review_interval_minutes` when set, else the supplied heartbeat interval + /// (floored at 1 so it can never be a busy-loop). + pub fn effective_review_interval_minutes(&self, heartbeat_interval: u32) -> u32 { + self.review_interval_minutes + .unwrap_or(heartbeat_interval) + .max(1) + } } impl Default for OrchestrationConfig { @@ -84,6 +101,7 @@ impl Default for OrchestrationConfig { message_window: default_message_window(), context_evict_threshold: default_evict_threshold(), subagent_concurrency: default_subagent_concurrency(), + review_interval_minutes: None, } } } diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index 4ef078804..6c25b58e8 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -456,7 +456,7 @@ async fn store_session_inner( // heartbeat loop. Idempotent — no-op on subsequent logins of the same // process. Bootstrap failures are non-fatal: the session itself is // already stored above, so we only warn. - if let Err(e) = crate::openhuman::subconscious::global::bootstrap_after_login().await { + if let Err(e) = crate::openhuman::subconscious::registry::bootstrap_after_login().await { tracing::warn!(error = %e, "[subconscious] post-login bootstrap failed"); logs.push(format!("subconscious bootstrap warning: {e}")); } else { @@ -597,7 +597,7 @@ pub async fn clear_session(config: &Config) -> Result bool { let lower = body.to_ascii_lowercase(); lower.contains("request too large") diff --git a/src/openhuman/orchestration/ops.rs b/src/openhuman/orchestration/ops.rs index 85e3fba18..ef9a04a61 100644 --- a/src/openhuman/orchestration/ops.rs +++ b/src/openhuman/orchestration/ops.rs @@ -16,6 +16,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::openhuman::config::Config; @@ -183,26 +184,69 @@ fn advance_cursor(config: &Config, agent_id: &str, session_id: &str, latest: i64 } // ── Stage 6: subconscious orchestration review ────────────────────────────── +// +// The review is driven by the dedicated **`tinyplace` subconscious instance** +// (`subconscious::profiles::tinyplace`), which ticks on its own cadence via the +// heartbeat fan-out — it no longer piggybacks on the memory tick. That profile +// calls [`load_review_window`] (observe) + [`synthesize_and_persist`] (reflect) +// and advances the review cursor from its own `commit`. The all-in-one +// [`run_orchestration_review`] wrapper below is retained for its unit tests. -/// The subconscious tick's `orchestration_review` stage (stage 6): reflect over -/// the orchestration layer's unreviewed compressed history + cumulative -/// world-diff timeline and, if a macro-trend warrants it, emit **one** steering -/// directive that later reasoning cycles inject into their prompt. +/// Reflect over the orchestration layer's unreviewed compressed history + +/// cumulative world-diff timeline and, if a macro-trend warrants it, emit **one** +/// steering directive that later reasoning cycles inject into their prompt. /// /// Fully offline: a single **tool-free** provider chat on the `subconscious` /// route (structurally isolated — no channel/effect tools reachable). Self-gating /// (no-op when orchestration is disabled or there is nothing new to review) and -/// idempotent (advances a review cursor only after a successful persist). Returns -/// `Ok(true)` when a directive was emitted. +/// idempotent (advances a review cursor after the persist). Returns `Ok(true)` +/// when a directive was emitted. The live tick path uses the split +/// `load_review_window` + `synthesize_and_persist` instead (see the stage note). pub async fn run_orchestration_review( config: &Config, source_tick_id: &str, ) -> Result { - if !config.orchestration.enabled { + let Some(window) = load_review_window(config).await? else { return Ok(false); + }; + let emitted = synthesize_and_persist(config, &window, source_tick_id).await?; + // The all-in-one wrapper advances the cursor itself (idle or emitted) to + // preserve the pre-split behaviour; the tinyplace profile instead advances + // it from its `commit`, so a superseded tick can't skip rows. + if let Some(newest) = &window.newest_reviewed { + let _ = store::with_connection(&config.workspace_dir, |conn| { + store::set_review_cursor(conn, newest) + }); + } + Ok(emitted.is_some()) +} + +/// The unreviewed slice of orchestration history a steering review reflects +/// over, plus the cursor token that pins exactly the window observed. Serde so +/// it can ride the subconscious tick graph's checkpointed state. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReviewWindow { + /// Compressed-history summaries new since the review cursor (oldest-first). + pub summaries: Vec, + /// The cumulative world-diff mutation timeline (context, not the trigger). + pub mutations: Vec, + /// Reasoning-cycle counter stamped on an emitted directive. + pub current_cycle: i64, + /// How many compressed rows this window folded (for `derived_from`). + pub compressed_count: usize, + /// Newest reviewed compressed-row `created_at` — the commit cursor. `None` + /// only on an empty window (which never produces a `Some(window)`). + pub newest_reviewed: Option, +} + +/// Stage 6 load half: the unreviewed compressed history + cumulative world-diff +/// timeline. Self-gating — returns `None` (a clean quiet tick) when orchestration +/// is disabled or there is nothing new since the review cursor. +pub async fn load_review_window(config: &Config) -> Result, String> { + if !config.orchestration.enabled { + return Ok(None); } - // 1. Load unreviewed compressed history + the cumulative world-diff timeline. let (compressed, mutations, current_cycle) = store::with_connection(&config.workspace_dir, |conn| { let cursor = store::review_cursor(conn)?; @@ -215,54 +259,57 @@ pub async fn run_orchestration_review( // Idempotence trigger: a review fires only on **new** compressed history // since the cursor. Compressed rows are written every cycle alongside the - // world diff, so this makes a re-tick with no new data a clean no-op while - // still handing the model the full cumulative world timeline for context. + // world diff, so a re-tick with no new data is a clean no-op while still + // handing the model the full cumulative world timeline for context. if compressed.is_empty() { log::debug!(target: LOG, "[orchestration] review.idle — no new compressed history"); - return Ok(false); + return Ok(None); } + let newest_reviewed = compressed.iter().map(|(c, _)| c.clone()).max(); - let summaries: Vec = compressed.iter().map(|(_, t)| t.clone()).collect(); + Ok(Some(ReviewWindow { + summaries: compressed.iter().map(|(_, t)| t.clone()).collect(), + compressed_count: compressed.len(), + mutations, + current_cycle, + newest_reviewed, + })) +} - // 2. Synthesize offline (tool-free chat, tainted origin). Retry once on a - // contract violation; a clean NONE is a valid idle response. - let prompt = build_steering_prompt(&summaries, &mutations); - let parsed = synthesize_steering(config, &prompt, source_tick_id).await; - - let Some(parsed) = parsed else { - // No directive this window (idle, NONE, or twice-failed). Advance the - // cursor so we don't reflect on the same rows forever — a transient model - // failure simply yields no directive for this batch. - if let Some(newest) = newest_reviewed { - let _ = store::with_connection(&config.workspace_dir, |conn| { - store::set_review_cursor(conn, &newest) - }); - } - return Ok(false); +/// Stage 6 synthesize half: reflect over `window` offline (tool-free chat, +/// tainted origin) and, when a macro-trend warrants it, persist **one** steering +/// directive (superseding the prior) + surface it in the local Subconscious +/// window. Returns the new directive id, or `None` on a clean NONE / twice-failed. +/// +/// Deliberately does **not** advance the review cursor — the caller owns that so +/// a superseded tick cannot skip rows (the tinyplace profile advances it from +/// `commit`). +pub async fn synthesize_and_persist( + config: &Config, + window: &ReviewWindow, + source_tick_id: &str, +) -> Result, String> { + let prompt = build_steering_prompt(&window.summaries, &window.mutations); + let Some(parsed) = synthesize_steering(config, &prompt, source_tick_id).await else { + return Ok(None); }; - // 3. Persist the directive (superseding the prior), advance the cursor, and - // surface it in the local Subconscious chat window. let now = chrono::Utc::now().to_rfc3339(); let derived_from = format!( "compressed_rows:{} world_mutations:{}", - compressed.len(), - mutations.len() + window.compressed_count, + window.mutations.len() ); let directive_id = store::with_connection(&config.workspace_dir, |conn| { - let id = store::insert_steering_directive( + store::insert_steering_directive( conn, &parsed.text, &now, source_tick_id, parsed.expires_after_cycles, - current_cycle, + window.current_cycle, &derived_from, - )?; - if let Some(newest) = &newest_reviewed { - store::set_review_cursor(conn, newest)?; - } - Ok(id) + ) }) .map_err(|e| format!("review persist: {e}"))?; @@ -272,7 +319,7 @@ pub async fn run_orchestration_review( "[orchestration] review.directive_emitted id={directive_id} expires_after={} derived={derived_from}", parsed.expires_after_cycles, ); - Ok(true) + Ok(Some(directive_id)) } /// Run the offline steering synthesis: a single tool-free chat on the diff --git a/src/openhuman/orchestration/schemas.rs b/src/openhuman/orchestration/schemas.rs index a85267083..9321ca2cc 100644 --- a/src/openhuman/orchestration/schemas.rs +++ b/src/openhuman/orchestration/schemas.rs @@ -485,12 +485,12 @@ fn handle_status(_params: Map) -> ControllerFuture { .map_err(|e| format!("status: {e}"))?; // Last subconscious tick (best-effort — subconscious store is separate). - let last_tick_at = crate::openhuman::subconscious::store::with_connection( - &config.workspace_dir, - crate::openhuman::subconscious::store::get_last_tick_at, - ) - .ok() - .filter(|v| *v > 0.0); + let last_tick_at = + crate::openhuman::subconscious::store::with_connection(&config.workspace_dir, |conn| { + crate::openhuman::subconscious::store::get_last_tick_at(conn, "memory") + }) + .ok() + .filter(|v| *v > 0.0); to_json(OrchestrationStatus { steering, diff --git a/src/openhuman/subconscious/README.md b/src/openhuman/subconscious/README.md index d4eae10b5..8309fc4ec 100644 --- a/src/openhuman/subconscious/README.md +++ b/src/openhuman/subconscious/README.md @@ -1,82 +1,114 @@ # subconscious -The subconscious is OpenHuman's background-awareness layer: a periodic loop that, on each tick, runs a small **structured three-stage flow** and lets a slim agent decide what (if anything) to act on. The actual periodic schedule is owned by the `heartbeat` domain; this module owns the tick flow, the world baseline, and the proactive output surface. +The subconscious is OpenHuman's **Deep Reflection Layer**: an offline, cron-driven +loop that consumes a compressed view of how a *world* changed and emits short, +dense outputs that steer the rest of the system. It is a **factory** — one generic +reflection runner instantiated once per world: -## The tick (`engine.rs`) +- **`memory`** — the user's connected memory sources (Gmail / Slack / Notion / + folders). Observes a `memory_diff` against a baseline checkpoint; reflects with + the slim decision agent (to-dos, goals, `notify_user`, delegation). +- **`tinyplace`** — the tiny.place orchestration world. Observes the 20:1-compressed + execution history + cumulative world-state diff; reflects with a **tool-free** + steering synthesis that emits `STEERING_DIRECTIVE`s for the reasoning core. -1. **memory_diff (code)** — diff the user's connected memory sources against the **world baseline** captured at the end of the previous tick (`memory_diff::ops::diff_since_checkpoint`). Renders a compact "what changed" summary. A quiet window (no changes), the first-ever tick, or a diff error short-circuits the tick: it refreshes the baseline, advances `last_tick_at`, and returns **without** running the agent — so idle ticks cost nothing. -2. **prepare_context (code)** — run the read-only `context_scout` over the diff (`agent_prepare_context::run_context_scout_with_catalog`, driven from engine code with the subconscious tool catalogue) to gather grounding from memory, goals/profile, integrations, and the web. -3. **decide (agent)** — hand `diff + prepared context` to the slim `subconscious` agent. It records/advances actionable follow-ups on the user's **global to-do board** (`update_task`, `threadId: "user-tasks"`), evolves **long-term goals** (`goals_*`), surfaces time-sensitive items (`notify_user`), or delegates deeper work (`spawn_async_subagent`). +Adding a world is a new profile file + one factory arm — not another engine. -Continuity across ticks lives in those durable stores (global to-dos + goals), not a bespoke scratchpad. The world baseline is the only per-tick engine state, persisted as a `memory_diff` checkpoint id. +## The generic tick (`instance.rs`) -## Responsibilities +Each tick body is a [`tinyagents` `CompiledGraph`](../tinyagents) — the same durable +runtime the orchestration wake path runs on: -- Route the decision turn to a local Ollama/LM Studio model or the OpenHuman cloud (`workload_local_model("subconscious")` / `subconscious_provider`). `run_agent` sets `default_model = "hint:subconscious"` so the session builder resolves the `subconscious` workload role (not `chat`); on the managed backend that pins the lightweight `chat-v1` tier. -- Run the decision agent with **Full** autonomy (it must write internal continuity — to-dos/goals/notify), while escalating taint via the turn origin: any tick that reacted to source changes runs as `SubconsciousTainted` so the approval gate refuses external-effect tools. -- Persist `last_tick_at` (status/dedupe) and `baseline_checkpoint_id` (the world snapshot the next tick diffs against) across restarts. -- Provide an overlap guard (generation counter) so a newer tick supersedes an in-flight one and discards its results without advancing state. -- Expose `status` / `trigger` over JSON-RPC. +```text +START ─► observe ─┬─(quiet)──────────────────────────► commit ─► done ─► END + └─(changed)─► prepare ─► reflect ─┬─(ok)──► commit ─► done ─► END + └─(err)────────────► done ─► END +``` + +The node handlers delegate 1:1 to the world's [`SubconsciousProfile`](profile.rs) — +the profile **is** the injected runtime. Everything world-agnostic lives in the +runner **once**, so every world gets it for free: + +- per-instance tick lock + 5s acquisition skip, +- generation/supersede counter (checked in the `commit` node **and** post-run, so a + superseded tick never advances state or commits), +- `TICK_TIMEOUT` (30 min) wall clock, +- provider gate + per-instance rate-cap halt (signature `"|"`, so + one world's 413/TPM halt never silences another — TAURI-RUST-HXF), +- tool-capability classifier (TAURI-RUST-ADC), +- advance-baseline-only-on-success, quiet-tick short-circuit, +- `SqliteCheckpointer` resume of an interrupted tick. + +## Profiles (`profiles/`) + +A profile implements `observe → prepare_context → reflect → commit` + `origin`: + +| Method | `memory` | `tinyplace` | +| --- | --- | --- | +| `observe` | diff connected sources vs the baseline checkpoint | load the unreviewed compressed history + world-diff window | +| `prepare_context` | read-only `context_scout` over the diff | none (steering is deliberately tool-free) | +| `reflect` | slim decision agent (`hint:subconscious`, Full autonomy) → `Acted` | tool-free provider chat → `Steered`/`Idle` | +| `commit` | re-checkpoint the world baseline | advance the review cursor to the observed window | +| `origin` | tainted iff the diff carried external content | always `SubconsciousTainted` | + +## Persistence + +SQLite at `/subconscious/subconscious.db` (per-user workspace). State +keys are **namespaced per instance** (`":"`): + +- `subconscious_state` — REAL KV: `memory:last_tick_at`, `tinyplace:last_tick_at`. +- `subconscious_state_text` — TEXT KV: `memory:baseline_checkpoint_id`. +- The tinyplace **review cursor** lives in the orchestration store, not here — the + profile owns it via `commit`. +- Legacy single-engine keys (`last_tick_at`, `baseline_checkpoint_id`) migrate to + the `memory:`-namespace via an idempotent, old-version-tolerant UPDATE in the DDL + batch. +- Tick graph checkpoints: `/subconscious/graph_checkpoints.db`, thread + `subconscious::`. + +Legacy task/log/escalation/reflection tables are retained for back-compat with +existing DBs but are no longer written or read. ## Key files | File | Role | | --- | --- | -| `mod.rs` | Export-focused; re-exports the engine, session, source_chunk, schemas, and core types. | -| `types.rs` | `SubconsciousStatus`, `TickResult`. | -| `engine.rs` | `SubconsciousEngine` — the three-stage tick (`tick`/`tick_inner`), `prepare_context`, `refresh_baseline`, `run_agent`, world-diff rendering, provider routing (`resolve_subconscious_route`, `subconscious_provider_unavailable_reason`), `tick_origin_source`, tool-capability-error detection. | -| `store.rs` | SQLite persistence + DDL; `with_connection` with busy-timeout + retry (TAURI-RUST-A). `get/set_last_tick_at`, `get/set_baseline_checkpoint_id`. (Legacy task/log/escalation/reflection tables are retained for back-compat but no longer written.) | -| `source_chunk.rs` | `SourceChunk` + `resolve_chunks` / `parse_ref` — used by the agent prompt builder to hydrate reflection `source_refs` into frozen previews (`PREVIEW_MAX_CHARS = 400`). Shared with `agent::prompts`, not subconscious-only. | -| `session.rs` | `LongLivedSession` — persistent agent for the opt-in event-driven trigger path (`subconscious_triggers`); builds the `subconscious` agent and resumes history from the reserved orchestrator thread. | -| `user_thread.rs` | `NotifyUserTool` / `notify_user` — proactive user handoff; publishes `DomainEvent::ProactiveMessageRequested` and the reserved `subconscious:user` thread. | -| `agent/` | The slim `subconscious` agent definition: `agent.toml` (toolset) + `prompt.md`. | -| `global.rs` | Engine singleton: `get_or_init_engine`, `bootstrap_after_login`, `stop_heartbeat_loop`. Spawns the `heartbeat` loop and the opt-in trigger orchestrator. | -| `heartbeat/` | Periodic scheduler + event planner (meeting/reminder/notification delivery) that drives `engine.tick()`. | -| `schemas.rs` | RPC controller schemas + handlers (`subconscious.status` / `subconscious.trigger`). | -| `decision_log.rs`, `executor.rs` | Legacy stubs retained for back-compat; not on the live tick path. | - -## Public surface - -From `mod.rs`: `SubconsciousEngine`, `LongLivedSession`/`ProcessOutcome`/`ORCHESTRATOR_THREAD_ID`, `SourceChunk`, `SubconsciousStatus`/`TickResult`, `notify_user`/`NotifyUserTool`/`USER_THREAD_ID`, `all_subconscious_controller_schemas` / `all_subconscious_registered_controllers`, and the `global::*` lifecycle functions. +| `mod.rs` | Export-focused; re-exports the factory, instance, profile types, session, source_chunk, schemas. | +| `profile.rs` | `SubconsciousProfile` trait + serde `Observation`/`Reflection`. | +| `instance.rs` | `SubconsciousInstance` — the generic tinyagents-graph runner + scheduler/circuit-breaker shell + `status()`/`is_due()`. | +| `profiles/memory.rs` | `MemoryProfile` + `memory_instance()` — the memory world (world-diff render, decision agent, baseline). | +| `profiles/tinyplace.rs` | `TinyPlaceProfile` + `tinyplace_instance()` — orchestration steering (wraps `orchestration::ops::load_review_window` / `synthesize_and_persist`). | +| `provider.rs` | Shared subconscious provider routing, rate-cap halt signature, and permanent-error classifiers (world-agnostic). | +| `factory.rs` | `SubconsciousKind` + `make_subconscious` + `enabled_kinds` — the bootstrap set. | +| `registry.rs` | Keyed `HashMap>`; `get_or_init_instance`, `registered_instances`, `bootstrap_after_login`, user-switch reset. Spawns the heartbeat loop + opt-in trigger orchestrator. | +| `heartbeat/` | Periodic scheduler + event planner; each interval fans out, ticking every registered instance whose cadence has elapsed, concurrently. | +| `store.rs` | SQLite persistence + DDL; instance-namespaced `get/set_last_tick_at`, `get/set_baseline_checkpoint_id` + legacy-key migration. | +| `schemas.rs` | RPC controllers (`subconscious.status` / `subconscious.trigger`). | +| `session.rs`, `source_chunk.rs`, `user_thread.rs`, `agent/` | Unchanged roles (opt-in trigger session, chunk hydration, `notify_user`, the slim agent def). | ## RPC / controllers -Namespace `subconscious` (i.e. `openhuman.subconscious_`): +Namespace `subconscious` (`openhuman.subconscious_`): | Function | Purpose | | --- | --- | -| `status` | Engine status (read entirely from DB to avoid blocking on the tick mutex). | -| `trigger` | Manually fire a tick (spawned in the background; returns immediately). | - -## Agent tools - -This module owns `user_thread.rs` (`notify_user`). The tick's other tools come from elsewhere: `memory_diff` (`memory_diff` domain), `agent_prepare_context` / `spawn_async_subagent` (`agent_orchestration`), `update_task` (`todos`/agent tools), `goals_*` (`memory_goals`). - -## Persistence - -SQLite at `/subconscious/subconscious.db` (per-user workspace): -- `subconscious_state` — REAL KV holding `last_tick_at` (restart-durable dedupe cutoff). -- `subconscious_state_text` — TEXT KV holding `baseline_checkpoint_id` (the `memory_diff` checkpoint the next tick diffs against). -- Legacy tables (`subconscious_tasks` / `_log` / `_escalations` / `_reflections` / `_hotness_snapshots`) are retained for back-compat with existing DBs and are no longer written or read. - -The world snapshots/checkpoints themselves live in the `memory_diff` domain's own DB (`/memory_diff/diff.db`), not here. - -`with_connection` runs all DDL on every open, with a 5s busy timeout and 3-retry exponential backoff for transient `SQLITE_BUSY`/`SQLITE_LOCKED`. - -## Dependencies - -- `openhuman::config` — `Config`/`HeartbeatConfig`, provider routing, `workspace_dir`. -- `openhuman::memory_diff` — `ops::diff_since_checkpoint` / `ops::create_checkpoint` + diff types (stage 1 + baseline). -- `openhuman::agent_orchestration` — `run_context_scout_with_catalog` (stage 2). -- `openhuman::agent` — `Agent`, turn-origin taint plumbing (stage 3). -- `openhuman::heartbeat` — `HeartbeatEngine`; `global.rs` spawns the periodic loop that calls `tick`. -- `openhuman::credentials` — `AuthService`/`APP_SESSION_PROVIDER` for cloud-session provider availability. -- `openhuman::scheduler_gate` — `is_signed_out()` gate for the cloud provider. +| `status` | Legacy top-level fields mirror the **memory** instance; `instances[]` lists every registered world (each tagged with `instance`). Read entirely from SQLite / the small state mutex — never the tick lock. | +| `trigger` | Fire a tick for a world: optional `kind` (`"memory"` default, `"tinyplace"`, `"all"`). Spawned; returns immediately. | ## Notes / gotchas -- **Engine bootstraps post-login** (`global::bootstrap_after_login`) so state writes to the per-user workspace, not the pre-login global default. -- **`status` RPC never touches the engine mutex** — it reads straight from SQLite, since the engine lock is held for the full tick. -- **State only advances on success.** A failed decision turn leaves `last_tick_at` and the baseline in place, so the next tick re-diffs the same window instead of losing it. A superseded tick discards its result. -- **Quiet ticks short-circuit before the agent** — if the diff has no changes, no decision turn runs (and no cost is incurred); the baseline is still refreshed. -- **Taint:** any tick that reacted to source changes runs `SubconsciousTainted`; the decision agent's slim toolset is internal-only, and external effects (incl. inside delegated work) stay gated by the approval gate. +- **Instances bootstrap post-login** (`registry::bootstrap_after_login`) against the + per-user workspace. The heartbeat loop is the clock that drives every world's tick. +- **`status` never touches the tick mutex** — it reads SQLite + the small state + mutex, since the tick lock is held for a full tick. +- **State only advances on success.** A failed reflect leaves `last_tick_at` and the + baseline/cursor in place (re-observe the same window next tick). A superseded tick + discards its result and skips commit. +- **Quiet ticks short-circuit before reflect** — no LLM call when `observe` is empty; + the baseline is still refreshed via `commit`. +- **Taint:** the memory decision agent's toolset is internal-only, the tinyplace + reflect path is a tool-free provider chat (source-scan test enforces no + Agent/channel/send-message symbols); external effects stay gated by the approval + gate. A tick that reacted to external content runs `SubconsciousTainted`. +- **One world's rate-cap halt never silences another** — the halt signature is + prefixed with the instance id. diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs deleted file mode 100644 index 0abdad5ad..000000000 --- a/src/openhuman/subconscious/engine.rs +++ /dev/null @@ -1,865 +0,0 @@ -//! Subconscious engine — periodic, structured background loop. -//! -//! Each tick is a small, deterministic, three-stage flow: -//! -//! 1. **memory_diff (code)** — diff the agent's connected sources against the -//! world baseline captured at the end of the previous tick, to see how the -//! user's world changed (`memory_diff::ops::diff_since_checkpoint`). -//! 2. **prepare_context (code)** — run the read-only `context_scout` -//! (`agent_prepare_context`) over that diff to gather grounding context -//! from memory, goals/profile, integrations, and the web. -//! 3. **decide (agent)** — hand `diff + context` to the slim subconscious -//! agent, which decides what (if anything) to do: record follow-ups on the -//! user's global to-do board (`update_task`), evolve long-term goals -//! (`goals_*`), notify the user (`notify_user`), or delegate deeper work -//! (`spawn_async_subagent`). -//! -//! Continuity across ticks lives in those durable stores (global to-dos + -//! goals), not in a bespoke scratchpad — so quiet ticks (no diff) cost nothing -//! and the loop stays stateless beyond the world baseline. -//! -//! ## Concurrency & timeouts -//! -//! A per-engine `tick_lock` prevents overlapping ticks. Each tick has a hard -//! wall-clock timeout (`TICK_TIMEOUT`) so a stuck LLM call cannot block the loop -//! forever. Individual tool calls within the agent turn are bounded by the agent -//! harness's own iteration cap. - -use super::store; -use super::types::{SubconsciousStatus, TickResult}; -use crate::openhuman::agent_orchestration::parent_context::with_root_parent; -use crate::openhuman::config::schema::SubconsciousMode; -use crate::openhuman::config::Config; -use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; -use crate::openhuman::memory_diff::types::CrossSourceDiff; -use anyhow::Result; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::sync::Mutex; -use tracing::{debug, info, warn}; - -/// Hard timeout for a single subconscious tick (agent run). -const TICK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60); - -/// Per-tool-call timeout injected into the agent config. -const TOOL_CALL_TIMEOUT_SECS: u64 = 5 * 60; - -/// Label stamped on the world-baseline checkpoint the tick re-creates each run. -const BASELINE_CHECKPOINT_LABEL: &str = "subconscious_tick"; - -/// Max changed items listed per source in the rendered world diff, to keep the -/// decision agent's prompt bounded when a source churns a lot. -const MAX_ITEMS_PER_SOURCE: usize = 10; - -/// Tool catalogue handed to the `context_scout` so its `recommended_tool_calls` -/// stay grounded in tools the decision agent can actually call. Keep in sync -/// with `agent/agent.toml`'s `[tools].named` (actionable subset). -const SUBCONSCIOUS_TOOL_CATALOG: &str = "\ -- notify_user: Send the user a proactive message about something important or time-sensitive. -- update_task: Add or update an actionable item on the user's global to-do board. -- goals_add: Record a new long-term goal that the changed world makes relevant. -- goals_edit: Revise an existing long-term goal. -- spawn_async_subagent: Delegate deeper research or multi-step work. -"; - -/// Actionable reason surfaced (via `SubconsciousStatus.provider_unavailable_reason`) -/// when a subconscious tick fails because the configured chat model has no -/// tool-use endpoint. The subconscious turn is inherently tool-bearing (it acts -/// through tools), so a tool-incapable model can never satisfy a tick — this -/// tells the user how to recover. See TAURI-RUST-ADC. -const TOOL_UNSUPPORTED_REASON: &str = "The selected chat model has no tool-use endpoint, so Subconscious can't run. Pick a tool-capable model in Settings > AI."; - -/// Surfaced in [`SubconsciousStatus`] when the circuit breaker has halted ticks -/// because the configured Subconscious model keeps rejecting requests with a -/// permanent per-minute token cap (413/TPM). Actionable: the fix is the user's -/// to make (a bigger model/tier), so the message points there. -const RATE_CAP_HALT_REASON: &str = "Subconscious is paused: the selected model rejected the request because it exceeds your provider's per-minute token limit. Pick a higher-tier model or provider for Subconscious in Settings > AI > Advanced."; - -/// Pick the `TrustedAutomationSource` variant for a subconscious tick. -/// -/// Extracted from the engine's `run_agent` body so the origin-escalation -/// contract can be unit-tested without spinning up a real `Agent` + provider. -/// -/// Contract: any tick that reacted to third-party sync changes (the memory_diff -/// surfaced added/modified/removed items, all of which originate from external -/// sources like Gmail / Slack / Notion / synced folders) must run with -/// `SubconsciousTainted` so the approval gate refuses external_effect tools. -/// A tick with no external changes keeps the legacy `Subconscious` origin. -pub(crate) fn tick_origin_source( - has_external_content: bool, -) -> crate::openhuman::agent::turn_origin::TrustedAutomationSource { - if has_external_content { - crate::openhuman::agent::turn_origin::TrustedAutomationSource::SubconsciousTainted - } else { - crate::openhuman::agent::turn_origin::TrustedAutomationSource::Subconscious - } -} - -pub struct SubconsciousEngine { - workspace_dir: PathBuf, - mode: SubconsciousMode, - interval_minutes: u32, - context_budget_tokens: u32, - enabled: bool, - state: Mutex, - tick_generation: AtomicU64, - tick_lock: Mutex<()>, -} - -struct EngineState { - last_tick_at: f64, - total_ticks: u64, - consecutive_failures: u64, - provider_unavailable_reason: Option, - /// Signature of the subconscious provider routing (see - /// [`subconscious_provider_signature`]) that is permanently rejecting ticks - /// with a per-minute token-cap `413`/TPM. While set and still matching the - /// live config, ticks skip the agent run entirely instead of re-firing the - /// doomed request every interval (TAURI-RUST-HXF — a permanent provider - /// rejection re-reported per tick is the cron-billing-flood family, #3913). - /// Cleared automatically when the config's signature changes (the user - /// switched the Subconscious model/provider/tier). In-memory only: a restart - /// re-probes once, then re-halts on the first rejection — one event per - /// launch, not a flood. - rate_cap_halt_signature: Option, -} - -impl EngineState { - /// Pre-tick gate: consult the rate-cap halt against the live provider - /// signature. Returns `true` when the tick must skip the agent run because a - /// halt is active for the still-current config. A halt whose signature no - /// longer matches (the user switched Subconscious model/provider/tier) is - /// cleared here and the tick proceeds. Counts a skipped tick so status stays - /// accurate. TAURI-RUST-HXF. - fn should_skip_for_rate_cap_halt(&mut self, signature: &str) -> bool { - match evaluate_rate_cap_halt(self.rate_cap_halt_signature.as_deref(), signature) { - RateCapHaltDecision::Skip => { - info!( - "[subconscious] halted — the Subconscious provider keeps hitting a permanent \ - per-minute token cap (413/TPM); skipping tick until the model/tier changes \ - (TAURI-RUST-HXF)" - ); - self.total_ticks += 1; - true - } - RateCapHaltDecision::Resume => { - info!( - "[subconscious] Subconscious provider config changed — clearing rate-cap halt \ - and resuming ticks" - ); - self.rate_cap_halt_signature = None; - if self.provider_unavailable_reason.as_deref() == Some(RATE_CAP_HALT_REASON) { - self.provider_unavailable_reason = None; - } - false - } - RateCapHaltDecision::Proceed => false, - } - } - - /// Arm the rate-cap halt after a tick failed with a permanent per-minute - /// token-cap rejection, so subsequent ticks skip until the provider - /// signature changes. Surfaces an actionable reason in - /// [`SubconsciousStatus`]. TAURI-RUST-HXF. - fn arm_rate_cap_halt(&mut self, signature: &str) { - info!( - "[subconscious] provider rejected the tick with a permanent per-minute token cap \ - (413/TPM) — halting until the Subconscious model/tier changes (TAURI-RUST-HXF)" - ); - self.rate_cap_halt_signature = Some(signature.to_string()); - self.provider_unavailable_reason = Some(RATE_CAP_HALT_REASON.to_string()); - } -} - -impl SubconsciousEngine { - pub fn new(config: &crate::openhuman::config::Config) -> Self { - Self::from_heartbeat_config(&config.heartbeat, config.workspace_dir.clone()) - } - - pub fn from_heartbeat_config( - heartbeat: &crate::openhuman::config::HeartbeatConfig, - workspace_dir: PathBuf, - ) -> Self { - let last_tick_at = match store::with_connection(&workspace_dir, store::get_last_tick_at) { - Ok(v) => { - if v > 0.0 { - info!("[subconscious] resumed last_tick_at={v} from disk"); - } - v - } - Err(e) => { - warn!("[subconscious] last_tick_at load failed, falling back to 0.0: {e}"); - 0.0 - } - }; - - let mode = heartbeat.effective_subconscious_mode(); - - Self { - workspace_dir, - mode, - interval_minutes: mode.default_interval_minutes().max(5), - context_budget_tokens: heartbeat.context_budget_tokens, - enabled: mode.is_enabled(), - state: Mutex::new(EngineState { - last_tick_at, - total_ticks: 0, - consecutive_failures: 0, - provider_unavailable_reason: None, - rate_cap_halt_signature: None, - }), - tick_generation: AtomicU64::new(0), - tick_lock: Mutex::new(()), - } - } - - pub async fn run(&self) -> Result<()> { - if !self.enabled { - info!("[subconscious] disabled, exiting"); - return Ok(()); - } - - let interval_secs = u64::from(self.interval_minutes) * 60; - info!( - "[subconscious] started: every {} minutes, budget {} tokens", - self.interval_minutes, self.context_budget_tokens - ); - - loop { - tokio::time::sleep(std::time::Duration::from_secs(interval_secs)).await; - match self.tick().await { - Ok(result) => { - info!( - "[subconscious] tick: duration={}ms response_chars={}", - result.duration_ms, result.response_chars - ); - } - Err(e) => { - warn!("[subconscious] tick error: {e}"); - } - } - } - } - - pub async fn tick(&self) -> Result { - let _tick_guard = - match tokio::time::timeout(std::time::Duration::from_secs(5), self.tick_lock.lock()) - .await - { - Ok(guard) => guard, - Err(_) => { - warn!("[subconscious] tick skipped — another tick is still running"); - return Ok(TickResult { - tick_at: now_secs(), - duration_ms: 0, - response_chars: 0, - }); - } - }; - - match tokio::time::timeout(TICK_TIMEOUT, self.tick_inner()).await { - Ok(result) => result, - Err(_) => { - warn!( - "[subconscious] tick timed out after {}s", - TICK_TIMEOUT.as_secs() - ); - let mut state = self.state.lock().await; - state.consecutive_failures += 1; - state.total_ticks += 1; - Ok(TickResult { - tick_at: now_secs(), - duration_ms: TICK_TIMEOUT.as_millis() as u64, - response_chars: 0, - }) - } - } - } - - async fn tick_inner(&self) -> Result { - let started = std::time::Instant::now(); - let tick_at = now_secs(); - - let my_generation = self.tick_generation.fetch_add(1, Ordering::SeqCst) + 1; - - let config = match Config::load_or_init().await { - Ok(c) => c, - Err(e) => { - warn!("[subconscious] config load failed: {e}"); - let mut state = self.state.lock().await; - state.provider_unavailable_reason = Some(format!("Config unavailable: {e}")); - state.consecutive_failures += 1; - state.total_ticks += 1; - return Ok(TickResult { - tick_at, - duration_ms: started.elapsed().as_millis() as u64, - response_chars: 0, - }); - } - }; - - let provider_signature = subconscious_provider_signature(&config); - if self - .state - .lock() - .await - .should_skip_for_rate_cap_halt(&provider_signature) - { - return Ok(TickResult { - tick_at, - duration_ms: started.elapsed().as_millis() as u64, - response_chars: 0, - }); - } - - if let Some(reason) = subconscious_provider_unavailable_reason(&config) { - info!("[subconscious] provider unavailable, skipping tick: {reason}"); - let mut state = self.state.lock().await; - state.provider_unavailable_reason = Some(reason); - state.consecutive_failures += 1; - state.total_ticks += 1; - return Ok(TickResult { - tick_at, - duration_ms: started.elapsed().as_millis() as u64, - response_chars: 0, - }); - } - - { - let mut state = self.state.lock().await; - state.provider_unavailable_reason = None; - } - - // ── Stage: orchestration_review — steer the reasoning core (stage 6) ── - // Offline reflection over the orchestration layer's compressed history + - // cumulative world diff, emitting a steering directive for later reasoning - // cycles. Runs before the memory_diff stage and independently of it (it is - // driven by orchestration activity, not source syncs). Self-gating: a - // clean no-op when orchestration is disabled or there is nothing new to - // review. Its only output is the directive (+ the local Subconscious - // window) — no channels, no external effects. - let review_tick_id = format!("subconscious:orchestration_review:{tick_at}"); - match crate::openhuman::orchestration::ops::run_orchestration_review( - &config, - &review_tick_id, - ) - .await - { - Ok(true) => info!("[subconscious] orchestration_review emitted a steering directive"), - Ok(false) => {} - Err(e) => warn!("[subconscious] orchestration_review failed: {e}"), - } - - // ── Stage 1: memory_diff — how did the agent's world change? ────────── - let baseline = - store::with_connection(&self.workspace_dir, store::get_baseline_checkpoint_id) - .unwrap_or_else(|e| { - warn!("[subconscious] baseline load failed: {e}"); - None - }); - - let diff: Option = match &baseline { - Some(checkpoint_id) => match crate::openhuman::memory_diff::ops::diff_since_checkpoint( - checkpoint_id, - &config, - false, - ) - .await - { - Ok(d) => Some(d), - Err(e) => { - warn!("[subconscious] memory_diff failed (baseline={checkpoint_id}): {e}"); - None - } - }, - None => { - debug!("[subconscious] no world baseline yet — first tick establishes one"); - None - } - }; - - let has_changes = diff - .as_ref() - .map(|d| world_diff_change_count(d) > 0) - .unwrap_or(false); - - if !has_changes { - // Quiet window, first tick, or a diff error: nothing to react to. - // Refresh the baseline and return without spending a decision turn. - info!("[subconscious] no world changes this tick — refreshing baseline, no agent run"); - self.refresh_baseline(&config).await; - let mut state = self.state.lock().await; - state.total_ticks += 1; - if self.tick_generation.load(Ordering::SeqCst) == my_generation { - state.consecutive_failures = 0; - state.last_tick_at = tick_at; - persist_last_tick_at(&self.workspace_dir, tick_at); - } - return Ok(TickResult { - tick_at, - duration_ms: started.elapsed().as_millis() as u64, - response_chars: 0, - }); - } - - let diff = diff.expect("has_changes implies diff is Some"); - let world_diff = render_world_diff(&diff); - // Every change originates from an external source sync, so the decision - // turn runs tainted: the approval gate refuses external_effect tools. - let has_external_content = true; - - // ── Stage 2: prepare_context — ground the diff before deciding ─────── - let prepared_context = self.prepare_context(&config, &world_diff).await; - - // ── Stage 3: decide — slim agent acts on diff + prepared context ───── - let mut agent_prompt = - String::with_capacity(world_diff.len() + prepared_context.len() + 256); - agent_prompt.push_str("## What changed in your world since the last check\n\n"); - agent_prompt.push_str(&world_diff); - agent_prompt.push_str("\n\n"); - if !prepared_context.is_empty() { - agent_prompt.push_str("## Prepared context\n\n"); - agent_prompt.push_str(&prepared_context); - agent_prompt.push_str("\n\n"); - } - - let agent_result = self - .run_agent(&config, &agent_prompt, has_external_content) - .await; - let agent_failed = agent_result.is_err(); - let response_chars = *agent_result.as_ref().unwrap_or(&0); - - // Check if superseded by a newer tick. - if self.tick_generation.load(Ordering::SeqCst) != my_generation { - info!("[subconscious] tick superseded by newer tick, discarding"); - let mut state = self.state.lock().await; - state.total_ticks += 1; - return Ok(TickResult { - tick_at, - duration_ms: started.elapsed().as_millis() as u64, - response_chars: 0, - }); - } - - // Advance the world baseline only on a successful, current tick, so a - // failed tick re-diffs the same window next time instead of losing it. - if !agent_failed { - self.refresh_baseline(&config).await; - } - - let mut state = self.state.lock().await; - state.total_ticks += 1; - if agent_failed { - state.consecutive_failures += 1; - // Surface an actionable reason when the failure is a permanent - // tool-capability error (TAURI-RUST-ADC): the subconscious turn is - // inherently tool-bearing, so a tool-incapable chat model can never - // satisfy it and the user must pick a tool-capable model. - if let Err(e) = &agent_result { - if is_tool_capability_error(e) { - info!( - "[subconscious] configured chat model has no tool-use endpoint — Subconscious can't run until the model changes (TAURI-RUST-ADC)" - ); - state.provider_unavailable_reason = Some(TOOL_UNSUPPORTED_REASON.to_string()); - } else if is_permanent_rate_cap_error(e) { - state.arm_rate_cap_halt(&provider_signature); - } - } - } else { - state.consecutive_failures = 0; - state.last_tick_at = tick_at; - persist_last_tick_at(&self.workspace_dir, tick_at); - } - - Ok(TickResult { - tick_at, - duration_ms: started.elapsed().as_millis() as u64, - response_chars, - }) - } - - /// Stage 2: run the read-only `context_scout` over the world diff to gather - /// grounding context. Best-effort — on any error the decision agent simply - /// runs without a prepared-context section. - /// - /// The tick is a controller-spawned background surface with **no enclosing - /// agent turn**, so the `context_scout` spawn has no ambient - /// `current_parent()`. We establish a root parent for the spawn via the - /// shared [`with_root_parent`] helper — the same construction the - /// workflow-run engine and agent-team runtime use. Without it every tick's - /// scout died with `NoParentContext` and the decision ran un-grounded - /// (Sentry TAURI-RUST-HMW; #4337). The build runs only on ticks that have - /// world changes (the sole caller is past the `has_changes` gate), so quiet - /// ticks pay nothing. - async fn prepare_context(&self, config: &Config, world_diff: &str) -> String { - let question = format!( - "Background awareness check. Here is what changed in the user's connected sources \ - since the last check:\n\n{world_diff}\n\nSurface what the user should be aware of or \ - act on, and the context that grounds a good decision.", - ); - - // Flatten: outer Err = root-parent build failure, inner = scout result. - let scout = with_root_parent(config, "subconscious", "subconscious", "subconscious", { - crate::openhuman::agent_orchestration::tools::run_context_scout_with_catalog( - &question, - None, - SUBCONSCIOUS_TOOL_CATALOG, - ) - }) - .await - .and_then(|inner| inner); - - match scout { - Ok(result) if !result.is_error => { - debug!( - "[subconscious] prepared context bundle ({} chars)", - result.output().chars().count() - ); - result.output().to_string() - } - Ok(result) => { - warn!( - "[subconscious] prepare_context returned an error result: {}", - result.output() - ); - String::new() - } - Err(e) => { - warn!("[subconscious] prepare_context failed: {e}"); - String::new() - } - } - } - - /// Re-snapshot the world and persist the new checkpoint as the baseline the - /// next tick diffs against. Best-effort — a failure leaves the old baseline - /// in place (the next tick diffs against a slightly older window). - async fn refresh_baseline(&self, config: &Config) { - match crate::openhuman::memory_diff::ops::create_checkpoint( - BASELINE_CHECKPOINT_LABEL, - config, - ) - .await - { - Ok(ckpt) => { - if let Err(e) = store::with_connection(&self.workspace_dir, |conn| { - store::set_baseline_checkpoint_id(conn, &ckpt.id) - }) { - warn!("[subconscious] failed to persist baseline checkpoint id: {e}"); - } else { - debug!("[subconscious] world baseline advanced to {}", ckpt.id); - } - } - Err(e) => warn!("[subconscious] failed to create world baseline checkpoint: {e}"), - } - } - - pub async fn status(&self) -> SubconsciousStatus { - let state = self.state.lock().await; - - SubconsciousStatus { - enabled: self.enabled, - mode: self.mode.as_str().to_string(), - provider_available: state.provider_unavailable_reason.is_none(), - provider_unavailable_reason: state.provider_unavailable_reason.clone(), - interval_minutes: self.interval_minutes, - last_tick_at: if state.last_tick_at > 0.0 { - Some(state.last_tick_at) - } else { - None - }, - total_ticks: state.total_ticks, - consecutive_failures: state.consecutive_failures, - } - } - - /// Run the slim subconscious agent over `prompt_text` (diff + prepared - /// context). The agent decides and acts through its tools. Returns - /// `response_chars` on success, or `Err` on agent init/run failure. - async fn run_agent( - &self, - config: &Config, - prompt_text: &str, - has_external_content: bool, - ) -> Result { - use crate::openhuman::agent::Agent; - - let mut effective = config.clone(); - effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS; - // Route the tick build through the `subconscious` background workload so - // Settings → AI → Advanced "Subconscious" governs the cloud tick - // provider, instead of riding the `chat` role. The session builder maps - // `hint:subconscious` → the `subconscious` provider role; on the managed - // backend the model still resolves to `chat-v1` (no regression). - effective.default_model = Some("hint:subconscious".to_string()); - debug!( - "[subconscious] tick provider routed via hint:subconscious (subconscious_provider={:?})", - effective.subconscious_provider - ); - - // The decision agent must write internal continuity (global to-dos, - // goals) and surface proactive messages — all app-internal writes, not - // external effects. So it runs with Full autonomy; genuinely external - // effects are still gated by the tainted origin + approval gate. Mode - // only scales how much delegation depth the tick gets. - effective.autonomy.level = crate::openhuman::security::AutonomyLevel::Full; - match self.mode { - SubconsciousMode::Simple => { - effective.agent.max_tool_iterations = 15; - } - SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { - effective.agent.max_tool_iterations = 30; - } - SubconsciousMode::Off => return Ok(0), - } - - let mut agent = Agent::from_config(&effective).map_err(|e| { - warn!("[subconscious] agent init failed: {e}"); - format!("agent init: {e}") - })?; - - agent.set_event_context( - format!("subconscious:tick:{}", now_secs() as u64), - "subconscious", - ); - - let mode_guidance = match self.mode { - SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { - "\n\nYou may delegate deeper work with `spawn_async_subagent` (e.g. research \ - or multi-step execution) when you spot something genuinely actionable." - } - _ => "", - }; - - let user_message = format!( - "{prompt_text}\ - ## Your job\n\n\ - The diff above is how the user's world changed since the last check; the prepared \ - context grounds it. Decide what (if anything) deserves action:\n\ - - Record or update actionable follow-ups on the user's to-do board with `update_task` \ - (pass `threadId: \"user-tasks\"`).\n\ - - Evolve the user's long-term goals with `goals_add` / `goals_edit` when the world \ - shifts what matters to them.\n\ - - Surface anything time-sensitive or important with `notify_user`.\n\n\ - If nothing meaningful changed, do nothing — staying silent is the right call most \ - ticks. Do not invent busywork.{mode_guidance}", - ); - - debug!("[subconscious] spawning decision agent"); - let source = tick_origin_source(has_external_content); - debug!( - "[subconscious] tick origin source={:?} has_external_content={has_external_content}", - source - ); - let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation { - job_id: format!("subconscious:tick:{}", now_secs() as u64), - source, - }; - let response = crate::openhuman::agent::turn_origin::with_origin( - origin, - agent.run_single(&user_message), - ) - .await - .map_err(|e| { - warn!("[subconscious] agent run failed: {e}"); - format!("agent run: {e}") - })?; - - let response_chars = response.chars().count(); - info!( - "[subconscious] decision agent completed (response {} chars)", - response_chars - ); - Ok(response_chars) - } -} - -// ── World-diff rendering ───────────────────────────────────────────────────── - -/// Total added + modified + removed across all sources in a cross-source diff. -fn world_diff_change_count(diff: &CrossSourceDiff) -> u32 { - diff.summary.added + diff.summary.modified + diff.summary.removed -} - -/// Render a [`CrossSourceDiff`] into a compact markdown summary for the decision -/// agent's prompt. Per-source change lists are capped at [`MAX_ITEMS_PER_SOURCE`] -/// so a churny source can't blow out the context window. -fn render_world_diff(diff: &CrossSourceDiff) -> String { - let s = &diff.summary; - let total = s.added + s.modified + s.removed; - if total == 0 { - return "Nothing changed across your connected sources since the last check.".to_string(); - } - - let mut out = format!( - "{total} item(s) changed across your sources since the last check \ - ({} added, {} modified, {} removed).\n", - s.added, s.modified, s.removed - ); - - for source in &diff.per_source { - let ss = &source.summary; - if ss.added + ss.modified + ss.removed == 0 { - continue; - } - out.push_str(&format!( - "\n### {} ({})\n- {} added, {} modified, {} removed\n", - source.source_label, source.source_kind, ss.added, ss.modified, ss.removed - )); - for change in source.changes.iter().take(MAX_ITEMS_PER_SOURCE) { - let verb = match change.kind { - crate::openhuman::memory_diff::types::ChangeKind::Added => "added", - crate::openhuman::memory_diff::types::ChangeKind::Removed => "removed", - crate::openhuman::memory_diff::types::ChangeKind::Modified => "modified", - }; - let label = if change.title.trim().is_empty() { - change.item_id.as_str() - } else { - change.title.as_str() - }; - out.push_str(&format!(" - [{verb}] {label}\n")); - } - if source.changes.len() > MAX_ITEMS_PER_SOURCE { - out.push_str(&format!( - " - …and {} more\n", - source.changes.len() - MAX_ITEMS_PER_SOURCE - )); - } - } - out -} - -// ── Provider routing ──────────────────────────────────────────────────────── - -#[derive(Clone, Debug, Eq, PartialEq)] -enum SubconsciousProviderRoute { - LocalOllama { model: String }, - OpenHumanCloud, - Other(String), -} - -pub(crate) fn subconscious_provider_unavailable_reason(config: &Config) -> Option { - match resolve_subconscious_route(config) { - SubconsciousProviderRoute::LocalOllama { .. } => None, - SubconsciousProviderRoute::OpenHumanCloud => { - if crate::openhuman::scheduler_gate::is_signed_out() { - return Some( - "Sign in to use the OpenHuman cloud Subconscious provider.".to_string(), - ); - } - - let state_dir = config - .config_path - .parent() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| config.workspace_dir.clone()); - let auth = AuthService::new(&state_dir, config.secrets.encrypt); - match auth.get_provider_bearer_token(APP_SESSION_PROVIDER, None) { - Ok(Some(token)) if !token.trim().is_empty() => None, - Ok(_) => Some( - "Sign in or configure a local Subconscious provider in Settings > AI." - .to_string(), - ), - Err(e) => Some(format!("Unable to read the OpenHuman session: {e}")), - } - } - SubconsciousProviderRoute::Other(_) => None, - } -} - -fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute { - if let Some(model) = config.workload_local_model("subconscious") { - return SubconsciousProviderRoute::LocalOllama { model }; - } - - let raw = config - .subconscious_provider - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("cloud"); - let is_openhuman_cloud = raw.eq_ignore_ascii_case("cloud") - || raw.eq_ignore_ascii_case("openhuman") - || raw.to_ascii_lowercase().starts_with("openhuman:"); - if is_openhuman_cloud { - SubconsciousProviderRoute::OpenHumanCloud - } else { - SubconsciousProviderRoute::Other(raw.to_string()) - } -} - -/// Stable identity of the Subconscious provider routing — the exact knobs a -/// user changes in Settings > AI > Advanced to switch the tick model/provider. -/// The rate-cap circuit breaker keys its halt on this so a permanent per-minute -/// token-cap rejection stops re-firing while the SAME config is set, and -/// auto-clears the moment the user picks a different model/provider/tier. -fn subconscious_provider_signature(config: &Config) -> String { - match resolve_subconscious_route(config) { - SubconsciousProviderRoute::LocalOllama { model } => format!("local:{model}"), - SubconsciousProviderRoute::OpenHumanCloud => "cloud".to_string(), - SubconsciousProviderRoute::Other(raw) => format!("other:{raw}"), - } -} - -/// Outcome of comparing an active rate-cap halt against the live provider -/// signature at the start of a tick. Pure so it is unit-testable without -/// spinning an engine/agent. -#[derive(Debug, PartialEq, Eq)] -enum RateCapHaltDecision { - /// A halt is set for the same signature still in config — skip the run. - Skip, - /// A halt is set but the signature changed — clear it and resume ticking. - Resume, - /// No halt in effect — run the tick normally. - Proceed, -} - -/// Decide whether a tick should skip, resume, or proceed given the stored -/// rate-cap halt signature (if any) and the live provider signature. -fn evaluate_rate_cap_halt(halt_signature: Option<&str>, current: &str) -> RateCapHaltDecision { - match halt_signature { - Some(sig) if sig == current => RateCapHaltDecision::Skip, - Some(_) => RateCapHaltDecision::Resume, - None => RateCapHaltDecision::Proceed, - } -} - -/// True when an agent-run error is a permanent per-minute token-cap rejection -/// (413/TPM) — the request is larger than the provider account's per-minute -/// budget, so retrying the same tick can never succeed. Delegates to the shared -/// provider matcher (single source of truth with the Sentry classifier in -/// `core::observability`) so the wording can't drift. TAURI-RUST-HXF. -fn is_permanent_rate_cap_error(msg: &str) -> bool { - crate::openhuman::inference::provider::is_provider_rate_cap_exceeded_message(msg) -} - -/// True when an agent-run error means the configured chat model can't do tool -/// calls at all — a permanent, user-actionable condition (pick a tool-capable -/// model). Matches both the direct-provider body (` does not support -/// tools`) and OpenRouter's router-level phrasing (`No endpoints found that -/// support tool use`, TAURI-RUST-ADC). Kept narrow to tool capability so an -/// unrelated provider error (auth, billing, rate-limit) is not misread as one. -fn is_tool_capability_error(msg: &str) -> bool { - let lower = msg.to_ascii_lowercase(); - lower.contains("no endpoints found that support tool use") - || lower.contains("does not support tools") -} - -fn persist_last_tick_at(workspace_dir: &std::path::Path, tick_at: f64) { - if let Err(e) = - store::with_connection(workspace_dir, |conn| store::set_last_tick_at(conn, tick_at)) - { - warn!("[subconscious] failed to persist last_tick_at={tick_at}: {e}"); - } -} - -fn now_secs() -> f64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs_f64()) - .unwrap_or(0.0) -} - -#[cfg(test)] -#[path = "engine_tests.rs"] -mod tests; diff --git a/src/openhuman/subconscious/engine_tests.rs b/src/openhuman/subconscious/engine_tests.rs deleted file mode 100644 index 62588132a..000000000 --- a/src/openhuman/subconscious/engine_tests.rs +++ /dev/null @@ -1,243 +0,0 @@ -use super::*; - -// ── Tick origin upgrade (#approval-origin) ────────────────────────────── - -#[test] -fn tick_origin_untainted_keeps_subconscious_source() { - use crate::openhuman::agent::turn_origin::TrustedAutomationSource; - let source = tick_origin_source(false); - assert!(matches!(source, TrustedAutomationSource::Subconscious)); -} - -#[test] -fn tick_origin_with_external_sync_chunk_uses_tainted_source() { - use crate::openhuman::agent::turn_origin::TrustedAutomationSource; - let source = tick_origin_source(true); - assert!(matches!( - source, - TrustedAutomationSource::SubconsciousTainted - )); -} - -// ── Tool-capability error detection (TAURI-RUST-ADC) ──────────────────── - -#[test] -fn tool_capability_error_matches_openrouter_and_direct_bodies() { - // OpenRouter router-level 404 (the reported ADC body). - assert!(is_tool_capability_error( - r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"No endpoints found that support tool use. Try disabling \"spawn_async_subagent\"."}}"# - )); - // Direct-provider "does not support tools" phrasing (TAURI-RUST-35 family). - assert!(is_tool_capability_error( - r#"agent run: cloud API error: {"error":{"message":"qwen2:0.5b does not support tools"}}"# - )); - // Case-insensitive. - assert!(is_tool_capability_error( - "NO ENDPOINTS FOUND THAT SUPPORT TOOL USE" - )); -} - -#[test] -fn tool_capability_error_ignores_unrelated_failures() { - // A different 404, an auth wall, and a generic timeout must NOT match. - assert!(!is_tool_capability_error( - r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"model 'llama3.3' not found"}}"# - )); - assert!(!is_tool_capability_error( - "agent run: Backend returned 401 Unauthorized: Invalid token" - )); - assert!(!is_tool_capability_error("agent run: request timed out")); -} - -// ── World-diff rendering (Stage 1) ────────────────────────────────────── - -use crate::openhuman::memory_diff::types::{ - ChangeKind, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, -}; - -fn change(item_id: &str, title: &str, kind: ChangeKind) -> ItemChange { - ItemChange { - item_id: item_id.to_string(), - title: title.to_string(), - kind, - old_content_hash: None, - new_content_hash: None, - text_diff: None, - } -} - -#[test] -fn empty_cross_source_diff_has_zero_change_count() { - let diff = CrossSourceDiff { - checkpoint_id: Some("ckpt_1".into()), - computed_at_ms: 0, - summary: DiffSummary::default(), - per_source: Vec::new(), - }; - assert_eq!(world_diff_change_count(&diff), 0); - // The "no changes" render is the quiet-tick sentinel; the tick short-circuits - // before it ever reaches the agent, but the renderer stays well-defined. - assert!(render_world_diff(&diff).contains("Nothing changed")); -} - -#[test] -fn render_world_diff_summarises_changes_per_source() { - let diff = CrossSourceDiff { - checkpoint_id: Some("ckpt_1".into()), - computed_at_ms: 0, - summary: DiffSummary { - added: 2, - modified: 1, - removed: 0, - unchanged: 5, - }, - per_source: vec![DiffResult { - source_id: "src_gmail".into(), - source_kind: "composio".into(), - source_label: "Gmail".into(), - from_snapshot_id: Some("snap_a".into()), - to_snapshot_id: "snap_b".into(), - summary: DiffSummary { - added: 2, - modified: 1, - removed: 0, - unchanged: 5, - }, - changes: vec![ - change("m1", "Invoice from Acme", ChangeKind::Added), - change("m2", "Re: launch plan", ChangeKind::Added), - change("m3", "Standup notes", ChangeKind::Modified), - ], - }], - }; - - assert_eq!(world_diff_change_count(&diff), 3); - let rendered = render_world_diff(&diff); - assert!(rendered.contains("3 item(s) changed")); - assert!(rendered.contains("Gmail (composio)")); - assert!(rendered.contains("[added] Invoice from Acme")); - assert!(rendered.contains("[modified] Standup notes")); -} - -#[test] -fn render_world_diff_caps_items_and_falls_back_to_item_id() { - let mut changes = Vec::new(); - for i in 0..(MAX_ITEMS_PER_SOURCE + 3) { - // Empty title forces the item_id fallback. - changes.push(change(&format!("item_{i}"), "", ChangeKind::Added)); - } - let n = changes.len() as u32; - let diff = CrossSourceDiff { - checkpoint_id: None, - computed_at_ms: 0, - summary: DiffSummary { - added: n, - ..DiffSummary::default() - }, - per_source: vec![DiffResult { - source_id: "src_folder".into(), - source_kind: "folder".into(), - source_label: "Notes".into(), - from_snapshot_id: None, - to_snapshot_id: "snap_x".into(), - summary: DiffSummary { - added: n, - ..DiffSummary::default() - }, - changes, - }], - }; - - let rendered = render_world_diff(&diff); - assert!(rendered.contains("[added] item_0"), "uses item_id fallback"); - assert!(rendered.contains("…and 3 more"), "caps the per-source list"); -} - -// ── Rate-cap circuit breaker (TAURI-RUST-HXF) ─────────────────────────── - -#[test] -fn evaluate_rate_cap_halt_skip_resume_proceed() { - // No halt in effect → run normally. - assert_eq!( - evaluate_rate_cap_halt(None, "other:groq"), - RateCapHaltDecision::Proceed - ); - // Halt set for the same signature still in config → skip the doomed run. - assert_eq!( - evaluate_rate_cap_halt(Some("other:groq"), "other:groq"), - RateCapHaltDecision::Skip - ); - // Halt set but the user switched provider/model → clear it and resume. - assert_eq!( - evaluate_rate_cap_halt(Some("other:groq"), "cloud"), - RateCapHaltDecision::Resume - ); -} - -#[test] -fn permanent_rate_cap_error_matches_wrapped_groq_agent_error_only() { - // The verbatim wrapped agent-run error the tick surfaces (413/TPM) → - // permanent, so the breaker halts. - assert!(is_permanent_rate_cap_error( - r#"agent run: groq API error (413 Payload Too Large): {"error":{"message":"Request too large for model `openai/gpt-oss-120b` in organization `org_x` service tier `on_demand` on tokens per minute (TPM): Limit 8000, Requested 42084."}}"# - )); - // A transient 429 burst ("try again in Ns") must NOT halt — it stays - // retryable, so the two permanent-error arms never overlap. - assert!(!is_permanent_rate_cap_error( - "agent run: groq API error (429 Too Many Requests): Rate limit reached. Please try again in 2.5s." - )); - // A tool-capability error is a different permanent condition handled by its - // own arm, not the rate-cap breaker. - assert!(!is_permanent_rate_cap_error( - "agent run: No endpoints found that support tool use" - )); -} - -#[test] -fn subconscious_provider_signature_tracks_config_changes() { - // Default config routes to OpenHuman cloud. - let mut cfg = Config::default(); - assert_eq!(subconscious_provider_signature(&cfg), "cloud"); - - // A BYO provider override yields a distinct, stable signature. - cfg.subconscious_provider = Some("groq".to_string()); - let groq_sig = subconscious_provider_signature(&cfg); - assert_eq!(groq_sig, "other:groq"); - - // Switching the provider changes the signature — the breaker's cue to - // clear a halt and resume ticking. - cfg.subconscious_provider = Some("openai".to_string()); - assert_ne!(subconscious_provider_signature(&cfg), groq_sig); -} - -#[test] -fn rate_cap_halt_state_transitions() { - let mut state = EngineState { - last_tick_at: 0.0, - total_ticks: 0, - consecutive_failures: 0, - provider_unavailable_reason: None, - rate_cap_halt_signature: None, - }; - - // No halt armed → the tick proceeds (does not skip). - assert!(!state.should_skip_for_rate_cap_halt("other:groq")); - - // A permanent rate-cap failure arms the halt + actionable reason. - state.arm_rate_cap_halt("other:groq"); - assert_eq!(state.rate_cap_halt_signature.as_deref(), Some("other:groq")); - assert_eq!( - state.provider_unavailable_reason.as_deref(), - Some(RATE_CAP_HALT_REASON) - ); - - // Same config still set → skip the doomed run, and count the skipped tick. - let before = state.total_ticks; - assert!(state.should_skip_for_rate_cap_halt("other:groq")); - assert_eq!(state.total_ticks, before + 1); - - // User switched provider (signature changed) → clear halt + reason, resume. - assert!(!state.should_skip_for_rate_cap_halt("cloud")); - assert!(state.rate_cap_halt_signature.is_none()); - assert!(state.provider_unavailable_reason.is_none()); -} diff --git a/src/openhuman/subconscious/factory.rs b/src/openhuman/subconscious/factory.rs new file mode 100644 index 000000000..de135be88 --- /dev/null +++ b/src/openhuman/subconscious/factory.rs @@ -0,0 +1,69 @@ +//! The "make a subconscious" surface: the [`SubconsciousKind`] enum + the single +//! [`make_subconscious`] constructor every caller (registry, trigger RPC, tests) +//! goes through. Adding a world is a profile file + one match arm here + one +//! `enabled_kinds` line — never another engine. + +use serde::{Deserialize, Serialize}; + +use super::instance::SubconsciousInstance; +use super::profiles::{memory::memory_instance, tinyplace::tinyplace_instance}; +use crate::openhuman::config::Config; + +/// One instantiable subconscious world. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubconsciousKind { + /// The user's connected memory sources (Gmail / Slack / Notion / folders). + Memory, + /// The tiny.place orchestration steering world. + TinyPlace, +} + +impl SubconsciousKind { + /// Every kind, in a stable order (memory first — it owns the legacy status). + pub const ALL: [SubconsciousKind; 2] = [SubconsciousKind::Memory, SubconsciousKind::TinyPlace]; + + /// Stable id — store-key namespace, log prefix, RPC name. + pub fn id(self) -> &'static str { + match self { + SubconsciousKind::Memory => "memory", + SubconsciousKind::TinyPlace => "tinyplace", + } + } + + /// Parse a kind id (`"memory"` | `"tinyplace"`); `None` on anything else. + pub fn parse(s: &str) -> Option { + match s { + "memory" => Some(SubconsciousKind::Memory), + "tinyplace" => Some(SubconsciousKind::TinyPlace), + _ => None, + } + } + + /// Which kinds should run for this config — the bootstrap set. + /// + /// - `Memory` ⇐ `heartbeat.enabled && mode != Off` (the pre-factory gate). + /// - `TinyPlace` ⇐ `orchestration.enabled` (the pre-factory review gate). + pub fn enabled_kinds(config: &Config) -> Vec { + let mut kinds = Vec::new(); + if config.heartbeat.enabled && config.heartbeat.effective_subconscious_mode().is_enabled() { + kinds.push(SubconsciousKind::Memory); + } + if config.orchestration.enabled { + kinds.push(SubconsciousKind::TinyPlace); + } + kinds + } +} + +/// The only place profiles are constructed into a runner. +pub fn make_subconscious(kind: SubconsciousKind, config: &Config) -> SubconsciousInstance { + match kind { + SubconsciousKind::Memory => memory_instance(config), + SubconsciousKind::TinyPlace => tinyplace_instance(config), + } +} + +#[cfg(test)] +#[path = "factory_tests.rs"] +mod tests; diff --git a/src/openhuman/subconscious/factory_tests.rs b/src/openhuman/subconscious/factory_tests.rs new file mode 100644 index 000000000..9c5181eee --- /dev/null +++ b/src/openhuman/subconscious/factory_tests.rs @@ -0,0 +1,57 @@ +use super::*; +use crate::openhuman::config::schema::SubconsciousMode; +use crate::openhuman::config::Config; + +#[test] +fn kind_id_and_parse_round_trip() { + for kind in SubconsciousKind::ALL { + assert_eq!(SubconsciousKind::parse(kind.id()), Some(kind)); + } + assert_eq!(SubconsciousKind::parse("nope"), None); +} + +#[test] +fn enabled_kinds_gates_memory_on_heartbeat_and_mode() { + let mut cfg = Config::default(); + + // Heartbeat enabled + an enabled mode + orchestration on (default) → both. + cfg.heartbeat.enabled = true; + cfg.heartbeat.subconscious_mode = SubconsciousMode::Simple; + cfg.orchestration.enabled = true; + assert_eq!( + SubconsciousKind::enabled_kinds(&cfg), + vec![SubconsciousKind::Memory, SubconsciousKind::TinyPlace] + ); + + // Mode Off drops memory but tinyplace still runs on orchestration.enabled. + cfg.heartbeat.subconscious_mode = SubconsciousMode::Off; + assert_eq!( + SubconsciousKind::enabled_kinds(&cfg), + vec![SubconsciousKind::TinyPlace] + ); + + // Heartbeat disabled drops memory regardless of mode. + cfg.heartbeat.enabled = false; + cfg.heartbeat.subconscious_mode = SubconsciousMode::Aggressive; + assert_eq!( + SubconsciousKind::enabled_kinds(&cfg), + vec![SubconsciousKind::TinyPlace] + ); + + // Orchestration off too → nothing runs. + cfg.orchestration.enabled = false; + assert!(SubconsciousKind::enabled_kinds(&cfg).is_empty()); +} + +#[test] +fn make_subconscious_builds_each_kind_with_matching_id() { + let cfg = Config::default(); + assert_eq!( + make_subconscious(SubconsciousKind::Memory, &cfg).id(), + "memory" + ); + assert_eq!( + make_subconscious(SubconsciousKind::TinyPlace, &cfg).id(), + "tinyplace" + ); +} diff --git a/src/openhuman/subconscious/heartbeat/engine.rs b/src/openhuman/subconscious/heartbeat/engine.rs index 6262d75ce..06d73f4cf 100644 --- a/src/openhuman/subconscious/heartbeat/engine.rs +++ b/src/openhuman/subconscious/heartbeat/engine.rs @@ -1,10 +1,17 @@ use crate::openhuman::config::{Config, HeartbeatConfig}; -use crate::openhuman::subconscious::global::get_or_init_engine; +use crate::openhuman::subconscious::registry::registered_instances; use anyhow::Result; use std::path::Path; use tokio::time::{self, Duration}; use tracing::{info, warn}; +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + /// Heartbeat engine — periodic scheduler that delegates to planner collectors /// and optional subconscious model inference. pub struct HeartbeatEngine { @@ -101,32 +108,29 @@ impl HeartbeatEngine { } if current.inference_enabled { - // Get the shared global engine (same instance as RPC handlers) - let lock = match get_or_init_engine().await { - Ok(l) => l, - Err(e) => { - warn!("[heartbeat] failed to get engine: {e}"); - continue; + // Fan out: tick every registered instance whose own cadence has + // elapsed, concurrently — a slow memory tick must not delay a + // tinyplace review. `is_due` is a lock-free read of each + // instance's `last_tick_at`, so this poll never blocks. + let now = now_secs(); + let mut handles = Vec::new(); + for inst in registered_instances().await { + if inst.is_due(now).await { + handles.push(tokio::spawn(async move { + let id = inst.id(); + match inst.tick().await { + Ok(result) => info!( + "[heartbeat] {id} tick: duration={}ms response_chars={}", + result.duration_ms, result.response_chars + ), + Err(e) => warn!("[heartbeat] {id} tick error: {e}"), + } + })); } - }; - let guard = lock.lock().await; - let engine = match guard.as_ref() { - Some(e) => e, - None => { - warn!("[heartbeat] engine not initialized"); - continue; - } - }; - - match engine.tick().await { - Ok(result) => { - info!( - "[heartbeat] tick: duration={}ms response_chars={}", - result.duration_ms, result.response_chars - ); - } - Err(e) => { - warn!("[heartbeat] subconscious tick error: {e}"); + } + for handle in handles { + if let Err(e) = handle.await { + warn!("[heartbeat] subconscious tick task join failed: {e}"); } } } else { diff --git a/src/openhuman/subconscious/heartbeat/rpc.rs b/src/openhuman/subconscious/heartbeat/rpc.rs index 233804487..c86d8cd68 100644 --- a/src/openhuman/subconscious/heartbeat/rpc.rs +++ b/src/openhuman/subconscious/heartbeat/rpc.rs @@ -141,11 +141,11 @@ pub async fn settings_set( || patch.triggers_enabled.is_some() || patch.max_promotions_per_hour.is_some() { - crate::openhuman::subconscious::global::stop_heartbeat_loop().await; + crate::openhuman::subconscious::registry::stop_heartbeat_loop().await; if config.heartbeat.effective_subconscious_mode().is_enabled() { debug!("[heartbeat][rpc] settings_set: (re)starting for mode change"); if let Err(error) = - crate::openhuman::subconscious::global::bootstrap_after_login().await + crate::openhuman::subconscious::registry::bootstrap_after_login().await { warn!("[heartbeat][rpc] settings_set: heartbeat bootstrap failed: {error}"); return Err(format!( diff --git a/src/openhuman/subconscious/instance.rs b/src/openhuman/subconscious/instance.rs new file mode 100644 index 000000000..19de2aa56 --- /dev/null +++ b/src/openhuman/subconscious/instance.rs @@ -0,0 +1,600 @@ +//! `SubconsciousInstance` — the generic, world-agnostic subconscious runner. +//! +//! One instance ticks one [`SubconsciousProfile`] (`memory`, `tinyplace`, …). +//! The tick body is a [`tinyagents` `CompiledGraph`](tinyagents::graph) — the +//! same durable-workflow runtime the orchestration wake path runs on: +//! +//! ```text +//! START ─► observe ─┬─(quiet)────────────────────────────► commit ─► done ─► END +//! └─(changed)─► prepare ─► reflect ─┬─(ok)──► commit ─► done ─► END +//! └─(err)────────────► done ─► END +//! ``` +//! +//! The node handlers delegate 1:1 to the profile — the profile trait **is** the +//! injected runtime (mirroring `OrchestrationRuntime`). Everything world-agnostic +//! lives here, **once**, so every world gets it for free: the tick lock + 5s +//! acquisition skip, the generation/supersede counter, the `TICK_TIMEOUT` wall +//! clock, the provider gate + per-instance rate-cap halt (TAURI-RUST-HXF), the +//! tool-capability classifier (TAURI-RUST-ADC), only-advance-baselines-on-success, +//! and the quiet-tick short-circuit. +//! +//! Deviation from the plan sketch: the graph is rebuilt per tick (capturing the +//! freshly-loaded `Config` + the profile in the node closures) rather than once +//! at construction, so each tick reflects the live provider config. This is the +//! established `orchestration::graph::build::run_orchestration_graph` pattern and +//! does not affect checkpoint resume, which is keyed by thread id on disk. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use tinyagents::graph::{ + Checkpointer, ClosureStateReducer, Command, CompiledGraph, GraphBuilder, NodeContext, + NodeResult, SqliteCheckpointer, +}; +use tokio::sync::Mutex; +use tracing::{debug, info, warn}; + +use super::profile::{Observation, Reflection, SubconsciousProfile}; +use super::provider::{ + evaluate_rate_cap_halt, is_permanent_rate_cap_error, is_tool_capability_error, + subconscious_provider_signature, subconscious_provider_unavailable_reason, RateCapHaltDecision, + RATE_CAP_HALT_REASON, TOOL_UNSUPPORTED_REASON, +}; +use super::types::{SubconsciousStatus, TickResult}; +use crate::openhuman::config::Config; +use crate::openhuman::tinyagents::observability::GraphTracingSink; + +/// Hard timeout for a single subconscious tick. +const TICK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60); + +/// The state threaded through one tick graph. Serde so a killed tick can resume +/// from the last checkpoint boundary rather than losing its window. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct SubconsciousState { + /// Stable per-tick id (``), scoping the checkpoint thread. + tick_id: String, + /// The generation this tick was born with — compared against the shared + /// counter in the `commit` node so a superseded tick never advances state. + generation: u64, + observation: Observation, + prepared_context: String, + reflection: Option, + reflect_error: Option, + committed: bool, +} + +/// Reducer update emitted by a subconscious node (one per node result). +enum SubconsciousUpdate { + Observed(Observation), + Prepared(String), + Reflected(Reflection), + ReflectFailed(String), + Committed, + Noop, +} + +/// The instance-local, in-memory counters and circuit-breaker state. Guarded by +/// a small mutex the `status()` path can take without ever touching `tick_lock` +/// (invariant: status never blocks on a running tick). +struct InstanceState { + last_tick_at: f64, + total_ticks: u64, + consecutive_failures: u64, + provider_unavailable_reason: Option, + /// Signature (`"|"`) of a permanently-rejecting provider + /// (413/TPM). While set and still matching the live config, ticks skip the + /// reflect run. Cleared when the config's signature changes. In-memory only. + /// TAURI-RUST-HXF. + rate_cap_halt_signature: Option, +} + +impl InstanceState { + /// Pre-tick gate: consult the rate-cap halt against the live signature. + /// Returns `true` when the tick must skip because a halt is active for the + /// still-current config. A halt whose signature no longer matches is cleared + /// here and the tick proceeds. Counts a skipped tick. TAURI-RUST-HXF. + fn should_skip_for_rate_cap_halt(&mut self, signature: &str, log_prefix: &str) -> bool { + match evaluate_rate_cap_halt(self.rate_cap_halt_signature.as_deref(), signature) { + RateCapHaltDecision::Skip => { + info!( + "{log_prefix} halted — provider keeps hitting a permanent per-minute token cap \ + (413/TPM); skipping tick until the model/tier changes (TAURI-RUST-HXF)" + ); + self.total_ticks += 1; + true + } + RateCapHaltDecision::Resume => { + info!("{log_prefix} provider config changed — clearing rate-cap halt, resuming"); + self.rate_cap_halt_signature = None; + if self.provider_unavailable_reason.as_deref() == Some(RATE_CAP_HALT_REASON) { + self.provider_unavailable_reason = None; + } + false + } + RateCapHaltDecision::Proceed => false, + } + } + + /// Arm the rate-cap halt after a tick failed with a permanent per-minute + /// token-cap rejection. TAURI-RUST-HXF. + fn arm_rate_cap_halt(&mut self, signature: &str, log_prefix: &str) { + info!( + "{log_prefix} provider rejected the tick with a permanent per-minute token cap \ + (413/TPM) — halting until the model/tier changes (TAURI-RUST-HXF)" + ); + self.rate_cap_halt_signature = Some(signature.to_string()); + self.provider_unavailable_reason = Some(RATE_CAP_HALT_REASON.to_string()); + } +} + +pub struct SubconsciousInstance { + profile: Arc, + workspace_dir: PathBuf, + enabled: bool, + interval_minutes: u32, + /// Display label for `status().mode` — the memory instance maps its + /// `SubconsciousMode`; worlds with no mode concept pass a fixed label. + mode_label: String, + state: Mutex, + /// Shared with the graph's `commit` node so a superseded tick self-skips its + /// commit between supersteps, not just at the end. + tick_generation: Arc, + tick_lock: Mutex<()>, +} + +impl SubconsciousInstance { + /// Construct an instance around `profile`, resuming its persisted + /// `last_tick_at` from disk. `enabled`, `interval_minutes` and `mode_label` + /// are supplied by the caller (factory / bootstrap) since they derive from + /// world-specific config the profile already read. + pub fn new( + profile: Arc, + workspace_dir: PathBuf, + enabled: bool, + interval_minutes: u32, + mode_label: impl Into, + ) -> Self { + let instance_id = profile.id(); + let last_tick_at = match super::store::with_connection(&workspace_dir, |conn| { + super::store::get_last_tick_at(conn, instance_id) + }) { + Ok(v) => { + if v > 0.0 { + info!("[subconscious:{instance_id}] resumed last_tick_at={v} from disk"); + } + v + } + Err(e) => { + warn!("[subconscious:{instance_id}] last_tick_at load failed, using 0.0: {e}"); + 0.0 + } + }; + + Self { + profile, + workspace_dir, + enabled, + interval_minutes, + mode_label: mode_label.into(), + state: Mutex::new(InstanceState { + last_tick_at, + total_ticks: 0, + consecutive_failures: 0, + provider_unavailable_reason: None, + rate_cap_halt_signature: None, + }), + tick_generation: Arc::new(AtomicU64::new(0)), + tick_lock: Mutex::new(()), + } + } + + fn log_prefix(&self) -> String { + format!("[subconscious:{}]", self.profile.id()) + } + + /// Run one tick: acquire the per-instance tick lock (skip if another tick + /// holds it after 5s), then run the tick under the hard `TICK_TIMEOUT`. + pub async fn tick(&self) -> Result { + let prefix = self.log_prefix(); + let _tick_guard = + match tokio::time::timeout(std::time::Duration::from_secs(5), self.tick_lock.lock()) + .await + { + Ok(guard) => guard, + Err(_) => { + warn!("{prefix} tick skipped — another tick is still running"); + return Ok(TickResult { + tick_at: now_secs(), + duration_ms: 0, + response_chars: 0, + }); + } + }; + + match tokio::time::timeout(TICK_TIMEOUT, self.tick_inner()).await { + Ok(result) => result, + Err(_) => { + warn!("{prefix} tick timed out after {}s", TICK_TIMEOUT.as_secs()); + let mut state = self.state.lock().await; + state.consecutive_failures += 1; + state.total_ticks += 1; + Ok(TickResult { + tick_at: now_secs(), + duration_ms: TICK_TIMEOUT.as_millis() as u64, + response_chars: 0, + }) + } + } + } + + async fn tick_inner(&self) -> Result { + let config = match Config::load_or_init().await { + Ok(c) => c, + Err(e) => { + warn!("{} config load failed: {e}", self.log_prefix()); + let mut state = self.state.lock().await; + state.provider_unavailable_reason = Some(format!("Config unavailable: {e}")); + state.consecutive_failures += 1; + state.total_ticks += 1; + return Ok(TickResult { + tick_at: now_secs(), + duration_ms: 0, + response_chars: 0, + }); + } + }; + self.run_tick(config).await + } + + /// The tick body given a loaded config. Split out so tests can drive it with + /// a crafted `Config` without hitting disk (`Config::load_or_init`). + async fn run_tick(&self, config: Config) -> Result { + let prefix = self.log_prefix(); + let started = std::time::Instant::now(); + let tick_at = now_secs(); + let my_generation = self.tick_generation.fetch_add(1, Ordering::SeqCst) + 1; + + // ── Provider gate + rate-cap halt (per-instance signature) ─────────── + let provider_signature = format!( + "{}|{}", + self.profile.id(), + subconscious_provider_signature(&config) + ); + if self + .state + .lock() + .await + .should_skip_for_rate_cap_halt(&provider_signature, &prefix) + { + return Ok(self.quiet_result(tick_at, started)); + } + if let Some(reason) = subconscious_provider_unavailable_reason(&config) { + info!("{prefix} provider unavailable, skipping tick: {reason}"); + let mut state = self.state.lock().await; + state.provider_unavailable_reason = Some(reason); + state.consecutive_failures += 1; + state.total_ticks += 1; + return Ok(self.quiet_result(tick_at, started)); + } + { + let mut state = self.state.lock().await; + state.provider_unavailable_reason = None; + } + + // ── Run the tick graph (observe → [prepare → reflect] → commit) ────── + let seed = SubconsciousState { + tick_id: format!("{}", tick_at as u64), + generation: my_generation, + ..Default::default() + }; + let final_state = match self.run_graph(&config, seed).await { + Ok(s) => s, + Err(e) => { + warn!("{prefix} tick graph run failed: {e}"); + let mut state = self.state.lock().await; + state.consecutive_failures += 1; + state.total_ticks += 1; + return Ok(self.quiet_result(tick_at, started)); + } + }; + + // ── Superseded-generation discard ──────────────────────────────────── + if self.tick_generation.load(Ordering::SeqCst) != my_generation { + info!("{prefix} tick superseded by newer tick, discarding"); + let mut state = self.state.lock().await; + state.total_ticks += 1; + return Ok(self.quiet_result(tick_at, started)); + } + + // ── Per-instance state/status update ───────────────────────────────── + let response_chars = match &final_state.reflection { + Some(Reflection::Acted { response_chars }) => *response_chars, + _ => 0, + }; + let mut state = self.state.lock().await; + state.total_ticks += 1; + if let Some(err) = &final_state.reflect_error { + state.consecutive_failures += 1; + if is_tool_capability_error(err) { + info!("{prefix} configured chat model has no tool-use endpoint (TAURI-RUST-ADC)"); + state.provider_unavailable_reason = Some(TOOL_UNSUPPORTED_REASON.to_string()); + } else if is_permanent_rate_cap_error(err) { + state.arm_rate_cap_halt(&provider_signature, &prefix); + } + } else { + // Quiet tick or successful reflect — both advance. + state.consecutive_failures = 0; + state.last_tick_at = tick_at; + persist_last_tick_at(&self.workspace_dir, self.profile.id(), tick_at); + } + + Ok(TickResult { + tick_at, + duration_ms: started.elapsed().as_millis() as u64, + response_chars, + }) + } + + fn quiet_result(&self, tick_at: f64, started: std::time::Instant) -> TickResult { + TickResult { + tick_at, + duration_ms: started.elapsed().as_millis() as u64, + response_chars: 0, + } + } + + /// Build and run the per-tick graph, checkpointing at every super-step + /// boundary under thread `subconscious::`. + async fn run_graph( + &self, + config: &Config, + seed: SubconsciousState, + ) -> anyhow::Result { + let graph = self.build_graph(config)?; + let thread_id = format!("subconscious:{}:{}", self.profile.id(), seed.tick_id); + let checkpoint_db = self + .workspace_dir + .join("subconscious") + .join("graph_checkpoints.db"); + let checkpointer = Arc::new( + SqliteCheckpointer::::open(&checkpoint_db) + .map_err(|e| anyhow::anyhow!("open subconscious checkpoint store: {e}"))?, + ); + // Keep a handle for post-run GC. Each tick uses a unique thread id, so a + // *completed* tick's checkpoints are dead weight — ticks run every N + // minutes forever, so without pruning `graph_checkpoints.db` grows + // unboundedly (phase 6: adopt the checkpointer's existing retention + // primitive rather than adding one upstream). + let gc = Arc::clone(&checkpointer); + let graph = graph + .with_checkpointer(checkpointer) + .with_event_sink(Arc::new(GraphTracingSink::new(thread_id.clone()))); + let exec = graph + .run_with_thread(thread_id.clone(), seed) + .await + .map_err(|e| anyhow::anyhow!("subconscious graph run failed: {e}"))?; + // The run returned; resume value is spent. Drop this tick's thread so the + // checkpoint db stays bounded. Best-effort — a GC failure is not a tick + // failure. + if let Err(e) = gc.delete_thread(&thread_id).await { + debug!( + "{} checkpoint GC failed for {thread_id}: {e}", + self.log_prefix() + ); + } + Ok(exec.state) + } + + fn build_graph( + &self, + config: &Config, + ) -> anyhow::Result> { + let profile = self.profile.clone(); + let config = Arc::new(config.clone()); + let generation = self.tick_generation.clone(); + let log_prefix = self.log_prefix(); + + let mut builder = GraphBuilder::::new().set_reducer( + ClosureStateReducer::new(|mut s: SubconsciousState, u: SubconsciousUpdate| { + match u { + SubconsciousUpdate::Observed(obs) => s.observation = obs, + SubconsciousUpdate::Prepared(ctx) => s.prepared_context = ctx, + SubconsciousUpdate::Reflected(r) => s.reflection = Some(r), + SubconsciousUpdate::ReflectFailed(e) => s.reflect_error = Some(e), + SubconsciousUpdate::Committed => s.committed = true, + SubconsciousUpdate::Noop => {} + } + Ok(s) + }), + ); + + // `observe`: what changed in this world? Routes quiet → commit, + // changed → prepare. + { + let profile = profile.clone(); + let config = config.clone(); + let prefix = log_prefix.clone(); + builder = builder.add_node("observe", move |_s: SubconsciousState, _c: NodeContext| { + let profile = profile.clone(); + let config = config.clone(); + let prefix = prefix.clone(); + async move { + let obs = profile.observe(&config).await; + let route = if obs.has_changes { "prepare" } else { "commit" }; + debug!( + "{prefix} node.observe has_changes={} external={} route={route}", + obs.has_changes, obs.has_external_content, + ); + Ok(NodeResult::Command( + Command::default() + .with_update(SubconsciousUpdate::Observed(obs)) + .with_goto([route]), + )) + } + }); + } + + // `prepare`: grounding context for the reflection turn (changed only). + { + let profile = profile.clone(); + let config = config.clone(); + builder = builder.add_node("prepare", move |s: SubconsciousState, _c: NodeContext| { + let profile = profile.clone(); + let config = config.clone(); + async move { + let ctx = profile.prepare_context(&config, &s.observation).await; + Ok(NodeResult::Update(SubconsciousUpdate::Prepared(ctx))) + } + }); + } + + // `reflect`: the reflection turn. Routes ok → commit, err → done (a + // failed reflect must not advance the baseline). + { + let profile = profile.clone(); + let config = config.clone(); + let prefix = log_prefix.clone(); + builder = builder.add_node("reflect", move |s: SubconsciousState, _c: NodeContext| { + let profile = profile.clone(); + let config = config.clone(); + let prefix = prefix.clone(); + async move { + let origin = profile.origin(&s.observation); + debug!("{prefix} node.reflect origin={origin:?}"); + match profile + .reflect(&config, &s.observation, &s.prepared_context) + .await + { + Ok(reflection) => Ok(NodeResult::Command( + Command::default() + .with_update(SubconsciousUpdate::Reflected(reflection)) + .with_goto(["commit"]), + )), + Err(e) => { + warn!("{prefix} node.reflect failed: {e}"); + Ok(NodeResult::Command( + Command::default() + .with_update(SubconsciousUpdate::ReflectFailed(e)) + .with_goto(["done"]), + )) + } + } + } + }); + } + + // `commit`: advance the world baseline/cursor — but only if this tick + // has not been superseded by a newer one (checked against the shared + // generation counter, the graph twin of an external cancel token). + { + let profile = profile.clone(); + let config = config.clone(); + let prefix = log_prefix.clone(); + builder = builder.add_node("commit", move |s: SubconsciousState, _c: NodeContext| { + let profile = profile.clone(); + let config = config.clone(); + let generation = generation.clone(); + let prefix = prefix.clone(); + async move { + if generation.load(Ordering::SeqCst) != s.generation { + debug!("{prefix} node.commit skipped — tick superseded"); + return Ok(NodeResult::Update(SubconsciousUpdate::Noop)); + } + profile.commit(&config, &s.observation).await; + debug!("{prefix} node.commit advanced baseline"); + Ok(NodeResult::Update(SubconsciousUpdate::Committed)) + } + }); + } + + let graph = builder + .add_node( + "done", + |_s: SubconsciousState, _c: NodeContext| async move { + Ok(NodeResult::Update(SubconsciousUpdate::Noop)) + }, + ) + .add_edge("prepare", "reflect") + .add_edge("commit", "done") + .set_entry("observe") + .mark_command_routing("observe") + .mark_command_routing("reflect") + .set_finish("done") + .compile() + .map_err(|e| anyhow::anyhow!("subconscious graph compile failed: {e}"))?; + Ok(graph) + } + + /// Stable world id (`"memory"` | `"tinyplace"`) — for logging + fan-out. + pub fn id(&self) -> &'static str { + self.profile.id() + } + + /// Whether this instance's cadence has elapsed since its last successful + /// tick (a never-ticked instance is always due). Pure read of the small + /// `state` mutex — never touches `tick_lock`, so the heartbeat fan-out can + /// poll it without ever blocking on a running tick. + pub async fn is_due(&self, now: f64) -> bool { + let last = self.state.lock().await.last_tick_at; + if last <= 0.0 { + return true; + } + now - last >= f64::from(self.interval_minutes) * 60.0 + } + + pub async fn status(&self) -> SubconsciousStatus { + let state = self.state.lock().await; + SubconsciousStatus { + instance: self.profile.id().to_string(), + enabled: self.enabled, + mode: self.mode_label.clone(), + provider_available: state.provider_unavailable_reason.is_none(), + provider_unavailable_reason: state.provider_unavailable_reason.clone(), + interval_minutes: self.interval_minutes, + last_tick_at: (state.last_tick_at > 0.0).then_some(state.last_tick_at), + total_ticks: state.total_ticks, + consecutive_failures: state.consecutive_failures, + } + } +} + +#[cfg(test)] +impl SubconsciousInstance { + /// Drive one tick with a caller-supplied `Config`, skipping the tick lock, + /// timeout, and `Config::load_or_init` disk read. Test seam only. + pub(crate) async fn run_tick_for_test(&self, config: Config) -> Result { + self.run_tick(config).await + } + + /// A clone of the shared generation counter so a test profile can simulate a + /// newer tick superseding the one in flight. + pub(crate) fn generation_handle(&self) -> Arc { + self.tick_generation.clone() + } + + pub(crate) async fn snapshot_failures(&self) -> u64 { + self.state.lock().await.consecutive_failures + } +} + +fn persist_last_tick_at(workspace_dir: &std::path::Path, instance: &str, tick_at: f64) { + if let Err(e) = super::store::with_connection(workspace_dir, |conn| { + super::store::set_last_tick_at(conn, instance, tick_at) + }) { + warn!("[subconscious:{instance}] failed to persist last_tick_at={tick_at}: {e}"); + } +} + +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +#[cfg(test)] +#[path = "instance_tests.rs"] +mod tests; diff --git a/src/openhuman/subconscious/instance_tests.rs b/src/openhuman/subconscious/instance_tests.rs new file mode 100644 index 000000000..4c23a54df --- /dev/null +++ b/src/openhuman/subconscious/instance_tests.rs @@ -0,0 +1,343 @@ +//! Runner mechanics driven by a scripted [`FakeProfile`] — the world-agnostic +//! tick behaviour (quiet short-circuit, baseline-on-success, rate-cap halt +//! lifecycle, supersede discard) verified without any real provider/agent IO. + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use super::*; +use crate::openhuman::agent::turn_origin::TrustedAutomationSource; +use crate::openhuman::config::Config; +use crate::openhuman::subconscious::profile::{Observation, Reflection, SubconsciousProfile}; + +/// A scripted profile: records how often each stage ran and returns canned +/// observations / reflections so the runner mechanics can be asserted in +/// isolation. +struct FakeProfile { + observations: Mutex>, + reflect_result: Mutex>, + observe_calls: AtomicUsize, + prepare_calls: AtomicUsize, + reflect_calls: AtomicUsize, + commit_calls: AtomicUsize, + /// When set, `reflect` bumps this generation counter once to simulate a + /// newer tick superseding the in-flight one. + supersede: Mutex>>, + supersede_pending: AtomicUsize, +} + +impl FakeProfile { + fn new(obs: Observation, reflect: Result) -> Self { + Self { + observations: Mutex::new(vec![obs]), + reflect_result: Mutex::new(reflect), + observe_calls: AtomicUsize::new(0), + prepare_calls: AtomicUsize::new(0), + reflect_calls: AtomicUsize::new(0), + commit_calls: AtomicUsize::new(0), + supersede: Mutex::new(None), + supersede_pending: AtomicUsize::new(0), + } + } + + fn changed() -> Observation { + Observation { + rendered: "something changed".into(), + has_changes: true, + has_external_content: true, + commit_token: None, + } + } + + fn quiet() -> Observation { + Observation { + rendered: String::new(), + has_changes: false, + has_external_content: false, + commit_token: None, + } + } +} + +#[async_trait::async_trait] +impl SubconsciousProfile for FakeProfile { + fn id(&self) -> &'static str { + "memory" + } + fn cadence(&self, _config: &Config) -> std::time::Duration { + std::time::Duration::from_secs(300) + } + async fn observe(&self, _config: &Config) -> Observation { + self.observe_calls.fetch_add(1, Ordering::SeqCst); + let mut obs = self.observations.lock().unwrap(); + if obs.len() > 1 { + obs.remove(0) + } else { + obs[0].clone() + } + } + async fn prepare_context(&self, _config: &Config, _obs: &Observation) -> String { + self.prepare_calls.fetch_add(1, Ordering::SeqCst); + "prepared".into() + } + async fn reflect( + &self, + _config: &Config, + _obs: &Observation, + _prepared: &str, + ) -> Result { + self.reflect_calls.fetch_add(1, Ordering::SeqCst); + if self.supersede_pending.swap(0, Ordering::SeqCst) > 0 { + if let Some(gen) = self.supersede.lock().unwrap().as_ref() { + gen.fetch_add(1, Ordering::SeqCst); + } + } + self.reflect_result.lock().unwrap().clone() + } + async fn commit(&self, _config: &Config, _obs: &Observation) { + self.commit_calls.fetch_add(1, Ordering::SeqCst); + } + fn origin(&self, obs: &Observation) -> TrustedAutomationSource { + if obs.has_external_content { + TrustedAutomationSource::SubconsciousTainted + } else { + TrustedAutomationSource::Subconscious + } + } +} + +/// A config that routes the subconscious provider to an available BYO provider +/// with a stable, non-cloud signature (`other:groq`) so the provider gate never +/// short-circuits and the rate-cap signature is deterministic. +fn test_config(workspace: &std::path::Path) -> Config { + let mut cfg = Config::default(); + cfg.workspace_dir = workspace.to_path_buf(); + cfg.subconscious_provider = Some("groq".to_string()); + cfg +} + +fn build(profile: Arc, workspace: &std::path::Path) -> SubconsciousInstance { + SubconsciousInstance::new( + profile as Arc, + workspace.to_path_buf(), + true, + 5, + "simple", + ) +} + +// ── Rate-cap circuit breaker state machine (TAURI-RUST-HXF) ───────────── + +#[test] +fn instance_state_rate_cap_transitions() { + let mut state = InstanceState { + last_tick_at: 0.0, + total_ticks: 0, + consecutive_failures: 0, + provider_unavailable_reason: None, + rate_cap_halt_signature: None, + }; + let prefix = "[subconscious:memory]"; + + // No halt armed → the tick proceeds (does not skip). + assert!(!state.should_skip_for_rate_cap_halt("memory|other:groq", prefix)); + + // A permanent rate-cap failure arms the halt + actionable reason. + state.arm_rate_cap_halt("memory|other:groq", prefix); + assert_eq!( + state.rate_cap_halt_signature.as_deref(), + Some("memory|other:groq") + ); + assert_eq!( + state.provider_unavailable_reason.as_deref(), + Some(RATE_CAP_HALT_REASON) + ); + + // Same config still set → skip the doomed run, and count the skipped tick. + let before = state.total_ticks; + assert!(state.should_skip_for_rate_cap_halt("memory|other:groq", prefix)); + assert_eq!(state.total_ticks, before + 1); + + // User switched provider (signature changed) → clear halt + reason, resume. + assert!(!state.should_skip_for_rate_cap_halt("memory|cloud", prefix)); + assert!(state.rate_cap_halt_signature.is_none()); + assert!(state.provider_unavailable_reason.is_none()); +} + +#[tokio::test] +async fn quiet_observation_commits_and_advances_without_reflecting() { + let dir = tempfile::tempdir().unwrap(); + let profile = Arc::new(FakeProfile::new(FakeProfile::quiet(), Ok(Reflection::Idle))); + let instance = build(profile.clone(), dir.path()); + + let result = instance + .run_tick_for_test(test_config(dir.path())) + .await + .unwrap(); + + assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1); + assert_eq!( + profile.prepare_calls.load(Ordering::SeqCst), + 0, + "no prepare" + ); + assert_eq!( + profile.reflect_calls.load(Ordering::SeqCst), + 0, + "no reflect" + ); + assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 1, "committed"); + // last_tick_at advanced to this tick. + let status = instance.status().await; + assert!(status.last_tick_at.is_some()); + assert_eq!(status.consecutive_failures, 0); + assert_eq!(status.total_ticks, 1); + assert_eq!(result.response_chars, 0); +} + +#[tokio::test] +async fn changed_observation_runs_full_pipeline_and_commits_on_success() { + let dir = tempfile::tempdir().unwrap(); + let profile = Arc::new(FakeProfile::new( + FakeProfile::changed(), + Ok(Reflection::Acted { response_chars: 42 }), + )); + let instance = build(profile.clone(), dir.path()); + + let result = instance + .run_tick_for_test(test_config(dir.path())) + .await + .unwrap(); + + assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1); + assert_eq!(profile.prepare_calls.load(Ordering::SeqCst), 1); + assert_eq!(profile.reflect_calls.load(Ordering::SeqCst), 1); + assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 1); + assert_eq!(result.response_chars, 42); + assert_eq!(instance.status().await.consecutive_failures, 0); +} + +#[tokio::test] +async fn failing_reflect_holds_baseline_and_bumps_failures() { + let dir = tempfile::tempdir().unwrap(); + let profile = Arc::new(FakeProfile::new( + FakeProfile::changed(), + Err("agent run: transient boom".into()), + )); + let instance = build(profile.clone(), dir.path()); + + instance + .run_tick_for_test(test_config(dir.path())) + .await + .unwrap(); + + assert_eq!(profile.reflect_calls.load(Ordering::SeqCst), 1); + assert_eq!(profile.commit_calls.load(Ordering::SeqCst), 0, "no commit"); + let status = instance.status().await; + assert_eq!(status.consecutive_failures, 1); + // A failed reflect must not advance last_tick_at (re-diff the window next). + assert!(status.last_tick_at.is_none()); +} + +#[tokio::test] +async fn permanent_rate_cap_error_arms_halt_then_config_change_resumes() { + let dir = tempfile::tempdir().unwrap(); + let rate_cap = r#"agent run: groq API error (413 Payload Too Large): {"error":{"message":"Request too large for model in organization on tokens per minute (TPM): Limit 8000, Requested 42084."}}"#; + let profile = Arc::new(FakeProfile::new( + FakeProfile::changed(), + Err(rate_cap.to_string()), + )); + let instance = build(profile.clone(), dir.path()); + + // Tick 1: reflect fails with a permanent 413 → halt armed under `memory|other:groq`. + instance + .run_tick_for_test(test_config(dir.path())) + .await + .unwrap(); + assert_eq!(profile.observe_calls.load(Ordering::SeqCst), 1); + let status = instance.status().await; + assert!(!status.provider_available, "halt surfaced as unavailable"); + + // Tick 2: same config → the gate skips before observe even runs. + instance + .run_tick_for_test(test_config(dir.path())) + .await + .unwrap(); + assert_eq!( + profile.observe_calls.load(Ordering::SeqCst), + 1, + "halted tick must not observe" + ); + + // Tick 3: user switched provider (signature changes) → halt clears, resumes. + let mut resumed = test_config(dir.path()); + resumed.subconscious_provider = Some("openai".to_string()); + instance.run_tick_for_test(resumed).await.unwrap(); + assert_eq!( + profile.observe_calls.load(Ordering::SeqCst), + 2, + "resumed tick observes again" + ); +} + +#[tokio::test] +async fn completed_ticks_leave_no_checkpoint_threads() { + // Phase 6: each tick uses a unique checkpoint thread; a completed tick GCs + // it, so the checkpoint db stays bounded no matter how many ticks run. + use tinyagents::graph::{Checkpointer, SqliteCheckpointer}; + + let dir = tempfile::tempdir().unwrap(); + let profile = Arc::new(FakeProfile::new( + FakeProfile::changed(), + Ok(Reflection::Acted { response_chars: 1 }), + )); + let instance = build(profile.clone(), dir.path()); + + for _ in 0..3 { + instance + .run_tick_for_test(test_config(dir.path())) + .await + .unwrap(); + } + + let db = dir.path().join("subconscious").join("graph_checkpoints.db"); + let cp = SqliteCheckpointer::::open(&db).unwrap(); + let threads = cp.list_threads().await.unwrap(); + assert!( + threads.is_empty(), + "completed ticks pruned their threads, got {threads:?}" + ); +} + +#[tokio::test] +async fn superseded_tick_discards_result_and_skips_commit() { + let dir = tempfile::tempdir().unwrap(); + let profile = Arc::new(FakeProfile::new( + FakeProfile::changed(), + Ok(Reflection::Acted { response_chars: 99 }), + )); + let instance = build(profile.clone(), dir.path()); + // Wire the profile to bump the instance's generation during reflect. + *profile.supersede.lock().unwrap() = Some(instance.generation_handle()); + profile.supersede_pending.store(1, Ordering::SeqCst); + + let result = instance + .run_tick_for_test(test_config(dir.path())) + .await + .unwrap(); + + assert_eq!(profile.reflect_calls.load(Ordering::SeqCst), 1); + assert_eq!( + profile.commit_calls.load(Ordering::SeqCst), + 0, + "superseded tick must not commit" + ); + assert_eq!(result.response_chars, 0, "result discarded"); + let status = instance.status().await; + assert!(status.last_tick_at.is_none(), "no baseline advance"); + assert_eq!( + instance.snapshot_failures().await, + 0, + "not counted a failure" + ); +} diff --git a/src/openhuman/subconscious/mod.rs b/src/openhuman/subconscious/mod.rs index 2c83ca075..20d9583d3 100644 --- a/src/openhuman/subconscious/mod.rs +++ b/src/openhuman/subconscious/mod.rs @@ -1,7 +1,11 @@ pub mod agent; -pub mod engine; -pub mod global; +pub mod factory; pub mod heartbeat; +pub mod instance; +pub mod profile; +pub mod profiles; +pub mod provider; +pub mod registry; mod schemas; pub mod session; pub mod source_chunk; @@ -9,7 +13,16 @@ pub mod store; pub mod types; pub mod user_thread; -pub use engine::SubconsciousEngine; +pub use factory::{make_subconscious, SubconsciousKind}; +pub use instance::SubconsciousInstance; +pub use profile::{Observation, Reflection, SubconsciousProfile}; +pub use profiles::memory::memory_instance; +pub use profiles::tinyplace::tinyplace_instance; + +/// Back-compat alias for the old single-engine type. The live `memory` world is +/// now a [`SubconsciousInstance`] built via [`memory_instance`]; callers that +/// held a `SubconsciousEngine` keep the same runtime type. +pub type SubconsciousEngine = SubconsciousInstance; pub use schemas::{ all_controller_schemas as all_subconscious_controller_schemas, all_registered_controllers as all_subconscious_registered_controllers, diff --git a/src/openhuman/subconscious/profile.rs b/src/openhuman/subconscious/profile.rs new file mode 100644 index 000000000..03a12f097 --- /dev/null +++ b/src/openhuman/subconscious/profile.rs @@ -0,0 +1,98 @@ +//! The `SubconsciousProfile` contract — one "world" a subconscious can be +//! instantiated over. +//! +//! A profile is the injected runtime for the generic [`super::instance`] tick +//! graph: its methods are the graph's nodes (observe → prepare_context → +//! reflect → commit). The runner owns everything world-agnostic (tick lock, +//! generation/supersede, timeout, provider gate + rate-cap halt, status); the +//! profile owns only what differs between worlds. Adding a new world is a new +//! profile file, not another engine. +//! +//! Two profiles ship today (phases 2–3): +//! - `memory` — the user's connected sources; observes a memory_diff, reflects +//! with the slim decision agent (to-dos, goals, `notify_user`, delegation). +//! - `tinyplace` — the tiny.place orchestration world; observes the compressed +//! execution history + world-diff, reflects with a tool-free steering +//! synthesis that emits `STEERING_DIRECTIVE`s. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::openhuman::agent::turn_origin::TrustedAutomationSource; +use crate::openhuman::config::Config; + +/// What a profile's `observe` stage found changed in its world since the last +/// baseline. Serde so it can ride in the tick graph's checkpointed state. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Observation { + /// Rendered world diff handed to `reflect` (empty when quiet). + pub rendered: String, + /// Whether this window is worth a reflection turn. `false` short-circuits + /// the runner's quiet path (no LLM call — quiet ticks cost nothing). + pub has_changes: bool, + /// Whether the change window contains third-party content — the taint input + /// for [`SubconsciousProfile::origin`]. + pub has_external_content: bool, + /// Opaque token `commit` uses to advance exactly the observed window + /// (memory: none — it re-checkpoints the whole world; tinyplace: the newest + /// reviewed compressed-row `created_at` for the review cursor). + pub commit_token: Option, +} + +/// The outcome of a profile's `reflect` stage. Serde so it can ride in the tick +/// graph's checkpointed state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Reflection { + /// The decision agent acted through tools (memory profile). + Acted { response_chars: usize }, + /// A steering directive was emitted (tinyplace profile). + Steered { directive_id: i64 }, + /// The model looked and correctly chose to do nothing. + Idle, +} + +/// One "world" a subconscious can be instantiated over. See the module docs. +#[async_trait::async_trait] +pub trait SubconsciousProfile: Send + Sync { + /// Stable instance id — store-key namespace, log prefix, RPC name. + /// (`"memory"` | `"tinyplace"`.) + fn id(&self) -> &'static str; + + /// Tick cadence for this world (the heartbeat fans out on this). + fn cadence(&self, config: &Config) -> Duration; + + /// Stage 1: what changed in this world since my baseline? + /// + /// Infallible by signature: errors and first-ever ticks surface as an empty + /// observation (`has_changes == false`) so the runner's branch-free quiet + /// path handles them uniformly. The profile logs its own errors. + async fn observe(&self, config: &Config) -> Observation; + + /// Stage 2 (optional): grounding context for the reflection turn. The + /// default returns `""` (the tinyplace profile skips this — steering is + /// deliberately tool-free). Runs only when `obs.has_changes`. + async fn prepare_context(&self, _config: &Config, _obs: &Observation) -> String { + String::new() + } + + /// Stage 3: the reflection turn. Runs only when `obs.has_changes`. + /// + /// Returns `Err(String)` on a genuine failure (agent/provider error) so the + /// runner can classify it (tool-capability, permanent rate cap) and hold the + /// baseline; a "looked and did nothing" result is `Ok(Reflection::Idle)`. + async fn reflect( + &self, + config: &Config, + obs: &Observation, + prepared_context: &str, + ) -> Result; + + /// Advance this world's baseline/cursor. Called by the runner after a quiet + /// tick (refresh the baseline) and after a successful reflect — never after + /// a failed or superseded one. + async fn commit(&self, config: &Config, obs: &Observation); + + /// Turn-origin taint for this observation (memory: tainted iff the diff + /// carried external content; tinyplace: always tainted). + fn origin(&self, obs: &Observation) -> TrustedAutomationSource; +} diff --git a/src/openhuman/subconscious/profiles/memory.rs b/src/openhuman/subconscious/profiles/memory.rs new file mode 100644 index 000000000..1cdd39aa8 --- /dev/null +++ b/src/openhuman/subconscious/profiles/memory.rs @@ -0,0 +1,429 @@ +//! The `memory` subconscious world — OpenHuman's internal high-level world of +//! the user's connected memory sources (Gmail / Slack / Notion / folders). +//! +//! Extracted verbatim from the old monolithic engine (stages 1–3): +//! 1. **observe** — diff the connected sources against the world baseline +//! (`memory_diff::diff_since_checkpoint`) to see how the user's world changed. +//! 2. **prepare_context** — run the read-only `context_scout` over that diff to +//! gather grounding context. +//! 3. **reflect** — hand `diff + context` to the slim decision agent, which +//! records to-dos (`update_task`), evolves goals (`goals_*`), notifies the +//! user (`notify_user`), or delegates (`spawn_async_subagent`). +//! +//! The generic [`SubconsciousInstance`] runner owns the scheduler + circuit +//! breaker; this profile owns only what is memory-specific. + +use std::sync::Arc; + +use async_trait::async_trait; +use tracing::{debug, info, warn}; + +use super::super::instance::SubconsciousInstance; +use super::super::profile::{Observation, Reflection, SubconsciousProfile}; +use super::super::store; +use crate::openhuman::agent::turn_origin::TrustedAutomationSource; +use crate::openhuman::agent_orchestration::parent_context::with_root_parent; +use crate::openhuman::config::schema::SubconsciousMode; +use crate::openhuman::config::Config; +use crate::openhuman::memory_diff::types::CrossSourceDiff; + +/// Per-tool-call timeout injected into the decision agent config. +const TOOL_CALL_TIMEOUT_SECS: u64 = 5 * 60; + +/// Label stamped on the world-baseline checkpoint `commit` re-creates each tick. +const BASELINE_CHECKPOINT_LABEL: &str = "subconscious_tick"; + +/// Max changed items listed per source in the rendered world diff, to keep the +/// decision agent's prompt bounded when a source churns a lot. +const MAX_ITEMS_PER_SOURCE: usize = 10; + +/// Tool catalogue handed to the `context_scout` so its `recommended_tool_calls` +/// stay grounded in tools the decision agent can actually call. Keep in sync +/// with `agent/agent.toml`'s `[tools].named` (actionable subset). +const SUBCONSCIOUS_TOOL_CATALOG: &str = "\ +- notify_user: Send the user a proactive message about something important or time-sensitive. +- update_task: Add or update an actionable item on the user's global to-do board. +- goals_add: Record a new long-term goal that the changed world makes relevant. +- goals_edit: Revise an existing long-term goal. +- spawn_async_subagent: Delegate deeper research or multi-step work. +"; + +/// Construct the live `memory` instance from config (used by the registry / +/// bootstrap). The only place `MemoryProfile` is wired into a runner. +pub fn memory_instance(config: &Config) -> SubconsciousInstance { + let mode = config.heartbeat.effective_subconscious_mode(); + SubconsciousInstance::new( + Arc::new(MemoryProfile::new(config)), + config.workspace_dir.clone(), + mode.is_enabled(), + mode.default_interval_minutes().max(5), + mode.as_str(), + ) +} + +/// The `memory` world profile. Holds only the subconscious mode (drives cadence +/// and the decision agent's delegation depth); everything else it reads live +/// from the per-tick `Config`. +pub struct MemoryProfile { + mode: SubconsciousMode, +} + +impl MemoryProfile { + pub fn new(config: &Config) -> Self { + Self { + mode: config.heartbeat.effective_subconscious_mode(), + } + } + + /// Stage 2: run the read-only `context_scout` over the world diff to gather + /// grounding context. Best-effort — on any error the decision agent simply + /// runs without a prepared-context section. + /// + /// The tick is a controller-spawned background surface with **no enclosing + /// agent turn**, so the scout spawn has no ambient `current_parent()`. We + /// establish a root parent via [`with_root_parent`] — without it every + /// tick's scout died with `NoParentContext` (Sentry TAURI-RUST-HMW; #4337). + async fn run_scout(&self, config: &Config, world_diff: &str) -> String { + let question = format!( + "Background awareness check. Here is what changed in the user's connected sources \ + since the last check:\n\n{world_diff}\n\nSurface what the user should be aware of or \ + act on, and the context that grounds a good decision.", + ); + + // Flatten: outer Err = root-parent build failure, inner = scout result. + let scout = with_root_parent(config, "subconscious", "subconscious", "subconscious", { + crate::openhuman::agent_orchestration::tools::run_context_scout_with_catalog( + &question, + None, + SUBCONSCIOUS_TOOL_CATALOG, + ) + }) + .await + .and_then(|inner| inner); + + match scout { + Ok(result) if !result.is_error => { + debug!( + "[subconscious:memory] prepared context bundle ({} chars)", + result.output().chars().count() + ); + result.output().to_string() + } + Ok(result) => { + warn!( + "[subconscious:memory] prepare_context returned an error result: {}", + result.output() + ); + String::new() + } + Err(e) => { + warn!("[subconscious:memory] prepare_context failed: {e}"); + String::new() + } + } + } + + /// Run the slim subconscious agent over `prompt_text` (diff + prepared + /// context). The agent decides and acts through its tools. Returns + /// `response_chars` on success, or `Err` on agent init/run failure. + async fn run_agent( + &self, + config: &Config, + prompt_text: &str, + has_external_content: bool, + ) -> Result { + use crate::openhuman::agent::Agent; + + let mut effective = config.clone(); + effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS; + // Route the tick build through the `subconscious` background workload so + // Settings → AI → Advanced "Subconscious" governs the cloud tick + // provider, instead of riding the `chat` role. + effective.default_model = Some("hint:subconscious".to_string()); + debug!( + "[subconscious:memory] tick provider routed via hint:subconscious (subconscious_provider={:?})", + effective.subconscious_provider + ); + + // The decision agent must write internal continuity (global to-dos, + // goals) and surface proactive messages — all app-internal writes, not + // external effects. So it runs with Full autonomy; genuinely external + // effects are still gated by the tainted origin + approval gate. Mode + // only scales how much delegation depth the tick gets. + effective.autonomy.level = crate::openhuman::security::AutonomyLevel::Full; + match self.mode { + SubconsciousMode::Simple => { + effective.agent.max_tool_iterations = 15; + } + SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { + effective.agent.max_tool_iterations = 30; + } + SubconsciousMode::Off => return Ok(0), + } + + let mut agent = Agent::from_config(&effective).map_err(|e| { + warn!("[subconscious:memory] agent init failed: {e}"); + format!("agent init: {e}") + })?; + + agent.set_event_context( + format!("subconscious:tick:{}", now_secs() as u64), + "subconscious", + ); + + let mode_guidance = match self.mode { + SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { + "\n\nYou may delegate deeper work with `spawn_async_subagent` (e.g. research \ + or multi-step execution) when you spot something genuinely actionable." + } + _ => "", + }; + + let user_message = format!( + "{prompt_text}\ + ## Your job\n\n\ + The diff above is how the user's world changed since the last check; the prepared \ + context grounds it. Decide what (if anything) deserves action:\n\ + - Record or update actionable follow-ups on the user's to-do board with `update_task` \ + (pass `threadId: \"user-tasks\"`).\n\ + - Evolve the user's long-term goals with `goals_add` / `goals_edit` when the world \ + shifts what matters to them.\n\ + - Surface anything time-sensitive or important with `notify_user`.\n\n\ + If nothing meaningful changed, do nothing — staying silent is the right call most \ + ticks. Do not invent busywork.{mode_guidance}", + ); + + debug!("[subconscious:memory] spawning decision agent"); + let source = tick_origin_source(has_external_content); + let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation { + job_id: format!("subconscious:tick:{}", now_secs() as u64), + source, + }; + let response = crate::openhuman::agent::turn_origin::with_origin( + origin, + agent.run_single(&user_message), + ) + .await + .map_err(|e| { + warn!("[subconscious:memory] agent run failed: {e}"); + format!("agent run: {e}") + })?; + + let response_chars = response.chars().count(); + info!( + "[subconscious:memory] decision agent completed (response {} chars)", + response_chars + ); + Ok(response_chars) + } +} + +#[async_trait] +impl SubconsciousProfile for MemoryProfile { + fn id(&self) -> &'static str { + "memory" + } + + fn cadence(&self, _config: &Config) -> std::time::Duration { + std::time::Duration::from_secs(u64::from(self.mode.default_interval_minutes().max(5)) * 60) + } + + async fn observe(&self, config: &Config) -> Observation { + // ── Stage 1: memory_diff — how did the agent's world change? ────────── + // (The tiny.place orchestration review is now its own `tinyplace` + // instance, ticked independently by the heartbeat fan-out — it no longer + // piggybacks here.) + let baseline = store::with_connection(&config.workspace_dir, |conn| { + store::get_baseline_checkpoint_id(conn, "memory") + }) + .unwrap_or_else(|e| { + warn!("[subconscious:memory] baseline load failed: {e}"); + None + }); + + let diff: Option = match &baseline { + Some(checkpoint_id) => match crate::openhuman::memory_diff::ops::diff_since_checkpoint( + checkpoint_id, + config, + false, + ) + .await + { + Ok(d) => Some(d), + Err(e) => { + warn!( + "[subconscious:memory] memory_diff failed (baseline={checkpoint_id}): {e}" + ); + None + } + }, + None => { + debug!("[subconscious:memory] no world baseline yet — first tick establishes one"); + None + } + }; + + let has_changes = diff + .as_ref() + .map(|d| world_diff_change_count(d) > 0) + .unwrap_or(false); + + if !has_changes { + // Quiet window, first tick, or a diff error: nothing to react to. + // The runner routes straight to commit, which refreshes the baseline. + info!("[subconscious:memory] no world changes this tick — refreshing baseline, no agent run"); + return Observation::default(); + } + + let diff = diff.expect("has_changes implies diff is Some"); + Observation { + rendered: render_world_diff(&diff), + has_changes: true, + // Every change originates from an external source sync, so the + // decision turn runs tainted: the approval gate refuses + // external_effect tools. + has_external_content: true, + commit_token: None, + } + } + + async fn prepare_context(&self, config: &Config, obs: &Observation) -> String { + self.run_scout(config, &obs.rendered).await + } + + async fn reflect( + &self, + config: &Config, + obs: &Observation, + prepared_context: &str, + ) -> Result { + let mut agent_prompt = + String::with_capacity(obs.rendered.len() + prepared_context.len() + 256); + agent_prompt.push_str("## What changed in your world since the last check\n\n"); + agent_prompt.push_str(&obs.rendered); + agent_prompt.push_str("\n\n"); + if !prepared_context.is_empty() { + agent_prompt.push_str("## Prepared context\n\n"); + agent_prompt.push_str(prepared_context); + agent_prompt.push_str("\n\n"); + } + + let response_chars = self + .run_agent(config, &agent_prompt, obs.has_external_content) + .await?; + Ok(Reflection::Acted { response_chars }) + } + + async fn commit(&self, config: &Config, _obs: &Observation) { + // Re-snapshot the world and persist the new checkpoint as the baseline + // the next tick diffs against. Best-effort — a failure leaves the old + // baseline in place (the next tick diffs against a slightly older window). + match crate::openhuman::memory_diff::ops::create_checkpoint( + BASELINE_CHECKPOINT_LABEL, + config, + ) + .await + { + Ok(ckpt) => { + if let Err(e) = store::with_connection(&config.workspace_dir, |conn| { + store::set_baseline_checkpoint_id(conn, "memory", &ckpt.id) + }) { + warn!("[subconscious:memory] failed to persist baseline checkpoint id: {e}"); + } else { + debug!( + "[subconscious:memory] world baseline advanced to {}", + ckpt.id + ); + } + } + Err(e) => { + warn!("[subconscious:memory] failed to create world baseline checkpoint: {e}") + } + } + } + + fn origin(&self, obs: &Observation) -> TrustedAutomationSource { + tick_origin_source(obs.has_external_content) + } +} + +// ── World-diff rendering ───────────────────────────────────────────────────── + +/// Pick the `TrustedAutomationSource` variant for a memory tick. +/// +/// Contract: any tick that reacted to third-party sync changes (added/modified/ +/// removed items, all originating from external sources like Gmail / Slack / +/// Notion / synced folders) must run with `SubconsciousTainted` so the approval +/// gate refuses external_effect tools. A tick with no external changes keeps the +/// legacy `Subconscious` origin. +pub(crate) fn tick_origin_source(has_external_content: bool) -> TrustedAutomationSource { + if has_external_content { + TrustedAutomationSource::SubconsciousTainted + } else { + TrustedAutomationSource::Subconscious + } +} + +/// Total added + modified + removed across all sources in a cross-source diff. +pub(crate) fn world_diff_change_count(diff: &CrossSourceDiff) -> u32 { + diff.summary.added + diff.summary.modified + diff.summary.removed +} + +/// Render a [`CrossSourceDiff`] into a compact markdown summary for the decision +/// agent's prompt. Per-source change lists are capped at [`MAX_ITEMS_PER_SOURCE`] +/// so a churny source can't blow out the context window. +pub(crate) fn render_world_diff(diff: &CrossSourceDiff) -> String { + let s = &diff.summary; + let total = s.added + s.modified + s.removed; + if total == 0 { + return "Nothing changed across your connected sources since the last check.".to_string(); + } + + let mut out = format!( + "{total} item(s) changed across your sources since the last check \ + ({} added, {} modified, {} removed).\n", + s.added, s.modified, s.removed + ); + + for source in &diff.per_source { + let ss = &source.summary; + if ss.added + ss.modified + ss.removed == 0 { + continue; + } + out.push_str(&format!( + "\n### {} ({})\n- {} added, {} modified, {} removed\n", + source.source_label, source.source_kind, ss.added, ss.modified, ss.removed + )); + for change in source.changes.iter().take(MAX_ITEMS_PER_SOURCE) { + let verb = match change.kind { + crate::openhuman::memory_diff::types::ChangeKind::Added => "added", + crate::openhuman::memory_diff::types::ChangeKind::Removed => "removed", + crate::openhuman::memory_diff::types::ChangeKind::Modified => "modified", + }; + let label = if change.title.trim().is_empty() { + change.item_id.as_str() + } else { + change.title.as_str() + }; + out.push_str(&format!(" - [{verb}] {label}\n")); + } + if source.changes.len() > MAX_ITEMS_PER_SOURCE { + out.push_str(&format!( + " - …and {} more\n", + source.changes.len() - MAX_ITEMS_PER_SOURCE + )); + } + } + out +} + +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +#[cfg(test)] +#[path = "memory_tests.rs"] +mod tests; diff --git a/src/openhuman/subconscious/profiles/memory_tests.rs b/src/openhuman/subconscious/profiles/memory_tests.rs new file mode 100644 index 000000000..942105589 --- /dev/null +++ b/src/openhuman/subconscious/profiles/memory_tests.rs @@ -0,0 +1,124 @@ +use super::*; + +// ── Tick origin upgrade (#approval-origin) ────────────────────────────── + +#[test] +fn tick_origin_untainted_keeps_subconscious_source() { + use crate::openhuman::agent::turn_origin::TrustedAutomationSource; + let source = tick_origin_source(false); + assert!(matches!(source, TrustedAutomationSource::Subconscious)); +} + +#[test] +fn tick_origin_with_external_sync_chunk_uses_tainted_source() { + use crate::openhuman::agent::turn_origin::TrustedAutomationSource; + let source = tick_origin_source(true); + assert!(matches!( + source, + TrustedAutomationSource::SubconsciousTainted + )); +} + +// ── World-diff rendering (Stage 1) ────────────────────────────────────── + +use crate::openhuman::memory_diff::types::{ + ChangeKind, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, +}; + +fn change(item_id: &str, title: &str, kind: ChangeKind) -> ItemChange { + ItemChange { + item_id: item_id.to_string(), + title: title.to_string(), + kind, + old_content_hash: None, + new_content_hash: None, + text_diff: None, + } +} + +#[test] +fn empty_cross_source_diff_has_zero_change_count() { + let diff = CrossSourceDiff { + checkpoint_id: Some("ckpt_1".into()), + computed_at_ms: 0, + summary: DiffSummary::default(), + per_source: Vec::new(), + }; + assert_eq!(world_diff_change_count(&diff), 0); + // The "no changes" render is the quiet-tick sentinel; the tick short-circuits + // before it ever reaches the agent, but the renderer stays well-defined. + assert!(render_world_diff(&diff).contains("Nothing changed")); +} + +#[test] +fn render_world_diff_summarises_changes_per_source() { + let diff = CrossSourceDiff { + checkpoint_id: Some("ckpt_1".into()), + computed_at_ms: 0, + summary: DiffSummary { + added: 2, + modified: 1, + removed: 0, + unchanged: 5, + }, + per_source: vec![DiffResult { + source_id: "src_gmail".into(), + source_kind: "composio".into(), + source_label: "Gmail".into(), + from_snapshot_id: Some("snap_a".into()), + to_snapshot_id: "snap_b".into(), + summary: DiffSummary { + added: 2, + modified: 1, + removed: 0, + unchanged: 5, + }, + changes: vec![ + change("m1", "Invoice from Acme", ChangeKind::Added), + change("m2", "Re: launch plan", ChangeKind::Added), + change("m3", "Standup notes", ChangeKind::Modified), + ], + }], + }; + + assert_eq!(world_diff_change_count(&diff), 3); + let rendered = render_world_diff(&diff); + assert!(rendered.contains("3 item(s) changed")); + assert!(rendered.contains("Gmail (composio)")); + assert!(rendered.contains("[added] Invoice from Acme")); + assert!(rendered.contains("[modified] Standup notes")); +} + +#[test] +fn render_world_diff_caps_items_and_falls_back_to_item_id() { + let mut changes = Vec::new(); + for i in 0..(MAX_ITEMS_PER_SOURCE + 3) { + // Empty title forces the item_id fallback. + changes.push(change(&format!("item_{i}"), "", ChangeKind::Added)); + } + let n = changes.len() as u32; + let diff = CrossSourceDiff { + checkpoint_id: None, + computed_at_ms: 0, + summary: DiffSummary { + added: n, + ..DiffSummary::default() + }, + per_source: vec![DiffResult { + source_id: "src_folder".into(), + source_kind: "folder".into(), + source_label: "Notes".into(), + from_snapshot_id: None, + to_snapshot_id: "snap_x".into(), + summary: DiffSummary { + added: n, + ..DiffSummary::default() + }, + changes, + }], + }; + + let rendered = render_world_diff(&diff); + assert!(rendered.contains("[added] item_0"), "uses item_id fallback"); + assert!(rendered.contains("…and 3 more"), "caps the per-source list"); +} diff --git a/src/openhuman/subconscious/profiles/mod.rs b/src/openhuman/subconscious/profiles/mod.rs new file mode 100644 index 000000000..9ab585311 --- /dev/null +++ b/src/openhuman/subconscious/profiles/mod.rs @@ -0,0 +1,7 @@ +//! Concrete subconscious worlds. Each profile file implements +//! [`SubconsciousProfile`](super::profile::SubconsciousProfile) for one world; +//! the generic [`SubconsciousInstance`](super::instance::SubconsciousInstance) +//! runner ticks it. Adding a world is a new file here, not a new engine. + +pub mod memory; +pub mod tinyplace; diff --git a/src/openhuman/subconscious/profiles/tinyplace.rs b/src/openhuman/subconscious/profiles/tinyplace.rs new file mode 100644 index 000000000..49995e75e --- /dev/null +++ b/src/openhuman/subconscious/profiles/tinyplace.rs @@ -0,0 +1,140 @@ +//! The `tinyplace` subconscious world — tiny.place orchestration steering. +//! +//! Reflects offline over the orchestration layer's 20:1-compressed execution +//! history + cumulative world-state diff and, when a macro-trend warrants it, +//! emits **one** `STEERING_DIRECTIVE` that later reasoning cycles inject into +//! their prompt. The reflect turn is a **tool-free provider chat** — it +//! constructs no Agent and no toolset, so it can never contact anyone (the +//! isolation invariant, enforced by a source scan below). +//! +//! This profile is pure scheduler + policy: the orchestration domain still owns +//! its store shapes and the steering contract via +//! [`orchestration::ops::load_review_window`] / [`synthesize_and_persist`]. + +use std::sync::Arc; + +use async_trait::async_trait; +use tracing::warn; + +use super::super::instance::SubconsciousInstance; +use super::super::profile::{Observation, Reflection, SubconsciousProfile}; +use crate::openhuman::agent::turn_origin::TrustedAutomationSource; +use crate::openhuman::config::Config; +use crate::openhuman::orchestration::ops::{load_review_window, synthesize_and_persist}; +use crate::openhuman::orchestration::store as orch_store; + +/// Construct the live `tinyplace` instance from config (used by the registry / +/// bootstrap). The only place `TinyPlaceProfile` is wired into a runner. +pub fn tinyplace_instance(config: &Config) -> SubconsciousInstance { + let interval = config + .orchestration + .effective_review_interval_minutes(config.heartbeat.interval_minutes); + SubconsciousInstance::new( + Arc::new(TinyPlaceProfile), + config.workspace_dir.clone(), + config.orchestration.enabled, + interval, + // Steering has no mode concept; a fixed label keeps the status shape. + "steering", + ) +} + +/// The `tinyplace` world profile. Stateless — every tick reads its window live +/// from the orchestration store through the shared ops seam. +pub struct TinyPlaceProfile; + +#[async_trait] +impl SubconsciousProfile for TinyPlaceProfile { + fn id(&self) -> &'static str { + "tinyplace" + } + + fn cadence(&self, config: &Config) -> std::time::Duration { + let mins = config + .orchestration + .effective_review_interval_minutes(config.heartbeat.interval_minutes); + std::time::Duration::from_secs(u64::from(mins) * 60) + } + + async fn observe(&self, config: &Config) -> Observation { + // Self-gating: `None` when orchestration is disabled or nothing new to + // review (same idle gate as the pre-factory `Ok(false)` path). + match load_review_window(config).await { + Ok(Some(window)) => { + let commit_token = window.newest_reviewed.clone(); + // Serialize the whole window through the tick graph's state so + // reflect sees exactly the rows observe pinned (the checkpoint + // schema stays a single string, per the generic Observation). + let rendered = match serde_json::to_string(&window) { + Ok(json) => json, + Err(e) => { + warn!("[subconscious:tinyplace] review window encode failed: {e}"); + return Observation::default(); + } + }; + Observation { + rendered, + has_changes: true, + // Harness DMs are third-party content — always tainted. + has_external_content: true, + commit_token, + } + } + Ok(None) => Observation::default(), + Err(e) => { + warn!("[subconscious:tinyplace] review load failed: {e}"); + Observation::default() + } + } + } + + // prepare_context: default no-op — steering is deliberately tool-free. + + async fn reflect( + &self, + config: &Config, + obs: &Observation, + _prepared_context: &str, + ) -> Result { + let window = serde_json::from_str(&obs.rendered) + .map_err(|e| format!("review window decode: {e}"))?; + let tick_id = format!("subconscious:tinyplace:{}", now_secs() as u64); + match synthesize_and_persist(config, &window, &tick_id).await? { + Some(directive_id) => Ok(Reflection::Steered { directive_id }), + // A clean NONE or twice-failed synthesis is an idle result, not an + // error — the cursor still advances (via commit) so we don't reflect + // the same rows forever. + None => Ok(Reflection::Idle), + } + } + + async fn commit(&self, config: &Config, obs: &Observation) { + // Advance the review cursor to exactly the window observed. Only the + // runner's non-superseded, non-failed path reaches here, so this is the + // uniform "advance only when the tick stuck" point. A quiet tick carries + // no token → no-op. + if let Some(token) = &obs.commit_token { + if let Err(e) = orch_store::with_connection(&config.workspace_dir, |conn| { + orch_store::set_review_cursor(conn, token) + }) { + warn!("[subconscious:tinyplace] review cursor advance failed: {e}"); + } + } + } + + fn origin(&self, _obs: &Observation) -> TrustedAutomationSource { + // Steering always reacts to third-party harness content. + TrustedAutomationSource::SubconsciousTainted + } +} + +fn now_secs() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +#[cfg(test)] +#[path = "tinyplace_tests.rs"] +mod tests; diff --git a/src/openhuman/subconscious/profiles/tinyplace_tests.rs b/src/openhuman/subconscious/profiles/tinyplace_tests.rs new file mode 100644 index 000000000..7b67cde99 --- /dev/null +++ b/src/openhuman/subconscious/profiles/tinyplace_tests.rs @@ -0,0 +1,202 @@ +use super::*; +use crate::openhuman::orchestration::store; +use std::sync::Arc; + +/// The factory test override is process-global, so tests that install a scripted +/// provider must not run concurrently. Poison-tolerant serialization lock. +static PROVIDER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A scripted provider so `create_chat_provider` returns a canned steering +/// synthesis without any network (the factory test override). +struct ScriptedProvider { + reply: String, +} +#[async_trait] +impl crate::openhuman::inference::provider::Provider for ScriptedProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + Ok(self.reply.clone()) + } +} + +fn test_config(dir: &std::path::Path) -> Config { + let mut cfg = Config::default(); + cfg.workspace_dir = dir.to_path_buf(); + cfg.orchestration.enabled = true; + // A BYO provider signature so any downstream provider gate is deterministic. + cfg.subconscious_provider = Some("groq".to_string()); + cfg +} + +/// Seed one compressed-history row + one world-diff entry so a review has data. +fn seed_activity(config: &Config, tag: &str) { + store::with_connection(&config.workspace_dir, |conn| { + store::insert_compressed( + conn, + &format!("h1#{tag}"), + "h1", + "@me", + 400, + 20, + &format!("did work {tag}"), + &format!("2026-07-02T00:0{tag}:00Z"), + )?; + store::append_world_diff( + conn, + &format!("h1#{tag}"), + "h1", + "@me", + "sig", + &format!("world moved {tag}"), + "delta", + &format!("2026-07-02T00:0{tag}:00Z"), + )?; + Ok(()) + }) + .unwrap(); +} + +#[test] +fn tinyplace_profile_metadata() { + let profile = TinyPlaceProfile; + assert_eq!(profile.id(), "tinyplace"); + // Always tainted — steering reacts to third-party harness content. + let tainted = profile.origin(&Observation::default()); + assert!(matches!( + tainted, + TrustedAutomationSource::SubconsciousTainted + )); +} + +#[test] +fn cadence_defaults_to_heartbeat_interval_then_honours_override() { + let mut cfg = Config::default(); + cfg.heartbeat.interval_minutes = 7; + // No explicit review interval → follows the heartbeat. + assert_eq!( + TinyPlaceProfile.cadence(&cfg), + std::time::Duration::from_secs(7 * 60) + ); + // Explicit override wins. + cfg.orchestration.review_interval_minutes = Some(3); + assert_eq!( + TinyPlaceProfile.cadence(&cfg), + std::time::Duration::from_secs(3 * 60) + ); +} + +#[tokio::test] +async fn observe_is_quiet_when_orchestration_disabled_or_empty() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = test_config(dir.path()); + + // Disabled → quiet regardless of data. + cfg.orchestration.enabled = false; + let obs = TinyPlaceProfile.observe(&cfg).await; + assert!(!obs.has_changes); + + // Enabled but empty store → quiet. + cfg.orchestration.enabled = true; + let obs = TinyPlaceProfile.observe(&cfg).await; + assert!(!obs.has_changes, "no unreviewed history → quiet"); +} + +#[tokio::test] +async fn observe_reflect_commit_emits_directive_then_advances_cursor() { + let _serial = PROVIDER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + seed_activity(&config, "1"); + + let _guard = crate::openhuman::inference::provider::factory::test_provider_override::install( + Arc::new(ScriptedProvider { + reply: "STEERING_DIRECTIVE: prioritize the billing migration\n\ + expires_after_cycles: 12" + .to_string(), + }), + ); + + let profile = TinyPlaceProfile; + + // observe → changed window, tainted, with a commit cursor token. + let obs = profile.observe(&config).await; + assert!(obs.has_changes); + assert!(obs.has_external_content); + assert!( + obs.commit_token.is_some(), + "carries the review cursor token" + ); + + // reflect → a directive is synthesized + persisted. + let reflection = profile.reflect(&config, &obs, "").await.unwrap(); + let directive_id = match reflection { + Reflection::Steered { directive_id } => directive_id, + other => panic!("expected Steered, got {other:?}"), + }; + assert!(directive_id > 0); + store::with_connection(&config.workspace_dir, |conn| { + let cur = store::current_steering_directive(conn, 0)?.expect("current directive"); + assert_eq!(cur.text, "prioritize the billing migration"); + Ok(()) + }) + .unwrap(); + + // Before commit, the cursor is still empty (the split moved advance here). + let before = store::with_connection(&config.workspace_dir, store::review_cursor).unwrap(); + assert!(before.is_empty(), "cursor not advanced until commit"); + + // commit → cursor advances to the observed window. + profile.commit(&config, &obs).await; + let after = store::with_connection(&config.workspace_dir, store::review_cursor).unwrap(); + assert!(!after.is_empty(), "commit advanced the review cursor"); + + // A re-observe over the same (now-reviewed) data is quiet — idempotent. + let requiet = profile.observe(&config).await; + assert!(!requiet.has_changes, "re-tick with no new data is quiet"); +} + +#[tokio::test] +async fn clean_none_is_idle_not_error() { + let _serial = PROVIDER_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let config = test_config(dir.path()); + seed_activity(&config, "1"); + + let _guard = crate::openhuman::inference::provider::factory::test_provider_override::install( + Arc::new(ScriptedProvider { + reply: "NONE".to_string(), + }), + ); + + let profile = TinyPlaceProfile; + let obs = profile.observe(&config).await; + assert!(obs.has_changes); + // A clean NONE is an idle result — not an Err (so the cursor still advances). + let reflection = profile.reflect(&config, &obs, "").await.unwrap(); + assert!(matches!(reflection, Reflection::Idle)); +} + +#[test] +fn steering_reflect_path_imports_no_agent_or_channel_symbols() { + // Isolation invariant (stage 6): the tinyplace reflect path is a tool-free + // provider chat — it must construct no Agent and reference no outbound + // channel/send-message symbols. Source-scan the profile module itself. + const SRC: &str = include_str!("tinyplace.rs"); + for forbidden in [ + "Agent::from_config", + "agent_tools", + "send_message", + "run_single", + "spawn_async_subagent", + ] { + assert!( + !SRC.contains(forbidden), + "tinyplace profile must not reference `{forbidden}` (isolation invariant)" + ); + } +} diff --git a/src/openhuman/subconscious/provider.rs b/src/openhuman/subconscious/provider.rs new file mode 100644 index 000000000..fdc35c63c --- /dev/null +++ b/src/openhuman/subconscious/provider.rs @@ -0,0 +1,152 @@ +//! Shared Subconscious provider routing + failure classification. +//! +//! Both subconscious worlds (the `memory` decision agent and the `tinyplace` +//! steering synthesis) run on the same **`subconscious`** provider route — +//! Settings → AI → Advanced "Subconscious" governs the cloud/local tick model. +//! The route resolution, the rate-cap circuit-breaker signature, and the two +//! permanent-error classifiers (tool-capability, per-minute token cap) are +//! therefore world-agnostic and live here, shared by the generic +//! [`super::instance`] runner and every [`super::profiles`] world. + +use crate::openhuman::config::Config; +use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; + +/// Actionable reason surfaced (via `SubconsciousStatus.provider_unavailable_reason`) +/// when a subconscious tick fails because the configured chat model has no +/// tool-use endpoint. The memory decision turn is inherently tool-bearing (it +/// acts through tools), so a tool-incapable model can never satisfy such a tick +/// — this tells the user how to recover. See TAURI-RUST-ADC. +pub(crate) const TOOL_UNSUPPORTED_REASON: &str = "The selected chat model has no tool-use endpoint, so Subconscious can't run. Pick a tool-capable model in Settings > AI."; + +/// Surfaced in `SubconsciousStatus` when the circuit breaker has halted ticks +/// because the configured Subconscious model keeps rejecting requests with a +/// permanent per-minute token cap (413/TPM). Actionable: the fix is the user's +/// to make (a bigger model/tier), so the message points there. +pub(crate) const RATE_CAP_HALT_REASON: &str = "Subconscious is paused: the selected model rejected the request because it exceeds your provider's per-minute token limit. Pick a higher-tier model or provider for Subconscious in Settings > AI > Advanced."; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum SubconsciousProviderRoute { + LocalOllama { model: String }, + OpenHumanCloud, + Other(String), +} + +/// Actionable reason the configured Subconscious provider can't run right now +/// (e.g. signed out of the OpenHuman cloud), or `None` when it is available. +/// Route resolution is shared by both worlds. +pub(crate) fn subconscious_provider_unavailable_reason(config: &Config) -> Option { + match resolve_subconscious_route(config) { + SubconsciousProviderRoute::LocalOllama { .. } => None, + SubconsciousProviderRoute::OpenHumanCloud => { + if crate::openhuman::scheduler_gate::is_signed_out() { + return Some( + "Sign in to use the OpenHuman cloud Subconscious provider.".to_string(), + ); + } + + let state_dir = config + .config_path + .parent() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| config.workspace_dir.clone()); + let auth = AuthService::new(&state_dir, config.secrets.encrypt); + match auth.get_provider_bearer_token(APP_SESSION_PROVIDER, None) { + Ok(Some(token)) if !token.trim().is_empty() => None, + Ok(_) => Some( + "Sign in or configure a local Subconscious provider in Settings > AI." + .to_string(), + ), + Err(e) => Some(format!("Unable to read the OpenHuman session: {e}")), + } + } + SubconsciousProviderRoute::Other(_) => None, + } +} + +fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute { + if let Some(model) = config.workload_local_model("subconscious") { + return SubconsciousProviderRoute::LocalOllama { model }; + } + + let raw = config + .subconscious_provider + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("cloud"); + let is_openhuman_cloud = raw.eq_ignore_ascii_case("cloud") + || raw.eq_ignore_ascii_case("openhuman") + || raw.to_ascii_lowercase().starts_with("openhuman:"); + if is_openhuman_cloud { + SubconsciousProviderRoute::OpenHumanCloud + } else { + SubconsciousProviderRoute::Other(raw.to_string()) + } +} + +/// Stable identity of the Subconscious provider routing — the exact knobs a +/// user changes in Settings > AI > Advanced to switch the tick model/provider. +/// The rate-cap circuit breaker keys its halt on this so a permanent per-minute +/// token-cap rejection stops re-firing while the SAME config is set, and +/// auto-clears the moment the user picks a different model/provider/tier. +/// +/// The generic runner prefixes this with the instance id (`"memory|cloud"`) so +/// one world's halt never silences another. +pub(crate) fn subconscious_provider_signature(config: &Config) -> String { + match resolve_subconscious_route(config) { + SubconsciousProviderRoute::LocalOllama { model } => format!("local:{model}"), + SubconsciousProviderRoute::OpenHumanCloud => "cloud".to_string(), + SubconsciousProviderRoute::Other(raw) => format!("other:{raw}"), + } +} + +/// Outcome of comparing an active rate-cap halt against the live provider +/// signature at the start of a tick. Pure so it is unit-testable without +/// spinning an engine/agent. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RateCapHaltDecision { + /// A halt is set for the same signature still in config — skip the run. + Skip, + /// A halt is set but the signature changed — clear it and resume ticking. + Resume, + /// No halt in effect — run the tick normally. + Proceed, +} + +/// Decide whether a tick should skip, resume, or proceed given the stored +/// rate-cap halt signature (if any) and the live provider signature. +pub(crate) fn evaluate_rate_cap_halt( + halt_signature: Option<&str>, + current: &str, +) -> RateCapHaltDecision { + match halt_signature { + Some(sig) if sig == current => RateCapHaltDecision::Skip, + Some(_) => RateCapHaltDecision::Resume, + None => RateCapHaltDecision::Proceed, + } +} + +/// True when an agent-run error is a permanent per-minute token-cap rejection +/// (413/TPM) — the request is larger than the provider account's per-minute +/// budget, so retrying the same tick can never succeed. Delegates to the shared +/// provider matcher (single source of truth with the Sentry classifier in +/// `core::observability`) so the wording can't drift. TAURI-RUST-HXF. +pub(crate) fn is_permanent_rate_cap_error(msg: &str) -> bool { + crate::openhuman::inference::provider::is_provider_rate_cap_exceeded_message(msg) +} + +/// True when an agent-run error means the configured chat model can't do tool +/// calls at all — a permanent, user-actionable condition (pick a tool-capable +/// model). Matches both the direct-provider body (` does not support +/// tools`) and OpenRouter's router-level phrasing (`No endpoints found that +/// support tool use`, TAURI-RUST-ADC). Kept narrow to tool capability so an +/// unrelated provider error (auth, billing, rate-limit) is not misread as one. +pub(crate) fn is_tool_capability_error(msg: &str) -> bool { + let lower = msg.to_ascii_lowercase(); + lower.contains("no endpoints found that support tool use") + || lower.contains("does not support tools") +} + +#[cfg(test)] +#[path = "provider_tests.rs"] +mod tests; diff --git a/src/openhuman/subconscious/provider_tests.rs b/src/openhuman/subconscious/provider_tests.rs new file mode 100644 index 000000000..fed43f880 --- /dev/null +++ b/src/openhuman/subconscious/provider_tests.rs @@ -0,0 +1,89 @@ +use super::*; +use crate::openhuman::config::Config; + +// ── Tool-capability error detection (TAURI-RUST-ADC) ──────────────────── + +#[test] +fn tool_capability_error_matches_openrouter_and_direct_bodies() { + // OpenRouter router-level 404 (the reported ADC body). + assert!(is_tool_capability_error( + r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"No endpoints found that support tool use. Try disabling \"spawn_async_subagent\"."}}"# + )); + // Direct-provider "does not support tools" phrasing (TAURI-RUST-35 family). + assert!(is_tool_capability_error( + r#"agent run: cloud API error: {"error":{"message":"qwen2:0.5b does not support tools"}}"# + )); + // Case-insensitive. + assert!(is_tool_capability_error( + "NO ENDPOINTS FOUND THAT SUPPORT TOOL USE" + )); +} + +#[test] +fn tool_capability_error_ignores_unrelated_failures() { + // A different 404, an auth wall, and a generic timeout must NOT match. + assert!(!is_tool_capability_error( + r#"agent run: openrouter API error (404 Not Found): {"error":{"message":"model 'llama3.3' not found"}}"# + )); + assert!(!is_tool_capability_error( + "agent run: Backend returned 401 Unauthorized: Invalid token" + )); + assert!(!is_tool_capability_error("agent run: request timed out")); +} + +// ── Rate-cap circuit breaker (TAURI-RUST-HXF) ─────────────────────────── + +#[test] +fn evaluate_rate_cap_halt_skip_resume_proceed() { + // No halt in effect → run normally. + assert_eq!( + evaluate_rate_cap_halt(None, "other:groq"), + RateCapHaltDecision::Proceed + ); + // Halt set for the same signature still in config → skip the doomed run. + assert_eq!( + evaluate_rate_cap_halt(Some("other:groq"), "other:groq"), + RateCapHaltDecision::Skip + ); + // Halt set but the user switched provider/model → clear it and resume. + assert_eq!( + evaluate_rate_cap_halt(Some("other:groq"), "cloud"), + RateCapHaltDecision::Resume + ); +} + +#[test] +fn permanent_rate_cap_error_matches_wrapped_groq_agent_error_only() { + // The verbatim wrapped agent-run error the tick surfaces (413/TPM) → + // permanent, so the breaker halts. + assert!(is_permanent_rate_cap_error( + r#"agent run: groq API error (413 Payload Too Large): {"error":{"message":"Request too large for model `openai/gpt-oss-120b` in organization `org_x` service tier `on_demand` on tokens per minute (TPM): Limit 8000, Requested 42084."}}"# + )); + // A transient 429 burst ("try again in Ns") must NOT halt — it stays + // retryable, so the two permanent-error arms never overlap. + assert!(!is_permanent_rate_cap_error( + "agent run: groq API error (429 Too Many Requests): Rate limit reached. Please try again in 2.5s." + )); + // A tool-capability error is a different permanent condition handled by its + // own arm, not the rate-cap breaker. + assert!(!is_permanent_rate_cap_error( + "agent run: No endpoints found that support tool use" + )); +} + +#[test] +fn subconscious_provider_signature_tracks_config_changes() { + // Default config routes to OpenHuman cloud. + let mut cfg = Config::default(); + assert_eq!(subconscious_provider_signature(&cfg), "cloud"); + + // A BYO provider override yields a distinct, stable signature. + cfg.subconscious_provider = Some("groq".to_string()); + let groq_sig = subconscious_provider_signature(&cfg); + assert_eq!(groq_sig, "other:groq"); + + // Switching the provider changes the signature — the breaker's cue to + // clear a halt and resume ticking. + cfg.subconscious_provider = Some("openai".to_string()); + assert_ne!(subconscious_provider_signature(&cfg), groq_sig); +} diff --git a/src/openhuman/subconscious/global.rs b/src/openhuman/subconscious/registry.rs similarity index 65% rename from src/openhuman/subconscious/global.rs rename to src/openhuman/subconscious/registry.rs index bcb346243..d2a9da64c 100644 --- a/src/openhuman/subconscious/global.rs +++ b/src/openhuman/subconscious/registry.rs @@ -1,47 +1,61 @@ -//! Global singleton for the SubconsciousEngine. +//! Registry of live subconscious instances — one per enabled [`SubconsciousKind`]. //! -//! Shared between the heartbeat background loop and RPC handlers -//! so both see the same state and counters. +//! Shared between the heartbeat fan-out and the RPC handlers so both see the +//! same instances and counters. Instances are held as plain `Arc` (not +//! `Mutex>`): each instance's own `tick_lock`/`state` mutexes +//! serialize what needs serializing, and the status path must stay lock-free +//! (it never takes `tick_lock`). -use super::engine::SubconsciousEngine; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use tokio::sync::Mutex; use tokio::task::JoinHandle; -static ENGINE: OnceLock>>> = OnceLock::new(); +use super::factory::{make_subconscious, SubconsciousKind}; +use super::instance::SubconsciousInstance; + +static REGISTRY: OnceLock>>> = + OnceLock::new(); static BOOTSTRAPPED: AtomicBool = AtomicBool::new(false); static HEARTBEAT_HANDLE: OnceLock>>> = OnceLock::new(); -fn engine_lock() -> &'static Arc>> { - ENGINE.get_or_init(|| Arc::new(Mutex::new(None))) +fn registry() -> &'static Mutex>> { + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } fn heartbeat_slot() -> &'static Mutex>> { HEARTBEAT_HANDLE.get_or_init(|| Mutex::new(None)) } -pub async fn get_or_init_engine() -> Result>>, String> { - let lock = engine_lock(); +/// Lazily construct (or fetch) the instance for `kind`, keyed in the registry. +pub async fn get_or_init_instance( + kind: SubconsciousKind, +) -> Result, String> { { - let guard = lock.lock().await; - if guard.is_some() { - return Ok(Arc::clone(lock)); + let map = registry().lock().await; + if let Some(inst) = map.get(&kind) { + return Ok(Arc::clone(inst)); } } let config = crate::openhuman::config::Config::load_or_init() .await .map_err(|e| format!("load config: {e}"))?; + let inst = Arc::new(make_subconscious(kind, &config)); - let engine = SubconsciousEngine::new(&config); + let mut map = registry().lock().await; + Ok(Arc::clone(map.entry(kind).or_insert(inst))) +} - let mut guard = lock.lock().await; - if guard.is_none() { - *guard = Some(engine); - } - - Ok(Arc::clone(lock)) +/// Every currently-registered instance (the bootstrap set), in a stable order +/// (memory first). Used by the heartbeat fan-out and `subconscious.status`. +pub async fn registered_instances() -> Vec> { + let map = registry().lock().await; + SubconsciousKind::ALL + .iter() + .filter_map(|k| map.get(k).map(Arc::clone)) + .collect() } pub async fn bootstrap_after_login() -> Result<(), String> { @@ -57,18 +71,26 @@ pub async fn bootstrap_after_login() -> Result<(), String> { format!("load config: {e}") })?; + // The heartbeat loop is the clock that drives every instance's tick (plus + // the event-planner duties). If it is disabled, no world ticks — same gate + // as the pre-factory build. if !config.heartbeat.enabled { tracing::info!("[subconscious] heartbeat disabled in config — bootstrap skipped"); BOOTSTRAPPED.store(false, Ordering::SeqCst); return Ok(()); } - get_or_init_engine().await.inspect_err(|_e| { - BOOTSTRAPPED.store(false, Ordering::SeqCst); - })?; + // Initialize every enabled world against the per-user workspace. + let kinds = SubconsciousKind::enabled_kinds(&config); + for kind in &kinds { + get_or_init_instance(*kind).await.inspect_err(|_e| { + BOOTSTRAPPED.store(false, Ordering::SeqCst); + })?; + } tracing::info!( workspace = %config.workspace_dir.display(), - "[subconscious] engine initialized against per-user workspace" + kinds = ?kinds.iter().map(|k| k.id()).collect::>(), + "[subconscious] instances initialized against per-user workspace" ); let heartbeat = crate::openhuman::heartbeat::engine::HeartbeatEngine::new( @@ -156,12 +178,11 @@ pub async fn stop_heartbeat_loop() { BOOTSTRAPPED.store(false, Ordering::SeqCst); } +/// Reset for a user switch: stop the heartbeat + triggers, then clear the whole +/// registry so a subsequent bootstrap rebuilds every instance against the new +/// workspace. pub async fn reset_engine_for_user_switch() { stop_heartbeat_loop().await; - - let lock = engine_lock(); - let mut guard = lock.lock().await; - *guard = None; - - tracing::info!("[subconscious] engine reset for user switch"); + registry().lock().await.clear(); + tracing::info!("[subconscious] registry reset for user switch"); } diff --git a/src/openhuman/subconscious/schemas.rs b/src/openhuman/subconscious/schemas.rs index d0e1638e0..001bc0ed5 100644 --- a/src/openhuman/subconscious/schemas.rs +++ b/src/openhuman/subconscious/schemas.rs @@ -2,8 +2,10 @@ use serde_json::{Map, Value}; -use super::global::get_or_init_engine; +use super::factory::SubconsciousKind; +use super::registry::{get_or_init_instance, registered_instances}; use super::store; +use super::types::SubconsciousStatus; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::rpc::RpcOutcome; @@ -30,15 +32,20 @@ pub fn schemas(function: &str) -> ControllerSchema { "status" => ControllerSchema { namespace: "subconscious", function: "status", - description: "Get the current subconscious engine status.", + description: "Get subconscious status. Legacy top-level fields mirror the memory \ + instance; `instances` lists every registered world.", inputs: vec![], outputs: vec![field("result", TypeSchema::Json, "Engine status.")], }, "trigger" => ControllerSchema { namespace: "subconscious", function: "trigger", - description: "Manually trigger a subconscious tick.", - inputs: vec![], + description: "Manually trigger a subconscious tick for a world.", + inputs: vec![optional_field( + "kind", + TypeSchema::String, + "Which world to tick: \"memory\" (default), \"tinyplace\", or \"all\".", + )], outputs: vec![field("result", TypeSchema::Json, "Tick result.")], }, _other => ControllerSchema { @@ -51,33 +58,56 @@ pub fn schemas(function: &str) -> ControllerSchema { } } +/// The `subconscious.status` response: today's flat status (mirroring the memory +/// instance for backward compatibility) plus one row per registered world. +#[derive(serde::Serialize)] +struct SubconsciousStatusResponse { + #[serde(flatten)] + legacy: SubconsciousStatus, + instances: Vec, +} + // ── Handlers ───────────────────────────────────────────────────────────────── fn handle_status(_params: Map) -> ControllerFuture { Box::pin(async move { - let engine_arc = get_or_init_engine().await.ok(); - if let Some(arc) = engine_arc { - let guard = arc.lock().await; - if let Some(engine) = guard.as_ref() { - let status = engine.status().await; - return to_json(RpcOutcome::single_log(status, "subconscious status")); + let instances = registered_instances().await; + if !instances.is_empty() { + let mut rows = Vec::with_capacity(instances.len()); + for inst in &instances { + rows.push(inst.status().await); } + // Legacy top-level = the memory row (or the first registered world). + let legacy = rows + .iter() + .find(|r| r.instance == "memory") + .or_else(|| rows.first()) + .cloned() + .expect("non-empty instances"); + let response = SubconsciousStatusResponse { + legacy, + instances: rows, + }; + return to_json(RpcOutcome::single_log(response, "subconscious status")); } + // Not bootstrapped yet — derive the memory row from config (as before). let config = load_config().await?; let hb = &config.heartbeat; - let last_tick_at = - store::with_connection(&config.workspace_dir, |conn| store::get_last_tick_at(conn)) - .ok(); + let last_tick_at = store::with_connection(&config.workspace_dir, |conn| { + store::get_last_tick_at(conn, "memory") + }) + .ok(); let provider_unavailable_reason = if hb.enabled && hb.inference_enabled { - super::engine::subconscious_provider_unavailable_reason(&config) + super::provider::subconscious_provider_unavailable_reason(&config) } else { None }; let mode = hb.effective_subconscious_mode(); - let status = super::types::SubconsciousStatus { + let status = SubconsciousStatus { + instance: "memory".to_string(), enabled: mode.is_enabled(), mode: mode.as_str().to_string(), provider_available: provider_unavailable_reason.is_none(), @@ -87,36 +117,58 @@ fn handle_status(_params: Map) -> ControllerFuture { total_ticks: 0, consecutive_failures: 0, }; - - to_json(RpcOutcome::single_log(status, "subconscious status")) + let response = SubconsciousStatusResponse { + legacy: status.clone(), + instances: vec![status], + }; + to_json(RpcOutcome::single_log(response, "subconscious status")) }) } -fn handle_trigger(_params: Map) -> ControllerFuture { +fn handle_trigger(params: Map) -> ControllerFuture { Box::pin(async move { - let lock = get_or_init_engine().await?; + // `kind`: "memory" (default), "tinyplace", or "all". + let raw = params + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("memory") + .to_string(); - let lock_clone = std::sync::Arc::clone(&lock); - tokio::spawn(async move { - let guard = lock_clone.lock().await; - if let Some(engine) = guard.as_ref() { - match engine.tick().await { - Ok(result) => { - tracing::info!( - "[subconscious] manual tick: duration={}ms response_chars={}", - result.duration_ms, - result.response_chars, - ); - } - Err(e) => { - tracing::warn!("[subconscious] manual tick error: {e}"); - } + let kinds: Vec = if raw == "all" { + SubconsciousKind::ALL.to_vec() + } else { + match SubconsciousKind::parse(&raw) { + Some(k) => vec![k], + None => { + return Err(format!( + "unknown subconscious kind '{raw}' (expected memory|tinyplace|all)" + )) } } - }); + }; + + // Fire-and-forget: spawn each world's tick and return immediately. + for kind in kinds { + tokio::spawn(async move { + match get_or_init_instance(kind).await { + Ok(inst) => match inst.tick().await { + Ok(result) => tracing::info!( + "[subconscious] manual {} tick: duration={}ms response_chars={}", + kind.id(), + result.duration_ms, + result.response_chars, + ), + Err(e) => { + tracing::warn!("[subconscious] manual {} tick error: {e}", kind.id()) + } + }, + Err(e) => tracing::warn!("[subconscious] manual {} init error: {e}", kind.id()), + } + }); + } to_json(RpcOutcome::single_log( - serde_json::json!({"triggered": true}), + serde_json::json!({"triggered": true, "kind": raw}), "subconscious tick triggered", )) }) @@ -137,6 +189,15 @@ fn field(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSche } } +fn optional_field(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty, + comment, + required: false, + } +} + fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } diff --git a/src/openhuman/subconscious/session.rs b/src/openhuman/subconscious/session.rs index f44a7847b..80af1448a 100644 --- a/src/openhuman/subconscious/session.rs +++ b/src/openhuman/subconscious/session.rs @@ -29,7 +29,7 @@ use crate::openhuman::config::Config; use crate::openhuman::memory_conversations::ConversationMessage; use crate::openhuman::security::AutonomyLevel; -use super::engine::tick_origin_source; +use super::profiles::memory::tick_origin_source; /// Reserved conversation thread backing the background orchestrator's /// internal reasoning. Distinct from the user-facing thread (slice 6). diff --git a/src/openhuman/subconscious/store.rs b/src/openhuman/subconscious/store.rs index 1fdd86086..d6e8a7aff 100644 --- a/src/openhuman/subconscious/store.rs +++ b/src/openhuman/subconscious/store.rs @@ -246,6 +246,27 @@ const SCHEMA_DDL: &str = " key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + -- One-time key namespacing migration (subconscious-factory phase 1): the + -- single-engine keys become `memory:`-namespaced so a second world + -- (tinyplace, …) can keep its own `last_tick_at`/baseline without colliding. + -- Idempotent and old-version tolerant: guarded by NOT EXISTS so running + -- old→new→old never loses the value, and a no-op on a fresh DB (no legacy + -- rows). The tinyplace review cursor lives in the orchestration store, not + -- here — the profile owns it via `commit`. + UPDATE subconscious_state + SET key = 'memory:last_tick_at' + WHERE key = 'last_tick_at' + AND NOT EXISTS ( + SELECT 1 FROM subconscious_state WHERE key = 'memory:last_tick_at' + ); + UPDATE subconscious_state_text + SET key = 'memory:baseline_checkpoint_id' + WHERE key = 'baseline_checkpoint_id' + AND NOT EXISTS ( + SELECT 1 FROM subconscious_state_text + WHERE key = 'memory:baseline_checkpoint_id' + ); "; #[cfg(test)] @@ -256,43 +277,57 @@ pub(crate) const SCHEMA_DDL_FOR_TESTS: &str = SCHEMA_DDL; const STATE_KEY_LAST_TICK_AT: &str = "last_tick_at"; const STATE_KEY_BASELINE_CHECKPOINT_ID: &str = "baseline_checkpoint_id"; -pub fn get_last_tick_at(conn: &Connection) -> Result { +/// Instance-namespaced state key: `":"` (e.g. `memory:last_tick_at`). +/// One subconscious.db is shared across all worlds, so each instance prefixes +/// its keys with its stable id to keep independent `last_tick_at`/baseline rows. +fn namespaced(instance: &str, key: &str) -> String { + format!("{instance}:{key}") +} + +pub fn get_last_tick_at(conn: &Connection, instance: &str) -> Result { let value: Option = conn .query_row( "SELECT value FROM subconscious_state WHERE key = ?1", - [STATE_KEY_LAST_TICK_AT], + [namespaced(instance, STATE_KEY_LAST_TICK_AT)], |row| row.get(0), ) .optional()?; Ok(value.unwrap_or(0.0)) } -pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> { +pub fn set_last_tick_at(conn: &Connection, instance: &str, value: f64) -> Result<()> { conn.execute( "INSERT OR REPLACE INTO subconscious_state (key, value) VALUES (?1, ?2)", - rusqlite::params![STATE_KEY_LAST_TICK_AT, value], + rusqlite::params![namespaced(instance, STATE_KEY_LAST_TICK_AT), value], )?; Ok(()) } /// The memory_diff checkpoint the structured tick diffs against — i.e. the /// snapshot of "the agent's world" captured at the end of the previous tick. -/// `None` until the first tick establishes a baseline. -pub fn get_baseline_checkpoint_id(conn: &Connection) -> Result> { +/// `None` until the first tick establishes a baseline. Namespaced per instance. +pub fn get_baseline_checkpoint_id(conn: &Connection, instance: &str) -> Result> { let value: Option = conn .query_row( "SELECT value FROM subconscious_state_text WHERE key = ?1", - [STATE_KEY_BASELINE_CHECKPOINT_ID], + [namespaced(instance, STATE_KEY_BASELINE_CHECKPOINT_ID)], |row| row.get(0), ) .optional()?; Ok(value) } -pub fn set_baseline_checkpoint_id(conn: &Connection, checkpoint_id: &str) -> Result<()> { +pub fn set_baseline_checkpoint_id( + conn: &Connection, + instance: &str, + checkpoint_id: &str, +) -> Result<()> { conn.execute( "INSERT OR REPLACE INTO subconscious_state_text (key, value) VALUES (?1, ?2)", - rusqlite::params![STATE_KEY_BASELINE_CHECKPOINT_ID, checkpoint_id], + rusqlite::params![ + namespaced(instance, STATE_KEY_BASELINE_CHECKPOINT_ID), + checkpoint_id + ], )?; Ok(()) } diff --git a/src/openhuman/subconscious/store_tests.rs b/src/openhuman/subconscious/store_tests.rs index 9ce8cefc3..d95d3ef46 100644 --- a/src/openhuman/subconscious/store_tests.rs +++ b/src/openhuman/subconscious/store_tests.rs @@ -9,33 +9,111 @@ fn test_conn() -> Connection { #[test] fn last_tick_at_round_trip() { let conn = test_conn(); - assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0); - set_last_tick_at(&conn, 12345.678).unwrap(); - assert_eq!(get_last_tick_at(&conn).unwrap(), 12345.678); + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 0.0); + set_last_tick_at(&conn, "memory", 12345.678).unwrap(); + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 12345.678); } #[test] fn last_tick_at_upsert() { let conn = test_conn(); - set_last_tick_at(&conn, 1.0).unwrap(); - set_last_tick_at(&conn, 2.0).unwrap(); - assert_eq!(get_last_tick_at(&conn).unwrap(), 2.0); + set_last_tick_at(&conn, "memory", 1.0).unwrap(); + set_last_tick_at(&conn, "memory", 2.0).unwrap(); + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 2.0); +} + +#[test] +fn state_keys_are_namespaced_per_instance() { + // Two worlds share one subconscious.db; their `last_tick_at`/baseline rows + // must not collide (subconscious-factory invariant: independent state keys). + let conn = test_conn(); + set_last_tick_at(&conn, "memory", 10.0).unwrap(); + set_last_tick_at(&conn, "tinyplace", 20.0).unwrap(); + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 10.0); + assert_eq!(get_last_tick_at(&conn, "tinyplace").unwrap(), 20.0); + + set_baseline_checkpoint_id(&conn, "memory", "ckpt_mem").unwrap(); + set_baseline_checkpoint_id(&conn, "tinyplace", "ckpt_tp").unwrap(); + assert_eq!( + get_baseline_checkpoint_id(&conn, "memory").unwrap(), + Some("ckpt_mem".to_string()) + ); + assert_eq!( + get_baseline_checkpoint_id(&conn, "tinyplace").unwrap(), + Some("ckpt_tp".to_string()) + ); +} + +#[test] +fn legacy_keys_migrate_to_memory_namespace() { + // Seed the pre-factory single-engine keys, then re-run the DDL (as every + // `open_and_initialize` does) and assert the values now live under the + // `memory:`-namespaced keys the memory instance reads. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA_DDL).unwrap(); + conn.execute( + "INSERT INTO subconscious_state (key, value) VALUES ('last_tick_at', 555.5)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO subconscious_state_text (key, value) VALUES ('baseline_checkpoint_id', 'ckpt_legacy')", + [], + ) + .unwrap(); + + // The migration is part of the idempotent DDL batch — re-running it renames. + conn.execute_batch(SCHEMA_DDL).unwrap(); + + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 555.5); + assert_eq!( + get_baseline_checkpoint_id(&conn, "memory").unwrap(), + Some("ckpt_legacy".to_string()) + ); + // The bare legacy keys are gone (renamed, not duplicated). + let legacy_count: i32 = conn + .query_row( + "SELECT COUNT(*) FROM subconscious_state WHERE key = 'last_tick_at'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(legacy_count, 0); +} + +#[test] +fn migration_never_clobbers_existing_namespaced_value() { + // Old→new→old tolerance: if a `memory:`-namespaced value already exists, a + // stale bare legacy key must NOT overwrite it (NOT EXISTS guard). + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA_DDL).unwrap(); + set_last_tick_at(&conn, "memory", 999.0).unwrap(); + conn.execute( + "INSERT INTO subconscious_state (key, value) VALUES ('last_tick_at', 1.0)", + [], + ) + .unwrap(); + + conn.execute_batch(SCHEMA_DDL).unwrap(); + + // The namespaced value wins; the legacy row is left in place, not merged. + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 999.0); } #[test] fn baseline_checkpoint_id_round_trip() { let conn = test_conn(); // Unset until the first tick establishes a baseline. - assert_eq!(get_baseline_checkpoint_id(&conn).unwrap(), None); - set_baseline_checkpoint_id(&conn, "ckpt_abc").unwrap(); + assert_eq!(get_baseline_checkpoint_id(&conn, "memory").unwrap(), None); + set_baseline_checkpoint_id(&conn, "memory", "ckpt_abc").unwrap(); assert_eq!( - get_baseline_checkpoint_id(&conn).unwrap(), + get_baseline_checkpoint_id(&conn, "memory").unwrap(), Some("ckpt_abc".to_string()) ); // Advancing the baseline replaces the previous id. - set_baseline_checkpoint_id(&conn, "ckpt_def").unwrap(); + set_baseline_checkpoint_id(&conn, "memory", "ckpt_def").unwrap(); assert_eq!( - get_baseline_checkpoint_id(&conn).unwrap(), + get_baseline_checkpoint_id(&conn, "memory").unwrap(), Some("ckpt_def".to_string()) ); } @@ -72,8 +150,8 @@ fn open_and_initialize_creates_usable_db_on_real_fs() { let db_path = dir.path().join("subconscious.db"); let conn = open_and_initialize(&db_path).unwrap(); - set_last_tick_at(&conn, 99.5).unwrap(); - assert_eq!(get_last_tick_at(&conn).unwrap(), 99.5); + set_last_tick_at(&conn, "memory", 99.5).unwrap(); + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 99.5); // On a normal local filesystem WAL succeeds; assert we landed on a valid // persistent journal mode (wal when supported, otherwise the truncate @@ -93,8 +171,8 @@ fn with_connection_creates_parent_dir_and_db() { // workspace and initialize a working DB end-to-end. let workspace = tempfile::tempdir().unwrap(); let tick = with_connection(workspace.path(), |conn| { - set_last_tick_at(conn, 7.0)?; - get_last_tick_at(conn) + set_last_tick_at(conn, "memory", 7.0)?; + get_last_tick_at(conn, "memory") }) .unwrap(); assert_eq!(tick, 7.0); @@ -113,5 +191,5 @@ fn apply_journal_mode_falls_back_without_panicking() { let conn = Connection::open_in_memory().unwrap(); apply_journal_mode(&conn); conn.execute_batch(SCHEMA_DDL).unwrap(); - assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0); + assert_eq!(get_last_tick_at(&conn, "memory").unwrap(), 0.0); } diff --git a/src/openhuman/subconscious/types.rs b/src/openhuman/subconscious/types.rs index f17bdcd90..6851479ed 100644 --- a/src/openhuman/subconscious/types.rs +++ b/src/openhuman/subconscious/types.rs @@ -2,9 +2,13 @@ use serde::{Deserialize, Serialize}; -/// Summary of the subconscious engine status. +/// Summary of a subconscious instance's status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubconsciousStatus { + /// Which world this row describes (`"memory"` | `"tinyplace"`). Defaulted to + /// `"memory"` when absent so older callers/serialized rows keep parsing. + #[serde(default = "default_instance")] + pub instance: String, pub enabled: bool, pub mode: String, pub provider_available: bool, @@ -15,6 +19,10 @@ pub struct SubconsciousStatus { pub consecutive_failures: u64, } +fn default_instance() -> String { + "memory".to_string() +} + /// Result of a single subconscious tick. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TickResult { diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 48e2a64e7..2c254cdc0 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -5555,6 +5555,103 @@ async fn json_rpc_screen_intelligence_capture_test_returns_stable_shape() { rpc_join.abort(); } +#[tokio::test] +async fn json_rpc_subconscious_status_exposes_instances_and_trigger_takes_kind() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // ── subconscious.status: legacy top-level fields + instances[] ────────── + let status = post_json_rpc(&rpc_base, 1101, "openhuman.subconscious_status", json!({})).await; + let result = assert_no_jsonrpc_error(&status, "subconscious_status"); + let status_result = result.get("result").unwrap_or(result); + + // Legacy flattened fields (backward compat) mirror the memory instance. + assert_eq!( + status_result.get("instance").and_then(Value::as_str), + Some("memory"), + "legacy top-level mirrors the memory instance: {status_result}" + ); + assert!( + status_result.get("mode").and_then(Value::as_str).is_some(), + "expected top-level mode string: {status_result}" + ); + assert!( + status_result + .get("enabled") + .and_then(Value::as_bool) + .is_some(), + "expected top-level enabled bool: {status_result}" + ); + + // Additive: instances[] with at least the memory row, each tagged. + let instances = status_result + .get("instances") + .and_then(Value::as_array) + .expect("expected instances array"); + assert!( + !instances.is_empty(), + "instances not empty: {status_result}" + ); + assert!( + instances + .iter() + .any(|row| row.get("instance").and_then(Value::as_str) == Some("memory")), + "instances includes the memory row: {status_result}" + ); + + // ── subconscious.trigger: optional kind echoes back ───────────────────── + let trig = post_json_rpc( + &rpc_base, + 1102, + "openhuman.subconscious_trigger", + json!({ "kind": "tinyplace" }), + ) + .await; + let trig_result = assert_no_jsonrpc_error(&trig, "subconscious_trigger"); + let trig_body = trig_result.get("result").unwrap_or(trig_result); + assert_eq!( + trig_body.get("triggered").and_then(Value::as_bool), + Some(true), + "trigger fire-and-forget acks: {trig_body}" + ); + assert_eq!( + trig_body.get("kind").and_then(Value::as_str), + Some("tinyplace"), + "trigger echoes the requested kind: {trig_body}" + ); + + // An unknown kind is a clean JSON-RPC error, not a panic. + let bad = post_json_rpc( + &rpc_base, + 1103, + "openhuman.subconscious_trigger", + json!({ "kind": "nope" }), + ) + .await; + assert!( + bad.get("error").is_some(), + "unknown kind returns a JSON-RPC error: {bad}" + ); + + rpc_join.abort(); + mock_join.abort(); +} + #[tokio::test] async fn json_rpc_screen_intelligence_status_returns_stable_shape() { let _env_lock = json_rpc_e2e_env_lock();