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
tools.py ADDED
@@ -0,0 +1,2694 @@
1
+ """Tool definitions and implementations for Dulus."""
2
+ import json
3
+ import os
4
+ import re
5
+ import glob as _glob
6
+ import difflib
7
+ import subprocess
8
+ import threading
9
+ from pathlib import Path
10
+ from typing import Callable, Optional
11
+
12
+ from tool_registry import ToolDef, register_tool
13
+ from tool_registry import execute_tool as _registry_execute
14
+
15
+ # Import input.py for slash command autocompletion
16
+ try:
17
+ from input import setup as input_setup, HAS_PROMPT_TOOLKIT, read_line
18
+ # Expose setup for backwards compatibility (Dulus uses input.setup())
19
+ except ImportError:
20
+ HAS_PROMPT_TOOLKIT = False
21
+ input_setup = None
22
+ read_line = None
23
+
24
+ # Import dulus's COMMANDS and _CMD_META for autocompletion
25
+ try:
26
+ from dulus import COMMANDS, _CMD_META
27
+ except ImportError:
28
+ COMMANDS = {}
29
+ _CMD_META = {}
30
+ try:
31
+ from config import load_config
32
+ except ImportError:
33
+ load_config = None
34
+ try:
35
+ from common import clr
36
+ except ImportError:
37
+ def clr(text, *keys):
38
+ return str(text)
39
+
40
+ # ── AskUserQuestion state ──────────────────────────────────────────────────────
41
+ # The main REPL loop drains _pending_questions and fills _question_answers.
42
+ _pending_questions: list[dict] = [] # [{id, question, options, allow_freetext, event, result_holder}]
43
+ _ask_lock = threading.Lock()
44
+
45
+ # ── Telegram turn detection (thread-local) ─────────────────────────────────
46
+ # Using thread-local storage instead of a shared config key prevents race
47
+ # conditions when slash commands run in their own daemon threads while the
48
+ # Telegram poll loop and the main REPL loop continue on other threads.
49
+ _tg_thread_local = threading.local()
50
+
51
+
52
+ def _is_in_tg_turn(config: dict) -> bool:
53
+ """Return True if the *current thread* is handling a Telegram interaction.
54
+
55
+ Checks the thread-local flag first (set by the slash-command runner thread),
56
+ then falls back to the config key (set by the main REPL for _bg_runner turns).
57
+ """
58
+ return getattr(_tg_thread_local, "active", False) or bool(config.get("_in_telegram_turn", False))
59
+
60
+ # ── Tool JSON schemas (sent to Claude API) ─────────────────────────────────
61
+
62
+ TOOL_SCHEMAS = [
63
+ {
64
+ "name": "Read",
65
+ "description": (
66
+ "Read a file's contents. Returns content with line numbers "
67
+ "(format: 'N\\tline'). Use limit/offset to read large files in chunks."
68
+ ),
69
+ "input_schema": {
70
+ "type": "object",
71
+ "properties": {
72
+ "file_path": {"type": "string", "description": "Absolute file path"},
73
+ "limit": {"type": "integer", "description": "Max lines to read"},
74
+ "offset": {"type": "integer", "description": "Start line (0-indexed)"},
75
+ },
76
+ "required": ["file_path"],
77
+ },
78
+ },
79
+ {
80
+ "name": "Write",
81
+ "description": "Write content to a file. DO NOT use this for temporary results or data that should simply be printed to the user - use PrintToConsole for that. Only use Write for persistent code or documentation.",
82
+ "input_schema": {
83
+ "type": "object",
84
+ "properties": {
85
+ "file_path": {"type": "string"},
86
+ "content": {"type": "string"},
87
+ },
88
+ "required": ["file_path", "content"],
89
+ },
90
+ },
91
+ {
92
+ "name": "Edit",
93
+ "description": (
94
+ "Replace exact text in a file. old_string must match exactly (including whitespace). "
95
+ "If old_string appears multiple times, use replace_all=true or add more context."
96
+ ),
97
+ "input_schema": {
98
+ "type": "object",
99
+ "properties": {
100
+ "file_path": {"type": "string"},
101
+ "old_string": {"type": "string", "description": "Exact text to replace"},
102
+ "new_string": {"type": "string", "description": "Replacement text"},
103
+ "replace_all": {"type": "boolean", "description": "Replace all occurrences"},
104
+ },
105
+ "required": ["file_path", "old_string", "new_string"],
106
+ },
107
+ },
108
+ {
109
+ "name": "Bash",
110
+ "description": "Execute a shell command. Returns stdout+stderr. Stateless (no cd persistence).",
111
+ "input_schema": {
112
+ "type": "object",
113
+ "properties": {
114
+ "command": {"type": "string"},
115
+ "timeout": {"type": "integer", "description": "Seconds before timeout (default 30). Use 120-300 for package installs (npm, pip, npx), builds, and long-running commands."},
116
+ },
117
+ "required": ["command"],
118
+ },
119
+ },
120
+ {
121
+ "name": "Glob",
122
+ "description": "Find files matching a glob pattern. Returns sorted list of matching paths.",
123
+ "input_schema": {
124
+ "type": "object",
125
+ "properties": {
126
+ "pattern": {"type": "string", "description": "Glob pattern e.g. **/*.py"},
127
+ "path": {"type": "string", "description": "Base directory (default: cwd)"},
128
+ },
129
+ "required": ["pattern"],
130
+ },
131
+ },
132
+ {
133
+ "name": "Grep",
134
+ "description": "Search file contents with regex using ripgrep (falls back to grep).",
135
+ "input_schema": {
136
+ "type": "object",
137
+ "properties": {
138
+ "pattern": {"type": "string", "description": "Regex pattern"},
139
+ "path": {"type": "string", "description": "File or directory to search"},
140
+ "glob": {"type": "string", "description": "File filter e.g. *.py"},
141
+ "output_mode": {
142
+ "type": "string",
143
+ "enum": ["content", "files_with_matches", "count"],
144
+ "description": "content=matching lines, files_with_matches=file paths, count=match counts",
145
+ },
146
+ "case_insensitive": {"type": "boolean"},
147
+ "context": {"type": "integer", "description": "Lines of context around matches"},
148
+ },
149
+ "required": ["pattern"],
150
+ },
151
+ },
152
+ {
153
+ "name": "WebFetch",
154
+ "description": (
155
+ "Fetch a URL and return its text content (HTML stripped). "
156
+ ),
157
+ "input_schema": {
158
+ "type": "object",
159
+ "properties": {
160
+ "url": {"type": "string", "description": "URL to fetch or file:// path"},
161
+ },
162
+ "required": ["url"],
163
+ },
164
+ },
165
+ {
166
+ "name": "WebSearch",
167
+ "description": "Search the web (via Brave or DuckDuckGo). DO NOT save search results to files - just process them or use PrintToConsole to show them to the user.",
168
+ "input_schema": {
169
+ "type": "object",
170
+ "properties": {
171
+ "query": {"type": "string"},
172
+ },
173
+ "required": ["query"],
174
+ },
175
+ },
176
+ {
177
+ "name": "LineCount",
178
+ "description": "Rapidly count the number of lines in a file.",
179
+ "input_schema": {
180
+ "type": "object",
181
+ "properties": {
182
+ "file_path": {"type": "string", "description": "Absolute file path"},
183
+ },
184
+ "required": ["file_path"],
185
+ },
186
+ },
187
+ {
188
+ "name": "SearchLastOutput",
189
+ "description": (
190
+ "Search or summarize the tool outputs accumulated during this turn. "
191
+ "Use this to find specific data across one or more tool results that were truncated. "
192
+ "With no pattern: returns a summary of the whole accumulation. "
193
+ "With a pattern: returns only matching lines with context."
194
+ ),
195
+ "input_schema": {
196
+ "type": "object",
197
+ "properties": {
198
+ "pattern": {
199
+ "type": "string",
200
+ "description": "Regex pattern to search for (case-insensitive). Omit to get a summary.",
201
+ },
202
+ "context": {
203
+ "type": "integer",
204
+ "description": "Lines of context around each match (default 2)",
205
+ },
206
+ },
207
+ "required": [],
208
+ },
209
+ },
210
+ {
211
+ "name": "PrintLastOutput",
212
+ "description": (
213
+ "Print the raw content of the last tool output file directly to terminal. "
214
+ "Use this for ASCII art, tables, or large outputs that shouldn't be rewritten by the model. "
215
+ "Returns the raw file content for direct display without processing."
216
+ ),
217
+ "input_schema": {
218
+ "type": "object",
219
+ "properties": {},
220
+ "required": [],
221
+ },
222
+ },
223
+ # ── Task tools (schemas also listed here for Claude's tool list) ──────────
224
+ {
225
+ "name": "TaskCreate",
226
+ "description": (
227
+ "Create a new task in the task list. "
228
+ "Use this to track work items, to-dos, and multi-step plans."
229
+ ),
230
+ "input_schema": {
231
+ "type": "object",
232
+ "properties": {
233
+ "subject": {"type": "string", "description": "Brief title"},
234
+ "description": {"type": "string", "description": "What needs to be done"},
235
+ "active_form": {"type": "string", "description": "Present-continuous label while in_progress"},
236
+ "metadata": {"type": "object", "description": "Arbitrary metadata"},
237
+ },
238
+ "required": ["subject", "description"],
239
+ },
240
+ },
241
+ {
242
+ "name": "TaskUpdate",
243
+ "description": (
244
+ "Update a task: change status, subject, description, owner, "
245
+ "dependency edges, or metadata. "
246
+ "Set status='deleted' to remove. "
247
+ "Statuses: pending, in_progress, completed, cancelled, deleted."
248
+ ),
249
+ "input_schema": {
250
+ "type": "object",
251
+ "properties": {
252
+ "task_id": {"type": "string"},
253
+ "subject": {"type": "string"},
254
+ "description": {"type": "string"},
255
+ "status": {"type": "string", "enum": ["pending","in_progress","completed","cancelled","deleted"]},
256
+ "active_form": {"type": "string"},
257
+ "owner": {"type": "string"},
258
+ "add_blocks": {"type": "array", "items": {"type": "string"}},
259
+ "add_blocked_by":{"type": "array", "items": {"type": "string"}},
260
+ "metadata": {"type": "object"},
261
+ },
262
+ "required": ["task_id"],
263
+ },
264
+ },
265
+ {
266
+ "name": "TaskGet",
267
+ "description": "Retrieve full details of a single task by ID.",
268
+ "input_schema": {
269
+ "type": "object",
270
+ "properties": {
271
+ "task_id": {"type": "string", "description": "Task ID to retrieve"},
272
+ },
273
+ "required": ["task_id"],
274
+ },
275
+ },
276
+ {
277
+ "name": "TaskList",
278
+ "description": "List all tasks with their status, owner, and pending blockers.",
279
+ "input_schema": {
280
+ "type": "object",
281
+ "properties": {},
282
+ "required": [],
283
+ },
284
+ },
285
+ {
286
+ "name": "NotebookEdit",
287
+ "description": (
288
+ "Edit a Jupyter notebook (.ipynb) cell. "
289
+ "Supports replace (modify existing cell), insert (add new cell after cell_id), "
290
+ "and delete (remove cell) operations. "
291
+ "Read the notebook with the Read tool first to see cell IDs."
292
+ ),
293
+ "input_schema": {
294
+ "type": "object",
295
+ "properties": {
296
+ "notebook_path": {
297
+ "type": "string",
298
+ "description": "Absolute path to the .ipynb notebook file",
299
+ },
300
+ "new_source": {
301
+ "type": "string",
302
+ "description": "New source code/text for the cell",
303
+ },
304
+ "cell_id": {
305
+ "type": "string",
306
+ "description": (
307
+ "ID of the cell to edit. For insert, the new cell is inserted after this cell "
308
+ "(or at the beginning if omitted). Use 'cell-N' (0-indexed) if no IDs are set."
309
+ ),
310
+ },
311
+ "cell_type": {
312
+ "type": "string",
313
+ "enum": ["code", "markdown"],
314
+ "description": "Cell type. Required for insert; defaults to current type for replace.",
315
+ },
316
+ "edit_mode": {
317
+ "type": "string",
318
+ "enum": ["replace", "insert", "delete"],
319
+ "description": "replace (default) / insert / delete",
320
+ },
321
+ },
322
+ "required": ["notebook_path", "new_source"],
323
+ },
324
+ },
325
+ {
326
+ "name": "GetDiagnostics",
327
+ "description": (
328
+ "Get LSP-style diagnostics (errors, warnings, hints) for a source file. "
329
+ "Uses pyright/mypy/flake8 for Python, tsc for TypeScript/JavaScript, "
330
+ "and shellcheck for shell scripts. Returns structured diagnostic output."
331
+ ),
332
+ "input_schema": {
333
+ "type": "object",
334
+ "properties": {
335
+ "file_path": {
336
+ "type": "string",
337
+ "description": "Absolute or relative path to the file to diagnose",
338
+ },
339
+ "language": {
340
+ "type": "string",
341
+ "description": (
342
+ "Override auto-detected language: python, javascript, typescript, "
343
+ "shellscript. Omit to auto-detect from file extension."
344
+ ),
345
+ },
346
+ },
347
+ "required": ["file_path"],
348
+ },
349
+ },
350
+ {
351
+ "name": "AskUserQuestion",
352
+ "description": (
353
+ "Pause execution and ask the user a clarifying question. "
354
+ "Use this when you need a decision from the user before proceeding. "
355
+ "Returns the user's answer as a string."
356
+ ),
357
+ "input_schema": {
358
+ "type": "object",
359
+ "properties": {
360
+ "question": {
361
+ "type": "string",
362
+ "description": "The question to ask the user.",
363
+ },
364
+ "options": {
365
+ "type": "array",
366
+ "description": "Optional list of choices. Each item: {label, description}.",
367
+ "items": {
368
+ "type": "object",
369
+ "properties": {
370
+ "label": {"type": "string"},
371
+ "description": {"type": "string"},
372
+ },
373
+ "required": ["label"],
374
+ },
375
+ },
376
+ "allow_freetext": {
377
+ "type": "boolean",
378
+ "description": "If true (default), user may type a free-text answer instead of selecting an option.",
379
+ },
380
+ },
381
+ "required": ["question"],
382
+ },
383
+ },
384
+ {
385
+ "name": "SleepTimer",
386
+ "description": (
387
+ "Schedule a background timer. When the timer finishes, a (System Automated Event) notification is injected "
388
+ "so you can wake up and execute deferred monitoring tasks or checks."
389
+ ),
390
+ "input_schema": {
391
+ "type": "object",
392
+ "properties": {
393
+ "seconds": {"type": "integer", "description": "Number of seconds to sleep before waking up."}
394
+ },
395
+ "required": ["seconds"],
396
+ },
397
+ },
398
+ {
399
+ "name": "PrintToConsole",
400
+ "description": (
401
+ "Display text to the USER in the chat console WITHOUT using response tokens. "
402
+ "WARNING: This tool CANNOT save files. The 'file_path' parameter is for READING existing files only. "
403
+ "DO NOT try to use this to 'store' results. Use the 'content' parameter to show results to the user. "
404
+ "Perfect for: progress updates, step-by-step logs, lengthy explanations, debug info. "
405
+ "The content appears in the chat immediately as the tool result. "
406
+ "CRITICAL: After using PrintToConsole, DO NOT repeat the same content in your response."
407
+ ),
408
+ "input_schema": {
409
+ "type": "object",
410
+ "properties": {
411
+ "content": {
412
+ "type": "string",
413
+ "description": "Text to display to the user. Supports newlines and formatting. This appears in the chat console.",
414
+ },
415
+ "style": {
416
+ "type": "string",
417
+ "enum": ["normal", "success", "info", "warning", "error"],
418
+ "description": "Visual style prefix: success=[OK], info=[i], warning=[!], error=[X], normal=none",
419
+ "default": "normal",
420
+ },
421
+ "prefix": {
422
+ "type": "string",
423
+ "description": "Optional source prefix like '[TOOL]' shown before the content",
424
+ "default": "",
425
+ },
426
+ "from_line": {
427
+ "type": "integer",
428
+ "description": "Extract content starting from this line number (1-indexed). Use with to_line to show specific range.",
429
+ "minimum": 1,
430
+ },
431
+ "to_line": {
432
+ "type": "integer",
433
+ "description": "Extract content up to this line number (inclusive). Use with from_line to show specific range.",
434
+ "minimum": 1,
435
+ },
436
+ "file_path": {
437
+ "type": "string",
438
+ "description": "Path to a file to read and display. If provided, reads this file instead of using content parameter. Useful for job files, logs, etc.",
439
+ },
440
+ },
441
+ "required": [],
442
+ },
443
+ },
444
+ ]
445
+
446
+ # ── Safe bash commands (never ask permission) ───────────────────────────────
447
+
448
+ _SAFE_PREFIXES = (
449
+ "ls", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "date",
450
+ "which", "type", "env", "printenv", "uname", "whoami", "id",
451
+ "git log", "git status", "git diff", "git show", "git branch",
452
+ "git remote", "git stash list", "git tag",
453
+ "find ", "grep ", "rg ", "ag ", "fd ",
454
+ "python ", "python3 ", "node ", "ruby ", "perl ",
455
+ "pip show", "pip list", "npm list", "cargo metadata",
456
+ "df ", "du ", "free ", "top -bn", "ps ",
457
+ "curl -I", "curl --head",
458
+ )
459
+
460
+ def _is_safe_bash(cmd: str) -> bool:
461
+ c = cmd.strip()
462
+ return any(c.startswith(p) for p in _SAFE_PREFIXES)
463
+
464
+
465
+ # ── Diff helpers ──────────────────────────────────────────────────────────
466
+
467
+ def generate_unified_diff(old, new, filename, context_lines=3):
468
+ old_lines = old.splitlines(keepends=True)
469
+ new_lines = new.splitlines(keepends=True)
470
+ diff = difflib.unified_diff(old_lines, new_lines,
471
+ fromfile=f"a/{filename}", tofile=f"b/{filename}", n=context_lines)
472
+ return "".join(diff)
473
+
474
+ def maybe_truncate_diff(diff_text, max_lines=80):
475
+ lines = diff_text.splitlines()
476
+ if len(lines) <= max_lines:
477
+ return diff_text
478
+ shown = lines[:max_lines]
479
+ remaining = len(lines) - max_lines
480
+ return "\n".join(shown) + f"\n\n[... {remaining} more lines ...]"
481
+
482
+
483
+ # ── Tool implementations ───────────────────────────────────────────────────
484
+
485
+ _DEFAULT_READ_LIMIT = 1000 # kimi-cli default
486
+
487
+
488
+ def _read(file_path: str, limit: int = None, offset: int = None) -> str:
489
+ p = Path(file_path).expanduser().resolve()
490
+ if not p.exists():
491
+ return f"Error: file not found: {p}"
492
+ if p.is_dir():
493
+ return f"Error: {p} is a directory"
494
+ try:
495
+ # Default limit so the model doesn't accidentally swallow multi-MB files.
496
+ effective_limit = limit if limit is not None else _DEFAULT_READ_LIMIT
497
+
498
+ # For small files, we can just read everything. For large files, we should iterate.
499
+ # Threshold for "large" file: 10MB
500
+ size = p.stat().st_size
501
+ if size < 10 * 1024 * 1024:
502
+ lines = p.read_text(encoding="utf-8", errors="replace", newline="").splitlines(keepends=True)
503
+ total = len(lines)
504
+ start = offset or 0
505
+ chunk = lines[start:start + effective_limit]
506
+ else:
507
+ # Memory efficient reading for large files
508
+ total = 0
509
+ chunk = []
510
+ start = offset or 0
511
+ end = start + effective_limit
512
+
513
+ with p.open("r", encoding="utf-8", errors="replace", newline="") as f:
514
+ for i, line in enumerate(f):
515
+ total += 1
516
+ if i >= start and i < end:
517
+ chunk.append(line)
518
+
519
+ if not chunk and total > 0:
520
+ return f"(offset {start} >= total lines {total})"
521
+ if not chunk:
522
+ return "(empty file)"
523
+
524
+ header = f"[File: {file_path} | Total lines: {total} | Reading: {start+1} to {start+len(chunk)}]\n"
525
+ if limit is None and total > effective_limit:
526
+ header += f"[TRUNCATED — default limit of {effective_limit} lines applied. Use offset + limit to read more.]\n"
527
+ content = "".join(f"{start + i + 1:6}\t{l}" for i, l in enumerate(chunk))
528
+ return header + content
529
+ except Exception as e:
530
+ return f"Error: {e}"
531
+
532
+
533
+ def _line_count(file_path: str) -> str:
534
+ p = Path(file_path)
535
+ if not p.exists():
536
+ return f"Error: file not found: {file_path}"
537
+ try:
538
+ count = 0
539
+ with p.open("rb") as f:
540
+ for line in f:
541
+ count += 1
542
+ return f"File: {file_path}\nTotal lines: {count}"
543
+ except Exception as e:
544
+ return f"Error: {e}"
545
+
546
+
547
+ def _print_last_output() -> str:
548
+ """Print the full content of the last tool output directly.
549
+
550
+ Use this to display large outputs (ASCII art, logs, etc.) without re-writing them.
551
+ """
552
+ out_file = Path.home() / ".dulus" / "last_tool_output.txt"
553
+ if not out_file.exists():
554
+ return "No saved tool output available."
555
+ try:
556
+ content = out_file.read_text(encoding="utf-8", errors="replace")
557
+ if not content.strip():
558
+ return "Last tool output is empty."
559
+ return content
560
+ except Exception as e:
561
+ return f"Error reading saved output: {e}"
562
+
563
+
564
+ def _search_last_output(pattern: str = None, context: int = 2) -> str:
565
+ """Search or summarize the tool outputs accumulated during this turn."""
566
+ out_file = Path.home() / ".dulus" / "last_tool_output.txt"
567
+ if not out_file.exists():
568
+ return "No saved tool output available. No tool has produced truncated output yet."
569
+ try:
570
+ lines = out_file.read_text(encoding="utf-8", errors="replace").splitlines()
571
+ except Exception as e:
572
+ return f"Error reading saved output: {e}"
573
+
574
+ total = len(lines)
575
+ if total == 0:
576
+ return "Saved tool output is empty."
577
+
578
+ # No pattern → summary mode
579
+ if not pattern:
580
+ preview_n = 30
581
+ head = lines[:preview_n]
582
+ tail = lines[-preview_n:] if total > preview_n * 2 else []
583
+ parts = [f"[Last tool output: {total} lines]"]
584
+ parts.append("── First {0} lines ──".format(min(preview_n, total)))
585
+ for i, l in enumerate(head):
586
+ parts.append(f"{i + 1:6}\t{l}")
587
+ if tail:
588
+ parts.append(f"\n── Last {preview_n} lines ──")
589
+ start = total - preview_n
590
+ for i, l in enumerate(tail):
591
+ parts.append(f"{start + i + 1:6}\t{l}")
592
+ return "\n".join(parts)
593
+
594
+ # Pattern mode → search with context
595
+ import re as _re
596
+ try:
597
+ rx = _re.compile(pattern, _re.IGNORECASE)
598
+ except _re.error as e:
599
+ return f"Invalid regex: {e}"
600
+
601
+ matches = []
602
+ for i, line in enumerate(lines):
603
+ if rx.search(line):
604
+ start = max(0, i - context)
605
+ end = min(total, i + context + 1)
606
+ block = []
607
+ for j in range(start, end):
608
+ marker = ">>>" if j == i else " "
609
+ block.append(f"{marker} {j + 1:6}\t{lines[j]}")
610
+ matches.append("\n".join(block))
611
+
612
+ if not matches:
613
+ return f"No matches for '{pattern}' in {total} lines of saved output."
614
+
615
+ header = f"[Found {len(matches)} match(es) in {total} lines]"
616
+ # Cap output to avoid blowing up context
617
+ result = header + "\n\n" + "\n---\n".join(matches)
618
+ if len(result) > 16000:
619
+ result = result[:16000] + f"\n\n... (output capped — {len(matches)} total matches, refine your pattern)"
620
+
621
+ # SAVE filtered result as new last_output so PrintLastOutput can display it
622
+ try:
623
+ out_file.write_text(result, encoding="utf-8")
624
+ except Exception:
625
+ pass # Silently fail if can't write
626
+
627
+ return result
628
+
629
+
630
+ def _write(file_path: str, content: str) -> str:
631
+ p = Path(file_path)
632
+ try:
633
+ is_new = not p.exists()
634
+ # Ensure utf-8 and newline="" for reading existing content to generate diff
635
+ old_content = "" if is_new else p.read_text(encoding="utf-8", errors="replace", newline="")
636
+ p.parent.mkdir(parents=True, exist_ok=True)
637
+ # Always write as utf-8 with newline="" to prevent double CRLF on Windows
638
+ p.write_text(content, encoding="utf-8", newline="")
639
+ if is_new:
640
+ lc = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
641
+ return f"Created {file_path} ({lc} lines)"
642
+ filename = p.name
643
+ diff = generate_unified_diff(old_content, content, filename)
644
+ if not diff:
645
+ return f"No changes in {file_path}"
646
+ truncated = maybe_truncate_diff(diff)
647
+ return f"File updated — {file_path}:\n\n{truncated}"
648
+ except Exception as e:
649
+ return f"Error: {e}"
650
+
651
+
652
+ def _edit(file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
653
+ p = Path(file_path)
654
+ if not p.exists():
655
+ return f"Error: file not found: {file_path}"
656
+ try:
657
+ # Read with newline="" to get original line endings
658
+ content = p.read_text(encoding="utf-8", errors="replace", newline="")
659
+
660
+ # Detect original line endings: only treat as pure CRLF if every \n is part of \r\n
661
+ crlf_count = content.count("\r\n")
662
+ lf_count = content.count("\n")
663
+ is_pure_crlf = crlf_count > 0 and crlf_count == lf_count
664
+
665
+ # Normalize line endings to avoid \r\n vs \n mismatch during matching
666
+ content_norm = content.replace("\r\n", "\n")
667
+ old_norm = old_string.replace("\r\n", "\n")
668
+ new_norm = new_string.replace("\r\n", "\n")
669
+
670
+ count = content_norm.count(old_norm)
671
+ if count == 0:
672
+ return "Error: old_string not found in file. Please ensure EXACT match, including all exact leading spaces/indentation and trailing newlines."
673
+ if count > 1 and not replace_all:
674
+ return (f"Error: old_string appears {count} times. "
675
+ "Provide more context to make it unique, or use replace_all=true.")
676
+
677
+ old_content_norm = content_norm
678
+ new_content_norm = content_norm.replace(old_norm, new_norm) if replace_all else \
679
+ content_norm.replace(old_norm, new_norm, 1)
680
+
681
+ # Restore CRLF only for pure-CRLF files; mixed or LF-only files stay as LF
682
+ if is_pure_crlf:
683
+ final_content = new_content_norm.replace("\n", "\r\n")
684
+ old_content_final = content
685
+ else:
686
+ final_content = new_content_norm
687
+ old_content_final = content_norm
688
+
689
+ # Write with newline="" to prevent double CRLF translation on Windows
690
+ p.write_text(final_content, encoding="utf-8", newline="")
691
+ filename = p.name
692
+ diff = generate_unified_diff(old_content_final, final_content, filename)
693
+ return f"Changes applied to {filename}:\n\n{diff}"
694
+ except Exception as e:
695
+ return f"Error: {e}"
696
+
697
+
698
+ def _kill_proc_tree(pid: int):
699
+ """Kill a process and all its children."""
700
+ import sys as _sys
701
+ if _sys.platform == "win32":
702
+ # taskkill /T kills the entire process tree on Windows
703
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
704
+ capture_output=True)
705
+ else:
706
+ import signal
707
+ try:
708
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
709
+ except (ProcessLookupError, PermissionError):
710
+ try:
711
+ os.kill(pid, signal.SIGKILL)
712
+ except (ProcessLookupError, PermissionError):
713
+ pass
714
+
715
+
716
+ def _find_windows_bash():
717
+ """Return (kind, path) for the best bash available on Windows, or None."""
718
+ import shutil
719
+ if not hasattr(_find_windows_bash, "_cache"):
720
+ result = None
721
+ # 1. bash already in PATH (Git for Windows added to PATH, MSYS2, etc.)
722
+ bash_in_path = shutil.which("bash")
723
+ if bash_in_path:
724
+ # Skip WSL bash stub disguised as native bash
725
+ if "system32" not in bash_in_path.lower() and "sysnative" not in bash_in_path.lower() and "syswow64" not in bash_in_path.lower():
726
+ result = ("gitbash", bash_in_path)
727
+ # 2. Git Bash at default install locations
728
+ if result is None:
729
+ for candidate in [
730
+ r"C:\Program Files\Git\bin\bash.exe",
731
+ r"C:\Program Files (x86)\Git\bin\bash.exe",
732
+ ]:
733
+ if Path(candidate).exists():
734
+ result = ("gitbash", candidate)
735
+ break
736
+ # 3. WSL
737
+ if result is None:
738
+ wsl = shutil.which("wsl")
739
+ if wsl:
740
+ try:
741
+ r = subprocess.run(["wsl", "echo", "ok"],
742
+ capture_output=True, text=True, timeout=5)
743
+ if r.returncode == 0:
744
+ result = ("wsl", wsl)
745
+ except Exception:
746
+ pass
747
+ _find_windows_bash._cache = result
748
+ return _find_windows_bash._cache
749
+
750
+
751
+ def _find_shell_by_type(shell_type: str, forced_path: str = ""):
752
+ """Find a specific shell type on Windows. Returns (kind, path) or None."""
753
+ import shutil
754
+
755
+ # Handle custom shell with forced path
756
+ if shell_type == "custom" and forced_path and Path(forced_path).exists():
757
+ return ("custom", forced_path)
758
+
759
+ if shell_type == "gitbash":
760
+ # Try bash in PATH first (but not WSL stub)
761
+ bash_in_path = shutil.which("bash")
762
+ if bash_in_path:
763
+ if "system32" not in bash_in_path.lower() and "sysnative" not in bash_in_path.lower() and "syswow64" not in bash_in_path.lower():
764
+ return ("gitbash", bash_in_path)
765
+ # Try default Git locations
766
+ for candidate in [
767
+ r"C:\Program Files\Git\bin\bash.exe",
768
+ r"C:\Program Files (x86)\Git\bin\bash.exe",
769
+ ]:
770
+ if Path(candidate).exists():
771
+ return ("gitbash", candidate)
772
+
773
+ elif shell_type == "wsl":
774
+ wsl = shutil.which("wsl")
775
+ if wsl:
776
+ try:
777
+ r = subprocess.run(["wsl", "echo", "ok"],
778
+ capture_output=True, text=True, timeout=5)
779
+ if r.returncode == 0:
780
+ return ("wsl", wsl)
781
+ except Exception:
782
+ pass
783
+
784
+ elif shell_type == "powershell":
785
+ # Try PowerShell Core first, then Windows PowerShell
786
+ candidates = [
787
+ shutil.which("pwsh"), # PowerShell Core
788
+ shutil.which("powershell"),
789
+ r"C:\Program Files\PowerShell\7\pwsh.exe",
790
+ r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
791
+ ]
792
+ for candidate in candidates:
793
+ if candidate and Path(candidate).exists():
794
+ return ("powershell", candidate)
795
+
796
+ elif shell_type == "cmd":
797
+ cmd = shutil.which("cmd") or r"C:\Windows\System32\cmd.exe"
798
+ if Path(cmd).exists():
799
+ return ("cmd", cmd)
800
+
801
+ return None
802
+
803
+
804
+ def _win_to_posix(path_str: str, wsl: bool = False) -> str:
805
+ """Convert a Windows path string to POSIX for bash/WSL.
806
+ C:\\Users\\foo → /c/Users/foo (gitbash)
807
+ C:\\Users\\foo → /mnt/c/Users/foo (wsl)
808
+ """
809
+ import re
810
+ def _replace(m):
811
+ drive = m.group(1).lower()
812
+ rest = m.group(2).replace("\\", "/")
813
+ prefix = f"/mnt/{drive}" if wsl else f"/{drive}"
814
+ return prefix + "/" + rest
815
+ return re.sub(r"(?<![A-Za-z])([A-Za-z]):[\\/]([^'\";\n]*)", _replace, path_str)
816
+
817
+
818
+ # ── Bash sandbox: blocked dangerous command patterns ─────────────────────
819
+ _BASH_BLOCKED_PATTERNS: list[str] = [
820
+ # rm -rf targeting system / home
821
+ r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+/(?:\s*;|\s*&&|\s*\|\||\s*$)",
822
+ r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+/\*",
823
+ r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+~",
824
+ # Disk destruction
825
+ r"dd\s+.*of=/dev/[sh]d[a-z]",
826
+ r"dd\s+.*of=/dev/nvme",
827
+ r"dd\s+.*of=/dev/mmcblk",
828
+ r">\s*/dev/[sh]d[a-z]",
829
+ r">\s*/dev/nvme",
830
+ # Formatters
831
+ r"mkfs\.\w+\s+/dev/",
832
+ r"mkfs\s+/dev/",
833
+ r"fdisk\s+/dev/",
834
+ r"parted\s+/dev/",
835
+ # Permission destruction
836
+ r"chmod\s+-[a-zA-Z]*R[a-zA-Z]*\s+777\s+/",
837
+ # Fork bomb
838
+ r":\s*\(\s*\)\s*\{\s*:\s*\|:\s*&\s*\}\s*;\s*:",
839
+ # Curl/wget pipe-to-shell
840
+ r"curl\s+.*\|\s*(?:bash|sh|zsh|fish)",
841
+ r"wget\s+.*\|\s*(?:bash|sh|zsh|fish)",
842
+ # Sensitive file reads
843
+ r"cat\s+.*(?:/etc/shadow|/etc/gshadow|/etc/master\.passwd)",
844
+ # Data exfiltration via curl
845
+ r"curl\s+.*(?:--data|@-|-d\s+@)",
846
+ r"curl\s+.*-T\s+\S+",
847
+ # Backdoor-ish one-liners
848
+ r"bash\s+-i\s+>&\s*/dev/tcp/",
849
+ r"sh\s+-i\s+>&\s*/dev/tcp/",
850
+ r"python\s+-c\s+.*socket.*subprocess",
851
+ r"python3\s+-c\s+.*socket.*subprocess",
852
+ # System-wide kills
853
+ r"kill\s+-9\s+-1",
854
+ r"killall\s+-9",
855
+ r"pkill\s+-9",
856
+ # Mount manipulation
857
+ r"mount\s+-o\s+remount",
858
+ r"umount\s+/",
859
+ # History wiping
860
+ r"history\s+-c",
861
+ r"cat\s+/dev/null\s*>\s*~/\.bash_history",
862
+ r">\s*~/\.bash_history",
863
+ ]
864
+
865
+
866
+ def _is_bash_safe(command: str) -> tuple[bool, str]:
867
+ """Check if a bash command passes the safety filter.
868
+
869
+ Returns (is_safe, reason_if_unsafe).
870
+ """
871
+ cmd_lower = command.lower().strip()
872
+ for pattern in _BASH_BLOCKED_PATTERNS:
873
+ if re.search(pattern, cmd_lower):
874
+ return False, f"Blocked dangerous pattern: {pattern[:60]}..."
875
+ return True, ""
876
+
877
+
878
+ # ── RTK (Rust Token Killer) integration ──────────────────────────────────
879
+ # Transparently rewrites covered commands (ls, grep, git, find, diff, read…)
880
+ # via `rtk rewrite` so model-issued commands always emit token-optimized
881
+ # output. Soft-fallback: missing binary, disabled flag, or rewrite failure
882
+ # all leave the command unchanged.
883
+
884
+ _rtk_binary_cache: Optional[str] = None
885
+ _rtk_binary_resolved = False
886
+
887
+
888
+ def _rtk_binary() -> Optional[str]:
889
+ global _rtk_binary_cache, _rtk_binary_resolved
890
+ if _rtk_binary_resolved:
891
+ return _rtk_binary_cache
892
+
893
+ import sys as _sys
894
+ import shutil as _shutil
895
+
896
+ here = Path(__file__).resolve().parent
897
+ name = "rtk.exe" if _sys.platform == "win32" else "rtk"
898
+ candidates = [here / "rtk" / name]
899
+ if _sys.platform != "win32":
900
+ candidates.append(Path.home() / ".local" / "bin" / "rtk")
901
+
902
+ for c in candidates:
903
+ if c.exists() and c.is_file():
904
+ _rtk_binary_cache = str(c)
905
+ _rtk_binary_resolved = True
906
+ return _rtk_binary_cache
907
+
908
+ _rtk_binary_cache = _shutil.which(name)
909
+ _rtk_binary_resolved = True
910
+ return _rtk_binary_cache
911
+
912
+
913
+ def _rtk_enabled() -> bool:
914
+ if not load_config:
915
+ return False
916
+ try:
917
+ return bool(load_config().get("rtk_enabled", False))
918
+ except Exception:
919
+ return False
920
+
921
+
922
+ def _ensure_rtk_in_path() -> None:
923
+ """Add the bundled rtk binary's directory to PATH so subshells resolve `rtk`.
924
+
925
+ Idempotent: re-checks PATH each call (flag may flip at runtime).
926
+ """
927
+ if not _rtk_enabled():
928
+ return
929
+ binary = _rtk_binary()
930
+ if not binary:
931
+ return
932
+ rtk_dir = str(Path(binary).parent)
933
+ current = os.environ.get("PATH", "")
934
+ if rtk_dir not in current.split(os.pathsep):
935
+ os.environ["PATH"] = rtk_dir + os.pathsep + current
936
+
937
+
938
+ def _rtk_wrap_cmd(cmd: list) -> list:
939
+ """Prepend the rtk binary so a subprocess argv list runs through rtk.
940
+
941
+ Used by tools that shell out directly via subprocess (GitStatus/Log/Diff,
942
+ Grep). For RTK-supported subcommands (git, grep, ls, find, diff, log, …)
943
+ this gets you token-optimized output; unsupported commands pass through.
944
+ Soft-fallback: returns cmd unchanged when rtk is disabled or missing.
945
+ """
946
+ if not _rtk_enabled() or not cmd:
947
+ return cmd
948
+ binary = _rtk_binary()
949
+ if not binary:
950
+ return cmd
951
+ return [binary, *cmd]
952
+
953
+
954
+ def _maybe_rewrite_with_rtk(command: str) -> str:
955
+ if not _rtk_enabled():
956
+ return command
957
+ binary = _rtk_binary()
958
+ if not binary:
959
+ return command
960
+ try:
961
+ r = subprocess.run(
962
+ [binary, "rewrite", command],
963
+ capture_output=True, text=True,
964
+ encoding="utf-8", errors="replace", timeout=5,
965
+ )
966
+ rewritten = (r.stdout or "").strip()
967
+ if rewritten:
968
+ return rewritten
969
+ except Exception:
970
+ pass
971
+ return command
972
+
973
+
974
+ def _bash(command: str, timeout: int = 30) -> str:
975
+ import sys as _sys
976
+ import shutil
977
+
978
+ # ── Sandbox check ──
979
+ safe, reason = _is_bash_safe(command)
980
+ if not safe:
981
+ return f"[SANDBOX BLOCKED] {reason}\n\nCommand: {command[:200]}"
982
+
983
+ # ── RTK transparent rewrite (token-optimized output) ──
984
+ _ensure_rtk_in_path()
985
+ command = _maybe_rewrite_with_rtk(command)
986
+
987
+
988
+
989
+ # Load shell configuration
990
+ shell_cfg = {"type": "auto", "path": ""}
991
+ if load_config:
992
+ try:
993
+ cfg = load_config()
994
+ shell_cfg.update(cfg.get("shell", {}))
995
+ except Exception:
996
+ pass
997
+
998
+ cwd = os.getcwd()
999
+
1000
+ if _sys.platform == "win32":
1001
+ shell_type = shell_cfg.get("type", "auto")
1002
+ forced_path = shell_cfg.get("path", "")
1003
+
1004
+ # Determine shell to use
1005
+ if shell_type == "auto":
1006
+ shell_info = _find_windows_bash()
1007
+ elif shell_type == "custom" and forced_path and Path(forced_path).exists():
1008
+ # Custom shell with explicit path
1009
+ shell_info = ("custom", forced_path)
1010
+ elif forced_path and Path(forced_path).exists():
1011
+ # User forced a specific shell path with known type
1012
+ shell_info = (shell_type, forced_path)
1013
+ else:
1014
+ # Try to find the specified shell type
1015
+ shell_info = _find_shell_by_type(shell_type, forced_path)
1016
+
1017
+ if shell_info:
1018
+ kind, path = shell_info
1019
+ import time; time.sleep(0.5) # Small stabilization delay for Windows shells
1020
+ if kind == "gitbash":
1021
+ posix_cwd = _win_to_posix(cwd)
1022
+ args = [path, "-c", f"cd {posix_cwd!r} && {command}"]
1023
+ kwargs = dict(shell=False, stdout=subprocess.PIPE,
1024
+ stderr=subprocess.PIPE, text=True,
1025
+ encoding='utf-8', errors='replace')
1026
+ elif kind == "wsl":
1027
+ posix_cwd = _win_to_posix(cwd, wsl=True)
1028
+ args = ["wsl", "--", "bash", "-c",
1029
+ f"cd {posix_cwd!r} && {command}"]
1030
+ kwargs = dict(shell=False, stdout=subprocess.PIPE,
1031
+ stderr=subprocess.PIPE, text=True,
1032
+ encoding='utf-8', errors='replace')
1033
+ elif kind == "powershell":
1034
+ # PowerShell execution
1035
+ args = [path, "-NoProfile", "-Command", f"cd '{cwd}'; {command}"]
1036
+ kwargs = dict(shell=False, stdout=subprocess.PIPE,
1037
+ stderr=subprocess.PIPE, text=True,
1038
+ encoding='utf-8', errors='replace')
1039
+ elif kind == "cmd":
1040
+ # CMD execution
1041
+ args = [path, "/c", f"cd /d {cwd} && {command}"]
1042
+ kwargs = dict(shell=False, stdout=subprocess.PIPE,
1043
+ stderr=subprocess.PIPE, text=True,
1044
+ encoding='utf-8', errors='replace')
1045
+ elif kind == "custom":
1046
+ # Custom shell - try to be smart about the command format
1047
+ # Most shells accept -c for commands, but we'll try different approaches
1048
+ cmd_lower = command.lower().strip()
1049
+ # Check if it looks like a Windows command (uses Windows paths, backslashes, etc.)
1050
+ looks_like_windows = (
1051
+ '\\' in command or
1052
+ 'dir ' in cmd_lower or
1053
+ 'echo %' in cmd_lower or
1054
+ '.exe' in cmd_lower or
1055
+ 'C:' in command or
1056
+ 'D:' in command
1057
+ )
1058
+ if looks_like_windows:
1059
+ # Treat as Windows command - pass to shell's -c
1060
+ args = [path, "-c", command]
1061
+ else:
1062
+ # Treat as Unix-style command
1063
+ args = [path, "-c", command]
1064
+ kwargs = dict(shell=False, stdout=subprocess.PIPE,
1065
+ stderr=subprocess.PIPE, text=True,
1066
+ encoding='utf-8', errors='replace', cwd=cwd)
1067
+ else:
1068
+ # Fallback to shell=True with system default
1069
+ args = command
1070
+ kwargs = dict(shell=True, stdout=subprocess.PIPE,
1071
+ stderr=subprocess.PIPE, text=True,
1072
+ encoding='utf-8', errors='replace', cwd=cwd)
1073
+ else:
1074
+ # No shell found, use system default
1075
+ args = command
1076
+ kwargs = dict(shell=True, stdout=subprocess.PIPE,
1077
+ stderr=subprocess.PIPE, text=True,
1078
+ encoding='utf-8', errors='replace', cwd=cwd)
1079
+ else:
1080
+ # Unix/Linux/Mac - use configured shell or default
1081
+ forced_path = shell_cfg.get("path", "")
1082
+ if forced_path and Path(forced_path).exists():
1083
+ args = [forced_path, "-c", command]
1084
+ kwargs = dict(shell=False, stdout=subprocess.PIPE,
1085
+ stderr=subprocess.PIPE, text=True,
1086
+ encoding='utf-8', errors='replace', cwd=cwd)
1087
+ else:
1088
+ args = command
1089
+ kwargs = dict(shell=True, stdout=subprocess.PIPE,
1090
+ stderr=subprocess.PIPE, text=True,
1091
+ encoding='utf-8', errors='replace',
1092
+ cwd=cwd, start_new_session=True)
1093
+
1094
+ try:
1095
+ proc = subprocess.Popen(args, **kwargs)
1096
+ try:
1097
+ stdout, stderr = proc.communicate(timeout=timeout)
1098
+ except subprocess.TimeoutExpired:
1099
+ _kill_proc_tree(proc.pid)
1100
+ proc.wait()
1101
+ return f"Error: timed out after {timeout}s (process killed)"
1102
+ out = stdout
1103
+ if stderr:
1104
+ # Strip rtk hook-status warnings (noise — already rate-limited by rtk to 1x/day)
1105
+ stderr = "\n".join(
1106
+ ln for ln in stderr.splitlines()
1107
+ if "[rtk]" not in ln or "hook" not in ln.lower()
1108
+ ).strip()
1109
+ if stderr:
1110
+ out += ("\n" if out else "") + "[stderr]\n" + stderr
1111
+ return out.strip() or "(no output)"
1112
+ except Exception as e:
1113
+ return f"Error: {e}"
1114
+
1115
+
1116
+ def _glob(pattern: str, path: str = None) -> str:
1117
+ # pathlib's Path.glob() rejects absolute patterns ("Non-relative patterns
1118
+ # are unsupported"). If the model passes an absolute pattern, split it
1119
+ # into the longest non-glob prefix (base) + the rest (relative pattern).
1120
+ p = Path(pattern)
1121
+ if p.is_absolute() or any(c in pattern for c in (":\\", ":/")):
1122
+ parts = p.parts
1123
+ split_idx = len(parts)
1124
+ for i, part in enumerate(parts):
1125
+ if any(ch in part for ch in "*?["):
1126
+ split_idx = i
1127
+ break
1128
+ base = Path(*parts[:split_idx]) if split_idx > 0 else Path(p.anchor)
1129
+ rel_pattern = str(Path(*parts[split_idx:])) if split_idx < len(parts) else "*"
1130
+ if path:
1131
+ base = Path(path)
1132
+ else:
1133
+ base = Path(path) if path else Path.cwd()
1134
+ rel_pattern = pattern
1135
+ try:
1136
+ matches = sorted(base.glob(rel_pattern))
1137
+ if not matches:
1138
+ return "No files matched"
1139
+ return "\n".join(str(m) for m in matches[:500])
1140
+ except Exception as e:
1141
+ return f"Error: {e}"
1142
+
1143
+
1144
+ def _has_rg() -> bool:
1145
+ try:
1146
+ subprocess.run(["rg", "--version"], capture_output=True, check=True)
1147
+ return True
1148
+ except Exception:
1149
+ return False
1150
+
1151
+
1152
+ def _grep_python_pure(pattern: str, search_path: Path, glob_pat: str = None,
1153
+ output_mode: str = "files_with_matches",
1154
+ case_insensitive: bool = False, context: int = 0) -> str:
1155
+ """Pure-Python grep fallback for Windows or when grep/rg misbehave."""
1156
+ import re, fnmatch
1157
+ flags = re.IGNORECASE if case_insensitive else 0
1158
+ try:
1159
+ compiled = re.compile(pattern, flags)
1160
+ except re.error as e:
1161
+ return f"Error: invalid regex pattern: {e}"
1162
+
1163
+ results = []
1164
+ files_to_search = []
1165
+
1166
+ if search_path.is_file():
1167
+ files_to_search.append(search_path)
1168
+ elif search_path.is_dir():
1169
+ for root, _dirs, files in os.walk(search_path):
1170
+ for fname in files:
1171
+ if glob_pat and not fnmatch.fnmatch(fname, glob_pat):
1172
+ continue
1173
+ files_to_search.append(Path(root) / fname)
1174
+ else:
1175
+ return f"Error: path not found: {search_path}"
1176
+
1177
+ for fp in files_to_search:
1178
+ try:
1179
+ text = fp.read_text("utf-8", errors="replace")
1180
+ except Exception:
1181
+ continue
1182
+ lines = text.splitlines()
1183
+ file_results = []
1184
+ for i, line in enumerate(lines, start=1):
1185
+ if compiled.search(line):
1186
+ if output_mode == "files_with_matches":
1187
+ results.append(str(fp))
1188
+ break
1189
+ elif output_mode == "count":
1190
+ file_results.append(1)
1191
+ else:
1192
+ # content mode with optional context
1193
+ start_ctx = max(0, i - context - 1)
1194
+ end_ctx = min(len(lines), i + context)
1195
+ ctx_lines = lines[start_ctx:end_ctx]
1196
+ ctx_nums = list(range(start_ctx + 1, end_ctx + 1))
1197
+ for ln_num, ln_text in zip(ctx_nums, ctx_lines):
1198
+ marker = ":" if ln_num == i else "-"
1199
+ file_results.append(f"{fp}:{ln_num}{marker}{ln_text}")
1200
+ if output_mode == "count" and file_results:
1201
+ results.append(f"{fp}:{len(file_results)}")
1202
+ elif output_mode == "content" and file_results:
1203
+ results.extend(file_results)
1204
+
1205
+ if not results:
1206
+ return "No matches found"
1207
+ out = "\n".join(results)
1208
+ return out[:20000]
1209
+
1210
+
1211
+ def _grep(pattern: str, path: str = None, glob: str = None,
1212
+ output_mode: str = "files_with_matches",
1213
+ case_insensitive: bool = False, context: int = 0) -> str:
1214
+ # Guard against empty pattern (model sometimes passes it by mistake)
1215
+ if not pattern or not pattern.strip():
1216
+ return "Error: pattern is required and cannot be empty."
1217
+
1218
+ search_path = Path(path) if path else Path.cwd()
1219
+ if not search_path.exists():
1220
+ return f"Error: path not found: {search_path}"
1221
+
1222
+ use_rg = _has_rg()
1223
+ # On Windows without ripgrep, use pure Python to avoid path/quote hell
1224
+ if not use_rg and os.name == "nt":
1225
+ return _grep_python_pure(pattern, search_path, glob, output_mode, case_insensitive, context)
1226
+
1227
+ cmd = ["rg" if use_rg else "grep"]
1228
+ if use_rg:
1229
+ cmd.append("--no-heading")
1230
+ if case_insensitive:
1231
+ cmd.append("-i")
1232
+ if output_mode == "files_with_matches":
1233
+ cmd.append("-l")
1234
+ elif output_mode == "count":
1235
+ cmd.append("-c")
1236
+ else:
1237
+ cmd.append("-n")
1238
+ if context:
1239
+ cmd += ["-C", str(context)]
1240
+ if glob:
1241
+ cmd += (["--glob", glob] if use_rg else ["--include", glob])
1242
+ # grep needs -r for directories (rg handles both automatically)
1243
+ if not use_rg and search_path.is_dir():
1244
+ cmd.append("-r")
1245
+ cmd.append(pattern)
1246
+ cmd.append(str(search_path))
1247
+ try:
1248
+ r = subprocess.run(_rtk_wrap_cmd(cmd), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30)
1249
+ if r.returncode != 0 and r.returncode != 1:
1250
+ err = r.stderr.strip() if r.stderr else f"exit code {r.returncode}"
1251
+ # If grep choked on path/regex, fall back to pure Python
1252
+ if "No such file" in err or "Is a directory" in err or "invalid regular expression" in err.lower():
1253
+ return _grep_python_pure(pattern, search_path, glob, output_mode, case_insensitive, context)
1254
+ return f"Error: {err}"
1255
+ out = r.stdout.strip()
1256
+ return out[:20000] if out else "No matches found"
1257
+ except Exception as e:
1258
+ return _grep_python_pure(pattern, search_path, glob, output_mode, case_insensitive, context)
1259
+
1260
+
1261
+
1262
+
1263
+ def _libretranslate_host() -> str:
1264
+ """Return the best LibreTranslate host URL.
1265
+ In WSL2, localhost points to the WSL VM — use the Windows host IP instead
1266
+ (read from /etc/resolv.conf nameserver line).
1267
+ Falls back to localhost if not in WSL or can't parse."""
1268
+ try:
1269
+ from pathlib import Path as _P
1270
+ resolv = _P("/etc/resolv.conf")
1271
+ if resolv.exists():
1272
+ for line in resolv.read_text().splitlines():
1273
+ if line.startswith("nameserver"):
1274
+ ip = line.split()[1].strip()
1275
+ return f"http://{ip}:5000"
1276
+ except Exception:
1277
+ pass
1278
+ return "http://localhost:5000"
1279
+
1280
+
1281
+ def _clean_html(html: str) -> str:
1282
+ """Extract content text from HTML — only meaningful tags, strips noise."""
1283
+ try:
1284
+ from bs4 import BeautifulSoup
1285
+ soup = BeautifulSoup(html, "html.parser")
1286
+
1287
+ # Remove noise tags entirely
1288
+ for junk in soup(["script", "style", "header", "footer", "nav", "aside", "form"]):
1289
+ junk.decompose()
1290
+
1291
+ # Get all remaining text content
1292
+ text = soup.get_text(separator=" ")
1293
+
1294
+ # Clean up horizontal whitespace but preserve double newlines for structure
1295
+ lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines()]
1296
+ return "\n".join(line for line in lines if line)
1297
+ except Exception:
1298
+ return html[:5000] # Fallback to raw-ish if soup fails
1299
+
1300
+
1301
+ def _libretranslate(text: str, source: str, target: str,
1302
+ host: str = None) -> str | None:
1303
+ """Translate via LibreTranslate (local). Returns None if unavailable.
1304
+ Splits into 800-char chunks to stay within API limits."""
1305
+ host = host or _libretranslate_host()
1306
+ try:
1307
+ import httpx
1308
+ chunks, out = [], []
1309
+ for i in range(0, len(text), 800):
1310
+ chunks.append(text[i:i+800])
1311
+ for chunk in chunks:
1312
+ # LibreTranslate expects multipart/form-data, not JSON
1313
+ payload = {"q": chunk, "source": source, "target": target,
1314
+ "format": "text"}
1315
+ _lt_key = os.environ.get("LIBRETRANSLATE_API_KEY")
1316
+ if _lt_key:
1317
+ payload["api_key"] = _lt_key
1318
+ r = httpx.post(f"{host}/translate", data=payload, timeout=15)
1319
+ if r.status_code != 200:
1320
+ return None
1321
+ out.append(r.json().get("translatedText", chunk))
1322
+ return "".join(out)
1323
+ except Exception:
1324
+ return None
1325
+
1326
+
1327
+ def _libretranslate_available() -> bool:
1328
+ host = _libretranslate_host()
1329
+ try:
1330
+ import httpx
1331
+ r = httpx.get(f"{host}/languages", timeout=3)
1332
+ return r.status_code == 200
1333
+ except Exception:
1334
+ return False
1335
+
1336
+
1337
+ def _webfetch(url: str) -> str:
1338
+ """Fetch URL → plain text.
1339
+ """
1340
+ try:
1341
+ from pathlib import Path
1342
+
1343
+ # ── Fetch ──────────────────────────────────────────────────────────
1344
+ if url.startswith("file://"):
1345
+ fp = Path(url[7:])
1346
+ if not fp.exists():
1347
+ return f"Error: Local file not found: {url[7:]}"
1348
+ text = fp.read_text(encoding="utf-8", errors="replace")
1349
+ else:
1350
+ import requests
1351
+ r = requests.get(url, headers={
1352
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
1353
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1354
+ "Accept-Language": "en-US,en;q=0.9",
1355
+ "Connection": "keep-alive",
1356
+ "Upgrade-Insecure-Requests": "1",
1357
+ }, timeout=30, allow_redirects=True)
1358
+ r.raise_for_status()
1359
+
1360
+ # Ensure proper encoding
1361
+ if r.encoding is None or r.encoding == 'ISO-8859-1':
1362
+ r.encoding = r.apparent_encoding
1363
+
1364
+ text = r.text
1365
+ ct = r.headers.get("content-type", "").lower()
1366
+ if "html" in ct:
1367
+ text = _clean_html(text)
1368
+
1369
+
1370
+ # ── Normal path ────────────────────────────────────────────────────
1371
+ return text[:25000]
1372
+
1373
+ except ImportError:
1374
+ return "Error: httpx not installed — run: pip install httpx"
1375
+ except Exception as e:
1376
+ return f"Error: {e}"
1377
+
1378
+
1379
+ def _bravesearch(query: str, api_key: str, country: str = None) -> str:
1380
+ """Search using Brave Search API."""
1381
+ try:
1382
+ import requests
1383
+ url = "https://api.search.brave.com/res/v1/web/search"
1384
+ headers = {
1385
+ "Accept": "application/json",
1386
+ "Accept-Encoding": "gzip",
1387
+ "X-Subscription-Token": api_key
1388
+ }
1389
+ params = {"q": query}
1390
+ if country:
1391
+ params["country"] = country.strip().lower()
1392
+
1393
+ r = requests.get(url, params=params, headers=headers, timeout=30)
1394
+ if r.status_code != 200:
1395
+ return f"Error: Brave Search API returned {r.status_code}: {r.text[:200]}"
1396
+
1397
+ data = r.json()
1398
+ results = []
1399
+ # Brave Search API returns results in 'web.results'
1400
+ for res in data.get("web", {}).get("results", [])[:10]:
1401
+ title = res.get("title", "")
1402
+ href = res.get("url", "")
1403
+ desc = res.get("description", "")
1404
+ if title and href:
1405
+ results.append(f"{title}\n{href}\n{desc}")
1406
+
1407
+ return "\n\n".join(results[:8]) if results else "No results found"
1408
+ except Exception as e:
1409
+ return f"Error: Brave Search failed: {e}"
1410
+
1411
+
1412
+ def _websearch(query: str, config: dict = None, region: str = None) -> str:
1413
+ try:
1414
+ import requests
1415
+ from bs4 import BeautifulSoup
1416
+ from urllib.parse import unquote, urlparse, parse_qs
1417
+
1418
+ # Determine region (priority: tool call param > config > None)
1419
+ active_region = region or (config.get("search_region") if config else None)
1420
+
1421
+ # ── Brave Search Fallback ───────────────────────────────────────────────
1422
+ if config and config.get("brave_search_enabled") and config.get("brave_search_key"):
1423
+ # Brave uses 2-letter country code (e.g. 'do', 'us', 'mx')
1424
+ cc = active_region.split("-")[0] if active_region else None
1425
+ return _bravesearch(query, config["brave_search_key"], country='ALL')
1426
+
1427
+ # User-provided stealth headers (Firefox 150 style)
1428
+ headers = {
1429
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0",
1430
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1431
+ "Accept-Language": "en-US,en;q=0.9",
1432
+ "Connection": "keep-alive",
1433
+ "Upgrade-Insecure-Requests": "1",
1434
+ "Sec-Fetch-Dest": "document",
1435
+ "Sec-Fetch-Mode": "navigate",
1436
+ "Sec-Fetch-Site": "none",
1437
+ "Sec-Fetch-User": "?1",
1438
+ "DNT": "1",
1439
+ }
1440
+
1441
+ # Try HTML POST version first
1442
+ url = "https://html.duckduckgo.com/html/"
1443
+ data = {"q": query}
1444
+ if active_region:
1445
+ data["kl"] = active_region # DDG uses codes like 'do-es', 'us-en'
1446
+
1447
+ r = requests.post(url, headers=headers, data=data, timeout=30)
1448
+
1449
+ # If challenged (202), fallback to Lite GET version
1450
+ if r.status_code == 202:
1451
+ lite_url = f"https://duckduckgo.com/lite/?q={requests.utils.quote(query)}"
1452
+ if active_region:
1453
+ lite_url += f"&kl={active_region}"
1454
+ r = requests.get(lite_url, headers=headers, timeout=30)
1455
+
1456
+ if r.status_code != 200:
1457
+ return f"Error: HTTP {r.status_code}"
1458
+
1459
+ soup = BeautifulSoup(r.text, "html.parser")
1460
+ results = []
1461
+
1462
+ # Parse results (selectors differ slightly between html and lite, but .result__a is common)
1463
+ for link in soup.select("a.result__a")[:10]:
1464
+ href = link.get("href", "")
1465
+ title = link.get_text(strip=True)
1466
+ if not href or not title or len(title) < 3:
1467
+ continue
1468
+
1469
+ if "uddg=" in href:
1470
+ parsed = urlparse(href)
1471
+ qs = parse_qs(parsed.query)
1472
+ real_urls = qs.get("uddg", [])
1473
+ if real_urls:
1474
+ href = unquote(real_urls[0])
1475
+
1476
+ if "duckduckgo.com" in href and "uddg" not in href:
1477
+ continue
1478
+
1479
+ results.append(f"{title}\n{href}")
1480
+
1481
+ return "\n\n".join(results[:8]) if results else "No results found"
1482
+ except ImportError as e:
1483
+ return f"Error: {e} — run: pip install requests beautifulsoup4"
1484
+ except Exception as e:
1485
+ return f"Error: {e}"
1486
+
1487
+
1488
+ # ── NotebookEdit implementation ────────────────────────────────────────────
1489
+
1490
+ def _parse_cell_id(cell_id: str) -> int | None:
1491
+ """Convert 'cell-N' shorthand to integer index; return None if not that form."""
1492
+ m = re.fullmatch(r"cell-(\d+)", cell_id)
1493
+ return int(m.group(1)) if m else None
1494
+
1495
+
1496
+ def _notebook_edit(
1497
+ notebook_path: str,
1498
+ new_source: str,
1499
+ cell_id: str = None,
1500
+ cell_type: str = None,
1501
+ edit_mode: str = "replace",
1502
+ ) -> str:
1503
+ p = Path(notebook_path)
1504
+ if p.suffix != ".ipynb":
1505
+ return "Error: file must be a Jupyter notebook (.ipynb)"
1506
+ if not p.exists():
1507
+ return f"Error: notebook not found: {notebook_path}"
1508
+
1509
+ try:
1510
+ nb = json.loads(p.read_text(encoding="utf-8"))
1511
+ except json.JSONDecodeError as e:
1512
+ return f"Error: notebook is not valid JSON: {e}"
1513
+
1514
+ cells = nb.get("cells", [])
1515
+
1516
+ # Resolve cell index
1517
+ def _resolve_index(cid: str) -> int | None:
1518
+ # Try exact id match first
1519
+ for i, c in enumerate(cells):
1520
+ if c.get("id") == cid:
1521
+ return i
1522
+ # Fallback: cell-N
1523
+ idx = _parse_cell_id(cid)
1524
+ if idx is not None and 0 <= idx < len(cells):
1525
+ return idx
1526
+ return None
1527
+
1528
+ if edit_mode == "replace":
1529
+ if not cell_id:
1530
+ return "Error: cell_id is required for replace"
1531
+ idx = _resolve_index(cell_id)
1532
+ if idx is None:
1533
+ return f"Error: cell '{cell_id}' not found"
1534
+ target = cells[idx]
1535
+ target["source"] = new_source
1536
+ if cell_type and cell_type != target.get("cell_type"):
1537
+ target["cell_type"] = cell_type
1538
+ if target.get("cell_type") == "code":
1539
+ target["execution_count"] = None
1540
+ target["outputs"] = []
1541
+
1542
+ elif edit_mode == "insert":
1543
+ if not cell_type:
1544
+ return "Error: cell_type is required for insert ('code' or 'markdown')"
1545
+ # Determine nb format for cell ids
1546
+ nbformat = nb.get("nbformat", 4)
1547
+ nbformat_minor = nb.get("nbformat_minor", 0)
1548
+ use_ids = nbformat > 4 or (nbformat == 4 and nbformat_minor >= 5)
1549
+ new_id = None
1550
+ if use_ids:
1551
+ import random, string
1552
+ new_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
1553
+
1554
+ if cell_type == "markdown":
1555
+ new_cell = {"cell_type": "markdown", "source": new_source, "metadata": {}}
1556
+ else:
1557
+ new_cell = {
1558
+ "cell_type": "code",
1559
+ "source": new_source,
1560
+ "metadata": {},
1561
+ "execution_count": None,
1562
+ "outputs": [],
1563
+ }
1564
+ if use_ids and new_id:
1565
+ new_cell["id"] = new_id
1566
+
1567
+ if cell_id:
1568
+ idx = _resolve_index(cell_id)
1569
+ if idx is None:
1570
+ return f"Error: cell '{cell_id}' not found"
1571
+ cells.insert(idx + 1, new_cell)
1572
+ else:
1573
+ cells.insert(0, new_cell)
1574
+ nb["cells"] = cells
1575
+ cell_id = new_id or cell_id
1576
+
1577
+ elif edit_mode == "delete":
1578
+ if not cell_id:
1579
+ return "Error: cell_id is required for delete"
1580
+ idx = _resolve_index(cell_id)
1581
+ if idx is None:
1582
+ return f"Error: cell '{cell_id}' not found"
1583
+ cells.pop(idx)
1584
+ nb["cells"] = cells
1585
+ p.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
1586
+ return f"Deleted cell '{cell_id}' from {notebook_path}"
1587
+ else:
1588
+ return f"Error: unknown edit_mode '{edit_mode}' — use replace, insert, or delete"
1589
+
1590
+ nb["cells"] = cells
1591
+ p.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
1592
+ return f"NotebookEdit({edit_mode}) applied to cell '{cell_id}' in {notebook_path}"
1593
+
1594
+
1595
+ # ── GetDiagnostics implementation ──────────────────────────────────────────
1596
+
1597
+ def _detect_language(file_path: str) -> str:
1598
+ ext = Path(file_path).suffix.lower()
1599
+ return {
1600
+ ".py": "python",
1601
+ ".js": "javascript",
1602
+ ".mjs": "javascript",
1603
+ ".cjs": "javascript",
1604
+ ".ts": "typescript",
1605
+ ".tsx": "typescript",
1606
+ ".sh": "shellscript",
1607
+ ".bash": "shellscript",
1608
+ ".zsh": "shellscript",
1609
+ }.get(ext, "unknown")
1610
+
1611
+
1612
+ def _run_quietly(cmd: list[str], cwd: str | None = None, timeout: int = 30) -> tuple[int, str]:
1613
+ """Run a command, return (returncode, combined_output)."""
1614
+ try:
1615
+ r = subprocess.run(
1616
+ cmd, capture_output=True, text=True, timeout=timeout,
1617
+ cwd=cwd or os.getcwd(),
1618
+ )
1619
+ out = (r.stdout + ("\n" + r.stderr if r.stderr else "")).strip()
1620
+ return r.returncode, out
1621
+ except FileNotFoundError:
1622
+ return -1, f"(command not found: {cmd[0]})"
1623
+ except subprocess.TimeoutExpired:
1624
+ return -1, f"(timed out after {timeout}s)"
1625
+ except Exception as e:
1626
+ return -1, f"(error: {e})"
1627
+
1628
+
1629
+ def _get_diagnostics(file_path: str, language: str = None) -> str:
1630
+ p = Path(file_path)
1631
+ if not p.exists():
1632
+ return f"Error: file not found: {file_path}"
1633
+
1634
+ lang = language or _detect_language(file_path)
1635
+ abs_path = str(p.resolve())
1636
+ results: list[str] = []
1637
+
1638
+ if lang == "python":
1639
+ # Try pyright first (most comprehensive)
1640
+ rc, out = _run_quietly(["pyright", "--outputjson", abs_path])
1641
+ if rc != -1:
1642
+ try:
1643
+ data = json.loads(out)
1644
+ diags = data.get("generalDiagnostics", [])
1645
+ if not diags:
1646
+ results.append("pyright: no diagnostics")
1647
+ else:
1648
+ lines = [f"pyright ({len(diags)} issue(s)):"]
1649
+ for d in diags[:50]:
1650
+ rng = d.get("range", {}).get("start", {})
1651
+ ln = rng.get("line", 0) + 1
1652
+ ch = rng.get("character", 0) + 1
1653
+ sev = d.get("severity", "error")
1654
+ msg = d.get("message", "")
1655
+ rule = d.get("rule", "")
1656
+ lines.append(f" {ln}:{ch} [{sev}] {msg}" + (f" ({rule})" if rule else ""))
1657
+ results.append("\n".join(lines))
1658
+ except json.JSONDecodeError:
1659
+ if out:
1660
+ results.append(f"pyright:\n{out[:3000]}")
1661
+ else:
1662
+ # Try mypy
1663
+ rc2, out2 = _run_quietly(["mypy", "--no-error-summary", abs_path])
1664
+ if rc2 != -1:
1665
+ results.append(f"mypy:\n{out2[:3000]}" if out2 else "mypy: no diagnostics")
1666
+ else:
1667
+ # Fall back to flake8
1668
+ rc3, out3 = _run_quietly(["flake8", abs_path])
1669
+ if rc3 != -1:
1670
+ results.append(f"flake8:\n{out3[:3000]}" if out3 else "flake8: no diagnostics")
1671
+ else:
1672
+ # Last resort: py_compile syntax check
1673
+ rc4, out4 = _run_quietly(["python3", "-m", "py_compile", abs_path])
1674
+ if out4:
1675
+ results.append(f"py_compile (syntax check):\n{out4}")
1676
+ else:
1677
+ results.append("py_compile: syntax OK (no further tools available)")
1678
+
1679
+ elif lang in ("javascript", "typescript"):
1680
+ # Try tsc
1681
+ rc, out = _run_quietly(["tsc", "--noEmit", "--strict", abs_path])
1682
+ if rc != -1:
1683
+ results.append(f"tsc:\n{out[:3000]}" if out else "tsc: no errors")
1684
+ else:
1685
+ # Try eslint
1686
+ rc2, out2 = _run_quietly(["eslint", abs_path])
1687
+ if rc2 != -1:
1688
+ results.append(f"eslint:\n{out2[:3000]}" if out2 else "eslint: no issues")
1689
+ else:
1690
+ results.append("No TypeScript/JavaScript checker found (install tsc or eslint)")
1691
+
1692
+ elif lang == "shellscript":
1693
+ rc, out = _run_quietly(["shellcheck", abs_path])
1694
+ if rc != -1:
1695
+ results.append(f"shellcheck:\n{out[:3000]}" if out else "shellcheck: no issues")
1696
+ else:
1697
+ # Basic bash syntax check
1698
+ rc2, out2 = _run_quietly(["bash", "-n", abs_path])
1699
+ results.append(f"bash -n (syntax check):\n{out2}" if out2 else "bash -n: syntax OK")
1700
+
1701
+ else:
1702
+ results.append(f"No diagnostic tool available for language: {lang or 'unknown'} (ext: {Path(file_path).suffix})")
1703
+
1704
+ return "\n\n".join(results) if results else "(no diagnostics output)"
1705
+
1706
+
1707
+ # ── AskUserQuestion implementation ────────────────────────────────────────
1708
+
1709
+ def _ask_user_question(
1710
+ question: str,
1711
+ options: list[dict] | None = None,
1712
+ allow_freetext: bool = True,
1713
+ config: dict = None,
1714
+ ) -> str:
1715
+ """
1716
+ Block the agent loop and surface a question to the user in the terminal.
1717
+ """
1718
+ event = threading.Event()
1719
+ result_holder: list[str] = []
1720
+ entry = {
1721
+ "question": question,
1722
+ "options": options or [],
1723
+ "allow_freetext": allow_freetext,
1724
+ "event": event,
1725
+ "result": result_holder,
1726
+ }
1727
+ with _ask_lock:
1728
+ _pending_questions.append(entry)
1729
+
1730
+ if threading.current_thread() is threading.main_thread() or _is_in_tg_turn(config or {}):
1731
+ # Prevent deadlock: we are blocking the main loop generator,
1732
+ # so we must drain it ourselves synchronously!
1733
+ drain_pending_questions(config or {})
1734
+ return result_holder[0] if result_holder else "(no answer)"
1735
+
1736
+ # Block until the REPL answers us (for background agents)
1737
+ event.wait(timeout=300) # 5-minute max wait
1738
+
1739
+ if result_holder:
1740
+ return result_holder[0]
1741
+ return "(no answer - timeout)"
1742
+
1743
+
1744
+ def ask_input_interactive(prompt: str, config: dict, menu_text: str = None) -> str:
1745
+ """Prompt the user for input, routing to Telegram if in a Telegram turn.
1746
+ If menu_text is provided, it is sent ahead of the prompt."""
1747
+ is_tg = _is_in_tg_turn(config)
1748
+ if is_tg and "_tg_send_callback" in config:
1749
+ token = config.get("telegram_token")
1750
+ chat_id = config.get("telegram_chat_id")
1751
+ import re, threading
1752
+ clean_prompt = re.sub(r'\x1b\[[0-9;]*m', '', prompt).strip()
1753
+
1754
+ payload = ""
1755
+ if menu_text:
1756
+ clean_menu = re.sub(r'\x1b\[[0-9;]*m', '', menu_text).strip()
1757
+ payload += f"{clean_menu}\n\n"
1758
+ payload += f"*Input Required*\n{clean_prompt}"
1759
+
1760
+ evt = threading.Event()
1761
+ config["_tg_input_event"] = evt
1762
+
1763
+ config["_tg_send_callback"](token, chat_id, payload)
1764
+
1765
+ config["_tg_pause_typing"] = True
1766
+ evt.wait()
1767
+ config["_tg_pause_typing"] = False
1768
+
1769
+ text = config.pop("_tg_input_value", "").strip()
1770
+ config.pop("_tg_input_event", None)
1771
+ return text
1772
+ else:
1773
+ try:
1774
+ # Use prompt_toolkit with autocomplete if available, otherwise fall back to input()
1775
+ if HAS_PROMPT_TOOLKIT and input_setup:
1776
+ # Setup input with command and metadata autocomplete providers
1777
+ # Providers must be CALLABLES that return dicts (not the dicts themselves!)
1778
+ commands_provider = lambda: dict(COMMANDS)
1779
+ meta_provider = lambda: dict(_CMD_META)
1780
+ input_setup(commands_provider, meta_provider)
1781
+
1782
+ # Call the read_line function from input module (not readline)
1783
+ # prompt_toolkit handles ANSI escapes natively, no need for \001/\002 markers
1784
+ if read_line:
1785
+ return read_line(prompt)
1786
+ else:
1787
+ # Fallback to input() if read_line is not available
1788
+ import re as _re
1789
+ safe = _re.sub(r'(\033\[[0-9;]*m)', r'\001\1\002', prompt)
1790
+ return input(safe)
1791
+ else:
1792
+ # Fallback to standard input()
1793
+ import re as _re
1794
+ safe = _re.sub(r'(\033\[[0-9;]*m)', r'\001\1\002', prompt)
1795
+ return input(safe)
1796
+ except (KeyboardInterrupt, EOFError):
1797
+ print()
1798
+ return ""
1799
+
1800
+ def drain_pending_questions(config: dict) -> bool:
1801
+ """
1802
+ Called by the REPL loop after each streaming turn.
1803
+ Renders pending questions and collects user input.
1804
+ Returns True if any questions were answered.
1805
+ """
1806
+ with _ask_lock:
1807
+ pending = list(_pending_questions)
1808
+ _pending_questions.clear()
1809
+
1810
+ if not pending:
1811
+ return False
1812
+
1813
+ # Temporarily restore the real stdout/stderr for the entire drain so that
1814
+ # both print() and input() (used by ask_input_interactive) go to the
1815
+ # terminal and not into any redirect_stdout() buffer from execute_tool.
1816
+ import sys as _sys
1817
+ _saved_out = _sys.stdout
1818
+ _saved_err = _sys.stderr
1819
+ _sys.stdout = _sys.__stdout__
1820
+ _sys.stderr = _sys.__stderr__
1821
+
1822
+ for entry in pending:
1823
+ question = entry["question"]
1824
+ options = entry["options"]
1825
+ allow_ft = entry["allow_freetext"]
1826
+ event = entry["event"]
1827
+ result = entry["result"]
1828
+
1829
+ print()
1830
+ print(clr("Question from assistant:", "magenta", "bold"))
1831
+ print(f" {question}")
1832
+
1833
+ if options:
1834
+ menu_lines = [question, ""]
1835
+ for i, opt in enumerate(options, 1):
1836
+ label = opt.get("label", "")
1837
+ desc = opt.get("description", "")
1838
+ line = f"[{i}] {label}"
1839
+ if desc:
1840
+ line += f" — {desc}"
1841
+ menu_lines.append(line)
1842
+ print(f" {line}")
1843
+ if allow_ft:
1844
+ menu_lines.append("[0] Type a custom answer")
1845
+ print(" [0] Type a custom answer")
1846
+ print()
1847
+ menu_text = "\n".join(menu_lines)
1848
+
1849
+ while True:
1850
+ raw = ask_input_interactive(" ❯ ", config, menu_text=menu_text).strip()
1851
+ if not raw:
1852
+ break
1853
+
1854
+ if raw.isdigit():
1855
+ idx = int(raw)
1856
+ if 1 <= idx <= len(options):
1857
+ raw = options[idx - 1]["label"]
1858
+ break
1859
+ elif idx == 0 and allow_ft:
1860
+ raw = ask_input_interactive(" ❯ ", config, menu_text=question).strip()
1861
+ break
1862
+ else:
1863
+ print(f"Invalid option: {idx}")
1864
+ raw = ""
1865
+ continue
1866
+ elif allow_ft:
1867
+ break # accept free text directly
1868
+ else:
1869
+ # Free-text only
1870
+ print()
1871
+ raw = ask_input_interactive(" ❯ ", config, menu_text=question).strip()
1872
+
1873
+ result.append(raw)
1874
+ event.set()
1875
+
1876
+
1877
+ _sys.stdout = _saved_out
1878
+ _sys.stderr = _saved_err
1879
+
1880
+ return True
1881
+
1882
+
1883
+ def _sleeptimer(seconds: int, config: dict) -> str:
1884
+ import threading
1885
+ cb = config.get("_run_query_callback")
1886
+ if not cb:
1887
+ return "Error: Internal callback missing, dulus did not provide _run_query_callback"
1888
+
1889
+ def worker():
1890
+ import time
1891
+ time.sleep(seconds)
1892
+ cb("(System Automated Event): The timer has finished. Please wake up, perform any pending monitoring checks and report to the user now.")
1893
+
1894
+ t = threading.Thread(target=worker, daemon=True)
1895
+ t.start()
1896
+ return f"Timer successfully scheduled for {seconds} seconds. You can output your final thoughts and end your turn. You will be automatically awakened."
1897
+
1898
+
1899
+ def _print_to_console(content: str = "", style: str = "normal", prefix: str = "", from_line: int = None, to_line: int = None, file_path: str = None, config: dict = None) -> str:
1900
+ """Print content to the user's console.
1901
+
1902
+ This tool displays text to the user WITHOUT consuming output tokens.
1903
+ The content is shown immediately in the chat console.
1904
+ If the conversation started via Telegram, also sends to Telegram.
1905
+
1906
+ Args:
1907
+ content: Text to display (or use file_path to read from file)
1908
+ style: Visual style (normal, success, info, warning, error)
1909
+ prefix: Optional prefix to identify the source
1910
+ from_line: Extract content starting from this line (1-indexed)
1911
+ to_line: Extract content up to this line (inclusive)
1912
+ file_path: Path to file to read and display (alternative to content)
1913
+ config: Optional config dict for Telegram integration
1914
+
1915
+ Returns:
1916
+ The formatted content that was displayed (possibly extracted to specific lines)
1917
+ """
1918
+ import sys
1919
+ from pathlib import Path
1920
+
1921
+ # If file_path provided, read from file
1922
+ if file_path:
1923
+ try:
1924
+ fp = Path(file_path)
1925
+ # Special case: last_tool_output.txt is usually in the app config dir (~/.dulus)
1926
+ if file_path == "last_tool_output.txt" and not fp.exists():
1927
+ # Cross-platform home directory resolution
1928
+ fp = Path.home() / ".dulus" / "last_tool_output.txt"
1929
+
1930
+ if not fp.exists():
1931
+ return f"[ERROR] File not found: {file_path}"
1932
+ content = fp.read_text(encoding='utf-8', errors='replace')
1933
+ except Exception as e:
1934
+ return f"[ERROR] Could not read file: {e}"
1935
+
1936
+ # Extract specific lines if requested
1937
+ if from_line is not None or to_line is not None:
1938
+ lines = content.split('\n')
1939
+ total_lines = len(lines)
1940
+
1941
+ # Default values
1942
+ start = (from_line - 1) if from_line else 0 # Convert to 0-indexed
1943
+ end = to_line if to_line else total_lines
1944
+
1945
+ # Clamp to valid range
1946
+ start = max(0, min(start, total_lines))
1947
+ end = max(0, min(end, total_lines))
1948
+
1949
+ # Extract lines
1950
+ if start < end:
1951
+ extracted = lines[start:end]
1952
+ content = '\n'.join(extracted)
1953
+ # Add info about extraction
1954
+ prefix_info = f"[LINES {start+1}-{end} of {total_lines}] "
1955
+ else:
1956
+ content = "[No lines in specified range]"
1957
+ prefix_info = ""
1958
+ else:
1959
+ prefix_info = ""
1960
+
1961
+ # Build styled output (ASCII-friendly para Windows)
1962
+ style_prefixes = {
1963
+ "success": "[OK] ",
1964
+ "info": "[i] ",
1965
+ "warning": "[!] ",
1966
+ "error": "[X] ",
1967
+ "normal": "",
1968
+ }
1969
+
1970
+ # Build output
1971
+ style_indicator = style_prefixes.get(style, "")
1972
+
1973
+ # Add user-provided prefix
1974
+ full_prefix = f"[{prefix}] " if prefix else ""
1975
+
1976
+ # Build the visible output with extraction info if applicable
1977
+ output = f"{prefix_info}{full_prefix}{style_indicator}{content}"
1978
+
1979
+ # ALSO print to server log for debugging
1980
+ print(f"[PrintToConsole] {len(content)} chars displayed")
1981
+
1982
+ # If in Telegram turn, also send to Telegram
1983
+ if config and _is_in_tg_turn(config):
1984
+ token = config.get("telegram_token")
1985
+ chat_id = config.get("telegram_chat_id")
1986
+ if token and chat_id and "_tg_send_callback" in config:
1987
+ import re
1988
+ # Clean ANSI codes and send
1989
+ clean_output = re.sub(r'\x1b\[[0-9;]*m', '', output).strip()
1990
+ if clean_output:
1991
+ try:
1992
+ config["_tg_send_callback"](token, chat_id, clean_output)
1993
+ except Exception:
1994
+ pass # Fail silently if Telegram send fails
1995
+
1996
+ # Return the content so it shows in the tool result to the user
1997
+ return output
1998
+
1999
+
2000
+ # ── Dispatcher (backward-compatible wrapper) ──────────────────────────────
2001
+
2002
+ def execute_tool(
2003
+ name: str,
2004
+ inputs: dict,
2005
+ permission_mode: str = "auto",
2006
+ ask_permission: Optional[Callable[[str], bool]] = None,
2007
+ config: dict = None,
2008
+ ) -> str:
2009
+ """Dispatch tool execution; ask permission for write/destructive ops.
2010
+
2011
+ Permission checking is done here, then delegation goes to the registry.
2012
+ The config dict is forwarded to tool functions so they can access
2013
+ runtime context like _depth, _system_prompt, model, etc.
2014
+ """
2015
+ cfg = config or {}
2016
+
2017
+ def _check(desc: str) -> bool:
2018
+ """Return True if action is allowed."""
2019
+ if permission_mode == "accept-all":
2020
+ return True
2021
+ if ask_permission:
2022
+ return ask_permission(desc)
2023
+ return True # headless: allow everything
2024
+
2025
+ # --- permission gate ---
2026
+ if name == "Write":
2027
+ if not _check(f"Write to {inputs['file_path']}"):
2028
+ return "Denied: user rejected write operation"
2029
+ elif name == "Edit":
2030
+ fp = inputs.get("file_path", inputs.get("filePath", "<unknown>"))
2031
+ if not _check(f"Edit {fp}"):
2032
+ return "Denied: user rejected edit operation"
2033
+ elif name == "Bash":
2034
+ cmd = inputs["command"]
2035
+ if permission_mode != "accept-all" and not _is_safe_bash(cmd):
2036
+ if not _check(f"Bash: {cmd}"):
2037
+ return "Denied: user rejected bash command"
2038
+ elif name == "NotebookEdit":
2039
+ if not _check(f"Edit notebook {inputs['notebook_path']}"):
2040
+ return "Denied: user rejected notebook edit operation"
2041
+
2042
+ return _registry_execute(name, inputs, cfg, max_output=cfg.get("max_tool_output", 2500))
2043
+
2044
+
2045
+ # ── Register built-in tools with the plugin registry ─────────────────────
2046
+
2047
+ def _register_builtins() -> None:
2048
+ """Register all built-in tools into the central registry."""
2049
+ # Use a name → schema map so ordering changes in TOOL_SCHEMAS never break this.
2050
+ _schemas = {s["name"]: s for s in TOOL_SCHEMAS}
2051
+
2052
+ _tool_defs = [
2053
+ ToolDef(
2054
+ name="Read",
2055
+ schema=_schemas["Read"],
2056
+ func=lambda p, c: _read(**p),
2057
+ read_only=True,
2058
+ concurrent_safe=True,
2059
+ ),
2060
+ ToolDef(
2061
+ name="Write",
2062
+ schema=_schemas["Write"],
2063
+ func=lambda p, c: _write(**p),
2064
+ read_only=False,
2065
+ concurrent_safe=False,
2066
+ ),
2067
+ ToolDef(
2068
+ name="Edit",
2069
+ schema=_schemas["Edit"],
2070
+ func=lambda p, c: _edit(**p),
2071
+ read_only=False,
2072
+ concurrent_safe=False,
2073
+ ),
2074
+ ToolDef(
2075
+ name="Bash",
2076
+ schema=_schemas["Bash"],
2077
+ func=lambda p, c: _bash(p["command"], p.get("timeout", 30)),
2078
+ read_only=False,
2079
+ concurrent_safe=False,
2080
+ ),
2081
+ ToolDef(
2082
+ name="Glob",
2083
+ schema=_schemas["Glob"],
2084
+ func=lambda p, c: _glob(p["pattern"], p.get("path")),
2085
+ read_only=True,
2086
+ concurrent_safe=True,
2087
+ ),
2088
+ ToolDef(
2089
+ name="Grep",
2090
+ schema=_schemas["Grep"],
2091
+ func=lambda p, c: _grep(
2092
+ p["pattern"], p.get("path"), p.get("glob"),
2093
+ p.get("output_mode", "files_with_matches"),
2094
+ p.get("case_insensitive", False),
2095
+ p.get("context", 0),
2096
+ ),
2097
+ read_only=True,
2098
+ concurrent_safe=True,
2099
+ ),
2100
+ ToolDef(
2101
+ name="WebFetch",
2102
+ schema=_schemas["WebFetch"],
2103
+ func=lambda p, c: _webfetch(p["url"]),
2104
+ read_only=True,
2105
+ concurrent_safe=True,
2106
+ ),
2107
+ ToolDef(
2108
+ name="WebSearch",
2109
+ schema=_schemas["WebSearch"],
2110
+ func=lambda p, c: _websearch(p["query"], c, region=p.get("region")),
2111
+ read_only=True,
2112
+ concurrent_safe=True,
2113
+ ),
2114
+ ToolDef(
2115
+ name="NotebookEdit",
2116
+ schema=_schemas["NotebookEdit"],
2117
+ func=lambda p, c: _notebook_edit(
2118
+ p["notebook_path"],
2119
+ p["new_source"],
2120
+ p.get("cell_id"),
2121
+ p.get("cell_type"),
2122
+ p.get("edit_mode", "replace"),
2123
+ ),
2124
+ read_only=False,
2125
+ concurrent_safe=False,
2126
+ ),
2127
+ ToolDef(
2128
+ name="GetDiagnostics",
2129
+ schema=_schemas["GetDiagnostics"],
2130
+ func=lambda p, c: _get_diagnostics(
2131
+ p["file_path"],
2132
+ p.get("language"),
2133
+ ),
2134
+ read_only=True,
2135
+ concurrent_safe=True,
2136
+ ),
2137
+ ToolDef(
2138
+ name="LineCount",
2139
+ schema=_schemas["LineCount"],
2140
+ func=lambda p, c: _line_count(p["file_path"]),
2141
+ read_only=True,
2142
+ concurrent_safe=True,
2143
+ ),
2144
+ ToolDef(
2145
+ name="AskUserQuestion",
2146
+ schema=_schemas["AskUserQuestion"],
2147
+ func=lambda p, c: _ask_user_question(
2148
+ p["question"],
2149
+ p.get("options"),
2150
+ p.get("allow_freetext", True),
2151
+ c,
2152
+ ),
2153
+ read_only=True,
2154
+ concurrent_safe=False,
2155
+ ),
2156
+ ToolDef(
2157
+ name="SleepTimer",
2158
+ schema=_schemas["SleepTimer"],
2159
+ func=lambda p, c: _sleeptimer(p["seconds"], c),
2160
+ read_only=False,
2161
+ concurrent_safe=True,
2162
+ ),
2163
+ ToolDef(
2164
+ name="SearchLastOutput",
2165
+ schema=_schemas["SearchLastOutput"],
2166
+ func=lambda p, c: _search_last_output(
2167
+ p.get("pattern"), p.get("context", 2),
2168
+ ),
2169
+ read_only=True,
2170
+ concurrent_safe=True,
2171
+ ),
2172
+ ToolDef(
2173
+ name="PrintLastOutput",
2174
+ schema=_schemas["PrintLastOutput"],
2175
+ func=lambda p, c: _print_last_output(),
2176
+ read_only=True,
2177
+ concurrent_safe=True,
2178
+ ),
2179
+ ToolDef(
2180
+ name="PrintToConsole",
2181
+ schema=_schemas["PrintToConsole"],
2182
+ func=lambda p, c: _print_to_console(
2183
+ p.get("content", ""),
2184
+ p.get("style", "normal"),
2185
+ p.get("prefix", ""),
2186
+ p.get("from_line"),
2187
+ p.get("to_line"),
2188
+ p.get("file_path"),
2189
+ c, # Pass config for Telegram integration
2190
+ ),
2191
+ read_only=True,
2192
+ concurrent_safe=True,
2193
+ display_only=True, # NO TRUNCATION - prints directly to console
2194
+ ),
2195
+ ]
2196
+ for td in _tool_defs:
2197
+ register_tool(td)
2198
+
2199
+
2200
+ _register_builtins()
2201
+
2202
+ # ── Tmux tools (auto-detected: only registered when tmux is on the system) ───
2203
+ try:
2204
+ from tmux_tools import register_tmux_tools, tmux_available
2205
+ _tmux_count = register_tmux_tools()
2206
+ except ImportError:
2207
+ _tmux_count = 0
2208
+
2209
+ # ── Memory tools (MemorySave, MemoryDelete, MemorySearch, MemoryList) ────────
2210
+ # Defined in memory/tools.py; importing registers them automatically.
2211
+ import memory.tools as _memory_tools # noqa: F401
2212
+ from memory.offload import register_offload_tool
2213
+ register_offload_tool()
2214
+
2215
+
2216
+
2217
+ # ── Multi-agent tools (Agent, SendMessage, CheckAgentResult, ListAgentTasks, ListAgentTypes) ──
2218
+ # Defined in multi_agent/tools.py; importing registers them automatically.
2219
+ import multi_agent.tools as _multiagent_tools # noqa: F401
2220
+
2221
+ # Expose get_agent_manager at module level for backward compatibility
2222
+ from multi_agent.tools import get_agent_manager as _get_agent_manager # noqa: F401
2223
+
2224
+
2225
+ # ── Skill tools (Skill, SkillList) ────────────────────────────────────────
2226
+ # Defined in skill/tools.py; importing registers them automatically.
2227
+ import skill.tools as _skill_tools # noqa: F401
2228
+
2229
+
2230
+ # ── MCP tools ─────────────────────────────────────────────────────────────────
2231
+ # mcp/tools.py connects to configured MCP servers and registers their tools.
2232
+ # Connection happens in a background thread so startup is not blocked.
2233
+ import dulus_mcp.tools as _mcp_tools # noqa: F401
2234
+
2235
+
2236
+ # ── Plugin tools ───────────────────────────────────────────────────────────────
2237
+ # Load tools contributed by installed+enabled plugins.
2238
+ try:
2239
+ from plugin.loader import register_plugin_tools as _reg_plugin_tools
2240
+ _reg_plugin_tools()
2241
+ except Exception as _plugin_err:
2242
+ pass # Plugin loading is best-effort; never crash startup
2243
+
2244
+
2245
+ # ── Task tools (TaskCreate, TaskUpdate, TaskGet, TaskList) ─────────────────────
2246
+ # task/tools.py registers all four tools into the central registry on import.
2247
+ import task.tools as _task_tools # noqa: F401
2248
+
2249
+
2250
+ # ── Checkpoint hooks (backup files before Write/Edit/NotebookEdit) ───────────
2251
+ from checkpoint.hooks import install_hooks as _install_checkpoint_hooks
2252
+ _install_checkpoint_hooks()
2253
+
2254
+
2255
+ # ── Plan mode tools (EnterPlanMode / ExitPlanMode) ──────────────────────────
2256
+
2257
+ def _enter_plan_mode(params: dict, config: dict) -> str:
2258
+ """Enter plan mode: read-only except plan file."""
2259
+ if config.get("permission_mode") == "plan":
2260
+ return "Already in plan mode. Write your plan to the plan file, then call ExitPlanMode."
2261
+
2262
+ session_id = config.get("_session_id", "default")
2263
+ plans_dir = Path.cwd() / ".dulus-context" / "plans"
2264
+ plans_dir.mkdir(parents=True, exist_ok=True)
2265
+ plan_path = plans_dir / f"{session_id}.md"
2266
+
2267
+ task_desc = params.get("task_description", "")
2268
+ if not plan_path.exists() or plan_path.stat().st_size == 0:
2269
+ header = f"# Plan: {task_desc}\n\n" if task_desc else "# Plan\n\n"
2270
+ plan_path.write_text(header, encoding="utf-8")
2271
+
2272
+ config["_prev_permission_mode"] = config.get("permission_mode", "auto")
2273
+ config["permission_mode"] = "plan"
2274
+ config["_plan_file"] = str(plan_path)
2275
+
2276
+ return (
2277
+ f"Plan mode activated. You are now in read-only mode.\n"
2278
+ f"Plan file: {plan_path}\n\n"
2279
+ f"Instructions:\n"
2280
+ f"1. Analyze the codebase using Read, Glob, Grep, WebSearch\n"
2281
+ f"2. Write your detailed implementation plan to the plan file using Write or Edit\n"
2282
+ f"3. When the plan is ready, call ExitPlanMode to request user approval\n"
2283
+ f"4. Do NOT attempt to write to any other files — they will be blocked"
2284
+ )
2285
+
2286
+
2287
+ def _exit_plan_mode(params: dict, config: dict) -> str:
2288
+ """Exit plan mode and present plan for user approval."""
2289
+ if config.get("permission_mode") != "plan":
2290
+ return "Not in plan mode. Use EnterPlanMode first."
2291
+
2292
+ plan_file = config.get("_plan_file", "")
2293
+ plan_content = ""
2294
+ if plan_file:
2295
+ p = Path(plan_file)
2296
+ if p.exists():
2297
+ plan_content = p.read_text(encoding="utf-8").strip()
2298
+
2299
+ if not plan_content or plan_content == "# Plan":
2300
+ return "Plan file is empty. Write your plan to the plan file before calling ExitPlanMode."
2301
+
2302
+ # Restore permissions
2303
+ prev = config.pop("_prev_permission_mode", "auto")
2304
+ config["permission_mode"] = prev
2305
+
2306
+ return (
2307
+ f"Plan mode exited. Permission mode restored to: {prev}\n"
2308
+ f"Plan file: {plan_file}\n\n"
2309
+ f"The plan is ready for the user to review. "
2310
+ f"Wait for the user to approve before starting implementation.\n\n"
2311
+ f"--- Plan Content ---\n{plan_content}"
2312
+ )
2313
+
2314
+
2315
+ _PLAN_MODE_SCHEMAS = [
2316
+ {
2317
+ "name": "EnterPlanMode",
2318
+ "description": (
2319
+ "Enter plan mode to analyze the codebase and create an implementation plan "
2320
+ "before writing code. Use this for complex, multi-file tasks. "
2321
+ "In plan mode, only the plan file is writable; all other writes are blocked."
2322
+ ),
2323
+ "input_schema": {
2324
+ "type": "object",
2325
+ "properties": {
2326
+ "task_description": {
2327
+ "type": "string",
2328
+ "description": "Brief description of the task to plan for",
2329
+ },
2330
+ },
2331
+ "required": [],
2332
+ },
2333
+ },
2334
+ {
2335
+ "name": "ExitPlanMode",
2336
+ "description": (
2337
+ "Exit plan mode and present the plan for user approval. "
2338
+ "Call this after writing your implementation plan to the plan file. "
2339
+ "The user must approve the plan before you begin implementation."
2340
+ ),
2341
+ "input_schema": {
2342
+ "type": "object",
2343
+ "properties": {},
2344
+ "required": [],
2345
+ },
2346
+ },
2347
+ ]
2348
+
2349
+ register_tool(ToolDef(
2350
+ name="EnterPlanMode",
2351
+ schema=_PLAN_MODE_SCHEMAS[0],
2352
+ func=_enter_plan_mode,
2353
+ read_only=False,
2354
+ concurrent_safe=False,
2355
+ ))
2356
+
2357
+ register_tool(ToolDef(
2358
+ name="ExitPlanMode",
2359
+ schema=_PLAN_MODE_SCHEMAS[1],
2360
+ func=_exit_plan_mode,
2361
+ read_only=False,
2362
+ concurrent_safe=False,
2363
+ ))
2364
+
2365
+ def _plugin_list(params: dict, config: dict) -> str:
2366
+ """Implement the PluginList tool to query installed tools dynamically."""
2367
+ try:
2368
+ from plugin.store import list_plugins, PluginScope
2369
+ plugins = []
2370
+ # get both scopes and filter out duplicates if needed, or just list all
2371
+ plugins.extend(list_plugins(PluginScope.USER))
2372
+ plugins.extend(list_plugins(PluginScope.PROJECT))
2373
+
2374
+ # Deduplicate by name and scope
2375
+ seen = set()
2376
+ unique = []
2377
+ for p in plugins:
2378
+ uid = f"{p.name}_{p.scope}"
2379
+ if uid not in seen:
2380
+ seen.add(uid)
2381
+ unique.append(p)
2382
+
2383
+ names = []
2384
+ for p in unique:
2385
+ if p.manifest:
2386
+ status = "disabled" if not p.enabled else "enabled"
2387
+ names.append(f"- {p.name} ({p.scope.value}, {status}): {p.manifest.description}")
2388
+ return "Installed Plugins:\n" + ("\n".join(names) if names else "No plugins currently installed.")
2389
+ except ImportError:
2390
+ return "Error: plugin system not available."
2391
+ except Exception as e:
2392
+ return f"Error: {e}"
2393
+
2394
+ _PLUGIN_LIST_SCHEMA = {
2395
+ "name": "PluginList",
2396
+ "description": "List all currently installed Dulus plugins, their scopes, and their status (enabled/disabled). Use this if you need to recall which plugins you have available.",
2397
+ "input_schema": {
2398
+ "type": "object",
2399
+ "properties": {},
2400
+ "required": [],
2401
+ },
2402
+ }
2403
+
2404
+ # Append to TOOL_SCHEMAS so it gets sent in the system prompt alongside core tools
2405
+ TOOL_SCHEMAS.append(_PLUGIN_LIST_SCHEMA)
2406
+
2407
+ register_tool(ToolDef(
2408
+ name="PluginList",
2409
+ schema=_PLUGIN_LIST_SCHEMA,
2410
+ func=_plugin_list,
2411
+ read_only=True,
2412
+ concurrent_safe=True,
2413
+ ))
2414
+
2415
+
2416
+ def _plugin_tools_list(params: dict, config: dict) -> str:
2417
+ """List all tools exposed by installed plugins."""
2418
+ try:
2419
+ from plugin.loader import load_all_plugins
2420
+ from plugin.types import PluginScope
2421
+ import importlib.util
2422
+ import sys
2423
+
2424
+ plugins = load_all_plugins()
2425
+
2426
+ if not plugins:
2427
+ return "No plugins installed. Use /plugin install to add plugins."
2428
+
2429
+ lines = ["Plugin Tools:", ""]
2430
+ total_tools = 0
2431
+
2432
+ for entry in plugins:
2433
+ if not entry.enabled or not entry.manifest or not entry.manifest.tools:
2434
+ continue
2435
+
2436
+ plugin_tools = []
2437
+ for module_name in entry.manifest.tools:
2438
+ # Import the module to get its tools
2439
+ plugin_dir_str = str(entry.install_dir)
2440
+ if plugin_dir_str not in sys.path:
2441
+ sys.path.insert(0, plugin_dir_str)
2442
+
2443
+ unique_name = f"_plugin_{entry.name}_{module_name}"
2444
+ try:
2445
+ if unique_name in sys.modules:
2446
+ mod = sys.modules[unique_name]
2447
+ else:
2448
+ candidate = entry.install_dir / f"{module_name}.py"
2449
+ if not candidate.exists():
2450
+ continue
2451
+ spec = importlib.util.spec_from_file_location(unique_name, candidate)
2452
+ mod = importlib.util.module_from_spec(spec)
2453
+ sys.modules[unique_name] = mod
2454
+ spec.loader.exec_module(mod)
2455
+
2456
+ if hasattr(mod, "TOOL_DEFS"):
2457
+ for tdef in mod.TOOL_DEFS:
2458
+ if hasattr(tdef, 'schema'):
2459
+ plugin_tools.append({
2460
+ "name": tdef.schema.get("name", "unknown"),
2461
+ "desc": tdef.schema.get("description", "No description")[:60] + "..."
2462
+ })
2463
+ except Exception:
2464
+ continue
2465
+
2466
+ if plugin_tools:
2467
+ lines.append(f"[{entry.name}]")
2468
+ for tool in plugin_tools:
2469
+ lines.append(f" - {tool['name']}: {tool['desc']}")
2470
+ lines.append("")
2471
+ total_tools += len(plugin_tools)
2472
+
2473
+ lines.insert(0, f"Plugin Tools ({total_tools} total from installed plugins):")
2474
+
2475
+ return "\n".join(lines) if total_tools > 0 else "No tools available from installed plugins."
2476
+ except ImportError:
2477
+ return "Error: plugin system not available."
2478
+ except Exception as e:
2479
+ return f"Error: {e}"
2480
+
2481
+
2482
+ _PLUGIN_TOOLS_LIST_SCHEMA = {
2483
+ "name": "PluginToolsList",
2484
+ "description": "List all tools exposed by installed Dulus plugins. Returns each plugin's name and the tools it provides with brief descriptions. Use this to discover what plugin tools are available without searching files.",
2485
+ "input_schema": {
2486
+ "type": "object",
2487
+ "properties": {},
2488
+ "required": [],
2489
+ },
2490
+ }
2491
+
2492
+ # Append to TOOL_SCHEMAS
2493
+ TOOL_SCHEMAS.append(_PLUGIN_TOOLS_LIST_SCHEMA)
2494
+
2495
+ register_tool(ToolDef(
2496
+ name="PluginToolsList",
2497
+ schema=_PLUGIN_TOOLS_LIST_SCHEMA,
2498
+ func=_plugin_tools_list,
2499
+ read_only=True,
2500
+ concurrent_safe=True,
2501
+ ))
2502
+
2503
+ # ── Auto-register plugin tools on module load ─────────────────────────────────
2504
+ def _read_job(params: dict, config: dict) -> str:
2505
+ """Read a job result by its ID. Simple way to get TmuxOffload results."""
2506
+ job_id = params.get("job_id", "").strip()
2507
+ pattern = params.get("pattern", "").strip()
2508
+ max_lines = params.get("max_lines", 0) # 0 = no limit
2509
+ if not job_id:
2510
+ return "Error: job_id is required"
2511
+
2512
+ try:
2513
+ from pathlib import Path
2514
+ import re
2515
+ jobs_dir = Path.home() / ".dulus" / "jobs"
2516
+ job_file = jobs_dir / f"{job_id}.json"
2517
+
2518
+ if not job_file.exists():
2519
+ # Try listing available jobs
2520
+ available = [f.stem for f in jobs_dir.glob("*.json")] if jobs_dir.exists() else []
2521
+ available_str = ", ".join(available[:10]) if available else "No jobs found"
2522
+ return f"Error: Job '{job_id}' not found.\nAvailable jobs: {available_str}"
2523
+
2524
+ content = json.loads(job_file.read_text(encoding="utf-8"))
2525
+
2526
+ # Format the response nicely
2527
+ status = content.get("status", "unknown")
2528
+ tool_name = content.get("tool_name", "unknown")
2529
+ created = content.get("created_at", "unknown")
2530
+ result = content.get("result", "")
2531
+
2532
+ # Apply max_lines limit FIRST (before pattern filter)
2533
+ if max_lines > 0 and result:
2534
+ lines = result.splitlines()
2535
+ total = len(lines)
2536
+ if total > max_lines:
2537
+ lines = lines[:max_lines]
2538
+ result = "\n".join(lines)
2539
+ result = f"[TRUNCATED to first {max_lines}/{total} lines]\n\n" + result
2540
+
2541
+ # Apply pattern filter if specified (TOKEN OPTIMIZATION)
2542
+ if pattern and result:
2543
+ try:
2544
+ lines = result.splitlines()
2545
+ filtered = []
2546
+ regex = re.compile(pattern, re.IGNORECASE)
2547
+ for i, line in enumerate(lines):
2548
+ if regex.search(line):
2549
+ # Include context: 2 lines before and after
2550
+ start = max(0, i - 2)
2551
+ end = min(len(lines), i + 3)
2552
+ for j in range(start, end):
2553
+ if lines[j] not in filtered:
2554
+ filtered.append(lines[j])
2555
+ if filtered:
2556
+ result = "\n".join(filtered)
2557
+ result = f"[FILTERED with pattern '{pattern}' - {len(filtered)}/{len(lines)} lines]\n\n" + result
2558
+ else:
2559
+ result = f"[Pattern '{pattern}' matched 0 lines. Showing first 50 chars of result]\n{result[:50]}..."
2560
+ except re.error:
2561
+ return f"Error: Invalid regex pattern '{pattern}'"
2562
+
2563
+ lines = [
2564
+ f"Job: {job_id}",
2565
+ f"Tool: {tool_name}",
2566
+ f"Status: {status}",
2567
+ f"Created: {created}",
2568
+ "-" * 40,
2569
+ ]
2570
+
2571
+ if result:
2572
+ lines.append("Result:")
2573
+ lines.append(result)
2574
+ else:
2575
+ lines.append("(No result available)")
2576
+
2577
+ return "\n".join(lines)
2578
+
2579
+ except Exception as e:
2580
+ return f"Error reading job: {e}"
2581
+
2582
+ _READ_JOB_SCHEMA = {
2583
+ "name": "ReadJob",
2584
+ "description": "Read a job result by its ID. Use this to get results from TmuxOffload or background tasks. CRITICAL: For large outputs, use 'max_lines' (e.g., 100) or 'pattern' to avoid loading 20K+ chars into context. ReadJob does NOT replace last_tool_output, so you can safely use it.",
2585
+ "input_schema": {
2586
+ "type": "object",
2587
+ "properties": {
2588
+ "job_id": {"type": "string", "description": "The job ID (e.g., '4ef7350f' from TmuxOffload)"},
2589
+ "pattern": {"type": "string", "description": "Optional regex pattern to filter results. HIGHLY RECOMMENDED for large outputs. Example: 'claimed|site_name' or 'username|profile'"},
2590
+ "max_lines": {"type": "integer", "description": "Maximum lines to return. CRITICAL for huge outputs (Sherlock: use 50-100). 0 = no limit. This is applied BEFORE pattern filter."},
2591
+ },
2592
+ "required": ["job_id"],
2593
+ },
2594
+ }
2595
+
2596
+ TOOL_SCHEMAS.append(_READ_JOB_SCHEMA)
2597
+
2598
+ register_tool(ToolDef(
2599
+ name="ReadJob",
2600
+ schema=_READ_JOB_SCHEMA,
2601
+ func=_read_job,
2602
+ read_only=True,
2603
+ concurrent_safe=True,
2604
+ ))
2605
+
2606
+
2607
+ # ── Git Tools ─────────────────────────────────────────────────────────────
2608
+
2609
+ _GIT_DIFF_SCHEMA = {
2610
+ "name": "GitDiff",
2611
+ "description": "Show git diff for a file or the entire repo. Optionally specify commit range.",
2612
+ "input_schema": {
2613
+ "type": "object",
2614
+ "properties": {
2615
+ "file_path": {"type": "string", "description": "Optional file path to diff"},
2616
+ "commit": {"type": "string", "description": "Optional commit hash or range (e.g. HEAD~1)"},
2617
+ },
2618
+ },
2619
+ }
2620
+
2621
+ _GIT_STATUS_SCHEMA = {
2622
+ "name": "GitStatus",
2623
+ "description": "Show git status: modified, staged, untracked files in the repo.",
2624
+ "input_schema": {
2625
+ "type": "object",
2626
+ "properties": {},
2627
+ },
2628
+ }
2629
+
2630
+ _GIT_LOG_SCHEMA = {
2631
+ "name": "GitLog",
2632
+ "description": "Show recent git commit history. Optionally filter by file.",
2633
+ "input_schema": {
2634
+ "type": "object",
2635
+ "properties": {
2636
+ "file_path": {"type": "string", "description": "Optional file to filter history"},
2637
+ "n": {"type": "integer", "description": "Number of commits (default 10)"},
2638
+ },
2639
+ },
2640
+ }
2641
+
2642
+
2643
+ def _git_diff(params: dict, _config: dict) -> str:
2644
+ file_path = params.get("file_path", "")
2645
+ commit = params.get("commit", "")
2646
+ cmd = ["git", "diff"]
2647
+ if commit:
2648
+ cmd += commit.split()
2649
+ if file_path:
2650
+ cmd.append(file_path)
2651
+ try:
2652
+ r = subprocess.run(_rtk_wrap_cmd(cmd), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30)
2653
+ return r.stdout.strip() or "(no changes)"
2654
+ except Exception as e:
2655
+ return f"Error: {e}"
2656
+
2657
+
2658
+ def _git_status(_params: dict, _config: dict) -> str:
2659
+ try:
2660
+ r = subprocess.run(_rtk_wrap_cmd(["git", "status", "-sb"]), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15)
2661
+ return r.stdout.strip() or "(no changes)"
2662
+ except Exception as e:
2663
+ return f"Error: {e}"
2664
+
2665
+
2666
+ def _git_log(params: dict, _config: dict) -> str:
2667
+ file_path = params.get("file_path", "")
2668
+ n = params.get("n", 10)
2669
+ cmd = ["git", "log", f"--max-count={n}", "--oneline", "--decorate"]
2670
+ if file_path:
2671
+ cmd += ["--", file_path]
2672
+ try:
2673
+ r = subprocess.run(_rtk_wrap_cmd(cmd), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15)
2674
+ return r.stdout.strip() or "(no commits)"
2675
+ except Exception as e:
2676
+ return f"Error: {e}"
2677
+
2678
+
2679
+ TOOL_SCHEMAS.extend([_GIT_DIFF_SCHEMA, _GIT_STATUS_SCHEMA, _GIT_LOG_SCHEMA])
2680
+
2681
+ register_tool(ToolDef(name="GitDiff", schema=_GIT_DIFF_SCHEMA, func=_git_diff, read_only=True, concurrent_safe=True))
2682
+ register_tool(ToolDef(name="GitStatus", schema=_GIT_STATUS_SCHEMA, func=_git_status, read_only=True, concurrent_safe=True))
2683
+ register_tool(ToolDef(name="GitLog", schema=_GIT_LOG_SCHEMA, func=_git_log, read_only=True, concurrent_safe=True))
2684
+
2685
+
2686
+ # Plugins are loaded once when Dulus starts (not on every reload to avoid overhead)
2687
+ try:
2688
+ from plugin.loader import register_plugin_tools
2689
+ _plugin_count = register_plugin_tools()
2690
+ # Silent registration - plugins are now available as tools
2691
+ except Exception:
2692
+ # If plugin system fails, continue with core tools only
2693
+ _plugin_count = 0
2694
+