rubyn-code 0.2.2 → 0.3.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/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
|
@@ -7,11 +7,15 @@ module RubynCode
|
|
|
7
7
|
module Tools
|
|
8
8
|
class EditFile < Base
|
|
9
9
|
TOOL_NAME = 'edit_file'
|
|
10
|
-
DESCRIPTION = 'Performs exact string replacement in a file.
|
|
10
|
+
DESCRIPTION = 'Performs exact string replacement in a file. ' \
|
|
11
|
+
'Fails if old_text is not found or is ambiguous.'
|
|
11
12
|
PARAMETERS = {
|
|
12
|
-
path: { type: :string, required: true,
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
path: { type: :string, required: true,
|
|
14
|
+
description: 'Path to the file to edit' },
|
|
15
|
+
old_text: { type: :string, required: true,
|
|
16
|
+
description: 'The exact text to find and replace' },
|
|
17
|
+
new_text: { type: :string, required: true,
|
|
18
|
+
description: 'The replacement text' },
|
|
15
19
|
replace_all: { type: :boolean, required: false, default: false,
|
|
16
20
|
description: 'Replace all occurrences (default: false)' }
|
|
17
21
|
}.freeze
|
|
@@ -22,25 +26,77 @@ module RubynCode
|
|
|
22
26
|
resolved = read_file_safely(path)
|
|
23
27
|
content = File.read(resolved)
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
validate_occurrences!(path, content, old_text, replace_all)
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
new_content = apply_replacement(content, old_text, new_text, replace_all)
|
|
32
|
+
File.write(resolved, new_content)
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"old_text found #{occurrences} times in #{path}. Use replace_all: true to replace all, or provide a more specific old_text."
|
|
32
|
-
end
|
|
34
|
+
format_diff_result(path, content, old_text, new_text, replace_all)
|
|
35
|
+
end
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
content.gsub(old_text, new_text)
|
|
36
|
-
else
|
|
37
|
-
content.sub(old_text, new_text)
|
|
38
|
-
end
|
|
37
|
+
private
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
def validate_occurrences!(path, content, old_text, replace_all)
|
|
40
|
+
count = content.scan(old_text).length
|
|
41
|
+
|
|
42
|
+
raise Error, "old_text not found in #{path}. No changes made." if count.zero?
|
|
43
|
+
|
|
44
|
+
return if replace_all || count == 1
|
|
45
|
+
|
|
46
|
+
raise Error,
|
|
47
|
+
"old_text found #{count} times in #{path}. " \
|
|
48
|
+
'Use replace_all: true or provide more specific old_text.'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def apply_replacement(content, old_text, new_text, replace_all)
|
|
52
|
+
replace_all ? content.gsub(old_text, new_text) : content.sub(old_text, new_text)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
CONTEXT_LINES = 3 # rubocop:disable Lint/UselessConstantScoping
|
|
56
|
+
|
|
57
|
+
def format_diff_result(path, original, old_text, new_text, replace_all)
|
|
58
|
+
count = replace_all ? original.scan(old_text).length : 1
|
|
59
|
+
lines = diff_header(path, count, original, old_text)
|
|
60
|
+
lines.concat(diff_body(original, old_text, new_text))
|
|
61
|
+
lines.join("\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def diff_header(path, count, original, old_text)
|
|
65
|
+
line_num = find_line_number(original, old_text)
|
|
66
|
+
header = ["Edited #{path} (#{count} replacement#{'s' if count > 1})"]
|
|
67
|
+
header << " @@ line #{line_num} @@" if line_num
|
|
68
|
+
header
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def diff_body(original, old_text, new_text)
|
|
72
|
+
lines = context_before(original, old_text)
|
|
73
|
+
old_text.lines.each { |l| lines << " - #{l.chomp}" }
|
|
74
|
+
new_text.lines.each { |l| lines << " + #{l.chomp}" }
|
|
75
|
+
lines.concat(context_after(original, old_text))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def context_before(content, text)
|
|
79
|
+
idx = content.index(text)
|
|
80
|
+
return [] unless idx
|
|
81
|
+
|
|
82
|
+
before = content[0...idx].lines.last(CONTEXT_LINES)
|
|
83
|
+
before.map { |l| " #{l.chomp}" }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def context_after(content, text)
|
|
87
|
+
idx = content.index(text)
|
|
88
|
+
return [] unless idx
|
|
89
|
+
|
|
90
|
+
after_start = idx + text.length
|
|
91
|
+
after = content[after_start..].lines.first(CONTEXT_LINES)
|
|
92
|
+
after.map { |l| " #{l.chomp}" }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def find_line_number(content, text)
|
|
96
|
+
idx = content.index(text)
|
|
97
|
+
return nil unless idx
|
|
41
98
|
|
|
42
|
-
|
|
43
|
-
"Successfully replaced #{replaced_count} occurrence#{'s' if replaced_count > 1} in #{path}"
|
|
99
|
+
content[0...idx].count("\n") + 1
|
|
44
100
|
end
|
|
45
101
|
end
|
|
46
102
|
|
|
@@ -3,30 +3,27 @@
|
|
|
3
3
|
module RubynCode
|
|
4
4
|
module Tools
|
|
5
5
|
class Executor
|
|
6
|
-
attr_reader :project_root
|
|
7
|
-
attr_accessor :llm_client, :background_worker, :on_agent_status, :db
|
|
6
|
+
attr_reader :project_root, :output_compressor, :file_cache
|
|
7
|
+
attr_accessor :llm_client, :background_worker, :on_agent_status, :db, :ask_user_callback
|
|
8
8
|
|
|
9
9
|
def initialize(project_root:)
|
|
10
10
|
@project_root = File.expand_path(project_root)
|
|
11
11
|
@injections = {}
|
|
12
|
+
@output_compressor = OutputCompressor.new
|
|
13
|
+
@file_cache = FileCache.new
|
|
12
14
|
Registry.load_all!
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
def execute(tool_name, params)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
# Inject dependencies for tools that need them
|
|
20
|
-
inject_dependencies(tool, tool_name)
|
|
17
|
+
def execute(tool_name, params) # rubocop:disable Metrics/AbcSize -- maps tool errors to results
|
|
18
|
+
# File cache intercept: serve cached reads, invalidate on writes
|
|
19
|
+
cached = try_file_cache(tool_name, params)
|
|
20
|
+
return cached if cached
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
filtered = allowed.empty? ? symbolized : symbolized.slice(*allowed)
|
|
28
|
-
result = tool.execute(**filtered)
|
|
29
|
-
tool.truncate(result.to_s)
|
|
22
|
+
tool = build_tool(tool_name)
|
|
23
|
+
filtered = filter_params(tool, params)
|
|
24
|
+
raw = tool.truncate(tool.execute(**filtered).to_s)
|
|
25
|
+
update_file_cache(tool_name, filtered, raw)
|
|
26
|
+
@output_compressor.compress(tool_name, raw)
|
|
30
27
|
rescue ToolNotFoundError => e
|
|
31
28
|
error_result("Tool error: #{e.message}")
|
|
32
29
|
rescue PermissionDeniedError => e
|
|
@@ -45,20 +42,73 @@ module RubynCode
|
|
|
45
42
|
|
|
46
43
|
private
|
|
47
44
|
|
|
48
|
-
def
|
|
45
|
+
def build_tool(tool_name)
|
|
46
|
+
tool_class = Registry.get(tool_name)
|
|
47
|
+
tool = tool_class.new(project_root: project_root)
|
|
48
|
+
inject_dependencies(tool, tool_name)
|
|
49
|
+
tool
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def filter_params(tool, params)
|
|
53
|
+
symbolized = params.transform_keys(&:to_sym)
|
|
54
|
+
allowed = tool.method(:execute).parameters
|
|
55
|
+
.select { |type, _| %i[key keyreq].include?(type) } # rubocop:disable Style/HashSlice
|
|
56
|
+
.map(&:last)
|
|
57
|
+
allowed.empty? ? symbolized : symbolized.slice(*allowed)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inject_dependencies(tool, tool_name) # rubocop:disable Metrics/CyclomaticComplexity -- tool-specific dependency injection
|
|
49
61
|
case tool_name
|
|
50
|
-
when 'spawn_agent'
|
|
51
|
-
tool
|
|
52
|
-
tool.
|
|
53
|
-
when 'spawn_teammate'
|
|
54
|
-
tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
|
|
55
|
-
tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
|
|
56
|
-
tool.db = @db if tool.respond_to?(:db=)
|
|
62
|
+
when 'spawn_agent', 'spawn_teammate'
|
|
63
|
+
inject_agent_deps(tool)
|
|
64
|
+
tool.db = @db if tool_name == 'spawn_teammate' && tool.respond_to?(:db=)
|
|
57
65
|
when 'background_run'
|
|
58
66
|
tool.background_worker = @background_worker if tool.respond_to?(:background_worker=)
|
|
67
|
+
when 'ask_user'
|
|
68
|
+
tool.prompt_callback = @ask_user_callback if tool.respond_to?(:prompt_callback=)
|
|
59
69
|
end
|
|
60
70
|
end
|
|
61
71
|
|
|
72
|
+
def inject_agent_deps(tool)
|
|
73
|
+
tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
|
|
74
|
+
tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Serve read_file from cache if the file hasn't changed.
|
|
78
|
+
def try_file_cache(tool_name, params)
|
|
79
|
+
return nil unless tool_name == 'read_file'
|
|
80
|
+
|
|
81
|
+
path = resolve_cache_path(params)
|
|
82
|
+
return nil unless path && @file_cache.cached?(path)
|
|
83
|
+
|
|
84
|
+
result = @file_cache.read(path)
|
|
85
|
+
result[:content]
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Cache read_file results; invalidate on write_file/edit_file.
|
|
91
|
+
def update_file_cache(tool_name, params, _raw)
|
|
92
|
+
path = resolve_cache_path(params)
|
|
93
|
+
return unless path
|
|
94
|
+
|
|
95
|
+
case tool_name
|
|
96
|
+
when 'read_file' then @file_cache.read(path) # populates cache
|
|
97
|
+
when 'write_file', 'edit_file' then @file_cache.on_write(path)
|
|
98
|
+
end
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve_cache_path(params)
|
|
104
|
+
p = params[:path] || params['path']
|
|
105
|
+
return nil unless p
|
|
106
|
+
|
|
107
|
+
File.expand_path(p, @project_root)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
62
112
|
def error_result(message)
|
|
63
113
|
message
|
|
64
114
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Session-scoped file cache that serves previously-read file contents
|
|
6
|
+
# when the file has not been modified since the last read. Invalidates
|
|
7
|
+
# automatically when Rubyn writes or edits a file, or when mtime changes.
|
|
8
|
+
class FileCache
|
|
9
|
+
CHARS_PER_TOKEN = 4
|
|
10
|
+
|
|
11
|
+
Entry = Data.define(:content, :mtime, :token_count, :read_count, :cache_hits)
|
|
12
|
+
|
|
13
|
+
attr_reader :cache
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@cache = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns cached content if the file hasn't changed, otherwise reads
|
|
20
|
+
# from disk and caches the result.
|
|
21
|
+
#
|
|
22
|
+
# @param path [String] absolute file path
|
|
23
|
+
# @return [Hash] { content:, source: :cache|:disk, tokens_saved: }
|
|
24
|
+
def read(path)
|
|
25
|
+
current_mtime = File.mtime(path)
|
|
26
|
+
cached = @cache[path]
|
|
27
|
+
|
|
28
|
+
if cached && cached.mtime == current_mtime
|
|
29
|
+
bump_hits(path)
|
|
30
|
+
{ content: cached.content, source: :cache, tokens_saved: cached.token_count }
|
|
31
|
+
else
|
|
32
|
+
content = File.read(path)
|
|
33
|
+
token_count = estimate_tokens(content)
|
|
34
|
+
@cache[path] = Entry.new(
|
|
35
|
+
content: content, mtime: current_mtime,
|
|
36
|
+
token_count: token_count, read_count: 1, cache_hits: 0
|
|
37
|
+
)
|
|
38
|
+
{ content: content, source: :disk, tokens_saved: 0 }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Removes a path from the cache. Called when Rubyn writes/edits the file.
|
|
43
|
+
def invalidate(path)
|
|
44
|
+
@cache.delete(path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Alias for use as a write hook.
|
|
48
|
+
def on_write(path)
|
|
49
|
+
invalidate(path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns true if the given path is currently cached and fresh.
|
|
53
|
+
def cached?(path)
|
|
54
|
+
return false unless @cache.key?(path)
|
|
55
|
+
|
|
56
|
+
@cache[path].mtime == File.mtime(path)
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
@cache.delete(path)
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Clears the entire cache.
|
|
63
|
+
def clear!
|
|
64
|
+
@cache.clear
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns aggregate statistics about cache performance.
|
|
68
|
+
def stats
|
|
69
|
+
total_reads = @cache.values.sum(&:read_count)
|
|
70
|
+
total_hits = @cache.values.sum(&:cache_hits)
|
|
71
|
+
tokens_saved = @cache.values.sum { |e| e.cache_hits * e.token_count }
|
|
72
|
+
hit_rate = total_reads.positive? ? total_hits.to_f / (total_reads + total_hits) : 0.0
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
entries: @cache.size,
|
|
76
|
+
total_reads: total_reads,
|
|
77
|
+
cache_hits: total_hits,
|
|
78
|
+
hit_rate: hit_rate.round(3),
|
|
79
|
+
tokens_saved: tokens_saved
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def bump_hits(path)
|
|
86
|
+
old = @cache[path]
|
|
87
|
+
@cache[path] = old.with(cache_hits: old.cache_hits + 1)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def estimate_tokens(content)
|
|
91
|
+
(content.bytesize.to_f / CHARS_PER_TOKEN).ceil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -58,21 +58,23 @@ module RubynCode
|
|
|
58
58
|
def create_commit(message)
|
|
59
59
|
stdout, stderr, status = safe_capture3('git', 'commit', '-m', message, chdir: project_root)
|
|
60
60
|
|
|
61
|
-
unless status.success?
|
|
62
|
-
return 'Nothing to commit — working tree is clean.' if stderr.include?('nothing to commit')
|
|
61
|
+
return handle_commit_failure(stdout, stderr) unless status.success?
|
|
63
62
|
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
format_commit_output(stdout)
|
|
64
|
+
end
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
def handle_commit_failure(stdout, stderr)
|
|
67
|
+
output = "#{stdout}\n#{stderr}"
|
|
68
|
+
return 'Nothing to commit — working tree is clean.' if output.include?('nothing to commit')
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
raise Error, "Commit failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_commit_output(stdout)
|
|
74
|
+
lines = ["Committed on branch: #{current_branch}"]
|
|
75
|
+
lines << "Commit: #{extract_commit_hash}" if extract_commit_hash
|
|
73
76
|
lines << ''
|
|
74
77
|
lines << stdout.strip
|
|
75
|
-
|
|
76
78
|
lines.join("\n")
|
|
77
79
|
end
|
|
78
80
|
|
|
@@ -18,26 +18,28 @@ module RubynCode
|
|
|
18
18
|
|
|
19
19
|
def execute(count: 20, branch: nil)
|
|
20
20
|
validate_git_repo!
|
|
21
|
+
stdout = run_git_log(count.to_i.clamp(1, 200), branch)
|
|
22
|
+
format_log_output(stdout, branch)
|
|
23
|
+
end
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
private
|
|
23
26
|
|
|
27
|
+
def run_git_log(count, branch)
|
|
24
28
|
cmd = ['git', 'log', '--oneline', "-#{count}"]
|
|
25
29
|
cmd << branch unless branch.nil? || branch.strip.empty?
|
|
26
30
|
|
|
27
31
|
stdout, stderr, status = safe_capture3(*cmd, chdir: project_root)
|
|
28
|
-
|
|
29
32
|
raise Error, "git log failed: #{stderr.strip}" unless status.success?
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
'No commits found.'
|
|
33
|
-
else
|
|
34
|
-
current = current_branch
|
|
35
|
-
header = "Commit history#{branch ? " (#{branch})" : " (#{current})"}:\n\n"
|
|
36
|
-
truncate("#{header}#{stdout}", max: 50_000)
|
|
37
|
-
end
|
|
34
|
+
stdout
|
|
38
35
|
end
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
def format_log_output(stdout, branch)
|
|
38
|
+
return 'No commits found.' if stdout.strip.empty?
|
|
39
|
+
|
|
40
|
+
display_branch = branch || current_branch
|
|
41
|
+
truncate("Commit history (#{display_branch}):\n\n#{stdout}", max: 50_000)
|
|
42
|
+
end
|
|
41
43
|
|
|
42
44
|
def validate_git_repo!
|
|
43
45
|
_, _, status = safe_capture3('git', 'rev-parse', '--is-inside-work-tree', chdir: project_root)
|
|
@@ -9,29 +9,45 @@ module RubynCode
|
|
|
9
9
|
TOOL_NAME = 'glob'
|
|
10
10
|
DESCRIPTION = 'File pattern matching. Returns sorted list of file paths matching the glob pattern.'
|
|
11
11
|
PARAMETERS = {
|
|
12
|
-
pattern: {
|
|
13
|
-
|
|
12
|
+
pattern: {
|
|
13
|
+
type: :string, required: true,
|
|
14
|
+
description: "Glob pattern (e.g. '**/*.rb', 'app/**/*.erb')"
|
|
15
|
+
},
|
|
16
|
+
path: {
|
|
17
|
+
type: :string, required: false,
|
|
18
|
+
description: 'Directory to search in (defaults to project root)'
|
|
19
|
+
}
|
|
14
20
|
}.freeze
|
|
15
21
|
RISK_LEVEL = :read
|
|
16
22
|
REQUIRES_CONFIRMATION = false
|
|
17
23
|
|
|
18
24
|
def execute(pattern:, path: nil)
|
|
19
|
-
search_dir =
|
|
20
|
-
|
|
21
|
-
raise Error, "Directory not found: #{path || project_root}" unless File.directory?(search_dir)
|
|
22
|
-
|
|
25
|
+
search_dir = resolve_search_dir(path)
|
|
23
26
|
full_pattern = File.join(search_dir, pattern)
|
|
24
27
|
matches = Dir.glob(full_pattern, File::FNM_DOTMATCH).sort
|
|
25
28
|
|
|
26
29
|
matches
|
|
27
30
|
.select { |f| File.file?(f) }
|
|
28
|
-
.reject { |f|
|
|
31
|
+
.reject { |f| dot_entry?(f) }
|
|
29
32
|
.map { |f| relative_to_root(f) }
|
|
30
33
|
.join("\n")
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
private
|
|
34
37
|
|
|
38
|
+
def resolve_search_dir(path)
|
|
39
|
+
search_dir = path ? safe_path(path) : project_root
|
|
40
|
+
|
|
41
|
+
raise Error, "Directory not found: #{path || project_root}" unless File.directory?(search_dir)
|
|
42
|
+
|
|
43
|
+
search_dir
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dot_entry?(file)
|
|
47
|
+
basename = File.basename(file)
|
|
48
|
+
['.', '..'].include?(basename)
|
|
49
|
+
end
|
|
50
|
+
|
|
35
51
|
def relative_to_root(absolute_path)
|
|
36
52
|
absolute_path.delete_prefix("#{project_root}/")
|
|
37
53
|
end
|
|
@@ -7,7 +7,8 @@ module RubynCode
|
|
|
7
7
|
module Tools
|
|
8
8
|
class Grep < Base
|
|
9
9
|
TOOL_NAME = 'grep'
|
|
10
|
-
DESCRIPTION = 'Searches file contents using regular expressions.
|
|
10
|
+
DESCRIPTION = 'Searches file contents using regular expressions. ' \
|
|
11
|
+
'Returns matching lines with file paths and line numbers.'
|
|
11
12
|
PARAMETERS = {
|
|
12
13
|
pattern: { type: :string, required: true, description: 'Regular expression pattern to search for' },
|
|
13
14
|
path: { type: :string, required: false,
|
|
@@ -7,9 +7,11 @@ module RubynCode
|
|
|
7
7
|
module Tools
|
|
8
8
|
class LoadSkill < Base
|
|
9
9
|
TOOL_NAME = 'load_skill'
|
|
10
|
-
DESCRIPTION = 'Loads a skill document into
|
|
10
|
+
DESCRIPTION = 'Loads a best-practice skill document into context. ' \
|
|
11
|
+
'Pass the skill name (e.g. "shared-examples", "adapter", "request-specs").'
|
|
11
12
|
PARAMETERS = {
|
|
12
|
-
name: { type: :string, required: true,
|
|
13
|
+
name: { type: :string, required: true,
|
|
14
|
+
description: 'Skill name, e.g. "adapter", "shared-examples", "request-specs"' }
|
|
13
15
|
}.freeze
|
|
14
16
|
RISK_LEVEL = :read
|
|
15
17
|
REQUIRES_CONFIRMATION = false
|
|
@@ -20,18 +22,23 @@ module RubynCode
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def execute(name:)
|
|
25
|
+
# Strip leading slash — LLM sometimes sends /skill-name
|
|
26
|
+
cleaned = name.to_s.sub(%r{\A/+}, '').strip
|
|
27
|
+
return 'Error: skill name required' if cleaned.empty?
|
|
28
|
+
|
|
23
29
|
loader = @skill_loader || default_loader
|
|
24
|
-
loader.load(
|
|
30
|
+
loader.load(cleaned)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
private
|
|
28
34
|
|
|
29
35
|
def default_loader
|
|
30
36
|
skills_dirs = [
|
|
31
|
-
File.
|
|
32
|
-
File.join(
|
|
37
|
+
File.expand_path('../../../skills', __dir__), # bundled gem skills
|
|
38
|
+
File.join(project_root, '.rubyn-code', 'skills'), # project skills
|
|
39
|
+
File.join(Dir.home, '.rubyn-code', 'skills') # global user skills
|
|
33
40
|
]
|
|
34
|
-
catalog = Skills::Catalog.new(skills_dirs)
|
|
41
|
+
catalog = Skills::Catalog.new(skills_dirs.select { |d| Dir.exist?(d) })
|
|
35
42
|
Skills::Loader.new(catalog)
|
|
36
43
|
end
|
|
37
44
|
end
|
|
@@ -14,7 +14,8 @@ module RubynCode
|
|
|
14
14
|
query: { type: :string, required: true, description: 'Search query for finding relevant memories' },
|
|
15
15
|
tier: { type: :string, required: false, description: 'Filter by memory tier: short, medium, or long' },
|
|
16
16
|
category: { type: :string, required: false,
|
|
17
|
-
description: 'Filter by category: code_pattern, user_preference,
|
|
17
|
+
description: 'Filter by category: code_pattern, user_preference, ' \
|
|
18
|
+
'project_convention, error_resolution, or decision' },
|
|
18
19
|
limit: { type: :integer, required: false, description: 'Maximum number of results to return (default 10)' }
|
|
19
20
|
}.freeze
|
|
20
21
|
RISK_LEVEL = :read
|
|
@@ -49,21 +50,21 @@ module RubynCode
|
|
|
49
50
|
# @return [String]
|
|
50
51
|
def format_results(records)
|
|
51
52
|
lines = ["Found #{records.size} memor#{records.size == 1 ? 'y' : 'ies'}:\n"]
|
|
52
|
-
|
|
53
|
-
records.each_with_index do |record, idx|
|
|
54
|
-
lines << "--- Memory #{idx + 1} ---"
|
|
55
|
-
lines << "ID: #{record.id}"
|
|
56
|
-
lines << "Tier: #{record.tier} | Category: #{record.category || 'none'}"
|
|
57
|
-
lines << "Relevance: #{format('%.2f', record.relevance_score)} | Accessed: #{record.access_count} times"
|
|
58
|
-
lines << "Created: #{record.created_at}"
|
|
59
|
-
lines << ''
|
|
60
|
-
lines << record.content
|
|
61
|
-
lines << ''
|
|
62
|
-
end
|
|
63
|
-
|
|
53
|
+
records.each_with_index { |record, idx| lines.concat(format_single_memory(record, idx)) }
|
|
64
54
|
lines.join("\n")
|
|
65
55
|
end
|
|
66
56
|
|
|
57
|
+
def format_single_memory(record, idx)
|
|
58
|
+
[
|
|
59
|
+
"--- Memory #{idx + 1} ---",
|
|
60
|
+
"ID: #{record.id}",
|
|
61
|
+
"Tier: #{record.tier} | Category: #{record.category || 'none'}",
|
|
62
|
+
"Relevance: #{format('%.2f', record.relevance_score)} | Accessed: #{record.access_count} times",
|
|
63
|
+
"Created: #{record.created_at}",
|
|
64
|
+
'', record.content, ''
|
|
65
|
+
]
|
|
66
|
+
end
|
|
67
|
+
|
|
67
68
|
# Lazily resolves a Memory::Search instance from the project root.
|
|
68
69
|
#
|
|
69
70
|
# @return [Memory::Search]
|
|
@@ -15,7 +15,8 @@ module RubynCode
|
|
|
15
15
|
tier: { type: :string, required: false,
|
|
16
16
|
description: 'Memory retention tier: short, medium (default), or long' },
|
|
17
17
|
category: { type: :string, required: false,
|
|
18
|
-
description: 'Category: code_pattern, user_preference,
|
|
18
|
+
description: 'Category: code_pattern, user_preference, ' \
|
|
19
|
+
'project_convention, error_resolution, or decision' }
|
|
19
20
|
}.freeze
|
|
20
21
|
RISK_LEVEL = :read # Memory is internal — no user approval needed
|
|
21
22
|
|