riffer-rig 0.4.0 → 0.6.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +43 -6
  4. data/lib/riffer/rig/cli.rb +51 -47
  5. data/lib/riffer/rig/coding_agent.rb +1 -6
  6. data/lib/riffer/rig/credentials/resolution.rb +16 -0
  7. data/lib/riffer/rig/credentials.rb +151 -60
  8. data/lib/riffer/rig/env.rb +29 -0
  9. data/lib/riffer/rig/events/command_output.rb +27 -0
  10. data/lib/riffer/rig/events/event.rb +30 -0
  11. data/lib/riffer/rig/events/notify.rb +27 -0
  12. data/lib/riffer/rig/events/session_end.rb +24 -0
  13. data/lib/riffer/rig/events/session_start.rb +27 -0
  14. data/lib/riffer/rig/events/skill_activated.rb +24 -0
  15. data/lib/riffer/rig/events/turn_end.rb +32 -0
  16. data/lib/riffer/rig/events.rb +4 -0
  17. data/lib/riffer/rig/extension.rb +27 -0
  18. data/lib/riffer/rig/hosts/base.rb +36 -0
  19. data/lib/riffer/rig/hosts/mirror.rb +60 -0
  20. data/lib/riffer/rig/hosts/null.rb +36 -0
  21. data/lib/riffer/rig/provider_setup/field.rb +24 -0
  22. data/lib/riffer/rig/provider_setup.rb +84 -0
  23. data/lib/riffer/rig/registrar.rb +35 -0
  24. data/lib/riffer/rig/repl.rb +42 -31
  25. data/lib/riffer/rig/runtime/cancel_flag.rb +27 -0
  26. data/lib/riffer/rig/runtime.rb +225 -0
  27. data/lib/riffer/rig/settings/document.rb +28 -0
  28. data/lib/riffer/rig/settings/pricing.rb +11 -1
  29. data/lib/riffer/rig/settings.rb +49 -60
  30. data/lib/riffer/rig/token_tally.rb +1 -21
  31. data/lib/riffer/rig/tools/bash.rb +33 -12
  32. data/lib/riffer/rig/tools/edit.rb +1 -1
  33. data/lib/riffer/rig/tools/read.rb +2 -2
  34. data/lib/riffer/rig/ui/animator.rb +8 -15
  35. data/lib/riffer/rig/ui/cursor.rb +4 -6
  36. data/lib/riffer/rig/ui/renderer.rb +18 -31
  37. data/lib/riffer/rig/ui/smoother.rb +15 -23
  38. data/lib/riffer/rig/ui/theme.rb +4 -6
  39. data/lib/riffer/rig/version.rb +1 -1
  40. data/lib/riffer/rig.rb +26 -0
  41. data/sig/generated/riffer/rig/cli.rbs +27 -19
  42. data/sig/generated/riffer/rig/credentials/resolution.rbs +13 -0
  43. data/sig/generated/riffer/rig/credentials.rbs +71 -41
  44. data/sig/generated/riffer/rig/env.rbs +20 -0
  45. data/sig/generated/riffer/rig/events/command_output.rbs +19 -0
  46. data/sig/generated/riffer/rig/events/event.rbs +20 -0
  47. data/sig/generated/riffer/rig/events/notify.rbs +19 -0
  48. data/sig/generated/riffer/rig/events/session_end.rbs +16 -0
  49. data/sig/generated/riffer/rig/events/session_start.rbs +19 -0
  50. data/sig/generated/riffer/rig/events/skill_activated.rbs +16 -0
  51. data/sig/generated/riffer/rig/events/turn_end.rbs +22 -0
  52. data/sig/generated/riffer/rig/events.rbs +4 -0
  53. data/sig/generated/riffer/rig/extension.rbs +24 -0
  54. data/sig/generated/riffer/rig/hosts/base.rbs +26 -0
  55. data/sig/generated/riffer/rig/hosts/mirror.rbs +41 -0
  56. data/sig/generated/riffer/rig/hosts/null.rbs +26 -0
  57. data/sig/generated/riffer/rig/provider_setup/field.rbs +22 -0
  58. data/sig/generated/riffer/rig/provider_setup.rbs +30 -0
  59. data/sig/generated/riffer/rig/registrar.rbs +25 -0
  60. data/sig/generated/riffer/rig/repl.rbs +17 -16
  61. data/sig/generated/riffer/rig/runtime/cancel_flag.rbs +19 -0
  62. data/sig/generated/riffer/rig/runtime.rbs +120 -0
  63. data/sig/generated/riffer/rig/settings/document.rbs +24 -0
  64. data/sig/generated/riffer/rig/settings/pricing.rbs +4 -1
  65. data/sig/generated/riffer/rig/settings.rbs +28 -49
  66. data/sig/generated/riffer/rig/token_tally.rbs +2 -21
  67. data/sig/generated/riffer/rig/tools/bash.rbs +15 -3
  68. data/sig/generated/riffer/rig/tools/edit.rbs +2 -2
  69. data/sig/generated/riffer/rig/tools/read.rbs +3 -3
  70. data/sig/generated/riffer/rig/ui/animator.rbs +8 -16
  71. data/sig/generated/riffer/rig/ui/cursor.rbs +4 -6
  72. data/sig/generated/riffer/rig/ui/renderer.rbs +13 -35
  73. data/sig/generated/riffer/rig/ui/smoother.rbs +9 -22
  74. data/sig/generated/riffer/rig/ui/theme.rbs +3 -5
  75. data/sig/generated/riffer/rig.rbs +12 -0
  76. data/sig/manual/riffer/rig/cli.rbs +2 -4
  77. data/sig/manual/riffer/rig/credentials.rbs +2 -4
  78. data/sig/manual/riffer/rig/hosts.rbs +5 -0
  79. data/sig/manual/riffer/rig/settings.rbs +2 -4
  80. data/sig/manual/riffer/rig/ui/banner.rbs +2 -4
  81. data/sig/manual/riffer/rig.rbs +11 -0
  82. metadata +45 -3
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'securerandom'
5
+
6
+ # Never renders, prints or reads the filesystem: anything a host needs is a
7
+ # Runtime feature, so embedders get it too.
8
+ class Riffer::Rig::Runtime
9
+ class BusyError < StandardError; end
10
+
11
+ class ClosedError < StandardError; end
12
+
13
+ BASE_PROMPT_TEMPLATE = <<~TEXT
14
+ You are %<name>s, a general-purpose agent. You work by using the tools you have
15
+ been given; each tool describes what it does and when to use it.
16
+
17
+ - Verify with your tools before answering. When a tool can settle a question,
18
+ look rather than guess.
19
+ - Do what was asked, and all of what was asked. Do not widen the scope, tidy
20
+ nearby things, or add extras that were not requested.
21
+ - Be concise. Lead with the outcome; do not restate the question.
22
+ TEXT
23
+
24
+ DEFAULT_NAME = 'riffer'
25
+
26
+ # Unlimited: riffer's own default (16) is too small for a general-purpose
27
+ # harness.
28
+ DEFAULT_MAX_STEPS = nil #: Integer?
29
+
30
+ INTERRUPT_CANCELLED = :cancelled #: Symbol
31
+
32
+ # @rbs @agent: Riffer::Agent
33
+ # @rbs @base_prompt: String
34
+ # @rbs @cancel_flag: Riffer::Rig::Runtime::CancelFlag
35
+ # @rbs @credentials: Hash[Symbol, Hash[Symbol, String]]
36
+ # @rbs @cwd: String
37
+ # @rbs @host: Riffer::Rig::Hosts::Mirror
38
+ # @rbs @id: String
39
+ # @rbs @settings: Hash[Symbol, untyped]
40
+ # @rbs @busy: bool
41
+ # @rbs @closed: bool
42
+ # @rbs @session_start_pending: bool
43
+ # @rbs @registrar: Riffer::Rig::Registrar
44
+
45
+ # @dynamic agent, credentials, cwd, host, id, settings
46
+ attr_reader :agent #: Riffer::Agent
47
+ attr_reader :credentials #: Hash[Symbol, Hash[Symbol, String]]
48
+ attr_reader :cwd #: String
49
+ attr_reader :host #: Riffer::Rig::Hosts::Mirror
50
+ attr_reader :id #: String
51
+ attr_reader :settings #: Hash[Symbol, untyped]
52
+
53
+ # @rbs model: String
54
+ # @rbs extensions: Array[Riffer::Rig::Extension]
55
+ # @rbs tools: Array[String]?
56
+ # @rbs settings: Hash[Symbol, untyped]
57
+ # @rbs host: Riffer::Rig::Hosts::Base
58
+ # @rbs cwd: String?
59
+ # @rbs name: String
60
+ # @rbs instructions: String?
61
+ # @rbs credentials: Hash[Symbol, Hash[Symbol, String]]
62
+ # @rbs pricing: Hash[String, Riffer::Rig::Settings::Pricing]
63
+ # @rbs max_steps: Integer?
64
+ # @rbs snapshot: Hash[Symbol, untyped]?
65
+ # @rbs return: void
66
+ def initialize(
67
+ model,
68
+ extensions: [],
69
+ tools: nil,
70
+ settings: {},
71
+ host: Riffer::Rig::Hosts::Null.new,
72
+ cwd: nil,
73
+ name: DEFAULT_NAME,
74
+ instructions: nil,
75
+ credentials: {},
76
+ pricing: {},
77
+ max_steps: DEFAULT_MAX_STEPS,
78
+ snapshot: nil
79
+ )
80
+ # Doubles as the snapshot id and ACP sessionId.
81
+ @id = ::SecureRandom.uuid_v7
82
+ @host = Riffer::Rig::Hosts::Mirror.new(host)
83
+ @cwd = cwd || Dir.pwd
84
+ @settings = settings
85
+ @credentials = credentials
86
+
87
+ @busy = false
88
+ @closed = false
89
+ @cancel_flag = Riffer::Rig::Runtime::CancelFlag.new
90
+ @session_start_pending = true
91
+ @registrar = build_registrar(extensions)
92
+ tool_classes = select_tools(@registrar.tools, tools)
93
+ @base_prompt = instructions || format(BASE_PROMPT_TEMPLATE, name: name)
94
+
95
+ @agent = Riffer::Agent.new(
96
+ context: { cancel_flag: @cancel_flag },
97
+ config: Riffer::Agent::Config.new(
98
+ model: model,
99
+ instructions: system_prompt([]),
100
+ tools_config: tool_classes,
101
+ max_steps: max_steps
102
+ )
103
+ )
104
+ @agent.session.on_message { |_message| interrupt_if_cancelled }
105
+ end
106
+
107
+ # @rbs text: String
108
+ # @rbs &block: ?(::Riffer::StreamEvents::Base | Riffer::Rig::Events::Event) -> void
109
+ # @rbs return: (nil | Enumerator[::Riffer::StreamEvents::Base | Riffer::Rig::Events::Event, Riffer::Agent::Response])
110
+ def prompt(text, &block)
111
+ raise BusyError, 'a prompt is already running on this Runtime' if @busy
112
+ raise ClosedError, 'this Runtime is closed' if @closed
113
+
114
+ @busy = true
115
+ @cancel_flag.clear
116
+ refresh_system_message
117
+ if block
118
+ wrap_stream(@agent.stream(text)).each(&block)
119
+ nil
120
+ else
121
+ wrap_stream(@agent.stream(text))
122
+ end
123
+ ensure
124
+ @busy = false
125
+ end
126
+
127
+ # @rbs text: String
128
+ # @rbs return: Riffer::Agent::Response
129
+ def ask(text)
130
+ raise BusyError, 'a prompt is already running on this Runtime' if @busy
131
+ raise ClosedError, 'this Runtime is closed' if @closed
132
+
133
+ @busy = true
134
+ @cancel_flag.clear
135
+ refresh_system_message
136
+ @agent.stream(text).each { |event| event }
137
+ ensure
138
+ @busy = false
139
+ end
140
+
141
+ # @rbs return: nil
142
+ def cancel
143
+ @cancel_flag.set
144
+ nil
145
+ end
146
+
147
+ # @rbs return: void
148
+ def close
149
+ # TODO: emit Riffer::Rig::Events::SessionEnd once the rebuild ticket settles
150
+ # the stream's session_end reasons.
151
+ @closed = true
152
+ end
153
+
154
+ private
155
+
156
+ # @rbs stream: Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
157
+ # @rbs return: Enumerator[::Riffer::StreamEvents::Base | Riffer::Rig::Events::Event, Riffer::Agent::Response]
158
+ def wrap_stream(stream)
159
+ Enumerator.new do |yielder|
160
+ yielder << Riffer::Rig::Events::SessionStart.new(@id, :new) if @session_start_pending
161
+ @session_start_pending = false
162
+ @host.drain.each { |event| yielder << event }
163
+ response = stream.each { |event| yielder << event }
164
+ yielder << Riffer::Rig::Events::TurnEnd.new(stop_reason(response.outcome), response.token_usage)
165
+ response
166
+ end
167
+ end
168
+
169
+ # @rbs return: void
170
+ def interrupt_if_cancelled
171
+ return unless @cancel_flag.set?
172
+
173
+ @agent.session.discard_pending_tool_calls
174
+ # Upstream candidate: a cancel token on riffer's run loop. Until then the
175
+ # loop can only be stopped from inside, at a message boundary.
176
+ @agent.interrupt!(INTERRUPT_CANCELLED)
177
+ end
178
+
179
+ # @rbs outcome: Riffer::Agent::Outcome
180
+ # @rbs return: Symbol
181
+ def stop_reason(outcome)
182
+ # Upstream candidate: riffer's outcome vocabulary is closed, so a cancel
183
+ # reaches us as :interrupted with the reason in detail.
184
+ cancelled = outcome.reason == :interrupted && outcome.detail == INTERRUPT_CANCELLED.to_s
185
+ cancelled ? INTERRUPT_CANCELLED : outcome.reason
186
+ end
187
+
188
+ # @rbs extensions: Array[Riffer::Rig::Extension]
189
+ # @rbs return: Riffer::Rig::Registrar
190
+ def build_registrar(extensions)
191
+ raise BusyError, 'a prompt is already running on this Runtime' if @busy
192
+
193
+ extensions.each_with_object(Riffer::Rig::Registrar.new) do |extension, registrar|
194
+ extension.run(registrar)
195
+ end
196
+ end
197
+
198
+ # @rbs registered: Array[singleton(Riffer::Tool)]
199
+ # @rbs allowlist: Array[String]?
200
+ # @rbs return: Array[singleton(Riffer::Tool)]
201
+ def select_tools(registered, allowlist)
202
+ return registered if allowlist.nil?
203
+
204
+ registered.select { |klass| allowlist.include?(klass.name) }
205
+ end
206
+
207
+ # @rbs return: void
208
+ def refresh_system_message
209
+ # Upstream candidate: riffer resolves `instructions` once, in Agent.new, so
210
+ # a per-turn system message has to be swapped into the session by hand.
211
+ session = @agent.session
212
+ session.set([Riffer::Messages::System.new(system_prompt(rendered_sections)), *session.messages.drop(1)])
213
+ end
214
+
215
+ # @rbs return: Array[String]
216
+ def rendered_sections
217
+ @registrar.prompts.each_value.map { |section| section.call(self).to_s }.reject(&:empty?)
218
+ end
219
+
220
+ # @rbs sections: Array[String]
221
+ # @rbs return: String
222
+ def system_prompt(sections)
223
+ [@base_prompt, *sections, "Current date: #{Date.today}\nCurrent working directory: #{@cwd}"].join("\n\n")
224
+ end
225
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Settings::Document
4
+ # @rbs @model: String?
5
+ # @rbs @reasoning: String?
6
+ # @rbs @models: Hash[String, Riffer::Rig::Settings::Pricing]
7
+ # @rbs @providers: Hash[String, Hash[String, String]]
8
+
9
+ # @dynamic model, reasoning, models, providers
10
+ attr_reader :model #: String?
11
+ attr_reader :reasoning #: String?
12
+ attr_reader :models #: Hash[String, Riffer::Rig::Settings::Pricing]
13
+ attr_reader :providers #: Hash[String, Hash[String, String]]
14
+
15
+ # @rbs source: untyped
16
+ # @rbs return: void
17
+ def initialize(source)
18
+ @model = source['model'].is_a?(String) ? source['model'] : nil
19
+ @reasoning = source['reasoning'].is_a?(String) ? source['reasoning'] : nil
20
+ entries = source['models'].is_a?(Hash) ? source['models'] : {} #: Hash[String, untyped]
21
+ @models = entries.filter_map do |name, entry|
22
+ [name, Riffer::Rig::Settings::Pricing.from(entry)] if name.is_a?(String) && entry.is_a?(Hash)
23
+ end.to_h
24
+ blocks = source['providers'].is_a?(Hash) ? source['providers'] : {} #: Hash[String, untyped]
25
+ @providers = blocks.select { |_identifier, fields| fields.is_a?(Hash) }
26
+ .transform_values { |fields| fields.select { |_name, value| value.is_a?(String) } }
27
+ end
28
+ end
@@ -1,10 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # USD per million tokens, parsed from a model's entry in the settings file.
4
3
  class Riffer::Rig::Settings::Pricing
5
4
  # @dynamic input, output, cache_write, cache_read
6
5
  attr_reader :input, :output, :cache_write, :cache_read #: Float
7
6
 
7
+ # @rbs entry: Hash[String, untyped]
8
+ # @rbs return: ::Riffer::Rig::Settings::Pricing
9
+ def self.from(entry)
10
+ new(
11
+ input: entry.fetch('input', 0).to_f,
12
+ output: entry.fetch('output', 0).to_f,
13
+ cache_write: entry.fetch('cache_write', 0).to_f,
14
+ cache_read: entry.fetch('cache_read', 0).to_f
15
+ )
16
+ end
17
+
8
18
  # @rbs input: Float
9
19
  # @rbs output: Float
10
20
  # @rbs cache_write: Float
@@ -3,36 +3,6 @@
3
3
  require 'json'
4
4
  require 'fileutils'
5
5
 
6
- # Reads and writes the riffer-rig user settings file at
7
- # <tt>~/.riffer/settings.json</tt>.
8
- #
9
- # Example file:
10
- #
11
- # {
12
- # "model": "anthropic/claude-sonnet-4-6",
13
- # "reasoning": "low",
14
- # "models": {
15
- # "anthropic/claude-sonnet-4-6": {
16
- # "input": 3.0,
17
- # "output": 15.0,
18
- # "cache_write": 3.75,
19
- # "cache_read": 0.3
20
- # }
21
- # }
22
- # }
23
- #
24
- # All keys are optional. Missing pricing means cost display is suppressed.
25
- # <tt>"reasoning"</tt> is translated to the appropriate provider-specific
26
- # parameter on each API call; accepted values depend on the provider:
27
- #
28
- # - Anthropic: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
29
- # <tt>"xhigh"</tt>, <tt>"max"</tt>
30
- # - OpenAI / OpenRouter: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
31
- # <tt>"xhigh"</tt>
32
- #
33
- # Omitting the key (or supplying an unrecognised value) leaves the model's
34
- # default reasoning behaviour unchanged.
35
- #
36
6
  module Riffer::Rig::Settings
37
7
  extend self
38
8
 
@@ -46,17 +16,12 @@ module Riffer::Rig::Settings
46
16
  'openrouter' => %w[low medium high xhigh].freeze
47
17
  }.freeze #: Hash[String, Array[String]]
48
18
 
49
- # Returns the configured model string, or +DEFAULT_MODEL+ if not set.
50
- #
51
19
  # @rbs path: String
52
20
  # @rbs return: String
53
21
  def model(path: PATH)
54
- read(path).fetch('model', DEFAULT_MODEL)
22
+ read(path).model || DEFAULT_MODEL
55
23
  end
56
24
 
57
- # Returns model options for the configured model and reasoning level, ready
58
- # to pass directly to the Riffer agent's +model_options+.
59
- #
60
25
  # @rbs path: String
61
26
  # @rbs return: Hash[Symbol, untyped]
62
27
  def model_options(path: PATH)
@@ -64,35 +29,59 @@ module Riffer::Rig::Settings
64
29
  base_options(provider).merge(reasoning_options(reasoning_for(path:, provider:), provider))
65
30
  end
66
31
 
67
- # Returns the provider prefix for +model_string+, e.g. <tt>"anthropic"</tt>
68
- # for <tt>"anthropic/claude-sonnet-4-6"</tt>. Returns +nil+ if the model
69
- # string contains no slash.
70
- #
71
32
  # @rbs model_string: String
72
33
  # @rbs return: String?
73
34
  def provider_for(model_string)
74
35
  model_string.split('/', 2).first if model_string.include?('/')
75
36
  end
76
37
 
77
- # Returns the pricing for +model+, or +nil+ if not configured.
78
- #
79
38
  # @rbs model: String
80
39
  # @rbs path: String
81
40
  # @rbs return: Pricing?
82
41
  def pricing_for(model, path: PATH)
83
- entry = read(path).dig('models', model)
84
- return nil unless entry.is_a?(Hash)
85
-
86
- Pricing.new(
87
- input: entry.fetch('input', 0).to_f,
88
- output: entry.fetch('output', 0).to_f,
89
- cache_write: entry.fetch('cache_write', 0).to_f,
90
- cache_read: entry.fetch('cache_read', 0).to_f
91
- )
42
+ read(path).models[model]
43
+ end
44
+
45
+ # @rbs identifier: String
46
+ # @rbs path: String
47
+ # @rbs return: Hash[String, String]
48
+ def provider_fields(identifier, path: PATH)
49
+ read(path).providers[identifier] || {}
50
+ end
51
+
52
+ # @rbs identifier: String
53
+ # @rbs fields: Hash[String, String]
54
+ # @rbs path: String
55
+ # @rbs return: void
56
+ def store_provider(identifier, fields, path: PATH)
57
+ update_providers(path) do |providers|
58
+ providers.merge(identifier => fields) { |_identifier, stored, given| stored.merge(given) }
59
+ end
60
+ end
61
+
62
+ # @rbs identifier: String
63
+ # @rbs path: String
64
+ # @rbs return: void
65
+ def remove_provider(identifier, path: PATH)
66
+ update_providers(path) { |providers| providers.except(identifier) }
92
67
  end
93
68
 
94
69
  private
95
70
 
71
+ # @rbs path: String
72
+ # @rbs &: (Hash[String, Hash[String, String]]) -> Hash[String, Hash[String, String]]
73
+ # @rbs return: void
74
+ def update_providers(path)
75
+ source = read_source(path)
76
+ current = Document.new(source).providers
77
+ providers = yield current
78
+ return if providers == current
79
+
80
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
81
+ document = providers.empty? ? source.except('providers') : source.merge('providers' => providers)
82
+ File.write(path, JSON.pretty_generate(document))
83
+ end
84
+
96
85
  # @rbs provider: String?
97
86
  # @rbs return: Hash[Symbol, untyped]
98
87
  def base_options(provider)
@@ -102,17 +91,14 @@ module Riffer::Rig::Settings
102
91
  end
103
92
 
104
93
  # @rbs path: String
105
- # @rbs ?provider: String?
94
+ # @rbs provider: String?
106
95
  # @rbs return: String?
107
96
  def reasoning_for(path: PATH, provider: nil)
108
- level = read(path)['reasoning']
97
+ level = read(path).reasoning
109
98
  valid_levels = (provider && REASONING_LEVELS_BY_PROVIDER[provider]) || []
110
99
  valid_levels.include?(level) ? level : nil
111
100
  end
112
101
 
113
- # Maps a reasoning level to the provider-specific model option hash expected
114
- # by Riffer. Returns an empty hash when +level+ is +nil+.
115
- #
116
102
  # @rbs level: String?
117
103
  # @rbs provider: String?
118
104
  # @rbs return: Hash[Symbol, untyped]
@@ -129,12 +115,15 @@ module Riffer::Rig::Settings
129
115
  end
130
116
  end
131
117
 
132
- # The parsed settings file, or an empty hash when the file is absent or
133
- # malformed.
134
- #
135
118
  # @rbs path: String
136
- # @rbs return: Hash[String, untyped]
119
+ # @rbs return: Document
137
120
  def read(path)
121
+ Document.new(read_source(path))
122
+ end
123
+
124
+ # @rbs path: String
125
+ # @rbs return: Hash[String, untyped]
126
+ def read_source(path)
138
127
  return {} unless File.file?(path)
139
128
 
140
129
  JSON.parse(File.read(path))
@@ -1,17 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Accumulates token usage across multiple turns in a session and computes an
4
- # estimated cost based on a pricing object sourced from
5
- # +Riffer::Rig::Settings+.
6
- #
7
- # Instantiate once per REPL session, call +add+ after each turn, then read
8
- # +total_tokens+ and +estimated_cost+ for display.
9
- #
10
- # pricing = Riffer::Rig::Settings.pricing_for('anthropic/claude-sonnet-4-6')
11
- # tally = Riffer::Rig::TokenTally.new(pricing: pricing)
12
- # tally.add(usage)
13
- # tally.estimated_cost # => 0.000042 (USD), or nil if pricing is nil
14
- #
15
3
  class Riffer::Rig::TokenTally
16
4
  # @rbs @input_tokens: Integer
17
5
  # @rbs @output_tokens: Integer
@@ -22,7 +10,7 @@ class Riffer::Rig::TokenTally
22
10
  # @dynamic input_tokens, output_tokens, cache_write_tokens, cache_read_tokens
23
11
  attr_reader :input_tokens, :output_tokens, :cache_write_tokens, :cache_read_tokens
24
12
 
25
- # @rbs ?pricing: Riffer::Rig::Settings::Pricing?
13
+ # @rbs pricing: Riffer::Rig::Settings::Pricing?
26
14
  # @rbs return: void
27
15
  def initialize(pricing: nil)
28
16
  @pricing = pricing
@@ -32,8 +20,6 @@ class Riffer::Rig::TokenTally
32
20
  @cache_read_tokens = 0
33
21
  end
34
22
 
35
- # Accumulates token counts from a +Riffer::Providers::TokenUsage+ object.
36
- #
37
23
  # @rbs usage: Riffer::Providers::TokenUsage
38
24
  # @rbs return: void
39
25
  def add(usage)
@@ -43,22 +29,16 @@ class Riffer::Rig::TokenTally
43
29
  @cache_read_tokens += usage.cache_read_tokens || 0
44
30
  end
45
31
 
46
- # Returns the total token count across all categories.
47
- #
48
32
  # @rbs return: Integer
49
33
  def total_tokens
50
34
  @input_tokens + @output_tokens + @cache_write_tokens + @cache_read_tokens
51
35
  end
52
36
 
53
- # Returns +true+ if any tokens have been counted.
54
- #
55
37
  # @rbs return: bool
56
38
  def any?
57
39
  total_tokens.positive?
58
40
  end
59
41
 
60
- # Returns the estimated cost in USD, or +nil+ if no pricing was provided.
61
- #
62
42
  # @rbs return: Float?
63
43
  def estimated_cost
64
44
  pricing = @pricing
@@ -13,6 +13,8 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
13
13
 
14
14
  MAX_OUTPUT_BYTES = 30_000 #: Integer
15
15
 
16
+ POLL_SECONDS = 0.1 #: Float
17
+
16
18
  params do
17
19
  required :command, String, description: 'The shell command to run'
18
20
  optional :timeout_ms,
@@ -23,10 +25,11 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
23
25
 
24
26
  # @rbs context: Riffer::Agent::Context?
25
27
  # @rbs command: String
26
- # @rbs ?timeout_ms: Integer
28
+ # @rbs timeout_ms: Integer
27
29
  # @rbs return: Riffer::Tools::Response
28
30
  def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
29
- output, status = run(command, timeout_ms / 1000.0)
31
+ cancel_flag = context&.[](:cancel_flag) #: Riffer::Rig::Runtime::CancelFlag?
32
+ output, status = run(command, timeout_ms / 1000.0, cancel_flag)
30
33
  output = truncate(output.rstrip)
31
34
 
32
35
  return error("Command exited with status #{status}\n#{output}", type: :command_failed) unless status.zero?
@@ -38,23 +41,41 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
38
41
 
39
42
  # @rbs command: String
40
43
  # @rbs timeout_seconds: Float
44
+ # @rbs cancel_flag: Riffer::Rig::Runtime::CancelFlag?
41
45
  # @rbs return: [String, Integer]
42
- def run(command, timeout_seconds)
46
+ def run(command, timeout_seconds, cancel_flag)
43
47
  stdin, stdout_and_stderr, wait_thread = Open3.popen2e(command, chdir: Dir.pwd, pgroup: true)
44
48
  stdin.close
45
49
 
46
- joined = wait_thread.join(timeout_seconds) #: Thread?
50
+ ending = await(wait_thread, monotonic_now + timeout_seconds, cancel_flag)
51
+ kill_group(wait_thread.pid) unless ending == :exited
52
+ output = stdout_and_stderr.read
53
+ stdout_and_stderr.close
47
54
 
48
- unless joined
49
- kill_group(wait_thread.pid)
50
- output = stdout_and_stderr.read
51
- stdout_and_stderr.close
52
- return ["#{output}\n[timed out after #{timeout_seconds.round}s]", 124]
55
+ case ending
56
+ when :timed_out then ["#{output}\n[timed out after #{timeout_seconds.round}s]", 124]
57
+ when :cancelled then ["#{output}\n[cancelled]", 130]
58
+ else [output, wait_thread.value.exitstatus || 1]
53
59
  end
60
+ end
54
61
 
55
- output = stdout_and_stderr.read
56
- stdout_and_stderr.close
57
- [output, wait_thread.value.exitstatus || 1]
62
+ # @rbs wait_thread: Process::Waiter
63
+ # @rbs deadline: Float
64
+ # @rbs cancel_flag: Riffer::Rig::Runtime::CancelFlag?
65
+ # @rbs return: Symbol
66
+ def await(wait_thread, deadline, cancel_flag)
67
+ # Upstream candidate: a cancel token on riffer's run loop would reach a
68
+ # running tool directly; until then the flag is polled from the context.
69
+ until wait_thread.join(POLL_SECONDS)
70
+ return :cancelled if cancel_flag&.set?
71
+ return :timed_out if monotonic_now >= deadline
72
+ end
73
+ :exited
74
+ end
75
+
76
+ # @rbs return: Float
77
+ def monotonic_now
78
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
79
  end
59
80
 
60
81
  # @rbs pid: Integer
@@ -19,7 +19,7 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
19
19
  # @rbs path: String
20
20
  # @rbs old_string: String
21
21
  # @rbs new_string: String
22
- # @rbs ?replace_all: bool
22
+ # @rbs replace_all: bool
23
23
  # @rbs return: Riffer::Tools::Response
24
24
  def call(context:, path:, old_string:, new_string:, replace_all: false)
25
25
  resolved = File.expand_path(path, Dir.pwd)
@@ -15,8 +15,8 @@ class Riffer::Rig::Tools::Read < Riffer::Tool
15
15
 
16
16
  # @rbs context: Riffer::Agent::Context?
17
17
  # @rbs path: String
18
- # @rbs ?offset: Integer?
19
- # @rbs ?limit: Integer
18
+ # @rbs offset: Integer?
19
+ # @rbs limit: Integer
20
20
  # @rbs return: Riffer::Tools::Response
21
21
  def call(context:, path:, offset: nil, limit: DEFAULT_LIMIT)
22
22
  resolved = File.expand_path(path, Dir.pwd)
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Animated output is gated behind an interactive, colour-enabled TTY, so piped
4
- # or tested runs stay silent and escape-free.
5
3
  class Riffer::Rig::UI::Animator
6
4
  REVEAL_FRAME_SECONDS = 0.05 #: Float
7
5
 
@@ -23,15 +21,15 @@ class Riffer::Rig::UI::Animator
23
21
 
24
22
  REASONING_TICK_RANGE = (1..5) #: Range[Integer]
25
23
 
26
- # @rbs @io: untyped
24
+ # @rbs @io: IO
27
25
  # @rbs @theme: Riffer::Rig::UI::Theme
28
26
  # @rbs @thread: Thread?
29
27
  # @rbs @mode: Symbol
30
28
  # @rbs @phrase: String?
31
29
  # @rbs @stop: bool
32
30
 
33
- # @rbs io: untyped
34
- # @rbs ?theme: Riffer::Rig::UI::Theme
31
+ # @rbs io: IO
32
+ # @rbs theme: Riffer::Rig::UI::Theme
35
33
  # @rbs return: void
36
34
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
37
35
  @io = io
@@ -41,8 +39,6 @@ class Riffer::Rig::UI::Animator
41
39
  @phrase = nil
42
40
  end
43
41
 
44
- # When not on a TTY, prints the final frame once instead of animating.
45
- #
46
42
  # @rbs frames: Array[Array[String]]
47
43
  # @rbs return: void
48
44
  def reveal(frames)
@@ -60,7 +56,7 @@ class Riffer::Rig::UI::Animator
60
56
  end
61
57
  end
62
58
 
63
- # @rbs ?mode: Symbol
59
+ # @rbs mode: Symbol
64
60
  # @rbs return: void
65
61
  def start(mode = :neutral)
66
62
  return unless enabled?
@@ -88,7 +84,7 @@ class Riffer::Rig::UI::Animator
88
84
  end
89
85
 
90
86
  # @rbs tick: Integer
91
- # @rbs ?label: String
87
+ # @rbs label: String
92
88
  # @rbs return: String
93
89
  def equalizer(tick, label = NEUTRAL_LABEL)
94
90
  bars = Array.new(EQ_BARS) do |i|
@@ -104,7 +100,7 @@ class Riffer::Rig::UI::Animator
104
100
  # @rbs return: void
105
101
  def animate
106
102
  tick = 0
107
- roll_at = 0.0 # force an immediate phrase roll when entering reasoning mode
103
+ roll_at = 0.0
108
104
  until @stop
109
105
  roll_at = roll_phrase(roll_at)
110
106
  @io.print("\r #{equalizer(tick, label)}\e[K")
@@ -114,8 +110,6 @@ class Riffer::Rig::UI::Animator
114
110
  end
115
111
  end
116
112
 
117
- # Re-rolls both phrase and duration whenever the reasoning tick expires.
118
- #
119
113
  # @rbs roll_at: Float
120
114
  # @rbs return: Float
121
115
  def roll_phrase(roll_at)
@@ -126,11 +120,10 @@ class Riffer::Rig::UI::Animator
126
120
  now + roll_rand
127
121
  end
128
122
 
129
- # Range rand returns nil for an empty range; this one is a non-empty constant.
130
- #
131
123
  # @rbs return: Integer
132
124
  def roll_rand
133
125
  x = rand(REASONING_TICK_RANGE)
126
+ # Range rand returns nil for an empty range; this one is a non-empty constant.
134
127
  x || 0
135
128
  end
136
129
 
@@ -141,6 +134,6 @@ class Riffer::Rig::UI::Animator
141
134
 
142
135
  # @rbs return: bool
143
136
  def enabled?
144
- @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
137
+ @theme.enabled && @io.tty?
145
138
  end
146
139
  end