-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathdulus.py
More file actions
11234 lines (10015 loc) · 488 KB
/
dulus.py
File metadata and controls
11234 lines (10015 loc) · 488 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Dulus — Next-gen Python Autonomous Agent.
Usage:
python dulus.py [options] [prompt]
dulus [options] [prompt] (if dulus.bat is in PATH)
Options:
-p, --print Non-interactive: run prompt and exit (also --print-output)
-m, --model MODEL Override model (e.g., -m kimi/kimi-k2.5, -m gpt-4o)
--accept-all Never ask permission (dangerous)
--verbose Show thinking + token counts
--version Print version and exit
-h, --help Show this help message
-c, --cmd COMMAND Execute a Dulus slash command and exit (no REPL)
Useful for scripting and automation.
Examples:
dulus --cmd "plugin reload"
dulus --cmd "status"
dulus --cmd "kill_tmux"
dulus --cmd "checkpoint clear"
dulus -c "skills"
Note: Some commands require an active session.
Non-interactive Examples:
dulus "explain this code" # Quick question and exit
dulus -p "refactor this function" # Same, explicit flag
dulus --cmd "plugin install art@gh" # Install plugin from CLI
dulus --cmd "checkpoint" # List checkpoints
Slash commands in REPL:
/help Show this help
/clear Clear conversation
/model [m] Show or set model
/config Show config / set key=value
/save [f] Save session to file
/load [f] Load session from file
/history Print conversation history
/context Show context window usage
/cost Show API cost this session
/verbose Toggle verbose mode
/thinking [off|min|med|max|raw|0-4] Set extended-thinking level (raw = API default, no nudges; no arg = toggle)
/soul [name] List souls / switch active soul (e.g. /soul chill, /soul forensic)
/schema [tool] Inspect tool input schema (human-facing; model does not see this)
/schema -> list all tools grouped
/schema <tool> -> pretty-print inputs + description
/schema --json <t> -> raw JSON dump
/deep_override Toggle DeepSeek simplified prompt (requires restart)
/deep_tools Toggle DeepSeek auto tool-wrap for JSON calls
/autojob Toggle auto-job printer (auto-print job results)
/auto_show Toggle auto-show for visual tools (ASCII art, etc.)
/ultra_search Toggle ULTRA_SEARCH mode
/permissions [mode] Set permission mode
/afk Toggle AFK mode (auto-dismiss questions, auto-approve tools)
/yolo Toggle YOLO mode (auto-approve ALL actions without prompts)
/cwd [path] Show or change working directory
/memory [query] Search persistent memories
/memory list List all stored memories formatted
/memory load [n|name] Inject numbered memory (or multiple: 1,2,3) into context
/memory delete <name> Delete a specific memory by name
/memory purge Total wipe of memories EXCEPT the 'Soul'
/memory purge-soul Total wipe of EVERYTHING (Danger)
/memory consolidate Extract long-term insights from session via AI
/skills List active Dulus skills (loaded each turn)
/skill Browse + manage Anthropic/ClawHub skills
/skill list Show installed + all available Anthropic skills
/skill get <plugin/skill> Install a skill (e.g. /skill get frontend-design/frontend-design)
/skill use <name> Inject skill into next message /skill remove <name> Uninstall
/agents Show sub-agent tasks
/mcp List MCP servers and their tools
/mcp reload Reconnect all MCP servers
/mcp add <n> <cmd> [args] Add a stdio MCP server
/mcp remove <n> Remove an MCP server from config
/plugin List installed plugins
/plugin install name@url [--project] [--main-agent]
Install a plugin. --main-agent hands off to the
main agent post-install to review/adapt the plugin
/plugin uninstall name Uninstall a plugin
/plugin enable/disable name Toggle plugin
/plugin update name Update a plugin
/plugin recommend [ctx] Recommend plugins for context
/tasks List all tasks
/tasks create <subject> Quick-create a task
/tasks start/done/cancel <id> Update task status
/tasks delete <id> Delete a task
/tasks get <id> Show full task details
/tasks clear Delete all tasks
/voice Record voice input, transcribe, and submit
/voice status Show available recording and STT backends
/voice lang <code> Set STT language (e.g. zh, en, ja — default: auto)
/wake on|off Toggle wake-word (hotword) detection — say "Hey Dulus"
/wake status Show wake-word listener state
/wake phrases List recognised wake phrases
/wake calibrate Measure your mic levels for 5s and suggest a threshold
/wake test Debug mode — shows RMS + STT output for 10 seconds
/wake threshold <n> Tune mic sensitivity (0.001-1.0, default 0.020)
/proactive [dur] Background sentinel polling (e.g. /proactive 5m)
/proactive off Disable proactive polling
/cloudsave setup <token> Configure GitHub token for cloud sync
/cloudsave Upload current session to GitHub Gist
/cloudsave push [desc] Upload with optional description
/cloudsave auto on|off Toggle auto-upload on exit
/cloudsave list List your dulus Gists
/cloudsave load <gist_id> Download and load a session from Gist
/kill_tmux Kill all stuck tmux/psmux sessions (cleanup)
/shell [cmd|on|off] Toggle shell mode or execute shell command
/copy [file] Copy last response or file content to clipboard
/batch Manage Kimi Batch tasks (list, status, fetch)
/roundtable Start a multi-model roundtable discussion
/fork Fork session at a given turn
/undo Undo last turn
/add-dir [path] Manage additional workspace directories
/import <file> Import conversation from file or session
/harvest Harvest Claude.ai cookies (alias: /harvest-claude)
/harvest-claude Harvest Claude.ai cookies
/harvest-kimi Harvest Kimi.com (Consumer) session/gRPC tokens
/harvest-gemini Harvest Gemini (Consumer) session tokens
/harvest-qwen Harvest Qwen (chat.qwen.ai) session tokens
/kimi_chats List recent Kimi conversations
/webchat [port] Spawn web chat UI (background Flask server)
/webchat stop Kill the webchat server
/sandbox Open Dulus Sandbox OS in browser (starts webchat if needed)
/sandbox stop Stop the webchat server
/rtk [on|off] Toggle RTK token-optimized shell command rewriting
/exit /quit Exit
"""
from __future__ import annotations
# ── Suppress Python 3.14 resource_tracker semaphore-leak warning ─────────────
# The multiprocessing.resource_tracker runs as a SEPARATE child process on
# POSIX, so a filterwarnings() in the parent won't reach it. We have to set
# PYTHONWARNINGS in the environment BEFORE multiprocessing is imported the
# first time. The leak in question comes from optional deps (faster-whisper /
# CTranslate2 worker pools) that don't explicitly unlink their POSIX
# semaphores at exit — the kernel reclaims them anyway, so this is pure
# noise on shutdown. Set surgically by message pattern so other warnings
# still surface.
import os as _early_os
_pw_existing = _early_os.environ.get("PYTHONWARNINGS", "")
_pw_silence = "ignore:.*leaked semaphore objects.*:UserWarning"
if _pw_silence not in _pw_existing:
_early_os.environ["PYTHONWARNINGS"] = (
_pw_existing + ("," if _pw_existing else "") + _pw_silence
)
import sys
# ── Windows UTF-8 stdout fix ─────────────────────────────────────────────
# Prevents cp1252 crashes on emoji / international characters.
# Uses reconfigure() so the underlying file descriptor stays intact
# (argparse and other libs need a working fileno()/isatty()).
if sys.platform == "win32":
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
# ── Suppress noisy third-party startup warnings ──────────────────────────
# These don't affect functionality but pollute every Dulus boot (REPL,
# daemon, --print, every shell call). Filtered globally so logs stay clean.
import warnings as _warnings
# requests >= 2.32 nags about urllib3/chardet version pins on Python 3.13+.
_warnings.filterwarnings("ignore", message=r".*urllib3.*")
_warnings.filterwarnings("ignore", message=r".*chardet.*charset_normalizer.*")
_warnings.filterwarnings("ignore", message=r".*RequestsDependencyWarning.*")
# Dulus's own dev-license warning — only relevant if you're building
# license keys for production, not noise we want on every boot.
_warnings.filterwarnings("ignore", message=r".*DULUS_LICENSE_SECRET.*")
# Catch-all: any RequestsDependencyWarning by category, regardless of msg.
try:
from requests.exceptions import RequestsDependencyWarning as _RDW # type: ignore
_warnings.filterwarnings("ignore", category=_RDW)
except Exception:
pass
# pkg_resources / setuptools-based deprecations from optional plugins.
_warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"pkg_resources.*")
# Python 3.14 multiprocessing.resource_tracker emits a UserWarning at
# interpreter shutdown about "leaked semaphore objects" whenever ANY
# dependency that touches multiprocessing.synchronize (notably
# faster-whisper / CTranslate2 worker pools) exits without explicitly
# closing its named POSIX semaphores. The OS reclaims them at exit
# anyway; nothing in Dulus actually leaks. Silence the noise so users
# don't think the CLI is broken on Linux/WSL when shutting down.
_warnings.filterwarnings(
"ignore",
message=r".*leaked semaphore objects.*",
category=UserWarning,
module=r"multiprocessing\.resource_tracker",
)
from pathlib import Path
# ── Global Import Hook ───────────────────────────────────────────────────────
# This allows running dulus.py from any directory while keeping its modules.
# We find the directory where dulus.py actually lives.
DULUS_CODE_ROOT = Path(__file__).resolve().parent
if str(DULUS_CODE_ROOT) not in sys.path:
sys.path.insert(0, str(DULUS_CODE_ROOT))
from tools import ask_input_interactive, _tg_thread_local, _is_in_tg_turn
import input as dulus_input
try:
import paste_placeholders as _paste_ph # type: ignore
except ImportError:
_paste_ph = None # type: ignore[assignment]
try:
import git_prompt as _git_prompt # type: ignore
except ImportError:
_git_prompt = None # type: ignore[assignment]
try:
from common import C
except ImportError:
# Fallback uses Dulus orange (default theme accent) instead of generic cyan
_DULUS_ORANGE = "\033[38;2;255;135;0m"
C = {"cyan": _DULUS_ORANGE, "green": _DULUS_ORANGE, "blue": _DULUS_ORANGE,
"bold": "\033[1m", "reset": "\033[0m", "gray": "\033[90m", "dim": "\033[2m"}
# ── License gate (KevRojo — tu esfuerzo, tu leche) ──────────────────────────
from license_manager import LicenseManager, LicenseTier
# Eagerly extract the sandbox bundle on Dulus boot in a daemon thread, so by
# the time the user (or the webchat server, or a sub-agent) asks for
# /sandbox/ the static files are already sitting at ~/.dulus/sandbox/.
# Silent, no prompt, no notification — exactly the UX we want.
def _eager_extract_sandbox() -> None:
try:
import threading as _th
from sandbox_bootstrap import ensure_sandbox as _es
_th.Thread(target=_es, daemon=True, name="sandbox-extract").start()
except Exception:
pass # missing bundle on dev/source runs is fine — fallback handles it
_eager_extract_sandbox()
import argparse
import atexit
import json
import os
import re
import textwrap
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional, Union, Any, Callable
if sys.platform == "win32":
os.system("") # Enable ANSI escape codes on Windows CMD
# IDLE wraps stdout/stderr in StdOutputFile which lacks .reconfigure —
# guard so launching from the IDLE editor doesn't crash at import time.
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8")
except (AttributeError, Exception):
pass
try:
import readline
except ImportError:
readline = None # Windows compatibility
# ── Optional rich for markdown rendering ──────────────────────────────────
try:
from rich.console import Console
from rich.markdown import Markdown
from rich.live import Live
from rich.syntax import Syntax
from rich.panel import Panel
from rich import print as rprint
_RICH = True
console = Console()
except ImportError:
_RICH = False
console = None
# ── Optional bubblewrap for chat bubbles (NerdFont required) ──────────────
try:
from bubblewrap import Bubbles as _BubblesClass
_bubbles = _BubblesClass()
# Probe: can stdout actually encode the NerdFont powerline characters?
# On legacy Windows consoles (cp1252) these fail with UnicodeEncodeError.
_nf_test_chars = "\ue0b6\ue0b4" # rounded powerline glyphs used by bubblewrap
try:
_enc = getattr(sys.stdout, "encoding", "utf-8") or "utf-8"
_nf_test_chars.encode(_enc)
_HAS_BUBBLES = True
except (UnicodeEncodeError, LookupError):
_HAS_BUBBLES = False
_bubbles = None
except ImportError:
_HAS_BUBBLES = False
_bubbles = None
# Single source of truth: pyproject.toml. Falls back to a hardcoded value
# only when the package isn't installed (e.g. running dulus.py from source
# without a `pip install -e .`).
try:
from importlib.metadata import version as _pkg_version
VERSION = _pkg_version("dulus")
except Exception:
VERSION = "0.2.94" # dev fallback — keep in sync with pyproject.toml
# ── ANSI helpers (used even with rich for non-markdown output) ─────────────
from common import C, clr, info, ok, warn, err, stream_thinking, print_tool_start, print_tool_end, sanitize_text
def _rl_safe(prompt: str) -> str:
"""Wrap ANSI escape sequences with \\001/\\002 so readline ignores them
when calculating visible prompt width. Fixes duplicate-on-scroll and
cursor-misalignment bugs in terminals that use readline."""
import re
return re.sub(r'(\033\[[0-9;]*m)', r'\001\1\002', prompt)
# info, ok, warn, err, stream_thinking are imported from common above
def render_diff(text: str):
"""Print diff text with ANSI colors: red for removals, green for additions."""
for line in text.splitlines():
if line.startswith("+++") or line.startswith("---"):
print(C["bold"] + line + C["reset"])
elif line.startswith("+"):
print(C["green"] + line + C["reset"])
elif line.startswith("-"):
print(C["red"] + line + C["reset"])
elif line.startswith("@@"):
print(C["cyan"] + line + C["reset"])
else:
print(line)
def _has_diff(text: str) -> bool:
"""Check if text contains a unified diff."""
return "--- a/" in text and "+++ b/" in text
# ── Conversation rendering ─────────────────────────────────────────────────
# NOTE: This section mirrors ui/render.py with dulus-specific optimizations.
# Keep in sync with ui/render.py when making changes.
_accumulated_text: list[str] = [] # buffer text during streaming
_current_live: "Live | None" = None # active Rich Live instance (one at a time)
_RICH_LIVE = True # set to False (via config rich_live=false) to disable in-place Live streaming
_SUPPRESS_CONSOLE = False # When True, all console output is suppressed (for background mode)
def _make_renderable(text: str):
"""Return a Rich renderable: Markdown if text contains markup, else plain."""
if any(c in text for c in ("#", "*", "`", "_", "[")):
# We use a custom style for code blocks to make them more subtle (less "blocky" background)
# Default code block background can be aggressive for ASCII art.
import common as _cm
return Markdown(text, code_theme=getattr(_cm, "CODE_THEME", "monokai"))
return text
def _use_bubbles() -> bool:
"""Whether to use bubblewrap chat-bubble mode (requires NerdFont + Rich)."""
return _HAS_BUBBLES and _RICH
def _wrap_in_bubble(renderable, raw_text: str = ""):
"""Wrap a Rich renderable in a rounded Panel for chat-bubble effect.
Calculates a snug width from the raw text to prevent the Panel from
taking up 100% of the screen width when rendering Markdown rules/tables."""
from rich.box import ROUNDED
kw = {"box": ROUNDED, "border_style": "bright_black", "padding": (0, 1), "expand": False}
if raw_text:
lines = raw_text.split("\n")
# Estimate visual width (ignore minor ANSI/emoji double-width inaccuracies)
max_len = max((len(line) for line in lines), default=0)
# Add buffer space: ~2 for left/right borders, 2 for padding, + 6 margin for blockquotes
snug_width = min(console.width - 2, max_len + 10)
kw["width"] = snug_width
else:
kw["width"] = console.width - 2
return Panel(renderable, **kw)
def _start_live() -> None:
"""Start a Rich Live block for in-place Markdown streaming (no-op if not Rich)."""
global _current_live
if _RICH and _RICH_LIVE and _current_live is None:
_current_live = Live(console=console, auto_refresh=False,
vertical_overflow="visible")
_current_live.start()
_last_live_update = 0
_LIVE_UPDATE_INTERVAL = 0.03 # 30ms throttle (~33 FPS) — keeps streaming fluid
_buffered_since_render = 0 # chunks buffered without a Live update
_LIVE_LINE_LIMIT = 80 # auto-switch to plain streaming beyond this many lines
_streamed_plain = False # when bubbles forced plain streaming, skip bubble in flush
def stream_text(chunk: str) -> None:
"""Buffer chunk; update Live in-place when Rich available, else print directly.
Safety: if accumulated text exceeds _LIVE_LINE_LIMIT lines, auto-switch
from Rich Live to plain streaming to prevent terminal re-render duplication
on terminals that can't handle large Live areas (Windows Terminal, etc.).
"""
global _current_live, _last_live_update, _buffered_since_render
_accumulated_text.append(chunk)
# Suppress all console output when in background/silent mode
if _SUPPRESS_CONSOLE:
return
# In split-layout mode stdout is redirected to _OutputRedirector; Rich
# Live's cursor-based repaint pollutes the output buffer with ghost
# lines (those "stuck messages" that keep reappearing). Force plain
# streaming in that case — each chunk becomes one clean append.
_redirected = type(sys.stdout).__name__ == "_OutputRedirector"
# When bubbles are on, Live's cursor-up math goes wrong because the
# snug Panel width grows mid-stream. Result: the bubble re-prints
# stacked instead of in-place (the duplicated-bubble bug). Stream
# plain during the response, render the bubble once in flush_response.
_bubble_active = _use_bubbles()
if _RICH and _RICH_LIVE and not _redirected and not _bubble_active:
full = "".join(_accumulated_text)
line_count = full.count("\n")
# Safety: too many lines → kill Live and fall back to plain streaming
if _current_live is not None and line_count > _LIVE_LINE_LIMIT:
_current_live.stop()
_current_live = None
# Print the full text once (Live already displayed partial content,
# but stopping Live clears it — so we re-print cleanly)
_r = _make_renderable(full)
if _use_bubbles():
_r = _wrap_in_bubble(_r, full)
console.print(_r)
_accumulated_text.clear()
return
if line_count <= _LIVE_LINE_LIMIT:
if _current_live is None:
_start_live()
# Throttle updates for performance
_buffered_since_render += 1
now = time.time()
if ((now - _last_live_update) > _LIVE_UPDATE_INTERVAL
or len(chunk) > 50
or _buffered_since_render >= 5):
_r = _make_renderable(full)
if _use_bubbles():
_r = _wrap_in_bubble(_r, full)
_current_live.update(_r, refresh=True)
_last_live_update = now
_buffered_since_render = 0
else:
# Already past limit, no Live — just append new chunk
print(chunk, end="", flush=True)
elif _bubble_active:
# Bubble mode: stream plain so the user sees progress. We mark
# _streamed_plain so flush_response skips the bubble repaint
# (text is already on screen — re-printing it inside a Panel
# would duplicate the response).
global _streamed_plain
# Defensive: if a Live instance leaked from a previous turn
# (sub-agent flow, exception during streaming, etc.) kill it.
# Otherwise that orphan Live keeps repainting bubbles below us.
if _current_live is not None:
try:
_current_live.stop()
except Exception:
pass
_current_live = None
_streamed_plain = True
print(chunk, end="", flush=True)
else:
print(chunk, end="", flush=True)
# stream_thinking imported from common above
def _count_visual_lines(text: str, width: int) -> int:
"""How many terminal rows did `text` occupy when streamed plain?
Counts wraps for long logical lines, ignores ANSI for length math.
Approximate (doesn't track double-width emoji exactly) but good
enough for the bubble re-render erase trick."""
import re as _re
total = 0
width = max(1, width)
for line in text.split("\n"):
stripped = _re.sub(r'\x1b\[[0-9;]*m', '', line)
visible = len(stripped)
wrapped = max(1, (visible + width - 1) // width) if visible else 1
total += wrapped
return total
def flush_response() -> None:
"""Commit buffered text to screen: stop Live (freezes rendered Markdown in place)."""
global _current_live, _streamed_plain
full = "".join(_accumulated_text)
_accumulated_text.clear()
# If bubbles forced plain streaming, erase what we streamed and
# repaint the whole response inside a Panel — gives the user the
# clean bubble without the mid-stream duplication bug.
if _streamed_plain:
_streamed_plain = False
if full.strip():
try:
lines = _count_visual_lines(full, console.width)
# Move cursor up `lines` rows to col 0, clear from there to EOS.
sys.stdout.write(f"\r\033[{lines}A\033[J")
sys.stdout.flush()
_r = _make_renderable(full)
_r = _wrap_in_bubble(_r, full)
out_c = Console(
file=sys.stdout,
width=console.width,
force_terminal=console.is_terminal,
color_system=console.color_system,
legacy_windows=console.legacy_windows,
)
out_c.print(_r)
except Exception:
# Fallback: if escape codes don't work, just close cleanly.
# The plain text stays on screen — no bubble but no duplicate.
print()
return
if _current_live is not None:
try:
# Final render pass — chunks buffered within the last window may not
# have triggered an update() yet. Freeze the Live at the complete text.
if full:
_r = _make_renderable(full)
if _use_bubbles():
_r = _wrap_in_bubble(_r, full)
_current_live.update(_r, refresh=True)
_current_live.stop()
except Exception:
pass
finally:
_current_live = None
elif _use_bubbles() and full.strip():
# Bubble mode without Live (background turns, etc.):
# Render Panel natively directly to sys.stdout (even if it's a StringIO).
# Conserving original terminal capabilities so it renders actual Unicode borders.
_r = _make_renderable(full)
_r = _wrap_in_bubble(_r, full)
out_c = Console(
file=sys.stdout,
width=console.width,
force_terminal=console.is_terminal,
color_system=console.color_system,
legacy_windows=console.legacy_windows
)
out_c.print(_r)
elif _RICH and full.strip() and type(sys.stdout).__name__ != "_OutputRedirector":
# Fallback: Rich available but no bubbles — render markdown statically
console.print(_make_renderable(full))
else:
print()
sys.stdout.flush()
from spinner import TOOL_SPINNER_PHRASES as _TOOL_SPINNER_PHRASES
from spinner import DEBATE_SPINNER_PHRASES as _DEBATE_SPINNER_PHRASES
_tool_spinner_thread = None
_tool_spinner_stop = threading.Event()
_telegram_thread: threading.Thread | None = None
_telegram_stop: threading.Event | None = None
_telegram_dashboard_bridge = None # TelegramDashboardBridge instance when dashboard mode is active
_spinner_phrase = ""
_spinner_lock = threading.Lock()
def _run_tool_spinner():
"""Background spinner on a single line using carriage return.
In split-input mode stdout is redirected to _OutputRedirector (which
line-buffers and strips \\r), so each spinner frame would eventually
accumulate into the output area. Skip writes in that case — the split
layout has its own visual affordance.
"""
chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
i = 0
while not _tool_spinner_stop.is_set():
with _spinner_lock:
phrase = _spinner_phrase
frame = chars[i % len(chars)]
_redirected = type(sys.stdout).__name__ == "_OutputRedirector"
if not _SUPPRESS_CONSOLE and not _redirected:
sys.stdout.write(f"\033[2K\r {frame} {clr(phrase, 'dim')} ")
sys.stdout.flush()
i += 1
_tool_spinner_stop.wait(0.1)
def _start_tool_spinner(phrase: str | None = None):
global _tool_spinner_thread
if _tool_spinner_thread and _tool_spinner_thread.is_alive():
return # already running
import random
with _spinner_lock:
global _spinner_phrase
_spinner_phrase = phrase or random.choice(_TOOL_SPINNER_PHRASES)
_tool_spinner_stop.clear()
_tool_spinner_thread = threading.Thread(target=_run_tool_spinner, daemon=True)
_tool_spinner_thread.start()
def _change_spinner_phrase():
"""Change the spinner phrase without stopping it."""
import random
with _spinner_lock:
global _spinner_phrase
_spinner_phrase = random.choice(_TOOL_SPINNER_PHRASES)
def _stop_tool_spinner():
global _tool_spinner_thread
if not _tool_spinner_thread:
return
_tool_spinner_stop.set()
_tool_spinner_thread.join(timeout=1)
_tool_spinner_thread = None
# Clear entire line regardless of cursor position
_redirected = type(sys.stdout).__name__ == "_OutputRedirector"
if not _SUPPRESS_CONSOLE and not _redirected:
sys.stdout.write("\033[2K\r")
sys.stdout.flush()
def print_tool_start(name: str, inputs: dict, verbose: bool):
"""Show tool invocation."""
desc = _tool_desc(name, inputs)
print(clr(f" ⚙ {desc}", "dim", "cyan"), flush=True)
if verbose:
print(clr(f" inputs: {json.dumps(inputs, ensure_ascii=False)[:200]}", "dim"))
def print_tool_end(name: str, result: str, verbose: bool, config: dict = None):
# Special handling for PrintToConsole - always show full content
if name == "PrintToConsole":
print(clr(f" [PrintToConsole] {len(result)} chars", "dim", "cyan"), flush=True)
print()
# Print content directly to avoid encoding issues with clr()
# NO TRUNCATION - PrintToConsole shows EVERYTHING to the console (0 tokens)
try:
print(result, flush=True)
except UnicodeEncodeError:
print(result.encode('utf-8', errors='replace').decode('utf-8'), flush=True)
print(flush=True)
return
# Check if this is a display-only tool (visual output like ASCII art)
from tool_registry import is_display_only
is_display = is_display_only(name)
# auto_show is the master switch for user-facing output.
# ON → render the tool's full output to the user (display tools, bash, reads, etc.)
# OFF → suppress automatic render; a hint is injected into the model's view
# (see agent.py) so it can call PrintToConsole when output matters.
auto_show = config.get("auto_show", True) if config else True
lines = result.count("\n") + 1
size = len(result)
summary = f"-> {lines} lines ({size} chars)"
if not result.startswith("Error") and not result.startswith("Denied"):
print(clr(f" [OK] {summary}", "dim", "green"), flush=True)
# Display-only tools render their full output when auto_show is ON.
if is_display and auto_show:
print()
try:
print(result)
except UnicodeEncodeError:
print(result.encode('utf-8', errors='replace').decode('utf-8'))
print()
# Render diff for Edit/Write results only in verbose mode
if verbose and name in ("Edit", "Write") and _has_diff(result):
parts = result.split("\n\n", 1)
if len(parts) == 2:
print(clr(f" {parts[0]}", "dim"))
render_diff(parts[1])
else:
print(clr(f" [X] {result[:120]}", "dim", "red"), flush=True)
if verbose and not result.startswith("Denied") and not (is_display and auto_show):
preview = result[:500] + ("..." if len(result) > 500 else "")
try:
print(clr(f" {preview.replace(chr(10), chr(10)+' ')}", "dim"))
except UnicodeEncodeError:
safe = preview.encode('ascii', errors='replace').decode('ascii')
print(clr(f" {safe}", "dim"))
def _tool_desc(name: str, inputs: dict) -> str:
if name == "Read": return f"Read({inputs.get('file_path','')})"
if name == "Write": return f"Write({inputs.get('file_path','')})"
if name == "Edit": return f"Edit({inputs.get('file_path','')})"
if name == "Bash": return f"Bash({inputs.get('command','')[:80]})"
if name == "Glob": return f"Glob({inputs.get('pattern','')})"
if name == "Grep": return f"Grep({inputs.get('pattern','')})"
if name == "WebFetch": return f"WebFetch({inputs.get('url','')[:60]})"
if name == "WebSearch": return f"WebSearch({inputs.get('query','')})"
if name == "Agent":
atype = inputs.get("subagent_type", "")
aname = inputs.get("name", "")
iso = inputs.get("isolation", "")
parts = []
if atype: parts.append(atype)
if aname: parts.append(f"name={aname}")
if iso: parts.append(f"isolation={iso}")
suffix = f"({', '.join(parts)})" if parts else ""
prompt_short = inputs.get("prompt", "")[:60]
return f"Agent{suffix}: {prompt_short}"
if name == "SendMessage":
return f"SendMessage(to={inputs.get('to','')}: {inputs.get('message','')[:50]})"
if name == "CheckAgentResult": return f"CheckAgentResult({inputs.get('task_id','')})"
if name == "ListAgentTasks": return "ListAgentTasks()"
if name == "ListAgentTypes": return "ListAgentTypes()"
return f"{name}({list(inputs.values())[:1]})"
# ── Permission prompt ──────────────────────────────────────────────────────
def ask_permission_interactive(desc: str, config: dict) -> bool:
text = ask_input_interactive(f" Allow: {desc} [y/N/a(ccept-all)] ", config).strip().lower()
if text == "a" or text == "accept all" or text == "accept-all":
config["permission_mode"] = "accept-all"
if _is_in_tg_turn(config):
token = config.get("telegram_token")
# Reply to the user who actually triggered this prompt; fall back
# to the first configured chat_id if the active one is unknown.
cid = config.get("_active_tg_chat_id") or (_tg_get_chat_ids(config) or [None])[0]
if cid:
_tg_send(token, cid, "✅ Permission mode set to accept-all for this session.")
else:
ok(" Permission mode set to accept-all for this session.")
return True
return text in ("y", "yes")
# ── Slash commands ─────────────────────────────────────────────────────────
import time
import traceback
def _proactive_watcher_loop(config):
"""Background daemon that fires a wake-up prompt after a period of inactivity."""
while True:
time.sleep(1)
if not config.get("_proactive_enabled"):
continue
try:
now = time.time()
interval = config.get("_proactive_interval", 300)
last = config.get("_last_interaction_time", now)
if now - last >= interval:
cb = config.get("_run_query_callback")
if cb:
# Grace period: wait a beat — if the user types in that
# window, abort this firing to prevent output reordering.
# Bug fix: previously we wrote _last_interaction_time = now
# BEFORE this check, which made the grace condition always
# trigger (0.5s < 5s) and the wake-up never fired.
time.sleep(0.5)
if config.get("_last_interaction_time", 0) > last:
# User interacted while we were waiting — skip.
continue
config["_last_interaction_time"] = time.time()
cb(f"(System Automated Event) You have been inactive for {interval} seconds. "
"Before doing anything else, review your previous messages in this conversation. "
"💡 CRITICAL HINT: Look up to find the LAST true direct message from the user so you don't lose the context of the conversation! "
"If you said you would implement, fix, or do something and didn't finish it, "
"continue and complete that work now. "
"Otherwise, check if you have any pending tasks to execute or simply say 'No pending tasks'.")
except Exception as e:
print(f"\n[proactive watcher error]: {e}", flush=True)
# Categorized command catalog for /help. Order = page order.
# Each entry: (page_title, [(command, one_line_description), ...])
_HELP_PAGES = [
("Core", [
("/help", "Show this help (paginated)"),
("/clear", "Clear conversation"),
("/model [m]", "Show or set the active model"),
("/config", "Show config / set key=value"),
("/cwd [path]", "Show or change working directory"),
("/copy [file]", "Copy last response (or file) to clipboard"),
("/shell [cmd|on|off]", "Shell mode toggle or one-shot command"),
("/exit /quit", "Exit Dulus"),
]),
("Session", [
("/save [name]", "Save session to file (any name)"),
("/load", "Load session — paginated by day (*D*S syntax)"),
("/history", "Print conversation history"),
("/context", "Show context window usage"),
("/cost", "Show API cost this session"),
("/fork", "Fork session at a given turn"),
("/undo", "Undo last turn"),
("/import <file>","Import conversation from file/session"),
("/add-dir [path]","Manage additional workspace directories"),
("/batch", "Manage Kimi Batch tasks"),
("/roundtable", "Multi-model roundtable discussion"),
]),
("Memory & Soul", [
("/memory [query]", "Search persistent memories"),
("/memory list", "List all stored memories"),
("/memory load <n|name>","Inject memory into context"),
("/memory delete <name>","Delete a specific memory"),
("/memory purge", "Wipe memories (keep Soul)"),
("/memory purge-soul", "Wipe EVERYTHING (danger)"),
("/memory consolidate", "Extract long-term insights via AI"),
("/soul [name]", "List souls / switch active soul"),
]),
("Skills · Plugins · Agents · MCP · Tasks", [
("/skills", "List active Dulus skills"),
("/skill list", "Browse installed + available skills"),
("/skill get <slug>", "Install an Anthropic/ClawHub skill"),
("/skill use <name>", "Inject skill into next message"),
("/skill remove <name>", "Uninstall a skill"),
("/plugin", "List installed plugins"),
("/plugin install <name@url>","Install a plugin"),
("/plugin uninstall <name>","Uninstall a plugin"),
("/plugin enable|disable <n>","Toggle a plugin"),
("/plugin update <name>", "Update a plugin"),
("/plugin recommend [ctx]", "Recommend plugins for a context"),
("/agents", "Show sub-agent tasks"),
("/mcp", "List MCP servers and tools"),
("/mcp reload | add | remove","Manage MCP servers"),
("/tasks", "List/create/update tasks"),
]),
("Voice · Wake", [
("/voice", "Record voice → transcribe → submit"),
("/voice status", "Show recording + STT backends"),
("/voice lang <code>", "Set STT language (zh/en/ja/auto)"),
("/wake on|off", "Toggle wake-word ('Hey Dulus')"),
("/wake status", "Show wake-word listener state"),
("/wake phrases", "List recognised wake phrases"),
("/wake calibrate", "Measure mic 5s, suggest threshold"),
("/wake test", "Debug mode (RMS + STT for 10s)"),
("/wake threshold <n>", "Tune mic sensitivity (0.001–1.0)"),
("/wake feedback on|off", "TTS reply on wake (off = beep only)"),
]),
("Web · Sandbox · Cloud · Harvest", [
("/webchat [port]", "Spawn web chat UI (Flask)"),
("/webchat stop", "Kill the webchat server"),
("/sandbox", "Open Dulus Sandbox OS in browser"),
("/sandbox stop", "Stop the sandbox server"),
("/cloudsave", "Upload current session to GitHub Gist"),
("/cloudsave setup <token>","Configure GitHub token"),
("/cloudsave auto on|off", "Toggle auto-upload on exit"),
("/cloudsave list", "List your Dulus Gists"),
("/cloudsave load <id>", "Download + load a session from Gist"),
("/harvest", "Harvest Claude.ai cookies"),
("/harvest-kimi", "Harvest Kimi consumer tokens"),
("/harvest-gemini", "Harvest Gemini consumer tokens"),
("/harvest-qwen", "Harvest Qwen tokens"),
("/kimi_chats", "List recent Kimi conversations"),
]),
("Advanced", [
("/thinking [off|min|med|max|raw|0-4]","Set extended-thinking level"),
("/schema [tool]", "Inspect tool input schema"),
("/deep_override", "DeepSeek simplified prompt (toggle)"),
("/deep_tools", "DeepSeek auto JSON tool-wrap (toggle)"),
("/autojob", "Auto-print job results (toggle)"),
("/auto_show", "Auto-render visual tools (toggle)"),
("/ultra_search", "Aggressive multi-query search"),
("/permissions [mode]", "Set permission mode"),
("/afk", "AFK mode (auto-approve tools)"),
("/yolo", "YOLO mode (auto-approve ALL)"),
("/proactive [dur|off]", "Background sentinel polling"),
("/kill_tmux", "Kill stuck tmux/psmux sessions"),
("/rtk [on|off]", "Toggle RTK shell-command rewriting"),
]),
]
def _render_toggle_footer(config) -> None:
"""Print the toggle status block. Called at the bottom of every /help page
so the user always sees current state without scrolling.
"""
_toggles = [
("auto_show", True, "/auto_show", "Visual tools auto-render to console"),
("autojob", False, "/autojob", "Auto-print full background-job results"),
("verbose", False, "/verbose", "Verbose output (thinking chunks, debug)"),
("sticky_input", True, "/sticky_input", "Anchored input bar (prompt_toolkit)"),
("hide_sender", True, "/hide_sender", "Hide typed message above the bar"),
("mem_palace", True, "/mem_palace", "Per-turn MemPalace memory injection"),
("mem_palace_print",False, "/mem_palace print","Debug-print MemPalace injections"),
("schema_autoload", True, "/schema_autoload", "Inject full tool inventory at startup"),
("ultra_search", False, "/ultra_search", "Aggressive multi-query search"),
("proactive", False, "/proactive", "Background sentinel polling"),
("cloudsave_auto", False, "/cloudsave auto", "Auto-upload session to Gist on exit"),
("lite_mode", False, "/lite", "Lite mode (smaller system prompt)"),
("brave_search_enabled", False, "/brave", "Brave Search API integration"),
("tts_enabled", False, "/tts", "Automatic Text-to-Speech"),
("wake_enabled", False, "/wake", "Wake-word hotword detection"),
("daemon", False, "/daemon", "External triggers without REPL"),
("afk_mode", False, "/afk", "AFK mode (auto-approve tools)"),
("yolo_mode", False, "/yolo", "YOLO mode (auto-approve ALL)"),
("rtk_enabled", True, "/rtk", "RTK shell command rewriting"),
]
print(clr(" ── Toggles ──", "cyan", "bold"))
for key, default, cmd, desc in _toggles:
val = config.get(key, default)
state_str = clr("ON ", "green") if val else clr("OFF", "red")
print(f" [{state_str}] {clr(cmd, 'magenta'):<28} {clr(desc, 'dim')}")
def _render_help_page_telegram(config) -> None:
"""Telegram-friendly rendering: full categorized dump, no pagination.
Telegram users can scroll the message; pagination would need extra UX
wiring through the bot. Toggles are appended at the end once.
"""
print("Dulus — Commands\n")
for title, items in _HELP_PAGES:
print(f"━━ {title} ━━")
for cmd, desc in items:
print(f" {cmd:<32} {desc}")
print()
print("━━ Toggles ━━")
_render_toggle_footer(config)
def cmd_help(_args: str, _state, config) -> bool:
# Single-shot dump. Pagination was nice in the REPL but broke Telegram
# (no live keyboard for n/p/q, and the prompt would hang the bridge).
# One flat categorized print works everywhere — terminal, Telegram,
# piped to a file, log capture, the lot.
print(clr(" Dulus — Commands", "cyan", "bold"))
print(clr(" " + "─" * 60, "dim"))
for title, items in _HELP_PAGES:
print()
print(clr(f" ━━ {title} ━━", "yellow", "bold"))
for cmd, desc in items:
print(f" {clr(cmd, 'magenta'):<40} {clr(desc, 'dim')}")
print()
_render_toggle_footer(config)
return True
def cmd_model(args: str, _state, config) -> bool:
from providers import PROVIDERS, detect_provider
if not args:
model = config["model"]
pname = detect_provider(model)
info(f"Current model: {model} (provider: {pname})")
info("\nAvailable models by provider:")
for pn, pdata in PROVIDERS.items():
ms = pdata.get("models", [])
if ms:
info(f" {pn:12s} " + ", ".join(ms[:4]) + ("..." if len(ms) > 4 else ""))
info("\nFormat: 'provider/model' or just model name (auto-detected)")
info(" e.g. /model gpt-4o")
info(" e.g. /model ollama/qwen2.5-coder")
info(" e.g. /model kimi:moonshot-v1-32k")
else:
# Accept both "ollama/model" and "ollama:model" syntax
# Only treat ':' as provider separator if left side is a known provider
m = args.strip()
if "/" not in m and ":" in m:
left, right = m.split(":", 1)
if left in PROVIDERS:
m = f"{left}/{right}"
config["model"] = m
pname = detect_provider(m)
ok(f"Model set to {m} (provider: {pname})")
from config import save_config
save_config(config)
return True
def _generate_personas(topic: str, curr_model: str, config: dict, count: int = 5) -> dict | None:
"""Ask the LLM to generate `count` topic-appropriate expert personas as a dict."""
from providers import stream, TextChunk
import json
example_entries = "\n".join(
f' "p{i+1}": {{"icon": "emoji", "role": "Expert Title", "desc": "One sentence describing their analytical angle."}}'
for i in range(count)
)
user_msg = f"""Generate {count} expert personas for a multi-perspective brainstorming debate on: "{topic}"
Return ONLY a valid JSON object — no markdown fences, no extra text — like this:
{{
{example_entries}
}}
Choose experts whose domains are most relevant to analyzing "{topic}" from different angles."""
internal_config = config.copy()
internal_config["no_tools"] = True
chunks = []
try:
for event in stream(curr_model, "You are a debate facilitator. Return only valid JSON.", [{"role": "user", "content": user_msg}], [], internal_config):
if isinstance(event, TextChunk):
chunks.append(event.text)
except Exception:
return None
raw = "".join(chunks).strip()
# Strip markdown code fences if the model wraps in ```json ... ```
if "```" in raw:
for part in raw.split("```"):
part = part.strip().lstrip("json").strip()
try:
return json.loads(part)
except Exception:
continue
try:
return json.loads(raw)