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