v0.4.0: agy provider swap, port auto-kill, apscheduler auto-install, chat history + file uploads, memory knowledge graph, diff viewer, cache/layout fixes, 74 tests

- Replace Gemini CLI with agy (Antigravity) across backend, routing, frontend, skills, docs
- start.sh: kill stale process on target port; scheduler.py: auto-install apscheduler
- Chat History page (q/agent/limit search), /api/chat/upload with 2MB allowlist + 24h TTL
- /api/memory/graph knowledge graph, /api/diff code diff viewer
- Fix stale-cache 404s, zoom-proof layout, tab-bar collapse, skill count
- 15 new isolated tests (59 -> 74)
This commit is contained in:
modimihir07 2026-08-12 01:20:34 +05:30
parent eeb6cc4c9a
commit c7ebbb41d8
53 changed files with 1829 additions and 205 deletions

1
.gitignore vendored
View File

@ -18,4 +18,5 @@ data/error-log.json
data/circuit-breaker.json
data/memory.db
data/kanban/*.json
data/uploads/
scheduler/*.pyc

View File

@ -2,7 +2,7 @@
## Role Definition
You are an **AI Agent Operating System (Agentic OS)** — a multi-agent orchestration platform that coordinates **opencode**, **Hermes Agent**, and **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.
@ -36,7 +36,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
│ │ 3-AGENT EXECUTION ENGINE │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │
│ │ │ opencode │ │ Hermes │ │ Gemini CLI │ │ │
│ │ │ opencode │ │ Hermes │ │ agy CLI │ │ │
│ │ │ (Code/DevOps)│ │ (Memory/Sched│ │(Research/ │ │ │
│ │ │ File Ops) │ │ /Channels) │ │ Analysis) │ │ │
│ │ └──────────────┘ └──────────────┘ └────────────┘ │ │
@ -62,14 +62,14 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|-------|-------------|---------------|
| **opencode** | Code generation, file operations, DevOps/GCP infra, git management, software engineering | Any task involving file edits, code writing, infrastructure-as-code, terminal commands for build/test |
| **Hermes Agent** | Persistent memory (SQLite FTS5), cron scheduling, Telegram/Discord channels, skill hub, multi-agent coordination | Tasks needing cross-session memory, scheduled recurring tasks, multi-platform notifications, skill discovery |
| **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
- **Code/DevOps task?** → opencode
- **Memory/Channel/Schedule?** → Hermes Agent
- **Research/Analysis?**Gemini CLI
- **Complex multi-step?** → Chain: Gemini researches → opencode implements → Hermes monitors/schedules
- **Research/Analysis?**agy CLI
- **Complex multi-step?** → Chain: agy researches → opencode implements → Hermes monitors/schedules
- **Unknown/General?** → opencode first (best general-purpose coding agent)
---
@ -213,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
│ ├── content-draft/ # Blog/newsletter writing
│ ├── code-review/ # [F21]
│ ├── research-synthesis/ # Gemini research
│ ├── research-synthesis/ # agy research
│ ├── daily-standup/ # Morning briefing
│ ├── meeting-minutes/ # Meeting notes processor
│ ├── project-planner/ # [F25, F26, F46]
@ -233,9 +233,9 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
│ │ ├── SOUL.md
│ │ ├── USER.md
│ │ └── MEMORY.md
│ └── gemini/
│ ├── GEMINI.md
│ └── gemini-extension.json
│ └── agy/
│ ├── AGY.md
│ └── agy-extension.json
├── scheduler/ # [F9] Scheduling
│ ├── scheduler.py
@ -318,8 +318,8 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
|--------|------|
| **Budget** | Strictly free tiers (GCP Free, GitHub Student Pack, Colab, Kaggle) |
| **Active Project** | CloudMart — GCP DevOps multi-region e-commerce platform |
| **CLI Tools Available** | opencode, Hermes Agent, Gemini CLI |
| **Preferred Model** | Hermes: Owl Alpha (OpenRouter, free), opencode: deepseek-v4-flash-free (opencode-zen), Gemini: gemini-2.5-flash (Google OAuth) |
| **CLI Tools Available** | opencode, Hermes Agent, agy CLI |
| **Preferred Model** | Hermes: Owl Alpha (OpenRouter, free), opencode: deepseek-v4-flash-free (opencode-zen), agy: Antigravity (free CLI) |
---
@ -350,7 +350,7 @@ When you (an AI agent) are dropped into this directory for the first time:
### Session History
- opencode: `~/.local/share/opencode/opencode.db` + `~/.local/share/opencode/log/`
- Hermes: `~/.hermes/sessions.json`
- Gemini: `~/.gemini/history/`
- agy: `~/.antigravity/history/`
---
@ -448,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.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.*

View File

@ -4,8 +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/FastAPI-0.115+-green.svg" alt="FastAPI"/>
<img src="https://img.shields.io/badge/agents-3-orange.svg" alt="3 Agents"/>
<img src="https://img.shields.io/badge/skills-16-purple.svg" alt="16 Skills"/>
<img src="https://img.shields.io/badge/version-v0.3.0-blueviolet.svg" alt="v0.3.0"/>
<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"/>
<a href="https://dev.to/mihir_nmodi_14a06a4019e1/i-built-an-open-source-agent-os-2h30"><img src="https://img.shields.io/badge/dev.to-article-blue.svg" alt="dev.to article"/></a>
<br/><br/>
@ -13,7 +13,7 @@
# Agentic OS (agentic-os) 🧠 — Multi-Agent Orchestration Platform
A locally-hosted operating system for AI agents — an open-source GitHub repository — that coordinates **opencode**, **Hermes Agent**, and **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.
@ -23,8 +23,8 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
| Category | Features |
|----------|----------|
| **🤖 3-Agent Engine** | opencode (code/DevOps), Hermes (memory/scheduling), Gemini (research/analysis) with intelligent routing |
| **🧩 16+ Skills** | Executable skill packs with eval scoring, learnings, and score history per run |
| **🤖 3-Agent Engine** | opencode (code/DevOps), Hermes (memory/scheduling), agy (research/analysis) with intelligent routing |
| **🧩 15+ Skills** | Executable skill packs with eval scoring, learnings, and score history per run |
| **🧠 Persistent Memory** | SQLite FTS5 + `brain/` folder — shared context read by all agents at session start |
| **⏱ Cron Scheduler** | APScheduler-powered jobs — heartbeat, memory consolidation, daily standup, DevOps audit |
| **💰 Cost Analytics** | Track spending per provider, model, agent. Free-tier alerts prevent surprise bills |
@ -34,11 +34,11 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
| **📐 Standards System** | Discover and inject coding conventions across your project |
| **🔌 Plugin Registry** | Marketplace-style plugin management (extensible via skills) |
| **🎨 Dark/Light Theme** | GitHub-style dark mode + clean light theme, toggle from sidebar |
| **⚡ Zero API Costs** | Built for free tiers — 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 Gemini CLI availability |
| **❤️ 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 |
@ -61,7 +61,7 @@ A locally-hosted operating system for AI agents — an open-source GitHub reposi
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌────────────────┐ ┌────────────────────┐ │
│ │ opencode │ │ Hermes │ │ Gemini CLI │ │
│ │ opencode │ │ Hermes │ │ agy CLI │ │
│ │ (Code/DevOps) │ │ (Memory/Sched) │ │ (Research/Analy) │ │
│ │ File Ops) │ │ /Channels) │ │ │ │
│ └───────────────┘ └────────────────┘ └────────────────────┘ │
@ -85,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** |
| **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
- **Code/DevOps task?** → opencode
- **Memory/Channel/Schedule?** → Hermes Agent
- **Research/Analysis?**Gemini CLI
- **Complex multi-step?** → Chain: Gemini researches → opencode implements → Hermes monitors/schedules
- **Research/Analysis?**agy CLI
- **Complex multi-step?** → Chain: agy researches → opencode implements → Hermes monitors/schedules
---
@ -116,7 +116,7 @@ chmod +x install.sh && ./install.sh
| Node.js 18+ | ⚠ For opencode | `curl -fsSL https://deb.nodesource.com/setup_20.x \| sudo bash - && sudo apt install -y nodejs` |
| opencode | ⚠ For code tasks | `npm install -g @opencode/cli` |
| Hermes Agent | ⚠ For memory/scheduling | `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh \| bash` |
| 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.
@ -130,10 +130,10 @@ chmod +x install.sh && ./install.sh
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key-here' > ~/.hermes/.env
```
### Gemini CLI (Google OAuth)
### agy CLI (Antigravity)
```bash
gemini auth login
# Complete OAuth in browser — tokens saved to ~/.gemini/oauth_creds.json
agy login
# Authenticate to enable web research, analysis, and document understanding
```
### Dashboard Settings
@ -142,7 +142,7 @@ Edit `data/settings.json`:
{
"dashboard": { "port": 8080 },
"theme": "dark",
"agents": { "opencode": true, "hermes": true, "gemini": true }
"agents": { "opencode": true, "hermes": true, "agy": true }
}
```
@ -164,11 +164,12 @@ agentic-os/
│ ├── api.js # API client (all endpoints)
│ ├── styles.css # Full dark/light theme CSS
│ ├── utils.js # Shared utilities
│ └── pages/ # 21 page modules (13 original + 7 v0.2.0 + 1 v0.3.0)
│ └── pages/ # 22 page modules (13 original + 7 v0.2.0 + 1 v0.3.0 + 1 v0.4.0)
│ ├── dashboard.js # Overview with stats
│ ├── skills.js # Skill grid/list/detail
│ ├── memory.js # Brain file editor
│ ├── chat.js # Multi-agent chat
│ ├── memory.js # Brain file editor + Knowledge Graph
│ ├── chat.js # Multi-agent chat + file attachments
│ ├── history.js # ▸ Chat History Search (v0.4.0)
│ ├── scheduler.js # Cron job manager
│ ├── audit.js # Activity trail
│ ├── cost.js # Cost analytics charts
@ -197,12 +198,12 @@ agentic-os/
│ ├── constitution.md
│ └── journal/ # Daily markdown entries (YYYY-MM-DD.md)
├── skills/ # 16 executable skills
├── skills/ # 15 executable skills
│ ├── devops-audit/ # GCP/CloudMart infra audit
│ ├── heartbeat/ # 5-min health check
│ ├── content-draft/ # Blog/newsletter writing
│ ├── code-review/ # PR review checklist
│ ├── research-synthesis/ # Gemini research aggregator
│ ├── research-synthesis/ # agy research aggregator
│ ├── daily-standup/ # Morning briefing
│ ├── meeting-minutes/ # Notes processor
│ ├── project-planner/ # Step-by-step plans
@ -215,18 +216,38 @@ agentic-os/
│ ├── goal-planner/ # Goal → steps
│ └── _template/ # Starter template
├── agents/ # Per-agent configs
├── agents/ # Per-agent configs (opencode, hermes, agy)
├── scheduler/jobs/ # Cron job definitions
├── registry/ # Plugin marketplace
├── standards/ # Discover/inject conventions
├── prompts/ # 10 reusable templates
├── data/ # Runtime data (agent-routes.json tracked; settings/cost/chat/error/scheduler/memory/circuit/kanban gitignored)
├── data/ # Runtime data (agent-routes.json tracked; settings/cost/chat/error/scheduler/memory/circuit/kanban/uploads gitignored)
├── audit/ # Activity log (gitignored)
└── backups/ # Snapshots (gitignored)
```
---
## 🆕 What's New in v0.4.0
| Feature | Description |
|---------|-------------|
| **🤖 agy (Antigravity) replaces Gemini CLI** | Full provider swap across backend, routing, frontend, skills, agents/, and docs. `data/agent-routes.json`, circuit breaker, health checks, chat all use `agy` |
| **🛡 Port Conflict Auto-Kill** | `start.sh` detects a stale dashboard process on the target port and kills it before starting |
| **📦 Auto-Install apscheduler** | `scheduler.py` auto-installs missing dependencies instead of failing on startup |
| **💬 Chat History Search** | `/api/chat/history` supports `q`, `agent`, `limit` filters + a dedicated History page with search, agent filter pills, and date grouping |
| **📎 Chat File Attachments** | `POST /api/chat/upload` (multipart) accepts text files up to 2 MB (validated allowlist), previews content to the agent, and stores under `data/uploads/` with 24h TTL cleanup |
| **🕸 Memory Knowledge Graph** | `GET /api/memory/graph` returns nodes (brain/skills/journal docs + extracted entities) and edges (shares_source / mentions), rendered as an interactive Canvas graph in the Memory page |
| **📜 Code Diff Viewer** | `GET /api/diff?file=&ref=` returns unified git diff with repo-relative path-traversal protection; "View Diff" button on the Skills page |
| **🧪 15 new tests** | Suite grows from 59 → 74 isolated tests covering chat history, uploads, memory graph, and diff viewer |
| **🛠 Stale-Cache Elimination** | `no-store` cache headers on all dashboard assets, `?v=` cache-busting, service-worker purge on load, root-level asset fallbacks so stale index.html never breaks the UI |
| **📐 Zoom-Proof Layout** | Content width capped at 1600px, responsive file-card grid, and `flex-shrink: 0` on tab bars — layout stays clean from 60% to 100% zoom |
| **🔢 Correct Skill Count** | Skills badge now counts skill directories (15) via the status endpoint instead of returning 0 |
During the v0.4.0 hardening pass the following **layout & caching bugs** were crushed: tab bars (Files/Knowledge Graph, Grid/List) collapsing to 1px under content at certain zooms, oversized cards stretching to 3500px+ at 50% zoom, cached index.html referencing dead asset URLs, and the sidebar Skills badge showing 0.
---
## 🆕 What's New in v0.3.0
| Feature | Description |
@ -259,7 +280,7 @@ agentic-os/
| **📋 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, Gemini CLI). Auto-refresh every 5 seconds |
| **❤️ 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 |
@ -282,10 +303,10 @@ Select an agent from the sidebar → type your message → get response.
|-------|----------|---------|
| **opencode** | "Check system status", "Deploy to GKE" | Code + terminal automation |
| **Hermes** | "What did I work on recently?", "Schedule a daily backup" | Memory recall, scheduling |
| **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
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
Create cron jobs: heartbeat (5 min), memory consolidation (weekly), daily standup, DevOps audit.
@ -309,7 +330,7 @@ Describe a task in plain English — the router analyzes keywords and suggests t
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 16 skills. Trends chart shows score progression over time. Top skills ranked by performance.
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.
@ -338,11 +359,11 @@ Open Agentic OS on your phone — bottom nav bar replaces sidebar, touch targets
| 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** |
| **Stack** | Next.js + Tailwind | FastAPI + vanilla JS SPA |
| **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 |
| **Scheduler** | Not shown | APScheduler cron jobs |
| **Cost Tracking** | Not shown | Built-in per-provider analytics |
@ -360,7 +381,7 @@ Open Agentic OS on your phone — bottom nav bar replaces sidebar, touch targets
- **OS**: Linux (Ubuntu 22.04+), macOS
- **Python**: 3.10, 3.11, 3.12
- **Browsers**: Chrome, Firefox, Edge
- **Agents**: opencode v0.8+, Hermes Agent v1.0+, Gemini CLI v1.0+
- **Agents**: opencode v0.8+, Hermes Agent v1.0+, agy CLI (Antigravity)
---

24
agents/agy/AGY.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
## Active Context
- Building Agentic OS (May 2026)
- 3-agent system: opencode + Hermes + Gemini CLI
- 3-agent system: opencode + Hermes + agy
- Web dashboard on FastAPI
- v0.2.0 released Jun 5, 2026 — 68 features (51 ref + 10 extras + 7 new), 58 endpoints, 20 pages

View File

@ -7,7 +7,7 @@ You are the memory and scheduling subsystem of Agentic OS. You manage persistent
- Maintain MEMORY.md and USER.md with cross-session context
- Schedule and monitor recurring tasks via cron
- Route coding tasks to opencode
- Route research tasks to Gemini CLI
- Route research tasks to agy CLI
- Log all actions to audit/audit.log
## Memory Configuration

View File

@ -2,7 +2,7 @@
## User
- Name: User
- Tech Stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
- Tech Stack: opencode, Hermes Agent, agy CLI, deepseek-v4-flash-free
- Projects: CloudMart (GCP DevOps), Agentic OS
- Budget: Free tiers only
@ -13,5 +13,5 @@
- Cost-conscious — always free tier first
## Communication
- Primary interaction: CLI (opencode/Hermes/Gemini)
- Primary interaction: CLI (opencode/Hermes/agy)
- Channels: CLI, messaging (Telegram)

View File

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

View File

@ -10,7 +10,7 @@
- Python 3.10+ required (FastAPI backend)
- Node.js 18+ required (opencode)
- 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)
- Dashboard binds to localhost only — no external exposure without explicit config

View File

@ -79,9 +79,89 @@ def search(query: str, limit: int = 20) -> list:
"WHERE memory_fts MATCH ? ORDER BY rank LIMIT ?",
(query, limit)
).fetchall()
return [dict(r) for r in rows]
except sqlite3.OperationalError:
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

View File

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

View File

@ -1,5 +1,7 @@
const pageCache = {};
const APP_VERSION = '0.4.1';
const PAGE_BASE = '/dashboard/pages/';
async function loadPage(name) {
@ -15,9 +17,10 @@ async function loadPage(name) {
function loadScript(src) {
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');
script.src = src;
script.src = versioned;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(script);

View File

@ -12,8 +12,7 @@
<meta name="theme-color" content="#6c5ce7">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="styles.css?v=0.4.0">
</head>
<body>
<div id="topLoadingBar"></div>
@ -38,6 +37,7 @@
<div class="sidebar-nav">
<div class="sidebar-section"><div class="sidebar-section-label">Primary</div></div>
<a href="#chat" class="nav-item" data-page="chat"><span class="nav-icon">💬</span><span class="nav-label">AI Chat</span></a>
<a href="#history" class="nav-item" data-page="history"><span class="nav-icon">🕘</span><span class="nav-label">Chat History</span></a>
<a href="#dashboard" class="nav-item active" data-page="dashboard"><span class="nav-icon"></span><span class="nav-label">Dashboard</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Agents</div></div>
<a href="#skills" class="nav-item" data-page="skills"><span class="nav-icon"></span><span class="nav-label">Skills</span><span class="nav-badge" id="skillCount">0</span></a>
@ -102,13 +102,23 @@
<a href="#errors" class="bottom-nav-item" data-page="errors"><span class="bottom-nav-icon"></span><span class="bottom-nav-label">Errors</span></a>
</nav>
<script src="utils.js"></script>
<script src="api.js"></script>
<script src="app.js"></script>
<script 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>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// 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>
</html>

View File

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

View File

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

View File

@ -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
const agentTotals = {};
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() {
showModal('Record Cost Entry', `
<div class="form-group">
<label class="form-label">Agent</label>
<select id="rcAgent" class="form-select"><option>opencode</option><option>hermes</option><option>gemini</option></select>
<select id="rcAgent" class="form-select"><option>opencode</option><option>hermes</option><option>agy</option></select>
</div>
<div class="form-group">
<label class="form-label">Model</label>

View File

@ -120,7 +120,7 @@ async function loadCircuitBreaker() {
container.innerHTML = '<div style="grid-column:1/-1"><div class="empty-state" style="padding:20px"><div class="empty-state-icon">🔌</div><div class="empty-state-title">No circuit breaker data</div></div></div>';
return;
}
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
const agentIcons = { opencode: '🔧', hermes: '⚡', agy: '🧠' };
container.innerHTML = agentNames.map(a => {
const cb = agents[a] || {};
const isOpen = cb.state === 'open';

117
dashboard/pages/history.js Normal file
View File

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

View File

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

View File

@ -22,7 +22,7 @@ async function renderSettings() {
<div class="card">
<div class="card-header"><span class="card-title">🤖 Agent Preferences</span></div>
<div class="grid grid-3">
${['opencode', 'hermes', 'gemini'].map(a => `
${['opencode', 'hermes', 'agy'].map(a => `
<div class="card" style="padding:14px">
<div class="flex items-center gap-2 mb-2">
<div class="agent-dot ${prefs[a] && prefs[a].enabled !== false ? 'online' : 'offline'}" style="width:10px;height:10px"></div>
@ -124,7 +124,7 @@ async function saveAllSettings() {
agent_preferences: {
opencode: { enabled: document.getElementById('agent_opencode').checked, binary: document.getElementById('bin_opencode').value },
hermes: { enabled: document.getElementById('agent_hermes').checked, binary: document.getElementById('bin_hermes').value },
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: {
port: parseInt(document.getElementById('setPort').value) || 8080,
@ -167,7 +167,7 @@ async function resetSettings() {
async function confirmReset() {
const defaults = {
theme: 'dark',
agent_preferences: { opencode: { enabled: true, binary: 'opencode' }, hermes: { enabled: true, binary: 'hermes' }, 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 },
api_keys: { gemini: '', openrouter: '' },
free_tier_limits: { gemini_flash: { requests_per_day: 1500, tokens_per_day: 1000000 }, openrouter_free: { requests_per_day: 100, tokens_per_day: 200000 } },

View File

@ -51,7 +51,7 @@ async function renderWizardStep() {
wc.innerHTML = `
<div style="text-align:center;padding:12px 0">
<div style="font-size:48px;margin-bottom:16px"></div>
<p style="font-size:14px;color:var(--text-secondary);line-height:1.6">Agentic OS coordinates <strong>opencode</strong>, <strong>Hermes Agent</strong>, and <strong>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>
`;
break;
@ -89,7 +89,7 @@ async function renderWizardStep() {
<select id="wizDefault" class="form-select">
<option value="opencode">opencode Best for code/DevOps</option>
<option value="hermes">Hermes Best for memory/scheduling</option>
<option value="gemini">Gemini CLI Best for research</option>
<option value="agy">agy Best for research</option>
</select>
</div>
<div class="form-group">

View File

@ -102,6 +102,7 @@ async function showSkillDetail(encodedName) {
<div style="margin-bottom:16px">
<button class="btn btn-ghost" onclick="backToSkills()"> Back to Skills</button>
<button class="btn btn-primary" style="margin-left:8px" onclick="quickRunSkill('${encodeURIComponent(name)}')"> Run ${safeName.replace(/-/g, ' ')}</button>
<button class="btn btn-accent" style="margin-left:8px" onclick="showSkillDiff('${encodeURIComponent(name)}')">📝 View Diff</button>
</div>
<div class="grid grid-2">
<div class="card">
@ -159,7 +160,7 @@ async function quickRunSkill(encodedName) {
<option value="auto">Auto-detect</option>
<option value="opencode">opencode</option>
<option value="hermes">Hermes</option>
<option value="gemini">Gemini CLI</option>
<option value="agy">agy (Antigravity)</option>
</select>
</div>
<div id="skillResult" style="display:none"></div>
@ -202,3 +203,37 @@ async function executeSkillRun(encodedName) {
if (runBtn) { runBtn.textContent = '▶ Run'; runBtn.disabled = false; }
}
}
async function showSkillDiff(encodedName) {
const name = decodeURIComponent(encodedName);
showModal(`Diff: ${escapeHtml(name)}`, `
<div class="form-group">
<label class="form-label">File (repo-relative)</label>
<div style="display:flex;gap:8px">
<input id="diffFile" class="form-input" value="skills/${escapeHtml(name)}/SKILL.md" style="flex:1;font-size:12px">
<button class="btn btn-primary" onclick="loadDiff()">🔍 Load</button>
</div>
</div>
<div id="diffResult"><div style="color:var(--text-muted);font-size:13px;padding:8px">Enter a file path to see its git diff.</div></div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Close</button>
`);
}
async function loadDiff() {
const input = document.getElementById('diffFile');
const result = document.getElementById('diffResult');
const file = input ? input.value.trim() : '';
if (!file) { showToast('Enter a file path', 'warning'); return; }
result.innerHTML = '<div class="loading" style="padding:16px"><div class="loading-spinner"></div></div>';
try {
const data = await api.getDiff(file);
if (!data.changed) {
result.innerHTML = `<div class="empty-state" style="padding:16px"><div class="empty-state-icon">✓</div><div class="empty-state-title">No changes</div><div class="empty-state-desc">${escapeHtml(file)} matches HEAD (or no uncommitted diff).</div></div>`;
return;
}
result.innerHTML = `<pre style="max-height:400px;overflow:auto;font-size:12px;white-space:pre-wrap;background:var(--bg-code,#1a1a2e);border-radius:8px;padding:12px;line-height:1.5">${escapeHtml(data.diff)}</pre>`;
} catch (err) {
result.innerHTML = `<div class="empty-state" style="padding:16px"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Error</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
}
}

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@
},
{
"pattern": "research|analyze|search|summarize|compare|investigate|learn",
"target": "gemini",
"target": "agy",
"priority": 10
},
{
@ -25,7 +25,7 @@
"agent_capabilities": {
"opencode": ["code_generation", "file_operations", "git_management", "terminal_execution", "infrastructure_as_code", "testing", "debugging"],
"hermes": ["persistent_memory", "scheduled_tasks", "messaging_channels", "skill_hub", "voice", "browser_automation", "subagent_delegation"],
"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": {
"enabled": true,

View File

@ -199,12 +199,12 @@
<circle cx="362" cy="823" r="2.5" fill="#c0fff0"/>
<text x="372" y="827" fill="#c0fff0" font-size="10">Cron scheduling &amp; Telegram/Discord</text>
<!-- Agent 3: 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="4" rx="2" fill="#faa0c0"/>
<circle cx="701" cy="732" r="18" fill="#ffffff" fill-opacity="0.15"/>
<text x="701" y="738" text-anchor="middle" fill="#ffffff" font-size="16">🔍</text>
<text x="701" y="764" text-anchor="middle" fill="#ffffff" font-size="16" font-weight="700">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 &amp; Analysis Agent</text>
<line x1="614" y1="793" x2="789" y2="793" stroke="#ffffff" stroke-opacity="0.1" stroke-width="1"/>
<circle cx="614" cy="808" r="2.5" fill="#ffe0ee"/>
@ -217,7 +217,7 @@
<text x="450" y="889" text-anchor="middle" fill="#636e8a" font-size="10">138 files · 16 skills · 7 brain files · 13 dashboard pages · MIT</text>
<rect x="200" y="905" width="500" height="24" rx="12" fill="#1a1a2e" stroke="#2d2d4a" stroke-width="0.5"/>
<text x="450" y="921" text-anchor="middle" fill="#fdcb6e" font-size="10">100% Free Tier — OpenRouter · 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>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@ -75,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"
fi
# Check Gemini CLI
if command -v gemini &>/dev/null; then
echo "Gemini CLI: found"
# Check agy (Antigravity CLI)
if command -v agy &>/dev/null; then
echo "agy (Antigravity): found"
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
# Create required directories

View File

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

View File

@ -151,8 +151,16 @@ class CronScheduler:
from apscheduler.schedulers.background import BackgroundScheduler as BS
from apscheduler.triggers.cron import CronTrigger as CT
except ImportError:
print("APScheduler not found. Run ./install.sh to install dependencies.")
return
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()

251
server.py
View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
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 json
@ -18,7 +18,7 @@ from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
@ -43,7 +43,7 @@ async def lifespan(app: FastAPI):
except Exception:
pass
app = FastAPI(title="Agentic OS", version="1.1.0", lifespan=lifespan)
app = FastAPI(title="Agentic OS", version="0.4.0", lifespan=lifespan)
# Load OpenRouter API key from Hermes .env
HERMES_ENV = Path.home() / ".hermes" / ".env"
@ -64,6 +64,22 @@ app.add_middleware(
allow_headers=["*"],
)
# No-cache for dashboard assets — SPA JS is loaded on demand and updated
# frequently during development (v0.4.0). Prevents stale-cached pages.
from starlette.middleware.base import BaseHTTPMiddleware
class NoCacheMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
path = request.url.path
if path.startswith("/dashboard") or path in ("/", "/index.html"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app.add_middleware(NoCacheMiddleware)
BASE_DIR = Path(__file__).parent.resolve()
# ─── Models ───────────────────────────────────────────────────────
@ -190,12 +206,9 @@ def check_agent(name: str) -> dict:
elif name == "hermes":
exists = shutil.which("hermes") is not None
status = "online" if exists else "offline"
elif name == "gemini":
# Gemini has valid OAuth tokens logged in
oauth = Path.home() / ".gemini" / "oauth_creds.json"
exists = shutil.which("gemini") is not None
logged_in = oauth.exists() and "ya29" in oauth.read_text()
status = "online" if exists and logged_in else "offline" if not exists else "warning"
elif name == "agy":
exists = shutil.which("agy") is not None
status = "online" if exists else "offline"
else:
status = "offline"
except Exception:
@ -206,8 +219,10 @@ def check_agent(name: str) -> dict:
@app.get("/api/status")
def get_status():
agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]]
skills = list_dir(BASE_DIR / "skills")
agents = [check_agent(a) for a in ["opencode", "hermes", "agy"]]
skills_dir = BASE_DIR / "skills"
skills = [d.name for d in skills_dir.iterdir()
if d.is_dir() and not d.name.startswith("_")] if skills_dir.exists() else []
return {
"status": "healthy",
"agents": agents,
@ -309,14 +324,14 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
if any(k in name for k in devops_keywords):
agent_choice = "opencode"
elif any(k in name for k in research_keywords):
agent_choice = "gemini"
agent_choice = "agy"
else:
# Check SKILL.md for explicit agent assignment
for line in skill_md.split('\n'):
line = line.strip()
if "Primary:" in line:
candidate = line.split(":")[-1].strip().lower()
if candidate in ("opencode", "hermes", "gemini"):
if candidate in ("opencode", "hermes", "agy"):
agent_choice = candidate
break
if agent_choice == "auto":
@ -620,6 +635,17 @@ 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."""
@ -726,7 +752,7 @@ def get_circuit_breaker():
@app.post("/api/circuit-breaker/trip")
def trip_circuit_breaker(data: dict):
agent = data.get("agent", "")
if agent not in ["opencode", "hermes", "gemini"]:
if agent not in ["opencode", "hermes", "agy"]:
raise HTTPException(400, "Invalid agent")
state = _get_circuit_state()
if agent not in state["agents"]:
@ -743,7 +769,7 @@ def trip_circuit_breaker(data: dict):
@app.post("/api/circuit-breaker/reset")
def reset_circuit_breaker(data: dict):
agent = data.get("agent", "")
if agent not in ["opencode", "hermes", "gemini"]:
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}
@ -867,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 err_msg or f"hermes returned exit code {code}"
elif agent == "gemini":
for attempt, (args, to) in enumerate([
(["-y", "-m", "gemini-2.5-flash"], 30),
(["-y"], 30),
]):
try:
code, out, err = run_cli(["gemini", *args, message], timeout=to)
except subprocess.TimeoutExpired:
if attempt == 0:
continue
return f"**Gemini CLI timed out.**\n\nTry running `gemini \"{message[:60]}\"` directly."
combined = ((err or "") + " " + (out or "")).strip()
if code == 0:
return (out or "").strip() or f"**Gemini CLI**\n\nProcessed your query.\n\n**Message:** {message}"
if attempt == 0 and ("model" in combined.lower() or "not found" in combined.lower()):
continue
if "auth" in combined.lower() or "login" in combined.lower() or "Please set an Auth" in combined:
return f"**Gemini needs auth**\n\nRun `gemini auth login` to authenticate.\n\n**Details:** {combined[:200]}"
return combined or f"gemini returned exit code {code}"
return "Gemini CLI did not return a response."
elif agent == "agy":
try:
code, out, err = run_cli(["agy", "--print", message], timeout=60)
except subprocess.TimeoutExpired:
return f"**agy timed out.**\n\nTry running `agy --print \"{message[:60]}\"` directly."
combined = ((err or "") + " " + (out or "")).strip()
if code == 0:
return (out or "").strip() or f"**agy**\n\nProcessed your query."
if "auth" in combined.lower() or "login" in combined.lower() or "api key" in combined.lower():
return f"**agy needs auth**\n\nRun `agy login` to authenticate.\n\n**Details:** {combined[:200]}"
return combined or f"agy returned exit code {code}"
else:
return f"Unknown agent: {agent}"
@ -900,8 +917,8 @@ def execute_agent(agent: str, message: str) -> str:
@app.post("/api/chat")
def chat(req: ChatRequest):
agent = req.agent.lower().strip()
if agent not in ["opencode", "hermes", "gemini"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
if agent not in ["opencode", "hermes", "agy"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, agy")
message = (req.message or "").strip()
if not message:
raise HTTPException(400, "Message cannot be empty")
@ -933,8 +950,99 @@ def chat(req: ChatRequest):
return {"status": "ok", "response": agent_msg}
@app.get("/api/chat/history")
def get_chat_history():
return load_chat_history()
def get_chat_history(q: str = Query(""), agent: str = Query(""), limit: int = Query(200, le=1000)):
"""Chat history with optional search/filter (v0.4.0)."""
history = load_chat_history()
messages = history.get("messages", [])
if q:
ql = q.lower()
messages = [m for m in messages if ql in m.get("content", "").lower()]
if agent:
messages = [m for m in messages if m.get("agent") == agent]
if limit:
messages = messages[-limit:]
return {"messages": messages, "total": len(messages), "query": q, "agent": agent}
# ─── Routes: Chat File Attachments (v0.4.0) ─────────────────────────
UPLOAD_DIR = BASE_DIR / "data" / "uploads"
UPLOAD_MAX_BYTES = 2 * 1024 * 1024 # 2 MB
UPLOAD_TTL_HOURS = 24
ALLOWED_UPLOAD_EXTENSIONS = {
".txt", ".md", ".log", ".json", ".yml", ".yaml", ".csv", ".py",
".js", ".ts", ".sh", ".toml", ".ini", ".env", ".cfg", ".xml",
".html", ".css", ".go", ".rs", ".sql", ".tsx", ".jsx",
}
def _cleanup_uploads(force: bool = False):
"""Delete upload files older than the TTL (24h)."""
if not UPLOAD_DIR.exists():
return
now = time.time()
for f in UPLOAD_DIR.glob("*"):
try:
if force or now - f.stat().st_mtime > UPLOAD_TTL_HOURS * 3600:
f.unlink(missing_ok=True)
except OSError:
pass
@app.post("/api/chat/upload")
async def chat_upload(
agent: str = Form(...),
message: str = Form(""),
file: UploadFile = File(...),
):
"""Chat with an optional file attachment (multipart/form-data, v0.4.0)."""
agent = agent.lower().strip()
if agent not in ["opencode", "hermes", "agy"]:
raise HTTPException(400, "Agent must be one of: opencode, hermes, agy")
raw = await file.read(UPLOAD_MAX_BYTES + 1)
if len(raw) > UPLOAD_MAX_BYTES:
raise HTTPException(413, "File too large (max 2 MB)")
filename = (file.filename or "attachment.txt").strip().replace("\\", "/").split("/")[-1]
if not re.match(r"^[A-Za-z0-9._-]+$", filename):
raise HTTPException(400, "Invalid file name")
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_UPLOAD_EXTENSIONS:
raise HTTPException(400, f"File type .{ext} not allowed")
_cleanup_uploads()
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
safe_name = f"{uuid.uuid4().hex[:8]}_{filename}"
upload_path = UPLOAD_DIR / safe_name
upload_path.write_bytes(raw)
append_audit({"action": "chat_upload", "file": filename, "size": len(raw)})
# Prepend file content to the message so the agent can read it
try:
text = raw.decode("utf-8", errors="replace")[:50000]
except Exception:
text = "[binary file — content not readable as text]"
attachment_block = f"--- File: {filename} ---\n{text}\n--- End {filename} ---"
message = (message or "").strip()
full_message = f"{attachment_block}\n\n{message}" if message else attachment_block
# Reuse the standard chat flow
user_msg = {
"id": str(uuid.uuid4())[:8],
"role": "user",
"agent": agent,
"content": full_message,
"timestamp": get_timestamp(),
}
save_chat_message(user_msg)
response_text = execute_agent(agent, full_message)
agent_msg = {
"id": str(uuid.uuid4())[:8],
"role": "assistant",
"agent": agent,
"content": response_text,
"timestamp": get_timestamp(),
}
save_chat_message(agent_msg)
append_audit({"action": "chat_message", "agent": agent, "msg_preview": (message or filename)[:50]})
return {"status": "ok", "response": agent_msg, "file": filename, "saved_as": safe_name}
# ═══════════════════════════════════════════════════════════════════
# v0.2.0 — New Feature Endpoints
@ -1348,7 +1456,7 @@ def search_journal(q: str = Query("")):
def get_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "gemini"]:
for name in ["opencode", "hermes", "agy"]:
info = check_agent(name)
info["uptime"] = 0
info["success_rate"] = 100
@ -1361,7 +1469,7 @@ def get_agent_health():
@app.get("/api/agents/{name}/stats")
def get_agent_stats(name: str):
try:
if name not in ["opencode", "hermes", "gemini"]:
if name not in ["opencode", "hermes", "agy"]:
raise HTTPException(400, "Invalid agent")
info = check_agent(name)
return {
@ -1382,7 +1490,7 @@ def get_agent_stats(name: str):
def refresh_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "gemini"]:
for name in ["opencode", "hermes", "agy"]:
info = check_agent(name)
agents.append(info)
append_audit({"action": "agent_health_refreshed"})
@ -1395,7 +1503,7 @@ def refresh_agent_health():
ROUTER_RULES = {
"opencode": ["code", "devops", "deploy", "git", "file", "terraform", "docker", "test", "build", "infra", "script"],
"hermes": ["memory", "schedule", "channel", "skill", "cron", "reminder", "brain", "plugin", "backup"],
"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")
@ -1420,7 +1528,7 @@ def router_suggest(data: RouterSuggest):
def router_route(data: RouterRoute):
try:
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}"}
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
return {
@ -1532,6 +1640,36 @@ def get_session_replay(session_id: str):
except Exception as e:
return {"session_id": session_id, "messages": [], "error": str(e)}
# ─── Routes: Code Diff Viewer (v0.4.0) ─────────────────────────────
DIFF_ALLOWED_PREFIXES = (
"brain/", "skills/", "server.py", "scheduler/", "dashboard/",
"prompts/", "standards/", "agents/", "data/", "registry/", "tests/",
)
@app.get("/api/diff")
def get_diff(file: str = Query(""), ref: str = Query("HEAD")):
"""Unified git diff for a file in the repo (v0.4.0)."""
try:
if not file:
raise HTTPException(400, "Query parameter 'file' is required")
# Prevent traversal — allow only repo-relative paths
resolved = (BASE_DIR / file).resolve()
if not str(resolved).startswith(str(BASE_DIR.resolve()) + os.sep) and resolved != BASE_DIR:
raise HTTPException(400, "Invalid file path")
if not resolved.exists():
raise HTTPException(404, "File not found")
rel = str(resolved.relative_to(BASE_DIR))
code, out, err = run_cli(["git", "-C", str(BASE_DIR), "diff", ref, "--", rel], timeout=10)
if code == 0 and not out.strip():
# no diff against ref — try working tree vs index
return {"file": rel, "diff": "", "changed": False, "ref": ref}
return {"file": rel, "diff": out or err, "changed": bool(out.strip()), "ref": ref}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
# ─── Routes: Dashboard Static Files ──────────────────────────────
dashboard_dir = BASE_DIR / "dashboard"
@ -1543,14 +1681,31 @@ def index():
html_file = BASE_DIR / "dashboard" / "index.html"
if html_file.exists():
content = html_file.read_text()
content = content.replace('href="styles.css"', 'href="/dashboard/styles.css"')
content = content.replace('src="utils.js"', 'src="/dashboard/utils.js"')
content = content.replace('src="api.js"', 'src="/dashboard/api.js"')
content = content.replace('src="app.js"', 'src="/dashboard/app.js"')
# Version-agnostic rewrite: handle any ?v= suffix (or none) so both
# freshly-served and previously-cached index.html resolve correctly.
content = re.sub(r'href="(styles\.css)(\?v=[0-9.]+)?"',
r'href="/dashboard/styles.css\2"', content)
for name in ("utils.js", "api.js", "app.js"):
content = re.sub(rf'src="{name}(?:\?v=[0-9.]+)?"',
rf'src="/dashboard/{name}"', content)
content = content.replace('pages/', '/dashboard/pages/')
return HTMLResponse(content=content)
return HTMLResponse("<h1>Agentic OS</h1><p>Dashboard not built yet. Run <code>./install.sh</code> first.</p>")
# Root-level fallbacks so stale cached index.html (which references
# root-relative core assets) still resolves even when /dashboard rewrite
# hasn't been seen by the browser (v0.4.1).
for _asset in ("styles.css", "utils.js", "api.js", "app.js"):
def _serve_asset(asset=_asset):
f = BASE_DIR / "dashboard" / asset
if not f.exists():
raise HTTPException(404, "Not found")
media = "text/css" if asset.endswith(".css") else "application/javascript"
return Response(content=f.read_bytes(), media_type=media)
app.add_api_route(f"/{_asset}", _serve_asset)
# ─── Favicon ──────────────────────────────────────────────────────
FAVICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#6c5ce7"/><stop offset="100%" stop-color="#fd79a8"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#g)"/><polygon points="16,6 24,11 24,21 16,26 8,21 8,11" fill="none" stroke="white" stroke-width="2" stroke-linejoin="round"/><circle cx="16" cy="16" r="3" fill="white"/></svg>'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,5 +36,15 @@ echo "Dashboard: http://127.0.0.1:${PORT}"
echo "Press Ctrl+C to stop"
echo ""
# Kill any stale process still bound to the port (from a crashed/previous run)
if command -v lsof &>/dev/null; then
PIDS=$(lsof -ti:"${PORT}" 2>/dev/null || true)
if [ -n "${PIDS}" ]; then
echo "Port ${PORT} in use — stopping stale process(es): ${PIDS}"
kill -9 ${PIDS} 2>/dev/null || true
sleep 1
fi
fi
# Start server using venv Python
"$PYTHON" server.py --port "${PORT}"

205
tests/harness.py Normal file
View File

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

35
tests/run_all.py Normal file
View File

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

133
tests/test_core.py Normal file
View File

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

View File

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

View File

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

122
tests/test_security.py Normal file
View File

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

199
tests/test_v040.py Normal file
View File

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