rubyn-code 0.5.0 → 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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. metadata +49 -4
@@ -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
- now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
25
- messages_json = JSON.generate(messages)
26
- meta_json = JSON.generate(opts.fetch(:metadata, {}))
27
- title = opts[:title]
28
- model = opts[:model]
29
-
30
- insert_params = [session_id, project_path, title, model, messages_json, 'active', meta_json, now, now]
31
- update_params = [messages_json, title, model, meta_json, now]
32
-
33
- @db.execute(<<~SQL, insert_params + update_params)
34
- INSERT INTO sessions (id, project_path, title, model, messages, status, metadata, created_at, updated_at)
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: parse_json_array(row['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
- lcs_table = build_lcs_table(old_lines, new_lines)
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.unshift([:equal, old_idx - 1, new_idx - 1])
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.unshift([:add, nil, new_idx - 1])
185
+ result << [:add, nil, new_idx - 1]
131
186
  [old_idx, new_idx - 1]
132
187
  else
133
- result.unshift([:delete, old_idx - 1, nil])
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
- @client.fetch_suggestions(gems)
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 = TIMEOUT_SECONDS
142
- f.options.open_timeout = TIMEOUT_SECONDS
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