ruby_coded 0.3.1 → 0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +113 -1
- data/lib/ruby_coded/chat/app.rb +35 -34
- data/lib/ruby_coded/chat/bridge_common/auto_switch.rb +45 -0
- data/lib/ruby_coded/chat/bridge_common/mode_transitions.rb +70 -0
- data/lib/ruby_coded/chat/bridge_common/tool_flow.rb +61 -0
- data/lib/ruby_coded/chat/bridge_common.rb +31 -0
- data/lib/ruby_coded/chat/bridge_factory.rb +61 -0
- data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +1 -20
- data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +3 -15
- data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +8 -32
- data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +3 -34
- data/lib/ruby_coded/chat/codex_bridge.rb +31 -46
- data/lib/ruby_coded/chat/command_handler/agent_commands.rb +3 -3
- data/lib/ruby_coded/chat/command_handler/plan_commands.rb +1 -1
- data/lib/ruby_coded/chat/command_handler/skill_commands.rb +87 -0
- data/lib/ruby_coded/chat/command_handler.rb +3 -0
- data/lib/ruby_coded/chat/llm_bridge/chat_configuration.rb +38 -0
- data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +3 -32
- data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +9 -50
- data/lib/ruby_coded/chat/llm_bridge.rb +31 -65
- data/lib/ruby_coded/chat/prompt_builder.rb +66 -0
- data/lib/ruby_coded/chat/renderer/chat_panel.rb +48 -62
- data/lib/ruby_coded/chat/renderer/chat_panel_cache.rb +35 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_formatting.rb +72 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_layout.rb +81 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_sections.rb +93 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +20 -34
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking_render.rb +49 -0
- data/lib/ruby_coded/chat/renderer/rich_text.rb +92 -0
- data/lib/ruby_coded/chat/renderer/rich_text_inline.rb +81 -0
- data/lib/ruby_coded/chat/renderer.rb +12 -0
- data/lib/ruby_coded/chat/runtime_mode.rb +97 -0
- data/lib/ruby_coded/commands/core_provider.rb +7 -0
- data/lib/ruby_coded/commands.rb +11 -0
- data/lib/ruby_coded/plugins.rb +10 -1
- data/lib/ruby_coded/skills/catalog.rb +103 -0
- data/lib/ruby_coded/skills/markdown_loader.rb +112 -0
- data/lib/ruby_coded/skills/prompt_formatter.rb +42 -0
- data/lib/ruby_coded/skills/skill_definition.rb +29 -0
- data/lib/ruby_coded/skills.rb +18 -0
- data/lib/ruby_coded/tools/base_tool.rb +14 -0
- data/lib/ruby_coded/tools/delete_path_tool.rb +5 -8
- data/lib/ruby_coded/tools/edit_file_tool.rb +6 -8
- data/lib/ruby_coded/tools/execution_pipeline.rb +60 -0
- data/lib/ruby_coded/tools/execution_policy.rb +89 -0
- data/lib/ruby_coded/tools/write_file_tool.rb +6 -9
- data/lib/ruby_coded/version.rb +1 -1
- data/lib/ruby_coded.rb +1 -0
- metadata +25 -2
|
@@ -68,7 +68,14 @@ module RubyCoded
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def format_thinking_text(cycle_messages)
|
|
71
|
-
|
|
71
|
+
rich_text_plain(format_thinking_lines(cycle_messages))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def format_thinking_lines(cycle_messages)
|
|
75
|
+
cycle_messages.flat_map.with_index do |msg, index|
|
|
76
|
+
lines = format_thinking_message_rich(msg)
|
|
77
|
+
index == cycle_messages.length - 1 ? lines : lines + [@tui.line(spans: [@tui.span(content: "")])]
|
|
78
|
+
end
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
def format_thinking_message(msg)
|
|
@@ -82,41 +89,20 @@ module RubyCoded
|
|
|
82
89
|
end
|
|
83
90
|
end
|
|
84
91
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
prior = messages[0...(messages.length - full_cycle.length)]
|
|
89
|
-
chat_area, thinking_area = split_chat_thinking(area)
|
|
90
|
-
|
|
91
|
-
render_messages_in_area(frame, chat_area, prior)
|
|
92
|
-
render_thinking_panel(frame, thinking_area, format_thinking_text(cycle))
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def split_chat_thinking(area)
|
|
96
|
-
@tui.layout_split(
|
|
97
|
-
area,
|
|
98
|
-
direction: :vertical,
|
|
99
|
-
constraints: [@tui.constraint_fill(3), @tui.constraint_fill(2)]
|
|
100
|
-
)
|
|
92
|
+
def format_thinking_message_rich(msg)
|
|
93
|
+
text, role = thinking_rich_text_and_role(msg)
|
|
94
|
+
rich_text_lines(text, role: role)
|
|
101
95
|
end
|
|
102
96
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
frame.render_widget(widget, area)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def thinking_scroll_y(area, text)
|
|
116
|
-
inner_height = [area.height - 2, 0].max
|
|
117
|
-
inner_width = [area.width - 2, 0].max
|
|
118
|
-
total_lines = count_wrapped_lines(text, inner_width)
|
|
119
|
-
[total_lines - inner_height, 0].max
|
|
97
|
+
def thinking_rich_text_and_role(msg)
|
|
98
|
+
case msg[:role]
|
|
99
|
+
when :assistant then [msg[:content].gsub(%r{</?think>}, ""), :assistant]
|
|
100
|
+
when :tool_call then [">> #{msg[:content]}", :tool_call]
|
|
101
|
+
when :tool_pending then ["?? #{msg[:content]}", :tool_pending]
|
|
102
|
+
when :tool_result then [" #{msg[:content]}", :tool_result]
|
|
103
|
+
when :system then ["--- #{msg[:content]}", :system]
|
|
104
|
+
else [msg[:content].to_s, :assistant]
|
|
105
|
+
end
|
|
120
106
|
end
|
|
121
107
|
end
|
|
122
108
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class Renderer
|
|
6
|
+
# Layout and widget rendering for the split chat/thinking panel view.
|
|
7
|
+
module ChatPanelThinkingRender
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def render_chat_with_thinking(frame, area, messages)
|
|
11
|
+
full_cycle = current_cycle_messages(messages)
|
|
12
|
+
cycle = tail_of_cycle(full_cycle)
|
|
13
|
+
prior = messages[0...(messages.length - full_cycle.length)]
|
|
14
|
+
chat_area, thinking_area = split_chat_thinking(area)
|
|
15
|
+
|
|
16
|
+
render_messages_in_area(frame, chat_area, prior)
|
|
17
|
+
render_thinking_panel(frame, thinking_area, format_thinking_lines(cycle))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def split_chat_thinking(area)
|
|
21
|
+
@tui.layout_split(
|
|
22
|
+
area,
|
|
23
|
+
direction: :vertical,
|
|
24
|
+
constraints: [@tui.constraint_fill(3), @tui.constraint_fill(2)]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render_thinking_panel(frame, area, thinking_lines)
|
|
29
|
+
scroll_y = thinking_scroll_y(area, thinking_lines)
|
|
30
|
+
|
|
31
|
+
widget = @tui.paragraph(
|
|
32
|
+
text: thinking_lines,
|
|
33
|
+
wrap: true,
|
|
34
|
+
scroll: [scroll_y, 0],
|
|
35
|
+
block: @tui.block(title: "thinking...", borders: [:all])
|
|
36
|
+
)
|
|
37
|
+
frame.render_widget(widget, area)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def thinking_scroll_y(area, text_or_lines)
|
|
41
|
+
inner_height = [area.height - 2, 0].max
|
|
42
|
+
inner_width = [area.width - 2, 0].max
|
|
43
|
+
total_lines = count_wrapped_lines(text_or_lines, inner_width)
|
|
44
|
+
[total_lines - inner_height, 0].max
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rich_text_inline"
|
|
4
|
+
|
|
5
|
+
module RubyCoded
|
|
6
|
+
module Chat
|
|
7
|
+
class Renderer
|
|
8
|
+
# Helpers for building and serializing rich text using ratatui text lines/spans.
|
|
9
|
+
module RichText
|
|
10
|
+
include RichTextInline
|
|
11
|
+
|
|
12
|
+
def rich_text_lines(text, role: :assistant)
|
|
13
|
+
return [] if text.nil? || text.empty?
|
|
14
|
+
|
|
15
|
+
parse_rich_text(text, role: role)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rich_text_plain(lines)
|
|
19
|
+
Array(lines).map { |line| rich_line_plain(line) }.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rich_line_plain(line)
|
|
23
|
+
return line.to_s unless line.respond_to?(:spans)
|
|
24
|
+
|
|
25
|
+
Array(line.spans).map { |span| span.respond_to?(:content) ? span.content.to_s : span.to_s }.join
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse_rich_text(text, role: :assistant)
|
|
29
|
+
state = { lines: [], in_code_block: false }
|
|
30
|
+
text.split("\n", -1).each { |raw_line| parse_rich_line(raw_line, state, role) }
|
|
31
|
+
state[:lines]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_rich_line(raw_line, state, role)
|
|
35
|
+
return toggle_code_fence(raw_line, state) if raw_line.start_with?("```")
|
|
36
|
+
|
|
37
|
+
state[:lines] << rich_line_for(raw_line, state[:in_code_block], role)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def toggle_code_fence(raw_line, state)
|
|
41
|
+
if state[:in_code_block]
|
|
42
|
+
state[:in_code_block] = false
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
state[:in_code_block] = true
|
|
47
|
+
language = raw_line.delete_prefix("```").strip
|
|
48
|
+
state[:lines] << code_language_line(language) unless language.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def rich_line_for(raw_line, in_code_block, role)
|
|
52
|
+
return @tui.line(spans: inline_spans(raw_line, role: role)) unless in_code_block
|
|
53
|
+
|
|
54
|
+
@tui.line(spans: [@tui.span(content: raw_line, style: code_block_style)])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def code_language_line(language)
|
|
58
|
+
@tui.line(spans: [@tui.span(content: "[#{language}]", style: @tui.style(fg: :yellow, modifiers: [:bold]))])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def merge_style(style, foreground: nil, background: nil, modifiers: [])
|
|
62
|
+
@tui.style(
|
|
63
|
+
fg: foreground || style&.fg,
|
|
64
|
+
bg: background || style&.bg,
|
|
65
|
+
modifiers: ((style&.modifiers || []) + modifiers).uniq
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
BASE_TEXT_STYLES = {
|
|
70
|
+
system: { fg: :dark_gray, modifiers: [:italic] },
|
|
71
|
+
user_label: { fg: :cyan, modifiers: [:bold] },
|
|
72
|
+
tool_call: { fg: :yellow, modifiers: [:bold] },
|
|
73
|
+
tool_pending: { fg: :magenta, modifiers: [:bold] },
|
|
74
|
+
tool_result: { fg: :green },
|
|
75
|
+
thinking_title: { fg: :blue, modifiers: [:bold] }
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
def base_text_style(role)
|
|
79
|
+
@tui.style(**BASE_TEXT_STYLES.fetch(role, { modifiers: [] }))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inline_code_style
|
|
83
|
+
@tui.style(fg: :yellow, bg: :dark_gray)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def code_block_style
|
|
87
|
+
@tui.style(fg: :green, bg: :black)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class Renderer
|
|
6
|
+
# Inline markdown parsing (code, bold, italic) into ratatui spans.
|
|
7
|
+
module RichTextInline
|
|
8
|
+
def inline_spans(text, role: :assistant)
|
|
9
|
+
spans = []
|
|
10
|
+
remaining = text.dup
|
|
11
|
+
|
|
12
|
+
while !remaining.empty?
|
|
13
|
+
marker, index = next_inline_marker(remaining)
|
|
14
|
+
unless marker
|
|
15
|
+
spans << @tui.span(content: remaining, style: base_text_style(role))
|
|
16
|
+
break
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
prefix = remaining[0...index]
|
|
20
|
+
spans << @tui.span(content: prefix, style: base_text_style(role)) unless prefix.empty?
|
|
21
|
+
|
|
22
|
+
consumed = append_styled_span!(spans, remaining[index..], role: role)
|
|
23
|
+
if consumed
|
|
24
|
+
remaining = remaining[(index + consumed)..]
|
|
25
|
+
else
|
|
26
|
+
spans << @tui.span(content: remaining[index], style: base_text_style(role))
|
|
27
|
+
remaining = remaining[(index + 1)..]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
spans = [@tui.span(content: "", style: base_text_style(role))] if spans.empty?
|
|
32
|
+
spans
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def next_inline_marker(text)
|
|
36
|
+
markers = ["`", "**", "*"]
|
|
37
|
+
positions = markers.filter_map do |marker|
|
|
38
|
+
index = text.index(marker)
|
|
39
|
+
[marker, index] if index
|
|
40
|
+
end
|
|
41
|
+
positions.min_by { |(_, index)| index }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def append_styled_span!(spans, text, role: :assistant)
|
|
45
|
+
return append_code_span!(spans, text) if text.start_with?("`")
|
|
46
|
+
return append_bold_span!(spans, text, role: role) if text.start_with?("**")
|
|
47
|
+
return append_italic_span!(spans, text, role: role) if text.start_with?("*")
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def append_code_span!(spans, text)
|
|
53
|
+
close = text.index("`", 1)
|
|
54
|
+
return nil unless close
|
|
55
|
+
|
|
56
|
+
content = text[1...close]
|
|
57
|
+
spans << @tui.span(content: content, style: inline_code_style)
|
|
58
|
+
close + 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def append_bold_span!(spans, text, role: :assistant)
|
|
62
|
+
close = text.index("**", 2)
|
|
63
|
+
return nil unless close
|
|
64
|
+
|
|
65
|
+
content = text[2...close]
|
|
66
|
+
spans << @tui.span(content: content, style: merge_style(base_text_style(role), modifiers: [:bold]))
|
|
67
|
+
close + 2
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def append_italic_span!(spans, text, role: :assistant)
|
|
71
|
+
close = text.index("*", 1)
|
|
72
|
+
return nil unless close
|
|
73
|
+
|
|
74
|
+
content = text[1...close]
|
|
75
|
+
spans << @tui.span(content: content, style: merge_style(base_text_style(role), modifiers: [:italic]))
|
|
76
|
+
close + 1
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "renderer/rich_text"
|
|
4
|
+
require_relative "renderer/chat_panel_formatting"
|
|
5
|
+
require_relative "renderer/chat_panel_sections"
|
|
6
|
+
require_relative "renderer/chat_panel_layout"
|
|
7
|
+
require_relative "renderer/chat_panel_cache"
|
|
3
8
|
require_relative "renderer/chat_panel"
|
|
4
9
|
require_relative "renderer/chat_panel_thinking"
|
|
10
|
+
require_relative "renderer/chat_panel_thinking_render"
|
|
5
11
|
require_relative "renderer/chat_panel_input"
|
|
6
12
|
require_relative "renderer/model_selector"
|
|
7
13
|
require_relative "renderer/plan_clarifier_layout"
|
|
@@ -14,8 +20,14 @@ module RubyCoded
|
|
|
14
20
|
module Chat
|
|
15
21
|
# This class manages the rendering of the UI elements
|
|
16
22
|
class Renderer
|
|
23
|
+
include RichText
|
|
24
|
+
include ChatPanelFormatting
|
|
25
|
+
include ChatPanelSections
|
|
26
|
+
include ChatPanelLayout
|
|
27
|
+
include ChatPanelCache
|
|
17
28
|
include ChatPanel
|
|
18
29
|
include ChatPanelThinking
|
|
30
|
+
include ChatPanelThinkingRender
|
|
19
31
|
include ChatPanelInput
|
|
20
32
|
include ModelSelector
|
|
21
33
|
include PlanClarifierLayout
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
# Value object representing the runtime mode of the assistant.
|
|
6
|
+
#
|
|
7
|
+
# Modes are the single source of truth for tool availability,
|
|
8
|
+
# prompt selection, mutation permission, and confirmation policy.
|
|
9
|
+
# Bridges hold a RuntimeMode instance instead of independent
|
|
10
|
+
# boolean flags to avoid inconsistent combinations.
|
|
11
|
+
class RuntimeMode
|
|
12
|
+
NAMES = %i[chat agent plan].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
def initialize(name)
|
|
17
|
+
raise ArgumentError, "unknown mode: #{name.inspect}" unless NAMES.include?(name)
|
|
18
|
+
|
|
19
|
+
@name = name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chat?
|
|
23
|
+
@name == :chat
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def agent?
|
|
27
|
+
@name == :agent
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def plan?
|
|
31
|
+
@name == :plan
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Tools may be invoked in this mode (readonly for plan, full for agent).
|
|
35
|
+
def allows_tools?
|
|
36
|
+
agent? || plan?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Destructive/write tools are permitted only in agent mode.
|
|
40
|
+
def allows_mutation?
|
|
41
|
+
agent?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# User confirmation is required for non-safe tools outside chat mode.
|
|
45
|
+
def requires_confirmation?
|
|
46
|
+
agent? || plan?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Symbol used when querying skills for this mode.
|
|
50
|
+
def skill_mode
|
|
51
|
+
@name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_s
|
|
55
|
+
@name.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ==(other)
|
|
59
|
+
other.is_a?(RuntimeMode) && other.name == @name
|
|
60
|
+
end
|
|
61
|
+
alias eql? ==
|
|
62
|
+
|
|
63
|
+
def hash
|
|
64
|
+
[self.class, @name].hash
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
CHAT = new(:chat)
|
|
68
|
+
AGENT = new(:agent)
|
|
69
|
+
PLAN = new(:plan)
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
def chat
|
|
73
|
+
CHAT
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def agent
|
|
77
|
+
AGENT
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def plan
|
|
81
|
+
PLAN
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def for(value)
|
|
85
|
+
return value if value.is_a?(RuntimeMode)
|
|
86
|
+
|
|
87
|
+
case value.to_sym
|
|
88
|
+
when :chat then CHAT
|
|
89
|
+
when :agent then AGENT
|
|
90
|
+
when :plan then PLAN
|
|
91
|
+
else raise ArgumentError, "unknown mode: #{value.inspect}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -83,6 +83,13 @@ module RubyCoded
|
|
|
83
83
|
handler: :cmd_commands,
|
|
84
84
|
source: :core,
|
|
85
85
|
usage: "/commands [reload|list]"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "/skills",
|
|
89
|
+
description: "Manage project-local skills",
|
|
90
|
+
handler: :cmd_skills,
|
|
91
|
+
source: :core,
|
|
92
|
+
usage: "/skills [reload|list]"
|
|
86
93
|
}
|
|
87
94
|
].freeze
|
|
88
95
|
|
data/lib/ruby_coded/commands.rb
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Commands are **explicit user-invoked actions**.
|
|
4
|
+
#
|
|
5
|
+
# Every command is triggered by the user typing a leading slash
|
|
6
|
+
# (e.g. `/agent`, `/plan`, `/model`, `/help`, or a project-local
|
|
7
|
+
# markdown command like `/review-auth`). Commands are dispatched by
|
|
8
|
+
# CommandHandler and never fire automatically.
|
|
9
|
+
#
|
|
10
|
+
# See also:
|
|
11
|
+
# - RubyCoded::Skills — automatic prompt overlays (no user typing).
|
|
12
|
+
# - RubyCoded::Plugins — code-level behavioral extensions.
|
|
13
|
+
|
|
3
14
|
require_relative "commands/command_definition"
|
|
4
15
|
require_relative "commands/core_provider"
|
|
5
16
|
require_relative "commands/plugin_provider"
|
data/lib/ruby_coded/plugins.rb
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Plugins are **code-level behavioral extensions** to the chat runtime.
|
|
4
|
+
#
|
|
5
|
+
# A plugin can extend State, InputHandler, Renderer, or CommandHandler
|
|
6
|
+
# through `apply_extensions!`. Plugins may register their own slash
|
|
7
|
+
# commands, but they may also add invisible behavior (e.g. command
|
|
8
|
+
# completion). Contrast with:
|
|
9
|
+
#
|
|
10
|
+
# - RubyCoded::Commands — explicit user-invoked slash actions.
|
|
11
|
+
# - RubyCoded::Skills — automatic prompt overlays for the model.
|
|
12
|
+
|
|
3
13
|
require_relative "plugins/base"
|
|
4
14
|
require_relative "plugins/registry"
|
|
5
15
|
|
|
@@ -17,5 +27,4 @@ end
|
|
|
17
27
|
|
|
18
28
|
require_relative "plugins/command_completion/plugin"
|
|
19
29
|
|
|
20
|
-
# Register built-in plugins
|
|
21
30
|
RubyCoded.register_plugin(RubyCoded::Plugins::CommandCompletion::Plugin)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "skill_definition"
|
|
4
|
+
require_relative "markdown_loader"
|
|
5
|
+
|
|
6
|
+
module RubyCoded
|
|
7
|
+
module Skills
|
|
8
|
+
# Loads, validates, and caches project-local skill definitions.
|
|
9
|
+
class Catalog
|
|
10
|
+
def initialize(project_root:)
|
|
11
|
+
@project_root = project_root
|
|
12
|
+
@last_reload_report = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def all_skills
|
|
16
|
+
valid_skills.sort_by { |skill| [-skill.priority, skill.name.downcase] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def skills_for_mode(mode)
|
|
20
|
+
all_skills.select { |skill| skill.applies_to_mode?(mode) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def relevant_skills_for(mode:, input: nil)
|
|
24
|
+
mode_skills = skills_for_mode(mode)
|
|
25
|
+
return mode_skills if input.to_s.strip.empty?
|
|
26
|
+
|
|
27
|
+
matched = mode_skills.select { |skill| matches_input?(skill, input) }
|
|
28
|
+
matched.empty? ? mode_skills : matched.sort_by { |skill| [-skill.priority, skill.name.downcase] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reload!
|
|
32
|
+
previous_names = cached_skill_names
|
|
33
|
+
clear_cached_reports!
|
|
34
|
+
current_names = valid_skills.map { |skill| skill.name.downcase }
|
|
35
|
+
@last_reload_report = build_reload_report(previous_names, current_names)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def last_reload_report
|
|
39
|
+
@last_reload_report || default_reload_report
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def cached_skill_names
|
|
45
|
+
return [] unless @valid_skills
|
|
46
|
+
|
|
47
|
+
@valid_skills.map { |skill| skill.name.downcase }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear_cached_reports!
|
|
51
|
+
@load_report = nil
|
|
52
|
+
@valid_skills = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_reload_report
|
|
56
|
+
build_reload_report([], valid_skills.map { |skill| skill.name.downcase }).merge(added: 0)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_reload_report(previous_names, current_names)
|
|
60
|
+
{
|
|
61
|
+
total: current_names.size,
|
|
62
|
+
added: (current_names - previous_names).size,
|
|
63
|
+
removed: (previous_names - current_names).size,
|
|
64
|
+
invalid: load_report[:invalid_count],
|
|
65
|
+
invalid_files: load_report[:invalid_files],
|
|
66
|
+
duplicates: duplicate_names.size,
|
|
67
|
+
duplicate_skills: duplicate_names
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def valid_skills
|
|
72
|
+
@valid_skills ||= load_report[:entries]
|
|
73
|
+
.uniq { |entry| entry[:name].downcase }
|
|
74
|
+
.map { |entry| build_definition(entry) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def duplicate_names
|
|
78
|
+
counts = Hash.new(0)
|
|
79
|
+
load_report[:entries].each { |entry| counts[entry[:name].downcase] += 1 }
|
|
80
|
+
counts.select { |_name, count| count > 1 }.keys.sort
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_definition(entry)
|
|
84
|
+
SkillDefinition.new(**entry)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def matches_input?(skill, input)
|
|
88
|
+
haystack = input.to_s.downcase
|
|
89
|
+
trigger_match = !skill.trigger.to_s.empty? && haystack.include?(skill.trigger.downcase)
|
|
90
|
+
tag_match = skill.tags.any? { |tag| haystack.include?(tag.downcase) }
|
|
91
|
+
trigger_match || tag_match
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def loader
|
|
95
|
+
@loader ||= MarkdownLoader.new(project_root: @project_root)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load_report
|
|
99
|
+
@load_report ||= loader.load_report
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RubyCoded
|
|
6
|
+
module Skills
|
|
7
|
+
# Loads project-local markdown skill files.
|
|
8
|
+
class MarkdownLoader
|
|
9
|
+
SUPPORTED_MODES = %w[chat plan agent].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(project_root:)
|
|
12
|
+
@project_root = project_root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load_files
|
|
16
|
+
load_report[:entries]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load_report
|
|
20
|
+
return empty_report unless Dir.exist?(skills_dir)
|
|
21
|
+
|
|
22
|
+
build_report(skill_paths)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def empty_report
|
|
28
|
+
{ entries: [], invalid_count: 0, invalid_files: [] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_report(paths)
|
|
32
|
+
entries, invalid_files = paths.each_with_object([[], []]) do |path, memo|
|
|
33
|
+
collect_report_entry(path, *memo)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
entries: entries,
|
|
38
|
+
invalid_count: invalid_files.size,
|
|
39
|
+
invalid_files: invalid_files
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def collect_report_entry(path, entries, invalid_files)
|
|
44
|
+
parsed = parse_file(path)
|
|
45
|
+
parsed ? entries << parsed : invalid_files << File.basename(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def skill_paths
|
|
49
|
+
Dir.glob(File.join(skills_dir, "*.md"))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def skills_dir
|
|
53
|
+
File.join(@project_root, ".rubycoded", "skills")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_file(path)
|
|
57
|
+
frontmatter, body = extract_frontmatter(File.read(path))
|
|
58
|
+
return nil unless frontmatter
|
|
59
|
+
|
|
60
|
+
build_entry(path, extract_attributes(frontmatter, body))
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_attributes(frontmatter, body)
|
|
66
|
+
data = YAML.safe_load(frontmatter) || {}
|
|
67
|
+
{
|
|
68
|
+
name: data["name"]&.strip,
|
|
69
|
+
description: data["description"]&.strip,
|
|
70
|
+
modes: normalize_modes(data["modes"]),
|
|
71
|
+
trigger: data["trigger"]&.strip,
|
|
72
|
+
priority: data["priority"],
|
|
73
|
+
tags: normalize_tags(data["tags"]),
|
|
74
|
+
content: body.to_s.strip
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def normalize_modes(value)
|
|
79
|
+
Array(value).map { |mode| mode.to_s.strip.downcase }.reject(&:empty?)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_tags(value)
|
|
83
|
+
Array(value).map { |tag| tag.to_s.strip }.reject(&:empty?)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_entry(path, attrs)
|
|
87
|
+
return nil unless valid_entry?(attrs)
|
|
88
|
+
|
|
89
|
+
attrs.merge(path: path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def valid_entry?(attrs)
|
|
93
|
+
required_fields_present?(attrs) && valid_modes?(attrs[:modes])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def required_fields_present?(attrs)
|
|
97
|
+
%i[name description content].all? { |field| !attrs[field].to_s.empty? }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def valid_modes?(modes)
|
|
101
|
+
modes.any? && modes.all? { |mode| SUPPORTED_MODES.include?(mode) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_frontmatter(raw)
|
|
105
|
+
match = raw.match(/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m)
|
|
106
|
+
return [nil, nil] unless match
|
|
107
|
+
|
|
108
|
+
[match[1], match[2]]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|