@arthovlabs/liber 0.0.0-stage → 0.5.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -2
  3. package/bin/liber +706 -0
  4. package/bin/liber-tui +244 -0
  5. package/bin/liber-tui.py +825 -0
  6. package/bin/liberd-entry +17 -0
  7. package/liber.yaml +84 -0
  8. package/package.json +43 -4
  9. package/resources/icons/appIcon.png +0 -0
  10. package/resources/icons/appIcon.svg +20 -0
  11. package/resources/icons/appIcon@512.png +0 -0
  12. package/resources/icons/atom-transparent-256.png +0 -0
  13. package/resources/icons/atom-transparent.png +0 -0
  14. package/resources/icons/atom-transparent.svg +19 -0
  15. package/resources/icons/make-atom.js +168 -0
  16. package/src/cli/ui.js +520 -0
  17. package/src/daemon/core/auth.js +217 -0
  18. package/src/daemon/core/automations.js +130 -0
  19. package/src/daemon/core/budget.js +145 -0
  20. package/src/daemon/core/circuit-breaker.js +31 -0
  21. package/src/daemon/core/config-apply.js +223 -0
  22. package/src/daemon/core/connectors.js +131 -0
  23. package/src/daemon/core/engine.js +1029 -0
  24. package/src/daemon/core/evidence.js +306 -0
  25. package/src/daemon/core/executor.js +10 -0
  26. package/src/daemon/core/grounding.js +420 -0
  27. package/src/daemon/core/injection.js +24 -0
  28. package/src/daemon/core/journal.js +81 -0
  29. package/src/daemon/core/liber-soul.js +157 -0
  30. package/src/daemon/core/lock.js +32 -0
  31. package/src/daemon/core/memory.js +106 -0
  32. package/src/daemon/core/model-switch.js +58 -0
  33. package/src/daemon/core/policy.js +102 -0
  34. package/src/daemon/core/providers.js +503 -0
  35. package/src/daemon/core/registry.js +356 -0
  36. package/src/daemon/core/secrets.js +35 -0
  37. package/src/daemon/core/skills.js +264 -0
  38. package/src/daemon/core/tools/browser-tool.js +280 -0
  39. package/src/daemon/core/tools/file-tools.js +277 -0
  40. package/src/daemon/core/tools/memory-tool.js +127 -0
  41. package/src/daemon/core/tools/skill-tool.js +44 -0
  42. package/src/daemon/core/tools/task-tool.js +41 -0
  43. package/src/daemon/core/tools/terminal-tool.js +449 -0
  44. package/src/daemon/core/tools/todo-tool.js +100 -0
  45. package/src/daemon/core/tools/web-tool.js +181 -0
  46. package/src/daemon/core/trace.js +40 -0
  47. package/src/daemon/core/validate.js +105 -0
  48. package/src/daemon/core/vault.js +158 -0
  49. package/src/daemon/core/verifier.js +98 -0
  50. package/src/daemon/core/voice.js +83 -0
  51. package/src/daemon/core/workspace.js +199 -0
  52. package/src/daemon/liberd.js +1343 -0
  53. package/src/daemon/llm/adapters/anthropic-adapter.js +190 -0
  54. package/src/daemon/llm/adapters/gemini-adapter.js +148 -0
  55. package/src/daemon/llm/adapters/ollama-adapter.js +74 -0
  56. package/src/daemon/llm/adapters/openai-adapter.js +148 -0
  57. package/src/daemon/llm/base.js +31 -0
  58. package/src/daemon/llm/manager.js +134 -0
  59. package/src/utils/config-loader.js +109 -0
  60. package/src/utils/logger.js +56 -0
@@ -0,0 +1,825 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ LIBER :: Autonomous Agent Harness TUI (Tactical Cyber-Obsidian)
4
+ Theme: Tactical Cyber-Obsidian
5
+ Stack: Textual + Rich Renderables + Async Engine Integration
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ import json
11
+ import asyncio
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import Optional, AsyncGenerator, Dict, Any
15
+
16
+ import httpx
17
+ from rich.text import Text
18
+ from rich.panel import Panel
19
+ from rich.syntax import Syntax
20
+ from rich.table import Table
21
+
22
+ from textual.app import App, ComposeResult
23
+ from textual.containers import Horizontal, Vertical
24
+ from textual.reactive import reactive
25
+ from textual.widgets import Footer, RichLog, Static, Input, OptionList
26
+ from textual.binding import Binding
27
+ from textual.screen import ModalScreen
28
+ from textual import work
29
+
30
+ DAEMON_URL = "http://127.0.0.1:4567"
31
+ ROOT_DIR = Path(__file__).resolve().parent.parent
32
+
33
+
34
+ def load_local_facts() -> Dict[str, Any]:
35
+ """Read version + tuning facts from the repo itself — never hardcode them."""
36
+ import re
37
+ facts = {"version": "0.0.0", "window_size": None, "max_loops": None}
38
+ try:
39
+ pkg_text = (ROOT_DIR / "package.json").read_text(encoding="utf-8")
40
+ m = re.search(r'"version"\s*:\s*"([^"]+)"', pkg_text)
41
+ if m:
42
+ facts["version"] = m.group(1)
43
+ except OSError:
44
+ pass
45
+ try:
46
+ yaml_text = (ROOT_DIR / "liber.yaml").read_text(encoding="utf-8")
47
+ m = re.search(r"window_size\s*:\s*(\d+)", yaml_text)
48
+ if m:
49
+ facts["window_size"] = int(m.group(1))
50
+ m = re.search(r"max_loops\s*:\s*(\d+)", yaml_text)
51
+ if m:
52
+ facts["max_loops"] = int(m.group(1))
53
+ except OSError:
54
+ pass
55
+ return facts
56
+
57
+
58
+ LOCAL_FACTS = load_local_facts()
59
+
60
+
61
+ def auth_headers() -> Dict[str, str]:
62
+ """Bearer token written by the daemon at boot (.liber/daemon.token)."""
63
+ token = os.environ.get("LIBER_TOKEN", "")
64
+ if not token:
65
+ try:
66
+ token = (Path.cwd() / ".liber" / "daemon.token").read_text(encoding="utf-8").strip()
67
+ except OSError:
68
+ token = ""
69
+ return {"Authorization": f"Bearer {token}"} if token else {}
70
+
71
+
72
+ # =====================================================================
73
+ # 1. VISUAL CUE & DESIGN SYSTEM HELPER
74
+ # =====================================================================
75
+
76
+ class LiberCue:
77
+ """Helper untuk merender visual cue, pill badges, dan dynamic meters."""
78
+
79
+ @staticmethod
80
+ def badge(label: str, style: str) -> Text:
81
+ """Membuat badge inverted pill dengan padding rapat."""
82
+ return Text.assemble(
83
+ (" ", style),
84
+ (f"{label.upper()}", f"bold {style}"),
85
+ (" ", style),
86
+ (" ", "default")
87
+ )
88
+
89
+ @staticmethod
90
+ def context_bar(used: int, total: int, width: int = 14) -> Text:
91
+ """Merender bar kapasitas context window dengan transisi warna cerdas."""
92
+ total = max(total, 1)
93
+ pct = min(max(used / total, 0.0), 1.0)
94
+ filled = int(pct * width)
95
+ empty = width - filled
96
+
97
+ if pct < 0.60:
98
+ color = "#10B981" # Mint Green
99
+ elif pct < 0.85:
100
+ color = "#F59E0B" # Cyber Amber
101
+ else:
102
+ color = "bold #EF4444" # Crimson Overflow Warning
103
+
104
+ bar = "█" * filled + "░" * empty
105
+ return Text.assemble(
106
+ ("CTX [", "#52525B"),
107
+ (bar, color),
108
+ (f"] {int(pct * 100)}% ({used // 1000}k/{total // 1000}k)", "dim #E2E8F0")
109
+ )
110
+
111
+ @staticmethod
112
+ def tool_dispatch_box(tool_name: str, cmd: str) -> Panel:
113
+ """Panel khusus blok eksekusi tool/sandbox."""
114
+ body = Text.assemble(
115
+ ("⚡ COMMAND: ", "bold #00F0FF"),
116
+ (f"{cmd}\n", "bright_white"),
117
+ ("↳ ENV: ", "dim #71717A"),
118
+ ("local://powershell-sandbox ", "#10B981"),
119
+ ("│ TIMEOUT: ", "dim #71717A"),
120
+ ("30s", "white")
121
+ )
122
+ return Panel(
123
+ body,
124
+ title=f"[bold #00F0FF]TOOL DISPATCH :: {tool_name.upper()}[/bold #00F0FF]",
125
+ border_style="#00F0FF",
126
+ padding=(0, 1)
127
+ )
128
+
129
+
130
+ # =====================================================================
131
+ # 2. CUSTOM REACTIVE WIDGETS
132
+ # =====================================================================
133
+
134
+ class HeaderBar(Static):
135
+ """Header status bar dengan visual telemetry global."""
136
+ # Honest idle values: zeros until the daemon reports real numbers.
137
+ latency = reactive(0)
138
+ tok_speed = reactive(0.0)
139
+ ctx_used = reactive(0)
140
+ ctx_total = 32000
141
+ model_name = reactive("—")
142
+ state_label = reactive("IDLE")
143
+
144
+ def render(self) -> Panel:
145
+ state_color = "#10B981" if self.state_label == "IDLE" else "#00F0FF"
146
+
147
+ left_text = Text.assemble(
148
+ (" ╭─ ", "#27272A"),
149
+ ("LIBER", "bold #00FF87"),
150
+ (" // AGENTIC HARNESS ", "dim white"),
151
+ ("[STATE: ", "#52525B"),
152
+ (self.state_label, f"bold {state_color}"),
153
+ ("] ", "#52525B"),
154
+ ("MODEL: ", "dim white"),
155
+ (self.model_name, "bold white")
156
+ )
157
+
158
+ ctx_render = LiberCue.context_bar(self.ctx_used, self.ctx_total)
159
+
160
+ right_text = Text.assemble(
161
+ (f"⚡ {self.tok_speed:.1f} t/s ", "bold #00F0FF"),
162
+ ("│ ", "#27272A"),
163
+ (f"⏱ {self.latency}ms ", "#10B981"),
164
+ ("│ ", "#27272A"),
165
+ ctx_render,
166
+ (" ─╮", "#27272A")
167
+ )
168
+
169
+ grid = Table.grid(expand=True)
170
+ grid.add_column(justify="left")
171
+ grid.add_column(justify="right")
172
+ grid.add_row(left_text, right_text)
173
+
174
+ return Panel(grid, style="on #08090A", border_style="#00FF87")
175
+
176
+
177
+ class TelemetrySidebar(Static):
178
+ """Sidebar kanan berisi metrik performa, daftar agen nyata, dan memory bank."""
179
+ tok_velocity = reactive(" ▂▃▅▇█▇▆▅ — t/s")
180
+ cost_accum = reactive(0.0)
181
+ agents = reactive([])
182
+ gateway = reactive("LiberDaemon:4567")
183
+
184
+ def render(self) -> Panel:
185
+ content = Text()
186
+
187
+ # Real agent fleet, straight from the daemon — no invented topology.
188
+ content.append("⎔ AGENT FLEET\n", "bold #8B5CF6")
189
+ agents = self.agents or []
190
+ if not agents:
191
+ content.append(" └─ (no agents reported)\n\n", "dim white")
192
+ else:
193
+ for i, a in enumerate(agents):
194
+ branch = " └─" if i == len(agents) - 1 else " ├─"
195
+ running = a.get("status") == "RUNNING"
196
+ icon, color = ("●", "#10B981") if running else ("○", "#52525B")
197
+ content.append(f" {branch} [{icon}] ", color)
198
+ content.append(f"{a.get('id', '?')} ({a.get('status', '?')})\n", "white")
199
+ content.append("\n", "default")
200
+
201
+ # Telemetry Data
202
+ content.append("⎔ RUNTIME TELEMETRY\n", "bold #00F0FF")
203
+ content.append(" • Velocity : ", "dim white")
204
+ content.append(f"{self.tok_velocity}\n", "bright_white")
205
+ content.append(" • Cost Acc : ", "dim white")
206
+ content.append(f"${self.cost_accum:.4f}\n", "bold #F59E0B")
207
+ content.append(" • Gateway : ", "dim white")
208
+ content.append(f"{DAEMON_URL.split('//')[-1]}\n", "#38BDF8")
209
+ content.append(" • Isolation: ", "dim white")
210
+ content.append("Pattern policy\n", "#F59E0B")
211
+ content.append(" ", "dim white")
212
+ content.append("(no OS sandbox)\n\n", "dim #52525B")
213
+
214
+ # Memory Slots
215
+ content.append("⎔ MEMORY POOL (Cognitive)\n", "bold #F59E0B")
216
+ content.append(" • Working Memory : ", "dim white")
217
+ win = LOCAL_FACTS.get("window_size")
218
+ content.append(f"sliding-window ({win})" if win else "sliding-window\n", "white")
219
+ content.append("\n", "white")
220
+ content.append(" • Active Context : ", "dim white")
221
+ content.append("Evidence Ledger [ON]\n", "white")
222
+ content.append(" • Circuit Breaker : ", "dim white")
223
+ loops = LOCAL_FACTS.get("max_loops")
224
+ content.append(f"{loops} loops max\n" if loops else "—\n", "dim #52525B")
225
+
226
+ return Panel(
227
+ content,
228
+ title="[bold #8B5CF6]MISSION INSPECTOR[/bold #8B5CF6]",
229
+ border_style="#27272A",
230
+ padding=(1, 1)
231
+ )
232
+
233
+
234
+ class HitlGate(Static):
235
+ """Dock panel for Human-In-The-Loop interventions."""
236
+ hitl_active = reactive(False)
237
+ action_target = reactive("system_call")
238
+
239
+ def render(self) -> Panel:
240
+ if not self.hitl_active:
241
+ msg = Text.assemble(
242
+ ("● MODE: ", "#52525B"),
243
+ ("AUTONOMOUS EXECUTION", "bold #10B981"),
244
+ (" │ Press ", "dim white"),
245
+ ("[SPACE]", "bold white"),
246
+ (" to pause execution loop. Type instructions below.", "dim white")
247
+ )
248
+ return Panel(msg, border_style="#27272A", style="on #08090A")
249
+
250
+ msg = Text.assemble(
251
+ ("⚠️ HITL INTERCEPT ACTIVE: ", "bold black on #F59E0B"),
252
+ (f" Agent requested operator authorization for: `{self.action_target}`\n", "bold white"),
253
+ ("❯ Choose Action: ", "bold #F59E0B"),
254
+ ("[A]", "bold #10B981"), (" Approve ", "white"),
255
+ ("[D]", "bold #EF4444"), (" Deny & Abort ", "white"),
256
+ ("[SPACE]", "bold #00F0FF"), (" Toggle Pause", "white")
257
+ )
258
+ return Panel(msg, border_style="#F59E0B", style="on #0D1117")
259
+
260
+
261
+ # =====================================================================
262
+ # 3. ROOT TUI APPLICATION
263
+ # =====================================================================
264
+
265
+ # (label, model, provider, endpoint) — daemon validates and persists the choice.
266
+ MODEL_CATALOG = [
267
+ ("Ollama Cloud · glm-5.3-flash", "glm-5.3-flash", "openai", "https://ollama.com/v1"),
268
+ ("Ollama Cloud · kimi-k2.7-code", "kimi-k2.7-code", "openai", "https://ollama.com/v1"),
269
+ ("OpenAI · gpt-4.1-mini", "gpt-4.1-mini", "openai", "https://api.openai.com/v1"),
270
+ ("Groq · llama-3.3-70b-versatile", "llama-3.3-70b-versatile", "openai", "https://api.groq.com/openai/v1"),
271
+ ("Mistral · mistral-large-latest", "mistral-large-latest", "openai", "https://api.mistral.ai/v1"),
272
+ ("DeepSeek · deepseek-chat", "deepseek-chat", "openai", "https://api.deepseek.com/v1"),
273
+ ("Anthropic · claude-sonnet-4-5", "claude-sonnet-4-5", "anthropic", "https://api.anthropic.com"),
274
+ ("Google · gemini-2.5-pro", "gemini-2.5-pro", "gemini", "https://generativelanguage.googleapis.com"),
275
+ ("Ollama local · llama3.2", "llama3.2", "ollama", "http://localhost:11434"),
276
+ ]
277
+
278
+
279
+ class ModelSelectScreen(ModalScreen):
280
+ """Picker modal: memilih model dari katalog dan meneruskan ke daemon."""
281
+
282
+ def compose(self) -> ComposeResult:
283
+ with Vertical():
284
+ yield Static("Select AI model — the provider key must exist in .env", classes="model-hint")
285
+ yield OptionList(*[label for label, *_ in MODEL_CATALOG], id="model-options")
286
+
287
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
288
+ self.dismiss(str(event.option.id))
289
+
290
+
291
+ class LiberApp(App):
292
+ """Aplikasi TUI Utama Harness Liber."""
293
+
294
+ CSS = """
295
+ Screen {
296
+ background: #08090A;
297
+ color: #E2E8F0;
298
+ }
299
+
300
+ HeaderBar {
301
+ height: 3;
302
+ margin: 0;
303
+ padding: 0;
304
+ }
305
+
306
+ #main-container {
307
+ height: 1fr;
308
+ }
309
+
310
+ #stream-pane {
311
+ width: 70%;
312
+ border: round #27272A;
313
+ background: #08090A;
314
+ margin-right: 1;
315
+ }
316
+
317
+ #sidebar-pane {
318
+ width: 30%;
319
+ }
320
+
321
+ HitlGate {
322
+ height: 4;
323
+ margin-top: 0;
324
+ margin-bottom: 0;
325
+ }
326
+
327
+ #prompt-input {
328
+ dock: bottom;
329
+ height: 3;
330
+ border: round #00F0FF;
331
+ background: #0D1117;
332
+ color: #E2E8F0;
333
+ padding: 0 1;
334
+ margin-bottom: 0;
335
+ }
336
+
337
+ RichLog {
338
+ background: #08090A;
339
+ color: #E2E8F0;
340
+ scrollbar-gutter: stable;
341
+ padding: 0 1;
342
+ }
343
+
344
+ ModelSelectScreen {
345
+ align: center middle;
346
+ background: rgba(8, 9, 10, 0.85);
347
+ }
348
+
349
+ ModelSelectScreen Vertical {
350
+ width: 52;
351
+ height: auto;
352
+ max-height: 26;
353
+ border: round #8B5CF6;
354
+ background: #0D1117;
355
+ padding: 0 1;
356
+ }
357
+
358
+ #model-options {
359
+ height: auto;
360
+ max-height: 18;
361
+ }
362
+
363
+ .model-hint {
364
+ color: #94A3B8;
365
+ padding: 1 1 0 1;
366
+ }
367
+ """
368
+
369
+ BINDINGS = [
370
+ Binding("space", "toggle_pause", "Pause/Resume"),
371
+ Binding("m", "switch_model", "Model"),
372
+ Binding("a", "approve_action", "Approve HITL"),
373
+ Binding("d", "deny_action", "Deny HITL"),
374
+ Binding("c", "clear_logs", "Clear Logs"),
375
+ Binding("q", "quit", "Quit Liber")
376
+ ]
377
+
378
+ def compose(self) -> ComposeResult:
379
+ yield HeaderBar(id="header-bar")
380
+ with Horizontal(id="main-container"):
381
+ with Vertical(id="stream-pane"):
382
+ yield RichLog(id="agent-log", wrap=True, highlight=False, markup=True)
383
+ with Vertical(id="sidebar-pane"):
384
+ yield TelemetrySidebar(id="telemetry-sidebar")
385
+ yield HitlGate(id="hitl-gate")
386
+ yield Input(placeholder="Type instructions for agent (Press Enter to send)...", id="prompt-input")
387
+ yield Footer()
388
+
389
+ async def on_mount(self) -> None:
390
+ """Initialize daemon connection and mount widgets."""
391
+ self.log_widget = self.query_one("#agent-log", RichLog)
392
+ self.hitl_widget = self.query_one("#hitl-gate", HitlGate)
393
+ self.sidebar_widget = self.query_one("#telemetry-sidebar", TelemetrySidebar)
394
+ self.header_widget = self.query_one("#header-bar", HeaderBar)
395
+ self.prompt_input = self.query_one("#prompt-input", Input)
396
+
397
+ self.hitl_signal: Optional[asyncio.Event] = None
398
+ self.hitl_verdict: bool = False
399
+ self.agent_busy: bool = False
400
+
401
+ # Welcome operator in stream log
402
+ self.log_widget.write(
403
+ Text.assemble(
404
+ LiberCue.badge("SYSTEM", "black on #00FF87"),
405
+ (f" Liber Agentic Harness v{LOCAL_FACTS['version']} active. Engine: ", "bold white"),
406
+ (f"{DAEMON_URL}\n", "#38BDF8"),
407
+ (" Type commands in input bar below or press ", "dim white"),
408
+ ("[SPACE]", "bold white"),
409
+ (" to toggle pause.\n", "dim white")
410
+ )
411
+ )
412
+
413
+ # Check daemon availability
414
+ asyncio.create_task(self.check_and_ensure_daemon())
415
+
416
+ async def check_and_ensure_daemon(self) -> None:
417
+ """Verify daemon status and fetch active agent profile."""
418
+ try:
419
+ async with httpx.AsyncClient(timeout=2.0) as client:
420
+ res = await client.get(f"{DAEMON_URL}/ps", headers=auth_headers())
421
+ if res.status_code == 200:
422
+ data = res.json()
423
+ agents = data.get("data", [])
424
+ if agents:
425
+ agent = agents[0]
426
+ model = agent.get("model") or agent.get("config", {}).get("engine", {}).get("model", "—")
427
+ self.header_widget.model_name = model
428
+ self.sidebar_widget.agents = agents
429
+ self.log_widget.write(
430
+ Text.assemble(
431
+ LiberCue.badge("DAEMON", "black on #00F0FF"),
432
+ (f" Connected to agent: `{agent.get('id', 'dev-bot')}` [ONLINE]\n", "#10B981")
433
+ )
434
+ )
435
+ return
436
+ except Exception:
437
+ pass
438
+
439
+ # Offline guidance
440
+ self.log_widget.write(
441
+ Text.assemble(
442
+ LiberCue.badge("OFFLINE", "black on #F59E0B"),
443
+ (" Daemon is offline on port 4567. Run 'liber spawn' in terminal or wait for auto-spawn...", "dim #F59E0B")
444
+ )
445
+ )
446
+
447
+ # -----------------------------------------------------------------
448
+ # Event-Driven Backend Streaming Queue
449
+ # -----------------------------------------------------------------
450
+
451
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
452
+ """Triggered when the operator submits an instruction in the prompt bar."""
453
+ prompt = event.value.strip()
454
+ if not prompt or self.agent_busy:
455
+ return
456
+
457
+ self.prompt_input.value = ""
458
+ asyncio.create_task(self.dispatch_agent_cycle(prompt))
459
+
460
+ async def dispatch_agent_cycle(self, prompt: str) -> None:
461
+ """Connects prompt to backend engine real-time SSE stream."""
462
+ self.agent_busy = True
463
+ self.header_widget.state_label = "EXECUTING"
464
+ self.query_one("#stream-pane").styles.border = ("round", "#00F0FF")
465
+
466
+ # Display operator prompt in log stream
467
+ self.log_widget.write(
468
+ Text.assemble(
469
+ LiberCue.badge("OPERATOR", "black on #00F0FF"),
470
+ (f" {prompt}", "bold bright_white")
471
+ )
472
+ )
473
+
474
+ try:
475
+ async with httpx.AsyncClient(timeout=120.0) as client:
476
+ # Streaming POST ke /chat/stream
477
+ async with client.stream(
478
+ "POST",
479
+ f"{DAEMON_URL}/chat/stream",
480
+ json={"agentId": "dev-bot", "message": prompt},
481
+ headers=auth_headers()
482
+ ) as response:
483
+ if response.status_code != 200:
484
+ self.log_widget.write(
485
+ Text.assemble(
486
+ LiberCue.badge("ERROR", "white on #EF4444"),
487
+ (f" Gateway HTTP {response.status_code}: Gagal memulai stream.", "red")
488
+ )
489
+ )
490
+ return
491
+
492
+ current_event = ""
493
+ async for line in response.aiter_lines():
494
+ line = line.strip()
495
+ if not line:
496
+ continue
497
+
498
+ if line.startswith("event:"):
499
+ current_event = line.replace("event:", "").strip()
500
+ continue
501
+
502
+ if line.startswith("data:"):
503
+ raw_data = line.replace("data:", "").strip()
504
+ try:
505
+ payload = json.loads(raw_data)
506
+ except Exception:
507
+ payload = {"content": raw_data}
508
+
509
+ await self.handle_backend_event(current_event, payload)
510
+
511
+ except httpx.ConnectError:
512
+ self.log_widget.write(
513
+ Text.assemble(
514
+ LiberCue.badge("ERROR", "white on #EF4444"),
515
+ (" Unable to connect to Liber daemon (127.0.0.1:4567). Ensure daemon is running.", "#EF4444")
516
+ )
517
+ )
518
+ except Exception as e:
519
+ self.log_widget.write(
520
+ Text.assemble(
521
+ LiberCue.badge("EXCEPTION", "white on #EF4444"),
522
+ (f" {str(e)}", "red")
523
+ )
524
+ )
525
+ finally:
526
+ self.agent_busy = False
527
+ self.header_widget.state_label = "IDLE"
528
+ self.query_one("#stream-pane").styles.border = ("round", "#27272A")
529
+
530
+ async def handle_backend_event(self, event: str, data: Dict[str, Any]) -> None:
531
+ """
532
+ Event Parser for agent harness:
533
+ - thought -> LiberCue.badge("THOUGHT", ...)
534
+ - tool_dispatch -> LiberCue.tool_dispatch_box(tool_name, cmd)
535
+ - hitl_intercept -> Trigger breakpoint HITL
536
+ - observation -> LiberCue.badge("OBSERVE", ...)
537
+ - telemetry -> Reactive updates on header and sidebar
538
+ - final -> Output final answer
539
+ """
540
+ # 0. Live draft stream (raw model output, not yet audited)
541
+ if event == "draft_start":
542
+ self.header_widget.state_label = "DRAFTING"
543
+ return
544
+ if event == "draft":
545
+ return
546
+ if event == "draft_end":
547
+ self.header_widget.state_label = "IDLE"
548
+ if data.get("failed"):
549
+ self.log_widget.write(
550
+ Text.assemble(
551
+ LiberCue.badge("DRAFT", "white on #F59E0B"),
552
+ (" stream interrupted — falling back to a complete audited answer", "dim #F59E0B")
553
+ )
554
+ )
555
+ else:
556
+ self.log_widget.write(
557
+ Text.assemble(
558
+ LiberCue.badge("DRAFT", "white on #8B5CF6"),
559
+ (f" {data.get('chars', 0)} chars streamed · UNVERIFIED until the audit passes", "dim #A78BFA")
560
+ )
561
+ )
562
+ return
563
+
564
+ if event == "subagent_start":
565
+ self.log_widget.write(
566
+ Text.assemble(
567
+ LiberCue.badge("SUBAGENT", "black on #06B6D4"),
568
+ (f" {data.get('id', '')} · {data.get('goal', '')}", "dim #67E8F9")
569
+ )
570
+ )
571
+ return
572
+ if event == "subagent_end":
573
+ self.log_widget.write(
574
+ Text.assemble(
575
+ LiberCue.badge("SUBAGENT", "white on #0891B2"),
576
+ (f" {data.get('id', '')} · {data.get('status', 'done')}", "dim #67E8F9")
577
+ )
578
+ )
579
+ return
580
+
581
+ # 1. Thought / Reasoning
582
+ if event == "thought":
583
+ thought_text = data.get("content", "")
584
+ self.log_widget.write(
585
+ Text.assemble(
586
+ LiberCue.badge("REASONING", "black on #8B5CF6"),
587
+ (f" {thought_text}", "italic #E2E8F0")
588
+ )
589
+ )
590
+
591
+ # 2. Tool Dispatch
592
+ elif event == "tool_dispatch":
593
+ tool_name = data.get("tool", "sandbox_exec")
594
+ cmd = data.get("command", "")
595
+ self.log_widget.write(LiberCue.tool_dispatch_box(tool_name, cmd))
596
+
597
+ # 3. Sensitive Action -> HITL Breakpoint
598
+ elif event == "hitl_intercept":
599
+ target = data.get("target", "command")
600
+ self.hitl_widget.action_target = target
601
+ self.hitl_widget.hitl_active = True
602
+ self.query_one("#stream-pane").styles.border = ("round", "#F59E0B")
603
+
604
+ self.log_widget.write(
605
+ Text.assemble(
606
+ LiberCue.badge("CRIT/WARN", "black on #F59E0B"),
607
+ (f" [BREAKPOINT ACTIVATED] Policy requires manual operator authorization for: `{target}`", "bold #F59E0B")
608
+ )
609
+ )
610
+
611
+ # Signal barrier
612
+ self.hitl_signal = asyncio.Event()
613
+ await self.hitl_signal.wait()
614
+
615
+ # Send verdict back to daemon backend
616
+ verdict_str = "approve" if self.hitl_verdict else "deny"
617
+ try:
618
+ async with httpx.AsyncClient(timeout=5.0) as client:
619
+ await client.post(f"{DAEMON_URL}/hitl/verdict", json={"verdict": verdict_str}, headers=auth_headers())
620
+ except Exception:
621
+ pass
622
+
623
+ # 4. Observation Stage
624
+ elif event == "observation":
625
+ output = data.get("output", "")
626
+ exit_code = data.get("exit_code", 0)
627
+ duration_ms = data.get("duration_ms", 0)
628
+ approved = data.get("approved", True)
629
+
630
+ color = "#10B981" if approved and exit_code == 0 else "#EF4444"
631
+ self.log_widget.write(
632
+ Text.assemble(
633
+ LiberCue.badge("OBSERVE", "white on #27272A"),
634
+ (f" STDOUT: {output}\n", color),
635
+ (f"↳ EXIT: {exit_code} │ DURATION: {duration_ms}ms │ POLICY: OK", "dim #71717A")
636
+ )
637
+ )
638
+
639
+ # 5. Reactive Telemetry Updates
640
+ elif event == "telemetry":
641
+ if "tok_speed" in data:
642
+ self.header_widget.tok_speed = float(data["tok_speed"])
643
+ if "ctx_used" in data:
644
+ self.header_widget.ctx_used = int(data["ctx_used"])
645
+ if "ctx_total" in data:
646
+ self.header_widget.ctx_total = int(data["ctx_total"])
647
+ if "latency" in data:
648
+ self.header_widget.latency = int(data["latency"])
649
+ if "cost_accum" in data:
650
+ self.sidebar_widget.cost_accum = float(data["cost_accum"])
651
+
652
+ # 5b. Grounding gate events (zero-hallucination layer)
653
+ elif event == "grounding_audit":
654
+ codes = ", ".join(sorted({v.get("code", "?") for v in data.get("violations", [])}))
655
+ self.log_widget.write(
656
+ Text.assemble(
657
+ LiberCue.badge("GROUNDING", "black on #F59E0B"),
658
+ (f" repair {data.get('attempt', 1)}/{data.get('max_repairs', 2)} — blocked unverified claims: {codes}", "bold #F59E0B")
659
+ )
660
+ )
661
+
662
+ elif event == "grounding_warning":
663
+ n = len(data.get("violations", []))
664
+ self.log_widget.write(
665
+ Text.assemble(
666
+ LiberCue.badge("UNVERIFIED", "white on #EF4444"),
667
+ (f" {n} claim(s) shipped without receipts — see GROUNDING AUDIT footer", "bold #EF4444")
668
+ )
669
+ )
670
+
671
+ elif event == "grounding_pass":
672
+ self.log_widget.write(
673
+ Text.assemble(
674
+ LiberCue.badge("GROUNDED", "black on #10B981"),
675
+ (f" {data.get('claims_checked', 0)} claim(s) bound to {data.get('receipts', 0)} receipt(s)", "dim #10B981")
676
+ )
677
+ )
678
+
679
+ # 6. Final Agent Response
680
+ elif event == "final":
681
+ final_text = data.get("response", "")
682
+ duration = data.get("durationMs", 0)
683
+ iterations = data.get("iterations", 1)
684
+ grounding = data.get("grounding") or {}
685
+ status = grounding.get("status", "DISABLED")
686
+
687
+ if status == "PASS":
688
+ cycle = f"↳ [CYCLE COMPLETE: {duration}ms │ {iterations} turns │ GROUNDED · {len(grounding.get('ledger', {}).get('receipts', []))} receipts]"
689
+ cycle_style = "dim #10B981"
690
+ elif status == "REPAIR":
691
+ cycle = f"↳ [CYCLE COMPLETE: {duration}ms │ {iterations} turns │ ⚠ {len(grounding.get('violations', []))} UNVERIFIED CLAIM(S)]"
692
+ cycle_style = "bold #F59E0B"
693
+ else:
694
+ cycle = f"↳ [CYCLE COMPLETE: {duration}ms │ {iterations} turns]"
695
+ cycle_style = "dim #10B981"
696
+
697
+ self.log_widget.write(
698
+ Text.assemble(
699
+ LiberCue.badge("RESPONSE", "black on #00FF87"),
700
+ (f" {final_text}\n", "bright_white"),
701
+ (cycle, cycle_style)
702
+ )
703
+ )
704
+
705
+ # 7. Error event
706
+ elif event == "error":
707
+ err_msg = data.get("error", "Unknown error")
708
+ self.log_widget.write(
709
+ Text.assemble(
710
+ LiberCue.badge("ERROR", "white on #EF4444"),
711
+ (f" {err_msg}", "bold #EF4444")
712
+ )
713
+ )
714
+
715
+ # -----------------------------------------------------------------
716
+ # Key Action Handlers (Operator Interventions)
717
+ # -----------------------------------------------------------------
718
+
719
+ def action_switch_model(self) -> None:
720
+ """Buka modal pemilihan model AI."""
721
+ self.push_screen(ModelSelectScreen(), self.on_model_selected)
722
+
723
+ def on_model_selected(self, label: Optional[str]) -> None:
724
+ if not label:
725
+ return
726
+ preset = next((p for p in MODEL_CATALOG if p[0] == label), None)
727
+ if not preset:
728
+ return
729
+ _, model, provider, endpoint = preset
730
+ self.run_worker(self.apply_model_switch(model, provider, endpoint))
731
+
732
+ @work
733
+ async def apply_model_switch(self, model: str, provider: str, endpoint: str) -> None:
734
+ """POST /model/switch dan perbarui header dari respons daemon."""
735
+ try:
736
+ async with httpx.AsyncClient(timeout=8.0) as client:
737
+ res = await client.post(
738
+ f"{DAEMON_URL}/model/switch",
739
+ json={"model": model, "provider": provider, "endpoint": endpoint},
740
+ headers=auth_headers(),
741
+ )
742
+ if res.status_code == 200:
743
+ data = res.json()
744
+ if data.get("success"):
745
+ active = data.get("active") or {}
746
+ self.header_widget.model_name = active.get("model", model)
747
+ self.sidebar_widget.gateway = active.get("endpoint", "").replace("https://", "").replace("http://", "") or self.sidebar_widget.gateway
748
+ self.log_widget.write(
749
+ Text.assemble(
750
+ LiberCue.badge("MODEL", "black on #8B5CF6"),
751
+ (f" switched to {active.get('model', model)} via {active.get('provider', provider)}", "#10B981"),
752
+ )
753
+ )
754
+ return
755
+ try:
756
+ detail = res.json().get("error", "")
757
+ except Exception:
758
+ detail = f"HTTP {res.status_code}"
759
+ self.log_widget.write(
760
+ Text.assemble(
761
+ LiberCue.badge("MODEL", "white on #EF4444"),
762
+ (f" switch failed: {detail}", "red"),
763
+ )
764
+ )
765
+ except Exception as exc: # noqa: BLE001
766
+ self.log_widget.write(
767
+ Text.assemble(
768
+ LiberCue.badge("MODEL", "white on #EF4444"),
769
+ (f" switch failed: {exc}", "red"),
770
+ )
771
+ )
772
+
773
+ def action_toggle_pause(self) -> None:
774
+ """Pause or resume the execution loop."""
775
+ self.hitl_widget.hitl_active = not self.hitl_widget.hitl_active
776
+ if self.hitl_widget.hitl_active:
777
+ self.query_one("#stream-pane").styles.border = ("round", "#F59E0B")
778
+ self.log_widget.write(
779
+ Text.assemble(LiberCue.badge("PAUSED", "black on #F59E0B"), (" Execution halted by operator.", "white"))
780
+ )
781
+ else:
782
+ self.query_one("#stream-pane").styles.border = ("round", "#27272A")
783
+ self.log_widget.write(
784
+ Text.assemble(LiberCue.badge("RESUMED", "black on #10B981"), (" Resuming agent loop cycle...", "white"))
785
+ )
786
+
787
+ def action_approve_action(self) -> None:
788
+ """Approve sensitive tool/command execution."""
789
+ if self.hitl_widget.hitl_active:
790
+ self.hitl_widget.hitl_active = False
791
+ self.hitl_verdict = True
792
+ self.query_one("#stream-pane").styles.border = ("round", "#10B981")
793
+ self.log_widget.write(
794
+ Text.assemble(
795
+ LiberCue.badge("APPROVED", "black on #10B981"),
796
+ (f" Authorization granted for `{self.hitl_widget.action_target}`. Executing...", "white")
797
+ )
798
+ )
799
+ if self.hitl_signal:
800
+ self.hitl_signal.set()
801
+
802
+ def action_deny_action(self) -> None:
803
+ """Deny sensitive action and require agent to re-plan."""
804
+ if self.hitl_widget.hitl_active:
805
+ self.hitl_widget.hitl_active = False
806
+ self.hitl_verdict = False
807
+ self.query_one("#stream-pane").styles.border = ("round", "#EF4444")
808
+ self.log_widget.write(
809
+ Text.assemble(
810
+ LiberCue.badge("REJECTED", "white on #EF4444"),
811
+ (f" Action `{self.hitl_widget.action_target}` denied by operator. Sending feedback to agent.", "bold #EF4444")
812
+ )
813
+ )
814
+ if self.hitl_signal:
815
+ self.hitl_signal.set()
816
+
817
+ def action_clear_logs(self) -> None:
818
+ """Clear log pane."""
819
+ self.log_widget.clear()
820
+
821
+
822
+ if __name__ == "__main__":
823
+ app = LiberApp()
824
+ app.run()
825
+