diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index e50ede17..abe4ebe1 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -1,6 +1,7 @@ ## Added -- **Voice CoS tool expansion — round two** — nine more hands-free tools registered in `server/services/voice/tools.js`, bringing the total to 14: +- **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`, further expanding the Voice CoS toolset: - `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. - `goal_list` — "What are my goals?"; lists active goals ordered by urgency with horizon, category, and progress percent. 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..2b05617e --- /dev/null +++ b/client/src/components/brain/tabs/DailyLogTab.jsx @@ -0,0 +1,577 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + BookOpen, ChevronLeft, ChevronRight, Mic, MicOff, RefreshCw, Save, Volume2, Settings, + Plus, Trash2, 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'; + +// Slim shape kept in the sidebar history list — full `content`/`segments` +// would accumulate as the log grows and the sidebar never renders them. +const toHistorySummary = (entry) => ({ + id: entry.id, + date: entry.date, + updatedAt: entry.updatedAt, + obsidianPath: entry.obsidianPath || null, + segmentCount: typeof entry.segmentCount === 'number' + ? entry.segmentCount + : (Array.isArray(entry.segments) ? entry.segments.length : 0), +}); + +const upsertHistory = (prev, entry) => { + const summary = toHistorySummary(entry); + const others = prev.filter((h) => h.date !== summary.date); + return [summary, ...others].sort((a, b) => b.date.localeCompare(a.date)); +}; + +// ISO YYYY-MM-DD fallback — browser local timezone. Used only as an initial +// value before the backend responds with its canonical "today" (which honors +// the user's configured timezone, so remote/VPN access doesn't desync the +// day). Replaced on mount via a GET /daily-log/today. +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()); + // Backend today — resolved via GET /daily-log/today on mount so the + // "Today" button, disabled-forward-nav check, and isToday chip all match + // the server's timezone. Falls back to localToday() until fetched. + const [serverToday, setServerToday] = 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); + // Ref mirror of the dirty flag so the socket event handler can check it + // without adding `content`/`entry` to the effect's dependency list + // (which would re-subscribe on every keystroke). + const dirtyRef = useRef(false); + // Monotonic counter of outstanding loadEntry() calls so an older fetch + // resolving after a newer one can't overwrite the entry state for the + // wrong date (common when prev/next is mashed or the server-today fetch + // lands after the user has already picked a different date). + const loadRequestRef = useRef(0); + // Tracks the dictation state the user just requested (null when idle). + // Set by toggleDictation; consumed by the voice:dictation echo handler to + // fire the success toast, or by the voice:error handler to revert and + // surface a failure toast. Without this, clicking toggle while voice is + // disabled would show an optimistic "Dictation on" that never actually + // happened on the server. + const pendingDictationRef = useRef(null); + + const dirty = content !== (entry?.content || ''); + dirtyRef.current = dirty; + + const loadEntry = useCallback(async (d, { silent = false } = {}) => { + if (!silent) setLoading(true); + const reqId = ++loadRequestRef.current; + const res = await api.getDailyLog(d).catch(() => null); + if (reqId !== loadRequestRef.current) return; + 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]); + + // Keep the server's dictation target date aligned with the UI while + // dictation is active — otherwise navigating to an earlier day (prev/next + // button, date picker) would still route new voice utterances into the + // day that was active when the user toggled dictation on. + useEffect(() => { + if (dictation) setVoiceDictation(true, date); + }, [date, dictation]); + + // Ask the server for its canonical "today" so a user in a different timezone + // than the browser (remote/VPN access) doesn't open the tab on the wrong day. + useEffect(() => { + let cancelled = false; + api.getDailyLog('today').then((res) => { + if (cancelled || !res?.date) return; + setServerToday(res.date); + // If we initialized with a wrong local date, hop to the real one. + if (date === localToday() && res.date !== date) setDate(res.date); + }).catch(() => null); + return () => { cancelled = true; }; + // Only on mount — we intentionally don't re-run when date changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Server sends only the delta ({date, text, segment, segmentCount, + // updatedAt}) — patch local state to avoid repeatedly shipping the + // full entry over the socket as the day grows. + const onAppend = (payload) => { + if (!payload?.date || typeof payload.text !== 'string') return; + const { date: appendedDate, text: appendedText, segment, segmentCount, updatedAt } = payload; + // Full-entry patch — used for the right-hand editor/preview where + // `segments[]` and `content` must be present. Safe against either a + // summary-only previous state or a full entry. + const patchFullEntry = (prev) => { + if (!prev || prev.date !== appendedDate) { + return { + date: appendedDate, + content: appendedText, + segments: segment ? [segment] : [], + segmentCount: segmentCount ?? (segment ? 1 : 0), + updatedAt: updatedAt || prev?.updatedAt, + obsidianPath: prev?.obsidianPath || null, + }; + } + const nextContent = prev.content + ? `${prev.content.replace(/\s+$/, '')}\n\n${appendedText}` + : appendedText; + const nextSegments = segment ? [...(prev.segments || []), segment] : (prev.segments || []); + return { + ...prev, + content: nextContent, + segments: nextSegments, + segmentCount: segmentCount ?? nextSegments.length, + updatedAt: updatedAt || prev.updatedAt, + }; + }; + setHistory((prev) => { + const existing = prev.find((h) => h.date === appendedDate); + // Sidebar entries are summaries — only carry metadata, not content. + // Patch just what the sidebar renders (segmentCount, updatedAt, + // obsidianPath) to keep memory and renders cheap. + const patched = existing + ? { ...existing, segmentCount: segmentCount ?? (existing.segmentCount ?? 0) + 1, updatedAt: updatedAt || existing.updatedAt } + : { + date: appendedDate, + segmentCount: segmentCount ?? 1, + updatedAt: updatedAt || new Date().toISOString(), + obsidianPath: null, + }; + return upsertHistory(prev, patched); + }); + if (appendedDate === date) { + setEntry((prev) => patchFullEntry(prev)); + // Only sync the textarea when the user has no unsaved edits — + // otherwise an incoming voice segment would clobber whatever they're + // in the middle of typing. The entry state still updates so the + // segment count badge reflects the append. + if (!dirtyRef.current) { + setContent((prevContent) => (prevContent + ? `${prevContent.replace(/\s+$/, '')}\n\n${appendedText}` + : appendedText)); + } else { + toast('Voice segment appended while you were editing — save or refresh to see it.', { icon: '📝' }); + } + } + }; + const onDictation = (payload) => { + const nextEnabled = !!payload?.enabled; + setDictation((prev) => (prev === nextEnabled ? prev : nextEnabled)); + if (payload?.date && payload.date !== date) setDate(payload.date); + // If this echo is the server's response to a user-initiated toggle, + // confirm success with the appropriate toast. Voice-tool-initiated + // changes (no pending ref set) are confirmed by the CoS reply, so + // we stay quiet. + const requested = pendingDictationRef.current; + if (requested !== null && nextEnabled === requested) { + pendingDictationRef.current = null; + if (nextEnabled) { + toast('Dictation on — speak your log. Say "stop dictation" to end.', { icon: '🎙️' }); + } else { + toast('Dictation off.', { icon: '🔇' }); + } + } + }; + // A voice:error with stage='dictation' while a toggle is in flight + // means the server rejected the change (most commonly: voice mode is + // disabled). Revert the optimistic local state and surface a failure + // toast. Unrelated voice:error stages (turn/text) are handled by the + // VoiceWidget's own listener — don't clobber our pending dictation + // state on those. + const onVoiceError = (err) => { + if (pendingDictationRef.current !== null && err?.stage === 'dictation') { + pendingDictationRef.current = null; + setDictation(false); + toast.error('Voice mode is disabled — can\'t enter dictation. Enable it in Settings → Voice.'); + } + }; + const offs = [ + onVoiceEvent('voice:dailyLog:appended', onAppend), + onVoiceEvent('voice:dictation', onDictation), + onVoiceEvent('voice:error', onVoiceError), + ]; + 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; + // Optimistic local flip for responsive UI; the success toast waits for + // the server echo (voice:dictation) and a voice:error revert will undo + // this if the server rejected the change (e.g. voice mode disabled). + pendingDictationRef.current = next; + setDictation(next); + setVoiceDictation(next, date); + }; + + // 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. + // + // The socket's MAX_TEXT_LEN cap (4000 chars) would reject any reasonably + // full log if we inlined the content, so for long entries we delegate to + // the daily_log_read tool and let the LLM speak the server-returned body. + // Short logs still get inlined so the model can't add commentary or + // accidentally skip content by summarizing the tool result. + const READ_BACK_INLINE_LIMIT = 3800; // leaves room for prompt scaffolding under MAX_TEXT_LEN + const readBack = () => { + const body = content.trim(); + if (!body) { + toast('Daily log is empty.', { icon: '📖' }); + return; + } + if (body.length <= READ_BACK_INLINE_LIMIT) { + sendText(`Read this back to me verbatim, exactly as written, with no commentary:\n\n${body}`); + } else { + sendText(`Use the daily_log_read tool for ${date} and speak the full returned content aloud verbatim — no summarization, no commentary, just read it exactly as written.`); + } + }; + + const handleDelete = async () => { + const ok = await api.deleteDailyLog(date).then(() => true, () => false); + if (!ok) { + toast.error('Delete failed'); + return; + } + 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 === serverToday; + const segmentCount = entry?.segments?.length ?? entry?.segmentCount ?? 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 || serverToday)} + 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 ? ( +
+ +
+ ) : ( + <> +