From 0ac6e6a5bcfc18555f6775f132173aba8b604948 Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Sat, 6 Jun 2026 14:29:44 -0400
Subject: [PATCH] feat(subconscious): add scratchpad deep research loop (#3439)
---
.../IntelligenceSubconsciousTab.tsx | 18 +-
.../SubconsciousReflectionCards.tsx | 291 ----------
.../IntelligenceSubconsciousTab.test.tsx | 28 +-
.../SubconsciousReflectionCards.test.tsx | 183 ------
app/src/lib/i18n/ar.ts | 2 +
app/src/lib/i18n/bn.ts | 2 +
app/src/lib/i18n/de.ts | 2 +
app/src/lib/i18n/en.ts | 2 +
app/src/lib/i18n/es.ts | 2 +
app/src/lib/i18n/fr.ts | 2 +
app/src/lib/i18n/hi.ts | 2 +
app/src/lib/i18n/id.ts | 2 +
app/src/lib/i18n/it.ts | 2 +
app/src/lib/i18n/ko.ts | 2 +
app/src/lib/i18n/pl.ts | 2 +
app/src/lib/i18n/pt.ts | 2 +
app/src/lib/i18n/ru.ts | 2 +
app/src/lib/i18n/zh-CN.ts | 2 +
.../utils/tauriCommands/subconscious.test.ts | 101 ----
app/src/utils/tauriCommands/subconscious.ts | 71 +--
src/core/cli.rs | 3 +
src/core/mod.rs | 1 +
src/core/subconscious_cli.rs | 286 ++++++++++
src/openhuman/agent/harness/definition.rs | 13 +-
.../harness/session/builder/builder_tests.rs | 24 +-
.../agent/harness/session/builder/factory.rs | 15 +-
.../agent/harness/session/builder/mod.rs | 16 +
src/openhuman/agent_memory/tools.rs | 14 +
.../agents/crypto_agent/agent.toml | 3 +
.../agent_registry/agents/help/agent.toml | 3 +
src/openhuman/agent_registry/agents/loader.rs | 63 ++-
.../agents/markets_agent/agent.toml | 3 +
.../agents/orchestrator/agent.toml | 4 +
.../agent_registry/agents/planner/agent.toml | 3 +
.../agents/presentation_agent/agent.toml | 3 +
.../agents/profile_memory_agent/agent.toml | 3 +
.../agents/researcher/agent.toml | 3 +
.../agents/trigger_reactor/agent.toml | 3 +
.../channels/providers/web/session.rs | 32 +-
src/openhuman/heartbeat/mod.rs | 21 +-
src/openhuman/mcp_server/resources.rs | 6 +
src/openhuman/subconscious/agent/agent.toml | 32 ++
src/openhuman/subconscious/agent/mod.rs | 1 +
src/openhuman/subconscious/agent/prompt.md | 68 +++
src/openhuman/subconscious/agent/prompt.rs | 38 ++
src/openhuman/subconscious/engine.rs | 530 ++++++++++--------
src/openhuman/subconscious/engine_tests.rs | 58 --
.../{ => subconscious}/heartbeat/README.md | 0
.../{ => subconscious}/heartbeat/engine.rs | 12 +-
src/openhuman/subconscious/heartbeat/mod.rs | 15 +
.../heartbeat/planner/collectors.rs | 0
.../heartbeat/planner/mod.rs | 0
.../heartbeat/planner/persistence.rs | 0
.../heartbeat/planner/plan.rs | 0
.../heartbeat/planner/store.rs | 0
.../heartbeat/planner/types.rs | 0
.../heartbeat/planner/utils.rs | 0
.../{ => subconscious}/heartbeat/rpc.rs | 0
.../{ => subconscious}/heartbeat/schemas.rs | 8 +-
.../subconscious/integration_tests.rs | 90 ---
src/openhuman/subconscious/mod.rs | 10 +-
src/openhuman/subconscious/prompt.rs | 174 ------
src/openhuman/subconscious/reflection.rs | 198 -------
.../subconscious/reflection_store.rs | 286 ----------
.../subconscious/reflection_store_tests.rs | 108 ----
.../subconscious/reflection_tests.rs | 124 ----
src/openhuman/subconscious/schemas.rs | 234 +-------
src/openhuman/subconscious/schemas_tests.rs | 27 +-
src/openhuman/subconscious/scratchpad/mod.rs | 422 ++++++++++++++
.../subconscious/scratchpad/tools.rs | 230 ++++++++
.../subconscious/situation_report/mod.rs | 36 +-
.../situation_report/reflections.rs | 99 ----
src/openhuman/subconscious/store.rs | 4 -
src/openhuman/subconscious/types.rs | 4 +-
src/openhuman/tools/ops.rs | 3 +
tests/subconscious_e2e.rs | 20 +-
76 files changed, 1632 insertions(+), 2441 deletions(-)
delete mode 100644 app/src/components/intelligence/SubconsciousReflectionCards.tsx
delete mode 100644 app/src/components/intelligence/__tests__/SubconsciousReflectionCards.test.tsx
delete mode 100644 app/src/utils/tauriCommands/subconscious.test.ts
create mode 100644 src/core/subconscious_cli.rs
create mode 100644 src/openhuman/subconscious/agent/agent.toml
create mode 100644 src/openhuman/subconscious/agent/mod.rs
create mode 100644 src/openhuman/subconscious/agent/prompt.md
create mode 100644 src/openhuman/subconscious/agent/prompt.rs
rename src/openhuman/{ => subconscious}/heartbeat/README.md (100%)
rename src/openhuman/{ => subconscious}/heartbeat/engine.rs (96%)
create mode 100644 src/openhuman/subconscious/heartbeat/mod.rs
rename src/openhuman/{ => subconscious}/heartbeat/planner/collectors.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/planner/mod.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/planner/persistence.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/planner/plan.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/planner/store.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/planner/types.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/planner/utils.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/rpc.rs (100%)
rename src/openhuman/{ => subconscious}/heartbeat/schemas.rs (94%)
delete mode 100644 src/openhuman/subconscious/integration_tests.rs
delete mode 100644 src/openhuman/subconscious/prompt.rs
delete mode 100644 src/openhuman/subconscious/reflection.rs
delete mode 100644 src/openhuman/subconscious/reflection_store.rs
delete mode 100644 src/openhuman/subconscious/reflection_store_tests.rs
delete mode 100644 src/openhuman/subconscious/reflection_tests.rs
create mode 100644 src/openhuman/subconscious/scratchpad/mod.rs
create mode 100644 src/openhuman/subconscious/scratchpad/tools.rs
delete mode 100644 src/openhuman/subconscious/situation_report/reflections.rs
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 && (
- onNavigateToThread?.(r.thread_id!)}
- className="px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 border border-stone-200 dark:border-neutral-700 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
- {t('reflections.viewConversation')}
-
- )}
- {r.proposed_action && (
- void handleAct(r)}
- className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors">
- {t('reflections.act')}
-
- )}
- void handleDismiss(r.id)}
- className="px-3 py-1.5 text-xs bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
- {t('reflections.dismiss')}
-
-
-
-
- ))}
-
-
- );
-}
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 }) => (
- onNavigateToThread?.('spawned-thread-42')}>
- trigger
-
- ),
-}));
-
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 ---");