bone-agent 1.3.3 → 2.0.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/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -184
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -141
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -36
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -985
- package/src/core/chat_manager.py +0 -1564
- package/src/core/config_manager.py +0 -253
- package/src/core/cron.py +0 -582
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/retry.py +0 -71
- package/src/core/sub_agent.py +0 -326
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -778
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -171
- package/src/llm/config.py +0 -492
- package/src/llm/prompts.py +0 -489
- package/src/llm/providers.py +0 -436
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -384
- package/src/tools/__init__.py +0 -212
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -545
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -105
- package/src/tools/helpers/base.py +0 -550
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -231
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -232
- package/src/tools/helpers/plugin_manifest.py +0 -156
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -189
- package/src/tools/rg_search.py +0 -460
- package/src/tools/search_plugins.py +0 -109
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -2809
- package/src/ui/displays.py +0 -214
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -647
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -215
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -158
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -191
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -191
- package/src/utils/web_search.py +0 -173
package/src/core/cron.py
DELETED
|
@@ -1,582 +0,0 @@
|
|
|
1
|
-
"""Cron scheduler for bone-agent.
|
|
2
|
-
|
|
3
|
-
Provides natural-language scheduled job execution integrated into the
|
|
4
|
-
bone-agent agentic loop. Jobs are defined in ~/.bone/cron/jobs.yaml and
|
|
5
|
-
run as background threads while bone-agent is active.
|
|
6
|
-
|
|
7
|
-
External trigger: bone-agent --cron-run <job-id>
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
import re
|
|
12
|
-
import threading
|
|
13
|
-
from dataclasses import dataclass, field, asdict
|
|
14
|
-
from datetime import datetime
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from typing import Optional
|
|
17
|
-
|
|
18
|
-
import yaml
|
|
19
|
-
|
|
20
|
-
logger = logging.getLogger(__name__)
|
|
21
|
-
|
|
22
|
-
# ── Paths ────────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
def _get_cron_dir() -> Path:
|
|
25
|
-
"""Return ~/.bone/cron/ directory, creating it if needed."""
|
|
26
|
-
cron_dir = Path.home() / ".bone" / "cron"
|
|
27
|
-
cron_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
-
return cron_dir
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _get_jobs_path() -> Path:
|
|
32
|
-
return _get_cron_dir() / "jobs.yaml"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _get_log_dir() -> Path:
|
|
36
|
-
log_dir = _get_cron_dir() / "logs"
|
|
37
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
-
return log_dir
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# ── Data model ───────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
@dataclass
|
|
44
|
-
class CronJob:
|
|
45
|
-
"""A single cron job definition."""
|
|
46
|
-
id: str
|
|
47
|
-
schedule: str # Natural language: "every 5 minutes", "weekdays at 8am"
|
|
48
|
-
command: str # The prompt to feed into the agentic loop
|
|
49
|
-
enabled: bool = True
|
|
50
|
-
description: str = ""
|
|
51
|
-
last_run: Optional[str] = None # ISO timestamp of last successful run
|
|
52
|
-
last_status: Optional[str] = None # "ok" | "error"
|
|
53
|
-
created: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
54
|
-
|
|
55
|
-
def to_dict(self) -> dict:
|
|
56
|
-
return asdict(self)
|
|
57
|
-
|
|
58
|
-
@classmethod
|
|
59
|
-
def from_dict(cls, d: dict) -> "CronJob":
|
|
60
|
-
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# ── Schedule parser ──────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
# Patterns we support (ordered by specificity):
|
|
66
|
-
# "every N minutes|hours|days"
|
|
67
|
-
# "daily at HH:MM"
|
|
68
|
-
# "weekdays at HH:MM"
|
|
69
|
-
# "mondays|tuesdays|... at HH:MM"
|
|
70
|
-
# "HH:MM" (daily shorthand)
|
|
71
|
-
|
|
72
|
-
def _extract_time(m: re.Match) -> dict:
|
|
73
|
-
"""Extract hour/minute from a regex match with hour, minute, ampm groups."""
|
|
74
|
-
hour = int(m.group("hour"))
|
|
75
|
-
minute = int(m.group("minute") or 0)
|
|
76
|
-
ampm = (m.group("ampm") or "").lower()
|
|
77
|
-
if ampm == "am" and hour == 12:
|
|
78
|
-
hour = 0
|
|
79
|
-
elif ampm == "pm" and hour != 12:
|
|
80
|
-
hour += 12
|
|
81
|
-
return {"hour": hour, "minute": minute}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
_SCHEDULE_PATTERNS = [
|
|
85
|
-
# every N <unit>
|
|
86
|
-
(re.compile(
|
|
87
|
-
r"^every\s+(?P<n>\d+)\s*(?P<unit>minute|minutes|min|m|hour|hours|hr|h|day|days|d)s?\s*$",
|
|
88
|
-
re.IGNORECASE
|
|
89
|
-
), "interval"),
|
|
90
|
-
# every day/night/morning/afternoon/evening [at] <time>
|
|
91
|
-
(re.compile(
|
|
92
|
-
r"^every\s+(?:day|night|morning|afternoon|evening)s?\s+(?:at\s+)?(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
|
|
93
|
-
re.IGNORECASE
|
|
94
|
-
), "daily"),
|
|
95
|
-
# weekdays at <time>
|
|
96
|
-
(re.compile(
|
|
97
|
-
r"^weekdays?\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
|
|
98
|
-
re.IGNORECASE
|
|
99
|
-
), "weekdays"),
|
|
100
|
-
# specific day at <time>
|
|
101
|
-
(re.compile(
|
|
102
|
-
r"^(?P<day>monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
|
|
103
|
-
re.IGNORECASE
|
|
104
|
-
), "day_of_week"),
|
|
105
|
-
# daily at <time>
|
|
106
|
-
(re.compile(
|
|
107
|
-
r"^daily\s+at\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>[ap]m)?\s*$",
|
|
108
|
-
re.IGNORECASE
|
|
109
|
-
), "daily"),
|
|
110
|
-
# bare HH:MM or HHam/pm (treated as daily)
|
|
111
|
-
(re.compile(
|
|
112
|
-
r"^(?P<hour>\d{1,2}):(?P<minute>\d{2})\s*(?P<ampm>[ap]m)?\s*$"
|
|
113
|
-
), "daily"),
|
|
114
|
-
]
|
|
115
|
-
|
|
116
|
-
_DAY_MAP = {
|
|
117
|
-
"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
|
|
118
|
-
"friday": 4, "saturday": 5, "sunday": 6,
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def parse_schedule(schedule: str) -> dict:
|
|
123
|
-
"""Parse a natural-language schedule into a structured spec.
|
|
124
|
-
|
|
125
|
-
Returns dict with:
|
|
126
|
-
type: "interval" | "daily" | "weekdays" | "day_of_week"
|
|
127
|
-
For interval: interval_seconds (int)
|
|
128
|
-
For time-based: hour (int), minute (int)
|
|
129
|
-
For day_of_week: weekday (0=Mon..6=Sun)
|
|
130
|
-
|
|
131
|
-
Raises ValueError if schedule can't be parsed.
|
|
132
|
-
"""
|
|
133
|
-
schedule = schedule.strip()
|
|
134
|
-
for pattern, sched_type in _SCHEDULE_PATTERNS:
|
|
135
|
-
m = pattern.match(schedule)
|
|
136
|
-
if m:
|
|
137
|
-
if sched_type == "interval":
|
|
138
|
-
n = int(m.group("n"))
|
|
139
|
-
if n <= 0:
|
|
140
|
-
raise ValueError(
|
|
141
|
-
f"Interval must be at least 1: 'every {n} {m.group('unit')}'"
|
|
142
|
-
)
|
|
143
|
-
unit = m.group("unit").lower()
|
|
144
|
-
if unit in ("minute", "minutes", "min", "m"):
|
|
145
|
-
return {"type": "interval", "interval_seconds": n * 60}
|
|
146
|
-
elif unit in ("hour", "hours", "hr", "h"):
|
|
147
|
-
return {"type": "interval", "interval_seconds": n * 3600}
|
|
148
|
-
elif unit in ("day", "days", "d"):
|
|
149
|
-
return {"type": "interval", "interval_seconds": n * 86400}
|
|
150
|
-
elif sched_type == "weekdays":
|
|
151
|
-
t = _extract_time(m)
|
|
152
|
-
return {"type": "weekdays", **t}
|
|
153
|
-
elif sched_type == "day_of_week":
|
|
154
|
-
t = _extract_time(m)
|
|
155
|
-
return {
|
|
156
|
-
"type": "day_of_week",
|
|
157
|
-
"weekday": _DAY_MAP[m.group("day").lower()],
|
|
158
|
-
**t,
|
|
159
|
-
}
|
|
160
|
-
elif sched_type == "daily":
|
|
161
|
-
t = _extract_time(m)
|
|
162
|
-
return {"type": "daily", **t}
|
|
163
|
-
|
|
164
|
-
raise ValueError(
|
|
165
|
-
f"Cannot parse schedule: '{schedule}'. "
|
|
166
|
-
f"Examples: 'every 5 minutes', 'every hour', 'daily at 8am', "
|
|
167
|
-
f"'every day at 5am', 'weekdays at 9:00', 'mondays at 10:30pm'"
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _should_run(spec: dict, last_run: Optional[datetime], now: datetime) -> bool:
|
|
172
|
-
"""Check if a job with the given schedule spec should run now."""
|
|
173
|
-
if spec["type"] == "interval":
|
|
174
|
-
interval = spec["interval_seconds"]
|
|
175
|
-
if last_run is None:
|
|
176
|
-
return True
|
|
177
|
-
return (now - last_run).total_seconds() >= interval
|
|
178
|
-
|
|
179
|
-
elif spec["type"] in ("daily", "weekdays", "day_of_week"):
|
|
180
|
-
# Time-based: check if we've passed the target time today
|
|
181
|
-
# and haven't already run today
|
|
182
|
-
target_time = now.replace(hour=spec["hour"], minute=spec["minute"], second=0, microsecond=0)
|
|
183
|
-
|
|
184
|
-
# Check day-of-week constraints
|
|
185
|
-
if spec["type"] == "weekdays" and now.weekday() >= 5:
|
|
186
|
-
return False
|
|
187
|
-
if spec["type"] == "day_of_week" and now.weekday() != spec["weekday"]:
|
|
188
|
-
return False
|
|
189
|
-
|
|
190
|
-
# Has the target time passed today?
|
|
191
|
-
if now < target_time:
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
|
-
# Did we already run today (after target time)?
|
|
195
|
-
if last_run is not None and last_run >= target_time:
|
|
196
|
-
return False
|
|
197
|
-
|
|
198
|
-
return True
|
|
199
|
-
|
|
200
|
-
return False
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
# ── Config persistence ───────────────────────────────────────────────────
|
|
204
|
-
|
|
205
|
-
class CronConfig:
|
|
206
|
-
"""Load/save cron jobs from ~/.bone/cron/jobs.yaml."""
|
|
207
|
-
|
|
208
|
-
def __init__(self):
|
|
209
|
-
self._path = _get_jobs_path()
|
|
210
|
-
self.jobs: dict[str, CronJob] = {}
|
|
211
|
-
self.load()
|
|
212
|
-
|
|
213
|
-
def load(self):
|
|
214
|
-
self.jobs.clear()
|
|
215
|
-
if not self._path.exists():
|
|
216
|
-
return
|
|
217
|
-
try:
|
|
218
|
-
data = yaml.safe_load(self._path.read_text(encoding="utf-8")) or {}
|
|
219
|
-
for job_dict in data.get("jobs", []):
|
|
220
|
-
job = CronJob.from_dict(job_dict)
|
|
221
|
-
self.jobs[job.id] = job
|
|
222
|
-
except Exception as e:
|
|
223
|
-
logger.warning("Failed to load cron config: %s", e)
|
|
224
|
-
|
|
225
|
-
def save(self):
|
|
226
|
-
data = {"jobs": [j.to_dict() for j in self.jobs.values()]}
|
|
227
|
-
self._path.write_text(
|
|
228
|
-
yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
|
229
|
-
encoding="utf-8",
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
def add_job(self, job: CronJob):
|
|
233
|
-
self.jobs[job.id] = job
|
|
234
|
-
self.save()
|
|
235
|
-
|
|
236
|
-
def remove_job(self, job_id: str) -> bool:
|
|
237
|
-
if job_id in self.jobs:
|
|
238
|
-
del self.jobs[job_id]
|
|
239
|
-
self.save()
|
|
240
|
-
return True
|
|
241
|
-
return False
|
|
242
|
-
|
|
243
|
-
def get_job(self, job_id: str) -> Optional[CronJob]:
|
|
244
|
-
return self.jobs.get(job_id)
|
|
245
|
-
|
|
246
|
-
def update_job(self, job_id: str, **kwargs):
|
|
247
|
-
job = self.jobs.get(job_id)
|
|
248
|
-
if job:
|
|
249
|
-
for k, v in kwargs.items():
|
|
250
|
-
if k in job.__dataclass_fields__:
|
|
251
|
-
setattr(job, k, v)
|
|
252
|
-
self.save()
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
# ── Scheduler ────────────────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
def _write_job_log(job: CronJob, output: str, error: bool):
|
|
258
|
-
"""Append job output to a log file."""
|
|
259
|
-
log_dir = _get_log_dir()
|
|
260
|
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
261
|
-
log_file = log_dir / f"{job.id}_{timestamp}.log"
|
|
262
|
-
try:
|
|
263
|
-
log_file.write_text(
|
|
264
|
-
f"Job: {job.id}\n"
|
|
265
|
-
f"Schedule: {job.schedule}\n"
|
|
266
|
-
f"Ran at: {datetime.now().isoformat()}\n"
|
|
267
|
-
f"Status: {'ERROR' if error else 'OK'}\n"
|
|
268
|
-
f"{'─' * 40}\n"
|
|
269
|
-
f"{output}\n",
|
|
270
|
-
encoding="utf-8",
|
|
271
|
-
)
|
|
272
|
-
except Exception as e:
|
|
273
|
-
logger.error("Failed to write cron log: %s", e)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
# ── Dream job (auto-seeded) ─────────────────────────────────────────────
|
|
277
|
-
|
|
278
|
-
DREAM_JOB_ID = "dream"
|
|
279
|
-
DREAM_JOB_SCHEDULE = "daily at 4am"
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def ensure_dream_job(config: CronConfig) -> None:
|
|
283
|
-
"""Sync the dream memory job with the DREAM_SETTINGS.enabled config.
|
|
284
|
-
|
|
285
|
-
- Enabled and missing → seed the job
|
|
286
|
-
- Enabled and present → no-op
|
|
287
|
-
- Disabled and present → remove the job
|
|
288
|
-
- Disabled and missing → no-op
|
|
289
|
-
"""
|
|
290
|
-
from utils.settings import dream_settings
|
|
291
|
-
from llm.config import MEMORY_SETTINGS
|
|
292
|
-
|
|
293
|
-
if dream_settings.enabled and MEMORY_SETTINGS.get("enabled", True):
|
|
294
|
-
if DREAM_JOB_ID in config.jobs:
|
|
295
|
-
return
|
|
296
|
-
job = CronJob(
|
|
297
|
-
id=DREAM_JOB_ID,
|
|
298
|
-
schedule=DREAM_JOB_SCHEDULE,
|
|
299
|
-
command="Run the dream memory consolidation process. Read yesterday's user messages from ~/.bone/conversations/, analyze them for preferences and patterns, and consolidate into memory files. Then clean up JSONL files older than 7 days.",
|
|
300
|
-
enabled=True,
|
|
301
|
-
description="Dream memory consolidation — scans user messages and updates memories",
|
|
302
|
-
)
|
|
303
|
-
config.add_job(job)
|
|
304
|
-
logger.info("Seeded dream memory cron job (daily at 4am)")
|
|
305
|
-
else:
|
|
306
|
-
if DREAM_JOB_ID in config.jobs:
|
|
307
|
-
config.remove_job(DREAM_JOB_ID)
|
|
308
|
-
logger.info("Removed dream memory cron job (disabled in config)")
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def run_single_job(job: CronJob, console=None, interactive=False) -> None:
|
|
312
|
-
"""Execute a single cron job without requiring a CronScheduler instance.
|
|
313
|
-
|
|
314
|
-
Used by the /cron run subcommand (interactive=True) and run_job_headless
|
|
315
|
-
(interactive=False, default).
|
|
316
|
-
|
|
317
|
-
Args:
|
|
318
|
-
job: The CronJob to execute.
|
|
319
|
-
console: Optional Rich console for interactive output.
|
|
320
|
-
interactive: If True, use the real console for interactive command
|
|
321
|
-
approval (test-run mode). Commands are auto-saved to the allow list.
|
|
322
|
-
If False, use a buffer console (scheduled mode). Unlisted commands
|
|
323
|
-
are blocked.
|
|
324
|
-
"""
|
|
325
|
-
from rich.console import Console as RichConsole
|
|
326
|
-
from io import StringIO
|
|
327
|
-
from core.cron_allowlist import CronAllowlist
|
|
328
|
-
|
|
329
|
-
# Capture output for logging
|
|
330
|
-
output_buf = StringIO()
|
|
331
|
-
|
|
332
|
-
if interactive and console is not None:
|
|
333
|
-
# Interactive test run: use the real console so user can approve commands
|
|
334
|
-
job_console = console
|
|
335
|
-
else:
|
|
336
|
-
# Scheduled run: use a buffer console (no interactive prompts)
|
|
337
|
-
job_console = RichConsole(
|
|
338
|
-
file=output_buf,
|
|
339
|
-
force_terminal=True,
|
|
340
|
-
width=80,
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
try:
|
|
344
|
-
from core.chat_manager import ChatManager
|
|
345
|
-
from core.agentic import AgenticOrchestrator
|
|
346
|
-
from utils.paths import RG_EXE_PATH
|
|
347
|
-
from tools.loader import load_all_tools
|
|
348
|
-
from llm.config import TOOLS_ENABLED
|
|
349
|
-
|
|
350
|
-
if not TOOLS_ENABLED:
|
|
351
|
-
raise RuntimeError("Cron requires tools to be enabled")
|
|
352
|
-
|
|
353
|
-
# Ensure tools are loaded
|
|
354
|
-
load_all_tools()
|
|
355
|
-
|
|
356
|
-
# Fresh ChatManager for this job
|
|
357
|
-
chat_manager = ChatManager()
|
|
358
|
-
|
|
359
|
-
# Dream job: auto-approve edits and run cleanup before agent starts
|
|
360
|
-
if job.id == DREAM_JOB_ID:
|
|
361
|
-
chat_manager.approve_mode = "accept_edits"
|
|
362
|
-
from utils.user_message_logger import UserMessageLogger
|
|
363
|
-
removed = UserMessageLogger.cleanup_old_files()
|
|
364
|
-
if removed:
|
|
365
|
-
logger.info("Dream job: removed %d old JSONL files", removed)
|
|
366
|
-
|
|
367
|
-
# Build the prompt — load dream.md for dream job, else use command field
|
|
368
|
-
if job.id == DREAM_JOB_ID:
|
|
369
|
-
dream_prompt_path = Path(__file__).resolve().parents[2] / "prompts" / "main" / "dream.md"
|
|
370
|
-
if dream_prompt_path.is_file():
|
|
371
|
-
command_text = dream_prompt_path.read_text(encoding="utf-8").strip()
|
|
372
|
-
else:
|
|
373
|
-
command_text = job.command
|
|
374
|
-
else:
|
|
375
|
-
command_text = job.command
|
|
376
|
-
|
|
377
|
-
prompt = (
|
|
378
|
-
f"[Cron job: {job.id}]\n"
|
|
379
|
-
f"{command_text}"
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
repo_root = Path.cwd().resolve()
|
|
383
|
-
|
|
384
|
-
# Set up cron allow list for command gating
|
|
385
|
-
allowlist = CronAllowlist()
|
|
386
|
-
|
|
387
|
-
orchestrator = AgenticOrchestrator(
|
|
388
|
-
chat_manager=chat_manager,
|
|
389
|
-
repo_root=repo_root,
|
|
390
|
-
rg_exe_path=RG_EXE_PATH,
|
|
391
|
-
console=job_console,
|
|
392
|
-
debug_mode=False,
|
|
393
|
-
suppress_result_display=False,
|
|
394
|
-
cron_job_id=job.id,
|
|
395
|
-
cron_allowlist=allowlist,
|
|
396
|
-
cron_interactive=interactive,
|
|
397
|
-
)
|
|
398
|
-
orchestrator.run(prompt)
|
|
399
|
-
|
|
400
|
-
# Log output
|
|
401
|
-
_write_job_log(job, output_buf.getvalue(), error=False)
|
|
402
|
-
|
|
403
|
-
except Exception as e:
|
|
404
|
-
_write_job_log(job, str(e), error=True)
|
|
405
|
-
raise
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
class CronScheduler:
|
|
409
|
-
"""Background scheduler that runs cron jobs via the agentic loop.
|
|
410
|
-
|
|
411
|
-
Starts a daemon thread that wakes every 30 seconds to check if any
|
|
412
|
-
jobs are due. When a job fires, it creates a fresh ChatManager
|
|
413
|
-
(to avoid polluting the user's conversation) and runs the job's
|
|
414
|
-
command through the agentic orchestrator.
|
|
415
|
-
"""
|
|
416
|
-
|
|
417
|
-
CHECK_INTERVAL = 30 # seconds between schedule checks
|
|
418
|
-
|
|
419
|
-
def __init__(self, console=None):
|
|
420
|
-
self.config = CronConfig()
|
|
421
|
-
self.console = console
|
|
422
|
-
self._thread: Optional[threading.Thread] = None
|
|
423
|
-
self._stop_event = threading.Event()
|
|
424
|
-
self._lock = threading.Lock()
|
|
425
|
-
self._running = False
|
|
426
|
-
|
|
427
|
-
# Auto-seed the dream memory job if it doesn't exist
|
|
428
|
-
ensure_dream_job(self.config)
|
|
429
|
-
|
|
430
|
-
def start(self):
|
|
431
|
-
"""Start the cron scheduler background thread."""
|
|
432
|
-
enabled_jobs = [j for j in self.config.jobs.values() if j.enabled]
|
|
433
|
-
|
|
434
|
-
# Validate all schedules on startup
|
|
435
|
-
for job in enabled_jobs:
|
|
436
|
-
try:
|
|
437
|
-
parse_schedule(job.schedule)
|
|
438
|
-
except ValueError as e:
|
|
439
|
-
logger.warning("Cron job '%s' has invalid schedule: %s", job.id, e)
|
|
440
|
-
|
|
441
|
-
self._stop_event.clear()
|
|
442
|
-
self._thread = None
|
|
443
|
-
try:
|
|
444
|
-
self._running = True
|
|
445
|
-
self._thread = threading.Thread(
|
|
446
|
-
target=self._run_loop,
|
|
447
|
-
name="cron-scheduler",
|
|
448
|
-
daemon=True,
|
|
449
|
-
)
|
|
450
|
-
self._thread.start()
|
|
451
|
-
except Exception:
|
|
452
|
-
self._running = False
|
|
453
|
-
self._thread = None
|
|
454
|
-
raise
|
|
455
|
-
logger.info("Cron scheduler started with %d job(s)", len(enabled_jobs))
|
|
456
|
-
|
|
457
|
-
def stop(self):
|
|
458
|
-
"""Signal the scheduler thread to stop and wait for it."""
|
|
459
|
-
self._stop_event.set()
|
|
460
|
-
self._running = False
|
|
461
|
-
if self._thread and self._thread.is_alive():
|
|
462
|
-
self._thread.join(timeout=10)
|
|
463
|
-
logger.info("Cron scheduler stopped")
|
|
464
|
-
|
|
465
|
-
def reload(self):
|
|
466
|
-
"""Reload config from disk (e.g. after /cron add/remove)."""
|
|
467
|
-
with self._lock:
|
|
468
|
-
self.config.load()
|
|
469
|
-
|
|
470
|
-
def execute_job(self, job: CronJob):
|
|
471
|
-
"""Execute a single cron job. Public wrapper around run_single_job."""
|
|
472
|
-
run_single_job(job, console=self.console)
|
|
473
|
-
|
|
474
|
-
def _run_loop(self):
|
|
475
|
-
"""Main scheduler loop — runs in background thread."""
|
|
476
|
-
# Track last run times from persisted state
|
|
477
|
-
last_runs: dict[str, datetime] = {}
|
|
478
|
-
for job in self.config.jobs.values():
|
|
479
|
-
if job.last_run:
|
|
480
|
-
try:
|
|
481
|
-
last_runs[job.id] = datetime.fromisoformat(job.last_run)
|
|
482
|
-
except (ValueError, TypeError):
|
|
483
|
-
pass
|
|
484
|
-
|
|
485
|
-
while not self._stop_event.is_set():
|
|
486
|
-
now = datetime.now()
|
|
487
|
-
|
|
488
|
-
# Collect due jobs under lock, then execute outside
|
|
489
|
-
due_jobs: list[CronJob] = []
|
|
490
|
-
with self._lock:
|
|
491
|
-
for job in list(self.config.jobs.values()):
|
|
492
|
-
if not job.enabled:
|
|
493
|
-
continue
|
|
494
|
-
try:
|
|
495
|
-
spec = parse_schedule(job.schedule)
|
|
496
|
-
except ValueError:
|
|
497
|
-
continue
|
|
498
|
-
|
|
499
|
-
last_run = last_runs.get(job.id)
|
|
500
|
-
if _should_run(spec, last_run, now):
|
|
501
|
-
due_jobs.append(job)
|
|
502
|
-
|
|
503
|
-
# Execute jobs outside the lock so scheduling isn't blocked
|
|
504
|
-
for job in due_jobs:
|
|
505
|
-
logger.info("Cron firing job '%s'", job.id)
|
|
506
|
-
try:
|
|
507
|
-
self.execute_job(job)
|
|
508
|
-
job.last_run = now.isoformat()
|
|
509
|
-
job.last_status = "ok"
|
|
510
|
-
last_runs[job.id] = now
|
|
511
|
-
except Exception as e:
|
|
512
|
-
logger.error("Cron job '%s' failed: %s", job.id, e)
|
|
513
|
-
job.last_status = "error"
|
|
514
|
-
job.last_run = now.isoformat()
|
|
515
|
-
last_runs[job.id] = now
|
|
516
|
-
finally:
|
|
517
|
-
with self._lock:
|
|
518
|
-
# Snapshot only the current job's updated state
|
|
519
|
-
lr, ls = job.last_run, job.last_status
|
|
520
|
-
|
|
521
|
-
# Reload to pick up any /cron changes made while
|
|
522
|
-
# the job was running, so we don't overwrite them
|
|
523
|
-
self.config.load()
|
|
524
|
-
|
|
525
|
-
# Merge our last_run/last_status back onto reloaded job
|
|
526
|
-
reloaded = self.config.jobs.get(job.id)
|
|
527
|
-
if reloaded:
|
|
528
|
-
reloaded.last_run = lr
|
|
529
|
-
reloaded.last_status = ls
|
|
530
|
-
|
|
531
|
-
self.config.save()
|
|
532
|
-
|
|
533
|
-
self._stop_event.wait(self.CHECK_INTERVAL)
|
|
534
|
-
|
|
535
|
-
# Reload config from disk so /cron add/remove changes are picked up
|
|
536
|
-
with self._lock:
|
|
537
|
-
self.config.load()
|
|
538
|
-
# Sync in-memory last_runs from reloaded config
|
|
539
|
-
# (picks up /cron run or --cron-run updates)
|
|
540
|
-
for job in self.config.jobs.values():
|
|
541
|
-
if job.id not in last_runs and job.last_run:
|
|
542
|
-
try:
|
|
543
|
-
last_runs[job.id] = datetime.fromisoformat(job.last_run)
|
|
544
|
-
except (ValueError, TypeError):
|
|
545
|
-
pass
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
# ── External runner (for --cron-run) ────────────────────────────────────
|
|
549
|
-
|
|
550
|
-
def run_job_headless(job_id: str) -> int:
|
|
551
|
-
"""Run a single job headlessly (no interactive session).
|
|
552
|
-
|
|
553
|
-
Used by `bone-agent --cron-run <job-id>`.
|
|
554
|
-
|
|
555
|
-
Returns 0 on success, 1 on failure.
|
|
556
|
-
"""
|
|
557
|
-
config = CronConfig()
|
|
558
|
-
job = config.get_job(job_id)
|
|
559
|
-
if not job:
|
|
560
|
-
print(f"Error: cron job '{job_id}' not found")
|
|
561
|
-
return 1
|
|
562
|
-
|
|
563
|
-
print(f"Running cron job: {job.id}")
|
|
564
|
-
print(f"Schedule: {job.schedule}")
|
|
565
|
-
print(f"Command: {job.command}")
|
|
566
|
-
print("─" * 40)
|
|
567
|
-
|
|
568
|
-
try:
|
|
569
|
-
run_single_job(job)
|
|
570
|
-
job.last_run = datetime.now().isoformat()
|
|
571
|
-
job.last_status = "ok"
|
|
572
|
-
config.save()
|
|
573
|
-
print("─" * 40)
|
|
574
|
-
print("Job completed successfully.")
|
|
575
|
-
return 0
|
|
576
|
-
except Exception as e:
|
|
577
|
-
job.last_run = datetime.now().isoformat()
|
|
578
|
-
job.last_status = "error"
|
|
579
|
-
config.save()
|
|
580
|
-
print(f"─" * 40)
|
|
581
|
-
print(f"Job failed: {e}")
|
|
582
|
-
return 1
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
"""Per-job command allow list for cron jobs.
|
|
2
|
-
|
|
3
|
-
Stores approved shell commands per job ID in ~/.bone/cron/allowed_commands.yaml.
|
|
4
|
-
During scheduled runs, only commands on the allow list (plus global SAFE_COMMAND_RULES)
|
|
5
|
-
are auto-approved. Unlisted commands are blocked with agent feedback.
|
|
6
|
-
|
|
7
|
-
During interactive test runs (/cron run), accepted commands are auto-saved.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Optional
|
|
13
|
-
|
|
14
|
-
import yaml
|
|
15
|
-
|
|
16
|
-
logger = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _get_allowed_commands_path() -> Path:
|
|
20
|
-
"""Return ~/.bone/cron/allowed_commands.yaml."""
|
|
21
|
-
cron_dir = Path.home() / ".bone" / "cron"
|
|
22
|
-
cron_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
-
return cron_dir / "allowed_commands.yaml"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class CronAllowlist:
|
|
27
|
-
"""Manages per-job shell command allow lists.
|
|
28
|
-
|
|
29
|
-
Storage format (YAML):
|
|
30
|
-
jobs:
|
|
31
|
-
my_job:
|
|
32
|
-
commands:
|
|
33
|
-
- "git add -A"
|
|
34
|
-
- "git commit -m 'auto commit'"
|
|
35
|
-
- "git push origin main"
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
def __init__(self):
|
|
39
|
-
self._path = _get_allowed_commands_path()
|
|
40
|
-
self._jobs: dict[str, list[str]] = {}
|
|
41
|
-
self.load()
|
|
42
|
-
|
|
43
|
-
def load(self):
|
|
44
|
-
"""Load allow list from disk."""
|
|
45
|
-
self._jobs.clear()
|
|
46
|
-
if not self._path.exists():
|
|
47
|
-
return
|
|
48
|
-
try:
|
|
49
|
-
data = yaml.safe_load(self._path.read_text(encoding="utf-8")) or {}
|
|
50
|
-
for job_id, entry in data.get("jobs", {}).items():
|
|
51
|
-
self._jobs[job_id] = entry.get("commands", [])
|
|
52
|
-
except Exception as e:
|
|
53
|
-
logger.warning("Failed to load cron allow list: %s", e)
|
|
54
|
-
|
|
55
|
-
def save(self):
|
|
56
|
-
"""Persist allow list to disk."""
|
|
57
|
-
data = {
|
|
58
|
-
"jobs": {
|
|
59
|
-
job_id: {"commands": cmds}
|
|
60
|
-
for job_id, cmds in self._jobs.items()
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
self._path.write_text(
|
|
64
|
-
yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
|
65
|
-
encoding="utf-8",
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
def get_commands(self, job_id: str) -> list[str]:
|
|
69
|
-
"""Return the list of allowed commands for a job."""
|
|
70
|
-
return list(self._jobs.get(job_id, []))
|
|
71
|
-
|
|
72
|
-
def add_command(self, job_id: str, command: str) -> bool:
|
|
73
|
-
"""Add a command to a job's allow list. Returns True if newly added."""
|
|
74
|
-
command = command.strip()
|
|
75
|
-
if not command:
|
|
76
|
-
return False
|
|
77
|
-
if job_id not in self._jobs:
|
|
78
|
-
self._jobs[job_id] = []
|
|
79
|
-
if command not in self._jobs[job_id]:
|
|
80
|
-
self._jobs[job_id].append(command)
|
|
81
|
-
self.save()
|
|
82
|
-
return True
|
|
83
|
-
return False
|
|
84
|
-
|
|
85
|
-
def remove_command(self, job_id: str, command: str) -> bool:
|
|
86
|
-
"""Remove a command from a job's allow list. Returns True if removed."""
|
|
87
|
-
command = command.strip()
|
|
88
|
-
if job_id in self._jobs and command in self._jobs[job_id]:
|
|
89
|
-
self._jobs[job_id].remove(command)
|
|
90
|
-
if not self._jobs[job_id]:
|
|
91
|
-
del self._jobs[job_id]
|
|
92
|
-
self.save()
|
|
93
|
-
return True
|
|
94
|
-
return False
|
|
95
|
-
|
|
96
|
-
def clear_job(self, job_id: str) -> int:
|
|
97
|
-
"""Remove all commands for a job. Returns count of removed commands."""
|
|
98
|
-
if job_id not in self._jobs:
|
|
99
|
-
return 0
|
|
100
|
-
count = len(self._jobs[job_id])
|
|
101
|
-
del self._jobs[job_id]
|
|
102
|
-
self.save()
|
|
103
|
-
return count
|
|
104
|
-
|
|
105
|
-
def is_allowed(self, job_id: str, command: str) -> bool:
|
|
106
|
-
"""Check if a command is on the allow list for a job.
|
|
107
|
-
|
|
108
|
-
Matching is exact (normalized whitespace). For commands that
|
|
109
|
-
are prefixes of list entries or vice versa, we don't match --
|
|
110
|
-
the user should approve the exact command they expect.
|
|
111
|
-
"""
|
|
112
|
-
command = command.strip()
|
|
113
|
-
allowed = self._jobs.get(job_id, [])
|
|
114
|
-
return command in allowed
|
|
115
|
-
|
|
116
|
-
def all_jobs(self) -> dict[str, list[str]]:
|
|
117
|
-
"""Return a copy of all job allow lists."""
|
|
118
|
-
return {jid: list(cmds) for jid, cmds in self._jobs.items()}
|