ara-cli 0.1.9.77__py3-none-any.whl → 0.1.10.8__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 ara-cli might be problematic. Click here for more details.

Files changed (122) hide show
  1. ara_cli/__init__.py +18 -2
  2. ara_cli/__main__.py +245 -66
  3. ara_cli/ara_command_action.py +128 -63
  4. ara_cli/ara_config.py +201 -177
  5. ara_cli/ara_subcommands/__init__.py +0 -0
  6. ara_cli/ara_subcommands/autofix.py +26 -0
  7. ara_cli/ara_subcommands/chat.py +27 -0
  8. ara_cli/ara_subcommands/classifier_directory.py +16 -0
  9. ara_cli/ara_subcommands/common.py +100 -0
  10. ara_cli/ara_subcommands/create.py +75 -0
  11. ara_cli/ara_subcommands/delete.py +22 -0
  12. ara_cli/ara_subcommands/extract.py +22 -0
  13. ara_cli/ara_subcommands/fetch_templates.py +14 -0
  14. ara_cli/ara_subcommands/list.py +65 -0
  15. ara_cli/ara_subcommands/list_tags.py +25 -0
  16. ara_cli/ara_subcommands/load.py +48 -0
  17. ara_cli/ara_subcommands/prompt.py +136 -0
  18. ara_cli/ara_subcommands/read.py +47 -0
  19. ara_cli/ara_subcommands/read_status.py +20 -0
  20. ara_cli/ara_subcommands/read_user.py +20 -0
  21. ara_cli/ara_subcommands/reconnect.py +27 -0
  22. ara_cli/ara_subcommands/rename.py +22 -0
  23. ara_cli/ara_subcommands/scan.py +14 -0
  24. ara_cli/ara_subcommands/set_status.py +22 -0
  25. ara_cli/ara_subcommands/set_user.py +22 -0
  26. ara_cli/ara_subcommands/template.py +16 -0
  27. ara_cli/artefact_autofix.py +214 -28
  28. ara_cli/artefact_creator.py +5 -8
  29. ara_cli/artefact_deleter.py +2 -4
  30. ara_cli/artefact_fuzzy_search.py +13 -6
  31. ara_cli/artefact_lister.py +29 -55
  32. ara_cli/artefact_models/artefact_data_retrieval.py +23 -0
  33. ara_cli/artefact_models/artefact_model.py +106 -25
  34. ara_cli/artefact_models/artefact_templates.py +23 -13
  35. ara_cli/artefact_models/epic_artefact_model.py +11 -2
  36. ara_cli/artefact_models/feature_artefact_model.py +56 -1
  37. ara_cli/artefact_models/userstory_artefact_model.py +15 -3
  38. ara_cli/artefact_reader.py +4 -5
  39. ara_cli/artefact_renamer.py +6 -2
  40. ara_cli/artefact_scan.py +2 -2
  41. ara_cli/chat.py +594 -219
  42. ara_cli/chat_agent/__init__.py +0 -0
  43. ara_cli/chat_agent/agent_communicator.py +62 -0
  44. ara_cli/chat_agent/agent_process_manager.py +211 -0
  45. ara_cli/chat_agent/agent_status_manager.py +73 -0
  46. ara_cli/chat_agent/agent_workspace_manager.py +76 -0
  47. ara_cli/commands/__init__.py +0 -0
  48. ara_cli/commands/command.py +7 -0
  49. ara_cli/commands/extract_command.py +15 -0
  50. ara_cli/commands/load_command.py +65 -0
  51. ara_cli/commands/load_image_command.py +34 -0
  52. ara_cli/commands/read_command.py +117 -0
  53. ara_cli/completers.py +144 -0
  54. ara_cli/directory_navigator.py +37 -4
  55. ara_cli/error_handler.py +134 -0
  56. ara_cli/file_classifier.py +3 -2
  57. ara_cli/file_loaders/__init__.py +0 -0
  58. ara_cli/file_loaders/binary_file_loader.py +33 -0
  59. ara_cli/file_loaders/document_file_loader.py +34 -0
  60. ara_cli/file_loaders/document_reader.py +245 -0
  61. ara_cli/file_loaders/document_readers.py +233 -0
  62. ara_cli/file_loaders/file_loader.py +50 -0
  63. ara_cli/file_loaders/file_loaders.py +123 -0
  64. ara_cli/file_loaders/image_processor.py +89 -0
  65. ara_cli/file_loaders/markdown_reader.py +75 -0
  66. ara_cli/file_loaders/text_file_loader.py +187 -0
  67. ara_cli/global_file_lister.py +51 -0
  68. ara_cli/prompt_extractor.py +214 -87
  69. ara_cli/prompt_handler.py +508 -146
  70. ara_cli/tag_extractor.py +54 -24
  71. ara_cli/template_loader.py +245 -0
  72. ara_cli/template_manager.py +14 -4
  73. ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
  74. ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
  75. ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
  76. ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
  77. ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
  78. ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
  79. ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
  80. ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
  81. ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
  82. ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
  83. ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
  84. ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
  85. ara_cli/update_config_prompt.py +7 -1
  86. ara_cli/version.py +1 -1
  87. ara_cli-0.1.10.8.dist-info/METADATA +241 -0
  88. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/RECORD +104 -59
  89. tests/test_ara_command_action.py +66 -52
  90. tests/test_ara_config.py +200 -279
  91. tests/test_artefact_autofix.py +361 -5
  92. tests/test_artefact_lister.py +52 -132
  93. tests/test_artefact_scan.py +1 -1
  94. tests/test_chat.py +2009 -603
  95. tests/test_file_classifier.py +23 -0
  96. tests/test_file_creator.py +3 -5
  97. tests/test_global_file_lister.py +131 -0
  98. tests/test_prompt_handler.py +746 -0
  99. tests/test_tag_extractor.py +19 -13
  100. tests/test_template_loader.py +192 -0
  101. tests/test_template_manager.py +5 -4
  102. ara_cli/ara_command_parser.py +0 -536
  103. ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
  104. ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
  105. ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
  106. ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
  107. ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
  108. ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
  109. ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
  110. ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
  111. ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
  112. ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
  113. ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
  114. ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
  115. ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
  116. ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
  117. ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
  118. ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
  119. ara_cli-0.1.9.77.dist-info/METADATA +0 -18
  120. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/WHEEL +0 -0
  121. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/entry_points.txt +0 -0
  122. {ara_cli-0.1.9.77.dist-info → ara_cli-0.1.10.8.dist-info}/top_level.txt +0 -0
ara_cli/chat.py CHANGED
@@ -1,11 +1,43 @@
1
1
  import os
2
+ import argparse
2
3
  import cmd2
4
+
3
5
  from ara_cli.prompt_handler import send_prompt
4
6
 
7
+ from . import error_handler
8
+ from ara_cli.error_handler import AraError, AraConfigurationError
9
+
10
+ from ara_cli.file_loaders.document_file_loader import DocumentFileLoader
11
+ from ara_cli.file_loaders.binary_file_loader import BinaryFileLoader
12
+ from ara_cli.file_loaders.text_file_loader import TextFileLoader
13
+ from ara_cli.chat_agent.agent_process_manager import AgentProcessManager
14
+
15
+
16
+ extract_parser = argparse.ArgumentParser()
17
+ extract_parser.add_argument(
18
+ "-f", "--force", action="store_true", help="Force extraction"
19
+ )
20
+ extract_parser.add_argument(
21
+ "-w",
22
+ "--write",
23
+ action="store_true",
24
+ help="Overwrite existing files without using LLM for merging.",
25
+ )
26
+
27
+ load_parser = argparse.ArgumentParser()
28
+ load_parser.add_argument("file_name", nargs="?",
29
+ default="", help="File to load")
30
+ load_parser.add_argument(
31
+ "--load-images",
32
+ action="store_true",
33
+ help="Extract and describe images from documents",
34
+ )
35
+
5
36
 
6
37
  class Chat(cmd2.Cmd):
7
38
  CATEGORY_CHAT_CONTROL = "Chat control commands"
8
39
  CATEGORY_LLM_CONTROL = "Language model controls"
40
+ CATEGORY_AGENT_CONTROL = "Agent control commands"
9
41
 
10
42
  INTRO = """/***************************************/
11
43
  araarar
@@ -29,37 +61,50 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
29
61
  ROLE_PROMPT = "ara prompt"
30
62
  ROLE_RESPONSE = "ara response"
31
63
 
64
+ # Available agents
65
+ AVAILABLE_AGENTS = [
66
+ "interview_agent",
67
+ "autocoder_v2_agent",
68
+ "question_and_answer_agent",
69
+ "feature_creation_guide_agent",
70
+ ]
71
+
32
72
  BINARY_TYPE_MAPPING = {
33
- ".png": "image/png",
34
- ".jpg": "image/jpeg",
35
- ".jpeg": "image/jpeg",
36
- }
37
-
73
+ ".png": "image/png",
74
+ ".jpg": "image/jpeg",
75
+ ".jpeg": "image/jpeg",
76
+ }
77
+
38
78
  DOCUMENT_TYPE_EXTENSIONS = [".docx", ".doc", ".odt", ".pdf"]
39
79
 
40
80
  def __init__(
41
81
  self,
42
82
  chat_name: str,
43
83
  reset: bool | None = None,
44
- enable_commands: list[str] | None = None
84
+ enable_commands: list[str] | None = None,
45
85
  ):
86
+ from ara_cli.template_loader import TemplateLoader
87
+
46
88
  shortcuts = dict(cmd2.DEFAULT_SHORTCUTS)
47
89
  if enable_commands:
48
90
  enable_commands.append("quit") # always allow quitting
49
91
  enable_commands.append("eof") # always allow quitting with ctrl-D
50
92
  enable_commands.append("help") # always allow help
51
93
 
52
- shortcuts = {key: value for key, value in shortcuts.items() if value in enable_commands}
94
+ shortcuts = {
95
+ key: value
96
+ for key, value in shortcuts.items()
97
+ if value in enable_commands
98
+ }
53
99
 
54
- super().__init__(
55
- allow_cli_args=False,
56
- shortcuts=shortcuts
57
- )
100
+ super().__init__(allow_cli_args=False, shortcuts=shortcuts)
58
101
  self.create_default_aliases()
59
102
 
60
103
  if enable_commands:
61
104
  all_commands = self.get_all_commands()
62
- commands_to_disable = [command for command in all_commands if command not in enable_commands]
105
+ commands_to_disable = [
106
+ command for command in all_commands if command not in enable_commands
107
+ ]
63
108
  self.disable_commands(commands_to_disable)
64
109
 
65
110
  self.prompt = "ara> "
@@ -71,12 +116,18 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
71
116
  self.chat_history = []
72
117
  self.message_buffer = []
73
118
  self.config = self._retrieve_ara_config()
119
+ self.template_loader = TemplateLoader(chat_instance=self)
120
+
121
+ # Initialize agent process manager
122
+ self.agent_manager = AgentProcessManager(self)
74
123
 
75
124
  def disable_commands(self, commands: list[str]):
76
125
  for command in commands:
77
- setattr(self, f'do_{command}', self.default)
126
+ setattr(self, f"do_{command}", self.default)
78
127
  self.hidden_commands.append(command)
79
- aliases_to_remove = [alias for alias, cmd in self.aliases.items() if cmd in commands]
128
+ aliases_to_remove = [
129
+ alias for alias, cmd in self.aliases.items() if cmd in commands
130
+ ]
80
131
  for alias in aliases_to_remove:
81
132
  del self.aliases[alias]
82
133
 
@@ -97,6 +148,11 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
97
148
  self.aliases["lg"] = "LOAD_GIVENS"
98
149
  self.aliases["lb"] = "LOAD_BLUEPRINT"
99
150
  self.aliases["lt"] = "LOAD_TEMPLATE"
151
+ # Agent control aliases
152
+ self.aliases["a"] = "AGENT_RUN"
153
+ self.aliases["as"] = "AGENT_STOP"
154
+ self.aliases["ac"] = "AGENT_CONTINUE"
155
+ self.aliases["astat"] = "AGENT_STATUS"
100
156
 
101
157
  def setup_chat(self, chat_name, reset: bool = None):
102
158
  if os.path.exists(chat_name):
@@ -111,8 +167,10 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
111
167
  chat_file_short = os.path.split(chat_file)[-1]
112
168
 
113
169
  if reset is None:
114
- user_input = input(f"{chat_file_short} already exists. Do you want to reset the chat? (y/N): ")
115
- if user_input.lower() == 'y':
170
+ user_input = input(
171
+ f"{chat_file_short} already exists. Do you want to reset the chat? (y/N): "
172
+ )
173
+ if user_input.lower() == "y":
116
174
  self.create_empty_chat_file(chat_file)
117
175
  if reset:
118
176
  self.create_empty_chat_file(chat_file)
@@ -139,16 +197,14 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
139
197
  print(f"File {file_name} not found.")
140
198
  return False
141
199
  return method(self, file_path, *args, **kwargs)
200
+
142
201
  return wrapper
143
202
 
144
203
  @staticmethod
145
204
  def get_last_role_marker(lines):
146
205
  if not lines:
147
206
  return
148
- role_markers = [
149
- f"# {Chat.ROLE_PROMPT}:",
150
- f"# {Chat.ROLE_RESPONSE}"
151
- ]
207
+ role_markers = [f"# {Chat.ROLE_PROMPT}:", f"# {Chat.ROLE_RESPONSE}"]
152
208
  for line in reversed(lines):
153
209
  stripped_line = line.strip()
154
210
  if stripped_line.startswith(tuple(role_markers)):
@@ -156,7 +212,7 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
156
212
  return None
157
213
 
158
214
  def start_non_interactive(self):
159
- with open(self.chat_name, 'r', encoding='utf-8') as file:
215
+ with open(self.chat_name, "r", encoding="utf-8") as file:
160
216
  content = file.read()
161
217
  print(content)
162
218
 
@@ -191,23 +247,19 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
191
247
  text_content = []
192
248
  image_data_list = []
193
249
 
194
- image_pattern = re.compile(r'\((data:image/[^;]+;base64,.*?)\)')
250
+ image_pattern = re.compile(r"\((data:image/[^;]+;base64,.*?)\)")
195
251
 
196
252
  for line in message.splitlines():
197
253
  match = image_pattern.search(line)
198
254
  if match:
199
- image_data = {"type": "image_url", "image_url": {"url": match.group(1)}}
255
+ image_data = {"type": "image_url",
256
+ "image_url": {"url": match.group(1)}}
200
257
  image_data_list.append(image_data)
201
258
  else:
202
259
  text_content.append(line)
203
260
 
204
- message_content = {
205
- "type": "text",
206
- "text": '\n'.join(text_content)}
207
- message = {
208
- "role": role,
209
- "content": [message_content]
210
- }
261
+ message_content = {"type": "text", "text": "\n".join(text_content)}
262
+ message = {"role": role, "content": [message_content]}
211
263
  message = append_images_to_message(message, image_data_list)
212
264
  return message
213
265
 
@@ -220,7 +272,7 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
220
272
 
221
273
  split_pattern = re.compile(f"({prompt_marker}|{response_marker})")
222
274
 
223
- parts = re.split(split_pattern, '\n'.join(self.chat_history))
275
+ parts = re.split(split_pattern, "\n".join(self.chat_history))
224
276
 
225
277
  all_prompts_and_responses = []
226
278
  current = ""
@@ -254,7 +306,7 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
254
306
  prompt_to_send = self.assemble_prompt()
255
307
  role_marker = f"# {Chat.ROLE_RESPONSE}:"
256
308
 
257
- with open(self.chat_name, 'a+', encoding='utf-8') as file:
309
+ with open(self.chat_name, "a+", encoding="utf-8") as file:
258
310
  last_line = self.get_last_line(file)
259
311
 
260
312
  print(role_marker)
@@ -277,24 +329,24 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
277
329
 
278
330
  def save_message(self, role: str, message: str):
279
331
  role_marker = f"# {role}:"
280
- with open(self.chat_name, 'r', encoding='utf-8') as file:
332
+ with open(self.chat_name, "r", encoding="utf-8") as file:
281
333
  stripped_line = self.get_last_non_empty_line(file)
282
334
  line_to_write = f"{message}\n\n"
283
335
  if stripped_line != role_marker:
284
336
  line_to_write = f"\n{role_marker}\n{message}\n"
285
337
 
286
- with open(self.chat_name, 'a', encoding='utf-8') as file:
338
+ with open(self.chat_name, "a", encoding="utf-8") as file:
287
339
  file.write(line_to_write)
288
340
  self.chat_history.append(line_to_write)
289
341
 
290
342
  def resend_message(self):
291
- with open(self.chat_name, 'r', encoding='utf-8') as file:
343
+ with open(self.chat_name, "r", encoding="utf-8") as file:
292
344
  lines = file.readlines()
293
345
  if not lines:
294
346
  return
295
347
  index_to_remove = self.find_last_reply_index(lines)
296
348
  if index_to_remove is not None:
297
- with open(self.chat_name, 'w', encoding='utf-8') as file:
349
+ with open(self.chat_name, "w", encoding="utf-8") as file:
298
350
  file.writelines(lines[:index_to_remove])
299
351
  self.send_message()
300
352
 
@@ -309,114 +361,91 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
309
361
  return index_to_remove
310
362
 
311
363
  def append_strings(self, strings: list[str]):
312
- output = '\n'.join(strings)
313
- with open(self.chat_name, 'a') as file:
314
- file.write(output + '\n')
364
+ output = "\n".join(strings)
365
+ with open(self.chat_name, "a") as file:
366
+ file.write(output + "\n")
315
367
 
316
368
  def load_chat_history(self, chat_file: str):
317
369
  chat_history = []
318
370
  if os.path.exists(chat_file):
319
- with open(chat_file, 'r', encoding='utf-8') as file:
371
+ with open(chat_file, "r", encoding="utf-8") as file:
320
372
  chat_history = file.readlines()
321
373
  return chat_history
322
374
 
323
375
  def create_empty_chat_file(self, chat_file: str):
324
- with open(chat_file, 'w', encoding='utf-8') as file:
376
+ with open(chat_file, "w", encoding="utf-8") as file:
325
377
  file.write(self.default_chat_content)
326
378
  self.chat_history = []
327
379
 
328
380
  def add_prompt_tag_if_needed(self, chat_file: str):
329
- with open(chat_file, 'r', encoding='utf-8') as file:
381
+ with open(chat_file, "r", encoding="utf-8") as file:
330
382
  lines = file.readlines()
383
+
331
384
  prompt_tag = f"# {Chat.ROLE_PROMPT}:"
332
385
  if Chat.get_last_role_marker(lines) == prompt_tag:
333
386
  return
334
387
  append = prompt_tag
335
388
  last_line = lines[-1].strip()
336
- if last_line != "" and last_line != '\n':
389
+ if last_line != "" and last_line != "\n":
337
390
  append = f"\n{append}"
338
- with open(chat_file, 'a', encoding='utf-8') as file:
391
+ with open(chat_file, "a", encoding="utf-8") as file:
339
392
  file.write(append)
340
393
 
341
- def determine_file_path(self, file_name: str):
342
- current_directory = os.path.dirname(self.chat_name)
343
- file_path = os.path.join(current_directory, file_name)
344
- if not os.path.exists(file_path):
345
- file_path = file_name
346
- if not os.path.exists(file_path):
347
- print(f"File {file_name} not found")
348
- return None
349
- return file_path
350
-
351
- @file_exists_check
352
- def load_text_file(self, file_path, prefix: str = "", suffix: str = "", block_delimiter: str = ""):
353
- with open(file_path, 'r', encoding='utf-8', errors="replace") as file:
354
- file_content = file.read()
355
- if block_delimiter:
356
- file_content = f"{block_delimiter}\n{file_content}\n{block_delimiter}"
357
- write_content = f"{prefix}{file_content}{suffix}\n"
358
-
359
- with open(self.chat_name, 'a', encoding='utf-8') as chat_file:
360
- chat_file.write(write_content)
361
- return True
362
-
363
- @file_exists_check
364
- def load_binary_file(self, file_path, mime_type: str, prefix: str = "", suffix: str = ""):
365
- import base64
366
-
367
- with open(file_path, 'rb') as file:
368
- file_content = file.read()
369
- base64_image = base64.b64encode(file_content).decode("utf-8")
370
-
371
- write_content = f"{prefix}![{os.path.basename(file_path)}](data:{mime_type};base64,{base64_image}){suffix}\n"
394
+ def load_text_file(
395
+ self,
396
+ file_path,
397
+ prefix: str = "",
398
+ suffix: str = "",
399
+ block_delimiter: str = "",
400
+ extract_images: bool = False,
401
+ ):
402
+ loader = TextFileLoader(self)
403
+ return loader.load(
404
+ file_path,
405
+ prefix=prefix,
406
+ suffix=suffix,
407
+ block_delimiter=block_delimiter,
408
+ extract_images=extract_images,
409
+ )
372
410
 
373
- with open(self.chat_name, 'a', encoding='utf-8') as chat_file:
374
- chat_file.write(write_content)
375
- return True
376
-
377
- def read_docx(self, file_path):
378
- import docx
379
- doc = docx.Document(file_path)
380
- return '\n'.join(para.text for para in doc.paragraphs)
381
-
382
- def read_pdf(self, file_path):
383
- import pymupdf4llm
384
- return pymupdf4llm.to_markdown(file_path, write_images=False)
385
-
386
- def read_odt(self, file_path):
387
- import pymupdf4llm
388
- return pymupdf4llm.to_markdown(file_path, write_images=False)
389
-
390
- @file_exists_check
391
- def load_document_file(self, file_path: str, prefix: str = "", suffix: str = "", block_delimiter: str = "```"):
392
- import os
393
-
394
- _, ext = os.path.splitext(file_path)
395
- ext = ext.lower()
396
-
397
- text_content = ""
398
- match ext:
399
- case ".docx":
400
- text_content = self.read_docx(file_path)
401
- case ".pdf":
402
- text_content = self.read_pdf(file_path)
403
- case ".odt":
404
- text_content = self.read_odt(file_path)
405
- # Add more cases if needed.
406
- case _:
407
- print("Unsupported document type.")
408
- return False
411
+ def load_binary_file(
412
+ self, file_path, mime_type: str, prefix: str = "", suffix: str = ""
413
+ ):
414
+ loader = BinaryFileLoader(self)
415
+ return loader.load(file_path, mime_type=mime_type, prefix=prefix, suffix=suffix)
409
416
 
410
- if block_delimiter:
411
- text_content = f"{block_delimiter}\n{text_content}\n{block_delimiter}"
417
+ def read_markdown(self, file_path: str, extract_images: bool = False) -> str:
418
+ """Read markdown file and optionally extract/describe images"""
419
+ from ara_cli.file_loaders.text_file_loader import MarkdownReader
412
420
 
413
- write_content = f"{prefix}{text_content}{suffix}\n"
421
+ reader = MarkdownReader(file_path)
422
+ return reader.read(extract_images=extract_images)
414
423
 
415
- with open(self.chat_name, 'a', encoding='utf-8') as chat_file:
416
- chat_file.write(write_content)
417
- return True
424
+ def load_document_file(
425
+ self,
426
+ file_path: str,
427
+ prefix: str = "",
428
+ suffix: str = "",
429
+ block_delimiter: str = "```",
430
+ extract_images: bool = False,
431
+ ):
432
+ loader = DocumentFileLoader(self)
433
+ return loader.load(
434
+ file_path,
435
+ prefix=prefix,
436
+ suffix=suffix,
437
+ block_delimiter=block_delimiter,
438
+ extract_images=extract_images,
439
+ )
418
440
 
419
- def load_file(self, file_name: str, prefix: str = "", suffix: str = "", block_delimiter: str = ""):
441
+ def load_file(
442
+ self,
443
+ file_name: str,
444
+ prefix: str = "",
445
+ suffix: str = "",
446
+ block_delimiter: str = "",
447
+ extract_images: bool = False,
448
+ ):
420
449
  binary_type_mapping = Chat.BINARY_TYPE_MAPPING
421
450
  document_type_extensions = Chat.DOCUMENT_TYPE_EXTENSIONS
422
451
 
@@ -427,28 +456,29 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
427
456
  file_type = mime_type
428
457
  break
429
458
 
430
- is_file_document = any(file_name_lower.endswith(ext) for ext in document_type_extensions)
459
+ is_file_document = any(
460
+ file_name_lower.endswith(ext) for ext in document_type_extensions
461
+ )
431
462
 
432
463
  if is_file_document:
433
464
  return self.load_document_file(
434
- file_name=file_name,
465
+ file_path=file_name,
435
466
  prefix=prefix,
436
467
  suffix=suffix,
437
- block_delimiter=block_delimiter
468
+ block_delimiter=block_delimiter,
469
+ extract_images=extract_images,
438
470
  )
439
471
  elif file_type:
440
472
  return self.load_binary_file(
441
- file_name=file_name,
442
- mime_type=file_type,
443
- prefix=prefix,
444
- suffix=suffix
473
+ file_path=file_name, mime_type=file_type, prefix=prefix, suffix=suffix
445
474
  )
446
475
  else:
447
476
  return self.load_text_file(
448
- file_name=file_name,
477
+ file_path=file_name,
449
478
  prefix=prefix,
450
479
  suffix=suffix,
451
- block_delimiter=block_delimiter
480
+ block_delimiter=block_delimiter,
481
+ extract_images=extract_images,
452
482
  )
453
483
 
454
484
  def choose_file_to_load(self, files: list[str], pattern: str):
@@ -460,11 +490,14 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
460
490
  try:
461
491
  choice_index = int(choice) - 1
462
492
  if choice_index < 0 or choice_index >= len(files):
463
- print("Invalid choice. Aborting load.")
493
+ error_handler.report_error(
494
+ ValueError("Invalid choice. Aborting load.")
495
+ )
464
496
  return None
465
497
  file_path = files[choice_index]
466
- except ValueError:
467
- print("Invalid input. Aborting load.")
498
+ except ValueError as e:
499
+ error_handler.report_error(
500
+ ValueError("Invalid input. Aborting load."))
468
501
  return None
469
502
  else:
470
503
  file_path = files[0]
@@ -473,26 +506,53 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
473
506
  def _help_menu(self, verbose: bool = False):
474
507
  super()._help_menu(verbose)
475
508
  if self.aliases:
476
- aliases = [f"{alias} -> {command}" for alias, command in self.aliases.items()]
509
+ aliases = [
510
+ f"{alias} -> {command}" for alias, command in self.aliases.items()
511
+ ]
477
512
  self._print_topics("Aliases", aliases, verbose)
478
513
 
479
514
  def do_quit(self, _):
480
515
  """Exit ara-cli"""
516
+ self.agent_manager.cleanup_agent_process()
481
517
  print("Chat ended")
482
518
  self.last_result = True
483
519
  return True
484
520
 
521
+ def onecmd(self, *args, **kwargs):
522
+ try:
523
+ return super().onecmd(*args, **kwargs)
524
+ except Exception as e:
525
+ error_handler.report_error(e)
526
+ return False
527
+
485
528
  def onecmd_plus_hooks(self, line, orig_rl_history_length):
486
529
  # store the full line for use with default()
487
530
  self.full_input = line
488
- return super().onecmd_plus_hooks(line, orig_rl_history_length=orig_rl_history_length)
531
+ return super().onecmd_plus_hooks(
532
+ line, orig_rl_history_length=orig_rl_history_length
533
+ )
489
534
 
490
535
  def default(self, line):
491
- self.message_buffer.append(self.full_input)
536
+ if self.agent_manager.agent_mode:
537
+ if self.full_input.lower() in ["quit", "exit"]:
538
+ self.agent_manager.cleanup_agent_process()
539
+ print("Agent stopped.")
540
+ else:
541
+ # In agent mode, send input directly to agent
542
+ self.agent_manager.send_to_agent(self.full_input)
543
+ else:
544
+ # In normal chat mode, buffer the message
545
+ self.message_buffer.append(self.full_input)
492
546
 
493
547
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
494
- def do_LOAD(self, file_name):
495
- """Load a file and append its contents to chat file. Can be given the file name in-line. Will attempt to find the file relative to chat file first, then treat the given path as absolute"""
548
+ @cmd2.with_argparser(load_parser)
549
+ def do_LOAD(self, args):
550
+ """Load a file and append its contents to chat file. Can be given the file name in-line. Will attempt to find the file relative to chat file first, then treat the given path as absolute. Use --load-images flag to extract and describe images from documents."""
551
+ from ara_cli.commands.load_command import LoadCommand
552
+
553
+ file_name = args.file_name
554
+ load_images = args.load_images
555
+
496
556
  matching_files = self.find_matching_files_to_load(file_name)
497
557
  if not matching_files:
498
558
  return
@@ -501,16 +561,26 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
501
561
  block_delimiter = "```"
502
562
  prefix = f"\nFile: {file_path}\n"
503
563
  self.add_prompt_tag_if_needed(self.chat_name)
504
- if not os.path.isdir(file_path) and self.load_file(file_path, prefix=prefix, block_delimiter=block_delimiter):
505
- print(f"Loaded contents of file {file_path}")
564
+
565
+ if not os.path.isdir(file_path):
566
+ command = LoadCommand(
567
+ chat_instance=self,
568
+ file_path=file_path,
569
+ prefix=prefix,
570
+ block_delimiter=block_delimiter,
571
+ extract_images=load_images,
572
+ output=self.poutput,
573
+ )
574
+ command.execute()
506
575
 
507
576
  def complete_LOAD(self, text, line, begidx, endidx):
508
577
  import glob
509
578
 
510
- return [x for x in glob.glob(text + '*')]
579
+ return [x for x in glob.glob(text + "*")]
511
580
 
512
581
  def _retrieve_ara_config(self):
513
582
  from ara_cli.prompt_handler import ConfigManager
583
+
514
584
  return ConfigManager().get_config()
515
585
 
516
586
  def _retrieve_llm_config(self):
@@ -526,7 +596,9 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
526
596
  file_pattern = os.path.join(os.path.dirname(self.chat_name), file_name)
527
597
  matching_files = glob.glob(file_pattern)
528
598
  if not matching_files:
529
- print(f"No files matching pattern {file_name} found.")
599
+ error_handler.report_error(
600
+ AraError(f"No files matching pattern '{file_name}' found.")
601
+ )
530
602
  return
531
603
  return matching_files
532
604
 
@@ -542,37 +614,30 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
542
614
 
543
615
  if file_type:
544
616
  return self.load_binary_file(
545
- file_name=file_name,
546
- mime_type=file_type,
547
- prefix=prefix,
548
- suffix=suffix
617
+ file_path=file_name, mime_type=file_type, prefix=prefix, suffix=suffix
549
618
  )
550
- print(f"File {file_name} not recognized as image, could not load")
619
+ error_handler.report_error(
620
+ AraError(
621
+ f"File {file_name} not recognized as image, could not load")
622
+ )
551
623
 
552
624
  def _verify_llm_choice(self, model_name):
553
625
  llm_config = self._retrieve_llm_config()
554
626
  models = [name for name in llm_config.keys()]
555
627
  if model_name not in models:
556
- print(f"Model {model_name} unavailable. Retrieve the list of available models using the LIST_MODELS command.")
628
+ error_handler.report_error(
629
+ AraConfigurationError(
630
+ f"Model {model_name} unavailable. Retrieve the list of available models using the LIST_MODELS command."
631
+ )
632
+ )
557
633
  return False
558
634
  return True
559
635
 
560
- @cmd2.with_category(CATEGORY_CHAT_CONTROL)
561
- def do_LOAD_DOCUMENT(self, file_name):
562
- """Load a document file (PDF, DOCX, DOC, ODT) and append its text content to chat file. Can be given the file name in-line. Will attempt to find the file relative to chat file first, then treat the given path as absolute"""
563
- matching_files = self.find_matching_files_to_load(file_name)
564
- if not matching_files:
565
- return
566
-
567
- for file_path in matching_files:
568
- prefix = f"\nFile: {file_path}\n"
569
- self.add_prompt_tag_if_needed(self.chat_name)
570
- if not os.path.isdir(file_path) and self.load_document_file(file_path, prefix=prefix):
571
- print(f"Loaded document file {file_path}")
572
-
573
636
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
574
637
  def do_LOAD_IMAGE(self, file_name):
575
638
  """Load an image file and append it to chat file. Can be given the file name in-line. Will attempt to find the file relative to chat file first, then treat the given path as absolute"""
639
+ from ara_cli.commands.load_image_command import LoadImageCommand
640
+
576
641
  matching_files = self.find_matching_files_to_load(file_name)
577
642
  if not matching_files:
578
643
  return
@@ -580,8 +645,31 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
580
645
  for file_path in matching_files:
581
646
  prefix = f"\nFile: {file_path}\n"
582
647
  self.add_prompt_tag_if_needed(self.chat_name)
583
- if not os.path.isdir(file_path) and self.load_image(file_path, prefix=prefix):
584
- print(f"Loaded image file {file_path}")
648
+
649
+ if not os.path.isdir(file_path):
650
+ # Determine mime type
651
+ file_type = None
652
+ file_path_lower = file_path.lower()
653
+ for extension, mime_type in Chat.BINARY_TYPE_MAPPING.items():
654
+ if file_path_lower.endswith(extension):
655
+ file_type = mime_type
656
+ break
657
+
658
+ if file_type:
659
+ command = LoadImageCommand(
660
+ chat_instance=self,
661
+ file_path=file_path,
662
+ mime_type=file_type,
663
+ prefix=prefix,
664
+ output=self.poutput,
665
+ )
666
+ command.execute()
667
+ else:
668
+ error_handler.report_error(
669
+ AraError(
670
+ f"File {file_path} not recognized as image, could not load"
671
+ )
672
+ )
585
673
 
586
674
  @cmd2.with_category(CATEGORY_LLM_CONTROL)
587
675
  def do_LIST_MODELS(self, _):
@@ -600,14 +688,38 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
600
688
  original_dir = os.getcwd()
601
689
  navigator = DirectoryNavigator()
602
690
  navigator.navigate_to_target()
603
- os.chdir('..')
691
+ os.chdir("..")
604
692
 
605
693
  if not self._verify_llm_choice(model_name):
606
694
  return
695
+
607
696
  self.config.default_llm = model_name
608
697
  save_data(filepath=DEFAULT_CONFIG_LOCATION, config=self.config)
609
698
 
610
- LLMSingleton.set_model(model_name)
699
+ LLMSingleton.set_default_model(model_name)
700
+ print(f"Language model switched to '{model_name}'")
701
+
702
+ os.chdir(original_dir)
703
+
704
+ @cmd2.with_category(CATEGORY_LLM_CONTROL)
705
+ def do_CHOOSE_EXTRACTION_MODEL(self, model_name):
706
+ from ara_cli.prompt_handler import LLMSingleton
707
+ from ara_cli.ara_config import DEFAULT_CONFIG_LOCATION, save_data
708
+ from ara_cli.directory_navigator import DirectoryNavigator
709
+
710
+ original_dir = os.getcwd()
711
+ navigator = DirectoryNavigator()
712
+ navigator.navigate_to_target()
713
+ os.chdir("..")
714
+
715
+ if not self._verify_llm_choice(model_name):
716
+ return
717
+
718
+ self.config.extraction_llm = model_name
719
+ save_data(filepath=DEFAULT_CONFIG_LOCATION, config=self.config)
720
+
721
+ LLMSingleton.set_extraction_model(model_name)
722
+ print(f"Extraction model switched to '{model_name}'")
611
723
 
612
724
  os.chdir(original_dir)
613
725
 
@@ -615,7 +727,14 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
615
727
  def do_CURRENT_MODEL(self, _):
616
728
  from ara_cli.prompt_handler import LLMSingleton
617
729
 
618
- print(LLMSingleton.get_model())
730
+ print(LLMSingleton.get_default_model())
731
+
732
+ @cmd2.with_category(CATEGORY_LLM_CONTROL)
733
+ def do_CURRENT_EXTRACTION_MODEL(self, _):
734
+ """Displays the current extraction language model."""
735
+ from ara_cli.prompt_handler import LLMSingleton
736
+
737
+ print(LLMSingleton.get_extraction_model())
619
738
 
620
739
  def _complete_llms(self, text, line, begidx, endidx):
621
740
  llm_config = self._retrieve_llm_config()
@@ -631,6 +750,9 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
631
750
  def complete_CHOOSE_MODEL(self, text, line, begidx, endidx):
632
751
  return self._complete_llms(text, line, begidx, endidx)
633
752
 
753
+ def complete_CHOOSE_EXTRACTION_MODEL(self, text, line, begidx, endidx):
754
+ return self._complete_llms(text, line, begidx, endidx)
755
+
634
756
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
635
757
  def do_NEW(self, chat_name):
636
758
  """Create a new chat. Optionally provide a chat name in-line: NEW new_chat"""
@@ -649,36 +771,51 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
649
771
  def do_CLEAR(self, _):
650
772
  """Clear the chat and the file containing it"""
651
773
  user_input = input("Are you sure you want to clear the chat? (y/N): ")
652
- if user_input.lower() != 'y':
774
+ if user_input.lower() != "y":
653
775
  return
654
776
  self.create_empty_chat_file(self.chat_name)
655
777
  self.chat_history = self.load_chat_history(self.chat_name)
778
+ self.message_buffer.clear()
656
779
  print(f"Cleared content of {self.chat_name}")
657
780
 
658
781
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
659
782
  def do_LOAD_RULES(self, rules_name):
660
783
  """Load rules from ./prompt.data/*.rules.md or from a specified template directory if an argument is given. Specify global/<rules_template> to access globally defined rules templates"""
661
- self._load_template_helper(rules_name, "rules", "*.rules.md")
784
+ self.template_loader.load_template(
785
+ rules_name, "rules", self.chat_name, "*.rules.md"
786
+ )
662
787
 
663
788
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
664
789
  def do_LOAD_INTENTION(self, intention_name):
665
790
  """Load intention from ./prompt.data/*.intention.md or from a specified template directory if an argument is given. Specify global/<intention_template> to access globally defined intention templates"""
666
- self._load_template_helper(intention_name, "intention", "*.intention.md")
791
+ self.template_loader.load_template(
792
+ intention_name, "intention", self.chat_name, "*.intention.md"
793
+ )
667
794
 
668
795
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
669
796
  def do_LOAD_COMMANDS(self, commands_name):
670
797
  """Load commands from ./prompt.data/*.commands.md or from a specified template directory if an argument is given. Specify global/<commands_template> to access globally defined commands templates"""
671
- self._load_template_helper(commands_name, "commands", "*.commands.md")
798
+ self.template_loader.load_template(
799
+ commands_name, "commands", self.chat_name, "*.commands.md"
800
+ )
672
801
 
673
802
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
674
803
  def do_LOAD_BLUEPRINT(self, blueprint_name):
675
804
  """Load specified blueprint. Specify global/<blueprint_name> to access globally defined blueprints"""
676
- self._load_template_from_global_or_local(blueprint_name, "blueprint")
805
+ self.template_loader.load_template(
806
+ blueprint_name, "blueprint", self.chat_name)
677
807
 
678
- def _load_helper(self, directory: str, pattern: str, file_type: str, exclude_pattern: str | None = None):
808
+ def _load_helper(
809
+ self,
810
+ directory: str,
811
+ pattern: str,
812
+ file_type: str,
813
+ exclude_pattern: str | None = None,
814
+ ):
679
815
  import glob
680
816
 
681
- directory_path = os.path.join(os.path.dirname(self.chat_name), directory)
817
+ directory_path = os.path.join(
818
+ os.path.dirname(self.chat_name), directory)
682
819
  file_pattern = os.path.join(directory_path, pattern)
683
820
 
684
821
  exclude_files = []
@@ -688,7 +825,7 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
688
825
  matching_files = list(set(matching_files) - set(exclude_files))
689
826
 
690
827
  if not matching_files:
691
- print(f"No {file_type} file found.")
828
+ error_handler.report_error(AraError(f"No {file_type} file found."))
692
829
  return
693
830
 
694
831
  file_path = self.choose_file_to_load(matching_files, pattern)
@@ -705,10 +842,7 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
705
842
  from ara_cli.ara_config import ConfigManager
706
843
  from ara_cli.directory_navigator import DirectoryNavigator
707
844
 
708
- plurals = {
709
- "commands": "commands",
710
- "rules": "rules"
711
- }
845
+ plurals = {"commands": "commands", "rules": "rules"}
712
846
 
713
847
  plural = f"{template_type}s"
714
848
  if template_type in plurals:
@@ -716,7 +850,9 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
716
850
 
717
851
  if template_name.startswith("global/"):
718
852
  directory = f"{TemplatePathManager.get_template_base_path()}/prompt-modules/{plural}/"
719
- self._load_helper(directory, template_name.removeprefix("global/"), template_type)
853
+ self._load_helper(
854
+ directory, template_name.removeprefix("global/"), template_type
855
+ )
720
856
  return
721
857
 
722
858
  ara_config = ConfigManager.get_config()
@@ -730,7 +866,9 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
730
866
  os.chdir(original_directory)
731
867
 
732
868
  custom_prompt_templates_subdir = self.config.custom_prompt_templates_subdir
733
- template_directory = f"{local_templates_path}/{custom_prompt_templates_subdir}/{plural}"
869
+ template_directory = (
870
+ f"{local_templates_path}/{custom_prompt_templates_subdir}/{plural}"
871
+ )
734
872
  self._load_helper(template_directory, template_name, template_type)
735
873
 
736
874
  def _load_template_helper(self, template_name, template_type, default_pattern):
@@ -738,55 +876,100 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
738
876
  self._load_helper("prompt.data", default_pattern, template_type)
739
877
  return
740
878
 
741
- self._load_template_from_global_or_local(template_name=template_name, template_type=template_type)
879
+ self._load_template_from_global_or_local(
880
+ template_name=template_name, template_type=template_type
881
+ )
742
882
 
743
883
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
744
- def do_EXTRACT(self, _):
745
- """Search for markdown code blocks containing \"# [x] extract\" as first line and \"# filename: <path/filename>\" as second line and copy the content of the code block to the specified file. The extracted code block is then marked with \"# [v] extract\""""
746
- from ara_cli.prompt_extractor import extract_responses
884
+ @cmd2.with_argparser(extract_parser)
885
+ def do_EXTRACT(self, args):
886
+ """Search for markdown code blocks containing "# [x] extract" as first line and "# filename: <path/filename>" as second line and copy the content of the code block to the specified file. The extracted code block is then marked with "# [v] extract"."""
887
+ from ara_cli.commands.extract_command import ExtractCommand
888
+
889
+ command = ExtractCommand(
890
+ file_name=self.chat_name,
891
+ force=args.force,
892
+ write=args.write,
893
+ output=self.poutput,
894
+ )
895
+ command.execute()
896
+
897
+ def _find_givens_files(self, file_name: str) -> list[str]:
898
+ """
899
+ Finds the givens files to be processed.
900
+ - If file_name is provided, it resolves that path.
901
+ - Otherwise, it looks for default givens files.
902
+ - If no defaults are found, it prompts the user.
903
+ Returns a list of absolute file paths or an empty list if none are found.
904
+ """
905
+ base_directory = os.path.dirname(self.chat_name)
747
906
 
748
- extract_responses(self.chat_name, True)
749
- print("End of extraction")
907
+ def resolve_path(name):
908
+ """Inner helper to resolve a path relative to chat, then absolute."""
909
+ relative_path = os.path.join(base_directory, name)
910
+ if os.path.exists(relative_path):
911
+ return relative_path
912
+ if os.path.exists(name):
913
+ return name
914
+ return None
915
+
916
+ if file_name:
917
+ path = resolve_path(file_name)
918
+ if path:
919
+ return [path]
920
+ relative_path_for_error = os.path.join(base_directory, file_name)
921
+ error_handler.report_error(
922
+ AraError,
923
+ f"No givens file found at {relative_path_for_error} or {file_name}",
924
+ )
925
+ return []
926
+
927
+ # If no file_name, check for defaults
928
+ default_files_to_check = [
929
+ os.path.join(base_directory, "prompt.data",
930
+ "config.prompt_givens.md"),
931
+ os.path.join(
932
+ base_directory, "prompt.data", "config.prompt_global_givens.md"
933
+ ),
934
+ ]
935
+ existing_defaults = [
936
+ f for f in default_files_to_check if os.path.exists(f)]
937
+ if existing_defaults:
938
+ return existing_defaults
939
+
940
+ # No defaults found, prompt user
941
+ user_input = input("Please specify a givens file: ")
942
+ if not user_input:
943
+ self.poutput("Aborting.")
944
+ return []
945
+
946
+ path = resolve_path(user_input)
947
+ if path:
948
+ return [path]
949
+ error_handler.report_error(
950
+ AraError(f"No givens file found at {user_input}. Aborting.")
951
+ )
952
+ return []
750
953
 
751
954
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
752
955
  def do_LOAD_GIVENS(self, file_name):
753
- """Load all files listed in a ./prompt.data/config.prompt_givens.md"""
754
- from ara_cli.directory_navigator import DirectoryNavigator
956
+ """Load all files listed in a ./prompt.data/config.prompt_givens.md and ./prompt.data/config.prompt_global_givens.md"""
755
957
  from ara_cli.prompt_handler import load_givens
756
958
 
757
- base_directory = os.path.dirname(self.chat_name)
959
+ givens_files_to_process = self._find_givens_files(file_name)
960
+ if not givens_files_to_process:
961
+ error_handler.report_error(AraError("No givens files to load."))
962
+ return
758
963
 
759
- if file_name == "":
760
- file_name = f"{base_directory}/prompt.data/config.prompt_givens.md"
761
-
762
- # Check the relative path first
763
- relative_givens_path = os.path.join(base_directory, file_name)
764
- if os.path.exists(relative_givens_path):
765
- givens_path = relative_givens_path
766
- elif os.path.exists(file_name): # Check the absolute path
767
- givens_path = file_name
768
- else:
769
- print(f"No givens file found at {relative_givens_path} or {file_name}")
770
- user_input = input("Please specify a givens file: ")
771
- if os.path.exists(os.path.join(base_directory, user_input)):
772
- givens_path = os.path.join(base_directory, user_input)
773
- elif os.path.exists(user_input):
774
- givens_path = user_input
775
- else:
776
- print(f"No givens file found at {user_input}. Aborting.")
777
- return
964
+ for givens_path in givens_files_to_process:
965
+ # The givens_path is absolute, and load_givens reconstructs absolute paths
966
+ # from the markdown file. No directory change is needed.
967
+ content, _ = load_givens(givens_path)
778
968
 
779
- cwd = os.getcwd()
780
- navigator = DirectoryNavigator()
781
- navigator.navigate_to_target()
782
- os.chdir('..')
783
- content, image_data = load_givens(givens_path)
784
- os.chdir(cwd)
785
-
786
- with open(self.chat_name, 'a', encoding='utf-8') as chat_file:
787
- chat_file.write(content)
969
+ with open(self.chat_name, "a", encoding="utf-8") as chat_file:
970
+ chat_file.write(content)
788
971
 
789
- print(f"Loaded files listed and marked in {givens_path}")
972
+ self.poutput(f"Loaded files listed and marked in {givens_path}")
790
973
 
791
974
  @cmd2.with_category(CATEGORY_CHAT_CONTROL)
792
975
  def do_SEND(self, _):
@@ -800,17 +983,20 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
800
983
  """Load artefact template"""
801
984
  from ara_cli.artefact_models.artefact_templates import template_artefact_of_type
802
985
 
803
- artefact = template_artefact_of_type(''.join(template_name))
986
+ artefact = template_artefact_of_type("".join(template_name))
804
987
  if not artefact:
988
+ error_handler.report_error(
989
+ ValueError(f"No template for '{template_name}' found.")
990
+ )
805
991
  return
806
992
  write_content = artefact.serialize()
807
993
  self.add_prompt_tag_if_needed(self.chat_name)
808
- with open(self.chat_name, 'a', encoding='utf-8') as chat_file:
994
+ with open(self.chat_name, "a", encoding="utf-8") as chat_file:
809
995
  chat_file.write(write_content)
810
996
  print(f"Loaded {template_name} artefact template")
811
997
 
812
998
  def complete_LOAD_TEMPLATE(self, text, line, begidx, endidx):
813
- return self._complete_classifiers(self, text, line, begidx, endidx)
999
+ return self._complete_classifiers(text, line, begidx, endidx)
814
1000
 
815
1001
  def _complete_classifiers(self, text, line, begidx, endidx):
816
1002
  from ara_cli.classifier import Classifier
@@ -819,6 +1005,195 @@ Start chatting (type 'HELP'/'h' for available commands, 'QUIT'/'q' to exit chat
819
1005
  if not text:
820
1006
  completions = classifiers
821
1007
  else:
822
- completions = [classifier for classifier in classifiers if classifier.startswith(text)]
1008
+ completions = [
1009
+ classifier for classifier in classifiers if classifier.startswith(text)
1010
+ ]
823
1011
 
824
1012
  return completions
1013
+
1014
+ def _get_plural_template_type(self, template_type: str) -> str:
1015
+ """Determines the plural form of a template type."""
1016
+ plurals = {"commands": "commands", "rules": "rules"}
1017
+ return plurals.get(template_type, f"{template_type}s")
1018
+
1019
+ def _find_project_root(self) -> str | None:
1020
+ """
1021
+ Finds the project root by searching for an 'ara' directory,
1022
+ starting from the chat file's directory and moving upwards.
1023
+ """
1024
+ current_dir = os.path.dirname(self.chat_name)
1025
+ while True:
1026
+ if os.path.isdir(os.path.join(current_dir, "ara")):
1027
+ return current_dir
1028
+ parent_dir = os.path.dirname(current_dir)
1029
+ if parent_dir == current_dir: # Reached the filesystem root
1030
+ return None
1031
+ current_dir = parent_dir
1032
+
1033
+ def _gather_templates_from_path(
1034
+ self, search_path: str, templates_set: set, prefix: str = ""
1035
+ ):
1036
+ """
1037
+ Scans a given path for items and adds them to the provided set,
1038
+ optionally prepending a prefix.
1039
+ """
1040
+ import glob
1041
+
1042
+ if not os.path.isdir(search_path):
1043
+ return
1044
+ for path in glob.glob(os.path.join(search_path, "*")):
1045
+ templates_set.add(f"{prefix}{os.path.basename(path)}")
1046
+
1047
+ def _get_available_templates(self, template_type: str) -> list[str]:
1048
+ """
1049
+ Scans for available global and project-local custom templates.
1050
+ This method safely searches for template files without changing the
1051
+ current directory, making it safe for use in autocompleters.
1052
+
1053
+ Args:
1054
+ template_type: The type of template to search for (e.g., 'rules').
1055
+
1056
+ Returns:
1057
+ A sorted list of unique template names. Global templates are
1058
+ prefixed with 'global/'.
1059
+ """
1060
+ from ara_cli.template_manager import TemplatePathManager
1061
+
1062
+ plural_type = self._get_plural_template_type(template_type)
1063
+ templates = set()
1064
+
1065
+ # 1. Find Global Templates
1066
+ try:
1067
+ global_base_path = TemplatePathManager.get_template_base_path()
1068
+ global_template_dir = os.path.join(
1069
+ global_base_path, "prompt-modules", plural_type
1070
+ )
1071
+ self._gather_templates_from_path(
1072
+ global_template_dir, templates, prefix="global/"
1073
+ )
1074
+ except Exception:
1075
+ pass # Silently ignore if global templates are not found
1076
+
1077
+ # 2. Find Local Custom Templates
1078
+ try:
1079
+ project_root = self._find_project_root()
1080
+ if project_root:
1081
+ local_templates_base = os.path.join(
1082
+ project_root, self.config.local_prompt_templates_dir
1083
+ )
1084
+ custom_dir = os.path.join(
1085
+ local_templates_base,
1086
+ self.config.custom_prompt_templates_subdir,
1087
+ plural_type,
1088
+ )
1089
+ self._gather_templates_from_path(custom_dir, templates)
1090
+ except Exception:
1091
+ pass # Silently ignore if local templates cannot be resolved
1092
+
1093
+ return sorted(list(templates))
1094
+
1095
+ def _template_completer(self, text: str, template_type: str) -> list[str]:
1096
+ """Generic completer for different template types."""
1097
+ available_templates = self.template_loader.get_available_templates(
1098
+ template_type, os.path.dirname(self.chat_name)
1099
+ )
1100
+ if not text:
1101
+ return available_templates
1102
+ return [t for t in available_templates if t.startswith(text)]
1103
+
1104
+ def complete_LOAD_RULES(self, text, line, begidx, endidx):
1105
+ """Completer for the LOAD_RULES command."""
1106
+ return self._template_completer(text, "rules")
1107
+
1108
+ def complete_LOAD_INTENTION(self, text, line, begidx, endidx):
1109
+ """Completer for the LOAD_INTENTION command."""
1110
+ return self._template_completer(text, "intention")
1111
+
1112
+ def complete_LOAD_COMMANDS(self, text, line, begidx, endidx):
1113
+ """Completer for the LOAD_COMMANDS command."""
1114
+ return self._template_completer(text, "commands")
1115
+
1116
+ def complete_LOAD_BLUEPRINT(self, text, line, begidx, endidx):
1117
+ """Completer for the LOAD_BLUEPRINT command."""
1118
+ return self._template_completer(text, "blueprint")
1119
+
1120
+ # ===== AGENT CONTROL COMMANDS =====
1121
+
1122
+ @cmd2.with_category(CATEGORY_AGENT_CONTROL)
1123
+ def do_AGENT_RUN(self, args):
1124
+ """Run an agent. Usage: AGENT_RUN <agent_name> [artefact_classifier artefact_name]
1125
+
1126
+ Examples:
1127
+ AGENT_RUN interview_agent
1128
+ AGENT_RUN interview_agent feature my_feature
1129
+ """
1130
+ parts = args.split()
1131
+ if not parts:
1132
+ print(f"Available agents: {', '.join(self.AVAILABLE_AGENTS)}")
1133
+ print(
1134
+ "Usage: AGENT_RUN <agent_name> [artefact_classifier artefact_name]")
1135
+ return
1136
+
1137
+ agent_name = parts[0]
1138
+ if agent_name not in self.AVAILABLE_AGENTS:
1139
+ print(f"Unknown agent: {agent_name}")
1140
+ print(f"Available agents: {', '.join(self.AVAILABLE_AGENTS)}")
1141
+ return
1142
+
1143
+ # Get initial prompt
1144
+ initial_prompt = input(
1145
+ "Enter initial prompt (or press Enter for default): "
1146
+ ).strip()
1147
+ if not initial_prompt:
1148
+ initial_prompt = "Let's begin the interview."
1149
+
1150
+ # Extract artefact info if provided
1151
+ artefact_classifier = None
1152
+ artefact_name = None
1153
+ if len(parts) >= 3:
1154
+ artefact_classifier = parts[1]
1155
+ artefact_name = parts[2]
1156
+
1157
+ try:
1158
+ self.agent_manager.start_agent(
1159
+ agent_name, initial_prompt, artefact_classifier, artefact_name
1160
+ )
1161
+ except AraError as e:
1162
+ print(f"Error: {e}")
1163
+
1164
+ @cmd2.with_category(CATEGORY_AGENT_CONTROL)
1165
+ def do_AGENT_STOP(self, _):
1166
+ """Stop the currently running agent gracefully (sends termination signal)."""
1167
+ if not self.agent_manager.agent_process:
1168
+ print("No agent is currently running.")
1169
+ return
1170
+
1171
+ print(f"Stopping agent {self.agent_manager.agent_name}...")
1172
+ self.agent_manager.cleanup_agent_process()
1173
+ print("Agent stopped.")
1174
+
1175
+ @cmd2.with_category(CATEGORY_AGENT_CONTROL)
1176
+ def do_AGENT_CONTINUE(self, _):
1177
+ """Continue with the agent without providing new input (sends empty line)."""
1178
+ self.agent_manager.continue_agent()
1179
+
1180
+ @cmd2.with_category(CATEGORY_AGENT_CONTROL)
1181
+ def do_AGENT_STATUS(self, _):
1182
+ """Show status of the current agent."""
1183
+ print(self.agent_manager.get_agent_status())
1184
+
1185
+ def complete_AGENT_RUN(self, text, line, begidx, endidx):
1186
+ """Completer for AGENT_RUN command."""
1187
+ parts = line.split()
1188
+
1189
+ # Complete agent name
1190
+ if len(parts) <= 2:
1191
+ if not text:
1192
+ return self.AVAILABLE_AGENTS
1193
+ return [a for a in self.AVAILABLE_AGENTS if a.startswith(text)]
1194
+
1195
+ # Complete classifier
1196
+ if len(parts) == 3:
1197
+ return self._complete_classifiers(text, line, begidx, endidx)
1198
+
1199
+ return []