adelie-ai 0.1.8 → 0.2.0

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.
@@ -1 +1,16 @@
1
1
  """Adelie — Self-communicating autonomous AI loop system."""
2
+
3
+ from pathlib import Path as _Path
4
+ import json as _json
5
+
6
+ def _get_version() -> str:
7
+ """Read version from package.json (single source of truth)."""
8
+ try:
9
+ pkg = _Path(__file__).resolve().parent.parent / "package.json"
10
+ if pkg.exists():
11
+ return _json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.0.0")
12
+ except Exception:
13
+ pass
14
+ return "0.0.0"
15
+
16
+ __version__ = _get_version()
package/adelie/cli.py CHANGED
@@ -167,10 +167,15 @@ def cmd_help(args: argparse.Namespace) -> None:
167
167
  [green]adelie config[/green] Show current configuration
168
168
  [green]adelie config --provider[/green] [dim]ollama[/dim] Switch LLM provider
169
169
  [green]adelie config --model[/green] [dim]gemma3:12b[/dim] Set model
170
- [green]adelie config --interval[/green] [dim]60[/dim] Set loop interval (seconds)
171
170
  [green]adelie config --api-key[/green] [dim]KEY[/dim] Set Gemini API key
172
171
  [green]adelie config --ollama-url[/green] [dim]URL[/dim] Set Ollama server URL
173
- [green]adelie config --lang[/green] [dim]ko|en[/dim] Set display language
172
+
173
+ [bold]Settings[/bold]
174
+ [green]adelie settings[/green] Show all settings (workspace + global)
175
+ [green]adelie settings --global[/green] Show global-only settings
176
+ [green]adelie settings set[/green] [dim]<key> <val>[/dim] Change a workspace setting
177
+ [green]adelie settings set --global[/green] [dim]<key> <val>[/dim] Change a global setting
178
+ [green]adelie settings reset[/green] [dim]<key>[/dim] Reset setting to default
174
179
 
175
180
  [bold]Monitoring[/bold]
176
181
  [green]adelie status[/green] System health & provider status
@@ -783,6 +788,236 @@ def cmd_config(args: argparse.Namespace) -> None:
783
788
  console.print(f"[dim]Config: {_workspace_config_path()}[/dim]")
784
789
 
785
790
 
791
+ # ═══════════════════════════════════════════════════════════════════════════════
792
+ # SETTINGS
793
+ # ═══════════════════════════════════════════════════════════════════════════════
794
+
795
+ # Settings definition: key -> (env_var, config_json_key, default, type, description)
796
+ # env_var: key in .env file (or None if stored in config.json)
797
+ # config_json_key: key in config.json (or None if stored in .env)
798
+ _SETTINGS_DEFS: dict[str, dict] = {
799
+ "dashboard": {"env": "DASHBOARD_ENABLED", "cfg": None, "default": "true", "type": "bool", "desc": "대시보드 on/off", "group": "🌐 Dashboard"},
800
+ "dashboard.port": {"env": "DASHBOARD_PORT", "cfg": None, "default": "5042", "type": "int", "desc": "대시보드 포트", "group": "🌐 Dashboard"},
801
+ "loop.interval": {"env": None, "cfg": "loop_interval", "default": "30", "type": "int", "desc": "루프 간격 (초)", "group": "⚡ Runtime"},
802
+ "plan.mode": {"env": "PLAN_MODE", "cfg": None, "default": "false", "type": "bool", "desc": "Plan Mode (승인 후 실행)", "group": "⚡ Runtime"},
803
+ "sandbox": {"env": "SANDBOX_MODE", "cfg": None, "default": "none", "type": "str", "desc": "샌드박스 (none/seatbelt/docker)", "group": "⚡ Runtime"},
804
+ "mcp": {"env": "MCP_ENABLED", "cfg": None, "default": "true", "type": "bool", "desc": "MCP 프로토콜 on/off", "group": "⚡ Runtime"},
805
+ "browser.search": {"env": "BROWSER_SEARCH_ENABLED", "cfg": None, "default": "true", "type": "bool", "desc": "브라우저 검색 on/off", "group": "🔍 Search"},
806
+ "browser.max_pages": {"env": "BROWSER_SEARCH_MAX_PAGES","cfg": None,"default": "3", "type": "int", "desc": "검색 최대 페이지", "group": "🔍 Search"},
807
+ "fallback.models": {"env": "FALLBACK_MODELS", "cfg": None, "default": "", "type": "str", "desc": "폴백 모델 체인", "group": "🔄 Fallback"},
808
+ "fallback.cooldown": {"env": "FALLBACK_COOLDOWN_SECONDS","cfg": None,"default": "60","type": "int", "desc": "폴백 쿨다운 (초)", "group": "🔄 Fallback"},
809
+ "language": {"env": "ADELIE_LANGUAGE", "cfg": None, "default": "ko", "type": "str", "desc": "언어 (ko/en)", "group": "🎨 Display"},
810
+ }
811
+
812
+ _GLOBAL_SETTINGS_FILE = Path.home() / ".adelie" / "settings.json"
813
+
814
+
815
+ def _load_global_settings() -> dict:
816
+ """Load global settings from ~/.adelie/settings.json."""
817
+ if _GLOBAL_SETTINGS_FILE.exists():
818
+ try:
819
+ return json.loads(_GLOBAL_SETTINGS_FILE.read_text(encoding="utf-8"))
820
+ except json.JSONDecodeError:
821
+ return {}
822
+ return {}
823
+
824
+
825
+ def _save_global_settings(settings: dict) -> None:
826
+ """Save global settings to ~/.adelie/settings.json."""
827
+ _GLOBAL_SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
828
+ _GLOBAL_SETTINGS_FILE.write_text(
829
+ json.dumps(settings, indent=2, ensure_ascii=False), encoding="utf-8"
830
+ )
831
+
832
+
833
+ def _read_ws_env_value(env_key: str) -> str | None:
834
+ """Read a specific env key from workspace .env file (raw file read, not os.environ)."""
835
+ ws_root = _find_workspace_root()
836
+ env_path = ws_root / ".env"
837
+ if not env_path.exists():
838
+ return None
839
+ for line in env_path.read_text(encoding="utf-8").splitlines():
840
+ stripped = line.strip()
841
+ if stripped.startswith(f"{env_key}="):
842
+ return stripped.split("=", 1)[1].strip()
843
+ return None
844
+
845
+
846
+ def _resolve_setting(key: str, is_global: bool = False) -> tuple[str, str]:
847
+ """
848
+ Resolve a setting value and its source.
849
+ Priority: workspace .env/config.json > global settings > default
850
+ Returns (value, source).
851
+ """
852
+ defn = _SETTINGS_DEFS.get(key)
853
+ if not defn:
854
+ return ("", "unknown")
855
+
856
+ # If global-only, just check global settings
857
+ if is_global:
858
+ gs = _load_global_settings()
859
+ if key in gs:
860
+ return (str(gs[key]), "global")
861
+ return (defn["default"], "default")
862
+
863
+ # Workspace level: check .env or config.json
864
+ ws_value = None
865
+ if defn["env"]:
866
+ ws_value = _read_ws_env_value(defn["env"])
867
+ elif defn["cfg"]:
868
+ ws_config = _load_workspace_config()
869
+ if defn["cfg"] in ws_config:
870
+ ws_value = str(ws_config[defn["cfg"]])
871
+
872
+ if ws_value is not None:
873
+ return (ws_value, "workspace")
874
+
875
+ # Fall back to global
876
+ gs = _load_global_settings()
877
+ if key in gs:
878
+ return (str(gs[key]), "global")
879
+
880
+ return (defn["default"], "default")
881
+
882
+
883
+ def cmd_settings(args: argparse.Namespace) -> None:
884
+ """View, update, or reset settings (global or workspace-level)."""
885
+ _ensure_adelie_config()
886
+
887
+ action = getattr(args, "settings_action", "show") or "show"
888
+ is_global = getattr(args, "use_global", False)
889
+
890
+ if action == "set":
891
+ key = getattr(args, "settings_key", None)
892
+ value = getattr(args, "settings_value", None)
893
+ if not key or value is None:
894
+ console.print("[red]Usage: adelie settings set <key> <value>[/red]")
895
+ return
896
+
897
+ if key not in _SETTINGS_DEFS:
898
+ console.print(f"[red]ERROR: Unknown setting: {key}[/red]")
899
+ console.print(f"[dim]Available: {', '.join(sorted(_SETTINGS_DEFS.keys()))}[/dim]")
900
+ return
901
+
902
+ defn = _SETTINGS_DEFS[key]
903
+
904
+ # Validate type
905
+ if defn["type"] == "bool" and value.lower() not in ("true", "false"):
906
+ console.print(f"[red]ERROR: '{key}' must be 'true' or 'false'[/red]")
907
+ return
908
+ if defn["type"] == "int":
909
+ try:
910
+ int(value)
911
+ except ValueError:
912
+ console.print(f"[red]ERROR: '{key}' must be a number[/red]")
913
+ return
914
+
915
+ if is_global:
916
+ gs = _load_global_settings()
917
+ gs[key] = value
918
+ _save_global_settings(gs)
919
+ console.print(f"[green]✅ [global] {key} → {value}[/green]")
920
+ else:
921
+ if defn["env"]:
922
+ _update_env_file({defn["env"]: value})
923
+ elif defn["cfg"]:
924
+ ws_config = _load_workspace_config()
925
+ ws_config[defn["cfg"]] = int(value) if defn["type"] == "int" else value
926
+ _save_workspace_config(ws_config)
927
+ console.print(f"[green]✅ [workspace] {key} → {value}[/green]")
928
+
929
+ elif action == "reset":
930
+ key = getattr(args, "settings_key", None)
931
+ if not key:
932
+ console.print("[red]Usage: adelie settings reset <key>[/red]")
933
+ return
934
+
935
+ if key not in _SETTINGS_DEFS:
936
+ console.print(f"[red]ERROR: Unknown setting: {key}[/red]")
937
+ return
938
+
939
+ defn = _SETTINGS_DEFS[key]
940
+ default_val = defn["default"]
941
+
942
+ if is_global:
943
+ gs = _load_global_settings()
944
+ gs.pop(key, None)
945
+ _save_global_settings(gs)
946
+ console.print(f"[green]✅ [global] {key} reset (removed)[/green]")
947
+ else:
948
+ # Reset workspace value to default
949
+ if defn["env"]:
950
+ _update_env_file({defn["env"]: default_val})
951
+ elif defn["cfg"]:
952
+ ws_config = _load_workspace_config()
953
+ if defn["type"] == "int":
954
+ ws_config[defn["cfg"]] = int(default_val)
955
+ else:
956
+ ws_config[defn["cfg"]] = default_val
957
+ _save_workspace_config(ws_config)
958
+ console.print(f"[green]✅ [workspace] {key} → {default_val} (default)[/green]")
959
+
960
+ else:
961
+ # Show all settings
962
+ scope_label = "Global Settings" if is_global else "Settings (workspace + global)"
963
+ table = Table(
964
+ title=f"Adelie {scope_label}",
965
+ show_header=True,
966
+ border_style="cyan",
967
+ )
968
+ table.add_column("Setting", style="bold")
969
+ table.add_column("Value")
970
+ table.add_column("Source", style="dim")
971
+ table.add_column("Description", style="dim")
972
+
973
+ current_group = ""
974
+ for key in _SETTINGS_DEFS:
975
+ defn = _SETTINGS_DEFS[key]
976
+ group = defn["group"]
977
+
978
+ # Group separator
979
+ if group != current_group:
980
+ if current_group:
981
+ table.add_row("", "", "", "", style="dim")
982
+ current_group = group
983
+ table.add_row(f"[bold cyan]{group}[/bold cyan]", "", "", "")
984
+
985
+ value, source = _resolve_setting(key, is_global)
986
+
987
+ # Color the source
988
+ if source == "workspace":
989
+ source_styled = "[green]workspace[/green]"
990
+ elif source == "global":
991
+ source_styled = "[yellow]global[/yellow]"
992
+ else:
993
+ source_styled = "[dim]default[/dim]"
994
+
995
+ # Color bool values
996
+ if defn["type"] == "bool":
997
+ if value.lower() == "true":
998
+ value_styled = "[green]true[/green]"
999
+ else:
1000
+ value_styled = "[dim]false[/dim]"
1001
+ elif not value:
1002
+ value_styled = "[dim](not set)[/dim]"
1003
+ else:
1004
+ value_styled = value
1005
+
1006
+ table.add_row(f" {key}", value_styled, source_styled, defn["desc"])
1007
+
1008
+ console.print(table)
1009
+
1010
+ if is_global:
1011
+ console.print(f"\n[dim]Global: {_GLOBAL_SETTINGS_FILE}[/dim]")
1012
+ else:
1013
+ ws_root = _find_workspace_root()
1014
+ console.print(f"\n[dim]Workspace: {ws_root / '.env'} + {_workspace_config_path()}[/dim]")
1015
+ console.print(f"[dim]Global: {_GLOBAL_SETTINGS_FILE}[/dim]")
1016
+
1017
+ console.print(f"\n[dim]Change: adelie settings set <key> <value>[/dim]")
1018
+ console.print(f"[dim]Global: adelie settings set --global <key> <value>[/dim]")
1019
+
1020
+
786
1021
  def cmd_kb(args: argparse.Namespace) -> None:
787
1022
  """Knowledge Base management."""
788
1023
  _ensure_adelie_config()
@@ -1457,12 +1692,15 @@ def cmd_metrics(args: argparse.Namespace) -> None:
1457
1692
 
1458
1693
 
1459
1694
  def main() -> None:
1695
+ from adelie import __version__
1460
1696
  parser = argparse.ArgumentParser(
1461
1697
  prog="adelie",
1462
1698
  description="Adelie — Self-Communicating Autonomous AI Loop",
1463
1699
  formatter_class=argparse.RawDescriptionHelpFormatter,
1464
1700
  epilog="Run [adelie help] for detailed command reference.",
1465
1701
  )
1702
+ parser.add_argument("-v", "--version", action="version",
1703
+ version=f"adelie {__version__}")
1466
1704
  subparsers = parser.add_subparsers(dest="command", help="Available commands")
1467
1705
 
1468
1706
  # ── help ──────
@@ -1540,6 +1778,19 @@ def main() -> None:
1540
1778
  p_config.add_argument("--plan-mode", type=str, dest="plan_mode", help="Plan mode: 'true' or 'false'")
1541
1779
  p_config.set_defaults(func=cmd_config)
1542
1780
 
1781
+ # ── settings ──
1782
+ p_settings = subparsers.add_parser("settings", help="Manage runtime settings (global & workspace)")
1783
+ p_settings.add_argument("settings_action", nargs="?", default="show",
1784
+ choices=["show", "set", "reset"],
1785
+ help="show (default) / set / reset")
1786
+ p_settings.add_argument("settings_key", nargs="?", default=None,
1787
+ help="Setting key (e.g. dashboard, loop.interval)")
1788
+ p_settings.add_argument("settings_value", nargs="?", default=None,
1789
+ help="New value (for set)")
1790
+ p_settings.add_argument("--global", action="store_true", dest="use_global",
1791
+ help="Target global settings (~/.adelie/settings.json)")
1792
+ p_settings.set_defaults(func=cmd_settings)
1793
+
1543
1794
  # ── kb ────────
1544
1795
  p_kb = subparsers.add_parser("kb", help="Knowledge Base management")
1545
1796
  p_kb.add_argument("--clear-errors", action="store_true", help="Clear error files")
@@ -1653,7 +1904,7 @@ def main() -> None:
1653
1904
  except Exception:
1654
1905
  provider_info = "(not configured)"
1655
1906
  console.print(Panel.fit(
1656
- f"[bold cyan]Adelie[/bold cyan] — Autonomous AI Loop\n"
1907
+ f"[bold cyan]Adelie[/bold cyan] v{__version__} — Autonomous AI Loop\n"
1657
1908
  f"[dim]LLM: {provider_info}[/dim]",
1658
1909
  border_style="cyan",
1659
1910
  ))
@@ -7,7 +7,8 @@ Serves a single-page dashboard on a configurable port (default 5042).
7
7
  Features:
8
8
  - SSE (Server-Sent Events) for live push to browsers
9
9
  - JSON REST API for initial state / history
10
- - Thread-safe event broadcasting
10
+ - Thread-safe event broadcasting with batching
11
+ - ThreadingHTTPServer for concurrent SSE + API handling
11
12
  - No external dependencies — uses stdlib http.server
12
13
 
13
14
  Started automatically from interactive.py when `adelie run` is called.
@@ -15,9 +16,11 @@ Started automatically from interactive.py when `adelie run` is called.
15
16
 
16
17
  from __future__ import annotations
17
18
 
19
+ import collections
18
20
  import json
19
21
  import queue
20
22
  import re
23
+ import socketserver
21
24
  import threading
22
25
  import time
23
26
  from datetime import datetime
@@ -30,14 +33,14 @@ from adelie.dashboard_html import DASHBOARD_HTML
30
33
  # ── Event Bus ────────────────────────────────────────────────────────────────
31
34
 
32
35
  class EventBus:
33
- """Thread-safe pub/sub for SSE clients."""
36
+ """Thread-safe pub/sub for SSE clients with event coalescing."""
34
37
 
35
38
  def __init__(self):
36
39
  self._clients: list[queue.Queue] = []
37
40
  self._lock = threading.Lock()
38
41
 
39
42
  def subscribe(self) -> queue.Queue:
40
- q: queue.Queue = queue.Queue(maxsize=200)
43
+ q: queue.Queue = queue.Queue(maxsize=500)
41
44
  with self._lock:
42
45
  self._clients.append(q)
43
46
  return q
@@ -73,18 +76,15 @@ class EventBus:
73
76
  # ── Log Ring Buffer ──────────────────────────────────────────────────────────
74
77
 
75
78
  class LogBuffer:
76
- """Thread-safe ring buffer for recent log entries."""
79
+ """Thread-safe ring buffer for recent log entries using deque for O(1) ops."""
77
80
 
78
81
  def __init__(self, maxlen: int = 200):
79
- self._buf: list[dict] = []
80
- self._maxlen = maxlen
82
+ self._buf: collections.deque = collections.deque(maxlen=maxlen)
81
83
  self._lock = threading.Lock()
82
84
 
83
85
  def append(self, entry: dict) -> None:
84
86
  with self._lock:
85
87
  self._buf.append(entry)
86
- if len(self._buf) > self._maxlen:
87
- self._buf.pop(0)
88
88
 
89
89
  def get_all(self) -> list[dict]:
90
90
  with self._lock:
@@ -110,10 +110,22 @@ class DashboardState:
110
110
  self.events = EventBus()
111
111
  self.logs = LogBuffer(maxlen=200)
112
112
  self._lock = threading.Lock()
113
+ # Debounce tracking for agent updates
114
+ self._agent_last_publish: dict[str, float] = {}
115
+ self._agent_debounce_ms: float = 0.05 # 50ms debounce for same agent
113
116
 
114
117
  def update_agent(self, name: str, info: dict) -> None:
118
+ now = time.monotonic()
115
119
  with self._lock:
116
120
  self.agents[name] = info
121
+ last = self._agent_last_publish.get(name, 0)
122
+ # Coalesce rapid agent updates (< 50ms apart) unless state changed
123
+ prev_state = self._agent_last_publish.get(f"{name}_state")
124
+ cur_state = info.get("state")
125
+ if cur_state == prev_state and (now - last) < self._agent_debounce_ms:
126
+ return
127
+ self._agent_last_publish[name] = now
128
+ self._agent_last_publish[f"{name}_state"] = cur_state
117
129
  self.events.publish("agent", {"name": name, **info})
118
130
 
119
131
  def update_cycle(self, iteration: int, phase: str, state: str) -> None:
@@ -124,6 +136,7 @@ class DashboardState:
124
136
  # Reset agents
125
137
  for name in list(self.agents.keys()):
126
138
  self.agents[name] = {"state": "idle", "detail": "idle", "elapsed": 0}
139
+ self._agent_last_publish.clear()
127
140
  self.events.publish("cycle_start", {"iteration": iteration, "phase": phase, "state": state})
128
141
  self.events.publish("state", {"cycle": iteration, "phase": phase, "goal": self.goal})
129
142
 
@@ -212,7 +225,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
212
225
  self.send_error(404)
213
226
 
214
227
  def _handle_sse(self, ds: DashboardState) -> None:
215
- """Stream Server-Sent Events to the client."""
228
+ """Stream Server-Sent Events to the client with batched flushing."""
216
229
  self.send_response(200)
217
230
  self.send_header("Content-Type", "text/event-stream")
218
231
  self.send_header("Cache-Control", "no-cache")
@@ -228,20 +241,52 @@ class DashboardHandler(BaseHTTPRequestHandler):
228
241
  client_q = ds.events.subscribe()
229
242
  try:
230
243
  while True:
244
+ # Batch: collect all available events within a short window
245
+ batch: list[str] = []
231
246
  try:
232
- payload = client_q.get(timeout=15)
233
- self.wfile.write(payload.encode("utf-8"))
234
- self.wfile.flush()
247
+ # Block up to 100ms for first event, then drain
248
+ payload = client_q.get(timeout=0.1)
249
+ batch.append(payload)
235
250
  except queue.Empty:
236
- # Send keepalive comment
237
- self.wfile.write(": keepalive\n\n".encode("utf-8"))
251
+ pass
252
+
253
+ # Drain any additional queued events (non-blocking)
254
+ while not client_q.empty() and len(batch) < 50:
255
+ try:
256
+ batch.append(client_q.get_nowait())
257
+ except queue.Empty:
258
+ break
259
+
260
+ if batch:
261
+ # Write all events in one I/O call
262
+ combined = "".join(batch)
263
+ self.wfile.write(combined.encode("utf-8"))
238
264
  self.wfile.flush()
265
+ else:
266
+ # Send keepalive every ~15s (150 empty 100ms cycles)
267
+ # Use a counter to avoid frequent keepalives
268
+ if not hasattr(self, '_keepalive_counter'):
269
+ self._keepalive_counter = 0
270
+ self._keepalive_counter += 1
271
+ if self._keepalive_counter >= 150: # ~15 seconds
272
+ self.wfile.write(": keepalive\n\n".encode("utf-8"))
273
+ self.wfile.flush()
274
+ self._keepalive_counter = 0
275
+
239
276
  except (BrokenPipeError, ConnectionResetError, OSError):
240
277
  pass
241
278
  finally:
242
279
  ds.events.unsubscribe(client_q)
243
280
 
244
281
 
282
+ # ── Threading HTTP Server ────────────────────────────────────────────────────
283
+
284
+ class ThreadingDashboardHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
285
+ """Multi-threaded HTTP server so SSE connections don't block API requests."""
286
+ daemon_threads = True
287
+ allow_reuse_address = True
288
+
289
+
245
290
  # ── Dashboard Server ─────────────────────────────────────────────────────────
246
291
 
247
292
  class DashboardServer:
@@ -258,13 +303,13 @@ class DashboardServer:
258
303
  def __init__(self, state: DashboardState, port: int = 5042):
259
304
  self.state = state
260
305
  self.port = port
261
- self._httpd: Optional[HTTPServer] = None
306
+ self._httpd: Optional[ThreadingDashboardHTTPServer] = None
262
307
  self._thread: Optional[threading.Thread] = None
263
308
 
264
309
  def start(self) -> bool:
265
310
  """Start the dashboard server in a background thread. Returns True on success."""
266
311
  try:
267
- self._httpd = HTTPServer(("0.0.0.0", self.port), DashboardHandler)
312
+ self._httpd = ThreadingDashboardHTTPServer(("0.0.0.0", self.port), DashboardHandler)
268
313
  self._httpd._dashboard_state = self.state # type: ignore
269
314
  self._httpd.timeout = 0.5
270
315
  self._thread = threading.Thread(
@@ -3,6 +3,12 @@ adelie/dashboard_html.py
3
3
 
4
4
  Embedded HTML/CSS/JS template for the Adelie real-time web dashboard.
5
5
  Served as a single self-contained page — no external assets needed.
6
+
7
+ Performance-optimized:
8
+ - requestAnimationFrame DOM batching
9
+ - DocumentFragment for bulk log insertion
10
+ - Event throttling for rapid agent updates
11
+ - Phase timeline class-toggle (no full re-render)
6
12
  """
7
13
 
8
14
  DASHBOARD_HTML = r"""<!DOCTYPE html>
@@ -49,8 +55,8 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
49
55
  /* ── Agent Grid ─────────────────────── */
50
56
  .section-title{font-size:13px;font-weight:600;color:var(--fg2);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
51
57
  .agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-bottom:16px}
52
- .agent-card{background:var(--glass);border:1px solid var(--border);border-radius:10px;padding:14px;transition:all .3s ease;position:relative;overflow:hidden}
53
- .agent-card:hover{border-color:var(--fg3);transform:translateY(-1px)}
58
+ .agent-card{background:var(--glass);border:1px solid var(--border);border-radius:10px;padding:14px;transition:border-color .3s ease,box-shadow .3s ease;position:relative;overflow:hidden;contain:layout style}
59
+ .agent-card:hover{border-color:var(--fg3)}
54
60
  .agent-card.running{border-color:var(--cyan);box-shadow:0 0 20px rgba(88,166,255,.1)}
55
61
  .agent-card.running::before{content:'';position:absolute;top:0;left:-100%;width:100%;height:2px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);animation:scan 2s linear infinite}
56
62
  @keyframes scan{to{left:100%}}
@@ -128,7 +134,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
128
134
  <svg viewBox="0 0 32 32" fill="none"><circle cx="16" cy="12" r="10" fill="#58a6ff" opacity=".15" stroke="#58a6ff" stroke-width="1.5"/><circle cx="13" cy="10" r="1.5" fill="#e6edf3"/><circle cx="19" cy="10" r="1.5" fill="#e6edf3"/><ellipse cx="16" cy="14" rx="3" ry="2" fill="#f0883e"/><path d="M10 20 C10 26 22 26 22 20" stroke="#58a6ff" stroke-width="1.5" fill="none"/><path d="M6 14 C4 18 8 22 10 20" stroke="#58a6ff" stroke-width="1.5" fill="none"/><path d="M26 14 C28 18 24 22 22 20" stroke="#58a6ff" stroke-width="1.5" fill="none"/></svg>
129
135
  Adelie Dashboard
130
136
  </div>
131
- <span class="version" id="version">v0.1.5</span>
137
+ <span class="version" id="version">v0.2.0</span>
132
138
  <div class="spacer"></div>
133
139
  <div class="status-dot" id="statusDot"></div>
134
140
  <span class="status-label" id="statusLabel">Connected</span>
@@ -199,7 +205,6 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
199
205
 
200
206
  // ── Agents ──────────────────────────
201
207
  const AGENTS = ["Writer","Expert","Scanner","Coder","Reviewer","Tester","Runner","Monitor","Analyst","Research"];
202
- const AGENT_COLORS = {Writer:"#58a6ff",Expert:"#58a6ff",Scanner:"#bc8cff",Coder:"#3fb950","Coder:0":"#3fb950","Coder:1":"#3fb950","Coder:2":"#3fb950",Reviewer:"#d29922",Tester:"#f85149",Runner:"#3fb950",Monitor:"#58a6ff",Analyst:"#bc8cff",Inform:"#58a6ff",Research:"#d29922"};
203
208
 
204
209
  // ── Phases ──────────────────────────
205
210
  const PHASES = [
@@ -213,7 +218,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
213
218
 
214
219
  // ── State ───────────────────────────
215
220
  let state = {agents:{},cycle:0,phase:"initial",goal:"",workspace:"",metrics:{}};
216
- let logs = [];
221
+ let logCount = 0;
217
222
  const MAX_LOGS = 300;
218
223
  let autoScroll = true;
219
224
 
@@ -221,11 +226,33 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
221
226
  const $ = id => document.getElementById(id);
222
227
  const agentsGrid = $("agentsGrid");
223
228
  const logBody = $("logBody");
224
- const logCount = $("logCount");
229
+ const logCountEl = $("logCount");
230
+
231
+ // ── RAF Batch Queue ─────────────────
232
+ // All DOM mutations are queued and applied in a single rAF frame
233
+ let pendingUpdates = [];
234
+ let rafScheduled = false;
235
+
236
+ function scheduleUpdate(fn) {
237
+ pendingUpdates.push(fn);
238
+ if (!rafScheduled) {
239
+ rafScheduled = true;
240
+ requestAnimationFrame(flushUpdates);
241
+ }
242
+ }
243
+
244
+ function flushUpdates() {
245
+ const updates = pendingUpdates;
246
+ pendingUpdates = [];
247
+ rafScheduled = false;
248
+ for (let i = 0; i < updates.length; i++) {
249
+ updates[i]();
250
+ }
251
+ }
225
252
 
226
253
  // ── Init agents grid ────────────────
227
254
  function initAgents(){
228
- agentsGrid.innerHTML = "";
255
+ const frag = document.createDocumentFragment();
229
256
  AGENTS.forEach(name => {
230
257
  const card = document.createElement("div");
231
258
  card.className = "agent-card";
@@ -234,79 +261,157 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
234
261
  <div class="agent-name"><span class="dot idle" id="dot-${name}"></span>${name}</div>
235
262
  <div class="agent-detail" id="detail-${name}">idle</div>
236
263
  <div class="agent-elapsed" id="elapsed-${name}"></div>`;
237
- agentsGrid.appendChild(card);
264
+ frag.appendChild(card);
238
265
  });
266
+ agentsGrid.appendChild(frag);
239
267
  }
240
268
 
241
- // ── Update agent card ───────────────
269
+ // ── Update agent card (throttled) ──
270
+ const agentThrottleMap = {};
271
+ const AGENT_THROTTLE_MS = 80;
272
+
242
273
  function updateAgent(name, info){
243
- const card = $("agent-"+name);
244
- if(!card) return;
245
- const dot = $("dot-"+name);
246
- const detail = $("detail-"+name);
247
- const elapsed = $("elapsed-"+name);
248
- const st = info.state || "idle";
249
- card.className = "agent-card " + st;
250
- dot.className = "dot " + st;
251
- detail.textContent = info.detail || st;
252
- if(info.elapsed > 0) elapsed.textContent = info.elapsed.toFixed(1)+"s";
253
- else elapsed.textContent = st === "running" ? "…" : "";
274
+ const now = performance.now();
275
+ const lastUpdate = agentThrottleMap[name] || 0;
276
+ const prevState = agentThrottleMap[name + "_state"];
277
+ const curState = info.state || "idle";
278
+
279
+ // Skip if same state and within throttle window
280
+ if (curState === prevState && (now - lastUpdate) < AGENT_THROTTLE_MS) {
281
+ return;
282
+ }
283
+ agentThrottleMap[name] = now;
284
+ agentThrottleMap[name + "_state"] = curState;
285
+
286
+ scheduleUpdate(() => {
287
+ const card = $("agent-"+name);
288
+ if(!card) return;
289
+ const dot = $("dot-"+name);
290
+ const detail = $("detail-"+name);
291
+ const elapsed = $("elapsed-"+name);
292
+ const st = info.state || "idle";
293
+ card.className = "agent-card " + st;
294
+ dot.className = "dot " + st;
295
+ detail.textContent = info.detail || st;
296
+ if(info.elapsed > 0) elapsed.textContent = info.elapsed.toFixed(1)+"s";
297
+ else elapsed.textContent = st === "running" ? "…" : "";
298
+ });
254
299
  }
255
300
 
256
- // ── Update phase timeline ──────────
257
- function updatePhase(current){
301
+ // ── Init phase timeline (once) ─────
302
+ let phaseElements = [];
303
+ function initPhaseTimeline() {
258
304
  const tl = $("phaseTimeline");
259
- tl.innerHTML = "";
260
- let found = false;
261
- PHASES.forEach(p => {
305
+ const frag = document.createDocumentFragment();
306
+ PHASES.forEach((p, i) => {
262
307
  const item = document.createElement("div");
263
308
  item.className = "phase-item";
264
- if(p.value === current){ item.classList.add("active"); found = true; }
265
- else if(!found){ item.classList.add("completed"); }
309
+ item.dataset.phase = p.value;
266
310
  item.innerHTML = `<span class="phase-name">${p.label}</span>`;
267
- tl.appendChild(item);
311
+ frag.appendChild(item);
312
+ phaseElements.push(item);
313
+ });
314
+ tl.appendChild(frag);
315
+ }
316
+
317
+ // ── Update phase timeline (class toggle only) ──
318
+ let currentPhase = "";
319
+ function updatePhase(current){
320
+ if (current === currentPhase) return;
321
+ currentPhase = current;
322
+
323
+ scheduleUpdate(() => {
324
+ let found = false;
325
+ for (let i = 0; i < phaseElements.length; i++) {
326
+ const el = phaseElements[i];
327
+ el.classList.remove("active", "completed");
328
+ if (el.dataset.phase === current) {
329
+ el.classList.add("active");
330
+ found = true;
331
+ } else if (!found) {
332
+ el.classList.add("completed");
333
+ }
334
+ }
335
+ $("phaseBadge").textContent = (PHASES.find(p=>p.value===current)||{}).label || current;
268
336
  });
269
- $("phaseBadge").textContent = (PHASES.find(p=>p.value===current)||{}).label || current;
270
337
  }
271
338
 
272
339
  // ── Update metrics panel ───────────
273
340
  function updateMetrics(m){
274
341
  if(!m) return;
275
- $("mTokens").textContent = (m.total_tokens||0).toLocaleString();
276
- $("mCalls").textContent = m.llm_calls || m.calls || 0;
277
- $("mFiles").textContent = m.files_written || 0;
278
- $("mTime").textContent = (m.cycle_time||0).toFixed(1)+"s";
279
- if(m.tests_total > 0) $("mTests").textContent = m.tests_passed+"/"+m.tests_total;
280
- if(m.review_score > 0) $("mReview").textContent = m.review_score.toFixed(0)+"/10";
342
+ scheduleUpdate(() => {
343
+ $("mTokens").textContent = (m.total_tokens||0).toLocaleString();
344
+ $("mCalls").textContent = m.llm_calls || m.calls || 0;
345
+ $("mFiles").textContent = m.files_written || 0;
346
+ $("mTime").textContent = (m.cycle_time||0).toFixed(1)+"s";
347
+ if(m.tests_total > 0) $("mTests").textContent = m.tests_passed+"/"+m.tests_total;
348
+ if(m.review_score > 0) $("mReview").textContent = m.review_score.toFixed(0)+"/10";
349
+ });
281
350
  }
282
351
 
283
352
  // ── Update cycle chart ─────────────
284
353
  function updateChart(history){
285
- const bars = $("chartBars");
286
- if(!history||!history.length){ bars.innerHTML="<span style='color:var(--fg3);font-size:11px'>No data yet</span>"; return; }
287
- const maxTime = Math.max(...history.map(h=>h.cycle_time||1), 1);
288
- bars.innerHTML = "";
289
- history.slice(-30).forEach(h => {
290
- const pct = Math.max(5, ((h.cycle_time||0)/maxTime)*100);
291
- const bar = document.createElement("div");
292
- bar.className = "chart-bar";
293
- bar.style.height = pct+"%";
294
- bar.innerHTML = `<div class="tooltip">#${h.cycle} · ${(h.cycle_time||0).toFixed(1)}s · ${((h.tokens||{}).total||0).toLocaleString()} tok</div>`;
295
- bars.appendChild(bar);
354
+ scheduleUpdate(() => {
355
+ const bars = $("chartBars");
356
+ if(!history||!history.length){ bars.innerHTML="<span style='color:var(--fg3);font-size:11px'>No data yet</span>"; return; }
357
+ const slice = history.slice(-30);
358
+ const maxTime = Math.max(...slice.map(h=>h.cycle_time||1), 1);
359
+ const frag = document.createDocumentFragment();
360
+ slice.forEach(h => {
361
+ const pct = Math.max(5, ((h.cycle_time||0)/maxTime)*100);
362
+ const bar = document.createElement("div");
363
+ bar.className = "chart-bar";
364
+ bar.style.height = pct+"%";
365
+ bar.innerHTML = `<div class="tooltip">#${h.cycle} · ${(h.cycle_time||0).toFixed(1)}s · ${((h.tokens||{}).total||0).toLocaleString()} tok</div>`;
366
+ frag.appendChild(bar);
367
+ });
368
+ bars.innerHTML = "";
369
+ bars.appendChild(frag);
296
370
  });
297
371
  }
298
372
 
299
- // ── Add log entry ──────────────────
373
+ // ── Add log entries (batched) ──────
300
374
  const CAT_ICONS = {agent_start:"▶",agent_end:"✓",error:"✕",warning:"⚠",phase_change:"◆",info:"·",debug:"·",cycle_header:"─",cycle_summary:"📊",progress:"→"};
375
+ let pendingLogs = [];
376
+ let logRafScheduled = false;
377
+
301
378
  function addLog(entry){
302
- logs.push(entry);
303
- if(logs.length > MAX_LOGS){ logs.shift(); logBody.removeChild(logBody.firstChild); }
304
- const div = document.createElement("div");
305
- div.className = "log-entry " + (entry.category||"info");
306
- const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : "";
307
- div.innerHTML = `<span class="log-ts">${ts}</span><span class="log-cat">${CAT_ICONS[entry.category]||"·"}</span><span class="log-msg">${escHtml(entry.message||"")}</span>`;
308
- logBody.appendChild(div);
309
- logCount.textContent = logs.length + " entries";
379
+ pendingLogs.push(entry);
380
+ logCount++;
381
+ if (!logRafScheduled) {
382
+ logRafScheduled = true;
383
+ requestAnimationFrame(flushLogs);
384
+ }
385
+ }
386
+
387
+ function flushLogs() {
388
+ logRafScheduled = false;
389
+ const entries = pendingLogs;
390
+ pendingLogs = [];
391
+ if (!entries.length) return;
392
+
393
+ // Trim excess before adding
394
+ const totalAfter = logBody.childElementCount + entries.length;
395
+ if (totalAfter > MAX_LOGS) {
396
+ const toRemove = totalAfter - MAX_LOGS;
397
+ for (let i = 0; i < toRemove && logBody.firstChild; i++) {
398
+ logBody.removeChild(logBody.firstChild);
399
+ }
400
+ }
401
+
402
+ // Build all new entries in a DocumentFragment
403
+ const frag = document.createDocumentFragment();
404
+ for (let i = 0; i < entries.length; i++) {
405
+ const entry = entries[i];
406
+ const div = document.createElement("div");
407
+ div.className = "log-entry " + (entry.category||"info");
408
+ const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : "";
409
+ div.innerHTML = `<span class="log-ts">${ts}</span><span class="log-cat">${CAT_ICONS[entry.category]||"·"}</span><span class="log-msg">${escHtml(entry.message||"")}</span>`;
410
+ frag.appendChild(div);
411
+ }
412
+ logBody.appendChild(frag);
413
+
414
+ logCountEl.textContent = Math.min(logCount, MAX_LOGS) + " entries";
310
415
  if(autoScroll) logBody.scrollTop = logBody.scrollHeight;
311
416
  }
312
417
 
@@ -316,7 +421,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
316
421
  logBody.addEventListener("scroll", () => {
317
422
  const gap = logBody.scrollHeight - logBody.scrollTop - logBody.clientHeight;
318
423
  autoScroll = gap < 40;
319
- });
424
+ }, {passive: true});
320
425
 
321
426
  // ── Load initial state ──────────────
322
427
  async function loadState(){
@@ -354,9 +459,18 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
354
459
  // ── SSE Connection ─────────────────
355
460
  let evtSource = null;
356
461
  let reconnectDelay = 1000;
462
+ let reconnectTimer = null;
357
463
 
358
464
  function connectSSE(){
359
- if(evtSource) evtSource.close();
465
+ if(evtSource){
466
+ evtSource.close();
467
+ evtSource = null;
468
+ }
469
+ if(reconnectTimer){
470
+ clearTimeout(reconnectTimer);
471
+ reconnectTimer = null;
472
+ }
473
+
360
474
  evtSource = new EventSource("/events");
361
475
 
362
476
  evtSource.onopen = () => {
@@ -368,9 +482,9 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
368
482
 
369
483
  evtSource.addEventListener("state", (e) => {
370
484
  const d = JSON.parse(e.data);
371
- if(d.cycle) $("cycleNum").textContent = "#"+d.cycle;
485
+ if(d.cycle) scheduleUpdate(() => { $("cycleNum").textContent = "#"+d.cycle; });
372
486
  if(d.phase) updatePhase(d.phase);
373
- if(d.goal) $("goal").textContent = d.goal;
487
+ if(d.goal) scheduleUpdate(() => { $("goal").textContent = d.goal; });
374
488
  });
375
489
 
376
490
  evtSource.addEventListener("agent", (e) => {
@@ -390,7 +504,9 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
390
504
 
391
505
  evtSource.addEventListener("cycle_start", (e) => {
392
506
  const d = JSON.parse(e.data);
393
- $("cycleNum").textContent = "#"+(d.iteration||0);
507
+ scheduleUpdate(() => {
508
+ $("cycleNum").textContent = "#"+(d.iteration||0);
509
+ });
394
510
  // Reset agents
395
511
  AGENTS.forEach(name => updateAgent(name, {state:"idle",detail:"idle",elapsed:0}));
396
512
  });
@@ -406,21 +522,20 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--f
406
522
  $("statusDot").style.background = "var(--red)";
407
523
  $("statusLabel").textContent = "Disconnected";
408
524
  evtSource.close();
409
- setTimeout(connectSSE, reconnectDelay);
410
- reconnectDelay = Math.min(reconnectDelay * 2, 10000);
525
+ evtSource = null;
526
+ reconnectTimer = setTimeout(connectSSE, reconnectDelay);
527
+ reconnectDelay = Math.min(reconnectDelay * 1.5, 15000);
411
528
  };
412
529
  }
413
530
 
414
531
  // ── Init ────────────────────────────
415
532
  initAgents();
533
+ initPhaseTimeline();
416
534
  loadState().then(()=>{
417
535
  loadLogs();
418
536
  loadHistory();
419
537
  connectSSE();
420
538
  });
421
-
422
- // Refresh history every 30s
423
- setInterval(loadHistory, 30000);
424
539
  })();
425
540
  </script>
426
541
  </body>
@@ -62,14 +62,14 @@ AGENT_COLORS = {
62
62
  # ── Header ────────────────────────────────────────────────────────────────────
63
63
 
64
64
  def print_header(goal: str, phase: str, model: str, workspace: str):
65
- """Print the startup header — gemini-cli style ASCII icon + info."""
65
+ """Print the startup header — penguin ASCII icon + info."""
66
+ from adelie import __version__
66
67
  width = shutil.get_terminal_size((80, 24)).columns
67
68
 
68
69
  console.print()
69
- console.print(" [cyan]▝▜▄[/cyan] [bold]Adelie[/bold] [dim]v0.1.0[/dim]")
70
- console.print(f" [cyan]▝▜▄[/cyan] [dim]{model}[/dim]")
71
- console.print(f" [cyan]▗▟▀[/cyan] [dim]Phase:[/dim] {phase}")
72
- console.print(f" [cyan]▝▀[/cyan]")
70
+ console.print(" [cyan] (o_ [/cyan] [bold]Adelie[/bold] [dim]v" + __version__ + "[/dim]")
71
+ console.print(" [cyan] //\\\\ [/cyan] [dim]" + model + "[/dim]")
72
+ console.print(" [cyan] V_/_ [/cyan] [dim]Phase:[/dim] " + phase)
73
73
  console.print()
74
74
 
75
75
  # Goal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adelie-ai",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Adelie — Self-Communicating Autonomous AI Loop CLI",
5
5
  "bin": {
6
6
  "adelie": "bin/adelie.js"