Skip to content

Commit 086f212

Browse files
committed
v0.58.0 — Semantic session search with go-vector (RandomProjections + k-NN)
1 parent 94a6735 commit 086f212

9 files changed

Lines changed: 618 additions & 6 deletions

File tree

cmd/odek/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,8 @@ func continueCmd(args []string) error {
18721872
if err != nil {
18731873
return fmt.Errorf("session store: %w", err)
18741874
}
1875+
// Initialize semantic search index (non-fatal on failure).
1876+
_ = store.InitVectorIndex()
18751877

18761878
var sess *session.Session
18771879
if sessionID != "" {

cmd/odek/session_search_tool.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func newSessionSearchTool(store *session.Store) *sessionSearchTool {
2424
}
2525

2626
func (t *sessionSearchTool) Name() string { return "session_search" }
27-
func (t *sessionSearchTool) Description() string { return `Search and retrieve past agent sessions. Actions: list (recent sessions), search (keyword search), get (full session by ID), find (sessions by task/title). Use this when the user asks about previously discussed topics.` }
27+
func (t *sessionSearchTool) Description() string { return `Search and retrieve past agent sessions. Actions: list (recent sessions), search (semantic keyword search through full message content), get (full session by ID), find (sessions by task/title). Uses semantic vector search for the search action — it finds sessions whose conversation content is relevant to your query, even when titles don't match. Use OR between keywords for broad recall.` }
2828

2929
type sessionSearchArgs struct {
3030
Action string `json:"action"` // list, search, get, find
@@ -43,6 +43,7 @@ type sessionSummary struct {
4343

4444
type sessionSearchResult struct {
4545
Action string `json:"action"`
46+
Query string `json:"query,omitempty"` // echoed back for the LLM
4647
Sessions []sessionSummary `json:"sessions,omitempty"`
4748
Count int `json:"count"`
4849
// For get action — full session details
@@ -154,6 +155,42 @@ func (t *sessionSearchTool) handleSearch(query string, limit int) (string, error
154155

155156
tokens := strings.Fields(strings.ToLower(query))
156157

158+
// Phase 1: Vector search — semantic matching over conversation content.
159+
// This finds sessions whose message content is semantically similar to
160+
// the query, even when no keywords match the session title.
161+
if t.store.Vec != nil && t.store.Vec.Ready() {
162+
vecResults, err := t.store.Vec.Search(query, limit)
163+
if err == nil && len(vecResults) > 0 {
164+
// Load full session metadata for each result.
165+
results := make([]sessionSummary, 0, len(vecResults))
166+
for _, vr := range vecResults {
167+
sess, err := t.store.Load(vr.SessionID)
168+
if err != nil || sess == nil {
169+
continue
170+
}
171+
scoreLabel := fmt.Sprintf("(score: %.3f)", vr.Score)
172+
results = append(results, sessionSummary{
173+
ID: sess.ID,
174+
Task: sess.Task + " " + scoreLabel,
175+
Turns: sess.Turns,
176+
CreatedAt: sess.CreatedAt.UTC().Format(time.RFC3339),
177+
UpdatedAt: sess.UpdatedAt.UTC().Format(time.RFC3339),
178+
Model: sess.Model,
179+
})
180+
}
181+
if len(results) > 0 {
182+
return jsonResult(sessionSearchResult{
183+
Action: "search",
184+
Query: query,
185+
Sessions: results,
186+
Count: len(results),
187+
})
188+
}
189+
}
190+
// Vector search returned no results — fall through to keyword.
191+
}
192+
193+
// Phase 2: Keyword search (existing logic — title + deep scan).
157194
// Get generous candidate list
158195
listLimit := limit * 4
159196
if listLimit < 20 {
@@ -169,7 +206,7 @@ func (t *sessionSearchTool) handleSearch(query string, limit int) (string, error
169206
})
170207
}
171208

172-
// Phase 1: score by task + buffer
209+
// Phase 2a: score by task + buffer
173210
var matches []sessionMatch
174211
for _, s := range sessions {
175212
m := t.scoreSession(tokens, s)
@@ -178,7 +215,7 @@ func (t *sessionSearchTool) handleSearch(query string, limit int) (string, error
178215
}
179216
}
180217

181-
// Phase 2: if not enough results, load full sessions and search messages
218+
// Phase 2b: if not enough results, load full sessions and search messages
182219
if len(matches) < limit {
183220
matches = t.deepSearch(tokens, sessions, matches, limit)
184221
}
@@ -211,6 +248,7 @@ func (t *sessionSearchTool) handleSearch(query string, limit int) (string, error
211248

212249
return jsonResult(sessionSearchResult{
213250
Action: "search",
251+
Query: query,
214252
Sessions: results,
215253
Count: len(results),
216254
})

cmd/odek/telegram.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ func telegramCmd(args []string) error {
140140
return err
141141
}
142142

143+
// Initialize semantic search index.
144+
if err := store.InitVectorIndex(); err != nil {
145+
fmt.Fprintf(os.Stderr, "odek telegram: vector index: %v\n", err)
146+
// Non-fatal — search falls back to metadata-only.
147+
}
148+
143149
// 6. Create session manager (per-chat Telegram session cache)
144150
// with the configured session TTL (default 24h).
145151
sessionManager := telegram.NewSessionManager(store, time.Duration(cfg.SessionTTL)*time.Hour)

docs/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## v0.58.0 (2026-05-26) — Semantic Session Search (go-vector)
4+
5+
### Features
6+
- **Semantic session search**`session_search` now uses go-vector's RandomProjections embedder to find sessions by semantic similarity of their conversation content, not just keyword matching on titles. A search for "code review" now finds sessions discussing code review, even when their titles are "tg-8592463065".
7+
- **Automatic vector indexing** — every session Save() embeds the user+assistant conversation text using a pure-Go random projection (Achlioptas 2003) and persists the vector in `~/.odek/sessions/vectors.gob`. New sessions are indexed automatically. Index is rebuilt from scratch on first startup, then loaded from persisted gob files.
8+
- **`BuildConversationText()`** — new helper extracts user queries + assistant responses for embedding, excluding system prompts and tool noise.
9+
10+
### Infrastructure
11+
- **`go-vector` upgraded v1.1.1→v1.2.0** — adds `SaveEmbedder()`/`LoadEmbedder()` methods to RandomProjections for embedder state persistence (seed 42 deterministic, 256-dim sparse random projection).
12+
- **`internal/session/vector_index.go`** — new VectorIndex type wrapping go-vector's Store + RandomProjections with Init/Add/Remove/Search/Save lifecycle. Thread-safe.
13+
14+
### Testing
15+
- **5 new tests**`TestNewVectorIndex_Search` (semantic ranking), `TestVectorIndex_Remove` (idempotent delete), `TestVectorIndex_Persistence` (survives restart), `TestVectorIndex_EmptySearch` (graceful on empty), `TestBuildConversationText` (user/assistant only).
16+
317
## v0.57.0 (2026-05-26) — search_files & multi_grep Performance
418

519
### Performance

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.0
44

55
require (
66
github.com/BackendStack21/go-mcp v1.1.0
7-
github.com/BackendStack21/go-vector v1.1.1
7+
github.com/BackendStack21/go-vector v1.2.0
88
golang.org/x/net v0.54.0
99
golang.org/x/term v0.43.0
1010
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/BackendStack21/go-mcp v1.1.0 h1:NQStOkqUWjzzzmySnfFHvPKhbKr92iBuPJjkN
22
github.com/BackendStack21/go-mcp v1.1.0/go.mod h1:RKFw6nrl6ySQqqrR8KtG7HYZ/heyyjT8SjiEtlbTMY8=
33
github.com/BackendStack21/go-vector v1.1.1 h1:sycI+a/ifT2DD3kdH0HleWtjKmIVNutvL/pgOL9qTA8=
44
github.com/BackendStack21/go-vector v1.1.1/go.mod h1:+IzfAFO4m6xrjsOhZsiTAbMbm8+hX0d9C1uD0SGPzHc=
5+
github.com/BackendStack21/go-vector v1.2.0 h1:Zq6J/x7/OlAmVYC5t6VX64cf6V9rkSU2sR6nz49Fu+I=
6+
github.com/BackendStack21/go-vector v1.2.0/go.mod h1:+IzfAFO4m6xrjsOhZsiTAbMbm8+hX0d9C1uD0SGPzHc=
57
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
68
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
79
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=

internal/session/session.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ type Session struct {
5353
type Store struct {
5454
dir string // e.g. /home/user/.odek/sessions/
5555
mu sync.Mutex
56+
57+
// Vec is the optional semantic search index. When non-nil, every
58+
// Save/Delete/Cleanup call updates the vector index automatically.
59+
// Call InitVectorIndex() to initialize.
60+
Vec *VectorIndex
5661
}
5762

5863
// NewStore creates a session store rooted at ~/.odek/sessions/.
@@ -69,6 +74,17 @@ func NewStore() (*Store, error) {
6974
return &Store{dir: dir}, nil
7075
}
7176

77+
// InitVectorIndex initializes the semantic search index. Must be called
78+
// after NewStore, before the first Save. Safe to call multiple times —
79+
// subsequent calls are no-ops.
80+
func (s *Store) InitVectorIndex() error {
81+
if s.Vec != nil && s.Vec.Ready() {
82+
return nil // already initialized
83+
}
84+
s.Vec = new(VectorIndex)
85+
return s.Vec.Init(s.dir)
86+
}
87+
7288
// ── ID Generation ──────────────────────────────────────────────────────
7389

7490
// generateID creates a session ID: YYYYMMDD-<random 3 bytes hex>.
@@ -293,7 +309,18 @@ func (s *Store) saveLocked(sess *Session) error {
293309
// Update the index atomically.
294310
idx := s.loadIndex()
295311
idx[sess.ID] = indexEntry(sess)
296-
return s.saveIndexLocked(idx)
312+
if err := s.saveIndexLocked(idx); err != nil {
313+
return err
314+
}
315+
316+
// Update the vector index for semantic search.
317+
if s.Vec != nil {
318+
if err := s.Vec.Add(sess.ID, sess.Messages); err != nil {
319+
return fmt.Errorf("session: vector index add: %w", err)
320+
}
321+
}
322+
323+
return nil
297324
}
298325

299326
// Load reads a session from disk by ID. Returns an error if the file
@@ -443,7 +470,16 @@ func (s *Store) Delete(id string) error {
443470
// Remove from index atomically.
444471
idx := s.loadIndex()
445472
delete(idx, id)
446-
return s.saveIndexLocked(idx)
473+
if err := s.saveIndexLocked(idx); err != nil {
474+
return err
475+
}
476+
477+
// Remove from vector index.
478+
if s.Vec != nil {
479+
_ = s.Vec.Remove(id) // best-effort
480+
}
481+
482+
return nil
447483
}
448484

449485
// Cleanup deletes all sessions whose UpdatedAt is before the given time.

0 commit comments

Comments
 (0)