Chris4K commited on
Commit
7182363
Β·
verified Β·
1 Parent(s): 0e491e5

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +15 -0
  2. README.md +106 -8
  3. main.py +1594 -0
  4. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+
4
+ RUN useradd -m -u 1000 user
5
+
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ COPY . .
10
+
11
+ RUN mkdir -p data logs traces && chown -R user:user /app
12
+
13
+ USER user
14
+ EXPOSE 7860
15
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,13 +1,111 @@
1
  ---
2
  title: Agent Pulse
3
- emoji: πŸ“‰
4
- colorFrom: green
5
- colorTo: pink
6
- sdk: gradio
7
- sdk_version: 6.9.0
8
- app_file: app.py
9
  pinned: false
10
- short_description: Agent Pulse - v1-
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Agent Pulse
3
+ emoji: πŸ’“
4
+ colorFrom: blue
5
+ colorTo: red
6
+ sdk: docker
 
 
7
  pinned: false
8
+ short_description: Agent Nervous System β€” Heartbeat, ReAct Timetable Scheduler
9
  ---
10
 
11
+ # πŸ’“ PULSE β€” Agent Nervous System
12
+
13
+ The heartbeat, ReAct loop, identity layer, and timetable scheduler connecting the entire ki-fusion-labs agent ecosystem.
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ PULSE (this)
19
+ β”‚
20
+ β”œβ”€β”€ πŸ—£ RELAY β€” send/receive messages, delegate tasks
21
+ β”œβ”€β”€ 🧠 MEMORY β€” store findings, recall context
22
+ β”œβ”€β”€ πŸ“‹ KANBAN β€” create/claim/move tasks
23
+ β”œβ”€β”€ ⚑ NEXUS β€” LLM routing (RTX 5090 + HF fallback)
24
+ β”œβ”€β”€ πŸ—„ VAULT β€” read/write/execute code
25
+ β”œβ”€β”€ πŸ”§ FORGE β€” skill & tool registry
26
+ └── πŸ“š KNOWLEDGE β€” document search (RAG)
27
+ ```
28
+
29
+ ## Agent ReAct Loop
30
+
31
+ Each agent tick:
32
+ 1. Check RELAY inbox for messages
33
+ 2. Check KANBAN for assigned open tasks
34
+ 3. If anything found β†’ ReAct loop via NEXUS
35
+ 4. Steps: Thought β†’ Action(tool) β†’ Observation β†’ repeat
36
+ 5. Store results in MEMORY / KANBAN / VAULT
37
+
38
+ ## Timetable Scheduler
39
+
40
+ - **Weekly calendar** view β€” drag, add, edit schedule entries
41
+ - **Daily / Weekly / Once** recurrence with APScheduler
42
+ - Per-agent cron schedules (UTC)
43
+ - Each entry carries a custom prompt for the agent
44
+
45
+ ## Environment Variables (HF Secrets)
46
+
47
+ | Variable | Default | Description |
48
+ |---|---|---|
49
+ | `RELAY_URL` | chris4k-agent-relay.hf.space | RELAY space URL |
50
+ | `MEMORY_URL` | chris4k-agent-memory.hf.space | MEMORY space URL |
51
+ | `KANBAN_URL` | chris4k-agent-kanban-board.hf.space | KANBAN URL |
52
+ | `NEXUS_URL` | chris4k-agent-nexus.hf.space | NEXUS LLM router URL |
53
+ | `VAULT_URL` | chris4k-agent-vault.hf.space | VAULT URL |
54
+ | `FORGE_URL` | chris4k-agent-forge.hf.space | FORGE URL |
55
+ | `NEXUS_MODEL` | nexus-auto | Model name for LLM calls |
56
+ | `REACT_MAX_STEPS` | 6 | Max steps per ReAct loop |
57
+ | `VAULT_EXEC_TIMEOUT` | 30 | Execution timeout |
58
+
59
+ ## Default Agents
60
+
61
+ | Agent | Heartbeat | Role |
62
+ |---|---|---|
63
+ | `researcher` | off | Info gathering, memory storage |
64
+ | `coder` | off | Code writing & execution |
65
+ | `planner` | off | Task decomposition, sprint planning |
66
+ | `monitor` | 5min | System health, alerts, watchdog |
67
+ | `christof` | off | Personal coordinator, daily briefs |
68
+
69
+ ## Default Schedule
70
+
71
+ | Time | Agent | Task |
72
+ |---|---|---|
73
+ | Daily 08:00 | monitor | Morning system check |
74
+ | Daily 09:00 | researcher | AI research digest |
75
+ | Mon 09:30 | planner | Sprint planning |
76
+ | Wed 10:00 | coder | Code quality check |
77
+ | Daily 18:00 | monitor | Evening task review |
78
+ | Fri 16:00 | planner | Weekly retrospective |
79
+
80
+ ## MCP Config
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "pulse": {
86
+ "command": "npx",
87
+ "args": ["-y", "mcp-remote", "https://chris4k-agent-pulse.hf.space/mcp/sse"]
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## Agent Tools Available in ReAct
94
+
95
+ ```
96
+ relay_send β€” Send message to agent or broadcast
97
+ relay_inbox β€” Read unread messages
98
+ memory_search β€” Search memory by query
99
+ memory_store β€” Store new memory
100
+ kanban_list β€” List tasks by status/agent
101
+ kanban_move β€” Move task to new status
102
+ kanban_create β€” Create new task
103
+ vault_exec β€” Execute bash/python/node/npm/git
104
+ vault_read β€” Read file
105
+ vault_write β€” Write file
106
+ forge_search β€” Find skills in FORGE
107
+ delegate β€” Delegate task to another agent
108
+ finish β€” Complete ReAct loop
109
+ ```
110
+
111
+ *Chris4K Β· ki-fusion-labs.de*
main.py ADDED
@@ -0,0 +1,1594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PULSE β€” Agent Nervous System & Scheduler
3
+ The heartbeat, ReAct loop, timetable, and identity layer
4
+ connecting all 7 ki-fusion-labs agent spaces.
5
+
6
+ Connected spaces:
7
+ FORGE β€” skill registry
8
+ RELAY β€” communication hub
9
+ MEMORY β€” multi-tier memory
10
+ KANBAN β€” task board
11
+ NEXUS β€” LLM routing (OpenAI-compatible)
12
+ VAULT β€” file workspace + execution
13
+ KNOWLEDGE β€” knowledge base (if available)
14
+
15
+ Agent lifecycle per heartbeat tick:
16
+ 1. Check RELAY inbox for messages
17
+ 2. Check KANBAN for assigned open tasks
18
+ 3. Check timetable for due jobs
19
+ 4. If anything found β†’ ReAct loop via NEXUS
20
+ 5. ReAct: Thought β†’ Action(tool) β†’ Observation β†’ repeat
21
+ 6. Write results to MEMORY / KANBAN / RELAY / VAULT
22
+ 7. Sleep β†’ next tick
23
+ """
24
+
25
+ import os, uuid, json, asyncio, time, re, logging
26
+ from pathlib import Path
27
+ from datetime import datetime, timezone, timedelta
28
+ from typing import Optional, Any
29
+ from collections import defaultdict
30
+
31
+ import httpx
32
+ from fastapi import FastAPI, HTTPException, Request
33
+ from fastapi.responses import JSONResponse, HTMLResponse, StreamingResponse
34
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
35
+ from apscheduler.triggers.cron import CronTrigger
36
+ from apscheduler.triggers.date import DateTrigger
37
+
38
+ logging.basicConfig(level=logging.INFO)
39
+ log = logging.getLogger("pulse")
40
+
41
+ BASE = Path(__file__).parent
42
+ for d in ["data", "logs", "traces"]:
43
+ (BASE / d).mkdir(exist_ok=True)
44
+
45
+ # ── Space URLs ─────────────────────────────────────────────────────
46
+ SPACES = {
47
+ "relay": os.environ.get("RELAY_URL", "https://chris4k-agent-relay.hf.space"),
48
+ "memory": os.environ.get("MEMORY_URL", "https://chris4k-agent-memory.hf.space"),
49
+ "kanban": os.environ.get("KANBAN_URL", "https://chris4k-agent-kanban-board.hf.space"),
50
+ "nexus": os.environ.get("NEXUS_URL", "https://chris4k-agent-nexus.hf.space"),
51
+ "vault": os.environ.get("VAULT_URL", "https://chris4k-agent-vault.hf.space"),
52
+ "forge": os.environ.get("FORGE_URL", "https://chris4k-agent-forge.hf.space"),
53
+ "knowledge": os.environ.get("KNOWLEDGE_URL", "https://chris4k-agent-knowledge.hf.space"),
54
+ }
55
+ NEXUS_MODEL = os.environ.get("NEXUS_MODEL", "nexus-auto")
56
+ REACT_MAX = int(os.environ.get("REACT_MAX_STEPS", "6"))
57
+
58
+ # ── Persistence ────────────────────────────────────────────────────
59
+ AGENTS_FILE = BASE / "data" / "agents.json"
60
+ SCHEDULE_FILE = BASE / "data" / "schedule.json"
61
+ ACTIVITY_FILE = BASE / "data" / "activity.json"
62
+
63
+ def load_json(p: Path, default):
64
+ return json.loads(p.read_text()) if p.exists() else default
65
+
66
+ def save_json(p: Path, data):
67
+ p.write_text(json.dumps(data, indent=2, ensure_ascii=False))
68
+
69
+ # ── Live feed ──────────────────────────────────────────────────────
70
+ live_queues: list[asyncio.Queue] = []
71
+ agent_status: dict[str, dict] = {} # name β†’ {running, last_tick, last_action, tick_count}
72
+
73
+ def push_live(event: dict):
74
+ event["ts"] = int(time.time())
75
+ for q in live_queues:
76
+ try: q.put_nowait(json.dumps(event))
77
+ except: pass
78
+ # Append to activity log (last 200)
79
+ act = load_json(ACTIVITY_FILE, [])
80
+ act.insert(0, event)
81
+ save_json(ACTIVITY_FILE, act[:200])
82
+
83
+ # ── Space clients ──────────────────────────────────────────────────
84
+ HTTP_TIMEOUT = 20
85
+
86
+ async def space_get(space: str, path: str, params: dict = {}) -> Optional[Any]:
87
+ url = SPACES[space] + path
88
+ try:
89
+ async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as c:
90
+ r = await c.get(url, params=params)
91
+ r.raise_for_status()
92
+ return r.json()
93
+ except Exception as e:
94
+ log.warning(f"space_get {space}{path}: {e}")
95
+ return None
96
+
97
+ async def space_post(space: str, path: str, data: dict) -> Optional[Any]:
98
+ url = SPACES[space] + path
99
+ try:
100
+ async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as c:
101
+ r = await c.post(url, json=data)
102
+ r.raise_for_status()
103
+ return r.json()
104
+ except Exception as e:
105
+ log.warning(f"space_post {space}{path}: {e}")
106
+ return None
107
+
108
+ # ── ReAct tools ────────────────────────────────────────────────────
109
+ TOOL_SPECS = [
110
+ {"name":"relay_send", "desc":"Send message to an agent or broadcast. Args: to, subject, body, priority(low/normal/high/urgent), channel(internal/telegram/browser)"},
111
+ {"name":"relay_inbox", "desc":"Read unread messages for an agent. Args: agent"},
112
+ {"name":"memory_search", "desc":"Search agent memory. Args: query, tier(all/episodic/semantic/procedural/working)"},
113
+ {"name":"memory_store", "desc":"Store a memory. Args: content, tier, tags(list), importance(0-10)"},
114
+ {"name":"kanban_list", "desc":"List tasks. Args: status(todo/doing/done/blocked/failed), agent(optional)"},
115
+ {"name":"kanban_move", "desc":"Move a task to new status. Args: id, status"},
116
+ {"name":"kanban_create", "desc":"Create a task. Args: title, body, priority(low/medium/high/critical), agent"},
117
+ {"name":"vault_exec", "desc":"Execute code. Args: runtime(bash/python3/node/npm/pip/git), code, cwd(optional)"},
118
+ {"name":"vault_read", "desc":"Read a file. Args: path"},
119
+ {"name":"vault_write", "desc":"Write a file. Args: path, content"},
120
+ {"name":"forge_search", "desc":"Search for skills/tools in FORGE. Args: query"},
121
+ {"name":"delegate", "desc":"Delegate a task to another agent. Args: to_agent, task, priority"},
122
+ {"name":"finish", "desc":"Complete the ReAct loop with a result. Args: result"},
123
+ ]
124
+
125
+ TOOL_NAMES = {t["name"] for t in TOOL_SPECS}
126
+
127
+ async def exec_tool(agent_name: str, tool: str, args: dict) -> str:
128
+ """Execute a ReAct tool and return observation string."""
129
+ try:
130
+ if tool == "relay_send":
131
+ r = await space_post("relay", "/api/messages", {
132
+ "from": agent_name, "to": args.get("to","broadcast"),
133
+ "subject": args.get("subject",""), "body": args.get("body",""),
134
+ "priority": args.get("priority","normal"),
135
+ "channel": args.get("channel","internal")})
136
+ return f"Message sent id={r.get('id','')} status={r.get('dispatch_status','?')}" if r else "relay_send failed"
137
+
138
+ if tool == "relay_inbox":
139
+ r = await space_get("relay", f"/api/inbox/{args.get('agent',agent_name)}", {"unread":"true"})
140
+ if not r: return "inbox empty"
141
+ msgs = r[:5] if isinstance(r, list) else []
142
+ return json.dumps([{"from":m.get("from"),"subject":m.get("subject"),"body":m.get("body","")[:200]} for m in msgs])
143
+
144
+ if tool == "memory_search":
145
+ r = await space_get("memory", "/api/memories/search",
146
+ {"q": args.get("query",""), "tier": args.get("tier","all"), "limit":8})
147
+ if not r: return "no results"
148
+ results = r if isinstance(r, list) else r.get("results",[])
149
+ return json.dumps([{"content":m.get("content","")[:200],"tier":m.get("tier"),"tags":m.get("tags")} for m in results[:5]])
150
+
151
+ if tool == "memory_store":
152
+ r = await space_post("memory", "/api/memories", {
153
+ "content": args.get("content",""), "tier": args.get("tier","episodic"),
154
+ "tags": args.get("tags",[]), "importance": args.get("importance",6),
155
+ "agent": agent_name})
156
+ return f"stored id={r.get('id','?')}" if r else "memory_store failed"
157
+
158
+ if tool == "kanban_list":
159
+ params = {}
160
+ if args.get("status"): params["status"] = args["status"]
161
+ if args.get("agent"): params["agent"] = args["agent"]
162
+ r = await space_get("kanban", "/api/tasks", params)
163
+ tasks = r if isinstance(r, list) else []
164
+ return json.dumps([{"id":t.get("id"),"title":t.get("title"),"status":t.get("status"),"priority":t.get("priority")} for t in tasks[:8]])
165
+
166
+ if tool == "kanban_move":
167
+ r = await space_post("kanban", "/api/move", {"id":args.get("id"),"status":args.get("status")})
168
+ return f"moved {args.get('id')} to {args.get('status')}" if r else "kanban_move failed"
169
+
170
+ if tool == "kanban_create":
171
+ r = await space_post("kanban", "/api/tasks", {
172
+ "title": args.get("title",""), "body": args.get("body",""),
173
+ "priority": args.get("priority","medium"), "agent": args.get("agent",agent_name),
174
+ "type": "ai"})
175
+ return f"created task id={r.get('id','?')}" if r else "kanban_create failed"
176
+
177
+ if tool == "vault_exec":
178
+ r = await space_post("vault", "/api/exec", {
179
+ "runtime": args.get("runtime","python3"),
180
+ "code": args.get("code",""), "cwd": args.get("cwd","scratch"),
181
+ "timeout": 30})
182
+ if not r: return "vault_exec failed"
183
+ return f"exit={r.get('exit_code')} ms={r.get('ms')}\n{r.get('output','')[:500]}"
184
+
185
+ if tool == "vault_read":
186
+ r = await space_get("vault", "/api/read", {"path": args.get("path","")})
187
+ return (r.get("content","")[:800] if r else "vault_read failed")
188
+
189
+ if tool == "vault_write":
190
+ r = await space_post("vault", "/api/write", {
191
+ "path": args.get("path",""), "content": args.get("content",""),
192
+ "agent": agent_name})
193
+ return f"written: {args.get('path')} snap={r.get('snapshot',{}).get('id','?')}" if r else "vault_write failed"
194
+
195
+ if tool == "forge_search":
196
+ r = await space_get("forge", "/api/v1/skills", {"q": args.get("query",""), "limit":5})
197
+ items = r if isinstance(r, list) else (r.get("skills",[]) if r else [])
198
+ return json.dumps([{"name":s.get("name"),"description":s.get("description","")[:100]} for s in items[:5]])
199
+
200
+ if tool == "delegate":
201
+ r = await space_post("relay", "/api/messages", {
202
+ "from": agent_name, "to": args.get("to_agent","broadcast"),
203
+ "subject": f"[DELEGATION] {args.get('task','')[:60]}",
204
+ "body": args.get("task",""), "priority": args.get("priority","normal"),
205
+ "channel": "internal", "tags": ["delegation","task"]})
206
+ return f"delegated to {args.get('to_agent')} via relay" if r else "delegation failed"
207
+
208
+ return f"unknown tool: {tool}"
209
+ except Exception as e:
210
+ return f"tool error: {e}"
211
+
212
+ # ── ReAct loop ─────────────────────────────────────────────────────
213
+ SYSTEM_TEMPLATE = """\
214
+ You are {name}. {persona}
215
+
216
+ Your connected tools:
217
+ {tools}
218
+
219
+ Respond ONLY as valid JSON, one object per step:
220
+ {{"thought":"<reasoning>","action":"<tool_name>","args":{{<args>}}}}
221
+ OR to complete:
222
+ {{"thought":"<reasoning>","action":"finish","args":{{"result":"<summary>"}}}}
223
+
224
+ Rules:
225
+ - Always use a tool or finish. Never respond in plain text.
226
+ - Check relay_inbox and kanban_list before doing other actions.
227
+ - Store key findings in memory.
228
+ - Delegate sub-tasks if appropriate.
229
+ - Finish within {max_steps} steps.
230
+ """
231
+
232
+ async def react_loop(agent: dict, trigger_type: str, trigger_content: str) -> dict:
233
+ name = agent["name"]
234
+ persona = agent.get("persona", "A helpful AI agent.")
235
+ cost_mode = agent.get("cost_mode", "balanced")
236
+ max_steps = agent.get("max_react_steps", REACT_MAX)
237
+
238
+ tool_list = "\n".join(f" {t['name']}: {t['desc']}" for t in TOOL_SPECS)
239
+ system_msg = SYSTEM_TEMPLATE.format(
240
+ name=name, persona=persona, tools=tool_list, max_steps=max_steps)
241
+
242
+ user_msg = (f"TRIGGER: {trigger_type}\n"
243
+ f"CONTEXT: {trigger_content}\n"
244
+ f"Current time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}\n"
245
+ "Execute your task using available tools. Begin.")
246
+
247
+ messages = [{"role":"system","content":system_msg},
248
+ {"role":"user","content":user_msg}]
249
+
250
+ trace = {"agent":name,"trigger":trigger_type,"started":int(time.time()),
251
+ "steps":[],"result":"","ok":True}
252
+
253
+ push_live({"type":"react_start","agent":name,"trigger":trigger_type})
254
+
255
+ for step_n in range(max_steps):
256
+ # Call NEXUS
257
+ try:
258
+ payload = {"model": NEXUS_MODEL, "messages": messages,
259
+ "max_tokens": 600, "temperature": 0.3, "cost_mode": cost_mode}
260
+ async with httpx.AsyncClient(timeout=30) as c:
261
+ r = await c.post(f"{SPACES['nexus']}/v1/chat/completions", json=payload)
262
+ r.raise_for_status()
263
+ rj = r.json()
264
+ except Exception as e:
265
+ trace["result"] = f"nexus error: {e}"; trace["ok"] = False; break
266
+
267
+ raw = rj.get("choices",[{}])[0].get("message",{}).get("content","").strip()
268
+
269
+ # Parse JSON
270
+ try:
271
+ # Strip markdown fences
272
+ clean = re.sub(r"```(?:json)?|```","", raw).strip()
273
+ # Take first JSON object
274
+ m = re.search(r'\{.*\}', clean, re.DOTALL)
275
+ step_json = json.loads(m.group()) if m else {}
276
+ except Exception:
277
+ step_json = {"thought": raw, "action": "finish", "args": {"result": raw}}
278
+
279
+ thought = step_json.get("thought","")
280
+ action = step_json.get("action","finish")
281
+ args = step_json.get("args",{})
282
+
283
+ step_record = {"n":step_n+1,"thought":thought,"action":action,"args":args,"observation":""}
284
+ push_live({"type":"react_step","agent":name,"step":step_n+1,
285
+ "thought":thought[:120],"action":action})
286
+
287
+ if action == "finish":
288
+ trace["result"] = args.get("result","done")
289
+ step_record["observation"] = "[FINISHED]"
290
+ trace["steps"].append(step_record)
291
+ break
292
+
293
+ if action not in TOOL_NAMES:
294
+ observation = f"unknown tool: {action}. Available: {sorted(TOOL_NAMES)}"
295
+ else:
296
+ observation = await exec_tool(name, action, args)
297
+
298
+ step_record["observation"] = str(observation)[:400]
299
+ trace["steps"].append(step_record)
300
+ push_live({"type":"react_obs","agent":name,"step":step_n+1,
301
+ "observation":step_record["observation"][:100]})
302
+
303
+ messages.append({"role":"assistant","content":raw})
304
+ messages.append({"role":"user","content":f"Observation: {observation}"})
305
+
306
+ # Store trace step in memory
307
+ asyncio.create_task(space_post("memory", "/api/memories", {
308
+ "content": f"[{name}] Step {step_n+1}: {action}({json.dumps(args)[:100]}) οΏ½οΏ½οΏ½ {observation[:150]}",
309
+ "tier": "episodic", "tags": [name,"react","trace"], "importance": 3, "agent": name}))
310
+
311
+ else:
312
+ trace["result"] = f"max steps ({max_steps}) reached"
313
+
314
+ trace["finished"] = int(time.time())
315
+ trace["ms"] = (trace["finished"] - trace["started"]) * 1000
316
+
317
+ # Save full trace
318
+ tid = uuid.uuid4().hex[:8]
319
+ (BASE / "traces" / f"{tid}.json").write_text(json.dumps(trace, indent=2))
320
+
321
+ push_live({"type":"react_done","agent":name,"result":trace["result"][:120],
322
+ "ok":trace["ok"],"ms":trace["ms"],"steps":len(trace["steps"])})
323
+
324
+ return trace
325
+
326
+ # ── Heartbeat engine ───────────────────────────────────────────────
327
+ scheduler = AsyncIOScheduler(timezone="UTC")
328
+
329
+ async def agent_tick(agent_name: str, trigger_type: str = "heartbeat", content: str = ""):
330
+ agents = load_json(AGENTS_FILE, [])
331
+ agent = next((a for a in agents if a["name"] == agent_name), None)
332
+ if not agent or not agent.get("enabled", True):
333
+ return
334
+
335
+ if agent_status.get(agent_name, {}).get("running"):
336
+ log.info(f"Agent {agent_name} already running, skip tick")
337
+ return
338
+
339
+ agent_status[agent_name] = {**agent_status.get(agent_name,{}),
340
+ "running": True, "last_tick": int(time.time())}
341
+ push_live({"type":"heartbeat","agent":agent_name,"trigger":trigger_type})
342
+
343
+ try:
344
+ # Build context from multiple sources
345
+ context_parts = []
346
+ if content: context_parts.append(content)
347
+
348
+ # Check relay inbox
349
+ inbox = await space_get("relay", f"/api/inbox/{agent_name}", {"unread":"true","limit":"3"})
350
+ if isinstance(inbox, list) and inbox:
351
+ msgs = [f"[{m.get('from')}]: {m.get('subject','')} β€” {m.get('body','')[:100]}" for m in inbox[:3]]
352
+ context_parts.append("UNREAD MESSAGES:\n" + "\n".join(msgs))
353
+
354
+ # Check kanban for assigned tasks
355
+ kanban = await space_get("kanban", "/api/tasks", {"agent":agent_name,"status":"todo"})
356
+ if isinstance(kanban, list) and kanban:
357
+ tasks = [f"[{t.get('priority','?')}] {t.get('title','')} (id:{t.get('id','')})" for t in kanban[:3]]
358
+ context_parts.append("OPEN TASKS:\n" + "\n".join(tasks))
359
+
360
+ if not context_parts and trigger_type == "heartbeat":
361
+ # Nothing to do
362
+ agent_status[agent_name]["running"] = False
363
+ agent_status[agent_name]["last_result"] = "idle"
364
+ push_live({"type":"idle","agent":agent_name})
365
+ return
366
+
367
+ full_context = "\n\n".join(context_parts) if context_parts else "Routine heartbeat check."
368
+ trace = await react_loop(agent, trigger_type, full_context)
369
+
370
+ tc = agent_status.get(agent_name, {}).get("tick_count", 0) + 1
371
+ agent_status[agent_name] = {**agent_status.get(agent_name,{}),
372
+ "running": False, "tick_count": tc,
373
+ "last_result": trace["result"][:100],
374
+ "last_ok": trace["ok"], "last_ms": trace.get("ms",0)}
375
+ except Exception as e:
376
+ log.error(f"agent_tick {agent_name}: {e}")
377
+ agent_status[agent_name] = {**agent_status.get(agent_name,{}),
378
+ "running": False, "last_result": f"error: {e}", "last_ok": False}
379
+ push_live({"type":"error","agent":agent_name,"message":str(e)})
380
+
381
+ def register_agent_jobs(agent: dict):
382
+ """Register APScheduler jobs for an agent."""
383
+ name = agent["name"]
384
+ # Remove existing jobs for this agent
385
+ for job in scheduler.get_jobs():
386
+ if job.id.startswith(f"hb_{name}"):
387
+ job.remove()
388
+
389
+ if not agent.get("enabled", True):
390
+ return
391
+
392
+ # Heartbeat
393
+ interval = agent.get("heartbeat_seconds", 0)
394
+ if interval and interval > 0:
395
+ from apscheduler.triggers.interval import IntervalTrigger
396
+ scheduler.add_job(
397
+ agent_tick, IntervalTrigger(seconds=max(interval, 30)),
398
+ args=[name, "heartbeat", ""],
399
+ id=f"hb_{name}_interval", replace_existing=True,
400
+ max_instances=1, misfire_grace_time=60)
401
+ log.info(f"Registered heartbeat for {name} every {interval}s")
402
+
403
+ def register_schedule_job(entry: dict):
404
+ """Register a timetable entry as APScheduler job."""
405
+ eid = entry["id"]
406
+ agent = entry.get("agent","")
407
+ if not entry.get("enabled", True): return
408
+
409
+ trigger_content = entry.get("prompt","Scheduled task: " + entry.get("title",""))
410
+
411
+ job_id = f"sch_{eid}"
412
+ for job in scheduler.get_jobs():
413
+ if job.id == job_id: job.remove()
414
+
415
+ if entry.get("recurrence") == "once":
416
+ dt_str = entry.get("datetime","")
417
+ if dt_str:
418
+ try:
419
+ dt = datetime.fromisoformat(dt_str)
420
+ scheduler.add_job(agent_tick, DateTrigger(run_date=dt),
421
+ args=[agent, "scheduled", trigger_content],
422
+ id=job_id, replace_existing=True, max_instances=1)
423
+ except: pass
424
+ else:
425
+ # weekly / daily cron
426
+ day = entry.get("day", 0) # 0=Mon
427
+ hour = entry.get("hour", 9)
428
+ minute = entry.get("minute", 0)
429
+ day_map = {0:"mon",1:"tue",2:"wed",3:"thu",4:"fri",5:"sat",6:"sun"}
430
+ if entry.get("recurrence") == "daily":
431
+ cron = CronTrigger(hour=hour, minute=minute)
432
+ else:
433
+ cron = CronTrigger(day_of_week=day_map.get(day,"mon"), hour=hour, minute=minute)
434
+ scheduler.add_job(agent_tick, cron,
435
+ args=[agent, "scheduled", trigger_content],
436
+ id=job_id, replace_existing=True, max_instances=1)
437
+
438
+ def reload_all_jobs():
439
+ agents = load_json(AGENTS_FILE, [])
440
+ schedule = load_json(SCHEDULE_FILE, [])
441
+ for a in agents: register_agent_jobs(a)
442
+ for e in schedule: register_schedule_job(e)
443
+
444
+ # ── Default data ───────────────────────────────────────────────────
445
+ DEFAULT_AGENTS = [
446
+ {"name":"researcher","persona":"You are a research specialist. You search for information, analyze data, store findings in memory, and report summaries. You prefer to read from knowledge and memory before searching externally. You are thorough and cite your sources.","enabled":True,"heartbeat_seconds":0,"cost_mode":"balanced","max_react_steps":5,"color":"#0ea5e9","tags":["research","analysis"]},
447
+ {"name":"coder","persona":"You are a senior software engineer. You write clean, efficient code in Python, JavaScript, and bash. You use vault_exec to run and test code. You create tasks in kanban for complex work. You document your code thoroughly.","enabled":True,"heartbeat_seconds":0,"cost_mode":"best","max_react_steps":6,"color":"#2ed573","tags":["coding","execution"]},
448
+ {"name":"planner","persona":"You are a strategic planner and project manager. You break down complex goals into tasks, create kanban tickets, delegate to specialized agents via relay, and track progress. You always think in milestones and dependencies.","enabled":True,"heartbeat_seconds":0,"cost_mode":"balanced","max_react_steps":5,"color":"#ff9500","tags":["planning","coordination"]},
449
+ {"name":"monitor","persona":"You are a system monitor and watchdog. You check kanban for failed tasks, check relay for urgent messages, scan vault for errors, and alert the team. You run diagnostic scripts and report health status.","enabled":True,"heartbeat_seconds":300,"cost_mode":"cheap","max_react_steps":4,"color":"#ff6b9d","tags":["monitoring","alerts"]},
450
+ {"name":"christof","persona":"You are Christof's personal AI assistant. You manage his project backlog, summarize daily progress, and coordinate between specialist agents. You understand his work at bofrost*, ki-fusion-labs.de, and AI research projects. You write concisely and flag blockers immediately.","enabled":True,"heartbeat_seconds":0,"cost_mode":"best","max_react_steps":6,"color":"#ff6b00","tags":["personal","coordinator"]},
451
+ ]
452
+
453
+ DEFAULT_SCHEDULE = [
454
+ {"id":"s1","agent":"monitor","title":"Morning system check","recurrence":"daily","hour":8,"minute":0,"day":0,"prompt":"Run a full system health check: check relay for urgent messages, scan kanban for blocked/failed tasks, report to christof.","enabled":True,"color":"#ff6b9d"},
455
+ {"id":"s2","agent":"researcher","title":"Daily AI research digest","recurrence":"daily","hour":9,"minute":0,"day":0,"prompt":"Search memory and knowledge for recent AI topics. Find the top 3 relevant developments for ki-fusion-labs projects. Store summary in memory (semantic tier). Send digest to christof via relay.","enabled":True,"color":"#0ea5e9"},
456
+ {"id":"s3","agent":"planner","title":"Sprint planning","recurrence":"weekly","hour":9,"minute":30,"day":0,"prompt":"Review all todo tasks in kanban. Prioritize by urgency and dependencies. Create a sprint plan for the week. Send summary to christof via relay.","enabled":True,"color":"#ff9500"},
457
+ {"id":"s4","agent":"coder","title":"Code quality check","recurrence":"weekly","hour":10,"minute":0,"day":2,"prompt":"List files in vault/code. Run basic linting on Python files. Report any issues to kanban as tasks. Store quality report in vault/reports.","enabled":True,"color":"#2ed573"},
458
+ {"id":"s5","agent":"monitor","title":"Evening task review","recurrence":"daily","hour":18,"minute":0,"day":0,"prompt":"Review today's kanban activity: tasks completed, blocked, or failed. Send end-of-day summary to christof. Archive completed tasks.","enabled":True,"color":"#ff6b9d"},
459
+ {"id":"s6","agent":"planner","title":"Weekly retrospective","recurrence":"weekly","hour":16,"minute":0,"day":4,"prompt":"Review the week: what was completed, what is blocked, what needs attention next week. Generate a markdown retrospective report and store in vault/reports. Send highlights to christof.","enabled":True,"color":"#ff9500"},
460
+ ]
461
+
462
+ def seed():
463
+ if not AGENTS_FILE.exists(): save_json(AGENTS_FILE, DEFAULT_AGENTS)
464
+ if not SCHEDULE_FILE.exists(): save_json(SCHEDULE_FILE, DEFAULT_SCHEDULE)
465
+ if not ACTIVITY_FILE.exists(): save_json(ACTIVITY_FILE, [])
466
+
467
+ seed()
468
+
469
+ # ── FastAPI ───────────────────────────────────────────────────────
470
+ app = FastAPI(title="PULSE β€” Agent Nervous System")
471
+
472
+ @app.on_event("startup")
473
+ async def startup():
474
+ scheduler.start()
475
+ reload_all_jobs()
476
+ log.info("PULSE scheduler started")
477
+
478
+ @app.on_event("shutdown")
479
+ async def shutdown():
480
+ scheduler.shutdown(wait=False)
481
+
482
+ def jresp(d, s=200): return JSONResponse(content=d, status_code=s)
483
+
484
+ # ── Agent API ─────────────────────────────────────────────────────
485
+ @app.get("/api/agents")
486
+ async def list_agents():
487
+ agents = load_json(AGENTS_FILE, [])
488
+ return jresp([{**a, "status": agent_status.get(a["name"],{"running":False,"tick_count":0})} for a in agents])
489
+
490
+ @app.post("/api/agents")
491
+ async def upsert_agent(request: Request):
492
+ data = await request.json()
493
+ name = data.get("name","").strip().lower()
494
+ if not name: raise HTTPException(400, "name required")
495
+ agents = load_json(AGENTS_FILE, [])
496
+ existing = next((i for i,a in enumerate(agents) if a["name"]==name), None)
497
+ agent = {**(agents[existing] if existing is not None else {}), **data, "name": name}
498
+ if existing is not None: agents[existing] = agent
499
+ else: agents.append(agent)
500
+ save_json(AGENTS_FILE, agents)
501
+ register_agent_jobs(agent)
502
+ return jresp({"status":"saved","agent":agent})
503
+
504
+ @app.delete("/api/agents/{name}")
505
+ async def delete_agent(name: str):
506
+ agents = load_json(AGENTS_FILE, [])
507
+ agents = [a for a in agents if a["name"] != name]
508
+ save_json(AGENTS_FILE, agents)
509
+ for job in scheduler.get_jobs():
510
+ if job.id.startswith(f"hb_{name}"): job.remove()
511
+ return jresp({"status":"deleted"})
512
+
513
+ @app.post("/api/agents/{name}/run")
514
+ async def trigger_agent(name: str, request: Request):
515
+ data = await request.json()
516
+ trigger = data.get("trigger","manual")
517
+ content = data.get("content","Manual trigger from PULSE UI")
518
+ asyncio.create_task(agent_tick(name, trigger, content))
519
+ return jresp({"status":"triggered","agent":name})
520
+
521
+ @app.get("/api/agents/{name}/traces")
522
+ async def agent_traces(name: str, limit: int = 10):
523
+ traces = []
524
+ for p in sorted((BASE/"traces").glob("*.json"), reverse=True)[:50]:
525
+ try:
526
+ t = json.loads(p.read_text())
527
+ if t.get("agent") == name: traces.append(t)
528
+ if len(traces) >= limit: break
529
+ except: pass
530
+ return jresp(traces)
531
+
532
+ @app.get("/api/agents/status/all")
533
+ async def all_status():
534
+ return jresp(agent_status)
535
+
536
+ # ── Schedule API ──────────────────────────────────────────────────
537
+ @app.get("/api/schedule")
538
+ async def list_schedule():
539
+ return jresp(load_json(SCHEDULE_FILE, []))
540
+
541
+ @app.post("/api/schedule")
542
+ async def upsert_schedule(request: Request):
543
+ data = await request.json()
544
+ if not data.get("id"): data["id"] = uuid.uuid4().hex[:8]
545
+ entries = load_json(SCHEDULE_FILE, [])
546
+ existing = next((i for i,e in enumerate(entries) if e["id"]==data["id"]), None)
547
+ if existing is not None: entries[existing] = data
548
+ else: entries.append(data)
549
+ save_json(SCHEDULE_FILE, entries)
550
+ register_schedule_job(data)
551
+ return jresp({"status":"saved","entry":data})
552
+
553
+ @app.delete("/api/schedule/{eid}")
554
+ async def delete_schedule(eid: str):
555
+ entries = load_json(SCHEDULE_FILE, [])
556
+ entries = [e for e in entries if e["id"] != eid]
557
+ save_json(SCHEDULE_FILE, entries)
558
+ for job in scheduler.get_jobs():
559
+ if job.id == f"sch_{eid}": job.remove()
560
+ return jresp({"status":"deleted"})
561
+
562
+ @app.post("/api/schedule/{eid}/run")
563
+ async def run_now(eid: str):
564
+ entries = load_json(SCHEDULE_FILE, [])
565
+ entry = next((e for e in entries if e["id"]==eid), None)
566
+ if not entry: raise HTTPException(404)
567
+ asyncio.create_task(agent_tick(entry.get("agent",""), "manual_schedule",
568
+ entry.get("prompt","scheduled")))
569
+ return jresp({"status":"triggered"})
570
+
571
+ # ── Activity + SSE ────────────────────────────────────────────────
572
+ @app.get("/api/activity")
573
+ async def activity(limit: int = 50):
574
+ return jresp(load_json(ACTIVITY_FILE, [])[:limit])
575
+
576
+ @app.get("/api/live")
577
+ async def live_feed():
578
+ q = asyncio.Queue()
579
+ live_queues.append(q)
580
+ async def stream():
581
+ try:
582
+ # Send recent activity on connect
583
+ recent = load_json(ACTIVITY_FILE, [])[:5]
584
+ for ev in reversed(recent):
585
+ yield f"data: {json.dumps(ev)}\n\n"
586
+ yield f"data: {json.dumps({'type':'connected','spaces':list(SPACES.keys())})}\n\n"
587
+ while True:
588
+ try:
589
+ payload = await asyncio.wait_for(q.get(), timeout=25)
590
+ yield f"data: {payload}\n\n"
591
+ except asyncio.TimeoutError:
592
+ yield f"data: {json.dumps({'type':'ping','ts':int(time.time())})}\n\n"
593
+ finally:
594
+ live_queues.remove(q)
595
+ return StreamingResponse(stream(), media_type="text/event-stream",
596
+ headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})
597
+
598
+ @app.get("/api/spaces/health")
599
+ async def spaces_health():
600
+ results = {}
601
+ async def check(name, url):
602
+ try:
603
+ async with httpx.AsyncClient(timeout=5) as c:
604
+ r = await c.get(url + "/api/stats")
605
+ results[name] = {"ok": r.status_code < 400, "status": r.status_code, "url": url}
606
+ except Exception as e:
607
+ results[name] = {"ok": False, "error": str(e)[:60], "url": url}
608
+ await asyncio.gather(*[check(n,u) for n,u in SPACES.items()])
609
+ return jresp(results)
610
+
611
+ @app.get("/api/traces")
612
+ async def all_traces(limit: int = 20):
613
+ traces = []
614
+ for p in sorted((BASE/"traces").glob("*.json"), reverse=True)[:limit]:
615
+ try: traces.append(json.loads(p.read_text()))
616
+ except: pass
617
+ return jresp(traces)
618
+
619
+ # ── MCP ──────────────────────────────────────────────────────────
620
+ MCP_TOOLS = [
621
+ {"name":"pulse_trigger","description":"Trigger an agent to run its ReAct loop",
622
+ "inputSchema":{"type":"object","required":["agent"],"properties":{
623
+ "agent":{"type":"string"},"content":{"type":"string"},"trigger":{"type":"string"}}}},
624
+ {"name":"pulse_schedule","description":"Add or update a schedule entry",
625
+ "inputSchema":{"type":"object","required":["agent","title"],"properties":{
626
+ "agent":{"type":"string"},"title":{"type":"string"},
627
+ "recurrence":{"type":"string","enum":["daily","weekly","once"]},
628
+ "hour":{"type":"integer"},"minute":{"type":"integer"},"day":{"type":"integer"},
629
+ "prompt":{"type":"string"},"enabled":{"type":"boolean"}}}},
630
+ {"name":"pulse_status","description":"Get status of all agents",
631
+ "inputSchema":{"type":"object","properties":{}}},
632
+ ]
633
+
634
+ async def mcp_call(name, args):
635
+ if name == "pulse_trigger":
636
+ asyncio.create_task(agent_tick(args["agent"], args.get("trigger","mcp"), args.get("content","")))
637
+ return json.dumps({"triggered": args["agent"]})
638
+ if name == "pulse_schedule":
639
+ if not args.get("id"): args["id"] = uuid.uuid4().hex[:8]
640
+ entries = load_json(SCHEDULE_FILE, [])
641
+ entries.append(args); save_json(SCHEDULE_FILE, entries)
642
+ register_schedule_job(args)
643
+ return json.dumps({"scheduled": args["id"]})
644
+ if name == "pulse_status":
645
+ return json.dumps(agent_status)
646
+ return json.dumps({"error": f"unknown: {name}"})
647
+
648
+ @app.get("/mcp/sse")
649
+ async def mcp_sse():
650
+ async def stream():
651
+ init = {"jsonrpc":"2.0","method":"notifications/initialized",
652
+ "params":{"serverInfo":{"name":"pulse","version":"1.0"},"capabilities":{"tools":{}}}}
653
+ yield f"data: {json.dumps(init)}\n\n"
654
+ await asyncio.sleep(0.1)
655
+ yield f"data: {json.dumps({'jsonrpc':'2.0','method':'notifications/tools/list_changed','params':{}})}\n\n"
656
+ while True:
657
+ await asyncio.sleep(25)
658
+ yield f"data: {json.dumps({'jsonrpc':'2.0','method':'ping'})}\n\n"
659
+ return StreamingResponse(stream(), media_type="text/event-stream",
660
+ headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})
661
+
662
+ @app.post("/mcp")
663
+ async def mcp_rpc(request: Request):
664
+ body = await request.json(); method = body.get("method",""); rid = body.get("id",1)
665
+ if method == "initialize":
666
+ return jresp({"jsonrpc":"2.0","id":rid,"result":{"serverInfo":{"name":"pulse","version":"1.0"},"capabilities":{"tools":{}}}})
667
+ if method == "tools/list":
668
+ return jresp({"jsonrpc":"2.0","id":rid,"result":{"tools":MCP_TOOLS}})
669
+ if method == "tools/call":
670
+ p = body.get("params",{}); res = await mcp_call(p.get("name",""), p.get("arguments",{}))
671
+ return jresp({"jsonrpc":"2.0","id":rid,"result":{"content":[{"type":"text","text":res}]}})
672
+ return jresp({"jsonrpc":"2.0","id":rid,"error":{"code":-32601,"message":"not found"}})
673
+
674
+ @app.get("/", response_class=HTMLResponse)
675
+ async def ui():
676
+ return HTMLResponse(content=SPA, media_type="text/html; charset=utf-8")
677
+
678
+ SPA = r"""<!DOCTYPE html>
679
+ <html lang="en">
680
+ <head>
681
+ <meta charset="UTF-8">
682
+ <meta name="viewport" content="width=device-width,initial-scale=1">
683
+ <title>PULSE &#x2014; Agent Nervous System</title>
684
+ <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
685
+ <style>
686
+ :root{
687
+ --bg:#05050e;--s1:#090916;--s2:#0d0d1f;--bd:#161630;--bd2:#1e1e3c;
688
+ --acc:#ff6b00;--acc2:#ff9240;--pulse:#f0c040;--lo:#39ff6a;--cr:#ff2255;
689
+ --info:#38bdf8;--violet:#8b5cf6;--pink:#ec4899;
690
+ --txt:#e0e0ff;--dim:#2a2a50;--sub:#484878;
691
+ --font:'Syne',sans-serif;--mono:'DM Mono',monospace;
692
+ --mon-w:150px;
693
+ }
694
+ *{box-sizing:border-box;margin:0;padding:0;}
695
+ html,body{height:100%;overflow:hidden;}
696
+ body{font-family:var(--font);background:var(--bg);color:var(--txt);display:flex;flex-direction:column;}
697
+
698
+ /* Scanline overlay */
699
+ body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:1000;
700
+ background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,20,.12) 2px,rgba(0,0,20,.12) 3px);}
701
+
702
+ /* HEADER */
703
+ #hdr{flex-shrink:0;display:flex;align-items:center;gap:1.2rem;padding:.65rem 1.5rem;
704
+ border-bottom:1px solid var(--bd);background:linear-gradient(180deg,#0a0a1a,var(--bg));z-index:10;
705
+ position:relative;}
706
+ #hdr::after{content:'';position:absolute;bottom:0;left:0;right:0;height:1px;
707
+ background:linear-gradient(90deg,transparent,var(--acc),transparent);}
708
+ #logo{display:flex;flex-direction:column;gap:1px;flex-shrink:0;}
709
+ #logo-main{font-size:1.3rem;font-weight:800;letter-spacing:4px;
710
+ background:linear-gradient(90deg,var(--acc),var(--pulse),var(--acc2));
711
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
712
+ #logo-sub{font-size:.45rem;color:var(--sub);letter-spacing:.3em;text-transform:uppercase;}
713
+ #pulse-dot{width:10px;height:10px;border-radius:50%;background:var(--pulse);flex-shrink:0;
714
+ box-shadow:0 0 12px var(--pulse);animation:pulsebeat 2s ease-in-out infinite;}
715
+ @keyframes pulsebeat{0%,100%{transform:scale(1);opacity:1;}50%{transform:scale(1.4);opacity:.6;}}
716
+ #hdr-center{flex:1;display:flex;gap:.4rem;flex-wrap:wrap;align-items:center;}
717
+ .hs{display:flex;align-items:center;gap:.3rem;background:var(--s1);border:1px solid var(--bd);
718
+ border-radius:5px;padding:.22rem .55rem;font-size:.52rem;color:var(--sub);}
719
+ .hs-n{font-size:.85rem;font-weight:700;color:var(--txt);line-height:1;}
720
+ #nav{display:flex;gap:.2rem;flex-shrink:0;}
721
+ .nav-btn{background:transparent;border:1px solid var(--bd);color:var(--sub);
722
+ padding:.32rem .75rem;font-family:var(--font);font-size:.6rem;font-weight:600;
723
+ border-radius:5px;cursor:pointer;transition:all .12s;letter-spacing:.08em;}
724
+ .nav-btn:hover{border-color:var(--bd2);color:var(--txt);}
725
+ .nav-btn.on{background:var(--acc);color:#000;border-color:var(--acc);}
726
+
727
+ /* LAYOUT */
728
+ #layout{flex:1;display:flex;min-height:0;overflow:hidden;}
729
+ #sidebar{width:220px;flex-shrink:0;border-right:1px solid var(--bd);
730
+ display:flex;flex-direction:column;overflow:hidden;background:var(--s1);}
731
+ #main-area{flex:1;overflow:hidden;display:flex;flex-direction:column;}
732
+
733
+ /* SIDEBAR */
734
+ .sb-section{padding:.5rem .7rem .3rem;font-size:.44rem;font-weight:700;letter-spacing:.18em;
735
+ color:var(--sub);text-transform:uppercase;border-top:1px solid var(--bd);margin-top:.3rem;}
736
+ .sb-section:first-child{border-top:none;margin-top:0;}
737
+ .agent-card{margin:.2rem .5rem;border-radius:7px;border:1px solid var(--bd);
738
+ background:var(--s2);overflow:hidden;cursor:pointer;transition:all .12s;}
739
+ .agent-card:hover{border-color:var(--bd2);}
740
+ .agent-card.active{border-color:var(--acc);}
741
+ .ac-header{display:flex;align-items:center;gap:.4rem;padding:.38rem .5rem;}
742
+ .ac-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;}
743
+ .ac-dot.running{animation:pulsebeat 1s ease-in-out infinite;}
744
+ .ac-name{font-size:.65rem;font-weight:700;flex:1;}
745
+ .ac-hb{font-size:.45rem;color:var(--sub);}
746
+ .ac-status{font-size:.45rem;padding:.15rem .38rem;border-radius:3px;}
747
+ .ac-status.idle{background:rgba(255,107,0,.08);color:var(--acc);}
748
+ .ac-status.running{background:rgba(57,255,106,.12);color:var(--lo);}
749
+ .ac-status.error{background:rgba(255,34,85,.1);color:var(--cr);}
750
+ .ac-result{font-size:.5rem;color:var(--sub);padding:0 .5rem .38rem;
751
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;}
752
+ .ac-actions{display:flex;gap:.2rem;padding:0 .5rem .38rem;}
753
+ .ac-btn{font-size:.48rem;background:var(--s1);border:1px solid var(--bd);border-radius:3px;
754
+ padding:1px 6px;cursor:pointer;color:var(--sub);font-family:var(--font);}
755
+ .ac-btn:hover{color:var(--lo);border-color:var(--lo);}
756
+ .sb-scroll{flex:1;overflow-y:auto;padding:.3rem 0;}
757
+ .sb-scroll::-webkit-scrollbar{width:2px;}
758
+ .sb-scroll::-webkit-scrollbar-thumb{background:var(--bd2);}
759
+
760
+ /* SPACES HEALTH */
761
+ .space-row{display:flex;align-items:center;gap:.4rem;padding:.22rem .65rem;font-size:.55rem;}
762
+ .sp-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;}
763
+ .sp-dot.ok{background:var(--lo);}
764
+ .sp-dot.bad{background:var(--cr);}
765
+ .sp-dot.unk{background:var(--sub);}
766
+ .sp-name{flex:1;color:var(--sub);}
767
+ .sp-status{font-size:.45rem;color:var(--dim);}
768
+
769
+ /* PANELS */
770
+ .panel{display:none;flex:1;overflow:hidden;flex-direction:column;}
771
+ .panel.on{display:flex;}
772
+
773
+ /* TIMETABLE */
774
+ #panel-timetable{padding:0;}
775
+ #cal-header{flex-shrink:0;display:flex;align-items:center;gap:.8rem;
776
+ padding:.6rem 1.2rem;border-bottom:1px solid var(--bd);background:var(--s1);}
777
+ #cal-title{font-size:.9rem;font-weight:700;letter-spacing:.08em;}
778
+ .cal-nav{background:var(--s2);border:1px solid var(--bd);color:var(--sub);
779
+ width:28px;height:28px;border-radius:5px;display:flex;align-items:center;justify-content:center;
780
+ cursor:pointer;font-size:.8rem;}
781
+ .cal-nav:hover{border-color:var(--acc);color:var(--acc);}
782
+ #btn-add-sched{background:var(--acc);color:#000;border:none;padding:.32rem .75rem;
783
+ font-family:var(--font);font-size:.6rem;font-weight:700;letter-spacing:.08em;
784
+ border-radius:5px;cursor:pointer;margin-left:auto;}
785
+ #btn-add-sched:hover{background:var(--acc2);}
786
+ #cal-body{flex:1;overflow:hidden;display:flex;flex-direction:column;}
787
+ #cal-days-hdr{display:grid;grid-template-columns:var(--mon-w) repeat(7,1fr);
788
+ border-bottom:1px solid var(--bd);flex-shrink:0;}
789
+ .cal-day-hdr{padding:.35rem .5rem;font-size:.55rem;font-weight:600;text-align:center;
790
+ color:var(--sub);border-left:1px solid var(--bd);}
791
+ .cal-day-hdr.today{color:var(--acc);}
792
+ .cal-day-hdr:first-child{border-left:none;font-size:.45rem;color:var(--dim);}
793
+ #cal-grid{flex:1;overflow-y:auto;position:relative;}
794
+ #cal-grid::-webkit-scrollbar{width:4px;}
795
+ #cal-grid::-webkit-scrollbar-thumb{background:var(--bd2);}
796
+ .cal-row{display:grid;grid-template-columns:var(--mon-w) repeat(7,1fr);
797
+ border-bottom:1px solid var(--bd);min-height:52px;}
798
+ .cal-time-cell{padding:.3rem .5rem;font-size:.5rem;color:var(--sub);font-family:var(--mono);
799
+ border-right:1px solid var(--bd);display:flex;align-items:flex-start;justify-content:flex-end;
800
+ padding-top:.6rem;}
801
+ .cal-cell{border-left:1px solid var(--bd);padding:.2rem .18rem;position:relative;min-height:52px;}
802
+ .cal-cell.today{background:rgba(255,107,0,.02);}
803
+ .cal-event{border-radius:5px;padding:.22rem .38rem;margin:.08rem 0;cursor:pointer;
804
+ font-size:.52rem;font-weight:600;border-left:3px solid;transition:all .1s;
805
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:.3rem;}
806
+ .cal-event:hover{opacity:.85;transform:translateX(2px);}
807
+ .cal-event .ev-agent{font-size:.42rem;opacity:.75;font-weight:400;}
808
+ .ev-new{animation:evslide .2s ease;}
809
+ @keyframes evslide{from{opacity:0;transform:translateX(-4px)}to{opacity:1;transform:none}}
810
+
811
+ /* AGENTS PANEL */
812
+ #panel-agents{padding:.8rem 1.2rem;overflow-y:auto;}
813
+ #panel-agents::-webkit-scrollbar{width:4px;}
814
+ #panel-agents::-webkit-scrollbar-thumb{background:var(--bd2);}
815
+ .agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:.75rem;}
816
+ .agent-full-card{background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:1rem;
817
+ transition:border-color .12s;}
818
+ .agent-full-card:hover{border-color:var(--bd2);}
819
+ .afc-header{display:flex;align-items:center;gap:.6rem;margin-bottom:.7rem;}
820
+ .afc-dot{width:12px;height:12px;border-radius:50%;}
821
+ .afc-name{font-size:.95rem;font-weight:700;flex:1;}
822
+ .afc-toggle{background:var(--s2);border:1px solid var(--bd);border-radius:4px;padding:.24rem .55rem;
823
+ font-size:.52rem;cursor:pointer;font-family:var(--font);}
824
+ .afc-toggle.on{border-color:var(--lo);color:var(--lo);}
825
+ .afc-toggle.off{border-color:var(--cr);color:var(--cr);}
826
+ .afc-persona{font-size:.58rem;color:var(--sub);line-height:1.5;margin-bottom:.7rem;
827
+ max-height:60px;overflow:hidden;text-overflow:ellipsis;}
828
+ .afc-meta{display:flex;gap:.35rem;flex-wrap:wrap;margin-bottom:.7rem;}
829
+ .afc-tag{font-size:.48rem;background:var(--s2);border:1px solid var(--bd);border-radius:3px;
830
+ padding:1px 6px;color:var(--sub);}
831
+ .afc-actions{display:flex;gap:.35rem;}
832
+ .afc-btn{font-size:.55rem;background:var(--s2);border:1px solid var(--bd);border-radius:4px;
833
+ padding:.26rem .6rem;cursor:pointer;font-family:var(--font);color:var(--sub);}
834
+ .afc-btn:hover{border-color:var(--acc);color:var(--acc);}
835
+ .afc-btn.run{border-color:rgba(57,255,106,.3);color:var(--lo);background:rgba(57,255,106,.04);}
836
+ .afc-btn.run:hover{border-color:var(--lo);background:rgba(57,255,106,.1);}
837
+ #btn-add-agent{background:var(--acc);color:#000;border:none;padding:.38rem .9rem;
838
+ font-family:var(--font);font-size:.62rem;font-weight:700;border-radius:5px;
839
+ cursor:pointer;margin-bottom:.8rem;letter-spacing:.06em;}
840
+
841
+ /* LIVE FEED */
842
+ #panel-live{padding:0;overflow:hidden;}
843
+ #live-wrap{display:flex;height:100%;}
844
+ #live-feed{flex:1;overflow-y:auto;padding:.6rem .9rem;font-family:var(--mono);font-size:.65rem;}
845
+ #live-feed::-webkit-scrollbar{width:4px;}
846
+ #live-feed::-webkit-scrollbar-thumb{background:var(--bd2);}
847
+ .lf-item{display:flex;align-items:flex-start;gap:.55rem;padding:.32rem .45rem;
848
+ border-radius:5px;margin-bottom:.22rem;animation:lfin .18s ease;}
849
+ @keyframes lfin{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:none}}
850
+ .lf-icon{font-size:.8rem;flex-shrink:0;width:18px;text-align:center;}
851
+ .lf-ts{font-size:.48rem;color:var(--dim);flex-shrink:0;width:52px;padding-top:2px;}
852
+ .lf-body{flex:1;}
853
+ .lf-agent{font-size:.6rem;font-weight:700;margin-right:.35rem;}
854
+ .lf-msg{font-size:.6rem;color:var(--sub);}
855
+ .lf-type-heartbeat{background:rgba(240,192,64,.03);}
856
+ .lf-type-react_start{background:rgba(56,189,248,.04);}
857
+ .lf-type-react_step{background:rgba(139,92,246,.04);}
858
+ .lf-type-react_done{background:rgba(57,255,106,.04);}
859
+ .lf-type-error{background:rgba(255,34,85,.06);}
860
+ .lf-type-idle{opacity:.4;}
861
+ #live-sidebar{width:300px;border-left:1px solid var(--bd);display:flex;flex-direction:column;overflow:hidden;}
862
+ #live-sidebar-hdr{padding:.55rem .8rem;border-bottom:1px solid var(--bd);font-size:.55rem;
863
+ font-weight:700;letter-spacing:.12em;color:var(--acc);}
864
+ #trace-list{flex:1;overflow-y:auto;padding:.4rem;}
865
+ #trace-list::-webkit-scrollbar{width:2px;}
866
+ #trace-list::-webkit-scrollbar-thumb{background:var(--bd2);}
867
+ .trace-item{background:var(--s2);border:1px solid var(--bd);border-radius:6px;
868
+ padding:.5rem .65rem;margin-bottom:.3rem;cursor:pointer;transition:all .1s;}
869
+ .trace-item:hover{border-color:var(--bd2);}
870
+ .tr-agent{font-size:.62rem;font-weight:700;margin-bottom:.2rem;}
871
+ .tr-result{font-size:.55rem;color:var(--sub);overflow:hidden;text-overflow:ellipsis;
872
+ white-space:nowrap;margin-bottom:.25rem;}
873
+ .tr-meta{display:flex;gap:.4rem;font-size:.48rem;color:var(--dim);}
874
+ .tr-ok{color:var(--lo);}.tr-fail{color:var(--cr);}
875
+
876
+ /* TRACE DETAIL MODAL */
877
+ #trace-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200;
878
+ backdrop-filter:blur(6px);align-items:center;justify-content:center;}
879
+ #trace-modal.open{display:flex;}
880
+ .tm-box{background:var(--s1);border:1px solid var(--bd2);border-top:2px solid var(--acc);
881
+ border-radius:12px;width:750px;max-width:98vw;max-height:90vh;display:flex;flex-direction:column;
882
+ animation:mdin .16s ease;}
883
+ @keyframes mdin{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:none}}
884
+ .tm-hdr{display:flex;align-items:center;gap:.7rem;padding:.8rem 1.1rem;border-bottom:1px solid var(--bd);}
885
+ .tm-title{font-size:.8rem;font-weight:700;flex:1;color:var(--acc);}
886
+ .tm-close{background:none;border:none;color:var(--sub);cursor:pointer;font-size:1rem;
887
+ width:28px;height:28px;border-radius:4px;display:flex;align-items:center;justify-content:center;}
888
+ .tm-close:hover{background:var(--bd2);color:var(--txt);}
889
+ .tm-body{flex:1;overflow-y:auto;padding:.9rem 1.1rem;}
890
+ .tm-body::-webkit-scrollbar{width:4px;}
891
+ .tm-body::-webkit-scrollbar-thumb{background:var(--bd2);}
892
+ .step-block{margin-bottom:.9rem;background:var(--s2);border:1px solid var(--bd);border-radius:8px;overflow:hidden;}
893
+ .step-hdr{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem;background:var(--s1);
894
+ border-bottom:1px solid var(--bd);}
895
+ .step-n{font-size:.58rem;background:var(--acc);color:#000;border-radius:3px;padding:1px 6px;font-weight:700;}
896
+ .step-action{font-size:.62rem;font-weight:700;font-family:var(--mono);}
897
+ .step-body{padding:.55rem .75rem;}
898
+ .step-label{font-size:.48rem;color:var(--sub);font-weight:700;letter-spacing:.12em;
899
+ text-transform:uppercase;margin-bottom:.18rem;}
900
+ .step-text{font-size:.6rem;color:var(--txt);font-family:var(--mono);white-space:pre-wrap;
901
+ word-break:break-word;line-height:1.55;}
902
+ .step-obs{background:rgba(57,255,106,.04);border-top:1px solid var(--bd);padding:.45rem .75rem;}
903
+
904
+ /* MODAL (agents / schedule) */
905
+ #modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:100;
906
+ backdrop-filter:blur(5px);align-items:center;justify-content:center;}
907
+ #modal.open{display:flex;}
908
+ .mdl{background:var(--s1);border:1px solid var(--bd2);border-top:2px solid var(--acc);
909
+ border-radius:12px;width:520px;max-width:98vw;max-height:90vh;display:flex;flex-direction:column;
910
+ animation:mdin .16s ease;position:relative;}
911
+ .mdl-hdr{padding:.85rem 1.1rem;border-bottom:1px solid var(--bd);display:flex;align-items:center;}
912
+ .mdl-title{font-size:.75rem;font-weight:700;letter-spacing:2px;flex:1;color:var(--acc);}
913
+ .mdl-x{background:none;border:none;color:var(--sub);cursor:pointer;font-size:.9rem;
914
+ width:26px;height:26px;border-radius:4px;display:flex;align-items:center;justify-content:center;}
915
+ .mdl-x:hover{background:var(--bd2);}
916
+ .mdl-body{flex:1;overflow-y:auto;padding:1rem 1.1rem;}
917
+ .mdl-body::-webkit-scrollbar{width:3px;}
918
+ .mdl-body::-webkit-scrollbar-thumb{background:var(--bd2);}
919
+ .mfl{margin-bottom:.7rem;}
920
+ .mfl label{display:block;font-size:.46rem;color:var(--sub);text-transform:uppercase;
921
+ letter-spacing:.12em;margin-bottom:.22rem;font-weight:700;}
922
+ .mfl input,.mfl select,.mfl textarea{width:100%;background:var(--s2);border:1px solid var(--bd2);
923
+ border-radius:5px;padding:.38rem .55rem;font-family:var(--font);font-size:.65rem;color:var(--txt);
924
+ outline:none;transition:border-color .12s;}
925
+ .mfl input:focus,.mfl select:focus,.mfl textarea:focus{border-color:var(--acc);}
926
+ .mfl textarea{resize:vertical;min-height:80px;font-family:var(--mono);}
927
+ .mfl-row{display:grid;grid-template-columns:1fr 1fr;gap:.5rem;}
928
+ .mfl-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.5rem;}
929
+ .mdl-footer{padding:.75rem 1.1rem;border-top:1px solid var(--bd);display:flex;gap:.4rem;}
930
+ .mdl-ok{flex:1;background:var(--acc);color:#000;border:none;padding:.44rem;
931
+ font-family:var(--font);font-size:.65rem;font-weight:700;letter-spacing:.1em;
932
+ text-transform:uppercase;border-radius:5px;cursor:pointer;}
933
+ .mdl-ok:hover{background:var(--acc2);}
934
+ .mdl-cancel{background:var(--s2);color:var(--sub);border:1px solid var(--bd2);
935
+ padding:.44rem .9rem;font-family:var(--font);font-size:.65rem;border-radius:5px;cursor:pointer;}
936
+ .mdl-cancel:hover{color:var(--txt);}
937
+
938
+ /* TOAST */
939
+ #toasts{position:fixed;bottom:1rem;right:1rem;z-index:300;display:flex;flex-direction:column;gap:.3rem;}
940
+ .tst{background:var(--s1);border:1px solid var(--bd2);border-left:3px solid var(--acc);
941
+ padding:.38rem .72rem;font-size:.58rem;border-radius:5px;animation:tin .14s ease;color:var(--txt);}
942
+ .tst.ok{border-left-color:var(--lo);}.tst.err{border-left-color:var(--cr);}
943
+ .tst.warn{border-left-color:var(--pulse);}
944
+ @keyframes tin{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:none}}
945
+
946
+ /* SPACES OVERVIEW */
947
+ #panel-spaces{padding:.8rem 1.2rem;overflow-y:auto;}
948
+ #panel-spaces::-webkit-scrollbar{width:4px;}
949
+ #panel-spaces::-webkit-scrollbar-thumb{background:var(--bd2);}
950
+ .spaces-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.65rem;}
951
+ .space-card{background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:.85rem 1rem;
952
+ transition:all .12s;}
953
+ .space-card:hover{border-color:var(--bd2);}
954
+ .sc-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;}
955
+ .sc-dot{width:10px;height:10px;border-radius:50%;}
956
+ .sc-dot.ok{background:var(--lo);box-shadow:0 0 8px var(--lo);}
957
+ .sc-dot.bad{background:var(--cr);}
958
+ .sc-dot.unk{background:var(--sub);animation:pulsebeat 2s ease-in-out infinite;}
959
+ .sc-name{font-size:.78rem;font-weight:700;flex:1;}
960
+ .sc-desc{font-size:.55rem;color:var(--sub);line-height:1.5;margin-bottom:.5rem;}
961
+ .sc-tools{display:flex;flex-wrap:wrap;gap:.2rem;margin-bottom:.5rem;}
962
+ .sc-tool{font-size:.44rem;background:var(--s2);border:1px solid var(--bd);border-radius:3px;
963
+ padding:1px 5px;color:var(--dim);}
964
+ .sc-url{font-size:.48rem;color:var(--acc);font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;}
965
+ .sc-link{text-decoration:none;font-size:.5rem;background:var(--s2);border:1px solid var(--bd);
966
+ border-radius:3px;padding:2px 7px;color:var(--sub);margin-top:.4rem;display:inline-block;}
967
+ .sc-link:hover{color:var(--acc);border-color:var(--acc);}
968
+
969
+ /* Flashing run indicator */
970
+ @keyframes runglow{0%,100%{box-shadow:0 0 0 0 transparent}50%{box-shadow:0 0 12px 2px var(--lo)}}
971
+ .running-glow{animation:runglow 1.2s ease-in-out infinite;}
972
+ </style>
973
+ </head>
974
+ <body>
975
+ <div id="hdr">
976
+ <span id="pulse-dot"></span>
977
+ <div id="logo">
978
+ <div id="logo-main">PULSE</div>
979
+ <div id="logo-sub">Agent Nervous System &mdash; ki-fusion-labs.de</div>
980
+ </div>
981
+ <div id="hdr-center">
982
+ <div class="hs"><span class="hs-n" id="hs-agents">0</span>AGENTS</div>
983
+ <div class="hs"><span class="hs-n" id="hs-running">0</span>RUNNING</div>
984
+ <div class="hs"><span class="hs-n" id="hs-jobs">0</span>SCHEDULED</div>
985
+ <div class="hs"><span class="hs-n" id="hs-ticks">0</span>TICKS TODAY</div>
986
+ </div>
987
+ <div id="nav">
988
+ <button class="nav-btn on" data-panel="timetable">&#x1F4C5; Timetable</button>
989
+ <button class="nav-btn" data-panel="agents">&#x1F916; Agents</button>
990
+ <button class="nav-btn" data-panel="live">&#x26A1; Live</button>
991
+ <button class="nav-btn" data-panel="spaces">&#x1F310; Spaces</button>
992
+ </div>
993
+ </div>
994
+
995
+ <div id="layout">
996
+ <div id="sidebar">
997
+ <div class="sb-scroll">
998
+ <div class="sb-section">Active Agents</div>
999
+ <div id="agent-list"></div>
1000
+ <div class="sb-section" style="margin-top:.5rem">Connected Spaces</div>
1001
+ <div id="space-health-list"></div>
1002
+ </div>
1003
+ </div>
1004
+
1005
+ <div id="main-area">
1006
+
1007
+ <!-- TIMETABLE -->
1008
+ <div id="panel-timetable" class="panel on">
1009
+ <div id="cal-header">
1010
+ <button class="cal-nav" id="btn-prev-week">&#8249;</button>
1011
+ <div id="cal-title">Week</div>
1012
+ <button class="cal-nav" id="btn-next-week">&#8250;</button>
1013
+ <button class="cal-nav" id="btn-today" title="Go to today" style="font-size:.55rem;width:auto;padding:0 .5rem">Today</button>
1014
+ <button id="btn-add-sched">+ Schedule</button>
1015
+ </div>
1016
+ <div id="cal-body">
1017
+ <div id="cal-days-hdr">
1018
+ <div class="cal-day-hdr" style="border-left:none">UTC</div>
1019
+ </div>
1020
+ <div id="cal-grid"></div>
1021
+ </div>
1022
+ </div>
1023
+
1024
+ <!-- AGENTS -->
1025
+ <div id="panel-agents" class="panel">
1026
+ <button id="btn-add-agent">+ New Agent</button>
1027
+ <div class="agents-grid" id="agents-full-grid"></div>
1028
+ </div>
1029
+
1030
+ <!-- LIVE -->
1031
+ <div id="panel-live" class="panel">
1032
+ <div id="live-wrap">
1033
+ <div id="live-feed"></div>
1034
+ <div id="live-sidebar">
1035
+ <div id="live-sidebar-hdr">RECENT TRACES</div>
1036
+ <div id="trace-list"></div>
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+
1041
+ <!-- SPACES -->
1042
+ <div id="panel-spaces" class="panel">
1043
+ <div class="spaces-grid" id="spaces-grid"></div>
1044
+ </div>
1045
+
1046
+ </div>
1047
+ </div>
1048
+
1049
+ <!-- TRACE MODAL -->
1050
+ <div id="trace-modal">
1051
+ <div class="tm-box">
1052
+ <div class="tm-hdr">
1053
+ <span class="tm-title" id="tm-title">TRACE</span>
1054
+ <button class="tm-close" id="tm-close">&#x2715;</button>
1055
+ </div>
1056
+ <div class="tm-body" id="tm-body"></div>
1057
+ </div>
1058
+ </div>
1059
+
1060
+ <!-- MODAL -->
1061
+ <div id="modal">
1062
+ <div class="mdl">
1063
+ <div class="mdl-hdr">
1064
+ <span class="mdl-title" id="mdl-title">MODAL</span>
1065
+ <button class="mdl-x" id="mdl-x">&#x2715;</button>
1066
+ </div>
1067
+ <div class="mdl-body" id="mdl-body"></div>
1068
+ <div class="mdl-footer">
1069
+ <button class="mdl-ok" id="mdl-ok">Save</button>
1070
+ <button class="mdl-cancel" id="mdl-cancel">Cancel</button>
1071
+ </div>
1072
+ </div>
1073
+ </div>
1074
+
1075
+ <div id="toasts"></div>
1076
+
1077
+ <script>
1078
+ // ── Utils ──────────────────────────────────────────────────────────
1079
+ var S = function(id){return document.getElementById(id);};
1080
+ function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
1081
+ function post(u,d){return fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});}
1082
+ function del(u,d){return fetch(u,{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify(d||{})});}
1083
+ function toast(msg,t){var e=document.createElement('div');e.className='tst'+(t?' '+t:'');e.textContent=msg;S('toasts').appendChild(e);setTimeout(function(){e.remove();},2600);}
1084
+ function fmtTime(ts){return new Date(ts*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});}
1085
+ function fmtDateTime(ts){return new Date(ts*1000).toLocaleString([],{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});}
1086
+
1087
+ // ── State ─────────────────────────────────────────────────────────
1088
+ var AGENTS = [], SCHEDULE = [], SPACES_HEALTH = {};
1089
+ var WEEK_START = getMonday(new Date());
1090
+ var SELECTED_AGENT = null;
1091
+ var MODAL_MODE = '', MODAL_DATA = {};
1092
+ var TICKS_TODAY = 0;
1093
+
1094
+ function getMonday(d){
1095
+ var dd=new Date(d); var day=dd.getDay(); var diff=dd.getDate()-day+(day==0?-6:1);
1096
+ dd.setDate(diff); dd.setHours(0,0,0,0); return dd;
1097
+ }
1098
+
1099
+ // ── Panel nav ─────────────────────────────────────────────────────
1100
+ document.querySelectorAll('.nav-btn[data-panel]').forEach(function(btn){
1101
+ btn.addEventListener('click',function(){
1102
+ document.querySelectorAll('.nav-btn').forEach(function(b){b.classList.remove('on');});
1103
+ document.querySelectorAll('.panel').forEach(function(p){p.classList.remove('on');});
1104
+ this.classList.add('on');
1105
+ S('panel-'+this.getAttribute('data-panel')).classList.add('on');
1106
+ if(this.getAttribute('data-panel')==='live') loadTraces();
1107
+ if(this.getAttribute('data-panel')==='spaces') renderSpacesPanel();
1108
+ });
1109
+ });
1110
+
1111
+ // ── Load agents ───────────────────────────────────────────────────
1112
+ function loadAgents(){
1113
+ fetch('/api/agents').then(function(r){return r.json();}).then(function(data){
1114
+ AGENTS=data;
1115
+ renderSidebarAgents();
1116
+ renderAgentsFull();
1117
+ S('hs-agents').textContent=data.length;
1118
+ var running=data.filter(function(a){return (a.status||{}).running;}).length;
1119
+ S('hs-running').textContent=running;
1120
+ });
1121
+ }
1122
+
1123
+ function agentColor(a){return a.color||'#ff6b00';}
1124
+ function agentStatusClass(a){
1125
+ var st=a.status||{};
1126
+ if(st.running) return 'running';
1127
+ if((st.last_ok===false)&&st.last_result) return 'error';
1128
+ return 'idle';
1129
+ }
1130
+
1131
+ function renderSidebarAgents(){
1132
+ var list=S('agent-list'); list.innerHTML='';
1133
+ AGENTS.forEach(function(a){
1134
+ var st=a.status||{}; var sc=agentStatusClass(a);
1135
+ var div=document.createElement('div');
1136
+ div.className='agent-card'+(SELECTED_AGENT===a.name?' active':'');
1137
+ div.innerHTML=
1138
+ '<div class="ac-header">'
1139
+ +'<div class="ac-dot '+sc+(sc==='running'?' running-glow':'')+'" style="background:'+agentColor(a)+'"></div>'
1140
+ +'<span class="ac-name">'+esc(a.name)+'</span>'
1141
+ +'<span class="ac-status '+sc+'">'+sc+'</span>'
1142
+ +'</div>'
1143
+ +(st.last_result?'<div class="ac-result" title="'+esc(st.last_result)+'">'+esc(st.last_result)+'</div>':'')
1144
+ +'<div class="ac-actions">'
1145
+ +'<button class="ac-btn" data-name="'+esc(a.name)+'" data-action="run">&#x25BA; Run</button>'
1146
+ +'<button class="ac-btn" data-name="'+esc(a.name)+'" data-action="edit">Edit</button>'
1147
+ +'</div>';
1148
+ list.appendChild(div);
1149
+ div.querySelectorAll('[data-action]').forEach(function(btn){
1150
+ btn.addEventListener('click',function(e){e.stopPropagation();
1151
+ var n=this.getAttribute('data-name'), ac=this.getAttribute('data-action');
1152
+ if(ac==='run') triggerAgent(n,'manual','Manual trigger from UI');
1153
+ else openAgentModal(n);
1154
+ });
1155
+ });
1156
+ div.addEventListener('click',function(){SELECTED_AGENT=a.name;renderSidebarAgents();});
1157
+ });
1158
+ }
1159
+
1160
+ function renderAgentsFull(){
1161
+ var grid=S('agents-full-grid'); grid.innerHTML='';
1162
+ AGENTS.forEach(function(a){
1163
+ var st=a.status||{}; var en=a.enabled!==false;
1164
+ var div=document.createElement('div'); div.className='agent-full-card';
1165
+ div.innerHTML=
1166
+ '<div class="afc-header">'
1167
+ +'<div class="afc-dot" style="background:'+agentColor(a)+';box-shadow:0 0 10px '+agentColor(a)+'44"></div>'
1168
+ +'<span class="afc-name">'+esc(a.name)+'</span>'
1169
+ +'<button class="afc-toggle '+(en?'on':'off')+'" data-name="'+esc(a.name)+'">'+(en?'&#x2714; ON':'&#x2716; OFF')+'</button>'
1170
+ +'</div>'
1171
+ +'<div class="afc-persona">'+esc(a.persona||'')+'</div>'
1172
+ +'<div class="afc-meta">'
1173
+ +(a.heartbeat_seconds>0?'<span class="afc-tag">&#x2665; every '+a.heartbeat_seconds+'s</span>':'')
1174
+ +'<span class="afc-tag">'+esc(a.cost_mode||'balanced')+'</span>'
1175
+ +'<span class="afc-tag">max '+a.max_react_steps+' steps</span>'
1176
+ +((a.tags||[]).map(function(t){return '<span class="afc-tag">'+esc(t)+'</span>';}).join(''))
1177
+ +'</div>'
1178
+ +(st.last_result?'<div style="font-size:.52rem;color:var(--sub);margin-bottom:.5rem;font-family:var(--mono)">Last: '+esc(st.last_result.substring(0,80))+'</div>':'')
1179
+ +'<div class="afc-actions">'
1180
+ +'<button class="afc-btn run" data-name="'+esc(a.name)+'" data-action="run">&#x25BA; Run Now</button>'
1181
+ +'<button class="afc-btn" data-name="'+esc(a.name)+'" data-action="edit">&#x270F; Edit</button>'
1182
+ +'<button class="afc-btn" data-name="'+esc(a.name)+'" data-action="confer" style="border-color:rgba(139,92,246,.3);color:var(--violet)">&#x1F4AC; Confer</button>'
1183
+ +'<button class="afc-btn" data-name="'+esc(a.name)+'" data-action="delete" style="border-color:rgba(255,34,85,.2);color:var(--cr)">&#x1F5D1;</button>'
1184
+ +'</div>';
1185
+ grid.appendChild(div);
1186
+ div.querySelectorAll('[data-action]').forEach(function(btn){
1187
+ btn.addEventListener('click',function(e){e.stopPropagation();
1188
+ var n=this.getAttribute('data-name'), ac=this.getAttribute('data-action');
1189
+ if(ac==='run') triggerAgent(n,'manual','Manual trigger from Agents panel');
1190
+ else if(ac==='edit') openAgentModal(n);
1191
+ else if(ac==='confer') openConferModal(n);
1192
+ else if(ac==='delete'){if(confirm('Delete agent '+n+'?')) deleteAgent(n);}
1193
+ });
1194
+ });
1195
+ div.querySelector('.afc-toggle').addEventListener('click',function(e){e.stopPropagation();
1196
+ var n=this.getAttribute('data-name'), a2=AGENTS.find(function(x){return x.name===n;});
1197
+ if(a2){a2.enabled=!(a2.enabled!==false);post('/api/agents',a2).then(function(){loadAgents();});}
1198
+ });
1199
+ });
1200
+ }
1201
+
1202
+ function triggerAgent(name, trigger, content){
1203
+ post('/api/agents/'+name+'/run',{trigger:trigger||'manual',content:content||''})
1204
+ .then(function(){toast('Triggered: '+name,'ok');setTimeout(loadAgents,800);});
1205
+ }
1206
+ function deleteAgent(name){
1207
+ del('/api/agents/'+name).then(function(){toast('Deleted: '+name);loadAgents();});
1208
+ }
1209
+
1210
+ // ── Calendar ──────────────────────────────────────────────────────
1211
+ var HOURS = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23];
1212
+ var DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
1213
+ var DAY_MAP = {0:'Mon',1:'Tue',2:'Wed',3:'Thu',4:'Fri',5:'Sat',6:'Sun'};
1214
+
1215
+ function loadSchedule(){
1216
+ fetch('/api/schedule').then(function(r){return r.json();}).then(function(data){
1217
+ SCHEDULE=data; renderCalendar(); S('hs-jobs').textContent=data.filter(function(e){return e.enabled;}).length;
1218
+ });
1219
+ }
1220
+
1221
+ function renderCalendar(){
1222
+ // Day headers
1223
+ var hdr=S('cal-days-hdr');
1224
+ var weekDates=getWeekDates(WEEK_START);
1225
+ var today=new Date(); today.setHours(0,0,0,0);
1226
+ hdr.innerHTML='<div class="cal-day-hdr" style="border-left:none">UTC</div>';
1227
+ weekDates.forEach(function(d,i){
1228
+ var isToday=d.getTime()===today.getTime();
1229
+ hdr.innerHTML+='<div class="cal-day-hdr'+(isToday?' today':'')+'">'+DAYS[i]+'<br>'
1230
+ +'<span style="font-size:.7rem;font-weight:700;color:'+(isToday?'var(--acc)':'var(--txt)')+'">'+d.getDate()+'</span></div>';
1231
+ });
1232
+ // Update title
1233
+ var y=WEEK_START.getFullYear();
1234
+ var m1=WEEK_START.toLocaleString('default',{month:'short'});
1235
+ var end=new Date(WEEK_START); end.setDate(end.getDate()+6);
1236
+ var m2=end.toLocaleString('default',{month:'short'});
1237
+ S('cal-title').textContent='Week '+getWeekNum(WEEK_START)+' β€” '+m1+(m2!==m1?' / '+m2:'')+' '+y;
1238
+ // Grid
1239
+ var grid=S('cal-grid'); grid.innerHTML='';
1240
+ HOURS.forEach(function(h){
1241
+ var row=document.createElement('div'); row.className='cal-row';
1242
+ var timeCell=document.createElement('div'); timeCell.className='cal-time-cell';
1243
+ timeCell.textContent=String(h).padStart(2,'0')+':00';
1244
+ row.appendChild(timeCell);
1245
+ weekDates.forEach(function(d,di){
1246
+ var cell=document.createElement('div');
1247
+ var isToday=d.getTime()===today.getTime();
1248
+ cell.className='cal-cell'+(isToday?' today':'');
1249
+ // Find events for this day+hour
1250
+ var dayIdx=di; // 0=Mon
1251
+ SCHEDULE.forEach(function(e){
1252
+ var matches=false;
1253
+ if(e.recurrence==='daily' && e.hour===h) matches=true;
1254
+ if(e.recurrence==='weekly' && e.hour===h && e.day===dayIdx) matches=true;
1255
+ if(e.recurrence==='once'){
1256
+ var dt=e.datetime?new Date(e.datetime):null;
1257
+ if(dt && dt.getDay()===((dayIdx+1)%7) && dt.getHours()===h) matches=true;
1258
+ }
1259
+ if(matches){
1260
+ var ev=document.createElement('div');
1261
+ var col=e.color||'#ff6b00';
1262
+ ev.className='cal-event'; ev.style.borderLeftColor=col;
1263
+ ev.style.background=col+'18'; ev.style.color=col;
1264
+ ev.innerHTML='<span>'+esc(e.title)+'</span><span class="ev-agent">@'+esc(e.agent)+'</span>'
1265
+ +(e.enabled?'':' <span style="opacity:.5">(off)</span>');
1266
+ ev.setAttribute('data-eid',e.id);
1267
+ ev.addEventListener('click',function(evt){evt.stopPropagation();openScheduleModal(e.id);});
1268
+ cell.appendChild(ev);
1269
+ }
1270
+ });
1271
+ row.appendChild(cell);
1272
+ });
1273
+ grid.appendChild(row);
1274
+ });
1275
+ // Scroll to 7am
1276
+ setTimeout(function(){
1277
+ var rows=grid.querySelectorAll('.cal-row');
1278
+ if(rows[7]) rows[7].scrollIntoView({block:'start'});
1279
+ },50);
1280
+ }
1281
+
1282
+ function getWeekDates(monday){
1283
+ var dates=[]; for(var i=0;i<7;i++){var d=new Date(monday);d.setDate(monday.getDate()+i);dates.push(d);} return dates;
1284
+ }
1285
+ function getWeekNum(d){
1286
+ var date=new Date(Date.UTC(d.getFullYear(),d.getMonth(),d.getDate()));
1287
+ var day=date.getUTCDay()||7; date.setUTCDate(date.getUTCDate()+4-day);
1288
+ var yearStart=new Date(Date.UTC(date.getUTCFullYear(),0,1));
1289
+ return Math.ceil((((date-yearStart)/86400000)+1)/7);
1290
+ }
1291
+
1292
+ S('btn-prev-week').addEventListener('click',function(){WEEK_START.setDate(WEEK_START.getDate()-7);renderCalendar();});
1293
+ S('btn-next-week').addEventListener('click',function(){WEEK_START.setDate(WEEK_START.getDate()+7);renderCalendar();});
1294
+ S('btn-today').addEventListener('click',function(){WEEK_START=getMonday(new Date());renderCalendar();});
1295
+ S('btn-add-sched').addEventListener('click',function(){openScheduleModal(null);});
1296
+
1297
+ // ── Schedule modal ─────────────────────────────────────────────────
1298
+ function openScheduleModal(eid){
1299
+ var existing=eid?SCHEDULE.find(function(e){return e.id===eid;}):null;
1300
+ MODAL_MODE='schedule'; MODAL_DATA=existing||{};
1301
+ S('mdl-title').textContent=existing?'EDIT SCHEDULE':'ADD SCHEDULE';
1302
+ var agents=AGENTS.map(function(a){return '<option value="'+esc(a.name)+'"'+(existing&&existing.agent===a.name?' selected':'')+'>'+esc(a.name)+'</option>';}).join('');
1303
+ S('mdl-body').innerHTML=
1304
+ '<div class="mfl"><label>Title</label><input id="mi-title" value="'+esc(existing?existing.title:'')+'"></div>'
1305
+ +'<div class="mfl"><label>Agent</label><select id="mi-agent">'+agents+'</select></div>'
1306
+ +'<div class="mfl"><label>Prompt (what the agent should do)</label><textarea id="mi-prompt">'+esc(existing?existing.prompt:'')+'</textarea></div>'
1307
+ +'<div class="mfl-row">'
1308
+ +'<div class="mfl"><label>Recurrence</label><select id="mi-rec">'
1309
+ +'<option value="daily"'+(existing&&existing.recurrence==='daily'?' selected':'')+'>Daily</option>'
1310
+ +'<option value="weekly"'+((!existing||existing.recurrence==='weekly')?' selected':'')+'>Weekly</option>'
1311
+ +'<option value="once"'+(existing&&existing.recurrence==='once'?' selected':'')+'>Once</option>'
1312
+ +'</select></div>'
1313
+ +'<div class="mfl"><label>Day (weekly)</label><select id="mi-day">'
1314
+ +['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].map(function(d,i){
1315
+ return '<option value="'+i+'"'+(existing&&existing.day===i?' selected':'')+'>'+d+'</option>';}).join('')
1316
+ +'</select></div>'
1317
+ +'</div>'
1318
+ +'<div class="mfl-row">'
1319
+ +'<div class="mfl"><label>Hour (UTC)</label><input id="mi-hour" type="number" min="0" max="23" value="'+(existing?existing.hour:9)+'"></div>'
1320
+ +'<div class="mfl"><label>Minute</label><input id="mi-minute" type="number" min="0" max="59" value="'+(existing?existing.minute:0)+'"></div>'
1321
+ +'</div>'
1322
+ +'<div class="mfl-row">'
1323
+ +'<div class="mfl"><label>Color</label><input id="mi-color" type="color" value="'+(existing&&existing.color?existing.color:'#ff6b00')+'" style="height:36px;padding:2px"></div>'
1324
+ +'<div class="mfl"><label>Enabled</label><select id="mi-enabled">'
1325
+ +'<option value="1"'+((!existing||existing.enabled)?' selected':'')+'>Yes</option>'
1326
+ +'<option value="0"'+(existing&&!existing.enabled?' selected':'')+'>No</option>'
1327
+ +'</select></div>'
1328
+ +'</div>'
1329
+ +(existing?'<div style="margin-top:.5rem;display:flex;gap:.4rem">'
1330
+ +'<button onclick="runScheduleNow(\''+esc(eid)+'\')" style="font-size:.6rem;background:rgba(57,255,106,.08);border:1px solid rgba(57,255,106,.3);color:var(--lo);border-radius:4px;padding:.3rem .7rem;cursor:pointer;font-family:var(--font)">&#x25BA; Run Now</button>'
1331
+ +'<button onclick="deleteSchedule(\''+esc(eid)+'\')" style="font-size:.6rem;background:rgba(255,34,85,.06);border:1px solid rgba(255,34,85,.2);color:var(--cr);border-radius:4px;padding:.3rem .7rem;cursor:pointer;font-family:var(--font)">&#x1F5D1; Delete</button>'
1332
+ +'</div>':'');
1333
+ S('modal').classList.add('open');
1334
+ }
1335
+
1336
+ function runScheduleNow(eid){
1337
+ post('/api/schedule/'+eid+'/run',{}).then(function(){toast('Triggered!','ok');closeModal();});
1338
+ }
1339
+ function deleteSchedule(eid){
1340
+ del('/api/schedule/'+eid).then(function(){toast('Deleted','ok');closeModal();loadSchedule();});
1341
+ }
1342
+
1343
+ // ── Agent modal ────────────────────────────────────────────────────
1344
+ function openAgentModal(name){
1345
+ var existing=name?AGENTS.find(function(a){return a.name===name;}):null;
1346
+ MODAL_MODE='agent'; MODAL_DATA=existing||{};
1347
+ S('mdl-title').textContent=existing?'EDIT AGENT':'NEW AGENT';
1348
+ S('mdl-body').innerHTML=
1349
+ '<div class="mfl"><label>Name (lowercase, no spaces)</label><input id="mi-name" value="'+esc(existing?existing.name:'')+'" placeholder="researcher"></div>'
1350
+ +'<div class="mfl"><label>Persona (system prompt)</label><textarea id="mi-persona" style="min-height:120px">'+esc(existing?existing.persona:'You are a helpful AI agent.')+'</textarea></div>'
1351
+ +'<div class="mfl-row">'
1352
+ +'<div class="mfl"><label>Heartbeat (sec, 0=off)</label><input id="mi-hb" type="number" min="0" value="'+(existing?existing.heartbeat_seconds:0)+'"></div>'
1353
+ +'<div class="mfl"><label>Cost mode</label><select id="mi-cost"><option value="cheap">cheap</option><option value="balanced"'+((!existing||existing.cost_mode==='balanced')?' selected':'')+'>balanced</option><option value="best"'+(existing&&existing.cost_mode==='best'?' selected':'')+'>best</option></select></div>'
1354
+ +'</div>'
1355
+ +'<div class="mfl-row">'
1356
+ +'<div class="mfl"><label>Max ReAct steps</label><input id="mi-steps" type="number" min="1" max="10" value="'+(existing?existing.max_react_steps:5)+'"></div>'
1357
+ +'<div class="mfl"><label>Color</label><input id="mi-acolor" type="color" value="'+(existing&&existing.color?existing.color:'#ff6b00')+'" style="height:36px;padding:2px"></div>'
1358
+ +'</div>'
1359
+ +'<div class="mfl"><label>Tags (comma separated)</label><input id="mi-tags" value="'+esc(existing&&existing.tags?(existing.tags).join(', '):'')+'"></div>';
1360
+ S('modal').classList.add('open');
1361
+ }
1362
+
1363
+ // ── Conference modal ───────────────────────────────────────────────
1364
+ function openConferModal(name){
1365
+ MODAL_MODE='confer'; MODAL_DATA={initiator:name};
1366
+ S('mdl-title').textContent='AGENT CONFERENCE';
1367
+ var others=AGENTS.filter(function(a){return a.name!==name;});
1368
+ S('mdl-body').innerHTML=
1369
+ '<div style="font-size:.6rem;color:var(--sub);margin-bottom:.7rem">Initiate a multi-agent conference. The task will be sent to selected agents via RELAY.</div>'
1370
+ +'<div class="mfl"><label>Conference Topic / Task</label><textarea id="mi-conf-topic" style="min-height:80px" placeholder="Analyze our codebase and create a sprint plan for next week"></textarea></div>'
1371
+ +'<div class="mfl"><label>Invite Agents</label>'
1372
+ +others.map(function(a){
1373
+ return '<label style="display:flex;align-items:center;gap:.4rem;padding:.2rem 0;font-size:.62rem;color:var(--txt);cursor:pointer">'
1374
+ +'<input type="checkbox" value="'+esc(a.name)+'" style="accent-color:var(--acc)"> '
1375
+ +'<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:'+agentColor(a)+'"></span> '
1376
+ +esc(a.name)+'</label>';
1377
+ }).join('')
1378
+ +'</div>'
1379
+ +'<div class="mfl"><label>Priority</label><select id="mi-conf-prio"><option value="normal">normal</option><option value="high">high</option><option value="urgent">urgent</option></select></div>';
1380
+ S('modal').classList.add('open');
1381
+ }
1382
+
1383
+ // ── Modal save ─────────────────────────────────────────────────────
1384
+ S('mdl-ok').addEventListener('click',function(){
1385
+ if(MODAL_MODE==='schedule'){
1386
+ var data={
1387
+ id: MODAL_DATA.id||'',
1388
+ title: S('mi-title').value.trim(),
1389
+ agent: S('mi-agent').value,
1390
+ prompt: S('mi-prompt').value.trim(),
1391
+ recurrence: S('mi-rec').value,
1392
+ day: parseInt(S('mi-day').value),
1393
+ hour: parseInt(S('mi-hour').value),
1394
+ minute: parseInt(S('mi-minute').value),
1395
+ color: S('mi-color').value,
1396
+ enabled: S('mi-enabled').value==='1',
1397
+ };
1398
+ if(!data.title||!data.agent){toast('Title and agent required','warn');return;}
1399
+ post('/api/schedule',data).then(function(){toast('Schedule saved','ok');closeModal();loadSchedule();});
1400
+ } else if(MODAL_MODE==='agent'){
1401
+ var data={
1402
+ name: S('mi-name').value.trim().toLowerCase(),
1403
+ persona: S('mi-persona').value.trim(),
1404
+ heartbeat_seconds: parseInt(S('mi-hb').value)||0,
1405
+ cost_mode: S('mi-cost').value,
1406
+ max_react_steps: parseInt(S('mi-steps').value)||5,
1407
+ color: S('mi-acolor').value,
1408
+ tags: S('mi-tags').value.split(',').map(function(t){return t.trim();}).filter(Boolean),
1409
+ enabled: MODAL_DATA.enabled!==false,
1410
+ };
1411
+ if(!data.name){toast('Name required','warn');return;}
1412
+ post('/api/agents',data).then(function(){toast('Agent saved','ok');closeModal();loadAgents();});
1413
+ } else if(MODAL_MODE==='confer'){
1414
+ var topic=S('mi-conf-topic').value.trim();
1415
+ var invited=[...document.querySelectorAll('#mdl-body input[type=checkbox]:checked')].map(function(cb){return cb.value;});
1416
+ var prio=S('mi-conf-prio').value;
1417
+ if(!topic||!invited.length){toast('Topic and at least one agent required','warn');return;}
1418
+ var promises=invited.map(function(ag){
1419
+ return post('/api/agents/'+ag+'/run',{trigger:'conference',content:'[CONFERENCE from '+MODAL_DATA.initiator+']\nTopic: '+topic+'\nCoordinate with other agents: '+invited.join(', ')});
1420
+ });
1421
+ Promise.all(promises).then(function(){toast('Conference started with '+invited.length+' agents','ok');closeModal();});
1422
+ }
1423
+ });
1424
+
1425
+ function closeModal(){S('modal').classList.remove('open');}
1426
+ S('mdl-x').addEventListener('click',closeModal);
1427
+ S('mdl-cancel').addEventListener('click',closeModal);
1428
+ S('modal').addEventListener('click',function(e){if(e.target===this)closeModal();});
1429
+ S('btn-add-agent').addEventListener('click',function(){openAgentModal(null);});
1430
+
1431
+ // ── Live feed ──────────────────────────────────────────────────────
1432
+ var LIVE_COUNT = 0;
1433
+ var EVENT_ICONS = {heartbeat:'&#x2665;',react_start:'&#x26A1;',react_step:'&#x1F535;',react_done:'&#x2714;',react_obs:'&#x1F441;',idle:'&#x1F4A4;',error:'&#x26A0;',connected:'&#x1F310;',ping:''};
1434
+ var EVENT_COLORS = {heartbeat:'var(--pulse)',react_start:'var(--info)',react_step:'var(--violet)',react_done:'var(--lo)',error:'var(--cr)',idle:'var(--sub)',connected:'var(--acc)'};
1435
+
1436
+ function initLiveFeed(){
1437
+ var src=new EventSource('/api/live');
1438
+ src.onmessage=function(e){
1439
+ try{var ev=JSON.parse(e.data); if(ev.type==='ping') return; appendLive(ev);}catch(err){}
1440
+ };
1441
+ src.onerror=function(){setTimeout(initLiveFeed,3000);};
1442
+ }
1443
+
1444
+ function appendLive(ev){
1445
+ var feed=S('live-feed');
1446
+ if(!feed) return;
1447
+ if(ev.type==='react_done'||ev.type==='heartbeat') TICKS_TODAY++;
1448
+ S('hs-ticks').textContent=TICKS_TODAY;
1449
+ var icon=EVENT_ICONS[ev.type]||'&#x25CF;';
1450
+ var col=EVENT_COLORS[ev.type]||'var(--sub)';
1451
+ var msg='';
1452
+ if(ev.type==='react_start') msg='Starting ReAct loop ['+esc(ev.trigger)+']';
1453
+ else if(ev.type==='react_step') msg='Step '+ev.step+': <span style="color:var(--violet)">'+esc(ev.action)+'</span> β€” '+esc((ev.thought||'').substring(0,80));
1454
+ else if(ev.type==='react_obs') msg='&#x1F441; '+esc((ev.observation||'').substring(0,100));
1455
+ else if(ev.type==='react_done') msg='Done in '+ev.steps+' steps ('+ev.ms+'ms): <span style="color:'+(ev.ok?'var(--lo)':'var(--cr)')+'">'+esc((ev.result||'').substring(0,80))+'</span>';
1456
+ else if(ev.type==='heartbeat') msg='Heartbeat tick ['+esc(ev.trigger)+']';
1457
+ else if(ev.type==='idle') msg='Nothing to do β€” idle';
1458
+ else if(ev.type==='error') msg='<span style="color:var(--cr)">'+esc(ev.message||'')+'</span>';
1459
+ else msg=esc(JSON.stringify(ev).substring(0,100));
1460
+
1461
+ var item=document.createElement('div');
1462
+ item.className='lf-item lf-type-'+ev.type;
1463
+ item.innerHTML='<span class="lf-icon" style="color:'+col+'">'+icon+'</span>'
1464
+ +'<span class="lf-ts">'+new Date(ev.ts*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})+'</span>'
1465
+ +'<div class="lf-body">'
1466
+ +(ev.agent?'<span class="lf-agent" style="color:'+agentColorByName(ev.agent)+'">@'+esc(ev.agent)+'</span>':'')
1467
+ +'<span class="lf-msg">'+msg+'</span>'
1468
+ +'</div>';
1469
+ feed.insertBefore(item, feed.firstChild);
1470
+ // Keep last 150
1471
+ LIVE_COUNT++;
1472
+ while(feed.children.length>150) feed.removeChild(feed.lastChild);
1473
+ // Update running state in sidebar
1474
+ if(ev.type==='react_start'||ev.type==='heartbeat') setTimeout(loadAgents,200);
1475
+ if(ev.type==='react_done'||ev.type==='idle'||ev.type==='error') setTimeout(loadAgents,400);
1476
+ }
1477
+
1478
+ function agentColorByName(n){
1479
+ var a=AGENTS.find(function(x){return x.name===n;});
1480
+ return a?agentColor(a):'var(--acc)';
1481
+ }
1482
+
1483
+ // ── Traces ─────────────────────────────────────────────────────────
1484
+ function loadTraces(){
1485
+ fetch('/api/traces?limit=20').then(function(r){return r.json();}).then(function(traces){
1486
+ var list=S('trace-list'); list.innerHTML='';
1487
+ traces.forEach(function(t){
1488
+ var div=document.createElement('div'); div.className='trace-item';
1489
+ div.innerHTML='<div class="tr-agent" style="color:'+agentColorByName(t.agent)+'">@'+esc(t.agent)+'</div>'
1490
+ +'<div class="tr-result">'+esc((t.result||'').substring(0,80))+'</div>'
1491
+ +'<div class="tr-meta">'
1492
+ +'<span class="'+(t.ok?'tr-ok':'tr-fail')+'">'+(t.ok?'&#x2714;':'&#x2716;')+'</span>'
1493
+ +'<span>'+t.steps.length+' steps</span>'
1494
+ +'<span>'+Math.round((t.ms||0)/1000)+'s</span>'
1495
+ +'<span style="color:var(--dim)">'+fmtDateTime(t.started)+'</span>'
1496
+ +'</div>';
1497
+ div.addEventListener('click',function(){showTrace(t);});
1498
+ list.appendChild(div);
1499
+ });
1500
+ });
1501
+ }
1502
+
1503
+ function showTrace(t){
1504
+ S('tm-title').textContent='TRACE: @'+t.agent+' β€” '+t.trigger;
1505
+ var body=S('tm-body');
1506
+ body.innerHTML='<div style="display:flex;gap:.5rem;margin-bottom:.8rem;flex-wrap:wrap">'
1507
+ +'<span class="afc-tag">trigger: '+esc(t.trigger)+'</span>'
1508
+ +'<span class="afc-tag '+(t.ok?'tr-ok':'tr-fail')+'">'+(t.ok?'&#x2714; OK':'&#x2716; FAILED')+'</span>'
1509
+ +'<span class="afc-tag">'+t.steps.length+' steps</span>'
1510
+ +'<span class="afc-tag">'+Math.round((t.ms||0)/1000)+'s</span>'
1511
+ +'</div>'
1512
+ +'<div style="font-size:.62rem;color:var(--lo);margin-bottom:1rem;font-family:var(--mono)">Result: '+esc(t.result||'')+'</div>'
1513
+ +t.steps.map(function(step){
1514
+ return '<div class="step-block">'
1515
+ +'<div class="step-hdr"><span class="step-n">'+step.n+'</span>'
1516
+ +'<span class="step-action" style="color:var(--info)">'+esc(step.action)+'</span>'
1517
+ +(step.args?'<span style="font-size:.52rem;color:var(--sub);font-family:var(--mono)"> ('+esc(JSON.stringify(step.args).substring(0,60))+')</span>':'')
1518
+ +'</div>'
1519
+ +'<div class="step-body"><div class="step-label">Thought</div>'
1520
+ +'<div class="step-text">'+esc(step.thought||'')+'</div></div>'
1521
+ +'<div class="step-obs"><div class="step-label">Observation</div>'
1522
+ +'<div class="step-text" style="color:var(--lo)">'+esc(step.observation||'')+'</div></div>'
1523
+ +'</div>';
1524
+ }).join('');
1525
+ S('trace-modal').classList.add('open');
1526
+ }
1527
+ S('tm-close').addEventListener('click',function(){S('trace-modal').classList.remove('open');});
1528
+ S('trace-modal').addEventListener('click',function(e){if(e.target===this)S('trace-modal').classList.remove('open');});
1529
+
1530
+ // ── Spaces health ──────────────────────────────────────────────────
1531
+ function loadSpacesHealth(){
1532
+ fetch('/api/spaces/health').then(function(r){return r.json();}).then(function(h){
1533
+ SPACES_HEALTH=h;
1534
+ var list=S('space-health-list'); list.innerHTML='';
1535
+ Object.entries(h).forEach(function(kv){
1536
+ var k=kv[0],v=kv[1];
1537
+ var cls=v.ok?'ok':'bad';
1538
+ list.innerHTML+='<div class="space-row"><div class="sp-dot '+cls+'"></div>'
1539
+ +'<span class="sp-name">'+esc(k)+'</span>'
1540
+ +'<span class="sp-status">'+(v.ok?'&#x2714;':(v.error?v.error.substring(0,18):'err'))+'</span></div>';
1541
+ });
1542
+ }).catch(function(){});
1543
+ }
1544
+
1545
+ var SPACE_META = {
1546
+ relay: {desc:'Communication hub β€” messages, channels, broadcast',color:'#ff6b9d',tools:['relay_send','relay_inbox','relay_broadcast','relay_ack']},
1547
+ memory: {desc:'Multi-tier memory β€” episodic, semantic, procedural, working',color:'#8b5cf6',tools:['memory_store','memory_search','memory_recall','memory_update']},
1548
+ kanban: {desc:'Task board β€” create, assign, track, complete tasks',color:'#f0c040',tools:['kanban_list','kanban_create','kanban_move','kanban_claim']},
1549
+ nexus: {desc:'LLM gateway β€” OpenAI-compatible model router (RTX 5090 + HF)',color:'#38bdf8',tools:['chat/completions','classify','route','health']},
1550
+ vault: {desc:'File workspace + execution β€” read, write, run code',color:'#2ed573',tools:['vault_read','vault_write','vault_exec','vault_versions']},
1551
+ forge: {desc:'Skill registry β€” search and use agent skills & tools',color:'#ff6b00',tools:['forge_search','skill_invoke','tool_list']},
1552
+ knowledge:{desc:'Knowledge base β€” documents, RAG, semantic search',color:'#ec4899',tools:['kb_search','kb_store','kb_retrieve']},
1553
+ };
1554
+
1555
+ function renderSpacesPanel(){
1556
+ var grid=S('spaces-grid'); grid.innerHTML='';
1557
+ Object.entries(SPACE_META).forEach(function(kv){
1558
+ var k=kv[0], meta=kv[1];
1559
+ var health=SPACES_HEALTH[k]||{};
1560
+ var cls=health.ok?'ok':(health.error?'bad':'unk');
1561
+ var url='https://huggingface.co/spaces/Chris4K/agent-'+k;
1562
+ var card=document.createElement('div'); card.className='space-card';
1563
+ card.style.borderTopColor=meta.color;
1564
+ card.innerHTML='<div class="sc-header">'
1565
+ +'<div class="sc-dot '+cls+'" style="background:'+meta.color+'"></div>'
1566
+ +'<span class="sc-name" style="color:'+meta.color+'">'+k.toUpperCase()+'</span>'
1567
+ +'</div>'
1568
+ +'<div class="sc-desc">'+esc(meta.desc)+'</div>'
1569
+ +'<div class="sc-tools">'+meta.tools.map(function(t){return '<span class="sc-tool">'+esc(t)+'</span>';}).join('')+'</div>'
1570
+ +'<div class="sc-url">'+url+'</div>'
1571
+ +'<a class="sc-link" href="'+url+'" target="_blank">Open Space &#x2197;</a>';
1572
+ grid.appendChild(card);
1573
+ });
1574
+ }
1575
+
1576
+ // ── Keyboard ───────────────────────────────────────────────────────
1577
+ document.addEventListener('keydown',function(e){
1578
+ if(e.key==='Escape'){
1579
+ closeModal();
1580
+ S('trace-modal').classList.remove('open');
1581
+ }
1582
+ });
1583
+
1584
+ // ── Init ───────────────────────────────────────────────────────────
1585
+ loadAgents();
1586
+ loadSchedule();
1587
+ loadSpacesHealth();
1588
+ initLiveFeed();
1589
+ setInterval(loadAgents, 6000);
1590
+ setInterval(loadSpacesHealth, 30000);
1591
+ setInterval(function(){if(S('panel-live').classList.contains('on'))loadTraces();}, 10000);
1592
+ </script>
1593
+ </body>
1594
+ </html>"""
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.111.0
2
+ uvicorn>=0.30.0
3
+ python-multipart>=0.0.9
4
+ httpx>=0.27.0
5
+ apscheduler>=3.10.4