diff --git a/.gitignore b/.gitignore index 8803202..4e8a34c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e71bc63..3eb3849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0241c9 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/OPERATIONAL-NOTES.md b/OPERATIONAL-NOTES.md new file mode 100644 index 0000000..ea3897d --- /dev/null +++ b/OPERATIONAL-NOTES.md @@ -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 ` + +## 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 diff --git a/agents/hermes/USER.md b/agents/hermes/USER.md index b7bdb86..da08b28 100644 --- a/agents/hermes/USER.md +++ b/agents/hermes/USER.md @@ -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 diff --git a/brain/active-projects.md b/brain/active-projects.md index 0295e21..a760703 100644 --- a/brain/active-projects.md +++ b/brain/active-projects.md @@ -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. diff --git a/brain/business-brain.md b/brain/business-brain.md index e005738..5cfd122 100644 --- a/brain/business-brain.md +++ b/brain/business-brain.md @@ -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 diff --git a/brain/github-activity.md b/brain/github-activity.md new file mode 100644 index 0000000..4edc0fd --- /dev/null +++ b/brain/github-activity.md @@ -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) diff --git a/brain/health-report.md b/brain/health-report.md new file mode 100644 index 0000000..06bb449 --- /dev/null +++ b/brain/health-report.md @@ -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. diff --git a/brain/journal/2026-06-25.md b/brain/journal/2026-06-25.md new file mode 100644 index 0000000..04bca5a --- /dev/null +++ b/brain/journal/2026-06-25.md @@ -0,0 +1 @@ +Journal entry content verification test \ No newline at end of file diff --git a/brain/journal/2026-06-28.md b/brain/journal/2026-06-28.md new file mode 100644 index 0000000..dd91ad2 --- /dev/null +++ b/brain/journal/2026-06-28.md @@ -0,0 +1 @@ +I got to figure out how to get the KanBan Board working right. diff --git a/brain/log.md b/brain/log.md new file mode 100644 index 0000000..54dd2fe --- /dev/null +++ b/brain/log.md @@ -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 diff --git a/brain/memory.md b/brain/memory.md index ddaefbf..48bd024 100644 --- a/brain/memory.md +++ b/brain/memory.md @@ -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 diff --git a/brain/recent-decisions.md b/brain/recent-decisions.md index 1d4f322..8c330cd 100644 --- a/brain/recent-decisions.md +++ b/brain/recent-decisions.md @@ -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 diff --git a/brain/skill-usage.md b/brain/skill-usage.md new file mode 100644 index 0000000..86cf25e --- /dev/null +++ b/brain/skill-usage.md @@ -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) | diff --git a/dashboard/AGENT_TIME_MONITOR.md b/dashboard/AGENT_TIME_MONITOR.md new file mode 100644 index 0000000..87e905e --- /dev/null +++ b/dashboard/AGENT_TIME_MONITOR.md @@ -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) diff --git a/dashboard/api.js b/dashboard/api.js index e11c1dd..599ac22 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -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)}`), }; diff --git a/dashboard/index.html b/dashboard/index.html index 6a600ba..2985fd0 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -8,6 +8,7 @@ + @@ -49,6 +50,7 @@ 🧭Smart Router 📊Learning Analytics 🔄Session Replay + Agent Time 💰Cost Analytics 🔌Plugins diff --git a/dashboard/pages/agent-health.js b/dashboard/pages/agent-health.js index 8cd65ed..960ec53 100644 --- a/dashboard/pages/agent-health.js +++ b/dashboard/pages/agent-health.js @@ -1,115 +1,220 @@ -let agentHealthInterval = null; - async function renderAgentHealth() { const content = document.getElementById('pageContent'); content.innerHTML = ` -
-
-
-
+
+
+
+
-
Health Overview
-
-
Loading health data...
+
+
Agent Registry
+
`; - 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 => ` -
-
- ${agentIcons[a.name] || '🤖'} -
-
-
${a.name}
-
- - ${a.status} + 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 ` +
+
+
+ ${escapeHtml(a.display_name || a.name)} + ${a.builtin ? 'built-in' : 'custom'}
-
-
-
${a.status === 'online' ? '100' : '0'}%
-
Uptime
-
-
-
${a.status === 'online' ? '✓' : '✗'}
-
Reachable
-
-
-
${new Date(data.updated).toLocaleTimeString()}
-
Last Check
-
-
-
-
- `).join(''); - const overview = document.getElementById('healthOverviewCard'); - if (overview) { - const online = agents.filter(a => a.status === 'online').length; - const total = agents.length; - overview.innerHTML = ` -
-
-
System Status
-
- ${online}/${total} agents online · Last updated: ${new Date(data.updated).toLocaleTimeString()} -
-
-
- - ${online === total ? 'All Online' : online > 0 ? 'Partial' : 'Offline'} +
${escapeHtml(a.description || 'No description')}
+
+ ${a.status} + ${a.type || 'cli'}
+ ${!a.builtin ? `
` : ''}
`; - } + }).join(''); + + // Table view + table.innerHTML = ` + + + + + + + + + + + + ${agents.map(a => ` + + + + + + + + `).join('')} + +
AgentTypeStatusSourceActions
+
+
+ ${escapeHtml(a.display_name || a.name)} +
+
${escapeHtml(a.description || '')}
+
${a.type || 'cli'}${a.status}${a.builtin ? 'built-in' : 'custom'} + ${!a.builtin ? `` : ''} +
+ `; } catch (err) { - const cards = document.getElementById('agentHealthCards'); - if (cards) cards.innerHTML = `
Failed to load health data
${escapeHtml(err.message)}
`; + document.getElementById('agentCards').innerHTML = `
Error
${escapeHtml(err.message)}
`; + } +} + +function showAddAgentModal() { + showModal('Register New Agent', ` +
+ + +
Unique identifier (lowercase, no spaces)
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ + +
Keywords for smart routing to this agent
+
+ `, ` + + + `); + + // 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'); } } diff --git a/dashboard/pages/agent-time-monitor.html b/dashboard/pages/agent-time-monitor.html new file mode 100644 index 0000000..fa887ad --- /dev/null +++ b/dashboard/pages/agent-time-monitor.html @@ -0,0 +1,222 @@ + + + + + +Agentic-OS · Agent Time Monitor + + + +
+
+

Agentic-OS · Agent Time Monitor

+
Total AI-agent time spent on the project · derived from audit & chat logs
+
+
project: agentic-os
+
+ +
+
+
+
+
Total Agent Time
+
+
loading…
+
+
+
Agents Active
+
+
distinct agents
+
+
+
Logged Events
+
+
audit + chat + cost
+
+
+
Coverage
+
+
first → last activity
+
+
+ +
+ + + Ready. Estimates are session-gap based. +
+
+ +
+

Time per Agent

+
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).
+ + + +
AgentTotal TimeShareSessionsTouches
loading…
+
+ +
+

Codebase Knowledge Graph · graphify

+
Interactive graphify visualization of the agentic-os codebase + (nodes = code symbols/modules, edges = relationships). Rebuild with + graphify . --code-only inside the project.
+
+ nodes: — + edges: — + communities: — + ↗ open full graph in new tab +
+ +
+
+ + + + diff --git a/dashboard/pages/agent-time.js b/dashboard/pages/agent-time.js new file mode 100644 index 0000000..aaeb5a9 --- /dev/null +++ b/dashboard/pages/agent-time.js @@ -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 = ` + +
+
+
+
+
+
+
+
+ `; + 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 ? ` +
+
Codebase Graph (graphify)
+
+
${g.nodes}
nodes
+
${g.edges}
edges
+
${g.communities != null ? g.communities : '—'}
communities
+
+ ↗ Open interactive graph +
` : ''; + + body.innerHTML = ` +
+
+
Total Agent Time
+
${escapeHtml(data.total_human || '0h 0m 0s')}
+
estimated · ${escapeHtml((data.method || '').replace('session-gap estimate ', ''))}
+
+
+
Active Agents
+
${keys.length}
+
distinct agents
+
+
+
Logged Events
+
${data.event_count || 0}
+
audit + chat + cost
+
+
+
Coverage
+
${(data.first_seen || '—').slice(0, 10)}
+
→ ${(data.last_seen || '—').slice(0, 10)}
+
first → last activity
+
+
+ +
+
Time per Agent + sessions of activity (30-min gap) + 2-min tail
+
+ + + + ${sorted.map(k => { + const a = agents[k]; + const pct = ((a.total_seconds / max) * 100).toFixed(1); + return ` + + + + + + + `; + }).join('')} + +
AgentTotal TimeShareSessionsTouches
${escapeHtml(k)}${fmtDuration(a.total_seconds)} +
+
+
+
${a.sessions}${a.touches}
+
+
+ + ${graphCard ? `
${graphCard}
` : ''} + +
+ Computed ${escapeHtml((data.generated_at || '').replace('T', ' ').slice(0, 19))} UTC. + Totals are estimates — the project logs events, not durations; see the method in the report. +
+ `; + // animate bars + setTimeout(() => { + document.querySelectorAll('#agentTimeBody [data-w]').forEach(el => { el.style.width = el.dataset.w; }); + }, 60); + } catch (err) { + body.innerHTML = ` +
+
+
+
Could not load agent-time report
+
${escapeHtml(err.message)}
+ +
+
`; + } +} + +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'); + } +} diff --git a/dashboard/pages/journal.js b/dashboard/pages/journal.js index 1f464da..50373ef 100644 --- a/dashboard/pages/journal.js +++ b/dashboard/pages/journal.js @@ -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); } } diff --git a/dashboard/pages/plugins.js b/dashboard/pages/plugins.js index 04385fb..ac19a5a 100644 --- a/dashboard/pages/plugins.js +++ b/dashboard/pages/plugins.js @@ -4,68 +4,178 @@ async function renderPlugins() { + +
+
+
Install a Plugin
+
+ + +
Paste a GitHub URL to clone a skill from a repo
+
+
+ + +
+ +
+
+ +
+
Connect External App
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+
Connected Apps
+
+
+
`; 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 = '
🔌
No plugins installed
Install plugins from the registry or create your own
'; - return; - } - - container.innerHTML = ` -
- - - - ${plugins.map(p => ` - - - - - - - `).join('')} - -
PluginVersionTypeInstalled
${p.name}${p.version || '1.0.0'}${p.type || 'skill'}${formatDate(p.installed)}
-
-
${plugins.length} plugin${plugins.length !== 1 ? 's' : ''}
- `; + renderPluginTable(pluginsData.plugins || []); + renderIntegrations(integrationsData.integrations || []); } catch (err) { document.getElementById('pluginList').innerHTML = `
${escapeHtml(err.message)}
`; } } -function showInstallPlugin() { - showModal('Install Plugin', ` -
- - -
Enter the name of the plugin to install from the registry
+function renderPluginTable(plugins) { + const container = document.getElementById('pluginList'); + if (plugins.length === 0) { + container.innerHTML = '
🔌
No plugins installed
Install plugins from GitHub or create your own
'; + return; + } + container.innerHTML = ` +
+
Installed Plugins (${plugins.length})
+
+ + + + ${plugins.map(p => ` + + + + + + + + + `).join('')} + +
PluginVersionTypeSourceInstalled
${escapeHtml(p.name)}${p.version || '1.0.0'}${p.type || 'skill'}${escapeHtml(p.source || 'built-in')}${formatDate(p.installed)}${p.source ? `` : '—'}
+
- `, ` - - - `); + `; } -async function installPlugin() { +function renderIntegrations(integrations) { + const container = document.getElementById('integrationsList'); + if (integrations.length === 0) { + container.innerHTML = '
No external apps connected yet
'; + return; + } + container.innerHTML = `
+ ${integrations.map(i => ` +
+
+ ${escapeHtml(i.name)} + ${escapeHtml(i.type)} +
${escapeHtml(i.url)}
+
+ ● connected +
+ `).join('')} +
`; +} + +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 = 'Please enter a GitHub repo URL'; + return; + } + + statusEl.innerHTML = 'Installing...'; 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 = `✓ ${escapeHtml(r.plugin)} installed`; + document.getElementById('pluginRepoInput').value = ''; + document.getElementById('pluginNameInput').value = ''; + setTimeout(() => renderPlugins(), 2000); + } catch (err) { + statusEl.innerHTML = `Error: ${escapeHtml(err.message)}`; + } +} + +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 = 'Name and URL required'; + return; + } + + statusEl.innerHTML = 'Connecting...'; + try { + const r = await api.addIntegration(name, url, type); + statusEl.innerHTML = `✓ ${escapeHtml(r.name)} connected`; + document.getElementById('integrationNameInput').value = ''; + document.getElementById('integrationUrlInput').value = ''; + setTimeout(() => renderPlugins(), 2000); + } catch (err) { + statusEl.innerHTML = `Error: ${escapeHtml(err.message)}`; + } +} diff --git a/dashboard/pages/smart-router.js b/dashboard/pages/smart-router.js index ba8e319..6a10207 100644 --- a/dashboard/pages/smart-router.js +++ b/dashboard/pages/smart-router.js @@ -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() {
@@ -32,14 +43,51 @@ async function renderSmartRouter() {
Routing Rules
- +
- - - +
AgentBest ForKeywords
🔧 opencodeCode, DevOps, infra, git, file operationscode, deploy, git, terraform, docker, test, build, script
⚡ HermesMemory, scheduling, messaging, skillsmemory, schedule, cron, reminder, brain, plugin, backup
🧠 Gemini CLIResearch, analysis, study, document, reviewresearch, analyze, search, explain, study, learn, report
`; + 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 ` + ${meta.icon} ${name} + ${meta.blurb} + ${kw.join(', ') || '—'} + `; + }).join(''); + table.innerHTML = `AgentBest ForKeywords${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 = `
diff --git a/dashboard/pages/terminal.js b/dashboard/pages/terminal.js index 25548a1..281d405 100644 --- a/dashboard/pages/terminal.js +++ b/dashboard/pages/terminal.js @@ -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 = ` -
-
+
+
`; - 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 = - `
Failed to load terminal library
${escapeHtml(err.message || String(err))}
`; - 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' })); + } + } } diff --git a/dashboard/serve_monitor.py b/dashboard/serve_monitor.py new file mode 100644 index 0000000..6fbe065 --- /dev/null +++ b/dashboard/serve_monitor.py @@ -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() diff --git a/dashboard/start-monitor.sh b/dashboard/start-monitor.sh new file mode 100755 index 0000000..ee7d0d8 --- /dev/null +++ b/dashboard/start-monitor.sh @@ -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:/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}" diff --git a/dashboard/styles.css b/dashboard/styles.css index 79bdefd..5db0b5f 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -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; diff --git a/dashboard/test.html b/dashboard/test.html new file mode 100644 index 0000000..b26f946 --- /dev/null +++ b/dashboard/test.html @@ -0,0 +1,20 @@ + + +Agentic OS Test + +

Connection Test

+
Testing...
+
+ + + diff --git a/dashboard/utils.js b/dashboard/utils.js index 2dd85c1..b3afcc5 100644 --- a/dashboard/utils.js +++ b/dashboard/utils.js @@ -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' }, }; diff --git a/data/agent-registry.json b/data/agent-registry.json new file mode 100644 index 0000000..2706d2e --- /dev/null +++ b/data/agent-registry.json @@ -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" + } +} \ No newline at end of file diff --git a/data/agent-stats.json b/data/agent-stats.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/data/agent-stats.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/data/agent-time.json b/data/agent-time.json new file mode 100644 index 0000000..1d216d3 --- /dev/null +++ b/data/agent-time.json @@ -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" +} \ No newline at end of file diff --git a/data/goals.json b/data/goals.json index 7459094..3326c23 100644 --- a/data/goals.json +++ b/data/goals.json @@ -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" } ] \ No newline at end of file diff --git a/data/integrations.json b/data/integrations.json new file mode 100644 index 0000000..d8401c6 --- /dev/null +++ b/data/integrations.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/data/kanban/1314316c.json b/data/kanban/1314316c.json new file mode 100644 index 0000000..6133aca --- /dev/null +++ b/data/kanban/1314316c.json @@ -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" +} \ No newline at end of file diff --git a/data/kanban/7a250df7.json b/data/kanban/7a250df7.json new file mode 100644 index 0000000..0a1be97 --- /dev/null +++ b/data/kanban/7a250df7.json @@ -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" +} \ No newline at end of file diff --git a/data/router-keywords.json b/data/router-keywords.json new file mode 100644 index 0000000..6c65844 --- /dev/null +++ b/data/router-keywords.json @@ -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" + ] +} \ No newline at end of file diff --git a/docs/GRAPHIFY_MEMORY.md b/docs/GRAPHIFY_MEMORY.md new file mode 100644 index 0000000..ae270b3 --- /dev/null +++ b/docs/GRAPHIFY_MEMORY.md @@ -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) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bbefb68 --- /dev/null +++ b/docs/README.md @@ -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//` 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`: `` +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 diff --git a/scheduler/jobs/test-audit-job.json b/scheduler/jobs/test-audit-job.json new file mode 100644 index 0000000..16ed989 --- /dev/null +++ b/scheduler/jobs/test-audit-job.json @@ -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 +} \ No newline at end of file diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index b8cba8a..f27dd35 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -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() diff --git a/scripts/analyze_agent_time.py b/scripts/analyze_agent_time.py new file mode 100644 index 0000000..60e51c4 --- /dev/null +++ b/scripts/analyze_agent_time.py @@ -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() diff --git a/scripts/update-api-docs.py b/scripts/update-api-docs.py new file mode 100644 index 0000000..fd5f7e0 --- /dev/null +++ b/scripts/update-api-docs.py @@ -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() diff --git a/server.py b/server.py index 4b0760b..a476ba1 100644 --- a/server.py +++ b/server.py @@ -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//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("

Agentic OS

Dashboard not built yet. Run the installer for your platform first (./install.sh on Linux/macOS or .\\install.ps1 on Windows).

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

Test page not found

") + +# ─── 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 = '' @@ -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) diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..042a590 --- /dev/null +++ b/skills-lock.json @@ -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" + } + } +} diff --git a/skills/audit-test-plugin/SKILL.md b/skills/audit-test-plugin/SKILL.md new file mode 100644 index 0000000..4c0ec9b --- /dev/null +++ b/skills/audit-test-plugin/SKILL.md @@ -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 diff --git a/skills/audit-test-plugin/eval.json b/skills/audit-test-plugin/eval.json new file mode 100644 index 0000000..420b2f6 --- /dev/null +++ b/skills/audit-test-plugin/eval.json @@ -0,0 +1,7 @@ +{ + "criteria": [ + { "name": "completeness", "weight": 0.4 }, + { "name": "accuracy", "weight": 0.3 }, + { "name": "clarity", "weight": 0.3 } + ] +} diff --git a/skills/audit-test-plugin/learnings.md b/skills/audit-test-plugin/learnings.md new file mode 100644 index 0000000..c0f0d24 --- /dev/null +++ b/skills/audit-test-plugin/learnings.md @@ -0,0 +1,6 @@ +# Learnings + +Add lessons learned from each run here. + +## Initial Setup +- Template created with standard skill structure diff --git a/skills/audit-test-plugin/score-history.json b/skills/audit-test-plugin/score-history.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/skills/audit-test-plugin/score-history.json @@ -0,0 +1 @@ +[] diff --git a/skills/backup-skill/learnings.md b/skills/backup-skill/learnings.md index b586338..f77efbb 100644 --- a/skills/backup-skill/learnings.md +++ b/skills/backup-skill/learnings.md @@ -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 diff --git a/skills/brainstorming/learnings.md b/skills/brainstorming/learnings.md index baea8b2..3b71582 100644 --- a/skills/brainstorming/learnings.md +++ b/skills/brainstorming/learnings.md @@ -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 diff --git a/skills/code-review/learnings.md b/skills/code-review/learnings.md index 8dd8e7e..7b595e0 100644 --- a/skills/code-review/learnings.md +++ b/skills/code-review/learnings.md @@ -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 diff --git a/skills/code-review/score-history.json b/skills/code-review/score-history.json index fe51488..0637a08 100644 --- a/skills/code-review/score-history.json +++ b/skills/code-review/score-history.json @@ -1 +1 @@ -[] +[] \ No newline at end of file diff --git a/skills/daily-standup/learnings.md b/skills/daily-standup/learnings.md index f1b8ddc..4dd83b6 100644 --- a/skills/daily-standup/learnings.md +++ b/skills/daily-standup/learnings.md @@ -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 diff --git a/skills/daily-standup/score-history.json b/skills/daily-standup/score-history.json index fe51488..0637a08 100644 --- a/skills/daily-standup/score-history.json +++ b/skills/daily-standup/score-history.json @@ -1 +1 @@ -[] +[] \ No newline at end of file diff --git a/skills/firebase-ai-logic-basics b/skills/firebase-ai-logic-basics new file mode 120000 index 0000000..5f1bea8 --- /dev/null +++ b/skills/firebase-ai-logic-basics @@ -0,0 +1 @@ +../.agents/skills/firebase-ai-logic-basics \ No newline at end of file diff --git a/skills/firebase-app-hosting-basics b/skills/firebase-app-hosting-basics new file mode 120000 index 0000000..d64dc07 --- /dev/null +++ b/skills/firebase-app-hosting-basics @@ -0,0 +1 @@ +../.agents/skills/firebase-app-hosting-basics \ No newline at end of file diff --git a/skills/firebase-auth-basics b/skills/firebase-auth-basics new file mode 120000 index 0000000..f3d671e --- /dev/null +++ b/skills/firebase-auth-basics @@ -0,0 +1 @@ +../.agents/skills/firebase-auth-basics \ No newline at end of file diff --git a/skills/firebase-basics b/skills/firebase-basics new file mode 120000 index 0000000..a79c9ad --- /dev/null +++ b/skills/firebase-basics @@ -0,0 +1 @@ +../.agents/skills/firebase-basics \ No newline at end of file diff --git a/skills/firebase-crashlytics b/skills/firebase-crashlytics new file mode 120000 index 0000000..941f300 --- /dev/null +++ b/skills/firebase-crashlytics @@ -0,0 +1 @@ +../.agents/skills/firebase-crashlytics \ No newline at end of file diff --git a/skills/firebase-data-connect b/skills/firebase-data-connect new file mode 120000 index 0000000..c8bd928 --- /dev/null +++ b/skills/firebase-data-connect @@ -0,0 +1 @@ +../.agents/skills/firebase-data-connect \ No newline at end of file diff --git a/skills/firebase-firestore b/skills/firebase-firestore new file mode 120000 index 0000000..d942ee7 --- /dev/null +++ b/skills/firebase-firestore @@ -0,0 +1 @@ +../.agents/skills/firebase-firestore \ No newline at end of file diff --git a/skills/firebase-hosting-basics b/skills/firebase-hosting-basics new file mode 120000 index 0000000..0216d92 --- /dev/null +++ b/skills/firebase-hosting-basics @@ -0,0 +1 @@ +../.agents/skills/firebase-hosting-basics \ No newline at end of file diff --git a/skills/firebase-remote-config-basics b/skills/firebase-remote-config-basics new file mode 120000 index 0000000..12c5d57 --- /dev/null +++ b/skills/firebase-remote-config-basics @@ -0,0 +1 @@ +../.agents/skills/firebase-remote-config-basics \ No newline at end of file diff --git a/skills/firebase-security-rules-auditor b/skills/firebase-security-rules-auditor new file mode 120000 index 0000000..b783562 --- /dev/null +++ b/skills/firebase-security-rules-auditor @@ -0,0 +1 @@ +../.agents/skills/firebase-security-rules-auditor \ No newline at end of file diff --git a/skills/goal-planner/learnings.md b/skills/goal-planner/learnings.md index b8e3d54..95dedc9 100644 --- a/skills/goal-planner/learnings.md +++ b/skills/goal-planner/learnings.md @@ -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 diff --git a/skills/memory-consolidation/learnings.md b/skills/memory-consolidation/learnings.md index 08defbb..8f14cfd 100644 --- a/skills/memory-consolidation/learnings.md +++ b/skills/memory-consolidation/learnings.md @@ -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 diff --git a/skills/notion-knowledge-capture b/skills/notion-knowledge-capture new file mode 120000 index 0000000..60e5b0a --- /dev/null +++ b/skills/notion-knowledge-capture @@ -0,0 +1 @@ +../.agents/skills/notion-knowledge-capture \ No newline at end of file diff --git a/skills/tdd-cycle/learnings.md b/skills/tdd-cycle/learnings.md index 34f8f01..2cf6093 100644 --- a/skills/tdd-cycle/learnings.md +++ b/skills/tdd-cycle/learnings.md @@ -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 diff --git a/skills/test-plugin/SKILL.md b/skills/test-plugin/SKILL.md new file mode 100644 index 0000000..4c0ec9b --- /dev/null +++ b/skills/test-plugin/SKILL.md @@ -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 diff --git a/skills/test-plugin/eval.json b/skills/test-plugin/eval.json new file mode 100644 index 0000000..420b2f6 --- /dev/null +++ b/skills/test-plugin/eval.json @@ -0,0 +1,7 @@ +{ + "criteria": [ + { "name": "completeness", "weight": 0.4 }, + { "name": "accuracy", "weight": 0.3 }, + { "name": "clarity", "weight": 0.3 } + ] +} diff --git a/skills/test-plugin/learnings.md b/skills/test-plugin/learnings.md new file mode 100644 index 0000000..530635a --- /dev/null +++ b/skills/test-plugin/learnings.md @@ -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 diff --git a/skills/test-plugin/score-history.json b/skills/test-plugin/score-history.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/skills/test-plugin/score-history.json @@ -0,0 +1 @@ +[] diff --git a/skills/xcode-project-setup b/skills/xcode-project-setup new file mode 120000 index 0000000..5bab6fb --- /dev/null +++ b/skills/xcode-project-setup @@ -0,0 +1 @@ +../.agents/skills/xcode-project-setup \ No newline at end of file diff --git a/start-agentic-os.sh b/start-agentic-os.sh new file mode 100755 index 0000000..6274080 --- /dev/null +++ b/start-agentic-os.sh @@ -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 diff --git a/start.sh b/start.sh index 4dd584d..e296845 100755 --- a/start.sh +++ b/start.sh @@ -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 ""