dulus 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. webchat_server.py +1761 -0
clipboard_utils.py ADDED
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import io
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ import pyperclip
15
+ from PIL import Image, ImageGrab
16
+
17
+ # Video file extensions recognized for clipboard paste.
18
+ _VIDEO_SUFFIXES: frozenset[str] = frozenset(
19
+ {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".flv", ".3gp", ".3g2"}
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ClipboardResult:
25
+ """Result of reading media from the clipboard.
26
+
27
+ Both fields may be non-empty when the clipboard contains a mix of
28
+ image files and non-image files (videos, PDFs, etc.).
29
+ """
30
+
31
+ images: tuple[Image.Image, ...]
32
+ file_paths: tuple[Path, ...]
33
+
34
+
35
+ def is_clipboard_available() -> bool:
36
+ """Check if the Pyperclip text clipboard is available."""
37
+ try:
38
+ pyperclip.paste()
39
+ return True
40
+ except Exception:
41
+ return False
42
+
43
+
44
+ def is_media_clipboard_available() -> bool:
45
+ """Check if the media clipboard (xclip/wl-paste) is available.
46
+
47
+ On headless Linux (e.g. SSH remote), pyperclip may fail because
48
+ DISPLAY is not set, but images can still be read through xclip or
49
+ wl-paste (e.g. via clipboard bridging tools like cc-clip that shim
50
+ xclip over an SSH tunnel).
51
+ """
52
+ if sys.platform == "linux":
53
+ return shutil.which("xclip") is not None or shutil.which("wl-paste") is not None
54
+ # macOS and Windows use native APIs that do not require external tools.
55
+ return True
56
+
57
+
58
+ def grab_media_from_clipboard() -> ClipboardResult | None:
59
+ """Read media from the clipboard.
60
+
61
+ Inspects the clipboard once and returns all detected media.
62
+ Image files are returned as loaded PIL images; non-image files
63
+ (videos, PDFs, etc.) are returned as file paths.
64
+
65
+ On macOS the native pasteboard API is tried first to avoid
66
+ misidentifying a file's thumbnail as clipboard image data.
67
+ """
68
+ # 1. Try macOS native API for file paths (most reliable for Finder copies).
69
+ if sys.platform == "darwin":
70
+ file_paths = _read_clipboard_file_paths_macos_native()
71
+ images, non_image_paths = _classify_file_paths(file_paths)
72
+ if images or non_image_paths:
73
+ return ClipboardResult(
74
+ images=tuple(images),
75
+ file_paths=tuple(non_image_paths),
76
+ )
77
+
78
+ # 2. On Linux, use explicit xclip/wl-paste fallback instead of Pillow's
79
+ # opaque internal selection, which may pick a broken tool first.
80
+ if sys.platform == "linux":
81
+ image = _grab_image_linux()
82
+ if image is not None:
83
+ return ClipboardResult(images=(image,), file_paths=())
84
+ return None
85
+
86
+ # 3. On Windows and other platforms, use Pillow's default implementation.
87
+ payload = ImageGrab.grabclipboard()
88
+ if payload is None:
89
+ return None
90
+ if isinstance(payload, Image.Image):
91
+ # Raw image data (screenshot or thumbnail).
92
+ # If we reach here, the macOS native path lookup did not find any
93
+ # file paths, so this is safe to treat as a real image.
94
+ return ClipboardResult(images=(payload,), file_paths=())
95
+ # payload is a list of file path strings.
96
+ images, non_image_paths = _classify_file_paths(payload)
97
+ if images or non_image_paths:
98
+ return ClipboardResult(
99
+ images=tuple(images),
100
+ file_paths=tuple(non_image_paths),
101
+ )
102
+ return None
103
+
104
+
105
+ def _grab_image_linux() -> Image.Image | None:
106
+ """Read image from Linux clipboard with session-aware tool fallback.
107
+
108
+ Tries the backend matching the current session type first to avoid
109
+ reading stale data from the wrong clipboard (e.g. XWayland vs
110
+ Wayland). On headless systems with no session type, xclip is tried
111
+ first since clipboard bridges (e.g. cc-clip) typically shim xclip.
112
+ """
113
+ xclip_args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]
114
+ wlpaste_args = ["wl-paste", "-t", "image"]
115
+
116
+ if os.getenv("WAYLAND_DISPLAY"):
117
+ candidates = (wlpaste_args, xclip_args)
118
+ elif os.getenv("DISPLAY"):
119
+ candidates = (xclip_args, wlpaste_args)
120
+ else: # headless — xclip first for common clipboard bridges
121
+ candidates = (xclip_args, wlpaste_args)
122
+
123
+ for idx, args in enumerate(candidates):
124
+ if shutil.which(args[0]) is None:
125
+ continue
126
+ try:
127
+ p = subprocess.run(args, capture_output=True, timeout=3)
128
+ except subprocess.TimeoutExpired:
129
+ continue
130
+ if p.returncode == 0 and p.stdout:
131
+ data = io.BytesIO(p.stdout)
132
+ try:
133
+ im = Image.open(data)
134
+ im.load()
135
+ return im
136
+ except Exception:
137
+ continue
138
+ # Silent errors mean clipboard is empty or has no image.
139
+ err = p.stderr
140
+ silent_errors = [
141
+ b"Nothing is copied",
142
+ b"No selection",
143
+ b"No suitable type of content copied",
144
+ b" not available",
145
+ b"cannot convert ",
146
+ b"no owner for the ",
147
+ ]
148
+ if any(se in err for se in silent_errors):
149
+ # Trust the session-native tool: if it says "no image", don't
150
+ # fall back to a different clipboard namespace (e.g. XWayland
151
+ # vs Wayland) which may contain stale unrelated data.
152
+ if idx == 0:
153
+ return None
154
+ continue
155
+ # Otherwise, a real error (e.g. tool broken) — try next candidate.
156
+
157
+ return None
158
+
159
+
160
+ def _classify_file_paths(
161
+ paths: Iterable[os.PathLike[str] | str],
162
+ ) -> tuple[list[Image.Image], list[Path]]:
163
+ """Classify clipboard file paths into images and non-image files.
164
+
165
+ Returns ``(images, non_image_paths)`` where *images* contains loaded
166
+ PIL images and *non_image_paths* contains paths to videos, documents,
167
+ and other non-image files.
168
+ """
169
+ resolved: list[Path] = []
170
+ for item in paths:
171
+ try:
172
+ path = Path(item)
173
+ except (TypeError, ValueError):
174
+ continue
175
+ if not path.is_file():
176
+ continue
177
+ resolved.append(path)
178
+
179
+ images: list[Image.Image] = []
180
+ non_image_paths: list[Path] = []
181
+
182
+ for path in resolved:
183
+ # Video files are never opened as images.
184
+ if path.suffix.lower() in _VIDEO_SUFFIXES:
185
+ non_image_paths.append(path)
186
+ continue
187
+ try:
188
+ with Image.open(path) as img:
189
+ img.load()
190
+ images.append(img.copy())
191
+ except Exception:
192
+ non_image_paths.append(path)
193
+
194
+ return images, non_image_paths
195
+
196
+
197
+ def _read_clipboard_file_paths_macos_native() -> list[Path]:
198
+ try:
199
+ appkit = cast(Any, importlib.import_module("AppKit"))
200
+ foundation = cast(Any, importlib.import_module("Foundation"))
201
+ except Exception:
202
+ return []
203
+
204
+ NSPasteboard = appkit.NSPasteboard
205
+ NSURL = foundation.NSURL
206
+ options_key = getattr(
207
+ appkit,
208
+ "NSPasteboardURLReadingFileURLsOnlyKey",
209
+ "NSPasteboardURLReadingFileURLsOnlyKey",
210
+ )
211
+
212
+ pb = NSPasteboard.generalPasteboard()
213
+ options = {options_key: True}
214
+ try:
215
+ urls: list[Any] | None = pb.readObjectsForClasses_options_([NSURL], options)
216
+ except Exception:
217
+ urls = None
218
+
219
+ paths: list[Path] = []
220
+ if urls:
221
+ for url in urls:
222
+ try:
223
+ path = url.path()
224
+ except Exception:
225
+ continue
226
+ if path:
227
+ paths.append(Path(str(path)))
228
+
229
+ if paths:
230
+ return paths
231
+
232
+ try:
233
+ file_list = cast(list[str] | str | None, pb.propertyListForType_("NSFilenamesPboardType"))
234
+ except Exception:
235
+ return []
236
+
237
+ if not file_list:
238
+ return []
239
+
240
+ file_items: list[str] = []
241
+ if isinstance(file_list, list):
242
+ file_items.extend(item for item in file_list if item)
243
+ else:
244
+ file_items.append(file_list)
245
+
246
+ return [Path(item) for item in file_items]
cloudsave.py ADDED
@@ -0,0 +1,159 @@
1
+ """
2
+ Cloud sync for dulus sessions via GitHub Gist.
3
+
4
+ Supported provider: GitHub Gist
5
+ - No extra cloud account needed beyond a GitHub Personal Access Token
6
+ - Sessions stored as private Gists (JSON), browsable in GitHub UI
7
+ - Zero extra dependencies (uses urllib from stdlib)
8
+
9
+ Config keys (stored in ~/.dulus/config.json):
10
+ gist_token — GitHub Personal Access Token (needs 'gist' scope)
11
+ cloudsave_auto — bool: auto-upload on /exit
12
+ cloudsave_last_gist_id — last uploaded gist ID (for in-place update)
13
+ """
14
+ from __future__ import annotations
15
+ import json
16
+ import urllib.request
17
+ import urllib.error
18
+ from datetime import datetime
19
+
20
+ GIST_TAG = "[dulus]"
21
+ _API = "https://api.github.com"
22
+
23
+
24
+ # ── Low-level Gist API ────────────────────────────────────────────────────────
25
+
26
+ def _request(method: str, path: str, token: str, body: dict | None = None) -> dict:
27
+ url = f"{_API}{path}"
28
+ data = json.dumps(body).encode() if body else None
29
+ req = urllib.request.Request(
30
+ url,
31
+ data=data,
32
+ method=method,
33
+ headers={
34
+ "Authorization": f"token {token}",
35
+ "Accept": "application/vnd.github+json",
36
+ "Content-Type": "application/json",
37
+ "X-GitHub-Api-Version": "2022-11-28",
38
+ },
39
+ )
40
+ with urllib.request.urlopen(req) as resp:
41
+ return json.loads(resp.read())
42
+
43
+
44
+ def _request_safe(method: str, path: str, token: str, body: dict | None = None):
45
+ """Like _request but returns (result, error_str)."""
46
+ try:
47
+ return _request(method, path, token, body), None
48
+ except urllib.error.HTTPError as e:
49
+ msg = e.read().decode(errors="replace")
50
+ try:
51
+ msg = json.loads(msg).get("message", msg)
52
+ except Exception:
53
+ pass
54
+ return None, f"GitHub API {e.code}: {msg}"
55
+ except Exception as e:
56
+ return None, str(e)
57
+
58
+
59
+ # ── Public API ────────────────────────────────────────────────────────────────
60
+
61
+ def validate_token(token: str) -> tuple[bool, str]:
62
+ """Check token is valid and has gist scope. Returns (ok, message)."""
63
+ result, err = _request_safe("GET", "/user", token)
64
+ if err:
65
+ return False, f"Token validation failed: {err}"
66
+ scopes_needed = {"gist"}
67
+ # GitHub returns X-OAuth-Scopes header but urllib doesn't easily expose it;
68
+ # a successful /user call is sufficient for basic validation.
69
+ login = result.get("login", "unknown")
70
+ return True, login
71
+
72
+
73
+ def upload_session(
74
+ session_data: dict,
75
+ token: str,
76
+ description: str = "",
77
+ gist_id: str | None = None,
78
+ ) -> tuple[str | None, str | None]:
79
+ """
80
+ Create or update a Gist with the session JSON.
81
+ Returns (gist_id, error). On success gist_id is the Gist ID.
82
+ """
83
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M")
84
+ desc = f"{GIST_TAG} {description or ts}"
85
+ filename = f"dulus_session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
86
+ content = json.dumps(session_data, indent=2, default=str)
87
+
88
+ body = {
89
+ "description": desc,
90
+ "public": False,
91
+ "files": {filename: {"content": content}},
92
+ }
93
+
94
+ if gist_id:
95
+ result, err = _request_safe("PATCH", f"/gists/{gist_id}", token, body)
96
+ else:
97
+ result, err = _request_safe("POST", "/gists", token, body)
98
+
99
+ if err:
100
+ return None, err
101
+ return result["id"], None
102
+
103
+
104
+ def list_sessions(token: str, max_results: int = 20) -> tuple[list[dict], str | None]:
105
+ """
106
+ List Gists tagged as dulus sessions.
107
+ Returns (list of {id, description, updated_at, url}), error).
108
+ """
109
+ result, err = _request_safe("GET", "/gists?per_page=100", token)
110
+ if err:
111
+ return [], err
112
+
113
+ sessions = [
114
+ {
115
+ "id": g["id"],
116
+ "description": g["description"],
117
+ "updated_at": g["updated_at"],
118
+ "url": g["html_url"],
119
+ "files": list(g["files"].keys()),
120
+ }
121
+ for g in result
122
+ if g.get("description", "").startswith(GIST_TAG)
123
+ ]
124
+ return sessions[:max_results], None
125
+
126
+
127
+ def download_session(token: str, gist_id: str) -> tuple[dict | None, str | None]:
128
+ """
129
+ Fetch a Gist and return the parsed session JSON.
130
+ Returns (session_data, error).
131
+ """
132
+ result, err = _request_safe("GET", f"/gists/{gist_id}", token)
133
+ if err:
134
+ return None, err
135
+
136
+ files = result.get("files", {})
137
+ if not files:
138
+ return None, "Gist has no files"
139
+
140
+ # Take the first (and usually only) file
141
+ file_info = next(iter(files.values()))
142
+ raw_content = file_info.get("content")
143
+ if not raw_content:
144
+ # Truncated — fetch raw URL
145
+ raw_url = file_info.get("raw_url")
146
+ if not raw_url:
147
+ return None, "Could not retrieve file content"
148
+ req = urllib.request.Request(
149
+ raw_url,
150
+ headers={"Authorization": f"token {token}"},
151
+ )
152
+ with urllib.request.urlopen(req) as resp:
153
+ raw_content = resp.read().decode()
154
+
155
+ try:
156
+ data = json.loads(raw_content)
157
+ except json.JSONDecodeError as e:
158
+ return None, f"Invalid JSON in Gist: {e}"
159
+ return data, None
common.py ADDED
@@ -0,0 +1,177 @@
1
+ import sys
2
+ import json
3
+
4
+ # ── Import slash completer helpers ──
5
+ try:
6
+ from backend.ui.input import (
7
+ setup as _setup_slash_complete,
8
+ read_line as _read_line_pt,
9
+ reset_session as _reset_pt_session,
10
+ HAS_PROMPT_TOOLKIT as _HAS_PT,
11
+ )
12
+ def setup_slash_commands(commands_provider, meta_provider):
13
+ """Initialize slash command tab completion."""
14
+ _setup_slash_complete(commands_provider, meta_provider)
15
+ return _HAS_PT
16
+
17
+ def read_slash_input(prompt):
18
+ """Read input with slash completion."""
19
+ return _read_line_pt(prompt, None)
20
+
21
+ def reset_slash_session():
22
+ """Reset the prompt_toolkit session."""
23
+ _reset_pt_session()
24
+ except ImportError:
25
+ def setup_slash_commands(*args, **kwargs):
26
+ return False
27
+ def read_slash_input(prompt):
28
+ return input(prompt)
29
+ def reset_slash_session():
30
+ pass
31
+
32
+ # ── ANSI helpers ─────────────────────────────────────────────────────────────
33
+
34
+ def _rgb(hex_str: str) -> str:
35
+ """Convert '#rrggbb' → ANSI 24-bit foreground escape."""
36
+ h = hex_str.lstrip("#")
37
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
38
+ return f"\033[38;2;{r};{g};{b}m"
39
+
40
+
41
+ # Curated palettes (hex per semantic role). `cyan/green/blue` collapse to the
42
+ # theme's accent color since Dulus uses them all for primary chrome.
43
+ # Add new ones here and they show up in `/theme` automatically.
44
+ THEMES: dict = {
45
+ "dulus": {"accent": "#FF8700", "warn": "#FFAF00", "code": "monokai"},
46
+ "dracula": {"accent": "#BD93F9", "warn": "#FFB86C", "code": "dracula"},
47
+ "nord": {"accent": "#88C0D0", "warn": "#EBCB8B", "code": "nord"},
48
+ "gruvbox": {"accent": "#FABD2F", "warn": "#FE8019", "code": "gruvbox-dark"},
49
+ "solarized": {"accent": "#268BD2", "warn": "#B58900", "code": "solarized-dark"},
50
+ "tokyo-night": {"accent": "#7AA2F7", "warn": "#E0AF68", "code": "one-dark"},
51
+ "catppuccin": {"accent": "#F5C2E7", "warn": "#FAB387", "code": "one-dark"},
52
+ "matrix": {"accent": "#00FF41", "warn": "#CCFF00", "code": "monokai"},
53
+ "synthwave": {"accent": "#FF00FF", "warn": "#FFCC00", "code": "fruity"},
54
+ "midnight": {"accent": "#00BCD4", "warn": "#FFC107", "code": "dracula"},
55
+ "ocean": {"accent": "#38bdf8", "warn": "#fbbf24", "code": "nord"},
56
+ "monokai": {"accent": "#a6e22e", "warn": "#e6db74", "code": "monokai"},
57
+ "mono": {"accent": "#E0E0E0", "warn": "#A0A0A0", "code": "bw"},
58
+ "none": {"accent": "#FFFFFF", "warn": "#FFFFFF", "code": "default"},
59
+ }
60
+
61
+ # Active code-block style for Rich Markdown rendering — read by dulus.py.
62
+ CODE_THEME: str = "monokai"
63
+
64
+ C = {
65
+ "cyan": "", "green": "", "yellow": "", "red": "",
66
+ "blue": "", "magenta": "", "white": "", "gray": "",
67
+ "bold": "\033[1m",
68
+ "dim": "\033[2m",
69
+ "reset": "\033[0m",
70
+ }
71
+
72
+
73
+ def apply_theme(name: str) -> bool:
74
+ """Mutate the global ANSI color map in-place to a named theme."""
75
+ global CODE_THEME
76
+ p = THEMES.get(name)
77
+ if not p:
78
+ return False
79
+ accent = _rgb(p["accent"])
80
+ warn = _rgb(p["warn"])
81
+ C["cyan"] = C["green"] = C["blue"] = accent
82
+ C["yellow"] = C["magenta"] = warn
83
+ C["red"] = "\033[38;5;196m" # errors stay red across all themes
84
+ C["white"] = "\033[97m"
85
+ C["gray"] = "\033[90m"
86
+ CODE_THEME = p["code"]
87
+ return True
88
+
89
+
90
+ # Default = Dulus orange (preserve previous look).
91
+ apply_theme("dulus")
92
+
93
+ def clr(text: str, *keys: str) -> str:
94
+ return "".join(C[k] for k in keys) + str(text) + C["reset"]
95
+
96
+ def info(msg: str): print(clr(msg, "cyan"))
97
+ def ok(msg: str): print(clr(msg, "green"))
98
+ def warn(msg: str): print(clr(f"Warning: {msg}", "yellow"))
99
+ def err(msg: str): print(clr(f"Error: {msg}", "red"), file=sys.stderr)
100
+
101
+ def stream_thinking(chunk: str, verbose: bool):
102
+ if verbose:
103
+ clean_chunk = chunk.replace("\n", " ")
104
+ if clean_chunk:
105
+ print(f"{C['dim']}{clean_chunk}", end="", flush=True)
106
+
107
+ # ── Tool Impersonation UI ────────────────────────────────────────────────────
108
+ def print_tool_start(name: str, inputs: dict):
109
+ desc = f"{name}({', '.join(f'{k}={v}' for k, v in inputs.items())})"
110
+ if name == "Read": desc = f"Read({inputs.get('file_path','')})"
111
+ if name == "Write": desc = f"Write({inputs.get('file_path','')})"
112
+ if name == "Bash": desc = f"Bash({inputs.get('command','')[:60]})"
113
+
114
+ print(clr(f" ⚙ {desc}", "dim", "cyan"), flush=True)
115
+
116
+ def print_tool_end(name: str, result: str, success: bool = True, verbose: bool = False, auto_show: bool = True):
117
+ # For PrintToConsole, always show the full content since that's the point
118
+ if name == "PrintToConsole":
119
+ print(clr(f" [PrintToConsole] {len(result)} chars displayed", "dim", "cyan"))
120
+ print()
121
+ # Print the actual content directly without clr() to avoid encoding issues
122
+ try:
123
+ print(result)
124
+ except UnicodeEncodeError:
125
+ # Fallback: encode then decode with error handling
126
+ print(result.encode('utf-8', errors='replace').decode('utf-8'))
127
+ print()
128
+ return
129
+
130
+ # For display-only tools (ASCII art, etc.), show full content like PrintToConsole if auto_show is ON
131
+ from tool_registry import is_display_only
132
+ is_display = is_display_only(name)
133
+
134
+ if success:
135
+ symbol = "[OK]"
136
+ color = "green"
137
+ summary = f"-> {len(result)} chars" if len(result) > 100 else f"-> {result}"
138
+ print(clr(f" {symbol} {summary}", "dim", color), flush=True)
139
+
140
+ # For display-only tools, show the full content immediately if auto_show is ON
141
+ if is_display and auto_show:
142
+ print()
143
+ try:
144
+ print(result)
145
+ except UnicodeEncodeError:
146
+ print(result.encode('utf-8', errors='replace').decode('utf-8'))
147
+ print()
148
+ else:
149
+ symbol = "[X]"
150
+ color = "red"
151
+ print(clr(f" {symbol} {result[:120]}", "dim", color), flush=True)
152
+
153
+ if verbose and success and not (is_display and auto_show):
154
+ preview = result[:300] + ("..." if len(result) > 300 else "")
155
+ # Replace newlines for indentation but handle encoding
156
+ try:
157
+ indented = preview.replace(chr(10), chr(10)+' ')
158
+ print(clr(f" {indented}", "dim"))
159
+ except UnicodeEncodeError:
160
+ safe_preview = preview.encode('ascii', errors='replace').decode('ascii')
161
+ print(clr(f" {safe_preview}", "dim"))
162
+
163
+
164
+ def sanitize_text(text: str) -> str:
165
+ """Remove invalid UTF-16 surrogates and ensure valid UTF-8.
166
+
167
+ On Windows consoles (cp1252) pasted emojis often become stray surrogates
168
+ (e.g. \\ud83d\\udcec) which later explode with:
169
+ 'utf-8' codec can't encode characters: surrogates not allowed
170
+ This helper cleans them *once* at the boundary before they enter the
171
+ conversation state or are sent to any API.
172
+ """
173
+ if not isinstance(text, str):
174
+ return str(text)
175
+ # Strip surrogate characters (U+D800-U+DFFF) — these are invalid in
176
+ # UTF-8 and will cause encoding errors when JSON-serialised.
177
+ return "".join(c for c in text if not (0xD800 <= ord(c) <= 0xDFFF))