bone-agent 1.3.2 → 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.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/config.yaml.example +8 -0
  3. package/package.json +3 -2
  4. package/prompts/main/ask_questions.md +31 -0
  5. package/prompts/main/batch_independent_calls.md +5 -0
  6. package/prompts/main/casual_interactions.md +11 -0
  7. package/prompts/main/code_references.md +8 -0
  8. package/prompts/main/communication_style.md +12 -0
  9. package/prompts/main/context_reliability.md +12 -0
  10. package/prompts/main/conversational_tool_calling.md +15 -0
  11. package/prompts/main/dream.md +36 -0
  12. package/prompts/main/editing_pattern.md +13 -0
  13. package/prompts/main/error_handling.md +6 -0
  14. package/prompts/main/exploration_pattern.md +21 -0
  15. package/prompts/main/intro.md +1 -0
  16. package/prompts/main/obsidian.md +16 -0
  17. package/prompts/main/obsidian_project.md +79 -0
  18. package/prompts/main/professional_objectivity.md +3 -0
  19. package/prompts/main/targeted_searching.md +10 -0
  20. package/prompts/main/task_lists_pattern.md +8 -0
  21. package/prompts/main/temp_folder.md +9 -0
  22. package/prompts/main/think_before_acting.md +10 -0
  23. package/prompts/main/tone_and_style.md +4 -0
  24. package/prompts/main/tool_preferences.md +24 -0
  25. package/prompts/main/trust_subagent_context.md +21 -0
  26. package/prompts/main/when_to_use_sub_agent.md +7 -0
  27. package/prompts/micro/ask_questions.md +1 -0
  28. package/prompts/micro/batch_independent_calls.md +1 -0
  29. package/prompts/micro/casual_interactions.md +1 -0
  30. package/prompts/micro/code_references.md +1 -0
  31. package/prompts/micro/communication_style.md +1 -0
  32. package/prompts/micro/context_reliability.md +1 -0
  33. package/prompts/micro/conversational_tool_calling.md +1 -0
  34. package/prompts/micro/editing_pattern.md +1 -0
  35. package/prompts/micro/error_handling.md +1 -0
  36. package/prompts/micro/exploration_pattern.md +1 -0
  37. package/prompts/micro/intro.md +1 -0
  38. package/prompts/micro/obsidian.md +4 -0
  39. package/prompts/micro/obsidian_project.md +5 -0
  40. package/prompts/micro/professional_objectivity.md +1 -0
  41. package/prompts/micro/targeted_searching.md +1 -0
  42. package/prompts/micro/task_lists_pattern.md +1 -0
  43. package/prompts/micro/temp_folder.md +1 -0
  44. package/prompts/micro/think_before_acting.md +5 -0
  45. package/prompts/micro/tone_and_style.md +1 -0
  46. package/prompts/micro/tool_preferences.md +1 -0
  47. package/prompts/micro/trust_subagent_context.md +1 -0
  48. package/prompts/micro/when_to_use_sub_agent.md +1 -0
  49. package/src/core/agentic.py +1 -73
  50. package/src/core/chat_manager.py +42 -7
  51. package/src/core/config_manager.py +6 -0
  52. package/src/core/cron.py +57 -2
  53. package/src/core/memory.py +3 -90
  54. package/src/llm/config.py +28 -2
  55. package/src/llm/prompts.py +251 -497
  56. package/src/llm/providers.py +25 -6
  57. package/src/llm/token_tracker.py +17 -1
  58. package/src/tools/edit.py +8 -6
  59. package/src/tools/helpers/path_resolver.py +18 -12
  60. package/src/tools/rg_search.py +97 -30
  61. package/src/ui/commands.py +120 -5
  62. package/src/ui/displays.py +1 -0
  63. package/src/ui/main.py +1 -0
  64. package/src/utils/settings.py +19 -2
  65. package/src/utils/user_message_logger.py +120 -0
@@ -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': anthropic_usage.get('input_tokens', 0),
179
- 'completion_tokens': anthropic_usage.get('output_tokens', 0),
180
- 'total_tokens': anthropic_usage.get('input_tokens', 0) + anthropic_usage.get('output_tokens', 0),
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': usage_data.get('input_tokens', 0),
285
- 'completion_tokens': usage_data.get('output_tokens', 0),
286
- 'total_tokens': usage_data.get('input_tokens', 0) + usage_data.get('output_tokens', 0),
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
@@ -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
- computed = self._calculate_cost(prompt_tokens, completion_tokens, cost_in, cost_out)
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 writes that the
227
- # system itself adds to .gitignore, so gitignore filtering would block them.
228
- is_memory = Path(path).parent.name == ".bone" or Path(path).name == "user_memory.md"
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 on first write.
240
- # These are already auto-approved, so directory+file creation is safe.
241
- if file_path.parent.name == ".bone" or file_path.name == "user_memory.md":
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 or vault_path
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
- if self.vault_path is not None:
116
- try:
117
- path.relative_to(self.vault_path)
118
- except ValueError:
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("outside_allowed_roots")
131
+ _track_validation_error("outside_repo")
121
132
  _path_resolution_times.append(elapsed)
122
- return None, f"Path is outside allowed directories: {path_str}"
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:
@@ -2,21 +2,84 @@
2
2
 
3
3
  import logging
4
4
  import re
5
- import shlex
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 _prepare_execution_environment, run_shell_command
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 command from arguments
107
- cmd_parts = ["rg"]
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
- cmd_parts.append("--line-number")
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
- cmd_parts.append("-U")
117
- cmd_parts.append("--multiline-dotall")
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
- cmd_parts.append("--ignore-case")
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
- cmd_parts.append(f"--context={context_lines}")
190
+ args.append(f"--context={context_lines}")
128
191
 
129
192
  # Add glob pattern
130
193
  if glob:
131
- cmd_parts.append(f"--glob={glob}")
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
- cmd_parts.append(f"--type={file_type}")
199
+ args.append(f"--type={file_type}")
137
200
 
138
- # Add files-with-matches flag for count mode
201
+ # Add output mode flags
139
202
  if output_mode == "files_with_matches":
140
- cmd_parts.append("--files-with-matches")
203
+ args.append("--files-with-matches")
141
204
  elif output_mode == "count":
142
- cmd_parts.append("--count")
205
+ args.append("--count")
143
206
 
144
- # Add pattern - quote if it contains spaces
145
- if " " in pattern:
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
- cmd_parts.append(search_path)
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
- repo_result = run_shell_command(
164
- command, repo_root, rg_exe_path, console, debug_mode, gitignore_spec,
165
- max_matches=max_matches
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=30,
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,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 / conv_in * 100
566
- ) if conv_in > 0 else 0
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}% of input served from cache)")
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
- / tracker.total_prompt_tokens * 100
1071
- ) if tracker.total_prompt_tokens > 0 else 0
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:
@@ -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)))
package/src/ui/main.py CHANGED
@@ -507,6 +507,7 @@ def main():
507
507
  thinking_indicator.start()
508
508
  INPUT_BLOCKED['blocked'] = True
509
509
  try:
510
+ console.print("─" * console.width, style="rgb(30,30,30)")
510
511
  console.print() # Extra newline after user input to separate from LLM response
511
512
  # Add user message
512
513
  if TOOLS_ENABLED:
@@ -28,10 +28,14 @@ def left_align_headings(text: str) -> str:
28
28
  @dataclass
29
29
  class ServerSettings:
30
30
  """Local llama-server configuration."""
31
- ngl_layers: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("ngl_layers", 30))
31
+ ngl_layers: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("ngl_layers", 99))
32
32
  ctx_size: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("ctx_size", 8192))
33
33
  n_predict: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("n_predict", 8192))
34
34
  rope_scale: float = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("rope_scale", 1.0))
35
+ threads: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("threads", 4))
36
+ batch_size: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("batch_size", 2048))
37
+ ubatch_size: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("ubatch_size", 512))
38
+ flash_attn: bool = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("flash_attn", True))
35
39
  health_check_timeout_sec: int = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("health_check_timeout_sec", 120))
36
40
  health_check_interval_sec: float = field(default_factory=lambda: _CONFIG.get("SERVER_SETTINGS", {}).get("health_check_interval_sec", 1.0))
37
41
 
@@ -104,6 +108,18 @@ class ContextSettings:
104
108
  self.hard_limit_tokens = int(self.max_context_window * 0.9)
105
109
 
106
110
 
111
+ @dataclass
112
+ class PromptSettings:
113
+ """Prompt variant selection."""
114
+ variant: str = field(default_factory=lambda: _CONFIG.get("PROMPT_SETTINGS", {}).get("variant", "micro"))
115
+
116
+
117
+ @dataclass
118
+ class DreamSettings:
119
+ """Dream memory consolidation settings."""
120
+ enabled: bool = field(default_factory=lambda: _CONFIG.get("DREAM_SETTINGS", {}).get("enabled", True))
121
+
122
+
107
123
  @dataclass
108
124
  class ObsidianSettings:
109
125
  """Obsidian vault integration settings.
@@ -165,8 +181,9 @@ tool_settings = ToolSettings()
165
181
  file_settings = FileSettings()
166
182
  context_settings = ContextSettings()
167
183
  sub_agent_settings = SubAgentSettings()
184
+ dream_settings = DreamSettings()
168
185
  obsidian_settings = ObsidianSettings()
169
-
186
+ prompt_settings = PromptSettings()
170
187
  # Tool execution constants
171
188
  MAX_TOOL_CALLS = tool_settings.max_tool_calls
172
189
  MAX_COMMAND_OUTPUT_LINES = tool_settings.max_command_output_lines