Compare commits
14 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
c7ebbb41d8 | |
|
|
eeb6cc4c9a | |
|
|
4af5ea360c | |
|
|
0cd8ca0e72 | |
|
|
54ae2d5dc5 | |
|
|
21989dadae | |
|
|
2cdd2e64a5 | |
|
|
a7dce93e74 | |
|
|
aeda34fdfe | |
|
|
e003688658 | |
|
|
a30162d114 | |
|
|
d9b1b0d9fd | |
|
|
14584fc55e | |
|
|
1586633904 |
|
|
@ -1,6 +1,8 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
|
.venv/
|
||||||
|
bin/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
@ -11,3 +13,10 @@ audit/*
|
||||||
data/settings.json
|
data/settings.json
|
||||||
data/chat-history.json
|
data/chat-history.json
|
||||||
data/cost-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
119
AGENTS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Role Definition
|
## Role Definition
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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 |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| **Name** | Agentic OS |
|
| **Name** | Agentic OS |
|
||||||
| **Location** | `/home/mihir/Desktop/Agentic OS Project/` |
|
| **Location** | `~/Desktop/Agentic OS Project/` |
|
||||||
| **GitHub** | [github.com/modimihir07/agentic-os](https://github.com/modimihir07/agentic-os) |
|
| **GitHub** | [github.com/modimihir07/agentic-os](https://github.com/modimihir07/agentic-os) |
|
||||||
| **Author** | Mihir N Modi (Gujarat, India — BTech CSE 1st year) |
|
| **Author** | modimihir07 |
|
||||||
| **Created** | May 17, 2026 |
|
| **Created** | May 17, 2026 |
|
||||||
| **License** | MIT |
|
| **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 |
|
| **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 │ │
|
│ │ 3-AGENT EXECUTION ENGINE │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │
|
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │
|
||||||
│ │ │ opencode │ │ Hermes │ │ Gemini CLI │ │ │
|
│ │ │ opencode │ │ Hermes │ │ agy CLI │ │ │
|
||||||
│ │ │ (Code/DevOps)│ │ (Memory/Sched│ │(Research/ │ │ │
|
│ │ │ (Code/DevOps)│ │ (Memory/Sched│ │(Research/ │ │ │
|
||||||
│ │ │ File Ops) │ │ /Channels) │ │ Analysis) │ │ │
|
│ │ │ 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 |
|
| **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 |
|
| **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 |
|
||||||
| **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 |
|
| **agy CLI** | Web research, multi-modal analysis (images/PDFs), reasoning, data analysis | Research tasks, content analysis, document understanding, competitive analysis, learning/research |
|
||||||
|
|
||||||
### Routing Rules
|
### Routing Rules
|
||||||
|
|
||||||
- **Code/DevOps task?** → opencode
|
- **Code/DevOps task?** → opencode
|
||||||
- **Memory/Channel/Schedule?** → Hermes Agent
|
- **Memory/Channel/Schedule?** → Hermes Agent
|
||||||
- **Research/Analysis?** → Gemini CLI
|
- **Research/Analysis?** → agy CLI
|
||||||
- **Complex multi-step?** → Chain: Gemini researches → opencode implements → Hermes monitors/schedules
|
- **Complex multi-step?** → Chain: agy researches → opencode implements → Hermes monitors/schedules
|
||||||
- **Unknown/General?** → opencode first (best general-purpose coding agent)
|
- **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
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
/home/mihir/Desktop/Agentic OS Project/
|
~/Desktop/Agentic OS Project/
|
||||||
├── AGENTS.md # THIS FILE — complete context for any AI agent
|
├── AGENTS.md # THIS FILE — complete context for any AI agent
|
||||||
├── README.md # User-facing documentation
|
├── README.md # User-facing documentation
|
||||||
├── server.py # FastAPI backend (REST API for dashboard)
|
├── server.py # FastAPI backend (REST API for dashboard)
|
||||||
|
|
@ -192,14 +192,15 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|
||||||
├── backup.sh # Manual backup [E3]
|
├── backup.sh # Manual backup [E3]
|
||||||
├── restore.sh # Manual restore [E3]
|
├── restore.sh # Manual restore [E3]
|
||||||
│
|
│
|
||||||
├── brain/ # [F2, F7, F10, F49, F50] Shared context
|
├── brain/ # [F2, F7, F10, F49, F50, F54] Shared context
|
||||||
│ ├── business-brain.md
|
│ ├── business-brain.md
|
||||||
│ ├── memory.md
|
│ ├── memory.md
|
||||||
│ ├── recent-decisions.md
|
│ ├── recent-decisions.md
|
||||||
│ ├── active-projects.md
|
│ ├── active-projects.md
|
||||||
│ ├── constraints.md
|
│ ├── constraints.md
|
||||||
│ ├── identity.md
|
│ ├── identity.md
|
||||||
│ └── constitution.md
|
│ ├── constitution.md
|
||||||
|
│ └── journal/ # [F54] Daily markdown entries (YYYY-MM-DD.md)
|
||||||
│
|
│
|
||||||
├── skills/ # [F5, F8, F15, F17] Skills Hub
|
├── skills/ # [F5, F8, F15, F17] Skills Hub
|
||||||
│ ├── _template/
|
│ ├── _template/
|
||||||
|
|
@ -212,7 +213,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|
||||||
│ ├── devops-audit/ # CloudMart GCP infra
|
│ ├── devops-audit/ # CloudMart GCP infra
|
||||||
│ ├── content-draft/ # Blog/newsletter writing
|
│ ├── content-draft/ # Blog/newsletter writing
|
||||||
│ ├── code-review/ # [F21]
|
│ ├── code-review/ # [F21]
|
||||||
│ ├── research-synthesis/ # Gemini research
|
│ ├── research-synthesis/ # agy research
|
||||||
│ ├── daily-standup/ # Morning briefing
|
│ ├── daily-standup/ # Morning briefing
|
||||||
│ ├── meeting-minutes/ # Meeting notes processor
|
│ ├── meeting-minutes/ # Meeting notes processor
|
||||||
│ ├── project-planner/ # [F25, F26, F46]
|
│ ├── project-planner/ # [F25, F26, F46]
|
||||||
|
|
@ -232,9 +233,9 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|
||||||
│ │ ├── SOUL.md
|
│ │ ├── SOUL.md
|
||||||
│ │ ├── USER.md
|
│ │ ├── USER.md
|
||||||
│ │ └── MEMORY.md
|
│ │ └── MEMORY.md
|
||||||
│ └── gemini/
|
│ └── agy/
|
||||||
│ ├── GEMINI.md
|
│ ├── AGY.md
|
||||||
│ └── gemini-extension.json
|
│ └── agy-extension.json
|
||||||
│
|
│
|
||||||
├── scheduler/ # [F9] Scheduling
|
├── scheduler/ # [F9] Scheduling
|
||||||
│ ├── scheduler.py
|
│ ├── scheduler.py
|
||||||
|
|
@ -272,7 +273,14 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|
||||||
│ ├── prompts.js
|
│ ├── prompts.js
|
||||||
│ ├── standards.js
|
│ ├── standards.js
|
||||||
│ ├── settings.js
|
│ ├── settings.js
|
||||||
│ └── setup-wizard.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)
|
||||||
│
|
│
|
||||||
├── audit/ # [F38] Audit Trail
|
├── audit/ # [F38] Audit Trail
|
||||||
│ └── audit.log
|
│ └── audit.log
|
||||||
|
|
@ -295,7 +303,9 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|
||||||
├── data/ # Runtime data
|
├── data/ # Runtime data
|
||||||
│ ├── settings.json
|
│ ├── settings.json
|
||||||
│ ├── cost-history.json
|
│ ├── cost-history.json
|
||||||
│ └── agent-routes.json
|
│ ├── agent-routes.json
|
||||||
|
│ ├── kanban/ # [F52] Kanban task JSON files
|
||||||
|
│ └── goals.json # [F53] Goals storage
|
||||||
│
|
│
|
||||||
└── .git/ # [E2] Auto-versioning
|
└── .git/ # [E2] Auto-versioning
|
||||||
```
|
```
|
||||||
|
|
@ -306,17 +316,10 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|
||||||
|
|
||||||
| Detail | Info |
|
| 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) |
|
| **Budget** | Strictly free tiers (GCP Free, GitHub Student Pack, Colab, Kaggle) |
|
||||||
| **Active Project** | CloudMart — GCP DevOps multi-region e-commerce platform |
|
| **Active Project** | CloudMart — GCP DevOps multi-region e-commerce platform |
|
||||||
| **Other Projects** | AgriAssist AI, Hermes Agent/OpenClaw, EROS Wellness AI, Java OOP practice |
|
| **CLI Tools Available** | opencode, Hermes Agent, agy CLI |
|
||||||
| **CLI Tools Available** | opencode, Hermes Agent, Gemini CLI |
|
| **Preferred Model** | Hermes: Owl Alpha (OpenRouter, free), opencode: deepseek-v4-flash-free (opencode-zen), agy: Antigravity (free 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) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -347,12 +350,13 @@ When you (an AI agent) are dropped into this directory for the first time:
|
||||||
### Session History
|
### Session History
|
||||||
- opencode: `~/.local/share/opencode/opencode.db` + `~/.local/share/opencode/log/`
|
- opencode: `~/.local/share/opencode/opencode.db` + `~/.local/share/opencode/log/`
|
||||||
- Hermes: `~/.hermes/sessions.json`
|
- Hermes: `~/.hermes/sessions.json`
|
||||||
- Gemini: `~/.gemini/history/`
|
- agy: `~/.antigravity/history/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Endpoints (server.py FastAPI)
|
## API Endpoints (server.py FastAPI, 58 total — 28 original + 30 v0.2.0)
|
||||||
|
|
||||||
|
### Core (28 original endpoints)
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| GET | `/api/status` | System health + 3 agent status |
|
| GET | `/api/status` | System health + 3 agent status |
|
||||||
|
|
@ -378,6 +382,64 @@ When you (an AI agent) are dropped into this directory for the first time:
|
||||||
| GET | `/api/standards` | List standards |
|
| GET | `/api/standards` | List standards |
|
||||||
| POST | `/api/standards/discover` | Run standards discovery |
|
| 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
|
## Version History
|
||||||
|
|
@ -386,7 +448,8 @@ 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.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 |
|
| 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, Gemini 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, agy CLI, Hermes, Cursor, etc.) reading this file should have complete context to continue the project seamlessly.*
|
||||||
|
|
|
||||||
227
README.md
227
README.md
|
|
@ -4,7 +4,8 @@
|
||||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="Python 3.10+"/>
|
<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/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/agents-3-orange.svg" alt="3 Agents"/>
|
||||||
<img src="https://img.shields.io/badge/skills-16-purple.svg" alt="16 Skills"/>
|
<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/status-stable-brightgreen.svg" alt="Status: Stable"/>
|
<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>
|
<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/>
|
<br/><br/>
|
||||||
|
|
@ -12,7 +13,7 @@
|
||||||
|
|
||||||
# Agentic OS (agentic-os) 🧠 — Multi-Agent Orchestration Platform
|
# 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 **Gemini 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 **agy 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.
|
> **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.
|
||||||
|
|
||||||
|
|
@ -22,8 +23,8 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
|
||||||
|
|
||||||
| Category | Features |
|
| Category | Features |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| **🤖 3-Agent Engine** | opencode (code/DevOps), Hermes (memory/scheduling), Gemini (research/analysis) with intelligent routing |
|
| **🤖 3-Agent Engine** | opencode (code/DevOps), Hermes (memory/scheduling), agy (research/analysis) with intelligent routing |
|
||||||
| **🧩 16+ Skills** | Executable skill packs with eval scoring, learnings, and score history per run |
|
| **🧩 15+ 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 |
|
| **🧠 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 |
|
| **⏱ 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 |
|
| **💰 Cost Analytics** | Track spending per provider, model, agent. Free-tier alerts prevent surprise bills |
|
||||||
|
|
@ -33,7 +34,21 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
|
||||||
| **📐 Standards System** | Discover and inject coding conventions across your project |
|
| **📐 Standards System** | Discover and inject coding conventions across your project |
|
||||||
| **🔌 Plugin Registry** | Marketplace-style plugin management (extensible via skills) |
|
| **🔌 Plugin Registry** | Marketplace-style plugin management (extensible via skills) |
|
||||||
| **🎨 Dark/Light Theme** | GitHub-style dark mode + clean light theme, toggle from sidebar |
|
| **🎨 Dark/Light Theme** | GitHub-style dark mode + clean light theme, toggle from sidebar |
|
||||||
| **⚡ Zero API Costs** | Built for free tiers — Gemini Flash, OpenRouter free models, local opencode |
|
| **⚡ 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -41,26 +56,26 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
│ AGENTIC OS DASHBOARD │
|
│ AGENTIC OS DASHBOARD │
|
||||||
│ FastAPI + Tailwind SPA │
|
│ FastAPI + Tailwind SPA │
|
||||||
├──────────────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────-┐ ┌──────────────┐ ┌──────────────────┐ |
|
│ ┌───────────────┐ ┌────────────────┐ ┌────────────────────┐ │
|
||||||
│ │ opencode │ │ Hermes │ │ Gemini CLI │ │
|
│ │ opencode │ │ Hermes │ │ agy CLI │ │
|
||||||
│ │ (Code/DevOps)│ │ (Memory/Sched│ │ (Research/Analy) │ │
|
│ │ (Code/DevOps) │ │ (Memory/Sched) │ │ (Research/Analy) │ │
|
||||||
│ │ File Ops) │ │ /Channels) │ │ │ │
|
│ │ File Ops) │ │ /Channels) │ │ │ │
|
||||||
│ └──────────────-┘ └──────────────┘ └──────────────────┘ |
|
│ └───────────────┘ └────────────────┘ └────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
│ │ 7 CORE LAYERS (Stacked) │ │
|
│ │ 7 CORE LAYERS (Stacked) │ │
|
||||||
│ │ Layer 7: Identity / Persona / Constitution │ │
|
│ │ Layer 7: Identity / Persona / Constitution │ │
|
||||||
│ │ Layer 6: Self-Evolution + Capability Manager │ │
|
│ │ Layer 6: Self-Evolution + Capability Manager │ │
|
||||||
│ │ Layer 5: Scheduler + Awareness + Health Guardian │ │
|
│ │ Layer 5: Scheduler + Awareness + Health Guardian │ │
|
||||||
│ │ Layer 4: Memory Graph + Memory Consolidation │ │
|
│ │ Layer 4: Memory Graph + Memory Consolidation │ │
|
||||||
│ │ Layer 3: Skills Hub + Eval + Learnings Loop │ │
|
│ │ Layer 3: Skills Hub + Eval + Learnings Loop │ │
|
||||||
│ │ Layer 2: Business Brain + Context Folders │ │
|
│ │ Layer 2: Business Brain + Context Folders │ │
|
||||||
│ │ Layer 1: Agent Router + Standards + Profiles │ │
|
│ │ Layer 1: Agent Router + Standards + Profiles │ │
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
└──────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -70,14 +85,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** |
|
| **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** |
|
| **Hermes Agent** | Persistent memory, scheduling, messaging | Owl Alpha (1M ctx) | OpenRouter | **$0** |
|
||||||
| **Gemini CLI** | Web research, multi-modal analysis | gemini-2.5-flash | Google OAuth | **$0** |
|
| **agy CLI** | Web research, multi-modal analysis | Antigravity | agy CLI | **$0** |
|
||||||
|
|
||||||
### Routing Rules
|
### Routing Rules
|
||||||
|
|
||||||
- **Code/DevOps task?** → opencode
|
- **Code/DevOps task?** → opencode
|
||||||
- **Memory/Channel/Schedule?** → Hermes Agent
|
- **Memory/Channel/Schedule?** → Hermes Agent
|
||||||
- **Research/Analysis?** → Gemini CLI
|
- **Research/Analysis?** → agy CLI
|
||||||
- **Complex multi-step?** → Chain: Gemini researches → opencode implements → Hermes monitors/schedules
|
- **Complex multi-step?** → Chain: agy researches → opencode implements → Hermes monitors/schedules
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -97,11 +112,11 @@ chmod +x install.sh && ./install.sh
|
||||||
|
|
||||||
| Tool | Required? | Install |
|
| Tool | Required? | Install |
|
||||||
|------|-----------|---------|
|
|------|-----------|---------|
|
||||||
| Python 3.10+ | ✅ Required | `sudo apt install python3 python3-pip` |
|
| Python 3.10+ | ✅ Required | Auto-installed via `uv` by `install.sh` |
|
||||||
| Node.js 18+ | ⚠ For opencode | `curl -fsSL https://deb.nodesource.com/setup_20.x \| sudo bash - && sudo apt install -y nodejs` |
|
| 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` |
|
| 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` |
|
| Hermes Agent | ⚠ For memory/scheduling | `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh \| bash` |
|
||||||
| Gemini CLI | ⚠ For Google AI | `npm install -g @google/gemini-cli` |
|
| agy CLI | ⚠ For research/analysis | `curl -fsSL https://antigravity.ai/install \| bash` |
|
||||||
|
|
||||||
> ⚠ = Optional — the dashboard works with any subset of installed agents.
|
> ⚠ = Optional — the dashboard works with any subset of installed agents.
|
||||||
|
|
||||||
|
|
@ -115,10 +130,10 @@ chmod +x install.sh && ./install.sh
|
||||||
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key-here' > ~/.hermes/.env
|
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key-here' > ~/.hermes/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gemini CLI (Google OAuth)
|
### agy CLI (Antigravity)
|
||||||
```bash
|
```bash
|
||||||
gemini auth login
|
agy login
|
||||||
# Complete OAuth in browser — tokens saved to ~/.gemini/oauth_creds.json
|
# Authenticate to enable web research, analysis, and document understanding
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dashboard Settings
|
### Dashboard Settings
|
||||||
|
|
@ -127,7 +142,7 @@ Edit `data/settings.json`:
|
||||||
{
|
{
|
||||||
"dashboard": { "port": 8080 },
|
"dashboard": { "port": 8080 },
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"agents": { "opencode": true, "hermes": true, "gemini": true }
|
"agents": { "opencode": true, "hermes": true, "agy": true }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -149,11 +164,12 @@ agentic-os/
|
||||||
│ ├── api.js # API client (all endpoints)
|
│ ├── api.js # API client (all endpoints)
|
||||||
│ ├── styles.css # Full dark/light theme CSS
|
│ ├── styles.css # Full dark/light theme CSS
|
||||||
│ ├── utils.js # Shared utilities
|
│ ├── utils.js # Shared utilities
|
||||||
│ └── pages/ # 13 page modules
|
│ └── pages/ # 22 page modules (13 original + 7 v0.2.0 + 1 v0.3.0 + 1 v0.4.0)
|
||||||
│ ├── dashboard.js # Overview with stats
|
│ ├── dashboard.js # Overview with stats
|
||||||
│ ├── skills.js # Skill grid/list/detail
|
│ ├── skills.js # Skill grid/list/detail
|
||||||
│ ├── memory.js # Brain file editor
|
│ ├── memory.js # Brain file editor + Knowledge Graph
|
||||||
│ ├── chat.js # Multi-agent chat
|
│ ├── chat.js # Multi-agent chat + file attachments
|
||||||
|
│ ├── history.js # ▸ Chat History Search (v0.4.0)
|
||||||
│ ├── scheduler.js # Cron job manager
|
│ ├── scheduler.js # Cron job manager
|
||||||
│ ├── audit.js # Activity trail
|
│ ├── audit.js # Activity trail
|
||||||
│ ├── cost.js # Cost analytics charts
|
│ ├── cost.js # Cost analytics charts
|
||||||
|
|
@ -162,22 +178,32 @@ agentic-os/
|
||||||
│ ├── prompts.js # Template library
|
│ ├── prompts.js # Template library
|
||||||
│ ├── standards.js # Code conventions
|
│ ├── standards.js # Code conventions
|
||||||
│ ├── settings.js # Config editor
|
│ ├── settings.js # Config editor
|
||||||
│ └── setup-wizard.js # Guided setup
|
│ ├── 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)
|
||||||
│
|
│
|
||||||
├── brain/ # Shared context (all agents read)
|
├── brain/ # Shared context (all agents read)
|
||||||
|
│ ├── memory_search.py # ▸ SQLite FTS5 search (v0.3.0)
|
||||||
│ ├── business-brain.md # Current project context
|
│ ├── business-brain.md # Current project context
|
||||||
│ ├── memory.md # Accumulated knowledge
|
│ ├── memory.md # Accumulated knowledge
|
||||||
│ ├── recent-decisions.md
|
│ ├── recent-decisions.md
|
||||||
│ ├── active-projects.md
|
│ ├── active-projects.md
|
||||||
│ ├── identity.md
|
│ ├── identity.md
|
||||||
│ └── constitution.md
|
│ ├── constitution.md
|
||||||
|
│ └── journal/ # Daily markdown entries (YYYY-MM-DD.md)
|
||||||
│
|
│
|
||||||
├── skills/ # 16 executable skills
|
├── skills/ # 15 executable skills
|
||||||
│ ├── devops-audit/ # GCP/CloudMart infra audit
|
│ ├── devops-audit/ # GCP/CloudMart infra audit
|
||||||
│ ├── heartbeat/ # 5-min health check
|
│ ├── heartbeat/ # 5-min health check
|
||||||
│ ├── content-draft/ # Blog/newsletter writing
|
│ ├── content-draft/ # Blog/newsletter writing
|
||||||
│ ├── code-review/ # PR review checklist
|
│ ├── code-review/ # PR review checklist
|
||||||
│ ├── research-synthesis/ # Gemini research aggregator
|
│ ├── research-synthesis/ # agy research aggregator
|
||||||
│ ├── daily-standup/ # Morning briefing
|
│ ├── daily-standup/ # Morning briefing
|
||||||
│ ├── meeting-minutes/ # Notes processor
|
│ ├── meeting-minutes/ # Notes processor
|
||||||
│ ├── project-planner/ # Step-by-step plans
|
│ ├── project-planner/ # Step-by-step plans
|
||||||
|
|
@ -190,18 +216,84 @@ agentic-os/
|
||||||
│ ├── goal-planner/ # Goal → steps
|
│ ├── goal-planner/ # Goal → steps
|
||||||
│ └── _template/ # Starter template
|
│ └── _template/ # Starter template
|
||||||
│
|
│
|
||||||
├── agents/ # Per-agent configs
|
├── agents/ # Per-agent configs (opencode, hermes, agy)
|
||||||
├── scheduler/jobs/ # Cron job definitions
|
├── scheduler/jobs/ # Cron job definitions
|
||||||
├── registry/ # Plugin marketplace
|
├── registry/ # Plugin marketplace
|
||||||
├── standards/ # Discover/inject conventions
|
├── standards/ # Discover/inject conventions
|
||||||
├── prompts/ # 10 reusable templates
|
├── prompts/ # 10 reusable templates
|
||||||
├── data/ # Runtime data (agent-routes.json tracked; settings/cost/chat gitignored)
|
├── data/ # Runtime data (agent-routes.json tracked; settings/cost/chat/error/scheduler/memory/circuit/kanban/uploads gitignored)
|
||||||
├── audit/ # Activity log (gitignored)
|
├── audit/ # Activity log (gitignored)
|
||||||
└── backups/ # Snapshots (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
|
## 🎮 Usage
|
||||||
|
|
||||||
### AI Chat
|
### AI Chat
|
||||||
|
|
@ -211,10 +303,10 @@ Select an agent from the sidebar → type your message → get response.
|
||||||
|-------|----------|---------|
|
|-------|----------|---------|
|
||||||
| **opencode** | "Check system status", "Deploy to GKE" | Code + terminal automation |
|
| **opencode** | "Check system status", "Deploy to GKE" | Code + terminal automation |
|
||||||
| **Hermes** | "What did I work on recently?", "Schedule a daily backup" | Memory recall, scheduling |
|
| **Hermes** | "What did I work on recently?", "Schedule a daily backup" | Memory recall, scheduling |
|
||||||
| **Gemini** | "Research latest AI agent trends", "Analyze this image" | Web research, multi-modal |
|
| **agy** | "Research latest AI agent trends", "Analyze this image" | Web research, multi-modal |
|
||||||
|
|
||||||
### Skills
|
### Skills
|
||||||
Browse 16 skills from Skills Hub → click Run → monitor eval scores over time.
|
Browse 15 skills from Skills Hub → click Run → monitor eval scores over time.
|
||||||
|
|
||||||
### Scheduler
|
### Scheduler
|
||||||
Create cron jobs: heartbeat (5 min), memory consolidation (weekly), daily standup, DevOps audit.
|
Create cron jobs: heartbeat (5 min), memory consolidation (weekly), daily standup, DevOps audit.
|
||||||
|
|
@ -222,17 +314,56 @@ Create cron jobs: heartbeat (5 min), memory consolidation (weekly), daily standu
|
||||||
### Cost Analytics
|
### Cost Analytics
|
||||||
Track spending per provider/model/agent. Free-tier alerts warn when nearing limits.
|
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)
|
## 📊 Comparison: Agentic OS vs Claude Agent OS (Julian Goldie)
|
||||||
|
|
||||||
| Feature | Claude Agent OS (Video) | Agentic OS (This Project) |
|
| Feature | Claude Agent OS (Video) | Agentic OS (This Project) |
|
||||||
|---------|------------------------|---------------------------|
|
|---------|------------------------|---------------------------|
|
||||||
| **Core Agents** | Claude + OpenClaw + Hermes | opencode + Hermes + Gemini CLI |
|
| **Core Agents** | Claude + OpenClaw + Hermes | opencode + Hermes + agy CLI |
|
||||||
| **Cost** | $20/mo (Claude subscription) | **$0 — all free tiers** |
|
| **Cost** | $20/mo (Claude subscription) | **$0 — all free tiers** |
|
||||||
| **Stack** | Next.js + Tailwind | FastAPI + vanilla JS SPA |
|
| **Stack** | Next.js + Tailwind | FastAPI + vanilla JS SPA |
|
||||||
| **Architecture** | 4 layers | **7 layers** |
|
| **Architecture** | 4 layers | **7 layers** |
|
||||||
| **Skills System** | Plugin marketplace (2,000+ from Hermes) | 16 curated skills + eval scoring + learnings |
|
| **Skills System** | Plugin marketplace (2,000+ from Hermes) | 15 curated skills + eval scoring + learnings |
|
||||||
| **Memory** | Obsidian vault (external) | Built-in brain/ + SQLite FTS5 |
|
| **Memory** | Obsidian vault (external) | Built-in brain/ + SQLite FTS5 |
|
||||||
| **Scheduler** | Not shown | APScheduler cron jobs |
|
| **Scheduler** | Not shown | APScheduler cron jobs |
|
||||||
| **Cost Tracking** | Not shown | Built-in per-provider analytics |
|
| **Cost Tracking** | Not shown | Built-in per-provider analytics |
|
||||||
|
|
@ -240,7 +371,7 @@ Track spending per provider/model/agent. Free-tier alerts warn when nearing limi
|
||||||
| **Audit Trail** | Not shown | Full activity log |
|
| **Audit Trail** | Not shown | Full activity log |
|
||||||
| **Standards System** | Not shown | Discover/inject conventions |
|
| **Standards System** | Not shown | Discover/inject conventions |
|
||||||
| **Client Timeout** | Not shown | 200s AbortController |
|
| **Client Timeout** | Not shown | 200s AbortController |
|
||||||
| **Kanban Board** | Yes | No (not needed for agent OS) |
|
| **Kanban Board** | Yes | **Yes — built-in** with drag-and-drop, priority, block/unblock, filters |
|
||||||
| **Open Source** | No (tutorial only) | **MIT License** |
|
| **Open Source** | No (tutorial only) | **MIT License** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -250,7 +381,7 @@ Track spending per provider/model/agent. Free-tier alerts warn when nearing limi
|
||||||
- **OS**: Linux (Ubuntu 22.04+), macOS
|
- **OS**: Linux (Ubuntu 22.04+), macOS
|
||||||
- **Python**: 3.10, 3.11, 3.12
|
- **Python**: 3.10, 3.11, 3.12
|
||||||
- **Browsers**: Chrome, Firefox, Edge
|
- **Browsers**: Chrome, Firefox, Edge
|
||||||
- **Agents**: opencode v0.8+, Hermes Agent v1.0+, Gemini CLI v1.0+
|
- **Agents**: opencode v0.8+, Hermes Agent v1.0+, agy CLI (Antigravity)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# 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"
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,18 @@
|
||||||
|
|
||||||
## Active Context
|
## Active Context
|
||||||
- Building Agentic OS (May 2026)
|
- Building Agentic OS (May 2026)
|
||||||
- 3-agent system: opencode + Hermes + Gemini CLI
|
- 3-agent system: opencode + Hermes + agy
|
||||||
- Web dashboard on FastAPI
|
- Web dashboard on FastAPI
|
||||||
- 51 features + 10 extras
|
- 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
|
||||||
|
|
||||||
## Skills Known
|
## Skills Known
|
||||||
- heartbeat: system health monitoring
|
- heartbeat: system health monitoring
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# Hermes Agent — SOUL.md
|
# Hermes Agent — SOUL.md
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Core Directives
|
## Core Directives
|
||||||
- Maintain MEMORY.md and USER.md with cross-session context
|
- Maintain MEMORY.md and USER.md with cross-session context
|
||||||
- Schedule and monitor recurring tasks via cron
|
- Schedule and monitor recurring tasks via cron
|
||||||
- Route coding tasks to opencode
|
- Route coding tasks to opencode
|
||||||
- Route research tasks to Gemini CLI
|
- Route research tasks to agy CLI
|
||||||
- Log all actions to audit/audit.log
|
- Log all actions to audit/audit.log
|
||||||
|
|
||||||
## Memory Configuration
|
## Memory Configuration
|
||||||
|
|
@ -16,6 +16,5 @@ You are the memory and scheduling subsystem of Agentic OS. You manage persistent
|
||||||
- Context files: AGENTS.md, CLAUDE.md, .cursorrules
|
- Context files: AGENTS.md, CLAUDE.md, .cursorrules
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
- Telegram: Configured via gateway
|
|
||||||
- Discord: Configured via gateway
|
|
||||||
- CLI: Default interaction mode
|
- CLI: Default interaction mode
|
||||||
|
- Gateway: Configured
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
# USER.md — User Profile for Hermes
|
# USER.md — User Profile for Hermes
|
||||||
|
|
||||||
## User
|
## User
|
||||||
- Name: Mihir N Modi
|
- Name: User
|
||||||
- Location: Gujarat, India
|
- Tech Stack: opencode, Hermes Agent, agy CLI, deepseek-v4-flash-free
|
||||||
- Tech Stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
|
- Projects: CloudMart (GCP DevOps), Agentic OS
|
||||||
- Projects: CloudMart (GCP DevOps), Agentic OS, AgriAssist AI
|
|
||||||
- Goals: AI/ML/LLMs + DevOps/Cloud → MNCs
|
|
||||||
- Budget: Free tiers only
|
- Budget: Free tiers only
|
||||||
|
|
||||||
## Preferences
|
## Preferences
|
||||||
|
|
@ -15,6 +13,5 @@
|
||||||
- Cost-conscious — always free tier first
|
- Cost-conscious — always free tier first
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
- Email: rinkumi0210@gmail.com
|
- Primary interaction: CLI (opencode/Hermes/agy)
|
||||||
- Primary interaction: CLI (opencode/Hermes/Gemini)
|
- Channels: CLI, messaging (Telegram)
|
||||||
- Channels: CLI, Telegram (configured)
|
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@ Code generation, DevOps, file operations, infrastructure-as-code, git management
|
||||||
- Check brain/recent-decisions.md for active context
|
- Check brain/recent-decisions.md for active context
|
||||||
- After each task: update learnings.md, append to audit/audit.log
|
- After each task: update learnings.md, append to audit/audit.log
|
||||||
- Run `git add -A && git commit -m "description"` after meaningful changes
|
- Run `git add -A && git commit -m "description"` after meaningful changes
|
||||||
- Route research tasks to Gemini CLI
|
- Route research tasks to agy CLI
|
||||||
- Route memory/channel tasks to Hermes Agent
|
- Route memory/channel tasks to Hermes Agent
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,3 @@
|
||||||
- Status: Active (ongoing)
|
- Status: Active (ongoing)
|
||||||
- GCP DevOps multi-region e-commerce platform
|
- GCP DevOps multi-region e-commerce platform
|
||||||
- GKE Autopilot, Cloud SQL, Cloud CDN, Istio, Next.js
|
- 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) —
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
- "Kernel of a system" mentality — everything has a purpose, nothing is decorative
|
||||||
|
|
||||||
## Key Relationships
|
## Key Relationships
|
||||||
- User: Mihir N Modi — BTech CSE 1st year, Gujarat, India
|
- User: Developer — AI/ML and DevOps enthusiast
|
||||||
- Tool stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
|
- Tool stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
|
||||||
- Active projects: CloudMart (GCP DevOps), AgriAssist AI, Java OOP, SEM-2 Academics
|
- Active projects: CloudMart (GCP DevOps), Agentic OS
|
||||||
|
|
||||||
## Standing Decisions
|
## Standing Decisions
|
||||||
- All memory is stored in markdown for cross-agent compatibility
|
- All memory is stored in markdown for cross-agent compatibility
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,9 @@
|
||||||
- Python 3.10+ required (FastAPI backend)
|
- Python 3.10+ required (FastAPI backend)
|
||||||
- Node.js 18+ required (opencode)
|
- Node.js 18+ required (opencode)
|
||||||
- Hermes Agent needs Python 3.11+ and Node.js
|
- Hermes Agent needs Python 3.11+ and Node.js
|
||||||
- Gemini CLI needs `gemini` binary installed
|
- agy CLI (Antigravity) needs `agy` binary installed — replaces the deprecated gemini CLI
|
||||||
- Linux environment (Ubuntu/WSL)
|
- Linux environment (Ubuntu/WSL)
|
||||||
- Dashboard binds to localhost only — no external exposure without explicit config
|
- Dashboard binds to localhost only — no external exposure without explicit config
|
||||||
|
|
||||||
## Time
|
## Time
|
||||||
- User is a BTech 1st year — study schedule takes priority
|
|
||||||
- Project builds during free time and weekends
|
- Project builds during free time and weekends
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,14 @@
|
||||||
- All skills follow _template/ convention
|
- All skills follow _template/ convention
|
||||||
|
|
||||||
## User
|
## User
|
||||||
- Name: Mihir N Modi
|
- Name: User
|
||||||
- Email: rinkumi0210@gmail.com
|
|
||||||
- Location: Gujarat, India
|
|
||||||
- Education: BTech CSE 1st year
|
|
||||||
- Career: AI/ML/LLMs + DevOps/Cloud → MNCs
|
|
||||||
- Budget: Strictly free tiers
|
- Budget: Strictly free tiers
|
||||||
|
|
||||||
## Active Work
|
## Active Work
|
||||||
- Building Agentic OS from scratch (May 17, 2026)
|
- Building Agentic OS from scratch (May 17, 2026)
|
||||||
- CloudMart GCP DevOps project ongoing
|
- CloudMart GCP DevOps project ongoing
|
||||||
- SEM-2 academics active (Physics, Maths-2, EGD, Industry 4.0, EVS)
|
|
||||||
|
|
||||||
## Technology Preferences
|
## Technology Preferences
|
||||||
- Preferred model: deepseek-v4-flash-free
|
- Preferred model: deepseek-v4-flash-free
|
||||||
- Free tiers: GCP Free, GitHub Student Dev Pack, Colab, Kaggle
|
- Free tiers: GCP Free, GitHub Student Dev Pack, Colab, Kaggle
|
||||||
- Obsidian vaults for knowledge management (4 vaults)
|
- Knowledge management via markdown vaults
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
"""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()
|
||||||
|
|
@ -51,7 +51,25 @@ const api = {
|
||||||
getStandards: () => api.get('/api/standards'),
|
getStandards: () => api.get('/api/standards'),
|
||||||
discoverStandards: () => api.post('/api/standards/discover'),
|
discoverStandards: () => api.post('/api/standards/discover'),
|
||||||
chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller),
|
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'),
|
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
|
// Kanban
|
||||||
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
|
getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'),
|
||||||
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),
|
getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`),
|
||||||
|
|
@ -89,4 +107,22 @@ const api = {
|
||||||
// Session Replay
|
// Session Replay
|
||||||
listSessions: () => api.get('/api/sessions/list'),
|
listSessions: () => api.get('/api/sessions/list'),
|
||||||
getSessionReplay: (id) => api.get(`/api/sessions/${encodeURIComponent(id)}/replay`),
|
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)}`),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
const pageCache = {};
|
const pageCache = {};
|
||||||
|
|
||||||
|
const APP_VERSION = '0.4.1';
|
||||||
|
|
||||||
const PAGE_BASE = '/dashboard/pages/';
|
const PAGE_BASE = '/dashboard/pages/';
|
||||||
|
|
||||||
async function loadPage(name) {
|
async function loadPage(name) {
|
||||||
|
|
@ -15,9 +17,10 @@ async function loadPage(name) {
|
||||||
|
|
||||||
function loadScript(src) {
|
function loadScript(src) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
|
const versioned = `${src}?v=${APP_VERSION}`;
|
||||||
|
if (document.querySelector(`script[src="${versioned}"]`)) { resolve(); return; }
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = src;
|
script.src = versioned;
|
||||||
script.onload = () => resolve();
|
script.onload = () => resolve();
|
||||||
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
|
|
@ -32,7 +35,7 @@ async function navigate(page) {
|
||||||
const bar = document.getElementById('topLoadingBar');
|
const bar = document.getElementById('topLoadingBar');
|
||||||
if (bar) { bar.classList.add('active'); bar.style.width = '30%'; }
|
if (bar) { bar.classList.add('active'); bar.style.width = '30%'; }
|
||||||
|
|
||||||
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
document.querySelectorAll('.nav-item, .bottom-nav-item').forEach(el => el.classList.remove('active'));
|
||||||
const navItem = document.querySelector(`[data-page="${hash}"]`);
|
const navItem = document.querySelector(`[data-page="${hash}"]`);
|
||||||
if (navItem) navItem.classList.add('active');
|
if (navItem) navItem.classList.add('active');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Agentic OS</title>
|
<title>Agentic OS</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<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 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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css?v=0.4.0">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="topLoadingBar"></div>
|
<div id="topLoadingBar"></div>
|
||||||
|
|
@ -33,6 +37,7 @@
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<div class="sidebar-section"><div class="sidebar-section-label">Primary</div></div>
|
<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="#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>
|
<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>
|
<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>
|
<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>
|
||||||
|
|
@ -48,6 +53,7 @@
|
||||||
<a href="#smart-router" class="nav-item" data-page="smart-router"><span class="nav-icon">🧭</span><span class="nav-label">Smart Router</span></a>
|
<a href="#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="#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="#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>
|
<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="#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>
|
<a href="#plugins" class="nav-item" data-page="plugins"><span class="nav-icon">🔌</span><span class="nav-label">Plugins</span></a>
|
||||||
|
|
@ -88,8 +94,31 @@
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
<div id="modalContainer"></div>
|
<div id="modalContainer"></div>
|
||||||
|
|
||||||
<script src="utils.js"></script>
|
<nav id="bottomNav" class="bottom-nav">
|
||||||
<script src="api.js"></script>
|
<a href="#chat" class="bottom-nav-item" data-page="chat"><span class="bottom-nav-icon">💬</span><span class="bottom-nav-label">Chat</span></a>
|
||||||
<script src="app.js"></script>
|
<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>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ async function refreshAgentHealth() {
|
||||||
const agents = data.agents || [];
|
const agents = data.agents || [];
|
||||||
const cards = document.getElementById('agentHealthCards');
|
const cards = document.getElementById('agentHealthCards');
|
||||||
if (!cards) return;
|
if (!cards) return;
|
||||||
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
|
const agentIcons = { opencode: '🔧', hermes: '⚡', agy: '🧠' };
|
||||||
const agentColors = { opencode: 'purple', hermes: 'green', gemini: 'blue' };
|
const agentColors = { opencode: 'purple', hermes: 'green', agy: 'blue' };
|
||||||
cards.innerHTML = agents.map(a => `
|
cards.innerHTML = agents.map(a => `
|
||||||
<div class="agent-health-card">
|
<div class="agent-health-card">
|
||||||
<div class="agent-health-avatar" style="background:var(--${agentColors[a.name] || 'accent'}-dim);color:var(--${agentColors[a.name] || 'accent'})">
|
<div class="agent-health-avatar" style="background:var(--${agentColors[a.name] || 'accent'}-dim);color:var(--${agentColors[a.name] || 'accent'})">
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ function applyAuditFilter() {
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-size:12px;white-space:nowrap">${formatDate(e.timestamp)}</td>
|
<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><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>${e.skill}</strong>` : ''}${e.file ? `File: ${e.file}` : ''}${e.job ? `Job: ${e.job}` : ''}${e.plugin ? `Plugin: ${e.plugin}` : ''}</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:11px;color:var(--text-muted);font-family:var(--font-mono)">${e.id || ''}</td>
|
<td style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">${e.id || ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@ async function renderBackups() {
|
||||||
<tbody>
|
<tbody>
|
||||||
${backups.map(b => `
|
${backups.map(b => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${b.name}</strong></td>
|
<td><strong>${escapeHtml(b.name)}</strong></td>
|
||||||
<td>${formatBytes(b.size)}</td>
|
<td>${formatBytes(b.size)}</td>
|
||||||
<td style="font-size:12px">${formatDate(b.created)}</td>
|
<td style="font-size:12px">${formatDate(b.created)}</td>
|
||||||
<td><button class="btn btn-sm btn-danger" onclick="restoreBackup('${b.name}')">Restore</button></td>
|
<td><button class="btn btn-sm btn-danger" onclick="restoreBackup('${encodeURIComponent(b.name)}')">Restore</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -53,19 +53,21 @@ async function createBackup() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreBackup(name) {
|
async function restoreBackup(encodedName) {
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
showModal('Restore Backup', `
|
showModal('Restore Backup', `
|
||||||
<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>
|
<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>
|
||||||
<div class="card" style="background:var(--red-dim);border-color:transparent">
|
<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 class="flex items-center gap-2"><span>⚠</span><span style="font-size:13px;font-weight:500">This action cannot be undone</span></div>
|
||||||
</div>
|
</div>
|
||||||
`, `
|
`, `
|
||||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||||
<button class="btn btn-danger" onclick="confirmRestore('${name}')">Restore</button>
|
<button class="btn btn-danger" onclick="confirmRestore('${encodeURIComponent(name)}')">Restore</button>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRestore(name) {
|
async function confirmRestore(encodedName) {
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
try {
|
try {
|
||||||
const r = await api.restoreBackup(name);
|
const r = await api.restoreBackup(name);
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ async function renderChat() {
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-header-left">
|
<div class="page-header-left">
|
||||||
<h1 class="page-title">AI Chat</h1>
|
<h1 class="page-title">AI Chat</h1>
|
||||||
<p class="page-subtitle">Talk to opencode, Hermes, and Gemini CLI</p>
|
<p class="page-subtitle">Talk to opencode, Hermes, and agy CLI</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn" onclick="clearChat()">🗑 Clear</button>
|
<button class="btn" onclick="clearChat()">🗑 Clear</button>
|
||||||
|
|
@ -28,10 +28,10 @@ async function renderChat() {
|
||||||
<div class="chat-agent-desc">Memory & Scheduling</div>
|
<div class="chat-agent-desc">Memory & Scheduling</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-agent" data-agent="gemini" onclick="selectAgent('gemini')">
|
<div class="chat-agent" data-agent="agy" onclick="selectAgent('agy')">
|
||||||
<div class="agent-dot offline"></div>
|
<div class="agent-dot offline"></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="chat-agent-name">Gemini CLI</div>
|
<div class="chat-agent-name">agy (Antigravity)</div>
|
||||||
<div class="chat-agent-desc">Research & Analysis</div>
|
<div class="chat-agent-desc">Research & Analysis</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -48,15 +48,20 @@ async function renderChat() {
|
||||||
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;justify-content:center">
|
<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('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('hermes','What did I work on recently?')">🧠 Recall Memory</button>
|
||||||
<button class="btn btn-sm" onclick="sendQuickPrompt('gemini','Research the latest trends in AI agents')">📊 Research</button>
|
<button class="btn btn-sm" onclick="sendQuickPrompt('agy','Research the latest trends in AI agents')">📊 Research</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
<div class="chat-agent-indicator" id="chatAgentIndicator">opencode</div>
|
<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>
|
<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>
|
<button class="btn btn-primary btn-icon" onclick="sendChatMessage()" id="chatSendBtn" title="Send">➤</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -116,29 +121,38 @@ function autoResizeTextarea(el) {
|
||||||
async function sendChatMessage() {
|
async function sendChatMessage() {
|
||||||
const input = document.getElementById('chatInput');
|
const input = document.getElementById('chatInput');
|
||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
if (!message) return;
|
const fileInput = document.getElementById('chatFileInput');
|
||||||
|
const file = fileInput && fileInput.files && fileInput.files[0];
|
||||||
|
if (!message && !file) return;
|
||||||
|
|
||||||
const agent = window._currentAgent || 'opencode';
|
const agent = window._currentAgent || 'opencode';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.style.height = 'auto';
|
input.style.height = 'auto';
|
||||||
|
|
||||||
// Add user message to chat
|
// Add user message to chat
|
||||||
addChatMessage('user', message, agent);
|
addChatMessage('user', message || `📎 ${file.name}`, agent);
|
||||||
|
|
||||||
// Show typing indicator
|
// Show typing indicator
|
||||||
const typingId = showTypingIndicator(agent);
|
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 {
|
try {
|
||||||
// Client-side timeout: 200s (slightly more than Hermes' 180s backend timeout)
|
let r;
|
||||||
const controller = new AbortController();
|
if (file) {
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 200000);
|
r = await api.chatWithFile(agent, message, file, controller);
|
||||||
const r = await api.chat(agent, message, controller);
|
clearChatAttachment();
|
||||||
|
} else {
|
||||||
|
r = await api.chat(agent, message, controller);
|
||||||
|
}
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
removeTypingIndicator(typingId);
|
removeTypingIndicator(typingId);
|
||||||
addChatMessage('assistant', r.response.content, agent);
|
addChatMessage('assistant', r.response.content, agent);
|
||||||
|
|
||||||
// Store in local history
|
// Store in local history
|
||||||
window._chatHistory.push({ role: 'user', content: message, agent });
|
window._chatHistory.push({ role: 'user', content: message || `📎 ${file.name}`, agent });
|
||||||
window._chatHistory.push({ role: 'assistant', content: r.response.content, agent });
|
window._chatHistory.push({ role: 'assistant', content: r.response.content, agent });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
removeTypingIndicator(typingId);
|
removeTypingIndicator(typingId);
|
||||||
|
|
@ -147,6 +161,25 @@ 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) {
|
function addChatMessage(role, content, agent) {
|
||||||
const container = document.getElementById('chatMessages');
|
const container = document.getElementById('chatMessages');
|
||||||
const welcome = container.querySelector('.chat-welcome');
|
const welcome = container.querySelector('.chat-welcome');
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ async function renderCost() {
|
||||||
${entries.slice(-20).reverse().map(e => `
|
${entries.slice(-20).reverse().map(e => `
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-size:12px">${formatDate(e.timestamp)}</td>
|
<td style="font-size:12px">${formatDate(e.timestamp)}</td>
|
||||||
<td><span class="badge badge-accent">${e.agent}</span></td>
|
<td><span class="badge badge-accent">${escapeHtml(e.agent)}</span></td>
|
||||||
<td style="font-size:12px">${e.model}</td>
|
<td style="font-size:12px">${escapeHtml(e.model)}</td>
|
||||||
<td>${(e.tokens || 0).toLocaleString()}</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>
|
<td><span class="badge ${(e.cost || 0) > 0 ? 'badge-warning' : 'badge-success'}">$${(e.cost || 0).toFixed(6)}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -80,6 +80,16 @@ 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
|
// Build agent chart
|
||||||
const agentTotals = {};
|
const agentTotals = {};
|
||||||
entries.forEach(e => {
|
entries.forEach(e => {
|
||||||
|
|
@ -115,11 +125,22 @@ 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() {
|
async function recordTestCost() {
|
||||||
showModal('Record Cost Entry', `
|
showModal('Record Cost Entry', `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Agent</label>
|
<label class="form-label">Agent</label>
|
||||||
<select id="rcAgent" class="form-select"><option>opencode</option><option>hermes</option><option>gemini</option></select>
|
<select id="rcAgent" class="form-select"><option>opencode</option><option>hermes</option><option>agy</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Model</label>
|
<label class="form-label">Model</label>
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ async function renderDashboard() {
|
||||||
<div class="event-item">
|
<div class="event-item">
|
||||||
<div class="event-dot" style="background:${e.action === 'skill_run' ? 'var(--accent)' : 'var(--blue)'}"></div>
|
<div class="event-dot" style="background:${e.action === 'skill_run' ? 'var(--accent)' : 'var(--blue)'}"></div>
|
||||||
<div class="event-content">
|
<div class="event-content">
|
||||||
<div class="event-title">${e.action}${e.skill ? `: ${e.skill}` : ''}</div>
|
<div class="event-title">${escapeHtml(e.action)}${e.skill ? `: ${escapeHtml(e.skill)}` : ''}</div>
|
||||||
<div class="event-meta">${e.agent ? `via ${e.agent}` : ''} ${e.run_id ? `#${e.run_id}` : ''}</div>
|
<div class="event-meta">${e.agent ? `via ${e.agent}` : ''} ${e.run_id ? `#${e.run_id}` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-time">${timeAgo(e.timestamp)}</div>
|
<div class="event-time">${timeAgo(e.timestamp)}</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
async function renderErrors() {
|
||||||
|
const content = document.getElementById('pageContent');
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header-left">
|
||||||
|
<div class="page-title">Error Dashboard</div>
|
||||||
|
<div class="page-subtitle">Track and manage system errors across all agents</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-ghost" onclick="refreshErrors()">🔄 Refresh</button>
|
||||||
|
<button class="btn btn-danger" onclick="clearAllErrors()">🗑 Clear All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mb-3" style="flex-wrap:wrap">
|
||||||
|
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||||
|
<div class="metric-tile-value" id="errorTotalCount">0</div>
|
||||||
|
<div class="metric-tile-label">Total Errors</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||||
|
<div class="metric-tile-value" id="errorAgentCount">0</div>
|
||||||
|
<div class="metric-tile-label">Agent Errors</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||||
|
<div class="metric-tile-value" id="errorSkillCount">0</div>
|
||||||
|
<div class="metric-tile-label">Skill Errors</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-tile" style="flex:1;min-width:120px">
|
||||||
|
<div class="metric-tile-value" id="errorCircuitCount">0</div>
|
||||||
|
<div class="metric-tile-label">Circuit Breaks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mb-3" style="flex-wrap:wrap">
|
||||||
|
<select id="errorCategoryFilter" class="form-select" style="width:160px" onchange="refreshErrors()">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<option value="agent">Agent</option>
|
||||||
|
<option value="skill">Skill</option>
|
||||||
|
<option value="api">API</option>
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="general">General</option>
|
||||||
|
</select>
|
||||||
|
<span id="errorCountBadge" class="badge badge-danger" style="display:none"></span>
|
||||||
|
</div>
|
||||||
|
<div id="errorList"><div class="loading"><div class="loading-spinner"></div></div></div>
|
||||||
|
<div class="section-title mt-4">Circuit Breaker Status</div>
|
||||||
|
<div id="circuitBreakerCards" class="grid grid-3"></div>
|
||||||
|
`;
|
||||||
|
await Promise.all([refreshErrors(), loadCircuitBreaker()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshErrors() {
|
||||||
|
const container = document.getElementById('errorList');
|
||||||
|
if (!container) return;
|
||||||
|
const category = document.getElementById('errorCategoryFilter')?.value || '';
|
||||||
|
try {
|
||||||
|
const data = await api.getErrors(100, category);
|
||||||
|
const errors = data.errors || [];
|
||||||
|
const totalEl = document.getElementById('errorTotalCount');
|
||||||
|
if (totalEl) totalEl.textContent = errors.length;
|
||||||
|
const agentErrors = errors.filter(e => e.category === 'agent').length;
|
||||||
|
const skillErrors = errors.filter(e => e.category === 'skill').length;
|
||||||
|
const circuitErrors = errors.filter(e => e.category === 'circuit').length;
|
||||||
|
const aEl = document.getElementById('errorAgentCount');
|
||||||
|
if (aEl) aEl.textContent = agentErrors;
|
||||||
|
const sEl = document.getElementById('errorSkillCount');
|
||||||
|
if (sEl) sEl.textContent = skillErrors;
|
||||||
|
const cEl = document.getElementById('errorCircuitCount');
|
||||||
|
if (cEl) cEl.textContent = circuitErrors;
|
||||||
|
const badge = document.getElementById('errorCountBadge');
|
||||||
|
if (badge) {
|
||||||
|
if (errors.length > 0) { badge.style.display = 'inline'; badge.textContent = errors.length + ' issues'; }
|
||||||
|
else { badge.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
if (errors.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">✅</div><div class="empty-state-title">No errors</div><div class="empty-state-desc">System is running smoothly</div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>Category</th><th>Source</th><th>Message</th><th>ID</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${errors.slice().reverse().map(e => `
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px;white-space:nowrap">${formatDate(e.timestamp)}</td>
|
||||||
|
<td><span class="badge ${e.category === 'agent' ? 'badge-danger' : e.category === 'skill' ? 'badge-warning' : e.category === 'api' ? 'badge-info' : 'badge'}">${escapeHtml(e.category)}</span></td>
|
||||||
|
<td style="font-size:13px"><strong>${escapeHtml(e.source)}</strong></td>
|
||||||
|
<td style="font-size:13px">${escapeHtml(e.message)}</td>
|
||||||
|
<td style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">${e.id || ''}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);text-align:right;margin-top:8px">${errors.length} error${errors.length !== 1 ? 's' : ''}</div>
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
if (container) container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAllErrors() {
|
||||||
|
if (!confirm('Clear all error logs?')) return;
|
||||||
|
try {
|
||||||
|
await api.clearErrors();
|
||||||
|
showToast('Error log cleared', 'success');
|
||||||
|
refreshErrors();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Error: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCircuitBreaker() {
|
||||||
|
const container = document.getElementById('circuitBreakerCards');
|
||||||
|
if (!container) return;
|
||||||
|
try {
|
||||||
|
const data = await api.getCircuitBreaker();
|
||||||
|
const agents = data.agents || {};
|
||||||
|
const agentNames = Object.keys(agents);
|
||||||
|
if (agentNames.length === 0) {
|
||||||
|
container.innerHTML = '<div style="grid-column:1/-1"><div class="empty-state" style="padding:20px"><div class="empty-state-icon">🔌</div><div class="empty-state-title">No circuit breaker data</div></div></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agentIcons = { opencode: '🔧', hermes: '⚡', 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -7,36 +7,175 @@ async function renderMemory() {
|
||||||
<p class="page-subtitle">Shared brain context across all agents</p>
|
<p class="page-subtitle">Shared brain context across all agents</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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 {
|
try {
|
||||||
const brain = await api.getBrain();
|
const brain = await api.getBrain();
|
||||||
const files = Object.entries(brain);
|
const files = Object.entries(brain);
|
||||||
const container = document.getElementById('memoryList');
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
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>';
|
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🧠</div><div class="empty-state-title">No memory files</div></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `<div style="display:grid;gap:12px">${files.map(([name, content]) => {
|
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]) => {
|
||||||
const preview = content ? content.slice(0, 200) : '';
|
const preview = content ? content.slice(0, 200) : '';
|
||||||
return `<div class="card" style="cursor:pointer" onclick="editMemory('${name}')">
|
const safeName = escapeHtml(name.replace('.md', '').replace(/-/g, ' '));
|
||||||
|
return `<div class="card" style="cursor:pointer;min-width:0" onclick="editMemory('${encodeURIComponent(name)}')">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div><span class="card-title">${name.replace('.md', '').replace(/-/g, ' ')}</span></div>
|
<div><span class="card-title">${safeName}</span></div>
|
||||||
<span class="badge badge-info">${content ? content.split('\n').length : 0} lines</span>
|
<span class="badge badge-info">${content ? content.split('\n').length : 0} lines</span>
|
||||||
</div>
|
</div>
|
||||||
<pre style="max-height:80px;overflow:hidden;font-size:11px;color:var(--text-muted)">${escapeHtml(preview)}${preview.length >= 200 ? '...' : ''}</pre>
|
<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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')}</div>`;
|
}).join('')}</div>`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('memoryList').innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
|
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editMemory(name) {
|
async function loadMemoryGraph() {
|
||||||
const display = name.replace('.md', '').replace(/-/g, ' ');
|
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, ' '));
|
||||||
let content = '';
|
let content = '';
|
||||||
try {
|
try {
|
||||||
const r = await api.getBrainFile(name);
|
const r = await api.getBrainFile(name);
|
||||||
|
|
@ -50,11 +189,12 @@ async function editMemory(name) {
|
||||||
</div>
|
</div>
|
||||||
`, `
|
`, `
|
||||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||||
<button class="btn btn-primary" onclick="saveMemory('${name}')">💾 Save</button>
|
<button class="btn btn-primary" onclick="saveMemory('${encodeURIComponent(name)}')">💾 Save</button>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveMemory(name) {
|
async function saveMemory(encodedName) {
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
const content = document.getElementById('memContent').value;
|
const content = document.getElementById('memContent').value;
|
||||||
try {
|
try {
|
||||||
await api.updateBrainFile(name, content);
|
await api.updateBrainFile(name, content);
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ async function renderPlugins() {
|
||||||
<tbody>
|
<tbody>
|
||||||
${plugins.map(p => `
|
${plugins.map(p => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${p.name}</strong></td>
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
<td><code>${p.version || '1.0.0'}</code></td>
|
<td><code>${escapeHtml(p.version || '1.0.0')}</code></td>
|
||||||
<td><span class="badge badge-info">${p.type || 'skill'}</span></td>
|
<td><span class="badge badge-info">${escapeHtml(p.type || 'skill')}</span></td>
|
||||||
<td style="font-size:12px;color:var(--text-muted)">${formatDate(p.installed)}</td>
|
<td style="font-size:12px;color:var(--text-muted)">${formatDate(p.installed)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,10 @@ async function renderPrompts() {
|
||||||
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
const preview = content.slice(0, 180);
|
const preview = content.slice(0, 180);
|
||||||
const lines = content.split('\n').length;
|
const lines = content.split('\n').length;
|
||||||
return `<div class="skill-card" onclick="viewPrompt('${name}')">
|
return `<div class="skill-card" onclick="viewPrompt('${encodeURIComponent(name)}')">
|
||||||
<div class="skill-card-header">
|
<div class="skill-card-header">
|
||||||
<div class="skill-card-icon">📝</div>
|
<div class="skill-card-icon">📝</div>
|
||||||
<div class="skill-card-name">${displayName}</div>
|
<div class="skill-card-name">${escapeHtml(displayName)}</div>
|
||||||
</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-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>
|
<div class="skill-card-footer"><span class="badge badge-info">${lines} lines</span></div>
|
||||||
|
|
@ -38,14 +38,15 @@ async function renderPrompts() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewPrompt(name) {
|
async function viewPrompt(encodedName) {
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
let content = '';
|
let content = '';
|
||||||
try {
|
try {
|
||||||
const prompts = await api.getPrompts();
|
const prompts = await api.getPrompts();
|
||||||
content = prompts[name] || '';
|
content = prompts[name] || '';
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
const displayName = escapeHtml(name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()));
|
||||||
|
|
||||||
// Store raw content for clipboard copy (avoids HTML entity encoding issue)
|
// Store raw content for clipboard copy (avoids HTML entity encoding issue)
|
||||||
window._promptCopyContent = content;
|
window._promptCopyContent = content;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ async function renderSettings() {
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title">🤖 Agent Preferences</span></div>
|
<div class="card-header"><span class="card-title">🤖 Agent Preferences</span></div>
|
||||||
<div class="grid grid-3">
|
<div class="grid grid-3">
|
||||||
${['opencode', 'hermes', 'gemini'].map(a => `
|
${['opencode', 'hermes', 'agy'].map(a => `
|
||||||
<div class="card" style="padding:14px">
|
<div class="card" style="padding:14px">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<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>
|
<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>
|
</label>
|
||||||
<div class="form-group" style="margin-bottom:0;margin-top:8px">
|
<div class="form-group" style="margin-bottom:0;margin-top:8px">
|
||||||
<label class="form-label">Binary Path</label>
|
<label class="form-label">Binary Path</label>
|
||||||
<input id="bin_${a}" class="form-input" value="${(prefs[a] && prefs[a].binary) || a}" style="font-size:12px">
|
<input id="bin_${a}" class="form-input" value="${escapeHtml((prefs[a] && prefs[a].binary) || a)}" style="font-size:12px">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
|
|
@ -50,7 +50,7 @@ async function renderSettings() {
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Host</label>
|
<label class="form-label">Host</label>
|
||||||
<input id="setHost" class="form-input" value="${dashboard.host || '127.0.0.1'}">
|
<input id="setHost" class="form-input" value="${escapeHtml(dashboard.host || '127.0.0.1')}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -67,11 +67,11 @@ async function renderSettings() {
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Gemini API Key</label>
|
<label class="form-label">Gemini API Key</label>
|
||||||
<input id="keyGemini" class="form-input" type="password" value="${apiKeys.gemini || ''}" placeholder="Enter Gemini API key">
|
<input id="keyGemini" class="form-input" type="password" value="${escapeHtml(apiKeys.gemini || '')}" placeholder="Enter Gemini API key">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">OpenRouter API Key</label>
|
<label class="form-label">OpenRouter API Key</label>
|
||||||
<input id="keyOpenrouter" class="form-input" type="password" value="${apiKeys.openrouter || ''}" placeholder="Enter OpenRouter API key">
|
<input id="keyOpenrouter" class="form-input" type="password" value="${escapeHtml(apiKeys.openrouter || '')}" placeholder="Enter OpenRouter API key">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -124,7 +124,7 @@ async function saveAllSettings() {
|
||||||
agent_preferences: {
|
agent_preferences: {
|
||||||
opencode: { enabled: document.getElementById('agent_opencode').checked, binary: document.getElementById('bin_opencode').value },
|
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 },
|
hermes: { enabled: document.getElementById('agent_hermes').checked, binary: document.getElementById('bin_hermes').value },
|
||||||
gemini: { enabled: document.getElementById('agent_gemini').checked, binary: document.getElementById('bin_gemini').value },
|
agy: { enabled: document.getElementById('agent_agy').checked, binary: document.getElementById('bin_agy').value },
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
port: parseInt(document.getElementById('setPort').value) || 8080,
|
port: parseInt(document.getElementById('setPort').value) || 8080,
|
||||||
|
|
@ -167,7 +167,7 @@ async function resetSettings() {
|
||||||
async function confirmReset() {
|
async function confirmReset() {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
agent_preferences: { opencode: { enabled: true, binary: 'opencode' }, hermes: { enabled: true, binary: 'hermes' }, gemini: { enabled: true, binary: 'gemini', model: 'gemini-2.5-flash' } },
|
agent_preferences: { opencode: { enabled: true, binary: 'opencode' }, hermes: { enabled: true, binary: 'hermes' }, agy: { enabled: true, binary: 'agy' } },
|
||||||
dashboard: { port: 8080, host: '127.0.0.1', dark_mode: true },
|
dashboard: { port: 8080, host: '127.0.0.1', dark_mode: true },
|
||||||
api_keys: { gemini: '', openrouter: '' },
|
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 } },
|
free_tier_limits: { gemini_flash: { requests_per_day: 1500, tokens_per_day: 1000000 }, openrouter_free: { requests_per_day: 100, tokens_per_day: 200000 } },
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ async function renderWizardStep() {
|
||||||
wc.innerHTML = `
|
wc.innerHTML = `
|
||||||
<div style="text-align:center;padding:12px 0">
|
<div style="text-align:center;padding:12px 0">
|
||||||
<div style="font-size:48px;margin-bottom:16px">⬡</div>
|
<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>Gemini CLI</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>agy</strong> into a unified multi-agent orchestration platform. This wizard will help you get everything configured.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
|
|
@ -65,11 +65,12 @@ async function renderWizardStep() {
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
${agents.map(a => {
|
${agents.map(a => {
|
||||||
const sc = statusColor(a.status);
|
const sc = statusColor(a.status);
|
||||||
|
const safeStatus = ({online:'online',offline:'offline',warning:'warning'})[a.status] || 'offline';
|
||||||
return `<div class="agent-card">
|
return `<div class="agent-card">
|
||||||
<div class="agent-dot ${a.status}" style="width:14px;height:14px"></div>
|
<div class="agent-dot ${safeStatus}" style="width:14px;height:14px"></div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600;font-size:14px">${a.name}</div>
|
<div style="font-weight:600;font-size:14px">${escapeHtml(a.name)}</div>
|
||||||
<div style="font-size:12px;color:${sc.text}">${a.status}</div>
|
<div style="font-size:12px;color:${sc.text}">${escapeHtml(a.status)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
|
@ -88,7 +89,7 @@ async function renderWizardStep() {
|
||||||
<select id="wizDefault" class="form-select">
|
<select id="wizDefault" class="form-select">
|
||||||
<option value="opencode">opencode — Best for code/DevOps</option>
|
<option value="opencode">opencode — Best for code/DevOps</option>
|
||||||
<option value="hermes">Hermes — Best for memory/scheduling</option>
|
<option value="hermes">Hermes — Best for memory/scheduling</option>
|
||||||
<option value="gemini">Gemini CLI — Best for research</option>
|
<option value="agy">agy — Best for research</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -33,22 +33,24 @@ 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>';
|
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">⚡</div><div class="empty-state-title">No skills installed</div></div>';
|
||||||
return;
|
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 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 avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
|
||||||
const icons = ['⚡', '🔧', '📝', '🔍', '🔄', '🎯', '📊', '🛠', '💡', '🧪', '📋', '💾', '💰', '🔄', '🎨'];
|
const icons = ['⚡', '🔧', '📝', '🔍', '🔄', '🎯', '📊', '🛠', '💡', '🧪', '📋', '💾', '💰', '🔄', '🎨'];
|
||||||
const iconIdx = s.name.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % icons.length;
|
const iconIdx = s.name.split('').reduce((a, c) => a + c.charCodeAt(0), 0) % icons.length;
|
||||||
const icon = icons[iconIdx];
|
const icon = icons[iconIdx];
|
||||||
return `<div class="skill-card" onclick="showSkillDetail('${s.name}')">
|
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)}')">
|
||||||
<div class="skill-card-header">
|
<div class="skill-card-header">
|
||||||
<div class="skill-card-icon">${icon}</div>
|
<div class="skill-card-icon">${icon}</div>
|
||||||
<div class="skill-card-name">${s.name.replace(/-/g, ' ')}</div>
|
<div class="skill-card-name">${sName.replace(/-/g, ' ')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="skill-card-desc">${s.description ? s.description.slice(0, 120) + (s.description.length > 120 ? '...' : '') : 'No description'}</div>
|
<div class="skill-card-desc">${sDesc || 'No description'}</div>
|
||||||
<div class="skill-card-footer">
|
<div class="skill-card-footer">
|
||||||
${avg !== null ? `<span class="badge badge-success">${(avg * 100).toFixed(0)}%</span>` : '<span class="badge badge-info">New</span>'}
|
${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>' : ''}
|
${s.has_learnings ? '<span class="badge badge-accent">📖</span>' : ''}
|
||||||
<button class="btn btn-sm btn-primary" style="margin-left:auto" onclick="event.stopPropagation();quickRunSkill('${s.name}')">▶ Run</button>
|
<button class="btn btn-sm btn-primary" style="margin-left:auto" onclick="event.stopPropagation();quickRunSkill('${encodeURIComponent(s.name)}')">▶ Run</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')}</div>`;
|
}).join('')}</div>`;
|
||||||
|
|
@ -61,11 +63,12 @@ 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 => {
|
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 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 avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null;
|
||||||
return `<tr onclick="showSkillDetail('${s.name}')" style="cursor:pointer">
|
const sName = escapeHtml(s.name);
|
||||||
<td><strong>${s.name.replace(/-/g, ' ')}</strong></td>
|
return `<tr onclick="showSkillDetail('${encodeURIComponent(s.name)}')" style="cursor:pointer">
|
||||||
|
<td><strong>${sName.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>${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>${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('${s.name}')">▶</button></td>
|
<td><button class="btn btn-sm btn-primary" onclick="event.stopPropagation();quickRunSkill('${encodeURIComponent(s.name)}')">▶</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('')}</tbody></table></div>`;
|
}).join('')}</tbody></table></div>`;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -79,7 +82,8 @@ function filterSkills() {
|
||||||
renderSkillGrid(skills);
|
renderSkillGrid(skills);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSkillDetail(name) {
|
async function showSkillDetail(encodedName) {
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
document.getElementById('skillsContainer').style.display = 'none';
|
document.getElementById('skillsContainer').style.display = 'none';
|
||||||
document.getElementById('skillTabs').style.display = 'none';
|
document.getElementById('skillTabs').style.display = 'none';
|
||||||
document.getElementById('skillFilter').style.display = 'none';
|
document.getElementById('skillFilter').style.display = 'none';
|
||||||
|
|
@ -92,11 +96,13 @@ async function showSkillDetail(name) {
|
||||||
const scores = skill.score_history || [];
|
const scores = skill.score_history || [];
|
||||||
const lastScore = scores.length > 0 ? scores[scores.length - 1] : null;
|
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 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 = `
|
detail.innerHTML = `
|
||||||
<div style="margin-bottom:16px">
|
<div style="margin-bottom:16px">
|
||||||
<button class="btn btn-ghost" onclick="backToSkills()">← Back to Skills</button>
|
<button class="btn btn-ghost" onclick="backToSkills()">← Back to Skills</button>
|
||||||
<button class="btn btn-primary" style="margin-left:8px" onclick="quickRunSkill('${name}')">▶ Run ${name.replace(/-/g, ' ')}</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>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-2">
|
<div class="grid grid-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -122,9 +128,9 @@ async function showSkillDetail(name) {
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title">📁 Context Files</span></div>
|
<div class="card-header"><span class="card-title">📁 Context Files</span></div>
|
||||||
${skill.context && skill.context.length > 0
|
${skill.context && skill.context.length > 0
|
||||||
? `<div style="display:flex;flex-wrap:wrap;gap:6px">${skill.context.map(f => `<span class="badge badge-info">${f}</span>`).join('')}</div>`
|
? `<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="color:var(--text-muted);font-size:13px">No context files</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">${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">${escapeHtml(c)}</span>`).join('')}</div></div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -140,8 +146,9 @@ function backToSkills() {
|
||||||
document.getElementById('skillDetail').style.display = 'none';
|
document.getElementById('skillDetail').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function quickRunSkill(name) {
|
async function quickRunSkill(encodedName) {
|
||||||
const displayName = name.replace(/-/g, ' ');
|
const name = decodeURIComponent(encodedName);
|
||||||
|
const displayName = escapeHtml(name.replace(/-/g, ' '));
|
||||||
showModal(`Run: ${displayName}`, `
|
showModal(`Run: ${displayName}`, `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Input (optional)</label>
|
<label class="form-label">Input (optional)</label>
|
||||||
|
|
@ -153,17 +160,18 @@ async function quickRunSkill(name) {
|
||||||
<option value="auto">Auto-detect</option>
|
<option value="auto">Auto-detect</option>
|
||||||
<option value="opencode">opencode</option>
|
<option value="opencode">opencode</option>
|
||||||
<option value="hermes">Hermes</option>
|
<option value="hermes">Hermes</option>
|
||||||
<option value="gemini">Gemini CLI</option>
|
<option value="agy">agy (Antigravity)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="skillResult" style="display:none"></div>
|
<div id="skillResult" style="display:none"></div>
|
||||||
`, `
|
`, `
|
||||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||||
<button class="btn btn-primary" onclick="executeSkillRun('${name}')">▶ Run</button>
|
<button class="btn btn-primary" onclick="executeSkillRun('${encodeURIComponent(name)}')">▶ Run</button>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeSkillRun(name) {
|
async function executeSkillRun(encodedName) {
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
const input = document.getElementById('qrsInput').value;
|
const input = document.getElementById('qrsInput').value;
|
||||||
const agent = document.getElementById('qrsAgent').value;
|
const agent = document.getElementById('qrsAgent').value;
|
||||||
const runBtn = document.querySelector('#modalContainer .btn-primary');
|
const runBtn = document.querySelector('#modalContainer .btn-primary');
|
||||||
|
|
@ -195,3 +203,37 @@ async function executeSkillRun(name) {
|
||||||
if (runBtn) { runBtn.textContent = '▶ Run'; runBtn.disabled = false; }
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ async function renderSmartRouter() {
|
||||||
<option value="auto">🤖 Auto (AI suggests)</option>
|
<option value="auto">🤖 Auto (AI suggests)</option>
|
||||||
<option value="opencode">🔧 opencode (Code/DevOps)</option>
|
<option value="opencode">🔧 opencode (Code/DevOps)</option>
|
||||||
<option value="hermes">⚡ Hermes (Memory/Scheduling)</option>
|
<option value="hermes">⚡ Hermes (Memory/Scheduling)</option>
|
||||||
<option value="gemini">🧠 Gemini CLI (Research/Analysis)</option>
|
<option value="agy">🧠 agy (Research/Analysis)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="suggestRouter()" style="margin-bottom:16px">🤖 Suggest Agent</button>
|
<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><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>🔧 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>⚡ 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>🧠 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>
|
<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>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -50,7 +50,7 @@ async function suggestRouter() {
|
||||||
try {
|
try {
|
||||||
const data = await api.suggestRouter(task);
|
const data = await api.suggestRouter(task);
|
||||||
const result = document.getElementById('routerResult');
|
const result = document.getElementById('routerResult');
|
||||||
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
|
const agentIcons = { opencode: '🔧', hermes: '⚡', agy: '🧠' };
|
||||||
const confidenceColors = { high: 'var(--green)', medium: 'var(--yellow)', low: 'var(--text-muted)' };
|
const confidenceColors = { high: 'var(--green)', medium: 'var(--yellow)', low: 'var(--text-muted)' };
|
||||||
result.innerHTML = `
|
result.innerHTML = `
|
||||||
<div class="card" style="border-color:${confidenceColors[data.confidence] || 'var(--border)'};margin-bottom:12px">
|
<div class="card" style="border-color:${confidenceColors[data.confidence] || 'var(--border)'};margin-bottom:12px">
|
||||||
|
|
|
||||||
|
|
@ -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>';
|
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 {
|
} else {
|
||||||
html += `<div class="grid grid-2">${standards.map(s => `
|
html += `<div class="grid grid-2">${standards.map(s => `
|
||||||
<div class="card" style="cursor:pointer" onclick="viewStandard('${s.name}')">
|
<div class="card" style="cursor:pointer" onclick="viewStandard('${encodeURIComponent(s.name)}')">
|
||||||
<div class="card-header"><span class="card-title">${s.name.replace(/-/g, ' ')}</span></div>
|
<div class="card-header"><span class="card-title">${escapeHtml(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>
|
<pre style="max-height:200px;overflow:hidden;font-size:12px">${escapeHtml(s.content.slice(0, 300))}${s.content.length > 300 ? '...' : ''}</pre>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}</div>`;
|
`).join('')}</div>`;
|
||||||
|
|
@ -40,7 +40,8 @@ async function renderStandards() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewStandard(name) {
|
async function viewStandard(encodedName) {
|
||||||
|
const name = decodeURIComponent(encodedName);
|
||||||
let content = '';
|
let content = '';
|
||||||
try {
|
try {
|
||||||
const data = await api.getStandards();
|
const data = await api.getStandards();
|
||||||
|
|
@ -48,7 +49,7 @@ async function viewStandard(name) {
|
||||||
if (std) content = std.content;
|
if (std) content = std.content;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
showModal(`Standard: ${name.replace(/-/g, ' ')}`, `
|
showModal(`Standard: ${escapeHtml(name.replace(/-/g, ' '))}`, `
|
||||||
<pre style="white-space:pre-wrap;font-size:12px;max-height:60vh;overflow:auto">${escapeHtml(content)}</pre>
|
<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>
|
<button class="btn btn-ghost" onclick="closeModal()">Close</button>
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ body {
|
||||||
padding: 12px 28px; border-bottom: 1px solid var(--border);
|
padding: 12px 28px; border-bottom: 1px solid var(--border);
|
||||||
min-height: 56px; background: var(--bg-secondary);
|
min-height: 56px; background: var(--bg-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 100%; max-width: 1600px; margin: 0 auto;
|
||||||
}
|
}
|
||||||
.topbar-left { display: flex; align-items: center; gap: 12px; }
|
.topbar-left { display: flex; align-items: center; gap: 12px; }
|
||||||
.topbar-title { font-size: 16px; font-weight: 600; }
|
.topbar-title { font-size: 16px; font-weight: 600; }
|
||||||
|
|
@ -237,6 +238,7 @@ body {
|
||||||
flex: 1; display: flex; flex-direction: column;
|
flex: 1; display: flex; flex-direction: column;
|
||||||
overflow-y: auto; padding: 24px 28px;
|
overflow-y: auto; padding: 24px 28px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
width: 100%; max-width: 1600px; margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Page Header ─── */
|
/* ─── Page Header ─── */
|
||||||
|
|
@ -543,7 +545,7 @@ pre {
|
||||||
/* ─── Tabs ─── */
|
/* ─── Tabs ─── */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex; gap: 2px; border-bottom: 1px solid var(--border);
|
display: flex; gap: 2px; border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 16px; overflow-x: auto;
|
margin-bottom: 16px; overflow-x: auto; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
padding: 10px 16px; font-size: 13px; font-weight: 500;
|
padding: 10px 16px; font-size: 13px; font-weight: 500;
|
||||||
|
|
@ -1352,6 +1354,77 @@ pre {
|
||||||
color: var(--text-muted);
|
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 loader */
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%);
|
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%);
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ const PAGE_TITLES = {
|
||||||
settings: { title: 'Settings', breadcrumb: 'Configuration' },
|
settings: { title: 'Settings', breadcrumb: 'Configuration' },
|
||||||
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
|
'setup-wizard': { title: 'Setup Wizard', breadcrumb: 'Guided configuration' },
|
||||||
chat: { title: 'AI Chat', breadcrumb: 'Multi-agent terminal' },
|
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' },
|
kanban: { title: 'Kanban Board', breadcrumb: 'Multi-agent task management' },
|
||||||
goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
|
goals: { title: 'Goals', breadcrumb: 'Project targets and progress' },
|
||||||
journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },
|
journal: { title: 'Journal', breadcrumb: 'Daily entries and notes' },
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pattern": "research|analyze|search|summarize|compare|investigate|learn",
|
"pattern": "research|analyze|search|summarize|compare|investigate|learn",
|
||||||
"target": "gemini",
|
"target": "agy",
|
||||||
"priority": 10
|
"priority": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"agent_capabilities": {
|
"agent_capabilities": {
|
||||||
"opencode": ["code_generation", "file_operations", "git_management", "terminal_execution", "infrastructure_as_code", "testing", "debugging"],
|
"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"],
|
"hermes": ["persistent_memory", "scheduled_tasks", "messaging_channels", "skill_hub", "voice", "browser_automation", "subagent_delegation"],
|
||||||
"gemini": ["web_search", "multi_modal_analysis", "document_understanding", "data_analysis", "research_synthesis", "reasoning"]
|
"agy": ["web_search", "multi_modal_analysis", "document_understanding", "data_analysis", "research_synthesis", "reasoning"]
|
||||||
},
|
},
|
||||||
"handoff_protocol": {
|
"handoff_protocol": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"id": "0f822987",
|
|
||||||
"title": "Fix login bug",
|
|
||||||
"body": "The login page has a race condition",
|
|
||||||
"status": "todo",
|
|
||||||
"priority": "high",
|
|
||||||
"assignee": "opencode",
|
|
||||||
"comments": [],
|
|
||||||
"links": [],
|
|
||||||
"created": "2026-06-05T09:52:18.236452+00:00",
|
|
||||||
"updated": "2026-06-05T09:52:18.236473+00:00"
|
|
||||||
}
|
|
||||||
|
|
@ -199,12 +199,12 @@
|
||||||
<circle cx="362" cy="823" r="2.5" fill="#c0fff0"/>
|
<circle cx="362" cy="823" r="2.5" fill="#c0fff0"/>
|
||||||
<text x="372" y="827" fill="#c0fff0" font-size="10">Cron scheduling & Telegram/Discord</text>
|
<text x="372" y="827" fill="#c0fff0" font-size="10">Cron scheduling & Telegram/Discord</text>
|
||||||
|
|
||||||
<!-- Agent 3: Gemini CLI -->
|
<!-- Agent 3: agy 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="135" rx="10" fill="url(#geminiGrad)" filter="url(#agentShadow)"/>
|
||||||
<rect x="589" y="700" width="225" height="4" rx="2" fill="#faa0c0"/>
|
<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"/>
|
<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="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">Gemini CLI</text>
|
<text x="701" y="764" text-anchor="middle" fill="#ffffff" font-size="16" font-weight="700">agy CLI</text>
|
||||||
<text x="701" y="782" text-anchor="middle" fill="#ffe0ee" font-size="11">Research & Analysis Agent</text>
|
<text x="701" y="782" text-anchor="middle" fill="#ffe0ee" font-size="11">Research & Analysis Agent</text>
|
||||||
<line x1="614" y1="793" x2="789" y2="793" stroke="#ffffff" stroke-opacity="0.1" stroke-width="1"/>
|
<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"/>
|
<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>
|
<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"/>
|
<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 · Gemini Flash · Local opencode</text>
|
<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="960" text-anchor="middle" fill="#3d3d5a" font-size="9">github.com/modimihir07/agentic-os</text>
|
<text x="450" y="960" text-anchor="middle" fill="#3d3d5a" font-size="9">github.com/modimihir07/agentic-os</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -5,22 +5,22 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
<!-- Primary SEO -->
|
<!-- Primary SEO -->
|
||||||
<title>Agentic OS (agentic-os) — Multi-Agent Dashboard & GitHub Repository | opencode + Hermes + Gemini CLI</title>
|
<title>Agentic OS (agentic-os) — Multi-Agent Dashboard & 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 Gemini CLI into one dashboard with 16+ skills, cron scheduler, cost analytics, persistent memory, and backup/restore.">
|
<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, gemini cli, agent dashboard, skills hub, cost analytics, devops automation, open source">
|
<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">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow">
|
||||||
<link rel="canonical" href="https://modimihir07.github.io/agentic-os/">
|
<link rel="canonical" href="https://modimihir07.github.io/agentic-os/">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="Agentic OS (agentic-os) — Multi-Agent Orchestration Dashboard & GitHub Repository">
|
<meta property="og:title" content="Agentic OS (agentic-os) — Multi-Agent Orchestration Dashboard & 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 Gemini 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 agy 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:url" content="https://modimihir07.github.io/agentic-os/">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Agentic OS (agentic-os) — Multi-Agent Orchestration Dashboard & GitHub Repository">
|
<meta name="twitter:title" content="Agentic OS (agentic-os) — Multi-Agent Orchestration Dashboard & 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 Gemini 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 agy CLI into one dashboard with skills, scheduler, cost analytics, memory, and backup.">
|
||||||
|
|
||||||
<!-- JSON-LD Structured Data -->
|
<!-- JSON-LD Structured Data -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"alternateName": "agentic-os",
|
"alternateName": "agentic-os",
|
||||||
"applicationCategory": "DeveloperApplication",
|
"applicationCategory": "DeveloperApplication",
|
||||||
"operatingSystem": "Linux, macOS",
|
"operatingSystem": "Linux, macOS",
|
||||||
"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.",
|
"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.",
|
||||||
"url": "https://github.com/modimihir07/agentic-os",
|
"url": "https://github.com/modimihir07/agentic-os",
|
||||||
"author": {
|
"author": {
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
|
|
@ -130,12 +130,12 @@ td { color: var(--text); }
|
||||||
<circle cx="18" cy="18" r="4" fill="url(#lg)"/>
|
<circle cx="18" cy="18" r="4" fill="url(#lg)"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>Agentic OS (agentic-os)</h1>
|
<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 Gemini CLI into one unified dashboard with persistent memory, cron scheduling, 16+ skills, and cost analytics.</p>
|
<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>
|
||||||
<div class="hero-badges">
|
<div class="hero-badges">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT">
|
<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/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/agents-3-orange.svg" alt="3 Agents">
|
||||||
<img src="https://img.shields.io/badge/skills-16-purple.svg" alt="16 Skills">
|
<img src="https://img.shields.io/badge/skills-15-purple.svg" alt="15 Skills">
|
||||||
<img src="https://img.shields.io/badge/status-stable-brightgreen.svg" alt="Stable">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
@ -152,8 +152,8 @@ td { color: var(--text); }
|
||||||
<h2>Why <span>Agentic OS</span>?</h2>
|
<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>
|
<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-grid">
|
||||||
<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>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>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>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>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>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>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>
|
<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>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>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>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 — Gemini Flash, 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 — Antigravity (agy), 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>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 class="feature-card"><div class="icon">🔌</div><h3>Plugin Registry</h3><p>Marketplace-style plugin management. Extend via custom skills.</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -179,7 +179,7 @@ td { color: var(--text); }
|
||||||
├──────────────────────────────────────────────┤
|
├──────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
│ │ opencode │ │ Hermes │ │ Gemini │ │
|
│ │ opencode │ │ Hermes │ │ agy │ │
|
||||||
│ │Code/DevOp│ │Mem/Sched │ │Research │ │
|
│ │Code/DevOp│ │Mem/Sched │ │Research │ │
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
│ │
|
│ │
|
||||||
|
|
@ -204,7 +204,7 @@ td { color: var(--text); }
|
||||||
<p>An honest comparison of the two projects.</p>
|
<p>An honest comparison of the two projects.</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Feature</th><th>Claude Agent OS (Video)</th><th>Agentic OS (This Project)</th></tr>
|
<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 + Gemini CLI</td></tr>
|
<tr><td>Core Agents</td><td>Claude + OpenClaw + Hermes</td><td>opencode + Hermes + agy CLI</td></tr>
|
||||||
<tr><td>Cost</td><td>$20/mo (Claude sub)</td><td class="win">$0 — all free tiers</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>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>
|
<tr><td>Architecture Layers</td><td>4 layers</td><td class="win">7 layers</td></tr>
|
||||||
|
|
|
||||||
44
install.sh
44
install.sh
|
|
@ -17,19 +17,41 @@ echo "Detected OS: $OS"
|
||||||
if command -v python3 &>/dev/null; then
|
if command -v python3 &>/dev/null; then
|
||||||
echo "Python: $(python3 --version)"
|
echo "Python: $(python3 --version)"
|
||||||
else
|
else
|
||||||
echo "ERROR: Python 3.10+ required. Install via: sudo apt install python3 python3-pip"
|
echo "ERROR: Python 3.10+ required. Install via: sudo apt install python3"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check pip
|
# Install uv into bin/ if not present
|
||||||
if ! command -v pip3 &>/dev/null; then
|
BIN_DIR="bin"
|
||||||
echo "Installing pip..."
|
UV="$BIN_DIR/uv"
|
||||||
python3 -m ensurepip --upgrade
|
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"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install Python deps
|
# 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
|
||||||
echo "Installing Python dependencies..."
|
echo "Installing Python dependencies..."
|
||||||
pip3 install -r requirements.txt --quiet
|
"$UV" pip install --python "$VENV_DIR/bin/python3" -r requirements.txt --quiet
|
||||||
|
|
||||||
# Check Node.js (for opencode)
|
# Check Node.js (for opencode)
|
||||||
if command -v node &>/dev/null; then
|
if command -v node &>/dev/null; then
|
||||||
|
|
@ -53,11 +75,11 @@ else
|
||||||
echo "WARNING: Hermes Agent not found. Install via: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
|
echo "WARNING: Hermes Agent not found. Install via: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check Gemini CLI
|
# Check agy (Antigravity CLI)
|
||||||
if command -v gemini &>/dev/null; then
|
if command -v agy &>/dev/null; then
|
||||||
echo "Gemini CLI: found"
|
echo "agy (Antigravity): found"
|
||||||
else
|
else
|
||||||
echo "WARNING: Gemini CLI not found. Install via: npm install -g @google/gemini-cli"
|
echo "WARNING: agy CLI not found. Install via: curl -fsSL https://antigravity.ai/install | bash"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create required directories
|
# Create required directories
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
{
|
{
|
||||||
"name": "research-synthesis",
|
"name": "research-synthesis",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Web research and synthesis via Gemini CLI",
|
"description": "Web research and synthesis via agy CLI",
|
||||||
"author": "Agentic OS",
|
"author": "Agentic OS",
|
||||||
"installed": "2026-05-17T00:00:00Z",
|
"installed": "2026-05-17T00:00:00Z",
|
||||||
"type": "built-in"
|
"type": "built-in"
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,213 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Agentic OS — APScheduler engine for recurring tasks"""
|
"""Agentic OS — Event-Driven Scheduler Engine
|
||||||
|
|
||||||
|
File watcher + cron-based scheduler with execution history.
|
||||||
|
Handles job reloading, webhook triggers, skill execution events.
|
||||||
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
try:
|
from typing import Optional, Callable
|
||||||
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()
|
BASE_DIR = Path(__file__).parent.resolve()
|
||||||
JOBS_DIR = BASE_DIR / "jobs"
|
JOBS_DIR = BASE_DIR / "jobs"
|
||||||
|
HISTORY_FILE = BASE_DIR.parent / "data" / "scheduler-history.json"
|
||||||
|
|
||||||
def run_skill(skill_name: str):
|
_event_listeners = []
|
||||||
"""Execute a skill by invoking the appropriate agent."""
|
_on_files_changed = []
|
||||||
|
|
||||||
|
def on_event(listener: Callable):
|
||||||
|
_event_listeners.append(listener)
|
||||||
|
return listener
|
||||||
|
|
||||||
|
def on_files_changed(cb: Callable):
|
||||||
|
_on_files_changed.append(cb)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
def emit_event(event: dict):
|
||||||
|
event["timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
event["id"] = str(uuid.uuid4())[:8]
|
||||||
|
for listener in _event_listeners:
|
||||||
|
try:
|
||||||
|
listener(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_save_history(event)
|
||||||
|
|
||||||
|
def _save_history(event: dict):
|
||||||
|
history = []
|
||||||
|
if HISTORY_FILE.exists():
|
||||||
|
history = json.loads(HISTORY_FILE.read_text())
|
||||||
|
history.append(event)
|
||||||
|
if len(history) > 1000:
|
||||||
|
history = history[-1000:]
|
||||||
|
HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
||||||
|
|
||||||
|
def get_history(limit: int = 100) -> list:
|
||||||
|
if not HISTORY_FILE.exists():
|
||||||
|
return []
|
||||||
|
history = json.loads(HISTORY_FILE.read_text())
|
||||||
|
return history[-limit:]
|
||||||
|
|
||||||
|
def load_job_definitions() -> list:
|
||||||
|
jobs = []
|
||||||
|
for f in sorted(JOBS_DIR.glob("*.json")):
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
data["_file"] = str(f)
|
||||||
|
jobs.append(data)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
def get_job_by_id(job_id: str) -> Optional[dict]:
|
||||||
|
for job in load_job_definitions():
|
||||||
|
if job.get("id") == job_id:
|
||||||
|
return job
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_job_by_name(name: str) -> Optional[dict]:
|
||||||
|
for job in load_job_definitions():
|
||||||
|
if job.get("name") == name:
|
||||||
|
return job
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run_skill(skill_name: str, trigger: str = "scheduler", input_text: str = ""):
|
||||||
|
"""Execute a skill via the API."""
|
||||||
audit_file = BASE_DIR.parent / "audit" / "audit.log"
|
audit_file = BASE_DIR.parent / "audit" / "audit.log"
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
entry = {
|
entry = {
|
||||||
"action": "scheduler_run",
|
"action": "scheduler_run",
|
||||||
"skill": skill_name,
|
"skill": skill_name,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"trigger": trigger,
|
||||||
|
"timestamp": timestamp,
|
||||||
}
|
}
|
||||||
with open(audit_file, "a") as f:
|
with open(audit_file, "a") as f:
|
||||||
f.write(json.dumps(entry) + "\n")
|
f.write(json.dumps(entry) + "\n")
|
||||||
print(f"[{datetime.now().isoformat()}] Ran skill: {skill_name}")
|
emit_event({
|
||||||
|
"type": "skill_run",
|
||||||
|
"skill": skill_name,
|
||||||
|
"trigger": trigger,
|
||||||
|
"status": "started",
|
||||||
|
})
|
||||||
|
print(f"[{timestamp}] Skill '{skill_name}' triggered by {trigger}")
|
||||||
|
return {"status": "triggered", "skill": skill_name, "trigger": trigger}
|
||||||
|
|
||||||
def load_jobs(scheduler: BackgroundScheduler):
|
|
||||||
"""Load job definitions from jobs/ directory."""
|
# ─── File Watcher ─────────────────────────────────────────────
|
||||||
for job_file in JOBS_DIR.glob("*.json"):
|
|
||||||
data = json.loads(job_file.read_text())
|
class JobFileWatcher:
|
||||||
if not data.get("enabled", True):
|
"""Watch scheduler/jobs/ for changes and notify listeners."""
|
||||||
continue
|
def __init__(self, interval: float = 2.0):
|
||||||
scheduler.add_job(
|
self.interval = interval
|
||||||
run_skill,
|
self._known = {}
|
||||||
CronTrigger.from_crontab(data["cron"]),
|
self._running = False
|
||||||
args=[data["skill"]],
|
self._thread = None
|
||||||
id=data.get("id", data["name"]),
|
|
||||||
name=data["name"],
|
def start(self):
|
||||||
replace_existing=True,
|
self._running = True
|
||||||
)
|
self._scan()
|
||||||
print(f" Scheduled: {data['name']} ({data['cron']})")
|
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 main():
|
def main():
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = CronScheduler()
|
||||||
load_jobs(scheduler)
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
print(f"Agentic OS Scheduler running. Jobs loaded from: {JOBS_DIR}")
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
import time
|
|
||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
scheduler.shutdown()
|
scheduler.stop()
|
||||||
print("Scheduler stopped.")
|
print("Scheduler stopped.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
630
server.py
630
server.py
|
|
@ -1,11 +1,12 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Agentic OS — FastAPI Backend
|
Agentic OS — FastAPI Backend
|
||||||
Multi-agent orchestration server for opencode, Hermes, Gemini CLI
|
Multi-agent orchestration server for opencode, Hermes, agy CLI
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
|
|
@ -15,13 +16,34 @@ from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
app = FastAPI(title="Agentic OS", version="1.1.0")
|
_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)
|
||||||
|
|
||||||
# Load OpenRouter API key from Hermes .env
|
# Load OpenRouter API key from Hermes .env
|
||||||
HERMES_ENV = Path.home() / ".hermes" / ".env"
|
HERMES_ENV = Path.home() / ".hermes" / ".env"
|
||||||
|
|
@ -42,6 +64,22 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
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()
|
BASE_DIR = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
# ─── Models ───────────────────────────────────────────────────────
|
# ─── Models ───────────────────────────────────────────────────────
|
||||||
|
|
@ -83,7 +121,7 @@ def write_file(path: Path, content: str):
|
||||||
def list_dir(path: Path):
|
def list_dir(path: Path):
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".")])
|
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".") and p.is_file()])
|
||||||
|
|
||||||
def get_timestamp():
|
def get_timestamp():
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
@ -95,6 +133,68 @@ def append_audit(entry: dict):
|
||||||
with open(audit_file, "a") as f:
|
with open(audit_file, "a") as f:
|
||||||
f.write(json.dumps(entry) + "\n")
|
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) ────────────────────
|
# ─── Agent Discovery (instant filesystem checks) ────────────────────
|
||||||
|
|
||||||
def check_agent(name: str) -> dict:
|
def check_agent(name: str) -> dict:
|
||||||
|
|
@ -106,12 +206,9 @@ def check_agent(name: str) -> dict:
|
||||||
elif name == "hermes":
|
elif name == "hermes":
|
||||||
exists = shutil.which("hermes") is not None
|
exists = shutil.which("hermes") is not None
|
||||||
status = "online" if exists else "offline"
|
status = "online" if exists else "offline"
|
||||||
elif name == "gemini":
|
elif name == "agy":
|
||||||
# Gemini has valid OAuth tokens logged in
|
exists = shutil.which("agy") is not None
|
||||||
oauth = Path.home() / ".gemini" / "oauth_creds.json"
|
status = "online" if exists else "offline"
|
||||||
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:
|
else:
|
||||||
status = "offline"
|
status = "offline"
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -122,8 +219,10 @@ def check_agent(name: str) -> dict:
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def get_status():
|
def get_status():
|
||||||
agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]]
|
agents = [check_agent(a) for a in ["opencode", "hermes", "agy"]]
|
||||||
skills = list_dir(BASE_DIR / "skills")
|
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 []
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"agents": agents,
|
"agents": agents,
|
||||||
|
|
@ -135,15 +234,20 @@ def get_status():
|
||||||
|
|
||||||
@app.get("/api/brain")
|
@app.get("/api/brain")
|
||||||
def list_brain():
|
def list_brain():
|
||||||
files = list_dir(BASE_DIR / "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()])
|
||||||
brain_data = {}
|
brain_data = {}
|
||||||
for f in files:
|
for f in files:
|
||||||
path = BASE_DIR / "brain" / f
|
path = brain_dir / f
|
||||||
brain_data[f] = read_file(path)
|
brain_data[f] = read_file(path)
|
||||||
return brain_data
|
return brain_data
|
||||||
|
|
||||||
@app.get("/api/brain/{file_name}")
|
@app.get("/api/brain/{file_name}")
|
||||||
def get_brain_file(file_name: str):
|
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
|
path = BASE_DIR / "brain" / file_name
|
||||||
if not path.exists() or path.is_dir():
|
if not path.exists() or path.is_dir():
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
@ -151,6 +255,8 @@ def get_brain_file(file_name: str):
|
||||||
|
|
||||||
@app.put("/api/brain/{file_name}")
|
@app.put("/api/brain/{file_name}")
|
||||||
def update_brain_file(file_name: str, data: BrainUpdate):
|
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
|
path = BASE_DIR / "brain" / file_name
|
||||||
write_file(path, data.content)
|
write_file(path, data.content)
|
||||||
append_audit({"action": "brain_update", "file": file_name})
|
append_audit({"action": "brain_update", "file": file_name})
|
||||||
|
|
@ -184,6 +290,7 @@ def list_skills():
|
||||||
|
|
||||||
@app.get("/api/skills/{name}")
|
@app.get("/api/skills/{name}")
|
||||||
def get_skill(name: str):
|
def get_skill(name: str):
|
||||||
|
validate_identifier(name, r"^[a-zA-Z0-9_-]+$", "skill name")
|
||||||
path = BASE_DIR / "skills" / name
|
path = BASE_DIR / "skills" / name
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Skill not found")
|
raise HTTPException(404, "Skill not found")
|
||||||
|
|
@ -198,6 +305,7 @@ def get_skill(name: str):
|
||||||
|
|
||||||
@app.post("/api/skills/{name}/run")
|
@app.post("/api/skills/{name}/run")
|
||||||
def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
||||||
|
validate_identifier(name, r"^[a-zA-Z0-9_-]+$", "skill name")
|
||||||
path = BASE_DIR / "skills" / name
|
path = BASE_DIR / "skills" / name
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Skill not found")
|
raise HTTPException(404, "Skill not found")
|
||||||
|
|
@ -216,14 +324,14 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
||||||
if any(k in name for k in devops_keywords):
|
if any(k in name for k in devops_keywords):
|
||||||
agent_choice = "opencode"
|
agent_choice = "opencode"
|
||||||
elif any(k in name for k in research_keywords):
|
elif any(k in name for k in research_keywords):
|
||||||
agent_choice = "gemini"
|
agent_choice = "agy"
|
||||||
else:
|
else:
|
||||||
# Check SKILL.md for explicit agent assignment
|
# Check SKILL.md for explicit agent assignment
|
||||||
for line in skill_md.split('\n'):
|
for line in skill_md.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if "Primary:" in line:
|
if "Primary:" in line:
|
||||||
candidate = line.split(":")[-1].strip().lower()
|
candidate = line.split(":")[-1].strip().lower()
|
||||||
if candidate in ("opencode", "hermes", "gemini"):
|
if candidate in ("opencode", "hermes", "agy"):
|
||||||
agent_choice = candidate
|
agent_choice = candidate
|
||||||
break
|
break
|
||||||
if agent_choice == "auto":
|
if agent_choice == "auto":
|
||||||
|
|
@ -281,6 +389,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
||||||
|
|
||||||
@app.get("/api/skills/{name}/eval")
|
@app.get("/api/skills/{name}/eval")
|
||||||
def get_skill_eval(name: str):
|
def get_skill_eval(name: str):
|
||||||
|
validate_identifier(name, r"^[a-zA-Z0-9_-]+$", "skill name")
|
||||||
path = BASE_DIR / "skills" / name / "score-history.json"
|
path = BASE_DIR / "skills" / name / "score-history.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {"scores": []}
|
return {"scores": []}
|
||||||
|
|
@ -300,6 +409,7 @@ def list_jobs():
|
||||||
def create_job(job: ScheduleJobRequest):
|
def create_job(job: ScheduleJobRequest):
|
||||||
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
||||||
jobs_dir.mkdir(parents=True, exist_ok=True)
|
jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
validate_identifier(job.name, r"^[a-zA-Z0-9 _-]+$", "job name")
|
||||||
job_data = {
|
job_data = {
|
||||||
"id": str(uuid.uuid4())[:8],
|
"id": str(uuid.uuid4())[:8],
|
||||||
"name": job.name,
|
"name": job.name,
|
||||||
|
|
@ -419,11 +529,12 @@ def create_backup():
|
||||||
|
|
||||||
@app.post("/api/backup/restore")
|
@app.post("/api/backup/restore")
|
||||||
def restore_backup(data: BackupRestoreRequest):
|
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
|
backup_file = BASE_DIR / "backups" / data.file
|
||||||
if not backup_file.exists():
|
if not backup_file.exists():
|
||||||
raise HTTPException(404, "Backup file not found")
|
raise HTTPException(404, "Backup file not found")
|
||||||
with tarfile.open(backup_file, "r:gz") as tar:
|
with tarfile.open(backup_file, "r:gz") as tar:
|
||||||
tar.extractall(path=BASE_DIR)
|
safe_extractall(tar, BASE_DIR)
|
||||||
append_audit({"action": "backup_restored", "file": data.file})
|
append_audit({"action": "backup_restored", "file": data.file})
|
||||||
return {"status": "restored"}
|
return {"status": "restored"}
|
||||||
|
|
||||||
|
|
@ -444,7 +555,11 @@ def get_settings():
|
||||||
sf = BASE_DIR / "data" / "settings.json"
|
sf = BASE_DIR / "data" / "settings.json"
|
||||||
if not sf.exists():
|
if not sf.exists():
|
||||||
return {}
|
return {}
|
||||||
return json.loads(sf.read_text())
|
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
|
||||||
|
|
||||||
@app.put("/api/settings")
|
@app.put("/api/settings")
|
||||||
def update_settings(data: SettingsUpdate):
|
def update_settings(data: SettingsUpdate):
|
||||||
|
|
@ -456,6 +571,211 @@ def update_settings(data: SettingsUpdate):
|
||||||
append_audit({"action": "settings_updated"})
|
append_audit({"action": "settings_updated"})
|
||||||
return {"status": "ok"}
|
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 ────────────────────────────────────────────
|
# ─── Routes: Standards ────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/standards")
|
@app.get("/api/standards")
|
||||||
|
|
@ -484,13 +804,19 @@ def discover_standards():
|
||||||
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
|
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
|
||||||
|
|
||||||
def load_chat_history():
|
def load_chat_history():
|
||||||
if CHAT_HISTORY_FILE.exists():
|
if not CHAT_HISTORY_FILE.exists():
|
||||||
return json.loads(CHAT_HISTORY_FILE.read_text())
|
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
|
||||||
return {"messages": []}
|
return {"messages": []}
|
||||||
|
|
||||||
def save_chat_message(msg: dict):
|
def save_chat_message(msg: dict):
|
||||||
history = load_chat_history()
|
history = load_chat_history()
|
||||||
history["messages"].append(msg)
|
history.setdefault("messages", []).append(msg)
|
||||||
if len(history["messages"]) > 200:
|
if len(history["messages"]) > 200:
|
||||||
history["messages"] = history["messages"][-200:]
|
history["messages"] = history["messages"][-200:]
|
||||||
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
||||||
|
|
@ -567,26 +893,17 @@ 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 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}"
|
return err_msg or f"hermes returned exit code {code}"
|
||||||
|
|
||||||
elif agent == "gemini":
|
elif agent == "agy":
|
||||||
for attempt, (args, to) in enumerate([
|
try:
|
||||||
(["-y", "-m", "gemini-2.5-flash"], 60),
|
code, out, err = run_cli(["agy", "--print", message], timeout=60)
|
||||||
(["-y"], 40),
|
except subprocess.TimeoutExpired:
|
||||||
]):
|
return f"**agy timed out.**\n\nTry running `agy --print \"{message[:60]}\"` directly."
|
||||||
try:
|
combined = ((err or "") + " " + (out or "")).strip()
|
||||||
code, out, err = run_cli(["gemini", *args, message], timeout=to)
|
if code == 0:
|
||||||
except subprocess.TimeoutExpired:
|
return (out or "").strip() or f"**agy**\n\nProcessed your query."
|
||||||
if attempt == 0:
|
if "auth" in combined.lower() or "login" in combined.lower() or "api key" in combined.lower():
|
||||||
continue
|
return f"**agy needs auth**\n\nRun `agy login` to authenticate.\n\n**Details:** {combined[:200]}"
|
||||||
return f"⏱ Gemini timed out.\n\nTry running `gemini \"{message[:60]}\"` directly.\n\n**Message:** {message[:100]}"
|
return combined or f"agy returned exit code {code}"
|
||||||
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:
|
else:
|
||||||
return f"Unknown agent: {agent}"
|
return f"Unknown agent: {agent}"
|
||||||
|
|
@ -600,19 +917,24 @@ def execute_agent(agent: str, message: str) -> str:
|
||||||
@app.post("/api/chat")
|
@app.post("/api/chat")
|
||||||
def chat(req: ChatRequest):
|
def chat(req: ChatRequest):
|
||||||
agent = req.agent.lower().strip()
|
agent = req.agent.lower().strip()
|
||||||
if agent not in ["opencode", "hermes", "gemini"]:
|
if agent not in ["opencode", "hermes", "agy"]:
|
||||||
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
|
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)")
|
||||||
|
|
||||||
user_msg = {
|
user_msg = {
|
||||||
"id": str(uuid.uuid4())[:8],
|
"id": str(uuid.uuid4())[:8],
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"agent": agent,
|
"agent": agent,
|
||||||
"content": req.message,
|
"content": message,
|
||||||
"timestamp": get_timestamp(),
|
"timestamp": get_timestamp(),
|
||||||
}
|
}
|
||||||
save_chat_message(user_msg)
|
save_chat_message(user_msg)
|
||||||
|
|
||||||
response_text = execute_agent(agent, req.message)
|
response_text = execute_agent(agent, message)
|
||||||
|
|
||||||
agent_msg = {
|
agent_msg = {
|
||||||
"id": str(uuid.uuid4())[:8],
|
"id": str(uuid.uuid4())[:8],
|
||||||
|
|
@ -623,13 +945,104 @@ def chat(req: ChatRequest):
|
||||||
}
|
}
|
||||||
save_chat_message(agent_msg)
|
save_chat_message(agent_msg)
|
||||||
|
|
||||||
append_audit({"action": "chat_message", "agent": agent, "msg_preview": req.message[:50]})
|
append_audit({"action": "chat_message", "agent": agent, "msg_preview": message[:50]})
|
||||||
|
|
||||||
return {"status": "ok", "response": agent_msg}
|
return {"status": "ok", "response": agent_msg}
|
||||||
|
|
||||||
@app.get("/api/chat/history")
|
@app.get("/api/chat/history")
|
||||||
def get_chat_history():
|
def get_chat_history(q: str = Query(""), agent: str = Query(""), limit: int = Query(200, le=1000)):
|
||||||
return load_chat_history()
|
"""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}
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# v0.2.0 — New Feature Endpoints
|
# v0.2.0 — New Feature Endpoints
|
||||||
|
|
@ -706,6 +1119,7 @@ def load_kanban_tasks():
|
||||||
|
|
||||||
def save_kanban_task(task: dict):
|
def save_kanban_task(task: dict):
|
||||||
ensure_dir(KANBAN_DIR)
|
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))
|
(KANBAN_DIR / f"{task['id']}.json").write_text(json.dumps(task, indent=2))
|
||||||
|
|
||||||
def load_goals():
|
def load_goals():
|
||||||
|
|
@ -735,6 +1149,7 @@ def kanban_board(status: Optional[str] = None):
|
||||||
|
|
||||||
@app.get("/api/kanban/tasks/{task_id}")
|
@app.get("/api/kanban/tasks/{task_id}")
|
||||||
def kanban_get_task(task_id: str):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -763,6 +1178,7 @@ def kanban_create_task(data: KanbanTaskCreate):
|
||||||
|
|
||||||
@app.patch("/api/kanban/tasks/{task_id}")
|
@app.patch("/api/kanban/tasks/{task_id}")
|
||||||
def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -778,6 +1194,7 @@ def kanban_update_task(task_id: str, data: KanbanTaskUpdate):
|
||||||
|
|
||||||
@app.post("/api/kanban/tasks/{task_id}/complete")
|
@app.post("/api/kanban/tasks/{task_id}/complete")
|
||||||
def kanban_complete_task(task_id: str, data: KanbanComplete):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -792,6 +1209,7 @@ def kanban_complete_task(task_id: str, data: KanbanComplete):
|
||||||
|
|
||||||
@app.post("/api/kanban/tasks/{task_id}/block")
|
@app.post("/api/kanban/tasks/{task_id}/block")
|
||||||
def kanban_block_task(task_id: str, data: KanbanBlock):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -805,6 +1223,7 @@ def kanban_block_task(task_id: str, data: KanbanBlock):
|
||||||
|
|
||||||
@app.post("/api/kanban/tasks/{task_id}/unblock")
|
@app.post("/api/kanban/tasks/{task_id}/unblock")
|
||||||
def kanban_unblock_task(task_id: str):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -818,6 +1237,7 @@ def kanban_unblock_task(task_id: str):
|
||||||
|
|
||||||
@app.post("/api/kanban/tasks/{task_id}/comments")
|
@app.post("/api/kanban/tasks/{task_id}/comments")
|
||||||
def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -835,6 +1255,7 @@ def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
|
||||||
@app.post("/api/kanban/links")
|
@app.post("/api/kanban/links")
|
||||||
def kanban_add_link(data: KanbanLinkCreate):
|
def kanban_add_link(data: KanbanLinkCreate):
|
||||||
for tid in [data.parent_id, data.child_id]:
|
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"
|
path = KANBAN_DIR / f"{tid}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, f"Task {tid} not found")
|
raise HTTPException(404, f"Task {tid} not found")
|
||||||
|
|
@ -851,6 +1272,7 @@ def kanban_add_link(data: KanbanLinkCreate):
|
||||||
@app.delete("/api/kanban/links")
|
@app.delete("/api/kanban/links")
|
||||||
def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
|
def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
|
||||||
for tid in [parent_id, child_id]:
|
for tid in [parent_id, child_id]:
|
||||||
|
validate_identifier(tid, r"^[a-zA-Z0-9_-]+$", "task id")
|
||||||
path = KANBAN_DIR / f"{tid}.json"
|
path = KANBAN_DIR / f"{tid}.json"
|
||||||
if path.exists():
|
if path.exists():
|
||||||
t = json.loads(path.read_text())
|
t = json.loads(path.read_text())
|
||||||
|
|
@ -867,6 +1289,7 @@ def kanban_dispatch():
|
||||||
|
|
||||||
@app.post("/api/kanban/tasks/{task_id}/specify")
|
@app.post("/api/kanban/tasks/{task_id}/specify")
|
||||||
def kanban_specify_task(task_id: str):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -879,6 +1302,7 @@ def kanban_specify_task(task_id: str):
|
||||||
|
|
||||||
@app.post("/api/kanban/tasks/{task_id}/decompose")
|
@app.post("/api/kanban/tasks/{task_id}/decompose")
|
||||||
def kanban_decompose_task(task_id: str):
|
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"
|
path = KANBAN_DIR / f"{task_id}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(404, "Task not found")
|
raise HTTPException(404, "Task not found")
|
||||||
|
|
@ -990,6 +1414,7 @@ def list_journal_entries():
|
||||||
|
|
||||||
@app.get("/api/journal/entries/{entry_date}")
|
@app.get("/api/journal/entries/{entry_date}")
|
||||||
def get_journal_entry(entry_date: str):
|
def get_journal_entry(entry_date: str):
|
||||||
|
validate_identifier(entry_date, r"^\d{4}-\d{2}-\d{2}$", "date")
|
||||||
try:
|
try:
|
||||||
path = JOURNAL_DIR / f"{entry_date}.md"
|
path = JOURNAL_DIR / f"{entry_date}.md"
|
||||||
ensure_dir(JOURNAL_DIR)
|
ensure_dir(JOURNAL_DIR)
|
||||||
|
|
@ -1000,6 +1425,7 @@ def get_journal_entry(entry_date: str):
|
||||||
|
|
||||||
@app.put("/api/journal/entries/{entry_date}")
|
@app.put("/api/journal/entries/{entry_date}")
|
||||||
def save_journal_entry(entry_date: str, data: JournalSave):
|
def save_journal_entry(entry_date: str, data: JournalSave):
|
||||||
|
validate_identifier(entry_date, r"^\d{4}-\d{2}-\d{2}$", "date")
|
||||||
try:
|
try:
|
||||||
ensure_dir(JOURNAL_DIR)
|
ensure_dir(JOURNAL_DIR)
|
||||||
path = JOURNAL_DIR / f"{entry_date}.md"
|
path = JOURNAL_DIR / f"{entry_date}.md"
|
||||||
|
|
@ -1030,7 +1456,7 @@ def search_journal(q: str = Query("")):
|
||||||
def get_agent_health():
|
def get_agent_health():
|
||||||
try:
|
try:
|
||||||
agents = []
|
agents = []
|
||||||
for name in ["opencode", "hermes", "gemini"]:
|
for name in ["opencode", "hermes", "agy"]:
|
||||||
info = check_agent(name)
|
info = check_agent(name)
|
||||||
info["uptime"] = 0
|
info["uptime"] = 0
|
||||||
info["success_rate"] = 100
|
info["success_rate"] = 100
|
||||||
|
|
@ -1043,7 +1469,7 @@ def get_agent_health():
|
||||||
@app.get("/api/agents/{name}/stats")
|
@app.get("/api/agents/{name}/stats")
|
||||||
def get_agent_stats(name: str):
|
def get_agent_stats(name: str):
|
||||||
try:
|
try:
|
||||||
if name not in ["opencode", "hermes", "gemini"]:
|
if name not in ["opencode", "hermes", "agy"]:
|
||||||
raise HTTPException(400, "Invalid agent")
|
raise HTTPException(400, "Invalid agent")
|
||||||
info = check_agent(name)
|
info = check_agent(name)
|
||||||
return {
|
return {
|
||||||
|
|
@ -1064,7 +1490,7 @@ def get_agent_stats(name: str):
|
||||||
def refresh_agent_health():
|
def refresh_agent_health():
|
||||||
try:
|
try:
|
||||||
agents = []
|
agents = []
|
||||||
for name in ["opencode", "hermes", "gemini"]:
|
for name in ["opencode", "hermes", "agy"]:
|
||||||
info = check_agent(name)
|
info = check_agent(name)
|
||||||
agents.append(info)
|
agents.append(info)
|
||||||
append_audit({"action": "agent_health_refreshed"})
|
append_audit({"action": "agent_health_refreshed"})
|
||||||
|
|
@ -1077,7 +1503,7 @@ def refresh_agent_health():
|
||||||
ROUTER_RULES = {
|
ROUTER_RULES = {
|
||||||
"opencode": ["code", "devops", "deploy", "git", "file", "terraform", "docker", "test", "build", "infra", "script"],
|
"opencode": ["code", "devops", "deploy", "git", "file", "terraform", "docker", "test", "build", "infra", "script"],
|
||||||
"hermes": ["memory", "schedule", "channel", "skill", "cron", "reminder", "brain", "plugin", "backup"],
|
"hermes": ["memory", "schedule", "channel", "skill", "cron", "reminder", "brain", "plugin", "backup"],
|
||||||
"gemini": ["research", "analyze", "search", "compare", "explain", "study", "learn", "document", "report", "review"],
|
"agy": ["research", "analyze", "search", "compare", "explain", "study", "learn", "document", "report", "review"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/api/router/suggest")
|
@app.post("/api/router/suggest")
|
||||||
|
|
@ -1102,7 +1528,7 @@ def router_suggest(data: RouterSuggest):
|
||||||
def router_route(data: RouterRoute):
|
def router_route(data: RouterRoute):
|
||||||
try:
|
try:
|
||||||
agent = data.agent.lower()
|
agent = data.agent.lower()
|
||||||
if agent not in ["opencode", "hermes", "gemini"]:
|
if agent not in ["opencode", "hermes", "agy"]:
|
||||||
return {"status": "error", "message": f"Invalid agent: {agent}"}
|
return {"status": "error", "message": f"Invalid agent: {agent}"}
|
||||||
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
|
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
|
||||||
return {
|
return {
|
||||||
|
|
@ -1188,8 +1614,12 @@ def list_sessions():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"sessions": [], "error": str(e)}
|
return {"sessions": [], "error": str(e)}
|
||||||
|
|
||||||
|
MAX_SESSION_CONTENT = 2000
|
||||||
|
|
||||||
@app.get("/api/sessions/{session_id}/replay")
|
@app.get("/api/sessions/{session_id}/replay")
|
||||||
def get_session_replay(session_id: str):
|
def get_session_replay(session_id: str):
|
||||||
|
if ".." in session_id or "/" in session_id:
|
||||||
|
raise HTTPException(400, "Invalid session ID")
|
||||||
try:
|
try:
|
||||||
sessions_dir = Path.home() / ".local" / "share" / "opencode"
|
sessions_dir = Path.home() / ".local" / "share" / "opencode"
|
||||||
log_file = sessions_dir / "log" / f"{session_id}.log"
|
log_file = sessions_dir / "log" / f"{session_id}.log"
|
||||||
|
|
@ -1203,13 +1633,43 @@ def get_session_replay(session_id: str):
|
||||||
return {
|
return {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"lines": len(lines),
|
"lines": len(lines),
|
||||||
"messages": messages[:100],
|
"messages": messages[:50],
|
||||||
"content": content[:5000],
|
"content": content[:MAX_SESSION_CONTENT],
|
||||||
}
|
}
|
||||||
return {"session_id": session_id, "messages": [], "content": "Session log not found"}
|
return {"session_id": session_id, "messages": [], "content": "Session log not found"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"session_id": session_id, "messages": [], "error": str(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 ──────────────────────────────
|
# ─── Routes: Dashboard Static Files ──────────────────────────────
|
||||||
|
|
||||||
dashboard_dir = BASE_DIR / "dashboard"
|
dashboard_dir = BASE_DIR / "dashboard"
|
||||||
|
|
@ -1221,14 +1681,31 @@ def index():
|
||||||
html_file = BASE_DIR / "dashboard" / "index.html"
|
html_file = BASE_DIR / "dashboard" / "index.html"
|
||||||
if html_file.exists():
|
if html_file.exists():
|
||||||
content = html_file.read_text()
|
content = html_file.read_text()
|
||||||
content = content.replace('href="styles.css"', 'href="/dashboard/styles.css"')
|
# Version-agnostic rewrite: handle any ?v= suffix (or none) so both
|
||||||
content = content.replace('src="utils.js"', 'src="/dashboard/utils.js"')
|
# freshly-served and previously-cached index.html resolve correctly.
|
||||||
content = content.replace('src="api.js"', 'src="/dashboard/api.js"')
|
content = re.sub(r'href="(styles\.css)(\?v=[0-9.]+)?"',
|
||||||
content = content.replace('src="app.js"', 'src="/dashboard/app.js"')
|
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('pages/', '/dashboard/pages/')
|
content = content.replace('pages/', '/dashboard/pages/')
|
||||||
return HTMLResponse(content=content)
|
return HTMLResponse(content=content)
|
||||||
return HTMLResponse("<h1>Agentic OS</h1><p>Dashboard not built yet. Run <code>./install.sh</code> first.</p>")
|
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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
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>'
|
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>'
|
||||||
|
|
@ -1241,6 +1718,41 @@ def favicon():
|
||||||
def favicon_svg():
|
def favicon_svg():
|
||||||
return Response(content=FAVICON_SVG, media_type="image/svg+xml")
|
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 ─────────────────────────────────────────────────────────
|
# ─── Main ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ Brief description of what this skill does.
|
||||||
- What this skill produces
|
- What this skill produces
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: opencode / hermes / gemini
|
- Primary: opencode / hermes / agy
|
||||||
- Fallback: {fallback agent}
|
- Fallback: {fallback agent}
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,4 @@ Design document with: problem statement, alternatives, evaluation, recommendatio
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: opencode
|
- Primary: opencode
|
||||||
- Research: gemini
|
- Research: agy
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,4 @@ Structured review report with severity levels
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: opencode
|
- Primary: opencode
|
||||||
- Fallback: gemini
|
- Fallback: agy
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Drafts blog posts, newsletter issues, documentation, and other written content.
|
||||||
## Process
|
## Process
|
||||||
1. Read business-brain.md for brand voice
|
1. Read business-brain.md for brand voice
|
||||||
2. Review input topic and desired format
|
2. Review input topic and desired format
|
||||||
3. Research if needed (via Gemini CLI)
|
3. Research if needed (via agy CLI)
|
||||||
4. Draft content in appropriate tone
|
4. Draft content in appropriate tone
|
||||||
5. Self-review against eval criteria
|
5. Self-review against eval criteria
|
||||||
6. Output to context/ folder
|
6. Output to context/ folder
|
||||||
|
|
@ -30,4 +30,4 @@ Draft markdown file with metadata (word count, reading time, tone)
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: opencode
|
- Primary: opencode
|
||||||
- Research: gemini
|
- Research: agy
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,4 @@ Structured audit report markdown in context/ folder
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: opencode
|
- Primary: opencode
|
||||||
- Fallback: gemini
|
- Fallback: agy
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,4 @@ Structured plan with: goal, milestones, tasks, dependencies, estimates
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: opencode
|
- Primary: opencode
|
||||||
- Fallback: gemini
|
- Fallback: agy
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Checks system health at regular intervals: agent status, disk usage, memory pres
|
||||||
- Before and after running other skills
|
- Before and after running other skills
|
||||||
|
|
||||||
## Process
|
## Process
|
||||||
1. Check all 3 agents (opencode, hermes, gemini) are online
|
1. Check all 3 agents (opencode, hermes, agy) are online
|
||||||
2. Check disk usage (< 90%)
|
2. Check disk usage (< 90%)
|
||||||
3. Check memory pressure (< 80%)
|
3. Check memory pressure (< 80%)
|
||||||
4. Scan recent audit log for errors
|
4. Scan recent audit log for errors
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,4 @@ Spec document + task plan + implementation order
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: opencode
|
- Primary: opencode
|
||||||
- Fallback: gemini
|
- Fallback: agy
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: research-synthesis
|
name: research-synthesis
|
||||||
description: Web research and synthesis using Gemini CLI
|
description: Web research and synthesis using agy CLI
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
author: Agentic OS
|
author: Agentic OS
|
||||||
tags: [research, analysis, web, synthesis]
|
tags: [research, analysis, web, synthesis]
|
||||||
|
|
@ -20,7 +20,7 @@ Performs multi-source web research on a topic, synthesizes findings into a struc
|
||||||
|
|
||||||
## Process
|
## Process
|
||||||
1. Define research scope and questions
|
1. Define research scope and questions
|
||||||
2. Search web via Gemini CLI for multiple sources
|
2. Search web via agy CLI for multiple sources
|
||||||
3. Extract key findings per source
|
3. Extract key findings per source
|
||||||
4. Cross-reference and validate
|
4. Cross-reference and validate
|
||||||
5. Synthesize into structured report
|
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
|
Research report with sections: summary, findings, sources, recommendations
|
||||||
|
|
||||||
## Agent Assignment
|
## Agent Assignment
|
||||||
- Primary: gemini
|
- Primary: agy
|
||||||
- Fallback: opencode (for formatting only)
|
- Fallback: opencode (for formatting only)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Learnings
|
# Learnings
|
||||||
|
|
||||||
## 2026-05-17
|
## 2026-05-17
|
||||||
- Uses Gemini CLI for web research
|
- Uses agy CLI for web research
|
||||||
- Sources should be diverse and cross-referenced
|
- Sources should be diverse and cross-referenced
|
||||||
|
|
|
||||||
37
start.sh
37
start.sh
|
|
@ -10,18 +10,41 @@ if [ ! -f server.py ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check dependencies
|
# Check virtual environment
|
||||||
pip3 install -r requirements.txt --quiet 2>/dev/null
|
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
|
||||||
|
|
||||||
# Get port from settings or default
|
# Get port from settings or default
|
||||||
PORT=8080
|
PORT=8080
|
||||||
if command -v python3 &>/dev/null; then
|
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")
|
||||||
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 "Dashboard: http://127.0.0.1:${PORT}"
|
||||||
echo "Press Ctrl+C to stop"
|
echo "Press Ctrl+C to stop"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Start server
|
# Kill any stale process still bound to the port (from a crashed/previous run)
|
||||||
python3 server.py --port "${PORT}"
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
"""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
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/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())
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""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)
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
"""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()
|
||||||
Loading…
Reference in New Issue