diff --git a/blog/opinion.go b/blog/opinion.go
index d1703f92..05f6ddd7 100644
--- a/blog/opinion.go
+++ b/blog/opinion.go
@@ -90,8 +90,7 @@ func opinionEngageLoop() {
}
// maxDailyOpinions limits how many opinion posts are generated per day.
-// Reduced from 8 (one per category) to control API costs while no users are active.
-const maxDailyOpinions = 2
+const maxDailyOpinions = 1
// publishNextOpinion finds the next category that needs an opinion today
// and publishes it, respecting the spacing between posts.
diff --git a/chat/chat.go b/chat/chat.go
index c6dfebd4..87939e10 100644
--- a/chat/chat.go
+++ b/chat/chat.go
@@ -1043,11 +1043,12 @@ func Load() {
if okUri && okContent && okType {
app.Log("chat", "Received summary generation request for %s (%s)", uri, eventType)
- // Generate summary using LLM (low priority - background task)
+ // Generate summary using LLM (low priority - background task, Haiku for cost)
prompt := &ai.Prompt{
System: "You are a helpful assistant that creates concise summaries. Provide only the summary content itself without any introductory phrases like 'Here is a summary' or 'This article is about'. Just write 2-3 clear sentences that capture the key points. Report what the article says without editorialising, fact-checking, or adding commentary about whether claims are true or false. Never add disclaimers, caveats, or corrections.",
Question: fmt.Sprintf("Summarize this article:\n\n%s", content),
Priority: ai.PriorityLow,
+ Model: "claude-haiku-4-5-20251001",
Caller: "article-summary",
}
@@ -1105,6 +1106,7 @@ func Load() {
System: fmt.Sprintf("You are a content categorization assistant. Your task is to categorize posts into ONE of these categories ONLY: %s. If the post does not clearly fit into any of these categories, respond with 'None'. Respond with ONLY the category name or 'None', nothing else.", strings.Join(topics, ", ")),
Question: fmt.Sprintf("Categorize this post:\n\nTitle: %s\n\nContent: %s\n\nWhich single category best fits this post?", title, content),
Priority: ai.PriorityLow,
+ Model: "claude-haiku-4-5-20251001",
Caller: "auto-tag-post",
}
@@ -1159,6 +1161,7 @@ func Load() {
System: "You are a note organization assistant. Given a note, suggest ONE short tag (1-2 words, lowercase) that best categorizes it. Examples: 'work', 'ideas', 'shopping', 'todo', 'recipe', 'travel', 'health', 'finance'. Respond with ONLY the tag, nothing else. If the note is too short or unclear, respond with 'personal'.",
Question: content,
Priority: ai.PriorityLow,
+ Model: "claude-haiku-4-5-20251001",
Caller: "auto-tag-note",
}
@@ -1216,6 +1219,7 @@ func generateSummaries() {
Rag: ragContext,
Question: prompt,
Priority: ai.PriorityMedium,
+ Model: "claude-haiku-4-5-20251001",
Caller: "topic-summary",
})
@@ -1589,12 +1593,10 @@ func handlePostChat(w http.ResponseWriter, r *http.Request) {
type llmAnalyzer struct{}
func (a *llmAnalyzer) Analyze(promptText, question string) (string, error) {
- // Create a simple prompt for analysis
prompt := &ai.Prompt{
System: promptText,
Question: question,
- Context: nil,
- Rag: nil,
+ Model: "claude-haiku-4-5-20251001",
}
return askLLM(prompt)
}
diff --git a/home/cards.json b/home/cards.json
index d5989a13..8fbdcdde 100644
--- a/home/cards.json
+++ b/home/cards.json
@@ -1,10 +1,18 @@
{
"left": [
+ {
+ "id": "reminder",
+ "title": "Reminder",
+ "type": "reminder",
+ "position": 0,
+ "link": "",
+ "icon": "/reminder.svg"
+ },
{
"id": "blog",
"title": "Blog",
"type": "blog",
- "position": 0,
+ "position": 1,
"link": "/blog",
"icon": "/post.png"
},
@@ -12,41 +20,25 @@
"id": "news",
"title": "News",
"type": "news",
- "position": 1,
+ "position": 2,
"link": "/news",
"icon": "/news.png"
}
],
"right": [
- {
- "id": "weather",
- "title": "Weather",
- "type": "weather",
- "position": 0,
- "link": "/weather",
- "icon": ""
- },
{
"id": "markets",
"title": "Markets",
"type": "markets",
- "position": 1,
+ "position": 0,
"link": "/markets",
"icon": "/markets.svg"
},
- {
- "id": "reminder",
- "title": "Reminder",
- "type": "reminder",
- "position": 2,
- "link": "",
- "icon": "/reminder.svg"
- },
{
"id": "social",
"title": "Social",
"type": "social",
- "position": 3,
+ "position": 1,
"link": "/social",
"icon": ""
},
@@ -54,7 +46,7 @@
"id": "video",
"title": "Video",
"type": "video",
- "position": 4,
+ "position": 2,
"link": "/video",
"icon": "/video.png"
}
diff --git a/home/home.go b/home/home.go
index 719a3a40..80d714b6 100644
--- a/home/home.go
+++ b/home/home.go
@@ -327,19 +327,20 @@ document.getElementById('home-date-weather').textContent=w.temp+'°C '+(e||'');
}
b.WriteString(``)
- // ── Console view (stream) ──
- consoleEvents := stream.Recent(stream.StreamLimit, viewerID)
- consoleEvents = stream.DedupeAdjacent(consoleEvents)
- b.WriteString(`
`)
- b.WriteString(`
`)
- b.WriteString(stream.RenderEventList(consoleEvents, viewerID))
+ // ── Console view — stateless command prompt ──
+ // Like Alexa: ask a question, get an answer. No persistent history,
+ // no feed of system events. Just a prompt and a response area.
+ b.WriteString(`
`)
+ b.WriteString(`
`)
+ b.WriteString(`
Ask Micro anything
`)
b.WriteString(`
`)
- // Compose box pinned at bottom (logged-in only).
if viewerID != "" {
- b.WriteString(fmt.Sprintf(`
`, stream.MaxContentLength))
+ } else {
+ b.WriteString(`
Log in to use the console
`)
}
b.WriteString(consoleScript)
b.WriteString(`
`)
@@ -474,61 +475,76 @@ func htmlEsc(s string) string {
//
// The script is defensive: if anything throws, the form still falls
// back to its native POST + redirect behaviour.
-// consoleScript handles polling + form submit for the console stream
-// embedded on the home page.
+// consoleScript — stateless command prompt. Sends the question to the
+// agent endpoint, shows a thinking indicator, then renders the answer.
+// Each new question clears the previous answer. No history, no polling.
const consoleScript = ``
diff --git a/markets/markets.go b/markets/markets.go
index dbdb985d..79d0021b 100644
--- a/markets/markets.go
+++ b/markets/markets.go
@@ -11,11 +11,8 @@ import (
"sync"
"time"
- "math"
-
"mu/internal/app"
"mu/internal/data"
- "mu/stream"
"github.com/piquette/finance-go/future"
"github.com/piquette/finance-go/quote"
@@ -105,12 +102,6 @@ func refreshMarkets() {
for {
prices, priceData := fetchPrices()
if prices != nil {
- // Detect significant price moves BEFORE overwriting the cache.
- marketsMutex.RLock()
- oldPrices := cachedPrices
- marketsMutex.RUnlock()
- surfaceMarketMoves(oldPrices, prices, priceData)
-
marketsMutex.Lock()
cachedPrices = prices
cachedPriceData = priceData
@@ -303,51 +294,6 @@ func generateMarketsCardHTML(prices map[string]float64) string {
return sb.String()
}
-// surfaceMarketMoves posts to the stream when a tracked asset moves
-// more than the threshold since the last refresh. Only fires for a
-// small set of key tickers to avoid flooding the stream.
-func surfaceMarketMoves(oldPrices, newPrices map[string]float64, pd map[string]PriceData) {
- if len(oldPrices) == 0 {
- return // first fetch, no comparison
- }
- // Only surface moves for these tickers.
- tracked := []string{"BTC", "ETH", "SOL", "GOLD", "OIL"}
- threshold := 0.02 // 2%
-
- for _, ticker := range tracked {
- oldP, okOld := oldPrices[ticker]
- newP, okNew := newPrices[ticker]
- if !okOld || !okNew || oldP == 0 {
- continue
- }
- pctChange := (newP - oldP) / oldP
- if math.Abs(pctChange) < threshold {
- continue
- }
- direction := "+"
- if pctChange < 0 {
- direction = ""
- }
- content := fmt.Sprintf("%s %s%.1f%% ($%s)", ticker, direction, pctChange*100, formatPrice(newP))
- change24h := float64(0)
- if d, ok := pd[ticker]; ok {
- change24h = d.Change24h
- }
- stream.Publish(&stream.Event{
- Type: stream.TypeMarket,
- AuthorID: app.SystemUserID,
- Content: content,
- Metadata: map[string]any{
- "ticker": ticker,
- "price": newP,
- "change_pct": pctChange * 100,
- "change_24h": change24h,
- },
- })
- app.Log("markets", "Stream: %s", content)
- }
-}
-
func indexMarketPrices(prices map[string]float64) {
app.Log("markets", "Indexing %d prices", len(prices))
timestamp := time.Now().Format(time.RFC3339)
diff --git a/news/news.go b/news/news.go
index 5da8c025..e06dd578 100644
--- a/news/news.go
+++ b/news/news.go
@@ -528,13 +528,7 @@ func getMetadata(uri string, publishedAt time.Time) (*Metadata, bool, error) {
if isHN {
age := time.Since(time.Unix(0, cached.Created))
if age < time.Hour {
- // Request summary if we don't have one yet, with smart retry
- if cached.Summary == "" {
- shouldRetry := shouldRequestSummary(cached)
- if shouldRetry {
- go requestArticleSummary(uri, cached)
- }
- }
+ // Summaries generated on-demand when article is viewed.
return cached, false, nil // false = from cache
}
app.Log("news", "HN metadata cache expired for %s (age: %v), refetching comments", uri, age.Round(time.Minute))
@@ -547,13 +541,7 @@ func getMetadata(uri string, publishedAt time.Time) (*Metadata, bool, error) {
uri, cachedTime.Format(time.RFC3339), publishedAt.Format(time.RFC3339))
} else {
// Cache is still valid
- // Request summary if we don't have one yet, with smart retry
- if cached.Summary == "" {
- shouldRetry := shouldRequestSummary(cached)
- if shouldRetry {
- go requestArticleSummary(uri, cached)
- }
- }
+ // Summaries generated on-demand when article is viewed.
return cached, false, nil
}
}
@@ -1555,10 +1543,19 @@ func handleArticleView(w http.ResponseWriter, r *http.Request, articleID string)
}
}
- app.Log("news", "Final title='%s', desc='%s'", title, description)
+ // On-demand summary generation: if the article has no summary yet,
+ // request one now that someone is actually reading it. Uses Haiku
+ // for cost efficiency. Previously this ran proactively for every
+ // article in the feed.
+ if summary == "" && articleURL != "" {
+ if cached, exists := loadCachedMetadata(articleURL); exists && cached.Summary == "" {
+ if shouldRequestSummary(cached) {
+ go requestArticleSummary(articleURL, cached)
+ }
+ }
+ }
- // Don't fall back to entry.Content for description - that might contain AI-generated summary
- // Keep description and summary clearly separated
+ app.Log("news", "Final title='%s', desc='%s'", title, description)
// Build the article page
imageSection := ""
diff --git a/reminder/reminder.go b/reminder/reminder.go
index 75af4631..50d9afdf 100644
--- a/reminder/reminder.go
+++ b/reminder/reminder.go
@@ -12,7 +12,6 @@ import (
"mu/internal/app"
"mu/internal/data"
"mu/internal/event"
- "mu/stream"
)
var (
@@ -83,14 +82,6 @@ func fetchReminder() {
reminderMutex.Unlock()
event.Publish(event.Event{Type: "reminder_updated"})
- // Post the verse to the platform stream so it appears in the console.
- stream.Publish(&stream.Event{
- Type: stream.TypeReminder,
- AuthorID: app.SystemUserID,
- Content: strings.TrimSpace(verseText),
- Metadata: map[string]any{"url": moreURL},
- })
-
// Extract message and updated for indexing
message := ""
if m, ok := val["message"]; ok {
diff --git a/search/topics.go b/search/topics.go
index 60b7966f..e6b94539 100644
--- a/search/topics.go
+++ b/search/topics.go
@@ -94,6 +94,7 @@ func regenerateTopics() {
Return exactly 10 topics, one per line. Nothing else.`,
Question: "Extract 10 trending search topics from these recent headlines:\n\n" + strings.Join(headlines, "\n"),
Priority: ai.PriorityLow,
+ Model: "claude-haiku-4-5-20251001",
Caller: "topic-generation",
}
diff --git a/social/social.go b/social/social.go
index 0e062d48..28e0d1b6 100644
--- a/social/social.go
+++ b/social/social.go
@@ -19,7 +19,6 @@ import (
"mu/internal/event"
"mu/internal/flag"
"mu/news"
- "mu/stream"
"mu/wallet"
)
@@ -222,14 +221,6 @@ func SurfaceBreaking(category, title, link string) {
Content: content,
PostedAt: time.Now(),
})
-
- // Also post to the platform stream so it appears in the console.
- stream.Publish(&stream.Event{
- Type: stream.TypeNews,
- AuthorID: app.SystemUserID,
- Content: title,
- Metadata: map[string]any{"url": link, "category": category},
- })
}
func save() error {