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
3 changes: 1 addition & 2 deletions blog/opinion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down Expand Up @@ -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",
}

Expand Down Expand Up @@ -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",
}

Expand Down Expand Up @@ -1216,6 +1219,7 @@ func generateSummaries() {
Rag: ragContext,
Question: prompt,
Priority: ai.PriorityMedium,
Model: "claude-haiku-4-5-20251001",
Caller: "topic-summary",
})

Expand Down Expand Up @@ -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)
}
Expand Down
34 changes: 13 additions & 21 deletions home/cards.json
Original file line number Diff line number Diff line change
@@ -1,60 +1,52 @@
{
"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"
},
{
"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": ""
},
{
"id": "video",
"title": "Video",
"type": "video",
"position": 4,
"position": 2,
"link": "/video",
"icon": "/video.png"
}
Expand Down
126 changes: 71 additions & 55 deletions home/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,19 +327,20 @@ document.getElementById('home-date-weather').textContent=w.temp+'°C '+(e||'');
}
b.WriteString(`</div>`)

// ── Console view (stream) ──
consoleEvents := stream.Recent(stream.StreamLimit, viewerID)
consoleEvents = stream.DedupeAdjacent(consoleEvents)
b.WriteString(`<div id="home-console" style="display:none;flex-direction:column;height:calc(100vh - 120px);max-height:calc(100vh - 120px)">`)
b.WriteString(`<div id="stream-events" style="flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding-bottom:8px">`)
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(`<div id="home-console" style="display:none;flex-direction:column;height:calc(100vh - 120px)">`)
b.WriteString(`<div id="console-response" style="flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:16px 0;display:flex;align-items:center;justify-content:center">`)
b.WriteString(`<p style="color:#bbb;font-size:15px">Ask Micro anything</p>`)
b.WriteString(`</div>`)
// Compose box pinned at bottom (logged-in only).
if viewerID != "" {
b.WriteString(fmt.Sprintf(`<form id="stream-form" method="POST" action="/stream" style="display:flex;gap:6px;padding:8px 0;border-top:1px solid #eee;background:#fff;flex-shrink:0;min-width:0">
<input type="text" name="content" id="stream-input" placeholder="Message @micro..." maxlength="%d" autocomplete="off" style="flex:1;min-width:0;padding:8px 10px;border:1px solid #ddd;border-radius:6px;font-size:14px;box-sizing:border-box">
<button type="submit" style="padding:8px 12px;background:#000;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;flex-shrink:0;white-space:nowrap">Send</button>
b.WriteString(fmt.Sprintf(`<form id="console-form" style="display:flex;gap:6px;padding:8px 0;border-top:1px solid #eee;background:#fff;flex-shrink:0;min-width:0">
<input type="text" id="console-input" placeholder="What's the BTC price? What's in my mail? Summarise the news..." maxlength="%d" autocomplete="off" style="flex:1;min-width:0;padding:10px 12px;border:1px solid #ddd;border-radius:8px;font-size:14px;box-sizing:border-box">
<button type="submit" style="padding:10px 14px;background:#000;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:14px;flex-shrink:0">Ask</button>
</form>`, stream.MaxContentLength))
} else {
b.WriteString(`<p style="padding:8px 0;color:#999;font-size:13px;text-align:center"><a href="/login">Log in</a> to use the console</p>`)
}
b.WriteString(consoleScript)
b.WriteString(`</div>`)
Expand Down Expand Up @@ -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 = `<script>
(function(){
var eventsEl = document.getElementById('stream-events');
var formEl = document.getElementById('stream-form');
if (!eventsEl) return;
var pollInterval = 10000;
var inflight = false;
var form = document.getElementById('console-form');
var resp = document.getElementById('console-response');
if (!form || !resp) return;

function csrfToken() {
var m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}

function refresh(clear) {
if (inflight) return;
inflight = true;
fetch('/stream/fragment', { credentials: 'same-origin', cache: 'no-store' })
.then(function(r){ return r.ok ? r.text() : null; })
.then(function(html){
if (html == null) return;
var scroll = eventsEl.scrollTop;
eventsEl.innerHTML = html;
if (!clear) eventsEl.scrollTop = scroll;
})
.catch(function(){})
.then(function(){ inflight = false; });
}

if (formEl) {
formEl.addEventListener('submit', function(ev){
ev.preventDefault();
var input = document.getElementById('stream-input');
if (!input) return;
var text = input.value.trim();
if (!text) return;
var body = new URLSearchParams();
body.set('content', text);
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
var tok = csrfToken();
if (tok) headers['X-CSRF-Token'] = tok;
input.value = '';
fetch('/stream', {
method: 'POST',
credentials: 'same-origin',
headers: headers,
body: body.toString()
}).then(function(){ refresh(true); })
.catch(function(){ formEl.submit(); });
form.addEventListener('submit', function(ev){
ev.preventDefault();
var input = document.getElementById('console-input');
if (!input) return;
var q = input.value.trim();
if (!q) return;

// Show thinking state.
resp.style.alignItems = 'flex-start';
resp.style.justifyContent = 'flex-start';
resp.innerHTML = '<div style="padding:12px 0"><p style="color:#333;font-weight:600;margin-bottom:8px">' + escHtml(q) + '</p><p style="color:#999">Thinking...</p></div>';
input.value = '';

var headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
var tok = csrfToken();
if (tok) headers['X-CSRF-Token'] = tok;

fetch('/stream', {
method: 'POST',
credentials: 'same-origin',
headers: headers,
body: JSON.stringify({ content: '@micro ' + q })
}).then(function(r) {
if (!r.ok) return r.text().then(function(t){ throw new Error(t) });
// Poll for the agent response — it's async, so we check every 2s.
var attempts = 0;
var maxAttempts = 30;
function poll() {
attempts++;
fetch('/stream?format=json&since=' + Math.floor(Date.now()/1000 - 60), { credentials: 'same-origin' })
.then(function(r){ return r.json() })
.then(function(data){
if (!data.events) { if (attempts < maxAttempts) setTimeout(poll, 2000); return; }
// Find the latest agent response.
for (var i = 0; i < data.events.length; i++) {
if (data.events[i].type === 'agent') {
resp.innerHTML = '<div style="padding:12px 0"><p style="color:#333;font-weight:600;margin-bottom:8px">' + escHtml(q) + '</p><div style="color:#555;line-height:1.6;white-space:pre-wrap;word-wrap:break-word">' + escHtml(data.events[i].content) + '</div></div>';
return;
}
}
if (attempts < maxAttempts) setTimeout(poll, 2000);
else resp.innerHTML = '<div style="padding:12px 0"><p style="color:#333;font-weight:600;margin-bottom:8px">' + escHtml(q) + '</p><p style="color:#c00">Timed out waiting for a response. Try again.</p></div>';
})
.catch(function(){ if (attempts < maxAttempts) setTimeout(poll, 2000); });
}
setTimeout(poll, 2000);
}).catch(function(err){
resp.innerHTML = '<div style="padding:12px 0"><p style="color:#c00">' + escHtml(err.message || 'Something went wrong') + '</p></div>';
});
}
});

setInterval(function(){ if (!document.hidden) refresh(); }, pollInterval);
document.addEventListener('visibilitychange', function(){ if (!document.hidden) refresh(); });
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
})();
</script>`

Expand Down
54 changes: 0 additions & 54 deletions markets/markets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading