rubyn-code 0.7.0 → 0.8.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 +17 -1
- data/lib/rubyn_code/agent/conversation.rb +11 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
- data/lib/rubyn_code/agent/llm_caller.rb +5 -1
- data/lib/rubyn_code/agent/loop.rb +57 -3
- data/lib/rubyn_code/agent/response_parser.rb +8 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
- data/lib/rubyn_code/agent/tool_processor.rb +10 -0
- data/lib/rubyn_code/autonomous/daemon.rb +1 -1
- data/lib/rubyn_code/cli/commands/context.rb +27 -0
- data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
- data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
- data/lib/rubyn_code/cli/commands/effort.rb +47 -0
- data/lib/rubyn_code/cli/commands/export.rb +174 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
- data/lib/rubyn_code/cli/commands/resume.rb +97 -26
- data/lib/rubyn_code/cli/commands/think.rb +47 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/mention_expander.rb +19 -0
- data/lib/rubyn_code/cli/repl.rb +31 -1
- data/lib/rubyn_code/cli/repl_commands.rb +1 -1
- data/lib/rubyn_code/cli/repl_setup.rb +8 -6
- data/lib/rubyn_code/config/defaults.rb +2 -1
- data/lib/rubyn_code/config/schema.json +5 -0
- data/lib/rubyn_code/config/settings.rb +4 -2
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +64 -3
- data/lib/rubyn_code/index/prism_extractor.rb +82 -0
- data/lib/rubyn_code/learning/injector.rb +1 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
- data/lib/rubyn_code/llm/adapters/base.rb +2 -1
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
- data/lib/rubyn_code/llm/client.rb +16 -3
- data/lib/rubyn_code/llm/image_reader.rb +60 -0
- data/lib/rubyn_code/llm/message_builder.rb +21 -1
- data/lib/rubyn_code/llm/model_router.rb +4 -4
- data/lib/rubyn_code/mcp/discovery.rb +93 -0
- data/lib/rubyn_code/memory/session_persistence.rb +1 -1
- data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
- data/lib/rubyn_code/protocols/RUBYN.md +0 -3
- data/lib/rubyn_code/tasks/models.rb +0 -16
- data/lib/rubyn_code/teams/teammate.rb +0 -15
- data/lib/rubyn_code/tools/RUBYN.md +2 -2
- data/lib/rubyn_code/tools/bash.rb +3 -3
- data/lib/rubyn_code/tools/code_graph.rb +134 -0
- data/lib/rubyn_code/tools/executor.rb +4 -1
- data/lib/rubyn_code/tools/todo_store.rb +55 -0
- data/lib/rubyn_code/tools/todo_write.rb +88 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +13 -8
- data/skills/rubyn_self_test.md +140 -0
- metadata +10 -7
- data/lib/rubyn_code/context/context_budget.rb +0 -183
- data/lib/rubyn_code/context/schema_filter.rb +0 -64
- data/lib/rubyn_code/learning/shortcut.rb +0 -95
- data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
- data/lib/rubyn_code/llm/streaming.rb +0 -10
- data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rubyn_code/mcp/config'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module MCP
|
|
8
|
+
# Discovers MCP server definitions from a project's `.mcp.json` file
|
|
9
|
+
# (the Claude Code–style layout at the project root) and merges them
|
|
10
|
+
# with the user-level `.rubyn-code/mcp.json` definitions parsed by
|
|
11
|
+
# `MCP::Config`.
|
|
12
|
+
#
|
|
13
|
+
# Project-level entries are tagged `source: :project` so /mcp can
|
|
14
|
+
# distinguish them from user-level entries.
|
|
15
|
+
module Discovery
|
|
16
|
+
PROJECT_CONFIG_FILENAME = '.mcp.json'
|
|
17
|
+
|
|
18
|
+
Entry = Data.define(:name, :command, :args, :env, :url, :source)
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param project_root [String, nil] project root (nil => nothing discovered)
|
|
23
|
+
# @return [Array<Entry>]
|
|
24
|
+
def discover(project_root)
|
|
25
|
+
user = MCP::Config.load(project_root.to_s).map { |cfg| to_entry(cfg, :user) }
|
|
26
|
+
proj = load_project(project_root)
|
|
27
|
+
user + proj
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param project_root [String, nil]
|
|
31
|
+
# @return [Array<Entry>]
|
|
32
|
+
def load_project(project_root)
|
|
33
|
+
return [] if project_root.to_s.empty?
|
|
34
|
+
|
|
35
|
+
path = File.join(project_root, PROJECT_CONFIG_FILENAME)
|
|
36
|
+
return [] unless File.exist?(path)
|
|
37
|
+
|
|
38
|
+
data = JSON.parse(File.read(path))
|
|
39
|
+
Array(data['mcpServers']).filter_map do |name, server_def|
|
|
40
|
+
build_entry(name, server_def, :project)
|
|
41
|
+
end
|
|
42
|
+
rescue JSON::ParserError => e
|
|
43
|
+
RubynCode::Debug.warn("[MCP::Discovery] Failed to parse #{path}: #{e.message}")
|
|
44
|
+
[]
|
|
45
|
+
rescue SystemCallError => e
|
|
46
|
+
RubynCode::Debug.warn("[MCP::Discovery] Could not read #{path}: #{e.message}")
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Entry>] entries that the local runner can start right now
|
|
51
|
+
def stdio_servers(entries)
|
|
52
|
+
entries.reject { |e| e.command.to_s.empty? }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Array<Entry>] entries that need a network protocol (deferred)
|
|
56
|
+
def remote_servers(entries)
|
|
57
|
+
entries.select { |e| e.command.to_s.empty? && !e.url.to_s.empty? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_entry(cfg, source)
|
|
61
|
+
Entry.new(
|
|
62
|
+
name: cfg[:name],
|
|
63
|
+
command: cfg[:command],
|
|
64
|
+
args: cfg[:args],
|
|
65
|
+
env: cfg[:env],
|
|
66
|
+
url: cfg[:url],
|
|
67
|
+
source: source
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_entry(name, server_def, source)
|
|
72
|
+
return nil unless server_def.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
command = server_def['command'].to_s
|
|
75
|
+
url = server_def['url'].to_s
|
|
76
|
+
|
|
77
|
+
if command.empty? && url.empty?
|
|
78
|
+
RubynCode::Debug.warn("[MCP::Discovery] Skipping #{name}: no command or url")
|
|
79
|
+
return nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Entry.new(
|
|
83
|
+
name: name,
|
|
84
|
+
command: command,
|
|
85
|
+
args: Array(server_def['args']),
|
|
86
|
+
env: server_def['env'].is_a?(Hash) ? server_def['env'].transform_keys(&:to_s) : {},
|
|
87
|
+
url: url,
|
|
88
|
+
source: source
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -303,7 +303,7 @@ module RubynCode
|
|
|
303
303
|
CREATE TABLE IF NOT EXISTS messages (
|
|
304
304
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
305
305
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
306
|
-
role TEXT NOT NULL CHECK(role IN ('system','user','assistant')),
|
|
306
|
+
role TEXT NOT NULL CHECK(role IN ('system','user','assistant','tool','function')),
|
|
307
307
|
content TEXT,
|
|
308
308
|
tool_calls TEXT,
|
|
309
309
|
tool_use_id TEXT,
|
|
@@ -9,12 +9,15 @@ module RubynCode
|
|
|
9
9
|
module CostCalculator
|
|
10
10
|
# Per-million-token rates: { model_prefix => [input_rate, output_rate] }
|
|
11
11
|
PRICING = {
|
|
12
|
-
# Anthropic — Claude 5 / 4.8 / 4.6
|
|
12
|
+
# Anthropic — Claude 5 / 4.8 / 4.7 / 4.6
|
|
13
13
|
'claude-fable-5' => [10.00, 50.00],
|
|
14
|
+
'claude-opus-5' => [5.00, 25.00],
|
|
14
15
|
'claude-opus-4-8' => [5.00, 25.00],
|
|
15
|
-
'claude-
|
|
16
|
+
'claude-opus-4-7' => [5.00, 25.00],
|
|
17
|
+
'claude-opus-4-6' => [5.00, 25.00],
|
|
18
|
+
'claude-sonnet-5' => [3.00, 15.00],
|
|
16
19
|
'claude-sonnet-4-6' => [3.00, 15.00],
|
|
17
|
-
'claude-
|
|
20
|
+
'claude-haiku-4-5' => [1.00, 5.00],
|
|
18
21
|
# OpenAI — GPT-5.4
|
|
19
22
|
'gpt-5.4' => [2.50, 10.00],
|
|
20
23
|
'gpt-5.4-mini' => [0.15, 0.60],
|
|
@@ -7,8 +7,5 @@ Safety and coordination protocols for agent lifecycle.
|
|
|
7
7
|
- **`ShutdownHandshake`** — Graceful shutdown. Waits for the current tool call to complete,
|
|
8
8
|
saves conversation state, and cleans up resources.
|
|
9
9
|
|
|
10
|
-
- **`PlanApproval`** — When the agent proposes a multi-step plan, this prompts the user
|
|
11
|
-
for approval before execution. Shows the plan, waits for yes/no/edit.
|
|
12
|
-
|
|
13
10
|
- **`InterruptHandler`** — Traps SIGINT (Ctrl+C). First interrupt cancels the current
|
|
14
11
|
operation. Second interrupt within 2 seconds triggers shutdown.
|
|
@@ -11,22 +11,6 @@ module RubynCode
|
|
|
11
11
|
def completed? = status == 'completed'
|
|
12
12
|
def blocked? = status == 'blocked'
|
|
13
13
|
def failed? = status == 'failed'
|
|
14
|
-
|
|
15
|
-
def to_h
|
|
16
|
-
{
|
|
17
|
-
id: id,
|
|
18
|
-
session_id: session_id,
|
|
19
|
-
title: title,
|
|
20
|
-
description: description,
|
|
21
|
-
status: status,
|
|
22
|
-
priority: priority,
|
|
23
|
-
owner: owner,
|
|
24
|
-
result: result,
|
|
25
|
-
metadata: metadata,
|
|
26
|
-
created_at: created_at,
|
|
27
|
-
updated_at: updated_at
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
14
|
end
|
|
31
15
|
end
|
|
32
16
|
end
|
|
@@ -21,21 +21,6 @@ module RubynCode
|
|
|
21
21
|
|
|
22
22
|
# @return [Boolean] true if this teammate was not spawned by another agent
|
|
23
23
|
def root? = parent_agent_id.nil?
|
|
24
|
-
|
|
25
|
-
# @return [Hash]
|
|
26
|
-
def to_h
|
|
27
|
-
{
|
|
28
|
-
id: id,
|
|
29
|
-
name: name,
|
|
30
|
-
role: role,
|
|
31
|
-
persona: persona,
|
|
32
|
-
model: model,
|
|
33
|
-
status: status,
|
|
34
|
-
parent_agent_id: parent_agent_id,
|
|
35
|
-
metadata: metadata,
|
|
36
|
-
created_at: created_at
|
|
37
|
-
}
|
|
38
|
-
end
|
|
39
24
|
end
|
|
40
25
|
end
|
|
41
26
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Layer 2: Tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
33 built-in tools that Claude can invoke. The extensibility surface of the system.
|
|
4
4
|
|
|
5
5
|
## Core Classes
|
|
6
6
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
| Category | Tools |
|
|
22
22
|
|----------|-------|
|
|
23
|
-
| File I/O | `read_file`, `write_file`, `edit_file`, `glob`, `grep` |
|
|
23
|
+
| File I/O | `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `code_graph` |
|
|
24
24
|
| Shell | `bash`, `background_run` |
|
|
25
25
|
| Rails | `rails_generate`, `db_migrate`, `run_specs`, `bundle_install`, `bundle_add` |
|
|
26
26
|
| Git | `git_commit`, `git_diff`, `git_log`, `git_status` |
|
|
@@ -43,9 +43,9 @@ module RubynCode
|
|
|
43
43
|
env = ENV.to_h.dup
|
|
44
44
|
|
|
45
45
|
env.each_key do |key|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
# String#[] instead of #include?: Style/ArrayIntersect misfires on
|
|
47
|
+
# the any?+include? shape even though key is a String.
|
|
48
|
+
env[key] = '[SCRUBBED]' if Config::Defaults::SCRUB_ENV_VARS.any? { |sensitive| key.upcase[sensitive] }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
env
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'registry'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
# Codegraph-style exploration over the persistent codebase index: one
|
|
9
|
+
# call answers "where is X and how is it wired?" with the verbatim
|
|
10
|
+
# line-numbered source of matching symbols plus their callers, callees,
|
|
11
|
+
# and affected files — replacing a grep + read_file loop.
|
|
12
|
+
class CodeGraph < Base
|
|
13
|
+
TOOL_NAME = 'code_graph'
|
|
14
|
+
DESCRIPTION = 'Explores the codebase knowledge graph. Given symbol names or keywords, returns the ' \
|
|
15
|
+
'matching definitions with verbatim line-numbered source, the methods that call them, ' \
|
|
16
|
+
'the methods they call, and the affected files (including specs). Prefer this over ' \
|
|
17
|
+
'grep/read_file when locating or understanding code — one call replaces a search loop.'
|
|
18
|
+
PARAMETERS = {
|
|
19
|
+
query: { type: :string, required: true,
|
|
20
|
+
description: 'Symbol names or keywords, e.g. "build_request_body" or "task budget"' },
|
|
21
|
+
max_symbols: { type: :integer, required: false, default: 5,
|
|
22
|
+
description: 'Maximum matching symbols to expand (default 5)' }
|
|
23
|
+
}.freeze
|
|
24
|
+
RISK_LEVEL = :read
|
|
25
|
+
REQUIRES_CONFIRMATION = false
|
|
26
|
+
|
|
27
|
+
MAX_SOURCE_LINES = 60
|
|
28
|
+
MAX_RELATED = 15
|
|
29
|
+
MATCHABLE_TYPES = %w[class module model controller service concern method].freeze
|
|
30
|
+
|
|
31
|
+
def self.summarize(output, args)
|
|
32
|
+
query = args['query'] || args[:query] || ''
|
|
33
|
+
count = output.to_s.scan(/^## /).size
|
|
34
|
+
"code_graph #{query} (#{count} symbols)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute(query:, max_symbols: 5)
|
|
38
|
+
index = Index::CodebaseIndex.new(project_root: project_root)
|
|
39
|
+
index.load_or_build!
|
|
40
|
+
|
|
41
|
+
matches = ranked_matches(index, query, max_symbols)
|
|
42
|
+
return "No symbols matching '#{query}' in the codebase index." if matches.empty?
|
|
43
|
+
|
|
44
|
+
sections = matches.map { |node| render_symbol(index, node) }
|
|
45
|
+
truncate(sections.join("\n\n"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def ranked_matches(index, query, limit)
|
|
51
|
+
terms = query.to_s.downcase.split(/\W+/).reject(&:empty?)
|
|
52
|
+
return [] if terms.empty?
|
|
53
|
+
|
|
54
|
+
scored = index.nodes.filter_map do |node|
|
|
55
|
+
next unless MATCHABLE_TYPES.include?(node['type'])
|
|
56
|
+
|
|
57
|
+
score = score_node(node, terms)
|
|
58
|
+
[score, node] if score.positive?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
scored.sort_by { |score, node| [-score, node['file'].to_s] }
|
|
62
|
+
.map(&:last)
|
|
63
|
+
.uniq { |n| [n['name'], n['file'], n['line']] }
|
|
64
|
+
.first(limit)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def score_node(node, terms)
|
|
68
|
+
name = node['name'].to_s.downcase
|
|
69
|
+
file = node['file'].to_s.downcase
|
|
70
|
+
score = terms.sum do |term|
|
|
71
|
+
if name == term then 100
|
|
72
|
+
elsif name.include?(term) then 40
|
|
73
|
+
elsif file.include?(term) then 10
|
|
74
|
+
else 0
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
# A name hitting every query term beats a single exact-term match.
|
|
78
|
+
score += 50 if terms.size > 1 && terms.all? { |t| name.include?(t) }
|
|
79
|
+
score
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_symbol(index, node)
|
|
83
|
+
header = [node['owner'], node['name']].reject { |p| p.nil? || p.empty? }.join('#')
|
|
84
|
+
lines = ["## #{header} (#{node['file']}:#{node['line']})"]
|
|
85
|
+
lines << source_slice(node)
|
|
86
|
+
append_call_info(lines, index, node) if node['type'] == 'method'
|
|
87
|
+
append_affected_files(lines, index, node)
|
|
88
|
+
lines.join("\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def source_slice(node)
|
|
92
|
+
path = File.join(project_root, node['file'].to_s)
|
|
93
|
+
return '(source unavailable)' unless File.file?(path)
|
|
94
|
+
|
|
95
|
+
start = [node['line'].to_i, 1].max
|
|
96
|
+
finish = (node['end_line'] || (start + MAX_SOURCE_LINES)).to_i
|
|
97
|
+
truncated = finish > start + MAX_SOURCE_LINES
|
|
98
|
+
finish = start + MAX_SOURCE_LINES if truncated
|
|
99
|
+
|
|
100
|
+
slice = File.readlines(path)[(start - 1)..(finish - 1)] || []
|
|
101
|
+
rendered = slice.each_with_index.map { |line, i| "#{(start + i).to_s.rjust(5)}| #{line.rstrip}" }
|
|
102
|
+
rendered << " ...| (truncated at #{MAX_SOURCE_LINES} lines)" if truncated
|
|
103
|
+
rendered.join("\n")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def append_call_info(lines, index, node)
|
|
107
|
+
callers = call_edges(index).select { |e| e['to'] == node['name'] }
|
|
108
|
+
callees = call_edges(index).select { |e| e['from'] == node['file'] && e['from_method'] == node['name'] }
|
|
109
|
+
|
|
110
|
+
unless callers.empty?
|
|
111
|
+
described = callers.first(MAX_RELATED).map { |e| "#{e['from_method']} (#{e['from']}:#{e['line']})" }
|
|
112
|
+
lines << "Called by: #{described.uniq.join(', ')}"
|
|
113
|
+
end
|
|
114
|
+
return if callees.empty?
|
|
115
|
+
|
|
116
|
+
lines << "Calls: #{callees.map { |e| e['to'] }.uniq.first(MAX_RELATED).join(', ')}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def append_affected_files(lines, index, node)
|
|
120
|
+
caller_files = call_edges(index).select { |e| e['to'] == node['name'] }.map { |e| e['from'] }
|
|
121
|
+
spec_edges = index.edges.select { |e| e['relationship'] == 'tests' && e['to'] == node['file'] }
|
|
122
|
+
spec_files = spec_edges.map { |e| e['from'] }
|
|
123
|
+
affected = (caller_files + spec_files).uniq - [node['file']]
|
|
124
|
+
lines << "Affected files: #{affected.first(MAX_RELATED).join(', ')}" unless affected.empty?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def call_edges(index)
|
|
128
|
+
@call_edges ||= index.edges.select { |e| e['relationship'] == 'calls' }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Registry.register(CodeGraph)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -5,7 +5,7 @@ module RubynCode
|
|
|
5
5
|
class Executor
|
|
6
6
|
attr_reader :project_root, :output_compressor, :file_cache
|
|
7
7
|
attr_accessor :llm_client, :background_worker, :on_agent_status, :db, :ask_user_callback,
|
|
8
|
-
:codebase_index, :ide_client
|
|
8
|
+
:codebase_index, :ide_client, :todo_store
|
|
9
9
|
|
|
10
10
|
def initialize(project_root:, ide_client: nil)
|
|
11
11
|
@project_root = File.expand_path(project_root)
|
|
@@ -64,6 +64,9 @@ module RubynCode
|
|
|
64
64
|
else
|
|
65
65
|
tool = tool_class.new(project_root: project_root)
|
|
66
66
|
end
|
|
67
|
+
if @todo_store && tool_class.instance_method(:initialize).parameters.any? { |_, name| name == :store }
|
|
68
|
+
tool.instance_variable_set(:@store, @todo_store)
|
|
69
|
+
end
|
|
67
70
|
inject_dependencies(tool, tool_name)
|
|
68
71
|
tool
|
|
69
72
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
# Shared, thread-safe checklist store. The Agent::Loop owns one instance and
|
|
6
|
+
# exposes it to (a) the renderer so the user sees what's in flight, and
|
|
7
|
+
# (b) the TodoWrite tool so the model can mutate it.
|
|
8
|
+
class TodoStore
|
|
9
|
+
include MonitorMixin
|
|
10
|
+
|
|
11
|
+
Item = Data.define(:content, :status, :active_form)
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super
|
|
15
|
+
@items = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def replace(items)
|
|
19
|
+
synchronize do
|
|
20
|
+
@items = items.map do |item|
|
|
21
|
+
Item.new(
|
|
22
|
+
content: item['content'] || item[:content],
|
|
23
|
+
status: item['status'] || item[:status],
|
|
24
|
+
active_form: (item['active_form'] || item[:active_form]).to_s
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def current
|
|
31
|
+
synchronize { @items.map(&:to_h) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clear
|
|
35
|
+
synchronize { @items = [] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty?
|
|
39
|
+
current.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render
|
|
43
|
+
current.map do |item|
|
|
44
|
+
mark =
|
|
45
|
+
case item[:status]
|
|
46
|
+
when 'completed' then '☑'
|
|
47
|
+
when 'in_progress' then '[~]'
|
|
48
|
+
else '[ ]'
|
|
49
|
+
end
|
|
50
|
+
"#{mark} #{item[:content]}"
|
|
51
|
+
end.join("\n")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'registry'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
# Update the in-turn task checklist. The model uses this to keep the user
|
|
9
|
+
# informed of progress while it works. The store is shared between the
|
|
10
|
+
# Agent::Loop (which exposes it for the renderer) and this tool.
|
|
11
|
+
class TodoWrite < Base
|
|
12
|
+
TOOL_NAME = 'TodoWrite'
|
|
13
|
+
DESCRIPTION = 'Update the in-turn task checklist. ' \
|
|
14
|
+
'Use this to keep the user informed of what you plan to do, ' \
|
|
15
|
+
'what you are currently working on, and what you have finished.'
|
|
16
|
+
PARAMETERS = {
|
|
17
|
+
todos: { type: :array, required: true,
|
|
18
|
+
description: 'The full set of tasks currently on the checklist. ' \
|
|
19
|
+
'Replace the existing list with this. Each task is ' \
|
|
20
|
+
'{ "content": "...", "status": "pending|in_progress|completed", ' \
|
|
21
|
+
'"active_form": "..." }.' }
|
|
22
|
+
}.freeze
|
|
23
|
+
RISK_LEVEL = :read
|
|
24
|
+
REQUIRES_CONFIRMATION = false
|
|
25
|
+
|
|
26
|
+
VALID_STATUS = %w[pending in_progress completed].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(project_root:, store: nil)
|
|
29
|
+
super(project_root: project_root)
|
|
30
|
+
@store = store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def execute(todos:)
|
|
34
|
+
items = Array(todos)
|
|
35
|
+
validated = []
|
|
36
|
+
|
|
37
|
+
items.each_with_index do |item, i|
|
|
38
|
+
return "TodoWrite: item #{i} is not a hash — got #{item.class}" unless item.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
content = item[:content] || item['content']
|
|
41
|
+
status = (item[:status] || item['status']).to_s
|
|
42
|
+
active_form = item[:active_form] || item['active_form']
|
|
43
|
+
|
|
44
|
+
return "TodoWrite: item #{i} missing 'content'" if content.to_s.empty?
|
|
45
|
+
unless VALID_STATUS.include?(status)
|
|
46
|
+
return "TodoWrite: item #{i} status must be one of #{VALID_STATUS.join('/')} (got #{status.inspect})"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
validated << {
|
|
50
|
+
'content' => content.to_s,
|
|
51
|
+
'status' => status,
|
|
52
|
+
'active_form' => active_form.to_s.empty? ? content.to_s : active_form.to_s
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@store&.replace(validated)
|
|
57
|
+
format(validated)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.summarize(_output, args)
|
|
61
|
+
count = Array(args[:todos] || args['todos']).size
|
|
62
|
+
if count.zero?
|
|
63
|
+
'cleared checklist'
|
|
64
|
+
else
|
|
65
|
+
"checklist: #{count} item#{'s' unless count == 1}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def format(items)
|
|
72
|
+
return 'Checklist cleared.' if items.empty?
|
|
73
|
+
|
|
74
|
+
items.map do |item|
|
|
75
|
+
mark =
|
|
76
|
+
case item['status']
|
|
77
|
+
when 'completed' then '[x]'
|
|
78
|
+
when 'in_progress' then '[~]'
|
|
79
|
+
else '[ ]'
|
|
80
|
+
end
|
|
81
|
+
"#{mark} #{item['content']}"
|
|
82
|
+
end.join("\n")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Registry.register(TodoWrite)
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/rubyn_code/version.rb
CHANGED
data/lib/rubyn_code.rb
CHANGED
|
@@ -47,6 +47,11 @@ module RubynCode
|
|
|
47
47
|
module LLM
|
|
48
48
|
autoload :Client, 'rubyn_code/llm/client'
|
|
49
49
|
autoload :MessageBuilder, 'rubyn_code/llm/message_builder'
|
|
50
|
+
autoload :TextBlock, 'rubyn_code/llm/message_builder'
|
|
51
|
+
autoload :ThinkingBlock, 'rubyn_code/llm/message_builder'
|
|
52
|
+
autoload :ImageBlock, 'rubyn_code/llm/message_builder'
|
|
53
|
+
autoload :ToolUseBlock, 'rubyn_code/llm/message_builder'
|
|
54
|
+
autoload :ImageReader, 'rubyn_code/llm/image_reader'
|
|
50
55
|
autoload :ModelRouter, 'rubyn_code/llm/model_router'
|
|
51
56
|
|
|
52
57
|
# Adapters (provider-specific implementations)
|
|
@@ -54,7 +59,6 @@ module RubynCode
|
|
|
54
59
|
autoload :Base, 'rubyn_code/llm/adapters/base'
|
|
55
60
|
autoload :JsonParsing, 'rubyn_code/llm/adapters/json_parsing'
|
|
56
61
|
autoload :PromptCaching, 'rubyn_code/llm/adapters/prompt_caching'
|
|
57
|
-
autoload :TokenCaching, 'rubyn_code/llm/adapters/token_caching'
|
|
58
62
|
autoload :Anthropic, 'rubyn_code/llm/adapters/anthropic'
|
|
59
63
|
autoload :AnthropicCompatible, 'rubyn_code/llm/adapters/anthropic_compatible'
|
|
60
64
|
autoload :AnthropicStreaming, 'rubyn_code/llm/adapters/anthropic_streaming'
|
|
@@ -63,9 +67,6 @@ module RubynCode
|
|
|
63
67
|
autoload :OpenAICompatible, 'rubyn_code/llm/adapters/openai_compatible'
|
|
64
68
|
autoload :OpenAIMessageTranslator, 'rubyn_code/llm/adapters/openai_message_translator'
|
|
65
69
|
end
|
|
66
|
-
|
|
67
|
-
# Backward-compat: LLM::Streaming → Adapters::AnthropicStreaming
|
|
68
|
-
autoload :Streaming, 'rubyn_code/llm/streaming'
|
|
69
70
|
end
|
|
70
71
|
|
|
71
72
|
# Layer 1: Agent Loop
|
|
@@ -81,6 +82,7 @@ module RubynCode
|
|
|
81
82
|
module Tools
|
|
82
83
|
autoload :Base, 'rubyn_code/tools/base'
|
|
83
84
|
autoload :Registry, 'rubyn_code/tools/registry'
|
|
85
|
+
autoload :TodoStore, 'rubyn_code/tools/todo_store'
|
|
84
86
|
autoload :Schema, 'rubyn_code/tools/schema'
|
|
85
87
|
autoload :Executor, 'rubyn_code/tools/executor'
|
|
86
88
|
autoload :ReadFile, 'rubyn_code/tools/read_file'
|
|
@@ -88,6 +90,7 @@ module RubynCode
|
|
|
88
90
|
autoload :EditFile, 'rubyn_code/tools/edit_file'
|
|
89
91
|
autoload :Glob, 'rubyn_code/tools/glob'
|
|
90
92
|
autoload :Grep, 'rubyn_code/tools/grep'
|
|
93
|
+
autoload :CodeGraph, 'rubyn_code/tools/code_graph'
|
|
91
94
|
autoload :Bash, 'rubyn_code/tools/bash'
|
|
92
95
|
autoload :RailsGenerate, 'rubyn_code/tools/rails_generate'
|
|
93
96
|
autoload :DbMigrate, 'rubyn_code/tools/db_migrate'
|
|
@@ -95,6 +98,7 @@ module RubynCode
|
|
|
95
98
|
autoload :BundleInstall, 'rubyn_code/tools/bundle_install'
|
|
96
99
|
autoload :BundleAdd, 'rubyn_code/tools/bundle_add'
|
|
97
100
|
autoload :Compact, 'rubyn_code/tools/compact'
|
|
101
|
+
autoload :TodoWrite, 'rubyn_code/tools/todo_write'
|
|
98
102
|
autoload :LoadSkill, 'rubyn_code/tools/load_skill'
|
|
99
103
|
autoload :Task, 'rubyn_code/tools/task'
|
|
100
104
|
autoload :MemorySearch, 'rubyn_code/tools/memory_search'
|
|
@@ -133,8 +137,6 @@ module RubynCode
|
|
|
133
137
|
autoload :AutoCompact, 'rubyn_code/context/auto_compact'
|
|
134
138
|
autoload :ManualCompact, 'rubyn_code/context/manual_compact'
|
|
135
139
|
autoload :ContextCollapse, 'rubyn_code/context/context_collapse'
|
|
136
|
-
autoload :ContextBudget, 'rubyn_code/context/context_budget'
|
|
137
|
-
autoload :SchemaFilter, 'rubyn_code/context/schema_filter'
|
|
138
140
|
autoload :DecisionCompactor, 'rubyn_code/context/decision_compactor'
|
|
139
141
|
end
|
|
140
142
|
|
|
@@ -201,7 +203,6 @@ module RubynCode
|
|
|
201
203
|
# Layer 10: Protocols
|
|
202
204
|
module Protocols
|
|
203
205
|
autoload :ShutdownHandshake, 'rubyn_code/protocols/shutdown_handshake'
|
|
204
|
-
autoload :PlanApproval, 'rubyn_code/protocols/plan_approval'
|
|
205
206
|
autoload :InterruptHandler, 'rubyn_code/protocols/interrupt_handler'
|
|
206
207
|
end
|
|
207
208
|
|
|
@@ -258,6 +259,7 @@ module RubynCode
|
|
|
258
259
|
autoload :ToolBridge, 'rubyn_code/mcp/tool_bridge'
|
|
259
260
|
autoload :ServerExtrasBridge, 'rubyn_code/mcp/server_extras_bridge'
|
|
260
261
|
autoload :Config, 'rubyn_code/mcp/config'
|
|
262
|
+
autoload :Discovery, 'rubyn_code/mcp/discovery'
|
|
261
263
|
end
|
|
262
264
|
|
|
263
265
|
# Layer 16: Learning
|
|
@@ -266,7 +268,6 @@ module RubynCode
|
|
|
266
268
|
autoload :Instinct, 'rubyn_code/learning/instinct'
|
|
267
269
|
autoload :InstinctMethods, 'rubyn_code/learning/instinct'
|
|
268
270
|
autoload :Injector, 'rubyn_code/learning/injector'
|
|
269
|
-
autoload :Shortcut, 'rubyn_code/learning/shortcut'
|
|
270
271
|
autoload :Porter, 'rubyn_code/learning/porter'
|
|
271
272
|
end
|
|
272
273
|
|
|
@@ -286,6 +287,7 @@ module RubynCode
|
|
|
286
287
|
# Codebase Index
|
|
287
288
|
module Index
|
|
288
289
|
autoload :CodebaseIndex, 'rubyn_code/index/codebase_index'
|
|
290
|
+
autoload :PrismExtractor, 'rubyn_code/index/prism_extractor'
|
|
289
291
|
end
|
|
290
292
|
|
|
291
293
|
# CLI
|
|
@@ -347,6 +349,9 @@ module RubynCode
|
|
|
347
349
|
autoload :ChiselAudit, 'rubyn_code/cli/commands/chisel_audit'
|
|
348
350
|
autoload :ChiselDebt, 'rubyn_code/cli/commands/chisel_debt'
|
|
349
351
|
autoload :ChiselGain, 'rubyn_code/cli/commands/chisel_gain'
|
|
352
|
+
autoload :Think, 'rubyn_code/cli/commands/think'
|
|
353
|
+
autoload :Effort, 'rubyn_code/cli/commands/effort'
|
|
354
|
+
autoload :Export, 'rubyn_code/cli/commands/export'
|
|
350
355
|
end
|
|
351
356
|
end
|
|
352
357
|
|