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,48 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module RubynCode
4
6
  module CLI
5
7
  module Commands
8
+ # List saved sessions or restore one into the current conversation.
9
+ #
10
+ # /resume # list sessions, recent first
11
+ # /resume <id> # load session <id> (prompts before clobber)
12
+ # /resume <id> --force # skip the confirm prompt
6
13
  class Resume < Base
7
14
  def self.command_name = '/resume'
8
- def self.description = 'Resume a session or list recent sessions'
15
+ def self.description = 'List saved sessions or restore one into the current conversation'
16
+
17
+ MAX_LIST_ENTRIES = 10
9
18
 
10
19
  def execute(args, ctx)
11
- session_id = args.first
20
+ persistence = ctx.session_persistence
21
+ if persistence.nil?
22
+ ctx.renderer.error('No session persistence wired in. Run a prompt first.')
23
+ return
24
+ end
12
25
 
13
- if session_id
14
- resume_session(session_id, ctx)
15
- else
16
- list_sessions(ctx)
26
+ opts = parse_args(args)
27
+
28
+ if opts[:id].nil?
29
+ list_sessions(persistence, ctx)
30
+ return
17
31
  end
32
+
33
+ restore_session(persistence, opts, ctx)
18
34
  end
19
35
 
20
36
  private
21
37
 
22
- def resume_session(session_id, ctx)
23
- data = ctx.session_persistence.load_session(session_id)
38
+ def parse_args(args)
39
+ args.delete('--force')
40
+ force = args.delete('-y')
41
+ { id: args.first, force: force }
42
+ end
43
+
44
+ def list_sessions(persistence, ctx)
45
+ rows = persistence.list_sessions
46
+ if rows.empty?
47
+ ctx.renderer.info('No saved sessions yet.')
48
+ return
49
+ end
50
+
51
+ ctx.renderer.info("Saved sessions (most recent first, up to #{MAX_LIST_ENTRIES}):")
52
+ rows.first(MAX_LIST_ENTRIES).each do |row|
53
+ count = row[:message_count].to_i
54
+ time = row[:last_activity]
55
+ title = row[:title].to_s.empty? ? '(untitled)' : row[:title]
56
+ sid = row[:session_id]
57
+ ctx.renderer.info(" #{sid} #{count} msg · #{format_time(time)} · #{title}")
58
+ end
59
+ end
60
+
61
+ def restore_session(persistence, opts, ctx)
62
+ loaded = persistence.load_session(opts[:id])
63
+ if loaded.nil?
64
+ ctx.renderer.error("Session not found: #{opts[:id]}")
65
+ return
66
+ end
67
+
68
+ messages = loaded[:messages] || []
69
+ if messages.empty?
70
+ ctx.renderer.warning("Session #{opts[:id]} has no messages — nothing to restore.")
71
+ return
72
+ end
24
73
 
25
- if data
26
- ctx.conversation.replace!(data[:messages])
27
- ctx.renderer.info("Resumed session #{session_id[0..7]}")
28
- { action: :set_session_id, session_id: session_id }
29
- else
30
- ctx.renderer.error("Session not found: #{session_id}")
74
+ unless confirm_replace(ctx, opts, loaded, messages)
75
+ ctx.renderer.info('Resume cancelled.')
76
+ return
31
77
  end
78
+
79
+ ctx.conversation.replace!(messages)
80
+ title = loaded[:title].to_s.empty? ? '(untitled)' : loaded[:title]
81
+ ctx.renderer.info("✓ Resumed session #{opts[:id]} — #{messages.size} messages from '#{title}'")
82
+ end
83
+
84
+ def confirm_replace(ctx, opts, loaded, messages)
85
+ return true if opts[:force]
86
+ return true if ctx.conversation.nil?
87
+ return true if ctx.conversation.messages.empty?
88
+
89
+ current = ctx.conversation.messages.size
90
+ target = messages.size
91
+ title = loaded[:title].to_s.empty? ? '(untitled)' : loaded[:title]
92
+ prompt = "Replace current conversation (#{current} messages) " \
93
+ "with session #{opts[:id]} '#{title}' (#{target} messages)? [y/N]"
94
+
95
+ return false unless ctx.renderer.respond_to?(:ask)
96
+
97
+ ctx.renderer.ask(prompt, default: false)
32
98
  end
33
99
 
34
- def list_sessions(ctx)
35
- sessions = ctx.session_persistence.list_sessions(
36
- project_path: ctx.project_root,
37
- limit: 10
38
- )
39
-
40
- if sessions.empty?
41
- ctx.renderer.info('No previous sessions.')
42
- else
43
- sessions.each do |s|
44
- puts " #{s[:id][0..7]} | #{s[:title] || 'untitled'} | #{s[:created_at]}"
45
- end
100
+ def format_time(time)
101
+ return 'never' if time.nil?
102
+
103
+ t = begin
104
+ time.is_a?(Time) ? time : Time.parse(time.to_s)
105
+ rescue StandardError
106
+ nil
107
+ end
108
+ return 'never' if t.nil?
109
+
110
+ delta = Time.now - t
111
+ case delta
112
+ when 0...60 then 'just now'
113
+ when 60...3600 then "#{delta.to_i / 60} min ago"
114
+ when 3600...86_400 then "#{delta.to_i / 3600} hr ago"
115
+ when 86_400...604_800 then "#{delta.to_i / 86_400} d ago"
116
+ else t.strftime('%Y-%m-%d')
46
117
  end
47
118
  end
48
119
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/rewind` — restore the session to an earlier checkpoint, mirroring
7
+ # Claude Code's /rewind. A checkpoint is taken at the start of each user
8
+ # turn, capturing the conversation and the original contents of any files
9
+ # changed that turn.
10
+ #
11
+ # /rewind list checkpoints
12
+ # /rewind <id> restore code + conversation to checkpoint <id>
13
+ # /rewind <id> code restore only the files
14
+ # /rewind <id> chat restore only the conversation
15
+ class Rewind < Base
16
+ def self.command_name = '/rewind'
17
+ def self.description = 'Rewind to an earlier checkpoint (/rewind to list)'
18
+
19
+ SCOPES = { 'code' => :code, 'chat' => :chat, 'both' => :both }.freeze
20
+
21
+ def execute(args, ctx)
22
+ manager = ctx.checkpoint_manager
23
+ return ctx.renderer.info('Checkpoints are not available in this session.') unless manager
24
+
25
+ id = args.first
26
+ return list(manager, ctx) if id.nil?
27
+
28
+ restore(manager, ctx, id, args[1])
29
+ end
30
+
31
+ private
32
+
33
+ def list(manager, ctx)
34
+ checkpoints = manager.list
35
+ return ctx.renderer.info('No checkpoints yet — they are created as you work.') if checkpoints.empty?
36
+
37
+ ctx.renderer.info('Checkpoints (newest last):')
38
+ checkpoints.each do |cp|
39
+ puts " #{cp[:id].to_s.rjust(3)} #{cp[:files]} file(s) — #{cp[:label]}"
40
+ end
41
+ puts
42
+ ctx.renderer.info('Restore with: /rewind <id> [code|chat]')
43
+ nil
44
+ end
45
+
46
+ def restore(manager, ctx, id_arg, scope_arg)
47
+ scope = SCOPES.fetch(scope_arg.to_s, :both)
48
+ result = manager.restore(id_arg.to_i, ctx.conversation, scope: scope)
49
+ return ctx.renderer.warning("No checkpoint ##{id_arg}.") unless result
50
+
51
+ ctx.renderer.info(restored_message(result, scope))
52
+ { action: :rewound }
53
+ end
54
+
55
+ def restored_message(result, scope)
56
+ case scope
57
+ when :code then "⏪ Restored #{result[:files_restored]} file(s) to checkpoint ##{result[:id]}."
58
+ when :chat then "⏪ Restored conversation to checkpoint ##{result[:id]}."
59
+ else "⏪ Rewound to checkpoint ##{result[:id]} (#{result[:files_restored]} file(s) + conversation)."
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Toggle extended thinking on/off and optionally set the budget.
7
+ #
8
+ # /think # show current budget
9
+ # /think off # disable
10
+ # /think <budget> # enable with <budget> tokens (e.g. /think 8192)
11
+ class Think < Base
12
+ DEFAULT_BUDGET = 8_192
13
+
14
+ def self.command_name = '/think'
15
+ def self.description = 'Toggle or set extended thinking budget (tokens)'
16
+
17
+ def execute(args, ctx)
18
+ arg = args.first
19
+
20
+ current = ctx.llm_client.respond_to?(:thinking_budget_tokens) ? ctx.llm_client.thinking_budget_tokens : 0
21
+
22
+ case arg
23
+ when nil
24
+ show_status(current, ctx)
25
+ when 'off', '0'
26
+ ctx.llm_client.thinking_budget_tokens = 0
27
+ ctx.renderer.info('Extended thinking OFF 🧠✋')
28
+ when /\A\d+\z/
29
+ budget = arg.to_i
30
+ ctx.llm_client.thinking_budget_tokens = budget
31
+ ctx.renderer.info("Extended thinking ON — budget: #{budget} tokens 🧠")
32
+ else
33
+ ctx.renderer.warning("Usage: /think [off|<budget>]. Got: #{arg}")
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def show_status(current, ctx)
40
+ state = current.to_i.positive? ? "ON (#{current} tokens)" : 'OFF'
41
+ ctx.renderer.info("Extended thinking: #{state}")
42
+ ctx.renderer.info('Usage: /think <budget> e.g. /think 8192 (or /think off)')
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -136,7 +136,7 @@ module RubynCode
136
136
  def default_model(provider)
137
137
  return 'gpt-5.4' if provider == 'openai'
138
138
 
139
- 'claude-opus-4-6'
139
+ 'claude-opus-5'
140
140
  end
141
141
 
142
142
  def display_summary
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ # Drives a repeating execution of a payload (a prompt or slash command).
6
+ #
7
+ # Pure orchestration with injected dependencies so it can be unit-tested
8
+ # without a REPL: the caller supplies a `runner` callable that performs one
9
+ # iteration and a `sleeper` for the wait between iterations.
10
+ #
11
+ # Two modes:
12
+ # - interval mode (interval is a positive number of seconds): run, wait,
13
+ # run, ... until max_iterations or stopped.
14
+ # - self-paced mode (interval is nil): run back-to-back until the runner
15
+ # returns :stop, emits the DONE_SENTINEL, or max_iterations is hit.
16
+ class LoopRunner
17
+ DEFAULT_MAX_ITERATIONS = 50
18
+ DONE_SENTINEL = 'LOOP_DONE'
19
+ UNITS = { 's' => 1, 'm' => 60, 'h' => 3600 }.freeze
20
+
21
+ # Parse an interval token like "30s", "5m", "2h", or a bare "45"
22
+ # (seconds). Returns nil when the token is not a valid interval.
23
+ #
24
+ # @param token [String, nil]
25
+ # @return [Integer, nil] seconds, or nil
26
+ def self.parse_interval(token)
27
+ return nil if token.nil?
28
+
29
+ match = token.strip.match(/\A(\d+)\s*([smh]?)\z/i)
30
+ return nil unless match
31
+
32
+ amount = match[1].to_i
33
+ return nil if amount <= 0
34
+
35
+ amount * UNITS.fetch(match[2].downcase, 1)
36
+ end
37
+
38
+ # @param interval [Integer, nil] seconds between runs; nil => self-paced
39
+ # @param runner [#call] ->(iteration_index) => String|:stop
40
+ # @param max_iterations [Integer] hard cap on iterations
41
+ # @param sleeper [#call, nil] ->(seconds); defaults to Kernel#sleep
42
+ # @param on_iteration [#call, nil] ->(n, total) UI callback before each run
43
+ def initialize(runner:, interval: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
44
+ sleeper: nil, on_iteration: nil)
45
+ @interval = interval
46
+ @runner = runner
47
+ @max_iterations = max_iterations
48
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
49
+ @on_iteration = on_iteration
50
+ @stopped = false
51
+ end
52
+
53
+ # Request the loop to stop after the current iteration.
54
+ # @return [void]
55
+ def stop!
56
+ @stopped = true
57
+ end
58
+
59
+ # @return [Boolean]
60
+ def stopped? = @stopped
61
+
62
+ # Run the loop.
63
+ #
64
+ # @return [Integer] number of iterations completed
65
+ def run
66
+ completed = 0
67
+ @max_iterations.times do |index|
68
+ break if @stopped
69
+
70
+ @on_iteration&.call(index + 1, @max_iterations)
71
+ result = @runner.call(index)
72
+ completed += 1
73
+
74
+ break if @stopped || done?(result)
75
+
76
+ wait_before_next(index)
77
+ end
78
+ completed
79
+ rescue Interrupt
80
+ completed
81
+ end
82
+
83
+ private
84
+
85
+ def wait_before_next(index)
86
+ return unless @interval && index < @max_iterations - 1
87
+
88
+ @sleeper.call(@interval)
89
+ end
90
+
91
+ def done?(result)
92
+ return true if result == :stop
93
+
94
+ result.is_a?(String) && result.include?(DONE_SENTINEL)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubyn_code/llm/image_reader'
4
+
5
+ module RubynCode
6
+ module CLI
7
+ # Expands `@path/to/file` mentions in user input into inline file content,
8
+ # mirroring Claude Code / Codex `@`-mentions. The original text is kept and
9
+ # the referenced files are appended as labeled, fenced blocks so the agent
10
+ # sees both the request and the files it points at.
11
+ #
12
+ # Conservative by design: only existing, readable, reasonably-sized regular
13
+ # files inside the project are expanded. Unresolved mentions (and things
14
+ # that merely look like mentions, e.g. email addresses) are left untouched.
15
+ class MentionExpander
16
+ # A mention is an "@" that doesn't follow a word char or another "@"
17
+ # (so emails like foo@bar.com don't match), followed by a path-ish run.
18
+ MENTION = /(?<![\w@])@([^\s@]+)/
19
+ TRAILING_PUNCT = /[).,;:!?'"]+\z/
20
+ MAX_FILE_BYTES = 64 * 1024
21
+ MAX_FILES = 10
22
+
23
+ # @param project_root [String]
24
+ def initialize(project_root:)
25
+ @project_root = project_root
26
+ end
27
+
28
+ # @param input [String] raw user input
29
+ # @return [Array(String, Array<String>)] expanded text + resolved rel paths
30
+ def expand(input)
31
+ return [input, []] unless input.is_a?(String) && input.include?('@')
32
+
33
+ resolved = scan(input)
34
+ return [input, []] if resolved.empty?
35
+
36
+ blocks = resolved.map { |rel, abs| file_block(rel, abs) }
37
+ ["#{input}\n\n#{blocks.join("\n\n")}", resolved.map(&:first)]
38
+ end
39
+
40
+ # Extract image references from input and return them as a list of
41
+ # LLM::ImageBlock instances. Image files (.png/.jpg/.jpeg/.gif/.webp)
42
+ # are NOT included in `expand` because they're sent as image content
43
+ # blocks, not as text fances. Non-image files are returned as nil.
44
+ #
45
+ # @param input [String]
46
+ # @return [Array<LLM::ImageBlock>] resolved image blocks (in order)
47
+ def expand_images(input)
48
+ return [] unless input.is_a?(String) && input.include?('@')
49
+
50
+ scan(input).filter_map do |rel, abs|
51
+ next unless LLM::ImageReader.image_extension?(rel)
52
+
53
+ LLM::ImageReader.for_path(abs)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # @return [Array<Array(String, String)>] unique [rel, abs] pairs, in order
60
+ def scan(input)
61
+ seen = {}
62
+ input.scan(MENTION).each do |(raw)|
63
+ rel = raw.sub(TRAILING_PUNCT, '')
64
+ next if rel.empty?
65
+
66
+ abs = resolve(rel)
67
+ next unless abs && !seen.key?(abs)
68
+
69
+ seen[abs] = rel
70
+ break if seen.size >= MAX_FILES
71
+ end
72
+ seen.map { |abs, rel| [rel, abs] }
73
+ end
74
+
75
+ # Resolve a mention to an absolute path inside the project, or nil.
76
+ def resolve(rel)
77
+ abs = File.expand_path(rel, @project_root)
78
+ return nil unless inside_project?(abs)
79
+ return nil unless File.file?(abs)
80
+
81
+ abs
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ def inside_project?(abs)
87
+ root = File.expand_path(@project_root)
88
+ abs == root || abs.start_with?("#{root}#{File::SEPARATOR}")
89
+ end
90
+
91
+ def file_block(rel, abs)
92
+ body = read_truncated(abs)
93
+ <<~BLOCK.strip
94
+ @#{rel}:
95
+ ```
96
+ #{body}
97
+ ```
98
+ BLOCK
99
+ end
100
+
101
+ def read_truncated(abs)
102
+ content = File.read(abs, MAX_FILE_BYTES + 1)
103
+ return content if content.bytesize <= MAX_FILE_BYTES
104
+
105
+ "#{content.byteslice(0, MAX_FILE_BYTES)}\n… [truncated]"
106
+ rescue StandardError => e
107
+ "[could not read file: #{e.message}]"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pastel'
4
- require 'rouge'
5
4
 
6
5
  module RubynCode
7
6
  module CLI
8
7
  class Renderer
9
8
  def initialize
10
9
  @pastel = Pastel.new
11
- @rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
12
10
  end
13
11
 
14
12
  attr_writer :yolo
@@ -177,6 +175,9 @@ module RubynCode
177
175
  end
178
176
 
179
177
  def render_code_block(code, lang)
178
+ # Lazy so REPL boot never pays for rouge until a code block is rendered.
179
+ require 'rouge'
180
+ @rouge_formatter ||= Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
180
181
  lexer = Rouge::Lexer.find(lang) || Rouge::Lexers::PlainText.new
181
182
  highlighted = @rouge_formatter.format(lexer.lex(code))
182
183
  border = @pastel.dim(' │ ')
@@ -31,10 +31,10 @@ module RubynCode
31
31
  def run
32
32
  @version_check = VersionCheck.new(renderer: @renderer)
33
33
  @version_check.start
34
+ @auto_suggest = Skills::AutoSuggest.new(project_root: @project_root)
35
+ @auto_suggest.start
34
36
 
35
37
  @renderer.welcome
36
- @version_check.notify
37
- check_skill_suggestions!
38
38
 
39
39
  at_exit { shutdown! }
40
40
 
@@ -45,17 +45,20 @@ module RubynCode
45
45
 
46
46
  private
47
47
 
48
- def check_skill_suggestions!
49
- suggest = Skills::AutoSuggest.new(project_root: @project_root)
50
- message = suggest.check
48
+ # Surface results of the background startup checks (version, skill
49
+ # suggestions) between prompts — never blocking input on the network.
50
+ def flush_startup_notices!
51
+ @version_check&.notify
52
+ message = @auto_suggest&.pending_message
51
53
  @renderer.info(message) if message
52
54
  rescue StandardError
53
- # Never block session start on suggestion failure
55
+ # Never block the prompt on startup notices
54
56
  end
55
57
 
56
58
  def run_input_loop
57
59
  while @running
58
60
  begin
61
+ flush_startup_notices!
59
62
  input = read_input
60
63
  break if input.nil?
61
64
 
@@ -94,9 +97,22 @@ module RubynCode
94
97
 
95
98
  def handle_on_tool_result(name, result)
96
99
  @renderer.tool_result(name, result)
100
+ render_todo_checklist
97
101
  @spinner.start
98
102
  end
99
103
 
104
+ # Refresh the in-turn checklist above the spinner after every tool call.
105
+ # Only printed if the TodoWrite tool has been used in this turn.
106
+ def render_todo_checklist
107
+ return unless @agent_loop.respond_to?(:todo_store)
108
+
109
+ store = @agent_loop.todo_store
110
+ return if store.nil? || store.empty?
111
+ return if @spinner.done
112
+
113
+ @renderer.info("☐ Checklist:\n#{store.render}")
114
+ end
115
+
100
116
  def handle_on_text(text)
101
117
  @spinner.stop
102
118
  if @streaming_first_chunk
@@ -109,26 +125,27 @@ module RubynCode
109
125
 
110
126
  # -- sequential steps with interrupt rescue
111
127
  def handle_message(input)
128
+ # Checkpoint before the turn (raw input as label), then expand
129
+ # @-mentions so the agent sees referenced file contents.
130
+ @checkpoint_manager&.checkpoint!(label: input, conversation: @conversation)
131
+ image_blocks = expand_image_mentions(input)
132
+ input = expand_mentions(input)
112
133
  @spinner.start
113
134
  @streaming_first_chunk = true
114
135
 
115
- response = @agent_loop.send_message(input)
136
+ response = @agent_loop.send_message(input, blocks: image_blocks.empty? ? nil : image_blocks)
116
137
 
117
138
  @spinner.stop
118
- if @streaming_first_chunk
119
- @renderer.display(response)
120
- else
121
- @stream_formatter&.flush
122
- @stream_formatter = nil
123
- puts
124
- end
125
-
139
+ render_response(response)
126
140
  save_session!
141
+ response
127
142
  rescue Interrupt
128
143
  @spinner.stop
129
144
  puts
130
145
  @renderer.warning('Interrupted — session state preserved')
146
+ @current_loop&.stop! # Ctrl-C during a /loop iteration stops the loop
131
147
  save_session!
148
+ nil
132
149
  rescue BudgetExceededError => e
133
150
  @spinner.error
134
151
  @renderer.error("Budget exceeded: #{e.message}")
@@ -137,6 +154,42 @@ module RubynCode
137
154
  @renderer.error("Error: #{e.message}")
138
155
  end
139
156
 
157
+ def render_response(response)
158
+ if @streaming_first_chunk
159
+ @renderer.display(response)
160
+ else
161
+ @stream_formatter&.flush
162
+ @stream_formatter = nil
163
+ puts
164
+ end
165
+ end
166
+
167
+ # Expand @path file mentions into inline content before the agent sees
168
+ # the message. Surfaces what was attached so the user knows.
169
+ def expand_mentions(input)
170
+ expanded, paths = @mention_expander.expand(input)
171
+ @renderer.info("📎 Attached: #{paths.join(', ')}") unless paths.empty?
172
+ expanded
173
+ rescue StandardError
174
+ input
175
+ end
176
+
177
+ # Collect image references from @path/to/image.png style mentions and
178
+ # return them as a list of LLM::ImageBlock hashes ready for the
179
+ # Agent::Loop. Falls back to [] when no images are attached.
180
+ def expand_image_mentions(input)
181
+ blocks = @mention_expander.expand_images(input)
182
+ if blocks.empty?
183
+ []
184
+ else
185
+ @renderer.info("🖼 Attached images: #{blocks.size}")
186
+ # Translate via MessageBuilder for adapter-aware shape
187
+ RubynCode::LLM::MessageBuilder.new.format_content_blocks(blocks)
188
+ end
189
+ rescue StandardError
190
+ []
191
+ end
192
+
140
193
  def setup_readline!
141
194
  completions = @command_registry.completions
142
195