Skip to content

Commit 339276d

Browse files
committed
feat: add core CLI features — stats, session export, pr, enhanced websearch, daemon API
- hawk stats: usage analytics with --days, --top, --format, --models flags - hawk sessions export: JSON/Markdown export with secret redaction - hawk pr: review/create/describe subcommands wrapping gh CLI - WebSearch: multi-backend (Brave → SearXNG → DuckDuckGo fallback) - Daemon API: GET /v1/sessions/{id}, messages, DELETE, GET /v1/stats
1 parent f3a21e3 commit 339276d

11 files changed

Lines changed: 1890 additions & 14 deletions

File tree

cmd/pr.go

Lines changed: 718 additions & 0 deletions
Large diffs are not rendered by default.

cmd/session_export.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/GrayCodeAI/hawk/session"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var (
12+
exportFormat string
13+
exportRedact bool
14+
exportOutput string
15+
)
16+
17+
var sessionExportCmd = &cobra.Command{
18+
Use: "export [session-id]",
19+
Short: "Export a session as JSON or Markdown",
20+
Args: cobra.MaximumNArgs(1),
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
var s *session.Session
23+
var err error
24+
25+
if len(args) > 0 {
26+
s, err = session.Load(args[0])
27+
} else {
28+
s, err = session.LoadLatest()
29+
}
30+
if err != nil {
31+
return fmt.Errorf("load session: %w", err)
32+
}
33+
34+
data, err := session.Export(s, exportFormat, exportRedact)
35+
if err != nil {
36+
return fmt.Errorf("export session: %w", err)
37+
}
38+
39+
if exportOutput == "" {
40+
cmd.Print(string(data))
41+
return nil
42+
}
43+
44+
if err := os.WriteFile(exportOutput, data, 0o644); err != nil {
45+
return fmt.Errorf("write output file: %w", err)
46+
}
47+
cmd.Printf("Session exported to %s\n", exportOutput)
48+
return nil
49+
},
50+
}
51+
52+
func init() {
53+
sessionExportCmd.Flags().StringVar(&exportFormat, "format", "json", "Output format (json|md)")
54+
sessionExportCmd.Flags().BoolVar(&exportRedact, "redact", true, "Redact secrets from output")
55+
sessionExportCmd.Flags().StringVar(&exportOutput, "output", "", "Output file path (default: stdout)")
56+
sessionsCmd.AddCommand(sessionExportCmd)
57+
}

cmd/stats.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"math"
7+
"sort"
8+
"strings"
9+
"time"
10+
11+
"github.com/GrayCodeAI/hawk/analytics"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var (
16+
statsDays int
17+
statsTop int
18+
statsFormat string
19+
statsModels bool
20+
)
21+
22+
var statsCmd = &cobra.Command{
23+
Use: "stats",
24+
Short: "Show usage statistics and cost analytics",
25+
Long: `Display aggregated usage statistics from session traces including
26+
session counts, message totals, cost breakdowns, model usage, and tool
27+
call frequency over a configurable time window.`,
28+
RunE: runStats,
29+
}
30+
31+
func init() {
32+
statsCmd.Flags().IntVar(&statsDays, "days", 30, "number of days to include in statistics")
33+
statsCmd.Flags().IntVar(&statsTop, "top", 10, "number of top tools to display")
34+
statsCmd.Flags().StringVar(&statsFormat, "format", "text", "output format: text or json")
35+
statsCmd.Flags().BoolVar(&statsModels, "models", false, "show per-model breakdown")
36+
rootCmd.AddCommand(statsCmd)
37+
}
38+
39+
type statsOutput struct {
40+
Period string `json:"period"`
41+
TotalSessions int `json:"total_sessions"`
42+
TotalMessages int `json:"total_messages"`
43+
TotalToolCalls int `json:"total_tool_calls"`
44+
TotalCostUSD float64 `json:"total_cost_usd"`
45+
ActiveDays int `json:"active_days"`
46+
AvgCostPerSession float64 `json:"avg_cost_per_session"`
47+
AvgCostPerDay float64 `json:"avg_cost_per_day"`
48+
Models map[string]modelStat `json:"models,omitempty"`
49+
TopTools []toolStat `json:"top_tools,omitempty"`
50+
DailyBreakdown []dayStat `json:"daily_breakdown,omitempty"`
51+
}
52+
53+
type modelStat struct {
54+
Requests int `json:"requests"`
55+
Messages int `json:"messages"`
56+
CostUSD float64 `json:"cost_usd"`
57+
}
58+
59+
type toolStat struct {
60+
Name string `json:"name"`
61+
Count int `json:"count"`
62+
}
63+
64+
type dayStat struct {
65+
Date string `json:"date"`
66+
Sessions int `json:"sessions"`
67+
Cost float64 `json:"cost"`
68+
}
69+
70+
func runStats(cmd *cobra.Command, args []string) error {
71+
traces, err := analytics.GetTraces()
72+
if err != nil {
73+
return fmt.Errorf("loading traces: %w", err)
74+
}
75+
76+
cutoff := time.Now().AddDate(0, 0, -statsDays)
77+
78+
// Filter traces by date
79+
var filtered []*analytics.SessionTrace
80+
for _, t := range traces {
81+
if t.StartTime.After(cutoff) {
82+
filtered = append(filtered, t)
83+
}
84+
}
85+
86+
if len(filtered) == 0 {
87+
cmd.Println("No session data found for the specified time period.")
88+
cmd.Println("Sessions are recorded automatically when you use hawk.")
89+
return nil
90+
}
91+
92+
// Aggregate statistics
93+
output := aggregateStats(filtered, statsDays)
94+
95+
if statsFormat == "json" {
96+
data, err := json.MarshalIndent(output, "", " ")
97+
if err != nil {
98+
return fmt.Errorf("marshaling JSON: %w", err)
99+
}
100+
cmd.Println(string(data))
101+
return nil
102+
}
103+
104+
// Text output
105+
printStatsText(cmd, output)
106+
return nil
107+
}
108+
109+
func aggregateStats(traces []*analytics.SessionTrace, days int) *statsOutput {
110+
out := &statsOutput{
111+
Period: fmt.Sprintf("last %d days", days),
112+
TotalSessions: len(traces),
113+
Models: make(map[string]modelStat),
114+
}
115+
116+
toolCounts := make(map[string]int)
117+
dailyMap := make(map[string]*dayStat)
118+
activeDays := make(map[string]bool)
119+
120+
for _, t := range traces {
121+
out.TotalMessages += t.MessageCount
122+
out.TotalToolCalls += t.ToolCalls
123+
out.TotalCostUSD += t.CostUSD
124+
125+
// Model breakdown
126+
ms := out.Models[t.Model]
127+
ms.Requests++
128+
ms.Messages += t.MessageCount
129+
ms.CostUSD += t.CostUSD
130+
out.Models[t.Model] = ms
131+
132+
// Tool calls are aggregated as a single count per session
133+
// (individual tool names not available in traces, tracked as total)
134+
if t.ToolCalls > 0 {
135+
toolCounts[t.Model+"/tools"] += t.ToolCalls
136+
}
137+
138+
// Daily breakdown
139+
dayKey := t.StartTime.Format("2006-01-02")
140+
activeDays[dayKey] = true
141+
ds, ok := dailyMap[dayKey]
142+
if !ok {
143+
ds = &dayStat{Date: dayKey}
144+
dailyMap[dayKey] = ds
145+
}
146+
ds.Sessions++
147+
ds.Cost += t.CostUSD
148+
}
149+
150+
out.ActiveDays = len(activeDays)
151+
152+
if out.TotalSessions > 0 {
153+
out.AvgCostPerSession = out.TotalCostUSD / float64(out.TotalSessions)
154+
}
155+
if out.ActiveDays > 0 {
156+
out.AvgCostPerDay = out.TotalCostUSD / float64(out.ActiveDays)
157+
}
158+
159+
// Sort tools by count
160+
for name, count := range toolCounts {
161+
out.TopTools = append(out.TopTools, toolStat{Name: name, Count: count})
162+
}
163+
sort.Slice(out.TopTools, func(i, j int) bool {
164+
return out.TopTools[i].Count > out.TopTools[j].Count
165+
})
166+
167+
// Sort daily breakdown
168+
for _, ds := range dailyMap {
169+
out.DailyBreakdown = append(out.DailyBreakdown, *ds)
170+
}
171+
sort.Slice(out.DailyBreakdown, func(i, j int) bool {
172+
return out.DailyBreakdown[i].Date < out.DailyBreakdown[j].Date
173+
})
174+
175+
return out
176+
}
177+
178+
func printStatsText(cmd *cobra.Command, out *statsOutput) {
179+
w := cmd.OutOrStdout()
180+
181+
fmt.Fprintf(w, "\n")
182+
fmt.Fprintf(w, "══════════════════════════════════════════════════\n")
183+
fmt.Fprintf(w, " Hawk Usage Statistics (%s)\n", out.Period)
184+
fmt.Fprintf(w, "══════════════════════════════════════════════════\n")
185+
186+
// Overview section
187+
fmt.Fprintf(w, "\n")
188+
fmt.Fprintf(w, "─── Overview ───\n")
189+
fmt.Fprintf(w, " Sessions: %d\n", out.TotalSessions)
190+
fmt.Fprintf(w, " Messages: %d\n", out.TotalMessages)
191+
fmt.Fprintf(w, " Tool calls: %d\n", out.TotalToolCalls)
192+
fmt.Fprintf(w, " Active days: %d\n", out.ActiveDays)
193+
194+
// Cost section
195+
fmt.Fprintf(w, "\n")
196+
fmt.Fprintf(w, "─── Cost ───\n")
197+
fmt.Fprintf(w, " Total cost: $%.4f\n", out.TotalCostUSD)
198+
fmt.Fprintf(w, " Avg cost/session: $%.4f\n", out.AvgCostPerSession)
199+
fmt.Fprintf(w, " Avg cost/day: $%.4f\n", out.AvgCostPerDay)
200+
201+
// Models section
202+
if statsModels && len(out.Models) > 0 {
203+
fmt.Fprintf(w, "\n")
204+
fmt.Fprintf(w, "─── Models ───\n")
205+
fmt.Fprintf(w, " %-30s %8s %10s\n", "MODEL", "REQUESTS", "COST")
206+
fmt.Fprintf(w, " %-30s %8s %10s\n", strings.Repeat("─", 30), strings.Repeat("─", 8), strings.Repeat("─", 10))
207+
208+
// Sort models by cost descending
209+
type modelEntry struct {
210+
name string
211+
stat modelStat
212+
}
213+
var models []modelEntry
214+
for name, stat := range out.Models {
215+
models = append(models, modelEntry{name, stat})
216+
}
217+
sort.Slice(models, func(i, j int) bool {
218+
return models[i].stat.CostUSD > models[j].stat.CostUSD
219+
})
220+
221+
for _, m := range models {
222+
fmt.Fprintf(w, " %-30s %8d %10s\n", m.name, m.stat.Requests, fmt.Sprintf("$%.4f", m.stat.CostUSD))
223+
}
224+
}
225+
226+
// Top Tools section
227+
if len(out.TopTools) > 0 {
228+
fmt.Fprintf(w, "\n")
229+
fmt.Fprintf(w, "─── Top Tools ───\n")
230+
231+
limit := statsTop
232+
if limit > len(out.TopTools) {
233+
limit = len(out.TopTools)
234+
}
235+
236+
// Find max count for bar scaling
237+
maxCount := 0
238+
for i := 0; i < limit; i++ {
239+
if out.TopTools[i].Count > maxCount {
240+
maxCount = out.TopTools[i].Count
241+
}
242+
}
243+
244+
barWidth := 30
245+
for i := 0; i < limit; i++ {
246+
t := out.TopTools[i]
247+
barLen := int(math.Round(float64(t.Count) / float64(maxCount) * float64(barWidth)))
248+
if barLen < 1 {
249+
barLen = 1
250+
}
251+
bar := strings.Repeat("█", barLen)
252+
fmt.Fprintf(w, " %-20s %s %d\n", t.Name, bar, t.Count)
253+
}
254+
}
255+
256+
fmt.Fprintf(w, "\n")
257+
}

daemon/api_types.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package daemon
2+
3+
import "time"
4+
5+
// ErrorResponse is the standard error envelope.
6+
type ErrorResponse struct {
7+
Error string `json:"error"`
8+
Code string `json:"code,omitempty"`
9+
Details string `json:"details,omitempty"`
10+
}
11+
12+
// PaginatedResponse wraps paginated list results.
13+
type PaginatedResponse struct {
14+
Data interface{} `json:"data"`
15+
Total int `json:"total"`
16+
Offset int `json:"offset"`
17+
Limit int `json:"limit"`
18+
HasMore bool `json:"has_more"`
19+
}
20+
21+
// SessionDetailResponse is the response for GET /v1/sessions/{id}.
22+
type SessionDetailResponse struct {
23+
ID string `json:"id"`
24+
CreatedAt time.Time `json:"created_at"`
25+
UpdatedAt time.Time `json:"updated_at"`
26+
Model string `json:"model"`
27+
Provider string `json:"provider"`
28+
CWD string `json:"cwd"`
29+
Name string `json:"name"`
30+
MessageCount int `json:"message_count"`
31+
ToolCalls int `json:"tool_calls"`
32+
}
33+
34+
// MessageResponse is a message in GET /v1/sessions/{id}/messages.
35+
type MessageResponse struct {
36+
Role string `json:"role"`
37+
Content string `json:"content,omitempty"`
38+
ToolUse interface{} `json:"tool_use,omitempty"`
39+
ToolResult interface{} `json:"tool_result,omitempty"`
40+
}
41+
42+
// StatsResponse is the response for GET /v1/stats.
43+
type StatsResponse struct {
44+
TotalSessions int `json:"total_sessions"`
45+
TotalMessages int `json:"total_messages"`
46+
TotalToolCalls int `json:"total_tool_calls"`
47+
TotalCostUSD float64 `json:"total_cost_usd"`
48+
ActiveDays int `json:"active_days"`
49+
Models []ModelStatResp `json:"models"`
50+
}
51+
52+
// ModelStatResp is per-model statistics within StatsResponse.
53+
type ModelStatResp struct {
54+
Model string `json:"model"`
55+
Requests int `json:"requests"`
56+
CostUSD float64 `json:"cost_usd"`
57+
}

daemon/daemon.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ func (s *Server) routes() {
148148
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
149149
s.mux.HandleFunc("POST /v1/chat", s.handleChat)
150150
s.mux.HandleFunc("GET /v1/sessions", s.handleListSessions)
151+
s.mux.HandleFunc("GET /v1/sessions/{id}", s.handleGetSession)
152+
s.mux.HandleFunc("GET /v1/sessions/{id}/messages", s.handleGetMessages)
153+
s.mux.HandleFunc("DELETE /v1/sessions/{id}", s.handleDeleteSession)
154+
s.mux.HandleFunc("GET /v1/stats", s.handleStats)
151155
}
152156

153157
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {

0 commit comments

Comments
 (0)