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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- 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))
|