diff --git a/docs/content/building-gormes/architecture_plan/progress.json b/docs/content/building-gormes/architecture_plan/progress.json index e75fd1254..111531699 100644 --- a/docs/content/building-gormes/architecture_plan/progress.json +++ b/docs/content/building-gormes/architecture_plan/progress.json @@ -1321,9 +1321,9 @@ { "name": "Honcho host integration compatibility fixtures", "priority": "P3", - "status": "planned", + "status": "complete", "contract": "GONCHO preserves host-facing Honcho session, peer, and tool semantics needed by current OpenCode and SillyTavern integrations", - "contract_status": "draft", + "contract_status": "validated", "slice_size": "small", "execution_owner": "memory", "trust_class": [ @@ -1331,17 +1331,13 @@ "system" ], "degraded_mode": "Doctor and memory status explain which Honcho-compatible host mappings are unsupported instead of silently accepting incompatible config.", - "fixture": "internal/goncho host integration mapping fixtures", + "fixture": "internal/goncho/host_integration_test.go", "source_refs": [ "../honcho/docs/v3/guides/integrations/opencode.mdx", "../honcho/docs/v3/guides/integrations/sillytavern.mdx", "../honcho/docs/v3/guides/integrations/hermes.mdx", "docs/content/building-gormes/architecture_plan/phase-3-memory.md" ], - "blocked_by": [ - "Honcho-compatible scope/source tool schema", - "Interrupted-turn memory sync suppression" - ], "ready_when": [ "Public honcho_* schemas expose scope/source controls and interrupted turns cannot persist partial memory observations." ], @@ -1353,7 +1349,7 @@ "Host-scoped config keys do not mutate another host's settings.", "GONCHO keeps internal names while generated tool schemas and docs preserve honcho_* external compatibility." ], - "note": "Honcho upstream added OpenCode and SillyTavern integration docs in e659b6b. Gormes should not adopt their Node/Bun plugins, but the internal GONCHO contract must remain compatible with the shared Honcho concepts they depend on: workspace, peer, session strategy, host-scoped config, context-only/tool-only/hybrid recall, and durable conclusion/search tools.", + "note": "TDD landed: internal/goncho host integration fixtures now map Hermes/OpenCode/SillyTavern-style workspace, peer, session strategy, recall mode, and Honcho-compatible external tool naming semantics without adding a cloud Honcho client or renaming the internal Goncho service. Fixtures cover per-directory, per-repo, per-session, chat-instance, and global session keys, context/tools/hybrid recall behavior including context-only and tool-only aliases, host-scoped config patch isolation, and fail-closed diagnostics for unsupported host mappings.", "write_scope": [ "internal/goncho/", "internal/memory/", diff --git a/internal/goncho/host_integration.go b/internal/goncho/host_integration.go new file mode 100644 index 000000000..d4fa277b5 --- /dev/null +++ b/internal/goncho/host_integration.go @@ -0,0 +1,334 @@ +package goncho + +import ( + "fmt" + "strings" +) + +// HostIntegrationInput is the host-facing compatibility fixture input. It +// models the shared Honcho concepts used by current hosts without importing or +// running those hosts' plugins. +type HostIntegrationInput struct { + Host string + Workspace string + PeerName string + AIPeer string + SessionStrategy string + WorkingDirectory string + Repository string + Branch string + HostSessionID string + ChatInstanceID string + CharacterName string + RecallMode string +} + +// HostIntegrationMapping is the internal Goncho interpretation of one host +// configuration. +type HostIntegrationMapping struct { + Host string + WorkspaceID string + UserPeerID string + AIPeerID string + SessionStrategy string + SessionKey string + RecallMode string + InjectContext bool + ExposeTools bool + InternalService string + ExternalToolNames []string + Unsupported []UnsupportedHostMapping +} + +// UnsupportedHostMapping explains a host compatibility input that Goncho cannot +// safely accept yet. +type UnsupportedHostMapping struct { + Field string + Value string + Reason string +} + +// ExternalCompatibility records the internal/external naming contract. +type ExternalCompatibility struct { + InternalService string + ExternalToolNames []string +} + +// HostConfigDocument is the shared ~/.honcho/config.json shape needed for +// host-scoped config isolation fixtures. +type HostConfigDocument struct { + APIKey string `json:"apiKey,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + PeerName string `json:"peerName,omitempty"` + Workspace string `json:"workspace,omitempty"` + Hosts map[string]HostRuntimeConfig `json:"hosts,omitempty"` +} + +// HostRuntimeConfig is one hosts. block from the Honcho shared config. +type HostRuntimeConfig struct { + Workspace string `json:"workspace,omitempty"` + AIPeer string `json:"aiPeer,omitempty"` + PeerName string `json:"peerName,omitempty"` + RecallMode string `json:"recallMode,omitempty"` + ObservationMode string `json:"observationMode,omitempty"` + SessionStrategy string `json:"sessionStrategy,omitempty"` +} + +// HostConfigPatch updates only one hosts. block. +type HostConfigPatch struct { + Workspace *string + AIPeer *string + PeerName *string + RecallMode *string + ObservationMode *string + SessionStrategy *string +} + +type hostDefaults struct { + workspace string + aiPeer string + sessionStrategy string + recallMode string +} + +// MapHostIntegration translates host config concepts to the current internal +// Goncho service contract. Unsupported fields are returned as diagnostics +// instead of being silently widened or accepted. +func MapHostIntegration(input HostIntegrationInput) HostIntegrationMapping { + host := normalizeHost(input.Host) + defaults, ok := defaultsForHost(host) + if !ok { + defaults = hostDefaults{ + workspace: "default", + aiPeer: "gormes", + sessionStrategy: "per-session", + recallMode: "hybrid", + } + } + + compat := HonchoExternalCompatibility() + out := HostIntegrationMapping{ + Host: host, + WorkspaceID: firstNonBlank(input.Workspace, defaults.workspace, "default"), + UserPeerID: strings.TrimSpace(input.PeerName), + AIPeerID: firstNonBlank(input.AIPeer, defaults.aiPeer, "gormes"), + InternalService: compat.InternalService, + ExternalToolNames: append([]string(nil), compat.ExternalToolNames...), + } + if !ok { + out.Unsupported = append(out.Unsupported, UnsupportedHostMapping{ + Field: "host", + Value: strings.TrimSpace(input.Host), + Reason: "host has no Goncho compatibility defaults", + }) + } + if out.UserPeerID == "" { + out.Unsupported = append(out.Unsupported, UnsupportedHostMapping{ + Field: "peer_name", + Value: "", + Reason: "host mappings require an explicit durable user peer", + }) + } + + out.SessionStrategy = normalizeSessionStrategy(firstNonBlank(input.SessionStrategy, defaults.sessionStrategy)) + out.SessionKey = sessionKeyForStrategy(host, out.SessionStrategy, input, &out.Unsupported) + + recallMode, ok := normalizeRecallMode(firstNonBlank(input.RecallMode, defaults.recallMode)) + if !ok { + out.Unsupported = append(out.Unsupported, UnsupportedHostMapping{ + Field: "recall_mode", + Value: strings.TrimSpace(input.RecallMode), + Reason: "supported recall modes are context, tools, and hybrid", + }) + } else { + out.RecallMode = recallMode + out.InjectContext = recallMode == "context" || recallMode == "hybrid" + out.ExposeTools = recallMode == "tools" || recallMode == "hybrid" + } + + return out +} + +// ApplyHostConfigPatch applies host-scoped config writes without mutating the +// input document or sibling host entries. +func ApplyHostConfigPatch(doc HostConfigDocument, host string, patch HostConfigPatch) (HostConfigDocument, error) { + host = normalizeHost(host) + if host == "" { + return HostConfigDocument{}, fmt.Errorf("goncho: host is required") + } + + out := doc + out.Hosts = make(map[string]HostRuntimeConfig, len(doc.Hosts)+1) + for key, value := range doc.Hosts { + out.Hosts[normalizeHost(key)] = value + } + + cfg := out.Hosts[host] + if patch.Workspace != nil { + cfg.Workspace = strings.TrimSpace(*patch.Workspace) + } + if patch.AIPeer != nil { + cfg.AIPeer = strings.TrimSpace(*patch.AIPeer) + } + if patch.PeerName != nil { + cfg.PeerName = strings.TrimSpace(*patch.PeerName) + } + if patch.RecallMode != nil { + cfg.RecallMode = strings.TrimSpace(*patch.RecallMode) + } + if patch.ObservationMode != nil { + cfg.ObservationMode = strings.TrimSpace(*patch.ObservationMode) + } + if patch.SessionStrategy != nil { + cfg.SessionStrategy = strings.TrimSpace(*patch.SessionStrategy) + } + out.Hosts[host] = cfg + return out, nil +} + +// HonchoExternalCompatibility returns the current public Honcho-compatible +// tool names while keeping the implementation service named Goncho. +func HonchoExternalCompatibility() ExternalCompatibility { + return ExternalCompatibility{ + InternalService: "goncho", + ExternalToolNames: []string{ + "honcho_profile", + "honcho_search", + "honcho_context", + "honcho_reasoning", + "honcho_conclude", + }, + } +} + +func defaultsForHost(host string) (hostDefaults, bool) { + switch host { + case "hermes": + return hostDefaults{ + workspace: "hermes", + aiPeer: "hermes", + sessionStrategy: "per-directory", + recallMode: "hybrid", + }, true + case "opencode": + return hostDefaults{ + workspace: "opencode", + aiPeer: "opencode", + sessionStrategy: "per-directory", + recallMode: "hybrid", + }, true + case "sillytavern": + return hostDefaults{ + workspace: "sillytavern", + aiPeer: "sillytavern", + sessionStrategy: "chat-instance", + recallMode: "hybrid", + }, true + default: + return hostDefaults{}, false + } +} + +func sessionKeyForStrategy(host, strategy string, input HostIntegrationInput, unsupported *[]UnsupportedHostMapping) string { + switch strategy { + case "per-directory": + value := strings.TrimSpace(input.WorkingDirectory) + if value == "" { + addUnsupported(unsupported, "session_strategy", strategy, "per-directory requires working_directory") + return "" + } + return host + ":dir:" + value + case "per-repo": + value := strings.TrimSpace(input.Repository) + if value == "" { + addUnsupported(unsupported, "session_strategy", strategy, "per-repo requires repository") + return "" + } + return host + ":repo:" + value + case "git-branch": + repo := strings.TrimSpace(input.Repository) + branch := strings.TrimSpace(input.Branch) + if repo == "" || branch == "" { + addUnsupported(unsupported, "session_strategy", strategy, "git-branch requires repository and branch") + return "" + } + return host + ":branch:" + repo + ":" + branch + case "per-session": + value := firstNonBlank(input.HostSessionID, input.CharacterName) + if value == "" { + addUnsupported(unsupported, "session_strategy", strategy, "per-session requires host_session_id") + return "" + } + return host + ":session:" + value + case "chat-instance": + value := strings.TrimSpace(input.ChatInstanceID) + if value == "" { + addUnsupported(unsupported, "session_strategy", strategy, "chat-instance requires chat_instance_id") + return "" + } + return host + ":chat:" + value + case "global": + return host + ":global" + default: + addUnsupported(unsupported, "session_strategy", strategy, "unsupported session strategy") + return "" + } +} + +func addUnsupported(items *[]UnsupportedHostMapping, field, value, reason string) { + *items = append(*items, UnsupportedHostMapping{ + Field: field, + Value: value, + Reason: reason, + }) +} + +func normalizeHost(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + host = strings.ReplaceAll(host, "-", "_") + switch host { + case "silly_tavern": + return "sillytavern" + default: + return host + } +} + +func normalizeSessionStrategy(strategy string) string { + switch strings.ToLower(strings.TrimSpace(strategy)) { + case "directory", "per_directory": + return "per-directory" + case "repo", "per_repo": + return "per-repo" + case "branch", "git_branch": + return "git-branch" + case "session", "per_session", "custom", "per-character", "per_character": + return "per-session" + case "chat", "per-chat", "per_chat", "chat_instance", "auto": + return "chat-instance" + default: + return strings.ToLower(strings.TrimSpace(strategy)) + } +} + +func normalizeRecallMode(mode string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "hybrid", "reasoning": + return "hybrid", true + case "context", "context-only", "context_only": + return "context", true + case "tools", "tool", "tool-only", "tool_only", "tool-call", "tool_call": + return "tools", true + default: + return "", false + } +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/goncho/host_integration_test.go b/internal/goncho/host_integration_test.go new file mode 100644 index 000000000..8dc647ed5 --- /dev/null +++ b/internal/goncho/host_integration_test.go @@ -0,0 +1,265 @@ +package goncho + +import ( + "encoding/json" + "slices" + "strings" + "testing" +) + +func TestHostIntegrationMappingSupportsDocumentedSessionStrategies(t *testing.T) { + tests := []struct { + name string + input HostIntegrationInput + wantKey string + }{ + { + name: "opencode per-directory", + input: HostIntegrationInput{ + Host: "opencode", + PeerName: "alice", + SessionStrategy: "per-directory", + WorkingDirectory: "/work/acme/frontend", + RecallMode: "hybrid", + }, + wantKey: "opencode:dir:/work/acme/frontend", + }, + { + name: "opencode per-repo", + input: HostIntegrationInput{ + Host: "opencode", + PeerName: "alice", + SessionStrategy: "per-repo", + Repository: "github.com/acme/gormes", + RecallMode: "hybrid", + }, + wantKey: "opencode:repo:github.com/acme/gormes", + }, + { + name: "opencode per-session", + input: HostIntegrationInput{ + Host: "opencode", + PeerName: "alice", + SessionStrategy: "per-session", + HostSessionID: "oc-session-7", + RecallMode: "hybrid", + }, + wantKey: "opencode:session:oc-session-7", + }, + { + name: "opencode chat-instance", + input: HostIntegrationInput{ + Host: "opencode", + PeerName: "alice", + SessionStrategy: "chat-instance", + ChatInstanceID: "chat-42", + RecallMode: "hybrid", + }, + wantKey: "opencode:chat:chat-42", + }, + { + name: "opencode global", + input: HostIntegrationInput{ + Host: "opencode", + PeerName: "alice", + SessionStrategy: "global", + RecallMode: "hybrid", + }, + wantKey: "opencode:global", + }, + { + name: "sillytavern chat-instance", + input: HostIntegrationInput{ + Host: "sillytavern", + PeerName: "alice-rp", + SessionStrategy: "chat-instance", + ChatInstanceID: "st-chat-9", + RecallMode: "context-only", + }, + wantKey: "sillytavern:chat:st-chat-9", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MapHostIntegration(tt.input) + if len(got.Unsupported) != 0 { + t.Fatalf("Unsupported = %+v, want none", got.Unsupported) + } + if got.SessionKey != tt.wantKey { + t.Fatalf("SessionKey = %q, want %q", got.SessionKey, tt.wantKey) + } + if got.UserPeerID != tt.input.PeerName { + t.Fatalf("UserPeerID = %q, want %q", got.UserPeerID, tt.input.PeerName) + } + if got.WorkspaceID == "" { + t.Fatal("WorkspaceID must be populated from host defaults or input") + } + }) + } +} + +func TestHostIntegrationRecallModesMapContextToolsAndHybrid(t *testing.T) { + tests := []struct { + mode string + wantPrompt bool + wantTools bool + wantMode string + }{ + {mode: "context", wantPrompt: true, wantTools: false, wantMode: "context"}, + {mode: "context-only", wantPrompt: true, wantTools: false, wantMode: "context"}, + {mode: "tools", wantPrompt: false, wantTools: true, wantMode: "tools"}, + {mode: "tool-only", wantPrompt: false, wantTools: true, wantMode: "tools"}, + {mode: "hybrid", wantPrompt: true, wantTools: true, wantMode: "hybrid"}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + got := MapHostIntegration(HostIntegrationInput{ + Host: "opencode", + PeerName: "alice", + SessionStrategy: "per-directory", + WorkingDirectory: "/work/acme/frontend", + RecallMode: tt.mode, + }) + if len(got.Unsupported) != 0 { + t.Fatalf("Unsupported = %+v, want none", got.Unsupported) + } + if got.RecallMode != tt.wantMode { + t.Fatalf("RecallMode = %q, want %q", got.RecallMode, tt.wantMode) + } + if got.InjectContext != tt.wantPrompt || got.ExposeTools != tt.wantTools { + t.Fatalf("recall behavior = context:%v tools:%v, want context:%v tools:%v", + got.InjectContext, got.ExposeTools, tt.wantPrompt, tt.wantTools) + } + }) + } +} + +func TestHostConfigPatchScopesWritesToSelectedHost(t *testing.T) { + doc := HostConfigDocument{ + APIKey: "hch-shared", + BaseURL: "http://127.0.0.1:8000", + PeerName: "alice", + Hosts: map[string]HostRuntimeConfig{ + "opencode": { + Workspace: "opencode", + AIPeer: "opencode", + RecallMode: "hybrid", + SessionStrategy: "per-directory", + }, + "sillytavern": { + Workspace: "sillytavern", + PeerName: "alice-rp", + RecallMode: "context", + }, + }, + } + + updated, err := ApplyHostConfigPatch(doc, "opencode", HostConfigPatch{ + Workspace: stringPtr("team-acme"), + RecallMode: stringPtr("tools"), + }) + if err != nil { + t.Fatal(err) + } + + if updated.Hosts["opencode"].Workspace != "team-acme" { + t.Fatalf("opencode workspace = %q, want team-acme", updated.Hosts["opencode"].Workspace) + } + if updated.Hosts["opencode"].RecallMode != "tools" { + t.Fatalf("opencode recall = %q, want tools", updated.Hosts["opencode"].RecallMode) + } + if updated.Hosts["sillytavern"] != doc.Hosts["sillytavern"] { + t.Fatalf("sillytavern host config changed: got %+v want %+v", updated.Hosts["sillytavern"], doc.Hosts["sillytavern"]) + } + if doc.Hosts["opencode"].Workspace != "opencode" { + t.Fatalf("original document mutated: opencode workspace = %q", doc.Hosts["opencode"].Workspace) + } +} + +func TestHostConfigDocumentJSONUsesHonchoSharedKeys(t *testing.T) { + raw, err := json.Marshal(HostConfigDocument{ + APIKey: "hch-shared", + BaseURL: "http://127.0.0.1:8000", + PeerName: "alice", + Hosts: map[string]HostRuntimeConfig{ + "opencode": { + Workspace: "opencode", + AIPeer: "opencode", + RecallMode: "hybrid", + SessionStrategy: "per-directory", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + text := string(raw) + for _, key := range []string{`"apiKey"`, `"baseUrl"`, `"peerName"`, `"hosts"`, `"aiPeer"`, `"recallMode"`, `"sessionStrategy"`} { + if !strings.Contains(text, key) { + t.Fatalf("config json %s missing key %s", text, key) + } + } + for _, key := range []string{`"APIKey"`, `"BaseURL"`, `"PeerName"`, `"AIPeer"`, `"RecallMode"`, `"SessionStrategy"`} { + if strings.Contains(text, key) { + t.Fatalf("config json %s leaked Go field key %s", text, key) + } + } +} + +func TestHostIntegrationMappingReportsUnsupportedConfig(t *testing.T) { + got := MapHostIntegration(HostIntegrationInput{ + Host: "opencode", + PeerName: "alice", + SessionStrategy: "per-repo", + RecallMode: "always-on", + }) + + if len(got.Unsupported) != 2 { + t.Fatalf("Unsupported = %+v, want session_strategy and recall_mode diagnostics", got.Unsupported) + } + if !hasUnsupportedField(got.Unsupported, "session_strategy") { + t.Fatalf("missing session_strategy diagnostic: %+v", got.Unsupported) + } + if !hasUnsupportedField(got.Unsupported, "recall_mode") { + t.Fatalf("missing recall_mode diagnostic: %+v", got.Unsupported) + } + if got.SessionKey != "" { + t.Fatalf("SessionKey = %q, want empty when required per-repo input is missing", got.SessionKey) + } +} + +func TestHonchoExternalCompatibilityKeepsGonchoInternalName(t *testing.T) { + got := HonchoExternalCompatibility() + if got.InternalService != "goncho" { + t.Fatalf("InternalService = %q, want goncho", got.InternalService) + } + for _, name := range got.ExternalToolNames { + if !strings.HasPrefix(name, "honcho_") { + t.Fatalf("external tool name %q does not preserve honcho_ prefix", name) + } + if strings.HasPrefix(name, "goncho_") { + t.Fatalf("external tool name %q leaked internal goncho prefix", name) + } + } + for _, name := range []string{"honcho_profile", "honcho_search", "honcho_context", "honcho_conclude"} { + if !slices.Contains(got.ExternalToolNames, name) { + t.Fatalf("ExternalToolNames = %v, missing %s", got.ExternalToolNames, name) + } + } +} + +func hasUnsupportedField(items []UnsupportedHostMapping, field string) bool { + for _, item := range items { + if item.Field == field && item.Reason != "" { + return true + } + } + return false +} + +func stringPtr(value string) *string { + return &value +}