riffer-rig 0.7.0 → 0.9.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +13 -3
  4. data/lib/riffer/rig/cli/flags.rb +16 -2
  5. data/lib/riffer/rig/cli.rb +2 -1
  6. data/lib/riffer/rig/commands/auth.rb +79 -0
  7. data/lib/riffer/rig/commands/model.rb +70 -21
  8. data/lib/riffer/rig/commands/reload.rb +23 -0
  9. data/lib/riffer/rig/credentials.rb +13 -1
  10. data/lib/riffer/rig/extension.rb +12 -0
  11. data/lib/riffer/rig/loader.rb +327 -23
  12. data/lib/riffer/rig/provider_setup.rb +36 -5
  13. data/lib/riffer/rig/providers.rb +48 -0
  14. data/lib/riffer/rig/registrar.rb +8 -0
  15. data/lib/riffer/rig/runtime.rb +110 -21
  16. data/lib/riffer/rig/sdk.rb +62 -0
  17. data/lib/riffer/rig/settings/document.rb +9 -1
  18. data/lib/riffer/rig/settings.rb +5 -4
  19. data/lib/riffer/rig/stores/jsonl.rb +115 -0
  20. data/lib/riffer/rig/stores/recorder.rb +119 -0
  21. data/lib/riffer/rig/terminal/renderer.rb +86 -13
  22. data/lib/riffer/rig/terminal/wrapper.rb +103 -0
  23. data/lib/riffer/rig/trust.rb +37 -0
  24. data/lib/riffer/rig/version.rb +1 -1
  25. data/lib/riffer/rig.rb +7 -1
  26. data/sig/generated/riffer/rig/cli/flags.rbs +5 -2
  27. data/sig/generated/riffer/rig/commands/auth.rbs +35 -0
  28. data/sig/generated/riffer/rig/commands/model.rbs +21 -9
  29. data/sig/generated/riffer/rig/commands/reload.rbs +12 -0
  30. data/sig/generated/riffer/rig/credentials.rbs +8 -0
  31. data/sig/generated/riffer/rig/extension.rbs +4 -0
  32. data/sig/generated/riffer/rig/loader.rbs +142 -5
  33. data/sig/generated/riffer/rig/provider_setup.rbs +15 -4
  34. data/sig/generated/riffer/rig/providers.rbs +28 -0
  35. data/sig/generated/riffer/rig/registrar.rbs +6 -0
  36. data/sig/generated/riffer/rig/runtime.rbs +63 -9
  37. data/sig/generated/riffer/rig/sdk.rbs +33 -0
  38. data/sig/generated/riffer/rig/settings/document.rbs +7 -1
  39. data/sig/generated/riffer/rig/settings.rbs +2 -3
  40. data/sig/generated/riffer/rig/stores/jsonl.rbs +64 -0
  41. data/sig/generated/riffer/rig/stores/recorder.rbs +67 -0
  42. data/sig/generated/riffer/rig/terminal/renderer.rbs +41 -1
  43. data/sig/generated/riffer/rig/terminal/wrapper.rbs +43 -0
  44. data/sig/generated/riffer/rig/trust.rbs +20 -0
  45. data/sig/generated/riffer/rig.rbs +4 -0
  46. data/sig/manual/riffer/rig/commands/auth.rbs +6 -0
  47. data/sig/manual/riffer/rig/commands/reload.rbs +6 -0
  48. data/sig/manual/riffer/rig/providers.rbs +6 -0
  49. data/sig/manual/riffer/rig/sdk.rbs +6 -0
  50. data/sig/manual/riffer/rig/stores.rbs +11 -0
  51. data/sig/manual/riffer/rig/trust.rbs +6 -0
  52. metadata +23 -29
@@ -29,6 +29,15 @@ class Riffer::Rig::Runtime
29
29
 
30
30
  INTERRUPT_CANCELLED = :cancelled #: Symbol
31
31
 
32
+ # Upstream candidate: riffer's Anthropic, OpenAI and Azure OpenAI classes read
33
+ # the web_search option and OpenRouter passes unknown options through to its
34
+ # API, but the provider seam cannot yet answer which provider takes which
35
+ # option, so rig keeps the mapping until it can. Mock consumes web_search, so
36
+ # tests and embedders can exercise the switches.
37
+ NATIVE_TOOLS_BY_PROVIDER = {
38
+ web_search: { anthropic: true, openai: true, azure_openai: true, mock: true }.freeze
39
+ }.freeze #: Hash[Symbol, Hash[Symbol, bool]]
40
+
32
41
  # @rbs @agent: Riffer::Agent
33
42
  # @rbs @base_prompt: String
34
43
  # @rbs @cancel_flag: Riffer::Rig::Runtime::CancelFlag
@@ -43,8 +52,12 @@ class Riffer::Rig::Runtime
43
52
  # @rbs @session_start_reason: Symbol
44
53
  # @rbs @model_override: String?
45
54
  # @rbs @message_observers: Array[^(Riffer::Messages::Base) -> void]
55
+ # @rbs @model_observers: Array[^(String) -> void]
46
56
  # @rbs @prompts: Hash[Symbol, ^(Riffer::Rig::Runtime) -> String?]
47
57
  # @rbs @commands: Hash[String, Riffer::Rig::Command]
58
+ # @rbs @core_commands: Array[Riffer::Rig::Command]
59
+ # @rbs @claim_depth: Integer
60
+ # @rbs @claim_thread: Thread?
48
61
  # @rbs @declared_settings: Hash[String, Hash[Symbol, untyped]]
49
62
  # @rbs @errors: Array[Riffer::Rig::Extension::Failure]
50
63
  # @rbs @hooks: Riffer::Rig::Runtime::Hooks
@@ -52,6 +65,7 @@ class Riffer::Rig::Runtime
52
65
  # @rbs @mcp_registry: Riffer::Rig::Mcp::_Registry
53
66
  # @rbs @mcp_servers: Hash[String, Riffer::Rig::Mcp::Server]
54
67
  # @rbs @model_options: Hash[Symbol, untyped]
68
+ # @rbs @native_tools: Hash[Symbol, untyped]
55
69
 
56
70
  # @dynamic agent, credentials, cwd, host, id, settings, declared_settings
57
71
  attr_reader :agent #: Riffer::Agent
@@ -76,6 +90,7 @@ class Riffer::Rig::Runtime
76
90
  # @rbs mcp_registry: Riffer::Rig::Mcp::_Registry
77
91
  # @rbs max_steps: Integer?
78
92
  # @rbs model_options: Hash[Symbol, untyped]
93
+ # @rbs native_tools: Hash[Symbol, untyped]
79
94
  # @rbs snapshot: Hash[Symbol, untyped]?
80
95
  # @rbs return: void
81
96
  def initialize(
@@ -93,6 +108,7 @@ class Riffer::Rig::Runtime
93
108
  mcp_registry: Riffer::Mcp,
94
109
  max_steps: DEFAULT_MAX_STEPS,
95
110
  model_options: {},
111
+ native_tools: {},
96
112
  snapshot: nil
97
113
  )
98
114
  # Doubles as the snapshot id and ACP sessionId.
@@ -103,14 +119,19 @@ class Riffer::Rig::Runtime
103
119
 
104
120
  @busy = false
105
121
  @closed = false
122
+ @claim_depth = 0
123
+ @claim_thread = nil
124
+ @core_commands = []
106
125
  @cancel_flag = Riffer::Rig::Runtime::CancelFlag.new
107
126
  @session_start_pending = true
108
127
  @session_start_reason = snapshot ? :restore : :new
109
128
  @message_observers = []
129
+ @model_observers = []
110
130
  Riffer::Rig::Settings::Pricing.register(pricing, riffer_config.pricing)
111
131
  @errors = []
112
132
  @tool_allowlist = tools
113
- @model_options = model_options
133
+ @native_tools = native_tools
134
+ @model_options = model_options.merge(native_options(model))
114
135
  @base_prompt = instructions || format(BASE_PROMPT_TEMPLATE, name: name)
115
136
  @mcp_registry = mcp_registry
116
137
  @mcp_servers = {}
@@ -136,7 +157,7 @@ class Riffer::Rig::Runtime
136
157
  turn(text)
137
158
  end
138
159
  ensure
139
- @busy = false
160
+ release
140
161
  end
141
162
  end
142
163
 
@@ -147,7 +168,7 @@ class Riffer::Rig::Runtime
147
168
  begin
148
169
  turn(text).each { |event| event }
149
170
  ensure
150
- @busy = false
171
+ release
151
172
  end
152
173
  end
153
174
 
@@ -156,6 +177,16 @@ class Riffer::Rig::Runtime
156
177
  @commands.values
157
178
  end
158
179
 
180
+ # Installs a host-of-the-runtime command (the Loader's /reload) so it
181
+ # survives every rebuild; an extension command of the same name replaces it.
182
+ # @rbs command: Riffer::Rig::Command
183
+ # @rbs return: nil
184
+ def install_command(command)
185
+ @core_commands << command
186
+ @commands[command.name] = command
187
+ nil
188
+ end
189
+
159
190
  # @rbs name: String
160
191
  # @rbs args: String
161
192
  # @rbs &block: ?(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void
@@ -166,7 +197,7 @@ class Riffer::Rig::Runtime
166
197
  begin
167
198
  execute(name, args, emit)
168
199
  ensure
169
- @busy = false
200
+ release
170
201
  end
171
202
  @host.drain.each(&emit)
172
203
  nil
@@ -187,15 +218,25 @@ class Riffer::Rig::Runtime
187
218
  def model=(model)
188
219
  # Upstream candidate: riffer resolves the model Proc once, in Agent.new, so
189
220
  # a switch rebuilds the agent over the same session and config.
190
- @agent = successor(model, @agent.config)
221
+ agent = successor(model, @agent.config)
222
+ rederive_options(model)
223
+ @agent = agent
191
224
  @model_override = model
225
+ @model_observers.each { |observer| observer.call(model) }
226
+ end
227
+
228
+ # @rbs provider: Symbol
229
+ # @rbs values: Hash[Symbol, String]
230
+ # @rbs return: void
231
+ def merge_credentials(provider, values)
232
+ @credentials = @credentials.merge(provider => values)
192
233
  end
193
234
 
194
235
  # @rbs extensions: Array[Riffer::Rig::Extension]
195
236
  # @rbs settings: Hash[Symbol, untyped]
196
237
  # @rbs return: nil
197
238
  def rebuild(extensions:, settings:)
198
- claim
239
+ claim_rebuild
199
240
  begin
200
241
  registrars = build_registrars(extensions, settings) { |_extension, error| raise error }
201
242
  mcp_servers = register_mcp_servers(registrars)
@@ -208,7 +249,7 @@ class Riffer::Rig::Runtime
208
249
  unregister_mcp_servers(dropped)
209
250
  lifecycle(:session_start, Riffer::Rig::Events::SessionStart.new(@id, :reload))
210
251
  ensure
211
- @busy = false
252
+ release
212
253
  end
213
254
  nil
214
255
  end
@@ -225,6 +266,13 @@ class Riffer::Rig::Runtime
225
266
  nil
226
267
  end
227
268
 
269
+ # @rbs &block: (String) -> void
270
+ # @rbs return: nil
271
+ def on_model_change(&block)
272
+ @model_observers << block
273
+ nil
274
+ end
275
+
228
276
  # @rbs return: nil
229
277
  def cancel
230
278
  @cancel_flag.set
@@ -255,6 +303,31 @@ class Riffer::Rig::Runtime
255
303
  raise ClosedError, 'this Runtime is closed' if @closed
256
304
 
257
305
  @busy = true
306
+ @claim_depth += 1
307
+ @claim_thread = Thread.current
308
+ end
309
+
310
+ # Rebuild is a configuration swap at a quiet boundary, not a turn entry, so
311
+ # it may run nested under the thread that holds the Runtime — the /reload
312
+ # command and the automatic reload check both do. Another thread is still
313
+ # refused while work runs.
314
+ # @rbs return: void
315
+ def claim_rebuild
316
+ raise BusyError, 'a prompt is already running on this Runtime' if @busy && @claim_thread != Thread.current
317
+ raise ClosedError, 'this Runtime is closed' if @closed
318
+
319
+ @busy = true
320
+ @claim_depth += 1
321
+ @claim_thread = Thread.current
322
+ end
323
+
324
+ # @rbs return: void
325
+ def release
326
+ @claim_depth -= 1
327
+ return if @claim_depth.positive?
328
+
329
+ @busy = false
330
+ @claim_thread = nil
258
331
  end
259
332
 
260
333
  # @rbs model: String
@@ -275,6 +348,33 @@ class Riffer::Rig::Runtime
275
348
  agent
276
349
  end
277
350
 
351
+ # The options the settings built for the starting model are provider-shaped
352
+ # (cache control, per-provider reasoning levels), so a switch re-derives them.
353
+ # Runs after the successor is built, so a failed build leaves the running
354
+ # agent's options alone; riffer reads config.model_options per request, so the
355
+ # swap still reaches the new agent through the shared config.
356
+ # @rbs model: String
357
+ # @rbs return: void
358
+ def rederive_options(model)
359
+ reasoning = Riffer::Rig::Settings::Document.new(@settings).reasoning
360
+ @model_options = Riffer::Rig::Settings.model_options(model, reasoning).merge(native_options(model))
361
+ @agent.config.model_options = @model_options
362
+ end
363
+
364
+ # The switches are on/off settings, so what the agent can do stays a function
365
+ # of the current provider: a switch whose provider cannot take the option
366
+ # contributes nothing, and a switch to such a provider drops it, both silently.
367
+ # @rbs model: String
368
+ # @rbs return: Hash[Symbol, untyped]
369
+ def native_options(model)
370
+ provider = Riffer::Rig::Settings.provider_for(model)&.to_sym
371
+ @native_tools.filter_map do |tool, switch|
372
+ next unless provider && NATIVE_TOOLS_BY_PROVIDER[tool]&.key?(provider)
373
+
374
+ [tool, switch]
375
+ end.to_h
376
+ end
377
+
278
378
  # @rbs registrars: Array[Riffer::Rig::Registrar]
279
379
  # @rbs hooks: Riffer::Rig::Runtime::Hooks
280
380
  # @rbs max_steps: Numeric?
@@ -321,6 +421,8 @@ class Riffer::Rig::Runtime
321
421
  @prompts = registrars.flat_map { |registrar| registrar.prompts.to_a }.to_h
322
422
  skills = agent.context.skills&.skills&.values || []
323
423
  commands = [
424
+ *@core_commands,
425
+ Riffer::Rig::Commands::Auth.command,
324
426
  Riffer::Rig::Commands::Model.command,
325
427
  *skills.map { |skill| Riffer::Rig::Commands::Skill.command(skill) },
326
428
  *registrars.flat_map { |registrar| registrar.commands.values }
@@ -545,7 +647,7 @@ class Riffer::Rig::Runtime
545
647
  def build_registrars(extensions, settings)
546
648
  extensions.filter_map do |extension|
547
649
  registrar = Riffer::Rig::Registrar.new(extension.name, settings[extension.name.to_sym] || {})
548
- error = load_extension(extension, registrar)
650
+ error = extension.load_into(registrar)
549
651
  next registrar unless error
550
652
 
551
653
  yield(extension, error)
@@ -553,19 +655,6 @@ class Riffer::Rig::Runtime
553
655
  end
554
656
  end
555
657
 
556
- # @rbs extension: Riffer::Rig::Extension
557
- # @rbs registrar: Riffer::Rig::Registrar
558
- # @rbs return: StandardError?
559
- def load_extension(extension, registrar)
560
- rejection = extension.mismatch || registrar.collision
561
- return rejection if rejection
562
-
563
- extension.run(registrar)
564
- nil
565
- rescue StandardError => e
566
- e
567
- end
568
-
569
658
  # @rbs extension: Riffer::Rig::Extension
570
659
  # @rbs error: StandardError
571
660
  # @rbs return: void
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Riffer::Rig::SDK
4
+ extend self
5
+
6
+ CONFIRM_QUESTION = 'Install the %s gem now?' #: String
7
+
8
+ PROGRESS_LABEL = 'Installing %s %s' #: String
9
+
10
+ BUNDLER_MESSAGE = "%s is not installed and Bundler manages the load path; add gem '%s', '%s' to the Gemfile" #: String
11
+
12
+ DECLINED_MESSAGE = "%s is not installed; install it with gem install %s -v '%s' or add it to a Gemfile" #: String
13
+
14
+ FAILED_MESSAGE = '%s was installed but could not be loaded' #: String
15
+
16
+ private_constant :CONFIRM_QUESTION, :PROGRESS_LABEL, :BUNDLER_MESSAGE, :DECLINED_MESSAGE, :FAILED_MESSAGE
17
+
18
+ # @rbs gem: String
19
+ # @rbs requirement: String
20
+ # @rbs host: Riffer::Rig::Hosts::_Host
21
+ # @rbs return: String?
22
+ def ensure(gem, requirement, host:)
23
+ return nil if installed?(gem, requirement) && require_gem(gem)
24
+ return format(BUNDLER_MESSAGE, gem, gem, requirement) if bundler?
25
+
26
+ return format(DECLINED_MESSAGE, gem, gem, requirement) unless host.confirm(format(CONFIRM_QUESTION, gem))
27
+
28
+ host.progress(format(PROGRESS_LABEL, gem, requirement)) do
29
+ Gem.install(gem, Gem::Requirement.new(requirement))
30
+ Gem.clear_paths
31
+ end
32
+ return nil if require_gem(gem)
33
+
34
+ format(FAILED_MESSAGE, gem)
35
+ end
36
+
37
+ private
38
+
39
+ # @rbs gem: String
40
+ # @rbs requirement: String
41
+ # @rbs return: bool
42
+ def installed?(gem, requirement)
43
+ Gem::Specification.find_by_name(gem, requirement)
44
+ true
45
+ rescue Gem::LoadError
46
+ false
47
+ end
48
+
49
+ # @rbs gem: String
50
+ # @rbs return: bool
51
+ def require_gem(gem)
52
+ require gem
53
+ true
54
+ rescue ::LoadError
55
+ false
56
+ end
57
+
58
+ # @rbs return: bool
59
+ def bundler?
60
+ !defined?(Bundler).nil?
61
+ end
62
+ end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Riffer::Rig::Settings::Document
4
- # @dynamic model, reasoning, models, providers, disabled
4
+ # @dynamic model, reasoning, models, providers, disabled, autoload, native_tools, save
5
5
  attr_reader :model #: String?
6
6
  attr_reader :reasoning #: String?
7
7
  attr_reader :models #: Hash[String, Riffer::Rig::Settings::Pricing]
8
8
  attr_reader :providers #: Hash[String, Hash[String, String]]
9
9
  attr_reader :disabled #: Array[String]
10
+ attr_reader :autoload #: bool
11
+ attr_reader :native_tools #: Hash[Symbol, bool | Hash[Symbol, untyped]]
12
+ attr_reader :save #: bool
10
13
 
11
14
  # @rbs source: Hash[Symbol, untyped]
12
15
  # @rbs return: void
@@ -26,6 +29,11 @@ class Riffer::Rig::Settings::Document
26
29
  disabled = hash_or_empty(source[:extensions])[:disabled]
27
30
  names = disabled.is_a?(Array) ? disabled.grep(String) : [] #: Array[String]
28
31
  @disabled = names.freeze
32
+ @autoload = hash_or_empty(source[:extensions])[:autoload] == true
33
+ @save = hash_or_empty(source[:sessions])[:save] != false
34
+ @native_tools = hash_or_empty(hash_or_empty(source[:tools])[:native])
35
+ .select { |_tool, switch| switch == true || switch.is_a?(Hash) }
36
+ .transform_keys(&:to_sym).freeze
29
37
  freeze
30
38
  end
31
39
 
@@ -16,9 +16,10 @@ module Riffer::Rig::Settings
16
16
 
17
17
  MODEL_STRING = %r{\A(?<provider>[^/\s]+)/\S+\z} #: Regexp
18
18
 
19
- # Upstream candidate: riffer's Repository keeps extension registrations
20
- # private and lists no identifiers, so the list names riffer's built-ins.
21
- PROVIDERS = (Riffer::Providers::Repository::REPO.keys - [:mock]).freeze #: Array[Symbol]
19
+ # @rbs return: Array[Symbol]
20
+ def providers
21
+ Riffer::Rig::Providers.identifiers
22
+ end
22
23
 
23
24
  # @rbs path: String
24
25
  # @rbs return: Hash[Symbol, untyped]
@@ -62,7 +63,7 @@ module Riffer::Rig::Settings
62
63
  provider = provider_for(model)
63
64
  return if provider && Riffer::Providers::Repository.find(provider)
64
65
 
65
- "#{model} is not a provider/name model string; the provider is one of: #{PROVIDERS.join(', ')}"
66
+ "#{model} is not a provider/name model string; the provider is one of: #{providers.join(', ')}"
66
67
  end
67
68
 
68
69
  # @rbs model: String
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ class Riffer::Rig::Stores::JSONL
7
+ HEADER_VERSION = 1 #: Integer
8
+
9
+ # @rbs @home: String
10
+ # @rbs @paths: Hash[String, String]
11
+
12
+ # @rbs home: String
13
+ # @rbs return: void
14
+ def initialize(home: Dir.home)
15
+ @home = home
16
+ @paths = {}
17
+ end
18
+
19
+ # @rbs id: String
20
+ # @rbs entry: Hash[Symbol, untyped]
21
+ # @rbs return: void
22
+ def append(id, entry)
23
+ File.write(path_for(id, entry), "#{JSON.generate(entry)}\n", mode: 'a')
24
+ end
25
+
26
+ # @rbs id: String
27
+ # @rbs return: Array[Hash[Symbol, untyped]]
28
+ def read(id)
29
+ path = find(id)
30
+ return [] unless path
31
+
32
+ File.foreach(path).filter_map { |line| parse(line) }
33
+ end
34
+
35
+ # @rbs cwd: String?
36
+ # @rbs return: Array[Hash[Symbol, untyped]]
37
+ def list(cwd: nil)
38
+ headers = Dir.glob(File.join(root, '*', '*.jsonl')).filter_map { |path| header_of(path) }
39
+ cwd ? headers.select { |header| header[:cwd] == cwd } : headers
40
+ end
41
+
42
+ # @rbs id: String
43
+ # @rbs return: void
44
+ def delete(id)
45
+ path = find(id)
46
+ return unless path
47
+
48
+ File.delete(path)
49
+ @paths.delete(id)
50
+ end
51
+
52
+ private
53
+
54
+ # @rbs return: String
55
+ def root
56
+ File.join(@home, '.riffer', 'sessions')
57
+ end
58
+
59
+ # @rbs id: String
60
+ # @rbs entry: Hash[Symbol, untyped]
61
+ # @rbs return: String
62
+ def path_for(id, entry)
63
+ @paths.fetch(id) do
64
+ path = entry[:type] == 'header' ? new_path(id, entry.fetch(:cwd)) : find(id)
65
+ raise ArgumentError, "no session file for #{id}" unless path
66
+
67
+ @paths[id] = path
68
+ end
69
+ end
70
+
71
+ # @rbs id: String
72
+ # @rbs cwd: String
73
+ # @rbs return: String
74
+ def new_path(id, cwd)
75
+ dir = File.join(root, slug(cwd))
76
+ FileUtils.mkdir_p(dir)
77
+ File.join(dir, "#{id}.jsonl")
78
+ end
79
+
80
+ # @rbs path: String
81
+ # @rbs return: Hash[Symbol, untyped]?
82
+ def header_of(path)
83
+ line = File.foreach(path).first
84
+ entry = parse(line) if line
85
+ entry if entry.is_a?(Hash) && entry[:type] == 'header'
86
+ end
87
+
88
+ # @rbs id: String
89
+ # @rbs return: String?
90
+ def find(id)
91
+ @paths.fetch(id) do
92
+ found = Dir.glob(File.join(root, '*', "#{id}.jsonl")).first
93
+ @paths[id] = found if found
94
+ found
95
+ end
96
+ end
97
+
98
+ # The slug groups a cwd's sessions for listing; it is not the truth — the
99
+ # header's cwd is, so two cwd strings that slug alike share a directory. A
100
+ # bare "." or ".." is collapsed so the file stays inside the sessions tree.
101
+ # @rbs cwd: String
102
+ # @rbs return: String
103
+ def slug(cwd)
104
+ slug = cwd.gsub(/[^A-Za-z0-9._-]+/, '-').gsub(/\A-+|-+\z/, '')
105
+ slug.empty? || slug.match?(/\A\.{1,2}\z/) ? '-' : slug
106
+ end
107
+
108
+ # @rbs line: String
109
+ # @rbs return: Hash[Symbol, untyped]?
110
+ def parse(line)
111
+ JSON.parse(line, symbolize_names: true)
112
+ rescue JSON::ParserError
113
+ nil
114
+ end
115
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ class Riffer::Rig::Stores::Recorder
6
+ EXTENSION_NAME = 'session-store' #: String
7
+
8
+ # @rbs @store: Riffer::Rig::Stores::_Store
9
+ # @rbs @runtime: Riffer::Rig::Runtime?
10
+ # @rbs @header_written: bool
11
+ # @rbs @pending: Array[Hash[Symbol, untyped]]
12
+ # @rbs @title: String?
13
+
14
+ # Records one Loader-built session into the store: the header on the first
15
+ # message (its title needs the first prompt), one entry per message, one per
16
+ # model switch and one per skill activation. Entries that land before the
17
+ # first message (a /model before the first prompt) wait for the header.
18
+ # @rbs store: Riffer::Rig::Stores::_Store
19
+ # @rbs return: void
20
+ def initialize(store:)
21
+ @store = store
22
+ @runtime = nil
23
+ @header_written = false
24
+ @pending = []
25
+ @title = nil
26
+ end
27
+
28
+ # @rbs runtime: Riffer::Rig::Runtime
29
+ # @rbs return: void
30
+ def attach(runtime)
31
+ @runtime = runtime
32
+ runtime.on_message { |message| message(message) }
33
+ runtime.on_model_change { |model| switched(model) }
34
+ end
35
+
36
+ # @rbs event: Riffer::StreamEvents::SkillActivation
37
+ # @rbs return: void
38
+ def skill(event)
39
+ record(type: 'skill', skill: event.name)
40
+ end
41
+
42
+ # @rbs message: Riffer::Messages::Base
43
+ # @rbs return: void
44
+ def message(message)
45
+ @title ||= one_line(message.content) if message.is_a?(Riffer::Messages::User)
46
+ write_header unless @header_written
47
+ record(type: 'message', message: message.to_h)
48
+ end
49
+
50
+ # @rbs model: String
51
+ # @rbs return: void
52
+ def switched(model)
53
+ record(type: 'model', model: model)
54
+ end
55
+
56
+ # The extension rides the build as its last extension, so its :stream hook
57
+ # sees the model's skill activations. Per build, unlike the extensions in
58
+ # the process registry: the hook closes over this build's recorder.
59
+ # @rbs recorder: Riffer::Rig::Stores::Recorder
60
+ # @rbs return: Riffer::Rig::Extension
61
+ def self.extension(recorder)
62
+ Riffer::Rig::Extension.new(EXTENSION_NAME) do |registrar|
63
+ registrar.on(:stream) do |event|
64
+ case event
65
+ when Riffer::StreamEvents::SkillActivation then recorder.skill(event)
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # @rbs entry: Hash[Symbol, untyped]
74
+ # @rbs return: void
75
+ def record(entry)
76
+ unless @header_written
77
+ @pending << entry
78
+ return
79
+ end
80
+
81
+ @store.append(runtime.id, entry)
82
+ end
83
+
84
+ # @rbs return: void
85
+ def write_header
86
+ @header_written = true
87
+ @store.append(runtime.id, header_entry)
88
+ @pending.each { |entry| @store.append(runtime.id, entry) }
89
+ @pending.clear
90
+ end
91
+
92
+ # @rbs return: Hash[Symbol, untyped]
93
+ def header_entry
94
+ {
95
+ type: 'header',
96
+ schema_version: Riffer::Rig::Stores::JSONL::HEADER_VERSION,
97
+ id: runtime.id,
98
+ cwd: runtime.cwd,
99
+ created_at: Time.now.utc.iso8601,
100
+ model: runtime.model,
101
+ riffer_rig_version: Riffer::Rig::VERSION,
102
+ riffer_version: Riffer::VERSION,
103
+ title: @title.to_s
104
+ }
105
+ end
106
+
107
+ # @rbs content: String
108
+ # @rbs return: String
109
+ def one_line(content)
110
+ content.lines.first.to_s.strip
111
+ end
112
+
113
+ # The recorder is built before the Runtime it records; every entry arrives
114
+ # after attach, so the runtime is always there by then.
115
+ # @rbs return: Riffer::Rig::Runtime
116
+ def runtime
117
+ @runtime || raise(StandardError, 'Recorder used before attach')
118
+ end
119
+ end