Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/mnemonic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,7 @@ func serveCommand(configPath string) {
ServiceRestarter: daemon.NewServiceManager(),
PIDRestart: daemon.PIDRestart,
MCPToolCount: mcp.ToolCount(),
StartTime: time.Now(),
Log: log,
}
// Only set Consolidator if it's non-nil (avoids Go nil-interface trap)
Expand Down
8 changes: 8 additions & 0 deletions internal/agent/consolidation/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ type CycleReport struct {
PatternsDecayed int
PatternsDeduplicated int
NeverRecalledArchived int
FeedbackPruned int
}

// runCycle executes the full consolidation pipeline.
Expand Down Expand Up @@ -366,6 +367,13 @@ func (ca *ConsolidationAgent) runCycle(ctx context.Context) (*CycleReport, error
}
report.PatternsDeduplicated = patternsDeduped

// Step 10: Prune old retrieval feedback records (30-day TTL)
feedbackPruned, err := ca.store.PruneOldFeedback(ctx, 30*24*time.Hour)
if err != nil {
ca.log.Warn("feedback pruning failed", "error", err)
}
report.FeedbackPruned = feedbackPruned

// Record the cycle
report.Duration = time.Since(startTime)
if err := ca.recordCycle(ctx, report); err != nil {
Expand Down
10 changes: 10 additions & 0 deletions internal/agent/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"sync"
"time"

Expand Down Expand Up @@ -39,6 +40,9 @@ type HealthReport struct {
LastConsolidation string `json:"last_consolidation"`
LastDreamCycle string `json:"last_dream_cycle"`
AutonomousActions int `json:"autonomous_actions_total"`
HeapAllocMB float64 `json:"heap_alloc_mb"`
Goroutines int `json:"goroutines"`
DBSizeMB float64 `json:"db_size_mb"`
Warnings []string `json:"warnings,omitempty"`
}

Expand Down Expand Up @@ -374,6 +378,9 @@ func (o *Orchestrator) writeHealthReport() {
}

o.mu.Lock()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)

report := HealthReport{
Timestamp: time.Now(),
Uptime: time.Since(o.startTime).Round(time.Second).String(),
Expand All @@ -384,6 +391,9 @@ func (o *Orchestrator) writeHealthReport() {
AbstractionCount: len(level2) + len(level3),
LastConsolidation: lastConsolidation,
AutonomousActions: o.autonomousCount,
HeapAllocMB: float64(memStats.HeapAlloc) / (1024 * 1024),
Goroutines: runtime.NumGoroutine(),
DBSizeMB: float64(stats.StorageSizeBytes) / (1024 * 1024),
Warnings: append([]string{}, o.warnings...),
AgentStatus: map[string]string{
"orchestrator": "running",
Expand Down
5 changes: 5 additions & 0 deletions internal/api/routes/feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ func HandleFeedback(s store.Store, log *slog.Logger) http.HandlerFunc {
}
}

// Prune bulky traversal data now that feedback has been applied
fb.TraversedAssocs = nil
fb.AccessSnapshot = nil
_ = s.WriteRetrievalFeedback(ctx, fb)

log.Info("feedback recorded",
"query_id", req.QueryID,
"quality", req.Quality,
Expand Down
6 changes: 3 additions & 3 deletions internal/api/routes/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ func TestHandleHealthCheck(t *testing.T) {
},
}
llmProv := &mockLLMProvider{}
handler := HandleHealth(ms, llmProv, "test", 23, testLogger())
handler := HandleHealth(ms, llmProv, "test", 23, time.Now(), testLogger())

req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
Expand Down Expand Up @@ -629,7 +629,7 @@ func TestHandleHealthCheck(t *testing.T) {
},
}
llmProv := &failingLLMProvider{}
handler := HandleHealth(ms, llmProv, "test", 23, testLogger())
handler := HandleHealth(ms, llmProv, "test", 23, time.Now(), testLogger())

req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
Expand Down Expand Up @@ -662,7 +662,7 @@ func TestHandleHealthCheck(t *testing.T) {
},
}
llmProv := &mockLLMProvider{}
handler := HandleHealth(ms, llmProv, "test", 23, testLogger())
handler := HandleHealth(ms, llmProv, "test", 23, time.Now(), testLogger())

req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
Expand Down
56 changes: 39 additions & 17 deletions internal/api/routes/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"net/http"
"runtime"
"time"

"github.com/appsprout-dev/mnemonic/internal/llm"
Expand All @@ -12,20 +13,26 @@ import (

// HealthResponse is the JSON response for the health check endpoint.
type HealthResponse struct {
Status string `json:"status"`
Version string `json:"version,omitempty"`
LLMAvailable bool `json:"llm_available"`
LLMModel string `json:"llm_model,omitempty"`
StoreHealthy bool `json:"store_healthy"`
MemoryCount int `json:"memory_count"`
ToolCount int `json:"tool_count"`
Timestamp string `json:"timestamp"`
Status string `json:"status"`
Version string `json:"version,omitempty"`
LLMAvailable bool `json:"llm_available"`
LLMModel string `json:"llm_model,omitempty"`
StoreHealthy bool `json:"store_healthy"`
MemoryCount int `json:"memory_count"`
ToolCount int `json:"tool_count"`
HeapAllocMB float64 `json:"heap_alloc_mb"`
HeapSysMB float64 `json:"heap_sys_mb"`
Goroutines int `json:"goroutines"`
GCPauseTotalMs float64 `json:"gc_pause_total_ms"`
UptimeSeconds int64 `json:"uptime_seconds"`
DBSizeMB float64 `json:"db_size_mb"`
Timestamp string `json:"timestamp"`
}

// HandleHealth returns an HTTP handler that performs a health check.
// Checks LLM availability with 2s timeout and store health.
// Returns 200 with health status JSON.
func HandleHealth(s store.Store, llmProv llm.Provider, version string, toolCount int, log *slog.Logger) http.HandlerFunc {
func HandleHealth(s store.Store, llmProv llm.Provider, version string, toolCount int, startTime time.Time, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Debug("health check requested")

Expand Down Expand Up @@ -64,15 +71,30 @@ func HandleHealth(s store.Store, llmProv llm.Provider, version string, toolCount
status = "degraded"
}

// Runtime metrics
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)

var dbSizeMB float64
if stats, err := s.GetStatistics(storeCtx); err == nil {
dbSizeMB = float64(stats.StorageSizeBytes) / (1024 * 1024)
}

resp := HealthResponse{
Status: status,
Version: version,
LLMAvailable: llmAvailable,
LLMModel: llmModel,
StoreHealthy: storeHealthy,
MemoryCount: memoryCount,
ToolCount: toolCount,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Status: status,
Version: version,
LLMAvailable: llmAvailable,
LLMModel: llmModel,
StoreHealthy: storeHealthy,
MemoryCount: memoryCount,
ToolCount: toolCount,
HeapAllocMB: float64(memStats.HeapAlloc) / (1024 * 1024),
HeapSysMB: float64(memStats.HeapSys) / (1024 * 1024),
Goroutines: runtime.NumGoroutine(),
GCPauseTotalMs: float64(memStats.PauseTotalNs) / 1e6,
UptimeSeconds: int64(time.Since(startTime).Seconds()),
DBSizeMB: dbSizeMB,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}

log.Info("health check completed", "status", status, "llm_available", llmAvailable, "store_healthy", storeHealthy, "memory_count", memoryCount)
Expand Down
3 changes: 2 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type ServerDeps struct {
ServiceRestarter routes.ServiceRestarter // can be nil if not installed as service
PIDRestart routes.PIDRestartFunc // fallback restart when service manager unavailable
MCPToolCount int // number of registered MCP tools
StartTime time.Time // daemon start time for uptime calculation
Log *slog.Logger
}

Expand Down Expand Up @@ -79,7 +80,7 @@ func NewServer(cfg ServerConfig, deps ServerDeps) *Server {
// registerRoutes registers all API routes with the mux.
func (s *Server) registerRoutes() {
// Health and stats
s.mux.HandleFunc("GET /api/v1/health", routes.HandleHealth(s.deps.Store, s.deps.LLM, s.deps.Version, s.deps.MCPToolCount, s.deps.Log))
s.mux.HandleFunc("GET /api/v1/health", routes.HandleHealth(s.deps.Store, s.deps.LLM, s.deps.Version, s.deps.MCPToolCount, s.deps.StartTime, s.deps.Log))
s.mux.HandleFunc("GET /api/v1/stats", routes.HandleStats(s.deps.Store, s.deps.Log))

// Self-update
Expand Down
4 changes: 4 additions & 0 deletions internal/backup/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ func ImportFromJSON(ctx context.Context, s store.Store, filePath string, mode Im

// Import memories
for _, memory := range exportData.Memories {
// Ensure raw_id is never empty — use id as fallback
if memory.RawID == "" {
memory.RawID = memory.ID
}
if err := s.WriteMemory(ctx, memory); err != nil {
result.SkippedDuplicates++
} else {
Expand Down
19 changes: 18 additions & 1 deletion internal/store/sqlite/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ const memoryColumns = `id, raw_id, timestamp, type, content, summary, concepts,
// scanMemory scans a memory row from the database.
func scanMemoryFrom(s scanner) (store.Memory, error) {
var mem store.Memory
var rawID sql.NullString
var memType sql.NullString
var conceptsStr sql.NullString
var embeddingBlob []byte
Expand All @@ -365,7 +366,7 @@ func scanMemoryFrom(s scanner) (store.Memory, error) {
var recallSuppressed int
err := s.Scan(
&mem.ID,
&mem.RawID,
&rawID,
&mem.Timestamp,
&memType,
&mem.Content,
Expand All @@ -391,6 +392,8 @@ func scanMemoryFrom(s scanner) (store.Memory, error) {
return mem, err
}

mem.RawID = rawID.String

// Decode concepts
if conceptsStr.Valid && conceptsStr.String != "" {
concepts, err := decodeStringSlice(conceptsStr.String)
Expand Down Expand Up @@ -2172,6 +2175,20 @@ func (s *SQLiteStore) ListRecentRetrievalFeedback(ctx context.Context, since tim
return results, rows.Err()
}

// PruneOldFeedback deletes retrieval_feedback records older than the given duration.
func (s *SQLiteStore) PruneOldFeedback(ctx context.Context, olderThan time.Duration) (int, error) {
cutoff := time.Now().Add(-olderThan).Format(time.RFC3339)
result, err := s.db.ExecContext(ctx, `DELETE FROM retrieval_feedback WHERE created_at < ?`, cutoff)
if err != nil {
return 0, fmt.Errorf("prune old feedback: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("prune old feedback rows affected: %w", err)
}
return int(rows), nil
}

// GetMemoryFeedbackScores computes a normalized feedback score for each memory ID
// by scanning retrieval_feedback rows where the memory appears in retrieved_memory_ids.
// "helpful" = +1, "irrelevant" = -1, "partial" = 0. Returns sum/count per memory.
Expand Down
1 change: 1 addition & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ type Store interface {
WriteRetrievalFeedback(ctx context.Context, fb RetrievalFeedback) error
GetRetrievalFeedback(ctx context.Context, queryID string) (RetrievalFeedback, error)
ListRecentRetrievalFeedback(ctx context.Context, since time.Time, limit int) ([]RetrievalFeedback, error)
PruneOldFeedback(ctx context.Context, olderThan time.Duration) (int, error)
// GetMemoryFeedbackScores computes a normalized feedback score for each memory ID
// based on retrieval_feedback records. "helpful" = +1, "irrelevant" = -1, "partial" = 0.
// Returns sum/count per memory, so scores range from -1.0 to +1.0.
Expand Down
1 change: 1 addition & 0 deletions internal/store/storetest/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func (MockStore) GetRetrievalFeedback(context.Context, string) (store.RetrievalF
func (MockStore) ListRecentRetrievalFeedback(context.Context, time.Time, int) ([]store.RetrievalFeedback, error) {
return nil, nil
}
func (MockStore) PruneOldFeedback(context.Context, time.Duration) (int, error) { return 0, nil }
func (MockStore) GetMemoryFeedbackScores(context.Context, []string) (map[string]float32, error) {
return nil, nil
}
Expand Down