bardgent 1.0.0__py3-none-any.whl

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.
bardgent/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """BardGent - a small terminal coding agent."""
2
+
3
+ __version__ = "1.0.0"
bardgent/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m bardgent` (and the scheduler daemon spawn) to work."""
2
+ from bardgent.main import main
3
+
4
+ if __name__ == '__main__':
5
+ main()
@@ -0,0 +1,132 @@
1
+ """
2
+ Silent, git-backed project checkpoints. Every applied Write/Edit inside a
3
+ git repo gets snapshotted onto a side ref (refs/bardgent/checkpoints) via a
4
+ throwaway index file, so it never touches the user's real HEAD, branch, or
5
+ staged changes. /checkpoints lists them, /restore <n> rolls the working
6
+ tree back to one.
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import time
12
+ import subprocess
13
+
14
+ from bardgent import config
15
+ from bardgent.config import log_event
16
+
17
+
18
+ def _git_root(path):
19
+ try:
20
+ result = subprocess.run(
21
+ ['git', 'rev-parse', '--show-toplevel'],
22
+ cwd=os.path.dirname(os.path.abspath(path)) or '.',
23
+ capture_output=True, text=True, timeout=5,
24
+ )
25
+ if result.returncode == 0:
26
+ return result.stdout.strip()
27
+ except (OSError, subprocess.SubprocessError):
28
+ pass
29
+ return None
30
+
31
+
32
+ def _load_checkpoint_log():
33
+ if config.CHECKPOINT_LOG.exists():
34
+ try:
35
+ return json.loads(config.CHECKPOINT_LOG.read_text(encoding='utf-8'))
36
+ except (OSError, json.JSONDecodeError):
37
+ return []
38
+ return []
39
+
40
+
41
+ def _save_checkpoint_log(entries):
42
+ try:
43
+ config.PERMISSIONS_DIR.mkdir(exist_ok=True)
44
+ config.CHECKPOINT_LOG.write_text(json.dumps(entries, indent=2), encoding='utf-8')
45
+ except OSError as e:
46
+ log_event(f"CHECKPOINT LOG SAVE FAILED: {e}")
47
+
48
+
49
+ def make_git_checkpoint(path, message):
50
+ root = _git_root(path)
51
+ if not root:
52
+ return None
53
+ try:
54
+ env = os.environ.copy()
55
+ env['GIT_INDEX_FILE'] = str(config.CHECKPOINT_INDEX_FILE)
56
+ subprocess.run(['git', 'add', '-A'], cwd=root, env=env, capture_output=True, text=True, timeout=20)
57
+ tree = subprocess.run(['git', 'write-tree'], cwd=root, env=env, capture_output=True, text=True, timeout=20)
58
+ if tree.returncode != 0:
59
+ log_event(f"CHECKPOINT write-tree failed: {tree.stderr.strip()}")
60
+ return None
61
+ tree_hash = tree.stdout.strip()
62
+
63
+ parent_args = []
64
+ parent = subprocess.run(['git', 'rev-parse', config.CHECKPOINT_REF], cwd=root, capture_output=True, text=True, timeout=5)
65
+ if parent.returncode == 0:
66
+ parent_args = ['-p', parent.stdout.strip()]
67
+ else:
68
+ head = subprocess.run(['git', 'rev-parse', 'HEAD'], cwd=root, capture_output=True, text=True, timeout=5)
69
+ if head.returncode == 0:
70
+ parent_args = ['-p', head.stdout.strip()]
71
+
72
+ commit = subprocess.run(
73
+ ['git', 'commit-tree', tree_hash, *parent_args, '-m', message],
74
+ cwd=root, env=env, capture_output=True, text=True, timeout=20,
75
+ )
76
+ if commit.returncode != 0:
77
+ log_event(f"CHECKPOINT commit-tree failed: {commit.stderr.strip()}")
78
+ return None
79
+ commit_hash = commit.stdout.strip()
80
+
81
+ upd = subprocess.run(['git', 'update-ref', config.CHECKPOINT_REF, commit_hash], cwd=root, capture_output=True, text=True, timeout=10)
82
+ if upd.returncode != 0:
83
+ log_event(f"CHECKPOINT update-ref failed: {upd.stderr.strip()}")
84
+ return None
85
+
86
+ entries = _load_checkpoint_log()
87
+ entries.append({
88
+ 'time': time.strftime('%Y-%m-%d %H:%M:%S'),
89
+ 'message': message,
90
+ 'commit': commit_hash,
91
+ 'root': root,
92
+ })
93
+ entries = entries[-100:]
94
+ _save_checkpoint_log(entries)
95
+ log_event(f"CHECKPOINT {commit_hash[:10]} ({message})")
96
+ return commit_hash
97
+ except (OSError, subprocess.SubprocessError) as e:
98
+ log_event(f"CHECKPOINT failed: {type(e).__name__}: {e}")
99
+ return None
100
+
101
+
102
+ def list_checkpoints():
103
+ entries = _load_checkpoint_log()
104
+ if not entries:
105
+ return '(no checkpoints yet. checkpoints are created automatically on Write/Edit inside a git repo)'
106
+ lines = []
107
+ for i, e in enumerate(entries, 1):
108
+ lines.append(f"{i}. {e['time']} {e['commit'][:10]} {e['message']}")
109
+ return '\n'.join(lines)
110
+
111
+
112
+ def restore_checkpoint(index):
113
+ entries = _load_checkpoint_log()
114
+ try:
115
+ idx = int(index)
116
+ except (TypeError, ValueError):
117
+ return f"Invalid checkpoint index: {index!r}"
118
+ if idx < 1 or idx > len(entries):
119
+ return f"No checkpoint at index {idx}. Use /checkpoints to see valid indices."
120
+ entry = entries[idx - 1]
121
+ root, commit = entry['root'], entry['commit']
122
+ try:
123
+ result = subprocess.run(
124
+ ['git', 'checkout', commit, '--', '.'],
125
+ cwd=root, capture_output=True, text=True, timeout=30,
126
+ )
127
+ if result.returncode != 0:
128
+ return f"Restore failed: {result.stderr.strip()}"
129
+ except (OSError, subprocess.SubprocessError) as e:
130
+ return f"Restore failed: {type(e).__name__}: {e}"
131
+ log_event(f"RESTORED checkpoint #{idx} ({commit[:10]})")
132
+ return f"Working tree in {root} restored to checkpoint #{idx} ({entry['time']}, {commit[:10]}). Your git branch/HEAD/index are untouched. only file contents were overwritten."
bardgent/commands.py ADDED
@@ -0,0 +1,335 @@
1
+ """Slash commands (/model, /clear, /resume, /plan, ...) and mode switching."""
2
+ import datetime
3
+ from rich.panel import Panel
4
+ from rich.markup import escape
5
+ from pathlib import Path
6
+
7
+ from bardgent import config, skills, scheduler
8
+ from bardgent.config import console, log_event
9
+ from bardgent.state import ask_approval
10
+ from bardgent.session import (
11
+ session_file_name, do_summary_and_compact, resume_session, list_sessions,
12
+ )
13
+ from bardgent.checkpoints import list_checkpoints, restore_checkpoint
14
+ from bardgent.telegram import discover_telegram_chat_id, send_telegram_message, _save_telegram_chat_id
15
+ from bardgent.skills import install_skill_from_github
16
+ from bardgent.system_prompt import refresh_system_message
17
+
18
+ COMMANDS = {
19
+ '/help': 'List all slash commands',
20
+ '/summary': 'Summarize the current conversation',
21
+ '/model': 'Show the current model, or switch: /model <name>',
22
+ '/clear': 'Clear history and start a new session',
23
+ '/resume': 'Pick a past session and resume it',
24
+ '/telegram': 'Toggle sending the agent\'s final answers to Telegram',
25
+ '/plan': 'Switch to PLAN mode (read-only exploration, agent proposes a plan)',
26
+ '/normal': 'Switch to NORMAL mode (approve each action, default)',
27
+ '/auto': 'Switch to AUTO mode (auto-approve everything except dangerous commands)',
28
+ '/mode': 'Show the current mode',
29
+ '/skills': 'List installed skills (auto-detected capability packs)',
30
+ '/skill install': 'Install a skill from GitHub: /skill install <github_url>',
31
+ '/checkpoints': 'List recent git checkpoints (auto-created on Write/Edit)',
32
+ '/restore': 'Restore the working tree to a checkpoint: /restore <n>',
33
+ '/schedule': 'Create a scheduled task: /schedule <spec> :: <prompt>. Manage: /schedule pause|resume|delete|run <id> | daemon status|start|stop',
34
+ '/schedules': 'List scheduled tasks (next/last run, status) and daemon state',
35
+ '/init': 'Analyze current project (files, structure, key configs) and generate/update AGENTS.md',
36
+ '/exit': 'Quit Bardgent',
37
+ }
38
+
39
+ MODE_CYCLE = ['normal', 'auto', 'plan']
40
+ _MODE_COLOR = {'plan': 'cyan', 'normal': 'white', 'auto': 'bold red'}
41
+
42
+ SCHEDULE_HELP = (
43
+ "Usage: /schedule <spec> :: <prompt>\n"
44
+ " Specs:\n"
45
+ " every 30m | every 2h | every 1d (recurring interval, min 60s)\n"
46
+ " daily 09:00 | daily 6pm (once a day)\n"
47
+ " weekly mon 09:00 (once a week)\n"
48
+ " once 2026-07-20 09:00 (a single one-off run)\n"
49
+ " cron */15 * * * * (standard 5-field cron)\n"
50
+ " Example:\n"
51
+ " /schedule daily 09:00 :: Summarize my unread Slack messages from the last 24 hours\n"
52
+ " Manage existing tasks:\n"
53
+ " /schedules (list all + daemon status)\n"
54
+ " /schedule pause <id>\n"
55
+ " /schedule resume <id>\n"
56
+ " /schedule delete <id>\n"
57
+ " /schedule run <id> (run it right now, on demand)\n"
58
+ " Daemon (survives closing the terminal):\n"
59
+ " /schedule daemon status\n"
60
+ " /schedule daemon start\n"
61
+ " /schedule daemon stop\n"
62
+ " Note: tasks keep firing after you quit Bardgent as long as the daemon is\n"
63
+ " running. A full machine reboot stops the daemon until Bardgent starts again."
64
+ )
65
+
66
+
67
+ def switch_mode(state, new_mode, announce=True):
68
+ """Single source of truth for changing state.mode, used by /plan,
69
+ /normal, /auto, and the shift+tab shortcut alike."""
70
+ old_mode = state.mode
71
+ if new_mode == old_mode:
72
+ if announce:
73
+ console.print(f'[dim]Already in {new_mode} mode.[/dim]')
74
+ return
75
+ state.mode = new_mode
76
+ if announce:
77
+ color = _MODE_COLOR.get(new_mode, 'white')
78
+ # console.print(f'[{color}]Mode switched: {old_mode} -> {new_mode}[/{color}]')
79
+ # if new_mode == 'plan':
80
+ # console.print('[dim]Only read-only tools are allowed until you switch back with /normal or /auto.[/dim]')
81
+ # elif new_mode == 'auto':
82
+ # console.print('[dim]Non-dangerous actions will be auto-approved without asking. '
83
+ # 'Dangerous commands (rm, sudo, chmod, kill, ...) still always ask.[/dim]')
84
+ log_event(f"[{state.name}] MODE {old_mode} -> {new_mode}")
85
+ try:
86
+ from bardgent.status_bar import draw_status_bar
87
+ draw_status_bar(state, force=True)
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ def handle_command(user_input, state):
93
+ cmd = user_input.strip().lower()
94
+
95
+ if cmd in ('/help', '/?'):
96
+ lines = [f" [bold cyan]{name}[/bold cyan] {desc}" for name, desc in COMMANDS.items()]
97
+ console.print(Panel('\n'.join(lines), title='[bold cyan]Commands', border_style='cyan'))
98
+ return 'handled'
99
+
100
+ if cmd == '/model' or cmd.startswith('/model '):
101
+ parts = user_input.strip().split(maxsplit=1)
102
+ if len(parts) == 1:
103
+ console.print(f'Current model: [bold cyan]{config.MODEL}[/bold cyan]')
104
+ else:
105
+ new_model = parts[1].strip()
106
+ try:
107
+ available = [m.id for m in config.client.models.list().data]
108
+ except Exception as e:
109
+ available = None
110
+ console.print(f'[dim]Could not reach server to verify model list: {e}[/dim]')
111
+ if available and new_model not in available:
112
+ console.print(f"[yellow]Warning: '{new_model}' was not found on the server.[/yellow]")
113
+ console.print(f"[dim]Available: {', '.join(available)}[/dim]")
114
+ if not ask_approval(state, 'model_switch_unverified', "Switch to it anyway?"):
115
+ return 'handled'
116
+ config.MODEL = new_model
117
+ console.print(f'[bold green]Model switched to {config.MODEL}.[/bold green]')
118
+ return 'handled'
119
+
120
+ if cmd == '/clear':
121
+ from bardgent.ui import print_welcome # local import: avoids a circular import with main.py
122
+ del state.messages[1:]
123
+ state.session_file = config.SESSION_DIR / session_file_name()
124
+ state.last_prompt_tokens = None
125
+ refresh_system_message(state)
126
+ console.clear()
127
+ print_welcome()
128
+ console.print("[bold green]New session started.[/bold green]")
129
+ return 'handled'
130
+
131
+ if cmd == '/resume':
132
+ resume_session(state)
133
+ return 'handled'
134
+
135
+ if cmd in ('/plan', '/normal', '/auto'):
136
+ switch_mode(state, cmd[1:])
137
+ return 'handled'
138
+
139
+ if cmd == '/mode':
140
+ console.print(f'Current mode: [bold cyan]{state.mode}[/bold cyan]')
141
+ return 'handled'
142
+
143
+ if cmd == '/skills':
144
+ skills.refresh_skills()
145
+ console.print(Panel(escape(skills.list_skills_text()), title='[bold cyan]Installed skills', border_style='cyan'))
146
+ return 'handled'
147
+
148
+ if cmd == '/checkpoints':
149
+ console.print(Panel(escape(list_checkpoints()), title='[bold cyan]Git checkpoints', border_style='cyan'))
150
+ return 'handled'
151
+
152
+ if cmd == '/restore' or cmd.startswith('/restore '):
153
+ parts = user_input.strip().split(maxsplit=1)
154
+ if len(parts) == 1:
155
+ console.print('[yellow]Usage: /restore <n> (see /checkpoints for indices)[/yellow]')
156
+ return 'handled'
157
+ if not ask_approval(state, 'restore_checkpoint',
158
+ f"This overwrites files in the working tree to match checkpoint #{parts[1].strip()}. Continue?"):
159
+ console.print('[yellow]Restore cancelled.[/yellow]')
160
+ return 'handled'
161
+ console.print(restore_checkpoint(parts[1].strip()))
162
+ return 'handled'
163
+
164
+ if cmd == '/schedules':
165
+ console.print(Panel(escape(scheduler.list_tasks_text()), title='[bold cyan]Scheduled tasks', border_style='cyan'))
166
+ return 'handled'
167
+
168
+ if cmd == '/schedule' or cmd.startswith('/schedule '):
169
+ rest = user_input.strip()[len('/schedule'):].strip()
170
+ if not rest:
171
+ console.print(Panel(SCHEDULE_HELP, title='[bold cyan]/schedule help', border_style='cyan'))
172
+ return 'handled'
173
+
174
+ first_word = rest.split(maxsplit=1)[0].lower()
175
+ if first_word == 'daemon':
176
+ parts = rest.split(maxsplit=1)
177
+ action = parts[1].strip().lower() if len(parts) > 1 else 'status'
178
+ if action in ('status', ''):
179
+ st = scheduler.daemon_status()
180
+ if st['running']:
181
+ console.print(
182
+ f'[green]Scheduler daemon running[/green] (pid {st["pid"]}). '
183
+ f'Tasks keep firing after you close the terminal. '
184
+ f'Log: ~/.bardgent/scheduler.log'
185
+ )
186
+ else:
187
+ console.print(
188
+ '[yellow]Scheduler daemon is not running.[/yellow] '
189
+ 'Start it with /schedule daemon start (or just open Bardgent).'
190
+ )
191
+ elif action == 'start':
192
+ ok, msg = scheduler.ensure_daemon_running()
193
+ console.print(f'[green]{msg}[/green]' if ok else f'[red]{msg}[/red]')
194
+ elif action == 'stop':
195
+ ok, msg = scheduler.stop_daemon()
196
+ console.print(f'[green]{msg}[/green]' if ok else f'[red]{msg}[/red]')
197
+ else:
198
+ console.print('[yellow]Usage: /schedule daemon status|start|stop[/yellow]')
199
+ return 'handled'
200
+
201
+ if first_word in ('pause', 'resume', 'delete', 'remove', 'cancel', 'run'):
202
+ parts = rest.split(maxsplit=1)
203
+ if len(parts) < 2:
204
+ console.print(f'[yellow]Usage: /schedule {first_word} <id> (see /schedules for ids)[/yellow]')
205
+ return 'handled'
206
+ task_id = parts[1].strip()
207
+
208
+ if first_word == 'pause':
209
+ ok = scheduler.set_enabled(task_id, False)
210
+ console.print(f'[green]Paused {task_id}.[/green]' if ok else f'[red]No scheduled task with id {task_id}.[/red]')
211
+ elif first_word == 'resume':
212
+ ok = scheduler.set_enabled(task_id, True)
213
+ console.print(f'[green]Resumed {task_id}.[/green]' if ok else f'[red]No scheduled task with id {task_id}.[/red]')
214
+ elif first_word in ('delete', 'remove', 'cancel'):
215
+ ok = scheduler.remove_task(task_id)
216
+ console.print(f'[green]Deleted {task_id}.[/green]' if ok else f'[red]No scheduled task with id {task_id}.[/red]')
217
+ elif first_word == 'run':
218
+ task = scheduler.get_task(task_id)
219
+ if not task:
220
+ console.print(f'[red]No scheduled task with id {task_id}.[/red]')
221
+ elif scheduler.is_task_running(task_id):
222
+ console.print(f'[yellow]{task_id} ("{task["name"]}") is already running - not starting a second one.[/yellow]')
223
+ else:
224
+ console.print(f'[cyan]Running {task_id} ("{task["name"]}") now in the background...[/cyan]')
225
+ scheduler.run_task_in_background(task_id)
226
+ return 'handled'
227
+
228
+ if '::' not in rest:
229
+ console.print(Panel(SCHEDULE_HELP, title='[bold cyan]/schedule help', border_style='cyan'))
230
+ return 'handled'
231
+
232
+ spec_part, prompt_part = rest.split('::', 1)
233
+ task, err = scheduler.add_task(prompt_part.strip(), spec_part.strip())
234
+ if err:
235
+ console.print(f'[bold red]Could not create scheduled task:[/bold red] {err}')
236
+ else:
237
+ nr_text = scheduler.format_dt(task.get('next_run'))
238
+ console.print(f'[bold green]Scheduled task created:[/bold green] {task["id"]} (next run: {nr_text})')
239
+ st = scheduler.daemon_status()
240
+ if st['running']:
241
+ console.print(
242
+ f'[dim]Daemon running (pid {st["pid"]}) — this task will still fire if you close the terminal.[/dim]'
243
+ )
244
+ else:
245
+ console.print(
246
+ '[yellow]Warning: scheduler daemon is not running. '
247
+ 'Use /schedule daemon start so tasks fire after you quit.[/yellow]'
248
+ )
249
+ if not config.TELEGRAM_BOT_TOKEN:
250
+ console.print('[dim]Note: no TELEGRAM_BOT_TOKEN configured, results will only be visible via /schedules, not delivered to Telegram.[/dim]')
251
+ elif not state.telegram_chat_id:
252
+ console.print('[dim]Note: Telegram isn\'t linked yet - run /telegram once to link it so results get delivered there too.[/dim]')
253
+ return 'handled'
254
+
255
+ if cmd == '/init':
256
+ console.print('[bold cyan]Starting deep codebase analysis using the agent...[/bold cyan]')
257
+
258
+ analysis_prompt = f"""You are an expert coding agent initializing this project.
259
+
260
+ **Task**: Thoroughly explore the codebase and create/update `AGENTS.md` in the root with high-quality project context.
261
+
262
+ Current working directory: {Path.cwd()}
263
+
264
+ **Steps you must follow:**
265
+ 1. Use `Glob("**/*")`, `Glob("*.py")`, `Glob("**/*.md")` etc. to map the structure.
266
+ 2. Read key files: `README.md`, `pyproject.toml`, `requirements.txt`, main entry files, config files.
267
+ 3. Use `Grep` to find architecture clues, main functions, TODOs, important patterns.
268
+ 4. Summarize:
269
+ - Project purpose
270
+ - Tech stack
271
+ - Architecture
272
+ - Coding conventions
273
+ - Important files and their roles
274
+ 5. Write the final `AGENTS.md` using the `Write` tool.
275
+
276
+ Make `AGENTS.md` professional, specific to this codebase, and useful for future sessions. Do not be generic."""
277
+
278
+ # Use Task via dispatch (correct way)
279
+ from bardgent.tool_schemas import dispatch_tool
280
+ result = dispatch_tool('Task', {'prompt': analysis_prompt}, state)
281
+
282
+ console.print(Panel(str(result), title="✅ /init completed - Agent Analysis", border_style="green"))
283
+ return 'handled'
284
+
285
+ if cmd == '/exit':
286
+ console.print('Goodbye!')
287
+ import sys
288
+ sys.exit(0)
289
+
290
+ if cmd == '/summary':
291
+ do_summary_and_compact(state)
292
+ return 'handled'
293
+
294
+ if cmd == '/telegram':
295
+ if not config.TELEGRAM_BOT_TOKEN:
296
+ console.print('[bold red]No TELEGRAM_BOT_TOKEN found (check your .env file). Cannot enable Telegram.[/bold red]')
297
+ return 'handled'
298
+
299
+ if state.telegram_enabled:
300
+ state.telegram_enabled = False
301
+ console.print('[yellow]Telegram messaging turned off.[/yellow]')
302
+ log_event("TELEGRAM disabled")
303
+ return 'handled'
304
+
305
+ if not state.telegram_chat_id:
306
+ chat_id = discover_telegram_chat_id()
307
+ if not chat_id:
308
+ console.print('[bold red]No message received from the bot in time. '
309
+ 'Message your bot on Telegram, then run /telegram again.[/bold red]')
310
+ return 'handled'
311
+ state.telegram_chat_id = chat_id
312
+ _save_telegram_chat_id(chat_id)
313
+ console.print(f'[bold green]Telegram linked (chat_id={chat_id}).[/bold green]')
314
+ log_event(f"TELEGRAM linked chat_id={chat_id}")
315
+
316
+ state.telegram_enabled = True
317
+ console.print('[bold green]Telegram messaging turned on, final answers will be sent there too.[/bold green]')
318
+ log_event("TELEGRAM enabled")
319
+ return 'handled'
320
+
321
+ if cmd == '/skill install' or cmd.startswith('/skill install '):
322
+ parts = user_input.strip().split(maxsplit=2)
323
+ if len(parts) < 3:
324
+ console.print('[yellow]Usage: /skill install <github_url>[/yellow]')
325
+ console.print('[dim]Example: /skill install https://github.com/alirezarezvani/claude-skills[/dim]')
326
+ return 'handled'
327
+
328
+ github_url = parts[2].strip()
329
+ console.print(f'[cyan]Installing skill from {github_url}...[/cyan]')
330
+ result = install_skill_from_github(github_url)
331
+ console.print(result)
332
+ skills.refresh_skills()
333
+ return 'handled'
334
+
335
+ return None
bardgent/config.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ Central place for constants, paths, the OpenAI-compatible client, logging,
3
+ and anything else every other module needs.
4
+
5
+ IMPORTANT: `MODEL` is mutated at runtime (via /model). Other modules must
6
+ always read it as `config.MODEL` (attribute access) rather than
7
+ `from bardgent.config import MODEL`, or they'll keep a stale copy.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import platform
13
+ import logging
14
+ import threading
15
+ import datetime
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from openai import OpenAI
20
+ from rich.console import Console
21
+ from dotenv import load_dotenv
22
+
23
+ load_dotenv()
24
+ load_dotenv(Path.expanduser(Path("~/.bardgent/.env")))
25
+
26
+ console = Console()
27
+ console_lock = threading.RLock()
28
+
29
+ python_path = sys.executable
30
+ operating_system = platform.platform()
31
+ working_directory = os.getcwd()
32
+ home_directory = os.path.expanduser('~')
33
+
34
+ client = OpenAI(
35
+ base_url='https://integrate.api.nvidia.com/v1',
36
+ api_key=os.environ.get('GEMINI_API_KEY', '')
37
+ )
38
+
39
+ MODEL = 'nvidia/nemotron-3-ultra-550b-a55b'
40
+ TEMPERATURE = 0.2
41
+ MAX_ITERATIONS = 100
42
+ # Tool loops produce 2 messages per call (assistant tool_calls + tool result).
43
+ # 30 was far too low and mid-turn trims wiped the whole conversation.
44
+ MAX_HISTORY_MESSAGES = 120
45
+ # Always keep at least this many non-system messages after a trim.
46
+ MIN_HISTORY_MESSAGES = 6
47
+ MAX_TOOL_OUTPUT = 24_000
48
+ BASH_TIMEOUT_SECONDS = 60
49
+ # Default wait when the model calls Await() without a timeout.
50
+ BASH_AWAIT_DEFAULT_SECONDS = 30
51
+ # Hard cap on Await waits so a hung process cannot block the agent forever.
52
+ BASH_AWAIT_MAX_SECONDS = 600
53
+ # Default Read() window when limit is omitted but offset is set, and max lines
54
+ # returned in one Read call to keep tool results manageable.
55
+ READ_DEFAULT_LIMIT = 2_000
56
+ READ_MAX_LIMIT = 5_000
57
+ RESPONSE_TOKEN_RESERVE = 8_192
58
+ MAX_HISTORY_TOKENS = 180_000
59
+ AUTO_SUMMARY_TOKEN_THRESHOLD = 140_000
60
+ CONTEXT_WINDOW_TOKENS = 1_000_000
61
+
62
+ # Retry schedule for transient API failures.
63
+ MODEL_MAX_RETRIES = 10
64
+ MODEL_RETRY_DELAYS = [3, 5, 8, 13, 21, 30, 45, 60, 60, 60]
65
+
66
+ from openai import ( # noqa: E402 (kept together with the retry schedule above)
67
+ APIError,
68
+ APIConnectionError,
69
+ APITimeoutError,
70
+ RateLimitError,
71
+ InternalServerError,
72
+ BadRequestError,
73
+ AuthenticationError,
74
+ PermissionDeniedError,
75
+ NotFoundError,
76
+ UnprocessableEntityError,
77
+ )
78
+
79
+ # Transient / server-side only. 4xx client errors (bad history, bad schema,
80
+ # auth, etc.) must not be retried — they will fail the same way every time.
81
+ RETRYABLE_ERRORS = (APIConnectionError, APITimeoutError, RateLimitError, InternalServerError, APIError)
82
+ NON_RETRYABLE_ERRORS = (
83
+ BadRequestError,
84
+ AuthenticationError,
85
+ PermissionDeniedError,
86
+ NotFoundError,
87
+ UnprocessableEntityError,
88
+ )
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Modes: plan / normal / auto (Claude Code style)
92
+ # ---------------------------------------------------------------------------
93
+ VALID_MODES = ('plan', 'normal', 'auto')
94
+ READONLY_TOOLS = {
95
+ 'Read', 'Glob', 'Grep', 'WebSearch', 'Fetch',
96
+ 'read_memory', 'list_memory', 'Skill', 'list_skills',
97
+ # Observe background jobs without mutating the project.
98
+ 'ListJobs', 'Await',
99
+ # Observing scheduled tasks doesn't mutate anything either.
100
+ 'ListScheduledTasks',
101
+ }
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Directories
105
+ # ---------------------------------------------------------------------------
106
+ GLOBAL_DIR = Path.home() / '.bardgent'
107
+ GLOBAL_DIR.mkdir(exist_ok=True)
108
+
109
+ PERMISSIONS_DIR = Path.cwd() / '.bardgent'
110
+
111
+ SESSION_DIR = GLOBAL_DIR / "sessions"
112
+ SESSION_DIR.mkdir(exist_ok=True)
113
+ SESSION_PREFIX = "session_"
114
+ SUMMARY_PREFIX = '[Conversation summary so far]: '
115
+
116
+ BACKUP_DIR = GLOBAL_DIR / "backups"
117
+ BACKUP_DIR.mkdir(exist_ok=True)
118
+ last_backup = {}
119
+
120
+ MEMORY_FILE = GLOBAL_DIR / 'Bardgent.md'
121
+
122
+ # Background job log files (Bash run_in_background=true) live here. Must be
123
+ # defined after GLOBAL_DIR, since it's a subdirectory of it.
124
+ JOBS_DIR = GLOBAL_DIR / 'jobs'
125
+ JOBS_DIR.mkdir(exist_ok=True)
126
+
127
+ CHECKPOINT_REF = 'refs/bardgent/checkpoints'
128
+ CHECKPOINT_LOG = PERMISSIONS_DIR / 'checkpoints.json'
129
+ CHECKPOINT_INDEX_FILE = PERMISSIONS_DIR / 'checkpoint.index'
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Telegram
133
+ # ---------------------------------------------------------------------------
134
+ TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
135
+ TELEGRAM_API_BASE = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}'
136
+ TELEGRAM_CHATID_FILE = GLOBAL_DIR / 'telegram_chat_id.json'
137
+ TELEGRAM_MAX_LEN = 4000
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Logging
141
+ # ---------------------------------------------------------------------------
142
+ LOG_FILE = GLOBAL_DIR / 'bardgent.log'
143
+ logging.basicConfig(
144
+ filename=LOG_FILE,
145
+ level=logging.INFO,
146
+ format='%(asctime)s [%(levelname)s] %(message)s',
147
+ )
148
+
149
+
150
+ def log_event(msg):
151
+ logging.info(msg)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # <thought>/<think> tag stripping
156
+ # ---------------------------------------------------------------------------
157
+ THOUGHT_TAG_RE = re.compile(r"<(?:thought|think)>.*?</(?:thought|think)>", re.DOTALL | re.IGNORECASE)
158
+ THOUGHT_OPEN_RE = re.compile(r"<(?:thought|think)>", re.IGNORECASE)
159
+
160
+
161
+ def remove_thoughts(text):
162
+ if not text:
163
+ return text
164
+ text = THOUGHT_TAG_RE.sub("", text)
165
+ m = THOUGHT_OPEN_RE.search(text)
166
+ if m:
167
+ text = text[:m.start()]
168
+ return text.strip()
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Misc runtime constants
173
+ # ---------------------------------------------------------------------------
174
+ CWD_MARKER = '__BARDGENT_CWD__'
175
+ IS_WARP = os.environ.get('TERM_PROGRAM') == 'WarpTerminal'
176
+
177
+ DATETIME = datetime.datetime.now().astimezone()
178
+
179
+
180
+ def get_system_info():
181
+ """Fresh system info for system prompts (CWD can change via Bash)."""
182
+ return f"""[CRITICAL SYSTEM INFO]:
183
+ - Python Executable Path: {python_path}
184
+ - Operating System: {operating_system}
185
+ - Current Working Directory: {os.getcwd()}
186
+ - User Home Directory: {home_directory}"""
187
+
188
+
189
+ # Kept for backward compatibility; prefer get_system_info() in prompts.
190
+ SYSTEM_INFO = get_system_info()