Changeset 0.28.0.8 (#180)

This commit is contained in:
2026-03-12 22:49:35 +00:00
parent f3f5afd14c
commit aa870f1040
14 changed files with 679 additions and 116 deletions

View File

@@ -4,45 +4,92 @@
identifies facts, preferences, and context about the user and stores identifies facts, preferences, and context about the user and stores
them for injection into future conversations. them for injection into future conversations.
**Auth:** All user endpoints require authenticated session.
Admin endpoints require platform admin role.
---
### User Memory ### User Memory
``` ```
GET /memories → { "data": [memory objects] } GET /memories → { "data": [memory objects] }
GET /memories/count → { "count": 42 } GET /memories/count → { "active": 5, "pending": 2 }
PUT /memories/:id ← { "content": "updated text" } PUT /memories/:id ← { "key": "...", "value": "...", "confidence": 0.9 }
DELETE /memories/:id DELETE /memories/:id
POST /memories/:id/approve → approve a pending memory POST /memories/:id/approve → approve a pending_review memory
POST /memories/:id/reject → reject a pending memory POST /memories/:id/reject → reject (archive) a pending_review memory
``` ```
Query params for `GET /memories`:
| Param | Default | Description |
|-------|---------|-------------|
| `status` | `active` | Filter by status: `active`, `pending_review`, `archived` |
| `query` | — | Keyword filter on key+value (PG: full-text, SQLite: LIKE) |
Memory object: Memory object:
```json ```json
{ {
"id": "uuid", "id": "uuid",
"user_id": "uuid", "scope": "user|persona|persona_user",
"persona_id": "uuid|null", "owner_id": "uuid",
"scope": "user|persona", "user_id": "uuid|null",
"content": "User prefers Go for backend work", "key": "preferred language",
"source_channel_id": "uuid", "value": "Go with Gin framework for backend services",
"status": "pending|approved|rejected", "source_channel_id": "uuid|null",
"confidence": 0.85, "confidence": 0.85,
"status": "active|pending_review|archived",
"created_at": "...", "created_at": "...",
"updated_at": "..." "updated_at": "..."
} }
``` ```
`scope`: `user` memories apply across all conversations. `persona` **Scopes:**
memories are specific to interactions with that Persona.
| Scope | `owner_id` | `user_id` | Description |
|-------|-----------|-----------|-------------|
| `user` | user's ID | null | Personal facts, persist across all conversations |
| `persona` | persona's ID | null | Shared across all users of a Persona |
| `persona_user` | persona's ID | user's ID | Per-user facts within a Persona context |
**Ownership:** `PUT` and `DELETE` enforce ownership — a user can only
modify their own user-scope memories (`owner_id == user_id`).
### Admin Memory Review ### Admin Memory Review
``` ```
GET /admin/memories/pending → { "data": [memory objects with user info] } GET /admin/memories/pending → { "data": [memory objects] }
POST /admin/memories/bulk-approve ← { "ids": ["uuid1", "uuid2"] } POST /admin/memories/bulk-approve ← { "ids": ["uuid1", "uuid2"] }
→ { "approved": 2 }
``` ```
Platform admin can review and bulk-approve pending memories across Platform admin can review and bulk-approve pending memories across
all users. all users.
### AI Tools (non-REST)
Two tools are registered for AI use during completions:
| Tool | Description |
|------|-------------|
| `memory_save` | Save a fact with `key`, `value`, `confidence`. Saves as `active` (bypasses review). |
| `memory_recall` | Search memories by query. Uses hybrid semantic+keyword recall when embedder is configured. |
### Background Extraction
A background scanner periodically finds conversations with enough
new activity and calls the utility model to extract memorable facts.
Extracted memories are saved as `pending_review`.
Controlled by `memory_extraction_enabled` global config key and
per-persona `memory_enabled` flag.
### Memory Injection
Active memories are injected into the system prompt at completion
time via `BuildMemoryHint`. Uses hybrid semantic recall when an
embedder is available, falling back to keyword/confidence ranking.
Budget: ~6000 characters.
--- ---

View File

@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS memories (
confidence REAL NOT NULL DEFAULT 1.0, confidence REAL NOT NULL DEFAULT 1.0,
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'pending_review', 'archived')), CHECK (status IN ('active', 'pending_review', 'archived')),
embedding TEXT,
last_compacted_at TEXT, last_compacted_at TEXT,
decay_rate REAL NOT NULL DEFAULT 0.0, decay_rate REAL NOT NULL DEFAULT 0.0,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),

View File

@@ -330,6 +330,16 @@ func setupHarness(t *testing.T) *testHarness {
// Tasks (v0.28.0) // Tasks (v0.28.0)
taskH := NewTaskHandler(stores) taskH := NewTaskHandler(stores)
protected.GET("/tasks", taskH.ListMine) protected.GET("/tasks", taskH.ListMine)
// Memory (v0.18.0, audit v0.28.0)
memH := NewMemoryHandler(stores)
protected.GET("/memories", memH.ListMyMemories)
protected.PUT("/memories/:id", memH.UpdateMemory)
protected.DELETE("/memories/:id", memH.DeleteMemory)
protected.POST("/memories/:id/approve", memH.ApproveMemory)
protected.POST("/memories/:id/reject", memH.RejectMemory)
protected.GET("/memories/count", memH.MemoryCount)
protected.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Create) protected.POST("/tasks", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Create)
protected.GET("/tasks/:id", taskH.Get) protected.GET("/tasks/:id", taskH.Get)
protected.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Update) protected.PUT("/tasks/:id", middleware.RequirePermission(authpkg.PermTaskCreate, stores), taskH.Update)
@@ -423,6 +433,11 @@ func setupHarness(t *testing.T) *testHarness {
admin.POST("/tasks/:id/kill", taskAdm.KillRun) admin.POST("/tasks/:id/kill", taskAdm.KillRun)
admin.DELETE("/tasks/:id", taskAdm.Delete) admin.DELETE("/tasks/:id", taskAdm.Delete)
// Admin memory review
adminMemH := NewMemoryHandler(stores)
admin.GET("/memories/pending", adminMemH.ListPendingReview)
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
return &testHarness{router: r, t: t} return &testHarness{router: r, t: t}
} }

View File

@@ -43,7 +43,10 @@ func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"memories": memories}) if memories == nil {
memories = []models.Memory{}
}
c.JSON(http.StatusOK, gin.H{"data": memories})
} }
// UpdateMemory updates a memory's key/value/confidence. // UpdateMemory updates a memory's key/value/confidence.
@@ -84,7 +87,7 @@ func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
existing.Confidence = *body.Confidence existing.Confidence = *body.Confidence
} }
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil { if err := h.stores.Memories.Update(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -128,6 +131,11 @@ func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
return return
} }
if existing.Status != models.MemoryStatusPendingReview {
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be approved"})
return
}
existing.Status = models.MemoryStatusActive existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil { if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -140,8 +148,26 @@ func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
// RejectMemory archives a pending_review memory. // RejectMemory archives a pending_review memory.
// POST /api/v1/memories/:id/reject // POST /api/v1/memories/:id/reject
func (h *MemoryHandler) RejectMemory(c *gin.Context) { func (h *MemoryHandler) RejectMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("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 reject their own user-scope memories
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
if existing.Status != models.MemoryStatusPendingReview {
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be rejected"})
return
}
if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil { if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -156,15 +182,23 @@ func (h *MemoryHandler) MemoryCount(c *gin.Context) {
userID := c.GetString("user_id") userID := c.GetString("user_id")
ctx := c.Request.Context() ctx := c.Request.Context()
active, _ := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID) active, err := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Count pending by listing // Count pending by listing
pending, _ := h.stores.Memories.List(ctx, models.MemoryFilter{ pending, err := h.stores.Memories.List(ctx, models.MemoryFilter{
Scope: models.MemoryScopeUser, Scope: models.MemoryScopeUser,
OwnerID: userID, OwnerID: userID,
Status: models.MemoryStatusPendingReview, Status: models.MemoryStatusPendingReview,
Limit: 1000, Limit: 1000,
}) })
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"active": active, "active": active,
@@ -177,22 +211,18 @@ func (h *MemoryHandler) MemoryCount(c *gin.Context) {
// ListPendingReview returns all pending_review memories (admin only). // ListPendingReview returns all pending_review memories (admin only).
// GET /api/v1/admin/memories/pending // GET /api/v1/admin/memories/pending
func (h *MemoryHandler) ListPendingReview(c *gin.Context) { 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() ctx := c.Request.Context()
// Query pending memories directly via raw SQL for efficiency memories, err := h.stores.Memories.ListByStatus(ctx, models.MemoryStatusPendingReview, 200)
var memories []models.Memory if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
userMemories, _ := h.stores.Memories.List(ctx, models.MemoryFilter{ if memories == nil {
Scope: models.MemoryScopeUser, memories = []models.Memory{}
OwnerID: "%", // This won't work with List — we need a custom query }
Status: models.MemoryStatusPendingReview, c.JSON(http.StatusOK, gin.H{"data": memories})
Limit: 100,
})
memories = append(memories, userMemories...)
c.JSON(http.StatusOK, gin.H{"memories": memories})
} }
// BulkApprove approves multiple memories at once. // BulkApprove approves multiple memories at once.
@@ -213,6 +243,9 @@ func (h *MemoryHandler) BulkApprove(c *gin.Context) {
if err != nil { if err != nil {
continue continue
} }
if existing.Status != models.MemoryStatusPendingReview {
continue
}
existing.Status = models.MemoryStatusActive existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(ctx, existing); err == nil { if err := h.stores.Memories.Upsert(ctx, existing); err == nil {
approved++ approved++

View File

@@ -0,0 +1,435 @@
package handlers
import (
"net/http"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// seedMemory inserts a memory directly into the DB for testing.
func seedMemory(t *testing.T, ownerID, key, value, status string) string {
t.Helper()
id := seedID()
q := dialectSQL(`
INSERT INTO memories (id, scope, owner_id, key, value, confidence, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`)
_, err := database.TestDB.Exec(q, id, models.MemoryScopeUser, ownerID, key, value, 0.9, status)
if err != nil {
t.Fatalf("seedMemory: %v", err)
}
return id
}
// ═══════════════════════════════════════════════
// List
// ═══════════════════════════════════════════════
func TestMemory_ListEmpty(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("memuser1", "memuser1@test.com")
w := h.request("GET", "/api/v1/memories", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
// Must use "data" key (envelope convention)
data, ok := resp["data"].([]interface{})
if !ok {
t.Fatalf("response must have 'data' array key, got: %s", w.Body.String())
}
if len(data) != 0 {
t.Fatalf("expected 0 memories, got %d", len(data))
}
}
func TestMemory_ListWithData(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memuser2", "memuser2@test.com")
seedMemory(t, userID, "lang", "Go", models.MemoryStatusActive)
seedMemory(t, userID, "editor", "Vim", models.MemoryStatusActive)
seedMemory(t, userID, "pending-fact", "something", models.MemoryStatusPendingReview)
// Default status=active
w := h.request("GET", "/api/v1/memories", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 2 {
t.Fatalf("expected 2 active memories, got %d", len(data))
}
// Explicit status=pending_review
w = h.request("GET", "/api/v1/memories?status=pending_review", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list pending: want 200, got %d", w.Code)
}
decode(w, &resp)
data = resp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 pending memory, got %d", len(data))
}
}
func TestMemory_ListIsolation(t *testing.T) {
h := setupHarness(t)
userA, tokenA := h.createAdminUser("memA", "memA@test.com")
userB, _ := h.createAdminUser("memB", "memB@test.com")
seedMemory(t, userA, "a-fact", "user A", models.MemoryStatusActive)
seedMemory(t, userB, "b-fact", "user B", models.MemoryStatusActive)
w := h.request("GET", "/api/v1/memories", tokenA, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("user A should only see 1 memory, got %d", len(data))
}
mem := data[0].(map[string]interface{})
if mem["key"].(string) != "a-fact" {
t.Fatalf("wrong memory: got key %q", mem["key"])
}
}
// ═══════════════════════════════════════════════
// Update
// ═══════════════════════════════════════════════
func TestMemory_Update(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memupdater", "memupdater@test.com")
memID := seedMemory(t, userID, "old-key", "old-value", models.MemoryStatusActive)
w := h.request("PUT", "/api/v1/memories/"+memID, token, map[string]interface{}{
"key": "new-key",
"value": "new-value",
})
if w.Code != http.StatusOK {
t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
mem := resp["memory"].(map[string]interface{})
if mem["key"].(string) != "new-key" {
t.Fatalf("key not updated: got %q", mem["key"])
}
if mem["value"].(string) != "new-value" {
t.Fatalf("value not updated: got %q", mem["value"])
}
}
func TestMemory_UpdateOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("memowner", "memowner@test.com")
_, otherToken := h.createAdminUser("memother", "memother@test.com")
memID := seedMemory(t, ownerID, "secret", "mine", models.MemoryStatusActive)
w := h.request("PUT", "/api/v1/memories/"+memID, otherToken, map[string]interface{}{
"value": "hacked",
})
if w.Code != http.StatusForbidden {
t.Fatalf("update other's memory: want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestMemory_UpdateNotFound(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("memghost", "memghost@test.com")
w := h.request("PUT", "/api/v1/memories/"+seedID(), token, map[string]interface{}{
"value": "x",
})
if w.Code != http.StatusNotFound {
t.Fatalf("update missing: want 404, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Delete
// ═══════════════════════════════════════════════
func TestMemory_Delete(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memdeleter", "memdeleter@test.com")
memID := seedMemory(t, userID, "to-delete", "gone", models.MemoryStatusActive)
w := h.request("DELETE", "/api/v1/memories/"+memID, token, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify gone — list should be empty
w = h.request("GET", "/api/v1/memories", token, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 0 {
t.Fatalf("memory should be deleted, got %d", len(data))
}
}
func TestMemory_DeleteOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("delowner", "delowner@test.com")
_, otherToken := h.createAdminUser("delother", "delother@test.com")
memID := seedMemory(t, ownerID, "no-touch", "x", models.MemoryStatusActive)
w := h.request("DELETE", "/api/v1/memories/"+memID, otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("delete other's memory: want 403, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Approve
// ═══════════════════════════════════════════════
func TestMemory_Approve(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memapprover", "memapprover@test.com")
memID := seedMemory(t, userID, "pending", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("approve: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
mem := resp["memory"].(map[string]interface{})
if mem["status"].(string) != models.MemoryStatusActive {
t.Fatalf("status should be active, got %q", mem["status"])
}
}
func TestMemory_ApproveStatusGuard(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memguard1", "memguard1@test.com")
// Try to approve an already-active memory
memID := seedMemory(t, userID, "already-active", "val", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("approve active memory: want 409, got %d: %s", w.Code, w.Body.String())
}
// Try to approve an archived memory
archivedID := seedMemory(t, userID, "archived", "val", models.MemoryStatusArchived)
w = h.request("POST", "/api/v1/memories/"+archivedID+"/approve", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("approve archived memory: want 409, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Reject
// ═══════════════════════════════════════════════
func TestMemory_Reject(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memrejecter", "memrejecter@test.com")
memID := seedMemory(t, userID, "to-reject", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("reject: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
if resp["rejected"] != true {
t.Fatal("response should have rejected: true")
}
}
func TestMemory_RejectStatusGuard(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memguard2", "memguard2@test.com")
// Can't reject an already-active memory
memID := seedMemory(t, userID, "active-reject", "val", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("reject active memory: want 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestMemory_RejectOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("rejowner", "rejowner@test.com")
_, otherToken := h.createAdminUser("rejother", "rejother@test.com")
memID := seedMemory(t, ownerID, "not-yours", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("reject other's memory: want 403, got %d: %s", w.Code, w.Body.String())
}
}
// ═══════════════════════════════════════════════
// Count
// ═══════════════════════════════════════════════
func TestMemory_Count(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memcounter", "memcounter@test.com")
seedMemory(t, userID, "fact1", "a", models.MemoryStatusActive)
seedMemory(t, userID, "fact2", "b", models.MemoryStatusActive)
seedMemory(t, userID, "pend1", "c", models.MemoryStatusPendingReview)
w := h.request("GET", "/api/v1/memories/count", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("count: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
// Response shape: {"active": N, "pending": N}
active := int(resp["active"].(float64))
pending := int(resp["pending"].(float64))
if active != 2 {
t.Fatalf("expected 2 active, got %d", active)
}
if pending != 1 {
t.Fatalf("expected 1 pending, got %d", pending)
}
}
// ═══════════════════════════════════════════════
// Admin — List Pending
// ═══════════════════════════════════════════════
func TestMemory_AdminListPending(t *testing.T) {
h := setupHarness(t)
userA, _ := h.createAdminUser("adminmemA", "adminmemA@test.com")
userB, _ := h.createAdminUser("adminmemB", "adminmemB@test.com")
_, adminToken := h.createAdminUser("memadmin", "memadmin@test.com")
// Seed pending memories for both users
seedMemory(t, userA, "factA", "from A", models.MemoryStatusPendingReview)
seedMemory(t, userB, "factB", "from B", models.MemoryStatusPendingReview)
seedMemory(t, userA, "active-A", "active", models.MemoryStatusActive) // should NOT appear
w := h.request("GET", "/api/v1/admin/memories/pending", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin pending: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 2 {
t.Fatalf("expected 2 pending memories across users, got %d", len(data))
}
}
func TestMemory_AdminListPendingRequiresAdmin(t *testing.T) {
h := setupHarness(t)
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("normalmem", "normalmem@test.com", "password123")
w := h.request("GET", "/api/v1/admin/memories/pending", userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin should be forbidden: want 403, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Admin — Bulk Approve
// ═══════════════════════════════════════════════
func TestMemory_AdminBulkApprove(t *testing.T) {
h := setupHarness(t)
userID, _ := h.createAdminUser("bulkowner", "bulkowner@test.com")
_, adminToken := h.createAdminUser("bulkadmin", "bulkadmin@test.com")
id1 := seedMemory(t, userID, "bulk1", "a", models.MemoryStatusPendingReview)
id2 := seedMemory(t, userID, "bulk2", "b", models.MemoryStatusPendingReview)
id3 := seedMemory(t, userID, "already-active", "c", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, map[string]interface{}{
"ids": []string{id1, id2, id3},
})
if w.Code != http.StatusOK {
t.Fatalf("bulk approve: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
approved := int(resp["approved"].(float64))
if approved != 2 {
t.Fatalf("expected 2 approved (skipping already-active), got %d", approved)
}
}
func TestMemory_AdminBulkApproveInvalidBody(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("bulkbad", "bulkbad@test.com")
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, "not-json")
if w.Code != http.StatusBadRequest {
t.Fatalf("bad body: want 400, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Field Shape Verification
// ═══════════════════════════════════════════════
func TestMemory_FieldShape(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memshape", "memshape@test.com")
seedMemory(t, userID, "test-key", "test-value", models.MemoryStatusActive)
w := h.request("GET", "/api/v1/memories", token, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
mem := data[0].(map[string]interface{})
// Verify all expected fields exist
requiredFields := []string{"id", "scope", "owner_id", "key", "value", "confidence", "status", "created_at", "updated_at"}
for _, f := range requiredFields {
if _, ok := mem[f]; !ok {
t.Errorf("missing field %q in memory object", f)
}
}
// Verify no legacy "content" field
if _, ok := mem["content"]; ok {
t.Error("memory should not have 'content' field (legacy ICD)")
}
// Verify no legacy "persona_id" field
if _, ok := mem["persona_id"]; ok {
t.Error("memory should not have 'persona_id' field (legacy ICD)")
}
// Verify scope is correct
if mem["scope"].(string) != models.MemoryScopeUser {
t.Errorf("scope should be %q, got %q", models.MemoryScopeUser, mem["scope"])
}
// Confidence should be a number
confidence, ok := mem["confidence"].(float64)
if !ok || confidence < 0 || confidence > 1 {
t.Errorf("confidence should be 0-1 float, got %v", mem["confidence"])
}
}

View File

@@ -117,8 +117,14 @@ func (e *Extractor) Extract(ctx context.Context, channelID, userID, teamID, pers
prompt := defaultExtractionPrompt prompt := defaultExtractionPrompt
if personaID != "" && e.stores.Personas != nil { if personaID != "" && e.stores.Personas != nil {
persona, err := e.stores.Personas.GetByID(ctx, personaID) persona, err := e.stores.Personas.GetByID(ctx, personaID)
if err == nil && persona.MemoryExtractionPrompt != nil && *persona.MemoryExtractionPrompt != "" { if err == nil {
prompt = *persona.MemoryExtractionPrompt // Respect persona memory_enabled flag (H6 — audit v0.28.0)
if !persona.MemoryEnabled {
return nil
}
if persona.MemoryExtractionPrompt != nil && *persona.MemoryExtractionPrompt != "" {
prompt = *persona.MemoryExtractionPrompt
}
} }
} }

View File

@@ -76,21 +76,23 @@ type candidate struct {
ChannelID string ChannelID string
UserID string UserID string
TeamID string TeamID string
PersonaID string // non-empty if a persona is active on the channel
} }
func (s *Scanner) scan() { func (s *Scanner) scan() {
ctx := context.Background() ctx := context.Background()
// Check global kill switch // Check global kill switch.
// Default: ENABLED. Only disable if the key exists and is explicitly false.
// This inverts the previous behavior where a missing key blocked all extraction.
if s.stores.GlobalConfig != nil { if s.stores.GlobalConfig != nil {
val, err := s.stores.GlobalConfig.Get(ctx, "memory_extraction_enabled") val, err := s.stores.GlobalConfig.Get(ctx, "memory_extraction_enabled")
if err != nil || val == nil { if err == nil && val != nil {
return // disabled or not configured if enabled, ok := val["value"].(bool); ok && !enabled {
} return // explicitly disabled
// val is a map; check the "value" key }
if enabled, ok := val["value"].(bool); !ok || !enabled {
return
} }
// If key is missing (err != nil or val == nil), extraction is enabled.
} }
candidates, err := s.findCandidates(ctx) candidates, err := s.findCandidates(ctx)
@@ -124,7 +126,7 @@ func (s *Scanner) scan() {
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel() defer cancel()
if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, ""); err != nil { if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, cand.PersonaID); err != nil {
log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v", log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v",
cand.ChannelID, cand.UserID, err) cand.ChannelID, cand.UserID, err)
} }
@@ -135,6 +137,12 @@ func (s *Scanner) scan() {
} }
// findCandidates queries for channels with enough new activity since last extraction. // findCandidates queries for channels with enough new activity since last extraction.
//
// Fixes from v0.28.0 audit:
// - Uses channels.user_id (was: non-existent created_by)
// - Uses channels.team_id directly (was: JOIN to non-existent channel_users table)
// - JOINs channel_participants + personas to check memory_enabled (H6)
// - Filters out channels whose active persona has memory_enabled=false
func (s *Scanner) findCandidates(ctx context.Context) ([]candidate, error) { func (s *Scanner) findCandidates(ctx context.Context) ([]candidate, error) {
db := database.DB db := database.DB
if db == nil { if db == nil {
@@ -144,11 +152,17 @@ func (s *Scanner) findCandidates(ctx context.Context) ([]candidate, error) {
var query string var query string
if database.IsSQLite() { if database.IsSQLite() {
query = ` query = `
SELECT c.id, c.created_by, COALESCE(cu.team_id, '') SELECT c.id, c.user_id, COALESCE(c.team_id, ''),
COALESCE(cp.participant_id, '')
FROM channels c FROM channels c
LEFT JOIN channel_users cu ON cu.channel_id = c.id AND cu.user_id = c.created_by LEFT JOIN channel_participants cp
LEFT JOIN memory_extraction_log mel ON mel.channel_id = c.id AND mel.user_id = c.created_by ON cp.channel_id = c.id AND cp.participant_type = 'persona'
WHERE c.created_by IS NOT NULL LEFT JOIN personas p
ON p.id = cp.participant_id
LEFT JOIN memory_extraction_log mel
ON mel.channel_id = c.id AND mel.user_id = c.user_id
WHERE c.user_id IS NOT NULL
AND (cp.id IS NULL OR p.memory_enabled = 1)
AND ( AND (
SELECT COUNT(*) FROM messages m SELECT COUNT(*) FROM messages m
WHERE m.channel_id = c.id WHERE m.channel_id = c.id
@@ -161,11 +175,17 @@ func (s *Scanner) findCandidates(ctx context.Context) ([]candidate, error) {
` `
} else { } else {
query = ` query = `
SELECT c.id, c.created_by, COALESCE(cu.team_id, '') SELECT c.id, c.user_id, COALESCE(c.team_id::text, ''),
COALESCE(cp.participant_id::text, '')
FROM channels c FROM channels c
LEFT JOIN channel_users cu ON cu.channel_id = c.id AND cu.user_id = c.created_by LEFT JOIN channel_participants cp
LEFT JOIN memory_extraction_log mel ON mel.channel_id = c.id AND mel.user_id = c.created_by ON cp.channel_id = c.id AND cp.participant_type = 'persona'
WHERE c.created_by IS NOT NULL LEFT JOIN personas p
ON p.id = cp.participant_id
LEFT JOIN memory_extraction_log mel
ON mel.channel_id = c.id AND mel.user_id = c.user_id
WHERE c.user_id IS NOT NULL
AND (cp.id IS NULL OR p.memory_enabled = true)
AND ( AND (
SELECT COUNT(*) FROM messages m SELECT COUNT(*) FROM messages m
WHERE m.channel_id = c.id WHERE m.channel_id = c.id
@@ -187,7 +207,7 @@ func (s *Scanner) findCandidates(ctx context.Context) ([]candidate, error) {
var candidates []candidate var candidates []candidate
for rows.Next() { for rows.Next() {
var c candidate var c candidate
if err := rows.Scan(&c.ChannelID, &c.UserID, &c.TeamID); err != nil { if err := rows.Scan(&c.ChannelID, &c.UserID, &c.TeamID, &c.PersonaID); err != nil {
continue continue
} }
candidates = append(candidates, c) candidates = append(candidates, c)

View File

@@ -1,18 +0,0 @@
// ── models_memory_persona.go ────────────────
// Phase 2 additions to the Persona struct.
//
// Add these fields to the Persona struct in models/models.go
// (after the KBIDs field):
//
// MemoryEnabled bool `json:"memory_enabled" db:"memory_enabled"`
// MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty" db:"memory_extraction_prompt"`
//
// Add to PersonaPatch:
//
// MemoryEnabled *bool `json:"memory_enabled,omitempty"`
// MemoryExtractionPrompt *string `json:"memory_extraction_prompt,omitempty"`
//
// This file exists purely as documentation — the actual fields
// must be added to the existing structs in models.go.
package models

View File

@@ -5,7 +5,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings" "strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
@@ -42,6 +41,16 @@ func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error {
return err return err
} }
func (s *MemoryStore) Update(ctx context.Context, m *models.Memory) error {
_, err := DB.ExecContext(ctx, `
UPDATE memories
SET key = $1, value = $2, confidence = $3, status = $4,
source_channel_id = $5, updated_at = now()
WHERE id = $6
`, m.Key, m.Value, m.Confidence, m.Status, m.SourceChannelID, m.ID)
return err
}
func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) { func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) {
m := &models.Memory{} m := &models.Memory{}
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
@@ -220,6 +229,25 @@ func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (
return count, err return count, err
} }
func (s *MemoryStore) ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := DB.QueryContext(ctx, `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2
`, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
// ── Helpers ───────────────────────────────── // ── Helpers ─────────────────────────────────
func scanMemories(rows *sql.Rows) ([]models.Memory, error) { func scanMemories(rows *sql.Rows) ([]models.Memory, error) {
@@ -240,30 +268,5 @@ func scanMemories(rows *sql.Rows) ([]models.Memory, error) {
return memories, rows.Err() 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 // ensure compile-time interface satisfaction
var _ interface { var _ store.MemoryStore = (*MemoryStore)(nil)
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

@@ -2,7 +2,6 @@ package postgres
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"strings" "strings"
@@ -130,6 +129,3 @@ func mergeResults(semantic, keyword []models.Memory, limit int) []models.Memory
return result return result
} }
// ensure unused
var _ = sql.ErrNoRows

View File

@@ -41,6 +41,16 @@ func (s *MemoryStore) Upsert(ctx context.Context, m *models.Memory) error {
return err return err
} }
func (s *MemoryStore) Update(ctx context.Context, m *models.Memory) error {
_, err := DB.ExecContext(ctx, `
UPDATE memories
SET key = ?, value = ?, confidence = ?, status = ?,
source_channel_id = ?, updated_at = datetime('now')
WHERE id = ?
`, m.Key, m.Value, m.Confidence, m.Status, m.SourceChannelID, m.ID)
return err
}
func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) { func (s *MemoryStore) GetByID(ctx context.Context, id string) (*models.Memory, error) {
m := &models.Memory{} m := &models.Memory{}
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
@@ -202,6 +212,25 @@ func (s *MemoryStore) CountByOwner(ctx context.Context, scope, ownerID string) (
return count, err return count, err
} }
func (s *MemoryStore) ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := DB.QueryContext(ctx, `
SELECT id, scope, owner_id, user_id, key, value,
source_channel_id, confidence, status, created_at, updated_at
FROM memories
WHERE status = ?
ORDER BY created_at DESC
LIMIT ?
`, status, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return s.scanMemories(rows)
}
// ── Helpers ───────────────────────────────── // ── Helpers ─────────────────────────────────
func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) { func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) {
@@ -222,3 +251,6 @@ func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) {
} }
return memories, rows.Err() return memories, rows.Err()
} }
// ensure compile-time interface satisfaction
var _ store.MemoryStore = (*MemoryStore)(nil)

View File

@@ -21,6 +21,10 @@ type MemoryStore interface {
// If a memory with the same key exists, value/confidence/status are updated. // If a memory with the same key exists, value/confidence/status are updated.
Upsert(ctx context.Context, m *models.Memory) error Upsert(ctx context.Context, m *models.Memory) error
// Update modifies an existing memory by ID. Use for handler edits where
// the key may change (Upsert conflicts on composite key, not on ID).
Update(ctx context.Context, m *models.Memory) error
// GetByID returns a single memory by ID. // GetByID returns a single memory by ID.
GetByID(ctx context.Context, id string) (*models.Memory, error) GetByID(ctx context.Context, id string) (*models.Memory, error)
@@ -41,6 +45,10 @@ type MemoryStore interface {
// CountByOwner returns the number of active memories for a scope+owner. // CountByOwner returns the number of active memories for a scope+owner.
CountByOwner(ctx context.Context, scope, ownerID string) (int, error) CountByOwner(ctx context.Context, scope, ownerID string) (int, error)
// ListByStatus returns all memories with the given status across all owners.
// Used by admin review endpoints. Results ordered by created_at DESC.
ListByStatus(ctx context.Context, status string, limit int) ([]models.Memory, error)
// RecallHybrid returns active memories using vector similarity + keyword search. // RecallHybrid returns active memories using vector similarity + keyword search.
// If queryVec is nil/empty, falls back to keyword-only (same as Recall). // 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) RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error)

View File

@@ -1,15 +0,0 @@
// ── 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

View File

@@ -84,7 +84,7 @@ const MemoryUI = {
try { try {
const data = await API.listMemories(this._filter.status, this._filter.query); const data = await API.listMemories(this._filter.status, this._filter.query);
this._memories = data.memories || []; this._memories = data.data || [];
if (!this._memories.length) { if (!this._memories.length) {
el.innerHTML = `<div class="empty-hint">No ${this._filter.status.replace('_', ' ')} memories${this._filter.query ? ' matching "' + esc(this._filter.query) + '"' : ''}</div>`; el.innerHTML = `<div class="empty-hint">No ${this._filter.status.replace('_', ' ')} memories${this._filter.query ? ' matching "' + esc(this._filter.query) + '"' : ''}</div>`;
@@ -211,7 +211,7 @@ const MemoryUI = {
async approveAllPending() { async approveAllPending() {
try { try {
const data = await API.listMemories('pending_review', ''); const data = await API.listMemories('pending_review', '');
const ids = (data.memories || []).map(m => m.id); const ids = (data.data || []).map(m => m.id);
if (!ids.length) return UI.toast('No pending memories', 'info'); if (!ids.length) return UI.toast('No pending memories', 'info');
if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return; if (!await showConfirm(`Approve all ${ids.length} pending memories?`)) return;
await API.bulkApproveMemories(ids); await API.bulkApproveMemories(ids);
@@ -231,7 +231,7 @@ const MemoryUI = {
try { try {
const data = await API.adminListPendingMemories(); const data = await API.adminListPendingMemories();
const pending = data.memories || []; const pending = data.data || [];
el.innerHTML = ` el.innerHTML = `
<div class="admin-toolbar"> <div class="admin-toolbar">