@makefinks/daemon 0.10.0 → 0.11.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 (36) hide show
  1. package/README.md +60 -14
  2. package/package.json +4 -2
  3. package/src/ai/copilot-client.ts +775 -0
  4. package/src/ai/daemon-ai.ts +32 -234
  5. package/src/ai/model-config.ts +53 -12
  6. package/src/ai/providers/capabilities.ts +16 -0
  7. package/src/ai/providers/copilot-provider.ts +632 -0
  8. package/src/ai/providers/openrouter-provider.ts +217 -0
  9. package/src/ai/providers/registry.ts +14 -0
  10. package/src/ai/providers/types.ts +31 -0
  11. package/src/ai/tools/subagents.ts +1 -1
  12. package/src/ai/tools/tool-registry.ts +16 -1
  13. package/src/app/components/AppOverlays.tsx +9 -1
  14. package/src/app/components/ConversationPane.tsx +8 -2
  15. package/src/components/ModelMenu.tsx +202 -140
  16. package/src/components/OnboardingOverlay.tsx +147 -1
  17. package/src/components/SettingsMenu.tsx +27 -1
  18. package/src/components/TokenUsageDisplay.tsx +5 -3
  19. package/src/hooks/daemon-event-handlers.ts +61 -14
  20. package/src/hooks/keyboard-handlers.ts +109 -28
  21. package/src/hooks/use-app-callbacks.ts +141 -43
  22. package/src/hooks/use-app-context-builder.ts +5 -0
  23. package/src/hooks/use-app-controller.ts +31 -2
  24. package/src/hooks/use-app-copilot-models-loader.ts +45 -0
  25. package/src/hooks/use-app-display-state.ts +24 -2
  26. package/src/hooks/use-app-model.ts +103 -17
  27. package/src/hooks/use-app-preferences-bootstrap.ts +54 -10
  28. package/src/hooks/use-bootstrap-controller.ts +5 -0
  29. package/src/hooks/use-daemon-events.ts +8 -2
  30. package/src/hooks/use-daemon-keyboard.ts +19 -6
  31. package/src/hooks/use-daemon-runtime-controller.ts +4 -0
  32. package/src/hooks/use-menu-keyboard.ts +6 -1
  33. package/src/state/app-context.tsx +6 -0
  34. package/src/types/index.ts +22 -1
  35. package/src/utils/copilot-models.ts +77 -0
  36. package/src/utils/preferences.ts +3 -0
@@ -4,7 +4,7 @@ import type { ScrollBoxRenderable } from "@opentui/core";
4
4
  import { useKeyboard } from "@opentui/react";
5
5
  import { useCallback } from "react";
6
6
  import { getDaemonManager } from "../state/daemon-state";
7
- import { type AppPreferences, DaemonState } from "../types";
7
+ import { type AppPreferences, type LlmProvider, DaemonState } from "../types";
8
8
  import { COLORS } from "../ui/constants";
9
9
  export interface KeyboardHandlerState {
10
10
  isOverlayOpen: boolean;
@@ -13,6 +13,7 @@ export interface KeyboardHandlerState {
13
13
  hasGrounding: boolean;
14
14
  showFullReasoning: boolean;
15
15
  showToolOutput: boolean;
16
+ currentModelProvider: LlmProvider;
16
17
  }
17
18
 
18
19
  export interface KeyboardHandlerActions {
@@ -43,7 +44,14 @@ export interface KeyboardHandlerActions {
43
44
 
44
45
  export function useDaemonKeyboard(state: KeyboardHandlerState, actions: KeyboardHandlerActions) {
45
46
  const manager = getDaemonManager();
46
- const { isOverlayOpen, escPendingCancel, hasInteracted, hasGrounding, showFullReasoning } = state;
47
+ const {
48
+ isOverlayOpen,
49
+ escPendingCancel,
50
+ hasInteracted,
51
+ hasGrounding,
52
+ showFullReasoning,
53
+ currentModelProvider,
54
+ } = state;
47
55
 
48
56
  const closeAllMenus = useCallback(() => {
49
57
  actions.setShowDeviceMenu(false);
@@ -161,6 +169,10 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
161
169
  key.eventType === "press" &&
162
170
  (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING)
163
171
  ) {
172
+ if (currentModelProvider !== "openrouter") {
173
+ key.preventDefault();
174
+ return;
175
+ }
164
176
  closeAllMenus();
165
177
  actions.setShowProviderMenu(true);
166
178
  key.preventDefault();
@@ -310,8 +322,8 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
310
322
  currentState !== DaemonState.TYPING
311
323
  ) {
312
324
  if (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING) {
313
- // Check for OpenRouter API key first (needed for any AI response)
314
- if (!process.env.OPENROUTER_API_KEY) {
325
+ // OpenRouter provider requires OPENROUTER_API_KEY for model responses.
326
+ if (currentModelProvider === "openrouter" && !process.env.OPENROUTER_API_KEY) {
315
327
  actions.setApiKeyMissingError(
316
328
  "OPENROUTER_API_KEY not found · Set via environment variable or enter in onboarding"
317
329
  );
@@ -340,8 +352,8 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
340
352
  // Shift+Tab for typing mode
341
353
  if (key.name === "tab" && key.shift && key.eventType === "press") {
342
354
  if (currentState === DaemonState.IDLE) {
343
- // Check for OpenRouter API key (needed for any AI response)
344
- if (!process.env.OPENROUTER_API_KEY) {
355
+ // OpenRouter provider requires OPENROUTER_API_KEY for model responses.
356
+ if (currentModelProvider === "openrouter" && !process.env.OPENROUTER_API_KEY) {
345
357
  actions.setApiKeyMissingError(
346
358
  "OPENROUTER_API_KEY not found · Set via environment variable or enter in onboarding"
347
359
  );
@@ -411,6 +423,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
411
423
  hasGrounding,
412
424
  showFullReasoning,
413
425
  state.showToolOutput,
426
+ currentModelProvider,
414
427
  actions,
415
428
  ]
416
429
  );
@@ -9,6 +9,7 @@ import { useResponseTimer } from "./use-response-timer";
9
9
  import { useTypingMode } from "./use-typing-mode";
10
10
 
11
11
  import { daemonEvents } from "../state/daemon-events";
12
+ import type { LlmProvider } from "../types";
12
13
 
13
14
  export interface DaemonRuntimeControllerResult {
14
15
  reasoning: ReturnType<typeof useReasoningAnimation>;
@@ -41,6 +42,7 @@ export interface DaemonRuntimeControllerResult {
41
42
  }
42
43
 
43
44
  export function useDaemonRuntimeController({
45
+ currentModelProvider,
44
46
  currentModelId,
45
47
  preferencesLoaded,
46
48
  sessionId,
@@ -48,6 +50,7 @@ export function useDaemonRuntimeController({
48
50
  ensureSessionId,
49
51
  onFirstMessage,
50
52
  }: {
53
+ currentModelProvider: LlmProvider;
51
54
  currentModelId: string;
52
55
  preferencesLoaded: boolean;
53
56
  sessionId: string | null;
@@ -77,6 +80,7 @@ export function useDaemonRuntimeController({
77
80
  setSessionUsage,
78
81
  applyAvatarForState,
79
82
  } = useDaemonEvents({
83
+ currentModelProvider,
80
84
  currentModelId,
81
85
  preferencesLoaded,
82
86
  setReasoningQueue: reasoning.setReasoningQueue,
@@ -12,6 +12,7 @@ interface UseMenuKeyboardParams {
12
12
  enableViKeys?: boolean;
13
13
  closeOnSelect?: boolean;
14
14
  ignoreEscape?: boolean;
15
+ disabled?: boolean;
15
16
  }
16
17
 
17
18
  interface UseMenuKeyboardReturn {
@@ -27,6 +28,7 @@ export function useMenuKeyboard({
27
28
  enableViKeys = true,
28
29
  closeOnSelect = true,
29
30
  ignoreEscape = false,
31
+ disabled = false,
30
32
  }: UseMenuKeyboardParams): UseMenuKeyboardReturn {
31
33
  const [selectedIndex, setSelectedIndex] = useState(initialIndex);
32
34
 
@@ -49,6 +51,9 @@ export function useMenuKeyboard({
49
51
  const handleKeyPress = useCallback(
50
52
  (key: KeyEvent) => {
51
53
  if (key.eventType !== "press") return;
54
+ if (disabled) {
55
+ return;
56
+ }
52
57
  if (ignoreEscape && key.name === "escape") {
53
58
  return;
54
59
  }
@@ -84,7 +89,7 @@ export function useMenuKeyboard({
84
89
 
85
90
  key.preventDefault();
86
91
  },
87
- [itemCount, selectedIndex, onClose, onSelect, enableViKeys, closeOnSelect]
92
+ [itemCount, selectedIndex, onClose, onSelect, enableViKeys, closeOnSelect, ignoreEscape, disabled]
88
93
  );
89
94
 
90
95
  useKeyboard(handleKeyPress);
@@ -6,6 +6,7 @@ import type {
6
6
  AudioDevice,
7
7
  BashApprovalLevel,
8
8
  GroundingMap,
9
+ LlmProvider,
9
10
  ModelOption,
10
11
  OnboardingStep,
11
12
  ReasoningEffort,
@@ -55,6 +56,7 @@ export interface SettingsState {
55
56
  reasoningEffort: ReasoningEffort;
56
57
  bashApprovalLevel: BashApprovalLevel;
57
58
  supportsReasoning: boolean;
59
+ supportsReasoningXHigh: boolean;
58
60
  canEnableVoiceOutput: boolean;
59
61
  showFullReasoning: boolean;
60
62
  setShowFullReasoning: (show: boolean) => void;
@@ -71,6 +73,8 @@ export interface ModelState {
71
73
  openRouterModels: ModelOption[];
72
74
  openRouterModelsLoading: boolean;
73
75
  openRouterModelsUpdatedAt: number | null;
76
+ currentModelProvider: LlmProvider;
77
+ setCurrentModelProvider: (provider: LlmProvider) => void;
74
78
  currentModelId: string;
75
79
  setCurrentModelId: (modelId: string) => void;
76
80
  providerMenuItems: ProviderMenuItem[];
@@ -92,6 +96,7 @@ export interface GroundingState {
92
96
  export interface OnboardingState {
93
97
  onboardingActive: boolean;
94
98
  onboardingStep: OnboardingStep;
99
+ copilotAuthenticated: boolean;
95
100
  setOnboardingStep: (step: OnboardingStep) => void;
96
101
  onboardingPreferences: AppPreferences | null;
97
102
  apiKeyTextareaRef: MutableRefObject<TextareaRenderable | null>;
@@ -104,6 +109,7 @@ export interface DeviceCallbacks {
104
109
 
105
110
  export interface SettingsCallbacks {
106
111
  onToggleInteractionMode: () => void;
112
+ onCycleModelProvider: () => void;
107
113
  onSetVoiceInteractionType: (type: VoiceInteractionType) => void;
108
114
  onSetSpeechSpeed: (speed: SpeechSpeed) => void;
109
115
  onSetReasoningEffort: (effort: ReasoningEffort) => void;
@@ -150,6 +150,11 @@ export interface TranscriptionResult {
150
150
  text: string;
151
151
  }
152
152
 
153
+ /**
154
+ * LLM backend provider used for agent responses.
155
+ */
156
+ export type LlmProvider = "openrouter" | "copilot";
157
+
153
158
  /**
154
159
  * Callbacks for streaming AI responses
155
160
  */
@@ -213,18 +218,25 @@ export type SpeechSpeed = 1.0 | 1.25 | 1.5 | 1.75 | 2.0;
213
218
  * - "low": SURFACE - minimal reasoning
214
219
  * - "medium": DEEP - moderate reasoning (default)
215
220
  * - "high": ABYSSAL - maximum reasoning depth
221
+ * - "xhigh": EXTREME - extended deep reasoning (model-dependent)
216
222
  */
217
- export type ReasoningEffort = "low" | "medium" | "high";
223
+ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh";
218
224
 
219
225
  /** Display labels for reasoning effort levels */
220
226
  export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
221
227
  low: "LOW",
222
228
  medium: "MEDIUM",
223
229
  high: "HIGH",
230
+ xhigh: "XHIGH",
224
231
  };
225
232
 
226
233
  /** Ordered list of reasoning effort levels for cycling */
227
234
  export const REASONING_EFFORT_LEVELS: ReasoningEffort[] = ["low", "medium", "high"];
235
+ export const REASONING_EFFORT_LEVELS_WITH_XHIGH: ReasoningEffort[] = ["low", "medium", "high", "xhigh"];
236
+
237
+ export function getReasoningEffortLevels(includeXHigh: boolean): ReasoningEffort[] {
238
+ return includeXHigh ? REASONING_EFFORT_LEVELS_WITH_XHIGH : REASONING_EFFORT_LEVELS;
239
+ }
228
240
 
229
241
  /**
230
242
  * Bash tool approval level settings.
@@ -248,7 +260,9 @@ export const BASH_APPROVAL_LEVELS: BashApprovalLevel[] = ["none", "dangerous", "
248
260
  /**
249
261
  * Onboarding flow steps.
250
262
  * - intro: Welcome screen
263
+ * - provider: Select backend provider
251
264
  * - openrouter_key: OpenRouter API key input (for AI models)
265
+ * - copilot_auth: GitHub Copilot authentication
252
266
  * - openai_key: OpenAI API key input (for transcription)
253
267
  * - exa_key: Exa API key input (for web search)
254
268
  * - device: Audio device selection
@@ -257,7 +271,9 @@ export const BASH_APPROVAL_LEVELS: BashApprovalLevel[] = ["none", "dangerous", "
257
271
  */
258
272
  export type OnboardingStep =
259
273
  | "intro"
274
+ | "provider"
260
275
  | "openrouter_key"
276
+ | "copilot_auth"
261
277
  | "openai_key"
262
278
  | "exa_key"
263
279
  | "device"
@@ -302,6 +318,7 @@ export interface AppPreferences {
302
318
  onboardingCompleted: boolean;
303
319
  audioDeviceName?: string;
304
320
  audioOutputDeviceName?: string;
321
+ modelProvider?: LlmProvider;
305
322
  modelId?: string;
306
323
  /**
307
324
  * OpenRouter inference provider slug (aka `provider` routing tag), e.g. "openai".
@@ -359,6 +376,10 @@ export interface ModelOption {
359
376
  pricing?: ModelPricing;
360
377
  contextLength?: number;
361
378
  supportsCaching?: boolean;
379
+ /** Per-model support for reasoning effort controls (currently used by Copilot). */
380
+ supportsReasoningEffort?: boolean;
381
+ /** Whether this model supports Copilot's `xhigh` reasoning effort tier. */
382
+ supportsReasoningEffortXHigh?: boolean;
362
383
  }
363
384
 
364
385
  /**
@@ -0,0 +1,77 @@
1
+ import type { ModelOption } from "../types";
2
+ import { listCopilotModelsSafe } from "../ai/copilot-client";
3
+ import { debug } from "./debug-logger";
4
+
5
+ let inMemoryCache: { timestamp: number; models: ModelOption[] } | null = null;
6
+ function supportsXHighReasoning(modelId: string): boolean {
7
+ const normalized = modelId.trim().toLowerCase();
8
+ return normalized.includes("5.1") || normalized.includes("5.2") || normalized.includes("codex");
9
+ }
10
+
11
+ function normalizeCopilotModels(
12
+ items: Array<{
13
+ id: string;
14
+ name: string;
15
+ capabilities?: {
16
+ supports?: {
17
+ reasoningEffort?: boolean;
18
+ };
19
+ limits?: {
20
+ max_context_window_tokens?: number;
21
+ };
22
+ };
23
+ }>
24
+ ): ModelOption[] {
25
+ return items
26
+ .filter((item) => typeof item.id === "string" && item.id.trim().length > 0)
27
+ .map((item) => ({
28
+ id: item.id.trim(),
29
+ name: typeof item.name === "string" && item.name.trim().length > 0 ? item.name.trim() : item.id.trim(),
30
+ contextLength: item.capabilities?.limits?.max_context_window_tokens,
31
+ supportsReasoningEffort: item.capabilities?.supports?.reasoningEffort === true,
32
+ supportsReasoningEffortXHigh: supportsXHighReasoning(item.id.trim()),
33
+ }))
34
+ .sort((a, b) => a.name.localeCompare(b.name));
35
+ }
36
+
37
+ export async function getCopilotModels(options: { forceRefresh?: boolean } = {}): Promise<{
38
+ models: ModelOption[];
39
+ timestamp: number | null;
40
+ fromCache: boolean;
41
+ }> {
42
+ const now = Date.now();
43
+ const forceRefresh = options.forceRefresh === true;
44
+
45
+ if (!forceRefresh && inMemoryCache) {
46
+ return {
47
+ models: inMemoryCache.models,
48
+ timestamp: inMemoryCache.timestamp,
49
+ fromCache: true,
50
+ };
51
+ }
52
+
53
+ try {
54
+ const modelInfo = await listCopilotModelsSafe();
55
+ const models = normalizeCopilotModels(modelInfo);
56
+ if (models.length > 0) {
57
+ inMemoryCache = {
58
+ timestamp: now,
59
+ models,
60
+ };
61
+ return {
62
+ models,
63
+ timestamp: now,
64
+ fromCache: false,
65
+ };
66
+ }
67
+ } catch (error) {
68
+ const err = error instanceof Error ? error : new Error(String(error));
69
+ debug.error("Failed to fetch Copilot models:", err);
70
+ }
71
+
72
+ return {
73
+ models: inMemoryCache?.models ?? [],
74
+ timestamp: inMemoryCache?.timestamp ?? null,
75
+ fromCache: Boolean(inMemoryCache),
76
+ };
77
+ }
@@ -74,6 +74,9 @@ export function parsePreferences(raw: unknown): AppPreferences | null {
74
74
  if (typeof raw.modelId === "string") {
75
75
  prefs.modelId = raw.modelId;
76
76
  }
77
+ if (raw.modelProvider === "openrouter" || raw.modelProvider === "copilot") {
78
+ prefs.modelProvider = raw.modelProvider;
79
+ }
77
80
  if (typeof raw.openRouterProviderTag === "string") {
78
81
  prefs.openRouterProviderTag = raw.openRouterProviderTag;
79
82
  }