riffer-rig 0.2.1 → 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 +12 -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 +65 -4
  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 +14 -2
  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 +29 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 935cb9bdd3c8aee780466f38ac5ee4122b22e63718140399df5f94e1c0d4dd26
4
- data.tar.gz: 3ddc6c58d14e306f12a6d14f86d958df336f718a4d4c0b12be19ee34e9736dad
3
+ metadata.gz: 01f34d2a4cb9347ea2d9f7a81a0969415c98b6622d4f78e123034f9b2c15509e
4
+ data.tar.gz: 5a61c231fcf2832a514ec62513bdf42e56c87dc08ec994f9fb04fa263188b53d
5
5
  SHA512:
6
- metadata.gz: 629aa97a63e9437f086f36e373c1720965936f86aa5d4dfbc9d546143a42c8500bd89e3b820a655fcc848df53e25903c57ad1b744d9b4c33975b7858db714cef
7
- data.tar.gz: 6e73d9d8d1f6d2ef95b5692e1a506eb2b7440937475a873a3db73713d130591b1f47926bec7c34ff57218f2c230f167077160ce22358bd508ae7a0fe36be5196
6
+ metadata.gz: 1b8518928f77909373cfdfe2783372aa1a6ebb04fed67a70951041c0adf637bf3aba0ee8b85051f9bdb63c1cdcda91554b2fd9c86b4f82a279eed30964984588
7
+ data.tar.gz: 3cd5548405bcf8607d516e7f8a585cdf327cfc904e1641458aca4802e8a3654127c6ed44a46435003faeca1eaaafcd4bed0968c0787a90aca8c586438b685146
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## [0.2.1](https://github.com/bottrall/riffer-rig/compare/v0.2.0...v0.2.1) (2026-09-08)
4
16
 
5
17
 
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,9 +1,29 @@
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
-
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
7
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
@@ -13,9 +33,11 @@ class Riffer::Rig::REPL
13
33
  @theme = theme
14
34
  @input = input
15
35
  @output = output
36
+ @pending_tool_calls = []
16
37
  @agent.session.on_message { |message| render_tool_result(message) }
17
38
  end
18
39
 
40
+ # @rbs return: Symbol
19
41
  def run
20
42
  loop do
21
43
  @output.print("\n#{@theme.pink('›')} ")
@@ -36,10 +58,13 @@ class Riffer::Rig::REPL
36
58
  end
37
59
 
38
60
  @output.puts("\n#{@theme.grey('see you on the next riff.')}")
61
+ :done
39
62
  end
40
63
 
41
64
  private
42
65
 
66
+ # @rbs prompt: String
67
+ # @rbs return: void
43
68
  def run_turn(prompt)
44
69
  @cursor.hide
45
70
  @animator.start
@@ -54,23 +79,34 @@ class Riffer::Rig::REPL
54
79
  # Round bookkeeping: nothing renders, so the indicator just carries on
55
80
  # (or stays parked while the smoother finishes typing earlier text).
56
81
  next
57
- 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
58
88
  # The spinner shares its line with what's about to print, and tool
59
89
  # execution plus the next model invocation emit no events — stop it for
60
90
  # the render, then bring it straight back to cover the silent stretch.
91
+ # Stats print before any pending tool call lines (see below).
61
92
  @animator.stop
62
93
  @renderer.render(event)
94
+ flush_pending_tool_calls
63
95
  @animator.start
64
96
  next
65
97
  else
66
98
  @animator.stop
67
99
  end
100
+ flush_pending_tool_calls
68
101
  @renderer.render(event)
69
102
  end
70
103
  @animator.stop
104
+ flush_pending_tool_calls
71
105
  @smoother.finish
72
106
  @output.puts
73
107
  rescue StandardError => e
108
+ @animator.stop
109
+ flush_pending_tool_calls
74
110
  @output.puts("\nError: #{e.message}")
75
111
  ensure
76
112
  @smoother.finish
@@ -78,6 +114,9 @@ class Riffer::Rig::REPL
78
114
  @cursor.show
79
115
  end
80
116
 
117
+ # @rbs name: String
118
+ # @rbs args: String
119
+ # @rbs return: void
81
120
  def run_skill_command(name, args)
82
121
  block = activate_skill(name)
83
122
  return if block.nil?
@@ -85,6 +124,8 @@ class Riffer::Rig::REPL
85
124
  run_turn([block, args].reject(&:empty?).join("\n\n"))
86
125
  end
87
126
 
127
+ # @rbs name: String
128
+ # @rbs return: String?
88
129
  def activate_skill(name)
89
130
  skills = @agent.context.skills
90
131
 
@@ -107,13 +148,33 @@ class Riffer::Rig::REPL
107
148
  nil
108
149
  end
109
150
 
151
+ # @rbs name: String
152
+ # @rbs body: String
153
+ # @rbs return: String
110
154
  def skill_block(name, body)
111
155
  "<skill name=\"#{name}\">\n#{body}\n</skill>"
112
156
  end
113
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
+
114
172
  # Tool results can land mid-animation (tool execution emits no stream events,
115
173
  # so the indicator is up); stop it around the line so its next frame doesn't
116
174
  # erase what we printed.
175
+ #
176
+ # @rbs message: Riffer::Messages::Base
177
+ # @rbs return: void
117
178
  def render_tool_result(message)
118
179
  return unless message.is_a?(Riffer::Messages::Tool)
119
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))