rcas 0.2.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 +7 -0
- data/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module RCAS
|
|
7
|
+
module Chat
|
|
8
|
+
# The read-eval-print loop: reads input, decides whether it is a
|
|
9
|
+
# command, a shell line, Ruby or a question, and keeps the session saved.
|
|
10
|
+
class REPL
|
|
11
|
+
HISTORY_FILE = File.join(HOME, "history")
|
|
12
|
+
OLD_HISTORY_FILE = File.join(Dir.home, ".rcas_history")
|
|
13
|
+
PROMPT = "❯ "
|
|
14
|
+
CONTINUE = " "
|
|
15
|
+
|
|
16
|
+
COMMANDS = {
|
|
17
|
+
"/help [NAME]" => "these commands, or what one function, set or class does",
|
|
18
|
+
"/output [text|typeset|both|latex]" => "how results are shown: ASCII, typeset picture, both, or text + LaTeX source",
|
|
19
|
+
"/backend [katex|latex]" => "typesetting backend (KaTeX + Chrome, or a TeX installation)",
|
|
20
|
+
"/scale N" => "zoom factor for typeset output (1 = natural size)",
|
|
21
|
+
"/theme [dark|light]" => "colour of typeset output for your terminal background",
|
|
22
|
+
"/plotstyle [text|image]" => "how plots are shown: braille art or a picture",
|
|
23
|
+
"/unicode [on|off]" => "print ℤ, π and ∞ instead of ZZ, pi and oo",
|
|
24
|
+
"/numbered [on|off]" => "number the session's lines in the prompt (on); In[3] and Out[3] reach them either way",
|
|
25
|
+
"/latex EXPR" => "print the LaTeX source of a Ruby expression",
|
|
26
|
+
"/show EXPR" => "typeset a Ruby expression regardless of the output mode",
|
|
27
|
+
"/png EXPR FILE" => "write the typeset expression to a PNG file",
|
|
28
|
+
"/ask TEXT" => "ask Claude (also: start the line with ? )",
|
|
29
|
+
"/vars" => "list the session's variables",
|
|
30
|
+
"/assumptions" => "list declared variable domains",
|
|
31
|
+
"/forget [x ...]" => "drop variable domains",
|
|
32
|
+
"/model [ID]" => "show or switch the Claude model",
|
|
33
|
+
"/fallbacks [on|off]" => "server-side fallback to #{Assistant::FALLBACK_MODEL} when Claude refuses",
|
|
34
|
+
"/cost" => "token usage of this session",
|
|
35
|
+
"/compact" => "forget the conversation with Claude, keep the variables",
|
|
36
|
+
"/sessions" => "list saved sessions",
|
|
37
|
+
"/resume [NAME|ID|N]" => "pick a saved session from a list, or switch to one by name",
|
|
38
|
+
"/rename NAME" => "name this session (also /title)",
|
|
39
|
+
"/reset" => "start a fresh session (variables and conversation)",
|
|
40
|
+
"/save [FILE]" => "save a Markdown transcript",
|
|
41
|
+
"/settings [save|reset]" => "show the defaults in ~/.rcas/settings.json, save the current ones, or delete the file",
|
|
42
|
+
"/clear" => "clear the screen",
|
|
43
|
+
"/exit" => "leave (also Ctrl-D)",
|
|
44
|
+
"!CMD" => "run a shell command"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Listed and accepted only when Claude is configured.
|
|
48
|
+
ASSISTANT_COMMANDS = %w[/ask /model /fallbacks /cost /compact].freeze
|
|
49
|
+
|
|
50
|
+
attr_reader :workspace, :ui, :assistant, :session
|
|
51
|
+
|
|
52
|
+
# +assistant_factory+ builds the Assistant for a workspace and UI; tests
|
|
53
|
+
# pass one that talks to a fake API.
|
|
54
|
+
def initialize(input: $stdin, output: $stdout, model: Assistant::DEFAULT_MODEL, mode: nil, assistant_factory: nil, session: nil, persist: true)
|
|
55
|
+
@input = input
|
|
56
|
+
@ui = UI.new(out: output, mode: mode)
|
|
57
|
+
@assistant_factory = assistant_factory || ->(ws, ui, m = model) { Assistant.new(ws, ui, model: m) }
|
|
58
|
+
@persist = persist
|
|
59
|
+
@running = true
|
|
60
|
+
fresh_session(model)
|
|
61
|
+
resume(session) if session
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fresh_session(model = @assistant&.model)
|
|
65
|
+
@workspace = Workspace.new
|
|
66
|
+
@assistant = @assistant_factory.call(@workspace, @ui, model)
|
|
67
|
+
@assistant.on_tool_call = ->(code, result) { @session.transcript << [:tool, code, result] }
|
|
68
|
+
@session = Session.new
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def transcript = @session.transcript
|
|
72
|
+
|
|
73
|
+
def run
|
|
74
|
+
@ui.banner(model: @assistant.model, backend: Render.available? ? Render.selected.name : :none,
|
|
75
|
+
assistant: @assistant.available?, session: (@session.id if @persist))
|
|
76
|
+
@ui.puts
|
|
77
|
+
@ui.history(transcript) unless transcript.empty?
|
|
78
|
+
setup_reline if interactive?
|
|
79
|
+
while @running && (line = read_input)
|
|
80
|
+
handle(line)
|
|
81
|
+
end
|
|
82
|
+
save_history if interactive?
|
|
83
|
+
@ui.puts if interactive?
|
|
84
|
+
Render.cleanup!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def interactive? = @input.respond_to?(:tty?) && @input.tty?
|
|
88
|
+
|
|
89
|
+
# One complete input, continuing over lines while the Ruby is unfinished.
|
|
90
|
+
def read_input
|
|
91
|
+
buffer = +""
|
|
92
|
+
prompt = Results.prompt(PROMPT) # "[3]❯ " when the lines are numbered
|
|
93
|
+
loop do
|
|
94
|
+
line = read_line(prompt)
|
|
95
|
+
return nil if line.nil? && buffer.empty?
|
|
96
|
+
return buffer if line.nil?
|
|
97
|
+
buffer << line << "\n"
|
|
98
|
+
text = buffer.strip
|
|
99
|
+
return text if text.empty? || text.start_with?("/", "!", "?") || !@workspace.incomplete?(text)
|
|
100
|
+
prompt = CONTINUE
|
|
101
|
+
end
|
|
102
|
+
rescue Interrupt
|
|
103
|
+
@ui.puts
|
|
104
|
+
@ui.info("(to leave, type /exit or press Ctrl-D)")
|
|
105
|
+
""
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Interactive input sits between two rules. The one above stays in the
|
|
109
|
+
# scrollback as the turn separator; the one below is drawn, the cursor
|
|
110
|
+
# moved back up onto the prompt line, and it is erased once Enter is hit.
|
|
111
|
+
def read_line(prompt)
|
|
112
|
+
if interactive?
|
|
113
|
+
rule = @ui.rule
|
|
114
|
+
@ui.puts rule unless prompt == CONTINUE
|
|
115
|
+
@ui.print "\n#{rule}\e[1A\r"
|
|
116
|
+
@ui.flush
|
|
117
|
+
line = Reline.readline(Style.paint(prompt, :bold), true)
|
|
118
|
+
@ui.print "\e[2K\r"
|
|
119
|
+
@ui.flush
|
|
120
|
+
line
|
|
121
|
+
else
|
|
122
|
+
line = @input.gets
|
|
123
|
+
return nil if line.nil?
|
|
124
|
+
@ui.puts "#{prompt}#{line.chomp}" if ENV["RCAS_ECHO"]
|
|
125
|
+
line.chomp
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle(line)
|
|
130
|
+
text = line.strip
|
|
131
|
+
return if text.empty?
|
|
132
|
+
transcript << [:input, text]
|
|
133
|
+
case text
|
|
134
|
+
when %r{\A/} then command(text)
|
|
135
|
+
when /\A!/ then shell(text[1..])
|
|
136
|
+
when /\A\?\s*/ then @assistant.available? ? ask(text.sub(/\A\?\s*/, "")) : route(text)
|
|
137
|
+
else route(text)
|
|
138
|
+
end
|
|
139
|
+
rescue Interrupt
|
|
140
|
+
@ui.puts
|
|
141
|
+
@ui.info("(interrupted)")
|
|
142
|
+
ensure
|
|
143
|
+
persist
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Ruby when it parses and runs; otherwise a question for Claude, or,
|
|
147
|
+
# without Claude, Ruby's own syntax error.
|
|
148
|
+
def route(text)
|
|
149
|
+
return ask(text) if !@workspace.ruby?(text) && @assistant.available?
|
|
150
|
+
# "what is x squared" parses as what(is(x(squared))); since an unknown
|
|
151
|
+
# name applied to arguments is a symbolic function, route a sentence
|
|
152
|
+
# that calls undefined names to Claude before evaluating it.
|
|
153
|
+
return ask(text) if prose?(text) && @assistant.available? && @workspace.undefined_calls?(text)
|
|
154
|
+
evaluate(text)
|
|
155
|
+
rescue NameError, NoMethodError, TypeError, ArgumentError => e
|
|
156
|
+
if prose?(text) && @assistant.available?
|
|
157
|
+
ask(text, note: "#{e.class}: #{e.message.lines.first&.strip}")
|
|
158
|
+
else
|
|
159
|
+
report(e)
|
|
160
|
+
end
|
|
161
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
162
|
+
report(e)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def evaluate(code)
|
|
166
|
+
Results.record_input(code, @workspace.binding) # `In[3]` gives the line back held
|
|
167
|
+
Lint.warnings(code).each { |message| @ui.warning(message) }
|
|
168
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
169
|
+
value, = @ui.busy("computing") { @workspace.eval(code) }
|
|
170
|
+
@ui.result(value)
|
|
171
|
+
@ui.took(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
172
|
+
transcript << [:result, @ui.text_of(value), (LaTeX.of(value) if @ui.typesettable?(value))]
|
|
173
|
+
value
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def report(error)
|
|
177
|
+
@ui.error("#{error.class}: #{error.message.lines.first&.strip}")
|
|
178
|
+
@ui.hint(Usage.hint(error))
|
|
179
|
+
transcript << [:error, "#{error.class}: #{error.message.lines.first&.strip}"]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Several words, most of them plain lowercase words: a sentence, not code.
|
|
183
|
+
def prose?(text)
|
|
184
|
+
words = text.split
|
|
185
|
+
return false if words.size < 3
|
|
186
|
+
plain = words.count { |w| w.match?(/\A[A-Za-z][a-z']*[?.,!:]?\z/) }
|
|
187
|
+
plain >= [2, words.size / 2].max
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def ask(question, note: nil)
|
|
191
|
+
return unless @assistant.available?
|
|
192
|
+
reply = @assistant.ask(question, note: note)
|
|
193
|
+
transcript << [:assistant, reply] if reply
|
|
194
|
+
rescue Error => e
|
|
195
|
+
@ui.error(e.message)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def shell(command)
|
|
199
|
+
return @ui.error("usage: !command") if command.strip.empty?
|
|
200
|
+
out = IO.popen(command, err: %i[child out], &:read)
|
|
201
|
+
@ui.print(out)
|
|
202
|
+
@ui.puts unless out.end_with?("\n") || out.empty?
|
|
203
|
+
transcript << [:shell, command, out]
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
@ui.error(e.message)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ---- sessions -----------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def persist
|
|
211
|
+
return unless @persist
|
|
212
|
+
@session.model = @assistant.model
|
|
213
|
+
@session.mode = @ui.mode
|
|
214
|
+
@session.messages = @assistant.messages
|
|
215
|
+
@session.usage = @assistant.usage.to_h
|
|
216
|
+
@session.save
|
|
217
|
+
rescue StandardError => e
|
|
218
|
+
@ui.info("(could not save session: #{e.message})")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Load +ref+ (a Session, an id, a prefix or a list index) into this REPL.
|
|
222
|
+
def resume(ref)
|
|
223
|
+
session = ref.is_a?(Session) ? ref : Session.find(ref)
|
|
224
|
+
raise Error, "no saved session #{ref.inspect}; see /sessions" unless session
|
|
225
|
+
fallbacks = @assistant.fallbacks
|
|
226
|
+
RCAS.forget
|
|
227
|
+
fresh_session(session.model || @assistant.model)
|
|
228
|
+
@workspace = session.replay_into(@workspace)
|
|
229
|
+
@assistant = @assistant_factory.call(@workspace, @ui, session.model || @assistant.model)
|
|
230
|
+
@assistant.fallbacks = fallbacks
|
|
231
|
+
@assistant.on_tool_call = ->(code, result) { @session.transcript << [:tool, code, result] }
|
|
232
|
+
@assistant.messages = symbolize(session.messages)
|
|
233
|
+
session.usage.each { |k, v| @assistant.usage[k.to_sym] = v }
|
|
234
|
+
# UI.mode_for, not MODES: a session saved before :tex was renamed
|
|
235
|
+
# to :typeset still says "tex", and it still means this mode.
|
|
236
|
+
@ui.mode = UI.mode_for(session.mode) || @ui.mode
|
|
237
|
+
@session = session
|
|
238
|
+
session
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def symbolize(obj)
|
|
242
|
+
case obj
|
|
243
|
+
when Hash then obj.to_h { |k, v| [k.to_sym, k.to_s == "role" ? v.to_sym : symbolize(v)] }
|
|
244
|
+
when Array then obj.map { |v| symbolize(v) }
|
|
245
|
+
else obj
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# ---- slash commands -----------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def command(text)
|
|
252
|
+
name, arg = text.split(/\s+/, 2)
|
|
253
|
+
arg = arg.to_s.strip
|
|
254
|
+
case name
|
|
255
|
+
when "/help" then help(arg)
|
|
256
|
+
when "/settings" then settings_command(arg)
|
|
257
|
+
when "/output", "/tex" then output(arg)
|
|
258
|
+
when "/backend" then backend(arg)
|
|
259
|
+
when "/scale"
|
|
260
|
+
return @ui.error("usage: /scale N") unless arg.match?(/\A\d+(\.\d+)?\z/)
|
|
261
|
+
Render.scale = arg.to_f
|
|
262
|
+
@ui.info("scale #{Render.scale}")
|
|
263
|
+
when "/theme"
|
|
264
|
+
return @ui.error("usage: /theme dark|light") unless %w[dark light].include?(arg)
|
|
265
|
+
Render.theme = arg
|
|
266
|
+
@ui.info("theme #{arg}")
|
|
267
|
+
when "/plotstyle" then plotstyle(arg)
|
|
268
|
+
when "/unicode" then unicode(arg)
|
|
269
|
+
when "/numbered" then numbered(arg)
|
|
270
|
+
when "/latex" then @ui.puts(LaTeX.of(@workspace.eval(arg).first))
|
|
271
|
+
when "/show"
|
|
272
|
+
value, = @workspace.eval(arg)
|
|
273
|
+
value.is_a?(Plot) ? @ui.plot_picture(value, force: true) : @ui.typeset(value, force: true)
|
|
274
|
+
when "/png"
|
|
275
|
+
code, file = arg.split(/\s+(?=\S+\z)/, 2)
|
|
276
|
+
return @ui.error("usage: /png EXPR FILE.png") if file.nil?
|
|
277
|
+
value, = @workspace.eval(code)
|
|
278
|
+
value.is_a?(Plot) ? value.to_png(File.expand_path(file)) : Render.png(value, File.expand_path(file))
|
|
279
|
+
@ui.info("wrote #{file}")
|
|
280
|
+
when "/ask" then @assistant.available? ? ask(arg) : @ui.error("unknown command /ask; try /help")
|
|
281
|
+
when "/vars"
|
|
282
|
+
locals = @workspace.locals
|
|
283
|
+
return @ui.info("no variables yet") if locals.empty?
|
|
284
|
+
width = locals.keys.map(&:size).max
|
|
285
|
+
locals.each { |k, v| @ui.puts " #{Style.cyan(k.to_s.ljust(width))} #{@ui.text_of(v).gsub("\n", "\n#{' ' * (width + 4)}")}" }
|
|
286
|
+
when "/assumptions"
|
|
287
|
+
a = RCAS.assumptions
|
|
288
|
+
a.empty? ? @ui.info("no assumptions") : a.values.flatten.each { |v| @ui.puts(" #{v.to_s.sub(' in ', ' ∈ ')}") }
|
|
289
|
+
when "/forget"
|
|
290
|
+
RCAS.forget(*arg.split.map(&:to_sym))
|
|
291
|
+
@ui.info("forgot #{arg.empty? ? 'all assumptions' : arg}")
|
|
292
|
+
when "/model", "/fallbacks", "/cost", "/compact" then assistant_command(name, arg)
|
|
293
|
+
when "/sessions" then sessions
|
|
294
|
+
when "/resume" then resume_command(arg)
|
|
295
|
+
when "/rename", "/title"
|
|
296
|
+
return @ui.error("usage: /rename NAME") if arg.empty?
|
|
297
|
+
@session.title = arg
|
|
298
|
+
@ui.info("session named #{arg.inspect}; /resume #{arg} or rcas-chat --resume #{arg.inspect} brings it back")
|
|
299
|
+
when "/reset"
|
|
300
|
+
fallbacks = @assistant.fallbacks
|
|
301
|
+
RCAS.forget
|
|
302
|
+
fresh_session
|
|
303
|
+
@assistant.fallbacks = fallbacks
|
|
304
|
+
@ui.info("fresh session #{@session.id}")
|
|
305
|
+
when "/save" then save(arg)
|
|
306
|
+
when "/clear" then @ui.print("\e[2J\e[H")
|
|
307
|
+
when "/exit", "/quit", "/q" then @running = false
|
|
308
|
+
else @ui.error("unknown command #{name}; try /help")
|
|
309
|
+
end
|
|
310
|
+
rescue Error, Render::Error => e
|
|
311
|
+
@ui.error(e.message)
|
|
312
|
+
rescue StandardError, ScriptError => e
|
|
313
|
+
report(e)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# /help lists the commands; /help factor explains one name.
|
|
317
|
+
def help(arg = "")
|
|
318
|
+
return help_for(arg.strip) unless arg.strip.empty?
|
|
319
|
+
commands = COMMANDS.reject { |k, _| !@assistant.available? && ASSISTANT_COMMANDS.include?(k.split.first) }
|
|
320
|
+
width = commands.keys.map(&:size).max
|
|
321
|
+
commands.each { |k, v| @ui.puts " #{Style.cyan(k.ljust(width))} #{v}" }
|
|
322
|
+
@ui.puts
|
|
323
|
+
tail = @assistant.available? ? " or, if it is not Ruby, a question for Claude" : ""
|
|
324
|
+
@ui.info("Anything else is Ruby (x + 1, e.expand, ZZ[x].(x**2 - 1).factor)#{tail}.")
|
|
325
|
+
@ui.info("/help factor, /help ZZ, /help Matrix explain one name.")
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# One command, or one function, set or class (from the source, see Docs).
|
|
329
|
+
def help_for(name)
|
|
330
|
+
if name.start_with?("/")
|
|
331
|
+
entry = COMMANDS.find { |k, _| k.split(/[\s\[]/).first == name }
|
|
332
|
+
return @ui.error("unknown command #{name}; try /help") if entry.nil?
|
|
333
|
+
return @ui.puts(" #{Style.cyan(entry[0])} #{entry[1]}")
|
|
334
|
+
end
|
|
335
|
+
doc = Docs.doc(name)
|
|
336
|
+
@ui.puts(" #{Style.cyan(doc.signature)}")
|
|
337
|
+
doc.lines.each { |l| @ui.puts(" #{l}") }
|
|
338
|
+
@ui.info("also: #{doc.also}") if doc.also
|
|
339
|
+
doc.background&.each do |label, text| # the label is coloured, the prose is not
|
|
340
|
+
wrapped = RCAS::Documentation.wrap(" #{label}: ", text)
|
|
341
|
+
head = " #{label}:"
|
|
342
|
+
@ui.puts(" #{Style.cyan("#{label}:")}#{wrapped.first[head.size..]}")
|
|
343
|
+
wrapped.drop(1).each { |line| @ui.puts(line) }
|
|
344
|
+
end
|
|
345
|
+
doc.sources&.each_with_index do |source, i|
|
|
346
|
+
wrapped = RCAS::Documentation.wrap(i.zero? ? " sources: " : " ", source)
|
|
347
|
+
@ui.puts(i.zero? ? " #{Style.cyan('sources:')}#{wrapped.first[10..]}" : wrapped.first)
|
|
348
|
+
wrapped.drop(1).each { |line| @ui.puts(line) }
|
|
349
|
+
end
|
|
350
|
+
doc.reading&.each_with_index do |link, i|
|
|
351
|
+
@ui.puts(i.zero? ? " #{Style.cyan('read:')} #{link}" : " #{link}")
|
|
352
|
+
end
|
|
353
|
+
@ui.info("manual: #{doc.sections.join('; ')}") unless doc.sections.empty?
|
|
354
|
+
rescue Docs::NotFound => e
|
|
355
|
+
@ui.error(e.message)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def assistant_command(name, arg)
|
|
359
|
+
return @ui.error("unknown command #{name}; try /help") unless @assistant.available?
|
|
360
|
+
case name
|
|
361
|
+
when "/model"
|
|
362
|
+
@assistant.model = arg unless arg.empty?
|
|
363
|
+
@ui.info("model: #{@assistant.model}")
|
|
364
|
+
when "/fallbacks"
|
|
365
|
+
@assistant.fallbacks = (arg == "on") unless arg.empty?
|
|
366
|
+
@ui.info("fallbacks: #{@assistant.fallbacks ? "on (#{Assistant::FALLBACK_MODEL} on refusal)" : 'off'}")
|
|
367
|
+
when "/cost" then @ui.puts(@assistant.cost.lines.map { |l| " #{l}" }.join)
|
|
368
|
+
when "/compact"
|
|
369
|
+
@assistant.reset
|
|
370
|
+
@ui.info("conversation cleared; variables kept")
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def plotstyle(arg)
|
|
375
|
+
return @ui.error("usage: /plotstyle #{Plot::STYLES.join('|')}") unless arg.empty? || Plot::STYLES.include?(arg.to_sym)
|
|
376
|
+
Plot.style = arg unless arg.empty?
|
|
377
|
+
note = Plot.image? && !Plot.pictures?(@ui.io) ? " (no inline pictures here, so plots stay text)" : ""
|
|
378
|
+
@ui.info("plotstyle #{Plot.style}#{note}")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def unicode(arg)
|
|
382
|
+
return @ui.error("usage: /unicode on|off") unless arg.empty? || %w[on off].include?(arg)
|
|
383
|
+
RCAS.unicode = (arg == "on") unless arg.empty?
|
|
384
|
+
@ui.info("unicode #{RCAS.unicode? ? 'on' : 'off'}")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def numbered(arg)
|
|
388
|
+
return @ui.error("usage: /numbered on|off") unless arg.empty? || %w[on off].include?(arg)
|
|
389
|
+
RCAS.numbered = (arg == "on") unless arg.empty?
|
|
390
|
+
@ui.info("numbered #{RCAS.numbered? ? 'on' : 'off'} (In and Out keep the session either way)")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def output(arg)
|
|
394
|
+
case arg
|
|
395
|
+
when "" then nil
|
|
396
|
+
when "on" then @ui.mode = :typeset
|
|
397
|
+
when "off" then @ui.mode = :text
|
|
398
|
+
else @ui.mode = arg
|
|
399
|
+
end
|
|
400
|
+
if @ui.typeset? && !Render.inline?(@ui.out)
|
|
401
|
+
@ui.info("output #{@ui.mode}: inline pictures need iTerm2, so results stay text here; try /output latex")
|
|
402
|
+
elsif @ui.typeset? && !Render.available?
|
|
403
|
+
@ui.info("output #{@ui.mode}: no typesetting backend found (npm install for KaTeX, or install LaTeX + dvipng)")
|
|
404
|
+
else
|
|
405
|
+
@ui.info("output #{@ui.mode}#{@ui.typeset? ? " (#{Render.selected.name}, scale #{Render.scale}, theme #{Render.theme})" : ''}")
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def settings_command(arg)
|
|
410
|
+
case arg
|
|
411
|
+
when "save"
|
|
412
|
+
file = Settings.save(Settings.current(@ui, @assistant))
|
|
413
|
+
@ui.info("saved #{file}")
|
|
414
|
+
when "reset"
|
|
415
|
+
Settings.reset
|
|
416
|
+
@ui.info("removed #{Settings::FILE}; built-in defaults apply from the next start")
|
|
417
|
+
when ""
|
|
418
|
+
stored = Settings.load
|
|
419
|
+
@ui.puts " #{Style.dim(Settings::FILE)}#{stored.empty? ? Style.dim(' (none)') : ''}"
|
|
420
|
+
Settings.current(@ui, @assistant).each do |k, v|
|
|
421
|
+
mark = stored.key?(k) && stored[k].to_s != v.to_s ? Style.dim(" (file: #{stored[k]})") : ""
|
|
422
|
+
@ui.puts " #{Style.cyan(k.ljust(10))} #{v}#{mark}"
|
|
423
|
+
end
|
|
424
|
+
else @ui.error("usage: /settings [save|reset]")
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def backend(arg)
|
|
429
|
+
unless arg.empty?
|
|
430
|
+
return @ui.error("usage: /backend katex|latex") unless %w[katex latex].include?(arg)
|
|
431
|
+
Render.backend = arg.to_sym
|
|
432
|
+
Render.reset!
|
|
433
|
+
Render.selected
|
|
434
|
+
end
|
|
435
|
+
available = Render.available_backends.map(&:name)
|
|
436
|
+
@ui.info("backend #{Render.available? ? Render.selected.name : 'none'} (available: #{available.empty? ? 'none' : available.join(', ')})")
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# /resume: a picker over the other sessions, or a direct switch.
|
|
440
|
+
def resume_command(arg)
|
|
441
|
+
persist
|
|
442
|
+
others = Session.list.reject { |s| s.id == @session.id }
|
|
443
|
+
target =
|
|
444
|
+
if arg.empty?
|
|
445
|
+
return @ui.info("no other saved sessions") if others.empty?
|
|
446
|
+
Picker.new(others, input: @input, output: @ui.out).run or return @ui.info("(kept the current session)")
|
|
447
|
+
else
|
|
448
|
+
Session.find(arg, among: others) or return @ui.error("no unique session matches #{arg.inspect}; see /sessions")
|
|
449
|
+
end
|
|
450
|
+
resume(target)
|
|
451
|
+
@ui.info("resumed #{@session.name} (#{@session.id})")
|
|
452
|
+
@ui.history(transcript)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def sessions
|
|
456
|
+
list = Session.list
|
|
457
|
+
return @ui.info("no saved sessions") if list.empty?
|
|
458
|
+
list.first(20).each_with_index do |s, i|
|
|
459
|
+
marker = s.id == @session.id ? Style.green("*") : " "
|
|
460
|
+
@ui.puts " #{marker} #{Style.dim((i + 1).to_s.rjust(2))} #{s.summary}"
|
|
461
|
+
end
|
|
462
|
+
@ui.info("/resume opens a picker; /resume NAME, N or ID switches directly; /rename names this one")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def save(file)
|
|
466
|
+
file = "rcas-#{Time.now.strftime('%Y%m%d-%H%M%S')}.md" if file.empty?
|
|
467
|
+
File.write(File.expand_path(file), markdown)
|
|
468
|
+
@ui.info("saved #{file}")
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def markdown
|
|
472
|
+
out = ["# rcas session #{@session.id}", ""]
|
|
473
|
+
transcript.each do |kind, a, b|
|
|
474
|
+
case kind
|
|
475
|
+
when :input then out << "```ruby" << a << "```"
|
|
476
|
+
when :result
|
|
477
|
+
out << "=> `#{a.lines.first&.chomp}`#{a.lines.size > 1 ? "\n```\n#{a}\n```" : ''}"
|
|
478
|
+
out << "" << "$$#{b}$$" if b
|
|
479
|
+
when :error then out << "Error: #{a}"
|
|
480
|
+
when :assistant then out << "" << a
|
|
481
|
+
when :tool then out << "```ruby" << "# rcas_eval" << a << "```" << "```" << b.to_s << "```"
|
|
482
|
+
when :shell then out << "```" << "$ #{a}" << b.to_s.chomp << "```"
|
|
483
|
+
end
|
|
484
|
+
out << ""
|
|
485
|
+
end
|
|
486
|
+
out.join("\n")
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# ---- line editing -------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def setup_reline
|
|
492
|
+
Reline.completion_append_character = ""
|
|
493
|
+
Reline.completion_proc = lambda do |word|
|
|
494
|
+
if word.start_with?("/")
|
|
495
|
+
COMMANDS.keys.map { |k| k.split.first }.select { |c| c.start_with?(word) }
|
|
496
|
+
else
|
|
497
|
+
@workspace.complete(word)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
load_history
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def load_history
|
|
504
|
+
if !File.file?(HISTORY_FILE) && File.file?(OLD_HISTORY_FILE)
|
|
505
|
+
FileUtils.mkdir_p(File.dirname(HISTORY_FILE))
|
|
506
|
+
File.rename(OLD_HISTORY_FILE, HISTORY_FILE)
|
|
507
|
+
end
|
|
508
|
+
return unless File.file?(HISTORY_FILE)
|
|
509
|
+
File.readlines(HISTORY_FILE, chomp: true).last(1000).each { |l| Reline::HISTORY << l unless l.empty? }
|
|
510
|
+
rescue StandardError
|
|
511
|
+
nil
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def save_history
|
|
515
|
+
FileUtils.mkdir_p(File.dirname(HISTORY_FILE))
|
|
516
|
+
File.write(HISTORY_FILE, Reline::HISTORY.to_a.last(1000).join("\n") + "\n")
|
|
517
|
+
rescue StandardError
|
|
518
|
+
nil
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Entry point for bin/rcas-chat.
|
|
523
|
+
def self.start(argv = ARGV, input: $stdin, output: $stdout)
|
|
524
|
+
settings = Settings.apply(Settings.load)
|
|
525
|
+
options = {
|
|
526
|
+
mode: settings["output"]&.to_sym,
|
|
527
|
+
model: ENV["RCAS_MODEL"] || settings["model"] || Assistant::DEFAULT_MODEL,
|
|
528
|
+
session: nil, pick: false,
|
|
529
|
+
fallbacks: (ENV.key?("RCAS_FALLBACKS") ? ENV["RCAS_FALLBACKS"] != "0" : settings.fetch("fallbacks", nil))
|
|
530
|
+
}
|
|
531
|
+
until argv.empty?
|
|
532
|
+
case (arg = argv.shift)
|
|
533
|
+
when "--no-tex", "--no-typeset", "--text" then options[:mode] = :text
|
|
534
|
+
when "--tex", "--typeset" then options[:mode] = :typeset
|
|
535
|
+
when /\A--output=(\w+)\z/ then options[:mode] = Regexp.last_match(1).to_sym
|
|
536
|
+
when /\A--backend=(katex|latex)\z/ then Render.backend = Regexp.last_match(1).to_sym
|
|
537
|
+
when "--model" then options[:model] = argv.shift
|
|
538
|
+
when /\A--model=(.+)\z/ then options[:model] = Regexp.last_match(1)
|
|
539
|
+
when "-c", "--continue" then options[:session] = Session.latest || (warn "no saved session to continue"; return 1)
|
|
540
|
+
when "-r", "--resume"
|
|
541
|
+
ref = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
542
|
+
if ref
|
|
543
|
+
options[:session] = Session.find(ref) || (warn "no saved session #{ref}"; return 1)
|
|
544
|
+
else
|
|
545
|
+
options[:pick] = true
|
|
546
|
+
end
|
|
547
|
+
when "--no-color" then Style.enabled = false
|
|
548
|
+
when "-h", "--help"
|
|
549
|
+
claude = Assistant.configured?
|
|
550
|
+
output.puts <<~USAGE
|
|
551
|
+
usage: rcas-chat [options]
|
|
552
|
+
-c, --continue continue the most recent session
|
|
553
|
+
-r, --resume [NAME|ID] resume a session by name or id (pick from a list without an argument)
|
|
554
|
+
#{claude ? " --model ID Claude model (default #{Assistant::DEFAULT_MODEL}, or RCAS_MODEL)\n" : ''} --output=MODE text | typeset | both | latex (--typeset, --text for short)
|
|
555
|
+
--backend=katex|latex typesetting backend (or RCAS_TEX_BACKEND)
|
|
556
|
+
--no-color
|
|
557
|
+
#{claude ? 'Ruby is evaluated; anything else is a question for Claude.' : 'Ruby is evaluated and the results are typeset.'}
|
|
558
|
+
USAGE
|
|
559
|
+
return 0
|
|
560
|
+
else
|
|
561
|
+
warn "unknown option #{arg}; try --help"
|
|
562
|
+
return 1
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
if options[:pick]
|
|
566
|
+
options[:session] = pick_session(input, output) or return 0
|
|
567
|
+
end
|
|
568
|
+
repl = REPL.new(input: input, output: output, model: options[:model], mode: options[:mode], session: options[:session])
|
|
569
|
+
repl.assistant.fallbacks = options[:fallbacks] unless options[:fallbacks].nil?
|
|
570
|
+
repl.run
|
|
571
|
+
0
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def self.pick_session(input, output)
|
|
575
|
+
list = Session.list
|
|
576
|
+
if list.empty?
|
|
577
|
+
output.puts "no saved sessions"
|
|
578
|
+
return nil
|
|
579
|
+
end
|
|
580
|
+
Picker.new(list, input: input, output: output).run || (output.puts "no session chosen"; nil)
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|