Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
95642bf
fix: add missing Soul-prefixed API exports for digital twin components
atomantic Apr 16, 2026
f17221a
feat: voice-dictatable Daily Log with Obsidian mirror + memory indexing
atomantic Apr 17, 2026
0504f09
fix: rename shadowed 'source' variable + drop unused imports
atomantic Apr 17, 2026
0a2ab99
address review: harden daily-log edges flagged by Copilot
atomantic Apr 17, 2026
e3b80d1
address review 2: journal deletion event, iso-date validation, dictat…
atomantic Apr 17, 2026
5fcebd3
address review 3: consolidate date validation, tighten append/setting…
atomantic Apr 17, 2026
f108dee
address review 4: per-entry journal events, defensive sockets, timezo…
atomantic Apr 17, 2026
d98da86
address review 5: null-safe settings edit, refresh obsidian path on c…
atomantic Apr 17, 2026
64853ba
address review 6: journals dry-run stats match real-run behavior
atomantic Apr 17, 2026
b1fac78
address review 7: de-dupe dictation toast, document single-user obsid…
atomantic Apr 17, 2026
2191980
address review 8: guard voice:text payload, force flag for manual obs…
atomantic Apr 17, 2026
3f60a17
address review 9: sync dictation date on nav, trim daily_log_read sum…
atomantic Apr 17, 2026
70bd566
address review 10: preserve unsaved edits, fix TTS latency, refresh s…
atomantic Apr 17, 2026
a10dad9
address review: voice dictation + daily log race/payload/guard fixes
atomantic Apr 17, 2026
4aed43f
address review: drop unused withStore, normalize segment source, refr…
atomantic Apr 17, 2026
50cd418
address review: slim listJournals default, vault-aware obsidian delete
atomantic Apr 17, 2026
ca471a0
address review: stale-fetch race, slim history, confirm-after-echo to…
atomantic Apr 17, 2026
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: 2 additions & 1 deletion .changelog/NEXT.md
Original file line number Diff line number Diff line change
@@ -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 `<vault>/<folder>/<YYYY-MM-DD>.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.
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/brain/constants.js
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
577 changes: 577 additions & 0 deletions client/src/components/brain/tabs/DailyLogTab.jsx

Large diffs are not rendered by default.

25 changes: 23 additions & 2 deletions client/src/components/voice/VoiceWidget.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Mic, MicOff, Brain, Volume2, Square, Trash2, ChevronDown, ChevronUp, Send, Infinity as InfinityIcon, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Mic, MicOff, Brain, Volume2, Square, Trash2, ChevronDown, ChevronUp, Send, Infinity as InfinityIcon, NotebookPen, X } from 'lucide-react';
import {
startCapture, stopCapture, interrupt, resetConversation, sendText, onVoiceEvent, isCapturing,
startContinuous, stopContinuous, isContinuous, whenPlaybackDrained, getVadLevel,
Expand Down Expand Up @@ -35,6 +36,7 @@ const warnIfQuiet = (peak) => {
};

export default function VoiceWidget() {
const navigate = useNavigate();
const [enabled, setEnabled] = useState(false);
const [hotkey, setHotkey] = useState('Space');
const [sttEngine, setSttEngine] = useState('whisper');
Expand All @@ -45,6 +47,7 @@ export default function VoiceWidget() {
const [draft, setDraft] = useState('');
const [interimTranscript, setInterimTranscript] = useState('');
const [expanded, setExpanded] = useState(false);
const [dictationActive, setDictationActive] = useState(false);
const [handsFree, setHandsFree] = useState(() => {
if (typeof window === 'undefined') return true;
const stored = window.localStorage.getItem(HANDS_FREE_KEY);
Expand Down Expand Up @@ -136,9 +139,21 @@ export default function VoiceWidget() {
toast.error(`Voice: ${d.message}`);
setStage(restState());
}),
onVoiceEvent('voice:navigate', (d) => {
if (d?.path && typeof d.path === 'string') navigate(d.path);
}),
onVoiceEvent('voice:dictation', (d) => {
setDictationActive((prev) => (prev === !!d?.enabled ? prev : !!d?.enabled));
}),
onVoiceEvent('voice:dailyLog:appended', (d) => {
if (d?.text) {
const preview = d.text.length > 60 ? `${d.text.slice(0, 60)}…` : d.text;
toast(`📓 +"${preview}"`);
}
}),
];
return () => offs.forEach((off) => off());
}, [enabled]);
}, [enabled, navigate]);

// Auto-scroll to bottom on new content
useEffect(() => {
Expand Down Expand Up @@ -361,6 +376,12 @@ export default function VoiceWidget() {
<Send size={14} />
</button>
</form>
{dictationActive && (
<div className="flex items-center gap-2 bg-port-accent/15 border border-port-accent/40 rounded-full px-3 py-1 text-xs text-port-accent shadow-lg">
<NotebookPen size={12} className="animate-pulse" />
Dictating to Daily Log — say &quot;stop dictation&quot; to end
</div>
)}
<div className="flex items-center gap-2 bg-port-card/95 backdrop-blur border border-port-border rounded-full pl-3 pr-1 py-1 shadow-lg">
<span className={`text-xs ${tone}`}>{label}</span>
{!useWebSpeech && handsFree && isContinuous() && (
Expand Down
3 changes: 3 additions & 0 deletions client/src/pages/Brain.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import DigestTab from '../components/brain/tabs/DigestTab';
import FeedsTab from '../components/brain/tabs/FeedsTab';
import TrustTab from '../components/brain/tabs/TrustTab';
import NotesTab from '../components/brain/tabs/NotesTab';
import DailyLogTab from '../components/brain/tabs/DailyLogTab';
import ConfigTab from '../components/brain/tabs/ConfigTab';

export default function Brain() {
Expand Down Expand Up @@ -55,6 +56,8 @@ export default function Brain() {
return <MemoryTab onRefresh={fetchData} />;
case 'notes':
return <NotesTab onRefresh={fetchData} />;
case 'daily-log':
return <DailyLogTab />;
case 'graph':
return <BrainGraph />;
case 'digest':
Expand Down
27 changes: 27 additions & 0 deletions client/src/services/apiBrain.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,30 @@ export const getBrainGraph = () => request('/brain/graph');

// Brain - Bridge Sync (brain data to CoS memory system)
export const syncBrainData = () => request('/brain/bridge-sync', { method: 'POST' });

// Brain - Daily Log
export const listDailyLogs = (options = {}) => {
const params = new URLSearchParams();
if (options.limit) params.set('limit', options.limit);
if (options.offset) params.set('offset', options.offset);
return request(`/brain/daily-log?${params}`);
};
export const getDailyLog = (date = 'today') => request(`/brain/daily-log/${encodeURIComponent(date)}`);
export const appendDailyLog = (date, text, source = 'text') => request(
`/brain/daily-log/${encodeURIComponent(date)}/append`,
{ method: 'POST', body: JSON.stringify({ text, source }) }
);
export const updateDailyLog = (date, content) => request(
`/brain/daily-log/${encodeURIComponent(date)}`,
{ method: 'PUT', body: JSON.stringify({ content }) }
);
export const deleteDailyLog = (date) => request(
`/brain/daily-log/${encodeURIComponent(date)}`,
{ method: 'DELETE' }
);
export const getDailyLogSettings = () => request('/brain/daily-log/settings');
export const updateDailyLogSettings = (settings) => request('/brain/daily-log/settings', {
method: 'PUT',
body: JSON.stringify(settings)
});
export const syncDailyLogsToObsidian = () => request('/brain/daily-log/sync-obsidian', { method: 'POST' });
13 changes: 9 additions & 4 deletions client/src/services/voiceClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,15 @@ export const stopCapture = async ({ submit = true } = {}) => {
return { mimeType: 'audio/wav', size: wav.byteLength, sourceSize: blob.size, peak };
};

export const sendText = (text) => {
export const sendText = (text, source = 'text') => {
const trimmed = (text || '').trim();
if (!trimmed) return;
stopPlayback();
socket.emit('voice:text', { text: trimmed });
socket.emit('voice:text', { text: trimmed, source });
};

export const setDictation = (enabled, date) => {
socket.emit('voice:dictation:set', { enabled: !!enabled, date: date || null });
};

export const interrupt = () => {
Expand Down Expand Up @@ -622,8 +626,9 @@ export const startWebSpeechCapture = ({ language, ...callbacks } = {}) => {
callbacks.onFinal?.(final);
// Any successful result resets the restart-failure counter.
webSpeechRestartFailures = 0;
// Send as text — server pipeline skips STT and goes straight to LLM
sendText(final);
// source='voice' so the server still treats this as a spoken utterance
// for dictation-mode routing — the text path otherwise bypasses it.
sendText(final, 'voice');
}
};

Expand Down
9 changes: 9 additions & 0 deletions server/lib/brainValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,12 @@ const brainSyncChangeSchema = z.object({
export const brainSyncPushSchema = z.object({
changes: z.array(brainSyncChangeSchema).min(1).max(1000)
});

// Daily log settings schema (PUT /api/brain/daily-log/settings body).
// Only these three keys are persisted — strict() rejects unknown keys so
// a typo or stray payload field can't corrupt the settings file.
export const dailyLogSettingsSchema = z.object({
obsidianVaultId: z.string().nullable().optional(),
obsidianFolder: z.string().optional(),
autoSync: z.boolean().optional()
}).strict();
119 changes: 118 additions & 1 deletion server/routes/brain.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ import {
linkUpdateInputSchema,
linksQuerySchema,
brainSyncQuerySchema,
brainSyncPushSchema
brainSyncPushSchema,
dailyLogSettingsSchema
} from '../lib/brainValidation.js';
import * as githubCloner from '../services/githubCloner.js';
import { getBrainGraphData } from '../services/brainGraph.js';
import { syncAllBrainData } from '../services/brainMemoryBridge.js';
import * as brainSyncLog from '../services/brainSyncLog.js';
import * as brainSync from '../services/brainSync.js';
import * as journal from '../services/brainJournal.js';

const router = Router();

Expand Down Expand Up @@ -767,4 +769,119 @@ router.post('/sync', asyncHandler(async (req, res) => {
res.json(result);
}));

// =============================================================================
// DAILY LOG
// =============================================================================

// Resolve the :date route param: either 'today' → current local date, or a
// real ISO YYYY-MM-DD calendar day. Delegates to journal.isIsoDate so the
// date rules stay in one place (service layer) and can't drift between
// routes and internal callers.
const resolveJournalDate = async (date) => {
if (!date || date === 'today') return journal.getToday();
if (!journal.isIsoDate(date)) {
throw new ServerError('Invalid date. Expected "today" or YYYY-MM-DD.', {
status: 400,
code: 'BAD_REQUEST',
});
}
return date;
};

/**
* GET /api/brain/daily-log
* List daily log entries (most recent first)
*/
router.get('/daily-log', asyncHandler(async (req, res) => {
// Clamp pagination: negative or zero limit / negative offset would slice
// unpredictably (or from the end of the array). Match the convention used
// by other paginated brain routes.
const parsedLimit = parseInt(req.query.limit, 10);
const parsedOffset = parseInt(req.query.offset, 10);
const limit = Math.min(Math.max(Number.isNaN(parsedLimit) ? 50 : parsedLimit, 1), 200);
const offset = Math.max(Number.isNaN(parsedOffset) ? 0 : parsedOffset, 0);
// Opt-in to full entries; default is slim summaries (date + segmentCount +
// obsidianPath) so the sidebar doesn't pull every day's content on load.
const includeContent = req.query.includeContent === '1' || req.query.includeContent === 'true';
const result = await journal.listJournals({ limit, offset, includeContent });
res.json(result);
}));

/**
* GET /api/brain/daily-log/settings
* Get daily log configuration (obsidian vault/folder, auto-sync)
*/
router.get('/daily-log/settings', asyncHandler(async (req, res) => {
const settings = await journal.getSettings();
res.json(settings);
}));

/**
* PUT /api/brain/daily-log/settings
*/
router.put('/daily-log/settings', asyncHandler(async (req, res) => {
const data = validateRequest(dailyLogSettingsSchema, req.body || {});
const next = await journal.updateSettings(data);
res.json(next);
}));

/**
* POST /api/brain/daily-log/sync-obsidian
* Re-mirror every existing entry into the currently-configured Obsidian vault.
*/
router.post('/daily-log/sync-obsidian', asyncHandler(async (req, res) => {
const stats = await journal.resyncAllToObsidian();
res.json(stats);
}));

/**
* GET /api/brain/daily-log/:date (accepts 'today')
*/
router.get('/daily-log/:date', asyncHandler(async (req, res) => {
const date = await resolveJournalDate(req.params.date);
const entry = await journal.getJournal(date);
res.json({ date, entry });
}));

/**
* POST /api/brain/daily-log/:date/append — append a text segment
*/
router.post('/daily-log/:date/append', asyncHandler(async (req, res) => {
const date = await resolveJournalDate(req.params.date);
const { text, source } = req.body || {};
// Trim-check here too so a whitespace-only payload doesn't no-op all the
// way through appendJournal() and still return a 200 — clients would read
// that as a successful append.
if (typeof text !== 'string' || text.trim().length === 0) {
Comment thread
atomantic marked this conversation as resolved.
throw new ServerError('text is required', { status: 400, code: 'BAD_REQUEST' });
}
const entry = await journal.appendJournal(date, text, { source });
res.json({ date, entry });
}));

/**
* PUT /api/brain/daily-log/:date — full content replace
*/
router.put('/daily-log/:date', asyncHandler(async (req, res) => {
const date = await resolveJournalDate(req.params.date);
const { content } = req.body || {};
if (typeof content !== 'string') {
throw new ServerError('content is required', { status: 400, code: 'BAD_REQUEST' });
}
const entry = await journal.setJournalContent(date, content);
res.json({ date, entry });
}));

/**
* DELETE /api/brain/daily-log/:date
*/
router.delete('/daily-log/:date', asyncHandler(async (req, res) => {
const date = await resolveJournalDate(req.params.date);
const deleted = await journal.deleteJournal(date);
if (!deleted) {
throw new ServerError('Journal not found', { status: 404, code: 'NOT_FOUND' });
}
res.status(204).send();
}));

export default router;
Loading