rubino-agent 0.5.2.1 → 0.5.2.2

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +15 -0
  3. data/CHANGELOG.md +56 -0
  4. data/Dockerfile +56 -0
  5. data/agent.md +112 -0
  6. data/docs/design/bg-shell-pty-port.md +88 -0
  7. data/docs/design/bg-shell-review-refinements.md +65 -0
  8. data/docs/design/bg-shell-ux.md +130 -0
  9. data/docs/tools.md +3 -12
  10. data/lib/rubino/agent/iteration_budget.rb +13 -0
  11. data/lib/rubino/agent/loop.rb +43 -5
  12. data/lib/rubino/agent/prompts/build.txt +3 -5
  13. data/lib/rubino/agent/prompts/memory_guidance.txt +5 -0
  14. data/lib/rubino/agent/prompts/tool_use_enforcement.txt +4 -0
  15. data/lib/rubino/agent/prompts/tool_use_enforcement_google.txt +9 -0
  16. data/lib/rubino/agent/prompts/tool_use_enforcement_openai.txt +48 -0
  17. data/lib/rubino/agent/runner.rb +55 -12
  18. data/lib/rubino/agent/tool_executor.rb +1 -1
  19. data/lib/rubino/cli/chat/idle_card_host.rb +6 -1
  20. data/lib/rubino/cli/chat_command.rb +119 -17
  21. data/lib/rubino/cli/commands.rb +5 -0
  22. data/lib/rubino/commands/handlers/agents.rb +27 -18
  23. data/lib/rubino/commands/handlers/status.rb +6 -3
  24. data/lib/rubino/config/configuration.rb +25 -8
  25. data/lib/rubino/config/defaults.rb +15 -13
  26. data/lib/rubino/context/prompt_assembler.rb +89 -1
  27. data/lib/rubino/context/summary_builder.rb +0 -22
  28. data/lib/rubino/interaction/events.rb +2 -2
  29. data/lib/rubino/interaction/lifecycle.rb +54 -20
  30. data/lib/rubino/llm/ruby_llm_adapter.rb +178 -20
  31. data/lib/rubino/security/redactor.rb +1 -1
  32. data/lib/rubino/session/message.rb +12 -0
  33. data/lib/rubino/tools/background_tasks.rb +107 -12
  34. data/lib/rubino/tools/base.rb +1 -1
  35. data/lib/rubino/tools/read_attachment_tool.rb +52 -54
  36. data/lib/rubino/tools/registry.rb +21 -72
  37. data/lib/rubino/tools/shell_entry_adapter.rb +97 -0
  38. data/lib/rubino/tools/shell_input_tool.rb +1 -1
  39. data/lib/rubino/tools/shell_kill_tool.rb +4 -4
  40. data/lib/rubino/tools/shell_registry.rb +178 -38
  41. data/lib/rubino/tools/shell_tool.rb +45 -5
  42. data/lib/rubino/tools/task_result_tool.rb +4 -1
  43. data/lib/rubino/tools/task_tool.rb +74 -11
  44. data/lib/rubino/tools/vision_tool.rb +1 -1
  45. data/lib/rubino/ui/agent_menu.rb +8 -2
  46. data/lib/rubino/ui/api.rb +11 -0
  47. data/lib/rubino/ui/bottom_composer.rb +24 -11
  48. data/lib/rubino/ui/cli.rb +254 -15
  49. data/lib/rubino/ui/markdown_renderer.rb +4 -1
  50. data/lib/rubino/ui/stdout_proxy.rb +25 -10
  51. data/lib/rubino/ui/streaming_markdown.rb +67 -12
  52. data/lib/rubino/ui/subagent_cards.rb +8 -7
  53. data/lib/rubino/ui/tool_args_stream.rb +143 -0
  54. data/lib/rubino/update_check.rb +10 -2
  55. data/lib/rubino/version.rb +1 -1
  56. metadata +14 -6
  57. data/AGENTS.md +0 -97
  58. data/docs/agents.md +0 -216
  59. data/lib/rubino/jobs/handlers/summarize_session_job.rb +0 -21
  60. data/lib/rubino/tools/summarize_file_tool.rb +0 -194
@@ -56,6 +56,13 @@ module Rubino
56
56
  # produced the "1. Mercury / 1. Venus / 1. Earth" off-by-one (B4).
57
57
  LIST_ITEM_RE = /\A\s*(?:[-*+]|\d+[.)])\s/
58
58
 
59
+ # An ATX heading: 1-6 `#` (up to 3 leading spaces) followed by a space or
60
+ # end-of-line. CommonMark makes this a SINGLE-LINE block that can interrupt
61
+ # a paragraph, so the splitter commits it on its own — that's what lets a
62
+ # heading render WITH its H1/H2 breathing-room spacing live, instead of
63
+ # staying glued inside a multi-line block stuck in the capped live tail.
64
+ ATX_HEADING_RE = /\A {0,3}\#{1,6}(?:\s|\z)/
65
+
59
66
  # A GFM pipe-table separator row, e.g. "|---|:--:|---|" or "---|---". The
60
67
  # EXACT regex MarkdownRenderer uses to detect a table (markdown_renderer.rb)
61
68
  # — DRY: a separator row is the single unambiguous "this is a table" signal,
@@ -124,8 +131,7 @@ module Rubino
124
131
  while (idx = @pending.index("\n"))
125
132
  line = @pending[0...idx]
126
133
  @pending = @pending[(idx + 1)..] || +""
127
- block = consume_line(line)
128
- completed << block if block
134
+ completed.concat(consume_line(line))
129
135
  end
130
136
 
131
137
  completed
@@ -140,6 +146,34 @@ module Rubino
140
146
  parts.join("\n")
141
147
  end
142
148
 
149
+ # How many trailing lines of the in-flight block the LIVE markdown preview
150
+ # ever needs as render input. We only SHOW the last few rendered rows
151
+ # (CLI::LIVE_TAIL_ROWS), so feeding the renderer more than a screenful of
152
+ # source is wasted work: it re-parses the WHOLE growing block on EVERY
153
+ # delta and throws all but the last rows away — O(N²), which freezes the
154
+ # stream once a long ``` fence (a streamed file/code dump) accumulates.
155
+ # Generous enough that ordinary prose/lists/tables still render in full.
156
+ LIVE_SOURCE_MAX_LINES = 60
157
+
158
+ # Bounded source text for the LIVE markdown preview (#show_live_markdown):
159
+ # at most the last +max_lines+ lines of the in-flight block. When the block
160
+ # is inside a code fence the OPENER line (```lang) is always prepended so
161
+ # the windowed tail still parses AS a fenced block (same language, same box
162
+ # framing) — which makes the rendered last-LIVE_TAIL_ROWS rows identical to
163
+ # rendering the whole block, just without the quadratic cost. Outside a
164
+ # fence the cap only bites a pathologically long loose list (the one other
165
+ # unbounded block type); its transient numbering may differ for a frame,
166
+ # which beats freezing. Small blocks return the full #tail unchanged.
167
+ def live_source(max_lines = LIVE_SOURCE_MAX_LINES)
168
+ lines = @block.dup
169
+ lines << @pending unless @pending.empty?
170
+ return lines.join("\n") if lines.length <= max_lines
171
+
172
+ window = lines.last(max_lines)
173
+ window.unshift(@block.find { |l| l.match?(FENCE_OPEN_RE) }) if @in_fence
174
+ window.compact.join("\n")
175
+ end
176
+
143
177
  # The in-progress tail to show live (raw): the LAST +rows+ lines of the
144
178
  # in-flight block — its most recent already-newlined lines plus the
145
179
  # un-newlined remainder. Newline-joined; the live region renders one row
@@ -190,16 +224,18 @@ module Rubino
190
224
  private
191
225
 
192
226
  # Feed one complete (newline-stripped) line through the state machine.
193
- # Returns the finished block's text when this line closes a block, else nil.
227
+ # Returns the block texts this line COMPLETED usually 0 or 1, but a
228
+ # heading can close the open block AND emit itself, so the contract is an
229
+ # Array (feed concats them; the public #feed already returns an array).
194
230
  def consume_line(line)
195
231
  if @in_fence
196
232
  @block << line
197
- return nil unless fence_line_closes?(line)
233
+ return [] unless fence_line_closes?(line)
198
234
 
199
235
  @in_fence = false
200
236
  @fence_len = 0
201
237
  @fence_depth = nil
202
- return take_block
238
+ return [take_block]
203
239
  end
204
240
 
205
241
  if (m = line.match(FENCE_OPEN_RE)) # opening fence starts a code block
@@ -212,7 +248,7 @@ module Rubino
212
248
  @fence_depth = MARKDOWN_FENCE_LANGS.include?(m[2].to_s.strip.downcase) ? 1 : nil
213
249
  flush_blanks
214
250
  @block << line
215
- return nil
251
+ return []
216
252
  end
217
253
 
218
254
  # A GFM pipe table is its own block type (like a fence): handled in
@@ -220,8 +256,15 @@ module Rubino
220
256
  # header-ish row), holds its rows, and closes it on a blank/non-row line.
221
257
  # Returns a 2-tuple [handled?, completed_block_or_nil].
222
258
  handled, table_block = consume_table_line(line)
223
- return table_block if handled
259
+ return Array(table_block) if handled
224
260
 
261
+ consume_prose_line(line)
262
+ end
263
+
264
+ # A non-fence, non-table line: blank separators, ATX headings, list items,
265
+ # and plain prose. Returns the block texts this line completed (see
266
+ # #consume_line for the Array contract).
267
+ def consume_prose_line(line)
225
268
  if line.strip.empty?
226
269
  # A blank line inside a list is BUFFERED, not a separator: it only ends
227
270
  # the block if the list doesn't continue (handled when the next
@@ -229,11 +272,23 @@ module Rubino
229
272
  # blank line ends the current prose block (separator consumed).
230
273
  if @in_list
231
274
  @blanks += 1
232
- return nil
275
+ return []
233
276
  end
234
- return nil if @block.empty?
277
+ return [] if @block.empty?
278
+
279
+ return [take_block]
280
+ end
235
281
 
236
- return take_block
282
+ # An ATX heading is a single-line block that can interrupt a paragraph
283
+ # (ATX_HEADING_RE). Close any open block first (dropping a list's trailing
284
+ # separator blank, like the list-exit path below), then emit the heading
285
+ # on its OWN so it commits — and renders with its breathing-room spacing —
286
+ # the instant it arrives, instead of staying glued in the capped live tail
287
+ # with the following lines until the whole block ends.
288
+ if line.match?(ATX_HEADING_RE)
289
+ @blanks = 0 if @in_list
290
+ finished = @block.empty? ? [] : [take_block]
291
+ return finished + [line]
237
292
  end
238
293
 
239
294
  is_item = line.match?(LIST_ITEM_RE)
@@ -245,13 +300,13 @@ module Rubino
245
300
  @blanks = 0 # drop the trailing blank(s) that separated list from this line
246
301
  finished = take_block
247
302
  @block << line
248
- return finished
303
+ return [finished]
249
304
  end
250
305
 
251
306
  flush_blanks
252
307
  @in_list = true if is_item
253
308
  @block << line
254
- nil
309
+ []
255
310
  end
256
311
 
257
312
  # Does this line (already appended to the open fence's block) CLOSE that
@@ -71,13 +71,14 @@ module Rubino
71
71
  else
72
72
  glyph = @pastel.cyan(COLLAPSED)
73
73
  state = entry.status == :stopping ? "stopping" : "running"
74
- count = entry.tool_count.to_i
75
- # Compact card: id · label · state · N tools · elapsed. The per-tool
76
- # last_activity (often a long grep/glob arg or absolute path) is NOT
77
- # shown here — too noisy on the always-visible card; the live detail
78
- # lives in the agent's own view (Enter) / drill-in.
79
- body = "#{entry.id} · #{safe(card_label(entry))} · #{state} · " \
80
- "#{count} tool#{"s" if count != 1} · #{elapsed(entry)}"
74
+ # Compact card: id · label · state · [N tools ·] elapsed. The tool count
75
+ # shows only when the entry HAS one (subagents); a background shell runs
76
+ # no tools, so its tool_count is nil and the segment is omitted instead of
77
+ # a meaningless "0 tools". last_activity is NOT shown — too noisy on the
78
+ # always-visible card; the live detail lives in the agent's view (Enter).
79
+ count = entry.tool_count
80
+ metric = count ? "#{count.to_i} tool#{"s" if count.to_i != 1} · " : ""
81
+ body = "#{entry.id} · #{safe(card_label(entry))} · #{state} · #{metric}#{elapsed(entry)}"
81
82
  " #{glyph} #{body}"
82
83
  end
83
84
  end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module UI
5
+ # Decodes a tool call's JSON arguments AS THEY STREAM into readable text, so
6
+ # the timeline can show what a `write`/`edit`/`shell` is composing while the
7
+ # model is still emitting it (#608). It surfaces the JSON string VALUES only
8
+ # (the `content` a write is producing, the `command` a shell will run) and
9
+ # drops the keys/structure, JSON-unescaping as it goes.
10
+ #
11
+ # #feed returns the COMPLETE decoded lines available so far (each ending in
12
+ # "\n"), holding the partial last line back until its newline lands so the
13
+ # caller never renders a half-line that the next fragment continues. #flush
14
+ # returns whatever partial remains at the end of the call.
15
+ #
16
+ # Single pass, O(n): fragments are concatenated only across an incomplete
17
+ # trailing escape (a "\\" or "\\uXXX" split mid-fragment), carried to the next
18
+ # feed so an escape is never decoded half-formed.
19
+ class ToolArgsStream
20
+ # JSON single-char escapes → their literal char (\uXXXX is handled inline).
21
+ UNESCAPE = { "n" => "\n", "t" => "\t", "r" => "\r", "b" => "\b",
22
+ "f" => "\f", '"' => '"', "\\" => "\\", "/" => "/" }.freeze
23
+
24
+ def initialize
25
+ @in_string = false
26
+ @is_value = false # the current string is a VALUE (emit) vs a KEY (skip)
27
+ @containers = [] # stack of :obj / :arr to read `,` correctly
28
+ @expect = :value # what the next opening string is (top-level value)
29
+ @line = +"" # decoded VALUE text not yet returned (current line)
30
+ @carry = +"" # raw chars held back across an incomplete escape
31
+ end
32
+
33
+ # Feed a raw argument fragment; returns the complete decoded lines unlocked
34
+ # by it (possibly empty), with the partial last line held back.
35
+ def feed(fragment)
36
+ return "" if fragment.nil? || fragment.empty?
37
+
38
+ scan(@carry + fragment)
39
+ take_complete_lines
40
+ end
41
+
42
+ # Final partial line (the value tail with no trailing newline), or "".
43
+ def flush
44
+ out = @line.dup
45
+ @line.clear
46
+ out
47
+ end
48
+
49
+ private
50
+
51
+ # Walks chars, decoding string VALUES into @line. Stops early and stashes
52
+ # an incomplete trailing escape into @carry so it is completed next feed.
53
+ def scan(buf)
54
+ @carry = +""
55
+ i = 0
56
+ n = buf.length
57
+ while i < n
58
+ ch = buf[i]
59
+ if @in_string
60
+ if ch == "\\"
61
+ consumed = decode_escape(buf, i)
62
+ return if consumed.nil? # incomplete escape → carried, wait for more
63
+
64
+ i += consumed
65
+ next
66
+ elsif ch == '"'
67
+ close_string
68
+ elsif @is_value
69
+ @line << ch
70
+ end
71
+ else
72
+ structural(ch)
73
+ end
74
+ i += 1
75
+ end
76
+ end
77
+
78
+ # Handles a backslash escape at buf[pos]. Returns the number of chars
79
+ # consumed (>=2), or nil when the escape is split across the fragment
80
+ # boundary (the raw tail is stashed in @carry to retry on the next feed).
81
+ def decode_escape(buf, pos)
82
+ nxt = buf[pos + 1]
83
+ return carry(buf[pos..]) if nxt.nil?
84
+
85
+ if nxt == "u"
86
+ hex = buf[pos + 2, 4]
87
+ return carry(buf[pos..]) if hex.nil? || hex.length < 4
88
+
89
+ @line << [hex.to_i(16)].pack("U") if @is_value
90
+ 6
91
+ else
92
+ @line << UNESCAPE.fetch(nxt, nxt) if @is_value
93
+ 2
94
+ end
95
+ end
96
+
97
+ # Stash the unfinished tail and signal "incomplete" to #scan.
98
+ def carry(tail)
99
+ @carry = tail.dup
100
+ nil
101
+ end
102
+
103
+ def structural(chr)
104
+ case chr
105
+ when '"'
106
+ @in_string = true
107
+ @is_value = (@expect == :value)
108
+ when "{"
109
+ @containers.push(:obj)
110
+ @expect = :key
111
+ when "["
112
+ @containers.push(:arr)
113
+ @expect = :value
114
+ when "}", "]"
115
+ @containers.pop
116
+ when ":"
117
+ @expect = :value
118
+ when ","
119
+ @expect = @containers.last == :arr ? :value : :key
120
+ end
121
+ end
122
+
123
+ # A string just closed. After a VALUE, drop a newline so the next value
124
+ # (e.g. a write's `content` after its `path`) starts on its own line.
125
+ def close_string
126
+ @in_string = false
127
+ @line << "\n" if @is_value
128
+ @expect = @containers.last == :arr ? :value : :key
129
+ end
130
+
131
+ # Splits @line at its last newline: everything up to and including it is
132
+ # returned; the remainder stays buffered as the partial current line.
133
+ def take_complete_lines
134
+ idx = @line.rindex("\n")
135
+ return "" if idx.nil?
136
+
137
+ out = @line[0..idx]
138
+ @line = @line[(idx + 1)..] || +""
139
+ out
140
+ end
141
+ end
142
+ end
143
+ end
@@ -191,9 +191,17 @@ module Rubino
191
191
  installed_gem_version(GEM_NAME) ? :gem : :source
192
192
  end
193
193
 
194
+ # The HIGHEST installed version of +name+ on disk, or nil when none is
195
+ # installed. Uses find_all_by_name (NOT find_by_name) because the running
196
+ # `rubino` process has its own gem version ACTIVATED — find_by_name returns
197
+ # that activated spec, so right after `gem update` pulls a newer gem it still
198
+ # reported the old version and `rubino update` wrongly said "already up to
199
+ # date". find_all_by_name isn't activation-filtered; taking the max sees the
200
+ # freshly-installed version (callers that need post-install freshness run
201
+ # Gem.refresh first — see Commands#update).
194
202
  def installed_gem_version(name)
195
- Gem::Specification.find_by_name(name).version.to_s
196
- rescue Gem::MissingSpecError, StandardError
203
+ Gem::Specification.find_all_by_name(name).map(&:version).max&.to_s
204
+ rescue StandardError
197
205
  nil
198
206
  end
199
207
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubino
4
- VERSION = "0.5.2.1"
4
+ VERSION = "0.5.2.2"
5
5
 
6
6
  # The ONE product tagline (#559). Both chrome surfaces that introduce rubino —
7
7
  # the `rubino --help` banner (CLI::Commands::TAGLINE) and the first-run chat
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubino-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2.1
4
+ version: 0.5.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jhon Rojas
@@ -516,21 +516,25 @@ extensions:
516
516
  - ext/landlock/extconf.rb
517
517
  extra_rdoc_files: []
518
518
  files:
519
+ - ".dockerignore"
519
520
  - ".rspec"
520
521
  - ".rubocop.yml"
521
522
  - ".rubocop_todo.yml"
522
523
  - ".ruby-version"
523
- - AGENTS.md
524
524
  - CHANGELOG.md
525
525
  - CONTRIBUTING.md
526
+ - Dockerfile
526
527
  - LICENSE
527
528
  - README.md
528
529
  - Rakefile
529
- - docs/agents.md
530
+ - agent.md
530
531
  - docs/api/v1.md
531
532
  - docs/architecture.md
532
533
  - docs/commands.md
533
534
  - docs/configuration.md
535
+ - docs/design/bg-shell-pty-port.md
536
+ - docs/design/bg-shell-review-refinements.md
537
+ - docs/design/bg-shell-ux.md
534
538
  - docs/getting-started.md
535
539
  - docs/jobs.md
536
540
  - docs/mcp.md
@@ -562,7 +566,11 @@ files:
562
566
  - lib/rubino/agent/prompts/compaction.txt
563
567
  - lib/rubino/agent/prompts/explore.txt
564
568
  - lib/rubino/agent/prompts/general.txt
569
+ - lib/rubino/agent/prompts/memory_guidance.txt
565
570
  - lib/rubino/agent/prompts/plan.txt
571
+ - lib/rubino/agent/prompts/tool_use_enforcement.txt
572
+ - lib/rubino/agent/prompts/tool_use_enforcement_google.txt
573
+ - lib/rubino/agent/prompts/tool_use_enforcement_openai.txt
566
574
  - lib/rubino/agent/response_validator.rb
567
575
  - lib/rubino/agent/runner.rb
568
576
  - lib/rubino/agent/tool_executor.rb
@@ -724,7 +732,6 @@ files:
724
732
  - lib/rubino/jobs/handlers/compact_session_job.rb
725
733
  - lib/rubino/jobs/handlers/distill_skill_job.rb
726
734
  - lib/rubino/jobs/handlers/extract_memory_job.rb
727
- - lib/rubino/jobs/handlers/summarize_session_job.rb
728
735
  - lib/rubino/jobs/queue.rb
729
736
  - lib/rubino/jobs/registry.rb
730
737
  - lib/rubino/jobs/runner.rb
@@ -860,6 +867,7 @@ files:
860
867
  - lib/rubino/tools/retrieve_output_tool.rb
861
868
  - lib/rubino/tools/ruby_tool.rb
862
869
  - lib/rubino/tools/session_search_tool.rb
870
+ - lib/rubino/tools/shell_entry_adapter.rb
863
871
  - lib/rubino/tools/shell_input_tool.rb
864
872
  - lib/rubino/tools/shell_kill_tool.rb
865
873
  - lib/rubino/tools/shell_output_tool.rb
@@ -868,7 +876,6 @@ files:
868
876
  - lib/rubino/tools/shell_tool.rb
869
877
  - lib/rubino/tools/steer_tool.rb
870
878
  - lib/rubino/tools/subagent_probe.rb
871
- - lib/rubino/tools/summarize_file_tool.rb
872
879
  - lib/rubino/tools/task_result_tool.rb
873
880
  - lib/rubino/tools/task_stop_tool.rb
874
881
  - lib/rubino/tools/task_tool.rb
@@ -907,6 +914,7 @@ files:
907
914
  - lib/rubino/ui/stdout_proxy.rb
908
915
  - lib/rubino/ui/streaming_markdown.rb
909
916
  - lib/rubino/ui/subagent_cards.rb
917
+ - lib/rubino/ui/tool_args_stream.rb
910
918
  - lib/rubino/ui/tool_label.rb
911
919
  - lib/rubino/update_check.rb
912
920
  - lib/rubino/util/atomic_file.rb
@@ -955,7 +963,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
955
963
  - !ruby/object:Gem::Version
956
964
  version: '0'
957
965
  requirements: []
958
- rubygems_version: 4.0.14
966
+ rubygems_version: 4.0.6
959
967
  specification_version: 4
960
968
  summary: A lightweight Ruby coding and automation agent with persistent memory, sessions,
961
969
  and context compaction
data/AGENTS.md DELETED
@@ -1,97 +0,0 @@
1
- # AGENTS.md — rubino
2
-
3
- Onboarding for anyone (human or AI) working on this codebase.
4
-
5
- ## Vision
6
-
7
- `rubino` is a **micro agent**: a small, self-contained Ruby gem that runs an LLM-driven coding/automation agent *on the machine where the work happens* — the user's PC or a VM. You drop it onto that machine and it works there, reachable over a clean HTTP API and a CLI. It executes conversation turns with tool calls, streams events, manages skills, and persists sessions/memory in a single SQLite file. It can be embedded as a library, run as a CLI, or run as a small service behind a REST gateway.
8
-
9
- It is a lightweight agent, **not a heavy framework**. We pick the few primitives that have real value and skip everything else. Scope discipline is part of the product.
10
-
11
- ## Non-goals (explicit)
12
-
13
- These decisions are permanent. Reopen only with a written justification.
14
-
15
- - **No dual HTTP APIs.** One server, one versioned prefix (`/v1`). When we break, bump to `/v2`. Never aliases.
16
- - **No multi-tenant inside one process.** One instance = one workspace = one identity. Multi-tenant deployments run N instances.
17
- - **No dashboard / theme system / plugin hub.** The dashboard is whatever client consumes our API. We don't ship a UI server.
18
- - **No skill catalog bundled.** Skills are user-supplied directories. We don't ship a catalog.
19
- - **No compat layers.** Renamed something? Rename everywhere. Reshaped a response? Bump version.
20
- - **No auto-update endpoints.** Deploy = gem update + restart. Period.
21
- - **No backwards-compat shims, no _deprecated suffixes, no "legacy" anything.**
22
-
23
- ## Architectural rules (load-bearing)
24
-
25
- These are load-bearing. When a PR violates them, it is the PR that gets redone.
26
-
27
- 1. **One interface per concept.** One `LLM::Adapter`, one `Tools::Base`, one `Memory::Store`. Variants are subclasses, not parallels.
28
- 2. **No PORO commands in `lib/`.** Domain logic on models/repositories; orchestration as class methods. No `app/services/Foo::DoThingCommand`.
29
- 3. **No optional-magic params.** Signatures explicit. If long, refactor responsibilities, don't add `**opts`.
30
- 4. **Schema-validated input at boundaries** (HTTP, CLI, MCP). Internals trust types.
31
- 5. **Typed errors only.** `raise NotFoundError, "session"` not `raise "session not found"`.
32
- 6. **Test contracts, not implementation.** Internals change without breaking tests, not vice versa.
33
- 7. **Structured logging from day one.** JSON-line. No `puts`/`pp` outside the UI layer. Logger DI-injected.
34
- 8. **No global singletons.** Config flows from the main loop into everything via constructor.
35
- 9. **Handlers stay thin.** Endpoint >30 LOC → extract `Operation` class.
36
- 10. **Less code, less bugs.** A refactor is good if it deletes net lines. Distrust preparatory abstractions (PORO/Result/custom errors before you need them).
37
-
38
- ## Tech stack
39
-
40
- | Layer | Choice | Why |
41
- |---|---|---|
42
- | LLM | `ruby_llm ~> 1.0` (thin wrap) | One mature gem covering OpenAI/Anthropic/Gemini/Bedrock. We never call providers directly. |
43
- | MCP | `ruby_llm-mcp ~> 0.8` | Pairs natively with `ruby_llm`. |
44
- | HTTP server | `rack` + `puma` | Standard. WEBrick is gone. |
45
- | DB | `sequel` + `sqlite3` | One file, zero ops. Postgres later if multi-writer matters. |
46
- | Schema | `dry-schema` | Boundary validation. |
47
- | Scheduler | `rufus-scheduler` | Cron syntax, in-process. |
48
- | Config | `dry-configurable` | Already in use. |
49
- | CLI | `thor` | Already in use. |
50
- | Autoload | `zeitwerk` | Already in use. |
51
- | CLI UI | `tty-*` + `reline` | The interactive `rubino chat` prompt (history, completion, multi-line editing). |
52
-
53
- `ruby_llm` is the foundation. `LLM::RubyLLMAdapter` is a thin wrapper: it configures `RubyLLM`, delegates `chat`/`stream`/`model_info`, and exposes nothing the underlying gem can't already do. **If you find yourself adding business logic to the adapter, push it into the run loop instead.**
54
-
55
- ## Layout
56
-
57
- ```
58
- lib/rubino/
59
- agent/ # run loop, lifecycle, multi-agent routing
60
- api/ # HTTP gateway (Rack app, middleware, operations)
61
- cli/ # thor commands
62
- config/ # dry-configurable
63
- context/ # compaction, prompt assembly
64
- interaction/ # conversation lifecycle primitives
65
- jobs/ # scheduler + worker (cron)
66
- llm/ # ruby_llm adapter + model registry
67
- mcp/ # MCP client + tool wrapper
68
- memory/ # Memory::Store + extractors + retrievers
69
- oauth/ # OAuth providers + connection storage
70
- security/ # approval policy, sandboxing
71
- session/ # repository + persistence
72
- skills/ # SKILL.md loader
73
- tools/ # built-in tools
74
- ui/ # cli/null/api stub UIs
75
- ```
76
-
77
- ## Surfaces this project exposes
78
-
79
- - **HTTP API** (`/v1/*`) — the canonical interface. See `docs/api/v1.md`.
80
- - **CLI** — `rubino {setup,chat,prompt,server,config,memory,sessions,jobs,skills,tools,tls_cert,doctor,version,update}`.
81
- - **Library** — `require "rubino"; Rubino.run(...)`.
82
-
83
- The interactive CLI ships as part of `rubino chat`. Multi-agent routing, MCP, and plugin hooks are designed in but not fully wired yet.
84
-
85
- ## Working on this codebase
86
-
87
- - Read `docs/architecture.md` for the bigger picture.
88
- - Read `docs/api/v1.md` for HTTP contract.
89
- - Read `docs/oauth-providers.md` for OAuth design.
90
- - Run `bundle exec rspec` before pushing.
91
- - Don't commit if rubocop fails.
92
-
93
- ## Git/commit conventions
94
-
95
- - Conventional commits welcome but not required.
96
- - No AI attribution lines in commit messages.
97
- - Small commits over big ones. A commit that touches 30 files needs a justification.