v0.3.0: Event-driven scheduler, error dashboard, persistent memory, PWA

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)
This commit is contained in:
modimihir07 2026-06-29 14:08:57 +05:30
parent a30162d114
commit e003688658
10 changed files with 861 additions and 46 deletions

6
.gitignore vendored
View File

@ -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

161
brain/memory_search.py Normal file
View File

@ -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, '<mark>', '</mark>', '...', 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()

View File

@ -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'),
};

View File

@ -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');

View File

@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agentic OS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="manifest" href="/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Agentic OS">
<meta name="theme-color" content="#6c5ce7">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
@ -48,6 +53,7 @@
<a href="#smart-router" class="nav-item" data-page="smart-router"><span class="nav-icon">🧭</span><span class="nav-label">Smart Router</span></a>
<a href="#learning-analytics" class="nav-item" data-page="learning-analytics"><span class="nav-icon">📊</span><span class="nav-label">Learning Analytics</span></a>
<a href="#session-replay" class="nav-item" data-page="session-replay"><span class="nav-icon">🔄</span><span class="nav-label">Session Replay</span></a>
<a href="#errors" class="nav-item" data-page="errors"><span class="nav-icon"></span><span class="nav-label">Error Dashboard</span><span class="nav-badge" id="errorCount">0</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Management</div></div>
<a href="#cost" class="nav-item" data-page="cost"><span class="nav-icon">💰</span><span class="nav-label">Cost Analytics</span></a>
<a href="#plugins" class="nav-item" data-page="plugins"><span class="nav-icon">🔌</span><span class="nav-label">Plugins</span></a>
@ -88,8 +94,21 @@
<div id="toastContainer" class="toast-container"></div>
<div id="modalContainer"></div>
<nav id="bottomNav" class="bottom-nav">
<a href="#chat" class="bottom-nav-item" data-page="chat"><span class="bottom-nav-icon">💬</span><span class="bottom-nav-label">Chat</span></a>
<a href="#dashboard" class="bottom-nav-item" data-page="dashboard"><span class="bottom-nav-icon"></span><span class="bottom-nav-label">Home</span></a>
<a href="#skills" class="bottom-nav-item" data-page="skills"><span class="bottom-nav-icon"></span><span class="bottom-nav-label">Skills</span></a>
<a href="#kanban" class="bottom-nav-item" data-page="kanban"><span class="bottom-nav-icon">📌</span><span class="bottom-nav-label">Board</span></a>
<a href="#errors" class="bottom-nav-item" data-page="errors"><span class="bottom-nav-icon"></span><span class="bottom-nav-label">Errors</span></a>
</nav>
<script src="utils.js"></script>
<script src="api.js"></script>
<script src="app.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
</script>
</body>
</html>

157
dashboard/pages/errors.js Normal file
View File

@ -0,0 +1,157 @@
async function renderErrors() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Error Dashboard</div>
<div class="page-subtitle">Track and manage system errors across all agents</div>
</div>
<div class="btn-group">
<button class="btn btn-ghost" onclick="refreshErrors()">🔄 Refresh</button>
<button class="btn btn-danger" onclick="clearAllErrors()">🗑 Clear All</button>
</div>
</div>
<div class="flex gap-3 mb-3" style="flex-wrap:wrap">
<div class="metric-tile" style="flex:1;min-width:120px">
<div class="metric-tile-value" id="errorTotalCount">0</div>
<div class="metric-tile-label">Total Errors</div>
</div>
<div class="metric-tile" style="flex:1;min-width:120px">
<div class="metric-tile-value" id="errorAgentCount">0</div>
<div class="metric-tile-label">Agent Errors</div>
</div>
<div class="metric-tile" style="flex:1;min-width:120px">
<div class="metric-tile-value" id="errorSkillCount">0</div>
<div class="metric-tile-label">Skill Errors</div>
</div>
<div class="metric-tile" style="flex:1;min-width:120px">
<div class="metric-tile-value" id="errorCircuitCount">0</div>
<div class="metric-tile-label">Circuit Breaks</div>
</div>
</div>
<div class="flex gap-2 mb-3" style="flex-wrap:wrap">
<select id="errorCategoryFilter" class="form-select" style="width:160px" onchange="refreshErrors()">
<option value="">All Categories</option>
<option value="agent">Agent</option>
<option value="skill">Skill</option>
<option value="api">API</option>
<option value="system">System</option>
<option value="general">General</option>
</select>
<span id="errorCountBadge" class="badge badge-danger" style="display:none"></span>
</div>
<div id="errorList"><div class="loading"><div class="loading-spinner"></div></div></div>
<div class="section-title mt-4">Circuit Breaker Status</div>
<div id="circuitBreakerCards" class="grid grid-3"></div>
`;
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 = '<div class="empty-state"><div class="empty-state-icon">✅</div><div class="empty-state-title">No errors</div><div class="empty-state-desc">System is running smoothly</div></div>';
return;
}
container.innerHTML = `
<div class="table-wrapper">
<table>
<thead><tr><th>Time</th><th>Category</th><th>Source</th><th>Message</th><th>ID</th></tr></thead>
<tbody>
${errors.slice().reverse().map(e => `
<tr>
<td style="font-size:12px;white-space:nowrap">${formatDate(e.timestamp)}</td>
<td><span class="badge ${e.category === 'agent' ? 'badge-danger' : e.category === 'skill' ? 'badge-warning' : e.category === 'api' ? 'badge-info' : 'badge'}">${escapeHtml(e.category)}</span></td>
<td style="font-size:13px"><strong>${escapeHtml(e.source)}</strong></td>
<td style="font-size:13px">${escapeHtml(e.message)}</td>
<td style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">${e.id || ''}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div style="font-size:12px;color:var(--text-muted);text-align:right;margin-top:8px">${errors.length} error${errors.length !== 1 ? 's' : ''}</div>
`;
} catch (err) {
if (container) container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
}
}
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 = '<div style="grid-column:1/-1"><div class="empty-state" style="padding:20px"><div class="empty-state-icon">🔌</div><div class="empty-state-title">No circuit breaker data</div></div></div>';
return;
}
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
container.innerHTML = agentNames.map(a => {
const cb = agents[a] || {};
const isOpen = cb.state === 'open';
return `
<div class="card" style="border-color:${isOpen ? 'var(--red)' : 'var(--green-dim)'}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:20px">${agentIcons[a] || '🤖'}</span>
<div>
<div style="font-weight:600;text-transform:capitalize">${escapeHtml(a)}</div>
<div style="font-size:12px;color:${isOpen ? 'var(--red)' : 'var(--green)'}">${cb.state || 'closed'}</div>
</div>
</div>
<div style="display:flex;gap:8px;font-size:12px;color:var(--text-muted);margin-bottom:8px">
<span>Failures: ${cb.failures || 0}</span>
<span>Threshold: ${data.threshold || 3}</span>
</div>
${isOpen ? `<button class="btn btn-sm btn-primary" onclick="resetCircuit('${a}')">🔓 Reset</button>` : ''}
</div>
`;
}).join('');
} catch {
container.innerHTML = '<div style="grid-column:1/-1"><div class="empty-state" style="padding:20px"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load circuit breaker</div></div></div>';
}
}
async function resetCircuit(agent) {
try {
await api.resetCircuitBreaker(agent);
showToast(`Circuit breaker reset for ${agent}`, 'success');
loadCircuitBreaker();
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
}

View File

@ -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%);

View File

@ -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"
}

View File

@ -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__":

254
server.py
View File

@ -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__":