riffer-rig 0.2.1 → 0.4.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 +24 -0
  3. data/README.md +2 -2
  4. data/lib/riffer/rig/cli.rb +31 -2
  5. data/lib/riffer/rig/coding_agent.rb +35 -31
  6. data/lib/riffer/rig/credentials.rb +24 -4
  7. data/lib/riffer/rig/repl.rb +75 -22
  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 +23 -5
  12. data/lib/riffer/rig/tools/edit.rb +16 -2
  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 +137 -29
  20. data/lib/riffer/rig/ui/smoother.rb +56 -14
  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 +68 -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 +116 -0
  39. data/sig/generated/riffer/rig/ui/smoother.rbs +72 -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 +31 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 935cb9bdd3c8aee780466f38ac5ee4122b22e63718140399df5f94e1c0d4dd26
4
- data.tar.gz: 3ddc6c58d14e306f12a6d14f86d958df336f718a4d4c0b12be19ee34e9736dad
3
+ metadata.gz: e833f2a4dfe0b87598e2be9d585f3cb772562ab39a2dd528684a38dca87f63ab
4
+ data.tar.gz: f469b328103a49e52773c0073e02d8c2fbd06d8cf6779daa50c09b49ab2eba8e
5
5
  SHA512:
6
- metadata.gz: 629aa97a63e9437f086f36e373c1720965936f86aa5d4dfbc9d546143a42c8500bd89e3b820a655fcc848df53e25903c57ad1b744d9b4c33975b7858db714cef
7
- data.tar.gz: 6e73d9d8d1f6d2ef95b5692e1a506eb2b7440937475a873a3db73713d130591b1f47926bec7c34ff57218f2c230f167077160ce22358bd508ae7a0fe36be5196
6
+ metadata.gz: 696e8b92a7c1b7a3b71e1f628894e89aed725fbe072dba712d94df277fc5730b06d74def094445b02842d0849409b7900038aedf5d65f29f5416d1450a5b5121
7
+ data.tar.gz: 3dc42ac6e625d292462e00119bd7b3c7a8d1f23ad3c4f66763fff96cbfc3328f962dca67bdc4613e884ba81a6e3f61d9af86f1ba1d8bbd16e0f3caa307ca453c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/bottrall/riffer-rig/compare/v0.3.0...v0.4.0) (2026-09-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **ui:** one blank line between blocks, prompt-owned gaps ([#170](https://github.com/bottrall/riffer-rig/issues/170)) ([636e4d8](https://github.com/bottrall/riffer-rig/commit/636e4d82a069f158775043037a43e2aa494f56dc))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **lint:** adjust rubocop rules and reformat to match ([#168](https://github.com/bottrall/riffer-rig/issues/168)) ([56b81aa](https://github.com/bottrall/riffer-rig/commit/56b81aa904609f246340c8b8135ee7e57791abdd))
14
+
15
+ ## [0.3.0](https://github.com/bottrall/riffer-rig/compare/v0.2.1...v0.3.0) (2026-09-10)
16
+
17
+
18
+ ### Features
19
+
20
+ * **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))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * 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))
26
+
3
27
  ## [0.2.1](https://github.com/bottrall/riffer-rig/compare/v0.2.0...v0.2.1) (2026-09-08)
4
28
 
5
29
 
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,23 +79,41 @@ 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(', ')
84
103
 
85
- animator.reveal([Riffer::Rig::UI::Banner.lines(theme, model: model, cwd: Dir.pwd, context: context, skills: count_skills, version: Riffer::Rig::VERSION)])
104
+ animator.reveal(
105
+ [Riffer::Rig::UI::Banner.lines(
106
+ theme,
107
+ model: model,
108
+ cwd: Dir.pwd,
109
+ context: context,
110
+ skills: count_skills,
111
+ version: Riffer::Rig::VERSION
112
+ )]
113
+ )
86
114
  end
87
115
 
116
+ # @rbs return: String
88
117
  def count_skills
89
118
  dirs = [Riffer::Rig::CodingAgent::GLOBAL_SKILLS_DIR, Riffer::Rig::CodingAgent::PROJECT_SKILLS_DIR.call]
90
119
  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
@@ -23,32 +25,34 @@ class Riffer::Rig::CodingAgent < Riffer::Agent
23
25
 
24
26
  max_steps nil
25
27
 
26
- instructions(lambda do
27
- context_files = [GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')]
28
- .select { |path| File.file?(path) }
29
- .map { |path| "<project_instructions path=\"#{path}\">\n#{File.read(path)}\n</project_instructions>" }
30
-
31
- project_context =
32
- if context_files.empty?
33
- ''
34
- else
35
- "\n\n<project_context>\n#{context_files.join("\n\n")}\n</project_context>"
36
- end
37
-
38
- <<~PROMPT.chomp + project_context + "\n\nCurrent date: #{Date.today}\nCurrent working directory: #{Dir.pwd}"
39
- You are an expert coding assistant running inside riffer-rig, a terminal coding agent. You help the user by reading files, running shell commands, editing code, and writing new files.
40
-
41
- Available tools:
42
- - read: read a file's contents
43
- - write: create or overwrite a file
44
- - edit: replace an exact string in a file
45
- - bash: run a shell command (use it for ls, rg/grep, find, tests, git, etc.)
46
-
47
- Guidelines:
48
- - Be concise and direct in your responses.
49
- - Show file paths clearly when working with files.
50
- - Use bash for file exploration (ls, rg, find) rather than guessing.
51
- - Prefer editing existing files over creating new ones.
52
- PROMPT
53
- end)
28
+ instructions(
29
+ lambda do
30
+ context_files = [GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')]
31
+ .select { |path| File.file?(path) }
32
+ .map { |path| "<project_instructions path=\"#{path}\">\n#{File.read(path)}\n</project_instructions>" }
33
+
34
+ project_context =
35
+ if context_files.empty?
36
+ ''
37
+ else
38
+ "\n\n<project_context>\n#{context_files.join("\n\n")}\n</project_context>"
39
+ end
40
+
41
+ <<~PROMPT.chomp + project_context + "\n\nCurrent date: #{Date.today}\nCurrent working directory: #{Dir.pwd}"
42
+ You are an expert coding assistant running inside riffer-rig, a terminal coding agent. You help the user by reading files, running shell commands, editing code, and writing new files.
43
+
44
+ Available tools:
45
+ - read: read a file's contents
46
+ - write: write content to a file
47
+ - edit: replace an exact string in a file
48
+ - bash: run a shell command in the working directory
49
+
50
+ Guidelines:
51
+ - Be concise and direct in your responses.
52
+ - Show file paths clearly when working with files.
53
+ - Use bash for file exploration (ls, rg, find) rather than guessing.
54
+ - Prefer editing existing files over creating new ones.
55
+ PROMPT
56
+ end
57
+ )
54
58
  end
@@ -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,10 +1,38 @@
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:), cursor: Riffer::Rig::UI::Cursor.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
+
17
+ # @rbs ?agent: Riffer::Agent
18
+ # @rbs ?renderer: Riffer::Rig::UI::Renderer
19
+ # @rbs input: untyped
20
+ # @rbs output: untyped
21
+ # @rbs ?theme: Riffer::Rig::UI::Theme
22
+ # @rbs ?animator: Riffer::Rig::UI::Animator
23
+ # @rbs ?smoother: Riffer::Rig::UI::Smoother
24
+ # @rbs ?cursor: Riffer::Rig::UI::Cursor
25
+ # @rbs return: void
26
+ def initialize(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output),
27
+ animator: Riffer::Rig::UI::Animator.new(
28
+ io: output,
29
+ theme:
30
+ ), smoother: Riffer::Rig::UI::Smoother.new(
31
+ io: output,
32
+ theme:
33
+ ), cursor: Riffer::Rig::UI::Cursor.new(
34
+ io: output, theme:
35
+ ))
8
36
  @agent = agent
9
37
  @renderer = renderer
10
38
  @animator = animator
@@ -16,9 +44,10 @@ class Riffer::Rig::REPL
16
44
  @agent.session.on_message { |message| render_tool_result(message) }
17
45
  end
18
46
 
47
+ # @rbs return: Symbol
19
48
  def run
20
49
  loop do
21
- @output.print("\n#{@theme.pink('›')} ")
50
+ @renderer.prompt
22
51
  line = @input.gets
23
52
  break if line.nil?
24
53
 
@@ -35,11 +64,14 @@ class Riffer::Rig::REPL
35
64
  end
36
65
  end
37
66
 
38
- @output.puts("\n#{@theme.grey('see you on the next riff.')}")
67
+ print_block { @theme.grey('see you on the next riff.') }
68
+ :done
39
69
  end
40
70
 
41
71
  private
42
72
 
73
+ # @rbs prompt: String
74
+ # @rbs return: void
43
75
  def run_turn(prompt)
44
76
  @cursor.hide
45
77
  @animator.start
@@ -51,17 +83,20 @@ class Riffer::Rig::REPL
51
83
  when Riffer::StreamEvents::ReasoningDone
52
84
  @animator.start
53
85
  when Riffer::StreamEvents::ToolCallDelta, Riffer::StreamEvents::FinishReasonDone
54
- # Round bookkeeping: nothing renders, so the indicator just carries on
55
- # (or stays parked while the smoother finishes typing earlier text).
56
86
  next
57
- when Riffer::StreamEvents::ToolCallDone, Riffer::StreamEvents::SkillActivation, Riffer::StreamEvents::TokenUsageDone
58
- # The spinner shares its line with what's about to print, and tool
59
- # execution plus the next model invocation emit no events — stop it for
60
- # the render, then bring it straight back to cover the silent stretch.
87
+ when Riffer::StreamEvents::SkillActivation
88
+ # Tool execution and the next model invocation emit no events, so the
89
+ # spinner comes straight back on to cover the silent stretch.
61
90
  @animator.stop
62
91
  @renderer.render(event)
63
92
  @animator.start
64
93
  next
94
+ when Riffer::StreamEvents::TokenUsageDone
95
+ # Usage renders nothing inline — it flushes below the turn's output.
96
+ # Restarting here covers the silent tool-execution stretch that follows.
97
+ @renderer.render(event)
98
+ @animator.start
99
+ next
65
100
  else
66
101
  @animator.stop
67
102
  end
@@ -69,15 +104,18 @@ class Riffer::Rig::REPL
69
104
  end
70
105
  @animator.stop
71
106
  @smoother.finish
72
- @output.puts
107
+ @renderer.flush_usage
73
108
  rescue StandardError => e
74
- @output.puts("\nError: #{e.message}")
75
- ensure
76
109
  @smoother.finish
110
+ print_block { @theme.red("Error: #{e.message}") }
111
+ ensure
77
112
  @animator.stop
78
113
  @cursor.show
79
114
  end
80
115
 
116
+ # @rbs name: String
117
+ # @rbs args: String
118
+ # @rbs return: void
81
119
  def run_skill_command(name, args)
82
120
  block = activate_skill(name)
83
121
  return if block.nil?
@@ -85,11 +123,13 @@ class Riffer::Rig::REPL
85
123
  run_turn([block, args].reject(&:empty?).join("\n\n"))
86
124
  end
87
125
 
126
+ # @rbs name: String
127
+ # @rbs return: String?
88
128
  def activate_skill(name)
89
129
  skills = @agent.context.skills
90
130
 
91
131
  unless skills
92
- @output.puts(@theme.grey('No skills configured.'))
132
+ print_block { @theme.grey('No skills configured.') }
93
133
  return
94
134
  end
95
135
 
@@ -97,23 +137,36 @@ class Riffer::Rig::REPL
97
137
  # a non-mutating Context#read. `activate` marks the skill model-activated as
98
138
  # a side effect, which drops it from the model's catalog after manual use.
99
139
  body = skills.activate(name)
100
- @output.puts(@theme.magenta("✦ skill: #{name}"))
140
+ print_block { @theme.magenta("✦ skill: #{name}") }
101
141
  skill_block(name, body)
102
142
  rescue Riffer::ArgumentError
103
- @output.puts(@theme.red("Unknown skill: #{name}"))
143
+ print_block { @theme.red("Unknown skill: #{name}") }
104
144
  nil
105
145
  rescue StandardError => e
106
- @output.puts(@theme.red("Error activating skill: #{e.message}"))
146
+ print_block { @theme.red("Error activating skill: #{e.message}") }
107
147
  nil
108
148
  end
109
149
 
150
+ # @rbs name: String
151
+ # @rbs body: String
152
+ # @rbs return: String
110
153
  def skill_block(name, body)
111
154
  "<skill name=\"#{name}\">\n#{body}\n</skill>"
112
155
  end
113
156
 
114
- # Tool results can land mid-animation (tool execution emits no stream events,
115
- # so the indicator is up); stop it around the line so its next frame doesn't
116
- # erase what we printed.
157
+ # Chrome lines that never pass through the renderer (errors, exit line) end
158
+ # their line with a newline but leave the renderer's tool-group state alone —
159
+ # they can only follow prose, never mid-group.
160
+ #
161
+ # @rbs return: void
162
+ def print_block(&)
163
+ @output.puts
164
+ @output.puts(yield)
165
+ @output.flush
166
+ end
167
+
168
+ # @rbs message: Riffer::Messages::Base
169
+ # @rbs return: void
117
170
  def render_tool_result(message)
118
171
  return unless message.is_a?(Riffer::Messages::Tool)
119
172
 
@@ -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