diff --git a/agent/run.go b/agent/run.go index bcb4f281..bd61f4d5 100644 --- a/agent/run.go +++ b/agent/run.go @@ -11,8 +11,58 @@ import ( "mu/internal/api" "mu/internal/app" "mu/internal/auth" + "mu/internal/memory" ) +// extractMemory checks if the user's prompt contains something to +// remember (preferences, facts about themselves, interests). Runs +// async after the response so it doesn't slow down the answer. +func extractMemory(accountID, prompt string) { + lower := strings.ToLower(prompt) + // Quick check — only run the LLM if the prompt looks like it + // contains a memory-worthy statement. + triggers := []string{"remember", "my ", "i like", "i prefer", "i'm ", "i am ", + "don't show", "always ", "never ", "i want", "i need", "i use", "my name", + "call me", "i live", "i work"} + found := false + for _, t := range triggers { + if strings.Contains(lower, t) { + found = true + break + } + } + if !found { + return + } + + result, err := ai.Ask(&ai.Prompt{ + System: `Extract any personal preference or fact the user is sharing about themselves. +Output ONLY valid JSON: {"key":"short label","value":"what to remember"} +If the message does NOT contain a personal preference or fact, output: {} +Examples: +"Remember I like Bitcoin" → {"key":"interest","value":"likes Bitcoin"} +"I live in London" → {"key":"location","value":"London"} +"What's the weather?" → {}`, + Question: prompt, + Model: ai.BackgroundModel(), + Caller: "memory-extract", + }) + if err != nil || result == "" { + return + } + var extracted struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(result)), &extracted); err != nil { + return + } + if extracted.Key != "" && extracted.Value != "" { + memory.Set(accountID, extracted.Key, extracted.Value) + app.Log("memory", "Saved for %s: %s = %s", accountID, extracted.Key, extracted.Value) + } +} + // UserContextFunc is set by main.go to provide personalised context // for the agent's responses. Returns a string with the user's current // state (unread mail, market prices, etc.) that gets injected into the @@ -161,6 +211,9 @@ func RunHandler(w http.ResponseWriter, r *http.Request) { answer = app.StripLatexDollars(answer) + // Check if the user asked to remember something. + go extractMemory(acc.ID, req.Prompt) + // Save as a flow so it appears in the agent history at /agent. var steps []FlowStep for _, tu := range toolsUsed { diff --git a/internal/memory/memory.go b/internal/memory/memory.go new file mode 100644 index 00000000..239b3258 --- /dev/null +++ b/internal/memory/memory.go @@ -0,0 +1,147 @@ +// Package memory provides persistent per-user memory for the AI agent. +// The agent remembers preferences, interests, and facts about the user +// across sessions. Memory is stored as simple key-value notes. +package memory + +import ( + "strings" + "sync" + "time" + + "mu/internal/data" +) + +// Entry is a single thing the agent remembers about a user. +type Entry struct { + Key string `json:"key"` + Value string `json:"value"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MaxEntriesPerUser caps memory to prevent unbounded growth. +const MaxEntriesPerUser = 50 + +var ( + mu sync.RWMutex + store = map[string][]*Entry{} // userID → entries +) + +func init() { + data.LoadJSON("memory.json", &store) +} + +func save() { + data.SaveJSON("memory.json", store) +} + +// Set stores or updates a memory entry for a user. +func Set(userID, key, value string) { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" { + return + } + + mu.Lock() + defer mu.Unlock() + + entries := store[userID] + now := time.Now() + + // Update if key exists. + for _, e := range entries { + if strings.EqualFold(e.Key, key) { + e.Value = value + e.UpdatedAt = now + save() + return + } + } + + // Add new entry. + entries = append(entries, &Entry{ + Key: key, + Value: value, + CreatedAt: now, + UpdatedAt: now, + }) + + // Cap at max. + if len(entries) > MaxEntriesPerUser { + entries = entries[len(entries)-MaxEntriesPerUser:] + } + + store[userID] = entries + save() +} + +// Get retrieves a specific memory by key. +func Get(userID, key string) string { + mu.RLock() + defer mu.RUnlock() + + for _, e := range store[userID] { + if strings.EqualFold(e.Key, key) { + return e.Value + } + } + return "" +} + +// All returns all memory entries for a user. +func All(userID string) []*Entry { + mu.RLock() + defer mu.RUnlock() + + entries := store[userID] + result := make([]*Entry, len(entries)) + copy(result, entries) + return result +} + +// ForContext returns a formatted string of all memories suitable for +// injecting into the agent's system prompt. +func ForContext(userID string) string { + mu.RLock() + defer mu.RUnlock() + + entries := store[userID] + if len(entries) == 0 { + return "" + } + + var sb strings.Builder + for _, e := range entries { + sb.WriteString("- ") + sb.WriteString(e.Key) + sb.WriteString(": ") + sb.WriteString(e.Value) + sb.WriteString("\n") + } + return sb.String() +} + +// Delete removes a memory entry. +func Delete(userID, key string) { + mu.Lock() + defer mu.Unlock() + + entries := store[userID] + var kept []*Entry + for _, e := range entries { + if !strings.EqualFold(e.Key, key) { + kept = append(kept, e) + } + } + store[userID] = kept + save() +} + +// Clear removes all memory for a user (account deletion). +func Clear(userID string) { + mu.Lock() + defer mu.Unlock() + delete(store, userID) + save() +} diff --git a/main.go b/main.go index b55e47ad..48435e83 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "mu/docs" "mu/home" "mu/mail" + "mu/internal/memory" "mu/news" "mu/news/digest" "mu/markets" @@ -131,6 +132,14 @@ func main() { if bal > 0 { parts = append(parts, fmt.Sprintf("- Wallet: %d credits", bal)) } + // Market prices — top movers. + if prices := markets.TopMovers(3); prices != "" { + parts = append(parts, "- Markets: "+prices) + } + // Persistent memory — things the user has told you to remember. + if mem := memory.ForContext(accountID); mem != "" { + parts = append(parts, "User preferences/notes:\n"+mem) + } if len(parts) == 0 { return "" } @@ -282,6 +291,7 @@ func main() { mail.DeleteInbox, func(id string) { wallet.DeleteWallet(id) }, func(id string) { app.ClearUserPrefs(id) }, + memory.Clear, ) // Enable indexing after all content is loaded diff --git a/markets/markets.go b/markets/markets.go index 79d0021b..a12e9e04 100644 --- a/markets/markets.go +++ b/markets/markets.go @@ -98,6 +98,46 @@ func Load() { go refreshMarkets() } +// TopMovers returns a short string summarising the N biggest movers +// by 24h change. Used by the agent context to give price awareness. +func TopMovers(n int) string { + marketsMutex.RLock() + defer marketsMutex.RUnlock() + + if len(cachedPriceData) == 0 { + return "" + } + + type mover struct { + symbol string + price float64 + change float64 + } + tracked := []string{"BTC", "ETH", "SOL", "GOLD", "OIL"} + var movers []mover + for _, sym := range tracked { + if pd, ok := cachedPriceData[sym]; ok { + movers = append(movers, mover{sym, pd.Price, pd.Change24h}) + } + } + if len(movers) == 0 { + return "" + } + if n > len(movers) { + n = len(movers) + } + + var parts []string + for _, m := range movers[:n] { + dir := "+" + if m.change < 0 { + dir = "" + } + parts = append(parts, fmt.Sprintf("%s $%.0f (%s%.1f%%)", m.symbol, m.price, dir, m.change)) + } + return strings.Join(parts, ", ") +} + func refreshMarkets() { for { prices, priceData := fetchPrices()