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
46 changes: 46 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,49 @@ goimports -w . # fix import ordering
- Don't create subpackages prematurely — this is a CLI tool, not a library.
- Don't commit `worktrees/` directory contents.
- Don't use `os.WriteFile` directly for config/state — use `atomicWriteFile`.

<!-- hivemind-memory-start -->
## Hivemind Memory

Hivemind maintains an IDE-wide persistent memory store across all sessions and projects.

### Rules

- **Before answering** any question about the user's preferences, setup, past decisions, or active projects: call `memory_search` first.
- **After every session** where you learn something durable: call `memory_write` to persist it.
- Write **stable facts** (hardware, OS, global preferences) to `global.md` using `scope="global"`. Write **project decisions** with `scope="repo"` (default for dated files).
- **When asked to write memory at session end**: Do it immediately. Call memory_write with a concise summary of: (1) what was built/changed, (2) key decisions made, (3) any user preferences expressed.

### What is worth writing to memory

- User's OS, hardware, terminal and editor setup
- API keys, services, and credentials configured
- Project tech stack decisions and the reasoning behind them
- Recurring patterns the user likes or dislikes
- Anything you had to look up or figure out that the user will likely ask again

### Tools

| Tool | When to use |
|------|-------------|
| `memory_search(query)` | Start of session, before answering questions about prior context |
| `memory_write(content, file?, scope?)` | scope="repo" for this project's decisions; scope="global" for user preferences/hardware |
| `memory_get(path, from?, lines?)` | Read specific lines from a memory file |
| `memory_list()` | Browse all memory files |

### Global context

**[global.md L3]** ## Hardware & OS
- **Machine**: Apple M4 Pro, 24 GB RAM
- **OS**: macOS 26.2 (Darwin 25.2.0, kernel arm64) — codename Tahoe
- **Shell**: zsh
- **Hostname**: DE08-M0079
- **User**: fabian.urbanek

**[global.md L1]** # Global Setup

### Repo context (memory_1896e0b419b5aa60)

*(no repo memory yet)*

<!-- hivemind-memory-end -->
10 changes: 9 additions & 1 deletion app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,11 @@ func newHome(ctx context.Context, program string, autoYes bool) *home {
if appConfig.Memory != nil && appConfig.Memory.StartupInjectCount > 0 {
injectCount = appConfig.Memory.StartupInjectCount
}
session.SetMemoryManager(memMgr, injectCount)
sysBudget := 4000
if appConfig.Memory != nil && appConfig.Memory.SystemBudgetChars > 0 {
sysBudget = appConfig.Memory.SystemBudgetChars
}
session.SetMemoryManager(memMgr, injectCount, sysBudget)
if stop, err := memMgr.StartWatcher(); err != nil {
log.WarningLog.Printf("memory watcher: %v", err)
} else {
Expand Down Expand Up @@ -705,6 +709,10 @@ func (m *home) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case onboardingStartedMsg:
if msg.err != nil {
log.WarningLog.Printf("onboarding: companion failed to start: %v", msg.err)
// Companion failed — fall back to the normal UI so the user is not stuck.
m.state = stateDefault
m.toastManager.Error("Companion failed to start: " + msg.err.Error())
return m, tea.Batch(tea.WindowSize(), m.toastTickCmd())
}
// Trigger a window size update so layout is recalculated for the companion view.
return m, tea.WindowSize()
Expand Down
27 changes: 24 additions & 3 deletions app/app_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,7 @@ func (m *home) handleKeyPress(msg tea.KeyMsg) (mod tea.Model, cmd tea.Cmd) {
case stateNewChatAgent:
return m.handleNewChatAgentKeys(msg)
case stateOnboarding:
// Block all keys during onboarding; the companion session drives interaction.
return m, nil
return m.handleOnboardingKeys(msg)
default:
return m.handleDefaultKeys(msg)
}
Expand Down Expand Up @@ -770,6 +769,24 @@ func (m *home) handleRenameTopicKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

// handleOnboardingKeys forwards keypresses to the companion's tmux session so
// the user can interact with the companion during the first-launch ritual.
func (m *home) handleOnboardingKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
companion := m.findInstanceByTitle("companion")
if companion == nil || !companion.Started() {
// Companion not yet started — ignore keys.
return m, nil
}
data := keyToBytes(msg)
if data == nil {
return m, nil
}
if err := companion.SendKeys(string(data)); err != nil {
log.WarningLog.Printf("onboarding: failed to forward key to companion: %v", err)
}
return m, nil
}

func (m *home) handleFocusAgentKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Ctrl+O exits focus mode
if msg.Type == tea.KeyCtrlO {
Expand Down Expand Up @@ -2259,12 +2276,16 @@ func (m *home) createChatAgent(name string) (tea.Model, tea.Cmd) {
// Refresh the list so the new agent appears in the Chat tab.
m.refreshListChatFilter()

// Notify the tabbed window about the new selection so it enters chat mode
// and clears stale content from any previously selected instance.
instanceChangedCmd := m.instanceChanged()

startCmd := func() tea.Msg {
if err := agent.Start(true); err != nil {
return instanceStartedMsg{instance: agent, err: err}
}
return instanceStartedMsg{instance: agent, err: nil}
}

return m, tea.Batch(tea.WindowSize(), startCmd)
return m, tea.Batch(tea.WindowSize(), instanceChangedCmd, startCmd)
}
13 changes: 10 additions & 3 deletions app/app_onboarding.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ func (m *home) startOnboarding() (tea.Model, tea.Cmd) {
personalityDir, err := session.GetAgentPersonalityDir(slug)
if err != nil {
log.WarningLog.Printf("onboarding: GetAgentPersonalityDir: %v", err)
return m, nil
startErr := err
return m, func() tea.Msg { return onboardingStartedMsg{err: startErr} }
}

companion, err := session.NewInstance(session.InstanceOptions{
Expand All @@ -35,7 +36,8 @@ func (m *home) startOnboarding() (tea.Model, tea.Cmd) {
})
if err != nil {
log.WarningLog.Printf("onboarding: NewInstance: %v", err)
return m, nil
startErr := err
return m, func() tea.Msg { return onboardingStartedMsg{err: startErr} }
}

m.allInstances = append(m.allInstances, companion)
Expand Down Expand Up @@ -89,5 +91,10 @@ func (m *home) viewOnboarding() string {
Padding(1, 2).
Render(preview)

return lipgloss.Place(m.width, totalHeight, lipgloss.Center, lipgloss.Center, panel)
hint := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Render("Type to talk to your companion")

combined := lipgloss.JoinVertical(lipgloss.Center, panel, hint)
return lipgloss.Place(m.width, totalHeight, lipgloss.Center, lipgloss.Center, combined)
}
13 changes: 13 additions & 0 deletions app/app_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,25 @@ func accumulateInstanceStats(instances []*session.Instance) (countByTopic map[st
}

func (m *home) updateSidebarItemsSingleRepo() {
// Chat tab has no code topics — show only ungrouped chat agents.
if m.sidebarTab == sidebarTabChat {
_, _, topicStatuses := accumulateInstanceStats(m.list.GetInstances())
m.sidebar.SetItems(nil, nil, len(m.list.GetInstances()), nil, nil, topicStatuses)
return
}
topicNames, sharedTopics, autoYesTopics := topicMeta(m.topics)
countByTopic, ungroupedCount, topicStatuses := accumulateInstanceStats(m.list.GetInstances())
m.sidebar.SetItems(topicNames, countByTopic, ungroupedCount, sharedTopics, autoYesTopics, topicStatuses)
}

func (m *home) updateSidebarItemsMultiRepo() {
// Chat tab in multi-repo mode: no repo groups or code topics, just ungrouped chat agents.
if m.sidebarTab == sidebarTabChat {
_, _, topicStatuses := accumulateInstanceStats(m.list.GetInstances())
m.sidebar.SetItems(nil, nil, len(m.list.GetInstances()), nil, nil, topicStatuses)
return
}

allInstances := m.list.GetInstances()
groups := make([]ui.RepoGroup, 0, len(m.activeRepoPaths))

Expand Down
6 changes: 5 additions & 1 deletion app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,5 +391,9 @@ func (m *home) restartMemoryManager() {
if m.appConfig.Memory != nil && m.appConfig.Memory.StartupInjectCount > 0 {
injectCount = m.appConfig.Memory.StartupInjectCount
}
session.SetMemoryManager(mgr, injectCount)
sysBudget := 4000
if m.appConfig.Memory != nil && m.appConfig.Memory.SystemBudgetChars > 0 {
sysBudget = m.appConfig.Memory.SystemBudgetChars
}
session.SetMemoryManager(mgr, injectCount, sysBudget)
}
6 changes: 6 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ type MemoryConfig struct {
// StartupInjectCount controls how many memory snippets are injected into
// CLAUDE.md when starting an agent. Default 5.
StartupInjectCount int `json:"startup_inject_count,omitempty"`
// GitEnabled controls whether memory changes are git-versioned.
// Default true. Set to false to disable auto-commits in the memory directory.
GitEnabled *bool `json:"git_enabled,omitempty"`
// SystemBudgetChars is the max characters of system/ file content injected
// into CLAUDE.md at startup. Default 4000.
SystemBudgetChars int `json:"system_budget_chars,omitempty"`
}

// Config represents the application configuration
Expand Down
Binary file modified hivemind-mcp
Binary file not shown.
Binary file added hivemind-race
Binary file not shown.
143 changes: 143 additions & 0 deletions mcp/memory_skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package mcp

import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/ByteMirror/hivemind/brain"
"github.com/ByteMirror/hivemind/memory"
gomcp "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
)

// handleMemoryInit spawns an agent to bootstrap memory from codebase analysis.
func handleMemoryInit(mgr *memory.Manager, client BrainClient, repoPath, instanceID string) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req gomcp.CallToolRequest) (*gomcp.CallToolResult, error) {
Log("tool call: memory_init (instanceID=%s)", instanceID)

title := fmt.Sprintf("memory-init-%d", time.Now().Unix())
prompt := "Analyze this codebase and the user's environment. Use the memory tools to create organized memory files:\n" +
"1. system/global.md — hardware, OS, shell, tools (use frontmatter: description: \"Hardware and OS info\")\n" +
"2. system/conventions.md — code patterns, style, architecture decisions\n" +
"3. A project-specific file with key decisions and tech stack\n\n" +
"Use memory_tree first to see what already exists. Don't overwrite existing files — append or create new ones.\n" +
"Use YAML frontmatter (---\\ndescription: ...\\n---) at the top of each file."

skipPerms := true
result, err := client.CreateInstance(repoPath, instanceID, brain.CreateInstanceParams{
Title: title,
Prompt: prompt,
Role: "architect",
SkipPermissions: &skipPerms,
})
if err != nil {
Log("memory_init error: %v", err)
return gomcp.NewToolResultError("failed to spawn memory init agent: " + err.Error()), nil
}

data, _ := json.Marshal(result)
Log("memory_init: spawned %s", title)
return gomcp.NewToolResultText(fmt.Sprintf("Memory init agent spawned: %s\n%s", title, string(data))), nil
}
}

// handleMemoryReflect spawns an agent to review recent memory changes and persist insights.
func handleMemoryReflect(mgr *memory.Manager, client BrainClient, repoPath, instanceID string) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req gomcp.CallToolRequest) (*gomcp.CallToolResult, error) {
Log("tool call: memory_reflect (instanceID=%s)", instanceID)

// Gather recent history to include in the prompt.
var historySection string
entries, err := mgr.History("", 20)
if err == nil && len(entries) > 0 {
var sb strings.Builder
sb.WriteString("Recent memory changes:\n")
for _, e := range entries {
sb.WriteString(fmt.Sprintf("- [%s] %s (%s)\n", e.Date, e.Message, strings.Join(e.Files, ", ")))
}
historySection = sb.String()
} else {
historySection = "No recent history available."
}

title := fmt.Sprintf("memory-reflect-%d", time.Now().Unix())
today := time.Now().Format("2006-01-02")
prompt := fmt.Sprintf("Review the recent memory activity and write a reflection.\n\n%s\n\n"+
"Instructions:\n"+
"1. Use memory_tree and memory_search to understand the current state\n"+
"2. Look for patterns, consolidate duplicates, identify gaps\n"+
"3. Write a concise reflection to reflections/%s.md using memory_write\n"+
"4. If you notice duplicate or contradictory information, use memory_move/memory_delete to clean up",
historySection, today)

skipPerms := true
result, err := client.CreateInstance(repoPath, instanceID, brain.CreateInstanceParams{
Title: title,
Prompt: prompt,
Role: "reviewer",
SkipPermissions: &skipPerms,
})
if err != nil {
Log("memory_reflect error: %v", err)
return gomcp.NewToolResultError("failed to spawn memory reflect agent: " + err.Error()), nil
}

data, _ := json.Marshal(result)
Log("memory_reflect: spawned %s", title)
return gomcp.NewToolResultText(fmt.Sprintf("Memory reflect agent spawned: %s\n%s", title, string(data))), nil
}
}

// handleMemoryDefrag spawns an agent to reorganize aging memory files.
func handleMemoryDefrag(mgr *memory.Manager, client BrainClient, repoPath, instanceID string) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req gomcp.CallToolRequest) (*gomcp.CallToolResult, error) {
Log("tool call: memory_defrag (instanceID=%s)", instanceID)

// Gather file list to include in the prompt.
var fileListSection string
files, err := mgr.List()
if err == nil && len(files) > 0 {
var sb strings.Builder
sb.WriteString("Current memory files:\n")
for _, f := range files {
sizeKB := float64(f.SizeBytes) / 1024.0
sb.WriteString(fmt.Sprintf("- %s (%.1fK, %d chunks)\n", f.Path, sizeKB, f.ChunkCount))
}
fileListSection = sb.String()
} else {
fileListSection = "No memory files found."
}

title := fmt.Sprintf("memory-defrag-%d", time.Now().Unix())
prompt := fmt.Sprintf("Reorganize the memory store for clarity and efficiency.\n\n%s\n\n"+
"Instructions:\n"+
"1. Use memory_tree to see the full structure with descriptions\n"+
"2. Aim for 15-25 focused files organized into logical directories\n"+
"3. Use memory_move to rename/reorganize files\n"+
"4. Use memory_write to merge small related files\n"+
"5. Use memory_delete to remove duplicates or obsolete content\n"+
"6. Ensure every file has YAML frontmatter with a description\n"+
"7. Pin important reference files to system/ using memory_pin\n"+
"8. Do NOT delete system/ files unless creating better replacements",
fileListSection)

skipPerms := true
result, err := client.CreateInstance(repoPath, instanceID, brain.CreateInstanceParams{
Title: title,
Prompt: prompt,
Role: "architect",
SkipPermissions: &skipPerms,
})
if err != nil {
Log("memory_defrag error: %v", err)
return gomcp.NewToolResultError("failed to spawn memory defrag agent: " + err.Error()), nil
}

data, _ := json.Marshal(result)
Log("memory_defrag: spawned %s", title)
return gomcp.NewToolResultText(fmt.Sprintf("Memory defrag agent spawned: %s\n%s", title, string(data))), nil
}
}
Loading
Loading