zrb 1.13.1__py3-none-any.whl → 1.21.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. zrb/__init__.py +2 -6
  2. zrb/attr/type.py +8 -8
  3. zrb/builtin/__init__.py +2 -0
  4. zrb/builtin/group.py +31 -15
  5. zrb/builtin/http.py +7 -8
  6. zrb/builtin/llm/attachment.py +40 -0
  7. zrb/builtin/llm/chat_session.py +130 -144
  8. zrb/builtin/llm/chat_session_cmd.py +226 -0
  9. zrb/builtin/llm/chat_trigger.py +73 -0
  10. zrb/builtin/llm/history.py +4 -4
  11. zrb/builtin/llm/llm_ask.py +218 -110
  12. zrb/builtin/llm/tool/api.py +74 -62
  13. zrb/builtin/llm/tool/cli.py +35 -16
  14. zrb/builtin/llm/tool/code.py +49 -47
  15. zrb/builtin/llm/tool/file.py +262 -251
  16. zrb/builtin/llm/tool/note.py +84 -0
  17. zrb/builtin/llm/tool/rag.py +25 -18
  18. zrb/builtin/llm/tool/sub_agent.py +29 -22
  19. zrb/builtin/llm/tool/web.py +135 -143
  20. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
  21. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
  22. zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
  23. zrb/builtin/searxng/config/settings.yml +5671 -0
  24. zrb/builtin/searxng/start.py +21 -0
  25. zrb/builtin/setup/latex/ubuntu.py +1 -0
  26. zrb/builtin/setup/ubuntu.py +1 -1
  27. zrb/builtin/shell/autocomplete/bash.py +4 -3
  28. zrb/builtin/shell/autocomplete/zsh.py +4 -3
  29. zrb/config/config.py +255 -78
  30. zrb/config/default_prompt/file_extractor_system_prompt.md +109 -9
  31. zrb/config/default_prompt/interactive_system_prompt.md +24 -30
  32. zrb/config/default_prompt/persona.md +1 -1
  33. zrb/config/default_prompt/repo_extractor_system_prompt.md +31 -31
  34. zrb/config/default_prompt/repo_summarizer_system_prompt.md +27 -8
  35. zrb/config/default_prompt/summarization_prompt.md +8 -13
  36. zrb/config/default_prompt/system_prompt.md +36 -30
  37. zrb/config/llm_config.py +129 -24
  38. zrb/config/llm_context/config.py +127 -90
  39. zrb/config/llm_context/config_parser.py +1 -7
  40. zrb/config/llm_context/workflow.py +81 -0
  41. zrb/config/llm_rate_limitter.py +89 -45
  42. zrb/context/any_shared_context.py +7 -1
  43. zrb/context/context.py +8 -2
  44. zrb/context/shared_context.py +6 -8
  45. zrb/group/any_group.py +12 -5
  46. zrb/group/group.py +67 -3
  47. zrb/input/any_input.py +5 -1
  48. zrb/input/base_input.py +18 -6
  49. zrb/input/text_input.py +7 -24
  50. zrb/runner/cli.py +21 -20
  51. zrb/runner/common_util.py +24 -19
  52. zrb/runner/web_route/task_input_api_route.py +5 -5
  53. zrb/runner/web_route/task_session_api_route.py +1 -4
  54. zrb/runner/web_util/user.py +7 -3
  55. zrb/session/any_session.py +12 -6
  56. zrb/session/session.py +39 -18
  57. zrb/task/any_task.py +24 -3
  58. zrb/task/base/context.py +17 -9
  59. zrb/task/base/execution.py +15 -8
  60. zrb/task/base/lifecycle.py +8 -4
  61. zrb/task/base/monitoring.py +12 -7
  62. zrb/task/base_task.py +69 -5
  63. zrb/task/base_trigger.py +12 -5
  64. zrb/task/llm/agent.py +138 -52
  65. zrb/task/llm/config.py +45 -13
  66. zrb/task/llm/conversation_history.py +76 -6
  67. zrb/task/llm/conversation_history_model.py +0 -168
  68. zrb/task/llm/default_workflow/coding/workflow.md +41 -0
  69. zrb/task/llm/default_workflow/copywriting/workflow.md +68 -0
  70. zrb/task/llm/default_workflow/git/workflow.md +118 -0
  71. zrb/task/llm/default_workflow/golang/workflow.md +128 -0
  72. zrb/task/llm/default_workflow/html-css/workflow.md +135 -0
  73. zrb/task/llm/default_workflow/java/workflow.md +146 -0
  74. zrb/task/llm/default_workflow/javascript/workflow.md +158 -0
  75. zrb/task/llm/default_workflow/python/workflow.md +160 -0
  76. zrb/task/llm/default_workflow/researching/workflow.md +153 -0
  77. zrb/task/llm/default_workflow/rust/workflow.md +162 -0
  78. zrb/task/llm/default_workflow/shell/workflow.md +299 -0
  79. zrb/task/llm/file_replacement.py +206 -0
  80. zrb/task/llm/file_tool_model.py +57 -0
  81. zrb/task/llm/history_summarization.py +22 -35
  82. zrb/task/llm/history_summarization_tool.py +24 -0
  83. zrb/task/llm/print_node.py +182 -63
  84. zrb/task/llm/prompt.py +213 -153
  85. zrb/task/llm/tool_wrapper.py +210 -53
  86. zrb/task/llm/workflow.py +76 -0
  87. zrb/task/llm_task.py +98 -47
  88. zrb/task/make_task.py +2 -3
  89. zrb/task/rsync_task.py +25 -10
  90. zrb/task/scheduler.py +4 -4
  91. zrb/util/attr.py +50 -40
  92. zrb/util/cli/markdown.py +12 -0
  93. zrb/util/cli/text.py +30 -0
  94. zrb/util/file.py +27 -11
  95. zrb/util/{llm/prompt.py → markdown.py} +2 -3
  96. zrb/util/string/conversion.py +1 -1
  97. zrb/util/truncate.py +23 -0
  98. zrb/util/yaml.py +204 -0
  99. {zrb-1.13.1.dist-info → zrb-1.21.17.dist-info}/METADATA +40 -20
  100. {zrb-1.13.1.dist-info → zrb-1.21.17.dist-info}/RECORD +102 -79
  101. {zrb-1.13.1.dist-info → zrb-1.21.17.dist-info}/WHEEL +1 -1
  102. zrb/task/llm/default_workflow/coding.md +0 -24
  103. zrb/task/llm/default_workflow/copywriting.md +0 -17
  104. zrb/task/llm/default_workflow/researching.md +0 -18
  105. {zrb-1.13.1.dist-info → zrb-1.21.17.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
- default_base_url: str | None = None,
19
- default_api_key: str | None = None,
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
- default_modes: list[str] | None = None,
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 = default_base_url
35
- self._default_model_api_key = default_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._default_modes = default_modes
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 default_modes(self) -> list[str]:
194
+ def default_workflows(self) -> list[str]:
129
195
  return self._get_property(
130
- self._default_modes, CFG.LLM_MODES, lambda: ["coding"]
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 | None":
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 None
156
- from pydantic_ai.models.openai import OpenAIModel
221
+ return "openai:gpt-4o"
222
+ from pydantic_ai.models.openai import OpenAIChatModel
157
223
 
158
- return OpenAIModel(
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 set_default_modes(self, modes: list[str]):
190
- self._default_modes = modes
282
+ def set_default_workflows(self, workflows: list[str]):
283
+ self._default_workflows = workflows
191
284
 
192
- def add_default_mode(self, mode: str):
193
- if self._default_modes is None:
194
- self._default_modes = []
195
- self._default_modes.append(mode)
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 remove_default_mode(self, mode: str):
198
- if self._default_modes is None:
199
- self._default_modes = []
200
- self._default_modes.remove(mode)
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()
@@ -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.util.llm.prompt import demote_markdown_headers
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 get_contexts(self, cwd: str | None = None) -> dict[str, str]:
39
- """Gathers all relevant contexts for a given path."""
40
- if cwd is None:
41
- cwd = os.getcwd()
42
- all_sections = self._get_all_sections(cwd)
43
- contexts: dict[str, str] = {}
44
- for config_dir, sections in reversed(all_sections):
45
- for key, value in sections.items():
46
- if key.startswith("Context:"):
47
- context_path = key.replace("Context:", "").strip()
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.llm.prompt import promote_markdown_headers
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