@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,160 @@
1
+ import type { TextareaRenderable } from "@opentui/core";
2
+ import { type MutableRefObject, type ReactNode, createContext, useContext } from "react";
3
+ import type { ProviderMenuItem } from "../components/ProviderMenu";
4
+ import type {
5
+ AppPreferences,
6
+ AudioDevice,
7
+ BashApprovalLevel,
8
+ GroundingMap,
9
+ ModelOption,
10
+ OnboardingStep,
11
+ ReasoningEffort,
12
+ SessionInfo,
13
+ SpeechSpeed,
14
+ VoiceInteractionType,
15
+ } from "../types";
16
+
17
+ export interface MenuState {
18
+ showDeviceMenu: boolean;
19
+ setShowDeviceMenu: React.Dispatch<React.SetStateAction<boolean>>;
20
+ showSettingsMenu: boolean;
21
+ setShowSettingsMenu: React.Dispatch<React.SetStateAction<boolean>>;
22
+ showModelMenu: boolean;
23
+ setShowModelMenu: React.Dispatch<React.SetStateAction<boolean>>;
24
+ showProviderMenu: boolean;
25
+ setShowProviderMenu: React.Dispatch<React.SetStateAction<boolean>>;
26
+ showSessionMenu: boolean;
27
+ setShowSessionMenu: React.Dispatch<React.SetStateAction<boolean>>;
28
+ showHotkeysPane: boolean;
29
+ setShowHotkeysPane: React.Dispatch<React.SetStateAction<boolean>>;
30
+ showGroundingMenu: boolean;
31
+ setShowGroundingMenu: React.Dispatch<React.SetStateAction<boolean>>;
32
+ }
33
+
34
+ export interface DeviceState {
35
+ devices: AudioDevice[];
36
+ currentDevice: string | undefined;
37
+ setCurrentDevice: (deviceName: string | undefined) => void;
38
+ currentOutputDevice: string | undefined;
39
+ setCurrentOutputDevice: (deviceName: string | undefined) => void;
40
+ deviceLoadTimedOut: boolean;
41
+ soxAvailable: boolean;
42
+ soxInstallHint: string;
43
+ }
44
+
45
+ export interface SettingsState {
46
+ interactionMode: "text" | "voice";
47
+ voiceInteractionType: VoiceInteractionType;
48
+ speechSpeed: SpeechSpeed;
49
+ reasoningEffort: ReasoningEffort;
50
+ bashApprovalLevel: BashApprovalLevel;
51
+ supportsReasoning: boolean;
52
+ canEnableVoiceOutput: boolean;
53
+ showFullReasoning: boolean;
54
+ setShowFullReasoning: (show: boolean) => void;
55
+ showToolOutput: boolean;
56
+ setShowToolOutput: (show: boolean) => void;
57
+ setBashApprovalLevel: (level: BashApprovalLevel) => void;
58
+ persistPreferences: (updates: Partial<AppPreferences>) => void;
59
+ }
60
+
61
+ export interface ModelState {
62
+ curatedModels: ModelOption[];
63
+ openRouterModels: ModelOption[];
64
+ openRouterModelsLoading: boolean;
65
+ openRouterModelsUpdatedAt: number | null;
66
+ currentModelId: string;
67
+ setCurrentModelId: (modelId: string) => void;
68
+ providerMenuItems: ProviderMenuItem[];
69
+ currentOpenRouterProviderTag: string | undefined;
70
+ }
71
+
72
+ export interface SessionState {
73
+ sessionMenuItems: Array<SessionInfo & { isNew: boolean }>;
74
+ currentSessionId: string | null;
75
+ }
76
+
77
+ export interface GroundingState {
78
+ latestGroundingMap: GroundingMap | null;
79
+ groundingInitialIndex: number;
80
+ groundingSelectedIndex: number;
81
+ setGroundingSelectedIndex: (index: number) => void;
82
+ }
83
+
84
+ export interface OnboardingState {
85
+ onboardingActive: boolean;
86
+ onboardingStep: OnboardingStep;
87
+ setOnboardingStep: (step: OnboardingStep) => void;
88
+ onboardingPreferences: AppPreferences | null;
89
+ apiKeyTextareaRef: MutableRefObject<TextareaRenderable | null>;
90
+ }
91
+
92
+ export interface DeviceCallbacks {
93
+ onDeviceSelect: (device: AudioDevice) => void;
94
+ onOutputDeviceSelect: (device: AudioDevice) => void;
95
+ }
96
+
97
+ export interface SettingsCallbacks {
98
+ onToggleInteractionMode: () => void;
99
+ onSetVoiceInteractionType: (type: VoiceInteractionType) => void;
100
+ onSetSpeechSpeed: (speed: SpeechSpeed) => void;
101
+ onSetReasoningEffort: (effort: ReasoningEffort) => void;
102
+ onSetBashApprovalLevel: (level: BashApprovalLevel) => void;
103
+ }
104
+
105
+ export interface ModelCallbacks {
106
+ onModelSelect: (model: ModelOption) => void;
107
+ onModelRefresh: () => void;
108
+ onProviderSelect: (tag: string | undefined) => void;
109
+ }
110
+
111
+ export interface SessionCallbacks {
112
+ onSessionSelect: (index: number) => void;
113
+ onSessionDelete: (index: number) => void;
114
+ }
115
+
116
+ export interface GroundingCallbacks {
117
+ onGroundingSelect: (index: number) => void;
118
+ onGroundingIndexChange: (index: number) => void;
119
+ }
120
+
121
+ export interface OnboardingCallbacks {
122
+ onKeySubmit: () => void;
123
+ completeOnboarding: () => void;
124
+ }
125
+
126
+ export interface AppContextValue {
127
+ menus: MenuState;
128
+ device: DeviceState;
129
+ settings: SettingsState;
130
+ model: ModelState;
131
+ session: SessionState;
132
+ grounding: GroundingState;
133
+ onboarding: OnboardingState;
134
+
135
+ deviceCallbacks: DeviceCallbacks;
136
+ settingsCallbacks: SettingsCallbacks;
137
+ modelCallbacks: ModelCallbacks;
138
+ sessionCallbacks: SessionCallbacks;
139
+ groundingCallbacks: GroundingCallbacks;
140
+ onboardingCallbacks: OnboardingCallbacks;
141
+ }
142
+
143
+ const AppContext = createContext<AppContextValue | null>(null);
144
+
145
+ export function useAppContext(): AppContextValue {
146
+ const context = useContext(AppContext);
147
+ if (!context) {
148
+ throw new Error("useAppContext must be used within an AppProvider");
149
+ }
150
+ return context;
151
+ }
152
+
153
+ export interface AppProviderProps {
154
+ value: AppContextValue;
155
+ children: ReactNode;
156
+ }
157
+
158
+ export function AppProvider({ value, children }: AppProviderProps) {
159
+ return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
160
+ }
@@ -0,0 +1,67 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ import type {
4
+ ModelMessage,
5
+ TokenUsage,
6
+ ToolCallStatus,
7
+ ToolApprovalRequest,
8
+ ToolApprovalResponse,
9
+ } from "../types";
10
+ import { DaemonState } from "../types";
11
+
12
+ export type DaemonStateEvents = {
13
+ stateChange: (state: DaemonState) => void;
14
+ transcriptionUpdate: (text: string) => void;
15
+ transcriptionReady: (text: string) => void;
16
+ micLevel: (level: number) => void;
17
+ ttsLevel: (level: number) => void;
18
+ reasoningToken: (token: string) => void;
19
+ toolInputStart: (toolName: string, toolCallId: string) => void;
20
+ toolInvocation: (toolName: string, input: unknown, toolCallId?: string) => void;
21
+ toolResult: (toolName: string, result: unknown, toolCallId?: string) => void;
22
+ toolComplete: (toolCallId: string, status: ToolCallStatus) => void;
23
+ toolApprovalRequest: (request: ToolApprovalRequest) => void;
24
+ toolApprovalResolved: (toolCallId: string, approved: boolean) => void;
25
+ awaitingApprovals: (
26
+ pendingApprovals: ToolApprovalRequest[],
27
+ respondToApprovals: (responses: ToolApprovalResponse[]) => void
28
+ ) => void;
29
+ subagentToolCall: (toolCallId: string, toolName: string, input?: unknown) => void;
30
+ subagentUsage: (usage: TokenUsage) => void;
31
+ subagentToolResult: (toolCallId: string, toolName: string, success: boolean) => void;
32
+ subagentComplete: (toolCallId: string, success: boolean) => void;
33
+ responseToken: (token: string) => void;
34
+ stepUsage: (usage: TokenUsage) => void;
35
+ responseComplete: (fullText: string, responseMessages: ModelMessage[], usage?: TokenUsage) => void;
36
+ userMessage: (text: string) => void;
37
+ speakingStart: () => void;
38
+ speakingComplete: () => void;
39
+ groundingSaved: (sessionId: string, messageId: number, mapId: string) => void;
40
+ cancelled: () => void;
41
+ error: (error: Error) => void;
42
+ };
43
+
44
+ class DaemonEventBus extends EventEmitter {
45
+ override emit(event: string | symbol, ...args: unknown[]): boolean;
46
+ override emit<K extends keyof DaemonStateEvents>(
47
+ event: K,
48
+ ...args: Parameters<DaemonStateEvents[K]>
49
+ ): boolean;
50
+ override emit(event: string | symbol, ...args: unknown[]): boolean {
51
+ return super.emit(event, ...args);
52
+ }
53
+
54
+ override on(event: string | symbol, listener: (...args: unknown[]) => void): this;
55
+ override on<K extends keyof DaemonStateEvents>(event: K, listener: DaemonStateEvents[K]): this;
56
+ override on(event: string | symbol, listener: (...args: unknown[]) => void): this {
57
+ return super.on(event, listener);
58
+ }
59
+
60
+ override off(event: string | symbol, listener: (...args: unknown[]) => void): this;
61
+ override off<K extends keyof DaemonStateEvents>(event: K, listener: DaemonStateEvents[K]): this;
62
+ override off(event: string | symbol, listener: (...args: unknown[]) => void): this {
63
+ return super.off(event, listener);
64
+ }
65
+ }
66
+
67
+ export const daemonEvents = new DaemonEventBus();
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Daemon state manager - handles the full interaction flow.
3
+ * Central state machine for DAEMON's operational states.
4
+ */
5
+
6
+ import { AgentTurnRunner } from "../ai/agent-turn-runner";
7
+ import { transcribeAudio } from "../ai/daemon-ai";
8
+ import { debug } from "../utils/debug-logger";
9
+ import { SpeechController } from "../voice/tts/speech-controller";
10
+ import { VoiceInputController } from "../voice/voice-input-controller";
11
+ import type {
12
+ ModelMessage,
13
+ InteractionMode,
14
+ VoiceInteractionType,
15
+ SpeechSpeed,
16
+ ReasoningEffort,
17
+ BashApprovalLevel,
18
+ } from "../types";
19
+ import { DaemonState } from "../types";
20
+ import { daemonEvents, type DaemonStateEvents } from "./daemon-events";
21
+ import { ModelHistoryStore } from "./model-history-store";
22
+
23
+ /**
24
+ * Daemon state manager - handles the full interaction flow
25
+ */
26
+ class DaemonStateManager {
27
+ private _state: DaemonState = DaemonState.IDLE;
28
+ private _transcription: string = "";
29
+ private _response: string = "";
30
+ private modelHistory = new ModelHistoryStore();
31
+ private ensureSessionIdFn: (() => Promise<string>) | null = null;
32
+ private voiceInput = new VoiceInputController();
33
+ private speechController = new SpeechController();
34
+ private agentTurnRunner = new AgentTurnRunner();
35
+ private transcriptionAbortController: AbortController | null = null;
36
+ private _ttsEnabled = false;
37
+ private _interactionMode: InteractionMode = "text";
38
+ private _voiceInteractionType: VoiceInteractionType = "direct";
39
+ private _speechSpeed: SpeechSpeed = 1.25;
40
+ private _reasoningEffort: ReasoningEffort = "medium";
41
+ private _bashApprovalLevel: BashApprovalLevel = "dangerous";
42
+ private _outputDeviceName: string | undefined = undefined;
43
+ private _turnId = 0;
44
+ private speechRunId = 0;
45
+
46
+ constructor() {
47
+ this.voiceInput.on("micLevel", (level: number) => {
48
+ if (this._state !== DaemonState.LISTENING) return;
49
+ this.emitEvent("micLevel", level);
50
+ });
51
+ this.voiceInput.on("error", (error: Error) => {
52
+ this.emitEvent("error", error);
53
+ this.setState(DaemonState.IDLE);
54
+ });
55
+
56
+ this.speechController.on("audioLevel", (level: number) => {
57
+ if (this._state !== DaemonState.SPEAKING) return;
58
+ this.emitEvent("ttsLevel", level);
59
+ });
60
+ }
61
+
62
+ private emitEvent<K extends keyof DaemonStateEvents>(
63
+ event: K,
64
+ ...args: Parameters<DaemonStateEvents[K]>
65
+ ): void {
66
+ daemonEvents.emit(event, ...args);
67
+ }
68
+
69
+ get state(): DaemonState {
70
+ return this._state;
71
+ }
72
+
73
+ get transcription(): string {
74
+ return this._transcription;
75
+ }
76
+
77
+ get response(): string {
78
+ return this._response;
79
+ }
80
+
81
+ get conversationHistory(): ModelMessage[] {
82
+ return this.modelHistory.get();
83
+ }
84
+
85
+ setConversationHistory(history: ModelMessage[]): void {
86
+ this.modelHistory.set(history);
87
+ }
88
+
89
+ get ttsEnabled(): boolean {
90
+ return this._ttsEnabled;
91
+ }
92
+
93
+ set ttsEnabled(enabled: boolean) {
94
+ this._ttsEnabled = enabled;
95
+ }
96
+
97
+ get interactionMode(): InteractionMode {
98
+ return this._interactionMode;
99
+ }
100
+
101
+ set interactionMode(mode: InteractionMode) {
102
+ this._interactionMode = mode;
103
+ // Voice mode implies TTS enabled, Text mode implies TTS disabled
104
+ this._ttsEnabled = mode === "voice";
105
+ }
106
+
107
+ get voiceInteractionType(): VoiceInteractionType {
108
+ return this._voiceInteractionType;
109
+ }
110
+
111
+ set voiceInteractionType(type: VoiceInteractionType) {
112
+ this._voiceInteractionType = type;
113
+ }
114
+
115
+ get speechSpeed(): SpeechSpeed {
116
+ return this._speechSpeed;
117
+ }
118
+
119
+ set speechSpeed(speed: SpeechSpeed) {
120
+ this._speechSpeed = speed;
121
+ }
122
+
123
+ get reasoningEffort(): ReasoningEffort {
124
+ return this._reasoningEffort;
125
+ }
126
+
127
+ set reasoningEffort(effort: ReasoningEffort) {
128
+ this._reasoningEffort = effort;
129
+ }
130
+
131
+ get bashApprovalLevel(): BashApprovalLevel {
132
+ return this._bashApprovalLevel;
133
+ }
134
+
135
+ set bashApprovalLevel(level: BashApprovalLevel) {
136
+ this._bashApprovalLevel = level;
137
+ }
138
+
139
+ get outputDeviceName(): string | undefined {
140
+ return this._outputDeviceName;
141
+ }
142
+
143
+ set outputDeviceName(deviceName: string | undefined) {
144
+ this._outputDeviceName = deviceName;
145
+ }
146
+
147
+ setEnsureSessionId(fn: (() => Promise<string>) | null): void {
148
+ this.ensureSessionIdFn = fn;
149
+ }
150
+
151
+ private setState(newState: DaemonState): void {
152
+ if (this._state !== newState) {
153
+ this._state = newState;
154
+ this.emitEvent("stateChange", newState);
155
+ }
156
+ }
157
+
158
+ private async ensureSessionId(): Promise<boolean> {
159
+ if (!this.ensureSessionIdFn) return true;
160
+ try {
161
+ await this.ensureSessionIdFn();
162
+ return true;
163
+ } catch (error) {
164
+ const err = error instanceof Error ? error : new Error(String(error));
165
+ debug.error("session-ensure-failed", { message: err.message });
166
+ this.emitEvent("error", err);
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Start listening for voice input (called when space is pressed)
173
+ */
174
+ startListening(): void {
175
+ if (this._state !== DaemonState.IDLE && this._state !== DaemonState.TYPING) {
176
+ return;
177
+ }
178
+
179
+ this._transcription = "";
180
+ this._response = "";
181
+ this.setState(DaemonState.LISTENING);
182
+ this.voiceInput.start();
183
+ }
184
+
185
+ /**
186
+ * Stop listening and process the audio (called when space is pressed again)
187
+ */
188
+ async stopListening(): Promise<void> {
189
+ if (this._state !== DaemonState.LISTENING) {
190
+ return;
191
+ }
192
+
193
+ const { duration, audioBuffer } = await this.voiceInput.stop();
194
+
195
+ // Check if we have enough audio data
196
+ const minDuration = 0.5;
197
+ if (audioBuffer.length < 1000 || duration < minDuration) {
198
+ this.emitEvent("error", new Error(`Recording too short (${duration.toFixed(1)}s). Hold longer.`));
199
+ this.setState(DaemonState.IDLE);
200
+ return;
201
+ }
202
+
203
+ // Start transcription with abort support
204
+ this.setState(DaemonState.TRANSCRIBING);
205
+ this.transcriptionAbortController = new AbortController();
206
+
207
+ try {
208
+ const result = await transcribeAudio(audioBuffer, this.transcriptionAbortController.signal);
209
+ this.transcriptionAbortController = null;
210
+ this._transcription = result.text;
211
+
212
+ if (!result.text.trim()) {
213
+ this.setState(DaemonState.IDLE);
214
+ return;
215
+ }
216
+
217
+ if (this._voiceInteractionType === "review") {
218
+ this.setState(DaemonState.TYPING);
219
+ this.emitEvent("transcriptionReady", result.text);
220
+ } else {
221
+ this.emitEvent("transcriptionUpdate", result.text);
222
+ this.emitEvent("userMessage", result.text);
223
+ await this.generateResponseFromText(result.text);
224
+ }
225
+ } catch (error) {
226
+ if (error instanceof Error && error.name === "AbortError") {
227
+ this.transcriptionAbortController = null;
228
+ this.emitEvent("cancelled");
229
+ this.setState(DaemonState.IDLE);
230
+ return;
231
+ }
232
+ this.transcriptionAbortController = null;
233
+ const err = error instanceof Error ? error : new Error(String(error));
234
+ this.emitEvent("error", err);
235
+ this.setState(DaemonState.IDLE);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Submit text input (for typing mode)
241
+ */
242
+ async submitText(text: string): Promise<void> {
243
+ if (!text.trim()) return;
244
+
245
+ this._transcription = text;
246
+ this.emitEvent("transcriptionUpdate", text);
247
+ this.emitEvent("userMessage", text);
248
+ await this.generateResponseFromText(text);
249
+ }
250
+
251
+ /**
252
+ * Generate a response from text input
253
+ */
254
+ private async generateResponseFromText(text: string): Promise<void> {
255
+ const ok = await this.ensureSessionId();
256
+ if (!ok) {
257
+ this.setState(DaemonState.IDLE);
258
+ return;
259
+ }
260
+
261
+ this.setState(DaemonState.RESPONDING);
262
+ this._response = "";
263
+ const turnId = ++this._turnId;
264
+ debug.info("agent-turn-start", {
265
+ turnId,
266
+ text,
267
+ mode: this._interactionMode,
268
+ reasoningEffort: this._reasoningEffort,
269
+ });
270
+
271
+ try {
272
+ const result = await this.agentTurnRunner.run(
273
+ {
274
+ userText: text,
275
+ conversationHistory: this.modelHistory.get(),
276
+ interactionMode: this._interactionMode,
277
+ reasoningEffort: this._reasoningEffort,
278
+ },
279
+ {
280
+ onReasoningToken: (token) => this.emitEvent("reasoningToken", token),
281
+ onToolCallStart: (toolName, toolCallId) => this.emitEvent("toolInputStart", toolName, toolCallId),
282
+ onToolCall: (toolName, args, toolCallId) =>
283
+ this.emitEvent("toolInvocation", toolName, args, toolCallId),
284
+ onToolResult: (toolName, resultValue, toolCallId) =>
285
+ this.emitEvent("toolResult", toolName, resultValue, toolCallId),
286
+ onToolApprovalRequest: (request) => this.emitEvent("toolApprovalRequest", request),
287
+ onAwaitingApprovals: (pendingApprovals, respondToApprovals) =>
288
+ this.emitEvent("awaitingApprovals", pendingApprovals, respondToApprovals),
289
+ onSubagentToolCall: (toolCallId, toolName, input) =>
290
+ this.emitEvent("subagentToolCall", toolCallId, toolName, input),
291
+ onSubagentUsage: (usage) => this.emitEvent("subagentUsage", usage),
292
+ onSubagentToolResult: (toolCallId, toolName, success) =>
293
+ this.emitEvent("subagentToolResult", toolCallId, toolName, success),
294
+ onSubagentComplete: (toolCallId, success) =>
295
+ this.emitEvent("subagentComplete", toolCallId, success),
296
+ onToken: (token) => {
297
+ this._response += token;
298
+ this.emitEvent("responseToken", token);
299
+ },
300
+ onStepUsage: (usage) => this.emitEvent("stepUsage", usage),
301
+ }
302
+ );
303
+
304
+ if (!result) {
305
+ if (this._state === DaemonState.RESPONDING) {
306
+ this.setState(DaemonState.IDLE);
307
+ }
308
+ return;
309
+ }
310
+
311
+ debug.info("agent-turn-complete", {
312
+ turnId,
313
+ fullText: result.fullText,
314
+ finalText: result.finalText,
315
+ responseMessages: result.responseMessages,
316
+ usage: result.usage,
317
+ });
318
+ this.modelHistory.appendTurn(text, result.responseMessages);
319
+ this.emitEvent("responseComplete", result.fullText, result.responseMessages, result.usage);
320
+
321
+ // Trigger TTS if enabled - use finalText (last assistant message only) for speech
322
+ const textToSpeak = result.finalText ?? result.fullText;
323
+ if (this._ttsEnabled && textToSpeak.trim()) {
324
+ void this.speakResponse(textToSpeak);
325
+ } else {
326
+ this.setState(DaemonState.IDLE);
327
+ }
328
+ } catch (error) {
329
+ const err = error instanceof Error ? error : new Error(String(error));
330
+ debug.error("agent-turn-error", {
331
+ turnId,
332
+ message: err.message,
333
+ stack: err.stack,
334
+ });
335
+ this.emitEvent("error", err);
336
+ this.setState(DaemonState.IDLE);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Enter typing mode (shift+tab pressed)
342
+ */
343
+ enterTypingMode(): void {
344
+ if (this._state === DaemonState.IDLE) {
345
+ this.setState(DaemonState.TYPING);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Exit typing mode (escape or submission)
351
+ */
352
+ exitTypingMode(): void {
353
+ if (this._state === DaemonState.TYPING) {
354
+ this.setState(DaemonState.IDLE);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Speak a response using TTS with audio effects.
360
+ */
361
+ private async speakResponse(text: string): Promise<void> {
362
+ if (!text.trim()) {
363
+ this.setState(DaemonState.IDLE);
364
+ return;
365
+ }
366
+
367
+ const speechRunId = ++this.speechRunId;
368
+ this.setState(DaemonState.SPEAKING);
369
+ this.emitEvent("speakingStart");
370
+
371
+ try {
372
+ await this.speechController.speak(text, {
373
+ speed: this._speechSpeed,
374
+ outputDeviceName: this._outputDeviceName,
375
+ });
376
+ } catch (error) {
377
+ if (error instanceof Error && error.name !== "AbortError") {
378
+ this.emitEvent("error", new Error(`TTS error: ${error.message}`));
379
+ }
380
+ } finally {
381
+ if (speechRunId === this.speechRunId) {
382
+ this.emitEvent("speakingComplete");
383
+ this.setState(DaemonState.IDLE);
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Stop current TTS playback
390
+ */
391
+ stopSpeaking(): void {
392
+ if (this._state !== DaemonState.SPEAKING) return;
393
+
394
+ this.speechRunId++;
395
+ this.speechController.stop();
396
+ this.emitEvent("speakingComplete");
397
+ this.setState(DaemonState.IDLE);
398
+ }
399
+
400
+ /**
401
+ * Cancel recording without processing (escape pressed during listening)
402
+ */
403
+ cancelListening(): void {
404
+ if (this._state !== DaemonState.LISTENING) {
405
+ return;
406
+ }
407
+
408
+ this.voiceInput.cancel();
409
+ this._transcription = "";
410
+ this.emitEvent("cancelled");
411
+ this.setState(DaemonState.IDLE);
412
+ }
413
+
414
+ /**
415
+ * Cancel current action (transcription, response generation, or speaking)
416
+ */
417
+ cancelCurrentAction(): void {
418
+ if (this._state === DaemonState.LISTENING) {
419
+ this.cancelListening();
420
+ return;
421
+ }
422
+
423
+ if (this._state === DaemonState.SPEAKING) {
424
+ this.stopSpeaking();
425
+ return;
426
+ }
427
+
428
+ if (this._state !== DaemonState.TRANSCRIBING && this._state !== DaemonState.RESPONDING) {
429
+ return;
430
+ }
431
+
432
+ if (this._state === DaemonState.TRANSCRIBING && this.transcriptionAbortController) {
433
+ this.transcriptionAbortController.abort();
434
+ this.transcriptionAbortController = null;
435
+ }
436
+
437
+ if (this._state === DaemonState.RESPONDING) {
438
+ this.agentTurnRunner.cancel();
439
+ }
440
+
441
+ this._transcription = "";
442
+ this._response = "";
443
+ this.emitEvent("cancelled");
444
+ this.setState(DaemonState.IDLE);
445
+ }
446
+
447
+ /**
448
+ * Clear conversation history
449
+ */
450
+ clearHistory(): void {
451
+ this.modelHistory.clear();
452
+ this._transcription = "";
453
+ this._response = "";
454
+ }
455
+
456
+ /**
457
+ * Undo the last turn (user message + assistant response) from the model history.
458
+ * Returns the number of messages removed, or 0 if nothing to undo.
459
+ */
460
+ undoLastTurn(): number {
461
+ return this.modelHistory.undoLastTurn();
462
+ }
463
+
464
+ /**
465
+ * Clean up resources
466
+ */
467
+ destroy(): void {
468
+ this.agentTurnRunner.cancel();
469
+ if (this.transcriptionAbortController) {
470
+ this.transcriptionAbortController.abort();
471
+ this.transcriptionAbortController = null;
472
+ }
473
+ this.speechController.destroy();
474
+ this.voiceInput.destroy();
475
+ }
476
+ }
477
+
478
+ // Singleton instance
479
+ let manager: DaemonStateManager | null = null;
480
+
481
+ export function getDaemonManager(): DaemonStateManager {
482
+ if (!manager) {
483
+ manager = new DaemonStateManager();
484
+ }
485
+ return manager;
486
+ }
487
+
488
+ export function destroyDaemonManager(): void {
489
+ if (manager) {
490
+ manager.destroy();
491
+ manager = null;
492
+ }
493
+ }