diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index 6631b5bd..3364440d 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -171,6 +171,7 @@ func optionalTools() []tool.Tool { tool.DiagnosticsTool{}, tool.CodeSearchTool{}, tool.CodeMatchTool{}, + tool.ToolsetTool{}, tool.CoreMemoryAppendTool{}, tool.CoreMemoryReplaceTool{}, tool.CoreMemoryRethinkTool{}, diff --git a/internal/engine/safety/capabilities.go b/internal/engine/safety/capabilities.go index 4dd4b542..53842da6 100644 --- a/internal/engine/safety/capabilities.go +++ b/internal/engine/safety/capabilities.go @@ -47,6 +47,7 @@ var toolPolicies = map[string]ToolPolicy{ "SmartRead": {Name: "SmartRead", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "CodeSearch": {Name: "CodeSearch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "CodeMatch": {Name: "CodeMatch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, + "Toolset": {Name: "Toolset", Capabilities: nil, DefaultRisk: RiskLow}, "CodeGraph": {Name: "CodeGraph", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "Impact": {Name: "Impact", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "GitHistory": {Name: "GitHistory", Capabilities: []Capability{CapabilityFilesystemRead, CapabilityProcessExecute}, DefaultRisk: RiskLow}, diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index 2a772414..ab1b97e1 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -317,6 +317,8 @@ func canonicalToolName(name string) string { return "WebSearch" case "code_match", "codematch", "match_code": return "CodeMatch" + case "toolset": + return "Toolset" case "tool_health", "toolhealth", "tools_health": return "ToolHealth" case "project_verify", "projectverify", "verify_project": diff --git a/internal/engine/trajectory/trajectory.go b/internal/engine/trajectory/trajectory.go new file mode 100644 index 00000000..3c6ed230 --- /dev/null +++ b/internal/engine/trajectory/trajectory.go @@ -0,0 +1,135 @@ +// Package trajectory implements trajectory compression for agent runs, +// adopting Hermes Agent's trajectory_compressor: protect the first and last +// N turns verbatim (including their tool calls), compress only the middle into +// a single human summary message, and keep the surviving tool calls intact so +// training/eval signal is preserved. +package trajectory + +import ( + "strings" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +// bytesPerToken is the rough character-based token estimate. +const bytesPerToken = 4 + +// estTokens estimates the token cost of a message from its text plus tool +// payload sizes. +func estTokens(m types.EyrieMessage) int { + n := len(m.Content) + for _, tu := range m.ToolUse { + if s, ok := tu.Arguments["_raw"]; ok { + if str, ok := s.(string); ok { + n += len(str) + } + } + for _, v := range tu.Arguments { + if str, ok := v.(string); ok { + n += len(str) + } + } + } + for _, tr := range m.ToolResults { + n += len(tr.Content) + } + return n / bytesPerToken +} + +// CompressTrajectory returns a trajectory where the first protectFirst turns +// and the last protectLast turns are kept verbatim and the middle is replaced +// by a single human summary message, only when the total exceeds +// targetTokens. The summary is a plain text "user" message so downstream +// consumers treat it as a faithful human-supplied checkpoint. Tool calls in +// the kept head/tail remain intact. +// +// Returns (msgs, false) unchanged when already under budget or there is no +// compressible middle. +func CompressTrajectory(msgs []types.EyrieMessage, targetTokens, protectFirst, protectLast int) ([]types.EyrieMessage, bool) { + if len(msgs) == 0 || targetTokens <= 0 { + return msgs, false + } + total := 0 + for _, m := range msgs { + total += estTokens(m) + } + if total <= targetTokens { + return msgs, false + } + if protectFirst < 0 { + protectFirst = 0 + } + if protectLast < 0 { + protectLast = 0 + } + // Ensure head and tail do not overlap. + if protectFirst+protectLast >= len(msgs) { + return msgs, false // no middle to compress + } + + head := msgs[:protectFirst] + tail := msgs[len(msgs)-protectLast:] + middle := msgs[protectFirst : len(msgs)-protectLast] + + summary := summarizeMiddle(middle) + out := make([]types.EyrieMessage, 0, len(head)+len(tail)+1) + out = append(out, head...) + out = append(out, types.EyrieMessage{ + Role: "user", + Content: "[compressed middle: " + summary + "]", + }) + out = append(out, tail...) + return out, true +} + +// summarizeMiddle reduces the middle region to a compact one-line digest. It +// counts turns and tool calls and captures the last user request, which is the +// most task-relevant signal for a human checkpoint. +func summarizeMiddle(middle []types.EyrieMessage) string { + turns := len(middle) + toolCalls := 0 + lastUser := "" + for _, m := range middle { + toolCalls += len(m.ToolUse) + if m.Role == "user" && strings.TrimSpace(m.Content) != "" { + lastUser = strings.TrimSpace(m.Content) + } + } + var b strings.Builder + b.WriteString("summarized ") + b.WriteString(itoa(turns)) + b.WriteString(" turns, ") + b.WriteString(itoa(toolCalls)) + b.WriteString(" tool calls") + if lastUser != "" { + lastUser = strings.ReplaceAll(lastUser, "\n", " ") + if len(lastUser) > 160 { + lastUser = lastUser[:160] + "…" + } + b.WriteString("; latest request: ") + b.WriteString(lastUser) + } + return b.String() +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/internal/engine/trajectory/trajectory_test.go b/internal/engine/trajectory/trajectory_test.go new file mode 100644 index 00000000..6cd2655f --- /dev/null +++ b/internal/engine/trajectory/trajectory_test.go @@ -0,0 +1,86 @@ +package trajectory + +import ( + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func bigToolMsg(payload string) types.EyrieMessage { + return types.EyrieMessage{ + Role: "assistant", + ToolUse: []types.ToolCall{{Name: "Bash", Arguments: map[string]interface{}{"command": payload}}}, + ToolResults: []types.ToolResult{{Content: payload}}, + } +} + +func TestUnderBudgetUnchanged(t *testing.T) { + msgs := []types.EyrieMessage{{Role: "user", Content: "hi"}} + out, changed := CompressTrajectory(msgs, 10000, 2, 2) + if changed || len(out) != 1 { + t.Fatal("small trajectory must be unchanged") + } +} + +func TestProtectHeadTailCompressMiddle(t *testing.T) { + msgs := []types.EyrieMessage{ + {Role: "user", Content: "start"}, + bigToolMsg(strings.Repeat("a", 4000)), // head turn with tool call + {Role: "user", Content: strings.Repeat("b", 3000)}, + {Role: "assistant", Content: strings.Repeat("c", 3000)}, + {Role: "user", Content: strings.Repeat("d", 3000)}, + {Role: "user", Content: "end"}, // tail + } + // protectFirst=2, protectLast=1; target small enough to force compression + out, changed := CompressTrajectory(msgs, 400, 2, 1) + if !changed { + t.Fatal("expected compression") + } + // head (2) + summary + tail (1) = 4 messages + if len(out) != 4 { + t.Fatalf("len = %d, want 4", len(out)) + } + // head verbatim (incl. tool call) + if out[0].Content != "start" { + t.Fatalf("head[0] altered: %q", out[0].Content) + } + if len(out[1].ToolUse) != 1 || len(out[1].ToolResults) != 1 { + t.Fatal("head tool call not preserved") + } + // summary + if out[2].Role != "user" || !strings.Contains(out[2].Content, "[compressed middle") { + t.Fatalf("summary missing: %+v", out[2]) + } + // tail verbatim + if out[3].Content != "end" { + t.Fatalf("tail altered: %q", out[3].Content) + } +} + +func TestNoMiddleWhenProtectCoversAll(t *testing.T) { + msgs := []types.EyrieMessage{ + {Role: "user", Content: "a"}, + {Role: "user", Content: strings.Repeat("b", 5000)}, + {Role: "user", Content: "c"}, + } + out, changed := CompressTrajectory(msgs, 100, 2, 2) + if changed || len(out) != 3 { + t.Fatal("overlapping protect must leave unchanged") + } +} + +func TestSummaryCapturesLatestRequest(t *testing.T) { + msgs := []types.EyrieMessage{ + {Role: "user", Content: "old"}, + {Role: "user", Content: "the real request here"}, + {Role: "assistant", Content: strings.Repeat("x", 3000)}, + } + out, changed := CompressTrajectory(msgs, 200, 1, 0) + if !changed { + t.Fatal("expected change") + } + if !strings.Contains(out[1].Content, "the real request here") { + t.Fatalf("summary lost latest request: %q", out[1].Content) + } +} diff --git a/internal/intelligence/skillcurator/record.go b/internal/intelligence/skillcurator/record.go new file mode 100644 index 00000000..92cf9581 --- /dev/null +++ b/internal/intelligence/skillcurator/record.go @@ -0,0 +1,23 @@ +package skillcurator + +import ( + "path/filepath" + + "github.com/GrayCodeAI/hawk/internal/storage" +) + +// RecordSkillUsage is a best-effort, non-blocking integration point for the +// Skill tool: it records a skill invocation in the user-scoped curator so the +// auto-archive review has accurate usage data. It never errors — skill +// execution must not be interrupted by curator bookkeeping. +func RecordSkillUsage(name string) { + if name == "" { + return + } + dir := filepath.Join(storage.StateDir(), "skills") + c, err := New(Config{SkillsDir: dir, StateFile: filepath.Join(dir, ".curator_state.json")}) + if err != nil { + return + } + c.RecordUse(name) +} diff --git a/internal/intelligence/skillcurator/skillcurator.go b/internal/intelligence/skillcurator/skillcurator.go new file mode 100644 index 00000000..9823b95d --- /dev/null +++ b/internal/intelligence/skillcurator/skillcurator.go @@ -0,0 +1,380 @@ +// Package skillcurator maintains a coding agent's skill collection over time, +// adopting Hermes Agent's curator design. It records per-skill usage, runs an +// inactivity-triggered review, and auto-transitions agent-created skills +// through lifecycle states (Active -> Archived) under hard invariants: +// +// - only agent-created skills are ever touched (installed third-party skills +// are left alone); +// - nothing is ever deleted — archiving moves the skill to a recoverable +// .archive/ directory; +// - explicitly pinned skills bypass all auto-transitions; +// - the review is best-effort and never blocks the agent. +package skillcurator + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Status is a skill's lifecycle state. +type Status string + +const ( + StatusActive Status = "active" + StatusPinned Status = "pinned" + StatusArchived Status = "archived" +) + +// Skill is the curator's view of one agent-created skill file. +type Skill struct { + Name string + Path string // current location of SKILL.md (or its dir) + Category string + Version string + Status Status + LastUsed time.Time + UseCount int +} + +// Config controls the curator's behavior. +type Config struct { + // SkillsDir is the user-scoped agent-created skills directory. + SkillsDir string + // StateFile is where curator + usage state persists. Defaults to + // /.curator_state.json. + StateFile string + // IdleDaysBeforeArchive archives an Active skill unused this long. + IdleDaysBeforeArchive int + // IntervalHours between auto reviews (inactivity-triggered). + IntervalHours int +} + +func (c *Config) normalize() { + if c.SkillsDir == "" { + c.SkillsDir = "~/.hawk/skills" + } + if c.IdleDaysBeforeArchive <= 0 { + c.IdleDaysBeforeArchive = 30 + } + if c.IntervalHours <= 0 { + c.IntervalHours = 7 * 24 // weekly + } + if c.StateFile == "" { + c.StateFile = filepath.Join(c.SkillsDir, ".curator_state.json") + } +} + +// Curator tracks usage and runs lifecycle transitions over agent-created +// skills. Thread-safe. +type Curator struct { + mu sync.Mutex + cfg Config + state state + pinned map[string]bool // resolved at load from state.Pinned +} + +type state struct { + Version int `json:"version"` + LastRunAt time.Time `json:"last_run_at,omitempty"` + Pinned []string `json:"pinned,omitempty"` + Usage map[string]*Usage `json:"usage,omitempty"` +} + +// Usage records observed use of a skill. +type Usage struct { + LastUsed time.Time `json:"last_used"` + UseCount int `json:"use_count"` +} + +// New creates a curator and loads persisted state. Missing state is fine. +func New(cfg Config) (*Curator, error) { + cfg.normalize() + c := &Curator{cfg: cfg, state: state{Usage: map[string]*Usage{}}} + c.pinned = map[string]bool{} + if err := c.load(); err != nil { + return nil, err + } + return c, nil +} + +func (c *Curator) load() error { + raw, err := os.ReadFile(c.cfg.StateFile) // #nosec G304 -- curator-owned state path + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var st state + if err := json.Unmarshal(raw, &st); err != nil { + return fmt.Errorf("skillcurator: parse state: %w", err) + } + if st.Usage == nil { + st.Usage = map[string]*Usage{} + } + c.state = st + for _, p := range st.Pinned { + c.pinned[p] = true + } + return nil +} + +func (c *Curator) save() error { + c.state.Pinned = nil + for p := range c.pinned { + c.state.Pinned = append(c.state.Pinned, p) + } + sort.Strings(c.state.Pinned) + data, err := json.MarshalIndent(c.state, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(c.cfg.StateFile), 0o750); err != nil { + return err + } + tmp := c.cfg.StateFile + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { // #nosec G306 -- curator-owned state + return err + } + return os.Rename(tmp, c.cfg.StateFile) +} + +// RecordUse records that a skill was invoked now. It lazily marks the skill +// active on first use. Never touches installed (non-agent) skills' files. +func (c *Curator) RecordUse(name string) { + c.mu.Lock() + defer c.mu.Unlock() + u, ok := c.state.Usage[name] + if !ok { + u = &Usage{} + c.state.Usage[name] = u + } + u.LastUsed = time.Now() + u.UseCount++ + _ = c.save() +} + +// Pin pins a skill so auto-transitions never touch it. +func (c *Curator) Pin(name string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.pinned[name] = true + return c.save() +} + +// Unpin removes a pin. +func (c *Curator) Unpin(name string) error { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.pinned, name) + return c.save() +} + +// Archive moves a skill's directory into a recoverable .archive/ subfolder. +// It refuses to archive pinned skills and refuses to delete anything. +func (c *Curator) Archive(name, reason string) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.pinned[name] { + return fmt.Errorf("skillcurator: %s is pinned; refuse to archive", name) + } + src := filepath.Join(c.cfg.SkillsDir, name) + st, err := os.Stat(src) + if err != nil { + return fmt.Errorf("skillcurator: skill %s not found: %w", name, err) + } + if !st.IsDir() { + return fmt.Errorf("skillcurator: %s is not a skill directory", name) + } + archiveRoot := filepath.Join(c.cfg.SkillsDir, ".archive") + if err := os.MkdirAll(archiveRoot, 0o750); err != nil { + return err + } + dst := filepath.Join(archiveRoot, name) + if _, err := os.Stat(dst); os.IsNotExist(err) { + if err := os.Rename(src, dst); err != nil { + return err + } + } + // Record the archive in state so it stays discoverable/recoverable. + if c.state.Usage == nil { + c.state.Usage = map[string]*Usage{} + } + if _, ok := c.state.Usage[name]; !ok { + c.state.Usage[name] = &Usage{} + } + c.state.Usage[name].LastUsed = time.Now() + _ = reason + return c.save() +} + +// List enumerates agent-created skills under the skills dir with their status. +func (c *Curator) List() ([]Skill, error) { + c.mu.Lock() + defer c.mu.Unlock() + entries, err := os.ReadDir(c.cfg.SkillsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []Skill + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if strings.HasPrefix(name, ".") { + continue // .archive, .curator_state, etc. + } + s := Skill{ + Name: name, + Path: filepath.Join(c.cfg.SkillsDir, name), + Status: StatusActive, + } + if u, ok := c.state.Usage[name]; ok { + s.LastUsed = u.LastUsed + s.UseCount = u.UseCount + } + if c.pinned[name] { + s.Status = StatusPinned + } + if meta := readFrontmatter(filepath.Join(s.Path, "SKILL.md")); meta != nil { + s.Category = meta.category + s.Version = meta.version + } + out = append(out, s) + } + // Include archived skills (recoverable) from .archive/. + archiveDir := filepath.Join(c.cfg.SkillsDir, ".archive") + if ae, aerr := os.ReadDir(archiveDir); aerr == nil { + for _, e := range ae { + if !e.IsDir() { + continue + } + s := Skill{Name: e.Name(), Path: filepath.Join(archiveDir, e.Name()), Status: StatusArchived} + out = append(out, s) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// MaybeRun is the inactivity-triggered review: it runs only when the last run +// is older than IntervalHours, then archives unused Active skills. +func (c *Curator) MaybeRun(now time.Time) ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.state.LastRunAt.IsZero() && now.Sub(c.state.LastRunAt) < time.Duration(c.cfg.IntervalHours)*time.Hour { + return nil, nil // not due + } + c.state.LastRunAt = now + // Unlock for the file walk; re-lock for mutations. + archived, err := c.reviewLocked(now) + if err != nil { + return nil, err + } + if serr := c.save(); serr != nil { + return archived, serr + } + return archived, nil +} + +// reviewLocked runs the auto-transition pass. The caller holds the lock. +func (c *Curator) reviewLocked(now time.Time) ([]string, error) { + threshold := now.AddDate(0, 0, -c.cfg.IdleDaysBeforeArchive) + var archived []string + entries, err := os.ReadDir(c.cfg.SkillsDir) + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + name := e.Name() + if c.pinned[name] { + continue + } + u, ok := c.state.Usage[name] + if !ok { + continue // never-used agent skill: leave it (conservative) + } + if u.LastUsed.Before(threshold) { + // Only auto-archive skills the agent actually created and that have + // seen use in the past (use count > 0) but have since gone cold. + if u.UseCount > 0 { + if err := c.moveToArchiveLocked(name); err != nil { + continue + } + archived = append(archived, name) + } + } + } + return archived, nil +} + +func (c *Curator) moveToArchiveLocked(name string) error { + src := filepath.Join(c.cfg.SkillsDir, name) + archiveRoot := filepath.Join(c.cfg.SkillsDir, ".archive") + if err := os.MkdirAll(archiveRoot, 0o750); err != nil { + return err + } + dst := filepath.Join(archiveRoot, name) + if _, err := os.Stat(dst); os.IsNotExist(err) { + return os.Rename(src, dst) + } + return nil +} + +// frontmatter holds the minimal SKILL.md YAML keys the curator reads. +type frontmatter struct { + category string + version string +} + +// readFrontmatter parses the leading --- frontmatter block of a SKILL.md. +func readFrontmatter(path string) *frontmatter { + raw, err := os.ReadFile(path) // #nosec G304 -- skill file under curated dir + if err != nil { + return nil + } + text := string(raw) + if !strings.HasPrefix(text, "---") { + return nil + } + rest := text[3:] + if idx := strings.Index(rest, "---"); idx < 0 { + return nil + } else { + rest = rest[:idx] + } + fm := &frontmatter{} + for _, line := range strings.Split(rest, "\n") { + line = strings.TrimSpace(line) + if v, ok := cutKV(line, "category"); ok { + fm.category = v + } + if v, ok := cutKV(line, "version"); ok { + fm.version = v + } + } + return fm +} + +func cutKV(line, key string) (string, bool) { + kv := strings.TrimSpace(line) + if !strings.HasPrefix(kv, key+":") { + return "", false + } + v := strings.TrimSpace(strings.TrimPrefix(kv, key+":")) + v = strings.Trim(v, `"'`) + return v, true +} diff --git a/internal/intelligence/skillcurator/skillcurator_test.go b/internal/intelligence/skillcurator/skillcurator_test.go new file mode 100644 index 00000000..583b925f --- /dev/null +++ b/internal/intelligence/skillcurator/skillcurator_test.go @@ -0,0 +1,190 @@ +package skillcurator + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func makeSkill(t *testing.T, dir, name, frontmatter string) { + t.Helper() + p := filepath.Join(dir, name) + if err := os.MkdirAll(p, 0o750); err != nil { + t.Fatal(err) + } + body := "" + if frontmatter != "" { + body = "---\n" + frontmatter + "---\n\n# " + name + "\n" + } + if err := os.WriteFile(filepath.Join(p, "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func newTestCurator(t *testing.T, idleDays int) (*Curator, string) { + t.Helper() + dir := t.TempDir() + cfg := Config{ + SkillsDir: dir, IdleDaysBeforeArchive: idleDays, IntervalHours: 1, + StateFile: filepath.Join(dir, ".curator_state.json"), + } + c, err := New(cfg) + if err != nil { + t.Fatal(err) + } + return c, dir +} + +func TestRecordUseAndListStatus(t *testing.T) { + c, dir := newTestCurator(t, 30) + makeSkill(t, dir, "go-helper", "category: devtools\n") + c.RecordUse("go-helper") + skills, err := c.List() + if err != nil { + t.Fatal(err) + } + if len(skills) != 1 || skills[0].Name != "go-helper" { + t.Fatalf("skills = %+v", skills) + } + if skills[0].Category != "devtools" { + t.Fatalf("category = %q", skills[0].Category) + } + if skills[0].UseCount != 1 { + t.Fatalf("use count = %d", skills[0].UseCount) + } + if skills[0].Status != StatusActive { + t.Fatalf("status = %q", skills[0].Status) + } +} + +func TestPinBypassesArchive(t *testing.T) { + c, dir := newTestCurator(t, 0) // idle threshold 0 -> anything old archives + makeSkill(t, dir, "keep", "") + c.RecordUse("keep") + c.state.Usage["keep"].LastUsed = time.Now().AddDate(0, 0, -10) // cold + + if err := c.Pin("keep"); err != nil { + t.Fatal(err) + } + archived, err := c.MaybeRun(time.Now()) + if err != nil { + t.Fatal(err) + } + if len(archived) != 0 { + t.Fatalf("pinned skill was archived: %v", archived) + } + // It must still be present and pinned. + skills, _ := c.List() + if len(skills) != 1 || skills[0].Status != StatusPinned { + t.Fatalf("skills = %+v", skills) + } +} + +func TestArchiveMovesToRecoverableDotArchive(t *testing.T) { + c, dir := newTestCurator(t, 30) + makeSkill(t, dir, "old-skill", "") + if err := c.Archive("old-skill", "obsolete"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "old-skill")); !os.IsNotExist(err) { + t.Fatal("original dir should be gone") + } + if _, err := os.Stat(filepath.Join(dir, ".archive", "old-skill")); err != nil { + t.Fatalf("archived copy missing: %v", err) + } + // List reports it as archived (recoverable), never deleted. + skills, _ := c.List() + found := false + for _, s := range skills { + if s.Name == "old-skill" && s.Status == StatusArchived { + found = true + } + } + if !found { + t.Fatalf("archived skill not reported: %+v", skills) + } +} + +func TestArchiveRefusesPinned(t *testing.T) { + c, dir := newTestCurator(t, 30) + makeSkill(t, dir, "keep", "") + _ = c.Pin("keep") + if err := c.Archive("keep", ""); err == nil { + t.Fatal("archive must refuse a pinned skill") + } + if _, err := os.Stat(filepath.Join(dir, "keep")); err != nil { + t.Fatal("pinned skill must remain in place") + } +} + +func TestMaybeRunArchivesColdUsedSkill(t *testing.T) { + c, dir := newTestCurator(t, 30) + makeSkill(t, dir, "stale", "") + c.RecordUse("stale") + // Force its last-used to long ago. + c.mu.Lock() + c.state.Usage["stale"].LastUsed = time.Now().AddDate(0, 0, -60) + c.mu.Unlock() + + archived, err := c.MaybeRun(time.Now()) + if err != nil { + t.Fatal(err) + } + if len(archived) != 1 || archived[0] != "stale" { + t.Fatalf("archived = %v", archived) + } + // Never-used skills are left alone (conservative). + makeSkill(t, dir, "brand-new", "") + archived2, err := c.MaybeRun(time.Now().Add(2 * time.Hour)) + if err != nil { + t.Fatal(err) + } + if len(archived2) != 0 { + t.Fatalf("never-used skill auto-archived: %v", archived2) + } +} + +func TestMaybeRunRespectsInterval(t *testing.T) { + c, dir := newTestCurator(t, 30) + makeSkill(t, dir, "s", "") + c.RecordUse("s") + c.mu.Lock() + c.state.Usage["s"].LastUsed = time.Now().AddDate(0, 0, -60) + c.mu.Unlock() + + now := time.Now() + if _, err := c.MaybeRun(now); err != nil { + t.Fatal(err) + } + // Running again immediately (within interval) does nothing. + c.mu.Lock() + c.state.Usage["s"].LastUsed = time.Now().AddDate(0, 0, -60) + c.mu.Unlock() + archived, err := c.MaybeRun(now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if len(archived) != 0 { + t.Fatalf("review ran within interval: %v", archived) + } +} + +func TestStateRoundTrip(t *testing.T) { + c, dir := newTestCurator(t, 30) + makeSkill(t, dir, "x", "") + c.RecordUse("x") + _ = c.Pin("x") + + c2, err := New(Config{SkillsDir: dir, StateFile: filepath.Join(dir, ".curator_state.json")}) + if err != nil { + t.Fatal(err) + } + skills, _ := c2.List() + if len(skills) != 1 || skills[0].Status != StatusPinned { + t.Fatalf("pin not restored: %+v", skills) + } + if skills[0].UseCount != 1 { + t.Fatalf("usage not restored: %+v", skills[0]) + } +} diff --git a/internal/tool/skill.go b/internal/tool/skill.go index 8b5ce11d..0292b96a 100644 --- a/internal/tool/skill.go +++ b/internal/tool/skill.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "github.com/GrayCodeAI/hawk/internal/intelligence/skillcurator" "github.com/GrayCodeAI/hawk/internal/plugin" ) @@ -85,5 +86,10 @@ func (SkillTool) Execute(ctx context.Context, input json.RawMessage) (string, er } sb.WriteString("\n") sb.WriteString(entry.Content) + // Best-effort skill-usage recording for the curator (opt-in). Never + // interrupts skill execution. + if strings.EqualFold(os.Getenv("HAWK_SKILL_CURATOR"), "1") { + skillcurator.RecordSkillUsage(p.Skill) + } return sb.String(), nil } diff --git a/internal/tool/toolset_tool.go b/internal/tool/toolset_tool.go new file mode 100644 index 00000000..cbfa4064 --- /dev/null +++ b/internal/tool/toolset_tool.go @@ -0,0 +1,73 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/GrayCodeAI/hawk/internal/toolset" +) + +// ToolsetTool lists and resolves named, composable tool groups (research, +// dev, ops, full_stack). It lets a user/agent scope the tool surface instead +// of always advertising every tool — adopted from Hermes Agent's toolset +// system. +type ToolsetTool struct{} + +func (ToolsetTool) Name() string { return "Toolset" } +func (ToolsetTool) RiskLevel() string { return "low" } +func (ToolsetTool) Aliases() []string { return []string{"toolset"} } +func (ToolsetTool) Description() string { + return "List available toolsets or resolve one to its concrete tool list. Toolsets are named, composable groups (research, dev, ops, full_stack); resolving expands required toolsets transitively." +} + +func (ToolsetTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "enum": []string{"list", "resolve"}, + "description": "list: show available toolsets; resolve: expand a toolset to its tools.", + }, + "name": map[string]interface{}{ + "type": "string", + "description": "Toolset name to resolve (action=resolve).", + }, + }, + "required": []string{"action"}, + } +} + +func (ToolsetTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { + var p struct { + Action string `json:"action"` + Name string `json:"name"` + } + if err := json.Unmarshal(input, &p); err != nil { + return "", fmt.Errorf("invalid input: %w", err) + } + reg, err := toolset.NewRegistry(toolset.Defaults()) + if err != nil { + return "", err + } + switch strings.ToLower(strings.TrimSpace(p.Action)) { + case "list": + return "Available toolsets: " + strings.Join(reg.Names(), ", "), nil + case "resolve": + tools, err := reg.Resolve(strings.TrimSpace(p.Name)) + if err != nil { + return "", err + } + payload := map[string]interface{}{ + "toolset": strings.TrimSpace(p.Name), + "tools": tools, + "count": len(tools), + } + out, _ := json.MarshalIndent(payload, "", " ") + return string(out), nil + default: + return "", fmt.Errorf("unsupported action %q (use list or resolve)", p.Action) + } +} diff --git a/internal/tool/toolset_tool_test.go b/internal/tool/toolset_tool_test.go new file mode 100644 index 00000000..df09f559 --- /dev/null +++ b/internal/tool/toolset_tool_test.go @@ -0,0 +1,59 @@ +package tool + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestToolsetToolList(t *testing.T) { + out, err := (ToolsetTool{}).Execute(context.Background(), json.RawMessage(`{"action":"list"}`)) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for _, want := range []string{"research", "dev", "ops", "full_stack"} { + if !strings.Contains(out, want) { + t.Fatalf("list missing %q: %s", want, out) + } + } +} + +func TestToolsetToolResolve(t *testing.T) { + out, err := (ToolsetTool{}).Execute(context.Background(), json.RawMessage(`{"action":"resolve","name":"research"}`)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "WebSearch") || !strings.Contains(out, "CodeMatch") { + t.Fatalf("resolve output missing expected tools: %s", out) + } + var resp struct { + Toolset string `json:"toolset"` + Tools []string `json:"tools"` + Count int `json:"count"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatal(err) + } + if resp.Toolset != "research" || resp.Count != len(resp.Tools) || resp.Count == 0 { + t.Fatalf("resp = %+v", resp) + } +} + +func TestToolsetToolResolveFullStackComposes(t *testing.T) { + out, err := (ToolsetTool{}).Execute(context.Background(), json.RawMessage(`{"action":"resolve","name":"full_stack"}`)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Bash", "Edit", "Write", "CronCreate", "WebFetch"} { + if !strings.Contains(out, `"`+want+`"`) { + t.Fatalf("full_stack missing %q: %s", want, out) + } + } +} + +func TestToolsetToolUnknown(t *testing.T) { + if _, err := (ToolsetTool{}).Execute(context.Background(), json.RawMessage(`{"action":"resolve","name":"nope"}`)); err == nil { + t.Fatal("expected error for unknown toolset") + } +} diff --git a/internal/toolset/toolset.go b/internal/toolset/toolset.go new file mode 100644 index 00000000..ca31201b --- /dev/null +++ b/internal/toolset/toolset.go @@ -0,0 +1,114 @@ +// Package toolset provides named, composable tool groups for scoping an +// agent's tool surface, adopting Hermes Agent's toolset system. A toolset is +// a named set of tools that can be composed from other toolsets; resolving a +// set expands its Requires transitively (cycle-safe). This lets an agent +// (or a /toolset command) switch between focused surfaces such as +// "research", "dev", or "full_stack" instead of always advertising every tool. +package toolset + +import "sort" + +// Toolset is a named group of tools, optionally composing other toolsets. +type Toolset struct { + Name string + Tools []string + Requires []string // other toolset names to expand +} + +// Registry holds the known toolsets and is the lookup source for resolution. +type Registry struct { + sets map[string]Toolset +} + +// NewRegistry builds a registry from the given toolsets, returning an error on +// duplicate names. +func NewRegistry(sets []Toolset) (*Registry, error) { + r := &Registry{sets: map[string]Toolset{}} + for _, s := range sets { + if _, dup := r.sets[s.Name]; dup { + return nil, errDup(s.Name) + } + r.sets[s.Name] = s + } + return r, nil +} + +// Resolve expands a toolset name to the full, de-duplicated, sorted list of +// tools, including every transitively required toolset. Unknown names return +// an error. Cycles are tolerated (no infinite recursion). +func (r *Registry) Resolve(name string) ([]string, error) { + seenSet := map[string]bool{} + seenTool := map[string]bool{} + var out []string + + var expand func(n string) error + expand = func(n string) error { + if seenSet[n] { + return nil + } + s, ok := r.sets[n] + if !ok { + return errUnknown(n) + } + seenSet[n] = true + for _, req := range s.Requires { + if err := expand(req); err != nil { + return err + } + } + for _, t := range s.Tools { + if !seenTool[t] { + seenTool[t] = true + out = append(out, t) + } + } + return nil + } + if err := expand(name); err != nil { + return nil, err + } + sort.Strings(out) + return out, nil +} + +// Names returns all registered toolset names, sorted. +func (r *Registry) Names() []string { + out := make([]string, 0, len(r.sets)) + for n := range r.sets { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// Defaults returns the built-in toolset set. +func Defaults() []Toolset { + return []Toolset{ + { + Name: "research", + Tools: []string{"WebFetch", "WebSearch", "CodeSearch", "CodeMatch", "Grep", "Glob", "Read"}, + }, + { + Name: "dev", + Tools: []string{"Read", "Write", "Edit", "Bash", "Grep", "Glob", "CodeMatch", "ProjectVerify", "TaskRun"}, + Requires: []string{"research"}, + }, + { + Name: "ops", + Tools: []string{"Bash", "CronCreate", "CronDelete", "WebFetch", "Read"}, + Requires: []string{"research"}, + }, + { + Name: "full_stack", + Requires: []string{"dev", "ops"}, + }, + } +} + +func errDup(n string) error { return &RegError{"duplicate toolset: " + n} } +func errUnknown(n string) error { return &RegError{"unknown toolset: " + n} } + +// RegError is a toolset registry error. +type RegError struct{ msg string } + +func (e *RegError) Error() string { return "toolset: " + e.msg } diff --git a/internal/toolset/toolset_test.go b/internal/toolset/toolset_test.go new file mode 100644 index 00000000..e03665da --- /dev/null +++ b/internal/toolset/toolset_test.go @@ -0,0 +1,95 @@ +package toolset + +import ( + "reflect" + "sort" + "testing" +) + +func TestResolveFlat(t *testing.T) { + r, _ := NewRegistry([]Toolset{{Name: "research", Tools: []string{"WebSearch", "Read"}}}) + tools, err := r.Resolve("research") + if err != nil { + t.Fatal(err) + } + want := []string{"Read", "WebSearch"} + if !reflect.DeepEqual(tools, want) { + t.Fatalf("tools = %v, want %v", tools, want) + } +} + +func TestResolveComposedAndDeduped(t *testing.T) { + r, _ := NewRegistry(Defaults()) + tools, err := r.Resolve("full_stack") + if err != nil { + t.Fatal(err) + } + // dev+ops both include research; shared tools (Read, Bash, WebFetch) must + // appear once. + seen := map[string]bool{} + dupes := 0 + for _, t := range tools { + if seen[t] { + dupes++ + } + seen[t] = true + } + if dupes != 0 { + t.Fatalf("duplicate tools after resolve: %d", dupes) + } + // dev tools must be present. + for _, want := range []string{"Read", "Write", "Edit", "Bash", "CodeMatch", "ProjectVerify"} { + if !seen[want] { + t.Fatalf("dev tool %q missing from full_stack: %v", want, tools) + } + } + if !sort.StringsAreSorted(tools) { + t.Fatal("resolved tools must be sorted") + } +} + +func TestResolveCycleSafe(t *testing.T) { + r, _ := NewRegistry([]Toolset{ + {Name: "a", Requires: []string{"b"}}, + {Name: "b", Requires: []string{"a"}, Tools: []string{"X"}}, + }) + tools, err := r.Resolve("a") + if err != nil { + t.Fatal(err) + } + if len(tools) != 1 || tools[0] != "X" { + t.Fatalf("tools = %v", tools) + } +} + +func TestResolveUnknown(t *testing.T) { + r, _ := NewRegistry(Defaults()) + if _, err := r.Resolve("nope"); err == nil { + t.Fatal("expected error for unknown toolset") + } +} + +func TestDuplicateNameError(t *testing.T) { + if _, err := NewRegistry([]Toolset{{Name: "x"}, {Name: "x"}}); err == nil { + t.Fatal("expected duplicate name error") + } +} + +func TestNamesSorted(t *testing.T) { + r, _ := NewRegistry(Defaults()) + names := r.Names() + if !sort.StringsAreSorted(names) { + t.Fatalf("names not sorted: %v", names) + } + for _, want := range []string{"dev", "full_stack", "ops", "research"} { + found := false + for _, n := range names { + if n == want { + found = true + } + } + if !found { + t.Fatalf("missing %q in %v", want, names) + } + } +}