bone-agent 1.3.2 → 1.4.0

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 (87) hide show
  1. package/README.md +19 -2
  2. package/config.yaml.example +13 -2
  3. package/package.json +3 -2
  4. package/prompts/main/ask_questions.md +31 -0
  5. package/prompts/main/batch_independent_calls.md +5 -0
  6. package/prompts/main/casual_interactions.md +11 -0
  7. package/prompts/main/code_references.md +8 -0
  8. package/prompts/main/communication_style.md +12 -0
  9. package/prompts/main/context_reliability.md +12 -0
  10. package/prompts/main/conversational_tool_calling.md +15 -0
  11. package/prompts/main/dream.md +50 -0
  12. package/prompts/main/editing_pattern.md +13 -0
  13. package/prompts/main/error_handling.md +6 -0
  14. package/prompts/main/exploration_pattern.md +21 -0
  15. package/prompts/main/intro.md +1 -0
  16. package/prompts/main/obsidian.md +16 -0
  17. package/prompts/main/obsidian_project.md +79 -0
  18. package/prompts/main/professional_objectivity.md +3 -0
  19. package/prompts/main/skills.md +3 -0
  20. package/prompts/main/targeted_searching.md +10 -0
  21. package/prompts/main/task_lists_pattern.md +8 -0
  22. package/prompts/main/temp_folder.md +9 -0
  23. package/prompts/main/think_before_acting.md +10 -0
  24. package/prompts/main/tone_and_style.md +4 -0
  25. package/prompts/main/tool_preferences.md +24 -0
  26. package/prompts/main/trust_subagent_context.md +21 -0
  27. package/prompts/main/when_to_use_sub_agent.md +7 -0
  28. package/prompts/micro/ask_questions.md +1 -0
  29. package/prompts/micro/batch_independent_calls.md +1 -0
  30. package/prompts/micro/casual_interactions.md +1 -0
  31. package/prompts/micro/code_references.md +1 -0
  32. package/prompts/micro/communication_style.md +1 -0
  33. package/prompts/micro/context_reliability.md +1 -0
  34. package/prompts/micro/conversational_tool_calling.md +1 -0
  35. package/prompts/micro/editing_pattern.md +1 -0
  36. package/prompts/micro/error_handling.md +1 -0
  37. package/prompts/micro/exploration_pattern.md +1 -0
  38. package/prompts/micro/intro.md +1 -0
  39. package/prompts/micro/obsidian.md +4 -0
  40. package/prompts/micro/obsidian_project.md +5 -0
  41. package/prompts/micro/professional_objectivity.md +1 -0
  42. package/prompts/micro/skills.md +1 -0
  43. package/prompts/micro/targeted_searching.md +1 -0
  44. package/prompts/micro/task_lists_pattern.md +1 -0
  45. package/prompts/micro/temp_folder.md +1 -0
  46. package/prompts/micro/think_before_acting.md +5 -0
  47. package/prompts/micro/tone_and_style.md +1 -0
  48. package/prompts/micro/tool_preferences.md +1 -0
  49. package/prompts/micro/trust_subagent_context.md +1 -0
  50. package/prompts/micro/when_to_use_sub_agent.md +1 -0
  51. package/src/core/agentic.py +134 -106
  52. package/src/core/chat_manager.py +60 -12
  53. package/src/core/config_manager.py +14 -1
  54. package/src/core/cron.py +57 -6
  55. package/src/core/memory.py +3 -90
  56. package/src/core/metadata.py +75 -0
  57. package/src/core/skills.py +463 -0
  58. package/src/core/sub_agent.py +93 -43
  59. package/src/core/tool_feedback.py +87 -76
  60. package/src/llm/client.py +7 -2
  61. package/src/llm/codex_provider.py +350 -0
  62. package/src/llm/config.py +74 -4
  63. package/src/llm/prompts.py +261 -502
  64. package/src/llm/providers.py +28 -7
  65. package/src/llm/token_tracker.py +32 -1
  66. package/src/tools/__init__.py +24 -85
  67. package/src/tools/create_file.py +1 -1
  68. package/src/tools/directory.py +1 -1
  69. package/src/tools/edit.py +13 -7
  70. package/src/tools/file_reader.py +1 -1
  71. package/src/tools/helpers/__init__.py +1 -7
  72. package/src/tools/helpers/base.py +65 -16
  73. package/src/tools/helpers/loader.py +2 -88
  74. package/src/tools/helpers/path_resolver.py +70 -13
  75. package/src/tools/helpers/plugin_manifest.py +99 -70
  76. package/src/tools/review_sub_agent.py +2 -1
  77. package/src/tools/rg_search.py +119 -35
  78. package/src/tools/search_plugins.py +140 -72
  79. package/src/tools/shell.py +3 -3
  80. package/src/ui/commands.py +470 -33
  81. package/src/ui/displays.py +27 -1
  82. package/src/ui/main.py +1 -4
  83. package/src/ui/tool_confirmation.py +16 -5
  84. package/src/utils/editor.py +88 -39
  85. package/src/utils/settings.py +25 -4
  86. package/src/utils/user_message_logger.py +120 -0
  87. package/src/utils/validation.py +10 -0
@@ -2,7 +2,6 @@
2
2
 
3
3
  import json
4
4
  import logging
5
- import time
6
5
  from pathlib import Path
7
6
  from typing import Optional
8
7
 
@@ -47,6 +46,7 @@ from core.tool_feedback import (
47
46
  display_tool_feedback,
48
47
  )
49
48
  from ui.sub_agent_panel import SubAgentPanel
49
+ from tools.helpers.path_resolver import extract_boundary_path, is_boundary_error, set_full_filesystem_access
50
50
 
51
51
 
52
52
  def _handle_empty_response(empty_response_count, console):
@@ -88,7 +88,6 @@ def _handle_tool_limit_reached(chat_manager, console):
88
88
  response,
89
89
  model_name=provider_cfg.get("model", ""),
90
90
  )
91
-
92
91
  try:
93
92
  final_message = response["choices"][0]["message"]
94
93
  except (KeyError, IndexError):
@@ -106,8 +105,6 @@ def _handle_tool_limit_reached(chat_manager, console):
106
105
  console.print("[red]Error: model returned empty response after tool limit reached.[/red]")
107
106
  return False
108
107
 
109
-
110
-
111
108
  class AgenticOrchestrator:
112
109
  """Orchestrates the agentic tool-calling loop.
113
110
 
@@ -172,78 +169,42 @@ class AgenticOrchestrator:
172
169
  # Check if we're in a parallel context with suppressed console
173
170
  return self._parallel_context.get('console', self.console)
174
171
 
175
- def _is_memory_file(self, path: str) -> bool:
176
- """Check if path targets a memory file (auto-approved).
172
+ def _get_effective_tools(self, allowed_tools=None, allow_active_plugins=False):
173
+ """Return tool schemas allowed for the current run."""
174
+ from tools.helpers.base import ToolRegistry
177
175
 
178
- Auto-approve scope (restricted to known memory paths):
179
- - {repo_root}/.bone/agents.md project memory
180
- - ~/.bone/user_memory.md — global user memory
181
- - Any file under {repo_root}/.bone/ — project memory directory
176
+ tools = TOOLS()
177
+ if allowed_tools is None:
178
+ return tools
182
179
 
183
- Args:
184
- path: File path from tool arguments.
180
+ effective_names = set(allowed_tools)
181
+ if allow_active_plugins:
182
+ effective_names.update(ToolRegistry.active_plugin_names())
185
183
 
186
- Returns:
187
- True if the file should be auto-approved as a memory file.
188
- """
189
- p = Path(path).resolve()
190
- repo_root = Path(self.repo_root).resolve()
191
- # Known memory paths
192
- if p == Path.home() / ".bone" / "user_memory.md":
193
- return True
194
- # Any file under {repo_root}/.bone/ (future memory files)
195
- bone_dir = repo_root / ".bone"
196
- if p.is_relative_to(bone_dir):
197
- return True
198
- return False
184
+ if not effective_names:
185
+ return []
199
186
 
200
- def _execute_memory_edit(self, arguments) -> bool:
201
- """Apply a memory file edit synchronously with one retry on failure.
187
+ return [tool for tool in tools if tool["function"]["name"] in effective_names]
202
188
 
203
- Args:
204
- arguments: Tool arguments dict (path, search, replace, etc.)
205
- """
206
- from tools.edit import _execute_edit_file
207
-
208
- kwargs = dict(
209
- path=arguments.get("path"),
210
- search=arguments.get("search"),
211
- replace=arguments.get("replace"),
212
- repo_root=self.repo_root,
213
- console=None, # silent — no output in chat
214
- gitignore_spec=self.gitignore_spec,
215
- context_lines=arguments.get("context_lines", 3),
216
- vault_root=vault_root_str(),
217
- )
218
- try:
219
- _execute_edit_file(**kwargs)
220
- return True
221
- except Exception as e:
222
- logger.warning("Memory edit failed (retrying in 0.5s): %s", e)
223
- time.sleep(0.5)
224
- try:
225
- _execute_edit_file(**kwargs)
226
- logger.info("Memory edit retry succeeded after initial failure.")
227
- return True
228
- except Exception as e2:
229
- logger.error("Memory edit failed after retry: %s", e2)
230
- return False
231
-
232
- def run(self, user_input, thinking_indicator=None, allowed_tools=None):
189
+ def run(self, user_input, thinking_indicator=None, allowed_tools=None, allow_active_plugins=False):
233
190
  """Main orchestration loop.
234
191
 
235
192
  Args:
236
193
  user_input: User's input message
237
194
  thinking_indicator: Optional ThinkingIndicator instance
238
195
  allowed_tools: Optional list of allowed tool names (for research)
196
+ allow_active_plugins: Whether to include active plugin tools in restricted runs
239
197
  """
198
+ self._current_allowed_tools = allowed_tools
199
+ self._current_allow_active_plugins = allow_active_plugins
200
+
240
201
  # Append user message
241
202
  self.chat_manager.messages.append({"role": "user", "content": user_input})
242
203
 
243
204
  # Log user message
244
205
  self.chat_manager.log_message({"role": "user", "content": user_input})
245
206
 
246
- from tools.base import ToolRegistry
207
+ from tools.helpers.base import ToolRegistry
247
208
 
248
209
  while True:
249
210
  # Decrement plugin TTLs after previous iteration's tool execution.
@@ -253,7 +214,10 @@ class AgenticOrchestrator:
253
214
  self.console.print(f"[dim]Plugins evicted (TTL expired): {evicted}[/dim]")
254
215
 
255
216
  # Get response from LLM
256
- response = self._get_llm_response(allowed_tools=allowed_tools)
217
+ response = self._get_llm_response(
218
+ allowed_tools=allowed_tools,
219
+ allow_active_plugins=allow_active_plugins,
220
+ )
257
221
  if response is None:
258
222
  return
259
223
 
@@ -267,11 +231,16 @@ class AgenticOrchestrator:
267
231
  if self._handle_final_response(response, thinking_indicator):
268
232
  return
269
233
  else:
270
- should_exit = self._handle_tool_calls(response, thinking_indicator, allowed_tools)
234
+ should_exit = self._handle_tool_calls(
235
+ response,
236
+ thinking_indicator,
237
+ allowed_tools,
238
+ allow_active_plugins=allow_active_plugins,
239
+ )
271
240
  if should_exit:
272
241
  return
273
242
 
274
- def _get_llm_response(self, allowed_tools=None):
243
+ def _get_llm_response(self, allowed_tools=None, allow_active_plugins=False):
275
244
  """Get next LLM response with tool definitions.
276
245
 
277
246
  Includes automatic retry with live countdown for timeout/connection errors.
@@ -279,6 +248,7 @@ class AgenticOrchestrator:
279
248
 
280
249
  Args:
281
250
  allowed_tools: Optional list of allowed tool names (overrides mode-based filtering)
251
+ allow_active_plugins: Whether to include active plugin tools in restricted runs
282
252
 
283
253
  Returns:
284
254
  Response dict from LLM, or None if error occurred
@@ -287,19 +257,17 @@ class AgenticOrchestrator:
287
257
  self.chat_manager.ensure_context_fits(console=self.console)
288
258
 
289
259
  # Use allowed_tools if provided, otherwise use mode-based filtering
290
- if allowed_tools is not None:
291
- # Validate that allowed_tools is not empty
292
- if not allowed_tools:
293
- self.console.print("[red]Error: allowed_tools is empty[/red]")
294
- return None
295
- # TOOLS is a function, call it to get the list
296
- tools = [tool for tool in TOOLS() if tool["function"]["name"] in allowed_tools]
297
- # Log filtered tools for debugging
298
- if self.debug_mode:
299
- tool_names = [t["function"]["name"] for t in tools]
300
- self.console.print(f"[dim]Available tools: {tool_names}[/dim]")
301
- else:
302
- tools = TOOLS()
260
+ if allowed_tools is not None and not allowed_tools and not allow_active_plugins:
261
+ self.console.print("[red]Error: allowed_tools is empty[/red]")
262
+ return None
263
+
264
+ tools = self._get_effective_tools(
265
+ allowed_tools=allowed_tools,
266
+ allow_active_plugins=allow_active_plugins,
267
+ )
268
+ if allowed_tools is not None and self.debug_mode:
269
+ tool_names = [t["function"]["name"] for t in tools]
270
+ self.console.print(f"[dim]Available tools: {tool_names}[/dim]")
303
271
 
304
272
  # Retry loop for timeout/connection errors
305
273
  last_error = None
@@ -320,7 +288,21 @@ class AgenticOrchestrator:
320
288
  continue
321
289
  else:
322
290
  # Non-retryable error or final attempt exhausted
291
+ detail_lines = []
292
+ for key, value in getattr(e, "details", {}).items():
293
+ value_str = str(value)
294
+ if "\n" in value_str or key == "original_error":
295
+ detail_lines.append(f"{key}: {value_str}")
296
+ detailed_error = str(e)
297
+ if detail_lines:
298
+ detailed_error += "\n\n" + "\n\n".join(detail_lines)
299
+
300
+ if self.is_sub_agent:
301
+ raise LLMError(detailed_error, details=getattr(e, "details", {}))
302
+
323
303
  self.console.print(f"[red]LLM Error: {e}[/red]")
304
+ if detail_lines:
305
+ self.console.print(f"[dim]{detail_lines[0]}[/dim]", markup=False)
324
306
  return None
325
307
 
326
308
  # Successful response — parse and return
@@ -382,8 +364,11 @@ class AgenticOrchestrator:
382
364
  # NEW: Compact tool results after final answer (per-message compaction)
383
365
  self.chat_manager.compact_tool_results(skip_token_update=True)
384
366
 
385
- # Update context tokens with current mode's tools
386
- tools_for_mode = TOOLS()
367
+ # Update context tokens with current run's effective tools
368
+ tools_for_mode = self._get_effective_tools(
369
+ allowed_tools=getattr(self, "_current_allowed_tools", None),
370
+ allow_active_plugins=getattr(self, "_current_allow_active_plugins", False),
371
+ )
387
372
  self.chat_manager._update_context_tokens(tools_for_mode)
388
373
 
389
374
  self.console.print()
@@ -395,13 +380,14 @@ class AgenticOrchestrator:
395
380
  )
396
381
  return not should_continue
397
382
 
398
- def _handle_tool_calls(self, response, thinking_indicator, allowed_tools=None):
383
+ def _handle_tool_calls(self, response, thinking_indicator, allowed_tools=None, allow_active_plugins=False):
399
384
  """Process tool calls and display accompanying content.
400
385
 
401
386
  Args:
402
387
  response: Full message dict from LLM (includes content and tool_calls)
403
388
  thinking_indicator: Optional ThinkingIndicator instance
404
389
  allowed_tools: Optional list of allowed tool names
390
+ allow_active_plugins: Whether to allow active plugin tools in restricted runs
405
391
 
406
392
  Returns:
407
393
  True if should exit the orchestration loop
@@ -415,6 +401,8 @@ class AgenticOrchestrator:
415
401
  # This must happen BEFORE filtering so the LLM sees its original intent
416
402
  content = (response.get("content") or "").strip()
417
403
  assistant_msg = {"role": "assistant", "tool_calls": tool_calls}
404
+ if response.get("_responses_output"):
405
+ assistant_msg["_responses_output"] = response["_responses_output"]
418
406
  if content:
419
407
  assistant_msg["content"] = content
420
408
  self.chat_manager.messages.append(assistant_msg)
@@ -425,7 +413,7 @@ class AgenticOrchestrator:
425
413
  # This silently removes unknown tools or tools not in the allowed whitelist
426
414
  # to prevent error messages from reaching the user while allowing the agent
427
415
  # to continue with alternative tools.
428
- from tools.base import ToolRegistry
416
+ from tools.helpers.base import ToolRegistry
429
417
 
430
418
  filtered_calls = []
431
419
  filtered_tool_ids = [] # Track filtered tool IDs to provide feedback
@@ -442,10 +430,14 @@ class AgenticOrchestrator:
442
430
  filtered_tool_ids.append(tool_call.get("id"))
443
431
  continue
444
432
 
445
- # Check if tool is in allowed_tools whitelist (if provided)
446
- # Plugin-tier tools bypass the whitelist — they are already vetted
447
- # by the manifest and activated on-demand via search_plugins.
448
- if allowed_tools and function_name not in allowed_tools and not ToolRegistry.is_plugin_active(function_name):
433
+ # Check if tool is in the effective allowlist for this run.
434
+ effective_allowed_tools = None
435
+ if allowed_tools is not None:
436
+ effective_allowed_tools = set(allowed_tools)
437
+ if allow_active_plugins:
438
+ effective_allowed_tools.update(ToolRegistry.active_plugin_names())
439
+
440
+ if effective_allowed_tools is not None and function_name not in effective_allowed_tools:
449
441
  # Silent fail - skip this tool
450
442
  if self.debug_mode:
451
443
  self.console.print(f"[dim]Silently filtered non-allowed tool: {function_name}[/dim]")
@@ -600,8 +592,11 @@ class AgenticOrchestrator:
600
592
  # Compact completed tool blocks once after all tools complete
601
593
  self.chat_manager.compact_tool_results(skip_token_update=True)
602
594
 
603
- # Update context tokens with current mode's tools
604
- tools_for_mode = TOOLS()
595
+ # Update context tokens with current run's effective tools
596
+ tools_for_mode = self._get_effective_tools(
597
+ allowed_tools=getattr(self, "_current_allowed_tools", None),
598
+ allow_active_plugins=getattr(self, "_current_allow_active_plugins", False),
599
+ )
605
600
  self.chat_manager._update_context_tokens(tools_for_mode)
606
601
 
607
602
  # Pre-send guard: ensure context fits before next LLM call
@@ -621,7 +616,7 @@ class AgenticOrchestrator:
621
616
  """
622
617
  if not tool_calls:
623
618
  return False
624
- from tools.parallel_executor import ParallelToolExecutor, ToolCall
619
+ from tools.helpers.parallel_executor import ParallelToolExecutor, ToolCall
625
620
 
626
621
  # Suppress console output in handlers during parallel execution
627
622
  # We'll display results ourselves in order below
@@ -841,8 +836,11 @@ class AgenticOrchestrator:
841
836
  # after all parallel results are appended (safe — only compacts completed blocks)
842
837
  self.chat_manager.compact_tool_results(skip_token_update=True)
843
838
 
844
- # Update context tokens with current mode's tools
845
- tools_for_mode = TOOLS()
839
+ # Update context tokens with current run's effective tools
840
+ tools_for_mode = self._get_effective_tools(
841
+ allowed_tools=getattr(self, "_current_allowed_tools", None),
842
+ allow_active_plugins=getattr(self, "_current_allow_active_plugins", False),
843
+ )
846
844
  self.chat_manager._update_context_tokens(tools_for_mode)
847
845
 
848
846
  # Pre-send guard: ensure context fits before next LLM call
@@ -853,6 +851,38 @@ class AgenticOrchestrator:
853
851
  # Restore console output
854
852
  self._parallel_context['console'] = self.console
855
853
 
854
+ def _boundary_prompt(self, path_str):
855
+ """Prompt the user to grant filesystem access for a path outside boundaries.
856
+
857
+ Called after a tool returns a boundary error. If the user grants access,
858
+ the caller retries the tool with the boundary lifted.
859
+
860
+ Args:
861
+ path_str: The path that triggered the boundary violation.
862
+
863
+ Returns:
864
+ True if user granted access, False if denied.
865
+ """
866
+ if self.is_sub_agent:
867
+ return False
868
+
869
+ console = self._get_console()
870
+ if console is None:
871
+ return False
872
+
873
+ from ui.tool_confirmation import ToolConfirmationPanel
874
+ panel = ToolConfirmationPanel(
875
+ 'Grant filesystem access',
876
+ reason=f'Agent requested access outside project boundary: {path_str}',
877
+ is_edit_tool=False
878
+ )
879
+ action, _ = panel.run()
880
+
881
+ if action == "accept":
882
+ console.print("[yellow]Full filesystem access granted[/yellow]\n")
883
+ return True
884
+ return False
885
+
856
886
  def _process_single_tool_call(self, tool_call, thinking_indicator):
857
887
  """Process a single tool call.
858
888
 
@@ -884,7 +914,7 @@ class AgenticOrchestrator:
884
914
  panel_to_use = SubAgentPanel(query, self.console)
885
915
 
886
916
  # Execute via tool registry
887
- from tools.base import ToolRegistry, build_context
917
+ from tools.helpers.base import ToolRegistry, build_context
888
918
 
889
919
  tool = ToolRegistry.get(function_name)
890
920
  if tool:
@@ -908,26 +938,12 @@ class AgenticOrchestrator:
908
938
 
909
939
  # Check if tool requires approval
910
940
  if tool.requires_approval:
911
- # For edit_file: check memory file auto-approve first
941
+ # For edit_file: validate path then request approval
912
942
  if function_name == "edit_file":
913
943
  edit_path = arguments.get("path", "")
914
944
  if not edit_path:
915
945
  return False, "Error: path is required for edit_file."
916
946
 
917
- # Memory file: auto-approve, fire-and-forget
918
- if self._is_memory_file(edit_path):
919
- # Generate preview to validate the edit (reuses existing logic)
920
- result = tool.execute(arguments, context)
921
- preview, is_valid = resolve_edit_preview(result)
922
- if is_valid:
923
- ok = self._execute_memory_edit(arguments)
924
- if self.debug_mode:
925
- console = self._get_console()
926
- if console:
927
- console.print(f"[dim]Memory edit auto-approved: {edit_path}[/dim]")
928
- return False, "Memory saved." if ok else f"Memory edit failed: {edit_path}"
929
- return False, str(result)
930
-
931
947
  # Normal edit: generate preview and request approval
932
948
  result = tool.execute(arguments, context)
933
949
 
@@ -988,6 +1004,20 @@ class AgenticOrchestrator:
988
1004
  if policy == TERMINAL_YIELD and thinking_indicator:
989
1005
  thinking_indicator.resume()
990
1006
 
1007
+ # Boundary escalation: if the tool result is a path boundary
1008
+ # violation, prompt the user to grant session-wide access.
1009
+ result_str = str(result)
1010
+ if is_boundary_error(result_str):
1011
+ path_arg = arguments.get("path", arguments.get("path_str", ""))
1012
+ if not path_arg:
1013
+ path_arg = extract_boundary_path(result_str)
1014
+ granted = self._boundary_prompt(path_arg)
1015
+ if granted:
1016
+ set_full_filesystem_access(True)
1017
+ # Retry with the boundary now lifted
1018
+ result = tool.execute(arguments, context)
1019
+ result_str = str(result)
1020
+
991
1021
  # Display result for registry tools
992
1022
  # Skip display for tools that take over the terminal (they handle their own display)
993
1023
  if policy != TERMINAL_YIELD:
@@ -1020,7 +1050,7 @@ class AgenticOrchestrator:
1020
1050
  # Then display feedback
1021
1051
  display_tool_feedback(label, result, console, indent=self.is_sub_agent, panel_updater=self.panel_updater)
1022
1052
 
1023
- return False, str(result)
1053
+ return False, result_str
1024
1054
  except Exception as e:
1025
1055
  # If thinking_indicator was paused (TERMINAL_YIELD) and tool
1026
1056
  # raised, resume it so the spinner reappears for the next iteration
@@ -1053,5 +1083,3 @@ def agentic_answer(chat_manager, user_input, console, repo_root, rg_exe_path, de
1053
1083
  debug_mode=debug_mode,
1054
1084
  )
1055
1085
  orchestrator.run(user_input, thinking_indicator)
1056
-
1057
-
@@ -9,12 +9,14 @@ import requests
9
9
  from typing import Optional, IO
10
10
 
11
11
  from llm.client import LLMClient
12
- from llm.config import get_providers, get_provider_config, reload_config
12
+ from llm.config import get_providers, get_provider_config, get_provider_display_name, reload_config
13
13
  from llm.prompts import build_system_prompt
14
+ from core.skills import render_active_skills_section
14
15
  from pathlib import Path
15
16
  from llm.token_tracker import TokenTracker
16
17
  from utils.settings import server_settings, context_settings
17
18
  from utils.logger import MarkdownConversationLogger
19
+ from utils.user_message_logger import UserMessageLogger
18
20
  from utils.result_parsers import extract_exit_code, extract_metadata_from_result
19
21
 
20
22
  # Token counting constants
@@ -44,6 +46,10 @@ class ChatManager:
44
46
  self.task_list = []
45
47
  self.task_list_title = None
46
48
 
49
+ # In-session active skill tracking. These skills are rendered into the
50
+ # system prompt for the current chat.
51
+ self.loaded_skills = set()
52
+
47
53
  # .gitignore filtering state
48
54
  self._gitignore_spec = None
49
55
  self._gitignore_mtime = None
@@ -62,6 +68,9 @@ class ChatManager:
62
68
  conversations_dir=context_settings.conversations_dir
63
69
  )
64
70
 
71
+ # User message logging (always on, for dream memory system)
72
+ self.user_message_logger = UserMessageLogger()
73
+
65
74
  # Compaction lock: prevents compaction during active tool execution
66
75
  # Set by agentic.py before executing tools, cleared after all results appended
67
76
  self._compaction_locked = False
@@ -90,6 +99,9 @@ class ChatManager:
90
99
  if self.markdown_logger:
91
100
  self.markdown_logger.start_session()
92
101
 
102
+ # Active skills are scoped to the current message history/session.
103
+ self.loaded_skills = set()
104
+
93
105
  # Start with system prompt only
94
106
  self.messages = [{"role": "system", "content": self._build_system_prompt()}]
95
107
 
@@ -119,20 +131,38 @@ class ChatManager:
119
131
  self._update_context_tokens()
120
132
  self.context_token_estimate = self.token_tracker.current_context_tokens
121
133
 
122
- def _build_system_prompt(self) -> str:
123
- """Build system prompt."""
124
- return build_system_prompt()
134
+ def _build_system_prompt(self, variant: str | None = None) -> str:
135
+ """Build system prompt.
136
+
137
+ Args:
138
+ variant: Prompt variant name (e.g. 'main', 'micro').
139
+ If None, reads from prompt_settings.
140
+ """
141
+ if variant is None:
142
+ from utils.settings import prompt_settings
143
+ variant = prompt_settings.variant
144
+ active_skills_section = render_active_skills_section(self.loaded_skills)
145
+ return build_system_prompt(variant, active_skills_section=active_skills_section)
146
+
147
+ def update_system_prompt(self, variant: str | None = None):
148
+ """Rebuild system prompt in-place (e.g. after hotswap or session reset).
125
149
 
126
- def update_system_prompt(self):
127
- """Rebuild system prompt (e.g. after session reset)."""
150
+ Args:
151
+ variant: Prompt variant to use. If None, keeps current variant.
152
+ Updates token_tracker.current_variant.
153
+ """
128
154
  if not self.messages:
129
155
  raise RuntimeError("Cannot update system prompt: messages array is empty")
130
156
 
131
157
  if self.messages[0]["role"] != "system":
132
158
  raise RuntimeError(f"Cannot update system prompt: messages[0] has role '{self.messages[0]['role']}', expected 'system'")
133
159
 
134
- # Update the system message with current mode
135
- self.messages[0]["content"] = self._build_system_prompt()
160
+ if variant is None:
161
+ from utils.settings import prompt_settings
162
+ variant = prompt_settings.variant
163
+
164
+ self.messages[0]["content"] = self._build_system_prompt(variant)
165
+ self.token_tracker.current_variant = variant
136
166
  self._update_context_tokens()
137
167
 
138
168
  def _load_agents_md(self) -> tuple[str, str]:
@@ -1305,7 +1335,8 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
1305
1335
  """
1306
1336
  providers = get_providers()
1307
1337
  if provider_name not in providers:
1308
- return f"Invalid provider. Use /provider to list. Available: {', '.join(providers)}"
1338
+ available = ', '.join(get_provider_display_name(provider) for provider in providers)
1339
+ return f"Invalid provider. Use /provider to list. Available: {available}"
1309
1340
 
1310
1341
  previous_provider = self.client.provider
1311
1342
 
@@ -1321,10 +1352,13 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
1321
1352
  # Failed to start server - revert
1322
1353
  self.client.switch_provider(previous_provider)
1323
1354
  self._init_messages(reset_costs=True)
1324
- return f"Failed to start local server. Reverted to {previous_provider} provider."
1355
+ previous_label = get_provider_display_name(previous_provider)
1356
+ return f"Failed to start local server. Reverted to {previous_label} provider."
1325
1357
  self.server_process = server
1326
- return f"Switched to {provider_name} provider (server ready)."
1327
- return f"Switched to {provider_name} provider."
1358
+ provider_label = get_provider_display_name(provider_name)
1359
+ return f"Switched to {provider_label} provider (server ready)."
1360
+ provider_label = get_provider_display_name(provider_name)
1361
+ return f"Switched to {provider_label} provider."
1328
1362
  return "Provider switch failed."
1329
1363
 
1330
1364
  def reload_config(self):
@@ -1376,6 +1410,10 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
1376
1410
  server_path,
1377
1411
  "-m", model_path,
1378
1412
  "-ngl", str(server_settings.ngl_layers),
1413
+ "--threads", str(server_settings.threads),
1414
+ "--batch-size", str(server_settings.batch_size),
1415
+ "--ubatch-size", str(server_settings.ubatch_size),
1416
+ "--flash-attn" if server_settings.flash_attn else "--no-flash-attn",
1379
1417
  "--split-mode", "none",
1380
1418
  "--ctx-size", str(server_settings.ctx_size),
1381
1419
  "--n-predict", str(server_settings.n_predict),
@@ -1383,6 +1421,7 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
1383
1421
  "--host", host,
1384
1422
  "--port", str(port),
1385
1423
  "--jinja",
1424
+ "--reasoning", "off",
1386
1425
  ]
1387
1426
 
1388
1427
  # Restrict to RTX 5070 Ti only (GPU 0)
@@ -1459,6 +1498,15 @@ Provide a concise summary (2-4 paragraphs) that captures all essential context f
1459
1498
  if self.markdown_logger:
1460
1499
  self.markdown_logger.log_message(message)
1461
1500
 
1501
+ # Log user messages to JSONL for dream memory processing (only if memory enabled)
1502
+ if message.get("role") == "user" and message.get("content"):
1503
+ from llm.config import MEMORY_SETTINGS
1504
+ if MEMORY_SETTINGS.get("enabled", True):
1505
+ self.user_message_logger.log_user_message(
1506
+ message["content"],
1507
+ project_dir=Path.cwd().resolve(),
1508
+ )
1509
+
1462
1510
  def sync_log(self):
1463
1511
  """Rewrite the entire conversation log to match current message state.
1464
1512
 
@@ -36,12 +36,16 @@ class ConfigManager:
36
36
  with open(self.config_path, 'r', encoding='utf-8-sig') as f:
37
37
  self._cached_data = yaml.safe_load(f) or {}
38
38
 
39
- # Migrate: rename old provider IDs -> bone (provider ID rename)
39
+ # Migrate legacy provider IDs to current names.
40
40
  old_provider = self._cached_data.get('LAST_PROVIDER')
41
41
  if old_provider in ('vmcode_proxy', 'vmcode_free', 'vmcode'):
42
42
  logger.info("Migrating provider name '%s' -> 'bone'", old_provider)
43
43
  self._cached_data['LAST_PROVIDER'] = 'bone'
44
44
  self.save(self._cached_data, create_backup=True)
45
+ elif old_provider == 'codex_plan':
46
+ logger.info("Migrating provider name '%s' -> 'codex'", old_provider)
47
+ self._cached_data['LAST_PROVIDER'] = 'codex'
48
+ self.save(self._cached_data, create_backup=True)
45
49
 
46
50
  return self._cached_data
47
51
  except yaml.YAMLError as e:
@@ -114,11 +118,14 @@ class ConfigManager:
114
118
  if model is None:
115
119
  provider_model_map = {
116
120
  'bone': 'BONE_PROXY_MODEL',
121
+ 'codex': 'CODEX_PLAN_MODEL',
117
122
  'openrouter': 'OPENROUTER_MODEL',
118
123
  'glm': 'GLM_MODEL',
124
+ 'glm_plan': 'GLM_PLAN_MODEL',
119
125
  'openai': 'OPENAI_MODEL',
120
126
  'gemini': 'GEMINI_MODEL',
121
127
  'minimax': 'MINIMAX_MODEL',
128
+ 'minimax_plan': 'MINIMAX_PLAN_MODEL',
122
129
  'anthropic': 'ANTHROPIC_MODEL',
123
130
  'kimi': 'KIMI_MODEL'
124
131
  }
@@ -142,11 +149,14 @@ class ConfigManager:
142
149
  provider_keys = {
143
150
  'local': 'LOCAL_MODEL_PATH',
144
151
  'bone': 'BONE_PROXY_MODEL',
152
+ 'codex': 'CODEX_PLAN_MODEL',
145
153
  'openrouter': 'OPENROUTER_MODEL',
146
154
  'glm': 'GLM_MODEL',
155
+ 'glm_plan': 'GLM_PLAN_MODEL',
147
156
  'openai': 'OPENAI_MODEL',
148
157
  'gemini': 'GEMINI_MODEL',
149
158
  'minimax': 'MINIMAX_MODEL',
159
+ 'minimax_plan': 'MINIMAX_PLAN_MODEL',
150
160
  'anthropic': 'ANTHROPIC_MODEL',
151
161
  'kimi': 'KIMI_MODEL'
152
162
  }
@@ -171,10 +181,13 @@ class ConfigManager:
171
181
  provider_keys = {
172
182
  'openrouter': 'OPENROUTER_API_KEY',
173
183
  'bone': 'BONE_PROXY_API_KEY',
184
+ 'codex': 'CODEX_PLAN_API_KEY',
174
185
  'glm': 'GLM_API_KEY',
186
+ 'glm_plan': 'GLM_PLAN_API_KEY',
175
187
  'openai': 'OPENAI_API_KEY',
176
188
  'gemini': 'GEMINI_API_KEY',
177
189
  'minimax': 'MINIMAX_API_KEY',
190
+ 'minimax_plan': 'MINIMAX_PLAN_API_KEY',
178
191
  'anthropic': 'ANTHROPIC_API_KEY',
179
192
  'kimi': 'KIMI_API_KEY'
180
193
  }