Skip to content

Commit 8260f4f

Browse files
feat(memory): add expiry, supersession, and TTL support (#84)
- Add Expire() and Supersede() methods to the memory Store interface - Expired entries excluded from Recall by default (IncludeExpired flag to override) - Superseded entries store a forward pointer to the replacement - StoreEntry accepts ExpiresAt for TTL-based auto-expiry at query time - Dedup skips expired entries so replacements are not silently merged - Stats now reports ExpiredCount and ActiveCount - EventExpired lifecycle event for cache boundary managers - REST endpoints: POST /v1/memory/expire, POST /v1/memory/supersede - MCP tools: memory_expire, memory_supersede - 12 new tests covering all expiry and supersession paths Closes #79 Co-authored-by: Ona <no-reply@ona.com>
1 parent 75e26df commit 8260f4f

8 files changed

Lines changed: 632 additions & 18 deletions

File tree

cmd/api_memory.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ func (m *MemoryAPI) RegisterMemoryRoutes(mux *http.ServeMux, mw func(string, htt
2222
mux.HandleFunc("/v1/memory/store", mw("/v1/memory/store", m.handleStore))
2323
mux.HandleFunc("/v1/memory/recall", mw("/v1/memory/recall", m.handleRecall))
2424
mux.HandleFunc("/v1/memory/forget", mw("/v1/memory/forget", m.handleForget))
25+
mux.HandleFunc("/v1/memory/expire", mw("/v1/memory/expire", m.handleExpire))
26+
mux.HandleFunc("/v1/memory/supersede", mw("/v1/memory/supersede", m.handleSupersede))
2527
mux.HandleFunc("/v1/memory/stats", mw("/v1/memory/stats", m.handleStats))
2628
}
2729

@@ -110,6 +112,66 @@ func (m *MemoryAPI) handleRecall(w http.ResponseWriter, r *http.Request) {
110112
_ = json.NewEncoder(w).Encode(result)
111113
}
112114

115+
func (m *MemoryAPI) handleExpire(w http.ResponseWriter, r *http.Request) {
116+
if r.Method != http.MethodPost {
117+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
118+
return
119+
}
120+
121+
var req memory.ExpireRequest
122+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
123+
writeJSONError(w, "invalid request body", http.StatusBadRequest)
124+
return
125+
}
126+
127+
if len(req.IDs) == 0 {
128+
writeJSONError(w, "ids is required", http.StatusBadRequest)
129+
return
130+
}
131+
132+
result, err := m.store.Expire(r.Context(), req)
133+
if err != nil {
134+
writeJSONError(w, err.Error(), http.StatusInternalServerError)
135+
return
136+
}
137+
138+
w.Header().Set("Content-Type", "application/json")
139+
_ = json.NewEncoder(w).Encode(result)
140+
}
141+
142+
func (m *MemoryAPI) handleSupersede(w http.ResponseWriter, r *http.Request) {
143+
if r.Method != http.MethodPost {
144+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
145+
return
146+
}
147+
148+
var req memory.SupersedeRequest
149+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
150+
writeJSONError(w, "invalid request body", http.StatusBadRequest)
151+
return
152+
}
153+
154+
if req.OldID == "" {
155+
writeJSONError(w, "old_id is required", http.StatusBadRequest)
156+
return
157+
}
158+
159+
result, err := m.store.Supersede(r.Context(), req)
160+
if err != nil {
161+
code := http.StatusInternalServerError
162+
if err == memory.ErrNotFound {
163+
code = http.StatusNotFound
164+
} else if err == memory.ErrAlreadyExpired {
165+
code = http.StatusConflict
166+
}
167+
writeJSONError(w, err.Error(), code)
168+
return
169+
}
170+
171+
w.Header().Set("Content-Type", "application/json")
172+
_ = json.NewEncoder(w).Encode(result)
173+
}
174+
113175
func (m *MemoryAPI) handleForget(w http.ResponseWriter, r *http.Request) {
114176
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
115177
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)

cmd/mcp.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,27 @@ Use this to clean up outdated or incorrect memories.`),
443443
)
444444
s.AddTool(forgetMemoryTool, m.handleForgetMemory)
445445

446+
expireMemoryTool := mcp.NewTool("memory_expire",
447+
mcp.WithDescription("Mark memory entries as expired. Expired entries are excluded from recall by default but remain in the store."),
448+
mcp.WithArray("ids",
449+
mcp.Description("Memory entry IDs to expire"),
450+
mcp.Required(),
451+
),
452+
)
453+
s.AddTool(expireMemoryTool, m.handleExpireMemory)
454+
455+
supersedeMemoryTool := mcp.NewTool("memory_supersede",
456+
mcp.WithDescription("Mark a memory as superseded by a newer entry. The old entry is expired and a forward pointer to the replacement is stored."),
457+
mcp.WithString("old_id",
458+
mcp.Description("ID of the memory being superseded"),
459+
mcp.Required(),
460+
),
461+
mcp.WithString("new_id",
462+
mcp.Description("ID of the replacement memory"),
463+
),
464+
)
465+
s.AddTool(supersedeMemoryTool, m.handleSupersedeMemory)
466+
446467
memoryStatsTool := mcp.NewTool("memory_stats",
447468
mcp.WithDescription("Get statistics about the persistent memory store."),
448469
)

cmd/mcp_memory.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,53 @@ func (m *MCPServer) handleForgetMemory(ctx context.Context, request mcp.CallTool
144144
return mcp.NewToolResultText(string(out)), nil
145145
}
146146

147+
func (m *MCPServer) handleExpireMemory(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
148+
args := request.GetArguments()
149+
150+
var ids []string
151+
if idsRaw, ok := args["ids"].([]interface{}); ok {
152+
for _, id := range idsRaw {
153+
if s, ok := id.(string); ok {
154+
ids = append(ids, s)
155+
}
156+
}
157+
}
158+
159+
if len(ids) == 0 {
160+
return mcp.NewToolResultError("ids is required"), nil
161+
}
162+
163+
result, err := m.memStore.Expire(ctx, memory.ExpireRequest{IDs: ids})
164+
if err != nil {
165+
return mcp.NewToolResultError(fmt.Sprintf("expire error: %v", err)), nil
166+
}
167+
168+
out, _ := json.MarshalIndent(result, "", " ")
169+
return mcp.NewToolResultText(string(out)), nil
170+
}
171+
172+
func (m *MCPServer) handleSupersedeMemory(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
173+
args := request.GetArguments()
174+
175+
oldID, _ := args["old_id"].(string)
176+
newID, _ := args["new_id"].(string)
177+
178+
if oldID == "" {
179+
return mcp.NewToolResultError("old_id is required"), nil
180+
}
181+
182+
result, err := m.memStore.Supersede(ctx, memory.SupersedeRequest{
183+
OldID: oldID,
184+
NewID: newID,
185+
})
186+
if err != nil {
187+
return mcp.NewToolResultError(fmt.Sprintf("supersede error: %v", err)), nil
188+
}
189+
190+
out, _ := json.MarshalIndent(result, "", " ")
191+
return mcp.NewToolResultText(string(out)), nil
192+
}
193+
147194
func (m *MCPServer) handleMemoryStats(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
148195
stats, err := m.memStore.Stats(ctx)
149196
if err != nil {

pkg/memory/cache_events.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const (
2020
// EventEvicted fires when an entry is removed from the store entirely.
2121
// Any cached prefix that included this entry must retreat.
2222
EventEvicted MemoryEventType = "evicted"
23+
24+
// EventExpired fires when an entry is marked as expired or superseded.
25+
// The entry remains in the store but is excluded from recall by default.
26+
EventExpired MemoryEventType = "expired"
2327
)
2428

2529
// MemoryEvent describes a single lifecycle transition for a memory entry.

0 commit comments

Comments
 (0)