zrb 1.21.29__py3-none-any.whl → 2.0.0a4__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.
Potentially problematic release.
This version of zrb might be problematic. Click here for more details.
- zrb/__init__.py +118 -129
- zrb/builtin/__init__.py +54 -2
- zrb/builtin/llm/chat.py +147 -0
- zrb/callback/callback.py +8 -1
- zrb/cmd/cmd_result.py +2 -1
- zrb/config/config.py +491 -280
- zrb/config/helper.py +84 -0
- zrb/config/web_auth_config.py +50 -35
- zrb/context/any_shared_context.py +13 -2
- zrb/context/context.py +31 -3
- zrb/context/print_fn.py +13 -0
- zrb/context/shared_context.py +14 -1
- zrb/input/option_input.py +30 -2
- zrb/llm/agent/__init__.py +9 -0
- zrb/llm/agent/agent.py +215 -0
- zrb/llm/agent/summarizer.py +20 -0
- zrb/llm/app/__init__.py +10 -0
- zrb/llm/app/completion.py +281 -0
- zrb/llm/app/confirmation/allow_tool.py +66 -0
- zrb/llm/app/confirmation/handler.py +178 -0
- zrb/llm/app/confirmation/replace_confirmation.py +77 -0
- zrb/llm/app/keybinding.py +34 -0
- zrb/llm/app/layout.py +117 -0
- zrb/llm/app/lexer.py +155 -0
- zrb/llm/app/redirection.py +28 -0
- zrb/llm/app/style.py +16 -0
- zrb/llm/app/ui.py +733 -0
- zrb/llm/config/__init__.py +4 -0
- zrb/llm/config/config.py +122 -0
- zrb/llm/config/limiter.py +247 -0
- zrb/llm/history_manager/__init__.py +4 -0
- zrb/llm/history_manager/any_history_manager.py +23 -0
- zrb/llm/history_manager/file_history_manager.py +91 -0
- zrb/llm/history_processor/summarizer.py +108 -0
- zrb/llm/note/__init__.py +3 -0
- zrb/llm/note/manager.py +122 -0
- zrb/llm/prompt/__init__.py +29 -0
- zrb/llm/prompt/claude_compatibility.py +92 -0
- zrb/llm/prompt/compose.py +55 -0
- zrb/llm/prompt/default.py +51 -0
- zrb/llm/prompt/markdown/mandate.md +23 -0
- zrb/llm/prompt/markdown/persona.md +3 -0
- zrb/llm/prompt/markdown/summarizer.md +21 -0
- zrb/llm/prompt/note.py +41 -0
- zrb/llm/prompt/system_context.py +46 -0
- zrb/llm/prompt/zrb.py +41 -0
- zrb/llm/skill/__init__.py +3 -0
- zrb/llm/skill/manager.py +86 -0
- zrb/llm/task/__init__.py +4 -0
- zrb/llm/task/llm_chat_task.py +316 -0
- zrb/llm/task/llm_task.py +245 -0
- zrb/llm/tool/__init__.py +39 -0
- zrb/llm/tool/bash.py +75 -0
- zrb/llm/tool/code.py +266 -0
- zrb/llm/tool/file.py +419 -0
- zrb/llm/tool/note.py +70 -0
- zrb/{builtin/llm → llm}/tool/rag.py +8 -5
- zrb/llm/tool/search/brave.py +53 -0
- zrb/llm/tool/search/searxng.py +47 -0
- zrb/llm/tool/search/serpapi.py +47 -0
- zrb/llm/tool/skill.py +19 -0
- zrb/llm/tool/sub_agent.py +70 -0
- zrb/llm/tool/web.py +97 -0
- zrb/llm/tool/zrb_task.py +66 -0
- zrb/llm/util/attachment.py +101 -0
- zrb/llm/util/prompt.py +104 -0
- zrb/llm/util/stream_response.py +178 -0
- zrb/session/any_session.py +0 -3
- zrb/session/session.py +1 -1
- zrb/task/base/context.py +25 -13
- zrb/task/base/execution.py +52 -47
- zrb/task/base/lifecycle.py +7 -4
- zrb/task/base_task.py +48 -49
- zrb/task/base_trigger.py +4 -1
- zrb/task/cmd_task.py +6 -0
- zrb/task/http_check.py +11 -5
- zrb/task/make_task.py +3 -0
- zrb/task/rsync_task.py +5 -0
- zrb/task/scaffolder.py +7 -4
- zrb/task/scheduler.py +3 -0
- zrb/task/tcp_check.py +6 -4
- zrb/util/ascii_art/art/bee.txt +17 -0
- zrb/util/ascii_art/art/cat.txt +9 -0
- zrb/util/ascii_art/art/ghost.txt +16 -0
- zrb/util/ascii_art/art/panda.txt +17 -0
- zrb/util/ascii_art/art/rose.txt +14 -0
- zrb/util/ascii_art/art/unicorn.txt +15 -0
- zrb/util/ascii_art/banner.py +92 -0
- zrb/util/cli/markdown.py +22 -2
- zrb/util/cmd/command.py +33 -10
- zrb/util/file.py +51 -32
- zrb/util/match.py +78 -0
- zrb/util/run.py +3 -3
- {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/METADATA +9 -15
- {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/RECORD +100 -128
- zrb/attr/__init__.py +0 -0
- zrb/builtin/llm/attachment.py +0 -40
- zrb/builtin/llm/chat_completion.py +0 -274
- zrb/builtin/llm/chat_session.py +0 -270
- zrb/builtin/llm/chat_session_cmd.py +0 -288
- zrb/builtin/llm/chat_trigger.py +0 -79
- zrb/builtin/llm/history.py +0 -71
- zrb/builtin/llm/input.py +0 -27
- zrb/builtin/llm/llm_ask.py +0 -269
- zrb/builtin/llm/previous-session.js +0 -21
- zrb/builtin/llm/tool/__init__.py +0 -0
- zrb/builtin/llm/tool/api.py +0 -75
- zrb/builtin/llm/tool/cli.py +0 -52
- zrb/builtin/llm/tool/code.py +0 -236
- zrb/builtin/llm/tool/file.py +0 -560
- zrb/builtin/llm/tool/note.py +0 -84
- zrb/builtin/llm/tool/sub_agent.py +0 -150
- zrb/builtin/llm/tool/web.py +0 -171
- zrb/builtin/project/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/template/app_template/module/my_module/service/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/common/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/permission/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/role/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/user/__init__.py +0 -0
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/schema/__init__.py +0 -0
- zrb/builtin/project/create/__init__.py +0 -0
- zrb/builtin/shell/__init__.py +0 -0
- zrb/builtin/shell/autocomplete/__init__.py +0 -0
- zrb/callback/__init__.py +0 -0
- zrb/cmd/__init__.py +0 -0
- zrb/config/default_prompt/interactive_system_prompt.md +0 -29
- zrb/config/default_prompt/persona.md +0 -1
- zrb/config/default_prompt/summarization_prompt.md +0 -57
- zrb/config/default_prompt/system_prompt.md +0 -38
- zrb/config/llm_config.py +0 -339
- zrb/config/llm_context/config.py +0 -166
- zrb/config/llm_context/config_parser.py +0 -40
- zrb/config/llm_context/workflow.py +0 -81
- zrb/config/llm_rate_limitter.py +0 -190
- zrb/content_transformer/__init__.py +0 -0
- zrb/context/__init__.py +0 -0
- zrb/dot_dict/__init__.py +0 -0
- zrb/env/__init__.py +0 -0
- zrb/group/__init__.py +0 -0
- zrb/input/__init__.py +0 -0
- zrb/runner/__init__.py +0 -0
- zrb/runner/web_route/__init__.py +0 -0
- zrb/runner/web_route/home_page/__init__.py +0 -0
- zrb/session/__init__.py +0 -0
- zrb/session_state_log/__init__.py +0 -0
- zrb/session_state_logger/__init__.py +0 -0
- zrb/task/__init__.py +0 -0
- zrb/task/base/__init__.py +0 -0
- zrb/task/llm/__init__.py +0 -0
- zrb/task/llm/agent.py +0 -204
- zrb/task/llm/agent_runner.py +0 -152
- zrb/task/llm/config.py +0 -122
- zrb/task/llm/conversation_history.py +0 -209
- zrb/task/llm/conversation_history_model.py +0 -67
- zrb/task/llm/default_workflow/coding/workflow.md +0 -41
- zrb/task/llm/default_workflow/copywriting/workflow.md +0 -68
- zrb/task/llm/default_workflow/git/workflow.md +0 -118
- zrb/task/llm/default_workflow/golang/workflow.md +0 -128
- zrb/task/llm/default_workflow/html-css/workflow.md +0 -135
- zrb/task/llm/default_workflow/java/workflow.md +0 -146
- zrb/task/llm/default_workflow/javascript/workflow.md +0 -158
- zrb/task/llm/default_workflow/python/workflow.md +0 -160
- zrb/task/llm/default_workflow/researching/workflow.md +0 -153
- zrb/task/llm/default_workflow/rust/workflow.md +0 -162
- zrb/task/llm/default_workflow/shell/workflow.md +0 -299
- zrb/task/llm/error.py +0 -95
- zrb/task/llm/file_replacement.py +0 -206
- zrb/task/llm/file_tool_model.py +0 -57
- zrb/task/llm/history_processor.py +0 -206
- zrb/task/llm/history_summarization.py +0 -25
- zrb/task/llm/print_node.py +0 -221
- zrb/task/llm/prompt.py +0 -321
- zrb/task/llm/subagent_conversation_history.py +0 -41
- zrb/task/llm/tool_wrapper.py +0 -361
- zrb/task/llm/typing.py +0 -3
- zrb/task/llm/workflow.py +0 -76
- zrb/task/llm_task.py +0 -379
- zrb/task_status/__init__.py +0 -0
- zrb/util/__init__.py +0 -0
- zrb/util/cli/__init__.py +0 -0
- zrb/util/cmd/__init__.py +0 -0
- zrb/util/codemod/__init__.py +0 -0
- zrb/util/string/__init__.py +0 -0
- zrb/xcom/__init__.py +0 -0
- /zrb/{config/default_prompt/file_extractor_system_prompt.md → llm/prompt/markdown/file_extractor.md} +0 -0
- /zrb/{config/default_prompt/repo_extractor_system_prompt.md → llm/prompt/markdown/repo_extractor.md} +0 -0
- /zrb/{config/default_prompt/repo_summarizer_system_prompt.md → llm/prompt/markdown/repo_summarizer.md} +0 -0
- {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/WHEEL +0 -0
- {zrb-1.21.29.dist-info → zrb-2.0.0a4.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Iterable
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit.completion import (
|
|
6
|
+
CompleteEvent,
|
|
7
|
+
Completer,
|
|
8
|
+
Completion,
|
|
9
|
+
PathCompleter,
|
|
10
|
+
)
|
|
11
|
+
from prompt_toolkit.document import Document
|
|
12
|
+
|
|
13
|
+
from zrb.llm.history_manager.any_history_manager import AnyHistoryManager
|
|
14
|
+
from zrb.util.match import fuzzy_match
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InputCompleter(Completer):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
history_manager: AnyHistoryManager,
|
|
21
|
+
attach_commands: list[str] = [],
|
|
22
|
+
exit_commands: list[str] = [],
|
|
23
|
+
info_commands: list[str] = [],
|
|
24
|
+
save_commands: list[str] = [],
|
|
25
|
+
load_commands: list[str] = [],
|
|
26
|
+
redirect_output_commands: list[str] = [],
|
|
27
|
+
summarize_commands: list[str] = [],
|
|
28
|
+
exec_commands: list[str] = [],
|
|
29
|
+
):
|
|
30
|
+
self._history_manager = history_manager
|
|
31
|
+
self._attach_commands = attach_commands
|
|
32
|
+
self._exit_commands = exit_commands
|
|
33
|
+
self._info_commands = info_commands
|
|
34
|
+
self._save_commands = save_commands
|
|
35
|
+
self._load_commands = load_commands
|
|
36
|
+
self._redirect_output_commands = redirect_output_commands
|
|
37
|
+
self._summarize_commands = summarize_commands
|
|
38
|
+
self._exec_commands = exec_commands
|
|
39
|
+
# expanduser=True allows ~/path
|
|
40
|
+
self._path_completer = PathCompleter(expanduser=True)
|
|
41
|
+
# Cache for file listing to improve performance
|
|
42
|
+
self._file_cache: list[str] | None = None
|
|
43
|
+
self._file_cache_time = 0
|
|
44
|
+
self._cmd_history = self._get_cmd_history()
|
|
45
|
+
|
|
46
|
+
def get_completions(
|
|
47
|
+
self, document: Document, complete_event: CompleteEvent
|
|
48
|
+
) -> Iterable[Completion]:
|
|
49
|
+
text_before_cursor = document.text_before_cursor.lstrip()
|
|
50
|
+
word = document.get_word_before_cursor(WORD=True)
|
|
51
|
+
|
|
52
|
+
all_commands = (
|
|
53
|
+
self._exit_commands
|
|
54
|
+
+ self._attach_commands
|
|
55
|
+
+ self._summarize_commands
|
|
56
|
+
+ self._info_commands
|
|
57
|
+
+ self._save_commands
|
|
58
|
+
+ self._load_commands
|
|
59
|
+
+ self._redirect_output_commands
|
|
60
|
+
+ self._exec_commands
|
|
61
|
+
)
|
|
62
|
+
command_prefixes = {cmd[0] for cmd in all_commands if cmd}
|
|
63
|
+
|
|
64
|
+
# 1. Command and Argument Completion
|
|
65
|
+
if text_before_cursor and text_before_cursor[0] in command_prefixes:
|
|
66
|
+
parts = text_before_cursor.split()
|
|
67
|
+
# Check if we are typing the command itself or arguments
|
|
68
|
+
is_typing_command = len(parts) == 1 and not text_before_cursor.endswith(" ")
|
|
69
|
+
is_typing_arg = (len(parts) == 1 and text_before_cursor.endswith(" ")) or (
|
|
70
|
+
len(parts) >= 2
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if is_typing_command:
|
|
74
|
+
lower_word = word.lower()
|
|
75
|
+
prefix = text_before_cursor[0]
|
|
76
|
+
for cmd in all_commands:
|
|
77
|
+
if cmd.startswith(prefix) and cmd.lower().startswith(lower_word):
|
|
78
|
+
yield Completion(cmd, start_position=-len(word))
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if is_typing_arg:
|
|
82
|
+
cmd = parts[0]
|
|
83
|
+
arg_prefix = text_before_cursor[len(cmd) :].lstrip()
|
|
84
|
+
|
|
85
|
+
# Exec Command: Suggest History
|
|
86
|
+
if self._is_command(cmd, self._exec_commands):
|
|
87
|
+
# Filter history
|
|
88
|
+
matches = [h for h in self._cmd_history if h.startswith(arg_prefix)]
|
|
89
|
+
# Sort matches by length (shorter first) as heuristic? Or just recent?
|
|
90
|
+
# Since _cmd_history is set (unique), we lose order.
|
|
91
|
+
# But Python 3.7+ dicts preserve insertion order, so if we used dict keys, we kept order.
|
|
92
|
+
# Let's assume _get_cmd_history returns recent last.
|
|
93
|
+
# We reverse to show most recent first.
|
|
94
|
+
for h in reversed(matches):
|
|
95
|
+
yield Completion(h, start_position=-len(arg_prefix))
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Check if we are typing the second part (argument) strictly
|
|
99
|
+
# (Re-evaluating logic for other commands which only take 1 arg usually)
|
|
100
|
+
if not (
|
|
101
|
+
(len(parts) == 1 and text_before_cursor.endswith(" "))
|
|
102
|
+
or (len(parts) == 2 and not text_before_cursor.endswith(" "))
|
|
103
|
+
):
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
arg_prefix = parts[1] if len(parts) == 2 else ""
|
|
107
|
+
|
|
108
|
+
# Save Command: Suggest Timestamp
|
|
109
|
+
if self._is_command(cmd, self._save_commands):
|
|
110
|
+
ts = datetime.now().strftime("%Y-%m-%d-%H-%M")
|
|
111
|
+
if ts.startswith(arg_prefix):
|
|
112
|
+
yield Completion(ts, start_position=-len(arg_prefix))
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Redirect Command: Suggest Timestamp.txt
|
|
116
|
+
if self._is_command(cmd, self._redirect_output_commands):
|
|
117
|
+
ts = datetime.now().strftime("%Y-%m-%d-%H-%M.txt")
|
|
118
|
+
if ts.startswith(arg_prefix):
|
|
119
|
+
yield Completion(ts, start_position=-len(arg_prefix))
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Load Command: Search History
|
|
123
|
+
if self._is_command(cmd, self._load_commands):
|
|
124
|
+
results = self._history_manager.search(arg_prefix)
|
|
125
|
+
for res in results[:10]:
|
|
126
|
+
yield Completion(res, start_position=-len(arg_prefix))
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Attach Command: Suggest Files
|
|
130
|
+
if self._is_command(cmd, self._attach_commands):
|
|
131
|
+
yield from self._get_file_completions(
|
|
132
|
+
arg_prefix, complete_event, only_files=True
|
|
133
|
+
)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Other commands (Exit, Info, Summarize) need no completion
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# 2. File Completion (@)
|
|
140
|
+
if word.startswith("@"):
|
|
141
|
+
path_part = word[1:]
|
|
142
|
+
yield from self._get_file_completions(
|
|
143
|
+
path_part, complete_event, only_files=False
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _get_cmd_history(self) -> list[str]:
|
|
147
|
+
history_files = [
|
|
148
|
+
os.path.expanduser("~/.bash_history"),
|
|
149
|
+
os.path.expanduser("~/.zsh_history"),
|
|
150
|
+
]
|
|
151
|
+
unique_cmds = {} # Use dict to preserve order (insertion order)
|
|
152
|
+
|
|
153
|
+
for hist_file in history_files:
|
|
154
|
+
if not os.path.exists(hist_file):
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
with open(hist_file, "r", errors="ignore") as f:
|
|
158
|
+
for line in f:
|
|
159
|
+
line = line.strip()
|
|
160
|
+
if not line:
|
|
161
|
+
continue
|
|
162
|
+
# Handle zsh timestamp format: : 1612345678:0;command
|
|
163
|
+
if line.startswith(": ") and ";" in line:
|
|
164
|
+
parts = line.split(";", 1)
|
|
165
|
+
if len(parts) == 2:
|
|
166
|
+
line = parts[1]
|
|
167
|
+
|
|
168
|
+
if line:
|
|
169
|
+
# Remove existing to update position to end (most recent)
|
|
170
|
+
if line in unique_cmds:
|
|
171
|
+
del unique_cmds[line]
|
|
172
|
+
unique_cmds[line] = None
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
return list(unique_cmds.keys())
|
|
177
|
+
|
|
178
|
+
def _is_command(self, cmd: str, cmd_list: list[str]) -> bool:
|
|
179
|
+
return cmd.lower() in [c.lower() for c in cmd_list]
|
|
180
|
+
|
|
181
|
+
def _get_file_completions(
|
|
182
|
+
self, text: str, complete_event: CompleteEvent, only_files: bool = False
|
|
183
|
+
) -> Iterable[Completion]:
|
|
184
|
+
# Logic:
|
|
185
|
+
# - If text indicates path traversal (/, ., ~), use PathCompleter
|
|
186
|
+
# - Else, check file count. If < 5000, use Fuzzy. Else use PathCompleter.
|
|
187
|
+
|
|
188
|
+
if self._is_path_navigation(text):
|
|
189
|
+
yield from self._get_path_completions(text, complete_event, only_files)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# Count files (cached strategy could be added here if needed)
|
|
193
|
+
files = self._get_recursive_files(limit=5000)
|
|
194
|
+
if len(files) < 5000:
|
|
195
|
+
# Fuzzy Match
|
|
196
|
+
yield from self._get_fuzzy_completions(text, files, only_files)
|
|
197
|
+
else:
|
|
198
|
+
# Fallback to PathCompleter for large repos
|
|
199
|
+
yield from self._get_path_completions(text, complete_event, only_files)
|
|
200
|
+
|
|
201
|
+
def _is_path_navigation(self, text: str) -> bool:
|
|
202
|
+
return (
|
|
203
|
+
text.startswith("/")
|
|
204
|
+
or text.startswith(".")
|
|
205
|
+
or text.startswith("~")
|
|
206
|
+
or os.sep in text
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _get_path_completions(
|
|
210
|
+
self, text: str, complete_event: CompleteEvent, only_files: bool
|
|
211
|
+
) -> Iterable[Completion]:
|
|
212
|
+
# PathCompleter needs a document where text represents the path
|
|
213
|
+
fake_document = Document(text=text, cursor_position=len(text))
|
|
214
|
+
for c in self._path_completer.get_completions(fake_document, complete_event):
|
|
215
|
+
if only_files:
|
|
216
|
+
# Check if the completed path is a directory
|
|
217
|
+
# Note: 'text' is the prefix. c.text is the completion suffix.
|
|
218
|
+
# We need to reconstruct full path to check isdir
|
|
219
|
+
# This is tricky with PathCompleter's internal logic.
|
|
220
|
+
# A simple heuristic: if it ends with path separator, it's a dir.
|
|
221
|
+
if c.text.endswith(os.sep):
|
|
222
|
+
continue
|
|
223
|
+
yield c
|
|
224
|
+
|
|
225
|
+
def _get_fuzzy_completions(
|
|
226
|
+
self, text: str, files: list[str], only_files: bool
|
|
227
|
+
) -> Iterable[Completion]:
|
|
228
|
+
matches = []
|
|
229
|
+
for f in files:
|
|
230
|
+
if only_files and f.endswith(os.sep):
|
|
231
|
+
continue
|
|
232
|
+
is_match, score = fuzzy_match(f, text)
|
|
233
|
+
if is_match:
|
|
234
|
+
matches.append((score, f))
|
|
235
|
+
|
|
236
|
+
# Sort by score (lower is better)
|
|
237
|
+
matches.sort(key=lambda x: x[0])
|
|
238
|
+
|
|
239
|
+
# Return top 20
|
|
240
|
+
for _, f in matches[:20]:
|
|
241
|
+
yield Completion(f, start_position=-len(text))
|
|
242
|
+
|
|
243
|
+
def _get_recursive_files(self, root: str = ".", limit: int = 5000) -> list[str]:
|
|
244
|
+
# Simple walker with exclusions
|
|
245
|
+
paths = []
|
|
246
|
+
# Check if current dir is hidden
|
|
247
|
+
cwd_is_hidden = os.path.basename(os.path.abspath(root)).startswith(".")
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
251
|
+
# Exclude hidden directories unless root is hidden
|
|
252
|
+
if not cwd_is_hidden:
|
|
253
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
254
|
+
|
|
255
|
+
# Exclude common ignores
|
|
256
|
+
dirnames[:] = [
|
|
257
|
+
d
|
|
258
|
+
for d in dirnames
|
|
259
|
+
if d not in ("node_modules", "__pycache__", "venv", ".venv")
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
263
|
+
if rel_dir == ".":
|
|
264
|
+
rel_dir = ""
|
|
265
|
+
|
|
266
|
+
# Add directories
|
|
267
|
+
for d in dirnames:
|
|
268
|
+
paths.append(os.path.join(rel_dir, d) + os.sep)
|
|
269
|
+
if len(paths) >= limit:
|
|
270
|
+
return paths
|
|
271
|
+
|
|
272
|
+
# Add files
|
|
273
|
+
for f in filenames:
|
|
274
|
+
if not cwd_is_hidden and f.startswith("."):
|
|
275
|
+
continue
|
|
276
|
+
paths.append(os.path.join(rel_dir, f))
|
|
277
|
+
if len(paths) >= limit:
|
|
278
|
+
return paths
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
return paths
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from zrb.llm.app.confirmation.handler import ConfirmationMiddleware, UIProtocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def allow_tool_usage(
|
|
9
|
+
tool_name: str, kwargs: Optional[Dict[str, str]] = None
|
|
10
|
+
) -> ConfirmationMiddleware:
|
|
11
|
+
"""
|
|
12
|
+
Creates a confirmation middleware that automatically approves a tool execution
|
|
13
|
+
if it matches the specified tool_name and argument constraints.
|
|
14
|
+
|
|
15
|
+
:param tool_name: The name of the tool to allow.
|
|
16
|
+
:param kwargs: A dictionary of regex patterns for arguments.
|
|
17
|
+
If None or empty, the tool is allowed regardless of arguments.
|
|
18
|
+
If provided, arguments in the tool call must match the regex patterns
|
|
19
|
+
specified in kwargs (only for arguments present in both).
|
|
20
|
+
:return: A ConfirmationMiddleware function.
|
|
21
|
+
"""
|
|
22
|
+
from pydantic_ai import ToolApproved
|
|
23
|
+
|
|
24
|
+
async def middleware(
|
|
25
|
+
ui: UIProtocol,
|
|
26
|
+
call: Any,
|
|
27
|
+
response: str,
|
|
28
|
+
next_handler: Callable[[UIProtocol, Any, str], Awaitable[Any]],
|
|
29
|
+
) -> Any:
|
|
30
|
+
# Check if tool name matches
|
|
31
|
+
if call.tool_name != tool_name:
|
|
32
|
+
return await next_handler(ui, call, response)
|
|
33
|
+
|
|
34
|
+
# If kwargs is empty or None, approve
|
|
35
|
+
if not kwargs:
|
|
36
|
+
ui.append_to_output(f"\n✅ Auto-approved tool: {tool_name}")
|
|
37
|
+
return ToolApproved()
|
|
38
|
+
|
|
39
|
+
# Parse arguments
|
|
40
|
+
try:
|
|
41
|
+
args = call.args
|
|
42
|
+
if isinstance(args, str):
|
|
43
|
+
args = json.loads(args)
|
|
44
|
+
|
|
45
|
+
if not isinstance(args, dict):
|
|
46
|
+
# If args is not a dict (e.g. primitive), and kwargs is not empty,
|
|
47
|
+
# we assume it doesn't match complex constraints (or we can't check keys).
|
|
48
|
+
# So we delegate to the next handler.
|
|
49
|
+
return await next_handler(ui, call, response)
|
|
50
|
+
|
|
51
|
+
except (json.JSONDecodeError, ValueError):
|
|
52
|
+
return await next_handler(ui, call, response)
|
|
53
|
+
|
|
54
|
+
# Check constraints
|
|
55
|
+
# "all parameter in the call parameter has to match the ones in kwargs (if that parameter defined in the kwargs)"
|
|
56
|
+
for arg_name, arg_value in args.items():
|
|
57
|
+
if arg_name in kwargs:
|
|
58
|
+
pattern = kwargs[arg_name]
|
|
59
|
+
# Convert arg_value to string for regex matching
|
|
60
|
+
if not re.search(pattern, str(arg_value)):
|
|
61
|
+
return await next_handler(ui, call, response)
|
|
62
|
+
|
|
63
|
+
ui.append_to_output(f"\n✅ Auto-approved tool: {tool_name} with matching args")
|
|
64
|
+
return ToolApproved()
|
|
65
|
+
|
|
66
|
+
return middleware
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import Any, Awaitable, Callable, Protocol, TextIO
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from zrb.config.config import CFG
|
|
10
|
+
from zrb.util.yaml import yaml_dump
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UIProtocol(Protocol):
|
|
14
|
+
async def ask_user(self, prompt: str) -> str: ...
|
|
15
|
+
|
|
16
|
+
def append_to_output(
|
|
17
|
+
self,
|
|
18
|
+
*values: object,
|
|
19
|
+
sep: str = " ",
|
|
20
|
+
end: str = "\n",
|
|
21
|
+
file: TextIO | None = None,
|
|
22
|
+
flush: bool = False,
|
|
23
|
+
): ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ConfirmationMiddleware = Callable[
|
|
27
|
+
[UIProtocol, Any, str, Callable[[UIProtocol, Any, str], Awaitable[Any]]],
|
|
28
|
+
Awaitable[Any],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfirmationHandler:
|
|
33
|
+
def __init__(self, middlewares: list[ConfirmationMiddleware]):
|
|
34
|
+
self._middlewares = middlewares
|
|
35
|
+
|
|
36
|
+
def add_middleware(self, *middleware: ConfirmationMiddleware):
|
|
37
|
+
self.prepend_middleware(*middleware)
|
|
38
|
+
|
|
39
|
+
def prepend_middleware(self, *middleware: ConfirmationMiddleware):
|
|
40
|
+
self._middlewares = list(middleware) + self._middlewares
|
|
41
|
+
|
|
42
|
+
async def handle(self, ui: UIProtocol, call: Any) -> Any:
|
|
43
|
+
while True:
|
|
44
|
+
message = self._get_confirm_user_message(call)
|
|
45
|
+
ui.append_to_output(f"\n\n{message}", end="")
|
|
46
|
+
# Wait for user input
|
|
47
|
+
user_input = await ui.ask_user("")
|
|
48
|
+
user_response = user_input.strip()
|
|
49
|
+
|
|
50
|
+
# Build the chain
|
|
51
|
+
async def _next(
|
|
52
|
+
ui: UIProtocol, call: Any, response: str, index: int
|
|
53
|
+
) -> Any:
|
|
54
|
+
if index >= len(self._middlewares):
|
|
55
|
+
# Default if no middleware handles it
|
|
56
|
+
return None
|
|
57
|
+
middleware = self._middlewares[index]
|
|
58
|
+
return await middleware(
|
|
59
|
+
ui,
|
|
60
|
+
call,
|
|
61
|
+
response,
|
|
62
|
+
lambda u, c, r: _next(u, c, r, index + 1),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
result = await _next(ui, call, user_response, 0)
|
|
66
|
+
if result is None:
|
|
67
|
+
continue
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def _get_confirm_user_message(self, call: Any) -> str:
|
|
71
|
+
args_section = ""
|
|
72
|
+
if f"{call.args}" != "{}":
|
|
73
|
+
args_str = self._format_args(call.args)
|
|
74
|
+
args_section = f" Arguments:\n{args_str}\n"
|
|
75
|
+
return (
|
|
76
|
+
f" 🎰 Executing tool '{call.tool_name}'\n"
|
|
77
|
+
f"{args_section}"
|
|
78
|
+
" ❓ Allow tool Execution? (✅ Y | 🛑 n | ✏️ e)? "
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _format_args(self, args: Any) -> str:
|
|
82
|
+
indent = " " * 7
|
|
83
|
+
try:
|
|
84
|
+
if isinstance(args, str):
|
|
85
|
+
try:
|
|
86
|
+
args = json.loads(args)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
pass
|
|
89
|
+
args_str = yaml_dump(args)
|
|
90
|
+
# Indent nicely for display
|
|
91
|
+
return "\n".join([f"{indent}{line}" for line in args_str.splitlines()])
|
|
92
|
+
except Exception:
|
|
93
|
+
return f"{indent}{args}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def last_confirmation(
|
|
97
|
+
ui: UIProtocol,
|
|
98
|
+
call: Any,
|
|
99
|
+
user_response: str,
|
|
100
|
+
next_handler: Callable[[UIProtocol, Any, str], Awaitable[Any]],
|
|
101
|
+
) -> Any:
|
|
102
|
+
from pydantic_ai import ToolApproved, ToolDenied
|
|
103
|
+
|
|
104
|
+
if user_response.lower() in ("y", "yes", "ok", "okay", ""):
|
|
105
|
+
ui.append_to_output("\n✅ Execution approved.")
|
|
106
|
+
return ToolApproved()
|
|
107
|
+
elif user_response.lower() in ("n", "no"):
|
|
108
|
+
ui.append_to_output("\n🛑 Execution denied.")
|
|
109
|
+
return ToolDenied("User denied execution")
|
|
110
|
+
elif user_response.lower() in ("e", "edit"):
|
|
111
|
+
# Edit logic
|
|
112
|
+
try:
|
|
113
|
+
args = call.args
|
|
114
|
+
if isinstance(args, str):
|
|
115
|
+
try:
|
|
116
|
+
args = json.loads(args)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# YAML for editing
|
|
121
|
+
is_yaml_edit = True
|
|
122
|
+
try:
|
|
123
|
+
content = yaml_dump(args)
|
|
124
|
+
extension = ".yaml"
|
|
125
|
+
except Exception:
|
|
126
|
+
# Fallback to JSON
|
|
127
|
+
content = json.dumps(args, indent=2)
|
|
128
|
+
extension = ".json"
|
|
129
|
+
is_yaml_edit = False
|
|
130
|
+
|
|
131
|
+
new_content = await wait_edit_content(
|
|
132
|
+
text_editor=CFG.DEFAULT_EDITOR,
|
|
133
|
+
content=content,
|
|
134
|
+
extension=extension,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Compare content
|
|
138
|
+
if new_content == content:
|
|
139
|
+
ui.append_to_output("\nℹ️ No changes made.")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
if is_yaml_edit:
|
|
144
|
+
new_args = yaml.safe_load(new_content)
|
|
145
|
+
else:
|
|
146
|
+
new_args = json.loads(new_content)
|
|
147
|
+
ui.append_to_output("\n✅ Execution approved (with modification).")
|
|
148
|
+
return ToolApproved(override_args=new_args)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
ui.append_to_output(f"\n❌ Invalid format: {e}. ", end="")
|
|
151
|
+
# Return None to signal loop retry
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
ui.append_to_output(f"\n❌ Error editing: {e}. ", end="")
|
|
156
|
+
return None
|
|
157
|
+
else:
|
|
158
|
+
ui.append_to_output("\n🛑 Execution denied.")
|
|
159
|
+
return ToolDenied(f"User denied execution with message: {user_response}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def wait_edit_content(
|
|
163
|
+
text_editor: str, content: str, extension: str = ".txt"
|
|
164
|
+
) -> str:
|
|
165
|
+
from prompt_toolkit.application import run_in_terminal
|
|
166
|
+
|
|
167
|
+
# Write temporary file
|
|
168
|
+
with tempfile.NamedTemporaryFile(suffix=extension, mode="w+", delete=False) as tf:
|
|
169
|
+
tf.write(content)
|
|
170
|
+
tf_path = tf.name
|
|
171
|
+
|
|
172
|
+
# Edit and wait
|
|
173
|
+
await run_in_terminal(lambda: subprocess.call([text_editor, tf_path]))
|
|
174
|
+
with open(tf_path, "r") as tf:
|
|
175
|
+
new_content = tf.read()
|
|
176
|
+
os.remove(tf_path)
|
|
177
|
+
|
|
178
|
+
return new_content
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import Any, Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.application import run_in_terminal
|
|
8
|
+
|
|
9
|
+
from zrb.config.config import CFG
|
|
10
|
+
from zrb.llm.app.confirmation.handler import UIProtocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def replace_confirmation(
|
|
14
|
+
ui: UIProtocol,
|
|
15
|
+
call: Any,
|
|
16
|
+
response: str,
|
|
17
|
+
next_handler: Callable[[UIProtocol, Any, str], Awaitable[Any]],
|
|
18
|
+
) -> Any:
|
|
19
|
+
from pydantic_ai import ToolApproved
|
|
20
|
+
|
|
21
|
+
if call.tool_name != "replace_in_file":
|
|
22
|
+
return await next_handler(ui, call, response)
|
|
23
|
+
|
|
24
|
+
if response.lower() not in ("e", "edit"):
|
|
25
|
+
return await next_handler(ui, call, response)
|
|
26
|
+
|
|
27
|
+
# It is replace_in_file and user wants to edit
|
|
28
|
+
args = call.args
|
|
29
|
+
if isinstance(args, str):
|
|
30
|
+
try:
|
|
31
|
+
args = json.loads(args)
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
old_text = args.get("old_text", "")
|
|
36
|
+
new_text = args.get("new_text", "")
|
|
37
|
+
|
|
38
|
+
# Create temporary files
|
|
39
|
+
with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".old") as tf_old:
|
|
40
|
+
tf_old.write(old_text)
|
|
41
|
+
old_path = tf_old.name
|
|
42
|
+
|
|
43
|
+
with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".new") as tf_new:
|
|
44
|
+
tf_new.write(new_text)
|
|
45
|
+
new_path = tf_new.name
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Prepare command
|
|
49
|
+
cmd_tpl = CFG.DEFAULT_DIFF_EDIT_COMMAND_TPL
|
|
50
|
+
cmd = cmd_tpl.format(old=old_path, new=new_path)
|
|
51
|
+
|
|
52
|
+
# Run command
|
|
53
|
+
await run_in_terminal(lambda: subprocess.call(cmd, shell=True))
|
|
54
|
+
|
|
55
|
+
# Read back new content
|
|
56
|
+
with open(new_path, "r") as f:
|
|
57
|
+
edited_new_text = f.read()
|
|
58
|
+
|
|
59
|
+
if edited_new_text != new_text:
|
|
60
|
+
# Update args
|
|
61
|
+
new_args = dict(args)
|
|
62
|
+
new_args["new_text"] = edited_new_text
|
|
63
|
+
ui.append_to_output("\n✅ Replacement modified.")
|
|
64
|
+
return ToolApproved(override_args=new_args)
|
|
65
|
+
else:
|
|
66
|
+
ui.append_to_output("\nℹ️ No changes made.")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
ui.append_to_output(f"\n❌ Error during diff edit: {e}")
|
|
71
|
+
return None
|
|
72
|
+
finally:
|
|
73
|
+
# Cleanup
|
|
74
|
+
if os.path.exists(old_path):
|
|
75
|
+
os.remove(old_path)
|
|
76
|
+
if os.path.exists(new_path):
|
|
77
|
+
os.remove(new_path)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import string
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit.application import get_app
|
|
4
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
5
|
+
from prompt_toolkit.widgets import TextArea
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_output_keybindings(input_field: TextArea) -> KeyBindings:
|
|
9
|
+
kb = KeyBindings()
|
|
10
|
+
|
|
11
|
+
@kb.add("escape")
|
|
12
|
+
def _(event):
|
|
13
|
+
get_app().layout.focus(input_field)
|
|
14
|
+
|
|
15
|
+
@kb.add("c-c")
|
|
16
|
+
def _(event):
|
|
17
|
+
# Copy selection to clipboard
|
|
18
|
+
if event.current_buffer.selection_state:
|
|
19
|
+
data = event.current_buffer.copy_selection()
|
|
20
|
+
event.app.clipboard.set_data(data)
|
|
21
|
+
get_app().layout.focus(input_field)
|
|
22
|
+
|
|
23
|
+
def redirect_focus(event):
|
|
24
|
+
get_app().layout.focus(input_field)
|
|
25
|
+
input_field.buffer.insert_text(event.data)
|
|
26
|
+
|
|
27
|
+
for char in string.printable:
|
|
28
|
+
# Skip control characters (Tab, Newline, etc.)
|
|
29
|
+
# to preserve navigation/standard behavior
|
|
30
|
+
if char in "\t\n\r\x0b\x0c":
|
|
31
|
+
continue
|
|
32
|
+
kb.add(char)(redirect_focus)
|
|
33
|
+
|
|
34
|
+
return kb
|