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,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "date"
|
|
8
|
+
|
|
9
|
+
module RCAS
|
|
10
|
+
module Chat
|
|
11
|
+
# A saved session: the transcript, the conversation with Claude and the
|
|
12
|
+
# settings, as JSON under ~/.rcas/sessions. Variables are not stored;
|
|
13
|
+
# they are rebuilt by replaying the transcript's Ruby when resuming.
|
|
14
|
+
class Session
|
|
15
|
+
DIR = ENV.fetch("RCAS_SESSION_DIR", File.join(HOME, "sessions"))
|
|
16
|
+
|
|
17
|
+
attr_reader :id, :created_at
|
|
18
|
+
attr_accessor :title, :model, :mode, :transcript, :messages, :usage, :updated_at, :cwd
|
|
19
|
+
|
|
20
|
+
def self.dir = DIR
|
|
21
|
+
|
|
22
|
+
def self.path(id) = File.join(dir, "#{id}.json")
|
|
23
|
+
|
|
24
|
+
def self.list
|
|
25
|
+
return [] unless File.directory?(dir)
|
|
26
|
+
Dir[File.join(dir, "*.json")].filter_map { |f| load(File.basename(f, ".json")) rescue nil }.sort_by { |s| [s.updated_at, s.id] }.reverse
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.latest = list.first
|
|
30
|
+
|
|
31
|
+
# By id, by name (exact, then unique prefix, case-insensitive), by a
|
|
32
|
+
# unique id prefix, or by a 1-based index into the list.
|
|
33
|
+
def self.find(ref, among: nil)
|
|
34
|
+
return latest if ref.nil? || ref.to_s.empty?
|
|
35
|
+
return load(ref) if among.nil? && File.file?(path(ref))
|
|
36
|
+
sessions = among || list
|
|
37
|
+
ref = ref.to_s.strip
|
|
38
|
+
return sessions[ref.to_i - 1] if ref.match?(/\A\d{1,3}\z/) && ref.to_i.between?(1, sessions.size)
|
|
39
|
+
exact = sessions.select { |s| s.id == ref || s.title&.casecmp?(ref) }
|
|
40
|
+
return exact.first if exact.size == 1
|
|
41
|
+
matches = sessions.select { |s| s.id.start_with?(ref) || s.title.to_s.downcase.start_with?(ref.downcase) }
|
|
42
|
+
matches.size == 1 ? matches.first : nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.load(id)
|
|
46
|
+
data = JSON.parse(File.read(path(id)), symbolize_names: true)
|
|
47
|
+
new(id: data[:id], created_at: Time.parse(data[:created_at])).tap do |s|
|
|
48
|
+
s.updated_at = Time.parse(data[:updated_at])
|
|
49
|
+
s.title = data[:title]
|
|
50
|
+
s.model = data[:model]
|
|
51
|
+
s.mode = data[:mode]&.to_sym
|
|
52
|
+
s.cwd = data[:cwd]
|
|
53
|
+
s.transcript = data[:transcript].to_a.map { |e| [e[0].to_sym, *e[1..]] }
|
|
54
|
+
s.messages = data[:messages].to_a
|
|
55
|
+
s.usage = (data[:usage] || {}).transform_keys(&:to_sym)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(id: nil, created_at: Time.now)
|
|
60
|
+
@id = id || "#{created_at.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(2)}"
|
|
61
|
+
@created_at = created_at
|
|
62
|
+
@updated_at = created_at
|
|
63
|
+
@transcript = []
|
|
64
|
+
@messages = []
|
|
65
|
+
@usage = {}
|
|
66
|
+
@cwd = Dir.pwd
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def path = self.class.path(id)
|
|
70
|
+
|
|
71
|
+
def save
|
|
72
|
+
@updated_at = Time.now
|
|
73
|
+
@title ||= transcript.find { |k, _| k == :input }&.[](1)&.lines&.first&.strip&.[](0, 60)
|
|
74
|
+
FileUtils.mkdir_p(self.class.dir)
|
|
75
|
+
File.write(path, JSON.pretty_generate(to_h))
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_h
|
|
80
|
+
{
|
|
81
|
+
id: id, created_at: created_at.iso8601(6), updated_at: updated_at.iso8601(6), title: title,
|
|
82
|
+
model: model, mode: mode, cwd: cwd, usage: usage,
|
|
83
|
+
transcript: transcript,
|
|
84
|
+
messages: JSON.parse(JSON.generate(messages))
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delete
|
|
89
|
+
File.delete(path) if File.file?(path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def turns = transcript.count { |k, _| k == :input }
|
|
93
|
+
|
|
94
|
+
# The first thing typed, as a one-line preview.
|
|
95
|
+
def first_input
|
|
96
|
+
transcript.find { |k, _| k == :input }&.[](1)&.lines&.first&.strip&.[](0, 80)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The title, or the first input when the session was never named.
|
|
100
|
+
def name = title.to_s.empty? ? (first_input || "(empty)") : title
|
|
101
|
+
|
|
102
|
+
# "just now", "5m ago", "3h ago", "yesterday 14:02", "Sep 10 14:02"
|
|
103
|
+
def relative_time(now = Time.now)
|
|
104
|
+
age = now - updated_at
|
|
105
|
+
return "just now" if age < 60
|
|
106
|
+
return "#{(age / 60).floor}m ago" if age < 3600
|
|
107
|
+
return "#{(age / 3600).floor}h ago" if age < 86_400 && updated_at.to_date == now.to_date
|
|
108
|
+
return "yesterday #{updated_at.strftime('%H:%M')}" if updated_at.to_date == (now.to_date - 1)
|
|
109
|
+
updated_at.strftime(updated_at.year == now.year ? "%b %d %H:%M" : "%Y-%m-%d")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def summary
|
|
113
|
+
"#{id} #{relative_time.ljust(15)} #{turns.to_s.rjust(3)} inputs #{name}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Bring a workspace back to the state the transcript left it in.
|
|
117
|
+
def replay_into(workspace)
|
|
118
|
+
transcript.each do |kind, text, _|
|
|
119
|
+
case kind
|
|
120
|
+
when :input
|
|
121
|
+
next if text.start_with?("!", "?")
|
|
122
|
+
if text.start_with?("/")
|
|
123
|
+
case text
|
|
124
|
+
when %r{\A/forget\b(.*)} then RCAS.forget(*Regexp.last_match(1).split.map(&:to_sym))
|
|
125
|
+
when %r{\A/reset\b} then workspace = Workspace.new
|
|
126
|
+
end
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
workspace.replay(text)
|
|
130
|
+
when :tool then workspace.replay(text)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
workspace
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RCAS
|
|
7
|
+
module Chat
|
|
8
|
+
# ~/.rcas (RCAS_HOME): settings.json, history, sessions/
|
|
9
|
+
HOME = ENV.fetch("RCAS_HOME", File.join(Dir.home, ".rcas"))
|
|
10
|
+
|
|
11
|
+
# Persistent defaults in ~/.rcas/settings.json. Precedence, lowest first:
|
|
12
|
+
# this file, environment variables, command-line flags, /commands in a
|
|
13
|
+
# session. `/settings save` writes the current values back.
|
|
14
|
+
module Settings
|
|
15
|
+
FILE = File.join(HOME, "settings.json")
|
|
16
|
+
KEYS = %w[output backend scale theme wrap plotstyle unicode numbered model fallbacks].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def load(file = FILE)
|
|
21
|
+
return {} unless File.file?(file)
|
|
22
|
+
data = JSON.parse(File.read(file))
|
|
23
|
+
raise Error, "settings must be a JSON object" unless data.is_a?(Hash)
|
|
24
|
+
data.select { |k, _| KEYS.include?(k) }
|
|
25
|
+
rescue JSON::ParserError, Error => e
|
|
26
|
+
warn "rcas: ignoring #{file}: #{e.message}"
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def save(settings, file = FILE)
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
32
|
+
File.write(file, JSON.pretty_generate(settings.select { |k, _| KEYS.include?(k) }) + "\n")
|
|
33
|
+
file
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset(file = FILE)
|
|
37
|
+
File.delete(file) if File.file?(file)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Apply the file's rendering defaults where no environment variable
|
|
41
|
+
# overrides them. Output mode, model and fallbacks are consumed by the
|
|
42
|
+
# REPL because they depend on the session.
|
|
43
|
+
def apply(settings)
|
|
44
|
+
Render.backend = settings["backend"].to_sym if settings["backend"] && !ENV["RCAS_TEX_BACKEND"]
|
|
45
|
+
Render.scale = settings["scale"].to_f if settings["scale"] && !ENV["RCAS_TEX_SCALE"]
|
|
46
|
+
Render.theme = settings["theme"].to_sym if settings["theme"] && !ENV["RCAS_TEX_THEME"]
|
|
47
|
+
Render.wrap = settings["wrap"].to_i if settings["wrap"] && !ENV["RCAS_TEX_WRAP"]
|
|
48
|
+
Plot.style = settings["plotstyle"] if settings["plotstyle"] && !ENV["RCAS_PLOT_STYLE"]
|
|
49
|
+
RCAS.unicode = settings["unicode"] if settings.key?("unicode") && !ENV["RCAS_UNICODE"]
|
|
50
|
+
RCAS.numbered = settings["numbered"] if settings.key?("numbered") && !ENV["RCAS_NUMBERED"]
|
|
51
|
+
settings
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The values in force right now, in the file's format.
|
|
55
|
+
def current(ui, assistant)
|
|
56
|
+
{
|
|
57
|
+
"output" => ui.mode.to_s,
|
|
58
|
+
"backend" => (Render.available? ? Render.selected.name.to_s : nil),
|
|
59
|
+
"scale" => Render.scale,
|
|
60
|
+
"theme" => Render.theme.to_s,
|
|
61
|
+
"wrap" => Render.wrap,
|
|
62
|
+
"plotstyle" => Plot.style.to_s,
|
|
63
|
+
"unicode" => RCAS.unicode?,
|
|
64
|
+
"numbered" => RCAS.numbered?,
|
|
65
|
+
"model" => (assistant.model if assistant.available?),
|
|
66
|
+
"fallbacks" => (assistant.fallbacks if assistant.available?)
|
|
67
|
+
}.compact
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
module Chat
|
|
5
|
+
# ANSI colours, off when not writing to a terminal or NO_COLOR is set.
|
|
6
|
+
module Style
|
|
7
|
+
CODES = { bold: 1, dim: 2, italic: 3, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37 }.freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_writer :enabled
|
|
11
|
+
|
|
12
|
+
def enabled?
|
|
13
|
+
return @enabled unless @enabled.nil?
|
|
14
|
+
$stdout.respond_to?(:tty?) && $stdout.tty? && ENV["NO_COLOR"].nil? && ENV["TERM"] != "dumb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def paint(text, *styles)
|
|
18
|
+
return text.to_s if styles.empty? || !enabled?
|
|
19
|
+
"\e[#{styles.map { |s| CODES.fetch(s) }.join(';')}m#{text}\e[0m"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def strip(text) = text.to_s.gsub(/\e\[[\d;]*m/, "")
|
|
23
|
+
|
|
24
|
+
CODES.each_key do |name|
|
|
25
|
+
define_method(name) { |text| paint(text, name) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
module Chat
|
|
5
|
+
# The tool Claude calls to run Ruby in the session. It subclasses the
|
|
6
|
+
# anthropic gem's classes, so it is loaded after the gem and only with
|
|
7
|
+
# it, by Chat.load_anthropic.
|
|
8
|
+
class EvalInput < Anthropic::BaseModel
|
|
9
|
+
required :code, String, doc: "Ruby code to evaluate in the user's rcas session. Variables persist between calls."
|
|
10
|
+
optional :show, Anthropic::Boolean, doc: "Display the result typeset in the user's terminal. Use it for the final result(s) the user asked for."
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Runs Ruby in the workspace on Claude's behalf and echoes the call and
|
|
14
|
+
# its result to the terminal, Claude-Code style.
|
|
15
|
+
class EvalTool < Anthropic::BaseTool
|
|
16
|
+
self.tool_name = "rcas_eval"
|
|
17
|
+
description "Evaluate Ruby code in the live rcas session and return the printed output and the result (as text and LaTeX)."
|
|
18
|
+
input_schema EvalInput
|
|
19
|
+
|
|
20
|
+
MAX_RESULT = 6000
|
|
21
|
+
|
|
22
|
+
attr_accessor :on_call
|
|
23
|
+
|
|
24
|
+
def initialize(workspace, ui)
|
|
25
|
+
@workspace = workspace
|
|
26
|
+
@ui = ui
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(input)
|
|
30
|
+
code = input.code.to_s
|
|
31
|
+
@ui.tool_call("rcas_eval", code)
|
|
32
|
+
value, output = @ui.busy("computing") { @workspace.eval(code, capture: true) }
|
|
33
|
+
shown = "#{output}=> #{@ui.text_of(value)}"
|
|
34
|
+
@ui.tool_result(shown)
|
|
35
|
+
@ui.typeset(value, force: true) if input.show
|
|
36
|
+
on_call&.call(code, shown)
|
|
37
|
+
@ui.busy_start("thinking")
|
|
38
|
+
reply = shown
|
|
39
|
+
reply += "\nLaTeX: #{LaTeX.of(value)}" if @ui.typesettable?(value)
|
|
40
|
+
reply.size > MAX_RESULT ? "#{reply[0, MAX_RESULT]}\n… (truncated)" : reply
|
|
41
|
+
rescue StandardError, ScriptError => e
|
|
42
|
+
message = "#{e.class}: #{e.message.lines.first&.strip}"
|
|
43
|
+
hint = Usage.hint(e)
|
|
44
|
+
@ui.tool_result(message, error: true)
|
|
45
|
+
@ui.hint(hint)
|
|
46
|
+
message = ([message] + hint).join("\n") if hint
|
|
47
|
+
on_call&.call(code, message)
|
|
48
|
+
@ui.busy_start("thinking")
|
|
49
|
+
raise StandardError, message
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/rcas/chat/ui.rb
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
module Chat
|
|
5
|
+
# Everything that is printed: results in the chosen output mode, the
|
|
6
|
+
# banner, Claude's streamed text and its tool calls.
|
|
7
|
+
class UI
|
|
8
|
+
# How results are displayed:
|
|
9
|
+
# :text - plain rcas text only
|
|
10
|
+
# :typeset - typeset picture only (text when the value has no LaTeX form)
|
|
11
|
+
# :both - text, then the picture
|
|
12
|
+
# :latex - text, then the LaTeX source
|
|
13
|
+
MODES = %i[text typeset both latex].freeze
|
|
14
|
+
# :tex was this mode's name until Sept 2026; a settings.json written
|
|
15
|
+
# before then, and the habit, still say it.
|
|
16
|
+
ALIASES = { tex: :typeset }.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :out, :mode
|
|
19
|
+
|
|
20
|
+
def initialize(out: $stdout, mode: nil)
|
|
21
|
+
@out = out
|
|
22
|
+
@mode = mode || default_mode
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_mode
|
|
26
|
+
Render.inline?(@out) && Render.available? ? :typeset : :text
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mode=(value)
|
|
30
|
+
value = self.class.mode_for(value)
|
|
31
|
+
raise Error, "output mode must be one of #{MODES.join(', ')}" if value.nil?
|
|
32
|
+
@mode = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The mode +name+ stands for, or nil for a name that is none.
|
|
36
|
+
def self.mode_for(name)
|
|
37
|
+
return nil if name.nil? || name.to_s.empty?
|
|
38
|
+
name = name.to_sym
|
|
39
|
+
name = ALIASES.fetch(name, name)
|
|
40
|
+
MODES.include?(name) ? name : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def typeset? = %i[typeset both].include?(@mode)
|
|
44
|
+
|
|
45
|
+
def io = @out
|
|
46
|
+
def puts(text = "") = @out.puts(text)
|
|
47
|
+
def print(text) = @out.print(text)
|
|
48
|
+
def flush = @out.flush
|
|
49
|
+
|
|
50
|
+
# ---- banner -------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
# Without Claude configured, nothing here hints that it exists.
|
|
53
|
+
def banner(model:, backend:, assistant:, session: nil)
|
|
54
|
+
tagline = assistant ? "type Ruby or ask a question" : "type Ruby"
|
|
55
|
+
lines = [
|
|
56
|
+
"#{Style.paint('✻', :magenta, :bold)} #{Style.bold("rcas #{RCAS::VERSION}")} #{Style.dim("- symbols are indeterminates; #{tagline}")}",
|
|
57
|
+
""
|
|
58
|
+
]
|
|
59
|
+
lines << " #{Style.dim('model')} #{model}" if assistant
|
|
60
|
+
lines << " #{Style.dim('output')} #{@mode}#{typeset? ? Style.dim(" via #{backend}") : ''}"
|
|
61
|
+
lines << " #{Style.dim('help')} /help #{Style.dim('quit')} /exit or Ctrl-D"
|
|
62
|
+
lines << " #{Style.dim('session')} #{session}" if session
|
|
63
|
+
lines << ""
|
|
64
|
+
examples = ["e = (x + 1) * (1 - x)", "e.expand", "ZZ[x].(x**6 - 1).factor"]
|
|
65
|
+
examples << "factor x**6 - 1 over the integers" if assistant
|
|
66
|
+
lines << " #{Style.dim('try')} #{examples.first}"
|
|
67
|
+
examples.drop(1).each { |ex| lines << " #{ex}" }
|
|
68
|
+
box(lines)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def box(lines)
|
|
72
|
+
width = [lines.map { |l| visible_width(l) }.max + 2, columns - 2].min
|
|
73
|
+
width = 20 if width < 20
|
|
74
|
+
puts Style.dim("╭#{'─' * width}╮")
|
|
75
|
+
lines.each do |l|
|
|
76
|
+
l = truncate(l, width - 2)
|
|
77
|
+
pad = [width - visible_width(l) - 1, 0].max
|
|
78
|
+
puts "#{Style.dim('│')} #{l}#{' ' * pad}#{Style.dim('│')}"
|
|
79
|
+
end
|
|
80
|
+
puts Style.dim("╰#{'─' * width}╯")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Cut a (possibly coloured) line to +max+ visible characters.
|
|
84
|
+
def truncate(line, max)
|
|
85
|
+
return line if visible_width(line) <= max
|
|
86
|
+
plain = Style.strip(line)
|
|
87
|
+
"#{plain[0, [max - 1, 0].max]}…"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def visible_width(text) = Style.strip(text).size
|
|
91
|
+
|
|
92
|
+
def columns
|
|
93
|
+
require "io/console"
|
|
94
|
+
width = @out.respond_to?(:winsize) && @out.tty? ? @out.winsize[1] : 0
|
|
95
|
+
width = ENV.fetch("COLUMNS", "0").to_i if width.to_i <= 0
|
|
96
|
+
width.to_i.positive? ? width.to_i : 100
|
|
97
|
+
rescue StandardError
|
|
98
|
+
100
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ---- results ------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
# A plot is braille art unless /plotstyle image asks for a picture.
|
|
104
|
+
def plot_picture(value, force: false)
|
|
105
|
+
return false unless force || Plot.image?
|
|
106
|
+
return false unless value.picture?(@out)
|
|
107
|
+
print " "
|
|
108
|
+
value.picture(io: @out)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def result(value)
|
|
112
|
+
Results.record(value) # `Out[3]` reaches the result of the session's third line
|
|
113
|
+
return if value.is_a?(Plot) && plot_picture(value)
|
|
114
|
+
show_text = @mode != :typeset || !typesettable?(value) || !Render.inline?(@out)
|
|
115
|
+
if show_text
|
|
116
|
+
lines = text_of(value).lines.map(&:chomp)
|
|
117
|
+
mark = Results.mark
|
|
118
|
+
indent = " " * (mark ? mark.length + 1 : 0)
|
|
119
|
+
puts "#{mark ? "#{Style.dim(mark)} " : ''}#{Style.green(lines.first.to_s)}"
|
|
120
|
+
lines.drop(1).each { |l| puts "#{indent}#{Style.green(l)}" }
|
|
121
|
+
end
|
|
122
|
+
case @mode
|
|
123
|
+
when :typeset, :both then typeset(value)
|
|
124
|
+
when :latex then puts " #{Style.dim(LaTeX.of(value))}" if typesettable?(value)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def text_of(value)
|
|
129
|
+
value.respond_to?(:to_latex) && !value.is_a?(String) ? value.to_s : value.inspect
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Inline picture when possible; otherwise nothing (the text is there).
|
|
133
|
+
def typeset(value, force: false)
|
|
134
|
+
return unless typesettable?(value)
|
|
135
|
+
return puts(" #{LaTeX.of(value)}") unless Render.inline?(@out) && Render.available?
|
|
136
|
+
return unless force || typeset?
|
|
137
|
+
print " "
|
|
138
|
+
Render.show(value, io: @out)
|
|
139
|
+
rescue Render::Error => e
|
|
140
|
+
info("(typesetting failed: #{e.message.lines.first&.strip})")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def typesettable?(value)
|
|
144
|
+
case value
|
|
145
|
+
when Expression, Polynomial, Factorization, Domain, Vector, Matrix, VectorSpace, MatrixSpace, Symbol, Numeric then true
|
|
146
|
+
when ->(v) { defined?(Equation) && v.is_a?(Equation) } then true
|
|
147
|
+
when ->(v) { v.respond_to?(:to_latex) && !v.is_a?(String) } then true # inequalities, sets, field elements, ...
|
|
148
|
+
when Array then !value.empty? && value.all? { |v| typesettable?(v) }
|
|
149
|
+
when Hash then !value.empty? && value.values.all? { |v| v.is_a?(Domain) }
|
|
150
|
+
else false
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def error(message) = puts(Style.red(" #{message}"))
|
|
155
|
+
def info(message) = puts(Style.dim(" #{message}"))
|
|
156
|
+
def warning(message) = puts(Style.yellow(" warning: #{message}"))
|
|
157
|
+
|
|
158
|
+
# A usage hint under an error: the first line in cyan, the rest dimmed.
|
|
159
|
+
def hint(lines)
|
|
160
|
+
return if lines.nil? || lines.empty?
|
|
161
|
+
first, *rest = lines
|
|
162
|
+
puts " #{Style.cyan(first)}"
|
|
163
|
+
rest.each { |l| puts " #{Style.dim(l)}" }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ---- waiting ------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def tty? = @out.respond_to?(:tty?) && @out.tty?
|
|
169
|
+
|
|
170
|
+
# A horizontal rule across the terminal.
|
|
171
|
+
def rule = Style.dim("─" * [columns, 200].min)
|
|
172
|
+
|
|
173
|
+
# Animate +label+ on the current line while the block runs; the line is
|
|
174
|
+
# cleared before anything else is printed. Returns the block's value.
|
|
175
|
+
def busy(label)
|
|
176
|
+
busy_start(label)
|
|
177
|
+
yield
|
|
178
|
+
ensure
|
|
179
|
+
busy_stop
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def busy_start(label)
|
|
183
|
+
busy_stop
|
|
184
|
+
return unless tty?
|
|
185
|
+
@spinner = Spinner.new(@out, label).start
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def busy_stop
|
|
189
|
+
@spinner&.stop
|
|
190
|
+
@spinner = nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Seconds a computation took, mentioned only when it was noticeable.
|
|
194
|
+
# At the right edge of the terminal, where it is out of the way of the
|
|
195
|
+
# result; piped output has no edge to hang it on, so there it is
|
|
196
|
+
# indented like any other aside.
|
|
197
|
+
def took(seconds)
|
|
198
|
+
return if seconds < 1.5
|
|
199
|
+
text = "(#{seconds.round(1)}s)"
|
|
200
|
+
return info(text) unless tty?
|
|
201
|
+
puts Style.dim(text.rjust([columns - 1, text.size + 2].max))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A dimmed replay of earlier turns when a session is resumed.
|
|
205
|
+
def history(transcript, limit: 12)
|
|
206
|
+
entries = transcript.last(limit)
|
|
207
|
+
info("… #{transcript.size - entries.size} earlier entries") if transcript.size > entries.size
|
|
208
|
+
entries.each do |kind, a, b|
|
|
209
|
+
case kind
|
|
210
|
+
when :input then puts "#{Style.dim('❯')} #{Style.dim(a)}"
|
|
211
|
+
when :result then puts " #{Style.dim("=> #{a.lines.first&.chomp}#{a.lines.size > 1 ? ' …' : ''}")}"
|
|
212
|
+
when :error then puts " #{Style.dim(a.lines.first&.chomp)}"
|
|
213
|
+
when :assistant then puts "#{Style.dim('⏺')} #{Style.dim(a.to_s.lines.first(3).join.chomp)}#{a.to_s.lines.size > 3 ? Style.dim(' …') : ''}"
|
|
214
|
+
when :tool then puts "#{Style.dim('⏺')} #{Style.dim("rcas_eval(#{a.lines.first&.chomp})")}"
|
|
215
|
+
when :shell then puts "#{Style.dim('!')} #{Style.dim(a)}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
puts
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ---- assistant turn -----------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def assistant_start
|
|
224
|
+
@in_text = false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def assistant_text(delta)
|
|
228
|
+
busy_stop
|
|
229
|
+
unless @in_text
|
|
230
|
+
print "#{Style.paint('⏺', :white, :bold)} "
|
|
231
|
+
@in_text = true
|
|
232
|
+
end
|
|
233
|
+
print delta.gsub("\n", "\n ")
|
|
234
|
+
flush
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def assistant_end
|
|
238
|
+
busy_stop
|
|
239
|
+
puts if @in_text
|
|
240
|
+
@in_text = false
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def tool_call(name, code)
|
|
244
|
+
busy_stop
|
|
245
|
+
assistant_end
|
|
246
|
+
first, *rest = code.lines.map(&:chomp)
|
|
247
|
+
puts "#{Style.paint('⏺', :green, :bold)} #{Style.bold(name)}#{Style.dim('(')}#{Style.cyan(first)}#{rest.empty? ? Style.dim(')') : ''}"
|
|
248
|
+
rest.each_with_index { |l, i| puts " #{Style.cyan(l)}#{i == rest.size - 1 ? Style.dim(')') : ''}" }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def tool_result(text, error: false)
|
|
252
|
+
busy_stop
|
|
253
|
+
lines = text.to_s.lines.map(&:chomp)
|
|
254
|
+
lines = lines.first(12) + ["… (#{lines.size - 12} more lines)"] if lines.size > 13
|
|
255
|
+
lines.each_with_index do |l, i|
|
|
256
|
+
prefix = i.zero? ? " #{Style.dim('⎿')} " : " "
|
|
257
|
+
puts "#{prefix}#{error ? Style.red(l) : Style.dim(l)}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A braille spinner with a label and, after a while, the elapsed time.
|
|
263
|
+
# Runs in its own thread so CPU-bound work in the main thread still
|
|
264
|
+
# lets it turn (Ruby switches threads every 100ms).
|
|
265
|
+
class Spinner
|
|
266
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
267
|
+
|
|
268
|
+
def initialize(out, label, delay: 0.15, interval: 0.08)
|
|
269
|
+
@out = out
|
|
270
|
+
@label = label
|
|
271
|
+
@delay = delay
|
|
272
|
+
@interval = interval
|
|
273
|
+
@shown = false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def start
|
|
277
|
+
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
278
|
+
@thread = Thread.new do
|
|
279
|
+
sleep @delay
|
|
280
|
+
i = 0
|
|
281
|
+
loop do
|
|
282
|
+
draw(FRAMES[i % FRAMES.size])
|
|
283
|
+
i += 1
|
|
284
|
+
sleep @interval
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
@thread.report_on_exception = false
|
|
288
|
+
self
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def stop
|
|
292
|
+
@thread&.kill
|
|
293
|
+
@thread&.join(0.2)
|
|
294
|
+
@thread = nil
|
|
295
|
+
if @shown
|
|
296
|
+
@out.print "\r\e[2K"
|
|
297
|
+
@out.flush
|
|
298
|
+
@shown = false
|
|
299
|
+
end
|
|
300
|
+
elapsed
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def elapsed = @started ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started : 0.0
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def draw(frame)
|
|
308
|
+
secs = elapsed
|
|
309
|
+
time = secs >= 2 ? Style.dim(" #{secs.round}s") : ""
|
|
310
|
+
@out.print "\r\e[2K#{Style.magenta(frame)} #{Style.dim("#{@label}…")}#{time}"
|
|
311
|
+
@out.flush
|
|
312
|
+
@shown = true
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
module Chat
|
|
5
|
+
# Usage hints for errors raised inside rcas, taken from the source: the
|
|
6
|
+
# `def` line of the method the user called and the comment block above
|
|
7
|
+
# it (rcas documents every public function that way).
|
|
8
|
+
#
|
|
9
|
+
# Usage.hint(error) # => ["usage: sum(f, k = nil, from = nil, to = nil, **range)",
|
|
10
|
+
# # "sum(k**2, k, 1, n) or sum(k**2, k: 1..n); an endless range means infinity"]
|
|
11
|
+
module Usage
|
|
12
|
+
LIB = File.expand_path("../..", __dir__)
|
|
13
|
+
USER_FRAME = "(rcas)"
|
|
14
|
+
MAX_DOC_LINES = 4
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Lines to print under an error, or nil when there is nothing to say.
|
|
19
|
+
# Only a plain ArgumentError counts: subclasses such as DomainError
|
|
20
|
+
# describe the mathematics, not the call.
|
|
21
|
+
# +user_frame+ is the file name the front end evaluates input under.
|
|
22
|
+
def hint(error, user_frame: USER_FRAME)
|
|
23
|
+
return nil unless error.instance_of?(ArgumentError)
|
|
24
|
+
frame = entry_frame(error, user_frame) or return nil
|
|
25
|
+
info = describe(frame.path, frame.lineno) or return nil
|
|
26
|
+
lines = ["usage: #{info[:signature]}"]
|
|
27
|
+
lines.concat(info[:doc].first(MAX_DOC_LINES))
|
|
28
|
+
lines
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The outermost rcas frame that the user's code called directly.
|
|
32
|
+
def entry_frame(error, user_frame = USER_FRAME)
|
|
33
|
+
locations = error.backtrace_locations or return nil
|
|
34
|
+
before_user = locations.take_while { |l| l.path != user_frame }
|
|
35
|
+
return nil if before_user.size == locations.size
|
|
36
|
+
before_user.reverse.find { |l| l.path.start_with?(LIB) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# => { name:, signature:, doc: [...] } for the method whose body
|
|
40
|
+
# contains +lineno+ of +path+.
|
|
41
|
+
def describe(path, lineno)
|
|
42
|
+
lines = File.readlines(path)
|
|
43
|
+
i = lineno - 1
|
|
44
|
+
i -= 1 while i >= 0 && !lines[i].match?(/^\s*def\s/)
|
|
45
|
+
return nil if i.negative?
|
|
46
|
+
m = lines[i].match(/def\s+(?:self\.)?(?<name>[A-Za-z_]\w*[?!=]?|[+\-*\/%<>=!\[\]~^&|]+@?)\s*(?:\((?<params>.*?)\))?/)
|
|
47
|
+
return nil unless m
|
|
48
|
+
doc = []
|
|
49
|
+
j = i - 1
|
|
50
|
+
while j >= 0 && lines[j].match?(/^\s*#/)
|
|
51
|
+
doc.unshift(lines[j].sub(/^\s*#\s?/, "").rstrip)
|
|
52
|
+
j -= 1
|
|
53
|
+
end
|
|
54
|
+
params = m[:params].to_s.strip
|
|
55
|
+
signature = params.empty? ? m[:name] : "#{m[:name]}(#{params})"
|
|
56
|
+
{ name: m[:name], signature: signature, doc: doc }
|
|
57
|
+
rescue SystemCallError, IOError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|