/** * useAmbient — background thinking loop with live countdown state. * * Inspired by agi-diy/docs/vision.js AmbientMode class. * Emits `careless:ambient-fire` when ready for the app to send a prompt. * * States: idle (waiting user stop) → cooldown → ready → running → done */ import { useEffect, useRef, useState, useCallback } from 'react' import { fireBusEvent } from '../lib/event-bus' import type { AgentStatus } from '../types/index' export type AmbientState = 'off' | 'idle' | 'cooldown' | 'waiting' | 'running' | 'ready' | 'done' export type AmbientMode = 'standard' | 'autonomous' export interface AmbientStatus { state: AmbientState mode: AmbientMode iteration: number maxIterations: number secondsRemaining: number } interface Opts { enabled: boolean autonomous?: boolean idleThresholdMs?: number cooldownMs?: number maxIterations?: number agentStatus: AgentStatus lastQuery?: string | null /** Last assistant response text — checked for [AMBIENT_DONE] completion signal. */ lastResponse?: string | null onFire: (prompt: string, iteration: number) => Promise | void /** Called when the agent signals completion with [AMBIENT_DONE] or similar. */ onComplete?: () => void } /** Magic phrases the agent can use to signal autonomous completion. */ const COMPLETION_SIGNALS = [ '[AMBIENT_DONE]', '[TASK_COMPLETE]', '[NOTHING_MORE_TO_DO]', 'i\'ve completed my exploration', 'nothing more to explore', 'task is complete', ] function hasCompletionSignal(text: string | null | undefined): boolean { if (!text) return false const lower = text.toLowerCase() return COMPLETION_SIGNALS.some(sig => lower.includes(sig.toLowerCase())) } export function useAmbient(opts: Opts): AmbientStatus & { reset: () => void; trigger: () => void } { const { enabled, autonomous = false, agentStatus, lastQuery, lastResponse, onFire, onComplete } = opts const idleThresholdMs = opts.idleThresholdMs ?? (autonomous ? 0 : 30_000) const cooldownMs = opts.cooldownMs ?? (autonomous ? 8_000 : 60_000) const maxIterations = opts.maxIterations ?? (autonomous ? 100 : 3) const [state, setState] = useState(enabled ? 'idle' : 'off') const [iteration, setIteration] = useState(0) const [secondsRemaining, setSecondsRemaining] = useState(0) const lastInteractionRef = useRef(Date.now()) const lastFireRef = useRef(0) const firingRef = useRef(false) // Record activity whenever user types or agent is busy useEffect(() => { if (agentStatus === 'streaming' || agentStatus === 'thinking') { lastInteractionRef.current = Date.now() } }, [agentStatus]) useEffect(() => { const handler = () => { lastInteractionRef.current = Date.now() } window.addEventListener('keydown', handler) window.addEventListener('mousedown', handler) window.addEventListener('touchstart', handler) return () => { window.removeEventListener('keydown', handler) window.removeEventListener('mousedown', handler) window.removeEventListener('touchstart', handler) } }, []) // Detect AMBIENT_DONE / completion signal in agent responses useEffect(() => { if (!autonomous || !lastResponse) return if (hasCompletionSignal(lastResponse)) { console.log('[ambient] completion signal detected — stopping autonomous loop') setState('done') setIteration(i => Math.max(i, maxIterations)) // prevent re-fire try { onComplete?.() } catch {} try { window.dispatchEvent(new CustomEvent('careless:ambient-complete', { detail: { iteration } })) } catch {} fireBusEvent({ source: 'ambient', kind: 'completed', summary: `Autonomous loop complete at iter ${iteration}` }) } }, [autonomous, lastResponse, maxIterations, onComplete, iteration]) const fire = useCallback(async () => { if (firingRef.current) return firingRef.current = true setState('running') const nextIter = iteration + 1 fireBusEvent({ source: 'ambient', kind: 'fired', summary: `${autonomous ? 'autonomous' : 'ambient'} iter ${nextIter}/${maxIterations}` }) const prompt = autonomous ? (iteration === 0 && lastQuery ? `[AUTONOMOUS MODE] Continue: "${lastQuery.slice(0, 300)}". Take action. When truly done, include [AMBIENT_DONE].` : `[AUTONOMOUS iteration ${nextIter}] Continue working. [AMBIENT_DONE] when done.`) : (lastQuery ? `[AMBIENT] Reflect on "${lastQuery.slice(0, 200)}". Go deeper, find improvements.` : `[AMBIENT] Think about what to do next based on current context.`) try { await onFire(prompt, nextIter) } catch (e) { console.error('[ambient] fire error', e) } finally { firingRef.current = false lastFireRef.current = Date.now() setIteration(nextIter) if (nextIter >= maxIterations) setState('done') } }, [iteration, autonomous, lastQuery, onFire, maxIterations]) useEffect(() => { if (!enabled) { setState('off'); return } if (state === 'done') return const interval = setInterval(() => { if (agentStatus === 'streaming' || agentStatus === 'thinking') { setState('waiting') setSecondsRemaining(0) return } const idleFor = Date.now() - lastInteractionRef.current const sinceFire = Date.now() - lastFireRef.current if (iteration >= maxIterations) { setState('done'); return } if (!autonomous && idleFor < idleThresholdMs) { setState('idle') setSecondsRemaining(Math.ceil((idleThresholdMs - idleFor) / 1000)) return } if (sinceFire < cooldownMs) { setState('cooldown') setSecondsRemaining(Math.ceil((cooldownMs - sinceFire) / 1000)) return } setState('ready') setSecondsRemaining(0) fire() }, 500) return () => clearInterval(interval) }, [enabled, state, agentStatus, iteration, idleThresholdMs, cooldownMs, maxIterations, autonomous, fire]) const reset = useCallback(() => { setIteration(0) lastFireRef.current = 0 lastInteractionRef.current = Date.now() setState(enabled ? 'idle' : 'off') }, [enabled]) const trigger = useCallback(() => { fire() }, [fire]) return { state, mode: autonomous ? 'autonomous' : 'standard', iteration, maxIterations, secondsRemaining, reset, trigger, } }