diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74465e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +client/build/ +client/.svelte-kit/ +target/ +classes/ + +# Database +*.db +*.sqlite + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +*.iml +.vscode/ +*.swp +*.swo + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Clojure +.cpcache/ +.nrepl-port +.lsp/ +.clj-kondo/ + +# PWA +client/dev-dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a60391a --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Spiceflow + +AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode sessions. + +> "The spice must flow." + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐ +│ Claude Code │◀───▶│ Spiceflow Server │◀───▶│ PWA Client │ +│ (CLI) │ │ (Clojure) │ │ (Svelte) │ +└─────────────────┘ │ │ └─────────────────┘ + │ ┌─────────────────┐ │ +┌─────────────────┐ │ │ SQLite + DB │ │ +│ OpenCode │◀───▶│ │ Abstraction │ │ +│ (CLI) │ │ │ WebSocket/SSE │ │ +└─────────────────┘ │ └─────────────────┘ │ + └─────────────────────────┘ +``` + +## Quick Start + +### Prerequisites + +- Clojure CLI (deps.edn) +- Node.js 18+ and pnpm +- SQLite + +### Server + +```bash +cd server +clj -M:run +``` + +The server will start on http://localhost:3000. + +### Client + +```bash +cd client +pnpm install +pnpm dev +``` + +The client dev server will start on http://localhost:5173 with proxy to the API. + +### Production Build + +```bash +# Build client +cd client +pnpm build + +# Run server (serves static files from client/build) +cd ../server +clj -M:run +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/health` | Health check | +| GET | `/api/sessions` | List all tracked sessions | +| POST | `/api/sessions` | Create/import a session | +| GET | `/api/sessions/:id` | Get session with messages | +| DELETE | `/api/sessions/:id` | Delete a session | +| POST | `/api/sessions/:id/send` | Send message to session | +| GET | `/api/discover/claude` | Discover Claude Code sessions | +| GET | `/api/discover/opencode` | Discover OpenCode sessions | +| POST | `/api/import` | Import a discovered session | +| WS | `/api/ws` | WebSocket for real-time updates | + +## Development + +### Running Tests + +```bash +# Server tests +cd server +clj -M:test + +# Client type checking +cd client +pnpm check +``` + +### Project Structure + +``` +spiceflow/ +├── server/ # Clojure backend +│ ├── deps.edn +│ ├── src/spiceflow/ +│ │ ├── core.clj # Entry point +│ │ ├── config.clj # Configuration +│ │ ├── db/ +│ │ │ ├── protocol.clj # DataStore protocol +│ │ │ ├── sqlite.clj # SQLite implementation +│ │ │ └── memory.clj # In-memory impl for tests +│ │ ├── adapters/ +│ │ │ ├── protocol.clj # AgentAdapter protocol +│ │ │ ├── claude.clj # Claude Code adapter +│ │ │ └── opencode.clj # OpenCode adapter +│ │ ├── api/ +│ │ │ ├── routes.clj # REST endpoints +│ │ │ └── websocket.clj # WebSocket handlers +│ │ └── session/ +│ │ └── manager.clj # Session lifecycle +│ └── test/spiceflow/ +│ +├── client/ # SvelteKit PWA +│ ├── src/ +│ │ ├── routes/ # SvelteKit routes +│ │ └── lib/ +│ │ ├── api.ts # API client +│ │ ├── stores/ # Svelte stores +│ │ └── components/ # UI components +│ └── static/ # PWA assets +│ +└── README.md +``` + +## Configuration + +### Server + +Configuration via `resources/config.edn` or environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPICEFLOW_PORT` | 3000 | Server port | +| `SPICEFLOW_HOST` | 0.0.0.0 | Server host | +| `SPICEFLOW_DB` | spiceflow.db | SQLite database path | +| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory | +| `OPENCODE_CMD` | opencode | OpenCode command | + +### PWA Icons + +Generate PWA icons from the SVG favicon: + +```bash +cd client/static +# Use a tool like svg2png or imagemagick to generate: +# - pwa-192x192.png +# - pwa-512x512.png +# - apple-touch-icon.png (180x180) +``` + +## License + +MIT diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..9aa1aa9 --- /dev/null +++ b/client/package.json @@ -0,0 +1,30 @@ +{ + "name": "spiceflow-client", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.1", + "@sveltejs/kit": "^2.5.0", + "@sveltejs/vite-plugin-svelte": "^3.0.2", + "@types/node": "^20.11.0", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "svelte": "^4.2.9", + "svelte-check": "^3.6.3", + "tailwindcss": "^3.4.1", + "tslib": "^2.6.2", + "typescript": "^5.3.3", + "vite": "^5.0.12", + "vite-plugin-pwa": "^0.19.2", + "workbox-window": "^7.0.0" + }, + "dependencies": {} +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..0f77216 --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/client/src/app.css b/client/src/app.css new file mode 100644 index 0000000..5402d72 --- /dev/null +++ b/client/src/app.css @@ -0,0 +1,64 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + -webkit-tap-highlight-color: transparent; + } + + body { + @apply antialiased; + } + + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + ::-webkit-scrollbar-track { + @apply bg-zinc-800; + } + + ::-webkit-scrollbar-thumb { + @apply bg-zinc-600 rounded-full; + } + + ::-webkit-scrollbar-thumb:hover { + @apply bg-zinc-500; + } +} + +@layer components { + .btn { + @apply px-4 py-2 rounded-lg font-medium transition-colors; + } + + .btn-primary { + @apply bg-spice-500 hover:bg-spice-600 text-white; + } + + .btn-secondary { + @apply bg-zinc-700 hover:bg-zinc-600 text-zinc-100; + } + + .card { + @apply bg-zinc-800 rounded-xl p-4 border border-zinc-700; + } + + .input { + @apply w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg + text-zinc-100 placeholder:text-zinc-500 + focus:outline-none focus:ring-2 focus:ring-spice-500 focus:border-transparent; + } +} + +@layer utilities { + .safe-bottom { + padding-bottom: env(safe-area-inset-bottom, 1rem); + } + + .safe-top { + padding-top: env(safe-area-inset-top, 0); + } +} diff --git a/client/src/app.d.ts b/client/src/app.d.ts new file mode 100644 index 0000000..743f07b --- /dev/null +++ b/client/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/client/src/app.html b/client/src/app.html new file mode 100644 index 0000000..9a43ff5 --- /dev/null +++ b/client/src/app.html @@ -0,0 +1,16 @@ + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..8ebf823 --- /dev/null +++ b/client/src/lib/api.ts @@ -0,0 +1,236 @@ +const API_BASE = '/api'; + +export interface Session { + id: string; + provider: 'claude' | 'opencode'; + 'external-id'?: string; + externalId?: string; + title?: string; + 'working-dir'?: string; + workingDir?: string; + status: 'idle' | 'running' | 'completed'; + 'created-at'?: string; + createdAt?: string; + 'updated-at'?: string; + updatedAt?: string; + messages?: Message[]; +} + +export interface Message { + id: string; + 'session-id': string; + sessionId?: string; + role: 'user' | 'assistant' | 'system'; + content: string; + metadata?: Record; + 'created-at'?: string; + createdAt?: string; +} + +export interface DiscoveredSession { + 'external-id': string; + provider: 'claude' | 'opencode'; + title?: string; + 'working-dir'?: string; + 'file-path'?: string; +} + +export interface StreamEvent { + event?: string; + 'session-id'?: string; + sessionId?: string; + text?: string; + content?: string; + type?: string; + message?: string; +} + +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string = API_BASE) { + this.baseUrl = baseUrl; + } + + private async request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + // Sessions + async getSessions(): Promise { + return this.request('/sessions'); + } + + async getSession(id: string): Promise { + return this.request(`/sessions/${id}`); + } + + async createSession(session: Partial): Promise { + return this.request('/sessions', { + method: 'POST', + body: JSON.stringify(session) + }); + } + + async deleteSession(id: string): Promise { + await this.request(`/sessions/${id}`, { method: 'DELETE' }); + } + + async sendMessage(sessionId: string, message: string): Promise<{ status: string }> { + return this.request<{ status: string }>(`/sessions/${sessionId}/send`, { + method: 'POST', + body: JSON.stringify({ message }) + }); + } + + // Discovery + async discoverClaude(): Promise { + return this.request('/discover/claude'); + } + + async discoverOpenCode(): Promise { + return this.request('/discover/opencode'); + } + + async importSession(session: DiscoveredSession): Promise { + return this.request('/import', { + method: 'POST', + body: JSON.stringify(session) + }); + } + + // Health + async health(): Promise<{ status: string; service: string }> { + return this.request<{ status: string; service: string }>('/health'); + } +} + +export const api = new ApiClient(); + +// WebSocket connection +export class WebSocketClient { + private ws: WebSocket | null = null; + private url: string; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private listeners: Map void>> = new Map(); + private globalListeners: Set<(event: StreamEvent) => void> = new Set(); + + constructor(url: string = `ws://${window.location.host}/api/ws`) { + this.url = url; + } + + connect(): Promise { + return new Promise((resolve, reject) => { + if (this.ws?.readyState === WebSocket.OPEN) { + resolve(); + return; + } + + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.reconnectAttempts = 0; + resolve(); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + this.attemptReconnect(); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + reject(error); + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as StreamEvent; + this.handleMessage(data); + } catch (e) { + console.error('Failed to parse WebSocket message:', e); + } + }; + }); + } + + private handleMessage(event: StreamEvent) { + // Notify global listeners + this.globalListeners.forEach((listener) => listener(event)); + + // Notify session-specific listeners + const sessionId = event['session-id'] || event.sessionId; + if (sessionId) { + const sessionListeners = this.listeners.get(sessionId); + sessionListeners?.forEach((listener) => listener(event)); + } + } + + private attemptReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('Max reconnection attempts reached'); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + setTimeout(() => { + console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + this.connect().catch(() => {}); + }, delay); + } + + subscribe(sessionId: string, callback: (event: StreamEvent) => void) { + if (!this.listeners.has(sessionId)) { + this.listeners.set(sessionId, new Set()); + } + this.listeners.get(sessionId)!.add(callback); + + // Send subscribe message + this.send({ type: 'subscribe', 'session-id': sessionId }); + + return () => { + this.listeners.get(sessionId)?.delete(callback); + this.send({ type: 'unsubscribe', 'session-id': sessionId }); + }; + } + + onMessage(callback: (event: StreamEvent) => void) { + this.globalListeners.add(callback); + return () => this.globalListeners.delete(callback); + } + + send(data: Record) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } + + disconnect() { + this.ws?.close(); + this.ws = null; + } +} + +export const wsClient = new WebSocketClient(); diff --git a/client/src/lib/components/InputBar.svelte b/client/src/lib/components/InputBar.svelte new file mode 100644 index 0000000..766bd87 --- /dev/null +++ b/client/src/lib/components/InputBar.svelte @@ -0,0 +1,65 @@ + + +
+
+ + + +
+
diff --git a/client/src/lib/components/MessageList.svelte b/client/src/lib/components/MessageList.svelte new file mode 100644 index 0000000..39e26dc --- /dev/null +++ b/client/src/lib/components/MessageList.svelte @@ -0,0 +1,114 @@ + + +
+ {#if messages.length === 0 && !streamingContent} +
+
+ + + +

No messages yet

+

Send a message to start the conversation

+
+
+ {:else} + {#each messages as message (message.id)} +
+
+ + {roleLabels[message.role]} + +
+
+ {message.content} +
+
+ {/each} + + {#if streamingContent} +
+
+ + Assistant + + + + + + +
+
+ {streamingContent}| +
+
+ {/if} + {/if} +
diff --git a/client/src/lib/components/SessionCard.svelte b/client/src/lib/components/SessionCard.svelte new file mode 100644 index 0000000..6fe78bc --- /dev/null +++ b/client/src/lib/components/SessionCard.svelte @@ -0,0 +1,67 @@ + + + +
+
+
+ + + {session.provider} + +
+ +

+ {session.title || `Session ${shortId}`} +

+ + {#if projectName} +

+ {projectName} +

+ {/if} +
+ +
+ {formatTime(updatedAt)} +
+
+
diff --git a/client/src/lib/index.ts b/client/src/lib/index.ts new file mode 100644 index 0000000..4d9ace8 --- /dev/null +++ b/client/src/lib/index.ts @@ -0,0 +1,3 @@ +// Spiceflow client library exports +export * from './api'; +export * from './stores/sessions'; diff --git a/client/src/lib/stores/sessions.ts b/client/src/lib/stores/sessions.ts new file mode 100644 index 0000000..5e5615c --- /dev/null +++ b/client/src/lib/stores/sessions.ts @@ -0,0 +1,207 @@ +import { writable, derived, type Readable } from 'svelte/store'; +import { api, wsClient, type Session, type Message, type StreamEvent } from '$lib/api'; + +interface SessionsState { + sessions: Session[]; + loading: boolean; + error: string | null; +} + +interface ActiveSessionState { + session: Session | null; + messages: Message[]; + streamingContent: string; + loading: boolean; + error: string | null; +} + +function createSessionsStore() { + const { subscribe, set, update } = writable({ + sessions: [], + loading: false, + error: null + }); + + return { + subscribe, + async load() { + update((s) => ({ ...s, loading: true, error: null })); + try { + const sessions = await api.getSessions(); + update((s) => ({ ...s, sessions, loading: false })); + } catch (e) { + update((s) => ({ ...s, loading: false, error: (e as Error).message })); + } + }, + async delete(id: string) { + try { + await api.deleteSession(id); + update((s) => ({ + ...s, + sessions: s.sessions.filter((session) => session.id !== id) + })); + } catch (e) { + update((s) => ({ ...s, error: (e as Error).message })); + } + }, + updateSession(id: string, data: Partial) { + update((s) => ({ + ...s, + sessions: s.sessions.map((session) => + session.id === id ? { ...session, ...data } : session + ) + })); + } + }; +} + +function createActiveSessionStore() { + const { subscribe, set, update } = writable({ + session: null, + messages: [], + streamingContent: '', + loading: false, + error: null + }); + + let unsubscribeWs: (() => void) | null = null; + + return { + subscribe, + async load(id: string) { + update((s) => ({ ...s, loading: true, error: null, streamingContent: '' })); + + // Unsubscribe from previous session + if (unsubscribeWs) { + unsubscribeWs(); + unsubscribeWs = null; + } + + try { + const session = await api.getSession(id); + update((s) => ({ + ...s, + session, + messages: session.messages || [], + loading: false + })); + + // Subscribe to WebSocket updates + await wsClient.connect(); + unsubscribeWs = wsClient.subscribe(id, (event) => { + handleStreamEvent(event); + }); + } catch (e) { + update((s) => ({ ...s, loading: false, error: (e as Error).message })); + } + }, + async sendMessage(message: string) { + const state = get(); + if (!state.session) return; + + // Add user message immediately + const userMessage: Message = { + id: `temp-${Date.now()}`, + 'session-id': state.session.id, + role: 'user', + content: message, + 'created-at': new Date().toISOString() + }; + + update((s) => ({ + ...s, + messages: [...s.messages, userMessage], + streamingContent: '' + })); + + try { + await api.sendMessage(state.session.id, message); + } catch (e) { + update((s) => ({ ...s, error: (e as Error).message })); + } + }, + appendStreamContent(text: string) { + update((s) => ({ ...s, streamingContent: s.streamingContent + text })); + }, + finalizeStream() { + update((s) => { + if (!s.streamingContent || !s.session) return s; + + const assistantMessage: Message = { + id: `stream-${Date.now()}`, + 'session-id': s.session.id, + role: 'assistant', + content: s.streamingContent, + 'created-at': new Date().toISOString() + }; + + return { + ...s, + messages: [...s.messages, assistantMessage], + streamingContent: '' + }; + }); + }, + clear() { + if (unsubscribeWs) { + unsubscribeWs(); + unsubscribeWs = null; + } + set({ + session: null, + messages: [], + streamingContent: '', + loading: false, + error: null + }); + } + }; + + function get(): ActiveSessionState { + let state: ActiveSessionState; + subscribe((s) => (state = s))(); + return state!; + } + + function handleStreamEvent(event: StreamEvent) { + if (event.event === 'content-delta' && event.text) { + update((s) => ({ ...s, streamingContent: s.streamingContent + event.text })); + } else if (event.event === 'message-stop') { + update((s) => { + if (!s.streamingContent || !s.session) return s; + + const assistantMessage: Message = { + id: `stream-${Date.now()}`, + 'session-id': s.session.id, + role: 'assistant', + content: s.streamingContent, + 'created-at': new Date().toISOString() + }; + + return { + ...s, + messages: [...s.messages, assistantMessage], + streamingContent: '' + }; + }); + } else if (event.event === 'error') { + update((s) => ({ ...s, error: event.message || 'Stream error' })); + } + } +} + +export const sessions = createSessionsStore(); +export const activeSession = createActiveSessionStore(); + +// Derived stores +export const sortedSessions: Readable = derived(sessions, ($sessions) => + [...$sessions.sessions].sort((a, b) => { + const aDate = a['updated-at'] || a.updatedAt || a['created-at'] || a.createdAt || ''; + const bDate = b['updated-at'] || b.updatedAt || b['created-at'] || b.createdAt || ''; + return bDate.localeCompare(aDate); + }) +); + +export const runningSessions: Readable = derived(sessions, ($sessions) => + $sessions.sessions.filter((s) => s.status === 'running') +); diff --git a/client/src/routes/+layout.svelte b/client/src/routes/+layout.svelte new file mode 100644 index 0000000..38bd38f --- /dev/null +++ b/client/src/routes/+layout.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/client/src/routes/+layout.ts b/client/src/routes/+layout.ts new file mode 100644 index 0000000..ceccaaf --- /dev/null +++ b/client/src/routes/+layout.ts @@ -0,0 +1,2 @@ +export const prerender = true; +export const ssr = false; diff --git a/client/src/routes/+page.svelte b/client/src/routes/+page.svelte new file mode 100644 index 0000000..7c741f6 --- /dev/null +++ b/client/src/routes/+page.svelte @@ -0,0 +1,245 @@ + + + + Spiceflow + + + +
+
+
+

Spiceflow

+

The spice must flow

+
+ +
+ + + +
+
+ + {#if $runningSessions.length > 0} +
+ + {$runningSessions.length} session{$runningSessions.length === 1 ? '' : 's'} running + +
+ {/if} +
+ + +
+ {#if $sessions.error} +
+ {$sessions.error} +
+ {/if} + + {#if $sessions.loading && $sortedSessions.length === 0} +
+
+ + + + +

Loading sessions...

+
+
+ {:else if $sortedSessions.length === 0} +
+
+ + + +

No sessions yet

+

+ Click "Discover" to find existing Claude Code or OpenCode sessions on your machine. +

+
+
+ {:else} +
+ {#each $sortedSessions as session (session.id)} + + {/each} +
+ {/if} +
+ + +{#if showDiscovery} +
(showDiscovery = false)} + on:keydown={(e) => e.key === 'Escape' && (showDiscovery = false)} + role="dialog" + tabindex="-1" + > +
+
+

Discovered Sessions

+ +
+ +
+ {#if discoveredSessions.length === 0} +

No new sessions found.

+ {:else} +
+ {#each discoveredSessions as session} +
+
+
+ + {session.provider} + +
+

+ {session.title || session['external-id'].slice(0, 8)} +

+ {#if session['working-dir']} +

+ {session['working-dir']} +

+ {/if} +
+ +
+ {/each} +
+ {/if} +
+
+
+{/if} diff --git a/client/src/routes/session/[id]/+page.svelte b/client/src/routes/session/[id]/+page.svelte new file mode 100644 index 0000000..577185a --- /dev/null +++ b/client/src/routes/session/[id]/+page.svelte @@ -0,0 +1,116 @@ + + + + {session?.title || `Session ${shortId}`} - Spiceflow + + + +
+
+ + + {#if $activeSession.loading} +
+
+
+
+ {:else if session} +
+
+ +

+ {session.title || `Session ${shortId}`} +

+
+ {#if projectName} +

{projectName}

+ {/if} +
+ + + {session.provider} + + {/if} +
+
+ + +{#if $activeSession.error} +
+
+
{$activeSession.error}
+ +
+
+{:else if $activeSession.loading} +
+ + + + +
+{:else} + + + +{/if} diff --git a/client/static/apple-touch-icon.svg b/client/static/apple-touch-icon.svg new file mode 100644 index 0000000..a36910b --- /dev/null +++ b/client/static/apple-touch-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/static/favicon.ico b/client/static/favicon.ico new file mode 120000 index 0000000..423f809 --- /dev/null +++ b/client/static/favicon.ico @@ -0,0 +1 @@ +favicon.svg \ No newline at end of file diff --git a/client/static/favicon.svg b/client/static/favicon.svg new file mode 100644 index 0000000..32a3fc1 --- /dev/null +++ b/client/static/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/static/pwa-192x192.svg b/client/static/pwa-192x192.svg new file mode 100644 index 0000000..a36910b --- /dev/null +++ b/client/static/pwa-192x192.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/static/pwa-512x512.svg b/client/static/pwa-512x512.svg new file mode 100644 index 0000000..a36910b --- /dev/null +++ b/client/static/pwa-512x512.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/svelte.config.js b/client/svelte.config.js new file mode 100644 index 0000000..930e539 --- /dev/null +++ b/client/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: true + }), + paths: { + base: '' + } + } +}; + +export default config; diff --git a/client/tailwind.config.js b/client/tailwind.config.js new file mode 100644 index 0000000..f13e987 --- /dev/null +++ b/client/tailwind.config.js @@ -0,0 +1,27 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + colors: { + spice: { + 50: '#fff7ed', + 100: '#ffedd5', + 200: '#fed7aa', + 300: '#fdba74', + 400: '#fb923c', + 500: '#f97316', + 600: '#ea580c', + 700: '#c2410c', + 800: '#9a3412', + 900: '#7c2d12', + 950: '#431407' + } + }, + fontFamily: { + mono: ['JetBrains Mono', 'Fira Code', 'monospace'] + } + } + }, + plugins: [] +}; diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..d60e0a3 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,70 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +import { VitePWA } from 'vite-plugin-pwa'; + +export default defineConfig({ + plugins: [ + sveltekit(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'], + manifest: { + name: 'Spiceflow', + short_name: 'Spiceflow', + description: 'AI Session Orchestration - The spice must flow', + theme_color: '#f97316', + background_color: '#18181b', + display: 'standalone', + orientation: 'portrait', + scope: '/', + start_url: '/', + icons: [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png' + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any maskable' + } + ] + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + runtimeCaching: [ + { + urlPattern: /^https:\/\/.*\/api\/.*/i, + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { + maxEntries: 100, + maxAgeSeconds: 60 * 5 // 5 minutes + } + } + } + ] + }, + devOptions: { + enabled: true + } + }) + ], + server: { + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + ws: true + } + } + } +}); diff --git a/server/deps.edn b/server/deps.edn new file mode 100644 index 0000000..49ec183 --- /dev/null +++ b/server/deps.edn @@ -0,0 +1,36 @@ +{:paths ["src" "resources"] + :deps {org.clojure/clojure {:mvn/version "1.11.1"} + + ;; Web server + ring/ring-core {:mvn/version "1.10.0"} + ring/ring-jetty-adapter {:mvn/version "1.10.0"} + ring/ring-json {:mvn/version "0.5.1"} + ring-cors/ring-cors {:mvn/version "0.1.13"} + + ;; Routing + metosin/reitit {:mvn/version "0.7.0-alpha7"} + + ;; WebSocket + info.sunng/ring-jetty9-adapter {:mvn/version "0.30.0"} + + ;; Database + com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"} + org.xerial/sqlite-jdbc {:mvn/version "3.44.1.0"} + + ;; JSON + metosin/jsonista {:mvn/version "0.3.8"} + + ;; Utilities + aero/aero {:mvn/version "1.1.6"} + mount/mount {:mvn/version "0.1.18"} + org.clojure/tools.logging {:mvn/version "1.2.4"} + ch.qos.logback/logback-classic {:mvn/version "1.4.11"}} + + :aliases + {:run {:main-opts ["-m" "spiceflow.core"]} + :test {:extra-paths ["test"] + :extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}} + :main-opts ["-m" "kaocha.runner"]} + :repl {:main-opts ["-m" "nrepl.cmdline" "-i"] + :extra-deps {nrepl/nrepl {:mvn/version "1.1.0"} + cider/cider-nrepl {:mvn/version "0.44.0"}}}}} diff --git a/server/resources/config.edn b/server/resources/config.edn new file mode 100644 index 0000000..56b1a38 --- /dev/null +++ b/server/resources/config.edn @@ -0,0 +1,10 @@ +{:server {:port #long #or [#env SPICEFLOW_PORT 3000] + :host #or [#env SPICEFLOW_HOST "0.0.0.0"]} + + :database {:type :sqlite + :dbname #or [#env SPICEFLOW_DB "spiceflow.db"]} + + :claude {:sessions-dir #or [#env CLAUDE_SESSIONS_DIR + #join [#env HOME "/.claude/projects"]]} + + :opencode {:command #or [#env OPENCODE_CMD "opencode"]}} diff --git a/server/resources/logback.xml b/server/resources/logback.xml new file mode 100644 index 0000000..ab0dad2 --- /dev/null +++ b/server/resources/logback.xml @@ -0,0 +1,15 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/server/src/spiceflow/adapters/claude.clj b/server/src/spiceflow/adapters/claude.clj new file mode 100644 index 0000000..cef3bf9 --- /dev/null +++ b/server/src/spiceflow/adapters/claude.clj @@ -0,0 +1,171 @@ +(ns spiceflow.adapters.claude + "Adapter for Claude Code CLI" + (:require [spiceflow.adapters.protocol :as proto] + [clojure.java.io :as io] + [clojure.string :as str] + [jsonista.core :as json] + [clojure.tools.logging :as log]) + (:import [java.io BufferedReader InputStreamReader BufferedWriter OutputStreamWriter] + [java.nio.file Files Paths] + [java.net URLDecoder] + [java.nio.charset StandardCharsets])) + +(def ^:private mapper (json/object-mapper {:decode-key-fn keyword})) + +(defn- decode-path + "Decode a URL-encoded path segment" + [encoded] + (-> encoded + (str/replace "%" "%25") ; Handle already-encoded % signs + (str/replace "%25" "%") + (URLDecoder/decode "UTF-8"))) + +(defn- encoded-path->real-path + "Convert Claude's encoded path format back to real path. + e.g., '-home-user-project' -> '/home/user/project'" + [encoded] + (-> encoded + (str/replace #"^-" "/") + (str/replace "-" "/"))) + +(defn- discover-project-sessions + "Discover sessions from a single project directory" + [project-dir] + (let [project-path (encoded-path->real-path (.getName project-dir))] + (->> (.listFiles project-dir) + (filter #(str/ends-with? (.getName %) ".jsonl")) + (map (fn [session-file] + (let [session-id (str/replace (.getName session-file) ".jsonl" "")] + {:external-id session-id + :provider :claude + :working-dir project-path + :title (str "Session " (subs session-id 0 8) "...") + :file-path (.getAbsolutePath session-file)})))))) + +(defn- parse-jsonl-message + "Parse a JSONL message from Claude Code" + [line] + (try + (let [data (json/read-value line mapper)] + (case (:type data) + "user" {:role :user + :content (get-in data [:message :content]) + :timestamp (:timestamp data) + :uuid (:uuid data)} + "assistant" {:role :assistant + :content (get-in data [:message :content]) + :timestamp (:timestamp data) + :uuid (:uuid data) + :metadata {:model (get-in data [:message :model]) + :stop-reason (get-in data [:message :stopReason])}} + ;; Stream events from --output-format stream-json + "content_block_start" {:event :content-start + :index (:index data) + :content-type (get-in data [:content_block :type])} + "content_block_delta" {:event :content-delta + :index (:index data) + :text (get-in data [:delta :text])} + "content_block_stop" {:event :content-stop + :index (:index data)} + "message_start" {:event :message-start + :message-id (get-in data [:message :id])} + "message_delta" {:event :message-delta + :stop-reason (get-in data [:delta :stop_reason])} + "message_stop" {:event :message-stop} + "result" {:event :result + :content (get-in data [:result :assistant :content]) + :cost (:cost data) + :session-id (:session_id data)} + ;; Unknown type + {:raw data})) + (catch Exception e + (log/debug "Failed to parse JSONL line:" line (.getMessage e)) + nil))) + +(defn- read-session-messages + "Read messages from a session JSONL file" + [file-path] + (try + (with-open [reader (io/reader file-path)] + (->> (line-seq reader) + (keep parse-jsonl-message) + (filter #(contains? #{:user :assistant} (:role %))) + vec)) + (catch Exception e + (log/warn "Failed to read session file:" file-path (.getMessage e)) + []))) + +(defrecord ClaudeAdapter [sessions-dir] + proto/AgentAdapter + + (provider-name [_] :claude) + + (discover-sessions [_] + (let [base-dir (io/file sessions-dir)] + (if (.exists base-dir) + (->> (.listFiles base-dir) + (filter #(.isDirectory %)) + (mapcat discover-project-sessions) + vec) + []))) + + (spawn-session [_ session-id opts] + (let [{:keys [working-dir model permission-mode]} opts + args (cond-> ["claude" + "--resume" session-id + "--output-format" "stream-json" + "--input-format" "stream-json" + "--print"] + model (conj "--model" model) + permission-mode (conj "--permission-mode" permission-mode)) + pb (ProcessBuilder. args)] + (when working-dir + (.directory pb (io/file working-dir))) + (.redirectErrorStream pb false) + (let [process (.start pb)] + {:process process + :stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8)) + :stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8)) + :stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))}))) + + (send-message [_ {:keys [stdin]} message] + (try + (let [json-msg (json/write-value-as-string {:type "user" :content message})] + (.write stdin json-msg) + (.newLine stdin) + (.flush stdin) + true) + (catch Exception e + (log/error "Failed to send message:" (.getMessage e)) + false))) + + (read-stream [this {:keys [stdout]} callback] + (try + (loop [] + (when-let [line (.readLine stdout)] + (when-let [parsed (proto/parse-output this line)] + (callback parsed)) + (recur))) + (catch Exception e + (log/debug "Stream ended:" (.getMessage e))))) + + (kill-process [_ {:keys [process]}] + (when process + (.destroyForcibly process))) + + (parse-output [_ line] + (when (and line (not (str/blank? line))) + (parse-jsonl-message line)))) + +(defn create-adapter + "Create a Claude Code adapter" + ([] + (create-adapter (str (System/getProperty "user.home") "/.claude/projects"))) + ([sessions-dir] + (->ClaudeAdapter sessions-dir))) + +(defn get-session-messages + "Read messages from a discovered session" + [session] + (when-let [file-path (:file-path session)] + (read-session-messages file-path))) diff --git a/server/src/spiceflow/adapters/opencode.clj b/server/src/spiceflow/adapters/opencode.clj new file mode 100644 index 0000000..a5aaba8 --- /dev/null +++ b/server/src/spiceflow/adapters/opencode.clj @@ -0,0 +1,145 @@ +(ns spiceflow.adapters.opencode + "Adapter for OpenCode CLI" + (:require [spiceflow.adapters.protocol :as proto] + [clojure.java.io :as io] + [clojure.java.shell :as shell] + [clojure.string :as str] + [jsonista.core :as json] + [clojure.tools.logging :as log]) + (:import [java.io BufferedReader InputStreamReader BufferedWriter OutputStreamWriter] + [java.nio.charset StandardCharsets])) + +(def ^:private mapper (json/object-mapper {:decode-key-fn keyword})) + +(defn- run-command + "Run an opencode command and return parsed output" + [command & args] + (let [result (apply shell/sh command args)] + (when (zero? (:exit result)) + (:out result)))) + +(defn- parse-session-list + "Parse output from 'opencode session list'" + [output] + (try + (let [data (json/read-value output mapper)] + (->> data + (map (fn [session] + {:external-id (or (:id session) (:session_id session)) + :provider :opencode + :title (:title session) + :working-dir (:working_dir session) + :created-at (get-in session [:time :created]) + :updated-at (get-in session [:time :updated])})))) + (catch Exception e + (log/debug "Failed to parse session list, trying line-by-line") + ;; Fallback: parse line by line if it's not JSON + (->> (str/split-lines output) + (filter #(str/starts-with? % "ses_")) + (map (fn [line] + (let [[id title] (str/split line #"\s+" 2)] + {:external-id id + :provider :opencode + :title (or title "Untitled")}))))))) + +(defn- parse-session-export + "Parse output from 'opencode export '" + [output] + (try + (let [data (json/read-value output mapper)] + {:info (:info data) + :messages (->> (:messages data) + (map (fn [msg] + {:role (keyword (get-in msg [:info :role] "assistant")) + :content (->> (:parts msg) + (filter #(= (:type %) "text")) + (map :text) + (str/join "\n")) + :metadata {:parts (:parts msg)}})))}) + (catch Exception e + (log/warn "Failed to parse session export:" (.getMessage e)) + nil))) + +(defn- parse-stream-output + "Parse a line of streaming output from OpenCode" + [line] + (try + (when (and line (not (str/blank? line))) + (if (str/starts-with? line "{") + (let [data (json/read-value line mapper)] + (cond + (:content data) {:event :content-delta + :text (:content data)} + (:done data) {:event :message-stop} + (:error data) {:event :error + :message (:error data)} + :else {:raw data})) + ;; Plain text output + {:event :content-delta + :text line})) + (catch Exception e + (log/debug "Failed to parse line:" line) + {:event :content-delta :text line}))) + +(defrecord OpenCodeAdapter [command] + proto/AgentAdapter + + (provider-name [_] :opencode) + + (discover-sessions [_] + (if-let [output (run-command command "session" "list" "--json")] + (vec (parse-session-list output)) + [])) + + (spawn-session [_ session-id opts] + (let [{:keys [working-dir]} opts + args [command "run" "--session" session-id] + pb (ProcessBuilder. args)] + (when working-dir + (.directory pb (io/file working-dir))) + (.redirectErrorStream pb false) + (let [process (.start pb)] + {:process process + :stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8)) + :stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8)) + :stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))}))) + + (send-message [_ {:keys [stdin]} message] + (try + (.write stdin message) + (.newLine stdin) + (.flush stdin) + true + (catch Exception e + (log/error "Failed to send message:" (.getMessage e)) + false))) + + (read-stream [this {:keys [stdout]} callback] + (try + (loop [] + (when-let [line (.readLine stdout)] + (when-let [parsed (proto/parse-output this line)] + (callback parsed)) + (recur))) + (catch Exception e + (log/debug "Stream ended:" (.getMessage e))))) + + (kill-process [_ {:keys [process]}] + (when process + (.destroyForcibly process))) + + (parse-output [_ line] + (parse-stream-output line))) + +(defn create-adapter + "Create an OpenCode adapter" + ([] + (create-adapter "opencode")) + ([command] + (->OpenCodeAdapter command))) + +(defn export-session + "Export a session's full history" + [adapter session-id] + (when-let [output (run-command (:command adapter) "export" session-id)] + (parse-session-export output))) diff --git a/server/src/spiceflow/adapters/protocol.clj b/server/src/spiceflow/adapters/protocol.clj new file mode 100644 index 0000000..b2dc81d --- /dev/null +++ b/server/src/spiceflow/adapters/protocol.clj @@ -0,0 +1,38 @@ +(ns spiceflow.adapters.protocol) + +(defprotocol AgentAdapter + "Protocol for interacting with AI coding assistants (Claude Code, OpenCode, etc.)" + + (provider-name [this] + "Return the provider name as a keyword (:claude or :opencode)") + + (discover-sessions [this] + "Discover existing sessions from the CLI's storage. + Returns a sequence of session maps with :external-id, :title, :working-dir, etc.") + + (spawn-session [this session-id opts] + "Spawn a new CLI process for the given session. + opts may include :working-dir, :model, :permission-mode + Returns a process handle map with :process, :stdin, :stdout, :stderr") + + (send-message [this process-handle message] + "Send a message to a running CLI process. + Returns true if sent successfully.") + + (read-stream [this process-handle callback] + "Read streamed output from the CLI process. + callback is called with each parsed message/event. + Returns when the process completes or stream ends.") + + (kill-process [this process-handle] + "Kill a running CLI process.") + + (parse-output [this line] + "Parse a line of CLI output into a structured message. + Returns nil if the line should be ignored.")) + +(defn running? + "Check if a process handle represents a running process" + [{:keys [process]}] + (when process + (.isAlive process))) diff --git a/server/src/spiceflow/api/routes.clj b/server/src/spiceflow/api/routes.clj new file mode 100644 index 0000000..9db8187 --- /dev/null +++ b/server/src/spiceflow/api/routes.clj @@ -0,0 +1,141 @@ +(ns spiceflow.api.routes + "REST API routes" + (:require [reitit.ring :as ring] + [reitit.ring.middleware.parameters :as parameters] + [ring.middleware.json :refer [wrap-json-body wrap-json-response]] + [ring.middleware.cors :refer [wrap-cors]] + [ring.util.response :as response] + [spiceflow.db.protocol :as db] + [spiceflow.session.manager :as manager] + [spiceflow.adapters.claude :as claude] + [clojure.tools.logging :as log])) + +(defn- json-response + "Create a JSON response" + [body] + (-> (response/response body) + (response/content-type "application/json"))) + +(defn- error-response + "Create an error response" + [status message] + (-> (response/response {:error message}) + (response/status status) + (response/content-type "application/json"))) + +;; Session handlers +(defn list-sessions-handler + [store] + (fn [_request] + (json-response (db/get-sessions store)))) + +(defn get-session-handler + [store] + (fn [request] + (let [id (get-in request [:path-params :id])] + (if-let [session (db/get-session store id)] + (let [messages (db/get-messages store id)] + (json-response (assoc session :messages messages))) + (error-response 404 "Session not found"))))) + +(defn create-session-handler + [store] + (fn [request] + (let [body (:body request)] + (if (db/valid-session? body) + (let [session (db/save-session store body)] + (-> (json-response session) + (response/status 201))) + (error-response 400 "Invalid session data"))))) + +(defn delete-session-handler + [store] + (fn [request] + (let [id (get-in request [:path-params :id])] + (if (db/get-session store id) + (do + (manager/stop-session store id) + (db/delete-session store id) + (response/status (response/response nil) 204)) + (error-response 404 "Session not found"))))) + +(defn send-message-handler + [store broadcast-fn] + (fn [request] + (let [id (get-in request [:path-params :id]) + message (get-in request [:body :message])] + (if-let [session (db/get-session store id)] + (try + ;; Send message and start streaming in a separate thread + (manager/send-message-to-session store id message) + (future + (try + (manager/stream-session-response store id + (fn [event] + (broadcast-fn id event))) + (catch Exception e + (log/error "Streaming error:" (.getMessage e)) + (broadcast-fn id {:event :error :message (.getMessage e)})))) + (json-response {:status "sent"}) + (catch Exception e + (error-response 500 (.getMessage e)))) + (error-response 404 "Session not found"))))) + +;; Discovery handlers +(defn discover-claude-handler + [_store] + (fn [_request] + (let [adapter (claude/create-adapter) + sessions (manager/discover-all-sessions)] + (json-response (->> sessions + (filter #(= :claude (:provider %))) + vec))))) + +(defn discover-opencode-handler + [_store] + (fn [_request] + (let [sessions (manager/discover-all-sessions)] + (json-response (->> sessions + (filter #(= :opencode (:provider %))) + vec))))) + +(defn import-session-handler + [store] + (fn [request] + (let [body (:body request)] + (if (:external-id body) + (let [session (manager/import-session store body)] + (-> (json-response session) + (response/status 201))) + (error-response 400 "Missing external-id"))))) + +;; Health check +(defn health-handler + [_request] + (json-response {:status "ok" :service "spiceflow"})) + +(defn create-routes + "Create API routes with the given store and broadcast function" + [store broadcast-fn] + [["/api" + ["/health" {:get health-handler}] + ["/sessions" {:get (list-sessions-handler store) + :post (create-session-handler store)}] + ["/sessions/:id" {:get (get-session-handler store) + :delete (delete-session-handler store)}] + ["/sessions/:id/send" {:post (send-message-handler store broadcast-fn)}] + ["/discover/claude" {:get (discover-claude-handler store)}] + ["/discover/opencode" {:get (discover-opencode-handler store)}] + ["/import" {:post (import-session-handler store)}]]]) + +(defn create-app + "Create the Ring application" + [store broadcast-fn] + (-> (ring/ring-handler + (ring/router (create-routes store broadcast-fn)) + (ring/create-default-handler)) + (wrap-json-body {:keywords? true}) + wrap-json-response + (wrap-cors :access-control-allow-origin [#".*"] + :access-control-allow-methods [:get :post :put :delete :options] + :access-control-allow-headers [:content-type :authorization]))) diff --git a/server/src/spiceflow/api/websocket.clj b/server/src/spiceflow/api/websocket.clj new file mode 100644 index 0000000..39124a6 --- /dev/null +++ b/server/src/spiceflow/api/websocket.clj @@ -0,0 +1,108 @@ +(ns spiceflow.api.websocket + "WebSocket handlers for real-time updates" + (:require [jsonista.core :as json] + [clojure.tools.logging :as log]) + (:import [org.eclipse.jetty.websocket.api Session WebSocketListener] + [java.util.concurrent ConcurrentHashMap])) + +(def ^:private mapper (json/object-mapper {:encode-key-fn name})) + +;; Connected WebSocket sessions: session-id -> #{ws-sessions} +(defonce ^:private connections (ConcurrentHashMap.)) + +;; All connected WebSocket sessions for broadcast +(defonce ^:private all-connections (ConcurrentHashMap/newKeySet)) + +(defn- send-to-ws + "Send a message to a WebSocket session" + [^Session ws-session message] + (try + (when (.isOpen ws-session) + (let [json-str (json/write-value-as-string message mapper)] + (.sendString (.getRemote ws-session) json-str))) + (catch Exception e + (log/debug "Failed to send to WebSocket:" (.getMessage e))))) + +(defn broadcast-to-session + "Broadcast an event to all WebSocket connections subscribed to a session" + [session-id event] + (when-let [subscribers (.get connections session-id)] + (let [message (assoc event :session-id session-id)] + (doseq [ws-session subscribers] + (send-to-ws ws-session message))))) + +(defn broadcast-all + "Broadcast an event to all connected WebSocket sessions" + [event] + (doseq [ws-session all-connections] + (send-to-ws ws-session event))) + +(defn- subscribe-to-session + "Subscribe a WebSocket session to updates for a session" + [ws-session session-id] + (.compute connections session-id + (fn [_k existing] + (let [subscribers (or existing (ConcurrentHashMap/newKeySet))] + (.add subscribers ws-session) + subscribers)))) + +(defn- unsubscribe-from-session + "Unsubscribe a WebSocket session from a session" + [ws-session session-id] + (when-let [subscribers (.get connections session-id)] + (.remove subscribers ws-session) + (when (.isEmpty subscribers) + (.remove connections session-id)))) + +(defn- unsubscribe-from-all + "Unsubscribe a WebSocket session from all sessions" + [ws-session] + (doseq [[session-id _] connections] + (unsubscribe-from-session ws-session session-id))) + +(defn- handle-message + "Handle an incoming WebSocket message" + [ws-session message] + (try + (let [data (json/read-value message mapper)] + (case (:type data) + "subscribe" (do + (subscribe-to-session ws-session (:session-id data)) + (send-to-ws ws-session {:type "subscribed" + :session-id (:session-id data)})) + "unsubscribe" (do + (unsubscribe-from-session ws-session (:session-id data)) + (send-to-ws ws-session {:type "unsubscribed" + :session-id (:session-id data)})) + "ping" (send-to-ws ws-session {:type "pong"}) + (log/debug "Unknown WebSocket message type:" (:type data)))) + (catch Exception e + (log/warn "Failed to handle WebSocket message:" (.getMessage e))))) + +(defn create-ws-listener + "Create a WebSocket listener" + [] + (reify WebSocketListener + (onWebSocketConnect [_ session] + (log/debug "WebSocket connected") + (.add all-connections session) + (send-to-ws session {:type "connected"})) + + (onWebSocketText [_ session message] + (handle-message session message)) + + (onWebSocketBinary [_ _session _payload _offset _len] + (log/debug "Binary WebSocket message ignored")) + + (onWebSocketClose [_ session status-code reason] + (log/debug "WebSocket closed:" status-code reason) + (.remove all-connections session) + (unsubscribe-from-all session)) + + (onWebSocketError [_ _session cause] + (log/warn "WebSocket error:" (.getMessage cause))))) + +(defn ws-handler + "Ring handler for WebSocket upgrade" + [_request] + {:ring.websocket/listener (create-ws-listener)}) diff --git a/server/src/spiceflow/config.clj b/server/src/spiceflow/config.clj new file mode 100644 index 0000000..1de497c --- /dev/null +++ b/server/src/spiceflow/config.clj @@ -0,0 +1,28 @@ +(ns spiceflow.config + (:require [aero.core :as aero] + [clojure.java.io :as io] + [mount.core :refer [defstate]])) + +(defn load-config + "Load configuration from config.edn or environment" + ([] + (load-config :default)) + ([profile] + (let [config-file (io/resource "config.edn")] + (if config-file + (aero/read-config config-file {:profile profile}) + ;; Default config if no file exists + {:server {:port 3000 + :host "0.0.0.0"} + :database {:type :sqlite + :dbname "spiceflow.db"} + :claude {:sessions-dir (str (System/getProperty "user.home") "/.claude/projects")} + :opencode {:command "opencode"}})))) + +(defstate config + :start (load-config (keyword (or (System/getenv "SPICEFLOW_ENV") "default")))) + +(defn get-in-config + "Get a nested value from config" + [ks] + (get-in config ks)) diff --git a/server/src/spiceflow/core.clj b/server/src/spiceflow/core.clj new file mode 100644 index 0000000..6bf7d06 --- /dev/null +++ b/server/src/spiceflow/core.clj @@ -0,0 +1,56 @@ +(ns spiceflow.core + "Main entry point for Spiceflow server" + (:require [ring.adapter.jetty9 :as jetty] + [spiceflow.config :as config] + [spiceflow.db.sqlite :as sqlite] + [spiceflow.api.routes :as routes] + [spiceflow.api.websocket :as ws] + [spiceflow.session.manager :as manager] + [mount.core :as mount :refer [defstate]] + [clojure.tools.logging :as log]) + (:gen-class)) + +;; Database store +(defstate store + :start (do + (log/info "Initializing database...") + (let [db-path (get-in config/config [:database :dbname] "spiceflow.db")] + (sqlite/create-store db-path))) + :stop nil) + +;; HTTP Server +(defstate server + :start (let [port (get-in config/config [:server :port] 3000) + host (get-in config/config [:server :host] "0.0.0.0") + app (routes/create-app store ws/broadcast-to-session)] + (log/info "Starting Spiceflow server on" (str host ":" port)) + (jetty/run-jetty app + {:port port + :host host + :join? false + :websockets {"/api/ws" ws/ws-handler}})) + :stop (do + (log/info "Stopping Spiceflow server...") + (manager/cleanup-all store) + (.stop server))) + +(defn -main + "Main entry point" + [& _args] + (log/info "Starting Spiceflow - The spice must flow!") + (mount/start) + (log/info "Spiceflow is ready.") + ;; Keep the main thread alive + @(promise)) + +(comment + ;; Development helpers + (mount/start) + (mount/stop) + + ;; Test database + (require '[spiceflow.db.protocol :as db]) + (db/get-sessions store) + + ;; Test discovery + (manager/discover-all-sessions)) diff --git a/server/src/spiceflow/db/memory.clj b/server/src/spiceflow/db/memory.clj new file mode 100644 index 0000000..00e8ad7 --- /dev/null +++ b/server/src/spiceflow/db/memory.clj @@ -0,0 +1,79 @@ +(ns spiceflow.db.memory + "In-memory implementation of DataStore for testing" + (:require [spiceflow.db.protocol :as proto]) + (:import [java.util UUID] + [java.time Instant])) + +(defn- generate-id [] + (str (UUID/randomUUID))) + +(defn- now-iso [] + (.toString (Instant/now))) + +(defrecord MemoryStore [sessions messages] + proto/DataStore + + (get-sessions [_] + (->> @sessions + vals + (sort-by :updated-at) + reverse + vec)) + + (get-session [_ id] + (get @sessions id)) + + (save-session [this session] + (let [id (or (:id session) (generate-id)) + now (now-iso) + new-session (assoc session + :id id + :status (or (:status session) :idle) + :created-at now + :updated-at now)] + (swap! sessions assoc id new-session) + new-session)) + + (update-session [_ id data] + (let [now (now-iso)] + (swap! sessions update id merge data {:updated-at now}) + (get @sessions id))) + + (delete-session [_ id] + (swap! messages (fn [msgs] + (into {} (remove #(= (:session-id (val %)) id) msgs)))) + (swap! sessions dissoc id) + nil) + + (get-messages [_ session-id] + (->> @messages + vals + (filter #(= (:session-id %) session-id)) + (sort-by :created-at) + vec)) + + (save-message [this message] + (let [id (or (:id message) (generate-id)) + now (now-iso) + new-message (assoc message + :id id + :created-at now)] + (swap! messages assoc id new-message) + ;; Update session's updated-at + (when-let [session-id (:session-id message)] + (swap! sessions update session-id assoc :updated-at now)) + new-message)) + + (get-message [_ id] + (get @messages id))) + +(defn create-store + "Create an in-memory store for testing" + [] + (->MemoryStore (atom {}) (atom {}))) + +(defn clear-store! + "Clear all data from an in-memory store" + [store] + (reset! (:sessions store) {}) + (reset! (:messages store) {})) diff --git a/server/src/spiceflow/db/protocol.clj b/server/src/spiceflow/db/protocol.clj new file mode 100644 index 0000000..dfa6043 --- /dev/null +++ b/server/src/spiceflow/db/protocol.clj @@ -0,0 +1,38 @@ +(ns spiceflow.db.protocol) + +(defprotocol DataStore + "Protocol for database operations. Implementations can be SQLite, in-memory, etc." + + ;; Session operations + (get-sessions [this] + "Get all sessions") + (get-session [this id] + "Get a single session by ID") + (save-session [this session] + "Save a new session. Returns the saved session with ID.") + (update-session [this id data] + "Update session fields. Returns updated session.") + (delete-session [this id] + "Delete a session and its messages") + + ;; Message operations + (get-messages [this session-id] + "Get all messages for a session, ordered by created_at") + (save-message [this message] + "Save a new message. Returns the saved message with ID.") + (get-message [this id] + "Get a single message by ID")) + +(defn valid-session? + "Validate session data has required fields" + [{:keys [provider]}] + (and provider + (contains? #{:claude :opencode "claude" "opencode"} provider))) + +(defn valid-message? + "Validate message data has required fields" + [{:keys [session-id role content]}] + (and session-id + role + (contains? #{:user :assistant :system "user" "assistant" "system"} role) + content)) diff --git a/server/src/spiceflow/db/sqlite.clj b/server/src/spiceflow/db/sqlite.clj new file mode 100644 index 0000000..f00b1fb --- /dev/null +++ b/server/src/spiceflow/db/sqlite.clj @@ -0,0 +1,194 @@ +(ns spiceflow.db.sqlite + (:require [next.jdbc :as jdbc] + [next.jdbc.result-set :as rs] + [next.jdbc.sql :as sql] + [spiceflow.db.protocol :as proto] + [jsonista.core :as json] + [clojure.string :as str]) + (:import [java.util UUID] + [java.time Instant])) + +(def ^:private mapper (json/object-mapper {:decode-key-fn keyword})) + +(defn- generate-id [] + (str (UUID/randomUUID))) + +(defn- now-iso [] + (.toString (Instant/now))) + +(defn- row->session + "Convert a database row to a session map" + [row] + (when row + {:id (:sessions/id row) + :provider (keyword (:sessions/provider row)) + :external-id (:sessions/external_id row) + :title (:sessions/title row) + :working-dir (:sessions/working_dir row) + :status (keyword (or (:sessions/status row) "idle")) + :created-at (:sessions/created_at row) + :updated-at (:sessions/updated_at row)})) + +(defn- row->message + "Convert a database row to a message map" + [row] + (when row + {:id (:messages/id row) + :session-id (:messages/session_id row) + :role (keyword (:messages/role row)) + :content (:messages/content row) + :metadata (when-let [m (:messages/metadata row)] + (json/read-value m mapper)) + :created-at (:messages/created_at row)})) + +(defn- session->row + "Convert a session map to database columns" + [{:keys [id provider external-id title working-dir status]}] + (cond-> {} + id (assoc :id id) + provider (assoc :provider (name provider)) + external-id (assoc :external_id external-id) + title (assoc :title title) + working-dir (assoc :working_dir working-dir) + status (assoc :status (name status)))) + +(defn- message->row + "Convert a message map to database columns" + [{:keys [id session-id role content metadata]}] + (cond-> {} + id (assoc :id id) + session-id (assoc :session_id session-id) + role (assoc :role (name role)) + content (assoc :content content) + metadata (assoc :metadata (json/write-value-as-string metadata)))) + +(defrecord SQLiteStore [datasource] + proto/DataStore + + (get-sessions [_] + (let [rows (jdbc/execute! datasource + ["SELECT * FROM sessions ORDER BY updated_at DESC"] + {:builder-fn rs/as-unqualified-kebab-maps})] + (mapv (fn [row] + {:id (:id row) + :provider (keyword (:provider row)) + :external-id (:external-id row) + :title (:title row) + :working-dir (:working-dir row) + :status (keyword (or (:status row) "idle")) + :created-at (:created-at row) + :updated-at (:updated-at row)}) + rows))) + + (get-session [_ id] + (let [row (jdbc/execute-one! datasource + ["SELECT * FROM sessions WHERE id = ?" id] + {:builder-fn rs/as-unqualified-kebab-maps})] + (when row + {:id (:id row) + :provider (keyword (:provider row)) + :external-id (:external-id row) + :title (:title row) + :working-dir (:working-dir row) + :status (keyword (or (:status row) "idle")) + :created-at (:created-at row) + :updated-at (:updated-at row)}))) + + (save-session [this session] + (let [id (or (:id session) (generate-id)) + now (now-iso) + row (-> (session->row session) + (assoc :id id + :created_at now + :updated_at now))] + (sql/insert! datasource :sessions row) + (proto/get-session this id))) + + (update-session [this id data] + (let [row (-> (session->row data) + (assoc :updated_at (now-iso)))] + (sql/update! datasource :sessions row {:id id}) + (proto/get-session this id))) + + (delete-session [_ id] + (jdbc/execute! datasource ["DELETE FROM messages WHERE session_id = ?" id]) + (jdbc/execute! datasource ["DELETE FROM sessions WHERE id = ?" id]) + nil) + + (get-messages [_ session-id] + (let [rows (jdbc/execute! datasource + ["SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC" + session-id] + {:builder-fn rs/as-unqualified-kebab-maps})] + (mapv (fn [row] + {:id (:id row) + :session-id (:session-id row) + :role (keyword (:role row)) + :content (:content row) + :metadata (when-let [m (:metadata row)] + (json/read-value m mapper)) + :created-at (:created-at row)}) + rows))) + + (save-message [this message] + (let [id (or (:id message) (generate-id)) + now (now-iso) + row (-> (message->row message) + (assoc :id id + :created_at now))] + (sql/insert! datasource :messages row) + ;; Update session's updated_at + (jdbc/execute! datasource + ["UPDATE sessions SET updated_at = ? WHERE id = ?" + now (:session-id message)]) + (proto/get-message this id))) + + (get-message [_ id] + (let [row (jdbc/execute-one! datasource + ["SELECT * FROM messages WHERE id = ?" id] + {:builder-fn rs/as-unqualified-kebab-maps})] + (when row + {:id (:id row) + :session-id (:session-id row) + :role (keyword (:role row)) + :content (:content row) + :metadata (when-let [m (:metadata row)] + (json/read-value m mapper)) + :created-at (:created-at row)})))) + +(def schema + "SQLite schema for spiceflow" + ["CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + external_id TEXT, + title TEXT, + working_dir TEXT, + status TEXT DEFAULT 'idle', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )" + "CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + metadata TEXT, + created_at TEXT DEFAULT (datetime('now')) + )" + "CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)" + "CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider)" + "CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"]) + +(defn init-schema! + "Initialize database schema" + [datasource] + (doseq [stmt schema] + (jdbc/execute! datasource [stmt]))) + +(defn create-store + "Create a SQLite store with the given database path" + [db-path] + (let [datasource (jdbc/get-datasource {:dbtype "sqlite" :dbname db-path})] + (init-schema! datasource) + (->SQLiteStore datasource))) diff --git a/server/src/spiceflow/session/manager.clj b/server/src/spiceflow/session/manager.clj new file mode 100644 index 0000000..a2a220e --- /dev/null +++ b/server/src/spiceflow/session/manager.clj @@ -0,0 +1,122 @@ +(ns spiceflow.session.manager + "Session lifecycle management" + (:require [spiceflow.db.protocol :as db] + [spiceflow.adapters.protocol :as adapter] + [spiceflow.adapters.claude :as claude] + [spiceflow.adapters.opencode :as opencode] + [clojure.tools.logging :as log]) + (:import [java.util.concurrent ConcurrentHashMap])) + +;; Active process handles for running sessions +(defonce ^:private active-processes (ConcurrentHashMap.)) + +(defn get-adapter + "Get the appropriate adapter for a provider" + [provider] + (case (keyword provider) + :claude (claude/create-adapter) + :opencode (opencode/create-adapter) + (throw (ex-info "Unknown provider" {:provider provider})))) + +(defn discover-all-sessions + "Discover sessions from all configured providers" + [] + (let [claude-sessions (adapter/discover-sessions (claude/create-adapter)) + opencode-sessions (adapter/discover-sessions (opencode/create-adapter))] + (concat claude-sessions opencode-sessions))) + +(defn import-session + "Import a discovered session into the database" + [store session] + (db/save-session store session)) + +(defn get-active-process + "Get the active process handle for a session" + [session-id] + (.get active-processes session-id)) + +(defn session-running? + "Check if a session has an active process" + [session-id] + (when-let [handle (get-active-process session-id)] + (adapter/running? handle))) + +(defn start-session + "Start a CLI process for a session" + [store session-id] + (let [session (db/get-session store session-id)] + (when-not session + (throw (ex-info "Session not found" {:session-id session-id}))) + (when (session-running? session-id) + (throw (ex-info "Session already running" {:session-id session-id}))) + (let [adapter (get-adapter (:provider session)) + handle (adapter/spawn-session adapter + (:external-id session) + {:working-dir (:working-dir session)})] + (.put active-processes session-id handle) + (db/update-session store session-id {:status :running}) + handle))) + +(defn stop-session + "Stop a running CLI process for a session" + [store session-id] + (when-let [handle (.remove active-processes session-id)] + (let [session (db/get-session store session-id) + adapter (get-adapter (:provider session))] + (adapter/kill-process adapter handle) + (db/update-session store session-id {:status :idle})))) + +(defn send-message-to-session + "Send a message to a running session" + [store session-id message] + (let [session (db/get-session store session-id) + _ (when-not session + (throw (ex-info "Session not found" {:session-id session-id}))) + handle (get-active-process session-id) + ;; Start session if not running + handle (or handle (start-session store session-id)) + adapter (get-adapter (:provider session))] + ;; Save user message + (db/save-message store {:session-id session-id + :role :user + :content message}) + ;; Send to CLI + (adapter/send-message adapter handle message))) + +(defn stream-session-response + "Stream response from a running session, calling callback for each event" + [store session-id callback] + (let [session (db/get-session store session-id) + _ (when-not session + (throw (ex-info "Session not found" {:session-id session-id}))) + handle (get-active-process session-id) + _ (when-not handle + (throw (ex-info "Session not running" {:session-id session-id}))) + adapter (get-adapter (:provider session)) + content-buffer (StringBuilder.)] + ;; Read stream and accumulate content + (adapter/read-stream adapter handle + (fn [event] + (callback event) + ;; Accumulate text content + (when-let [text (:text event)] + (.append content-buffer text)) + ;; On message stop, save the accumulated message + (when (= :message-stop (:event event)) + (let [content (.toString content-buffer)] + (when (seq content) + (db/save-message store {:session-id session-id + :role :assistant + :content content})))))) + ;; Update session status when stream ends + (db/update-session store session-id {:status :idle}) + (.remove active-processes session-id))) + +(defn cleanup-all + "Stop all running sessions" + [store] + (doseq [session-id (keys active-processes)] + (try + (stop-session store session-id) + (catch Exception e + (log/warn "Failed to stop session:" session-id (.getMessage e)))))) diff --git a/server/test/spiceflow/adapters_test.clj b/server/test/spiceflow/adapters_test.clj new file mode 100644 index 0000000..139f226 --- /dev/null +++ b/server/test/spiceflow/adapters_test.clj @@ -0,0 +1,80 @@ +(ns spiceflow.adapters-test + (:require [clojure.test :refer [deftest testing is]] + [spiceflow.adapters.protocol :as proto] + [spiceflow.adapters.claude :as claude] + [spiceflow.adapters.opencode :as opencode])) + +(deftest test-claude-adapter + (testing "Provider name" + (let [adapter (claude/create-adapter)] + (is (= :claude (proto/provider-name adapter))))) + + (testing "Parse JSONL messages" + (let [adapter (claude/create-adapter)] + ;; User message + (let [parsed (proto/parse-output adapter + "{\"type\":\"user\",\"message\":{\"content\":\"Hello\"},\"timestamp\":\"2024-01-01T00:00:00Z\"}")] + (is (= :user (:role parsed))) + (is (= "Hello" (:content parsed)))) + + ;; Assistant message + (let [parsed (proto/parse-output adapter + "{\"type\":\"assistant\",\"message\":{\"content\":\"Hi!\"},\"timestamp\":\"2024-01-01T00:00:00Z\"}")] + (is (= :assistant (:role parsed))) + (is (= "Hi!" (:content parsed)))) + + ;; Content delta (streaming) + (let [parsed (proto/parse-output adapter + "{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"text\":\"Hello\"}}")] + (is (= :content-delta (:event parsed))) + (is (= "Hello" (:text parsed)))) + + ;; Message stop + (let [parsed (proto/parse-output adapter + "{\"type\":\"message_stop\"}")] + (is (= :message-stop (:event parsed)))) + + ;; Empty line + (is (nil? (proto/parse-output adapter ""))) + (is (nil? (proto/parse-output adapter " ")))))) + +(deftest test-opencode-adapter + (testing "Provider name" + (let [adapter (opencode/create-adapter)] + (is (= :opencode (proto/provider-name adapter))))) + + (testing "Parse stream output" + (let [adapter (opencode/create-adapter)] + ;; JSON content + (let [parsed (proto/parse-output adapter "{\"content\":\"Hello\"}")] + (is (= :content-delta (:event parsed))) + (is (= "Hello" (:text parsed)))) + + ;; Plain text + (let [parsed (proto/parse-output adapter "Some output text")] + (is (= :content-delta (:event parsed))) + (is (= "Some output text" (:text parsed)))) + + ;; Done message + (let [parsed (proto/parse-output adapter "{\"done\":true}")] + (is (= :message-stop (:event parsed))))))) + +(deftest test-discover-sessions + (testing "Claude adapter discovers sessions (may be empty)" + (let [adapter (claude/create-adapter) + sessions (proto/discover-sessions adapter)] + (is (vector? sessions)) + (doseq [session sessions] + (is (:external-id session)) + (is (= :claude (:provider session)))))) + + (testing "OpenCode adapter discovers sessions (may fail if not installed)" + (let [adapter (opencode/create-adapter)] + ;; This may fail if opencode is not installed, so we just check + ;; that it doesn't throw an unexpected exception + (try + (let [sessions (proto/discover-sessions adapter)] + (is (or (nil? sessions) (vector? sessions)))) + (catch Exception _ + ;; OK if opencode is not installed + (is true)))))) diff --git a/server/test/spiceflow/db_test.clj b/server/test/spiceflow/db_test.clj new file mode 100644 index 0000000..da38f49 --- /dev/null +++ b/server/test/spiceflow/db_test.clj @@ -0,0 +1,86 @@ +(ns spiceflow.db-test + (:require [clojure.test :refer [deftest testing is use-fixtures]] + [spiceflow.db.protocol :as db] + [spiceflow.db.memory :as memory] + [spiceflow.db.sqlite :as sqlite])) + +;; Test both implementations +(def ^:dynamic *store* nil) + +(defn memory-fixture [f] + (binding [*store* (memory/create-store)] + (f))) + +(defn sqlite-fixture [f] + (let [db-path (str "/tmp/spiceflow-test-" (System/currentTimeMillis) ".db")] + (binding [*store* (sqlite/create-store db-path)] + (try + (f) + (finally + (clojure.java.io/delete-file db-path true)))))) + +;; Session tests +(deftest test-session-crud + (testing "Create and retrieve session" + (let [session (db/save-session *store* {:provider :claude + :external-id "test-123" + :title "Test Session" + :working-dir "/home/test"})] + (is (:id session)) + (is (= :claude (:provider session))) + (is (= "test-123" (:external-id session))) + (is (:created-at session)) + + ;; Get session + (let [retrieved (db/get-session *store* (:id session))] + (is (= (:id session) (:id retrieved))) + (is (= "Test Session" (:title retrieved)))))) + + (testing "List sessions" + (let [sessions (db/get-sessions *store*)] + (is (>= (count sessions) 1)))) + + (testing "Update session" + (let [session (first (db/get-sessions *store*)) + updated (db/update-session *store* (:id session) {:title "Updated Title" + :status :running})] + (is (= "Updated Title" (:title updated))) + (is (= :running (:status updated))))) + + (testing "Delete session" + (let [session (first (db/get-sessions *store*))] + (db/delete-session *store* (:id session)) + (is (nil? (db/get-session *store* (:id session))))))) + +;; Message tests +(deftest test-message-crud + (testing "Create and retrieve messages" + (let [session (db/save-session *store* {:provider :claude + :external-id "msg-test" + :title "Message Test"}) + msg1 (db/save-message *store* {:session-id (:id session) + :role :user + :content "Hello"}) + msg2 (db/save-message *store* {:session-id (:id session) + :role :assistant + :content "Hi there!" + :metadata {:model "claude-3"}})] + (is (:id msg1)) + (is (:id msg2)) + + ;; Get messages + (let [messages (db/get-messages *store* (:id session))] + (is (= 2 (count messages))) + (is (= :user (:role (first messages)))) + (is (= :assistant (:role (second messages)))) + (is (= {:model "claude-3"} (:metadata (second messages))))) + + ;; Get single message + (let [retrieved (db/get-message *store* (:id msg1))] + (is (= "Hello" (:content retrieved))))))) + +;; Run with memory store +(use-fixtures :each memory-fixture) + +;; Uncomment to run with SQLite +;; (use-fixtures :each sqlite-fixture) diff --git a/server/tests.edn b/server/tests.edn new file mode 100644 index 0000000..9e7c718 --- /dev/null +++ b/server/tests.edn @@ -0,0 +1,4 @@ +#kaocha/v1 +{:tests [{:id :unit + :test-paths ["test"]}] + :reporter [kaocha.report/dots]}