Files
synapbus/internal/api/memory_core.go
T
Algis DumbrisandClaude Opus 4.7 a52d68ed88 feat(020): US2 — per-agent core memory blob
Letta-style identity blob, one per (owner, agent), always included in
session-start (my_status) responses. Replaces wholesale on rewrite; size
capped at SYNAPBUS_CORE_MEMORY_MAX_BYTES (default 2048); owner-scoped.

Components:
- internal/messaging/memory_core.go (+ test): CoreMemoryStore with
  Get/Set/Delete/List, ErrCoreMemoryTooLarge, NewCoreProvider adapter
  for search.CoreMemoryProvider.
- internal/mcp/server.go SetInjection: wires the core provider into
  the my_status handler wrap.
- internal/mcp/injection_core_test.go: seed → wrapped my_status →
  relevant_context.core_memory matches; missing row → no field.
- internal/api/memory_core.go + router: GET/PUT/DELETE
  /api/owner/{ownerID}/agents/{agentName}/core-memory, session-auth,
  413 on oversize.
- internal/admin/socket.go: memory.core.{get,set,delete} dispatch
  handlers with username→user.id resolution.
- cmd/synapbus/admin.go: `synapbus memory core {get,set,delete}` cobra
  subtree.
- cmd/synapbus/main.go: wires ParseMemoryConfig, CoreMemoryStore,
  MemoryInjections; calls mcpSrv.SetInjection on startup.

Pin overlay still TODO (US3-T029).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:18:06 +03:00

176 lines
6.2 KiB
Go

// REST endpoints for per-(owner, agent) core memory (feature 020 — US2).
// Surfaces the underlying messaging.CoreMemoryStore to the Web UI under
// `/api/owner/{ownerID}/agents/{agentName}/core-memory`.
//
// Auth: every handler enforces that the session-bound owner matches the
// path's `ownerID`. Cross-owner access yields 403 to avoid leaking the
// existence of another owner's resources.
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/synapbus/synapbus/internal/messaging"
)
// MemoryCoreHandler exposes GET/PUT/DELETE for the `memory_core` table.
type MemoryCoreHandler struct {
store *messaging.CoreMemoryStore
logger *slog.Logger
}
// NewMemoryCoreHandler wires the handler. `store` must be non-nil.
func NewMemoryCoreHandler(store *messaging.CoreMemoryStore) *MemoryCoreHandler {
return &MemoryCoreHandler{
store: store,
logger: slog.Default().With("component", "api.memory-core"),
}
}
// authorize resolves the URL `ownerID` and confirms it matches the
// session-bound owner. Returns the resolved owner string (matching the
// memory_core.owner_id TEXT format) plus the agent name on success.
func (h *MemoryCoreHandler) authorize(w http.ResponseWriter, r *http.Request) (ownerStr string, agentName string, ok bool) {
sessionOwnerID, found := OwnerIDFromContext(r.Context())
if !found {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return "", "", false
}
pathOwner := chi.URLParam(r, "ownerID")
if pathOwner == "" {
writeJSON(w, http.StatusBadRequest, errorBody("bad_request", "ownerID is required"))
return "", "", false
}
pathOwnerID, err := strconv.ParseInt(pathOwner, 10, 64)
if err != nil || pathOwnerID <= 0 {
writeJSON(w, http.StatusBadRequest, errorBody("bad_request", "ownerID must be a positive integer"))
return "", "", false
}
if pathOwnerID != sessionOwnerID {
// Use 403, not 404, so the response shape matches other owner-scoped
// handlers in this package (see agents_handler.GetAgent).
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this owner"))
return "", "", false
}
agentName = chi.URLParam(r, "agentName")
if agentName == "" {
writeJSON(w, http.StatusBadRequest, errorBody("bad_request", "agentName is required"))
return "", "", false
}
return strconv.FormatInt(pathOwnerID, 10), agentName, true
}
// Get handles GET /api/owner/{ownerID}/agents/{agentName}/core-memory.
func (h *MemoryCoreHandler) Get(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeJSON(w, http.StatusServiceUnavailable, errorBody("unavailable", "core memory store not configured"))
return
}
ownerStr, agentName, ok := h.authorize(w, r)
if !ok {
return
}
blob, updatedAt, exists, err := h.store.Get(r.Context(), ownerStr, agentName)
if err != nil {
h.logger.Error("memory_core get failed", "error", err, "owner", ownerStr, "agent", agentName)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to read core memory"))
return
}
if !exists {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "core memory not set for this agent"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"owner_id": ownerStr,
"agent_name": agentName,
"blob": blob,
"updated_at": updatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
// Put handles PUT /api/owner/{ownerID}/agents/{agentName}/core-memory.
// Body: {"blob": "..."}. Returns 200 on success, 413 on
// core_memory_too_large, 400 on malformed body.
func (h *MemoryCoreHandler) Put(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeJSON(w, http.StatusServiceUnavailable, errorBody("unavailable", "core memory store not configured"))
return
}
ownerStr, agentName, ok := h.authorize(w, r)
if !ok {
return
}
var body struct {
Blob string `json:"blob"`
UpdatedBy string `json:"updated_by,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("bad_request", "Invalid JSON body"))
return
}
updatedBy := body.UpdatedBy
if updatedBy == "" {
updatedBy = "human"
}
if err := h.store.Set(r.Context(), ownerStr, agentName, body.Blob, updatedBy); err != nil {
if errors.Is(err, messaging.ErrCoreMemoryTooLarge) {
writeJSON(w, http.StatusRequestEntityTooLarge, errorBody(
"core_memory_too_large",
fmt.Sprintf("Blob exceeds %d bytes", h.store.MaxBytes()),
))
return
}
h.logger.Error("memory_core set failed", "error", err, "owner", ownerStr, "agent", agentName)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to write core memory"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"owner_id": ownerStr,
"agent_name": agentName,
"blob_chars": len(body.Blob),
"updated_by": updatedBy,
})
}
// Delete handles DELETE /api/owner/{ownerID}/agents/{agentName}/core-memory.
// Returns 204 when a row existed and was removed, 404 when no row was
// present at the start of the call.
func (h *MemoryCoreHandler) Delete(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeJSON(w, http.StatusServiceUnavailable, errorBody("unavailable", "core memory store not configured"))
return
}
ownerStr, agentName, ok := h.authorize(w, r)
if !ok {
return
}
// Check existence so we can return the canonical 204 vs 404. The
// store's Delete is idempotent — it never errors on missing rows.
_, _, exists, err := h.store.Get(r.Context(), ownerStr, agentName)
if err != nil {
h.logger.Error("memory_core get-before-delete failed", "error", err, "owner", ownerStr, "agent", agentName)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to read core memory"))
return
}
if !exists {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "core memory not set for this agent"))
return
}
if err := h.store.Delete(r.Context(), ownerStr, agentName); err != nil {
h.logger.Error("memory_core delete failed", "error", err, "owner", ownerStr, "agent", agentName)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to delete core memory"))
return
}
w.WriteHeader(http.StatusNoContent)
}