@makefinks/daemon 0.4.0 → 0.6.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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/ai/daemon-ai.ts +8 -35
  3. package/src/ai/message-utils.ts +26 -0
  4. package/src/ai/tools/subagents.ts +8 -7
  5. package/src/app/App.tsx +3 -0
  6. package/src/app/components/AvatarLayer.tsx +96 -24
  7. package/src/app/components/ConversationPane.tsx +9 -8
  8. package/src/avatar/DaemonAvatarRenderable.ts +26 -3
  9. package/src/avatar/daemon-avatar-rig.ts +10 -1159
  10. package/src/avatar/rig/core/rig-engine.ts +202 -0
  11. package/src/avatar/rig/core/rig-types.ts +17 -0
  12. package/src/avatar/rig/scene/create-scene-elements.ts +298 -0
  13. package/src/avatar/rig/state/rig-state.ts +193 -0
  14. package/src/avatar/rig/theme/rig-theme.ts +31 -0
  15. package/src/avatar/rig/tools/rig-tools.ts +8 -0
  16. package/src/avatar/rig/update/update-core.ts +32 -0
  17. package/src/avatar/rig/update/update-eye.ts +31 -0
  18. package/src/avatar/rig/update/update-fragments.ts +46 -0
  19. package/src/avatar/rig/update/update-glitch.ts +64 -0
  20. package/src/avatar/rig/update/update-idle.ts +95 -0
  21. package/src/avatar/rig/update/update-intensity.ts +16 -0
  22. package/src/avatar/rig/update/update-main-anchor.ts +20 -0
  23. package/src/avatar/rig/update/update-particles.ts +49 -0
  24. package/src/avatar/rig/update/update-rings.ts +35 -0
  25. package/src/avatar/rig/update/update-sigils.ts +26 -0
  26. package/src/avatar/rig/update/update-spawn.ts +83 -0
  27. package/src/avatar/rig/utils/math.ts +17 -0
  28. package/src/components/ContentBlockView.tsx +6 -1
  29. package/src/components/ToolCallView.tsx +9 -12
  30. package/src/components/tool-layouts/components.tsx +4 -3
  31. package/src/components/tool-layouts/layouts/subagent.tsx +140 -16
  32. package/src/hooks/daemon-event-handlers.ts +3 -3
  33. package/src/hooks/use-app-controller.ts +51 -1
  34. package/src/hooks/use-app-display-state.ts +1 -1
  35. package/src/hooks/use-glitchy-banner.ts +175 -0
  36. package/src/hooks/use-reasoning-animation.ts +1 -1
  37. package/src/state/daemon-state.ts +3 -3
  38. package/src/ui/reasoning-ticker.tsx +4 -1
  39. package/src/ui/startup.ts +5 -0
  40. package/src/utils/debug-logger.ts +34 -23
package/package.json CHANGED
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "module": "src/index.tsx",
30
30
  "type": "module",
31
- "version": "0.4.0",
31
+ "version": "0.6.0",
32
32
  "bin": {
33
33
  "daemon": "dist/cli.js"
34
34
  },
@@ -22,9 +22,10 @@ import type {
22
22
  ToolApprovalResponse,
23
23
  TranscriptionResult,
24
24
  } from "../types";
25
- import { debug } from "../utils/debug-logger";
25
+ import { debug, toolDebug } from "../utils/debug-logger";
26
26
  import { getOpenRouterReportedCost } from "../utils/openrouter-reported-cost";
27
27
  import { getWorkspacePath } from "../utils/workspace-manager";
28
+ import { extractFinalAssistantText } from "./message-utils";
28
29
  import { TRANSCRIPTION_MODEL, buildOpenRouterChatSettings, getResponseModel } from "./model-config";
29
30
  import { sanitizeMessagesForInput } from "./sanitize-messages";
30
31
  import { type InteractionMode, buildDaemonSystemPrompt } from "./system-prompt";
@@ -54,40 +55,6 @@ function normalizeStreamError(error: unknown): Error {
54
55
  return new Error(String(error));
55
56
  }
56
57
 
57
- /**
58
- * Extract the final text content from the last assistant message.
59
- * In multi-step agent loops, we only want to speak the final response, not intermediate text.
60
- */
61
- function extractFinalAssistantText(messages: ModelMessage[]): string {
62
- // Find the last assistant message
63
- for (let i = messages.length - 1; i >= 0; i--) {
64
- const msg = messages[i];
65
- if (msg?.role === "assistant") {
66
- const content = msg.content;
67
- if (Array.isArray(content)) {
68
- // Find the last text part. In some models/providers, intermediate
69
- // "thoughts" might be included as separate text blocks before the final answer.
70
- // We prioritize the last text block in the message for the final response.
71
- for (let j = content.length - 1; j >= 0; j--) {
72
- const part = content[j];
73
- if (
74
- part &&
75
- typeof part === "object" &&
76
- "type" in part &&
77
- part.type === "text" &&
78
- "text" in part &&
79
- typeof part.text === "string"
80
- ) {
81
- return part.text;
82
- }
83
- }
84
- // If this assistant message had no text parts, continue searching previous messages
85
- }
86
- }
87
- }
88
- return "";
89
- }
90
-
91
58
  /**
92
59
  * The DAEMON agent instance.
93
60
  * Handles the agent loop internally, allowing for multi-step tool usage.
@@ -239,6 +206,12 @@ export async function generateResponse(
239
206
  } else if (part.type === "tool-result") {
240
207
  callbacks.onToolResult?.(part.toolName, part.output, part.toolCallId);
241
208
  } else if (part.type === "tool-error") {
209
+ toolDebug.error("tool-error", {
210
+ toolName: part.toolName,
211
+ toolCallId: part.toolCallId,
212
+ input: part.input,
213
+ error: part.error,
214
+ });
242
215
  callbacks.onToolResult?.(part.toolName, { error: part.error, input: part.input }, part.toolCallId);
243
216
  } else if (part.type === "tool-approval-request") {
244
217
  const approvalRequest: ToolApprovalRequest = {
@@ -0,0 +1,26 @@
1
+ import type { ModelMessage } from "ai";
2
+
3
+ export function extractFinalAssistantText(messages: ModelMessage[]): string {
4
+ for (let i = messages.length - 1; i >= 0; i--) {
5
+ const msg = messages[i];
6
+ if (msg?.role === "assistant") {
7
+ const content = msg.content;
8
+ if (Array.isArray(content)) {
9
+ for (let j = content.length - 1; j >= 0; j--) {
10
+ const part = content[j];
11
+ if (
12
+ part &&
13
+ typeof part === "object" &&
14
+ "type" in part &&
15
+ part.type === "text" &&
16
+ "text" in part &&
17
+ typeof part.text === "string"
18
+ ) {
19
+ return part.text;
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ return "";
26
+ }
@@ -5,12 +5,13 @@
5
5
 
6
6
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
7
7
  import { tool } from "ai";
8
- import { ToolLoopAgent, stepCountIs } from "ai";
8
+ import { type ModelMessage, ToolLoopAgent, stepCountIs } from "ai";
9
9
  import type { ToolSet } from "ai";
10
10
  import { z } from "zod";
11
11
  import { getDaemonManager } from "../../state/daemon-state";
12
12
  import type { SubagentProgressEmitter } from "../../types";
13
13
  import { getOpenRouterReportedCost } from "../../utils/openrouter-reported-cost";
14
+ import { extractFinalAssistantText } from "../message-utils";
14
15
  import { buildOpenRouterChatSettings, getSubagentModel } from "../model-config";
15
16
  import { buildToolSet } from "./tool-registry";
16
17
 
@@ -60,7 +61,7 @@ RULES:
60
61
  - The final summary needs to be self contained and needs to provide enough information to the main agent so it is clear what you have done and what the results are.
61
62
 
62
63
  Today's date: ${new Date().toISOString().split("T")[0]}
63
- `;
64
+ `;
64
65
  }
65
66
 
66
67
  // Global emitter that will be set by the daemon-ai module
@@ -109,7 +110,6 @@ Provide a concise summary for display and a very specific task description (espe
109
110
  stopWhen: stepCountIs(MAX_SUBAGENT_STEPS),
110
111
  });
111
112
 
112
- let responseText = "";
113
113
  let costTotal = 0;
114
114
  let hasCost = false;
115
115
 
@@ -119,9 +119,7 @@ Provide a concise summary for display and a very specific task description (espe
119
119
  });
120
120
 
121
121
  for await (const part of stream.fullStream) {
122
- if (part.type === "text-delta") {
123
- responseText += part.text;
124
- } else if (part.type === "finish-step") {
122
+ if (part.type === "finish-step") {
125
123
  const reportedCost = getOpenRouterReportedCost(part.providerMetadata);
126
124
  if (reportedCost !== undefined) {
127
125
  costTotal += reportedCost;
@@ -145,6 +143,9 @@ Provide a concise summary for display and a very specific task description (espe
145
143
  }
146
144
  }
147
145
 
146
+ const responseMessages = await stream.response.then((response) => response.messages);
147
+ const finalResponse = extractFinalAssistantText(responseMessages);
148
+
148
149
  const streamUsage = await stream.usage;
149
150
  if (streamUsage) {
150
151
  progressEmitter?.onSubagentUsage({
@@ -163,7 +164,7 @@ Provide a concise summary for display and a very specific task description (espe
163
164
  return {
164
165
  success: true,
165
166
  summary,
166
- response: responseText || "Task completed but no text response generated.",
167
+ response: finalResponse || "Task completed but no text response generated.",
167
168
  };
168
169
  } catch (error) {
169
170
  const errorMessage = error instanceof Error ? error.message : String(error);
package/src/app/App.tsx CHANGED
@@ -70,6 +70,9 @@ export function App() {
70
70
  width={controller.avatarLayerProps.width}
71
71
  height={controller.avatarLayerProps.height}
72
72
  zIndex={controller.avatarLayerProps.zIndex}
73
+ showBanner={controller.avatarLayerProps.showBanner}
74
+ animateBanner={controller.avatarLayerProps.animateBanner}
75
+ startupAnimationActive={controller.avatarLayerProps.startupAnimationActive}
73
76
  />
74
77
 
75
78
  {controller.isListeningDim ? (
@@ -1,6 +1,7 @@
1
- import { memo, useCallback } from "react";
1
+ import { memo, useCallback, useEffect, useRef } from "react";
2
2
  import type { RefObject } from "react";
3
3
  import type { DaemonAvatarRenderable } from "../../avatar/DaemonAvatarRenderable";
4
+ import { BANNER_GRADIENT, DAEMON_BANNER_LINES, useGlitchyBanner } from "../../hooks/use-glitchy-banner";
4
5
  import { DaemonState } from "../../types";
5
6
 
6
7
  export interface AvatarLayerProps {
@@ -10,41 +11,112 @@ export interface AvatarLayerProps {
10
11
  width: number;
11
12
  height: number;
12
13
  zIndex?: number;
14
+ showBanner?: boolean;
15
+ animateBanner?: boolean;
16
+ startupAnimationActive?: boolean;
13
17
  }
14
18
 
15
19
  function AvatarLayerImpl(props: AvatarLayerProps) {
16
- const { avatarRef, daemonState, applyAvatarForState, width, height, zIndex = 0 } = props;
20
+ const {
21
+ avatarRef,
22
+ daemonState,
23
+ applyAvatarForState,
24
+ width,
25
+ height,
26
+ zIndex = 0,
27
+ showBanner = false,
28
+ animateBanner = false,
29
+ startupAnimationActive = false,
30
+ } = props;
31
+
32
+ // Use glitchy banner animation when animateBanner is true
33
+ const glitchyBanner = useGlitchyBanner(showBanner && animateBanner);
34
+
35
+ // Determine which lines/colors to use
36
+ const bannerLines = animateBanner ? glitchyBanner.lines : DAEMON_BANNER_LINES;
37
+ const bannerColors = animateBanner ? glitchyBanner.colors : BANNER_GRADIENT;
38
+
39
+ // Keep a stable callback ref so we don't detach/reattach on daemonState changes.
40
+ const daemonStateRef = useRef(daemonState);
41
+ const applyAvatarForStateRef = useRef(applyAvatarForState);
42
+ const startupAnimationActiveRef = useRef(startupAnimationActive);
43
+
44
+ useEffect(() => {
45
+ daemonStateRef.current = daemonState;
46
+ }, [daemonState]);
47
+ useEffect(() => {
48
+ applyAvatarForStateRef.current = applyAvatarForState;
49
+ }, [applyAvatarForState]);
50
+ useEffect(() => {
51
+ startupAnimationActiveRef.current = startupAnimationActive;
52
+ }, [startupAnimationActive]);
17
53
 
18
54
  const handleAvatarRef = useCallback(
19
55
  (ref: DaemonAvatarRenderable | null) => {
56
+ if (ref === avatarRef.current) return;
20
57
  avatarRef.current = ref;
21
- if (ref) {
22
- applyAvatarForState(daemonState);
58
+ if (!ref) return;
59
+
60
+ applyAvatarForStateRef.current(daemonStateRef.current);
61
+ if (startupAnimationActiveRef.current) {
62
+ ref.resetSpawn();
63
+ } else {
64
+ ref.skipSpawn();
23
65
  }
24
66
  },
25
- [avatarRef, applyAvatarForState, daemonState]
67
+ [avatarRef]
26
68
  );
27
69
 
70
+ useEffect(() => {
71
+ const ref = avatarRef.current;
72
+ if (!ref) return;
73
+ if (startupAnimationActive) {
74
+ ref.resetSpawn();
75
+ } else {
76
+ ref.skipSpawn();
77
+ }
78
+ }, [avatarRef, startupAnimationActive]);
79
+
28
80
  return (
29
- <box
30
- position="absolute"
31
- top={0}
32
- left={0}
33
- width="100%"
34
- height="100%"
35
- alignItems="center"
36
- justifyContent="center"
37
- zIndex={zIndex}
38
- >
39
- <daemon-avatar
40
- id="daemon-avatar"
41
- live
42
- width={width}
43
- height={height}
44
- respectAlpha={true}
45
- ref={handleAvatarRef}
46
- />
47
- </box>
81
+ <>
82
+ {showBanner && (
83
+ <box
84
+ position="absolute"
85
+ top={6}
86
+ left={0}
87
+ width="100%"
88
+ alignItems="center"
89
+ justifyContent="center"
90
+ flexDirection="column"
91
+ zIndex={10}
92
+ >
93
+ {bannerLines.map((line, i) => (
94
+ <text key={i}>
95
+ <span fg={bannerColors[i]}>{line}</span>
96
+ </text>
97
+ ))}
98
+ </box>
99
+ )}
100
+ <box
101
+ position="absolute"
102
+ top={0}
103
+ left={0}
104
+ width="100%"
105
+ height="100%"
106
+ alignItems="center"
107
+ justifyContent="center"
108
+ zIndex={zIndex}
109
+ >
110
+ <daemon-avatar
111
+ id="daemon-avatar"
112
+ live
113
+ width={width}
114
+ height={height}
115
+ respectAlpha={true}
116
+ ref={handleAvatarRef}
117
+ />
118
+ </box>
119
+ </>
48
120
  );
49
121
  }
50
122
 
@@ -7,7 +7,6 @@ import {
7
7
  isLastTextBlockInList,
8
8
  shouldHideContentBlock,
9
9
  } from "../../components/ContentBlockView";
10
- import { DaemonText } from "../../components/DaemonText";
11
10
  import { GroundingBadge } from "../../components/GroundingBadge";
12
11
  import { InlineStatusIndicator } from "../../components/InlineStatusIndicator";
13
12
  import { StatusBar } from "../../components/StatusBar";
@@ -16,6 +15,8 @@ import { TypingInputBar } from "../../components/TypingInputBar";
16
15
  import type { ContentBlock, ConversationMessage, TokenUsage } from "../../types";
17
16
  import { DaemonState } from "../../types";
18
17
  import { COLORS, REASONING_MARKDOWN_STYLE } from "../../ui/constants";
18
+ import { renderReasoningTicker } from "../../ui/reasoning-ticker";
19
+ import { formatElapsedTime } from "../../utils/formatters";
19
20
  import type { ModelMetadata } from "../../utils/model-metadata";
20
21
 
21
22
  export interface ConversationDisplayState {
@@ -76,6 +77,7 @@ export interface ConversationPaneProps {
76
77
  modelName?: string;
77
78
  sessionTitle?: string;
78
79
  isVoiceOutputEnabled?: boolean;
80
+ startupIntroDone?: boolean;
79
81
  }
80
82
 
81
83
  function ConversationPaneImpl(props: ConversationPaneProps) {
@@ -96,6 +98,7 @@ function ConversationPaneImpl(props: ConversationPaneProps) {
96
98
  modelName,
97
99
  sessionTitle,
98
100
  isVoiceOutputEnabled,
101
+ startupIntroDone = true,
99
102
  } = props;
100
103
 
101
104
  const { conversationHistory, currentTranscription, currentContentBlocks } = conversation;
@@ -175,6 +178,8 @@ function ConversationPaneImpl(props: ConversationPaneProps) {
175
178
  const isReasoning =
176
179
  daemonState === DaemonState.RESPONDING &&
177
180
  (!conversation.currentResponse || !!reasoningDisplay || !!reasoningQueue);
181
+ const fullReasoningDurationLabel =
182
+ responseElapsedMs > 0 ? ` · ${formatElapsedTime(responseElapsedMs, { style: "detailed" })}` : "";
178
183
 
179
184
  return (
180
185
  <>
@@ -189,7 +194,7 @@ function ConversationPaneImpl(props: ConversationPaneProps) {
189
194
  />
190
195
  )}
191
196
 
192
- {!hasInteracted && (
197
+ {!hasInteracted && startupIntroDone && (
193
198
  <box
194
199
  position="absolute"
195
200
  left={0}
@@ -408,6 +413,7 @@ function ConversationPaneImpl(props: ConversationPaneProps) {
408
413
  >
409
414
  <text>
410
415
  <span fg={COLORS.REASONING}>{"REASONING"}</span>
416
+ <span fg={COLORS.REASONING_DIM}>{fullReasoningDurationLabel}</span>
411
417
  </text>
412
418
  <code
413
419
  content={fullReasoning}
@@ -418,12 +424,7 @@ function ConversationPaneImpl(props: ConversationPaneProps) {
418
424
  />
419
425
  </box>
420
426
  ) : reasoningDisplay ? (
421
- <text>
422
- <span fg={COLORS.REASONING_DIM}>
423
- {"⟡ "}
424
- {reasoningDisplay}
425
- </span>
426
- </text>
427
+ renderReasoningTicker(reasoningDisplay)
427
428
  ) : null}
428
429
  </box>
429
430
  )}
@@ -1,16 +1,16 @@
1
1
  import {
2
+ type CliRenderer,
2
3
  FrameBufferRenderable,
4
+ OptimizedBuffer,
3
5
  RGBA,
4
6
  TextAttributes,
5
- OptimizedBuffer,
6
- type CliRenderer,
7
7
  } from "@opentui/core";
8
8
  import { SuperSampleType, ThreeCliRenderer } from "@opentui/core/3d";
9
9
  import {
10
- createDaemonRig,
11
10
  type DaemonColorTheme,
12
11
  type DaemonRig,
13
12
  type ToolCategory,
13
+ createDaemonRig,
14
14
  } from "./daemon-avatar-rig";
15
15
 
16
16
  export type { ToolCategory } from "./daemon-avatar-rig";
@@ -30,6 +30,7 @@ export class DaemonAvatarRenderable extends FrameBufferRenderable {
30
30
  private pendingTheme: DaemonColorTheme | null = null;
31
31
  private pendingIntensity: { value: number; immediate: boolean } | null = null;
32
32
  private pendingAudioLevel: { value: number; immediate: boolean } | null = null;
33
+ private pendingSpawnAction: "reset" | "skip" | null = null;
33
34
 
34
35
  private getDesiredAspectRatio(width: number, height: number): number {
35
36
  if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return 1;
@@ -138,6 +139,14 @@ export class DaemonAvatarRenderable extends FrameBufferRenderable {
138
139
  if (this.pendingAudioLevel) {
139
140
  this.rig.setAudioLevel(this.pendingAudioLevel.value, { immediate: this.pendingAudioLevel.immediate });
140
141
  }
142
+ if (this.pendingSpawnAction) {
143
+ if (this.pendingSpawnAction === "reset") {
144
+ this.rig.resetSpawn();
145
+ } else {
146
+ this.rig.skipSpawn();
147
+ }
148
+ this.pendingSpawnAction = null;
149
+ }
141
150
  this.renderBuffer = OptimizedBuffer.create(
142
151
  this.frameBuffer.width,
143
152
  this.frameBuffer.height,
@@ -340,4 +349,18 @@ export class DaemonAvatarRenderable extends FrameBufferRenderable {
340
349
  this.rig.triggerTypingPulse();
341
350
  }
342
351
  }
352
+
353
+ public resetSpawn(): void {
354
+ this.pendingSpawnAction = "reset";
355
+ if (this.rig) {
356
+ this.rig.resetSpawn();
357
+ }
358
+ }
359
+
360
+ public skipSpawn(): void {
361
+ this.pendingSpawnAction = "skip";
362
+ if (this.rig) {
363
+ this.rig.skipSpawn();
364
+ }
365
+ }
343
366
  }