diff --git a/PLATFORM.md b/PLATFORM.md index ff7bca6a0a..f22042544d 100644 --- a/PLATFORM.md +++ b/PLATFORM.md @@ -28,7 +28,7 @@ This document tracks all platform-specific code and dependencies across PAI, pro - **Status:** Fixed with conditional `uname -s` detection 2. ✅ `/opt/homebrew/bin` hardcoded in PATH - - **Files:** `pai-observability-server/src/observability/manage.sh:8`, `pai-observability-server.md:1316` + - **Files:** `pai-observability-server/src/Observability/manage.sh:8`, `pai-observability-server.md:1316` - **Fix:** Conditional PATH based on directory existence - **Status:** Fixed with `[ -d "/opt/homebrew/bin" ]` check diff --git a/Packs/pai-observability-server/src/observability/apps/client/index.html b/Packs/pai-observability-server/src/observability/apps/client/index.html deleted file mode 100755 index 3e68f0c340..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Agents - - -
- - - diff --git a/Packs/pai-observability-server/src/observability/apps/client/package.json b/Packs/pai-observability-server/src/observability/apps/client/package.json deleted file mode 100755 index 47a522146e..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "multi-agent-observability-client", - "private": true, - "version": "1.2.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vue-tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "lucide-vue-next": "^0.548.0", - "vue": "^3.5.17" - }, - "devDependencies": { - "@types/node": "^22.11.2", - "@vitejs/plugin-vue": "^6.0.0", - "@vue/tsconfig": "^0.7.0", - "autoprefixer": "^10.4.20", - "postcss": "^8.5.3", - "tailwindcss": "^3.4.16", - "typescript": "~5.8.3", - "vite": "^7.0.4", - "vue-tsc": "^2.2.12" - } -} diff --git a/Packs/pai-observability-server/src/observability/apps/client/postcss.config.js b/Packs/pai-observability-server/src/observability/apps/client/postcss.config.js deleted file mode 100755 index e99ebc2c0e..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} \ No newline at end of file diff --git a/Packs/pai-observability-server/src/observability/apps/client/src/App.vue b/Packs/pai-observability-server/src/observability/apps/client/src/App.vue deleted file mode 100755 index 80f3c63318..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/src/App.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - \ No newline at end of file diff --git a/Packs/pai-observability-server/src/observability/apps/client/src/main.ts b/Packs/pai-observability-server/src/observability/apps/client/src/main.ts deleted file mode 100755 index 064c7a1994..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/src/main.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createApp } from 'vue' -import './assets/fonts.css' -import './styles/main.css' -import './styles/themes.css' -import './styles/compact.css' -import App from './App.vue' - -createApp(App).mount('#app') diff --git a/Packs/pai-observability-server/src/observability/apps/client/src/style.css b/Packs/pai-observability-server/src/observability/apps/client/src/style.css deleted file mode 100755 index 41e4388427..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/src/style.css +++ /dev/null @@ -1,104 +0,0 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; - overflow-x: hidden; /* Prevent horizontal scrolling on mobile */ -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -.card { - padding: 2em; -} - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} - -/* Mobile optimizations */ -@media (max-width: 699px) { - body { - place-items: stretch; /* Allow full width on mobile */ - } - - #app { - max-width: none; /* Remove max-width constraint on mobile */ - padding: 0; /* Remove default padding on mobile */ - text-align: left; /* Left-align text on mobile for better readability */ - } - - /* Improve touch targets on mobile */ - button, select, input[type="button"], input[type="submit"] { - min-height: 44px; - min-width: 44px; - } - - /* Optimize text selection on mobile */ - * { - -webkit-tap-highlight-color: transparent; - } -} diff --git a/Packs/pai-observability-server/src/observability/apps/client/tailwind.config.js b/Packs/pai-observability-server/src/observability/apps/client/tailwind.config.js deleted file mode 100755 index 53cbf2afde..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/tailwind.config.js +++ /dev/null @@ -1,146 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{vue,js,ts,jsx,tsx}", - ], - darkMode: 'class', - theme: { - extend: { - fontFamily: { - "equity-text-b": ["equity-text-b", "Georgia", "serif"], - "concourse-t3": ["concourse-t3", "sans-serif"], - "concourse-c3": ["concourse-c3", "sans-serif"], - "advocate": ["advocate", "sans-serif"], - "valkyrie-text": ["valkyrie-text", "Georgia", "serif"], - "sans": ["concourse-t3", "system-ui", "-apple-system", "sans-serif"], - "serif": ["valkyrie-text", "Georgia", "serif"], - "mono": ["ui-monospace", "SFMono-Regular", "monospace"], - }, - screens: { - 'mobile': {'max': '699px'}, // Custom mobile breakpoint for < 700px - 'short': {'raw': '(max-height: 400px)'}, // Custom breakpoint for height <= 400px - }, - colors: { - // Theme-aware colors using CSS custom properties - 'theme': { - 'primary': 'var(--theme-primary)', - 'primary-hover': 'var(--theme-primary-hover)', - 'primary-light': 'var(--theme-primary-light)', - 'primary-dark': 'var(--theme-primary-dark)', - 'bg': { - 'primary': 'var(--theme-bg-primary)', - 'secondary': 'var(--theme-bg-secondary)', - 'tertiary': 'var(--theme-bg-tertiary)', - 'quaternary': 'var(--theme-bg-quaternary)', - }, - 'text': { - 'primary': 'var(--theme-text-primary)', - 'secondary': 'var(--theme-text-secondary)', - 'tertiary': 'var(--theme-text-tertiary)', - 'quaternary': 'var(--theme-text-quaternary)', - }, - 'border': { - 'primary': 'var(--theme-border-primary)', - 'secondary': 'var(--theme-border-secondary)', - 'tertiary': 'var(--theme-border-tertiary)', - }, - 'accent': { - 'success': 'var(--theme-accent-success)', - 'warning': 'var(--theme-accent-warning)', - 'error': 'var(--theme-accent-error)', - 'info': 'var(--theme-accent-info)', - } - } - }, - boxShadow: { - 'theme': 'var(--theme-shadow)', - 'theme-lg': 'var(--theme-shadow-lg)', - }, - transitionProperty: { - 'theme': 'var(--theme-transition)', - 'theme-fast': 'var(--theme-transition-fast)', - } - }, - }, - plugins: [], - safelist: [ - // Background colors - 'bg-blue-500', - 'bg-green-500', - 'bg-yellow-500', - 'bg-purple-500', - 'bg-pink-500', - 'bg-indigo-500', - 'bg-red-500', - 'bg-orange-500', - 'bg-teal-500', - 'bg-cyan-500', - // Border colors - 'border-blue-500', - 'border-green-500', - 'border-yellow-500', - 'border-purple-500', - 'border-pink-500', - 'border-indigo-500', - 'border-red-500', - 'border-orange-500', - 'border-teal-500', - 'border-cyan-500', - // Gradient colors - 'from-blue-500', - 'to-blue-600', - 'from-green-500', - 'to-green-600', - 'from-yellow-500', - 'to-yellow-600', - 'from-purple-500', - 'to-purple-600', - 'from-pink-500', - 'to-pink-600', - 'from-indigo-500', - 'to-indigo-600', - 'from-red-500', - 'to-red-600', - 'from-orange-500', - 'to-orange-600', - 'from-teal-500', - 'to-teal-600', - 'from-cyan-500', - 'to-cyan-600', - // Theme classes - 'theme-bg-primary', - 'theme-bg-secondary', - 'theme-bg-tertiary', - 'theme-bg-quaternary', - 'theme-text-primary', - 'theme-text-secondary', - 'theme-text-tertiary', - 'theme-text-quaternary', - 'theme-border-primary', - 'theme-border-secondary', - 'theme-border-tertiary', - 'theme-primary', - 'theme-primary-bg', - 'theme-primary-border', - 'theme-accent-success', - 'theme-accent-warning', - 'theme-accent-error', - 'theme-accent-info', - 'theme-shadow', - 'theme-shadow-lg', - 'theme-transition', - 'theme-transition-fast', - 'theme-hover', - 'theme-active', - 'theme-focus', - 'backdrop-blur', - // Theme class names - 'theme-light', - 'theme-dark', - 'theme-modern', - 'theme-earth', - 'theme-glass', - 'theme-high-contrast', - ] -} \ No newline at end of file diff --git a/Packs/pai-observability-server/src/observability/apps/client/vite.config.ts b/Packs/pai-observability-server/src/observability/apps/client/vite.config.ts deleted file mode 100755 index 08eed6896b..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/client/vite.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [vue()], - server: { - port: 5172, - strictPort: true, - }, -}) diff --git a/Packs/pai-observability-server/src/observability/apps/server/package.json b/Packs/pai-observability-server/src/observability/apps/server/package.json deleted file mode 100755 index cadaeb2b7c..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/server/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "multi-agent-observability-server", - "version": "1.2.0", - "module": "src/index.ts", - "type": "module", - "private": true, - "scripts": { - "dev": "bun --watch src/index.ts", - "start": "bun src/index.ts", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/ws": "^8.5.13", - "typescript": "^5.8.3" - }, - "dependencies": {} -} diff --git a/Packs/pai-observability-server/src/observability/apps/server/src/file-ingest.ts b/Packs/pai-observability-server/src/observability/apps/server/src/file-ingest.ts deleted file mode 100755 index 01a1bfc644..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/server/src/file-ingest.ts +++ /dev/null @@ -1,504 +0,0 @@ -#!/usr/bin/env bun -/** - * Projects-based Event Streaming (In-Memory Only) - * Watches Claude Code's native projects/ directory for session transcripts - * NO DATABASE - streams directly to WebSocket clients - * Fresh start each time - no persistence - * - * Replaces RAW-based ingestion - reads from native Claude Code storage - */ - -import { watch, existsSync, readdirSync, statSync } from 'fs'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; -import type { HookEvent } from './types'; - -// In-memory event store (last N events only) -const MAX_EVENTS = 1000; -const events: HookEvent[] = []; - -// Track the last read position for each file -const filePositions = new Map(); - -// Track which files we're currently watching -const watchedFiles = new Set(); - -// Callback for when new events arrive (for WebSocket broadcasting) -let onEventsReceived: ((events: HookEvent[]) => void) | null = null; - -// Agent session mapping (session_id -> agent_name) -const agentSessions = new Map(); - -// Todo tracking per session (session_id -> current todos) -const sessionTodos = new Map(); - -// Projects directory path - dynamically constructed from username -const PROJECTS_DIR = join(homedir(), '.claude', 'projects', `-Users-${process.env.USER || 'user'}--claude`); - -/** - * Get the most recently modified JSONL files in projects/ - */ -function getRecentSessionFiles(limit: number = 50): string[] { - if (!existsSync(PROJECTS_DIR)) { - console.log('⚠️ Projects directory not found:', PROJECTS_DIR); - return []; - } - - const files = readdirSync(PROJECTS_DIR) - .filter(f => f.endsWith('.jsonl')) - .map(f => ({ - name: f, - path: join(PROJECTS_DIR, f), - mtime: statSync(join(PROJECTS_DIR, f)).mtime.getTime() - })) - .sort((a, b) => b.mtime - a.mtime) - .slice(0, limit); - - return files.map(f => f.path); -} - -/** - * Parse a Claude Code projects JSONL entry and convert to HookEvent format - */ -function parseProjectsEntry(entry: any): HookEvent | null { - // Skip queue operations - if (entry.type === 'queue-operation') { - return null; - } - - // Skip summary entries - if (entry.type === 'summary') { - return null; - } - - const rawTimestamp = entry.timestamp || new Date().toISOString(); - const sessionId = entry.sessionId || 'unknown'; - - // Convert timestamp to numeric (ms since epoch) for client chart compatibility - const timestamp = typeof rawTimestamp === 'string' - ? new Date(rawTimestamp).getTime() - : rawTimestamp; - - // Base event structure - const baseEvent: Partial = { - source_app: 'claude-code', - session_id: sessionId, - timestamp: timestamp, - timestamp_pst: new Date(timestamp).toLocaleString('en-US', { timeZone: 'America/Los_Angeles' }), - }; - - // User message -> UserPromptSubmit - if (entry.type === 'user' && entry.message?.role === 'user') { - const content = entry.message.content; - let userText = ''; - - if (typeof content === 'string') { - userText = content; - } else if (Array.isArray(content)) { - // Check if it's a tool result - const toolResult = content.find((c: any) => c.type === 'tool_result'); - if (toolResult) { - return { - ...baseEvent, - hook_event_type: 'PostToolUse', - payload: { - tool_use_id: toolResult.tool_use_id, - tool_result: typeof toolResult.content === 'string' - ? toolResult.content.slice(0, 500) - : JSON.stringify(toolResult.content).slice(0, 500) - }, - summary: `Tool result received` - } as HookEvent; - } - - // Regular text content - userText = content - .filter((c: any) => c.type === 'text') - .map((c: any) => c.text) - .join(' '); - } - - return { - ...baseEvent, - hook_event_type: 'UserPromptSubmit', - payload: { - prompt: userText.slice(0, 500) - }, - summary: userText.slice(0, 100) - } as HookEvent; - } - - // Assistant message -> Stop or PreToolUse - if (entry.type === 'assistant' && entry.message?.role === 'assistant') { - const content = entry.message.content; - - if (Array.isArray(content)) { - // Check for tool_use - const toolUse = content.find((c: any) => c.type === 'tool_use'); - if (toolUse) { - return { - ...baseEvent, - hook_event_type: 'PreToolUse', - payload: { - tool_name: toolUse.name, - tool_input: toolUse.input - }, - summary: `${toolUse.name}: ${JSON.stringify(toolUse.input).slice(0, 100)}` - } as HookEvent; - } - - // Text response -> Stop event - const textContent = content.find((c: any) => c.type === 'text'); - if (textContent) { - return { - ...baseEvent, - hook_event_type: 'Stop', - payload: { - response: textContent.text?.slice(0, 500) - }, - summary: textContent.text?.slice(0, 100) - } as HookEvent; - } - } - } - - return null; -} - -/** - * Read new events from a JSONL file starting from a given position - */ -function readNewEvents(filePath: string): HookEvent[] { - if (!existsSync(filePath)) { - return []; - } - - const lastPosition = filePositions.get(filePath) || 0; - - try { - const content = readFileSync(filePath, 'utf-8'); - const newContent = content.slice(lastPosition); - - // Update position to end of file - filePositions.set(filePath, content.length); - - if (!newContent.trim()) { - return []; - } - - // Parse JSONL - one JSON object per line - const lines = newContent.trim().split('\n'); - const newEvents: HookEvent[] = []; - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const entry = JSON.parse(line); - const event = parseProjectsEntry(entry); - - if (event) { - // Add auto-incrementing ID for UI - event.id = events.length + newEvents.length + 1; - // Enrich with agent name - const enrichedEvent = enrichEventWithAgentName(event); - // Process todo events (returns array of events) - const processedEvents = processTodoEvent(enrichedEvent); - // Reassign IDs for any synthetic events - for (let i = 0; i < processedEvents.length; i++) { - processedEvents[i].id = events.length + newEvents.length + i + 1; - } - newEvents.push(...processedEvents); - } - } catch (error) { - // Skip malformed lines silently - } - } - - return newEvents; - } catch (error) { - console.error(`Error reading file ${filePath}:`, error); - return []; - } -} - -/** - * Add events to in-memory store (keeping last MAX_EVENTS only) - */ -function storeEvents(newEvents: HookEvent[]): void { - if (newEvents.length === 0) return; - - // Add to in-memory array - events.push(...newEvents); - - // Keep only last MAX_EVENTS - if (events.length > MAX_EVENTS) { - events.splice(0, events.length - MAX_EVENTS); - } - - console.log(`✅ Received ${newEvents.length} event(s) (${events.length} in memory)`); - - // Notify subscribers (WebSocket clients) - if (onEventsReceived) { - onEventsReceived(newEvents); - } -} - -/** - * Load agent sessions from agent-sessions.json - */ -function loadAgentSessions(): void { - const sessionsFile = join(homedir(), '.claude', 'MEMORY', 'STATE', 'agent-sessions.json'); - - if (!existsSync(sessionsFile)) { - console.log('⚠️ agent-sessions.json not found, agent names will be "unknown"'); - return; - } - - try { - const content = readFileSync(sessionsFile, 'utf-8'); - const data = JSON.parse(content); - - agentSessions.clear(); - Object.entries(data).forEach(([sessionId, agentName]) => { - agentSessions.set(sessionId, agentName as string); - }); - - console.log(`✅ Loaded ${agentSessions.size} agent sessions`); - } catch (error) { - console.error('❌ Error loading agent-sessions.json:', error); - } -} - -/** - * Watch agent-sessions.json for changes - */ -function watchAgentSessions(): void { - const sessionsFile = join(homedir(), '.claude', 'MEMORY', 'STATE', 'agent-sessions.json'); - - if (!existsSync(sessionsFile)) { - console.log('⚠️ agent-sessions.json not found, skipping watch'); - return; - } - - console.log('👀 Watching agent-sessions.json for changes'); - - const watcher = watch(sessionsFile, (eventType) => { - if (eventType === 'change') { - console.log('🔄 agent-sessions.json changed, reloading...'); - loadAgentSessions(); - } - }); - - watcher.on('error', (error) => { - console.error('❌ Error watching agent-sessions.json:', error); - }); -} - -/** - * Enrich event with agent name from session mapping - */ -function enrichEventWithAgentName(event: HookEvent): HookEvent { - // Special case: UserPromptSubmit events are from Daniel, not the agent - if (event.hook_event_type === 'UserPromptSubmit') { - return { - ...event, - agent_name: 'Daniel' - }; - } - - // Default to DA name for main agent sessions (from settings.json env) - const mainAgentName = process.env.DA || 'PAI'; - - // If source_app is set to a sub-agent type (not the main agent), respect it - const subAgentTypes = ['artist', 'intern', 'engineer', 'pentester', 'architect', 'designer', 'qatester', 'researcher']; - if (event.source_app && subAgentTypes.includes(event.source_app.toLowerCase())) { - const capitalizedName = event.source_app.charAt(0).toUpperCase() + event.source_app.slice(1); - return { - ...event, - agent_name: capitalizedName - }; - } - - const agentName = agentSessions.get(event.session_id) || mainAgentName; - return { - ...event, - agent_name: agentName - }; -} - -/** - * Process todo events and detect completions - */ -function processTodoEvent(event: HookEvent): HookEvent[] { - // Only process TodoWrite tool events - if (event.payload?.tool_name !== 'TodoWrite') { - return [event]; - } - - const currentTodos = event.payload.tool_input?.todos || []; - const previousTodos = sessionTodos.get(event.session_id) || []; - - // Find newly completed todos - const completedTodos = []; - - for (const currentTodo of currentTodos) { - if (currentTodo.status === 'completed') { - const prevTodo = previousTodos.find((t: any) => t.content === currentTodo.content); - if (!prevTodo || prevTodo.status !== 'completed') { - completedTodos.push(currentTodo); - } - } - } - - // Update session todos - sessionTodos.set(event.session_id, currentTodos); - - // Create synthetic completion events - const resultEvents: HookEvent[] = [event]; - - for (const completedTodo of completedTodos) { - const completionEvent: HookEvent = { - ...event, - id: event.id, - hook_event_type: 'Completed', - payload: { - task: completedTodo.content - }, - summary: undefined, - timestamp: event.timestamp - }; - resultEvents.push(completionEvent); - } - - return resultEvents; -} - -/** - * Watch a file for changes and stream new events - */ -function watchFile(filePath: string): void { - if (watchedFiles.has(filePath)) { - return; - } - - console.log(`👀 Watching: ${filePath.split('/').pop()}`); - watchedFiles.add(filePath); - - // Set file position to END - only read NEW events from now on - if (existsSync(filePath)) { - const content = readFileSync(filePath, 'utf-8'); - filePositions.set(filePath, content.length); - } - - // Watch for changes - const watcher = watch(filePath, (eventType) => { - if (eventType === 'change') { - const newEvents = readNewEvents(filePath); - storeEvents(newEvents); - } - }); - - watcher.on('error', (error) => { - console.error(`Error watching ${filePath}:`, error); - watchedFiles.delete(filePath); - }); -} - -/** - * Watch the projects directory for new session files - */ -function watchProjectsDirectory(): void { - if (!existsSync(PROJECTS_DIR)) { - console.log('⚠️ Projects directory not found, skipping watch'); - return; - } - - console.log('👀 Watching projects directory for new sessions'); - - const watcher = watch(PROJECTS_DIR, (eventType, filename) => { - if (filename && filename.endsWith('.jsonl')) { - const filePath = join(PROJECTS_DIR, filename); - if (existsSync(filePath) && !watchedFiles.has(filePath)) { - // New session file appeared, start watching it - watchFile(filePath); - } - } - }); - - watcher.on('error', (error) => { - console.error('❌ Error watching projects directory:', error); - }); -} - -/** - * Start watching for events - * @param callback Optional callback to be notified when new events arrive - */ -export function startFileIngestion(callback?: (events: HookEvent[]) => void): void { - console.log('🚀 Starting projects-based event streaming (in-memory only)'); - console.log(`📂 Reading from ${PROJECTS_DIR}/`); - - // Set the callback for event notifications - if (callback) { - onEventsReceived = callback; - } - - // Load and watch agent sessions for name enrichment - loadAgentSessions(); - watchAgentSessions(); - - // Get recent session files and watch them - const recentFiles = getRecentSessionFiles(20); - console.log(`📁 Found ${recentFiles.length} recent session files`); - - for (const filePath of recentFiles) { - watchFile(filePath); - } - - // Watch for new session files - watchProjectsDirectory(); - - console.log('✅ Projects streaming started'); -} - -/** - * Get all events currently in memory - */ -export function getRecentEvents(limit: number = 100): HookEvent[] { - return events.slice(-limit).reverse(); -} - -/** - * Get filter options from in-memory events - */ -export function getFilterOptions() { - const sourceApps = new Set(); - const sessionIds = new Set(); - const hookEventTypes = new Set(); - - for (const event of events) { - if (event.source_app) sourceApps.add(event.source_app); - if (event.session_id) sessionIds.add(event.session_id); - if (event.hook_event_type) hookEventTypes.add(event.hook_event_type); - } - - return { - source_apps: Array.from(sourceApps).sort(), - session_ids: Array.from(sessionIds).slice(0, 100), - hook_event_types: Array.from(hookEventTypes).sort() - }; -} - -// For testing - can be run directly -if (import.meta.main) { - startFileIngestion(); - - console.log('Press Ctrl+C to stop'); - - process.on('SIGINT', () => { - console.log('\n👋 Shutting down...'); - process.exit(0); - }); -} diff --git a/Packs/pai-observability-server/src/observability/apps/server/src/index.ts b/Packs/pai-observability-server/src/observability/apps/server/src/index.ts deleted file mode 100755 index b80be344d7..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/server/src/index.ts +++ /dev/null @@ -1,509 +0,0 @@ -import type { HookEvent } from './types'; -import { - createTheme, - updateThemeById, - getThemeById, - searchThemes, - deleteThemeById, - exportThemeById, - importTheme, - getThemeStats -} from './theme'; -import { startFileIngestion, getRecentEvents, getFilterOptions } from './file-ingest'; -import { startTaskWatcher, getAllTasks, getTask, getTaskOutput, type BackgroundTask } from './task-watcher'; - -// Store WebSocket clients -const wsClients = new Set(); - -// Start file-based ingestion (reads from ~/.claude/projects/) -// Pass a callback to broadcast new events to connected WebSocket clients -startFileIngestion((events) => { - // Broadcast each event to all connected WebSocket clients - events.forEach(event => { - const message = JSON.stringify({ type: 'event', data: event }); - wsClients.forEach(client => { - try { - client.send(message); - } catch (err) { - // Client disconnected, remove from set - wsClients.delete(client); - } - }); - }); -}); - -// Start background task watcher -startTaskWatcher((task: BackgroundTask) => { - // Broadcast task updates to all connected WebSocket clients - const message = JSON.stringify({ type: 'task_update', data: task }); - wsClients.forEach(client => { - try { - client.send(message); - } catch (err) { - wsClients.delete(client); - } - }); -}); - -// Create Bun server with HTTP and WebSocket support -const server = Bun.serve({ - port: 4000, - - async fetch(req: Request) { - const url = new URL(req.url); - - // Handle CORS - const headers = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }; - - // Handle preflight - if (req.method === 'OPTIONS') { - return new Response(null, { headers }); - } - - // GET /events/filter-options - Get available filter options - if (url.pathname === '/events/filter-options' && req.method === 'GET') { - const options = getFilterOptions(); - return new Response(JSON.stringify(options), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // GET /events/recent - Get recent events - if (url.pathname === '/events/recent' && req.method === 'GET') { - const limit = parseInt(url.searchParams.get('limit') || '100'); - const events = getRecentEvents(limit); - return new Response(JSON.stringify(events), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // GET /events/by-agent/:agentName - Get events for specific agent - if (url.pathname.startsWith('/events/by-agent/') && req.method === 'GET') { - const agentName = decodeURIComponent(url.pathname.split('/')[3]); - const limit = parseInt(url.searchParams.get('limit') || '100'); - - if (!agentName) { - return new Response(JSON.stringify({ - error: 'Agent name is required' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - const allEvents = getRecentEvents(limit); - const agentEvents = allEvents.filter(e => e.agent_name === agentName); - - return new Response(JSON.stringify(agentEvents), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // Theme API endpoints - - // POST /api/themes - Create a new theme - if (url.pathname === '/api/themes' && req.method === 'POST') { - try { - const themeData = await req.json(); - const result = await createTheme(themeData); - - const status = result.success ? 201 : 400; - return new Response(JSON.stringify(result), { - status, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } catch (error) { - console.error('Error creating theme:', error); - return new Response(JSON.stringify({ - success: false, - error: 'Invalid request body' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - } - - // GET /api/themes - Search themes - if (url.pathname === '/api/themes' && req.method === 'GET') { - const query = { - query: url.searchParams.get('query') || undefined, - isPublic: url.searchParams.get('isPublic') ? url.searchParams.get('isPublic') === 'true' : undefined, - authorId: url.searchParams.get('authorId') || undefined, - sortBy: url.searchParams.get('sortBy') as any || undefined, - sortOrder: url.searchParams.get('sortOrder') as any || undefined, - limit: url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!) : undefined, - offset: url.searchParams.get('offset') ? parseInt(url.searchParams.get('offset')!) : undefined, - }; - - const result = await searchThemes(query); - return new Response(JSON.stringify(result), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // GET /api/themes/:id - Get a specific theme - if (url.pathname.startsWith('/api/themes/') && req.method === 'GET') { - const id = url.pathname.split('/')[3]; - if (!id) { - return new Response(JSON.stringify({ - success: false, - error: 'Theme ID is required' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - const result = await getThemeById(id); - const status = result.success ? 200 : 404; - return new Response(JSON.stringify(result), { - status, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // PUT /api/themes/:id - Update a theme - if (url.pathname.startsWith('/api/themes/') && req.method === 'PUT') { - const id = url.pathname.split('/')[3]; - if (!id) { - return new Response(JSON.stringify({ - success: false, - error: 'Theme ID is required' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - try { - const updates = await req.json(); - const result = await updateThemeById(id, updates); - - const status = result.success ? 200 : 400; - return new Response(JSON.stringify(result), { - status, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } catch (error) { - console.error('Error updating theme:', error); - return new Response(JSON.stringify({ - success: false, - error: 'Invalid request body' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - } - - // DELETE /api/themes/:id - Delete a theme - if (url.pathname.startsWith('/api/themes/') && req.method === 'DELETE') { - const id = url.pathname.split('/')[3]; - if (!id) { - return new Response(JSON.stringify({ - success: false, - error: 'Theme ID is required' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - const authorId = url.searchParams.get('authorId'); - const result = await deleteThemeById(id, authorId || undefined); - - const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 403); - return new Response(JSON.stringify(result), { - status, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // GET /api/themes/:id/export - Export a theme - if (url.pathname.match(/^\/api\/themes\/[^\/]+\/export$/) && req.method === 'GET') { - const id = url.pathname.split('/')[3]; - - const result = await exportThemeById(id); - if (!result.success) { - const status = result.error?.includes('not found') ? 404 : 400; - return new Response(JSON.stringify(result), { - status, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - return new Response(JSON.stringify(result.data), { - headers: { - ...headers, - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="${result.data.theme.name}.json"` - } - }); - } - - // POST /api/themes/import - Import a theme - if (url.pathname === '/api/themes/import' && req.method === 'POST') { - try { - const importData = await req.json(); - const authorId = url.searchParams.get('authorId'); - - const result = await importTheme(importData, authorId || undefined); - - const status = result.success ? 201 : 400; - return new Response(JSON.stringify(result), { - status, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } catch (error) { - console.error('Error importing theme:', error); - return new Response(JSON.stringify({ - success: false, - error: 'Invalid import data' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - } - - // GET /api/themes/stats - Get theme statistics - if (url.pathname === '/api/themes/stats' && req.method === 'GET') { - const result = await getThemeStats(); - return new Response(JSON.stringify(result), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // GET /api/activities - Get current activities from Kitty tab titles - if (url.pathname === '/api/activities' && req.method === 'GET') { - try { - // Run kitty @ ls to get tab/window info - const proc = Bun.spawn(['kitty', '@', 'ls'], { - stdout: 'pipe', - stderr: 'pipe' - }); - - const stdout = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - if (exitCode !== 0) { - return new Response(JSON.stringify([]), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - const kittyData = JSON.parse(stdout); - const activities: { agent: string; activity: string; timestamp: string }[] = []; - - // Parse ALL Kitty tabs - just return their titles as-is - for (const osWindow of kittyData) { - for (const tab of osWindow.tabs || []) { - // Strip trailing ellipsis and leading "N: " tab number prefix - const title = (tab.title || '') - .replace(/\.{3}$/, '') - .replace(/^\d+:\s*/, '') - .trim(); - - if (!title) continue; - - activities.push({ - agent: process.env.DA || 'main', - activity: title, - timestamp: new Date().toISOString() - }); - } - } - - return new Response(JSON.stringify(activities), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } catch (error) { - console.error('Error fetching Kitty activities:', error); - return new Response(JSON.stringify([]), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - } - - // POST /api/haiku/summarize - Proxy for Haiku summarization (reads API key from ${PAI_DIR}/.env) - if (url.pathname === '/api/haiku/summarize' && req.method === 'POST') { - try { - // Load .env from ${PAI_DIR}/.env - const homeDir = process.env.HOME || ''; - const paiDir = process.env.PAI_DIR || `${homeDir}/.claude`; - const envPath = `${paiDir}/.env`; - - let apiKey = ''; - try { - const envFile = await Bun.file(envPath).text(); - const match = envFile.match(/ANTHROPIC_API_KEY=(.+)/); - if (match) { - apiKey = match[1].trim(); - } - } catch (err) { - console.error('Failed to read .env:', err); - } - - if (!apiKey) { - return new Response(JSON.stringify({ - success: false, - error: 'ANTHROPIC_API_KEY not configured in ${PAI_DIR}/.env' - }), { - status: 500, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - const body = await req.json(); - const { prompt } = body; - - if (!prompt) { - return new Response(JSON.stringify({ - success: false, - error: 'Missing prompt' - }), { - status: 400, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // Call Anthropic API - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01' - }, - body: JSON.stringify({ - model: 'claude-haiku-4-5', - max_tokens: 50, - messages: [{ - role: 'user', - content: prompt - }] - }) - }); - - if (!response.ok) { - const error = await response.text(); - return new Response(JSON.stringify({ - success: false, - error: `Haiku API error: ${response.status}`, - details: error - }), { - status: response.status, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - const data = await response.json(); - return new Response(JSON.stringify({ - success: true, - text: data.content?.[0]?.text || '' - }), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } catch (error) { - console.error('Error in Haiku proxy:', error); - return new Response(JSON.stringify({ - success: false, - error: 'Internal server error' - }), { - status: 500, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - } - - // GET /api/tasks - List all background tasks - if (url.pathname === '/api/tasks' && req.method === 'GET') { - const tasks = getAllTasks(); - return new Response(JSON.stringify(tasks), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // GET /api/tasks/:taskId - Get a specific task - if (url.pathname.match(/^\/api\/tasks\/[^\/]+$/) && req.method === 'GET') { - const taskId = url.pathname.split('/')[3]; - const task = getTask(taskId); - - if (!task) { - return new Response(JSON.stringify({ error: 'Task not found' }), { - status: 404, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - return new Response(JSON.stringify(task), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // GET /api/tasks/:taskId/output - Get full task output - if (url.pathname.match(/^\/api\/tasks\/[^\/]+\/output$/) && req.method === 'GET') { - const taskId = url.pathname.split('/')[3]; - const output = getTaskOutput(taskId); - - if (!output) { - return new Response(JSON.stringify({ error: 'Task output not found' }), { - status: 404, - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - return new Response(JSON.stringify({ output }), { - headers: { ...headers, 'Content-Type': 'application/json' } - }); - } - - // WebSocket upgrade - if (url.pathname === '/stream') { - const success = server.upgrade(req); - if (success) { - return undefined; - } - } - - // Default response - return new Response('Multi-Agent Observability Server', { - headers: { ...headers, 'Content-Type': 'text/plain' } - }); - }, - - websocket: { - open(ws) { - console.log('WebSocket client connected'); - wsClients.add(ws); - - // Send recent events on connection - const events = getRecentEvents(50); - ws.send(JSON.stringify({ type: 'initial', data: events })); - }, - - message(ws, message) { - // Handle any client messages if needed - console.log('Received message:', message); - }, - - close(ws) { - console.log('WebSocket client disconnected'); - wsClients.delete(ws); - }, - - error(ws, error) { - console.error('WebSocket error:', error); - wsClients.delete(ws); - } - } -}); - -console.log(`🚀 Server running on http://localhost:${server.port}`); -console.log(`📊 WebSocket endpoint: ws://localhost:${server.port}/stream`); -console.log(`📮 POST events to: http://localhost:${server.port}/events`); \ No newline at end of file diff --git a/Packs/pai-observability-server/src/observability/apps/server/src/types.ts b/Packs/pai-observability-server/src/observability/apps/server/src/types.ts deleted file mode 100755 index 585f61f9b5..0000000000 --- a/Packs/pai-observability-server/src/observability/apps/server/src/types.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Todo item interface -export interface TodoItem { - content: string; - status: 'pending' | 'in_progress' | 'completed'; - activeForm: string; -} - -// New interface for human-in-the-loop requests -export interface HumanInTheLoop { - question: string; - responseWebSocketUrl: string; - type: 'question' | 'permission' | 'choice'; - choices?: string[]; // For multiple choice questions - timeout?: number; // Optional timeout in seconds - requiresResponse?: boolean; // Whether response is required or optional -} - -// Response interface -export interface HumanInTheLoopResponse { - response?: string; - permission?: boolean; - choice?: string; // Selected choice from options - hookEvent: HookEvent; - respondedAt: number; - respondedBy?: string; // Optional user identifier -} - -// Status tracking interface -export interface HumanInTheLoopStatus { - status: 'pending' | 'responded' | 'timeout' | 'error'; - respondedAt?: number; - response?: HumanInTheLoopResponse; -} - -export interface HookEvent { - id?: number; - source_app: string; - session_id: string; - agent_name?: string; // Agent name enriched from MEMORY/STATE/agent-sessions.json - hook_event_type: string; - payload: Record; - chat?: any[]; - summary?: string; - timestamp?: number; - model_name?: string; - - // NEW: Optional HITL data - humanInTheLoop?: HumanInTheLoop; - humanInTheLoopStatus?: HumanInTheLoopStatus; - - // NEW: Optional Todo data - todos?: TodoItem[]; - completedTodos?: TodoItem[]; // Todos that were completed in this event -} - -export interface FilterOptions { - source_apps: string[]; - session_ids: string[]; - hook_event_types: string[]; -} - -// Theme-related interfaces for server-side storage and API -export interface ThemeColors { - primary: string; - primaryHover: string; - primaryLight: string; - primaryDark: string; - bgPrimary: string; - bgSecondary: string; - bgTertiary: string; - bgQuaternary: string; - textPrimary: string; - textSecondary: string; - textTertiary: string; - textQuaternary: string; - borderPrimary: string; - borderSecondary: string; - borderTertiary: string; - accentSuccess: string; - accentWarning: string; - accentError: string; - accentInfo: string; - shadow: string; - shadowLg: string; - hoverBg: string; - activeBg: string; - focusRing: string; -} - -export interface Theme { - id: string; - name: string; - displayName: string; - description?: string; - colors: ThemeColors; - isPublic: boolean; - authorId?: string; - authorName?: string; - createdAt: number; - updatedAt: number; - tags: string[]; - downloadCount?: number; - rating?: number; - ratingCount?: number; -} - -export interface ThemeSearchQuery { - query?: string; - tags?: string[]; - authorId?: string; - isPublic?: boolean; - sortBy?: 'name' | 'created' | 'updated' | 'downloads' | 'rating'; - sortOrder?: 'asc' | 'desc'; - limit?: number; - offset?: number; -} - -export interface ThemeShare { - id: string; - themeId: string; - shareToken: string; - expiresAt?: number; - isPublic: boolean; - allowedUsers: string[]; - createdAt: number; - accessCount: number; -} - -export interface ThemeRating { - id: string; - themeId: string; - userId: string; - rating: number; // 1-5 - comment?: string; - createdAt: number; -} - -export interface ThemeValidationError { - field: string; - message: string; - code: string; -} - -export interface ApiResponse { - success: boolean; - data?: T; - error?: string; - message?: string; - validationErrors?: ThemeValidationError[]; -} \ No newline at end of file