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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +2 -2
- data/lib/riffer/rig/cli.rb +21 -1
- data/lib/riffer/rig/coding_agent.rb +7 -5
- data/lib/riffer/rig/credentials.rb +24 -4
- data/lib/riffer/rig/repl.rb +70 -5
- data/lib/riffer/rig/settings/pricing.rb +19 -0
- data/lib/riffer/rig/settings.rb +33 -8
- data/lib/riffer/rig/token_tally.rb +22 -6
- data/lib/riffer/rig/tools/bash.rb +19 -4
- data/lib/riffer/rig/tools/edit.rb +6 -0
- data/lib/riffer/rig/tools/read.rb +6 -1
- data/lib/riffer/rig/tools/write.rb +4 -0
- data/lib/riffer/rig/ui/animator.rb +51 -11
- data/lib/riffer/rig/ui/banner.rb +28 -4
- data/lib/riffer/rig/ui/cursor.rb +46 -0
- data/lib/riffer/rig/ui/palette.rb +13 -7
- data/lib/riffer/rig/ui/renderer.rb +38 -7
- data/lib/riffer/rig/ui/smoother.rb +31 -5
- data/lib/riffer/rig/ui/theme.rb +38 -7
- data/lib/riffer/rig/version.rb +1 -1
- data/sig/generated/riffer/rig/cli.rbs +41 -0
- data/sig/generated/riffer/rig/coding_agent.rbs +9 -0
- data/sig/generated/riffer/rig/credentials.rbs +55 -0
- data/sig/generated/riffer/rig/repl.rbs +76 -0
- data/sig/generated/riffer/rig/settings/pricing.rbs +23 -0
- data/sig/generated/riffer/rig/settings.rbs +92 -0
- data/sig/generated/riffer/rig/token_tally.rbs +61 -0
- data/sig/generated/riffer/rig/tools/bash.rbs +28 -0
- data/sig/generated/riffer/rig/tools/edit.rbs +11 -0
- data/sig/generated/riffer/rig/tools/read.rbs +12 -0
- data/sig/generated/riffer/rig/tools/write.rbs +9 -0
- data/sig/generated/riffer/rig/ui/animator.rbs +76 -0
- data/sig/generated/riffer/rig/ui/banner.rbs +42 -0
- data/sig/generated/riffer/rig/ui/cursor.rbs +33 -0
- data/sig/generated/riffer/rig/ui/palette.rbs +17 -0
- data/sig/generated/riffer/rig/ui/renderer.rbs +68 -0
- data/sig/generated/riffer/rig/ui/smoother.rbs +67 -0
- data/sig/generated/riffer/rig/ui/theme.rbs +60 -0
- data/sig/generated/riffer/rig/version.rbs +7 -0
- data/sig/generated/riffer/rig.rbs +2 -0
- data/sig/manual/riffer/rig/cli.rbs +8 -0
- data/sig/manual/riffer/rig/credentials.rbs +8 -0
- data/sig/manual/riffer/rig/settings.rbs +8 -0
- data/sig/manual/riffer/rig/tools.rbs +5 -0
- data/sig/manual/riffer/rig/ui/banner.rbs +8 -0
- data/sig/manual/riffer/rig/ui.rbs +5 -0
- metadata +30 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01f34d2a4cb9347ea2d9f7a81a0969415c98b6622d4f78e123034f9b2c15509e
|
|
4
|
+
data.tar.gz: 5a61c231fcf2832a514ec62513bdf42e56c87dc08ec994f9fb04fa263188b53d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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 |
|
data/lib/riffer/rig/cli.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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:
|
|
45
|
+
- write: write content to a file
|
|
44
46
|
- edit: replace an exact string in a file
|
|
45
|
-
- bash: run a shell command
|
|
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
|
-
|
|
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
|
|
data/lib/riffer/rig/repl.rb
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
data/lib/riffer/rig/settings.rb
CHANGED
|
@@ -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
|
|
77
|
+
# Returns the pricing for +model+, or +nil+ if not configured.
|
|
68
78
|
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
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
|
|
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
|
-
|
|
64
|
+
pricing = @pricing
|
|
65
|
+
return nil unless pricing
|
|
50
66
|
|
|
51
67
|
(
|
|
52
|
-
(@input_tokens *
|
|
53
|
-
(@output_tokens *
|
|
54
|
-
(@cache_write_tokens *
|
|
55
|
-
(@cache_read_tokens *
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|