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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/npm-wrapper.js +235 -0
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +133 -0
- package/package.json +53 -0
- package/requirements.txt +9 -0
- package/src/__init__.py +11 -0
- package/src/core/__init__.py +1 -0
- package/src/core/agentic.py +1054 -0
- package/src/core/chat_manager.py +1552 -0
- package/src/core/config_manager.py +247 -0
- package/src/core/cron.py +527 -0
- package/src/core/cron_allowlist.py +118 -0
- package/src/core/memory.py +232 -0
- package/src/core/retry.py +71 -0
- package/src/core/sub_agent.py +326 -0
- package/src/core/tool_approval.py +220 -0
- package/src/core/tool_feedback.py +778 -0
- package/src/exceptions.py +79 -0
- package/src/llm/__init__.py +1 -0
- package/src/llm/client.py +171 -0
- package/src/llm/config.py +466 -0
- package/src/llm/prompts.py +735 -0
- package/src/llm/providers.py +417 -0
- package/src/llm/streaming.py +163 -0
- package/src/llm/token_tracker.py +368 -0
- package/src/tools/__init__.py +212 -0
- package/src/tools/constants.py +59 -0
- package/src/tools/create_file.py +136 -0
- package/src/tools/directory.py +389 -0
- package/src/tools/edit.py +543 -0
- package/src/tools/file_reader.py +322 -0
- package/src/tools/helpers/__init__.py +105 -0
- package/src/tools/helpers/base.py +550 -0
- package/src/tools/helpers/converters.py +44 -0
- package/src/tools/helpers/file_helpers.py +189 -0
- package/src/tools/helpers/formatters.py +411 -0
- package/src/tools/helpers/loader.py +231 -0
- package/src/tools/helpers/parallel_executor.py +231 -0
- package/src/tools/helpers/path_resolver.py +226 -0
- package/src/tools/helpers/plugin_manifest.py +156 -0
- package/src/tools/obsidian.py +96 -0
- package/src/tools/review_sub_agent.py +189 -0
- package/src/tools/rg_search.py +393 -0
- package/src/tools/search_plugins.py +109 -0
- package/src/tools/select_option.py +593 -0
- package/src/tools/shell.py +302 -0
- package/src/tools/sub_agent.py +139 -0
- package/src/tools/task_list.py +269 -0
- package/src/tools/web_search.py +61 -0
- package/src/ui/__init__.py +1 -0
- package/src/ui/banner.py +87 -0
- package/src/ui/commands.py +2694 -0
- package/src/ui/displays.py +213 -0
- package/src/ui/loader.py +284 -0
- package/src/ui/main.py +646 -0
- package/src/ui/prompt_utils.py +113 -0
- package/src/ui/setting_selector.py +590 -0
- package/src/ui/setup_wizard.py +294 -0
- package/src/ui/sub_agent_panel.py +234 -0
- package/src/ui/tool_confirmation.py +215 -0
- package/src/utils/__init__.py +1 -0
- package/src/utils/citation_parser.py +199 -0
- package/src/utils/editor.py +158 -0
- package/src/utils/gitignore_filter.py +149 -0
- package/src/utils/logger.py +254 -0
- package/src/utils/paths.py +30 -0
- package/src/utils/result_parsers.py +108 -0
- package/src/utils/safe_commands.py +243 -0
- package/src/utils/settings.py +174 -0
- package/src/utils/validation.py +191 -0
- package/src/utils/web_search.py +173 -0
package/src/ui/main.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""Main entry point for bone-agent chatbot."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import warnings
|
|
9
|
+
import atexit
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Suppress prompt_toolkit RuntimeWarning about unawaited coroutines during cleanup
|
|
13
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
14
|
+
|
|
15
|
+
# Add src directory to Python path so we can import llm, core, utils modules
|
|
16
|
+
src_dir = Path(__file__).resolve().parent.parent
|
|
17
|
+
if str(src_dir) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(src_dir))
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.theme import Theme
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
from prompt_toolkit import PromptSession
|
|
24
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
25
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
26
|
+
from prompt_toolkit.styles import Style
|
|
27
|
+
|
|
28
|
+
from llm import config
|
|
29
|
+
from llm.config import TOOLS_ENABLED
|
|
30
|
+
from core.chat_manager import ChatManager
|
|
31
|
+
from ui.commands import process_command
|
|
32
|
+
from ui.banner import display_startup_banner
|
|
33
|
+
from ui.prompt_utils import get_bottom_toolbar_text, setup_common_bindings, TOOLBAR_STYLE
|
|
34
|
+
from core.agentic import agentic_answer
|
|
35
|
+
from utils.settings import MonokaiDarkBGStyle, left_align_headings
|
|
36
|
+
from utils.paths import REPO_ROOT, RG_EXE_PATH
|
|
37
|
+
from exceptions import BoneAgentError
|
|
38
|
+
from tools.loader import load_all_tools
|
|
39
|
+
|
|
40
|
+
# Console setup
|
|
41
|
+
console = Console(theme=Theme({
|
|
42
|
+
"markdown.hr": "grey50",
|
|
43
|
+
"markdown.heading": "default",
|
|
44
|
+
"markdown.h1": "default",
|
|
45
|
+
"markdown.h2": "default",
|
|
46
|
+
"markdown.h3": "default",
|
|
47
|
+
"markdown.h4": "default",
|
|
48
|
+
"markdown.h5": "default",
|
|
49
|
+
"markdown.h6": "default",
|
|
50
|
+
"markdown.paragraph_text": "default",
|
|
51
|
+
"markdown.text": "default",
|
|
52
|
+
"markdown.item": "default",
|
|
53
|
+
"markdown.list_item": "default",
|
|
54
|
+
"markdown.code": "default",
|
|
55
|
+
"markdown.code_block": "default",
|
|
56
|
+
"markdown.link": "default",
|
|
57
|
+
"markdown.link_url": "default",
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
# Debug mode container (used as mutable reference)
|
|
61
|
+
DEBUG_MODE_CONTAINER = {'debug': False}
|
|
62
|
+
|
|
63
|
+
# Ctrl+C exit tracking (for double Ctrl+C to exit)
|
|
64
|
+
CTRL_C_TRACKER = {
|
|
65
|
+
'last_time': 0,
|
|
66
|
+
'exit_window': 2.0, # 2 second window for double Ctrl+C
|
|
67
|
+
'exit_requested': False
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Block input during thinking/agentic processing (prevents key presses from being queued)
|
|
71
|
+
INPUT_BLOCKED = {'blocked': False}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ThinkingIndicator:
|
|
75
|
+
"""Simple spinner wrapper that always cleans up."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, console, message="Thinking ...", spinner="dots"):
|
|
78
|
+
self.console = console
|
|
79
|
+
self.message = message
|
|
80
|
+
self.spinner = spinner
|
|
81
|
+
self._last_word_change = 0
|
|
82
|
+
self._word_change_interval = 15.0 # Change word every 15 seconds
|
|
83
|
+
|
|
84
|
+
self._common_words = [
|
|
85
|
+
"Thinking ...",
|
|
86
|
+
"Chunking ...",
|
|
87
|
+
"Completing ...",
|
|
88
|
+
"Computing ...",
|
|
89
|
+
"Programming ...",
|
|
90
|
+
"Understanding ...",
|
|
91
|
+
"Vibing ...",
|
|
92
|
+
"Perpetuating ...",
|
|
93
|
+
"Analyzing ...",
|
|
94
|
+
"Evaluating ...",
|
|
95
|
+
"Synthesizing ...",
|
|
96
|
+
"Working ...",
|
|
97
|
+
"Debugging ...",
|
|
98
|
+
"Scrutinizing ...",
|
|
99
|
+
"Formulating ...",
|
|
100
|
+
"Predicting next token ...",
|
|
101
|
+
"Outsourcing ...",
|
|
102
|
+
"Checking vitals ...",
|
|
103
|
+
"Scanning fingerprints ...",
|
|
104
|
+
"Rerouting ...",
|
|
105
|
+
"Refactoring ...",
|
|
106
|
+
"Burning tokens ...",
|
|
107
|
+
"Conjuring ...",
|
|
108
|
+
"Recalculating ...",
|
|
109
|
+
"Spinning ...",
|
|
110
|
+
"Pointing ...",
|
|
111
|
+
"Dematerializing ...",
|
|
112
|
+
"Compiling ...",
|
|
113
|
+
"Fetching ...",
|
|
114
|
+
"Buffering ...",
|
|
115
|
+
"Syncing ...",
|
|
116
|
+
"Caching ...",
|
|
117
|
+
"Connecting ...",
|
|
118
|
+
"Indexing ...",
|
|
119
|
+
"Authenticating ...",
|
|
120
|
+
"Validating ...",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
self._rare_words = [
|
|
124
|
+
'"Engineering" ...',
|
|
125
|
+
"Deleting (jk) ...",
|
|
126
|
+
"Computer... Fix my program ...",
|
|
127
|
+
"Exiting VIM ...",
|
|
128
|
+
"Rolling for perception ...",
|
|
129
|
+
"Pinging ...",
|
|
130
|
+
"Ponging ...",
|
|
131
|
+
"Programming HTML ...",
|
|
132
|
+
"Leaking memory ...",
|
|
133
|
+
"Cooking ...",
|
|
134
|
+
"Mining ...",
|
|
135
|
+
"Crafting ...",
|
|
136
|
+
"Pushing to prod ...",
|
|
137
|
+
"Checking with Altman ...",
|
|
138
|
+
"Collecting 200 ...",
|
|
139
|
+
"Rebooting...",
|
|
140
|
+
"Wasting water ...",
|
|
141
|
+
"Asking Stack Overflow ...",
|
|
142
|
+
"Reading the docs ...",
|
|
143
|
+
"Asking ChatGPT ...",
|
|
144
|
+
"Binging it ...",
|
|
145
|
+
"Googling it ...",
|
|
146
|
+
"Dockerizing ...",
|
|
147
|
+
"Forking it ...",
|
|
148
|
+
"Checking the logs ...",
|
|
149
|
+
"Checking the backup ...",
|
|
150
|
+
"Performing vLookup ...",
|
|
151
|
+
"Downloading more RAM ...",
|
|
152
|
+
"Performing SumIf ...",
|
|
153
|
+
"Spinning up servers ...",
|
|
154
|
+
"Getting chat completion ...",
|
|
155
|
+
"Merging conflicts ...",
|
|
156
|
+
"Feature creeping ...",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
self._legendary_words = [
|
|
160
|
+
"I'm confused ...",
|
|
161
|
+
"Running in O(n²) ...",
|
|
162
|
+
"Checking Jira ...",
|
|
163
|
+
"Gaining consciousness ...",
|
|
164
|
+
"Mining Bitcoin ...",
|
|
165
|
+
"Accessing null pointer ...",
|
|
166
|
+
"FIXING ME ...",
|
|
167
|
+
"READING ME ...",
|
|
168
|
+
"Converting to PDF and back ...",
|
|
169
|
+
"Rewriting in Rust ...",
|
|
170
|
+
"Rewriting in JavaScript ...",
|
|
171
|
+
"Recursively calling myself ...",
|
|
172
|
+
"Contacting AWS Support ...",
|
|
173
|
+
"Reviewing footage ...",
|
|
174
|
+
"Dedotating wam ...",
|
|
175
|
+
"Pondering the orb ...",
|
|
176
|
+
"Computer... ENHANCE ...",
|
|
177
|
+
"Consulting council ...",
|
|
178
|
+
"Releasing the files ...",
|
|
179
|
+
"Redacting the files ...",
|
|
180
|
+
"Uhhhh ...",
|
|
181
|
+
"Selling data ...",
|
|
182
|
+
"Okeyyy lets go ...",
|
|
183
|
+
]
|
|
184
|
+
self._status = None
|
|
185
|
+
self._active = False
|
|
186
|
+
self._start_time = None
|
|
187
|
+
self._timer_thread = None
|
|
188
|
+
self._stop_timer = threading.Event()
|
|
189
|
+
self._elapsed_before_pause = 0.0
|
|
190
|
+
self._has_been_started = False
|
|
191
|
+
self._saved_termios = None
|
|
192
|
+
|
|
193
|
+
def _select_random_word(self):
|
|
194
|
+
"""Select a random word from weighted word lists."""
|
|
195
|
+
roll = random.random()
|
|
196
|
+
|
|
197
|
+
if roll < 0.80:
|
|
198
|
+
return random.choice(self._common_words)
|
|
199
|
+
elif roll < 0.95:
|
|
200
|
+
return random.choice(self._rare_words)
|
|
201
|
+
else:
|
|
202
|
+
return random.choice(self._legendary_words)
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _format_time(seconds):
|
|
206
|
+
"""Format seconds as whole seconds or minutes:seconds."""
|
|
207
|
+
if seconds >= 60:
|
|
208
|
+
mins = int(seconds // 60)
|
|
209
|
+
secs = int(seconds % 60)
|
|
210
|
+
return f"{mins}m {secs}s"
|
|
211
|
+
else:
|
|
212
|
+
return f"{int(seconds)}s"
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _set_raw_mode():
|
|
216
|
+
"""Switch stdin to raw mode to prevent keystroke echoes during spinner."""
|
|
217
|
+
if os.name == 'nt':
|
|
218
|
+
return
|
|
219
|
+
try:
|
|
220
|
+
import termios
|
|
221
|
+
fd = sys.stdin.fileno()
|
|
222
|
+
old = termios.tcgetattr(fd)
|
|
223
|
+
new = old.copy()
|
|
224
|
+
# lflag: disable ECHO, ICANON (line buffering), IEXTEN
|
|
225
|
+
new[3] &= ~(termios.ECHO | termios.ICANON | termios.IEXTEN)
|
|
226
|
+
# iflag: disable ICRNL (map CR to NL) so Enter doesn't produce newline
|
|
227
|
+
new[0] &= ~(termios.ICRNL)
|
|
228
|
+
termios.tcsetattr(fd, termios.TCSANOW, new)
|
|
229
|
+
return old
|
|
230
|
+
except Exception:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _restore_terminal_mode(saved):
|
|
235
|
+
"""Restore terminal mode from saved termios attributes."""
|
|
236
|
+
if os.name == 'nt' or saved is None:
|
|
237
|
+
return
|
|
238
|
+
try:
|
|
239
|
+
import termios
|
|
240
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, saved)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
def start(self):
|
|
245
|
+
# Select initial word
|
|
246
|
+
self.message = self._select_random_word()
|
|
247
|
+
|
|
248
|
+
# Initialize timer (reset only on first start)
|
|
249
|
+
if not self._has_been_started:
|
|
250
|
+
self._elapsed_before_pause = 0.0
|
|
251
|
+
self._has_been_started = True
|
|
252
|
+
self._last_word_change = 0
|
|
253
|
+
|
|
254
|
+
self._start_time = time.time()
|
|
255
|
+
self._stop_timer.clear()
|
|
256
|
+
|
|
257
|
+
# Always recreate and restart status with new message
|
|
258
|
+
if self._status and self._active:
|
|
259
|
+
self._status.stop()
|
|
260
|
+
self._saved_termios = self._set_raw_mode()
|
|
261
|
+
self._status = self.console.status(self.message, spinner=self.spinner, spinner_style="#5F9EA0")
|
|
262
|
+
self._status.start()
|
|
263
|
+
self._active = True
|
|
264
|
+
|
|
265
|
+
# Start background timer thread
|
|
266
|
+
self._timer_thread = threading.Thread(target=self._update_timer, daemon=True)
|
|
267
|
+
self._timer_thread.start()
|
|
268
|
+
|
|
269
|
+
def _update_timer(self):
|
|
270
|
+
"""Background thread: update status message with elapsed time."""
|
|
271
|
+
while not self._stop_timer.is_set() and self._status and self._active:
|
|
272
|
+
# Calculate elapsed time including previous pauses
|
|
273
|
+
elapsed = self._elapsed_before_pause + (time.time() - self._start_time)
|
|
274
|
+
|
|
275
|
+
# Change word every 15 seconds
|
|
276
|
+
if elapsed - self._last_word_change >= self._word_change_interval:
|
|
277
|
+
self.message = self._select_random_word()
|
|
278
|
+
self._last_word_change = elapsed
|
|
279
|
+
|
|
280
|
+
# Format elapsed time (e.g., "Thinking ... (1s)" or "Thinking ... (1m 30s)")
|
|
281
|
+
time_str = f"({self._format_time(elapsed)})"
|
|
282
|
+
updated_message = f"{self.message} {time_str}"
|
|
283
|
+
|
|
284
|
+
# Update the status message
|
|
285
|
+
if self._status:
|
|
286
|
+
self._status.update(updated_message)
|
|
287
|
+
|
|
288
|
+
self._stop_timer.wait(0.1) # Update every 100ms
|
|
289
|
+
|
|
290
|
+
def stop(self, reset=False):
|
|
291
|
+
"""Stop the thinking indicator.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
reset: If True, reset elapsed time and state for next use cycle.
|
|
295
|
+
"""
|
|
296
|
+
# Calculate and store elapsed time (including accumulated pauses)
|
|
297
|
+
elapsed_time = None
|
|
298
|
+
if self._start_time:
|
|
299
|
+
elapsed_time = self._elapsed_before_pause + (time.time() - self._start_time)
|
|
300
|
+
self._elapsed_before_pause = elapsed_time
|
|
301
|
+
|
|
302
|
+
# Stop timer thread first (close race window before stopping status)
|
|
303
|
+
self._active = False
|
|
304
|
+
self._stop_timer.set()
|
|
305
|
+
if self._timer_thread:
|
|
306
|
+
self._timer_thread.join(timeout=0.5)
|
|
307
|
+
|
|
308
|
+
if self._status:
|
|
309
|
+
self._status.stop()
|
|
310
|
+
self._status = None
|
|
311
|
+
|
|
312
|
+
# Restore terminal mode (must happen after status.stop() so Rich
|
|
313
|
+
# cursor cleanup runs in raw mode, then we hand control back to ptk)
|
|
314
|
+
self._restore_terminal_mode(self._saved_termios)
|
|
315
|
+
self._saved_termios = None
|
|
316
|
+
|
|
317
|
+
# Reset state for next use cycle
|
|
318
|
+
if reset:
|
|
319
|
+
self._has_been_started = False
|
|
320
|
+
self._elapsed_before_pause = 0.0
|
|
321
|
+
|
|
322
|
+
self._start_time = None
|
|
323
|
+
|
|
324
|
+
def pause(self):
|
|
325
|
+
# Stop without showing completion time (accumulates elapsed time)
|
|
326
|
+
self.stop(reset=False)
|
|
327
|
+
|
|
328
|
+
def resume(self):
|
|
329
|
+
# Resume with timer continuing from accumulated time
|
|
330
|
+
self.start()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def check_double_ctrl_c() -> bool:
|
|
334
|
+
"""
|
|
335
|
+
Check if this is a double Ctrl+C (within exit window).
|
|
336
|
+
Returns True if should exit, False otherwise.
|
|
337
|
+
Updates the tracker timestamp and exit_requested flag.
|
|
338
|
+
"""
|
|
339
|
+
# Check if exit was already requested
|
|
340
|
+
if CTRL_C_TRACKER['exit_requested']:
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
current_time = time.time()
|
|
344
|
+
time_since_last = current_time - CTRL_C_TRACKER['last_time']
|
|
345
|
+
|
|
346
|
+
if time_since_last <= CTRL_C_TRACKER['exit_window']:
|
|
347
|
+
# Double Ctrl+C detected - set exit flag and return True
|
|
348
|
+
CTRL_C_TRACKER['exit_requested'] = True
|
|
349
|
+
return True
|
|
350
|
+
else:
|
|
351
|
+
# First Ctrl+C or too much time passed - update timestamp and continue
|
|
352
|
+
CTRL_C_TRACKER['last_time'] = current_time
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _drain_stdin(session):
|
|
357
|
+
"""Drain buffered keystrokes and clear the prompt_toolkit buffer.
|
|
358
|
+
|
|
359
|
+
Called after AI processing ends to discard any input the user
|
|
360
|
+
typed while the thinking indicator was active.
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
if os.name != 'nt':
|
|
364
|
+
import termios
|
|
365
|
+
termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
|
|
366
|
+
else:
|
|
367
|
+
import msvcrt
|
|
368
|
+
while msvcrt.kbhit():
|
|
369
|
+
msvcrt.getch()
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
buf = session.default_buffer
|
|
375
|
+
if buf and buf.text:
|
|
376
|
+
buf.text = ""
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def main():
|
|
382
|
+
"""Main interactive chat loop."""
|
|
383
|
+
|
|
384
|
+
# Load all tools (built-in and user tools)
|
|
385
|
+
# This populates the ToolRegistry with all decorated tools
|
|
386
|
+
load_all_tools()
|
|
387
|
+
|
|
388
|
+
# Check for config.yaml — run setup wizard on first run
|
|
389
|
+
from ui.setup_wizard import is_first_run, run_wizard as _run_setup_wizard
|
|
390
|
+
|
|
391
|
+
if is_first_run():
|
|
392
|
+
console.print("\n[#5F9EA0]No config found — launching setup wizard.[/#5F9EA0]\n")
|
|
393
|
+
_run_setup_wizard(console)
|
|
394
|
+
# Reload config after wizard writes it
|
|
395
|
+
try:
|
|
396
|
+
from llm import config as llm_config
|
|
397
|
+
llm_config.reload_config()
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
chat_manager = ChatManager()
|
|
402
|
+
thinking_indicator = ThinkingIndicator(console)
|
|
403
|
+
# Safety net: ensure terminal mode is restored even on unhandled exceptions
|
|
404
|
+
def _safety_restore():
|
|
405
|
+
ThinkingIndicator._restore_terminal_mode(thinking_indicator._saved_termios)
|
|
406
|
+
atexit.register(_safety_restore)
|
|
407
|
+
# Start server if needed
|
|
408
|
+
console.print("[yellow]Initializing...[/yellow]")
|
|
409
|
+
chat_manager.server_process = chat_manager.start_server_if_needed()
|
|
410
|
+
if not chat_manager.server_process and chat_manager.client.provider == "local":
|
|
411
|
+
console.print("[red]Failed to start local server![/red]")
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
display_startup_banner(chat_manager.approve_mode, clear_screen=True)
|
|
415
|
+
|
|
416
|
+
# Start cron scheduler (background thread for scheduled jobs)
|
|
417
|
+
cron_scheduler = None
|
|
418
|
+
try:
|
|
419
|
+
from core.cron import CronScheduler
|
|
420
|
+
cron_scheduler = CronScheduler(console=console)
|
|
421
|
+
cron_scheduler.start()
|
|
422
|
+
except Exception as e:
|
|
423
|
+
import logging as _log
|
|
424
|
+
_log.warning("Cron scheduler failed to start: %s", e)
|
|
425
|
+
console.print(f"[yellow]Cron scheduler unavailable: {e}[/yellow]")
|
|
426
|
+
|
|
427
|
+
# First-run onboarding: check if active provider needs an API key but has none
|
|
428
|
+
try:
|
|
429
|
+
from llm import config as llm_config
|
|
430
|
+
active_provider = chat_manager.client.provider
|
|
431
|
+
provider_cfg = llm_config.get_provider_config(active_provider)
|
|
432
|
+
if (
|
|
433
|
+
provider_cfg.get("type") == "api"
|
|
434
|
+
and not provider_cfg.get("api_key")
|
|
435
|
+
):
|
|
436
|
+
console.print()
|
|
437
|
+
console.print("[bold #5F9EA0]Welcome! Get started in two steps:[/bold #5F9EA0]")
|
|
438
|
+
console.print()
|
|
439
|
+
console.print(" [bold]1.[/bold] [bold white on grey23] /signup <email> [/bold white on grey23] [dim]— create a free account & API key[/dim]")
|
|
440
|
+
console.print(" [bold]2.[/bold] [bold white on grey23] /provider[/bold white on grey23] [dim]— or pick another provider (OpenAI, Anthropic, ...)[/dim]")
|
|
441
|
+
console.print()
|
|
442
|
+
console.print("[dim]Tip: use [bold #5F9EA0]/key <your-key>[/bold #5F9EA0] to set a key for any provider.[/dim]")
|
|
443
|
+
console.print()
|
|
444
|
+
except Exception:
|
|
445
|
+
pass # Best-effort; don't block startup on failure
|
|
446
|
+
|
|
447
|
+
# Setup prompt_toolkit with Tab key binding
|
|
448
|
+
bindings = setup_common_bindings(chat_manager)
|
|
449
|
+
|
|
450
|
+
def get_prompt(chat_manager):
|
|
451
|
+
"""Return colored prompt."""
|
|
452
|
+
prompt_text = Text.assemble(
|
|
453
|
+
(" > ", "white")
|
|
454
|
+
)
|
|
455
|
+
with console.capture() as capture:
|
|
456
|
+
console.print(prompt_text, end="")
|
|
457
|
+
return ANSI(capture.get())
|
|
458
|
+
|
|
459
|
+
@bindings.add('escape', 'escape')
|
|
460
|
+
def clear_input(event):
|
|
461
|
+
"""Clear the current input line on double ESC press (blocked during thinking)."""
|
|
462
|
+
if INPUT_BLOCKED.get('blocked', False):
|
|
463
|
+
return
|
|
464
|
+
buffer = event.app.current_buffer
|
|
465
|
+
if buffer is not None:
|
|
466
|
+
buffer.text = ""
|
|
467
|
+
event.app.invalidate()
|
|
468
|
+
|
|
469
|
+
session = PromptSession(key_bindings=bindings, style=TOOLBAR_STYLE)
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
while True:
|
|
473
|
+
# Check if exit was requested via double Ctrl+C
|
|
474
|
+
if CTRL_C_TRACKER['exit_requested']:
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
# Use prompt_toolkit for input with Tab key binding and dynamic prompt
|
|
479
|
+
prompt_kwargs = {
|
|
480
|
+
"bottom_toolbar": lambda: get_bottom_toolbar_text(chat_manager),
|
|
481
|
+
}
|
|
482
|
+
raw_input = session.prompt(
|
|
483
|
+
lambda: get_prompt(chat_manager),
|
|
484
|
+
**prompt_kwargs,
|
|
485
|
+
)
|
|
486
|
+
user_input = raw_input.strip()
|
|
487
|
+
|
|
488
|
+
if not user_input:
|
|
489
|
+
# Clear the empty input line to avoid multiple prompts stacking
|
|
490
|
+
import sys
|
|
491
|
+
sys.stdout.write("\033[F\033[K") # Move up and clear line
|
|
492
|
+
sys.stdout.flush()
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
# Process commands
|
|
496
|
+
cmd_result, modified_input = process_command(chat_manager, user_input, console, DEBUG_MODE_CONTAINER, cron_scheduler)
|
|
497
|
+
if cmd_result == "exit":
|
|
498
|
+
break
|
|
499
|
+
elif cmd_result == "handled":
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
# Use modified input if provided (from /edit command)
|
|
503
|
+
final_input = modified_input if modified_input else user_input
|
|
504
|
+
|
|
505
|
+
chat_manager.maybe_auto_compact(console)
|
|
506
|
+
|
|
507
|
+
thinking_indicator.start()
|
|
508
|
+
INPUT_BLOCKED['blocked'] = True
|
|
509
|
+
try:
|
|
510
|
+
console.print() # Extra newline after user input to separate from LLM response
|
|
511
|
+
# Add user message
|
|
512
|
+
if TOOLS_ENABLED:
|
|
513
|
+
try:
|
|
514
|
+
agentic_answer(
|
|
515
|
+
chat_manager,
|
|
516
|
+
final_input,
|
|
517
|
+
console,
|
|
518
|
+
REPO_ROOT,
|
|
519
|
+
RG_EXE_PATH,
|
|
520
|
+
DEBUG_MODE_CONTAINER['debug'],
|
|
521
|
+
thinking_indicator=thinking_indicator,
|
|
522
|
+
)
|
|
523
|
+
chat_manager._update_context_tokens()
|
|
524
|
+
except KeyboardInterrupt:
|
|
525
|
+
if not check_double_ctrl_c():
|
|
526
|
+
console.print("\n[yellow]Response interrupted (Ctrl+C). Press Ctrl+C again to exit.[/yellow]")
|
|
527
|
+
console.print() # Extra spacing
|
|
528
|
+
except BoneAgentError as e:
|
|
529
|
+
# Handle all bone-agent custom exceptions gracefully
|
|
530
|
+
console.print(f"[red]Error: {e}[/red]", markup=False)
|
|
531
|
+
if hasattr(e, 'details') and e.details:
|
|
532
|
+
console.print(f"[dim]Details: {e.details}[/dim]", markup=False)
|
|
533
|
+
else:
|
|
534
|
+
chat_manager.messages.append({"role": "user", "content": final_input})
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
stream = chat_manager.client.chat_completion(
|
|
538
|
+
chat_manager.messages, stream=True
|
|
539
|
+
)
|
|
540
|
+
if isinstance(stream, str):
|
|
541
|
+
console.print(f"[red]Error: {stream}[/red]")
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
try:
|
|
545
|
+
# Stream response
|
|
546
|
+
chunks = []
|
|
547
|
+
usage_data = None
|
|
548
|
+
for chunk in stream:
|
|
549
|
+
# Check if this is usage data (final chunk)
|
|
550
|
+
if isinstance(chunk, dict) and '__usage__' in chunk:
|
|
551
|
+
usage_data = chunk['__usage__']
|
|
552
|
+
else:
|
|
553
|
+
chunks.append(chunk)
|
|
554
|
+
full_response = "".join(chunks)
|
|
555
|
+
|
|
556
|
+
# Clear thinking indicator before printing response
|
|
557
|
+
thinking_indicator.stop(reset=True)
|
|
558
|
+
INPUT_BLOCKED['blocked'] = False
|
|
559
|
+
_drain_stdin(session)
|
|
560
|
+
|
|
561
|
+
if full_response.strip():
|
|
562
|
+
md = Markdown(left_align_headings(full_response), code_theme=MonokaiDarkBGStyle, justify="left")
|
|
563
|
+
console.print(md)
|
|
564
|
+
|
|
565
|
+
chat_manager.messages.append(
|
|
566
|
+
{"role": "assistant", "content": full_response}
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Add usage tracking (resolves cost from config if
|
|
570
|
+
# upstream-reported cost is absent in the usage dict)
|
|
571
|
+
if usage_data:
|
|
572
|
+
provider_cfg = llm.config.get_provider_config(chat_manager.client.provider)
|
|
573
|
+
chat_manager.token_tracker.add_usage(
|
|
574
|
+
usage_data,
|
|
575
|
+
model_name=provider_cfg.get("model", ""),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
chat_manager._update_context_tokens()
|
|
579
|
+
except KeyboardInterrupt:
|
|
580
|
+
# Ctrl+C pressed during streaming
|
|
581
|
+
if not check_double_ctrl_c():
|
|
582
|
+
console.print("\n[yellow]Response interrupted (Ctrl+C). Press Ctrl+C again to exit.[/yellow]")
|
|
583
|
+
# Save partial response
|
|
584
|
+
if chunks:
|
|
585
|
+
partial = "".join(chunks)
|
|
586
|
+
if partial.strip():
|
|
587
|
+
partial_with_note = partial + "\n\n*[Response interrupted]*"
|
|
588
|
+
md = Markdown(left_align_headings(partial_with_note), code_theme=MonokaiDarkBGStyle, justify="left")
|
|
589
|
+
console.print(md)
|
|
590
|
+
chat_manager.messages.append(
|
|
591
|
+
{"role": "assistant", "content": partial}
|
|
592
|
+
)
|
|
593
|
+
console.print() # Extra spacing
|
|
594
|
+
finally:
|
|
595
|
+
# Ensure HTTP connection is closed
|
|
596
|
+
if hasattr(stream, 'close'):
|
|
597
|
+
stream.close()
|
|
598
|
+
|
|
599
|
+
except BoneAgentError as e:
|
|
600
|
+
# Handle all bone-agent custom exceptions gracefully
|
|
601
|
+
console.print(f"[red]Error: {e}[/red]", markup=False)
|
|
602
|
+
if hasattr(e, 'details') and e.details:
|
|
603
|
+
console.print(f"[dim]Details: {e.details}[/dim]", markup=False)
|
|
604
|
+
except Exception as e:
|
|
605
|
+
console.print(f"[red]Error during generation: {e}[/red]", markup=False)
|
|
606
|
+
finally:
|
|
607
|
+
thinking_indicator.stop(reset=True)
|
|
608
|
+
INPUT_BLOCKED['blocked'] = False
|
|
609
|
+
_drain_stdin(session)
|
|
610
|
+
|
|
611
|
+
except KeyboardInterrupt:
|
|
612
|
+
# Ctrl+C pressed while waiting for input
|
|
613
|
+
if check_double_ctrl_c():
|
|
614
|
+
break
|
|
615
|
+
else:
|
|
616
|
+
console.print("\n[dim](Press Ctrl+C again to exit, or type 'exit' to quit)[/dim]")
|
|
617
|
+
continue
|
|
618
|
+
except EOFError:
|
|
619
|
+
# stdin closed (Ctrl+D or piped input ended)
|
|
620
|
+
break
|
|
621
|
+
|
|
622
|
+
finally:
|
|
623
|
+
# Display session summary before cleanup
|
|
624
|
+
summary = chat_manager.token_tracker.get_session_summary()
|
|
625
|
+
console.print(f"\n[white]Session Summary: {summary}[/white]")
|
|
626
|
+
|
|
627
|
+
# Stop cron scheduler if running
|
|
628
|
+
if cron_scheduler:
|
|
629
|
+
cron_scheduler.stop()
|
|
630
|
+
|
|
631
|
+
chat_manager.cleanup()
|
|
632
|
+
console.print("[yellow]Goodbye![/yellow]")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
if __name__ == "__main__":
|
|
636
|
+
import argparse
|
|
637
|
+
|
|
638
|
+
parser = argparse.ArgumentParser(description="bone-agent CLI")
|
|
639
|
+
parser.add_argument("--cron-run", metavar="JOB_ID", help="Run a cron job headlessly and exit")
|
|
640
|
+
args = parser.parse_args()
|
|
641
|
+
|
|
642
|
+
if args.cron_run:
|
|
643
|
+
from core.cron import run_job_headless
|
|
644
|
+
sys.exit(run_job_headless(args.cron_run))
|
|
645
|
+
|
|
646
|
+
main()
|