diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx index 4ef6dcc94..f3404f553 100644 --- a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -1,12 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { setSelectedThread } from '../../store/threadSlice'; import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat'; import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious'; -import SubconsciousReflectionCards from './SubconsciousReflectionCards'; interface ModeOption { id: SubconsciousMode; @@ -70,7 +67,6 @@ export default function IntelligenceSubconsciousTab({ }: IntelligenceSubconsciousTabProps) { const { t } = useT(); const navigate = useNavigate(); - const dispatch = useDispatch(); const providerUnavailable = status?.provider_available === false; const providerUnavailableReason = providerUnavailable ? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle')) @@ -96,11 +92,6 @@ export default function IntelligenceSubconsciousTab({ } }, [localSlider, intervalMinutes, setIntervalMinutes]); - const handleNavigateToThread = (threadId: string) => { - dispatch(setSelectedThread(threadId)); - navigate('/chat'); - }; - const handleRunTick = async () => { try { await triggerTick(); @@ -256,10 +247,11 @@ export default function IntelligenceSubconsciousTab({ )} {isEnabled && ( - +
+

+ {t('subconscious.scratchpadInfo')} +

+
)} ); diff --git a/app/src/components/intelligence/SubconsciousReflectionCards.tsx b/app/src/components/intelligence/SubconsciousReflectionCards.tsx deleted file mode 100644 index ea02af58e..000000000 --- a/app/src/components/intelligence/SubconsciousReflectionCards.tsx +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Reflection card list for the Intelligence tab (#623). - * - * Self-contained component that polls `subconscious_reflections_list`, - * renders a card per reflection with kind chip, action button (only when - * `proposed_action` is non-null), and dismiss button. Optimistic dismiss - * hides the card immediately on tap so the UI feels responsive. - * - * Acting on a reflection drives `actOnReflection`, which **spawns a fresh - * conversation thread** seeded with body + proposed_action and returns - * the new thread id. The component navigates the user (via the - * `onNavigateToThread` callback) into the new conversation. Reflections - * never write into existing threads — every act gets its own thread so - * the user's main chat surface stays uncluttered. - */ -import { useCallback, useEffect, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import { - actOnReflection, - dismissReflection, - listReflections, - type Reflection, - type ReflectionKind, -} from '../../utils/tauriCommands/subconscious'; - -interface SubconsciousReflectionCardsProps { - /** - * Called after a successful "Act" with the freshly-spawned thread id. - * Caller is responsible for routing the user into the new conversation - * (e.g. setting active thread + navigating to the chat surface). - */ - onNavigateToThread?: (threadId: string) => void; - /** - * Polling interval (ms). 0 disables polling — the component will - * fetch once on mount. - */ - pollIntervalMs?: number; - /** - * Test-only seed used by Vitest to bypass the Tauri RPC layer. When - * provided, the component renders these reflections without polling. - */ - initialReflections?: Reflection[]; -} - -const KIND_LABEL: Partial> = { - hotness_spike: 'Hotness spike', - cross_source_pattern: 'Cross-source pattern', - daily_digest: 'Daily digest', - due_item: 'Due item', - risk: 'Risk', - opportunity: 'Opportunity', -}; - -function kindLabel(kind: ReflectionKind, _t: (key: string) => string): string { - return KIND_LABEL[kind] ?? kind; -} - -/** - * Render a `created_at` (epoch seconds, as Rust serializes `f64` from - * `subconscious_reflections.created_at`) into a short relative-time - * label like "Just now", "5m ago", "3h ago", "2d ago". Anything older - * than ~7 days falls back to a fixed `MMM D` so cards aren't ambiguous - * when the user scrolls into older reflections. - */ -function formatRelativeTime(epochSeconds: number, t: (key: string) => string): string { - const nowMs = Date.now(); - const tsMs = epochSeconds * 1000; - const diffSec = Math.max(0, Math.floor((nowMs - tsMs) / 1000)); - if (diffSec < 45) return t('notifications.justNow'); - if (diffSec < 3600) - return t('notifications.minAgo').replace('{n}', String(Math.floor(diffSec / 60))); - if (diffSec < 86_400) - return t('notifications.hrAgo').replace('{n}', String(Math.floor(diffSec / 3600))); - if (diffSec < 604_800) - return t('notifications.dayAgo').replace('{n}', String(Math.floor(diffSec / 86_400))); - return new Date(tsMs).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} - -/** Full ISO-ish datetime for the title-attribute tooltip. */ -function formatAbsoluteTime(epochSeconds: number): string { - return new Date(epochSeconds * 1000).toLocaleString(); -} - -export default function SubconsciousReflectionCards({ - onNavigateToThread, - pollIntervalMs = 0, - initialReflections, -}: SubconsciousReflectionCardsProps) { - const { t } = useT(); - const [reflections, setReflections] = useState(initialReflections ?? []); - const [hiddenIds, setHiddenIds] = useState>(new Set()); - const [loading, setLoading] = useState(initialReflections === undefined); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - if (initialReflections !== undefined) return; // test mode - try { - const resp = await listReflections(50); - const data = resp.result ?? []; - console.debug('[subconscious-ui] reflections list:ok', { count: data.length }); - setReflections(data); - setError(null); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.debug('[subconscious-ui] reflections list:error', { error: msg }); - setError(msg); - } finally { - setLoading(false); - } - }, [initialReflections]); - - useEffect(() => { - // Fire the initial fetch through a microtask so `setState` calls - // inside `refresh` don't run during effect-commit (which trips the - // `react-hooks/set-state-in-effect` lint). - let cancelled = false; - const tick = () => { - if (cancelled) return; - void refresh(); - }; - Promise.resolve().then(tick); - if (pollIntervalMs > 0 && initialReflections === undefined) { - const handle = setInterval(tick, pollIntervalMs); - return () => { - cancelled = true; - clearInterval(handle); - }; - } - return () => { - cancelled = true; - }; - }, [refresh, pollIntervalMs, initialReflections]); - - const handleDismiss = async (id: string) => { - console.debug('[subconscious-ui] reflection dismiss:start', { id }); - setHiddenIds(prev => new Set(prev).add(id)); // optimistic - try { - await dismissReflection(id); - console.debug('[subconscious-ui] reflection dismiss:ok', { id }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.debug('[subconscious-ui] reflection dismiss:error', { id, error: msg }); - // Rollback optimistic hide on failure. - setHiddenIds(prev => { - const next = new Set(prev); - next.delete(id); - return next; - }); - } - }; - - const handleAct = async (reflection: Reflection) => { - console.debug('[subconscious-ui] reflection act:start', { id: reflection.id }); - try { - const resp = await actOnReflection(reflection.id); - console.debug('[subconscious-ui] reflection act:ok', { - id: reflection.id, - thread: resp.result.thread_id, - }); - setHiddenIds(prev => new Set(prev).add(reflection.id)); - onNavigateToThread?.(resp.result.thread_id); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.debug('[subconscious-ui] reflection act:error', { id: reflection.id, error: msg }); - setError(msg); - } - }; - - const visible = reflections.filter( - r => !hiddenIds.has(r.id) && r.dismissed_at === null && r.acted_on_at === null - ); - - if (loading) { - return ( -
- {t('reflections.loading')} -
- ); - } - - if (visible.length === 0 && !error) { - return ( -
- {t('reflections.empty')} -
- ); - } - - // Nested-scroll layout: header is pinned at the top of the cards section, - // the card list below scrolls independently inside `flex-1 overflow-y-auto`. - // `min-h-0` is the Tailwind escape hatch for the flex-overflow gotcha — - // without it, `flex-1` children with overflow won't actually shrink to - // the parent's height and the inner scrollbar never engages. - return ( -
-
-

- - {t('reflections.title')} - - {visible.length} - -

- {error && ( -
- {error} -
- )} -
- {/* - Card list. Two height knobs working together: - * `flex-1 min-h-0` — when an ancestor has a constrained height - (e.g. a panel with `h-full`), the inner scroll area fills the - remaining space and `min-h-0` is the flex-overflow escape - hatch that lets it actually shrink + scroll instead of - blowing the parent's bounds. - * `max-h-[70vh]` — when the cards live inside a flow-sized - container (the current Intelligence tab uses `space-y-6` with - no `h-full`, so the panel just grows with content), this - caps the list at roughly the viewport's upper half. On a - typical laptop the cap is ~720px, which fits ~8 cards - comfortably; on a 720p display it shrinks to ~500px. - Either way the inner list scrolls independently of the rest - of the Subconscious tab once the cap is hit. - */} -
- {visible.map(r => ( -
-
-
-
- - {kindLabel(r.kind, t)} - - - {formatRelativeTime(r.created_at, t)} - -
-

- {r.body} -

- {r.proposed_action && ( -

- {t('reflections.proposedAction')}: {r.proposed_action} -

- )} -
-
- {r.thread_id && ( - - )} - {r.proposed_action && ( - - )} - -
-
-
- ))} -
-
- ); -} diff --git a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx index 37b20c713..b53b672a7 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx @@ -5,27 +5,12 @@ import { fireEvent, render, screen } from '@testing-library/react'; import type { ComponentProps } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setSelectedThread } from '../../../store/threadSlice'; import IntelligenceSubconsciousTab from '../IntelligenceSubconsciousTab'; -const mockDispatch = vi.fn(); const mockNavigate = vi.fn(); -vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch, useSelector: () => 'en' })); - vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate })); -vi.mock('../SubconsciousReflectionCards', () => ({ - default: ({ onNavigateToThread }: { onNavigateToThread?: (id: string) => void }) => ( - - ), -})); - function baseProps(): ComponentProps { return { status: null, @@ -62,27 +47,18 @@ describe('IntelligenceSubconsciousTab', () => { expect(setMode).toHaveBeenCalledWith('simple'); }); - it('hides Run Now and reflections when mode is off', () => { + it('hides Run Now when mode is off', () => { render(); expect(screen.queryByText('Run Now')).not.toBeInTheDocument(); - expect(screen.queryByTestId('cards-stub-trigger')).not.toBeInTheDocument(); }); - it('shows Run Now and reflections when mode is simple', () => { + it('shows Run Now when mode is simple', () => { render(); expect(screen.getByText('Run Now')).toBeInTheDocument(); - expect(screen.getByTestId('cards-stub-trigger')).toBeInTheDocument(); }); it('shows aggressive warning when mode is aggressive', () => { render(); expect(screen.getByText(/full tool access including writes/)).toBeInTheDocument(); }); - - it('on Act → dispatches setSelectedThread + navigates to /chat', () => { - render(); - fireEvent.click(screen.getByTestId('cards-stub-trigger')); - expect(mockDispatch).toHaveBeenCalledWith(setSelectedThread('spawned-thread-42')); - expect(mockNavigate).toHaveBeenCalledWith('/chat'); - }); }); diff --git a/app/src/components/intelligence/__tests__/SubconsciousReflectionCards.test.tsx b/app/src/components/intelligence/__tests__/SubconsciousReflectionCards.test.tsx deleted file mode 100644 index 82288b9c3..000000000 --- a/app/src/components/intelligence/__tests__/SubconsciousReflectionCards.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Vitest for SubconsciousReflectionCards (#623). - * - * Covers: empty state, card rendering with/without proposed_action, - * action button visibility, dismiss optimistic hide, the act → spawn- - * thread RPC wiring, and the onNavigateToThread callback. - */ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../../test/test-utils'; -import { - actOnReflection, - dismissReflection, - listReflections, - type Reflection, -} from '../../../utils/tauriCommands/subconscious'; -import SubconsciousReflectionCards from '../SubconsciousReflectionCards'; - -// Mock just the subconscious tauriCommand surface — leaves the rest of -// the module untouched so the component's static imports don't blow up. -vi.mock('../../../utils/tauriCommands/subconscious', async () => { - const actual = await vi.importActual( - '../../../utils/tauriCommands/subconscious' - ); - return { - ...actual, - listReflections: vi.fn(), - actOnReflection: vi.fn(), - dismissReflection: vi.fn(), - }; -}); - -const mockedListReflections = vi.mocked(listReflections); -const mockedActOnReflection = vi.mocked(actOnReflection); -const mockedDismissReflection = vi.mocked(dismissReflection); - -function refl(overrides: Partial = {}): Reflection { - return { - id: 'r-1', - kind: 'hotness_spike', - body: 'Phoenix surge', - proposed_action: 'Pull mentions', - source_refs: ['entity:phoenix'], - created_at: 1, - acted_on_at: null, - dismissed_at: null, - thread_id: null, - ...overrides, - }; -} - -describe('SubconsciousReflectionCards', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders empty state when no reflections', () => { - renderWithProviders(); - expect(screen.getByTestId('reflection-cards-empty')).toBeTruthy(); - }); - - it('renders reflections with Act + Dismiss buttons when proposed_action is present', () => { - renderWithProviders(); - expect(screen.getByText('Phoenix surge')).toBeTruthy(); - expect(screen.getByText('Hotness spike')).toBeTruthy(); - expect(screen.getByTestId('reflection-act-r-1')).toBeTruthy(); - expect(screen.getByTestId('reflection-dismiss-r-1')).toBeTruthy(); - }); - - it('renders reflections without Act button when proposed_action is null', () => { - renderWithProviders( - - ); - expect(screen.queryByTestId('reflection-act-obs-1')).toBeNull(); - expect(screen.getByTestId('reflection-dismiss-obs-1')).toBeTruthy(); - }); - - it('hides card optimistically on dismiss tap', async () => { - mockedDismissReflection.mockResolvedValueOnce({ result: { dismissed: 'r-1' }, logs: [] }); - renderWithProviders(); - fireEvent.click(screen.getByTestId('reflection-dismiss-r-1')); - await waitFor(() => { - expect(screen.queryByTestId('reflection-card-r-1')).toBeNull(); - }); - expect(mockedDismissReflection).toHaveBeenCalledWith('r-1'); - }); - - it('act fires actOnReflection RPC, hides card, and calls onNavigateToThread with the new thread id', async () => { - mockedActOnReflection.mockResolvedValueOnce({ - result: { reflection_id: 'r-1', thread_id: 'spawned-thread-1' }, - logs: [], - }); - const onNavigateToThread = vi.fn(); - renderWithProviders( - - ); - fireEvent.click(screen.getByTestId('reflection-act-r-1')); - await waitFor(() => { - expect(mockedActOnReflection).toHaveBeenCalledWith('r-1'); - }); - await waitFor(() => { - expect(screen.queryByTestId('reflection-card-r-1')).toBeNull(); - }); - expect(onNavigateToThread).toHaveBeenCalledWith('spawned-thread-1'); - }); - - it('hides reflections that already have dismissed_at or acted_on_at', () => { - renderWithProviders( - - ); - expect(screen.getByTestId('reflection-card-visible')).toBeTruthy(); - expect(screen.queryByTestId('reflection-card-gone-acted')).toBeNull(); - expect(screen.queryByTestId('reflection-card-gone-dismissed')).toBeNull(); - }); - - it('fetches reflections on mount via listReflections (when no initial seed)', async () => { - mockedListReflections.mockResolvedValueOnce({ result: [refl({ id: 'fetched' })], logs: [] }); - renderWithProviders(); - await waitFor(() => { - expect(mockedListReflections).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(screen.getByTestId('reflection-card-fetched')).toBeTruthy(); - }); - }); - - it('shows the error banner when listReflections rejects', async () => { - mockedListReflections.mockRejectedValueOnce(new Error('boom: rpc unreachable')); - renderWithProviders(); - await waitFor(() => { - expect(screen.getByTestId('reflection-cards-error')).toBeTruthy(); - }); - expect(screen.getByTestId('reflection-cards-error').textContent).toContain( - 'boom: rpc unreachable' - ); - }); - - it('rolls the optimistic dismiss back when dismissReflection rejects', async () => { - mockedDismissReflection.mockRejectedValueOnce(new Error('rpc denied')); - renderWithProviders(); - fireEvent.click(screen.getByTestId('reflection-dismiss-r-1')); - // First the card disappears (optimistic), then it comes back when the - // rejection lands in the catch handler — the rollback path is what - // bumps coverage on the otherwise-untested catch branch. - await waitFor(() => { - expect(screen.queryByTestId('reflection-card-r-1')).toBeNull(); - }); - await waitFor(() => { - expect(screen.getByTestId('reflection-card-r-1')).toBeTruthy(); - }); - }); - - it('surfaces the error banner when actOnReflection rejects', async () => { - mockedActOnReflection.mockRejectedValueOnce(new Error('act failed')); - const onNavigateToThread = vi.fn(); - renderWithProviders( - - ); - fireEvent.click(screen.getByTestId('reflection-act-r-1')); - await waitFor(() => { - expect(screen.getByTestId('reflection-cards-error')).toBeTruthy(); - }); - // Card stays visible (act failed → no optimistic hide finalises) and - // the navigate callback is *not* fired. - expect(screen.getByTestId('reflection-card-r-1')).toBeTruthy(); - expect(onNavigateToThread).not.toHaveBeenCalled(); - }); -}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index ae9c03912..2acdedf2e 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2074,6 +2074,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'تشغيل الآن', 'subconscious.providerUnavailableTitle': 'تم إيقاف اللاوعي مؤقتًا', 'subconscious.providerSettings': 'إعدادات الذكاء الاصطناعي', + 'subconscious.scratchpadInfo': + 'يحتفظ اللاوعي بدفتر ملاحظات مستمر للملاحظات عبر الدورات. تحقق من الإعدادات → وصول الوكيل لتكوين الوضع والتكرار.', 'subconscious.approvalNeeded': 'يلزم الموافقة', 'subconscious.requiresApproval': 'يتطلب الموافقة', 'subconscious.fixInConnections': 'إصلاح في الاتصالات', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 36a446241..1c8dbc65c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2116,6 +2116,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'এখনই চালান', 'subconscious.providerUnavailableTitle': 'Subconscious বিরত আছে', 'subconscious.providerSettings': 'AI সেটিংস', + 'subconscious.scratchpadInfo': + 'সাবকনশাস টিক জুড়ে পর্যবেক্ষণের একটি স্থায়ী স্ক্র্যাচপ্যাড বজায় রাখে। মোড এবং ফ্রিকোয়েন্সি কনফিগার করতে সেটিংস → এজেন্ট অ্যাক্সেস দেখুন।', 'subconscious.approvalNeeded': 'অনুমোদন প্রয়োজন', 'subconscious.requiresApproval': 'অনুমোদন প্রয়োজন', 'subconscious.fixInConnections': 'সংযোগে ঠিক করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 61182c40e..6641c12e5 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2168,6 +2168,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Jetzt ausführen', 'subconscious.providerUnavailableTitle': 'Unterbewusstsein ist pausiert', 'subconscious.providerSettings': 'KI-Einstellungen', + 'subconscious.scratchpadInfo': + 'Das Unterbewusstsein führt ein dauerhaftes Notizbuch mit Beobachtungen über Ticks hinweg. Überprüfen Sie Einstellungen → Agentenzugriff, um Modus und Häufigkeit zu konfigurieren.', 'subconscious.approvalNeeded': 'Genehmigung erforderlich', 'subconscious.requiresApproval': 'Erfordert eine Genehmigung', 'subconscious.fixInConnections': 'Fix in Verbindungen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ddbdbca27..8e9999aa5 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2579,6 +2579,8 @@ const en: TranslationMap = { 'subconscious.runNow': 'Run Now', 'subconscious.providerUnavailableTitle': 'Subconscious is paused', 'subconscious.providerSettings': 'AI settings', + 'subconscious.scratchpadInfo': + 'The subconscious maintains a persistent scratchpad of observations across ticks. Check Settings → Agent access to configure mode and frequency.', 'subconscious.approvalNeeded': 'Approval Needed', 'subconscious.requiresApproval': 'Requires approval', 'subconscious.fixInConnections': 'Fix in Connections', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 2ae302ab8..c2f1f2bc7 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2160,6 +2160,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Ejecutar ahora', 'subconscious.providerUnavailableTitle': 'Subconsciente en pausa', 'subconscious.providerSettings': 'Ajustes de IA', + 'subconscious.scratchpadInfo': + 'El subconsciente mantiene un bloc de notas persistente de observaciones a través de los ciclos. Consulta Configuración → Acceso del agente para configurar el modo y la frecuencia.', 'subconscious.approvalNeeded': 'Se necesita aprobación', 'subconscious.requiresApproval': 'Requiere aprobación', 'subconscious.fixInConnections': 'Corregir en Conexiones', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 454b06080..c54401200 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2166,6 +2166,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Exécuter maintenant', 'subconscious.providerUnavailableTitle': 'Subconscient en pause', 'subconscious.providerSettings': 'Paramètres IA', + 'subconscious.scratchpadInfo': + "Le subconscient maintient un bloc-notes persistant d'observations à travers les cycles. Consultez Paramètres → Accès agent pour configurer le mode et la fréquence.", 'subconscious.approvalNeeded': 'Approbation requise', 'subconscious.requiresApproval': 'Nécessite une approbation', 'subconscious.fixInConnections': 'Corriger dans Connexions', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 90fc36dc7..f33dd836a 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2115,6 +2115,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'अभी चलाएं', 'subconscious.providerUnavailableTitle': 'Subconscious रुका हुआ है', 'subconscious.providerSettings': 'AI सेटिंग्स', + 'subconscious.scratchpadInfo': + 'सबकॉन्शस टिक्स में अवलोकनों का एक स्थायी स्क्रैचपैड बनाए रखता है। मोड और आवृत्ति कॉन्फ़िगर करने के लिए सेटिंग्स → एजेंट एक्सेस देखें।', 'subconscious.approvalNeeded': 'अनुमति चाहिए', 'subconscious.requiresApproval': 'अनुमति ज़रूरी है', 'subconscious.fixInConnections': 'Connections में ठीक करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 123674527..85b4e6a89 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2118,6 +2118,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Jalankan Sekarang', 'subconscious.providerUnavailableTitle': 'Subconscious dijeda', 'subconscious.providerSettings': 'Pengaturan AI', + 'subconscious.scratchpadInfo': + 'Alam bawah sadar memelihara catatan pengamatan yang persisten di seluruh siklus. Periksa Pengaturan → Akses agen untuk mengonfigurasi mode dan frekuensi.', 'subconscious.approvalNeeded': 'Persetujuan Diperlukan', 'subconscious.requiresApproval': 'Memerlukan persetujuan', 'subconscious.fixInConnections': 'Perbaiki di Koneksi', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 03454a154..700854679 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2152,6 +2152,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Esegui ora', 'subconscious.providerUnavailableTitle': 'Subconscio in pausa', 'subconscious.providerSettings': 'Impostazioni IA', + 'subconscious.scratchpadInfo': + 'Il subconscio mantiene un blocco appunti persistente di osservazioni attraverso i cicli. Controlla Impostazioni → Accesso agente per configurare modalità e frequenza.', 'subconscious.approvalNeeded': 'Approvazione necessaria', 'subconscious.requiresApproval': 'Richiede approvazione', 'subconscious.fixInConnections': 'Correggi in Connessioni', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 3b447915a..1fb12b207 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2094,6 +2094,8 @@ const messages: TranslationMap = { 'subconscious.runNow': '지금 실행', 'subconscious.providerUnavailableTitle': 'Subconscious 일시 중지됨', 'subconscious.providerSettings': 'AI 설정', + 'subconscious.scratchpadInfo': + '잠재의식은 틱 전반에 걸쳐 관찰 사항의 영구 메모장을 유지합니다. 모드와 빈도를 구성하려면 설정 → 에이전트 접근을 확인하세요.', 'subconscious.approvalNeeded': '승인 필요', 'subconscious.requiresApproval': '승인이 필요함', 'subconscious.fixInConnections': '연결에서 수정', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index d1c1155e1..e749afb0b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2139,6 +2139,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Uruchom teraz', 'subconscious.providerUnavailableTitle': 'Podświadomość wstrzymana', 'subconscious.providerSettings': 'Ustawienia AI', + 'subconscious.scratchpadInfo': + 'Podświadomość utrzymuje trwały notatnik obserwacji między cyklami. Sprawdź Ustawienia → Dostęp agenta, aby skonfigurować tryb i częstotliwość.', 'subconscious.approvalNeeded': 'Wymagana zgoda', 'subconscious.requiresApproval': 'Wymaga zgody', 'subconscious.fixInConnections': 'Napraw w Połączeniach', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 880558f4b..b07e798d3 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2156,6 +2156,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Executar Agora', 'subconscious.providerUnavailableTitle': 'Subconsciente pausado', 'subconscious.providerSettings': 'Configurações de IA', + 'subconscious.scratchpadInfo': + 'O subconsciente mantém um bloco de notas persistente de observações entre ciclos. Verifique Configurações → Acesso do agente para configurar o modo e a frequência.', 'subconscious.approvalNeeded': 'Aprovação Necessária', 'subconscious.requiresApproval': 'Requer aprovação', 'subconscious.fixInConnections': 'Corrigir em Conexões', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8f45117ef..176cc5a1b 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2133,6 +2133,8 @@ const messages: TranslationMap = { 'subconscious.runNow': 'Запустить сейчас', 'subconscious.providerUnavailableTitle': 'Подсознание приостановлено', 'subconscious.providerSettings': 'Настройки ИИ', + 'subconscious.scratchpadInfo': + 'Подсознание ведёт постоянный блокнот наблюдений между циклами. Проверьте Настройки → Доступ агента для настройки режима и частоты.', 'subconscious.approvalNeeded': 'Требуется подтверждение', 'subconscious.requiresApproval': 'Требует подтверждения', 'subconscious.fixInConnections': 'Исправить в подключениях', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index a2b9e6984..cca0eba85 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2011,6 +2011,8 @@ const messages: TranslationMap = { 'subconscious.runNow': '立即运行', 'subconscious.providerUnavailableTitle': '潜意识已暂停', 'subconscious.providerSettings': 'AI 设置', + 'subconscious.scratchpadInfo': + '潜意识在每次循环中维护一个持久的观察记事本。请查看设置 → 代理访问来配置模式和频率。', 'subconscious.approvalNeeded': '需要审批', 'subconscious.requiresApproval': '需要审批', 'subconscious.fixInConnections': '在连接中修复', diff --git a/app/src/utils/tauriCommands/subconscious.test.ts b/app/src/utils/tauriCommands/subconscious.test.ts deleted file mode 100644 index 802a68aa8..000000000 --- a/app/src/utils/tauriCommands/subconscious.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Vitest for the subconscious tauriCommands surface (#623). - * - * Covers the three RPC wrappers — `listReflections`, `actOnReflection`, - * `dismissReflection` — plus their `isTauri()` guard. Mirrors the - * mocking pattern used by `config.test.ts` and `core.test.ts` so the - * wrappers are validated against the live `callCoreRpc` contract - * without spinning up a real Tauri runtime. - */ -import { isTauri } from '@tauri-apps/api/core'; -import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; - -import { callCoreRpc } from '../../services/coreRpcClient'; - -vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() })); -vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); - -describe('tauriCommands/subconscious', () => { - const mockIsTauri = isTauri as Mock; - const mockCallCoreRpc = callCoreRpc as Mock; - let listReflections: typeof import('./subconscious').listReflections; - let actOnReflection: typeof import('./subconscious').actOnReflection; - let dismissReflection: typeof import('./subconscious').dismissReflection; - - beforeEach(async () => { - vi.clearAllMocks(); - mockIsTauri.mockReturnValue(true); - const actual = await vi.importActual('./subconscious'); - listReflections = actual.listReflections; - actOnReflection = actual.actOnReflection; - dismissReflection = actual.dismissReflection; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('listReflections', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(listReflections()).rejects.toThrow('Not running in Tauri'); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('forwards default limit and omits since_ts when absent', async () => { - mockCallCoreRpc.mockResolvedValue({ result: [], logs: [] }); - await listReflections(); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.subconscious_reflections_list', - params: { limit: 50 }, - }); - }); - - test('passes through explicit limit + since_ts when supplied', async () => { - mockCallCoreRpc.mockResolvedValue({ result: [], logs: [] }); - await listReflections(20, 1700000000); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.subconscious_reflections_list', - params: { limit: 20, since_ts: 1700000000 }, - }); - }); - }); - - describe('actOnReflection', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(actOnReflection('r-1')).rejects.toThrow('Not running in Tauri'); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('dispatches openhuman.subconscious_reflections_act with reflection_id', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { reflection_id: 'r-1', thread_id: 'thr-9' }, - logs: [], - }); - const resp = await actOnReflection('r-1'); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.subconscious_reflections_act', - params: { reflection_id: 'r-1' }, - }); - expect(resp.result.thread_id).toBe('thr-9'); - }); - }); - - describe('dismissReflection', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(dismissReflection('r-1')).rejects.toThrow('Not running in Tauri'); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('dispatches openhuman.subconscious_reflections_dismiss with reflection_id', async () => { - mockCallCoreRpc.mockResolvedValue({ result: { dismissed: 'r-1' }, logs: [] }); - await dismissReflection('r-1'); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.subconscious_reflections_dismiss', - params: { reflection_id: 'r-1' }, - }); - }); - }); -}); diff --git a/app/src/utils/tauriCommands/subconscious.ts b/app/src/utils/tauriCommands/subconscious.ts index 83a443a9d..1fb7b0b6a 100644 --- a/app/src/utils/tauriCommands/subconscious.ts +++ b/app/src/utils/tauriCommands/subconscious.ts @@ -1,5 +1,8 @@ /** - * Subconscious engine commands — thoughts (reflections) and engine control. + * Subconscious engine commands — engine control and scratchpad. + * + * Reflection/thoughts RPCs have been removed — the subconscious now + * maintains only a scratchpad (via agent tools) and run logs. */ import { callCoreRpc } from '../../services/coreRpcClient'; import { type CommandResponse, isTauri } from './common'; @@ -19,9 +22,8 @@ export interface SubconsciousStatus { export interface TickResult { tick_at: number; - thoughts_count: number; - thread_id: string | null; duration_ms: number; + response_chars?: number; } // ── Status & Trigger ───────────────────────────────────────────────────────── @@ -39,66 +41,3 @@ export async function subconsciousTrigger(): Promise method: 'openhuman.subconscious_trigger', }); } - -// ── Thoughts (Reflections) ────────────────────────────────────────────────── - -export type ReflectionKind = - | 'hotness_spike' - | 'cross_source_pattern' - | 'daily_digest' - | 'due_item' - | 'risk' - | 'opportunity'; - -export interface SourceChunk { - ref_id: string; - kind: string; - content: string; - metadata?: unknown; -} - -export interface Reflection { - id: string; - kind: ReflectionKind; - body: string; - proposed_action: string | null; - source_refs: string[]; - source_chunks?: SourceChunk[]; - created_at: number; - acted_on_at: number | null; - dismissed_at: number | null; - thread_id: string | null; -} - -export async function listReflections( - limit = 50, - sinceTs?: number -): Promise> { - if (!isTauri()) throw new Error('Not running in Tauri'); - const params: Record = { limit }; - if (sinceTs !== undefined) params.since_ts = sinceTs; - return await callCoreRpc>({ - method: 'openhuman.subconscious_reflections_list', - params, - }); -} - -export async function actOnReflection( - reflectionId: string -): Promise> { - if (!isTauri()) throw new Error('Not running in Tauri'); - return await callCoreRpc>({ - method: 'openhuman.subconscious_reflections_act', - params: { reflection_id: reflectionId }, - }); -} - -export async function dismissReflection( - reflectionId: string -): Promise> { - if (!isTauri()) throw new Error('Not running in Tauri'); - return await callCoreRpc>({ - method: 'openhuman.subconscious_reflections_dismiss', - params: { reflection_id: reflectionId }, - }); -} diff --git a/src/core/cli.rs b/src/core/cli.rs index 1c7063d78..a05c518e8 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -73,6 +73,9 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { ) } "memory" => crate::core::memory_cli::run_memory_command(&args[1..]), + "subconscious" | "sub" => { + crate::core::subconscious_cli::run_subconscious_command(&args[1..]) + } "agent" => { log::debug!( "[cli] dispatching to agent subcommand, args={:?}", diff --git a/src/core/mod.rs b/src/core/mod.rs index 3a2f185a9..52c448e4a 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -23,6 +23,7 @@ pub mod rpc_log; pub mod runtime; pub mod shutdown; pub mod socketio; +pub mod subconscious_cli; pub mod types; /// Canonical function contract for domain controllers. diff --git a/src/core/subconscious_cli.rs b/src/core/subconscious_cli.rs new file mode 100644 index 000000000..81fde3430 --- /dev/null +++ b/src/core/subconscious_cli.rs @@ -0,0 +1,286 @@ +//! `openhuman subconscious` — CLI for testing and debugging the subconscious loop. +//! +//! Usage: +//! openhuman subconscious tick [--workspace ] [--mode simple|aggressive] [--verbose] +//! openhuman subconscious status [--workspace ] +//! openhuman subconscious scratchpad [--workspace ] + +use anyhow::{anyhow, Result}; +use std::path::PathBuf; + +pub fn run_subconscious_command(args: &[String]) -> Result<()> { + if args.is_empty() || is_help(&args[0]) { + print_help(); + return Ok(()); + } + + match args[0].as_str() { + "tick" => run_tick(&args[1..]), + "status" => run_status(&args[1..]), + "scratchpad" | "pad" => run_scratchpad(&args[1..]), + other => Err(anyhow!( + "unknown subconscious subcommand '{other}'. Run `openhuman subconscious --help`." + )), + } +} + +// ── tick ──────────────────────────────────────────────────────────────────── + +struct TickFlags { + workspace: Option, + mode: Option, + verbose: bool, +} + +fn parse_tick_flags(args: &[String]) -> Result { + let mut workspace: Option = None; + let mut mode: Option = None; + let mut verbose = false; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--workspace" | "-w" => { + workspace = Some(PathBuf::from( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --workspace"))?, + )); + i += 2; + } + "--mode" | "-m" => { + mode = Some( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --mode"))? + .clone(), + ); + i += 2; + } + "--verbose" | "-v" => { + verbose = true; + i += 1; + } + other => return Err(anyhow!("unknown flag '{other}'")), + } + } + Ok(TickFlags { + workspace, + mode, + verbose, + }) +} + +fn run_tick(args: &[String]) -> Result<()> { + let flags = parse_tick_flags(args)?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let mut config = crate::openhuman::config::Config::load_or_init() + .await + .map_err(|e| anyhow!("config load failed: {e}"))?; + + if let Some(ws) = &flags.workspace { + config.workspace_dir = ws.clone(); + } + + if let Some(mode_str) = &flags.mode { + config.heartbeat.subconscious_mode = match mode_str.as_str() { + "simple" => crate::openhuman::config::schema::SubconsciousMode::Simple, + "aggressive" => crate::openhuman::config::schema::SubconsciousMode::Aggressive, + other => { + return Err(anyhow!( + "unknown mode '{other}', expected simple|aggressive" + )) + } + }; + config.heartbeat.enabled = true; + config.heartbeat.inference_enabled = true; + } + + // Ensure subconscious is enabled + if !config.heartbeat.enabled || !config.heartbeat.inference_enabled { + config.heartbeat.enabled = true; + config.heartbeat.inference_enabled = true; + if !config.heartbeat.subconscious_mode.is_enabled() { + config.heartbeat.subconscious_mode = + crate::openhuman::config::schema::SubconsciousMode::Simple; + } + } + + let mode = config.heartbeat.effective_subconscious_mode(); + eprintln!( + "[subconscious] mode={} workspace={}", + mode.as_str(), + config.workspace_dir.display() + ); + + // Init memory client + let _ = crate::openhuman::memory::global::init(config.workspace_dir.clone()); + + // Init scheduler gate so is_signed_out() works + crate::openhuman::scheduler_gate::init_global(&config); + + // Seed signed_out from session token + match crate::api::jwt::get_session_token(&config) { + Ok(Some(_)) => { + crate::openhuman::scheduler_gate::set_signed_out(false); + eprintln!("[subconscious] session token found — provider available"); + } + Ok(None) => { + eprintln!("[subconscious] WARNING: no session token — cloud provider will fail"); + eprintln!(" hint: run `openhuman call auth store_session --token ` first"); + } + Err(e) => { + eprintln!("[subconscious] WARNING: session token read failed: {e}"); + } + } + + // Check provider availability + if let Some(reason) = + crate::openhuman::subconscious::engine::subconscious_provider_unavailable_reason( + &config, + ) + { + eprintln!("[subconscious] provider unavailable: {reason}"); + return Err(anyhow!("provider unavailable: {reason}")); + } + + // Create engine and run tick + let memory = crate::openhuman::memory::global::client_if_ready(); + let engine = crate::openhuman::subconscious::SubconsciousEngine::new(&config, memory); + + eprintln!("[subconscious] running tick..."); + let result = engine + .tick() + .await + .map_err(|e| anyhow!("tick failed: {e}"))?; + + eprintln!( + "[subconscious] tick complete: duration={}ms response_chars={}", + result.duration_ms, result.response_chars, + ); + + if flags.verbose { + // Print scratchpad state after tick + let entries = crate::openhuman::subconscious::scratchpad::load(&config.workspace_dir) + .unwrap_or_default(); + if !entries.is_empty() { + eprintln!("\n[subconscious] scratchpad after tick:"); + println!("{}", serde_json::to_string_pretty(&entries)?); + } + } + + Ok(()) + }) +} + +// ── status ───────────────────────────────────────────────────────────────── + +fn run_status(args: &[String]) -> Result<()> { + let workspace = parse_workspace_flag(args)?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let mut config = crate::openhuman::config::Config::load_or_init() + .await + .map_err(|e| anyhow!("config load failed: {e}"))?; + if let Some(ws) = workspace { + config.workspace_dir = ws; + } + + let mode = config.heartbeat.effective_subconscious_mode(); + let provider_reason = if mode.is_enabled() { + crate::openhuman::subconscious::engine::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 status = serde_json::json!({ + "mode": mode.as_str(), + "enabled": mode.is_enabled(), + "provider_available": provider_reason.is_none(), + "provider_unavailable_reason": provider_reason, + "last_tick_at": last_tick.filter(|v| *v > 0.0), + "interval_minutes": mode.default_interval_minutes().max(5), + }); + + println!("{}", serde_json::to_string_pretty(&status)?); + Ok(()) + }) +} + +// ── scratchpad ───────────────────────────────────────────────────────────── + +fn run_scratchpad(args: &[String]) -> Result<()> { + let workspace = parse_workspace_flag(args)?; + + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let mut config = crate::openhuman::config::Config::load_or_init() + .await + .map_err(|e| anyhow!("config load failed: {e}"))?; + if let Some(ws) = workspace { + config.workspace_dir = ws; + } + + let entries = crate::openhuman::subconscious::scratchpad::load(&config.workspace_dir) + .map_err(|e| anyhow!("failed to read scratchpad: {e}"))?; + + if entries.is_empty() { + eprintln!("(scratchpad empty)"); + } else { + println!("{}", serde_json::to_string_pretty(&entries)?); + } + Ok(()) + }) +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +fn parse_workspace_flag(args: &[String]) -> Result> { + let mut workspace: Option = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--workspace" | "-w" => { + workspace = Some(PathBuf::from( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing --workspace"))?, + )); + i += 2; + } + other => return Err(anyhow!("unknown flag '{other}'")), + } + } + Ok(workspace) +} + +fn is_help(s: &str) -> bool { + matches!(s, "--help" | "-h" | "help") +} + +fn print_help() { + eprintln!( + "Usage: openhuman subconscious [options] + +Commands: + tick Run a single subconscious tick (synchronous, waits for completion) + status Show current subconscious engine status + scratchpad Dump the persistent scratchpad + +Tick options: + --mode Override the subconscious mode + --workspace Override workspace directory + --verbose, -v Print scratchpad after tick + +Common options: + --workspace Override workspace directory +" + ); +} diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index ee5ea0004..131edd632 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -225,8 +225,10 @@ pub struct AgentDefinition { /// hands off to `Reasoning` or `Worker`, never to itself. /// * `Reasoning` MUST NOT list another `Reasoning` agent in /// `subagents`. Reasoning composes downward into `Worker`s. - /// * `Worker` MUST NOT list any subagents. Workers execute; they - /// do not orchestrate. + /// * `Worker` MUST NOT list open-ended subagents. Workers execute; + /// they do not orchestrate. The hidden `call_memory_agent` tool may + /// still use `agent_memory` in this policy so memory retrieval is + /// gated without adding visible delegation tools. /// * `{ skills = "*" }` entries expand to the generic /// `integrations_agent` (a `Worker`) so they are always allowed. /// @@ -259,9 +261,10 @@ pub struct AgentDefinition { /// ``` /// /// `Chat` and `Reasoning` are forbidden from spawning their own tier; -/// `Worker` is forbidden from spawning anything. Total depth is capped -/// at three hops by the harness regardless of tier (defence in depth -/// against custom TOMLs that drop the tier annotation). +/// `Worker` is forbidden from spawning anything except the hidden +/// `agent_memory` retrieval specialist. Total depth is capped at three +/// hops by the harness regardless of tier (defence in depth against +/// custom TOMLs that drop the tier annotation). #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum AgentTier { diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 71445ed0e..c0ffdf2c3 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -1,6 +1,6 @@ //! Tests for the builder module — dedup_visible_tool_specs and related logic. -use super::dedup_visible_tool_specs; +use super::{dedup_visible_tool_specs, should_synthesize_delegation_tools}; use crate::openhuman::tools::ToolSpec; use serde_json::json; @@ -71,3 +71,25 @@ fn preserves_full_spec_content_for_kept_entries() { json!({"type": "object", "required": ["x"]}) ); } + +#[test] +fn memory_only_subagent_policy_does_not_synthesize_delegate_tools() { + let defs = crate::openhuman::agent_registry::agents::load_builtins().unwrap(); + let help = defs + .iter() + .find(|def| def.id == "help") + .expect("help agent is built in"); + let orchestrator = defs + .iter() + .find(|def| def.id == "orchestrator") + .expect("orchestrator is built in"); + + assert!( + !should_synthesize_delegation_tools(help), + "memory-only subagent policy should gate call_memory_agent without adding delegate tools" + ); + assert!( + should_synthesize_delegation_tools(orchestrator), + "orchestrator still needs synthesized delegate tools" + ); +} diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 6eb45eed2..05463fe6c 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -2,6 +2,7 @@ //! `build_session_agent_inner` constructor. use super::helpers::prefetch_tool_memory_rules_blocking; +use super::should_synthesize_delegation_tools; use crate::openhuman::agent::dispatcher::{ NativeToolDispatcher, PFormatToolDispatcher, XmlToolDispatcher, }; @@ -740,11 +741,15 @@ impl Agent { crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global(), ) { (Some(def), Some(reg)) => { - let synthed = tools::orchestrator_tools::collect_orchestrator_tools( - def, - reg, - prewarmed_integrations_slice, - ); + let synthed = if should_synthesize_delegation_tools(def) { + tools::orchestrator_tools::collect_orchestrator_tools( + def, + reg, + prewarmed_integrations_slice, + ) + } else { + Vec::new() + }; let filter: Option> = match &def.tools { ToolScope::Named(names) => { let mut set: std::collections::HashSet = diff --git a/src/openhuman/agent/harness/session/builder/mod.rs b/src/openhuman/agent/harness/session/builder/mod.rs index c7b29ea21..88e164f68 100644 --- a/src/openhuman/agent/harness/session/builder/mod.rs +++ b/src/openhuman/agent/harness/session/builder/mod.rs @@ -13,6 +13,7 @@ mod setters; #[cfg(test)] mod builder_tests; +use crate::openhuman::agent::harness::definition::{AgentDefinition, ToolScope}; use crate::openhuman::agent_tool_policy::ToolPolicySession; use crate::openhuman::tools::ToolSpec; @@ -64,3 +65,18 @@ pub(super) fn visible_tool_specs_for_policy( .cloned() .collect() } + +pub(super) fn should_synthesize_delegation_tools(def: &AgentDefinition) -> bool { + match &def.tools { + ToolScope::Wildcard => !def.subagents.is_empty(), + ToolScope::Named(names) => names.iter().any(|name| { + matches!( + name.as_str(), + "spawn_subagent" + | "spawn_async_subagent" + | "spawn_parallel_agents" + | "spawn_worker_thread" + ) + }), + } +} diff --git a/src/openhuman/agent_memory/tools.rs b/src/openhuman/agent_memory/tools.rs index 2b89357a4..d8bbba13d 100644 --- a/src/openhuman/agent_memory/tools.rs +++ b/src/openhuman/agent_memory/tools.rs @@ -130,6 +130,20 @@ impl Tool for CallMemoryAgentTool { ) })?; + let parent = parent.expect("checked above"); + if !parent.allowed_subagent_ids.contains(AGENT_ID) { + log::warn!( + "[call_memory_agent] blocked memory subagent outside parent allowlist parent_agent={} requested_agent={} allowed={:?}", + parent.agent_definition_id, + AGENT_ID, + parent.allowed_subagent_ids + ); + return Ok(ToolResult::error(format!( + "call_memory_agent: agent '{AGENT_ID}' is not in parent agent '{}' subagents.allowlist", + parent.agent_definition_id + ))); + } + let mut prompt = format!( "Search the user's memory tree and return relevant context for this query:\n\n{query}" ); diff --git a/src/openhuman/agent_registry/agents/crypto_agent/agent.toml b/src/openhuman/agent_registry/agents/crypto_agent/agent.toml index d5a4a7056..403039c27 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/crypto_agent/agent.toml @@ -18,6 +18,9 @@ omit_skills_catalog = true [model] hint = "agentic" +[subagents] +allowlist = ["agent_memory"] + [tools] # Narrow allowlist. Wallet + web3 + market primitives only — no shell, no # file_write, no broad HTTP, no integration delegation. Wallet names line up diff --git a/src/openhuman/agent_registry/agents/help/agent.toml b/src/openhuman/agent_registry/agents/help/agent.toml index a58508a99..36e279ae0 100644 --- a/src/openhuman/agent_registry/agents/help/agent.toml +++ b/src/openhuman/agent_registry/agents/help/agent.toml @@ -20,5 +20,8 @@ omit_memory_md = false [model] hint = "agentic" +[subagents] +allowlist = ["agent_memory"] + [tools] named = ["gitbooks_search", "gitbooks_get_page", "call_memory_agent"] diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 18f309a01..578b5510d 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -193,6 +193,11 @@ pub const BUILTINS: &[BuiltinAgent] = &[ toml: include_str!("../../agent_memory/agent/agent.toml"), prompt_fn: crate::openhuman::agent_memory::agent::prompt::build, }, + BuiltinAgent { + id: "subconscious", + toml: include_str!("../../subconscious/agent/agent.toml"), + prompt_fn: crate::openhuman::subconscious::agent::prompt::build, + }, ]; /// Parse every entry in [`BUILTINS`] into an [`AgentDefinition`]. @@ -245,12 +250,16 @@ pub fn validate_tier_hierarchy(defs: &[AgentDefinition]) -> Result<()> { SubagentEntry::Skills(_) => continue, }; - // Worker leaves: no spawn surface at all. - if def.agent_tier == AgentTier::Worker { + // Worker leaves: no open-ended spawn surface. A worker may still + // name `agent_memory` so the hidden `call_memory_agent` tool can + // be policy-gated by the same parent-context allowlist without + // synthesising visible delegate tools. + if def.agent_tier == AgentTier::Worker && child_id != "agent_memory" { anyhow::bail!( - "agent `{parent}` is a `worker` tier and must not list `{child}` (or any \ - agent) in its subagents — workers are leaf executors. Either remove the \ - entry or re-tier `{parent}` as `chat` / `reasoning`.", + "agent `{parent}` is a `worker` tier and must not list `{child}` in its \ + subagents — workers are leaf executors except for the hidden `agent_memory` \ + retrieval policy. Either remove the entry or re-tier `{parent}` as `chat` / \ + `reasoning`.", parent = def.id, child = child_id, ); @@ -317,7 +326,9 @@ fn parse_builtin(b: &BuiltinAgent) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::openhuman::agent::harness::definition::{ModelSpec, SandboxMode, ToolScope}; + use crate::openhuman::agent::harness::definition::{ + ModelSpec, SandboxMode, SubagentEntry, ToolScope, + }; #[test] fn all_builtins_parse() { @@ -325,6 +336,27 @@ mod tests { assert_eq!(defs.len(), BUILTINS.len()); } + #[test] + fn call_memory_agent_users_allow_agent_memory_subagent() { + for def in load_builtins().expect("built-in TOML must parse") { + let uses_call_memory_agent = match &def.tools { + ToolScope::Named(tools) => tools.iter().any(|tool| tool == "call_memory_agent"), + ToolScope::Wildcard => false, + }; + if !uses_call_memory_agent { + continue; + } + + assert!( + def.subagents.iter().any(|entry| { + matches!(entry, SubagentEntry::AgentId(id) if id == "agent_memory") + }), + "{} exposes call_memory_agent but does not allow agent_memory in subagents", + def.id + ); + } + } + #[test] fn trigger_reactor_has_agentic_hint_and_narrow_tools() { let def = find("trigger_reactor"); @@ -1056,6 +1088,8 @@ mod tests { #[test] fn control_specialists_have_named_tools_and_are_worker_leaves() { + use crate::openhuman::agent::harness::definition::SubagentEntry; + for expected in [ "task_manager_agent", "settings_agent", @@ -1065,7 +1099,18 @@ mod tests { ] { let def = find(expected); assert_eq!(def.agent_tier, AgentTier::Worker); - assert!(def.subagents.is_empty(), "{expected} must be a worker leaf"); + let visible_subagents: Vec<&str> = def + .subagents + .iter() + .filter_map(|entry| match entry { + SubagentEntry::AgentId(id) if id != "agent_memory" => Some(id.as_str()), + _ => None, + }) + .collect(); + assert!( + visible_subagents.is_empty(), + "{expected} must be a worker leaf except for hidden agent_memory lookup" + ); match def.tools { ToolScope::Named(tools) => { assert!( @@ -1103,13 +1148,13 @@ mod tests { #[test] fn other_builtins_default_to_worker_tier() { for def in load_builtins().unwrap() { - if def.id == "orchestrator" || def.id == "planner" { + if def.id == "orchestrator" || def.id == "planner" || def.id == "subconscious" { continue; } assert_eq!( def.agent_tier, AgentTier::Worker, - "{} should default to worker tier (only orchestrator/planner are non-worker today)", + "{} should default to worker tier (only orchestrator/planner/subconscious are non-worker today)", def.id ); } diff --git a/src/openhuman/agent_registry/agents/markets_agent/agent.toml b/src/openhuman/agent_registry/agents/markets_agent/agent.toml index 46d5fa819..580bb9f6d 100644 --- a/src/openhuman/agent_registry/agents/markets_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/markets_agent/agent.toml @@ -18,6 +18,9 @@ omit_skills_catalog = true [model] hint = "agentic" +[subagents] +allowlist = ["agent_memory"] + [tools] # Narrow allowlist. Prediction-market venues only — no shell, no # file_write, no broad HTTP, no integration delegation, no wallet diff --git a/src/openhuman/agent_registry/agents/orchestrator/agent.toml b/src/openhuman/agent_registry/agents/orchestrator/agent.toml index 96246fd54..c16d6ef27 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/agent.toml +++ b/src/openhuman/agent_registry/agents/orchestrator/agent.toml @@ -60,6 +60,10 @@ allowlist = [ # "forget", profile/persona edits, learned facets, and people alias # management here. "profile_memory_agent", + # Hidden memory retrieval specialist reached through `call_memory_agent`. + # Keep it in the allowlist so direct memory lookups are subject to the + # same parent-context gate as explicit subagent delegation. + "agent_memory", # Account/admin specialist. Route billing, team membership/invites/roles, # OAuth/credential/session, and referral requests here. "account_admin_agent", diff --git a/src/openhuman/agent_registry/agents/planner/agent.toml b/src/openhuman/agent_registry/agents/planner/agent.toml index 202401772..efb441c01 100644 --- a/src/openhuman/agent_registry/agents/planner/agent.toml +++ b/src/openhuman/agent_registry/agents/planner/agent.toml @@ -21,6 +21,9 @@ agent_tier = "reasoning" [model] hint = "reasoning" +[subagents] +allowlist = ["agent_memory"] + [tools] # Read + research only. The planner produces plans — it never mutates # the workspace, memory, or state. Any writes the plan requires get diff --git a/src/openhuman/agent_registry/agents/presentation_agent/agent.toml b/src/openhuman/agent_registry/agents/presentation_agent/agent.toml index 07571b95b..b29ac6632 100644 --- a/src/openhuman/agent_registry/agents/presentation_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/presentation_agent/agent.toml @@ -15,6 +15,9 @@ omit_memory_md = true [model] hint = "agentic" +[subagents] +allowlist = ["agent_memory"] + [tools] named = [ "generate_presentation", diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml b/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml index 8898e8c46..c425450a7 100644 --- a/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/agent.toml @@ -16,6 +16,9 @@ omit_memory_md = false [model] hint = "agentic" +[subagents] +allowlist = ["agent_memory"] + [tools] named = [ "call_memory_agent", diff --git a/src/openhuman/agent_registry/agents/researcher/agent.toml b/src/openhuman/agent_registry/agents/researcher/agent.toml index d34b4165a..ede1be041 100644 --- a/src/openhuman/agent_registry/agents/researcher/agent.toml +++ b/src/openhuman/agent_registry/agents/researcher/agent.toml @@ -15,6 +15,9 @@ omit_skills_catalog = true [model] hint = "agentic" +[subagents] +allowlist = ["agent_memory"] + [tools] named = [ "http_request", diff --git a/src/openhuman/agent_registry/agents/trigger_reactor/agent.toml b/src/openhuman/agent_registry/agents/trigger_reactor/agent.toml index 191457810..0dc97157b 100644 --- a/src/openhuman/agent_registry/agents/trigger_reactor/agent.toml +++ b/src/openhuman/agent_registry/agents/trigger_reactor/agent.toml @@ -29,6 +29,9 @@ omit_memory_md = false # via the normal `RouterProvider` path. hint = "agentic" +[subagents] +allowlist = ["agent_memory"] + [tools] # Small, deliberately narrow tool surface: # - call_memory_agent / memory_store: look up and persist context diff --git a/src/openhuman/channels/providers/web/session.rs b/src/openhuman/channels/providers/web/session.rs index 95fbbb765..3c2d08def 100644 --- a/src/openhuman/channels/providers/web/session.rs +++ b/src/openhuman/channels/providers/web/session.rs @@ -129,34 +129,12 @@ pub(super) fn build_session_agent( } fn load_reflection_chunks_for_thread( - workspace_dir: &std::path::Path, - thread_id: &str, + _workspace_dir: &std::path::Path, + _thread_id: &str, ) -> Option> { - let messages = crate::openhuman::memory_conversations::get_messages( - workspace_dir.to_path_buf(), - thread_id, - ) - .ok()?; - let first = messages.first()?; - let origin = first - .extra_metadata - .get("origin") - .and_then(|v| v.as_str())?; - if origin != "subconscious_reflection" { - return None; - } - let reflection_id = first - .extra_metadata - .get("reflection_id") - .and_then(|v| v.as_str())? - .to_string(); - let reflection = - crate::openhuman::subconscious::store::with_connection(workspace_dir, |conn| { - crate::openhuman::subconscious::reflection_store::get_reflection(conn, &reflection_id) - }) - .ok() - .flatten()?; - Some(reflection.source_chunks) + // Reflection store has been removed. Existing threads spawned from + // reflections no longer receive memory-context injection. + None } pub(crate) fn locale_reply_directive(locale: &str) -> Option { diff --git a/src/openhuman/heartbeat/mod.rs b/src/openhuman/heartbeat/mod.rs index 9909ab3c0..6072a85b3 100644 --- a/src/openhuman/heartbeat/mod.rs +++ b/src/openhuman/heartbeat/mod.rs @@ -1,15 +1,10 @@ -//! Heartbeat loop — periodic scheduler that delegates to the subconscious -//! engine for task-driven evaluation via local model inference. -//! -//! HEARTBEAT.md in the workspace defines the task checklist. -//! The subconscious engine evaluates tasks against workspace state -//! (memory, graph, skills) using the local Ollama model. +//! Heartbeat — re-exports from `subconscious::heartbeat` after module +//! consolidation. Kept as a thin shim so external `crate::openhuman::heartbeat::*` +//! paths continue to compile without a crate-wide rename. -pub mod engine; -pub mod planner; -pub mod rpc; -mod schemas; -pub use schemas::{ - all_controller_schemas as all_heartbeat_controller_schemas, - all_registered_controllers as all_heartbeat_registered_controllers, +pub use crate::openhuman::subconscious::heartbeat::engine; +pub use crate::openhuman::subconscious::heartbeat::planner; +pub use crate::openhuman::subconscious::heartbeat::rpc; +pub use crate::openhuman::subconscious::heartbeat::{ + all_heartbeat_controller_schemas, all_heartbeat_registered_controllers, }; diff --git a/src/openhuman/mcp_server/resources.rs b/src/openhuman/mcp_server/resources.rs index a2d871c58..360d1c3a5 100644 --- a/src/openhuman/mcp_server/resources.rs +++ b/src/openhuman/mcp_server/resources.rs @@ -211,6 +211,12 @@ const RESOURCE_CATALOG: &[PromptResource] = &[ description: "Dedicated memory retrieval subagent using smart-walk strategies.", content: include_str!("../agent_memory/agent/prompt.md"), }, + PromptResource { + uri: "openhuman://prompts/agents/subconscious", + name: "subconscious", + description: "Background reasoning agent that maintains subconscious scratchpad context.", + content: include_str!("../subconscious/agent/prompt.md"), + }, ]; /// Returns the `resources/list` result payload listing every catalog entry. diff --git a/src/openhuman/subconscious/agent/agent.toml b/src/openhuman/subconscious/agent/agent.toml new file mode 100644 index 000000000..74bdc1b2c --- /dev/null +++ b/src/openhuman/subconscious/agent/agent.toml @@ -0,0 +1,32 @@ +id = "subconscious" +display_name = "Subconscious Agent" +when_to_use = "Background awareness loop — periodically wakes up, reviews the user's situation report and context, maintains a persistent scratchpad of observations, and delegates deeper research when in aggressive mode." +temperature = 0.4 +max_iterations = 30 +sandbox_mode = "read_only" +# Background coordinator: simple mode only edits scratchpad, but aggressive +# mode may delegate bounded deep work through the explicit subagent policy. +agent_tier = "reasoning" +omit_identity = false +omit_memory_context = false +omit_safety_preamble = true +omit_skills_catalog = true +omit_profile = false +omit_memory_md = false + +[model] +hint = "chat" + +[subagents] +allowlist = [ + "orchestrator", + "researcher", +] + +[tools] +named = [ + "scratchpad_add", + "scratchpad_edit", + "scratchpad_remove", + "spawn_subagent", +] diff --git a/src/openhuman/subconscious/agent/mod.rs b/src/openhuman/subconscious/agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/subconscious/agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/subconscious/agent/prompt.md b/src/openhuman/subconscious/agent/prompt.md new file mode 100644 index 000000000..1e2d4eced --- /dev/null +++ b/src/openhuman/subconscious/agent/prompt.md @@ -0,0 +1,68 @@ +# Subconscious Agent + +You are the user's background awareness layer — a deep reasoning loop +that wakes up periodically, reviews the user's situation report, and +maintains a persistent scratchpad of observations and follow-ups. + +Your situation report and any pre-loaded memory context are provided +in the user message. Use this information to maintain your scratchpad. + +## Scratchpad Maintenance + +Your scratchpad IS your continuity mechanism across ticks. Maintain it +actively — it persists between ticks and is the primary output of your work. + +**Tools:** + +1. **`scratchpad_add`** — Save a thought, hypothesis, or follow-up item. + Use `priority` (0-10) to mark importance. + Example: `{"body": "User has a meeting with Alice on Friday — check + if prep is done", "priority": 5}` + +2. **`scratchpad_edit`** — Update an existing entry with new information + or revised thinking. Pass the `id` shown in brackets. + +3. **`scratchpad_remove`** — Remove an entry that's no longer relevant + or has been fully addressed. + +**Scratchpad discipline:** +- Add new observations as you discover them from the situation report +- Edit stale entries with fresh data +- Remove resolved items — don't let the pad grow stale +- High-priority items (p7+) should be actionable, not vague + +## Deep Research (Aggressive mode only) + +When operating in aggressive mode, you have access to `spawn_subagent` +for deeper investigation: + +- **`spawn_subagent`** with `agent_id: "orchestrator"` — Delegate + complex multi-step tasks. The orchestrator can plan, execute code, + search the web, and coordinate across tools. Use this when you + identify something the user should act on and you have the autonomy + to help. + - Pass `model: ""` for deep reasoning tasks + - Example: `{"agent_id": "orchestrator", "prompt": "Research and + draft a summary of...", "model": "reasoning-v1"}` + +- **`spawn_subagent`** with `agent_id: "researcher"` — Delegate web + searches, artifact fetching, or external research that goes beyond + what your context provides. + +**When to use aggressive delegation:** +- A deadline is approaching and the user hasn't started prep +- A pattern across sources suggests an emerging issue +- The scratchpad has a high-priority item that needs external data + +## Observation Guidelines + +Based on your situation report, identify: +- **Patterns** across sources (email + calendar + chat converging on same topic) +- **Deadlines** approaching or overdue +- **Risks** — concentration of negative signals, unresolved blockers +- **Opportunities** — connections the user might not see +- **Activity spikes** — topics getting unusually hot + +**Self vs. others**: the *Your Identifiers* section (if present) lists +the user's handles, emails, and user_ids. Never attribute someone else's +activity to the user. diff --git a/src/openhuman/subconscious/agent/prompt.rs b/src/openhuman/subconscious/agent/prompt.rs new file mode 100644 index 000000000..a77064f56 --- /dev/null +++ b/src/openhuman/subconscious/agent/prompt.rs @@ -0,0 +1,38 @@ +//! System prompt builder for the `subconscious` built-in agent. + +use crate::openhuman::context::prompt::{ + render_safety, render_tools, render_user_files, render_workspace, PromptContext, +}; +use anyhow::Result; + +const ARCHETYPE: &str = include_str!("prompt.md"); + +pub fn build(ctx: &PromptContext<'_>) -> Result { + let mut out = String::with_capacity(8192); + out.push_str(ARCHETYPE.trim_end()); + out.push_str("\n\n"); + + let user_files = render_user_files(ctx)?; + if !user_files.trim().is_empty() { + out.push_str(user_files.trim_end()); + out.push_str("\n\n"); + } + + let tools = render_tools(ctx)?; + if !tools.trim().is_empty() { + out.push_str(tools.trim_end()); + out.push_str("\n\n"); + } + + let safety = render_safety(); + out.push_str(safety.trim_end()); + out.push_str("\n\n"); + + let workspace = render_workspace(ctx)?; + if !workspace.trim().is_empty() { + out.push_str(workspace.trim_end()); + out.push('\n'); + } + + Ok(out) +} diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index 4ec7b1738..6156ffe97 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -1,13 +1,18 @@ -//! Subconscious engine — periodic agent loop that produces thoughts. +//! Subconscious engine — periodic agent loop that maintains a scratchpad. //! -//! On each tick: build situation report → run subconscious agent → -//! parse thoughts from output → create thread → store reflections. +//! On each tick: load scratchpad → retrieve memory context (in code) → +//! build situation report → run subconscious agent (with tool access + +//! timeout) → agent maintains scratchpad via tools → log the run. +//! +//! ## 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::prompt; -use super::reflection::{apply_cap, hydrate_draft, Reflection, ReflectionDraft}; -use super::reflection_store; +use super::scratchpad; use super::situation_report::build_situation_report; -use super::source_chunk::resolve_chunks; use super::store; use super::types::{SubconsciousStatus, TickResult}; use crate::openhuman::config::schema::SubconsciousMode; @@ -17,10 +22,18 @@ use crate::openhuman::memory_store::MemoryClientRef; use anyhow::Result; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; use tokio::sync::Mutex; use tracing::{debug, info, warn}; +/// Max chunks to retrieve from memory before the LLM call. +const MEMORY_RETRIEVAL_MAX_CHUNKS: u32 = 30; + +/// 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; + /// Pick the `TrustedAutomationSource` variant for a subconscious tick. /// /// Extracted from the engine's `run_agent` body so the @@ -51,6 +64,7 @@ pub struct SubconsciousEngine { memory: Option, state: Mutex, tick_generation: AtomicU64, + tick_lock: Mutex<()>, } struct EngineState { @@ -99,6 +113,7 @@ impl SubconsciousEngine { provider_unavailable_reason: None, }), tick_generation: AtomicU64::new(0), + tick_lock: Mutex::new(()), } } @@ -119,8 +134,8 @@ impl SubconsciousEngine { match self.tick().await { Ok(result) => { info!( - "[subconscious] tick: thoughts={} thread={:?} duration={}ms", - result.thoughts_count, result.thread_id, result.duration_ms + "[subconscious] tick: duration={}ms response_chars={}", + result.duration_ms, result.response_chars ); } Err(e) => { @@ -131,6 +146,41 @@ impl SubconsciousEngine { } 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(); @@ -146,9 +196,8 @@ impl SubconsciousEngine { state.total_ticks += 1; return Ok(TickResult { tick_at, - thoughts_count: 0, - thread_id: None, duration_ms: started.elapsed().as_millis() as u64, + response_chars: 0, }); } }; @@ -161,9 +210,8 @@ impl SubconsciousEngine { state.total_ticks += 1; return Ok(TickResult { tick_at, - thoughts_count: 0, - thread_id: None, duration_ms: started.elapsed().as_millis() as u64, + response_chars: 0, }); } @@ -173,67 +221,63 @@ impl SubconsciousEngine { drop(state); // 1. Build situation report - let recent_reflections = store::with_connection(&self.workspace_dir, |conn| { - reflection_store::list_recent(conn, 8, None) - }) - .unwrap_or_else(|e| { - warn!("[subconscious] recent reflections load failed: {e}"); - Vec::new() - }); let report = build_situation_report( &config, &self.workspace_dir, last_tick_at, self.context_budget_tokens, - &recent_reflections, ) .await; let has_external_content = report.has_external_content; - // 2. Load identity context - let identity = prompt::load_identity_context(&self.workspace_dir); + // 2. Load scratchpad (persistent working memory) + let scratchpad_entries = scratchpad::load(&self.workspace_dir).unwrap_or_else(|e| { + warn!("[subconscious] scratchpad load failed: {e}"); + Vec::new() + }); + let scratchpad_section = scratchpad::render_for_prompt(&scratchpad_entries); - // 3. Run the subconscious agent - let agent_prompt = prompt::build_agent_prompt(&report.prompt_text, &identity); + // 3. Pre-LLM memory retrieval — query the memory tree using + // scratchpad entries as context so the recall is focused on + // what the subconscious is currently tracking. + let memory_section = retrieve_memory_context(&self.memory, &scratchpad_entries).await; + + // 4. Load identity context + let identity = load_identity_context(&self.workspace_dir); + + // 5. Build user message with dynamic context (system prompt comes from agent definition) + let agent_prompt = format!( + "{identity}\n\n\ + ## Situation Report (pre-loaded context)\n\n\ + {situation}\n\n\ + {memory}\n\n\ + {scratchpad}", + situation = report.prompt_text, + memory = memory_section, + scratchpad = scratchpad_section, + ); let agent_result = self .run_agent(&config, &agent_prompt, has_external_content) .await; let agent_failed = agent_result.is_err(); - let drafts = agent_result.unwrap_or_default(); + let response_chars = match &agent_result { + Ok(chars) => *chars, + Err(_) => 0, + }; - // 4. Check if superseded + // 6. Check if superseded 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, - thoughts_count: 0, - thread_id: None, duration_ms: started.elapsed().as_millis() as u64, + response_chars: 0, }); } - // 5. Create thread and persist reflections - let thread_id = if !drafts.is_empty() { - let tid = self.create_tick_thread(&config, tick_at, &agent_prompt, &drafts); - Some(tid) - } else { - None - }; - - let reflections = persist_reflections( - &self.workspace_dir, - &config, - drafts, - tick_at, - thread_id.as_deref(), - ) - .await; - - let thoughts_count = reflections.len(); - - // 6. Update state — only advance last_tick_at and reset failures + // 7. Update state — only advance last_tick_at and reset failures // when the agent actually ran. Errors keep consecutive_failures // incrementing and leave last_tick_at unchanged so the next tick // re-fetches the same window. @@ -249,9 +293,8 @@ impl SubconsciousEngine { Ok(TickResult { tick_at, - thoughts_count, - thread_id, duration_ms: started.elapsed().as_millis() as u64, + response_chars, }) } @@ -274,29 +317,29 @@ impl SubconsciousEngine { } } - /// Run the subconscious agent with mode-appropriate tool access and - /// parse thoughts from its final response. Returns `Err` on agent - /// init/run failure so the caller can track consecutive failures - /// separately from an empty-but-successful tick. + /// Run the subconscious agent with mode-appropriate tool access. + /// The agent maintains the scratchpad via tools during its turn. + /// 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, String> { + ) -> Result { use crate::openhuman::agent::Agent; let mut effective = config.clone(); + effective.agent.agent_timeout_secs = TOOL_CALL_TIMEOUT_SECS; match self.mode { SubconsciousMode::Simple => { effective.autonomy.level = crate::openhuman::security::AutonomyLevel::ReadOnly; - effective.agent.max_tool_iterations = 4; + effective.agent.max_tool_iterations = 15; } SubconsciousMode::Aggressive => { effective.autonomy.level = crate::openhuman::security::AutonomyLevel::Full; - effective.agent.max_tool_iterations = 8; + effective.agent.max_tool_iterations = 30; } - SubconsciousMode::Off => return Ok(vec![]), + SubconsciousMode::Off => return Ok(0), } let mut agent = Agent::from_config(&effective).map_err(|e| { @@ -309,26 +352,31 @@ impl SubconsciousEngine { "subconscious", ); + let mode_guidance = match self.mode { + SubconsciousMode::Aggressive => { + "\n\n\ + You are in AGGRESSIVE mode. You may use `spawn_subagent` to delegate \ + complex tasks:\n\ + - `agent_id: \"orchestrator\"` with `model: \"reasoning-v1\"` for deep \ + reasoning and multi-step execution\n\ + - `agent_id: \"researcher\"` for web research and external data\n\n\ + Use this power when you identify actionable opportunities, approaching \ + deadlines, or patterns that warrant proactive help." + } + _ => "", + }; + let user_message = format!( "{prompt_text}\n\n\ - Use your tools to look up any relevant memory, recent activity, or \ - context that would help you produce insightful observations. When \ - you're done researching, end your final message with a JSON block \ - of your thoughts:\n\n\ - ```json\n\ - {{\"thoughts\": [...]}}\n\ - ```" + ## Instructions\n\n\ + Based on the situation report and your existing scratchpad, maintain your \ + scratchpad — add new observations, edit stale entries, remove resolved items.\n\n\ + Your scratchpad IS your continuity mechanism across ticks. Keep it focused \ + and actionable.\ + {mode_guidance}", ); debug!("[subconscious] spawning agent with tool access"); - // Subconscious ticks are trusted automation: the user enabled the - // background loop knowing it should think about their state. - // When the situation report carries content derived from third- - // party sync sources (Gmail / Slack / Notion / sealed source - // summaries), escalate the origin so the approval gate refuses - // external_effect tools for the rest of the tick — a hostile - // upstream message can otherwise nudge the LLM into a tool call - // the user would never have authorised. let source = tick_origin_source(has_external_content); debug!( "[subconscious] tick origin source={:?} has_external_content={has_external_content}", @@ -348,85 +396,12 @@ impl SubconsciousEngine { format!("agent run: {e}") })?; - let drafts = parse_thoughts(&response); + let response_chars = response.chars().count(); info!( - "[subconscious] agent produced {} thoughts (response {} chars)", - drafts.len(), - response.len() + "[subconscious] agent completed (response {} chars)", + response_chars ); - Ok(drafts) - } - - /// Create a conversation thread for this tick so the user can view the - /// agent's reasoning by clicking on any thought. - fn create_tick_thread( - &self, - config: &Config, - tick_at: f64, - _agent_prompt: &str, - drafts: &[ReflectionDraft], - ) -> String { - let thread_id = uuid::Uuid::new_v4().to_string(); - let dt = chrono::DateTime::from_timestamp(tick_at as i64, 0) - .unwrap_or_else(|| chrono::Utc::now()); - let thread_title = format!("Subconscious — {}", dt.format("%b %d, %H:%M")); - let now_iso = chrono::Utc::now().to_rfc3339(); - - if let Err(e) = crate::openhuman::memory_conversations::ensure_thread( - config.workspace_dir.clone(), - crate::openhuman::memory_conversations::CreateConversationThread { - id: thread_id.clone(), - title: thread_title, - created_at: now_iso.clone(), - parent_thread_id: None, - labels: Some(vec!["subconscious".to_string()]), - personality_id: None, - }, - ) { - warn!("[subconscious] failed to create tick thread: {e}"); - return thread_id; - } - - // Seed thread with a summary of the thoughts as the assistant message - let body = drafts - .iter() - .map(|d| { - let action = d - .proposed_action - .as_deref() - .map(|a| format!("\n\n_Proposed action_: {a}")) - .unwrap_or_default(); - format!( - "**{}** — {}{}", - d.kind.as_str().replace('_', " "), - d.body, - action - ) - }) - .collect::>() - .join("\n\n---\n\n"); - - let seed_message = crate::openhuman::memory_conversations::ConversationMessage { - id: uuid::Uuid::new_v4().to_string(), - content: body, - message_type: "text".to_string(), - extra_metadata: serde_json::json!({ - "origin": "subconscious_tick", - "tick_at": tick_at, - "thoughts_count": drafts.len(), - }), - sender: "assistant".to_string(), - created_at: now_iso, - }; - if let Err(e) = crate::openhuman::memory_conversations::append_message( - config.workspace_dir.clone(), - &thread_id, - seed_message, - ) { - warn!("[subconscious] failed to seed tick thread: {e}"); - } - - thread_id + Ok(response_chars) } } @@ -489,113 +464,104 @@ fn resolve_subconscious_route(config: &Config) -> SubconsciousProviderRoute { } } -// ── Thought parsing ───────────────────────────────────────────────────────── +// ── Pre-LLM memory retrieval ──────────────────────────────────────────────── -/// Response envelope for the agent's JSON output. -#[derive(Debug, Clone, serde::Deserialize)] -struct ThoughtsResponse { - #[serde(default)] - thoughts: Vec, - // Backward compat: also accept "reflections" key - #[serde(default)] - reflections: Vec, -} - -fn parse_thoughts(text: &str) -> Vec { - let json_text = extract_json(text); - - // Try full envelope - if let Ok(response) = serde_json::from_str::(json_text) { - let mut drafts = response.thoughts; - if drafts.is_empty() { - drafts = response.reflections; +/// Query the memory tree using scratchpad entries as context, returning +/// a rendered markdown section to inject into the user message. This +/// replaces the old `call_memory_agent` tool call — the retrieval now +/// happens in code before the LLM runs, saving a full agent turn. +async fn retrieve_memory_context( + memory: &Option, + scratchpad_entries: &[scratchpad::ScratchpadEntry], +) -> String { + let client = match memory { + Some(c) => c, + None => { + debug!("[subconscious] no memory client — skipping pre-LLM retrieval"); + return String::new(); } - if !drafts.is_empty() { - return drafts; - } - } - - // Try bare array - if let Ok(drafts) = serde_json::from_str::>(json_text) { - if !drafts.is_empty() { - return drafts; - } - } - - warn!("[subconscious] could not parse agent output for thoughts"); - vec![] -} - -fn extract_json(text: &str) -> &str { - let trimmed = text.trim(); - let obj_start = trimmed.find('{'); - let arr_start = trimmed.find('['); - let start = match (obj_start, arr_start) { - (Some(o), Some(a)) => o.min(a), - (Some(o), None) => o, - (None, Some(a)) => a, - (None, None) => return trimmed, }; - let end = if trimmed.as_bytes().get(start) == Some(&b'[') { - trimmed.rfind(']').map(|i| i + 1) - } else { - trimmed.rfind('}').map(|i| i + 1) - }; - let end = end.unwrap_or(trimmed.len()); - if start < end { - &trimmed[start..end] - } else { - trimmed + + // Build a query from high-priority scratchpad items (p5+) or fall back + // to a generic recent-activity query. + let query = build_memory_query(scratchpad_entries); + debug!( + "[subconscious] pre-LLM memory retrieval query_len={}", + query.len() + ); + + let started = std::time::Instant::now(); + + // Query conversation_memory namespace for relevant context + let conversation_ctx = client + .query_namespace("conversation_memory", &query, MEMORY_RETRIEVAL_MAX_CHUNKS) + .await + .unwrap_or_else(|e| { + warn!("[subconscious] conversation_memory query failed: {e}"); + String::new() + }); + + // Also recall recent learning reflections (user patterns, preferences) + let reflections_ctx = client + .recall_namespace("learning_reflections", 10) + .await + .ok() + .flatten() + .unwrap_or_default(); + + let elapsed = started.elapsed(); + info!( + "[subconscious] pre-LLM memory retrieval done in {:.1}s conv_chars={} refl_chars={}", + elapsed.as_secs_f64(), + conversation_ctx.len(), + reflections_ctx.len() + ); + + if conversation_ctx.is_empty() && reflections_ctx.is_empty() { + return String::new(); } + + let mut section = String::from("## Memory Context (pre-loaded)\n\n"); + if !conversation_ctx.is_empty() { + section.push_str("### Recent Conversations & Activity\n\n"); + section.push_str(&conversation_ctx); + section.push_str("\n\n"); + } + if !reflections_ctx.is_empty() { + section.push_str("### Learned User Patterns\n\n"); + section.push_str(&reflections_ctx); + section.push_str("\n\n"); + } + section } -// ── Reflection persistence ────────────────────────────────────────────────── +/// Build a memory query from scratchpad entries. High-priority items +/// (p5+) get included verbatim; lower-priority items contribute keywords. +/// Falls back to a generic query when the scratchpad is empty. +fn build_memory_query(entries: &[scratchpad::ScratchpadEntry]) -> String { + if entries.is_empty() { + return "What has the user been working on recently? Any upcoming deadlines, \ + unresolved threads, or notable activity across all sources?" + .to_string(); + } -async fn persist_reflections( - workspace_dir: &std::path::Path, - config: &Config, - drafts: Vec, - now: f64, - thread_id: Option<&str>, -) -> Vec { - let (drafts, dropped) = apply_cap(drafts); - if dropped > 0 { - debug!( - "[subconscious] reflections cap dropped {} excess (kept {})", - dropped, - drafts.len() + let high_priority: Vec<&scratchpad::ScratchpadEntry> = + entries.iter().filter(|e| e.priority >= 5).collect(); + + if high_priority.is_empty() { + // Use all entries as a broad query + let bodies: Vec<&str> = entries.iter().map(|e| e.body.as_str()).collect(); + return format!( + "Recent activity and updates related to: {}", + bodies.join("; ") ); } - if drafts.is_empty() { - return vec![]; - } - let reflections: Vec = drafts - .into_iter() - .map(|d| { - let chunks = resolve_chunks(config, &d.source_refs); - hydrate_draft( - d, - uuid::Uuid::new_v4().to_string(), - now, - chunks, - thread_id.map(String::from), - ) - }) - .collect(); - - if let Err(e) = store::with_connection(workspace_dir, |conn| { - for r in &reflections { - if let Err(e) = reflection_store::add_reflection(conn, r) { - warn!("[subconscious] reflection persist failed id={}: {e}", r.id); - } - } - Ok(()) - }) { - warn!("[subconscious] reflection batch persist failed: {e}"); - } - - reflections + let bodies: Vec<&str> = high_priority.iter().map(|e| e.body.as_str()).collect(); + format!( + "Updates and context for these tracked items: {}", + bodies.join("; ") + ) } fn persist_last_tick_at(workspace_dir: &std::path::Path, tick_at: f64) { @@ -613,6 +579,72 @@ fn now_secs() -> f64 { .unwrap_or(0.0) } +// ── Identity loading ─────────────────────────────────────────────────────── + +const IDENTITY_EXCERPT_CHARS: usize = 2000; + +fn load_identity_context(workspace_dir: &std::path::Path) -> String { + let prompts_dir = resolve_prompts_dir(workspace_dir); + let mut ctx = String::new(); + + if let Some(ref dir) = prompts_dir { + if let Some(soul) = load_file_excerpt(dir, "SOUL.md") { + ctx.push_str(&soul); + ctx.push_str("\n\n"); + } + } + + if let Some(profile) = load_file_excerpt(workspace_dir, "PROFILE.md") { + ctx.push_str("## User Profile\n\n"); + ctx.push_str(&profile); + ctx.push_str("\n\n"); + } + + if ctx.is_empty() { + "You are OpenHuman, an AI assistant for productivity and collaboration.".to_string() + } else { + ctx + } +} + +fn resolve_prompts_dir(workspace_dir: &std::path::Path) -> Option { + let workspace_ai = workspace_dir.join("ai"); + if workspace_ai.is_dir() { + return Some(workspace_ai); + } + + if let Some(dir) = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from) { + let candidate = dir + .join("src") + .join("openhuman") + .join("agent") + .join("prompts"); + if candidate.is_dir() { + return Some(candidate); + } + } + + if let Ok(cwd) = std::env::current_dir() { + return crate::openhuman::dev_paths::repo_ai_prompts_dir(&cwd); + } + + None +} + +fn load_file_excerpt(dir: &std::path::Path, filename: &str) -> Option { + let content = std::fs::read_to_string(dir.join(filename)).ok()?; + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.chars().count() > IDENTITY_EXCERPT_CHARS { + let truncated: String = trimmed.chars().take(IDENTITY_EXCERPT_CHARS).collect(); + Some(format!("{truncated}\n[... truncated]")) + } else { + Some(trimmed.to_string()) + } +} + #[cfg(test)] #[path = "engine_tests.rs"] mod tests; diff --git a/src/openhuman/subconscious/engine_tests.rs b/src/openhuman/subconscious/engine_tests.rs index 71bb38c3b..d9d98285a 100644 --- a/src/openhuman/subconscious/engine_tests.rs +++ b/src/openhuman/subconscious/engine_tests.rs @@ -1,62 +1,4 @@ use super::*; -use crate::openhuman::subconscious::reflection::ReflectionKind; - -#[test] -fn parse_thoughts_from_envelope() { - let json = r#"{"thoughts": [ - {"kind": "hotness_spike", "body": "Phoenix surged", "source_refs": ["entity:phoenix"]}, - {"kind": "risk", "body": "Deadline approaching"} - ]}"#; - let drafts = parse_thoughts(json); - assert_eq!(drafts.len(), 2); - assert_eq!(drafts[0].kind, ReflectionKind::HotnessSpike); - assert_eq!(drafts[1].kind, ReflectionKind::Risk); -} - -#[test] -fn parse_thoughts_from_reflections_key() { - let json = r#"{"reflections": [ - {"kind": "opportunity", "body": "New connection available"} - ]}"#; - let drafts = parse_thoughts(json); - assert_eq!(drafts.len(), 1); -} - -#[test] -fn parse_thoughts_from_bare_array() { - let json = r#"[{"kind": "daily_digest", "body": "Summary of the day"}]"#; - let drafts = parse_thoughts(json); - assert_eq!(drafts.len(), 1); -} - -#[test] -fn parse_thoughts_returns_empty_on_garbage() { - let drafts = parse_thoughts("not json at all"); - assert!(drafts.is_empty()); -} - -#[test] -fn parse_thoughts_handles_markdown_wrapper() { - let json = "```json\n{\"thoughts\": [{\"kind\": \"risk\", \"body\": \"test\"}]}\n```"; - let drafts = parse_thoughts(json); - assert_eq!(drafts.len(), 1); -} - -#[test] -fn extract_json_finds_object() { - let text = "Here's the JSON: {\"a\": 1} done."; - let extracted = extract_json(text); - assert!(extracted.starts_with('{')); - assert!(extracted.ends_with('}')); -} - -#[test] -fn extract_json_finds_array() { - let text = "Result: [1, 2, 3] end."; - let extracted = extract_json(text); - assert!(extracted.starts_with('[')); - assert!(extracted.ends_with(']')); -} // ── Tick origin upgrade (#approval-origin) ────────────────────────────── diff --git a/src/openhuman/heartbeat/README.md b/src/openhuman/subconscious/heartbeat/README.md similarity index 100% rename from src/openhuman/heartbeat/README.md rename to src/openhuman/subconscious/heartbeat/README.md diff --git a/src/openhuman/heartbeat/engine.rs b/src/openhuman/subconscious/heartbeat/engine.rs similarity index 96% rename from src/openhuman/heartbeat/engine.rs rename to src/openhuman/subconscious/heartbeat/engine.rs index 9ba763e4e..d8a97626b 100644 --- a/src/openhuman/heartbeat/engine.rs +++ b/src/openhuman/subconscious/heartbeat/engine.rs @@ -108,8 +108,8 @@ impl HeartbeatEngine { match engine.tick().await { Ok(result) => { info!( - "[heartbeat] tick: thoughts={} thread={:?} duration={}ms", - result.thoughts_count, result.thread_id, result.duration_ms + "[heartbeat] tick: duration={}ms response_chars={}", + result.duration_ms, result.response_chars ); } Err(e) => { @@ -146,9 +146,11 @@ impl HeartbeatEngine { return; } - let summary = - crate::openhuman::heartbeat::planner::evaluate_and_dispatch(config, chrono::Utc::now()) - .await; + let summary = crate::openhuman::subconscious::heartbeat::planner::evaluate_and_dispatch( + config, + chrono::Utc::now(), + ) + .await; tracing::debug!( source_events = summary.source_events, deliveries_attempted = summary.deliveries_attempted, diff --git a/src/openhuman/subconscious/heartbeat/mod.rs b/src/openhuman/subconscious/heartbeat/mod.rs new file mode 100644 index 000000000..9909ab3c0 --- /dev/null +++ b/src/openhuman/subconscious/heartbeat/mod.rs @@ -0,0 +1,15 @@ +//! Heartbeat loop — periodic scheduler that delegates to the subconscious +//! engine for task-driven evaluation via local model inference. +//! +//! HEARTBEAT.md in the workspace defines the task checklist. +//! The subconscious engine evaluates tasks against workspace state +//! (memory, graph, skills) using the local Ollama model. + +pub mod engine; +pub mod planner; +pub mod rpc; +mod schemas; +pub use schemas::{ + all_controller_schemas as all_heartbeat_controller_schemas, + all_registered_controllers as all_heartbeat_registered_controllers, +}; diff --git a/src/openhuman/heartbeat/planner/collectors.rs b/src/openhuman/subconscious/heartbeat/planner/collectors.rs similarity index 100% rename from src/openhuman/heartbeat/planner/collectors.rs rename to src/openhuman/subconscious/heartbeat/planner/collectors.rs diff --git a/src/openhuman/heartbeat/planner/mod.rs b/src/openhuman/subconscious/heartbeat/planner/mod.rs similarity index 100% rename from src/openhuman/heartbeat/planner/mod.rs rename to src/openhuman/subconscious/heartbeat/planner/mod.rs diff --git a/src/openhuman/heartbeat/planner/persistence.rs b/src/openhuman/subconscious/heartbeat/planner/persistence.rs similarity index 100% rename from src/openhuman/heartbeat/planner/persistence.rs rename to src/openhuman/subconscious/heartbeat/planner/persistence.rs diff --git a/src/openhuman/heartbeat/planner/plan.rs b/src/openhuman/subconscious/heartbeat/planner/plan.rs similarity index 100% rename from src/openhuman/heartbeat/planner/plan.rs rename to src/openhuman/subconscious/heartbeat/planner/plan.rs diff --git a/src/openhuman/heartbeat/planner/store.rs b/src/openhuman/subconscious/heartbeat/planner/store.rs similarity index 100% rename from src/openhuman/heartbeat/planner/store.rs rename to src/openhuman/subconscious/heartbeat/planner/store.rs diff --git a/src/openhuman/heartbeat/planner/types.rs b/src/openhuman/subconscious/heartbeat/planner/types.rs similarity index 100% rename from src/openhuman/heartbeat/planner/types.rs rename to src/openhuman/subconscious/heartbeat/planner/types.rs diff --git a/src/openhuman/heartbeat/planner/utils.rs b/src/openhuman/subconscious/heartbeat/planner/utils.rs similarity index 100% rename from src/openhuman/heartbeat/planner/utils.rs rename to src/openhuman/subconscious/heartbeat/planner/utils.rs diff --git a/src/openhuman/heartbeat/rpc.rs b/src/openhuman/subconscious/heartbeat/rpc.rs similarity index 100% rename from src/openhuman/heartbeat/rpc.rs rename to src/openhuman/subconscious/heartbeat/rpc.rs diff --git a/src/openhuman/heartbeat/schemas.rs b/src/openhuman/subconscious/heartbeat/schemas.rs similarity index 94% rename from src/openhuman/heartbeat/schemas.rs rename to src/openhuman/subconscious/heartbeat/schemas.rs index 1a65525de..1f7ed485e 100644 --- a/src/openhuman/heartbeat/schemas.rs +++ b/src/openhuman/subconscious/heartbeat/schemas.rs @@ -123,7 +123,7 @@ pub fn schemas(function: &str) -> ControllerSchema { fn handle_settings_get(_params: Map) -> ControllerFuture { Box::pin(async move { - crate::openhuman::heartbeat::rpc::settings_get() + crate::openhuman::subconscious::heartbeat::rpc::settings_get() .await? .into_cli_compatible_json() }) @@ -131,10 +131,10 @@ fn handle_settings_get(_params: Map) -> ControllerFuture { fn handle_settings_set(params: Map) -> ControllerFuture { Box::pin(async move { - let patch: crate::openhuman::heartbeat::rpc::HeartbeatSettingsPatch = + let patch: crate::openhuman::subconscious::heartbeat::rpc::HeartbeatSettingsPatch = serde_json::from_value(Value::Object(params)) .map_err(|e| format!("invalid heartbeat settings_set params: {e}"))?; - crate::openhuman::heartbeat::rpc::settings_set(patch) + crate::openhuman::subconscious::heartbeat::rpc::settings_set(patch) .await? .into_cli_compatible_json() }) @@ -142,7 +142,7 @@ fn handle_settings_set(params: Map) -> ControllerFuture { fn handle_tick_now(_params: Map) -> ControllerFuture { Box::pin(async move { - crate::openhuman::heartbeat::rpc::tick_now() + crate::openhuman::subconscious::heartbeat::rpc::tick_now() .await? .into_cli_compatible_json() }) diff --git a/src/openhuman/subconscious/integration_tests.rs b/src/openhuman/subconscious/integration_tests.rs deleted file mode 100644 index 7351ef04b..000000000 --- a/src/openhuman/subconscious/integration_tests.rs +++ /dev/null @@ -1,90 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::openhuman::subconscious::reflection::{ - hydrate_draft, ReflectionDraft, ReflectionKind, - }; - use crate::openhuman::subconscious::reflection_store; - use crate::openhuman::subconscious::store; - - #[test] - fn reflection_with_thread_id_persists() { - let dir = tempfile::tempdir().unwrap(); - store::with_connection(dir.path(), |conn| { - let draft = ReflectionDraft { - kind: ReflectionKind::Opportunity, - body: "Test thought".into(), - proposed_action: Some("Do something".into()), - source_refs: vec!["entity:test".into()], - }; - let reflection = hydrate_draft( - draft, - "r-1".into(), - 1_700_000_000.0, - Vec::new(), - Some("thread-abc".into()), - ); - reflection_store::add_reflection(conn, &reflection)?; - - let got = reflection_store::get_reflection(conn, "r-1")?.unwrap(); - assert_eq!(got.thread_id, Some("thread-abc".into())); - assert_eq!(got.body, "Test thought"); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn reflection_without_thread_id_persists() { - let dir = tempfile::tempdir().unwrap(); - store::with_connection(dir.path(), |conn| { - let draft = ReflectionDraft { - kind: ReflectionKind::DailyDigest, - body: "No thread".into(), - proposed_action: None, - source_refs: vec![], - }; - let reflection = hydrate_draft(draft, "r-2".into(), 1_700_000_000.0, Vec::new(), None); - reflection_store::add_reflection(conn, &reflection)?; - - let got = reflection_store::get_reflection(conn, "r-2")?.unwrap(); - assert!(got.thread_id.is_none()); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn list_recent_includes_thread_id() { - let dir = tempfile::tempdir().unwrap(); - store::with_connection(dir.path(), |conn| { - for i in 0..3 { - let draft = ReflectionDraft { - kind: ReflectionKind::HotnessSpike, - body: format!("thought {i}"), - proposed_action: None, - source_refs: vec![], - }; - let tid = if i == 1 { - Some("thread-xyz".into()) - } else { - None - }; - let reflection = hydrate_draft( - draft, - format!("r-{i}"), - 1_700_000_000.0 + f64::from(i), - Vec::new(), - tid, - ); - reflection_store::add_reflection(conn, &reflection)?; - } - - let list = reflection_store::list_recent(conn, 10, None)?; - assert_eq!(list.len(), 3); - assert_eq!(list[1].thread_id, Some("thread-xyz".into())); - assert!(list[0].thread_id.is_none()); - Ok(()) - }) - .unwrap(); - } -} diff --git a/src/openhuman/subconscious/mod.rs b/src/openhuman/subconscious/mod.rs index 7966e25fb..ada2091d7 100644 --- a/src/openhuman/subconscious/mod.rs +++ b/src/openhuman/subconscious/mod.rs @@ -1,19 +1,15 @@ +pub mod agent; pub mod engine; pub mod global; -pub mod prompt; -pub mod reflection; -pub mod reflection_store; +pub mod heartbeat; mod schemas; +pub mod scratchpad; pub mod situation_report; pub mod source_chunk; pub mod store; pub mod types; -#[cfg(test)] -mod integration_tests; - pub use engine::SubconsciousEngine; -pub use reflection::{Reflection, ReflectionKind, MAX_REFLECTIONS_PER_TICK}; 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/prompt.rs b/src/openhuman/subconscious/prompt.rs deleted file mode 100644 index fe9d71f16..000000000 --- a/src/openhuman/subconscious/prompt.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Prompt builder for the subconscious agent. -//! -//! The subconscious agent is a periodic summarizer that reads the situation -//! report (memory-tree signals, recent activity, hotness deltas) and -//! produces structured thoughts (reflections) about the user's state. - -use std::path::Path; - -const IDENTITY_EXCERPT_CHARS: usize = 2000; - -/// Build the system prompt for the subconscious agent tick. The agent -/// observes the user's world via the situation report and produces -/// structured reflections. -pub fn build_agent_prompt(situation_report: &str, identity_context: &str) -> String { - format!( - r#"{identity_context} - -# Subconscious Agent - -You are the user's background awareness layer. You wake up periodically, -review what's happening in their world, and surface useful thoughts. - -## Situation Report (pre-loaded context) - -{situation_report} - -## Instructions - -1. **Research**: Use your tools to look up relevant memory, recent activity, - conversations, or web context that would deepen your understanding. - Use `memory_recall` to query specific topics. Use `web_fetch` or search - tools if external context would help. - -2. **Observe**: Based on both the situation report and your research, - identify patterns, deadlines, risks, opportunities, or interesting - cross-source connections. - -3. **Promote to orchestrator**: If you find something that needs a deeper - investigation or multi-step action, use `spawn_subagent` or - `spawn_worker_thread` to delegate the work. The orchestrator can take - action; you observe and delegate. - -4. **Surface thoughts**: Produce structured observations for the user. - Only surface genuinely useful insights — skip trivial observations. - -**Self vs. others**: the *Your Identifiers* section (if present) lists -the user's handles, emails, and user_ids. Never attribute someone else's -activity to the user. - -**Anti-double-emit**: the *Recent reflections* section shows what you -already surfaced. Re-emit only if the signal materially intensified. - -Cap: at most **5 thoughts per tick**. - -## Final output - -After you've finished researching, end your final message with a JSON -block containing your thoughts: - -```json -{{ - "thoughts": [ - {{ - "kind": "hotness_spike | cross_source_pattern | daily_digest | due_item | risk | opportunity", - "body": "Short markdown observation.", - "proposed_action": "Optional one-tap action text (or null).", - "source_refs": ["entity:foo", "summary:bar"] - }} - ] -}} -``` -"# - ) -} - -/// Render a slice of recent reflections as a prompt block for the -/// situation report's "Recent reflections" section. -pub fn format_recent_reflections_for_prompt( - reflections: &[crate::openhuman::subconscious::reflection::Reflection], -) -> String { - crate::openhuman::subconscious::situation_report::reflections::build_section(reflections) -} - -// ── Identity loading ───────────────────────────────────────────────────────── - -pub fn load_identity_context(workspace_dir: &Path) -> String { - let prompts_dir = resolve_prompts_dir(workspace_dir); - let mut ctx = String::new(); - - if let Some(ref dir) = prompts_dir { - if let Some(soul) = load_file_excerpt(dir, "SOUL.md") { - ctx.push_str(&soul); - ctx.push_str("\n\n"); - } - } - - if let Some(profile) = load_file_excerpt(workspace_dir, "PROFILE.md") { - ctx.push_str("## User Profile\n\n"); - ctx.push_str(&profile); - ctx.push_str("\n\n"); - } - - if ctx.is_empty() { - "You are OpenHuman, an AI assistant for productivity and collaboration.".to_string() - } else { - ctx - } -} - -fn resolve_prompts_dir(workspace_dir: &Path) -> Option { - let workspace_ai = workspace_dir.join("ai"); - if workspace_ai.is_dir() { - return Some(workspace_ai); - } - - if let Some(dir) = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from) { - let candidate = dir - .join("src") - .join("openhuman") - .join("agent") - .join("prompts"); - if candidate.is_dir() { - return Some(candidate); - } - } - - if let Ok(cwd) = std::env::current_dir() { - return crate::openhuman::dev_paths::repo_ai_prompts_dir(&cwd); - } - - None -} - -fn load_file_excerpt(dir: &Path, filename: &str) -> Option { - let content = std::fs::read_to_string(dir.join(filename)).ok()?; - let trimmed = content.trim(); - if trimmed.is_empty() { - return None; - } - if trimmed.chars().count() > IDENTITY_EXCERPT_CHARS { - let truncated: String = trimmed.chars().take(IDENTITY_EXCERPT_CHARS).collect(); - Some(format!("{truncated}\n[... truncated]")) - } else { - Some(trimmed.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn agent_prompt_includes_report_and_identity() { - let prompt = build_agent_prompt("## State\nSome data.", "Identity here"); - assert!(prompt.contains("Some data.")); - assert!(prompt.contains("Identity here")); - assert!(prompt.contains("thoughts")); - } - - #[test] - fn agent_prompt_includes_output_schema() { - let prompt = build_agent_prompt("", ""); - assert!(prompt.contains("kind")); - assert!(prompt.contains("body")); - assert!(prompt.contains("proposed_action")); - assert!(prompt.contains("source_refs")); - } - - #[test] - fn identity_context_loads_or_falls_back() { - let ctx = load_identity_context(std::path::Path::new("/nonexistent")); - assert!(ctx.contains("OpenHuman")); - } -} diff --git a/src/openhuman/subconscious/reflection.rs b/src/openhuman/subconscious/reflection.rs deleted file mode 100644 index edc795d04..000000000 --- a/src/openhuman/subconscious/reflection.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Reflection primitive for the proactive subconscious layer (#623). -//! -//! Reflections are the **observation** counterpart to [`super::types::Escalation`]: -//! the LLM emits them at tick time when memory-tree signals warrant attention, -//! but unlike escalations they **never** carry an executable side effect, and -//! (unlike the original #623 design) they **never** auto-post into any -//! conversation thread. Reflections live exclusively on the Intelligence tab; -//! `proposed_action` is a free-text suggestion the user sees as a one-tap -//! button. Tapping it spawns a *new* conversation thread seeded with the -//! reflection's body + action — the existing chat thread is never bloated. -//! -//! The per-tick cap [`MAX_REFLECTIONS_PER_TICK`] guards against runaway -//! emission. Excess reflections are dropped at debug log level. - -use serde::{Deserialize, Serialize}; - -use super::source_chunk::SourceChunk; - -/// Hard cap on reflections persisted per subconscious tick. Excess are -/// dropped with a `debug!` log entry. Picked empirically: five is the -/// sweet spot between "useful proactive surface" and "notification spam". -pub const MAX_REFLECTIONS_PER_TICK: usize = 5; - -/// One persisted observation about the user's state. Created by the -/// subconscious tick LLM, surfaced to the user only via the Intelligence -/// tab (no automatic conversation post). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Reflection { - /// Stable id (UUIDv4 string). - pub id: String, - /// What kind of signal triggered the reflection. See [`ReflectionKind`]. - pub kind: ReflectionKind, - /// Human-readable observation body. Markdown-friendly. - pub body: String, - /// Optional one-tap action text. When present, the frontend renders an - /// action button that drives `reflections_act`, which spawns a fresh - /// conversation thread seeded with body + action. - pub proposed_action: Option, - /// References to underlying signals (entity ids, summary ids, etc). - /// Free-form opaque strings — used for provenance, not parsed. - pub source_refs: Vec, - /// Resolved snapshot of the chunks the LLM cited via `source_refs`, - /// captured at tick time. Powers (a) the Intelligence-tab "Sources" - /// disclosure for transparency and (b) the orchestrator's memory- - /// context injection into the system prompt for any chat turn in a - /// thread spawned from this reflection. Snapshot semantics — chunks - /// freeze at tick time even if the underlying entity/summary mutates - /// later. See `super::source_chunk` for the resolver. - #[serde(default)] - pub source_chunks: Vec, - /// Epoch seconds when persisted. - pub created_at: f64, - /// Epoch seconds when the user tapped the proposed action. - pub acted_on_at: Option, - /// Epoch seconds when the user dismissed the card. - pub dismissed_at: Option, - /// Thread ID of the agent conversation that produced this reflection. - /// Clicking the thought in the UI navigates to this thread. - #[serde(default)] - pub thread_id: Option, -} - -/// Categorisation of the underlying signal. Start narrow; we can grow -/// the enum if a clear new bucket emerges from real data. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReflectionKind { - /// Hotness score moved sharply for an entity since last tick. - HotnessSpike, - /// Multiple sources are converging on the same entity / topic. - CrossSourcePattern, - /// New global L0 daily digest worth highlighting. - DailyDigest, - /// A sealed summary references an item with a near-term deadline. - DueItem, - /// Pattern looks risky — concentration of negative signals, etc. - Risk, - /// Pattern looks like an opportunity worth user attention. - Opportunity, -} - -impl ReflectionKind { - /// Stable lowercase string used for SQL persistence + UI chips. - pub fn as_str(self) -> &'static str { - match self { - Self::HotnessSpike => "hotness_spike", - Self::CrossSourcePattern => "cross_source_pattern", - Self::DailyDigest => "daily_digest", - Self::DueItem => "due_item", - Self::Risk => "risk", - Self::Opportunity => "opportunity", - } - } - - /// Inverse of [`Self::as_str`]. Falls back to [`Self::DailyDigest`] - /// on unknown values — the most generic bucket. - pub fn from_str_lossy(s: &str) -> Self { - match s { - "hotness_spike" => Self::HotnessSpike, - "cross_source_pattern" => Self::CrossSourcePattern, - "due_item" => Self::DueItem, - "risk" => Self::Risk, - "opportunity" => Self::Opportunity, - _ => Self::DailyDigest, - } - } -} - -/// Compact wire shape that the LLM emits per reflection. Differs from -/// [`Reflection`] in that the LLM does not yet know its persisted `id`, -/// `created_at`, or any of the lifecycle timestamps. We hydrate those -/// on the Rust side before persistence. -/// -/// Note: prior versions of this struct had a `disposition` field controlling -/// whether to post into a conversation thread. That auto-post path is gone — -/// reflections are now observation-only. If the LLM emits a `disposition` -/// field anyway (forward/backward compat), serde silently ignores it. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReflectionDraft { - pub kind: ReflectionKind, - pub body: String, - #[serde(default)] - pub proposed_action: Option, - #[serde(default)] - pub source_refs: Vec, -} - -/// Hydrate one [`ReflectionDraft`] into a persistable [`Reflection`]. -/// Pure: callers pass `id`, `now`, and the resolved `source_chunks` -/// explicitly so tests are deterministic and the resolver can be mocked. -/// Production callers: see `engine::persist_and_surface_reflections`, -/// which calls `source_chunk::resolve_chunks` against the live config -/// before invoking this. -pub fn hydrate_draft( - draft: ReflectionDraft, - id: String, - now: f64, - source_chunks: Vec, - thread_id: Option, -) -> Reflection { - Reflection { - id, - kind: draft.kind, - body: draft.body, - proposed_action: draft.proposed_action, - source_refs: draft.source_refs, - source_chunks, - created_at: now, - acted_on_at: None, - dismissed_at: None, - thread_id, - } -} - -/// Build a stable dedup key from the reflection's signal-identifying -/// fields. Two reflections with the same key and similar body should -/// not both persist within a tick — the second is the LLM repeating -/// itself rather than catching a meaningfully new signal. -/// -/// The key is `kind + sorted source_refs + leading 80 chars of body`. -/// Body is included because `kind`+`source_refs` alone misses cases -/// where the same source is interpreted two different ways. -pub fn dedup_key(kind: ReflectionKind, source_refs: &[String], body: &str) -> String { - // Canonicalize the refs: trim, drop empties, dedupe, sort. The LLM - // sometimes echoes the same id twice in `source_refs` or sandwiches - // whitespace; without this normalization those near-identical - // reflections produce different keys and slip through the gate. - let mut refs: Vec = source_refs - .iter() - .map(|r| r.trim().to_string()) - .filter(|r| !r.is_empty()) - .collect(); - refs.sort(); - refs.dedup(); - // Canonicalize the body: collapse runs of whitespace into single - // spaces and trim. Same rationale — a reflection with an extra - // newline or double space at the start would otherwise key - // differently from the original. - let canonical_body: String = body.split_whitespace().collect::>().join(" "); - let body_prefix: String = canonical_body.chars().take(80).collect(); - format!("{}|{}|{}", kind.as_str(), refs.join(","), body_prefix) -} - -/// Apply the per-tick cap to a list of drafts, dropping the tail. Returns -/// the kept slice along with the count dropped (so the caller can log -/// it at debug level). -pub fn apply_cap(drafts: Vec) -> (Vec, usize) { - if drafts.len() <= MAX_REFLECTIONS_PER_TICK { - return (drafts, 0); - } - let dropped = drafts.len() - MAX_REFLECTIONS_PER_TICK; - let kept = drafts.into_iter().take(MAX_REFLECTIONS_PER_TICK).collect(); - (kept, dropped) -} - -#[cfg(test)] -#[path = "reflection_tests.rs"] -mod tests; diff --git a/src/openhuman/subconscious/reflection_store.rs b/src/openhuman/subconscious/reflection_store.rs deleted file mode 100644 index bc2708f95..000000000 --- a/src/openhuman/subconscious/reflection_store.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! SQLite persistence for the proactive subconscious reflection layer (#623). -//! -//! Two tables: -//! - `subconscious_reflections` — durable record of every reflection the -//! tick LLM emits. Indexed by `(created_at DESC)` so the Intelligence tab -//! and the prompt's "Recent reflections" section can both fetch in one go. -//! - `subconscious_hotness_snapshots` — per-entity copy of the previous -//! tick's hotness score, used by the situation report's -//! `hotness_deltas` section to compute meaningful movement. -//! -//! DDL is appended to `super::store::SCHEMA_DDL` so the schema migration -//! and `with_connection` lifecycle stay unified — no parallel DB handle. -//! See [`super::store::with_connection`] for the sole entry point. -//! -//! Migration note: prior versions of this schema carried `disposition` and -//! `surfaced_at` columns to support the now-removed auto-post-into-thread -//! flow. [`migrate_drop_legacy_columns`] handles existing DBs by dropping -//! those columns + their index; the DDL below describes the post-migration -//! shape so fresh installs come up clean. - -use anyhow::{Context, Result}; -use rusqlite::{params, Connection, OptionalExtension}; - -use super::reflection::{Reflection, ReflectionKind}; -use super::source_chunk::SourceChunk; - -/// DDL appended to the subconscious schema. Imported by `super::store`'s -/// `SCHEMA_DDL` constant so every connection runs the migration. -pub const REFLECTION_SCHEMA_DDL: &str = " - CREATE TABLE IF NOT EXISTS subconscious_reflections ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - body TEXT NOT NULL, - proposed_action TEXT, - source_refs TEXT NOT NULL DEFAULT '[]', - source_chunks TEXT NOT NULL DEFAULT '[]', - created_at REAL NOT NULL, - acted_on_at REAL, - dismissed_at REAL, - thread_id TEXT - ); - CREATE INDEX IF NOT EXISTS idx_reflections_created - ON subconscious_reflections(created_at DESC); - - CREATE TABLE IF NOT EXISTS subconscious_hotness_snapshots ( - entity_id TEXT PRIMARY KEY, - score REAL NOT NULL, - captured_at REAL NOT NULL - ); -"; - -/// Best-effort migration: drop the legacy `disposition` / `surfaced_at` -/// columns and their index from `subconscious_reflections` if they still -/// exist on disk. Idempotent — repeated calls and clean installs are no-ops. -/// -/// Each statement is run with errors swallowed because: -/// - On a fresh install the columns/index were never created → DROP errors. -/// - On a previously-migrated install the columns/index are already gone. -/// - SQLite ≥ 3.35 supports `ALTER TABLE ... DROP COLUMN`; older builds -/// would fail this whole block, but we ship sqlite≥3.35 via rusqlite's -/// bundled feature so this is fine in practice. -pub fn migrate_drop_legacy_columns(conn: &Connection) { - let _ = conn.execute( - "DROP INDEX IF EXISTS idx_reflections_disposition_created", - [], - ); - let _ = conn.execute( - "ALTER TABLE subconscious_reflections DROP COLUMN disposition", - [], - ); - let _ = conn.execute( - "ALTER TABLE subconscious_reflections DROP COLUMN surfaced_at", - [], - ); -} - -/// Idempotent additive migration: add the `source_chunks` JSON column to -/// previously-migrated DBs that pre-date the #623-followup memory-context -/// snapshot work. Errors swallowed because: -/// - Fresh installs already have the column from the CREATE TABLE above. -/// - Already-migrated installs have it too, so ADD COLUMN errors with -/// "duplicate column" — a no-op for our purposes. -pub fn migrate_add_source_chunks_column(conn: &Connection) { - let _ = conn.execute( - "ALTER TABLE subconscious_reflections ADD COLUMN source_chunks TEXT NOT NULL DEFAULT '[]'", - [], - ); -} - -pub fn migrate_add_thread_id_column(conn: &Connection) { - let _ = conn.execute( - "ALTER TABLE subconscious_reflections ADD COLUMN thread_id TEXT", - [], - ); -} - -// ── Reflection CRUD ────────────────────────────────────────────────────────── - -/// Persist a fresh reflection. Idempotent on `id`: if a row with the same -/// id already exists the existing row is preserved (caller should be -/// generating UUIDs, so this is purely a safety net for double-writes). -pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()> { - let source_refs_json = serde_json::to_string(&reflection.source_refs) - .context("serialize source_refs") - .unwrap_or_else(|_| "[]".to_string()); - let source_chunks_json = serde_json::to_string(&reflection.source_chunks) - .context("serialize source_chunks") - .unwrap_or_else(|_| "[]".to_string()); - conn.execute( - "INSERT OR IGNORE INTO subconscious_reflections ( - id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at, thread_id - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - reflection.id, - reflection.kind.as_str(), - reflection.body, - reflection.proposed_action, - source_refs_json, - source_chunks_json, - reflection.created_at, - reflection.acted_on_at, - reflection.dismissed_at, - reflection.thread_id, - ], - ) - .context("insert reflection")?; - log::debug!( - "[subconscious::reflection_store] added id={} kind={} chunks={} thread={:?}", - reflection.id, - reflection.kind.as_str(), - reflection.source_chunks.len(), - reflection.thread_id, - ); - Ok(()) -} - -/// List reflections in reverse-chronological order, with optional cursor -/// `since_ts` (epoch seconds; rows with `created_at > since_ts`). -pub fn list_recent( - conn: &Connection, - limit: usize, - since_ts: Option, -) -> Result> { - let limit = limit.max(1) as i64; - let mut rows = Vec::new(); - let mut stmt; - let mapped: Vec = if let Some(ts) = since_ts { - stmt = conn.prepare( - "SELECT id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at, thread_id - FROM subconscious_reflections - WHERE created_at > ?1 - ORDER BY created_at DESC LIMIT ?2", - )?; - let it = stmt.query_map(params![ts, limit], row_to_reflection)?; - for r in it { - rows.push(r?); - } - rows - } else { - stmt = conn.prepare( - "SELECT id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at, thread_id - FROM subconscious_reflections - ORDER BY created_at DESC LIMIT ?1", - )?; - let it = stmt.query_map(params![limit], row_to_reflection)?; - for r in it { - rows.push(r?); - } - rows - }; - Ok(mapped) -} - -/// Fetch one reflection by id. -pub fn get_reflection(conn: &Connection, id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, kind, body, proposed_action, source_refs, source_chunks, - created_at, acted_on_at, dismissed_at, thread_id - FROM subconscious_reflections WHERE id = ?1", - )?; - let r = stmt - .query_row(params![id], row_to_reflection) - .optional() - .context("get reflection")?; - Ok(r) -} - -/// Stamp `acted_on_at` when the user taps the proposed action. -pub fn mark_acted(conn: &Connection, id: &str, ts: f64) -> Result<()> { - conn.execute( - "UPDATE subconscious_reflections SET acted_on_at = ?1 WHERE id = ?2", - params![ts, id], - )?; - Ok(()) -} - -/// Stamp `dismissed_at` when the user dismisses the card. -pub fn mark_dismissed(conn: &Connection, id: &str, ts: f64) -> Result<()> { - conn.execute( - "UPDATE subconscious_reflections SET dismissed_at = ?1 WHERE id = ?2", - params![ts, id], - )?; - Ok(()) -} - -fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result { - let id: String = row.get(0)?; - let kind_s: String = row.get(1)?; - let body: String = row.get(2)?; - let proposed_action: Option = row.get(3)?; - let source_refs_json: String = row.get(4)?; - let source_chunks_json: String = row.get(5)?; - let created_at: f64 = row.get(6)?; - let acted_on_at: Option = row.get(7)?; - let dismissed_at: Option = row.get(8)?; - let thread_id: Option = row.get(9)?; - - let source_refs: Vec = - serde_json::from_str(&source_refs_json).unwrap_or_else(|_| Vec::new()); - let source_chunks: Vec = - serde_json::from_str(&source_chunks_json).unwrap_or_else(|_| Vec::new()); - - Ok(Reflection { - id, - kind: ReflectionKind::from_str_lossy(&kind_s), - body, - proposed_action, - source_refs, - source_chunks, - created_at, - acted_on_at, - dismissed_at, - thread_id, - }) -} - -// ── Hotness snapshot CRUD ──────────────────────────────────────────────────── - -/// Read all stored snapshots — keyed by `entity_id`. Returns `(entity_id, -/// score)` pairs. Order is unspecified. -pub fn load_hotness_snapshots(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT entity_id, score FROM subconscious_hotness_snapshots")?; - let it = stmt.query_map([], |row| { - let id: String = row.get(0)?; - let score: f64 = row.get(1)?; - Ok((id, score)) - })?; - let mut out = Vec::new(); - for r in it { - out.push(r?); - } - Ok(out) -} - -/// Replace the snapshot table with a fresh capture of current hotness. -/// Atomic — wrapped in a transaction so partial state never leaks. -pub fn replace_hotness_snapshots( - conn: &mut Connection, - snapshots: &[(String, f64)], - captured_at: f64, -) -> Result<()> { - let tx = conn.transaction()?; - tx.execute("DELETE FROM subconscious_hotness_snapshots", [])?; - { - let mut stmt = tx.prepare( - "INSERT INTO subconscious_hotness_snapshots (entity_id, score, captured_at) - VALUES (?1, ?2, ?3)", - )?; - for (id, score) in snapshots { - stmt.execute(params![id, score, captured_at])?; - } - } - tx.commit()?; - log::debug!( - "[subconscious::reflection_store] replaced hotness snapshots count={}", - snapshots.len() - ); - Ok(()) -} - -#[cfg(test)] -#[path = "reflection_store_tests.rs"] -mod tests; diff --git a/src/openhuman/subconscious/reflection_store_tests.rs b/src/openhuman/subconscious/reflection_store_tests.rs deleted file mode 100644 index 72ae66c98..000000000 --- a/src/openhuman/subconscious/reflection_store_tests.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Lifecycle tests for `subconscious_reflections` + `subconscious_hotness_snapshots`. -//! -//! Builds an in-memory SQLite, runs the full subconscious DDL (so we -//! exercise the migration appended in `super::store::SCHEMA_DDL`), and -//! validates CRUD + idempotency + ordering. - -use super::*; -use crate::openhuman::subconscious::reflection::{hydrate_draft, ReflectionDraft, ReflectionKind}; -use rusqlite::Connection; - -fn fresh_conn() -> Connection { - let conn = Connection::open_in_memory().expect("open in-mem"); - // Run the same DDL that `with_connection` runs in production, so the - // migration path is exercised. - conn.execute_batch(crate::openhuman::subconscious::store::SCHEMA_DDL_FOR_TESTS) - .expect("apply DDL"); - conn -} - -fn sample_reflection(id: &str, created_at: f64) -> Reflection { - let draft = ReflectionDraft { - kind: ReflectionKind::HotnessSpike, - body: format!("body for {id}"), - proposed_action: Some("Take a look".into()), - source_refs: vec!["entity:foo".into()], - }; - hydrate_draft(draft, id.into(), created_at, Vec::new(), None) -} - -#[test] -fn add_and_get_round_trip() { - let conn = fresh_conn(); - let r = sample_reflection("r1", 1.0); - add_reflection(&conn, &r).expect("add"); - let got = get_reflection(&conn, "r1").expect("get").expect("present"); - assert_eq!(got, r); -} - -#[test] -fn add_is_idempotent_on_id() { - let conn = fresh_conn(); - let r = sample_reflection("dup", 5.0); - add_reflection(&conn, &r).unwrap(); - let mut bumped = r.clone(); - bumped.body = "DIFFERENT — should not overwrite".into(); - add_reflection(&conn, &bumped).unwrap(); - let got = get_reflection(&conn, "dup").unwrap().unwrap(); - assert_eq!(got.body, "body for dup"); -} - -#[test] -fn list_recent_orders_newest_first() { - let conn = fresh_conn(); - add_reflection(&conn, &sample_reflection("a", 1.0)).unwrap(); - add_reflection(&conn, &sample_reflection("b", 5.0)).unwrap(); - add_reflection(&conn, &sample_reflection("c", 3.0)).unwrap(); - let got = list_recent(&conn, 10, None).unwrap(); - let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect(); - assert_eq!(ids, vec!["b", "c", "a"]); -} - -#[test] -fn list_recent_respects_since_ts() { - let conn = fresh_conn(); - add_reflection(&conn, &sample_reflection("a", 1.0)).unwrap(); - add_reflection(&conn, &sample_reflection("b", 5.0)).unwrap(); - let got = list_recent(&conn, 10, Some(2.0)).unwrap(); - assert_eq!(got.len(), 1); - assert_eq!(got[0].id, "b"); -} - -#[test] -fn mark_acted_and_dismissed_set_timestamps() { - let conn = fresh_conn(); - add_reflection(&conn, &sample_reflection("act", 1.0)).unwrap(); - add_reflection(&conn, &sample_reflection("dis", 1.0)).unwrap(); - mark_acted(&conn, "act", 50.0).unwrap(); - mark_dismissed(&conn, "dis", 60.0).unwrap(); - assert_eq!( - get_reflection(&conn, "act").unwrap().unwrap().acted_on_at, - Some(50.0) - ); - assert_eq!( - get_reflection(&conn, "dis").unwrap().unwrap().dismissed_at, - Some(60.0) - ); -} - -#[test] -fn hotness_snapshot_replace_clears_then_writes() { - let mut conn = fresh_conn(); - replace_hotness_snapshots(&mut conn, &[("e1".into(), 0.5), ("e2".into(), 1.5)], 100.0).unwrap(); - let v1 = load_hotness_snapshots(&conn).unwrap(); - assert_eq!(v1.len(), 2); - - replace_hotness_snapshots(&mut conn, &[("e1".into(), 0.9)], 200.0).unwrap(); - let v2 = load_hotness_snapshots(&conn).unwrap(); - assert_eq!(v2.len(), 1); - assert_eq!(v2[0], ("e1".to_string(), 0.9)); -} - -#[test] -fn hotness_snapshot_replace_with_empty_clears_table() { - let mut conn = fresh_conn(); - replace_hotness_snapshots(&mut conn, &[("e1".into(), 0.1)], 1.0).unwrap(); - replace_hotness_snapshots(&mut conn, &[], 2.0).unwrap(); - assert!(load_hotness_snapshots(&conn).unwrap().is_empty()); -} diff --git a/src/openhuman/subconscious/reflection_tests.rs b/src/openhuman/subconscious/reflection_tests.rs deleted file mode 100644 index 5af62a380..000000000 --- a/src/openhuman/subconscious/reflection_tests.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Unit tests for `reflection.rs` — wire shape, hydration, dedup, cap. - -use super::*; - -#[test] -fn reflection_kind_round_trip() { - for k in [ - ReflectionKind::HotnessSpike, - ReflectionKind::CrossSourcePattern, - ReflectionKind::DailyDigest, - ReflectionKind::DueItem, - ReflectionKind::Risk, - ReflectionKind::Opportunity, - ] { - assert_eq!(ReflectionKind::from_str_lossy(k.as_str()), k); - } - // Unknown -> DailyDigest (most generic). - assert_eq!( - ReflectionKind::from_str_lossy("nope"), - ReflectionKind::DailyDigest - ); -} - -#[test] -fn parses_reflection_draft_from_llm_json() { - // The legacy `disposition` field is silently ignored by serde — kept - // in the fixture to verify forward/backward compat with LLM responses - // emitted before the field was dropped from the prompt contract. - let json = r#"{ - "kind": "hotness_spike", - "body": "Phoenix has been mentioned 4x in the last hour.", - "disposition": "notify", - "proposed_action": "Pull recent Phoenix mentions", - "source_refs": ["entity:phoenix", "summary:abc"] - }"#; - let d: ReflectionDraft = serde_json::from_str(json).expect("parse"); - assert_eq!(d.kind, ReflectionKind::HotnessSpike); - assert_eq!( - d.proposed_action.as_deref(), - Some("Pull recent Phoenix mentions") - ); - assert_eq!(d.source_refs.len(), 2); -} - -#[test] -fn parses_minimal_reflection_draft_without_optional_fields() { - let json = r#"{ - "kind": "daily_digest", - "body": "New daily digest sealed." - }"#; - let d: ReflectionDraft = serde_json::from_str(json).expect("parse"); - assert!(d.proposed_action.is_none()); - assert!(d.source_refs.is_empty()); -} - -#[test] -fn hydrate_draft_fills_lifecycle_fields() { - let draft = ReflectionDraft { - kind: ReflectionKind::Opportunity, - body: "User mentioned founders dinner".into(), - proposed_action: Some("Draft an invite list".into()), - source_refs: vec!["entity:dinner".into()], - }; - let r = hydrate_draft(draft, "abc-123".into(), 1_700_000_000.0, Vec::new(), None); - assert_eq!(r.id, "abc-123"); - assert_eq!(r.created_at, 1_700_000_000.0); - assert!(r.acted_on_at.is_none()); - assert!(r.dismissed_at.is_none()); -} - -#[test] -fn dedup_key_is_stable_across_source_ref_order() { - let body = "Same observation"; - let k1 = dedup_key( - ReflectionKind::Risk, - &["a".into(), "b".into(), "c".into()], - body, - ); - let k2 = dedup_key( - ReflectionKind::Risk, - &["c".into(), "a".into(), "b".into()], - body, - ); - assert_eq!(k1, k2); -} - -#[test] -fn dedup_key_changes_when_kind_changes() { - let refs = vec!["a".to_string()]; - let r1 = dedup_key(ReflectionKind::Risk, &refs, "body"); - let r2 = dedup_key(ReflectionKind::Opportunity, &refs, "body"); - assert_ne!(r1, r2); -} - -#[test] -fn apply_cap_keeps_within_limit() { - let drafts: Vec = (0..3) - .map(|i| ReflectionDraft { - kind: ReflectionKind::DailyDigest, - body: format!("body {i}"), - proposed_action: None, - source_refs: vec![], - }) - .collect(); - let (kept, dropped) = apply_cap(drafts); - assert_eq!(kept.len(), 3); - assert_eq!(dropped, 0); -} - -#[test] -fn apply_cap_trims_excess() { - let drafts: Vec = (0..10) - .map(|i| ReflectionDraft { - kind: ReflectionKind::DailyDigest, - body: format!("body {i}"), - proposed_action: None, - source_refs: vec![], - }) - .collect(); - let (kept, dropped) = apply_cap(drafts); - assert_eq!(kept.len(), MAX_REFLECTIONS_PER_TICK); - assert_eq!(dropped, 10 - MAX_REFLECTIONS_PER_TICK); - assert_eq!(kept[0].body, "body 0"); // FIFO order preserved -} diff --git a/src/openhuman/subconscious/schemas.rs b/src/openhuman/subconscious/schemas.rs index 892302a2e..d0e1638e0 100644 --- a/src/openhuman/subconscious/schemas.rs +++ b/src/openhuman/subconscious/schemas.rs @@ -3,20 +3,13 @@ use serde_json::{Map, Value}; use super::global::get_or_init_engine; -use super::reflection_store; use super::store; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::rpc::RpcOutcome; pub fn all_controller_schemas() -> Vec { - vec![ - schemas("status"), - schemas("trigger"), - schemas("reflections_list"), - schemas("reflections_act"), - schemas("reflections_dismiss"), - ] + vec![schemas("status"), schemas("trigger")] } pub fn all_registered_controllers() -> Vec { @@ -29,18 +22,6 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("trigger"), handler: handle_trigger, }, - RegisteredController { - schema: schemas("reflections_list"), - handler: handle_reflections_list, - }, - RegisteredController { - schema: schemas("reflections_act"), - handler: handle_reflections_act, - }, - RegisteredController { - schema: schemas("reflections_dismiss"), - handler: handle_reflections_dismiss, - }, ] } @@ -60,48 +41,6 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![field("result", TypeSchema::Json, "Tick result.")], }, - "reflections_list" => ControllerSchema { - namespace: "subconscious", - function: "reflections_list", - description: "List recent subconscious thoughts. Newest first.", - inputs: vec![ - field_opt("limit", TypeSchema::U64, "Max entries (default 50)."), - field_opt( - "since_ts", - TypeSchema::F64, - "Epoch seconds — only return thoughts newer than this.", - ), - ], - outputs: vec![field("reflections", TypeSchema::Json, "Thought records.")], - }, - "reflections_act" => ControllerSchema { - namespace: "subconscious", - function: "reflections_act", - description: "Act on a thought — creates a fresh conversation thread \ - and seeds it with the thought body as the first ASSISTANT \ - message. Returns the new thread id.", - inputs: vec![field_req( - "reflection_id", - TypeSchema::String, - "Thought ID.", - )], - outputs: vec![field( - "result", - TypeSchema::Json, - "{reflection_id, thread_id}.", - )], - }, - "reflections_dismiss" => ControllerSchema { - namespace: "subconscious", - function: "reflections_dismiss", - description: "Dismiss a thought card. Sets `dismissed_at`.", - inputs: vec![field_req( - "reflection_id", - TypeSchema::String, - "Thought ID.", - )], - outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")], - }, _other => ControllerSchema { namespace: "subconscious", function: "unknown", @@ -116,10 +55,6 @@ pub fn schemas(function: &str) -> ControllerSchema { fn handle_status(_params: Map) -> ControllerFuture { Box::pin(async move { - // Prefer live engine status (includes in-memory tick counters). - // Fall back to a config-derived snapshot when the engine is not yet - // initialised — the counters will read 0, which is accurate at that - // point because no ticks have run yet. let engine_arc = get_or_init_engine().await.ok(); if let Some(arc) = engine_arc { let guard = arc.lock().await; @@ -129,7 +64,6 @@ fn handle_status(_params: Map) -> ControllerFuture { } } - // Engine not yet initialised — build a snapshot from config. let config = load_config().await?; let hb = &config.heartbeat; @@ -143,8 +77,6 @@ fn handle_status(_params: Map) -> ControllerFuture { None }; let mode = hb.effective_subconscious_mode(); - // total_ticks and consecutive_failures are 0 here because the engine - // has not started; the engine Mutex cannot be held during RPC. let status = super::types::SubconsciousStatus { enabled: mode.is_enabled(), mode: mode.as_str().to_string(), @@ -171,10 +103,9 @@ fn handle_trigger(_params: Map) -> ControllerFuture { match engine.tick().await { Ok(result) => { tracing::info!( - "[subconscious] manual tick: thoughts={} thread={:?} duration={}ms", - result.thoughts_count, - result.thread_id, - result.duration_ms + "[subconscious] manual tick: duration={}ms response_chars={}", + result.duration_ms, + result.response_chars, ); } Err(e) => { @@ -191,145 +122,6 @@ fn handle_trigger(_params: Map) -> ControllerFuture { }) } -fn handle_reflections_list(params: Map) -> ControllerFuture { - Box::pin(async move { - let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; - let since_ts = params.get("since_ts").and_then(|v| v.as_f64()); - let config = load_config().await?; - let reflections = store::with_connection(&config.workspace_dir, |conn| { - reflection_store::list_recent(conn, limit, since_ts) - }) - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log(reflections, "reflections listed")) - }) -} - -fn handle_reflections_act(params: Map) -> ControllerFuture { - Box::pin(async move { - let reflection_id = params - .get("reflection_id") - .and_then(|v| v.as_str()) - .ok_or("reflection_id is required")? - .to_string(); - - let config = load_config().await?; - let reflection = store::with_connection(&config.workspace_dir, |conn| { - reflection_store::get_reflection(conn, &reflection_id) - }) - .map_err(|e| format!("{e:#}"))? - .ok_or_else(|| format!("reflection not found: {reflection_id}"))?; - - let thread_id = uuid::Uuid::new_v4().to_string(); - let thread_title: String = { - let mut s: String = reflection - .body - .chars() - .filter(|c| !c.is_control()) - .take(60) - .collect(); - if reflection.body.chars().count() > 60 { - s.push('…'); - } - if s.trim().is_empty() { - format!( - "Reflection: {kind}", - kind = reflection.kind.as_str().replace('_', " ") - ) - } else { - s - } - }; - let now_iso = chrono::Utc::now().to_rfc3339(); - crate::openhuman::memory_conversations::ensure_thread( - config.workspace_dir.clone(), - crate::openhuman::memory_conversations::CreateConversationThread { - id: thread_id.clone(), - title: thread_title, - created_at: now_iso.clone(), - parent_thread_id: None, - labels: Some(vec!["subconscious".to_string()]), - personality_id: None, - }, - ) - .map_err(|e| format!("ensure_thread (reflection-spawned) failed: {e}"))?; - - let body_md = match reflection.proposed_action.as_deref() { - Some(action) if !action.trim().is_empty() => format!( - "{body}\n\n_Proposed action_: {action}", - body = reflection.body.trim(), - action = action.trim() - ), - _ => reflection.body.trim().to_string(), - }; - let extra_metadata = serde_json::json!({ - "reflection_id": reflection.id, - "kind": reflection.kind.as_str(), - "proposed_action": reflection.proposed_action, - "source_refs": reflection.source_refs, - "origin": "subconscious_reflection", - }); - let seed_message = crate::openhuman::memory_conversations::ConversationMessage { - id: uuid::Uuid::new_v4().to_string(), - content: body_md, - message_type: "text".to_string(), - extra_metadata, - sender: "assistant".to_string(), - created_at: now_iso, - }; - crate::openhuman::memory_conversations::append_message( - config.workspace_dir.clone(), - &thread_id, - seed_message, - ) - .map_err(|e| format!("append seed reflection message failed: {e}"))?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs_f64()) - .unwrap_or(0.0); - if let Err(e) = store::with_connection(&config.workspace_dir, |conn| { - reflection_store::mark_acted(conn, &reflection_id, now) - }) { - log::warn!( - "[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e}", - reflection_id, - thread_id - ); - } - - to_json(RpcOutcome::single_log( - serde_json::json!({ - "reflection_id": reflection_id, - "thread_id": thread_id, - }), - "reflection acted", - )) - }) -} - -fn handle_reflections_dismiss(params: Map) -> ControllerFuture { - Box::pin(async move { - let reflection_id = params - .get("reflection_id") - .and_then(|v| v.as_str()) - .ok_or("reflection_id is required")? - .to_string(); - let config = load_config().await?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs_f64()) - .unwrap_or(0.0); - store::with_connection(&config.workspace_dir, |conn| { - reflection_store::mark_dismissed(conn, &reflection_id, now) - }) - .map_err(|e| format!("{e:#}"))?; - to_json(RpcOutcome::single_log( - serde_json::json!({"dismissed": reflection_id}), - "reflection dismissed", - )) - }) -} - // ── Helpers ────────────────────────────────────────────────────────────────── async fn load_config() -> Result { @@ -345,24 +137,6 @@ fn field(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSche } } -fn field_req(name: &'static str, ty: TypeSchema, comment: &'static str) -> FieldSchema { - FieldSchema { - name, - ty, - comment, - required: true, - } -} - -fn field_opt(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/schemas_tests.rs b/src/openhuman/subconscious/schemas_tests.rs index d4ac22a0d..e0296385c 100644 --- a/src/openhuman/subconscious/schemas_tests.rs +++ b/src/openhuman/subconscious/schemas_tests.rs @@ -1,24 +1,13 @@ use super::*; #[test] -fn all_schemas_returns_five() { - assert_eq!(all_controller_schemas().len(), 5); +fn all_schemas_returns_two() { + assert_eq!(all_controller_schemas().len(), 2); } #[test] -fn all_controllers_returns_five() { - assert_eq!(all_registered_controllers().len(), 5); -} - -#[test] -fn reflection_rpcs_are_registered() { - let names: Vec<&str> = all_controller_schemas() - .iter() - .map(|s| s.function) - .collect(); - assert!(names.contains(&"reflections_list")); - assert!(names.contains(&"reflections_act")); - assert!(names.contains(&"reflections_dismiss")); +fn all_controllers_returns_two() { + assert_eq!(all_registered_controllers().len(), 2); } #[test] @@ -32,12 +21,12 @@ fn status_and_trigger_are_registered() { } #[test] -fn task_endpoints_are_removed() { +fn reflection_rpcs_are_removed() { let names: Vec<&str> = all_controller_schemas() .iter() .map(|s| s.function) .collect(); - assert!(!names.contains(&"tasks_list")); - assert!(!names.contains(&"tasks_add")); - assert!(!names.contains(&"escalations_list")); + assert!(!names.contains(&"reflections_list")); + assert!(!names.contains(&"reflections_act")); + assert!(!names.contains(&"reflections_dismiss")); } diff --git a/src/openhuman/subconscious/scratchpad/mod.rs b/src/openhuman/subconscious/scratchpad/mod.rs new file mode 100644 index 000000000..879ba648c --- /dev/null +++ b/src/openhuman/subconscious/scratchpad/mod.rs @@ -0,0 +1,422 @@ +//! Subconscious scratchpad — persistent working memory across ticks. +//! +//! The scratchpad holds up to `MAX_ENTRIES` (default 100) short thoughts +//! that carry over between subconscious ticks, giving the agent a +//! consistent stream-of-consciousness. +//! +//! Stored as `{workspace_dir}/subconscious/SUBCONSCIOUS_SCRATCHPAD.md` +//! so it's trivially inspectable with any text editor. +//! +//! ## File format +//! +//! # Subconscious Scratchpad +//! +//! ```json +//! [ +//! { +//! "id": "abc123", +//! "body": "The thought body goes here.", +//! "priority": 5, +//! "created_at": 1700000000.123456, +//! "updated_at": 1700000000.123456 +//! } +//! ] +//! ``` +//! +//! The agent manages its own scratchpad via three tools: +//! `scratchpad_add`, `scratchpad_edit`, `scratchpad_remove`. + +pub mod tools; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_MAX_ENTRIES: usize = 100; + +const FILENAME: &str = "SUBCONSCIOUS_SCRATCHPAD.md"; + +/// One scratchpad entry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ScratchpadEntry { + pub id: String, + pub body: String, + pub priority: u32, + pub created_at: f64, + pub updated_at: f64, +} + +fn scratchpad_path(workspace_dir: &Path) -> PathBuf { + workspace_dir.join("subconscious").join(FILENAME) +} + +pub fn load(workspace_dir: &Path) -> Result> { + let path = scratchpad_path(workspace_dir); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e).context("read scratchpad"), + }; + Ok(parse_entries(&content)) +} + +pub fn add(workspace_dir: &Path, body: &str, priority: u32, max_entries: usize) -> Result { + let mut entries = load(workspace_dir)?; + let id = short_id(); + let now = now_secs(); + entries.push(ScratchpadEntry { + id: id.clone(), + body: body.to_string(), + priority, + created_at: now, + updated_at: now, + }); + evict(&mut entries, max_entries); + save(workspace_dir, &entries)?; + Ok(id) +} + +pub fn edit(workspace_dir: &Path, id: &str, body: &str, priority: Option) -> Result { + let mut entries = load(workspace_dir)?; + let Some(entry) = entries.iter_mut().find(|e| e.id == id) else { + return Ok(false); + }; + entry.body = body.to_string(); + if let Some(p) = priority { + entry.priority = p; + } + entry.updated_at = now_secs(); + save(workspace_dir, &entries)?; + Ok(true) +} + +pub fn remove(workspace_dir: &Path, id: &str) -> Result { + let mut entries = load(workspace_dir)?; + let before = entries.len(); + entries.retain(|e| e.id != id); + if entries.len() == before { + return Ok(false); + } + save(workspace_dir, &entries)?; + Ok(true) +} + +pub fn render_for_prompt(entries: &[ScratchpadEntry]) -> String { + if entries.is_empty() { + return String::new(); + } + let mut out = String::from("## Scratchpad (your persistent working memory)\n\n"); + out.push_str( + "These are your own notes from previous ticks. Update, remove, or \ + add entries as your understanding evolves.\n\n", + ); + for entry in entries { + let ts = chrono::DateTime::from_timestamp(entry.updated_at as i64, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown".to_string()); + out.push_str(&format!( + "- **[{}]** (p{}) {}\n _updated: {}_\n", + entry.id, entry.priority, entry.body, ts + )); + } + out +} + +// ── File I/O ──────────────────────────────────────────────────────────────── + +fn save(workspace_dir: &Path, entries: &[ScratchpadEntry]) -> Result<()> { + let path = scratchpad_path(workspace_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).context("create scratchpad dir")?; + } + let content = render_file(entries); + std::fs::write(&path, content).context("write scratchpad")?; + Ok(()) +} + +fn render_file(entries: &[ScratchpadEntry]) -> String { + let mut out = String::from("# Subconscious Scratchpad\n\n"); + if entries.is_empty() { + return out; + } + out.push_str("```json\n"); + match serde_json::to_string_pretty(entries) { + Ok(json) => out.push_str(&json), + Err(e) => { + log::warn!("[subconscious] failed to render scratchpad JSON: {e}"); + out.push_str("[]"); + } + } + out.push_str("\n```\n"); + out +} + +fn parse_entries(content: &str) -> Vec { + if let Some(entries) = parse_json_entries(content) { + return entries; + } + + let mut entries = Vec::new(); + + for block in content.split("\n---\n") { + let block = block.trim(); + if block.is_empty() || block.starts_with("# ") { + if let Some(rest) = block.strip_prefix("# Subconscious Scratchpad") { + let rest = rest.trim(); + if rest.is_empty() { + continue; + } + if let Some(entry) = parse_single_block(rest) { + entries.push(entry); + } + continue; + } + continue; + } + if let Some(entry) = parse_single_block(block) { + entries.push(entry); + } + } + + entries +} + +fn parse_json_entries(content: &str) -> Option> { + let marker = "```json"; + let start = content.find(marker)? + marker.len(); + let rest = &content[start..]; + let end = rest.rfind("```")?; + let json = rest[..end].trim(); + if json.is_empty() { + return Some(Vec::new()); + } + match serde_json::from_str(json) { + Ok(entries) => Some(entries), + Err(e) => { + log::warn!("[subconscious] failed to parse scratchpad JSON: {e}"); + None + } + } +} + +fn parse_single_block(block: &str) -> Option { + let meta_start = block.find("")? + meta_start + 3; + let meta_line = &block[meta_start..meta_end]; + + let id = extract_meta(meta_line, "entry:")?; + let priority = extract_meta(meta_line, "p:") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let created_at = extract_meta(meta_line, "created:") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + let updated_at = extract_meta(meta_line, "updated:") + .and_then(|s| s.parse::().ok()) + .unwrap_or(created_at); + + let body = block[meta_end..].trim().to_string(); + if body.is_empty() { + return None; + } + + Some(ScratchpadEntry { + id, + body, + priority, + created_at, + updated_at, + }) +} + +fn extract_meta(line: &str, key: &str) -> Option { + let start = line.find(key)? + key.len(); + let rest = &line[start..]; + let end = rest + .find(|c: char| c.is_whitespace() || c == '-') + .unwrap_or(rest.len()); + let val = rest[..end].trim().to_string(); + if val.is_empty() { + None + } else { + Some(val) + } +} + +fn evict(entries: &mut Vec, max: usize) { + if entries.len() <= max { + return; + } + entries.sort_by(|a, b| { + b.priority.cmp(&a.priority).then( + b.updated_at + .partial_cmp(&a.updated_at) + .unwrap_or(std::cmp::Ordering::Equal), + ) + }); + entries.truncate(max); +} + +fn short_id() -> String { + let uuid = uuid::Uuid::new_v4().to_string(); + uuid[..8].to_string() +} + +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)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn temp_workspace() -> TempDir { + tempfile::tempdir().unwrap() + } + + #[test] + fn add_and_load_round_trip() { + let ws = temp_workspace(); + let id = add(ws.path(), "first thought", 1, 100).unwrap(); + let entries = load(ws.path()).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, id); + assert_eq!(entries[0].body, "first thought"); + assert_eq!(entries[0].priority, 1); + } + + #[test] + fn edit_updates_body_and_priority() { + let ws = temp_workspace(); + let id = add(ws.path(), "old", 0, 100).unwrap(); + let found = edit(ws.path(), &id, "new", Some(5)).unwrap(); + assert!(found); + let entries = load(ws.path()).unwrap(); + assert_eq!(entries[0].body, "new"); + assert_eq!(entries[0].priority, 5); + } + + #[test] + fn remove_deletes_entry() { + let ws = temp_workspace(); + let id = add(ws.path(), "gone", 0, 100).unwrap(); + assert!(remove(ws.path(), &id).unwrap()); + assert!(load(ws.path()).unwrap().is_empty()); + } + + #[test] + fn remove_nonexistent_returns_false() { + let ws = temp_workspace(); + add(ws.path(), "keep", 0, 100).unwrap(); + assert!(!remove(ws.path(), "nope").unwrap()); + } + + #[test] + fn add_evicts_oldest_low_priority_beyond_cap() { + let ws = temp_workspace(); + for i in 0..5 { + add(ws.path(), &format!("note {i}"), 0, 100).unwrap(); + } + add(ws.path(), "high priority", 10, 100).unwrap(); + add(ws.path(), "newest", 0, 3).unwrap(); + let entries = load(ws.path()).unwrap(); + assert!(entries.len() <= 3); + assert!(entries.iter().any(|e| e.body == "high priority")); + } + + #[test] + fn render_for_prompt_formats_entries() { + let entries = vec![ScratchpadEntry { + id: "abc".to_string(), + body: "test thought".to_string(), + priority: 2, + created_at: 1700000000.0, + updated_at: 1700000000.0, + }]; + let rendered = render_for_prompt(&entries); + assert!(rendered.contains("## Scratchpad")); + assert!(rendered.contains("[abc]")); + assert!(rendered.contains("test thought")); + assert!(rendered.contains("(p2)")); + } + + #[test] + fn render_for_prompt_empty_returns_empty() { + assert!(render_for_prompt(&[]).is_empty()); + } + + #[test] + fn load_missing_file_returns_empty() { + let ws = temp_workspace(); + assert!(load(ws.path()).unwrap().is_empty()); + } + + #[test] + fn file_is_readable_markdown() { + let ws = temp_workspace(); + add(ws.path(), "thought one", 5, 100).unwrap(); + add(ws.path(), "thought two", 0, 100).unwrap(); + let content = std::fs::read_to_string(scratchpad_path(ws.path())).unwrap(); + assert!(content.starts_with("# Subconscious Scratchpad")); + assert!(content.contains("```json")); + assert!(content.contains("thought one")); + assert!(content.contains("thought two")); + assert!(content.contains("\"created_at\"")); + } + + #[test] + fn parse_round_trip_preserves_data() { + let original = vec![ + ScratchpadEntry { + id: "aaa".to_string(), + body: "first".to_string(), + priority: 3, + created_at: 1700000000.0, + updated_at: 1700000100.0, + }, + ScratchpadEntry { + id: "bbb".to_string(), + body: "second\nwith newlines".to_string(), + priority: 0, + created_at: 1700000050.0, + updated_at: 1700000050.0, + }, + ]; + let rendered = render_file(&original); + let parsed = parse_entries(&rendered); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].id, "aaa"); + assert_eq!(parsed[0].body, "first"); + assert_eq!(parsed[0].priority, 3); + assert_eq!(parsed[1].id, "bbb"); + assert_eq!(parsed[1].body, "second\nwith newlines"); + } + + #[test] + fn parse_round_trip_preserves_markdown_separators_and_subsecond_timestamps() { + let original = vec![ScratchpadEntry { + id: "aaa".to_string(), + body: "first\n---\nsecond".to_string(), + priority: 3, + created_at: 1700000000.123456, + updated_at: 1700000100.654321, + }]; + let rendered = render_file(&original); + let parsed = parse_entries(&rendered); + assert_eq!(parsed, original); + } + + #[test] + fn parse_legacy_markdown_entries() { + let content = "# Subconscious Scratchpad\n\n\nlegacy thought"; + let parsed = parse_entries(content); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].id, "abc"); + assert_eq!(parsed[0].body, "legacy thought"); + assert_eq!(parsed[0].priority, 2); + } +} diff --git a/src/openhuman/subconscious/scratchpad/tools.rs b/src/openhuman/subconscious/scratchpad/tools.rs new file mode 100644 index 000000000..0d9dbb9ac --- /dev/null +++ b/src/openhuman/subconscious/scratchpad/tools.rs @@ -0,0 +1,230 @@ +//! Agent tools for managing the subconscious scratchpad. +//! +//! Three tools: `scratchpad_add`, `scratchpad_edit`, `scratchpad_remove`. +//! Registered via `all_scratchpad_tools()` and wired into the tool +//! registry so the subconscious agent can manage its own working memory. + +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope}; +use async_trait::async_trait; +use serde_json::json; + +async fn workspace_dir() -> anyhow::Result { + let config = crate::openhuman::config::load_config_with_timeout() + .await + .map_err(|e| anyhow::anyhow!("config load: {e}"))?; + Ok(config.workspace_dir) +} + +pub fn all_scratchpad_tools() -> Vec> { + vec![ + Box::new(ScratchpadAddTool), + Box::new(ScratchpadEditTool), + Box::new(ScratchpadRemoveTool), + ] +} + +// ── scratchpad_add ────────────────────────────────────────────────────────── + +pub struct ScratchpadAddTool; + +#[async_trait] +impl Tool for ScratchpadAddTool { + fn name(&self) -> &str { + "scratchpad_add" + } + + fn description(&self) -> &str { + "Add a thought to the subconscious scratchpad. Use this to persist \ + observations, hypotheses, or follow-up items across ticks. Max 100 entries; \ + oldest low-priority entries are evicted when the cap is reached." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["body"], + "properties": { + "body": { + "type": "string", + "description": "The thought or note to persist (keep under ~500 chars)." + }, + "priority": { + "type": "integer", + "description": "Priority 0-10. Higher priority entries survive eviction longer. Default: 0.", + "minimum": 0, + "maximum": 10 + } + } + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + fn scope(&self) -> ToolScope { + ToolScope::AgentOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let body = args + .get("body") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("scratchpad_add: `body` is required"))?; + let priority = args + .get("priority") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + .min(10) as u32; + + let ws = workspace_dir().await?; + let id = super::add(&ws, body, priority, super::DEFAULT_MAX_ENTRIES)?; + + Ok(ToolResult::success(format!( + "Added scratchpad entry id={id} priority={priority}" + ))) + } +} + +// ── scratchpad_edit ───────────────────────────────────────────────────────── + +pub struct ScratchpadEditTool; + +#[async_trait] +impl Tool for ScratchpadEditTool { + fn name(&self) -> &str { + "scratchpad_edit" + } + + fn description(&self) -> &str { + "Edit an existing scratchpad entry. Update the body text and/or priority." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["id", "body"], + "properties": { + "id": { + "type": "string", + "description": "The entry ID to edit (shown in the scratchpad section as [id])." + }, + "body": { + "type": "string", + "description": "Updated thought text." + }, + "priority": { + "type": "integer", + "description": "Updated priority 0-10 (omit to keep current).", + "minimum": 0, + "maximum": 10 + } + } + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + fn scope(&self) -> ToolScope { + ToolScope::AgentOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("scratchpad_edit: `id` is required"))?; + let body = args + .get("body") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("scratchpad_edit: `body` is required"))?; + let priority = args + .get("priority") + .and_then(|v| v.as_u64()) + .map(|v| v.min(10) as u32); + + let ws = workspace_dir().await?; + let found = super::edit(&ws, id, body, priority)?; + + if found { + Ok(ToolResult::success(format!( + "Updated scratchpad entry id={id}" + ))) + } else { + Ok(ToolResult::error(format!( + "Scratchpad entry id={id} not found" + ))) + } + } +} + +// ── scratchpad_remove ─────────────────────────────────────────────────────── + +pub struct ScratchpadRemoveTool; + +#[async_trait] +impl Tool for ScratchpadRemoveTool { + fn name(&self) -> &str { + "scratchpad_remove" + } + + fn description(&self) -> &str { + "Remove a scratchpad entry by ID. Use when a thought is no longer relevant \ + or has been fully addressed." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The entry ID to remove." + } + } + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + fn scope(&self) -> ToolScope { + ToolScope::AgentOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("scratchpad_remove: `id` is required"))?; + + let ws = workspace_dir().await?; + let found = super::remove(&ws, id)?; + + if found { + Ok(ToolResult::success(format!( + "Removed scratchpad entry id={id}" + ))) + } else { + Ok(ToolResult::error(format!( + "Scratchpad entry id={id} not found" + ))) + } + } +} diff --git a/src/openhuman/subconscious/situation_report/mod.rs b/src/openhuman/subconscious/situation_report/mod.rs index e98668ee4..4285d2d27 100644 --- a/src/openhuman/subconscious/situation_report/mod.rs +++ b/src/openhuman/subconscious/situation_report/mod.rs @@ -6,24 +6,16 @@ //! 1. **Environment** (kept): host/OS/workspace/time anchor. //! 2. **Your Identifiers** (#1365): the user's connected-account //! identifiers (Slack/Gmail/Notion handles, emails, user_ids) so the -//! reflection LLM can disambiguate body-text mentions — "Cyrus said X" +//! LLM can disambiguate body-text mentions — "Cyrus said X" //! is the user iff `Cyrus` (or the email/handle) appears in this list. //! 3. **Pending Tasks** (kept): subconscious task list from SQLite. //! 4. **Recently-sealed summaries** (new): rows from `mem_tree_summaries` //! grouped by tree. //! 5. **Source-tree recap window** (new): recent source summaries since //! `last_tick_at`. -//! 6. **Recent reflections** (new): the last N reflections from the -//! subconscious store, used by the LLM as anti-double-emit context. -//! -//! The hotness-deltas and global-L0-digest sections were removed with the -//! topic/global trees (the entity-hotness signal was a topic-curator -//! byproduct, and there is no longer a global digest node). //! //! Sections are appended in priority order; truncation drops the tail -//! when `token_budget` is exceeded. The legacy unified-store sections -//! (`MemoryClient::list_documents`, `graph_query`) and the local-skills -//! placeholder are intentionally dropped. +//! when `token_budget` is exceeded. //! //! Each submodule is responsible for one section so churn stays local. @@ -31,10 +23,7 @@ use std::path::Path; use crate::openhuman::config::Config; -use super::reflection::Reflection; - mod query_window; -pub(crate) mod reflections; mod summaries; /// Rough chars-per-token estimate for budget enforcement. @@ -59,15 +48,11 @@ pub struct SituationReport { /// `last_tick_at` is 0.0 on cold start (include everything in the /// configured windows). `token_budget` caps total output; sections /// after the cap are truncated with a marker. -/// -/// Reflections come from `recent_reflections` so the caller can choose -/// whatever cursor logic suits them (typically: last 8 by `created_at`). pub async fn build_situation_report( config: &Config, workspace_dir: &Path, last_tick_at: f64, token_budget: u32, - recent_reflections: &[Reflection], ) -> SituationReport { let char_budget = (token_budget as usize) * CHARS_PER_TOKEN; let mut report = String::with_capacity(char_budget.min(64_000)); @@ -79,7 +64,7 @@ pub async fn build_situation_report( append_section(&mut report, &mut remaining, &env_section); // Section 2 (#1365): the user's connected-account identifiers, so - // the reflection LLM can disambiguate "Cyrus said X" from body text + // the LLM can disambiguate "Cyrus said X" from body text // — that's the user iff the identifier list claims it. let identifiers_section = build_identifiers_section(); append_section(&mut report, &mut remaining, &identifiers_section); @@ -99,10 +84,6 @@ pub async fn build_situation_report( append_section(&mut report, &mut remaining, &recap_section); has_external_content |= recap_tainted; - // Section 6: previous reflections (anti-double-emit context). - let reflections_section = reflections::build_section(recent_reflections); - append_section(&mut report, &mut remaining, &reflections_section); - if report.trim().is_empty() { report.push_str("No state changes detected since last tick.\n"); } @@ -130,7 +111,7 @@ fn build_environment_section(workspace_dir: &Path) -> String { } /// Render the user's connected-account identifiers (#1365) so the -/// reflection LLM can correlate body-text mentions back to the user. +/// LLM can correlate body-text mentions back to the user. /// Empty string when no providers are connected — the section just /// disappears rather than rendering an empty header. fn build_identifiers_section() -> String { @@ -144,9 +125,6 @@ fn build_identifiers_section() -> String { if body.trim().is_empty() { return String::new(); } - // The shared renderer emits "## Connected Identities". Rename the - // heading for the situation-report context so the LLM knows this is - // *the user's* identity surface, not a list of contacts. let renamed = body.replacen("## Connected Identities", "## Your Identifiers", 1); let mut out = renamed; if !out.ends_with('\n') { @@ -165,14 +143,11 @@ fn build_tasks_section(_workspace_dir: &Path) -> String { } /// Append a section, truncating at a UTF-8 char boundary if it overflows -/// the remaining budget. Once `remaining` hits zero, subsequent sections -/// are silently dropped (not even truncated marker added — caller -/// already noted the cap). +/// the remaining budget. fn append_section(report: &mut String, remaining: &mut usize, section: &str) { if *remaining == 0 { return; } - // +1 for the trailing newline we append let needed = section.len().saturating_add(1); if needed <= *remaining { report.push_str(section); @@ -221,7 +196,6 @@ mod tests { #[test] fn append_section_truncates_at_char_boundary() { let mut report = String::new(); - // "日本語" is 9 bytes (3 chars × 3 bytes each). let mut remaining = 5; append_section(&mut report, &mut remaining, "日本語タスク"); assert!(report.starts_with("日")); diff --git a/src/openhuman/subconscious/situation_report/reflections.rs b/src/openhuman/subconscious/situation_report/reflections.rs deleted file mode 100644 index 0bba67f26..000000000 --- a/src/openhuman/subconscious/situation_report/reflections.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Recent reflections section — anti-double-emit context for the LLM (#623). -//! -//! Renders the last N persisted reflections so the model can decide to -//! decay a stale observation, strengthen one that's intensifying, or -//! skip emitting a duplicate. -//! -//! The caller does the actual loading from `subconscious_reflections` -//! (see `engine.rs` tick logic) so this module stays a pure formatter -//! and trivial to unit-test. - -use std::fmt::Write; - -use crate::openhuman::subconscious::reflection::Reflection; - -/// Default cap on rendered reflections — `engine.rs` still supplies the -/// vector, but if more are passed we trim here so the prompt section -/// can't blow up. -const RENDER_CAP: usize = 8; - -pub fn build_section(reflections: &[Reflection]) -> String { - if reflections.is_empty() { - return "## Recent reflections\n\nNone yet — first tick.\n".to_string(); - } - - let mut section = String::from("## Recent reflections\n\n"); - section.push_str( - "Previous tick observations. Decide whether each still holds, has \ - intensified, or has decayed — emit a fresh reflection only on a \ - materially new signal:\n\n", - ); - for r in reflections.iter().take(RENDER_CAP) { - let _ = writeln!( - section, - "- [{id}] kind={kind} — {body}", - id = r.id, - kind = r.kind.as_str(), - body = trim_for_prompt(&r.body), - ); - } - section -} - -fn trim_for_prompt(text: &str) -> String { - let single_line = text.replace('\n', " "); - if single_line.chars().count() <= 200 { - return single_line; - } - let mut out: String = single_line.chars().take(200).collect(); - out.push('…'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::subconscious::reflection::{ - hydrate_draft, ReflectionDraft, ReflectionKind, - }; - - fn r(id: &str, body: &str) -> Reflection { - hydrate_draft( - ReflectionDraft { - kind: ReflectionKind::HotnessSpike, - body: body.into(), - proposed_action: None, - source_refs: vec![], - }, - id.into(), - 1.0, - Vec::new(), - None, - ) - } - - #[test] - fn empty_renders_first_tick_message() { - let s = build_section(&[]); - assert!(s.contains("None yet — first tick")); - } - - #[test] - fn renders_each_reflection() { - let s = build_section(&[r("a", "Phoenix surge"), r("b", "Calendar conflict")]); - assert!(s.contains("[a]")); - assert!(s.contains("Phoenix surge")); - assert!(s.contains("[b]")); - assert!(s.contains("Calendar conflict")); - } - - #[test] - fn caps_at_render_cap() { - let many: Vec = (0..20).map(|i| r(&format!("r{i}"), "body")).collect(); - let s = build_section(&many); - assert!(s.contains("[r0]")); - assert!(s.contains(&format!("[r{}]", RENDER_CAP - 1))); - // Past the cap should NOT appear. - assert!(!s.contains(&format!("[r{}]", RENDER_CAP))); - } -} diff --git a/src/openhuman/subconscious/store.rs b/src/openhuman/subconscious/store.rs index 87ca9dfa3..a981d1c4f 100644 --- a/src/openhuman/subconscious/store.rs +++ b/src/openhuman/subconscious/store.rs @@ -96,10 +96,6 @@ fn open_and_initialize(db_path: &Path) -> Result { conn.execute_batch(SCHEMA_DDL) .context("failed to run subconscious schema DDL")?; - super::reflection_store::migrate_drop_legacy_columns(&conn); - super::reflection_store::migrate_add_source_chunks_column(&conn); - super::reflection_store::migrate_add_thread_id_column(&conn); - Ok(conn) } diff --git a/src/openhuman/subconscious/types.rs b/src/openhuman/subconscious/types.rs index 205cb54bd..f17bdcd90 100644 --- a/src/openhuman/subconscious/types.rs +++ b/src/openhuman/subconscious/types.rs @@ -19,7 +19,7 @@ pub struct SubconsciousStatus { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TickResult { pub tick_at: f64, - pub thoughts_count: usize, - pub thread_id: Option, pub duration_ms: u64, + #[serde(default)] + pub response_chars: usize, } diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 11d9b9e2a..7777ace18 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -459,6 +459,9 @@ pub fn all_tools_with_runtime( Box::new(WorkspaceInitTool), ]; + // Subconscious scratchpad tools — persistent working memory across ticks. + tools.extend(crate::openhuman::subconscious::scratchpad::tools::all_scratchpad_tools()); + // Presentation generation (#2778). Native-Rust engine (ppt-rs // backed) as of the #2780-follow-up rust-engine refactor — no // managed Python venv, no first-call install latency. Always diff --git a/tests/subconscious_e2e.rs b/tests/subconscious_e2e.rs index d1774a777..29cf883ed 100644 --- a/tests/subconscious_e2e.rs +++ b/tests/subconscious_e2e.rs @@ -42,14 +42,13 @@ async fn ingest_doc( result.document_id } -/// Two-tick E2E test — verifies the agent-per-tick model produces -/// thoughts from ingested memory data. +/// Two-tick E2E test — verifies the agent-per-tick model can process +/// ingested memory data and persist tick state. #[tokio::test] #[ignore] // requires running Ollama async fn two_tick_e2e_with_real_ollama() { use openhuman_core::openhuman::embeddings::NoopEmbedding; use openhuman_core::openhuman::memory::{MemoryClient, UnifiedMemory}; - use openhuman_core::openhuman::subconscious::reflection_store; use openhuman_core::openhuman::subconscious::store; let tmp = tempfile::tempdir().expect("tempdir"); @@ -90,15 +89,11 @@ async fn two_tick_e2e_with_real_ollama() { println!("\n=== TICK 1 ==="); let result1 = engine.tick().await.expect("tick 1 should succeed"); println!(" Duration: {}ms", result1.duration_ms); - println!(" Thoughts: {}", result1.thoughts_count); - println!(" Thread: {:?}", result1.thread_id); + println!(" Response chars: {}", result1.response_chars); - // Check reflections in DB - let reflections1 = store::with_connection(workspace, |conn| { - reflection_store::list_recent(conn, 50, None) - }) - .expect("list reflections"); - println!(" Reflections in DB: {}", reflections1.len()); + let last_tick1 = + store::with_connection(workspace, store::get_last_tick_at).expect("read last tick"); + assert!(last_tick1 >= result1.tick_at); // Tick 2 with new data println!("\n=== TICK 2 ==="); @@ -114,8 +109,7 @@ async fn two_tick_e2e_with_real_ollama() { let result2 = engine.tick().await.expect("tick 2 should succeed"); println!(" Duration: {}ms", result2.duration_ms); - println!(" Thoughts: {}", result2.thoughts_count); - println!(" Thread: {:?}", result2.thread_id); + println!(" Response chars: {}", result2.response_chars); let status = engine.status().await; println!("\n--- Status ---");