bone-agent 1.3.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,294 @@
1
+ """Interactive first-run setup wizard.
2
+
3
+ Guides new users through provider selection, API key entry,
4
+ Obsidian vault configuration, and optional settings using
5
+ prompt_toolkit prompts and rich console output.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from prompt_toolkit import prompt as pt_prompt
12
+ from prompt_toolkit.formatted_text import FormattedText
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.text import Text
16
+
17
+ from llm import config as llm_config
18
+ from core.config_manager import ConfigManager
19
+
20
+ # Provider metadata for wizard display
21
+ WIZARD_PROVIDERS = [
22
+ ("bone", "bone-agent (free tier — no key needed)", "api"),
23
+ ("openai", "OpenAI", "api"),
24
+ ("anthropic", "Anthropic (Claude)", "api"),
25
+ ("gemini", "Google Gemini", "api"),
26
+ ("openrouter", "OpenRouter (multi-provider)", "api"),
27
+ ("glm", "GLM (Zhipu AI)", "api"),
28
+ ("kimi", "Kimi (Moonshot AI)", "api"),
29
+ ("minimax", "MiniMax", "api"),
30
+ ("local", "Local model (llama.cpp)", "local"),
31
+ ]
32
+
33
+ # API providers that need a key (skip bone free tier and local)
34
+ _API_PROVIDERS = [p for p in WIZARD_PROVIDERS if p[2] == "api" and p[0] != "bone"]
35
+
36
+
37
+ def _resolve_config_path() -> Path:
38
+ """Resolve config.yaml path (delegates to llm_config)."""
39
+ return llm_config.resolve_config_path()
40
+
41
+
42
+ def is_first_run() -> bool:
43
+ """Return True if config.yaml does not exist."""
44
+ return not _resolve_config_path().exists()
45
+
46
+
47
+ def _prompt(console: Console, message: str, default: str = "", password: bool = False) -> str:
48
+ """Prompt the user using prompt_toolkit with rich-styled message.
49
+
50
+ Falls back to simple input() if prompt_toolkit fails.
51
+ """
52
+ try:
53
+ suffix = f" [{default}]" if default else ""
54
+ if password:
55
+ return pt_prompt(
56
+ FormattedText([("class:prompt", f"{message}{suffix}: ")]),
57
+ is_password=True,
58
+ ).strip() or default
59
+ return pt_prompt(
60
+ FormattedText([("class:prompt", f"{message}{suffix}: ")]),
61
+ ).strip() or default
62
+ except (EOFError, KeyboardInterrupt):
63
+ raise
64
+ except Exception:
65
+ # Fallback for environments where prompt_toolkit misbehaves
66
+ suffix = f" [{default}]" if default else ""
67
+ if password:
68
+ import getpass
69
+ return getpass.getpass(f"{message}{suffix}: ").strip() or default
70
+ return input(f"{message}{suffix}: ").strip() or default
71
+
72
+
73
+ def _select_provider(console: Console) -> str:
74
+ """Interactive provider selection. Returns provider ID."""
75
+ console.print()
76
+ console.print(Panel(
77
+ Text.from_markup("[bold #5F9EA0]Select a provider[/bold #5F9EA0]\n\n"
78
+ "Enter the number or provider name."),
79
+ title="Step 1 of 3",
80
+ border_style="grey23",
81
+ padding=(0, 2),
82
+ title_align="left",
83
+ ))
84
+
85
+ for i, (pid, label, ptype) in enumerate(WIZARD_PROVIDERS, 1):
86
+ marker = "[dim]free[/dim]" if pid == "bone" else ""
87
+ console.print(f" [bold]{i:>2}[/bold]. {label} {marker}")
88
+
89
+ console.print()
90
+
91
+ while True:
92
+ choice = _prompt(console, "Provider", default="1")
93
+ # Try numeric lookup
94
+ try:
95
+ idx = int(choice)
96
+ if 1 <= idx <= len(WIZARD_PROVIDERS):
97
+ selected = WIZARD_PROVIDERS[idx - 1]
98
+ console.print(f" [green]Selected:[/green] {selected[1]}")
99
+ return selected[0]
100
+ except ValueError:
101
+ pass
102
+ # Try name lookup
103
+ choice_lower = choice.lower().strip()
104
+ for pid, label, ptype in WIZARD_PROVIDERS:
105
+ if choice_lower in (pid, pid.split("_")[0]):
106
+ console.print(f" [green]Selected:[/green] {label}")
107
+ return pid
108
+
109
+ console.print(f" [red]Invalid choice: '{choice}'. Enter a number or provider name.[/red]")
110
+
111
+
112
+ def _prompt_api_key(console: Console, provider_id: str) -> str:
113
+ """Prompt for API key if the provider requires one."""
114
+ # bone free tier and local don't need a key
115
+ if provider_id == "bone":
116
+ console.print("\n [dim]bone-agent free tier — no API key required.[/dim]")
117
+ return ""
118
+ if provider_id == "local":
119
+ console.print("\n [dim]Local model — no API key required.[/dim]")
120
+ return ""
121
+
122
+ provider_label = next((label for pid, label, _ in WIZARD_PROVIDERS if pid == provider_id), provider_id)
123
+ console.print()
124
+ console.print(Panel(
125
+ Text.from_markup(f"Enter your [bold]{provider_label}[/bold] API key."),
126
+ title="Step 2 of 3",
127
+ border_style="grey23",
128
+ padding=(0, 2),
129
+ title_align="left",
130
+ ))
131
+
132
+ key = _prompt(console, "API key", password=True)
133
+ if not key:
134
+ console.print(" [yellow]No key entered — you can set it later with [bold]/key[/bold].[/yellow]")
135
+ return key
136
+
137
+
138
+ def _prompt_obsidian(console: Console) -> tuple[bool, str]:
139
+ """Prompt for Obsidian vault settings. Returns (enabled, vault_path)."""
140
+ console.print()
141
+ console.print(Panel(
142
+ Text.from_markup("Enable Obsidian vault integration?\n"
143
+ "This lets bone-agent create project notes in your vault."),
144
+ title="Step 3 of 3",
145
+ border_style="grey23",
146
+ padding=(0, 2),
147
+ title_align="left",
148
+ ))
149
+
150
+ enable = _prompt(console, "Enable Obsidian? (y/n)", default="n").lower().startswith("y")
151
+
152
+ if not enable:
153
+ console.print(" [dim]Obsidian integration disabled. Enable later with [bold]/obsidian[/bold].[/dim]")
154
+ return (False, "")
155
+
156
+ while True:
157
+ vault_path = _prompt(console, "Vault path", default="~/Vault")
158
+ expanded = Path(vault_path).expanduser().resolve()
159
+
160
+ if expanded.is_dir():
161
+ # Check for .obsidian folder as validation
162
+ obsidian_marker = expanded / ".obsidian"
163
+ if obsidian_marker.is_dir():
164
+ console.print(f" [green]Valid Obsidian vault detected.[/green]")
165
+ else:
166
+ console.print(f" [yellow]Directory exists but no .obsidian/ folder found.[/yellow]")
167
+ console.print(f" [yellow]Make sure this is your Obsidian vault root.[/yellow]")
168
+ else:
169
+ console.print(f" [yellow]Directory does not exist yet: {expanded}[/yellow]")
170
+ console.print(f" [yellow]It will be created when needed.[/yellow]")
171
+
172
+ confirm = _prompt(console, "Confirm this path? (y/n)", default="y").lower().startswith("y")
173
+ if confirm:
174
+ return (True, str(expanded))
175
+
176
+ retry = _prompt(console, "Try another path? (y/n)", default="y").lower().startswith("y")
177
+ if not retry:
178
+ return (False, "")
179
+
180
+
181
+ def write_config(provider_id: str, api_key: str = "",
182
+ obsidian_enabled: bool = False, obsidian_path: str = "") -> Path:
183
+ """Generate and write config.yaml from wizard responses.
184
+
185
+ Uses generate_config_template() for the base, then overlays
186
+ user selections.
187
+
188
+ Returns:
189
+ Path to the written config file.
190
+ """
191
+ config_data = llm_config.generate_config_template()
192
+
193
+ # Set the chosen provider
194
+ config_data["LAST_PROVIDER"] = provider_id
195
+
196
+ # Set API key if provided
197
+ if api_key:
198
+ # Map provider ID to its API key config field
199
+ key_map = {
200
+ "openrouter": "OPENROUTER_API_KEY",
201
+ "glm": "GLM_API_KEY",
202
+ "openai": "OPENAI_API_KEY",
203
+ "gemini": "GEMINI_API_KEY",
204
+ "minimax": "MINIMAX_API_KEY",
205
+ "anthropic": "ANTHROPIC_API_KEY",
206
+ "kimi": "KIMI_API_KEY",
207
+ "bone": "BONE_PROXY_API_KEY",
208
+ }
209
+ config_key = key_map.get(provider_id)
210
+ if config_key:
211
+ config_data[config_key] = api_key
212
+
213
+ # Set Obsidian settings
214
+ config_data["OBSIDIAN_SETTINGS"] = {
215
+ "enabled": obsidian_enabled,
216
+ "vault_path": obsidian_path,
217
+ "exclude_folders": ".obsidian,.trash,node_modules,.git,__pycache__",
218
+ "project_base": "Dev",
219
+ }
220
+
221
+ config_path = _resolve_config_path()
222
+ config_path.parent.mkdir(parents=True, exist_ok=True)
223
+
224
+ import yaml
225
+ with open(config_path, "w", encoding="utf-8-sig") as f:
226
+ yaml.dump(config_data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
227
+
228
+ return config_path
229
+
230
+
231
+ def run_wizard(console: Optional[Console] = None) -> bool:
232
+ """Run the interactive setup wizard.
233
+
234
+ Args:
235
+ console: Rich console instance. Uses a new one if None.
236
+
237
+ Returns:
238
+ True if wizard completed successfully, False if user aborted.
239
+ """
240
+ if console is None:
241
+ console = Console()
242
+
243
+ console.print()
244
+ console.print(Panel(
245
+ Text.from_markup(
246
+ "[bold #5F9EA0]Welcome to bone-agent![/bold #5F9EA0]\n\n"
247
+ "Let's get you set up. This will take about a minute.\n"
248
+ "You can always change settings later with [bold]/config[/bold]."
249
+ ),
250
+ border_style="grey23",
251
+ padding=(0, 2),
252
+ title_align="left",
253
+ ))
254
+
255
+ try:
256
+ # Step 1: Provider
257
+ provider_id = _select_provider(console)
258
+
259
+ # Step 2: API key
260
+ api_key = _prompt_api_key(console, provider_id)
261
+
262
+ # Step 3: Obsidian
263
+ obsidian_enabled, obsidian_path = _prompt_obsidian(console)
264
+
265
+ # Write config
266
+ config_path = write_config(
267
+ provider_id=provider_id,
268
+ api_key=api_key,
269
+ obsidian_enabled=obsidian_enabled,
270
+ obsidian_path=obsidian_path,
271
+ )
272
+
273
+ console.print()
274
+ console.print(Panel(
275
+ Text.from_markup(
276
+ f"[bold green]Setup complete![/bold green]\n\n"
277
+ f"Config written to: [dim]{config_path}[/dim]\n"
278
+ f"Provider: [bold]{provider_id}[/bold]\n"
279
+ f"Obsidian: {'enabled' if obsidian_enabled else 'disabled'}\n\n"
280
+ f"[dim]Tip: Use [bold]/provider[/bold] to switch providers,[/dim]\n"
281
+ f"[dim] [bold]/key[/bold] to update API keys,[/dim]\n"
282
+ f"[dim] [bold]/obsidian[/bold] to reconfigure vault.[/dim]"
283
+ ),
284
+ border_style="grey23",
285
+ padding=(0, 2),
286
+ title_align="left",
287
+ ))
288
+ console.print()
289
+
290
+ return True
291
+
292
+ except (KeyboardInterrupt, EOFError):
293
+ console.print("\n[yellow]Setup cancelled.[/yellow]")
294
+ return False
@@ -0,0 +1,234 @@
1
+ """Live panel for streaming sub-agent tool output."""
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+
7
+ from rich.panel import Panel
8
+ from rich.text import Text
9
+ from rich.live import Live
10
+
11
+ from core.tool_feedback import build_panel_tool_message
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SubAgentPanel:
17
+ """Live panel for streaming sub-agent tool output.
18
+
19
+ Displays a Rich panel with animated spinner, tool call log, and
20
+ completion/error status for sub-agent invocations.
21
+ """
22
+
23
+ _SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
24
+
25
+ def __init__(self, query, console):
26
+ """Initialize the sub-agent panel.
27
+
28
+ Args:
29
+ query: The task query for the sub-agent
30
+ console: Rich console for display
31
+ """
32
+ self.console = console
33
+ self.query = query
34
+ self.tool_calls = [] # List of formatted Rich markup strings
35
+ self.total_tool_calls = 0
36
+ self.token_info = None # Live token info string, e.g. "32k / 45k"
37
+ self._live = None
38
+ self._spinner_index = 0
39
+ self._show_spinner = True
40
+ self._spinner_thread = None
41
+ self._stop_spinner = threading.Event()
42
+ self._saved_termios = None
43
+
44
+ # ------------------------------------------------------------------
45
+ # Panel rendering
46
+ # ------------------------------------------------------------------
47
+
48
+ def _get_title(self):
49
+ """Get panel title with optional spinner and tool call counter."""
50
+ token_suffix = f" | {self.token_info}" if self.token_info else ""
51
+ if self._show_spinner:
52
+ spinner = self._SPINNER_FRAMES[self._spinner_index % len(self._SPINNER_FRAMES)]
53
+ return f"[#5F9EA0]{spinner} Sub-Agent ({self.total_tool_calls}){token_suffix}[/#5F9EA0]"
54
+ return f"[#5F9EA0]Sub-Agent ({self.total_tool_calls}){token_suffix}[/#5F9EA0]"
55
+
56
+ def _render_panel(self, title=None, border_style="#5F9EA0"):
57
+ """Render the current panel state.
58
+
59
+ Args:
60
+ title: Optional title override. If None, uses _get_title().
61
+ border_style: Border style (default: "#5F9EA0")
62
+
63
+ Returns:
64
+ Rich Panel object with current content and title
65
+ """
66
+ lines = [f"[bold #5F9EA0]Query:[/bold #5F9EA0] {self.query}", ""]
67
+
68
+ if self.tool_calls:
69
+ content = "\n".join(self.tool_calls)
70
+ lines.append(content)
71
+ else:
72
+ lines.append("[dim]No tools called yet[/dim]")
73
+
74
+ content = "\n".join(lines)
75
+ return Panel(
76
+ Text.from_markup(content, justify="left"),
77
+ title=title if title is not None else self._get_title(),
78
+ title_align="left",
79
+ border_style=border_style,
80
+ padding=(0, 1),
81
+ )
82
+
83
+ # ------------------------------------------------------------------
84
+ # Spinner animation
85
+ # ------------------------------------------------------------------
86
+
87
+ def _spin(self):
88
+ """Background thread: continuously increment spinner and update display."""
89
+ while not self._stop_spinner.is_set():
90
+ self._spinner_index += 1
91
+ if self._live:
92
+ self._live.update(self._render_panel())
93
+ time.sleep(0.1) # 10 updates per second = smooth animation
94
+
95
+ # ------------------------------------------------------------------
96
+ # Terminal raw mode (suppress keystroke echoes during spinner)
97
+ # ------------------------------------------------------------------
98
+
99
+ @staticmethod
100
+ def _set_raw_mode():
101
+ """Switch stdin to raw mode to prevent keystroke echoes during spinner."""
102
+ import os
103
+ import sys
104
+ if os.name == 'nt':
105
+ return
106
+ try:
107
+ import termios
108
+ fd = sys.stdin.fileno()
109
+ old = termios.tcgetattr(fd)
110
+ new = old.copy()
111
+ new[3] &= ~(termios.ECHO | termios.ICANON | termios.IEXTEN)
112
+ new[0] &= ~(termios.ICRNL)
113
+ termios.tcsetattr(fd, termios.TCSANOW, new)
114
+ return old
115
+ except Exception:
116
+ return None
117
+
118
+ @staticmethod
119
+ def _restore_terminal_mode(saved):
120
+ """Restore terminal mode from saved termios attributes."""
121
+ import os
122
+ import sys
123
+ if saved is None:
124
+ return
125
+ try:
126
+ import os as _os
127
+ if _os.name == 'nt':
128
+ return
129
+ except Exception:
130
+ pass
131
+ try:
132
+ import termios
133
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, saved)
134
+ except Exception:
135
+ pass
136
+
137
+ # ------------------------------------------------------------------
138
+ # Context manager (Live display lifecycle)
139
+ # ------------------------------------------------------------------
140
+
141
+ def __enter__(self):
142
+ """Start Live display context.
143
+
144
+ Returns:
145
+ self for use in with statement
146
+ """
147
+ self._saved_termios = self._set_raw_mode()
148
+ panel = self._render_panel()
149
+ self._live = Live(panel, console=self.console, refresh_per_second=10)
150
+ self._live.__enter__()
151
+
152
+ # Start background spinner thread
153
+ self._spinner_thread = threading.Thread(target=self._spin, daemon=True)
154
+ self._spinner_thread.start()
155
+
156
+ return self
157
+
158
+ def __exit__(self, *args):
159
+ """End Live display context."""
160
+ self._stop_spinner.set()
161
+ if self._spinner_thread:
162
+ self._spinner_thread.join(timeout=0.5)
163
+ if self._live:
164
+ self._live.__exit__(*args)
165
+ self._restore_terminal_mode(self._saved_termios)
166
+ self._saved_termios = None
167
+
168
+ # ------------------------------------------------------------------
169
+ # Public API
170
+ # ------------------------------------------------------------------
171
+
172
+ def add_tool_call(self, tool_name, tool_result=None, command=None):
173
+ """Add a tool call message to the panel and refresh display.
174
+
175
+ Delegates formatting to core.tool_feedback.build_panel_tool_message
176
+ to avoid duplicating display logic.
177
+
178
+ Args:
179
+ tool_name: Name of the tool (e.g., "read_file", "rg")
180
+ tool_result: Optional tool result string (for detailed formatting)
181
+ command: Optional command string for context
182
+ """
183
+ self.total_tool_calls += 1
184
+ message = build_panel_tool_message(tool_name, tool_result, command)
185
+ self.tool_calls.append(message)
186
+ # Keep only last 5 tool calls
187
+ if len(self.tool_calls) > 5:
188
+ self.tool_calls.pop(0)
189
+ self._live.update(self._render_panel())
190
+
191
+ def append(self, text):
192
+ """Append text to panel and refresh display (kept for compatibility).
193
+
194
+ Args:
195
+ text: Text to append (may contain Rich markup)
196
+ """
197
+ # Just update panel to refresh title counter
198
+ self._live.update(self._render_panel())
199
+
200
+ def set_complete(self, usage=None):
201
+ """Mark panel as complete with optional token info.
202
+
203
+ Args:
204
+ usage: Optional dict with 'prompt', 'completion', 'total' token counts
205
+ """
206
+ self._show_spinner = False # Stop spinner
207
+
208
+ # Build title with token usage: conversation length first, total billed second
209
+ if usage and usage.get('total_tokens'):
210
+ ctx_tokens = usage.get('context_tokens', 0)
211
+ total_tokens = usage.get('total_tokens', 0)
212
+ title = f"[green]✓ Sub-Agent Complete ({self.total_tool_calls}) - {ctx_tokens:,} curr, {total_tokens:,} total[/green]"
213
+ else:
214
+ title = f"[green]✓ Sub-Agent Complete ({self.total_tool_calls})[/green]"
215
+
216
+ self._live.update(self._render_panel(
217
+ title=title,
218
+ border_style="green"
219
+ ))
220
+
221
+ def set_error(self, message):
222
+ """Show error in panel with red styling.
223
+
224
+ Args:
225
+ message: Error message to display
226
+ """
227
+ self._show_spinner = False # Stop spinner
228
+ self._live.update(Panel(
229
+ message,
230
+ title="[red]✗ Sub-Agent Error[/red]",
231
+ title_align="left",
232
+ border_style="red",
233
+ padding=(0, 1),
234
+ ))