Add mood sensing and auto collections to memory cloud

This commit is contained in:
OpenClaw
2026-04-24 22:40:46 +02:00
parent a13d08251d
commit e4195fdde2
3 changed files with 244 additions and 27 deletions
+91 -7
View File
@@ -100,6 +100,44 @@ const SMART_TAG_RULES = [
{ tag: 'family', patterns: [/kids/i, /family/i, /home/i] },
]
const TAG_ALIASES = {
login: 'auth',
oauth: 'auth',
permissions: 'auth',
permission: 'auth',
visual: 'design',
theme: 'design',
ui: 'design',
archive: 'memory',
memories: 'memory',
deploy: 'infra',
deployment: 'infra',
cloudflare: 'infra',
service: 'infra',
openclaw: 'agents',
agentboard: 'agents',
codex: 'agents',
child: 'kidsstories',
stories: 'kidsstories',
home: 'family',
}
const MOOD_PROFILES = [
{ mood: 'focused', score: 0, patterns: [/fix/i, /ship/i, /build/i, /implement/i, /polish/i, /deploy/i, /migrate/i] },
{ mood: 'playful', score: 0, patterns: [/fun/i, /mischief/i, /awesome/i, /play/i, /spark/i] },
{ mood: 'reflective', score: 0, patterns: [/remember/i, /reflect/i, /journal/i, /learn/i, /insight/i] },
{ mood: 'tense', score: 0, patterns: [/blocked/i, /worry/i, /broken/i, /error/i, /issue/i, /frustrat/i] },
{ mood: 'warm', score: 0, patterns: [/family/i, /kids/i, /home/i, /story/i, /love/i] },
]
const COLLECTION_RULES = [
{ id: 'infra-week', label: 'Infra week', tags: ['infra', 'appwrite', 'auth'] },
{ id: 'design-streak', label: 'Design streak', tags: ['design', 'memory'] },
{ id: 'family-notes', label: 'Family notes', tags: ['family', 'kidsstories'] },
{ id: 'agent-ops', label: 'Agent ops', tags: ['agents', 'appwrite', 'dashboard'] },
{ id: 'product-loop', label: 'Product loop', tags: ['product', 'design', 'appwrite'] },
]
const THEME_PROFILES = {
nord: { name: 'Nord', accent: '#8fbcbb', accentSoft: 'rgba(143,188,187,0.18)', accentStrong: '#a3d5d3', glow: 'rgba(143,188,187,0.28)' },
plum: { name: 'Plum', accent: '#b48ead', accentSoft: 'rgba(180,142,173,0.18)', accentStrong: '#d1a9ca', glow: 'rgba(180,142,173,0.28)' },
@@ -111,12 +149,21 @@ function isProbablyNoiseTag(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value) || /^\d+$/.test(value)
}
function normalizeTag(value) {
const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9+-]+/g, '').trim()
return TAG_ALIASES[cleaned] || cleaned
}
function generateTags(content, filename) {
const tags = []
const scores = new Map()
const addScore = (tag, amount) => scores.set(tag, (scores.get(tag) || 0) + amount)
const addScore = (tag, amount) => {
const normalized = normalizeTag(tag)
if (!normalized) return
scores.set(normalized, (scores.get(normalized) || 0) + amount)
}
const pushTag = (value) => {
const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9+-]+/g, '').trim()
const cleaned = normalizeTag(value)
if (!cleaned || cleaned.length < 3 || TAG_STOPWORDS.has(cleaned) || tags.includes(cleaned) || isProbablyNoiseTag(cleaned)) return
tags.push(cleaned)
}
@@ -170,6 +217,35 @@ function buildThemeProfile(content = '', tags = []) {
return THEME_PROFILES.nord
}
function detectMood(content = '', tags = []) {
const haystack = `${content} ${(tags || []).join(' ')}`.toLowerCase()
let best = { mood: 'steady', score: 0 }
for (const profile of MOOD_PROFILES) {
let score = 0
for (const pattern of profile.patterns) {
const matches = haystack.match(new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)) || []
score += matches.length
}
if (score > best.score) best = { mood: profile.mood, score }
}
return best.mood
}
function buildCollections(files = []) {
return COLLECTION_RULES.map((rule) => {
const matchedFiles = files.filter((file) => rule.tags.some((tag) => (file.tags || []).includes(tag)))
if (!matchedFiles.length) return null
return {
id: rule.id,
label: rule.label,
count: matchedFiles.length,
tags: rule.tags,
latest: matchedFiles[0]?.dateLabel,
filenames: matchedFiles.slice(0, 6).map((file) => file.filename),
}
}).filter(Boolean)
}
function formatDateLabel(filename) {
const match = filename.match(/^(\d{4})-(\d{2})-(\d{2})\.md$/)
if (!match) return filename
@@ -285,6 +361,7 @@ async function readMemoryDocument(filename) {
function buildFileMeta(doc) {
const content = doc.content || ''
const tags = generateTags(content, doc.filename)
const mtimeMs = doc.$updatedAt ? new Date(doc.$updatedAt).getTime() : Date.now()
return {
filename: doc.filename,
@@ -293,7 +370,8 @@ function buildFileMeta(doc) {
wordCount: wordCount(content),
entryCount: getEntryCount(content),
dateLabel: formatDateLabel(doc.filename),
tags: generateTags(content, doc.filename),
tags,
mood: detectMood(content, tags),
}
}
@@ -339,8 +417,10 @@ app.get('/api/meta', async (req, res) => {
}, { dailyFileCount: 0, totalDailyEntries: 0, totalDailyWords: 0 })
const theme = buildThemeProfile(mainDoc?.content || '', mainMemory?.tags || [])
const collections = buildCollections(dailyFiles)
const mood = detectMood(mainDoc?.content || '', mainMemory?.tags || [])
res.json({ mainMemory, dailyFiles, summary, theme })
res.json({ mainMemory: mainMemory ? { ...mainMemory, mood } : null, dailyFiles, summary, theme, collections })
} catch (error) {
res.status(500).json({ error: error.message })
}
@@ -370,7 +450,8 @@ app.get('/api/memories/:filename', async (req, res) => {
await ensureSync()
const doc = await readMemoryDocument(filename)
if (!doc) return res.status(404).json({ error: 'File not found' })
return res.json({ content: doc.content || '', tags: generateTags(doc.content || '', filename) })
const tags = generateTags(doc.content || '', filename)
return res.json({ content: doc.content || '', tags, mood: detectMood(doc.content || '', tags) })
} catch (error) {
return res.status(500).json({ error: error.message })
}
@@ -381,7 +462,8 @@ app.get('/api/main-memory', async (req, res) => {
await ensureSync()
const doc = await readMemoryDocument('MEMORY.md')
if (!doc) return res.status(404).json({ error: 'MEMORY.md not found' })
res.json({ content: doc.content || '', tags: generateTags(doc.content || '', 'MEMORY.md') })
const tags = generateTags(doc.content || '', 'MEMORY.md')
res.json({ content: doc.content || '', tags, mood: detectMood(doc.content || '', tags) })
} catch (error) {
res.status(500).json({ error: error.message })
}
@@ -455,12 +537,14 @@ app.get('/api/search', async (req, res) => {
const windowEnd = Math.min(content.length, firstMatchIdx + 120)
const snippet = content.slice(windowStart, windowEnd).replace(/\n/g, ' ')
const tags = generateTags(content, doc.filename)
results.push({
filename: doc.filename,
dateLabel: formatDateLabel(doc.filename),
entryCount: getEntryCount(content),
wordCount: wordCount(content),
tags: generateTags(content, doc.filename),
tags,
mood: detectMood(content, tags),
matchCount: (lower.match(new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length,
snippet: `${windowStart > 0 ? '…' : ''}${snippet}${windowEnd < content.length ? '…' : ''}`,
matchingLines,
+54
View File
@@ -152,6 +152,17 @@ button { border: 0; }
.result-meta,
.theme-copy { color: var(--muted-2); font-size: 0.82rem; }
.lead-meta { margin-top: 0.65rem; }
.lead-row,
.theme-metrics,
.viewer-meta,
.collection-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.lead-row { margin-top: 0.75rem; }
.lead-side { color: var(--muted); font-size: 0.82rem; }
.theme-title { margin-top: 0.35rem; font-size: 1.2rem; font-weight: 600; letter-spacing: -0.03em; }
.theme-copy { margin-top: 0.55rem; line-height: 1.55; }
@@ -186,6 +197,32 @@ button { border: 0; }
.search-block,
.main-grid { margin-top: 1rem; }
.main-grid { grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); }
.collection-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.8rem;
}
.collection-card {
text-align: left;
background: rgba(28,32,36,0.88);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.95rem;
cursor: pointer;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.collection-card:hover,
.collection-card.active {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
background: color-mix(in srgb, var(--accent-soft) 55%, rgba(28,32,36,0.92));
}
.collection-meta {
margin-top: 0.45rem;
color: var(--muted);
font-size: 0.82rem;
}
.section-heading h2,
.compact-heading h2 {
margin-top: 0.2rem;
@@ -299,6 +336,23 @@ button { border: 0; }
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
}
.mood-badge {
display: inline-flex;
align-items: center;
min-height: 1.8rem;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.08);
font-size: 0.74rem;
color: var(--text);
background: rgba(255,255,255,0.03);
}
.mood-focused { border-color: rgba(129,161,193,0.3); color: #b5d2ee; }
.mood-playful { border-color: rgba(180,142,173,0.3); color: #e1c0dd; }
.mood-reflective { border-color: rgba(143,188,187,0.3); color: #bfe1df; }
.mood-tense { border-color: rgba(208,106,106,0.35); color: #f1b3b3; }
.mood-warm { border-color: rgba(208,168,110,0.35); color: #f1d4ab; }
.mood-steady { border-color: rgba(156,163,175,0.25); color: #d1d5db; }
.ghost-btn,
.primary-btn,
+99 -20
View File
@@ -34,6 +34,17 @@ function pickSpark(content) {
return clipText(candidate.replace(/^[-*]\s*/, ''))
}
function moodLabel(mood) {
return {
focused: 'Focused',
playful: 'Playful',
reflective: 'Reflective',
tense: 'Tense',
warm: 'Warm',
steady: 'Steady',
}[mood] || 'Steady'
}
function TagStrip({ tags = [], onPick, activeTag }) {
if (!tags.length) return null
return (
@@ -52,12 +63,17 @@ function TagStrip({ tags = [], onPick, activeTag }) {
)
}
function MoodBadge({ mood }) {
if (!mood) return null
return <span className={`mood-badge mood-${mood}`}>{moodLabel(mood)}</span>
}
function FileCard({ file, onClick, onPickTag, activeTag }) {
return (
<button className="file-card interactive-card" onClick={onClick}>
<div className="file-card-top">
<div className="file-date">{formatDate(file.mtimeMs)}</div>
<div className="file-age">{formatRelative(file.mtimeMs)}</div>
<MoodBadge mood={file.mood} />
</div>
<div className="file-label">{file.dateLabel}</div>
<div className="file-snippet">{file.tags?.[0] ? `Leaning ${file.tags[0]}.` : 'Quiet day in the archive.'}</div>
@@ -65,6 +81,7 @@ function FileCard({ file, onClick, onPickTag, activeTag }) {
<div className="file-stats">
<span>{file.wordCount.toLocaleString()} words</span>
<span>{file.entryCount} entries</span>
<span>{formatRelative(file.mtimeMs)}</span>
</div>
</button>
)
@@ -76,6 +93,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
const [loading, setLoading] = useState(true)
const [saveState, setSaveState] = useState('idle')
const [tags, setTags] = useState(selected.tags || [])
const [mood, setMood] = useState(selected.mood || 'steady')
const isMainMemory = selected.type === 'main'
useEffect(() => {
@@ -90,6 +108,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
setContent(next)
setDraft(next)
setTags(data.tags || selected.tags || [])
setMood(data.mood || selected.mood || 'steady')
setLoading(false)
})
.catch(() => setLoading(false))
@@ -108,6 +127,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
setContent(draft)
const refreshed = await fetch(`${API}/api/main-memory`).then((res) => res.json())
setTags(refreshed.tags || [])
setMood(refreshed.mood || 'steady')
setSaveState('saved')
setTimeout(() => setSaveState('idle'), 1800)
} catch {
@@ -123,11 +143,14 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
<div className="viewer-kicker">{isMainMemory ? 'core memory' : 'daily note'}</div>
<div className="viewer-title">{selected.title}</div>
</div>
{isMainMemory && !loading && (
<button className="primary-btn viewer-save" onClick={save} disabled={saveState === 'saving' || draft === content}>
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : saveState === 'error' ? 'Retry' : 'Save'}
</button>
)}
<div className="viewer-meta">
<MoodBadge mood={mood} />
{isMainMemory && !loading && (
<button className="primary-btn viewer-save" onClick={save} disabled={saveState === 'saving' || draft === content}>
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : saveState === 'error' ? 'Retry' : 'Save'}
</button>
)}
</div>
</div>
<TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
{loading ? (
@@ -162,6 +185,7 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
<TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
</div>
<div className="rail-metrics">
<MoodBadge mood={file.mood} />
<strong>{file.wordCount.toLocaleString()}</strong>
<span>{file.entryCount} entries</span>
<div className="rail-bar"><span style={{ width: `${(file.wordCount / maxWords) * 100}%` }} /></div>
@@ -173,17 +197,50 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
)
}
function ThemeSwatches({ theme, tags, onPickTag, activeTag }) {
function ThemeSwatches({ theme, tags, mood, onPickTag, activeTag }) {
return (
<section className="panel recap-panel interactive-card">
<div className="section-kicker">theme drift</div>
<div className="theme-title">{theme?.name || 'Nord'} mode</div>
<p className="theme-copy">The color temperature now leans with what lives in core memory, not just a fixed skin.</p>
<p className="theme-copy">Color bends to core memory. Mood reads the tone behind it.</p>
<div className="theme-metrics">
<MoodBadge mood={mood} />
</div>
<TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
</section>
)
}
function CollectionsPanel({ collections = [], onOpenCollection, activeCollectionId }) {
if (!collections.length) return null
return (
<section className="panel cluster-panel interactive-card">
<div className="section-heading">
<div>
<div className="section-kicker">auto collections</div>
<h2>Memory is grouping itself.</h2>
</div>
</div>
<div className="collection-grid">
{collections.map((collection) => (
<button
key={collection.id}
className={`collection-card ${activeCollectionId === collection.id ? 'active' : ''}`}
onClick={() => onOpenCollection(collection)}
>
<div className="collection-head">
<strong>{collection.label}</strong>
<span>{collection.count}</span>
</div>
<div className="collection-meta">Latest {collection.latest}</div>
<TagStrip tags={collection.tags} />
</button>
))}
</div>
</section>
)
}
export default function App() {
const [meta, setMeta] = useState(null)
const [selected, setSelected] = useState(null)
@@ -196,6 +253,7 @@ export default function App() {
const [quickLog, setQuickLog] = useState('')
const [logState, setLogState] = useState('idle')
const [activeTag, setActiveTag] = useState('')
const [activeCollection, setActiveCollection] = useState(null)
const [pointer, setPointer] = useState({ x: 18, y: 12 })
const searchRef = useRef(null)
@@ -250,6 +308,12 @@ export default function App() {
}
}, [activeTag])
useEffect(() => {
if (activeCollection) {
setQuery(activeCollection.tags.join(' '))
}
}, [activeCollection])
useEffect(() => {
if (!query.trim() || query.length < 2) {
setSearchResults([])
@@ -259,9 +323,18 @@ export default function App() {
const t = setTimeout(async () => {
setSearching(true)
try {
const r = await fetch(`${API}/api/search?q=${encodeURIComponent(query)}`)
const d = await r.json()
setSearchResults(d.results || [])
const terms = query.trim().split(/\s+/).filter(Boolean)
const requests = await Promise.all(terms.map((term) => fetch(`${API}/api/search?q=${encodeURIComponent(term)}`).then((r) => r.json())))
const merged = new Map()
for (const payload of requests) {
for (const result of payload.results || []) {
const existing = merged.get(result.filename)
if (!existing || (existing.matchCount || 0) < (result.matchCount || 0)) {
merged.set(result.filename, result)
}
}
}
setSearchResults(Array.from(merged.values()).sort((a, b) => (b.matchCount || 0) - (a.matchCount || 0)))
} catch {
setSearchResults([])
}
@@ -300,7 +373,7 @@ export default function App() {
'--pointer-y': `${pointer.y}%`,
}), [meta, pointer])
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [] })
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [], mood: file.mood })
const submitLog = async (e) => {
e.preventDefault()
@@ -335,12 +408,12 @@ export default function App() {
<div className="hero-copy">
<div className="eyebrow">freeclaw memory cloud</div>
<h1>Memory with a pulse.</h1>
<p className="subtitle">Theme shifts with core memory. Tags are smarter. The archive should feel alive, not embalmed.</p>
<p className="subtitle">Now with merged tags, mood sensing, and self-assembling collections.</p>
</div>
<div className="hero-actions">
<button
className="ghost-btn"
onClick={() => setSelected({ type: 'main', title: 'MEMORY.md', filename: 'MEMORY.md', tags: meta?.mainMemory?.tags || [] })}
onClick={() => setSelected({ type: 'main', title: 'MEMORY.md', filename: 'MEMORY.md', tags: meta?.mainMemory?.tags || [], mood: meta?.mainMemory?.mood })}
>
Open core memory
</button>
@@ -354,6 +427,10 @@ export default function App() {
<div className="section-kicker">latest spark</div>
<div className="lead-copy">{spark || 'Loading…'}</div>
<div className="lead-meta">{sparkSource || 'recent archive'}</div>
<div className="lead-row">
<MoodBadge mood={heroStats.latest.mood} />
<span className="lead-side">{heroStats.latest.wordCount.toLocaleString()} words</span>
</div>
<TagStrip tags={heroStats.latest.tags} onPick={setActiveTag} activeTag={activeTag} />
</div>
@@ -364,10 +441,12 @@ export default function App() {
<div className="stat-row"><span>review queue</span><strong>{reviewCount ?? '—'}</strong></div>
</div>
<ThemeSwatches theme={meta.theme} tags={meta.mainMemory?.tags} onPickTag={setActiveTag} activeTag={activeTag} />
<ThemeSwatches theme={meta.theme} tags={meta.mainMemory?.tags} mood={meta.mainMemory?.mood} onPickTag={setActiveTag} activeTag={activeTag} />
</section>
)}
<CollectionsPanel collections={meta?.collections} activeCollectionId={activeCollection?.id} onOpenCollection={(collection) => setActiveCollection((current) => current?.id === collection.id ? null : collection)} />
<section className="panel cluster-panel interactive-card">
<div className="section-heading">
<div>
@@ -411,10 +490,10 @@ export default function App() {
<div className="section-heading compact-heading">
<div>
<div className="section-kicker">search</div>
<h2>{activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
<h2>{activeCollection ? activeCollection.label : activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
</div>
{activeTag && (
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setQuery('') }}>Clear tag</button>
{(activeTag || activeCollection) && (
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setActiveCollection(null); setQuery('') }}>Clear filters</button>
)}
</div>
<div className="search-section">
@@ -434,11 +513,11 @@ export default function App() {
<button
key={result.filename}
className="result-item interactive-card"
onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [] })}
onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [], mood: result.mood })}
>
<div className="result-head">
<div className="result-name">{result.dateLabel}</div>
<div className="result-meta">{result.matchCount} matches</div>
<MoodBadge mood={result.mood} />
</div>
<div className="result-snippet">{result.snippet}</div>
<TagStrip tags={result.tags} onPick={setActiveTag} activeTag={activeTag} />