Changeset 0.18.0 (#79)

This commit is contained in:
2026-02-28 18:24:19 +00:00
parent 12e316c234
commit e4a943b03e
48 changed files with 3999 additions and 1398 deletions

View File

@@ -38,6 +38,7 @@ type Stores struct {
KnowledgeBases KnowledgeBaseStore
Groups GroupStore
ResourceGrants ResourceGrantStore
Memories MemoryStore
}
// =========================================

View File

@@ -0,0 +1,269 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type MemoryStore struct{}
func NewMemoryStore() *MemoryStore { return &MemoryStore{} }
func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error {
if m.ID == "" {
m.ID = store.NewID()
}
if m.Status == "" {
m.Status = models.MemoryStatusActive
}
if m.Confidence == 0 {
m.Confidence = 1.0
}
_, err := DB.ExecContext(ctx, `
INSERT INTO memories (id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key)
DO UPDATE SET
value = EXCLUDED.value,
confidence = EXCLUDED.confidence,
status = EXCLUDED.status,
source_channel_id = EXCLUDED.source_channel_id,
updated_at = now()
`, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value,
m.SourceChannelID, m.Confidence, m.Status)
return err
}
func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) {
m := &models.Memory{}
err := DB.QueryRowContext(ctx, `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories WHERE id = $1
`, id).Scan(
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
&m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("memory not found: %s", id)
}
return m, err
}
func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) {
clauses := []string{"scope = $1", "owner_id = $2"}
args := []interface{}{f.Scope, f.OwnerID}
idx := 3
if f.UserID != nil {
clauses = append(clauses, fmt.Sprintf("user_id = $%d", idx))
args = append(args, *f.UserID)
idx++
}
status := f.Status
if status == "" {
status = models.MemoryStatusActive
}
clauses = append(clauses, fmt.Sprintf("status = $%d", idx))
args = append(args, status)
idx++
if f.Query != "" {
clauses = append(clauses, fmt.Sprintf(
"to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx))
args = append(args, f.Query)
idx++
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
args = append(args, limit)
query := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE %s
ORDER BY confidence DESC, updated_at DESC
LIMIT $%d
`, strings.Join(clauses, " AND "), idx)
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *MemoryStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = $1`, id)
return err
}
func (s *MemoryStore) Archive(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `
UPDATE memories SET status = 'archived', updated_at = now() WHERE id = $1
`, id)
return err
}
func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
// Build a UNION query across applicable scopes.
// Always include user-scope memories for this user.
// If a persona is active, also include persona and persona+user memories.
parts := []string{}
args := []interface{}{}
idx := 1
// 1. User-scope memories
userPart := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE scope = 'user' AND owner_id = $%d AND status = 'active'
`, idx)
args = append(args, userID)
idx++
if query != "" {
userPart += fmt.Sprintf(
" AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)
args = append(args, query)
idx++
}
parts = append(parts, userPart)
// 2. Persona-scope memories (if persona active)
if personaID != nil && *personaID != "" {
personaPart := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE scope = 'persona' AND owner_id = $%d AND status = 'active'
`, idx)
args = append(args, *personaID)
idx++
if query != "" {
personaPart += fmt.Sprintf(
" AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)
args = append(args, query)
idx++
}
parts = append(parts, personaPart)
// 3. Persona+user memories
puPart := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE scope = 'persona_user' AND owner_id = $%d AND user_id = $%d AND status = 'active'
`, idx, idx+1)
args = append(args, *personaID, userID)
idx += 2
if query != "" {
puPart += fmt.Sprintf(
" AND to_tsvector('english', key || ' ' || value) @@ plainto_tsquery('english', $%d)", idx)
args = append(args, query)
idx++
}
parts = append(parts, puPart)
}
// Scope priority: persona_user > persona > user (CASE ordering)
fullQuery := fmt.Sprintf(`
SELECT * FROM (%s) AS combined
ORDER BY
CASE scope
WHEN 'persona_user' THEN 0
WHEN 'persona' THEN 1
WHEN 'user' THEN 2
END,
confidence DESC,
updated_at DESC
LIMIT $%d
`, strings.Join(parts, " UNION ALL "), idx)
args = append(args, limit)
rows, err := DB.QueryContext(ctx, fullQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM memories
WHERE scope = $1 AND owner_id = $2 AND status = 'active'
`, scope, ownerID).Scan(&count)
return count, err
}
// ── Helpers ─────────────────────────────────
func scanMemories(rows *sql.Rows) ([]models.Memory, error) {
var memories []models.Memory
for rows.Next() {
var m models.Memory
if err := rows.Scan(
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
&m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, err
}
memories = append(memories, m)
}
if memories == nil {
memories = []models.Memory{}
}
return memories, rows.Err()
}
// scanMemory scans a single memory row — unused currently but available
// for future single-row queries.
func scanMemory(row *sql.Row) (*models.Memory, error) {
m := &models.Memory{}
err := row.Scan(
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
&m.SourceChannelID, &m.Confidence, &m.Status, &m.CreatedAt, &m.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return m, err
}
// ensure compile-time interface satisfaction
var _ interface {
Upsert(ctx context.Context, m *models.Memory) error
GetByID(ctx context.Context, id string) (*models.Memory, error)
List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error)
Delete(ctx context.Context, id string) error
Archive(ctx context.Context, id string) error
Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error)
CountByOwner(ctx context.Context, scope, ownerID string) (int, error)
} = (*MemoryStore)(nil)
// ensure unused imports don't cause errors
var _ = time.Now

View File

@@ -0,0 +1,135 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// RecallHybrid returns active memories using vector similarity + keyword search.
// If queryVec is nil/empty, falls back to keyword-only Recall.
func (s *MemoryStore) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) {
if len(queryVec) == 0 {
return s.Recall(ctx, userID, personaID, query, limit)
}
if limit <= 0 || limit > 100 {
limit = 30
}
// Run semantic recall
semantic, err := s.recallSemantic(ctx, userID, personaID, queryVec, limit)
if err != nil {
// Fall back to keyword
return s.Recall(ctx, userID, personaID, query, limit)
}
// Run keyword recall if query provided
var keyword []models.Memory
if query != "" {
keyword, _ = s.Recall(ctx, userID, personaID, query, limit)
}
return mergeResults(semantic, keyword, limit), nil
}
// recallSemantic uses pgvector cosine distance to find similar memories.
func (s *MemoryStore) recallSemantic(ctx context.Context, userID string, personaID *string, queryVec []float64, limit int) ([]models.Memory, error) {
vecStr := vectorToString(queryVec)
parts := []string{}
args := []interface{}{}
idx := 1
// User-scope
userPart := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at,
(embedding <=> $%d::vector) AS distance
FROM memories
WHERE scope = 'user' AND owner_id = $%d AND status = 'active'
AND embedding IS NOT NULL
`, idx, idx+1)
args = append(args, vecStr, userID)
idx += 2
parts = append(parts, userPart)
if personaID != nil && *personaID != "" {
// Persona-scope
personaPart := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at,
(embedding <=> $%d::vector) AS distance
FROM memories
WHERE scope = 'persona' AND owner_id = $%d AND status = 'active'
AND embedding IS NOT NULL
`, idx, idx+1)
args = append(args, vecStr, *personaID)
idx += 2
parts = append(parts, personaPart)
// Persona+user scope
puPart := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at,
(embedding <=> $%d::vector) AS distance
FROM memories
WHERE scope = 'persona_user' AND owner_id = $%d AND user_id = $%d AND status = 'active'
AND embedding IS NOT NULL
`, idx, idx+1, idx+2)
args = append(args, vecStr, *personaID, userID)
idx += 3
parts = append(parts, puPart)
}
args = append(args, limit)
fullQuery := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM (%s) AS combined
WHERE distance < 0.7
ORDER BY distance ASC, confidence DESC
LIMIT $%d
`, strings.Join(parts, " UNION ALL "), idx)
rows, err := DB.QueryContext(ctx, fullQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
// mergeResults deduplicates and merges semantic + keyword results.
func mergeResults(semantic, keyword []models.Memory, limit int) []models.Memory {
seen := make(map[string]bool, len(semantic))
result := make([]models.Memory, 0, limit)
// Semantic results first (higher relevance)
for _, m := range semantic {
if len(result) >= limit {
break
}
seen[m.ID] = true
result = append(result, m)
}
// Then keyword results not already included
for _, m := range keyword {
if len(result) >= limit {
break
}
if !seen[m.ID] {
seen[m.ID] = true
result = append(result, m)
}
}
return result
}
// ensure unused
var _ = sql.ErrNoRows

View File

@@ -15,20 +15,23 @@ func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at`
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
created_at, updated_at`
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
return DB.QueryRowContext(ctx, `
INSERT INTO personas (name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
scope, owner_id, created_by, is_active, is_shared,
memory_enabled, memory_extraction_prompt)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
RETURNING id, created_at, updated_at`,
p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
p.MemoryEnabled, p.MemoryExtractionPrompt,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
@@ -86,6 +89,12 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
if patch.IsShared != nil {
b.Set("is_shared", *patch.IsShared)
}
if patch.MemoryEnabled != nil {
b.Set("memory_enabled", *patch.MemoryEnabled)
}
if patch.MemoryExtractionPrompt != nil {
b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt)
}
if !b.HasSets() {
return nil
}
@@ -279,6 +288,7 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
@@ -305,6 +315,7 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {

View File

@@ -31,5 +31,6 @@ func NewStores(db *sql.DB) store.Stores {
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
ResourceGrants: NewResourceGrantStore(),
Memories: NewMemoryStore(),
}
}

View File

@@ -0,0 +1,224 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type MemoryStore struct{}
func NewMemoryStore() *MemoryStore { return &MemoryStore{} }
func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error {
if m.ID == "" {
m.ID = store.NewID()
}
if m.Status == "" {
m.Status = models.MemoryStatusActive
}
if m.Confidence == 0 {
m.Confidence = 1.0
}
_, err := DB.ExecContext(ctx, `
INSERT INTO memories (id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (scope, owner_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'), key)
DO UPDATE SET
value = excluded.value,
confidence = excluded.confidence,
status = excluded.status,
source_channel_id = excluded.source_channel_id,
updated_at = datetime('now')
`, m.ID, m.Scope, m.OwnerID, m.UserID, m.Key, m.Value,
m.SourceChannelID, m.Confidence, m.Status)
return err
}
func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) {
m := &models.Memory{}
err := DB.QueryRowContext(ctx, `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories WHERE id = ?
`, id).Scan(
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
&m.SourceChannelID, &m.Confidence, &m.Status,
st(&m.CreatedAt), st(&m.UpdatedAt),
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("memory not found: %s", id)
}
return m, err
}
func (s *MemoryStore) List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error) {
clauses := []string{"scope = ?", "owner_id = ?"}
args := []interface{}{f.Scope, f.OwnerID}
if f.UserID != nil {
clauses = append(clauses, "user_id = ?")
args = append(args, *f.UserID)
}
status := f.Status
if status == "" {
status = models.MemoryStatusActive
}
clauses = append(clauses, "status = ?")
args = append(args, status)
if f.Query != "" {
// SQLite: LIKE-based keyword search (no tsvector)
clauses = append(clauses, "(key || ' ' || value) LIKE ?")
args = append(args, "%"+f.Query+"%")
}
limit := f.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
args = append(args, limit)
query := fmt.Sprintf(`
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE %s
ORDER BY confidence DESC, updated_at DESC
LIMIT ?
`, strings.Join(clauses, " AND "))
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanMemories(rows)
}
func (s *MemoryStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM memories WHERE id = ?`, id)
return err
}
func (s *MemoryStore) Archive(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `
UPDATE memories SET status = 'archived', updated_at = datetime('now') WHERE id = ?
`, id)
return err
}
func (s *MemoryStore) Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
// Build UNION query across applicable scopes
parts := []string{}
args := []interface{}{}
// 1. User-scope memories
userPart := `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE scope = 'user' AND owner_id = ? AND status = 'active'
`
args = append(args, userID)
if query != "" {
userPart += " AND (key || ' ' || value) LIKE ?"
args = append(args, "%"+query+"%")
}
parts = append(parts, userPart)
// 2. Persona-scope memories
if personaID != nil && *personaID != "" {
personaPart := `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE scope = 'persona' AND owner_id = ? AND status = 'active'
`
args = append(args, *personaID)
if query != "" {
personaPart += " AND (key || ' ' || value) LIKE ?"
args = append(args, "%"+query+"%")
}
parts = append(parts, personaPart)
// 3. Persona+user memories
puPart := `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active'
`
args = append(args, *personaID, userID)
if query != "" {
puPart += " AND (key || ' ' || value) LIKE ?"
args = append(args, "%"+query+"%")
}
parts = append(parts, puPart)
}
fullQuery := fmt.Sprintf(`
SELECT * FROM (%s)
ORDER BY
CASE scope
WHEN 'persona_user' THEN 0
WHEN 'persona' THEN 1
WHEN 'user' THEN 2
END,
confidence DESC,
updated_at DESC
LIMIT ?
`, strings.Join(parts, " UNION ALL "))
args = append(args, limit)
rows, err := DB.QueryContext(ctx, fullQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanMemories(rows)
}
func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM memories
WHERE scope = ? AND owner_id = ? AND status = 'active'
`, scope, ownerID).Scan(&count)
return count, err
}
// ── Helpers ─────────────────────────────────
func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) {
var memories []models.Memory
for rows.Next() {
var m models.Memory
if err := rows.Scan(
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
&m.SourceChannelID, &m.Confidence, &m.Status,
st(&m.CreatedAt), st(&m.UpdatedAt),
); err != nil {
return nil, err
}
memories = append(memories, m)
}
if memories == nil {
memories = []models.Memory{}
}
return memories, rows.Err()
}

View File

@@ -0,0 +1,146 @@
package sqlite
import (
"context"
"encoding/json"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// RecallHybrid returns active memories using vector similarity + keyword search.
// SQLite has no pgvector, so we load embeddings and compute cosine similarity in Go.
func (s *MemoryStore) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) {
if len(queryVec) == 0 {
return s.Recall(ctx, userID, personaID, query, limit)
}
if limit <= 0 || limit > 100 {
limit = 30
}
// Load all active memories with embeddings for applicable scopes
semantic, err := s.recallSemantic(ctx, userID, personaID, queryVec, limit)
if err != nil || len(semantic) == 0 {
return s.Recall(ctx, userID, personaID, query, limit)
}
var keyword []models.Memory
if query != "" {
keyword, _ = s.Recall(ctx, userID, personaID, query, limit)
}
return mergeResultsSQLite(semantic, keyword, limit), nil
}
type scoredMemory struct {
Memory models.Memory
Similarity float64
}
func (s *MemoryStore) recallSemantic(ctx context.Context, userID string, personaID *string, queryVec []float64, limit int) ([]models.Memory, error) {
// Build query to load memories with embeddings
parts := []string{}
args := []interface{}{}
userPart := `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at, embedding
FROM memories
WHERE scope = 'user' AND owner_id = ? AND status = 'active'
AND embedding IS NOT NULL
`
args = append(args, userID)
parts = append(parts, userPart)
if personaID != nil && *personaID != "" {
personaPart := `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at, embedding
FROM memories
WHERE scope = 'persona' AND owner_id = ? AND status = 'active'
AND embedding IS NOT NULL
`
args = append(args, *personaID)
parts = append(parts, personaPart)
puPart := `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at, embedding
FROM memories
WHERE scope = 'persona_user' AND owner_id = ? AND user_id = ? AND status = 'active'
AND embedding IS NOT NULL
`
args = append(args, *personaID, userID)
parts = append(parts, puPart)
}
fullQuery := strings.Join(parts, " UNION ALL ")
rows, err := DB.QueryContext(ctx, fullQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Score each memory by cosine similarity
var scored []scoredMemory
for rows.Next() {
var m models.Memory
var embeddingJSON string
if err := rows.Scan(
&m.ID, &m.Scope, &m.OwnerID, &m.UserID, &m.Key, &m.Value,
&m.SourceChannelID, &m.Confidence, &m.Status,
st(&m.CreatedAt), st(&m.UpdatedAt), &embeddingJSON,
); err != nil {
continue
}
var vec []float64
if err := json.Unmarshal([]byte(embeddingJSON), &vec); err != nil {
continue
}
// cosineSimilarity is defined in knowledge_bases.go (same package)
sim := cosineSimilarity(queryVec, vec)
if sim > 0.3 {
scored = append(scored, scoredMemory{Memory: m, Similarity: sim})
}
}
// Sort by similarity descending (simple insertion sort for small N)
for i := 0; i < len(scored); i++ {
for j := i + 1; j < len(scored); j++ {
if scored[j].Similarity > scored[i].Similarity {
scored[i], scored[j] = scored[j], scored[i]
}
}
}
// Take top N
result := make([]models.Memory, 0, limit)
for i := 0; i < len(scored) && i < limit; i++ {
result = append(result, scored[i].Memory)
}
return result, rows.Err()
}
func mergeResultsSQLite(semantic, keyword []models.Memory, limit int) []models.Memory {
seen := make(map[string]bool, len(semantic))
result := make([]models.Memory, 0, limit)
for _, m := range semantic {
if len(result) >= limit {
break
}
seen[m.ID] = true
result = append(result, m)
}
for _, m := range keyword {
if len(result) >= limit {
break
}
if !seen[m.ID] {
result = append(result, m)
}
}
return result
}

View File

@@ -17,7 +17,8 @@ func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at`
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
created_at, updated_at`
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
p.ID = store.NewID()
@@ -27,13 +28,15 @@ func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO personas (id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
scope, owner_id, created_by, is_active, is_shared, memory_enabled, memory_extraction_prompt,
created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
p.MemoryEnabled, p.MemoryExtractionPrompt,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
@@ -93,6 +96,12 @@ func (s *PersonaStore) Update(ctx context.Context, id string, patch models.Perso
if patch.IsShared != nil {
b.Set("is_shared", *patch.IsShared)
}
if patch.MemoryEnabled != nil {
b.Set("memory_enabled", *patch.MemoryEnabled)
}
if patch.MemoryExtractionPrompt != nil {
b.Set("memory_extraction_prompt", *patch.MemoryExtractionPrompt)
}
if !b.HasSets() {
return nil
}
@@ -286,6 +295,7 @@ func scanPersona(row *sql.Row) (*models.Persona, error) {
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
st(&p.CreatedAt), st(&p.UpdatedAt),
)
if err != nil {
@@ -312,6 +322,7 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.MemoryEnabled, &p.MemoryExtractionPrompt,
st(&p.CreatedAt), st(&p.UpdatedAt),
)
if err != nil {

View File

@@ -31,5 +31,6 @@ func NewStores(db *sql.DB) store.Stores {
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
ResourceGrants: NewResourceGrantStore(),
Memories: NewMemoryStore(),
}
}

View File

@@ -0,0 +1,47 @@
// ── store_memory.go ─────────────────────────
// MemoryStore interface for v0.18.0.
// Add this interface to store/interfaces.go and add
// Memories MemoryStore
// to the Stores struct.
package store
import (
"context"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// =========================================
// MEMORY STORE (v0.18.0)
// =========================================
type MemoryStore interface {
// Upsert creates or updates a memory (matched on scope+owner+user+key).
// If a memory with the same key exists, value/confidence/status are updated.
Upsert(ctx context.Context, m *models.Memory) error
// GetByID returns a single memory by ID.
GetByID(ctx context.Context, id string) (*models.Memory, error)
// List returns memories matching the filter criteria.
List(ctx context.Context, f models.MemoryFilter) ([]models.Memory, error)
// Delete hard-deletes a memory by ID.
Delete(ctx context.Context, id string) error
// Archive sets status to 'archived' (soft delete).
Archive(ctx context.Context, id string) error
// Recall returns active memories relevant to a query, merging scopes.
// Combines user + persona + persona_user memories for the given context.
// Results are ordered by confidence DESC, updated_at DESC.
Recall(ctx context.Context, userID string, personaID *string, query string, limit int) ([]models.Memory, error)
// CountByOwner returns the number of active memories for a scope+owner.
CountByOwner(ctx context.Context, scope, ownerID string) (int, error)
// RecallHybrid returns active memories using vector similarity + keyword search.
// If queryVec is nil/empty, falls back to keyword-only (same as Recall).
RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error)
}

View File

@@ -0,0 +1,15 @@
// ── store_memory_hybrid.go ──────────────────
// Phase 2 addition to the MemoryStore interface.
//
// Add this method to the MemoryStore interface in store/store_memory.go:
//
// // RecallHybrid returns active memories using vector similarity + keyword search.
// // If queryVec is nil/empty, falls back to keyword-only (same as Recall).
// // Merges results from applicable scopes with deduplication.
// RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error)
//
// The implementations are in:
// - store/postgres/memory_hybrid.go (pgvector cosine distance)
// - store/sqlite/memory_hybrid.go (app-level cosine similarity)
package store