rubyn-code 0.5.1 → 0.7.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 +120 -3
- data/db/migrations/014_multi_agent_upgrade.rb +79 -0
- data/lib/rubyn_code/agent/conversation.rb +89 -3
- data/lib/rubyn_code/agent/llm_caller.rb +2 -2
- data/lib/rubyn_code/agent/loop.rb +49 -9
- data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
- data/lib/rubyn_code/agent/tool_processor.rb +3 -1
- data/lib/rubyn_code/auth/oauth.rb +1 -1
- data/lib/rubyn_code/auth/token_store.rb +49 -4
- data/lib/rubyn_code/checkpoint/hook.rb +26 -0
- data/lib/rubyn_code/checkpoint/manager.rb +109 -0
- data/lib/rubyn_code/chisel/debt.rb +65 -0
- data/lib/rubyn_code/chisel/inspection.rb +93 -0
- data/lib/rubyn_code/chisel.rb +127 -0
- data/lib/rubyn_code/cli/commands/agents.rb +31 -0
- data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
- data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
- data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
- data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
- data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
- data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
- data/lib/rubyn_code/cli/commands/context.rb +3 -1
- data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
- data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
- data/lib/rubyn_code/cli/commands/goal.rb +87 -0
- data/lib/rubyn_code/cli/commands/learning.rb +62 -0
- data/lib/rubyn_code/cli/commands/loop.rb +58 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
- data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
- data/lib/rubyn_code/cli/commands/registry.rb +14 -9
- data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
- data/lib/rubyn_code/cli/first_run.rb +1 -1
- data/lib/rubyn_code/cli/loop_runner.rb +98 -0
- data/lib/rubyn_code/cli/mention_expander.rb +92 -0
- data/lib/rubyn_code/cli/renderer.rb +3 -2
- data/lib/rubyn_code/cli/repl.rb +37 -14
- data/lib/rubyn_code/cli/repl_commands.rb +76 -2
- data/lib/rubyn_code/cli/repl_setup.rb +9 -1
- data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
- data/lib/rubyn_code/cli/version_check.rb +10 -3
- data/lib/rubyn_code/config/defaults.rb +13 -1
- data/lib/rubyn_code/config/schema.json +4 -0
- data/lib/rubyn_code/config/settings.rb +17 -2
- data/lib/rubyn_code/context/manager.rb +29 -12
- data/lib/rubyn_code/debug.rb +11 -5
- data/lib/rubyn_code/goal/evaluator.rb +95 -0
- data/lib/rubyn_code/hooks/event_map.rb +56 -0
- data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
- data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
- data/lib/rubyn_code/hooks/response.rb +83 -0
- data/lib/rubyn_code/hooks/runner.rb +61 -3
- data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
- data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
- data/lib/rubyn_code/index/codebase_index.rb +39 -1
- data/lib/rubyn_code/learning/porter.rb +129 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
- data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
- data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
- data/lib/rubyn_code/llm/model_router.rb +2 -2
- data/lib/rubyn_code/mcp/client.rb +59 -0
- data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
- data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
- data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
- data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +9 -5
- data/lib/rubyn_code/memory/session_persistence.rb +159 -21
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
- data/lib/rubyn_code/output/diff_renderer.rb +62 -7
- data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
- data/lib/rubyn_code/skills/registry_client.rb +4 -3
- data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
- data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
- data/lib/rubyn_code/teams/agent_registry.rb +120 -0
- data/lib/rubyn_code/teams/mailbox.rb +99 -10
- data/lib/rubyn_code/teams/manager.rb +83 -5
- data/lib/rubyn_code/teams/teammate.rb +5 -1
- data/lib/rubyn_code/tools/ask_user.rb +15 -1
- data/lib/rubyn_code/tools/executor.rb +5 -3
- data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
- data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
- data/lib/rubyn_code/tools/web_fetch.rb +1 -1
- data/lib/rubyn_code/tools/web_search.rb +4 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +45 -2
- data/skills/rubyn_self_test.md +322 -14
- data/skills/self_test/chisel_smoke.rb +84 -0
- data/skills/self_test/fixtures/chisel_sample.rb +64 -0
- metadata +37 -1
|
@@ -7,39 +7,41 @@ module RubynCode
|
|
|
7
7
|
module Memory
|
|
8
8
|
# Saves and restores full conversation sessions to SQLite, enabling
|
|
9
9
|
# session continuity across process restarts and session browsing.
|
|
10
|
-
class SessionPersistence
|
|
10
|
+
class SessionPersistence # rubocop:disable Metrics/ClassLength -- session CRUD + incremental message journal
|
|
11
11
|
# @param db [DB::Connection] database connection
|
|
12
12
|
def initialize(db)
|
|
13
13
|
@db = db
|
|
14
|
+
# Per-session journal bookkeeping: how many messages are already
|
|
15
|
+
# persisted and their object identities, so append-only saves can be
|
|
16
|
+
# detected without comparing message contents.
|
|
17
|
+
@journal_state = {}
|
|
14
18
|
ensure_table
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# Persists a complete session snapshot.
|
|
18
22
|
#
|
|
23
|
+
# Hot-path friendly: when the messages array has only grown since the
|
|
24
|
+
# last save for this session, the new messages are appended to the
|
|
25
|
+
# messages journal table instead of rewriting the whole JSON blob.
|
|
26
|
+
# The blob is only rewritten when history was replaced (compaction,
|
|
27
|
+
# undo, resume) or on the first save of a process.
|
|
28
|
+
#
|
|
19
29
|
# @param attrs [Hash] session attributes:
|
|
20
30
|
# :session_id, :project_path, :messages (required);
|
|
21
31
|
# :title, :model, :metadata (optional)
|
|
22
32
|
# @return [void]
|
|
23
33
|
def save_session(session_id:, project_path:, messages:, **opts)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
36
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
37
|
-
messages = ?,
|
|
38
|
-
title = COALESCE(?, title),
|
|
39
|
-
model = COALESCE(?, model),
|
|
40
|
-
metadata = ?,
|
|
41
|
-
updated_at = ?
|
|
42
|
-
SQL
|
|
34
|
+
if appendable?(session_id, messages)
|
|
35
|
+
append_to_journal(session_id, messages, opts)
|
|
36
|
+
else
|
|
37
|
+
snapshot_session(session_id, project_path, messages, opts)
|
|
38
|
+
end
|
|
39
|
+
remember_journal_state(session_id, messages)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
# Journal append can fail (e.g. role CHECK constraint on legacy
|
|
42
|
+
# schemas) — fall back to a full snapshot.
|
|
43
|
+
snapshot_session(session_id, project_path, messages, opts)
|
|
44
|
+
remember_journal_state(session_id, messages)
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# Loads a session by ID.
|
|
@@ -54,8 +56,10 @@ module RubynCode
|
|
|
54
56
|
return nil if rows.empty?
|
|
55
57
|
|
|
56
58
|
row = rows.first
|
|
59
|
+
blob_messages = parse_json_array(row['messages'])
|
|
60
|
+
blob_messages += journal_messages(session_id) if blob_messages.is_a?(Array)
|
|
57
61
|
{
|
|
58
|
-
messages:
|
|
62
|
+
messages: blob_messages,
|
|
59
63
|
metadata: parse_json_hash(row['metadata']),
|
|
60
64
|
title: row['title'],
|
|
61
65
|
model: row['model'],
|
|
@@ -103,6 +107,7 @@ module RubynCode
|
|
|
103
107
|
params << session_id
|
|
104
108
|
|
|
105
109
|
@db.execute("UPDATE sessions SET #{sets.join(', ')} WHERE id = ?", params)
|
|
110
|
+
clear_journal(session_id) if attrs.key?(:messages)
|
|
106
111
|
end
|
|
107
112
|
|
|
108
113
|
# Deletes a session permanently.
|
|
@@ -110,6 +115,7 @@ module RubynCode
|
|
|
110
115
|
# @param session_id [String]
|
|
111
116
|
# @return [void]
|
|
112
117
|
def delete_session(session_id)
|
|
118
|
+
clear_journal(session_id)
|
|
113
119
|
@db.execute('DELETE FROM sessions WHERE id = ?', [session_id])
|
|
114
120
|
end
|
|
115
121
|
|
|
@@ -118,6 +124,109 @@ module RubynCode
|
|
|
118
124
|
|
|
119
125
|
private
|
|
120
126
|
|
|
127
|
+
# ── Incremental persistence ───────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
# True when this process has already persisted a prefix of +messages+
|
|
130
|
+
# for the session and the prefix is unchanged, so only the tail needs
|
|
131
|
+
# to be written. Compares object identities — compaction/undo/resume
|
|
132
|
+
# replace message objects, which forces a snapshot.
|
|
133
|
+
def appendable?(session_id, messages)
|
|
134
|
+
state = @journal_state[session_id]
|
|
135
|
+
return false unless state
|
|
136
|
+
return false if messages.size < state[:count]
|
|
137
|
+
|
|
138
|
+
messages.first(state[:count]).map(&:object_id) == state[:ids]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def remember_journal_state(session_id, messages)
|
|
142
|
+
@journal_state[session_id] = {
|
|
143
|
+
count: messages.size,
|
|
144
|
+
ids: messages.map(&:object_id)
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Appends the not-yet-persisted tail of +messages+ to the journal
|
|
149
|
+
# table and touches the session row.
|
|
150
|
+
def append_to_journal(session_id, messages, opts)
|
|
151
|
+
new_messages = messages[@journal_state[session_id][:count]..] || []
|
|
152
|
+
now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
|
|
153
|
+
|
|
154
|
+
@db.transaction do
|
|
155
|
+
new_messages.each do |msg|
|
|
156
|
+
@db.execute(
|
|
157
|
+
'INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?)',
|
|
158
|
+
[session_id, msg[:role] || msg['role'], JSON.generate(msg), now]
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
touch_session(session_id, opts, now)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def touch_session(session_id, opts, now)
|
|
166
|
+
meta_json = opts.key?(:metadata) ? JSON.generate(opts[:metadata]) : nil
|
|
167
|
+
@db.execute(<<~SQL, [opts[:title], opts[:model], meta_json, now, session_id])
|
|
168
|
+
UPDATE sessions SET
|
|
169
|
+
title = COALESCE(?, title),
|
|
170
|
+
model = COALESCE(?, model),
|
|
171
|
+
metadata = COALESCE(?, metadata),
|
|
172
|
+
updated_at = ?
|
|
173
|
+
WHERE id = ?
|
|
174
|
+
SQL
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Writes the full messages blob and clears any journaled tail —
|
|
178
|
+
# the blob becomes the single source of truth again.
|
|
179
|
+
def snapshot_session(session_id, project_path, messages, opts)
|
|
180
|
+
now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
|
|
181
|
+
messages_json = JSON.generate(messages)
|
|
182
|
+
meta_json = JSON.generate(opts.fetch(:metadata, {}))
|
|
183
|
+
title = opts[:title]
|
|
184
|
+
model = opts[:model]
|
|
185
|
+
|
|
186
|
+
insert_params = [session_id, project_path, title, model, messages_json, 'active', meta_json, now, now]
|
|
187
|
+
update_params = [messages_json, title, model, meta_json, now]
|
|
188
|
+
|
|
189
|
+
@db.transaction do
|
|
190
|
+
@db.execute(<<~SQL, insert_params + update_params)
|
|
191
|
+
INSERT INTO sessions (id, project_path, title, model, messages, status, metadata, created_at, updated_at)
|
|
192
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
193
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
194
|
+
messages = ?,
|
|
195
|
+
title = COALESCE(?, title),
|
|
196
|
+
model = COALESCE(?, model),
|
|
197
|
+
metadata = ?,
|
|
198
|
+
updated_at = ?
|
|
199
|
+
SQL
|
|
200
|
+
@db.execute('DELETE FROM messages WHERE session_id = ?', [session_id])
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Journaled messages appended after the last blob snapshot, in insert order.
|
|
205
|
+
def journal_messages(session_id)
|
|
206
|
+
rows = @db.query(
|
|
207
|
+
'SELECT content FROM messages WHERE session_id = ? ORDER BY id',
|
|
208
|
+
[session_id]
|
|
209
|
+
).to_a
|
|
210
|
+
rows.filter_map { |row| parse_journal_row(row['content']) }
|
|
211
|
+
rescue StandardError
|
|
212
|
+
[]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_journal_row(raw)
|
|
216
|
+
return nil unless raw.is_a?(String)
|
|
217
|
+
|
|
218
|
+
JSON.parse(raw, symbolize_names: true)
|
|
219
|
+
rescue JSON::ParserError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def clear_journal(session_id)
|
|
224
|
+
@journal_state.delete(session_id)
|
|
225
|
+
@db.execute('DELETE FROM messages WHERE session_id = ?', [session_id])
|
|
226
|
+
rescue StandardError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
121
230
|
def build_list_filters(project_path, status)
|
|
122
231
|
conditions = []
|
|
123
232
|
params = []
|
|
@@ -161,6 +270,11 @@ module RubynCode
|
|
|
161
270
|
end
|
|
162
271
|
|
|
163
272
|
def ensure_table
|
|
273
|
+
ensure_sessions_table
|
|
274
|
+
ensure_messages_table
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def ensure_sessions_table
|
|
164
278
|
@db.execute(<<~SQL)
|
|
165
279
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
166
280
|
id TEXT PRIMARY KEY,
|
|
@@ -182,6 +296,30 @@ module RubynCode
|
|
|
182
296
|
# Column already exists — safe to continue
|
|
183
297
|
end
|
|
184
298
|
|
|
299
|
+
# Journal table for incrementally persisted messages. Mirrors
|
|
300
|
+
# db/migrations/002_create_messages.sql for databases that predate it.
|
|
301
|
+
def ensure_messages_table
|
|
302
|
+
@db.execute(<<~SQL)
|
|
303
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
304
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
305
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
306
|
+
role TEXT NOT NULL CHECK(role IN ('system','user','assistant')),
|
|
307
|
+
content TEXT,
|
|
308
|
+
tool_calls TEXT,
|
|
309
|
+
tool_use_id TEXT,
|
|
310
|
+
tool_name TEXT,
|
|
311
|
+
token_count INTEGER,
|
|
312
|
+
is_compacted INTEGER NOT NULL DEFAULT 0,
|
|
313
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
314
|
+
)
|
|
315
|
+
SQL
|
|
316
|
+
@db.execute('CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)')
|
|
317
|
+
rescue StandardError
|
|
318
|
+
# Table creation is best-effort — save_session falls back to
|
|
319
|
+
# full snapshots when the journal is unavailable.
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
185
323
|
# @param raw [String, Array, nil]
|
|
186
324
|
# @return [Array]
|
|
187
325
|
def parse_json_array(raw)
|
|
@@ -9,7 +9,9 @@ module RubynCode
|
|
|
9
9
|
module CostCalculator
|
|
10
10
|
# Per-million-token rates: { model_prefix => [input_rate, output_rate] }
|
|
11
11
|
PRICING = {
|
|
12
|
-
# Anthropic — Claude 4.6
|
|
12
|
+
# Anthropic — Claude 5 / 4.8 / 4.6
|
|
13
|
+
'claude-fable-5' => [10.00, 50.00],
|
|
14
|
+
'claude-opus-4-8' => [5.00, 25.00],
|
|
13
15
|
'claude-haiku-4-5' => [1.00, 5.00],
|
|
14
16
|
'claude-sonnet-4-6' => [3.00, 15.00],
|
|
15
17
|
'claude-opus-4-6' => [15.00, 75.00],
|
|
@@ -4,10 +4,15 @@ require 'pastel'
|
|
|
4
4
|
|
|
5
5
|
module RubynCode
|
|
6
6
|
module Output
|
|
7
|
-
class DiffRenderer
|
|
7
|
+
class DiffRenderer # rubocop:disable Metrics/ClassLength -- LCS diff computation + hunk grouping + rendering
|
|
8
8
|
# Immutable value object representing a single hunk in a unified diff.
|
|
9
9
|
Hunk = Data.define(:old_start, :old_count, :new_start, :new_count, :lines)
|
|
10
10
|
|
|
11
|
+
# The LCS table is O(N×M) in time and memory. Beyond this many cells
|
|
12
|
+
# (after prefix/suffix trimming) the middle section is rendered as a
|
|
13
|
+
# plain delete-all/add-all block instead of a minimal diff.
|
|
14
|
+
MAX_LCS_CELLS = 1_000_000
|
|
15
|
+
|
|
11
16
|
# Represents a single diff line with its type and content.
|
|
12
17
|
DiffLine = Data.define(:type, :content) do
|
|
13
18
|
def addition? = type == :add
|
|
@@ -79,11 +84,59 @@ module RubynCode
|
|
|
79
84
|
|
|
80
85
|
# Computes unified-diff hunks using the Myers diff algorithm (simple LCS approach).
|
|
81
86
|
def compute_hunks(old_lines, new_lines)
|
|
82
|
-
|
|
83
|
-
raw_diff = backtrack_diff(lcs_table, old_lines, new_lines)
|
|
87
|
+
raw_diff = build_diff_ops(old_lines, new_lines)
|
|
84
88
|
group_into_hunks(raw_diff, old_lines, new_lines)
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
# Strips common prefix/suffix lines before running the quadratic LCS —
|
|
92
|
+
# for the typical "small edit in a large file" case this shrinks the
|
|
93
|
+
# table from O(file²) to O(change²) — then re-offsets the middle ops
|
|
94
|
+
# back to absolute line indices.
|
|
95
|
+
def build_diff_ops(old_lines, new_lines)
|
|
96
|
+
prefix = common_prefix_length(old_lines, new_lines)
|
|
97
|
+
suffix = common_suffix_length(old_lines, new_lines, prefix)
|
|
98
|
+
|
|
99
|
+
old_mid = old_lines[prefix...(old_lines.size - suffix)]
|
|
100
|
+
new_mid = new_lines[prefix...(new_lines.size - suffix)]
|
|
101
|
+
|
|
102
|
+
ops = Array.new(prefix) { |i| [:equal, i, i] }
|
|
103
|
+
middle_diff_ops(old_mid, new_mid).each do |op, old_idx, new_idx|
|
|
104
|
+
ops << [op, old_idx && (old_idx + prefix), new_idx && (new_idx + prefix)]
|
|
105
|
+
end
|
|
106
|
+
suffix.times do |i|
|
|
107
|
+
ops << [:equal, old_lines.size - suffix + i, new_lines.size - suffix + i]
|
|
108
|
+
end
|
|
109
|
+
ops
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def middle_diff_ops(old_mid, new_mid)
|
|
113
|
+
return oversized_diff_ops(old_mid, new_mid) if old_mid.size * new_mid.size > MAX_LCS_CELLS
|
|
114
|
+
|
|
115
|
+
table = build_lcs_table(old_mid, new_mid)
|
|
116
|
+
backtrack_diff(table, old_mid, new_mid)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fallback for diffs too large for the LCS table: treat the whole
|
|
120
|
+
# middle section as replaced.
|
|
121
|
+
def oversized_diff_ops(old_mid, new_mid)
|
|
122
|
+
Array.new(old_mid.size) { |i| [:delete, i, nil] } +
|
|
123
|
+
Array.new(new_mid.size) { |i| [:add, nil, i] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def common_prefix_length(old_lines, new_lines)
|
|
127
|
+
max = [old_lines.size, new_lines.size].min
|
|
128
|
+
count = 0
|
|
129
|
+
count += 1 while count < max && old_lines[count] == new_lines[count]
|
|
130
|
+
count
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def common_suffix_length(old_lines, new_lines, prefix)
|
|
134
|
+
max = [old_lines.size, new_lines.size].min - prefix
|
|
135
|
+
count = 0
|
|
136
|
+
count += 1 while count < max && old_lines[-1 - count] == new_lines[-1 - count]
|
|
137
|
+
count
|
|
138
|
+
end
|
|
139
|
+
|
|
87
140
|
# Builds the LCS length table for two arrays of lines.
|
|
88
141
|
def build_lcs_table(old_lines, new_lines)
|
|
89
142
|
row_count = old_lines.size
|
|
@@ -110,6 +163,8 @@ module RubynCode
|
|
|
110
163
|
|
|
111
164
|
# Backtracks through the LCS table to produce a sequence of diff operations.
|
|
112
165
|
# Returns an array of [:equal, :delete, :add] paired with line indices.
|
|
166
|
+
# Ops are collected in reverse (backtracking walks end-to-start) and
|
|
167
|
+
# flipped once at the end — unshift per op would be O(n²).
|
|
113
168
|
def backtrack_diff(table, old_lines, new_lines)
|
|
114
169
|
result = []
|
|
115
170
|
old_idx = old_lines.size
|
|
@@ -119,18 +174,18 @@ module RubynCode
|
|
|
119
174
|
old_idx, new_idx = backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx)
|
|
120
175
|
end
|
|
121
176
|
|
|
122
|
-
result
|
|
177
|
+
result.reverse!
|
|
123
178
|
end
|
|
124
179
|
|
|
125
180
|
def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- LCS backtrack step requires all state
|
|
126
181
|
if lines_match?(old_lines, new_lines, old_idx, new_idx)
|
|
127
|
-
result
|
|
182
|
+
result << [:equal, old_idx - 1, new_idx - 1]
|
|
128
183
|
[old_idx - 1, new_idx - 1]
|
|
129
184
|
elsif new_idx.positive? && (old_idx.zero? || table[old_idx][new_idx - 1] >= table[old_idx - 1][new_idx])
|
|
130
|
-
result
|
|
185
|
+
result << [:add, nil, new_idx - 1]
|
|
131
186
|
[old_idx, new_idx - 1]
|
|
132
187
|
else
|
|
133
|
-
result
|
|
188
|
+
result << [:delete, old_idx - 1, nil]
|
|
134
189
|
[old_idx - 1, new_idx]
|
|
135
190
|
end
|
|
136
191
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'json'
|
|
4
5
|
require_relative 'gemfile_parser'
|
|
5
6
|
|
|
@@ -10,14 +11,43 @@ module RubynCode
|
|
|
10
11
|
# On session start, parses the Gemfile, queries the registry for matching
|
|
11
12
|
# packs, and shows a one-time suggestion. Tracks shown suggestions in
|
|
12
13
|
# `.rubyn-code/suggested.json` to avoid repeating.
|
|
14
|
+
#
|
|
15
|
+
# The registry lookup runs in a background thread (see #start) and
|
|
16
|
+
# responses are cached in `.rubyn-code/suggestions_cache.json` keyed by
|
|
17
|
+
# the Gemfile's gem list, so session start never blocks on the network.
|
|
13
18
|
class AutoSuggest
|
|
14
19
|
SUGGESTED_FILE = 'suggested.json'
|
|
20
|
+
CACHE_FILE = 'suggestions_cache.json'
|
|
21
|
+
CACHE_TTL = 86_400 # 24 hours
|
|
22
|
+
FETCH_TIMEOUT = 3
|
|
15
23
|
|
|
16
24
|
# @param project_root [String]
|
|
17
25
|
# @param registry_client [RegistryClient]
|
|
18
26
|
def initialize(project_root:, registry_client: nil)
|
|
19
27
|
@project_root = project_root
|
|
20
|
-
@client = registry_client || RegistryClient.new
|
|
28
|
+
@client = registry_client || RegistryClient.new(timeout: FETCH_TIMEOUT)
|
|
29
|
+
@thread = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Kicks off the suggestion check in a background thread.
|
|
33
|
+
# Call `pending_message` later to collect the result without blocking.
|
|
34
|
+
def start
|
|
35
|
+
@thread = Thread.new { @message = check }
|
|
36
|
+
@thread.abort_on_exception = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Non-blocking: returns the suggestion message once the background
|
|
40
|
+
# check has finished, nil while it's still running or after the
|
|
41
|
+
# message has already been consumed.
|
|
42
|
+
#
|
|
43
|
+
# @return [String, nil]
|
|
44
|
+
def pending_message
|
|
45
|
+
return nil unless @thread
|
|
46
|
+
return nil if @thread.alive?
|
|
47
|
+
|
|
48
|
+
message = @message
|
|
49
|
+
@message = nil
|
|
50
|
+
message
|
|
21
51
|
end
|
|
22
52
|
|
|
23
53
|
# Check for suggestable packs and return a display message if any.
|
|
@@ -75,11 +105,49 @@ module RubynCode
|
|
|
75
105
|
end
|
|
76
106
|
|
|
77
107
|
def fetch_suggestions(gems)
|
|
78
|
-
|
|
108
|
+
cached = read_suggestions_cache(gems)
|
|
109
|
+
return cached if cached
|
|
110
|
+
|
|
111
|
+
suggestions = @client.fetch_suggestions(gems)
|
|
112
|
+
write_suggestions_cache(gems, suggestions)
|
|
113
|
+
suggestions
|
|
79
114
|
rescue RegistryError
|
|
80
115
|
[]
|
|
81
116
|
end
|
|
82
117
|
|
|
118
|
+
def read_suggestions_cache(gems)
|
|
119
|
+
path = cache_path
|
|
120
|
+
return nil unless File.exist?(path)
|
|
121
|
+
|
|
122
|
+
data = JSON.parse(File.read(path))
|
|
123
|
+
return nil unless data['gemfile_hash'] == gemfile_hash(gems)
|
|
124
|
+
return nil if (Time.now.to_i - data['fetched_at'].to_i) > CACHE_TTL
|
|
125
|
+
|
|
126
|
+
suggestions = data['suggestions']
|
|
127
|
+
suggestions.is_a?(Array) ? suggestions : nil
|
|
128
|
+
rescue StandardError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_suggestions_cache(gems, suggestions)
|
|
133
|
+
FileUtils.mkdir_p(File.dirname(cache_path))
|
|
134
|
+
File.write(cache_path, JSON.pretty_generate(
|
|
135
|
+
'gemfile_hash' => gemfile_hash(gems),
|
|
136
|
+
'fetched_at' => Time.now.to_i,
|
|
137
|
+
'suggestions' => suggestions
|
|
138
|
+
))
|
|
139
|
+
rescue StandardError
|
|
140
|
+
# Best effort — caching failures must never break suggestions
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def gemfile_hash(gems)
|
|
144
|
+
Digest::SHA256.hexdigest(gems.sort.join(','))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def cache_path
|
|
148
|
+
File.join(@project_root, '.rubyn-code', CACHE_FILE)
|
|
149
|
+
end
|
|
150
|
+
|
|
83
151
|
def filter_shown(suggestions)
|
|
84
152
|
state = load_state
|
|
85
153
|
shown = Array(state['shown'])
|
|
@@ -17,8 +17,9 @@ module RubynCode
|
|
|
17
17
|
|
|
18
18
|
attr_reader :base_url
|
|
19
19
|
|
|
20
|
-
def initialize(base_url: nil)
|
|
20
|
+
def initialize(base_url: nil, timeout: TIMEOUT_SECONDS)
|
|
21
21
|
@base_url = base_url || ENV.fetch('RUBYN_REGISTRY_URL', DEFAULT_BASE_URL)
|
|
22
|
+
@timeout = timeout
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
# List all available packs (returns flat array for CLI commands).
|
|
@@ -138,8 +139,8 @@ module RubynCode
|
|
|
138
139
|
@connection ||= Faraday.new(url: base_url) do |f|
|
|
139
140
|
f.request :url_encoded
|
|
140
141
|
f.response :raise_error
|
|
141
|
-
f.options.timeout =
|
|
142
|
-
f.options.open_timeout =
|
|
142
|
+
f.options.timeout = @timeout
|
|
143
|
+
f.options.open_timeout = @timeout
|
|
143
144
|
f.headers['Accept'] = 'application/json'
|
|
144
145
|
f.headers['User-Accept'] = USER_ACCEPT_HEADER
|
|
145
146
|
f.headers['User-Agent'] = "rubyn-code/#{RubynCode::VERSION}"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module SubAgents
|
|
5
|
+
# A sub-agent type: the built-in `explore`/`worker`, or a user-defined
|
|
6
|
+
# agent loaded from .rubyn-code/agents/*.md. Captures everything
|
|
7
|
+
# spawn_agent needs to run it — display name, system prompt, the tool
|
|
8
|
+
# allowlist (nil = access-based default), access level, and turn budget.
|
|
9
|
+
AgentType = Data.define(:name, :description, :system_prompt, :tool_names, :access, :max_iterations) do
|
|
10
|
+
# @return [Boolean] read-only agents may only call read-risk tools
|
|
11
|
+
def read_only? = access == :read
|
|
12
|
+
|
|
13
|
+
# @return [Boolean]
|
|
14
|
+
def custom? = !%w[explore worker].include?(name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module SubAgents
|
|
7
|
+
# Resolves sub-agent types by name. Ships the built-in `explore` and
|
|
8
|
+
# `worker` agents and discovers user-defined ones from markdown files,
|
|
9
|
+
# mirroring Claude Code's `.claude/agents/*.md`:
|
|
10
|
+
#
|
|
11
|
+
# <project>/.rubyn-code/agents/*.md (project-local, takes priority)
|
|
12
|
+
# ~/.rubyn-code/agents/*.md (user-global)
|
|
13
|
+
#
|
|
14
|
+
# Frontmatter (all optional except a sensible default name):
|
|
15
|
+
# name: reviewer
|
|
16
|
+
# description: Reviews a diff for bugs
|
|
17
|
+
# tools: read_file, grep, glob, bash # omit → access-based default set
|
|
18
|
+
# access: read # read | write (default: write)
|
|
19
|
+
# The markdown body becomes the agent's system prompt.
|
|
20
|
+
class Catalog
|
|
21
|
+
FRONTMATTER = /\A---\s*\n(.+?\n)---\s*\n(.*)\z/m
|
|
22
|
+
NAME = /\A[a-z0-9][a-z0-9_-]*\z/i
|
|
23
|
+
|
|
24
|
+
BASE_PROMPT = 'You are a Rubyn sub-agent. Complete your task efficiently and ' \
|
|
25
|
+
'return a clear summary of what you found or did.'
|
|
26
|
+
EXPLORE_PROMPT = "#{BASE_PROMPT}\nYou have read-only access. Search, read files, and analyze. " \
|
|
27
|
+
'Do NOT attempt to write or modify anything.'.freeze
|
|
28
|
+
WORKER_PROMPT = "#{BASE_PROMPT}\nYou have full read/write access. Make the changes needed, " \
|
|
29
|
+
'run tests if appropriate, and report what you did.'.freeze
|
|
30
|
+
|
|
31
|
+
def initialize(project_root: nil, home_dir: Config::Defaults::HOME_DIR)
|
|
32
|
+
@project_root = project_root
|
|
33
|
+
@home_dir = home_dir
|
|
34
|
+
@custom = load_custom
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param name [String, Symbol]
|
|
38
|
+
# @return [AgentType, nil]
|
|
39
|
+
def get(name)
|
|
40
|
+
key = name.to_s
|
|
41
|
+
builtin[key] || @custom[key]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<AgentType>] all known agents (built-ins first)
|
|
45
|
+
def all
|
|
46
|
+
builtin.values + @custom.values
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<String>]
|
|
50
|
+
def custom_names = @custom.keys
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def builtin
|
|
55
|
+
{
|
|
56
|
+
'explore' => AgentType.new(
|
|
57
|
+
name: 'explore', description: 'Read-only research/reading agent',
|
|
58
|
+
system_prompt: EXPLORE_PROMPT, tool_names: nil, access: :read,
|
|
59
|
+
max_iterations: Config::Defaults::MAX_EXPLORE_AGENT_ITERATIONS
|
|
60
|
+
),
|
|
61
|
+
'worker' => AgentType.new(
|
|
62
|
+
name: 'worker', description: 'Full read/write coding agent',
|
|
63
|
+
system_prompt: WORKER_PROMPT, tool_names: nil, access: :write,
|
|
64
|
+
max_iterations: Config::Defaults::MAX_SUB_AGENT_ITERATIONS
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def load_custom
|
|
70
|
+
dirs = [
|
|
71
|
+
@project_root && File.join(@project_root, '.rubyn-code', 'agents'),
|
|
72
|
+
File.join(@home_dir, 'agents')
|
|
73
|
+
].compact
|
|
74
|
+
|
|
75
|
+
dirs.flat_map { |dir| load_dir(dir) }
|
|
76
|
+
.each_with_object({}) { |agent, acc| acc[agent.name] ||= agent }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_dir(dir)
|
|
80
|
+
return [] unless Dir.exist?(dir)
|
|
81
|
+
|
|
82
|
+
Dir.glob(File.join(dir, '*.md')).filter_map { |path| build(path) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build(path)
|
|
86
|
+
frontmatter, body = parse(File.read(path))
|
|
87
|
+
name = (frontmatter['name'] || File.basename(path, '.md')).to_s.strip
|
|
88
|
+
return nil unless name.match?(NAME) && !builtin.key?(name)
|
|
89
|
+
|
|
90
|
+
access = frontmatter['access'].to_s.downcase == 'read' ? :read : :write
|
|
91
|
+
desc = frontmatter['description'].to_s
|
|
92
|
+
AgentType.new(
|
|
93
|
+
name: name,
|
|
94
|
+
description: desc.empty? ? "Custom agent: #{name}" : desc,
|
|
95
|
+
system_prompt: body.empty? ? BASE_PROMPT : body,
|
|
96
|
+
tool_names: parse_tools(frontmatter['tools']),
|
|
97
|
+
access: access,
|
|
98
|
+
max_iterations: default_iterations(access)
|
|
99
|
+
)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
RubynCode::Debug.warn("Failed to load custom agent #{path}: #{e.message}")
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse(content)
|
|
106
|
+
match = FRONTMATTER.match(content)
|
|
107
|
+
return [{}, content.to_s.strip] unless match
|
|
108
|
+
|
|
109
|
+
[YAML.safe_load(match[1]) || {}, match[2].to_s.strip]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def parse_tools(value)
|
|
113
|
+
case value
|
|
114
|
+
when Array then value.map(&:to_s)
|
|
115
|
+
when String then value.split(/[,\s]+/).reject(&:empty?)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def default_iterations(access)
|
|
120
|
+
access == :read ? Config::Defaults::MAX_EXPLORE_AGENT_ITERATIONS : Config::Defaults::MAX_SUB_AGENT_ITERATIONS
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|