diff --git a/internal/engine/title.go b/internal/engine/title.go new file mode 100644 index 00000000..cc65b783 --- /dev/null +++ b/internal/engine/title.go @@ -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 +} diff --git a/internal/engine/title_test.go b/internal/engine/title_test.go new file mode 100644 index 00000000..1f47553d --- /dev/null +++ b/internal/engine/title_test.go @@ -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) + } + } +} diff --git a/internal/preset/preset.go b/internal/preset/preset.go new file mode 100644 index 00000000..b0bca647 --- /dev/null +++ b/internal/preset/preset.go @@ -0,0 +1,258 @@ +// Package preset implements agent preset configurations (DSH preset/agent-presets parity). +package preset + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// Source represents where a preset was loaded from. +type Source string + +const ( + SourceBuiltin Source = "builtin" + SourceUser Source = "user" + SourceProject Source = "project" +) + +// Preset defines a reusable agent configuration manifest. +type Preset struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + SystemPrompt string `json:"system_prompt,omitempty" yaml:"system_prompt,omitempty"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` + SubagentType string `json:"subagent_type,omitempty" yaml:"subagent_type,omitempty"` + CapabilityMode string `json:"capability_mode,omitempty" yaml:"capability_mode,omitempty"` + SandboxMode string `json:"sandbox_mode,omitempty" yaml:"sandbox_mode,omitempty"` + Tools []string `json:"tools,omitempty" yaml:"tools,omitempty"` + DenyTools []string `json:"deny_tools,omitempty" yaml:"deny_tools,omitempty"` + Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` + Source Source `json:"source,omitempty" yaml:"source,omitempty"` +} + +// Registry manages loaded agent presets with scope-based precedence (project > user > builtin). +type Registry struct { + mu sync.RWMutex + presets map[string]Preset +} + +var ( + defaultRegistry *Registry + defaultRegistryOnce sync.Once +) + +// Default returns the process-wide preset registry loaded with built-ins. +func Default() *Registry { + defaultRegistryOnce.Do(func() { + defaultRegistry = NewRegistry() + defaultRegistry.RegisterBuiltins() + }) + return defaultRegistry +} + +// NewRegistry creates a new empty preset registry. +func NewRegistry() *Registry { + return &Registry{ + presets: make(map[string]Preset), + } +} + +// Register registers a preset. Project presets override user presets which override builtins. +func (r *Registry) Register(p Preset) error { + if p.Name == "" { + return fmt.Errorf("preset name cannot be empty") + } + r.mu.Lock() + defer r.mu.Unlock() + + normName := strings.ToLower(strings.TrimSpace(p.Name)) + p.Name = normName + + if existing, ok := r.presets[normName]; ok { + // Respect precedence: project > user > builtin + if shouldOverride(existing.Source, p.Source) { + r.presets[normName] = p + } + return nil + } + + r.presets[normName] = p + return nil +} + +// Get looks up a preset by name. +func (r *Registry) Get(name string) (Preset, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + normName := strings.ToLower(strings.TrimSpace(name)) + p, ok := r.presets[normName] + return p, ok +} + +// List returns all registered presets. +func (r *Registry) List() []Preset { + r.mu.RLock() + defer r.mu.RUnlock() + + list := make([]Preset, 0, len(r.presets)) + for _, p := range r.presets { + list = append(list, p) + } + return list +} + +// RegisterBuiltins registers standard core agent presets. +func (r *Registry) RegisterBuiltins() { + builtins := []Preset{ + { + Name: "code-reviewer", + Description: "Reviews code changes for security, style, and correctness", + SubagentType: "explore", + CapabilityMode: "read-only", + SandboxMode: "strict", + SystemPrompt: "You are an expert code reviewer. Analyze changes carefully for correctness, edge cases, performance, and security.", + Tools: []string{"Read", "Grep", "Glob", "LS"}, + DenyTools: []string{"Write", "Patch", "DeleteFile"}, + Source: SourceBuiltin, + }, + { + Name: "security-auditor", + Description: "Audits codebase for secrets, vulnerabilities, and risky patterns", + SubagentType: "explore", + CapabilityMode: "read-only", + SandboxMode: "strict", + SystemPrompt: "You are a cybersecurity auditor. Scan the codebase for hardcoded credentials, injection risks, and insecure configurations.", + Tools: []string{"Read", "Grep", "Glob", "LS", "CodeSearch"}, + DenyTools: []string{"Write", "Patch", "Bash"}, + Source: SourceBuiltin, + }, + { + Name: "architect", + Description: "System architecture and implementation planning specialist", + SubagentType: "plan", + CapabilityMode: "read-only", + SandboxMode: "workspace", + SystemPrompt: "You are a senior software architect. Analyze requirements, assess system structure, and produce structured implementation plans.", + Tools: []string{"Read", "Grep", "Glob", "LS", "CodeSearch"}, + DenyTools: []string{"Write", "DeleteFile"}, + Source: SourceBuiltin, + }, + { + Name: "pair-programmer", + Description: "General interactive assistant with full coding capabilities", + SubagentType: "general-purpose", + CapabilityMode: "all", + SandboxMode: "workspace", + SystemPrompt: "You are a collaborative pair programmer. Write high-quality, verified code following project conventions.", + Source: SourceBuiltin, + }, + { + Name: "debugger", + Description: "Root-cause diagnostic specialist for test and runtime failures", + SubagentType: "general-purpose", + CapabilityMode: "read-write", + SandboxMode: "workspace", + SystemPrompt: "You are a diagnostic debugging specialist. Investigate failures, inspect logs and call traces, and craft precise fixes.", + Source: SourceBuiltin, + }, + } + + for _, p := range builtins { + _ = r.Register(p) + } +} + +// LoadFromDir scans a directory for JSON/YAML preset definitions. +func (r *Registry) LoadFromDir(dir string, source Source) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if ext != ".json" && ext != ".yaml" && ext != ".yml" { + continue + } + + path := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var p Preset + if ext == ".json" { + if err := json.Unmarshal(data, &p); err != nil { + continue + } + } else { + // Minimal fallback for simple YAML + if err := parseSimpleYAML(data, &p); err != nil { + continue + } + } + + if p.Name == "" { + p.Name = strings.TrimSuffix(entry.Name(), ext) + } + p.Source = source + _ = r.Register(p) + } + + return nil +} + +func shouldOverride(current, candidate Source) bool { + weight := map[Source]int{ + SourceBuiltin: 1, + SourceUser: 2, + SourceProject: 3, + } + return weight[candidate] >= weight[current] +} + +func parseSimpleYAML(data []byte, p *Preset) error { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(parts[0])) + val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) + + switch key { + case "name": + p.Name = val + case "description": + p.Description = val + case "system_prompt": + p.SystemPrompt = val + case "model": + p.Model = val + case "subagent_type": + p.SubagentType = val + case "capability_mode": + p.CapabilityMode = val + case "sandbox_mode": + p.SandboxMode = val + } + } + return nil +} diff --git a/internal/preset/preset_test.go b/internal/preset/preset_test.go new file mode 100644 index 00000000..d8b11cc2 --- /dev/null +++ b/internal/preset/preset_test.go @@ -0,0 +1,127 @@ +package preset + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPreset_Builtins(t *testing.T) { + reg := Default() + + p, ok := reg.Get("code-reviewer") + if !ok { + t.Fatal("expected code-reviewer builtin preset") + } + if p.SubagentType != "explore" { + t.Errorf("SubagentType = %q, want explore", p.SubagentType) + } + if p.CapabilityMode != "read-only" { + t.Errorf("CapabilityMode = %q, want read-only", p.CapabilityMode) + } + if p.SandboxMode != "strict" { + t.Errorf("SandboxMode = %q, want strict", p.SandboxMode) + } + + p2, ok := reg.Get("architect") + if !ok { + t.Fatal("expected architect builtin preset") + } + if p2.SubagentType != "plan" { + t.Errorf("SubagentType = %q, want plan", p2.SubagentType) + } + + list := reg.List() + if len(list) < 5 { + t.Errorf("expected at least 5 builtin presets, got %d", len(list)) + } +} + +func TestPreset_PrecedenceOverride(t *testing.T) { + reg := NewRegistry() + + // 1. Builtin + _ = reg.Register(Preset{ + Name: "custom-agent", + Description: "Builtin version", + Source: SourceBuiltin, + }) + + p, _ := reg.Get("custom-agent") + if p.Description != "Builtin version" { + t.Errorf("got %q, want Builtin version", p.Description) + } + + // 2. User overrides Builtin + _ = reg.Register(Preset{ + Name: "custom-agent", + Description: "User version", + Source: SourceUser, + }) + p, _ = reg.Get("custom-agent") + if p.Description != "User version" { + t.Errorf("got %q, want User version", p.Description) + } + + // 3. Project overrides User + _ = reg.Register(Preset{ + Name: "custom-agent", + Description: "Project version", + Source: SourceProject, + }) + p, _ = reg.Get("custom-agent") + if p.Description != "Project version" { + t.Errorf("got %q, want Project version", p.Description) + } + + // 4. Builtin cannot downgrade Project + _ = reg.Register(Preset{ + Name: "custom-agent", + Description: "Builtin attempt", + Source: SourceBuiltin, + }) + p, _ = reg.Get("custom-agent") + if p.Description != "Project version" { + t.Errorf("got %q, want Project version", p.Description) + } +} + +func TestPreset_LoadFromDir(t *testing.T) { + tempDir := t.TempDir() + + // Write a JSON preset + jsonContent := `{ + "name": "json-specialist", + "description": "JSON specialist description", + "subagent_type": "explore", + "capability_mode": "read-only" + }` + if err := os.WriteFile(filepath.Join(tempDir, "specialist.json"), []byte(jsonContent), 0o600); err != nil { + t.Fatal(err) + } + + // Write a YAML preset + yamlContent := `name: yaml-specialist +description: YAML specialist description +subagent_type: plan +capability_mode: read-only +` + if err := os.WriteFile(filepath.Join(tempDir, "specialist.yaml"), []byte(yamlContent), 0o600); err != nil { + t.Fatal(err) + } + + reg := NewRegistry() + if err := reg.LoadFromDir(tempDir, SourceProject); err != nil { + t.Fatalf("LoadFromDir failed: %v", err) + } + + pJSON, ok := reg.Get("json-specialist") + if !ok || pJSON.Description != "JSON specialist description" { + t.Errorf("json-specialist = %+v", pJSON) + } + + pYaml, ok := reg.Get("yaml-specialist") + if !ok || pYaml.Description != "YAML specialist description" { + t.Errorf("yaml-specialist = %+v", pYaml) + } +}