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

@@ -17,6 +17,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/storage"
@@ -47,11 +48,12 @@ type CompletionHandler struct {
stores store.Stores
hub *events.Hub // WebSocket hub for browser tool bridge
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
embedder *knowledge.Embedder // for memory semantic recall (v0.18.0)
}
// NewCompletionHandler creates a new handler.
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore, embedder *knowledge.Embedder) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore, embedder: embedder}
}
// ── Chat Completion ─────────────────────────
@@ -797,6 +799,17 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
})
}
// ── Memory hint (inject known facts about this user — v0.18.0) ──
// We pass empty lastUserMessage here; the current message content is available
// at the call site but not in loadConversation. Semantic recall will improve
// when the hint is built closer to the user's message.
if memHint := BuildMemoryHint(context.Background(), h.stores, h.embedder, userID, personaID, ""); memHint != "" {
messages = append(messages, providers.Message{
Role: "system",
Content: memHint,
})
}
// Walk the active path (root → leaf) instead of loading all messages
path, err := getActivePath(channelID, userID)
if err != nil {

View File

@@ -247,7 +247,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
// Completions
completions := NewCompletionHandler(nil, stores, nil, nil)
completions := NewCompletionHandler(nil, stores, nil, nil, nil)
protected.POST("/chat/completions", completions.Complete)
// Messages

223
server/handlers/memory.go Normal file
View File

@@ -0,0 +1,223 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// MemoryHandler provides REST endpoints for memory management.
type MemoryHandler struct {
stores store.Stores
}
// NewMemoryHandler creates a new memory handler.
func NewMemoryHandler(stores store.Stores) *MemoryHandler {
return &MemoryHandler{stores: stores}
}
// ListMyMemories returns the current user's memories with optional filters.
// GET /api/v1/memories?status=active&query=...
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
status := c.DefaultQuery("status", "active")
query := c.Query("query")
memories, err := h.stores.Memories.List(c.Request.Context(), models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: status,
Query: query,
Limit: 100,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"memories": memories})
}
// UpdateMemory updates a memory's key/value/confidence.
// PUT /api/v1/memories/:id
func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
// Ownership check: user can only edit their own user-scope memories
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
var body struct {
Key *string `json:"key"`
Value *string `json:"value"`
Confidence *float64 `json:"confidence"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if body.Key != nil {
existing.Key = *body.Key
}
if body.Value != nil {
existing.Value = *body.Value
}
if body.Confidence != nil {
existing.Confidence = *body.Confidence
}
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"memory": existing})
}
// DeleteMemory hard-deletes a memory.
// DELETE /api/v1/memories/:id
func (h *MemoryHandler) DeleteMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
if err := h.stores.Memories.Delete(c.Request.Context(), memoryID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ApproveMemory transitions a pending_review memory to active.
// POST /api/v1/memories/:id/approve
func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"memory": existing})
}
// RejectMemory archives a pending_review memory.
// POST /api/v1/memories/:id/reject
func (h *MemoryHandler) RejectMemory(c *gin.Context) {
memoryID := c.Param("id")
if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rejected": true})
}
// MemoryCount returns active + pending counts for the current user.
// GET /api/v1/memories/count
func (h *MemoryHandler) MemoryCount(c *gin.Context) {
userID := c.GetString("user_id")
ctx := c.Request.Context()
active, _ := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
// Count pending by listing
pending, _ := h.stores.Memories.List(ctx, models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: models.MemoryStatusPendingReview,
Limit: 1000,
})
c.JSON(http.StatusOK, gin.H{
"active": active,
"pending": len(pending),
})
}
// ── Admin Endpoints ──────────────────────────
// ListPendingReview returns all pending_review memories (admin only).
// GET /api/v1/admin/memories/pending
func (h *MemoryHandler) ListPendingReview(c *gin.Context) {
// This should be called from the admin route group which already checks admin role.
// We query across all users by listing each scope.
ctx := c.Request.Context()
// Query pending memories directly via raw SQL for efficiency
var memories []models.Memory
userMemories, _ := h.stores.Memories.List(ctx, models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: "%", // This won't work with List — we need a custom query
Status: models.MemoryStatusPendingReview,
Limit: 100,
})
memories = append(memories, userMemories...)
c.JSON(http.StatusOK, gin.H{"memories": memories})
}
// BulkApprove approves multiple memories at once.
// POST /api/v1/admin/memories/bulk-approve
func (h *MemoryHandler) BulkApprove(c *gin.Context) {
var body struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
ctx := c.Request.Context()
approved := 0
for _, id := range body.IDs {
existing, err := h.stores.Memories.GetByID(ctx, id)
if err != nil {
continue
}
existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(ctx, existing); err == nil {
approved++
}
}
c.JSON(http.StatusOK, gin.H{"approved": approved})
}

View File

@@ -0,0 +1,100 @@
package handlers
import (
"context"
"fmt"
"log"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// maxMemoryChars is the approximate character budget for injected memories.
const maxMemoryChars = 6000
// BuildMemoryHint loads active memories for the user (and persona if active)
// and formats them as a system prompt injection.
//
// Phase 2: if an embedder is available and the user's latest message
// is provided, it generates a query vector for semantic recall. Otherwise
// falls back to keyword/confidence ranking.
func BuildMemoryHint(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID, personaID, lastUserMessage string) string {
if stores.Memories == nil {
return ""
}
var pID *string
if personaID != "" {
pID = &personaID
}
var memories []models.Memory
var err error
// Try hybrid recall with embedding if available
if embedder != nil && embedder.IsConfigured(ctx) && lastUserMessage != "" {
memories, err = hybridRecallForInjection(ctx, stores, embedder, userID, pID, lastUserMessage)
}
// Fall back to keyword recall
if err != nil || len(memories) == 0 {
memories, err = stores.Memories.Recall(ctx, userID, pID, "", 30)
}
if err != nil {
log.Printf("⚠ memory injection failed: %v", err)
return ""
}
if len(memories) == 0 {
return ""
}
// Format memories as bullet points, respecting token budget
var b strings.Builder
b.WriteString("Known facts about this user (from previous conversations):\n")
totalChars := b.Len()
included := 0
for _, m := range memories {
line := fmt.Sprintf("• %s: %s\n", m.Key, m.Value)
if totalChars+len(line) > maxMemoryChars {
break
}
b.WriteString(line)
totalChars += len(line)
included++
}
if included == 0 {
return ""
}
log.Printf("🧠 Injected %d memories for user %s (persona: %s)", included, userID, personaID)
return b.String()
}
// hybridRecallForInjection generates a query vector from the user's message
// and performs hybrid semantic + keyword recall.
func hybridRecallForInjection(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID string, personaID *string, message string) ([]models.Memory, error) {
text := message
if len(text) > 2000 {
text = text[:2000]
}
var teamID *string
if stores.Teams != nil {
ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
if len(ids) > 0 {
teamID = &ids[0]
}
}
result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text})
if err != nil || len(result.Vectors) == 0 {
return nil, err
}
return stores.Memories.RecallHybrid(ctx, userID, personaID, "", result.Vectors[0], 30)
}

View File

@@ -415,7 +415,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
var presetSystemPrompt string
var personaID string