riffer-rig 0.2.0 → 0.3.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +2 -2
  4. data/lib/riffer/rig/cli.rb +21 -1
  5. data/lib/riffer/rig/coding_agent.rb +7 -5
  6. data/lib/riffer/rig/credentials.rb +24 -4
  7. data/lib/riffer/rig/repl.rb +70 -5
  8. data/lib/riffer/rig/settings/pricing.rb +19 -0
  9. data/lib/riffer/rig/settings.rb +33 -8
  10. data/lib/riffer/rig/token_tally.rb +22 -6
  11. data/lib/riffer/rig/tools/bash.rb +19 -4
  12. data/lib/riffer/rig/tools/edit.rb +6 -0
  13. data/lib/riffer/rig/tools/read.rb +6 -1
  14. data/lib/riffer/rig/tools/write.rb +4 -0
  15. data/lib/riffer/rig/ui/animator.rb +51 -11
  16. data/lib/riffer/rig/ui/banner.rb +28 -4
  17. data/lib/riffer/rig/ui/cursor.rb +46 -0
  18. data/lib/riffer/rig/ui/palette.rb +13 -7
  19. data/lib/riffer/rig/ui/renderer.rb +38 -7
  20. data/lib/riffer/rig/ui/smoother.rb +31 -5
  21. data/lib/riffer/rig/ui/theme.rb +38 -7
  22. data/lib/riffer/rig/version.rb +1 -1
  23. data/sig/generated/riffer/rig/cli.rbs +41 -0
  24. data/sig/generated/riffer/rig/coding_agent.rbs +9 -0
  25. data/sig/generated/riffer/rig/credentials.rbs +55 -0
  26. data/sig/generated/riffer/rig/repl.rbs +76 -0
  27. data/sig/generated/riffer/rig/settings/pricing.rbs +23 -0
  28. data/sig/generated/riffer/rig/settings.rbs +92 -0
  29. data/sig/generated/riffer/rig/token_tally.rbs +61 -0
  30. data/sig/generated/riffer/rig/tools/bash.rbs +28 -0
  31. data/sig/generated/riffer/rig/tools/edit.rbs +11 -0
  32. data/sig/generated/riffer/rig/tools/read.rbs +12 -0
  33. data/sig/generated/riffer/rig/tools/write.rbs +9 -0
  34. data/sig/generated/riffer/rig/ui/animator.rbs +76 -0
  35. data/sig/generated/riffer/rig/ui/banner.rbs +42 -0
  36. data/sig/generated/riffer/rig/ui/cursor.rbs +33 -0
  37. data/sig/generated/riffer/rig/ui/palette.rbs +17 -0
  38. data/sig/generated/riffer/rig/ui/renderer.rbs +68 -0
  39. data/sig/generated/riffer/rig/ui/smoother.rbs +67 -0
  40. data/sig/generated/riffer/rig/ui/theme.rbs +60 -0
  41. data/sig/generated/riffer/rig/version.rbs +7 -0
  42. data/sig/generated/riffer/rig.rbs +2 -0
  43. data/sig/manual/riffer/rig/cli.rbs +8 -0
  44. data/sig/manual/riffer/rig/credentials.rbs +8 -0
  45. data/sig/manual/riffer/rig/settings.rbs +8 -0
  46. data/sig/manual/riffer/rig/tools.rbs +5 -0
  47. data/sig/manual/riffer/rig/ui/banner.rbs +8 -0
  48. data/sig/manual/riffer/rig/ui.rbs +5 -0
  49. metadata +30 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f87fb650798f95679ff56654620a0fcb3cc383d8ce650849d9e33382a7837a6f
4
- data.tar.gz: e8784d13f472d45c5ffb7f1d269c5ab8ff7d0b41a62cfae446ceaee2345413b9
3
+ metadata.gz: 01f34d2a4cb9347ea2d9f7a81a0969415c98b6622d4f78e123034f9b2c15509e
4
+ data.tar.gz: 5a61c231fcf2832a514ec62513bdf42e56c87dc08ec994f9fb04fa263188b53d
5
5
  SHA512:
6
- metadata.gz: '09252fc5c265fdc91be5fc8753b3407560c3be9f94af9616b2d3ae9e8e3c28f100374bb1014d7b8038bd51abc5f86875e6116fc874733ba58dda87254b15e3cc'
7
- data.tar.gz: 7f1cc7dae6f6ccf69cf370296458d1eef837111f01041adae31a59e48df6719903c1c43630cf0248ba4b7ba140d420f663a585dcb13f5c945d5f105eaa2b8fc9
6
+ metadata.gz: 1b8518928f77909373cfdfe2783372aa1a6ebb04fed67a70951041c0adf637bf3aba0ee8b85051f9bdb63c1cdcda91554b2fd9c86b4f82a279eed30964984588
7
+ data.tar.gz: 3cd5548405bcf8607d516e7f8a585cdf327cfc904e1641458aca4802e8a3654127c6ed44a46435003faeca1eaaafcd4bed0968c0787a90aca8c586438b685146
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/bottrall/riffer-rig/compare/v0.2.1...v0.3.0) (2026-09-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * **types:** strict rbs-inline typing with collection-managed third-party RBS ([#164](https://github.com/bottrall/riffer-rig/issues/164)) ([be59fb9](https://github.com/bottrall/riffer-rig/commit/be59fb9102d9c6f9bae2a5f456650cc68131924f))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * print tool call lines after the round's stats line ([#166](https://github.com/bottrall/riffer-rig/issues/166)) ([dcc4e80](https://github.com/bottrall/riffer-rig/commit/dcc4e8093ffaec0bc93bb3eaafb47acd72292710))
14
+
15
+ ## [0.2.1](https://github.com/bottrall/riffer-rig/compare/v0.2.0...v0.2.1) (2026-09-08)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **ui:** hide the cursor while a turn is in flight ([#161](https://github.com/bottrall/riffer-rig/issues/161)) ([0d18fe0](https://github.com/bottrall/riffer-rig/commit/0d18fe06c51bb75a8f3002d22d64bda23671bad6))
21
+ * **ui:** stop the equalizer before the end-of-turn newline ([#158](https://github.com/bottrall/riffer-rig/issues/158)) ([00edaa3](https://github.com/bottrall/riffer-rig/commit/00edaa3bbf35a7d797ca3596820d11bd8c62789a))
22
+
3
23
  ## [0.2.0](https://github.com/bottrall/riffer-rig/compare/v0.1.2...v0.2.0) (2026-09-08)
4
24
 
5
25
 
data/README.md CHANGED
@@ -46,10 +46,10 @@ Every project chore is a script in `bin/`. The Rakefile behind them is an implem
46
46
 
47
47
  | Script | What it does |
48
48
  | --------------- | ---------------------------------------------------------------------------------------------- |
49
- | `bin/setup` | Install dependencies on a fresh checkout |
49
+ | `bin/setup` | Install dependencies on a fresh checkout (gems + rbs collection) |
50
50
  | `bin/test` | Run the test suite. Pass files and/or Minitest flags: `bin/test test/foo_test.rb -n /pattern/` |
51
51
  | `bin/lint` | Run RuboCop. Arguments are forwarded, e.g. `bin/lint -a` |
52
- | `bin/typecheck` | Check `sig/generated` is current, then type-check with Steep |
52
+ | `bin/typecheck` | Check the rbs collection lockfile and `sig/generated` are current, then type-check with Steep |
53
53
  | `bin/rbs` | Regenerate `sig/generated` from the inline annotations in `lib/` |
54
54
  | `bin/rbs-watch` | Regenerate `sig/generated` whenever `lib/` changes |
55
55
  | `bin/ci` | Run everything CI runs, serially. Use before pushing |
@@ -10,8 +10,11 @@ module Riffer::Rig::CLI
10
10
  'openai' => 'https://platform.openai.com/api-keys',
11
11
  'gemini' => 'https://aistudio.google.com/app/apikey',
12
12
  'openrouter' => 'https://openrouter.ai/keys'
13
- }.freeze
13
+ }.freeze #: Hash[String, String]
14
14
 
15
+ # @rbs output: untyped
16
+ # @rbs input: untyped
17
+ # @rbs return: Integer
15
18
  def start(output: $stdout, input: $stdin)
16
19
  theme = Riffer::Rig::UI::Theme.for(output)
17
20
 
@@ -38,6 +41,9 @@ module Riffer::Rig::CLI
38
41
 
39
42
  private
40
43
 
44
+ # @rbs provider: String?
45
+ # @rbs api_key: String
46
+ # @rbs return: void
41
47
  def configure_provider(provider, api_key)
42
48
  case provider
43
49
  when 'anthropic' then Riffer.configure { |c| c.anthropic.api_key = api_key }
@@ -47,6 +53,11 @@ module Riffer::Rig::CLI
47
53
  end
48
54
  end
49
55
 
56
+ # @rbs provider: String?
57
+ # @rbs theme: Riffer::Rig::UI::Theme
58
+ # @rbs output: untyped
59
+ # @rbs input: untyped
60
+ # @rbs return: String?
50
61
  def onboard(provider, theme, output:, input:)
51
62
  url = (provider && PROVIDER_URLS[provider]) || 'your provider'
52
63
  name = provider ? provider.capitalize : 'provider'
@@ -68,16 +79,24 @@ module Riffer::Rig::CLI
68
79
  key
69
80
  end
70
81
 
82
+ # @rbs provider: String?
83
+ # @rbs return: String
71
84
  def env_var_for(provider)
72
85
  (provider && Riffer::Rig::Credentials::PROVIDER_ENV_VARS[provider]) || 'the appropriate API key env var'
73
86
  end
74
87
 
88
+ # @rbs input: untyped
89
+ # @rbs return: String?
75
90
  def read_secret(input)
76
91
  return input.noecho(&:gets) if input.respond_to?(:noecho) && input.tty?
77
92
 
78
93
  input.gets
79
94
  end
80
95
 
96
+ # @rbs theme: Riffer::Rig::UI::Theme
97
+ # @rbs animator: Riffer::Rig::UI::Animator
98
+ # @rbs model: String
99
+ # @rbs return: void
81
100
  def reveal_banner(theme, animator, model)
82
101
  loaded = [Riffer::Rig::CodingAgent::GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')].select { |path| File.file?(path) }
83
102
  context = loaded.empty? ? 'none' : loaded.join(', ')
@@ -85,6 +104,7 @@ module Riffer::Rig::CLI
85
104
  animator.reveal([Riffer::Rig::UI::Banner.lines(theme, model: model, cwd: Dir.pwd, context: context, skills: count_skills, version: Riffer::Rig::VERSION)])
86
105
  end
87
106
 
107
+ # @rbs return: String
88
108
  def count_skills
89
109
  dirs = [Riffer::Rig::CodingAgent::GLOBAL_SKILLS_DIR, Riffer::Rig::CodingAgent::PROJECT_SKILLS_DIR.call]
90
110
  backend = Riffer::Skills::FilesystemBackend.new(*dirs)
@@ -3,9 +3,11 @@
3
3
  require 'date'
4
4
 
5
5
  class Riffer::Rig::CodingAgent < Riffer::Agent
6
- GLOBAL_AGENTS_FILE = File.expand_path('~/.riffer/AGENTS.md')
7
- GLOBAL_SKILLS_DIR = File.expand_path('~/.riffer/skills')
8
- PROJECT_SKILLS_DIR = -> { File.join(Dir.pwd, '.skills') }
6
+ GLOBAL_AGENTS_FILE = File.expand_path('~/.riffer/AGENTS.md') #: String
7
+
8
+ GLOBAL_SKILLS_DIR = File.expand_path('~/.riffer/skills') #: String
9
+
10
+ PROJECT_SKILLS_DIR = -> { File.join(Dir.pwd, '.skills') } #: ^() -> String
9
11
 
10
12
  model Riffer::Rig::Settings.model
11
13
  model_options Riffer::Rig::Settings.model_options
@@ -40,9 +42,9 @@ class Riffer::Rig::CodingAgent < Riffer::Agent
40
42
 
41
43
  Available tools:
42
44
  - read: read a file's contents
43
- - write: create or overwrite a file
45
+ - write: write content to a file
44
46
  - edit: replace an exact string in a file
45
- - bash: run a shell command (use it for ls, rg/grep, find, tests, git, etc.)
47
+ - bash: run a shell command in the working directory
46
48
 
47
49
  Guidelines:
48
50
  - Be concise and direct in your responses.
@@ -25,24 +25,33 @@ require 'fileutils'
25
25
  module Riffer::Rig::Credentials
26
26
  extend self
27
27
 
28
- PATH = File.expand_path('~/.riffer/auth.json')
28
+ PATH = File.expand_path('~/.riffer/auth.json') #: String
29
29
 
30
30
  PROVIDER_ENV_VARS = {
31
31
  'anthropic' => 'ANTHROPIC_API_KEY',
32
32
  'openai' => 'OPENAI_API_KEY',
33
33
  'gemini' => 'GEMINI_API_KEY',
34
34
  'openrouter' => 'OPENROUTER_API_KEY'
35
- }.freeze
35
+ }.freeze #: Hash[String, String]
36
36
 
37
37
  # Returns the API key for +provider+, checking the environment variable first,
38
38
  # then the stored file. Returns +nil+ if no key is available.
39
+ #
40
+ # @rbs provider: String?
41
+ # @rbs path: String
42
+ # @rbs return: String?
39
43
  def api_key_for(provider, path: PATH)
40
- env_var = PROVIDER_ENV_VARS[provider]
44
+ env_var = provider && PROVIDER_ENV_VARS[provider]
41
45
  key = env_var && ENV.fetch(env_var, nil).then { |v| v unless v.nil? || v.strip.empty? }
42
46
  key || key_from_file(provider, path)
43
47
  end
44
48
 
45
49
  # Saves an API key for +provider+ to the auth file with 0600 permissions.
50
+ #
51
+ # @rbs provider: String
52
+ # @rbs key: String
53
+ # @rbs path: String
54
+ # @rbs return: String
46
55
  def save_api_key(provider, key, path: PATH)
47
56
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
48
57
  data = read(path).tap { |h| h[provider] = key }
@@ -54,11 +63,22 @@ module Riffer::Rig::Credentials
54
63
 
55
64
  private
56
65
 
66
+ # @rbs provider: String?
67
+ # @rbs path: String
68
+ # @rbs return: String?
57
69
  def key_from_file(provider, path)
58
70
  key = read(path)[provider]
59
- key unless key.nil? || key.strip.empty?
71
+ return nil unless key.is_a?(String)
72
+ return key unless key.strip.empty?
73
+
74
+ nil
60
75
  end
61
76
 
77
+ # The parsed auth file, or an empty hash when the file is absent or
78
+ # malformed.
79
+ #
80
+ # @rbs path: String
81
+ # @rbs return: Hash[String, untyped]
62
82
  def read(path)
63
83
  return {} unless File.file?(path)
64
84
 
@@ -1,20 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Riffer::Rig::REPL
4
- EXIT_COMMANDS = ['/exit', '/quit'].freeze
5
- SKILL_COMMAND = %r{\A/skill:([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+(.*))?\z}m
6
-
7
- def initialize(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output), animator: Riffer::Rig::UI::Animator.new(io: output, theme:), smoother: Riffer::Rig::UI::Smoother.new(io: output, theme:))
4
+ EXIT_COMMANDS = ['/exit', '/quit'].freeze #: Array[String]
5
+
6
+ SKILL_COMMAND = %r{\A/skill:([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+(.*))?\z}m #: Regexp
7
+
8
+ # @rbs @agent: Riffer::Agent
9
+ # @rbs @renderer: Riffer::Rig::UI::Renderer
10
+ # @rbs @animator: Riffer::Rig::UI::Animator
11
+ # @rbs @smoother: Riffer::Rig::UI::Smoother
12
+ # @rbs @cursor: Riffer::Rig::UI::Cursor
13
+ # @rbs @theme: Riffer::Rig::UI::Theme
14
+ # @rbs @input: untyped
15
+ # @rbs @output: untyped
16
+ # @rbs @pending_tool_calls: Array[Riffer::StreamEvents::ToolCallDone]
17
+
18
+ # @rbs ?agent: Riffer::Agent
19
+ # @rbs ?renderer: Riffer::Rig::UI::Renderer
20
+ # @rbs input: untyped
21
+ # @rbs output: untyped
22
+ # @rbs ?theme: Riffer::Rig::UI::Theme
23
+ # @rbs ?animator: Riffer::Rig::UI::Animator
24
+ # @rbs ?smoother: Riffer::Rig::UI::Smoother
25
+ # @rbs ?cursor: Riffer::Rig::UI::Cursor
26
+ # @rbs return: void
27
+ def initialize(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output), animator: Riffer::Rig::UI::Animator.new(io: output, theme:), smoother: Riffer::Rig::UI::Smoother.new(io: output, theme:), cursor: Riffer::Rig::UI::Cursor.new(io: output, theme:))
8
28
  @agent = agent
9
29
  @renderer = renderer
10
30
  @animator = animator
11
31
  @smoother = smoother
32
+ @cursor = cursor
12
33
  @theme = theme
13
34
  @input = input
14
35
  @output = output
36
+ @pending_tool_calls = []
15
37
  @agent.session.on_message { |message| render_tool_result(message) }
16
38
  end
17
39
 
40
+ # @rbs return: Symbol
18
41
  def run
19
42
  loop do
20
43
  @output.print("\n#{@theme.pink('›')} ")
@@ -35,11 +58,15 @@ class Riffer::Rig::REPL
35
58
  end
36
59
 
37
60
  @output.puts("\n#{@theme.grey('see you on the next riff.')}")
61
+ :done
38
62
  end
39
63
 
40
64
  private
41
65
 
66
+ # @rbs prompt: String
67
+ # @rbs return: void
42
68
  def run_turn(prompt)
69
+ @cursor.hide
43
70
  @animator.start
44
71
  @smoother.start
45
72
  @agent.stream(prompt).each do |event|
@@ -52,28 +79,44 @@ class Riffer::Rig::REPL
52
79
  # Round bookkeeping: nothing renders, so the indicator just carries on
53
80
  # (or stays parked while the smoother finishes typing earlier text).
54
81
  next
55
- when Riffer::StreamEvents::ToolCallDone, Riffer::StreamEvents::SkillActivation, Riffer::StreamEvents::TokenUsageDone
82
+ when Riffer::StreamEvents::ToolCallDone
83
+ # Tool call lines are held back so the round's stats line can print
84
+ # above them (see flush_pending_tool_calls).
85
+ @pending_tool_calls << event
86
+ next
87
+ when Riffer::StreamEvents::SkillActivation, Riffer::StreamEvents::TokenUsageDone
56
88
  # The spinner shares its line with what's about to print, and tool
57
89
  # execution plus the next model invocation emit no events — stop it for
58
90
  # the render, then bring it straight back to cover the silent stretch.
91
+ # Stats print before any pending tool call lines (see below).
59
92
  @animator.stop
60
93
  @renderer.render(event)
94
+ flush_pending_tool_calls
61
95
  @animator.start
62
96
  next
63
97
  else
64
98
  @animator.stop
65
99
  end
100
+ flush_pending_tool_calls
66
101
  @renderer.render(event)
67
102
  end
103
+ @animator.stop
104
+ flush_pending_tool_calls
68
105
  @smoother.finish
69
106
  @output.puts
70
107
  rescue StandardError => e
108
+ @animator.stop
109
+ flush_pending_tool_calls
71
110
  @output.puts("\nError: #{e.message}")
72
111
  ensure
73
112
  @smoother.finish
74
113
  @animator.stop
114
+ @cursor.show
75
115
  end
76
116
 
117
+ # @rbs name: String
118
+ # @rbs args: String
119
+ # @rbs return: void
77
120
  def run_skill_command(name, args)
78
121
  block = activate_skill(name)
79
122
  return if block.nil?
@@ -81,6 +124,8 @@ class Riffer::Rig::REPL
81
124
  run_turn([block, args].reject(&:empty?).join("\n\n"))
82
125
  end
83
126
 
127
+ # @rbs name: String
128
+ # @rbs return: String?
84
129
  def activate_skill(name)
85
130
  skills = @agent.context.skills
86
131
 
@@ -103,13 +148,33 @@ class Riffer::Rig::REPL
103
148
  nil
104
149
  end
105
150
 
151
+ # @rbs name: String
152
+ # @rbs body: String
153
+ # @rbs return: String
106
154
  def skill_block(name, body)
107
155
  "<skill name=\"#{name}\">\n#{body}\n</skill>"
108
156
  end
109
157
 
158
+ # Tool call lines print below their round's stats line to reflect the actual
159
+ # timeline (usage is reported when the model stream closes, after the calls
160
+ # were decided). ToolCallDone events are therefore held until the stats line
161
+ # or the next event forces them out. Callers must have the spinner stopped —
162
+ # the flush renders between their existing stop/start pair.
163
+ #
164
+ # @rbs return: void
165
+ def flush_pending_tool_calls
166
+ return if @pending_tool_calls.empty?
167
+
168
+ @pending_tool_calls.each { |event| @renderer.render(event) }
169
+ @pending_tool_calls = []
170
+ end
171
+
110
172
  # Tool results can land mid-animation (tool execution emits no stream events,
111
173
  # so the indicator is up); stop it around the line so its next frame doesn't
112
174
  # erase what we printed.
175
+ #
176
+ # @rbs message: Riffer::Messages::Base
177
+ # @rbs return: void
113
178
  def render_tool_result(message)
114
179
  return unless message.is_a?(Riffer::Messages::Tool)
115
180
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # USD per million tokens, parsed from a model's entry in the settings file.
4
+ class Riffer::Rig::Settings::Pricing
5
+ # @dynamic input, output, cache_write, cache_read
6
+ attr_reader :input, :output, :cache_write, :cache_read #: Float
7
+
8
+ # @rbs input: Float
9
+ # @rbs output: Float
10
+ # @rbs cache_write: Float
11
+ # @rbs cache_read: Float
12
+ # @rbs return: void
13
+ def initialize(input:, output:, cache_write:, cache_read:)
14
+ @input = input
15
+ @output = output
16
+ @cache_write = cache_write
17
+ @cache_read = cache_read
18
+ end
19
+ end
@@ -36,22 +36,29 @@ require 'fileutils'
36
36
  module Riffer::Rig::Settings
37
37
  extend self
38
38
 
39
- PATH = File.expand_path('~/.riffer/settings.json')
39
+ PATH = File.expand_path('~/.riffer/settings.json') #: String
40
+
40
41
  DEFAULT_MODEL = 'anthropic/claude-sonnet-4-6'
41
42
 
42
43
  REASONING_LEVELS_BY_PROVIDER = {
43
44
  'anthropic' => %w[low medium high xhigh max].freeze,
44
45
  'openai' => %w[low medium high xhigh].freeze,
45
46
  'openrouter' => %w[low medium high xhigh].freeze
46
- }.freeze
47
+ }.freeze #: Hash[String, Array[String]]
47
48
 
48
49
  # Returns the configured model string, or +DEFAULT_MODEL+ if not set.
50
+ #
51
+ # @rbs path: String
52
+ # @rbs return: String
49
53
  def model(path: PATH)
50
54
  read(path).fetch('model', DEFAULT_MODEL)
51
55
  end
52
56
 
53
57
  # Returns model options for the configured model and reasoning level, ready
54
58
  # to pass directly to the Riffer agent's +model_options+.
59
+ #
60
+ # @rbs path: String
61
+ # @rbs return: Hash[Symbol, untyped]
55
62
  def model_options(path: PATH)
56
63
  provider = provider_for(model(path:))
57
64
  base_options(provider).merge(reasoning_options(reasoning_for(path:, provider:), provider))
@@ -60,34 +67,43 @@ module Riffer::Rig::Settings
60
67
  # Returns the provider prefix for +model_string+, e.g. <tt>"anthropic"</tt>
61
68
  # for <tt>"anthropic/claude-sonnet-4-6"</tt>. Returns +nil+ if the model
62
69
  # string contains no slash.
70
+ #
71
+ # @rbs model_string: String
72
+ # @rbs return: String?
63
73
  def provider_for(model_string)
64
74
  model_string.split('/', 2).first if model_string.include?('/')
65
75
  end
66
76
 
67
- # Returns the pricing hash for +model+, or +nil+ if not configured.
77
+ # Returns the pricing for +model+, or +nil+ if not configured.
68
78
  #
69
- # The returned hash has symbol keys +:input+, +:output+, +:cache_write+,
70
- # +:cache_read+ (all Float, USD per million tokens).
79
+ # @rbs model: String
80
+ # @rbs path: String
81
+ # @rbs return: Pricing?
71
82
  def pricing_for(model, path: PATH)
72
83
  entry = read(path).dig('models', model)
73
- return nil unless entry
84
+ return nil unless entry.is_a?(Hash)
74
85
 
75
- {
86
+ Pricing.new(
76
87
  input: entry.fetch('input', 0).to_f,
77
88
  output: entry.fetch('output', 0).to_f,
78
89
  cache_write: entry.fetch('cache_write', 0).to_f,
79
90
  cache_read: entry.fetch('cache_read', 0).to_f
80
- }
91
+ )
81
92
  end
82
93
 
83
94
  private
84
95
 
96
+ # @rbs provider: String?
97
+ # @rbs return: Hash[Symbol, untyped]
85
98
  def base_options(provider)
86
99
  return { cache_control: { type: :ephemeral } } if provider == 'anthropic'
87
100
 
88
101
  {}
89
102
  end
90
103
 
104
+ # @rbs path: String
105
+ # @rbs ?provider: String?
106
+ # @rbs return: String?
91
107
  def reasoning_for(path: PATH, provider: nil)
92
108
  level = read(path)['reasoning']
93
109
  valid_levels = (provider && REASONING_LEVELS_BY_PROVIDER[provider]) || []
@@ -96,6 +112,10 @@ module Riffer::Rig::Settings
96
112
 
97
113
  # Maps a reasoning level to the provider-specific model option hash expected
98
114
  # by Riffer. Returns an empty hash when +level+ is +nil+.
115
+ #
116
+ # @rbs level: String?
117
+ # @rbs provider: String?
118
+ # @rbs return: Hash[Symbol, untyped]
99
119
  def reasoning_options(level, provider)
100
120
  return {} unless level
101
121
 
@@ -109,6 +129,11 @@ module Riffer::Rig::Settings
109
129
  end
110
130
  end
111
131
 
132
+ # The parsed settings file, or an empty hash when the file is absent or
133
+ # malformed.
134
+ #
135
+ # @rbs path: String
136
+ # @rbs return: Hash[String, untyped]
112
137
  def read(path)
113
138
  return {} unless File.file?(path)
114
139
 
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Accumulates token usage across multiple turns in a session and computes an
4
- # estimated cost based on a pricing hash sourced from +Riffer::Rig::Settings+.
4
+ # estimated cost based on a pricing object sourced from
5
+ # +Riffer::Rig::Settings+.
5
6
  #
6
7
  # Instantiate once per REPL session, call +add+ after each turn, then read
7
8
  # +total_tokens+ and +estimated_cost+ for display.
@@ -12,8 +13,17 @@
12
13
  # tally.estimated_cost # => 0.000042 (USD), or nil if pricing is nil
13
14
  #
14
15
  class Riffer::Rig::TokenTally
16
+ # @rbs @input_tokens: Integer
17
+ # @rbs @output_tokens: Integer
18
+ # @rbs @cache_write_tokens: Integer
19
+ # @rbs @cache_read_tokens: Integer
20
+ # @rbs @pricing: Riffer::Rig::Settings::Pricing?
21
+
22
+ # @dynamic input_tokens, output_tokens, cache_write_tokens, cache_read_tokens
15
23
  attr_reader :input_tokens, :output_tokens, :cache_write_tokens, :cache_read_tokens
16
24
 
25
+ # @rbs ?pricing: Riffer::Rig::Settings::Pricing?
26
+ # @rbs return: void
17
27
  def initialize(pricing: nil)
18
28
  @pricing = pricing
19
29
  @input_tokens = 0
@@ -24,6 +34,8 @@ class Riffer::Rig::TokenTally
24
34
 
25
35
  # Accumulates token counts from a +Riffer::Providers::TokenUsage+ object.
26
36
  #
37
+ # @rbs usage: Riffer::Providers::TokenUsage
38
+ # @rbs return: void
27
39
  def add(usage)
28
40
  @input_tokens += usage.input_tokens
29
41
  @output_tokens += usage.output_tokens
@@ -33,26 +45,30 @@ class Riffer::Rig::TokenTally
33
45
 
34
46
  # Returns the total token count across all categories.
35
47
  #
48
+ # @rbs return: Integer
36
49
  def total_tokens
37
50
  @input_tokens + @output_tokens + @cache_write_tokens + @cache_read_tokens
38
51
  end
39
52
 
40
53
  # Returns +true+ if any tokens have been counted.
41
54
  #
55
+ # @rbs return: bool
42
56
  def any?
43
57
  total_tokens.positive?
44
58
  end
45
59
 
46
60
  # Returns the estimated cost in USD, or +nil+ if no pricing was provided.
47
61
  #
62
+ # @rbs return: Float?
48
63
  def estimated_cost
49
- return nil unless @pricing
64
+ pricing = @pricing
65
+ return nil unless pricing
50
66
 
51
67
  (
52
- (@input_tokens * @pricing[:input]) +
53
- (@output_tokens * @pricing[:output]) +
54
- (@cache_write_tokens * @pricing[:cache_write]) +
55
- (@cache_read_tokens * @pricing[:cache_read])
68
+ (@input_tokens * pricing.input) +
69
+ (@output_tokens * pricing.output) +
70
+ (@cache_write_tokens * pricing.cache_write) +
71
+ (@cache_read_tokens * pricing.cache_read)
56
72
  ) / 1_000_000.0
57
73
  end
58
74
  end
@@ -9,14 +9,19 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
9
9
 
10
10
  timeout 600
11
11
 
12
- DEFAULT_TIMEOUT_MS = 120_000
13
- MAX_OUTPUT_BYTES = 30_000
12
+ DEFAULT_TIMEOUT_MS = 120_000 #: Integer
13
+
14
+ MAX_OUTPUT_BYTES = 30_000 #: Integer
14
15
 
15
16
  params do
16
17
  required :command, String, description: 'The shell command to run'
17
18
  optional :timeout_ms, Integer, description: 'Kill the command after this many milliseconds', default: DEFAULT_TIMEOUT_MS
18
19
  end
19
20
 
21
+ # @rbs context: Riffer::Agent::Context?
22
+ # @rbs command: String
23
+ # @rbs ?timeout_ms: Integer
24
+ # @rbs return: Riffer::Tools::Response
20
25
  def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
21
26
  output, status = run(command, timeout_ms / 1000.0)
22
27
  output = truncate(output.rstrip)
@@ -28,11 +33,16 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
28
33
 
29
34
  private
30
35
 
36
+ # @rbs command: String
37
+ # @rbs timeout_seconds: Float
38
+ # @rbs return: [String, Integer]
31
39
  def run(command, timeout_seconds)
32
40
  stdin, stdout_and_stderr, wait_thread = Open3.popen2e(command, chdir: Dir.pwd, pgroup: true)
33
41
  stdin.close
34
42
 
35
- unless wait_thread.join(timeout_seconds)
43
+ joined = wait_thread.join(timeout_seconds) #: Thread?
44
+
45
+ unless joined
36
46
  kill_group(wait_thread.pid)
37
47
  output = stdout_and_stderr.read
38
48
  stdout_and_stderr.close
@@ -44,12 +54,17 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
44
54
  [output, wait_thread.value.exitstatus || 1]
45
55
  end
46
56
 
57
+ # @rbs pid: Integer
58
+ # @rbs return: Integer
47
59
  def kill_group(pid)
48
60
  Process.kill('TERM', -Process.getpgid(pid))
61
+ Process.getpgid(pid)
49
62
  rescue Errno::ESRCH, Errno::EPERM
50
- nil
63
+ 0
51
64
  end
52
65
 
66
+ # @rbs output: String
67
+ # @rbs return: String
53
68
  def truncate(output)
54
69
  return output if output.bytesize <= MAX_OUTPUT_BYTES
55
70
 
@@ -12,6 +12,12 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
12
12
  optional :replace_all, Riffer::Params::Boolean, description: 'Replace all occurrences instead of requiring a unique match', default: false
13
13
  end
14
14
 
15
+ # @rbs context: Riffer::Agent::Context?
16
+ # @rbs path: String
17
+ # @rbs old_string: String
18
+ # @rbs new_string: String
19
+ # @rbs ?replace_all: bool
20
+ # @rbs return: Riffer::Tools::Response
15
21
  def call(context:, path:, old_string:, new_string:, replace_all: false)
16
22
  resolved = File.expand_path(path, Dir.pwd)
17
23
  return error("File not found: #{path}", type: :not_found) unless File.file?(resolved)
@@ -5,7 +5,7 @@ class Riffer::Rig::Tools::Read < Riffer::Tool
5
5
  description 'Read a file from the filesystem. Returns its contents with line numbers. ' \
6
6
  'Use offset/limit to read a slice of a large file.'
7
7
 
8
- DEFAULT_LIMIT = 2000
8
+ DEFAULT_LIMIT = 2000 #: Integer
9
9
 
10
10
  params do
11
11
  required :path, String, description: 'Path to the file (absolute, or relative to the working directory)'
@@ -13,6 +13,11 @@ class Riffer::Rig::Tools::Read < Riffer::Tool
13
13
  optional :limit, Integer, description: 'Maximum number of lines to read', default: DEFAULT_LIMIT
14
14
  end
15
15
 
16
+ # @rbs context: Riffer::Agent::Context?
17
+ # @rbs path: String
18
+ # @rbs ?offset: Integer?
19
+ # @rbs ?limit: Integer
20
+ # @rbs return: Riffer::Tools::Response
16
21
  def call(context:, path:, offset: nil, limit: DEFAULT_LIMIT)
17
22
  resolved = File.expand_path(path, Dir.pwd)
18
23
  return error("File not found: #{path}", type: :not_found) unless File.file?(resolved)
@@ -11,6 +11,10 @@ class Riffer::Rig::Tools::Write < Riffer::Tool
11
11
  required :content, String, description: 'The full content to write to the file'
12
12
  end
13
13
 
14
+ # @rbs context: Riffer::Agent::Context?
15
+ # @rbs path: String
16
+ # @rbs content: String
17
+ # @rbs return: Riffer::Tools::Response
14
18
  def call(context:, path:, content:)
15
19
  resolved = File.expand_path(path, Dir.pwd)
16
20
  FileUtils.mkdir_p(File.dirname(resolved))