init commit

This commit is contained in:
2026-01-18 22:07:48 -05:00
parent 9c019e3d41
commit 56dde9cf91
43 changed files with 2925 additions and 0 deletions
+42
View File
@@ -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/
+154
View File
@@ -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
+30
View File
@@ -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": {}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+64
View File
@@ -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);
}
}
+13
View File
@@ -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 {};
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#f97316" />
<meta name="description" content="Spiceflow - AI Session Orchestration" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-zinc-900 text-zinc-100">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+236
View File
@@ -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<string, unknown>;
'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<T>(path: string, options?: RequestInit): Promise<T> {
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<Session[]> {
return this.request<Session[]>('/sessions');
}
async getSession(id: string): Promise<Session> {
return this.request<Session>(`/sessions/${id}`);
}
async createSession(session: Partial<Session>): Promise<Session> {
return this.request<Session>('/sessions', {
method: 'POST',
body: JSON.stringify(session)
});
}
async deleteSession(id: string): Promise<void> {
await this.request<void>(`/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<DiscoveredSession[]> {
return this.request<DiscoveredSession[]>('/discover/claude');
}
async discoverOpenCode(): Promise<DiscoveredSession[]> {
return this.request<DiscoveredSession[]>('/discover/opencode');
}
async importSession(session: DiscoveredSession): Promise<Session> {
return this.request<Session>('/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<string, Set<(event: StreamEvent) => 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<void> {
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<string, unknown>) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
disconnect() {
this.ws?.close();
this.ws = null;
}
}
export const wsClient = new WebSocketClient();
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let disabled = false;
export let placeholder = 'Type a message...';
const dispatch = createEventDispatcher<{ send: string }>();
let message = '';
let textarea: HTMLTextAreaElement;
function handleSubmit() {
const trimmed = message.trim();
if (!trimmed || disabled) return;
dispatch('send', trimmed);
message = '';
resizeTextarea();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
}
</script>
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
<div class="flex items-end gap-2 p-3">
<textarea
bind:this={textarea}
bind:value={message}
on:keydown={handleKeydown}
on:input={resizeTextarea}
{placeholder}
{disabled}
rows="1"
class="input resize-none min-h-[44px] max-h-[150px] py-3"
></textarea>
<button
type="submit"
disabled={disabled || !message.trim()}
class="btn btn-primary h-[44px] px-4 flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"
/>
</svg>
</button>
</div>
</form>
@@ -0,0 +1,114 @@
<script lang="ts">
import type { Message } from '$lib/api';
import { onMount, afterUpdate } from 'svelte';
export let messages: Message[] = [];
export let streamingContent: string = '';
let container: HTMLDivElement;
let autoScroll = true;
function scrollToBottom() {
if (autoScroll && container) {
container.scrollTop = container.scrollHeight;
}
}
function handleScroll() {
if (!container) return;
const threshold = 100;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
autoScroll = distanceFromBottom < threshold;
}
onMount(() => {
scrollToBottom();
});
afterUpdate(() => {
scrollToBottom();
});
const roleStyles = {
user: 'bg-spice-500/20 border-spice-500/30',
assistant: 'bg-zinc-800 border-zinc-700',
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
};
const roleLabels = {
user: 'You',
assistant: 'Assistant',
system: 'System'
};
</script>
<div
bind:this={container}
on:scroll={handleScroll}
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{#if messages.length === 0 && !streamingContent}
<div class="h-full flex items-center justify-center">
<div class="text-center text-zinc-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 mx-auto mb-3 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p>No messages yet</p>
<p class="text-sm mt-1">Send a message to start the conversation</p>
</div>
</div>
{:else}
{#each messages as message (message.id)}
<div class="rounded-lg border p-3 {roleStyles[message.role]}">
<div class="flex items-center gap-2 mb-2">
<span
class="text-xs font-semibold uppercase tracking-wide {message.role === 'user'
? 'text-spice-400'
: 'text-zinc-400'}"
>
{roleLabels[message.role]}
</span>
</div>
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
{message.content}
</div>
</div>
{/each}
{#if streamingContent}
<div class="rounded-lg border p-3 {roleStyles.assistant}">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
Assistant
</span>
<span class="flex gap-1">
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
<span
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
style="animation-delay: 0.1s"
></span>
<span
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
style="animation-delay: 0.2s"
></span>
</span>
</div>
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
{streamingContent}<span class="animate-pulse">|</span>
</div>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { Session } from '$lib/api';
export let session: Session;
$: externalId = session['external-id'] || session.externalId || '';
$: workingDir = session['working-dir'] || session.workingDir || '';
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
$: shortId = externalId.slice(0, 8);
$: projectName = workingDir.split('/').pop() || workingDir;
function formatTime(iso: string): string {
if (!iso) return '';
const date = new Date(iso);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
}
const statusColors = {
idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500'
};
const providerColors = {
claude: 'text-spice-400',
opencode: 'text-emerald-400'
};
</script>
<a
href="/session/{session.id}"
class="card block hover:border-spice-500/50 transition-all active:scale-[0.98]"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 rounded-full {statusColors[session.status]}"></span>
<span class="text-xs font-medium uppercase tracking-wide {providerColors[session.provider]}">
{session.provider}
</span>
</div>
<h3 class="font-medium text-zinc-100 truncate">
{session.title || `Session ${shortId}`}
</h3>
{#if projectName}
<p class="text-sm text-zinc-400 truncate mt-1">
{projectName}
</p>
{/if}
</div>
<div class="text-right flex-shrink-0">
<span class="text-xs text-zinc-500">{formatTime(updatedAt)}</span>
</div>
</div>
</a>
+3
View File
@@ -0,0 +1,3 @@
// Spiceflow client library exports
export * from './api';
export * from './stores/sessions';
+207
View File
@@ -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<SessionsState>({
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<Session>) {
update((s) => ({
...s,
sessions: s.sessions.map((session) =>
session.id === id ? { ...session, ...data } : session
)
}));
}
};
}
function createActiveSessionStore() {
const { subscribe, set, update } = writable<ActiveSessionState>({
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<Session[]> = 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<Session[]> = derived(sessions, ($sessions) =>
$sessions.sessions.filter((s) => s.status === 'running')
);
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { sessions } from '$lib/stores/sessions';
onMount(() => {
sessions.load();
});
</script>
<div class="h-screen flex flex-col bg-zinc-900 text-zinc-100 safe-top">
<slot />
</div>
+2
View File
@@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;
+245
View File
@@ -0,0 +1,245 @@
<script lang="ts">
import { sessions, sortedSessions, runningSessions } from '$lib/stores/sessions';
import { api, type DiscoveredSession } from '$lib/api';
import SessionCard from '$lib/components/SessionCard.svelte';
let discovering = false;
let discoveredSessions: DiscoveredSession[] = [];
let showDiscovery = false;
async function refresh() {
await sessions.load();
}
async function discoverSessions() {
discovering = true;
try {
const [claude, opencode] = await Promise.all([
api.discoverClaude().catch(() => []),
api.discoverOpenCode().catch(() => [])
]);
discoveredSessions = [...claude, ...opencode];
showDiscovery = true;
} finally {
discovering = false;
}
}
async function importSession(session: DiscoveredSession) {
await api.importSession(session);
discoveredSessions = discoveredSessions.filter(
(s) => s['external-id'] !== session['external-id']
);
await sessions.load();
if (discoveredSessions.length === 0) {
showDiscovery = false;
}
}
</script>
<svelte:head>
<title>Spiceflow</title>
</svelte:head>
<!-- Header -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold text-spice-500">Spiceflow</h1>
<p class="text-xs text-zinc-500">The spice must flow</p>
</div>
<div class="flex items-center gap-2">
<button
on:click={refresh}
disabled={$sessions.loading}
class="btn btn-secondary p-2"
title="Refresh"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 {$sessions.loading ? 'animate-spin' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button
on:click={discoverSessions}
disabled={discovering}
class="btn btn-primary"
>
{#if discovering}
<span class="animate-spin inline-block mr-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
</span>
{/if}
Discover
</button>
</div>
</div>
{#if $runningSessions.length > 0}
<div class="mt-2 px-2 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
<span class="text-xs text-green-400">
{$runningSessions.length} session{$runningSessions.length === 1 ? '' : 's'} running
</span>
</div>
{/if}
</header>
<!-- Content -->
<main class="flex-1 overflow-y-auto">
{#if $sessions.error}
<div class="m-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
{$sessions.error}
</div>
{/if}
{#if $sessions.loading && $sortedSessions.length === 0}
<div class="flex items-center justify-center h-full">
<div class="text-center text-zinc-500">
<svg class="animate-spin h-8 w-8 mx-auto mb-3" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
<p>Loading sessions...</p>
</div>
</div>
{:else if $sortedSessions.length === 0}
<div class="flex items-center justify-center h-full p-4">
<div class="text-center text-zinc-500 max-w-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<h2 class="text-lg font-medium text-zinc-300 mb-2">No sessions yet</h2>
<p class="text-sm mb-4">
Click "Discover" to find existing Claude Code or OpenCode sessions on your machine.
</p>
</div>
</div>
{:else}
<div class="p-4 space-y-3">
{#each $sortedSessions as session (session.id)}
<SessionCard {session} />
{/each}
</div>
{/if}
</main>
<!-- Discovery Modal -->
{#if showDiscovery}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center"
on:click={() => (showDiscovery = false)}
on:keydown={(e) => e.key === 'Escape' && (showDiscovery = false)}
role="dialog"
tabindex="-1"
>
<div
class="bg-zinc-900 w-full sm:max-w-lg sm:rounded-xl rounded-t-xl border border-zinc-700 max-h-[80vh] flex flex-col"
on:click|stopPropagation
on:keydown|stopPropagation
role="document"
>
<div class="p-4 border-b border-zinc-700 flex items-center justify-between">
<h2 class="text-lg font-semibold">Discovered Sessions</h2>
<button
on:click={() => (showDiscovery = false)}
class="p-1 hover:bg-zinc-700 rounded"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
{#if discoveredSessions.length === 0}
<p class="text-zinc-500 text-center py-8">No new sessions found.</p>
{:else}
<div class="space-y-3">
{#each discoveredSessions as session}
<div class="card flex items-center justify-between">
<div class="flex-1 min-w-0 mr-3">
<div class="flex items-center gap-2">
<span
class="text-xs font-medium uppercase {session.provider === 'claude'
? 'text-spice-400'
: 'text-emerald-400'}"
>
{session.provider}
</span>
</div>
<p class="text-sm text-zinc-300 truncate">
{session.title || session['external-id'].slice(0, 8)}
</p>
{#if session['working-dir']}
<p class="text-xs text-zinc-500 truncate">
{session['working-dir']}
</p>
{/if}
</div>
<button
on:click={() => importSession(session)}
class="btn btn-primary text-sm py-1.5"
>
Import
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { activeSession } from '$lib/stores/sessions';
import MessageList from '$lib/components/MessageList.svelte';
import InputBar from '$lib/components/InputBar.svelte';
$: sessionId = $page.params.id;
onMount(() => {
if (sessionId) {
activeSession.load(sessionId);
}
});
onDestroy(() => {
activeSession.clear();
});
function handleSend(event: CustomEvent<string>) {
activeSession.sendMessage(event.detail);
}
function goBack() {
goto('/');
}
$: session = $activeSession.session;
$: externalId = session?.['external-id'] || session?.externalId || '';
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
$: shortId = externalId.slice(0, 8);
$: projectName = workingDir.split('/').pop() || '';
const statusColors: Record<string, string> = {
idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500'
};
</script>
<svelte:head>
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
</svelte:head>
<!-- Header -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
<div class="flex items-center gap-3">
<button
on:click={goBack}
class="p-1 -ml-1 hover:bg-zinc-700 rounded transition-colors"
aria-label="Go back"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
{#if $activeSession.loading}
<div class="flex-1">
<div class="h-5 w-32 bg-zinc-700 rounded animate-pulse"></div>
<div class="h-4 w-24 bg-zinc-800 rounded animate-pulse mt-1"></div>
</div>
{:else if session}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
<h1 class="font-semibold truncate">
{session.title || `Session ${shortId}`}
</h1>
</div>
{#if projectName}
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
{/if}
</div>
<span
class="text-xs font-medium uppercase {session.provider === 'claude'
? 'text-spice-400'
: 'text-emerald-400'}"
>
{session.provider}
</span>
{/if}
</div>
</header>
<!-- Content -->
{#if $activeSession.error}
<div class="flex-1 flex items-center justify-center p-4">
<div class="text-center">
<div class="text-red-400 mb-4">{$activeSession.error}</div>
<button on:click={goBack} class="btn btn-secondary">Go Back</button>
</div>
</div>
{:else if $activeSession.loading}
<div class="flex-1 flex items-center justify-center">
<svg class="animate-spin h-8 w-8 text-spice-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
</div>
{:else}
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} />
<InputBar
on:send={handleSend}
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
placeholder={session?.status === 'running' ? 'Waiting for response...' : 'Type a message...'}
/>
{/if}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="38" fill="#18181b"/>
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
<circle cx="96" cy="96" r="23" fill="#18181b"/>
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+1
View File
@@ -0,0 +1 @@
favicon.svg
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="#18181b"/>
<path d="M50 20 L75 35 L75 65 L50 80 L25 65 L25 35 Z" fill="url(#spice)"/>
<circle cx="50" cy="50" r="12" fill="#18181b"/>
<circle cx="50" cy="50" r="6" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 525 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="38" fill="#18181b"/>
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
<circle cx="96" cy="96" r="23" fill="#18181b"/>
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="38" fill="#18181b"/>
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
<circle cx="96" cy="96" r="23" fill="#18181b"/>
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+21
View File
@@ -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;
+27
View File
@@ -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: []
};
+14
View File
@@ -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"
}
}
+70
View File
@@ -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
}
}
}
});
+36
View File
@@ -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"}}}}}
+10
View File
@@ -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"]}}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="spiceflow" level="INFO"/>
<logger name="org.eclipse.jetty" level="WARN"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
+171
View File
@@ -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)))
+145
View File
@@ -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 <session-id>'"
[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)))
@@ -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)))
+141
View File
@@ -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])))
+108
View File
@@ -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)})
+28
View File
@@ -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))
+56
View File
@@ -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))
+79
View File
@@ -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) {}))
+38
View File
@@ -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))
+194
View File
@@ -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)))
+122
View File
@@ -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))))))
+80
View File
@@ -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))))))
+86
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
#kaocha/v1
{:tests [{:id :unit
:test-paths ["test"]}]
:reporter [kaocha.report/dots]}