Wire up dynamic agent registry, integrations, agent-time tracking, UI modernization

- server.py: dynamic agent execution engine (cli/http/mcp), live agent
  registry (/api/agents register/unregister), integrations API, agent stats
  & skill scoring, kanban task delete, plugin uninstall, agent-time tracking
  + /test page
- dashboard: modernized agent-health, plugins marketplace, smart-router,
  styles; added agent-time monitor page + terminal page
- scheduler.py rewrite; start.sh / start-agentic-os.sh improvements
- skills: added firebase-*, notion-knowledge-capture, xcode-project-setup,
  audit/test plugins; updated learnings across skills
- brain/ docs + data registry files (agent-registry, integrations, router-keywords)
- .gitignore: exclude runtime artifacts (pid, graphify-out, logs)

Verified live on :8081 - all new endpoints return 200 and register/unregister
persists to data/agent-registry.json.
This commit is contained in:
Austin 2026-07-25 11:30:07 -07:00
parent 701df3e58e
commit 96d8281b9b
77 changed files with 4034 additions and 677 deletions

5
.gitignore vendored
View File

@ -15,3 +15,8 @@ audit/*
data/settings.json
data/chat-history.json
data/cost-history.json
.agentic-os.pid
graphify-out/
brain/graphify-out/
skills/*/graphify-out/
*.log

View File

@ -317,7 +317,7 @@ Your role is to act as the **kernel** of this system: route tasks to the right a
| Detail | Info |
|--------|------|
| **Budget** | Strictly free tiers (GCP Free, GitHub Student Pack, Colab, Kaggle) |
| **Active Project** | CloudMart — GCP DevOps multi-region e-commerce platform |
|| **Active Projects** | Kitchen-Inventory (catering kitchen management on antigravity), Pendleton-comms-live (walkie talkie for catering events on Railway) |
| **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) |

72
CHANGELOG.md Normal file
View File

@ -0,0 +1,72 @@
# Changelog
All notable changes to Agentic OS will be documented in this file.
## [v1.3.0] - 2026-06-28
### Added
- **Web Terminal** — Full bash shell in browser via xterm.js + WebSocket PTY (port 8082)
- 256-color support, terminal resizing, 10k line scrollback
- Reconnect and clear buttons
- Dark theme matching dashboard
- **Dynamic Agent Registry** — Register/remove custom agents via dashboard or API
- `GET /api/agents` — list all agents
- `POST /api/agents/register` — add custom agent (CLI, HTTP, or MCP type)
- `DELETE /api/agents/:name` — remove custom agent
- Agent config stored in `data/agent-registry.json`
- Custom agents auto-appear in status, health, router
- **Agent Health Dashboard** — Complete rewrite
- Card grid view with status indicators
- Registry table with type/source/actions
- "Add Agent" modal with form (name, binary, run args, health check, router keywords)
- Remove button for custom agents
- **Smart Router Extensibility** — Custom agents can register routing keywords via `data/router-keywords.json`
### Changed
- **Hermes Model** — Switched from `openrouter/owl-alpha` to free model chain:
1. `qwen/qwen3-next-80b-a3b-instruct:free` (primary)
2. `openai/gpt-oss-120b:free` (fallback 1)
3. `google/gemma-4-31b-it:free` (fallback 2)
4. `nvidia/nemotron-3-super-120b-a12b:free` (fallback 3)
- **Agent Discovery** — All endpoints now use dynamic registry instead of hardcoded `["opencode", "hermes", "gemini"]`
- `/api/status`, `/api/agents/health`, `/api/chat`, `/api/router/route`, `/api/agents/:name/stats`
- **Base URL** — Hermes now points to `https://openrouter.ai/api/v1` directly
### Fixed
- Audit log malformed timestamp entry removed
- Test artifacts cleaned from kanban, goals, cost history, brain
## [v1.2.0] - 2026-06-26
### Added
- Agent profiles (`agents/{opencode,hermes,gemini}/`)
- APScheduler integration (`scheduler/scheduler.py`)
- Plugin install from git repo or template (`POST /api/plugins/install`)
- Connected apps / integrations system
## [v1.1.0] - 2026-06-05
### Added
- Kanban board (6-column, 13 API endpoints)
- Goals system with auto-sync to `brain/active-projects.md`
- Journal with daily entries and full-text search
- Agent health monitoring (3 endpoints)
- Smart Router with keyword-based task routing
- Learning Analytics (skill eval scores, trends)
- Session Replay (browse opencode logs)
- Cost Analytics (token usage tracking)
- Backup/restore system (tar.gz snapshots)
## [v1.0.0] - 2026-05-17
### Added
- Initial release
- FastAPI backend (58+ endpoints)
- Vanilla JS SPA dashboard (21 pages)
- 3 built-in agents: OpenCode, Hermes, Gemini CLI
- 16 skills with eval scoring
- Brain/memory system (markdown files)
- Scheduler (APScheduler + Hermes cron)
- Plugin registry
- Standards system
- Prompt templates

28
OPERATIONAL-NOTES.md Normal file
View File

@ -0,0 +1,28 @@
# Operational Notes
## Server Debugging
- Logs: `tail -f /tmp/agentic-os.log`
- If browser shows blank dashboard, check for a single failing API endpoint crashing the SPA
- Malformed JSON in `audit.log` can crash `/api/audit` — the server skips bad lines but check if it's persistent
## WSL → Windows Browser
- Server-side 500 errors can look like connection failures from Windows browser
- WSL2 loopback and Windows loopback are separate interfaces — use `127.0.0.1` from WSL, not `localhost` from Windows
## Hermes Config
- Config: `~/.hermes/config.yaml`
- API keys: `~/.hermes/.env`
- Current model: `qwen/qwen3-next-80b-a3b-instruct:free` (OpenRouter)
- Fallbacks: gpt-oss-120b:free → gemma-4-31b-it:free → nemotron-3-super-120b:free
- Change model: `hermes config set model.default <model-id>`
## Agentic OS Docs
- Full docs: `docs/README.md`
- Changelog: `CHANGELOG.md`
- After API changes: `python3 scripts/update-api-docs.py`
- Skill: `software-development/agentic-os-docs`
## Brain Guardian Cron
- Job ID: `68221afb4d45`
- Runs every 2h via Hermes cron
- Outputs: brain/health-report.md, brain/skill-usage.md, brain/github-activity.md

View File

@ -3,7 +3,7 @@
## User
- Name: User
- Tech Stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
- Projects: CloudMart (GCP DevOps), Agentic OS
- Projects: Kitchen-Inventory (catering kitchen management on antigravity), Pendleton-comms-live (walkie talkie on Railway), Agentic OS
- Budget: Free tiers only
## Preferences

View File

@ -6,13 +6,25 @@
- Building complete agent orchestration platform
- 8 phases planned
## CloudMart
- Status: Active (ongoing)
- GCP DevOps multi-region e-commerce platform
- GKE Autopilot, Cloud SQL, Cloud CDN, Istio, Next.js
## Kitchen-Inventory (Active)
- Status: In development — catering kitchen management system
- Deployed on antigravity (server/host)
- Key features: invoice scanner (AI vision), barcode scanner, shelf life tracker, vendor invoice parsing
- Pipeline: Invoice image → pre-process → Gemini vision (primary) → GPT-5.5 (backup) → vendor detection → Claude (text→JSON) → math audit → Firebase write
- Vendor invoices: Sysco, Vern's, US Foods (each with custom prompts)
- Shelf scans: photo → Gemini → JSON array of items
- Code under review: geminiService.ts has 18 issues (4 critical) — wrong shelf prompt in GPT fallback, analyzeImage ignores prompt, no shelf retries, bad model names, bare catches
- [Waste log](goal:215a1f6a) — I have a couple of files that work as an applicate for a catering kitchen waste
## Pendelton-comms-live (Active)
- Status: In development — real-time voice communication app for catering events
- Purpose: Walkie talkie for employees spread out at big catering events
- GitHub: https://github.com/Linecheck-store/Pendelton-comms-live (private)
- Last worked on: 2026-02-28
- Tech: Node.js + Express + Socket.IO signaling, React + TypeScript frontend
- Audio: WebRTC peer-to-peer, STUN/TURN configured
- Deployed on Railway
- Known issues: audio cutting out on mobile Safari, reconnection logic needs improvement
- [Pendleton-comms-live](goal:ac785f05) — I need to finish my pendleton walkie talkie app before whiskey fest so my team c
- [Ship Agentic OS v1](goal:c24b3d85) — Complete audit and fix all issues
- [AUdit](goal:a0f84134) — Run a dependency audit
- [Push everything in the Kanban board down the line](goal:2c4b22ac) — It involves different skills to get different things done.

View File

@ -19,7 +19,7 @@ Agentic OS is a multi-agent orchestration platform that coordinates opencode, He
## Key Relationships
- User: Developer — AI/ML and DevOps enthusiast
- Tool stack: opencode, Hermes Agent, Gemini CLI, deepseek-v4-flash-free
- Active projects: CloudMart (GCP DevOps), Agentic OS
- Active projects: Kitchen-Inventory (on antigravity), Pendleton-comms-live (on Railway)
## Standing Decisions
- All memory is stored in markdown for cross-agent compatibility

19
brain/github-activity.md Normal file
View File

@ -0,0 +1,19 @@
# GitHub Activity Log
> Last checked: 2026-07-25 09:44
## Project: Agentic OS
- Repo: ~/agentic-os (github.com/modimihir07/agentic-os)
- Last commit: d9b1b0d "Update Hermes MEMORY.md with v0.2.0 features" (2026-06-05)
- Unpushed: 0 commits
- Recent (48h): none — last commit 50 days ago
- Uncommitted local changes: AGENTS.md, agents/hermes/USER.md (modified, not committed)
## Project: Kitchen-Inventory
- Repo: not cloned locally (deployed on antigravity server/host)
- No git activity data available from this machine
## Project: Pendelton-comms-live
- Repo: https://github.com/Linecheck-store/Pendelton-comms-live (private)
- Not cloned locally — no git activity data available
- Last worked on: 2026-02-28 (per active-projects.md)

32
brain/health-report.md Normal file
View File

@ -0,0 +1,32 @@
# Brain Health Report
> Generated: 2026-07-25 09:45
## Status: needs attention (activity stale ~8 days)
### Memory Files
- memory.md: 208 words (OK, limit 600)
- active-projects.md: 3 projects (2 need status refresh)
- log.md: 23 lines (OK)
- recent-decisions.md: 1 entry, 68 words (OK)
### Projects
- Agentic OS: Building Phase 1 — last commit 2026-06-05 (50 days ago); uncommitted changes to AGENTS.md and agents/hermes/USER.md sitting in working tree
- Kitchen-Inventory: In development — repo not cloned locally; geminiService.ts still flagged with 18 issues (4 critical) awaiting fixes
- Pendelton-comms-live: STALE — last worked on 2026-02-28 (~5 months); repo not cloned locally
### GitHub
- Agentic OS: 0 commits in 48h, 0 unpushed, 2 modified files uncommitted
- Kitchen-Inventory / Pendelton-comms-live: repos not cloned locally — no data
### Skills
- 2 skills with new learnings since 2026-06-28 (memory-consolidation, firebase-hosting-basics — both 2026-07-17)
- 0 skills with eval data (all 18 score-history.json files empty)
- Last skill_run: memory-consolidation 2026-07-17 (one run timed out)
- Recurring problem: agent timeouts on skill runs (opencode 06-28, hermes 07-17)
### Alerts
1. All 3 projects show no activity in the past week — flag for review.
2. Uncommitted changes in ~/agentic-os (AGENTS.md, agents/hermes/USER.md) — commit or discard.
3. Eval scoring system unused — score-history.json files never populated.
4. Kitchen-Inventory critical code-review findings (geminiService.ts) still open.

View File

@ -0,0 +1 @@
Journal entry content verification test

View File

@ -0,0 +1 @@
I got to figure out how to get the KanBan Board working right.

23
brain/log.md Normal file
View File

@ -0,0 +1,23 @@
# Wiki Log
> Chronological record of all wiki actions. Append-only.
> Format: `## [YYYY-MM-DD] action | subject`
> Actions: ingest, update, query, lint, create, archive, delete
> When this file exceeds 500 entries, rotate: rename to log-YYYY.md, start fresh.
## [2026-06-26] create | Wiki initialized
- Domain: Personal knowledge base (general-purpose second brain)
- Structure created with SCHEMA.md, index.md, log.md
- Directories: raw/{articles,papers,transcripts,assets}, entities, concepts, comparisons, queries
## [2026-06-26] ingest | Second Brain User Guide
- Source: user-provided document (full user guide)
- Created: concepts/second-brain-user-guide.md, concepts/gtd.md
- Updated: index.md (2 new entries, total: 3)
- Key topics: inbox workflow, page types, knowledge graph, journaling, people/project/idea/reference tracking, import/export, backup
## [2026-06-28] update | Project memory cleanup
- Removed CloudMart reference (not an active project)
- Added Kitchen-Inventory: catering kitchen management system (on antigravity) — invoice scanner, barcode scanner, shelf life tracker
- Added Pendleton-comms-live: walkie talkie app for catering events (on Railway) — WebRTC, Socket.IO, React/TS
- Updated: active-projects.md, memory.md, business-brain.md, AGENTS.md

View File

@ -4,6 +4,7 @@
- 3 agents configured: opencode, Hermes Agent, Gemini CLI
- Dashboard runs on FastAPI (Python) — localhost only
- All skills follow _template/ convention
- Memory consolidation: 2026-07-17 (run — all files clean, stale truncated run log from 2026-06-29 cleaned)
## User
- Name: User
@ -11,9 +12,19 @@
## Active Work
- Building Agentic OS from scratch (May 17, 2026)
- CloudMart GCP DevOps project ongoing
- Kitchen-Inventory — catering kitchen management system (on antigravity)
- Pendleton-comms-live — walkie talkie app for catering events (on Railway)
## Technology Preferences
- Preferred model: deepseek-v4-flash-free
- Free tiers: GCP Free, GitHub Student Dev Pack, Colab, Kaggle
- Knowledge management via markdown vaults
## Consolidated Insights (2026-06-29)
- **opencode** times out on non-code tasks (brainstorming, tdd-cycle, backup-skill, code-review, goal-planner, test-plugin) — route these to hermes
- **hermes** can also time out on complex multi-step tasks — keep queries short
- **daily-standup** is the most-run skill; hermes executes faster than opencode for it
- **Score tracking**: all 18 skills have empty score-history.json — no eval data yet
- **All learnings files** are concise (under 200 words each), no compression needed
- **No contradictions** found across skill learnings
- **recent-decisions.md**: 3 entries archived (all from 2026-05-17), 0 recent — no archiving needed

View File

@ -1,6 +1,10 @@
# Recent Decisions
## 2026-05-17
(No decisions in the last 30 days)
## Archived (older than 30 days)
### 2026-05-17
- Created Agentic OS project structure
- 3-agent architecture: opencode (code) + Hermes (memory/channels) + Gemini CLI (research)
- Web dashboard only (no CLI dashboard) — FastAPI + vanilla JS SPA

38
brain/skill-usage.md Normal file
View File

@ -0,0 +1,38 @@
# Skill Usage & Effectiveness
> Last updated: 2026-07-25
## Audit Log Summary (164 total entries)
| Action | Count |
|--------|-------|
| kanban_task_updated | 31 |
| skill_run | 22 |
| chat_message | 15 |
| brain_update | 11 |
| journal_saved | 10 |
| settings_updated | 9 |
| kanban_task_created | 8 |
| agent_registered | 7 |
| task_routed | 5 |
| standards_discovery_run | 5 |
## Recent Skill Runs
| Skill | Agent | Last Run | Result |
|-------|-------|----------|--------|
| memory-consolidation | hermes | 2026-07-17 | 1 completed, 1 timeout |
| firebase-hosting-basics | opencode | 2026-06-28 | timeout |
## Eval Scores
- All 18 score-history.json files are empty — no eval data has accumulated yet.
## New Learnings Since 2026-06-28
- memory-consolidation/learnings.md (updated 2026-07-17)
- firebase-hosting-basics/learnings.md (updated 2026-07-17)
- goal-planner, code-review, test-plugin (updated 2026-06-29)
## Skills Per Project
| Project | Skills Used |
|---------|-------------|
| Agentic OS | memory-consolidation, brain-guardian (Hermes cron), daily-standup, heartbeat |
| Kitchen-Inventory | code-review, firebase-hosting-basics |
| Pendelton-comms-live | (none recorded) |

View File

@ -0,0 +1,65 @@
# Agentic-OS · Agent Time Monitor
A localhost dashboard that tracks **total time the AI agents spent on the
agentic-os project**, plus an embedded graphify knowledge-graph view of the
codebase, with one-click sync of the total to **Mnemoverse** persistent memory.
## What it measures
The project's logs (`audit/audit.log`, `data/chat-history.json`,
`data/cost-history.json`) record *events* (a chat, a skill run, a write)
but not explicit session durations. Total agent time is therefore **estimated**
with a session-gap model:
- Each logged event is a timestamped "touch" by an agent.
- A *session* for an agent = a run of touches where each touch is within
`GAP` seconds (default 30 min) of the previous one.
- Session duration = (last touch first touch) + `TAIL` (default 2 min, to
account for work after the last logged event).
- `total_seconds` = sum of all an agent's session durations.
This is clearly labeled as an ESTIMATE in the UI. Tune `GAP`/`TAIL` via the
`AGENT_TIME_GAP` / `AGENT_TIME_TAIL` env vars.
## Run it
cd ~/agentic-os
./dashboard/start-monitor.sh # default port 8765
# or: python3 dashboard/serve_monitor.py --port 8765
Then open:
http://localhost:8765/dashboard/pages/agent-time-monitor.html
(Visiting http://localhost:8765/ redirects to the dashboard.)
## Endpoints (server: dashboard/serve_monitor.py)
- `GET /` → redirect to dashboard
- `GET /dashboard/.../agent-time-monitor.html` → the dashboard UI
- `GET /data/agent-time.json` → computed report (totals + per-agent)
- `GET /graphify-out/graph.html` → graphify code knowledge graph
- `POST /recompute` → re-run the analyzer from logs
- `POST /sync-mnemoverse` → push total to Mnemoverse (graceful)
## graphify integration
The interactive graph is produced by graphify (code-only, no API key needed):
cd ~/agentic-os
graphify . --code-only # writes graphify-out/{graph.json,graph.html,GRAPH_REPORT.md}
Rebuild any time the codebase changes; the dashboard iframe picks it up live.
## Mnemoverse integration
Set your key to enable the "Sync total to Mnemoverse" button:
export MNEMOVERSE_API_KEY=mk_live_xxxxxxxx
./dashboard/start-monitor.sh
The server calls `https://core.mnemoverse.com/api/v1/memory/write` with a
single memory holding the total agent time, the agent count, and the method.
Without a key (or offline) the button reports a clear, graceful message
instead of failing.
## Files
- `scripts/analyze_agent_time.py` — derives total/per-agent time from logs
- `dashboard/serve_monitor.py` — localhost server (stdlib only)
- `dashboard/pages/agent-time-monitor.html` — the dashboard UI
- `data/agent-time.json` — last computed report (regenerated each run)
- `graphify-out/` — graphify graph (built separately)

View File

@ -35,7 +35,10 @@ const api = {
getCost: () => api.get('/api/cost'),
recordCost: (data) => api.post('/api/cost/record', data),
getPlugins: () => api.get('/api/plugins'),
installPlugin: (name) => api.post('/api/plugins/install', { name }),
installPlugin: (name, repo_url) => api.post('/api/plugins/install', { name, repo_url }),
uninstallPlugin: (name) => api.del(`/api/plugins/${encodeURIComponent(name)}`),
getIntegrations: () => api.get('/api/integrations'),
addIntegration: (name, url, type) => api.post('/api/integrations', { name, url, type }),
getBackups: () => api.get('/api/backups'),
createBackup: () => api.post('/api/backup'),
restoreBackup: (file) => api.post('/api/backup/restore', { file }),
@ -85,4 +88,11 @@ const api = {
// Session Replay
listSessions: () => api.get('/api/sessions/list'),
getSessionReplay: (id) => api.get(`/api/sessions/${encodeURIComponent(id)}/replay`),
// Agent Time Monitor
getAgentTime: (recompute = false) => api.get(`/api/agent-time${recompute ? '?recompute=true' : ''}`),
recomputeAgentTime: () => api.post('/api/agent-time/recompute', {}),
// Agent Registry
getAgents: () => api.get('/api/agents'),
registerAgent: (data) => api.post('/api/agents/register', data),
unregisterAgent: (name) => api.del(`/api/agents/${encodeURIComponent(name)}`),
};

View File

@ -8,6 +8,7 @@
<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">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
</head>
<body>
@ -49,6 +50,7 @@
<a href="#smart-router" class="nav-item" data-page="smart-router"><span class="nav-icon">🧭</span><span class="nav-label">Smart Router</span></a>
<a href="#learning-analytics" class="nav-item" data-page="learning-analytics"><span class="nav-icon">📊</span><span class="nav-label">Learning Analytics</span></a>
<a href="#session-replay" class="nav-item" data-page="session-replay"><span class="nav-icon">🔄</span><span class="nav-label">Session Replay</span></a>
<a href="#agent-time" class="nav-item" data-page="agent-time"><span class="nav-icon"></span><span class="nav-label">Agent Time</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Management</div></div>
<a href="#cost" class="nav-item" data-page="cost"><span class="nav-icon">💰</span><span class="nav-label">Cost Analytics</span></a>
<a href="#plugins" class="nav-item" data-page="plugins"><span class="nav-icon">🔌</span><span class="nav-label">Plugins</span></a>

View File

@ -1,115 +1,220 @@
let agentHealthInterval = null;
async function renderAgentHealth() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<div class="page-title">Agent Health</div>
<div class="page-subtitle">Real-time monitoring of all 3 agents</div>
<h1 class="page-title">Agent Health</h1>
<p class="page-subtitle">Monitor and manage all connected agents</p>
</div>
<div class="btn-group">
<label class="switch" title="Auto-refresh every 5s">
<input type="checkbox" id="healthAutoRefresh" checked onchange="toggleHealthAutoRefresh()">
<span class="switch-slider"></span>
</label>
<span class="text-sm text-muted">Auto</span>
<button class="btn btn-primary" onclick="refreshAgentHealth()">🔄 Refresh Now</button>
<button class="btn btn-ghost" onclick="loadAgents()"> Refresh</button>
<button class="btn btn-primary" onclick="showAddAgentModal()">+ Add Agent</button>
</div>
</div>
<div id="agentHealthCards" class="grid grid-3" style="margin-bottom:20px">
<div class="skeleton" style="height:180px"></div>
<div class="skeleton" style="height:180px"></div>
<div class="skeleton" style="height:180px"></div>
<div id="agentCards" class="grid grid-3">
<div class="card"><div class="skeleton" style="height:120px"></div></div>
<div class="card"><div class="skeleton" style="height:120px"></div></div>
<div class="card"><div class="skeleton" style="height:120px"></div></div>
</div>
<div class="section-title">Health Overview</div>
<div class="card" id="healthOverviewCard">
<div class="loading"><div class="loading-spinner"></div><span>Loading health data...</span></div>
<div class="card mt-4">
<div class="card-header"><span class="card-title">Agent Registry</span></div>
<div id="agentTable"></div>
</div>
`;
await refreshAgentHealth();
if (document.getElementById('healthAutoRefresh')?.checked) {
startHealthAutoRefresh();
}
await loadAgents();
}
function startHealthAutoRefresh() {
stopHealthAutoRefresh();
agentHealthInterval = setInterval(refreshAgentHealth, 5000);
}
function stopHealthAutoRefresh() {
if (agentHealthInterval) {
clearInterval(agentHealthInterval);
agentHealthInterval = null;
}
}
function toggleHealthAutoRefresh() {
if (document.getElementById('healthAutoRefresh')?.checked) {
startHealthAutoRefresh();
} else {
stopHealthAutoRefresh();
}
}
async function refreshAgentHealth() {
async function loadAgents() {
try {
const data = await api.getAgentHealth();
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' };
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'})">
${agentIcons[a.name] || '🤖'}
</div>
<div class="agent-health-info">
<div class="agent-health-name" style="text-transform:capitalize">${a.name}</div>
<div class="agent-health-status">
<span class="agent-dot ${a.status === 'online' ? 'online' : a.status === 'warning' ? 'warning' : 'offline'}"></span>
<span style="text-transform:capitalize;color:var(--text-secondary)">${a.status}</span>
const { agents } = await api.getAgents();
const container = document.getElementById('agentCards');
const table = document.getElementById('agentTable');
// Card view
container.innerHTML = agents.map(a => {
const sc = statusColor(a.status);
return `
<div class="card agent-card">
<div class="agent-card-header">
<div class="agent-dot ${a.status}" style="width:12px;height:12px"></div>
<span style="font-weight:600;font-size:15px">${escapeHtml(a.display_name || a.name)}</span>
${a.builtin ? '<span class="nav-badge" style="background:var(--accent)">built-in</span>' : '<span class="nav-badge" style="background:var(--green)">custom</span>'}
</div>
<div class="agent-health-stats">
<div class="agent-health-stat">
<div class="agent-health-stat-value" style="color:var(--green)">${a.status === 'online' ? '100' : '0'}%</div>
<div class="agent-health-stat-label">Uptime</div>
</div>
<div class="agent-health-stat">
<div class="agent-health-stat-value" style="color:var(--accent-light)">${a.status === 'online' ? '✓' : '✗'}</div>
<div class="agent-health-stat-label">Reachable</div>
</div>
<div class="agent-health-stat">
<div class="agent-health-stat-value text-sm" style="font-size:11px;color:var(--text-muted)">${new Date(data.updated).toLocaleTimeString()}</div>
<div class="agent-health-stat-label">Last Check</div>
</div>
</div>
</div>
</div>
`).join('');
const overview = document.getElementById('healthOverviewCard');
if (overview) {
const online = agents.filter(a => a.status === 'online').length;
const total = agents.length;
overview.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between">
<div>
<div style="font-size:14px;font-weight:600">System Status</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">
${online}/${total} agents online · Last updated: ${new Date(data.updated).toLocaleTimeString()}
</div>
</div>
<div class="status-indicator ${online === total ? 'online' : online > 0 ? 'warning' : 'offline'}">
<span class="agent-dot ${online === total ? 'online' : online > 0 ? 'warning' : 'offline'}"></span>
${online === total ? 'All Online' : online > 0 ? 'Partial' : 'Offline'}
<div style="font-size:12px;color:var(--text-secondary);margin:8px 0">${escapeHtml(a.description || 'No description')}</div>
<div style="display:flex;gap:8px;align-items:center">
<span style="font-size:11px;color:${sc.text};text-transform:uppercase;font-weight:600">${a.status}</span>
<span style="font-size:11px;color:var(--text-muted)">${a.type || 'cli'}</span>
</div>
${!a.builtin ? `<div style="margin-top:12px"><button class="btn btn-ghost btn-sm" onclick="removeAgent('${a.name}')">Remove</button></div>` : ''}
</div>
`;
}
}).join('');
// Table view
table.innerHTML = `
<table class="agent-table">
<thead>
<tr>
<th>Agent</th>
<th>Type</th>
<th>Status</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${agents.map(a => `
<tr>
<td>
<div style="display:flex;align-items:center;gap:8px">
<div class="agent-dot ${a.status}" style="width:8px;height:8px"></div>
<span style="font-weight:500">${escapeHtml(a.display_name || a.name)}</span>
</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${escapeHtml(a.description || '')}</div>
</td>
<td style="text-transform:uppercase;font-size:11px">${a.type || 'cli'}</td>
<td><span style="color:${statusColor(a.status).text};font-size:11px;text-transform:uppercase;font-weight:600">${a.status}</span></td>
<td>${a.builtin ? '<span style="color:var(--text-muted)">built-in</span>' : '<span style="color:var(--green)">custom</span>'}</td>
<td>
${!a.builtin ? `<button class="btn btn-ghost btn-sm" onclick="removeAgent('${a.name}')">Remove</button>` : '<span style="color:var(--text-muted);font-size:11px">—</span>'}
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (err) {
const cards = document.getElementById('agentHealthCards');
if (cards) cards.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load health data</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div>`;
document.getElementById('agentCards').innerHTML = `<div class="card" style="grid-column:1/-1"><div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Error</div><div class="empty-state-desc">${escapeHtml(err.message)}</div></div></div>`;
}
}
function showAddAgentModal() {
showModal('Register New Agent', `
<div class="form-group">
<label class="form-label">Agent Name *</label>
<input id="agentName" class="form-input" placeholder="e.g. claude-code, codex, custom-llm">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Unique identifier (lowercase, no spaces)</div>
</div>
<div class="form-group">
<label class="form-label">Display Name</label>
<input id="agentDisplay" class="form-input" placeholder="e.g. Claude Code">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input id="agentDesc" class="form-input" placeholder="What this agent does">
</div>
<div class="form-group">
<label class="form-label">Agent Type</label>
<select id="agentType" class="form-select" onchange="updateAgentForm()">
<option value="cli">CLI Binary (local command)</option>
<option value="http">HTTP API (remote endpoint)</option>
<option value="mcp">MCP Server (Model Context Protocol)</option>
</select>
</div>
<div id="cliFields">
<div class="form-group">
<label class="form-label">Binary Name *</label>
<input id="agentBinary" class="form-input" placeholder="e.g. claude, codex">
</div>
<div class="form-group">
<label class="form-label">Run Arguments (one per line, use {message} for user input)</label>
<textarea id="agentRunArgs" class="form-textarea" rows="3" placeholder="claude&#10;-p&#10;{message}"></textarea>
</div>
<div class="form-group">
<label class="form-label">Health Check</label>
<select id="agentCheckType" class="form-select">
<option value="binary">Binary exists in PATH</option>
<option value="custom">Custom command</option>
</select>
</div>
<div class="form-group" id="customCheckGroup" style="display:none">
<label class="form-label">Custom Check Command</label>
<input id="agentCheckCmd" class="form-input" placeholder="e.g. claude --version">
</div>
</div>
<div id="httpFields" style="display:none">
<div class="form-group">
<label class="form-label">API URL *</label>
<input id="agentApiUrl" class="form-input" placeholder="https://api.example.com/chat">
</div>
<div class="form-group">
<label class="form-label">HTTP Method</label>
<select id="agentApiMethod" class="form-select">
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Timeout (seconds)</label>
<input id="agentTimeout" class="form-input" type="number" value="60">
</div>
<div class="form-group">
<label class="form-label">Router Keywords (comma-separated)</label>
<input id="agentKeywords" class="form-input" placeholder="code, debug, test, build">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Keywords for smart routing to this agent</div>
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="registerAgent()">Register Agent</button>
`);
// Show/hide custom check command field
document.getElementById('agentCheckType').addEventListener('change', (e) => {
document.getElementById('customCheckGroup').style.display = e.target.value === 'custom' ? 'block' : 'none';
});
}
function updateAgentForm() {
const type = document.getElementById('agentType').value;
document.getElementById('cliFields').style.display = type === 'cli' ? 'block' : 'none';
document.getElementById('httpFields').style.display = type === 'http' ? 'block' : 'none';
}
async function registerAgent() {
const name = document.getElementById('agentName').value.trim();
if (!name) { showToast('Agent name is required', 'error'); return; }
const type = document.getElementById('agentType').value;
const data = {
name: name,
display_name: document.getElementById('agentDisplay').value.trim() || name,
description: document.getElementById('agentDesc').value.trim(),
type: type,
binary: document.getElementById('agentBinary').value.trim() || name,
check_type: document.getElementById('agentCheckType').value === 'custom' ? 'custom' : 'binary',
check_command: document.getElementById('agentCheckCmd').value.trim(),
timeout: parseInt(document.getElementById('agentTimeout').value) || 60,
router_keywords: document.getElementById('agentKeywords').value.split(',').map(k => k.trim()).filter(Boolean),
};
if (type === 'cli') {
const runArgs = document.getElementById('agentRunArgs').value.trim();
if (runArgs) {
data.run_args = runArgs.split('\n').map(a => a.trim()).filter(Boolean);
}
} else if (type === 'http') {
data.api_url = document.getElementById('agentApiUrl').value.trim();
data.api_method = document.getElementById('agentApiMethod').value;
}
try {
await api.registerAgent(data);
closeModal();
showToast(`Agent '${name}' registered successfully`, 'success');
loadAgents();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function removeAgent(name) {
if (!confirm(`Remove agent '${name}'?`)) return;
try {
await api.unregisterAgent(name);
showToast(`Agent '${name}' removed`, 'success');
loadAgents();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}

View File

@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agentic-OS · Agent Time Monitor</title>
<style>
:root{
--bg:#0b0f17; --panel:#121826; --panel2:#0f1420; --line:#1f2a3d;
--txt:#e6edf6; --muted:#8a99b3; --accent:#5b8cff; --accent2:#37e0a6;
--warn:#ffcf5c; --danger:#ff6b6b;
--open:#5b8cff; --hermes:#37e0a6; --gemini:#ffcf5c; --sys:#9b8cff;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);
font-family:"Inter",system-ui,-apple-system,Segoe UI,Roboto,sans-serif;}
header{padding:18px 26px;border-bottom:1px solid var(--line);
display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}
header h1{font-size:18px;margin:0;font-weight:650;letter-spacing:.2px}
header .sub{color:var(--muted);font-size:13px;margin-top:2px}
.badge{font-size:11px;color:var(--accent2);border:1px solid #1d3b30;
background:#0e1c17;padding:3px 9px;border-radius:20px}
main{padding:22px 26px;max-width:1280px;margin:0 auto}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;margin-bottom:22px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px}
.card .label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.6px}
.card .big{font-size:30px;font-weight:700;margin-top:6px;line-height:1.1}
.card .meta{color:var(--muted);font-size:12px;margin-top:6px}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0 8px}
button{cursor:pointer;border:1px solid var(--line);background:var(--panel2);
color:var(--txt);padding:9px 14px;border-radius:10px;font-size:13px;font-weight:600}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
button:disabled{opacity:.5;cursor:not-allowed}
.status{font-size:13px;color:var(--muted)}
.status.ok{color:var(--accent2)} .status.err{color:var(--danger)}
table{width:100%;border-collapse:collapse;margin-top:6px}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);font-size:13px}
th{color:var(--muted);font-weight:600;text-transform:uppercase;font-size:11px;letter-spacing:.5px}
td.num{text-align:right;font-variant-numeric:tabular-nums}
.bar{height:9px;border-radius:6px;background:linear-gradient(90deg,var(--accent),var(--accent2));
width:0;transition:width 1s ease-out}
.barwrap{background:#0c1320;border-radius:6px;overflow:hidden;min-width:90px}
section{background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:16px 18px;margin-bottom:22px}
section h2{font-size:14px;margin:0 0 4px;font-weight:650}
section .hint{color:var(--muted);font-size:12px;margin-bottom:10px}
iframe{border:1px solid var(--line);border-radius:12px;width:100%;height:620px;background:#0a0d14}
.pill{display:inline-block;font-size:11px;padding:2px 8px;border-radius:20px;
border:1px solid var(--line);color:var(--muted);margin-right:6px}
code{background:#0c1320;padding:1px 6px;border-radius:5px;font-size:12px}
a{color:var(--accent)}
</style>
</head>
<body>
<header>
<div>
<h1>Agentic-OS · Agent Time Monitor</h1>
<div class="sub">Total AI-agent time spent on the project · derived from audit &amp; chat logs</div>
</div>
<div class="badge" id="project-badge">project: agentic-os</div>
</header>
<main>
<section style="background:linear-gradient(135deg,#121826,#0e1626)">
<div class="grid">
<div class="card">
<div class="label">Total Agent Time</div>
<div class="big" id="total-time"></div>
<div class="meta" id="total-meta">loading…</div>
</div>
<div class="card">
<div class="label">Agents Active</div>
<div class="big" id="agent-count"></div>
<div class="meta" id="agent-meta">distinct agents</div>
</div>
<div class="card">
<div class="label">Logged Events</div>
<div class="big" id="event-count"></div>
<div class="meta" id="event-meta">audit + chat + cost</div>
</div>
<div class="card">
<div class="label">Coverage</div>
<div class="big" id="coverage"></div>
<div class="meta" id="coverage-meta">first → last activity</div>
</div>
</div>
<div class="row">
<button class="primary" id="refresh-btn">↻ Recompute</button>
<button id="sync-btn">⇪ Sync total to Mnemoverse</button>
<span class="status" id="status">Ready. Estimates are session-gap based.</span>
</div>
</section>
<section>
<h2>Time per Agent</h2>
<div class="hint">Each bar is the estimated total time an agent was engaged with the project
(sessions of activity within a 30-min gap window, plus a 2-min tail).</div>
<table>
<thead><tr><th>Agent</th><th>Total Time</th><th style="width:34%">Share</th><th class="num">Sessions</th><th class="num">Touches</th></tr></thead>
<tbody id="agent-rows"><tr><td colspan="5" class="meta">loading…</td></tr></tbody>
</table>
</section>
<section>
<h2>Codebase Knowledge Graph · graphify</h2>
<div class="hint">Interactive graphify visualization of the <code>agentic-os</code> codebase
(nodes = code symbols/modules, edges = relationships). Rebuild with
<code>graphify . --code-only</code> inside the project.</div>
<div class="row">
<span class="pill" id="g-nodes">nodes: —</span>
<span class="pill" id="g-edges">edges: —</span>
<span class="pill" id="g-communities">communities: —</span>
<a href="/graphify-out/graph.html" target="_blank">↗ open full graph in new tab</a>
</div>
<iframe src="/graphify-out/graph.html" title="graphify knowledge graph" loading="lazy"></iframe>
</section>
</main>
<script>
const API = {
async get(path){ const r = await fetch(path); return await r.json(); },
};
function fmtHMS(s){
s = Math.floor(s); const h=Math.floor(s/3600), m=Math.floor((s%3600)/60), sec=s%60;
return h+"h "+m+"m "+sec+"s";
}
function fmtDuration(s){
s=Math.floor(s); const h=Math.floor(s/3600), m=Math.floor((s%3600)/60);
if(h>0) return h+"h "+m+"m"; return m+"m "+s%60+"s";
}
async function load(){
setStatus("Loading…");
try{
const data = await API.get("/data/agent-time.json");
document.getElementById("total-time").textContent = data.total_human;
document.getElementById("total-meta").textContent =
"estimated · " + data.method;
const agents = data.agents || {};
const keys = Object.keys(agents);
document.getElementById("agent-count").textContent = keys.length;
document.getElementById("event-count").textContent = data.event_count;
document.getElementById("coverage").textContent =
(data.first_seen||"—").slice(0,10) + " → " + (data.last_seen||"—").slice(0,10);
// rows
const max = Math.max(...keys.map(k=>agents[k].total_seconds), 1);
const sorted = keys.sort((a,b)=>agents[b].total_seconds-agents[a].total_seconds);
const tb = document.getElementById("agent-rows");
tb.innerHTML = "";
sorted.forEach((k,i)=>{
const a = agents[k];
const pct = (a.total_seconds/max*100).toFixed(1);
const tr = document.createElement("tr");
tr.innerHTML =
`<td>${k}</td>`+
`<td><b>${fmtDuration(a.total_seconds)}</b></td>`+
`<td><div class="barwrap"><div class="bar" data-w="${pct}" style="background:${
k==='system'?'var(--sys)':k==='hermes'?'var(--hermes)':k==='opencode'?'var(--open)':k==='gemini'?'var(--gemini)':'linear-gradient(90deg,var(--accent),var(--accent2))'
}"></div></div></td>`+
`<td class="num">${a.sessions}</td>`+
`<td class="num">${a.touches}</td>`;
tb.appendChild(tr);
});
// animate bars
setTimeout(()=>document.querySelectorAll(".bar").forEach(b=>b.style.width=b.dataset.w+"%"),60);
// graph meta
if(data.graph){
document.getElementById("g-nodes").textContent = "nodes: "+(data.graph.nodes||"—");
document.getElementById("g-edges").textContent = "edges: "+(data.graph.edges||"—");
document.getElementById("g-communities").textContent = "communities: "+(data.graph.communities||"—");
}
setStatus("✓ Computed at "+(data.generated_at||"").slice(0,19).replace("T"," ")+" UTC", "ok");
}catch(e){
setStatus("Failed to load agent-time.json: "+e.message, "err");
}
}
async function recompute(){
setStatus("Recomputing from logs…");
document.getElementById("refresh-btn").disabled = true;
try{
const r = await fetch("recompute", {method:"POST"});
const j = await r.json();
if(!j.ok){ throw new Error(j.error||"recompute failed"); }
setStatus("✓ Recomputed: "+j.total_human+" across "+j.agents+" agents", "ok");
await load();
}catch(e){
setStatus("Recompute error: "+e.message, "err");
}finally{
document.getElementById("refresh-btn").disabled = false;
}
}
async function syncMnemoverse(){
const btn = document.getElementById("sync-btn");
btn.disabled = true; setStatus("Pushing total to Mnemoverse…");
try{
const r = await fetch("sync-mnemoverse", {method:"POST"});
const j = await r.json();
if(j.ok){
setStatus("✓ Synced to Mnemoverse: "+j.message, "ok");
}else{
// graceful: show guidance (no key / offline)
setStatus("⚠ Mnemoverse: "+(j.error||"not synced")+(j.hint?(" — "+j.hint):""), "err");
}
}catch(e){
setStatus("Mnemoverse sync error: "+e.message, "err");
}finally{ btn.disabled=false; }
}
function setStatus(msg, cls){ const s=document.getElementById("status"); s.textContent=msg; s.className="status"+(cls?(" "+cls):""); }
document.getElementById("refresh-btn").addEventListener("click", recompute);
document.getElementById("sync-btn").addEventListener("click", syncMnemoverse);
load();
</script>
</body>
</html>

View File

@ -0,0 +1,176 @@
// Agent Time Monitor page — total time AI agents spent on the project.
// Data comes from the backend (/api/agent-time), which derives totals
// from event logs via a session-gap estimator. No Mnemoverse widget.
function fmtDuration(seconds) {
seconds = Math.floor(seconds || 0);
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function fmtHMS(seconds) {
seconds = Math.floor(seconds || 0);
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
}
// Agent accent colors, matching agent-health / known identities.
function agentColor(agent) {
const map = {
system: 'var(--purple, #9b8cff)',
hermes: 'var(--green, #37e0a6)',
opencode: 'var(--accent, #6c5ce7)',
gemini: 'var(--yellow, #ffcf5c)',
codex: '#5b8cff',
jarvis: '#ff79c6',
kilocode: '#74b9ff',
test_claude: '#a29bfe',
test: '#7f8fa6',
};
return map[agent] || 'linear-gradient(90deg, var(--accent), var(--green))';
}
async function renderAgentTime() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Agent Time</h1>
<p class="page-subtitle">Total time AI agents have spent on this project</p>
</div>
<div class="btn-group">
<button class="btn btn-ghost" onclick="loadAgentTime(false)"> Refresh</button>
<button class="btn btn-primary" onclick="recomputeAgentTime()"> Recompute</button>
</div>
</div>
<div id="agentTimeBody">
<div class="grid grid-4">
<div class="card"><div class="skeleton" style="height:90px"></div></div>
<div class="card"><div class="skeleton" style="height:90px"></div></div>
<div class="card"><div class="skeleton" style="height:90px"></div></div>
<div class="card"><div class="skeleton" style="height:90px"></div></div>
</div>
</div>
`;
await loadAgentTime(false);
}
async function loadAgentTime(recompute) {
const body = document.getElementById('agentTimeBody');
try {
const data = await api.getAgentTime(recompute);
const agents = data.agents || {};
const keys = Object.keys(agents);
const max = Math.max(1, ...keys.map(k => agents[k].total_seconds));
const sorted = keys.slice().sort((a, b) => agents[b].total_seconds - agents[a].total_seconds);
const g = data.graph || {};
const graphCard = g.nodes != null ? `
<div class="card">
<div class="card-header"><span class="card-title">Codebase Graph (graphify)</span></div>
<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:8px">
<div><div style="font-size:22px;font-weight:700">${g.nodes}</div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">nodes</div></div>
<div><div style="font-size:22px;font-weight:700">${g.edges}</div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">edges</div></div>
<div><div style="font-size:22px;font-weight:700">${g.communities != null ? g.communities : '—'}</div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">communities</div></div>
</div>
<a class="btn btn-ghost btn-sm mt-3" href="/dashboard/graphify-out/graph.html" target="_blank"> Open interactive graph</a>
</div>` : '';
body.innerHTML = `
<div class="grid grid-4">
<div class="card">
<div class="card-header"><span class="card-title">Total Agent Time</span></div>
<div style="font-size:30px;font-weight:800;margin-top:6px">${escapeHtml(data.total_human || '0h 0m 0s')}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px">estimated · ${escapeHtml((data.method || '').replace('session-gap estimate ', ''))}</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Active Agents</span></div>
<div style="font-size:30px;font-weight:800;margin-top:6px">${keys.length}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px">distinct agents</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Logged Events</span></div>
<div style="font-size:30px;font-weight:800;margin-top:6px">${data.event_count || 0}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px">audit + chat + cost</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Coverage</span></div>
<div style="font-size:18px;font-weight:700;margin-top:8px">${(data.first_seen || '—').slice(0, 10)}</div>
<div style="font-size:13px;color:var(--text-muted)"> ${(data.last_seen || '—').slice(0, 10)}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">first last activity</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header"><span class="card-title">Time per Agent</span>
<span style="font-size:11px;color:var(--text-muted)">sessions of activity (30-min gap) + 2-min tail</span></div>
<div class="table-wrap">
<table class="agent-table">
<thead><tr><th>Agent</th><th>Total Time</th><th>Share</th><th class="num">Sessions</th><th class="num">Touches</th></tr></thead>
<tbody>
${sorted.map(k => {
const a = agents[k];
const pct = ((a.total_seconds / max) * 100).toFixed(1);
return `
<tr>
<td><span style="font-weight:600">${escapeHtml(k)}</span></td>
<td><b>${fmtDuration(a.total_seconds)}</b></td>
<td style="min-width:140px">
<div style="height:8px;border-radius:6px;background:var(--bg-elevated,#0c1320);overflow:hidden">
<div style="height:100%;width:0;background:${agentColor(k)};border-radius:6px;transition:width .8s ease-out" data-w="${pct}%"></div>
</div>
</td>
<td class="num">${a.sessions}</td>
<td class="num">${a.touches}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
</div>
${graphCard ? `<div class="card mt-4">${graphCard}</div>` : ''}
<div style="font-size:11px;color:var(--text-muted);margin-top:14px">
Computed ${escapeHtml((data.generated_at || '').replace('T', ' ').slice(0, 19))} UTC.
Totals are <b>estimates</b> the project logs events, not durations; see the method in the report.
</div>
`;
// animate bars
setTimeout(() => {
document.querySelectorAll('#agentTimeBody [data-w]').forEach(el => { el.style.width = el.dataset.w; });
}, 60);
} catch (err) {
body.innerHTML = `
<div class="card">
<div class="empty-state">
<div class="empty-state-icon"></div>
<div class="empty-state-title">Could not load agent-time report</div>
<div class="empty-state-desc">${escapeHtml(err.message)}</div>
<button class="btn btn-primary mt-3" onclick="recomputeAgentTime()">Recompute now</button>
</div>
</div>`;
}
}
async function recomputeAgentTime() {
const body = document.getElementById('agentTimeBody');
showToast('Recomputing from logs…', 'info');
try {
const res = await api.recomputeAgentTime();
if (res.ok) {
showToast(`Recomputed: ${res.total_human} across ${res.agents} agents`, 'success');
await loadAgentTime(false);
} else {
showToast('Recompute failed', 'error');
}
} catch (err) {
showToast(`Recompute error: ${err.message}`, 'error');
}
}

View File

@ -77,7 +77,8 @@ async function saveJournalEntry() {
if (wc) wc.textContent = words;
} catch (err) {
const status = document.getElementById('journalSaveStatus');
if (status) status.textContent = 'Save failed';
if (status) status.textContent = 'Save failed: ' + err.message;
console.error('Journal save error:', err);
}
}

View File

@ -4,68 +4,178 @@ async function renderPlugins() {
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Plugin Registry</h1>
<p class="page-subtitle">Manage installed plugins and extensions</p>
<p class="page-subtitle">Install skills from GitHub or create your own</p>
</div>
<button class="btn btn-primary" onclick="showInstallPlugin()">+ Install</button>
<button class="btn btn-primary" onclick="showInstallPlugin()">+ Install from GitHub</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px">
<div class="card">
<div class="card-title" style="margin-bottom:12px">Install a Plugin</div>
<div class="form-group">
<label class="form-label">GitHub Repo URL</label>
<input id="pluginRepoInput" class="form-input" placeholder="https://github.com/you/your-skill-repo">
<div class="form-hint">Paste a GitHub URL to clone a skill from a repo</div>
</div>
<div class="form-group">
<label class="form-label">Plugin Name (optional)</label>
<input id="pluginNameInput" class="form-input" placeholder="auto-detect from repo URL">
</div>
<button class="btn btn-primary" onclick="installPluginFromRepo()">Install</button>
<div id="installStatus" style="margin-top:8px;font-size:12px"></div>
</div>
<div class="card">
<div class="card-title" style="margin-bottom:12px">Connect External App</div>
<div class="form-group">
<label class="form-label">App Name</label>
<input id="integrationNameInput" class="form-input" placeholder="e.g., Slack, Zapier, n8n">
</div>
<div class="form-group">
<label class="form-label">Webhook / URL</label>
<input id="integrationUrlInput" class="form-input" placeholder="https://hooks.slack.com/...">
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select id="integrationType" class="form-input">
<option value="webhook">Webhook</option>
<option value="api">REST API</option>
<option value="zapier">Zapier</option>
<option value="n8n">n8n</option>
</select>
</div>
<button class="btn btn-primary" onclick="addIntegration()">Connect</button>
<div id="integrationStatus" style="margin-top:8px;font-size:12px"></div>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<div class="card-title" style="margin-bottom:12px">Connected Apps</div>
<div id="integrationsList"></div>
</div>
<div id="pluginList"><div class="loading"><div class="loading-spinner"></div></div></div>
`;
try {
const data = await api.getPlugins();
const plugins = data.plugins || [];
const container = document.getElementById('pluginList');
const [pluginsData, integrationsData] = await Promise.all([
api.getPlugins(),
api.getIntegrations(),
]);
if (plugins.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🔌</div><div class="empty-state-title">No plugins installed</div><div class="empty-state-desc">Install plugins from the registry or create your own</div></div>';
return;
}
container.innerHTML = `
<div class="table-wrapper">
<table>
<thead><tr><th>Plugin</th><th>Version</th><th>Type</th><th>Installed</th></tr></thead>
<tbody>
${plugins.map(p => `
<tr>
<td><strong>${p.name}</strong></td>
<td><code>${p.version || '1.0.0'}</code></td>
<td><span class="badge badge-info">${p.type || 'skill'}</span></td>
<td style="font-size:12px;color:var(--text-muted)">${formatDate(p.installed)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div style="font-size:12px;color:var(--text-muted);text-align:right;margin-top:8px">${plugins.length} plugin${plugins.length !== 1 ? 's' : ''}</div>
`;
renderPluginTable(pluginsData.plugins || []);
renderIntegrations(integrationsData.integrations || []);
} catch (err) {
document.getElementById('pluginList').innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">${escapeHtml(err.message)}</div></div>`;
}
}
function showInstallPlugin() {
showModal('Install Plugin', `
<div class="form-group">
<label class="form-label">Plugin Name</label>
<input id="pluginNameInput" class="form-input" placeholder="e.g., my-custom-skill">
<div class="form-hint">Enter the name of the plugin to install from the registry</div>
function renderPluginTable(plugins) {
const container = document.getElementById('pluginList');
if (plugins.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🔌</div><div class="empty-state-title">No plugins installed</div><div class="empty-state-desc">Install plugins from GitHub or create your own</div></div>';
return;
}
container.innerHTML = `
<div class="card">
<div class="card-title">Installed Plugins (${plugins.length})</div>
<div class="table-wrapper">
<table>
<thead><tr><th>Plugin</th><th>Version</th><th>Type</th><th>Source</th><th>Installed</th><th></th></tr></thead>
<tbody>
${plugins.map(p => `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td><code>${p.version || '1.0.0'}</code></td>
<td><span class="badge badge-info">${p.type || 'skill'}</span></td>
<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(p.source || '')}">${escapeHtml(p.source || 'built-in')}</td>
<td style="font-size:12px;color:var(--text-muted)">${formatDate(p.installed)}</td>
<td>${p.source ? `<button class="btn btn-ghost" style="padding:4px 8px;font-size:11px" onclick="uninstallPlugin('${escapeHtml(p.name)}')">Remove</button>` : '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`, `
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="installPlugin()">Install</button>
`);
`;
}
async function installPlugin() {
function renderIntegrations(integrations) {
const container = document.getElementById('integrationsList');
if (integrations.length === 0) {
container.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">No external apps connected yet</div>';
return;
}
container.innerHTML = `<div style="display:flex;flex-direction:column;gap:8px">
${integrations.map(i => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-secondary);border-radius:6px">
<div>
<strong style="font-size:13px">${escapeHtml(i.name)}</strong>
<span class="badge badge-info" style="margin-left:8px">${escapeHtml(i.type)}</span>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${escapeHtml(i.url)}</div>
</div>
<span style="font-size:11px;color:var(--accent)"> connected</span>
</div>
`).join('')}
</div>`;
}
function showInstallPlugin() {
document.getElementById('pluginRepoInput').focus();
}
async function installPluginFromRepo() {
const repoUrl = document.getElementById('pluginRepoInput').value.trim();
const name = document.getElementById('pluginNameInput').value.trim();
if (!name) { showToast('Plugin name required', 'warning'); return; }
const statusEl = document.getElementById('installStatus');
if (!repoUrl) {
statusEl.innerHTML = '<span style="color:#ef4444">Please enter a GitHub repo URL</span>';
return;
}
statusEl.innerHTML = '<span style="color:var(--accent)">Installing...</span>';
try {
const r = await api.installPlugin(name);
closeModal();
showToast(r.status === 'already_installed' ? 'Already installed' : `"${name}" installed`, r.status === 'already_installed' ? 'info' : 'success');
const r = await api.installPlugin(name || repoUrl, repoUrl);
statusEl.innerHTML = `<span style="color:#22c55e">✓ ${escapeHtml(r.plugin)} installed</span>`;
document.getElementById('pluginRepoInput').value = '';
document.getElementById('pluginNameInput').value = '';
setTimeout(() => renderPlugins(), 2000);
} catch (err) {
statusEl.innerHTML = `<span style="color:#ef4444">Error: ${escapeHtml(err.message)}</span>`;
}
}
async function uninstallPlugin(name) {
if (!confirm(`Remove "${name}"?`)) return;
try {
await api.uninstallPlugin(name);
showToast(`${name} removed`, 'success');
renderPlugins();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function addIntegration() {
const name = document.getElementById('integrationNameInput').value.trim();
const url = document.getElementById('integrationUrlInput').value.trim();
const type = document.getElementById('integrationType').value;
const statusEl = document.getElementById('integrationStatus');
if (!name || !url) {
statusEl.innerHTML = '<span style="color:#ef4444">Name and URL required</span>';
return;
}
statusEl.innerHTML = '<span style="color:var(--accent)">Connecting...</span>';
try {
const r = await api.addIntegration(name, url, type);
statusEl.innerHTML = `<span style="color:#22c55e">✓ ${escapeHtml(r.name)} connected</span>`;
document.getElementById('integrationNameInput').value = '';
document.getElementById('integrationUrlInput').value = '';
setTimeout(() => renderPlugins(), 2000);
} catch (err) {
statusEl.innerHTML = `<span style="color:#ef4444">Error: ${escapeHtml(err.message)}</span>`;
}
}

View File

@ -1,3 +1,16 @@
// Per-agent display metadata (icons + role blurb). Built-ins + known custom agents.
const ROUTER_AGENT_META = {
opencode: { icon: '🔧', blurb: 'Code & DevOps' },
hermes: { icon: '⚡', blurb: 'Memory & Scheduling' },
gemini: { icon: '🧠', blurb: 'Research & Analysis' },
jarvis: { icon: '🤖', blurb: 'Local-first AI / Deep Research' },
kilocode: { icon: '💻', blurb: 'AI Coding Assistant' },
codex: { icon: '🧩', blurb: 'Code Generation' },
};
function routerAgentMeta(name) {
return ROUTER_AGENT_META[name] || { icon: '🤖', blurb: 'Custom agent' };
}
async function renderSmartRouter() {
const content = document.getElementById('pageContent');
content.innerHTML = `
@ -20,9 +33,7 @@ async function renderSmartRouter() {
<label class="form-label">Route to Agent</label>
<select class="form-select" id="routerAgentSelect">
<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>
<!-- agent options injected by loadRouterAgents() -->
</select>
</div>
<button class="btn btn-primary" onclick="suggestRouter()" style="margin-bottom:16px">🤖 Suggest Agent</button>
@ -32,14 +43,51 @@ async function renderSmartRouter() {
<div id="routerResult"></div>
<div class="section-title" style="margin-top:20px">Routing Rules</div>
<div class="card">
<table>
<table id="routerRulesTable">
<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>
<!-- rows injected by loadRouterAgents() -->
</table>
</div>
`;
await loadRouterAgents();
}
// Pull the live agent list from the server so the dropdown + rules table
// always reflect every registered agent (built-in AND custom).
async function loadRouterAgents() {
let agents = [];
try {
const data = await api.getAgents();
agents = (data.agents || []).map(a => a.name);
} catch (e) {
// Fallback to known built-ins if the API is unreachable.
agents = ['opencode', 'hermes', 'gemini'];
}
const select = document.getElementById('routerAgentSelect');
const table = document.getElementById('routerRulesTable');
if (!select || !table) return;
// Populate dropdown (preserve the Auto option already in the DOM).
agents.forEach(name => {
const meta = routerAgentMeta(name);
const opt = document.createElement('option');
opt.value = name;
opt.textContent = `${meta.icon} ${name} (${meta.blurb})`;
select.appendChild(opt);
});
// Populate routing-rules table.
const rows = agents.map(name => {
const meta = routerAgentMeta(name);
const kw = (window._routerKeywords && window._routerKeywords[name]) || [];
return `<tr>
<td><strong>${meta.icon} ${name}</strong></td>
<td>${meta.blurb}</td>
<td class="text-muted text-sm">${kw.join(', ') || '—'}</td>
</tr>`;
}).join('');
table.innerHTML = `<tr><th>Agent</th><th>Best For</th><th>Keywords</th></tr>${rows}`;
}
async function suggestRouter() {
@ -49,8 +97,13 @@ async function suggestRouter() {
if (btn) { btn.disabled = true; btn.textContent = '⏳ Thinking...'; }
try {
const data = await api.suggestRouter(task);
// Cache keyword map from scores for the rules table refresh.
window._routerKeywords = data.scores ? Object.keys(data.scores).reduce((m, a) => m, {}) : {};
const result = document.getElementById('routerResult');
const agentIcons = { opencode: '🔧', hermes: '⚡', gemini: '🧠' };
const agentIcons = {
opencode: '🔧', hermes: '⚡', gemini: '🧠',
jarvis: '🤖', kilocode: '💻', codex: '🧩'
};
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

@ -1,120 +1,150 @@
let _termInstance = null;
let _termSocket = null;
function loadXterm() {
if (window.Terminal && window.FitAddon) return Promise.resolve();
return new Promise((resolve, reject) => {
if (!document.querySelector('link[data-xterm-css]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css';
link.setAttribute('data-xterm-css', '1');
document.head.appendChild(link);
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js';
script.onload = () => {
const fitScript = document.createElement('script');
fitScript.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js';
fitScript.onload = () => resolve();
fitScript.onerror = () => reject(new Error('Failed to load xterm-addon-fit'));
document.body.appendChild(fitScript);
};
script.onerror = () => reject(new Error('Failed to load xterm.js'));
document.body.appendChild(script);
});
}
function setTerminalStatus(kind, text) {
const el = document.getElementById('terminalStatus');
if (!el) return;
el.textContent = text;
el.className = `badge badge-${kind === 'online' ? 'success' : kind === 'offline' ? 'danger' : 'warning'}`;
}
function closeTerminalSession() {
if (window._terminalResizeHandler) {
window.removeEventListener('resize', window._terminalResizeHandler);
window._terminalResizeHandler = null;
}
if (_termSocket) {
try { _termSocket.close(); } catch {}
_termSocket = null;
}
if (_termInstance) {
try { _termInstance.dispose(); } catch {}
_termInstance = null;
}
}
async function renderTerminal() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Terminal</h1>
<p class="page-subtitle">A real, interactive shell running on this machine</p>
<p class="page-subtitle">Full bash shell in the browser WSL environment</p>
</div>
<div class="btn-group">
<span id="terminalStatus" class="badge badge-warning">Connecting</span>
<button class="btn btn-ghost" onclick="terminalReconnect()"> Reconnect</button>
<button class="btn btn-ghost" onclick="terminalClear()"> Clear</button>
<span id="terminalStatus" class="nav-badge" style="background:var(--yellow)">Connecting...</span>
</div>
</div>
<div class="terminal-panel">
<div id="xtermContainer" class="terminal-xterm-container"></div>
<div class="terminal-container">
<div id="terminal"></div>
</div>
`;
closeTerminalSession();
// Load xterm.js from CDN (with fallback)
try {
await loadXterm();
await loadScript('https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js');
await loadScript('https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js');
} catch (err) {
document.getElementById('xtermContainer').innerHTML =
`<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Failed to load terminal library</div><div class="empty-state-desc">${escapeHtml(err.message || String(err))}</div></div>`;
return;
// Fallback to unpkg
await loadScript('https://unpkg.com/xterm@5.3.0/lib/xterm.min.js');
await loadScript('https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js');
}
const term = new window.Terminal({
// Verify Terminal and FitAddon are available
if (typeof Terminal === 'undefined') {
throw new Error('xterm.js failed to load from CDN');
}
// Initialize terminal
const term = new Terminal({
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace",
fontSize: 13,
theme: { background: '#0a0e14', foreground: '#c9d1d9' },
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
theme: {
background: '#0d1117',
foreground: '#c9d1d9',
cursor: '#58a6ff',
selection: '#264f78',
black: '#0d1117',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39d2c0',
white: '#c9d1d9',
},
scrollback: 50000,
allowProposedApi: true,
convertEol: true,
});
const fitAddon = new window.FitAddon.FitAddon();
const FitAddonClass = window.FitAddon?.FitAddon || window.FitAddon;
if (!FitAddonClass) {
throw new Error('xterm FitAddon failed to load');
}
const fitAddon = new FitAddonClass();
term.loadAddon(fitAddon);
term.open(document.getElementById('xtermContainer'));
term.open(document.getElementById('terminal'));
fitAddon.fit();
_termInstance = term;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${proto}//${window.location.host}/ws/terminal`);
_termSocket = socket;
window._agenticTerm = term;
window._agenticFit = fitAddon;
socket.onopen = () => {
setTerminalStatus('online', 'Connected');
fitAddon.fit();
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
term.focus();
};
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'output') term.write(msg.data);
} catch {}
};
socket.onclose = () => setTerminalStatus('offline', 'Disconnected');
socket.onerror = () => setTerminalStatus('offline', 'Connection error');
// Connect WebSocket
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsPort = 8082;
const wsUrl = `${protocol}//${window.location.hostname}:${wsPort}`;
const statusEl = document.getElementById('terminalStatus');
term.onData((data) => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data }));
});
try {
const ws = new WebSocket(wsUrl);
window._agenticWs = ws;
const resizeHandler = () => {
fitAddon.fit();
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
};
window.addEventListener('resize', resizeHandler);
window._terminalResizeHandler = resizeHandler;
ws.onopen = () => {
statusEl.textContent = 'Connected';
statusEl.style.background = 'var(--green)';
term.writeln('\x1b[32m✓ Connected to Agentic OS terminal\x1b[0m');
term.writeln(`\x1b[90m WSL • ${navigator.userAgent.includes('Windows') ? 'Windows browser → WSL shell' : 'Linux shell'}\x1b[0m\r\n`);
ws.send(JSON.stringify({ action: 'resize', cols: term.cols, rows: term.rows }));
};
window.addEventListener('hashchange', closeTerminalSession, { once: true });
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
term.write(event.data);
}
};
ws.onclose = () => {
statusEl.textContent = 'Disconnected';
statusEl.style.background = 'var(--red)';
term.writeln('\r\n\x1b[31m✕ Connection closed\x1b[0m');
};
ws.onerror = () => {
statusEl.textContent = 'Error';
statusEl.style.background = 'var(--red)';
};
// Send input
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'input', data: data }));
}
});
// Resize handler with debounce
let resizeTimeout;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
fitAddon.fit();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'resize', cols: term.cols, rows: term.rows }));
}
}, 100);
});
resizeObserver.observe(document.getElementById('terminal'));
window._agenticResize = resizeObserver;
} catch (err) {
statusEl.textContent = 'Failed';
statusEl.style.background = 'var(--red)';
term.writeln(`\x1b[31mError: ${err.message}\x1b[0m`);
}
term.focus();
}
function terminalReconnect() {
if (window._agenticWs) window._agenticWs.close();
if (window._agenticResize) window._agenticResize.disconnect();
renderTerminal();
}
function terminalClear() {
if (window._agenticTerm) {
window._agenticTerm.clear();
// Also send clear escape sequence to PTY
if (window._agenticWs && window._agenticWs.readyState === WebSocket.OPEN) {
window._agenticWs.send(JSON.stringify({ action: 'input', data: '\x0c' }));
}
}
}

163
dashboard/serve_monitor.py Normal file
View File

@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Agentic-OS :: Localhost Agent-Time Monitor Server
-------------------------------------------------
Stdlib-only HTTP server (no pip deps) that serves the project folder on
localhost and exposes two action endpoints:
GET / -> redirects to the monitor dashboard
GET /dashboard/pages/agent-time-monitor.html -> the dashboard
GET /agent-time.json -> computed total-agent-time report
GET /graphify-out/graph.html -> graphify code knowledge graph
POST /recompute -> re-run the time analyzer, return summary
POST /sync-mnemoverse -> push total time to Mnemoverse (graceful)
Run: python3 dashboard/serve_monitor.py [--port 8765]
Then open: http://localhost:8765/dashboard/pages/agent-time-monitor.html
"""
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from functools import partial
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # agentic-os/
ANALYZER = os.path.join(ROOT, "scripts", "analyze_agent_time.py")
AGENT_TIME = os.path.join(ROOT, "data", "agent-time.json")
MNEMOVERSE_URL = "https://core.mnemoverse.com/api/v1/memory/write"
DASHBOARD = "/dashboard/pages/agent-time-monitor.html"
DEFAULT_PORT = int(os.environ.get("MONITOR_PORT", "8765"))
def run_analyzer():
"""Recompute agent-time.json. Returns (ok, summary_dict)."""
try:
proc = subprocess.run(
[sys.executable, ANALYZER],
cwd=ROOT, capture_output=True, text=True, timeout=120,
)
if proc.returncode != 0:
return False, {"error": proc.stderr.strip() or "analyzer exited non-zero"}
with open(AGENT_TIME, "r", encoding="utf-8") as f:
data = json.load(f)
return True, {
"total_seconds": data["total_seconds"],
"total_human": data["total_human"],
"agents": len(data.get("agents", {})),
"events": data.get("event_count", 0),
}
except Exception as e:
return False, {"error": str(e)}
def sync_mnemoverse(api_key=None):
"""Write the total agent time to Mnemoverse. Graceful on missing key/offline."""
api_key = api_key or os.environ.get("MNEMOVERSE_API_KEY", "")
if not api_key:
return False, {
"error": "no MNEMOVERSE_API_KEY set",
"hint": "export MNEMOVERSE_API_KEY=mk_live_... then click again",
}
try:
with open(AGENT_TIME, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
return False, {"error": "cannot read agent-time.json: " + str(e)}
content = (
f"Agentic-OS total AI-agent time on project: {data['total_human']} "
f"across {len(data.get('agents', {}))} agents "
f"({data.get('event_count', 0)} logged events). "
f"Method: {data.get('method')}. Last computed {data.get('generated_at')}."
)
payload = json.dumps({
"content": content,
"concepts": ["agentic-os", "agent-time", "agentic-metrics", "ai-agent-usage"],
"domain": "agentic-os",
}).encode("utf-8")
req = urllib.request.Request(
MNEMOVERSE_URL, data=payload, method="POST",
headers={"Content-Type": "application/json", "X-Api-Key": api_key},
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
body = resp.read().decode("utf-8", "replace")
try:
j = json.loads(body)
except Exception:
j = {"raw": body[:200]}
return True, {"message": "memory written to Mnemoverse", "response": j}
except urllib.error.HTTPError as e:
return False, {"error": f"HTTP {e.code}: {e.read().decode('utf-8','replace')[:200]}"}
except urllib.error.URLError as e:
return False, {"error": "network error: " + str(e.reason),
"hint": "Mnemoverse unreachable from this host"}
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=ROOT, **kwargs)
def _send_json(self, obj, code=200):
body = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path in ("/", "/index.html"):
self.send_response(302)
self.send_header("Location", DASHBOARD)
self.end_headers()
return
# Default: serve files from ROOT (html, json, graph.html, etc.)
super().do_GET()
def do_POST(self):
if self.path == "/recompute":
ok, summary = run_analyzer()
self._send_json({"ok": ok, **summary}, 200 if ok else 500)
return
if self.path == "/sync-mnemoverse":
ok, result = sync_mnemoverse()
self._send_json({"ok": ok, **result}, 200 if ok else 200)
return
self._send_json({"ok": False, "error": "unknown endpoint"}, 404)
def log_message(self, fmt, *args):
sys.stderr.write("[monitor] " + (fmt % args) + "\n")
def main():
import argparse
p = argparse.ArgumentParser()
p.add_argument("--port", type=int, default=DEFAULT_PORT)
p.add_argument("--host", default="127.0.0.1")
args = p.parse_args()
# ensure a fresh agent-time.json exists
if not os.path.exists(AGENT_TIME):
ok, s = run_analyzer()
print(("recompute ok" if ok else "recompute failed: " + str(s)))
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
url = f"http://{args.host}:{args.port}{DASHBOARD}"
print("Agentic-OS Agent-Time Monitor")
print(f" serving : {ROOT}")
print(f" dashboard: {url}")
print(f" mnemoverse: {'ENABLED (key set)' if os.environ.get('MNEMOVERSE_API_KEY') else 'disabled (set MNEMOVERSE_API_KEY to enable sync)'}")
print(" ctrl-c to stop")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nstopped.")
if __name__ == "__main__":
main()

10
dashboard/start-monitor.sh Executable file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Start the Agentic-OS Agent-Time Monitor on localhost.
# Usage: ./dashboard/start-monitor.sh [port]
# Then open: http://localhost:<port>/dashboard/pages/agent-time-monitor.html
set -e
cd "$(dirname "$0")/.."
PORT="${1:-8765}"
if [ -d venv ]; then . venv/bin/activate; fi
echo "Starting monitor on http://127.0.0.1:${PORT} ..."
exec python3 dashboard/serve_monitor.py --port "${PORT}"

View File

@ -1,3 +1,74 @@
/* ─── Terminal ─────────────────────────────────────────────────── */
.terminal-container {
background: #0d1117;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
height: calc(100vh - 180px);
min-height: 400px;
position: relative;
}
#terminal {
width: 100%;
height: 100%;
padding: 8px;
}
#terminal .xterm {
height: 100%;
}
#terminal .xterm-viewport {
overflow-y: auto !important;
}
/* ─── Agent Cards ──────────────────────────────────────────────── */
.agent-card {
transition: border-color 0.2s, box-shadow 0.2s;
}
.agent-card:hover {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-glow);
}
.agent-card-header {
display: flex;
align-items: center;
gap: 8px;
}
.agent-table {
width: 100%;
border-collapse: collapse;
}
.agent-table th {
text-align: left;
padding: 10px 12px;
font-size: 11px;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.agent-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.agent-table tr:last-child td {
border-bottom: none;
}
.agent-table tr:hover td {
background: var(--bg-card-hover);
}
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
.mt-4 {
margin-top: 16px;
}
:root {
--bg-primary: #0d1117;
--bg-secondary: #151b24;

20
dashboard/test.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head><title>Agentic OS Test</title></head>
<body>
<h1>Connection Test</h1>
<div id="status">Testing...</div>
<div id="result"></div>
<script>
fetch('/api/status')
.then(r => r.json())
.then(d => {
document.getElementById('status').textContent = 'SUCCESS: API reachable';
document.getElementById('result').textContent = JSON.stringify(d, null, 2);
})
.catch(e => {
document.getElementById('status').textContent = 'FAILED: ' + e.message;
});
</script>
</body>
</html>

View File

@ -111,6 +111,7 @@ function renderSkeleton(count = 3) {
const PAGE_TITLES = {
dashboard: { title: 'Dashboard', breadcrumb: 'Overview' },
terminal: { title: 'Terminal', breadcrumb: 'Browser-based shell' },
skills: { title: 'Skills Hub', breadcrumb: 'Browse & execute skills' },
memory: { title: 'Memory', breadcrumb: 'Shared brain context' },
scheduler: { title: 'Scheduler', breadcrumb: 'Automated workflows' },
@ -131,4 +132,5 @@ const PAGE_TITLES = {
'smart-router': { title: 'Smart Router', breadcrumb: 'Task routing intelligence' },
'learning-analytics': { title: 'Learning Analytics', breadcrumb: 'Skill improvement tracking' },
'session-replay': { title: 'Session Replay', breadcrumb: 'Conversation history playback' },
'agent-time': { title: 'Agent Time', breadcrumb: 'Total agent effort on this project' },
};

47
data/agent-registry.json Normal file
View File

@ -0,0 +1,47 @@
{
"jarvis": {
"name": "jarvis",
"display_name": "OpenJarvis",
"description": "Local-first personal AI: deep research, knowledge, memory, general reasoning",
"binary": "jarvis-ask",
"type": "cli",
"run_args": [
"jarvis-ask",
"{message}"
],
"check_type": "binary",
"timeout": 120,
"builtin": false,
"api_method": "POST"
},
"kilocode": {
"name": "kilocode",
"display_name": "Kilo Code",
"description": "AI coding assistant by Kilo (linux binary via wrapper)",
"binary": "kilocode",
"type": "cli",
"run_args": [
"kilocode",
"{message}"
],
"check_type": "binary",
"timeout": 120,
"builtin": false,
"api_method": "POST"
},
"codex": {
"name": "codex",
"display_name": "OpenAI Codex",
"description": "OpenAI Codex coding/execution agent",
"binary": "codex",
"type": "cli",
"run_args": [
"codex",
"{message}"
],
"check_type": "binary",
"timeout": 300,
"builtin": false,
"api_method": "POST"
}
}

1
data/agent-stats.json Normal file
View File

@ -0,0 +1 @@
{}

83
data/agent-time.json Normal file
View File

@ -0,0 +1,83 @@
{
"project": "agentic-os",
"generated_at": "2026-07-25T18:27:15.982570+00:00",
"method": "session-gap estimate (GAP=1800s, TAIL=120s)",
"graph": {
"nodes": 321,
"edges": 517,
"communities": 41,
"file": "graphify-out/graph.json"
},
"gap_seconds": 1800,
"tail_seconds": 120,
"total_seconds": 20568,
"total_human": "5h 42m 48s",
"agents": {
"opencode": {
"sessions": 8,
"touches": 42,
"total_seconds": 4566,
"first_seen": "2026-06-23T11:17:40.771655+00:00",
"last_seen": "2026-07-25T17:40:45.570834+00:00"
},
"system": {
"sessions": 12,
"touches": 114,
"total_seconds": 7374,
"first_seen": "2026-06-25T17:58:22.902546+00:00",
"last_seen": "2026-07-25T17:10:23.236024+00:00"
},
"hermes": {
"sessions": 9,
"touches": 36,
"total_seconds": 6808,
"first_seen": "2026-06-26T01:25:01.320339+00:00",
"last_seen": "2026-07-25T17:42:17.726478+00:00"
},
"gemini": {
"sessions": 1,
"touches": 3,
"total_seconds": 220,
"first_seen": "2026-06-28T17:26:01.078648+00:00",
"last_seen": "2026-06-28T17:27:41.105686+00:00"
},
"test_claude": {
"sessions": 1,
"touches": 2,
"total_seconds": 129,
"first_seen": "2026-06-28T17:47:56.773101+00:00",
"last_seen": "2026-06-28T17:48:05.984878+00:00"
},
"jarvis": {
"sessions": 2,
"touches": 3,
"total_seconds": 240,
"first_seen": "2026-06-28T20:44:32.727658+00:00",
"last_seen": "2026-07-17T17:30:21.986571+00:00"
},
"kilocode": {
"sessions": 2,
"touches": 3,
"total_seconds": 240,
"first_seen": "2026-06-28T20:44:32.730628+00:00",
"last_seen": "2026-07-17T17:30:21.988722+00:00"
},
"test": {
"sessions": 1,
"touches": 1,
"total_seconds": 120,
"first_seen": "2026-06-28T21:39:14.911119+00:00",
"last_seen": "2026-06-28T21:39:14.911119+00:00"
},
"codex": {
"sessions": 1,
"touches": 3,
"total_seconds": 871,
"first_seen": "2026-07-17T17:18:53.396385+00:00",
"last_seen": "2026-07-17T17:31:24.434628+00:00"
}
},
"event_count": 207,
"first_seen": "2026-06-23T11:17:40.771655+00:00",
"last_seen": "2026-07-25T17:42:17.726478+00:00"
}

View File

@ -1,35 +1,13 @@
[
{
"id": "215a1f6a",
"title": "Waste log",
"description": "I have a couple of files that work as an applicate for a catering kitchen waste log. I need it to be local if possible and save local as well. I need it created fully functioning.",
"category": "development",
"target_date": "2026-07-07",
"id": "2c4b22ac",
"title": "Push everything in the Kanban board down the line",
"description": "It involves different skills to get different things done.",
"category": "general",
"target_date": "2026-06-28",
"status": "active",
"progress": 0,
"created": "2026-07-07T10:59:57.991678+00:00",
"updated": "2026-07-07T10:59:57.991691+00:00"
},
{
"id": "ac785f05",
"title": "Pendleton-comms-live",
"description": "I need to finish my pendleton walkie talkie app before whiskey fest so my team can talk. It needs to be an android native application.",
"category": "development",
"target_date": "2026-07-08",
"status": "active",
"progress": 0,
"created": "2026-07-07T11:02:12.398717+00:00",
"updated": "2026-07-07T11:02:12.398731+00:00"
},
{
"id": "a0f84134",
"title": "AUdit",
"description": "Run a dependency audit",
"category": "development",
"target_date": "2026-07-19",
"status": "active",
"progress": 0,
"created": "2026-07-19T07:52:31.175924+00:00",
"updated": "2026-07-19T07:52:31.175940+00:00"
"progress": 50,
"created": "2026-06-28T21:48:06.763421+00:00",
"updated": "2026-06-28T21:48:13.383386+00:00"
}
]

11
data/integrations.json Normal file
View File

@ -0,0 +1,11 @@
{
"integrations": [
{
"name": "test-webhook",
"url": "https://example.com/hook",
"type": "webhook",
"added": "2026-06-28T17:18:11.253089+00:00",
"status": "active"
}
]
}

12
data/kanban/1314316c.json Normal file
View File

@ -0,0 +1,12 @@
{
"id": "1314316c",
"title": "AT",
"body": "",
"status": "triage",
"priority": "medium",
"assignee": "",
"comments": [],
"links": [],
"created": "2026-07-17T07:51:37.997814+00:00",
"updated": "2026-07-17T16:02:48.789421+00:00"
}

12
data/kanban/7a250df7.json Normal file
View File

@ -0,0 +1,12 @@
{
"id": "7a250df7",
"title": "Login to get gemini running",
"body": "login to google so gemini will be running in this os",
"status": "done",
"priority": "high",
"assignee": "hermes",
"comments": [],
"links": [],
"created": "2026-06-26T01:30:00.162737+00:00",
"updated": "2026-07-17T16:54:35.405173+00:00"
}

29
data/router-keywords.json Normal file
View File

@ -0,0 +1,29 @@
{
"jarvis": [
"jarvis",
"research",
"deep-research",
"knowledge",
"memory",
"reasoning",
"local"
],
"kilocode": [
"kilo",
"kilocode",
"code",
"coding",
"programming",
"refactor",
"implement"
],
"codex": [
"codex",
"code",
"debug",
"test",
"build",
"fix",
"implement"
]
}

58
docs/GRAPHIFY_MEMORY.md Normal file
View File

@ -0,0 +1,58 @@
# Agentic-OS memory layer + Graphify — honest status
## Can Graphify BE the memory layer?
**Partially, and not as a replacement for the write path.**
What Graphify is: a tool that turns a *codebase* into a knowledge graph
(nodes = code symbols/modules, edges = relationships) and lets you
visualize / query it. It is a **read/visualize/query layer over your
files** — it does NOT store or write memory. The OS's actual
memory write path stays `brain/memory.md`, `append_audit()`,
`data/*.json`, etc.
## What we verified (2026-07-25)
- `graphify . --code-only` over the whole project → **321 nodes, 517
edges, 41 communities** (graphify-out/graph.json + graph.html).
This graphs the *code* (server.py, skills' .py, etc.).
- `graphify brain skills --code-only`**0 nodes**. The memory
folders are almost entirely **Markdown**, which `--code-only`
skips. So the memory *content* is invisible to a code-only build.
- To graph the *memory docs* (markdown notes), Graphify needs an
LLM key for semantic extraction (GEMINI_API_KEY / GOOGLE_API_KEY
/ ANTHROPIC_API_KEY / OPENAI_API_KEY / DEEPSEEK_API_KEY /
MOONSHOT_API_KEY). With a key:
`graphify brain skills` (no --code-only) will build a graph that
includes your notes as nodes. Without a key it errors out.
## Recommended setup (works today, upgrades with a key)
1. Build the code graph (no key needed):
cd ~/agentic-os && graphify . --code-only
→ live in the dashboard at http://localhost:8080/graphify-out/graph.html
and the "Agent Time" page's "Open interactive graph" link.
2. (Optional, with a key) Build the memory-inclusive graph:
export GEMINI_API_KEY=...
graphify brain skills data # docs + code, semantic
This adds your memory notes as queryable nodes.
3. Expose it to the OS agents as a query tool (MCP or HTTP):
# HTTP (team/shared):
python -m graphify.serve graphify-out/graph.json --transport http --port 8081
# or stdio MCP for a single agent:
python -m graphify.serve graphify-out/graph.json
Then point the agent/MCP client at it. Agents can ASK the graph
("what connects memory-consolidation to audit?", "show the
agentic-os data flow") — a retrieval aid, not a memory writer.
## Bottom line
- ✅ Use Graphify as a **visualization + semantic-query layer** for the
OS's memory/code. Great for "what's in my brain and how does it
connect."
- ❌ Do NOT treat it as the memory store. Writing memory still goes
through the existing `brain/` + `audit` + `data/` files. Graphify
reads those; it never writes them.
- ⚠️ Memory-note graphing requires an LLM key; code graphing does not.
## Files
- graphify-out/ — built code graph (graph.json, graph.html, GRAPH_REPORT.md)
- scripts/analyze_agent_time.py — agent-time estimator (the dashboard's data source)
- dashboard/.../agent-time.js — integrated "Agent Time" page (port 8080)
- dashboard/serve_monitor.py — standalone monitor (kept, unused by integration)

636
docs/README.md Normal file
View File

@ -0,0 +1,636 @@
# Agentic OS — Documentation
> Multi-agent orchestration platform — FastAPI backend + vanilla JS SPA dashboard
> Version: 1.3.0 | Last updated: 2026-06-28
## Table of Contents
1. [Quick Start](#quick-start)
2. [Architecture Overview](#architecture-overview)
3. [Dashboard Pages](#dashboard-pages)
4. [Web Terminal](#web-terminal)
5. [Agent System](#agent-system)
6. [API Reference](#api-reference)
7. [Skills](#skills)
8. [Scheduler](#scheduler)
9. [Configuration](#configuration)
10. [Development Guide](#development-guide)
---
## Quick Start
```bash
# Start the server
cd ~/agentic-os
./start.sh
# Or with browser auto-open
./start.sh --open
# Check status
./start.sh --status
# Stop
./start.sh --stop
```
**URLs:**
- Dashboard: `http://127.0.0.1:8081`
- Web Terminal: `http://127.0.0.1:8082` (WebSocket)
- API Base: `http://127.0.0.1:8081/api/`
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ AGENTIC OS DASHBOARD │
│ (FastAPI + Vanilla JS SPA) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
│ │ opencode │ │ Hermes │ │ Gemini │ │ Custom │ │
│ │ (Code) │ │ (Memory) │ │(Research) │ │ Agents │ │
│ └────┬─────┘ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ ─────┴──────────────┴──────────────┴──────────────┴──────── │
│ Agent Router │
│ ─────────────────────────────────────────────────────────── │
│ FastAPI Backend │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │
│ │ Brain │ │Skills │ │Kanban │ │Journal │ │ Terminal │ │
│ │ (files)│ │(agents)│ │(JSON) │ │(files) │ │ (PTY) │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**Tech Stack:**
- Backend: Python 3.11+, FastAPI, Uvicorn
- Frontend: Vanilla JS, Chart.js, xterm.js
- Terminal: WebSocket + PTY (pseudo-terminal)
- Data: JSON files (no database required)
- Agents: CLI binaries (opencode, hermes, gemini, + custom)
---
## Dashboard Pages
| Page | Route | Description |
|------|-------|-------------|
| Dashboard | `#dashboard` | System overview, agent status, recent activity |
| AI Chat | `#chat` | Multi-agent chat terminal |
| Terminal | `#terminal` | Browser-based bash shell (xterm.js) |
| Skills | `#skills` | Browse and execute 17+ skills |
| Memory | `#memory` | Shared brain context files |
| Scheduler | `#scheduler` | Cron job management |
| Audit | `#audit` | System activity trail |
| Kanban | `#kanban` | 6-column task board |
| Goals | `#goals` | Project targets with progress tracking |
| Journal | `#journal` | Daily entries with search |
| Agent Health | `#agent-health` | Agent monitoring + registration |
| Smart Router | `#smart-router` | Task routing intelligence |
| Learning Analytics | `#learning-analytics` | Skill eval scores and trends |
| Session Replay | `#session-replay` | Conversation history playback |
| Cost Analytics | `#cost` | Token usage and spending |
| Plugins | `#plugins` | Plugin registry management |
| Backups | `#backups` | Backup/restore system |
| Prompts | `#prompts` | Reusable prompt templates |
| Standards | `#standards` | Project conventions |
| Settings | `#settings` | System configuration |
| Setup Wizard | `#setup-wizard` | Guided configuration |
---
## Web Terminal
The terminal provides a full bash shell in the browser using xterm.js connected to a PTY via WebSocket.
**How it works:**
1. Server forks a PTY (`pty.fork()`) running `/bin/bash`
2. WebSocket server on port 8082 relays I/O between browser and PTY
3. xterm.js renders the terminal with 256-color support
4. Resize events propagate via `ioctl(TIOCSWINSZ)`
**Features:**
- Full bash shell with tab completion, history, colors
- Automatic terminal resizing
- 10,000 line scrollback
- Reconnect button if connection drops
- Dark theme matching dashboard
**Security:** The terminal runs as the server process user. Only accessible from localhost by default.
---
## Agent System
### Built-in Agents
| Agent | Binary | Role | Check Method |
|-------|--------|------|--------------|
| OpenCode | `opencode` | Code generation, DevOps, file ops | Binary in PATH |
| Hermes Agent | `hermes` | Memory, scheduling, coordination | Binary in PATH |
| Gemini CLI | `gemini` | Research, analysis, multi-modal | Binary + OAuth file |
### Agent Registry
Agents are defined in `data/agent-registry.json` (custom agents) with built-in defaults in `server.py`.
**Agent configuration schema:**
```json
{
"name": "agent_id",
"display_name": "Human Readable Name",
"description": "What this agent does",
"binary": "cli-binary-name",
"type": "cli",
"run_args": ["binary", "arg1", "{message}"],
"check_type": "binary",
"timeout": 60,
"builtin": false
}
```
**Check types:**
- `binary` — checks if binary exists in PATH
- `oauth_file` — checks if OAuth credential file exists
- `http` — HTTP health check against `health_url`
- `custom` — runs `check_command`, online if exit code 0
**Agent types:**
- `cli` — executes via subprocess with `{message}` substitution
- `http` — POSTs to `api_url` with `{"message": "..."}`
- `mcp` — Model Context Protocol (stub, not yet implemented)
### Adding Custom Agents
Via the dashboard: **Agent Health****+ Add Agent**
Or via API:
```bash
curl -X POST http://127.0.0.1:8081/api/agents/register \
-H "Content-Type: application/json" \
-d '{
"name": "my-llm",
"display_name": "My Local LLM",
"description": "Local LLM via Ollama",
"binary": "ollama",
"type": "cli",
"run_args": ["ollama", "run", "llama3", "{message}"],
"check_type": "binary",
"timeout": 120,
"router_keywords": ["local", "llama", "ollama"]
}'
```
### Removing Custom Agents
Via the dashboard: **Agent Health** → click **Remove** on any custom agent.
Or via API:
```bash
curl -X DELETE http://127.0.0.1:8081/api/agents/my-llm
```
Built-in agents cannot be removed.
---
## API Reference
### Agents
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/agents` | `list_agents` |
| GET | `/api/agents/health` | `get_agent_health` |
| POST | `/api/agents/health/refresh` | `refresh_agent_health` |
| POST | `/api/agents/register` | `register_agent` |
| DELETE | `/api/agents/{agent_name}` | `unregister_agent` |
| GET | `/api/agents/{name}/stats` | `get_agent_stats` |
### Analytics
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/analytics/skills` | `get_skill_analytics` |
| GET | `/api/analytics/trends` | `get_trend_analytics` |
### Audit
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/audit` | `get_audit` |
### Backup
| Method | Endpoint | Handler |
|--------|----------|---------|
| POST | `/api/backup` | `create_backup` |
| POST | `/api/backup/restore` | `restore_backup` |
### Backups
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/backups` | `list_backups` |
### Brain
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/brain` | `list_brain` |
| GET | `/api/brain/{file_name}` | `get_brain_file` |
| PUT | `/api/brain/{file_name}` | `update_brain_file` |
### Chat
| Method | Endpoint | Handler |
|--------|----------|---------|
| POST | `/api/chat` | `chat` |
| GET | `/api/chat/history` | `get_chat_history` |
### Cost
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/cost` | `get_cost` |
| POST | `/api/cost/record` | `record_cost` |
### Goals
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/goals` | `list_goals` |
| POST | `/api/goals` | `create_goal` |
| PUT | `/api/goals/{goal_id}` | `update_goal` |
| DELETE | `/api/goals/{goal_id}` | `delete_goal` |
### Integrations
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/integrations` | `list_integrations` |
| POST | `/api/integrations` | `add_integration` |
### Journal
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/journal/entries` | `list_journal_entries` |
| GET | `/api/journal/entries/{entry_date}` | `get_journal_entry` |
| PUT | `/api/journal/entries/{entry_date}` | `save_journal_entry` |
| GET | `/api/journal/search` | `search_journal` |
### Kanban
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/kanban/board` | `kanban_board` |
| POST | `/api/kanban/dispatch` | `kanban_dispatch` |
| POST | `/api/kanban/links` | `kanban_add_link` |
| DELETE | `/api/kanban/links` | `kanban_remove_link` |
| POST | `/api/kanban/tasks` | `kanban_create_task` |
| GET | `/api/kanban/tasks/{task_id}` | `kanban_get_task` |
| PATCH | `/api/kanban/tasks/{task_id}` | `kanban_update_task` |
| POST | `/api/kanban/tasks/{task_id}/block` | `kanban_block_task` |
| POST | `/api/kanban/tasks/{task_id}/comments` | `kanban_add_comment` |
| POST | `/api/kanban/tasks/{task_id}/complete` | `kanban_complete_task` |
| POST | `/api/kanban/tasks/{task_id}/decompose` | `kanban_decompose_task` |
| POST | `/api/kanban/tasks/{task_id}/specify` | `kanban_specify_task` |
| POST | `/api/kanban/tasks/{task_id}/unblock` | `kanban_unblock_task` |
### Plugins
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/plugins` | `list_plugins` |
| POST | `/api/plugins/install` | `install_plugin` |
| DELETE | `/api/plugins/{plugin_name}` | `uninstall_plugin` |
### Prompts
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/prompts` | `list_prompts` |
### Root
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/` | `index` |
| GET | `/favicon.ico` | `favicon` |
| GET | `/favicon.svg` | `favicon_svg` |
| GET | `/test` | `test_page` |
### Router
| Method | Endpoint | Handler |
|--------|----------|---------|
| POST | `/api/router/route` | `router_route` |
| POST | `/api/router/suggest` | `router_suggest` |
### Scheduler
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/scheduler/jobs` | `list_jobs` |
| POST | `/api/scheduler/jobs` | `create_job` |
| DELETE | `/api/scheduler/jobs/{job_id}` | `delete_job` |
### Sessions
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/sessions/list` | `list_sessions` |
| GET | `/api/sessions/{session_id}/replay` | `get_session_replay` |
### Settings
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/settings` | `get_settings` |
| PUT | `/api/settings` | `update_settings` |
### Skills
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/skills` | `list_skills` |
| GET | `/api/skills/{name}` | `get_skill` |
| GET | `/api/skills/{name}/eval` | `get_skill_eval` |
| POST | `/api/skills/{name}/run` | `run_skill` |
### Standards
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/standards` | `list_standards` |
| POST | `/api/standards/discover` | `discover_standards` |
### Status
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | `/api/status` | `get_status` |
## Skills
Skills are stored in `skills/<name>/` directories with this structure:
```
skills/
skill-name/
SKILL.md # Instructions (YAML frontmatter + markdown)
learnings.md # Accumulated run logs
eval.json # Evaluation criteria
score-history.json # Historical scores
context/ # Ephemeral task inputs
```
**Skill frontmatter (SKILL.md):**
```yaml
---
name: skill-name
description: What this skill does
version: 1.0.0
author: Agentic OS
tags: [category]
---
```
**Built-in skills (17):**
- `brainstorming` — Socratic design refinement
- `code-review` — Automated code review
- `content-draft` — Blog/newsletter writing
- `cost-analytics` — Token usage tracking
- `daily-standup` — Morning briefing
- `devops-audit` — GCP/K8s infrastructure audit
- `goal-planner` — Step-by-step goal planning
- `heartbeat` — System health monitoring
- `meeting-minutes` — Meeting notes processor
- `memory-consolidation` — Weekly memory synthesis
- `project-planner` — Implementation plans
- `research-synthesis` — Web research via Gemini
- `systematic-debug` — 4-phase root cause debugging
- `tdd-cycle` — Red-Green-Refactor TDD
- `backup-skill` — Backup snapshot creation
- `test-plugin` — Plugin test fixture
- `notion-knowledge-capture` — Third-party (via npx skills add)
**Skill execution flow:**
1. `POST /api/skills/:name/run` with optional `input` and `agent` override
2. Router determines agent (auto-detect from keywords or SKILL.md `Primary:` line)
3. Prompt built from SKILL.md + learnings.md + user input
4. Agent executes via CLI
5. Output appended to learnings.md, audit log updated
---
## Scheduler
Two scheduling systems exist:
### 1. Hermes Cron (active)
- Managed via `hermes cron` CLI or Hermes cronjob tool
- Currently running: `brain-guardian-autoupdate` (every 2h)
- Persistent across restarts
### 2. APScheduler (configured, not auto-started)
- Script: `scheduler/scheduler.py`
- Jobs stored in: `scheduler/jobs/*.json`
- Loaded at script start, runs in background thread
- Jobs: heartbeat (5min), daily-standup (weekdays 9am), devops-audit, memory-consolidation
**Job JSON format:**
```json
{
"id": "unique-id",
"name": "Job Name",
"skill": "skill-to-run",
"cron": "*/5 * * * *",
"enabled": true
}
```
---
## Configuration
### Server Configuration
**`data/settings.json`:**
```json
{
"agent_preferences": {
"opencode": {"enabled": false, "binary": "opencode"},
"hermes": {"enabled": false, "binary": "hermes"},
"gemini": {"enabled": true, "binary": "gemini"}
},
"dashboard": {"port": 8080, "host": "127.0.0.1", "dark_mode": true},
"free_tier_limits": {
"gemini_flash": {"requests_per_day": 1500, "tokens_per_day": 1000000},
"openrouter_free": {"requests_per_day": 100, "tokens_per_day": 200000}
}
}
```
### Hermes Model Configuration
**`~/.hermes/config.yaml`:**
```yaml
model:
default: qwen/qwen3-next-80b-a3b-instruct:free
provider: openrouter
base_url: https://openrouter.ai/api/v1
fallback_providers:
- provider: openrouter
model: openai/gpt-oss-120b:free
- provider: openrouter
model: google/gemma-4-31b-it:free
- provider: openrouter
model: nvidia/nemotron-3-super-120b-a12b:free
```
**Free model fallback chain:**
1. `qwen/qwen3-next-80b-a3b-instruct:free` — primary (Qwen 3, 80B)
2. `openai/gpt-oss-120b:free` — OpenAI open source
3. `google/gemma-4-31b-it:free` — Google Gemma
4. `nvidia/nemotron-3-super-120b-a12b:free` — NVIDIA Nemotron
### Environment Variables
**`~/.hermes/.env`:**
- `OPENROUTER_API_KEY` — OpenRouter API key (required for free models)
- `GOOGLE_API_KEY` — Google AI Studio key (optional)
- `DEEPSEEK_API_KEY` — DeepSeek API key (optional)
### Data Directory Structure
```
agentic-os/
├── brain/ # Persistent memory
│ ├── memory.md # Cross-session memory
│ ├── business-brain.md # Project context
│ ├── active-projects.md # Auto-synced from goals
│ ├── identity.md # Agent persona
│ ├── constitution.md # Governance rules
│ ├── recent-decisions.md # Decision log
│ └── journal/ # Daily entries (YYYY-MM-DD.md)
├── data/
│ ├── settings.json # System settings
│ ├── goals.json # Project goals
│ ├── chat-history.json # Chat messages (last 200)
│ ├── cost-history.json # Token cost tracking
│ ├── kanban/ # Task files (UUID.json)
│ ├── agent-registry.json # Custom agent definitions
│ ├── router-keywords.json # Custom agent routing keywords
│ └── integrations.json # Connected external apps
├── skills/ # Skill definitions
├── agents/ # Agent profiles
│ ├── opencode/AGENTS.md
│ ├── hermes/SOUL.md, MEMORY.md, USER.md
│ └── gemini/GEMINI.md
├── scheduler/jobs/ # Cron job definitions
├── registry/plugins.json # Plugin registry
├── standards/ # Project standards
├── prompts/ # Reusable prompt templates
├── audit/audit.log # Append-only audit trail
└── backups/ # tar.gz snapshots
```
---
## Development Guide
### Adding a Dashboard Page
1. Create `dashboard/pages/mypage.js` with `async function renderMypage() { ... }`
2. Add navigation link in `dashboard/index.html`: `<a href="#mypage" class="nav-item" data-page="mypage">`
3. Add title in `dashboard/utils.js` `PAGE_TITLES` object
4. Add any CSS to `dashboard/styles.css`
### Adding an API Endpoint
1. Add route in `server.py` under the appropriate section
2. Add Pydantic model if needed (in Models section)
3. Add frontend API method in `dashboard/api.js`
4. Call from page renderer
### Adding a Skill
```bash
cp -r skills/_template skills/my-skill
# Edit SKILL.md with instructions
# Add to registry/plugins.json if desired
```
### Adding an Agent (Programmatic)
```python
# In server.py, add to BUILTIN_AGENTS dict, or use the API:
curl -X POST http://127.0.0.1:8081/api/agents/register \
-H "Content-Type: application/json" \
-d '{"name":"my-agent","binary":"my-binary","type":"cli"}'
```
### Key Files
| File | Purpose |
|------|---------|
| `server.py` | FastAPI backend (1780+ lines, 58+ endpoints) |
| `dashboard/index.html` | SPA shell with sidebar nav |
| `dashboard/app.js` | Page router and agent status polling |
| `dashboard/api.js` | API client wrapper |
| `dashboard/utils.js` | Shared utilities (toasts, formatting, PAGE_TITLES) |
| `dashboard/styles.css` | All styling (dark/light theme) |
| `dashboard/pages/*.js` | Individual page renderers |
| `start.sh` | Start/stop/status script |
| `data/agent-registry.json` | Custom agent persistence |
| `brain/` | All persistent memory |
### Server Lifecycle
```bash
./start.sh # Start (port 8081 + terminal on 8082)
./start.sh --open # Start + open browser
./start.sh --stop # Stop
./start.sh --status # Check if running
```
Logs: `tail -f /tmp/agentic-os.log`
PID file: `.agentic-os.pid`
---
## Changelog
### v1.3.0 (2026-06-28)
- **Web Terminal**: Added browser-based bash shell (xterm.js + WebSocket PTY on port 8082)
- **Agent Registry**: Dynamic agent registration system (add/remove custom agents via dashboard)
- **Agent Health Page**: Complete rewrite with card view, registry table, add agent form
- **Hermes Free Models**: Switched to Qwen 3 80B free + 3 fallback chain (OpenAI, Google, NVIDIA)
- **Dynamic Agent Discovery**: All endpoints now use registry instead of hardcoded agent list
- **Router Extensibility**: Custom agents can register keywords for smart routing
- **API**: New `/api/agents`, `/api/agents/register`, `/api/agents/:name` (DELETE)
### v1.2.0 (2026-06-26)
- Agent profiles for opencode, hermes, gemini
- APScheduler integration (scheduler.py)
- Plugin install from git repo or template
### v1.1.0 (2026-06-05)
- Kanban board (13 endpoints)
- Goals with auto-sync to brain
- Journal with search
- Agent health monitoring
- Smart router with keyword scoring
- Learning analytics
- Session replay
### v1.0.0 (2026-05-17)
- Initial release: FastAPI backend, SPA dashboard, 3 agents, 16 skills

View File

@ -0,0 +1,10 @@
{
"id": "c334edd1",
"name": "test-audit-job",
"skill": "daily-standup",
"cron": "0 9 * * *",
"enabled": false,
"created": "2026-06-28T21:39:14.908697+00:00",
"last_run": null,
"next_run": null
}

View File

@ -1,8 +1,20 @@
#!/usr/bin/env python3
"""Agentic OS — APScheduler engine for recurring tasks"""
"""Agentic OS — APScheduler engine for recurring tasks.
Loads job definitions from scheduler/jobs/*.json and, when each cron
trigger fires, actually executes the referenced skill by calling the
running Agentic OS server's /api/skills/{name}/run endpoint. Running
skills through the API means they go through the same code path as the
dashboard: real agent invocation, real agent-health stats, and real
eval-score population.
If the server isn't reachable, the job is logged (audit) but skipped,
so the scheduler never crashes the loop.
"""
import json
import subprocess
import sys
import time
from pathlib import Path
from datetime import datetime, timezone
@ -13,65 +25,122 @@ except ImportError:
print("Install APScheduler: pip install apscheduler")
sys.exit(1)
import urllib.request
import urllib.error
BASE_DIR = Path(__file__).parent.resolve()
JOBS_DIR = BASE_DIR / "jobs"
# Server URL — same host, main API port. Override with AGENTIC_OS_URL env.
SERVER_URL = "http://127.0.0.1:8080"
RUN_TIMEOUT = 300 # seconds per skill run
def run_skill(skill_name: str):
"""Execute a skill by invoking the appropriate agent."""
def log_audit(entry: dict):
audit_file = BASE_DIR.parent / "audit" / "audit.log"
entry = {
"action": "scheduler_run",
"skill": skill_name,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
try:
audit_file.parent.mkdir(parents=True, exist_ok=True)
with open(audit_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except OSError as e:
print(f" [audit] failed to record run of {skill_name!r}: {e}")
print(f"[{datetime.now().isoformat()}] Ran skill: {skill_name}")
print(f" [audit] failed to record: {e}")
def run_skill_via_api(skill_name: str, agent: str = "auto") -> dict:
"""Invoke a skill through the live server API.
Returns a dict with at least {'ok': bool, 'reason'/'output': ...}.
"""
url = f"{SERVER_URL}/api/skills/{skill_name}/run"
payload = json.dumps({"input": "", "agent": agent}).encode()
req = urllib.request.Request(
url, data=payload, headers={"Content-Type": "application/json"}, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=RUN_TIMEOUT) as resp:
data = json.loads(resp.read())
return {"ok": True, "data": data}
except urllib.error.HTTPError as e:
return {"ok": False, "reason": f"HTTP {e.code}: {e.reason}"}
except urllib.error.URLError as e:
return {"ok": False, "reason": f"server unreachable: {e.reason}"}
except Exception as e: # timeout, json error, etc.
return {"ok": False, "reason": str(e)}
def run_job(job: dict):
skill = job.get("skill")
name = job.get("name", skill)
agent = job.get("agent", "auto")
print(f"[{datetime.now().isoformat()}] Firing job '{name}' -> skill '{skill}'")
log_audit({"action": "scheduler_run", "job": name, "skill": skill, "stage": "start"})
if not skill:
log_audit({"action": "scheduler_run", "job": name, "error": "no skill defined"})
return
result = run_skill_via_api(skill, agent)
if result["ok"]:
data = result["data"]
log_audit({
"action": "scheduler_run",
"job": name,
"skill": skill,
"agent": data.get("agent"),
"run_id": data.get("run_id"),
"stage": "done",
})
print(f" -> OK (agent={data.get('agent')}, run_id={data.get('run_id')})")
else:
reason = result["reason"]
log_audit({"action": "scheduler_run", "job": name, "skill": skill, "error": reason})
print(f" -> FAILED: {reason}")
def load_jobs(scheduler: BackgroundScheduler):
"""Load job definitions from jobs/ directory.
A single malformed job file is logged and skipped rather than being allowed
to abort loading of every other job.
"""
for job_file in JOBS_DIR.glob("*.json"):
if not JOBS_DIR.exists():
print(f"No jobs directory at {JOBS_DIR}")
return
for job_file in sorted(JOBS_DIR.glob("*.json")):
try:
data = json.loads(job_file.read_text())
except (json.JSONDecodeError, OSError) as e:
print(f" Skipping {job_file.name}: could not read job ({e})")
except (json.JSONDecodeError, KeyError) as e:
print(f" Skipping malformed job file {job_file.name}: {e}")
continue
if not data.get("enabled", True):
print(f" Skipping disabled job: {data.get('name', job_file.stem)}")
continue
try:
scheduler.add_job(
run_skill,
CronTrigger.from_crontab(data["cron"]),
args=[data["skill"]],
id=data.get("id", data["name"]),
name=data["name"],
replace_existing=True,
)
except (KeyError, ValueError) as e:
print(f" Skipping {job_file.name}: invalid job definition ({e})")
cron = data.get("cron")
skill = data.get("skill")
if not cron or not skill:
print(f" Skipping job {data.get('name')}: missing cron or skill")
continue
print(f" Scheduled: {data['name']} ({data['cron']})")
scheduler.add_job(
run_job,
CronTrigger.from_crontab(cron),
args=[data],
id=data.get("id", data["name"]),
name=data.get("name", skill),
replace_existing=True,
max_instances=1,
coalesce=True,
)
print(f" Scheduled: {data.get('name')} (skill={skill}, cron={cron})")
def main():
scheduler = BackgroundScheduler()
load_jobs(scheduler)
scheduler.start()
print(f"Agentic OS Scheduler running. Jobs loaded from: {JOBS_DIR}")
print("Server API target: " + SERVER_URL)
try:
while True:
import time
time.sleep(60)
except KeyboardInterrupt:
scheduler.shutdown()
print("Scheduler stopped.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Agentic-OS :: Agent Time Analyzer
-----------------------------------
Derives total time AI agents spent on the project from event logs.
The agentic-os project does NOT store explicit per-session durations,
so we estimate total agent time with a session-gap model:
* Every logged event (audit.log line, chat message, cost entry) is a
timestamped touch by an agent.
* A "session" for an agent = a maximal run of touches where each touch
is within GAP seconds of the previous one.
* Session duration = (last touch - first touch) + TAIL.
TAIL accounts for the agent working after its last logged event
(e.g. finishing a task, writing files, thinking).
This is an ESTIMATE, clearly labeled as such in the dashboard.
Tunable: GAP (default 30 min), TAIL (default 2 min).
"""
import json
import os
import sys
from datetime import datetime, timezone
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
AUDIT_LOG = os.path.join(ROOT, "audit", "audit.log")
CHAT_HISTORY = os.path.join(ROOT, "data", "chat-history.json")
COST_HISTORY = os.path.join(ROOT, "data", "cost-history.json")
OUT = os.path.join(ROOT, "data", "agent-time.json")
GAP_SECONDS = int(os.environ.get("AGENT_TIME_GAP", "1800")) # 30 min
TAIL_SECONDS = int(os.environ.get("AGENT_TIME_TAIL", "120")) # 2 min
def parse_ts(s):
if not s:
return None
try:
# normalize Z -> +00:00
s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def load_audit_events():
"""Read audit.log (JSON lines). Returns list of (ts, agent)."""
events = []
if not os.path.exists(AUDIT_LOG):
return events
with open(AUDIT_LOG, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except Exception:
continue
ts = parse_ts(rec.get("timestamp"))
if ts is None:
continue
agent = rec.get("agent") or "system"
events.append((ts, agent))
return events
def load_json_file(path):
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def load_chat_events():
events = []
data = load_json_file(CHAT_HISTORY)
if not data:
return events
for m in data.get("messages", []):
ts = parse_ts(m.get("timestamp"))
if ts is None:
continue
agent = m.get("agent") or "unknown"
events.append((ts, agent))
return events
def load_cost_events():
events = []
data = load_json_file(COST_HISTORY)
if not data:
return events
for e in data.get("entries", []):
ts = parse_ts(e.get("timestamp"))
if ts is None:
continue
agent = e.get("agent") or "unknown"
events.append((ts, agent))
return events
def compute_sessions(events):
"""Group (ts, agent) events into per-agent sessions via gap model."""
by_agent = {}
for ts, agent in events:
by_agent.setdefault(agent, []).append(ts)
per_agent = {}
for agent, times in by_agent.items():
times.sort()
sessions = []
cur_start = times[0]
cur_last = times[0]
for t in times[1:]:
if (t - cur_last).total_seconds() <= GAP_SECONDS:
cur_last = t
else:
sessions.append((cur_start, cur_last))
cur_start = t
cur_last = t
sessions.append((cur_start, cur_last))
total = sum(((last - start).total_seconds() + TAIL_SECONDS) for start, last in sessions)
per_agent[agent] = {
"sessions": len(sessions),
"touches": len(times),
"total_seconds": int(total),
"first_seen": times[0].isoformat(),
"last_seen": times[-1].isoformat(),
}
return per_agent
def fmt_hms(seconds):
seconds = int(seconds)
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h}h {m}m {s}s"
def main():
events = []
events += load_audit_events()
events += load_chat_events()
events += load_cost_events()
if not events:
print("No events found.", file=sys.stderr)
out = {
"project": "agentic-os",
"generated_at": datetime.now(timezone.utc).isoformat(),
"method": "session-gap estimate",
"gap_seconds": GAP_SECONDS,
"tail_seconds": TAIL_SECONDS,
"total_seconds": 0,
"total_human": "0h 0m 0s",
"agents": {},
"event_count": 0,
"first_seen": None,
"last_seen": None,
}
with open(OUT, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
print(json.dumps(out, indent=2))
return
events.sort(key=lambda x: x[0])
per_agent = compute_sessions(events)
total = sum(a["total_seconds"] for a in per_agent.values())
# graphify metadata (best-effort)
graph_meta = {}
gpath = os.path.join(ROOT, "graphify-out", "graph.json")
if os.path.exists(gpath):
try:
with open(gpath, "r", encoding="utf-8") as f:
gj = json.load(f)
nodes = gj.get("nodes", [])
edges = gj.get("links", []) or gj.get("edges", [])
graph_meta = {
"nodes": len(nodes) if hasattr(nodes, "__len__") else "?",
"edges": len(edges) if hasattr(edges, "__len__") else "?",
}
# count communities
comms = set()
for n in nodes:
c = n.get("community")
if c is not None:
comms.add(c)
graph_meta["communities"] = len(comms)
graph_meta["file"] = "graphify-out/graph.json"
except Exception:
pass
out = {
"project": "agentic-os",
"generated_at": datetime.now(timezone.utc).isoformat(),
"method": "session-gap estimate (GAP=%ds, TAIL=%ds)" % (GAP_SECONDS, TAIL_SECONDS),
"graph": graph_meta,
"gap_seconds": GAP_SECONDS,
"tail_seconds": TAIL_SECONDS,
"total_seconds": total,
"total_human": fmt_hms(total),
"agents": per_agent,
"event_count": len(events),
"first_seen": events[0][0].isoformat(),
"last_seen": events[-1][0].isoformat(),
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
print("Wrote", OUT)
print("TOTAL:", out["total_human"], "across", len(per_agent), "agents,", len(events), "events")
for agent, a in sorted(per_agent.items(), key=lambda kv: -kv[1]["total_seconds"]):
print(f" {agent:12s} {fmt_hms(a['total_seconds']):>14s} sessions={a['sessions']:<4d} touches={a['touches']}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Extract API documentation from server.py and update docs/README.md
Run this after adding/removing/changing API endpoints.
"""
import re
import sys
from pathlib import Path
BASE = Path(__file__).parent.parent.resolve() # Go up from scripts/ to project root
SERVER = BASE / "server.py"
DOCS = BASE / "docs" / "README.md"
def extract_endpoints():
"""Parse server.py for FastAPI route definitions."""
content = SERVER.read_text()
# Match @app.get/post/put/patch/delete("path")
pattern = r'@app\.(get|post|put|patch|delete)\("([^"]+)"'
endpoints = []
for match in re.finditer(pattern, content):
method = match.group(1).upper()
path = match.group(2)
# Find the function name after the decorator
rest = content[match.end():match.end()+200]
func_match = re.search(r'def\s+(\w+)', rest)
func_name = func_match.group(1) if func_match else "?"
endpoints.append((method, path, func_name))
return endpoints
def generate_api_table(endpoints):
"""Generate markdown table for API reference."""
# Group by category (based on path prefix)
categories = {}
for method, path, func in endpoints:
# Extract category from path
parts = path.split('/')
if len(parts) >= 3:
cat = parts[2] # e.g. "status", "brain", "skills"
else:
cat = "root"
categories.setdefault(cat, []).append((method, path, func))
lines = []
for cat in sorted(categories.keys()):
lines.append(f"\n### {cat.replace('-', ' ').title()}\n")
lines.append("| Method | Endpoint | Handler |")
lines.append("|--------|----------|---------|")
for method, path, func in sorted(categories[cat], key=lambda x: x[1]):
lines.append(f"| {method} | `{path}` | `{func}` |")
return "\n".join(lines)
def update_docs():
"""Update the API reference section in docs/README.md."""
endpoints = extract_endpoints()
new_api_section = generate_api_table(endpoints)
if not DOCS.exists():
print(f"ERROR: {DOCS} not found")
sys.exit(1)
content = DOCS.read_text()
# Find and replace the API Reference section
# Look for "## API Reference" until next "##" section
pattern = r'(## API Reference\n).*?(?=\n## )'
replacement = f"## API Reference\n\n{new_api_section}\n"
if re.search(pattern, content, re.DOTALL):
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
DOCS.write_text(new_content)
print(f"Updated {DOCS} with {len(endpoints)} API endpoints")
else:
print("WARNING: Could not find '## API Reference' section in docs")
print("Adding it before the last section...")
# Find last ## section
last_section = content.rfind("\n## ")
if last_section >= 0:
new_content = content[:last_section] + f"\n{replacement}\n" + content[last_section:]
DOCS.write_text(new_content)
print(f"Updated {DOCS} with {len(endpoints)} API endpoints")
else:
print("ERROR: No sections found in docs")
sys.exit(1)
if __name__ == "__main__":
update_docs()

772
server.py
View File

@ -12,6 +12,7 @@ import shlex
import shutil
import signal
import subprocess
import sys
import tarfile
import threading
import time
@ -172,7 +173,9 @@ def get_timestamp():
return datetime.now(timezone.utc).isoformat()
def append_audit(entry: dict):
audit_file = BASE_DIR / "audit" / "audit.log"
audit_dir = BASE_DIR / "audit"
audit_dir.mkdir(parents=True, exist_ok=True)
audit_file = audit_dir / "audit.log"
entry["timestamp"] = get_timestamp()
entry["id"] = new_id()
try:
@ -184,7 +187,188 @@ def append_audit(entry: dict):
# underlying operation, but surface it on the server console.
print(f"[audit] failed to write entry {entry.get('action')!r}: {e}")
# ─── Agent Discovery (instant filesystem checks) ────────────────────
# ─── Agent Stats (real, persisted) ──────────────────────────────
AGENT_STATS_FILE = BASE_DIR / "data" / "agent-stats.json"
def load_agent_stats() -> dict:
if AGENT_STATS_FILE.exists():
try:
return json.loads(AGENT_STATS_FILE.read_text())
except (json.JSONDecodeError, KeyError):
return {}
return {}
def save_agent_stats(stats: dict):
AGENT_STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
AGENT_STATS_FILE.write_text(json.dumps(stats, indent=2))
def record_agent_run(agent: str, success: bool, duration: float):
"""Persist real invocation stats for an agent (consumed by /api/agents/health)."""
stats = load_agent_stats()
now = get_timestamp()
s = stats.get(agent, {
"total_runs": 0, "successful_runs": 0, "failed_runs": 0,
"first_seen": now, "last_seen": now, "total_time": 0.0,
})
s["total_runs"] += 1
if success:
s["successful_runs"] += 1
else:
s["failed_runs"] += 1
s["last_seen"] = now
s["total_time"] = s.get("total_time", 0.0) + duration
s["avg_response_time"] = round(s["total_time"] / s["total_runs"], 3)
stats[agent] = s
save_agent_stats(stats)
def is_error_response(text: str) -> bool:
"""Detect the error/refusal/busy sentinels this server emits on agent failures.
Must match COMPLETE sentinel phrases, never bare glyphs the warning
sign () and clock () can appear inside legitimate agent prose,
so matching them alone would false-positive real successes.
"""
t = (text or "").strip()
if not t:
return True
# Full-phrase sentinels only (start-anchored where appropriate).
hard = (
"timed out", # '...timed out.' / 'timed out after'
"CLI not installed", # '⚠ Agent X CLI not installed'
"Error communicating", # '⚠ Error communicating with X'
"did not return", # 'Gemini CLI did not return a response'
"needs setup", # '**Hermes needs setup**'
"needs re-auth", # '**Gemini needs re-auth**'
"Error executing skill", # '⚠ Error executing skill'
"not configured", # hermes setup markers
"no api key", "api_key not",
"config file not found", "command not found",
)
if any(p in t for p in hard):
return True
# Whole-line / start-of-response sentinels.
if t.startswith("Unknown agent"):
return True
if t.startswith("**Hermes") and "error" in t.lower():
return True
if t.startswith("**Gemini needs"):
return True
return False
# ─── Skill Eval Scoring (heuristic self-improvement metric) ───────
def compute_skill_score(name: str, response_text: str) -> int:
"""Weighted 0-100 quality score from eval.json criteria + response features."""
skill_dir = BASE_DIR / "skills" / name
criteria = []
eval_path = skill_dir / "eval.json"
if eval_path.exists():
try:
criteria = json.loads(eval_path.read_text()).get("criteria", [])
except Exception:
criteria = []
if not criteria:
criteria = [{"name": "completeness", "weight": 0.4},
{"name": "accuracy", "weight": 0.3},
{"name": "clarity", "weight": 0.3}]
total_w = sum(c.get("weight", 0) for c in criteria) or 1.0
text = response_text or ""
low = text.lower()
is_err = is_error_response(text)
completeness = 0 if is_err else min(100, int(len(text) / 8))
accuracy = 0 if is_err else 60
if not is_err:
if "```" in text: accuracy += 15
if any(m in low for m in ["step", "1.", "first", "example", "because"]): accuracy += 15
accuracy = min(100, accuracy)
clarity = 10 if is_err else 30
if not is_err:
if "\n#" in text or "##" in text: clarity += 25
if "- " in text or "* " in text: clarity += 20
if "```" in text: clarity += 15
clarity = min(100, clarity)
score_map = {"completeness": completeness, "accuracy": accuracy, "clarity": clarity}
overall = sum(score_map.get(c["name"], 50) * c.get("weight", 0) for c in criteria) / total_w
return max(0, min(100, int(round(overall))))
def record_skill_score(name: str, score: int, agent: str):
"""Append a run's score to skills/<name>/score-history.json."""
skill_dir = BASE_DIR / "skills" / name
if not skill_dir.exists():
return
hist_path = skill_dir / "score-history.json"
hist = json.loads(hist_path.read_text()) if hist_path.exists() else []
hist.append({
"date": get_timestamp()[:10],
"timestamp": get_timestamp(),
"score": score,
"agent": agent,
})
hist_path.write_text(json.dumps(hist, indent=2))
# ─── Agent Registry (dynamic, user-extensible) ─────────────────────
AGENT_REGISTRY_FILE = BASE_DIR / "data" / "agent-registry.json"
# Built-in agents (always present)
BUILTIN_AGENTS = {
"opencode": {
"name": "opencode",
"display_name": "OpenCode",
"description": "Code generation, DevOps, file operations",
"binary": "opencode",
"type": "cli",
"run_args": ["opencode", "run", "--format", "json", "{message}"],
"check_type": "binary",
"builtin": True,
},
"hermes": {
"name": "hermes",
"display_name": "Hermes Agent",
"description": "Memory, scheduling, multi-agent coordination",
"binary": "hermes",
"type": "cli",
"run_args": ["hermes", "chat", "-q", "{message}"],
"check_type": "binary",
"builtin": True,
},
"gemini": {
"name": "gemini",
"display_name": "Gemini CLI",
"description": "Research, analysis, multi-modal understanding",
"binary": "gemini",
"type": "cli",
"run_args": ["gemini", "-y", "-m", "gemini-2.5-flash", "{message}"],
"check_type": "oauth_file",
"oauth_file": ".gemini/gemini-credentials.json",
"builtin": True,
},
}
def load_agent_registry() -> dict:
"""Load agent registry from disk, merging with builtins."""
registry = dict(BUILTIN_AGENTS)
if AGENT_REGISTRY_FILE.exists():
try:
custom = json.loads(AGENT_REGISTRY_FILE.read_text())
for name, agent in custom.items():
if name not in BUILTIN_AGENTS:
agent["builtin"] = False
registry[name] = agent
except (json.JSONDecodeError, KeyError):
pass
return registry
def save_agent_registry(agents: dict):
"""Save custom agents to disk (excluding builtins)."""
AGENT_REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
custom = {k: v for k, v in agents.items() if not v.get("builtin", False)}
AGENT_REGISTRY_FILE.write_text(json.dumps(custom, indent=2))
def _cli_has_subcommand(base_args: list, subcommand: str) -> bool:
try:
@ -193,6 +377,66 @@ def _cli_has_subcommand(base_args: list, subcommand: str) -> bool:
except Exception:
return False
def check_agent(name: str) -> dict:
"""Dynamic agent check based on registry configuration."""
registry = load_agent_registry()
agent = registry.get(name)
if not agent:
return {"name": name, "status": "unknown", "display_name": name}
try:
check_type = agent.get("check_type", "binary")
binary = agent.get("binary", name)
exists = shutil.which(binary) is not None
if check_type == "binary":
status = "online" if exists else "offline"
elif check_type == "oauth_file":
oauth_path = Path.home() / agent.get("oauth_file", "")
logged_in = oauth_path.exists()
status = "online" if exists and logged_in else "offline" if not exists else "warning"
elif check_type == "http":
# HTTP health check endpoint
import urllib.request
try:
url = agent.get("health_url", "")
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=5) as resp:
status = "online" if resp.status == 200 else "warning"
except Exception:
status = "offline" if not exists else "warning"
elif check_type == "custom":
# Run a custom check command. IMPORTANT: never use shell=True —
# check_command comes from agent-registry config and a malicious
# value ("; rm -rf ~") would otherwise execute arbitrary shell.
check_cmd = agent.get("check_command", "")
if check_cmd:
import shlex
try:
cmd = shlex.split(check_cmd)
result = subprocess.run(
cmd, shell=False, capture_output=True, timeout=10
)
status = "online" if result.returncode == 0 else "offline"
except (ValueError, OSError):
status = "offline"
else:
status = "online" if exists else "offline"
else:
status = "online" if exists else "offline"
except Exception:
status = "offline"
return {
"name": name,
"display_name": agent.get("display_name", name),
"status": status,
"description": agent.get("description", ""),
"type": agent.get("type", "cli"),
"builtin": agent.get("builtin", False),
}
def hermes_cli_args(*args: str) -> list:
"""Build the command to invoke Hermes, bridging through WSL if the real agent only lives there.
@ -248,13 +492,99 @@ def check_agent(name: str) -> dict:
status = "online" if exists and logged_in else "offline" if not exists else "warning"
else:
status = "offline"
return {"name": name, "status": status}
return {
"name": name,
"display_name": agent.get("display_name", name),
"status": status,
"description": agent.get("description", ""),
"type": agent.get("type", "cli"),
"builtin": agent.get("builtin", False),
}
def execute_agent_dynamic(agent_name: str, message: str) -> str:
"""Execute a message on any registered agent."""
registry = load_agent_registry()
agent = registry.get(agent_name)
if not agent:
return f"Unknown agent: '{agent_name}'"
agent_type = agent.get("type", "cli")
if agent_type == "cli":
return execute_agent_cli(agent, message)
elif agent_type == "http":
return execute_agent_http(agent, message)
elif agent_type == "mcp":
return execute_agent_mcp(agent, message)
else:
return f"Unsupported agent type: '{agent_type}'"
def execute_agent_cli(agent: dict, message: str) -> str:
"""Execute via CLI binary."""
binary = agent.get("binary", agent["name"])
run_args_template = agent.get("run_args", [binary, "{message}"])
# Build command with message substituted
cmd = []
for arg in run_args_template:
if "{message}" in arg:
cmd.append(arg.replace("{message}", message))
else:
cmd.append(arg)
timeout = agent.get("timeout", 60)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode == 0:
output = (result.stdout or "").strip()
if output:
return output
return f"**{agent.get('display_name', binary)}**\n\nProcessed your message.\n\n**Message:** {message[:100]}"
err = (result.stderr or "").strip()
return err or f"{binary} returned exit code {result.returncode}"
except subprocess.TimeoutExpired:
return f"⏱ Agent '{agent.get('display_name', binary)}' timed out after {timeout}s.\n\n**Message:** {message[:100]}"
except FileNotFoundError:
return f"⚠ Agent '{agent.get('display_name', binary)}' CLI not installed. Install it and try again."
except Exception as e:
return f"⚠ Error communicating with {binary}: {str(e)}"
def execute_agent_http(agent: dict, message: str) -> str:
"""Execute via HTTP API endpoint."""
import urllib.request
url = agent.get("api_url", "")
method = agent.get("api_method", "POST")
headers = agent.get("api_headers", {"Content-Type": "application/json"})
timeout = agent.get("timeout", 60)
body = json.dumps({"message": message}).encode()
try:
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
result = json.loads(resp.read())
return result.get("response", result.get("output", str(result)))
except Exception as e:
return f"⚠ HTTP agent error: {str(e)}"
def execute_agent_mcp(agent: dict, message: str) -> str:
"""Execute via MCP server."""
return f"⚠ MCP agent type not yet implemented for '{agent.get('display_name', agent['name'])}'"
# ─── Routes: Status ───────────────────────────────────────────────
@app.get("/api/status")
def get_status():
agents = [check_agent(a) for a in AGENTS]
registry = load_agent_registry()
agents = [check_agent(name) for name in registry]
skills = list_dir(BASE_DIR / "skills")
return {
"status": "healthy",
@ -429,7 +759,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
line = line.strip()
if "Primary:" in line:
candidate = line.split(":")[-1].strip().lower()
if candidate in ("opencode", "hermes", "gemini"):
if candidate in load_agent_registry():
agent_choice = candidate
break
if agent_choice == "auto":
@ -446,15 +776,31 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
run_id = new_id()
# Execute via agent
# Execute via agent (track real duration for agent-stats)
import time as _t
_t0 = _t.time()
try:
response_text = execute_agent(agent_choice, prompt)
except subprocess.TimeoutExpired:
response_text = f"⏱ Skill '{name}' timed out on agent '{agent_choice}'."
except FileNotFoundError:
response_text = f"⚠ Agent '{agent_choice}' CLI not installed. Install it and try again."
except Exception as e:
except Exception as e: # last-resort guard (execute_agent usually returns an error string)
response_text = f"⚠ Error executing skill: {str(e)}"
_elapsed = _t.time() - _t0
_agent_used = agent_choice or "auto"
# Record REAL agent stats (consumed by /api/agents/health)
success = not is_error_response(response_text)
record_agent_run(_agent_used, success, _elapsed)
# Record REAL skill eval score (consumed by /api/analytics/skills)
score = compute_skill_score(name, response_text)
record_skill_score(name, score, _agent_used)
append_audit({
"action": "skill_scored",
"skill": name,
"agent": agent_choice,
"score": score,
"duration_s": round(_elapsed, 2),
"success": success,
})
# Save output to learnings.md
timestamp = get_timestamp()[:10]
@ -547,8 +893,8 @@ def get_audit(limit: int = Query(100, le=500)):
continue
try:
entries.append(json.loads(l))
except json.JSONDecodeError:
# Skip a corrupt line rather than failing the whole audit view.
except (json.JSONDecodeError, ValueError):
# Skip malformed lines in the audit log
continue
return {"entries": entries[-limit:]}
@ -583,21 +929,112 @@ def list_plugins():
@app.post("/api/plugins/install")
def install_plugin(data: dict):
name = data.get("name", "").strip()
if not name:
raise HTTPException(400, "Plugin name required")
repo_url = data.get("repo_url", "").strip()
if not name and not repo_url:
raise HTTPException(400, "Plugin name or repo_url required")
# Derive name from repo_url if not given
if not name and repo_url:
name = repo_url.rstrip("/").split("/")[-1].replace(".git", "")
reg_file = BASE_DIR / "registry" / "plugins.json"
reg = read_json(reg_file, {"plugins": []})
if any(p["name"] == name for p in reg["plugins"]):
return {"status": "already_installed"}
return {"status": "already_installed", "plugin": name}
plugin_dir = BASE_DIR / "skills" / name
# If repo_url provided, clone it
if repo_url:
import subprocess
if plugin_dir.exists():
# Pull latest if already cloned
subprocess.run(["git", "pull"], cwd=str(plugin_dir), capture_output=True)
else:
subprocess.run(["git", "clone", "--depth=1", repo_url, str(plugin_dir)], capture_output=True)
# If no repo_url, create from template
elif not plugin_dir.exists():
import shutil
template_dir = BASE_DIR / "skills" / "_template"
shutil.copytree(template_dir, plugin_dir)
# Detect metadata from SKILL.md frontmatter
skill_md = plugin_dir / "SKILL.md"
version = "1.0.0"
description = ""
plugin_type = "skill"
if skill_md.exists():
content = skill_md.read_text()
import re
ver_match = re.search(r'^version:\s*(.+)$', content, re.MULTILINE)
desc_match = re.search(r'^description:\s*(.+)$', content, re.MULTILINE)
type_match = re.search(r'^type:\s*(.+)$', content, re.MULTILINE)
if ver_match:
version = ver_match.group(1).strip().strip('"').strip("'")
if desc_match:
description = desc_match.group(1).strip().strip('"').strip("'")
if type_match:
plugin_type = type_match.group(1).strip()
reg["plugins"].append({
"name": name,
"version": version,
"description": description,
"installed": get_timestamp(),
"version": "1.0.0",
"type": plugin_type,
"source": repo_url or "local",
})
write_json(reg_file, reg)
append_audit({"action": "plugin_installed", "plugin": name})
return {"status": "installed", "plugin": name}
@app.delete("/api/plugins/{plugin_name}")
def uninstall_plugin(plugin_name: str):
reg_file = BASE_DIR / "registry" / "plugins.json"
reg = json.loads(reg_file.read_text()) if reg_file.exists() else {"plugins": []}
reg["plugins"] = [p for p in reg["plugins"] if p["name"] != plugin_name]
reg_file.write_text(json.dumps(reg, indent=2))
append_audit({"action": "plugin_uninstalled", "plugin": plugin_name})
return {"status": "uninstalled", "plugin": plugin_name}
# ─── Routes: Connected Apps ────────────────────────────────────────
@app.get("/api/integrations")
def list_integrations():
"""List connected external apps/integrations."""
int_file = BASE_DIR / "data" / "integrations.json"
if not int_file.exists():
return {"integrations": []}
return json.loads(int_file.read_text())
@app.post("/api/integrations")
def add_integration(data: dict):
"""Connect an external app via URL/webhook."""
name = data.get("name", "").strip()
url = data.get("url", "").strip()
int_type = data.get("type", "webhook")
if not name or not url:
raise HTTPException(400, "name and url required")
int_file = BASE_DIR / "data" / "integrations.json"
integrations = []
if int_file.exists():
integrations = json.loads(int_file.read_text()).get("integrations", [])
if any(i["name"] == name for i in integrations):
raise HTTPException(409, f"Integration '{name}' already exists")
integrations.append({
"name": name,
"url": url,
"type": int_type,
"added": get_timestamp(),
"status": "active",
})
int_file.write_text(json.dumps({"integrations": integrations}, indent=2))
append_audit({"action": "integration_added", "name": name, "url": url})
return {"status": "connected", "name": name}
# ─── Routes: Backup ───────────────────────────────────────────────
@app.get("/api/backups")
@ -759,6 +1196,9 @@ def clean_hermes_output(raw: str) -> str:
return '\n'.join(non_meta[-5:]) or raw
def execute_agent(agent: str, message: str) -> str:
import time as _t
_t0 = _t.time()
_ok = True
try:
if agent == "opencode":
try:
@ -797,9 +1237,17 @@ def execute_agent(agent: str, message: str) -> str:
# Empty response from model - return useful fallback
return f"**Hermes**\n\nReceived your message but the model returned an empty response. Try rephrasing your query.\n\n**Message:** {message}"
err_msg = (err or "").strip()
if "invalid choice" in err_msg or "usage:" in err_msg:
# Only flag as "needs setup" when the error clearly indicates Hermes
# is genuinely unconfigured (missing key/config). Transient failures
# (rate limits, timeouts, empty model output, API hiccups) must NOT
# be mislabeled as a setup problem.
setup_markers = ("not configured", "no api key", "api_key not", "missing config", "config file not found", "command not found")
if code != 0 and any(m in err_msg.lower() for m in setup_markers):
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}"
# Otherwise surface the real error so failures are diagnosable.
if err_msg:
return f"**Hermes error (exit {code})**\n\n{err_msg[:400]}\n\nIf this looks like a config problem, run `hermes setup`."
return f"hermes returned exit code {code}"
elif agent == "gemini":
for attempt, (args, to) in enumerate([
@ -834,8 +1282,9 @@ 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 AGENTS:
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
registry = load_agent_registry()
if agent not in registry:
raise HTTPException(400, f"Agent must be one of: {', '.join(registry.keys())}")
user_msg = {
"id": new_id(),
@ -847,6 +1296,8 @@ def chat(req: ChatRequest):
save_chat_message(user_msg)
response_text = execute_agent(agent, req.message)
# Record REAL agent stats (consumed by /api/agents/health)
record_agent_run(agent, not is_error_response(response_text), 0.0)
agent_msg = {
"id": new_id(),
@ -1320,6 +1771,15 @@ def kanban_remove_link(parent_id: str = Query(...), child_id: str = Query(...)):
save_kanban_task(t)
return {"status": "unlinked"}
@app.delete("/api/kanban/tasks/{task_id}")
def kanban_delete_task(task_id: str):
path = KANBAN_DIR / f"{task_id}.json"
if not path.exists():
raise HTTPException(404, "Task not found")
path.unlink()
append_audit({"action": "kanban_task_deleted", "task_id": task_id})
return {"status": "deleted"}
@app.post("/api/kanban/dispatch")
def kanban_dispatch():
dispatched = []
@ -1494,12 +1954,19 @@ def search_journal(q: str = Query("")):
@app.get("/api/agents/health")
def get_agent_health():
try:
stats = load_agent_stats()
agents = []
for name in AGENTS:
for name in load_agent_registry():
info = check_agent(name)
info["uptime"] = 0
info["success_rate"] = 100
info["last_seen"] = get_timestamp()
s = stats.get(name, {})
total = s.get("total_runs", 0)
info["total_runs"] = total
info["successful_runs"] = s.get("successful_runs", 0)
info["failed_runs"] = s.get("failed_runs", 0)
info["success_rate"] = round(100.0 * s.get("successful_runs", 0) / total, 1) if total else 0.0
info["avg_response_time"] = s.get("avg_response_time", 0.0)
info["uptime"] = 0 # process-level uptime not tracked per-agent
info["last_seen"] = s.get("last_seen", "")
agents.append(info)
return {"agents": agents, "updated": get_timestamp()}
except Exception as e:
@ -1508,17 +1975,21 @@ def get_agent_health():
@app.get("/api/agents/{name}/stats")
def get_agent_stats(name: str):
try:
if name not in AGENTS:
if name not in load_agent_registry():
raise HTTPException(400, "Invalid agent")
info = check_agent(name)
stats = load_agent_stats().get(name, {})
total = stats.get("total_runs", 0)
return {
"name": name,
"status": info["status"],
"total_runs": 0,
"successful_runs": 0,
"failed_runs": 0,
"avg_response_time": 0,
"last_seen": get_timestamp(),
"total_runs": total,
"successful_runs": stats.get("successful_runs", 0),
"failed_runs": stats.get("failed_runs", 0),
"success_rate": round(100.0 * stats.get("successful_runs", 0) / total, 1) if total else 0.0,
"avg_response_time": stats.get("avg_response_time", 0.0),
"first_seen": stats.get("first_seen", ""),
"last_seen": stats.get("last_seen", ""),
}
except HTTPException:
raise
@ -1529,7 +2000,7 @@ def get_agent_stats(name: str):
def refresh_agent_health():
try:
agents = []
for name in AGENTS:
for name in load_agent_registry():
info = check_agent(name)
agents.append(info)
append_audit({"action": "agent_health_refreshed"})
@ -1537,6 +2008,114 @@ def refresh_agent_health():
except Exception as e:
return {"agents": [], "error": str(e)}
# ─── Routes: Agent Registry Management ───────────────────────────
class AgentRegisterRequest(BaseModel):
name: str
display_name: str = ""
description: str = ""
binary: str = ""
type: str = "cli" # cli, http, mcp
run_args: list = []
check_type: str = "binary" # binary, oauth_file, http, custom
oauth_file: str = ""
health_url: str = ""
api_url: str = ""
api_method: str = "POST"
check_command: str = ""
timeout: int = 60
router_keywords: list = []
@app.get("/api/agents")
def list_agents():
"""List all registered agents (built-in + custom)."""
registry = load_agent_registry()
agents = []
for name, agent in registry.items():
info = check_agent(name)
agents.append(info)
return {"agents": agents}
@app.post("/api/agents/register")
def register_agent(req: AgentRegisterRequest):
"""Register a new custom agent."""
name = req.name.lower().strip().replace(" ", "_").replace("-", "_")
if not name:
raise HTTPException(400, "Agent name required")
if name in BUILTIN_AGENTS:
raise HTTPException(409, f"'{name}' is a built-in agent")
registry = load_agent_registry()
if name in registry:
raise HTTPException(409, f"Agent '{name}' already exists")
if not req.display_name:
req.display_name = req.name.replace("_", " ").replace("-", " ").title()
if not req.binary:
req.binary = req.name
if not req.run_args:
req.run_args = [req.binary, "{message}"]
agent_config = {
"name": name,
"display_name": req.display_name,
"description": req.description,
"binary": req.binary,
"type": req.type,
"run_args": req.run_args,
"check_type": req.check_type,
"timeout": req.timeout,
"builtin": False,
}
if req.oauth_file:
agent_config["oauth_file"] = req.oauth_file
if req.health_url:
agent_config["health_url"] = req.health_url
if req.api_url:
agent_config["api_url"] = req.api_url
if req.api_method:
agent_config["api_method"] = req.api_method
if req.check_command:
agent_config["check_command"] = req.check_command
registry[name] = agent_config
save_agent_registry(registry)
if req.router_keywords:
rkf = BASE_DIR / "data" / "router-keywords.json"
kw = json.loads(rkf.read_text()) if rkf.exists() else {}
kw[name] = req.router_keywords
rkf.write_text(json.dumps(kw, indent=2))
append_audit({"action": "agent_registered", "agent": name})
return {"status": "registered", "agent": check_agent(name)}
@app.delete("/api/agents/{agent_name}")
def unregister_agent(agent_name: str):
"""Remove a custom agent (built-ins cannot be removed)."""
if agent_name in BUILTIN_AGENTS:
raise HTTPException(403, "Cannot remove built-in agents")
registry = load_agent_registry()
if agent_name not in registry:
raise HTTPException(404, "Agent not found")
del registry[agent_name]
save_agent_registry(registry)
rkf = BASE_DIR / "data" / "router-keywords.json"
if rkf.exists():
kw = json.loads(rkf.read_text())
kw.pop(agent_name, None)
rkf.write_text(json.dumps(kw, indent=2))
append_audit({"action": "agent_unregistered", "agent": agent_name})
return {"status": "unregistered", "agent": agent_name}
# ─── Routes: Smart Router (2 endpoints) ─────────────────────────
ROUTER_RULES = {
@ -1549,6 +2128,14 @@ ROUTER_RULES = {
def router_suggest(data: RouterSuggest):
try:
task_lower = data.task.lower()
# Include custom agent router keywords
rkf = BASE_DIR / "data" / "router-keywords.json"
if rkf.exists():
custom_kw = json.loads(rkf.read_text())
for agent, kw in custom_kw.items():
if agent not in ROUTER_RULES:
ROUTER_RULES[agent] = kw
scores = {}
for agent, keywords in ROUTER_RULES.items():
scores[agent] = sum(1 for k in keywords if k in task_lower)
@ -1567,7 +2154,7 @@ def router_suggest(data: RouterSuggest):
def router_route(data: RouterRoute):
try:
agent = data.agent.lower()
if agent not in AGENTS:
if agent not in load_agent_registry():
return {"status": "error", "message": f"Invalid agent: {agent}"}
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
return {
@ -1667,12 +2254,35 @@ def get_session_replay(session_id: str):
except Exception as e:
return {"session_id": session_id, "messages": [], "error": str(e)}
# ─── WebSocket Terminal ───────────────────────────────────────────
import asyncio
import websockets
import pty
import os
import select
import struct
import fcntl
import signal
# NOTE: The interactive terminal is provided by the in-app WebSocket
# endpoint `/ws/terminal` (see PtySession above). The previous standalone
# WebSocket terminal server on port 8082 was removed to avoid two
# redundant terminal implementations.
# ─── Routes: Dashboard Static Files ──────────────────────────────
dashboard_dir = BASE_DIR / "dashboard"
if dashboard_dir.exists():
app.mount("/dashboard", StaticFiles(directory=str(dashboard_dir)), name="dashboard")
# graphify knowledge-graph output (built by `graphify . --code-only`).
# Served so dashboard pages can link/open the interactive graph.
graphify_dir = BASE_DIR / "graphify-out"
if graphify_dir.exists():
app.mount("/graphify-out", StaticFiles(directory=str(graphify_dir)), name="graphify-out")
@app.get("/", response_class=HTMLResponse)
def index():
html_file = BASE_DIR / "dashboard" / "index.html"
@ -1686,6 +2296,80 @@ def index():
return HTMLResponse(content=content)
return HTMLResponse("<h1>Agentic OS</h1><p>Dashboard not built yet. Run the installer for your platform first (<code>./install.sh</code> on Linux/macOS or <code>.\\install.ps1</code> on Windows).</p>")
@app.get("/test", response_class=HTMLResponse)
def test_page():
html_file = BASE_DIR / "dashboard" / "test.html"
if html_file.exists():
return HTMLResponse(html_file.read_text())
return HTMLResponse("<h1>Test page not found</h1>")
# ─── Routes: Agent Time Monitor ───────────────────────────────
# Tracks total time AI agents spent on the project. Time is DERIVED from
# event logs (audit.log, chat-history.json, cost-history.json) via a
# session-gap estimator — see scripts/analyze_agent_time.py.
# NOTE: no recompute happens at startup or import; the report is only
# regenerated on explicit /api/agent-time/recompute (or if the cache
# file is missing). This keeps app boot fast and side-effect free.
AGENT_TIME_REPORT = BASE_DIR / "data" / "agent-time.json"
AGENT_TIME_ANALYZER = BASE_DIR / "scripts" / "analyze_agent_time.py"
def _recompute_agent_time() -> dict:
"""Run the analyzer to (re)generate data/agent-time.json. Returns report."""
import subprocess
try:
subprocess.run(
[sys.executable, str(AGENT_TIME_ANALYZER)],
cwd=str(BASE_DIR), capture_output=True, text=True, timeout=120,
)
except Exception:
pass
# The analyzer always writes the file (even with 0 events), but guard
# in case the script is missing.
if AGENT_TIME_REPORT.exists():
return json.loads(AGENT_TIME_REPORT.read_text(encoding="utf-8"))
return {"project": "agentic-os", "total_seconds": 0, "total_human": "0h 0m 0s",
"agents": {}, "event_count": 0, "method": "unavailable"}
@app.get("/api/agent-time")
def get_agent_time(recompute: bool = False):
if recompute or not AGENT_TIME_REPORT.exists():
return _recompute_agent_time()
try:
return json.loads(AGENT_TIME_REPORT.read_text(encoding="utf-8"))
except Exception:
return _recompute_agent_time()
@app.post("/api/agent-time/recompute")
def recompute_agent_time():
report = _recompute_agent_time()
return {"ok": True, "total_human": report.get("total_human"),
"agents": len(report.get("agents", {})),
"event_count": report.get("event_count", 0)}
# Skin-friendly view for Rainmeter (WebParser). Returns plain text:
# Line 1: human total, e.g. "5h 34m 45s"
# Line 2: total_seconds + fixed-order per-agent seconds (0 if absent)
# system hermes opencode gemini codex jarvis kilocode test_claude test
# This fixed-width layout lets a single RegExp grab every value reliably.
_AGENT_ORDER = ["system", "hermes", "opencode", "gemini", "codex",
"jarvis", "kilocode", "test_claude", "test"]
@app.get("/api/agent-time/skin")
def agent_time_skin():
if not AGENT_TIME_REPORT.exists():
report = _recompute_agent_time()
else:
try:
report = json.loads(AGENT_TIME_REPORT.read_text(encoding="utf-8"))
except Exception:
report = _recompute_agent_time()
agents = report.get("agents", {})
total = report.get("total_seconds", 0)
per = " ".join(str(agents.get(a, {}).get("total_seconds", 0)) for a in _AGENT_ORDER)
body = f"{report.get('total_human', '0h 0m 0s')}\n{total} {per}"
return Response(content=body, media_type="text/plain; charset=utf-8")
# ─── 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>'
@ -1701,9 +2385,29 @@ def favicon_svg():
# ─── Main ─────────────────────────────────────────────────────────
if __name__ == "__main__":
import socket
import uvicorn
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8080)
parser.add_argument("--host", type=str, default="127.0.0.1")
parser.add_argument("--host", type=str, default="0.0.0.0")
args = parser.parse_args()
# ── Double-launch guard ───────────────────────────────────────────
# Refuse to start if the API port is already bound by another process.
# Without this, two server.py instances collide on 8081 (and 8082),
# producing phantom "register doesn't persist" bugs and bind crashes.
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
probe.bind((args.host, args.port))
probe.close()
except OSError:
print(
f"ERROR: port {args.port} on {args.host} is already in use. "
f"Agentic OS appears to be running already — stop it first "
f"(./start.sh --stop) before launching another instance."
)
raise SystemExit(1)
uvicorn.run(app, host=args.host, port=args.port)

77
skills-lock.json Normal file
View File

@ -0,0 +1,77 @@
{
"version": 1,
"skills": {
"firebase-ai-logic-basics": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-ai-logic-basics/SKILL.md",
"computedHash": "aba2d689236a21ac7989286b88ecb3399d616b90f2dd8946e675466e57802c35"
},
"firebase-app-hosting-basics": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-app-hosting-basics/SKILL.md",
"computedHash": "562f27d560ff640a1e18ec4f5dd4ae70bd98447433c077be19292793ae095572"
},
"firebase-auth-basics": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-auth-basics/SKILL.md",
"computedHash": "f4850f578bacdda96cacaa57b5e1121ec047e99bf7d389033042b1975af91040"
},
"firebase-basics": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-basics/SKILL.md",
"computedHash": "8210ee53231b8299df100561d4ef776d39a293fd54403008bd738c65a856d54f"
},
"firebase-crashlytics": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-crashlytics/SKILL.md",
"computedHash": "62a51efdc3240eed27697e14bbb8a29ebb8c94530fd3a1c851e747b90804d69b"
},
"firebase-data-connect": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-data-connect-basics/SKILL.md",
"computedHash": "92d3dff5c94753c4077cd44a5c7c1f5e077b8c07dfe28455776204fe4f087d81"
},
"firebase-firestore": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-firestore/SKILL.md",
"computedHash": "be5ed12b7402b1f36364e20d4f9ccd2caac90302e23427395a02570db9f1434a"
},
"firebase-hosting-basics": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-hosting-basics/SKILL.md",
"computedHash": "fd0fede590512379a3103b74a7c1e612e38e6948b08e763a75962cd8c7f5b8e2"
},
"firebase-remote-config-basics": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-remote-config-basics/SKILL.md",
"computedHash": "d5e9090dec35e050be958b72b672e39bd931014ebb311bac3a46f542fc04e10d"
},
"firebase-security-rules-auditor": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/firebase-security-rules-auditor/SKILL.md",
"computedHash": "7d05c33d2e00c1d26cbd99ae174272fdd962e63b67c801e2fc050cc63e681723"
},
"notion-knowledge-capture": {
"source": "openai/skills",
"sourceType": "github",
"skillPath": "skills/.curated/notion-knowledge-capture/SKILL.md",
"computedHash": "c1376422aec0fb3a4733e5a8a99eed4feff24fa45e25a2ffddaf07a68eed9198"
},
"xcode-project-setup": {
"source": "firebase/skills",
"sourceType": "github",
"skillPath": "skills/xcode-project-setup/SKILL.md",
"computedHash": "e7ddb878c879eda7a3e462cebebcebb827d90836e4cb8a11b513e54403176e7d"
}
}
}

View File

@ -0,0 +1,34 @@
---
name: _template
description: Template for creating new Agentic OS skills
version: 1.0.0
author: Agentic OS
tags: [template, meta]
---
# {Skill Name}
## Description
Brief description of what this skill does.
## When to Use
- Trigger condition 1
- Trigger condition 2
## Input
- What this skill expects as input
## Process
1. Step one
2. Step two
3. Step three
## Output
- What this skill produces
## Agent Assignment
- Primary: opencode / hermes / gemini
- Fallback: {fallback agent}
## Dependencies
- Any prerequisites

View File

@ -0,0 +1,7 @@
{
"criteria": [
{ "name": "completeness", "weight": 0.4 },
{ "name": "accuracy", "weight": 0.3 },
{ "name": "clarity", "weight": 0.3 }
]
}

View File

@ -0,0 +1,6 @@
# Learnings
Add lessons learned from each run here.
## Initial Setup
- Template created with standard skill structure

View File

@ -0,0 +1 @@
[]

View File

@ -4,3 +4,7 @@
- Exclude data/settings.json from backups
- Keep last 30 backups, remove older
- Timestamp filenames for easy sorting
## 2026-06-26
- opencode timed out on backup-skill execution
- Recommend: use hermes agent for backup-skill

View File

@ -4,7 +4,6 @@
- Socratic questioning reveals hidden constraints
- 3+ alternatives before recommending
## 2026-07-06 (Run a3123fc4)
- Agent: opencode
- Input: (none)
- Output: ⚠ Agent 'opencode' CLI not installed. Install it and try again.
## 2026-06-23
- opencode consistently times out on brainstorming runs (4+ attempts logged)
- Recommend: use hermes agent (not opencode) for brainstorming tasks

View File

@ -2,3 +2,24 @@
## 2026-05-17
- Follows Superpowers methodology for code review
## 2026-06-28
- opencode consistently times out on non-code tasks → route to hermes
## 2026-07-25 (Run 07aca844)
- Agent: opencode
- Input: Review the server.py router logic
- Output: ⏱ Agent 'opencode' timed out.
OpenCode's model is taking too long. Try running `opencode run "Execute the 'code-review' skill.
## Skill Instructions
---
"` directly in your terminal.
**Message:** Execute the 'code-review' skill.
## Skill Instructions
---
name: code-review
description: Automated

View File

@ -1 +1 @@
[]
[]

View File

@ -4,99 +4,41 @@
- Reads from brain/ files and audit log
- Runs best at session start
## 2026-05-18 (Run 7215be12)
## 2026-05-18
- Multipleup runs confirm: consistent pattern of reading brain/ + audit log
- Both opencode and hermes agents execute standup successfully
- Known backup filenagentic-os-20260517_163610.tar.gz created & restored
## 2026-06-23
- hermes agent returns partial output when session times out mid-execution
- openerate agent reads brain/ files correctly via Read tool
- Recommend: use `hermes chat -q "morning briefing"` for faster standup execution
## 2026-07-25 (Run efcfb319)
- Agent: hermes
- Input: (none)
- Output: I'll execute the daily-standup skill by reading the relevant brain files and audit log.
Here's your morning briefing:
AGENTIC OS — Daily Standup (May 18, 2026)
YESTERDAY (May 17):
- Agentic OS project created from scratch — full 3-agent architecture (opencode + Hermes + Gemini CLI)
- 51 features + 10 extras planned across 8 phases
- Dashboard built: FastAPI backend + vanilla JS SPA
- Skills hub seeded with 15+ skills (DevOps, content, research, coding, productivity)
- Git auto-versioning enabled
- Input: Generate today's standup notes
- Output: I'll execute the daily-standup skill. Let me gather the context from the
brain files and audit log.
Now let me check the audit log and pending tasks (kanban/goals).
Let me read the kanban task files to see pending work, and check today's
journal.
I have all the context I need. Here's today's daily standup.
══════════════════════════════════════════════
DAILY STANDUP — Saturday, July 25, 2026
══════════════════════════════════════════════
YESTERDAY / RECENT ACTIVITY (last 24h + recent log)
- 2026
## 2026-05-18 (Run 2de16a6e)
## 2026-07-25 (Run ba720ad7)
- Agent: hermes
- Input: (none)
- Output: I'll execute the daily-standup skill by reading the relevant brain files and audit log.
Daily Standup — Monday, May 18, 2026
Yesterday (May 17)
- Built Agentic OS from scratch — full project structure, 51 features + 10 extras
- Configured 3-agent architecture: opencode + Hermes + Gemini CLI
- Set up FastAPI dashboard with SPA frontend
- Created 15+ skills following _template/ convention
- Ran heartbeat and devops-audit skills successfully
- Created + restored backup snapshot (agentic-os-20260517
## 2026-05-18 (Run 524c5285)
- Agent: hermes
- Input: (none)
- Output: I'll execute the daily-standup skill by reading the relevant brain files and audit log.
Here's your morning briefing:
AGENTIC OS — Daily Standup (May 18, 2026)
YESTERDAY (May 17):
- Built Agentic OS v1.0 from scratch — full 3-agent architecture (opencode + Hermes + Gemini CLI)
- Created 51 features + 10 extras across 7 layers
- Set up FastAPI dashboard + SPA frontend
- Created 15+ skills with eval scoring and learnings tracking
- Ran heartbeat and devops-audit skills successfully
- Tested backup
## 2026-05-18 (Run dacf584a)
- Agent: hermes
- Input: (none)
- Output: I'll execute the daily-standup skill by reading the relevant brain files and audit log.
Now let me check the last 24h of audit entries more carefully and get the latest entries:
Here's your morning briefing:
AGENTIC OS — Daily Standup (May 18, 2026)
YESTERDAY (May 17):
- Agentic OS project created from scratch — full 3-agent architecture (opencode + Hermes + Gemini CLI)
- 51 features + 10 extras planned across 8 phases
- Dashboard built: FastAPI backend + vanilla JS SPA
- Skills hub seeded with
## 2026-05-18 (Run b6069709)
- Agent: opencode
- Input: (none)
- Output: # Agentic OS — Daily Standup (May 18, 2026)
## Yesterday (May 17)
- Built Agentic OS v1.0 from scratch — full 3-agent architecture, 7 layers, 51 features + 10 extras
- FastAPI dashboard + SPA frontend created with 13 dashboard pages
- 15+ skills seeded (_template/, heartbeat, devops-audit, content-draft, code-review, daily-standup, etc.)
- All 3 agents tested: opencode, Hermes, Gemini CLI
- Backup created & restored successfully (agentic-os-20260517_163610.tar.gz)
- Standards discovery run comp
## 2026-05-18 (Run a6d21650)
- Agent: hermes
- Input: (none)
- Output: Let me gather the data for today's standup.
AGENTIC OS — Daily Standup (May 18, 2026)
YESTERDAY (May 17):
- Built Agentic OS v1.0 from scratch — full 3-agent architecture (opencode + Hermes + Gemini CLI)
- 51 features + 10 extras across 7 layers
- FastAPI dashboard + SPA frontend live
- 15+ skills created with eval scoring and learnings tracking
- Heartbeat + devops-audit skills ran successfully
- Backup created and restored (agentic-os-20260517_163610.tar.gz)
- Standards discovery ran
- Tested
## 2026-05-18 (Run e1fffffb)
- Agent: opencode
- Input: (none)
- Output: **Agentic OS — Daily Standup (May 18, 2026)**
**Yesterday (May 17)**
- Built Agentic OS v1.0 from scratch — 3-agent architecture, 7 layers, 51+10 features
- FastAPI dashboard + 13 SPA pages, 15+ skills seeded
- All 3 agents tested (opencode, Hermes, Gemini CLI)
- Backup created & restored, standards discovery run
**Overnight Activity**
- Multiple daily-standup skill runs across opencode + Hermes (runs 7215be12 through a6d21650)
- Heartbeat tested successfully
- Chat interactions across all 3 a
- Input: standup
- Output: I'll execute the daily-standup skill. Let me gather context from the
brain files, audit log, and task systems in parallel.
Now let me check the kanban tasks, goals, and today's journal entry.
Let me read the kanban task files to assess their status.
Daily Standup gathered. Here's the briefing.
══════════════════════════════════════════════
DAILY STANDUP — Saturday, July 25, 2026
══════════════════════════════════════════════
YESTERDAY / RECENT ACTIVITY (last 24h + recent log)
- 2026-07-25 17:10

View File

@ -1 +1 @@
[]
[]

View File

@ -0,0 +1 @@
../.agents/skills/firebase-ai-logic-basics

View File

@ -0,0 +1 @@
../.agents/skills/firebase-app-hosting-basics

1
skills/firebase-auth-basics Symbolic link
View File

@ -0,0 +1 @@
../.agents/skills/firebase-auth-basics

1
skills/firebase-basics Symbolic link
View File

@ -0,0 +1 @@
../.agents/skills/firebase-basics

1
skills/firebase-crashlytics Symbolic link
View File

@ -0,0 +1 @@
../.agents/skills/firebase-crashlytics

View File

@ -0,0 +1 @@
../.agents/skills/firebase-data-connect

1
skills/firebase-firestore Symbolic link
View File

@ -0,0 +1 @@
../.agents/skills/firebase-firestore

View File

@ -0,0 +1 @@
../.agents/skills/firebase-hosting-basics

View File

@ -0,0 +1 @@
../.agents/skills/firebase-remote-config-basics

View File

@ -0,0 +1 @@
../.agents/skills/firebase-security-rules-auditor

View File

@ -4,3 +4,6 @@
- Always clarify goal with targeted questions first
- Identify critical path for complex projects
- Break into milestones, then tasks, then estimates
## 2026-06-28
- opencode consistently times out on non-code tasks → route to hermes

View File

@ -5,195 +5,52 @@
- Archive decisions older than 30 days
- Check for contradictions across skills
## 2026-07-05 (Run c1a36c88)
## 2026-06-26
- hermes timed out on memory-consolidation (model too slow)
- Recommend: try shorter queries or check OpenRouter rate limits
## 2026-06-28
- All 18 learnings.md files under 600 words — no compression needed
- All score-history.json files empty — no pruning needed
- recent-decisions.md: 3 archived entries (older than 30 days), 0 recent
- No contradictions found across skill learnings
- Stale truncated run logs cleaned from memory-consolidation, code-review, goal-planner, test-plugin
## 2026-06-29 (Run 2)
- All 18 learnings.md files still under 600 words (largest: memory-consolidation at 186)
- All 18 score-history.json files empty — no pruning needed
- recent-decisions.md: 3 archived entries (from 2026-05-17), 0 recent — no archiving needed
- No contradictions found across skill learnings
- Cleaned stale truncated run log from memory-consolidation learnings.md (leftover from previous run)
## 2026-07-17 (Run — full)
- Agent: hermes
- Input: (none)
- Output: error: uv trampoline failed to canonicalize script path
- All 18 learnings.md files under 600 words (largest: memory-consolidation at 245) — no compression needed
- All 18 score-history.json files empty (`[]`) — no pruning needed
- recent-decisions.md: 3 archived entries (from 2026-05-17, already >30 days old), 0 recent — no archiving needed
- No contradictions found across skill learnings
- Cleaned stale truncated run log (Run 0e7b20b5) from this file — it cut off mid-line and was leftover from an earlier failed run
## 2026-07-06 (Run e43971ff)
## 2026-07-17 (Run 4f8a2c19)
- Agent: hermes
- Input: (none)
- Output: error: uv trampoline failed to canonicalize script path
- 19 learnings.md files under 600 words (largest: memory-consolidation at 333) — no compression needed
- All 19 score-history.json files empty (`[]`) — no pruning needed
- recent-decisions.md: 3 archived entries (from 2026-05-17, >30 days old), 0 recent — no archiving needed
- No contradictions found across skill learnings
- Cleaned stale truncated run log (Run 82b75bcd) and stale opencode-timeout logs from firebase-hosting-basics
## 2026-07-06 (Run 92dadbb3)
## 2026-07-17 (Run 5e7d871e)
- Agent: hermes
- Input: (none)
- Output: error: uv trampoline failed to canonicalize script path
- Output: ⏱ Hermes timed out.
## 2026-07-06 (Run 1f988d3e)
- Agent: hermes
- Input: (none)
- Output: error: uv trampoline failed to canonicalize script path
The model took too long to respond. Try a shorter query or check your OpenRouter rate limits.
## 2026-07-06 (Run 25512ecc)
- Agent: hermes
- Input: (none)
- Output: error: uv trampoline failed to canonicalize script path
**Message:** Execute the 'memory-consolidation' skill.
## 2026-07-06 (Run 161cf0c5)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-06 (Run 53f4ae13)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-06 (Run 0dc32925)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-07 (Run 68a154bf)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-07 (Run 282f0ee8)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-07 (Run 5c5f05a6)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-07 (Run 9f9ba9a6)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-08 (Run 641c1cf9)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-09 (Run 2249d632)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-09 (Run 6e486ade)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-10 (Run 17fdf401)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-18 (Run e1a81794)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-19 (Run 0faa0ff0)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## 2026-07-19 (Run c63df1b3)
- Agent: hermes
- Input: (none)
- Output: **Hermes needs setup**
Run `hermes setup` or check your config.
**Details:** usage: hermes [-h]
{help,version,init,clean,harvest,process,curate,deposit,postprocess}
...
hermes: error: argument subcommand: invalid choice: 'chat' (choose from 'help',
## Skill Instructions
---
name: memory-consolidation
desc

View File

@ -0,0 +1 @@
../.agents/skills/notion-knowledge-capture

View File

@ -5,3 +5,7 @@
- GREEN phase: minimal code to pass
- REFACTOR phase: clean while tests stay green
- Never skip RED phase
## 2026-06-26
- opencode timed out 3 times on tdd-cycle execution
- Recommend: use hermes agent for tdd-cycle skill

View File

@ -0,0 +1,34 @@
---
name: _template
description: Template for creating new Agentic OS skills
version: 1.0.0
author: Agentic OS
tags: [template, meta]
---
# {Skill Name}
## Description
Brief description of what this skill does.
## When to Use
- Trigger condition 1
- Trigger condition 2
## Input
- What this skill expects as input
## Process
1. Step one
2. Step two
3. Step three
## Output
- What this skill produces
## Agent Assignment
- Primary: opencode / hermes / gemini
- Fallback: {fallback agent}
## Dependencies
- Any prerequisites

View File

@ -0,0 +1,7 @@
{
"criteria": [
{ "name": "completeness", "weight": 0.4 },
{ "name": "accuracy", "weight": 0.3 },
{ "name": "clarity", "weight": 0.3 }
]
}

View File

@ -0,0 +1,9 @@
# Learnings
Add lessons learned from each run here.
## Initial Setup
- Template created with standard skill structure
## 2026-06-28
- opencode timed out on test-plugin execution; non-code tasks should route to hermes

View File

@ -0,0 +1 @@
[]

1
skills/xcode-project-setup Symbolic link
View File

@ -0,0 +1 @@
../.agents/skills/xcode-project-setup

43
start-agentic-os.sh Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Agentic OS server launcher — idempotent + fully detached.
# Safe to run repeatedly: if the server is already listening it does nothing.
# This is the FALLBACK path used only when systemd is unavailable
# (e.g. a WSL instance that hasn't started the user session).
set -u
DIR="/home/austin/agentic-os"
LOG="/tmp/agentic-os.log"
PIDFILE="$DIR/.agentic-os.pid"
HOST="0.0.0.0"
PORT="8080"
# Already serving? Then do nothing (idempotent).
if ss -ltn 2>/dev/null | grep -q ":$PORT "; then
echo "Agentic OS already listening on :$PORT — nothing to do."
exit 0
fi
cd "$DIR" || { echo "FATAL: cannot cd to $DIR"; exit 1; }
# Remove a stale pidfile if its process is no longer alive.
if [ -f "$PIDFILE" ]; then
OLD="$(cat "$PIDFILE" 2>/dev/null)"
if [ -n "$OLD" ] && ! kill -0 "$OLD" 2>/dev/null; then
rm -f "$PIDFILE"
fi
fi
# setsid detaches into a new session so the server outlives the launching
# shell (cron tick, session end, terminal close).
setsid python3 server.py >> "$LOG" 2>&1 < /dev/null &
echo "$!" > "$PIDFILE"
echo "Launched Agentic OS (pid $(cat "$PIDFILE")) — log: $LOG"
# Brief wait, then verify it actually came up.
sleep 4
if ss -ltn 2>/dev/null | grep -q ":$PORT "; then
echo "OK: serving on http://$HOST:$PORT"
else
echo "WARN: not listening after 4s — tail of $LOG:"
tail -15 "$LOG"
fi

144
start.sh
View File

@ -1,62 +1,104 @@
#!/usr/bin/env bash
# Agentic OS Quick Start
# Usage: ./start.sh — start server
# ./start.sh --open — start server + open browser
# ./start.sh --stop — stop running server
# ./start.sh --status — check if running
set -euo pipefail
echo "Starting Agentic OS Dashboard..."
PORT="${PORT:-8081}"
PID_FILE=".agentic-os.pid"
DASHBOARD_URL="http://127.0.0.1:${PORT}"
# ── Handle flags ───────────────────────────────────────────────────────────
case "${1:-}" in
--stop)
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
kill "$PID" && echo "Agentic OS stopped (PID $PID)."
rm -f "$PID_FILE"
else
echo "Agentic OS is not running (stale PID file removed)."
rm -f "$PID_FILE"
fi
else
echo "Agentic OS is not running."
fi
exit 0
;;
--status)
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "Agentic OS is running (PID $(cat "$PID_FILE")) — $DASHBOARD_URL"
else
echo "Agentic OS is not running."
fi
exit 0
;;
esac
# ── Already running? ───────────────────────────────────────────────────────
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "Agentic OS is already running at $DASHBOARD_URL"
echo "Use ./start.sh --stop to stop it first."
exit 0
fi
# ── Setup ──────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Activate venv if it exists
if [ -d "venv" ]; then
source venv/bin/activate
fi
# Install deps if needed
if ! python3 -c "import fastapi" 2>/dev/null; then
echo "Installing dependencies..."
pip3 install -r requirements.txt --quiet
fi
# ── Start server ───────────────────────────────────────────────────────────
echo ""
echo " Agentic OS Dashboard"
echo " ─────────────────────"
echo " Starting on port ${PORT}..."
echo " URL: ${DASHBOARD_URL}"
echo " Press Ctrl+C to stop"
echo ""
# Check if server.py exists
if [ ! -f server.py ]; then
echo "ERROR: server.py not found. Are you in the right directory?"
exit 1
fi
nohup python3 server.py --port "$PORT" --host 0.0.0.0 > /tmp/agentic-os.log 2>&1 &
echo $! > "$PID_FILE"
# Resolve Python
if command -v python &>/dev/null; then
PYTHON="python"
elif command -v python3 &>/dev/null; then
PYTHON="python3"
else
echo "ERROR: Python 3.10+ required. Install from https://www.python.org/downloads/ or your OS package manager."
exit 1
fi
# Check dependencies
$PYTHON -m pip install -r requirements.txt --quiet 2>/dev/null
# Get port from settings or default
CONFIGURED_PORT=$($PYTHON -c "import json; f=open('data/settings.json'); d=json.load(f); print(d.get('dashboard',{}).get('port',8080)); f.close()" 2>/dev/null || echo "8080")
# Find a free port starting at CONFIGURED_PORT (in case it's already taken by another app)
PORT="$CONFIGURED_PORT"
FOUND=0
for _ in $(seq 1 20); do
if ! (exec 3<>"/dev/tcp/127.0.0.1/${PORT}") 2>/dev/null; then
FOUND=1
break
fi
exec 3>&- 2>/dev/null || true
PORT=$((PORT + 1))
# Wait for server to be ready
for i in $(seq 1 10); do
if curl -s http://127.0.0.1:${PORT}/api/status > /dev/null 2>&1; then
echo " Ready! ($DASHBOARD_URL)"
break
fi
sleep 0.5
done
if [ "$FOUND" -eq 0 ]; then
echo "ERROR: Could not find a free port after 20 attempts starting at ${CONFIGURED_PORT}."
exit 1
fi
# ── Open browser ───────────────────────────────────────────────────────────
if [ "$PORT" != "$CONFIGURED_PORT" ]; then
echo "WARNING: Port ${CONFIGURED_PORT} is already in use - using ${PORT} instead."
fi
case "${1:-}" in
--open)
echo " Opening browser..."
if command -v xdg-open &>/dev/null; then
xdg-open "$DASHBOARD_URL" 2>/dev/null || true
elif command -v open &>/dev/null; then
open "$DASHBOARD_URL" 2>/dev/null || true
fi
;;
esac
echo "Dashboard: http://127.0.0.1:${PORT}"
echo "Press Ctrl+C to stop"
echo ""
# Best-effort auto-open browser once the server is up
( sleep 2
if command -v xdg-open &>/dev/null; then xdg-open "http://127.0.0.1:${PORT}" &>/dev/null
elif command -v open &>/dev/null; then open "http://127.0.0.1:${PORT}" &>/dev/null
fi
) &
# Start server
$PYTHON server.py --port "${PORT}"
echo " Logs: tail -f /tmp/agentic-os.log"
echo " Stop: ./start.sh --stop"
echo ""