Spaces:
Runtime error
Runtime error
Melany Macias commited on
Commit ·
45da134
1
Parent(s): 01229ee
final
Browse files- .dockerignore +0 -1
- Dockerfile +14 -18
- README.md +46 -0
- dashboard/app.py +6 -3
- dashboard/data.py +67 -1
- dashboard/templates/index.html +330 -252
- murmur.py +97 -29
.dockerignore
CHANGED
|
@@ -5,6 +5,5 @@
|
|
| 5 |
.gitignore
|
| 6 |
__pycache__/
|
| 7 |
*.pyc
|
| 8 |
-
dashboard/
|
| 9 |
mcp-aiven/
|
| 10 |
*.md
|
|
|
|
| 5 |
.gitignore
|
| 6 |
__pycache__/
|
| 7 |
*.pyc
|
|
|
|
| 8 |
mcp-aiven/
|
| 9 |
*.md
|
Dockerfile
CHANGED
|
@@ -1,25 +1,21 @@
|
|
| 1 |
-
# Murmur
|
| 2 |
-
#
|
| 3 |
-
#
|
|
|
|
| 4 |
FROM python:3.11-slim
|
| 5 |
|
| 6 |
WORKDIR /app
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
RUN pip install --no-cache-dir "mcp>=1.0" "anthropic>=0.40" "fastembed>=0.4"
|
| 10 |
-
|
| 11 |
-
# Bake the embedding model into the image so the worker never downloads it at runtime.
|
| 12 |
-
RUN python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')"
|
| 13 |
|
| 14 |
COPY murmur.py .
|
|
|
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
#
|
| 18 |
-
ENV
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
MURMUR_INTERVAL=300
|
| 22 |
-
EXPOSE 8080
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
CMD ["
|
|
|
|
| 1 |
+
# Murmur — ONE container that runs the agent swarm (conductor) + the live dashboard.
|
| 2 |
+
# Deploys anywhere that builds a Dockerfile; tuned for Hugging Face Spaces (Docker SDK, port 7860).
|
| 3 |
+
# The public URL is the live wall; the agents run inside, talking to Aiven entirely via the MCP.
|
| 4 |
+
# Secrets (ANTHROPIC_API_KEY, AIVEN_TOKEN, DATABASE_URL) are injected at runtime — never baked in.
|
| 5 |
FROM python:3.11-slim
|
| 6 |
|
| 7 |
WORKDIR /app
|
| 8 |
+
RUN pip install --no-cache-dir "mcp>=1.0" "anthropic>=0.40" "fastembed>=0.4" \
|
| 9 |
+
"flask>=3.0" "psycopg[binary]>=3.1" "python-dotenv>=1.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
COPY murmur.py .
|
| 12 |
+
COPY dashboard/ dashboard/
|
| 13 |
|
| 14 |
+
# HF Spaces serves the port declared as app_port (7860). The dashboard binds it publicly;
|
| 15 |
+
# the conductor keeps its own status endpoint on 8080 (internal).
|
| 16 |
+
ENV HOST=0.0.0.0 FLASK_DEBUG=0 MURMUR_FLEET=6 MURMUR_INTERVAL=180 \
|
| 17 |
+
HOME=/tmp PYTHONUNBUFFERED=1
|
| 18 |
+
EXPOSE 7860
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
# agents (conductor) in the background + the public dashboard in the foreground
|
| 21 |
+
CMD ["bash","-lc","PORT=8080 python murmur.py --serve & exec env PORT=7860 python dashboard/app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Murmur
|
| 3 |
+
emoji: 🐦
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Murmur — a self-coordinating agent swarm on Aiven
|
| 12 |
+
|
| 13 |
+
**AI agents that don't just _use_ a database — they _operate_ one.** No backend, no human:
|
| 14 |
+
a swarm that coordinates over Apache Kafka, remembers in PostgreSQL + pgvector, and
|
| 15 |
+
**provisions + self-tunes its own Aiven infrastructure — all through the Aiven MCP.**
|
| 16 |
+
|
| 17 |
+
This Space runs the whole thing in one container: the **agent swarm** (the conductor) plus a
|
| 18 |
+
live read-only **dashboard** (the public page you're looking at). Every agent action is an MCP
|
| 19 |
+
tool call — there are no direct DB/Kafka drivers.
|
| 20 |
+
|
| 21 |
+
## What it does
|
| 22 |
+
- **Stagger + diversify** — each agent reads the shared Kafka stream and uses a **pgvector**
|
| 23 |
+
similarity search to pick a measurably different hook at a staggered time. No controller.
|
| 24 |
+
- **Detect + amplify** — the fleet clusters the round's hooks in pgvector, finds the resonating
|
| 25 |
+
theme, and the owning agent allocates ad budget to double down (Kafka `signals`).
|
| 26 |
+
- **Provision itself** — a new segment agent creates its **own Kafka topic + Postgres state** via the MCP.
|
| 27 |
+
- **Self-optimize** — a watcher agent reads its own query stats, sees the diversify search going
|
| 28 |
+
full-scan at scale, and provisions a **pgvector HNSW index** itself (~23 ms → ~4 ms).
|
| 29 |
+
- **Decide autonomously** — an LLM **conductor** chooses the swarm's next move each cycle from live state.
|
| 30 |
+
|
| 31 |
+
## Run it locally
|
| 32 |
+
```bash
|
| 33 |
+
export ANTHROPIC_API_KEY=… # claude-sonnet for the agents' decisions
|
| 34 |
+
export AIVEN_TOKEN=… # Aiven personal token, for the MCP
|
| 35 |
+
uv run --with mcp --with anthropic --with fastembed python murmur.py --serve # the autonomous conductor
|
| 36 |
+
```
|
| 37 |
+
Dashboard: `cd dashboard && uv run --with flask --with "psycopg[binary]" --with python-dotenv python app.py`
|
| 38 |
+
|
| 39 |
+
## Deploy (this Space)
|
| 40 |
+
One container runs both. Set three **Secrets** in the Space settings:
|
| 41 |
+
`ANTHROPIC_API_KEY`, `AIVEN_TOKEN`, and `DATABASE_URL` (the pg-conductor connection string,
|
| 42 |
+
`postgres://…?sslmode=require`). The dashboard serves on port 7860; the conductor runs alongside it.
|
| 43 |
+
|
| 44 |
+
## Stack
|
| 45 |
+
Aiven MCP (PostgreSQL + pgvector, Apache Kafka) · claude-sonnet for every decision ·
|
| 46 |
+
fastembed (local 384-dim embeddings) · `murmur.py` (the swarm) + a Flask dashboard.
|
dashboard/app.py
CHANGED
|
@@ -44,6 +44,9 @@ def state():
|
|
| 44 |
|
| 45 |
|
| 46 |
if __name__ == "__main__":
|
| 47 |
-
|
| 48 |
-
#
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
if __name__ == "__main__":
|
| 47 |
+
# Local default: loopback + auto-reload. In a container (Hugging Face Spaces etc.) set
|
| 48 |
+
# HOST=0.0.0.0, PORT=<public port, e.g. 7860>, FLASK_DEBUG=0 to serve publicly.
|
| 49 |
+
host = os.environ.get("HOST", "127.0.0.1")
|
| 50 |
+
port = int(os.environ.get("PORT") or os.environ.get("MURMUR_PORT") or "5050")
|
| 51 |
+
debug = os.environ.get("FLASK_DEBUG", "1") not in ("0", "false", "no", "")
|
| 52 |
+
app.run(host=host, port=port, debug=debug)
|
dashboard/data.py
CHANGED
|
@@ -317,6 +317,62 @@ def _get_ops(cur, tables, posts, posts_live):
|
|
| 317 |
"budget_total": budget_total, "source": source}
|
| 318 |
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
# ------------------------------------------------------------------------ public API
|
| 321 |
def get_state(limit=60):
|
| 322 |
"""The whole payload the dashboard polls. Never raises — on any DB error it
|
|
@@ -346,7 +402,14 @@ def get_state(limit=60):
|
|
| 346 |
accounts = _get_accounts(cur) if "accounts" in tables else _mock_accounts()
|
| 347 |
accounts_src = "live" if "accounts" in tables else "mock (no accounts table)"
|
| 348 |
posts, posts_src = _get_posts(cur, tables, limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
ops = _get_ops(cur, tables, posts, posts_live=posts_src == "live")
|
|
|
|
| 350 |
return {
|
| 351 |
"generated_at": generated_at,
|
| 352 |
"mode": "live",
|
|
@@ -354,7 +417,10 @@ def get_state(limit=60):
|
|
| 354 |
"accounts": accounts,
|
| 355 |
"posts": posts,
|
| 356 |
"ops": ops,
|
| 357 |
-
"
|
|
|
|
|
|
|
|
|
|
| 358 |
}
|
| 359 |
except Exception as e: # noqa: BLE001 — viewer must stay up; show degraded state
|
| 360 |
accounts = _mock_accounts()
|
|
|
|
| 317 |
"budget_total": budget_total, "source": source}
|
| 318 |
|
| 319 |
|
| 320 |
+
def _get_signals(cur, tables, limit=14):
|
| 321 |
+
"""Recent autonomous signals (trend / amplify / optimize) the agents persisted to the
|
| 322 |
+
`signals` table. Each row's `payload` is jsonb → already a dict via psycopg. (rows, source)."""
|
| 323 |
+
if "signals" not in tables:
|
| 324 |
+
return [], "none"
|
| 325 |
+
cur.execute("SELECT kind, payload, ts FROM signals ORDER BY id DESC LIMIT %s", (limit,))
|
| 326 |
+
rows = [{"kind": r["kind"], "payload": r["payload"], "ts": _iso(r["ts"])} for r in cur.fetchall()]
|
| 327 |
+
return rows, ("live" if rows else "empty")
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _get_diversity(cur, tables):
|
| 331 |
+
"""Live pgvector readout. For each hook (latest per account+subject) find the nearest
|
| 332 |
+
peer hook from ANOTHER account within the same ~round window, and return the cosine
|
| 333 |
+
similarity — the EXACT metric the agents diversify on, recomputed live in Postgres so
|
| 334 |
+
the feed can show real numbers. Lower sim = more distinct. The 20-minute window scopes
|
| 335 |
+
it to the round the agent actually diversified against (not all-time near-dupes).
|
| 336 |
+
|
| 337 |
+
Read-only; returns {} if hooks/pgvector aren't present. Keyed by (account_id, subject)
|
| 338 |
+
so it maps cleanly onto `posts` rows in get_state()."""
|
| 339 |
+
if "hooks" not in tables:
|
| 340 |
+
return {}
|
| 341 |
+
try:
|
| 342 |
+
cur.execute(
|
| 343 |
+
"""
|
| 344 |
+
WITH h AS (
|
| 345 |
+
SELECT account_id, subject, embedding, ts,
|
| 346 |
+
row_number() OVER (PARTITION BY account_id, subject ORDER BY ts DESC) AS rn
|
| 347 |
+
FROM hooks
|
| 348 |
+
)
|
| 349 |
+
SELECT h.account_id, h.subject,
|
| 350 |
+
n.account_id AS near, n.subject AS near_subject, n.sim
|
| 351 |
+
FROM h
|
| 352 |
+
LEFT JOIN LATERAL (
|
| 353 |
+
SELECT o.account_id, o.subject,
|
| 354 |
+
round((1 - (h.embedding <=> o.embedding))::numeric, 3)::float8 AS sim
|
| 355 |
+
FROM hooks o
|
| 356 |
+
WHERE o.account_id <> h.account_id
|
| 357 |
+
AND o.ts BETWEEN h.ts - interval '20 minutes' AND h.ts + interval '20 minutes'
|
| 358 |
+
ORDER BY h.embedding <=> o.embedding
|
| 359 |
+
LIMIT 1
|
| 360 |
+
) n ON true
|
| 361 |
+
WHERE h.rn = 1
|
| 362 |
+
"""
|
| 363 |
+
)
|
| 364 |
+
out = {}
|
| 365 |
+
for r in cur.fetchall():
|
| 366 |
+
out[(r["account_id"], r["subject"])] = {
|
| 367 |
+
"near": r["near"],
|
| 368 |
+
"near_subject": r["near_subject"],
|
| 369 |
+
"sim": float(r["sim"]) if r["sim"] is not None else None,
|
| 370 |
+
}
|
| 371 |
+
return out
|
| 372 |
+
except Exception: # noqa: BLE001 — diversity is a bonus readout; never break the poll
|
| 373 |
+
return {}
|
| 374 |
+
|
| 375 |
+
|
| 376 |
# ------------------------------------------------------------------------ public API
|
| 377 |
def get_state(limit=60):
|
| 378 |
"""The whole payload the dashboard polls. Never raises — on any DB error it
|
|
|
|
| 402 |
accounts = _get_accounts(cur) if "accounts" in tables else _mock_accounts()
|
| 403 |
accounts_src = "live" if "accounts" in tables else "mock (no accounts table)"
|
| 404 |
posts, posts_src = _get_posts(cur, tables, limit)
|
| 405 |
+
# live pgvector diversity: attach each post's real cosine to its nearest peer hook
|
| 406 |
+
div = _get_diversity(cur, tables) if posts_src == "live" else {}
|
| 407 |
+
for p in posts:
|
| 408 |
+
d = div.get((p.get("account_id"), p.get("subject")))
|
| 409 |
+
if d:
|
| 410 |
+
p["sim"], p["near"], p["near_subject"] = d["sim"], d["near"], d["near_subject"]
|
| 411 |
ops = _get_ops(cur, tables, posts, posts_live=posts_src == "live")
|
| 412 |
+
signals, signals_src = _get_signals(cur, tables)
|
| 413 |
return {
|
| 414 |
"generated_at": generated_at,
|
| 415 |
"mode": "live",
|
|
|
|
| 417 |
"accounts": accounts,
|
| 418 |
"posts": posts,
|
| 419 |
"ops": ops,
|
| 420 |
+
"signals": signals,
|
| 421 |
+
"sources": {"accounts": accounts_src, "posts": posts_src,
|
| 422 |
+
"ops": ops["source"], "signals": signals_src,
|
| 423 |
+
"diversity": "live (pgvector)" if div else "none"},
|
| 424 |
}
|
| 425 |
except Exception as e: # noqa: BLE001 — viewer must stay up; show degraded state
|
| 426 |
accounts = _mock_accounts()
|
dashboard/templates/index.html
CHANGED
|
@@ -3,145 +3,199 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
-
<title>
|
| 7 |
<style>
|
| 8 |
-
:root
|
| 9 |
-
--bg:#
|
| 10 |
-
--ink:#
|
| 11 |
-
--cyan:#
|
| 12 |
-
--flash:#2a3550;
|
| 13 |
}
|
| 14 |
-
*
|
| 15 |
-
body
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
-
.mono
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
position:sticky; top:0; z-index:5;
|
| 24 |
-
}
|
| 25 |
-
header h1 { font-size:18px; margin:0; letter-spacing:.3px; }
|
| 26 |
-
header h1 b { color:var(--cyan); }
|
| 27 |
-
.status { display:flex; align-items:center; gap:8px; color:var(--dim); font-size:13px; }
|
| 28 |
-
.dot { width:9px; height:9px; border-radius:50%; background:var(--dimmer); }
|
| 29 |
-
.dot.live { background:var(--green); box-shadow:0 0 9px var(--green); animation:pulse 2s infinite; }
|
| 30 |
-
.dot.mock { background:var(--amber); box-shadow:0 0 9px var(--amber); }
|
| 31 |
-
.dot.err { background:var(--red); box-shadow:0 0 9px var(--red); }
|
| 32 |
-
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
| 33 |
-
.pill {
|
| 34 |
-
font-size:11px; padding:2px 8px; border-radius:999px; border:1px solid var(--line);
|
| 35 |
-
color:var(--dim); background:var(--panel); white-space:nowrap;
|
| 36 |
-
}
|
| 37 |
-
.pill.live { color:var(--green); border-color:#1f3a25; }
|
| 38 |
-
.pill.mock { color:var(--amber); border-color:#3a3320; }
|
| 39 |
-
.pill.derived { color:var(--cyan); border-color:#193a3b; }
|
| 40 |
-
.spacer { flex:1; }
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
.
|
| 58 |
-
.
|
| 59 |
-
|
| 60 |
-
.
|
| 61 |
-
.tile .last { margin-top:10px; font-size:12px; color:var(--dim); border-top:1px dashed var(--line); padding-top:8px; }
|
| 62 |
-
.tile .last .subj { color:var(--ink); }
|
| 63 |
-
.tile .tdot {
|
| 64 |
-
position:absolute; top:13px; right:13px; width:8px; height:8px; border-radius:50%;
|
| 65 |
-
background:var(--dimmer);
|
| 66 |
-
}
|
| 67 |
-
.tile.active .tdot { background:var(--green); box-shadow:0 0 8px var(--green); }
|
| 68 |
-
.badge { display:inline-block; font-size:10px; padding:1px 6px; border-radius:6px; margin-right:5px; }
|
| 69 |
-
.badge.stagger { color:var(--cyan); background:#0f2c2e; }
|
| 70 |
-
.badge.diversify{ color:var(--magenta); background:#2c0f2b; }
|
| 71 |
|
| 72 |
-
/* feed */
|
| 73 |
-
.
|
| 74 |
-
.feed
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
.
|
| 81 |
-
|
| 82 |
-
.
|
| 83 |
-
|
| 84 |
-
.
|
| 85 |
-
.
|
| 86 |
-
.
|
| 87 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
-
/*
|
| 90 |
-
.
|
| 91 |
-
.
|
| 92 |
-
|
| 93 |
-
.
|
| 94 |
-
|
| 95 |
-
.
|
| 96 |
-
.
|
| 97 |
-
.
|
| 98 |
-
.
|
| 99 |
-
.
|
| 100 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
</style>
|
| 105 |
</head>
|
| 106 |
<body>
|
|
|
|
|
|
|
|
|
|
| 107 |
<header>
|
| 108 |
-
<
|
| 109 |
-
<div class="status">
|
| 110 |
-
<span id="dot" class="dot"></span>
|
| 111 |
-
<span id="statustext">connecting…</span>
|
| 112 |
-
</div>
|
| 113 |
<span class="spacer"></span>
|
| 114 |
-
<span class="pill" id="p-
|
| 115 |
<span class="pill" id="p-posts">posts —</span>
|
| 116 |
-
<span class="pill" id="p-
|
| 117 |
-
<span class="pill mono" id="p-
|
| 118 |
</header>
|
| 119 |
|
| 120 |
<main>
|
| 121 |
<div class="col">
|
| 122 |
-
<section>
|
| 123 |
-
<h2>The swarm <span class="
|
| 124 |
-
<div class="tiles" id="tiles"></div>
|
| 125 |
</section>
|
| 126 |
-
<section>
|
| 127 |
-
<h2>Live feed <span class="
|
| 128 |
-
<div class="
|
| 129 |
-
|
|
|
|
|
|
|
| 130 |
</section>
|
| 131 |
</div>
|
| 132 |
|
| 133 |
<div class="col">
|
| 134 |
-
<section>
|
| 135 |
-
<h2>
|
| 136 |
-
<div class="
|
| 137 |
-
<div class="legend">Resonating theme detected from post performance → ad budget shifts toward it.</div>
|
| 138 |
</section>
|
| 139 |
-
<section>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
<h2>Coordination</h2>
|
| 141 |
-
<div class="
|
| 142 |
-
<div class="
|
| 143 |
-
<div class="
|
| 144 |
-
<div class="legend">No controller, no human — agents read the shared stream,
|
| 145 |
</div>
|
| 146 |
</section>
|
| 147 |
</div>
|
|
@@ -149,160 +203,184 @@
|
|
| 149 |
|
| 150 |
<script>
|
| 151 |
const POLL_MS = 2000;
|
| 152 |
-
|
| 153 |
-
let
|
| 154 |
-
let firstPaint = true;
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
return
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
if(s < 60) return s + "s ago";
|
| 165 |
-
if(s < 3600) return Math.floor(s/60) + "m ago";
|
| 166 |
-
return Math.floor(s/3600) + "h ago";
|
| 167 |
-
}
|
| 168 |
-
function shortName(persona){
|
| 169 |
-
if(!persona) return "?";
|
| 170 |
-
return persona.split("—")[0].trim().split(" ")[0];
|
| 171 |
-
}
|
| 172 |
-
function setPill(id, label, src){
|
| 173 |
-
const el = document.getElementById(id);
|
| 174 |
-
el.textContent = label + " · " + src;
|
| 175 |
-
el.className = "pill " + (src.startsWith("live") ? "live" : src.startsWith("derived") ? "derived" : "mock");
|
| 176 |
-
}
|
| 177 |
|
| 178 |
function render(s){
|
| 179 |
-
|
| 180 |
-
const dot
|
| 181 |
-
|
| 182 |
-
if(s.
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
} else {
|
| 189 |
-
dot.className = "dot mock";
|
| 190 |
-
st.textContent = "mock preview · set DATABASE_URL to go live";
|
| 191 |
-
}
|
| 192 |
-
document.getElementById("p-updated").textContent = fmtClock(s.generated_at);
|
| 193 |
-
setPill("p-accounts", "accounts", s.sources.accounts);
|
| 194 |
-
setPill("p-posts", "posts", s.sources.posts);
|
| 195 |
-
setPill("p-ops", "budget", s.sources.ops);
|
| 196 |
|
| 197 |
-
|
| 198 |
-
const latest
|
| 199 |
-
for(const p of s.posts){
|
| 200 |
-
if(!(p.account_id in latest)) latest[p.account_id] = p; // posts are newest-first
|
| 201 |
-
}
|
| 202 |
|
| 203 |
-
//
|
| 204 |
-
const tiles
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
const
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
'<
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
if(changed && !firstPaint){ setTimeout(() => div.classList.remove("flash"), 900); }
|
| 225 |
});
|
| 226 |
-
|
| 227 |
-
// remember latest ids for next diff
|
| 228 |
-
const nl = {};
|
| 229 |
-
for(const a of s.accounts){ if(latest[a.id]) nl[a.id] = latest[a.id].id; }
|
| 230 |
-
lastByAccount = nl;
|
| 231 |
|
| 232 |
-
//
|
| 233 |
-
const
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
const
|
| 241 |
-
const
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
'<span
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
'<span class="
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
});
|
| 253 |
-
|
| 254 |
-
if(s.posts.length === 0){
|
| 255 |
-
feed.innerHTML = '<div class="row"><span class="meta">no posts yet — waiting for the swarm…</span></div>';
|
| 256 |
-
}
|
| 257 |
|
| 258 |
-
//
|
| 259 |
-
|
| 260 |
-
const
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
const
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
-
//
|
| 274 |
-
const
|
| 275 |
-
let
|
| 276 |
-
if(
|
| 277 |
-
const
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
| 285 |
}
|
| 286 |
-
document.getElementById("staggertext").innerHTML
|
| 287 |
-
document.getElementById("diversifytext").innerHTML
|
| 288 |
-
|
| 289 |
-
firstPaint = false;
|
| 290 |
}
|
| 291 |
|
| 292 |
-
function esc(s){ return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
| 293 |
-
|
| 294 |
async function tick(){
|
| 295 |
-
try {
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
} catch(e){
|
| 299 |
-
const dot = document.getElementById("dot");
|
| 300 |
-
dot.className = "dot err";
|
| 301 |
-
document.getElementById("statustext").textContent = "dashboard offline — " + e;
|
| 302 |
-
}
|
| 303 |
}
|
| 304 |
-
tick();
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
</script>
|
| 307 |
</body>
|
| 308 |
</html>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>MURMUR — live swarm</title>
|
| 7 |
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#06070b; --glass:rgba(20,24,34,.62); --glass2:rgba(26,31,44,.55); --line:rgba(120,140,180,.14);
|
| 10 |
+
--ink:#eaf0f8; --dim:#93a0b4; --dimmer:#5d6a7e;
|
| 11 |
+
--cyan:#3ad9e0; --magenta:#e070d0; --green:#5be37a; --amber:#ffcb5e; --red:#ff6b6b; --violet:#9b8cff;
|
|
|
|
| 12 |
}
|
| 13 |
+
*{box-sizing:border-box}
|
| 14 |
+
html,body{height:100%}
|
| 15 |
+
body{
|
| 16 |
+
margin:0; height:100vh; overflow:hidden; display:flex; flex-direction:column;
|
| 17 |
+
background:radial-gradient(1200px 700px at 78% -10%, rgba(58,217,224,.08) 0, transparent 60%), var(--bg);
|
| 18 |
+
color:var(--ink); font:13.5px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
| 19 |
+
-webkit-font-smoothing:antialiased;
|
| 20 |
}
|
| 21 |
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
| 22 |
+
#flock{position:fixed; inset:0; z-index:0; pointer-events:none; opacity:.55}
|
| 23 |
+
.vignette{position:fixed; inset:0; z-index:1; pointer-events:none;
|
| 24 |
+
background:radial-gradient(120% 90% at 50% 0%, transparent 55%, rgba(0,0,0,.5) 100%)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
/* ---- app shell ---- */
|
| 27 |
+
header{flex:none; z-index:6; display:flex; align-items:center; gap:16px; flex-wrap:wrap;
|
| 28 |
+
padding:11px 22px; border-bottom:1px solid var(--line);
|
| 29 |
+
background:linear-gradient(180deg, rgba(8,10,16,.85), rgba(8,10,16,.45)); backdrop-filter:blur(10px)}
|
| 30 |
+
.brand{display:flex; align-items:baseline; gap:11px}
|
| 31 |
+
.brand h1{margin:0; font-size:22px; font-weight:800; letter-spacing:2.5px;
|
| 32 |
+
background:linear-gradient(90deg,var(--cyan),var(--violet) 55%,var(--magenta));
|
| 33 |
+
-webkit-background-clip:text; background-clip:text; color:transparent; text-shadow:0 0 28px rgba(58,217,224,.25)}
|
| 34 |
+
.brand .tag{color:var(--dim); font-size:11px; letter-spacing:.3px}
|
| 35 |
+
@media(max-width:880px){ .brand .tag{display:none} }
|
| 36 |
+
.status{display:flex; align-items:center; gap:8px; color:var(--dim); font-size:12px}
|
| 37 |
+
.dot{width:9px; height:9px; border-radius:50%; background:var(--dimmer); flex:none}
|
| 38 |
+
.dot.live{background:var(--green); box-shadow:0 0 12px var(--green); animation:pulse 1.8s infinite}
|
| 39 |
+
.dot.mock{background:var(--amber); box-shadow:0 0 12px var(--amber)}
|
| 40 |
+
.dot.err{background:var(--red); box-shadow:0 0 12px var(--red)}
|
| 41 |
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
| 42 |
+
.spacer{flex:1}
|
| 43 |
+
.pill{font-size:11px; padding:3px 9px; border-radius:999px; border:1px solid var(--line);
|
| 44 |
+
color:var(--dim); background:rgba(255,255,255,.02); white-space:nowrap}
|
| 45 |
+
.pill b{color:var(--ink); font-weight:700}
|
| 46 |
+
.pill.live{color:var(--green); border-color:rgba(91,227,122,.35)}
|
| 47 |
+
.pill.derived{color:var(--cyan); border-color:rgba(58,217,224,.30)}
|
| 48 |
+
.pill.mock{color:var(--amber); border-color:rgba(255,203,94,.30)}
|
| 49 |
|
| 50 |
+
main{flex:1; min-height:0; z-index:2; position:relative;
|
| 51 |
+
display:grid; grid-template-columns:1.55fr 1fr; gap:16px; padding:16px 22px; overflow:hidden}
|
| 52 |
+
@media(max-width:1000px){ main{grid-template-columns:1fr; overflow:auto} }
|
| 53 |
+
.col{display:flex; flex-direction:column; gap:16px; min-height:0; min-width:0}
|
| 54 |
+
section{display:flex; flex-direction:column; min-height:0}
|
| 55 |
+
section h2{flex:none; font-size:10.5px; text-transform:uppercase; letter-spacing:1.5px; color:var(--dim);
|
| 56 |
+
margin:0 0 9px; display:flex; align-items:center; gap:9px}
|
| 57 |
+
.count{font-size:10px; color:var(--dim); border:1px solid var(--line); border-radius:999px; padding:2px 8px}
|
| 58 |
+
.panel{flex:1; min-height:0; background:var(--glass); border:1px solid var(--line); border-radius:14px;
|
| 59 |
+
backdrop-filter:blur(7px); box-shadow:0 18px 50px -30px rgba(0,0,0,.9); display:flex; flex-direction:column}
|
| 60 |
+
.scroll{overflow:auto; min-height:0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
/* heights: left col = swarm + feed; right col = signals (grow) + budget + coord (compact) */
|
| 63 |
+
.s-swarm{flex:1 1 42%}
|
| 64 |
+
.s-feed{flex:1 1 58%}
|
| 65 |
+
.s-signals{flex:1 1 auto}
|
| 66 |
+
.s-fixed{flex:none}
|
| 67 |
+
|
| 68 |
+
/* ---- swarm tiles ---- */
|
| 69 |
+
.tiles{display:grid; gap:11px; grid-template-columns:repeat(auto-fill,minmax(168px,1fr)); align-content:start; padding:13px}
|
| 70 |
+
.tile{position:relative; padding:12px 13px; border-radius:13px; overflow:hidden;
|
| 71 |
+
background:var(--glass2); border:1px solid var(--line); transition:transform .25s ease, box-shadow .6s ease, border-color .6s ease}
|
| 72 |
+
.tile::before{content:""; position:absolute; inset:0; opacity:0; transition:opacity .6s ease;
|
| 73 |
+
background:radial-gradient(120% 80% at 50% 0%, var(--tc,var(--cyan)), transparent 70%)}
|
| 74 |
+
.tile.active{border-color:color-mix(in srgb, var(--tc,var(--cyan)) 55%, transparent)}
|
| 75 |
+
.tile.active::before{opacity:.10}
|
| 76 |
+
.tile.flash{transform:translateY(-3px); box-shadow:0 0 0 1px var(--tc,var(--cyan)), 0 16px 38px -18px var(--tc,var(--cyan))}
|
| 77 |
+
.tile.flash::before{opacity:.30}
|
| 78 |
+
.tile .name{font-weight:800; font-size:14px}
|
| 79 |
+
.tile .genre{font-size:9.5px; color:var(--tc,var(--magenta)); text-transform:uppercase; letter-spacing:.9px; margin-top:2px}
|
| 80 |
+
.tile .aid{font-size:9.5px; margin-top:3px; color:var(--tc,var(--cyan)); letter-spacing:.3px; overflow-wrap:anywhere; opacity:.95}
|
| 81 |
+
.tile .aid .self{display:inline-block; margin-left:6px; font-size:8px; letter-spacing:.5px; text-transform:uppercase;
|
| 82 |
+
color:var(--green); background:rgba(91,227,122,.12); border:1px solid rgba(91,227,122,.32); border-radius:5px; padding:1px 5px; vertical-align:middle}
|
| 83 |
+
.tile .persona{color:var(--dim); font-size:10.5px; margin-top:6px; min-height:28px; opacity:.8;
|
| 84 |
+
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden}
|
| 85 |
+
.tile .last{margin-top:8px; font-size:10.5px; color:var(--dim); border-top:1px solid var(--line); padding-top:6px;
|
| 86 |
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
|
| 87 |
+
.tile .last .s{color:var(--ink)}
|
| 88 |
+
.tile .tdot{position:absolute; top:12px; right:12px; width:6px; height:6px; border-radius:50%; background:var(--dimmer)}
|
| 89 |
+
.tile.active .tdot{background:var(--tc,var(--cyan)); box-shadow:0 0 9px var(--tc,var(--cyan))}
|
| 90 |
|
| 91 |
+
/* ---- feed ---- */
|
| 92 |
+
.feed{flex:1}
|
| 93 |
+
.frow{display:grid; grid-template-columns:92px 1fr 78px; gap:11px; align-items:center;
|
| 94 |
+
padding:8px 14px; border-bottom:1px solid var(--line)}
|
| 95 |
+
.frow.new{animation:rowin 1.1s ease}
|
| 96 |
+
@keyframes rowin{from{background:rgba(58,217,224,.13); transform:translateX(-6px)} to{background:transparent; transform:none}}
|
| 97 |
+
.frow .acct{font-size:11.5px; font-weight:700; color:var(--tc,var(--cyan)); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
|
| 98 |
+
.frow .subj{color:var(--ink)}
|
| 99 |
+
.frow .vs{color:var(--dim); font-size:10px; margin-left:5px}
|
| 100 |
+
.frow .vs b{color:var(--magenta); font-weight:600}
|
| 101 |
+
.frow .right{text-align:right}
|
| 102 |
+
.frow .perf{font-size:11px; color:var(--amber); font-weight:700}
|
| 103 |
+
.ptrack{height:4px; border-radius:3px; background:rgba(255,255,255,.06); margin-top:3px; overflow:hidden}
|
| 104 |
+
.pfill{height:100%; border-radius:3px; background:linear-gradient(90deg,var(--cyan),var(--green)); transition:width .8s ease}
|
| 105 |
+
.frow .when{font-size:9.5px; color:var(--dimmer)}
|
| 106 |
+
.frow{align-items:start}
|
| 107 |
+
.frow .mid{min-width:0}
|
| 108 |
+
.frow .subjline{display:flex; align-items:baseline; gap:7px; flex-wrap:wrap}
|
| 109 |
+
.frow .body{color:var(--dim); font-size:10.5px; margin-top:2px; opacity:.8; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
|
| 110 |
+
.cos{font-size:10px; color:var(--dim); white-space:nowrap}
|
| 111 |
+
.cos b{font-weight:600; color:var(--dim)}
|
| 112 |
+
.cos.distinct, .cos.distinct b{color:var(--green)}
|
| 113 |
+
.cos.close, .cos.close b{color:var(--amber)}
|
| 114 |
+
.cos.dup, .cos.dup b{color:var(--red)}
|
| 115 |
+
.proof{flex:none; padding:8px 14px; color:var(--dimmer); font-size:10.5px; border-top:1px solid var(--line)}
|
| 116 |
+
.proof code{color:var(--cyan)}
|
| 117 |
|
| 118 |
+
/* ---- signals ---- */
|
| 119 |
+
.signals{gap:9px; padding:11px; display:flex; flex-direction:column}
|
| 120 |
+
.sig{position:relative; padding:10px 12px 10px 14px; border-radius:11px; flex:none;
|
| 121 |
+
background:var(--glass2); border:1px solid var(--line); overflow:hidden}
|
| 122 |
+
.sig::before{content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--sc,var(--cyan))}
|
| 123 |
+
.sig.new{animation:sigin 1s ease}
|
| 124 |
+
@keyframes sigin{from{transform:translateY(-8px)} to{transform:none}}
|
| 125 |
+
.sig .head{display:flex; align-items:center; gap:8px; font-size:9.5px; letter-spacing:1.1px; text-transform:uppercase}
|
| 126 |
+
.sig .kind{color:var(--sc,var(--cyan)); font-weight:800}
|
| 127 |
+
.sig .head .t{color:var(--dimmer); margin-left:auto; letter-spacing:0; text-transform:none; font-size:9.5px}
|
| 128 |
+
.sig .body{margin-top:5px; font-size:12.5px; color:var(--ink)}
|
| 129 |
+
.sig .sub{margin-top:3px; font-size:10.5px; color:var(--dim)}
|
| 130 |
+
.sig .big{font-size:18px; font-weight:800; color:var(--sc); letter-spacing:.3px}
|
| 131 |
+
.sig .arrow{color:var(--green); font-weight:700}
|
| 132 |
+
|
| 133 |
+
/* ---- operator + coord ---- */
|
| 134 |
+
.ops{padding:13px}
|
| 135 |
+
.ops .top{font-size:11.5px; color:var(--dim); margin-bottom:10px}
|
| 136 |
+
.ops .top b{color:var(--amber)}
|
| 137 |
+
.bar{margin-bottom:9px} .bar:last-child{margin-bottom:0}
|
| 138 |
+
.bar .lab{display:flex; justify-content:space-between; font-size:11.5px; margin-bottom:4px}
|
| 139 |
+
.bar .lab .t{color:var(--ink)} .bar .lab .t.spike{color:var(--amber); font-weight:800}
|
| 140 |
+
.bar .lab .v{color:var(--dim)}
|
| 141 |
+
.track{height:8px; border-radius:6px; background:rgba(255,255,255,.06); overflow:hidden}
|
| 142 |
+
.fill{height:100%; border-radius:6px; background:var(--cyan); transition:width .9s cubic-bezier(.2,.8,.2,1)}
|
| 143 |
+
.fill.spike{background:linear-gradient(90deg,var(--amber),var(--magenta)); box-shadow:0 0 14px rgba(255,203,94,.4)}
|
| 144 |
+
.coord{padding:12px 13px; font-size:11.5px; color:var(--dim)}
|
| 145 |
+
.coord .row{display:flex; gap:8px; align-items:flex-start; margin-bottom:8px}
|
| 146 |
+
.coord .row:last-child{margin-bottom:0}
|
| 147 |
+
.chip{font-size:9px; letter-spacing:.6px; text-transform:uppercase; padding:2px 7px; border-radius:6px; font-weight:700; flex:none}
|
| 148 |
+
.chip.stagger{color:var(--cyan); background:rgba(58,217,224,.12)}
|
| 149 |
+
.chip.diversify{color:var(--magenta); background:rgba(224,112,208,.12)}
|
| 150 |
+
.legend{color:var(--dimmer); font-size:10px; margin-top:7px}
|
| 151 |
+
.err{color:var(--red); font-size:10.5px}
|
| 152 |
+
::-webkit-scrollbar{width:8px; height:8px} ::-webkit-scrollbar-thumb{background:rgba(120,140,180,.18); border-radius:6px}
|
| 153 |
</style>
|
| 154 |
</head>
|
| 155 |
<body>
|
| 156 |
+
<canvas id="flock"></canvas>
|
| 157 |
+
<div class="vignette"></div>
|
| 158 |
+
|
| 159 |
<header>
|
| 160 |
+
<div class="brand"><h1>MURMUR</h1><span class="tag">autonomous swarm · live on Aiven · every action through the MCP</span></div>
|
| 161 |
+
<div class="status"><span id="dot" class="dot"></span><span id="statustext">connecting…</span></div>
|
|
|
|
|
|
|
|
|
|
| 162 |
<span class="spacer"></span>
|
| 163 |
+
<span class="pill" id="p-acc">agents —</span>
|
| 164 |
<span class="pill" id="p-posts">posts —</span>
|
| 165 |
+
<span class="pill" id="p-sig">signals —</span>
|
| 166 |
+
<span class="pill mono" id="p-clock">—</span>
|
| 167 |
</header>
|
| 168 |
|
| 169 |
<main>
|
| 170 |
<div class="col">
|
| 171 |
+
<section class="s-swarm">
|
| 172 |
+
<h2>The swarm <span class="count" id="tilecount">0 agents</span></h2>
|
| 173 |
+
<div class="panel"><div class="tiles scroll" id="tiles"></div></div>
|
| 174 |
</section>
|
| 175 |
+
<section class="s-feed">
|
| 176 |
+
<h2>Live feed <span class="count mono">posts · newest first</span></h2>
|
| 177 |
+
<div class="panel">
|
| 178 |
+
<div class="feed scroll" id="feed"></div>
|
| 179 |
+
<div class="proof">every row = one real Postgres row · <span style="color:var(--magenta)">↯</span> = live pgvector cosine to nearest peer hook (agents reject ≥ 0.80) · <code>SELECT * FROM posts</code></div>
|
| 180 |
+
</div>
|
| 181 |
</section>
|
| 182 |
</div>
|
| 183 |
|
| 184 |
<div class="col">
|
| 185 |
+
<section class="s-signals">
|
| 186 |
+
<h2>Signals · autonomous decisions <span class="count" id="sigcount">—</span></h2>
|
| 187 |
+
<div class="panel signals scroll" id="signals"></div>
|
|
|
|
| 188 |
</section>
|
| 189 |
+
<section class="s-fixed">
|
| 190 |
+
<h2>Amplify · ad budget</h2>
|
| 191 |
+
<div class="panel ops" id="ops" style="flex:none"></div>
|
| 192 |
+
</section>
|
| 193 |
+
<section class="s-fixed">
|
| 194 |
<h2>Coordination</h2>
|
| 195 |
+
<div class="panel coord" style="flex:none">
|
| 196 |
+
<div class="row"><span class="chip stagger">stagger</span><span id="staggertext">—</span></div>
|
| 197 |
+
<div class="row"><span class="chip diversify">diversify</span><span id="diversifytext">—</span></div>
|
| 198 |
+
<div class="legend">No controller, no human — agents read the shared Kafka stream, stagger timing, and pgvector-diversify (reject ≥ 0.80 cosine to any peer).</div>
|
| 199 |
</div>
|
| 200 |
</section>
|
| 201 |
</div>
|
|
|
|
| 203 |
|
| 204 |
<script>
|
| 205 |
const POLL_MS = 2000;
|
| 206 |
+
const PALETTE = ["#3ad9e0","#e070d0","#5be37a","#ffcb5e","#9b8cff"];
|
| 207 |
+
let lastByAccount = {}, seenPosts = new Set(), seenSigs = new Set(), first = true;
|
|
|
|
| 208 |
|
| 209 |
+
const esc = s => String(s==null?"":s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
| 210 |
+
const clock = iso => iso ? new Date(iso).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"}) : "—";
|
| 211 |
+
const ago = iso => { if(!iso) return ""; const s=Math.max(0,(Date.now()-new Date(iso).getTime())/1000|0);
|
| 212 |
+
return s<60?s+"s":s<3600?(s/60|0)+"m":(s/3600|0)+"h"; };
|
| 213 |
+
const short = p => !p ? "?" : p.split("—")[0].split("–")[0].trim().split(" ")[0];
|
| 214 |
+
const setPill = (id,label,src) => { const e=document.getElementById(id); src=src||"—";
|
| 215 |
+
e.innerHTML = label+" <b>"+esc(src)+"</b>";
|
| 216 |
+
e.className = "pill "+(src.startsWith("live")?"live":src.startsWith("derived")?"derived":"mock"); };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
function render(s){
|
| 219 |
+
const sig = s.signals || [];
|
| 220 |
+
const dot=document.getElementById("dot"), st=document.getElementById("statustext");
|
| 221 |
+
if(s.error){ dot.className="dot err"; st.innerHTML='DB error — mock. <span class="err mono">'+esc(s.error)+'</span>'; }
|
| 222 |
+
else if(s.mode==="live"){ dot.className="dot live"; st.textContent="live · polling pg-conductor every "+(POLL_MS/1000)+"s"; }
|
| 223 |
+
else { dot.className="dot mock"; st.textContent="mock preview · set DATABASE_URL to go live"; }
|
| 224 |
+
document.getElementById("p-clock").textContent = clock(s.generated_at);
|
| 225 |
+
setPill("p-acc","agents", s.sources && s.sources.accounts);
|
| 226 |
+
setPill("p-posts","posts", s.sources && s.sources.posts);
|
| 227 |
+
setPill("p-sig","signals", (s.sources && s.sources.signals) || "none");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
+
const cidx={}; s.accounts.forEach((a,i)=>cidx[a.id]=PALETTE[i%PALETTE.length]);
|
| 230 |
+
const latest={}; for(const p of s.posts){ if(!(p.account_id in latest)) latest[p.account_id]=p; }
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
// tiles
|
| 233 |
+
const tiles=document.getElementById("tiles"); tiles.innerHTML="";
|
| 234 |
+
s.accounts.forEach(a=>{
|
| 235 |
+
const lp=latest[a.id], col=cidx[a.id];
|
| 236 |
+
const changed=lp && lastByAccount[a.id]!==undefined && lastByAccount[a.id]!==lp.id && !first;
|
| 237 |
+
const d=document.createElement("div");
|
| 238 |
+
d.className="tile"+(lp?" active":"")+(changed?" flash":""); d.style.setProperty("--tc",col);
|
| 239 |
+
// self-provisioned = onboarded via the MCP (segment id, not the seeded acct_NN / mock acct_a/b)
|
| 240 |
+
const self = /^acct_/.test(a.id) && !/^acct_\d+$/.test(a.id) && !/^acct_[ab]$/.test(a.id);
|
| 241 |
+
d.innerHTML='<span class="tdot"></span>'+
|
| 242 |
+
'<div class="name">'+esc(short(a.persona)||a.id)+'</div>'+
|
| 243 |
+
'<div class="aid mono">'+esc(a.id)+(self?'<span class="self">self-provisioned</span>':'')+'</div>'+
|
| 244 |
+
'<div class="persona" title="'+esc(a.persona||"")+'">'+esc(a.persona||"")+'</div>'+
|
| 245 |
+
(lp?'<div class="last" title="'+esc(lp.subject||"")+'">latest: <span class="s">'+esc(lp.subject||"—")+'</span> · '+ago(lp.created_at)+'</div>'
|
| 246 |
+
:'<div class="last">quiet — no posts yet</div>');
|
| 247 |
+
tiles.appendChild(d);
|
| 248 |
+
if(changed) setTimeout(()=>d.classList.remove("flash"),1100);
|
| 249 |
+
});
|
| 250 |
+
document.getElementById("tilecount").textContent=s.accounts.length+" agents";
|
| 251 |
+
const nl={}; for(const a of s.accounts) if(latest[a.id]) nl[a.id]=latest[a.id].id; lastByAccount=nl;
|
| 252 |
+
|
| 253 |
+
// feed
|
| 254 |
+
const feed=document.getElementById("feed"); feed.innerHTML="";
|
| 255 |
+
const now=new Set();
|
| 256 |
+
if(!s.posts.length) feed.innerHTML='<div class="frow"><span></span><span class="vs">waiting for the swarm…</span><span></span></div>';
|
| 257 |
+
s.posts.forEach(p=>{
|
| 258 |
+
const id=String(p.id); now.add(id); const isNew=!first&&!seenPosts.has(id);
|
| 259 |
+
const perf=p.performance!=null?Math.round(p.performance):null;
|
| 260 |
+
const r=document.createElement("div"); r.className="frow"+(isNew?" new":""); r.style.setProperty("--tc",cidx[p.account_id]||"#3ad9e0");
|
| 261 |
+
// live pgvector cosine to nearest peer (lower = more distinct); fall back to stored diversified_from
|
| 262 |
+
let cos="";
|
| 263 |
+
if(p.sim!=null){
|
| 264 |
+
const cls = p.sim>=0.80 ? "dup" : p.sim>=0.70 ? "close" : "distinct";
|
| 265 |
+
const tip = "live pgvector cosine to nearest peer"+(p.near_subject?": “"+esc(p.near_subject)+"”":"");
|
| 266 |
+
cos='<span class="cos '+cls+'" title="'+tip+'">↯ '+p.sim.toFixed(2)+(p.sim>=0.999?" collision":"")+' vs <b>'+esc(p.near||"?")+'</b></span>';
|
| 267 |
+
} else if(p.diversified_from){
|
| 268 |
+
cos='<span class="cos">↯ vs <b>'+esc(p.diversified_from)+'</b></span>';
|
| 269 |
}
|
| 270 |
+
const body = p.body?'<div class="body" title="'+esc(p.body)+'">'+esc(p.body)+'</div>':"";
|
| 271 |
+
r.innerHTML='<span class="acct mono" title="'+esc(p.account_id||"")+'">'+esc(p.account_id||"?")+'</span>'+
|
| 272 |
+
'<span class="mid"><span class="subjline"><span class="subj">'+(p.subject?esc(p.subject):'<span class="vs">(post)</span>')+'</span>'+cos+'</span>'+
|
| 273 |
+
body+
|
| 274 |
+
(perf!=null?'<div class="ptrack"><div class="pfill" style="width:'+Math.min(100,perf)+'%"></div></div>':'')+'</span>'+
|
| 275 |
+
'<span class="right"><div class="perf">'+(perf!=null?perf:"—")+'</div><div class="when mono">'+clock(p.created_at)+'</div></span>';
|
| 276 |
+
feed.appendChild(r);
|
|
|
|
| 277 |
});
|
| 278 |
+
seenPosts=now;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
+
// signals
|
| 281 |
+
const SC={trend:"#3ad9e0", amplify:"#ffcb5e", optimize:"#e070d0"};
|
| 282 |
+
const rail=document.getElementById("signals"); rail.innerHTML="";
|
| 283 |
+
document.getElementById("sigcount").textContent = sig.length ? sig.length+" recent" : "none yet";
|
| 284 |
+
if(!sig.length) rail.innerHTML='<div class="sig" style="--sc:#5d6a7e"><div class="body" style="color:var(--dim)">No signals yet — run a round (trend+amplify) or <span class="mono">--tier2</span> (optimize).</div></div>';
|
| 285 |
+
const nowS=new Set();
|
| 286 |
+
sig.forEach(x=>{
|
| 287 |
+
const p=x.payload||{}, k=x.kind||p.type||"signal";
|
| 288 |
+
const key=k+"|"+(x.ts||"")+"|"+(p.theme||p.query||""); nowS.add(key);
|
| 289 |
+
const isNew=!first&&!seenSigs.has(key);
|
| 290 |
+
let head, body, sub="";
|
| 291 |
+
if(k==="trend"){ head="Trend detected";
|
| 292 |
+
body='“'+esc(p.theme)+'” is resonating';
|
| 293 |
+
sub='avg performance '+esc(p.avg_performance)+'/100 · '+((p.members||[]).length)+' agents · leader '+esc(p.leader); }
|
| 294 |
+
else if(k==="amplify"){ head="Amplify · ad budget";
|
| 295 |
+
body='<span class="big">$'+esc(p.budget_usd)+'</span> → “'+esc(p.theme)+'”';
|
| 296 |
+
sub=esc(p.account_id)+' · '+esc(p.reason||""); }
|
| 297 |
+
else if(k==="optimize"){ head="Self-optimize · pgvector";
|
| 298 |
+
body=esc(p.scan_before)+' '+esc(p.before_ms)+'ms <span class="arrow">→</span> '+esc(p.scan_after)+' '+esc(p.after_ms)+'ms';
|
| 299 |
+
sub='<b style="color:var(--green)">'+esc(p.speedup)+'× faster</b> · recall '+esc(p.recall_pct)+'% · '+esc(p.index)+' index @ '+esc(p.rows)+' rows'; }
|
| 300 |
+
else { head=esc(k); body=esc(JSON.stringify(p)).slice(0,120); }
|
| 301 |
+
const d=document.createElement("div"); d.className="sig"+(isNew?" new":""); d.style.setProperty("--sc",SC[k]||"#9b8cff");
|
| 302 |
+
d.innerHTML='<div class="head"><span class="kind">'+esc(head)+'</span><span class="t mono">'+ago(x.ts)+'</span></div>'+
|
| 303 |
+
'<div class="body">'+body+'</div>'+(sub?'<div class="sub">'+sub+'</div>':'');
|
| 304 |
+
rail.appendChild(d);
|
| 305 |
});
|
| 306 |
+
seenSigs=nowS;
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
+
// operator: themes COMPETE by performance momentum; the theme the agent actually amplified
|
| 309 |
+
// spikes and shows its real ad budget. Always multi-bar so the budget *shift* is visible.
|
| 310 |
+
const ops=document.getElementById("ops");
|
| 311 |
+
const o=s.ops||{themes:[]};
|
| 312 |
+
let bars=(o.themes||[]).slice(0,5).map(t=>({theme:t.theme, val:(t.momentum!=null?t.momentum:t.budget)||0}));
|
| 313 |
+
const amp=sig.filter(x=>(x.kind||(x.payload&&x.payload.type))==="amplify").map(x=>x.payload||{});
|
| 314 |
+
const a0=amp.find(p=>p.theme);
|
| 315 |
+
let winner=o.top||(bars[0]&&bars[0].theme)||"—", budget=null;
|
| 316 |
+
if(a0){ winner=a0.theme; budget=+a0.budget_usd||0;
|
| 317 |
+
if(!bars.some(b=>b.theme===winner)) bars.unshift({theme:winner, val:Math.max(1,...bars.map(b=>b.val||0))}); }
|
| 318 |
+
bars=bars.slice(0,5); bars.forEach(b=>b.spike=(b.theme===winner));
|
| 319 |
+
if(bars.length && !bars.some(b=>b.spike)){ bars[0].spike=true; winner=bars[0].theme; }
|
| 320 |
+
const max=Math.max(1,...bars.map(b=>b.val));
|
| 321 |
+
const src = budget!=null ? "live · agent put $"+budget.toLocaleString()+" behind it" : (o.source||"derived from performance");
|
| 322 |
+
let html='<div class="top">resonating: <b>'+esc(winner)+'</b> · <span class="mono" style="font-size:9.5px">'+esc(src)+'</span></div>';
|
| 323 |
+
for(const b of bars){ const w=Math.round(100*(b.val||0)/max);
|
| 324 |
+
const tag=b.spike?(budget!=null?' ◀ $'+budget.toLocaleString():' ◀ amplifying'):'';
|
| 325 |
+
const rightv=b.spike&&budget!=null?'$'+budget.toLocaleString():Math.round(b.val).toLocaleString();
|
| 326 |
+
html+='<div class="bar"><div class="lab"><span class="t'+(b.spike?" spike":"")+'">'+esc(b.theme)+tag+'</span><span class="v">'+rightv+'</span></div>'+
|
| 327 |
+
'<div class="track"><div class="fill'+(b.spike?" spike":"")+'" style="width:'+w+'%"></div></div></div>'; }
|
| 328 |
+
if(!bars.length) html+='<div class="legend">no posts yet — run a round</div>';
|
| 329 |
+
ops.innerHTML=html;
|
| 330 |
|
| 331 |
+
// coordination
|
| 332 |
+
const ps=s.posts.slice().sort((a,b)=>new Date(a.created_at)-new Date(b.created_at));
|
| 333 |
+
let sg="—", dv="—";
|
| 334 |
+
if(ps.length>=2){ const a=ps[ps.length-2], b=ps[ps.length-1];
|
| 335 |
+
const gap=Math.abs((new Date(b.created_at)-new Date(a.created_at))/1000)|0;
|
| 336 |
+
sg=esc(a.account_id)+" → "+esc(b.account_id)+", "+gap+"s apart (no collision)";
|
| 337 |
+
dv=a.subject&&b.subject?'“'+esc(a.subject)+'” → “'+esc(b.subject)+'” — different hooks':"distinct hooks per agent"; }
|
| 338 |
+
// real pgvector readout: cosine range + the single most-distinct hook this round
|
| 339 |
+
const sims=s.posts.filter(p=>p.sim!=null);
|
| 340 |
+
if(sims.length){
|
| 341 |
+
const lo=Math.min(...sims.map(p=>p.sim)), hi=Math.max(...sims.map(p=>p.sim));
|
| 342 |
+
const m=sims.slice().sort((a,b)=>a.sim-b.sim)[0];
|
| 343 |
+
dv='nearest-peer cosine <b style="color:var(--ink)">'+lo.toFixed(2)+'–'+hi.toFixed(2)+'</b> · most distinct: “'+esc(m.subject)+'” <b style="color:var(--green)">'+m.sim.toFixed(2)+'</b>';
|
| 344 |
}
|
| 345 |
+
document.getElementById("staggertext").innerHTML=sg;
|
| 346 |
+
document.getElementById("diversifytext").innerHTML=dv;
|
| 347 |
+
first=false;
|
|
|
|
| 348 |
}
|
| 349 |
|
|
|
|
|
|
|
| 350 |
async function tick(){
|
| 351 |
+
try{ const r=await fetch("/api/state",{cache:"no-store"}); render(await r.json()); }
|
| 352 |
+
catch(e){ const d=document.getElementById("dot"); d.className="dot err";
|
| 353 |
+
document.getElementById("statustext").textContent="dashboard offline — "+e; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
}
|
| 355 |
+
tick(); setInterval(tick, POLL_MS);
|
| 356 |
+
|
| 357 |
+
/* ---- murmuration background (lightweight boids) ---- */
|
| 358 |
+
(function(){
|
| 359 |
+
const cv=document.getElementById("flock"), ctx=cv.getContext("2d");
|
| 360 |
+
let W,H,DPR; const COL=["58,217,224","224,112,208","155,140,255"];
|
| 361 |
+
const N=Math.min(170, Math.max(70, Math.round((innerWidth*innerHeight)/12000))); let B=[];
|
| 362 |
+
function size(){ DPR=Math.min(2,devicePixelRatio||1); W=cv.width=innerWidth*DPR; H=cv.height=innerHeight*DPR;
|
| 363 |
+
cv.style.width=innerWidth+"px"; cv.style.height=innerHeight+"px"; }
|
| 364 |
+
size(); addEventListener("resize", size);
|
| 365 |
+
for(let i=0;i<N;i++) B.push({x:Math.random()*W,y:Math.random()*H,
|
| 366 |
+
vx:(Math.random()-.5)*0.5*DPR, vy:(Math.random()-.5)*0.5*DPR, c:COL[i%COL.length]});
|
| 367 |
+
const R2=(70*DPR)**2, MAX=1.1*DPR, SEP2=(26*DPR)**2, LINK2=(34*DPR)**2;
|
| 368 |
+
function step(){
|
| 369 |
+
ctx.clearRect(0,0,W,H);
|
| 370 |
+
for(let i=0;i<B.length;i++){ const a=B[i]; let ax=0,ay=0,cx=0,cy=0,sx=0,sy=0,n=0;
|
| 371 |
+
for(let j=0;j<B.length;j++){ if(i===j) continue; const b=B[j]; const dx=b.x-a.x, dy=b.y-a.y; const d2=dx*dx+dy*dy;
|
| 372 |
+
if(d2<R2){ n++; ax+=b.vx; ay+=b.vy; cx+=b.x; cy+=b.y; if(d2<SEP2){ sx-=dx; sy-=dy; }
|
| 373 |
+
if(d2<LINK2){ ctx.strokeStyle="rgba("+a.c+",0.05)"; ctx.lineWidth=DPR*0.5; ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); } } }
|
| 374 |
+
if(n){ a.vx+=((ax/n)-a.vx)*0.04+(cx/n-a.x)*0.0006+sx*0.004; a.vy+=((ay/n)-a.vy)*0.04+(cy/n-a.y)*0.0006+sy*0.004; }
|
| 375 |
+
const sp=Math.hypot(a.vx,a.vy)||1; if(sp>MAX){ a.vx=a.vx/sp*MAX; a.vy=a.vy/sp*MAX; }
|
| 376 |
+
a.x+=a.vx; a.y+=a.vy;
|
| 377 |
+
if(a.x<0)a.x+=W; if(a.x>W)a.x-=W; if(a.y<0)a.y+=H; if(a.y>H)a.y-=H;
|
| 378 |
+
ctx.fillStyle="rgba("+a.c+",0.9)"; ctx.beginPath(); ctx.arc(a.x,a.y,1.4*DPR,0,6.283); ctx.fill();
|
| 379 |
+
}
|
| 380 |
+
requestAnimationFrame(step);
|
| 381 |
+
}
|
| 382 |
+
step();
|
| 383 |
+
})();
|
| 384 |
</script>
|
| 385 |
</body>
|
| 386 |
</html>
|
murmur.py
CHANGED
|
@@ -36,10 +36,11 @@ Transport: connects to the hosted Aiven MCP (https://mcp.aiven.live/mcp) using A
|
|
| 36 |
as a bearer; if that endpoint lacks the write tools (read-only), it falls back to spawning
|
| 37 |
the bundled local server (./mcp-aiven, built with `npm install && npm run build`) over stdio.
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
(
|
| 42 |
-
|
|
|
|
| 43 |
"""
|
| 44 |
|
| 45 |
import asyncio
|
|
@@ -255,6 +256,13 @@ async def store_post(session, account_id, subject, body, performance, diversifie
|
|
| 255 |
f"VALUES ('{sql_str(account_id)}', '{sql_str(subject)}', '{sql_str(body)}', "
|
| 256 |
f"{perf}, {div}, '{sql_str(scheduled_for)}')")
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
# --- Tier 2 observe/optimize MCP wrappers ---
|
| 259 |
async def query_stats(session, order_by="total_time:desc", limit=5, search=None):
|
| 260 |
args = {"project": PROJECT, "service_name": PG_SVC, "order_by": order_by, "limit": limit}
|
|
@@ -305,6 +313,26 @@ def persona_system(me):
|
|
| 305 |
f"fleet. Your genre is '{me['genre']}'. You think briefly, in character, then act. "
|
| 306 |
f"Always answer with a single JSON object only.")
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
# ----------------------------------------------------------------------------- setup (idempotent)
|
| 309 |
async def ensure_schema(session):
|
| 310 |
"""Create the tables, pgvector extension, and shared topics — idempotent, all via MCP."""
|
|
@@ -322,6 +350,9 @@ async def ensure_schema(session):
|
|
| 322 |
"CREATE TABLE IF NOT EXISTS posts (id bigserial PRIMARY KEY, "
|
| 323 |
"account_id text REFERENCES accounts(id), subject text, body text, performance numeric, "
|
| 324 |
"diversified_from text, scheduled_for text, created_at timestamptz NOT NULL DEFAULT now())")
|
|
|
|
|
|
|
|
|
|
| 325 |
for topic in (POSTS, SIGNALS):
|
| 326 |
try: # topics are usually pre-created; tolerate "already exists"
|
| 327 |
await call(session, "aiven_kafka_topic_create", {
|
|
@@ -448,10 +479,11 @@ async def detect_and_amplify(session, since_db):
|
|
| 448 |
for r in members:
|
| 449 |
print(f" {DIM}{r['account_id']:8} sim={r['sim_to_leader']} perf={r['performance']} {r['subject']}{RESET}")
|
| 450 |
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
| 455 |
|
| 456 |
# amplify: the theme owner allocates ad budget and doubles down (legit marketing ops)
|
| 457 |
me = await load_account(session, leader_acct)
|
|
@@ -473,10 +505,11 @@ async def detect_and_amplify(session, since_db):
|
|
| 473 |
await pg_write(session,
|
| 474 |
f"INSERT INTO events (account_id, type, topic) VALUES ('{sql_str(leader_acct)}', 'amplify', '{SIGNALS}')")
|
| 475 |
await store_post(session, leader_acct, theme, text, avg_perf, None, "now (amplified)")
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
|
|
|
| 480 |
return {"theme": theme, "leader": leader_acct, "budget": budget, "avg_performance": avg_perf}
|
| 481 |
|
| 482 |
# ----------------------------------------------------------------------------- ledger
|
|
@@ -675,11 +708,12 @@ async def tier2_optimize(session):
|
|
| 675 |
f"(was {before_ms:.1f} ms · {BOLD}{speedup}× faster{RESET}) · recall@10 {GREEN}{recall}%{RESET}")
|
| 676 |
|
| 677 |
# 6) EMIT — announce the optimization on the signals bus
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
|
|
|
| 683 |
|
| 684 |
# 7) CLEANUP — drain the bench (empty table+index persist, never read by the swarm)
|
| 685 |
await pg_write(session, f"DELETE FROM {bench}")
|
|
@@ -729,8 +763,8 @@ async def connect_and_run(token, runner=None):
|
|
| 729 |
raise SystemExit(f"Could not reach the Aiven MCP on any transport. Last error: {last_err}")
|
| 730 |
|
| 731 |
# ----------------------------------------------------------------------------- deploy mode (worker)
|
| 732 |
-
STATUS = {"service": "murmur-fleet", "fleet_size": FLEET_SIZE, "
|
| 733 |
-
"last_round": None, "last_error": None}
|
| 734 |
|
| 735 |
def _truthy(v):
|
| 736 |
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
|
@@ -750,19 +784,53 @@ def serve_status(port):
|
|
| 750 |
return
|
| 751 |
HTTPServer(("0.0.0.0", port), Handler).serve_forever()
|
| 752 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
async def worker(token):
|
| 754 |
-
"""
|
| 755 |
-
A failed
|
| 756 |
-
interval = int(os.environ.get("MURMUR_INTERVAL", "
|
|
|
|
| 757 |
while True:
|
| 758 |
try:
|
| 759 |
-
STATUS["last_round"] = await connect_and_run(token)
|
| 760 |
-
STATUS["
|
| 761 |
STATUS["last_error"] = None
|
| 762 |
-
except Exception as e: # noqa: BLE001 — keep the
|
| 763 |
STATUS["last_error"] = str(e)[:200]
|
| 764 |
-
print(f"{YELLOW}[
|
| 765 |
-
print(f"{DIM}[
|
| 766 |
await asyncio.sleep(interval)
|
| 767 |
|
| 768 |
def load_dotenv():
|
|
@@ -811,8 +879,8 @@ def main():
|
|
| 811 |
elif "--serve" in sys.argv or _truthy(os.environ.get("MURMUR_SERVE", "")):
|
| 812 |
port = int(os.environ.get("PORT", "8080"))
|
| 813 |
threading.Thread(target=serve_status, args=(port,), daemon=True).start()
|
| 814 |
-
print(f"{GREEN}[
|
| 815 |
-
f"{os.environ.get('MURMUR_INTERVAL', '
|
| 816 |
asyncio.run(worker(token))
|
| 817 |
else:
|
| 818 |
asyncio.run(connect_and_run(token))
|
|
|
|
| 36 |
as a bearer; if that endpoint lacks the write tools (read-only), it falls back to spawning
|
| 37 |
the bundled local server (./mcp-aiven, built with `npm install && npm run build`) over stdio.
|
| 38 |
|
| 39 |
+
Autonomous mode: `python murmur.py --serve` runs the CONDUCTOR loop — a status endpoint on
|
| 40 |
+
$PORT (default 8080) plus, every $MURMUR_INTERVAL seconds (default 180), an LLM decision on the
|
| 41 |
+
swarm's next move (round / onboard / optimize / idle) from its own live state. No human, no
|
| 42 |
+
fixed schedule of actions. The Dockerfile builds exactly this; run it under launchd/cron or
|
| 43 |
+
(when access is granted) Aiven Apps with ANTHROPIC_API_KEY + AIVEN_TOKEN as secrets — all via the MCP.
|
| 44 |
"""
|
| 45 |
|
| 46 |
import asyncio
|
|
|
|
| 256 |
f"VALUES ('{sql_str(account_id)}', '{sql_str(subject)}', '{sql_str(body)}', "
|
| 257 |
f"{perf}, {div}, '{sql_str(scheduled_for)}')")
|
| 258 |
|
| 259 |
+
async def store_signal(session, kind, payload):
|
| 260 |
+
"""Durable, dashboard-readable copy of a Kafka `signals` event (trend/amplify/optimize),
|
| 261 |
+
so the wall can show the real autonomous decisions — not a re-derived stand-in."""
|
| 262 |
+
await pg_write(session,
|
| 263 |
+
f"INSERT INTO signals (kind, payload) VALUES "
|
| 264 |
+
f"('{sql_str(kind)}', '{sql_str(json.dumps(payload))}'::jsonb)")
|
| 265 |
+
|
| 266 |
# --- Tier 2 observe/optimize MCP wrappers ---
|
| 267 |
async def query_stats(session, order_by="total_time:desc", limit=5, search=None):
|
| 268 |
args = {"project": PROJECT, "service_name": PG_SVC, "order_by": order_by, "limit": limit}
|
|
|
|
| 313 |
f"fleet. Your genre is '{me['genre']}'. You think briefly, in character, then act. "
|
| 314 |
f"Always answer with a single JSON object only.")
|
| 315 |
|
| 316 |
+
def conductor_decide(state):
|
| 317 |
+
"""The autonomous conductor: decide the swarm's NEXT MOVE from its live state (LLM, not a timer)."""
|
| 318 |
+
msg = AC.messages.create(
|
| 319 |
+
model=MODEL, max_tokens=220,
|
| 320 |
+
system="You are the autonomous conductor of Murmur, a self-running book-marketing agent swarm "
|
| 321 |
+
"on Aiven. You decide the swarm's next move from its live state — there is no human and "
|
| 322 |
+
"no schedule. Answer with a single JSON object only.",
|
| 323 |
+
messages=[{"role": "user", "content":
|
| 324 |
+
f"Live state: {state['agents']} agents · {state['recent_posts']} posts in the last 30min · "
|
| 325 |
+
f"{state['mins_since_post']} min since the last post · {state['hooks']} hooks in pgvector memory · "
|
| 326 |
+
f"last self-optimize {state['mins_since_optimize']} min ago · resonating theme: \"{state['top_theme']}\".\n"
|
| 327 |
+
"Decide the next move. Options: 'round' (agents post, diversify vs peers, the fleet detects a "
|
| 328 |
+
"trend & amplifies it); 'onboard' (recruit a NEW audience-segment agent — give a 2-4 word "
|
| 329 |
+
"segment); 'optimize' (self-tune the pgvector memory index — only worthwhile once memory has "
|
| 330 |
+
"grown a lot); 'idle' (wait, if it just acted). Keep the network alive and varied; don't "
|
| 331 |
+
"onboard every cycle. Reply ONLY JSON: "
|
| 332 |
+
'{"action":"round|onboard|optimize|idle","segment":"<only if onboard>","reason":"<one sentence>"}'}])
|
| 333 |
+
raw = "".join(b.text for b in msg.content if getattr(b, "type", None) == "text")
|
| 334 |
+
return extract_json(raw)
|
| 335 |
+
|
| 336 |
# ----------------------------------------------------------------------------- setup (idempotent)
|
| 337 |
async def ensure_schema(session):
|
| 338 |
"""Create the tables, pgvector extension, and shared topics — idempotent, all via MCP."""
|
|
|
|
| 350 |
"CREATE TABLE IF NOT EXISTS posts (id bigserial PRIMARY KEY, "
|
| 351 |
"account_id text REFERENCES accounts(id), subject text, body text, performance numeric, "
|
| 352 |
"diversified_from text, scheduled_for text, created_at timestamptz NOT NULL DEFAULT now())")
|
| 353 |
+
await pg_write(session,
|
| 354 |
+
"CREATE TABLE IF NOT EXISTS signals (id bigserial PRIMARY KEY, kind text NOT NULL, "
|
| 355 |
+
"payload jsonb NOT NULL, ts timestamptz NOT NULL DEFAULT now())")
|
| 356 |
for topic in (POSTS, SIGNALS):
|
| 357 |
try: # topics are usually pre-created; tolerate "already exists"
|
| 358 |
await call(session, "aiven_kafka_topic_create", {
|
|
|
|
| 479 |
for r in members:
|
| 480 |
print(f" {DIM}{r['account_id']:8} sim={r['sim_to_leader']} perf={r['performance']} {r['subject']}{RESET}")
|
| 481 |
|
| 482 |
+
trend_sig = {"type": "trend", "theme": theme, "leader": leader_acct,
|
| 483 |
+
"members": [r["account_id"] for r in members], "avg_performance": avg_perf, "ts": now_iso()}
|
| 484 |
+
await produce(session, SIGNALS, [{"key": {"theme": theme}, "value": trend_sig}])
|
| 485 |
+
await store_signal(session, "trend", trend_sig)
|
| 486 |
+
step(f"emitted trend signal to kafka topic '{SIGNALS}' (+ durable copy)")
|
| 487 |
|
| 488 |
# amplify: the theme owner allocates ad budget and doubles down (legit marketing ops)
|
| 489 |
me = await load_account(session, leader_acct)
|
|
|
|
| 505 |
await pg_write(session,
|
| 506 |
f"INSERT INTO events (account_id, type, topic) VALUES ('{sql_str(leader_acct)}', 'amplify', '{SIGNALS}')")
|
| 507 |
await store_post(session, leader_acct, theme, text, avg_perf, None, "now (amplified)")
|
| 508 |
+
amp_sig = {"type": "amplify", "account_id": leader_acct, "theme": theme, "budget_usd": budget,
|
| 509 |
+
"text": text, "reason": reason, "ts": now_iso()}
|
| 510 |
+
await produce(session, SIGNALS, [{"key": {"account_id": leader_acct}, "value": amp_sig}])
|
| 511 |
+
await store_signal(session, "amplify", amp_sig)
|
| 512 |
+
step(f"wrote amplify event + durable posts/signal rows + produced to '{SIGNALS}'")
|
| 513 |
return {"theme": theme, "leader": leader_acct, "budget": budget, "avg_performance": avg_perf}
|
| 514 |
|
| 515 |
# ----------------------------------------------------------------------------- ledger
|
|
|
|
| 708 |
f"(was {before_ms:.1f} ms · {BOLD}{speedup}× faster{RESET}) · recall@10 {GREEN}{recall}%{RESET}")
|
| 709 |
|
| 710 |
# 6) EMIT — announce the optimization on the signals bus
|
| 711 |
+
opt_sig = {"type": "optimize", "query": "diversify-nn", "rows": seeded, "scan_before": before_scan,
|
| 712 |
+
"scan_after": after_scan, "before_ms": round(before_ms, 1), "after_ms": round(after_ms, 1),
|
| 713 |
+
"speedup": speedup, "recall_pct": recall, "index": itype, "ts": now_iso()}
|
| 714 |
+
await produce(session, SIGNALS, [{"key": {"type": "optimize"}, "value": opt_sig}])
|
| 715 |
+
await store_signal(session, "optimize", opt_sig)
|
| 716 |
+
step(f"emitted optimization signal to '{SIGNALS}' (+ durable copy)")
|
| 717 |
|
| 718 |
# 7) CLEANUP — drain the bench (empty table+index persist, never read by the swarm)
|
| 719 |
await pg_write(session, f"DELETE FROM {bench}")
|
|
|
|
| 763 |
raise SystemExit(f"Could not reach the Aiven MCP on any transport. Last error: {last_err}")
|
| 764 |
|
| 765 |
# ----------------------------------------------------------------------------- deploy mode (worker)
|
| 766 |
+
STATUS = {"service": "murmur-fleet", "fleet_size": FLEET_SIZE, "cycles": 0,
|
| 767 |
+
"last_decision": None, "last_round": None, "last_error": None}
|
| 768 |
|
| 769 |
def _truthy(v):
|
| 770 |
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
|
|
|
| 784 |
return
|
| 785 |
HTTPServer(("0.0.0.0", port), Handler).serve_forever()
|
| 786 |
|
| 787 |
+
async def conductor_cycle(session):
|
| 788 |
+
"""One autonomous cycle: observe the swarm's own state via the MCP, let the LLM conductor decide
|
| 789 |
+
the next move, then dispatch it. Initiative is LLM-made, not clock-driven."""
|
| 790 |
+
await ensure_schema(session)
|
| 791 |
+
row = (await pg_read(session,
|
| 792 |
+
"SELECT (SELECT count(*) FROM posts WHERE created_at > now()-interval '30 minutes') AS recent_posts, "
|
| 793 |
+
"(SELECT count(*) FROM accounts) AS agents, (SELECT count(*) FROM hooks) AS hooks, "
|
| 794 |
+
"COALESCE(round(extract(epoch FROM (now()-(SELECT max(created_at) FROM posts)))/60)::int, 999) AS mins_since_post, "
|
| 795 |
+
"COALESCE(round(extract(epoch FROM (now()-(SELECT max(ts) FROM signals WHERE kind='optimize')))/60)::int, 999) AS mins_since_optimize, "
|
| 796 |
+
"COALESCE((SELECT payload->>'theme' FROM signals WHERE kind='trend' ORDER BY id DESC LIMIT 1), '—') AS top_theme"))[0]
|
| 797 |
+
state = {k: row.get(k) for k in ("recent_posts", "agents", "hooks", "mins_since_post", "mins_since_optimize", "top_theme")}
|
| 798 |
+
|
| 799 |
+
banner(BLUE, "◆ conductor", "observing the swarm + deciding the next move (no human, no schedule)")
|
| 800 |
+
step(f"{DIM}state: {state['agents']} agents · {state['recent_posts']} posts/30m · "
|
| 801 |
+
f"{state['mins_since_post']}m since last post · {state['hooks']} hooks · theme \"{state['top_theme']}\"{RESET}")
|
| 802 |
+
print(f" {DIM}thinking with {MODEL}…{RESET}")
|
| 803 |
+
d = await asyncio.to_thread(conductor_decide, state)
|
| 804 |
+
action = (d.get("action") or "round").strip().lower()
|
| 805 |
+
if action not in ("round", "onboard", "optimize", "idle"):
|
| 806 |
+
action = "round"
|
| 807 |
+
reason = d.get("reason", "").strip()
|
| 808 |
+
print(f" decision: {BOLD}{action.upper()}{RESET} — {DIM}{reason}{RESET}")
|
| 809 |
+
STATUS["last_decision"] = {"action": action, "reason": reason, "ts": now_iso()}
|
| 810 |
+
|
| 811 |
+
if action == "round":
|
| 812 |
+
return await do_demo(session)
|
| 813 |
+
if action == "onboard":
|
| 814 |
+
return await onboard(session, d.get("segment") or "new-segment")
|
| 815 |
+
if action == "optimize":
|
| 816 |
+
return await tier2_optimize(session)
|
| 817 |
+
step("idle — letting the stream settle")
|
| 818 |
+
return {"action": "idle", "reason": reason}
|
| 819 |
+
|
| 820 |
async def worker(token):
|
| 821 |
+
"""The autonomous conductor loop: every MURMUR_INTERVAL seconds the swarm DECIDES (via the LLM
|
| 822 |
+
conductor) and acts. A failed cycle must never kill the loop — log and try again next interval."""
|
| 823 |
+
interval = int(os.environ.get("MURMUR_INTERVAL", "180"))
|
| 824 |
+
print(f"{GREEN}[conductor] autonomous loop started — a fresh decision every ~{interval}s{RESET}")
|
| 825 |
while True:
|
| 826 |
try:
|
| 827 |
+
STATUS["last_round"] = await connect_and_run(token, conductor_cycle)
|
| 828 |
+
STATUS["cycles"] = STATUS.get("cycles", 0) + 1
|
| 829 |
STATUS["last_error"] = None
|
| 830 |
+
except Exception as e: # noqa: BLE001 — keep the loop alive
|
| 831 |
STATUS["last_error"] = str(e)[:200]
|
| 832 |
+
print(f"{YELLOW}[conductor] cycle failed: {e}{RESET}")
|
| 833 |
+
print(f"{DIM}[conductor] next decision in {interval}s{RESET}")
|
| 834 |
await asyncio.sleep(interval)
|
| 835 |
|
| 836 |
def load_dotenv():
|
|
|
|
| 879 |
elif "--serve" in sys.argv or _truthy(os.environ.get("MURMUR_SERVE", "")):
|
| 880 |
port = int(os.environ.get("PORT", "8080"))
|
| 881 |
threading.Thread(target=serve_status, args=(port,), daemon=True).start()
|
| 882 |
+
print(f"{GREEN}[conductor] status endpoint on 0.0.0.0:{port} · autonomous — the swarm decides "
|
| 883 |
+
f"its own move every {os.environ.get('MURMUR_INTERVAL', '180')}s{RESET}")
|
| 884 |
asyncio.run(worker(token))
|
| 885 |
else:
|
| 886 |
asyncio.run(connect_and_run(token))
|