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
@@ -1,12 +1,14 @@
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
7
9
 
8
10
  from core.config_manager import ConfigManager as ConfigManagerClass
9
- from ui.displays import show_help_table, show_cron_help_table
11
+ from ui.displays import show_help_table, show_cron_help_table, show_skills_help_table
10
12
  from ui.banner import display_startup_banner
11
13
  from core.agentic import SubAgentPanel
12
14
  from ui.setting_selector import SettingSelector, SettingCategory, SettingOption
@@ -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,16 @@ 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:
564
- cache_hit_pct = (
593
+ total_cached = conv_cache_read + conv_cache_creation
594
+ cache_activity_read_pct = (
595
+ conv_cache_read / total_cached * 100
596
+ ) if total_cached > 0 else 0
597
+ cache_coverage_pct = (
565
598
  conv_cache_read / conv_in * 100
566
599
  ) if conv_in > 0 else 0
567
600
  console.print(f" Cache read: {conv_cache_read:,} tokens")
568
601
  console.print(f" Cache write: {conv_cache_creation:,} tokens")
569
- console.print(f" ({cache_hit_pct:.0f}% of input served from cache)")
602
+ console.print(f" ({cache_coverage_pct:.0f}% input cached, {cache_activity_read_pct:.0f}% cache reads)")
570
603
 
571
604
  # Display cost — combined actual + estimated, with config-based fallback
572
605
  tracker_conv = chat_manager.token_tracker
@@ -635,11 +668,12 @@ def _open_provider_editor(chat_manager, console, provider):
635
668
  min_val=0.0, step=0.01,
636
669
  ))
637
670
 
638
- category = SettingCategory(title=f"{provider.capitalize()} Settings", settings=settings)
671
+ provider_label = config.get_provider_display_name(provider)
672
+ category = SettingCategory(title=f"{provider_label} Settings", settings=settings)
639
673
 
640
674
  selector = SettingSelector(
641
675
  categories=[category],
642
- title=f"Configure {provider.capitalize()}",
676
+ title=f"Configure {provider_label}",
643
677
  )
644
678
 
645
679
  changes = selector.run()
@@ -712,7 +746,8 @@ def _handle_provider(chat_manager, console, debug_mode_container, args, cron_sch
712
746
  # Validate provider name
713
747
  if provider not in config.get_providers():
714
748
  console.print(f"[red]Error: Unknown provider '{provider}'[/red]")
715
- console.print(f"[dim]Available providers: {', '.join(config.get_providers())}[/dim]")
749
+ available = ', '.join(config.get_provider_display_name(prov) for prov in config.get_providers())
750
+ console.print(f"[dim]Available providers: {available}[/dim]")
716
751
  return CommandResult(status="handled")
717
752
 
718
753
  # Switch directly to the named provider
@@ -726,7 +761,7 @@ def _handle_provider(chat_manager, console, debug_mode_container, args, cron_sch
726
761
 
727
762
  cfg = config.get_provider_config(provider)
728
763
  model = cfg.get('model') or cfg.get('api_model') or ''
729
- label = f"{provider.capitalize()}"
764
+ label = config.get_provider_display_name(provider)
730
765
  if model:
731
766
  label += f" ({model})"
732
767
  console.print(f"[green]Switched to {label}[/green]")
@@ -742,7 +777,7 @@ def _handle_provider(chat_manager, console, debug_mode_container, args, cron_sch
742
777
  for prov in config.get_providers():
743
778
  cfg = config.get_provider_config(prov)
744
779
  model = cfg.get('model') or cfg.get('api_model') or ''
745
- entry = {"value": prov, "text": prov.capitalize()}
780
+ entry = {"value": prov, "text": config.get_provider_display_name(prov)}
746
781
  if model:
747
782
  entry["description"] = model[:40]
748
783
  provider_options.append(entry)
@@ -1062,17 +1097,38 @@ def _handle_usage(chat_manager, console, debug_mode_container, args, cron_schedu
1062
1097
  console.print(f" Output tokens: {tracker.total_completion_tokens:,}")
1063
1098
  console.print(f" Total tokens: {tracker.total_tokens:,}")
1064
1099
 
1065
- # Display cache token breakdown (if any cache tokens were recorded)
1066
- has_cache = tracker.total_cache_read_tokens > 0 or tracker.total_cache_creation_tokens > 0
1067
- if has_cache:
1068
- cache_hit_pct = (
1100
+ # Display cache token breakdown when cache tokens were recorded.
1101
+ # Codex can report an explicit 0 cached_tokens value, which is still useful
1102
+ # confirmation that prompt-cache usage data is flowing through.
1103
+ show_cache = (
1104
+ tracker.total_cache_read_tokens > 0
1105
+ or tracker.total_cache_creation_tokens > 0
1106
+ or current_provider == "codex"
1107
+ )
1108
+ if show_cache:
1109
+ total_cached = tracker.total_cache_read_tokens + tracker.total_cache_creation_tokens
1110
+ cache_activity_read_pct = (
1111
+ tracker.total_cache_read_tokens
1112
+ / total_cached * 100
1113
+ ) if total_cached > 0 else 0
1114
+ cache_coverage_pct = (
1069
1115
  tracker.total_cache_read_tokens
1070
1116
  / tracker.total_prompt_tokens * 100
1071
1117
  ) if tracker.total_prompt_tokens > 0 else 0
1072
1118
  console.print()
1073
- console.print(f"[#5F9EA0]Input Cache ({cache_hit_pct:.0f}% hit rate):[/#5F9EA0]")
1119
+ console.print(
1120
+ f"[#5F9EA0]Input Cache ({cache_coverage_pct:.0f}% input cached, "
1121
+ f"{cache_activity_read_pct:.0f}% cache reads):[/#5F9EA0]"
1122
+ )
1074
1123
  console.print(f" Cache read: {tracker.total_cache_read_tokens:,} tokens")
1075
1124
  console.print(f" Cache write: {tracker.total_cache_creation_tokens:,} tokens")
1125
+ if current_provider == "codex" and total_cached == 0:
1126
+ if tracker.last_cache_metrics_reported is False:
1127
+ keys = ", ".join(tracker.last_usage_keys) if tracker.last_usage_keys else "none"
1128
+ console.print(" [dim]Codex did not report any cache-token fields in the last usage payload.[/dim]")
1129
+ console.print(f" [dim]Last usage keys: {keys}[/dim]")
1130
+ elif tracker.last_cache_metrics_reported is None:
1131
+ console.print(" [dim]No usage payload has been recorded yet for this Codex session.[/dim]")
1076
1132
  console.print()
1077
1133
 
1078
1134
 
@@ -2153,8 +2209,8 @@ def _handle_obsidian(chat_manager, console, debug_mode_container, args, cron_sch
2153
2209
  return CommandResult(status="handled")
2154
2210
 
2155
2211
 
2156
- def _persist_disabled_tools(console):
2157
- """Persist current disabled_tools to config file.
2212
+ def _persist_tool_visibility(console):
2213
+ """Persist tool and skill visibility state to config file.
2158
2214
 
2159
2215
  Returns True on success, False on failure.
2160
2216
  """
@@ -2163,6 +2219,7 @@ def _persist_disabled_tools(console):
2163
2219
  if "TOOL_SETTINGS" not in cfg_data:
2164
2220
  cfg_data["TOOL_SETTINGS"] = {}
2165
2221
  cfg_data["TOOL_SETTINGS"]["disabled_tools"] = list(tool_settings.disabled_tools)
2222
+ cfg_data["TOOL_SETTINGS"]["hidden_skills"] = list(tool_settings.hidden_skills)
2166
2223
  config_manager.save(cfg_data)
2167
2224
  return True
2168
2225
  except Exception as e:
@@ -2171,18 +2228,23 @@ def _persist_disabled_tools(console):
2171
2228
 
2172
2229
 
2173
2230
  def _handle_tools(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2174
- """Handle /tools command — toggle individual tools or groups on/off.
2231
+ """Handle /tools command — manage tool availability and skill discovery visibility.
2175
2232
 
2176
- No args: Launch interactive SettingSelector with tools grouped by category.
2233
+ No args: Launch interactive SettingSelector with tools/plugins grouped by category
2234
+ and a skills section for discovery visibility.
2177
2235
  Subcommands:
2178
- list — show all tools with group labels and status
2179
- enable <name> — enable a single tool
2180
- disable <name> — disable a single tool
2181
- enable-group <key> — enable all tools in a group (e.g. file_ops, search, shell)
2236
+ list — show all tools, plugins, and skills with status
2237
+ enable <name> — enable a core tool or plugin
2238
+ disable <name> — disable a core tool or plugin
2239
+ show-skill <name> — make a skill visible in discovery surfaces
2240
+ hide-skill <name> — hide a skill from discovery surfaces
2241
+ enable-group <key> — enable all tools in a group (e.g. file_ops, task_mgmt)
2182
2242
  disable-group <key> — disable all tools in a group
2183
2243
  """
2244
+ from core.skills import iter_skill_summaries, validate_skill_name
2184
2245
  from ui.setting_selector import SettingOption, SettingCategory, SettingSelector
2185
2246
  from tools.helpers.base import ToolRegistry, TOOL_GROUPS
2247
+ from tools.helpers.plugin_manifest import plugin_manifest
2186
2248
 
2187
2249
  # Text subcommands
2188
2250
  if args:
@@ -2190,17 +2252,27 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2190
2252
 
2191
2253
  if args_clean.lower() in ("list", "status"):
2192
2254
  all_tools = sorted(ToolRegistry._tools.values(), key=lambda t: t.name)
2255
+ plugin_defs = sorted(plugin_manifest.get_all(), key=lambda t: t.name)
2256
+ skills = sorted(iter_skill_summaries(), key=lambda s: s.name)
2193
2257
  disabled = ToolRegistry.get_disabled()
2194
- console.print(f"[bold #5F9EA0]Tools: {len(all_tools) - len(disabled)} enabled, {len(disabled)} disabled[/bold #5F9EA0]")
2258
+ hidden_skills = set(tool_settings.hidden_skills)
2259
+ disabled_tools = {t.name for t in all_tools if t.name in disabled}
2260
+ console.print(
2261
+ f"[bold #5F9EA0]Tools: {len(all_tools) - len(disabled_tools)} enabled, {len(disabled_tools)} disabled[/bold #5F9EA0]"
2262
+ )
2263
+ console.print(
2264
+ f"[bold #5F9EA0]User plugins: {sum(1 for p in plugin_defs if p.name not in disabled)} enabled, {sum(1 for p in plugin_defs if p.name in disabled)} disabled[/bold #5F9EA0]"
2265
+ )
2266
+ console.print(
2267
+ f"[bold #5F9EA0]Skills: {len(skills) - len(hidden_skills)} visible, {len(hidden_skills)} hidden[/bold #5F9EA0]"
2268
+ )
2195
2269
  console.print()
2196
2270
 
2197
- # Build reverse lookup: tool_name -> group_label
2198
2271
  tool_to_group = {}
2199
2272
  for gkey, gdef in TOOL_GROUPS.items():
2200
2273
  for tname in gdef["tools"]:
2201
2274
  tool_to_group.setdefault(tname, []).append(gdef["label"])
2202
2275
 
2203
- # Group tools for display
2204
2276
  current_group = None
2205
2277
  for t in all_tools:
2206
2278
  groups = tool_to_group.get(t.name, [])
@@ -2212,6 +2284,18 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2212
2284
  status = "[red]off[/red]" if is_off else "[green]on[/green] "
2213
2285
  console.print(f" {status} {t.name}")
2214
2286
 
2287
+ console.print()
2288
+ console.print(" [bold]User plugins[/bold]")
2289
+ for plugin in plugin_defs:
2290
+ status = "[red]off[/red]" if plugin.name in disabled else "[green]on[/green] "
2291
+ console.print(f" {status} {plugin.name}")
2292
+
2293
+ console.print()
2294
+ console.print(" [bold]Skills[/bold]")
2295
+ for skill in skills:
2296
+ status = "[red]hidden[/red]" if skill.name in hidden_skills else "[green]visible[/green]"
2297
+ console.print(f" {status} {skill.name}")
2298
+
2215
2299
  console.print()
2216
2300
  console.print("[dim]Groups:[/dim] " + ", ".join(
2217
2301
  f"[bold]{k}[/bold] ({v['label']})" for k, v in TOOL_GROUPS.items()
@@ -2250,17 +2334,17 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2250
2334
 
2251
2335
  # Sync and persist
2252
2336
  tool_settings.disabled_tools = sorted(ToolRegistry.get_disabled())
2253
- _persist_disabled_tools(console)
2337
+ _persist_tool_visibility(console)
2254
2338
  console.print()
2255
2339
  return CommandResult(status="handled")
2256
2340
 
2257
- # Single tool operations
2341
+ # Single tool/plugin operations
2258
2342
  if action in ("enable", "disable"):
2259
- # Match case-insensitively against registered tools
2260
2343
  all_registered_lower = {t.name.lower(): t.name for t in ToolRegistry._tools.values()}
2344
+ all_registered_lower.update({t.name.lower(): t.name for t in plugin_manifest.get_all()})
2261
2345
  matched = all_registered_lower.get(target.lower())
2262
2346
  if not matched:
2263
- console.print(f"[red]Unknown tool: {target}[/red]")
2347
+ console.print(f"[red]Unknown tool or plugin: {target}[/red]")
2264
2348
  console.print(f"[dim]Run [bold #5F9EA0]/tools list[/bold #5F9EA0] to see all tools.[/dim]")
2265
2349
  return CommandResult(status="handled")
2266
2350
 
@@ -2274,17 +2358,44 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2274
2358
  tool_settings.disabled_tools.append(matched)
2275
2359
  console.print(f"[yellow]Disabled: {matched}[/yellow]")
2276
2360
 
2277
- _persist_disabled_tools(console)
2361
+ _persist_tool_visibility(console)
2362
+ console.print()
2363
+ return CommandResult(status="handled")
2364
+
2365
+ if action in ("show-skill", "hide-skill"):
2366
+ try:
2367
+ skill_name = validate_skill_name(target)
2368
+ except Exception as e:
2369
+ console.print(f"[red]{e}[/red]")
2370
+ return CommandResult(status="handled")
2371
+
2372
+ known_skills = {skill.name for skill in iter_skill_summaries()}
2373
+ if skill_name not in known_skills:
2374
+ console.print(f"[red]Unknown skill: {skill_name}[/red]")
2375
+ return CommandResult(status="handled")
2376
+
2377
+ if action == "show-skill":
2378
+ tool_settings.hidden_skills = [n for n in tool_settings.hidden_skills if n != skill_name]
2379
+ console.print(f"[green]Skill visible in discovery: {skill_name}[/green]")
2380
+ else:
2381
+ if skill_name not in tool_settings.hidden_skills:
2382
+ tool_settings.hidden_skills.append(skill_name)
2383
+ console.print(f"[yellow]Skill hidden from discovery: {skill_name}[/yellow]")
2384
+
2385
+ tool_settings.hidden_skills = sorted(set(tool_settings.hidden_skills))
2386
+ _persist_tool_visibility(console)
2278
2387
  console.print()
2279
2388
  return CommandResult(status="handled")
2280
2389
 
2281
2390
  console.print(f"[red]Unknown subcommand: {args}[/red]")
2282
- console.print("Usage: [bold #5F9EA0]/tools[/bold #5F9EA0] [list | enable <name> | disable <name> | enable-group <key> | disable-group <key>]")
2391
+ console.print("Usage: [bold #5F9EA0]/tools[/bold #5F9EA0] [list | enable <name> | disable <name> | show-skill <name> | hide-skill <name> | enable-group <key> | disable-group <key>]")
2283
2392
  return CommandResult(status="handled")
2284
2393
 
2285
2394
  # No args — interactive toggle UI, organized by groups
2286
2395
  all_tools_map = {t.name: t for t in ToolRegistry._tools.values()}
2396
+ plugin_tools = {t.name: t for t in plugin_manifest.get_all()}
2287
2397
  disabled = ToolRegistry.get_disabled()
2398
+ hidden_skills = set(tool_settings.hidden_skills)
2288
2399
 
2289
2400
  categories = []
2290
2401
  for gkey, gdef in TOOL_GROUPS.items():
@@ -2324,10 +2435,36 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2324
2435
  input_type="boolean",
2325
2436
  on_text="ON",
2326
2437
  off_text="OFF",
2327
- description=f"Modes: {modes}",
2328
2438
  ))
2329
2439
  categories.append(SettingCategory(title="Other", settings=other_options))
2330
2440
 
2441
+ plugin_options = []
2442
+ for name in sorted(plugin_tools):
2443
+ is_off = name in disabled
2444
+ plugin_options.append(SettingOption(
2445
+ key=name,
2446
+ text=name,
2447
+ value=not is_off,
2448
+ input_type="boolean",
2449
+ on_text="ON",
2450
+ off_text="OFF",
2451
+ ))
2452
+ if plugin_options:
2453
+ categories.append(SettingCategory(title="User plugins", settings=plugin_options))
2454
+
2455
+ skill_options = []
2456
+ for skill in sorted(iter_skill_summaries(), key=lambda s: s.name):
2457
+ skill_options.append(SettingOption(
2458
+ key=f"skill:{skill.name}",
2459
+ text=skill.name,
2460
+ value=skill.name not in hidden_skills,
2461
+ input_type="boolean",
2462
+ on_text="VISIBLE",
2463
+ off_text="HIDDEN",
2464
+ ))
2465
+ if skill_options:
2466
+ categories.append(SettingCategory(title="Skills", settings=skill_options))
2467
+
2331
2468
  selector = SettingSelector(
2332
2469
  categories=categories,
2333
2470
  title="Tool Settings",
@@ -2346,7 +2483,17 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2346
2483
  # Apply changes
2347
2484
  newly_disabled = []
2348
2485
  newly_enabled = []
2486
+ newly_hidden_skills = []
2487
+ newly_visible_skills = []
2349
2488
  for name, enabled in changes.items():
2489
+ if name.startswith("skill:"):
2490
+ skill_name = name.split(":", 1)[1]
2491
+ if enabled and skill_name in hidden_skills:
2492
+ newly_visible_skills.append(skill_name)
2493
+ elif not enabled and skill_name not in hidden_skills:
2494
+ newly_hidden_skills.append(skill_name)
2495
+ continue
2496
+
2350
2497
  if not enabled and name not in disabled:
2351
2498
  ToolRegistry.disable(name)
2352
2499
  newly_disabled.append(name)
@@ -2354,10 +2501,13 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2354
2501
  ToolRegistry.enable(name)
2355
2502
  newly_enabled.append(name)
2356
2503
 
2357
- # Sync tool_settings.disabled_tools to be the full current disabled set
2358
2504
  tool_settings.disabled_tools = sorted(ToolRegistry.get_disabled())
2505
+ next_hidden_skills = set(hidden_skills)
2506
+ next_hidden_skills.update(newly_hidden_skills)
2507
+ next_hidden_skills.difference_update(newly_visible_skills)
2508
+ tool_settings.hidden_skills = sorted(next_hidden_skills)
2359
2509
 
2360
- _persist_disabled_tools(console)
2510
+ _persist_tool_visibility(console)
2361
2511
 
2362
2512
  # Summary
2363
2513
  change_lines = []
@@ -2365,6 +2515,10 @@ def _handle_tools(chat_manager, console, debug_mode_container, args, cron_schedu
2365
2515
  change_lines.append(f" [yellow]Disabled:[/yellow] {name}")
2366
2516
  for name in newly_enabled:
2367
2517
  change_lines.append(f" [green]Enabled:[/green] {name}")
2518
+ for name in newly_hidden_skills:
2519
+ change_lines.append(f" [yellow]Hidden skill:[/yellow] {name}")
2520
+ for name in newly_visible_skills:
2521
+ change_lines.append(f" [green]Visible skill:[/green] {name}")
2368
2522
 
2369
2523
  if change_lines:
2370
2524
  total_enabled = len(ToolRegistry.get_all())
@@ -2430,9 +2584,258 @@ def _handle_cd(chat_manager, console, debug_mode_container, args, cron_scheduler
2430
2584
  except Exception:
2431
2585
  pass
2432
2586
 
2587
+ # Rebuild system prompt so project root stays current
2588
+ try:
2589
+ chat_manager.update_system_prompt()
2590
+ except Exception:
2591
+ pass
2592
+
2433
2593
  return CommandResult(status="handled")
2434
2594
 
2435
2595
 
2596
+ def _handle_prompt(chat_manager, console, debug_mode_container, args, cron_scheduler=None):
2597
+ """Handle /prompt command — show/swap prompt variants."""
2598
+ from utils.settings import prompt_settings
2599
+ from llm.prompts import _variant_available, _list_variants
2600
+
2601
+ cfg_manager = config_manager
2602
+
2603
+ if not args or args.strip() == "list":
2604
+ variants = _list_variants()
2605
+ current = prompt_settings.variant
2606
+ console.print()
2607
+ console.print(f"[bold #5F9EA0]Prompt Variants[/bold #5F9EA0] (current: [bold]{current}[/bold])")
2608
+ console.print()
2609
+ for v in variants:
2610
+ marker = "[bold green]active[/bold green]" if v == current else ""
2611
+ console.print(f" [bold]{v}[/bold] {marker}")
2612
+ console.print()
2613
+ console.print("[dim]Switch with: [bold #5F9EA0]/prompt main[/bold #5F9EA0] or [bold #5F9EA0]/prompt micro[/bold #5F9EA0][/dim]")
2614
+ return CommandResult(status="handled")
2615
+
2616
+ # Single arg: variant name to switch to
2617
+ target = args.strip().lower()
2618
+
2619
+ if not _variant_available(target):
2620
+ variants = _list_variants()
2621
+ console.print(f"[red]Unknown variant: '{target}'[/red]")
2622
+ console.print(f"[dim]Available: {', '.join(variants)}[/dim]")
2623
+ return CommandResult(status="handled")
2624
+
2625
+ # Update settings
2626
+ prompt_settings.variant = target
2627
+
2628
+ # Persist to config
2629
+ try:
2630
+ cfg_data = cfg_manager.load(force_reload=True)
2631
+ if "PROMPT_SETTINGS" not in cfg_data:
2632
+ cfg_data["PROMPT_SETTINGS"] = {}
2633
+ cfg_data["PROMPT_SETTINGS"]["variant"] = target
2634
+ cfg_manager.save(cfg_data)
2635
+ except Exception as e:
2636
+ console.print(f"[red]Failed to save variant to config: {e}[/red]")
2637
+ console.print("[yellow]Variant applied for this session only — it will revert on restart.[/yellow]")
2638
+
2639
+ # Rebuild system prompt in-place (no restart)
2640
+ chat_manager.update_system_prompt(variant=target)
2641
+ console.print(f"[green]Switched to '{target}' variant[/green]")
2642
+ console.print("[dim]System prompt rebuilt in-place.[/dim]")
2643
+
2644
+ return CommandResult(status="handled")
2645
+
2646
+
2647
+ def _print_skills_usage(console):
2648
+ show_skills_help_table(console)
2649
+
2650
+
2651
+ def _skills_list(console, query=None):
2652
+ from datetime import datetime
2653
+ from core.skills import get_skills_dir, list_skills
2654
+
2655
+ skills = list_skills(query=query)
2656
+ if not skills:
2657
+ console.print("[dim]No skills found.[/dim]")
2658
+ console.print(f"[dim]Directory: {get_skills_dir()}[/dim]")
2659
+ console.print("[dim]Create one with: [bold #5F9EA0]/skills add frontend_design[/bold #5F9EA0][/dim]")
2660
+ return
2661
+
2662
+ table = Table(show_header=True, box=box.SIMPLE_HEAD)
2663
+ table.add_column("Skill", no_wrap=True)
2664
+ table.add_column("Preview")
2665
+ table.add_column("Modified", no_wrap=True)
2666
+ for skill in skills:
2667
+ modified = datetime.fromtimestamp(skill.modified).strftime("%Y-%m-%d %H:%M")
2668
+ table.add_row(f"[bold]{skill.name}[/bold]", skill.preview, modified)
2669
+ console.print(table)
2670
+ console.print("[dim]Load with: [bold #5F9EA0]/skills load <name>[/bold #5F9EA0][/dim]")
2671
+
2672
+
2673
+ def _open_skill_editor(console, debug_mode_container, initial_content):
2674
+ from utils.editor import open_editor_for_content
2675
+
2676
+ return open_editor_for_content(
2677
+ console,
2678
+ initial_content=initial_content,
2679
+ debug_mode=debug_mode_container["debug"],
2680
+ )
2681
+
2682
+
2683
+ def _write_skill_from_editor(console, debug_mode_container, name, initial_content, *, overwrite, verb):
2684
+ from core.skills import write_skill
2685
+
2686
+ success, content = _open_skill_editor(console, debug_mode_container, initial_content)
2687
+ if not success or not content or not content.strip():
2688
+ console.print("[dim]Cancelled.[/dim]")
2689
+ return None
2690
+ path = write_skill(name, content, overwrite=overwrite)
2691
+ console.print(f"[green]{verb} skill '{name}'.[/green] [dim]{path}[/dim]")
2692
+ return path
2693
+
2694
+
2695
+ def _handle_skills(chat_manager, console, debug_mode_container, args, cron_sched=None):
2696
+ """Handle /skills command — manage reusable prompt snippets."""
2697
+ from core.skills import (
2698
+ SkillError,
2699
+ activate_skill,
2700
+ get_skills_dir,
2701
+ read_skill,
2702
+ remove_skill,
2703
+ validate_skill_name,
2704
+ write_skill,
2705
+ )
2706
+
2707
+ if not args or not args.strip():
2708
+ _skills_list(console)
2709
+ return CommandResult(status="handled")
2710
+
2711
+ args_clean = args.strip()
2712
+ parts = args_clean.split(maxsplit=2)
2713
+ subcmd = parts[0].lower()
2714
+
2715
+ try:
2716
+ if subcmd in ("help", "-h", "--help"):
2717
+ _print_skills_usage(console)
2718
+ return CommandResult(status="handled")
2719
+
2720
+ if subcmd in ("list", "ls"):
2721
+ list_parts = args_clean.split(maxsplit=1)
2722
+ query = list_parts[1] if len(list_parts) > 1 else None
2723
+ _skills_list(console, query=query)
2724
+ return CommandResult(status="handled")
2725
+
2726
+ if subcmd == "dir":
2727
+ console.print(str(get_skills_dir()))
2728
+ return CommandResult(status="handled")
2729
+
2730
+ if subcmd == "show":
2731
+ if len(parts) < 2:
2732
+ console.print("[red]Usage: /skills show <name>[/red]")
2733
+ return CommandResult(status="handled")
2734
+ name = validate_skill_name(parts[1])
2735
+ content = read_skill(name, strip_heading=False)
2736
+ console.print(Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left"))
2737
+ return CommandResult(status="handled")
2738
+
2739
+ if subcmd in ("load", "use"):
2740
+ if len(parts) < 2:
2741
+ console.print(f"[red]Usage: /skills {subcmd} <name>[/red]")
2742
+ return CommandResult(status="handled")
2743
+ name = validate_skill_name(parts[1])
2744
+ tokens = activate_skill(chat_manager, name, read_skill(name))
2745
+ console.print(f"[green]Activated skill '{name}' for this chat.[/green] [dim](~{tokens:,} tokens)[/dim]")
2746
+ return CommandResult(status="handled")
2747
+
2748
+ if subcmd in ("remove", "rm", "delete"):
2749
+ if len(parts) < 2:
2750
+ console.print("[red]Usage: /skills remove <name>[/red]")
2751
+ return CommandResult(status="handled")
2752
+ name = validate_skill_name(parts[1])
2753
+ from rich.prompt import Confirm
2754
+ if not Confirm.ask(f"Remove skill '{name}'?", default=False):
2755
+ console.print("[dim]Cancelled.[/dim]")
2756
+ return CommandResult(status="handled")
2757
+ remove_skill(name)
2758
+ console.print(f"[green]Removed skill '{name}'.[/green]")
2759
+ return CommandResult(status="handled")
2760
+
2761
+ if subcmd == "edit":
2762
+ if len(parts) < 2:
2763
+ console.print("[red]Usage: /skills edit <name>[/red]")
2764
+ return CommandResult(status="handled")
2765
+ name = validate_skill_name(parts[1])
2766
+ try:
2767
+ initial_content = read_skill(name, strip_heading=False)
2768
+ except SkillError:
2769
+ initial_content = f"# {name}\n\n"
2770
+ _write_skill_from_editor(
2771
+ console,
2772
+ debug_mode_container,
2773
+ name,
2774
+ initial_content,
2775
+ overwrite=True,
2776
+ verb="Saved",
2777
+ )
2778
+ return CommandResult(status="handled")
2779
+
2780
+ if subcmd in ("add", "create", "new"):
2781
+ if len(parts) < 2:
2782
+ console.print(f"[red]Usage: /skills {subcmd} <name> [prompt][/red]")
2783
+ return CommandResult(status="handled")
2784
+ name = validate_skill_name(parts[1])
2785
+ if len(parts) >= 3:
2786
+ path = write_skill(name, parts[2], overwrite=False)
2787
+ console.print(f"[green]Created skill '{name}'.[/green] [dim]{path}[/dim]")
2788
+ return CommandResult(status="handled")
2789
+ _write_skill_from_editor(
2790
+ console,
2791
+ debug_mode_container,
2792
+ name,
2793
+ f"# {name}\n\n",
2794
+ overwrite=False,
2795
+ verb="Created",
2796
+ )
2797
+ return CommandResult(status="handled")
2798
+
2799
+ if subcmd == "modify":
2800
+ cmd_parts = args_clean.split(maxsplit=2)
2801
+ if len(cmd_parts) < 2:
2802
+ console.print("[red]Usage: /skills modify <name> [prompt][/red]")
2803
+ return CommandResult(status="handled")
2804
+ name = validate_skill_name(cmd_parts[1])
2805
+ if len(cmd_parts) < 3:
2806
+ _write_skill_from_editor(
2807
+ console,
2808
+ debug_mode_container,
2809
+ name,
2810
+ read_skill(name, strip_heading=False),
2811
+ overwrite=True,
2812
+ verb="Updated",
2813
+ )
2814
+ return CommandResult(status="handled")
2815
+ read_skill(name)
2816
+ path = write_skill(name, cmd_parts[2], overwrite=True)
2817
+ console.print(f"[green]Updated skill '{name}'.[/green] [dim]{path}[/dim]")
2818
+ return CommandResult(status="handled")
2819
+
2820
+ if len(parts) >= 2:
2821
+ name = validate_skill_name(parts[0])
2822
+ _, body = args_clean.split(maxsplit=1)
2823
+ path = write_skill(name, body, overwrite=False)
2824
+ console.print(f"[green]Created skill '{name}'.[/green] [dim]{path}[/dim]")
2825
+ return CommandResult(status="handled")
2826
+
2827
+ console.print("[red]Usage: /skills add <name> [prompt][/red]")
2828
+ console.print("[dim]Run [bold #5F9EA0]/skills help[/bold #5F9EA0] for all commands.[/dim]")
2829
+ return CommandResult(status="handled")
2830
+
2831
+ except SkillError as e:
2832
+ console.print(f"[red]{e}[/red]")
2833
+ return CommandResult(status="handled")
2834
+ except Exception as e:
2835
+ console.print(f"[red]Skills command failed: {e}[/red]")
2836
+ return CommandResult(status="handled")
2837
+
2838
+
2436
2839
  def _handle_obsidian_init(console, obsidian_settings):
2437
2840
  """Handle /obsidian init — scaffold project folder structure in vault."""
2438
2841
  if not obsidian_settings.is_active():
@@ -2658,9 +3061,35 @@ _COMMAND_HANDLERS = {
2658
3061
  "/cd": _handle_cd,
2659
3062
  "/setup": _handle_setup,
2660
3063
  "/cron": _handle_cron,
3064
+ "/prompt": _handle_prompt,
3065
+ "/skills": _handle_skills,
2661
3066
  }
2662
3067
 
2663
3068
 
3069
+ def _handle_shell_command(console, command):
3070
+ """Execute a shell command prefixed with : and display output."""
3071
+ from utils.settings import tool_settings
3072
+ try:
3073
+ result = subprocess.run(
3074
+ ["/bin/sh", "-c", command], capture_output=True, text=True,
3075
+ encoding="utf-8", errors="replace", timeout=tool_settings.command_timeout_sec,
3076
+ )
3077
+ output = ((result.stdout or "") + (result.stderr or "")).strip() or "(no output)"
3078
+ lines = output.splitlines()
3079
+ if len(lines) > 200:
3080
+ output = "\n".join(lines[:100]) + f"\n\n... ({len(lines) - 200} lines omitted) ...\n\n" + "\n".join(lines[-100:])
3081
+ console.print()
3082
+ if result.returncode != 0:
3083
+ console.print(f"[red]exit code: {result.returncode}[/red]")
3084
+ console.print(output)
3085
+ console.print()
3086
+ except subprocess.TimeoutExpired:
3087
+ console.print(f"[red]Command timed out after {tool_settings.command_timeout_sec}s[/red]")
3088
+ except Exception as e:
3089
+ console.print(f"[red]Error: {e}[/red]")
3090
+ return CommandResult(status="handled")
3091
+
3092
+
2664
3093
  def process_command(chat_manager, user_input, console, debug_mode_container, cron_scheduler=None):
2665
3094
  """Process command and optionally return replacement content.
2666
3095
 
@@ -2681,6 +3110,14 @@ def process_command(chat_manager, user_input, console, debug_mode_container, cro
2681
3110
  cmd = parts[0].lower()
2682
3111
  args = parts[1] if len(parts) > 1 else None
2683
3112
 
3113
+ # Shell command prefix (:command)
3114
+ if user_input.startswith(":"):
3115
+ shell_cmd = user_input[1:].strip()
3116
+ if shell_cmd:
3117
+ result = _handle_shell_command(console, shell_cmd)
3118
+ return (result.status, result.replacement_input)
3119
+ return ("handled", None)
3120
+
2684
3121
  # Look up handler in registry
2685
3122
  handler = _COMMAND_HANDLERS.get(cmd)
2686
3123
  if handler: