dulus 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. webchat_server.py +1761 -0
webchat_server.py ADDED
@@ -0,0 +1,1761 @@
1
+ """Dulus WebChat — in-process mirror of the terminal agent + Roundtable mode.
2
+ """
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import queue
7
+ import threading
8
+ import time
9
+ import uuid
10
+ import webbrowser
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Generator
14
+
15
+ from backend.context import build_context, build_smart_context, get_compact_context
16
+ from backend.personas import create_persona, get_active_persona, get_all_personas, get_persona, load_personas, set_active_persona, update_persona
17
+ from backend.plugins import load_all_plugins, get_plugin_info, start_watcher, stop_watcher, watcher_status, reload_plugin, unload_plugin
18
+ from task import create_task as task_create, list_tasks as task_list, update_task as task_update, get_task as task_get, delete_task as task_delete
19
+ from backend.marketplace import load_registry, search_plugins, get_stats as marketplace_stats, install_plugin, uninstall_plugin
20
+
21
+ DASHBOARD_DIR = Path(__file__).parent / "docs" / "dashboard"
22
+
23
+ from flask import Flask, request, jsonify, Response, stream_with_context
24
+
25
+ from agent import (
26
+ run as agent_run,
27
+ AgentState,
28
+ TextChunk,
29
+ ThinkingChunk,
30
+ ToolStart,
31
+ ToolEnd,
32
+ TurnDone,
33
+ PermissionRequest,
34
+ )
35
+ from context import build_system_prompt
36
+ from common import sanitize_text
37
+
38
+ # Ensure tools are registered
39
+ import tools as _tools_init
40
+ import memory.tools as _mem_tools_init
41
+ import multi_agent.tools as _ma_tools_init
42
+ import skill.tools as _sk_tools_init
43
+ import dulus_mcp.tools as _mcp_tools_init
44
+ import task.tools as _task_tools_init
45
+
46
+ try:
47
+ import tmux_tools as _tmux_tools_init
48
+ except Exception:
49
+ pass
50
+
51
+ # ─────────── SSE Broadcast System ───────────
52
+ _sse_clients: list[queue.Queue] = []
53
+ _sse_lock = threading.Lock()
54
+
55
+ def _add_sse_client(q: queue.Queue):
56
+ with _sse_lock:
57
+ _sse_clients.append(q)
58
+
59
+ def _remove_sse_client(q: queue.Queue):
60
+ with _sse_lock:
61
+ if q in _sse_clients:
62
+ _sse_clients.remove(q)
63
+
64
+ def broadcast_event(event_type: str, payload: dict):
65
+ """Broadcast JSON event to all connected SSE clients."""
66
+ data = json.dumps({"type": event_type, "data": payload, "ts": time.time()})
67
+ msg = f"event: {event_type}\ndata: {data}\n\n"
68
+ with _sse_lock:
69
+ dead = []
70
+ for q in _sse_clients:
71
+ try:
72
+ q.put_nowait(msg)
73
+ except queue.Full:
74
+ dead.append(q)
75
+ for q in dead:
76
+ _sse_clients.remove(q)
77
+
78
+ def _sse_heartbeat():
79
+ """Send periodic ping to keep connections alive."""
80
+ while True:
81
+ time.sleep(15)
82
+ broadcast_event("ping", {"status": "ok"})
83
+
84
+ threading.Thread(target=_sse_heartbeat, daemon=True, name="sse-heartbeat").start()
85
+
86
+ # ── shared refs ────────────────────────────────────────────────────────────
87
+ STATE: AgentState | None = None
88
+ CONFIG: dict | None = None
89
+ _LOCK = threading.Lock()
90
+ _PENDING_PERMISSIONS: dict[str, tuple[PermissionRequest, threading.Event]] = {}
91
+
92
+ _SERVER_THREAD: threading.Thread | None = None
93
+ _SERVER_PORT: int = 5000
94
+ _WERKZEUG_SERVER = None
95
+
96
+ # ── roundtable state ───────────────────────────────────────────────────────
97
+ class RoundtableAgent:
98
+ def __init__(self, agent_id: str, model: str):
99
+ self.id = agent_id
100
+ self.model = model
101
+ self.state = AgentState()
102
+
103
+
104
+ ROUNDTABLE_AGENTS: list[RoundtableAgent] = []
105
+ ROUNDTABLE_HISTORY: list[tuple[str, str]] = [] # (author_id, text) global log
106
+ ROUNDTABLE_LOCK = threading.Lock()
107
+
108
+ # Per-agent cancellation tokens for roundtable
109
+ _AGENT_STOP_EVENTS: dict[str, threading.Event] = {}
110
+ _STOP_EVENTS_LOCK = threading.Lock()
111
+
112
+
113
+ def _ensure_plugin_tools() -> None:
114
+ try:
115
+ from plugin.loader import register_plugin_tools
116
+ register_plugin_tools()
117
+ except Exception:
118
+ pass
119
+
120
+
121
+ _ANSI_RE = None
122
+
123
+
124
+ def _strip_ansi(text: str) -> str:
125
+ global _ANSI_RE
126
+ if _ANSI_RE is None:
127
+ import re
128
+ _ANSI_RE = re.compile(r'\x1b\[[0-9;]*m')
129
+ return _ANSI_RE.sub('', text)
130
+
131
+
132
+ def _run_slash_command(cmd_line: str) -> tuple[str, str | None]:
133
+ """Run a slash command through the REPL's registered handler,
134
+ capturing stdout. Mirrors the Telegram bridge behavior
135
+ (dulus.py:_handle_slash_from_telegram).
136
+
137
+ Returns (output_text, assistant_reply_or_None).
138
+ `assistant_reply` is set when the slash triggered a model query
139
+ (cmd_type == "query") so the caller can stream it as a separate chunk.
140
+ """
141
+ import io
142
+ if CONFIG is None:
143
+ return ("[webchat] server not initialized", None)
144
+ slash_cb = CONFIG.get("_handle_slash_callback")
145
+ if not slash_cb:
146
+ return (
147
+ f"[webchat] slash commands unavailable — REPL not active.\n"
148
+ f"Command was: {cmd_line}",
149
+ None,
150
+ )
151
+
152
+ old_stdout = sys.stdout
153
+ buf = io.StringIO()
154
+ sys.stdout = buf
155
+ try:
156
+ try:
157
+ cmd_type = slash_cb(cmd_line)
158
+ except Exception as e:
159
+ return (f"⚠ Error: {type(e).__name__}: {e}", None)
160
+ finally:
161
+ sys.stdout = old_stdout
162
+
163
+ captured = _strip_ansi(buf.getvalue()).strip()
164
+ if not captured and cmd_type == "simple":
165
+ cmd_name = cmd_line.strip().split()[0]
166
+ captured = f"✅ {cmd_name} executed."
167
+
168
+ assistant_reply: str | None = None
169
+ if cmd_type == "query" and STATE is not None and STATE.messages:
170
+ for m in reversed(STATE.messages):
171
+ if m.get("role") == "assistant":
172
+ content = m.get("content", "")
173
+ if isinstance(content, list):
174
+ parts = []
175
+ for block in content:
176
+ if isinstance(block, dict) and block.get("type") == "text":
177
+ parts.append(block["text"])
178
+ elif isinstance(block, str):
179
+ parts.append(block)
180
+ content = "\n".join(parts)
181
+ if content:
182
+ assistant_reply = content
183
+ break
184
+
185
+ return (captured, assistant_reply)
186
+
187
+
188
+ def _run_agent_mirror(user_message: str) -> Generator:
189
+ """Run the agent loop with shared state/config, yielding all events."""
190
+ _ensure_plugin_tools()
191
+ if STATE is None or CONFIG is None:
192
+ raise RuntimeError("webchat server not initialized")
193
+
194
+ cfg = CONFIG
195
+ state = STATE
196
+ user_input = sanitize_text(user_message)
197
+
198
+ _skill_body = cfg.pop("_skill_inject", "")
199
+ if _skill_body:
200
+ user_input = (
201
+ "[SKILL CONTEXT — follow these instructions for this turn]\n\n"
202
+ + _skill_body
203
+ + "\n\n---\n\n[USER MESSAGE]\n"
204
+ + user_input
205
+ )
206
+
207
+ if cfg.get("mem_palace", True) and user_input and len(user_input.strip()) >= 12:
208
+ _trivial = {
209
+ "hola", "klk", "gracias", "ok", "si", "no", "dale",
210
+ "exit", "quit", "help", "thanks", "bien",
211
+ }
212
+ _first = user_input.strip().lower().split()[0]
213
+ if _first not in _trivial:
214
+ try:
215
+ from memory import find_relevant_memories
216
+ _q = user_input.strip()[:200]
217
+ _raw_hits = find_relevant_memories(_q, max_results=3)
218
+ _raw_hits = [h for h in _raw_hits if h.get("keyword_score", 0.0) >= 60.0]
219
+ if _raw_hits:
220
+ _parts = []
221
+ for _i, _h in enumerate(_raw_hits, 1):
222
+ _name = _h.get("name", f"hit_{_i}")
223
+ _desc = _h.get("description", "")
224
+ _body = _h.get("content", "").strip()
225
+ _snip = _body[:300] + ("..." if len(_body) > 300 else "")
226
+ if _desc:
227
+ _parts.append(f"### {_name}\n_{_desc}_\n{_snip}")
228
+ else:
229
+ _parts.append(f"### {_name}\n{_snip}")
230
+ _hits_str = "\n\n".join(_parts)
231
+ if len(_hits_str) > 2000:
232
+ _hits_str = _hits_str[:2000] + "\n[...truncated]"
233
+ _inject = (
234
+ "[MemPalace — relevant memories pre-loaded for this turn. "
235
+ "Do NOT re-query unless the user explicitly asks for more.]\n\n"
236
+ + _hits_str
237
+ )
238
+ user_input = (
239
+ _inject + "\n\n---\n\n[USER MESSAGE]\n" + user_input
240
+ )
241
+ except Exception:
242
+ pass
243
+
244
+ system_prompt = build_system_prompt(cfg)
245
+ cfg.pop("_in_telegram_turn", None)
246
+ cfg["_last_interaction_time"] = time.time()
247
+
248
+ yield from agent_run(user_input, state, cfg, system_prompt)
249
+
250
+ try:
251
+ import checkpoint as ckpt
252
+ session_id = cfg.get("_session_id", "default")
253
+ tracked = ckpt.get_tracked_edits()
254
+ last_snaps = ckpt.list_snapshots(session_id)
255
+ skip = False
256
+ if not tracked and last_snaps:
257
+ if len(state.messages) == last_snaps[-1].get("message_index", -1):
258
+ skip = True
259
+ if not skip:
260
+ ckpt.make_snapshot(session_id, state, cfg, user_input, tracked_edits=tracked)
261
+ ckpt.reset_tracked()
262
+ except Exception:
263
+ pass
264
+
265
+
266
+ def _event_to_dict(event) -> dict | None:
267
+ if isinstance(event, TextChunk):
268
+ return {"type": "text", "text": event.text}
269
+ elif isinstance(event, ThinkingChunk):
270
+ return {"type": "thinking", "text": event.text}
271
+ elif isinstance(event, ToolStart):
272
+ return {"type": "tool_start", "name": event.name, "inputs": event.inputs}
273
+ elif isinstance(event, ToolEnd):
274
+ return {"type": "tool_end", "name": event.name, "result": event.result, "permitted": event.permitted}
275
+ elif isinstance(event, TurnDone):
276
+ return {
277
+ "type": "turn_done",
278
+ "in": event.input_tokens,
279
+ "out": event.output_tokens,
280
+ "cache_read": getattr(event, "cache_read_tokens", 0),
281
+ "cache_write": getattr(event, "cache_creation_tokens", 0),
282
+ }
283
+ elif isinstance(event, PermissionRequest):
284
+ pid = str(uuid.uuid4())
285
+ evt = threading.Event()
286
+ _PENDING_PERMISSIONS[pid] = (event, evt)
287
+ payload = {"type": "permission", "id": pid, "description": event.description}
288
+ return payload, evt
289
+ return None
290
+
291
+
292
+ def _sanitize_for_api(text: str) -> str:
293
+ """Aggressive sanitize: remove control chars (except \n\r\t), surrogates, and normalize."""
294
+ if not isinstance(text, str):
295
+ text = str(text)
296
+ # Step 1: remove UTF-16 surrogates
297
+ text = "".join(c for c in text if not (0xD800 <= ord(c) <= 0xDFFF))
298
+ # Step 2: remove control characters except newline, carriage return, tab
299
+ text = "".join(c for c in text if ord(c) >= 32 or c in "\n\r\t")
300
+ # Step 3: normalize fancy quotes to plain quotes
301
+ text = text.replace("\u201c", '"').replace("\u201d", '"')
302
+ text = text.replace("\u2018", "'").replace("\u2019", "'")
303
+ text = text.replace("\u2013", "-").replace("\u2014", "-")
304
+ # Step 4: strip leading/trailing whitespace per line but keep structure
305
+ return text.strip()
306
+
307
+
308
+ def _build_roundtable_prompt(agent: RoundtableAgent, user_msg: str, history: list[tuple[str, str]]) -> str:
309
+ user_msg = _sanitize_for_api(user_msg)
310
+ ctx_parts = []
311
+ for author, text in history:
312
+ text = _sanitize_for_api(text)
313
+ if text:
314
+ ctx_parts.append(f"[{author}]: {text}")
315
+ if ctx_parts:
316
+ ctx = "\n".join(ctx_parts)
317
+ return (
318
+ f"[Mesa Redonda - Contexto Compartido]\n\n"
319
+ f"Historial:\n{ctx}\n\n"
320
+ f"Usuario ahora: {user_msg}\n\n"
321
+ f"Instrucción individual: Eres el miembro {agent.id}. Responde desde tu perspectiva."
322
+ )
323
+ return (
324
+ f"[Mesa Redonda - Contexto Compartido]\n\n"
325
+ f"Estás en una mesa redonda junto con otros agentes. "
326
+ f"Cada uno de ustedes aportará su perspectiva sobre el tema. "
327
+ f"Sé conciso pero completo en tu respuesta.\n\n"
328
+ f"Usuario ahora: {user_msg}\n\n"
329
+ f"Instrucción individual: Eres el miembro {agent.id}. Responde desde tu perspectiva."
330
+ )
331
+
332
+
333
+ def _run_agent_for_roundtable(agent: RoundtableAgent, user_msg: str, history: list[tuple[str, str]], q: queue.Queue):
334
+ stop_evt = threading.Event()
335
+ with _STOP_EVENTS_LOCK:
336
+ _AGENT_STOP_EVENTS[agent.id] = stop_evt
337
+ try:
338
+ _ensure_plugin_tools()
339
+ if CONFIG is None:
340
+ q.put({"agent": agent.id, "type": "error", "message": "server not initialized"})
341
+ return
342
+ cfg = dict(CONFIG)
343
+ cfg["model"] = agent.model
344
+ prompt = _sanitize_for_api(_build_roundtable_prompt(agent, user_msg, history))
345
+ system_prompt = build_system_prompt(cfg)
346
+ cfg.pop("_in_telegram_turn", None)
347
+ cfg["_last_interaction_time"] = time.time()
348
+
349
+ # Optimize tokens: clear state to prevent N^2 duplication of history
350
+ # and to dump bulky transient tool outputs (e.g. bash stdout).
351
+ agent.state.messages.clear()
352
+
353
+ stopped = False
354
+ for event in agent_run(prompt, agent.state, cfg, system_prompt):
355
+ if stop_evt.is_set():
356
+ stopped = True
357
+ q.put({"agent": agent.id, "type": "agent_stopped"})
358
+ break
359
+ result = _event_to_dict(event)
360
+ if result is None:
361
+ continue
362
+ if isinstance(result, tuple):
363
+ payload, evt = result
364
+ payload["agent"] = agent.id
365
+ q.put(payload)
366
+ evt.wait(timeout=300)
367
+ _PENDING_PERMISSIONS.pop(payload["id"], None)
368
+ continue
369
+ payload = result
370
+ payload["agent"] = agent.id
371
+ q.put(payload)
372
+
373
+ if not stopped:
374
+ final_text = ""
375
+ if agent.state.messages:
376
+ for msg in reversed(agent.state.messages):
377
+ if msg.get("role") == "assistant" and msg.get("content"):
378
+ final_text = msg["content"]
379
+ break
380
+ q.put({"agent": agent.id, "type": "agent_done", "text": final_text})
381
+ except Exception as exc:
382
+ q.put({"agent": agent.id, "type": "error", "message": f"{type(exc).__name__}: {exc}"})
383
+ finally:
384
+ with _STOP_EVENTS_LOCK:
385
+ _AGENT_STOP_EVENTS.pop(agent.id, None)
386
+
387
+
388
+ # ── Flask app ──────────────────────────────────────────────────────────────
389
+
390
+ def create_app() -> Flask:
391
+ app = Flask(__name__)
392
+ import logging as _logging
393
+ _logging.getLogger("werkzeug").setLevel(_logging.ERROR)
394
+ app.logger.disabled = True
395
+
396
+ # ───────────────────────── Chat Normal HTML ─────────────────────────
397
+ CHAT_PAGE = r"""<!doctype html>
398
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
399
+ <title>Dulus WebChat</title>
400
+ <link rel="preconnect" href="https://fonts.googleapis.com">
401
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
402
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Archivo+Black&display=swap" rel="stylesheet">
403
+ <style>
404
+ :root{
405
+ --bg:#0a0a0a;
406
+ --bg2:#0f0f12;
407
+ --bg3:#15151a;
408
+ --ink:#f0e8df;
409
+ --dim:#6a6470;
410
+ --dim2:#3a3840;
411
+ --accent:#ff6b1f;
412
+ --accent2:#ffb347;
413
+ --mono:'JetBrains Mono',monospace;
414
+ --display:'Archivo Black','Impact',sans-serif;
415
+ --radius:4px;
416
+ }
417
+ *{box-sizing:border-box;margin:0;padding:0}
418
+ html{scroll-behavior:smooth;font-size:16px}
419
+ body{background:var(--bg);color:var(--ink);font-family:var(--mono);height:100vh;display:flex;flex-direction:column;position:relative}
420
+ ::-webkit-scrollbar{width:6px}
421
+ ::-webkit-scrollbar-track{background:var(--bg)}
422
+ ::-webkit-scrollbar-thumb{background:var(--accent);border-radius:3px}
423
+
424
+ .grid-bg{
425
+ position:fixed;inset:0;pointer-events:none;z-index:0;
426
+ background-image:linear-gradient(rgba(255,107,31,.06) 1px,transparent 1px),
427
+ linear-gradient(90deg,rgba(255,107,31,.06) 1px,transparent 1px);
428
+ background-size:40px 40px;
429
+ mask-image:radial-gradient(ellipse at center,black 30%,transparent 80%);
430
+ }
431
+
432
+ header{padding:0 40px;height:64px;background:rgba(10,10,10,.7);backdrop-filter:blur(16px);border-bottom:1px solid rgba(255,107,31,.12);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;position:relative;z-index:100}
433
+ header h1{font-family:var(--display);font-size:18px;letter-spacing:-.02em;color:var(--ink);display:flex;align-items:center;gap:12px}
434
+ header h1::before{content:"▲";font-size:18px;color:#000;background:var(--accent);width:32px;height:32px;display:grid;place-items:center;clip-path:polygon(50% 0%,100% 25%,100% 75%,50% 100%,0% 75%,0% 25%);}
435
+
436
+ header .model{font-size:11px;color:var(--dim)}
437
+ header a,header button{background:var(--bg2);color:var(--dim);border:1px solid var(--dim2);padding:6px 12px;border-radius:var(--radius);cursor:pointer;font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;text-decoration:none;transition:background .2s,border-color .2s,color .2s}
438
+ header a:hover,header button:hover{background:rgba(255,107,31,.1);border-color:var(--accent);color:var(--accent)}
439
+ #log{flex:1;overflow-y:auto;padding:24px 40px;display:flex;flex-direction:column;gap:16px;position:relative;z-index:1}
440
+ .msg{max-width:780px;padding:12px 16px;border-radius:6px;white-space:pre-wrap;word-wrap:break-word;font-size:14px}
441
+ .user{align-self:flex-end;background:rgba(255,107,31,.1);border:1px solid rgba(255,107,31,.25)}
442
+ .assistant{align-self:flex-start;background:var(--bg3);border:1px solid var(--dim2)}
443
+ .meta{font-size:10px;color:var(--dim);margin-top:6px}
444
+ .err{color:#ff5a6e;border-color:rgba(255,90,110,.4) !important}
445
+ #inputArea{display:flex;gap:10px;padding:16px 40px;background:var(--bg2);border-top:1px solid var(--dim2);position:relative;z-index:100}
446
+ textarea{flex:1;background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:12px;border-radius:var(--radius);font-family:var(--mono);font-size:14px;resize:none;height:64px;outline:none;transition:border-color .2s}
447
+ textarea:focus{border-color:var(--accent)}
448
+ textarea::placeholder{color:var(--dim)}
449
+ button.send{background:var(--accent);color:#000;border:none;padding:0 24px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .2s}
450
+ button.send:hover{background:var(--accent2)}
451
+ button.send:disabled{opacity:.4;cursor:not-allowed}
452
+ .think{font-size:11px;color:var(--dim);margin-top:8px;padding:8px 12px;border-left:2px solid var(--dim2);background:rgba(0,0,0,.2);white-space:pre-wrap}
453
+ .tool{font-size:11px;color:#a39ca8;margin-top:8px;padding:8px 12px;border-left:2px solid var(--accent);background:rgba(255,107,31,.04);white-space:pre-wrap}
454
+ .tool-result{font-size:11px;color:var(--dim);margin-top:4px;padding:8px 12px;border-left:2px solid var(--dim2);background:rgba(0,0,0,.2);white-space:pre-wrap;max-height:200px;overflow-y:auto}
455
+ .perm{font-size:12px;color:#ffd166;margin-top:8px;padding:12px;border:1px solid rgba(255,209,102,.25);background:rgba(255,209,102,.1);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
456
+ .perm button{background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:6px 14px;border-radius:3px;cursor:pointer;font-weight:700}
457
+ .perm button.approve{background:var(--accent);color:#000;border:none}
458
+ @media(max-width:600px){
459
+ header{padding:0 20px;height:auto;padding-bottom:10px}
460
+ #log{padding:16px 20px}
461
+ .msg{max-width:92%}
462
+ #inputArea{padding:16px 20px}
463
+ }
464
+ </style></head><body>
465
+ <div class="grid-bg"></div>
466
+ <header>
467
+ <h1>DULUS WEBCHAT</h1>
468
+ <select id="personaSelect" style="background:var(--bg3);color:var(--dim);border:1px solid var(--dim2);padding:4px 10px;border-radius:var(--radius);font-family:var(--mono);font-size:12px;outline:none;cursor:pointer;flex:1;max-width:250px;margin:0 15px;text-align:center"></select>
469
+ <div>
470
+ <a href="/roundtable">Mesa Redonda</a>
471
+ <a href="/dashboard">Task Manager</a>
472
+ <button onclick="clearChat()">clear</button>
473
+ </div>
474
+ </header>
475
+ <div id="log"></div>
476
+ <div id="inputArea">
477
+ <textarea id="inp" placeholder="Mensaje a Dulus... (Enter envia, Shift+Enter nueva linea)" autofocus></textarea>
478
+ <button class="send" id="sendBtn">SEND</button>
479
+ </div>
480
+ <script>
481
+ const log=document.getElementById('log');
482
+ const inp=document.getElementById('inp');
483
+ const btn=document.getElementById('sendBtn');
484
+
485
+ function add(role,text,extra){
486
+ const d=document.createElement('div');
487
+ d.className='msg '+role;
488
+ d.textContent=text;
489
+ if(extra){
490
+ const m=document.createElement('div');
491
+ m.className='meta';
492
+ m.textContent=extra;
493
+ d.appendChild(m);
494
+ }
495
+ log.appendChild(d);
496
+ log.scrollTop=log.scrollHeight;
497
+ return d;
498
+ }
499
+
500
+ let currentAssistant=null;
501
+ let currentText='';
502
+
503
+ function ensureAssistant(){
504
+ if(!currentAssistant){
505
+ currentAssistant=add('assistant','');
506
+ }
507
+ return currentAssistant;
508
+ }
509
+
510
+ function appendText(text){
511
+ ensureAssistant();
512
+ currentText+=text;
513
+ currentAssistant.textContent=currentText;
514
+ log.scrollTop=log.scrollHeight;
515
+ }
516
+
517
+ function appendThinking(text){
518
+ ensureAssistant();
519
+ let th=currentAssistant.querySelector('.think');
520
+ if(!th){
521
+ th=document.createElement('div');
522
+ th.className='think';
523
+ th.textContent='[thinking]\n';
524
+ currentAssistant.appendChild(th);
525
+ }
526
+ th.textContent+=text;
527
+ log.scrollTop=log.scrollHeight;
528
+ }
529
+
530
+ function startTool(name,inputs){
531
+ ensureAssistant();
532
+ const t=document.createElement('div');
533
+ t.className='tool';
534
+ t.textContent='🔧 '+name+'\n'+JSON.stringify(inputs,null,2);
535
+ currentAssistant.appendChild(t);
536
+ log.scrollTop=log.scrollHeight;
537
+ }
538
+
539
+ function endTool(name,result,permitted){
540
+ ensureAssistant();
541
+ const r=document.createElement('div');
542
+ r.className='tool-result';
543
+ r.textContent=(permitted?'✅':'❌')+' '+result;
544
+ currentAssistant.appendChild(r);
545
+ log.scrollTop=log.scrollHeight;
546
+ }
547
+
548
+ function showPermission(id,desc){
549
+ ensureAssistant();
550
+ const p=document.createElement('div');
551
+ p.className='perm';
552
+ p.innerHTML='<span>⛔ '+desc+'</span>';
553
+ const yes=document.createElement('button');
554
+ yes.textContent='Approve';
555
+ yes.className='approve';
556
+ yes.onclick=function(){sendPermission(id,true);p.remove();};
557
+ const no=document.createElement('button');
558
+ no.textContent='Deny';
559
+ no.onclick=function(){sendPermission(id,false);p.remove();};
560
+ p.appendChild(yes);
561
+ p.appendChild(no);
562
+ currentAssistant.appendChild(p);
563
+ log.scrollTop=log.scrollHeight;
564
+ }
565
+
566
+ async function sendPermission(id,granted){
567
+ await fetch('/permission',{
568
+ method:'POST',
569
+ headers:{'Content-Type':'application/json'},
570
+ body:JSON.stringify({id:id,granted:granted})
571
+ });
572
+ }
573
+
574
+ async function sendMessage(){
575
+ const t=inp.value.trim();
576
+ if(!t) return;
577
+ add('user',t);
578
+ inp.value='';
579
+ btn.disabled=true;
580
+ currentAssistant=null;
581
+ currentText='';
582
+ try{
583
+ const resp=await fetch('/chat',{
584
+ method:'POST',
585
+ headers:{'Content-Type':'application/json'},
586
+ body:JSON.stringify({message:t})
587
+ });
588
+ const reader=resp.body.getReader();
589
+ const decoder=new TextDecoder();
590
+ let buf='';
591
+ while(true){
592
+ const chunk=await reader.read();
593
+ if(chunk.done) break;
594
+ buf+=decoder.decode(chunk.value,{stream:true});
595
+ const lines=buf.split('\n');
596
+ buf=lines.pop();
597
+ for(let i=0;i<lines.length;i++){
598
+ const line=lines[i];
599
+ if(!line.startsWith('data: ')) continue;
600
+ let d;
601
+ try{d=JSON.parse(line.slice(6));}catch(_){continue;}
602
+ if(d.type==='text') appendText(d.text);
603
+ else if(d.type==='thinking') appendThinking(d.text);
604
+ else if(d.type==='tool_start') startTool(d.name,d.inputs);
605
+ else if(d.type==='tool_end') endTool(d.name,d.result,d.permitted);
606
+ else if(d.type==='permission') showPermission(d.id,d.description);
607
+ else if(d.type==='turn_done'){
608
+ const meta=document.createElement('div');
609
+ meta.className='meta';
610
+ let txt = 'in:'+d.in+' out:'+d.out;
611
+ if (d.cache_read) txt += ' [cache hit: ' + d.cache_read + ']';
612
+ if (d.cache_write) txt += ' [cache new: ' + d.cache_write + ']';
613
+ meta.textContent=txt;
614
+ ensureAssistant().appendChild(meta);
615
+ }
616
+ else if(d.type==='error') appendText('\n[error] '+d.message);
617
+ }
618
+ }
619
+ }catch(err){
620
+ add('assistant','[network] '+err,'').classList.add('err');
621
+ }finally{
622
+ btn.disabled=false;
623
+ inp.focus();
624
+ }
625
+ }
626
+
627
+ async function clearChat(){
628
+ await fetch('/clear',{method:'POST'});
629
+ log.innerHTML='';
630
+ currentAssistant=null;
631
+ currentText='';
632
+ }
633
+
634
+ async function syncChat(){
635
+ if(btn.disabled) return;
636
+ try{
637
+ const rh = await fetch('/api/chat/history');
638
+ if (rh.ok) {
639
+ const ht = await rh.json();
640
+ const currentMsgs = log.querySelectorAll('.msg').length;
641
+ if (ht.messages.length !== currentMsgs) {
642
+ const wasNearBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 50;
643
+ log.innerHTML='';
644
+ currentAssistant=null;
645
+ currentText='';
646
+ for (const m of ht.messages) {
647
+ if (m.role === 'user') add('user', m.content);
648
+ else if (m.role === 'assistant') {
649
+ let text = typeof m.content === 'string' ? m.content : '';
650
+ if (Array.isArray(m.content)) {
651
+ const tc = m.content.find(c => c.type === 'text');
652
+ if (tc) text = tc.text;
653
+ }
654
+ if (text) add('assistant', text);
655
+ }
656
+ }
657
+ if(wasNearBottom) log.scrollTop=log.scrollHeight;
658
+ }
659
+ }
660
+ const rp = await fetch('/api/personas');
661
+ if (rp.ok) {
662
+ const jp = await rp.json();
663
+ const sel = document.getElementById('personaSelect');
664
+ sel.innerHTML = jp.personas.map(p => {
665
+ const isSelected = jp.active[p.name] ? 'selected' : '';
666
+ return `<option value="${p.name}" ${isSelected}>${p.name} (${p.role})</option>`;
667
+ }).join('');
668
+ sel.onchange = async (e) => {
669
+ await fetch('/api/personas/activate', {
670
+ method:'POST',
671
+ headers:{'Content-Type':'application/json'},
672
+ body: JSON.stringify({name:e.target.value})
673
+ });
674
+ };
675
+ }
676
+ }catch(_){}
677
+ }
678
+
679
+ async function loadHist(){ return syncChat(); }
680
+
681
+ inp.addEventListener('keydown',function(e){
682
+ if(e.key==='Enter' && !e.shiftKey){
683
+ e.preventDefault();
684
+ sendMessage();
685
+ }
686
+ });
687
+
688
+ loadHist();
689
+ setInterval(syncChat, 5000);
690
+ </script>
691
+ </body></html>"""
692
+
693
+ # ─────────────────────── Mesa Redonda HTML ──────────────────────────
694
+ RT_PAGE = r"""<!doctype html>
695
+ <html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
696
+ <title>Dulus Mesa Redonda</title>
697
+ <link rel="preconnect" href="https://fonts.googleapis.com">
698
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
699
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Archivo+Black&display=swap" rel="stylesheet">
700
+ <style>
701
+ :root{
702
+ --bg:#0a0a0a;
703
+ --bg2:#0f0f12;
704
+ --bg3:#15151a;
705
+ --ink:#f0e8df;
706
+ --dim:#6a6470;
707
+ --dim2:#3a3840;
708
+ --accent:#ff6b1f;
709
+ --accent2:#ffb347;
710
+ --mono:'JetBrains Mono',monospace;
711
+ --display:'Archivo Black','Impact',sans-serif;
712
+ --radius:4px;
713
+ }
714
+ *{box-sizing:border-box;margin:0;padding:0}
715
+ html{scroll-behavior:smooth;font-size:16px}
716
+ body{background:var(--bg);color:var(--ink);font-family:var(--mono);height:100vh;display:flex;flex-direction:column;position:relative}
717
+ ::-webkit-scrollbar{width:6px}
718
+ ::-webkit-scrollbar-track{background:var(--bg)}
719
+ ::-webkit-scrollbar-thumb{background:var(--accent);border-radius:3px}
720
+
721
+ .grid-bg{
722
+ position:fixed;inset:0;pointer-events:none;z-index:0;
723
+ background-image:linear-gradient(rgba(255,107,31,.06) 1px,transparent 1px),
724
+ linear-gradient(90deg,rgba(255,107,31,.06) 1px,transparent 1px);
725
+ background-size:40px 40px;
726
+ mask-image:radial-gradient(ellipse at center,black 30%,transparent 80%);
727
+ }
728
+
729
+ header{padding:0 40px;height:64px;background:rgba(10,10,10,.7);backdrop-filter:blur(16px);border-bottom:1px solid rgba(255,107,31,.12);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;position:relative;z-index:100}
730
+ header h1{font-family:var(--display);font-size:18px;letter-spacing:-.02em;color:var(--ink);display:flex;align-items:center;gap:12px}
731
+ header h1::before{content:"▲";font-size:18px;color:#000;background:var(--accent);width:32px;height:32px;display:grid;place-items:center;clip-path:polygon(50% 0%,100% 25%,100% 75%,50% 100%,0% 75%,0% 25%);}
732
+
733
+ header a,header button{background:var(--bg2);color:var(--dim);border:1px solid var(--dim2);padding:6px 12px;border-radius:var(--radius);cursor:pointer;font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;text-decoration:none;transition:background .2s,border-color .2s,color .2s}
734
+ header a:hover,header button:hover{background:rgba(255,107,31,.1);border-color:var(--accent);color:var(--accent)}
735
+
736
+ #setup{padding:30px;display:flex;flex-direction:column;gap:16px;align-items:center;justify-content:center;flex:1;position:relative;z-index:1}
737
+ #setup h2{color:var(--accent);font-family:var(--display);font-size:32px;letter-spacing:-.02em}
738
+ #setup p{color:var(--dim);font-size:13px;letter-spacing:.1em;text-transform:uppercase}
739
+ #setup textarea{width:400px;max-width:90vw;height:120px;background:var(--bg3);border:1px solid var(--dim2);color:var(--ink);padding:12px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;resize:none;outline:none;transition:border-color .2s}
740
+ #setup textarea:focus{border-color:var(--accent)}
741
+ #setup button{background:var(--accent);color:#000;border:none;padding:12px 32px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .2s}
742
+ #setup button:hover{background:var(--accent2)}
743
+ #grid{display:none;flex:1;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px;padding:24px 40px;overflow-y:auto;position:relative;z-index:1}
744
+ .col{background:var(--bg2);border:1px solid var(--dim2);border-radius:6px;display:flex;flex-direction:column;overflow:hidden}
745
+ .col-head{padding:12px 16px;background:var(--bg3);border-bottom:1px solid var(--dim2);font-size:12px;font-weight:700;color:var(--accent);letter-spacing:.1em;text-transform:uppercase;display:flex;justify-content:space-between;align-items:center}
746
+ .col-head .stop-btn{background:#2a0a0a;color:#ff5a6e;border:1px solid rgba(255,90,110,.4);padding:3px 10px;border-radius:3px;cursor:pointer;font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;transition:background .2s,border-color .2s}
747
+ .col-head .stop-btn:hover{background:rgba(255,90,110,.15);border-color:#ff5a6e}
748
+ .col-head .stop-btn:disabled{opacity:.4;cursor:not-allowed}
749
+ .col.stopped{border-color:rgba(255,90,110,.4)}
750
+ .col.stopped .col-head{color:#ff5a6e}
751
+ .col-body{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
752
+ .user-bubble{align-self:flex-end;background:rgba(255,107,31,.1);border:1px solid rgba(255,107,31,.25);padding:10px 14px;border-radius:6px;white-space:pre-wrap;word-wrap:break-word;font-size:13px}
753
+ .agent-bubble{align-self:flex-start;background:var(--bg3);border:1px solid var(--dim2);padding:10px 14px;border-radius:6px;white-space:pre-wrap;word-wrap:break-word;font-size:13px}
754
+ .think{font-size:11px;color:var(--dim);margin-top:6px;padding:6px 10px;border-left:2px solid var(--dim2);background:rgba(0,0,0,.2);white-space:pre-wrap}
755
+ .tool{font-size:11px;color:#a39ca8;margin-top:6px;padding:6px 10px;border-left:2px solid var(--accent);background:rgba(255,107,31,.04);white-space:pre-wrap}
756
+ .meta{font-size:10px;color:var(--dim);margin-top:6px}
757
+ .err{color:#ff5a6e}
758
+ .perm{font-size:12px;color:#ffd166;margin-top:8px;padding:12px;border:1px solid rgba(255,209,102,.25);background:rgba(255,209,102,.1);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
759
+ .perm button{background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:6px 14px;border-radius:3px;cursor:pointer;font-weight:700}
760
+ .perm button.approve{background:var(--accent);color:#000;border:none}
761
+ #inputArea{display:flex;gap:10px;padding:16px 40px;background:var(--bg2);border-top:1px solid var(--dim2);position:relative;z-index:100}
762
+ textarea{flex:1;background:var(--bg3);color:var(--ink);border:1px solid var(--dim2);padding:12px;border-radius:var(--radius);font-family:var(--mono);font-size:14px;resize:none;height:64px;outline:none;transition:border-color .2s}
763
+ textarea:focus{border-color:var(--accent)}
764
+ button.send{background:var(--accent);color:#000;border:none;padding:0 24px;border-radius:var(--radius);font-family:var(--mono);font-size:13px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .2s}
765
+ button.send:disabled{opacity:.4;cursor:not-allowed}
766
+ @media(max-width:600px){
767
+ header{padding:10px 20px;height:auto}
768
+ #grid{padding:16px 20px}
769
+ #inputArea{padding:16px 20px}
770
+ }
771
+ </style></head><body>
772
+ <div class="grid-bg"></div>
773
+ <header>
774
+ <h1>DULUS MESA REDONDA</h1>
775
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
776
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--dim)">
777
+ <input type="checkbox" id="proactiveToggle" style="accent-color:var(--accent);cursor:pointer">
778
+ <span id="proactiveLabel">Auto-turno</span>
779
+ </label>
780
+ <a href="/">Chat</a>
781
+ <a href="/dashboard">Task Manager</a>
782
+ <button onclick="location.reload()">Reiniciar</button>
783
+ </div>
784
+ </header>
785
+ <div id="setup">
786
+ <h2>Setup</h2>
787
+ <p>Introduce 3 a 5 modelos (uno por linea)</p>
788
+ <textarea id="modelsInput" placeholder="kimi-code/kimi-for-coding&#10;kimi-code2/kimi-for-coding&#10;kimi-code3/kimi-for-coding"></textarea>
789
+ <button onclick="startRt()">Iniciar</button>
790
+ </div>
791
+ <div id="grid"></div>
792
+ <div id="inputArea" style="display:none">
793
+ <textarea id="inp" placeholder="Mensaje a la mesa... (Enter envia)" autofocus></textarea>
794
+ <button class="send" id="sendBtn" onclick="sendTurn()">SEND</button>
795
+ </div>
796
+ <script>
797
+ let agents=[];
798
+ let active=false;
799
+ let proactiveMode=false;
800
+ let autoRoundsLeft=0;
801
+
802
+ const proactiveToggle=document.getElementById('proactiveToggle');
803
+ proactiveToggle.addEventListener('change',function(){
804
+ proactiveMode=this.checked;
805
+ const lbl=document.getElementById('proactiveLabel');
806
+ lbl.textContent=proactiveMode?'Auto-turno (ON)':'Auto-turno';
807
+ lbl.style.color=proactiveMode?'#00ffa3':'#888';
808
+ });
809
+
810
+ function startRt(){
811
+ const raw=document.getElementById('modelsInput').value.trim().split('\n').filter(function(x){return x.trim();});
812
+ if(raw.length<3||raw.length>5){alert('Necesitas 3 a 5 modelos');return;}
813
+ fetch('/roundtable/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({models:raw})})
814
+ .then(function(r){return r.json();})
815
+ .then(function(j){
816
+ if(!j.ok){alert(j.error);return;}
817
+ agents=j.agents;
818
+ active=true;
819
+ document.getElementById('setup').style.display='none';
820
+ document.getElementById('grid').style.display='grid';
821
+ document.getElementById('inputArea').style.display='flex';
822
+ const grid=document.getElementById('grid');
823
+ grid.innerHTML='';
824
+ agents.forEach(function(a){
825
+ const col=document.createElement('div');
826
+ col.className='col';
827
+ col.id='col-'+a;
828
+ col.innerHTML='<div class="col-head"><span>'+a+'</span><button class="stop-btn" id="stop-'+a+'" onclick="stopAgent(\''+a+'\')">Stop</button></div><div class="col-body"></div>';
829
+ grid.appendChild(col);
830
+ });
831
+ });
832
+ }
833
+
834
+ function addUserToAll(msg){
835
+ agents.forEach(function(a){
836
+ const body=document.querySelector('#col-'+a+' .col-body');
837
+ const d=document.createElement('div');
838
+ d.className='user-bubble';
839
+ d.textContent=msg;
840
+ body.appendChild(d);
841
+ body.scrollTop=body.scrollHeight;
842
+ });
843
+ }
844
+
845
+ function addUserToAgent(agentId,msg){
846
+ const body=document.querySelector('#col-'+agentId+' .col-body');
847
+ if(!body) return;
848
+ const d=document.createElement('div');
849
+ d.className='user-bubble';
850
+ d.style.borderStyle='dashed';
851
+ d.textContent=msg;
852
+ body.appendChild(d);
853
+ body.scrollTop=body.scrollHeight;
854
+ }
855
+
856
+ function parseDirectMessage(text){
857
+ const m=text.match(/^\/([a-zA-Z0-9_-]+)\s+(.+)$/);
858
+ if(!m) return null;
859
+ return {agent:m[1], message:m[2].trim()};
860
+ }
861
+
862
+ function stopAgent(id){
863
+ const btn=document.getElementById('stop-'+id);
864
+ if(btn) btn.disabled=true;
865
+ fetch('/roundtable/stop-agent',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent_id:id})})
866
+ .then(function(r){return r.json();})
867
+ .then(function(j){
868
+ if(!j.ok && btn) btn.disabled=false;
869
+ })
870
+ .catch(function(){
871
+ if(btn) btn.disabled=false;
872
+ });
873
+ }
874
+
875
+ async function sendPermission(id,granted){
876
+ await fetch('/permission',{
877
+ method:'POST',
878
+ headers:{'Content-Type':'application/json'},
879
+ body:JSON.stringify({id:id,granted:granted})
880
+ });
881
+ }
882
+
883
+ function appendToAgent(agent,type,data){
884
+ const col=document.getElementById('col-'+agent);
885
+ if(!col) return;
886
+ const body=col.querySelector('.col-body');
887
+ if(type==='agent_stopped'){
888
+ col.classList.add('stopped');
889
+ const s=document.createElement('div');
890
+ s.className='meta';
891
+ s.style.color='#ff5a6e';
892
+ s.textContent='[detenido por usuario]';
893
+ body.appendChild(s);
894
+ body.scrollTop=body.scrollHeight;
895
+ const btn=document.getElementById('stop-'+agent);
896
+ if(btn) btn.disabled=true;
897
+ if(col._lastBubble) col._lastBubble.dataset.done='1';
898
+ return;
899
+ }
900
+ if(type==='agent_done'){
901
+ const btn=document.getElementById('stop-'+agent);
902
+ if(btn) btn.disabled=true;
903
+ }
904
+ if(type==='text'){
905
+ let bubble=col._lastBubble;
906
+ if(!bubble||bubble.dataset.done==='1'){
907
+ bubble=document.createElement('div');
908
+ bubble.className='agent-bubble';
909
+ bubble.dataset.done='0';
910
+ body.appendChild(bubble);
911
+ col._lastBubble=bubble;
912
+ }
913
+ bubble.textContent=(bubble.textContent||'')+data.text;
914
+ body.scrollTop=body.scrollHeight;
915
+ }
916
+ else if(type==='thinking'){
917
+ let th=body.querySelector('.think:last-child');
918
+ if(!th||th.dataset.type!=='thinking'){
919
+ th=document.createElement('div');
920
+ th.className='think';
921
+ th.dataset.type='thinking';
922
+ th.textContent='[thinking]\n';
923
+ body.appendChild(th);
924
+ }
925
+ th.textContent+=data.text;
926
+ body.scrollTop=body.scrollHeight;
927
+ }
928
+ else if(type==='tool_start'){
929
+ const t=document.createElement('div');
930
+ t.className='tool';
931
+ t.textContent='🔧 '+data.name+'\n'+JSON.stringify(data.inputs,null,2);
932
+ body.appendChild(t);
933
+ body.scrollTop=body.scrollHeight;
934
+ }
935
+ else if(type==='tool_end'){
936
+ const r=document.createElement('div');
937
+ r.className='tool';
938
+ r.style.borderLeftColor='#444';
939
+ r.style.background='#111';
940
+ r.textContent=(data.permitted?'✅':'❌')+' '+data.result;
941
+ body.appendChild(r);
942
+ body.scrollTop=body.scrollHeight;
943
+ }
944
+ else if(type==='permission'){
945
+ const p=document.createElement('div');
946
+ p.className='perm';
947
+ p.innerHTML='<span>⛔ '+data.description+'</span>';
948
+ const yes=document.createElement('button');
949
+ yes.textContent='Approve';
950
+ yes.className='approve';
951
+ yes.onclick=function(){sendPermission(data.id,true);p.remove();};
952
+ const no=document.createElement('button');
953
+ no.textContent='Deny';
954
+ no.onclick=function(){sendPermission(data.id,false);p.remove();};
955
+ p.appendChild(yes);
956
+ p.appendChild(no);
957
+ body.appendChild(p);
958
+ body.scrollTop=body.scrollHeight;
959
+ }
960
+ else if(type==='turn_done'){
961
+ const m=document.createElement('div');
962
+ m.className='meta';
963
+ m.textContent='in:'+data.in+' out:'+data.out;
964
+ body.appendChild(m);
965
+ if(col._lastBubble) col._lastBubble.dataset.done='1';
966
+ }
967
+ else if(type==='error'){
968
+ const e=document.createElement('div');
969
+ e.className='agent-bubble';
970
+ e.style.color='#ff6b6b';
971
+ e.textContent='[error] '+data.message;
972
+ body.appendChild(e);
973
+ body.scrollTop=body.scrollHeight;
974
+ }
975
+ else if(type==='agent_done'){
976
+ const btn=document.getElementById('stop-'+agent);
977
+ if(btn) btn.disabled=true;
978
+ if(col._lastBubble) col._lastBubble.dataset.done='1';
979
+ }
980
+ }
981
+
982
+ async function sendTurnWithMessage(t){
983
+ const inp=document.getElementById('inp');
984
+ const btn=document.getElementById('sendBtn');
985
+ if(!t) return;
986
+ const direct=parseDirectMessage(t);
987
+ if(direct){
988
+ const targetAgent=agents.find(function(a){ return a.toLowerCase()===direct.agent.toLowerCase(); });
989
+ if(!targetAgent){
990
+ alert('Agente no encontrado: '+direct.agent);
991
+ return;
992
+ }
993
+ inp.value='';
994
+ btn.disabled=true;
995
+ const stopBtnDirect=document.getElementById('stop-'+targetAgent);
996
+ if(stopBtnDirect) stopBtnDirect.disabled=false;
997
+ const colDirect=document.getElementById('col-'+targetAgent);
998
+ if(colDirect) colDirect.classList.remove('stopped');
999
+ addUserToAgent(targetAgent,'[→ '+targetAgent+'] '+direct.message);
1000
+ try{
1001
+ const resp=await fetch('/roundtable/direct',{
1002
+ method:'POST',
1003
+ headers:{'Content-Type':'application/json'},
1004
+ body:JSON.stringify({agent_id:targetAgent, message:direct.message})
1005
+ });
1006
+ const reader=resp.body.getReader();
1007
+ const decoder=new TextDecoder();
1008
+ let buf='';
1009
+ while(true){
1010
+ const chunk=await reader.read();
1011
+ if(chunk.done) break;
1012
+ buf+=decoder.decode(chunk.value,{stream:true});
1013
+ const lines=buf.split('\n');
1014
+ buf=lines.pop();
1015
+ for(let i=0;i<lines.length;i++){
1016
+ const line=lines[i];
1017
+ if(!line.startsWith('data: ')) continue;
1018
+ let d;
1019
+ try{d=JSON.parse(line.slice(6));}catch(_){continue;}
1020
+ if(d.agent) appendToAgent(d.agent,d.type,d);
1021
+ }
1022
+ }
1023
+ }catch(err){
1024
+ alert('[network] '+err);
1025
+ }finally{
1026
+ btn.disabled=false;
1027
+ inp.focus();
1028
+ }
1029
+ return;
1030
+ }
1031
+ inp.value='';
1032
+ btn.disabled=true;
1033
+ agents.forEach(function(a){
1034
+ const stopBtn=document.getElementById('stop-'+a);
1035
+ if(stopBtn) stopBtn.disabled=false;
1036
+ const col=document.getElementById('col-'+a);
1037
+ if(col) col.classList.remove('stopped');
1038
+ });
1039
+ addUserToAll(t);
1040
+ try{
1041
+ const resp=await fetch('/roundtable/chat',{
1042
+ method:'POST',
1043
+ headers:{'Content-Type':'application/json'},
1044
+ body:JSON.stringify({message:t})
1045
+ });
1046
+ const reader=resp.body.getReader();
1047
+ const decoder=new TextDecoder();
1048
+ let buf='';
1049
+ let gotDone=false;
1050
+ while(true){
1051
+ const chunk=await reader.read();
1052
+ if(chunk.done) break;
1053
+ buf+=decoder.decode(chunk.value,{stream:true});
1054
+ const lines=buf.split('\n');
1055
+ buf=lines.pop();
1056
+ for(let i=0;i<lines.length;i++){
1057
+ const line=lines[i];
1058
+ if(!line.startsWith('data: ')) continue;
1059
+ let d;
1060
+ try{d=JSON.parse(line.slice(6));}catch(_){continue;}
1061
+ if(d.type==='done'){gotDone=true;}
1062
+ if(d.agent) appendToAgent(d.agent,d.type,d);
1063
+ }
1064
+ }
1065
+ // if proactive is on and we got a clean done, auto-fire next round
1066
+ if(gotDone && proactiveMode && autoRoundsLeft>0){
1067
+ autoRoundsLeft--;
1068
+ setTimeout(function(){
1069
+ sendTurnWithMessage('Proactive mode active keep working');
1070
+ },800);
1071
+ }
1072
+ }catch(err){
1073
+ alert('[network] '+err);
1074
+ }finally{
1075
+ btn.disabled=false;
1076
+ inp.focus();
1077
+ }
1078
+ }
1079
+
1080
+ async function sendTurn(){
1081
+ const t=document.getElementById('inp').value.trim();
1082
+ if(!t) return;
1083
+ if(proactiveMode){
1084
+ autoRoundsLeft=10; // max 10 auto rounds when user manually triggers
1085
+ }
1086
+ await sendTurnWithMessage(t);
1087
+ }
1088
+
1089
+ async function restoreRt(){
1090
+ try{
1091
+ const r = await fetch('/roundtable/status');
1092
+ const j = await r.json();
1093
+ if(j.active && j.agents && j.agents.length){
1094
+ agents = j.agents;
1095
+ active = true;
1096
+ document.getElementById('setup').style.display='none';
1097
+ document.getElementById('grid').style.display='grid';
1098
+ document.getElementById('inputArea').style.display='flex';
1099
+ const grid = document.getElementById('grid');
1100
+ grid.innerHTML = '';
1101
+ agents.forEach(function(a){
1102
+ const col = document.createElement('div');
1103
+ col.className = 'col';
1104
+ col.id = 'col-' + a;
1105
+ col.innerHTML = '<div class="col-head"><span>' + a + '</span><button class="stop-btn" id="stop-' + a + '" onclick="stopAgent(\'' + a + '\')">Stop</button></div><div class="col-body"></div>';
1106
+ grid.appendChild(col);
1107
+ });
1108
+ if(j.history && j.history.length){
1109
+ j.history.forEach(function(h){
1110
+ if(h.author === 'Usuario'){
1111
+ addUserToAll(h.text);
1112
+ } else {
1113
+ appendToAgent(h.author, 'text', {text: h.text});
1114
+ const col = document.getElementById('col-' + h.author);
1115
+ if(col && col._lastBubble){
1116
+ col._lastBubble.dataset.done = '1';
1117
+ }
1118
+ }
1119
+ });
1120
+ }
1121
+ }
1122
+ }catch(_){}
1123
+ }
1124
+
1125
+ document.getElementById('inp').addEventListener('keydown',function(e){
1126
+ if(e.key==='Enter' && !e.shiftKey){
1127
+ e.preventDefault();
1128
+ sendTurn();
1129
+ }
1130
+ });
1131
+
1132
+ restoreRt();
1133
+ </script>
1134
+ </body></html>"""
1135
+
1136
+ @app.route("/")
1137
+ def home() -> Response:
1138
+ return Response(CHAT_PAGE, mimetype="text/html")
1139
+
1140
+ @app.route("/roundtable")
1141
+ def roundtable_page() -> Response:
1142
+ return Response(RT_PAGE, mimetype="text/html")
1143
+
1144
+ @app.route("/state")
1145
+ def state_endpoint() -> Response:
1146
+ with _LOCK:
1147
+ hist = [dict(m) for m in (STATE.messages if STATE else [])]
1148
+ model = CONFIG.get("model", "?") if CONFIG else "?"
1149
+ return jsonify(model=model, history=hist)
1150
+
1151
+ @app.route("/clear", methods=["POST"])
1152
+ def clear() -> Response:
1153
+ with _LOCK:
1154
+ if STATE:
1155
+ STATE.messages.clear()
1156
+ return jsonify(ok=True)
1157
+
1158
+ @app.route("/shutdown", methods=["POST"])
1159
+ def shutdown() -> Response:
1160
+ return jsonify(ok=True)
1161
+
1162
+ @app.route("/permission", methods=["POST"])
1163
+ def permission() -> Response:
1164
+ body = request.get_json(silent=True) or {}
1165
+ pid = body.get("id")
1166
+ granted = body.get("granted", False)
1167
+ with _LOCK:
1168
+ item = _PENDING_PERMISSIONS.get(pid)
1169
+ if item is None:
1170
+ return jsonify(error="not found"), 404
1171
+ req, evt = item
1172
+ req.granted = bool(granted)
1173
+ evt.set()
1174
+ return jsonify(ok=True)
1175
+
1176
+ @app.route("/chat", methods=["POST"])
1177
+ def chat() -> Response:
1178
+ body = request.get_json(silent=True) or {}
1179
+ msg = (body.get("message") or "").strip()
1180
+ if not msg:
1181
+ return jsonify(error="empty message"), 400
1182
+
1183
+ # Slash commands: same behavior as the Telegram bridge —
1184
+ # run via REPL's _handle_slash_callback, capture stdout,
1185
+ # stream output back as text events.
1186
+ if msg.startswith("/") and len(msg) > 1:
1187
+ def generate_slash():
1188
+ yield 'data: {"type":"start"}\n\n'
1189
+ try:
1190
+ output, assistant_reply = _run_slash_command(msg)
1191
+ if output:
1192
+ yield f"data: {json.dumps({'type':'text','text':output})}\n\n"
1193
+ if assistant_reply:
1194
+ sep = "\n\n" if output else ""
1195
+ yield f"data: {json.dumps({'type':'text','text':sep + assistant_reply})}\n\n"
1196
+ except Exception as e:
1197
+ yield f'data: {json.dumps({"type":"error","message":f"{type(e).__name__}: {e}"})}\n\n'
1198
+ yield 'data: {"type":"done"}\n\n'
1199
+ return Response(
1200
+ stream_with_context(generate_slash()),
1201
+ mimetype="text/event-stream",
1202
+ )
1203
+
1204
+ def generate():
1205
+ q: queue.Queue = queue.Queue(maxsize=512)
1206
+ exc_holder = [None]
1207
+
1208
+ def producer():
1209
+ try:
1210
+ for ev in _run_agent_mirror(msg):
1211
+ result = _event_to_dict(ev)
1212
+ if result is None:
1213
+ continue
1214
+ if isinstance(result, tuple):
1215
+ payload, evt = result
1216
+ q.put(payload)
1217
+ evt.wait(timeout=300)
1218
+ _PENDING_PERMISSIONS.pop(payload.get("id"), None)
1219
+ continue
1220
+ q.put(result)
1221
+ except Exception as e:
1222
+ exc_holder[0] = e
1223
+ finally:
1224
+ q.put(None)
1225
+
1226
+ t = threading.Thread(target=producer, daemon=True)
1227
+ t.start()
1228
+
1229
+ yield 'data: {"type":"start"}\n\n'
1230
+
1231
+ while True:
1232
+ item = q.get()
1233
+ if item is None:
1234
+ break
1235
+ yield f"data: {json.dumps(item)}\n\n"
1236
+
1237
+ if exc_holder[0]:
1238
+ err = exc_holder[0]
1239
+ yield f'data: {json.dumps({"type":"error","message":f"{type(err).__name__}: {err}"})}\n\n'
1240
+
1241
+ yield 'data: {"type":"done"}\n\n'
1242
+
1243
+ return Response(
1244
+ stream_with_context(generate()),
1245
+ mimetype="text/event-stream",
1246
+ )
1247
+
1248
+ # ── Roundtable endpoints ─────────────────────────────────────────────
1249
+
1250
+ @app.route("/roundtable/start", methods=["POST"])
1251
+ def roundtable_start() -> Response:
1252
+ body = request.get_json(silent=True) or {}
1253
+ models = body.get("models", [])
1254
+ if not (3 <= len(models) <= 5):
1255
+ return jsonify(ok=False, error="Necesitas 3 a 5 modelos"), 400
1256
+
1257
+ with ROUNDTABLE_LOCK:
1258
+ ROUNDTABLE_AGENTS.clear()
1259
+ ROUNDTABLE_HISTORY.clear()
1260
+ for i, model in enumerate(models):
1261
+ letter = chr(65 + i)
1262
+ ROUNDTABLE_AGENTS.append(RoundtableAgent(letter, model.strip()))
1263
+
1264
+ return jsonify(ok=True, agents=[a.id for a in ROUNDTABLE_AGENTS])
1265
+
1266
+ @app.route("/roundtable/chat", methods=["POST"])
1267
+ def roundtable_chat() -> Response:
1268
+ body = request.get_json(silent=True) or {}
1269
+ msg = (body.get("message") or "").strip()
1270
+ if not msg:
1271
+ return jsonify(error="empty message"), 400
1272
+
1273
+ with ROUNDTABLE_LOCK:
1274
+ agents = list(ROUNDTABLE_AGENTS)
1275
+ if not agents:
1276
+ return jsonify(error="no roundtable active"), 400
1277
+
1278
+ # Slash commands: run once via REPL handler, broadcast the
1279
+ # output to every agent column. Same pattern as Telegram bridge.
1280
+ if msg.startswith("/") and len(msg) > 1:
1281
+ def generate_slash_rt():
1282
+ yield 'data: {"type":"start"}\n\n'
1283
+ try:
1284
+ output, assistant_reply = _run_slash_command(msg)
1285
+ chunks = []
1286
+ if output:
1287
+ chunks.append(output)
1288
+ if assistant_reply:
1289
+ chunks.append(assistant_reply)
1290
+ full = "\n\n".join(chunks) if chunks else f"✅ {msg.split()[0]} executed."
1291
+ for ag in agents:
1292
+ yield f"data: {json.dumps({'type':'text','text':full,'agent':ag.id})}\n\n"
1293
+ yield f"data: {json.dumps({'type':'agent_done','agent':ag.id,'text':full})}\n\n"
1294
+ except Exception as e:
1295
+ err = f"{type(e).__name__}: {e}"
1296
+ for ag in agents:
1297
+ yield f"data: {json.dumps({'type':'error','agent':ag.id,'message':err})}\n\n"
1298
+ yield 'data: {"type":"done"}\n\n'
1299
+ return Response(
1300
+ stream_with_context(generate_slash_rt()),
1301
+ mimetype="text/event-stream",
1302
+ )
1303
+
1304
+ # Snapshot history BEFORE this turn, then add user message
1305
+ msg = _sanitize_for_api(msg)
1306
+ with ROUNDTABLE_LOCK:
1307
+ history_snapshot = list(ROUNDTABLE_HISTORY)
1308
+ ROUNDTABLE_HISTORY.append(("Usuario", msg))
1309
+
1310
+ def generate():
1311
+ q: queue.Queue = queue.Queue(maxsize=1024)
1312
+ active_flags = [True] * len(agents)
1313
+ agent_results: dict[str, str] = {}
1314
+
1315
+ def run_one(idx: int):
1316
+ try:
1317
+ _run_agent_for_roundtable(agents[idx], msg, history_snapshot, q)
1318
+ finally:
1319
+ active_flags[idx] = False
1320
+
1321
+ threads = [
1322
+ threading.Thread(target=run_one, args=(i,), daemon=True)
1323
+ for i in range(len(agents))
1324
+ ]
1325
+ for t in threads:
1326
+ t.start()
1327
+
1328
+ yield 'data: {"type":"start"}\n\n'
1329
+
1330
+ while any(active_flags) or not q.empty():
1331
+ try:
1332
+ item = q.get(timeout=0.2)
1333
+ except queue.Empty:
1334
+ continue
1335
+ if item.get("type") == "agent_done":
1336
+ agent_results[item["agent"]] = item.get("text", "")
1337
+ yield f"data: {json.dumps(item)}\n\n"
1338
+
1339
+ # All done — save responses to global history for next turn
1340
+ with ROUNDTABLE_LOCK:
1341
+ for agent in agents:
1342
+ text = agent_results.get(agent.id, "")
1343
+ text = _sanitize_for_api(text)
1344
+ if text:
1345
+ ROUNDTABLE_HISTORY.append((agent.id, text))
1346
+
1347
+ yield 'data: {"type":"done"}\n\n'
1348
+
1349
+ return Response(
1350
+ stream_with_context(generate()),
1351
+ mimetype="text/event-stream",
1352
+ )
1353
+
1354
+ @app.route("/roundtable/stop", methods=["POST"])
1355
+ def roundtable_stop() -> Response:
1356
+ with ROUNDTABLE_LOCK:
1357
+ ROUNDTABLE_AGENTS.clear()
1358
+ return jsonify(ok=True)
1359
+
1360
+ @app.route("/roundtable/stop-agent", methods=["POST"])
1361
+ def roundtable_stop_agent() -> Response:
1362
+ body = request.get_json(silent=True) or {}
1363
+ agent_id = body.get("agent_id", "").strip()
1364
+ if not agent_id:
1365
+ return jsonify(ok=False, error="missing agent_id"), 400
1366
+ with _STOP_EVENTS_LOCK:
1367
+ evt = _AGENT_STOP_EVENTS.get(agent_id)
1368
+ if evt is None:
1369
+ return jsonify(ok=False, error="agent not running"), 404
1370
+ evt.set()
1371
+ return jsonify(ok=True)
1372
+
1373
+ @app.route("/roundtable/status", methods=["GET"])
1374
+ def roundtable_status() -> Response:
1375
+ with ROUNDTABLE_LOCK:
1376
+ active = len(ROUNDTABLE_AGENTS) > 0
1377
+ agents = [a.id for a in ROUNDTABLE_AGENTS]
1378
+ history = [{"author": h[0], "text": h[1]} for h in ROUNDTABLE_HISTORY]
1379
+ return jsonify(active=active, agents=agents, history=history)
1380
+
1381
+ @app.route("/roundtable/direct", methods=["POST"])
1382
+ def roundtable_direct() -> Response:
1383
+ body = request.get_json(silent=True) or {}
1384
+ agent_id = (body.get("agent_id") or "").strip()
1385
+ msg = (body.get("message") or "").strip()
1386
+ if not agent_id or not msg:
1387
+ return jsonify(error="agent_id and message required"), 400
1388
+
1389
+ with ROUNDTABLE_LOCK:
1390
+ target = None
1391
+ for a in ROUNDTABLE_AGENTS:
1392
+ if a.id.lower() == agent_id.lower():
1393
+ target = a
1394
+ break
1395
+ if target is None:
1396
+ return jsonify(error="agent not found"), 404
1397
+
1398
+ msg = _sanitize_for_api(msg)
1399
+ with ROUNDTABLE_LOCK:
1400
+ history_snapshot = list(ROUNDTABLE_HISTORY)
1401
+ ROUNDTABLE_HISTORY.append(("Usuario", f"[{target.id}] {msg}"))
1402
+
1403
+ def generate():
1404
+ q: queue.Queue = queue.Queue(maxsize=1024)
1405
+ final_text = [""]
1406
+
1407
+ def run_one():
1408
+ try:
1409
+ _run_agent_for_roundtable(target, msg, history_snapshot, q)
1410
+ finally:
1411
+ q.put(None)
1412
+
1413
+ t = threading.Thread(target=run_one, daemon=True)
1414
+ t.start()
1415
+
1416
+ yield 'data: {"type":"start"}\n\n'
1417
+
1418
+ while True:
1419
+ try:
1420
+ item = q.get(timeout=0.5)
1421
+ except queue.Empty:
1422
+ continue
1423
+ if item is None:
1424
+ break
1425
+ if item.get("type") == "agent_done":
1426
+ final_text[0] = item.get("text", "")
1427
+ yield f"data: {json.dumps(item)}\n\n"
1428
+
1429
+ with ROUNDTABLE_LOCK:
1430
+ text = _sanitize_for_api(final_text[0])
1431
+ if text:
1432
+ ROUNDTABLE_HISTORY.append((target.id, text))
1433
+
1434
+ yield 'data: {"type":"done"}\n\n'
1435
+
1436
+ return Response(
1437
+ stream_with_context(generate()),
1438
+ mimetype="text/event-stream",
1439
+ )
1440
+
1441
+ # ── DULUS 2 UNIFIED ENDPOINTS ──
1442
+
1443
+ @app.route("/api/events")
1444
+ def api_events():
1445
+ def generate():
1446
+ q = queue.Queue(maxsize=100)
1447
+ _add_sse_client(q)
1448
+ yield f"event: connected\ndata: {json.dumps({'message':'Dulus SSE active'})}\n\n"
1449
+ try:
1450
+ while True:
1451
+ try:
1452
+ msg = q.get(timeout=30)
1453
+ yield msg
1454
+ except queue.Empty:
1455
+ yield ":\n\n"
1456
+ finally:
1457
+ _remove_sse_client(q)
1458
+ return Response(stream_with_context(generate()), mimetype="text/event-stream")
1459
+
1460
+ @app.route("/api/health")
1461
+ def api_health():
1462
+ return jsonify({"status": "ok", "agent": "Dulus", "mode": "proactive", "version": "2026.04.26"})
1463
+
1464
+ @app.route("/api/tasks", methods=["GET"])
1465
+ def get_api_tasks():
1466
+ tasks = task_list()
1467
+ return jsonify([t.to_dict() for t in tasks])
1468
+
1469
+ @app.route("/api/context", methods=["GET"])
1470
+ def api_context():
1471
+ return jsonify(build_context())
1472
+
1473
+ @app.route("/api/context/compact", methods=["GET"])
1474
+ def api_context_compact():
1475
+ return Response(get_compact_context(), mimetype="text/plain")
1476
+
1477
+ @app.route("/api/chat/history", methods=["GET"])
1478
+ def api_chat_history():
1479
+ msgs = []
1480
+ if STATE and hasattr(STATE, "messages"):
1481
+ for m in STATE.messages:
1482
+ msgs.append({"role": m.get("role", ""), "content": m.get("content", "")})
1483
+ return jsonify({"messages": msgs})
1484
+
1485
+ @app.route("/api/smart-context", methods=["GET"])
1486
+ def api_smart_context():
1487
+ return jsonify(build_smart_context())
1488
+
1489
+ @app.route("/api/smart-context/compact", methods=["POST"])
1490
+ def api_smart_context_compact():
1491
+ from backend.context import force_compaction
1492
+ return jsonify(force_compaction())
1493
+
1494
+ @app.route("/api/quick-message", methods=["POST"])
1495
+ def api_quick_message():
1496
+ body = request.get_json(silent=True) or {}
1497
+ msg = (body.get("message") or "").strip()
1498
+ if not msg:
1499
+ return jsonify(error="empty message"), 400
1500
+
1501
+ def run_blind():
1502
+ try:
1503
+ for event in _run_agent_mirror(msg):
1504
+ from agent import PermissionRequest
1505
+ if isinstance(event, PermissionRequest):
1506
+ # Auto-approve silently for background quick messages
1507
+ event.granted = True
1508
+ except Exception as e:
1509
+ import traceback
1510
+ traceback.print_exc()
1511
+
1512
+ threading.Thread(target=run_blind, daemon=True).start()
1513
+ return jsonify(ok=True)
1514
+
1515
+ @app.route("/api/agents", methods=["GET"])
1516
+ def api_agents():
1517
+ return jsonify(build_context().get("agents", []))
1518
+
1519
+ @app.route("/api/personas", methods=["GET"])
1520
+ def api_get_personas():
1521
+ return jsonify({"personas": get_all_personas(), "active": get_active_persona()})
1522
+
1523
+ @app.route("/api/personas/active", methods=["GET"])
1524
+ def api_personas_active():
1525
+ return jsonify(get_active_persona())
1526
+
1527
+ @app.route("/api/personas/<pid>", methods=["GET"])
1528
+ def api_get_persona_id(pid):
1529
+ p = get_persona(pid)
1530
+ if p: return jsonify(p)
1531
+ return jsonify(error="Not found"), 404
1532
+
1533
+ @app.route("/api/personas", methods=["POST"])
1534
+ def api_create_persona():
1535
+ data = request.get_json(silent=True) or {}
1536
+ r = create_persona(data)
1537
+ broadcast_event("persona_created", r)
1538
+ return jsonify(r), 201
1539
+
1540
+ @app.route("/api/tasks", methods=["POST"])
1541
+ def api_create_task():
1542
+ data = request.get_json(silent=True) or {}
1543
+ t = task_create(
1544
+ subject=data.get("subject", "New Task"),
1545
+ description=data.get("description", data.get("metadata", {}).get("description", "")),
1546
+ metadata=data.get("metadata", {}),
1547
+ )
1548
+ result = t.to_dict()
1549
+ broadcast_event("task_created", result)
1550
+ return jsonify(result), 201
1551
+
1552
+ @app.route("/api/tasks/<tid>", methods=["POST"])
1553
+ def api_update_task(tid):
1554
+ data = request.get_json(silent=True) or {}
1555
+ t, fields = task_update(
1556
+ task_id=tid,
1557
+ subject=data.get("subject"),
1558
+ description=data.get("description"),
1559
+ status=data.get("status"),
1560
+ owner=data.get("owner"),
1561
+ metadata=data.get("metadata"),
1562
+ )
1563
+ if t:
1564
+ result = t.to_dict()
1565
+ broadcast_event("task_updated", result)
1566
+ return jsonify(result)
1567
+ return jsonify(error="Not found"), 404
1568
+
1569
+ @app.route("/api/plugins", methods=["GET"])
1570
+ def api_get_plugins():
1571
+ import os
1572
+ user_plugins_dir = Path(os.path.expanduser("~")) / ".dulus" / "plugins"
1573
+ plugins = []
1574
+ if user_plugins_dir.exists():
1575
+ for d in sorted(user_plugins_dir.iterdir()):
1576
+ if d.is_dir() and not d.name.startswith(".") and not d.name.startswith("__"):
1577
+ plugins.append({
1578
+ "name": d.name,
1579
+ "status": "enabled",
1580
+ "source": "user",
1581
+ "path": str(d),
1582
+ })
1583
+ # Also include any from dulus2's hot-reload system
1584
+ try:
1585
+ load_all_plugins()
1586
+ for p in get_plugin_info():
1587
+ if not any(ep["name"] == p["name"] for ep in plugins):
1588
+ plugins.append(p)
1589
+ except Exception:
1590
+ pass
1591
+ return jsonify({"plugins": plugins, "count": len(plugins)})
1592
+
1593
+ @app.route("/api/plugins/status", methods=["GET"])
1594
+ def api_plugins_status():
1595
+ return jsonify(watcher_status())
1596
+
1597
+ @app.route("/api/plugins/reload", methods=["POST"])
1598
+ def api_plugins_reload():
1599
+ data = request.get_json(silent=True) or {}
1600
+ name = data.get("name")
1601
+ if name:
1602
+ from backend.plugins import PLUGINS_DIR
1603
+ r = reload_plugin(PLUGINS_DIR / f"{name}.py")
1604
+ dr = {"name": r.get("name", name), "version": r.get("version", "?"), "status": r.get("status", "?")}
1605
+ broadcast_event("plugin_reloaded", dr)
1606
+ return jsonify(dr)
1607
+ else:
1608
+ load_all_plugins()
1609
+ inf = get_plugin_info()
1610
+ broadcast_event("plugins_reloaded", {"count": len(inf)})
1611
+ return jsonify({"plugins": inf})
1612
+
1613
+ # ── Personas activate ──
1614
+ @app.route("/api/personas/activate", methods=["POST"])
1615
+ def api_personas_activate():
1616
+ data = request.get_json(silent=True) or {}
1617
+ pid = data.get("id")
1618
+ if not pid:
1619
+ return jsonify(error="Missing persona id"), 400
1620
+ result = set_active_persona(pid)
1621
+ if result:
1622
+ broadcast_event("persona_activated", result)
1623
+ return jsonify({"activated": True, "persona": result})
1624
+ return jsonify(error="Persona not found"), 404
1625
+
1626
+ # ── Marketplace ──
1627
+ @app.route("/api/marketplace", methods=["GET"])
1628
+ def api_marketplace():
1629
+ q = request.args.get("q", "")
1630
+ tag = request.args.get("tag", "")
1631
+ return jsonify({"plugins": search_plugins(q, tag)})
1632
+
1633
+ @app.route("/api/marketplace/stats", methods=["GET"])
1634
+ def api_marketplace_stats():
1635
+ return jsonify(marketplace_stats())
1636
+
1637
+ @app.route("/api/marketplace/install", methods=["POST"])
1638
+ def api_marketplace_install():
1639
+ data = request.get_json(silent=True) or {}
1640
+ plugin_id = data.get("id")
1641
+ if not plugin_id:
1642
+ return jsonify(error="Missing plugin id"), 400
1643
+ result = install_plugin(plugin_id)
1644
+ if result:
1645
+ broadcast_event("marketplace_install", result)
1646
+ return jsonify({"installed": True, "plugin": result})
1647
+ return jsonify(error="Plugin not found"), 404
1648
+
1649
+ @app.route("/api/marketplace/uninstall", methods=["POST"])
1650
+ def api_marketplace_uninstall():
1651
+ data = request.get_json(silent=True) or {}
1652
+ plugin_id = data.get("id")
1653
+ if not plugin_id:
1654
+ return jsonify(error="Missing plugin id"), 400
1655
+ result = uninstall_plugin(plugin_id)
1656
+ if result:
1657
+ broadcast_event("marketplace_uninstall", result)
1658
+ return jsonify({"uninstalled": True, "plugin": result})
1659
+ return jsonify(error="Plugin not found"), 404
1660
+
1661
+ # ── MemPalace ──
1662
+ @app.route("/api/mempalace", methods=["GET"])
1663
+ def api_mempalace():
1664
+ try:
1665
+ from backend.mempalace_bridge import load_cache, get_mempalace_compact_text
1666
+ data = load_cache()
1667
+ data["compact_text"] = get_mempalace_compact_text()
1668
+ return jsonify(data)
1669
+ except Exception as e:
1670
+ return jsonify(error=f"MemPalace error: {e}"), 500
1671
+
1672
+ # ── Themes ──
1673
+ @app.route("/api/themes", methods=["GET"])
1674
+ def api_themes():
1675
+ try:
1676
+ from gui.themes import THEMES
1677
+ theme_list = {name: f"{t['accent']} accent, {t['bg']} bg" for name, t in THEMES.items()}
1678
+ return jsonify({"themes": theme_list})
1679
+ except Exception:
1680
+ return jsonify({"themes": {}})
1681
+
1682
+ @app.route("/api/themes/<theme_name>/css", methods=["GET"])
1683
+ def api_theme_css(theme_name):
1684
+ try:
1685
+ from gui.themes import THEMES
1686
+ t = THEMES.get(theme_name)
1687
+ if not t:
1688
+ return Response("", mimetype="text/css")
1689
+ css = ":root{\n"
1690
+ css += f" --bg:{t['bg']};\n"
1691
+ css += f" --bg2:{t['card']};\n"
1692
+ css += f" --bg3:{t.get('code_bg', t['card'])};\n"
1693
+ css += f" --ink:{t['text']};\n"
1694
+ css += f" --dim:{t['dim']};\n"
1695
+ css += f" --dim2:{t['border']};\n"
1696
+ css += f" --accent:{t['accent']};\n"
1697
+ css += f" --accent2:{t.get('accent_hover', t['accent'])};\n"
1698
+ css += f" --green:{t.get('success', '#4caf50')};\n"
1699
+ css += f" --red:{t.get('error', '#ff6b6b')};\n"
1700
+ css += f" --yellow:{t.get('warning', '#FFC107')};\n"
1701
+ css += f" --blue:{t['accent']};\n"
1702
+ css += "}\n"
1703
+ return Response(css, mimetype="text/css")
1704
+ except Exception:
1705
+ return Response("", mimetype="text/css")
1706
+
1707
+ # ── Dashboard static serving ──
1708
+ @app.route("/dashboard")
1709
+ @app.route("/dashboard/")
1710
+ def dashboard_page():
1711
+ target = DASHBOARD_DIR / "index.html"
1712
+ if target.exists():
1713
+ return Response(target.read_bytes(), mimetype="text/html")
1714
+ return "Dashboard not found", 404
1715
+
1716
+ @app.route("/dashboard/<path:filepath>")
1717
+ def dashboard_static(filepath):
1718
+ target = DASHBOARD_DIR / filepath
1719
+ if target.exists() and target.is_file():
1720
+ ctype = "text/html"
1721
+ if filepath.endswith(".css"): ctype = "text/css"
1722
+ elif filepath.endswith(".js"): ctype = "application/javascript"
1723
+ elif filepath.endswith(".json"): ctype = "application/json"
1724
+ elif filepath.endswith(".png"): ctype = "image/png"
1725
+ elif filepath.endswith(".svg"): ctype = "image/svg+xml"
1726
+ return Response(target.read_bytes(), mimetype=ctype)
1727
+ return "Not found", 404
1728
+
1729
+ return app
1730
+
1731
+
1732
+ def start(state: AgentState, config: dict, port: int = 5000, open_browser: bool = False) -> bool:
1733
+ global STATE, CONFIG, _SERVER_THREAD, _SERVER_PORT, _WERKZEUG_SERVER
1734
+ if _SERVER_THREAD and _SERVER_THREAD.is_alive():
1735
+ return False
1736
+ STATE = state
1737
+ CONFIG = config
1738
+ _SERVER_PORT = port
1739
+ app = create_app()
1740
+ if open_browser:
1741
+ threading.Timer(1.0, lambda: webbrowser.open(f"http://127.0.0.1:{port}/")).start()
1742
+
1743
+ from werkzeug.serving import make_server
1744
+
1745
+ _WERKZEUG_SERVER = make_server("0.0.0.0", port, app, threaded=True)
1746
+ _SERVER_THREAD = threading.Thread(target=_WERKZEUG_SERVER.serve_forever, daemon=True)
1747
+ _SERVER_THREAD.start()
1748
+ return True
1749
+
1750
+
1751
+ def stop() -> None:
1752
+ global _SERVER_THREAD, _WERKZEUG_SERVER
1753
+ srv = _WERKZEUG_SERVER
1754
+ if srv is not None:
1755
+ srv.shutdown()
1756
+ _SERVER_THREAD = None
1757
+ _WERKZEUG_SERVER = None
1758
+
1759
+
1760
+ def is_running() -> bool:
1761
+ return _SERVER_THREAD is not None and _SERVER_THREAD.is_alive()