From 95642bfb5b996f5f7e1072e5936a638c31fc20d7 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Thu, 16 Apr 2026 09:10:56 -0700 Subject: [PATCH 01/17] fix: add missing Soul-prefixed API exports for digital twin components --- .changelog/NEXT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index e50ede17..89b7086c 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -1,5 +1,6 @@ ## Added +- **Daily Log — voice-dictatable diary in the Brain section** — new `Brain → Daily Log` tab at `/brain/daily-log` for free-form typed or dictated daily entries. One entry per date, stored in `data/brain/journals.json` with per-segment source metadata (`text` vs `voice`). Five voice tools land in the CoS: `daily_log_open` ("open my daily log" — navigates + optionally starts dictation), `daily_log_start_dictation` / `daily_log_stop_dictation`, `daily_log_append` (one-shot append without dictation mode), and `daily_log_read` (reads the entry verbatim via TTS). Dictation mode short-circuits the LLM at the server — every voice utterance appends straight to the log until the user says "stop dictation" — so the CoS stops talking back while you ramble. Tools can now emit client-side side effects (`voice:navigate`, `voice:dictation`); the Voice widget listens and routes the browser + surfaces a "Dictating to Daily Log" chip. Optional Obsidian mirror: each entry auto-writes to `//.md` when a vault is configured in the tab's settings panel (reuses the existing Notes vault registry), picking up iCloud backup for free. Entries flow through `brainMemoryBridge.js` (new `journals` brain type, `daily-log` category) so the CoS memory system vector-embeds each day and agents can semantically search across the log history. New routes: `GET/PUT/DELETE /api/brain/daily-log/:date`, `POST /api/brain/daily-log/:date/append`, `GET/PUT /api/brain/daily-log/settings`, `POST /api/brain/daily-log/sync-obsidian`. - **Voice CoS tool expansion — round two** — nine more hands-free tools registered in `server/services/voice/tools.js`, bringing the total to 14: - `brain_list_recent` — "Read me my last five captures"; returns the newest inbox entries with date + text. - `meatspace_log_weight` — "Log my weight at 180"; accepts `lb` (default) or `kg`, auto-converts to lb before persisting, defaults to today. From f17221aabc0ebbf9b954ac3489ae0a74b6b32e35 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Fri, 17 Apr 2026 10:38:02 -0700 Subject: [PATCH 02/17] feat: voice-dictatable Daily Log with Obsidian mirror + memory indexing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Daily Log tab to the Brain section for free-form typed or dictated daily entries. One entry per date, stored in data/brain/journals.json with per-segment source metadata. Voice integration: - 5 new CoS tools: daily_log_open, daily_log_start_dictation, daily_log_stop_dictation, daily_log_append, daily_log_read - Tools emit client-side side effects (voice:navigate, voice:dictation) via a new ctx.sideEffects channel in the tool dispatcher - Dictation mode short-circuits the LLM/TTS pipeline at the server — every voice utterance appends straight to the log until "stop dictation" - Web Speech STT mode honored via source='voice' hint on voice:text Obsidian mirror: - Optional per-date markdown file under //YYYY-MM-DD.md, reusing the existing Notes vault registry (iCloud backup comes for free) - Obsidian writes are fire-and-forget so dictation isn't blocked on iCloud CoS memory bridge: - New journals brain type (daily-log category) in brainMemoryBridge.js - Each entry vector-embeds into the memory system on change so agents can semantically search across daily logs --- client/src/components/brain/constants.js | 3 +- .../src/components/brain/tabs/DailyLogTab.jsx | 421 ++++++++++++++++++ client/src/components/voice/VoiceWidget.jsx | 25 +- client/src/pages/Brain.jsx | 3 + client/src/services/apiBrain.js | 27 ++ client/src/services/voiceClient.js | 13 +- server/routes/brain.js | 91 ++++ server/routes/brain.test.js | 98 ++++ server/services/brainJournal.js | 252 +++++++++++ server/services/brainJournal.test.js | 143 ++++++ server/services/brainMemoryBridge.js | 50 ++- server/services/voice/pipeline.js | 51 ++- server/services/voice/tools.js | 117 ++++- server/sockets/voice.js | 24 +- 14 files changed, 1300 insertions(+), 18 deletions(-) create mode 100644 client/src/components/brain/tabs/DailyLogTab.jsx create mode 100644 server/services/brainJournal.js create mode 100644 server/services/brainJournal.test.js diff --git a/client/src/components/brain/constants.js b/client/src/components/brain/constants.js index 231fe275..36a157e1 100644 --- a/client/src/components/brain/constants.js +++ b/client/src/components/brain/constants.js @@ -1,8 +1,9 @@ -import { MessageSquare, Database, Calendar, Rss, Shield, Users, FolderKanban, Lightbulb, ClipboardList, Settings, Link2, BookOpen, Network, FileText } from 'lucide-react'; +import { MessageSquare, Database, Calendar, Rss, Shield, Users, FolderKanban, Lightbulb, ClipboardList, Settings, Link2, BookOpen, Network, FileText, NotebookPen } from 'lucide-react'; // Main navigation tabs export const TABS = [ { id: 'inbox', label: 'Inbox', icon: MessageSquare }, + { id: 'daily-log', label: 'Daily Log', icon: NotebookPen }, { id: 'links', label: 'Links', icon: Link2 }, { id: 'memory', label: 'Memory', icon: Database }, { id: 'notes', label: 'Notes', icon: FileText }, diff --git a/client/src/components/brain/tabs/DailyLogTab.jsx b/client/src/components/brain/tabs/DailyLogTab.jsx new file mode 100644 index 00000000..041b3b69 --- /dev/null +++ b/client/src/components/brain/tabs/DailyLogTab.jsx @@ -0,0 +1,421 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + BookOpen, ChevronLeft, ChevronRight, Mic, MicOff, RefreshCw, Save, Volume2, Settings, + Plus, Trash2, X, FolderOpen, CloudUpload +} from 'lucide-react'; +import * as api from '../../../services/api'; +import { getNotesVaults } from '../../../services/apiNotes'; +import toast from '../../ui/Toast'; +import { onVoiceEvent, sendText, setDictation as setVoiceDictation } from '../../../services/voiceClient'; + +const upsertHistory = (prev, entry) => { + const others = prev.filter((h) => h.date !== entry.date); + return [entry, ...others].sort((a, b) => b.date.localeCompare(a.date)); +}; + +// ISO YYYY-MM-DD in the user's local timezone +const localToday = () => { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; + +const shiftDate = (iso, days) => { + const [y, m, d] = iso.split('-').map(Number); + const date = new Date(Date.UTC(y, m - 1, d)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +}; + +export default function DailyLogTab() { + const [date, setDate] = useState(localToday()); + const [entry, setEntry] = useState(null); + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [quickAppend, setQuickAppend] = useState(''); + const [appending, setAppending] = useState(false); + const [history, setHistory] = useState([]); + const [dictation, setDictation] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [settings, setSettings] = useState(null); + const [vaults, setVaults] = useState([]); + const [confirmDelete, setConfirmDelete] = useState(false); + const [syncing, setSyncing] = useState(false); + const editorRef = useRef(null); + + const dirty = content !== (entry?.content || ''); + + const loadEntry = useCallback(async (d, { silent = false } = {}) => { + if (!silent) setLoading(true); + const res = await api.getDailyLog(d).catch(() => null); + const data = res?.entry || null; + setEntry(data); + setContent(data?.content || ''); + if (!silent) setLoading(false); + }, []); + + const loadHistory = useCallback(async () => { + const res = await api.listDailyLogs({ limit: 60 }).catch(() => null); + setHistory(res?.records || []); + }, []); + + const loadSettings = useCallback(async () => { + const [s, v] = await Promise.all([ + api.getDailyLogSettings().catch(() => null), + getNotesVaults().catch(() => []), + ]); + if (s) setSettings(s); + setVaults(v || []); + }, []); + + useEffect(() => { loadEntry(date); }, [date, loadEntry]); + useEffect(() => { loadHistory(); loadSettings(); }, [loadHistory, loadSettings]); + + useEffect(() => { + const onAppend = (payload) => { + const appended = payload?.entry; + if (!appended) return; + setHistory((prev) => upsertHistory(prev, appended)); + if (appended.date === date) { + setEntry(appended); + setContent(appended.content || ''); + } + }; + const onDictation = (payload) => { + setDictation((prev) => (prev === !!payload?.enabled ? prev : !!payload?.enabled)); + if (payload?.date && payload.date !== date) setDate(payload.date); + if (payload?.enabled) toast('Dictation on — speak your log.', { icon: '🎙️' }); + }; + const offs = [ + onVoiceEvent('voice:dailyLog:appended', onAppend), + onVoiceEvent('voice:dictation', onDictation), + ]; + return () => offs.forEach((off) => off()); + }, [date]); + + const applyEntry = (next) => { + setEntry(next); + setContent(next.content || ''); + setHistory((prev) => upsertHistory(prev, next)); + }; + + const handleSave = async () => { + setSaving(true); + const res = await api.updateDailyLog(date, content).catch(() => null); + setSaving(false); + if (!res?.entry) { + toast.error('Save failed'); + return; + } + applyEntry(res.entry); + toast.success('Saved'); + }; + + const handleAppend = async () => { + const text = quickAppend.trim(); + if (!text) return; + setAppending(true); + const res = await api.appendDailyLog(date, text, 'text').catch(() => null); + setAppending(false); + if (!res?.entry) { + toast.error('Append failed'); + return; + } + applyEntry(res.entry); + setQuickAppend(''); + }; + + const toggleDictation = () => { + const next = !dictation; + setDictation(next); + setVoiceDictation(next, date); + if (next) { + toast('Dictation on — speak your log. Say "stop dictation" to end.', { icon: '🎙️' }); + } else { + toast('Dictation off.', { icon: '🔇' }); + } + }; + + // Route the read-back through the voice assistant so its TTS pipeline fires + // — the browser TTS APIs would skip the project's Kokoro/Piper voice. + const readBack = () => { + const body = content.trim(); + if (!body) { + toast('Daily log is empty.', { icon: '📖' }); + return; + } + sendText(`Read this back to me verbatim, exactly as written, with no commentary:\n\n${body}`); + }; + + const handleDelete = async () => { + await api.deleteDailyLog(date).catch(() => null); + toast.success('Deleted'); + setConfirmDelete(false); + setEntry(null); + setContent(''); + setHistory((prev) => prev.filter((h) => h.date !== date)); + }; + + const handleSyncObsidian = async () => { + setSyncing(true); + const res = await api.syncDailyLogsToObsidian().catch(() => null); + setSyncing(false); + if (res) toast.success(`Synced ${res.synced} entries to Obsidian`); + else toast.error('Sync failed'); + }; + + const saveSettings = async (partial) => { + const next = await api.updateDailyLogSettings(partial).catch(() => null); + if (next) { + setSettings(next); + toast.success('Settings saved'); + } + }; + + const isToday = date === localToday(); + const segmentCount = entry?.segments?.length || 0; + + const dateLabel = useMemo(() => { + try { + return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' + }); + } catch { return date; } + }, [date]); + + return ( +
+ {/* Left: history + settings */} +
+
+ + Daily Log + +
+ + {showSettings && ( +
+
+ + +
+
+ + setSettings((s) => ({ ...s, obsidianFolder: e.target.value }))} + onBlur={(e) => saveSettings({ obsidianFolder: e.target.value })} + className="w-full bg-port-bg border border-port-border rounded px-2 py-1.5 text-sm text-white" + placeholder="Daily Log" + /> +
+ + +

+ Entries embed into the Chief-of-Staff memory system automatically so agents can search + across daily logs. +

+
+ )} + +
+ {history.length === 0 ? ( +
No entries yet — start today.
+ ) : ( +
+ {history.map((h) => { + const active = h.date === date; + return ( + + ); + })} +
+ )} +
+
+ + {/* Right: editor */} +
+
+ + setDate(e.target.value || localToday())} + className="bg-port-bg border border-port-border rounded px-2 py-1 text-sm text-white" + /> + + {!isToday && ( + + )} +
+
{dateLabel}
+
+ {segmentCount} segment{segmentCount === 1 ? '' : 's'} + {entry?.obsidianPath ? ` · ${entry.obsidianPath}` : ''} +
+
+ + + + +
+ + {dictation && ( +
+ + Dictation on — speak your log. Say "stop dictation" to end. + The voice assistant is NOT replying — every utterance appends to this entry. +
+ )} + + {confirmDelete && ( +
+ Delete the entry for {date} permanently? + + +
+ )} + + {loading ? ( +
+ +
+ ) : ( + <> +