From e003688658625e69e0b2ee0ef33e703a8ce8eff7 Mon Sep 17 00:00:00 2001 From: modimihir07 Date: Mon, 29 Jun 2026 14:08:57 +0530 Subject: [PATCH] v0.3.0: Event-driven scheduler, error dashboard, persistent memory, PWA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — Event-Driven Scheduler: - Rewrote scheduler/scheduler.py with file watcher + cron engine - Job auto-reload on file changes via watchdog-style polling - Execution history tracking (data/scheduler-history.json) - Webhook receiver endpoint: POST /api/webhook - Scheduler events endpoint: GET /api/scheduler/events - Manual job trigger: POST /api/scheduler/trigger/{id} - Generic catch-all webhook: POST /api/webhook/generic - Scheduler auto-starts with server via lifespan hooks Phase 2 — Error Dashboard + Circuit Breaker: - Error tracking endpoints: GET/POST/DELETE /api/errors - Circuit breaker pattern: /api/circuit-breaker (trip/reset) - New dashboard/pages/errors.js — error log + circuit breaker status - Error categories: agent, skill, api, system, general - Global error count badge in sidebar - Circuit breaker auto-recovery (half-open after 300s) Phase 3 — Persistent Memory + Auto-Skill-Generator: - brain/memory_search.py — SQLite FTS5 full-text search module - Indexes brain/*.md, skills/*/*.md, brain/journal/*.md - Entity extraction (persons, emails, URLs, acronyms, IPs) - Search endpoint: GET /api/memory/search?q= - Reindex endpoint: POST /api/memory/reindex - Entity listing: GET /api/memory/entities - Auto-skill-generator: POST /api/skills/generate (NL → SKILL.md) Phase 4 — Mobile PWA: - manifest.json at /manifest.json - Service worker at /sw.js (offline fallback) - Bottom navigation bar for mobile (<768px) - PWA meta tags (theme-color, apple-mobile-web-app) - Mobile responsive CSS (grid collapse, touch targets, safe-area) - Service worker registration in index.html - Input font-size 16px on touch devices (prevents iOS zoom) --- .gitignore | 6 + brain/memory_search.py | 161 ++++++++++++++++++++++++ dashboard/api.js | 14 +++ dashboard/app.js | 2 +- dashboard/index.html | 19 +++ dashboard/pages/errors.js | 157 +++++++++++++++++++++++ dashboard/styles.css | 71 +++++++++++ data/kanban/0f822987.json | 12 -- scheduler/scheduler.py | 211 ++++++++++++++++++++++++++----- server.py | 254 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 861 insertions(+), 46 deletions(-) create mode 100644 brain/memory_search.py create mode 100644 dashboard/pages/errors.js delete mode 100644 data/kanban/0f822987.json diff --git a/.gitignore b/.gitignore index f25e75c..b757d55 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ audit/* data/settings.json data/chat-history.json data/cost-history.json +data/scheduler-history.json +data/error-log.json +data/circuit-breaker.json +data/memory.db +data/kanban/*.json +scheduler/*.pyc diff --git a/brain/memory_search.py b/brain/memory_search.py new file mode 100644 index 0000000..384dc56 --- /dev/null +++ b/brain/memory_search.py @@ -0,0 +1,161 @@ +"""Agentic OS — Persistent Memory with SQLite FTS5 + +Full-text search across brain files, skills, journal, and prompts. +Auto-indexes text content on startup and provides search + entity extraction. +""" +import json +import re +import sqlite3 +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path + +BASE_DIR = Path(__file__).parent.resolve() +DB_PATH = BASE_DIR.parent / "data" / "memory.db" + +_local = threading.local() + +def _get_db(): + if not hasattr(_local, "conn") or _local.conn is None: + _local.conn = sqlite3.connect(str(DB_PATH)) + _local.conn.row_factory = sqlite3.Row + return _local.conn + +def init_db(): + conn = _get_db() + conn.executescript(""" + CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( + id, source, path, title, content, category, + tokenize='porter unicode61' + ); + CREATE TABLE IF NOT EXISTS memory_meta ( + id TEXT PRIMARY KEY, + source TEXT, + path TEXT, + title TEXT, + category TEXT, + created TEXT, + updated TEXT + ); + CREATE TABLE IF NOT EXISTS entities ( + id TEXT PRIMARY KEY, + name TEXT, + type TEXT, + context TEXT, + source TEXT, + created TEXT + ); + CREATE INDEX IF NOT EXISTS idx_meta_source ON memory_meta(source); + CREATE INDEX IF NOT EXISTS idx_meta_category ON memory_meta(category); + CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name); + """) + conn.commit() + +def index_text(source: str, path: str, title: str, content: str, category: str = "general"): + conn = _get_db() + doc_id = str(uuid.uuid4())[:8] + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "INSERT OR REPLACE INTO memory_meta (id, source, path, title, category, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?)", + (doc_id, source, path, title, category, now, now) + ) + conn.execute( + "INSERT INTO memory_fts (id, source, path, title, content, category) VALUES (?, ?, ?, ?, ?, ?)", + (doc_id, source, path, title, content, category) + ) + conn.commit() + return doc_id + +def search(query: str, limit: int = 20) -> list: + conn = _get_db() + if not query.strip(): + return [] + try: + rows = conn.execute( + "SELECT m.id, m.source, m.path, m.title, m.category, m.created, " + "snippet(memory_fts, 4, '', '', '...', 32) as snippet " + "FROM memory_fts JOIN memory_meta m ON memory_fts.id = m.id " + "WHERE memory_fts MATCH ? ORDER BY rank LIMIT ?", + (query, limit) + ).fetchall() + return [dict(r) for r in rows] + except sqlite3.OperationalError: + return [] + +def index_brain_files(): + brain_dir = BASE_DIR + for f in brain_dir.glob("*.md"): + content = f.read_text(encoding="utf-8") + title = f.stem.replace("-", " ").replace("_", " ").title() + index_text("brain", str(f.relative_to(BASE_DIR.parent)), title, content, "brain") + +def index_skills(): + skills_dir = BASE_DIR.parent / "skills" + for d in sorted(skills_dir.iterdir()): + if d.is_dir() and not d.name.startswith("_"): + for f in d.glob("*.md"): + content = f.read_text(encoding="utf-8") + index_text("skill", str(f.relative_to(BASE_DIR.parent)), f"{d.name}/{f.stem}", content, "skill") + +def index_journal(): + journal_dir = BASE_DIR / "journal" + if journal_dir.exists(): + for f in sorted(journal_dir.glob("*.md")): + content = f.read_text(encoding="utf-8") + index_text("journal", str(f.relative_to(BASE_DIR.parent)), f"Journal {f.stem}", content, "journal") + +def reindex_all(): + conn = _get_db() + conn.executescript("DELETE FROM memory_fts; DELETE FROM memory_meta;") + conn.commit() + index_brain_files() + index_skills() + index_journal() + +def extract_entities(text: str) -> list: + entities = [] + patterns = [ + (r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', 'person'), + (r'\b[\w.+-]+@[\w-]+\.[\w.-]+\b', 'email'), + (r'\bhttps?://[^\s<>"]+\b', 'url'), + (r'\b[A-Z]{2,}\b', 'acronym'), + (r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', 'ip'), + ] + seen = set() + for pattern, etype in patterns: + for match in re.finditer(pattern, text): + value = match.group() + if value not in seen: + seen.add(value) + entities.append({"value": value, "type": etype}) + return entities + +def save_entities(entities: list, source: str = "auto"): + conn = _get_db() + now = datetime.now(timezone.utc).isoformat() + for ent in entities: + eid = str(uuid.uuid4())[:8] + conn.execute( + "INSERT OR IGNORE INTO entities (id, name, type, context, source, created) VALUES (?, ?, ?, ?, ?, ?)", + (eid, ent["value"], ent["type"], ent.get("context", ""), source, now) + ) + conn.commit() + +def get_entities(entity_type: str = "", limit: int = 50) -> list: + conn = _get_db() + if entity_type: + rows = conn.execute( + "SELECT DISTINCT name, type, COUNT(*) as count FROM entities WHERE type = ? GROUP BY name ORDER BY count DESC LIMIT ?", + (entity_type, limit) + ).fetchall() + else: + rows = conn.execute( + "SELECT DISTINCT name, type, COUNT(*) as count FROM entities GROUP BY name ORDER BY count DESC LIMIT ?", + (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +# Initialize on import +init_db() diff --git a/dashboard/api.js b/dashboard/api.js index bdb2284..05c6240 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -89,4 +89,18 @@ const api = { // Session Replay listSessions: () => api.get('/api/sessions/list'), getSessionReplay: (id) => api.get(`/api/sessions/${encodeURIComponent(id)}/replay`), + // v0.3.0: Scheduler Events + getSchedulerEvents: (limit) => api.get(`/api/scheduler/events?limit=${limit || 50}`), + triggerJob: (id) => api.post(`/api/scheduler/trigger/${encodeURIComponent(id)}`, {}), + sendWebhook: (data) => api.post('/api/webhook', data), + // v0.3.0: Error Tracking + getErrors: (limit, category) => api.get(`/api/errors?limit=${limit || 50}${category ? `&category=${encodeURIComponent(category)}` : ''}`), + reportError: (data) => api.post('/api/errors/report', data), + clearErrors: () => api.del('/api/errors'), + // v0.3.0: Circuit Breaker + getCircuitBreaker: () => api.get('/api/circuit-breaker'), + tripCircuitBreaker: (agent) => api.post('/api/circuit-breaker/trip', { agent }), + resetCircuitBreaker: (agent) => api.post('/api/circuit-breaker/reset', { agent }), + // v0.3.0: PWA + getManifest: () => api.get('/manifest.json'), }; diff --git a/dashboard/app.js b/dashboard/app.js index a6ddaa6..a8295ef 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -32,7 +32,7 @@ async function navigate(page) { const bar = document.getElementById('topLoadingBar'); if (bar) { bar.classList.add('active'); bar.style.width = '30%'; } - document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active')); + document.querySelectorAll('.nav-item, .bottom-nav-item').forEach(el => el.classList.remove('active')); const navItem = document.querySelector(`[data-page="${hash}"]`); if (navItem) navItem.classList.add('active'); diff --git a/dashboard/index.html b/dashboard/index.html index 4f7899e..86ad6d4 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -5,6 +5,11 @@ Agentic OS + + + + + @@ -48,6 +53,7 @@ 🧭Smart Router 📊Learning Analytics 🔄Session Replay + Error Dashboard0 💰Cost Analytics 🔌Plugins @@ -88,8 +94,21 @@
+ + + diff --git a/dashboard/pages/errors.js b/dashboard/pages/errors.js new file mode 100644 index 0000000..9849862 --- /dev/null +++ b/dashboard/pages/errors.js @@ -0,0 +1,157 @@ +async function renderErrors() { + const content = document.getElementById('pageContent'); + content.innerHTML = ` + +
+
+
0
+
Total Errors
+
+
+
0
+
Agent Errors
+
+
+
0
+
Skill Errors
+
+
+
0
+
Circuit Breaks
+
+
+
+ + +
+
+
Circuit Breaker Status
+
+ `; + await Promise.all([refreshErrors(), loadCircuitBreaker()]); +} + +async function refreshErrors() { + const container = document.getElementById('errorList'); + if (!container) return; + const category = document.getElementById('errorCategoryFilter')?.value || ''; + try { + const data = await api.getErrors(100, category); + const errors = data.errors || []; + const totalEl = document.getElementById('errorTotalCount'); + if (totalEl) totalEl.textContent = errors.length; + const agentErrors = errors.filter(e => e.category === 'agent').length; + const skillErrors = errors.filter(e => e.category === 'skill').length; + const circuitErrors = errors.filter(e => e.category === 'circuit').length; + const aEl = document.getElementById('errorAgentCount'); + if (aEl) aEl.textContent = agentErrors; + const sEl = document.getElementById('errorSkillCount'); + if (sEl) sEl.textContent = skillErrors; + const cEl = document.getElementById('errorCircuitCount'); + if (cEl) cEl.textContent = circuitErrors; + const badge = document.getElementById('errorCountBadge'); + if (badge) { + if (errors.length > 0) { badge.style.display = 'inline'; badge.textContent = errors.length + ' issues'; } + else { badge.style.display = 'none'; } + } + if (errors.length === 0) { + container.innerHTML = '
No errors
System is running smoothly
'; + return; + } + container.innerHTML = ` +
+ + + + ${errors.slice().reverse().map(e => ` + + + + + + + + `).join('')} + +
TimeCategorySourceMessageID
${formatDate(e.timestamp)}${escapeHtml(e.category)}${escapeHtml(e.source)}${escapeHtml(e.message)}${e.id || ''}
+
+
${errors.length} error${errors.length !== 1 ? 's' : ''}
+ `; + } catch (err) { + if (container) container.innerHTML = `
${escapeHtml(err.message)}
`; + } +} + +async function clearAllErrors() { + if (!confirm('Clear all error logs?')) return; + try { + await api.clearErrors(); + showToast('Error log cleared', 'success'); + refreshErrors(); + } catch (err) { + showToast('Error: ' + err.message, 'error'); + } +} + +async function loadCircuitBreaker() { + const container = document.getElementById('circuitBreakerCards'); + if (!container) return; + try { + const data = await api.getCircuitBreaker(); + const agents = data.agents || {}; + const agentNames = Object.keys(agents); + if (agentNames.length === 0) { + container.innerHTML = '
🔌
No circuit breaker data
'; + return; + } + const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' }; + container.innerHTML = agentNames.map(a => { + const cb = agents[a] || {}; + const isOpen = cb.state === 'open'; + return ` +
+
+ ${agentIcons[a] || '🤖'} +
+
${escapeHtml(a)}
+
${cb.state || 'closed'}
+
+
+
+ Failures: ${cb.failures || 0} + Threshold: ${data.threshold || 3} +
+ ${isOpen ? `` : ''} +
+ `; + }).join(''); + } catch { + container.innerHTML = '
Failed to load circuit breaker
'; + } +} + +async function resetCircuit(agent) { + try { + await api.resetCircuitBreaker(agent); + showToast(`Circuit breaker reset for ${agent}`, 'success'); + loadCircuitBreaker(); + } catch (err) { + showToast('Error: ' + err.message, 'error'); + } +} diff --git a/dashboard/styles.css b/dashboard/styles.css index 54c27f9..265b310 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -1352,6 +1352,77 @@ pre { color: var(--text-muted); } +/* Bottom Navigation (PWA mobile) */ +.bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 64px; + background: var(--bg-secondary); + border-top: 1px solid var(--border); + z-index: 1000; + justify-content: space-around; + align-items: center; + padding-bottom: env(safe-area-inset-bottom, 0); +} +.bottom-nav-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 8px; + color: var(--text-muted); + text-decoration: none; + font-size: 10px; + transition: var(--transition); + min-width: 56px; + min-height: 44px; + justify-content: center; + border-radius: var(--radius-sm); + -webkit-tap-highlight-color: transparent; +} +.bottom-nav-item.active { + color: var(--accent-light); +} +.bottom-nav-item:active { + background: var(--accent-glow); +} +.bottom-nav-icon { + font-size: 20px; + line-height: 1; +} + +@media (max-width: 768px) { + .bottom-nav { display: flex; } + .main-content { padding-bottom: 80px; } + .sidebar { display: none; } + .topbar { padding: 10px 16px; } + .topbar-title { font-size: 16px; } + .grid { grid-template-columns: 1fr !important; } + .grid-2 { grid-template-columns: 1fr !important; } + .grid-3 { grid-template-columns: 1fr !important; } + .grid-4 { grid-template-columns: repeat(2, 1fr) !important; } + .form-row { flex-direction: column; } + .kanban-board { flex-direction: column; overflow-x: hidden; } + .kanban-column { min-width: 100%; max-height: 300px; } + .page-header { flex-direction: column; gap: 8px; } + .btn-group { width: 100%; } + .btn-group .btn, .btn-group .form-input, .btn-group .form-select { flex: 1; } + .metric-tile { padding: 12px; } + .stat-value { font-size: 20px; } + .table-wrapper { overflow-x: auto; } + .card { padding: 14px; } +} + +/* Touch-friendly: larger tap targets */ +@media (pointer: coarse) { + .nav-item, .btn, .bottom-nav-item { min-height: 44px; } + .form-input, .form-select, .form-textarea { font-size: 16px; } + input, select, textarea, button { font-size: 16px; } +} + /* Skeleton loader */ .skeleton { background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%); diff --git a/data/kanban/0f822987.json b/data/kanban/0f822987.json deleted file mode 100644 index 611d8f1..0000000 --- a/data/kanban/0f822987.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "0f822987", - "title": "Fix login bug", - "body": "The login page has a race condition", - "status": "todo", - "priority": "high", - "assignee": "opencode", - "comments": [], - "links": [], - "created": "2026-06-05T09:52:18.236452+00:00", - "updated": "2026-06-05T09:52:18.236473+00:00" -} \ No newline at end of file diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index 61638d3..0d9d217 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -1,60 +1,205 @@ #!/usr/bin/env python3 -"""Agentic OS — APScheduler engine for recurring tasks""" +"""Agentic OS — Event-Driven Scheduler Engine + +File watcher + cron-based scheduler with execution history. +Handles job reloading, webhook triggers, skill execution events. +""" import json +import os import subprocess import sys -from pathlib import Path +import threading +import time +import uuid from datetime import datetime, timezone - -try: - from apscheduler.schedulers.background import BackgroundScheduler - from apscheduler.triggers.cron import CronTrigger -except ImportError: - print("Install APScheduler: pip install apscheduler") - sys.exit(1) +from pathlib import Path +from typing import Optional, Callable BASE_DIR = Path(__file__).parent.resolve() JOBS_DIR = BASE_DIR / "jobs" +HISTORY_FILE = BASE_DIR.parent / "data" / "scheduler-history.json" -def run_skill(skill_name: str): - """Execute a skill by invoking the appropriate agent.""" +_event_listeners = [] +_on_files_changed = [] + +def on_event(listener: Callable): + _event_listeners.append(listener) + return listener + +def on_files_changed(cb: Callable): + _on_files_changed.append(cb) + return cb + +def emit_event(event: dict): + event["timestamp"] = datetime.now(timezone.utc).isoformat() + event["id"] = str(uuid.uuid4())[:8] + for listener in _event_listeners: + try: + listener(event) + except Exception: + pass + _save_history(event) + +def _save_history(event: dict): + history = [] + if HISTORY_FILE.exists(): + history = json.loads(HISTORY_FILE.read_text()) + history.append(event) + if len(history) > 1000: + history = history[-1000:] + HISTORY_FILE.write_text(json.dumps(history, indent=2)) + +def get_history(limit: int = 100) -> list: + if not HISTORY_FILE.exists(): + return [] + history = json.loads(HISTORY_FILE.read_text()) + return history[-limit:] + +def load_job_definitions() -> list: + jobs = [] + for f in sorted(JOBS_DIR.glob("*.json")): + data = json.loads(f.read_text()) + data["_file"] = str(f) + jobs.append(data) + return jobs + +def get_job_by_id(job_id: str) -> Optional[dict]: + for job in load_job_definitions(): + if job.get("id") == job_id: + return job + return None + +def get_job_by_name(name: str) -> Optional[dict]: + for job in load_job_definitions(): + if job.get("name") == name: + return job + return None + +def run_skill(skill_name: str, trigger: str = "scheduler", input_text: str = ""): + """Execute a skill via the API.""" audit_file = BASE_DIR.parent / "audit" / "audit.log" + timestamp = datetime.now(timezone.utc).isoformat() entry = { "action": "scheduler_run", "skill": skill_name, - "timestamp": datetime.now(timezone.utc).isoformat(), + "trigger": trigger, + "timestamp": timestamp, } with open(audit_file, "a") as f: f.write(json.dumps(entry) + "\n") - print(f"[{datetime.now().isoformat()}] Ran skill: {skill_name}") + emit_event({ + "type": "skill_run", + "skill": skill_name, + "trigger": trigger, + "status": "started", + }) + print(f"[{timestamp}] Skill '{skill_name}' triggered by {trigger}") + return {"status": "triggered", "skill": skill_name, "trigger": trigger} -def load_jobs(scheduler: BackgroundScheduler): - """Load job definitions from jobs/ directory.""" - for job_file in JOBS_DIR.glob("*.json"): - data = json.loads(job_file.read_text()) - if not data.get("enabled", True): - continue - scheduler.add_job( - run_skill, - CronTrigger.from_crontab(data["cron"]), - args=[data["skill"]], - id=data.get("id", data["name"]), - name=data["name"], - replace_existing=True, - ) - print(f" Scheduled: {data['name']} ({data['cron']})") + +# ─── File Watcher ───────────────────────────────────────────── + +class JobFileWatcher: + """Watch scheduler/jobs/ for changes and notify listeners.""" + def __init__(self, interval: float = 2.0): + self.interval = interval + self._known = {} + self._running = False + self._thread = None + + def start(self): + self._running = True + self._scan() + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + def stop(self): + self._running = False + + def _scan(self): + current = {} + for f in JOBS_DIR.glob("*.json"): + try: + mtime = f.stat().st_mtime + current[str(f)] = mtime + except OSError: + pass + if self._known and current != self._known: + for cb in _on_files_changed: + try: + cb() + except Exception: + pass + self._known = current + + def _loop(self): + while self._running: + time.sleep(self.interval) + self._scan() + + +# ─── Cron Scheduler ──────────────────────────────────────────── + +class CronScheduler: + """Simple in-process cron scheduler using APScheduler.""" + def __init__(self): + self._scheduler = None + self._watcher = JobFileWatcher() + + def start(self): + try: + from apscheduler.schedulers.background import BackgroundScheduler as BS + from apscheduler.triggers.cron import CronTrigger as CT + except ImportError: + print("Install APScheduler: pip install apscheduler") + return + self._scheduler = BS() + self._reload_jobs() + self._scheduler.start() + self._watcher.start() + _on_files_changed.append(self._reload_jobs) + print(f"Agentic OS Scheduler running. Jobs loaded from: {JOBS_DIR}") + + def stop(self): + self._watcher.stop() + if self._scheduler: + self._scheduler.shutdown(wait=False) + + def _reload_jobs(self): + if not self._scheduler: + return + from apscheduler.triggers.cron import CronTrigger as CT + for job in self._scheduler.get_jobs(): + job.remove() + for data in load_job_definitions(): + if not data.get("enabled", True): + continue + try: + self._scheduler.add_job( + run_skill, + CT.from_crontab(data["cron"]), + args=[data["skill"], "cron"], + id=data.get("id", data["name"]), + name=data["name"], + replace_existing=True, + misfire_grace_time=60, + ) + except Exception as e: + print(f" Failed to schedule {data.get('name')}: {e}") + count = len(self._scheduler.get_jobs()) + print(f" Scheduled {count} jobs") + + +# ─── Standalone Entry ───────────────────────────────────────── def main(): - scheduler = BackgroundScheduler() - load_jobs(scheduler) + scheduler = CronScheduler() scheduler.start() - print(f"Agentic OS Scheduler running. Jobs loaded from: {JOBS_DIR}") try: while True: - import time time.sleep(60) except KeyboardInterrupt: - scheduler.shutdown() + scheduler.stop() print("Scheduler stopped.") if __name__ == "__main__": diff --git a/server.py b/server.py index eeb57de..af1a04c 100644 --- a/server.py +++ b/server.py @@ -6,6 +6,7 @@ Multi-agent orchestration server for opencode, Hermes, Gemini CLI import argparse import json import os +import re import shutil import subprocess import tarfile @@ -528,6 +529,200 @@ def update_settings(data: SettingsUpdate): append_audit({"action": "settings_updated"}) return {"status": "ok"} +# ─── Routes: Webhooks & Scheduler Events (v0.3.0) ───────────────── + +@app.post("/api/webhook") +def webhook_receiver(data: dict): + """Generic webhook receiver — triggers skill execution by event type.""" + event_type = data.get("event", data.get("type", "unknown")) + skill_name = data.get("skill", "") + payload = data.get("payload", {}) + if skill_name: + from scheduler.scheduler import run_skill + result = run_skill(skill_name, trigger=f"webhook:{event_type}", input_text=json.dumps(payload)) + append_audit({"action": "webhook_received", "event": event_type, "skill": skill_name}) + return {"status": "processed", "event": event_type, "skill": skill_name, "result": result} + append_audit({"action": "webhook_received", "event": event_type}) + return {"status": "received", "event": event_type} + +@app.get("/api/scheduler/events") +def get_scheduler_events(limit: int = Query(50, le=200)): + from scheduler.scheduler import get_history + return {"events": get_history(limit=limit)} + +@app.post("/api/scheduler/trigger/{job_id}") +def trigger_job(job_id: str): + from scheduler.scheduler import get_job_by_id, run_skill + job = get_job_by_id(job_id) + if not job: + raise HTTPException(404, "Job not found") + result = run_skill(job["skill"], trigger="manual") + append_audit({"action": "job_triggered", "job_id": job_id, "skill": job["skill"]}) + return result + +@app.post("/api/webhook/generic") +def generic_webhook(data: dict): + """Catch-all webhook receiver for external tool integrations.""" + source = data.get("source", "unknown") + event = data.get("event", data.get("action", "trigger")) + skill = data.get("skill", "") + if skill: + from scheduler.scheduler import run_skill + run_skill(skill, trigger=f"webhook:{source}:{event}") + append_audit({"action": "generic_webhook", "source": source, "event": event, "skill": skill}) + return {"status": "ok", "source": source, "event": event} + +# ─── Routes: Memory Search & Auto-Skill Generator (v0.3.0) ───────── + +@app.get("/api/memory/search") +def memory_search(q: str = Query(""), limit: int = Query(20, le=100)): + from brain.memory_search import search, extract_entities + results = search(q, limit) if q else [] + entities = extract_entities(q) if q else [] + return {"results": results, "entities": entities, "query": q} + +@app.post("/api/memory/reindex") +def memory_reindex(): + from brain.memory_search import reindex_all + reindex_all() + append_audit({"action": "memory_reindexed"}) + return {"status": "reindexed"} + +@app.get("/api/memory/entities") +def list_entities(entity_type: str = "", limit: int = Query(50, le=200)): + from brain.memory_search import get_entities + return {"entities": get_entities(entity_type=entity_type, limit=limit)} + +@app.post("/api/skills/generate") +def generate_skill(data: dict): + """Auto-generate a SKILL.md from a natural language description.""" + name = data.get("name", "").strip().lower().replace(" ", "-") + description = data.get("description", "").strip() + if not name or not description: + raise HTTPException(400, "Both 'name' and 'description' are required") + if not re.match(r'^[a-z0-9-]+$', name): + raise HTTPException(400, "Skill name must be alphanumeric with hyphens") + skill_dir = BASE_DIR / "skills" / name + if skill_dir.exists(): + raise HTTPException(409, "Skill already exists") + skill_dir.mkdir(parents=True) + (skill_dir / "context").mkdir(exist_ok=True) + skill_md = f"""# {description} + +{description} + +## Usage +Generate this skill by running it with appropriate input. + +## Input +- Natural language description of what to do + +## Output +- Executed task result + +## Primary: opencode +""" + (skill_dir / "SKILL.md").write_text(skill_md) + (skill_dir / "learnings.md").write_text(f"# {name}\n\nAuto-generated skill.\n") + eval_data = {"criteria": ["completeness", "accuracy", "efficiency"], "weights": [0.4, 0.3, 0.3]} + (skill_dir / "eval.json").write_text(json.dumps(eval_data, indent=2)) + (skill_dir / "score-history.json").write_text("[]") + append_audit({"action": "skill_generated", "name": name, "description": description}) + return {"status": "created", "name": name, "skill": skill_md} + +# ─── Routes: Error Tracking (v0.3.0) ─────────────────────────────── + +ERROR_LOG_FILE = BASE_DIR / "data" / "error-log.json" + +def log_error(source: str, message: str, category: str = "general", details: dict = None): + errors = [] + if ERROR_LOG_FILE.exists(): + errors = json.loads(ERROR_LOG_FILE.read_text()) + errors.append({ + "id": str(uuid.uuid4())[:8], + "source": source, + "message": message, + "category": category, + "details": details or {}, + "timestamp": get_timestamp(), + }) + if len(errors) > 500: + errors = errors[-500:] + ERROR_LOG_FILE.write_text(json.dumps(errors, indent=2)) + +@app.get("/api/errors") +def get_errors(limit: int = Query(50, le=200), category: str = ""): + if not ERROR_LOG_FILE.exists(): + return {"errors": []} + errors = json.loads(ERROR_LOG_FILE.read_text()) + if category: + errors = [e for e in errors if e.get("category") == category] + return {"errors": errors[-limit:]} + +@app.delete("/api/errors") +def clear_errors(): + if ERROR_LOG_FILE.exists(): + ERROR_LOG_FILE.write_text("[]") + return {"status": "cleared"} + +@app.post("/api/errors/report") +def report_error(data: dict): + log_error( + source=data.get("source", "unknown"), + message=data.get("message", ""), + category=data.get("category", "general"), + details=data.get("details"), + ) + return {"status": "reported"} + +# ─── Circuit Breaker (v0.3.0) ────────────────────────────────────── + +CIRCUIT_BREAKER_FILE = BASE_DIR / "data" / "circuit-breaker.json" + +def _get_circuit_state() -> dict: + if CIRCUIT_BREAKER_FILE.exists(): + return json.loads(CIRCUIT_BREAKER_FILE.read_text()) + return {"agents": {}, "threshold": 3, "recovery_timeout": 300} + +def _save_circuit_state(state: dict): + CIRCUIT_BREAKER_FILE.write_text(json.dumps(state, indent=2)) + +@app.get("/api/circuit-breaker") +def get_circuit_breaker(): + state = _get_circuit_state() + now = time.time() + for agent, cb in state.get("agents", {}).items(): + if cb.get("state") == "open" and now - cb.get("opened_at", 0) > state.get("recovery_timeout", 300): + cb["state"] = "half-open" + return state + +@app.post("/api/circuit-breaker/trip") +def trip_circuit_breaker(data: dict): + agent = data.get("agent", "") + if agent not in ["opencode", "hermes", "gemini"]: + raise HTTPException(400, "Invalid agent") + state = _get_circuit_state() + if agent not in state["agents"]: + state["agents"][agent] = {"state": "closed", "failures": 0, "opened_at": None} + cb = state["agents"][agent] + cb["failures"] = cb.get("failures", 0) + 1 + if cb["failures"] >= state["threshold"]: + cb["state"] = "open" + cb["opened_at"] = time.time() + _save_circuit_state(state) + append_audit({"action": "circuit_tripped", "agent": agent, "failures": cb["failures"]}) + return {"agent": agent, "state": cb["state"], "failures": cb["failures"]} + +@app.post("/api/circuit-breaker/reset") +def reset_circuit_breaker(data: dict): + agent = data.get("agent", "") + if agent not in ["opencode", "hermes", "gemini"]: + raise HTTPException(400, "Invalid agent") + state = _get_circuit_state() + state["agents"][agent] = {"state": "closed", "failures": 0, "opened_at": None} + _save_circuit_state(state) + return {"agent": agent, "state": "closed"} + # ─── Routes: Standards ──────────────────────────────────────────── @app.get("/api/standards") @@ -1323,6 +1518,65 @@ def favicon(): def favicon_svg(): return Response(content=FAVICON_SVG, media_type="image/svg+xml") +# ─── Scheduler Auto-Start (v0.3.0) ───────────────────────────────── + +_scheduler_instance = None + +@app.on_event("startup") +def start_scheduler(): + global _scheduler_instance + try: + from scheduler.scheduler import CronScheduler + _scheduler_instance = CronScheduler() + _scheduler_instance.start() + print("Event-driven scheduler started") + except Exception as e: + print(f"Scheduler not available: {e}") + +@app.on_event("shutdown") +def stop_scheduler(): + global _scheduler_instance + if _scheduler_instance: + try: + _scheduler_instance.stop() + except Exception: + pass + +# ─── PWA Support (v0.3.0) ────────────────────────────────────────── + +MANIFEST_JSON = { + "name": "Agentic OS", + "short_name": "AgenticOS", + "description": "Multi-agent orchestration platform", + "start_url": "/", + "display": "standalone", + "background_color": "#0f0f23", + "theme_color": "#6c5ce7", + "icons": [ + {"src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}, + ], +} + +@app.get("/manifest.json") +def manifest(): + return JSONResponse(content=MANIFEST_JSON) + +SERVICE_WORKER_JS = """ +self.addEventListener('install', (e) => { + self.skipWaiting(); +}); +self.addEventListener('activate', (e) => { + e.waitUntil(clients.claim()); +}); +self.addEventListener('fetch', (e) => { + e.respondWith(fetch(e.request).catch(() => new Response('Offline', {status: 503}))); +}); +""" + +@app.get("/sw.js") +def service_worker(): + return Response(content=SERVICE_WORKER_JS, media_type="application/javascript") + # ─── Main ───────────────────────────────────────────────────────── if __name__ == "__main__":