riffer-rig 0.10.0 → 0.12.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +107 -30
  4. data/lib/riffer/rig/acp/host.rb +83 -0
  5. data/lib/riffer/rig/acp.rb +353 -0
  6. data/lib/riffer/rig/bundled/mcp.rb +6 -1
  7. data/lib/riffer/rig/cli/flags.rb +64 -18
  8. data/lib/riffer/rig/cli/sessions.rb +124 -0
  9. data/lib/riffer/rig/cli.rb +39 -52
  10. data/lib/riffer/rig/headless/host.rb +55 -0
  11. data/lib/riffer/rig/headless/ndjson.rb +49 -0
  12. data/lib/riffer/rig/headless.rb +157 -0
  13. data/lib/riffer/rig/loader.rb +39 -12
  14. data/lib/riffer/rig/mcp/auth.rb +59 -0
  15. data/lib/riffer/rig/mcp/declaration.rb +29 -5
  16. data/lib/riffer/rig/registrar.rb +11 -3
  17. data/lib/riffer/rig/runtime.rb +35 -1
  18. data/lib/riffer/rig/stores/header.rb +107 -0
  19. data/lib/riffer/rig/stores/jsonl.rb +37 -24
  20. data/lib/riffer/rig/stores/message_entry.rb +31 -0
  21. data/lib/riffer/rig/stores/model_entry.rb +31 -0
  22. data/lib/riffer/rig/stores/recorder.rb +8 -9
  23. data/lib/riffer/rig/stores/skill_entry.rb +31 -0
  24. data/lib/riffer/rig/terminal/picker.rb +144 -0
  25. data/lib/riffer/rig/terminal/session.rb +33 -0
  26. data/lib/riffer/rig/terminal.rb +113 -17
  27. data/lib/riffer/rig/version.rb +1 -1
  28. data/lib/riffer/rig.rb +1 -1
  29. data/sig/generated/riffer/rig/acp/host.rbs +53 -0
  30. data/sig/generated/riffer/rig/acp.rbs +169 -0
  31. data/sig/generated/riffer/rig/cli/flags.rbs +31 -3
  32. data/sig/generated/riffer/rig/cli/sessions.rbs +72 -0
  33. data/sig/generated/riffer/rig/cli.rbs +14 -17
  34. data/sig/generated/riffer/rig/headless/host.rbs +37 -0
  35. data/sig/generated/riffer/rig/headless/ndjson.rbs +27 -0
  36. data/sig/generated/riffer/rig/headless.rbs +78 -0
  37. data/sig/generated/riffer/rig/loader.rbs +19 -6
  38. data/sig/generated/riffer/rig/mcp/auth.rbs +32 -0
  39. data/sig/generated/riffer/rig/mcp/declaration.rbs +23 -4
  40. data/sig/generated/riffer/rig/registrar.rbs +6 -2
  41. data/sig/generated/riffer/rig/runtime.rbs +16 -3
  42. data/sig/generated/riffer/rig/stores/header.rbs +67 -0
  43. data/sig/generated/riffer/rig/stores/jsonl.rbs +21 -17
  44. data/sig/generated/riffer/rig/stores/message_entry.rbs +21 -0
  45. data/sig/generated/riffer/rig/stores/model_entry.rbs +21 -0
  46. data/sig/generated/riffer/rig/stores/recorder.rbs +5 -5
  47. data/sig/generated/riffer/rig/stores/skill_entry.rbs +21 -0
  48. data/sig/generated/riffer/rig/terminal/picker.rbs +66 -0
  49. data/sig/generated/riffer/rig/terminal/session.rbs +32 -0
  50. data/sig/generated/riffer/rig/terminal.rbs +52 -8
  51. data/sig/manual/riffer/rig/events.rbs +1 -0
  52. data/sig/manual/riffer/rig/headless/ndjson.rbs +6 -0
  53. data/sig/manual/riffer/rig/headless.rbs +7 -0
  54. data/sig/manual/riffer/rig/mcp/auth.rbs +6 -0
  55. data/sig/manual/riffer/rig/stores.rbs +7 -5
  56. data/sig/manual/riffer/rig/terminal.rbs +12 -0
  57. metadata +45 -1
@@ -3,7 +3,12 @@
3
3
  module Riffer::Rig::Bundled
4
4
  Mcp = Riffer::Rig::Extension.new('mcp') do |rig|
5
5
  rig.settings[:servers].to_h.each do |name, server|
6
- rig.mcp(name.to_s, url: server.fetch(:url), headers: server[:headers].to_h.transform_keys(&:to_s))
6
+ rig.mcp(
7
+ name.to_s,
8
+ url: server.fetch(:url),
9
+ headers: server[:headers].to_h.transform_keys(&:to_s),
10
+ auth: Hash(server[:auth])
11
+ )
7
12
  end
8
13
  end #: Riffer::Rig::Extension
9
14
  end
@@ -5,38 +5,43 @@ require 'optparse'
5
5
  class Riffer::Rig::CLI::Flags
6
6
  BANNER = <<~TEXT.chomp #: String
7
7
  Usage: riffer [options]
8
- riffer -p [prompt] (not available yet)
8
+ riffer -p [prompt] run one prompt headless and exit
9
9
  riffer acp (not available yet)
10
10
  TEXT
11
11
 
12
- # @dynamic model, extensions, skills, agents_md, tools, max_steps, help, save, continue, resume
12
+ # The tokens OptionParser reports a missing argument for when -r is bare.
13
+ BARE_RESUME = %w[-r --resume].freeze #: Array[String]
14
+
15
+ # @dynamic model, extensions, skills, agents_md, tools, max_steps, help, save, verbose, json, prompt, continue, resume
13
16
  attr_reader :model #: String?
14
- attr_reader :extensions, :skills, :agents_md, :help #: bool
17
+ attr_reader :extensions, :skills, :agents_md, :help, :verbose, :json #: bool
15
18
  attr_reader :tools #: Array[String]?
16
19
  attr_reader :max_steps #: Integer?
17
20
  attr_reader :save #: bool
21
+ attr_reader :prompt #: String?
18
22
  attr_reader :continue #: bool
19
23
  attr_reader :resume #: String?
20
24
 
25
+ # A bare -r (no id) only reaches here for the REPL: the parser requires the
26
+ # id, and the missing-argument error is translated to an empty resume so the
27
+ # terminal can open the picker instead. Headless keeps the error and prints
28
+ # it as the usage failure.
29
+ #
21
30
  # @rbs argv: Array[String]
31
+ # @rbs prompt: bool
22
32
  # @rbs return: Riffer::Rig::CLI::Flags | String
23
- def self.parse(argv)
33
+ def self.parse(argv, prompt: false)
24
34
  values = {} #: Hash[Symbol, untyped]
25
35
  rest = parser.parse(argv, into: values)
26
- return "unexpected argument: #{rest.join(' ')}" unless rest.empty?
36
+ allowed = rest.empty? || (prompt && rest.size == 1)
37
+ return "unexpected argument: #{rest.join(' ')}" unless allowed
27
38
 
28
- new(
29
- model: values[:model],
30
- extensions: !values.key?(:'no-extensions'),
31
- skills: !values.key?(:'no-skills'),
32
- agents_md: !values.key?(:'no-agents-md'),
33
- tools: values[:tools],
34
- max_steps: values[:'max-steps'],
35
- save: !values.key?(:'no-save'),
36
- continue: values.key?(:continue),
37
- resume: values[:resume],
38
- help: values.key?(:help)
39
- )
39
+ assemble(values, rest.first, prompt: prompt)
40
+ rescue OptionParser::MissingArgument => e
41
+ bare = !prompt && BARE_RESUME.include?(e.args.first.to_s)
42
+ return assemble(values || {}, nil, prompt: prompt, bare_resume: bare) if bare
43
+
44
+ e.message
40
45
  rescue OptionParser::ParseError => e
41
46
  e.message
42
47
  end
@@ -46,6 +51,11 @@ class Riffer::Rig::CLI::Flags
46
51
  parser.help
47
52
  end
48
53
 
54
+ # @rbs return: Hash[Symbol, untyped]
55
+ def keywords
56
+ { model: model, extensions: extensions, skills: skills, agents_md: agents_md, tools: tools, max_steps: max_steps }
57
+ end
58
+
49
59
  # @rbs return: OptionParser
50
60
  def self.parser
51
61
  OptionParser.new do |parser|
@@ -59,12 +69,39 @@ class Riffer::Rig::CLI::Flags
59
69
  parser.on('--max-steps N', Integer, 'Stop a turn after N model calls')
60
70
  parser.on('--no-save', 'Do not save this session')
61
71
  parser.on('-c', '--continue', 'Continue the most recent session in this directory')
62
- parser.on('-r ID', '--resume ID', String, 'Resume the session with this id')
72
+ parser.on('-r ID', '--resume ID', String, 'Resume the session with this id; bare -r opens the picker in the REPL')
73
+ parser.on('-p', '--print', 'Run one prompt headless and exit; the prompt is the argument, else stdin')
74
+ parser.on('--verbose', 'With -p, trace each tool call to stderr')
75
+ parser.on('--json', 'With -p, stream every Runtime event as NDJSON to stdout')
63
76
  parser.on('-h', '--help', 'Show this help')
64
77
  end
65
78
  end
66
79
  private_class_method :parser
67
80
 
81
+ # @rbs values: Hash[Symbol, untyped]
82
+ # @rbs head: String?
83
+ # @rbs prompt: bool
84
+ # @rbs bare_resume: bool
85
+ # @rbs return: Riffer::Rig::CLI::Flags
86
+ def self.assemble(values, head, prompt:, bare_resume: false)
87
+ new(
88
+ model: values[:model],
89
+ extensions: !values.key?(:'no-extensions'),
90
+ skills: !values.key?(:'no-skills'),
91
+ agents_md: !values.key?(:'no-agents-md'),
92
+ tools: values[:tools],
93
+ max_steps: values[:'max-steps'],
94
+ save: !values.key?(:'no-save'),
95
+ verbose: values.key?(:verbose),
96
+ json: values.key?(:json),
97
+ prompt: prompt ? head : nil,
98
+ continue: values.key?(:continue),
99
+ resume: bare_resume ? '' : values[:resume],
100
+ help: values.key?(:help)
101
+ )
102
+ end
103
+ private_class_method :assemble
104
+
68
105
  # @rbs model: String?
69
106
  # @rbs extensions: bool
70
107
  # @rbs skills: bool
@@ -72,6 +109,9 @@ class Riffer::Rig::CLI::Flags
72
109
  # @rbs tools: Array[String]?
73
110
  # @rbs max_steps: Integer?
74
111
  # @rbs save: bool
112
+ # @rbs verbose: bool
113
+ # @rbs json: bool
114
+ # @rbs prompt: String?
75
115
  # @rbs continue: bool
76
116
  # @rbs resume: String?
77
117
  # @rbs help: bool
@@ -84,6 +124,9 @@ class Riffer::Rig::CLI::Flags
84
124
  tools: nil,
85
125
  max_steps: nil,
86
126
  save: true,
127
+ verbose: false,
128
+ json: false,
129
+ prompt: nil,
87
130
  continue: false,
88
131
  resume: nil,
89
132
  help: false
@@ -95,6 +138,9 @@ class Riffer::Rig::CLI::Flags
95
138
  @tools = tools&.freeze
96
139
  @max_steps = max_steps
97
140
  @save = save
141
+ @verbose = verbose
142
+ @json = json
143
+ @prompt = prompt
98
144
  @continue = continue
99
145
  @resume = resume
100
146
  @help = help
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::CLI::Sessions
4
+ # The terminal's sessions collaborator: translates the parsed flags into
5
+ # Loader builds and store lookups, so the host never sees the flags.
6
+ #
7
+ # @rbs @flags: Riffer::Rig::CLI::Flags
8
+ # @rbs @env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
9
+ # @rbs @cwd: String
10
+ # @rbs @home: String
11
+ # @rbs @store: Riffer::Rig::Stores::_Store?
12
+ # @rbs @loader: Riffer::Rig::Loader?
13
+
14
+ # @rbs flags: Riffer::Rig::CLI::Flags
15
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
16
+ # @rbs cwd: String
17
+ # @rbs home: String
18
+ # @rbs return: void
19
+ def initialize(flags:, env:, cwd:, home:)
20
+ @flags = flags
21
+ @env = env
22
+ @cwd = cwd
23
+ @home = home
24
+ @store = flags.save ? Riffer::Rig::Stores::JSONL.new(home: home) : nil
25
+ @loader = nil
26
+ end
27
+
28
+ # @rbs host: Riffer::Rig::Hosts::_Host
29
+ # @rbs return: Riffer::Rig::Runtime
30
+ def start(host)
31
+ started = find(host)
32
+ host.notify(missing) if started.nil? && asked?
33
+
34
+ started || loader(host).runtime(**@flags.keywords)
35
+ end
36
+
37
+ # @rbs host: Riffer::Rig::Hosts::_Host
38
+ # @rbs return: Riffer::Rig::Runtime?
39
+ def find(host)
40
+ id = resume_id
41
+ if id
42
+ loader(host).resume(id, **@flags.keywords)
43
+ elsif @flags.continue
44
+ loader(host).continue(**@flags.keywords)
45
+ end
46
+ end
47
+
48
+ # @rbs host: Riffer::Rig::Hosts::_Host
49
+ # @rbs return: Riffer::Rig::Runtime
50
+ def fresh(host)
51
+ loader(host).runtime(**@flags.keywords)
52
+ end
53
+
54
+ # @rbs host: Riffer::Rig::Hosts::_Host
55
+ # @rbs id: String
56
+ # @rbs return: Riffer::Rig::Runtime?
57
+ def resume(host, id)
58
+ loader(host).resume(id, **@flags.keywords)
59
+ end
60
+
61
+ # @rbs host: Riffer::Rig::Hosts::_Host
62
+ # @rbs all: bool
63
+ # @rbs return: Array[::Riffer::Rig::Terminal::Session]
64
+ def list(host, all: false)
65
+ loader(host)
66
+ .list(all: all)
67
+ # The id breaks mtime ties so the rows never shuffle under sort_by.
68
+ .sort_by { |header| [header.updated ? -header.updated.to_f : 0.0, header.id] }
69
+ .map { |header| row(header) }
70
+ end
71
+
72
+ # @rbs host: Riffer::Rig::Hosts::_Host
73
+ # @rbs id: String
74
+ # @rbs return: void
75
+ def delete(host, id)
76
+ loader(host).delete(id)
77
+ end
78
+
79
+ # @rbs return: bool
80
+ def asked?
81
+ resume_id ? true : @flags.continue
82
+ end
83
+
84
+ # @rbs return: String
85
+ def missing
86
+ id = resume_id
87
+ id ? "No saved session #{id}." : 'No saved session in this directory.'
88
+ end
89
+
90
+ private
91
+
92
+ # @rbs header: Riffer::Rig::Stores::Header
93
+ # @rbs return: ::Riffer::Rig::Terminal::Session
94
+ def row(header)
95
+ Riffer::Rig::Terminal::Session.new(
96
+ id: header.id,
97
+ title: header.title.gsub(/\s+/, ' ').strip,
98
+ updated: header.updated,
99
+ messages: message_count(header.id),
100
+ cwd: header.cwd
101
+ )
102
+ end
103
+
104
+ # @rbs id: String
105
+ # @rbs return: Integer
106
+ def message_count(id)
107
+ store = @store
108
+ return 0 unless store
109
+
110
+ store.read(id).count { |entry| entry.is_a?(Riffer::Rig::Stores::MessageEntry) }
111
+ end
112
+
113
+ # @rbs host: Riffer::Rig::Hosts::_Host
114
+ # @rbs return: Riffer::Rig::Loader
115
+ def loader(host)
116
+ @loader ||= Riffer::Rig::Loader.new(cwd: @cwd, host: host, env: @env, home: @home, store: @store)
117
+ end
118
+
119
+ # @rbs return: String?
120
+ def resume_id
121
+ id = @flags.resume
122
+ id unless id.nil? || id.empty?
123
+ end
124
+ end
@@ -26,12 +26,17 @@ module Riffer::Rig::CLI
26
26
  cwd: Dir.pwd,
27
27
  home: Dir.home
28
28
  )
29
- return unavailable("riffer #{ACP_COMMAND}", error) if argv.first == ACP_COMMAND
30
- return unavailable('riffer -p', error) if argv.intersect?(PRINT_FLAGS)
29
+ return refuse("#{ACP_COMMAND} takes no arguments", error) if argv.first == ACP_COMMAND && argv.length > 1
30
+ return acp(input: input, output: output, env: env, home: home) if argv.first == ACP_COMMAND
31
31
 
32
- flags = Flags.parse(argv)
32
+ headless_mode = argv.intersect?(PRINT_FLAGS)
33
+ flags = Flags.parse(argv, prompt: headless_mode)
33
34
  return refuse(flags, error) if flags.is_a?(String)
34
35
  return help(output) if flags.help
36
+ return refuse('--verbose is only available with -p', error) if flags.verbose && !headless_mode
37
+ return refuse('--json is only available with -p', error) if flags.json && !headless_mode
38
+
39
+ return headless(flags, input:, output:, error:, env:, cwd:, home:) if headless_mode
35
40
 
36
41
  terminal(flags, input:, output:, env:, cwd:, home:)
37
42
  end
@@ -41,69 +46,51 @@ module Riffer::Rig::CLI
41
46
  # @rbs flags: Riffer::Rig::CLI::Flags
42
47
  # @rbs input: IO
43
48
  # @rbs output: IO
49
+ # @rbs error: IO
44
50
  # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
45
51
  # @rbs cwd: String
46
52
  # @rbs home: String
47
53
  # @rbs return: Integer
48
- def terminal(flags, input:, output:, env:, cwd:, home:)
49
- Riffer::Rig::Terminal.for(
54
+ def headless(flags, input:, output:, error:, env:, cwd:, home:)
55
+ sessions = Sessions.new(flags: flags, env: env, cwd: cwd, home: home)
56
+ Riffer::Rig::Headless.for(
50
57
  input: input,
51
58
  output: output,
52
- version: Riffer::Rig::VERSION,
53
- no_color: env.is_a?(Riffer::Rig::Env) && env.no_color
54
- ).run do |host|
55
- loader = Riffer::Rig::Loader.new(
56
- cwd: cwd,
57
- host: host,
58
- env: env,
59
- home: home,
60
- store: flags.save ? Riffer::Rig::Stores::JSONL.new : nil
61
- )
62
- resumed(flags, host, loader) || build(loader, flags)
63
- end
64
- end
59
+ error: error,
60
+ verbose: flags.verbose,
61
+ json: flags.json
62
+ ).run(prompt: flags.prompt) do |host|
63
+ started = sessions.find(host)
64
+ raise Riffer::Rig::Loader::ConfigurationError, sessions.missing if started.nil? && sessions.asked?
65
65
 
66
- # @rbs flags: Riffer::Rig::CLI::Flags
67
- # @rbs host: Riffer::Rig::Hosts::_Host
68
- # @rbs loader: Riffer::Rig::Loader
69
- # @rbs return: Riffer::Rig::Runtime?
70
- def resumed(flags, host, loader)
71
- if flags.resume
72
- started = loader.resume(flags.resume, **keywords(flags))
73
- host.notify("No saved session #{flags.resume}.") unless started
74
- started
75
- elsif flags.continue
76
- started = loader.continue(**keywords(flags))
77
- host.notify('No saved session in this directory.') unless started
78
- started
66
+ started || sessions.fresh(host)
79
67
  end
80
68
  end
81
69
 
82
- # @rbs loader: Riffer::Rig::Loader
83
- # @rbs flags: Riffer::Rig::CLI::Flags
84
- # @rbs return: Riffer::Rig::Runtime
85
- def build(loader, flags)
86
- loader.runtime(**keywords(flags))
87
- end
88
-
89
70
  # @rbs flags: Riffer::Rig::CLI::Flags
90
- # @rbs return: Hash[Symbol, untyped]
91
- def keywords(flags)
92
- {
93
- model: flags.model,
94
- extensions: flags.extensions,
95
- skills: flags.skills,
96
- agents_md: flags.agents_md,
97
- tools: flags.tools,
98
- max_steps: flags.max_steps
99
- }
71
+ # @rbs input: IO
72
+ # @rbs output: IO
73
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
74
+ # @rbs cwd: String
75
+ # @rbs home: String
76
+ # @rbs return: Integer
77
+ def terminal(flags, input:, output:, env:, cwd:, home:)
78
+ Riffer::Rig::Terminal.for(
79
+ input: input,
80
+ output: output,
81
+ version: Riffer::Rig::VERSION,
82
+ no_color: env.is_a?(Riffer::Rig::Env) && env.no_color,
83
+ sessions: Sessions.new(flags: flags, env: env, cwd: cwd, home: home)
84
+ ).run(open_picker: flags.resume == '')
100
85
  end
101
86
 
102
- # @rbs name: String
103
- # @rbs error: IO
87
+ # @rbs input: IO
88
+ # @rbs output: IO
89
+ # @rbs env: Riffer::Rig::Env | Riffer::Rig::Env::Invalid
90
+ # @rbs home: String
104
91
  # @rbs return: Integer
105
- def unavailable(name, error)
106
- refuse("#{name} is not available yet", error)
92
+ def acp(input:, output:, env:, home:)
93
+ Riffer::Rig::ACP.for(input: input, output: output, env: env, home: home).run
107
94
  end
108
95
 
109
96
  # @rbs message: String
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Headless::Host
4
+ CAPABILITIES = Set[:notify, :progress].freeze #: Set[Symbol]
5
+
6
+ # @rbs @error: IO
7
+ # @rbs @json: bool
8
+
9
+ # @rbs error: IO
10
+ # @rbs json: bool
11
+ # @rbs return: void
12
+ def initialize(error: $stderr, json: false)
13
+ @error = error
14
+ @json = json
15
+ end
16
+
17
+ # @rbs return: Set[Symbol]
18
+ def capabilities
19
+ CAPABILITIES
20
+ end
21
+
22
+ # @rbs question: String?
23
+ # @rbs options: Array[String]?
24
+ # @rbs secret: bool
25
+ # @rbs return: String?
26
+ def ask(question = nil, options: nil, secret: false)
27
+ nil
28
+ end
29
+
30
+ # @rbs question: String?
31
+ # @rbs return: bool
32
+ def confirm(question = nil)
33
+ false
34
+ end
35
+
36
+ # @rbs message: String?
37
+ # @rbs level: Symbol
38
+ # @rbs return: void
39
+ def notify(message = nil, level: :info)
40
+ # In --json the Mirror queues the Notify event onto the Runtime's stream,
41
+ # where the printer emits it as a line; printing here would duplicate it
42
+ # on stderr.
43
+ return if @json
44
+
45
+ @error.puts(message.to_s)
46
+ end
47
+
48
+ # @rbs label: String?
49
+ # @rbs &block: ? () -> void
50
+ # @rbs return: void
51
+ def progress(label = nil, &block)
52
+ @error.puts(label) if label
53
+ block&.call
54
+ end
55
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Riffer::Rig::Headless::Ndjson
6
+ extend self
7
+
8
+ # @rbs event: ::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event
9
+ # @rbs return: String
10
+ def line(event)
11
+ "#{JSON.generate(envelope(event))}\n"
12
+ end
13
+
14
+ # @rbs message: String
15
+ # @rbs return: String
16
+ def error(message)
17
+ "#{JSON.generate({ 'type' => 'error', 'message' => message })}\n"
18
+ end
19
+
20
+ # @rbs event: ::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event
21
+ # @rbs return: Hash[String, Riffer::Rig::Headless::json]
22
+ def envelope(event)
23
+ json_hash(event.to_h).merge('type' => event_type(event))
24
+ end
25
+
26
+ # @rbs hash: Hash[Symbol, untyped]
27
+ # @rbs return: Hash[String, Riffer::Rig::Headless::json]
28
+ def json_hash(hash)
29
+ hash.to_h { |key, item| [key.to_s, json_value(item)] }
30
+ end
31
+
32
+ # @rbs event: ::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event
33
+ # @rbs return: String
34
+ def event_type(event)
35
+ event.class.name.to_s.split('::').last.to_s.gsub(/(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/, '_').downcase
36
+ end
37
+
38
+ # @rbs value: untyped
39
+ # @rbs return: Riffer::Rig::Headless::json
40
+ def json_value(value)
41
+ case value
42
+ when String, Integer, Float, true, false, nil then value
43
+ when Symbol then value.to_s
44
+ when Hash then value.to_h { |key, item| [key.to_s, json_value(item)] }
45
+ when Array then value.map { |item| json_value(item) }
46
+ else value.respond_to?(:to_h) ? json_value(value.to_h) : value.to_s
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Headless
4
+ USAGE_ERROR = 2 #: Integer
5
+
6
+ # The plan's exit-3 set: the cap plus the provider stop reasons that mean the
7
+ # turn ended without completing. Riffer's remaining stop reasons (:error,
8
+ # :other, :guardrail_blocked) are runtime failures and map to 1.
9
+ STOPPED_WITHOUT_COMPLETING = %i[
10
+ max_steps context_window length content_filter malformed_output
11
+ ].freeze #: Array[Symbol]
12
+
13
+ # @rbs @input: IO
14
+ # @rbs @output: IO
15
+ # @rbs @error: IO
16
+ # @rbs @host: Riffer::Rig::Headless::Host
17
+ # @rbs @verbose: bool
18
+ # @rbs @json: bool
19
+ # @rbs @streamed_text: bool
20
+
21
+ # @rbs input: IO
22
+ # @rbs output: IO
23
+ # @rbs error: IO
24
+ # @rbs verbose: bool
25
+ # @rbs json: bool
26
+ # @rbs return: Riffer::Rig::Headless
27
+ def self.for(input:, output:, error:, verbose: false, json: false)
28
+ host = Riffer::Rig::Headless::Host.new(error: error, json: json)
29
+ new(input: input, output: output, error: error, host: host, verbose: verbose, json: json)
30
+ end
31
+
32
+ # @rbs input: IO
33
+ # @rbs output: IO
34
+ # @rbs error: IO
35
+ # @rbs host: Riffer::Rig::Headless::Host
36
+ # @rbs verbose: bool
37
+ # @rbs json: bool
38
+ # @rbs return: void
39
+ def initialize(input:, output:, error:, host:, verbose: false, json: false)
40
+ @input = input
41
+ @output = output
42
+ @error = error
43
+ @host = host
44
+ @verbose = verbose
45
+ @json = json
46
+ end
47
+
48
+ # @rbs prompt: String?
49
+ # @rbs &build: (Riffer::Rig::Hosts::_Host) -> Riffer::Rig::Runtime
50
+ # @rbs return: Integer
51
+ def run(prompt:, &build)
52
+ text = prompt_text(prompt)
53
+ return usage_error if text.empty?
54
+
55
+ drive(yield(@host), text)
56
+ rescue Riffer::Rig::Loader::ConfigurationError => e
57
+ fatal(e.message, USAGE_ERROR)
58
+ rescue Interrupt
59
+ 130
60
+ rescue StandardError => e
61
+ fatal(e.message, 1)
62
+ end
63
+
64
+ private
65
+
66
+ # @rbs prompt: String?
67
+ # @rbs return: String
68
+ def prompt_text(prompt)
69
+ piped = @input.tty? ? '' : @input.read.to_s.strip
70
+ [prompt, piped].compact.reject(&:empty?).join("\n\n")
71
+ end
72
+
73
+ # @rbs return: Integer
74
+ def usage_error
75
+ fatal('no prompt: pass one as an argument, or pipe it to stdin', USAGE_ERROR)
76
+ end
77
+
78
+ # @rbs message: String
79
+ # @rbs code: Integer
80
+ # @rbs return: Integer
81
+ def fatal(message, code)
82
+ if @json
83
+ @output.write(Ndjson.error(message))
84
+ else
85
+ @error.puts("riffer: #{message}")
86
+ end
87
+ code
88
+ end
89
+
90
+ # @rbs runtime: Riffer::Rig::Runtime
91
+ # @rbs text: String
92
+ # @rbs return: Integer
93
+ def drive(runtime, text)
94
+ turn(text, runtime)
95
+ ensure
96
+ runtime.close
97
+ end
98
+
99
+ # @rbs text: String
100
+ # @rbs runtime: Riffer::Rig::Runtime
101
+ # @rbs return: Integer
102
+ def turn(text, runtime)
103
+ # @type var stop_reason: Symbol?
104
+ stop_reason = nil
105
+ # Runtime#cancel takes a Mutex, which Ruby refuses inside a trap handler,
106
+ # so the handler hands the cancel to a thread.
107
+ previous = Signal.trap('INT') { Thread.new { runtime.cancel } }
108
+ begin
109
+ runtime.prompt(text) do |event|
110
+ case event
111
+ when Riffer::Rig::Events::TurnEnd
112
+ stop_reason = event.stop_reason
113
+ @output.write(Ndjson.line(event)) if @json
114
+ else
115
+ handle(event)
116
+ end
117
+ end
118
+ rescue StandardError => e
119
+ return fatal(e.message, 1)
120
+ ensure
121
+ Signal.trap('INT', previous)
122
+ end
123
+ finish(stop_reason)
124
+ end
125
+
126
+ # @rbs event: ::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event
127
+ # @rbs return: void
128
+ def handle(event)
129
+ return @output.write(Ndjson.line(event)) if @json
130
+
131
+ case event
132
+ when Riffer::StreamEvents::TextDelta
133
+ @output.write(event.content)
134
+ @streamed_text = true
135
+ when Riffer::StreamEvents::TextDone
136
+ # A provider that streams nothing hands over the whole text here; one
137
+ # that streamed deltas has already written it.
138
+ @output.write(event.content) if !@streamed_text && !event.content.empty?
139
+ @output.puts if @streamed_text || !event.content.empty?
140
+ @streamed_text = false
141
+ when Riffer::StreamEvents::ToolCallDone
142
+ @error.puts("tool_call #{event.name} #{event.arguments}") if @verbose
143
+ end
144
+ end
145
+
146
+ # @rbs stop_reason: Symbol?
147
+ # @rbs return: Integer
148
+ def finish(stop_reason)
149
+ return 0 if stop_reason == :completed
150
+
151
+ @error.puts("turn ended: #{stop_reason}") if stop_reason
152
+ return 130 if stop_reason == Riffer::Rig::Runtime::INTERRUPT_CANCELLED
153
+ return 3 if stop_reason && STOPPED_WITHOUT_COMPLETING.include?(stop_reason)
154
+
155
+ 1
156
+ end
157
+ end