Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

63 changed files with 382 additions and 3136 deletions

9
.gitignore vendored
View File

@ -1,8 +1,6 @@
__pycache__/
*.pyc
.env
.venv/
bin/
*.egg-info/
dist/
node_modules/
@ -13,10 +11,3 @@ 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
data/uploads/
scheduler/*.pyc

119
AGENTS.md
View File

@ -2,7 +2,7 @@
## Role Definition
You are an **AI Agent Operating System (Agentic OS)** — a multi-agent orchestration platform that coordinates **opencode**, **Hermes Agent**, and **agy CLI** into a unified, self-improving, autonomous work operating system.
You are an **AI Agent Operating System (Agentic OS)** — a multi-agent orchestration platform that coordinates **opencode**, **Hermes Agent**, and **Gemini CLI** into a unified, self-improving, autonomous work operating system.
Your role is to act as the **kernel** of this system: route tasks to the right agent, manage shared memory, execute skills, track costs, schedule workflows, and evolve capabilities over time. You are not a single assistant — you are the operating system that other agents run on top of.
@ -13,9 +13,9 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
| Field | Value |
|-------|-------|
| **Name** | Agentic OS |
| **Location** | `~/Desktop/Agentic OS Project/` |
| **Location** | `/home/mihir/Desktop/Agentic OS Project/` |
| **GitHub** | [github.com/modimihir07/agentic-os](https://github.com/modimihir07/agentic-os) |
| **Author** | modimihir07 |
| **Author** | Mihir N Modi (Gujarat, India — BTech CSE 1st year) |
| **Created** | May 17, 2026 |
| **License** | MIT |
| **Inspiration** | "Agent OS: Claude + Hermes AI = Superpowers!" (YouTube), MindStudio 4-layer architecture, obra/superpowers, NousResearch/hermes-agent, buildermethods/agent-os, shivsoji/claude-os |
@ -36,7 +36,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
│ │ 3-AGENT EXECUTION ENGINE │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │
│ │ │ opencode │ │ Hermes │ │ agy CLI │ │ │
│ │ │ opencode │ │ Hermes │ │ Gemini CLI │ │ │
│ │ │ (Code/DevOps)│ │ (Memory/Sched│ │(Research/ │ │ │
│ │ │ File Ops) │ │ /Channels) │ │ Analysis) │ │ │
│ │ └──────────────┘ └──────────────┘ └────────────┘ │ │
@ -62,14 +62,14 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|-------|-------------|---------------|
| **opencode** | Code generation, file operations, DevOps/GCP infra, git management, software engineering | Any task involving file edits, code writing, infrastructure-as-code, terminal commands for build/test |
| **Hermes Agent** | Persistent memory (SQLite FTS5), cron scheduling, Telegram/Discord channels, skill hub, multi-agent coordination | Tasks needing cross-session memory, scheduled recurring tasks, multi-platform notifications, skill discovery |
| **agy CLI** | Web research, multi-modal analysis (images/PDFs), reasoning, data analysis | Research tasks, content analysis, document understanding, competitive analysis, learning/research |
| **Gemini CLI** | Web research, multi-modal analysis (images/PDFs), Gemini Flash free-tier reasoning, data analysis | Research tasks, content analysis, document understanding, competitive analysis, learning/research |
### Routing Rules
- **Code/DevOps task?** → opencode
- **Memory/Channel/Schedule?** → Hermes Agent
- **Research/Analysis?**agy CLI
- **Complex multi-step?** → Chain: agy researches → opencode implements → Hermes monitors/schedules
- **Research/Analysis?**Gemini CLI
- **Complex multi-step?** → Chain: Gemini researches → opencode implements → Hermes monitors/schedules
- **Unknown/General?** → opencode first (best general-purpose coding agent)
---
@ -182,7 +182,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
## Directory Structure
```
~/Desktop/Agentic OS Project/
/home/mihir/Desktop/Agentic OS Project/
├── AGENTS.md # THIS FILE — complete context for any AI agent
├── README.md # User-facing documentation
├── server.py # FastAPI backend (REST API for dashboard)
@ -192,15 +192,14 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
├── backup.sh # Manual backup [E3]
├── restore.sh # Manual restore [E3]
├── brain/ # [F2, F7, F10, F49, F50, F54] Shared context
├── brain/ # [F2, F7, F10, F49, F50] Shared context
│ ├── business-brain.md
│ ├── memory.md
│ ├── recent-decisions.md
│ ├── active-projects.md
│ ├── constraints.md
│ ├── identity.md
│ ├── constitution.md
│ └── journal/ # [F54] Daily markdown entries (YYYY-MM-DD.md)
│ └── constitution.md
├── skills/ # [F5, F8, F15, F17] Skills Hub
│ ├── _template/
@ -213,7 +212,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
│ ├── devops-audit/ # CloudMart GCP infra
│ ├── content-draft/ # Blog/newsletter writing
│ ├── code-review/ # [F21]
│ ├── research-synthesis/ # agy research
│ ├── research-synthesis/ # Gemini research
│ ├── daily-standup/ # Morning briefing
│ ├── meeting-minutes/ # Meeting notes processor
│ ├── project-planner/ # [F25, F26, F46]
@ -233,9 +232,9 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
│ │ ├── SOUL.md
│ │ ├── USER.md
│ │ └── MEMORY.md
│ └── agy/
│ ├── AGY.md
│ └── agy-extension.json
│ └── gemini/
│ ├── GEMINI.md
│ └── gemini-extension.json
├── scheduler/ # [F9] Scheduling
│ ├── scheduler.py
@ -273,14 +272,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
│ ├── prompts.js
│ ├── standards.js
│ ├── settings.js
│ ├── setup-wizard.js
│ ├── kanban.js # [F52] Kanban Board (v0.2.0)
│ ├── goals.js # [F53] Goals (v0.2.0)
│ ├── journal.js # [F54] Journal (v0.2.0)
│ ├── agent-health.js # [F55] Agent Health (v0.2.0)
│ ├── smart-router.js # [F56] Smart Router (v0.2.0)
│ ├── learning-analytics.js # [F57] Learning Analytics (v0.2.0)
│ └── session-replay.js # [F58] Session Replay (v0.2.0)
│ └── setup-wizard.js
├── audit/ # [F38] Audit Trail
│ └── audit.log
@ -303,9 +295,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
├── data/ # Runtime data
│ ├── settings.json
│ ├── cost-history.json
│ ├── agent-routes.json
│ ├── kanban/ # [F52] Kanban task JSON files
│ └── goals.json # [F53] Goals storage
│ └── agent-routes.json
└── .git/ # [E2] Auto-versioning
```
@ -316,10 +306,17 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
| Detail | Info |
|--------|------|
| **Name** | Mihir N Modi |
| **Email** | rinkumi0210@gmail.com |
| **Location** | Gujarat, India |
| **Education** | BTech Computer Engineering, 1st Year |
| **Career Goal** | AI/ML/LLMs + DevOps/Cloud → MNCs (Google-level) |
| **Budget** | Strictly free tiers (GCP Free, GitHub Student Pack, Colab, Kaggle) |
| **Active Project** | CloudMart — GCP DevOps multi-region e-commerce platform |
| **CLI Tools Available** | opencode, Hermes Agent, agy CLI |
| **Preferred Model** | Hermes: Owl Alpha (OpenRouter, free), opencode: deepseek-v4-flash-free (opencode-zen), agy: Antigravity (free CLI) |
| **Other Projects** | AgriAssist AI, Hermes Agent/OpenClaw, EROS Wellness AI, Java OOP practice |
| **CLI Tools Available** | opencode, Hermes Agent, Gemini CLI |
| **Preferred Model** | Hermes: Owl Alpha (OpenRouter, free), opencode: deepseek-v4-flash-free (opencode-zen), Gemini: gemini-2.5-flash (Google OAuth) |
| **Obsidian Vaults** | 4 vaults: DevOps, DeepSeek Chat, AI/ML, SEM-2 Academics (192 files) |
---
@ -350,13 +347,12 @@ When you (an AI agent) are dropped into this directory for the first time:
### Session History
- opencode: `~/.local/share/opencode/opencode.db` + `~/.local/share/opencode/log/`
- Hermes: `~/.hermes/sessions.json`
- agy: `~/.antigravity/history/`
- Gemini: `~/.gemini/history/`
---
## API Endpoints (server.py FastAPI, 58 total — 28 original + 30 v0.2.0)
## API Endpoints (server.py FastAPI)
### Core (28 original endpoints)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/status` | System health + 3 agent status |
@ -382,64 +378,6 @@ When you (an AI agent) are dropped into this directory for the first time:
| GET | `/api/standards` | List standards |
| POST | `/api/standards/discover` | Run standards discovery |
### Kanban Board (v0.2.0) — 13 endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/kanban/board` | Get kanban board (all columns + tasks) |
| GET | `/api/kanban/tasks/{id}` | Get single task |
| POST | `/api/kanban/tasks` | Create task |
| PATCH | `/api/kanban/tasks/{id}` | Update task fields |
| POST | `/api/kanban/tasks/{id}/complete` | Mark task done |
| POST | `/api/kanban/tasks/{id}/block` | Block task |
| POST | `/api/kanban/tasks/{id}/unblock` | Unblock task |
| POST | `/api/kanban/tasks/{id}/comments` | Add comment |
| POST | `/api/kanban/links` | Link parent/child tasks |
| DELETE | `/api/kanban/links` | Unlink tasks |
| POST | `/api/kanban/dispatch` | Dispatch triage tasks |
| POST | `/api/kanban/tasks/{id}/specify` | Generate spec |
| POST | `/api/kanban/tasks/{id}/decompose` | Decompose into subtasks |
### Goals (v0.2.0) — 4 endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/goals` | List all goals |
| POST | `/api/goals` | Create goal (auto-syncs to brain/active-projects.md) |
| PUT | `/api/goals/{id}` | Update goal progress/status |
| DELETE | `/api/goals/{id}` | Delete goal |
### Journal (v0.2.0) — 4 endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/journal/entries` | List all entry dates |
| GET | `/api/journal/entries/{date}` | Get entry content |
| PUT | `/api/journal/entries/{date}` | Save entry (creates/updates brain/journal/YYYY-MM-DD.md) |
| GET | `/api/journal/search?q=` | Full-text search across entries |
### Agent Health (v0.2.0) — 3 endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/agents/health` | Get live status of all 3 agents |
| GET | `/api/agents/{name}/stats` | Get per-agent statistics |
| POST | `/api/agents/health/refresh` | Force refresh all status checks |
### Smart Router (v0.2.0) — 2 endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/api/router/suggest` | Suggest best agent for a task description |
| POST | `/api/router/route` | Route task to a specific agent |
### Learning Analytics (v0.2.0) — 2 endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/analytics/skills` | Skill evaluation scores across all skills |
| GET | `/api/analytics/trends` | Score history trends per skill |
### Session Replay (v0.2.0) — 2 endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/sessions/list` | List opencode sessions |
| GET | `/api/sessions/{id}/replay` | Get session message content |
---
## Version History
@ -448,8 +386,7 @@ When you (an AI agent) are dropped into this directory for the first time:
|------|---------|---------|
| May 17, 2026 | v1.0.0 | Initial creation — all 51 features + 10 extras |
| May 17, 2026 | v1.1.0 | AI Chat fixes: corrected CLI syntax (hermes `chat -q`, gemini positional), switched Hermes default to OpenRouter + Owl Alpha, improved timeout/error handling, updated execute_agent fallback logic |
| Aug 11, 2026 | v0.4.0 | **agy (Antigravity) replaces Gemini CLI** across backend, routing, frontend, skills, agents/, docs; **port auto-kill** in start.sh; **apscheduler auto-install** in scheduler.py; **Chat History Search** (`q`/`agent`/`limit` + History page); **Chat File Attachments** (`/api/chat/upload`, 2 MB allowlist, 24h TTL); **Memory Knowledge Graph** (`/api/memory/graph` + Canvas renderer); **Code Diff Viewer** (`/api/diff` + Skills page button); **74 tests** (59 → 74, 15 new isolated v0.4.0 tests) |
---
*This AGENTS.md is designed to be the single source of truth. Any AI agent (opencode, Claude Code, agy CLI, Hermes, Cursor, etc.) reading this file should have complete context to continue the project seamlessly.*
*This AGENTS.md is designed to be the single source of truth. Any AI agent (opencode, Claude Code, Gemini CLI, Hermes, Cursor, etc.) reading this file should have complete context to continue the project seamlessly.*

227
README.md
View File

@ -4,8 +4,7 @@
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="Python 3.10+"/>
<img src="https://img.shields.io/badge/FastAPI-0.115+-green.svg" alt="FastAPI"/>
<img src="https://img.shields.io/badge/agents-3-orange.svg" alt="3 Agents"/>
<img src="https://img.shields.io/badge/skills-15-purple.svg" alt="15 Skills"/>
<img src="https://img.shields.io/badge/version-v0.4.0-blueviolet.svg" alt="v0.4.0"/>
<img src="https://img.shields.io/badge/skills-16-purple.svg" alt="16 Skills"/>
<img src="https://img.shields.io/badge/status-stable-brightgreen.svg" alt="Status: Stable"/>
<a href="https://dev.to/mihir_nmodi_14a06a4019e1/i-built-an-open-source-agent-os-2h30"><img src="https://img.shields.io/badge/dev.to-article-blue.svg" alt="dev.to article"/></a>
<br/><br/>
@ -13,7 +12,7 @@
# Agentic OS (agentic-os) 🧠 — Multi-Agent Orchestration Platform
A locally-hosted operating system for AI agents — an open-source GitHub repository — that coordinates **opencode**, **Hermes Agent**, and **agy CLI** into a unified dashboard with persistent memory, cron scheduling, skill execution, cost analytics, and disaster recovery.
A locally-hosted operating system for AI agents — an open-source GitHub repository — that coordinates **opencode**, **Hermes Agent**, and **Gemini CLI** into a unified dashboard with persistent memory, cron scheduling, skill execution, cost analytics, and disaster recovery.
> **Why Agentic OS?** Most agent tools work in isolation — a terminal for coding, a separate chat for research, another for memory. Agentic OS is the **control plane** that unifies them: one dashboard, one memory layer, one scheduler, one skill hub. Three agents, infinite capabilities.
@ -23,8 +22,8 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
| Category | Features |
|----------|----------|
| **🤖 3-Agent Engine** | opencode (code/DevOps), Hermes (memory/scheduling), agy (research/analysis) with intelligent routing |
| **🧩 15+ Skills** | Executable skill packs with eval scoring, learnings, and score history per run |
| **🤖 3-Agent Engine** | opencode (code/DevOps), Hermes (memory/scheduling), Gemini (research/analysis) with intelligent routing |
| **🧩 16+ Skills** | Executable skill packs with eval scoring, learnings, and score history per run |
| **🧠 Persistent Memory** | SQLite FTS5 + `brain/` folder — shared context read by all agents at session start |
| **⏱ Cron Scheduler** | APScheduler-powered jobs — heartbeat, memory consolidation, daily standup, DevOps audit |
| **💰 Cost Analytics** | Track spending per provider, model, agent. Free-tier alerts prevent surprise bills |
@ -34,21 +33,7 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
| **📐 Standards System** | Discover and inject coding conventions across your project |
| **🔌 Plugin Registry** | Marketplace-style plugin management (extensible via skills) |
| **🎨 Dark/Light Theme** | GitHub-style dark mode + clean light theme, toggle from sidebar |
| **⚡ Zero API Costs** | Built for free tiers — Antigravity (agy), OpenRouter free models, local opencode |
| **📋 Kanban Board** | Visual task management — drag-and-drop columns, priority/status filtering, block/unblock, detail view |
| **🎯 Goals** | Project targets with progress tracking, auto-syncs to `brain/active-projects.md` |
| **📓 Journal** | Daily markdown entries stored as `brain/journal/YYYY-MM-DD.md` with full-text search |
| **❤️ Agent Health** | Real-time monitoring of opencode, Hermes, and agy CLI availability |
| **🧭 Smart Router** | Keyword-based task routing with confidence scoring — suggests best agent for any task |
| **📊 Learning Analytics** | Skill evaluation scores, performance trends, and historical charts |
| **🎬 Session Replay** | Browse and replay past opencode sessions from the dashboard |
| **⚠ Error Dashboard** | Real-time error tracking with category filtering and circuit breaker status |
| **🔌 Circuit Breaker** | Auto-trip after N failures, auto-recovery after 300s, manual reset |
| **⏱ Event-Driven Scheduler** | File-watcher auto-reloads jobs on change, webhook receiver, execution history |
| **🔗 Webhook Receiver** | `/api/webhook` — trigger skill execution from external tools |
| **🧠 SQLite FTS5 Memory** | Full-text search across brain, skills, journal with entity extraction |
| **🤖 Auto-Skill Generator** | `POST /api/skills/generate` — create SKILL.md from natural language |
| **📱 Mobile PWA** | Bottom navigation bar, manifest.json, service worker, touch-friendly UI |
| **⚡ Zero API Costs** | Built for free tiers — Gemini Flash, OpenRouter free models, local opencode |
---
@ -56,26 +41,26 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
```
┌──────────────────────────────────────────────────────────────┐
AGENTIC OS DASHBOARD
FastAPI + Tailwind SPA
│ AGENTIC OS DASHBOARD │
│ FastAPI + Tailwind SPA │
├──────────────────────────────────────────────────────────────┤
│ ┌───────────────┐ ┌────────────────┐ ┌────────────────────┐ │
│ │ opencode │ │ Hermes │ │ agy CLI │
│ │ (Code/DevOps) │ │ (Memory/Sched) │ │ (Research/Analy)
│ │ File Ops) │ │ /Channels) │ │
│ └───────────────┘ └────────────────┘ └────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 7 CORE LAYERS (Stacked) │
│ │ Layer 7: Identity / Persona / Constitution
│ │ Layer 6: Self-Evolution + Capability Manager
│ │ Layer 5: Scheduler + Awareness + Health Guardian
│ │ Layer 4: Memory Graph + Memory Consolidation
│ │ Layer 3: Skills Hub + Eval + Learnings Loop
│ │ Layer 2: Business Brain + Context Folders
│ │ Layer 1: Agent Router + Standards + Profiles
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────-┐ ┌──────────────┐ ┌──────────────────┐ |
│ │ opencode │ │ Hermes │ │ Gemini CLI │
│ │ (Code/DevOps)│ │ (Memory/Sched│ │ (Research/Analy) │ │
│ │ File Ops) │ │ /Channels) │ │ │ │
│ └──────────────-┘ └──────────────┘ └──────────────────┘ |
│ │
│ ┌──────────────────────────────────────────────────────┐
│ │ 7 CORE LAYERS (Stacked) │
│ │ Layer 7: Identity / Persona / Constitution
│ │ Layer 6: Self-Evolution + Capability Manager
│ │ Layer 5: Scheduler + Awareness + Health Guardian
│ │ Layer 4: Memory Graph + Memory Consolidation
│ │ Layer 3: Skills Hub + Eval + Learnings Loop
│ │ Layer 2: Business Brain + Context Folders
│ │ Layer 1: Agent Router + Standards + Profiles
│ └──────────────────────────────────────────────────────┘
└──────────────────────────────────────────────────────────────┘
```
@ -85,14 +70,14 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
|-------|------|---------------|----------|------|
| **opencode** | Code generation, DevOps, file operations | deepseek-v4-flash-free | opencode-zen | **$0** |
| **Hermes Agent** | Persistent memory, scheduling, messaging | Owl Alpha (1M ctx) | OpenRouter | **$0** |
| **agy CLI** | Web research, multi-modal analysis | Antigravity | agy CLI | **$0** |
| **Gemini CLI** | Web research, multi-modal analysis | gemini-2.5-flash | Google OAuth | **$0** |
### Routing Rules
- **Code/DevOps task?** → opencode
- **Memory/Channel/Schedule?** → Hermes Agent
- **Research/Analysis?**agy CLI
- **Complex multi-step?** → Chain: agy researches → opencode implements → Hermes monitors/schedules
- **Research/Analysis?**Gemini CLI
- **Complex multi-step?** → Chain: Gemini researches → opencode implements → Hermes monitors/schedules
---
@ -112,11 +97,11 @@ chmod +x install.sh && ./install.sh
| Tool | Required? | Install |
|------|-----------|---------|
| Python 3.10+ | ✅ Required | Auto-installed via `uv` by `install.sh` |
| Python 3.10+ | ✅ Required | `sudo apt install python3 python3-pip` |
| Node.js 18+ | ⚠ For opencode | `curl -fsSL https://deb.nodesource.com/setup_20.x \| sudo bash - && sudo apt install -y nodejs` |
| opencode | ⚠ For code tasks | `npm install -g @opencode/cli` |
| Hermes Agent | ⚠ For memory/scheduling | `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh \| bash` |
| agy CLI | ⚠ For research/analysis | `curl -fsSL https://antigravity.ai/install \| bash` |
| Gemini CLI | ⚠ For Google AI | `npm install -g @google/gemini-cli` |
> ⚠ = Optional — the dashboard works with any subset of installed agents.
@ -130,10 +115,10 @@ chmod +x install.sh && ./install.sh
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key-here' > ~/.hermes/.env
```
### agy CLI (Antigravity)
### Gemini CLI (Google OAuth)
```bash
agy login
# Authenticate to enable web research, analysis, and document understanding
gemini auth login
# Complete OAuth in browser — tokens saved to ~/.gemini/oauth_creds.json
```
### Dashboard Settings
@ -142,7 +127,7 @@ Edit `data/settings.json`:
{
"dashboard": { "port": 8080 },
"theme": "dark",
"agents": { "opencode": true, "hermes": true, "agy": true }
"agents": { "opencode": true, "hermes": true, "gemini": true }
}
```
@ -164,12 +149,11 @@ agentic-os/
│ ├── api.js # API client (all endpoints)
│ ├── styles.css # Full dark/light theme CSS
│ ├── utils.js # Shared utilities
│ └── pages/ # 22 page modules (13 original + 7 v0.2.0 + 1 v0.3.0 + 1 v0.4.0)
│ └── pages/ # 13 page modules
│ ├── dashboard.js # Overview with stats
│ ├── skills.js # Skill grid/list/detail
│ ├── memory.js # Brain file editor + Knowledge Graph
│ ├── chat.js # Multi-agent chat + file attachments
│ ├── history.js # ▸ Chat History Search (v0.4.0)
│ ├── memory.js # Brain file editor
│ ├── chat.js # Multi-agent chat
│ ├── scheduler.js # Cron job manager
│ ├── audit.js # Activity trail
│ ├── cost.js # Cost analytics charts
@ -178,32 +162,22 @@ agentic-os/
│ ├── prompts.js # Template library
│ ├── standards.js # Code conventions
│ ├── settings.js # Config editor
│ ├── setup-wizard.js # Guided setup
│ ├── kanban.js # ▸ Kanban Board (v0.2.0)
│ ├── goals.js # ▸ Goals (v0.2.0)
│ ├── journal.js # ▸ Journal (v0.2.0)
│ ├── agent-health.js # ▸ Agent Health (v0.2.0)
│ ├── smart-router.js # ▸ Smart Router (v0.2.0)
│ ├── learning-analytics.js # ▸ Learning Analytics (v0.2.0)
│ ├── session-replay.js # ▸ Session Replay (v0.2.0)
│ └── errors.js # ▸ Error Dashboard (v0.3.0)
│ └── setup-wizard.js # Guided setup
├── brain/ # Shared context (all agents read)
│ ├── memory_search.py # ▸ SQLite FTS5 search (v0.3.0)
│ ├── business-brain.md # Current project context
│ ├── memory.md # Accumulated knowledge
│ ├── recent-decisions.md
│ ├── active-projects.md
│ ├── identity.md
│ ├── constitution.md
│ └── journal/ # Daily markdown entries (YYYY-MM-DD.md)
│ └── constitution.md
├── skills/ # 15 executable skills
├── skills/ # 16 executable skills
│ ├── devops-audit/ # GCP/CloudMart infra audit
│ ├── heartbeat/ # 5-min health check
│ ├── content-draft/ # Blog/newsletter writing
│ ├── code-review/ # PR review checklist
│ ├── research-synthesis/ # agy research aggregator
│ ├── research-synthesis/ # Gemini research aggregator
│ ├── daily-standup/ # Morning briefing
│ ├── meeting-minutes/ # Notes processor
│ ├── project-planner/ # Step-by-step plans
@ -216,84 +190,18 @@ agentic-os/
│ ├── goal-planner/ # Goal → steps
│ └── _template/ # Starter template
├── agents/ # Per-agent configs (opencode, hermes, agy)
├── agents/ # Per-agent configs
├── scheduler/jobs/ # Cron job definitions
├── registry/ # Plugin marketplace
├── standards/ # Discover/inject conventions
├── prompts/ # 10 reusable templates
├── data/ # Runtime data (agent-routes.json tracked; settings/cost/chat/error/scheduler/memory/circuit/kanban/uploads gitignored)
├── data/ # Runtime data (agent-routes.json tracked; settings/cost/chat gitignored)
├── audit/ # Activity log (gitignored)
└── backups/ # Snapshots (gitignored)
```
---
## 🆕 What's New in v0.4.0
| Feature | Description |
|---------|-------------|
| **🤖 agy (Antigravity) replaces Gemini CLI** | Full provider swap across backend, routing, frontend, skills, agents/, and docs. `data/agent-routes.json`, circuit breaker, health checks, chat all use `agy` |
| **🛡 Port Conflict Auto-Kill** | `start.sh` detects a stale dashboard process on the target port and kills it before starting |
| **📦 Auto-Install apscheduler** | `scheduler.py` auto-installs missing dependencies instead of failing on startup |
| **💬 Chat History Search** | `/api/chat/history` supports `q`, `agent`, `limit` filters + a dedicated History page with search, agent filter pills, and date grouping |
| **📎 Chat File Attachments** | `POST /api/chat/upload` (multipart) accepts text files up to 2 MB (validated allowlist), previews content to the agent, and stores under `data/uploads/` with 24h TTL cleanup |
| **🕸 Memory Knowledge Graph** | `GET /api/memory/graph` returns nodes (brain/skills/journal docs + extracted entities) and edges (shares_source / mentions), rendered as an interactive Canvas graph in the Memory page |
| **📜 Code Diff Viewer** | `GET /api/diff?file=&ref=` returns unified git diff with repo-relative path-traversal protection; "View Diff" button on the Skills page |
| **🧪 15 new tests** | Suite grows from 59 → 74 isolated tests covering chat history, uploads, memory graph, and diff viewer |
| **🛠 Stale-Cache Elimination** | `no-store` cache headers on all dashboard assets, `?v=` cache-busting, service-worker purge on load, root-level asset fallbacks so stale index.html never breaks the UI |
| **📐 Zoom-Proof Layout** | Content width capped at 1600px, responsive file-card grid, and `flex-shrink: 0` on tab bars — layout stays clean from 60% to 100% zoom |
| **🔢 Correct Skill Count** | Skills badge now counts skill directories (15) via the status endpoint instead of returning 0 |
During the v0.4.0 hardening pass the following **layout & caching bugs** were crushed: tab bars (Files/Knowledge Graph, Grid/List) collapsing to 1px under content at certain zooms, oversized cards stretching to 3500px+ at 50% zoom, cached index.html referencing dead asset URLs, and the sidebar Skills badge showing 0.
---
## 🆕 What's New in v0.3.0
| Feature | Description |
|---------|-------------|
| **🔒 Security Audit** | 23 vulnerabilities fixed: 3 CRITICAL (path traversal), 8 HIGH (XSS, command injection, missing headers), 7 MEDIUM. Security headers middleware added (CSP, HSTS, X-Frame-Options) |
| **⏱ Event-Driven Scheduler** | Rewritten with file-watcher auto-reload (watchdog-style), webhook receiver at `/api/webhook`, execution history, manual job triggers |
| **⚠ Error Dashboard** | New `/api/errors` endpoints with category filtering (agent/skill/api/system). Dedicated dashboard page with error log + circuit breaker status cards |
| **🔌 Circuit Breaker** | Auto-trip after 3 failures, half-open recovery after 300s. Per-agent state tracking with manual reset. Prevents cascading failures to offline agents |
| **🧠 Persistent Memory (SQLite FTS5)** | Full-text search across `brain/*.md`, `skills/*/*.md`, `brain/journal/`. Entity extraction (persons, emails, URLs, acronyms, IPs). Reindex endpoint |
| **🤖 Auto-Skill Generator** | `POST /api/skills/generate` takes natural language description → creates full SKILL.md with eval.json and context folder |
| **🔗 Webhook Receiver** | Generic webhook at `/api/webhook/generic` plus skill-targeted webhooks. Integrates with GitHub, CI/CD, external tools |
| **📱 Mobile PWA** | Bottom navigation bar, `manifest.json`, service worker (offline fallback), PWA meta tags, touch-friendly 16px inputs, responsive grid collapse |
### Security Hardening
- **Path traversal**: All file endpoints validate `..` and `/` in user-supplied names
- **XSS elimination**: 19 violations fixed across 7 JS files — all `onclick` handlers use `encodeURIComponent`, all text uses `escapeHtml()`
- **Security headers**: CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- **CORS restricted**: Only `http://127.0.0.1:8080` and `http://localhost:8080`
- **API keys masked**: `/api/settings` returns keys as masked values (e.g., `sk-o****`)
- **Input validation**: Chat messages limited to 10K chars, brain/skill names reject special chars
- **Session replay**: Content limited to 2000 chars, path traversal prevented
- **Runtime data gitignored**: `error-log.json`, `circuit-breaker.json`, `scheduler-history.json`, `memory.db`, `kanban/*.json`
---
## 🆕 What's New in v0.2.0
| Feature | Description |
|---------|-------------|
| **📋 Kanban Board** | Visual task management with 6 columns (triage → todo → ready → in_progress → blocked → done), drag-and-drop, priority labels, filtering, and detail modals with complete/block/unblock actions |
| **🎯 Goals** | Create and track project targets with progress bars, categories, and target dates. Auto-syncs to `brain/active-projects.md` for agent awareness |
| **📓 Journal** | Daily markdown journal entries stored as `brain/journal/YYYY-MM-DD.md`. Full-text search, day streak tracking, word count |
| **❤️ Agent Health** | Real-time dashboard showing online/offline status for all 3 agents (opencode, Hermes, agy CLI). Auto-refresh every 5 seconds |
| **🧭 Smart Router** | Keyword-based routing engine — type a task description and get an AI-suggested agent with confidence score. Manual override available |
| **📊 Learning Analytics** | Skill evaluation scores, performance trends, and per-skill detail breakdowns with mini bar charts |
| **🎬 Session Replay** | Browse and replay past opencode sessions directly from the dashboard. View message content and timestamps |
### UI Modernization
- **Glass morphism cards** with subtle backdrop blur
- **Glow borders** and gradient accents on interactive elements
- **Skeleton loaders** with shimmer animation for async content
- **Empty states** with icons and contextual messages
- **All CSS additions are zero-breaking** — existing 13 pages and 28 endpoints unchanged
---
## 🎮 Usage
### AI Chat
@ -303,10 +211,10 @@ Select an agent from the sidebar → type your message → get response.
|-------|----------|---------|
| **opencode** | "Check system status", "Deploy to GKE" | Code + terminal automation |
| **Hermes** | "What did I work on recently?", "Schedule a daily backup" | Memory recall, scheduling |
| **agy** | "Research latest AI agent trends", "Analyze this image" | Web research, multi-modal |
| **Gemini** | "Research latest AI agent trends", "Analyze this image" | Web research, multi-modal |
### Skills
Browse 15 skills from Skills Hub → click Run → monitor eval scores over time.
Browse 16 skills from Skills Hub → click Run → monitor eval scores over time.
### Scheduler
Create cron jobs: heartbeat (5 min), memory consolidation (weekly), daily standup, DevOps audit.
@ -314,56 +222,17 @@ Create cron jobs: heartbeat (5 min), memory consolidation (weekly), daily standu
### Cost Analytics
Track spending per provider/model/agent. Free-tier alerts warn when nearing limits.
### Kanban Board (v0.2.0)
Drag tasks across columns, filter by priority/category, click to view details. Block tasks when blocked, mark complete when done.
### Goals (v0.2.0)
Create goals with categories and target dates. Progress tracked via +25% increments. Completed goals auto-sync to brain context.
### Journal (v0.2.0)
Write daily entries with markdown support. Auto-saves after 2 seconds. Search across all entries from the dashboard.
### Smart Router (v0.2.0)
Describe a task in plain English — the router analyzes keywords and suggests the best agent. Route manually or let AI decide.
### Agent Health (v0.2.0)
Monitor online status of all 3 agents in real time with 5-second auto-refresh. Health checks are filesystem-based (no subprocess calls).
### Learning Analytics (v0.2.0)
View evaluation scores for all 15 skills. Trends chart shows score progression over time. Top skills ranked by performance.
### Session Replay (v0.2.0)
Browse opencode session logs by date and size. Click "Replay" to view all messages in a chat-like interface.
### Error Dashboard (v0.3.0)
View system errors grouped by category (agent, skill, API, system). Filter by category, clear all errors. Circuit breaker cards show per-agent failure state — reset from the dashboard.
### Event-Driven Scheduler (v0.3.0)
Jobs auto-reload when JSON files in `scheduler/jobs/` change. Trigger skills via webhooks at `POST /api/webhook` with `{"skill": "skill-name", "payload": {...}}`. Manual trigger at `/api/scheduler/trigger/{job_id}`.
### Persistent Memory Search (v0.3.0)
Full-text search across all brain files, skills, and journal entries via SQLite FTS5. Query at `GET /api/memory/search?q=...` with highlighted snippets. Reindex at `POST /api/memory/reindex`. Entities (persons, emails, URLs) auto-extracted.
### Auto-Skill Generator (v0.3.0)
Create new skills from natural language: `POST /api/skills/generate` with `{"name": "my-skill", "description": "Does X by doing Y"}`. Generates SKILL.md, eval.json, learnings.md, and context folder.
### Webhooks (v0.3.0)
`POST /api/webhook` — trigger any skill by name. `POST /api/webhook/generic` — catch-all for external tool integration (GitHub, CI/CD, etc.).
### Mobile PWA (v0.3.0)
Open Agentic OS on your phone — bottom nav bar replaces sidebar, touch targets are 44px+, service worker caches assets. Add to home screen for app-like experience.
---
## 📊 Comparison: Agentic OS vs Claude Agent OS (Julian Goldie)
| Feature | Claude Agent OS (Video) | Agentic OS (This Project) |
|---------|------------------------|---------------------------|
| **Core Agents** | Claude + OpenClaw + Hermes | opencode + Hermes + agy CLI |
| **Core Agents** | Claude + OpenClaw + Hermes | opencode + Hermes + Gemini CLI |
| **Cost** | $20/mo (Claude subscription) | **$0 — all free tiers** |
| **Stack** | Next.js + Tailwind | FastAPI + vanilla JS SPA |
| **Architecture** | 4 layers | **7 layers** |
| **Skills System** | Plugin marketplace (2,000+ from Hermes) | 15 curated skills + eval scoring + learnings |
| **Skills System** | Plugin marketplace (2,000+ from Hermes) | 16 curated skills + eval scoring + learnings |
| **Memory** | Obsidian vault (external) | Built-in brain/ + SQLite FTS5 |
| **Scheduler** | Not shown | APScheduler cron jobs |
| **Cost Tracking** | Not shown | Built-in per-provider analytics |
@ -371,7 +240,7 @@ Open Agentic OS on your phone — bottom nav bar replaces sidebar, touch targets
| **Audit Trail** | Not shown | Full activity log |
| **Standards System** | Not shown | Discover/inject conventions |
| **Client Timeout** | Not shown | 200s AbortController |
| **Kanban Board** | Yes | **Yes — built-in** with drag-and-drop, priority, block/unblock, filters |
| **Kanban Board** | Yes | No (not needed for agent OS) |
| **Open Source** | No (tutorial only) | **MIT License** |
---
@ -381,7 +250,7 @@ Open Agentic OS on your phone — bottom nav bar replaces sidebar, touch targets
- **OS**: Linux (Ubuntu 22.04+), macOS
- **Python**: 3.10, 3.11, 3.12
- **Browsers**: Chrome, Firefox, Edge
- **Agents**: opencode v0.8+, Hermes Agent v1.0+, agy CLI (Antigravity)
- **Agents**: opencode v0.8+, Hermes Agent v1.0+, Gemini CLI v1.0+
---

View File

@ -1,24 +0,0 @@
# agy (Antigravity) — Agentic OS Config
The **agy** CLI (Antigravity) replaces the deprecated `gemini` CLI as the
research/analysis agent in Agentic OS (v0.4.0).
- Use the `agy` binary from the CLI
- Non-interactive mode: `agy --print "<query>"`
- Handles web research, multi-modal analysis, document understanding, data
analysis, and reasoning tasks
## Integration
- Invoked via `execute_agent("agy", message)` in `server.py`
- Registered in `data/agent-routes.json` under `agent_capabilities`
- Status checked via `shutil.which("agy")` in `check_agent()`
## Install
```
curl -fsSL https://antigravity.ai/install | bash
```
## Usage
```
agy --print "Research the latest AI agent trends"
```

View File

@ -1,7 +0,0 @@
{
"name": "agy-extension",
"version": "1.0.0",
"description": "agy (Antigravity) CLI extension for Agentic OS",
"model": "agy",
"capabilities": ["web_search", "multi_modal", "data_analysis", "document_understanding", "reasoning"]
}

21
agents/gemini/GEMINI.md Normal file
View File

@ -0,0 +1,21 @@
# Gemini CLI — Agentic OS Config
## Role
Research, analysis, multi-modal understanding, web search, data analysis, document comprehension
## Instructions
- Use `gemini` binary from CLI
- Preferred model: gemini-2.5-flash (free tier)
- For web research: use web search tools
- For document analysis: use multi-modal capabilities
- Output results in markdown format
- Route coding tasks to opencode
- Route memory/channel tasks to Hermes Agent
- Cost tracking: log token usage to data/cost-history.json
## Capabilities
- Web search and research
- PDF/image analysis
- Data analysis and visualization
- Competitive analysis
- Learning and research

View File

@ -0,0 +1,7 @@
{
"name": "gemini-extension",
"version": "1.0.0",
"description": "Gemini CLI extension for Agentic OS",
"model": "gemini-2.5-flash",
"capabilities": ["web_search", "multi_modal", "data_analysis", "document_understanding"]
}

View File

@ -2,18 +2,9 @@
## Active Context
- Building Agentic OS (May 2026)
- 3-agent system: opencode + Hermes + agy
- 3-agent system: opencode + Hermes + Gemini CLI
- Web dashboard on FastAPI
- v0.2.0 released Jun 5, 2026 — 68 features (51 ref + 10 extras + 7 new), 58 endpoints, 20 pages
## v0.2.0 Features
- Kanban Board: 6-column task management via data/kanban/ JSON files
- Goals: Project targets auto-synced to brain/active-projects.md via data/goals.json
- Journal: Daily entries stored as brain/journal/YYYY-MM-DD.md
- Agent Health: Real-time status checks for all 3 agents
- Smart Router: Keyword-based task routing with confidence scoring
- Learning Analytics: Skill evaluation scores and trends
- Session Replay: Browse opencode session logs from dashboard
- 51 features + 10 extras
## Skills Known
- heartbeat: system health monitoring

View File

@ -1,13 +1,13 @@
# Hermes Agent — SOUL.md
## Identity
You are the memory and scheduling subsystem of Agentic OS. You manage persistent context, scheduled tasks, messaging channels, skill hub curation, and multi-agent coordination.
You are the memory and scheduling subsystem of Agentic OS. You manage persistent context, scheduled tasks, messaging channels (Telegram/Discord), skill hub curation, and multi-agent coordination.
## Core Directives
- Maintain MEMORY.md and USER.md with cross-session context
- Schedule and monitor recurring tasks via cron
- Route coding tasks to opencode
- Route research tasks to agy CLI
- Route research tasks to Gemini CLI
- Log all actions to audit/audit.log
## Memory Configuration
@ -16,5 +16,6 @@ You are the memory and scheduling subsystem of Agentic OS. You manage persistent
- Context files: AGENTS.md, CLAUDE.md, .cursorrules
## Channels
- Telegram: Configured via gateway
- Discord: Configured via gateway
- CLI: Default interaction mode
- Gateway: Configured

View File

@ -1,9 +1,11 @@
# USER.md — User Profile for Hermes
## User
- Name: User
- Tech Stack: opencode, Hermes Agent, agy CLI, deepseek-v4-flash-free
- Projects: CloudMart (GCP DevOps), Agentic OS
- Name: Mihir N Modi
- Location: Gujarat, India
- Tech Stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
- Projects: CloudMart (GCP DevOps), Agentic OS, AgriAssist AI
- Goals: AI/ML/LLMs + DevOps/Cloud → MNCs
- Budget: Free tiers only
## Preferences
@ -13,5 +15,6 @@
- Cost-conscious — always free tier first
## Communication
- Primary interaction: CLI (opencode/Hermes/agy)
- Channels: CLI, messaging (Telegram)
- Email: rinkumi0210@gmail.com
- Primary interaction: CLI (opencode/Hermes/Gemini)
- Channels: CLI, Telegram (configured)

View File

@ -8,5 +8,5 @@ Code generation, DevOps, file operations, infrastructure-as-code, git management
- Check brain/recent-decisions.md for active context
- After each task: update learnings.md, append to audit/audit.log
- Run `git add -A && git commit -m "description"` after meaningful changes
- Route research tasks to agy CLI
- Route research tasks to Gemini CLI
- Route memory/channel tasks to Hermes Agent

View File

@ -10,3 +10,15 @@
- Status: Active (ongoing)
- GCP DevOps multi-region e-commerce platform
- GKE Autopilot, Cloud SQL, Cloud CDN, Istio, Next.js
- Budget: $60-200/mo
## SEM-2 Academics
- Status: Active (exam prep)
- 192 markdown files across 5 subjects
- Formula sheets, exam insights, study planner
## AgriAssist AI
- Status: On hold
- Hackathon project (slides, MVP, pitch strategy)
- [Test goal](goal:c1c992ef) —

View File

@ -17,9 +17,9 @@ Agentic OS is a multi-agent orchestration platform that coordinates opencode, He
- "Kernel of a system" mentality — everything has a purpose, nothing is decorative
## Key Relationships
- User: Developer — AI/ML and DevOps enthusiast
- User: Mihir N Modi — BTech CSE 1st year, Gujarat, India
- Tool stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
- Active projects: CloudMart (GCP DevOps), Agentic OS
- Active projects: CloudMart (GCP DevOps), AgriAssist AI, Java OOP, SEM-2 Academics
## Standing Decisions
- All memory is stored in markdown for cross-agent compatibility

View File

@ -10,9 +10,10 @@
- Python 3.10+ required (FastAPI backend)
- Node.js 18+ required (opencode)
- Hermes Agent needs Python 3.11+ and Node.js
- agy CLI (Antigravity) needs `agy` binary installed — replaces the deprecated gemini CLI
- Gemini CLI needs `gemini` binary installed
- Linux environment (Ubuntu/WSL)
- Dashboard binds to localhost only — no external exposure without explicit config
## Time
- User is a BTech 1st year — study schedule takes priority
- Project builds during free time and weekends

View File

@ -6,14 +6,19 @@
- All skills follow _template/ convention
## User
- Name: User
- Name: Mihir N Modi
- Email: rinkumi0210@gmail.com
- Location: Gujarat, India
- Education: BTech CSE 1st year
- Career: AI/ML/LLMs + DevOps/Cloud → MNCs
- Budget: Strictly free tiers
## Active Work
- Building Agentic OS from scratch (May 17, 2026)
- CloudMart GCP DevOps project ongoing
- SEM-2 academics active (Physics, Maths-2, EGD, Industry 4.0, EVS)
## Technology Preferences
- Preferred model: deepseek-v4-flash-free
- Free tiers: GCP Free, GitHub Student Dev Pack, Colab, Kaggle
- Knowledge management via markdown vaults
- Obsidian vaults for knowledge management (4 vaults)

View File

@ -1,241 +0,0 @@
"""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()
except Exception:
return []
return [dict(r) for r in rows]
def build_graph() -> dict:
"""Build a knowledge graph from the FTS5 index + entity table (v0.4.0).
Nodes: brain files, skills, journal entries, extracted entities.
Edges: file->entity co-occurrence, entity co-occurrence in same source.
"""
conn = _get_db()
nodes, edges = [], []
node_ids, edge_keys = set(), set()
# Document nodes (from memory_meta)
rows = conn.execute(
"SELECT id, source, path, title, category FROM memory_meta"
).fetchall()
doc_by_id = {}
for r in rows:
node = {
"id": r["id"],
"label": r["title"] or r["path"],
"type": r["category"] or r["source"],
"path": r["path"],
"source": r["source"],
}
nodes.append(node)
node_ids.add(r["id"])
doc_by_id[r["id"]] = r["path"]
# Entity nodes
ents = conn.execute(
"SELECT DISTINCT name, type, source FROM entities LIMIT 200"
).fetchall()
entity_ids = set()
for e in ents:
nid = f"ent:{e['name']}:{e['type']}"
if nid in entity_ids:
continue
entity_ids.add(nid)
nodes.append({
"id": nid,
"label": e["name"],
"type": f"entity:{e['type']}",
"path": "",
"source": e["source"] or "auto",
})
# Edges: document <-> entity co-occurrence
edge_rows = conn.execute(
"SELECT source, name, type FROM entities LIMIT 500"
).fetchall()
ent_by_key = {}
for e in edge_rows:
key = f"ent:{e['name']}:{e['type']}"
ent_by_key.setdefault(e["source"], []).append(key)
# Edges between documents (same source directory / shared entity)
doc_sources = {}
for r in rows:
doc_sources.setdefault(r["source"], []).append(r["id"])
for source, ids in doc_sources.items():
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
key = tuple(sorted((ids[i], ids[j])))
if key not in edge_keys:
edge_keys.add(key)
edges.append({"source": ids[i], "target": ids[j], "type": "shares_source"})
for source, ent_ids in ent_by_key.items():
# link each entity to the document it appeared in
for doc_id, doc_path in doc_by_id.items():
if doc_path and source and doc_path in source:
key = (doc_id, ent_ids[0])
if key not in edge_keys:
edge_keys.add(key)
for ent_id in ent_ids[:5]:
edges.append({"source": doc_id, "target": ent_id, "type": "mentions"})
return {"nodes": nodes, "edges": edges,
"stats": {"nodes": len(nodes), "edges": len(edges)}}
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

@ -51,25 +51,7 @@ const api = {
getStandards: () => api.get('/api/standards'),
discoverStandards: () => api.post('/api/standards/discover'),
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
chatWithFile: async (agent, message, file, controller) => {
const form = new FormData();
form.append('agent', agent);
form.append('message', message || '');
form.append('file', file);
const opts = { method: 'POST', body: form };
if (controller) opts.signal = controller.signal;
const r = await fetch('/api/chat/upload', opts);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
getChatHistory: () => api.get('/api/chat/history'),
searchChatHistory: (q, agent, limit) => {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (agent) params.set('agent', agent);
if (limit) params.set('limit', limit);
return api.get(`/api/chat/history?${params.toString()}`);
},
// Kanban
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),
@ -107,22 +89,4 @@ 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'),
// v0.4.0: Memory Knowledge Graph
getMemoryGraph: () => api.get('/api/memory/graph'),
// v0.4.0: Code Diff Viewer
getDiff: (file, ref = 'HEAD') => api.get(`/api/diff?file=${encodeURIComponent(file)}&ref=${encodeURIComponent(ref)}`),
};

View File

@ -1,7 +1,5 @@
const pageCache = {};
const APP_VERSION = '0.4.1';
const PAGE_BASE = '/dashboard/pages/';
async function loadPage(name) {
@ -17,10 +15,9 @@ async function loadPage(name) {
function loadScript(src) {
return new Promise((resolve, reject) => {
const versioned = `${src}?v=${APP_VERSION}`;
if (document.querySelector(`script[src="${versioned}"]`)) { resolve(); return; }
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const script = document.createElement('script');
script.src = versioned;
script.src = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(script);
@ -35,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, .bottom-nav-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const navItem = document.querySelector(`[data-page="${hash}"]`);
if (navItem) navItem.classList.add('active');

View File

@ -5,14 +5,10 @@
<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?v=0.4.0">
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
</head>
<body>
<div id="topLoadingBar"></div>
@ -37,7 +33,6 @@
<div class="sidebar-nav">
<div class="sidebar-section"><div class="sidebar-section-label">Primary</div></div>
<a href="#chat" class="nav-item" data-page="chat"><span class="nav-icon">💬</span><span class="nav-label">AI Chat</span></a>
<a href="#history" class="nav-item" data-page="history"><span class="nav-icon">🕘</span><span class="nav-label">Chat History</span></a>
<a href="#dashboard" class="nav-item active" data-page="dashboard"><span class="nav-icon"></span><span class="nav-label">Dashboard</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Agents</div></div>
<a href="#skills" class="nav-item" data-page="skills"><span class="nav-icon"></span><span class="nav-label">Skills</span><span class="nav-badge" id="skillCount">0</span></a>
@ -53,7 +48,6 @@
<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>
@ -94,31 +88,8 @@
<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?v=0.4.1"></script>
<script src="api.js?v=0.4.1"></script>
<script src="app.js?v=0.4.1"></script>
<script>
// Purge any stale service workers/caches from earlier sessions (v0.4.1)
(function purgeStaleSW() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function (regs) {
regs.forEach(function (r) { r.unregister(); });
}).catch(function () {});
}
if (window.caches) {
caches.keys().then(function (keys) {
keys.forEach(function (k) { caches.delete(k); });
}).catch(function () {});
}
})();
</script>
<script src="utils.js"></script>
<script src="api.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@ -59,8 +59,8 @@ async function refreshAgentHealth() {
const agents = data.agents || [];
const cards = document.getElementById('agentHealthCards');
if (!cards) return;
const agentIcons = { opencode: '🔧', hermes: '⚡', agy: '🧠' };
const agentColors = { opencode: 'purple', hermes: 'green', agy: 'blue' };
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
const agentColors = { opencode: 'purple', hermes: 'green', gemini: 'blue' };
cards.innerHTML = agents.map(a => `
<div class="agent-health-card">
<div class="agent-health-avatar" style="background:var(--${agentColors[a.name] || 'accent'}-dim);color:var(--${agentColors[a.name] || 'accent'})">

View File

@ -67,7 +67,7 @@ function applyAuditFilter() {
<tr>
<td style="font-size:12px;white-space:nowrap">${formatDate(e.timestamp)}</td>
<td><span class="badge ${e.action === 'skill_run' ? 'badge-success' : e.action === 'brain_update' ? 'badge-info' : e.action === 'backup_created' ? 'badge-accent' : 'badge-warning'}">${e.action}</span></td>
<td style="font-size:13px">${e.skill ? `<strong>${escapeHtml(e.skill)}</strong>` : ''}${e.file ? `File: ${escapeHtml(e.file)}` : ''}${e.job ? `Job: ${escapeHtml(e.job)}` : ''}${e.plugin ? `Plugin: ${escapeHtml(e.plugin)}` : ''}</td>
<td style="font-size:13px">${e.skill ? `<strong>${e.skill}</strong>` : ''}${e.file ? `File: ${e.file}` : ''}${e.job ? `Job: ${e.job}` : ''}${e.plugin ? `Plugin: ${e.plugin}` : ''}</td>
<td style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">${e.id || ''}</td>
</tr>
`).join('')}

View File

@ -27,10 +27,10 @@ async function renderBackups() {
<tbody>
${backups.map(b => `
<tr>
<td><strong>${escapeHtml(b.name)}</strong></td>
<td><strong>${b.name}</strong></td>
<td>${formatBytes(b.size)}</td>
<td style="font-size:12px">${formatDate(b.created)}</td>
<td><button class="btn btn-sm btn-danger" onclick="restoreBackup('${encodeURIComponent(b.name)}')">Restore</button></td>
<td><button class="btn btn-sm btn-danger" onclick="restoreBackup('${b.name}')">Restore</button></td>
</tr>
`).join('')}
</tbody>
@ -53,21 +53,19 @@ async function createBackup() {
}
}
async function restoreBackup(encodedName) {
const name = decodeURIComponent(encodedName);
async function restoreBackup(name) {
showModal('Restore Backup', `
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">Restore <strong>${escapeHtml(name)}</strong>? This will overwrite current brain, skills, agents, registry, standards, and prompts data.</p>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">Restore <strong>${name}</strong>? This will overwrite current brain, skills, agents, registry, standards, and prompts data.</p>
<div class="card" style="background:var(--red-dim);border-color:transparent">
<div class="flex items-center gap-2"><span></span><span style="font-size:13px;font-weight:500">This action cannot be undone</span></div>
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" onclick="confirmRestore('${encodeURIComponent(name)}')">Restore</button>
<button class="btn btn-danger" onclick="confirmRestore('${name}')">Restore</button>
`);
}
async function confirmRestore(encodedName) {
const name = decodeURIComponent(encodedName);
async function confirmRestore(name) {
try {
const r = await api.restoreBackup(name);
closeModal();

View File

@ -4,7 +4,7 @@ async function renderChat() {
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">AI Chat</h1>
<p class="page-subtitle">Talk to opencode, Hermes, and agy CLI</p>
<p class="page-subtitle">Talk to opencode, Hermes, and Gemini CLI</p>
</div>
<div class="btn-group">
<button class="btn" onclick="clearChat()">🗑 Clear</button>
@ -28,10 +28,10 @@ async function renderChat() {
<div class="chat-agent-desc">Memory & Scheduling</div>
</div>
</div>
<div class="chat-agent" data-agent="agy" onclick="selectAgent('agy')">
<div class="chat-agent" data-agent="gemini" onclick="selectAgent('gemini')">
<div class="agent-dot offline"></div>
<div>
<div class="chat-agent-name">agy (Antigravity)</div>
<div class="chat-agent-name">Gemini CLI</div>
<div class="chat-agent-desc">Research & Analysis</div>
</div>
</div>
@ -48,20 +48,15 @@ async function renderChat() {
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;justify-content:center">
<button class="btn btn-sm" onclick="sendQuickPrompt('opencode','Check the system status and running processes')">🔍 System Check</button>
<button class="btn btn-sm" onclick="sendQuickPrompt('hermes','What did I work on recently?')">🧠 Recall Memory</button>
<button class="btn btn-sm" onclick="sendQuickPrompt('agy','Research the latest trends in AI agents')">📊 Research</button>
<button class="btn btn-sm" onclick="sendQuickPrompt('gemini','Research the latest trends in AI agents')">📊 Research</button>
</div>
</div>
</div>
<div class="chat-input-area">
<div class="chat-agent-indicator" id="chatAgentIndicator">opencode</div>
<textarea id="chatInput" class="chat-input" rows="1" placeholder="Type a message..." onkeydown="handleChatKey(event)"></textarea>
<button class="btn btn-icon" onclick="document.getElementById('chatFileInput').click()" title="Attach file" style="font-size:16px">📎</button>
<input type="file" id="chatFileInput" style="display:none" onchange="handleChatFile(this)">
<button class="btn btn-primary btn-icon" onclick="sendChatMessage()" id="chatSendBtn" title="Send"></button>
</div>
<div id="chatAttachment" style="display:none;padding:6px 12px;font-size:12px;color:var(--text-secondary);background:var(--yellow-dim);border-top:1px solid var(--border)">
📎 <span id="chatAttachmentName"></span> <span style="cursor:pointer;margin-left:8px;color:var(--red)" onclick="clearChatAttachment()"> remove</span>
</div>
</div>
</div>
`;
@ -121,38 +116,29 @@ function autoResizeTextarea(el) {
async function sendChatMessage() {
const input = document.getElementById('chatInput');
const message = input.value.trim();
const fileInput = document.getElementById('chatFileInput');
const file = fileInput && fileInput.files && fileInput.files[0];
if (!message && !file) return;
if (!message) return;
const agent = window._currentAgent || 'opencode';
input.value = '';
input.style.height = 'auto';
// Add user message to chat
addChatMessage('user', message || `📎 ${file.name}`, agent);
addChatMessage('user', message, agent);
// Show typing indicator
const typingId = showTypingIndicator(agent);
// Client-side timeout: 200s (slightly more than Hermes' 180s backend timeout)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 200000);
try {
let r;
if (file) {
r = await api.chatWithFile(agent, message, file, controller);
clearChatAttachment();
} else {
r = await api.chat(agent, message, controller);
}
// Client-side timeout: 200s (slightly more than Hermes' 180s backend timeout)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 200000);
const r = await api.chat(agent, message, controller);
clearTimeout(timeoutId);
removeTypingIndicator(typingId);
addChatMessage('assistant', r.response.content, agent);
// Store in local history
window._chatHistory.push({ role: 'user', content: message || `📎 ${file.name}`, agent });
window._chatHistory.push({ role: 'user', content: message, agent });
window._chatHistory.push({ role: 'assistant', content: r.response.content, agent });
} catch (err) {
removeTypingIndicator(typingId);
@ -161,25 +147,6 @@ async function sendChatMessage() {
}
}
function handleChatFile(fileInput) {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
showToast('File too large (max 2 MB)', 'error');
fileInput.value = '';
return;
}
document.getElementById('chatAttachment').style.display = '';
document.getElementById('chatAttachmentName').textContent = `${file.name} (${formatBytes(file.size)})`;
}
function clearChatAttachment() {
const fi = document.getElementById('chatFileInput');
if (fi) fi.value = '';
const bar = document.getElementById('chatAttachment');
if (bar) bar.style.display = 'none';
}
function addChatMessage(role, content, agent) {
const container = document.getElementById('chatMessages');
const welcome = container.querySelector('.chat-welcome');

View File

@ -53,8 +53,8 @@ async function renderCost() {
${entries.slice(-20).reverse().map(e => `
<tr>
<td style="font-size:12px">${formatDate(e.timestamp)}</td>
<td><span class="badge badge-accent">${escapeHtml(e.agent)}</span></td>
<td style="font-size:12px">${escapeHtml(e.model)}</td>
<td><span class="badge badge-accent">${e.agent}</span></td>
<td style="font-size:12px">${e.model}</td>
<td>${(e.tokens || 0).toLocaleString()}</td>
<td><span class="badge ${(e.cost || 0) > 0 ? 'badge-warning' : 'badge-success'}">$${(e.cost || 0).toFixed(6)}</span></td>
</tr>
@ -80,16 +80,6 @@ async function renderCost() {
`);
}
// Load Chart.js lazily (CDN only needed on this page)
if (entries.length > 0) {
const ok = await loadChartJS();
if (!ok) {
document.querySelector('.chart-container')?.insertAdjacentHTML('beforebegin',
'<div class="card mb-3" style="border-color:var(--yellow)"><div class="empty-state" style="padding:16px"><div class="empty-state-icon">📉</div><div class="empty-state-title">Charts unavailable</div><div class="empty-state-desc">Chart.js CDN could not be loaded. Check your internet connection.</div></div></div>');
return;
}
}
// Build agent chart
const agentTotals = {};
entries.forEach(e => {
@ -125,22 +115,11 @@ async function renderCost() {
}
}
function loadChartJS() {
return new Promise((resolve) => {
if (window.Chart) { resolve(true); return; }
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js';
script.onload = () => resolve(true);
script.onerror = () => resolve(false);
document.body.appendChild(script);
});
}
async function recordTestCost() {
showModal('Record Cost Entry', `
<div class="form-group">
<label class="form-label">Agent</label>
<select id="rcAgent" class="form-select"><option>opencode</option><option>hermes</option><option>agy</option></select>
<select id="rcAgent" class="form-select"><option>opencode</option><option>hermes</option><option>gemini</option></select>
</div>
<div class="form-group">
<label class="form-label">Model</label>

View File

@ -86,7 +86,7 @@ async function renderDashboard() {
<div class="event-item">
<div class="event-dot" style="background:${e.action === 'skill_run' ? 'var(--accent)' : 'var(--blue)'}"></div>
<div class="event-content">
<div class="event-title">${escapeHtml(e.action)}${e.skill ? `: ${escapeHtml(e.skill)}` : ''}</div>
<div class="event-title">${e.action}${e.skill ? `: ${e.skill}` : ''}</div>
<div class="event-meta">${e.agent ? `via ${e.agent}` : ''} ${e.run_id ? `#${e.run_id}` : ''}</div>
</div>
<div class="event-time">${timeAgo(e.timestamp)}</div>

View File

@ -1,157 +0,0 @@
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: '⚡', agy: '🧠' };
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

@ -1,117 +0,0 @@
async function renderHistory() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Chat History</h1>
<p class="page-subtitle">Browse, search, and replay past conversations</p>
</div>
<div class="btn-group">
<button class="btn" onclick="refreshHistory()">🔄 Refresh</button>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<div class="form-group" style="margin-bottom:8px">
<input id="historySearch" class="form-input" placeholder="Search conversations..." onkeydown="if(event.key==='Enter')loadHistory()">
</div>
<div class="flex items-center gap-2" style="flex-wrap:wrap">
<span style="font-size:12px;color:var(--text-muted)">Filter:</span>
${['', 'opencode', 'hermes', 'agy'].map(a => `
<button class="btn btn-sm ${!a ? 'btn-primary' : 'btn-ghost'}" data-agent="${a}" onclick="filterHistoryByAgent('${a}')">${a || 'All'}</button>
`).join('')}
<span style="flex:1"></span>
<button class="btn btn-sm btn-primary" onclick="loadHistory()">🔍 Search</button>
</div>
</div>
<div id="historyResults"><div class="loading"><div class="loading-spinner"></div><span>Loading history...</span></div></div>
`;
window._historyAgent = '';
window._historyFilter = '';
await loadHistory();
}
function filterHistoryByAgent(agent) {
window._historyAgent = agent;
document.querySelectorAll('#pageContent button[data-agent]').forEach(b => {
b.classList.toggle('btn-primary', b.dataset.agent === agent);
b.classList.toggle('btn-ghost', b.dataset.agent !== agent);
});
loadHistory();
}
async function loadHistory() {
const container = document.getElementById('historyResults');
if (!container) return;
const q = (document.getElementById('historySearch')?.value || '').trim();
try {
const data = await api.searchChatHistory(q, window._historyAgent, 500);
const messages = data.messages || [];
renderHistoryList(container, messages, q);
} catch (err) {
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
}
}
function renderHistoryList(container, messages, q) {
if (messages.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">💬</div><div class="empty-state-title">No conversations found</div><div class="empty-state-desc">Messages appear here after you chat with an agent.</div></div>';
return;
}
// Group by date (YYYY-MM-DD)
const groups = {};
messages.forEach(m => {
const day = (m.timestamp || '').slice(0, 10) || 'Unknown date';
(groups[day] = groups[day] || []).push(m);
});
const agentIcons = { opencode: '🔧', hermes: '⚡', agy: '🧠' };
const highlight = (text) => {
if (!q) return escapeHtml(text);
const escaped = escapeHtml(text);
const ql = escapeHtml(q);
return escaped.split(new RegExp(`(${ql.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'i')).map((part, i) =>
part.toLowerCase() === ql.toLowerCase() ? `<mark style="background:var(--yellow-dim);color:inherit;border-radius:2px">${part}</mark>` : part
).join('');
};
container.innerHTML = Object.entries(groups).map(([day, msgs]) => `
<div class="card" style="margin-bottom:16px">
<div class="card-header"><span class="card-title">📅 ${escapeHtml(day)}</span><span class="badge badge-info">${msgs.length} msg</span></div>
<div style="display:flex;flex-direction:column">
${msgs.map(m => {
const icon = agentIcons[m.agent] || '🤖';
return `<div class="history-msg" style="display:flex;gap:10px;padding:10px 0;border-bottom:1px solid var(--border);cursor:pointer" onclick="loadHistoryIntoChat('${m.agent}','${escapeHtml(m.content || '').replace(/'/g, "\\'").slice(0, 1000)}')">
<div style="flex:0 0 auto;width:28px;text-align:center;font-size:16px">${m.role === 'user' ? '👤' : icon}</div>
<div style="flex:1;min-width:0">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span style="font-size:12px;font-weight:600">${escapeHtml(m.role === 'user' ? 'You' : (m.agent || 'agent'))}</span>
<span class="badge ${m.role === 'user' ? 'badge-info' : 'badge-accent'}">${escapeHtml(m.agent || '')}</span>
<span style="font-size:11px;color:var(--text-muted)">${timeAgo(m.timestamp)}</span>
</div>
<div class="text-sm text-secondary" style="margin-top:3px;white-space:pre-wrap;word-break:break-word;max-height:80px;overflow:hidden">${highlight(m.content || '')}</div>
</div>
</div>`;
}).join('')}
</div>
</div>
`).join('');
}
async function refreshHistory() {
await loadHistory();
}
function loadHistoryIntoChat(agent, content) {
navigate('chat');
setTimeout(() => {
selectAgent(agent);
const input = document.getElementById('chatInput');
if (input) {
input.value = content;
input.focus();
autoResizeTextarea(input);
}
}, 300);
}

View File

@ -7,175 +7,36 @@ async function renderMemory() {
<p class="page-subtitle">Shared brain context across all agents</p>
</div>
</div>
<div class="tabs" id="memoryTabs">
<button class="tab active" data-view="files" onclick="switchMemoryView('files')">📄 Files</button>
<button class="tab" data-view="graph" onclick="switchMemoryView('graph')">🕸 Knowledge Graph</button>
</div>
<div id="memoryList"><div class="loading"><div class="loading-spinner"></div></div></div>
`;
window._memoryView = 'files';
await loadMemoryFiles();
}
function switchMemoryView(view) {
window._memoryView = view;
document.querySelectorAll('#memoryTabs .tab').forEach(t => t.classList.toggle('active', t.dataset.view === view));
if (view === 'graph') loadMemoryGraph();
else loadMemoryFiles();
}
async function loadMemoryFiles() {
const container = document.getElementById('memoryList');
if (!container) return;
try {
const brain = await api.getBrain();
const files = Object.entries(brain);
const container = document.getElementById('memoryList');
if (files.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🧠</div><div class="empty-state-title">No memory files</div></div>';
return;
}
container.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr));gap:12px;min-width:0">${files.map(([name, content]) => {
container.innerHTML = `<div style="display:grid;gap:12px">${files.map(([name, content]) => {
const preview = content ? content.slice(0, 200) : '';
const safeName = escapeHtml(name.replace('.md', '').replace(/-/g, ' '));
return `<div class="card" style="cursor:pointer;min-width:0" onclick="editMemory('${encodeURIComponent(name)}')">
return `<div class="card" style="cursor:pointer" onclick="editMemory('${name}')">
<div class="flex items-center justify-between mb-2">
<div><span class="card-title">${safeName}</span></div>
<div><span class="card-title">${name.replace('.md', '').replace(/-/g, ' ')}</span></div>
<span class="badge badge-info">${content ? content.split('\n').length : 0} lines</span>
</div>
<pre style="max-height:80px;overflow:hidden;font-size:11px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word">${escapeHtml(preview)}${preview.length >= 200 ? '...' : ''}</pre>
<pre style="max-height:80px;overflow:hidden;font-size:11px;color:var(--text-muted)">${escapeHtml(preview)}${preview.length >= 200 ? '...' : ''}</pre>
</div>`;
}).join('')}</div>`;
} catch (err) {
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
document.getElementById('memoryList').innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
}
}
async function loadMemoryGraph() {
const container = document.getElementById('memoryList');
if (!container) return;
container.innerHTML = `<div class="loading"><div class="loading-spinner"></div><span>Building graph...</span></div>`;
try {
const graph = await api.getMemoryGraph();
const nodes = graph.nodes || [];
const edges = graph.edges || [];
if (nodes.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🕸</div><div class="empty-state-title">No graph data</div><div class="empty-state-desc">Run a memory reindex or chat with agents to build connections.</div></div>';
return;
}
const stats = graph.stats || {};
container.innerHTML = `
<div class="card" style="margin-bottom:16px">
<div style="display:flex;gap:16px;flex-wrap:wrap">
<span class="badge badge-info">📄 ${stats.nodes || 0} nodes</span>
<span class="badge badge-accent">🔗 ${stats.edges || 0} edges</span>
<span class="badge badge-success">${nodes.filter(n => (n.type || '').startsWith('entity')).length} entities</span>
</div>
</div>
<div class="card">
<canvas id="memoryGraphCanvas" style="width:100%;height:480px"></canvas>
</div>
<div style="margin-top:12px;font-size:12px;color:var(--text-muted)">Click a node to open the associated file. Drag to pan, scroll to zoom.</div>
`;
drawMemoryGraph(graph);
} catch (err) {
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
}
}
function drawMemoryGraph(graph) {
const canvas = document.getElementById('memoryGraphCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = canvas.clientWidth || 800;
const height = canvas.clientHeight || 480;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
const nodes = graph.nodes || [];
const edges = graph.edges || [];
const typeColors = {
brain: '#6c5ce7', skill: '#00b894', journal: '#fdcb6e', general: '#74b9ff',
};
const colorFor = (t) => {
if (t.startsWith('entity:')) return '#fd79a8';
return typeColors[t] || '#74b9ff';
};
// Simple layout: entities on left, docs spread on right (force-ish radial fallback)
const entityNodes = nodes.filter(n => (n.type || '').startsWith('entity:'));
const docNodes = nodes.filter(n => !(n.type || '').startsWith('entity:'));
const positions = {};
const cx = width / 2, cy = height / 2;
docNodes.forEach((n, i) => {
const angle = (i / Math.max(docNodes.length, 1)) * Math.PI * 2 - Math.PI / 2;
positions[n.id] = { x: cx + Math.cos(angle) * Math.min(width * 0.3, 200), y: cy + Math.sin(angle) * Math.min(height * 0.35, 180) };
});
entityNodes.forEach((n, i) => {
const angle = (i / Math.max(entityNodes.length, 1)) * Math.PI * 2 - Math.PI / 2;
positions[n.id] = { x: cx + Math.cos(angle) * Math.min(width * 0.38, 260), y: cy + Math.sin(angle) * Math.min(height * 0.42, 220) };
});
ctx.clearRect(0, 0, width, height);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(148,163,184,0.4)';
edges.forEach(e => {
const a = positions[e.source], b = positions[e.target];
if (!a || !b) return;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
});
nodes.forEach(n => {
const p = positions[n.id];
if (!p) return;
const color = colorFor(n.type);
const isEntity = (n.type || '').startsWith('entity:');
const radius = isEntity ? 6 : 10;
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
const label = (n.label || '').slice(0, 18);
ctx.fillStyle = 'var(--text-secondary, #cbd5e1)';
ctx.font = '10px Inter, sans-serif';
ctx.fillText(label, p.x + radius + 4, p.y + 4);
});
// Click-to-open: store layout for hit testing
window._graphLayout = { positions, nodes, docById: {} };
nodes.forEach(n => { window._graphLayout.docById[n.id] = n; });
canvas.onclick = (ev) => {
const rect = canvas.getBoundingClientRect();
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
for (const [id, p] of Object.entries(positions)) {
if (Math.hypot(mx - p.x, my - p.y) < 14) {
const n = window._graphLayout.docById[id];
if (n && n.path && n.path.endsWith('.md') && !(n.type || '').startsWith('entity:')) {
editMemory(encodeURIComponent(n.path.split('/').pop()));
}
return;
}
}
};
}
async function editMemory(encodedName) {
const name = decodeURIComponent(encodedName);
const display = escapeHtml(name.replace('.md', '').replace(/-/g, ' '));
async function editMemory(name) {
const display = name.replace('.md', '').replace(/-/g, ' ');
let content = '';
try {
const r = await api.getBrainFile(name);
@ -189,12 +50,11 @@ async function editMemory(encodedName) {
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveMemory('${encodeURIComponent(name)}')">💾 Save</button>
<button class="btn btn-primary" onclick="saveMemory('${name}')">💾 Save</button>
`);
}
async function saveMemory(encodedName) {
const name = decodeURIComponent(encodedName);
async function saveMemory(name) {
const content = document.getElementById('memContent').value;
try {
await api.updateBrainFile(name, content);

View File

@ -28,9 +28,9 @@ async function renderPlugins() {
<tbody>
${plugins.map(p => `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td><code>${escapeHtml(p.version || '1.0.0')}</code></td>
<td><span class="badge badge-info">${escapeHtml(p.type || 'skill')}</span></td>
<td><strong>${p.name}</strong></td>
<td><code>${p.version || '1.0.0'}</code></td>
<td><span class="badge badge-info">${p.type || 'skill'}</span></td>
<td style="font-size:12px;color:var(--text-muted)">${formatDate(p.installed)}</td>
</tr>
`).join('')}

View File

@ -24,10 +24,10 @@ async function renderPrompts() {
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const preview = content.slice(0, 180);
const lines = content.split('\n').length;
return `<div class="skill-card" onclick="viewPrompt('${encodeURIComponent(name)}')">
return `<div class="skill-card" onclick="viewPrompt('${name}')">
<div class="skill-card-header">
<div class="skill-card-icon">📝</div>
<div class="skill-card-name">${escapeHtml(displayName)}</div>
<div class="skill-card-name">${displayName}</div>
</div>
<div class="skill-card-desc"><pre style="background:none;border:none;padding:0;max-height:100px;overflow:hidden;font-size:11px;color:var(--text-muted)">${escapeHtml(preview)}${preview.length >= 180 ? '...' : ''}</pre></div>
<div class="skill-card-footer"><span class="badge badge-info">${lines} lines</span></div>
@ -38,15 +38,14 @@ async function renderPrompts() {
}
}
async function viewPrompt(encodedName) {
const name = decodeURIComponent(encodedName);
async function viewPrompt(name) {
let content = '';
try {
const prompts = await api.getPrompts();
content = prompts[name] || '';
} catch {}
const displayName = escapeHtml(name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()));
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
// Store raw content for clipboard copy (avoids HTML entity encoding issue)
window._promptCopyContent = content;

View File

@ -22,7 +22,7 @@ async function renderSettings() {
<div class="card">
<div class="card-header"><span class="card-title">🤖 Agent Preferences</span></div>
<div class="grid grid-3">
${['opencode', 'hermes', 'agy'].map(a => `
${['opencode', 'hermes', 'gemini'].map(a => `
<div class="card" style="padding:14px">
<div class="flex items-center gap-2 mb-2">
<div class="agent-dot ${prefs[a] && prefs[a].enabled !== false ? 'online' : 'offline'}" style="width:10px;height:10px"></div>
@ -34,7 +34,7 @@ async function renderSettings() {
</label>
<div class="form-group" style="margin-bottom:0;margin-top:8px">
<label class="form-label">Binary Path</label>
<input id="bin_${a}" class="form-input" value="${escapeHtml((prefs[a] && prefs[a].binary) || a)}" style="font-size:12px">
<input id="bin_${a}" class="form-input" value="${(prefs[a] && prefs[a].binary) || a}" style="font-size:12px">
</div>
</div>
`).join('')}
@ -50,7 +50,7 @@ async function renderSettings() {
</div>
<div class="form-group">
<label class="form-label">Host</label>
<input id="setHost" class="form-input" value="${escapeHtml(dashboard.host || '127.0.0.1')}">
<input id="setHost" class="form-input" value="${dashboard.host || '127.0.0.1'}">
</div>
</div>
<div class="form-group">
@ -67,11 +67,11 @@ async function renderSettings() {
<div class="form-row">
<div class="form-group">
<label class="form-label">Gemini API Key</label>
<input id="keyGemini" class="form-input" type="password" value="${escapeHtml(apiKeys.gemini || '')}" placeholder="Enter Gemini API key">
<input id="keyGemini" class="form-input" type="password" value="${apiKeys.gemini || ''}" placeholder="Enter Gemini API key">
</div>
<div class="form-group">
<label class="form-label">OpenRouter API Key</label>
<input id="keyOpenrouter" class="form-input" type="password" value="${escapeHtml(apiKeys.openrouter || '')}" placeholder="Enter OpenRouter API key">
<input id="keyOpenrouter" class="form-input" type="password" value="${apiKeys.openrouter || ''}" placeholder="Enter OpenRouter API key">
</div>
</div>
</div>
@ -124,7 +124,7 @@ async function saveAllSettings() {
agent_preferences: {
opencode: { enabled: document.getElementById('agent_opencode').checked, binary: document.getElementById('bin_opencode').value },
hermes: { enabled: document.getElementById('agent_hermes').checked, binary: document.getElementById('bin_hermes').value },
agy: { enabled: document.getElementById('agent_agy').checked, binary: document.getElementById('bin_agy').value },
gemini: { enabled: document.getElementById('agent_gemini').checked, binary: document.getElementById('bin_gemini').value },
},
dashboard: {
port: parseInt(document.getElementById('setPort').value) || 8080,
@ -167,7 +167,7 @@ async function resetSettings() {
async function confirmReset() {
const defaults = {
theme: 'dark',
agent_preferences: { opencode: { enabled: true, binary: 'opencode' }, hermes: { enabled: true, binary: 'hermes' }, agy: { enabled: true, binary: 'agy' } },
agent_preferences: { opencode: { enabled: true, binary: 'opencode' }, hermes: { enabled: true, binary: 'hermes' }, gemini: { enabled: true, binary: 'gemini', model: 'gemini-2.5-flash' } },
dashboard: { port: 8080, host: '127.0.0.1', dark_mode: true },
api_keys: { gemini: '', openrouter: '' },
free_tier_limits: { gemini_flash: { requests_per_day: 1500, tokens_per_day: 1000000 }, openrouter_free: { requests_per_day: 100, tokens_per_day: 200000 } },

View File

@ -51,7 +51,7 @@ async function renderWizardStep() {
wc.innerHTML = `
<div style="text-align:center;padding:12px 0">
<div style="font-size:48px;margin-bottom:16px"></div>
<p style="font-size:14px;color:var(--text-secondary);line-height:1.6">Agentic OS coordinates <strong>opencode</strong>, <strong>Hermes Agent</strong>, and <strong>agy</strong> into a unified multi-agent orchestration platform. This wizard will help you get everything configured.</p>
<p style="font-size:14px;color:var(--text-secondary);line-height:1.6">Agentic OS coordinates <strong>opencode</strong>, <strong>Hermes Agent</strong>, and <strong>Gemini CLI</strong> into a unified multi-agent orchestration platform. This wizard will help you get everything configured.</p>
</div>
`;
break;
@ -65,12 +65,11 @@ async function renderWizardStep() {
<div class="grid grid-2">
${agents.map(a => {
const sc = statusColor(a.status);
const safeStatus = ({online:'online',offline:'offline',warning:'warning'})[a.status] || 'offline';
return `<div class="agent-card">
<div class="agent-dot ${safeStatus}" style="width:14px;height:14px"></div>
<div class="agent-dot ${a.status}" style="width:14px;height:14px"></div>
<div>
<div style="font-weight:600;font-size:14px">${escapeHtml(a.name)}</div>
<div style="font-size:12px;color:${sc.text}">${escapeHtml(a.status)}</div>
<div style="font-weight:600;font-size:14px">${a.name}</div>
<div style="font-size:12px;color:${sc.text}">${a.status}</div>
</div>
</div>`;
}).join('')}
@ -89,7 +88,7 @@ async function renderWizardStep() {
<select id="wizDefault" class="form-select">
<option value="opencode">opencode Best for code/DevOps</option>
<option value="hermes">Hermes Best for memory/scheduling</option>
<option value="agy">agy Best for research</option>
<option value="gemini">Gemini CLI Best for research</option>
</select>
</div>
<div class="form-group">

View File

@ -33,24 +33,22 @@ function renderSkillGrid(skills) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">⚡</div><div class="empty-state-title">No skills installed</div></div>';
return;
}
container.innerHTML = `<div class="grid grid-3" id="skillGrid">${skills.map(s => {
container.innerHTML = `<div class="grid grid-3" id="skillGrid">${skills.map(s => {
const lastScore = s.scores && s.scores.length > 0 ? s.scores[s.scores.length - 1] : null;
const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
const icons = ['⚡', '🔧', '📝', '🔍', '🔄', '🎯', '📊', '🛠', '💡', '🧪', '📋', '💾', '💰', '🔄', '🎨'];
const iconIdx = s.name.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % icons.length;
const icon = icons[iconIdx];
const sName = escapeHtml(s.name);
const sDesc = escapeHtml(s.description || '').slice(0, 120) + ((s.description || '').length > 120 ? '...' : '');
return `<div class="skill-card" onclick="showSkillDetail('${encodeURIComponent(s.name)}')">
return `<div class="skill-card" onclick="showSkillDetail('${s.name}')">
<div class="skill-card-header">
<div class="skill-card-icon">${icon}</div>
<div class="skill-card-name">${sName.replace(/-/g, ' ')}</div>
<div class="skill-card-name">${s.name.replace(/-/g, ' ')}</div>
</div>
<div class="skill-card-desc">${sDesc || 'No description'}</div>
<div class="skill-card-desc">${s.description ? s.description.slice(0, 120) + (s.description.length > 120 ? '...' : '') : 'No description'}</div>
<div class="skill-card-footer">
${avg !== null ? `<span class="badge badge-success">${(avg * 100).toFixed(0)}%</span>` : '<span class="badge badge-info">New</span>'}
${s.has_learnings ? '<span class="badge badge-accent">📖</span>' : ''}
<button class="btn btn-sm btn-primary" style="margin-left:auto" onclick="event.stopPropagation();quickRunSkill('${encodeURIComponent(s.name)}')"> Run</button>
<button class="btn btn-sm btn-primary" style="margin-left:auto" onclick="event.stopPropagation();quickRunSkill('${s.name}')"> Run</button>
</div>
</div>`;
}).join('')}</div>`;
@ -63,12 +61,11 @@ function switchSkillView(view) {
document.getElementById('skillsContainer').innerHTML = `<div class="table-wrapper"><table><thead><tr><th>Skill</th><th>Score</th><th>Learnings</th><th></th></tr></thead><tbody>${skills.map(s => {
const lastScore = s.scores && s.scores.length > 0 ? s.scores[s.scores.length - 1] : null;
const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
const sName = escapeHtml(s.name);
return `<tr onclick="showSkillDetail('${encodeURIComponent(s.name)}')" style="cursor:pointer">
<td><strong>${sName.replace(/-/g, ' ')}</strong></td>
return `<tr onclick="showSkillDetail('${s.name}')" style="cursor:pointer">
<td><strong>${s.name.replace(/-/g, ' ')}</strong></td>
<td>${avg !== null ? `<span class="badge badge-success">${(avg * 100).toFixed(0)}%</span>` : '<span class="badge badge-info">—</span>'}</td>
<td>${s.has_learnings ? '<span class="badge badge-accent">✓</span>' : '<span class="badge">—</span>'}</td>
<td><button class="btn btn-sm btn-primary" onclick="event.stopPropagation();quickRunSkill('${encodeURIComponent(s.name)}')"></button></td>
<td><button class="btn btn-sm btn-primary" onclick="event.stopPropagation();quickRunSkill('${s.name}')"></button></td>
</tr>`;
}).join('')}</tbody></table></div>`;
} else {
@ -82,8 +79,7 @@ function filterSkills() {
renderSkillGrid(skills);
}
async function showSkillDetail(encodedName) {
const name = decodeURIComponent(encodedName);
async function showSkillDetail(name) {
document.getElementById('skillsContainer').style.display = 'none';
document.getElementById('skillTabs').style.display = 'none';
document.getElementById('skillFilter').style.display = 'none';
@ -96,13 +92,11 @@ async function showSkillDetail(encodedName) {
const scores = skill.score_history || [];
const lastScore = scores.length > 0 ? scores[scores.length - 1] : null;
const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
const safeName = escapeHtml(name);
detail.innerHTML = `
<div style="margin-bottom:16px">
<button class="btn btn-ghost" onclick="backToSkills()"> Back to Skills</button>
<button class="btn btn-primary" style="margin-left:8px" onclick="quickRunSkill('${encodeURIComponent(name)}')"> Run ${safeName.replace(/-/g, ' ')}</button>
<button class="btn btn-accent" style="margin-left:8px" onclick="showSkillDiff('${encodeURIComponent(name)}')">📝 View Diff</button>
<button class="btn btn-primary" style="margin-left:8px" onclick="quickRunSkill('${name}')"> Run ${name.replace(/-/g, ' ')}</button>
</div>
<div class="grid grid-2">
<div class="card">
@ -128,9 +122,9 @@ async function showSkillDetail(encodedName) {
<div class="card">
<div class="card-header"><span class="card-title">📁 Context Files</span></div>
${skill.context && skill.context.length > 0
? `<div style="display:flex;flex-wrap:wrap;gap:6px">${skill.context.map(f => `<span class="badge badge-info">${escapeHtml(f)}</span>`).join('')}</div>`
? `<div style="display:flex;flex-wrap:wrap;gap:6px">${skill.context.map(f => `<span class="badge badge-info">${f}</span>`).join('')}</div>`
: '<div style="color:var(--text-muted);font-size:13px">No context files</div>'}
${skill.eval && skill.eval.criteria ? `<div style="margin-top:12px"><strong style="font-size:12px">Eval Criteria:</strong><div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">${skill.eval.criteria.map(c => `<span class="badge badge-accent">${escapeHtml(c)}</span>`).join('')}</div></div>` : ''}
${skill.eval && skill.eval.criteria ? `<div style="margin-top:12px"><strong style="font-size:12px">Eval Criteria:</strong><div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">${skill.eval.criteria.map(c => `<span class="badge badge-accent">${c}</span>`).join('')}</div></div>` : ''}
</div>
</div>
`;
@ -146,9 +140,8 @@ function backToSkills() {
document.getElementById('skillDetail').style.display = 'none';
}
async function quickRunSkill(encodedName) {
const name = decodeURIComponent(encodedName);
const displayName = escapeHtml(name.replace(/-/g, ' '));
async function quickRunSkill(name) {
const displayName = name.replace(/-/g, ' ');
showModal(`Run: ${displayName}`, `
<div class="form-group">
<label class="form-label">Input (optional)</label>
@ -160,18 +153,17 @@ async function quickRunSkill(encodedName) {
<option value="auto">Auto-detect</option>
<option value="opencode">opencode</option>
<option value="hermes">Hermes</option>
<option value="agy">agy (Antigravity)</option>
<option value="gemini">Gemini CLI</option>
</select>
</div>
<div id="skillResult" style="display:none"></div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="executeSkillRun('${encodeURIComponent(name)}')"> Run</button>
<button class="btn btn-primary" onclick="executeSkillRun('${name}')"> Run</button>
`);
}
async function executeSkillRun(encodedName) {
const name = decodeURIComponent(encodedName);
async function executeSkillRun(name) {
const input = document.getElementById('qrsInput').value;
const agent = document.getElementById('qrsAgent').value;
const runBtn = document.querySelector('#modalContainer .btn-primary');
@ -203,37 +195,3 @@ async function executeSkillRun(encodedName) {
if (runBtn) { runBtn.textContent = '▶ Run'; runBtn.disabled = false; }
}
}
async function showSkillDiff(encodedName) {
const name = decodeURIComponent(encodedName);
showModal(`Diff: ${escapeHtml(name)}`, `
<div class="form-group">
<label class="form-label">File (repo-relative)</label>
<div style="display:flex;gap:8px">
<input id="diffFile" class="form-input" value="skills/${escapeHtml(name)}/SKILL.md" style="flex:1;font-size:12px">
<button class="btn btn-primary" onclick="loadDiff()">🔍 Load</button>
</div>
</div>
<div id="diffResult"><div style="color:var(--text-muted);font-size:13px;padding:8px">Enter a file path to see its git diff.</div></div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Close</button>
`);
}
async function loadDiff() {
const input = document.getElementById('diffFile');
const result = document.getElementById('diffResult');
const file = input ? input.value.trim() : '';
if (!file) { showToast('Enter a file path', 'warning'); return; }
result.innerHTML = '<div class="loading" style="padding:16px"><div class="loading-spinner"></div></div>';
try {
const data = await api.getDiff(file);
if (!data.changed) {
result.innerHTML = `<div class="empty-state" style="padding:16px"><div class="empty-state-icon">✓</div><div class="empty-state-title">No changes</div><div class="empty-state-desc">${escapeHtml(file)} matches HEAD (or no uncommitted diff).</div></div>`;
return;
}
result.innerHTML = `<pre style="max-height:400px;overflow:auto;font-size:12px;white-space:pre-wrap;background:var(--bg-code,#1a1a2e);border-radius:8px;padding:12px;line-height:1.5">${escapeHtml(data.diff)}</pre>`;
} catch (err) {
result.innerHTML = `<div class="empty-state" style="padding:16px"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Error</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
}
}

View File

@ -22,7 +22,7 @@ async function renderSmartRouter() {
<option value="auto">🤖 Auto (AI suggests)</option>
<option value="opencode">🔧 opencode (Code/DevOps)</option>
<option value="hermes"> Hermes (Memory/Scheduling)</option>
<option value="agy">🧠 agy (Research/Analysis)</option>
<option value="gemini">🧠 Gemini CLI (Research/Analysis)</option>
</select>
</div>
<button class="btn btn-primary" onclick="suggestRouter()" style="margin-bottom:16px">🤖 Suggest Agent</button>
@ -36,7 +36,7 @@ async function renderSmartRouter() {
<tr><th>Agent</th><th>Best For</th><th>Keywords</th></tr>
<tr><td><strong>🔧 opencode</strong></td><td>Code, DevOps, infra, git, file operations</td><td class="text-muted text-sm">code, deploy, git, terraform, docker, test, build, script</td></tr>
<tr><td><strong> Hermes</strong></td><td>Memory, scheduling, messaging, skills</td><td class="text-muted text-sm">memory, schedule, cron, reminder, brain, plugin, backup</td></tr>
<tr><td><strong>🧠 agy</strong></td><td>Research, analysis, study, document, review</td><td class="text-muted text-sm">research, analyze, search, explain, study, learn, report</td></tr>
<tr><td><strong>🧠 Gemini CLI</strong></td><td>Research, analysis, study, document, review</td><td class="text-muted text-sm">research, analyze, search, explain, study, learn, report</td></tr>
</table>
</div>
`;
@ -50,7 +50,7 @@ async function suggestRouter() {
try {
const data = await api.suggestRouter(task);
const result = document.getElementById('routerResult');
const agentIcons = { opencode: '🔧', hermes: '⚡', agy: '🧠' };
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
const confidenceColors = { high: 'var(--green)', medium: 'var(--yellow)', low: 'var(--text-muted)' };
result.innerHTML = `
<div class="card" style="border-color:${confidenceColors[data.confidence] || 'var(--border)'};margin-bottom:12px">

View File

@ -27,8 +27,8 @@ async function renderStandards() {
html += '<div class="empty-state"><div class="empty-state-icon">📐</div><div class="empty-state-title">No standards defined</div><div class="empty-state-desc">Run "Discover Patterns" to extract conventions from your codebase</div></div>';
} else {
html += `<div class="grid grid-2">${standards.map(s => `
<div class="card" style="cursor:pointer" onclick="viewStandard('${encodeURIComponent(s.name)}')">
<div class="card-header"><span class="card-title">${escapeHtml(s.name.replace(/-/g, ' '))}</span></div>
<div class="card" style="cursor:pointer" onclick="viewStandard('${s.name}')">
<div class="card-header"><span class="card-title">${s.name.replace(/-/g, ' ')}</span></div>
<pre style="max-height:200px;overflow:hidden;font-size:12px">${escapeHtml(s.content.slice(0, 300))}${s.content.length > 300 ? '...' : ''}</pre>
</div>
`).join('')}</div>`;
@ -40,8 +40,7 @@ async function renderStandards() {
}
}
async function viewStandard(encodedName) {
const name = decodeURIComponent(encodedName);
async function viewStandard(name) {
let content = '';
try {
const data = await api.getStandards();
@ -49,7 +48,7 @@ async function viewStandard(encodedName) {
if (std) content = std.content;
} catch {}
showModal(`Standard: ${escapeHtml(name.replace(/-/g, ' '))}`, `
showModal(`Standard: ${name.replace(/-/g, ' ')}`, `
<pre style="white-space:pre-wrap;font-size:12px;max-height:60vh;overflow:auto">${escapeHtml(content)}</pre>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Close</button>

View File

@ -214,7 +214,6 @@ body {
padding: 12px 28px; border-bottom: 1px solid var(--border);
min-height: 56px; background: var(--bg-secondary);
flex-shrink: 0;
width: 100%; max-width: 1600px; margin: 0 auto;
}
.topbar-left { display: flex; align-items: center; gap: 12px; }
.topbar-title { font-size: 16px; font-weight: 600; }
@ -238,7 +237,6 @@ body {
flex: 1; display: flex; flex-direction: column;
overflow-y: auto; padding: 24px 28px;
min-height: 0;
width: 100%; max-width: 1600px; margin: 0 auto;
}
/* ─── Page Header ─── */
@ -545,7 +543,7 @@ pre {
/* ─── Tabs ─── */
.tabs {
display: flex; gap: 2px; border-bottom: 1px solid var(--border);
margin-bottom: 16px; overflow-x: auto; flex-shrink: 0;
margin-bottom: 16px; overflow-x: auto;
}
.tab {
padding: 10px 16px; font-size: 13px; font-weight: 500;
@ -1354,77 +1352,6 @@ 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

@ -123,8 +123,6 @@ const PAGE_TITLES = {
settings: { title: 'Settings', breadcrumb: 'Configuration' },
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
history: { title: 'Chat History', breadcrumb: 'Browse & replay conversations' },
errors: { title: 'Error Dashboard', breadcrumb: 'System errors & circuit breaker' },
kanban: { title: 'Kanban Board', breadcrumb: 'Multi-agent task management' },
goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },

View File

@ -12,7 +12,7 @@
},
{
"pattern": "research|analyze|search|summarize|compare|investigate|learn",
"target": "agy",
"target": "gemini",
"priority": 10
},
{
@ -25,7 +25,7 @@
"agent_capabilities": {
"opencode": ["code_generation", "file_operations", "git_management", "terminal_execution", "infrastructure_as_code", "testing", "debugging"],
"hermes": ["persistent_memory", "scheduled_tasks", "messaging_channels", "skill_hub", "voice", "browser_automation", "subagent_delegation"],
"agy": ["web_search", "multi_modal_analysis", "document_understanding", "data_analysis", "research_synthesis", "reasoning"]
"gemini": ["web_search", "multi_modal_analysis", "document_understanding", "data_analysis", "research_synthesis", "reasoning"]
},
"handoff_protocol": {
"enabled": true,

12
data/kanban/0f822987.json Normal file
View File

@ -0,0 +1,12 @@
{
"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

@ -199,12 +199,12 @@
<circle cx="362" cy="823" r="2.5" fill="#c0fff0"/>
<text x="372" y="827" fill="#c0fff0" font-size="10">Cron scheduling &amp; Telegram/Discord</text>
<!-- Agent 3: agy CLI -->
<!-- Agent 3: Gemini CLI -->
<rect x="589" y="700" width="225" height="135" rx="10" fill="url(#geminiGrad)" filter="url(#agentShadow)"/>
<rect x="589" y="700" width="225" height="4" rx="2" fill="#faa0c0"/>
<circle cx="701" cy="732" r="18" fill="#ffffff" fill-opacity="0.15"/>
<text x="701" y="738" text-anchor="middle" fill="#ffffff" font-size="16">🔍</text>
<text x="701" y="764" text-anchor="middle" fill="#ffffff" font-size="16" font-weight="700">agy CLI</text>
<text x="701" y="764" text-anchor="middle" fill="#ffffff" font-size="16" font-weight="700">Gemini CLI</text>
<text x="701" y="782" text-anchor="middle" fill="#ffe0ee" font-size="11">Research &amp; Analysis Agent</text>
<line x1="614" y1="793" x2="789" y2="793" stroke="#ffffff" stroke-opacity="0.1" stroke-width="1"/>
<circle cx="614" cy="808" r="2.5" fill="#ffe0ee"/>
@ -217,7 +217,7 @@
<text x="450" y="889" text-anchor="middle" fill="#636e8a" font-size="10">138 files · 16 skills · 7 brain files · 13 dashboard pages · MIT</text>
<rect x="200" y="905" width="500" height="24" rx="12" fill="#1a1a2e" stroke="#2d2d4a" stroke-width="0.5"/>
<text x="450" y="921" text-anchor="middle" fill="#fdcb6e" font-size="10">100% Free Tier — OpenRouter · Antigravity (agy) · Local opencode</text>
<text x="450" y="921" text-anchor="middle" fill="#fdcb6e" font-size="10">100% Free Tier — OpenRouter · Gemini Flash · Local opencode</text>
<text x="450" y="960" text-anchor="middle" fill="#3d3d5a" font-size="9">github.com/modimihir07/agentic-os</text>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -5,22 +5,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary SEO -->
<title>Agentic OS (agentic-os) — Multi-Agent Dashboard &amp; GitHub Repository | opencode + Hermes + agy CLI</title>
<meta name="description" content="Agentic OS (agentic-os) is a locally-hosted multi-agent orchestration OS and GitHub repository that coordinates opencode, Hermes Agent, and agy CLI into one dashboard with 15 skills, cron scheduler, cost analytics, persistent memory, and backup/restore.">
<meta name="keywords" content="agentic-os, agentic os, agent os, multi-agent orchestration, ai agents, hermes agent, opencode, agy cli, agent dashboard, skills hub, cost analytics, devops automation, open source">
<title>Agentic OS (agentic-os) — Multi-Agent Dashboard &amp; GitHub Repository | opencode + Hermes + Gemini CLI</title>
<meta name="description" content="Agentic OS (agentic-os) is a locally-hosted multi-agent orchestration OS and GitHub repository that coordinates opencode, Hermes Agent, and Gemini CLI into one dashboard with 16+ skills, cron scheduler, cost analytics, persistent memory, and backup/restore.">
<meta name="keywords" content="agentic-os, agentic os, agent os, multi-agent orchestration, ai agents, hermes agent, opencode, gemini cli, agent dashboard, skills hub, cost analytics, devops automation, open source">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://modimihir07.github.io/agentic-os/">
<!-- Open Graph -->
<meta property="og:title" content="Agentic OS (agentic-os) — Multi-Agent Orchestration Dashboard &amp; GitHub Repository">
<meta property="og:description" content="Agentic OS (agentic-os) is a locally-hosted multi-agent OS and GitHub repository — coordinates opencode, Hermes, and agy CLI into one dashboard with skills, scheduler, cost analytics, memory, and backup.">
<meta property="og:description" content="Agentic OS (agentic-os) is a locally-hosted multi-agent OS and GitHub repository — coordinates opencode, Hermes, and Gemini CLI into one dashboard with skills, scheduler, cost analytics, memory, and backup.">
<meta property="og:url" content="https://modimihir07.github.io/agentic-os/">
<meta property="og:type" content="website">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Agentic OS (agentic-os) — Multi-Agent Orchestration Dashboard &amp; GitHub Repository">
<meta name="twitter:description" content="Agentic OS (agentic-os) is a locally-hosted multi-agent OS and GitHub repository — coordinates opencode, Hermes, and agy CLI into one dashboard with skills, scheduler, cost analytics, memory, and backup.">
<meta name="twitter:description" content="Agentic OS (agentic-os) is a locally-hosted multi-agent OS and GitHub repository — coordinates opencode, Hermes, and Gemini CLI into one dashboard with skills, scheduler, cost analytics, memory, and backup.">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
@ -31,7 +31,7 @@
"alternateName": "agentic-os",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Linux, macOS",
"description": "A locally-hosted multi-agent orchestration platform that coordinates opencode, Hermes Agent, and agy CLI into a unified dashboard with 15 skills, cron scheduling, cost analytics, persistent memory, and backup/restore.",
"description": "A locally-hosted multi-agent orchestration platform that coordinates opencode, Hermes Agent, and Gemini CLI into a unified dashboard with 16+ skills, cron scheduling, cost analytics, persistent memory, and backup/restore.",
"url": "https://github.com/modimihir07/agentic-os",
"author": {
"@type": "Person",
@ -130,12 +130,12 @@ td { color: var(--text); }
<circle cx="18" cy="18" r="4" fill="url(#lg)"/>
</svg>
<h1>Agentic OS (agentic-os)</h1>
<p>A locally-hosted operating system for AI agents — an open-source GitHub repository orchestrating opencode, Hermes Agent, and agy CLI into one unified dashboard with persistent memory, cron scheduling, 15 skills, and cost analytics.</p>
<p>A locally-hosted operating system for AI agents — an open-source GitHub repository orchestrating opencode, Hermes Agent, and Gemini CLI into one unified dashboard with persistent memory, cron scheduling, 16+ skills, and cost analytics.</p>
<div class="hero-badges">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="Python">
<img src="https://img.shields.io/badge/agents-3-orange.svg" alt="3 Agents">
<img src="https://img.shields.io/badge/skills-15-purple.svg" alt="15 Skills">
<img src="https://img.shields.io/badge/skills-16-purple.svg" alt="16 Skills">
<img src="https://img.shields.io/badge/status-stable-brightgreen.svg" alt="Stable">
<a href="https://dev.to/mihir_nmodi_14a06a4019e1/i-built-an-open-source-agent-os-2h30"><img src="https://img.shields.io/badge/dev.to-article-blue.svg" alt="dev.to"></a>
</div>
@ -152,8 +152,8 @@ td { color: var(--text); }
<h2>Why <span>Agentic OS</span>?</h2>
<p>Most AI agent tools work in isolation — a terminal for coding, a chat for research, another tool for memory. Agentic OS is the control plane that unifies them: three agents, one dashboard, one memory layer, one scheduler, one skill hub.</p>
<div class="feature-grid">
<div class="feature-card"><div class="icon">🤖</div><h3>3-Agent Engine</h3><p>opencode (code/DevOps), Hermes (memory/scheduling), agy (research) with automatic task routing.</p></div>
<div class="feature-card"><div class="icon">🧩</div><h3>15 Skills</h3><p>Executable packs with eval scoring, learnings, and score history. Run from one click.</p></div>
<div class="feature-card"><div class="icon">🤖</div><h3>3-Agent Engine</h3><p>opencode (code/DevOps), Hermes (memory/scheduling), Gemini (research) with automatic task routing.</p></div>
<div class="feature-card"><div class="icon">🧩</div><h3>16+ Skills</h3><p>Executable packs with eval scoring, learnings, and score history. Run from one click.</p></div>
<div class="feature-card"><div class="icon">🧠</div><h3>Persistent Memory</h3><p>Shared brain/ folder + Hermes SQLite FTS5 — all agents share context across sessions.</p></div>
<div class="feature-card"><div class="icon"></div><h3>Cron Scheduler</h3><p>APScheduler jobs: heartbeat, standup, DevOps audit, memory consolidation. Fully configurable.</p></div>
<div class="feature-card"><div class="icon">💰</div><h3>Cost Analytics</h3><p>Track spending across providers with free-tier alerts. No surprise bills.</p></div>
@ -161,7 +161,7 @@ td { color: var(--text); }
<div class="feature-card"><div class="icon">📋</div><h3>Audit Trail</h3><p>Every action logged — chat, skill runs, config changes, backups. Searchable event history.</p></div>
<div class="feature-card"><div class="icon">📝</div><h3>Prompt Library</h3><p>10 reusable templates: code review, system audit, project plan, brainstorm, and more.</p></div>
<div class="feature-card"><div class="icon">🎨</div><h3>Dark/Light Theme</h3><p>GitHub-style dark mode with clean light theme. Collapsible sidebar with agent status.</p></div>
<div class="feature-card"><div class="icon"></div><h3>Zero API Costs</h3><p>Built for free tiers — Antigravity (agy), OpenRouter free models, local opencode. No subscription needed.</p></div>
<div class="feature-card"><div class="icon"></div><h3>Zero API Costs</h3><p>Built for free tiers — Gemini Flash, OpenRouter free models, local opencode. No subscription needed.</p></div>
<div class="feature-card"><div class="icon">📐</div><h3>Standards System</h3><p>Discover and inject project conventions automatically.</p></div>
<div class="feature-card"><div class="icon">🔌</div><h3>Plugin Registry</h3><p>Marketplace-style plugin management. Extend via custom skills.</p></div>
</div>
@ -179,7 +179,7 @@ td { color: var(--text); }
├──────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ opencode │ │ Hermes │ │ agy │ │
│ │ opencode │ │ Hermes │ │ Gemini │ │
│ │Code/DevOp│ │Mem/Sched │ │Research │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
@ -204,7 +204,7 @@ td { color: var(--text); }
<p>An honest comparison of the two projects.</p>
<table>
<tr><th>Feature</th><th>Claude Agent OS (Video)</th><th>Agentic OS (This Project)</th></tr>
<tr><td>Core Agents</td><td>Claude + OpenClaw + Hermes</td><td>opencode + Hermes + agy CLI</td></tr>
<tr><td>Core Agents</td><td>Claude + OpenClaw + Hermes</td><td>opencode + Hermes + Gemini CLI</td></tr>
<tr><td>Cost</td><td>$20/mo (Claude sub)</td><td class="win">$0 — all free tiers</td></tr>
<tr><td>Stack</td><td>Next.js + Tailwind</td><td>FastAPI + vanilla JS SPA</td></tr>
<tr><td>Architecture Layers</td><td>4 layers</td><td class="win">7 layers</td></tr>

View File

@ -17,41 +17,19 @@ echo "Detected OS: $OS"
if command -v python3 &>/dev/null; then
echo "Python: $(python3 --version)"
else
echo "ERROR: Python 3.10+ required. Install via: sudo apt install python3"
echo "ERROR: Python 3.10+ required. Install via: sudo apt install python3 python3-pip"
exit 1
fi
# Install uv into bin/ if not present
BIN_DIR="bin"
UV="$BIN_DIR/uv"
if [ -x "$UV" ]; then
echo "uv: $($UV --version)"
else
echo "Installing uv to $BIN_DIR/..."
mkdir -p "$BIN_DIR"
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="$PWD/$BIN_DIR" sh
if [ ! -x "$UV" ]; then
echo "ERROR: uv installation failed. Install manually: curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
echo "uv: $($UV --version)"
# Remove shell helper scripts — we reference bin/uv directly
rm -f "$BIN_DIR/env" "$BIN_DIR/env.fish"
# Check pip
if ! command -v pip3 &>/dev/null; then
echo "Installing pip..."
python3 -m ensurepip --upgrade
fi
# Create virtual environment
VENV_DIR=".venv"
if [ -d "$VENV_DIR" ]; then
echo "Virtual environment already exists at $VENV_DIR/"
else
echo "Creating virtual environment..."
"$UV" venv "$VENV_DIR" --python 3.12
echo "Created $VENV_DIR/"
fi
# Install Python deps into venv
# Install Python deps
echo "Installing Python dependencies..."
"$UV" pip install --python "$VENV_DIR/bin/python3" -r requirements.txt --quiet
pip3 install -r requirements.txt --quiet
# Check Node.js (for opencode)
if command -v node &>/dev/null; then
@ -75,11 +53,11 @@ else
echo "WARNING: Hermes Agent not found. Install via: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
fi
# Check agy (Antigravity CLI)
if command -v agy &>/dev/null; then
echo "agy (Antigravity): found"
# Check Gemini CLI
if command -v gemini &>/dev/null; then
echo "Gemini CLI: found"
else
echo "WARNING: agy CLI not found. Install via: curl -fsSL https://antigravity.ai/install | bash"
echo "WARNING: Gemini CLI not found. Install via: npm install -g @google/gemini-cli"
fi
# Create required directories

View File

@ -43,7 +43,7 @@
{
"name": "research-synthesis",
"version": "1.0.0",
"description": "Web research and synthesis via agy CLI",
"description": "Web research and synthesis via Gemini CLI",
"author": "Agentic OS",
"installed": "2026-05-17T00:00:00Z",
"type": "built-in"

View File

@ -1,213 +1,60 @@
#!/usr/bin/env python3
"""Agentic OS — Event-Driven Scheduler Engine
File watcher + cron-based scheduler with execution history.
Handles job reloading, webhook triggers, skill execution events.
"""
"""Agentic OS — APScheduler engine for recurring tasks"""
import json
import os
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Callable
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)
BASE_DIR = Path(__file__).parent.resolve()
JOBS_DIR = BASE_DIR / "jobs"
HISTORY_FILE = BASE_DIR.parent / "data" / "scheduler-history.json"
_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."""
def run_skill(skill_name: str):
"""Execute a skill by invoking the appropriate agent."""
audit_file = BASE_DIR.parent / "audit" / "audit.log"
timestamp = datetime.now(timezone.utc).isoformat()
entry = {
"action": "scheduler_run",
"skill": skill_name,
"trigger": trigger,
"timestamp": timestamp,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
with open(audit_file, "a") as f:
f.write(json.dumps(entry) + "\n")
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}
print(f"[{datetime.now().isoformat()}] Ran skill: {skill_name}")
# ─── 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("APScheduler not found — auto-installing...")
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "apscheduler", "--quiet"]
)
from apscheduler.schedulers.background import BackgroundScheduler as BS
from apscheduler.triggers.cron import CronTrigger as CT
except Exception as e:
print(f"Auto-install failed ({e}). Run: 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 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']})")
def main():
scheduler = CronScheduler()
scheduler = BackgroundScheduler()
load_jobs(scheduler)
scheduler.start()
print(f"Agentic OS Scheduler running. Jobs loaded from: {JOBS_DIR}")
try:
while True:
import time
time.sleep(60)
except KeyboardInterrupt:
scheduler.stop()
scheduler.shutdown()
print("Scheduler stopped.")
if __name__ == "__main__":

630
server.py
View File

@ -1,12 +1,11 @@
#!/usr/bin/env python3
"""
Agentic OS FastAPI Backend
Multi-agent orchestration server for opencode, Hermes, agy CLI
Multi-agent orchestration server for opencode, Hermes, Gemini CLI
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import tarfile
@ -16,34 +15,13 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
_scheduler_instance = None
@asynccontextmanager
async def lifespan(app: FastAPI):
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}")
yield
if _scheduler_instance:
try:
_scheduler_instance.stop()
except Exception:
pass
app = FastAPI(title="Agentic OS", version="0.4.0", lifespan=lifespan)
app = FastAPI(title="Agentic OS", version="1.1.0")
# Load OpenRouter API key from Hermes .env
HERMES_ENV = Path.home() / ".hermes" / ".env"
@ -64,22 +42,6 @@ app.add_middleware(
allow_headers=["*"],
)
# No-cache for dashboard assets — SPA JS is loaded on demand and updated
# frequently during development (v0.4.0). Prevents stale-cached pages.
from starlette.middleware.base import BaseHTTPMiddleware
class NoCacheMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
path = request.url.path
if path.startswith("/dashboard") or path in ("/", "/index.html"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app.add_middleware(NoCacheMiddleware)
BASE_DIR = Path(__file__).parent.resolve()
# ─── Models ───────────────────────────────────────────────────────
@ -121,7 +83,7 @@ def write_file(path: Path, content: str):
def list_dir(path: Path):
if not path.exists():
return []
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".") and p.is_file()])
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".")])
def get_timestamp():
return datetime.now(timezone.utc).isoformat()
@ -133,68 +95,6 @@ def append_audit(entry: dict):
with open(audit_file, "a") as f:
f.write(json.dumps(entry) + "\n")
def safe_resolve(base: Path, user_path: str) -> Path:
"""Resolve a user-supplied path relative to base, preventing traversal."""
resolved = (base / user_path).resolve()
if not str(resolved).startswith(str(base.resolve())):
raise HTTPException(400, "Invalid path")
return resolved
def safe_extractall(tar: tarfile.TarFile, path: Path):
"""Extract tar archive with path traversal protection."""
for member in tar.getmembers():
member_path = (path / member.name).resolve()
if not str(member_path).startswith(str(path.resolve())):
raise HTTPException(400, f"Blocked path traversal: {member.name}")
tar.extractall(path=path)
def validate_identifier(value: str, pattern: str, label: str = "name") -> str:
"""Reject path separators / traversal before using a value in a filesystem path."""
if not value or not re.fullmatch(pattern, value):
raise HTTPException(400, f"Invalid {label}")
return value
# ─── Security Headers Middleware ─────────────────────────────────
class SecurityHeadersMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def send_with_headers(message):
if message["type"] == "http.response.start":
headers = message.get("headers", [])
extra = [
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
(b"x-xss-protection", b"1; mode=block"),
(b"strict-transport-security", b"max-age=31536000; includeSubDomains"),
(b"referrer-policy", b"strict-origin-when-cross-origin"),
]
# Only add CSP for non-API routes (dashboard HTML)
path = scope.get("path", "")
if not path.startswith("/api/"):
csp = (
b"default-src 'self'; "
b"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
b"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; "
b"font-src 'self' https://fonts.gstatic.com; "
b"img-src 'self' data:; "
b"connect-src 'self' http://127.0.0.1:* http://localhost:*; "
b"frame-ancestors 'none'"
)
extra.append((b"content-security-policy", csp))
message["headers"] = list(headers) + extra
await send(message)
await self.app(scope, receive, send_with_headers)
app.add_middleware(SecurityHeadersMiddleware)
# ─── Agent Discovery (instant filesystem checks) ────────────────────
def check_agent(name: str) -> dict:
@ -206,9 +106,12 @@ def check_agent(name: str) -> dict:
elif name == "hermes":
exists = shutil.which("hermes") is not None
status = "online" if exists else "offline"
elif name == "agy":
exists = shutil.which("agy") is not None
status = "online" if exists else "offline"
elif name == "gemini":
# Gemini has valid OAuth tokens logged in
oauth = Path.home() / ".gemini" / "oauth_creds.json"
exists = shutil.which("gemini") is not None
logged_in = oauth.exists() and "ya29" in oauth.read_text()
status = "online" if exists and logged_in else "offline" if not exists else "warning"
else:
status = "offline"
except Exception:
@ -219,10 +122,8 @@ def check_agent(name: str) -> dict:
@app.get("/api/status")
def get_status():
agents = [check_agent(a) for a in ["opencode", "hermes", "agy"]]
skills_dir = BASE_DIR / "skills"
skills = [d.name for d in skills_dir.iterdir()
if d.is_dir() and not d.name.startswith("_")] if skills_dir.exists() else []
agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]]
skills = list_dir(BASE_DIR / "skills")
return {
"status": "healthy",
"agents": agents,
@ -234,20 +135,15 @@ def get_status():
@app.get("/api/brain")
def list_brain():
brain_dir = BASE_DIR / "brain"
if not brain_dir.exists():
return {}
files = sorted([p.name for p in brain_dir.iterdir() if p.name.endswith(".md") and p.is_file()])
files = list_dir(BASE_DIR / "brain")
brain_data = {}
for f in files:
path = brain_dir / f
path = BASE_DIR / "brain" / f
brain_data[f] = read_file(path)
return brain_data
@app.get("/api/brain/{file_name}")
def get_brain_file(file_name: str):
if ".." in file_name or "/" in file_name:
raise HTTPException(400, "Invalid file name")
path = BASE_DIR / "brain" / file_name
if not path.exists() or path.is_dir():
raise HTTPException(404, "File not found")
@ -255,8 +151,6 @@ def get_brain_file(file_name: str):
@app.put("/api/brain/{file_name}")
def update_brain_file(file_name: str, data: BrainUpdate):
if ".." in file_name or "/" in file_name:
raise HTTPException(400, "Invalid file name")
path = BASE_DIR / "brain" / file_name
write_file(path, data.content)
append_audit({"action": "brain_update", "file": file_name})
@ -290,7 +184,6 @@ def list_skills():
@app.get("/api/skills/{name}")
def get_skill(name: str):
validate_identifier(name, r"^[a-zA-Z0-9_-]+$", "skill name")
path = BASE_DIR / "skills" / name
if not path.exists():
raise HTTPException(404, "Skill not found")
@ -305,7 +198,6 @@ def get_skill(name: str):
@app.post("/api/skills/{name}/run")
def run_skill(name: str, req: Optional[SkillRunRequest] = None):
validate_identifier(name, r"^[a-zA-Z0-9_-]+$", "skill name")
path = BASE_DIR / "skills" / name
if not path.exists():
raise HTTPException(404, "Skill not found")
@ -324,14 +216,14 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
if any(k in name for k in devops_keywords):
agent_choice = "opencode"
elif any(k in name for k in research_keywords):
agent_choice = "agy"
agent_choice = "gemini"
else:
# Check SKILL.md for explicit agent assignment
for line in skill_md.split('\n'):
line = line.strip()
if "Primary:" in line:
candidate = line.split(":")[-1].strip().lower()
if candidate in ("opencode", "hermes", "agy"):
if candidate in ("opencode", "hermes", "gemini"):
agent_choice = candidate
break
if agent_choice == "auto":
@ -389,7 +281,6 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
@app.get("/api/skills/{name}/eval")
def get_skill_eval(name: str):
validate_identifier(name, r"^[a-zA-Z0-9_-]+$", "skill name")
path = BASE_DIR / "skills" / name / "score-history.json"
if not path.exists():
return {"scores": []}
@ -409,7 +300,6 @@ def list_jobs():
def create_job(job: ScheduleJobRequest):
jobs_dir = BASE_DIR / "scheduler" / "jobs"
jobs_dir.mkdir(parents=True, exist_ok=True)
validate_identifier(job.name, r"^[a-zA-Z0-9 _-]+$", "job name")
job_data = {
"id": str(uuid.uuid4())[:8],
"name": job.name,
@ -529,12 +419,11 @@ def create_backup():
@app.post("/api/backup/restore")
def restore_backup(data: BackupRestoreRequest):
validate_identifier(data.file, r"^agentic-os-\d{8}_\d{6}\.tar\.gz$", "backup file")
backup_file = BASE_DIR / "backups" / data.file
if not backup_file.exists():
raise HTTPException(404, "Backup file not found")
with tarfile.open(backup_file, "r:gz") as tar:
safe_extractall(tar, BASE_DIR)
tar.extractall(path=BASE_DIR)
append_audit({"action": "backup_restored", "file": data.file})
return {"status": "restored"}
@ -555,11 +444,7 @@ def get_settings():
sf = BASE_DIR / "data" / "settings.json"
if not sf.exists():
return {}
data = json.loads(sf.read_text())
# Mask sensitive values
if "api_keys" in data:
data["api_keys"] = {k: v[:4] + "****" if len(v) > 8 else "****" for k, v in data["api_keys"].items()}
return data
return json.loads(sf.read_text())
@app.put("/api/settings")
def update_settings(data: SettingsUpdate):
@ -571,211 +456,6 @@ 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.get("/api/memory/graph")
def memory_graph():
"""Knowledge graph of memory files, skills, and extracted entities (v0.4.0)."""
try:
from brain.memory_search import build_graph
graph = build_graph()
append_audit({"action": "memory_graph_viewed", "nodes": graph["stats"]["nodes"]})
return graph
except Exception as e:
return {"nodes": [], "edges": [], "stats": {"nodes": 0, "edges": 0}, "error": str(e)}
@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", "agy"]:
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", "agy"]:
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")
@ -804,19 +484,13 @@ def discover_standards():
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
def load_chat_history():
if not CHAT_HISTORY_FILE.exists():
return {"messages": []}
try:
data = json.loads(CHAT_HISTORY_FILE.read_text())
if isinstance(data, dict) and "messages" in data:
return data
except (json.JSONDecodeError, TypeError):
pass
if CHAT_HISTORY_FILE.exists():
return json.loads(CHAT_HISTORY_FILE.read_text())
return {"messages": []}
def save_chat_message(msg: dict):
history = load_chat_history()
history.setdefault("messages", []).append(msg)
history["messages"].append(msg)
if len(history["messages"]) > 200:
history["messages"] = history["messages"][-200:]
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
@ -893,17 +567,26 @@ def execute_agent(agent: str, message: str) -> str:
return f"**Hermes needs setup**\n\nRun `hermes setup` or check your config.\n\n**Details:** {err_msg[:200]}"
return err_msg or f"hermes returned exit code {code}"
elif agent == "agy":
try:
code, out, err = run_cli(["agy", "--print", message], timeout=60)
except subprocess.TimeoutExpired:
return f"**agy timed out.**\n\nTry running `agy --print \"{message[:60]}\"` directly."
combined = ((err or "") + " " + (out or "")).strip()
if code == 0:
return (out or "").strip() or f"**agy**\n\nProcessed your query."
if "auth" in combined.lower() or "login" in combined.lower() or "api key" in combined.lower():
return f"**agy needs auth**\n\nRun `agy login` to authenticate.\n\n**Details:** {combined[:200]}"
return combined or f"agy returned exit code {code}"
elif agent == "gemini":
for attempt, (args, to) in enumerate([
(["-y", "-m", "gemini-2.5-flash"], 60),
(["-y"], 40),
]):
try:
code, out, err = run_cli(["gemini", *args, message], timeout=to)
except subprocess.TimeoutExpired:
if attempt == 0:
continue
return f"⏱ Gemini timed out.\n\nTry running `gemini \"{message[:60]}\"` directly.\n\n**Message:** {message[:100]}"
if code == 0:
return (out or "").strip() or f"**Gemini CLI**\n\nProcessed your query.\n\n**Message:** {message}"
err_msg = (err or "").strip()
if attempt == 0 and ("model" in err_msg.lower() or "not found" in err_msg.lower()):
continue
if "auth" in err_msg.lower() or "login" in err_msg.lower():
return f"**Gemini needs re-auth**\n\nRun `gemini auth login` to re-authenticate.\n\n**Details:** {err_msg[:200]}"
return err_msg or f"gemini returned exit code {code}"
return "Gemini CLI did not return a response."
else:
return f"Unknown agent: {agent}"
@ -917,24 +600,19 @@ def execute_agent(agent: str, message: str) -> str:
@app.post("/api/chat")
def chat(req: ChatRequest):
agent = req.agent.lower().strip()
if agent not in ["opencode", "hermes", "agy"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, agy")
message = (req.message or "").strip()
if not message:
raise HTTPException(400, "Message cannot be empty")
if len(message) > 10000:
raise HTTPException(400, "Message too long (max 10000 characters)")
if agent not in ["opencode", "hermes", "gemini"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
user_msg = {
"id": str(uuid.uuid4())[:8],
"role": "user",
"agent": agent,
"content": message,
"content": req.message,
"timestamp": get_timestamp(),
}
save_chat_message(user_msg)
response_text = execute_agent(agent, message)
response_text = execute_agent(agent, req.message)
agent_msg = {
"id": str(uuid.uuid4())[:8],
@ -945,104 +623,13 @@ def chat(req: ChatRequest):
}
save_chat_message(agent_msg)
append_audit({"action": "chat_message", "agent": agent, "msg_preview": message[:50]})
append_audit({"action": "chat_message", "agent": agent, "msg_preview": req.message[:50]})
return {"status": "ok", "response": agent_msg}
@app.get("/api/chat/history")
def get_chat_history(q: str = Query(""), agent: str = Query(""), limit: int = Query(200, le=1000)):
"""Chat history with optional search/filter (v0.4.0)."""
history = load_chat_history()
messages = history.get("messages", [])
if q:
ql = q.lower()
messages = [m for m in messages if ql in m.get("content", "").lower()]
if agent:
messages = [m for m in messages if m.get("agent") == agent]
if limit:
messages = messages[-limit:]
return {"messages": messages, "total": len(messages), "query": q, "agent": agent}
# ─── Routes: Chat File Attachments (v0.4.0) ─────────────────────────
UPLOAD_DIR = BASE_DIR / "data" / "uploads"
UPLOAD_MAX_BYTES = 2 * 1024 * 1024 # 2 MB
UPLOAD_TTL_HOURS = 24
ALLOWED_UPLOAD_EXTENSIONS = {
".txt", ".md", ".log", ".json", ".yml", ".yaml", ".csv", ".py",
".js", ".ts", ".sh", ".toml", ".ini", ".env", ".cfg", ".xml",
".html", ".css", ".go", ".rs", ".sql", ".tsx", ".jsx",
}
def _cleanup_uploads(force: bool = False):
"""Delete upload files older than the TTL (24h)."""
if not UPLOAD_DIR.exists():
return
now = time.time()
for f in UPLOAD_DIR.glob("*"):
try:
if force or now - f.stat().st_mtime > UPLOAD_TTL_HOURS * 3600:
f.unlink(missing_ok=True)
except OSError:
pass
@app.post("/api/chat/upload")
async def chat_upload(
agent: str = Form(...),
message: str = Form(""),
file: UploadFile = File(...),
):
"""Chat with an optional file attachment (multipart/form-data, v0.4.0)."""
agent = agent.lower().strip()
if agent not in ["opencode", "hermes", "agy"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, agy")
raw = await file.read(UPLOAD_MAX_BYTES + 1)
if len(raw) > UPLOAD_MAX_BYTES:
raise HTTPException(413, "File too large (max 2 MB)")
filename = (file.filename or "attachment.txt").strip().replace("\\", "/").split("/")[-1]
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(400, "Invalid file name")
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_UPLOAD_EXTENSIONS:
raise HTTPException(400, f"File type .{ext} not allowed")
_cleanup_uploads()
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
safe_name = f"{uuid.uuid4().hex[:8]}_{filename}"
upload_path = UPLOAD_DIR / safe_name
upload_path.write_bytes(raw)
append_audit({"action": "chat_upload", "file": filename, "size": len(raw)})
# Prepend file content to the message so the agent can read it
try:
text = raw.decode("utf-8", errors="replace")[:50000]
except Exception:
text = "[binary file — content not readable as text]"
attachment_block = f"--- File: {filename} ---\n{text}\n--- End {filename} ---"
message = (message or "").strip()
full_message = f"{attachment_block}\n\n{message}" if message else attachment_block
# Reuse the standard chat flow
user_msg = {
"id": str(uuid.uuid4())[:8],
"role": "user",
"agent": agent,
"content": full_message,
"timestamp": get_timestamp(),
}
save_chat_message(user_msg)
response_text = execute_agent(agent, full_message)
agent_msg = {
"id": str(uuid.uuid4())[:8],
"role": "assistant",
"agent": agent,
"content": response_text,
"timestamp": get_timestamp(),
}
save_chat_message(agent_msg)
append_audit({"action": "chat_message", "agent": agent, "msg_preview": (message or filename)[:50]})
return {"status": "ok", "response": agent_msg, "file": filename, "saved_as": safe_name}
def get_chat_history():
return load_chat_history()
# ═══════════════════════════════════════════════════════════════════
# v0.2.0 — New Feature Endpoints
@ -1119,7 +706,6 @@ def load_kanban_tasks():
def save_kanban_task(task: dict):
ensure_dir(KANBAN_DIR)
validate_identifier(str(task["id"]), r"^[a-zA-Z0-9_-]+$", "task id")
(KANBAN_DIR / f"{task['id']}.json").write_text(json.dumps(task, indent=2))
def load_goals():
@ -1149,7 +735,6 @@ def kanban_board(status: Optional[str] = None):
@app.get("/api/kanban/tasks/{task_id}")
def kanban_get_task(task_id: str):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1178,7 +763,6 @@ def kanban_create_task(data: KanbanTaskCreate):
@app.patch("/api/kanban/tasks/{task_id}")
def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1194,7 +778,6 @@ def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
@app.post("/api/kanban/tasks/{task_id}/complete")
def kanban_complete_task(task_id: str, data: KanbanComplete):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1209,7 +792,6 @@ def kanban_complete_task(task_id: str, data: KanbanComplete):
@app.post("/api/kanban/tasks/{task_id}/block")
def kanban_block_task(task_id: str, data: KanbanBlock):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1223,7 +805,6 @@ def kanban_block_task(task_id: str, data: KanbanBlock):
@app.post("/api/kanban/tasks/{task_id}/unblock")
def kanban_unblock_task(task_id: str):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1237,7 +818,6 @@ def kanban_unblock_task(task_id: str):
@app.post("/api/kanban/tasks/{task_id}/comments")
def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1255,7 +835,6 @@ def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
@app.post("/api/kanban/links")
def kanban_add_link(data: KanbanLinkCreate):
for tid in [data.parent_id, data.child_id]:
validate_identifier(tid, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{tid}.json"
if not path.exists():
raise HTTPException(404, f"Task {tid} not found")
@ -1272,7 +851,6 @@ def kanban_add_link(data: KanbanLinkCreate):
@app.delete("/api/kanban/links")
def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
for tid in [parent_id, child_id]:
validate_identifier(tid, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{tid}.json"
if path.exists():
t = json.loads(path.read_text())
@ -1289,7 +867,6 @@ def kanban_dispatch():
@app.post("/api/kanban/tasks/{task_id}/specify")
def kanban_specify_task(task_id: str):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1302,7 +879,6 @@ def kanban_specify_task(task_id: str):
@app.post("/api/kanban/tasks/{task_id}/decompose")
def kanban_decompose_task(task_id: str):
validate_identifier(task_id, r"^[a-zA-Z0-9_-]+$", "task id")
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
@ -1414,7 +990,6 @@ def list_journal_entries():
@app.get("/api/journal/entries/{entry_date}")
def get_journal_entry(entry_date: str):
validate_identifier(entry_date, r"^\d{4}-\d{2}-\d{2}$", "date")
try:
path = JOURNAL_DIR / f"{entry_date}.md"
ensure_dir(JOURNAL_DIR)
@ -1425,7 +1000,6 @@ def get_journal_entry(entry_date: str):
@app.put("/api/journal/entries/{entry_date}")
def save_journal_entry(entry_date: str, data: JournalSave):
validate_identifier(entry_date, r"^\d{4}-\d{2}-\d{2}$", "date")
try:
ensure_dir(JOURNAL_DIR)
path = JOURNAL_DIR / f"{entry_date}.md"
@ -1456,7 +1030,7 @@ def search_journal(q: str = Query("")):
def get_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "agy"]:
for name in ["opencode", "hermes", "gemini"]:
info = check_agent(name)
info["uptime"] = 0
info["success_rate"] = 100
@ -1469,7 +1043,7 @@ def get_agent_health():
@app.get("/api/agents/{name}/stats")
def get_agent_stats(name: str):
try:
if name not in ["opencode", "hermes", "agy"]:
if name not in ["opencode", "hermes", "gemini"]:
raise HTTPException(400, "Invalid agent")
info = check_agent(name)
return {
@ -1490,7 +1064,7 @@ def get_agent_stats(name: str):
def refresh_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "agy"]:
for name in ["opencode", "hermes", "gemini"]:
info = check_agent(name)
agents.append(info)
append_audit({"action": "agent_health_refreshed"})
@ -1503,7 +1077,7 @@ def refresh_agent_health():
ROUTER_RULES = {
"opencode": ["code", "devops", "deploy", "git", "file", "terraform", "docker", "test", "build", "infra", "script"],
"hermes": ["memory", "schedule", "channel", "skill", "cron", "reminder", "brain", "plugin", "backup"],
"agy": ["research", "analyze", "search", "compare", "explain", "study", "learn", "document", "report", "review"],
"gemini": ["research", "analyze", "search", "compare", "explain", "study", "learn", "document", "report", "review"],
}
@app.post("/api/router/suggest")
@ -1528,7 +1102,7 @@ def router_suggest(data: RouterSuggest):
def router_route(data: RouterRoute):
try:
agent = data.agent.lower()
if agent not in ["opencode", "hermes", "agy"]:
if agent not in ["opencode", "hermes", "gemini"]:
return {"status": "error", "message": f"Invalid agent: {agent}"}
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
return {
@ -1614,12 +1188,8 @@ def list_sessions():
except Exception as e:
return {"sessions": [], "error": str(e)}
MAX_SESSION_CONTENT = 2000
@app.get("/api/sessions/{session_id}/replay")
def get_session_replay(session_id: str):
if ".." in session_id or "/" in session_id:
raise HTTPException(400, "Invalid session ID")
try:
sessions_dir = Path.home() / ".local" / "share" / "opencode"
log_file = sessions_dir / "log" / f"{session_id}.log"
@ -1633,43 +1203,13 @@ def get_session_replay(session_id: str):
return {
"session_id": session_id,
"lines": len(lines),
"messages": messages[:50],
"content": content[:MAX_SESSION_CONTENT],
"messages": messages[:100],
"content": content[:5000],
}
return {"session_id": session_id, "messages": [], "content": "Session log not found"}
except Exception as e:
return {"session_id": session_id, "messages": [], "error": str(e)}
# ─── Routes: Code Diff Viewer (v0.4.0) ─────────────────────────────
DIFF_ALLOWED_PREFIXES = (
"brain/", "skills/", "server.py", "scheduler/", "dashboard/",
"prompts/", "standards/", "agents/", "data/", "registry/", "tests/",
)
@app.get("/api/diff")
def get_diff(file: str = Query(""), ref: str = Query("HEAD")):
"""Unified git diff for a file in the repo (v0.4.0)."""
try:
if not file:
raise HTTPException(400, "Query parameter 'file' is required")
# Prevent traversal — allow only repo-relative paths
resolved = (BASE_DIR / file).resolve()
if not str(resolved).startswith(str(BASE_DIR.resolve()) + os.sep) and resolved != BASE_DIR:
raise HTTPException(400, "Invalid file path")
if not resolved.exists():
raise HTTPException(404, "File not found")
rel = str(resolved.relative_to(BASE_DIR))
code, out, err = run_cli(["git", "-C", str(BASE_DIR), "diff", ref, "--", rel], timeout=10)
if code == 0 and not out.strip():
# no diff against ref — try working tree vs index
return {"file": rel, "diff": "", "changed": False, "ref": ref}
return {"file": rel, "diff": out or err, "changed": bool(out.strip()), "ref": ref}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
# ─── Routes: Dashboard Static Files ──────────────────────────────
dashboard_dir = BASE_DIR / "dashboard"
@ -1681,31 +1221,14 @@ def index():
html_file = BASE_DIR / "dashboard" / "index.html"
if html_file.exists():
content = html_file.read_text()
# Version-agnostic rewrite: handle any ?v= suffix (or none) so both
# freshly-served and previously-cached index.html resolve correctly.
content = re.sub(r'href="(styles\.css)(\?v=[0-9.]+)?"',
r'href="/dashboard/styles.css\2"', content)
for name in ("utils.js", "api.js", "app.js"):
content = re.sub(rf'src="{name}(?:\?v=[0-9.]+)?"',
rf'src="/dashboard/{name}"', content)
content = content.replace('href="styles.css"', 'href="/dashboard/styles.css"')
content = content.replace('src="utils.js"', 'src="/dashboard/utils.js"')
content = content.replace('src="api.js"', 'src="/dashboard/api.js"')
content = content.replace('src="app.js"', 'src="/dashboard/app.js"')
content = content.replace('pages/', '/dashboard/pages/')
return HTMLResponse(content=content)
return HTMLResponse("<h1>Agentic OS</h1><p>Dashboard not built yet. Run <code>./install.sh</code> first.</p>")
# Root-level fallbacks so stale cached index.html (which references
# root-relative core assets) still resolves even when /dashboard rewrite
# hasn't been seen by the browser (v0.4.1).
for _asset in ("styles.css", "utils.js", "api.js", "app.js"):
def _serve_asset(asset=_asset):
f = BASE_DIR / "dashboard" / asset
if not f.exists():
raise HTTPException(404, "Not found")
media = "text/css" if asset.endswith(".css") else "application/javascript"
return Response(content=f.read_bytes(), media_type=media)
app.add_api_route(f"/{_asset}", _serve_asset)
# ─── Favicon ──────────────────────────────────────────────────────
FAVICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#6c5ce7"/><stop offset="100%" stop-color="#fd79a8"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#g)"/><polygon points="16,6 24,11 24,21 16,26 8,21 8,11" fill="none" stroke="white" stroke-width="2" stroke-linejoin="round"/><circle cx="16" cy="16" r="3" fill="white"/></svg>'
@ -1718,41 +1241,6 @@ def favicon():
def favicon_svg():
return Response(content=FAVICON_SVG, media_type="image/svg+xml")
# ─── 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__":

View File

@ -27,7 +27,7 @@ Brief description of what this skill does.
- What this skill produces
## Agent Assignment
- Primary: opencode / hermes / agy
- Primary: opencode / hermes / gemini
- Fallback: {fallback agent}
## Dependencies

View File

@ -30,4 +30,4 @@ Design document with: problem statement, alternatives, evaluation, recommendatio
## Agent Assignment
- Primary: opencode
- Research: agy
- Research: gemini

View File

@ -34,4 +34,4 @@ Structured review report with severity levels
## Agent Assignment
- Primary: opencode
- Fallback: agy
- Fallback: gemini

View File

@ -20,7 +20,7 @@ Drafts blog posts, newsletter issues, documentation, and other written content.
## Process
1. Read business-brain.md for brand voice
2. Review input topic and desired format
3. Research if needed (via agy CLI)
3. Research if needed (via Gemini CLI)
4. Draft content in appropriate tone
5. Self-review against eval criteria
6. Output to context/ folder
@ -30,4 +30,4 @@ Draft markdown file with metadata (word count, reading time, tone)
## Agent Assignment
- Primary: opencode
- Research: agy
- Research: gemini

View File

@ -29,4 +29,4 @@ Structured audit report markdown in context/ folder
## Agent Assignment
- Primary: opencode
- Fallback: agy
- Fallback: gemini

View File

@ -31,4 +31,4 @@ Structured plan with: goal, milestones, tasks, dependencies, estimates
## Agent Assignment
- Primary: opencode
- Fallback: agy
- Fallback: gemini

View File

@ -17,7 +17,7 @@ Checks system health at regular intervals: agent status, disk usage, memory pres
- Before and after running other skills
## Process
1. Check all 3 agents (opencode, hermes, agy) are online
1. Check all 3 agents (opencode, hermes, gemini) are online
2. Check disk usage (< 90%)
3. Check memory pressure (< 80%)
4. Scan recent audit log for errors

View File

@ -30,4 +30,4 @@ Spec document + task plan + implementation order
## Agent Assignment
- Primary: opencode
- Fallback: agy
- Fallback: gemini

View File

@ -1,6 +1,6 @@
---
name: research-synthesis
description: Web research and synthesis using agy CLI
description: Web research and synthesis using Gemini CLI
version: 1.0.0
author: Agentic OS
tags: [research, analysis, web, synthesis]
@ -20,7 +20,7 @@ Performs multi-source web research on a topic, synthesizes findings into a struc
## Process
1. Define research scope and questions
2. Search web via agy CLI for multiple sources
2. Search web via Gemini CLI for multiple sources
3. Extract key findings per source
4. Cross-reference and validate
5. Synthesize into structured report
@ -30,5 +30,5 @@ Performs multi-source web research on a topic, synthesizes findings into a struc
Research report with sections: summary, findings, sources, recommendations
## Agent Assignment
- Primary: agy
- Primary: gemini
- Fallback: opencode (for formatting only)

View File

@ -1,5 +1,5 @@
# Learnings
## 2026-05-17
- Uses agy CLI for web research
- Uses Gemini CLI for web research
- Sources should be diverse and cross-referenced

View File

@ -10,41 +10,18 @@ if [ ! -f server.py ]; then
exit 1
fi
# Check virtual environment
VENV_DIR=".venv"
if [ ! -d "$VENV_DIR" ]; then
echo "Virtual environment not found. Run ./install.sh first."
exit 1
fi
# Use venv Python directly (no shell activation needed)
PYTHON="$VENV_DIR/bin/python3"
# Ensure deps are up to date
UV="bin/uv"
if [ -x "$UV" ]; then
"$UV" pip install --python "$PYTHON" -r requirements.txt --quiet
else
"$PYTHON" -m pip install -r requirements.txt --quiet
fi
# Check dependencies
pip3 install -r requirements.txt --quiet 2>/dev/null
# Get port from settings or default
PORT=8080
PORT=$("$PYTHON" -c "import json; f=open('data/settings.json'); d=json.load(f); print(d.get('dashboard',{}).get('port',8080)); f.close()" 2>/dev/null || echo "8080")
if command -v python3 &>/dev/null; then
PORT=$(python3 -c "import json; f=open('data/settings.json'); d=json.load(f); print(d.get('dashboard',{}).get('port',8080)); f.close()" 2>/dev/null || echo "8080")
fi
echo "Dashboard: http://127.0.0.1:${PORT}"
echo "Press Ctrl+C to stop"
echo ""
# Kill any stale process still bound to the port (from a crashed/previous run)
if command -v lsof &>/dev/null; then
PIDS=$(lsof -ti:"${PORT}" 2>/dev/null || true)
if [ -n "${PIDS}" ]; then
echo "Port ${PORT} in use — stopping stale process(es): ${PIDS}"
kill -9 ${PIDS} 2>/dev/null || true
sleep 1
fi
fi
# Start server using venv Python
"$PYTHON" server.py --port "${PORT}"
# Start server
python3 server.py --port "${PORT}"

View File

@ -1,205 +0,0 @@
"""Zero-dependency test harness for Agentic OS (v0.4.0).
Boots the real FastAPI app (server.app) in-process on a free port with ALL
state redirected to a temporary directory, so tests never touch real
brain/, data/, scheduler/jobs/, audit/ or backups/. Runs on the Python
standard library only (urllib + threading + unittest) no pytest, no httpx.
Run: python3 tests/run_all.py
"""
import json
import shutil
import socket
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import server # noqa: E402 (must import after sys.path setup)
import scheduler.scheduler as sched # noqa: E402
import brain.memory_search as mem # noqa: E402
def get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Harness:
"""Boots server.app against an isolated temp state dir."""
def __init__(self):
self.tmp = Path(tempfile.mkdtemp(prefix="agentic-os-test-"))
self.port = get_free_port()
self.base_url = f"http://127.0.0.1:{self.port}"
self._server_thread = None
self._uvicorn = None
self._seed_state()
self._redirect_paths()
# ── isolation ─────────────────────────────────────────────
def _redirect_paths(self):
"""Point every module path global at the temp dir."""
server.BASE_DIR = self.tmp
server.ERROR_LOG_FILE = self.tmp / "data" / "error-log.json"
server.CIRCUIT_BREAKER_FILE = self.tmp / "data" / "circuit-breaker.json"
server.CHAT_HISTORY_FILE = self.tmp / "data" / "chat-history.json"
server.KANBAN_DIR = self.tmp / "data" / "kanban"
server.GOALS_FILE = self.tmp / "data" / "goals.json"
server.JOURNAL_DIR = self.tmp / "brain" / "journal"
server.UPLOAD_DIR = self.tmp / "data" / "uploads"
sched.BASE_DIR = self.tmp / "scheduler"
sched.JOBS_DIR = self.tmp / "scheduler" / "jobs"
sched.HISTORY_FILE = self.tmp / "data" / "scheduler-history.json"
mem.BASE_DIR = self.tmp / "brain"
mem.DB_PATH = self.tmp / "data" / "memory.db"
# memory_search caches a thread-local connection at import time
# (init_db() runs on import), so drop it before re-initializing
# against the temp DB to avoid touching the real data/memory.db.
if hasattr(mem._local, "conn") and mem._local.conn is not None:
mem._local.conn.close()
del mem._local.conn
mem.init_db()
def _seed_state(self):
"""Create the minimal dir structure the app expects."""
for d in [
"data/kanban",
"brain/journal",
"scheduler/jobs",
"audit",
"backups",
"skills",
"registry",
"standards",
"prompts",
"agents/opencode",
"agents/hermes",
"agents/agy",
]:
(self.tmp / d).mkdir(parents=True, exist_ok=True)
# Copy real read-only content (skills, brain notes) so endpoints
# behave realistically, without ever writing back to the repo.
for src, dst in [
(PROJECT_ROOT / "skills", self.tmp / "skills"),
(PROJECT_ROOT / "brain", self.tmp / "brain"),
(PROJECT_ROOT / "prompts", self.tmp / "prompts"),
(PROJECT_ROOT / "standards", self.tmp / "standards"),
(PROJECT_ROOT / "registry", self.tmp / "registry"),
(PROJECT_ROOT / "scheduler" / "jobs", self.tmp / "scheduler" / "jobs"),
]:
if src.is_dir():
for item in src.iterdir():
if item.is_dir():
shutil.copytree(item, dst / item.name, dirs_exist_ok=True)
elif item.is_file():
shutil.copy2(item, dst / item.name)
# ── lifecycle ─────────────────────────────────────────────
def start(self):
import uvicorn
config = uvicorn.Config(
server.app,
host="127.0.0.1",
port=self.port,
log_level="warning",
)
self._uvicorn = uvicorn.Server(config)
self._server_thread = threading.Thread(
target=self._uvicorn.run, daemon=True
)
self._server_thread.start()
self._wait_ready()
def _wait_ready(self, timeout: float = 20.0):
deadline = time.time() + timeout
while time.time() < deadline:
try:
status, _ = self.request("GET", "/api/status")
if status == 200:
return
except Exception:
pass
time.sleep(0.2)
raise RuntimeError("Server did not become ready in time")
def stop(self):
if self._uvicorn:
self._uvicorn.should_exit = True
if self._server_thread:
self._server_thread.join(timeout=10)
shutil.rmtree(self.tmp, ignore_errors=True)
# ── HTTP helper (stdlib only) ─────────────────────────────
def request(self, method: str, path: str, body: dict = None,
timeout: float = 15.0) -> tuple:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
self.base_url + path, data=data, method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
return resp.status, raw
except urllib.error.HTTPError as e:
return e.code, e.read()
def api(self, method: str, path: str, body: dict = None,
timeout: float = 15.0) -> tuple:
"""Returns (status_code, parsed_json_or_raw_text)."""
status, raw = self.request(method, path, body, timeout)
try:
parsed = json.loads(raw.decode("utf-8")) if raw else None
except (json.JSONDecodeError, UnicodeDecodeError):
parsed = raw.decode("utf-8", errors="replace") if raw else None
return status, parsed
# ── multipart upload helper (stdlib only) ──────────────────
def upload(self, path: str, fields: dict, filename: str,
content: bytes, timeout: float = 15.0) -> tuple:
"""POST multipart/form-data: returns (status, parsed_json_or_text)."""
boundary = "----agenticostestboundary"
body = b""
for key, value in fields.items():
body += (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
f"{value}\r\n"
).encode()
body += (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; '
f'filename="{filename}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode()
body += content + b"\r\n"
body += f"--{boundary}--\r\n".encode()
req = urllib.request.Request(
self.base_url + path,
data=body,
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
status = resp.status
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
try:
parsed = json.loads(raw.decode("utf-8")) if raw else None
except (json.JSONDecodeError, UnicodeDecodeError):
parsed = raw.decode("utf-8", errors="replace") if raw else None
return status, parsed

View File

@ -1,35 +0,0 @@
#!/usr/bin/env python3
"""Zero-dependency test runner for Agentic OS (v0.4.0).
Usage: python3 tests/run_all.py
Runs every tests/test_*.py module. Each module boots its own isolated
Harness (temp state dir), so no real project data is ever touched.
Requires: python3 + installed deps (fastapi, uvicorn) no pytest/httpx.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_core # noqa: E402
import test_security # noqa: E402
import test_kanban_journal # noqa: E402
import test_memory_scheduler # noqa: E402
import test_v040 # noqa: E402
def main() -> int:
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for module in (test_core, test_security, test_kanban_journal,
test_memory_scheduler, test_v040):
suite.addTests(loader.loadTestsFromModule(module))
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
return 0 if result.wasSuccessful() else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,133 +0,0 @@
"""Core endpoint smoke tests for Agentic OS (v0.4.0).
Run with: python3 tests/run_all.py (or: python3 tests/test_core.py)
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from harness import Harness # noqa: E402
class CoreEndpointTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_status_healthy(self):
status, data = self.h.api("GET", "/api/status")
self.assertEqual(status, 200)
self.assertEqual(data.get("status"), "healthy")
self.assertIn("agents", data)
def test_three_agents_present(self):
_, data = self.h.api("GET", "/api/status")
names = {a.get("name") for a in data.get("agents", [])}
self.assertEqual(names, {"opencode", "hermes", "agy"})
def test_skills_list(self):
status, data = self.h.api("GET", "/api/skills")
self.assertEqual(status, 200)
self.assertIsInstance(data, list)
def test_brain_files(self):
status, data = self.h.api("GET", "/api/brain")
self.assertEqual(status, 200)
self.assertIsInstance(data, dict)
def test_scheduler_jobs(self):
status, data = self.h.api("GET", "/api/scheduler/jobs")
self.assertEqual(status, 200)
self.assertIsInstance(data, list)
def test_audit_log(self):
status, data = self.h.api("GET", "/api/audit")
self.assertEqual(status, 200)
def test_cost_analytics(self):
status, data = self.h.api("GET", "/api/cost")
self.assertEqual(status, 200)
def test_plugins(self):
status, data = self.h.api("GET", "/api/plugins")
self.assertEqual(status, 200)
def test_prompts(self):
status, data = self.h.api("GET", "/api/prompts")
self.assertEqual(status, 200)
def test_settings_masked(self):
status, data = self.h.api("GET", "/api/settings")
self.assertEqual(status, 200)
text = str(data)
self.assertNotIn("ghp_", text, "API token must be masked")
def test_standards(self):
status, data = self.h.api("GET", "/api/standards")
self.assertEqual(status, 200)
def test_kanban_board(self):
status, data = self.h.api("GET", "/api/kanban/board")
self.assertEqual(status, 200)
def test_goals(self):
status, data = self.h.api("GET", "/api/goals")
self.assertEqual(status, 200)
def test_journal_entries(self):
status, data = self.h.api("GET", "/api/journal/entries")
self.assertEqual(status, 200)
def test_agent_health(self):
status, data = self.h.api("GET", "/api/agents/health")
self.assertEqual(status, 200)
def test_skill_analytics(self):
status, data = self.h.api("GET", "/api/analytics/skills")
self.assertEqual(status, 200)
def test_sessions_list(self):
status, data = self.h.api("GET", "/api/sessions/list")
self.assertEqual(status, 200)
def test_backups_list(self):
status, data = self.h.api("GET", "/api/backups")
self.assertEqual(status, 200)
def test_security_headers_present(self):
import urllib.request
req = urllib.request.Request(self.h.base_url + "/api/status")
with urllib.request.urlopen(req, timeout=10) as resp:
headers = dict(resp.headers)
self.assertIn("x-content-type-options", headers)
self.assertEqual(headers["x-content-type-options"], "nosniff")
self.assertIn("x-frame-options", headers)
self.assertIn("strict-transport-security", headers)
def test_cors_restricted_to_localhost(self):
import urllib.request
req = urllib.request.Request(self.h.base_url + "/api/status")
req.add_header("Origin", "http://evil.example.com")
with urllib.request.urlopen(req, timeout=10) as resp:
allow = resp.headers.get("access-control-allow-origin")
self.assertIsNone(allow, "Non-localhost origin must not be allowed")
def test_dashboard_served(self):
status, _ = self.h.request("GET", "/")
self.assertEqual(status, 200)
def test_manifest_served(self):
status, _ = self.h.request("GET", "/manifest.json")
self.assertEqual(status, 200)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -1,144 +0,0 @@
"""Kanban + Journal CRUD tests for Agentic OS (v0.4.0)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from harness import Harness # noqa: E402
class KanbanTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_create_and_get_task(self):
status, data = self.h.api(
"POST", "/api/kanban/tasks",
{"title": "Test task", "body": "do it", "status": "todo",
"priority": "high", "assignee": "tester"},
)
self.assertEqual(status, 200)
tid = data["id"]
self.assertTrue(tid)
status, got = self.h.api("GET", f"/api/kanban/tasks/{tid}")
self.assertEqual(status, 200)
self.assertEqual(got["title"], "Test task")
def test_update_task(self):
_, created = self.h.api(
"POST", "/api/kanban/tasks",
{"title": "Update me", "body": "", "status": "todo",
"priority": "low", "assignee": ""},
)
tid = created["id"]
status, updated = self.h.api(
"PATCH", f"/api/kanban/tasks/{tid}",
{"title": "Updated title", "status": "in_progress"},
)
self.assertEqual(status, 200)
self.assertEqual(updated["title"], "Updated title")
self.assertEqual(updated["status"], "in_progress")
def test_complete_task(self):
_, created = self.h.api(
"POST", "/api/kanban/tasks",
{"title": "Complete me", "body": "", "status": "todo",
"priority": "medium", "assignee": ""},
)
tid = created["id"]
status, done = self.h.api(
"POST", f"/api/kanban/tasks/{tid}/complete", {"summary": "finished"}
)
self.assertEqual(status, 200)
self.assertEqual(done["status"], "done")
def test_block_and_unblock(self):
_, created = self.h.api(
"POST", "/api/kanban/tasks",
{"title": "Block me", "body": "", "status": "todo",
"priority": "medium", "assignee": ""},
)
tid = created["id"]
_, blocked = self.h.api(
"POST", f"/api/kanban/tasks/{tid}/block", {"reason": "blocked"}
)
self.assertEqual(blocked["status"], "blocked")
self.assertEqual(blocked["block_reason"], "blocked")
_, unblocked = self.h.api("POST", f"/api/kanban/tasks/{tid}/unblock")
self.assertEqual(unblocked["status"], "ready")
def test_comment_roundtrip(self):
_, created = self.h.api(
"POST", "/api/kanban/tasks",
{"title": "Comment me", "body": "", "status": "todo",
"priority": "low", "assignee": ""},
)
tid = created["id"]
_, with_comment = self.h.api(
"POST", f"/api/kanban/tasks/{tid}/comments", {"message": "hello world"}
)
self.assertEqual(with_comment["comments"][-1]["message"], "hello world")
def test_missing_task_404(self):
status, _ = self.h.api("GET", "/api/kanban/tasks/nonexistent-id-xyz")
self.assertEqual(status, 404)
def test_board_contains_columns(self):
status, data = self.h.api("GET", "/api/kanban/board")
self.assertEqual(status, 200)
self.assertIn("columns", data)
class JournalTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_save_and_get_entry(self):
status, saved = self.h.api(
"PUT", "/api/journal/entries/2026-08-11",
{"content": "Today I learned testing."},
)
self.assertEqual(status, 200)
self.assertEqual(saved["status"], "saved")
status, got = self.h.api("GET", "/api/journal/entries/2026-08-11")
self.assertEqual(status, 200)
self.assertIn("Today I learned", got["content"])
def test_entries_list_includes_new(self):
self.h.api("PUT", "/api/journal/entries/2026-08-10",
{"content": "Journal test entry"})
status, data = self.h.api("GET", "/api/journal/entries")
self.assertEqual(status, 200)
dates = {e["date"] for e in data.get("entries", [])}
self.assertIn("2026-08-10", dates)
def test_search_finds_content(self):
self.h.api("PUT", "/api/journal/entries/2026-08-09",
{"content": "Searchable unicorn keyword here"})
status, data = self.h.api("GET", "/api/journal/search?q=unicorn")
self.assertEqual(status, 200)
hits = data.get("results", [])
self.assertTrue(any("unicorn" in r.get("preview", "") for r in hits))
def test_search_empty_query_ok(self):
status, data = self.h.api("GET", "/api/journal/search?q=")
self.assertEqual(status, 200)
self.assertEqual(data.get("results"), [])
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -1,126 +0,0 @@
"""Memory (FTS5 search) + Scheduler tests for Agentic OS (v0.4.0)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from harness import Harness # noqa: E402
class MemoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_search_endpoint_ok(self):
status, data = self.h.api("GET", "/api/memory/search?q=system")
self.assertEqual(status, 200)
self.assertIn("results", data)
self.assertIn("entities", data)
self.assertEqual(data["query"], "system")
def test_search_indexes_brain_file(self):
# Seed a brain file in temp state, reindex, then search
(self.h.tmp / "brain" / "test-brain-note.md").write_text(
"zephyrquadrant cloud infrastructure notes"
)
status, _ = self.h.api("POST", "/api/memory/reindex")
self.assertEqual(status, 200)
status, data = self.h.api("GET", "/api/memory/search?q=zephyrquadrant")
self.assertEqual(status, 200)
self.assertTrue(
data["results"],
"FTS5 search should find the indexed brain file",
)
def test_reindex_endpoint(self):
status, data = self.h.api("POST", "/api/memory/reindex")
self.assertEqual(status, 200)
self.assertEqual(data["status"], "reindexed")
def test_entities_endpoint(self):
status, data = self.h.api("GET", "/api/memory/entities")
self.assertEqual(status, 200)
self.assertIn("entities", data)
def test_search_without_query_empty(self):
status, data = self.h.api("GET", "/api/memory/search?q=")
self.assertEqual(status, 200)
self.assertEqual(data["results"], [])
def test_entity_extraction(self):
status, data = self.h.api(
"GET", "/api/memory/search?q=Contact%20test@example.com"
)
self.assertEqual(status, 200)
types = {e["type"] for e in data["entities"]}
self.assertIn("email", types)
class SchedulerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_events_endpoint(self):
status, data = self.h.api("GET", "/api/scheduler/events")
self.assertEqual(status, 200)
self.assertIn("events", data)
def test_trigger_unknown_job_404(self):
status, _ = self.h.api("POST", "/api/scheduler/trigger/does-not-exist")
self.assertEqual(status, 404)
def test_webhook_received(self):
status, data = self.h.api(
"POST", "/api/webhook/generic", {"source": "test", "event": "ping"}
)
self.assertEqual(status, 200)
self.assertEqual(data["status"], "ok")
self.assertEqual(data["event"], "ping")
class SkillGenerateTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_generate_skill(self):
status, data = self.h.api(
"POST", "/api/skills/generate",
{"name": "Test Skill V4", "description": "A test skill"},
)
self.assertEqual(status, 200)
self.assertEqual(data["status"], "created")
self.assertEqual(data["name"], "test-skill-v4")
# verify it appears in skills list, then clean up
(self.h.tmp / "skills" / "test-skill-v4").mkdir(exist_ok=True)
status, skills = self.h.api("GET", "/api/skills")
self.assertEqual(status, 200)
self.assertIn("test-skill-v4", [s["name"] for s in skills])
def test_generate_invalid_name(self):
status, _ = self.h.api(
"POST", "/api/skills/generate",
{"name": "bad name/../../x", "description": "bad"},
)
self.assertEqual(status, 400)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -1,122 +0,0 @@
"""Security hardening tests — path-traversal / injection guards (v0.4.0).
Validates the strict allowlist validation added in commit 4af5ea3:
skill names, kanban task ids, journal dates, scheduler job names, backup files.
"""
import sys
import unittest
from pathlib import Path
from urllib.parse import quote
sys.path.insert(0, str(Path(__file__).resolve().parent))
from harness import Harness # noqa: E402
class SecurityTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
# ── traversal payloads (URL-encoded so they stay single-segment) ─
TRAVERSALS = [
quote("../../etc/passwd"),
quote("..%2F..%2Fetc%2Fpasswd"),
quote("....//....//etc/passwd"),
quote("%2e%2e/settings"),
quote("..%5c..%5csettings"),
]
def test_skill_traversal_rejected(self):
for evil in self.TRAVERSALS:
status, _ = self.h.api("GET", f"/api/skills/{evil}")
self.assertNotEqual(status, 200, f"skill traversal slipped through: {evil}")
def test_skill_run_traversal_rejected(self):
for evil in [quote("../../settings"), quote("..%2F..%2Fdata%2Fsettings")]:
status, _ = self.h.api("POST", f"/api/skills/{evil}/run",
{"agent": "hermes", "input": "x"})
self.assertNotEqual(status, 200, f"skill run traversal slipped: {evil}")
def test_skill_eval_traversal_rejected(self):
for evil in [quote("../../settings"), quote("..%2F..%2Fdata")]:
status, _ = self.h.api("GET", f"/api/skills/{evil}/eval")
self.assertNotEqual(status, 200, f"skill eval traversal slipped: {evil}")
def test_skill_invalid_chars_rejected(self):
for evil in ["heartbeat.", ".hidden", quote("a b"), "a*b", quote("a\u00e9")]:
status, _ = self.h.api("GET", f"/api/skills/{evil}")
self.assertNotEqual(status, 200, f"invalid skill name accepted: {evil}")
def test_skill_valid_name_works(self):
status, data = self.h.api("GET", "/api/skills/heartbeat")
self.assertEqual(status, 200)
self.assertEqual(data.get("name"), "heartbeat")
def test_kanban_traversal_rejected(self):
for evil in ["../../settings", "..%2F..%2Fdata%2Fsettings"]:
status, _ = self.h.api("GET", f"/api/kanban/tasks/{evil}")
self.assertNotEqual(status, 200, f"kanban traversal slipped: {evil}")
def test_kanban_invalid_id_rejected(self):
for evil in ["..", ".", "a/b", "a\\b", ""]:
status, _ = self.h.api("GET", f"/api/kanban/tasks/{evil}")
self.assertNotEqual(status, 200, f"invalid kanban id accepted: {evil}")
def test_journal_traversal_rejected(self):
status, _ = self.h.api("GET", "/api/journal/entries/../../settings")
self.assertNotEqual(status, 200)
status, _ = self.h.api("GET", "/api/journal/entries/2026-06-05%2F..%2F..%2Fetc%2Fpasswd")
self.assertNotEqual(status, 200)
def test_journal_bad_date_format_rejected(self):
for evil in ["2026-6-5", quote("2026/06/05"), "2026.06.05", "06-05-2026", "20260605"]:
status, _ = self.h.api("GET", f"/api/journal/entries/{evil}")
# 400 = validator rejection, 404 = slash decoded before routing
self.assertIn(status, (400, 404), f"bad date accepted: {evil}")
def test_journal_valid_date_works(self):
status, _ = self.h.api("GET", "/api/journal/entries/2026-06-05")
self.assertEqual(status, 200)
def test_scheduler_job_traversal_rejected(self):
status, _ = self.h.api(
"POST", "/api/scheduler/jobs",
{"name": "../../evil", "skill": "heartbeat", "cron": "* * * * *"},
)
self.assertEqual(status, 400)
def test_scheduler_job_invalid_name_rejected(self):
status, _ = self.h.api(
"POST", "/api/scheduler/jobs",
{"name": "evil/../x", "skill": "heartbeat", "cron": "* * * * *"},
)
self.assertEqual(status, 400)
def test_scheduler_job_valid_name_works(self):
status, data = self.h.api(
"POST", "/api/scheduler/jobs",
{"name": "test-job-v4", "skill": "heartbeat", "cron": "0 0 * * *"},
)
self.assertEqual(status, 200)
self.assertEqual(data.get("name"), "test-job-v4")
# cleanup
self.h.api("DELETE", f"/api/scheduler/jobs/{data['id']}")
def test_backup_restore_traversal_rejected(self):
for evil in ["../../etc/passwd", "agentic-os.tar.gz", "..%2F..%2Fsettings"]:
status, _ = self.h.api("POST", "/api/backup/restore", {"file": evil})
self.assertEqual(status, 400, f"backup traversal slipped: {evil}")
def test_brain_file_traversal_rejected(self):
for evil in ["../../etc/passwd", "..%2F..%2Fserver.py"]:
status, _ = self.h.api("GET", f"/api/brain/{evil}")
self.assertNotEqual(status, 200, f"brain traversal slipped: {evil}")
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -1,199 +0,0 @@
"""v0.4.0 feature tests: chat history filter, file uploads, memory graph, diff.
Run with: python3 tests/run_all.py (or: python3 tests/test_v040.py)
"""
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent))
from harness import Harness # noqa: E402
import server # noqa: E402
class ChatHistoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def _seed_message(self, content, agent="opencode"):
msg = {
"id": "seed1",
"role": "user",
"agent": agent,
"content": content,
"timestamp": "2026-08-11T12:00:00Z",
}
history = server.load_chat_history()
history.setdefault("messages", []).append(msg)
server.save_chat_message(msg)
def test_history_empty_by_default(self):
# Clear any messages seeded by prior tests in this class
server.save_chat_message({"id": "clear", "role": "user", "agent": "opencode",
"content": "", "timestamp": ""})
history = server.load_chat_history()
history["messages"] = []
server.CHAT_HISTORY_FILE.write_text(__import__("json").dumps(history))
status, data = self.h.api("GET", "/api/chat/history")
self.assertEqual(status, 200)
self.assertEqual(data["total"], 0)
def test_history_search_filters(self):
self._seed_message("terraform plan complete", "opencode")
self._seed_message("pod restart completed", "hermes")
status, data = self.h.api("GET", "/api/chat/history?q=terraform")
self.assertEqual(status, 200)
self.assertEqual(data["total"], 1)
self.assertIn("terraform", data["messages"][0]["content"])
def test_history_agent_filter(self):
self._seed_message("deploy the cluster", "opencode")
self._seed_message("schedule the job", "hermes")
status, data = self.h.api("GET", "/api/chat/history?agent=hermes")
self.assertEqual(status, 200)
self.assertEqual(data["total"], 1)
self.assertEqual(data["messages"][0]["agent"], "hermes")
def test_history_limit(self):
for i in range(5):
self._seed_message(f"message number {i}")
status, data = self.h.api("GET", "/api/chat/history?limit=2")
self.assertEqual(status, 200)
self.assertEqual(data["total"], 2)
class ChatUploadTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_upload_valid_file(self):
with mock.patch.object(server, "execute_agent", return_value="mock reply"):
status, data = self.h.upload(
"/api/chat/upload",
{"agent": "opencode", "message": "review this"},
"notes.md",
b"# Notes\n\nsome content here",
)
self.assertEqual(status, 200)
self.assertEqual(data["status"], "ok")
self.assertEqual(data["file"], "notes.md")
self.assertEqual(data["response"]["content"], "mock reply")
# File must be stored in the isolated temp uploads dir
saved = self.h.tmp / "data" / "uploads"
self.assertTrue(saved.exists())
files = list(saved.glob("*.md"))
self.assertEqual(len(files), 1)
self.assertIn(b"# Notes", files[0].read_bytes())
def test_upload_agent_validation(self):
status, _ = self.h.upload(
"/api/chat/upload",
{"agent": "bogus", "message": ""},
"f.txt",
b"x",
)
self.assertEqual(status, 400)
def test_upload_invalid_extension(self):
status, _ = self.h.upload(
"/api/chat/upload",
{"agent": "opencode", "message": ""},
"virus.exe",
b"x",
)
self.assertEqual(status, 400)
def test_upload_oversized_file(self):
status, _ = self.h.upload(
"/api/chat/upload",
{"agent": "opencode", "message": ""},
"big.txt",
b"x" * (2 * 1024 * 1024 + 1),
)
self.assertEqual(status, 413)
def test_upload_traversal_filename_rejected(self):
status, _ = self.h.upload(
"/api/chat/upload",
{"agent": "opencode", "message": ""},
"..%2F..%2Fevil.txt",
b"x",
)
self.assertEqual(status, 400)
class MemoryGraphTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_memory_graph_endpoint(self):
status, data = self.h.api("GET", "/api/memory/graph")
self.assertEqual(status, 200)
self.assertIn("nodes", data)
self.assertIn("edges", data)
self.assertIsInstance(data["nodes"], list)
self.assertIsInstance(data["edges"], list)
def test_memory_graph_has_nodes(self):
(self.h.tmp / "brain" / "graph-note.md").write_text(
"zephyrquadrant graph connectivity notes"
)
status, _ = self.h.api("POST", "/api/memory/reindex")
self.assertEqual(status, 200)
status, data = self.h.api("GET", "/api/memory/graph")
self.assertEqual(status, 200)
self.assertGreater(len(data["nodes"]), 0)
class DiffViewerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.h = Harness()
cls.h.start()
@classmethod
def tearDownClass(cls):
cls.h.stop()
def test_diff_missing_file_param(self):
status, _ = self.h.api("GET", "/api/diff")
self.assertEqual(status, 400)
def test_diff_nonexistent_file(self):
status, _ = self.h.api("GET", "/api/diff?file=no/such/file.md")
self.assertEqual(status, 404)
def test_diff_traversal_rejected(self):
status, _ = self.h.api("GET", "/api/diff?file=../../etc/passwd")
self.assertEqual(status, 400)
def test_diff_known_file_ok(self):
# skills/ exists in the isolated state; git may not report a diff,
# but the endpoint must still return 200 with changed=false
status, data = self.h.api("GET", "/api/diff?file=skills/_template/SKILL.md")
self.assertEqual(status, 200)
self.assertIn("file", data)
self.assertIn("changed", data)
if __name__ == "__main__":
unittest.main()