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>
176 lines
6.2 KiB
Go
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)
|
|
}
|