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 +3 -0
- bardgent/__main__.py +5 -0
- bardgent/checkpoints.py +132 -0
- bardgent/commands.py +335 -0
- bardgent/config.py +190 -0
- bardgent/exec_tools.py +246 -0
- bardgent/fs_tools.py +386 -0
- bardgent/main.py +258 -0
- bardgent/memory.py +75 -0
- bardgent/model.py +218 -0
- bardgent/permissions.py +55 -0
- bardgent/project_instructions.py +91 -0
- bardgent/scheduler.py +1003 -0
- bardgent/session.py +463 -0
- bardgent/skills.py +275 -0
- bardgent/state.py +71 -0
- bardgent/status_bar.py +262 -0
- bardgent/subagents.py +220 -0
- bardgent/system_prompt.py +249 -0
- bardgent/telegram.py +183 -0
- bardgent/tool_schemas.py +356 -0
- bardgent/ui.py +11 -0
- bardgent/utils.py +31 -0
- bardgent/web_tools.py +69 -0
- bardgent-1.0.0.dist-info/METADATA +113 -0
- bardgent-1.0.0.dist-info/RECORD +30 -0
- bardgent-1.0.0.dist-info/WHEEL +5 -0
- bardgent-1.0.0.dist-info/entry_points.txt +2 -0
- bardgent-1.0.0.dist-info/licenses/LICENSE +21 -0
- bardgent-1.0.0.dist-info/top_level.txt +1 -0
bardgent/__init__.py
ADDED
bardgent/__main__.py
ADDED
bardgent/checkpoints.py
ADDED
|
@@ -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()
|