bone-agent 1.3.1 → 1.3.3
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/README.md +2 -2
- package/config.yaml.example +8 -0
- package/package.json +3 -2
- package/prompts/main/ask_questions.md +31 -0
- package/prompts/main/batch_independent_calls.md +5 -0
- package/prompts/main/casual_interactions.md +11 -0
- package/prompts/main/code_references.md +8 -0
- package/prompts/main/communication_style.md +12 -0
- package/prompts/main/context_reliability.md +12 -0
- package/prompts/main/conversational_tool_calling.md +15 -0
- package/prompts/main/dream.md +36 -0
- package/prompts/main/editing_pattern.md +13 -0
- package/prompts/main/error_handling.md +6 -0
- package/prompts/main/exploration_pattern.md +21 -0
- package/prompts/main/intro.md +1 -0
- package/prompts/main/obsidian.md +16 -0
- package/prompts/main/obsidian_project.md +79 -0
- package/prompts/main/professional_objectivity.md +3 -0
- package/prompts/main/targeted_searching.md +10 -0
- package/prompts/main/task_lists_pattern.md +8 -0
- package/prompts/main/temp_folder.md +9 -0
- package/prompts/main/think_before_acting.md +10 -0
- package/prompts/main/tone_and_style.md +4 -0
- package/prompts/main/tool_preferences.md +24 -0
- package/prompts/main/trust_subagent_context.md +21 -0
- package/prompts/main/when_to_use_sub_agent.md +7 -0
- package/prompts/micro/ask_questions.md +1 -0
- package/prompts/micro/batch_independent_calls.md +1 -0
- package/prompts/micro/casual_interactions.md +1 -0
- package/prompts/micro/code_references.md +1 -0
- package/prompts/micro/communication_style.md +1 -0
- package/prompts/micro/context_reliability.md +1 -0
- package/prompts/micro/conversational_tool_calling.md +1 -0
- package/prompts/micro/editing_pattern.md +1 -0
- package/prompts/micro/error_handling.md +1 -0
- package/prompts/micro/exploration_pattern.md +1 -0
- package/prompts/micro/intro.md +1 -0
- package/prompts/micro/obsidian.md +4 -0
- package/prompts/micro/obsidian_project.md +5 -0
- package/prompts/micro/professional_objectivity.md +1 -0
- package/prompts/micro/targeted_searching.md +1 -0
- package/prompts/micro/task_lists_pattern.md +1 -0
- package/prompts/micro/temp_folder.md +1 -0
- package/prompts/micro/think_before_acting.md +5 -0
- package/prompts/micro/tone_and_style.md +1 -0
- package/prompts/micro/tool_preferences.md +1 -0
- package/prompts/micro/trust_subagent_context.md +1 -0
- package/prompts/micro/when_to_use_sub_agent.md +1 -0
- package/src/core/agentic.py +9 -78
- package/src/core/chat_manager.py +120 -108
- package/src/core/config_manager.py +6 -0
- package/src/core/cron.py +57 -2
- package/src/core/memory.py +3 -90
- package/src/llm/config.py +28 -2
- package/src/llm/prompts.py +251 -497
- package/src/llm/providers.py +25 -6
- package/src/llm/token_tracker.py +17 -1
- package/src/tools/edit.py +8 -6
- package/src/tools/helpers/path_resolver.py +18 -12
- package/src/tools/rg_search.py +97 -30
- package/src/tools/select_option.py +12 -5
- package/src/ui/commands.py +120 -5
- package/src/ui/displays.py +1 -0
- package/src/ui/main.py +1 -0
- package/src/utils/settings.py +19 -2
- package/src/utils/user_message_logger.py +120 -0
package/src/llm/providers.py
CHANGED
|
@@ -173,17 +173,27 @@ class AnthropicHandler:
|
|
|
173
173
|
# OpenAI format: {"choices": [{"message": {"content": "..."}}], "usage": {...}}
|
|
174
174
|
|
|
175
175
|
# Convert Anthropic usage format (input_tokens/output_tokens) to OpenAI format (prompt_tokens/completion_tokens)
|
|
176
|
+
# Anthropic's input_tokens does NOT include cache tokens; total input =
|
|
177
|
+
# input_tokens + cache_read_input_tokens + cache_creation_input_tokens
|
|
176
178
|
anthropic_usage = response_json.get("usage", {})
|
|
179
|
+
cache_read = anthropic_usage.get('cache_read_input_tokens', 0)
|
|
180
|
+
cache_creation = anthropic_usage.get('cache_creation_input_tokens', 0)
|
|
181
|
+
prompt_tokens = anthropic_usage.get('input_tokens', 0) + cache_read + cache_creation
|
|
182
|
+
completion_tokens = anthropic_usage.get('output_tokens', 0)
|
|
177
183
|
openai_format_usage = {
|
|
178
|
-
'prompt_tokens':
|
|
179
|
-
'completion_tokens':
|
|
180
|
-
'total_tokens':
|
|
184
|
+
'prompt_tokens': prompt_tokens,
|
|
185
|
+
'completion_tokens': completion_tokens,
|
|
186
|
+
'total_tokens': prompt_tokens + completion_tokens,
|
|
181
187
|
}
|
|
182
188
|
# Preserve Anthropic cache token fields for the token tracker
|
|
183
189
|
if 'cache_read_input_tokens' in anthropic_usage:
|
|
184
190
|
openai_format_usage['cache_read_input_tokens'] = anthropic_usage['cache_read_input_tokens']
|
|
185
191
|
if 'cache_creation_input_tokens' in anthropic_usage:
|
|
186
192
|
openai_format_usage['cache_creation_input_tokens'] = anthropic_usage['cache_creation_input_tokens']
|
|
193
|
+
# Preserve non-cache input count so cost estimation can bill only the
|
|
194
|
+
# non-cache portion without relying on fragile prompt_tokens subtraction.
|
|
195
|
+
if 'input_tokens' in anthropic_usage:
|
|
196
|
+
openai_format_usage['input_tokens'] = anthropic_usage['input_tokens']
|
|
187
197
|
|
|
188
198
|
result = {
|
|
189
199
|
"choices": [],
|
|
@@ -279,17 +289,26 @@ class AnthropicHandler:
|
|
|
279
289
|
|
|
280
290
|
# Yield usage data as final item if captured
|
|
281
291
|
# Convert Anthropic format (input_tokens/output_tokens) to OpenAI format (prompt_tokens/completion_tokens)
|
|
292
|
+
# Anthropic's input_tokens does NOT include cache tokens; total input =
|
|
293
|
+
# input_tokens + cache_read_input_tokens + cache_creation_input_tokens
|
|
282
294
|
if usage_data:
|
|
295
|
+
cache_read = usage_data.get('cache_read_input_tokens', 0)
|
|
296
|
+
cache_creation = usage_data.get('cache_creation_input_tokens', 0)
|
|
297
|
+
prompt_tokens = usage_data.get('input_tokens', 0) + cache_read + cache_creation
|
|
298
|
+
completion_tokens = usage_data.get('output_tokens', 0)
|
|
283
299
|
openai_format_usage = {
|
|
284
|
-
'prompt_tokens':
|
|
285
|
-
'completion_tokens':
|
|
286
|
-
'total_tokens':
|
|
300
|
+
'prompt_tokens': prompt_tokens,
|
|
301
|
+
'completion_tokens': completion_tokens,
|
|
302
|
+
'total_tokens': prompt_tokens + completion_tokens,
|
|
287
303
|
}
|
|
288
304
|
# Preserve Anthropic cache token fields for the token tracker
|
|
289
305
|
if 'cache_read_input_tokens' in usage_data:
|
|
290
306
|
openai_format_usage['cache_read_input_tokens'] = usage_data['cache_read_input_tokens']
|
|
291
307
|
if 'cache_creation_input_tokens' in usage_data:
|
|
292
308
|
openai_format_usage['cache_creation_input_tokens'] = usage_data['cache_creation_input_tokens']
|
|
309
|
+
# Preserve non-cache input count for accurate cost estimation
|
|
310
|
+
if 'input_tokens' in usage_data:
|
|
311
|
+
openai_format_usage['input_tokens'] = usage_data['input_tokens']
|
|
293
312
|
yield {'__usage__': openai_format_usage}
|
|
294
313
|
|
|
295
314
|
@staticmethod
|
package/src/llm/token_tracker.py
CHANGED
|
@@ -55,6 +55,10 @@ class TokenTracker:
|
|
|
55
55
|
self.total_cache_creation_tokens = 0 # Cumulative input tokens written to cache
|
|
56
56
|
self.conv_cache_read_tokens = 0 # Per-conversation cache read tokens
|
|
57
57
|
self.conv_cache_creation_tokens = 0 # Per-conversation cache creation tokens
|
|
58
|
+
|
|
59
|
+
# Active prompt variant (loaded from prompts/ directory)
|
|
60
|
+
self.current_variant = "main"
|
|
61
|
+
|
|
58
62
|
def add_usage(self, usage_data, model_name: str = ""):
|
|
59
63
|
"""Add token usage from an API response.
|
|
60
64
|
|
|
@@ -122,7 +126,19 @@ class TokenTracker:
|
|
|
122
126
|
# Fallback: look up cost rates from config
|
|
123
127
|
cost_in, cost_out = get_model_cost(model_name)
|
|
124
128
|
if cost_in > 0 or cost_out > 0:
|
|
125
|
-
|
|
129
|
+
# Compute the billable (non-cache) input token count for cost
|
|
130
|
+
# estimation. Providers normalize `prompt_tokens` differently:
|
|
131
|
+
# - Anthropic handler: sums input + cache_read + cache_creation
|
|
132
|
+
# - OpenAI: prompt_tokens natively includes cached_tokens
|
|
133
|
+
# - Future providers: may exclude cache tokens from prompt_tokens
|
|
134
|
+
# Use the explicit `input_tokens` field (Anthropic native,
|
|
135
|
+
# non-cache portion) when available; otherwise subtract cache
|
|
136
|
+
# tokens from prompt_tokens (assumes prompt_tokens includes
|
|
137
|
+
# cache counts).
|
|
138
|
+
base_prompt = usage_data.get('input_tokens')
|
|
139
|
+
if base_prompt is None:
|
|
140
|
+
base_prompt = max(0, prompt_tokens - cache_read - cache_creation)
|
|
141
|
+
computed = self._calculate_cost(base_prompt, completion_tokens, cost_in, cost_out)
|
|
126
142
|
self.add_estimated_cost(computed['total_cost'])
|
|
127
143
|
|
|
128
144
|
def add_actual_cost(self, cost_usd: float):
|
package/src/tools/edit.py
CHANGED
|
@@ -223,9 +223,11 @@ def _prepare_edit(arguments, repo_root, gitignore_spec=None, vault_root=None) ->
|
|
|
223
223
|
if not path or not isinstance(path, str) or not path.strip():
|
|
224
224
|
raise FileEditError("Missing or invalid 'path' parameter")
|
|
225
225
|
|
|
226
|
-
# Memory files (.bone/ and user_memory.md) are auto-approved
|
|
227
|
-
# system itself adds to .gitignore, so gitignore filtering
|
|
228
|
-
|
|
226
|
+
# Memory files (.bone/ under repo root and user_memory.md) are auto-approved
|
|
227
|
+
# writes that the system itself adds to .gitignore, so gitignore filtering
|
|
228
|
+
# would block them. Must anchor to repo_root to avoid matching any .bone/ dir.
|
|
229
|
+
_resolved = (repo_root / path).resolve()
|
|
230
|
+
is_memory = str(_resolved).startswith(str((repo_root / ".bone").resolve()) + os.sep) or Path(path).name == "user_memory.md"
|
|
229
231
|
|
|
230
232
|
# Resolve and validate path using PathResolver
|
|
231
233
|
try:
|
|
@@ -236,9 +238,9 @@ def _prepare_edit(arguments, repo_root, gitignore_spec=None, vault_root=None) ->
|
|
|
236
238
|
raise FileEditError(str(e), details=e.details)
|
|
237
239
|
|
|
238
240
|
if not file_path.exists():
|
|
239
|
-
# Auto-create memory files (.bone/) with default header
|
|
240
|
-
#
|
|
241
|
-
if
|
|
241
|
+
# Auto-create memory files (.bone/ under repo root) with default header
|
|
242
|
+
# on first write. Already auto-approved, so creation is safe.
|
|
243
|
+
if is_memory:
|
|
242
244
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
245
|
header = "# Project Memory\n\n" if file_path.name == "agents.md" else "# User Memory\n\n"
|
|
244
246
|
file_path.write_text(header, encoding="utf-8")
|
|
@@ -107,24 +107,30 @@ class PathResolver:
|
|
|
107
107
|
# Resolve to absolute path (handles .. and symlinks)
|
|
108
108
|
path = path.resolve()
|
|
109
109
|
|
|
110
|
-
# Step 2b: Security boundary — path must be within repo_root
|
|
110
|
+
# Step 2b: Security boundary — path must be within repo_root, vault_path,
|
|
111
|
+
# or the agent's own data directory (~/.bone/).
|
|
111
112
|
if enforce_boundary:
|
|
112
113
|
try:
|
|
113
114
|
path.relative_to(self.repo_root)
|
|
114
115
|
except ValueError:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
# Check ~/.bone/ — agent data dir is always accessible
|
|
117
|
+
bone_root = Path.home() / ".bone"
|
|
118
|
+
try:
|
|
119
|
+
path.relative_to(bone_root)
|
|
120
|
+
except ValueError:
|
|
121
|
+
if self.vault_path is not None:
|
|
122
|
+
try:
|
|
123
|
+
path.relative_to(self.vault_path)
|
|
124
|
+
except ValueError:
|
|
125
|
+
elapsed = time.time() - start_time
|
|
126
|
+
_track_validation_error("outside_allowed_roots")
|
|
127
|
+
_path_resolution_times.append(elapsed)
|
|
128
|
+
return None, f"Path is outside allowed directories: {path_str}"
|
|
129
|
+
else:
|
|
119
130
|
elapsed = time.time() - start_time
|
|
120
|
-
_track_validation_error("
|
|
131
|
+
_track_validation_error("outside_repo")
|
|
121
132
|
_path_resolution_times.append(elapsed)
|
|
122
|
-
return None, f"Path is outside
|
|
123
|
-
else:
|
|
124
|
-
elapsed = time.time() - start_time
|
|
125
|
-
_track_validation_error("outside_repo")
|
|
126
|
-
_path_resolution_times.append(elapsed)
|
|
127
|
-
return None, f"Path is outside repository: {path_str}"
|
|
133
|
+
return None, f"Path is outside repository: {path_str}"
|
|
128
134
|
|
|
129
135
|
# Step 3: Check existence if required
|
|
130
136
|
if must_exist:
|
package/src/tools/rg_search.py
CHANGED
|
@@ -2,21 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
5
|
-
import
|
|
5
|
+
import stat
|
|
6
6
|
import subprocess
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Optional
|
|
9
9
|
|
|
10
10
|
from .helpers.base import tool
|
|
11
11
|
from .helpers.formatters import format_tool_result
|
|
12
|
-
from .shell import
|
|
12
|
+
from .shell import _execute_direct_command, _prepare_execution_environment
|
|
13
13
|
from .helpers.converters import coerce_bool, coerce_int
|
|
14
|
+
from utils.settings import tool_settings
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger(__name__)
|
|
16
17
|
|
|
17
18
|
# Default match limit for vault searches (separate from repo limit)
|
|
18
19
|
_VAULT_MAX_MATCHES = 20
|
|
19
20
|
|
|
21
|
+
# Regex for detecting file-path lines in rg output (shared by _annotate_file_sizes and _search_vault)
|
|
22
|
+
_path_line_re = re.compile(r"^[^\s:|].*[/.]")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format_file_size(size_bytes: int) -> str:
|
|
26
|
+
"""Format file size in human-readable form."""
|
|
27
|
+
if size_bytes < 1024:
|
|
28
|
+
return f"{size_bytes} B"
|
|
29
|
+
elif size_bytes < 1024 * 1024:
|
|
30
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
31
|
+
else:
|
|
32
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _annotate_file_sizes(formatted_output: str, base_path: Path, output_mode: str = "files_with_matches") -> str:
|
|
36
|
+
"""Append human-readable file sizes to each file path line in rg output.
|
|
37
|
+
|
|
38
|
+
Works on files_with_matches and count output modes where each content
|
|
39
|
+
line starts with a file path. Skips metadata, truncation, and section
|
|
40
|
+
header lines. Skipped entirely for content mode (no benefit there).
|
|
41
|
+
"""
|
|
42
|
+
if output_mode == "content":
|
|
43
|
+
return formatted_output
|
|
44
|
+
|
|
45
|
+
lines = formatted_output.split("\n")
|
|
46
|
+
annotated = []
|
|
47
|
+
for line in lines:
|
|
48
|
+
stripped = line.strip()
|
|
49
|
+
if (not stripped
|
|
50
|
+
or stripped.startswith("exit_code=")
|
|
51
|
+
or stripped.startswith("matches=")
|
|
52
|
+
or stripped.startswith("files=")
|
|
53
|
+
or stripped.startswith("... (")
|
|
54
|
+
or stripped.startswith("[repo]")
|
|
55
|
+
or stripped.startswith("[vault]")):
|
|
56
|
+
annotated.append(line)
|
|
57
|
+
continue
|
|
58
|
+
# Only annotate pure file-path lines (files_with_matches: "file",
|
|
59
|
+
# count: "file:N"). Skip content-mode match lines ("file:line:match")
|
|
60
|
+
# which always have 2+ colons or a colon-digit-dash pattern.
|
|
61
|
+
parts = line.split(":")
|
|
62
|
+
is_file_line = (
|
|
63
|
+
_path_line_re.match(line)
|
|
64
|
+
and len(parts) <= 2
|
|
65
|
+
and (len(parts) == 1 or parts[1].strip().isdigit())
|
|
66
|
+
)
|
|
67
|
+
if is_file_line:
|
|
68
|
+
file_part = parts[0].strip()
|
|
69
|
+
full_path = base_path / file_part
|
|
70
|
+
try:
|
|
71
|
+
st = full_path.stat()
|
|
72
|
+
if stat.S_ISREG(st.st_mode):
|
|
73
|
+
size = _format_file_size(st.st_size)
|
|
74
|
+
annotated.append(f"{line} {size:>8}")
|
|
75
|
+
else:
|
|
76
|
+
annotated.append(line)
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
annotated.append(line)
|
|
79
|
+
else:
|
|
80
|
+
annotated.append(line)
|
|
81
|
+
return "\n".join(annotated)
|
|
82
|
+
|
|
20
83
|
|
|
21
84
|
@tool(
|
|
22
85
|
name="rg",
|
|
@@ -103,72 +166,74 @@ def rg(
|
|
|
103
166
|
if not isinstance(pattern, str) or not pattern.strip():
|
|
104
167
|
return "exit_code=1\nrg requires a non-empty 'pattern' argument."
|
|
105
168
|
|
|
106
|
-
# Build rg
|
|
107
|
-
|
|
169
|
+
# Build rg args as a list (bypass string roundtrip to avoid shlex issues with regex metacharacters)
|
|
170
|
+
args = []
|
|
108
171
|
|
|
109
172
|
# Add --line-number for content mode
|
|
110
173
|
if output_mode == "content":
|
|
111
|
-
|
|
174
|
+
args.append("--line-number")
|
|
112
175
|
|
|
113
176
|
# Add multiline flag
|
|
114
177
|
multiline = coerce_bool(kwargs.get("multiline"), default=False)
|
|
115
178
|
if multiline:
|
|
116
|
-
|
|
117
|
-
|
|
179
|
+
args.append("-U")
|
|
180
|
+
args.append("--multiline-dotall")
|
|
118
181
|
|
|
119
182
|
# Add case insensitive flag
|
|
120
183
|
case_insensitive = coerce_bool(kwargs.get("case_insensitive"), default=False)
|
|
121
184
|
if case_insensitive:
|
|
122
|
-
|
|
185
|
+
args.append("--ignore-case")
|
|
123
186
|
|
|
124
187
|
# Add context lines flag
|
|
125
188
|
context_lines = coerce_int(kwargs.get("context_lines"))[0] if kwargs.get("context_lines") else None
|
|
126
189
|
if context_lines:
|
|
127
|
-
|
|
190
|
+
args.append(f"--context={context_lines}")
|
|
128
191
|
|
|
129
192
|
# Add glob pattern
|
|
130
193
|
if glob:
|
|
131
|
-
|
|
194
|
+
args.append(f"--glob={glob}")
|
|
132
195
|
|
|
133
196
|
# Add file type filter
|
|
134
197
|
file_type = kwargs.get("type")
|
|
135
198
|
if file_type:
|
|
136
|
-
|
|
199
|
+
args.append(f"--type={file_type}")
|
|
137
200
|
|
|
138
|
-
# Add
|
|
201
|
+
# Add output mode flags
|
|
139
202
|
if output_mode == "files_with_matches":
|
|
140
|
-
|
|
203
|
+
args.append("--files-with-matches")
|
|
141
204
|
elif output_mode == "count":
|
|
142
|
-
|
|
205
|
+
args.append("--count")
|
|
143
206
|
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
cmd_parts.append(shlex.quote(pattern))
|
|
147
|
-
else:
|
|
148
|
-
cmd_parts.append(pattern)
|
|
207
|
+
# Pattern and search path — no quoting needed, subprocess list form bypasses shell
|
|
208
|
+
args.append(pattern)
|
|
149
209
|
|
|
150
|
-
# Add path (default to current directory)
|
|
151
210
|
search_path = path or "."
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
# Build command string
|
|
155
|
-
command = " ".join(cmd_parts)
|
|
211
|
+
args.append(search_path)
|
|
156
212
|
|
|
157
213
|
# Get max_matches from kwargs (default: 100, set to 0 for no limit)
|
|
158
214
|
raw = coerce_int(kwargs.get("max_matches"))[0] if kwargs.get("max_matches") is not None else None
|
|
159
215
|
max_matches = raw if raw is not None and raw >= 0 else 100
|
|
160
216
|
|
|
161
|
-
# Execute repo search
|
|
217
|
+
# Execute repo search directly (no string→shlex roundtrip)
|
|
162
218
|
try:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
219
|
+
env = _prepare_execution_environment(repo_root, rg_exe_path)
|
|
220
|
+
|
|
221
|
+
result = _execute_direct_command(
|
|
222
|
+
[str(rg_exe_path)] + args,
|
|
223
|
+
repo_root, env, debug_mode, console,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
command_display = "rg " + " ".join(args)
|
|
227
|
+
repo_result = format_tool_result(
|
|
228
|
+
result, command=command_display, is_rg=True,
|
|
229
|
+
debug_mode=debug_mode, max_matches=max_matches,
|
|
166
230
|
)
|
|
167
231
|
except Exception as e:
|
|
168
232
|
return f"exit_code=1\nrg command failed: {str(e)}"
|
|
169
233
|
|
|
170
234
|
# If no vault configured, return repo results directly
|
|
171
235
|
if not vault_root:
|
|
236
|
+
repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
|
|
172
237
|
return repo_result
|
|
173
238
|
|
|
174
239
|
# Run vault search and merge results
|
|
@@ -184,9 +249,12 @@ def rg(
|
|
|
184
249
|
)
|
|
185
250
|
|
|
186
251
|
if not vault_output:
|
|
252
|
+
repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
|
|
187
253
|
return repo_result
|
|
188
254
|
|
|
189
255
|
# Merge results: repo section + vault section with absolute paths
|
|
256
|
+
repo_result = _annotate_file_sizes(repo_result, repo_root, output_mode)
|
|
257
|
+
vault_output = _annotate_file_sizes(vault_output, Path(vault_root), output_mode)
|
|
190
258
|
return _merge_results(repo_result, vault_output, output_mode)
|
|
191
259
|
|
|
192
260
|
|
|
@@ -257,7 +325,7 @@ def _search_vault(vault_root, rg_exe_path, output_mode, debug_mode, console,
|
|
|
257
325
|
text=True,
|
|
258
326
|
encoding="utf-8",
|
|
259
327
|
errors="replace",
|
|
260
|
-
timeout=
|
|
328
|
+
timeout=tool_settings.command_timeout_sec,
|
|
261
329
|
cwd=str(vault_path),
|
|
262
330
|
env=env,
|
|
263
331
|
)
|
|
@@ -288,7 +356,6 @@ def _search_vault(vault_root, rg_exe_path, output_mode, debug_mode, console,
|
|
|
288
356
|
# rg output: "relative/path:linenum:match" or "relative/path-linenum-context"
|
|
289
357
|
# or "relative/path:count" (count mode). Must contain / or . before any
|
|
290
358
|
# colon to avoid matching content-only lines or binary headers.
|
|
291
|
-
_path_line_re = re.compile(r"^[^\s:|].*[/.]")
|
|
292
359
|
vault_prefix = str(vault_path)
|
|
293
360
|
|
|
294
361
|
lines = formatted.split("\n")
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Interactive selection tool for presenting multiple-choice questions to the user."""
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
from html import escape as _html_escape
|
|
5
4
|
from threading import Timer
|
|
6
5
|
from typing import Optional, List, Dict, Any, Union
|
|
@@ -49,6 +48,7 @@ class SelectionPanel:
|
|
|
49
48
|
# Inline custom input editing state
|
|
50
49
|
self._editing_custom_input = False
|
|
51
50
|
self._custom_input_texts: Dict[int, str] = {} # question_idx -> typed text
|
|
51
|
+
self._auto_advance_timer: Optional[Timer] = None # Track for cancellation
|
|
52
52
|
|
|
53
53
|
# Multi-select state: per-question set of checked option indices
|
|
54
54
|
self._checked_indices: Dict[int, set] = {
|
|
@@ -265,7 +265,8 @@ class SelectionPanel:
|
|
|
265
265
|
# Single question - show summary then auto-exit
|
|
266
266
|
self._showing_summary = True
|
|
267
267
|
event.app.invalidate()
|
|
268
|
-
Timer(1.0, lambda: event.app.exit(result=self.selections[0]))
|
|
268
|
+
self._auto_advance_timer = Timer(1.0, lambda: event.app.exit(result=self.selections[0]))
|
|
269
|
+
self._auto_advance_timer.start()
|
|
269
270
|
else:
|
|
270
271
|
# Multi-question - advance or finish
|
|
271
272
|
if self.current_question_idx < len(self.questions) - 1:
|
|
@@ -275,7 +276,8 @@ class SelectionPanel:
|
|
|
275
276
|
else:
|
|
276
277
|
self._showing_summary = True
|
|
277
278
|
event.app.invalidate()
|
|
278
|
-
Timer(1.0, lambda: event.app.exit(result=self.selections))
|
|
279
|
+
self._auto_advance_timer = Timer(1.0, lambda: event.app.exit(result=self.selections))
|
|
280
|
+
self._auto_advance_timer.start()
|
|
279
281
|
|
|
280
282
|
def run(self) -> Optional[Union[str, List[str]]]:
|
|
281
283
|
"""Display the selection panel and wait for user input.
|
|
@@ -401,6 +403,9 @@ class SelectionPanel:
|
|
|
401
403
|
event.app.invalidate()
|
|
402
404
|
else:
|
|
403
405
|
# Cancel entire selection
|
|
406
|
+
if self._auto_advance_timer:
|
|
407
|
+
self._auto_advance_timer.cancel()
|
|
408
|
+
self._auto_advance_timer = None
|
|
404
409
|
event.app.exit(result=None)
|
|
405
410
|
|
|
406
411
|
# Printable character input for custom input editing
|
|
@@ -465,8 +470,10 @@ class SelectionPanel:
|
|
|
465
470
|
style=TOOLBAR_STYLE,
|
|
466
471
|
)
|
|
467
472
|
|
|
468
|
-
# Use
|
|
469
|
-
|
|
473
|
+
# Use prompt_toolkit's synchronous runner — avoids creating/destroying
|
|
474
|
+
# an event loop with asyncio.run(), which corrupts the parent
|
|
475
|
+
# PromptSession's event loop state and causes 100% CPU hangs.
|
|
476
|
+
result = application.run()
|
|
470
477
|
|
|
471
478
|
return result
|
|
472
479
|
|
package/src/ui/commands.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Command routing and help display."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import re
|
|
5
|
+
import subprocess
|
|
4
6
|
from dataclasses import dataclass
|
|
5
7
|
from typing import Optional
|
|
6
8
|
from llm import config
|
|
@@ -155,6 +157,9 @@ def _cron_remove(console, sub_args, cron_config, notify_scheduler):
|
|
|
155
157
|
if not job_id:
|
|
156
158
|
console.print("[red]Usage: /cron remove <id>[/red]")
|
|
157
159
|
return CommandResult(status="handled")
|
|
160
|
+
if job_id == "dream":
|
|
161
|
+
console.print("[red]The 'dream' job is managed by DREAM_SETTINGS.enabled in config.yaml and cannot be removed.[/red]")
|
|
162
|
+
return CommandResult(status="handled")
|
|
158
163
|
if cron_config.remove_job(job_id):
|
|
159
164
|
notify_scheduler()
|
|
160
165
|
console.print(f"[green]Removed cron job '{job_id}'[/green]")
|
|
@@ -171,6 +176,9 @@ def _cron_toggle(console, sub_args, cron_config, notify_scheduler, enable):
|
|
|
171
176
|
if not job_id:
|
|
172
177
|
console.print(f"[red]Usage: /cron {verb} <id>[/red]")
|
|
173
178
|
return CommandResult(status="handled")
|
|
179
|
+
if not enable and job_id == "dream":
|
|
180
|
+
console.print("[red]The 'dream' job is managed by DREAM_SETTINGS.enabled in config.yaml and cannot be disabled via /cron.[/red]")
|
|
181
|
+
return CommandResult(status="handled")
|
|
174
182
|
if job_id in cron_config.jobs:
|
|
175
183
|
cron_config.update_job(job_id, enabled=enable)
|
|
176
184
|
notify_scheduler()
|
|
@@ -381,6 +389,12 @@ def _handle_config(chat_manager, console, debug_mode_container, args, cron_sched
|
|
|
381
389
|
{"value": "danger", "text": "DANGER"},
|
|
382
390
|
],
|
|
383
391
|
),
|
|
392
|
+
SettingOption(
|
|
393
|
+
key="memory_enabled", text="Memory",
|
|
394
|
+
value=config.MEMORY_SETTINGS.get("enabled", True),
|
|
395
|
+
input_type="boolean",
|
|
396
|
+
on_text="ON", off_text="OFF",
|
|
397
|
+
),
|
|
384
398
|
]
|
|
385
399
|
|
|
386
400
|
# Build status bar settings
|
|
@@ -480,6 +494,10 @@ def _handle_config(chat_manager, console, debug_mode_container, args, cron_sched
|
|
|
480
494
|
console.print("[bold red on default] Dangerous git commands are still blocked.[/bold red on default]")
|
|
481
495
|
console.print("[bold yellow on default] Use at your own risk![/bold yellow on default]")
|
|
482
496
|
console.print()
|
|
497
|
+
elif key == "memory_enabled":
|
|
498
|
+
config.update_memory_settings({"enabled": value})
|
|
499
|
+
state = "enabled" if value else "disabled"
|
|
500
|
+
change_lines.append(f" Memory: {state}")
|
|
483
501
|
elif key == "compact_trigger_tokens":
|
|
484
502
|
context_settings.compact_trigger_tokens = int(value)
|
|
485
503
|
change_lines.append(f" Compaction Threshold: {value:,} tokens")
|
|
@@ -531,6 +549,17 @@ def _handle_config(chat_manager, console, debug_mode_container, args, cron_sched
|
|
|
531
549
|
except Exception as e:
|
|
532
550
|
console.print(f"[red]Failed to save status bar settings: {e}[/red]")
|
|
533
551
|
|
|
552
|
+
# Persist memory setting to config
|
|
553
|
+
if "memory_enabled" in changes:
|
|
554
|
+
try:
|
|
555
|
+
cfg_data = config_manager.load(force_reload=True)
|
|
556
|
+
if "MEMORY_SETTINGS" not in cfg_data:
|
|
557
|
+
cfg_data["MEMORY_SETTINGS"] = {}
|
|
558
|
+
cfg_data["MEMORY_SETTINGS"]["enabled"] = changes["memory_enabled"]
|
|
559
|
+
config_manager.save(cfg_data)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
console.print(f"[red]Failed to save memory settings: {e}[/red]")
|
|
562
|
+
|
|
534
563
|
# Display summary
|
|
535
564
|
console.print(f"[green]Settings updated:[/green]")
|
|
536
565
|
for line in change_lines:
|
|
@@ -561,12 +590,13 @@ def _handle_clear(chat_manager, console, debug_mode_container, args, cron_schedu
|
|
|
561
590
|
conv_cache_read = chat_manager.token_tracker.conv_cache_read_tokens
|
|
562
591
|
conv_cache_creation = chat_manager.token_tracker.conv_cache_creation_tokens
|
|
563
592
|
if conv_cache_read > 0 or conv_cache_creation > 0:
|
|
593
|
+
total_cached = conv_cache_read + conv_cache_creation
|
|
564
594
|
cache_hit_pct = (
|
|
565
|
-
conv_cache_read /
|
|
566
|
-
) if
|
|
595
|
+
conv_cache_read / total_cached * 100
|
|
596
|
+
) if total_cached > 0 else 0
|
|
567
597
|
console.print(f" Cache read: {conv_cache_read:,} tokens")
|
|
568
598
|
console.print(f" Cache write: {conv_cache_creation:,} tokens")
|
|
569
|
-
console.print(f" ({cache_hit_pct:.0f}%
|
|
599
|
+
console.print(f" ({cache_hit_pct:.0f}% cache hit rate)")
|
|
570
600
|
|
|
571
601
|
# Display cost — combined actual + estimated, with config-based fallback
|
|
572
602
|
tracker_conv = chat_manager.token_tracker
|
|
@@ -1065,10 +1095,11 @@ def _handle_usage(chat_manager, console, debug_mode_container, args, cron_schedu
|
|
|
1065
1095
|
# Display cache token breakdown (if any cache tokens were recorded)
|
|
1066
1096
|
has_cache = tracker.total_cache_read_tokens > 0 or tracker.total_cache_creation_tokens > 0
|
|
1067
1097
|
if has_cache:
|
|
1098
|
+
total_cached = tracker.total_cache_read_tokens + tracker.total_cache_creation_tokens
|
|
1068
1099
|
cache_hit_pct = (
|
|
1069
1100
|
tracker.total_cache_read_tokens
|
|
1070
|
-
/
|
|
1071
|
-
) if
|
|
1101
|
+
/ total_cached * 100
|
|
1102
|
+
) if total_cached > 0 else 0
|
|
1072
1103
|
console.print()
|
|
1073
1104
|
console.print(f"[#5F9EA0]Input Cache ({cache_hit_pct:.0f}% hit rate):[/#5F9EA0]")
|
|
1074
1105
|
console.print(f" Cache read: {tracker.total_cache_read_tokens:,} tokens")
|
|
@@ -2433,6 +2464,57 @@ def _handle_cd(chat_manager, console, debug_mode_container, args, cron_scheduler
|
|
|
2433
2464
|
return CommandResult(status="handled")
|
|
2434
2465
|
|
|
2435
2466
|
|
|
2467
|
+
def _handle_prompt(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
|
|
2468
|
+
"""Handle /prompt command — show/swap prompt variants."""
|
|
2469
|
+
from utils.settings import prompt_settings
|
|
2470
|
+
from llm.prompts import _variant_available, _list_variants
|
|
2471
|
+
|
|
2472
|
+
cfg_manager = config_manager
|
|
2473
|
+
|
|
2474
|
+
if not args or args.strip() == "list":
|
|
2475
|
+
variants = _list_variants()
|
|
2476
|
+
current = prompt_settings.variant
|
|
2477
|
+
console.print()
|
|
2478
|
+
console.print(f"[bold #5F9EA0]Prompt Variants[/bold #5F9EA0] (current: [bold]{current}[/bold])")
|
|
2479
|
+
console.print()
|
|
2480
|
+
for v in variants:
|
|
2481
|
+
marker = "[bold green]active[/bold green]" if v == current else ""
|
|
2482
|
+
console.print(f" [bold]{v}[/bold] {marker}")
|
|
2483
|
+
console.print()
|
|
2484
|
+
console.print("[dim]Switch with: [bold #5F9EA0]/prompt main[/bold #5F9EA0] or [bold #5F9EA0]/prompt micro[/bold #5F9EA0][/dim]")
|
|
2485
|
+
return CommandResult(status="handled")
|
|
2486
|
+
|
|
2487
|
+
# Single arg: variant name to switch to
|
|
2488
|
+
target = args.strip().lower()
|
|
2489
|
+
|
|
2490
|
+
if not _variant_available(target):
|
|
2491
|
+
variants = _list_variants()
|
|
2492
|
+
console.print(f"[red]Unknown variant: '{target}'[/red]")
|
|
2493
|
+
console.print(f"[dim]Available: {', '.join(variants)}[/dim]")
|
|
2494
|
+
return CommandResult(status="handled")
|
|
2495
|
+
|
|
2496
|
+
# Update settings
|
|
2497
|
+
prompt_settings.variant = target
|
|
2498
|
+
|
|
2499
|
+
# Persist to config
|
|
2500
|
+
try:
|
|
2501
|
+
cfg_data = cfg_manager.load(force_reload=True)
|
|
2502
|
+
if "PROMPT_SETTINGS" not in cfg_data:
|
|
2503
|
+
cfg_data["PROMPT_SETTINGS"] = {}
|
|
2504
|
+
cfg_data["PROMPT_SETTINGS"]["variant"] = target
|
|
2505
|
+
cfg_manager.save(cfg_data)
|
|
2506
|
+
except Exception as e:
|
|
2507
|
+
console.print(f"[red]Failed to save variant to config: {e}[/red]")
|
|
2508
|
+
console.print("[yellow]Variant applied for this session only — it will revert on restart.[/yellow]")
|
|
2509
|
+
|
|
2510
|
+
# Rebuild system prompt in-place (no restart)
|
|
2511
|
+
chat_manager.update_system_prompt(variant=target)
|
|
2512
|
+
console.print(f"[green]Switched to '{target}' variant[/green]")
|
|
2513
|
+
console.print("[dim]System prompt rebuilt in-place.[/dim]")
|
|
2514
|
+
|
|
2515
|
+
return CommandResult(status="handled")
|
|
2516
|
+
|
|
2517
|
+
|
|
2436
2518
|
def _handle_obsidian_init(console, obsidian_settings):
|
|
2437
2519
|
"""Handle /obsidian init — scaffold project folder structure in vault."""
|
|
2438
2520
|
if not obsidian_settings.is_active():
|
|
@@ -2658,9 +2740,34 @@ _COMMAND_HANDLERS = {
|
|
|
2658
2740
|
"/cd": _handle_cd,
|
|
2659
2741
|
"/setup": _handle_setup,
|
|
2660
2742
|
"/cron": _handle_cron,
|
|
2743
|
+
"/prompt": _handle_prompt,
|
|
2661
2744
|
}
|
|
2662
2745
|
|
|
2663
2746
|
|
|
2747
|
+
def _handle_shell_command(console, command):
|
|
2748
|
+
"""Execute a shell command prefixed with : and display output."""
|
|
2749
|
+
from utils.settings import tool_settings
|
|
2750
|
+
try:
|
|
2751
|
+
result = subprocess.run(
|
|
2752
|
+
["/bin/sh", "-c", command], capture_output=True, text=True,
|
|
2753
|
+
encoding="utf-8", errors="replace", timeout=tool_settings.command_timeout_sec,
|
|
2754
|
+
)
|
|
2755
|
+
output = ((result.stdout or "") + (result.stderr or "")).strip() or "(no output)"
|
|
2756
|
+
lines = output.splitlines()
|
|
2757
|
+
if len(lines) > 200:
|
|
2758
|
+
output = "\n".join(lines[:100]) + f"\n\n... ({len(lines) - 200} lines omitted) ...\n\n" + "\n".join(lines[-100:])
|
|
2759
|
+
console.print()
|
|
2760
|
+
if result.returncode != 0:
|
|
2761
|
+
console.print(f"[red]exit code: {result.returncode}[/red]")
|
|
2762
|
+
console.print(output)
|
|
2763
|
+
console.print()
|
|
2764
|
+
except subprocess.TimeoutExpired:
|
|
2765
|
+
console.print(f"[red]Command timed out after {tool_settings.command_timeout_sec}s[/red]")
|
|
2766
|
+
except Exception as e:
|
|
2767
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
2768
|
+
return CommandResult(status="handled")
|
|
2769
|
+
|
|
2770
|
+
|
|
2664
2771
|
def process_command(chat_manager, user_input, console, debug_mode_container, cron_scheduler=None):
|
|
2665
2772
|
"""Process command and optionally return replacement content.
|
|
2666
2773
|
|
|
@@ -2681,6 +2788,14 @@ def process_command(chat_manager, user_input, console, debug_mode_container, cro
|
|
|
2681
2788
|
cmd = parts[0].lower()
|
|
2682
2789
|
args = parts[1] if len(parts) > 1 else None
|
|
2683
2790
|
|
|
2791
|
+
# Shell command prefix (:command)
|
|
2792
|
+
if user_input.startswith(":"):
|
|
2793
|
+
shell_cmd = user_input[1:].strip()
|
|
2794
|
+
if shell_cmd:
|
|
2795
|
+
result = _handle_shell_command(console, shell_cmd)
|
|
2796
|
+
return (result.status, result.replacement_input)
|
|
2797
|
+
return ("handled", None)
|
|
2798
|
+
|
|
2684
2799
|
# Look up handler in registry
|
|
2685
2800
|
handler = _COMMAND_HANDLERS.get(cmd)
|
|
2686
2801
|
if handler:
|
package/src/ui/displays.py
CHANGED
|
@@ -70,6 +70,7 @@ def show_help_table(console):
|
|
|
70
70
|
table.add_row("[bold #5F9EA0]/tools[/bold #5F9EA0] [list|enable|disable|enable-group|disable-group]", "Toggle tools or groups (e.g. file_ops, task_mgmt)")
|
|
71
71
|
table.add_row("[bold #5F9EA0]/setup[/bold #5F9EA0]", "Re-run the first-run setup wizard")
|
|
72
72
|
table.add_row("[bold #5F9EA0]/cron[/bold #5F9EA0] [list|add|remove|enable|disable|run]", "Manage scheduled cron jobs")
|
|
73
|
+
table.add_row("[bold #5F9EA0]:[/bold #5F9EA0]<command>", "Run a shell command (e.g. :git status)")
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
console.print(Panel(table, title="[bold #5F9EA0]Commands[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
|