refactor(subconscious): replace task system with agent-per-tick model (#3079)

Co-authored-by: Steven Enamakel's Droid <enamakel.agent@tinyhumans.ai>
Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Co-authored-by: sanil-23 <sanil@tinyhumans.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-06-01 22:02:46 -07:00
committed by GitHub
co-authored by Steven Enamakel's Droid oxoxDev sanil-23 Claude Opus 4.8
parent 3136ef5483
commit 97ff4f53d3
51 changed files with 1399 additions and 5167 deletions
@@ -1,101 +1,72 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react';
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 {
SubconsciousEscalation,
SubconsciousLogEntry,
SubconsciousStatus,
SubconsciousTask,
} from '../../utils/tauriCommands/subconscious';
import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat';
import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious';
import SubconsciousReflectionCards from './SubconsciousReflectionCards';
const SKILL_KEYWORDS =
/\bskill\b|\boauth\b|\bnotion\b|\bgmail\b|\bintegration\b|\bdisconnect|\breconnect|\bre-?auth/i;
function isSkillRelated(title: string, description: string): boolean {
return SKILL_KEYWORDS.test(title) || SKILL_KEYWORDS.test(description);
interface ModeOption {
id: SubconsciousMode;
titleKey: string;
descKey: string;
}
function formatInterval(minutes: number, t: (key: string) => string): string {
switch (minutes) {
case 5:
return t('subconscious.interval.fiveMinutes');
case 10:
return t('subconscious.interval.tenMinutes');
case 15:
return t('subconscious.interval.fifteenMinutes');
case 30:
return t('subconscious.interval.thirtyMinutes');
case 60:
return t('subconscious.interval.oneHour');
case 360:
return t('subconscious.interval.sixHours');
case 720:
return t('subconscious.interval.twelveHours');
case 1440:
return t('subconscious.interval.oneDay');
default:
return String(minutes);
}
const MODE_OPTIONS: ModeOption[] = [
{ id: 'off', titleKey: 'subconscious.mode.off.title', descKey: 'subconscious.mode.off.desc' },
{
id: 'simple',
titleKey: 'subconscious.mode.simple.title',
descKey: 'subconscious.mode.simple.desc',
},
{
id: 'aggressive',
titleKey: 'subconscious.mode.aggressive.title',
descKey: 'subconscious.mode.aggressive.desc',
},
];
const INTERVAL_STOPS = [5, 10, 15, 30, 60, 120, 360, 720, 1440];
function formatMinutes(minutes: number, t: (key: string) => string): string {
if (minutes < 60) return t('subconscious.interval.minutes').replace('{n}', String(minutes));
const hours = minutes / 60;
if (hours === 1) return t('subconscious.interval.oneHour');
if (hours === 24) return t('subconscious.interval.oneDay');
return t('subconscious.interval.hours').replace('{n}', String(hours));
}
function formatPriority(priority: string, t: (key: string) => string): string {
switch (priority) {
case 'critical':
return t('subconscious.priority.critical');
case 'important':
return t('subconscious.priority.important');
default:
return t('subconscious.priority.normal');
}
function minutesToSlider(minutes: number): number {
const idx = INTERVAL_STOPS.indexOf(minutes);
return idx >= 0 ? idx : 0;
}
function formatDuration(durationMs: number, t: (key: string) => string): string {
if (durationMs > 1000) {
return t('subconscious.durationSeconds').replace('{seconds}', (durationMs / 1000).toFixed(1));
}
return t('subconscious.durationMilliseconds').replace('{milliseconds}', String(durationMs));
function sliderToMinutes(value: number): number {
return INTERVAL_STOPS[value] ?? 30;
}
interface IntelligenceSubconsciousTabProps {
addSubconsciousTask: (title: string) => Promise<void>;
approveEscalation: (escalationId: string) => Promise<void>;
dismissEscalation: (escalationId: string) => Promise<void>;
expandedLogIds: Set<string>;
logEntries: SubconsciousLogEntry[];
newTaskTitle: string;
removeSubconsciousTask: (taskId: string) => Promise<void>;
setExpandedLogIds: Dispatch<SetStateAction<Set<string>>>;
setNewTaskTitle: (value: string) => void;
status: SubconsciousStatus | null;
tasks: SubconsciousTask[];
toggleSubconsciousTask: (taskId: string, enabled: boolean) => Promise<void>;
mode: SubconsciousMode;
intervalMinutes: number;
triggerTick: () => Promise<void>;
triggering: boolean;
escalations: SubconsciousEscalation[];
loading: boolean;
settingMode: boolean;
setMode: (mode: SubconsciousMode) => Promise<void>;
setIntervalMinutes: (minutes: number) => Promise<void>;
}
export default function IntelligenceSubconsciousTab({
addSubconsciousTask,
approveEscalation,
dismissEscalation,
escalations,
expandedLogIds,
loading,
logEntries,
newTaskTitle,
removeSubconsciousTask,
setExpandedLogIds,
setNewTaskTitle,
status,
tasks,
toggleSubconsciousTask,
mode,
intervalMinutes,
triggerTick,
triggering,
settingMode,
setMode,
setIntervalMinutes,
}: IntelligenceSubconsciousTabProps) {
const { t } = useT();
const navigate = useNavigate();
@@ -104,52 +75,35 @@ export default function IntelligenceSubconsciousTab({
const providerUnavailableReason = providerUnavailable
? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle'))
: null;
const isEnabled = mode !== 'off';
// Reflection "Act" callback — sets the freshly-spawned thread as the
// selected one and navigates the user to the chat surface so they
// land in the new conversation. Reflections never write into existing
// threads (#623), so every act starts its own conversation.
//
// We dispatch `setSelectedThread` (NOT `setActiveThread`): the
// Conversations page reads `selectedThreadId` from the thread slice on
// mount and resumes that thread if present in the fetched list,
// falling back to the most recent thread otherwise. `activeThreadId`
// is a separate, runtime-only field used for in-flight chat-turn
// routing — setting it without `selectedThreadId` would not affect
// which thread the user lands on.
//
// Route is `/chat`, NOT `/conversations`. The repo's CLAUDE.md hash-
// route list is stale — `BottomTabBar` and `OpenhumanLinkModal` both
// navigate to `/chat`. Using `/conversations` falls through to a home
// redirect so the user ends up on `/home` instead of the new thread.
const handleNavigateToReflectionThread = (threadId: string) => {
console.debug('[subconscious-ui] reflection navigate:thread', { threadId });
const [localSlider, setLocalSlider] = useState(() => minutesToSlider(intervalMinutes));
// Keep the local slider in sync when the prop changes from outside (e.g. after a refresh).
useEffect(() => {
setLocalSlider(minutesToSlider(intervalMinutes));
}, [intervalMinutes]);
const handleSliderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setLocalSlider(val);
}, []);
const handleSliderCommit = useCallback(() => {
const minutes = sliderToMinutes(localSlider);
if (minutes !== intervalMinutes) {
void setIntervalMinutes(minutes);
}
}, [localSlider, intervalMinutes, setIntervalMinutes]);
const handleNavigateToThread = (threadId: string) => {
dispatch(setSelectedThread(threadId));
navigate('/chat');
};
const handleAddTask = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const title = newTaskTitle.trim();
if (!title) return;
console.debug('[subconscious-ui] add task:start', { title });
try {
await addSubconsciousTask(title);
setNewTaskTitle('');
console.debug('[subconscious-ui] add task:success', { title });
} catch (error) {
console.debug('[subconscious-ui] add task:error', {
title,
error: error instanceof Error ? error.message : String(error),
});
}
};
const handleRunTick = async () => {
console.debug('[subconscious-ui] run tick:start', { triggering });
try {
await triggerTick();
console.debug('[subconscious-ui] run tick:done');
} catch (error) {
console.debug('[subconscious-ui] run tick:error', {
error: error instanceof Error ? error.message : String(error),
@@ -157,136 +111,112 @@ export default function IntelligenceSubconsciousTab({
}
};
const handleApproveEscalation = async (escalationId: string) => {
console.debug('[subconscious-ui] escalation approve:start', { escalationId });
try {
await approveEscalation(escalationId);
console.debug('[subconscious-ui] escalation approve:success', { escalationId });
} catch (error) {
console.debug('[subconscious-ui] escalation approve:error', {
escalationId,
error: error instanceof Error ? error.message : String(error),
});
}
};
const handleDismissEscalation = async (escalationId: string) => {
console.debug('[subconscious-ui] escalation dismiss:start', { escalationId });
try {
await dismissEscalation(escalationId);
console.debug('[subconscious-ui] escalation dismiss:success', { escalationId });
} catch (error) {
console.debug('[subconscious-ui] escalation dismiss:error', {
escalationId,
error: error instanceof Error ? error.message : String(error),
});
}
};
const handleFixInSkills = (escalationId: string) => {
console.debug('[subconscious-ui] escalation fix in skills:navigate', { escalationId });
navigate('/skills', { state: { subconsciousEscalationId: escalationId } });
};
const handleToggleTask = async (taskId: string, enabled: boolean, title: string) => {
console.debug('[subconscious-ui] task toggle:start', { taskId, enabled, title });
try {
await toggleSubconsciousTask(taskId, enabled);
console.debug('[subconscious-ui] task toggle:success', { taskId, enabled, title });
} catch (error) {
console.debug('[subconscious-ui] task toggle:error', {
taskId,
enabled,
title,
error: error instanceof Error ? error.message : String(error),
});
}
};
const handleRemoveTask = async (taskId: string, title: string) => {
console.debug('[subconscious-ui] task remove:start', { taskId, title });
try {
await removeSubconsciousTask(taskId);
console.debug('[subconscious-ui] task remove:success', { taskId, title });
} catch (error) {
console.debug('[subconscious-ui] task remove:error', {
taskId,
title,
error: error instanceof Error ? error.message : String(error),
});
}
};
return (
<div className="space-y-6 animate-fade-up">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-stone-400 dark:text-neutral-500">
{status && (
<>
<span>
{status.task_count} {t('subconscious.tasks')}
</span>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span>
{status.total_ticks} {t('subconscious.ticks')}
</span>
{status.last_tick_at && (
<>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span>
{t('subconscious.last')}:{' '}
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
</span>
</>
)}
{status.consecutive_failures > 0 && (
<>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span className="text-coral-500">
{status.consecutive_failures} {t('subconscious.failed')}
</span>
</>
)}
</>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<svg
className="w-3 h-3 text-stone-400 dark:text-neutral-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
<div className="space-y-5 animate-fade-up">
{/* Mode selector */}
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('subconscious.mode.label')}
</h3>
<div className="grid grid-cols-3 gap-2">
{MODE_OPTIONS.map(opt => (
<button
key={opt.id}
type="button"
disabled={settingMode}
onClick={() => void setMode(opt.id)}
className={`flex flex-col items-center text-center rounded-lg border p-3 transition ${
mode === opt.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500/40'
} ${settingMode ? 'opacity-60 cursor-wait' : ''}`}>
<span
className={`inline-block w-3 h-3 rounded-full border-2 mb-1.5 ${
mode === opt.id
? 'bg-primary-500 border-primary-500'
: 'border-stone-300 dark:border-neutral-600'
}`}
/>
</svg>
<select
value={status?.interval_minutes ?? 5}
onChange={() => {
// Config update would require restart — show as read-only for now
}}
disabled
title={t('subconscious.tickInterval')}
className="text-xs bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 rounded px-1.5 py-0.5 text-stone-500 dark:text-neutral-400 cursor-not-allowed">
<option value={5}>{formatInterval(5, t)}</option>
<option value={10}>{formatInterval(10, t)}</option>
<option value={15}>{formatInterval(15, t)}</option>
<option value={30}>{formatInterval(30, t)}</option>
<option value={60}>{formatInterval(60, t)}</option>
<option value={360}>{formatInterval(360, t)}</option>
<option value={720}>{formatInterval(720, t)}</option>
<option value={1440}>{formatInterval(1440, t)}</option>
</select>
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t(opt.titleKey)}
</span>
<p className="mt-1 text-[11px] leading-tight text-stone-500 dark:text-neutral-400">
{t(opt.descKey)}
</p>
</button>
))}
</div>
{mode === 'aggressive' && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
{t('subconscious.mode.aggressiveWarning')}
</p>
)}
</div>
{/* Frequency slider */}
{isEnabled && (
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-stone-700 dark:text-neutral-300">
{t('subconscious.interval.label')}
</label>
<span className="text-xs text-stone-500 dark:text-neutral-400">
{formatMinutes(sliderToMinutes(localSlider), t)}
</span>
</div>
<input
type="range"
min={0}
max={INTERVAL_STOPS.length - 1}
step={1}
value={localSlider}
onChange={handleSliderChange}
onMouseUp={handleSliderCommit}
onTouchEnd={handleSliderCommit}
className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-stone-200 dark:bg-neutral-700 accent-primary-500"
/>
<div className="flex justify-between mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
<span>5m</span>
<span>1h</span>
<span>24h</span>
</div>
</div>
)}
{/* Status bar + Run Now */}
{isEnabled && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-stone-400 dark:text-neutral-500">
{status && (
<>
<span>
{status.total_ticks} {t('subconscious.ticks')}
</span>
{status.last_tick_at && (
<>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span>
{t('subconscious.last')}:{' '}
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
</span>
</>
)}
{status.consecutive_failures > 0 && (
<>
<span className="text-stone-300 dark:text-neutral-600">|</span>
<span className="text-coral-500">
{status.consecutive_failures} {t('subconscious.failed')}
</span>
</>
)}
</>
)}
</div>
<button
onClick={() => void handleRunTick()}
disabled={triggering || providerUnavailable}
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
{triggering ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
@@ -302,9 +232,9 @@ export default function IntelligenceSubconsciousTab({
{t('subconscious.runNow')}
</button>
</div>
</div>
)}
{providerUnavailable && (
{isEnabled && providerUnavailable && (
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@@ -325,256 +255,12 @@ export default function IntelligenceSubconsciousTab({
</div>
)}
<SubconsciousReflectionCards
onNavigateToThread={handleNavigateToReflectionThread}
pollIntervalMs={15_000}
/>
{escalations.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
{t('subconscious.approvalNeeded')}
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{escalations.length}
</span>
</h3>
<div className="space-y-2">
{escalations.map(esc => (
<div
key={esc.id}
className="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-xl p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{esc.title}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{esc.description}
</p>
<div className="flex items-center gap-2 mt-2">
<span
className={`text-[10px] px-2 py-0.5 rounded-full ${
esc.priority === 'critical'
? 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
: esc.priority === 'important'
? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300'
}`}>
{formatPriority(esc.priority, t)}
</span>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('subconscious.requiresApproval')}
</span>
</div>
</div>
<div className="flex gap-2 ml-3 flex-shrink-0">
{isSkillRelated(esc.title, esc.description) ? (
<button
onClick={() => handleFixInSkills(esc.id)}
className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors">
{t('subconscious.fixInConnections')}
</button>
) : (
<button
onClick={() => void handleApproveEscalation(esc.id)}
className="px-3 py-1.5 text-xs bg-sage-500 hover:bg-sage-600 text-white rounded-lg transition-colors">
{t('subconscious.goAhead')}
</button>
)}
<button
onClick={() => void handleDismissEscalation(esc.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('common.skip')}
</button>
</div>
</div>
</div>
))}
</div>
</div>
{isEnabled && (
<SubconsciousReflectionCards
onNavigateToThread={handleNavigateToThread}
pollIntervalMs={15_000}
/>
)}
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
{t('subconscious.activeTasks')}
</h3>
{loading && tasks.length === 0 ? (
<div className="text-center py-4">
<div className="w-6 h-6 mx-auto border-2 border-stone-300 dark:border-neutral-700 border-t-transparent rounded-full animate-spin" />
</div>
) : tasks.filter(t => !t.completed).length === 0 ? (
<p className="text-xs text-stone-400 dark:text-neutral-500 py-3">
{t('subconscious.noActiveTasks')}
</p>
) : (
<div className="space-y-1.5">
{tasks
.filter(t => !t.completed && t.source === 'system')
.map(task => (
<div
key={task.id}
className="flex items-center py-2 px-3 bg-stone-50 dark:bg-neutral-800/60 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-sage-400 flex-shrink-0 mr-2.5" />
<span className="text-sm text-stone-900 dark:text-neutral-100 truncate flex-1">
{task.title}
</span>
<span className="text-[10px] text-stone-400 dark:text-neutral-500 flex-shrink-0 px-1.5 py-0.5 rounded bg-stone-100 dark:bg-neutral-800">
{t('subconscious.default')}
</span>
</div>
))}
{tasks
.filter(t => !t.completed && t.source !== 'system')
.map(task => (
<div
key={task.id}
className="flex items-center justify-between py-2 px-3 bg-stone-50 dark:bg-neutral-800/60 rounded-lg group">
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<button
type="button"
aria-pressed={task.enabled}
aria-label={`${task.enabled ? t('common.disable') : t('common.enable')} ${task.title}`}
onClick={() => void handleToggleTask(task.id, !task.enabled, task.title)}
className={`relative w-7 h-4 rounded-full flex-shrink-0 transition-colors ${
task.enabled ? 'bg-sage-500' : 'bg-stone-300'
}`}>
<span
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
task.enabled ? 'translate-x-3' : 'translate-x-0'
}`}
/>
</button>
<span
className={`text-sm truncate ${task.enabled ? 'text-stone-900 dark:text-neutral-100' : 'text-stone-400 dark:text-neutral-500'}`}>
{task.title}
</span>
</div>
<button
type="button"
aria-label={`${t('common.remove')} ${task.title}`}
onClick={() => void handleRemoveTask(task.id, task.title)}
className="opacity-0 group-hover:opacity-100 p-1 text-stone-400 dark:text-neutral-500 hover:text-coral-500 transition-all">
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
)}
<form onSubmit={e => void handleAddTask(e)} className="flex gap-2 mt-3">
<input
type="text"
placeholder={t('subconscious.addTaskPlaceholder')}
value={newTaskTitle}
onChange={e => setNewTaskTitle(e.target.value)}
className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-900 dark:text-neutral-100 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors"
/>
<button
type="submit"
disabled={!newTaskTitle.trim()}
className="px-3 py-2 text-sm bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white rounded-lg transition-colors">
{t('common.add')}
</button>
</form>
</div>
<div>
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
{t('subconscious.activityLog')}
</h3>
{logEntries.length === 0 ? (
<p className="text-xs text-stone-400 dark:text-neutral-500 py-3">
{t('subconscious.noActivity')}
</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{logEntries.map(entry => (
<div key={entry.id} className="flex items-start gap-2 py-1.5 px-2 text-xs">
<span className="text-stone-400 dark:text-neutral-500 flex-shrink-0 w-14">
{new Date(entry.tick_at * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
<span
className={`flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5 ${
entry.decision === 'act'
? 'bg-sage-400'
: entry.decision === 'in_progress'
? 'bg-primary-400 animate-pulse'
: entry.decision === 'escalate'
? 'bg-amber-400'
: entry.decision === 'failed'
? 'bg-coral-400'
: entry.decision === 'cancelled'
? 'bg-stone-300'
: entry.decision === 'dismissed'
? 'bg-stone-300'
: 'bg-stone-200 dark:bg-neutral-800'
}`}
/>
<span
className={`break-words min-w-0 ${
entry.decision === 'in_progress'
? 'text-stone-400 dark:text-neutral-500'
: entry.decision === 'failed'
? 'text-coral-500'
: 'text-stone-600 dark:text-neutral-300'
} ${entry.result && entry.result.length > 120 ? 'cursor-pointer hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100' : ''}`}
onClick={() => {
if (entry.result && entry.result.length > 120) {
setExpandedLogIds(prev => {
const next = new Set(prev);
if (next.has(entry.id)) next.delete(entry.id);
else next.add(entry.id);
return next;
});
}
}}>
{entry.result
? expandedLogIds.has(entry.id)
? entry.result
: entry.result.length > 120
? `${entry.result.substring(0, 120)}...`
: entry.result
: entry.decision === 'noop'
? t('subconscious.decision.nothingNew')
: entry.decision === 'act'
? t('subconscious.decision.completed')
: entry.decision === 'in_progress'
? t('subconscious.decision.evaluating')
: entry.decision === 'escalate'
? t('subconscious.decision.waitingApproval')
: entry.decision === 'failed'
? t('subconscious.decision.failed')
: entry.decision === 'cancelled'
? t('subconscious.decision.cancelled')
: entry.decision === 'dismissed'
? t('subconscious.decision.skipped')
: entry.decision}
</span>
{entry.duration_ms != null && (
<span className="text-stone-300 dark:text-neutral-600 flex-shrink-0 ml-auto">
{formatDuration(entry.duration_ms, t)}
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -259,6 +259,14 @@ export default function SubconsciousReflectionCards({
)}
</div>
<div className="flex flex-col gap-2 flex-shrink-0">
{r.thread_id && (
<button
data-testid={`reflection-view-${r.id}`}
onClick={() => 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')}
</button>
)}
{r.proposed_action && (
<button
data-testid={`reflection-act-${r.id}`}
@@ -1,13 +1,5 @@
/**
* Vitest for the Intelligence Subconscious tab (#623).
*
* Covers `handleNavigateToReflectionThread` — the callback passed to
* `SubconsciousReflectionCards`. The function is small but load-bearing:
* it dispatches `setSelectedThread(threadId)` so `Conversations` resumes
* the new thread on mount, then routes to `/chat` (the unified chat
* surface; `/conversations` redirects to `/home`). Both dispatch and
* navigate are mocked so we can assert the contract without spinning up
* the full Redux/router stack.
* Vitest for the Intelligence Subconscious tab.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
@@ -23,10 +15,6 @@ vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch, useSelector: ()
vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate }));
// Stub out the cards component so we can trigger the navigate callback
// directly without exercising the RPC / polling path (already covered by
// `SubconsciousReflectionCards.test.tsx`). The stub renders a button
// that fires `onNavigateToThread` with a known thread id when clicked.
vi.mock('../SubconsciousReflectionCards', () => ({
default: ({ onNavigateToThread }: { onNavigateToThread?: (id: string) => void }) => (
<button
@@ -38,24 +26,16 @@ vi.mock('../SubconsciousReflectionCards', () => ({
),
}));
function baseProps() {
function baseProps(): ComponentProps<typeof IntelligenceSubconsciousTab> {
return {
addSubconsciousTask: vi.fn(),
approveEscalation: vi.fn(),
dismissEscalation: vi.fn(),
expandedLogIds: new Set<string>(),
logEntries: [],
newTaskTitle: '',
removeSubconsciousTask: vi.fn(),
setExpandedLogIds: vi.fn(),
setNewTaskTitle: vi.fn(),
status: null as ComponentProps<typeof IntelligenceSubconsciousTab>['status'],
tasks: [],
toggleSubconsciousTask: vi.fn(),
status: null,
mode: 'off',
intervalMinutes: 30,
triggerTick: vi.fn(),
triggering: false,
escalations: [],
loading: false,
settingMode: false,
setMode: vi.fn(),
setIntervalMinutes: vi.fn(),
};
}
@@ -68,48 +48,41 @@ describe('IntelligenceSubconsciousTab', () => {
vi.restoreAllMocks();
});
it('on Act → dispatches setSelectedThread + navigates to /chat', () => {
it('renders three mode options', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} />);
expect(screen.getByText('Off')).toBeInTheDocument();
expect(screen.getByText('Simple')).toBeInTheDocument();
expect(screen.getByText('Aggressive')).toBeInTheDocument();
});
it('clicking a mode option calls setMode', () => {
const setMode = vi.fn();
render(<IntelligenceSubconsciousTab {...baseProps()} setMode={setMode} />);
fireEvent.click(screen.getByText('Simple'));
expect(setMode).toHaveBeenCalledWith('simple');
});
it('hides Run Now and reflections when mode is off', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="off" />);
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', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="simple" />);
expect(screen.getByText('Run Now')).toBeInTheDocument();
expect(screen.getByTestId('cards-stub-trigger')).toBeInTheDocument();
});
it('shows aggressive warning when mode is aggressive', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="aggressive" />);
expect(screen.getByText(/full tool access including writes/)).toBeInTheDocument();
});
it('on Act → dispatches setSelectedThread + navigates to /chat', () => {
render(<IntelligenceSubconsciousTab {...baseProps()} mode="simple" />);
fireEvent.click(screen.getByTestId('cards-stub-trigger'));
// Redux dispatch payload should match the slice's action creator
// exactly — comparing the produced action keeps the assertion robust
// if the slice path changes.
expect(mockDispatch).toHaveBeenCalledWith(setSelectedThread('spawned-thread-42'));
// Route must be `/chat` (the unified chat surface), not
// `/conversations` — the latter falls through to a `/home` redirect
// and the user lands somewhere unexpected.
expect(mockNavigate).toHaveBeenCalledWith('/chat');
});
it('shows provider unavailable state and blocks manual ticks', () => {
const triggerTick = vi.fn();
render(
<IntelligenceSubconsciousTab
{...baseProps()}
triggerTick={triggerTick}
status={{
enabled: true,
provider_available: false,
provider_unavailable_reason: 'Sign in or configure a local Subconscious provider.',
interval_minutes: 5,
last_tick_at: null,
total_ticks: 0,
task_count: 3,
pending_escalations: 0,
consecutive_failures: 1,
}}
/>
);
expect(screen.getByText('Subconscious is paused')).toBeInTheDocument();
expect(screen.getByText(/configure a local Subconscious provider/i)).toBeInTheDocument();
const runNow = screen.getByRole('button', { name: /Run Now/i });
expect(runNow).toBeDisabled();
fireEvent.click(runNow);
expect(triggerTick).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /AI settings/i }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/llm');
});
});
@@ -45,6 +45,7 @@ function refl(overrides: Partial<Reflection> = {}): Reflection {
created_at: 1,
acted_on_at: null,
dismissed_at: null,
thread_id: null,
...overrides,
};
}
@@ -138,6 +138,7 @@ const baseHeartbeatSettings = {
meeting_lookahead_minutes: 60,
max_calendar_connections_per_tick: 2,
reminder_lookahead_minutes: 30,
subconscious_mode: 'off' as 'off' | 'simple' | 'aggressive',
};
const baseUsage = {
@@ -0,0 +1,128 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSubconscious } from '../useSubconscious';
const mockStatus = {
result: {
enabled: true,
mode: 'simple',
provider_available: true,
provider_unavailable_reason: null,
interval_minutes: 30,
last_tick_at: null,
total_ticks: 0,
consecutive_failures: 0,
},
logs: [],
};
const mockSettings = {
result: {
settings: {
enabled: true,
interval_minutes: 30,
inference_enabled: true,
notify_meetings: false,
notify_reminders: false,
notify_relevant_events: false,
external_delivery_enabled: false,
meeting_lookahead_minutes: 120,
max_calendar_connections_per_tick: 2,
reminder_lookahead_minutes: 30,
subconscious_mode: 'simple' as const,
},
},
logs: [],
};
let currentMode = 'simple';
vi.mock('../../utils/tauriCommands', () => ({
isTauri: () => true,
subconsciousStatus: vi.fn(async () => mockStatus),
subconsciousTrigger: vi.fn(async () => ({ result: { triggered: true }, logs: [] })),
openhumanHeartbeatSettingsGet: vi.fn(async () => ({
result: { settings: { ...mockSettings.result.settings, subconscious_mode: currentMode } },
logs: [],
})),
openhumanHeartbeatSettingsSet: vi.fn(async (patch: Record<string, unknown>) => {
if (patch.subconscious_mode) currentMode = patch.subconscious_mode as string;
return {
result: {
settings: { ...mockSettings.result.settings, ...patch, subconscious_mode: currentMode },
},
logs: [],
};
}),
}));
describe('useSubconscious', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
currentMode = 'simple';
});
afterEach(() => {
vi.useRealTimers();
});
it('loads status and mode on mount', async () => {
const { result } = renderHook(() => useSubconscious());
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.mode).toBe('simple');
expect(result.current.intervalMinutes).toBe(30);
expect(result.current.status).not.toBeNull();
});
it('setMode calls heartbeat settings set', async () => {
const { openhumanHeartbeatSettingsSet } = await import('../../utils/tauriCommands');
const { result } = renderHook(() => useSubconscious());
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await result.current.setMode('aggressive');
});
expect(openhumanHeartbeatSettingsSet).toHaveBeenCalledWith({ subconscious_mode: 'aggressive' });
expect(result.current.mode).toBe('aggressive');
});
it('setIntervalMinutes calls heartbeat settings set', async () => {
const { openhumanHeartbeatSettingsSet } = await import('../../utils/tauriCommands');
const { result } = renderHook(() => useSubconscious());
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await result.current.setIntervalMinutes(15);
});
expect(openhumanHeartbeatSettingsSet).toHaveBeenCalledWith({ interval_minutes: 15 });
});
it('triggerTick calls subconsciousTrigger', async () => {
const { subconsciousTrigger } = await import('../../utils/tauriCommands');
const { result } = renderHook(() => useSubconscious());
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await result.current.triggerTick();
});
expect(subconsciousTrigger).toHaveBeenCalled();
});
});
+52 -139
View File
@@ -1,62 +1,42 @@
/**
* useSubconscious — hook for the subconscious engine UI.
*
* Provides tasks, escalations, execution log, and actions for the
* Provides status, mode control, and engine actions for the
* subconscious tab on the Intelligence page.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
isTauri,
subconsciousEscalationsApprove,
subconsciousEscalationsDismiss,
subconsciousEscalationsList,
subconsciousLogList,
openhumanHeartbeatSettingsGet,
openhumanHeartbeatSettingsSet,
subconsciousStatus,
subconsciousTasksAdd,
subconsciousTasksList,
subconsciousTasksRemove,
subconsciousTasksUpdate,
subconsciousTrigger,
} from '../utils/tauriCommands';
import type {
SubconsciousEscalation,
SubconsciousLogEntry,
SubconsciousStatus,
SubconsciousTask,
} from '../utils/tauriCommands/subconscious';
import type { SubconsciousMode } from '../utils/tauriCommands/heartbeat';
import type { SubconsciousStatus } from '../utils/tauriCommands/subconscious';
export interface UseSubconsciousResult {
// Data
tasks: SubconsciousTask[];
escalations: SubconsciousEscalation[];
logEntries: SubconsciousLogEntry[];
status: SubconsciousStatus | null;
// Loading states
mode: SubconsciousMode;
intervalMinutes: number;
loading: boolean;
triggering: boolean;
// Actions
settingMode: boolean;
refresh: () => Promise<void>;
triggerTick: () => Promise<void>;
addTask: (title: string) => Promise<void>;
removeTask: (taskId: string) => Promise<void>;
toggleTask: (taskId: string, enabled: boolean) => Promise<void>;
approveEscalation: (escalationId: string) => Promise<void>;
dismissEscalation: (escalationId: string) => Promise<void>;
// Error
setMode: (mode: SubconsciousMode) => Promise<void>;
setIntervalMinutes: (minutes: number) => Promise<void>;
error: string | null;
}
export function useSubconscious(): UseSubconsciousResult {
const [tasks, setTasks] = useState<SubconsciousTask[]>([]);
const [escalations, setEscalations] = useState<SubconsciousEscalation[]>([]);
const [logEntries, setLogEntries] = useState<SubconsciousLogEntry[]>([]);
const [status, setStatus] = useState<SubconsciousStatus | null>(null);
const [mode, setModeState] = useState<SubconsciousMode>('off');
const [intervalMinutes, setIntervalState] = useState(30);
const [loading, setLoading] = useState(false);
const [triggering, setTriggering] = useState(false);
const [settingMode, setSettingMode] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchingRef = useRef(false);
@@ -66,25 +46,24 @@ export function useSubconscious(): UseSubconsciousResult {
setLoading(true);
setError(null);
try {
// Each RPC is bounded by RPC_TIMEOUT_MS so Promise.all is guaranteed
// to settle. Without this, a single hung request (e.g. sidecar held
// in a long-running tick) would leave fetchingRef.current === true
// forever, and every subsequent 3s poll would silently no-op at the
// early-return above — freezing the Intelligence page on a stale
// snapshot. withTimeout returns null on timeout, matching the
// existing `.catch(() => null)` failure contract, so downstream
// setState calls just skip that slice for this tick.
const [tasksRes, escalationsRes, logRes, statusRes] = await Promise.all([
withTimeout(subconsciousTasksList()),
withTimeout(subconsciousEscalationsList('pending')),
withTimeout(subconsciousLogList(undefined, 30)),
const [statusRes, settingsRes] = await Promise.all([
withTimeout(subconsciousStatus()),
withTimeout(openhumanHeartbeatSettingsGet()),
]);
if (tasksRes) setTasks(unwrap(tasksRes) ?? []);
if (escalationsRes) setEscalations(unwrap(escalationsRes) ?? []);
if (logRes) setLogEntries(unwrap(logRes) ?? []);
if (statusRes) setStatus(unwrap(statusRes) ?? null);
const settings = settingsRes
? unwrap<{ settings: { subconscious_mode: SubconsciousMode; interval_minutes: number } }>(
settingsRes
)
: null;
if (settings?.settings) {
if (settings.settings.subconscious_mode) {
setModeState(settings.settings.subconscious_mode);
}
if (settings.settings.interval_minutes) {
setIntervalState(settings.settings.interval_minutes);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load subconscious data');
} finally {
@@ -105,85 +84,37 @@ export function useSubconscious(): UseSubconsciousResult {
}
}, [triggering]);
const addTask = useCallback(
async (title: string) => {
const setMode = useCallback(
async (newMode: SubconsciousMode) => {
if (!isTauri()) return;
setSettingMode(true);
setModeState(newMode);
try {
await subconsciousTasksAdd(title);
await openhumanHeartbeatSettingsSet({ subconscious_mode: newMode });
await refresh();
} catch (err) {
console.warn('[subconscious] add task failed:', err);
throw err;
console.warn('[subconscious] setMode failed:', err);
setError(err instanceof Error ? err.message : 'Failed to update mode');
} finally {
setSettingMode(false);
}
},
[refresh]
);
const removeTask = useCallback(
async (taskId: string) => {
if (!isTauri()) return;
try {
await subconsciousTasksRemove(taskId);
await refresh();
} catch (err) {
console.warn('[subconscious] remove task failed:', err);
}
},
[refresh]
);
const setIntervalMinutes = useCallback(async (minutes: number) => {
if (!isTauri()) return;
setIntervalState(minutes);
try {
await openhumanHeartbeatSettingsSet({ interval_minutes: minutes });
} catch (err) {
console.warn('[subconscious] setInterval failed:', err);
}
}, []);
const toggleTask = useCallback(
async (taskId: string, enabled: boolean) => {
if (!isTauri()) return;
try {
await subconsciousTasksUpdate(taskId, { enabled });
await refresh();
} catch (err) {
console.warn('[subconscious] toggle task failed:', err);
}
},
[refresh]
);
const approveEscalation = useCallback(
async (escalationId: string) => {
if (!isTauri()) return;
try {
await subconsciousEscalationsApprove(escalationId);
await refresh();
} catch (err) {
console.warn('[subconscious] approve failed:', err);
throw err;
}
},
[refresh]
);
const dismissEscalation = useCallback(
async (escalationId: string) => {
if (!isTauri()) return;
try {
await subconsciousEscalationsDismiss(escalationId);
await refresh();
} catch (err) {
console.warn('[subconscious] dismiss failed:', err);
}
},
[refresh]
);
// Poll every 3s while the hook is mounted (user is on Subconscious tab).
// Picks up all state changes: in_progress → act/noop/escalate/failed,
// new escalations, background tick completions, etc.
//
// On unmount we also clear fetchingRef — otherwise a request that times
// out or resolves after the component has been torn down would leave the
// ref stuck `true` for the next mount (React Strict Mode double-mount in
// dev, or tab navigation back to Intelligence), silently wedging the
// poller exactly as before.
useEffect(() => {
refresh();
const interval = setInterval(refresh, 3000);
const interval = setInterval(refresh, 5000);
return () => {
clearInterval(interval);
fetchingRef.current = false;
@@ -191,36 +122,22 @@ export function useSubconscious(): UseSubconsciousResult {
}, [refresh]);
return {
tasks,
escalations,
logEntries,
status,
mode,
intervalMinutes,
loading,
triggering,
settingMode,
refresh,
triggerTick,
addTask,
removeTask,
toggleTask,
approveEscalation,
dismissEscalation,
setMode,
setIntervalMinutes,
error,
};
}
/**
* Per-RPC client-side timeout for the polling refresh. Must be strictly
* less than the 3s poll interval so a hung call can't stack up across
* ticks. 2500ms leaves a 500ms safety margin.
*/
const RPC_TIMEOUT_MS = 2500;
/**
* Race a promise against a timeout. Resolves to `null` on timeout or
* rejection — matching the prior `.catch(() => null)` contract used by
* the refresh logic so downstream code can treat "no data this tick" and
* "RPC failed this tick" identically.
*/
function withTimeout<T>(promise: Promise<T>, ms: number = RPC_TIMEOUT_MS): Promise<T | null> {
return Promise.race<T | null>([
promise.catch(() => null),
@@ -228,13 +145,9 @@ function withTimeout<T>(promise: Promise<T>, ms: number = RPC_TIMEOUT_MS): Promi
]);
}
/**
* Unwrap a CommandResponse — callCoreRpc returns `{ result: T, logs: [...] }`.
*/
function unwrap<T>(response: unknown): T | null {
if (!response || typeof response !== 'object') return null;
const r = response as Record<string, unknown>;
// CommandResponse shape: { result: T, logs: string[] }
if ('result' in r) {
return r.result as T;
}
+16 -8
View File
@@ -1882,6 +1882,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'الإجراء المقترح',
'reflections.act': 'تنفيذ',
'reflections.dismiss': 'تجاهل',
'reflections.viewConversation': 'عرض',
'subconscious.mode.label': 'وضع اللاوعي',
'subconscious.mode.off.title': 'إيقاف',
'subconscious.mode.off.desc': 'اللاوعي معطل.',
'subconscious.mode.simple.title': 'بسيط',
'subconscious.mode.simple.desc': 'مراقبة للقراءة فقط. الوصول للذاكرة والملفات فقط.',
'subconscious.mode.aggressive.title': 'مكثف',
'subconscious.mode.aggressive.desc':
'وصول كامل للأدوات. يمكنه الكتابة وإنشاء وكلاء وتفويض المهام.',
'subconscious.mode.aggressiveWarning':
'الوضع المكثف يمنح اللاوعي وصولاً كاملاً للأدوات بما في ذلك الكتابة وإنشاء الوكلاء الفرعيين.',
'subconscious.interval.label': 'التردد',
'subconscious.interval.minutes': '{n} د',
'subconscious.interval.hours': '{n} س',
'subconscious.interval.oneHour': 'ساعة واحدة',
'subconscious.interval.oneDay': '24 ساعة',
'whatsapp.chatsSynced': 'محادثات مزامنة',
'whatsapp.chatSynced': 'محادثة مزامنة',
'sync.active': 'نشط',
@@ -4061,14 +4077,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'التصفية حسب المصدر',
'calls.comingSoonDescription': 'المكالمات بمساعدة الذكاء الاصطناعي قادمة قريباً. ابقَ على اطلاع.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 دقائق',
'subconscious.interval.tenMinutes': '10 دقائق',
'subconscious.interval.fifteenMinutes': '15 دقيقة',
'subconscious.interval.thirtyMinutes': '30 دقيقة',
'subconscious.interval.oneHour': 'ساعة واحدة',
'subconscious.interval.sixHours': '6 ساعات',
'subconscious.interval.twelveHours': '12 ساعة',
'subconscious.interval.oneDay': 'يوم واحد',
'subconscious.priority.critical': 'حرجة',
'subconscious.priority.important': 'مهم',
'subconscious.priority.normal': 'عادي',
+16 -8
View File
@@ -1920,6 +1920,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'প্রস্তাবিত কাজ',
'reflections.act': 'কাজ করুন',
'reflections.dismiss': 'বাদ দিন',
'reflections.viewConversation': 'দেখুন',
'subconscious.mode.label': 'অবচেতন মোড',
'subconscious.mode.off.title': 'বন্ধ',
'subconscious.mode.off.desc': 'অবচেতন নিষ্ক্রিয়।',
'subconscious.mode.simple.title': 'সরল',
'subconscious.mode.simple.desc': 'শুধুমাত্র পঠনযোগ্য পর্যবেক্ষণ। শুধু মেমরি ও ফাইল অ্যাক্সেস।',
'subconscious.mode.aggressive.title': 'আক্রমণাত্মক',
'subconscious.mode.aggressive.desc':
'সম্পূর্ণ টুল অ্যাক্সেস। লিখতে, এজেন্ট তৈরি ও কাজ অর্পণ করতে পারে।',
'subconscious.mode.aggressiveWarning':
'আক্রমণাত্মক মোড অবচেতনকে লেখা ও সাব-এজেন্ট তৈরিসহ সম্পূর্ণ টুল অ্যাক্সেস দেয়।',
'subconscious.interval.label': 'ফ্রিকোয়েন্সি',
'subconscious.interval.minutes': '{n} মি',
'subconscious.interval.hours': '{n} ঘ',
'subconscious.interval.oneHour': '১ ঘণ্টা',
'subconscious.interval.oneDay': '২৪ ঘণ্টা',
'whatsapp.chatsSynced': 'চ্যাট সিঙ্ক হয়েছে',
'whatsapp.chatSynced': 'চ্যাট সিঙ্ক হয়েছে',
'sync.active': 'সক্রিয়',
@@ -4129,14 +4145,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'উত্স দ্বারা ফিল্টার',
'calls.comingSoonDescription': 'AI-সহায়তা কলগুলি শীঘ্রই আসছে৷ সাথে থাকুন।',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 মিনিট',
'subconscious.interval.tenMinutes': '10 মিনিট',
'subconscious.interval.fifteenMinutes': '15 মিনিট',
'subconscious.interval.thirtyMinutes': '30 মিনিট',
'subconscious.interval.oneHour': '1 ঘন্টা',
'subconscious.interval.sixHours': '1 ঘন্টা',
'subconscious.interval.twelveHours': '[[I18N_SEP_92731]] 12 ঘন্টা',
'subconscious.interval.oneDay': '1 দিন',
'subconscious.priority.critical': 'গুরুত্বপূর্ণ',
'subconscious.priority.important': 'গুরুত্বপূর্ণ',
'subconscious.priority.normal': 'স্বাভাবিক',
+16 -8
View File
@@ -1968,6 +1968,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Vorgeschlagene Aktion',
'reflections.act': 'Handeln',
'reflections.dismiss': 'Entlassen',
'reflections.viewConversation': 'Ansehen',
'subconscious.mode.label': 'Unterbewusstsein-Modus',
'subconscious.mode.off.title': 'Aus',
'subconscious.mode.off.desc': 'Unterbewusstsein ist deaktiviert.',
'subconscious.mode.simple.title': 'Einfach',
'subconscious.mode.simple.desc': 'Nur-Lese-Beobachtung. Nur Speicher- und Dateizugriff.',
'subconscious.mode.aggressive.title': 'Aggressiv',
'subconscious.mode.aggressive.desc':
'Voller Werkzeugzugriff. Kann schreiben, Agenten starten und Aufgaben delegieren.',
'subconscious.mode.aggressiveWarning':
'Aggressiver Modus gewährt dem Unterbewusstsein vollen Werkzeugzugriff einschließlich Schreiben und Sub-Agenten-Erstellung.',
'subconscious.interval.label': 'Häufigkeit',
'subconscious.interval.minutes': '{n} Min',
'subconscious.interval.hours': '{n} Std',
'subconscious.interval.oneHour': '1 Stunde',
'subconscious.interval.oneDay': '24 Stunden',
'whatsapp.chatsSynced': 'Chats synchronisiert',
'whatsapp.chatSynced': 'Chat synchronisiert',
'sync.active': 'Aktiv',
@@ -4243,14 +4259,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Nach Quelle filtern',
'calls.comingSoonDescription': 'KI-unterstützte Anrufe folgen in Kürze. Bleiben Sie dran.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 Min.',
'subconscious.interval.tenMinutes': '10 Min.',
'subconscious.interval.fifteenMinutes': '15 Min.',
'subconscious.interval.thirtyMinutes': '30 Min.',
'subconscious.interval.oneHour': '1 Stunde',
'subconscious.interval.sixHours': '6 Stunden',
'subconscious.interval.twelveHours': '12 Stunden',
'subconscious.interval.oneDay': '1 Tag',
'subconscious.priority.critical': 'kritisch',
'subconscious.priority.important': 'wichtig',
'subconscious.priority.normal': 'normal',
+18 -8
View File
@@ -2150,6 +2150,24 @@ const en: TranslationMap = {
'reflections.proposedAction': 'Proposed Action',
'reflections.act': 'Act',
'reflections.dismiss': 'Dismiss',
'reflections.viewConversation': 'View',
// Subconscious mode selector
'subconscious.mode.label': 'Subconscious Mode',
'subconscious.mode.off.title': 'Off',
'subconscious.mode.off.desc': 'Subconscious is disabled.',
'subconscious.mode.simple.title': 'Simple',
'subconscious.mode.simple.desc': 'Read-only observation. Memory and file access only.',
'subconscious.mode.aggressive.title': 'Aggressive',
'subconscious.mode.aggressive.desc':
'Full tool access. Can write, spawn agents, and delegate tasks.',
'subconscious.mode.aggressiveWarning':
'Aggressive mode gives the subconscious full tool access including writes and sub-agent spawning.',
'subconscious.interval.label': 'Frequency',
'subconscious.interval.minutes': '{n} min',
'subconscious.interval.hours': '{n}h',
'subconscious.interval.oneHour': '1 hour',
'subconscious.interval.oneDay': '24 hours',
// WhatsApp
'whatsapp.chatsSynced': 'chats synced',
@@ -4429,14 +4447,6 @@ const en: TranslationMap = {
'memory.sourceFilterAria': 'Filter by source',
'calls.comingSoonDescription': 'AI-assisted calls are coming soon. Stay tuned.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
'subconscious.interval.fifteenMinutes': '15 min',
'subconscious.interval.thirtyMinutes': '30 min',
'subconscious.interval.oneHour': '1 hour',
'subconscious.interval.sixHours': '6 hours',
'subconscious.interval.twelveHours': '12 hours',
'subconscious.interval.oneDay': '1 day',
'subconscious.priority.critical': 'critical',
'subconscious.priority.important': 'important',
'subconscious.priority.normal': 'normal',
+16 -8
View File
@@ -1960,6 +1960,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Acción propuesta',
'reflections.act': 'Actuar',
'reflections.dismiss': 'Descartar',
'reflections.viewConversation': 'Ver',
'subconscious.mode.label': 'Modo subconsciente',
'subconscious.mode.off.title': 'Apagado',
'subconscious.mode.off.desc': 'El subconsciente está desactivado.',
'subconscious.mode.simple.title': 'Simple',
'subconscious.mode.simple.desc': 'Observación de solo lectura. Solo acceso a memoria y archivos.',
'subconscious.mode.aggressive.title': 'Agresivo',
'subconscious.mode.aggressive.desc':
'Acceso completo a herramientas. Puede escribir, crear agentes y delegar tareas.',
'subconscious.mode.aggressiveWarning':
'El modo agresivo otorga al subconsciente acceso completo a herramientas, incluyendo escritura y creación de subagentes.',
'subconscious.interval.label': 'Frecuencia',
'subconscious.interval.minutes': '{n} min',
'subconscious.interval.hours': '{n}h',
'subconscious.interval.oneHour': '1 hora',
'subconscious.interval.oneDay': '24 horas',
'whatsapp.chatsSynced': 'chats sincronizados',
'whatsapp.chatSynced': 'chat sincronizado',
'sync.active': 'Activo',
@@ -4209,14 +4225,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Filtrar por fuente',
'calls.comingSoonDescription': 'Las llamadas asistidas por IA llegarán pronto. Mantente atento.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minutos',
'subconscious.interval.tenMinutes': '10 minutos',
'subconscious.interval.fifteenMinutes': '15 minutos',
'subconscious.interval.thirtyMinutes': '30 minutos',
'subconscious.interval.oneHour': '1 hora',
'subconscious.interval.sixHours': '6 horas',
'subconscious.interval.twelveHours': '12 horas',
'subconscious.interval.oneDay': '1 dia',
'subconscious.priority.critical': 'crítico',
'subconscious.priority.important': 'importante',
'subconscious.priority.normal': 'normales',
+17 -8
View File
@@ -1966,6 +1966,23 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Action proposée',
'reflections.act': 'Agir',
'reflections.dismiss': 'Ignorer',
'reflections.viewConversation': 'Voir',
'subconscious.mode.label': 'Mode subconscient',
'subconscious.mode.off.title': 'Désactivé',
'subconscious.mode.off.desc': 'Le subconscient est désactivé.',
'subconscious.mode.simple.title': 'Simple',
'subconscious.mode.simple.desc':
'Observation en lecture seule. Accès mémoire et fichiers uniquement.',
'subconscious.mode.aggressive.title': 'Agressif',
'subconscious.mode.aggressive.desc':
'Accès complet aux outils. Peut écrire, créer des agents et déléguer des tâches.',
'subconscious.mode.aggressiveWarning':
"Le mode agressif donne au subconscient un accès complet aux outils, y compris l'écriture et la création de sous-agents.",
'subconscious.interval.label': 'Fréquence',
'subconscious.interval.minutes': '{n} min',
'subconscious.interval.hours': '{n}h',
'subconscious.interval.oneHour': '1 heure',
'subconscious.interval.oneDay': '24 heures',
'whatsapp.chatsSynced': 'conversations synchronisées',
'whatsapp.chatSynced': 'conversation synchronisée',
'sync.active': 'Actif',
@@ -4224,14 +4241,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Filtrer par source',
'calls.comingSoonDescription': "Les appels assistés par IA arrivent bientôt. Restez à l'écoute.",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
'subconscious.interval.fifteenMinutes': '15 min',
'subconscious.interval.thirtyMinutes': '30 min',
'subconscious.interval.oneHour': '1 heure',
'subconscious.interval.sixHours': '6 heures',
'subconscious.interval.twelveHours': '12 heures',
'subconscious.interval.oneDay': '1 jour',
'subconscious.priority.critical': 'critique',
'subconscious.priority.important': 'important',
'subconscious.priority.normal': 'normal',
+16 -8
View File
@@ -1920,6 +1920,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'प्रस्तावित एक्शन',
'reflections.act': 'करें',
'reflections.dismiss': 'हटाएं',
'reflections.viewConversation': 'देखें',
'subconscious.mode.label': 'अवचेतन मोड',
'subconscious.mode.off.title': 'बंद',
'subconscious.mode.off.desc': 'अवचेतन अक्षम है।',
'subconscious.mode.simple.title': 'सरल',
'subconscious.mode.simple.desc': 'केवल-पठन अवलोकन। केवल मेमोरी और फ़ाइल एक्सेस।',
'subconscious.mode.aggressive.title': 'आक्रामक',
'subconscious.mode.aggressive.desc':
'पूर्ण टूल एक्सेस। लिख सकता है, एजेंट बना सकता है और कार्य सौंप सकता है।',
'subconscious.mode.aggressiveWarning':
'आक्रामक मोड अवचेतन को लेखन और उप-एजेंट निर्माण सहित पूर्ण टूल एक्सेस देता है।',
'subconscious.interval.label': 'आवृत्ति',
'subconscious.interval.minutes': '{n} मि',
'subconscious.interval.hours': '{n} घं',
'subconscious.interval.oneHour': '1 घंटा',
'subconscious.interval.oneDay': '24 घंटे',
'whatsapp.chatsSynced': 'चैट्स सिंक हुईं',
'whatsapp.chatSynced': 'चैट सिंक हुई',
'sync.active': 'एक्टिव',
@@ -4138,14 +4154,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'स्रोत के अनुसार फ़िल्टर करें',
'calls.comingSoonDescription': 'एआई-सहायक कॉल जल्द ही आ रही हैं। बने रहें।',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 मिनट',
'subconscious.interval.tenMinutes': '10 मिनट',
'subconscious.interval.fifteenMinutes': '15 मि',
'subconscious.interval.thirtyMinutes': '30 मि',
'subconscious.interval.oneHour': '1 घंटा',
'subconscious.interval.sixHours': '6 घंटे',
'subconscious.interval.twelveHours': '12 घंटे',
'subconscious.interval.oneDay': '1 दिन',
'subconscious.priority.critical': 'आलोचनात्मक',
'subconscious.priority.important': 'महत्वपूर्ण',
'subconscious.priority.normal': 'सामान्य',
+16 -8
View File
@@ -1923,6 +1923,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Tindakan yang Diusulkan',
'reflections.act': 'Tindakan',
'reflections.dismiss': 'Abaikan',
'reflections.viewConversation': 'Lihat',
'subconscious.mode.label': 'Mode Alam Bawah Sadar',
'subconscious.mode.off.title': 'Mati',
'subconscious.mode.off.desc': 'Alam bawah sadar dinonaktifkan.',
'subconscious.mode.simple.title': 'Sederhana',
'subconscious.mode.simple.desc': 'Pengamatan hanya-baca. Hanya akses memori dan file.',
'subconscious.mode.aggressive.title': 'Agresif',
'subconscious.mode.aggressive.desc':
'Akses alat penuh. Dapat menulis, membuat agen, dan mendelegasikan tugas.',
'subconscious.mode.aggressiveWarning':
'Mode agresif memberikan alam bawah sadar akses alat penuh termasuk menulis dan membuat sub-agen.',
'subconscious.interval.label': 'Frekuensi',
'subconscious.interval.minutes': '{n} mnt',
'subconscious.interval.hours': '{n} jam',
'subconscious.interval.oneHour': '1 jam',
'subconscious.interval.oneDay': '24 jam',
'whatsapp.chatsSynced': 'obrolan disinkronkan',
'whatsapp.chatSynced': 'obrolan disinkronkan',
'sync.active': 'Aktif',
@@ -4149,14 +4165,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Filter berdasarkan sumber',
'calls.comingSoonDescription': 'Panggilan dengan bantuan AI akan segera hadir. Pantau terus.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 menit',
'subconscious.interval.tenMinutes': '10 menit',
'subconscious.interval.fifteenMinutes': '15 menit',
'subconscious.interval.thirtyMinutes': '30 menit',
'subconscious.interval.oneHour': '1 jam',
'subconscious.interval.sixHours': '6 jam',
'subconscious.interval.twelveHours': '12 jam',
'subconscious.interval.oneDay': '1 hari',
'subconscious.priority.critical': 'kritis',
'subconscious.priority.important': 'penting',
'subconscious.priority.normal': 'normal',
+16 -8
View File
@@ -1951,6 +1951,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Azione proposta',
'reflections.act': 'Agisci',
'reflections.dismiss': 'Ignora',
'reflections.viewConversation': 'Visualizza',
'subconscious.mode.label': 'Modalità subconscio',
'subconscious.mode.off.title': 'Disattivato',
'subconscious.mode.off.desc': 'Il subconscio è disattivato.',
'subconscious.mode.simple.title': 'Semplice',
'subconscious.mode.simple.desc': 'Osservazione in sola lettura. Solo accesso a memoria e file.',
'subconscious.mode.aggressive.title': 'Aggressivo',
'subconscious.mode.aggressive.desc':
'Accesso completo agli strumenti. Può scrivere, creare agenti e delegare compiti.',
'subconscious.mode.aggressiveWarning':
'La modalità aggressiva concede al subconscio accesso completo agli strumenti, inclusa scrittura e creazione di sotto-agenti.',
'subconscious.interval.label': 'Frequenza',
'subconscious.interval.minutes': '{n} min',
'subconscious.interval.hours': '{n}h',
'subconscious.interval.oneHour': '1 ora',
'subconscious.interval.oneDay': '24 ore',
'whatsapp.chatsSynced': 'chat sincronizzate',
'whatsapp.chatSynced': 'chat sincronizzata',
'sync.active': 'Attivo',
@@ -4201,14 +4217,6 @@ const messages: TranslationMap = {
'calls.comingSoonDescription':
"Le chiamate assistite dall'IA sono in arrivo. Resta sintonizzato.",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minuti',
'subconscious.interval.tenMinutes': '10 minuti',
'subconscious.interval.fifteenMinutes': '15 minuti',
'subconscious.interval.thirtyMinutes': '30 minuti',
'subconscious.interval.oneHour': '1 ora',
'subconscious.interval.sixHours': '6 ore',
'subconscious.interval.twelveHours': '12 ore',
'subconscious.interval.oneDay': '1 giorno',
'subconscious.priority.critical': 'critico',
'subconscious.priority.important': 'importante',
'subconscious.priority.normal': 'normale',
+15 -8
View File
@@ -1900,6 +1900,21 @@ const messages: TranslationMap = {
'reflections.proposedAction': '제안된 작업',
'reflections.act': '실행',
'reflections.dismiss': '닫기',
'reflections.viewConversation': '보기',
'subconscious.mode.label': '잠재의식 모드',
'subconscious.mode.off.title': '끔',
'subconscious.mode.off.desc': '잠재의식이 비활성화되었습니다.',
'subconscious.mode.simple.title': '심플',
'subconscious.mode.simple.desc': '읽기 전용 관찰. 메모리 및 파일 접근만 가능.',
'subconscious.mode.aggressive.title': '적극적',
'subconscious.mode.aggressive.desc': '전체 도구 접근. 쓰기, 에이전트 생성, 작업 위임 가능.',
'subconscious.mode.aggressiveWarning':
'적극적 모드는 잠재의식에 쓰기 및 하위 에이전트 생성을 포함한 전체 도구 접근 권한을 부여합니다.',
'subconscious.interval.label': '빈도',
'subconscious.interval.minutes': '{n}분',
'subconscious.interval.hours': '{n}시간',
'subconscious.interval.oneHour': '1시간',
'subconscious.interval.oneDay': '24시간',
'whatsapp.chatsSynced': '채팅 동기화됨',
'whatsapp.chatSynced': '채팅 동기화됨',
'sync.active': '활성',
@@ -4097,14 +4112,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': '소스별 필터링',
'calls.comingSoonDescription': 'AI 지원 통화가 곧 제공됩니다. 기대해 주세요.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5분',
'subconscious.interval.tenMinutes': '10분',
'subconscious.interval.fifteenMinutes': '15분',
'subconscious.interval.thirtyMinutes': '30분',
'subconscious.interval.oneHour': '1시간',
'subconscious.interval.sixHours': '6시간',
'subconscious.interval.twelveHours': '12시간',
'subconscious.interval.oneDay': '1일',
'subconscious.priority.critical': '심각',
'subconscious.priority.important': '중요',
'subconscious.priority.normal': '정상',
+16 -8
View File
@@ -1941,6 +1941,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Proponowane działanie',
'reflections.act': 'Wykonaj',
'reflections.dismiss': 'Odrzuć',
'reflections.viewConversation': 'Zobacz',
'subconscious.mode.label': 'Tryb podświadomości',
'subconscious.mode.off.title': 'Wyłączony',
'subconscious.mode.off.desc': 'Podświadomość jest wyłączona.',
'subconscious.mode.simple.title': 'Prosty',
'subconscious.mode.simple.desc': 'Obserwacja tylko do odczytu. Tylko dostęp do pamięci i plików.',
'subconscious.mode.aggressive.title': 'Agresywny',
'subconscious.mode.aggressive.desc':
'Pełny dostęp do narzędzi. Może pisać, tworzyć agentów i delegować zadania.',
'subconscious.mode.aggressiveWarning':
'Tryb agresywny daje podświadomości pełny dostęp do narzędzi, w tym pisanie i tworzenie podagentów.',
'subconscious.interval.label': 'Częstotliwość',
'subconscious.interval.minutes': '{n} min',
'subconscious.interval.hours': '{n} godz',
'subconscious.interval.oneHour': '1 godzina',
'subconscious.interval.oneDay': '24 godziny',
'whatsapp.chatsSynced': 'rozmów zsynchronizowano',
'whatsapp.chatSynced': 'rozmowa zsynchronizowana',
'sync.active': 'Aktywna',
@@ -4202,14 +4218,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Filtruj po źródle',
'calls.comingSoonDescription': 'Połączenia wspierane AI pojawią się wkrótce. Bądź na bieżąco.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
'subconscious.interval.fifteenMinutes': '15 min',
'subconscious.interval.thirtyMinutes': '30 min',
'subconscious.interval.oneHour': '1 godz.',
'subconscious.interval.sixHours': '6 godz.',
'subconscious.interval.twelveHours': '12 godz.',
'subconscious.interval.oneDay': '1 dzień',
'subconscious.priority.critical': 'krytyczne',
'subconscious.priority.important': 'ważne',
'subconscious.priority.normal': 'normalne',
+17 -8
View File
@@ -1957,6 +1957,23 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Ação Proposta',
'reflections.act': 'Agir',
'reflections.dismiss': 'Dispensar',
'reflections.viewConversation': 'Ver',
'subconscious.mode.label': 'Modo subconsciente',
'subconscious.mode.off.title': 'Desligado',
'subconscious.mode.off.desc': 'O subconsciente está desativado.',
'subconscious.mode.simple.title': 'Simples',
'subconscious.mode.simple.desc':
'Observação somente leitura. Apenas acesso a memória e arquivos.',
'subconscious.mode.aggressive.title': 'Agressivo',
'subconscious.mode.aggressive.desc':
'Acesso completo a ferramentas. Pode escrever, criar agentes e delegar tarefas.',
'subconscious.mode.aggressiveWarning':
'O modo agressivo concede ao subconsciente acesso completo a ferramentas, incluindo escrita e criação de subagentes.',
'subconscious.interval.label': 'Frequência',
'subconscious.interval.minutes': '{n} min',
'subconscious.interval.hours': '{n}h',
'subconscious.interval.oneHour': '1 hora',
'subconscious.interval.oneDay': '24 horas',
'whatsapp.chatsSynced': 'chats sincronizados',
'whatsapp.chatSynced': 'chat sincronizado',
'sync.active': 'Ativo',
@@ -4201,14 +4218,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Filtrar por origem',
'calls.comingSoonDescription': 'Chamadas assistidas por IA chegam em breve. Fique ligado.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minutos',
'subconscious.interval.tenMinutes': '10 minutos',
'subconscious.interval.fifteenMinutes': '15 minutos',
'subconscious.interval.thirtyMinutes': '30 minutos',
'subconscious.interval.oneHour': '1 hora',
'subconscious.interval.sixHours': '6 horas',
'subconscious.interval.twelveHours': '12 horas',
'subconscious.interval.oneDay': '1 dia',
'subconscious.priority.critical': 'crítico',
'subconscious.priority.important': 'importante',
'subconscious.priority.normal': 'normal',
+16 -8
View File
@@ -1933,6 +1933,22 @@ const messages: TranslationMap = {
'reflections.proposedAction': 'Предлагаемое действие',
'reflections.act': 'Выполнить',
'reflections.dismiss': 'Закрыть',
'reflections.viewConversation': 'Просмотр',
'subconscious.mode.label': 'Режим подсознания',
'subconscious.mode.off.title': 'Выкл',
'subconscious.mode.off.desc': 'Подсознание отключено.',
'subconscious.mode.simple.title': 'Простой',
'subconscious.mode.simple.desc': 'Наблюдение в режиме чтения. Только доступ к памяти и файлам.',
'subconscious.mode.aggressive.title': 'Агрессивный',
'subconscious.mode.aggressive.desc':
'Полный доступ к инструментам. Может писать, создавать агентов и делегировать задачи.',
'subconscious.mode.aggressiveWarning':
'Агрессивный режим даёт подсознанию полный доступ к инструментам, включая запись и создание подагентов.',
'subconscious.interval.label': 'Частота',
'subconscious.interval.minutes': '{n} мин',
'subconscious.interval.hours': '{n} ч',
'subconscious.interval.oneHour': '1 час',
'subconscious.interval.oneDay': '24 часа',
'whatsapp.chatsSynced': 'чатов синхронизировано',
'whatsapp.chatSynced': 'чат синхронизирован',
'sync.active': 'Активно',
@@ -4169,14 +4185,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Фильтровать по источнику',
'calls.comingSoonDescription': 'Звонки с поддержкой ИИ скоро появятся. Следите за обновлениями.',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 минут',
'subconscious.interval.tenMinutes': '10 минут',
'subconscious.interval.fifteenMinutes': '15 минут',
'subconscious.interval.thirtyMinutes': '30 минут',
'subconscious.interval.oneHour': '1 час',
'subconscious.interval.sixHours': '6 часов',
'subconscious.interval.twelveHours': '12 часов',
'subconscious.interval.oneDay': '1 день',
'subconscious.priority.critical': 'критический',
'subconscious.priority.important': 'важный',
'subconscious.priority.normal': 'нормальный',
+15 -8
View File
@@ -1823,6 +1823,21 @@ const messages: TranslationMap = {
'reflections.proposedAction': '建议操作',
'reflections.act': '执行',
'reflections.dismiss': '忽略',
'reflections.viewConversation': '查看',
'subconscious.mode.label': '潜意识模式',
'subconscious.mode.off.title': '关闭',
'subconscious.mode.off.desc': '潜意识已禁用。',
'subconscious.mode.simple.title': '简单',
'subconscious.mode.simple.desc': '只读观察。仅可访问记忆和文件。',
'subconscious.mode.aggressive.title': '积极',
'subconscious.mode.aggressive.desc': '完整工具访问。可写入、创建代理和委派任务。',
'subconscious.mode.aggressiveWarning':
'积极模式赋予潜意识完整的工具访问权限,包括写入和创建子代理。',
'subconscious.interval.label': '频率',
'subconscious.interval.minutes': '{n}分钟',
'subconscious.interval.hours': '{n}小时',
'subconscious.interval.oneHour': '1小时',
'subconscious.interval.oneDay': '24小时',
'whatsapp.chatsSynced': '个对话已同步',
'whatsapp.chatSynced': '个对话已同步',
'sync.active': '活跃',
@@ -3934,14 +3949,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': '按来源过滤',
'calls.comingSoonDescription': '人工智能辅助通话即将推出。敬请关注。',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5分钟',
'subconscious.interval.tenMinutes': '10分钟',
'subconscious.interval.fifteenMinutes': '15分钟',
'subconscious.interval.thirtyMinutes': '30分钟',
'subconscious.interval.oneHour': '1小时',
'subconscious.interval.sixHours': '6小时',
'subconscious.interval.twelveHours': '12小时',
'subconscious.interval.oneDay': '1天',
'subconscious.priority.critical': '批评的',
'subconscious.priority.important': '重要的',
'subconscious.priority.normal': '正常',
+10 -24
View File
@@ -42,21 +42,15 @@ export default function Intelligence() {
// Subconscious engine data
const {
tasks: subconsciousTasks,
escalations,
logEntries,
status: subconsciousEngineStatus,
loading: subconsciousLoading,
mode: subconsciousMode,
intervalMinutes: subconsciousInterval,
triggering: subconsciousTriggering,
settingMode: subconsciousSettingMode,
triggerTick,
addTask: addSubconsciousTask,
removeTask: removeSubconsciousTask,
toggleTask: toggleSubconsciousTask,
approveEscalation,
dismissEscalation,
setMode: setSubconsciousMode,
setIntervalMinutes: setSubconsciousInterval,
} = useSubconscious();
const [newTaskTitle, setNewTaskTitle] = useState('');
const [expandedLogIds, setExpandedLogIds] = useState<Set<string>>(new Set());
// Socket integration
const socketManager = useIntelligenceSocketManager();
@@ -175,22 +169,14 @@ export default function Intelligence() {
{activeTab === 'subconscious' && (
<IntelligenceSubconsciousTab
addSubconsciousTask={addSubconsciousTask}
approveEscalation={approveEscalation}
dismissEscalation={dismissEscalation}
escalations={escalations}
expandedLogIds={expandedLogIds}
loading={subconsciousLoading}
logEntries={logEntries}
newTaskTitle={newTaskTitle}
removeSubconsciousTask={removeSubconsciousTask}
setExpandedLogIds={setExpandedLogIds}
setNewTaskTitle={setNewTaskTitle}
status={subconsciousEngineStatus}
tasks={subconsciousTasks}
toggleSubconsciousTask={toggleSubconsciousTask}
mode={subconsciousMode}
intervalMinutes={subconsciousInterval}
triggerTick={triggerTick}
triggering={subconsciousTriggering}
settingMode={subconsciousSettingMode}
setMode={setSubconsciousMode}
setIntervalMinutes={setSubconsciousInterval}
/>
)}
+1 -39
View File
@@ -47,7 +47,7 @@ import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '..
import type { ToastNotification } from '../types/intelligence';
import { IS_DEV } from '../utils/config';
import { isLocalSessionToken } from '../utils/localSession';
import { openhumanComposioGetMode, subconsciousEscalationsDismiss } from '../utils/tauriCommands';
import { openhumanComposioGetMode } from '../utils/tauriCommands';
import SkillsDashboard from './SkillsDashboard';
function channelStatusLabel(status: ChannelConnectionStatus, t: (key: string) => string): string {
@@ -426,43 +426,6 @@ export default function Skills() {
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
const pendingEscalationId =
location.state &&
typeof location.state === 'object' &&
'subconsciousEscalationId' in location.state &&
typeof location.state.subconsciousEscalationId === 'string'
? location.state.subconsciousEscalationId
: null;
const clearPendingEscalationState = useCallback(() => {
navigate(location.pathname, { replace: true, state: null });
}, [location.pathname, navigate]);
const dismissPendingEscalationIfResolved = useCallback(
async (resolution: string) => {
if (!pendingEscalationId) return;
console.debug('[skills][subconscious] dismiss escalation:start', {
escalationId: pendingEscalationId,
resolution,
});
try {
await subconsciousEscalationsDismiss(pendingEscalationId);
console.debug('[skills][subconscious] dismiss escalation:success', {
escalationId: pendingEscalationId,
resolution,
});
} catch (error) {
console.debug('[skills][subconscious] dismiss escalation:error', {
escalationId: pendingEscalationId,
resolution,
error: error instanceof Error ? error.message : String(error),
});
return;
}
clearPendingEscalationState();
},
[clearPendingEscalationState, pendingEscalationId]
);
// Discover SKILL.md skills via the core RPC. Ignore failures — the rest of
// the page still works when the sidecar is unreachable or no skills exist.
@@ -1156,7 +1119,6 @@ export default function Skills() {
}
onChanged={() => {
void refreshComposio();
void dismissPendingEscalationIfResolved(`composio:${composioModalToolkit.slug}`);
}}
onClose={() => setComposioModalToolkit(null)}
/>
@@ -59,11 +59,7 @@ vi.mock('../../utils/tauriCommands', async () => {
const actual = await vi.importActual<typeof import('../../utils/tauriCommands')>(
'../../utils/tauriCommands'
);
return {
...actual,
openhumanComposioGetMode: vi.fn(async () => composioModeStatus),
subconsciousEscalationsDismiss: vi.fn(),
};
return { ...actual, openhumanComposioGetMode: vi.fn(async () => composioModeStatus) };
});
describe('Skills page — Composio catalog fallback', () => {
+3
View File
@@ -4,6 +4,8 @@
import { callCoreRpc } from '../../services/coreRpcClient';
import { type CommandResponse, isTauri } from './common';
export type SubconsciousMode = 'off' | 'simple' | 'aggressive';
export interface HeartbeatSettings {
enabled: boolean;
interval_minutes: number;
@@ -15,6 +17,7 @@ export interface HeartbeatSettings {
meeting_lookahead_minutes: number;
max_calendar_connections_per_tick: number;
reminder_lookahead_minutes: number;
subconscious_mode: SubconsciousMode;
}
export type HeartbeatSettingsPatch = Partial<HeartbeatSettings>;
+6 -167
View File
@@ -1,62 +1,26 @@
/**
* Subconscious engine commands — task management, escalations, execution log.
* Subconscious engine commands — thoughts (reflections) and engine control.
*/
import { callCoreRpc } from '../../services/coreRpcClient';
import { type CommandResponse, isTauri } from './common';
// ── Types ────────────────────────────────────────────────────────────────────
export interface SubconsciousTask {
id: string;
title: string;
source: 'system' | 'user';
recurrence: string;
enabled: boolean;
last_run_at: number | null;
next_run_at: number | null;
completed: boolean;
created_at: number;
}
export interface SubconsciousLogEntry {
id: string;
task_id: string;
tick_at: number;
decision: 'noop' | 'act' | 'escalate' | 'dismissed' | string;
result: string | null;
duration_ms: number | null;
created_at: number;
}
export interface SubconsciousEscalation {
id: string;
task_id: string;
log_id: string | null;
title: string;
description: string;
priority: 'critical' | 'important' | 'normal';
status: 'pending' | 'approved' | 'dismissed';
created_at: number;
resolved_at: number | null;
}
export interface SubconsciousStatus {
enabled: boolean;
mode: 'off' | 'simple' | 'aggressive';
provider_available: boolean;
provider_unavailable_reason: string | null;
interval_minutes: number;
last_tick_at: number | null;
total_ticks: number;
task_count: number;
pending_escalations: number;
consecutive_failures: number;
}
export interface TickResult {
tick_at: number;
evaluations: Array<{ task_id: string; decision: string; reason: string }>;
executed: number;
escalated: number;
thoughts_count: number;
thread_id: string | null;
duration_ms: number;
}
@@ -76,101 +40,8 @@ export async function subconsciousTrigger(): Promise<CommandResponse<TickResult>
});
}
// ── Tasks CRUD ───────────────────────────────────────────────────────────────
// ── Thoughts (Reflections) ──────────────────────────────────────────────────
export async function subconsciousTasksList(
enabledOnly = false
): Promise<CommandResponse<SubconsciousTask[]>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<SubconsciousTask[]>>({
method: 'openhuman.subconscious_tasks_list',
params: { enabled_only: enabledOnly },
});
}
export async function subconsciousTasksAdd(
title: string,
source: 'user' | 'system' = 'user'
): Promise<CommandResponse<SubconsciousTask>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<SubconsciousTask>>({
method: 'openhuman.subconscious_tasks_add',
params: { title, source },
});
}
export async function subconsciousTasksUpdate(
taskId: string,
patch: { title?: string; enabled?: boolean }
): Promise<CommandResponse<{ updated: string }>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<{ updated: string }>>({
method: 'openhuman.subconscious_tasks_update',
params: { task_id: taskId, ...patch },
});
}
export async function subconsciousTasksRemove(
taskId: string
): Promise<CommandResponse<{ removed: string }>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<{ removed: string }>>({
method: 'openhuman.subconscious_tasks_remove',
params: { task_id: taskId },
});
}
// ── Log ──────────────────────────────────────────────────────────────────────
export async function subconsciousLogList(
taskId?: string,
limit = 50
): Promise<CommandResponse<SubconsciousLogEntry[]>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<SubconsciousLogEntry[]>>({
method: 'openhuman.subconscious_log_list',
params: { task_id: taskId, limit },
});
}
// ── Escalations ──────────────────────────────────────────────────────────────
export async function subconsciousEscalationsList(
status?: 'pending' | 'approved' | 'dismissed'
): Promise<CommandResponse<SubconsciousEscalation[]>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<SubconsciousEscalation[]>>({
method: 'openhuman.subconscious_escalations_list',
params: status ? { status } : {},
});
}
export async function subconsciousEscalationsApprove(
escalationId: string
): Promise<CommandResponse<{ approved: string }>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<{ approved: string }>>({
method: 'openhuman.subconscious_escalations_approve',
params: { escalation_id: escalationId },
});
}
export async function subconsciousEscalationsDismiss(
escalationId: string
): Promise<CommandResponse<{ dismissed: string }>> {
if (!isTauri()) throw new Error('Not running in Tauri');
return await callCoreRpc<CommandResponse<{ dismissed: string }>>({
method: 'openhuman.subconscious_escalations_dismiss',
params: { escalation_id: escalationId },
});
}
// ── #623: proactive reflection layer ─────────────────────────────────────────
/**
* Categorisation of the underlying signal that produced the reflection.
* Mirrors `subconscious::reflection::ReflectionKind` on the Rust side.
*/
export type ReflectionKind =
| 'hotness_spike'
| 'cross_source_pattern'
@@ -179,45 +50,24 @@ export type ReflectionKind =
| 'risk'
| 'opportunity';
/**
* One resolved chunk of memory-tree content the reflection LLM cited via
* `source_refs`, snapshot at tick time. Mirrors `subconscious::SourceChunk`
* on the Rust side. Powers the Intelligence-tab "Sources" disclosure for
* transparency, and the orchestrator's memory-context injection into the
* system prompt for any chat turn in a thread spawned from the reflection.
*
* Snapshot semantics — `content` is what the LLM saw at tick time, even
* if the underlying entity/summary has since mutated.
*/
export interface SourceChunk {
/** Original opaque ref like `"entity:phoenix"` or `"summary:abc123"`. */
ref_id: string;
/** Parsed kind portion of `ref_id`. `"entity"`, `"summary"`, etc. */
kind: string;
/** Resolved chunk preview at tick time. Empty if no resolver matched. */
content: string;
/** Optional per-kind metadata (display_name, hotness, sealed_at, etc). */
metadata?: unknown;
}
/**
* One persisted observation about the user's state. Created by the
* subconscious tick LLM. Reflections are observation-only — they live
* exclusively on the Intelligence tab and never auto-post into any
* conversation thread. Tapping the action button (when `proposed_action`
* is non-null) spawns a *fresh* conversation thread via `actOnReflection`.
*/
export interface Reflection {
id: string;
kind: ReflectionKind;
body: string;
proposed_action: string | null;
source_refs: string[];
/** Resolved chunks captured at tick time. See {@link SourceChunk}. */
source_chunks?: SourceChunk[];
created_at: number;
acted_on_at: number | null;
dismissed_at: number | null;
thread_id: string | null;
}
export async function listReflections(
@@ -233,17 +83,6 @@ export async function listReflections(
});
}
/**
* Spawn a fresh conversation thread and seed it with the reflection body
* as the FIRST ASSISTANT message (proposed_action appended if present).
* No LLM turn fires — the user lands in a thread that opens with the
* observation from OpenHuman, ready for them to reply.
*
* Marks `acted_on_at`. Returns the new thread's id so the caller can
* navigate the user into the new conversation. Reflections never write
* into existing threads — every act gets its own conversation so the
* user's main chat surface stays uncluttered.
*/
export async function actOnReflection(
reflectionId: string
): Promise<CommandResponse<{ reflection_id: string; thread_id: string }>> {
+118 -1
View File
@@ -1,8 +1,57 @@
//! Heartbeat and cron configuration.
//! Heartbeat, cron, and subconscious mode configuration.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Subconscious operating mode — controls tool access and tick frequency.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SubconsciousMode {
/// Disabled — the subconscious loop does not run.
#[default]
Off,
/// Read-only observation every 30 minutes. Memory recall and file
/// reading only — no writes, no sub-agent spawning.
Simple,
/// Full tool access every 5 minutes. Can write, spawn sub-agents,
/// and delegate tasks to the orchestrator.
Aggressive,
}
impl SubconsciousMode {
pub fn is_enabled(self) -> bool {
!matches!(self, Self::Off)
}
pub fn default_interval_minutes(self) -> u32 {
match self {
Self::Off => 5,
Self::Simple => 30,
Self::Aggressive => 5,
}
}
pub fn is_read_only(self) -> bool {
matches!(self, Self::Simple)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Simple => "simple",
Self::Aggressive => "aggressive",
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"simple" => Self::Simple,
"aggressive" => Self::Aggressive,
_ => Self::Off,
}
}
}
/// Heartbeat configuration — periodic background loop that evaluates
/// HEARTBEAT.md tasks and proactive notification sources.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -47,6 +96,11 @@ pub struct HeartbeatConfig {
/// Maximum lookahead window for reminder notifications.
#[serde(default = "default_reminder_lookahead_minutes")]
pub reminder_lookahead_minutes: u32,
/// Subconscious operating mode. Controls tool access and tick frequency.
/// Off (default) = disabled. Simple = read-only every 30 min.
/// Aggressive = full access every 5 min.
#[serde(default)]
pub subconscious_mode: SubconsciousMode,
}
fn default_context_budget() -> u32 {
@@ -93,6 +147,25 @@ impl Default for HeartbeatConfig {
meeting_lookahead_minutes: default_meeting_lookahead_minutes(),
max_calendar_connections_per_tick: default_max_calendar_connections_per_tick(),
reminder_lookahead_minutes: default_reminder_lookahead_minutes(),
subconscious_mode: SubconsciousMode::Off,
}
}
}
impl HeartbeatConfig {
/// Resolve the effective subconscious mode, handling backward
/// compatibility for configs that pre-date the `subconscious_mode`
/// field. If `subconscious_mode` is explicitly set (not Off-by-default),
/// use it. Otherwise, if the legacy `enabled && inference_enabled`
/// flags are true, treat as `Simple`.
pub fn effective_subconscious_mode(&self) -> SubconsciousMode {
if self.subconscious_mode != SubconsciousMode::Off {
return self.subconscious_mode;
}
if self.enabled && self.inference_enabled {
SubconsciousMode::Simple
} else {
SubconsciousMode::Off
}
}
}
@@ -112,6 +185,50 @@ mod tests {
assert!(!config.external_delivery_enabled);
assert_eq!(config.interval_minutes, 5);
assert_eq!(config.max_calendar_connections_per_tick, 2);
assert_eq!(config.subconscious_mode, SubconsciousMode::Off);
}
#[test]
fn subconscious_mode_serde_round_trip() {
assert_eq!(
serde_json::to_string(&SubconsciousMode::Simple).unwrap(),
r#""simple""#
);
assert_eq!(
serde_json::from_str::<SubconsciousMode>(r#""aggressive""#).unwrap(),
SubconsciousMode::Aggressive
);
assert_eq!(SubconsciousMode::default(), SubconsciousMode::Off);
}
#[test]
fn subconscious_mode_helpers() {
assert!(!SubconsciousMode::Off.is_enabled());
assert!(SubconsciousMode::Simple.is_enabled());
assert!(SubconsciousMode::Aggressive.is_enabled());
assert!(SubconsciousMode::Simple.is_read_only());
assert!(!SubconsciousMode::Aggressive.is_read_only());
assert_eq!(SubconsciousMode::Simple.default_interval_minutes(), 30);
assert_eq!(SubconsciousMode::Aggressive.default_interval_minutes(), 5);
}
#[test]
fn effective_mode_backward_compat() {
let mut config = HeartbeatConfig::default();
assert_eq!(config.effective_subconscious_mode(), SubconsciousMode::Off);
config.enabled = true;
config.inference_enabled = true;
assert_eq!(
config.effective_subconscious_mode(),
SubconsciousMode::Simple
);
config.subconscious_mode = SubconsciousMode::Aggressive;
assert_eq!(
config.effective_subconscious_mode(),
SubconsciousMode::Aggressive
);
}
#[test]
+1 -1
View File
@@ -61,7 +61,7 @@ pub use channels::{
pub use context::ContextConfig;
pub use dashboard::{DashboardConfig, DiagramViewerConfig, EventStreamConfig, ModelHealthConfig};
pub use dictation::{DictationActivationMode, DictationConfig};
pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
pub use heartbeat_cron::{CronConfig, HeartbeatConfig, SubconsciousMode};
pub use identity_cost::{CostConfig, ModelPricing};
pub use learning::{LearningConfig, ReflectionSource};
pub use local_ai::{LocalAiConfig, LocalAiUsage};
+2 -2
View File
@@ -108,8 +108,8 @@ impl HeartbeatEngine {
match engine.tick().await {
Ok(result) => {
info!(
"[heartbeat] tick: executed={} escalated={} duration={}ms",
result.executed, result.escalated, result.duration_ms
"[heartbeat] tick: thoughts={} thread={:?} duration={}ms",
result.thoughts_count, result.thread_id, result.duration_ms
);
}
Err(e) => {
+31 -10
View File
@@ -20,6 +20,7 @@ pub struct HeartbeatSettingsPatch {
pub meeting_lookahead_minutes: Option<u32>,
pub max_calendar_connections_per_tick: Option<u32>,
pub reminder_lookahead_minutes: Option<u32>,
pub subconscious_mode: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
@@ -34,6 +35,7 @@ pub struct HeartbeatSettingsView {
pub meeting_lookahead_minutes: u32,
pub max_calendar_connections_per_tick: u32,
pub reminder_lookahead_minutes: u32,
pub subconscious_mode: String,
}
pub async fn settings_get() -> Result<RpcOutcome<serde_json::Value>, String> {
@@ -90,23 +92,37 @@ pub async fn settings_set(
if let Some(reminder_lookahead_minutes) = patch.reminder_lookahead_minutes {
config.heartbeat.reminder_lookahead_minutes = reminder_lookahead_minutes.max(1);
}
if let Some(ref mode_str) = patch.subconscious_mode {
use crate::openhuman::config::schema::SubconsciousMode;
let mode = SubconsciousMode::from_str_lossy(mode_str);
config.heartbeat.subconscious_mode = mode;
config.heartbeat.enabled = mode.is_enabled() || config.heartbeat.enabled;
config.heartbeat.inference_enabled = mode.is_enabled();
config.heartbeat.interval_minutes = mode.default_interval_minutes();
}
config.save().await.map_err(|e| {
warn!("[heartbeat][rpc] settings_set: config.save failed: {e}");
e.to_string()
})?;
if config.heartbeat.enabled {
debug!("[heartbeat][rpc] settings_set: enabling — running bootstrap_after_login()");
if let Err(error) = crate::openhuman::subconscious::global::bootstrap_after_login().await {
warn!("[heartbeat][rpc] settings_set: heartbeat bootstrap failed: {error}");
return Err(format!(
"heartbeat settings saved, but failed to start heartbeat loop: {error}"
));
}
} else {
debug!("[heartbeat][rpc] settings_set: disabling — stopping heartbeat loop");
// Mode change requires a full engine restart so the new mode's interval
// and tool restrictions take effect. stop + bootstrap is idempotent.
if patch.subconscious_mode.is_some() || patch.enabled.is_some() {
crate::openhuman::subconscious::global::stop_heartbeat_loop().await;
if config.heartbeat.effective_subconscious_mode().is_enabled() {
debug!("[heartbeat][rpc] settings_set: (re)starting for mode change");
if let Err(error) =
crate::openhuman::subconscious::global::bootstrap_after_login().await
{
warn!("[heartbeat][rpc] settings_set: heartbeat bootstrap failed: {error}");
return Err(format!(
"heartbeat settings saved, but failed to start heartbeat loop: {error}"
));
}
} else {
debug!("[heartbeat][rpc] settings_set: subconscious off — loop stopped");
}
}
debug!("[heartbeat][rpc] settings_set: exit ok");
@@ -146,5 +162,10 @@ fn view(config: &Config) -> HeartbeatSettingsView {
meeting_lookahead_minutes: config.heartbeat.meeting_lookahead_minutes,
max_calendar_connections_per_tick: config.heartbeat.max_calendar_connections_per_tick,
reminder_lookahead_minutes: config.heartbeat.reminder_lookahead_minutes,
subconscious_mode: config
.heartbeat
.effective_subconscious_mode()
.as_str()
.to_string(),
}
}
+13
View File
@@ -81,6 +81,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
"reminder_lookahead_minutes",
"Max lookahead window (minutes) for reminder notifications.",
),
optional_string(
"subconscious_mode",
"Subconscious operating mode: off, simple, or aggressive.",
),
],
outputs: vec![FieldSchema {
name: "settings",
@@ -161,3 +165,12 @@ fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema {
required: false,
}
}
fn optional_string(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment,
required: false,
}
}
+3 -209
View File
@@ -1,209 +1,3 @@
//! Decision log for tracking what the subconscious has already surfaced.
//! Prevents re-escalating the same state changes across ticks.
use super::types::TickDecision;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// TTL for decision records before auto-expiry (24 hours).
const RECORD_TTL_SECS: f64 = 24.0 * 60.0 * 60.0;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionRecord {
pub tick_at: f64,
pub decision: TickDecision,
pub source_doc_ids: Vec<String>,
pub reason: String,
pub acknowledged: bool,
pub expires_at: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DecisionLog {
records: Vec<DecisionRecord>,
}
impl DecisionLog {
pub fn new() -> Self {
Self {
records: Vec::new(),
}
}
pub fn was_already_surfaced(&self, doc_ids: &[String]) -> bool {
let now = now_secs();
self.records.iter().any(|r| {
!r.acknowledged
&& r.expires_at > now
&& r.decision != TickDecision::Noop
&& r.source_doc_ids.iter().any(|id| doc_ids.contains(id))
})
}
pub fn filter_unsurfaced(&self, doc_ids: &[String]) -> Vec<String> {
let surfaced: HashSet<&str> = self
.records
.iter()
.filter(|r| {
!r.acknowledged && r.expires_at > now_secs() && r.decision != TickDecision::Noop
})
.flat_map(|r| r.source_doc_ids.iter().map(|s| s.as_str()))
.collect();
doc_ids
.iter()
.filter(|id| !surfaced.contains(id.as_str()))
.cloned()
.collect()
}
pub fn record(
&mut self,
tick_at: f64,
decision: TickDecision,
reason: &str,
source_doc_ids: Vec<String>,
) {
self.records.push(DecisionRecord {
tick_at,
decision,
source_doc_ids,
reason: reason.to_string(),
acknowledged: false,
expires_at: tick_at + RECORD_TTL_SECS,
});
}
pub fn mark_acknowledged(&mut self, doc_ids: &[String]) {
for record in &mut self.records {
if record.source_doc_ids.iter().any(|id| doc_ids.contains(id)) {
record.acknowledged = true;
}
}
}
pub fn prune_expired(&mut self) {
let now = now_secs();
self.records.retain(|r| r.expires_at > now);
}
pub fn active_count(&self) -> usize {
let now = now_secs();
self.records.iter().filter(|r| r.expires_at > now).count()
}
pub fn records(&self) -> &[DecisionRecord] {
&self.records
}
pub fn to_json(&self) -> Result<String, String> {
serde_json::to_string(self).map_err(|e| format!("serialize decision log: {e}"))
}
pub fn from_json(json: &str) -> Result<Self, String> {
serde_json::from_str(json).map_err(|e| format!("deserialize decision log: {e}"))
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> f64 {
now_secs()
}
#[test]
fn empty_log_surfaces_nothing() {
let log = DecisionLog::new();
assert!(!log.was_already_surfaced(&["doc-1".into()]));
}
#[test]
fn recorded_escalation_is_surfaced() {
let mut log = DecisionLog::new();
log.record(
now(),
TickDecision::Escalate,
"deadline",
vec!["doc-1".into()],
);
assert!(log.was_already_surfaced(&["doc-1".into()]));
assert!(!log.was_already_surfaced(&["doc-2".into()]));
}
#[test]
fn noop_decisions_are_not_surfaced() {
let mut log = DecisionLog::new();
log.record(now(), TickDecision::Noop, "nothing", vec!["doc-1".into()]);
assert!(!log.was_already_surfaced(&["doc-1".into()]));
}
#[test]
fn acknowledged_records_are_not_surfaced() {
let mut log = DecisionLog::new();
log.record(
now(),
TickDecision::Escalate,
"deadline",
vec!["doc-1".into()],
);
log.mark_acknowledged(&["doc-1".into()]);
assert!(!log.was_already_surfaced(&["doc-1".into()]));
}
#[test]
fn expired_records_are_not_surfaced() {
let mut log = DecisionLog::new();
let old_time = now() - RECORD_TTL_SECS - 1.0;
log.record(
old_time,
TickDecision::Escalate,
"old",
vec!["doc-1".into()],
);
assert!(!log.was_already_surfaced(&["doc-1".into()]));
}
#[test]
fn prune_removes_expired() {
let mut log = DecisionLog::new();
let old_time = now() - RECORD_TTL_SECS - 1.0;
log.record(
old_time,
TickDecision::Escalate,
"old",
vec!["doc-1".into()],
);
log.record(now(), TickDecision::Act, "new", vec!["doc-2".into()]);
assert_eq!(log.records().len(), 2);
log.prune_expired();
assert_eq!(log.records().len(), 1);
assert_eq!(log.records()[0].source_doc_ids, vec!["doc-2".to_string()]);
}
#[test]
fn filter_unsurfaced_returns_new_docs() {
let mut log = DecisionLog::new();
log.record(now(), TickDecision::Escalate, "seen", vec!["doc-1".into()]);
let unsurfaced = log.filter_unsurfaced(&["doc-1".into(), "doc-2".into(), "doc-3".into()]);
assert_eq!(unsurfaced, vec!["doc-2".to_string(), "doc-3".to_string()]);
}
#[test]
fn roundtrip_json() {
let mut log = DecisionLog::new();
log.record(now(), TickDecision::Escalate, "test", vec!["doc-1".into()]);
let json = log.to_json().unwrap();
let restored = DecisionLog::from_json(&json).unwrap();
assert_eq!(restored.records().len(), 1);
assert_eq!(restored.records()[0].reason, "test");
}
}
//! Legacy decision log — retained as an empty module for backward
//! compatibility. The task-based decision tracking was removed when
//! the subconscious switched to an agent-per-tick model.
File diff suppressed because it is too large Load Diff
+43 -227
View File
@@ -1,243 +1,59 @@
use super::*;
struct EnvVarGuard {
key: &'static str,
old: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set_to_path(key: &'static str, path: &std::path::Path) -> Self {
let old = std::env::var_os(key);
std::env::set_var(key, path);
Self { key, old }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.old {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
fn test_tasks() -> Vec<SubconsciousTask> {
vec![
SubconsciousTask {
id: "t1".into(),
title: "Check email".into(),
source: TaskSource::User,
recurrence: TaskRecurrence::Cron("0 8 * * *".into()),
enabled: true,
last_run_at: None,
next_run_at: None,
completed: false,
created_at: 0.0,
},
SubconsciousTask {
id: "t2".into(),
title: "Monitor skills".into(),
source: TaskSource::System,
recurrence: TaskRecurrence::Pending,
enabled: true,
last_run_at: None,
next_run_at: None,
completed: false,
created_at: 0.0,
},
]
}
#[tokio::test]
async fn tick_skips_unavailable_provider_without_activity_log_spam() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
let config = Config::load_or_init().await.expect("load test config");
let engine = SubconsciousEngine::from_heartbeat_config(
&config.heartbeat,
config.workspace_dir.clone(),
None,
);
let result = engine.tick().await.expect("tick should skip cleanly");
assert!(result.evaluations.is_empty());
let logs = store::with_connection(&config.workspace_dir, |conn| {
store::list_log_entries(conn, None, 20)
})
.expect("list logs");
assert!(
logs.is_empty(),
"provider skip must not append per-task failure log entries"
);
let status = engine.status().await;
assert_eq!(status.consecutive_failures, 1);
assert!(!status.provider_available);
assert!(status
.provider_unavailable_reason
.as_deref()
.unwrap_or_default()
.contains("Sign in"));
let _second = engine.tick().await.expect("repeat skip should be clean");
let logs = store::with_connection(&config.workspace_dir, |conn| {
store::list_log_entries(conn, None, 20)
})
.expect("list logs after repeat");
assert!(logs.is_empty(), "repeat skips must not spam activity log");
}
use crate::openhuman::subconscious::reflection::ReflectionKind;
#[test]
fn local_subconscious_provider_is_available() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut config = Config::default();
config.config_path = tmp.path().join("config.toml");
config.workspace_dir = tmp.path().join("workspace");
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
assert!(subconscious_provider_unavailable_reason(&config).is_none());
}
#[test]
fn local_subconscious_route_preserves_ollama_model() {
let mut config = Config::default();
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
assert_eq!(
resolve_subconscious_route(&config),
SubconsciousProviderRoute::LocalOllama {
model: "qwen2.5:0.5b".into(),
}
);
}
#[test]
fn local_subconscious_provider_does_not_require_legacy_endpoint() {
let mut config = Config::default();
config.subconscious_provider = Some("ollama:qwen2.5:0.5b".into());
config.memory_tree.llm_summariser_endpoint = None;
assert!(subconscious_provider_unavailable_reason(&config).is_none());
}
#[test]
fn openhuman_subconscious_alias_uses_cloud_route() {
let mut config = Config::default();
config.subconscious_provider = Some("openhuman:summarization".into());
assert_eq!(
resolve_subconscious_route(&config),
SubconsciousProviderRoute::OpenHumanCloud
);
}
#[test]
fn explicit_subconscious_provider_uses_other_route() {
let mut config = Config::default();
config.subconscious_provider = Some("custom-provider".into());
assert_eq!(
resolve_subconscious_route(&config),
SubconsciousProviderRoute::Other("custom-provider".into())
);
assert!(subconscious_provider_unavailable_reason(&config).is_none());
}
#[test]
fn parse_evaluation_response() {
let json = r#"{"evaluations": [
{"task_id": "t1", "decision": "act", "reason": "3 new urgent emails"},
{"task_id": "t2", "decision": "noop", "reason": "All skills healthy"}
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 (evals, drafts) = parse_response(json, &test_tasks());
assert_eq!(evals.len(), 2);
assert_eq!(evals[0].decision, TickDecision::Act);
assert_eq!(evals[1].decision, TickDecision::Noop);
assert!(drafts.is_empty());
}
#[test]
fn parse_evaluation_bare_array() {
let json = r#"[
{"task_id": "t1", "decision": "escalate", "reason": "Deadline conflict"}
]"#;
let (evals, drafts) = parse_response(json, &test_tasks());
assert_eq!(evals.len(), 1);
assert_eq!(evals[0].decision, TickDecision::Escalate);
assert!(drafts.is_empty());
}
#[test]
fn parse_evaluation_in_markdown() {
let json = "```json\n{\"evaluations\": [{\"task_id\": \"t1\", \"decision\": \"act\", \"reason\": \"Found items\"}]}\n```";
let (evals, _) = parse_response(json, &test_tasks());
assert_eq!(evals.len(), 1);
assert_eq!(evals[0].decision, TickDecision::Act);
}
#[test]
fn parse_evaluation_garbage_falls_back_to_noop() {
let (evals, drafts) = parse_response("Not JSON at all", &test_tasks());
assert_eq!(evals.len(), 2);
assert!(evals.iter().all(|e| e.decision == TickDecision::Noop));
assert!(drafts.is_empty());
}
#[test]
fn parse_response_extracts_reflections() {
let json = r#"{
"evaluations": [{"task_id": "t1", "decision": "noop", "reason": "nothing"}],
"reflections": [
{
"kind": "hotness_spike",
"body": "Phoenix surge",
"disposition": "notify",
"proposed_action": "Pull mentions",
"source_refs": ["entity:phoenix"]
},
{
"kind": "daily_digest",
"body": "New digest",
"disposition": "observe"
}
]
}"#;
let (evals, drafts) = parse_response(json, &test_tasks());
assert_eq!(evals.len(), 1);
let drafts = parse_thoughts(json);
assert_eq!(drafts.len(), 2);
assert_eq!(drafts[0].body, "Phoenix surge");
assert_eq!(drafts[1].body, "New digest");
assert_eq!(drafts[0].kind, ReflectionKind::HotnessSpike);
assert_eq!(drafts[1].kind, ReflectionKind::Risk);
}
#[test]
fn parse_response_handles_only_reflections() {
// LLM emitted reflections but no per-task evaluations.
let json = r#"{
"evaluations": [],
"reflections": [
{"kind": "risk", "body": "Concerning pattern", "disposition": "notify"}
]
}"#;
let (evals, drafts) = parse_response(json, &test_tasks());
// Tasks default to Noop so the existing tick loop still updates log entries.
assert_eq!(evals.len(), 2);
assert!(evals.iter().all(|e| e.decision == TickDecision::Noop));
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 extract_json_object() {
assert_eq!(extract_json(r#"{"key": "val"}"#), r#"{"key": "val"}"#);
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 extract_json_from_text() {
let input = "Here's the result: {\"evaluations\": []} done.";
assert!(extract_json(input).starts_with('{'));
assert!(extract_json(input).ends_with('}'));
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(']'));
}
+4 -542
View File
@@ -1,542 +1,4 @@
//! Task execution — dispatches tasks to either the local Ollama model (text-only)
//! or the full agentic loop (tool-required).
//!
//! When agentic-v1 is used for a task that didn't have explicit write intent,
//! it runs in analysis-only mode. If it recommends a write action, execution
//! is paused and an `UnapprovedWrite` result is returned so the engine can
//! create an escalation for user approval.
use super::prompt;
use super::types::{ExecutionResult, SubconsciousTask};
use tracing::{debug, info, warn};
#[cfg(test)]
mod test_mocks {
use std::sync::atomic::{AtomicU8, Ordering};
const MODE_REAL: u8 = 0;
const MODE_LOCAL_FAIL: u8 = 1;
const MODE_AGENT_FAIL: u8 = 2;
static MODE: AtomicU8 = AtomicU8::new(MODE_REAL);
pub fn mock_local() {
MODE.store(MODE_LOCAL_FAIL, Ordering::Release);
}
pub fn mock_agent() {
MODE.store(MODE_AGENT_FAIL, Ordering::Release);
}
pub fn reset() {
MODE.store(MODE_REAL, Ordering::Release);
}
pub fn is_local_mocked() -> bool {
MODE.load(Ordering::Acquire) == MODE_LOCAL_FAIL
}
pub fn is_agent_mocked() -> bool {
MODE.load(Ordering::Acquire) == MODE_AGENT_FAIL
}
}
/// Outcome of executing a task — either completed or needs user approval.
#[derive(Debug)]
pub enum ExecutionOutcome {
/// Task completed (either read-only analysis or approved write).
Completed(ExecutionResult),
/// agentic-v1 recommends a write action on a read-only task.
/// Contains the recommended action description for the escalation.
UnapprovedWrite {
recommendation: String,
duration_ms: u64,
},
}
/// Execute a task. Routes to local model or agentic loop based on whether
/// the task needs external tools.
pub async fn execute_task(
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> Result<ExecutionOutcome, String> {
let started = std::time::Instant::now();
let task_has_write_intent = needs_tools(&task.title);
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("config load: {e}"))?;
let result = if task_has_write_intent {
// Task explicitly asks for a write action — run with full permissions.
info!(
"[subconscious:executor] write task: id={} — agentic loop, full permissions",
task.id
);
execute_with_agent_full(&mut config, task, situation_report, identity_context)
.await
.map(|output| {
ExecutionOutcome::Completed(ExecutionResult {
output,
used_tools: true,
duration_ms: started.elapsed().as_millis() as u64,
})
})
} else if needs_agent(&task.title) {
// Read-only task but needs deeper reasoning — run analysis-only.
info!(
"[subconscious:executor] read-only task escalated: id={} — agentic loop, analysis only",
task.id
);
let output =
execute_with_agent_analysis(&mut config, task, situation_report, identity_context)
.await?;
let duration_ms = started.elapsed().as_millis() as u64;
if let Some(recommendation) = extract_recommended_action(&output) {
// agentic-v1 wants to take a write action the user didn't ask for.
Ok(ExecutionOutcome::UnapprovedWrite {
recommendation,
duration_ms,
})
} else {
Ok(ExecutionOutcome::Completed(ExecutionResult {
output,
used_tools: false,
duration_ms,
}))
}
} else {
// Simple text-only task. Use local model if configured for subconscious
// tasks, otherwise fall back to the cloud agentic analysis path.
if config.workload_uses_local("subconscious") {
debug!(
"[subconscious:executor] text task: id={} — using local model",
task.id
);
execute_with_local_model(&config, task, situation_report, identity_context)
.await
.map(|output| {
ExecutionOutcome::Completed(ExecutionResult {
output,
used_tools: false,
duration_ms: started.elapsed().as_millis() as u64,
})
})
} else {
info!(
"[subconscious:executor] text task: id={} — local AI disabled, using cloud fallback",
task.id
);
let output =
execute_with_agent_analysis(&mut config, task, situation_report, identity_context)
.await
.map_err(|e| format!("cloud fallback agent execution: {e}"))?;
let duration_ms = started.elapsed().as_millis() as u64;
debug!(
"[subconscious:executor] text task cloud fallback complete: id={} — duration_ms={}",
task.id, duration_ms
);
// Suppress UnapprovedWrite: passive tasks that didn't trigger
// needs_agent should never escalate even if the cloud model's
// output contains RECOMMENDED ACTION. The write-intent gate is
// needs_tools for active tasks and needs_agent for read-only
// escalations; the cloud fallback is a passthrough for simple
// text tasks and must not silently change the contract.
Ok(ExecutionOutcome::Completed(ExecutionResult {
output,
used_tools: false,
duration_ms,
}))
}
};
if let Err(ref e) = result {
warn!("[subconscious:executor] task id={} failed: {e}", task.id);
}
result
}
/// Execute an approved write action — called after user approves an escalation
/// that originated from `UnapprovedWrite`.
///
/// Independent `Config::load_or_init()`: the task was originally routed under
/// config_A in `execute_task`; now executes under config_B after user approval.
/// If `use_local_for_subconscious()` toggled between the two calls, the approval
/// was made under different assumptions. Risk is negligible in practice (config
/// changes require a restart to take effect on most fields), but callers should
/// be aware of this TOCTOU window.
pub async fn execute_approved_write(
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> Result<ExecutionResult, String> {
let started = std::time::Instant::now();
let mut config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("config load: {e}"))?;
let output =
execute_with_agent_full(&mut config, task, situation_report, identity_context).await?;
Ok(ExecutionResult {
output,
used_tools: true,
duration_ms: started.elapsed().as_millis() as u64,
})
}
/// Execute a text-only task using the local Ollama model.
///
/// The caller MUST have already checked `config.local_ai.use_local_for_subconscious()`
/// before calling this function.
async fn execute_with_local_model(
config: &crate::openhuman::config::Config,
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> Result<String, String> {
#[cfg(test)]
if test_mocks::is_local_mocked() {
return Err("local model: mocked failure (test)".into());
}
let prompt_text = prompt::build_text_execution_prompt(task, situation_report, identity_context);
let messages = vec![
crate::openhuman::inference::provider::traits::ChatMessage::system(prompt_text),
crate::openhuman::inference::provider::traits::ChatMessage::user("Execute the task now."),
];
let model_id = crate::openhuman::inference::model_ids::effective_chat_model_id(config);
let provider_string =
match crate::openhuman::inference::local::provider::provider_from_config(config) {
crate::openhuman::inference::local::provider::LocalAiProvider::Ollama => {
format!("ollama:{model_id}")
}
crate::openhuman::inference::local::provider::LocalAiProvider::LmStudio => {
format!("lmstudio:{model_id}")
}
};
let (provider, model) =
crate::openhuman::inference::provider::factory::create_local_chat_provider_from_string(
&provider_string,
config,
)
.map_err(|e| format!("local model: {e}"))?;
provider
.chat_with_history(&messages, &model, config.default_temperature)
.await
.map_err(|e| format!("local model: {e}"))
}
/// Execute with agentic-v1 at full permissions (write-intent tasks or approved writes).
///
/// Retries up to 3 times with exponential backoff (2s, 4s, 8s) on 429 rate-limit
/// errors from the agentic-v1 cloud model.
async fn execute_with_agent_full(
config: &mut crate::openhuman::config::Config,
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> Result<String, String> {
let prompt_text = prompt::build_tool_execution_prompt(task, situation_report, identity_context);
agent_chat_with_retry(config, &prompt_text).await
}
/// Execute with agentic-v1 in analysis-only mode (read-only tasks).
///
/// The prompt instructs the model to analyze but not execute write actions.
async fn execute_with_agent_analysis(
config: &mut crate::openhuman::config::Config,
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> Result<String, String> {
#[cfg(test)]
if test_mocks::is_agent_mocked() {
return Err("cloud fallback: mocked failure (test)".into());
}
let prompt_text = prompt::build_analysis_only_prompt(task, situation_report, identity_context);
agent_chat_with_retry(config, &prompt_text).await
}
/// Call agent_chat with rate-limit retry (429 only, up to 3 attempts).
async fn agent_chat_with_retry(
config: &mut crate::openhuman::config::Config,
prompt: &str,
) -> Result<String, String> {
const MAX_RETRIES: u32 = 3;
let mut attempt = 0;
loop {
let result =
crate::openhuman::inference::local::ops::agent_chat(config, prompt, None, Some(0.3))
.await;
match result {
Ok(outcome) => return Ok(outcome.value),
Err(e) => {
let is_rate_limit = e.contains("429") || e.to_lowercase().contains("rate limit");
attempt += 1;
if is_rate_limit && attempt < MAX_RETRIES {
let backoff_secs = 2u64 << (attempt - 1); // 2, 4, 8
warn!(
"[subconscious:executor] rate-limited (attempt {}/{}), retrying in {}s: {}",
attempt, MAX_RETRIES, backoff_secs, e
);
tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
continue;
}
return Err(format!("agent execution: {e}"));
}
}
}
}
/// Check if the analysis output contains a recommended write action.
/// Returns the recommendation text if found.
fn extract_recommended_action(output: &str) -> Option<String> {
// Look for "RECOMMENDED ACTION:" marker in the output
for line_idx in output.lines().enumerate().filter_map(|(i, l)| {
if l.trim().starts_with("RECOMMENDED ACTION:") {
Some(i)
} else {
None
}
}) {
let recommendation: String = output
.lines()
.skip(line_idx)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if !recommendation.is_empty() {
return Some(recommendation);
}
}
None
}
/// Heuristic: does this task need the agentic loop (deeper reasoning, tools)?
///
/// Tasks escalated by the local model that involve complex analysis
/// (multi-step reasoning, cross-referencing sources) benefit from agentic-v1
/// even without write actions.
fn needs_agent(title: &str) -> bool {
let lower = title.to_lowercase();
let agent_keywords = [
"compare",
"cross-reference",
"correlate",
"investigate",
"deep dive",
"research",
"audit",
"trace",
"debug",
"diagnose",
];
agent_keywords.iter().any(|kw| lower.contains(kw))
}
/// Heuristic: does this task description imply needing external tools?
///
/// Tasks with action verbs (send, create, post, delete, move, publish, schedule)
/// need the agentic loop. Tasks with passive verbs (summarize, check, monitor,
/// review, analyze, extract, classify) can be handled by local model.
pub fn needs_tools(title: &str) -> bool {
let lower = title.to_lowercase();
let tool_keywords = [
"send",
"post",
"create",
"delete",
"remove",
"move",
"publish",
"schedule",
"forward",
"reply",
"draft and send",
"upload",
"download",
"notify on",
"alert on",
"message",
"write to",
"update on",
"sync to",
];
tool_keywords.iter().any(|kw| lower.contains(kw))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::tempdir;
/// Guard that sets an env var for the duration of the test and restores it on drop.
struct EnvVarGuard {
key: String,
old: Option<String>,
}
impl EnvVarGuard {
fn set_to_path(key: &str, value: &Path) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, value.to_str().expect("path is valid utf-8"));
Self {
key: key.to_string(),
old,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.old {
Some(value) => std::env::set_var(&self.key, value),
None => std::env::remove_var(&self.key),
}
}
}
fn write_subconscious_test_config(workspace_root: &Path, local_ai_enabled: bool) {
let cfg = format!(
r#"default_temperature = 0.7
[local_ai]
runtime_enabled = {local_ai_enabled}
provider = "ollama"
[local_ai.usage]
subconscious = {local_ai_enabled}
[memory]
backend = "sqlite"
auto_save = true
embedding_provider = "none"
embedding_model = "none"
embedding_dimensions = 0
[secrets]
encrypt = false
"#
);
std::fs::create_dir_all(workspace_root).expect("mkdir test workspace root");
let config_path = workspace_root.join("config.toml");
std::fs::write(&config_path, &cfg).expect("write test config");
let _: crate::openhuman::config::Config =
toml::from_str(&cfg).expect("test config should deserialize");
}
fn make_text_task(title: &str) -> SubconsciousTask {
SubconsciousTask {
id: "test-id".into(),
title: title.into(),
source: super::super::types::TaskSource::User,
recurrence: super::super::types::TaskRecurrence::Once,
enabled: true,
last_run_at: None,
next_run_at: None,
completed: false,
created_at: 1700000000.0,
}
}
#[tokio::test]
async fn execute_task_routes_to_cloud_when_local_disabled() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
test_mocks::reset();
let tmp = tempdir().expect("tempdir");
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
write_subconscious_test_config(tmp.path(), false);
test_mocks::mock_agent();
let task = make_text_task("Summarize unread emails");
let result = execute_task(&task, "", "").await;
assert!(result.is_err(), "expected error (cloud path)");
let err = result.unwrap_err();
assert!(
err.contains("cloud fallback"),
"expected cloud fallback error, got: {err}"
);
}
#[tokio::test]
async fn execute_task_routes_to_local_when_local_enabled() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
test_mocks::reset();
let tmp = tempdir().expect("tempdir");
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
write_subconscious_test_config(tmp.path(), true);
test_mocks::mock_local();
let task = make_text_task("Summarize unread emails");
let result = execute_task(&task, "", "").await;
assert!(result.is_err(), "expected error (local path)");
let err = result.unwrap_err();
assert!(
err.contains("local model"),
"expected local model error, got: {err}"
);
}
#[test]
fn needs_tools_detects_action_verbs() {
assert!(needs_tools("Send email digest to Telegram"));
assert!(needs_tools("Post weekly standup to Slack"));
assert!(needs_tools("Create a summary in Notion"));
assert!(needs_tools("Delete old calendar events"));
assert!(needs_tools("Forward urgent emails to team"));
assert!(needs_tools("Schedule a meeting for tomorrow"));
}
#[test]
fn needs_tools_rejects_passive_verbs() {
assert!(!needs_tools("Summarize unread emails"));
assert!(!needs_tools("Check skills runtime health"));
assert!(!needs_tools("Monitor Ollama status"));
assert!(!needs_tools("Review upcoming deadlines"));
assert!(!needs_tools("Analyze email patterns"));
assert!(!needs_tools("Extract key points from Notion pages"));
assert!(!needs_tools("Classify email priority"));
}
#[test]
fn needs_tools_case_insensitive() {
assert!(needs_tools("SEND a message to Slack"));
assert!(needs_tools("Send A Message To Slack"));
}
#[test]
fn needs_agent_detects_complex_tasks() {
assert!(needs_agent("Compare Q1 and Q2 revenue data"));
assert!(needs_agent("Investigate why notifications stopped"));
assert!(needs_agent("Audit all active skill connections"));
assert!(!needs_agent("Check emails"));
assert!(!needs_agent("Summarize today's events"));
}
#[test]
fn extract_recommended_action_finds_marker() {
let output = "Analysis complete. Found 3 urgent emails.\n\nRECOMMENDED ACTION: Forward the 3 urgent emails to #team-alerts on Slack.";
let action = extract_recommended_action(output);
assert!(action.is_some());
assert!(action.unwrap().contains("Forward"));
}
#[test]
fn extract_recommended_action_returns_none_when_absent() {
let output = "All skills are healthy. No issues found.";
assert!(extract_recommended_action(output).is_none());
}
}
//! Legacy executor — retained as an empty module for backward
//! compatibility. Task execution was removed when the subconscious
//! switched to an agent-per-tick model. The agent now runs directly
//! in the engine tick via the chat provider.
+1 -43
View File
@@ -1,13 +1,7 @@
//! Global singleton for the SubconsciousEngine.
//!
//! Shared between the heartbeat background loop and RPC handlers
//! so both see the same decision log, counters, and last_tick_at.
//!
//! Lifecycle note: the engine is bootstrapped **post-login** via
//! [`bootstrap_after_login`] so that `seed_default_tasks` runs against the
//! per-user workspace (`~/.openhuman/users/<id>/workspace/`) instead of the
//! pre-login global default. See `load.rs::resolve_runtime_config_dirs` for
//! how `active_user.toml` drives `config.workspace_dir`.
//! so both see the same state and counters.
use super::engine::SubconsciousEngine;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -16,12 +10,7 @@ use tokio::sync::Mutex;
use tokio::task::JoinHandle;
static ENGINE: OnceLock<Arc<Mutex<Option<SubconsciousEngine>>>> = OnceLock::new();
/// True once [`bootstrap_after_login`] has successfully seeded the engine and
/// spawned the heartbeat loop for the current active user.
static BOOTSTRAPPED: AtomicBool = AtomicBool::new(false);
/// Heartbeat loop handle so logout / user switch can abort it cleanly.
static HEARTBEAT_HANDLE: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
fn engine_lock() -> &'static Arc<Mutex<Option<SubconsciousEngine>>> {
@@ -32,7 +21,6 @@ fn heartbeat_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
HEARTBEAT_HANDLE.get_or_init(|| Mutex::new(None))
}
/// Get or initialize the global engine. Both heartbeat loop and RPC use this.
pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>>>, String> {
let lock = engine_lock();
{
@@ -42,7 +30,6 @@ pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>
}
}
// Initialize
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("load config: {e}"))?;
@@ -63,15 +50,6 @@ pub async fn get_or_init_engine() -> Result<Arc<Mutex<Option<SubconsciousEngine>
Ok(Arc::clone(lock))
}
/// Construct the engine (which seeds defaults into the per-user workspace)
/// and spawn the heartbeat loop. Idempotent per-process via [`BOOTSTRAPPED`].
///
/// Call this:
/// - after a successful login writes `active_user.toml`, OR
/// - at sidecar startup **iff** `active_user.toml` already exists.
///
/// Calling before login would seed into the global pre-login workspace and
/// then silently diverge from the per-user workspace the UI reads from.
pub async fn bootstrap_after_login() -> Result<(), String> {
if BOOTSTRAPPED.swap(true, Ordering::SeqCst) {
tracing::debug!("[subconscious] bootstrap already ran — skipping");
@@ -91,10 +69,6 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
return Ok(());
}
// Build the engine against the NOW-correct per-user workspace_dir.
// SubconsciousEngine::new calls seed_default_tasks() inside the
// constructor, so by the time this returns the 3 system defaults are
// present in `<workspace>/subconscious/subconscious.db`.
get_or_init_engine().await.inspect_err(|_e| {
BOOTSTRAPPED.store(false, Ordering::SeqCst);
})?;
@@ -103,9 +77,6 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
"[subconscious] engine initialized against per-user workspace"
);
// Spawn the heartbeat loop and keep the JoinHandle so we can cancel it
// on logout. Without this the task would leak: tokio::spawn returns a
// detached task that drops on handle-drop but keeps running.
let heartbeat = crate::openhuman::heartbeat::engine::HeartbeatEngine::new(
config.heartbeat.clone(),
config.workspace_dir.clone(),
@@ -124,16 +95,9 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
Ok(())
}
/// Stop only the heartbeat loop. Keep the engine cache intact so manual
/// subconscious RPCs can still inspect state, while a future enable can spawn
/// a fresh loop.
pub async fn stop_heartbeat_loop() {
if let Some(handle) = heartbeat_slot().lock().await.take() {
handle.abort();
// Await the aborted task so it fully releases its engine reference
// before we let bootstrap_after_login spawn a fresh loop. Without this
// the old task can still be executing engine.run_tick() against a
// replaced engine state.
match handle.await {
Ok(()) => {
tracing::debug!("[heartbeat] loop exited before abort completed");
@@ -150,12 +114,6 @@ pub async fn stop_heartbeat_loop() {
BOOTSTRAPPED.store(false, Ordering::SeqCst);
}
/// Tear down the engine + heartbeat loop so the next login rebuilds them
/// against the new user's workspace. Call on logout or account switch.
///
/// Without this, the engine `OnceLock` would stay frozen on the previous
/// user's `workspace_dir` and subsequent ticks / RPC queries would leak
/// into the wrong DB.
pub async fn reset_engine_for_user_switch() {
stop_heartbeat_loop().await;
+70 -267
View File
@@ -1,285 +1,88 @@
#[cfg(test)]
mod tests {
use crate::openhuman::subconscious::decision_log::DecisionLog;
use crate::openhuman::subconscious::store;
use crate::openhuman::subconscious::types::{
EscalationPriority, EscalationStatus, TaskRecurrence, TaskSource, TickDecision,
use crate::openhuman::subconscious::reflection::{
hydrate_draft, ReflectionDraft, ReflectionKind,
};
use crate::openhuman::subconscious::reflection_store;
use crate::openhuman::subconscious::store;
#[test]
fn sqlite_task_lifecycle_one_off() {
fn reflection_with_thread_id_persists() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
// Add a one-off task
let task = store::add_task(
conn,
"Remind about meeting",
TaskSource::User,
TaskRecurrence::Once,
)?;
assert!(!task.completed);
assert_eq!(task.recurrence, TaskRecurrence::Once);
// Should be due immediately
let due = store::due_tasks(conn, 9999999999.0)?;
assert_eq!(due.len(), 1);
// Execute and complete
store::add_log_entry(
conn,
&task.id,
1000.0,
"act",
Some("Reminded user"),
Some(50),
)?;
store::mark_task_completed(conn, &task.id)?;
// Should no longer be due
let due = store::due_tasks(conn, 9999999999.0)?;
assert_eq!(due.len(), 0);
// Task still exists but completed
let t = store::get_task(conn, &task.id)?;
assert!(t.completed);
Ok(())
})
.unwrap();
}
#[test]
fn sqlite_task_lifecycle_recurrent() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
let task = store::add_task(
conn,
"Check email",
TaskSource::User,
TaskRecurrence::Cron("0 8 * * *".into()),
)?;
// Execute and set next run
let now = 1000.0;
let next = 2000.0;
store::add_log_entry(
conn,
&task.id,
now,
"act",
Some("Checked 3 emails"),
Some(200),
)?;
store::update_task_run_times(conn, &task.id, now, Some(next))?;
// Not due yet (before next_run_at)
let due = store::due_tasks(conn, 1500.0)?;
assert_eq!(due.len(), 0);
// Due after next_run_at
let due = store::due_tasks(conn, 2500.0)?;
assert_eq!(due.len(), 1);
// Task should NOT be completed
let t = store::get_task(conn, &task.id)?;
assert!(!t.completed);
Ok(())
})
.unwrap();
}
#[test]
fn escalation_approve_dismiss_flow() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
let task = store::add_task(
conn,
"Review deadline",
TaskSource::User,
TaskRecurrence::Once,
)?;
// Create escalation
let esc = store::add_escalation(
conn,
&task.id,
None,
"Deadline conflict",
"Two deadlines on the same day",
&EscalationPriority::Important,
)?;
assert_eq!(esc.status, EscalationStatus::Pending);
assert_eq!(store::pending_escalation_count(conn)?, 1);
// Approve
store::resolve_escalation(conn, &esc.id, &EscalationStatus::Approved)?;
let resolved = store::get_escalation(conn, &esc.id)?;
assert_eq!(resolved.status, EscalationStatus::Approved);
assert!(resolved.resolved_at.is_some());
assert_eq!(store::pending_escalation_count(conn)?, 0);
// Create another and dismiss
let esc2 = store::add_escalation(
conn,
&task.id,
None,
"Budget warning",
"Monthly spend at 90%",
&EscalationPriority::Normal,
)?;
store::resolve_escalation(conn, &esc2.id, &EscalationStatus::Dismissed)?;
let dismissed = store::get_escalation(conn, &esc2.id)?;
assert_eq!(dismissed.status, EscalationStatus::Dismissed);
Ok(())
})
.unwrap();
}
#[test]
fn execution_log_tracks_history() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
let task = store::add_task(
conn,
"Check health",
TaskSource::System,
TaskRecurrence::Pending,
)?;
store::add_log_entry(conn, &task.id, 1000.0, "noop", Some("All healthy"), None)?;
store::add_log_entry(
conn,
&task.id,
2000.0,
"act",
Some("Restarted skill"),
Some(500),
)?;
store::add_log_entry(conn, &task.id, 3000.0, "noop", Some("All healthy"), None)?;
let entries = store::list_log_entries(conn, Some(&task.id), 10)?;
assert_eq!(entries.len(), 3);
// Most recent first
assert_eq!(entries[0].tick_at, 3000.0);
assert_eq!(entries[1].decision, "act");
// Global log
let all = store::list_log_entries(conn, None, 2)?;
assert_eq!(all.len(), 2); // limited to 2
Ok(())
})
.unwrap();
}
#[test]
fn decision_log_dedup_still_works() {
let mut log = DecisionLog::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
log.record(
now,
TickDecision::Escalate,
"deadline email",
vec!["doc-1".into()],
);
// doc-1 should be filtered as already surfaced
let unsurfaced = log.filter_unsurfaced(&["doc-1".into(), "doc-2".into()]);
assert!(!unsurfaced.contains(&"doc-1".to_string()));
assert!(unsurfaced.contains(&"doc-2".to_string()));
// Acknowledge doc-1
log.mark_acknowledged(&["doc-1".into()]);
assert!(!log.was_already_surfaced(&["doc-1".into()]));
}
#[test]
fn seed_then_query_tasks() {
let dir = tempfile::tempdir().unwrap();
store::with_connection(dir.path(), |conn| {
let count = store::seed_default_tasks(conn)?;
assert_eq!(count, 3);
let tasks = store::list_tasks(conn, true)?;
assert_eq!(tasks.len(), 3);
assert!(tasks.iter().all(|t| t.source == TaskSource::System));
assert!(tasks
.iter()
.all(|t| t.recurrence == TaskRecurrence::Pending));
// All should be due (no next_run_at set)
let due = store::due_tasks(conn, 9999999999.0)?;
assert_eq!(due.len(), 3);
Ok(())
})
.unwrap();
}
/// Regression test for the "empty task list on fresh install" bug.
///
/// The core server's startup path calls `get_or_init_engine()` to
/// eagerly construct a `SubconsciousEngine`, relying on the constructor
/// to seed the 3 default system tasks. This test locks in that
/// invariant: constructing the engine alone — with no tick, no
/// trigger RPC, and no explicit seed call — must leave the 3 defaults
/// in the SQLite store.
#[test]
fn engine_construction_seeds_default_tasks() {
use crate::openhuman::config::HeartbeatConfig;
use crate::openhuman::subconscious::SubconsciousEngine;
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().to_path_buf();
// Construct the engine via the same path the core server uses at
// startup. Memory client is not required for seeding.
let _engine = SubconsciousEngine::from_heartbeat_config(
&HeartbeatConfig::default(),
workspace.clone(),
None,
);
// The 3 default system tasks must now exist in the store.
store::with_connection(&workspace, |conn| {
let tasks = store::list_tasks(conn, false)?;
assert_eq!(
tasks.len(),
3,
"engine construction must seed the 3 default system tasks"
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()),
);
assert!(tasks.iter().all(|t| t.source == TaskSource::System));
assert!(tasks
.iter()
.all(|t| t.recurrence == TaskRecurrence::Pending));
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();
}
// Reconstructing the engine on the same workspace must not
// duplicate the defaults — seed_default_tasks is idempotent.
#[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 _engine2 = SubconsciousEngine::from_heartbeat_config(
&HeartbeatConfig::default(),
workspace.clone(),
None,
);
let got = reflection_store::get_reflection(conn, "r-2")?.unwrap();
assert!(got.thread_id.is_none());
Ok(())
})
.unwrap();
}
store::with_connection(&workspace, |conn| {
let tasks = store::list_tasks(conn, false)?;
assert_eq!(
tasks.len(),
3,
"repeat engine construction must not duplicate default tasks"
);
#[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();
+1 -8
View File
@@ -1,5 +1,4 @@
pub mod engine;
pub mod executor;
pub mod global;
pub mod prompt;
pub mod reflection;
@@ -10,9 +9,6 @@ pub mod source_chunk;
pub mod store;
pub mod types;
// Keep decision_log for potential future dedup queries against the log table.
pub mod decision_log;
#[cfg(test)]
mod integration_tests;
@@ -23,7 +19,4 @@ pub use schemas::{
all_registered_controllers as all_subconscious_registered_controllers,
};
pub use source_chunk::SourceChunk;
pub use types::{
Escalation, EscalationStatus, SubconsciousLogEntry, SubconsciousStatus, SubconsciousTask,
TaskRecurrence, TaskSource, TickDecision, TickResult,
};
pub use types::{SubconsciousStatus, TickResult};
+53 -224
View File
@@ -1,216 +1,88 @@
//! Prompt builders for the subconscious evaluation and execution phases.
//! Prompt builder for the subconscious agent.
//!
//! Injects OpenClaw identity context (SOUL.md, PROFILE.md) so the local model
//! reasons as the agent, not a generic evaluator.
//! 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 super::types::SubconsciousTask;
use std::path::Path;
const IDENTITY_EXCERPT_CHARS: usize = 2000;
// ── Evaluation prompt ────────────────────────────────────────────────────────
/// Build the per-tick evaluation prompt. The local model evaluates each due
/// task against the situation report and returns a per-task decision.
pub fn build_evaluation_prompt(
tasks: &[SubconsciousTask],
situation_report: &str,
identity_context: &str,
) -> String {
let task_list = tasks
.iter()
.map(|t| format!("- [{}] {}", t.id, t.title))
.collect::<Vec<_>>()
.join("\n");
/// 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 Loop — Task Evaluation
# Subconscious Agent
You are the background awareness layer. You run periodically to evaluate
user-defined tasks against the current workspace state.
You are the user's background awareness layer. You wake up periodically,
review what's happening in their world, and surface useful thoughts.
## Due tasks
{task_list}
## Current state
## Situation Report (pre-loaded context)
{situation_report}
## Your job
## Instructions
For each task, check if the current state has anything relevant. Decide:
- **noop**: Nothing actionable for this task right now.
- **act**: The task should be executed now (state has relevant data).
- **escalate**: The task needs user approval before acting (ambiguous, risky, or irreversible).
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.
## Reflections (#623 — proactive layer)
2. **Observe**: Based on both the situation report and your research,
identify patterns, deadlines, risks, opportunities, or interesting
cross-source connections.
You also surface **reflections**: free-form observations grounded in the
memory-tree signal sections (Hotness deltas, Recent summaries, Latest
daily digest, Recap window). Reflections are *not* task evaluations —
they let you point out something the user should know, even if no task
covers it.
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.
**Self vs. others (#1365)**: the situation report includes a *Your
Identifiers* section listing the user's connected-account handles,
emails, and user_ids. Use it as the source of truth for who the user is.
4. **Surface thoughts**: Produce structured observations for the user.
Only surface genuinely useful insights — skip trivial observations.
- *Hotness deltas* tagged `(you)` are the user's own identifiers. Frame
reflections grounded in those in second person: *"Your phoenix mentions
surged 4× this hour."* Untagged hotness items are someone or something
else — reflect on them as *about that other entity*, not as the user.
- For *Recent summaries*, *Latest global digest*, and *Recap window*
body text, the prose mentions people by name, email, or handle inline.
Match each mention against the *Your Identifiers* list: if it appears
there, that sentence is about the user; otherwise it's about a
collaborator/contact. **Never attribute another person's activity to
the user.** A digest line "Cyrus deployed phoenix" is the user's
activity only when *Cyrus* (or the matching email/handle) appears in
*Your Identifiers* — otherwise Cyrus is someone the user is reading
about, and the reflection should reflect that.
- If a reflection mixes self and other signals, separate them
explicitly: *"You spent the morning on phoenix; meanwhile, Sam pushed
a refactor."*
**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.
For each reflection:
- `kind`: one of `hotness_spike` | `cross_source_pattern` | `daily_digest`
| `due_item` | `risk` | `opportunity`.
- `body`: short markdown-friendly observation.
- `proposed_action` (optional): one-tap action text. When the user taps
the action button, OpenHuman opens a *new* conversation thread seeded
with the body + this action — never auto-executed, never written into
any existing chat.
- `source_refs`: opaque ids from the situation report so we can trace
provenance.
**Anti-double-emit**: the *Recent reflections* section shows what you
already surfaced. Re-emit only if the signal materially intensified.
**Anti-double-emit**: the situation report's "Recent reflections" section
shows what you already noticed. Re-emit only if the underlying signal
materially intensified — otherwise let it decay silently.
Cap: at most **5 thoughts per tick**.
**No side effects, no thread bloat**: reflections are observation-only.
They surface on the Intelligence tab; nothing is auto-posted into any
conversation. Only emit a `proposed_action` when there is a concrete
follow-up the user could plausibly want to start a chat about.
## Final output
Cap: emit at most **5 reflections per tick**. Excess is dropped.
## Output format (strict JSON, no other text)
After you've finished researching, end your final message with a JSON
block containing your thoughts:
```json
{{
"evaluations": [
{{"task_id": "<id>", "decision": "noop|act|escalate", "reason": "one sentence"}}
],
"reflections": [
"thoughts": [
{{
"kind": "hotness_spike",
"body": "Phoenix mentions surged 4× in last hour across Slack + email.",
"proposed_action": "Pull the last 24h of Phoenix mentions into a thread",
"source_refs": ["entity:phoenix", "summary:abc123"]
"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 wire-format prompt block
/// matches what the LLM was taught about in `build_evaluation_prompt`.
/// Used by the situation_report's "Recent reflections" section so the
/// representation is identical between teaching and reading.
/// 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)
}
// ── Execution prompts ────────────────────────────────────────────────────────
/// Build the prompt for executing a text-only task via local Ollama model.
/// Used for tasks that don't need tools (summarize, extract, classify, etc.)
pub fn build_text_execution_prompt(
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> String {
format!(
r#"{identity_context}
# Task Execution
Execute the following task based on the current state. Respond with the result only.
## Task
{task_title}
## Current state
{situation_report}
Do the task now. Return only the result — no explanations or meta-commentary."#,
task_title = task.title
)
}
/// Build the prompt for executing a tool-required task via the full agentic loop.
/// Used for tasks that need side effects (send message, create doc, etc.)
pub fn build_tool_execution_prompt(
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> String {
format!(
r#"{identity_context}
# Background Task Execution
You are executing a user-defined background task. Use your available tools to complete it.
## Task
{task_title}
## Current state
{situation_report}
Execute this task using the appropriate tools. Complete the task fully — don't just describe what to do."#,
task_title = task.title
)
}
/// Build a read-only analysis prompt for agentic-v1. Used when a read-only task
/// is escalated — the agent should analyze and recommend but NOT execute writes.
pub fn build_analysis_only_prompt(
task: &SubconsciousTask,
situation_report: &str,
identity_context: &str,
) -> String {
format!(
r#"{identity_context}
# Background Task — Analysis Only
You are analyzing a background task. You may use read-only tools to gather information,
but you MUST NOT execute any write actions (send, post, create, delete, forward, reply, update, publish).
If you determine that a write action is needed, describe exactly what you would do in your response
but do not execute it. Start your recommendation with "RECOMMENDED ACTION:" on its own line.
## Task
{task_title}
## Current state
{situation_report}
Analyze the situation and report your findings. If action is needed, describe it clearly but do NOT execute."#,
task_title = task.title
)
}
// ── Identity loading ─────────────────────────────────────────────────────────
/// Load identity context from SOUL.md and PROFILE.md in the workspace.
/// Returns a formatted string to prepend to prompts.
pub fn load_identity_context(workspace_dir: &Path) -> String {
let prompts_dir = resolve_prompts_dir(workspace_dir);
let mut ctx = String::new();
@@ -222,8 +94,6 @@ pub fn load_identity_context(workspace_dir: &Path) -> String {
}
}
// PROFILE.md lives in the workspace root (not prompts dir) — it's
// generated by the onboarding enrichment pipeline, not bundled.
if let Some(profile) = load_file_excerpt(workspace_dir, "PROFILE.md") {
ctx.push_str("## User Profile\n\n");
ctx.push_str(&profile);
@@ -238,13 +108,11 @@ pub fn load_identity_context(workspace_dir: &Path) -> String {
}
fn resolve_prompts_dir(workspace_dir: &Path) -> Option<std::path::PathBuf> {
// Check workspace AI dir
let workspace_ai = workspace_dir.join("ai");
if workspace_ai.is_dir() {
return Some(workspace_ai);
}
// Try CARGO_MANIFEST_DIR (dev builds)
if let Some(dir) = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from) {
let candidate = dir
.join("src")
@@ -256,7 +124,6 @@ fn resolve_prompts_dir(workspace_dir: &Path) -> Option<std::path::PathBuf> {
}
}
// Walk up from cwd
if let Ok(cwd) = std::env::current_dir() {
return crate::openhuman::dev_paths::repo_ai_prompts_dir(&cwd);
}
@@ -281,60 +148,22 @@ fn load_file_excerpt(dir: &Path, filename: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::subconscious::types::{TaskRecurrence, TaskSource};
fn test_task(id: &str, title: &str) -> SubconsciousTask {
SubconsciousTask {
id: id.to_string(),
title: title.to_string(),
source: TaskSource::User,
recurrence: TaskRecurrence::Once,
enabled: true,
last_run_at: None,
next_run_at: None,
completed: false,
created_at: 0.0,
}
}
#[test]
fn evaluation_prompt_includes_tasks_and_report() {
let tasks = vec![
test_task("t1", "Check email"),
test_task("t2", "Review calendar"),
];
let prompt = build_evaluation_prompt(&tasks, "## State\nSome data.", "Identity here");
assert!(prompt.contains("[t1] Check email"));
assert!(prompt.contains("[t2] Review calendar"));
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 evaluation_prompt_includes_decision_schema() {
let tasks = vec![test_task("t1", "Task")];
let prompt = build_evaluation_prompt(&tasks, "", "");
assert!(prompt.contains("noop"));
assert!(prompt.contains("act"));
assert!(prompt.contains("escalate"));
assert!(prompt.contains("evaluations"));
assert!(prompt.contains("task_id"));
}
#[test]
fn text_execution_prompt_includes_task_title() {
let task = test_task("t1", "Summarize urgent emails");
let prompt = build_text_execution_prompt(&task, "3 new emails", "Identity");
assert!(prompt.contains("Summarize urgent emails"));
assert!(prompt.contains("3 new emails"));
}
#[test]
fn tool_execution_prompt_includes_tool_instructions() {
let task = test_task("t1", "Send digest to Telegram");
let prompt = build_tool_execution_prompt(&task, "Email data here", "Identity");
assert!(prompt.contains("Send digest to Telegram"));
assert!(prompt.contains("tools"));
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]
+6
View File
@@ -54,6 +54,10 @@ pub struct Reflection {
pub acted_on_at: Option<f64>,
/// Epoch seconds when the user dismissed the card.
pub dismissed_at: Option<f64>,
/// 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<String>,
}
/// Categorisation of the underlying signal. Start narrow; we can grow
@@ -132,6 +136,7 @@ pub fn hydrate_draft(
id: String,
now: f64,
source_chunks: Vec<SourceChunk>,
thread_id: Option<String>,
) -> Reflection {
Reflection {
id,
@@ -143,6 +148,7 @@ pub fn hydrate_draft(
created_at: now,
acted_on_at: None,
dismissed_at: None,
thread_id,
}
}
+20 -8
View File
@@ -36,7 +36,8 @@ pub const REFLECTION_SCHEMA_DDL: &str = "
source_chunks TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL,
acted_on_at REAL,
dismissed_at REAL
dismissed_at REAL,
thread_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_reflections_created
ON subconscious_reflections(created_at DESC);
@@ -86,6 +87,13 @@ pub fn migrate_add_source_chunks_column(conn: &Connection) {
);
}
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
@@ -101,8 +109,8 @@ pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()>
conn.execute(
"INSERT OR IGNORE INTO subconscious_reflections (
id, kind, body, proposed_action, source_refs, source_chunks,
created_at, acted_on_at, dismissed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
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(),
@@ -113,14 +121,16 @@ pub fn add_reflection(conn: &Connection, reflection: &Reflection) -> Result<()>
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={}",
"[subconscious::reflection_store] added id={} kind={} chunks={} thread={:?}",
reflection.id,
reflection.kind.as_str(),
reflection.source_chunks.len()
reflection.source_chunks.len(),
reflection.thread_id,
);
Ok(())
}
@@ -138,7 +148,7 @@ pub fn list_recent(
let mapped: Vec<Reflection> = 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
created_at, acted_on_at, dismissed_at, thread_id
FROM subconscious_reflections
WHERE created_at > ?1
ORDER BY created_at DESC LIMIT ?2",
@@ -151,7 +161,7 @@ pub fn list_recent(
} else {
stmt = conn.prepare(
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
created_at, acted_on_at, dismissed_at
created_at, acted_on_at, dismissed_at, thread_id
FROM subconscious_reflections
ORDER BY created_at DESC LIMIT ?1",
)?;
@@ -168,7 +178,7 @@ pub fn list_recent(
pub fn get_reflection(conn: &Connection, id: &str) -> Result<Option<Reflection>> {
let mut stmt = conn.prepare(
"SELECT id, kind, body, proposed_action, source_refs, source_chunks,
created_at, acted_on_at, dismissed_at
created_at, acted_on_at, dismissed_at, thread_id
FROM subconscious_reflections WHERE id = ?1",
)?;
let r = stmt
@@ -206,6 +216,7 @@ fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result<Reflection> {
let created_at: f64 = row.get(6)?;
let acted_on_at: Option<f64> = row.get(7)?;
let dismissed_at: Option<f64> = row.get(8)?;
let thread_id: Option<String> = row.get(9)?;
let source_refs: Vec<String> =
serde_json::from_str(&source_refs_json).unwrap_or_else(|_| Vec::new());
@@ -222,6 +233,7 @@ fn row_to_reflection(row: &rusqlite::Row) -> rusqlite::Result<Reflection> {
created_at,
acted_on_at,
dismissed_at,
thread_id,
})
}
@@ -24,7 +24,7 @@ fn sample_reflection(id: &str, created_at: f64) -> Reflection {
proposed_action: Some("Take a look".into()),
source_refs: vec!["entity:foo".into()],
};
hydrate_draft(draft, id.into(), created_at, Vec::new())
hydrate_draft(draft, id.into(), created_at, Vec::new(), None)
}
#[test]
@@ -61,7 +61,7 @@ fn hydrate_draft_fills_lifecycle_fields() {
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());
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());
+40 -387
View File
@@ -1,11 +1,10 @@
//! RPC endpoints for the subconscious task system.
//! RPC endpoints for the subconscious agent loop.
use serde_json::{Map, Value};
use super::global::get_or_init_engine;
use super::reflection_store;
use super::store;
use super::types::{EscalationStatus, TaskPatch, TaskRecurrence, TaskSource};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::rpc::RpcOutcome;
@@ -14,14 +13,6 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("status"),
schemas("trigger"),
schemas("tasks_list"),
schemas("tasks_add"),
schemas("tasks_update"),
schemas("tasks_remove"),
schemas("log_list"),
schemas("escalations_list"),
schemas("escalations_approve"),
schemas("escalations_dismiss"),
schemas("reflections_list"),
schemas("reflections_act"),
schemas("reflections_dismiss"),
@@ -38,38 +29,6 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("trigger"),
handler: handle_trigger,
},
RegisteredController {
schema: schemas("tasks_list"),
handler: handle_tasks_list,
},
RegisteredController {
schema: schemas("tasks_add"),
handler: handle_tasks_add,
},
RegisteredController {
schema: schemas("tasks_update"),
handler: handle_tasks_update,
},
RegisteredController {
schema: schemas("tasks_remove"),
handler: handle_tasks_remove,
},
RegisteredController {
schema: schemas("log_list"),
handler: handle_log_list,
},
RegisteredController {
schema: schemas("escalations_list"),
handler: handle_escalations_list,
},
RegisteredController {
schema: schemas("escalations_approve"),
handler: handle_escalations_approve,
},
RegisteredController {
schema: schemas("escalations_dismiss"),
handler: handle_escalations_dismiss,
},
RegisteredController {
schema: schemas("reflections_list"),
handler: handle_reflections_list,
@@ -101,143 +60,30 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![field("result", TypeSchema::Json, "Tick result.")],
},
"tasks_list" => ControllerSchema {
namespace: "subconscious",
function: "tasks_list",
description: "List all subconscious tasks.",
inputs: vec![field_opt(
"enabled_only",
TypeSchema::Bool,
"Filter to enabled tasks only.",
)],
outputs: vec![field("tasks", TypeSchema::Json, "Array of tasks.")],
},
"tasks_add" => ControllerSchema {
namespace: "subconscious",
function: "tasks_add",
description: "Add a new task. The agent classifies it as one-off or recurrent.",
inputs: vec![
field_req(
"title",
TypeSchema::String,
"Natural language task description.",
),
field_opt(
"source",
TypeSchema::String,
"Task source: 'user' (default) or 'system'.",
),
],
outputs: vec![field("task", TypeSchema::Json, "The created task.")],
},
"tasks_update" => ControllerSchema {
namespace: "subconscious",
function: "tasks_update",
description: "Update a task.",
inputs: vec![
field_req("task_id", TypeSchema::String, "Task ID to update."),
field_opt("title", TypeSchema::String, "New title."),
field_opt(
"recurrence",
TypeSchema::String,
"New recurrence: 'once' | 'cron:<expr>' | 'pending'.",
),
field_opt("enabled", TypeSchema::Bool, "Enable or disable."),
],
outputs: vec![field("result", TypeSchema::Json, "Update confirmation.")],
},
"tasks_remove" => ControllerSchema {
namespace: "subconscious",
function: "tasks_remove",
description: "Remove a task.",
inputs: vec![field_req(
"task_id",
TypeSchema::String,
"Task ID to remove.",
)],
outputs: vec![field("result", TypeSchema::Json, "Removal confirmation.")],
},
"log_list" => ControllerSchema {
namespace: "subconscious",
function: "log_list",
description: "List execution log entries.",
inputs: vec![
field_opt("task_id", TypeSchema::String, "Filter by task ID."),
field_opt("limit", TypeSchema::U64, "Max entries (default 50)."),
],
outputs: vec![field("entries", TypeSchema::Json, "Log entries.")],
},
"escalations_list" => ControllerSchema {
namespace: "subconscious",
function: "escalations_list",
description: "List escalations.",
inputs: vec![field_opt(
"status",
TypeSchema::String,
"Filter: 'pending' | 'approved' | 'dismissed'.",
)],
outputs: vec![field(
"escalations",
TypeSchema::Json,
"Escalation records.",
)],
},
"escalations_approve" => ControllerSchema {
namespace: "subconscious",
function: "escalations_approve",
description: "Approve an escalation — execute the task.",
inputs: vec![field_req(
"escalation_id",
TypeSchema::String,
"Escalation ID.",
)],
outputs: vec![field("result", TypeSchema::Json, "Approval confirmation.")],
},
"escalations_dismiss" => ControllerSchema {
namespace: "subconscious",
function: "escalations_dismiss",
description: "Dismiss an escalation — don't execute.",
inputs: vec![field_req(
"escalation_id",
TypeSchema::String,
"Escalation ID.",
)],
outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")],
},
// ── #623: proactive reflection layer ─────────────────────────────────
"reflections_list" => ControllerSchema {
namespace: "subconscious",
function: "reflections_list",
description: "List recent subconscious reflections (Observe + Notify). \
Newest first.",
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 reflections newer than this.",
"Epoch seconds — only return thoughts newer than this.",
),
],
outputs: vec![field(
"reflections",
TypeSchema::Json,
"Reflection records.",
)],
outputs: vec![field("reflections", TypeSchema::Json, "Thought records.")],
},
"reflections_act" => ControllerSchema {
namespace: "subconscious",
function: "reflections_act",
description: "Act on a reflection — creates a fresh conversation thread \
and seeds it with the reflection body as the first ASSISTANT \
message (with proposed_action appended if present). No LLM \
turn fires — the user lands in a thread that opens with the \
observation from OpenHuman, ready for them to reply. Marks \
`acted_on_at`. Returns the new thread id so the frontend can \
navigate to it.",
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,
"Reflection ID.",
"Thought ID.",
)],
outputs: vec![field(
"result",
@@ -248,11 +94,11 @@ pub fn schemas(function: &str) -> ControllerSchema {
"reflections_dismiss" => ControllerSchema {
namespace: "subconscious",
function: "reflections_dismiss",
description: "Dismiss a reflection card. Sets `dismissed_at`.",
description: "Dismiss a thought card. Sets `dismissed_at`.",
inputs: vec![field_req(
"reflection_id",
TypeSchema::String,
"Reflection ID.",
"Thought ID.",
)],
outputs: vec![field("result", TypeSchema::Json, "Dismissal confirmation.")],
},
@@ -270,42 +116,44 @@ pub fn schemas(function: &str) -> ControllerSchema {
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
// Read status entirely from DB — never touch the engine mutex.
// The engine lock is held for the full tick duration, so any RPC
// that acquires it would block until the tick completes.
// 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;
if let Some(engine) = guard.as_ref() {
let status = engine.status().await;
return to_json(RpcOutcome::single_log(status, "subconscious status"));
}
}
// Engine not yet initialised — build a snapshot from config.
let config = load_config().await?;
let hb = &config.heartbeat;
let (task_count, pending_escalations, last_tick_at, total_ticks) =
store::with_connection(&config.workspace_dir, |conn| {
let tc = store::task_count(conn).unwrap_or(0);
let pe = store::pending_escalation_count(conn).unwrap_or(0);
let (lt, tt) = conn
.query_row(
"SELECT MAX(tick_at), COUNT(DISTINCT tick_at) FROM subconscious_log",
[],
|row| Ok((row.get::<_, Option<f64>>(0)?, row.get::<_, u64>(1)?)),
)
.unwrap_or((None, 0));
Ok((tc, pe, lt, tt))
})
.map_err(|e| format!("{e:#}"))?;
let last_tick_at =
store::with_connection(&config.workspace_dir, |conn| store::get_last_tick_at(conn))
.ok();
let provider_unavailable_reason = if hb.enabled && hb.inference_enabled {
super::engine::subconscious_provider_unavailable_reason(&config)
} else {
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: hb.enabled && hb.inference_enabled,
enabled: mode.is_enabled(),
mode: mode.as_str().to_string(),
provider_available: provider_unavailable_reason.is_none(),
provider_unavailable_reason,
interval_minutes: hb.interval_minutes.max(5),
last_tick_at,
total_ticks,
task_count,
pending_escalations,
consecutive_failures: 0, // Only available from in-memory state; 0 is fine for UI
interval_minutes: mode.default_interval_minutes().max(5),
last_tick_at: last_tick_at.filter(|v| *v > 0.0),
total_ticks: 0,
consecutive_failures: 0,
};
to_json(RpcOutcome::single_log(status, "subconscious status"))
@@ -316,8 +164,6 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let lock = get_or_init_engine().await?;
// Spawn the tick in the background so the RPC returns immediately.
// The frontend can poll status/log to see in_progress → final transitions.
let lock_clone = std::sync::Arc::clone(&lock);
tokio::spawn(async move {
let guard = lock_clone.lock().await;
@@ -325,9 +171,9 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
match engine.tick().await {
Ok(result) => {
tracing::info!(
"[subconscious] manual tick: executed={} escalated={} duration={}ms",
result.executed,
result.escalated,
"[subconscious] manual tick: thoughts={} thread={:?} duration={}ms",
result.thoughts_count,
result.thread_id,
result.duration_ms
);
}
@@ -345,173 +191,6 @@ fn handle_trigger(_params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_tasks_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let enabled_only = params
.get("enabled_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let config = load_config().await?;
let tasks = store::with_connection(&config.workspace_dir, |conn| {
store::list_tasks(conn, enabled_only)
})
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(tasks, "tasks listed"))
})
}
fn handle_tasks_add(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let title = params
.get("title")
.and_then(|v| v.as_str())
.ok_or("title is required")?
.to_string();
let source = match params.get("source").and_then(|v| v.as_str()) {
Some("system") => TaskSource::System,
_ => TaskSource::User,
};
let lock = get_or_init_engine().await?;
let guard = lock.lock().await;
let engine = guard.as_ref().ok_or("engine not initialized")?;
let task = engine
.add_task(&title, source)
.await
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(task, "task added"))
})
}
fn handle_tasks_update(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let task_id = params
.get("task_id")
.and_then(|v| v.as_str())
.ok_or("task_id is required")?
.to_string();
let patch = TaskPatch {
title: params
.get("title")
.and_then(|v| v.as_str())
.map(String::from),
recurrence: params.get("recurrence").and_then(|v| v.as_str()).map(|s| {
if s == "once" {
TaskRecurrence::Once
} else if let Some(expr) = s.strip_prefix("cron:") {
TaskRecurrence::Cron(expr.to_string())
} else {
TaskRecurrence::Pending
}
}),
enabled: params.get("enabled").and_then(|v| v.as_bool()),
};
let config = load_config().await?;
store::with_connection(&config.workspace_dir, |conn| {
store::update_task(conn, &task_id, &patch)
})
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(
serde_json::json!({"updated": task_id}),
"task updated",
))
})
}
fn handle_tasks_remove(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let task_id = params
.get("task_id")
.and_then(|v| v.as_str())
.ok_or("task_id is required")?
.to_string();
let config = load_config().await?;
store::with_connection(&config.workspace_dir, |conn| {
store::remove_task(conn, &task_id)
})
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(
serde_json::json!({"removed": task_id}),
"task removed",
))
})
}
fn handle_log_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let task_id = params.get("task_id").and_then(|v| v.as_str());
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let config = load_config().await?;
let entries = store::with_connection(&config.workspace_dir, |conn| {
store::list_log_entries(conn, task_id, limit)
})
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(entries, "log entries listed"))
})
}
fn handle_escalations_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let status_filter = params
.get("status")
.and_then(|v| v.as_str())
.map(|s| match s {
"approved" => EscalationStatus::Approved,
"dismissed" => EscalationStatus::Dismissed,
_ => EscalationStatus::Pending,
});
let config = load_config().await?;
let escalations = store::with_connection(&config.workspace_dir, |conn| {
store::list_escalations(conn, status_filter.as_ref())
})
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(escalations, "escalations listed"))
})
}
fn handle_escalations_approve(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let escalation_id = params
.get("escalation_id")
.and_then(|v| v.as_str())
.ok_or("escalation_id is required")?
.to_string();
let lock = get_or_init_engine().await?;
let guard = lock.lock().await;
let engine = guard.as_ref().ok_or("engine not initialized")?;
engine
.approve_escalation(&escalation_id)
.await
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(
serde_json::json!({"approved": escalation_id}),
"escalation approved and executed",
))
})
}
fn handle_escalations_dismiss(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let escalation_id = params
.get("escalation_id")
.and_then(|v| v.as_str())
.ok_or("escalation_id is required")?
.to_string();
let lock = get_or_init_engine().await?;
let guard = lock.lock().await;
let engine = guard.as_ref().ok_or("engine not initialized")?;
engine
.dismiss_escalation(&escalation_id)
.await
.map_err(|e| format!("{e:#}"))?;
to_json(RpcOutcome::single_log(
serde_json::json!({"dismissed": escalation_id}),
"escalation dismissed",
))
})
}
// ── #623: proactive reflection handlers ──────────────────────────────────────
fn handle_reflections_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
@@ -540,10 +219,6 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
.map_err(|e| format!("{e:#}"))?
.ok_or_else(|| format!("reflection not found: {reflection_id}"))?;
// Spawn a fresh conversation thread for this action. Reflections never
// write into the user's existing threads — each act gets its own
// chat so the active conversation stays uncluttered. Title is the
// first ~60 chars of the body so it's recognisable in the thread list.
let thread_id = uuid::Uuid::new_v4().to_string();
let thread_title: String = {
let mut s: String = reflection
@@ -578,14 +253,6 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
)
.map_err(|e| format!("ensure_thread (reflection-spawned) failed: {e}"))?;
// Seed the new thread with the reflection as the FIRST message,
// sent from `assistant` (i.e. OpenHuman speaking). The frontend
// renders this as a regular AI message, so the user lands in a
// thread that already starts with the observation. They can then
// type their own reply — no auto LLM turn fires here. This is
// distinct from `start_chat`, which would have appended the
// reflection as a USER message and immediately triggered an
// orchestrator response.
let body_md = match reflection.proposed_action.as_deref() {
Some(action) if !action.trim().is_empty() => format!(
"{body}\n\n_Proposed action_: {action}",
@@ -616,12 +283,6 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
)
.map_err(|e| format!("append seed reflection message failed: {e}"))?;
// Stamp acted_on_at on success. If the stamp write fails, log a
// warning — the new thread already exists, so a silent failure
// here would leave the reflection unmarked and the user could
// re-Act on the same card and spawn a duplicate thread. The
// reflection itself is still actionable from the user's
// perspective, so we don't want to fail the whole call.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
@@ -630,7 +291,7 @@ fn handle_reflections_act(params: Map<String, Value>) -> ControllerFuture {
reflection_store::mark_acted(conn, &reflection_id, now)
}) {
log::warn!(
"[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e} — reflection card will reappear and a re-Act would spawn a duplicate thread",
"[subconscious] failed to stamp acted_on_at reflection={} thread={}: {e}",
reflection_id,
thread_id
);
@@ -672,14 +333,6 @@ fn handle_reflections_dismiss(params: Map<String, Value>) -> ControllerFuture {
// ── Helpers ──────────────────────────────────────────────────────────────────
async fn load_config() -> Result<crate::openhuman::config::Config, String> {
// Use the same 30s-bounded loader every other JSON-RPC domain uses
// (see cron/schemas.rs, webhooks/schemas.rs, etc.). Raw
// `Config::load_or_init()` can stall on `SecretStore::new` plus a chain
// of `decrypt_optional_secret` calls that may IPC to an OS keychain,
// so the subconscious handlers used to be the only unbounded outlier
// in the entire JSON-RPC surface. Under the Intelligence page's 3s
// poll that chokepoint let a slow keychain call pin the frontend's
// `Promise.all` and freeze the activity log on a stale snapshot.
crate::openhuman::config::load_config_with_timeout().await
}
+15 -227
View File
@@ -1,14 +1,13 @@
use super::*;
#[test]
fn all_schemas_returns_thirteen() {
// 10 task/escalation schemas + 3 reflection schemas (#623).
assert_eq!(all_controller_schemas().len(), 13);
fn all_schemas_returns_five() {
assert_eq!(all_controller_schemas().len(), 5);
}
#[test]
fn all_controllers_returns_thirteen() {
assert_eq!(all_registered_controllers().len(), 13);
fn all_controllers_returns_five() {
assert_eq!(all_registered_controllers().len(), 5);
}
#[test]
@@ -23,233 +22,22 @@ fn reflection_rpcs_are_registered() {
}
#[test]
fn all_use_subconscious_namespace() {
for s in all_controller_schemas() {
assert_eq!(s.namespace, "subconscious");
assert!(!s.description.is_empty());
}
}
#[test]
fn schemas_and_controllers_match() {
let s = all_controller_schemas();
let c = all_registered_controllers();
for (schema, ctrl) in s.iter().zip(c.iter()) {
assert_eq!(schema.function, ctrl.schema.function);
}
}
#[test]
fn known_functions_resolve() {
for fn_name in [
"status",
"trigger",
"tasks_list",
"tasks_add",
"tasks_update",
"tasks_remove",
"log_list",
"escalations_list",
"escalations_approve",
"escalations_dismiss",
] {
let s = schemas(fn_name);
assert_ne!(s.function, "unknown", "{fn_name} fell through");
}
}
#[test]
fn unknown_function_returns_unknown() {
let s = schemas("nonexistent");
assert_eq!(s.function, "unknown");
}
#[test]
fn status_schema_has_no_inputs() {
assert!(schemas("status").inputs.is_empty());
}
#[test]
fn trigger_schema_has_no_inputs() {
assert!(schemas("trigger").inputs.is_empty());
}
#[test]
fn tasks_add_requires_title() {
let s = schemas("tasks_add");
let required: Vec<&str> = s
.inputs
fn status_and_trigger_are_registered() {
let names: Vec<&str> = all_controller_schemas()
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.map(|s| s.function)
.collect();
assert!(required.contains(&"title"));
assert!(names.contains(&"status"));
assert!(names.contains(&"trigger"));
}
#[test]
fn tasks_update_requires_task_id() {
let s = schemas("tasks_update");
let required: Vec<&str> = s
.inputs
fn task_endpoints_are_removed() {
let names: Vec<&str> = all_controller_schemas()
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.map(|s| s.function)
.collect();
assert!(required.contains(&"task_id"));
}
#[test]
fn tasks_remove_requires_task_id() {
let s = schemas("tasks_remove");
let required: Vec<&str> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert!(required.contains(&"task_id"));
}
#[test]
fn escalations_approve_requires_escalation_id() {
let s = schemas("escalations_approve");
assert!(s
.inputs
.iter()
.any(|f| f.name == "escalation_id" && f.required));
}
#[test]
fn escalations_dismiss_requires_escalation_id() {
let s = schemas("escalations_dismiss");
assert!(s
.inputs
.iter()
.any(|f| f.name == "escalation_id" && f.required));
}
#[test]
fn log_list_has_optional_inputs() {
let s = schemas("log_list");
for input in &s.inputs {
assert!(
!input.required,
"log_list input '{}' should be optional",
input.name
);
}
}
#[test]
fn tasks_list_has_optional_enabled_only() {
let s = schemas("tasks_list");
let enabled = s.inputs.iter().find(|f| f.name == "enabled_only");
assert!(enabled.is_some_and(|f| !f.required));
}
// ── Field helpers ──────────────────────────────────────────────
#[test]
fn field_helper_is_required() {
let f = field("name", TypeSchema::String, "desc");
assert!(f.required);
}
#[test]
fn field_req_helper_is_required() {
let f = field_req("name", TypeSchema::String, "desc");
assert!(f.required);
}
#[test]
fn field_opt_helper_is_not_required() {
let f = field_opt("name", TypeSchema::String, "desc");
assert!(!f.required);
}
// ── Error chain preservation ───────────────────────────────────
//
// The RPC handlers in this module bridge `anyhow::Result` (from
// `store::with_connection` and the wrapped rusqlite errors) into the
// JSON-RPC `Result<Value, String>` boundary via `map_err(|e| ...)`.
//
// **Critical for observability**: plain `e.to_string()` on an
// `anyhow::Error` returns ONLY the outermost context. For a
// `with_connection` failure the outer wrap is
// `"failed to run subconscious schema DDL"` — the underlying rusqlite
// root (the actual SQLite error code + message) is dropped. That
// stringified message is what `jsonrpc::invoke_method_inner` later
// passes to `report_error_or_expected`, which in turn captures it in
// Sentry. Without the chain, Sentry events for TAURI-RUST-A only
// surface the generic wrapper text and the rusqlite root cause is
// permanently invisible.
//
// All `map_err` sites in `schemas.rs` use `format!("{e:#}")` (anyhow's
// alternate Display walks the cause chain inline joined by `": "`) so
// the rusqlite root reaches Sentry. These guard tests pin the format
// so future contributors don't silently regress to `e.to_string()`.
#[test]
fn anyhow_alternate_display_walks_chain() {
use anyhow::Context;
let inner = anyhow::anyhow!("database is locked").context("execute_batch failed");
let outer: anyhow::Result<()> = Err(inner).context("failed to run subconscious schema DDL");
let err = outer.unwrap_err();
// Plain to_string() — the broken (pre-fix) shape. Only outer
// wrapper reaches the caller, root cause lost.
let lossy = err.to_string();
assert_eq!(lossy, "failed to run subconscious schema DDL");
assert!(
!lossy.contains("database is locked"),
"plain Display must drop the root cause — if this changes the chain-formatter \
is no longer load-bearing, revisit observability assumptions"
);
// Alternate Display — what schemas.rs map_err now produces. Every
// layer joined by ": " so the rusqlite root reaches Sentry.
let full = format!("{err:#}");
assert!(
full.contains("failed to run subconscious schema DDL"),
"chain-formatted message must include outer wrapper, got: {full}"
);
assert!(
full.contains("execute_batch failed"),
"chain-formatted message must include middle context, got: {full}"
);
assert!(
full.contains("database is locked"),
"chain-formatted message must include the rusqlite root, got: {full}"
);
}
#[test]
fn anyhow_alternate_display_includes_rusqlite_error_chain() {
use anyhow::Context;
// Simulate the exact shape produced by `with_connection`:
// a real rusqlite Error wrapped in `with_context(...)`.
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseBusy,
extended_code: 5,
},
Some("database is locked".into()),
);
let wrapped: anyhow::Result<()> =
Err(anyhow::Error::from(raw)).context("failed to run subconscious schema DDL");
let err = wrapped.unwrap_err();
let chained = format!("{err:#}");
// Outer wrapper preserved.
assert!(chained.contains("failed to run subconscious schema DDL"));
// rusqlite-rendered root preserved — this is the signal Sentry
// needs to distinguish a DDL lock-race from a corruption / disk-full
// / permission failure. Without it, all four fingerprint identically.
assert!(
chained.contains("database is locked"),
"rusqlite root must appear in chain-formatted message, got: {chained}"
);
assert!(!names.contains(&"tasks_list"));
assert!(!names.contains(&"tasks_add"));
assert!(!names.contains(&"escalations_list"));
}
@@ -139,24 +139,8 @@ fn build_identifiers_section() -> String {
out
}
fn build_tasks_section(workspace_dir: &Path) -> String {
use std::fmt::Write;
let tasks = match super::store::with_connection(workspace_dir, |conn| {
super::store::list_tasks(conn, false)
}) {
Ok(tasks) => tasks,
Err(_) => return "## Pending Tasks\n\nFailed to read tasks.\n".to_string(),
};
if tasks.is_empty() {
return "## Pending Tasks\n\nNo tasks defined.\n".to_string();
}
let mut section = String::from("## Pending Tasks\n\n");
for task in &tasks {
let _ = writeln!(section, "- {}", task.title);
}
section
fn build_tasks_section(_workspace_dir: &Path) -> String {
String::new()
}
/// Append a section, truncating at a UTF-8 char boundary if it overflows
@@ -68,6 +68,7 @@ mod tests {
id.into(),
1.0,
Vec::new(),
None,
)
}
+18 -591
View File
@@ -1,59 +1,24 @@
//! SQLite persistence for subconscious tasks, execution log, and escalations.
//! SQLite persistence for the subconscious engine.
//!
//! Follows the cron module's `with_connection` pattern: opens the database,
//! runs DDL on every connection, and provides pure functions.
//!
//! ## Init-failure noise suppression (TAURI-RUST-A)
//!
//! `with_connection` runs the schema DDL on every call. Transient
//! `SQLITE_BUSY` / `SQLITE_LOCKED` errors are handled by a per-connection
//! busy timeout (5 s) plus an application-level retry loop (3 retries,
//! 100 / 300 / 900 ms backoff).
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension};
use std::path::Path;
use std::time::Duration;
use uuid::Uuid;
use super::types::{
Escalation, EscalationPriority, EscalationStatus, SubconsciousLogEntry, SubconsciousTask,
TaskPatch, TaskRecurrence, TaskSource,
};
/// Per-connection busy handler window. Tracks the value used by the cron
/// module + other domain stores (`memory_store::unified::init`, 15s) and
/// the higher-throughput whatsapp/memory_queue path (5s). 5 s is enough
/// for the subconscious tick — RPC handlers are user-driven (status
/// polling at 3 s, manual triggers) and we'd rather fail fast than block
/// the UI thread for the full 15 s of contention.
const BUSY_TIMEOUT: Duration = Duration::from_millis(5000);
/// Maximum number of application-level retries after rusqlite's busy
/// handler is exhausted. The first attempt is "attempt 0" — total
/// attempts = `OPEN_RETRY_ATTEMPTS` + 1.
const OPEN_RETRY_ATTEMPTS: u32 = 3;
/// Base backoff for application-level retries; per-attempt sleep is
/// `BASE * 3^attempt` so the schedule is `100 ms / 300 ms / 900 ms`
/// totalling ≤ 1.3 s before the final attempt fails through.
const OPEN_RETRY_BASE_MS: u64 = 100;
/// Open the subconscious database and run schema migrations.
///
/// Three layers of defence against transient `SQLITE_BUSY` / `SQLITE_LOCKED`
/// at the open / DDL boundary, motivated by Sentry TAURI-RUST-A
/// (cross-platform, ~1.3k events / 24 h, RPC paths `subconscious_tasks_list`
/// and `subconscious_status`):
///
/// 1. **Per-connection busy timeout** (`BUSY_TIMEOUT`, 5 s): SQLite's
/// default is `0` — first lock contention returns `SQLITE_BUSY`
/// immediately. The subconscious domain serialises several RPCs
/// (status poll every 3 s, tasks-list on Intelligence page, manual
/// trigger), each opening its own connection; without a timeout the
/// first concurrent open races and one returns `SQLITE_BUSY` mid-DDL.
/// 2. **Application-level retry** (3 attempts, exponential backoff
/// 100 / 300 / 900 ms): catches the residual case where the busy
/// handler is exhausted (long-running external write txn, AV scan
/// holding the file). Mirrors `whatsapp_data::sqlite_retry` /
/// `memory_queue::worker::is_sqlite_busy`.
/// 3. **Retry classifier** (`is_sqlite_busy`): only retries
/// `DatabaseBusy` / `DatabaseLocked`. Schema / syntax / corruption
/// errors are real bugs or unrecoverable file-state failures —
/// retrying just delays the report.
pub fn with_connection<T>(
workspace_dir: &Path,
f: impl FnOnce(&Connection) -> Result<T>,
@@ -68,11 +33,6 @@ pub fn with_connection<T>(
f(&conn)
}
/// Open the SQLite file, set `busy_timeout`, run `SCHEMA_DDL`, and apply
/// the idempotent reflection-store migrations — retrying the whole
/// sequence on `SQLITE_BUSY` / `SQLITE_LOCKED`. Split out so the retry
/// loop has a single failure surface to classify and the happy path
/// stays linear.
fn open_and_initialize_with_retry(db_path: &Path) -> Result<Connection> {
let mut last_err: Option<anyhow::Error> = None;
@@ -114,43 +74,23 @@ fn open_and_initialize_with_retry(db_path: &Path) -> Result<Connection> {
Err(last_err.expect("OPEN_RETRY_ATTEMPTS >= 0 ensures at least one attempt"))
}
/// Single-shot open + DDL + migrations. Each invocation returns an
/// owned `Connection`; on failure the partially-initialised connection
/// is dropped before the caller retries.
fn open_and_initialize(db_path: &Path) -> Result<Connection> {
let conn = Connection::open(db_path)
.with_context(|| format!("failed to open subconscious DB: {}", db_path.display()))?;
// Set busy_timeout BEFORE running DDL — the very first PRAGMA / CREATE
// TABLE in SCHEMA_DDL can race with another in-process connection
// (subconscious RPCs each call `with_connection` independently), and
// SQLite's default busy_timeout is 0.
conn.busy_timeout(BUSY_TIMEOUT)
.context("configure subconscious busy_timeout")?;
conn.execute_batch(SCHEMA_DDL)
.context("failed to run subconscious schema DDL")?;
// Drop the legacy `disposition` / `surfaced_at` columns + their index
// from previously-migrated DBs. Idempotent — fresh installs and
// already-migrated DBs no-op via swallowed errors.
super::reflection_store::migrate_drop_legacy_columns(&conn);
// Add the `source_chunks` JSON column to previously-migrated DBs.
// Idempotent (duplicate-column errors swallowed).
super::reflection_store::migrate_add_source_chunks_column(&conn);
super::reflection_store::migrate_add_thread_id_column(&conn);
Ok(conn)
}
/// Returns true when `err` is transient SQLite contention worth retrying
/// (`SQLITE_BUSY` / `SQLITE_LOCKED`). Schema / syntax / corruption errors
/// are NOT retried — the retry would just delay the same failure.
///
/// Modelled on [`crate::openhuman::memory_queue::worker::is_sqlite_busy`]
/// and [`crate::openhuman::whatsapp_data::sqlite_retry::is_sqlite_busy`];
/// kept private to the subconscious store so the retry policy can evolve
/// independently of those sibling domains.
fn is_sqlite_busy(err: &anyhow::Error) -> bool {
if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) =
err.downcast_ref::<rusqlite::Error>()
@@ -160,11 +100,6 @@ fn is_sqlite_busy(err: &anyhow::Error) -> bool {
rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
);
}
// Fallback for errors wrapped under `.context(...)` layers — the
// rusqlite root may sit a few levels deep after `with_context`
// wraps the open / DDL failure. anyhow's alternate Display joins
// every cause with ": " so the SQLite-rendered phrase remains
// searchable.
let msg = format!("{err:#}").to_ascii_lowercase();
msg.contains("database is locked") || msg.contains("database table is locked")
}
@@ -173,6 +108,8 @@ const SCHEMA_DDL: &str = "
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
-- Legacy tables retained for backward compatibility with existing DBs.
-- No longer written to or read from.
CREATE TABLE IF NOT EXISTS subconscious_tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
@@ -184,11 +121,6 @@ const SCHEMA_DDL: &str = "
completed INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_next_run
ON subconscious_tasks(next_run_at);
CREATE INDEX IF NOT EXISTS idx_tasks_enabled
ON subconscious_tasks(enabled, completed);
CREATE TABLE IF NOT EXISTS subconscious_log (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
@@ -198,11 +130,6 @@ const SCHEMA_DDL: &str = "
duration_ms INTEGER,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_log_task
ON subconscious_log(task_id, tick_at DESC);
CREATE INDEX IF NOT EXISTS idx_log_tick
ON subconscious_log(tick_at DESC);
CREATE TABLE IF NOT EXISTS subconscious_escalations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
@@ -214,18 +141,7 @@ const SCHEMA_DDL: &str = "
created_at REAL NOT NULL,
resolved_at REAL
);
CREATE INDEX IF NOT EXISTS idx_escalations_status
ON subconscious_escalations(status);
-- #623: reflection layer (proactive subconscious). Mirrored in
-- `super::reflection_store::REFLECTION_SCHEMA_DDL` for the unit
-- tests there. Legacy `disposition` / `surfaced_at` columns +
-- their index were removed when the auto-post-into-thread flow
-- was dropped — `migrate_drop_legacy_columns` cleans them off
-- previously-migrated DBs. The `source_chunks` JSON column was
-- added later for the memory-context snapshot feature —
-- `migrate_add_source_chunks_column` backfills previously-migrated
-- DBs that pre-date it.
CREATE TABLE IF NOT EXISTS subconscious_reflections (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
@@ -235,7 +151,8 @@ const SCHEMA_DDL: &str = "
source_chunks TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL,
acted_on_at REAL,
dismissed_at REAL
dismissed_at REAL,
thread_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_reflections_created
ON subconscious_reflections(created_at DESC);
@@ -246,495 +163,19 @@ const SCHEMA_DDL: &str = "
captured_at REAL NOT NULL
);
-- Tiny KV table for engine-local state that needs to survive
-- process restarts. Currently holds:
-- * `last_tick_at` — unix-seconds float of the most recent
-- successful tick. Used by the situation-
-- report sections (`summaries`, `query_window`,
-- `digest`) as a `WHERE sealed_at_ms > ?` cutoff
-- so the LLM only sees memory-tree rows that
-- have appeared since it last looked. Without
-- persistence the cutoff resets to 0 on every
-- restart, the LLM keeps reading the same
-- summaries, and `persist_and_surface_reflections`
-- (which has no insert-time dedupe) accumulates
-- near-duplicate reflections about the same
-- chunks (#623).
CREATE TABLE IF NOT EXISTS subconscious_state (
key TEXT PRIMARY KEY,
value REAL NOT NULL
);
";
/// Test-only re-export of [`SCHEMA_DDL`] for unit tests in sibling
/// modules (e.g. `reflection_store_tests`) that need to spin up an
/// in-memory connection with the full schema.
#[cfg(test)]
pub(crate) const SCHEMA_DDL_FOR_TESTS: &str = SCHEMA_DDL;
// ── Task CRUD ────────────────────────────────────────────────────────────────
pub fn add_task(
conn: &Connection,
title: &str,
source: TaskSource,
recurrence: TaskRecurrence,
) -> Result<SubconsciousTask> {
let id = Uuid::new_v4().to_string();
let now = now_secs();
let source_str = serde_json::to_value(&source)
.unwrap_or_default()
.as_str()
.unwrap_or("user")
.to_string();
let recurrence_str = recurrence_to_string(&recurrence);
conn.execute(
"INSERT INTO subconscious_tasks (id, title, source, recurrence, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![id, title, source_str, recurrence_str, now],
)?;
Ok(SubconsciousTask {
id,
title: title.to_string(),
source,
recurrence,
enabled: true,
last_run_at: None,
next_run_at: None,
completed: false,
created_at: now,
})
}
pub fn get_task(conn: &Connection, task_id: &str) -> Result<SubconsciousTask> {
conn.query_row(
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
FROM subconscious_tasks WHERE id = ?1",
[task_id],
row_to_task,
)
.with_context(|| format!("task not found: {task_id}"))
}
pub fn list_tasks(conn: &Connection, enabled_only: bool) -> Result<Vec<SubconsciousTask>> {
let sql = if enabled_only {
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
FROM subconscious_tasks WHERE enabled = 1 ORDER BY created_at"
} else {
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
FROM subconscious_tasks ORDER BY created_at"
};
let mut stmt = conn.prepare(sql)?;
let tasks = stmt
.query_map([], row_to_task)?
.collect::<Result<Vec<_>, _>>()?;
Ok(tasks)
}
pub fn update_task(conn: &Connection, task_id: &str, patch: &TaskPatch) -> Result<()> {
if let Some(ref title) = patch.title {
conn.execute(
"UPDATE subconscious_tasks SET title = ?1 WHERE id = ?2",
rusqlite::params![title, task_id],
)?;
}
if let Some(ref recurrence) = patch.recurrence {
conn.execute(
"UPDATE subconscious_tasks SET recurrence = ?1 WHERE id = ?2",
rusqlite::params![recurrence_to_string(recurrence), task_id],
)?;
}
if let Some(enabled) = patch.enabled {
conn.execute(
"UPDATE subconscious_tasks SET enabled = ?1 WHERE id = ?2",
rusqlite::params![enabled, task_id],
)?;
}
Ok(())
}
/// Remove a task. System tasks cannot be deleted — only disabled.
pub fn remove_task(conn: &Connection, task_id: &str) -> Result<()> {
let source: String = conn
.query_row(
"SELECT source FROM subconscious_tasks WHERE id = ?1",
[task_id],
|row| row.get(0),
)
.with_context(|| format!("task not found: {task_id}"))?;
if source == "system" {
anyhow::bail!("System tasks cannot be deleted. Disable them instead.");
}
conn.execute("DELETE FROM subconscious_tasks WHERE id = ?1", [task_id])?;
Ok(())
}
/// Get tasks that are due for evaluation (enabled, not completed, due now or never run).
pub fn due_tasks(conn: &Connection, now: f64) -> Result<Vec<SubconsciousTask>> {
let mut stmt = conn.prepare(
"SELECT id, title, source, recurrence, enabled, last_run_at, next_run_at, completed, created_at
FROM subconscious_tasks
WHERE enabled = 1 AND completed = 0
AND (next_run_at IS NULL OR next_run_at <= ?1)
ORDER BY next_run_at NULLS FIRST",
)?;
let tasks = stmt
.query_map([now], row_to_task)?
.collect::<Result<Vec<_>, _>>()?;
Ok(tasks)
}
pub fn mark_task_completed(conn: &Connection, task_id: &str) -> Result<()> {
conn.execute(
"UPDATE subconscious_tasks SET completed = 1 WHERE id = ?1",
[task_id],
)?;
Ok(())
}
pub fn update_task_run_times(
conn: &Connection,
task_id: &str,
last_run_at: f64,
next_run_at: Option<f64>,
) -> Result<()> {
conn.execute(
"UPDATE subconscious_tasks SET last_run_at = ?1, next_run_at = ?2 WHERE id = ?3",
rusqlite::params![last_run_at, next_run_at, task_id],
)?;
Ok(())
}
pub fn task_count(conn: &Connection) -> Result<u64> {
conn.query_row(
"SELECT COUNT(*) FROM subconscious_tasks WHERE enabled = 1 AND completed = 0",
[],
|row| row.get::<_, u64>(0),
)
.map_err(Into::into)
}
// ── Log CRUD ─────────────────────────────────────────────────────────────────
pub fn add_log_entry(
conn: &Connection,
task_id: &str,
tick_at: f64,
decision: &str,
result: Option<&str>,
duration_ms: Option<i64>,
) -> Result<SubconsciousLogEntry> {
let id = Uuid::new_v4().to_string();
let now = now_secs();
conn.execute(
"INSERT INTO subconscious_log (id, task_id, tick_at, decision, result, duration_ms, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![id, task_id, tick_at, decision, result, duration_ms, now],
)?;
Ok(SubconsciousLogEntry {
id,
task_id: task_id.to_string(),
tick_at,
decision: decision.to_string(),
result: result.map(String::from),
duration_ms,
created_at: now,
})
}
/// Update an existing log entry's decision, result, and duration in place.
pub fn update_log_entry(
conn: &Connection,
log_id: &str,
decision: &str,
result: Option<&str>,
duration_ms: Option<i64>,
) -> Result<()> {
conn.execute(
"UPDATE subconscious_log SET decision = ?1, result = ?2, duration_ms = ?3 WHERE id = ?4",
rusqlite::params![decision, result, duration_ms, log_id],
)?;
Ok(())
}
/// Bulk-update ALL in_progress log entries to cancelled.
/// Any entry still in_progress when a new tick starts is stale by definition.
pub fn cancel_stale_in_progress(conn: &Connection) -> Result<usize> {
let count = conn.execute(
"UPDATE subconscious_log SET decision = 'cancelled', result = 'Superseded by new tick'
WHERE decision = 'in_progress'",
[],
)?;
Ok(count)
}
pub fn list_log_entries(
conn: &Connection,
task_id: Option<&str>,
limit: usize,
) -> Result<Vec<SubconsciousLogEntry>> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(tid) = task_id {
(
"SELECT id, task_id, tick_at, decision, result, duration_ms, created_at
FROM subconscious_log WHERE task_id = ?1 ORDER BY tick_at DESC LIMIT ?2",
vec![Box::new(tid.to_string()), Box::new(limit as i64)],
)
} else {
(
"SELECT id, task_id, tick_at, decision, result, duration_ms, created_at
FROM subconscious_log ORDER BY tick_at DESC LIMIT ?1",
vec![Box::new(limit as i64)],
)
};
let mut stmt = conn.prepare(sql)?;
let entries = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(SubconsciousLogEntry {
id: row.get(0)?,
task_id: row.get(1)?,
tick_at: row.get(2)?,
decision: row.get(3)?,
result: row.get(4)?,
duration_ms: row.get(5)?,
created_at: row.get(6)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(entries)
}
// ── Escalation CRUD ──────────────────────────────────────────────────────────
pub fn add_escalation(
conn: &Connection,
task_id: &str,
log_id: Option<&str>,
title: &str,
description: &str,
priority: &EscalationPriority,
) -> Result<Escalation> {
let id = Uuid::new_v4().to_string();
let now = now_secs();
let priority_str = serde_json::to_value(priority)
.unwrap_or_default()
.as_str()
.unwrap_or("normal")
.to_string();
conn.execute(
"INSERT INTO subconscious_escalations (id, task_id, log_id, title, description, priority, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![id, task_id, log_id, title, description, priority_str, now],
)?;
Ok(Escalation {
id,
task_id: task_id.to_string(),
log_id: log_id.map(String::from),
title: title.to_string(),
description: description.to_string(),
priority: priority.clone(),
status: EscalationStatus::Pending,
created_at: now,
resolved_at: None,
})
}
pub fn list_escalations(
conn: &Connection,
status_filter: Option<&EscalationStatus>,
) -> Result<Vec<Escalation>> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(status) =
status_filter
{
let status_str = serde_json::to_value(status)
.unwrap_or_default()
.as_str()
.unwrap_or("pending")
.to_string();
(
"SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at
FROM subconscious_escalations WHERE status = ?1 ORDER BY created_at DESC",
vec![Box::new(status_str)],
)
} else {
(
"SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at
FROM subconscious_escalations ORDER BY created_at DESC",
vec![],
)
};
let mut stmt = conn.prepare(sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), row_to_escalation)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn resolve_escalation(
conn: &Connection,
escalation_id: &str,
status: &EscalationStatus,
) -> Result<()> {
let now = now_secs();
let status_str = serde_json::to_value(status)
.unwrap_or_default()
.as_str()
.unwrap_or("dismissed")
.to_string();
conn.execute(
"UPDATE subconscious_escalations SET status = ?1, resolved_at = ?2 WHERE id = ?3",
rusqlite::params![status_str, now, escalation_id],
)?;
Ok(())
}
pub fn pending_escalation_count(conn: &Connection) -> Result<u64> {
conn.query_row(
"SELECT COUNT(*) FROM subconscious_escalations WHERE status = 'pending'",
[],
|row| row.get::<_, u64>(0),
)
.map_err(Into::into)
}
pub fn get_escalation(conn: &Connection, escalation_id: &str) -> Result<Escalation> {
conn.query_row(
"SELECT id, task_id, log_id, title, description, priority, status, created_at, resolved_at
FROM subconscious_escalations WHERE id = ?1",
[escalation_id],
row_to_escalation,
)
.with_context(|| format!("escalation not found: {escalation_id}"))
}
// ── Seed default system tasks ────────────────────────────────────────────────
/// Default system tasks that are always seeded and cannot be deleted.
const DEFAULT_SYSTEM_TASKS: &[&str] = &[
"Check connected skills for errors or disconnections",
"Review new memory updates for actionable items",
"Monitor system health (Ollama, memory, connections)",
];
/// Seed default system tasks into SQLite.
/// Skips tasks whose title already exists. Returns the count of newly created tasks.
pub fn seed_default_tasks(conn: &Connection) -> Result<usize> {
let mut count = 0;
for title in DEFAULT_SYSTEM_TASKS {
if !task_title_exists(conn, title)? {
add_task(conn, title, TaskSource::System, TaskRecurrence::Pending)?;
count += 1;
}
}
Ok(count)
}
fn task_title_exists(conn: &Connection, title: &str) -> Result<bool> {
Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM subconscious_tasks WHERE title = ?1)",
[title],
|row| row.get(0),
)?)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn row_to_task(row: &rusqlite::Row) -> rusqlite::Result<SubconsciousTask> {
let source_str: String = row.get(2)?;
let recurrence_str: String = row.get(3)?;
Ok(SubconsciousTask {
id: row.get(0)?,
title: row.get(1)?,
source: string_to_source(&source_str),
recurrence: string_to_recurrence(&recurrence_str),
enabled: row.get::<_, bool>(4)?,
last_run_at: row.get(5)?,
next_run_at: row.get(6)?,
completed: row.get::<_, bool>(7)?,
created_at: row.get(8)?,
})
}
fn row_to_escalation(row: &rusqlite::Row) -> rusqlite::Result<Escalation> {
let priority_str: String = row.get(5)?;
let status_str: String = row.get(6)?;
Ok(Escalation {
id: row.get(0)?,
task_id: row.get(1)?,
log_id: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
priority: string_to_priority(&priority_str),
status: string_to_status(&status_str),
created_at: row.get(7)?,
resolved_at: row.get(8)?,
})
}
fn recurrence_to_string(r: &TaskRecurrence) -> String {
match r {
TaskRecurrence::Once => "once".to_string(),
TaskRecurrence::Cron(expr) => format!("cron:{expr}"),
TaskRecurrence::Pending => "pending".to_string(),
}
}
fn string_to_recurrence(s: &str) -> TaskRecurrence {
if s == "once" {
TaskRecurrence::Once
} else if let Some(expr) = s.strip_prefix("cron:") {
TaskRecurrence::Cron(expr.to_string())
} else {
TaskRecurrence::Pending
}
}
fn string_to_source(s: &str) -> TaskSource {
match s {
"system" => TaskSource::System,
_ => TaskSource::User,
}
}
fn string_to_priority(s: &str) -> EscalationPriority {
match s {
"critical" => EscalationPriority::Critical,
"important" => EscalationPriority::Important,
_ => EscalationPriority::Normal,
}
}
fn string_to_status(s: &str) -> EscalationStatus {
match s {
"approved" => EscalationStatus::Approved,
"dismissed" => EscalationStatus::Dismissed,
_ => EscalationStatus::Pending,
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
// ── Engine state KV ──────────────────────────────────────────────────────────
/// SQLite key for the most recent successful tick, in unix seconds.
/// Loaded by [`SubconsciousEngine::from_heartbeat_config`] on init and
/// updated after every successful tick. See `subconscious_state` table
/// docstring in [`SCHEMA_DDL`] for the dedupe rationale.
const STATE_KEY_LAST_TICK_AT: &str = "last_tick_at";
/// Read the persisted `last_tick_at` from `subconscious_state`. Returns
/// `0.0` when the row is absent (cold start or fresh workspace) so the
/// caller can treat "never ticked" identically to "first run".
pub fn get_last_tick_at(conn: &Connection) -> Result<f64> {
let value: Option<f64> = conn
.query_row(
@@ -746,9 +187,6 @@ pub fn get_last_tick_at(conn: &Connection) -> Result<f64> {
Ok(value.unwrap_or(0.0))
}
/// Persist `last_tick_at` so the next process restart picks up where
/// this run left off. Upsert via `INSERT OR REPLACE` — the table is one
/// row per key, so collisions are the expected case.
pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO subconscious_state (key, value) VALUES (?1, ?2)",
@@ -757,22 +195,11 @@ pub fn set_last_tick_at(conn: &Connection, value: f64) -> Result<()> {
Ok(())
}
/// Compute the next run time for a cron expression.
/// Normalizes 5-field cron to 6-field (prepends seconds=0) for the `cron` crate.
pub fn compute_next_run(cron_expr: &str) -> Option<f64> {
let normalized = normalize_cron_expr(cron_expr);
let schedule = normalized.parse::<cron::Schedule>().ok()?;
let next = schedule.upcoming(chrono::Utc).next()?;
Some(next.timestamp() as f64)
}
fn normalize_cron_expr(expr: &str) -> String {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() == 5 {
format!("0 {expr}")
} else {
expr.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)]
+15 -320
View File
@@ -7,335 +7,30 @@ fn test_conn() -> Connection {
}
#[test]
fn crud_tasks() {
fn last_tick_at_round_trip() {
let conn = test_conn();
let task = add_task(&conn, "Check email", TaskSource::User, TaskRecurrence::Once).unwrap();
assert_eq!(task.title, "Check email");
assert!(!task.completed);
let fetched = get_task(&conn, &task.id).unwrap();
assert_eq!(fetched.title, "Check email");
let all = list_tasks(&conn, false).unwrap();
assert_eq!(all.len(), 1);
update_task(
&conn,
&task.id,
&TaskPatch {
title: Some("Check Gmail".into()),
..Default::default()
},
)
.unwrap();
let updated = get_task(&conn, &task.id).unwrap();
assert_eq!(updated.title, "Check Gmail");
mark_task_completed(&conn, &task.id).unwrap();
let done = get_task(&conn, &task.id).unwrap();
assert!(done.completed);
remove_task(&conn, &task.id).unwrap();
assert!(get_task(&conn, &task.id).is_err());
assert_eq!(get_last_tick_at(&conn).unwrap(), 0.0);
set_last_tick_at(&conn, 12345.678).unwrap();
assert_eq!(get_last_tick_at(&conn).unwrap(), 12345.678);
}
#[test]
fn due_tasks_filters_correctly() {
fn last_tick_at_upsert() {
let conn = test_conn();
let now = now_secs();
// Task with no next_run_at — should be due
add_task(
&conn,
"No schedule",
TaskSource::User,
TaskRecurrence::Pending,
)
.unwrap();
// Task with future next_run_at — should NOT be due
let future_task =
add_task(&conn, "Future task", TaskSource::User, TaskRecurrence::Once).unwrap();
update_task_run_times(&conn, &future_task.id, now, Some(now + 3600.0)).unwrap();
// Task with past next_run_at — should be due
let past_task = add_task(&conn, "Past due", TaskSource::User, TaskRecurrence::Once).unwrap();
update_task_run_times(&conn, &past_task.id, now - 7200.0, Some(now - 3600.0)).unwrap();
let due = due_tasks(&conn, now).unwrap();
assert_eq!(due.len(), 2); // "No schedule" + "Past due"
assert!(due.iter().any(|t| t.title == "No schedule"));
assert!(due.iter().any(|t| t.title == "Past due"));
assert!(!due.iter().any(|t| t.title == "Future task"));
set_last_tick_at(&conn, 1.0).unwrap();
set_last_tick_at(&conn, 2.0).unwrap();
assert_eq!(get_last_tick_at(&conn).unwrap(), 2.0);
}
#[test]
fn crud_log_entries() {
fn schema_ddl_creates_tables() {
let conn = test_conn();
let task = add_task(&conn, "Test", TaskSource::User, TaskRecurrence::Once).unwrap();
let now = now_secs();
let entry = add_log_entry(
&conn,
&task.id,
now,
"act",
Some("Did the thing"),
Some(150),
)
.unwrap();
assert_eq!(entry.decision, "act");
let entries = list_log_entries(&conn, Some(&task.id), 10).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].result.as_deref(), Some("Did the thing"));
let all_entries = list_log_entries(&conn, None, 10).unwrap();
assert_eq!(all_entries.len(), 1);
}
#[test]
fn crud_escalations() {
let conn = test_conn();
let task = add_task(&conn, "Test", TaskSource::User, TaskRecurrence::Once).unwrap();
let esc = add_escalation(
&conn,
&task.id,
None,
"Deadline moved",
"The API deadline was moved to tomorrow",
&EscalationPriority::Critical,
)
.unwrap();
assert_eq!(esc.status, EscalationStatus::Pending);
let pending = list_escalations(&conn, Some(&EscalationStatus::Pending)).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending_escalation_count(&conn).unwrap(), 1);
resolve_escalation(&conn, &esc.id, &EscalationStatus::Approved).unwrap();
let resolved = get_escalation(&conn, &esc.id).unwrap();
assert_eq!(resolved.status, EscalationStatus::Approved);
assert!(resolved.resolved_at.is_some());
assert_eq!(pending_escalation_count(&conn).unwrap(), 0);
}
#[test]
fn seed_default_tasks_creates_system_tasks() {
let conn = test_conn();
let count = seed_default_tasks(&conn).unwrap();
assert_eq!(count, DEFAULT_SYSTEM_TASKS.len());
// Second seed should not duplicate
let count2 = seed_default_tasks(&conn).unwrap();
assert_eq!(count2, 0);
let tasks = list_tasks(&conn, false).unwrap();
assert_eq!(tasks.len(), DEFAULT_SYSTEM_TASKS.len());
assert!(tasks.iter().all(|t| t.source == TaskSource::System));
}
#[test]
fn recurrence_roundtrip() {
assert_eq!(
string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Once)),
TaskRecurrence::Once
);
assert_eq!(
string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Pending)),
TaskRecurrence::Pending
);
assert_eq!(
string_to_recurrence(&recurrence_to_string(&TaskRecurrence::Cron(
"0 8 * * *".into()
))),
TaskRecurrence::Cron("0 8 * * *".into())
);
}
// ── DDL resilience: classifier + retry happy path ──────────────
//
// These guards back Sentry TAURI-RUST-A: the production failure is
// `Connection::open` + `execute_batch(SCHEMA_DDL)` racing against
// another in-process connection that holds the write lock. With
// `BUSY_TIMEOUT` set and the application-level retry loop in place,
// the race resolves on its own; without them the first attempt
// returns `SQLITE_BUSY` and the user sees "failed to run subconscious
// schema DDL" in Sentry with no further context.
#[test]
fn is_sqlite_busy_matches_database_busy_code() {
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseBusy,
extended_code: 5, // SQLITE_BUSY
},
Some("database is locked".into()),
);
let err = anyhow::Error::from(raw);
assert!(is_sqlite_busy(&err));
}
#[test]
fn is_sqlite_busy_matches_database_locked_code() {
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseLocked,
extended_code: 6, // SQLITE_LOCKED
},
Some("database table is locked".into()),
);
let err = anyhow::Error::from(raw);
assert!(is_sqlite_busy(&err));
}
#[test]
fn is_sqlite_busy_does_not_match_constraint_violation() {
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::ConstraintViolation,
extended_code: 19,
},
Some("UNIQUE constraint failed".into()),
);
let err = anyhow::Error::from(raw);
assert!(!is_sqlite_busy(&err));
}
#[test]
fn is_sqlite_busy_does_not_match_schema_syntax_error() {
// A genuine bug in `SCHEMA_DDL` (e.g. typo in CREATE TABLE) would
// surface as a `SqliteFailure(Unknown, ...)` with "syntax error"
// in the message — retrying just delays the same failure, so the
// classifier must reject it. Use Unknown + non-busy message.
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::Unknown,
extended_code: 1,
},
Some("near \"FOO\": syntax error".into()),
);
let err = anyhow::Error::from(raw);
assert!(!is_sqlite_busy(&err));
}
#[test]
fn is_sqlite_busy_matches_through_context_layers() {
// The production failure shape: a rusqlite error wrapped under
// `with_context("failed to run subconscious schema DDL")` —
// exactly what `open_and_initialize` produces. Downcast must
// still find the rusqlite root.
let raw = rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code: rusqlite::ErrorCode::DatabaseBusy,
extended_code: 5,
},
Some("database is locked".into()),
);
let wrapped: anyhow::Result<()> = Err(anyhow::Error::from(raw))
.with_context(|| "failed to run subconscious schema DDL".to_string());
let err = wrapped.unwrap_err();
assert!(is_sqlite_busy(&err));
}
#[test]
fn is_sqlite_busy_text_fallback_when_downcast_misses() {
// If a future refactor stringifies the rusqlite error before
// wrapping (e.g. via anyhow!("{e}")), the downcast misses but
// the chain-formatter text still preserves "database is locked".
let err = anyhow::anyhow!("failed to run subconscious schema DDL: database is locked");
assert!(is_sqlite_busy(&err));
}
#[test]
fn with_connection_resolves_external_write_contention() {
use std::sync::mpsc;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().to_path_buf();
// First call: prime the DB so the file exists and the schema is
// initialized. Subsequent calls take the fast path.
with_connection(&workspace, |conn| {
add_task(conn, "primer", TaskSource::User, TaskRecurrence::Once)?;
Ok(())
})
.expect("prime DB");
// Hold an EXCLUSIVE write lock for ~250 ms in a side thread.
// The DDL loop in `open_and_initialize` re-runs PRAGMA journal_mode
// and CREATE TABLE IF NOT EXISTS — both are no-ops on an already
// initialized DB but still acquire the write lock briefly, which
// races against the held lock. The application-level retry
// (100 / 300 / 900 ms) plus the 5 s `busy_timeout` must absorb
// this and let the second `with_connection` succeed.
let db_path = workspace.join("subconscious").join("subconscious.db");
let (lock_ready_tx, lock_ready_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let blocker = std::thread::spawn(move || {
let conn = rusqlite::Connection::open(&db_path).expect("open blocker conn");
conn.busy_timeout(std::time::Duration::from_millis(100))
.expect("blocker busy_timeout");
let tx = conn
.unchecked_transaction()
.expect("begin blocker transaction");
// Force write-lock acquisition immediately.
tx.execute(
"INSERT INTO subconscious_tasks \
(id, title, source, recurrence, created_at) \
VALUES ('blocker', 'blocker', 'user', 'pending', 0.0)",
let count: i32 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'subconscious_%'",
[],
|row| row.get(0),
)
.expect("blocker insert");
lock_ready_tx.send(()).expect("signal lock acquired");
// Wait for the main thread to start contending, then a touch
// longer so the first one or two retries collide.
release_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("release signal");
std::thread::sleep(std::time::Duration::from_millis(50));
tx.rollback().expect("rollback blocker txn");
});
// Wait until the blocker actually holds the write lock.
lock_ready_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("blocker never acquired lock");
// Contender: should retry through the busy window and succeed
// once the blocker rolls back. We release the blocker after
// ~250 ms so the second / third retry attempt lands in the
// unlocked window.
let release_tx_for_timer = release_tx.clone();
let timer = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(250));
let _ = release_tx_for_timer.send(());
});
let result = with_connection(&workspace, |conn| {
// Confirm we can issue a real query through the contended
// connection — proves the open + DDL completed cleanly.
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM subconscious_tasks", [], |row| {
row.get(0)
})
.unwrap_or(-1);
Ok(count)
});
timer.join().expect("timer thread panicked");
blocker.join().expect("blocker thread panicked");
let count = result.expect("contended with_connection must succeed via retry");
// Primer row is "primer"; blocker's INSERT was rolled back, so
// the count should be exactly 1.
assert_eq!(
count, 1,
"expected only the primer row after blocker rollback, got {count}"
);
.unwrap();
assert!(count >= 4);
}
+4 -149
View File
@@ -1,161 +1,17 @@
//! Type definitions for the subconscious task execution system.
//! Type definitions for the subconscious agent loop.
use serde::{Deserialize, Serialize};
// ── Task types ───────────────────────────────────────────────────────────────
/// A task managed by the subconscious engine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubconsciousTask {
pub id: String,
pub title: String,
pub source: TaskSource,
pub recurrence: TaskRecurrence,
pub enabled: bool,
pub last_run_at: Option<f64>,
pub next_run_at: Option<f64>,
pub completed: bool,
pub created_at: f64,
}
/// Where the task came from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskSource {
/// Auto-populated by the system (skills health, Ollama status, etc.)
System,
/// Added by the user via UI or agent.
User,
}
/// How often the task should run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskRecurrence {
/// Execute once, then mark completed.
Once,
/// Recurrent on a cron schedule (5-field expression).
Cron(String),
/// Not yet classified — agent will decide on first tick.
Pending,
}
/// Partial update for a task.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskPatch {
pub title: Option<String>,
pub recurrence: Option<TaskRecurrence>,
pub enabled: Option<bool>,
}
// ── Tick evaluation types ────────────────────────────────────────────────────
/// Per-tick decision for a single task.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TickDecision {
/// Nothing relevant in current state for this task.
#[default]
Noop,
/// State has something relevant — execute the task.
Act,
/// Ambiguous or risky — surface to user for approval.
Escalate,
}
/// The local model's evaluation of a single task against the current state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskEvaluation {
pub task_id: String,
pub decision: TickDecision,
pub reason: String,
}
/// Full evaluation response from the per-tick LLM.
///
/// `evaluations` covers the task-bound layer (act/escalate/noop per
/// existing task). `reflections` (#623) covers the proactive layer —
/// LLM-emitted observations grounded in memory-tree signals. The two
/// are independent: a tick may produce only one, the other, or both.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvaluationResponse {
pub evaluations: Vec<TaskEvaluation>,
/// Proactive-layer reflections (#623). Defaults to empty so older
/// LLM payloads remain forward-compatible.
#[serde(default)]
pub reflections: Vec<super::reflection::ReflectionDraft>,
}
// ── Execution types ──────────────────────────────────────────────────────────
/// Result of executing a single task.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionResult {
pub output: String,
pub used_tools: bool,
pub duration_ms: u64,
}
// ── Log types ────────────────────────────────────────────────────────────────
/// A single entry in the execution log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubconsciousLogEntry {
pub id: String,
pub task_id: String,
pub tick_at: f64,
pub decision: String,
pub result: Option<String>,
pub duration_ms: Option<i64>,
pub created_at: f64,
}
// ── Escalation types ─────────────────────────────────────────────────────────
/// An escalation waiting for user input.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Escalation {
pub id: String,
pub task_id: String,
pub log_id: Option<String>,
pub title: String,
pub description: String,
pub priority: EscalationPriority,
pub status: EscalationStatus,
pub created_at: f64,
pub resolved_at: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EscalationPriority {
Critical,
Important,
Normal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EscalationStatus {
Pending,
Approved,
Dismissed,
}
// ── Status types ─────────────────────────────────────────────────────────────
/// Summary of the subconscious engine status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubconsciousStatus {
pub enabled: bool,
pub mode: String,
pub provider_available: bool,
pub provider_unavailable_reason: Option<String>,
pub interval_minutes: u32,
pub last_tick_at: Option<f64>,
pub total_ticks: u64,
pub task_count: u64,
pub pending_escalations: u64,
/// Number of consecutive tick failures (resets on success).
pub consecutive_failures: u64,
}
@@ -163,8 +19,7 @@ pub struct SubconsciousStatus {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TickResult {
pub tick_at: f64,
pub evaluations: Vec<TaskEvaluation>,
pub executed: usize,
pub escalated: usize,
pub thoughts_count: usize,
pub thread_id: Option<String>,
pub duration_ms: u64,
}
+26 -188
View File
@@ -7,7 +7,6 @@ use std::sync::Arc;
use serde_json::json;
/// Test config for the heuristic-only ingestion pipeline.
fn ci_safe_ingestion_config() -> openhuman_core::openhuman::memory::MemoryIngestionConfig {
openhuman_core::openhuman::memory::MemoryIngestionConfig::default()
}
@@ -42,96 +41,36 @@ async fn ingest_doc(
result.document_id
}
/// Full two-tick E2E test:
///
/// **Tick 1**: Gmail has 3 urgent emails, Notion has a deadline tracker.
/// → Ollama should detect urgent items → act or escalate.
///
/// **Tick 2**: New data — deadline moved, ownership changed.
/// → Ollama should detect the change → act or escalate on new state.
///
/// Verifies:
/// - Tasks loaded from HEARTBEAT.md seed
/// - Real Ollama evaluation produces valid decisions
/// - SQLite log entries created for each tick
/// - Act tasks produce text output from Ollama
/// - Second tick sees delta (new data only)
/// Two-tick E2E test — verifies the agent-per-tick model produces
/// thoughts from ingested memory data.
#[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;
// ── Setup workspace ──────────────────────────────────────────────
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path();
// Write HEARTBEAT.md
std::fs::write(
workspace.join("HEARTBEAT.md"),
"\
# Periodic Tasks
- Check Gmail for urgent emails that need immediate attention
- Review Notion project tracker for deadline changes
- Monitor connected skills for errors or disconnections
",
)
.expect("write heartbeat");
// Initialize memory
let memory = UnifiedMemory::new(workspace, Arc::new(NoopEmbedding), None).expect("init memory");
let memory_client =
MemoryClient::from_workspace_dir(workspace.to_path_buf()).expect("memory client");
// ── Tick 1: Ingest initial data ──────────────────────────────────
println!("\n============================================================");
println!(" TICK 1: Initial state — urgent emails + project tracker");
println!("============================================================\n");
// Ingest test data
ingest_doc(
&memory,
"skill-gmail",
"urgent-emails-batch1",
"3 urgent emails in inbox",
"\
Email 1: From alice@partner.com — Subject: URGENT: API contract deadline
Body: The API integration deadline has been moved from Friday to tomorrow (Thursday).
Please confirm you can deliver by end of day. This is blocking the partner launch.
Email 2: From boss@company.com — Subject: Re: Q1 Budget Review
Body: Need your updated numbers by 3pm today. The board meeting is tomorrow morning.
Please prioritize this over other tasks.
Email 3: From security@company.com — Subject: [ACTION REQUIRED] Password expiry
Body: Your corporate password expires in 24 hours. Please update it via the portal
to avoid being locked out of all systems.",
"Email 1: From alice@partner.com — Subject: URGENT: API contract deadline\n\
Body: The API integration deadline has been moved to tomorrow.\n\n\
Email 2: From boss@company.com — Subject: Re: Q1 Budget Review\n\
Body: Need your updated numbers by 3pm today.",
)
.await;
ingest_doc(
&memory,
"skill-notion",
"q1-tracker-v1",
"Q1 Delivery Tracker",
"\
Project: API Integration
Status: In Progress
Deadline: April 5 (Friday)
Owner: You
Dependencies: Partner API docs (received), Internal review (pending)
Notes: On track for Friday delivery. Partner team confirmed their side is ready.
Project: Q1 Budget Report
Status: Draft
Deadline: Today 3pm
Owner: You
Notes: Numbers need updating. Finance sent corrections yesterday.",
)
.await;
// Build engine with real config
let mut config = openhuman_core::openhuman::config::Config::default();
config.workspace_dir = workspace.to_path_buf();
config.heartbeat.enabled = true;
@@ -146,143 +85,42 @@ Project: Q1 Budget Report
Some(Arc::new(memory_client)),
);
// Run tick 1
// Tick 1
println!("\n=== TICK 1 ===");
let result1 = engine.tick().await.expect("tick 1 should succeed");
println!("\n--- Tick 1 Results ---");
println!(" Duration: {}ms", result1.duration_ms);
println!(" Evaluations: {}", result1.evaluations.len());
println!(" Executed: {}", result1.executed);
println!(" Escalated: {}", result1.escalated);
for eval in &result1.evaluations {
println!(" [{}] {:?}{}", eval.task_id, eval.decision, eval.reason);
}
println!(" Thoughts: {}", result1.thoughts_count);
println!(" Thread: {:?}", result1.thread_id);
// Verify tick 1
assert!(
!result1.evaluations.is_empty(),
"Ollama should produce evaluations for seeded tasks"
);
// Check SQLite log
let log1 = store::with_connection(workspace, |conn| store::list_log_entries(conn, None, 50))
.expect("list log");
println!("\n Log entries after tick 1: {}", log1.len());
for entry in &log1 {
println!(
" [{}] {}{}",
entry.task_id,
entry.decision,
entry.result.as_deref().unwrap_or("(none)")
);
}
assert!(!log1.is_empty(), "Should have log entries after tick 1");
// Check tasks were seeded
let tasks = store::with_connection(workspace, |conn| store::list_tasks(conn, false))
.expect("list tasks");
println!("\n Tasks: {}", tasks.len());
for t in &tasks {
println!(
" [{}] {} (source={:?}, completed={})",
t.id, t.title, t.source, t.completed
);
}
assert_eq!(tasks.len(), 3, "Should have 3 tasks from HEARTBEAT.md");
// ── Tick 2: Ingest NEW data (state change) ──────────────────────
println!("\n============================================================");
println!(" TICK 2: State change — deadline moved, new urgent email");
println!("============================================================\n");
// 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());
// Tick 2 with new data
println!("\n=== TICK 2 ===");
ingest_doc(
&memory,
"skill-gmail",
"urgent-deadline-moved",
"CRITICAL: API deadline moved to TOMORROW",
"\
Email from alice@partner.com — Subject: RE: URGENT: API contract deadline
Body: Update — the deadline has been moved UP to tomorrow (Wednesday) not Thursday.
The partner CEO is flying in Wednesday evening and wants a demo.
This is now the #1 priority. Please drop everything else.
Email from boss@company.com — Subject: Re: Re: Q1 Budget Review
Body: Good news — finance approved a 1-day extension on the budget report.
New deadline is Friday. Focus on the API delivery instead.",
"Email from alice@partner.com — Subject: RE: URGENT\n\
Body: The deadline has been moved UP to tomorrow. This is now #1 priority.",
)
.await;
ingest_doc(
&memory,
"skill-notion",
"q1-tracker-v2",
"Q1 Delivery Tracker (UPDATED)",
"\
Project: API Integration
Status: AT RISK
Deadline: TOMORROW (Wednesday) — moved up from Friday
Owner: You
Dependencies: Partner API docs (received), Internal review (BLOCKING — not started)
Notes: CRITICAL — deadline moved up 2 days. Internal review not started.
Partner CEO demo Wednesday evening. Need to start review NOW.
Blockers: Internal review team not yet assigned.
Project: Q1 Budget Report
Status: Draft
Deadline: Friday (extended from today)
Owner: You
Notes: Extension granted. Lower priority now.",
)
.await;
// Run tick 2
let result2 = engine.tick().await.expect("tick 2 should succeed");
println!("\n--- Tick 2 Results ---");
println!(" Duration: {}ms", result2.duration_ms);
println!(" Evaluations: {}", result2.evaluations.len());
println!(" Executed: {}", result2.executed);
println!(" Escalated: {}", result2.escalated);
for eval in &result2.evaluations {
println!(" [{}] {:?}{}", eval.task_id, eval.decision, eval.reason);
}
println!(" Thoughts: {}", result2.thoughts_count);
println!(" Thread: {:?}", result2.thread_id);
// Verify tick 2
assert!(
!result2.evaluations.is_empty(),
"Ollama should produce evaluations for tick 2"
);
// Check cumulative log
let log2 = store::with_connection(workspace, |conn| store::list_log_entries(conn, None, 50))
.expect("list log");
println!("\n Total log entries after tick 2: {}", log2.len());
assert!(
log2.len() > log1.len(),
"Tick 2 should add more log entries"
);
// Check for any escalations
let escalations = store::with_connection(workspace, |conn| store::list_escalations(conn, None))
.expect("list escalations");
println!(" Escalations: {}", escalations.len());
for esc in &escalations {
println!(
" [{}] {}{} (status={:?})",
esc.task_id, esc.title, esc.description, esc.status
);
}
// ── Status check ─────────────────────────────────────────────────
let status = engine.status().await;
println!("\n--- Engine Status ---");
println!("\n--- Status ---");
println!(" Enabled: {}", status.enabled);
println!(" Total ticks: {}", status.total_ticks);
println!(" Task count: {}", status.task_count);
println!(" Pending escalations: {}", status.pending_escalations);
assert_eq!(status.total_ticks, 2);
println!("\n============================================================");
println!(" E2E TEST PASSED");
println!("============================================================\n");
println!("\n=== E2E PASSED ===\n");
}