bone-agent 1.3.2 → 1.4.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.
Files changed (87) hide show
  1. package/README.md +19 -2
  2. package/config.yaml.example +13 -2
  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 +50 -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/skills.md +3 -0
  20. package/prompts/main/targeted_searching.md +10 -0
  21. package/prompts/main/task_lists_pattern.md +8 -0
  22. package/prompts/main/temp_folder.md +9 -0
  23. package/prompts/main/think_before_acting.md +10 -0
  24. package/prompts/main/tone_and_style.md +4 -0
  25. package/prompts/main/tool_preferences.md +24 -0
  26. package/prompts/main/trust_subagent_context.md +21 -0
  27. package/prompts/main/when_to_use_sub_agent.md +7 -0
  28. package/prompts/micro/ask_questions.md +1 -0
  29. package/prompts/micro/batch_independent_calls.md +1 -0
  30. package/prompts/micro/casual_interactions.md +1 -0
  31. package/prompts/micro/code_references.md +1 -0
  32. package/prompts/micro/communication_style.md +1 -0
  33. package/prompts/micro/context_reliability.md +1 -0
  34. package/prompts/micro/conversational_tool_calling.md +1 -0
  35. package/prompts/micro/editing_pattern.md +1 -0
  36. package/prompts/micro/error_handling.md +1 -0
  37. package/prompts/micro/exploration_pattern.md +1 -0
  38. package/prompts/micro/intro.md +1 -0
  39. package/prompts/micro/obsidian.md +4 -0
  40. package/prompts/micro/obsidian_project.md +5 -0
  41. package/prompts/micro/professional_objectivity.md +1 -0
  42. package/prompts/micro/skills.md +1 -0
  43. package/prompts/micro/targeted_searching.md +1 -0
  44. package/prompts/micro/task_lists_pattern.md +1 -0
  45. package/prompts/micro/temp_folder.md +1 -0
  46. package/prompts/micro/think_before_acting.md +5 -0
  47. package/prompts/micro/tone_and_style.md +1 -0
  48. package/prompts/micro/tool_preferences.md +1 -0
  49. package/prompts/micro/trust_subagent_context.md +1 -0
  50. package/prompts/micro/when_to_use_sub_agent.md +1 -0
  51. package/src/core/agentic.py +134 -106
  52. package/src/core/chat_manager.py +60 -12
  53. package/src/core/config_manager.py +14 -1
  54. package/src/core/cron.py +57 -6
  55. package/src/core/memory.py +3 -90
  56. package/src/core/metadata.py +75 -0
  57. package/src/core/skills.py +463 -0
  58. package/src/core/sub_agent.py +93 -43
  59. package/src/core/tool_feedback.py +87 -76
  60. package/src/llm/client.py +7 -2
  61. package/src/llm/codex_provider.py +350 -0
  62. package/src/llm/config.py +74 -4
  63. package/src/llm/prompts.py +261 -502
  64. package/src/llm/providers.py +28 -7
  65. package/src/llm/token_tracker.py +32 -1
  66. package/src/tools/__init__.py +24 -85
  67. package/src/tools/create_file.py +1 -1
  68. package/src/tools/directory.py +1 -1
  69. package/src/tools/edit.py +13 -7
  70. package/src/tools/file_reader.py +1 -1
  71. package/src/tools/helpers/__init__.py +1 -7
  72. package/src/tools/helpers/base.py +65 -16
  73. package/src/tools/helpers/loader.py +2 -88
  74. package/src/tools/helpers/path_resolver.py +70 -13
  75. package/src/tools/helpers/plugin_manifest.py +99 -70
  76. package/src/tools/review_sub_agent.py +2 -1
  77. package/src/tools/rg_search.py +119 -35
  78. package/src/tools/search_plugins.py +140 -72
  79. package/src/tools/shell.py +3 -3
  80. package/src/ui/commands.py +470 -33
  81. package/src/ui/displays.py +27 -1
  82. package/src/ui/main.py +1 -4
  83. package/src/ui/tool_confirmation.py +16 -5
  84. package/src/utils/editor.py +88 -39
  85. package/src/utils/settings.py +25 -4
  86. package/src/utils/user_message_logger.py +120 -0
  87. package/src/utils/validation.py +10 -0
@@ -22,7 +22,7 @@ def show_provider_table(current_provider: str, console):
22
22
  else:
23
23
  status = "✅" if cfg.get("api_key") else "❌ (set API key)"
24
24
  active = " [green](active)[/green]" if provider == current_provider else ""
25
- table.add_row(provider.capitalize(), status, f"{model[:40]}{active}")
25
+ table.add_row(config.get_provider_display_name(provider), status, f"{model[:40]}{active}")
26
26
 
27
27
  console.print(table)
28
28
 
@@ -66,10 +66,12 @@ def show_help_table(console):
66
66
  table.add_row("[bold #5F9EA0]/cd[/bold #5F9EA0] [path]", "Change working directory (no args to show current)")
67
67
  table.add_row("[bold #5F9EA0]/edit[/bold #5F9EA0], [bold #5F9EA0]/e[/bold #5F9EA0]", "Open editor for multi-line input")
68
68
  table.add_row("[bold #5F9EA0]/review[/bold #5F9EA0] [args], [bold #5F9EA0]/r[/bold #5F9EA0]", "Code review git changes (e.g. /review --staged, /review main..HEAD)")
69
+ table.add_row("[bold #5F9EA0]/skills[/bold #5F9EA0] [list|add|modify|remove|use]", "Manage reusable prompt skills")
69
70
  table.add_row("[bold #5F9EA0]/obsidian[/bold #5F9EA0] [set|enable|disable|status|init]", "Manage vault integration, scaffold project folders")
70
71
  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
72
  table.add_row("[bold #5F9EA0]/setup[/bold #5F9EA0]", "Re-run the first-run setup wizard")
72
73
  table.add_row("[bold #5F9EA0]/cron[/bold #5F9EA0] [list|add|remove|enable|disable|run]", "Manage scheduled cron jobs")
74
+ table.add_row("[bold #5F9EA0]:[/bold #5F9EA0]<command>", "Run a shell command (e.g. :git status)")
73
75
 
74
76
 
75
77
  console.print(Panel(table, title="[bold #5F9EA0]Commands[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
@@ -141,6 +143,30 @@ def show_cron_help_table(console):
141
143
  console.print("")
142
144
 
143
145
 
146
+ def show_skills_help_table(console):
147
+ """Display skills command help table.
148
+
149
+ Args:
150
+ console: Rich Console instance for output.
151
+ """
152
+ console.print("")
153
+ table = Table(show_header=True, box=box.SIMPLE_HEAD)
154
+ table.add_column("Command", no_wrap=True)
155
+ table.add_column("Description")
156
+
157
+ table.add_row("[bold #5F9EA0]/skills list[/bold #5F9EA0]", "List skills")
158
+ table.add_row("[bold #5F9EA0]/skills add[/bold #5F9EA0] <name>", "Create a skill in your editor")
159
+ table.add_row("[bold #5F9EA0]/skills edit[/bold #5F9EA0] <name>", "Edit an existing skill")
160
+ table.add_row("[bold #5F9EA0]/skills modify[/bold #5F9EA0] <name> <prompt>", "Replace a skill")
161
+ table.add_row("[bold #5F9EA0]/skills show[/bold #5F9EA0] <name>", "Show a skill")
162
+ table.add_row("[bold #5F9EA0]/skills load[/bold #5F9EA0] <name>", "Load a skill into this chat")
163
+ table.add_row("[bold #5F9EA0]/skills remove[/bold #5F9EA0] <name>", "Delete a skill")
164
+ table.add_row("[bold #5F9EA0]/skills dir[/bold #5F9EA0]", "Show the skills directory")
165
+
166
+ console.print(Panel(table, title="[bold #5F9EA0]Skills[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
167
+ console.print("")
168
+
169
+
144
170
  def show_config_overview(chat_manager, console, debug_mode_container, current_provider):
145
171
  """Display comprehensive configuration overview.
146
172
 
package/src/ui/main.py CHANGED
@@ -35,7 +35,6 @@ from core.agentic import agentic_answer
35
35
  from utils.settings import MonokaiDarkBGStyle, left_align_headings
36
36
  from utils.paths import REPO_ROOT, RG_EXE_PATH
37
37
  from exceptions import BoneAgentError
38
- from tools.loader import load_all_tools
39
38
 
40
39
  # Console setup
41
40
  console = Console(theme=Theme({
@@ -382,9 +381,6 @@ def main():
382
381
  """Main interactive chat loop."""
383
382
 
384
383
  # Load all tools (built-in and user tools)
385
- # This populates the ToolRegistry with all decorated tools
386
- load_all_tools()
387
-
388
384
  # Check for config.yaml — run setup wizard on first run
389
385
  from ui.setup_wizard import is_first_run, run_wizard as _run_setup_wizard
390
386
 
@@ -507,6 +503,7 @@ def main():
507
503
  thinking_indicator.start()
508
504
  INPUT_BLOCKED['blocked'] = True
509
505
  try:
506
+ console.print("─" * console.width, style="rgb(30,30,30)")
510
507
  console.print() # Extra newline after user input to separate from LLM response
511
508
  # Add user message
512
509
  if TOOLS_ENABLED:
@@ -1,6 +1,7 @@
1
1
  """Interactive tool confirmation panel with arrow key navigation."""
2
2
 
3
3
  import asyncio
4
+ from html import escape
4
5
  from threading import Timer
5
6
  from typing import Optional, Tuple
6
7
  from prompt_toolkit import HTML
@@ -50,6 +51,16 @@ class ToolConfirmationPanel:
50
51
  # Use appropriate options based on tool type
51
52
  self._options = self.EDIT_OPTIONS if is_edit_tool else self.STANDARD_OPTIONS
52
53
 
54
+ def _append_field(self, lines: list[str], label: str, value: object, *, formatted: bool = False) -> None:
55
+ """Append a field to the panel, escaping untrusted values by default."""
56
+ value_text = str(value)
57
+ if not formatted:
58
+ value_text = escape(value_text)
59
+ value_lines = value_text.splitlines() or [""]
60
+ lines.append(f"<b>{escape(label)}:</b> {value_lines[0]}")
61
+ for continuation in value_lines[1:]:
62
+ lines.append(f" {continuation}")
63
+
53
64
  def _get_display_text(self) -> HTML:
54
65
  """Get the formatted text to display.
55
66
 
@@ -66,14 +77,14 @@ class ToolConfirmationPanel:
66
77
 
67
78
  lines.append("<b>Selection Summary</b>")
68
79
  lines.append("")
69
- lines.append(f"<b>Tool:</b> {self.tool_command}")
70
- lines.append(f'<style fg="gray"> Selected: {str(selected_text)}</style>')
80
+ self._append_field(lines, "Tool", self.tool_command)
81
+ lines.append(f'<style fg="gray"> Selected: {escape(str(selected_text))}</style>')
71
82
  lines.append("")
72
83
  else:
73
84
  # Tool information
74
- lines.append(f"<b>Tool:</b> {self.tool_command}")
85
+ self._append_field(lines, "Tool", self.tool_command)
75
86
  if self.reason:
76
- lines.append(f"<b>Reason:</b> {self.reason}")
87
+ self._append_field(lines, "Reason", self.reason)
77
88
  lines.append("")
78
89
 
79
90
  # Render options
@@ -87,7 +98,7 @@ class ToolConfirmationPanel:
87
98
  # Unselected option - dark grey
88
99
  lines.append(f'<style fg="gray"> {text}</style>')
89
100
 
90
- return HTML("\n".join(lines))
101
+ return HTML("\n".join(lines))
91
102
 
92
103
  def _exit_with_summary(self, event, result: str) -> None:
93
104
  """Show summary screen and exit application after delay.
@@ -1,6 +1,7 @@
1
1
  """External editor integration for bone-agent."""
2
2
  import os
3
3
  import platform
4
+ import shlex
4
5
  import subprocess
5
6
  import tempfile
6
7
  import shutil
@@ -12,27 +13,30 @@ def get_editor() -> str:
12
13
  """Get editor command with OS-specific defaults.
13
14
 
14
15
  Priority:
15
- 1. EDITOR environment variable
16
- 2. OS defaults: notepad.exe (Windows) | nano/vi/vim (Unix)
16
+ 1. Windows: notepad.exe
17
+ 2. Linux: nvim, then EDITOR, then nano/vi/vim
18
+ 3. Other Unix: EDITOR, then nano/vi/vim
17
19
 
18
20
  Returns:
19
21
  str: Editor command or path
20
22
  """
21
- # 1. Check environment variable
23
+ system = platform.system()
24
+
25
+ if system == "Windows":
26
+ return "notepad.exe"
27
+
28
+ if system == "Linux" and shutil.which("nvim"):
29
+ return "nvim"
30
+
22
31
  editor = os.environ.get("EDITOR")
23
32
  if editor and editor.strip():
24
33
  return editor.strip()
25
34
 
26
- # 2. OS-specific defaults
27
- if platform.system() == "Windows":
28
- return "notepad.exe"
29
- else:
30
- # Try to find common editors
31
- for cmd in ["nvim", "nano", "vi", "vim"]:
32
- if shutil.which(cmd):
33
- return cmd
34
- # Fallback to nano even if not found (will error later)
35
- return "nano"
35
+ for cmd in ["nano", "vi", "vim"]:
36
+ if shutil.which(cmd):
37
+ return cmd
38
+
39
+ return "nano"
36
40
 
37
41
 
38
42
  def _create_temp_file() -> Tuple[Path, object]:
@@ -74,24 +78,38 @@ def _strip_comment_lines(content: str) -> str:
74
78
  return '\n'.join(lines).strip()
75
79
 
76
80
 
77
- def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Optional[str]]:
78
- """Open external editor and return user input.
81
+ def _build_editor_command(editor_cmd: str, temp_path: Path) -> Tuple[bool, str | list[str]]:
82
+ """Build a subprocess command for launching the configured editor."""
83
+ use_shell = platform.system() == "Windows"
84
+ if use_shell:
85
+ return use_shell, f'{editor_cmd} "{temp_path}"'
79
86
 
80
- Args:
81
- console: Rich console for output
82
- debug_mode: Whether to show debug information
87
+ try:
88
+ command_parts = shlex.split(editor_cmd)
89
+ except ValueError as e:
90
+ raise ValueError(f"Invalid EDITOR command: {e}") from e
83
91
 
84
- Returns:
85
- tuple: (success: bool, content: str or None)
86
- - (True, content) if successful
87
- - (False, None) if failed or cancelled
88
- """
89
- editor_cmd = get_editor()
92
+ if not command_parts:
93
+ raise FileNotFoundError(editor_cmd)
94
+
95
+ return use_shell, [*command_parts, str(temp_path)]
96
+
97
+
98
+ def _open_editor_with_temp_file(
99
+ console,
100
+ editor_cmd: str,
101
+ debug_mode: bool = False,
102
+ initial_content: str = "",
103
+ post_process=None,
104
+ ) -> Tuple[bool, Optional[str]]:
105
+ """Open the configured editor for a temporary file and return the saved content."""
90
106
  temp_path = None
91
107
 
92
108
  try:
93
- # Create temporary file
94
109
  temp_path, temp_fd = _create_temp_file()
110
+ if initial_content:
111
+ temp_fd.write(initial_content)
112
+ temp_fd.flush()
95
113
  temp_fd.close()
96
114
 
97
115
  if debug_mode:
@@ -101,24 +119,19 @@ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Opti
101
119
  console.print("[#5F9EA0]Opening editor...[/#5F9EA0]")
102
120
  console.print("[dim]Save and close the editor when done[/dim]")
103
121
 
104
- # Launch editor and wait for it to close
105
- # Use shell=True on Windows for notepad, False for better security on Unix
106
- use_shell = platform.system() == "Windows"
107
-
122
+ use_shell, command = _build_editor_command(editor_cmd, temp_path)
108
123
  result = subprocess.run(
109
- [editor_cmd, str(temp_path)],
124
+ command,
110
125
  shell=use_shell,
111
- check=False # Don't raise on non-zero exit
126
+ check=False,
112
127
  )
113
128
 
114
129
  if result.returncode != 0 and debug_mode:
115
130
  console.print(f"[yellow]Editor exited with code {result.returncode}[/yellow]")
116
131
 
117
- # Read content from temp file
118
- content = temp_path.read_text(encoding='utf-8')
119
-
120
- # Strip comment lines
121
- content = _strip_comment_lines(content)
132
+ content = temp_path.read_text(encoding="utf-8")
133
+ if post_process is not None:
134
+ content = post_process(content)
122
135
 
123
136
  if debug_mode:
124
137
  console.print(f"[dim]Read {len(content)} characters[/dim]")
@@ -145,14 +158,50 @@ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Opti
145
158
  return (False, None)
146
159
 
147
160
  finally:
148
- # Cleanup temp file
149
161
  if temp_path and temp_path.exists():
150
162
  try:
151
163
  temp_path.unlink()
152
164
  if debug_mode:
153
- console.print(f"[dim]Cleaned up temp file[/dim]")
165
+ console.print("[dim]Cleaned up temp file[/dim]")
154
166
  except Exception as e:
155
- # Log but don't crash on cleanup failure
156
167
  console.print(f"[yellow]Warning: Failed to delete temp file: {e}[/yellow]")
157
168
  if debug_mode:
158
169
  console.print(f"[dim]Temp file may remain at: {temp_path}[/dim]")
170
+
171
+
172
+ def open_editor_for_input(console, debug_mode: bool = False) -> Tuple[bool, Optional[str]]:
173
+ """Open external editor and return user input.
174
+
175
+ Args:
176
+ console: Rich console for output
177
+ debug_mode: Whether to show debug information
178
+
179
+ Returns:
180
+ tuple: (success: bool, content: str or None)
181
+ - (True, content) if successful
182
+ - (False, None) if failed or cancelled
183
+ """
184
+ return _open_editor_with_temp_file(
185
+ console,
186
+ editor_cmd=get_editor(),
187
+ debug_mode=debug_mode,
188
+ post_process=_strip_comment_lines,
189
+ )
190
+
191
+
192
+ def open_editor_for_content(
193
+ console,
194
+ initial_content: str = "",
195
+ debug_mode: bool = False,
196
+ ) -> Tuple[bool, Optional[str]]:
197
+ """Open external editor with initial content and return the saved file.
198
+
199
+ Unlike open_editor_for_input, this preserves markdown comment lines so it can
200
+ edit existing markdown files without dropping headings or notes.
201
+ """
202
+ return _open_editor_with_temp_file(
203
+ console,
204
+ editor_cmd=get_editor(),
205
+ debug_mode=debug_mode,
206
+ initial_content=initial_content or "",
207
+ )
@@ -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
 
@@ -47,6 +51,7 @@ class ToolSettings:
47
51
  max_shell_output_lines: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_shell_output_lines", 200))
48
52
  max_file_preview_lines: int = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("max_file_preview_lines", 200))
49
53
  disabled_tools: list = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("disabled_tools", []))
54
+ hidden_skills: list = field(default_factory=lambda: _CONFIG.get("TOOL_SETTINGS", {}).get("hidden_skills", []))
50
55
 
51
56
  @dataclass
52
57
  class FileSettings:
@@ -76,11 +81,14 @@ class ToolCompactionSettings:
76
81
  @dataclass
77
82
  class SubAgentSettings:
78
83
  """Sub-agent token limits and behavior configuration."""
79
- soft_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("soft_limit_tokens", 300_000))
80
- hard_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("hard_limit_tokens", 500_000))
84
+ soft_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("soft_limit_tokens", 100_000))
85
+ hard_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("hard_limit_tokens", 150_000))
86
+ billed_warning_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("billed_warning_tokens", 200_000))
87
+ billed_hard_limit_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("billed_hard_limit_tokens", 500_000))
81
88
  enable_compaction: bool = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("enable_compaction", True))
82
89
  compact_trigger_tokens: int = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("compact_trigger_tokens", 50_000))
83
90
  allowed_tools: list = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("allowed_tools", ["rg", "read_file", "list_directory", "web_search"]))
91
+ allow_active_plugins: bool = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("allow_active_plugins", False))
84
92
  dump_context_on_hard_limit: bool = field(default_factory=lambda: _CONFIG.get("SUB_AGENT_SETTINGS", {}).get("dump_context_on_hard_limit", True))
85
93
 
86
94
 
@@ -104,6 +112,18 @@ class ContextSettings:
104
112
  self.hard_limit_tokens = int(self.max_context_window * 0.9)
105
113
 
106
114
 
115
+ @dataclass
116
+ class PromptSettings:
117
+ """Prompt variant selection."""
118
+ variant: str = field(default_factory=lambda: _CONFIG.get("PROMPT_SETTINGS", {}).get("variant", "micro"))
119
+
120
+
121
+ @dataclass
122
+ class DreamSettings:
123
+ """Dream memory consolidation settings."""
124
+ enabled: bool = field(default_factory=lambda: _CONFIG.get("DREAM_SETTINGS", {}).get("enabled", True))
125
+
126
+
107
127
  @dataclass
108
128
  class ObsidianSettings:
109
129
  """Obsidian vault integration settings.
@@ -165,8 +185,9 @@ tool_settings = ToolSettings()
165
185
  file_settings = FileSettings()
166
186
  context_settings = ContextSettings()
167
187
  sub_agent_settings = SubAgentSettings()
188
+ dream_settings = DreamSettings()
168
189
  obsidian_settings = ObsidianSettings()
169
-
190
+ prompt_settings = PromptSettings()
170
191
  # Tool execution constants
171
192
  MAX_TOOL_CALLS = tool_settings.max_tool_calls
172
193
  MAX_COMMAND_OUTPUT_LINES = tool_settings.max_command_output_lines
@@ -0,0 +1,120 @@
1
+ """Lightweight user-message logger for the dream memory system.
2
+
3
+ Appends one JSONL line per user message, one file per day per project.
4
+ Always on by default — no toggle needed.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ import logging
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Base directory for daily message logs
16
+ CONVERSATIONS_DIR = Path.home() / ".bone" / "conversations"
17
+ RETENTION_DAYS = 7
18
+
19
+
20
+ def _project_suffix(project_dir: Path) -> str:
21
+ """Generate a short suffix from a project directory path.
22
+
23
+ Format: {dirname}_{first 6 chars of SHA256(path)}
24
+ Avoids collisions between repos with the same folder name.
25
+ """
26
+ path_str = str(project_dir.resolve())
27
+ h = hashlib.sha256(path_str.encode()).hexdigest()[:6]
28
+ return f"{project_dir.name}_{h}"
29
+
30
+
31
+ PROJECT_INDEX_FILE = CONVERSATIONS_DIR / ".project_index.jsonl"
32
+
33
+
34
+ def _register_project(key: str, project_dir: Path) -> None:
35
+ """Append a key→path mapping to the project index if not already present."""
36
+ resolved = str(project_dir.resolve())
37
+ # Check if this key already maps to this path
38
+ if PROJECT_INDEX_FILE.exists():
39
+ with open(PROJECT_INDEX_FILE, "r", encoding="utf-8") as f:
40
+ for line in f:
41
+ try:
42
+ entry = json.loads(line)
43
+ except json.JSONDecodeError:
44
+ continue
45
+ if entry.get("key") == key and entry.get("path") == resolved:
46
+ return # Already indexed
47
+ PROJECT_INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
48
+ with open(PROJECT_INDEX_FILE, "a", encoding="utf-8") as f:
49
+ f.write(json.dumps({"key": key, "path": resolved}) + "\n")
50
+
51
+
52
+ class UserMessageLogger:
53
+ """Logs user messages to daily JSONL files for later dream processing.
54
+
55
+ When a project_dir is provided, messages go to a per-project file:
56
+ {date}__{dirname}_{hash}.jsonl
57
+ Without a project_dir, messages go to the catch-all:
58
+ {date}.jsonl
59
+ """
60
+
61
+ def __init__(self, conversations_dir: Path | None = None):
62
+ self._dir = conversations_dir or CONVERSATIONS_DIR
63
+ self._dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ def log_user_message(self, content: str, project_dir: Path | None = None) -> None:
66
+ """Append a single user message to today's JSONL file.
67
+
68
+ Args:
69
+ content: The user message text.
70
+ project_dir: Optional project root directory. If provided,
71
+ messages are written to a per-project file.
72
+
73
+ Opens in append mode and flushes immediately for crash safety.
74
+ Each message is one self-contained JSON line.
75
+ """
76
+ today = datetime.now().strftime("%Y-%m-%d")
77
+ if project_dir:
78
+ suffix = _project_suffix(project_dir)
79
+ _register_project(suffix, project_dir)
80
+ filepath = self._dir / f"{today}__{suffix}.jsonl"
81
+ else:
82
+ filepath = self._dir / f"{today}.jsonl"
83
+ entry = {"ts": datetime.now().isoformat(), "msg": content}
84
+ with open(filepath, "a", encoding="utf-8") as f:
85
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
86
+
87
+ @staticmethod
88
+ def cleanup_old_files(directory: Path | None = None, retention_days: int = RETENTION_DAYS) -> int:
89
+ """Delete JSONL files older than retention_days. Returns count of files removed."""
90
+ target_dir = directory or CONVERSATIONS_DIR
91
+ if not target_dir.exists():
92
+ return 0
93
+
94
+ cutoff = datetime.now() - timedelta(days=retention_days)
95
+ removed = 0
96
+ surviving = set()
97
+ for f in target_dir.glob("*.jsonl"):
98
+ if f.stat().st_mtime < cutoff.timestamp():
99
+ f.unlink()
100
+ removed += 1
101
+ logger.debug("Removed old conversation log: %s", f.name)
102
+ else:
103
+ surviving.add(f.name)
104
+
105
+ # Prune stale entries from the project index
106
+ index_file = target_dir / ".project_index.jsonl"
107
+ if index_file.exists():
108
+ kept: list[str] = []
109
+ for line in index_file.read_text(encoding="utf-8").splitlines():
110
+ try:
111
+ entry = json.loads(line)
112
+ except json.JSONDecodeError:
113
+ continue
114
+ key = entry.get("key", "")
115
+ # Keep entry if any file matching its key still exists
116
+ if any(key in name for name in surviving):
117
+ kept.append(line)
118
+ index_file.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8")
119
+
120
+ return removed
@@ -167,6 +167,16 @@ def check_command(command):
167
167
  if command.lower().startswith("powershell"):
168
168
  return False, "nested powershell invocation"
169
169
 
170
+ # Multi-line shell scripts/heredocs are valid command payloads.
171
+ # Do not tokenize the full script here: tokenization can reject valid shell
172
+ # syntax before the shell sees it. Safety/approval checks happen upstream.
173
+ if "\n" in command:
174
+ for line in command.splitlines():
175
+ line = line.strip()
176
+ if line and line.lower().startswith("powershell"):
177
+ return False, "nested powershell invocation"
178
+ return True, None
179
+
170
180
  # Basic validation - ensure command has content
171
181
  tokens = _tokenize_segment(command)
172
182
  if not tokens: