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,269 @@
1
+ """Task list management tools.
2
+
3
+ These tools provide in-session task tracking for long EDIT workflows.
4
+ """
5
+
6
+ import textwrap
7
+ from pathlib import Path
8
+ from typing import Optional, List
9
+
10
+ from .helpers.base import tool
11
+ from .helpers.converters import coerce_int
12
+ from . import constants
13
+
14
+
15
+ def _escape_rich(text):
16
+ """Escape square brackets in text so Rich renders them literally."""
17
+ return text.replace("[", "\\[").replace("]", "\\]")
18
+ def _strip_rich_markup(text):
19
+ """Remove Rich console markup tags from text for plain-text comparison.
20
+
21
+ Handles [tag]...[/tag], [/tag], and standalone [tag] forms.
22
+ Also un-escapes literal bracket sequences (\\[ \\]).
23
+ """
24
+ import re
25
+ # Un-escape literal brackets first
26
+ text = text.replace("\\[", "[").replace("\\]", "]")
27
+ # Remove [/tag] closing tags
28
+ text = re.sub(r'\[/\w+\]', '', text)
29
+ # Remove [tag] opening tags (but not [x], [ ], or [N] patterns)
30
+ text = re.sub(r'\[(?!x\]|\s?\]|\d+\])/?\w+\]', '', text)
31
+ return text
32
+
33
+
34
+ def _format_task_list(task_list, title=None):
35
+ """Format task list for display with Rich markup.
36
+
37
+ Args:
38
+ task_list: List of task dicts with 'description' and 'completed' keys
39
+ title: Optional title for the task list
40
+
41
+ Returns:
42
+ Formatted task list string with Rich markup
43
+ """
44
+ if not task_list:
45
+ return "exit_code=1\nerror: No task list exists. Use create_task_list first.\n\n"
46
+
47
+ safe_title = (title or "").strip() if isinstance(title, str) else ""
48
+ safe_title = safe_title[:constants.MAX_TASK_TITLE_LEN] if safe_title else "untitled"
49
+
50
+ done_count = sum(1 for t in task_list if t.get("completed"))
51
+ total = len(task_list)
52
+ all_done = done_count == total
53
+
54
+ # Escape user-provided text to prevent Rich markup injection
55
+ escaped_title = _escape_rich(safe_title)
56
+
57
+ # Header with progress
58
+ if all_done:
59
+ header = f"[bold green]\u2713[/bold green] [bold]{escaped_title}[/bold] [green]({done_count}/{total} done)[/green]"
60
+ else:
61
+ header = f"[bold]{escaped_title}[/bold] [dim]({done_count}/{total} done)[/dim]"
62
+
63
+ lines = [header]
64
+
65
+ # Indent for task lines: 2 spaces + bullet + space = 4 visible chars before description
66
+ TASK_INDENT = " "
67
+ BULLET_DONE = "[dim green]\u2713[/dim green]"
68
+ BULLET_PENDING = "[dim white]\u25cb[/dim white]"
69
+ # Visible width of bullet prefix: " ✓ " = 4 chars
70
+ # Continuation indent must match for alignment
71
+ DESC_INDENT = " " # 4 spaces, aligns with description after bullet
72
+ TASK_WRAP_WIDTH = 60 # Reasonable width for wrapped task descriptions
73
+
74
+ for i, task in enumerate(task_list):
75
+ is_done = bool(task.get("completed"))
76
+ desc = str(task.get("description", ""))
77
+ if len(desc) > constants.MAX_TASK_LEN:
78
+ desc = desc[:constants.MAX_TASK_LEN - 3] + "..."
79
+ escaped_desc = _escape_rich(desc)
80
+
81
+ if is_done:
82
+ bullet = BULLET_DONE
83
+ # Wrap description text only (no bullet), apply markup separately
84
+ desc_lines = textwrap.wrap(escaped_desc, width=TASK_WRAP_WIDTH - 4, break_long_words=True, break_on_hyphens=True)
85
+ if not desc_lines:
86
+ desc_lines = [escaped_desc]
87
+ # First line: bullet + struck-through description
88
+ first_line = f"{TASK_INDENT}{bullet} [dim strike]{desc_lines[0]}[/dim strike]"
89
+ lines.append(first_line)
90
+ # Continuation lines: indent + struck-through text (no bullet)
91
+ for dline in desc_lines[1:]:
92
+ lines.append(f"{DESC_INDENT}[dim strike]{dline}[/dim strike]")
93
+ else:
94
+ bullet = BULLET_PENDING
95
+ # Wrap long pending descriptions with proper indentation
96
+ desc_lines = textwrap.wrap(escaped_desc, width=TASK_WRAP_WIDTH - 4, break_long_words=True, break_on_hyphens=True)
97
+ if not desc_lines:
98
+ desc_lines = [escaped_desc]
99
+ first_line = f"{TASK_INDENT}{bullet} {desc_lines[0]}"
100
+ lines.append(first_line)
101
+ for dline in desc_lines[1:]:
102
+ lines.append(f"{DESC_INDENT}{dline}")
103
+
104
+ return "\n".join(lines) + "\n\n"
105
+
106
+
107
+ @tool(
108
+ name="create_task_list",
109
+ description="Create or replace an in-session task list for tracking long edit workflows.",
110
+ parameters={
111
+ "type": "object",
112
+ "properties": {
113
+ "tasks": {
114
+ "type": "array",
115
+ "items": {"type": "string"},
116
+ "description": "Task descriptions (non-empty after trimming)"
117
+ },
118
+ "title": {
119
+ "type": "string",
120
+ "description": "Short title summarizing the workflow (e.g. 'Add pagination to user API'). Always provide a meaningful title."
121
+ }
122
+ },
123
+ "required": ["tasks", "title"]
124
+ },
125
+ requires_approval=False
126
+ )
127
+ def create_task_list(
128
+ tasks: List[str],
129
+ chat_manager,
130
+ title: str,
131
+ ) -> str:
132
+ """Create or replace an in-session task list.
133
+
134
+ Args:
135
+ tasks: List of task descriptions
136
+ chat_manager: ChatManager instance (injected by context)
137
+ title: Title for the task list
138
+
139
+ Returns:
140
+ Formatted task list result
141
+ """
142
+ # Validate title
143
+ if not isinstance(title, str):
144
+ return "exit_code=1\nerror: 'title' must be a string.\n\n"
145
+ title = title.strip()
146
+ if not title:
147
+ return "exit_code=1\nerror: 'title' must be non-empty.\n\n"
148
+ title = title[:constants.MAX_TASK_TITLE_LEN]
149
+
150
+ # Normalize tasks
151
+ normalized = []
152
+ for i, task in enumerate(tasks):
153
+ if not isinstance(task, str):
154
+ return f"exit_code=1\nerror: Task at index {i} must be a string.\n\n"
155
+ trimmed = task.strip()
156
+ if not trimmed:
157
+ return f"exit_code=1\nerror: Task at index {i} must be non-empty.\n\n"
158
+ if len(trimmed) > constants.MAX_TASK_LEN:
159
+ return (
160
+ f"exit_code=1\nerror: Task at index {i} exceeds MAX_TASK_LEN={constants.MAX_TASK_LEN}.\n\n"
161
+ )
162
+ normalized.append(trimmed)
163
+
164
+ if len(normalized) == 0:
165
+ return "exit_code=1\nerror: Provide at least one non-empty task.\n\n"
166
+ if len(normalized) > constants.MAX_TASKS:
167
+ return f"exit_code=1\nerror: Too many tasks (max {constants.MAX_TASKS}).\n\n"
168
+
169
+ # Set task list on chat_manager
170
+ chat_manager.task_list = [
171
+ {"description": t, "completed": False}
172
+ for t in normalized
173
+ ]
174
+ chat_manager.task_list_title = title or None
175
+
176
+ return _format_task_list(chat_manager.task_list, chat_manager.task_list_title)
177
+
178
+
179
+ @tool(
180
+ name="complete_task",
181
+ description="Mark one or more tasks complete in the current task list.",
182
+ parameters={
183
+ "type": "object",
184
+ "properties": {
185
+ "task_id": {
186
+ "type": "integer",
187
+ "description": "Zero-based index of a single task to complete"
188
+ },
189
+ "task_ids": {
190
+ "type": "array",
191
+ "items": {"type": "integer"},
192
+ "description": "Zero-based task indices to complete"
193
+ }
194
+ }
195
+ },
196
+ requires_approval=False
197
+ )
198
+ def complete_task(
199
+ chat_manager,
200
+ task_id: Optional[int] = None,
201
+ task_ids: Optional[List[int]] = None
202
+ ) -> str:
203
+ """Mark one or more tasks as complete.
204
+
205
+ Args:
206
+ chat_manager: ChatManager instance (injected by context)
207
+ task_id: Single task index to mark complete
208
+ task_ids: Multiple task indices to mark complete
209
+
210
+ Returns:
211
+ Formatted task list result
212
+ """
213
+ # Normalize to list: prefer task_ids if both provided
214
+ if task_ids is not None:
215
+ ids_raw = task_ids
216
+ elif task_id is not None:
217
+ ids_raw = [task_id]
218
+ else:
219
+ return "exit_code=1\nerror: Either 'task_id' or 'task_ids' must be provided.\n\n"
220
+
221
+ if not isinstance(ids_raw, list):
222
+ return "exit_code=1\nerror: IDs must be an array of integers.\n\n"
223
+
224
+ task_list = getattr(chat_manager, "task_list", None) or []
225
+ if not task_list:
226
+ return "exit_code=1\nerror: No task list exists. Use create_task_list first.\n\n"
227
+
228
+ # Validate all IDs
229
+ valid_ids = []
230
+ for i, tid in enumerate(ids_raw):
231
+ tid_int, error = coerce_int(tid)
232
+ if error:
233
+ return f"exit_code=1\nerror: ID at index {i}: {error}\n\n"
234
+ if tid_int < 0:
235
+ return f"exit_code=1\nerror: ID at index {i} must be non-negative.\n\n"
236
+ if tid_int >= len(task_list):
237
+ return (
238
+ f"exit_code=1\nerror: ID {tid_int} (index {i}) is out of range (0-{len(task_list) - 1}).\n\n"
239
+ )
240
+ valid_ids.append(tid_int)
241
+
242
+ # Mark tasks as complete
243
+ for tid in valid_ids:
244
+ task_list[tid]["completed"] = True
245
+
246
+ return _format_task_list(task_list, chat_manager.task_list_title)
247
+
248
+
249
+ @tool(
250
+ name="show_task_list",
251
+ description="Show the current task list without modifying it.",
252
+ parameters={"type": "object", "properties": {}},
253
+ requires_approval=False
254
+ )
255
+ def show_task_list(
256
+ chat_manager
257
+ ) -> str:
258
+ """Display the current task list.
259
+
260
+ Args:
261
+ chat_manager: ChatManager instance (injected by context)
262
+
263
+ Returns:
264
+ Formatted task list result
265
+ """
266
+ task_list = getattr(chat_manager, "task_list", None) or []
267
+ title = getattr(chat_manager, "task_list_title", None)
268
+
269
+ return _format_task_list(task_list, title)
@@ -0,0 +1,61 @@
1
+ """Web search tool using DuckDuckGo."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from .helpers.base import tool
7
+ from utils.web_search import run_web_search
8
+ from exceptions import LLMConnectionError
9
+
10
+
11
+ @tool(
12
+ name="web_search",
13
+ description="Search web for info, docs, and current events using DuckDuckGo (no API key needed). Automatically fetches and extracts full article content from top results.",
14
+ parameters={
15
+ "type": "object",
16
+ "properties": {
17
+ "query": {
18
+ "type": "string",
19
+ "description": "Search query to execute"
20
+ },
21
+ "num_results": {
22
+ "type": "integer",
23
+ "description": "Results to return (default: 5, max 10)"
24
+ },
25
+ "fetch_content": {
26
+ "type": "boolean",
27
+ "description": "Fetch full page content from top results (default: true). Set to false for URL/snippet only."
28
+ }
29
+ },
30
+ "required": ["query"]
31
+ },
32
+ requires_approval=False
33
+ )
34
+ def web_search(
35
+ query: str,
36
+ console,
37
+ num_results: Optional[int] = None,
38
+ fetch_content: bool = True
39
+ ) -> str:
40
+ """Search the web using DuckDuckGo with optional full content extraction.
41
+
42
+ Args:
43
+ query: Search query to execute
44
+ console: Rich console for output (injected by context)
45
+ num_results: Number of results to return (default: 5, max: 10)
46
+ fetch_content: Whether to fetch full page content (default: true)
47
+
48
+ Returns:
49
+ Formatted search results with content
50
+ """
51
+ arguments = {"query": query}
52
+ if num_results is not None:
53
+ arguments["num_results"] = num_results
54
+ arguments["fetch_content"] = fetch_content
55
+
56
+ try:
57
+ return run_web_search(arguments, console)
58
+ except LLMConnectionError as e:
59
+ return f"exit_code=1\nWeb search failed: {e}"
60
+ except Exception as e:
61
+ return f"exit_code=1\nWeb search failed: {str(e)}"
@@ -0,0 +1 @@
1
+ """User interface layer for bone-agent."""
@@ -0,0 +1,87 @@
1
+ """Startup banner display - separated from main.py to avoid circular imports."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+ from rich.table import Table
9
+ import json
10
+ from llm import config
11
+
12
+ console = Console()
13
+
14
+
15
+ def format_directory_path(path: str) -> str:
16
+ """Format directory path to show first and last parts with ellipsis.
17
+
18
+ Args:
19
+ path: Full directory path.
20
+
21
+ Returns:
22
+ Shortened path like 'c:/.../bone-agent' or full path if short enough.
23
+ """
24
+ parts = path.split(os.sep)
25
+ if len(parts) > 2:
26
+ return f"{parts[0]}.../{parts[-1]}"
27
+
28
+
29
+ def _get_version() -> str:
30
+ """Read version from package.json (single source of truth)."""
31
+ try:
32
+ pkg_path = Path(__file__).resolve().parent.parent.parent / "package.json"
33
+ with open(pkg_path) as f:
34
+ return json.load(f)["version"]
35
+ except Exception:
36
+ return "?.?.?"
37
+
38
+
39
+ def display_startup_banner(approve_mode: str, *, clear_screen: bool = False):
40
+ """Ultra-minimalist startup screen for bone-agent.
41
+
42
+ Args:
43
+ approve_mode: Current approval mode setting.
44
+ clear_screen: If True, clear the terminal before rendering.
45
+ """
46
+ if clear_screen:
47
+ console.clear()
48
+ # Get model name based on provider
49
+ provider_config = config.get_provider_config(config.LLM_PROVIDER)
50
+ if config.LLM_PROVIDER == "local":
51
+ model_path = provider_config.get("model") or ""
52
+ model_name = os.path.basename(model_path) if model_path else "None"
53
+ else:
54
+ model_name = provider_config.get("model") or "None"
55
+
56
+ # Get and format current directory
57
+ current_dir = os.getcwd()
58
+ formatted_dir = format_directory_path(current_dir)
59
+
60
+ # Create grid for 2-column layout
61
+ grid = Table.grid(expand=True)
62
+ grid.add_column(justify="left", ratio=1)
63
+ grid.add_column(justify="right", ratio=1)
64
+
65
+ # Add content
66
+ grid.add_row(
67
+ Text("bone", style="bold white"),
68
+ Text(f"v{_get_version()}", style="dim white")
69
+ )
70
+
71
+ model_info = Text.assemble(
72
+ (f"{config.LLM_PROVIDER.upper()} ", "bold #5F9EA0"),
73
+ (f"{model_name}", "grey70")
74
+ )
75
+
76
+ grid.add_row(
77
+ model_info,
78
+ Text(formatted_dir, style="dim grey50")
79
+ )
80
+
81
+ # Display in panel
82
+ console.print(Panel(
83
+ grid,
84
+ border_style="grey23",
85
+ padding=(0, 2)
86
+ ))
87
+