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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. 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