agent-code 1.0.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.
agent_code/agent.py ADDED
@@ -0,0 +1,1542 @@
1
+ """
2
+ agent.py — AI agent with Stop/Continue, local + cloud chat save,
3
+ headless browser, sandboxed shell, skill-loaded commands, two-pane Help.
4
+
5
+ No auto-install of skills — install them yourself via skill-manager.py
6
+ into ./skills/ and they'll be picked up on startup.
7
+
8
+ Deps: DrissionPage, openai
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import sys
14
+ import json
15
+ import time
16
+ import shlex
17
+ import queue
18
+ import threading
19
+ import itertools
20
+ import subprocess
21
+ import urllib.error
22
+ import urllib.request
23
+ from pathlib import Path
24
+
25
+ import tkinter as tk
26
+ from tkinter import ttk, messagebox
27
+ from tkinter import font as tkfont
28
+
29
+ from DrissionPage import ChromiumPage, ChromiumOptions
30
+ from openai import OpenAI
31
+
32
+ # Load the skill loader from skills/loader.py
33
+ from .loader import discover_skills as loader_discover
34
+ from .loader import load_skills as loader_load
35
+ from .loader import get_skill_help as loader_get_help
36
+ from . import paths
37
+
38
+
39
+ # ===========================================================================
40
+ # Paths
41
+ # ===========================================================================
42
+
43
+ ROOT = paths.project_dir()
44
+ WORKSPACE = paths.project_workspace_dir()
45
+ SKILLS_DIR = paths.project_skills_dir()
46
+ CHATS_DIR = paths.project_chats_dir()
47
+ CONFIG_FILE = paths.project_config_file()
48
+ CHAT_FILE = CHATS_DIR / "current.json"
49
+
50
+ paths.install_bundled_skills(overwrite=False)
51
+ os.environ["AGENT_WORKSPACE"] = str(WORKSPACE)
52
+
53
+ print(f"[sandbox] workspace = {WORKSPACE}")
54
+
55
+
56
+ # ===========================================================================
57
+ # Server URLs — primary HTTPS, IP fallback for cloud sync
58
+ # ===========================================================================
59
+
60
+ SKILL_SERVER_PRIMARY = "https://skills-manager.freesrv.com"
61
+ SKILL_SERVER_FALLBACK = "http://78.154.103.43:9074"
62
+ SKILL_SERVERS = [SKILL_SERVER_PRIMARY, SKILL_SERVER_FALLBACK]
63
+ DEFAULT_SKILL_SERVER = SKILL_SERVER_PRIMARY # kept for display
64
+
65
+ SM_CFG_FILE = Path.home() / ".skill-manager.json"
66
+ SKILL_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
67
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
68
+ "Chrome/125.0.0.0 Safari/537.36")
69
+
70
+
71
+ def _sm_cfg() -> dict:
72
+ if SM_CFG_FILE.exists():
73
+ try:
74
+ return json.loads(SM_CFG_FILE.read_text(encoding="utf-8"))
75
+ except Exception:
76
+ pass
77
+ return {}
78
+
79
+
80
+ def _sm_server_list() -> list:
81
+ cfg = _sm_cfg()
82
+ servers = []
83
+ custom = cfg.get("server")
84
+ if custom:
85
+ servers.append(custom)
86
+ for s in SKILL_SERVERS:
87
+ if s not in servers:
88
+ servers.append(s)
89
+ return servers
90
+
91
+
92
+ # ===========================================================================
93
+ # Config
94
+ # ===========================================================================
95
+
96
+ MAX_TURNS = 30
97
+ SHELL_TIMEOUT = 40
98
+
99
+
100
+ def load_agent_config() -> dict:
101
+ if CONFIG_FILE.exists():
102
+ try:
103
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
104
+ except Exception as e:
105
+ print(f"[config] failed to parse: {e}")
106
+ return {}
107
+
108
+
109
+ def save_agent_config(cfg: dict):
110
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
111
+
112
+
113
+ def normalize_keys(raw) -> list:
114
+ if isinstance(raw, str):
115
+ raw = [raw]
116
+ if not raw:
117
+ raw = [k.strip() for k in os.getenv("OPENAI_API_KEYS", "").split(",") if k.strip()]
118
+ return [k.strip().strip('"').strip("'") for k in raw if k and k.strip()]
119
+
120
+
121
+ # ===========================================================================
122
+ # LLM
123
+ # ===========================================================================
124
+
125
+ def make_client(api_keys: list, base_url: str):
126
+ if not api_keys:
127
+ return None
128
+ if len(api_keys) == 1:
129
+ return OpenAI(api_key=api_keys[0], base_url=base_url)
130
+ cycle = itertools.cycle(api_keys)
131
+ return OpenAI(api_key=lambda: next(cycle), base_url=base_url)
132
+
133
+
134
+ # ===========================================================================
135
+ # Shell (sandboxed)
136
+ # ===========================================================================
137
+
138
+ ALLOWED_CMDS = {
139
+ "ls", "pwd", "cd", "cat", "head", "tail", "grep", "find", "wc", "echo",
140
+ "date", "whoami", "git", "python", "python3", "pip", "pip3", "node", "npm",
141
+ "curl", "wget", "sed", "awk", "sort", "uniq", "diff", "tree", "dir",
142
+ "type", "where", "mkdir", "copy", "move",
143
+ }
144
+ BLOCKED_PATTERNS = [
145
+ r"rm\s+-rf\s+/", r":\(\)\s*\{", r"mkfs\.", r"dd\s+if=",
146
+ r">\s*/dev/sd", r"format\s+[a-z]:", r"del\s+/[sq]", r"rd\s+/s",
147
+ r"shutdown", r"reboot", r"reg\s+delete", r"regedit",
148
+ r"net\s+user\s+.*\s+/add", r"runas",
149
+ ]
150
+
151
+
152
+ def _arg_safe(arg: str) -> bool:
153
+ if arg.startswith(("http://", "https://")):
154
+ return True
155
+ if arg.startswith("-"):
156
+ return True
157
+ try:
158
+ p = Path(arg)
159
+ if p.is_absolute():
160
+ return str(p.resolve()).startswith(str(WORKSPACE))
161
+ except Exception:
162
+ pass
163
+ if ".." in re.split(r"[\\/]", arg):
164
+ return False
165
+ return True
166
+
167
+
168
+ def shell_run(command):
169
+ for pat in BLOCKED_PATTERNS:
170
+ if re.search(pat, command, re.IGNORECASE):
171
+ return f"BLOCKED: pattern {pat}"
172
+ if re.search(r"[|&;<>`$]", command):
173
+ return ("BLOCKED: shell metacharacters not allowed. "
174
+ "Run separate commands or write a script file first.")
175
+ try:
176
+ parts = shlex.split(command, posix=False)
177
+ except ValueError as e:
178
+ return f"BLOCKED: parse error {e}"
179
+ if not parts:
180
+ return "BLOCKED: empty"
181
+ base = os.path.basename(parts[0].strip('"')).lower()
182
+ if base not in ALLOWED_CMDS:
183
+ return f"BLOCKED: '{base}' not allowed"
184
+ for arg in parts[1:]:
185
+ if not _arg_safe(arg):
186
+ return f"BLOCKED: arg escapes workspace: {arg}"
187
+ try:
188
+ r = subprocess.run(parts, capture_output=True, text=True,
189
+ encoding="utf-8", errors="replace",
190
+ timeout=SHELL_TIMEOUT, cwd=str(WORKSPACE), shell=False)
191
+ out = ((r.stdout or "") + (r.stderr or "")).strip()
192
+ return out[:4000] or "(no output)"
193
+ except subprocess.TimeoutExpired:
194
+ return f"ERROR: timeout {SHELL_TIMEOUT}s"
195
+ except FileNotFoundError:
196
+ return f"ERROR: command not found: {base}"
197
+ except Exception as e:
198
+ return f"ERROR: {e}"
199
+
200
+
201
+ # ===========================================================================
202
+ # Browser
203
+ # ===========================================================================
204
+
205
+ class Browser:
206
+ def __init__(self, headless=True):
207
+ opts = (ChromiumOptions()
208
+ .headless(headless)
209
+ .set_argument("--disable-blink-features=AutomationControlled")
210
+ .set_argument("--no-sandbox")
211
+ .set_argument("--disable-dev-shm-usage")
212
+ .set_user_data_path(str(Path(os.environ.get("TEMP", "/tmp")) / "agent-profile")))
213
+ self.page = ChromiumPage(opts)
214
+ self._lock = threading.Lock()
215
+
216
+ def self_test(self):
217
+ with self._lock:
218
+ try:
219
+ self.page.get("https://example.com", timeout=15)
220
+ return f"OK · title={self.page.title!r}"
221
+ except Exception as e:
222
+ return f"FAILED · {e}"
223
+
224
+ def navigate(self, url):
225
+ if not url.startswith(("http://", "https://")):
226
+ url = "https://" + url
227
+ with self._lock:
228
+ try:
229
+ self.page.get(url, timeout=30)
230
+ time.sleep(0.3)
231
+ return f"OK title={self.page.title!r} url={self.page.url}"
232
+ except Exception as e:
233
+ return f"ERROR: {e}"
234
+
235
+ def snapshot(self, max_chars=6000):
236
+ with self._lock:
237
+ try:
238
+ body = self.page.ele("tag:body", timeout=5)
239
+ text = body.text if body else self.page.html
240
+ return (text or "(empty)")[:max_chars]
241
+ except Exception as e:
242
+ return f"ERROR: {e}"
243
+
244
+ def links(self, limit=30):
245
+ with self._lock:
246
+ try:
247
+ out = []
248
+ for a in self.page.eles("tag:a")[:limit]:
249
+ t = (a.text or "").strip()[:80]
250
+ h = a.attr("href") or ""
251
+ if t and h:
252
+ out.append(f"{t} -> {h}")
253
+ return "\n".join(out) or "(no links)"
254
+ except Exception as e:
255
+ return f"ERROR: {e}"
256
+
257
+ def click(self, selector):
258
+ with self._lock:
259
+ try:
260
+ ele = self.page.ele(selector, timeout=5)
261
+ if not ele:
262
+ return f"NOT_FOUND: {selector}"
263
+ ele.click()
264
+ time.sleep(0.3)
265
+ return "OK"
266
+ except Exception as e:
267
+ return f"ERROR: {e}"
268
+
269
+ def type_text(self, selector, text, clear=True):
270
+ with self._lock:
271
+ try:
272
+ ele = self.page.ele(selector, timeout=5)
273
+ if not ele:
274
+ return f"NOT_FOUND: {selector}"
275
+ if clear:
276
+ try:
277
+ ele.clear()
278
+ except Exception:
279
+ pass
280
+ ele.input(text)
281
+ return "OK"
282
+ except Exception as e:
283
+ return f"ERROR: {e}"
284
+
285
+ def scroll(self, pixels=800):
286
+ with self._lock:
287
+ try:
288
+ self.page.scroll.down(int(pixels))
289
+ return f"OK scrolled {pixels}px"
290
+ except Exception as e:
291
+ return f"ERROR: {e}"
292
+
293
+ def close(self):
294
+ try:
295
+ self.page.quit()
296
+ except Exception:
297
+ pass
298
+
299
+
300
+ # ===========================================================================
301
+ # Core tools + prompt
302
+ # ===========================================================================
303
+
304
+ CORE_SCHEMAS = [
305
+ {"type": "function", "function": {
306
+ "name": "browser_navigate",
307
+ "description": "Open a URL in the headless browser. Faster than shell curl.",
308
+ "parameters": {"type": "object", "properties": {"url": {"type": "string"}},
309
+ "required": ["url"]}}},
310
+ {"type": "function", "function": {
311
+ "name": "browser_snapshot",
312
+ "description": "Return visible text of the current page.",
313
+ "parameters": {"type": "object", "properties": {}}}},
314
+ {"type": "function", "function": {
315
+ "name": "browser_links",
316
+ "description": "List clickable links (text -> href).",
317
+ "parameters": {"type": "object", "properties": {"limit": {"type": "integer"}}}}},
318
+ {"type": "function", "function": {
319
+ "name": "browser_click",
320
+ "description": "Click an element by CSS selector.",
321
+ "parameters": {"type": "object", "properties": {"selector": {"type": "string"}},
322
+ "required": ["selector"]}}},
323
+ {"type": "function", "function": {
324
+ "name": "browser_type",
325
+ "description": "Type text into an input by CSS selector.",
326
+ "parameters": {"type": "object",
327
+ "properties": {"selector": {"type": "string"}, "text": {"type": "string"}},
328
+ "required": ["selector", "text"]}}},
329
+ {"type": "function", "function": {
330
+ "name": "browser_scroll",
331
+ "description": "Scroll the page down by N pixels.",
332
+ "parameters": {"type": "object", "properties": {"pixels": {"type": "integer"}}}}},
333
+ {"type": "function", "function": {
334
+ "name": "shell_run",
335
+ "description": "Run an allowlisted local command inside the sandbox (CWD=./workspace/).",
336
+ "parameters": {"type": "object", "properties": {"command": {"type": "string"}},
337
+ "required": ["command"]}}},
338
+ ]
339
+
340
+ CORE_PROMPT = f"""You are an autonomous agent with:
341
+
342
+ A) BROWSER (headless Chromium):
343
+ browser_navigate, browser_snapshot, browser_links, browser_click,
344
+ browser_type, browser_scroll
345
+ B) SHELL (sandboxed to {WORKSPACE}):
346
+ shell_run
347
+ C) SKILLS (auto-loaded, listed below)
348
+
349
+ TOOL SELECTION:
350
+ - Web tasks → BROWSER FIRST.
351
+ - shell_run("curl ...") for raw JSON/XML only. If it returns empty/403/HTML,
352
+ switch to browser_navigate.
353
+
354
+ SANDBOX:
355
+ - Workspace: {WORKSPACE}
356
+ - File paths are RELATIVE to it.
357
+ - shell_run cannot leave the workspace. No pipes, redirection, ;, &, &&.
358
+
359
+ ENVIRONMENT: Windows CMD. No heredocs. Multi-line Python → write .py file then run.
360
+
361
+ FILE CREATION:
362
+ - Large files: write_file first chunk, append_file the rest.
363
+ - NEVER answer with "hang tight" / "working on it". Either call a tool or
364
+ return the finished result.
365
+
366
+ WORKFLOW:
367
+ 1. Plan briefly, then act with tools.
368
+ 2. After browser_navigate/click, snapshot to verify.
369
+ 3. Login/CAPTCHA/2FA → STOP and ask.
370
+ 4. When done, summarize and give the file path.
371
+ """
372
+
373
+
374
+ # ===========================================================================
375
+ # Theme
376
+ # ===========================================================================
377
+
378
+ BG = "#0e1117"
379
+ BG_PANEL = "#161b22"
380
+ BG_CARD = "#1c2128"
381
+ BG_INPUT = "#1c2128"
382
+ BG_HOVER = "#21262d"
383
+ FG = "#c9d1d9"
384
+ FG_DIM = "#8b949e"
385
+ ACCENT = "#58a6ff"
386
+ GREEN = "#3fb950"
387
+ YELLOW = "#d29922"
388
+ RED = "#f85149"
389
+ PURPLE = "#bc8cff"
390
+ BORDER = "#30363d"
391
+
392
+
393
+ def apply_theme(root):
394
+ style = ttk.Style(root)
395
+ try:
396
+ style.theme_use("clam")
397
+ except tk.TclError:
398
+ pass
399
+ root.configure(bg=BG)
400
+ style.configure("TFrame", background=BG)
401
+ style.configure("Panel.TFrame", background=BG_PANEL)
402
+ style.configure("TLabel", background=BG, foreground=FG)
403
+ style.configure("Panel.TLabel", background=BG_PANEL, foreground=FG)
404
+ style.configure("Dim.TLabel", background=BG, foreground=FG_DIM)
405
+ style.configure("PanelDim.TLabel", background=BG_PANEL, foreground=FG_DIM)
406
+ style.configure("Header.TLabel", background=BG_PANEL,
407
+ foreground=ACCENT, font=("Segoe UI", 12, "bold"))
408
+ style.configure("Status.TLabel", background=BG_PANEL,
409
+ foreground=FG_DIM, font=("Segoe UI", 9))
410
+ style.configure("TEntry", fieldbackground=BG_INPUT, foreground=FG,
411
+ insertcolor=FG, bordercolor=BORDER,
412
+ lightcolor=BORDER, darkcolor=BORDER, padding=8)
413
+ style.configure("TButton", background=BG_CARD, foreground=FG,
414
+ borderwidth=1, focusthickness=0, padding=(10, 6),
415
+ font=("Segoe UI", 9))
416
+ style.map("TButton",
417
+ background=[("active", BG_HOVER), ("disabled", BG_CARD)],
418
+ foreground=[("disabled", FG_DIM)])
419
+ style.configure("Send.TButton", background=ACCENT, foreground="#ffffff",
420
+ borderwidth=0, focusthickness=0, padding=(16, 8),
421
+ font=("Segoe UI", 10, "bold"))
422
+ style.map("Send.TButton",
423
+ background=[("active", "#79b8ff"), ("disabled", "#30363d")],
424
+ foreground=[("disabled", FG_DIM)])
425
+ style.configure("Stop.TButton", background="#8b1a1a", foreground="#ffffff",
426
+ borderwidth=0, focusthickness=0, padding=(16, 8),
427
+ font=("Segoe UI", 10, "bold"))
428
+ style.map("Stop.TButton",
429
+ background=[("active", "#b02121"), ("disabled", "#30363d")],
430
+ foreground=[("disabled", FG_DIM)])
431
+ style.configure("Accent.TButton", background=ACCENT, foreground="#ffffff",
432
+ borderwidth=0, focusthickness=0, padding=(12, 6),
433
+ font=("Segoe UI", 9, "bold"))
434
+ style.map("Accent.TButton",
435
+ background=[("active", "#79b8ff"), ("disabled", "#30363d")],
436
+ foreground=[("disabled", FG_DIM)])
437
+ style.configure("Vertical.TScrollbar", background=BG_PANEL,
438
+ troughcolor=BG, bordercolor=BG, arrowcolor=FG_DIM)
439
+
440
+
441
+ # ===========================================================================
442
+ # Settings dialog
443
+ # ===========================================================================
444
+
445
+ class SettingsDialog(tk.Toplevel):
446
+ def __init__(self, parent, app):
447
+ super().__init__(parent)
448
+ self.app = app
449
+ self.title("Settings")
450
+ self.configure(bg=BG)
451
+ self.geometry("580x620")
452
+ self.resizable(False, False)
453
+ self.transient(parent)
454
+ self.grab_set()
455
+ apply_theme(self)
456
+
457
+ pad = ttk.Frame(self, style="Panel.TFrame")
458
+ pad.pack(fill="both", expand=True)
459
+ ttk.Label(pad, text="SETTINGS", style="Header.TLabel").pack(
460
+ anchor="w", padx=24, pady=(20, 4))
461
+ ttk.Label(pad, text=str(CONFIG_FILE), style="PanelDim.TLabel").pack(
462
+ anchor="w", padx=24, pady=(0, 14))
463
+
464
+ body = tk.Frame(pad, bg=BG_PANEL)
465
+ body.pack(fill="both", expand=True, padx=24)
466
+
467
+ ttk.Label(body, text="LLM", style="PanelDim.TLabel").pack(anchor="w")
468
+ ttk.Separator(body, orient="horizontal").pack(fill="x", pady=(2, 10))
469
+
470
+ ttk.Label(body, text="API keys (one per line)", style="Panel.TLabel").pack(anchor="w")
471
+ keys_frame = tk.Frame(body, bg=BG_PANEL)
472
+ keys_frame.pack(fill="x", pady=(2, 10))
473
+ self.keys_text = tk.Text(keys_frame, height=3, bg=BG_INPUT, fg=FG,
474
+ insertbackground=FG, relief="flat",
475
+ padx=8, pady=6, font=("Consolas", 10), wrap="none")
476
+ self.keys_text.pack(side="left", fill="x", expand=True)
477
+ ks = ttk.Scrollbar(keys_frame, orient="vertical", command=self.keys_text.yview)
478
+ ks.pack(side="right", fill="y")
479
+ self.keys_text.configure(yscrollcommand=ks.set)
480
+ for k in app.api_keys:
481
+ self.keys_text.insert("end", k + "\n")
482
+
483
+ ttk.Label(body, text="Base URL", style="Panel.TLabel").pack(anchor="w")
484
+ self.base_entry = ttk.Entry(body, font=("Segoe UI", 11))
485
+ self.base_entry.insert(0, app.base_url)
486
+ self.base_entry.pack(fill="x", pady=(2, 10))
487
+
488
+ ttk.Label(body, text="Model", style="Panel.TLabel").pack(anchor="w")
489
+ self.model_entry = ttk.Entry(body, font=("Segoe UI", 11))
490
+ self.model_entry.insert(0, app.model)
491
+ self.model_entry.pack(fill="x", pady=(2, 18))
492
+
493
+ ttk.Label(body, text="CLOUD SYNC", style="PanelDim.TLabel").pack(anchor="w")
494
+ ttk.Separator(body, orient="horizontal").pack(fill="x", pady=(2, 10))
495
+ self.cloud_var = tk.BooleanVar(value=app.cloud_sync)
496
+ ttk.Checkbutton(body, text="Sync chat state to skill-manager server",
497
+ variable=self.cloud_var).pack(anchor="w", pady=(0, 6))
498
+ ttk.Label(body, text="servers:",
499
+ style="PanelDim.TLabel").pack(anchor="w", pady=(0, 2))
500
+ for s in app.cloud_servers:
501
+ ttk.Label(body, text=f" {s}",
502
+ style="PanelDim.TLabel").pack(anchor="w")
503
+ ttk.Label(body, text=f"account: {app.cloud_user or '(not signed in)'}",
504
+ style="PanelDim.TLabel").pack(anchor="w", pady=(4, 0))
505
+
506
+ row = tk.Frame(pad, bg=BG_PANEL)
507
+ row.pack(fill="x", padx=24, pady=(14, 20))
508
+ ttk.Button(row, text="Cancel", command=self.destroy).pack(side="right")
509
+ ttk.Button(row, text="Save", style="Accent.TButton",
510
+ command=self._save).pack(side="right", padx=(0, 8))
511
+
512
+ def _save(self):
513
+ keys = [l.strip() for l in self.keys_text.get("1.0", "end").splitlines() if l.strip()]
514
+ base = self.base_entry.get().strip() or "https://api.openai.com/v1"
515
+ model = self.model_entry.get().strip() or "gpt-4o"
516
+ cloud = bool(self.cloud_var.get())
517
+ if not keys:
518
+ messagebox.showerror("Settings", "At least one API key required", parent=self)
519
+ return
520
+ cfg = load_agent_config()
521
+ cfg["openai_api_keys"] = keys
522
+ cfg["openai_base_url"] = base
523
+ cfg["openai_model"] = model
524
+ cfg["cloud_sync"] = cloud
525
+ cfg.setdefault("headless", True)
526
+ cfg.setdefault("disabled_skills", [])
527
+ try:
528
+ save_agent_config(cfg)
529
+ except Exception as e:
530
+ messagebox.showerror("Settings", f"save failed: {e}", parent=self)
531
+ return
532
+ self.app.api_keys = keys
533
+ self.app.base_url = base
534
+ self.app.model = model
535
+ self.app.cloud_sync = cloud
536
+ self.app.client = make_client(keys, base)
537
+ self.app._update_header()
538
+ self.destroy()
539
+ self.app.log_line(f"[settings] saved — model={model} cloud={cloud}", "dim")
540
+
541
+
542
+ # ===========================================================================
543
+ # Skills dialog
544
+ # ===========================================================================
545
+
546
+ class SkillsDialog(tk.Toplevel):
547
+ def __init__(self, parent, app):
548
+ super().__init__(parent)
549
+ self.app = app
550
+ self.title("Skills")
551
+ self.configure(bg=BG)
552
+ self.geometry("640x540")
553
+ self.resizable(True, True)
554
+ self.transient(parent)
555
+ self.grab_set()
556
+ apply_theme(self)
557
+
558
+ pad = ttk.Frame(self, style="Panel.TFrame")
559
+ pad.pack(fill="both", expand=True)
560
+ ttk.Label(pad, text="SKILLS", style="Header.TLabel").pack(
561
+ anchor="w", padx=24, pady=(20, 4))
562
+ ttk.Label(pad, text="Toggle skills on or off. Changes save immediately.",
563
+ style="PanelDim.TLabel").pack(anchor="w", padx=24, pady=(0, 16))
564
+
565
+ list_frame = tk.Frame(pad, bg=BG_PANEL)
566
+ list_frame.pack(fill="both", expand=True, padx=24)
567
+ self.listbox = tk.Listbox(list_frame, bg=BG_CARD, fg=FG,
568
+ selectbackground=ACCENT, selectforeground="#ffffff",
569
+ relief="flat", font=("Consolas", 10),
570
+ activestyle="none", borderwidth=0,
571
+ highlightthickness=0, height=10)
572
+ self.listbox.pack(side="left", fill="both", expand=True)
573
+ vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.listbox.yview)
574
+ self.listbox.configure(yscrollcommand=vsb.set)
575
+ vsb.pack(side="right", fill="y")
576
+ self.listbox.bind("<<ListboxSelect>>", self._on_select)
577
+ self.listbox.bind("<Double-1>", lambda e: self._toggle_selected())
578
+
579
+ desc_frame = tk.Frame(pad, bg=BG_PANEL)
580
+ desc_frame.pack(fill="x", padx=24, pady=(12, 6))
581
+ ttk.Label(desc_frame, text="Description", style="PanelDim.TLabel").pack(anchor="w")
582
+ self.desc = tk.Text(desc_frame, height=4, bg=BG_CARD, fg=FG,
583
+ relief="flat", padx=8, pady=6,
584
+ font=("Segoe UI", 9), wrap="word")
585
+ self.desc.pack(fill="x")
586
+ self.desc.configure(state="disabled")
587
+
588
+ row = tk.Frame(pad, bg=BG_PANEL)
589
+ row.pack(fill="x", padx=24, pady=(12, 6))
590
+ ttk.Button(row, text="Toggle", style="Accent.TButton",
591
+ command=self._toggle_selected).pack(side="left")
592
+ ttk.Button(row, text="Enable all", command=self._enable_all).pack(side="left", padx=(6, 0))
593
+ ttk.Button(row, text="Disable all", command=self._disable_all).pack(side="left", padx=(6, 0))
594
+ ttk.Button(row, text="Close", command=self.destroy).pack(side="right")
595
+
596
+ self.status = ttk.Label(pad, text="", style="PanelDim.TLabel")
597
+ self.status.pack(anchor="w", padx=24, pady=(0, 20))
598
+
599
+ self.skills = []
600
+ self._refresh()
601
+
602
+ def _refresh(self):
603
+ self.listbox.delete(0, "end")
604
+ self.skills = loader_discover(SKILLS_DIR)
605
+ for s in self.skills:
606
+ name = s["name"]
607
+ enabled = name not in self.app.disabled_skills
608
+ mark = "[✓]" if enabled else "[ ]"
609
+ self.listbox.insert("end", f" {mark} {name:<24} v{s.get('version','?')}")
610
+ if not enabled:
611
+ self.listbox.itemconfig("end", foreground=FG_DIM)
612
+ self.status.configure(
613
+ text=f"{len(self.skills)} skill(s) found · {len(self.app.disabled_skills)} disabled")
614
+
615
+ def _selected_name(self):
616
+ sel = self.listbox.curselection()
617
+ if not sel:
618
+ return None
619
+ return self.skills[sel[0]]["name"]
620
+
621
+ def _on_select(self, event):
622
+ name = self._selected_name()
623
+ if not name:
624
+ return
625
+ meta = next((s for s in self.skills if s["name"] == name), None)
626
+ if not meta:
627
+ return
628
+ self.desc.configure(state="normal")
629
+ self.desc.delete("1.0", "end")
630
+ self.desc.insert("1.0", meta.get("description", "(no description)") +
631
+ f"\n\nauthor: {meta.get('author', '?')}")
632
+ self.desc.configure(state="disabled")
633
+
634
+ def _toggle_selected(self):
635
+ name = self._selected_name()
636
+ if not name:
637
+ return
638
+ if name in self.app.disabled_skills:
639
+ self.app.disabled_skills.remove(name)
640
+ else:
641
+ self.app.disabled_skills.append(name)
642
+ self.app._save_disabled_skills()
643
+ self.app._reload_tools()
644
+ self._refresh()
645
+
646
+ def _enable_all(self):
647
+ self.app.disabled_skills = []
648
+ self.app._save_disabled_skills()
649
+ self.app._reload_tools()
650
+ self._refresh()
651
+
652
+ def _disable_all(self):
653
+ self.app.disabled_skills = [s["name"] for s in self.skills]
654
+ self.app._save_disabled_skills()
655
+ self.app._reload_tools()
656
+ self._refresh()
657
+
658
+
659
+ # ===========================================================================
660
+ # Help dialog — two-pane with clickable skills
661
+ # ===========================================================================
662
+
663
+ class HelpDialog(tk.Toplevel):
664
+ """Two-pane help browser. Left: sections + skills. Right: content."""
665
+
666
+ def __init__(self, parent, app):
667
+ super().__init__(parent)
668
+ self.app = app
669
+ self.title("Help")
670
+ self.configure(bg=BG)
671
+ self.geometry("1020x720")
672
+ self.minsize(820, 540)
673
+ self.transient(parent)
674
+ apply_theme(self)
675
+
676
+ pad = ttk.Frame(self, style="Panel.TFrame")
677
+ pad.pack(fill="both", expand=True)
678
+
679
+ # header
680
+ header = tk.Frame(pad, bg=BG_PANEL)
681
+ header.pack(fill="x", padx=20, pady=(16, 10))
682
+ ttk.Label(header, text="HELP", style="Header.TLabel").pack(side="left")
683
+ ttk.Label(header, text="click a section or a skill",
684
+ style="PanelDim.TLabel").pack(side="left", padx=(12, 0))
685
+
686
+ # split body
687
+ body = tk.Frame(pad, bg=BG_PANEL)
688
+ body.pack(fill="both", expand=True, padx=20, pady=(0, 12))
689
+
690
+ # ---- LEFT: tree ----
691
+ left_wrap = tk.Frame(body, bg=BG_CARD, width=250,
692
+ highlightbackground=BORDER, highlightthickness=1)
693
+ left_wrap.pack(side="left", fill="y")
694
+ left_wrap.pack_propagate(False)
695
+
696
+ style = ttk.Style(self)
697
+ style.configure("Help.Treeview",
698
+ background=BG_CARD, fieldbackground=BG_CARD,
699
+ foreground=FG, bordercolor=BORDER,
700
+ rowheight=26, font=("Segoe UI", 10))
701
+ style.map("Help.Treeview",
702
+ background=[("selected", ACCENT)],
703
+ foreground=[("selected", "#ffffff")])
704
+
705
+ self.tree = ttk.Treeview(left_wrap, show="tree",
706
+ selectmode="browse", style="Help.Treeview")
707
+ self.tree.column("#0", width=230, stretch=True)
708
+ self.tree.tag_configure("group",
709
+ foreground=ACCENT,
710
+ font=("Segoe UI", 9, "bold"))
711
+ self.tree.tag_configure("item",
712
+ foreground=FG,
713
+ font=("Segoe UI", 10))
714
+
715
+ vsb = ttk.Scrollbar(left_wrap, orient="vertical", command=self.tree.yview)
716
+ self.tree.configure(yscrollcommand=vsb.set)
717
+ self.tree.pack(side="left", fill="both", expand=True)
718
+ vsb.pack(side="right", fill="y")
719
+
720
+ # ---- RIGHT: content ----
721
+ right_wrap = tk.Frame(body, bg=BG_CARD,
722
+ highlightbackground=BORDER, highlightthickness=1)
723
+ right_wrap.pack(side="left", fill="both", expand=True, padx=(10, 0))
724
+
725
+ self.content = tk.Text(right_wrap, wrap="word", bg=BG_CARD, fg=FG,
726
+ relief="flat", padx=18, pady=14,
727
+ font=("Consolas", 10), borderwidth=0,
728
+ spacing1=2, spacing3=5)
729
+ self.content.pack(side="left", fill="both", expand=True)
730
+ csb = ttk.Scrollbar(right_wrap, orient="vertical",
731
+ command=self.content.yview)
732
+ self.content.configure(yscrollcommand=csb.set, state="disabled")
733
+ csb.pack(side="right", fill="y")
734
+
735
+ # ---- footer ----
736
+ footer = tk.Frame(pad, bg=BG_PANEL)
737
+ footer.pack(fill="x", padx=20, pady=(0, 16))
738
+ ttk.Button(footer, text="Close", style="Accent.TButton",
739
+ command=self.destroy).pack(side="right")
740
+
741
+ # build content
742
+ self._sections = self._build_sections()
743
+ self._populate_tree()
744
+
745
+ self.tree.bind("<<TreeviewSelect>>", self._on_select)
746
+ self.tree.selection_set("sec:quick_start")
747
+ self._show_section("quick_start")
748
+
749
+ # ---- content builders ----
750
+
751
+ def _build_sections(self) -> dict:
752
+ import getpass
753
+ import platform
754
+
755
+ try:
756
+ username = getpass.getuser()
757
+ except Exception:
758
+ username = (os.environ.get("USERNAME")
759
+ or os.environ.get("USER")
760
+ or "user")
761
+
762
+ platform_str = f"{platform.system()} {platform.release()}"
763
+ py_ver = (f"{sys.version_info.major}.{sys.version_info.minor}"
764
+ f".{sys.version_info.micro}")
765
+
766
+ workspace_p = str(WORKSPACE)
767
+ chat_p = str(CHAT_FILE)
768
+ config_p = str(CONFIG_FILE)
769
+ skills_p = str(SKILLS_DIR)
770
+ root_p = str(ROOT)
771
+
772
+ cmd_lines = []
773
+ for cname, cdef in sorted(self.app.skill_commands.items()):
774
+ cmd_lines.append(f" /{cname:<14} {cdef.get('description', '')}")
775
+ skill_cmds = "\n".join(cmd_lines) if cmd_lines else " (none)"
776
+
777
+ skill_lines = []
778
+ for s in self.app.skill_meta:
779
+ n = s.get("name", "?")
780
+ d = (s.get("description", "") or "")[:70]
781
+ skill_lines.append(f" {n:<18} {d}")
782
+ skills_list = "\n".join(skill_lines) if skill_lines else " (none loaded)"
783
+
784
+ servers_str = "\n".join(f" {s}" for s in self.app.cloud_servers)
785
+
786
+ sections = {}
787
+
788
+ sections["quick_start"] = ("Quick Start", f"""Type a task in the input box and press Enter.
789
+ The agent thinks, calls tools, and returns an answer.
790
+
791
+ Buttons
792
+ Send start a new task
793
+ Stop halt the running agent
794
+ Continue resume from the last tool result
795
+ Save save chat locally + cloud (if enabled)
796
+ Settings edit API key, base URL, model, cloud sync
797
+ Skills toggle installed skills on/off
798
+ Help this window
799
+
800
+ First run
801
+ The agent does NOT auto-install skills. Use skill-manager.py
802
+ to install what you want, then restart the agent.
803
+ """)
804
+
805
+ sections["commands"] = ("Commands", f"""Built-in (always available):
806
+
807
+ /reset clear conversation memory
808
+ /history print recent messages
809
+ /skills list loaded + disabled skills + commands
810
+ /save force save chat (local + cloud)
811
+ /open <file> open a file from the workspace
812
+ e.g. /open gta.html
813
+
814
+ Skill commands (loaded from skills):
815
+
816
+ {skill_cmds}
817
+
818
+ Type /skills in the chat to see this list live.
819
+ """)
820
+
821
+ sections["skills"] = ("Skills", f"""Skills are folders under:
822
+
823
+ {skills_p}
824
+
825
+ Each skill is a folder containing skill.py (code) and skill.md (docs).
826
+ They add tools the LLM can call and /commands you can type.
827
+
828
+ Loaded in this session:
829
+
830
+ {skills_list}
831
+
832
+ Install new skills:
833
+ 1. python skill-manager.py
834
+ 2. Sign in, browse, click Install
835
+ 3. Restart this agent
836
+
837
+ Toggle skills on/off with the Skills button. Changes save
838
+ immediately to agent_config.json.
839
+
840
+ To write your own skill, open docs/skills.html in a browser.
841
+ Click a skill in the left panel to see its own help page.
842
+ """)
843
+
844
+ sections["workspace"] = ("Workspace & Sandbox", f"""Everything the agent creates goes here:
845
+
846
+ {workspace_p}
847
+
848
+ Shell rules
849
+ • Commands must be in the allowlist (ls, cat, git, python, curl, …)
850
+ • CWD is forced to the workspace
851
+ • Absolute paths outside it are rejected
852
+ • No pipes, redirection, ;, &, &&
853
+
854
+ If the agent tries something blocked, you'll see:
855
+
856
+ BLOCKED: <reason>
857
+
858
+ To let it use a new command, add it to ALLOWED_CMDS in agent.py.
859
+ """)
860
+
861
+ sections["cloud"] = ("Chat Save & Cloud", f"""Local save
862
+ {chat_p}
863
+
864
+ Auto-saved after every turn and on window close.
865
+ Loaded on startup if it exists.
866
+
867
+ Cloud sync (optional)
868
+ Servers tried in order:
869
+ {servers_str}
870
+
871
+ account: {self.app.cloud_user or "(not signed in)"}
872
+
873
+ Enable in Settings → CLOUD SYNC. Uses the same account as
874
+ skill-manager.py. Local file wins if both exist — cloud is
875
+ loaded on startup only if the local file is missing.
876
+ """)
877
+
878
+ sections["troubleshooting"] = ("Troubleshooting", """browser: FAILED
879
+ Install WebView2 (Windows) or verify Chromium is on PATH.
880
+
881
+ LLM ERROR: 401
882
+ Wrong API key, or stray quotes around it in agent_config.json.
883
+
884
+ LLM ERROR: 404
885
+ Model name doesn't match the provider. Check the provider docs.
886
+
887
+ BLOCKED: <cmd> not allowed
888
+ Add it to ALLOWED_CMDS in agent.py, or use a different approach.
889
+
890
+ NOT_FOUND: <selector>
891
+ CSS selector didn't match. Call browser_snapshot first.
892
+
893
+ email: not configured
894
+ Run /email to see setup. Resend / Brevo / Mailjet, all free.
895
+
896
+ cloud: not synced
897
+ Settings → CLOUD SYNC on. Then click Save.
898
+ """)
899
+
900
+ sections["about"] = ("System Info", f"""OS {platform_str}
901
+ Python {py_ver}
902
+ User {username}
903
+
904
+ Paths
905
+ root {root_p}
906
+ config {config_p}
907
+ chat {chat_p}
908
+ workspace {workspace_p}
909
+ skills {skills_p}
910
+
911
+ LLM
912
+ model {self.app.model}
913
+ base {self.app.base_url}
914
+ keys {len(self.app.api_keys)}
915
+
916
+ Skills {len(self.app.skill_names)} loaded, {len(self.app.disabled_skills)} disabled
917
+ Cloud sync {"on" if self.app.cloud_sync else "off"}
918
+ Cloud acct {self.app.cloud_user or "(not signed in)"}
919
+ Cloud srv {self.app.cloud_servers[0]}
920
+ """)
921
+
922
+ return sections
923
+
924
+ # ---- tree ----
925
+
926
+ def _populate_tree(self):
927
+ self.tree.insert("", "end", iid="grp:general",
928
+ text=" GENERAL", open=True, tags=("group",))
929
+ for key, (title, _) in self._sections.items():
930
+ self.tree.insert("grp:general", "end", iid=f"sec:{key}",
931
+ text=f" {title}", tags=("item",))
932
+
933
+ self.tree.insert("", "end", iid="grp:skills",
934
+ text=" SKILLS", open=True, tags=("group",))
935
+ if self.app.skill_meta:
936
+ for s in self.app.skill_meta:
937
+ name = s.get("name", "?")
938
+ self.tree.insert("grp:skills", "end",
939
+ iid=f"skill:{name}",
940
+ text=f" {name}", tags=("item",))
941
+ else:
942
+ self.tree.insert("grp:skills", "end", iid="skill_none",
943
+ text=" (none loaded)", tags=("item",))
944
+
945
+ def _on_select(self, event):
946
+ sel = self.tree.selection()
947
+ if not sel:
948
+ return
949
+ iid = sel[0]
950
+ if iid.startswith("sec:"):
951
+ self._show_section(iid[4:])
952
+ elif iid.startswith("skill:"):
953
+ self._show_skill(iid[6:])
954
+
955
+ # ---- content renderer ----
956
+
957
+ def _show_section(self, key: str):
958
+ title, body = self._sections.get(key, ("?", "(not found)"))
959
+ header = f"{title}\n{'─' * len(title)}\n\n"
960
+ self._set_content(header + body)
961
+
962
+ def _show_skill(self, name: str):
963
+ meta = next((s for s in self.app.skill_meta
964
+ if s.get("name") == name), {}) or {}
965
+ body = loader_get_help(SKILLS_DIR, name)
966
+ header = (
967
+ f"{name}\n"
968
+ f"{'─' * len(name)}\n"
969
+ f"version: {meta.get('version', '?')} "
970
+ f"author: {meta.get('author', '?')}\n\n"
971
+ )
972
+ self._set_content(header + body)
973
+
974
+ def _set_content(self, text: str):
975
+ self.content.configure(state="normal")
976
+ self.content.delete("1.0", "end")
977
+ self.content.insert("1.0", text)
978
+ self.content.yview_moveto(0)
979
+ self.content.configure(state="disabled")
980
+
981
+
982
+ # ===========================================================================
983
+ # App
984
+ # ===========================================================================
985
+
986
+ class App:
987
+ def __init__(self, root, browser):
988
+ self.root = root
989
+ self.browser = browser
990
+ self._log_q = queue.Queue()
991
+ self._log_history = []
992
+ self._log_lock = threading.Lock()
993
+
994
+ self._stop_event = threading.Event()
995
+ self._worker = None
996
+ self._stopped_last = False
997
+
998
+ cfg = load_agent_config()
999
+ self.api_keys = normalize_keys(cfg.get("openai_api_keys"))
1000
+ self.base_url = (cfg.get("openai_base_url")
1001
+ or os.getenv("OPENAI_BASE_URL")
1002
+ or "https://api.openai.com/v1")
1003
+ self.model = (cfg.get("openai_model")
1004
+ or os.getenv("OPENAI_MODEL")
1005
+ or "gpt-4o")
1006
+ self.disabled_skills = list(cfg.get("disabled_skills") or [])
1007
+ self.cloud_sync = bool(cfg.get("cloud_sync", False))
1008
+
1009
+ if not self.api_keys:
1010
+ self.api_keys = normalize_keys(
1011
+ [k.strip() for k in os.getenv("OPENAI_API_KEYS", "").split(",")])
1012
+
1013
+ self.client = make_client(self.api_keys, self.base_url)
1014
+
1015
+ sm = _sm_cfg()
1016
+ self.cloud_servers = _sm_server_list()
1017
+ self.cloud_server = self.cloud_servers[0]
1018
+ self.cloud_token = sm.get("token")
1019
+ self.cloud_user = sm.get("nickname")
1020
+
1021
+ print(f"[config] model={self.model} base={self.base_url} keys={len(self.api_keys)}")
1022
+ print(f"[config] cloud={self.cloud_sync} servers={self.cloud_servers} user={self.cloud_user}")
1023
+
1024
+ apply_theme(root)
1025
+ self._build_toolset()
1026
+ self.messages = [{"role": "system", "content": self.system_prompt}]
1027
+
1028
+ root.title("AI Agent")
1029
+ root.geometry("1020x760")
1030
+ root.minsize(780, 520)
1031
+
1032
+ header = ttk.Frame(root, style="Panel.TFrame")
1033
+ header.pack(fill="x", side="top")
1034
+ ttk.Label(header, text="AI AGENT", style="Header.TLabel").pack(
1035
+ side="left", padx=16, pady=10)
1036
+ ttk.Button(header, text="Help", command=self._open_help).pack(
1037
+ side="right", padx=(6, 16), pady=10)
1038
+ ttk.Button(header, text="Skills", command=self._open_skills).pack(
1039
+ side="right", padx=(6, 0), pady=10)
1040
+ ttk.Button(header, text="Settings", command=self._open_settings).pack(
1041
+ side="right", padx=(6, 0), pady=10)
1042
+ ttk.Button(header, text="Save", command=self._save_chat_manual).pack(
1043
+ side="right", padx=(6, 0), pady=10)
1044
+ self.browser_lbl = ttk.Label(header, text="browser: starting…",
1045
+ style="Status.TLabel")
1046
+ self.browser_lbl.pack(side="right", padx=16)
1047
+ self.status_lbl = ttk.Label(header, text="", style="Status.TLabel")
1048
+ self.status_lbl.pack(side="right", padx=16)
1049
+ self._update_header()
1050
+
1051
+ log_wrap = tk.Frame(root, bg=BG)
1052
+ log_wrap.pack(fill="both", expand=True, padx=12, pady=(10, 6))
1053
+ ff = "Cascadia Mono" if self._has_font("Cascadia Mono") else "Consolas"
1054
+ self.log = tk.Text(log_wrap, wrap="word", bg=BG_PANEL, fg=FG,
1055
+ insertbackground=FG, selectbackground="#264f78",
1056
+ relief="flat", borderwidth=0, padx=14, pady=12,
1057
+ font=(ff, 10), spacing1=2, spacing3=4)
1058
+ self.log.pack(side="left", fill="both", expand=True)
1059
+ sb = ttk.Scrollbar(log_wrap, orient="vertical", command=self.log.yview)
1060
+ sb.pack(side="right", fill="y")
1061
+ self.log.configure(yscrollcommand=sb.set, state="disabled")
1062
+
1063
+ self.log.tag_configure("user", foreground=YELLOW, font=(ff, 10, "bold"))
1064
+ self.log.tag_configure("agent", foreground=GREEN, font=(ff, 10, "bold"))
1065
+ self.log.tag_configure("tool", foreground=ACCENT)
1066
+ self.log.tag_configure("result", foreground=PURPLE)
1067
+ self.log.tag_configure("error", foreground=RED)
1068
+ self.log.tag_configure("dim", foreground=FG_DIM)
1069
+ self.log.tag_configure("turn", foreground=FG_DIM, font=(ff, 9, "italic"))
1070
+
1071
+ bottom = ttk.Frame(root, style="Panel.TFrame")
1072
+ bottom.pack(fill="x", side="bottom")
1073
+ inner = tk.Frame(bottom, bg=BG_PANEL)
1074
+ inner.pack(fill="x", padx=12, pady=10)
1075
+ self.stop_btn = ttk.Button(inner, text="Stop", style="Stop.TButton",
1076
+ command=self._on_stop, state="disabled")
1077
+ self.stop_btn.pack(side="left", padx=(0, 6))
1078
+ self.continue_btn = ttk.Button(inner, text="Continue",
1079
+ command=self._on_continue, state="disabled")
1080
+ self.continue_btn.pack(side="left", padx=(0, 10))
1081
+ self.entry = ttk.Entry(inner, font=("Segoe UI", 11))
1082
+ self.entry.pack(side="left", fill="x", expand=True, ipady=2)
1083
+ self.entry.bind("<Return>", lambda e: self.on_send())
1084
+ self.send_btn = ttk.Button(inner, text="Send", style="Send.TButton",
1085
+ command=self.on_send)
1086
+ self.send_btn.pack(side="right", padx=(10, 0))
1087
+
1088
+ self._load_local_chat()
1089
+ self._try_load_cloud_chat()
1090
+
1091
+ self.log_line(f"Model : {self.model}", "dim")
1092
+ self.log_line(f"Base : {self.base_url}", "dim")
1093
+ self.log_line(f"Keys : {len(self.api_keys)}", "dim")
1094
+ self.log_line(f"Workspace: {WORKSPACE}", "dim")
1095
+ self.log_line(f"Chat file: {CHAT_FILE}", "dim")
1096
+ cloud_status = "on" if (self.cloud_sync and self.cloud_token) else "off"
1097
+ self.log_line(f"Cloud : {cloud_status} ({' → '.join(self.cloud_servers)})", "dim")
1098
+ self.log_line(f"Skills : {', '.join(self.skill_names) or '(none)'}", "dim")
1099
+ if self.disabled_skills:
1100
+ self.log_line(f"Disabled : {', '.join(self.disabled_skills)}", "dim")
1101
+ if self.skill_commands:
1102
+ self.log_line("Commands : " + ", ".join(f"/{c}" for c in sorted(self.skill_commands)),
1103
+ "dim")
1104
+ self.log_line("Buttons : Stop · Continue · Send · Save · Settings · Skills · Help", "dim")
1105
+ self.log_line("─" * 70, "dim")
1106
+
1107
+ threading.Thread(target=self._browser_selftest, daemon=True).start()
1108
+ self._poll_log()
1109
+ self.entry.focus_set()
1110
+ self.root.protocol("WM_DELETE_WINDOW", self._on_close)
1111
+
1112
+ # ---- header ----
1113
+
1114
+ def _update_header(self):
1115
+ enabled = len([s for s in loader_discover(SKILLS_DIR)
1116
+ if s["name"] not in self.disabled_skills])
1117
+ cloud = "☁" if (self.cloud_sync and self.cloud_token) else ""
1118
+ self.status_lbl.configure(
1119
+ text=f"{self.model} · {len(self.api_keys)} key · {enabled} skill {cloud}")
1120
+
1121
+ # ---- skills ----
1122
+
1123
+ def _build_toolset(self):
1124
+ self.all_schemas = list(CORE_SCHEMAS)
1125
+ self.all_callables = {
1126
+ "browser_navigate": self.browser.navigate,
1127
+ "browser_snapshot": self.browser.snapshot,
1128
+ "browser_links": self.browser.links,
1129
+ "browser_click": self.browser.click,
1130
+ "browser_type": self.browser.type_text,
1131
+ "browser_scroll": self.browser.scroll,
1132
+ "shell_run": shell_run,
1133
+ }
1134
+ skill_schemas, skill_callables, skill_meta, skill_commands = \
1135
+ loader_load(SKILLS_DIR, self.disabled_skills)
1136
+
1137
+ self.all_schemas.extend(skill_schemas)
1138
+ self.all_callables.update(skill_callables)
1139
+ self.skill_names = [m.get("name", "?") for m in skill_meta]
1140
+ self.skill_meta = skill_meta
1141
+ self.skill_commands = skill_commands
1142
+
1143
+ if skill_meta:
1144
+ lines = "\n".join(f"- {m['name']}: {m.get('description', '')}"
1145
+ for m in skill_meta)
1146
+ self.system_prompt = CORE_PROMPT + "\nSKILLS (loaded):\n" + lines + "\n"
1147
+ else:
1148
+ self.system_prompt = CORE_PROMPT
1149
+
1150
+ def _reload_tools(self):
1151
+ self._build_toolset()
1152
+ if hasattr(self, "messages") and self.messages:
1153
+ self.messages[0] = {"role": "system", "content": self.system_prompt}
1154
+ if hasattr(self, "status_lbl"):
1155
+ self._update_header()
1156
+ self.log_line(f"[skills] reloaded — active: {', '.join(self.skill_names) or '(none)'}", "dim")
1157
+
1158
+ def _save_disabled_skills(self):
1159
+ cfg = load_agent_config()
1160
+ cfg["disabled_skills"] = list(self.disabled_skills)
1161
+ cfg.setdefault("openai_api_keys", self.api_keys)
1162
+ cfg.setdefault("openai_base_url", self.base_url)
1163
+ cfg.setdefault("openai_model", self.model)
1164
+ cfg.setdefault("headless", True)
1165
+ cfg.setdefault("cloud_sync", self.cloud_sync)
1166
+ try:
1167
+ save_agent_config(cfg)
1168
+ except Exception as e:
1169
+ print(f"[config] save failed: {e}")
1170
+
1171
+ # ---- dialogs ----
1172
+
1173
+ def _open_settings(self):
1174
+ SettingsDialog(self.root, self)
1175
+
1176
+ def _open_skills(self):
1177
+ SkillsDialog(self.root, self)
1178
+
1179
+ def _open_help(self):
1180
+ HelpDialog(self.root, self)
1181
+
1182
+ # ---- logging ----
1183
+
1184
+ def _has_font(self, name):
1185
+ try:
1186
+ return name in tkfont.families()
1187
+ except Exception:
1188
+ return False
1189
+
1190
+ def _browser_selftest(self):
1191
+ result = self.browser.self_test()
1192
+ ok = result.startswith("OK")
1193
+ self.root.after(0, lambda: self.browser_lbl.configure(
1194
+ text="browser: ready" if ok else "browser: FAILED",
1195
+ foreground=GREEN if ok else RED))
1196
+ self.log_line(f"browser self-test: {result}", "dim" if ok else "error")
1197
+
1198
+ def log_line(self, text, tag=None):
1199
+ with self._log_lock:
1200
+ self._log_history.append((text, tag))
1201
+ self._log_q.put((text, tag))
1202
+
1203
+ def _poll_log(self):
1204
+ try:
1205
+ while True:
1206
+ text, tag = self._log_q.get_nowait()
1207
+ self.log.configure(state="normal")
1208
+ if tag:
1209
+ self.log.insert("end", text + "\n", tag)
1210
+ else:
1211
+ self.log.insert("end", text + "\n")
1212
+ self.log.see("end")
1213
+ self.log.configure(state="disabled")
1214
+ except queue.Empty:
1215
+ pass
1216
+ self.root.after(60, self._poll_log)
1217
+
1218
+ # ---- local chat ----
1219
+
1220
+ def _save_local_chat(self):
1221
+ try:
1222
+ data = {
1223
+ "messages": self.messages,
1224
+ "log": self._log_history[-500:],
1225
+ "model": self.model,
1226
+ "saved_at": time.time(),
1227
+ }
1228
+ tmp = CHAT_FILE.with_suffix(".json.tmp")
1229
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
1230
+ tmp.replace(CHAT_FILE)
1231
+ except Exception as e:
1232
+ print(f"[chat] save failed: {e}")
1233
+
1234
+ def _load_local_chat(self):
1235
+ if not CHAT_FILE.exists():
1236
+ return
1237
+ try:
1238
+ data = json.loads(CHAT_FILE.read_text(encoding="utf-8"))
1239
+ msgs = data.get("messages") or []
1240
+ if msgs and msgs[0].get("role") == "system":
1241
+ msgs[0] = {"role": "system", "content": self.system_prompt}
1242
+ if msgs:
1243
+ self.messages = msgs
1244
+ for text, tag in (data.get("log") or [])[-200:]:
1245
+ with self._log_lock:
1246
+ self._log_history.append((text, tag))
1247
+ self._log_q.put((text, tag))
1248
+ print(f"[chat] loaded {len(msgs)} messages from {CHAT_FILE}")
1249
+ except Exception as e:
1250
+ print(f"[chat] load failed: {e}")
1251
+
1252
+ # ---- cloud ----
1253
+
1254
+ def _cloud_headers(self):
1255
+ return {
1256
+ "Accept": "application/json",
1257
+ "User-Agent": SKILL_UA,
1258
+ "Content-Type": "application/json",
1259
+ "Authorization": f"Bearer {self.cloud_token}",
1260
+ }
1261
+
1262
+ def _save_cloud_chat(self):
1263
+ if not (self.cloud_sync and self.cloud_token):
1264
+ return
1265
+
1266
+ payload = json.dumps({
1267
+ "messages": self.messages,
1268
+ "log": self._log_history[-500:],
1269
+ "model": self.model,
1270
+ }, ensure_ascii=False)
1271
+ body = json.dumps({"state": payload}).encode("utf-8")
1272
+
1273
+ for server in self.cloud_servers:
1274
+ try:
1275
+ req = urllib.request.Request(
1276
+ server.rstrip("/") + "/state",
1277
+ data=body, method="POST", headers=self._cloud_headers())
1278
+ with urllib.request.urlopen(req, timeout=15) as r:
1279
+ r.read()
1280
+ if server != self.cloud_servers[0]:
1281
+ print(f"[cloud] saved via fallback: {server}")
1282
+ return
1283
+ except Exception as e:
1284
+ print(f"[cloud] {server} save failed: {e}")
1285
+ print("[cloud] all servers failed on save")
1286
+
1287
+ def _try_load_cloud_chat(self):
1288
+ if not (self.cloud_sync and self.cloud_token):
1289
+ return
1290
+ if CHAT_FILE.exists():
1291
+ return
1292
+
1293
+ for server in self.cloud_servers:
1294
+ try:
1295
+ req = urllib.request.Request(
1296
+ server.rstrip("/") + "/state",
1297
+ method="GET", headers=self._cloud_headers())
1298
+ with urllib.request.urlopen(req, timeout=10) as r:
1299
+ data = json.loads(r.read().decode("utf-8"))
1300
+ except Exception as e:
1301
+ print(f"[cloud] {server} load failed: {e}")
1302
+ continue
1303
+
1304
+ blob = data.get("state")
1305
+ if not blob:
1306
+ return
1307
+ try:
1308
+ state = json.loads(blob)
1309
+ except Exception as e:
1310
+ print(f"[cloud] bad state blob from {server}: {e}")
1311
+ return
1312
+
1313
+ msgs = state.get("messages") or []
1314
+ if msgs and msgs[0].get("role") == "system":
1315
+ msgs[0] = {"role": "system", "content": self.system_prompt}
1316
+ if msgs:
1317
+ self.messages = msgs
1318
+ for text, tag in (state.get("log") or [])[-200:]:
1319
+ with self._log_lock:
1320
+ self._log_history.append((text, tag))
1321
+ self._log_q.put((text, tag))
1322
+ print(f"[cloud] loaded {len(msgs)} messages from {server}")
1323
+ return
1324
+
1325
+ def _save_chat_manual(self):
1326
+ self._save_local_chat()
1327
+ self._save_cloud_chat()
1328
+ cloud = " + cloud" if (self.cloud_sync and self.cloud_token) else ""
1329
+ self.log_line(f"[save] chat saved{cloud} → {CHAT_FILE}", "dim")
1330
+
1331
+ # ---- run control ----
1332
+
1333
+ def _set_running(self, running):
1334
+ if running:
1335
+ self.send_btn.configure(state="disabled")
1336
+ self.stop_btn.configure(state="normal")
1337
+ self.continue_btn.configure(state="disabled")
1338
+ self.status_lbl.configure(text="running…")
1339
+ else:
1340
+ self.send_btn.configure(state="normal")
1341
+ self.stop_btn.configure(state="disabled")
1342
+ self.continue_btn.configure(
1343
+ state="normal" if self._stopped_last else "disabled")
1344
+ self._update_header()
1345
+
1346
+ def _on_stop(self):
1347
+ self._stop_event.set()
1348
+ self.log_line("[stop] requested", "error")
1349
+ self.stop_btn.configure(state="disabled")
1350
+
1351
+ def _on_continue(self):
1352
+ if self._worker and self._worker.is_alive():
1353
+ return
1354
+ self.log_line("\n▸ continue", "user")
1355
+ self._stopped_last = False
1356
+ self._stop_event.clear()
1357
+ self._set_running(True)
1358
+ self._worker = threading.Thread(target=self._run_agent, args=(None,), daemon=True)
1359
+ self._worker.start()
1360
+
1361
+ # ---- input ----
1362
+
1363
+ def on_send(self):
1364
+ task = self.entry.get().strip()
1365
+ if not task:
1366
+ return
1367
+ self.entry.delete(0, "end")
1368
+ low = task.lower()
1369
+
1370
+ # ---- built-in commands ----
1371
+ if low in ("/reset", "/clear"):
1372
+ self.messages = [{"role": "system", "content": self.system_prompt}]
1373
+ self._log_history = []
1374
+ self.log.configure(state="normal")
1375
+ self.log.delete("1.0", "end")
1376
+ self.log.configure(state="disabled")
1377
+ self.log_line("── conversation cleared ──", "dim")
1378
+ self._save_local_chat()
1379
+ return
1380
+ if low == "/history":
1381
+ for m in self.messages[1:]:
1382
+ role = m.get("role", "?")
1383
+ content = (m.get("content") or "")[:140].replace("\n", " ")
1384
+ self.log_line(f" [{role}] {content}", "dim")
1385
+ return
1386
+ if low == "/skills":
1387
+ self.log_line(f"active: {', '.join(self.skill_names) or '(none)'}", "dim")
1388
+ for m in self.skill_meta:
1389
+ self.log_line(f" - {m['name']}: {m.get('description', '')[:100]}", "dim")
1390
+ if self.disabled_skills:
1391
+ self.log_line(f"disabled: {', '.join(self.disabled_skills)}", "dim")
1392
+ if self.skill_commands:
1393
+ self.log_line("commands:", "dim")
1394
+ for cname, cdef in sorted(self.skill_commands.items()):
1395
+ self.log_line(f" /{cname:<14} {cdef.get('description','')}", "dim")
1396
+ return
1397
+ if low == "/save":
1398
+ self._save_chat_manual()
1399
+ return
1400
+ if low.startswith("/open "):
1401
+ rel = task[6:].strip()
1402
+ p = (WORKSPACE / rel).resolve()
1403
+ if not str(p).startswith(str(WORKSPACE)) or not p.exists():
1404
+ self.log_line(f"ERROR: not in workspace: {rel}", "error")
1405
+ return
1406
+ try:
1407
+ os.startfile(str(p))
1408
+ self.log_line(f"opened {p}", "dim")
1409
+ except Exception as e:
1410
+ self.log_line(f"ERROR: {e}", "error")
1411
+ return
1412
+
1413
+ # ---- dispatch to skill commands ----
1414
+ if task.startswith("/"):
1415
+ parts = task[1:].split()
1416
+ cmd_name = parts[0].lower() if parts else ""
1417
+ args = parts[1:]
1418
+ if cmd_name in self.skill_commands:
1419
+ handler = self.skill_commands[cmd_name]["handler"]
1420
+ try:
1421
+ handler(args, self.log_line)
1422
+ except Exception as e:
1423
+ self.log_line(f"[{cmd_name}] error: {e}", "error")
1424
+ return
1425
+ self.log_line(f"unknown command: /{cmd_name}", "error")
1426
+ return
1427
+
1428
+ # ---- normal task ----
1429
+ self.log_line(f"\n▸ {task}", "user")
1430
+ self._stopped_last = False
1431
+ self._stop_event.clear()
1432
+ self._set_running(True)
1433
+ self._worker = threading.Thread(target=self._run_agent, args=(task,), daemon=True)
1434
+ self._worker.start()
1435
+
1436
+ def _run_agent(self, task):
1437
+ self._stopped_last = False
1438
+ try:
1439
+ self._agent_loop(task)
1440
+ finally:
1441
+ self._save_local_chat()
1442
+ self._save_cloud_chat()
1443
+ self.root.after(0, self._set_running, False)
1444
+
1445
+ def _agent_loop(self, task):
1446
+ if task is not None:
1447
+ self.messages.append({"role": "user", "content": task})
1448
+ if len(self.messages) > 42:
1449
+ self.messages = [self.messages[0]] + self.messages[-40:]
1450
+
1451
+ for turn in range(1, MAX_TURNS + 1):
1452
+ if self._stop_event.is_set():
1453
+ self.log_line("[stopped]", "error")
1454
+ self._stopped_last = True
1455
+ return
1456
+
1457
+ self.log_line(f"[turn {turn}] thinking…", "turn")
1458
+ try:
1459
+ resp = self.client.chat.completions.create(
1460
+ model=self.model, messages=self.messages,
1461
+ tools=self.all_schemas, tool_choice="auto",
1462
+ temperature=0.2)
1463
+ except Exception as e:
1464
+ self.log_line(f"LLM ERROR: {e}", "error")
1465
+ return
1466
+
1467
+ msg = resp.choices[0].message
1468
+ assistant = {"role": "assistant", "content": msg.content or ""}
1469
+ if msg.tool_calls:
1470
+ assistant["tool_calls"] = [
1471
+ {"id": tc.id, "type": "function",
1472
+ "function": {"name": tc.function.name,
1473
+ "arguments": tc.function.arguments}}
1474
+ for tc in msg.tool_calls
1475
+ ]
1476
+ self.messages.append(assistant)
1477
+
1478
+ if not msg.tool_calls:
1479
+ self.log_line("AGENT ▸ " + (msg.content or "(done)"), "agent")
1480
+ return
1481
+
1482
+ for tc in msg.tool_calls:
1483
+ if self._stop_event.is_set():
1484
+ self.log_line("[stopped before tool call]", "error")
1485
+ self._stopped_last = True
1486
+ return
1487
+
1488
+ name = tc.function.name
1489
+ try:
1490
+ args = json.loads(tc.function.arguments or "{}")
1491
+ except json.JSONDecodeError:
1492
+ args = {}
1493
+ preview = ", ".join(f"{k}={v!r}" for k, v in args.items())
1494
+ self.log_line(f"→ {name}({preview[:200]})", "tool")
1495
+
1496
+ fn = self.all_callables.get(name)
1497
+ try:
1498
+ result = fn(**args) if fn else f"unknown tool: {name}"
1499
+ except Exception as e:
1500
+ result = f"ERROR: {e}"
1501
+
1502
+ tag = "error" if str(result).startswith(
1503
+ ("ERROR", "BLOCKED", "NOT_FOUND")) else "result"
1504
+ self.log_line(f"← {str(result)[:400].replace(chr(10), ' ⏎ ')}", tag)
1505
+
1506
+ self.messages.append({
1507
+ "role": "tool",
1508
+ "tool_call_id": tc.id,
1509
+ "content": str(result)[:4000],
1510
+ })
1511
+ self._save_local_chat()
1512
+
1513
+ self.log_line("stopped: max turns reached", "error")
1514
+
1515
+ def _on_close(self):
1516
+ try:
1517
+ self._stop_event.set()
1518
+ self._save_local_chat()
1519
+ self._save_cloud_chat()
1520
+ finally:
1521
+ try:
1522
+ self.browser.close()
1523
+ finally:
1524
+ self.root.destroy()
1525
+
1526
+
1527
+ # ===========================================================================
1528
+ # Entry
1529
+ # ===========================================================================
1530
+
1531
+ def main():
1532
+ if not load_agent_config().get("openai_api_keys"):
1533
+ print("[config] no API keys — open Settings in the GUI to add one")
1534
+
1535
+ browser = Browser(headless=True)
1536
+ root = tk.Tk()
1537
+ app = App(root, browser)
1538
+ root.mainloop()
1539
+
1540
+
1541
+ if __name__ == "__main__":
1542
+ main()