1// Package agent is the core orchestration layer for Crush AI agents.
2//
3// It provides session-based AI agent functionality for managing
4// conversations, tool execution, and message handling. It coordinates
5// interactions between language models, messages, sessions, and tools while
6// handling features like automatic summarization, queuing, and token
7// management.
8package agent
9
10import (
11 "cmp"
12 "context"
13 _ "embed"
14 "encoding/base64"
15 "errors"
16 "fmt"
17 "log/slog"
18 "os"
19 "path/filepath"
20 "regexp"
21 "strconv"
22 "strings"
23 "sync"
24 "time"
25
26 "charm.land/fantasy"
27 "charm.land/fantasy/providers/anthropic"
28 "charm.land/fantasy/providers/bedrock"
29 "charm.land/fantasy/providers/google"
30 "charm.land/fantasy/providers/openai"
31 "charm.land/fantasy/providers/openrouter"
32 "charm.land/lipgloss/v2"
33 "github.com/charmbracelet/catwalk/pkg/catwalk"
34 "github.com/charmbracelet/crush/internal/agent/hyper"
35 "github.com/charmbracelet/crush/internal/agent/tools"
36 "github.com/charmbracelet/crush/internal/config"
37 "github.com/charmbracelet/crush/internal/csync"
38 "github.com/charmbracelet/crush/internal/message"
39 "github.com/charmbracelet/crush/internal/permission"
40 "github.com/charmbracelet/crush/internal/session"
41 "github.com/charmbracelet/crush/internal/stringext"
42 "github.com/charmbracelet/x/exp/charmtone"
43)
44
45const (
46 defaultSessionName = "Untitled Session"
47
48 // Constants for auto-summarization thresholds
49 largeContextWindowThreshold = 200_000
50 largeContextWindowBuffer = 20_000
51 smallContextWindowRatio = 0.2
52)
53
54//go:embed templates/title.md
55var titlePrompt []byte
56
57//go:embed templates/summary.md
58var summaryPrompt []byte
59
60// Used to remove <think> tags from generated titles.
61var thinkTagRegex = regexp.MustCompile(`<think>.*?</think>`)
62
63type SessionAgentCall struct {
64 SessionID string
65 Prompt string
66 ProviderOptions fantasy.ProviderOptions
67 Attachments []message.Attachment
68 MaxOutputTokens int64
69 Temperature *float64
70 TopP *float64
71 TopK *int64
72 FrequencyPenalty *float64
73 PresencePenalty *float64
74}
75
76type SessionAgent interface {
77 Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
78 SetModels(large Model, small Model)
79 SetTools(tools []fantasy.AgentTool)
80 SetSystemPrompt(systemPrompt string)
81 Cancel(sessionID string)
82 CancelAll()
83 IsSessionBusy(sessionID string) bool
84 IsBusy() bool
85 QueuedPrompts(sessionID string) int
86 QueuedPromptsList(sessionID string) []string
87 ClearQueue(sessionID string)
88 Summarize(context.Context, string, fantasy.ProviderOptions) error
89 Model() Model
90}
91
92type Model struct {
93 Model fantasy.LanguageModel
94 CatwalkCfg catwalk.Model
95 ModelCfg config.SelectedModel
96}
97
98type sessionAgent struct {
99 largeModel *csync.Value[Model]
100 smallModel *csync.Value[Model]
101 systemPromptPrefix *csync.Value[string]
102 systemPrompt *csync.Value[string]
103 tools *csync.Slice[fantasy.AgentTool]
104
105 isSubAgent bool
106 sessions session.Service
107 messages message.Service
108 disableAutoSummarize bool
109 isYolo bool
110
111 messageQueue *csync.Map[string, []SessionAgentCall]
112 activeRequests *csync.Map[string, context.CancelFunc]
113}
114
115type SessionAgentOptions struct {
116 LargeModel Model
117 SmallModel Model
118 SystemPromptPrefix string
119 SystemPrompt string
120 IsSubAgent bool
121 DisableAutoSummarize bool
122 IsYolo bool
123 Sessions session.Service
124 Messages message.Service
125 Tools []fantasy.AgentTool
126}
127
128func NewSessionAgent(
129 opts SessionAgentOptions,
130) SessionAgent {
131 return &sessionAgent{
132 largeModel: csync.NewValue(opts.LargeModel),
133 smallModel: csync.NewValue(opts.SmallModel),
134 systemPromptPrefix: csync.NewValue(opts.SystemPromptPrefix),
135 systemPrompt: csync.NewValue(opts.SystemPrompt),
136 isSubAgent: opts.IsSubAgent,
137 sessions: opts.Sessions,
138 messages: opts.Messages,
139 disableAutoSummarize: opts.DisableAutoSummarize,
140 tools: csync.NewSliceFrom(opts.Tools),
141 isYolo: opts.IsYolo,
142 messageQueue: csync.NewMap[string, []SessionAgentCall](),
143 activeRequests: csync.NewMap[string, context.CancelFunc](),
144 }
145}
146
147func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
148 if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
149 return nil, ErrEmptyPrompt
150 }
151 if call.SessionID == "" {
152 return nil, ErrSessionMissing
153 }
154
155 // Queue the message if busy
156 if a.IsSessionBusy(call.SessionID) {
157 existing, ok := a.messageQueue.Get(call.SessionID)
158 if !ok {
159 existing = []SessionAgentCall{}
160 }
161 existing = append(existing, call)
162 a.messageQueue.Set(call.SessionID, existing)
163 return nil, nil
164 }
165
166 // Copy mutable fields under lock to avoid races with SetTools/SetModels.
167 agentTools := a.tools.Copy()
168 largeModel := a.largeModel.Get()
169 systemPrompt := a.systemPrompt.Get()
170 promptPrefix := a.systemPromptPrefix.Get()
171
172 if len(agentTools) > 0 {
173 // Add Anthropic caching to the last tool.
174 agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions())
175 }
176
177 agent := fantasy.NewAgent(
178 largeModel.Model,
179 fantasy.WithSystemPrompt(systemPrompt),
180 fantasy.WithTools(agentTools...),
181 )
182
183 sessionLock := sync.Mutex{}
184 currentSession, err := a.sessions.Get(ctx, call.SessionID)
185 if err != nil {
186 return nil, fmt.Errorf("failed to get session: %w", err)
187 }
188
189 msgs, err := a.getSessionMessages(ctx, currentSession)
190 if err != nil {
191 return nil, fmt.Errorf("failed to get session messages: %w", err)
192 }
193
194 var wg sync.WaitGroup
195 // Generate title if first message.
196 if len(msgs) == 0 {
197 titleCtx := ctx // Copy to avoid race with ctx reassignment below.
198 wg.Go(func() {
199 a.generateTitle(titleCtx, call.SessionID, call.Prompt)
200 })
201 }
202 defer wg.Wait()
203
204 // Add the user message to the session.
205 _, err = a.createUserMessage(ctx, call)
206 if err != nil {
207 return nil, err
208 }
209
210 // Add the session to the context.
211 ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
212
213 genCtx, cancel := context.WithCancel(ctx)
214 a.activeRequests.Set(call.SessionID, cancel)
215
216 defer cancel()
217 defer a.activeRequests.Del(call.SessionID)
218
219 history, files := a.preparePrompt(msgs, call.Attachments...)
220
221 startTime := time.Now()
222 a.eventPromptSent(call.SessionID)
223
224 var currentAssistant *message.Message
225 var shouldSummarize bool
226 result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
227 Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments),
228 Files: files,
229 Messages: history,
230 ProviderOptions: call.ProviderOptions,
231 MaxOutputTokens: &call.MaxOutputTokens,
232 TopP: call.TopP,
233 Temperature: call.Temperature,
234 PresencePenalty: call.PresencePenalty,
235 TopK: call.TopK,
236 FrequencyPenalty: call.FrequencyPenalty,
237 PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
238 prepared.Messages = options.Messages
239 for i := range prepared.Messages {
240 prepared.Messages[i].ProviderOptions = nil
241 }
242
243 queuedCalls, _ := a.messageQueue.Get(call.SessionID)
244 a.messageQueue.Del(call.SessionID)
245 for _, queued := range queuedCalls {
246 userMessage, createErr := a.createUserMessage(callContext, queued)
247 if createErr != nil {
248 return callContext, prepared, createErr
249 }
250 prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
251 }
252
253 prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
254
255 lastSystemRoleInx := 0
256 systemMessageUpdated := false
257 for i, msg := range prepared.Messages {
258 // Only add cache control to the last message.
259 if msg.Role == fantasy.MessageRoleSystem {
260 lastSystemRoleInx = i
261 } else if !systemMessageUpdated {
262 prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions()
263 systemMessageUpdated = true
264 }
265 // Than add cache control to the last 2 messages.
266 if i > len(prepared.Messages)-3 {
267 prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
268 }
269 }
270
271 if promptPrefix != "" {
272 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
273 }
274
275 var assistantMsg message.Message
276 assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
277 Role: message.Assistant,
278 Parts: []message.ContentPart{},
279 Model: largeModel.ModelCfg.Model,
280 Provider: largeModel.ModelCfg.Provider,
281 })
282 if err != nil {
283 return callContext, prepared, err
284 }
285 callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
286 callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
287 callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
288 currentAssistant = &assistantMsg
289 return callContext, prepared, err
290 },
291 OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
292 currentAssistant.AppendReasoningContent(reasoning.Text)
293 return a.messages.Update(genCtx, *currentAssistant)
294 },
295 OnReasoningDelta: func(id string, text string) error {
296 currentAssistant.AppendReasoningContent(text)
297 return a.messages.Update(genCtx, *currentAssistant)
298 },
299 OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
300 // handle anthropic signature
301 if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok {
302 if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok {
303 currentAssistant.AppendReasoningSignature(reasoning.Signature)
304 }
305 }
306 if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
307 if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
308 currentAssistant.AppendThoughtSignature(reasoning.Signature, reasoning.ToolID)
309 }
310 }
311 if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
312 if reasoning, ok := openaiData.(*openai.ResponsesReasoningMetadata); ok {
313 currentAssistant.SetReasoningResponsesData(reasoning)
314 }
315 }
316 currentAssistant.FinishThinking()
317 return a.messages.Update(genCtx, *currentAssistant)
318 },
319 OnTextDelta: func(id string, text string) error {
320 // Strip leading newline from initial text content. This is is
321 // particularly important in non-interactive mode where leading
322 // newlines are very visible.
323 if len(currentAssistant.Parts) == 0 {
324 text = strings.TrimPrefix(text, "\n")
325 }
326
327 currentAssistant.AppendContent(text)
328 return a.messages.Update(genCtx, *currentAssistant)
329 },
330 OnToolInputStart: func(id string, toolName string) error {
331 toolCall := message.ToolCall{
332 ID: id,
333 Name: toolName,
334 ProviderExecuted: false,
335 Finished: false,
336 }
337 currentAssistant.AddToolCall(toolCall)
338 return a.messages.Update(genCtx, *currentAssistant)
339 },
340 OnRetry: func(err *fantasy.ProviderError, delay time.Duration) {
341 // TODO: implement
342 },
343 OnToolCall: func(tc fantasy.ToolCallContent) error {
344 toolCall := message.ToolCall{
345 ID: tc.ToolCallID,
346 Name: tc.ToolName,
347 Input: tc.Input,
348 ProviderExecuted: false,
349 Finished: true,
350 }
351 currentAssistant.AddToolCall(toolCall)
352 return a.messages.Update(genCtx, *currentAssistant)
353 },
354 OnToolResult: func(result fantasy.ToolResultContent) error {
355 toolResult := a.convertToToolResult(result)
356 _, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
357 Role: message.Tool,
358 Parts: []message.ContentPart{
359 toolResult,
360 },
361 })
362 return createMsgErr
363 },
364 OnStepFinish: func(stepResult fantasy.StepResult) error {
365 finishReason := message.FinishReasonUnknown
366 switch stepResult.FinishReason {
367 case fantasy.FinishReasonLength:
368 finishReason = message.FinishReasonMaxTokens
369 case fantasy.FinishReasonStop:
370 finishReason = message.FinishReasonEndTurn
371 case fantasy.FinishReasonToolCalls:
372 finishReason = message.FinishReasonToolUse
373 }
374 currentAssistant.AddFinish(finishReason, "", "")
375 sessionLock.Lock()
376 updatedSession, getSessionErr := a.sessions.Get(genCtx, call.SessionID)
377 if getSessionErr != nil {
378 sessionLock.Unlock()
379 return getSessionErr
380 }
381 a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
382 _, sessionErr := a.sessions.Save(genCtx, updatedSession)
383 sessionLock.Unlock()
384 if sessionErr != nil {
385 return sessionErr
386 }
387 return a.messages.Update(genCtx, *currentAssistant)
388 },
389 StopWhen: []fantasy.StopCondition{
390 func(_ []fantasy.StepResult) bool {
391 cw := int64(largeModel.CatwalkCfg.ContextWindow)
392 tokens := currentSession.CompletionTokens + currentSession.PromptTokens
393 remaining := cw - tokens
394 var threshold int64
395 if cw > largeContextWindowThreshold {
396 threshold = largeContextWindowBuffer
397 } else {
398 threshold = int64(float64(cw) * smallContextWindowRatio)
399 }
400 if (remaining <= threshold) && !a.disableAutoSummarize {
401 shouldSummarize = true
402 return true
403 }
404 return false
405 },
406 },
407 })
408
409 a.eventPromptResponded(call.SessionID, time.Since(startTime).Truncate(time.Second))
410
411 if err != nil {
412 isCancelErr := errors.Is(err, context.Canceled)
413 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
414 if currentAssistant == nil {
415 return result, err
416 }
417 // Ensure we finish thinking on error to close the reasoning state.
418 currentAssistant.FinishThinking()
419 toolCalls := currentAssistant.ToolCalls()
420 // INFO: we use the parent context here because the genCtx has been cancelled.
421 msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
422 if createErr != nil {
423 return nil, createErr
424 }
425 for _, tc := range toolCalls {
426 if !tc.Finished {
427 tc.Finished = true
428 tc.Input = "{}"
429 currentAssistant.AddToolCall(tc)
430 updateErr := a.messages.Update(ctx, *currentAssistant)
431 if updateErr != nil {
432 return nil, updateErr
433 }
434 }
435
436 found := false
437 for _, msg := range msgs {
438 if msg.Role == message.Tool {
439 for _, tr := range msg.ToolResults() {
440 if tr.ToolCallID == tc.ID {
441 found = true
442 break
443 }
444 }
445 }
446 if found {
447 break
448 }
449 }
450 if found {
451 continue
452 }
453 content := "There was an error while executing the tool"
454 if isCancelErr {
455 content = "Tool execution canceled by user"
456 } else if isPermissionErr {
457 content = "User denied permission"
458 }
459 toolResult := message.ToolResult{
460 ToolCallID: tc.ID,
461 Name: tc.Name,
462 Content: content,
463 IsError: true,
464 }
465 _, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
466 Role: message.Tool,
467 Parts: []message.ContentPart{
468 toolResult,
469 },
470 })
471 if createErr != nil {
472 return nil, createErr
473 }
474 }
475 var fantasyErr *fantasy.Error
476 var providerErr *fantasy.ProviderError
477 const defaultTitle = "Provider Error"
478 linkStyle := lipgloss.NewStyle().Foreground(charmtone.Guac).Underline(true)
479 if isCancelErr {
480 currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
481 } else if isPermissionErr {
482 currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
483 } else if errors.Is(err, hyper.ErrNoCredits) {
484 url := hyper.BaseURL()
485 link := linkStyle.Hyperlink(url, "id=hyper").Render(url)
486 currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
487 } else if errors.As(err, &providerErr) {
488 if providerErr.Message == "The requested model is not supported." {
489 url := "https://github.com/settings/copilot/features"
490 link := linkStyle.Hyperlink(url, "id=copilot").Render(url)
491 currentAssistant.AddFinish(
492 message.FinishReasonError,
493 "Copilot model not enabled",
494 fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link),
495 )
496 } else {
497 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
498 }
499 } else if errors.As(err, &fantasyErr) {
500 currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
501 } else {
502 currentAssistant.AddFinish(message.FinishReasonError, defaultTitle, err.Error())
503 }
504 // Note: we use the parent context here because the genCtx has been
505 // cancelled.
506 updateErr := a.messages.Update(ctx, *currentAssistant)
507 if updateErr != nil {
508 return nil, updateErr
509 }
510 return nil, err
511 }
512
513 if shouldSummarize {
514 a.activeRequests.Del(call.SessionID)
515 if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
516 return nil, summarizeErr
517 }
518 // If the agent wasn't done...
519 if len(currentAssistant.ToolCalls()) > 0 {
520 existing, ok := a.messageQueue.Get(call.SessionID)
521 if !ok {
522 existing = []SessionAgentCall{}
523 }
524 call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
525 existing = append(existing, call)
526 a.messageQueue.Set(call.SessionID, existing)
527 }
528 }
529
530 // Release active request before processing queued messages.
531 a.activeRequests.Del(call.SessionID)
532 cancel()
533
534 queuedMessages, ok := a.messageQueue.Get(call.SessionID)
535 if !ok || len(queuedMessages) == 0 {
536 return result, err
537 }
538 // There are queued messages restart the loop.
539 firstQueuedMessage := queuedMessages[0]
540 a.messageQueue.Set(call.SessionID, queuedMessages[1:])
541 return a.Run(ctx, firstQueuedMessage)
542}
543
544func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
545 if a.IsSessionBusy(sessionID) {
546 return ErrSessionBusy
547 }
548
549 // Copy mutable fields under lock to avoid races with SetModels.
550 largeModel := a.largeModel.Get()
551 systemPromptPrefix := a.systemPromptPrefix.Get()
552
553 currentSession, err := a.sessions.Get(ctx, sessionID)
554 if err != nil {
555 return fmt.Errorf("failed to get session: %w", err)
556 }
557 msgs, err := a.getSessionMessages(ctx, currentSession)
558 if err != nil {
559 return err
560 }
561 if len(msgs) == 0 {
562 // Nothing to summarize.
563 return nil
564 }
565
566 // Save transcript for later search via memory_search tool.
567 if err := a.saveTranscript(ctx, sessionID); err != nil {
568 slog.Warn("failed to save transcript", "error", err)
569 }
570
571 aiMsgs, _ := a.preparePrompt(msgs)
572
573 genCtx, cancel := context.WithCancel(ctx)
574 a.activeRequests.Set(sessionID, cancel)
575 defer a.activeRequests.Del(sessionID)
576 defer cancel()
577
578 agent := fantasy.NewAgent(largeModel.Model,
579 fantasy.WithSystemPrompt(string(summaryPrompt)),
580 )
581 summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
582 Role: message.Assistant,
583 Model: largeModel.Model.Model(),
584 Provider: largeModel.Model.Provider(),
585 IsSummaryMessage: true,
586 })
587 if err != nil {
588 return err
589 }
590
591 summaryPromptText := buildSummaryPrompt(sessionID, currentSession.Todos)
592
593 resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
594 Prompt: summaryPromptText,
595 Messages: aiMsgs,
596 ProviderOptions: opts,
597 PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
598 prepared.Messages = options.Messages
599 if systemPromptPrefix != "" {
600 prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
601 }
602 return callContext, prepared, nil
603 },
604 OnReasoningDelta: func(id string, text string) error {
605 summaryMessage.AppendReasoningContent(text)
606 return a.messages.Update(genCtx, summaryMessage)
607 },
608 OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
609 // Handle anthropic signature.
610 if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
611 if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
612 summaryMessage.AppendReasoningSignature(signature.Signature)
613 }
614 }
615 summaryMessage.FinishThinking()
616 return a.messages.Update(genCtx, summaryMessage)
617 },
618 OnTextDelta: func(id, text string) error {
619 summaryMessage.AppendContent(text)
620 return a.messages.Update(genCtx, summaryMessage)
621 },
622 })
623 if err != nil {
624 isCancelErr := errors.Is(err, context.Canceled)
625 if isCancelErr {
626 // User cancelled summarize we need to remove the summary message.
627 deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
628 return deleteErr
629 }
630 return err
631 }
632
633 summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
634 err = a.messages.Update(genCtx, summaryMessage)
635 if err != nil {
636 return err
637 }
638
639 var openrouterCost *float64
640 for _, step := range resp.Steps {
641 stepCost := a.openrouterCost(step.ProviderMetadata)
642 if stepCost != nil {
643 newCost := *stepCost
644 if openrouterCost != nil {
645 newCost += *openrouterCost
646 }
647 openrouterCost = &newCost
648 }
649 }
650
651 a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost)
652
653 // Just in case, get just the last usage info.
654 usage := resp.Response.Usage
655 currentSession.SummaryMessageID = summaryMessage.ID
656 currentSession.CompletionTokens = usage.OutputTokens
657 currentSession.PromptTokens = 0
658 _, err = a.sessions.Save(genCtx, currentSession)
659 return err
660}
661
662func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
663 if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
664 return fantasy.ProviderOptions{}
665 }
666 return fantasy.ProviderOptions{
667 anthropic.Name: &anthropic.ProviderCacheControlOptions{
668 CacheControl: anthropic.CacheControl{Type: "ephemeral"},
669 },
670 bedrock.Name: &anthropic.ProviderCacheControlOptions{
671 CacheControl: anthropic.CacheControl{Type: "ephemeral"},
672 },
673 }
674}
675
676func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
677 parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
678 var attachmentParts []message.ContentPart
679 for _, attachment := range call.Attachments {
680 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
681 }
682 parts = append(parts, attachmentParts...)
683 msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
684 Role: message.User,
685 Parts: parts,
686 })
687 if err != nil {
688 return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
689 }
690 return msg, nil
691}
692
693func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
694 var history []fantasy.Message
695 hasSummary := false
696 for _, msg := range msgs {
697 if msg.IsSummaryMessage {
698 hasSummary = true
699 break
700 }
701 }
702 if !a.isSubAgent {
703 history = append(history, fantasy.NewUserMessage(
704 fmt.Sprintf("<system_reminder>%s</system_reminder>",
705 `This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware.
706If you are working on tasks that would benefit from a todo list please use the "todos" tool to create one.
707If not, please feel free to ignore. Again do not mention this message to the user.`,
708 ),
709 ))
710 if hasSummary {
711 history = append(history, fantasy.NewUserMessage(
712 fmt.Sprintf("<system_reminder>%s</system_reminder>",
713 `This session was summarized. If you need specific details from before the summary (commands, code, file paths, errors, decisions), use the "memory_search" tool to search the full transcript instead of guessing.`,
714 ),
715 ))
716 }
717 }
718 for _, m := range msgs {
719 if len(m.Parts) == 0 {
720 continue
721 }
722 // Assistant message without content or tool calls (cancelled before it
723 // returned anything).
724 if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
725 continue
726 }
727 history = append(history, m.ToAIMessage()...)
728 }
729
730 var files []fantasy.FilePart
731 for _, attachment := range attachments {
732 if attachment.IsText() {
733 continue
734 }
735 files = append(files, fantasy.FilePart{
736 Filename: attachment.FileName,
737 Data: attachment.Content,
738 MediaType: attachment.MimeType,
739 })
740 }
741
742 return history, files
743}
744
745func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
746 msgs, err := a.messages.List(ctx, session.ID)
747 if err != nil {
748 return nil, fmt.Errorf("failed to list messages: %w", err)
749 }
750
751 if session.SummaryMessageID != "" {
752 summaryMsgIndex := -1
753 for i, msg := range msgs {
754 if msg.ID == session.SummaryMessageID {
755 summaryMsgIndex = i
756 break
757 }
758 }
759 if summaryMsgIndex != -1 {
760 msgs = msgs[summaryMsgIndex:]
761 msgs[0].Role = message.User
762 }
763 }
764 return msgs, nil
765}
766
767// generateTitle generates a session titled based on the initial prompt.
768func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, userPrompt string) {
769 if userPrompt == "" {
770 return
771 }
772
773 smallModel := a.smallModel.Get()
774 largeModel := a.largeModel.Get()
775 systemPromptPrefix := a.systemPromptPrefix.Get()
776
777 var maxOutputTokens int64 = 40
778 if smallModel.CatwalkCfg.CanReason {
779 maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
780 }
781
782 newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
783 return fantasy.NewAgent(m,
784 fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
785 fantasy.WithMaxOutputTokens(tok),
786 )
787 }
788
789 streamCall := fantasy.AgentStreamCall{
790 Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
791 PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
792 prepared.Messages = opts.Messages
793 if systemPromptPrefix != "" {
794 prepared.Messages = append([]fantasy.Message{
795 fantasy.NewSystemMessage(systemPromptPrefix),
796 }, prepared.Messages...)
797 }
798 return callCtx, prepared, nil
799 },
800 }
801
802 // Use the small model to generate the title.
803 model := smallModel
804 agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
805 resp, err := agent.Stream(ctx, streamCall)
806 if err == nil {
807 // We successfully generated a title with the small model.
808 slog.Info("generated title with small model")
809 } else {
810 // It didn't work. Let's try with the big model.
811 slog.Error("error generating title with small model; trying big model", "err", err)
812 model = largeModel
813 agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
814 resp, err = agent.Stream(ctx, streamCall)
815 if err == nil {
816 slog.Info("generated title with large model")
817 } else {
818 // Welp, the large model didn't work either. Use the default
819 // session name and return.
820 slog.Error("error generating title with large model", "err", err)
821 saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
822 if saveErr != nil {
823 slog.Error("failed to save session title and usage", "error", saveErr)
824 }
825 return
826 }
827 }
828
829 if resp == nil {
830 // Actually, we didn't get a response so we can't. Use the default
831 // session name and return.
832 slog.Error("response is nil; can't generate title")
833 saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
834 if saveErr != nil {
835 slog.Error("failed to save session title and usage", "error", saveErr)
836 }
837 return
838 }
839
840 // Clean up title.
841 var title string
842 title = strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ")
843
844 // Remove thinking tags if present.
845 title = thinkTagRegex.ReplaceAllString(title, "")
846
847 title = strings.TrimSpace(title)
848 if title == "" {
849 slog.Warn("empty title; using fallback")
850 title = defaultSessionName
851 }
852
853 // Calculate usage and cost.
854 var openrouterCost *float64
855 for _, step := range resp.Steps {
856 stepCost := a.openrouterCost(step.ProviderMetadata)
857 if stepCost != nil {
858 newCost := *stepCost
859 if openrouterCost != nil {
860 newCost += *openrouterCost
861 }
862 openrouterCost = &newCost
863 }
864 }
865
866 modelConfig := model.CatwalkCfg
867 cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
868 modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
869 modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
870 modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
871
872 // Use override cost if available (e.g., from OpenRouter).
873 if openrouterCost != nil {
874 cost = *openrouterCost
875 }
876
877 promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens
878 completionTokens := resp.TotalUsage.OutputTokens + resp.TotalUsage.CacheReadTokens
879
880 // Atomically update only title and usage fields to avoid overriding other
881 // concurrent session updates.
882 saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
883 if saveErr != nil {
884 slog.Error("failed to save session title and usage", "error", saveErr)
885 return
886 }
887}
888
889func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float64 {
890 openrouterMetadata, ok := metadata[openrouter.Name]
891 if !ok {
892 return nil
893 }
894
895 opts, ok := openrouterMetadata.(*openrouter.ProviderMetadata)
896 if !ok {
897 return nil
898 }
899 return &opts.Usage.Cost
900}
901
902func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
903 modelConfig := model.CatwalkCfg
904 cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
905 modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
906 modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
907 modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
908
909 a.eventTokensUsed(session.ID, model, usage, cost)
910
911 if overrideCost != nil {
912 session.Cost += *overrideCost
913 } else {
914 session.Cost += cost
915 }
916
917 session.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
918 session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
919}
920
921func (a *sessionAgent) Cancel(sessionID string) {
922 // Cancel regular requests. Don't use Take() here - we need the entry to
923 // remain in activeRequests so IsBusy() returns true until the goroutine
924 // fully completes (including error handling that may access the DB).
925 // The defer in processRequest will clean up the entry.
926 if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
927 slog.Info("Request cancellation initiated", "session_id", sessionID)
928 cancel()
929 }
930
931 // Also check for summarize requests.
932 if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
933 slog.Info("Summarize cancellation initiated", "session_id", sessionID)
934 cancel()
935 }
936
937 if a.QueuedPrompts(sessionID) > 0 {
938 slog.Info("Clearing queued prompts", "session_id", sessionID)
939 a.messageQueue.Del(sessionID)
940 }
941}
942
943func (a *sessionAgent) ClearQueue(sessionID string) {
944 if a.QueuedPrompts(sessionID) > 0 {
945 slog.Info("Clearing queued prompts", "session_id", sessionID)
946 a.messageQueue.Del(sessionID)
947 }
948}
949
950func (a *sessionAgent) CancelAll() {
951 if !a.IsBusy() {
952 return
953 }
954 for key := range a.activeRequests.Seq2() {
955 a.Cancel(key) // key is sessionID
956 }
957
958 timeout := time.After(5 * time.Second)
959 for a.IsBusy() {
960 select {
961 case <-timeout:
962 return
963 default:
964 time.Sleep(200 * time.Millisecond)
965 }
966 }
967}
968
969func (a *sessionAgent) IsBusy() bool {
970 var busy bool
971 for cancelFunc := range a.activeRequests.Seq() {
972 if cancelFunc != nil {
973 busy = true
974 break
975 }
976 }
977 return busy
978}
979
980func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
981 _, busy := a.activeRequests.Get(sessionID)
982 return busy
983}
984
985func (a *sessionAgent) QueuedPrompts(sessionID string) int {
986 l, ok := a.messageQueue.Get(sessionID)
987 if !ok {
988 return 0
989 }
990 return len(l)
991}
992
993func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
994 l, ok := a.messageQueue.Get(sessionID)
995 if !ok {
996 return nil
997 }
998 prompts := make([]string, len(l))
999 for i, call := range l {
1000 prompts[i] = call.Prompt
1001 }
1002 return prompts
1003}
1004
1005func (a *sessionAgent) SetModels(large Model, small Model) {
1006 a.largeModel.Set(large)
1007 a.smallModel.Set(small)
1008}
1009
1010func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
1011 a.tools.SetSlice(tools)
1012}
1013
1014func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
1015 a.systemPrompt.Set(systemPrompt)
1016}
1017
1018func (a *sessionAgent) Model() Model {
1019 return a.largeModel.Get()
1020}
1021
1022// convertToToolResult converts a fantasy tool result to a message tool result.
1023func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
1024 baseResult := message.ToolResult{
1025 ToolCallID: result.ToolCallID,
1026 Name: result.ToolName,
1027 Metadata: result.ClientMetadata,
1028 }
1029
1030 switch result.Result.GetType() {
1031 case fantasy.ToolResultContentTypeText:
1032 if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result); ok {
1033 baseResult.Content = r.Text
1034 }
1035 case fantasy.ToolResultContentTypeError:
1036 if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result); ok {
1037 baseResult.Content = r.Error.Error()
1038 baseResult.IsError = true
1039 }
1040 case fantasy.ToolResultContentTypeMedia:
1041 if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Result); ok {
1042 content := r.Text
1043 if content == "" {
1044 content = fmt.Sprintf("Loaded %s content", r.MediaType)
1045 }
1046 baseResult.Content = content
1047 baseResult.Data = r.Data
1048 baseResult.MIMEType = r.MediaType
1049 }
1050 }
1051
1052 return baseResult
1053}
1054
1055// workaroundProviderMediaLimitations converts media content in tool results to
1056// user messages for providers that don't natively support images in tool results.
1057//
1058// Problem: OpenAI, Google, OpenRouter, and other OpenAI-compatible providers
1059// don't support sending images/media in tool result messages - they only accept
1060// text in tool results. However, they DO support images in user messages.
1061//
1062// If we send media in tool results to these providers, the API returns an error.
1063//
1064// Solution: For these providers, we:
1065// 1. Replace the media in the tool result with a text placeholder
1066// 2. Inject a user message immediately after with the image as a file attachment
1067// 3. This maintains the tool execution flow while working around API limitations
1068//
1069// Anthropic and Bedrock support images natively in tool results, so we skip
1070// this workaround for them.
1071//
1072// Example transformation:
1073//
1074// BEFORE: [tool result: image data]
1075// AFTER: [tool result: "Image loaded - see attached"], [user: image attachment]
1076func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
1077 providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1078 largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1079
1080 if providerSupportsMedia {
1081 return messages
1082 }
1083
1084 convertedMessages := make([]fantasy.Message, 0, len(messages))
1085
1086 for _, msg := range messages {
1087 if msg.Role != fantasy.MessageRoleTool {
1088 convertedMessages = append(convertedMessages, msg)
1089 continue
1090 }
1091
1092 textParts := make([]fantasy.MessagePart, 0, len(msg.Content))
1093 var mediaFiles []fantasy.FilePart
1094
1095 for _, part := range msg.Content {
1096 toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
1097 if !ok {
1098 textParts = append(textParts, part)
1099 continue
1100 }
1101
1102 if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
1103 decoded, err := base64.StdEncoding.DecodeString(media.Data)
1104 if err != nil {
1105 slog.Warn("failed to decode media data", "error", err)
1106 textParts = append(textParts, part)
1107 continue
1108 }
1109
1110 mediaFiles = append(mediaFiles, fantasy.FilePart{
1111 Data: decoded,
1112 MediaType: media.MediaType,
1113 Filename: fmt.Sprintf("tool-result-%s", toolResult.ToolCallID),
1114 })
1115
1116 textParts = append(textParts, fantasy.ToolResultPart{
1117 ToolCallID: toolResult.ToolCallID,
1118 Output: fantasy.ToolResultOutputContentText{
1119 Text: "[Image/media content loaded - see attached file]",
1120 },
1121 ProviderOptions: toolResult.ProviderOptions,
1122 })
1123 } else {
1124 textParts = append(textParts, part)
1125 }
1126 }
1127
1128 convertedMessages = append(convertedMessages, fantasy.Message{
1129 Role: fantasy.MessageRoleTool,
1130 Content: textParts,
1131 })
1132
1133 if len(mediaFiles) > 0 {
1134 convertedMessages = append(convertedMessages, fantasy.NewUserMessage(
1135 "Here is the media content from the tool result:",
1136 mediaFiles...,
1137 ))
1138 }
1139 }
1140
1141 return convertedMessages
1142}
1143
1144// buildSummaryPrompt constructs the prompt text for session summarization.
1145func buildSummaryPrompt(sessionID string, todos []session.Todo) string {
1146 var sb strings.Builder
1147 sb.WriteString("Provide a detailed summary of our conversation above.")
1148
1149 // Include transcript path for memory search.
1150 transcriptPath := TranscriptPath(sessionID)
1151 sb.WriteString("\n\n## Session Transcript\n\n")
1152 sb.WriteString(fmt.Sprintf("The full conversation transcript has been saved to: `%s`\n", transcriptPath))
1153 sb.WriteString("The resuming assistant can use the `memory_search` tool to search this transcript for specific details from the conversation.\n")
1154
1155 if len(todos) > 0 {
1156 sb.WriteString("\n\n## Current Todo List\n\n")
1157 for _, t := range todos {
1158 fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content)
1159 }
1160 sb.WriteString("\nInclude these tasks and their statuses in your summary. ")
1161 sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.")
1162 }
1163 return sb.String()
1164}
1165
1166// serializeTranscript converts a slice of messages to a searchable markdown
1167// transcript format. The transcript includes user messages, assistant
1168// responses, tool calls, tool results, and reasoning content.
1169func serializeTranscript(msgs []message.Message) string {
1170 var sb strings.Builder
1171 sb.WriteString("# Session Transcript\n\n")
1172
1173 for _, msg := range msgs {
1174 roleHeader := "Message"
1175 switch msg.Role {
1176 case message.User:
1177 roleHeader = "User"
1178 case message.Assistant:
1179 roleHeader = "Assistant"
1180 case message.Tool:
1181 roleHeader = "Tool Results"
1182 }
1183 sb.WriteString(fmt.Sprintf("## %s\n\n", roleHeader))
1184
1185 switch msg.Role {
1186 case message.User:
1187 if text := msg.Content().Text; text != "" {
1188 sb.WriteString("### Content\n\n")
1189 sb.WriteString(text)
1190 sb.WriteString("\n\n")
1191 }
1192 // Include binary content paths.
1193 attachments := msg.BinaryContent()
1194 if len(attachments) > 0 {
1195 sb.WriteString("### Attachments\n\n")
1196 for _, bc := range attachments {
1197 sb.WriteString(fmt.Sprintf("- %s (%s)\n", bc.Path, bc.MIMEType))
1198 }
1199 sb.WriteString("\n")
1200 }
1201
1202 case message.Assistant:
1203 if msg.Model != "" {
1204 sb.WriteString(fmt.Sprintf("**Model:** %s (%s)\n", msg.Model, msg.Provider))
1205 }
1206 sb.WriteString("\n")
1207
1208 // Reasoning content.
1209 if reasoning := msg.ReasoningContent(); reasoning.Thinking != "" {
1210 sb.WriteString("### Reasoning\n\n")
1211 sb.WriteString("<thinking>\n")
1212 sb.WriteString(reasoning.Thinking)
1213 sb.WriteString("\n</thinking>\n\n")
1214 }
1215
1216 // Text content.
1217 if text := msg.Content().Text; text != "" {
1218 sb.WriteString("### Response\n\n")
1219 sb.WriteString(text)
1220 sb.WriteString("\n\n")
1221 }
1222
1223 // Tool calls.
1224 toolCalls := msg.ToolCalls()
1225 if len(toolCalls) > 0 {
1226 sb.WriteString("### Tool Calls\n\n")
1227 for _, tc := range toolCalls {
1228 sb.WriteString("#### Tool Call\n\n")
1229 sb.WriteString(fmt.Sprintf("**Tool:** `%s`\n\n", tc.Name))
1230 sb.WriteString("**Input:**\n\n")
1231 sb.WriteString("```json\n")
1232 sb.WriteString(tc.Input)
1233 sb.WriteString("\n```\n\n")
1234 }
1235 }
1236
1237 case message.Tool:
1238 for _, tr := range msg.ToolResults() {
1239 sb.WriteString("#### Tool Result\n\n")
1240 sb.WriteString(fmt.Sprintf("**Tool:** `%s`\n", tr.Name))
1241 if tr.IsError {
1242 sb.WriteString("**Status:** Error\n")
1243 } else {
1244 sb.WriteString("**Status:** Success\n")
1245 }
1246 // Truncate very long tool results.
1247 content := tr.Content
1248 const maxToolResultLen = 10000
1249 if len(content) > maxToolResultLen {
1250 content = content[:maxToolResultLen] + "\n... (truncated)"
1251 sb.WriteString("**Output:** (truncated)\n\n")
1252 } else {
1253 sb.WriteString("**Output:**\n\n")
1254 }
1255 sb.WriteString("```\n")
1256 sb.WriteString(content)
1257 sb.WriteString("\n```\n\n")
1258 }
1259 }
1260
1261 sb.WriteString("---\n\n")
1262 }
1263
1264 return sb.String()
1265}
1266
1267// saveTranscript serializes messages to a markdown file for later search.
1268func (a *sessionAgent) saveTranscript(ctx context.Context, sessionID string) error {
1269 msgs, err := a.messages.List(ctx, sessionID)
1270 if err != nil {
1271 return fmt.Errorf("failed to list messages: %w", err)
1272 }
1273 cfg := config.Get()
1274 transcriptsDir := filepath.Join(cfg.Options.DataDirectory, "transcripts")
1275 if err := os.MkdirAll(transcriptsDir, 0o755); err != nil {
1276 return fmt.Errorf("failed to create transcripts directory: %w", err)
1277 }
1278
1279 transcriptPath := filepath.Join(transcriptsDir, sessionID+".md")
1280 transcript := serializeTranscript(msgs)
1281 if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
1282 return fmt.Errorf("failed to write transcript: %w", err)
1283 }
1284
1285 slog.Debug("saved transcript", "path", transcriptPath, "messages", len(msgs))
1286 return nil
1287}
1288
1289// TranscriptPath returns the path where a session's transcript would be saved.
1290func TranscriptPath(sessionID string) string {
1291 cfg := config.Get()
1292 return filepath.Join(cfg.Options.DataDirectory, "transcripts", sessionID+".md")
1293}