diff --git a/docker-compose.apple.yaml b/docker-compose.apple.yaml index 895b635..4a6997e 100644 --- a/docker-compose.apple.yaml +++ b/docker-compose.apple.yaml @@ -88,6 +88,7 @@ services: - ./.env environment: - LIVEKIT_URL=ws://livekit:7880 + - CAAL_MEMORY_DIR=/app/data # Point to mlx-audio (single service for STT + TTS) - SPEACHES_URL=${MLX_AUDIO_URL:-http://host.docker.internal:8001} - KOKORO_URL=${MLX_AUDIO_URL:-http://host.docker.internal:8001} diff --git a/docker-compose.cpu.yaml b/docker-compose.cpu.yaml index 454fcce..ef2600a 100644 --- a/docker-compose.cpu.yaml +++ b/docker-compose.cpu.yaml @@ -80,6 +80,7 @@ services: environment: - LIVEKIT_URL=ws://livekit:7880 - SPEACHES_URL=http://speaches:8000 + - CAAL_MEMORY_DIR=/app/data volumes: - caal-memory:/app/data - caal-config:/app/config # Runtime config (settings.json, mcp_servers.json) diff --git a/docker-compose.yaml b/docker-compose.yaml index 190fb28..443c459 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -76,6 +76,7 @@ services: - LIVEKIT_URL=ws://livekit:7880 - SPEACHES_URL=http://speaches:8000 - KOKORO_URL=http://kokoro:8880 + - CAAL_MEMORY_DIR=/app/data volumes: - caal-memory:/app/data - caal-config:/app/config # Runtime config (settings.json, mcp_servers.json) diff --git a/entrypoint.sh b/entrypoint.sh index 1b58e9c..17e68f9 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,11 +5,16 @@ set -e CONFIG_DIR="/app/config" +DATA_DIR="/app/data" # Ensure config directory exists and is writable by agent mkdir -p "$CONFIG_DIR" chown agent:agent "$CONFIG_DIR" +# Ensure data directory exists and is writable by agent (memory persistence) +mkdir -p "$DATA_DIR" +chown agent:agent "$DATA_DIR" + # settings.json - copy default if missing if [ ! -f "$CONFIG_DIR/settings.json" ]; then echo "Creating settings.json from defaults..." @@ -24,9 +29,16 @@ if [ ! -f "$CONFIG_DIR/mcp_servers.json" ]; then chown agent:agent "$CONFIG_DIR/mcp_servers.json" fi +# registry_cache.json - create empty if missing +if [ ! -f "$CONFIG_DIR/registry_cache.json" ]; then + echo '{}' > "$CONFIG_DIR/registry_cache.json" + chown agent:agent "$CONFIG_DIR/registry_cache.json" +fi + # Create symlinks from /app to config files (for code that expects them in /app) ln -sf "$CONFIG_DIR/settings.json" /app/settings.json ln -sf "$CONFIG_DIR/mcp_servers.json" /app/mcp_servers.json +ln -sf "$CONFIG_DIR/registry_cache.json" /app/registry_cache.json # Drop privileges and execute the main command as agent user exec gosu agent "$@" diff --git a/frontend/app/api/memory/[key]/route.ts b/frontend/app/api/memory/[key]/route.ts new file mode 100644 index 0000000..27b11ed --- /dev/null +++ b/frontend/app/api/memory/[key]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const WEBHOOK_URL = process.env.WEBHOOK_URL || 'http://agent:8889'; + +interface RouteParams { + params: Promise<{ key: string }>; +} + +/** + * GET /api/memory/[key] - Get a single memory entry + */ +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const { key } = await params; + const res = await fetch(`${WEBHOOK_URL}/memory/${encodeURIComponent(key)}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`[/api/memory/${key}] Backend error:`, res.status, text); + return NextResponse.json({ error: text || 'Backend error' }, { status: res.status }); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[/api/memory/[key]] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/memory/[key] - Delete a single memory entry + */ +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const { key } = await params; + const res = await fetch(`${WEBHOOK_URL}/memory/${encodeURIComponent(key)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`[/api/memory/${key}] Backend error:`, res.status, text); + return NextResponse.json({ error: text || 'Backend error' }, { status: res.status }); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[/api/memory/[key]] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/memory/route.ts b/frontend/app/api/memory/route.ts new file mode 100644 index 0000000..ab43e22 --- /dev/null +++ b/frontend/app/api/memory/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const WEBHOOK_URL = process.env.WEBHOOK_URL || 'http://agent:8889'; + +/** + * GET /api/memory - List all memory entries + */ +export async function GET() { + try { + const res = await fetch(`${WEBHOOK_URL}/memory`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res.ok) { + const text = await res.text(); + console.error('[/api/memory] Backend error:', res.status, text); + return NextResponse.json({ error: text || 'Backend error' }, { status: res.status }); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[/api/memory] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} + +/** + * POST /api/memory - Store a new memory entry + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + const res = await fetch(`${WEBHOOK_URL}/memory`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + console.error('[/api/memory] Backend error:', res.status, text); + return NextResponse.json({ error: text || 'Backend error' }, { status: res.status }); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[/api/memory] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/memory - Clear all memory entries + */ +export async function DELETE() { + try { + const res = await fetch(`${WEBHOOK_URL}/memory`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res.ok) { + const text = await res.text(); + console.error('[/api/memory] Backend error:', res.status, text); + return NextResponse.json({ error: text || 'Backend error' }, { status: res.status }); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[/api/memory] Error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/frontend/components/app/view-controller.tsx b/frontend/components/app/view-controller.tsx index 655f13b..c419fa9 100644 --- a/frontend/components/app/view-controller.tsx +++ b/frontend/components/app/view-controller.tsx @@ -6,6 +6,7 @@ import { useSessionContext } from '@livekit/components-react'; import type { AppConfig } from '@/app-config'; import { SessionView } from '@/components/app/session-view'; import { WelcomeView } from '@/components/app/welcome-view'; +import { MemoryPanel } from '@/components/memory'; import { SettingsPanel } from '@/components/settings/settings-panel'; import { ToolsPanel } from '@/components/tools'; @@ -38,6 +39,7 @@ export function ViewController({ appConfig }: ViewControllerProps) { const { isConnected, start } = useSessionContext(); const [settingsOpen, setSettingsOpen] = useState(false); const [toolsOpen, setToolsOpen] = useState(false); + const [memoryOpen, setMemoryOpen] = useState(false); return ( <> @@ -50,6 +52,7 @@ export function ViewController({ appConfig }: ViewControllerProps) { onStartCall={start} onOpenSettings={() => setSettingsOpen(true)} onOpenTools={() => setToolsOpen(true)} + onOpenMemory={() => setMemoryOpen(true)} /> )} {/* Session view */} @@ -63,6 +66,9 @@ export function ViewController({ appConfig }: ViewControllerProps) { {/* Tools panel */} setToolsOpen(false)} /> + + {/* Memory panel */} + setMemoryOpen(false)} /> ); } diff --git a/frontend/components/app/welcome-view.tsx b/frontend/components/app/welcome-view.tsx index 9778b3f..7cb4b45 100644 --- a/frontend/components/app/welcome-view.tsx +++ b/frontend/components/app/welcome-view.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { Gear, Wrench } from '@phosphor-icons/react/dist/ssr'; +import { Brain, Gear, Wrench } from '@phosphor-icons/react/dist/ssr'; import { Button } from '@/components/livekit/button'; function WelcomeImage() { @@ -26,12 +26,14 @@ interface WelcomeViewProps { onStartCall: () => void; onOpenSettings?: () => void; onOpenTools?: () => void; + onOpenMemory?: () => void; } export const WelcomeView = ({ onStartCall, onOpenSettings, onOpenTools, + onOpenMemory, ref, }: React.ComponentProps<'div'> & WelcomeViewProps) => { const t = useTranslations('Welcome'); @@ -41,6 +43,15 @@ export const WelcomeView = ({
{/* Top right buttons */}
+ {onOpenMemory && ( + + )} {onOpenTools && ( +
+ + + {/* Content */} +
+ {loading ? ( +
{tCommon('loading')}
+ ) : entries.length === 0 ? ( +
+ +

{t('panel.emptyState')}

+

{t('panel.emptyHint')}

+
+ ) : ( +
+ {entries.map((entry) => ( +
{ + setSelectedEntry(entry); + setEditing(false); + }} + > +
+
+ {entry.key} + +
+

+ {formatValue(entry.value)} +

+
+ {formatTimeAgo(entry.stored_at)} + {entry.expires_at && ( + + + {formatExpiry(entry.expires_at)} + + )} +
+
+ +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+ + {/* Detail modal */} + {selectedEntry && ( +
+
setSelectedEntry(null)} /> +
+ + +
+

{selectedEntry.key}

+ +
+ +
+
+ + {!editing && ( + + )} +
+ {editing ? ( +
+