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
53 changes: 53 additions & 0 deletions agent/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
147 changes: 147 additions & 0 deletions internal/memory/memory.go
Original file line number Diff line number Diff line change
@@ -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()
}
10 changes: 10 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"mu/docs"
"mu/home"
"mu/mail"
"mu/internal/memory"
"mu/news"
"mu/news/digest"
"mu/markets"
Expand Down Expand Up @@ -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 ""
}
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions markets/markets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading