rubyn-code 0.5.1 → 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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module IDE
@@ -22,7 +22,7 @@ module RubynCode
22
22
  session = @server.lookup_interview_session(session_id)
23
23
  unless session
24
24
  raise Protocol::JsonRpcError.new(SESSION_NOT_FOUND_CODE,
25
- "Unknown interview session: #{session_id}")
25
+ "Unknown interview session: #{session_id}")
26
26
  end
27
27
 
28
28
  outcome = session.answer(question_id, answer)
@@ -34,9 +34,9 @@ module RubynCode
34
34
  Megaplan::PlanProposer::InvalidProposalError => e
35
35
  warn "[PlanInterviewAnswerHandler] interview failed: #{e.message}"
36
36
  @server.notify('plan/interview/error', {
37
- 'sessionId' => params['sessionId'],
38
- 'message' => e.message
39
- })
37
+ 'sessionId' => params['sessionId'],
38
+ 'message' => e.message
39
+ })
40
40
  @server.drop_interview_session(params['sessionId'].to_s)
41
41
  raise Protocol::JsonRpcError.new(INVALID_INTERVIEW_CODE, e.message)
42
42
  end
@@ -46,16 +46,16 @@ module RubynCode
46
46
  def emit_outcome(session, outcome)
47
47
  if outcome.is_a?(Megaplan::InterviewSession::Question)
48
48
  @server.notify('plan/interview/question', {
49
- 'sessionId' => session.session_id,
50
- 'questionId' => outcome.id,
51
- 'text' => outcome.text,
52
- 'options' => outcome.options
53
- })
49
+ 'sessionId' => session.session_id,
50
+ 'questionId' => outcome.id,
51
+ 'text' => outcome.text,
52
+ 'options' => outcome.options
53
+ })
54
54
  else
55
55
  @server.notify('plan/interview/done', {
56
- 'sessionId' => session.session_id,
57
- 'plan' => outcome
58
- })
56
+ 'sessionId' => session.session_id,
57
+ 'plan' => outcome
58
+ })
59
59
  @server.drop_interview_session(session.session_id)
60
60
  end
61
61
  end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module IDE
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
4
  module IDE
@@ -12,7 +12,7 @@ module RubynCode
12
12
 
13
13
  def initialize(server, factory: nil)
14
14
  @server = server
15
- @factory = factory || ->(workspace_path:) {
15
+ @factory = factory || lambda { |workspace_path:|
16
16
  Megaplan::InterviewSession.new(workspace_path: workspace_path)
17
17
  }
18
18
  end
@@ -34,16 +34,16 @@ module RubynCode
34
34
  def emit_outcome(session, outcome)
35
35
  if outcome.is_a?(Megaplan::InterviewSession::Question)
36
36
  @server.notify('plan/interview/question', {
37
- 'sessionId' => session.session_id,
38
- 'questionId' => outcome.id,
39
- 'text' => outcome.text,
40
- 'options' => outcome.options
41
- })
37
+ 'sessionId' => session.session_id,
38
+ 'questionId' => outcome.id,
39
+ 'text' => outcome.text,
40
+ 'options' => outcome.options
41
+ })
42
42
  else
43
43
  @server.notify('plan/interview/done', {
44
- 'sessionId' => session.session_id,
45
- 'plan' => outcome
46
- })
44
+ 'sessionId' => session.session_id,
45
+ 'plan' => outcome
46
+ })
47
47
  @server.drop_interview_session(session.session_id)
48
48
  end
49
49
  end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
4
 
@@ -57,7 +57,15 @@ module RubynCode
57
57
  return unless thread&.alive?
58
58
 
59
59
  thread.raise(Interrupt)
60
- thread.join(2) # give it a moment to clean up
60
+ begin
61
+ thread.join(2) # give it a moment to clean up
62
+ rescue Interrupt
63
+ # We asked the thread to stop, so its Interrupt is expected. If it
64
+ # lands before the agent installs its own rescue, Thread#join would
65
+ # otherwise re-raise it into the caller (the cancel path) and, in
66
+ # tests, escape the example and abort the whole run.
67
+ nil
68
+ end
61
69
  end
62
70
 
63
71
  private
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
4
 
@@ -36,18 +36,34 @@ module RubynCode
36
36
  private
37
37
 
38
38
  def run_recovery(session_id, context)
39
+ notify_recovering(session_id, context)
40
+
41
+ recovery = @recovery || Megaplan::CiRecovery.new(
42
+ agent_invoker: build_invoker(session_id)
43
+ )
44
+ outcome = recovery.recover(context)
45
+
46
+ notify_outcome(session_id, context, outcome)
47
+ @server.notify('agent/status', {
48
+ 'sessionId' => session_id,
49
+ 'status' => 'done',
50
+ 'summary' => outcome['summary']
51
+ })
52
+ rescue StandardError => e
53
+ warn "[RecoverCiHandler] error: #{e.message}"
54
+ notify_error(session_id, context, e)
55
+ end
56
+
57
+ def notify_recovering(session_id, context)
39
58
  @server.notify('agent/status', {
40
59
  'sessionId' => session_id,
41
60
  'status' => 'recovering',
42
61
  'phaseNumber' => context['phase_number'],
43
62
  'attemptNumber' => context['attempt_number']
44
63
  })
64
+ end
45
65
 
46
- recovery = @recovery || Megaplan::CiRecovery.new(
47
- agent_invoker: build_invoker(session_id)
48
- )
49
- outcome = recovery.recover(context)
50
-
66
+ def notify_outcome(session_id, context, outcome)
51
67
  @server.notify('recovery/outcome', {
52
68
  'sessionId' => session_id,
53
69
  'planId' => context['plan_id'],
@@ -56,25 +72,20 @@ module RubynCode
56
72
  'commitSha' => outcome['commit_sha'],
57
73
  'summary' => outcome['summary']
58
74
  })
75
+ end
59
76
 
60
- @server.notify('agent/status', {
61
- 'sessionId' => session_id,
62
- 'status' => 'done',
63
- 'summary' => outcome['summary']
64
- })
65
- rescue StandardError => e
66
- warn "[RecoverCiHandler] error: #{e.message}"
77
+ def notify_error(session_id, context, error)
67
78
  @server.notify('recovery/outcome', {
68
79
  'sessionId' => session_id,
69
80
  'planId' => context['plan_id'],
70
81
  'phaseNumber' => context['phase_number'],
71
82
  'kind' => 'errored',
72
- 'summary' => e.message
83
+ 'summary' => error.message
73
84
  })
74
85
  @server.notify('agent/status', {
75
86
  'sessionId' => session_id,
76
87
  'status' => 'error',
77
- 'error' => e.message
88
+ 'error' => error.message
78
89
  })
79
90
  end
80
91
 
@@ -87,7 +98,7 @@ module RubynCode
87
98
  llm_client = LLM::Client.new
88
99
  response = llm_client.chat(
89
100
  messages: [{ role: 'user', content: prompt }],
90
- on_text: ->(text) {
101
+ on_text: lambda { |text|
91
102
  @server.notify('stream/text', {
92
103
  'sessionId' => session_id,
93
104
  'text' => text,
@@ -30,7 +30,7 @@ module RubynCode
30
30
  prompt = @server.handler_instance(:prompt)
31
31
  if prompt
32
32
  conversation = Agent::Conversation.new
33
- messages.each { |msg| conversation.messages << msg }
33
+ conversation.replace!(messages.dup)
34
34
  prompt.inject_conversation(session_id, conversation)
35
35
  end
36
36
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'fileutils'
5
+ require 'open3'
5
6
 
6
7
  module RubynCode
7
8
  module Index
@@ -13,6 +14,9 @@ module RubynCode
13
14
  INDEX_DIR = '.rubyn-code'
14
15
  INDEX_FILE = 'codebase_index.json'
15
16
  CHARS_PER_TOKEN = 4
17
+ # Bump when the stored shape changes so stale indexes rebuild instead
18
+ # of silently serving degraded data. 2 = Prism spans + call edges.
19
+ FORMAT_VERSION = 2
16
20
 
17
21
  attr_reader :nodes, :edges, :index_path
18
22
 
@@ -32,6 +36,7 @@ module RubynCode
32
36
 
33
37
  ruby_files.each { |file| index_file(file) }
34
38
  extract_rails_edges
39
+ prune_call_edges!
35
40
  save!
36
41
  self
37
42
  end
@@ -41,8 +46,12 @@ module RubynCode
41
46
  return nil unless File.exist?(@index_path)
42
47
 
43
48
  data = JSON.parse(File.read(@index_path))
49
+ return nil unless data['format_version'] == FORMAT_VERSION
50
+
44
51
  @nodes = data['nodes'] || []
45
- @edges = data['edges'] || []
52
+ # uniq drops duplicate edges accumulated by older versions, which
53
+ # appended tests edges on every update! without dedup.
54
+ @edges = (data['edges'] || []).uniq
46
55
  @file_mtimes = data['file_mtimes'] || {}
47
56
  self
48
57
  rescue StandardError
@@ -65,6 +74,21 @@ module RubynCode
65
74
  end
66
75
 
67
76
  extract_rails_edges
77
+ prune_call_edges!
78
+ save!
79
+ self
80
+ end
81
+
82
+ # Incremental update for a single known-changed file (e.g. after a
83
+ # write_file/edit_file tool call). Avoids the full-tree scan in update!.
84
+ def update_file!(path)
85
+ absolute = File.expand_path(path, @project_root)
86
+ return self unless absolute.start_with?("#{@project_root}/")
87
+
88
+ remove_nodes_for(absolute)
89
+ index_file(absolute) if File.exist?(absolute)
90
+ extract_rails_edges
91
+ prune_call_edges!
68
92
  save!
69
93
  self
70
94
  end
@@ -192,6 +216,26 @@ module RubynCode
192
216
  end
193
217
 
194
218
  def ruby_files
219
+ git_ruby_files || glob_ruby_files
220
+ end
221
+
222
+ # Prefer git's file list when available: it skips ignored dirs
223
+ # (tmp/, log/, coverage/, db/) the glob would index. --others picks up
224
+ # untracked-but-not-ignored files so freshly created ones still appear.
225
+ def git_ruby_files
226
+ return nil unless File.exist?(File.join(@project_root, '.git'))
227
+
228
+ stdout, status = Open3.capture2(
229
+ 'git', '-C', @project_root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.rb'
230
+ )
231
+ return nil unless status.success?
232
+
233
+ stdout.split("\0").map { |f| File.join(@project_root, f) }.select { |f| File.file?(f) }
234
+ rescue StandardError
235
+ nil
236
+ end
237
+
238
+ def glob_ruby_files
195
239
  Dir.glob(File.join(@project_root, '**', '*.rb'))
196
240
  .reject { |f| f.include?('/vendor/') || f.include?('/node_modules/') }
197
241
  end
@@ -201,14 +245,66 @@ module RubynCode
201
245
  content = File.read(file)
202
246
  @file_mtimes[relative] = File.mtime(file).to_i
203
247
 
204
- extract_classes(content, relative)
205
- extract_methods(content, relative)
248
+ extract_symbols(content, relative)
206
249
  extract_associations(content, relative)
207
250
  extract_rails_patterns(content, relative)
208
251
  rescue StandardError => e
209
252
  RubynCode::Debug.warn("Index: failed to parse #{file}: #{e.message}")
210
253
  end
211
254
 
255
+ # Prism gives real line spans (needed for verbatim source in code_graph)
256
+ # and call edges. Files that don't parse fall back to the regex pass,
257
+ # which produces the same node shapes minus end_line/owner/calls.
258
+ def extract_symbols(content, file)
259
+ extracted = PrismExtractor.extract(content)
260
+ unless extracted
261
+ extract_classes(content, file)
262
+ extract_methods(content, file)
263
+ return
264
+ end
265
+
266
+ add_class_nodes(extracted.classes, file)
267
+ add_method_nodes(extracted.defs, file)
268
+ add_call_edges(extracted.calls, file)
269
+ end
270
+
271
+ def add_class_nodes(classes, file)
272
+ classes.each do |c|
273
+ @nodes << {
274
+ 'type' => classify_node(file, c[:kind]), 'name' => c[:name],
275
+ 'file' => file, 'line' => c[:line], 'end_line' => c[:end_line]
276
+ }
277
+ end
278
+ end
279
+
280
+ def add_method_nodes(methods, file)
281
+ methods.each do |m|
282
+ @nodes << {
283
+ 'type' => 'method', 'name' => m[:name], 'file' => file,
284
+ 'line' => m[:line], 'end_line' => m[:end_line],
285
+ 'owner' => m[:owner], 'params' => m[:params], 'visibility' => 'public'
286
+ }
287
+ end
288
+ end
289
+
290
+ # `from` stays the file path so remove_nodes_for's from-based cleanup
291
+ # applies to call edges too; the calling method rides in from_method.
292
+ def add_call_edges(calls, file)
293
+ calls.each do |c|
294
+ @edges << {
295
+ 'from' => file, 'from_method' => c[:from], 'to' => c[:to],
296
+ 'relationship' => 'calls', 'line' => c[:line]
297
+ }
298
+ end
299
+ end
300
+
301
+ # Drop call edges whose target isn't defined in the project — filters
302
+ # out stdlib/gem calls (puts, map, ...) that would swamp the graph.
303
+ def prune_call_edges!
304
+ defined_methods = @nodes.filter_map { |n| n['name'] if n['type'] == 'method' }.to_set
305
+ @edges.reject! { |e| e['relationship'] == 'calls' && !defined_methods.include?(e['to']) }
306
+ end
307
+
212
308
  def extract_classes(content, file)
213
309
  content.scan(/^\s*(class|module)\s+(\S+)/).each do |type, name|
214
310
  node_type = classify_node(file, type)
@@ -253,6 +349,8 @@ module RubynCode
253
349
  end
254
350
 
255
351
  def extract_rails_edges
352
+ # Rebuild tests edges from scratch so repeated updates stay idempotent.
353
+ @edges.reject! { |e| e['relationship'] == 'tests' }
256
354
  spec_files = @file_mtimes.keys.select { |f| f.include?('spec/') || f.include?('test/') }
257
355
  spec_files.each do |spec_file|
258
356
  source = spec_file.sub(%r{spec/}, 'app/').sub(/_spec\.rb$/, '.rb')
@@ -304,7 +402,8 @@ module RubynCode
304
402
 
305
403
  def save!
306
404
  FileUtils.mkdir_p(File.dirname(@index_path))
307
- data = { 'nodes' => @nodes, 'edges' => @edges, 'file_mtimes' => @file_mtimes }
405
+ data = { 'format_version' => FORMAT_VERSION, 'nodes' => @nodes, 'edges' => @edges,
406
+ 'file_mtimes' => @file_mtimes }
308
407
  File.write(@index_path, JSON.generate(data))
309
408
  end
310
409
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module RubynCode
6
+ module Index
7
+ # Prism-based symbol and call extractor for a single Ruby file.
8
+ # Returns classes/modules and methods with real line spans, plus
9
+ # method-to-method call sites the regex pass can't see. Used by
10
+ # CodebaseIndex, with the regex extractors as a parse-error fallback.
11
+ class PrismExtractor < Prism::Visitor
12
+ Result = Struct.new(:classes, :defs, :calls)
13
+
14
+ # @return [Result, nil] nil when the file doesn't parse
15
+ def self.extract(content)
16
+ parsed = Prism.parse(content)
17
+ return nil unless parsed.success?
18
+
19
+ visitor = new
20
+ visitor.visit(parsed.value)
21
+ Result.new(visitor.classes, visitor.defs, visitor.calls)
22
+ rescue StandardError
23
+ nil
24
+ end
25
+
26
+ attr_reader :classes, :defs, :calls
27
+
28
+ def initialize
29
+ super
30
+ @namespace = []
31
+ @current_def = nil
32
+ @classes = [] # { name:, kind:, line:, end_line: }
33
+ @defs = [] # { name:, owner:, line:, end_line:, params: }
34
+ @calls = [] # { from:, to:, line: }
35
+ end
36
+
37
+ def visit_class_node(node)
38
+ record_namespace(node, 'class') { super }
39
+ end
40
+
41
+ def visit_module_node(node)
42
+ record_namespace(node, 'module') { super }
43
+ end
44
+
45
+ def visit_def_node(node)
46
+ @defs << {
47
+ name: node.name.to_s,
48
+ owner: @namespace.join('::'),
49
+ line: node.location.start_line,
50
+ end_line: node.location.end_line,
51
+ params: node.parameters ? "(#{node.parameters.slice})" : nil
52
+ }
53
+ previous = @current_def
54
+ @current_def = node.name.to_s
55
+ super
56
+ @current_def = previous
57
+ end
58
+
59
+ # ponytail: resolution is by method name, not receiver type — a call to
60
+ # `user.save` links to any `save` defined in the project. Real receiver
61
+ # inference needs type analysis; name matching covers the common case.
62
+ def visit_call_node(node)
63
+ @calls << { from: @current_def, to: node.name.to_s, line: node.location.start_line } if @current_def
64
+ super
65
+ end
66
+
67
+ private
68
+
69
+ # The block continues the walk into child nodes (the caller's `super`).
70
+ def record_namespace(node, kind)
71
+ name = node.constant_path.slice
72
+ @classes << {
73
+ name: name, kind: kind,
74
+ line: node.location.start_line, end_line: node.location.end_line
75
+ }
76
+ @namespace.push(name)
77
+ yield
78
+ @namespace.pop
79
+ end
80
+ end
81
+ end
82
+ end
@@ -112,8 +112,7 @@ module RubynCode
112
112
  tag_set = tags.to_set(&:downcase)
113
113
 
114
114
  instincts.select do |inst|
115
- inst_tags = inst.context_tags.map(&:downcase)
116
- inst_tags.any? { |t| tag_set.include?(t) }
115
+ inst.context_tags.any? { |t| tag_set.include?(t.downcase) }
117
116
  end
118
117
  end
119
118
 
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module RubynCode
7
+ module Learning
8
+ # Exports and imports learned instincts so a user can carry their
9
+ # accumulated learnings to another machine. Instincts live in SQLite under
10
+ # ~/.rubyn-code; this serializes them to a portable JSON file and loads
11
+ # them back, regenerating ids and de-duplicating by (project_path, pattern).
12
+ module Porter
13
+ FORMAT_VERSION = 1
14
+ # Columns carried across machines (id is regenerated on import).
15
+ COLUMNS = %w[
16
+ project_path pattern context_tags confidence decay_rate
17
+ times_applied times_helpful created_at updated_at
18
+ ].freeze
19
+
20
+ class Error < RubynCode::Error; end
21
+
22
+ class << self
23
+ # Export instincts to a JSON file.
24
+ #
25
+ # @param db [DB::Connection]
26
+ # @param path [String] destination file
27
+ # @param project_path [String, nil] limit to one project, or nil for all
28
+ # @return [Integer] number of instincts exported
29
+ def export(db:, path:, project_path: nil)
30
+ rows = fetch(db, project_path)
31
+ payload = { 'version' => FORMAT_VERSION, 'instincts' => rows }
32
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
33
+ rows.size
34
+ end
35
+
36
+ # Import instincts from a JSON file.
37
+ #
38
+ # @param db [DB::Connection]
39
+ # @param path [String] source file
40
+ # @param remap_project [String, nil] override every row's project_path
41
+ # (use the current project so imported learnings apply here)
42
+ # @return [Hash] { imported:, skipped:, total: }
43
+ def import(db:, path:, remap_project: nil)
44
+ raise Error, "File not found: #{path}" unless File.file?(path)
45
+
46
+ payload = parse(path)
47
+ instincts = Array(payload['instincts'])
48
+ imported = instincts.count { |row| import_row(db, row, remap_project) }
49
+
50
+ { imported: imported, skipped: instincts.size - imported, total: instincts.size }
51
+ end
52
+
53
+ # @return [Hash] { count:, projects: } summary for display
54
+ def stats(db, project_path: nil)
55
+ rows = fetch(db, project_path)
56
+ { count: rows.size, projects: rows.map { |r| r['project_path'] }.uniq.size }
57
+ end
58
+
59
+ private
60
+
61
+ def fetch(db, project_path)
62
+ select = "SELECT #{COLUMNS.join(', ')} FROM instincts"
63
+ if project_path
64
+ db.query("#{select} WHERE project_path = ?", [project_path]).to_a
65
+ else
66
+ db.query(select).to_a
67
+ end
68
+ end
69
+
70
+ def parse(path)
71
+ payload = JSON.parse(File.read(path))
72
+ raise Error, 'Not a Rubyn learnings file' unless payload.is_a?(Hash) && payload.key?('instincts')
73
+
74
+ version = payload['version'].to_i
75
+ raise Error, "Unsupported export version: #{version}" if version > FORMAT_VERSION
76
+
77
+ payload
78
+ rescue JSON::ParserError => e
79
+ raise Error, "Invalid JSON: #{e.message}"
80
+ end
81
+
82
+ # @return [Boolean] true if inserted, false if skipped (duplicate)
83
+ def import_row(db, row, remap_project)
84
+ project = remap_project || row['project_path']
85
+ return false if project.to_s.empty? || row['pattern'].to_s.empty?
86
+ return false if exists?(db, project, row['pattern'])
87
+
88
+ insert(db, row, project)
89
+ true
90
+ rescue StandardError => e
91
+ RubynCode::Debug.warn("Skipping instinct import: #{e.message}")
92
+ false
93
+ end
94
+
95
+ def exists?(db, project, pattern)
96
+ db.query(
97
+ 'SELECT 1 FROM instincts WHERE project_path = ? AND pattern = ? LIMIT 1',
98
+ [project, pattern]
99
+ ).to_a.any?
100
+ end
101
+
102
+ def insert(db, row, project)
103
+ now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
104
+ db.execute(
105
+ <<~SQL.tr("\n", ' ').strip,
106
+ INSERT INTO instincts (id, project_path, pattern, context_tags,
107
+ confidence, decay_rate, times_applied, times_helpful, created_at, updated_at)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ SQL
110
+ [
111
+ SecureRandom.uuid, project, row['pattern'], normalize_tags(row['context_tags']),
112
+ (row['confidence'] || 0.5).to_f, (row['decay_rate'] || 0.05).to_f,
113
+ (row['times_applied'] || 0).to_i, (row['times_helpful'] || 0).to_i,
114
+ row['created_at'] || now, row['updated_at'] || now
115
+ ]
116
+ )
117
+ end
118
+
119
+ # context_tags is stored as a JSON string column; accept either a JSON
120
+ # string or an array from the export file.
121
+ def normalize_tags(tags)
122
+ return tags if tags.is_a?(String)
123
+
124
+ JSON.generate(Array(tags))
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end