diff --git a/repos/kbconfig.go b/repos/kbconfig.go new file mode 100644 index 0000000..26c1cba --- /dev/null +++ b/repos/kbconfig.go @@ -0,0 +1,116 @@ +package repos + +import ( + "fmt" + "path/filepath" + + "forge.lthn.ai/core/go-io" + "gopkg.in/yaml.v3" +) + +// KBConfig holds knowledge base configuration for a workspace. +// Stored at .core/kb.yaml and checked into git. +type KBConfig struct { + Version int `yaml:"version"` + Wiki WikiConfig `yaml:"wiki"` + Search KBSearch `yaml:"search"` +} + +// WikiConfig controls local wiki mirror behaviour. +type WikiConfig struct { + // Enabled toggles wiki cloning on sync. + Enabled bool `yaml:"enabled"` + // Dir is the local directory for wiki clones, relative to .core/. + Dir string `yaml:"dir"` + // Remote is the SSH base URL for wiki repos (e.g. ssh://git@forge.lthn.ai:2223/core). + // Repo wikis are at {Remote}/{name}.wiki.git + Remote string `yaml:"remote"` +} + +// KBSearch configures vector search against the OpenBrain Qdrant collection. +type KBSearch struct { + // QdrantHost is the Qdrant server (gRPC). + QdrantHost string `yaml:"qdrant_host"` + // QdrantPort is the gRPC port. + QdrantPort int `yaml:"qdrant_port"` + // Collection is the Qdrant collection name. + Collection string `yaml:"collection"` + // OllamaURL is the Ollama API base URL for embedding queries. + OllamaURL string `yaml:"ollama_url"` + // EmbedModel is the Ollama model for embedding. + EmbedModel string `yaml:"embed_model"` + // TopK is the default number of results. + TopK int `yaml:"top_k"` +} + +// DefaultKBConfig returns sensible defaults for knowledge base config. +func DefaultKBConfig() *KBConfig { + return &KBConfig{ + Version: 1, + Wiki: WikiConfig{ + Enabled: true, + Dir: "kb/wiki", + Remote: "ssh://git@forge.lthn.ai:2223/core", + }, + Search: KBSearch{ + QdrantHost: "qdrant.lthn.sh", + QdrantPort: 6334, + Collection: "openbrain", + OllamaURL: "https://ollama.lthn.sh", + EmbedModel: "embeddinggemma", + TopK: 5, + }, + } +} + +// LoadKBConfig reads .core/kb.yaml from the given workspace root directory. +// Returns defaults if the file does not exist. +func LoadKBConfig(m io.Medium, root string) (*KBConfig, error) { + path := filepath.Join(root, ".core", "kb.yaml") + + if !m.Exists(path) { + return DefaultKBConfig(), nil + } + + content, err := m.Read(path) + if err != nil { + return nil, fmt.Errorf("failed to read kb config: %w", err) + } + + kb := DefaultKBConfig() + if err := yaml.Unmarshal([]byte(content), kb); err != nil { + return nil, fmt.Errorf("failed to parse kb config: %w", err) + } + + return kb, nil +} + +// SaveKBConfig writes .core/kb.yaml to the given workspace root directory. +func SaveKBConfig(m io.Medium, root string, kb *KBConfig) error { + coreDir := filepath.Join(root, ".core") + if err := m.EnsureDir(coreDir); err != nil { + return fmt.Errorf("failed to create .core directory: %w", err) + } + + data, err := yaml.Marshal(kb) + if err != nil { + return fmt.Errorf("failed to marshal kb config: %w", err) + } + + path := filepath.Join(coreDir, "kb.yaml") + if err := m.Write(path, string(data)); err != nil { + return fmt.Errorf("failed to write kb config: %w", err) + } + + return nil +} + +// WikiRepoURL returns the full clone URL for a repo's wiki. +func (kb *KBConfig) WikiRepoURL(repoName string) string { + return fmt.Sprintf("%s/%s.wiki.git", kb.Wiki.Remote, repoName) +} + +// WikiLocalPath returns the local path for a repo's wiki clone. +func (kb *KBConfig) WikiLocalPath(root, repoName string) string { + return filepath.Join(root, ".core", kb.Wiki.Dir, repoName) +} diff --git a/repos/kbconfig_test.go b/repos/kbconfig_test.go new file mode 100644 index 0000000..db72a5d --- /dev/null +++ b/repos/kbconfig_test.go @@ -0,0 +1,107 @@ +package repos + +import ( + "testing" + + "forge.lthn.ai/core/go-io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── DefaultKBConfig ──────────────────────────────────────────────── + +func TestDefaultKBConfig_Good(t *testing.T) { + kb := DefaultKBConfig() + assert.Equal(t, 1, kb.Version) + assert.True(t, kb.Wiki.Enabled) + assert.Equal(t, "kb/wiki", kb.Wiki.Dir) + assert.Contains(t, kb.Wiki.Remote, "forge.lthn.ai") + assert.Equal(t, "qdrant.lthn.sh", kb.Search.QdrantHost) + assert.Equal(t, 6334, kb.Search.QdrantPort) + assert.Equal(t, "openbrain", kb.Search.Collection) + assert.Equal(t, "embeddinggemma", kb.Search.EmbedModel) + assert.Equal(t, 5, kb.Search.TopK) +} + +// ── Load / Save round-trip ───────────────────────────────────────── + +func TestKBConfig_LoadSave_Good(t *testing.T) { + m := io.NewMockMedium() + _ = m.EnsureDir("/workspace/.core") + + kb := DefaultKBConfig() + kb.Search.TopK = 10 + kb.Search.Collection = "custom_brain" + + err := SaveKBConfig(m, "/workspace", kb) + require.NoError(t, err) + + loaded, err := LoadKBConfig(m, "/workspace") + require.NoError(t, err) + + assert.Equal(t, 1, loaded.Version) + assert.Equal(t, 10, loaded.Search.TopK) + assert.Equal(t, "custom_brain", loaded.Search.Collection) + assert.True(t, loaded.Wiki.Enabled) +} + +func TestKBConfig_Load_Good_NoFile(t *testing.T) { + m := io.NewMockMedium() + _ = m.EnsureDir("/workspace/.core") + + kb, err := LoadKBConfig(m, "/workspace") + require.NoError(t, err) + assert.Equal(t, DefaultKBConfig().Search.Collection, kb.Search.Collection) +} + +func TestKBConfig_Load_Good_PartialOverride(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("/workspace/.core/kb.yaml", ` +version: 1 +search: + top_k: 20 + collection: my_brain +`) + + kb, err := LoadKBConfig(m, "/workspace") + require.NoError(t, err) + + assert.Equal(t, 20, kb.Search.TopK) + assert.Equal(t, "my_brain", kb.Search.Collection) + // Defaults preserved + assert.True(t, kb.Wiki.Enabled) + assert.Equal(t, "embeddinggemma", kb.Search.EmbedModel) +} + +func TestKBConfig_Load_Bad_InvalidYAML(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("/workspace/.core/kb.yaml", "{{{{broken") + + _, err := LoadKBConfig(m, "/workspace") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse") +} + +// ── WikiRepoURL ──────────────────────────────────────────────────── + +func TestKBConfig_WikiRepoURL_Good(t *testing.T) { + kb := DefaultKBConfig() + url := kb.WikiRepoURL("go-scm") + assert.Equal(t, "ssh://git@forge.lthn.ai:2223/core/go-scm.wiki.git", url) +} + +func TestKBConfig_WikiRepoURL_Good_CustomRemote(t *testing.T) { + kb := &KBConfig{ + Wiki: WikiConfig{Remote: "ssh://git@git.example.com/org"}, + } + url := kb.WikiRepoURL("my-repo") + assert.Equal(t, "ssh://git@git.example.com/org/my-repo.wiki.git", url) +} + +// ── WikiLocalPath ────────────────────────────────────────────────── + +func TestKBConfig_WikiLocalPath_Good(t *testing.T) { + kb := DefaultKBConfig() + path := kb.WikiLocalPath("/workspace", "go-scm") + assert.Equal(t, "/workspace/.core/kb/wiki/go-scm", path) +}