Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions internal/engine/title.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package engine

import (
"context"
"fmt"
"strings"
"time"

"github.com/GrayCodeAI/hawk/internal/types"
)

// GenerateTitle derives a concise, descriptive title for the session.
// It attempts LLM-based summarization (3–6 words) on the initial conversation turns,
// falling back to the deterministic JournalTitle if LLM titling is unavailable or fails.
func (s *Session) GenerateTitle(ctx context.Context) (string, error) {
if s == nil {
return "Untitled Session", nil
}

titleCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

if j := s.Persistence().Journal(); j != nil && s.ChatLLM() != nil {
j.AppendSessionTitleLLMRequest(s.ChatLLM().Model())
}

title, err := s.generateTitleLLM(titleCtx)
if err == nil && title != "" {
if j := s.Persistence().Journal(); j != nil {
j.AppendSessionTitle(title)
}
return title, nil
}

// Deterministic fallback
detTitle := s.JournalTitle()
if j := s.Persistence().Journal(); j != nil {
j.AppendSessionTitle(detTitle)
}
return detTitle, nil
}

func (s *Session) generateTitleLLM(ctx context.Context) (string, error) {
msgs := s.Persistence().Messages()
if len(msgs) == 0 {
return "", fmt.Errorf("no messages to generate title from")
}

// Collect first user message and optional assistant reply
var userMsg, assistantMsg string
for _, m := range msgs {
if m.Role == "user" && userMsg == "" {
userMsg = m.Content
} else if m.Role == "assistant" && userMsg != "" && assistantMsg == "" {
assistantMsg = m.Content
break
}
}

if userMsg == "" {
return "", fmt.Errorf("no user message found")
}

if len(userMsg) > 500 {
userMsg = userMsg[:500]
}
if len(assistantMsg) > 500 {
assistantMsg = assistantMsg[:500]
}

titlePrompt := fmt.Sprintf(
"Generate a concise 3 to 6 word title summarizing the user's intent in this session. Return ONLY the title text, with no quotes, no markdown, and no trailing period.\n\nUser request: %s",
userMsg,
)
if assistantMsg != "" {
titlePrompt += fmt.Sprintf("\nAssistant response summary: %s", assistantMsg)
}

llm := s.ChatLLM()
if llm == nil {
return "", fmt.Errorf("no chat client available")
}

// Fast streaming call for title generation
reqMsgs := []types.EyrieMessage{
{
Role: "user",
Content: titlePrompt,
},
}

result, err := llm.Stream(ctx, reqMsgs, types.ChatOptions{})
if err != nil {
return "", err
}
defer result.Close()

var sb strings.Builder
for ev := range result.Events {
if ev.Type == "content" {
sb.WriteString(ev.Content)
}
}

raw := strings.TrimSpace(sb.String())
cleaned := sanitizeTitle(raw)
if cleaned == "" {
return "", fmt.Errorf("empty title returned by LLM")
}

return cleaned, nil
}

func sanitizeTitle(s string) string {
s = strings.TrimSpace(s)
s = strings.Trim(s, `"'`+"`")
s = strings.TrimPrefix(s, "Title:")
s = strings.TrimPrefix(s, "title:")
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, ".")
if len(s) > 80 {
s = s[:80]
}
return s
}
62 changes: 62 additions & 0 deletions internal/engine/title_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package engine

import (
"context"
"testing"

"github.com/GrayCodeAI/hawk/internal/eventlog"
"github.com/GrayCodeAI/hawk/internal/tool"
)

func TestSession_SanitizeTitle(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: ` "Fix authentication bug" `, want: "Fix authentication bug"},
{input: `Title: Refactor database schema.`, want: "Refactor database schema"},
{input: `title: Implement dark mode`, want: "Implement dark mode"},
{input: "`Add telemetry exporter`", want: "Add telemetry exporter"},
{input: "A very long title that exceeds the maximum allowable title length and should be truncated properly so it does not overflow UI headers or metadata fields", want: "A very long title that exceeds the maximum allowable title length and should be "},
}

for _, tc := range tests {
got := sanitizeTitle(tc.input)
if got != tc.want {
t.Errorf("sanitizeTitle(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}

func TestSession_GenerateTitle_DeterministicFallback(t *testing.T) {
reg := tool.NewRegistry()
sess := NewSession("", "", "System prompt", reg)

sess.AddUser("Fix memory leak in websocket listener")

title, err := sess.GenerateTitle(context.Background())
if err != nil {
t.Fatalf("GenerateTitle failed: %v", err)
}

if title != "Fix memory leak in websocket listener" {
t.Errorf("GenerateTitle() = %q, want 'Fix memory leak in websocket listener'", title)
}

// Verify title event in journal
j := sess.Persistence().Journal()
if j != nil {
var found bool
for _, ev := range j.Snapshot() {
if ev.Type == eventlog.SessionTitle {
if f, ok := ev.Data.(eventlog.SessionTitleFact); ok && f.Title == title {
found = true
break
}
}
}
if !found {
t.Errorf("expected SessionTitleFact with %q in journal", title)
}
}
}
Loading
Loading