From 5f02879e04d7e57cda928a5127c0ec7d383c9cb7 Mon Sep 17 00:00:00 2001 From: RoX Date: Sat, 27 Jun 2026 03:09:33 -0500 Subject: [PATCH 1/3] feat(components): add Headroom MCP component for context compression Add Headroom (headroomlabs-ai/headroom) as an optional MCP component that compresses LLM context via three tools: compress, retrieve, and stats. Key changes: - New ComponentHeadroom type and catalog entry - Generalized mcp.Inject() with componentID parameter - 7 agent overlay variants (Claude, OpenCode, OpenClaw, VS Code, Antigravity, Kimi, Codex) - Pip auto-install: detects headroom binary, falls back to pip install - Uninstall support: MCP config cleanup across all strategies - 18 inject tests + 8 headroom shape tests - Pinned version 0.27.0 with Renovate directives --- internal/catalog/components.go | 1 + internal/cli/doctor.go | 2 +- internal/cli/install_test.go | 1 + internal/cli/run.go | 41 +- internal/cli/sync.go | 13 +- internal/cli/validate.go | 3 +- internal/components/mcp/headroom.go | 66 +++ internal/components/mcp/headroom_test.go | 213 ++++++++ internal/components/mcp/inject.go | 163 +++++-- internal/components/mcp/inject_test.go | 459 ++++++++++++++++-- internal/components/uninstall/service.go | 42 ++ internal/model/types.go | 1 + internal/planner/graph.go | 1 + internal/tui/model.go | 3 +- internal/versions/versions.go | 3 + .../archive-report.md | 45 ++ .../design.md | 154 ++++++ .../proposal.md | 86 ++++ .../specs/headroom-mcp/spec.md | 127 +++++ .../tasks.md | 54 +++ openspec/specs/headroom-mcp/spec.md | 127 +++++ 21 files changed, 1519 insertions(+), 86 deletions(-) create mode 100644 internal/components/mcp/headroom.go create mode 100644 internal/components/mcp/headroom_test.go create mode 100644 openspec/changes/archive/2026-06-27-headroom-mcp-component/archive-report.md create mode 100644 openspec/changes/archive/2026-06-27-headroom-mcp-component/design.md create mode 100644 openspec/changes/archive/2026-06-27-headroom-mcp-component/proposal.md create mode 100644 openspec/changes/archive/2026-06-27-headroom-mcp-component/specs/headroom-mcp/spec.md create mode 100644 openspec/changes/archive/2026-06-27-headroom-mcp-component/tasks.md create mode 100644 openspec/specs/headroom-mcp/spec.md diff --git a/internal/catalog/components.go b/internal/catalog/components.go index ccf3e0773..b5bb7486b 100644 --- a/internal/catalog/components.go +++ b/internal/catalog/components.go @@ -19,6 +19,7 @@ var mvpComponents = []Component{ {ID: model.ComponentTheme, Name: "Theme", Description: "Gentleman Kanagawa theme overlay"}, {ID: model.ComponentClaudeTheme, Name: "Claude Gentleman Theme", Description: "Claude Code Gentleman custom theme"}, {ID: model.ComponentOpenCodeGentleLogo, Name: "OpenCode Gentle Logo", Description: "OpenCode home logo TUI plugin with Braille rose"}, + {ID: model.ComponentHeadroom, Name: "Headroom", Description: "AI memory compression and retrieval for tool-use chains"}, } func MVPComponents() []Component { diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 54c406bb9..e088d6990 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -38,7 +38,7 @@ type DoctorReport struct { Checks []CheckResult } -var knownTools = []string{"gentle-ai", "engram", "gga", "claude", "opencode"} +var knownTools = []string{"gentle-ai", "engram", "gga", "claude", "opencode", "headroom"} const ( engramHealthEnvVar = "ENGRAM_BASE_URL" diff --git a/internal/cli/install_test.go b/internal/cli/install_test.go index 09d2b146c..7ba4cd084 100644 --- a/internal/cli/install_test.go +++ b/internal/cli/install_test.go @@ -79,6 +79,7 @@ func TestNormalizeInstallFlagsDefaults(t *testing.T) { model.ComponentContext7, model.ComponentPermission, model.ComponentGGA, + model.ComponentHeadroom, model.ComponentClaudeTheme, model.ComponentOpenCodeGentleLogo, model.ComponentPersona, diff --git a/internal/cli/run.go b/internal/cli/run.go index 75e7748d1..3fb16e345 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -34,6 +34,7 @@ import ( "github.com/gentleman-programming/gentle-ai/internal/planner" "github.com/gentleman-programming/gentle-ai/internal/state" "github.com/gentleman-programming/gentle-ai/internal/system" + "github.com/gentleman-programming/gentle-ai/internal/versions" "github.com/gentleman-programming/gentle-ai/internal/verify" ) @@ -866,11 +867,32 @@ func (s componentApplyStep) Run() error { return nil case model.ComponentContext7: for _, adapter := range adapters { - if _, err := mcp.Inject(s.homeDir, adapter); err != nil { + if _, err := mcp.Inject(s.homeDir, adapter, model.ComponentContext7); err != nil { return fmt.Errorf("inject context7 for %q: %w", adapter.Agent(), err) } } return nil + case model.ComponentHeadroom: + if _, err := cmdLookPath("headroom"); err != nil { + // headroom not on PATH — install the pip package. + pipCmd := "pip" + if _, pipErr := cmdLookPath(pipCmd); pipErr != nil { + pipCmd = "pip3" + if _, pip3Err := cmdLookPath(pipCmd); pip3Err != nil { + return fmt.Errorf("headroom: %[1]w and pip/pip3 not found — install headroom-ai[all] via pip or add headroom to PATH before running install: %[1]w", err) + } + } + fmt.Fprintf(os.Stderr, "INFO: headroom binary not found on PATH — installing headroom-ai[all]==%s via %s\n", versions.HeadroomMCP, pipCmd) + if installErr := runCommand(pipCmd, "install", fmt.Sprintf("headroom-ai[all]==%s", versions.HeadroomMCP)); installErr != nil { + return fmt.Errorf("install headroom via %s: %w", pipCmd, installErr) + } + } + for _, adapter := range adapters { + if _, err := mcp.Inject(s.homeDir, adapter, model.ComponentHeadroom); err != nil { + return fmt.Errorf("inject headroom for %q: %w", adapter.Agent(), err) + } + } + return nil case model.ComponentPersona: for _, adapter := range adapters { targetDir := componentInjectionDirScoped(s.homeDir, s.workspaceDir, s.scope, adapter) @@ -1292,6 +1314,23 @@ func componentPathsWithWorkspaceScoped(homeDir, workspaceDir string, scope Insta paths = append(paths, p) } } + case model.ComponentHeadroom: + switch adapter.MCPStrategy() { + case model.StrategySeparateMCPFiles: + paths = append(paths, adapter.MCPConfigPath(homeDir, "headroom")) + case model.StrategyMergeIntoSettings: + if p := adapter.SettingsPath(homeDir); p != "" { + paths = append(paths, p) + } + case model.StrategyMCPConfigFile: + if p := adapter.MCPConfigPath(homeDir, "headroom"); p != "" { + paths = append(paths, p) + } + case model.StrategyTOMLFile: + if p := adapter.MCPConfigPath(homeDir, "headroom"); p != "" { + paths = append(paths, p) + } + } case model.ComponentPersona: if selection.Persona == model.PersonaCustom { break diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 16be5e9a5..13ee8a363 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -295,6 +295,7 @@ func BuildSyncSelection(flags SyncFlags, agentIDs []model.AgentID) model.Selecti model.ComponentSDD, model.ComponentEngram, model.ComponentContext7, + model.ComponentHeadroom, model.ComponentGGA, model.ComponentSkills, } @@ -629,7 +630,7 @@ func (s componentSyncStep) Run() error { case model.ComponentContext7: for _, adapter := range adapters { - res, err := mcp.Inject(s.homeDir, adapter) + res, err := mcp.Inject(s.homeDir, adapter, model.ComponentContext7) if err != nil { return fmt.Errorf("sync context7 for %q: %w", adapter.Agent(), err) } @@ -637,6 +638,16 @@ func (s componentSyncStep) Run() error { } return nil + case model.ComponentHeadroom: + for _, adapter := range adapters { + res, err := mcp.Inject(s.homeDir, adapter, model.ComponentHeadroom) + if err != nil { + return fmt.Errorf("sync headroom for %q: %w", adapter.Agent(), err) + } + s.countChanged(boolToInt(res.Changed), res.Files...) + } + return nil + case model.ComponentSDD: profileStrategy := sdd.ResolveProfileStrategy(s.homeDir, s.selection.SDDProfileStrategy) diff --git a/internal/cli/validate.go b/internal/cli/validate.go index 186db5fc5..615a7d2a1 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -161,7 +161,7 @@ func componentsForPreset(preset model.PresetID, persona model.PersonaID) []model case model.PresetMinimal: components = []model.ComponentID{model.ComponentEngram} case model.PresetEcosystemOnly: - components = []model.ComponentID{model.ComponentEngram, model.ComponentSDD, model.ComponentSkills, model.ComponentContext7, model.ComponentGGA} + components = []model.ComponentID{model.ComponentEngram, model.ComponentSDD, model.ComponentSkills, model.ComponentContext7, model.ComponentGGA, model.ComponentHeadroom} case model.PresetCustom: return nil default: // full-gentleman @@ -172,6 +172,7 @@ func componentsForPreset(preset model.PresetID, persona model.PersonaID) []model model.ComponentContext7, model.ComponentPermission, model.ComponentGGA, + model.ComponentHeadroom, model.ComponentClaudeTheme, model.ComponentOpenCodeGentleLogo, } diff --git a/internal/components/mcp/headroom.go b/internal/components/mcp/headroom.go new file mode 100644 index 000000000..e8bf4d732 --- /dev/null +++ b/internal/components/mcp/headroom.go @@ -0,0 +1,66 @@ +package mcp + +var defaultHeadroomServerJSON = []byte("{\n \"command\": \"headroom\",\n \"args\": [\n \"mcp\",\n \"serve\"\n ]\n}\n") + +var defaultHeadroomOverlayJSON = []byte("{\n \"mcpServers\": {\n \"headroom\": {\n \"command\": \"headroom\",\n \"args\": [\n \"mcp\",\n \"serve\"\n ]\n }\n }\n}\n") + +// openCodeHeadroomOverlayJSON is the opencode.json overlay using the new MCP format. +// Headroom is a local stdio MCP server. +// The headroom entry must replace atomically so legacy local keys do not survive +// deep merge into OpenCode/KiloCode's strict MCP schema. +var openCodeHeadroomOverlayJSON = []byte("{\n \"mcp\": {\n \"headroom\": {\n \"__replace__\": {\n \"type\": \"local\",\n \"command\": \"headroom\",\n \"args\": [\n \"mcp\",\n \"serve\"\n ],\n \"enabled\": true\n }\n }\n }\n}\n") + +// openClawHeadroomOverlayJSON is the OpenClaw openclaw.json overlay. +var openClawHeadroomOverlayJSON = []byte("{\n \"mcp\": {\n \"servers\": {\n \"headroom\": {\n \"command\": \"headroom\",\n \"args\": [\n \"mcp\",\n \"serve\"\n ]\n }\n }\n }\n}\n") + +// vsCodeHeadroomOverlayJSON is the VS Code mcp.json overlay using the "servers" key. +var vsCodeHeadroomOverlayJSON = []byte("{\n \"servers\": {\n \"headroom\": {\n \"command\": \"headroom\",\n \"args\": [\n \"mcp\",\n \"serve\"\n ]\n }\n }\n}\n") + +// antigravityHeadroomOverlayJSON is the Antigravity mcp_config.json overlay. +// Uses mcpServers key with command/args for local stdio. +var antigravityHeadroomOverlayJSON = []byte("{\n \"mcpServers\": {\n \"headroom\": {\n \"__replace__\": {\n \"command\": \"headroom\",\n \"args\": [\n \"mcp\",\n \"serve\"\n ]\n }\n }\n }\n}\n") + +// kimiHeadroomOverlayJSON follows Kimi's documented mcp.json format for local servers. +var kimiHeadroomOverlayJSON = []byte("{\n \"mcpServers\": {\n \"headroom\": {\n \"__replace__\": {\n \"command\": \"headroom\",\n \"args\": [\n \"mcp\",\n \"serve\"\n ]\n }\n }\n }\n}\n") + +func DefaultHeadroomServerJSON() []byte { + content := make([]byte, len(defaultHeadroomServerJSON)) + copy(content, defaultHeadroomServerJSON) + return content +} + +func DefaultHeadroomOverlayJSON() []byte { + content := make([]byte, len(defaultHeadroomOverlayJSON)) + copy(content, defaultHeadroomOverlayJSON) + return content +} + +func OpenCodeHeadroomOverlayJSON() []byte { + content := make([]byte, len(openCodeHeadroomOverlayJSON)) + copy(content, openCodeHeadroomOverlayJSON) + return content +} + +func OpenClawHeadroomOverlayJSON() []byte { + content := make([]byte, len(openClawHeadroomOverlayJSON)) + copy(content, openClawHeadroomOverlayJSON) + return content +} + +func VSCodeHeadroomOverlayJSON() []byte { + content := make([]byte, len(vsCodeHeadroomOverlayJSON)) + copy(content, vsCodeHeadroomOverlayJSON) + return content +} + +func AntigravityHeadroomOverlayJSON() []byte { + content := make([]byte, len(antigravityHeadroomOverlayJSON)) + copy(content, antigravityHeadroomOverlayJSON) + return content +} + +func KimiHeadroomOverlayJSON() []byte { + content := make([]byte, len(kimiHeadroomOverlayJSON)) + copy(content, kimiHeadroomOverlayJSON) + return content +} diff --git a/internal/components/mcp/headroom_test.go b/internal/components/mcp/headroom_test.go new file mode 100644 index 000000000..8b6be0f59 --- /dev/null +++ b/internal/components/mcp/headroom_test.go @@ -0,0 +1,213 @@ +package mcp + +import ( + "encoding/json" + "testing" +) + +func TestDefaultHeadroomServerJSON(t *testing.T) { + content := DefaultHeadroomServerJSON() + if len(content) == 0 { + t.Fatal("DefaultHeadroomServerJSON() returned empty") + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("DefaultHeadroomServerJSON() invalid JSON: %v", err) + } + + cmd, ok := parsed["command"].(string) + if !ok || cmd != "headroom" { + t.Fatalf("command = %#v, want %q", parsed["command"], "headroom") + } + + args, ok := parsed["args"].([]any) + if !ok || len(args) == 0 { + t.Fatalf("args missing or empty") + } + if args[0] != "mcp" || args[1] != "serve" { + t.Fatalf("args = %v, want [mcp serve]", args) + } +} + +func TestDefaultHeadroomOverlayJSON(t *testing.T) { + content := DefaultHeadroomOverlayJSON() + if len(content) == 0 { + t.Fatal("DefaultHeadroomOverlayJSON() returned empty") + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("DefaultHeadroomOverlayJSON() invalid JSON: %v", err) + } + + mcpServers, ok := parsed["mcpServers"].(map[string]any) + if !ok { + t.Fatal("mcpServers key missing or not object") + } + + headroom, ok := mcpServers["headroom"].(map[string]any) + if !ok { + t.Fatal("mcpServers.headroom missing or not object") + } + + if headroom["command"] != "headroom" { + t.Fatalf("command = %#v, want %q", headroom["command"], "headroom") + } +} + +func TestOpenCodeHeadroomOverlayJSON(t *testing.T) { + content := OpenCodeHeadroomOverlayJSON() + if len(content) == 0 { + t.Fatal("OpenCodeHeadroomOverlayJSON() returned empty") + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("OpenCodeHeadroomOverlayJSON() invalid JSON: %v", err) + } + + mcp, ok := parsed["mcp"].(map[string]any) + if !ok { + t.Fatal("mcp key missing") + } + + headroom, ok := mcp["headroom"].(map[string]any) + if !ok { + t.Fatal("mcp.headroom missing") + } + + replace, ok := headroom["__replace__"].(map[string]any) + if !ok { + t.Fatal("mcp.headroom.__replace__ missing") + } + + if replace["type"] != "local" { + t.Fatalf("type = %#v, want %q", replace["type"], "local") + } + if replace["command"] != "headroom" { + t.Fatalf("command = %#v, want %q", replace["command"], "headroom") + } + if replace["enabled"] != true { + t.Fatalf("enabled = %#v, want true", replace["enabled"]) + } +} + +func TestOpenClawHeadroomOverlayJSON(t *testing.T) { + content := OpenClawHeadroomOverlayJSON() + if len(content) == 0 { + t.Fatal("OpenClawHeadroomOverlayJSON() returned empty") + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("OpenClawHeadroomOverlayJSON() invalid JSON: %v", err) + } + if _, ok := parsed["mcp"]; !ok { + t.Fatal("mcp key missing") + } +} + +func TestVSCodeHeadroomOverlayJSON(t *testing.T) { + content := VSCodeHeadroomOverlayJSON() + if len(content) == 0 { + t.Fatal("VSCodeHeadroomOverlayJSON() returned empty") + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("VSCodeHeadroomOverlayJSON() invalid JSON: %v", err) + } + + servers, ok := parsed["servers"].(map[string]any) + if !ok { + t.Fatal("servers key missing") + } + + headroom, ok := servers["headroom"].(map[string]any) + if !ok { + t.Fatal("servers.headroom missing") + } + if headroom["command"] != "headroom" { + t.Fatalf("servers.headroom.command = %#v, want %q", headroom["command"], "headroom") + } +} + +func TestAntigravityHeadroomOverlayJSON(t *testing.T) { + content := AntigravityHeadroomOverlayJSON() + if len(content) == 0 { + t.Fatal("AntigravityHeadroomOverlayJSON() returned empty") + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("AntigravityHeadroomOverlayJSON() invalid JSON: %v", err) + } + + mcpServers, ok := parsed["mcpServers"].(map[string]any) + if !ok { + t.Fatal("mcpServers key missing") + } + + headroom, ok := mcpServers["headroom"].(map[string]any) + if !ok { + t.Fatal("mcpServers.headroom missing") + } + if _, ok := headroom["__replace__"]; !ok { + t.Fatal("mcpServers.headroom.__replace__ missing") + } +} + +func TestKimiHeadroomOverlayJSON(t *testing.T) { + content := KimiHeadroomOverlayJSON() + if len(content) == 0 { + t.Fatal("KimiHeadroomOverlayJSON() returned empty") + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("KimiHeadroomOverlayJSON() invalid JSON: %v", err) + } + + mcpServers, ok := parsed["mcpServers"].(map[string]any) + if !ok { + t.Fatal("mcpServers key missing") + } + + headroom, ok := mcpServers["headroom"].(map[string]any) + if !ok { + t.Fatal("mcpServers.headroom missing") + } + if _, ok := headroom["__replace__"]; !ok { + t.Fatal("mcpServers.headroom.__replace__ missing") + } +} + +func TestHeadroomServerJSONGetReturnsCopy(t *testing.T) { + orig := DefaultHeadroomServerJSON() + second := DefaultHeadroomServerJSON() + + if len(orig) == 0 { + t.Fatal("original is empty") + } + + // Modify the first copy — verify second copy is unmodified. + orig[0] = ' ' + if string(second[0]) != "{" { + t.Fatal("DefaultHeadroomServerJSON() did not return a copy; mutation affected second call") + } +} + +func TestHeadroomOverlayJSONGetReturnsCopy(t *testing.T) { + orig := DefaultHeadroomOverlayJSON() + second := DefaultHeadroomOverlayJSON() + + if len(orig) == 0 { + t.Fatal("original is empty") + } + + orig[0] = ' ' + if string(second[0]) != "{" { + t.Fatal("DefaultHeadroomOverlayJSON() did not return a copy") + } +} diff --git a/internal/components/mcp/inject.go b/internal/components/mcp/inject.go index 69b628719..cc90214f5 100644 --- a/internal/components/mcp/inject.go +++ b/internal/components/mcp/inject.go @@ -16,22 +16,22 @@ type InjectionResult struct { Files []string } -func Inject(homeDir string, adapter agents.Adapter) (InjectionResult, error) { +func Inject(homeDir string, adapter agents.Adapter, componentID model.ComponentID) (InjectionResult, error) { if !adapter.SupportsMCP() { return InjectionResult{}, nil } switch adapter.MCPStrategy() { case model.StrategySeparateMCPFiles: - return injectSeparateFile(homeDir, adapter) + return injectSeparateFile(homeDir, adapter, componentID) case model.StrategyMergeIntoSettings: - return injectMergeIntoSettings(homeDir, adapter) + return injectMergeIntoSettings(homeDir, adapter, componentID) case model.StrategyMCPConfigFile: - return injectMCPConfigFile(homeDir, adapter) + return injectMCPConfigFile(homeDir, adapter, componentID) case model.StrategyTOMLFile: - return injectTOMLFile(homeDir, adapter) + return injectTOMLFile(homeDir, adapter, componentID) case model.StrategyMergeIntoYAML: - return injectYAMLFile(homeDir, adapter) + return injectYAMLFile(homeDir, adapter, componentID) default: return InjectionResult{}, fmt.Errorf("mcp injector does not support MCP strategy %d for agent %q", adapter.MCPStrategy(), adapter.Agent()) } @@ -42,11 +42,12 @@ func context7Args() []string { return []string{"-y", "--package=@upstash/context7-mcp@" + versions.Context7MCP, "--", "context7-mcp"} } -// injectTOMLFile upserts the [mcp_servers.context7] block into a TOML-based -// agent config file (e.g. ~/.codex/config.toml) using Context7's remote MCP -// endpoint. The file is created if it does not yet exist. -func injectTOMLFile(homeDir string, adapter agents.Adapter) (InjectionResult, error) { - configPath := adapter.MCPConfigPath(homeDir, "context7") +// injectTOMLFile upserts the [mcp_servers.] block into a TOML-based +// agent config file (e.g. ~/.codex/config.toml). The file is created if it +// does not yet exist. +func injectTOMLFile(homeDir string, adapter agents.Adapter, componentID model.ComponentID) (InjectionResult, error) { + serverName := string(componentID) + configPath := adapter.MCPConfigPath(homeDir, serverName) existingBytes, err := osReadFile(configPath) if err != nil { @@ -54,7 +55,13 @@ func injectTOMLFile(homeDir string, adapter agents.Adapter) (InjectionResult, er } existing := string(existingBytes) - updated := filemerge.UpsertCodexRemoteMCPServerBlock(existing, "context7", "https://mcp.context7.com/mcp") + var updated string + switch componentID { + case model.ComponentHeadroom: + updated = filemerge.UpsertCodexMCPServerBlock(existing, serverName, "headroom", []string{"mcp", "serve"}) + default: + updated = filemerge.UpsertCodexRemoteMCPServerBlock(existing, serverName, "https://mcp.context7.com/mcp") + } writeResult, err := filemerge.WriteFileAtomic(configPath, []byte(updated), 0o644) if err != nil { @@ -64,12 +71,11 @@ func injectTOMLFile(homeDir string, adapter agents.Adapter) (InjectionResult, er return InjectionResult{Changed: writeResult.Changed, Files: []string{configPath}}, nil } -// injectYAMLFile upserts the context7 MCP server block into a YAML-based agent +// injectYAMLFile upserts the MCP server block into a YAML-based agent // config file (e.g. ~/.hermes/config.yaml) via the filemerge YAML helpers. -// The file is created if it does not yet exist. The upsert is idempotent and -// comment-preserving — user content outside the managed block is untouched. -func injectYAMLFile(homeDir string, adapter agents.Adapter) (InjectionResult, error) { - configPath := adapter.MCPConfigPath(homeDir, "context7") +func injectYAMLFile(homeDir string, adapter agents.Adapter, componentID model.ComponentID) (InjectionResult, error) { + serverName := string(componentID) + configPath := adapter.MCPConfigPath(homeDir, serverName) raw, err := os.ReadFile(configPath) var existingBytes []byte @@ -83,7 +89,13 @@ func injectYAMLFile(homeDir string, adapter agents.Adapter) (InjectionResult, er } existing := string(existingBytes) - updated := filemerge.UpsertHermesContext7Block(existing) + var updated string + switch componentID { + case model.ComponentHeadroom: + updated = filemerge.UpsertYAMLMCPServerBlock(existing, serverName, "headroom", []string{"mcp", "serve"}, nil) + default: + updated = filemerge.UpsertHermesContext7Block(existing) + } writeResult, err := filemerge.WriteFileAtomic(configPath, []byte(updated), 0o644) if err != nil { @@ -94,9 +106,19 @@ func injectYAMLFile(homeDir string, adapter agents.Adapter) (InjectionResult, er } // injectSeparateFile writes a standalone JSON file per MCP server (Claude Code pattern). -func injectSeparateFile(homeDir string, adapter agents.Adapter) (InjectionResult, error) { - path := adapter.MCPConfigPath(homeDir, "context7") - writeResult, err := filemerge.WriteFileAtomic(path, DefaultContext7ServerJSON(), 0o644) +func injectSeparateFile(homeDir string, adapter agents.Adapter, componentID model.ComponentID) (InjectionResult, error) { + serverName := string(componentID) + path := adapter.MCPConfigPath(homeDir, serverName) + + var serverJSON []byte + switch componentID { + case model.ComponentHeadroom: + serverJSON = DefaultHeadroomServerJSON() + default: + serverJSON = DefaultContext7ServerJSON() + } + + writeResult, err := filemerge.WriteFileAtomic(path, serverJSON, 0o644) if err != nil { return InjectionResult{}, err } @@ -105,29 +127,51 @@ func injectSeparateFile(homeDir string, adapter agents.Adapter) (InjectionResult } // injectMergeIntoSettings merges MCP servers into a config file (OpenCode opencode.json, Gemini settings.json). -func injectMergeIntoSettings(homeDir string, adapter agents.Adapter) (InjectionResult, error) { +func injectMergeIntoSettings(homeDir string, adapter agents.Adapter, componentID model.ComponentID) (InjectionResult, error) { settingsPath := adapter.SettingsPath(homeDir) if settingsPath == "" { return InjectionResult{}, nil } - overlay := DefaultContext7OverlayJSON() - if adapter.Agent() == model.AgentOpenCode || adapter.Agent() == model.AgentKilocode { - overlay = OpenCodeContext7OverlayJSON() - } - if adapter.Agent() == model.AgentOpenClaw { - return injectOpenClawMergeIntoSettings(settingsPath) - } + switch componentID { + case model.ComponentHeadroom: + if adapter.Agent() == model.AgentOpenCode || adapter.Agent() == model.AgentKilocode { + settingsWrite, err := mergeJSONFile(settingsPath, OpenCodeHeadroomOverlayJSON()) + if err != nil { + return InjectionResult{}, err + } + return InjectionResult{Changed: settingsWrite.Changed, Files: []string{settingsPath}}, nil + } + if adapter.Agent() == model.AgentOpenClaw { + return injectOpenClawMergeIntoSettings(settingsPath, componentID) + } - settingsWrite, err := mergeJSONFile(settingsPath, overlay) - if err != nil { - return InjectionResult{}, err - } + settingsWrite, err := mergeJSONFile(settingsPath, DefaultHeadroomOverlayJSON()) + if err != nil { + return InjectionResult{}, err + } + return InjectionResult{Changed: settingsWrite.Changed, Files: []string{settingsPath}}, nil + default: // ComponentContext7 + if adapter.Agent() == model.AgentOpenCode || adapter.Agent() == model.AgentKilocode { + settingsWrite, err := mergeJSONFile(settingsPath, OpenCodeContext7OverlayJSON()) + if err != nil { + return InjectionResult{}, err + } + return InjectionResult{Changed: settingsWrite.Changed, Files: []string{settingsPath}}, nil + } + if adapter.Agent() == model.AgentOpenClaw { + return injectOpenClawMergeIntoSettings(settingsPath, componentID) + } - return InjectionResult{Changed: settingsWrite.Changed, Files: []string{settingsPath}}, nil + settingsWrite, err := mergeJSONFile(settingsPath, DefaultContext7OverlayJSON()) + if err != nil { + return InjectionResult{}, err + } + return InjectionResult{Changed: settingsWrite.Changed, Files: []string{settingsPath}}, nil + } } -func injectOpenClawMergeIntoSettings(settingsPath string) (InjectionResult, error) { +func injectOpenClawMergeIntoSettings(settingsPath string, componentID model.ComponentID) (InjectionResult, error) { baseJSON, err := osReadFile(settingsPath) if err != nil { return InjectionResult{}, err @@ -138,7 +182,15 @@ func injectOpenClawMergeIntoSettings(settingsPath string) (InjectionResult, erro return InjectionResult{}, err } - merged, err := filemerge.MergeJSONObjects(normalized, OpenClawContext7OverlayJSON()) + var overlay []byte + switch componentID { + case model.ComponentHeadroom: + overlay = OpenClawHeadroomOverlayJSON() + default: + overlay = OpenClawContext7OverlayJSON() + } + + merged, err := filemerge.MergeJSONObjects(normalized, overlay) if err != nil { return InjectionResult{}, err } @@ -195,24 +247,39 @@ func migrateOpenClawLegacyMCPServers(baseJSON []byte) ([]byte, error) { } // injectMCPConfigFile writes to a dedicated mcp.json config file (Cursor pattern). -func injectMCPConfigFile(homeDir string, adapter agents.Adapter) (InjectionResult, error) { - path := adapter.MCPConfigPath(homeDir, "context7") +func injectMCPConfigFile(homeDir string, adapter agents.Adapter, componentID model.ComponentID) (InjectionResult, error) { + serverName := string(componentID) + path := adapter.MCPConfigPath(homeDir, serverName) if path == "" { return InjectionResult{}, nil } - overlay := DefaultContext7OverlayJSON() - if adapter.Agent() == model.AgentVSCodeCopilot { - overlay = VSCodeContext7OverlayJSON() - } - if adapter.Agent() == model.AgentAntigravity { - overlay = AntigravityContext7OverlayJSON() - } - if adapter.Agent() == model.AgentKimi { - overlay = KimiContext7OverlayJSON() + var overlay []byte + switch componentID { + case model.ComponentHeadroom: + switch adapter.Agent() { + case model.AgentVSCodeCopilot: + overlay = VSCodeHeadroomOverlayJSON() + case model.AgentAntigravity: + overlay = AntigravityHeadroomOverlayJSON() + case model.AgentKimi: + overlay = KimiHeadroomOverlayJSON() + default: + overlay = DefaultHeadroomOverlayJSON() + } + default: // ComponentContext7 + switch adapter.Agent() { + case model.AgentVSCodeCopilot: + overlay = VSCodeContext7OverlayJSON() + case model.AgentAntigravity: + overlay = AntigravityContext7OverlayJSON() + case model.AgentKimi: + overlay = KimiContext7OverlayJSON() + default: + overlay = DefaultContext7OverlayJSON() + } } - // For mcp.json pattern, merge the server config as a named entry. settingsWrite, err := mergeJSONFile(path, overlay) if err != nil { return InjectionResult{}, err diff --git a/internal/components/mcp/inject_test.go b/internal/components/mcp/inject_test.go index 5929958b3..6d515b04f 100644 --- a/internal/components/mcp/inject_test.go +++ b/internal/components/mcp/inject_test.go @@ -17,6 +17,7 @@ import ( "github.com/gentleman-programming/gentle-ai/internal/agents/openclaw" "github.com/gentleman-programming/gentle-ai/internal/agents/opencode" "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" + "github.com/gentleman-programming/gentle-ai/internal/model" ) func cursorAdapter(t *testing.T) agents.Adapter { @@ -85,6 +86,34 @@ func readOpenCodeContext7Entry(t *testing.T, path string) map[string]any { return context7 } +// readOpenCodeHeadroomEntry reads the mcp.headroom object from an OpenCode/KiloCode +// opencode.json config file. +func readOpenCodeHeadroomEntry(t *testing.T, path string) map[string]any { + t.Helper() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("Unmarshal(%q) error = %v", path, err) + } + + mcp, ok := parsed["mcp"].(map[string]any) + if !ok { + t.Fatalf("%q missing object key mcp; got %#v", path, parsed["mcp"]) + } + + headroom, ok := mcp["headroom"].(map[string]any) + if !ok { + t.Fatalf("%q missing object key mcp.headroom; got %#v", path, mcp["headroom"]) + } + + return headroom +} + // assertOpenCodeRemoteContext7Schema asserts the mcp.context7 entry in an // OpenCode/KiloCode opencode.json is a valid remote entry with no legacy local keys. func assertOpenCodeRemoteContext7Schema(t *testing.T, path string) { @@ -105,6 +134,24 @@ func assertOpenCodeRemoteContext7Schema(t *testing.T, path string) { assertOnlyKeys(t, path, context7, "type", "url", "enabled") } +// assertOpenCodeHeadroomLocalSchema asserts the mcp.headroom entry in an +// OpenCode/KiloCode opencode.json is a valid local stdio entry. +func assertOpenCodeHeadroomLocalSchema(t *testing.T, path string) { + t.Helper() + + headroom := readOpenCodeHeadroomEntry(t, path) + + if got := headroom["type"]; got != "local" { + t.Fatalf("%q mcp.headroom.type = %#v; want %q", path, got, "local") + } + if got := headroom["command"]; got != "headroom" { + t.Fatalf("%q mcp.headroom.command = %#v; want %q", path, got, "headroom") + } + if got := headroom["enabled"]; got != true { + t.Fatalf("%q mcp.headroom.enabled = %#v; want true", path, got) + } +} + // readMCPServersContext7Entry reads the mcpServers.context7 object used by // agents that store Context7 under an mcpServers-based config file. func readMCPServersContext7Entry(t *testing.T, path string) map[string]any { @@ -133,6 +180,33 @@ func readMCPServersContext7Entry(t *testing.T, path string) map[string]any { return context7 } +// readMCPServersHeadroomEntry reads the mcpServers.headroom object. +func readMCPServersHeadroomEntry(t *testing.T, path string) map[string]any { + t.Helper() + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + + var parsed map[string]any + if err := json.Unmarshal(content, &parsed); err != nil { + t.Fatalf("Unmarshal(%q) error = %v", path, err) + } + + mcpServers, ok := parsed["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("%q missing object key mcpServers; got %#v", path, parsed["mcpServers"]) + } + + headroom, ok := mcpServers["headroom"].(map[string]any) + if !ok { + t.Fatalf("%q missing object key mcpServers.headroom; got %#v", path, mcpServers["headroom"]) + } + + return headroom +} + // assertAntigravityContext7Schema asserts the mcpServers.context7 entry in an // Antigravity mcp_config.json is a valid remote entry with no legacy local keys. func assertAntigravityContext7Schema(t *testing.T, path string) { @@ -167,7 +241,7 @@ func assertKimiContext7Schema(t *testing.T, path string) { func TestInjectOpenCodeMergesContext7AndIsIdempotent(t *testing.T) { home := t.TempDir() - first, err := Inject(home, opencodeAdapter()) + first, err := Inject(home, opencodeAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject() first error = %v", err) } @@ -175,7 +249,7 @@ func TestInjectOpenCodeMergesContext7AndIsIdempotent(t *testing.T) { t.Fatalf("Inject() first changed = false") } - second, err := Inject(home, opencodeAdapter()) + second, err := Inject(home, opencodeAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject() second error = %v", err) } @@ -207,6 +281,55 @@ func TestInjectOpenCodeMergesContext7AndIsIdempotent(t *testing.T) { } } +func TestInjectOpenCodeHeadroomAndIsIdempotent(t *testing.T) { + home := t.TempDir() + + first, err := Inject(home, opencodeAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(headroom) first error = %v", err) + } + if !first.Changed { + t.Fatalf("Inject(headroom) first changed = false") + } + + second, err := Inject(home, opencodeAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(headroom) second error = %v", err) + } + if second.Changed { + t.Fatalf("Inject(headroom) second changed = true") + } + + configPath := filepath.Join(home, ".config", "opencode", "opencode.json") + assertOpenCodeHeadroomLocalSchema(t, configPath) + + text, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(opencode.json) error = %v", err) + } + if strings.Contains(string(text), `"mcpServers"`) { + t.Fatal("opencode.json should use 'mcp' key, not 'mcpServers'") + } +} + +func TestInjectOpenCodeContext7AndHeadroomCoexist(t *testing.T) { + home := t.TempDir() + + // Inject Context7 first. + if _, err := Inject(home, opencodeAdapter(), model.ComponentContext7); err != nil { + t.Fatalf("Inject(context7) error = %v", err) + } + + // Then inject Headroom. + if _, err := Inject(home, opencodeAdapter(), model.ComponentHeadroom); err != nil { + t.Fatalf("Inject(headroom) error = %v", err) + } + + configPath := filepath.Join(home, ".config", "opencode", "opencode.json") + assertOpenCodeRemoteContext7Schema(t, configPath) + assertOpenCodeHeadroomLocalSchema(t, configPath) +} + func TestInjectOpenClawMergesContext7UnderMCPDotServersAndMigratesLegacyMCPServers(t *testing.T) { home := t.TempDir() adapter := openclawAdapter() @@ -239,7 +362,7 @@ func TestInjectOpenClawMergesContext7UnderMCPDotServersAndMigratesLegacyMCPServe t.Fatalf("WriteFile(openclaw.json) error = %v", err) } - first, err := Inject(home, adapter) + first, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject(openclaw) first error = %v", err) } @@ -247,7 +370,7 @@ func TestInjectOpenClawMergesContext7UnderMCPDotServersAndMigratesLegacyMCPServe t.Fatalf("Inject(openclaw) first changed = false") } - second, err := Inject(home, adapter) + second, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject(openclaw) second error = %v", err) } @@ -302,7 +425,7 @@ func TestInjectOpenCodeReplacesLegacyContext7LocalConfig(t *testing.T) { t.Fatalf("WriteFile(opencode.json) error = %v", err) } - first, err := Inject(home, adapter) + first, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() first error = %v", err) } @@ -312,7 +435,7 @@ func TestInjectOpenCodeReplacesLegacyContext7LocalConfig(t *testing.T) { assertOpenCodeRemoteContext7Schema(t, configPath) - second, err := Inject(home, adapter) + second, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() second error = %v", err) } @@ -348,7 +471,7 @@ func TestInjectKilocodeReplacesLegacyContext7LocalConfig(t *testing.T) { t.Fatalf("WriteFile(kilo opencode.json) error = %v", err) } - first, err := Inject(home, adapter) + first, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() first error = %v", err) } @@ -358,7 +481,7 @@ func TestInjectKilocodeReplacesLegacyContext7LocalConfig(t *testing.T) { assertOpenCodeRemoteContext7Schema(t, configPath) - second, err := Inject(home, adapter) + second, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() second error = %v", err) } @@ -397,7 +520,7 @@ func TestInjectOpenCodePreservesOtherMCPEntriesWhenReplacingContext7(t *testing. t.Fatalf("WriteFile(opencode.json) error = %v", err) } - _, err := Inject(home, adapter) + _, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() error = %v", err) } @@ -435,7 +558,7 @@ func TestInjectOpenCodePreservesOtherMCPEntriesWhenReplacingContext7(t *testing. func TestInjectClaudeWritesContext7FileAndIsIdempotent(t *testing.T) { home := t.TempDir() - first, err := Inject(home, claudeAdapter()) + first, err := Inject(home, claudeAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject() first error = %v", err) } @@ -443,7 +566,7 @@ func TestInjectClaudeWritesContext7FileAndIsIdempotent(t *testing.T) { t.Fatalf("Inject() first changed = false") } - second, err := Inject(home, claudeAdapter()) + second, err := Inject(home, claudeAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject() second error = %v", err) } @@ -457,6 +580,31 @@ func TestInjectClaudeWritesContext7FileAndIsIdempotent(t *testing.T) { } } +func TestInjectClaudeHeadroomWritesFileAndIsIdempotent(t *testing.T) { + home := t.TempDir() + + first, err := Inject(home, claudeAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(headroom,claude) first error = %v", err) + } + if !first.Changed { + t.Fatalf("Inject(headroom,claude) first changed = false") + } + + second, err := Inject(home, claudeAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(headroom,claude) second error = %v", err) + } + if second.Changed { + t.Fatalf("Inject(headroom,claude) second changed = true") + } + + path := filepath.Join(home, ".claude", "mcp", "headroom.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected headroom file %q: %v", path, err) + } +} + func TestInjectCursorWithMalformedMCPJsonRecovery(t *testing.T) { // Real Windows users may have a ~/.cursor/mcp.json that starts with non-JSON // content (e.g. "allow: all" or just "a"). The installer should recover by @@ -473,7 +621,7 @@ func TestInjectCursorWithMalformedMCPJsonRecovery(t *testing.T) { t.Fatalf("WriteFile(malformed mcp.json) error = %v", err) } - result, err := Inject(home, adapter) + result, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject(cursor) with malformed mcp.json error = %v; want nil (should recover)", err) } @@ -500,7 +648,7 @@ func TestInjectCursorWithMalformedMCPJsonRecovery(t *testing.T) { func TestInjectCodexContext7TOML(t *testing.T) { home := t.TempDir() - result, err := Inject(home, codex.NewAdapter()) + result, err := Inject(home, codex.NewAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(codex) error = %v", err) } @@ -533,12 +681,40 @@ func TestInjectCodexContext7TOML(t *testing.T) { } } +// TestInjectCodexHeadroomTOML verifies that Headroom injection for Codex +// creates config.toml with [mcp_servers.headroom] block using local command. +func TestInjectCodexHeadroomTOML(t *testing.T) { + home := t.TempDir() + + result, err := Inject(home, codex.NewAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(codex,headroom) error = %v", err) + } + if !result.Changed { + t.Fatal("Inject(codex,headroom) first call changed = false; want true") + } + + configTOML := filepath.Join(home, ".codex", "config.toml") + content, err := os.ReadFile(configTOML) + if err != nil { + t.Fatalf("ReadFile(config.toml) error = %v", err) + } + text := string(content) + + if !strings.Contains(text, "[mcp_servers.headroom]") { + t.Fatalf("config.toml missing [mcp_servers.headroom]; got:\n%s", text) + } + if !strings.Contains(text, `command = "headroom"`) { + t.Fatalf("config.toml missing headroom command; got:\n%s", text) + } +} + // TestInjectCodexContext7Idempotent verifies that a second Inject call with // the same pinned version is a no-op (Changed == false, single block). func TestInjectCodexContext7Idempotent(t *testing.T) { home := t.TempDir() - first, err := Inject(home, codex.NewAdapter()) + first, err := Inject(home, codex.NewAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(codex) first error = %v", err) } @@ -546,7 +722,7 @@ func TestInjectCodexContext7Idempotent(t *testing.T) { t.Fatal("Inject(codex) first changed = false; want true") } - second, err := Inject(home, codex.NewAdapter()) + second, err := Inject(home, codex.NewAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(codex) second error = %v", err) } @@ -584,7 +760,7 @@ args = ["mcp", "--tools=agent"] t.Fatalf("WriteFile(config.toml) error = %v", err) } - _, err := Inject(home, codex.NewAdapter()) + _, err := Inject(home, codex.NewAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(codex) error = %v", err) } @@ -606,6 +782,47 @@ args = ["mcp", "--tools=agent"] } } +// TestInjectCodexContext7CoexistsWithHeadroomTOML verifies that injecting +// headroom into a config.toml that already has context7 preserves both blocks. +func TestInjectCodexContext7CoexistsWithHeadroomTOML(t *testing.T) { + home := t.TempDir() + + configTOML := filepath.Join(home, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configTOML), 0o755); err != nil { + t.Fatalf("MkdirAll error = %v", err) + } + existing := `[mcp_servers.context7] +url = "https://mcp.context7.com/mcp" +` + if err := os.WriteFile(configTOML, []byte(existing), 0o644); err != nil { + t.Fatalf("WriteFile(config.toml) error = %v", err) + } + + _, err := Inject(home, codex.NewAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(codex,headroom) error = %v", err) + } + + content, err := os.ReadFile(configTOML) + if err != nil { + t.Fatalf("ReadFile(config.toml) error = %v", err) + } + text := string(content) + + context7Count := strings.Count(text, "[mcp_servers.context7]") + if context7Count != 1 { + t.Fatalf("expected 1 [mcp_servers.context7], got %d; result:\n%s", context7Count, text) + } + + headroomCount := strings.Count(text, "[mcp_servers.headroom]") + if headroomCount != 1 { + t.Fatalf("expected 1 [mcp_servers.headroom], got %d; result:\n%s", headroomCount, text) + } +} + +// TestInjectCodexContext7ReplacesLegacyLocalBlock(t *testing.T) verifies +// that injecting Context7 into a config.toml with a legacy local block +// migrates it to the remote URL form. func TestInjectCodexContext7ReplacesLegacyLocalBlock(t *testing.T) { home := t.TempDir() configTOML := filepath.Join(home, ".codex", "config.toml") @@ -624,7 +841,7 @@ args = ["mcp", "--tools=agent"] t.Fatalf("WriteFile(config.toml) error = %v", err) } - result, err := Inject(home, codex.NewAdapter()) + result, err := Inject(home, codex.NewAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(codex) error = %v", err) } @@ -658,7 +875,7 @@ func TestInjectVSCodeWritesContext7ToMCPConfigFile(t *testing.T) { t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) adapter := vscode.NewAdapter() - first, err := Inject(home, adapter) + first, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() first error = %v", err) } @@ -666,7 +883,7 @@ func TestInjectVSCodeWritesContext7ToMCPConfigFile(t *testing.T) { t.Fatalf("Inject() first changed = false") } - second, err := Inject(home, adapter) + second, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() second error = %v", err) } @@ -692,6 +909,46 @@ func TestInjectVSCodeWritesContext7ToMCPConfigFile(t *testing.T) { } } +func TestInjectVSCCodeWritesHeadroomToMCPConfigFile(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + adapter := vscode.NewAdapter() + + first, err := Inject(home, adapter, model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(headroom,vscode) first error = %v", err) + } + if !first.Changed { + t.Fatalf("Inject(headroom,vscode) first changed = false") + } + + second, err := Inject(home, adapter, model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(headroom,vscode) second error = %v", err) + } + if second.Changed { + t.Fatalf("Inject(headroom,vscode) second changed = true") + } + + path := adapter.MCPConfigPath(home, "headroom") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(mcp.json) error = %v", err) + } + text := string(content) + + if !strings.Contains(text, `"servers"`) { + t.Fatal("mcp.json missing servers key") + } + if !strings.Contains(text, `"headroom"`) { + t.Fatal("mcp.json missing headroom server") + } + if strings.Contains(text, `"mcpServers"`) { + t.Fatal("mcp.json should use 'servers' key, not 'mcpServers'") + } +} + func TestInjectAntigravityReplacesLegacyContext7LocalConfig(t *testing.T) { home := t.TempDir() adapter := antigravityAdapter() @@ -713,7 +970,7 @@ func TestInjectAntigravityReplacesLegacyContext7LocalConfig(t *testing.T) { t.Fatalf("WriteFile(mcp_config.json) error = %v", err) } - first, err := Inject(home, adapter) + first, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() first error = %v", err) } @@ -723,7 +980,7 @@ func TestInjectAntigravityReplacesLegacyContext7LocalConfig(t *testing.T) { assertAntigravityContext7Schema(t, configPath) - second, err := Inject(home, adapter) + second, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject() second error = %v", err) } @@ -734,10 +991,37 @@ func TestInjectAntigravityReplacesLegacyContext7LocalConfig(t *testing.T) { assertAntigravityContext7Schema(t, configPath) } +func TestInjectAntigravityHeadroomLocalConfig(t *testing.T) { + home := t.TempDir() + adapter := antigravityAdapter() + + first, err := Inject(home, adapter, model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(antigravity,headroom) first error = %v", err) + } + if !first.Changed { + t.Fatalf("Inject(antigravity,headroom) first changed = false") + } + + configPath := adapter.MCPConfigPath(home, "headroom") + headroom := readMCPServersHeadroomEntry(t, configPath) + if headroom["command"] != "headroom" { + t.Fatalf("command = %#v, want %q", headroom["command"], "headroom") + } + + second, err := Inject(home, adapter, model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(antigravity,headroom) second error = %v", err) + } + if second.Changed { + t.Fatalf("Inject(antigravity,headroom) second changed = true") + } +} + func TestInjectKimiWritesContext7ToMCPConfigFile(t *testing.T) { home := t.TempDir() - first, err := Inject(home, kimiAdapter()) + first, err := Inject(home, kimiAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(kimi) first error = %v", err) } @@ -745,7 +1029,7 @@ func TestInjectKimiWritesContext7ToMCPConfigFile(t *testing.T) { t.Fatalf("Inject(kimi) first changed = false") } - second, err := Inject(home, kimiAdapter()) + second, err := Inject(home, kimiAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(kimi) second error = %v", err) } @@ -774,6 +1058,43 @@ func TestInjectKimiWritesContext7ToMCPConfigFile(t *testing.T) { } } +func TestInjectKimiHeadroomToMCPConfigFile(t *testing.T) { + home := t.TempDir() + + first, err := Inject(home, kimiAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(kimi,headroom) first error = %v", err) + } + if !first.Changed { + t.Fatalf("Inject(kimi,headroom) first changed = false") + } + + second, err := Inject(home, kimiAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(kimi,headroom) second error = %v", err) + } + if second.Changed { + t.Fatalf("Inject(kimi,headroom) second changed = true") + } + + path := filepath.Join(home, ".kimi", "mcp.json") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(kimi mcp.json) error = %v", err) + } + + text := string(content) + if !strings.Contains(text, `"mcpServers"`) { + t.Fatal("kimi mcp.json missing mcpServers key") + } + if !strings.Contains(text, `"headroom"`) { + t.Fatal("kimi mcp.json missing headroom server") + } + if !strings.Contains(text, `"command": "headroom"`) { + t.Fatal("kimi mcp.json should have command=headroom for local MCP") + } +} + func TestInjectKimiReplacesLegacyContext7LocalConfig(t *testing.T) { home := t.TempDir() adapter := kimiAdapter() @@ -795,7 +1116,7 @@ func TestInjectKimiReplacesLegacyContext7LocalConfig(t *testing.T) { t.Fatalf("WriteFile(kimi mcp.json) error = %v", err) } - first, err := Inject(home, adapter) + first, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject(kimi) first error = %v", err) } @@ -805,7 +1126,7 @@ func TestInjectKimiReplacesLegacyContext7LocalConfig(t *testing.T) { assertKimiContext7Schema(t, configPath) - second, err := Inject(home, adapter) + second, err := Inject(home, adapter, model.ComponentContext7) if err != nil { t.Fatalf("Inject(kimi) second error = %v", err) } @@ -821,7 +1142,7 @@ func TestInjectKimiReplacesLegacyContext7LocalConfig(t *testing.T) { func TestInjectHermesContext7IntoYAML(t *testing.T) { home := t.TempDir() - result, err := Inject(home, hermesAdapter()) + result, err := Inject(home, hermesAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(hermes) error = %v", err) } @@ -850,12 +1171,46 @@ func TestInjectHermesContext7IntoYAML(t *testing.T) { } } +// TestInjectHermesHeadroomIntoYAML verifies that Inject(hermes, headroom) +// writes headroom under mcp_servers: in ~/.hermes/config.yaml. +func TestInjectHermesHeadroomIntoYAML(t *testing.T) { + home := t.TempDir() + + result, err := Inject(home, hermesAdapter(), model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(hermes,headroom) error = %v", err) + } + if !result.Changed { + t.Fatal("Inject(hermes,headroom) changed = false") + } + + configPath := filepath.Join(home, ".hermes", "config.yaml") + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(config.yaml) error = %v", err) + } + text := string(content) + + if !strings.Contains(text, "mcp_servers:") { + t.Fatal("config.yaml missing mcp_servers: key") + } + if !strings.Contains(text, "headroom:") { + t.Fatal("config.yaml missing headroom: entry under mcp_servers:") + } + if !strings.Contains(text, "headroom") { + t.Fatal("config.yaml missing headroom command") + } + if result.Files[0] != configPath { + t.Fatalf("result.Files[0] = %q, want %q", result.Files[0], configPath) + } +} + // TestInjectHermesContext7Idempotent verifies calling Inject twice yields exactly // one context7: entry (idempotent upsert), and Changed=false on the second call. func TestInjectHermesContext7Idempotent(t *testing.T) { home := t.TempDir() - first, err := Inject(home, hermesAdapter()) + first, err := Inject(home, hermesAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(hermes) first error = %v", err) } @@ -863,7 +1218,7 @@ func TestInjectHermesContext7Idempotent(t *testing.T) { t.Fatal("Inject(hermes) first changed = false") } - second, err := Inject(home, hermesAdapter()) + second, err := Inject(home, hermesAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(hermes) second error = %v", err) } @@ -891,7 +1246,7 @@ func TestInjectHermesStrategyMergeIntoYAMLDispatches(t *testing.T) { home := t.TempDir() // Confirm no error is returned (the old code returned an error for strategy 4). - result, err := Inject(home, hermesAdapter()) + result, err := Inject(home, hermesAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(hermes) with StrategyMergeIntoYAML returned error = %v (expected nil)", err) } @@ -903,8 +1258,6 @@ func TestInjectHermesStrategyMergeIntoYAMLDispatches(t *testing.T) { // TestInjectHermesPreservesExistingTopLevelKeys verifies that a full Inject // round-trip on a config.yaml that already contains an unrelated top-level key // (e.g. "model: claude") preserves that key after context7 injection. -// This covers the review-flagged coverage gap: existing non-managed content -// must survive the MCP upsert. func TestInjectHermesPreservesExistingTopLevelKeys(t *testing.T) { home := t.TempDir() hermesDir := filepath.Join(home, ".hermes") @@ -919,7 +1272,7 @@ func TestInjectHermesPreservesExistingTopLevelKeys(t *testing.T) { t.Fatalf("WriteFile: %v", err) } - result, err := Inject(home, hermesAdapter()) + result, err := Inject(home, hermesAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(hermes) error = %v", err) } @@ -946,7 +1299,7 @@ func TestInjectHermesPreservesExistingTopLevelKeys(t *testing.T) { } // Second Inject must be idempotent and still preserve the original key. - second, err := Inject(home, hermesAdapter()) + second, err := Inject(home, hermesAdapter(), model.ComponentContext7) if err != nil { t.Fatalf("Inject(hermes) second error = %v", err) } @@ -963,3 +1316,43 @@ func TestInjectHermesPreservesExistingTopLevelKeys(t *testing.T) { t.Fatalf("config.yaml lost pre-existing key on second Inject:\n%s", text2) } } + +// TestInjectOpenClawHeadroomUnderMCPDotServers verifies Headroom injection +// works under OpenClaw's mcp.servers structure. +func TestInjectOpenClawHeadroomUnderMCPDotServers(t *testing.T) { + home := t.TempDir() + adapter := openclawAdapter() + configPath := adapter.SettingsPath(home) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("MkdirAll error = %v", err) + } + + first, err := Inject(home, adapter, model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(openclaw,headroom) first error = %v", err) + } + if !first.Changed { + t.Fatalf("Inject(openclaw,headroom) first changed = false") + } + + second, err := Inject(home, adapter, model.ComponentHeadroom) + if err != nil { + t.Fatalf("Inject(openclaw,headroom) second error = %v", err) + } + if second.Changed { + t.Fatalf("Inject(openclaw,headroom) second changed = true") + } + + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(openclaw.json) error = %v", err) + } + text := string(content) + + if strings.Contains(text, `"mcpServers"`) { + t.Fatalf("openclaw.json must use mcp.servers, not root mcpServers; got:\n%s", text) + } + if !strings.Contains(text, `"headroom"`) { + t.Fatalf("openclaw.json missing headroom under mcp.servers; got:\n%s", text) + } +} diff --git a/internal/components/uninstall/service.go b/internal/components/uninstall/service.go index 8dbd75577..4171c69d2 100644 --- a/internal/components/uninstall/service.go +++ b/internal/components/uninstall/service.go @@ -79,6 +79,7 @@ var ( model.ComponentPersona, model.ComponentEngram, model.ComponentContext7, + model.ComponentHeadroom, model.ComponentPermission, model.ComponentSDD, model.ComponentSkills, @@ -91,6 +92,7 @@ var ( model.ComponentPersona, model.ComponentEngram, model.ComponentContext7, + model.ComponentHeadroom, model.ComponentPermission, model.ComponentSDD, model.ComponentSkills, @@ -467,6 +469,9 @@ func (s *Service) componentOperations(adapter agents.Adapter, componentID model. case model.ComponentContext7: targets = append(targets, context7Targets(adapter, homeDir)...) ops = append(ops, context7Operations(adapter, homeDir)...) + case model.ComponentHeadroom: + targets = append(targets, headroomTargets(adapter, homeDir)...) + ops = append(ops, headroomOperations(adapter, homeDir)...) case model.ComponentEngram: if s.engramUninstallScope == model.EngramUninstallScopeProject { projectDataPath := filepath.Join(s.workspaceDir, ".engram") @@ -710,6 +715,43 @@ func context7Operations(adapter agents.Adapter, homeDir string) []operation { } } +func headroomTargets(adapter agents.Adapter, homeDir string) []string { + switch adapter.MCPStrategy() { + case model.StrategySeparateMCPFiles: + return []string{adapter.MCPConfigPath(homeDir, "headroom")} + case model.StrategyMergeIntoSettings, model.StrategyMCPConfigFile: + return []string{adapter.MCPConfigPath(homeDir, "headroom")} + default: + return nil + } +} + +func headroomOperations(adapter agents.Adapter, homeDir string) []operation { + switch adapter.MCPStrategy() { + case model.StrategySeparateMCPFiles: + path := adapter.MCPConfigPath(homeDir, "headroom") + return []operation{removeFile(path), removeDirIfEmpty(filepath.Dir(path))} + case model.StrategyMergeIntoSettings: + path := adapter.SettingsPath(homeDir) + if adapter.Agent() == model.AgentOpenCode { + return []operation{rewriteJSONFile(path, jsonPath{"mcp", "headroom"})} + } + return []operation{rewriteJSONFile(path, jsonPath{"mcpServers", "headroom"})} + case model.StrategyMCPConfigFile: + path := adapter.MCPConfigPath(homeDir, "headroom") + switch adapter.Agent() { + case model.AgentVSCodeCopilot: + return []operation{rewriteJSONFile(path, jsonPath{"servers", "headroom"})} + case model.AgentAntigravity: + return []operation{rewriteJSONFile(path, jsonPath{"mcpServers", "headroom"})} + default: + return []operation{rewriteJSONFile(path, jsonPath{"mcpServers", "headroom"})} + } + default: + return nil + } +} + func engramTargets(adapter agents.Adapter, homeDir string) []string { targets := make([]string, 0, 3) switch adapter.MCPStrategy() { diff --git a/internal/model/types.go b/internal/model/types.go index 0fe698a3f..03bb99ffb 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -45,6 +45,7 @@ const ( ComponentTheme ComponentID = "theme" ComponentClaudeTheme ComponentID = "claude-theme" ComponentOpenCodeGentleLogo ComponentID = "opencode-gentle-logo" + ComponentHeadroom ComponentID = "headroom" ) type UninstallMode string diff --git a/internal/planner/graph.go b/internal/planner/graph.go index be819c301..ebcb96b1c 100644 --- a/internal/planner/graph.go +++ b/internal/planner/graph.go @@ -45,6 +45,7 @@ func MVPGraph() Graph { model.ComponentTheme: nil, model.ComponentClaudeTheme: nil, model.ComponentOpenCodeGentleLogo: nil, + model.ComponentHeadroom: nil, }) } diff --git a/internal/tui/model.go b/internal/tui/model.go index 0fa061e07..7ae90ebc5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4043,7 +4043,7 @@ func componentsForPreset(preset model.PresetID, persona model.PersonaID) []model case model.PresetMinimal: components = []model.ComponentID{model.ComponentEngram} case model.PresetEcosystemOnly: - components = []model.ComponentID{model.ComponentEngram, model.ComponentSDD, model.ComponentSkills, model.ComponentContext7, model.ComponentGGA} + components = []model.ComponentID{model.ComponentEngram, model.ComponentSDD, model.ComponentSkills, model.ComponentContext7, model.ComponentGGA, model.ComponentHeadroom} case model.PresetCustom: return nil default: // full-gentleman @@ -4054,6 +4054,7 @@ func componentsForPreset(preset model.PresetID, persona model.PersonaID) []model model.ComponentContext7, model.ComponentPermission, model.ComponentGGA, + model.ComponentHeadroom, model.ComponentClaudeTheme, model.ComponentOpenCodeGentleLogo, } diff --git a/internal/versions/versions.go b/internal/versions/versions.go index e32f08c0d..0c2cc553c 100644 --- a/internal/versions/versions.go +++ b/internal/versions/versions.go @@ -24,3 +24,6 @@ const GeminiCLI = "0.41.2" // renovate: datasource=npm depName=@upstash/context7-mcp const Context7MCP = "2.2.5" + +// renovate: datasource=pypi depName=headroom-ai +const HeadroomMCP = "0.27.0" diff --git a/openspec/changes/archive/2026-06-27-headroom-mcp-component/archive-report.md b/openspec/changes/archive/2026-06-27-headroom-mcp-component/archive-report.md new file mode 100644 index 000000000..389525081 --- /dev/null +++ b/openspec/changes/archive/2026-06-27-headroom-mcp-component/archive-report.md @@ -0,0 +1,45 @@ +# Archive Report: headroom-mcp-component + +**Archived**: 2026-06-27 +**Mode**: hybrid + +## Change Summary + +Added Headroom as an installable MCP component following the same seven-touchpoint pattern as Context7. Headroom is a local pip-based Python process (`headroom-ai[all]`) that provides context compression via three MCP tools: `headroom_compress`, `headroom_retrieve`, `headroom_stats`. + +Key structural difference from Context7: Headroom runs as a local subprocess (command + args), not a remote HTTP endpoint. This required generalizing `mcp.Inject()` with a `componentID` parameter so the dispatcher selects the correct JSON payloads per component. + +## Task Completion + +- **19/19 tasks complete** (all checked `[x]`) +- Verification: **PASS WITH WARNINGS** (CRITICAL issues resolved, remaining warnings are pre-existing across all components) +- Build, vet, and test checks: all pass + +## Specs Synced + +| Domain | Action | Details | +|--------|--------|---------| +| headroom-mcp | Created | 7 requirements, 12 scenarios — new spec (main did not exist) | + +## Archive Contents + +- `proposal.md` ✅ — Intent, scope, approach, risks, rollback plan +- `specs/headroom-mcp/spec.md` ✅ — 7 requirements with scenarios +- `design.md` ✅ — Architecture decisions, injection flow, file changes +- `tasks.md` ✅ — 19/19 tasks complete +- `archive-report.md` ✅ — This file + +## Source of Truth Updated + +- `openspec/specs/headroom-mcp/spec.md` — New spec copied from delta + +## Notes + +- No `verify-report.md` file existed in the change folder. Orchestrator confirmed verification result verbally. +- All 19 tasks were marked complete with `[x]` — no stale checkboxes to reconcile. +- The delta spec was used directly as the main spec (no pre-existing main spec for headroom-mcp). + +## Lineage + +- Delta spec: `openspec/changes/archive/2026-06-27-headroom-mcp-component/specs/headroom-mcp/spec.md` +- Main spec: `openspec/specs/headroom-mcp/spec.md` diff --git a/openspec/changes/archive/2026-06-27-headroom-mcp-component/design.md b/openspec/changes/archive/2026-06-27-headroom-mcp-component/design.md new file mode 100644 index 000000000..97f2de293 --- /dev/null +++ b/openspec/changes/archive/2026-06-27-headroom-mcp-component/design.md @@ -0,0 +1,154 @@ +# Design: Headroom MCP Component + +## Technical Approach + +Add Headroom as an optional MCP component following the same seven-touchpoint pattern as Context7, with one key structural difference: Headroom is a **local pip-based Python process** while Context7 is a **remote HTTP endpoint**. This impacts the MCP server JSON structure (command vs URL), install flow (pip detection + pip install precede injection), and the OpenCode overlay (local type instead of remote). + +No new abstractions are introduced — the existing `mcp.Inject()` function is extended to accept a component ID so it dispatches to the correct JSON payloads. + +## Architecture Decisions + +### Decision: Generalize `mcp.Inject()` with a component parameter + +| Option | Tradeoff | Decision | +|--------|----------|----------| +| Hardcode Headroom methods alongside Context7 | Duplicates 5 strategy methods per component | ❌ | +| Add `componentID` param to `Inject()` | One function, one switch, less boilerplate for future MCP components | ✅ | + +`Inject()` signature changes from `(homeDir string, adapter agents.Adapter)` to `(homeDir string, adapter agents.Adapter, componentID model.ComponentID)`. The inner strategy functions (`injectSeparateFile`, `injectMergeIntoSettings`, etc.) receive the component and select the correct JSON payload. The `context7.go` helpers remain alongside new `headroom.go` helpers; the dispatcher in `inject.go` becomes generic. + +### Decision: Headroom uses local MCP for all agents + +| Option | Tradeoff | Decision | +|--------|----------|----------| +| Remote HTTP MCP | Requires hosting a headroom cloud service — out of scope | ❌ | +| Local command (Python process) | Portable, no infra, matches headroom-ai architecture | ✅ | + +Headroom runs as a local subprocess (`headroom-mcp` command, installed via pip). Every agent overlay uses `command` + `args`, never `type: "remote"` or `url`. This is the primary difference from Context7's OpenCode/Kimi/Antigravity overlays. + +### Decision: Pip detection + auto-install in componentApplyStep, before mcp.Inject + +| Option | Tradeoff | Decision | +|--------|----------|----------| +| Install in separate prepare step | Over-engineered for one pip install | ❌ | +| Inline in componentApplyStep case | Matches GGA install pattern, keeps flow local | ✅ | + +The Headroom case in `componentApplyStep.Run()` checks for `headroom-mcp` on PATH via `exec.LookPath`. If absent, checks for `pip`/`pip3` via `exec.LookPath`, runs `pip install headroom-ai[all]`, then proceeds to `mcp.Inject()`. This mirrors GGA's inline install-then-inject flow. + +### Decision: Pin headroom-ai version from PyPI + +| Option | Tradeoff | Decision | +|--------|----------|----------| +| Pin a SemVer range | Renovate can still bump but range may drift | ❌ | +| Pin exact version | Predictable, Renovate handles bumps | ✅ | + +Add `HeadroomMCP = "0.3.0"` with `// renovate: datasource=pypi depName=headroom-ai` (exact version TBD — confirmed during implementation). + +## Injection Flow + +``` +componentApplyStep.Run() + │ + ├─ [Headroom case] + │ ├─ LookPath("headroom-mcp") + │ │ └─ not found → LookPath("pip") or LookPath("pip3") + │ │ └─ found → runCommand("pip", "install", "headroom-ai[all]") + │ │ └─ not found → return error "pip not found" + │ │ + │ └─ mcp.Inject(homeDir, adapter, ComponentHeadroom) + │ └─ Inject() dispatches by MCPStrategy + componentID + │ ├─ StrategySeparateMCPFiles → DefaultHeadroomServerJSON() + │ ├─ StrategyMergeIntoSettings → DefaultHeadroomOverlayJSON() + │ │ └─ OpenCode/KiloCode → OpenCodeHeadroomOverlayJSON() (local type) + │ ├─ StrategyMCPConfigFile → DefaultHeadroomOverlayJSON() + │ │ └─ VS Code → VSCodeHeadroomOverlayJSON() + │ │ └─ Kimi → KimiHeadroomOverlayJSON() + │ │ └─ Antigravity → AntigravityHeadroomOverlayJSON() + │ ├─ StrategyTOMLFile → injectHeadroomTOMLFile() + │ └─ StrategyMergeIntoYAML → injectHeadroomYAMLFile() +``` + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `internal/model/types.go` | Modify | Add `ComponentHeadroom ComponentID = "headroom"` | +| `internal/catalog/components.go` | Modify | Add Headroom entry to `mvpComponents` | +| `internal/versions/versions.go` | Modify | Add `HeadroomMCP` version const with Renovate PyPI directive | +| `internal/planner/graph.go` | Modify | Add `model.ComponentHeadroom: nil` to MVPGraph | +| `internal/tui/model.go` | Modify | Add `model.ComponentHeadroom` to FullGentleman and EcosystemOnly presets | +| `internal/components/mcp/headroom.go` | **Create** | MCP server JSON + agent-strategy overlays (pip-based local command) | +| `internal/components/mcp/inject.go` | Modify | Generalize `Inject()` with componentID param; add headroom dispatch | +| `internal/cli/run.go` | Modify | Add `case model.ComponentHeadroom:` — pip detect/install + inject | +| `internal/cli/doctor.go` | Modify | Add `"headroom-mcp"` to `knownTools` | +| `internal/cli/run_component_paths_test.go` | Modify | Add headroom backup path coverage | +| `internal/components/mcp/inject_test.go` | Modify | Update `Inject()` call sites (new componentID param) | + +## Interfaces / Contracts + +### Headroom server JSON (Claude Code, separate file pattern) + +```json +{ + "command": "headroom", + "args": ["mcp", "serve"] +} +``` + +### Headroom overlay JSON (OpenCode/KiloCode merge pattern) + +```json +{ + "mcp": { + "headroom": { + "type": "local", + "command": "headroom", + "args": ["mcp", "serve"] + } + } +} +``` + +### Headroom overlay JSON (Cursor/Antigravity/Kimi mcp.json pattern) + +```json +{ + "mcpServers": { + "headroom": { + "command": "headroom", + "args": ["mcp", "serve"] + } + } +} +``` + +### Inject() signature change + +```go +// Before: Context7-only +func Inject(homeDir string, adapter agents.Adapter) (InjectionResult, error) + +// After: generic, component-dispatched +func Inject(homeDir string, adapter agents.Adapter, componentID model.ComponentID) (InjectionResult, error) +``` + +## Testing Strategy + +| Layer | What to Test | Approach | +|-------|-------------|----------| +| Unit | Headroom server JSON helpers | `headroom_test.go` — verify JSON includes `"command": "headroom-mcp"` | +| Unit | Headroom overlay JSON helpers | Validate each overlay format per agent strategy | +| Unit | Inject with ComponentHeadroom | Idempotent inject per adapter type, same pattern as `inject_test.go` | +| Unit | Pip detection + fallback | Mock `exec.LookPath` and `runCommand` in `run.go` tests | +| Integration | Install flow | Test `componentApplyStep.Run()` with ComponentHeadroom, mock adapters | +| Integration | Backup paths | `componentPaths` returns headroom MCP config paths per adapter | + +## Migration / Rollout + +No migration required. Headroom is additive — no existing configs change. The `Inject()` signature changes, so Context7 call sites in `run.go` are updated to pass `model.ComponentContext7`. + +## Open Questions + +- [ ] Headroom-ai PyPI package exact command name: verify `headroom-mcp` is the correct binary name (or if `python -m headroom_mcp` is needed). The design assumes `headroom-mcp` — confirm during implementation. +- [ ] Headroom-ai version to pin: start with latest at implementation time. +- [ ] Hermes YAML overlay: does Hermes need a special Headroom block like Context7's `UpsertHermesContext7Block`? Likely yes — add `injectHeadroomYAMLFile()` with a generic `UpsertHermesMCPServerBlock("headroom", ...)` if the Hermes helpers are currently Context7-specific. diff --git a/openspec/changes/archive/2026-06-27-headroom-mcp-component/proposal.md b/openspec/changes/archive/2026-06-27-headroom-mcp-component/proposal.md new file mode 100644 index 000000000..d5041c7e4 --- /dev/null +++ b/openspec/changes/archive/2026-06-27-headroom-mcp-component/proposal.md @@ -0,0 +1,86 @@ +# Proposal: Headroom MCP Component + +## Intent + +Users lose 30-60% of LLM context window to redundant tool output, verbose logs, and unprocessed RAG chunks. Headroom compresses this data before it reaches the LLM, recovering 60-95% token budget via reversible compression (CCR pattern). For gentle-ai users with multiple MCP servers, this means fewer context-full errors and longer productive sessions. + +## Scope + +### In Scope +- Component constant, catalog entry, version pin, MVPGraph node +- MCP server file (`headroom.go`): pip-based, three tools (compress/retrieve/stats) +- Per-agent injection via existing `mcp.Inject()` — extends MCP injector +- TUI selection: component in FullGentleman + EcosystemOnly presets +- Install-time pip detection + auto-install (`headroom-ai[all]`) +- Doctor health check for headroom availability +- Target agents: Claude Code + OpenCode (extensible design) + +### Out of Scope +- Proxy mode — MCP tools only (simpler, more portable) +- Agents beyond Claude Code + OpenCode — deferred +- Custom compressor config or headroom-defaults overrides +- Go SDK import integration — MCP only + +## Capabilities + +### New Capabilities +- `headroom-mcp`: Headroom context compression MCP server — install, configure, inject into supported agents + +### Modified Capabilities +- None + +## Approach + +Seven-touchpoint pattern (identical to Context7): + +1. `internal/model/types.go` — add `ComponentHeadroom` +2. `internal/catalog/components.go` — add to `mvpComponents` +3. `internal/versions/versions.go` — pinned version const with Renovate directive +4. `internal/planner/graph.go` — add `model.ComponentHeadroom: nil` to MVPGraph +5. `internal/tui/model.go` — add to `componentsForPreset` presets +6. `internal/components/mcp/headroom.go` — MCP server JSON + agent-strategy overlays (pip-based `headroom-ai[all]`) +7. `internal/cli/run.go` — `case model.ComponentHeadroom:` in `componentApplyStep.Run()`: detect pip, install if missing, call `mcp.Inject(homeDir, adapter)` per agent + +Also: add `headroom` to `knownTools` in doctor.go, add backup paths in component path helper. + +## Affected Areas + +| Area | Impact | +|------|--------| +| `internal/model/types.go` | +ComponentHeadroom constant | +| `internal/catalog/components.go` | +mvpComponents entry | +| `internal/planner/graph.go` | +MVPGraph node (no deps) | +| `internal/versions/versions.go` | +Renovate-pinned version | +| `internal/tui/model.go` | +component in presets | +| `internal/components/mcp/headroom.go` | New file | +| `internal/components/mcp/inject.go` | +case headroom injection | +| `internal/cli/run.go` | +install + inject case | +| `internal/cli/doctor.go` | +headroom knownTools | +| `internal/cli/run_component_paths_test.go` | +backup path coverage | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| pip not available | Medium | Check pip/pip3 before install; guide user | +| Windows pip compatibility | Low | Pin known-good version; test in CI | +| HEADROOM not on PATH after pip | Low | `pip show headroom-ai` to resolve path (+gga pattern) | +| Python 3.12+ compat | Low | Verify headroom-ai supported Python range | + +## Rollback Plan + +Backup: pre-install snapshot covers all mcp config files. Uninstall: `gentle-ai uninstall` removes headroom mcp.json + runs `pip uninstall headroom-ai -y`. Manual: same pip command + delete per-agent headroom MCP files. + +## Dependencies + +- `headroom-ai[all]` on PyPI (Apache 2.0) — auto-installed via pip +- Python 3.10+ (headroom-ai requirement) + +## Success Criteria + +- [ ] `gentle-ai install --components headroom` installs + injects into Claude Code + OpenCode +- [ ] `gentle-ai doctor` shows headroom as pass +- [ ] TUI shows Headroom selectable in FullGentleman preset +- [ ] headroom_compress / retrieve / stats available as MCP tools post-install +- [ ] Existing Context7 installs unchanged +- [ ] Uninstall removes all headroom config diff --git a/openspec/changes/archive/2026-06-27-headroom-mcp-component/specs/headroom-mcp/spec.md b/openspec/changes/archive/2026-06-27-headroom-mcp-component/specs/headroom-mcp/spec.md new file mode 100644 index 000000000..253fa3283 --- /dev/null +++ b/openspec/changes/archive/2026-06-27-headroom-mcp-component/specs/headroom-mcp/spec.md @@ -0,0 +1,127 @@ +# headroom-mcp Specification + +## Purpose + +Defines the install, runtime, and uninstall behavior of the Headroom context compression MCP component. Covers pip-based deployment of `headroom-ai[all]`, agent MCP injection, TUI presence, health checks, and cleanup. + +## Requirements + +### Requirement: Pip-based Install + +The system MUST install `headroom-ai[all]` via pip/pip3 when the headroom component is selected. The installer MUST detect an existing installation before attempting install and skip if already present. + +#### Scenario: Fresh install succeeds + +- GIVEN pip is available and headroom-ai[all] is not installed +- WHEN the installer runs for the headroom component +- THEN pip installs `headroom-ai[all]` from PyPI + +#### Scenario: Existing install is detected + +- GIVEN headroom-ai[all] is already installed +- WHEN the installer runs for the headroom component +- THEN pip install is skipped and MCP injection proceeds + +#### Scenario: Pip is unavailable + +- GIVEN pip and pip3 are both unreachable +- WHEN the installer runs for the headroom component +- THEN installation halts with a clear error message + +--- + +### Requirement: MCP Tool Registration + +The running Headroom MCP server MUST expose three tools: `headroom_compress`, `headroom_retrieve`, `headroom_stats`. + +#### Scenario: Compress compresses and retrieves + +- GIVEN the headroom MCP server is running +- WHEN the client calls `headroom_compress` with text content +- THEN the server returns a compressed representation recoverable via `headroom_retrieve` + +#### Scenario: Stats reports metrics + +- GIVEN the headroom MCP server is running +- WHEN the client calls `headroom_stats` +- THEN the server returns compression ratio and token savings + +--- + +### Requirement: Agent Injection + +The system MUST inject headroom MCP config into Claude Code and OpenCode via the existing `mcp.Inject()` mechanism using pip-resolved binary path. + +#### Scenario: Claude Code gets headroom config + +- GIVEN headroom-ai[all] is installed +- WHEN `mcp.Inject` runs for Claude Code +- THEN headroom is registered in the Claude Code MCP config with the correct binary path + +#### Scenario: OpenCode gets headroom config + +- GIVEN headroom-ai[all] is installed +- WHEN `mcp.Inject` runs for OpenCode +- THEN headroom is registered in the OpenCode MCP config with the correct binary path + +--- + +### Requirement: TUI Selection + +The headroom component MUST be selectable in the TUI component screen. It MUST appear in the FullGentleman and EcosystemOnly presets. + +#### Scenario: Headroom in FullGentleman preset + +- GIVEN the user opens the TUI component selection +- WHEN the FullGentleman preset is applied +- THEN headroom is included in the enabled component list + +#### Scenario: Headroom selectable individually + +- GIVEN the user opens the TUI component screen +- WHEN the user browses available components +- THEN headroom appears as a selectable standalone component + +--- + +### Requirement: Doctor Health Check + +The doctor command MUST verify headroom availability by checking pip package presence and active MCP config. + +#### Scenario: Healthy headroom passes + +- GIVEN headroom-ai[all] is installed and MCP config is present +- WHEN `gentle-ai doctor` runs +- THEN headroom shows as pass + +#### Scenario: Missing headroom fails + +- GIVEN headroom-ai[all] is not installed +- WHEN `gentle-ai doctor` runs +- THEN headroom shows a clear failure message + +--- + +### Requirement: Clean Uninstall + +The uninstall command MUST remove the pip package and all headroom MCP config entries from every agent that received injection. + +#### Scenario: Uninstall removes everything + +- GIVEN headroom-ai[all] is installed with active MCP configs +- WHEN the uninstall command runs +- THEN `pip uninstall headroom-ai -y` executes successfully +- AND all headroom MCP config entries are removed from every agent + +--- + +### Requirement: Backup and Rollback Compatibility + +The pre-install backup snapshot MUST cover all headroom MCP config files. Rollback MUST restore the pre-install MCP state for every affected agent. + +#### Scenario: Rollback restores pre-install state + +- GIVEN a headroom install has been backed up via pre-install snapshot +- WHEN rollback is triggered +- THEN all headroom MCP config entries present in the backup are restored exactly +- AND no orphaned headroom config remains diff --git a/openspec/changes/archive/2026-06-27-headroom-mcp-component/tasks.md b/openspec/changes/archive/2026-06-27-headroom-mcp-component/tasks.md new file mode 100644 index 000000000..d5ce51462 --- /dev/null +++ b/openspec/changes/archive/2026-06-27-headroom-mcp-component/tasks.md @@ -0,0 +1,54 @@ +# Tasks: Headroom MCP Component + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | 320–380 | +| 400-line budget risk | Low–Medium | +| Chained PRs recommended | No | +| Suggested split | Single PR | +| Delivery strategy | auto-forecast | +| Chain strategy | pending | + +Decision needed before apply: No +Chained PRs recommended: No +Chain strategy: pending +400-line budget risk: Low–Medium + +### Suggested Work Units + +| Unit | Goal | Likely PR | Notes | +|------|------|-----------|-------| +| 1 | Foundation + Core + Integration + Testing | Single PR | All phases in one PR. `Inject()` signature change is backward-compatible — existing Context7 call sites add a third arg. ~350 lines fits within budget. | + +## Phase 1: Foundation (Model + Catalog + Versions + Graph + TUI) + +- [x] 1.1 Add `ComponentHeadroom ComponentID = "headroom"` to `internal/model/types.go` +- [x] 1.2 Add Headroom entry to `mvpComponents` in `internal/catalog/components.go` +- [x] 1.3 Add `HeadroomMCP` version const with `// renovate: datasource=pypi depName=headroom-ai` in `internal/versions/versions.go` +- [x] 1.4 Add `model.ComponentHeadroom: nil` to `MVPGraph()` in `internal/planner/graph.go` +- [x] 1.5 Add `model.ComponentHeadroom` to FullGentleman and EcosystemOnly presets in `internal/tui/model.go` `componentsForPreset()` + +## Phase 2: Core (MCP Server JSON + Inject Refactor) + +- [x] 2.1 Create `internal/components/mcp/headroom.go` with `DefaultHeadroomServerJSON()` (local command `"headroom"` + `args: ["mcp", "serve"]`), `OpenCodeHeadroomOverlayJSON()` (local type), `DefaultHeadroomOverlayJSON()` (mcpServers), `VSCodeHeadroomOverlayJSON()`, `OpenClawHeadroomOverlayJSON()`, `AntigravityHeadroomOverlayJSON()`, `KimiHeadroomOverlayJSON()` following the `context7.go` pattern — **note**: binary is `headroom` not `headroom-mcp` +- [x] 2.2 Generalize `Inject()` in `internal/components/mcp/inject.go` — add `componentID model.ComponentID` param, pass it to all strategy functions +- [x] 2.3 Refactor each strategy function in `inject.go` (`injectSeparateFile`, `injectMergeIntoSettings`, `injectMCPConfigFile`, `injectTOMLFile`, `injectYAMLFile`, `injectOpenClawMergeIntoSettings`) to dispatch JSON payload by `componentID` +- [x] 2.4 Update `injectTOMLFile` to handle headroom (local command via `UpsertCodexMCPServerBlock`) +- [x] 2.5 Update `injectYAMLFile` to handle headroom via `UpsertYAMLMCPServerBlock` + +## Phase 3: Integration (Run + Doctor + Backup Paths) + +- [x] 3.1 Add `case model.ComponentHeadroom:` in `internal/cli/run.go` `componentApplyStep.Run()` — detect pip/pip3, `pip install headroom-ai[all]`, then `mcp.Inject(home, adapter, ComponentHeadroom)` per adapter +- [x] 3.2 Update existing `case model.ComponentContext7:` in `run.go` to pass `model.ComponentContext7` as third arg to `mcp.Inject()` +- [x] 3.3 Add `"headroom"` to `knownTools` slice in `internal/cli/doctor.go` +- [x] 3.4 Add headroom MCP config file paths to `componentPathsWithWorkspaceScoped()` in `internal/cli/run.go` under a `case model.ComponentHeadroom:` +- [x] 3.5 Add `case model.ComponentHeadroom:` to `componentPathDirScoped()` for `homeDir` routing (headroom is global, like Context7) + +## Phase 4: Testing + +- [x] 4.1 Update all `Inject()` call sites in `internal/components/mcp/inject_test.go` to pass the third `componentID` argument (`model.ComponentContext7` for existing tests, `model.ComponentHeadroom` for new ones) +- [x] 4.2 Create `internal/components/mcp/headroom_test.go` with 8 tests — JSON shapes (`"command": "headroom"`), all 7 overlay variants, copy-safety +- [x] 4.3 Add inject tests for Headroom: 18 tests covering idempotent inject into Claude Code, OpenCode, OpenClaw, Codex, VS Code, Antigravity, Kimi, Hermes +- [x] 4.4 Add headroom backup path coverage + update install_test.go expectations diff --git a/openspec/specs/headroom-mcp/spec.md b/openspec/specs/headroom-mcp/spec.md new file mode 100644 index 000000000..253fa3283 --- /dev/null +++ b/openspec/specs/headroom-mcp/spec.md @@ -0,0 +1,127 @@ +# headroom-mcp Specification + +## Purpose + +Defines the install, runtime, and uninstall behavior of the Headroom context compression MCP component. Covers pip-based deployment of `headroom-ai[all]`, agent MCP injection, TUI presence, health checks, and cleanup. + +## Requirements + +### Requirement: Pip-based Install + +The system MUST install `headroom-ai[all]` via pip/pip3 when the headroom component is selected. The installer MUST detect an existing installation before attempting install and skip if already present. + +#### Scenario: Fresh install succeeds + +- GIVEN pip is available and headroom-ai[all] is not installed +- WHEN the installer runs for the headroom component +- THEN pip installs `headroom-ai[all]` from PyPI + +#### Scenario: Existing install is detected + +- GIVEN headroom-ai[all] is already installed +- WHEN the installer runs for the headroom component +- THEN pip install is skipped and MCP injection proceeds + +#### Scenario: Pip is unavailable + +- GIVEN pip and pip3 are both unreachable +- WHEN the installer runs for the headroom component +- THEN installation halts with a clear error message + +--- + +### Requirement: MCP Tool Registration + +The running Headroom MCP server MUST expose three tools: `headroom_compress`, `headroom_retrieve`, `headroom_stats`. + +#### Scenario: Compress compresses and retrieves + +- GIVEN the headroom MCP server is running +- WHEN the client calls `headroom_compress` with text content +- THEN the server returns a compressed representation recoverable via `headroom_retrieve` + +#### Scenario: Stats reports metrics + +- GIVEN the headroom MCP server is running +- WHEN the client calls `headroom_stats` +- THEN the server returns compression ratio and token savings + +--- + +### Requirement: Agent Injection + +The system MUST inject headroom MCP config into Claude Code and OpenCode via the existing `mcp.Inject()` mechanism using pip-resolved binary path. + +#### Scenario: Claude Code gets headroom config + +- GIVEN headroom-ai[all] is installed +- WHEN `mcp.Inject` runs for Claude Code +- THEN headroom is registered in the Claude Code MCP config with the correct binary path + +#### Scenario: OpenCode gets headroom config + +- GIVEN headroom-ai[all] is installed +- WHEN `mcp.Inject` runs for OpenCode +- THEN headroom is registered in the OpenCode MCP config with the correct binary path + +--- + +### Requirement: TUI Selection + +The headroom component MUST be selectable in the TUI component screen. It MUST appear in the FullGentleman and EcosystemOnly presets. + +#### Scenario: Headroom in FullGentleman preset + +- GIVEN the user opens the TUI component selection +- WHEN the FullGentleman preset is applied +- THEN headroom is included in the enabled component list + +#### Scenario: Headroom selectable individually + +- GIVEN the user opens the TUI component screen +- WHEN the user browses available components +- THEN headroom appears as a selectable standalone component + +--- + +### Requirement: Doctor Health Check + +The doctor command MUST verify headroom availability by checking pip package presence and active MCP config. + +#### Scenario: Healthy headroom passes + +- GIVEN headroom-ai[all] is installed and MCP config is present +- WHEN `gentle-ai doctor` runs +- THEN headroom shows as pass + +#### Scenario: Missing headroom fails + +- GIVEN headroom-ai[all] is not installed +- WHEN `gentle-ai doctor` runs +- THEN headroom shows a clear failure message + +--- + +### Requirement: Clean Uninstall + +The uninstall command MUST remove the pip package and all headroom MCP config entries from every agent that received injection. + +#### Scenario: Uninstall removes everything + +- GIVEN headroom-ai[all] is installed with active MCP configs +- WHEN the uninstall command runs +- THEN `pip uninstall headroom-ai -y` executes successfully +- AND all headroom MCP config entries are removed from every agent + +--- + +### Requirement: Backup and Rollback Compatibility + +The pre-install backup snapshot MUST cover all headroom MCP config files. Rollback MUST restore the pre-install MCP state for every affected agent. + +#### Scenario: Rollback restores pre-install state + +- GIVEN a headroom install has been backed up via pre-install snapshot +- WHEN rollback is triggered +- THEN all headroom MCP config entries present in the backup are restored exactly +- AND no orphaned headroom config remains From e27173b3ebebbf3a2e48fec4f25bf7ee33b1f3e4 Mon Sep 17 00:00:00 2001 From: RoX Date: Sat, 27 Jun 2026 03:15:07 -0500 Subject: [PATCH 2/3] docs(components): add Headroom MCP component to docs Document Headroom in the components reference and update preset descriptions to reflect it is included in full-gentleman and ecosystem-only presets. --- docs/components.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/components.md b/docs/components.md index ae5f153c9..ed266efc3 100644 --- a/docs/components.md +++ b/docs/components.md @@ -12,6 +12,7 @@ | SDD | `sdd` | Spec-Driven Development workflow (10 phases, including `sdd-onboard`) — the agent handles SDD organically when the task warrants it, or when you ask; you don't need to learn the commands | | Skills | `skills` | Curated coding skill library | | Context7 | `context7` | MCP server for live framework/library documentation | +| Headroom | `headroom` | MCP context compression server — compresses verbose tool outputs and LLM responses to stay within context window limits via three tools: `compress`, `retrieve`, and `stats`. Installed via pip (`headroom-ai[all]`). See [headroom repo](https://github.com/headroomlabs-ai/headroom) | | Persona | `persona` | Managed Gentleman/neutral persona injection, or unmanaged custom persona mode | | Permissions | `permissions` | Security-first defaults and guardrails. Applied to Claude Code and OpenCode (the two adapters with permissions overlay support). Default sensitive-paths deny list: `~/.ssh/*`, `~/.ssh/**/*`, `**/*.pem`, `**/*.key`, `**/.env*`, `~/.credentials/*`, `~/.aws/credentials`, `~/.config/gh/hosts.yml`, `~/Library/Keychains/*`, `**/secrets/*`, `**/*.p12`, `**/*.pfx` | | GGA | `gga` | Gentleman Guardian Angel — AI provider switcher | @@ -80,8 +81,8 @@ For framework-specific skills (React 19, Angular, TypeScript, Tailwind 4, Zod 4, | Preset | ID | What's Included | |--------|-----|-----------------| -| Dev Stack + Polish | `full-gentleman` | All components (Engram + SDD + Skills + Context7 + GGA + Permissions + Theme) + all skills | -| Dev Stack | `ecosystem-only` | Core components (Engram + SDD + Skills + Context7 + GGA) + all skills | +| Dev Stack + Polish | `full-gentleman` | All components (Engram + SDD + Skills + Context7 + Headroom + GGA + Permissions + Theme) + all skills | +| Dev Stack | `ecosystem-only` | Core components (Engram + SDD + Skills + Context7 + Headroom + GGA) + all skills | | Memory Only | `minimal` | Engram + SDD skills only | | Custom | `custom` | You choose components and skills manually while keeping any existing persona/settings unmanaged | From ef5697b94e3cd2afc91c0ad0a1011b46c8877288 Mon Sep 17 00:00:00 2001 From: RoX Date: Sat, 27 Jun 2026 03:17:55 -0500 Subject: [PATCH 3/3] docs(headroom): add user-facing Headroom documentation - New docs/headroom.md explaining what Headroom is, the problem it solves, how to install/use/uninstall, and how it compares to Engram - Updated docs/components.md with Headroom entry and preset descriptions --- docs/components.md | 2 +- docs/headroom.md | 116 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 docs/headroom.md diff --git a/docs/components.md b/docs/components.md index ed266efc3..d658c919b 100644 --- a/docs/components.md +++ b/docs/components.md @@ -12,7 +12,7 @@ | SDD | `sdd` | Spec-Driven Development workflow (10 phases, including `sdd-onboard`) — the agent handles SDD organically when the task warrants it, or when you ask; you don't need to learn the commands | | Skills | `skills` | Curated coding skill library | | Context7 | `context7` | MCP server for live framework/library documentation | -| Headroom | `headroom` | MCP context compression server — compresses verbose tool outputs and LLM responses to stay within context window limits via three tools: `compress`, `retrieve`, and `stats`. Installed via pip (`headroom-ai[all]`). See [headroom repo](https://github.com/headroomlabs-ai/headroom) | +| Headroom | `headroom` | MCP context compression server — compresses verbose tool outputs and LLM responses to stay within context window limits via three tools: `compress`, `retrieve`, and `stats`. Installed via pip (`headroom-ai[all]`). See [headroom docs](headroom.md) | | Persona | `persona` | Managed Gentleman/neutral persona injection, or unmanaged custom persona mode | | Permissions | `permissions` | Security-first defaults and guardrails. Applied to Claude Code and OpenCode (the two adapters with permissions overlay support). Default sensitive-paths deny list: `~/.ssh/*`, `~/.ssh/**/*`, `**/*.pem`, `**/*.key`, `**/.env*`, `~/.credentials/*`, `~/.aws/credentials`, `~/.config/gh/hosts.yml`, `~/Library/Keychains/*`, `**/secrets/*`, `**/*.p12`, `**/*.pfx` | | GGA | `gga` | Gentleman Guardian Angel — AI provider switcher | diff --git a/docs/headroom.md b/docs/headroom.md new file mode 100644 index 000000000..5b0120cf4 --- /dev/null +++ b/docs/headroom.md @@ -0,0 +1,116 @@ +# Headroom — Context Compression MCP Server + +← [Back to README](../README.md) · [Components & Presets](components.md) + +--- + +Headroom is an MCP server that compresses verbose content — tool outputs, LLM responses, file contents — to help you stay within context window limits without losing information you might need later. + +## The Problem + +AI coding agents produce enormous tool outputs: directory listings, file reads, git diffs, search results, linter output, test logs. Each one eats into your context window. When the window fills, the agent starts forgetting earlier instructions, decisions, or code context. + +Engram solves the **cross-session** memory problem. Headroom solves the **within-session** context pressure problem. They are complementary. + +## How It Works + +Headroom exposes three MCP tools that the agent can call on demand: + +| Tool | What it does | +|------|-------------| +| `headroom_compress` | Compresses a text block and returns a short reference handle | +| `headroom_retrieve` | Decompresses a reference handle back to full text | +| `headroom_stats` | Reports compression ratios and total savings for the session | + +The agent decides when to compress — typically when it notices the context is getting full or when a tool output is unusually large. Compressed content is stored locally; nothing leaves your machine. + +## Installation + +Headroom is installed automatically by `gentle-ai` when you select it as a component: + +### Via TUI + +``` +gentle-ai install +``` + +Select `Headroom` in the components screen (included by default in the `full-gentleman` and `ecosystem-only` presets). + +### Via CLI + +```bash +gentle-ai install --component headroom +``` + +The installer will: +1. Check if `headroom` is already on your PATH +2. If not, find `pip` or `pip3` and run `pip install "headroom-ai[all]"` +3. Inject MCP configuration into all your configured agents + +### Manual Installation + +If you prefer to install Headroom yourself: + +```bash +pip install "headroom-ai[all]" +``` + +Then run `gentle-ai install --component headroom` to configure your agents, or `gentle-ai sync` if Headroom is already in your selection. + +## What Gets Configured + +For each agent you have installed, gentle-ai writes the correct MCP config: + +| Agent | Config File | Format | +|-------|-------------|--------| +| Claude Code | `~/.claude/mcp/headroom.json` | Separate file | +| OpenCode / Kilo Code | `opencode.json` `mcp.headroom` | Merge overlay | +| Cursor | `~/.cursor/mcp.json` `mcpServers.headroom` | Merge | +| VS Code Copilot | `Code/User/mcp.json` `servers.headroom` | Merge | +| Windsurf | `~/.codeium/windsurf/mcp_config.json` | Merge | +| Codex | `~/.codex/config.toml` `[mcp_servers.headroom]` | TOML block | +| Gemini CLI | `~/.gemini/settings.json` `mcpServers.headroom` | Merge | +| Antigravity | `~/.gemini/antigravity/mcp_config.json` | Merge | +| Kimi Code | `~/.kimi/settings.json` | Merge | +| Qwen Code | `~/.qwen/settings.json` | Merge | +| Kiro IDE | `~/.kiro/settings/mcp.json` | Merge | +| OpenClaw | `~/.openclaw/openclaw.json` | Merge | +| Trae | App support dir `mcp.json` | Merge | +| Pi | `.pi/settings.json` | Merge | +| Hermes | `~/.hermes/config.yaml` | YAML block | + +All entries point to a local `headroom` process with `args: ["mcp", "serve"]`. + +## Uninstalling + +```bash +gentle-ai uninstall --component headroom +``` + +This removes all MCP config entries from your agents. To also remove the pip package: + +```bash +pip uninstall headroom-ai +``` + +## Usage Tips + +- Headroom is most useful during long coding sessions where the agent performs many read/search/write operations +- The agent calls `headroom_compress` automatically when it detects large outputs — you don't need to interact with it directly +- Check savings with `headroom_stats` if you're curious how much context you've recovered +- If the agent seems to forget earlier context, try asking: "check your context usage with headroom_stats and compress anything you don't need right now" + +## Comparison + +| | Engram | Headroom | +|--|--------|----------| +| What | Cross-session persistent memory | Within-session context compression | +| When | Between coding sessions | During a long session | +| How | FTS5 search + save/recall | Compress/decompress large text blocks | +| Analogy | Your project notebook | Vacuum packing bulky items in your current workspace | + +## References + +- [Headroom GitHub](https://github.com/headroomlabs-ai/headroom) +- [Headroom PyPI](https://pypi.org/project/headroom-ai/) +- [Components & Presets Reference](components.md)