riffer-rig 0.3.0 → 0.5.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 +27 -0
- data/lib/riffer/rig/cli.rb +16 -8
- data/lib/riffer/rig/coding_agent.rb +31 -34
- data/lib/riffer/rig/credentials.rb +7 -7
- data/lib/riffer/rig/extension.rb +31 -0
- data/lib/riffer/rig/host.rb +52 -0
- data/lib/riffer/rig/registrar.rb +23 -0
- data/lib/riffer/rig/repl.rb +63 -59
- data/lib/riffer/rig/runtime.rb +162 -0
- data/lib/riffer/rig/settings/document.rb +23 -0
- data/lib/riffer/rig/settings/pricing.rb +11 -0
- data/lib/riffer/rig/settings.rb +9 -17
- data/lib/riffer/rig/token_tally.rb +1 -1
- data/lib/riffer/rig/tools/bash.rb +5 -2
- data/lib/riffer/rig/tools/edit.rb +11 -3
- data/lib/riffer/rig/tools/read.rb +2 -2
- data/lib/riffer/rig/ui/animator.rb +6 -6
- data/lib/riffer/rig/ui/cursor.rb +4 -4
- data/lib/riffer/rig/ui/renderer.rb +115 -36
- data/lib/riffer/rig/ui/smoother.rb +29 -13
- data/lib/riffer/rig/ui/theme.rb +2 -2
- data/lib/riffer/rig/version.rb +1 -1
- data/lib/riffer/rig.rb +31 -0
- data/sig/generated/riffer/rig/cli.rbs +8 -8
- data/sig/generated/riffer/rig/credentials.rbs +4 -3
- data/sig/generated/riffer/rig/extension.rbs +28 -0
- data/sig/generated/riffer/rig/host.rbs +42 -0
- data/sig/generated/riffer/rig/registrar.rbs +17 -0
- data/sig/generated/riffer/rig/repl.rbs +24 -23
- data/sig/generated/riffer/rig/runtime.rbs +101 -0
- data/sig/generated/riffer/rig/settings/document.rbs +20 -0
- data/sig/generated/riffer/rig/settings/pricing.rbs +4 -0
- data/sig/generated/riffer/rig/settings.rbs +5 -5
- data/sig/generated/riffer/rig/token_tally.rbs +2 -2
- data/sig/generated/riffer/rig/tools/bash.rbs +2 -2
- data/sig/generated/riffer/rig/tools/edit.rbs +2 -2
- data/sig/generated/riffer/rig/tools/read.rbs +3 -3
- data/sig/generated/riffer/rig/ui/animator.rbs +8 -8
- data/sig/generated/riffer/rig/ui/cursor.rbs +4 -4
- data/sig/generated/riffer/rig/ui/renderer.rbs +65 -15
- data/sig/generated/riffer/rig/ui/smoother.rbs +10 -5
- data/sig/generated/riffer/rig/ui/theme.rbs +2 -2
- data/sig/generated/riffer/rig.rbs +19 -0
- data/sig/manual/riffer/rig.rbs +11 -0
- metadata +24 -7
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
# One Runtime builds its own Riffer::Agent from a per-instance
|
|
6
|
+
# Riffer::Agent::Config and runs a prompt: streamed as events, or run to
|
|
7
|
+
# completion and returned as riffer's Agent::Response.
|
|
8
|
+
#
|
|
9
|
+
# runtime = Riffer::Rig::Runtime.new('mock/x')
|
|
10
|
+
# runtime.prompt('hello') { |event| ... } # yields riffer StreamEvents
|
|
11
|
+
# runtime.prompt('hello').each { |event| } # an Enumerator without a block
|
|
12
|
+
# response = runtime.ask('hello') # a Riffer::Agent::Response
|
|
13
|
+
#
|
|
14
|
+
# Two Runtimes in one process share nothing but the process-wide extension
|
|
15
|
+
# registry and riffer's provider repository. One prompt runs at a time; a
|
|
16
|
+
# second while one is running raises Riffer::Rig::Runtime::BusyError. The
|
|
17
|
+
# Runtime never renders, never prints, never reads the filesystem.
|
|
18
|
+
class Riffer::Rig::Runtime
|
|
19
|
+
# Raised when a second prompt, ask or registrar build runs while one is
|
|
20
|
+
# already running on this Runtime.
|
|
21
|
+
class BusyError < StandardError; end
|
|
22
|
+
|
|
23
|
+
BASE_PROMPT_TEMPLATE = <<~TEXT
|
|
24
|
+
You are %<name>s, a general-purpose agent. You work by using the tools you have
|
|
25
|
+
been given; each tool describes what it does and when to use it.
|
|
26
|
+
|
|
27
|
+
- Verify with your tools before answering. When a tool can settle a question,
|
|
28
|
+
look rather than guess.
|
|
29
|
+
- Do what was asked, and all of what was asked. Do not widen the scope, tidy
|
|
30
|
+
nearby things, or add extras that were not requested.
|
|
31
|
+
- Be concise. Lead with the outcome; do not restate the question.
|
|
32
|
+
TEXT
|
|
33
|
+
|
|
34
|
+
DEFAULT_NAME = 'riffer'
|
|
35
|
+
|
|
36
|
+
# The legacy CodingAgent's default: an unlimited agent loop. riffer's own
|
|
37
|
+
# default (16) is too small for a general-purpose harness.
|
|
38
|
+
DEFAULT_MAX_STEPS = nil #: Integer?
|
|
39
|
+
|
|
40
|
+
# @rbs!
|
|
41
|
+
# interface _Host
|
|
42
|
+
# def ask: (?String, ?options: Array[String]?, ?secret: bool) -> String?
|
|
43
|
+
# def confirm: (?String) -> bool
|
|
44
|
+
# def notify: (?String, ?level: Symbol) -> void
|
|
45
|
+
# def progress: (?String) { () -> void } -> void
|
|
46
|
+
# def capabilities: () -> Set[Symbol]
|
|
47
|
+
# end
|
|
48
|
+
|
|
49
|
+
# @rbs @agent: Riffer::Agent
|
|
50
|
+
# @rbs @cwd: String
|
|
51
|
+
# @rbs @host: _Host
|
|
52
|
+
# @rbs @settings: Hash[Symbol, untyped]
|
|
53
|
+
# @rbs @busy: bool
|
|
54
|
+
# @rbs @registrar: Riffer::Rig::Registrar
|
|
55
|
+
|
|
56
|
+
# @dynamic agent, cwd, host, settings
|
|
57
|
+
attr_reader :agent #: Riffer::Agent
|
|
58
|
+
attr_reader :cwd #: String
|
|
59
|
+
attr_reader :host #: _Host
|
|
60
|
+
attr_reader :settings #: Hash[Symbol, untyped]
|
|
61
|
+
|
|
62
|
+
# @rbs model: String
|
|
63
|
+
# @rbs extensions: Array[Riffer::Rig::Extension]
|
|
64
|
+
# @rbs tools: Array[String]?
|
|
65
|
+
# @rbs settings: Hash[Symbol, untyped]
|
|
66
|
+
# @rbs host: _Host
|
|
67
|
+
# @rbs cwd: String?
|
|
68
|
+
# @rbs name: String
|
|
69
|
+
# @rbs instructions: String?
|
|
70
|
+
# @rbs credentials: Hash[String, String]
|
|
71
|
+
# @rbs pricing: Hash[String, Riffer::Rig::Settings::Pricing]
|
|
72
|
+
# @rbs max_steps: Integer?
|
|
73
|
+
# @rbs snapshot: Hash[Symbol, untyped]?
|
|
74
|
+
# @rbs return: void
|
|
75
|
+
def initialize(
|
|
76
|
+
model,
|
|
77
|
+
extensions: [],
|
|
78
|
+
tools: nil,
|
|
79
|
+
settings: {},
|
|
80
|
+
host: Riffer::Rig::Host.new,
|
|
81
|
+
cwd: nil,
|
|
82
|
+
name: DEFAULT_NAME,
|
|
83
|
+
instructions: nil,
|
|
84
|
+
credentials: {},
|
|
85
|
+
pricing: {},
|
|
86
|
+
max_steps: DEFAULT_MAX_STEPS,
|
|
87
|
+
snapshot: nil
|
|
88
|
+
)
|
|
89
|
+
@host = host
|
|
90
|
+
@cwd = cwd || Dir.pwd
|
|
91
|
+
@settings = settings
|
|
92
|
+
|
|
93
|
+
@busy = false
|
|
94
|
+
@registrar = build_registrar(extensions)
|
|
95
|
+
tool_classes = select_tools(@registrar.tools, tools)
|
|
96
|
+
base_prompt = instructions || format(BASE_PROMPT_TEMPLATE, name: name)
|
|
97
|
+
|
|
98
|
+
@agent = Riffer::Agent.new(
|
|
99
|
+
config: Riffer::Agent::Config.new(
|
|
100
|
+
model: model,
|
|
101
|
+
instructions: system_prompt(base_prompt),
|
|
102
|
+
tools_config: tool_classes,
|
|
103
|
+
max_steps: max_steps
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @rbs text: String
|
|
109
|
+
# @rbs &block: ?(Riffer::StreamEvents::Base) -> void
|
|
110
|
+
# @rbs return: (nil | Enumerator[Riffer::StreamEvents::Base, void])
|
|
111
|
+
def prompt(text, &block)
|
|
112
|
+
raise BusyError, 'a prompt is already running on this Runtime' if @busy
|
|
113
|
+
|
|
114
|
+
@busy = true
|
|
115
|
+
if block
|
|
116
|
+
@agent.stream(text).each(&block)
|
|
117
|
+
nil
|
|
118
|
+
else
|
|
119
|
+
@agent.stream(text)
|
|
120
|
+
end
|
|
121
|
+
ensure
|
|
122
|
+
@busy = false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @rbs text: String
|
|
126
|
+
# @rbs return: Riffer::Agent::Response
|
|
127
|
+
def ask(text)
|
|
128
|
+
raise BusyError, 'a prompt is already running on this Runtime' if @busy
|
|
129
|
+
|
|
130
|
+
@busy = true
|
|
131
|
+
@agent.stream(text).each { |event| event }
|
|
132
|
+
ensure
|
|
133
|
+
@busy = false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# @rbs extensions: Array[Riffer::Rig::Extension]
|
|
139
|
+
# @rbs return: Riffer::Rig::Registrar
|
|
140
|
+
def build_registrar(extensions)
|
|
141
|
+
raise BusyError, 'a prompt is already running on this Runtime' if @busy
|
|
142
|
+
|
|
143
|
+
extensions.each_with_object(Riffer::Rig::Registrar.new) do |extension, registrar|
|
|
144
|
+
extension.run(registrar)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @rbs registered: Array[singleton(Riffer::Tool)]
|
|
149
|
+
# @rbs allowlist: Array[String]?
|
|
150
|
+
# @rbs return: Array[singleton(Riffer::Tool)]
|
|
151
|
+
def select_tools(registered, allowlist)
|
|
152
|
+
return registered if allowlist.nil?
|
|
153
|
+
|
|
154
|
+
registered.select { |klass| allowlist.include?(klass.name) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @rbs base_prompt: String
|
|
158
|
+
# @rbs return: String
|
|
159
|
+
def system_prompt(base_prompt)
|
|
160
|
+
"#{base_prompt}\n\nCurrent date: #{Date.today}\nCurrent working directory: #{@cwd}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
|
|
8
|
+
# @dynamic model, reasoning, models
|
|
9
|
+
attr_reader :model #: String?
|
|
10
|
+
attr_reader :reasoning #: String?
|
|
11
|
+
attr_reader :models #: Hash[String, Riffer::Rig::Settings::Pricing]
|
|
12
|
+
|
|
13
|
+
# @rbs source: untyped
|
|
14
|
+
# @rbs return: void
|
|
15
|
+
def initialize(source)
|
|
16
|
+
@model = source['model'].is_a?(String) ? source['model'] : nil
|
|
17
|
+
@reasoning = source['reasoning'].is_a?(String) ? source['reasoning'] : nil
|
|
18
|
+
entries = source['models'].is_a?(Hash) ? source['models'] : {} #: Hash[String, untyped]
|
|
19
|
+
@models = entries.filter_map do |name, entry|
|
|
20
|
+
[name, Riffer::Rig::Settings::Pricing.from(entry)] if name.is_a?(String) && entry.is_a?(Hash)
|
|
21
|
+
end.to_h
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -5,6 +5,17 @@ class Riffer::Rig::Settings::Pricing
|
|
|
5
5
|
# @dynamic input, output, cache_write, cache_read
|
|
6
6
|
attr_reader :input, :output, :cache_write, :cache_read #: Float
|
|
7
7
|
|
|
8
|
+
# @rbs entry: Hash[String, untyped]
|
|
9
|
+
# @rbs return: ::Riffer::Rig::Settings::Pricing
|
|
10
|
+
def self.from(entry)
|
|
11
|
+
new(
|
|
12
|
+
input: entry.fetch('input', 0).to_f,
|
|
13
|
+
output: entry.fetch('output', 0).to_f,
|
|
14
|
+
cache_write: entry.fetch('cache_write', 0).to_f,
|
|
15
|
+
cache_read: entry.fetch('cache_read', 0).to_f
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
8
19
|
# @rbs input: Float
|
|
9
20
|
# @rbs output: Float
|
|
10
21
|
# @rbs cache_write: Float
|
data/lib/riffer/rig/settings.rb
CHANGED
|
@@ -51,7 +51,7 @@ module Riffer::Rig::Settings
|
|
|
51
51
|
# @rbs path: String
|
|
52
52
|
# @rbs return: String
|
|
53
53
|
def model(path: PATH)
|
|
54
|
-
read(path).
|
|
54
|
+
read(path).model || DEFAULT_MODEL
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Returns model options for the configured model and reasoning level, ready
|
|
@@ -80,15 +80,7 @@ module Riffer::Rig::Settings
|
|
|
80
80
|
# @rbs path: String
|
|
81
81
|
# @rbs return: Pricing?
|
|
82
82
|
def pricing_for(model, path: PATH)
|
|
83
|
-
|
|
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
|
-
)
|
|
83
|
+
read(path).models[model]
|
|
92
84
|
end
|
|
93
85
|
|
|
94
86
|
private
|
|
@@ -102,10 +94,10 @@ module Riffer::Rig::Settings
|
|
|
102
94
|
end
|
|
103
95
|
|
|
104
96
|
# @rbs path: String
|
|
105
|
-
# @rbs
|
|
97
|
+
# @rbs provider: String?
|
|
106
98
|
# @rbs return: String?
|
|
107
99
|
def reasoning_for(path: PATH, provider: nil)
|
|
108
|
-
level = read(path)
|
|
100
|
+
level = read(path).reasoning
|
|
109
101
|
valid_levels = (provider && REASONING_LEVELS_BY_PROVIDER[provider]) || []
|
|
110
102
|
valid_levels.include?(level) ? level : nil
|
|
111
103
|
end
|
|
@@ -129,16 +121,16 @@ module Riffer::Rig::Settings
|
|
|
129
121
|
end
|
|
130
122
|
end
|
|
131
123
|
|
|
132
|
-
# The parsed settings file, or an empty
|
|
124
|
+
# The parsed settings file, or an empty document when the file is absent or
|
|
133
125
|
# malformed.
|
|
134
126
|
#
|
|
135
127
|
# @rbs path: String
|
|
136
|
-
# @rbs return:
|
|
128
|
+
# @rbs return: Document
|
|
137
129
|
def read(path)
|
|
138
|
-
return {} unless File.file?(path)
|
|
130
|
+
return Document.new({}) unless File.file?(path)
|
|
139
131
|
|
|
140
|
-
JSON.parse(File.read(path))
|
|
132
|
+
Document.new(JSON.parse(File.read(path)))
|
|
141
133
|
rescue JSON::ParserError
|
|
142
|
-
{}
|
|
134
|
+
Document.new({})
|
|
143
135
|
end
|
|
144
136
|
end
|
|
@@ -22,7 +22,7 @@ class Riffer::Rig::TokenTally
|
|
|
22
22
|
# @dynamic input_tokens, output_tokens, cache_write_tokens, cache_read_tokens
|
|
23
23
|
attr_reader :input_tokens, :output_tokens, :cache_write_tokens, :cache_read_tokens
|
|
24
24
|
|
|
25
|
-
# @rbs
|
|
25
|
+
# @rbs pricing: Riffer::Rig::Settings::Pricing?
|
|
26
26
|
# @rbs return: void
|
|
27
27
|
def initialize(pricing: nil)
|
|
28
28
|
@pricing = pricing
|
|
@@ -15,12 +15,15 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
|
|
|
15
15
|
|
|
16
16
|
params do
|
|
17
17
|
required :command, String, description: 'The shell command to run'
|
|
18
|
-
optional :timeout_ms,
|
|
18
|
+
optional :timeout_ms,
|
|
19
|
+
Integer,
|
|
20
|
+
description: 'Kill the command after this many milliseconds',
|
|
21
|
+
default: DEFAULT_TIMEOUT_MS
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
# @rbs context: Riffer::Agent::Context?
|
|
22
25
|
# @rbs command: String
|
|
23
|
-
# @rbs
|
|
26
|
+
# @rbs timeout_ms: Integer
|
|
24
27
|
# @rbs return: Riffer::Tools::Response
|
|
25
28
|
def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
26
29
|
output, status = run(command, timeout_ms / 1000.0)
|
|
@@ -9,14 +9,17 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
|
|
|
9
9
|
required :path, String, description: 'Path to the file (absolute, or relative to the working directory)'
|
|
10
10
|
required :old_string, String, description: 'The exact text to replace'
|
|
11
11
|
required :new_string, String, description: 'The text to replace it with'
|
|
12
|
-
optional :replace_all,
|
|
12
|
+
optional :replace_all,
|
|
13
|
+
Riffer::Params::Boolean,
|
|
14
|
+
description: 'Replace all occurrences instead of requiring a unique match',
|
|
15
|
+
default: false
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
# @rbs context: Riffer::Agent::Context?
|
|
16
19
|
# @rbs path: String
|
|
17
20
|
# @rbs old_string: String
|
|
18
21
|
# @rbs new_string: String
|
|
19
|
-
# @rbs
|
|
22
|
+
# @rbs replace_all: bool
|
|
20
23
|
# @rbs return: Riffer::Tools::Response
|
|
21
24
|
def call(context:, path:, old_string:, new_string:, replace_all: false)
|
|
22
25
|
resolved = File.expand_path(path, Dir.pwd)
|
|
@@ -26,7 +29,12 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
|
|
|
26
29
|
occurrences = content.scan(old_string).length
|
|
27
30
|
|
|
28
31
|
return error("old_string not found in #{path}", type: :not_found) if occurrences.zero?
|
|
29
|
-
|
|
32
|
+
if occurrences > 1 && !replace_all
|
|
33
|
+
return error(
|
|
34
|
+
"old_string is not unique in #{path} (#{occurrences} matches). Pass replace_all or add more context.",
|
|
35
|
+
type: :ambiguous_match
|
|
36
|
+
)
|
|
37
|
+
end
|
|
30
38
|
|
|
31
39
|
# Block form so backslash sequences in new_string (\0, \1, \\) are inserted
|
|
32
40
|
# literally instead of being interpreted as regexp backreferences.
|
|
@@ -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
|
|
19
|
-
# @rbs
|
|
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)
|
|
@@ -23,15 +23,15 @@ class Riffer::Rig::UI::Animator
|
|
|
23
23
|
|
|
24
24
|
REASONING_TICK_RANGE = (1..5) #: Range[Integer]
|
|
25
25
|
|
|
26
|
-
# @rbs @io:
|
|
26
|
+
# @rbs @io: IO
|
|
27
27
|
# @rbs @theme: Riffer::Rig::UI::Theme
|
|
28
28
|
# @rbs @thread: Thread?
|
|
29
29
|
# @rbs @mode: Symbol
|
|
30
30
|
# @rbs @phrase: String?
|
|
31
31
|
# @rbs @stop: bool
|
|
32
32
|
|
|
33
|
-
# @rbs io:
|
|
34
|
-
# @rbs
|
|
33
|
+
# @rbs io: IO
|
|
34
|
+
# @rbs theme: Riffer::Rig::UI::Theme
|
|
35
35
|
# @rbs return: void
|
|
36
36
|
def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
|
|
37
37
|
@io = io
|
|
@@ -60,7 +60,7 @@ class Riffer::Rig::UI::Animator
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
# @rbs
|
|
63
|
+
# @rbs mode: Symbol
|
|
64
64
|
# @rbs return: void
|
|
65
65
|
def start(mode = :neutral)
|
|
66
66
|
return unless enabled?
|
|
@@ -88,7 +88,7 @@ class Riffer::Rig::UI::Animator
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
# @rbs tick: Integer
|
|
91
|
-
# @rbs
|
|
91
|
+
# @rbs label: String
|
|
92
92
|
# @rbs return: String
|
|
93
93
|
def equalizer(tick, label = NEUTRAL_LABEL)
|
|
94
94
|
bars = Array.new(EQ_BARS) do |i|
|
|
@@ -141,6 +141,6 @@ class Riffer::Rig::UI::Animator
|
|
|
141
141
|
|
|
142
142
|
# @rbs return: bool
|
|
143
143
|
def enabled?
|
|
144
|
-
@theme.enabled && @io.
|
|
144
|
+
@theme.enabled && @io.tty?
|
|
145
145
|
end
|
|
146
146
|
end
|
data/lib/riffer/rig/ui/cursor.rb
CHANGED
|
@@ -7,11 +7,11 @@ class Riffer::Rig::UI::Cursor
|
|
|
7
7
|
|
|
8
8
|
SHOW = "\e[?25h" #: String
|
|
9
9
|
|
|
10
|
-
# @rbs @io:
|
|
10
|
+
# @rbs @io: IO
|
|
11
11
|
# @rbs @theme: Riffer::Rig::UI::Theme
|
|
12
12
|
|
|
13
|
-
# @rbs io:
|
|
14
|
-
# @rbs
|
|
13
|
+
# @rbs io: IO
|
|
14
|
+
# @rbs theme: Riffer::Rig::UI::Theme
|
|
15
15
|
# @rbs return: void
|
|
16
16
|
def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
|
|
17
17
|
@io = io
|
|
@@ -41,6 +41,6 @@ class Riffer::Rig::UI::Cursor
|
|
|
41
41
|
|
|
42
42
|
# @rbs return: bool
|
|
43
43
|
def enabled?
|
|
44
|
-
@theme.enabled && @io.
|
|
44
|
+
@theme.enabled && @io.tty?
|
|
45
45
|
end
|
|
46
46
|
end
|
|
@@ -5,21 +5,25 @@ require 'json'
|
|
|
5
5
|
class Riffer::Rig::UI::Renderer
|
|
6
6
|
RESULT_PREVIEW_LIMIT = 200 #: Integer
|
|
7
7
|
|
|
8
|
-
# @rbs @io:
|
|
8
|
+
# @rbs @io: IO
|
|
9
9
|
# @rbs @theme: Riffer::Rig::UI::Theme
|
|
10
10
|
# @rbs @tally: Riffer::Rig::TokenTally?
|
|
11
11
|
# @rbs @smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
|
|
12
|
+
# @rbs @deferred_usage: Riffer::Providers::TokenUsage?
|
|
13
|
+
# @rbs @prose_gap_pending: bool
|
|
12
14
|
|
|
13
|
-
# @rbs io:
|
|
14
|
-
# @rbs
|
|
15
|
-
# @rbs
|
|
16
|
-
# @rbs
|
|
15
|
+
# @rbs io: IO
|
|
16
|
+
# @rbs theme: Riffer::Rig::UI::Theme
|
|
17
|
+
# @rbs tally: Riffer::Rig::TokenTally?
|
|
18
|
+
# @rbs smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
|
|
17
19
|
# @rbs return: void
|
|
18
20
|
def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), tally: nil, smoother: nil)
|
|
19
21
|
@io = io
|
|
20
22
|
@theme = theme
|
|
21
23
|
@tally = tally
|
|
22
24
|
@smoother = smoother
|
|
25
|
+
@deferred_usage = nil
|
|
26
|
+
@prose_gap_pending = false
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# @rbs event: Riffer::StreamEvents::Base
|
|
@@ -27,33 +31,127 @@ class Riffer::Rig::UI::Renderer
|
|
|
27
31
|
def render(event)
|
|
28
32
|
case event
|
|
29
33
|
when Riffer::StreamEvents::TextDelta
|
|
30
|
-
|
|
34
|
+
render_prose(event.content)
|
|
31
35
|
when Riffer::StreamEvents::ToolCallDone
|
|
32
|
-
|
|
33
|
-
@io.puts("\n#{@theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})")}")
|
|
36
|
+
render_tool_activity(2) { @theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})") }
|
|
34
37
|
when Riffer::StreamEvents::SkillActivation
|
|
35
|
-
|
|
36
|
-
@io.puts("\n#{@theme.magenta("✦ skill: #{event.name}")}")
|
|
38
|
+
render_block(0) { @theme.magenta("✦ skill: #{event.name}") }
|
|
37
39
|
when Riffer::StreamEvents::Interrupt
|
|
38
|
-
|
|
39
|
-
@io.puts(@theme.dim("[interrupted: #{event.reason}]"))
|
|
40
|
+
render_block(0) { @theme.dim("[interrupted: #{event.reason}]") }
|
|
40
41
|
when Riffer::StreamEvents::TokenUsageDone
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
current = @deferred_usage
|
|
43
|
+
@deferred_usage = current ? current + event.token_usage : event.token_usage
|
|
43
44
|
end
|
|
44
45
|
end
|
|
45
46
|
|
|
47
|
+
# The prompt holds the line open for typed input, so unlike render_block it
|
|
48
|
+
# ends without a newline. It sits at the renderer so a tool group left open by
|
|
49
|
+
# the previous turn closes here, and the next turn's blocks open cleanly.
|
|
50
|
+
#
|
|
51
|
+
# @rbs return: void
|
|
52
|
+
def prompt
|
|
53
|
+
@prose_gap_pending = true
|
|
54
|
+
@io.puts
|
|
55
|
+
@io.print("#{@theme.pink('›')} ")
|
|
56
|
+
@io.flush
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Usage arrives per model call but tool results arrive via the session
|
|
60
|
+
# callback after each call's stream ends, so rendering inline would print the
|
|
61
|
+
# stats above the results they belong with. Held until the turn's output is
|
|
62
|
+
# done, then printed as one line below it.
|
|
63
|
+
#
|
|
64
|
+
# @rbs return: void
|
|
65
|
+
def flush_usage
|
|
66
|
+
usage = @deferred_usage
|
|
67
|
+
@deferred_usage = nil
|
|
68
|
+
tally = @tally
|
|
69
|
+
return unless usage && tally
|
|
70
|
+
|
|
71
|
+
tally.add(usage)
|
|
72
|
+
render_block(0) { @theme.dim(usage_line(usage, tally)) }
|
|
73
|
+
end
|
|
74
|
+
|
|
46
75
|
# @rbs message: Riffer::Messages::Base
|
|
47
76
|
# @rbs return: void
|
|
48
77
|
def render_tool_result(message)
|
|
49
78
|
return unless message.is_a?(Riffer::Messages::Tool)
|
|
50
79
|
|
|
80
|
+
open_tool_activity
|
|
51
81
|
line = "↳ #{preview(message.content)}"
|
|
52
|
-
|
|
82
|
+
styled = message.error? ? @theme.red(line) : @theme.dim(line)
|
|
83
|
+
@io.print(" #{styled}\n")
|
|
84
|
+
@io.flush
|
|
53
85
|
end
|
|
54
86
|
|
|
55
87
|
private
|
|
56
88
|
|
|
89
|
+
# Rule 2 for one-off blocks (skill line, interrupt, stats): one blank line
|
|
90
|
+
# above, no indent. The smoother drains first so any pending partial prose
|
|
91
|
+
# block ends cleanly before the gap is written, and any open tool group closes
|
|
92
|
+
# so the next group opens with its own gap.
|
|
93
|
+
#
|
|
94
|
+
# @rbs indent: Integer
|
|
95
|
+
# @rbs &block: () -> String
|
|
96
|
+
# @rbs return: void
|
|
97
|
+
def render_block(indent, &)
|
|
98
|
+
drain_smoother
|
|
99
|
+
@io.puts
|
|
100
|
+
@prose_gap_pending = true
|
|
101
|
+
@io.puts((' ' * indent) + yield)
|
|
102
|
+
@io.flush
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The blank line above a prose block is written at the first delta after a
|
|
106
|
+
# non-prose block. Streaming makes "which block is first?" a stateful
|
|
107
|
+
# question, so the answer is tracked rather than embedded at each render site.
|
|
108
|
+
#
|
|
109
|
+
# @rbs content: String
|
|
110
|
+
# @rbs return: void
|
|
111
|
+
def render_prose(content)
|
|
112
|
+
if @prose_gap_pending
|
|
113
|
+
@prose_gap_pending = false
|
|
114
|
+
@io.print("\n")
|
|
115
|
+
end
|
|
116
|
+
smoother << content
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @rbs indent: Integer
|
|
120
|
+
# @rbs &block: () -> String
|
|
121
|
+
# @rbs return: void
|
|
122
|
+
def render_tool_activity(indent, &)
|
|
123
|
+
open_tool_activity
|
|
124
|
+
@io.puts((' ' * indent) + yield)
|
|
125
|
+
@io.flush
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Tool-activity lines (⚙ calls, ↳ results) share one blank line above the
|
|
129
|
+
# group instead of one between each line — the gap goes between groups, not
|
|
130
|
+
# inside a pair. The group stays open so prose after it pays the closing gap.
|
|
131
|
+
#
|
|
132
|
+
# @rbs return: void
|
|
133
|
+
def open_tool_activity
|
|
134
|
+
return if @prose_gap_pending
|
|
135
|
+
|
|
136
|
+
drain_smoother
|
|
137
|
+
@io.puts
|
|
138
|
+
@prose_gap_pending = true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @rbs usage: Riffer::Providers::TokenUsage
|
|
142
|
+
# @rbs tally: Riffer::Rig::TokenTally
|
|
143
|
+
# @rbs return: String
|
|
144
|
+
def usage_line(usage, tally)
|
|
145
|
+
parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
|
|
146
|
+
parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
|
|
147
|
+
parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
|
|
148
|
+
parts << "session #{tally.total_tokens} tok"
|
|
149
|
+
cost = tally.estimated_cost
|
|
150
|
+
parts << format('~$%.4f', cost) if cost
|
|
151
|
+
|
|
152
|
+
parts.join(' · ')
|
|
153
|
+
end
|
|
154
|
+
|
|
57
155
|
# @rbs return: Riffer::Rig::UI::Smoother | PassThroughSmoother
|
|
58
156
|
def smoother
|
|
59
157
|
@smoother || PassThroughSmoother.new(@io)
|
|
@@ -62,9 +160,9 @@ class Riffer::Rig::UI::Renderer
|
|
|
62
160
|
# Stand-in when no smoother is injected, so a bare Riffer::Rig::UI::Renderer still prints
|
|
63
161
|
# synchronously.
|
|
64
162
|
class PassThroughSmoother
|
|
65
|
-
# @rbs @io:
|
|
163
|
+
# @rbs @io: IO
|
|
66
164
|
|
|
67
|
-
# @rbs io:
|
|
165
|
+
# @rbs io: IO
|
|
68
166
|
# @rbs return: void
|
|
69
167
|
def initialize(io) = @io = io
|
|
70
168
|
|
|
@@ -88,25 +186,6 @@ class Riffer::Rig::UI::Renderer
|
|
|
88
186
|
smoother.drain
|
|
89
187
|
end
|
|
90
188
|
|
|
91
|
-
# @rbs usage: Riffer::Providers::TokenUsage
|
|
92
|
-
# @rbs return: void
|
|
93
|
-
def render_token_usage(usage)
|
|
94
|
-
tally = @tally
|
|
95
|
-
return unless tally
|
|
96
|
-
|
|
97
|
-
tally.add(usage)
|
|
98
|
-
|
|
99
|
-
parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
|
|
100
|
-
parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
|
|
101
|
-
parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
|
|
102
|
-
|
|
103
|
-
session_parts = ["session #{tally.total_tokens} tok"]
|
|
104
|
-
cost = tally.estimated_cost
|
|
105
|
-
session_parts << format('~$%.4f', cost) if cost
|
|
106
|
-
|
|
107
|
-
@io.puts("\n#{@theme.dim("#{parts.join(' · ')} #{session_parts.join(' · ')}")}")
|
|
108
|
-
end
|
|
109
|
-
|
|
110
189
|
# @rbs arguments: String
|
|
111
190
|
# @rbs return: String
|
|
112
191
|
def format_arguments(arguments)
|