Melany Macias commited on
Commit
45da134
·
1 Parent(s): 01229ee
Files changed (7) hide show
  1. .dockerignore +0 -1
  2. Dockerfile +14 -18
  3. README.md +46 -0
  4. dashboard/app.py +6 -3
  5. dashboard/data.py +67 -1
  6. dashboard/templates/index.html +330 -252
  7. 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 fleet long-running worker that runs coordination rounds through the Aiven MCP.
2
- # No direct DB/Kafka drivers: it's an MCP client (mcp) + claude-sonnet (anthropic) + local
3
- # embeddings (fastembed). Secrets are injected at deploy time, never baked into the image.
 
4
  FROM python:3.11-slim
5
 
6
  WORKDIR /app
7
-
8
- # Python deps.
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
- # Aiven Apps requires a listening port; the worker serves a status endpoint here and runs
17
- # coordination rounds in the background.
18
- ENV PORT=8080 \
19
- MURMUR_SERVE=1 \
20
- MURMUR_FLEET=6 \
21
- MURMUR_INTERVAL=300
22
- EXPOSE 8080
23
 
24
- # ANTHROPIC_API_KEY and AIVEN_TOKEN are injected as secrets at deploy time never baked in.
25
- CMD ["python", "murmur.py", "--serve"]
 
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
- port = int(os.environ.get("MURMUR_PORT", "5050"))
48
- # host stays loopback this is a local demo viewer, not a public service.
49
- app.run(host="127.0.0.1", port=port, debug=False)
 
 
 
 
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
- "sources": {"accounts": accounts_src, "posts": posts_src, "ops": ops["source"]},
 
 
 
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>Murmur — live swarm</title>
7
  <style>
8
- :root {
9
- --bg:#0b0d12; --panel:#141821; --panel2:#1b212d; --line:#222a38;
10
- --ink:#e7ecf3; --dim:#8b97a8; --dimmer:#5b6675;
11
- --cyan:#35d0d6; --magenta:#d36bd0; --green:#56d364; --amber:#e3b341; --red:#f06a6a;
12
- --flash:#2a3550;
13
  }
14
- * { box-sizing:border-box; }
15
- body {
16
- margin:0; background:var(--bg); color:var(--ink);
17
- font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
 
 
 
18
  }
19
- .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
20
- header {
21
- display:flex; align-items:center; gap:16px; flex-wrap:wrap;
22
- padding:14px 22px; border-bottom:1px solid var(--line); background:#0e1218;
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
- main { padding:20px 22px; display:grid; gap:20px; grid-template-columns:1fr; max-width:1500px; margin:0 auto; }
43
- @media (min-width:1050px){ main { grid-template-columns:1.55fr 1fr; } }
44
- section h2 {
45
- font-size:12px; text-transform:uppercase; letter-spacing:1.4px; color:var(--dim);
46
- margin:0 0 12px; display:flex; align-items:center; gap:10px;
47
- }
48
- .col { display:flex; flex-direction:column; gap:20px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
- /* tile wall */
51
- .tiles { display:grid; gap:12px; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); }
52
- .tile {
53
- background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:14px;
54
- transition:background .6s ease, border-color .6s ease, transform .15s ease;
55
- position:relative; overflow:hidden;
56
- }
57
- .tile.flash { background:var(--flash); border-color:var(--cyan); }
58
- .tile .name { font-weight:700; font-size:15px; }
59
- .tile .genre { font-size:11px; color:var(--magenta); text-transform:uppercase; letter-spacing:.8px; }
60
- .tile .persona { color:var(--dimmer); font-size:11.5px; margin-top:6px; min-height:30px; }
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
- .feed { background:var(--panel); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
74
- .feed .row {
75
- display:grid; grid-template-columns:60px 1fr auto 70px; gap:10px; align-items:baseline;
76
- padding:9px 14px; border-bottom:1px solid var(--line); font-size:13px;
77
- }
78
- .feed .row.new { animation:slidein 1.2s ease; }
79
- @keyframes slidein { from{ background:var(--flash); } to{ background:transparent; } }
80
- .feed .row:last-child { border-bottom:none; }
81
- .feed .acct { color:var(--cyan); font-weight:600; }
82
- .feed .acct.b { color:var(--magenta); }
83
- .feed .subj { color:var(--ink); }
84
- .feed .meta { color:var(--dimmer); font-size:11px; }
85
- .feed .perf { color:var(--amber); text-align:right; white-space:nowrap; }
86
- .feed .when { color:var(--dim); text-align:right; white-space:nowrap; font-size:11px; }
87
- .feedhint { color:var(--dimmer); font-size:11px; margin-top:8px; }
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
- /* operator strip */
90
- .ops { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px; }
91
- .ops .top { font-size:13px; color:var(--dim); margin-bottom:12px; }
92
- .ops .top b { color:var(--amber); }
93
- .bar { margin-bottom:10px; }
94
- .bar .lab { display:flex; justify-content:space-between; font-size:12px; margin-bottom:4px; }
95
- .bar .lab .t { color:var(--ink); }
96
- .bar .lab .t.spike { color:var(--amber); font-weight:700; }
97
- .bar .lab .v { color:var(--dim); }
98
- .track { height:9px; background:var(--panel2); border-radius:6px; overflow:hidden; }
99
- .fill { height:100%; background:var(--cyan); border-radius:6px; transition:width .8s ease; }
100
- .fill.spike { background:linear-gradient(90deg,var(--amber),var(--magenta)); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
- .legend { color:var(--dimmer); font-size:11px; margin-top:6px; }
103
- .err { color:var(--red); font-size:12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  </style>
105
  </head>
106
  <body>
 
 
 
107
  <header>
108
- <h1><b>Murmur</b> · live swarm</h1>
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-accounts">accounts —</span>
115
  <span class="pill" id="p-posts">posts —</span>
116
- <span class="pill" id="p-ops">budget —</span>
117
- <span class="pill mono" id="p-updated">—</span>
118
  </header>
119
 
120
  <main>
121
  <div class="col">
122
- <section>
123
- <h2>The swarm <span class="pill mono" id="tilecount">0 accounts</span></h2>
124
- <div class="tiles" id="tiles"></div>
125
  </section>
126
- <section>
127
- <h2>Live feed <span class="pill mono">SELECT * FROM posts ORDER BY created_at DESC</span></h2>
128
- <div class="feed" id="feed"></div>
129
- <div class="feedhint">Each row is one real DB row. Run the proof query in the agent chat — these tiles must match it.</div>
 
 
130
  </section>
131
  </div>
132
 
133
  <div class="col">
134
- <section>
135
- <h2>Operator · amplify</h2>
136
- <div class="ops" id="ops"></div>
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="ops">
142
- <div class="bar"><span class="badge stagger">stagger</span><span class="meta" id="staggertext">—</span></div>
143
- <div class="bar"><span class="badge diversify">diversify</span><span class="meta" id="diversifytext">—</span></div>
144
- <div class="legend">No controller, no human — agents read the shared stream, then stagger timing &amp; diversify topic.</div>
145
  </div>
146
  </section>
147
  </div>
@@ -149,160 +203,184 @@
149
 
150
  <script>
151
  const POLL_MS = 2000;
152
- let lastByAccount = {}; // account_id -> latest post id (to flash tiles on change)
153
- let seenPostIds = new Set(); // post ids already shown (to animate only NEW feed rows)
154
- let firstPaint = true;
155
 
156
- function fmtClock(iso){
157
- if(!iso) return "—";
158
- const d = new Date(iso);
159
- return d.toLocaleTimeString([], {hour:"2-digit", minute:"2-digit", second:"2-digit"});
160
- }
161
- function ago(iso){
162
- if(!iso) return "";
163
- const s = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime())/1000));
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
- // ---- status ----
180
- const dot = document.getElementById("dot");
181
- const st = document.getElementById("statustext");
182
- if(s.error){
183
- dot.className = "dot err";
184
- st.innerHTML = 'DB error — showing mock. <span class="err mono">' + s.error + '</span>';
185
- } else if(s.mode === "live"){
186
- dot.className = "dot live";
187
- st.textContent = "live · polling pg-conductor every " + (POLL_MS/1000) + "s";
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
- // ---- index latest post per account ----
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
- // ---- tiles ----
204
- const tiles = document.getElementById("tiles");
205
- tiles.innerHTML = "";
206
- const order = Object.keys(latest);
207
- s.accounts.forEach((a, i) => {
208
- const lp = latest[a.id];
209
- const changed = lp && lastByAccount[a.id] !== undefined && lastByAccount[a.id] !== lp.id;
210
- const div = document.createElement("div");
211
- div.className = "tile" + (lp ? " active" : "") + ((changed && !firstPaint) ? " flash" : "");
212
- let lastHtml = '<div class="last">quiet — no posts yet</div>';
213
- if(lp){
214
- lastHtml = '<div class="last">last: <span class="subj">' +
215
- (lp.subject ? esc(lp.subject) : "") + '</span> · <span class="mono">' + ago(lp.created_at) + '</span></div>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  }
217
- div.innerHTML =
218
- '<span class="tdot"></span>' +
219
- '<div class="name">' + esc(shortName(a.persona) || a.id) + '</div>' +
220
- '<div class="genre">' + esc(a.genre || "") + '</div>' +
221
- '<div class="persona">' + esc(a.persona || a.id) + '</div>' +
222
- lastHtml;
223
- tiles.appendChild(div);
224
- if(changed && !firstPaint){ setTimeout(() => div.classList.remove("flash"), 900); }
225
  });
226
- document.getElementById("tilecount").textContent = s.accounts.length + " accounts";
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
- // ---- feed ----
233
- const feed = document.getElementById("feed");
234
- feed.innerHTML = "";
235
- const accIndex = {}; s.accounts.forEach((a,i)=>accIndex[a.id]=i);
236
- const nowSeen = new Set();
237
- s.posts.forEach(p => {
238
- const idStr = String(p.id);
239
- nowSeen.add(idStr);
240
- const isNew = !firstPaint && !seenPostIds.has(idStr);
241
- const row = document.createElement("div");
242
- row.className = "row" + (isNew ? " new" : "");
243
- const isB = (accIndex[p.account_id] || 0) % 2 === 1;
244
- const perf = (p.performance!=null) ? Math.round(p.performance).toLocaleString() : "—";
245
- row.innerHTML =
246
- '<span class="acct' + (isB?" b":"") + ' mono">' + esc(p.account_id||"?") + '</span>' +
247
- '<span><span class="subj">' + (p.subject?esc(p.subject):'<span class="meta">(post)</span>') + '</span>' +
248
- (p.diversified_from ? ' <span class="meta">↯ vs ' + esc(p.diversified_from) + '</span>' : '') + '</span>' +
249
- '<span class="perf">' + perf + '</span>' +
250
- '<span class="when mono" title="' + esc(p.created_at||"") + '">' + fmtClock(p.created_at) + '</span>';
251
- feed.appendChild(row);
 
 
 
 
 
252
  });
253
- seenPostIds = nowSeen;
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
- // ---- operator strip ----
259
- const ops = document.getElementById("ops");
260
- const o = s.ops || {themes:[]};
261
- let html = '<div class="top">resonating theme: <b>' + (o.top?esc(o.top):"—") +
262
- '</b> · budget pool ' + (o.budget_total||0).toLocaleString() + '</div>';
263
- const max = Math.max(1, ...o.themes.map(t=>t.budget||0));
264
- for(const t of o.themes){
265
- const w = Math.round(100*(t.budget||0)/max);
266
- html += '<div class="bar"><div class="lab"><span class="t' + (t.spike?" spike":"") + '">' +
267
- esc(t.theme) + (t.spike?" ◀ amplifying":"") + '</span><span class="v">' +
268
- Math.round(t.budget||0).toLocaleString() + '</span></div>' +
269
- '<div class="track"><div class="fill' + (t.spike?" spike":"") + '" style="width:' + w + '%"></div></div></div>';
270
- }
271
- ops.innerHTML = html;
 
 
 
 
 
 
 
 
272
 
273
- // ---- coordination summary ----
274
- const posts = s.posts.slice().sort((a,b)=> new Date(a.created_at)-new Date(b.created_at));
275
- let staggerTxt = "—", diversifyTxt = "—";
276
- if(posts.length >= 2){
277
- const a1 = posts[posts.length-2], a2 = posts[posts.length-1];
278
- const gap = Math.abs((new Date(a2.created_at)-new Date(a1.created_at))/1000);
279
- staggerTxt = "last two posts " + Math.round(gap) + "s apart (" + esc(a1.account_id) + " " + esc(a2.account_id) + ")";
280
- diversifyTxt = a1.subject && a2.subject
281
- ? (a1.subject.toLowerCase()===a2.subject.toLowerCase()
282
- ? "repeat detected: “" + esc(a2.subject) + "”"
283
- : "“" + esc(a1.subject) + "” → “" + esc(a2.subject) + "” (different hooks)")
284
- : "subjects not in schema yet";
 
285
  }
286
- document.getElementById("staggertext").innerHTML = staggerTxt;
287
- document.getElementById("diversifytext").innerHTML = diversifyTxt;
288
-
289
- firstPaint = false;
290
  }
291
 
292
- function esc(s){ return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
293
-
294
  async function tick(){
295
- try {
296
- const r = await fetch("/api/state", {cache:"no-store"});
297
- render(await r.json());
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
- setInterval(tick, POLL_MS);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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
- Deploy (step 4): `python murmur.py --serve` runs it as a long-running worker — a status
40
- endpoint on $PORT (default 8080) plus a coordination round every $MURMUR_INTERVAL seconds
41
- (default 300). The Dockerfile builds exactly this; deploy via Aiven Apps with ANTHROPIC_API_KEY
42
- and AIVEN_TOKEN injected as secrets still all via the MCP, no direct DB/Kafka drivers.
 
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
- await produce(session, SIGNALS, [{"key": {"theme": theme}, "value": {
452
- "type": "trend", "theme": theme, "leader": leader_acct,
453
- "members": [r["account_id"] for r in members], "avg_performance": avg_perf, "ts": now_iso()}}])
454
- step(f"emitted trend signal to kafka topic '{SIGNALS}'")
 
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
- await produce(session, SIGNALS, [{"key": {"account_id": leader_acct}, "value": {
477
- "type": "amplify", "account_id": leader_acct, "theme": theme, "budget_usd": budget,
478
- "text": text, "reason": reason, "ts": now_iso()}}])
479
- step(f"wrote amplify event + durable posts row + produced to '{SIGNALS}'")
 
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
- await produce(session, SIGNALS, [{"key": {"type": "optimize"}, "value": {
679
- "type": "optimize", "query": "diversify-nn", "rows": seeded, "scan_before": before_scan,
680
- "scan_after": after_scan, "before_ms": round(before_ms, 1), "after_ms": round(after_ms, 1),
681
- "speedup": speedup, "recall_pct": recall, "index": itype, "ts": now_iso()}}])
682
- step(f"emitted optimization signal to '{SIGNALS}'")
 
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, "rounds": 0,
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
- """Run coordination rounds forever, every MURMUR_INTERVAL seconds, updating STATUS.
755
- A failed round must never kill the workerit logs and tries again next interval."""
756
- interval = int(os.environ.get("MURMUR_INTERVAL", "300"))
 
757
  while True:
758
  try:
759
- STATUS["last_round"] = await connect_and_run(token)
760
- STATUS["rounds"] += 1
761
  STATUS["last_error"] = None
762
- except Exception as e: # noqa: BLE001 — keep the worker alive
763
  STATUS["last_error"] = str(e)[:200]
764
- print(f"{YELLOW}[worker] round failed: {e}{RESET}")
765
- print(f"{DIM}[worker] sleeping {interval}s until next round{RESET}")
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}[worker] status endpoint on 0.0.0.0:{port} · round every "
815
- f"{os.environ.get('MURMUR_INTERVAL', '300')}s{RESET}")
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 looplog 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))