Skip to content

Commit a77b764

Browse files
committed
feat: add taste learning, @mentions, !shell, staleness detection, /feedback
Major feature additions: - Taste learning system (taste/) with profile, collector, detector, store, and hooks for implicit signal collection from accept/edit/reject actions - @file mentions (mention/) with fuzzy matching for inline context injection - !bash prefix mode (shellmode/) for direct shell execution from prompt - Rule staleness detection (staleness/) with /stale command - /feedback command for in-session bug reporting - hawk taste show/push/pull/reset CLI commands for preference sharing
1 parent 9918023 commit a77b764

33 files changed

Lines changed: 3853 additions & 29 deletions

cmd/chat.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import (
2828
"github.com/GrayCodeAI/hawk/memory"
2929
"github.com/GrayCodeAI/hawk/plugin"
3030
"github.com/GrayCodeAI/hawk/session"
31+
"github.com/GrayCodeAI/hawk/staleness"
32+
"github.com/GrayCodeAI/hawk/taste"
3133
"github.com/GrayCodeAI/hawk/tool"
3234
)
3335

@@ -240,6 +242,16 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
240242
m.containerStatus = "checking docker…"
241243
}
242244

245+
// Initialize taste and staleness subsystems.
246+
m.stalenessDetector = staleness.NewDetector()
247+
if store, err := taste.NewStore(""); err == nil {
248+
cwd, _ := os.Getwd()
249+
projectID := filepath.Base(cwd)
250+
if hooks, err := taste.NewHooks(projectID, store); err == nil {
251+
m.tasteHooks = hooks
252+
}
253+
}
254+
243255
// Initialize write-ahead log for crash recovery
244256
if wal, err := session.NewWAL(sid); err == nil {
245257
m.wal = wal
@@ -566,6 +578,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
566578
if strings.HasPrefix(text, "!") {
567579
return m.handleShellEscape(text[1:])
568580
}
581+
// @ mention: resolve file references and include as context.
582+
text = m.handleMentions(text)
569583
m.messages = append(m.messages, displayMsg{role: "user", content: text})
570584
m.session.AddUser(text)
571585
if m.wal != nil {

cmd/chat_commands.go

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import (
1919
"github.com/GrayCodeAI/hawk/engine"
2020
"github.com/GrayCodeAI/hawk/plugin"
2121
"github.com/GrayCodeAI/hawk/session"
22+
"github.com/GrayCodeAI/hawk/shellmode"
23+
"github.com/GrayCodeAI/hawk/staleness"
24+
"github.com/GrayCodeAI/hawk/taste"
2225
"github.com/GrayCodeAI/hawk/tool"
2326
)
2427

@@ -27,13 +30,13 @@ func slashCommands() []string {
2730
"/add", "/add-dir", "/agents", "/agents-init", "/audit", "/branch", "/branches", "/bughunter", "/clean", "/clear",
2831
"/check", "/color", "/commit", "/compact", "/compress", "/config", "/context", "/council", "/design",
2932
"/copy", "/cost", "/cron", "/diff", "/doctor", "/drop", "/effort", "/env", "/exit", "/explain",
30-
"/export", "/fast", "/files", "/focus", "/fork", "/help", "/history", "/hooks", "/init",
33+
"/export", "/fast", "/feedback", "/files", "/focus", "/fork", "/help", "/history", "/hooks", "/init",
3134
"/integrity", "/keybindings", "/learn", "/lint", "/loop", "/mcp", "/memory", "/metrics", "/model", "/new",
3235
"/hunt", "/output-style", "/permissions", "/pin", "/plan", "/plugin", "/plugins",
3336
"/power", "/pr-comments", "/provider-status", "/quit", "/refresh-model-catalog", "/release-notes",
3437
"/reload-plugins", "/remote-env", "/rename", "/render", "/research", "/resume", "/retry", "/review", "/rewind",
35-
"/run", "/btw", "/sandbox", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/stats",
36-
"/status", "/statusline", "/summary", "/tag", "/tasks", "/test", "/theme",
38+
"/run", "/btw", "/sandbox", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/stale", "/stats",
39+
"/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme",
3740
"/think", "/think-back", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/undo", "/upgrade", "/usage",
3841
"/version", "/vibe", "/vim", "/voice", "/welcome", "/yolo",
3942
}
@@ -75,6 +78,7 @@ var slashDescriptions = map[string]string{
7578
"/exit": "Save and exit",
7679
"/explain": "Trace code back to the commit that created it",
7780
"/export": "Export session",
81+
"/feedback": "Submit feedback about hawk",
7882
"/fast": "Toggle fast mode",
7983
"/files": "Show modified files",
8084
"/focus": "Narrow agent attention to specific files/dirs",
@@ -106,6 +110,7 @@ var slashDescriptions = map[string]string{
106110
"/sandbox": "Toggle sandbox mode",
107111
"/search": "Search across sessions",
108112
"/snapshot": "Manage file snapshots: list, restore <hash>, diff <hash>",
113+
"/stale": "Show stale rules that may need updating or removal",
109114
"/security-review": "Security audit",
110115
"/skills": "List skills or manage: search, install, trending, info, remove, update, feedback, publish, audit",
111116
"/learn": "LLM-powered skill advisor (/learn deep for source analysis)",
@@ -138,6 +143,7 @@ var slashDescriptions = map[string]string{
138143
"/share": "Share session",
139144
"/statusline": "Show status line info",
140145
"/tag": "Tag current session",
146+
"/taste": "Show learned taste preferences",
141147
"/theme": "Change visual theme",
142148
"/think-back": "Review reasoning decisions",
143149
"/thinkback": "Review reasoning decisions",
@@ -428,6 +434,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) {
428434
/env — Show provider environment status
429435
/export — Export session to JSON
430436
/fast — Toggle fast mode
437+
/feedback <msg> — Submit feedback (saved to ~/.hawk/feedback/)
431438
/files — Show modified files
432439
/help — This help message
433440
/history — List saved sessions
@@ -457,10 +464,12 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) {
457464
/share — Share session
458465
/learn — LLM-powered skill advisor (deep, update)
459466
/skills — List, search, install, remove skills
467+
/stale — Show stale rules that may need removal
460468
/stats — Session statistics
461469
/status — Session status
462470
/summary — Summarize the current session
463471
/tag <label> — Tag session
472+
/taste — Show learned coding style preferences
464473
/tasks — Show task list
465474
/teams — Show team info
466475
/theme <t> — Set theme (dark/light/auto)
@@ -1472,6 +1481,58 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) {
14721481
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Exported to: %s", exportPath)})
14731482
}
14741483
return m, nil
1484+
case "/feedback":
1485+
body := strings.TrimSpace(strings.TrimPrefix(text, "/feedback"))
1486+
if body == "" {
1487+
m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /feedback <message>\nCaptures session context and saves feedback to ~/.hawk/feedback/"})
1488+
return m, nil
1489+
}
1490+
home, _ := os.UserHomeDir()
1491+
feedDir := filepath.Join(home, ".hawk", "feedback")
1492+
os.MkdirAll(feedDir, 0755)
1493+
report := fmt.Sprintf(`{"timestamp":%q,"version":%q,"model":%q,"provider":%q,"category":"session","body":%q,"session_id":%q}`,
1494+
time.Now().Format(time.RFC3339), version, m.session.Model(), m.session.Provider(), body, m.sessionID)
1495+
fname := fmt.Sprintf("feedback-%s.json", time.Now().Format("20060102-150405"))
1496+
fpath := filepath.Join(feedDir, fname)
1497+
if err := os.WriteFile(fpath, []byte(report), 0644); err != nil {
1498+
m.messages = append(m.messages, displayMsg{role: "error", content: "Failed to save feedback: " + err.Error()})
1499+
} else {
1500+
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Feedback saved to %s", fpath)})
1501+
}
1502+
return m, nil
1503+
case "/stale":
1504+
if m.stalenessDetector == nil {
1505+
m.messages = append(m.messages, displayMsg{role: "system", content: "No staleness data available yet. Rules will be tracked as they are used."})
1506+
return m, nil
1507+
}
1508+
threshold := 7 * 24 * time.Hour // 7 days default
1509+
if len(parts) > 1 {
1510+
if d, err := time.ParseDuration(parts[1]); err == nil {
1511+
threshold = d
1512+
}
1513+
}
1514+
staleRules := m.stalenessDetector.CheckStaleness(threshold)
1515+
if len(staleRules) == 0 {
1516+
m.messages = append(m.messages, displayMsg{role: "system", content: "No stale rules detected. All rules have been used within the threshold."})
1517+
} else {
1518+
m.messages = append(m.messages, displayMsg{role: "system", content: stalenessFormatReport(staleRules)})
1519+
}
1520+
return m, nil
1521+
case "/taste":
1522+
store, err := tasteStoreForSession()
1523+
if err != nil {
1524+
m.messages = append(m.messages, displayMsg{role: "error", content: "Taste store error: " + err.Error()})
1525+
return m, nil
1526+
}
1527+
cwd, _ := os.Getwd()
1528+
projectID := filepath.Base(cwd)
1529+
profile, err := store.Load(projectID)
1530+
if err != nil {
1531+
m.messages = append(m.messages, displayMsg{role: "error", content: "Load taste profile: " + err.Error()})
1532+
return m, nil
1533+
}
1534+
m.messages = append(m.messages, displayMsg{role: "system", content: profile.Summary()})
1535+
return m, nil
14751536
case "/rename":
14761537
if len(parts) < 2 {
14771538
m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /rename <new-session-name>"})
@@ -1985,26 +2046,33 @@ func (m *chatModel) handleShellEscape(command string) (tea.Model, tea.Cmd) {
19852046
if command == "" {
19862047
return m, nil
19872048
}
2049+
2050+
// Warn about destructive commands.
2051+
if shellmode.IsDestructive(command) {
2052+
m.messages = append(m.messages, displayMsg{role: "error", content: "Warning: potentially destructive command detected. Use with caution."})
2053+
}
2054+
19882055
m.messages = append(m.messages, displayMsg{role: "system", content: "$ " + command})
19892056
m.viewDirty = true
19902057

1991-
cmd := exec.Command("sh", "-c", command)
1992-
out, err := cmd.CombinedOutput()
1993-
result := strings.TrimRight(string(out), "\n")
1994-
if err != nil && result == "" {
1995-
result = err.Error()
1996-
}
1997-
if result != "" {
1998-
// Truncate very long output
1999-
if len(result) > 4000 {
2000-
lines := strings.Split(result, "\n")
2058+
result := shellmode.ExecuteShell(context.Background(), command)
2059+
output := result.Stdout + result.Stderr
2060+
output = strings.TrimRight(output, "\n")
2061+
2062+
if output != "" {
2063+
// Truncate very long output.
2064+
if len(output) > 4000 {
2065+
lines := strings.Split(output, "\n")
20012066
if len(lines) > 40 {
20022067
head := strings.Join(lines[:20], "\n")
20032068
tail := strings.Join(lines[len(lines)-20:], "\n")
2004-
result = head + fmt.Sprintf("\n\n... (%d lines omitted) ...\n\n", len(lines)-40) + tail
2069+
output = head + fmt.Sprintf("\n\n... (%d lines omitted) ...\n\n", len(lines)-40) + tail
20052070
}
20062071
}
2007-
m.messages = append(m.messages, displayMsg{role: "tool_result", content: result})
2072+
m.messages = append(m.messages, displayMsg{role: "tool_result", content: output})
2073+
}
2074+
if result.ExitCode != 0 && output == "" {
2075+
m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("exit code: %d", result.ExitCode)})
20082076
}
20092077
m.viewDirty = true
20102078
return m, nil
@@ -2017,3 +2085,13 @@ func truncate(s string, max int) string {
20172085
}
20182086
return s[:max] + "…"
20192087
}
2088+
2089+
// tasteStoreForSession returns a taste store using the default location.
2090+
func tasteStoreForSession() (*taste.Store, error) {
2091+
return taste.NewStore("")
2092+
}
2093+
2094+
// stalenessFormatReport formats stale rules for display.
2095+
func stalenessFormatReport(rules []staleness.StaleRule) string {
2096+
return staleness.FormatReport(rules)
2097+
}

cmd/chat_model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"github.com/GrayCodeAI/hawk/plugin"
1919
"github.com/GrayCodeAI/hawk/sandbox"
2020
"github.com/GrayCodeAI/hawk/session"
21+
"github.com/GrayCodeAI/hawk/staleness"
22+
"github.com/GrayCodeAI/hawk/taste"
2123
"github.com/GrayCodeAI/hawk/tool"
2224
)
2325

@@ -141,6 +143,10 @@ type chatModel struct {
141143
containerReady bool
142144
containerErr error
143145
containerSandbox *sandbox.ContainerSandbox
146+
147+
// Taste & staleness tracking
148+
tasteHooks *taste.Hooks
149+
stalenessDetector *staleness.Detector
144150
}
145151

146152
func blinkTickCmd() tea.Cmd {

cmd/chat_welcome.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ func indentedAPIKeyLines() string {
216216
}
217217

218218
func apiKeyStatusLines() []string {
219-
providers := client.NewEyrieClient(nil).GetProviders()
219+
providers := client.Client(nil).GetProviders()
220220
sort.Strings(providers)
221221
var lines []string
222222
for _, provider := range providers {

0 commit comments

Comments
 (0)