Skip to content

Commit 570c0ea

Browse files
committed
Skills semantic matching (opt-in) + shared embedding config
Phase 3 — skills: Route the skill VectorMatcher through internal/embedding. The skill corpus embeds once at reload (cheap); only the query embeds per turn, so the HTTP backend is opt-in and time-bounded: - NewVectorMatcherWithConfig selects the backend; default stays local RP. - For an HTTP config that omits timeout_seconds, a short 2s query timeout is applied so the per-turn embed fails fast. - SkillManager.MatchLazySkills is the single entry point the agent loop wires: when a remote backend is configured it tries semantic matching first and falls back to the keyword ScoredMatcher on no-match/timeout/down-backend; otherwise it uses the keyword matcher directly (unchanged default). The local RP vector matcher stays a fallback, never the primary. Phase 4 — shared config + docs: - New top-level `embedding` block is the shared default. Memory inherits it unless memory.embedding overrides (back-compat); sessions read it for semantic session_search; skills do NOT inherit it — opt in via skills.embedding because they run on every turn. - Resolved into ResolvedConfig.Embedding and Skills.Embedding; merge + memory inheritance wired in the loader. - Session init and the skill managers across all agent entry points (run, continue, repl, serve, subagent, mcp, telegram) now take the resolved embedding configs. - CONFIG.md documents the shared backend, the inherit/opt-in matrix, and the per-subsystem resilience (session fingerprint rebuild, skill fail-fast fallback, egress warning). Tests: HTTP-backed semantic skill match, default-RP-not-semantic, keyword fallback when the skills backend is down, and config resolution for top-level inheritance + per-subsystem overrides.
1 parent 9587832 commit 570c0ea

14 files changed

Lines changed: 466 additions & 81 deletions

File tree

cmd/odek/main.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -828,9 +828,10 @@ func run(args []string) error {
828828
// Skills setup
829829
var sm *skills.SkillManager
830830
if resolved.Skills.Learn {
831-
sm = skills.NewSkillManager(
831+
sm = skills.NewSkillManagerWithEmbedding(
832832
expandHome("~/.odek/skills"),
833833
"./.odek/skills",
834+
resolved.Skills.Embedding,
834835
)
835836
}
836837

@@ -1657,9 +1658,9 @@ func continueCmd(args []string) error {
16571658
// Resolve config (no CLI flags for continue — uses session's model)
16581659
resolved := config.LoadConfig(config.CLIFlags{Model: sess.Model})
16591660

1660-
// Initialize semantic search index (non-fatal on failure). Sessions share
1661-
// memory's embedding backend so one endpoint config powers both.
1662-
_ = store.InitVectorIndex(resolved.Memory.Embedding)
1661+
// Initialize semantic search index (non-fatal on failure). Sessions use the
1662+
// shared top-level embedding backend.
1663+
_ = store.InitVectorIndex(resolved.Embedding)
16631664

16641665
// Auto-apply sandbox if session was sandboxed (even if config changed)
16651666
if sess.Sandbox && !resolved.Sandbox {
@@ -1670,9 +1671,10 @@ func continueCmd(args []string) error {
16701671
// Build tools
16711672
var sm *skills.SkillManager
16721673
if resolved.Skills.Learn {
1673-
sm = skills.NewSkillManager(
1674+
sm = skills.NewSkillManagerWithEmbedding(
16741675
expandHome("~/.odek/skills"),
16751676
"./.odek/skills",
1677+
resolved.Skills.Embedding,
16761678
)
16771679
}
16781680
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, store)

cmd/odek/mcp.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ Flags:
6666
// Build skills manager (for skill tools)
6767
var sm *skills.SkillManager
6868
if resolved.Skills.Learn {
69-
sm = skills.NewSkillManager(
69+
sm = skills.NewSkillManagerWithEmbedding(
7070
expandHome("~/.odek/skills"),
7171
"./.odek/skills",
72+
resolved.Skills.Embedding,
7273
)
7374
}
7475

cmd/odek/repl.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ func replCmd(args []string) error {
7272
// Build tools
7373
var sm *skills.SkillManager
7474
if resolved.Skills.Learn {
75-
sm = skills.NewSkillManager(
75+
sm = skills.NewSkillManagerWithEmbedding(
7676
expandHome("~/.odek/skills"),
7777
"./.odek/skills",
78+
resolved.Skills.Embedding,
7879
)
7980
}
8081
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)

cmd/odek/serve.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,10 @@ func serveOnListener(listener net.Listener, mux *http.ServeMux) error {
257257
func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v any) error) (*odek.Agent, func() error, func(), *wsApprover, error) {
258258
var sm *skills.SkillManager
259259
if resolved.Skills.Learn {
260-
sm = skills.NewSkillManager(
260+
sm = skills.NewSkillManagerWithEmbedding(
261261
expandHome("~/.odek/skills"),
262262
"./.odek/skills",
263+
resolved.Skills.Embedding,
263264
)
264265
}
265266

cmd/odek/subagent.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,10 @@ func subagentCmd(args []string) error {
286286
// Build tools
287287
var sm *skills.SkillManager
288288
if resolved.Skills.Learn {
289-
sm = skills.NewSkillManager(
289+
sm = skills.NewSkillManagerWithEmbedding(
290290
expandHome("~/.odek/skills"),
291291
"./.odek/skills",
292+
resolved.Skills.Embedding,
292293
)
293294
}
294295
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)

cmd/odek/telegram.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,9 @@ func telegramCmd(args []string) error {
143143
return err
144144
}
145145

146-
// Initialize semantic search index. Sessions share memory's embedding
147-
// backend so a single Ollama/OpenAI endpoint config powers both.
148-
if err := store.InitVectorIndex(resolved.Memory.Embedding); err != nil {
146+
// Initialize semantic search index using the shared top-level embedding
147+
// backend so a single Ollama/OpenAI endpoint config powers it.
148+
if err := store.InitVectorIndex(resolved.Embedding); err != nil {
149149
fmt.Fprintf(os.Stderr, "odek telegram: vector index: %v\n", err)
150150
// Non-fatal — search falls back to metadata-only.
151151
}

docs/CONFIG.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,64 @@ Operational notes:
282282
(e.g. cloud metadata services) you would not otherwise expose. Prefer a local
283283
server (Ollama/llama.cpp) when episode/fact text must not leave the machine.
284284

285+
## Shared embedding backend (`embedding`) — sessions & skills
286+
287+
The same embedder that powers memory also powers **semantic session search**
288+
(the `session_search` tool) and, opt-in, **semantic skill matching**. Rather
289+
than configuring three separate endpoints, set one **top-level `embedding`
290+
block** as the shared default; subsystems then inherit or override it. The block
291+
uses the exact same fields as `memory.embedding` above
292+
(`provider`/`base_url`/`model`/`api_key`/`dims`/`timeout_seconds`).
293+
294+
```json
295+
{
296+
"embedding": {
297+
"provider": "http",
298+
"base_url": "http://localhost:11434/v1",
299+
"model": "nomic-embed-text"
300+
}
301+
}
302+
```
303+
304+
| Subsystem | Uses the shared `embedding`? | Override |
305+
|-----------|------------------------------|----------|
306+
| **Memory** | Yes, when `memory.embedding` is unset | `memory.embedding` wins if set (back-compat) |
307+
| **Sessions** (`session_search`) | Yes — semantic session search | *(top-level only)* |
308+
| **Skills** (lazy matching) | **No — opt-in** | `skills.embedding` (explicit) |
309+
310+
Why skills are opt-in: skill matching runs on **every user turn**, so a remote
311+
embedding call sits on the hot path. Sessions and memory embed infrequently
312+
(explicit search / session-end) and persist their vectors, so inheriting the
313+
shared default there is cheap. To enable semantic skill matching, set
314+
`skills.embedding` explicitly:
315+
316+
```json
317+
{
318+
"skills": {
319+
"embedding": {
320+
"provider": "http",
321+
"base_url": "http://localhost:11434/v1",
322+
"model": "nomic-embed-text",
323+
"timeout_seconds": 2
324+
}
325+
}
326+
}
327+
```
328+
329+
Operational notes:
330+
331+
- **Sessions self-heal across backend changes** exactly like memory: a
332+
`vectors_meta.json` fingerprint records the embedding space; changing
333+
`provider`/`model`/`dims` forces a one-time rebuild from the session files. A
334+
down backend degrades `session_search` to its keyword fallback and backs off
335+
for 30s — it never fails a session save.
336+
- **Skill matching fails fast and falls back.** The per-turn query embed is
337+
bounded by a short timeout (defaults to 2s when `skills.embedding` omits
338+
`timeout_seconds`); on a slow/failed/empty result the matcher falls back to
339+
the local keyword matcher, so a down backend never stalls or blocks loading.
340+
- The **egress warning above applies to every subsystem** — session transcripts
341+
and skill text are POSTed to `base_url`. Point it only at a server you trust.
342+
285343
### ⚠️ `extract_facts` — automatic fact learning (opt-in, off by default)
286344

287345
When enabled, after each session of ≥3 turns odek asks the LLM to pull a few

internal/config/loader.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"strings"
2323

2424
"github.com/BackendStack21/odek/internal/danger"
25+
"github.com/BackendStack21/odek/internal/embedding"
2526
"github.com/BackendStack21/odek/internal/mcpclient"
2627
"github.com/BackendStack21/odek/internal/memory"
2728
"github.com/BackendStack21/odek/internal/redact"
@@ -85,6 +86,7 @@ type SkillsConfig struct {
8586
LLMLearn *bool `json:"llm_learn,omitempty"`
8687
LLMCurate *bool `json:"llm_curate,omitempty"`
8788
Verbose *bool `json:"verbose,omitempty"`
89+
Embedding *embedding.Config `json:"embedding,omitempty"`
8890
}
8991

9092
// TranscriptionConfig controls the transcribe tool (local whisper.cpp).
@@ -175,6 +177,12 @@ type FileConfig struct {
175177
// Memory section controls the persistent memory system.
176178
Memory *memory.MemoryConfig `json:"memory,omitempty"`
177179

180+
// Embedding is the shared default embedding backend for semantic retrieval.
181+
// Memory uses it unless memory.embedding overrides it, and sessions use it
182+
// for semantic session_search. Skills opt in separately (skills.embedding)
183+
// because they run on every turn. See internal/embedding.Config.
184+
Embedding *embedding.Config `json:"embedding,omitempty"`
185+
178186
// MCPServers maps server names to MCP server configurations.
179187
// Each server is an external MCP server (e.g., Playwright, database,
180188
// web scraping) whose tools are exposed to the agent.
@@ -297,6 +305,11 @@ type ResolvedConfig struct {
297305
// Memory is the resolved memory config with default values.
298306
Memory memory.MemoryConfig
299307

308+
// Embedding is the resolved shared embedding backend used by sessions for
309+
// semantic session_search (and as memory's default when memory.embedding is
310+
// unset). nil = default RandomProjections.
311+
Embedding *embedding.Config
312+
300313
// MCPServers maps server names to external MCP server configurations.
301314
// Populated from the mcp_servers section of odek.json.
302315
MCPServers map[string]mcpclient.ServerConfig
@@ -713,6 +726,7 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
713726
Skills: resolveSkills(cfg.Skills),
714727
Dangerous: resolveDangerous(cfg.Dangerous),
715728
Memory: resolveMemory(cfg.Memory),
729+
Embedding: cfg.Embedding,
716730
MCPServers: cfg.MCPServers,
717731
Telegram: resolveTelegram(cfg.Telegram),
718732
Transcription: resolveTranscription(cfg.Transcription),
@@ -723,6 +737,13 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
723737
ToolProgress: ifZero(cfg.ToolProgress, "all"),
724738
}
725739

740+
// Memory inherits the shared top-level embedding backend unless it set its
741+
// own (memory.embedding wins for back-compat). Sessions read
742+
// resolved.Embedding directly; skills opt in via skills.embedding only.
743+
if resolved.Memory.Embedding == nil {
744+
resolved.Memory.Embedding = cfg.Embedding
745+
}
746+
726747
// MaxConcurrency: default to 3 if not set
727748
if cfg.MaxConcurrency > 0 {
728749
resolved.MaxConcurrency = cfg.MaxConcurrency
@@ -846,6 +867,10 @@ func resolveSkills(cfg *SkillsConfig) skills.SkillsConfig {
846867
if cfg.Verbose != nil {
847868
def.Verbose = *cfg.Verbose
848869
}
870+
// Opt-in only: skills do not inherit the top-level embedding default.
871+
if cfg.Embedding != nil {
872+
def.Embedding = cfg.Embedding
873+
}
849874
return def
850875
}
851876

@@ -1149,6 +1174,9 @@ func overlayFile(base, override FileConfig) FileConfig {
11491174
if override.Memory != nil {
11501175
base.Memory = override.Memory
11511176
}
1177+
if override.Embedding != nil {
1178+
base.Embedding = override.Embedding
1179+
}
11521180
if override.Telegram != nil {
11531181
base.Telegram = override.Telegram
11541182
}

internal/config/loader_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,3 +935,61 @@ func TestLoadConfig_MemoryEmbeddingSection(t *testing.T) {
935935
t.Errorf("base_url/api_key = %q/%q, want unexpanded ${...} placeholders", emb.BaseURL, emb.APIKey)
936936
}
937937
}
938+
939+
// TestLoadConfig_TopLevelEmbeddingShared verifies the shared top-level
940+
// embedding block: it populates ResolvedConfig.Embedding (sessions read it) and
941+
// memory inherits it when memory.embedding is unset. Skills do NOT inherit it —
942+
// skill matching is opt-in via skills.embedding only.
943+
func TestLoadConfig_TopLevelEmbeddingShared(t *testing.T) {
944+
dir := t.TempDir()
945+
t.Setenv("HOME", dir)
946+
t.Chdir(dir)
947+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
948+
"embedding": {
949+
"provider": "http",
950+
"base_url": "http://localhost:11434/v1",
951+
"model": "nomic-embed-text"
952+
}
953+
}`), 0644); err != nil {
954+
t.Fatal(err)
955+
}
956+
cfg := LoadConfig(CLIFlags{})
957+
958+
if cfg.Embedding == nil || cfg.Embedding.Model != "nomic-embed-text" {
959+
t.Fatalf("top-level embedding not resolved: %+v", cfg.Embedding)
960+
}
961+
// Memory inherits the shared default.
962+
if cfg.Memory.Embedding == nil || cfg.Memory.Embedding.Model != "nomic-embed-text" {
963+
t.Errorf("memory should inherit top-level embedding, got %+v", cfg.Memory.Embedding)
964+
}
965+
// Skills do NOT inherit it (opt-in only).
966+
if cfg.Skills.Embedding != nil {
967+
t.Errorf("skills must not inherit the top-level embedding default, got %+v", cfg.Skills.Embedding)
968+
}
969+
}
970+
971+
// TestLoadConfig_EmbeddingOverrides verifies memory.embedding overrides the
972+
// shared default and skills.embedding opts in independently.
973+
func TestLoadConfig_EmbeddingOverrides(t *testing.T) {
974+
dir := t.TempDir()
975+
t.Setenv("HOME", dir)
976+
t.Chdir(dir)
977+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
978+
"embedding": {"provider": "http", "base_url": "http://shared/v1", "model": "shared-model"},
979+
"memory": {"embedding": {"provider": "http", "base_url": "http://mem/v1", "model": "mem-model"}},
980+
"skills": {"embedding": {"provider": "http", "base_url": "http://skill/v1", "model": "skill-model"}}
981+
}`), 0644); err != nil {
982+
t.Fatal(err)
983+
}
984+
cfg := LoadConfig(CLIFlags{})
985+
986+
if cfg.Embedding == nil || cfg.Embedding.Model != "shared-model" {
987+
t.Fatalf("shared embedding = %+v, want shared-model", cfg.Embedding)
988+
}
989+
if cfg.Memory.Embedding == nil || cfg.Memory.Embedding.Model != "mem-model" {
990+
t.Errorf("memory.embedding should win over shared default, got %+v", cfg.Memory.Embedding)
991+
}
992+
if cfg.Skills.Embedding == nil || cfg.Skills.Embedding.Model != "skill-model" {
993+
t.Errorf("skills.embedding opt-in = %+v, want skill-model", cfg.Skills.Embedding)
994+
}
995+
}

0 commit comments

Comments
 (0)