@makefinks/daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,163 @@
1
+ import { useCallback } from "react";
2
+ import { toast } from "@opentui-ui/toast/react";
3
+ import { getDaemonManager } from "../state/daemon-state";
4
+ import {
5
+ buildModelHistoryFromConversation,
6
+ deleteSession,
7
+ loadSessionSnapshot,
8
+ saveSessionSnapshot,
9
+ } from "../state/session-store";
10
+ import type { ConversationMessage, SessionInfo, TokenUsage } from "../types";
11
+
12
+ export interface UseConversationManagerParams {
13
+ conversationHistory: ConversationMessage[];
14
+ sessionUsage: TokenUsage;
15
+ currentSessionId: string | null;
16
+ ensureSessionId: () => Promise<string>;
17
+ setCurrentSessionIdSafe: (sessionId: string | null) => void;
18
+ currentSessionIdRef: React.RefObject<string | null>;
19
+ setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
20
+
21
+ hydrateConversationHistory: (history: ConversationMessage[]) => void;
22
+ setCurrentTranscription: React.Dispatch<React.SetStateAction<string>>;
23
+ setCurrentResponse: React.Dispatch<React.SetStateAction<string>>;
24
+ clearCurrentContentBlocks: () => void;
25
+ clearReasoningState: () => void;
26
+ resetSessionUsage: () => void;
27
+ setSessionUsage: React.Dispatch<React.SetStateAction<TokenUsage>>;
28
+ currentUserInputRef: React.RefObject<string>;
29
+ }
30
+
31
+ export interface UseConversationManagerReturn {
32
+ clearConversationState: () => void;
33
+ loadSessionById: (sessionId: string) => Promise<void>;
34
+ startNewSession: () => void;
35
+ undoLastTurn: () => void;
36
+ }
37
+
38
+ export function useConversationManager(params: UseConversationManagerParams): UseConversationManagerReturn {
39
+ const {
40
+ conversationHistory,
41
+ sessionUsage,
42
+ currentSessionId,
43
+ ensureSessionId,
44
+ setCurrentSessionIdSafe,
45
+ currentSessionIdRef,
46
+ setSessions,
47
+
48
+ hydrateConversationHistory,
49
+ setCurrentTranscription,
50
+ setCurrentResponse,
51
+ clearCurrentContentBlocks,
52
+ clearReasoningState,
53
+ resetSessionUsage,
54
+ setSessionUsage,
55
+ currentUserInputRef,
56
+ } = params;
57
+
58
+ const manager = getDaemonManager();
59
+
60
+ const clearConversationState = useCallback(() => {
61
+ manager.clearHistory();
62
+ manager.setConversationHistory([]);
63
+ hydrateConversationHistory([]);
64
+ setCurrentTranscription("");
65
+ setCurrentResponse("");
66
+ clearCurrentContentBlocks();
67
+ clearReasoningState();
68
+ resetSessionUsage();
69
+ currentUserInputRef.current = "";
70
+ }, [
71
+ manager,
72
+ hydrateConversationHistory,
73
+ setCurrentTranscription,
74
+ setCurrentResponse,
75
+ clearCurrentContentBlocks,
76
+ clearReasoningState,
77
+ resetSessionUsage,
78
+ currentUserInputRef,
79
+ ]);
80
+
81
+ const loadSessionById = useCallback(
82
+ async (sessionId: string) => {
83
+ const snapshot = await loadSessionSnapshot(sessionId);
84
+ clearConversationState();
85
+ if (snapshot) {
86
+ hydrateConversationHistory(snapshot.conversationHistory);
87
+ setSessionUsage(snapshot.sessionUsage);
88
+ manager.setConversationHistory(buildModelHistoryFromConversation(snapshot.conversationHistory));
89
+ }
90
+ setCurrentSessionIdSafe(sessionId);
91
+ },
92
+ [clearConversationState, hydrateConversationHistory, setSessionUsage, manager, setCurrentSessionIdSafe]
93
+ );
94
+
95
+ const startNewSession = useCallback(() => {
96
+ manager.stopSpeaking();
97
+ void (async () => {
98
+ if (conversationHistory.length > 0) {
99
+ const targetSessionId = currentSessionId ?? (await ensureSessionId());
100
+ await saveSessionSnapshot(
101
+ {
102
+ conversationHistory,
103
+ sessionUsage,
104
+ },
105
+ targetSessionId
106
+ );
107
+ }
108
+
109
+ clearConversationState();
110
+ setCurrentSessionIdSafe(null);
111
+ })();
112
+ }, [
113
+ conversationHistory,
114
+ sessionUsage,
115
+ currentSessionId,
116
+ ensureSessionId,
117
+ clearConversationState,
118
+ setCurrentSessionIdSafe,
119
+ ]);
120
+
121
+ const undoLastTurn = useCallback(() => {
122
+ if (conversationHistory.length === 0) {
123
+ toast.info("Nothing to undo");
124
+ return;
125
+ }
126
+
127
+ const userMessageCount = conversationHistory.filter((m) => m.type === "user").length;
128
+ if (userMessageCount <= 1) {
129
+ toast.warning("Cannot delete the first message", {
130
+ description: "Start a new session to clear conversation",
131
+ });
132
+ return;
133
+ }
134
+
135
+ const lastDaemonIdx = [...conversationHistory].reverse().findIndex((m) => m.type === "daemon");
136
+ if (lastDaemonIdx === -1) {
137
+ toast.info("Nothing to undo");
138
+ return;
139
+ }
140
+
141
+ const actualIdx = conversationHistory.length - 1 - lastDaemonIdx;
142
+ const userMsgIdx =
143
+ actualIdx > 0 && conversationHistory[actualIdx - 1]?.type === "user" ? actualIdx - 1 : actualIdx;
144
+
145
+ const newHistory = conversationHistory.slice(0, userMsgIdx);
146
+ hydrateConversationHistory(newHistory);
147
+
148
+ manager.stopSpeaking();
149
+
150
+ const modelMessagesRemoved = manager.undoLastTurn();
151
+
152
+ if (modelMessagesRemoved > 0) {
153
+ toast.info("Last message deleted");
154
+ }
155
+ }, [conversationHistory, hydrateConversationHistory, manager]);
156
+
157
+ return {
158
+ clearConversationState,
159
+ loadSessionById,
160
+ startNewSession,
161
+ undoLastTurn,
162
+ };
163
+ }
@@ -0,0 +1,50 @@
1
+ import { useCallback } from "react";
2
+ import { toast } from "@opentui-ui/toast/react";
3
+ import { useRenderer } from "@opentui/react";
4
+ import { writeClipboardText } from "../utils/clipboard";
5
+
6
+ export interface UseCopyOnSelectReturn {
7
+ handleCopyOnSelectMouseUp: () => Promise<void>;
8
+ }
9
+
10
+ export function useCopyOnSelect(): UseCopyOnSelectReturn {
11
+ const renderer = useRenderer();
12
+
13
+ const handleCopyOnSelectMouseUp = useCallback(async () => {
14
+ if (process.env.DAEMON_DISABLE_COPY_ON_SELECT) {
15
+ renderer.clearSelection();
16
+ return;
17
+ }
18
+
19
+ const selection = renderer.getSelection();
20
+ if (!selection) return;
21
+
22
+ try {
23
+ const text = selection.getSelectedText();
24
+ if (!text) return;
25
+
26
+ try {
27
+ const base64 = Buffer.from(text).toString("base64");
28
+ const osc52 = `\x1b]52;c;${base64}\x07`;
29
+ const finalOsc52 = process.env.TMUX ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52;
30
+ // @ts-expect-error - OpenTUI keeps this private, but it's safe to use here.
31
+ renderer.writeOut(finalOsc52);
32
+ } catch {
33
+ // Ignore OSC52 failures (unsupported terminal, etc).
34
+ }
35
+
36
+ try {
37
+ const didCopy = await writeClipboardText(text);
38
+ if (didCopy) {
39
+ toast.info("Text copied to clipboard");
40
+ }
41
+ } catch {
42
+ // Ignore clipboard failures; OSC52 may still work.
43
+ }
44
+ } finally {
45
+ renderer.clearSelection();
46
+ }
47
+ }, [renderer]);
48
+
49
+ return { handleCopyOnSelectMouseUp };
50
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Hook for subscribing to DaemonStateManager events and managing UI state.
3
+ */
4
+
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { clearFetchCache } from "../ai/exa-fetch-cache";
7
+ import { DaemonAvatarRenderable } from "../avatar/DaemonAvatarRenderable";
8
+ import { daemonEvents } from "../state/daemon-events";
9
+ import { getDaemonManager } from "../state/daemon-state";
10
+ import { buildModelHistoryFromConversation } from "../state/session-store";
11
+ import { DaemonState } from "../types";
12
+ import type { ContentBlock, ConversationMessage, TokenUsage, ToolCall } from "../types";
13
+ import { REASONING_COLORS, STATE_COLORS } from "../types/theme";
14
+ import { REASONING_ANIMATION } from "../ui/constants";
15
+ import { type ModelMetadata, getModelMetadata } from "../utils/model-metadata";
16
+ import {
17
+ type EventHandlerDeps,
18
+ type EventHandlerRefs,
19
+ type EventHandlerSetters,
20
+ createCancelledHandler,
21
+ createCompleteHandler,
22
+ createErrorHandler,
23
+ createMicLevelHandler,
24
+ createReasoningTokenHandler,
25
+ createStateChangeHandler,
26
+ createStepUsageHandler,
27
+ createSubagentCompleteHandler,
28
+ createSubagentToolCallHandler,
29
+ createSubagentToolResultHandler,
30
+ createSubagentUsageHandler,
31
+ createTokenHandler,
32
+ createToolApprovalRequestHandler,
33
+ createToolApprovalResolvedHandler,
34
+ createToolInputStartHandler,
35
+ createToolInvocationHandler,
36
+ createToolResultHandler,
37
+ createTranscriptionHandler,
38
+ createTtsLevelHandler,
39
+ createUserMessageHandler,
40
+ } from "./daemon-event-handlers";
41
+
42
+ export interface UseDaemonEventsParams {
43
+ currentModelId: string;
44
+ preferencesLoaded: boolean;
45
+ setReasoningQueue: (queue: string | ((prev: string) => string)) => void;
46
+ setFullReasoning: (full: string | ((prev: string) => string)) => void;
47
+ clearReasoningState: () => void;
48
+ clearReasoningTicker: () => void;
49
+ fullReasoningRef: React.RefObject<string>;
50
+ sessionId: string | null;
51
+ sessionIdRef: React.RefObject<string | null>;
52
+ ensureSessionId: () => Promise<string>;
53
+ addToHistory: (input: string) => void;
54
+ onFirstMessage?: (sessionId: string, message: string) => void;
55
+ }
56
+
57
+ export interface UseDaemonEventsReturn {
58
+ daemonState: DaemonState;
59
+ conversationHistory: ConversationMessage[];
60
+ currentTranscription: string;
61
+ currentResponse: string;
62
+ currentContentBlocks: ContentBlock[];
63
+ error: string;
64
+ sessionUsage: TokenUsage;
65
+ modelMetadata: ModelMetadata | null;
66
+ avatarRef: React.RefObject<DaemonAvatarRenderable | null>;
67
+ hasStartedSpeakingRef: React.RefObject<boolean>;
68
+ currentUserInputRef: React.RefObject<string>;
69
+ setConversationHistory: React.Dispatch<React.SetStateAction<ConversationMessage[]>>;
70
+ hydrateConversationHistory: (history: ConversationMessage[]) => void;
71
+ setCurrentTranscription: React.Dispatch<React.SetStateAction<string>>;
72
+ setCurrentResponse: React.Dispatch<React.SetStateAction<string>>;
73
+ clearCurrentContentBlocks: () => void;
74
+ resetSessionUsage: () => void;
75
+ setSessionUsage: React.Dispatch<React.SetStateAction<TokenUsage>>;
76
+ applyAvatarForState: (state: DaemonState) => void;
77
+ }
78
+
79
+ export function useDaemonEvents(params: UseDaemonEventsParams): UseDaemonEventsReturn {
80
+ const {
81
+ currentModelId,
82
+ preferencesLoaded,
83
+ setReasoningQueue,
84
+ setFullReasoning,
85
+ clearReasoningState,
86
+ clearReasoningTicker,
87
+ fullReasoningRef,
88
+ sessionId,
89
+ sessionIdRef,
90
+ ensureSessionId,
91
+ addToHistory,
92
+ onFirstMessage,
93
+ } = params;
94
+
95
+ const [daemonState, setDaemonState] = useState<DaemonState>(DaemonState.IDLE);
96
+ const [conversationHistory, setConversationHistory] = useState<ConversationMessage[]>([]);
97
+ const [currentTranscription, setCurrentTranscription] = useState<string>("");
98
+ const [currentResponse, setCurrentResponse] = useState<string>("");
99
+ const [currentContentBlocks, setCurrentContentBlocks] = useState<ContentBlock[]>([]);
100
+ const [error, setError] = useState<string>("");
101
+ const initialSessionUsage: TokenUsage = {
102
+ promptTokens: 0,
103
+ completionTokens: 0,
104
+ totalTokens: 0,
105
+ subagentTotalTokens: 0,
106
+ subagentPromptTokens: 0,
107
+ subagentCompletionTokens: 0,
108
+ };
109
+ const [sessionUsage, setSessionUsage] = useState<TokenUsage>(initialSessionUsage);
110
+ const sessionUsageRef = useRef<TokenUsage>(initialSessionUsage);
111
+ const [modelMetadata, setModelMetadata] = useState<ModelMetadata | null>(null);
112
+
113
+ const resetSessionUsage = useCallback(() => {
114
+ setSessionUsage(initialSessionUsage);
115
+ }, []);
116
+
117
+ useEffect(() => {
118
+ if (!preferencesLoaded) return;
119
+ let cancelled = false;
120
+ getModelMetadata(currentModelId).then((metadata) => {
121
+ if (!cancelled) setModelMetadata(metadata);
122
+ });
123
+ return () => {
124
+ cancelled = true;
125
+ };
126
+ }, [currentModelId, preferencesLoaded]);
127
+
128
+ useEffect(() => {
129
+ clearFetchCache();
130
+ }, [sessionId]);
131
+
132
+ const avatarRef = useRef<DaemonAvatarRenderable | null>(null);
133
+ const hasStartedSpeakingRef = useRef(false);
134
+ const streamPhaseRef = useRef<"reasoning" | "text" | null>(null);
135
+ const messageIdRef = useRef(0);
136
+ const currentUserInputRef = useRef<string>("");
137
+ const toolCallsRef = useRef<ToolCall[]>([]);
138
+ const toolCallsByIdRef = useRef<Map<string, ToolCall>>(new Map());
139
+ const contentBlocksRef = useRef<ContentBlock[]>([]);
140
+ const reasoningStartAtRef = useRef<number | null>(null);
141
+ const reasoningDurationMsRef = useRef<number | null>(null);
142
+ const currentReasoningBlockRef = useRef<ContentBlock | null>(null);
143
+
144
+ useEffect(() => {
145
+ sessionUsageRef.current = sessionUsage;
146
+ }, [sessionUsage]);
147
+
148
+ const clearCurrentContentBlocks = useCallback(() => {
149
+ setCurrentContentBlocks([]);
150
+ toolCallsRef.current = [];
151
+ toolCallsByIdRef.current.clear();
152
+ contentBlocksRef.current = [];
153
+ }, []);
154
+
155
+ const hydrateConversationHistory = useCallback((history: ConversationMessage[]) => {
156
+ const sanitized = history.map((msg) => ({ ...msg, pending: false }));
157
+ setConversationHistory(sanitized);
158
+ const maxId = sanitized.reduce((max, msg) => Math.max(max, msg.id), -1);
159
+ messageIdRef.current = maxId + 1;
160
+ }, []);
161
+
162
+ const manager = getDaemonManager();
163
+
164
+ const applyAvatarForState = useCallback((state: DaemonState) => {
165
+ const avatar = avatarRef.current;
166
+ if (!avatar) return;
167
+
168
+ // While RESPONDING, the avatar style is driven by the current stream phase.
169
+ // Most models emit reasoning first and then text, but some providers can
170
+ // interleave or resume reasoning mid-stream. In those cases, we should
171
+ // switch back into the low-intensity reasoning style until visible text
172
+ // resumes.
173
+ if (state === DaemonState.RESPONDING && streamPhaseRef.current === "reasoning") {
174
+ avatar.setColors(REASONING_COLORS);
175
+ avatar.setIntensity(REASONING_ANIMATION.INTENSITY);
176
+ avatar.setAudioLevel(0);
177
+ avatar.setReasoningMode(true);
178
+ avatar.setTypingMode(false);
179
+ return;
180
+ }
181
+
182
+ // Fallback: if we're responding and haven't seen visible text yet, keep the
183
+ // avatar in the reasoning phase even if the stream phase hasn't been set.
184
+ if (state === DaemonState.RESPONDING && !hasStartedSpeakingRef.current) {
185
+ avatar.setColors(REASONING_COLORS);
186
+ avatar.setIntensity(REASONING_ANIMATION.INTENSITY);
187
+ avatar.setAudioLevel(0);
188
+ avatar.setReasoningMode(true);
189
+ avatar.setTypingMode(false);
190
+ return;
191
+ }
192
+
193
+ avatar.setReasoningMode(false);
194
+ avatar.setTypingMode(state === DaemonState.TYPING);
195
+ avatar.setColors(STATE_COLORS[state]);
196
+ const intensity =
197
+ state === DaemonState.RESPONDING
198
+ ? 0.7
199
+ : state === DaemonState.SPEAKING
200
+ ? 0.3
201
+ : state === DaemonState.TRANSCRIBING
202
+ ? 0.35
203
+ : state === DaemonState.LISTENING
204
+ ? 0.2
205
+ : state === DaemonState.TYPING
206
+ ? 0.2
207
+ : 0;
208
+ avatar.setIntensity(intensity);
209
+ avatar.setAudioLevel(0);
210
+ if (state !== DaemonState.RESPONDING) {
211
+ avatar.setToolActive(false);
212
+ }
213
+ }, []);
214
+
215
+ const finalizeReasoningDuration = useCallback(
216
+ (endAt: number) => {
217
+ const startAt = reasoningStartAtRef.current;
218
+ if (startAt === null) return;
219
+ const durationMs = Math.max(0, endAt - startAt);
220
+ reasoningDurationMsRef.current = durationMs;
221
+
222
+ const blocks = contentBlocksRef.current;
223
+ let target = currentReasoningBlockRef.current;
224
+ if (!target) {
225
+ target =
226
+ [...blocks].reverse().find((b) => b.type === "reasoning" && b.durationMs === undefined) ?? null;
227
+ }
228
+ if (target && target.type === "reasoning") {
229
+ target.durationMs = durationMs;
230
+ setCurrentContentBlocks([...blocks]);
231
+ }
232
+ reasoningStartAtRef.current = null;
233
+ currentReasoningBlockRef.current = null;
234
+ },
235
+ [setCurrentContentBlocks]
236
+ );
237
+
238
+ // Build refs, setters, and deps objects for event handler factories
239
+ const refs: EventHandlerRefs = useMemo(
240
+ () => ({
241
+ avatarRef,
242
+ hasStartedSpeakingRef,
243
+ streamPhaseRef,
244
+ messageIdRef,
245
+ currentUserInputRef,
246
+ toolCallsRef,
247
+ toolCallsByIdRef,
248
+ contentBlocksRef,
249
+ reasoningStartAtRef,
250
+ reasoningDurationMsRef,
251
+ currentReasoningBlockRef,
252
+ sessionUsageRef,
253
+ fullReasoningRef,
254
+ }),
255
+ [fullReasoningRef]
256
+ );
257
+
258
+ const setters: EventHandlerSetters = useMemo(
259
+ () => ({
260
+ setDaemonState,
261
+ setCurrentTranscription,
262
+ setCurrentResponse,
263
+ setCurrentContentBlocks,
264
+ setConversationHistory,
265
+ setSessionUsage,
266
+ setError,
267
+ setReasoningQueue,
268
+ setFullReasoning,
269
+ }),
270
+ [setReasoningQueue, setFullReasoning]
271
+ );
272
+
273
+ const deps: EventHandlerDeps = useMemo(
274
+ () => ({
275
+ applyAvatarForState,
276
+ clearReasoningState,
277
+ clearReasoningTicker,
278
+ finalizeReasoningDuration,
279
+ sessionId,
280
+ sessionIdRef,
281
+ ensureSessionId,
282
+ addToHistory,
283
+ onFirstMessage,
284
+ syncModelHistory: (history: ConversationMessage[]) => {
285
+ manager.setConversationHistory(buildModelHistoryFromConversation(history));
286
+ },
287
+ }),
288
+ [
289
+ applyAvatarForState,
290
+ clearReasoningState,
291
+ clearReasoningTicker,
292
+ finalizeReasoningDuration,
293
+ sessionId,
294
+ sessionIdRef,
295
+ ensureSessionId,
296
+ addToHistory,
297
+ onFirstMessage,
298
+ manager,
299
+ ]
300
+ );
301
+
302
+ // Set up event listeners for daemon state changes
303
+ useEffect(() => {
304
+ const handleStateChange = createStateChangeHandler(refs, setters, deps);
305
+ const handleMicLevel = createMicLevelHandler(refs, () => manager.state);
306
+ const handleTtsLevel = createTtsLevelHandler(refs, () => manager.state);
307
+ const handleTranscription = createTranscriptionHandler(refs, setters);
308
+ const handleUserMessage = createUserMessageHandler(refs, setters, deps);
309
+ const handleReasoningToken = createReasoningTokenHandler(refs, setters);
310
+ const handleToken = createTokenHandler(refs, setters, deps);
311
+ const handleToolInputStart = createToolInputStartHandler(refs, setters, deps);
312
+ const handleToolInvocation = createToolInvocationHandler(refs, setters, deps);
313
+ const handleToolApprovalRequest = createToolApprovalRequestHandler(refs, setters);
314
+ const handleToolApprovalResolved = createToolApprovalResolvedHandler(refs, setters);
315
+ const handleSubagentToolCall = createSubagentToolCallHandler(refs, setters);
316
+ const handleSubagentToolResult = createSubagentToolResultHandler(refs, setters);
317
+ const handleSubagentComplete = createSubagentCompleteHandler(refs, setters);
318
+ const handleStepUsage = createStepUsageHandler(setters);
319
+ const handleSubagentUsage = createSubagentUsageHandler(setters);
320
+ const handleToolResult = createToolResultHandler(refs, setters);
321
+ const handleComplete = createCompleteHandler(refs, setters, deps);
322
+ const handleCancelled = createCancelledHandler(refs, setters, deps);
323
+ const handleError = createErrorHandler(setters);
324
+
325
+ daemonEvents.on("stateChange", handleStateChange);
326
+ daemonEvents.on("micLevel", handleMicLevel);
327
+ daemonEvents.on("ttsLevel", handleTtsLevel);
328
+ daemonEvents.on("transcriptionUpdate", handleTranscription);
329
+ daemonEvents.on("reasoningToken", handleReasoningToken);
330
+ daemonEvents.on("toolInputStart", handleToolInputStart);
331
+ daemonEvents.on("toolInvocation", handleToolInvocation);
332
+ daemonEvents.on("toolApprovalRequest", handleToolApprovalRequest);
333
+ daemonEvents.on("toolApprovalResolved", handleToolApprovalResolved);
334
+ daemonEvents.on("toolResult", handleToolResult);
335
+ daemonEvents.on("subagentToolCall", handleSubagentToolCall);
336
+ daemonEvents.on("subagentUsage", handleSubagentUsage);
337
+ daemonEvents.on("subagentToolResult", handleSubagentToolResult);
338
+ daemonEvents.on("subagentComplete", handleSubagentComplete);
339
+ daemonEvents.on("stepUsage", handleStepUsage);
340
+ daemonEvents.on("responseToken", handleToken);
341
+ daemonEvents.on("responseComplete", handleComplete);
342
+ daemonEvents.on("cancelled", handleCancelled);
343
+ daemonEvents.on("userMessage", handleUserMessage);
344
+ daemonEvents.on("error", handleError);
345
+
346
+ // Sync immediately in case the user triggered a state change before this effect attached listeners.
347
+ const currentState = manager.state;
348
+ setters.setDaemonState(currentState);
349
+ deps.applyAvatarForState(currentState);
350
+
351
+ return () => {
352
+ daemonEvents.off("stateChange", handleStateChange);
353
+ daemonEvents.off("micLevel", handleMicLevel);
354
+ daemonEvents.off("ttsLevel", handleTtsLevel);
355
+ daemonEvents.off("transcriptionUpdate", handleTranscription);
356
+ daemonEvents.off("reasoningToken", handleReasoningToken);
357
+ daemonEvents.off("toolInputStart", handleToolInputStart);
358
+ daemonEvents.off("toolInvocation", handleToolInvocation);
359
+ daemonEvents.off("toolApprovalRequest", handleToolApprovalRequest);
360
+ daemonEvents.off("toolApprovalResolved", handleToolApprovalResolved);
361
+ daemonEvents.off("toolResult", handleToolResult);
362
+ daemonEvents.off("subagentToolCall", handleSubagentToolCall);
363
+ daemonEvents.off("subagentUsage", handleSubagentUsage);
364
+ daemonEvents.off("subagentToolResult", handleSubagentToolResult);
365
+ daemonEvents.off("subagentComplete", handleSubagentComplete);
366
+ daemonEvents.off("stepUsage", handleStepUsage);
367
+ daemonEvents.off("responseToken", handleToken);
368
+ daemonEvents.off("responseComplete", handleComplete);
369
+ daemonEvents.off("cancelled", handleCancelled);
370
+ daemonEvents.off("userMessage", handleUserMessage);
371
+ daemonEvents.off("error", handleError);
372
+ };
373
+ }, [manager, refs, setters, deps]);
374
+
375
+ return {
376
+ daemonState,
377
+ conversationHistory,
378
+ currentTranscription,
379
+ currentResponse,
380
+ currentContentBlocks,
381
+ error,
382
+ sessionUsage,
383
+ modelMetadata,
384
+ avatarRef,
385
+ hasStartedSpeakingRef,
386
+ currentUserInputRef,
387
+ setConversationHistory,
388
+ hydrateConversationHistory,
389
+ setCurrentTranscription,
390
+ setCurrentResponse,
391
+ clearCurrentContentBlocks,
392
+ resetSessionUsage,
393
+ setSessionUsage,
394
+ applyAvatarForState,
395
+ };
396
+ }