zrb 1.13.1__py3-none-any.whl → 1.21.33__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.
- zrb/__init__.py +2 -6
- zrb/attr/type.py +10 -7
- zrb/builtin/__init__.py +2 -0
- zrb/builtin/git.py +12 -1
- zrb/builtin/group.py +31 -15
- zrb/builtin/http.py +7 -8
- zrb/builtin/llm/attachment.py +40 -0
- zrb/builtin/llm/chat_completion.py +287 -0
- zrb/builtin/llm/chat_session.py +130 -144
- zrb/builtin/llm/chat_session_cmd.py +288 -0
- zrb/builtin/llm/chat_trigger.py +78 -0
- zrb/builtin/llm/history.py +4 -4
- zrb/builtin/llm/llm_ask.py +218 -110
- zrb/builtin/llm/tool/api.py +74 -62
- zrb/builtin/llm/tool/cli.py +56 -21
- zrb/builtin/llm/tool/code.py +57 -47
- zrb/builtin/llm/tool/file.py +292 -255
- zrb/builtin/llm/tool/note.py +84 -0
- zrb/builtin/llm/tool/rag.py +25 -18
- zrb/builtin/llm/tool/search/__init__.py +1 -0
- zrb/builtin/llm/tool/search/brave.py +66 -0
- zrb/builtin/llm/tool/search/searxng.py +61 -0
- zrb/builtin/llm/tool/search/serpapi.py +61 -0
- zrb/builtin/llm/tool/sub_agent.py +53 -26
- zrb/builtin/llm/tool/web.py +94 -157
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
- zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
- zrb/builtin/searxng/config/settings.yml +5671 -0
- zrb/builtin/searxng/start.py +21 -0
- zrb/builtin/setup/latex/ubuntu.py +1 -0
- zrb/builtin/setup/ubuntu.py +1 -1
- zrb/builtin/shell/autocomplete/bash.py +4 -3
- zrb/builtin/shell/autocomplete/zsh.py +4 -3
- zrb/config/config.py +297 -79
- zrb/config/default_prompt/file_extractor_system_prompt.md +109 -9
- zrb/config/default_prompt/interactive_system_prompt.md +25 -28
- zrb/config/default_prompt/persona.md +1 -1
- zrb/config/default_prompt/repo_extractor_system_prompt.md +31 -31
- zrb/config/default_prompt/repo_summarizer_system_prompt.md +27 -8
- zrb/config/default_prompt/summarization_prompt.md +57 -16
- zrb/config/default_prompt/system_prompt.md +29 -25
- zrb/config/llm_config.py +129 -24
- zrb/config/llm_context/config.py +127 -90
- zrb/config/llm_context/config_parser.py +1 -7
- zrb/config/llm_context/workflow.py +81 -0
- zrb/config/llm_rate_limitter.py +100 -47
- zrb/context/any_shared_context.py +7 -1
- zrb/context/context.py +8 -2
- zrb/context/shared_context.py +6 -8
- zrb/group/any_group.py +12 -5
- zrb/group/group.py +67 -3
- zrb/input/any_input.py +5 -1
- zrb/input/base_input.py +18 -6
- zrb/input/option_input.py +13 -1
- zrb/input/text_input.py +7 -24
- zrb/runner/cli.py +21 -20
- zrb/runner/common_util.py +24 -19
- zrb/runner/web_route/task_input_api_route.py +5 -5
- zrb/runner/web_route/task_session_api_route.py +1 -4
- zrb/runner/web_util/user.py +7 -3
- zrb/session/any_session.py +12 -6
- zrb/session/session.py +39 -18
- zrb/task/any_task.py +24 -3
- zrb/task/base/context.py +17 -9
- zrb/task/base/execution.py +15 -8
- zrb/task/base/lifecycle.py +8 -4
- zrb/task/base/monitoring.py +12 -7
- zrb/task/base_task.py +69 -5
- zrb/task/base_trigger.py +12 -5
- zrb/task/llm/agent.py +130 -145
- zrb/task/llm/agent_runner.py +152 -0
- zrb/task/llm/config.py +45 -13
- zrb/task/llm/conversation_history.py +110 -29
- zrb/task/llm/conversation_history_model.py +4 -179
- zrb/task/llm/default_workflow/coding/workflow.md +41 -0
- zrb/task/llm/default_workflow/copywriting/workflow.md +68 -0
- zrb/task/llm/default_workflow/git/workflow.md +118 -0
- zrb/task/llm/default_workflow/golang/workflow.md +128 -0
- zrb/task/llm/default_workflow/html-css/workflow.md +135 -0
- zrb/task/llm/default_workflow/java/workflow.md +146 -0
- zrb/task/llm/default_workflow/javascript/workflow.md +158 -0
- zrb/task/llm/default_workflow/python/workflow.md +160 -0
- zrb/task/llm/default_workflow/researching/workflow.md +153 -0
- zrb/task/llm/default_workflow/rust/workflow.md +162 -0
- zrb/task/llm/default_workflow/shell/workflow.md +299 -0
- zrb/task/llm/file_replacement.py +206 -0
- zrb/task/llm/file_tool_model.py +57 -0
- zrb/task/llm/history_processor.py +206 -0
- zrb/task/llm/history_summarization.py +2 -192
- zrb/task/llm/print_node.py +192 -64
- zrb/task/llm/prompt.py +198 -153
- zrb/task/llm/subagent_conversation_history.py +41 -0
- zrb/task/llm/tool_confirmation_completer.py +41 -0
- zrb/task/llm/tool_wrapper.py +216 -55
- zrb/task/llm/workflow.py +76 -0
- zrb/task/llm_task.py +122 -70
- zrb/task/make_task.py +2 -3
- zrb/task/rsync_task.py +25 -10
- zrb/task/scheduler.py +4 -4
- zrb/util/attr.py +54 -39
- zrb/util/cli/markdown.py +12 -0
- zrb/util/cli/text.py +30 -0
- zrb/util/file.py +27 -11
- zrb/util/git.py +2 -2
- zrb/util/{llm/prompt.py → markdown.py} +2 -3
- zrb/util/string/conversion.py +1 -1
- zrb/util/truncate.py +23 -0
- zrb/util/yaml.py +204 -0
- zrb/xcom/xcom.py +10 -0
- {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/METADATA +40 -20
- {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/RECORD +114 -83
- {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/WHEEL +1 -1
- zrb/task/llm/default_workflow/coding.md +0 -24
- zrb/task/llm/default_workflow/copywriting.md +0 -17
- zrb/task/llm/default_workflow/researching.md +0 -18
- {zrb-1.13.1.dist-info → zrb-1.21.33.dist-info}/entry_points.txt +0 -0
zrb/config/llm_config.py
CHANGED
|
@@ -2,8 +2,6 @@ import os
|
|
|
2
2
|
from typing import TYPE_CHECKING, Any, Callable
|
|
3
3
|
|
|
4
4
|
from zrb.config.config import CFG
|
|
5
|
-
from zrb.config.llm_context.config import llm_context_config
|
|
6
|
-
from zrb.util.llm.prompt import make_prompt_section
|
|
7
5
|
|
|
8
6
|
if TYPE_CHECKING:
|
|
9
7
|
from pydantic_ai.models import Model
|
|
@@ -15,8 +13,11 @@ class LLMConfig:
|
|
|
15
13
|
def __init__(
|
|
16
14
|
self,
|
|
17
15
|
default_model_name: str | None = None,
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
default_model_base_url: str | None = None,
|
|
17
|
+
default_model_api_key: str | None = None,
|
|
18
|
+
default_small_model_name: str | None = None,
|
|
19
|
+
default_small_model_base_url: str | None = None,
|
|
20
|
+
default_small_model_api_key: str | None = None,
|
|
20
21
|
default_persona: str | None = None,
|
|
21
22
|
default_system_prompt: str | None = None,
|
|
22
23
|
default_interactive_system_prompt: str | None = None,
|
|
@@ -24,15 +25,25 @@ class LLMConfig:
|
|
|
24
25
|
default_summarization_prompt: str | None = None,
|
|
25
26
|
default_summarize_history: bool | None = None,
|
|
26
27
|
default_history_summarization_token_threshold: int | None = None,
|
|
27
|
-
|
|
28
|
+
default_workflows: list[str] | None = None,
|
|
28
29
|
default_model: "Model | None" = None,
|
|
29
30
|
default_model_settings: "ModelSettings | None" = None,
|
|
30
31
|
default_model_provider: "Provider | None" = None,
|
|
32
|
+
default_small_model: "Model | None" = None,
|
|
33
|
+
default_small_model_settings: "ModelSettings | None" = None,
|
|
34
|
+
default_small_model_provider: "Provider | None" = None,
|
|
35
|
+
default_yolo_mode: bool | list[str] | None = None,
|
|
36
|
+
default_current_weather_tool: Callable | None = None,
|
|
37
|
+
default_current_location_tool: Callable | None = None,
|
|
38
|
+
default_search_internet_tool: Callable | None = None,
|
|
31
39
|
):
|
|
32
40
|
self.__internal_default_prompt: dict[str, str] = {}
|
|
33
41
|
self._default_model_name = default_model_name
|
|
34
|
-
self._default_model_base_url =
|
|
35
|
-
self._default_model_api_key =
|
|
42
|
+
self._default_model_base_url = default_model_base_url
|
|
43
|
+
self._default_model_api_key = default_model_api_key
|
|
44
|
+
self._default_small_model_name = default_small_model_name
|
|
45
|
+
self._default_small_model_base_url = default_small_model_base_url
|
|
46
|
+
self._default_small_model_api_key = default_small_model_api_key
|
|
36
47
|
self._default_persona = default_persona
|
|
37
48
|
self._default_system_prompt = default_system_prompt
|
|
38
49
|
self._default_interactive_system_prompt = default_interactive_system_prompt
|
|
@@ -42,10 +53,17 @@ class LLMConfig:
|
|
|
42
53
|
self._default_history_summarization_token_threshold = (
|
|
43
54
|
default_history_summarization_token_threshold
|
|
44
55
|
)
|
|
45
|
-
self.
|
|
56
|
+
self._default_workflows = default_workflows
|
|
46
57
|
self._default_model = default_model
|
|
47
58
|
self._default_model_settings = default_model_settings
|
|
48
59
|
self._default_model_provider = default_model_provider
|
|
60
|
+
self._default_small_model = default_small_model
|
|
61
|
+
self._default_small_model_settings = default_small_model_settings
|
|
62
|
+
self._default_small_model_provider = default_small_model_provider
|
|
63
|
+
self._default_yolo_mode = default_yolo_mode
|
|
64
|
+
self._default_current_weather_tool = default_current_weather_tool
|
|
65
|
+
self._default_current_location_tool = default_current_location_tool
|
|
66
|
+
self._default_search_internet_tool = default_search_internet_tool
|
|
49
67
|
|
|
50
68
|
def _get_internal_default_prompt(self, name: str) -> str:
|
|
51
69
|
if name not in self.__internal_default_prompt:
|
|
@@ -100,6 +118,54 @@ class LLMConfig:
|
|
|
100
118
|
base_url=self.default_model_base_url, api_key=self.default_model_api_key
|
|
101
119
|
)
|
|
102
120
|
|
|
121
|
+
@property
|
|
122
|
+
def default_small_model_name(self) -> str | None:
|
|
123
|
+
return self._get_property(
|
|
124
|
+
self._default_small_model_name,
|
|
125
|
+
CFG.LLM_MODEL_SMALL,
|
|
126
|
+
lambda: self.default_model_name,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def default_small_model_base_url(self) -> str | None:
|
|
131
|
+
return self._get_property(
|
|
132
|
+
self._default_small_model_base_url,
|
|
133
|
+
CFG.LLM_BASE_URL_SMALL,
|
|
134
|
+
lambda: self.default_model_base_url,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def default_small_model_api_key(self) -> str | None:
|
|
139
|
+
return self._get_property(
|
|
140
|
+
self._default_small_model_api_key,
|
|
141
|
+
CFG.LLM_API_KEY_SMALL,
|
|
142
|
+
lambda: self.default_model_api_key,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def default_small_model_settings(self) -> "ModelSettings | None":
|
|
147
|
+
return self._get_property(
|
|
148
|
+
self._default_small_model_settings,
|
|
149
|
+
None,
|
|
150
|
+
lambda: self.default_model_settings,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def default_small_model_provider(self) -> "Provider | str":
|
|
155
|
+
if self._default_small_model_provider is not None:
|
|
156
|
+
return self._default_small_model_provider
|
|
157
|
+
if (
|
|
158
|
+
self.default_small_model_base_url is None
|
|
159
|
+
and self.default_small_model_api_key is None
|
|
160
|
+
):
|
|
161
|
+
return self.default_model_provider
|
|
162
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
163
|
+
|
|
164
|
+
return OpenAIProvider(
|
|
165
|
+
base_url=self.default_small_model_base_url,
|
|
166
|
+
api_key=self.default_small_model_api_key,
|
|
167
|
+
)
|
|
168
|
+
|
|
103
169
|
@property
|
|
104
170
|
def default_system_prompt(self) -> str:
|
|
105
171
|
return self._get_property(
|
|
@@ -125,9 +191,9 @@ class LLMConfig:
|
|
|
125
191
|
)
|
|
126
192
|
|
|
127
193
|
@property
|
|
128
|
-
def
|
|
194
|
+
def default_workflows(self) -> list[str]:
|
|
129
195
|
return self._get_property(
|
|
130
|
-
self.
|
|
196
|
+
self._default_workflows, CFG.LLM_WORKFLOWS, lambda: []
|
|
131
197
|
)
|
|
132
198
|
|
|
133
199
|
@property
|
|
@@ -147,19 +213,28 @@ class LLMConfig:
|
|
|
147
213
|
)
|
|
148
214
|
|
|
149
215
|
@property
|
|
150
|
-
def default_model(self) -> "Model | str
|
|
216
|
+
def default_model(self) -> "Model | str":
|
|
151
217
|
if self._default_model is not None:
|
|
152
218
|
return self._default_model
|
|
153
219
|
model_name = self.default_model_name
|
|
154
220
|
if model_name is None:
|
|
155
|
-
return
|
|
156
|
-
from pydantic_ai.models.openai import
|
|
221
|
+
return "openai:gpt-4o"
|
|
222
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
157
223
|
|
|
158
|
-
return
|
|
224
|
+
return OpenAIChatModel(
|
|
159
225
|
model_name=model_name,
|
|
160
226
|
provider=self.default_model_provider,
|
|
161
227
|
)
|
|
162
228
|
|
|
229
|
+
@property
|
|
230
|
+
def default_small_model(self) -> "Model | str":
|
|
231
|
+
if self._default_small_model is not None:
|
|
232
|
+
return self._default_small_model
|
|
233
|
+
model_name = self.default_small_model_name
|
|
234
|
+
if model_name is None:
|
|
235
|
+
return "openai:gpt-4o"
|
|
236
|
+
return self.default_model
|
|
237
|
+
|
|
163
238
|
@property
|
|
164
239
|
def default_summarize_history(self) -> bool:
|
|
165
240
|
return self._get_property(
|
|
@@ -174,6 +249,24 @@ class LLMConfig:
|
|
|
174
249
|
lambda: 1000,
|
|
175
250
|
)
|
|
176
251
|
|
|
252
|
+
@property
|
|
253
|
+
def default_yolo_mode(self) -> bool | list[str]:
|
|
254
|
+
return self._get_property(
|
|
255
|
+
self._default_yolo_mode, CFG.LLM_YOLO_MODE, lambda: False
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def default_current_weather_tool(self) -> Callable | None:
|
|
260
|
+
return self._default_current_weather_tool
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def default_current_location_tool(self) -> Callable | None:
|
|
264
|
+
return self._default_current_location_tool
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def default_search_internet_tool(self) -> Callable | None:
|
|
268
|
+
return self._default_search_internet_tool
|
|
269
|
+
|
|
177
270
|
def set_default_persona(self, persona: str):
|
|
178
271
|
self._default_persona = persona
|
|
179
272
|
|
|
@@ -186,18 +279,18 @@ class LLMConfig:
|
|
|
186
279
|
def set_default_special_instruction_prompt(self, special_instruction_prompt: str):
|
|
187
280
|
self._default_special_instruction_prompt = special_instruction_prompt
|
|
188
281
|
|
|
189
|
-
def
|
|
190
|
-
self.
|
|
282
|
+
def set_default_workflows(self, workflows: list[str]):
|
|
283
|
+
self._default_workflows = workflows
|
|
191
284
|
|
|
192
|
-
def
|
|
193
|
-
if self.
|
|
194
|
-
self.
|
|
195
|
-
self.
|
|
285
|
+
def add_default_workflow(self, workflow: str):
|
|
286
|
+
if self._default_workflows is None:
|
|
287
|
+
self._default_workflows = []
|
|
288
|
+
self._default_workflows.append(workflow)
|
|
196
289
|
|
|
197
|
-
def
|
|
198
|
-
if self.
|
|
199
|
-
self.
|
|
200
|
-
self.
|
|
290
|
+
def remove_default_workflow(self, workflow: str):
|
|
291
|
+
if self._default_workflows is None:
|
|
292
|
+
self._default_workflows = []
|
|
293
|
+
self._default_workflows.remove(workflow)
|
|
201
294
|
|
|
202
295
|
def set_default_summarization_prompt(self, summarization_prompt: str):
|
|
203
296
|
self._default_summarization_prompt = summarization_prompt
|
|
@@ -230,5 +323,17 @@ class LLMConfig:
|
|
|
230
323
|
def set_default_model_settings(self, model_settings: "ModelSettings"):
|
|
231
324
|
self._default_model_settings = model_settings
|
|
232
325
|
|
|
326
|
+
def set_default_yolo_mode(self, yolo_mode: bool | list[str]):
|
|
327
|
+
self._default_yolo_mode = yolo_mode
|
|
328
|
+
|
|
329
|
+
def set_default_current_weather_tool(self, tool: Callable):
|
|
330
|
+
self._default_current_weather_tool = tool
|
|
331
|
+
|
|
332
|
+
def set_default_current_location_tool(self, tool: Callable):
|
|
333
|
+
self._default_current_location_tool = tool
|
|
334
|
+
|
|
335
|
+
def set_default_search_internet_tool(self, tool: Callable):
|
|
336
|
+
self._default_search_internet_tool = tool
|
|
337
|
+
|
|
233
338
|
|
|
234
339
|
llm_config = LLMConfig()
|
zrb/config/llm_context/config.py
CHANGED
|
@@ -2,12 +2,124 @@ import os
|
|
|
2
2
|
|
|
3
3
|
from zrb.config.config import CFG
|
|
4
4
|
from zrb.config.llm_context.config_parser import markdown_to_dict
|
|
5
|
-
from zrb.
|
|
5
|
+
from zrb.config.llm_context.workflow import LLMWorkflow
|
|
6
|
+
from zrb.util.markdown import demote_markdown_headers
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class LLMContextConfig:
|
|
9
10
|
"""High-level API for interacting with cascaded configurations."""
|
|
10
11
|
|
|
12
|
+
def write_note(
|
|
13
|
+
self,
|
|
14
|
+
content: str,
|
|
15
|
+
context_path: str | None = None,
|
|
16
|
+
cwd: str | None = None,
|
|
17
|
+
):
|
|
18
|
+
"""Writes content to a note block in the user's home configuration file."""
|
|
19
|
+
if cwd is None:
|
|
20
|
+
cwd = os.getcwd()
|
|
21
|
+
if context_path is None:
|
|
22
|
+
context_path = cwd
|
|
23
|
+
config_file = self._get_home_config_file()
|
|
24
|
+
sections = {}
|
|
25
|
+
if os.path.exists(config_file):
|
|
26
|
+
sections = self._parse_config(config_file)
|
|
27
|
+
abs_context_path = os.path.abspath(os.path.join(cwd, context_path))
|
|
28
|
+
found_key = None
|
|
29
|
+
for key in sections.keys():
|
|
30
|
+
if not key.startswith("Note:"):
|
|
31
|
+
continue
|
|
32
|
+
context_path_str = key[len("Note:") :].strip()
|
|
33
|
+
abs_key_path = self._normalize_context_path(
|
|
34
|
+
context_path_str,
|
|
35
|
+
os.path.dirname(config_file),
|
|
36
|
+
)
|
|
37
|
+
if abs_key_path == abs_context_path:
|
|
38
|
+
found_key = key
|
|
39
|
+
break
|
|
40
|
+
if found_key:
|
|
41
|
+
sections[found_key] = content
|
|
42
|
+
else:
|
|
43
|
+
config_dir = os.path.dirname(config_file)
|
|
44
|
+
formatted_path = self._format_context_path_for_writing(
|
|
45
|
+
abs_context_path,
|
|
46
|
+
config_dir,
|
|
47
|
+
)
|
|
48
|
+
new_key = f"Note: {formatted_path}"
|
|
49
|
+
sections[new_key] = content
|
|
50
|
+
# Serialize back to markdown
|
|
51
|
+
new_file_content = ""
|
|
52
|
+
for key, value in sections.items():
|
|
53
|
+
new_file_content += f"# {key}\n{demote_markdown_headers(value)}\n\n"
|
|
54
|
+
with open(config_file, "w") as f:
|
|
55
|
+
f.write(new_file_content)
|
|
56
|
+
|
|
57
|
+
def get_notes(self, cwd: str | None = None) -> dict[str, str]:
|
|
58
|
+
"""Gathers all notes for a given path."""
|
|
59
|
+
if cwd is None:
|
|
60
|
+
cwd = os.getcwd()
|
|
61
|
+
config_file = self._get_home_config_file()
|
|
62
|
+
if not os.path.exists(config_file):
|
|
63
|
+
return {}
|
|
64
|
+
config_dir = os.path.dirname(config_file)
|
|
65
|
+
sections = self._parse_config(config_file)
|
|
66
|
+
notes: dict[str, str] = {}
|
|
67
|
+
for key, value in sections.items():
|
|
68
|
+
if key.lower().startswith("note:"):
|
|
69
|
+
context_path_str = key[len("note:") :].strip()
|
|
70
|
+
abs_context_path = self._normalize_context_path(
|
|
71
|
+
context_path_str,
|
|
72
|
+
config_dir,
|
|
73
|
+
)
|
|
74
|
+
# A context is relevant if its path is an ancestor of cwd
|
|
75
|
+
if os.path.commonpath([cwd, abs_context_path]) == abs_context_path:
|
|
76
|
+
notes[abs_context_path] = value
|
|
77
|
+
return notes
|
|
78
|
+
|
|
79
|
+
def get_workflows(self, cwd: str | None = None) -> dict[str, LLMWorkflow]:
|
|
80
|
+
"""Gathers all relevant workflows for a given path."""
|
|
81
|
+
if cwd is None:
|
|
82
|
+
cwd = os.getcwd()
|
|
83
|
+
all_sections = self._get_all_sections(cwd)
|
|
84
|
+
workflows: dict[str, LLMWorkflow] = {}
|
|
85
|
+
# Iterate from closest to farthest
|
|
86
|
+
for config_dir, sections in all_sections:
|
|
87
|
+
for key, value in sections.items():
|
|
88
|
+
if key.lower().startswith("workflow:"):
|
|
89
|
+
workflow_name = key[len("workflow:") :].strip().lower()
|
|
90
|
+
# First one found wins
|
|
91
|
+
if workflow_name not in workflows:
|
|
92
|
+
workflows[workflow_name] = LLMWorkflow(
|
|
93
|
+
name=workflow_name,
|
|
94
|
+
content=value,
|
|
95
|
+
path=config_dir,
|
|
96
|
+
)
|
|
97
|
+
return workflows
|
|
98
|
+
|
|
99
|
+
def _format_context_path_for_writing(
|
|
100
|
+
self,
|
|
101
|
+
path_to_write: str,
|
|
102
|
+
relative_to_dir: str,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Formats a path for writing into a context file key."""
|
|
105
|
+
home_dir = os.path.expanduser("~")
|
|
106
|
+
abs_path_to_write = os.path.abspath(
|
|
107
|
+
os.path.join(relative_to_dir, path_to_write)
|
|
108
|
+
)
|
|
109
|
+
abs_relative_to_dir = os.path.abspath(relative_to_dir)
|
|
110
|
+
# Rule 1: Inside relative_to_dir
|
|
111
|
+
if abs_path_to_write.startswith(abs_relative_to_dir):
|
|
112
|
+
if abs_path_to_write == abs_relative_to_dir:
|
|
113
|
+
return "."
|
|
114
|
+
return os.path.relpath(abs_path_to_write, abs_relative_to_dir)
|
|
115
|
+
# Rule 2: Inside Home
|
|
116
|
+
if abs_path_to_write.startswith(home_dir):
|
|
117
|
+
if abs_path_to_write == home_dir:
|
|
118
|
+
return "~"
|
|
119
|
+
return os.path.join("~", os.path.relpath(abs_path_to_write, home_dir))
|
|
120
|
+
# Rule 3: Absolute
|
|
121
|
+
return abs_path_to_write
|
|
122
|
+
|
|
11
123
|
def _find_config_files(self, cwd: str) -> list[str]:
|
|
12
124
|
configs = []
|
|
13
125
|
current_dir = cwd
|
|
@@ -21,6 +133,10 @@ class LLMContextConfig:
|
|
|
21
133
|
current_dir = os.path.dirname(current_dir)
|
|
22
134
|
return configs
|
|
23
135
|
|
|
136
|
+
def _get_home_config_file(self) -> str:
|
|
137
|
+
home_dir = os.path.expanduser("~")
|
|
138
|
+
return os.path.join(home_dir, CFG.LLM_CONTEXT_FILE)
|
|
139
|
+
|
|
24
140
|
def _parse_config(self, file_path: str) -> dict[str, str]:
|
|
25
141
|
with open(file_path, "r") as f:
|
|
26
142
|
content = f.read()
|
|
@@ -35,95 +151,16 @@ class LLMContextConfig:
|
|
|
35
151
|
all_sections.append((config_dir, sections))
|
|
36
152
|
return all_sections
|
|
37
153
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if context_path == ".":
|
|
49
|
-
context_path = config_dir
|
|
50
|
-
elif not os.path.isabs(context_path):
|
|
51
|
-
context_path = os.path.abspath(
|
|
52
|
-
os.path.join(config_dir, context_path)
|
|
53
|
-
)
|
|
54
|
-
if os.path.isabs(context_path) or cwd.startswith(context_path):
|
|
55
|
-
contexts[context_path] = value
|
|
56
|
-
return contexts
|
|
57
|
-
|
|
58
|
-
def get_workflows(self, cwd: str | None = None) -> dict[str, str]:
|
|
59
|
-
"""Gathers all relevant workflows for a given path."""
|
|
60
|
-
if cwd is None:
|
|
61
|
-
cwd = os.getcwd()
|
|
62
|
-
all_sections = self._get_all_sections(cwd)
|
|
63
|
-
workflows: dict[str, str] = {}
|
|
64
|
-
for _, sections in reversed(all_sections):
|
|
65
|
-
for key, value in sections.items():
|
|
66
|
-
if key.startswith("Workflow:"):
|
|
67
|
-
workflow_name = key.replace("Workflow:", "").strip()
|
|
68
|
-
if workflow_name not in workflows:
|
|
69
|
-
workflows[workflow_name] = value
|
|
70
|
-
return workflows
|
|
71
|
-
|
|
72
|
-
def write_context(
|
|
73
|
-
self, content: str, context_path: str | None = None, cwd: str | None = None
|
|
74
|
-
):
|
|
75
|
-
"""Writes content to a context block in the nearest configuration file."""
|
|
76
|
-
if cwd is None:
|
|
77
|
-
cwd = os.getcwd()
|
|
78
|
-
if context_path is None:
|
|
79
|
-
context_path = cwd
|
|
80
|
-
|
|
81
|
-
config_files = self._find_config_files(cwd)
|
|
82
|
-
if config_files:
|
|
83
|
-
config_file = config_files[0] # Closest config file
|
|
84
|
-
else:
|
|
85
|
-
config_file = os.path.join(cwd, CFG.LLM_CONTEXT_FILE)
|
|
86
|
-
|
|
87
|
-
sections = {}
|
|
88
|
-
if os.path.exists(config_file):
|
|
89
|
-
sections = self._parse_config(config_file)
|
|
90
|
-
|
|
91
|
-
# Determine the section key
|
|
92
|
-
section_key_path = context_path
|
|
93
|
-
if not os.path.isabs(context_path):
|
|
94
|
-
config_dir = os.path.dirname(config_file)
|
|
95
|
-
section_key_path = os.path.abspath(os.path.join(config_dir, context_path))
|
|
96
|
-
|
|
97
|
-
# Find existing key
|
|
98
|
-
found_key = ""
|
|
99
|
-
for key in sections.keys():
|
|
100
|
-
if not key.startswith("Context:"):
|
|
101
|
-
continue
|
|
102
|
-
key_path = key.replace("Context:", "").strip()
|
|
103
|
-
if key_path == ".":
|
|
104
|
-
key_path = os.path.dirname(config_file)
|
|
105
|
-
elif not os.path.isabs(key_path):
|
|
106
|
-
key_path = os.path.abspath(
|
|
107
|
-
os.path.join(os.path.dirname(config_file), key_path)
|
|
108
|
-
)
|
|
109
|
-
if key_path == section_key_path:
|
|
110
|
-
found_key = key
|
|
111
|
-
break
|
|
112
|
-
|
|
113
|
-
if found_key != "":
|
|
114
|
-
sections[found_key] = content
|
|
115
|
-
else:
|
|
116
|
-
# Add new entry
|
|
117
|
-
new_key = f"Context: {context_path}"
|
|
118
|
-
sections[new_key] = content
|
|
119
|
-
|
|
120
|
-
# Serialize back to markdown
|
|
121
|
-
new_file_content = ""
|
|
122
|
-
for key, value in sections.items():
|
|
123
|
-
new_file_content += f"# {key}\n{demote_markdown_headers(value)}\n\n"
|
|
124
|
-
|
|
125
|
-
with open(config_file, "w") as f:
|
|
126
|
-
f.write(new_file_content)
|
|
154
|
+
def _normalize_context_path(
|
|
155
|
+
self,
|
|
156
|
+
path_str: str,
|
|
157
|
+
relative_to_dir: str,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Normalizes a context path string to an absolute path."""
|
|
160
|
+
expanded_path = os.path.expanduser(path_str)
|
|
161
|
+
if os.path.isabs(expanded_path):
|
|
162
|
+
return os.path.abspath(expanded_path)
|
|
163
|
+
return os.path.abspath(os.path.join(relative_to_dir, expanded_path))
|
|
127
164
|
|
|
128
165
|
|
|
129
166
|
llm_context_config = LLMContextConfig()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import re
|
|
2
2
|
|
|
3
|
-
from zrb.util.
|
|
3
|
+
from zrb.util.markdown import promote_markdown_headers
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def markdown_to_dict(markdown: str) -> dict[str, str]:
|
|
@@ -8,21 +8,17 @@ def markdown_to_dict(markdown: str) -> dict[str, str]:
|
|
|
8
8
|
current_title = ""
|
|
9
9
|
current_content: list[str] = []
|
|
10
10
|
fence_stack: list[str] = []
|
|
11
|
-
|
|
12
11
|
fence_pattern = re.compile(r"^([`~]{3,})(.*)$")
|
|
13
12
|
h1_pattern = re.compile(r"^# (.+)$")
|
|
14
|
-
|
|
15
13
|
for line in markdown.splitlines():
|
|
16
14
|
# Detect code fence open/close
|
|
17
15
|
fence_match = fence_pattern.match(line.strip())
|
|
18
|
-
|
|
19
16
|
if fence_match:
|
|
20
17
|
fence = fence_match.group(1)
|
|
21
18
|
if fence_stack and fence_stack[-1] == fence:
|
|
22
19
|
fence_stack.pop() # close current fence
|
|
23
20
|
else:
|
|
24
21
|
fence_stack.append(fence) # open new fence
|
|
25
|
-
|
|
26
22
|
# Only parse H1 when not inside a code fence
|
|
27
23
|
if not fence_stack:
|
|
28
24
|
h1_match = h1_pattern.match(line)
|
|
@@ -34,9 +30,7 @@ def markdown_to_dict(markdown: str) -> dict[str, str]:
|
|
|
34
30
|
current_title = h1_match.group(1).strip()
|
|
35
31
|
current_content = []
|
|
36
32
|
continue
|
|
37
|
-
|
|
38
33
|
current_content.append(line)
|
|
39
|
-
|
|
40
34
|
# Save final section
|
|
41
35
|
if current_title:
|
|
42
36
|
sections[current_title] = "\n".join(current_content).strip()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
class LLMWorkflow:
|
|
2
|
+
def __init__(
|
|
3
|
+
self, name: str, path: str, content: str, description: str | None = None
|
|
4
|
+
):
|
|
5
|
+
self._name = name
|
|
6
|
+
self._path = path
|
|
7
|
+
|
|
8
|
+
# Extract YAML metadata and clean content
|
|
9
|
+
(
|
|
10
|
+
extracted_description,
|
|
11
|
+
cleaned_content,
|
|
12
|
+
) = self._extract_yaml_metadata_and_clean_content(content)
|
|
13
|
+
self._content = cleaned_content
|
|
14
|
+
|
|
15
|
+
# Use provided description or extracted one
|
|
16
|
+
self._description = (
|
|
17
|
+
description if description is not None else extracted_description
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def _extract_yaml_metadata_and_clean_content(
|
|
21
|
+
self, content: str
|
|
22
|
+
) -> tuple[str | None, str]:
|
|
23
|
+
"""Extract YAML metadata and clean content.
|
|
24
|
+
|
|
25
|
+
Looks for YAML metadata between --- lines, extracts the 'description' field,
|
|
26
|
+
and returns the content without the YAML metadata.
|
|
27
|
+
"""
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
# Pattern to match YAML metadata between --- delimiters
|
|
33
|
+
yaml_pattern = r"^---\s*\n(.*?)\n---\s*\n"
|
|
34
|
+
match = re.search(yaml_pattern, content, re.DOTALL | re.MULTILINE)
|
|
35
|
+
|
|
36
|
+
if match:
|
|
37
|
+
yaml_content = match.group(1)
|
|
38
|
+
try:
|
|
39
|
+
metadata = yaml.safe_load(yaml_content)
|
|
40
|
+
description = (
|
|
41
|
+
metadata.get("description") if isinstance(metadata, dict) else None
|
|
42
|
+
)
|
|
43
|
+
# Remove the YAML metadata from content
|
|
44
|
+
cleaned_content = re.sub(
|
|
45
|
+
yaml_pattern, "", content, count=1, flags=re.DOTALL | re.MULTILINE
|
|
46
|
+
)
|
|
47
|
+
return description, cleaned_content.strip()
|
|
48
|
+
except yaml.YAMLError:
|
|
49
|
+
# If YAML parsing fails, return original content
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# No YAML metadata found, return original content
|
|
53
|
+
return None, content
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def name(self) -> str:
|
|
57
|
+
return self._name
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def path(self) -> str:
|
|
61
|
+
return self._path
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def content(self) -> str:
|
|
65
|
+
return self._content
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def description(self) -> str:
|
|
69
|
+
if self._description is not None:
|
|
70
|
+
return self._description
|
|
71
|
+
if len(self._content) > 1000:
|
|
72
|
+
non_empty_lines = [
|
|
73
|
+
line for line in self._content.split("\n") if line.strip() != ""
|
|
74
|
+
]
|
|
75
|
+
first_non_empty_line = (
|
|
76
|
+
non_empty_lines[0] if len(non_empty_lines) > 0 else ""
|
|
77
|
+
)
|
|
78
|
+
if len(first_non_empty_line) > 200:
|
|
79
|
+
return first_non_empty_line[:200] + "... (more)"
|
|
80
|
+
return first_non_empty_line
|
|
81
|
+
return self._content
|