bone-agent 1.3.3 → 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 (43) hide show
  1. package/README.md +17 -0
  2. package/config.yaml.example +5 -2
  3. package/package.json +1 -1
  4. package/prompts/main/communication_style.md +1 -1
  5. package/prompts/main/dream.md +23 -9
  6. package/prompts/main/skills.md +3 -0
  7. package/prompts/micro/communication_style.md +1 -1
  8. package/prompts/micro/skills.md +1 -0
  9. package/src/core/agentic.py +138 -38
  10. package/src/core/chat_manager.py +19 -6
  11. package/src/core/config_manager.py +8 -1
  12. package/src/core/cron.py +0 -4
  13. package/src/core/metadata.py +75 -0
  14. package/src/core/skills.py +463 -0
  15. package/src/core/sub_agent.py +93 -43
  16. package/src/core/tool_feedback.py +87 -76
  17. package/src/llm/client.py +7 -2
  18. package/src/llm/codex_provider.py +350 -0
  19. package/src/llm/config.py +46 -2
  20. package/src/llm/prompts.py +12 -7
  21. package/src/llm/providers.py +3 -1
  22. package/src/llm/token_tracker.py +15 -0
  23. package/src/tools/__init__.py +24 -85
  24. package/src/tools/create_file.py +1 -1
  25. package/src/tools/directory.py +1 -1
  26. package/src/tools/edit.py +5 -1
  27. package/src/tools/file_reader.py +1 -1
  28. package/src/tools/helpers/__init__.py +1 -7
  29. package/src/tools/helpers/base.py +65 -16
  30. package/src/tools/helpers/loader.py +2 -88
  31. package/src/tools/helpers/path_resolver.py +54 -3
  32. package/src/tools/helpers/plugin_manifest.py +99 -70
  33. package/src/tools/review_sub_agent.py +2 -1
  34. package/src/tools/rg_search.py +24 -7
  35. package/src/tools/search_plugins.py +140 -72
  36. package/src/tools/shell.py +3 -3
  37. package/src/ui/commands.py +355 -33
  38. package/src/ui/displays.py +26 -1
  39. package/src/ui/main.py +0 -4
  40. package/src/ui/tool_confirmation.py +16 -5
  41. package/src/utils/editor.py +88 -39
  42. package/src/utils/settings.py +6 -2
  43. package/src/utils/validation.py +10 -0
@@ -1,6 +1,7 @@
1
1
  """Interactive tool confirmation panel with arrow key navigation."""
2
2
 
3
3
  import asyncio
4
+ from html import escape
4
5
  from threading import Timer
5
6
  from typing import Optional, Tuple
6
7
  from prompt_toolkit import HTML
@@ -50,6 +51,16 @@ class ToolConfirmationPanel:
50
51
  # Use appropriate options based on tool type
51
52
  self._options = self.EDIT_OPTIONS if is_edit_tool else self.STANDARD_OPTIONS
52
53
 
54
+ def _append_field(self, lines: list[str], label: str, value: object, *, formatted: bool = False) -> None:
55
+ """Append a field to the panel, escaping untrusted values by default."""
56
+ value_text = str(value)
57
+ if not formatted:
58
+ value_text = escape(value_text)
59
+ value_lines = value_text.splitlines() or [""]
60
+ lines.append(f"<b>{escape(label)}:</b> {value_lines[0]}")
61
+ for continuation in value_lines[1:]:
62
+ lines.append(f" {continuation}")
63
+
53
64
  def _get_display_text(self) -> HTML:
54
65
  """Get the formatted text to display.
55
66
 
@@ -66,14 +77,14 @@ class ToolConfirmationPanel:
66
77
 
67
78
  lines.append("<b>Selection Summary</b>")
68
79
  lines.append("")
69
- lines.append(f"<b>Tool:</b> {self.tool_command}")
70
- lines.append(f'<style fg="gray"> Selected: {str(selected_text)}</style>')
80
+ self._append_field(lines, "Tool", self.tool_command)
81
+ lines.append(f'<style fg="gray"> Selected: {escape(str(selected_text))}</style>')
71
82
  lines.append("")
72
83
  else:
73
84
  # Tool information
74
- lines.append(f"<b>Tool:</b> {self.tool_command}")
85
+ self._append_field(lines, "Tool", self.tool_command)
75
86
  if self.reason:
76
- lines.append(f"<b>Reason:</b> {self.reason}")
87
+ self._append_field(lines, "Reason", self.reason)
77
88
  lines.append("")
78
89
 
79
90
  # Render options
@@ -87,7 +98,7 @@ class ToolConfirmationPanel:
87
98
  # Unselected option - dark grey
88
99
  lines.append(f'<style fg="gray"> {text}</style>')
89
100
 
90
- return HTML("\n".join(lines))
101
+ return HTML("\n".join(lines))
91
102
 
92
103
  def _exit_with_summary(self, event, result: str) -> None:
93
104
  """Show summary screen and exit application after delay.
@@ -1,6 +1,7 @@
1
1
  """External editor integration for bone-agent."""
2
2
  import os
3
3
  import platform
4
+ import shlex
4
5
  import subprocess
5
6
  import tempfile
6
7
  import shutil
@@ -12,27 +13,30 @@ def get_editor() -> str:
12
13
  """Get editor command with OS-specific defaults.
13
14
 
14
15
  Priority:
15
- 1. EDITOR environment variable
16
- 2. OS defaults: notepad.exe (Windows) | nano/vi/vim (Unix)
16
+ 1. Windows: notepad.exe
17
+ 2. Linux: nvim, then EDITOR, then nano/vi/vim
18
+ 3. Other Unix: EDITOR, then nano/vi/vim
17
19
 
18
20
  Returns:
19
21
  str: Editor command or path
20
22
  """
21
- # 1. Check environment variable
23
+ system = platform.system()
24
+
25
+ if system == "Windows":
26
+ return "notepad.exe"
27
+
28
+ if system == "Linux" and shutil.which("nvim"):
29
+ return "nvim"
30
+
22
31
  editor = os.environ.get("EDITOR")
23
32
  if editor and editor.strip():
24
33
  return editor.strip()
25
34
 
26
- # 2. OS-specific defaults
27
- if platform.system() == "Windows":
28
- return "notepad.exe"
29
- else:
30
- # Try to find common editors
31
- for cmd in ["nvim", "nano", "vi", "vim"]:
32
- if shutil.which(cmd):
33
- return cmd
34
- # Fallback to nano even if not found (will error later)
35
- return "nano"
35
+ for cmd in ["nano", "vi", "vim"]:
36
+ if shutil.which(cmd):
37
+ return cmd
38
+
39
+ return "nano"
36
40
 
37
41
 
38
42
  def _create_temp_file() -> Tuple[Path, object]:
@@ -74,24 +78,38 @@ def _strip_comment_lines(content: str) -> str:
74
78
  return '\n'.join(lines).strip()
75
79
 
76
80
 
77
- def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Optional[str]]:
78
- """Open external editor and return user input.
81
+ def _build_editor_command(editor_cmd: str, temp_path: Path) -> Tuple[bool, str | list[str]]:
82
+ """Build a subprocess command for launching the configured editor."""
83
+ use_shell = platform.system() == "Windows"
84
+ if use_shell:
85
+ return use_shell, f'{editor_cmd} "{temp_path}"'
79
86
 
80
- Args:
81
- console: Rich console for output
82
- debug_mode: Whether to show debug information
87
+ try:
88
+ command_parts = shlex.split(editor_cmd)
89
+ except ValueError as e:
90
+ raise ValueError(f"Invalid EDITOR command: {e}") from e
83
91
 
84
- Returns:
85
- tuple: (success: bool, content: str or None)
86
- - (True, content) if successful
87
- - (False, None) if failed or cancelled
88
- """
89
- editor_cmd = get_editor()
92
+ if not command_parts:
93
+ raise FileNotFoundError(editor_cmd)
94
+
95
+ return use_shell, [*command_parts, str(temp_path)]
96
+
97
+
98
+ def _open_editor_with_temp_file(
99
+ console,
100
+ editor_cmd: str,
101
+ debug_mode: bool = False,
102
+ initial_content: str = "",
103
+ post_process=None,
104
+ ) -> Tuple[bool, Optional[str]]:
105
+ """Open the configured editor for a temporary file and return the saved content."""
90
106
  temp_path = None
91
107
 
92
108
  try:
93
- # Create temporary file
94
109
  temp_path, temp_fd = _create_temp_file()
110
+ if initial_content:
111
+ temp_fd.write(initial_content)
112
+ temp_fd.flush()
95
113
  temp_fd.close()
96
114
 
97
115
  if debug_mode:
@@ -101,24 +119,19 @@ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Opti
101
119
  console.print("[#5F9EA0]Opening editor...[/#5F9EA0]")
102
120
  console.print("[dim]Save and close the editor when done[/dim]")
103
121
 
104
- # Launch editor and wait for it to close
105
- # Use shell=True on Windows for notepad, False for better security on Unix
106
- use_shell = platform.system() == "Windows"
107
-
122
+ use_shell, command = _build_editor_command(editor_cmd, temp_path)
108
123
  result = subprocess.run(
109
- [editor_cmd, str(temp_path)],
124
+ command,
110
125
  shell=use_shell,
111
- check=False # Don't raise on non-zero exit
126
+ check=False,
112
127
  )
113
128
 
114
129
  if result.returncode != 0 and debug_mode:
115
130
  console.print(f"[yellow]Editor exited with code {result.returncode}[/yellow]")
116
131
 
117
- # Read content from temp file
118
- content = temp_path.read_text(encoding='utf-8')
119
-
120
- # Strip comment lines
121
- content = _strip_comment_lines(content)
132
+ content = temp_path.read_text(encoding="utf-8")
133
+ if post_process is not None:
134
+ content = post_process(content)
122
135
 
123
136
  if debug_mode:
124
137
  console.print(f"[dim]Read {len(content)} characters[/dim]")
@@ -145,14 +158,50 @@ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Opti
145
158
  return (False, None)
146
159
 
147
160
  finally:
148
- # Cleanup temp file
149
161
  if temp_path and temp_path.exists():
150
162
  try:
151
163
  temp_path.unlink()
152
164
  if debug_mode:
153
- console.print(f"[dim]Cleaned up temp file[/dim]")
165
+ console.print("[dim]Cleaned up temp file[/dim]")
154
166
  except Exception as e:
155
- # Log but don't crash on cleanup failure
156
167
  console.print(f"[yellow]Warning: Failed to delete temp file: {e}[/yellow]")
157
168
  if debug_mode:
158
169
  console.print(f"[dim]Temp file may remain at: {temp_path}[/dim]")
170
+
171
+
172
+ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Optional[str]]:
173
+ """Open external editor and return user input.
174
+
175
+ Args:
176
+ console: Rich console for output
177
+ debug_mode: Whether to show debug information
178
+
179
+ Returns:
180
+ tuple: (success: bool, content: str or None)
181
+ - (True, content) if successful
182
+ - (False, None) if failed or cancelled
183
+ """
184
+ return _open_editor_with_temp_file(
185
+ console,
186
+ editor_cmd=get_editor(),
187
+ debug_mode=debug_mode,
188
+ post_process=_strip_comment_lines,
189
+ )
190
+
191
+
192
+ def open_editor_for_content(
193
+ console,
194
+ initial_content: str = "",
195
+ debug_mode: bool = False,
196
+ ) -> Tuple[bool, Optional[str]]:
197
+ """Open external editor with initial content and return the saved file.
198
+
199
+ Unlike open_editor_for_input, this preserves markdown comment lines so it can
200
+ edit existing markdown files without dropping headings or notes.
201
+ """
202
+ return _open_editor_with_temp_file(
203
+ console,
204
+ editor_cmd=get_editor(),
205
+ debug_mode=debug_mode,
206
+ initial_content=initial_content or "",
207
+ )
@@ -51,6 +51,7 @@ class ToolSettings:
51
51
  max_shell_output_lines: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_shell_output_lines", 200))
52
52
  max_file_preview_lines: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_file_preview_lines", 200))
53
53
  disabled_tools: list = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("disabled_tools", []))
54
+ hidden_skills: list = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("hidden_skills", []))
54
55
 
55
56
  @dataclass
56
57
  class FileSettings:
@@ -80,11 +81,14 @@ class ToolCompactionSettings:
80
81
  @dataclass
81
82
  class SubAgentSettings:
82
83
  """Sub-agent token limits and behavior configuration."""
83
- soft_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("soft_limit_tokens", 300_000))
84
- hard_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("hard_limit_tokens", 500_000))
84
+ soft_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("soft_limit_tokens", 100_000))
85
+ hard_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("hard_limit_tokens", 150_000))
86
+ billed_warning_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("billed_warning_tokens", 200_000))
87
+ billed_hard_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("billed_hard_limit_tokens", 500_000))
85
88
  enable_compaction: bool = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("enable_compaction", True))
86
89
  compact_trigger_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("compact_trigger_tokens", 50_000))
87
90
  allowed_tools: list = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("allowed_tools", ["rg", "read_file", "list_directory", "web_search"]))
91
+ allow_active_plugins: bool = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("allow_active_plugins", False))
88
92
  dump_context_on_hard_limit: bool = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("dump_context_on_hard_limit", True))
89
93
 
90
94
 
@@ -167,6 +167,16 @@ def check_command(command):
167
167
  if command.lower().startswith("powershell"):
168
168
  return False, "nested powershell invocation"
169
169
 
170
+ # Multi-line shell scripts/heredocs are valid command payloads.
171
+ # Do not tokenize the full script here: tokenization can reject valid shell
172
+ # syntax before the shell sees it. Safety/approval checks happen upstream.
173
+ if "\n" in command:
174
+ for line in command.splitlines():
175
+ line = line.strip()
176
+ if line and line.lower().startswith("powershell"):
177
+ return False, "nested powershell invocation"
178
+ return True, None
179
+
170
180
  # Basic validation - ensure command has content
171
181
  tokens = _tokenize_segment(command)
172
182
  if not tokens: