diff --git a/server.js b/server.js index 7991401..9864873 100644 --- a/server.js +++ b/server.js @@ -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, diff --git a/src/App.css b/src/App.css index 8309620..ea7f833 100644 --- a/src/App.css +++ b/src/App.css @@ -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, diff --git a/src/App.jsx b/src/App.jsx index aac9502..7fbbeed 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 {moodLabel(mood)} +} + function FileCard({ file, onClick, onPickTag, activeTag }) { return ( ) @@ -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 }) {
{isMainMemory ? 'core memory' : 'daily note'}
{selected.title}
- {isMainMemory && !loading && ( - - )} +
+ + {isMainMemory && !loading && ( + + )} +
{loading ? ( @@ -162,6 +185,7 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
+ {file.wordCount.toLocaleString()} {file.entryCount} entries
@@ -173,17 +197,50 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) { ) } -function ThemeSwatches({ theme, tags, onPickTag, activeTag }) { +function ThemeSwatches({ theme, tags, mood, onPickTag, activeTag }) { return (
theme drift
{theme?.name || 'Nord'} mode
-

The color temperature now leans with what lives in core memory, not just a fixed skin.

+

Color bends to core memory. Mood reads the tone behind it.

+
+ +
) } +function CollectionsPanel({ collections = [], onOpenCollection, activeCollectionId }) { + if (!collections.length) return null + return ( +
+
+
+
auto collections
+

Memory is grouping itself.

+
+
+
+ {collections.map((collection) => ( + + ))} +
+
+ ) +} + 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() {
freeclaw memory cloud

Memory with a pulse.

-

Theme shifts with core memory. Tags are smarter. The archive should feel alive, not embalmed.

+

Now with merged tags, mood sensing, and self-assembling collections.

@@ -354,6 +427,10 @@ export default function App() {
latest spark
{spark || 'Loading…'}
{sparkSource || 'recent archive'}
+
+ + {heroStats.latest.wordCount.toLocaleString()} words +
@@ -364,10 +441,12 @@ export default function App() {
review queue{reviewCount ?? '—'}
- + )} + setActiveCollection((current) => current?.id === collection.id ? null : collection)} /> +
@@ -411,10 +490,10 @@ export default function App() {
search
-

{activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}

+

{activeCollection ? activeCollection.label : activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}

- {activeTag && ( - + {(activeTag || activeCollection) && ( + )}
@@ -434,11 +513,11 @@ export default function App() {