aids 0.1.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/bin/aids +5 -0
- data/lib/aids.rb +137 -0
- data/lib/ansi.rb +25 -0
- data/lib/app.rb +421 -0
- data/lib/commands.rb +81 -0
- data/lib/highlighter.rb +171 -0
- data/lib/line_editor.rb +242 -0
- data/lib/paths.rb +44 -0
- data/lib/profile.rb +23 -0
- data/lib/session.rb +111 -0
- data/lib/stats.rb +32 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 337b293a5ba264b783e5dfec4a325a55b64cfd518df952e9d64ab4ce558e06d3
|
|
4
|
+
data.tar.gz: 2788dcdd58c52f75802bd09cbbbaa7635f77c3c944822869519a006c125b7217
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4b4505c645ef91a032a84030807383f6754ee2d5775a728a327779f2fd63abc983e18ab5445e71083dafba39bf809f4e44294918a66616941ac3c44bcff02e5e
|
|
7
|
+
data.tar.gz: 55ab70c325977de42c7f1e03ff6b511fd4b47bb5c8563c2d95e2d1cb0f6ceba73bcbc28c467c6fa25fb342307f467cb67bdb6b6cb28afa95cb53607c23f6bec0
|
data/bin/aids
ADDED
data/lib/aids.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
%w[net/http uri json optparse fileutils io/console pathname time emanlib].each { |lib| require lib }
|
|
2
|
+
|
|
3
|
+
module AI
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
API_URL = URI("https://api.deepseek.com/chat/completions")
|
|
6
|
+
MODEL = "deepseek-chat"
|
|
7
|
+
DATA_DIR = File.expand_path("~/.local/share/ai")
|
|
8
|
+
META_DIR = File.join(DATA_DIR, ".meta")
|
|
9
|
+
STATS_FILE = File.join(META_DIR, "stats.json")
|
|
10
|
+
RATES = { hit: 0.028, miss: 0.28, out: 0.42 }.freeze
|
|
11
|
+
|
|
12
|
+
LCAP = "\ue0b6"; RCAP = "\ue0b4"; USER_ICON = "👤"
|
|
13
|
+
USER_FG = 117; ATT_FG = 180; CMT_FG = 247
|
|
14
|
+
CMT_STYLE = "\e[2;3;38;5;#{CMT_FG}m"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require "profile"
|
|
18
|
+
require "ansi"
|
|
19
|
+
require "paths"
|
|
20
|
+
require "highlighter"
|
|
21
|
+
require "stats"
|
|
22
|
+
require "session"
|
|
23
|
+
require "commands"
|
|
24
|
+
require "line_editor"
|
|
25
|
+
require "app"
|
|
26
|
+
|
|
27
|
+
module AI
|
|
28
|
+
# ── HTTP / streaming ─────────────────────────────────────────────────
|
|
29
|
+
def self.stream(messages, system, key)
|
|
30
|
+
body = { model: MODEL,
|
|
31
|
+
messages: [{ role: "system", content: system }, *messages],
|
|
32
|
+
stream: true,
|
|
33
|
+
stream_options: { include_usage: true } }.to_json
|
|
34
|
+
usage = nil
|
|
35
|
+
full = +""
|
|
36
|
+
Net::HTTP.start(API_URL.host, API_URL.port, use_ssl: true, read_timeout: 120) do |http|
|
|
37
|
+
req = Net::HTTP::Post.new(API_URL,
|
|
38
|
+
"Authorization" => "Bearer #{key}",
|
|
39
|
+
"Content-Type" => "application/json")
|
|
40
|
+
req.body = body
|
|
41
|
+
http.request(req) do |res|
|
|
42
|
+
abort "#{Ansi.fg(196)}API #{res.code}#{Ansi.reset}" unless res.is_a?(Net::HTTPSuccess)
|
|
43
|
+
buf = +""
|
|
44
|
+
res.read_body do |chunk|
|
|
45
|
+
buf << chunk
|
|
46
|
+
while (nl = buf.index("\n"))
|
|
47
|
+
line = buf.slice!(0..nl).strip
|
|
48
|
+
next unless line.start_with?("data: ")
|
|
49
|
+
payload = line.delete_prefix("data: ")
|
|
50
|
+
next if payload == "[DONE]"
|
|
51
|
+
event = JSON.parse(payload) rescue next
|
|
52
|
+
usage = event["usage"] if event["usage"]
|
|
53
|
+
if (tok = event.dig("choices", 0, "delta", "content"))
|
|
54
|
+
full << tok
|
|
55
|
+
yield tok
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
[full, usage]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.title_query(system, user, key)
|
|
65
|
+
body = { model: MODEL, max_tokens: 40,
|
|
66
|
+
messages: [{ role: "system", content: system },
|
|
67
|
+
{ role: "user", content: user }] }.to_json
|
|
68
|
+
Net::HTTP.start(API_URL.host, API_URL.port, use_ssl: true, read_timeout: 15) do |http|
|
|
69
|
+
req = Net::HTTP::Post.new(API_URL,
|
|
70
|
+
"Authorization" => "Bearer #{key}",
|
|
71
|
+
"Content-Type" => "application/json")
|
|
72
|
+
req.body = body
|
|
73
|
+
res = http.request(req)
|
|
74
|
+
return nil unless res.is_a?(Net::HTTPSuccess)
|
|
75
|
+
(JSON.parse(res.body) rescue nil)&.dig("choices", 0, "message", "content")&.strip
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.print_usage(usage)
|
|
82
|
+
return unless usage
|
|
83
|
+
g = "#{Ansi.dim}#{Ansi.fg(243)}"; r = Ansi.reset
|
|
84
|
+
cells = %w[hit miss out total].zip([
|
|
85
|
+
usage["prompt_cache_hit_tokens"] || 0,
|
|
86
|
+
usage["prompt_cache_miss_tokens"] || 0,
|
|
87
|
+
usage["completion_tokens"] || 0,
|
|
88
|
+
usage["total_tokens"] || 0,
|
|
89
|
+
]).map { |label, val| "#{g}#{label}#{r} #{Ansi.fg(87)}#{val}#{r}" }
|
|
90
|
+
cells << "#{Ansi.fg(220)}#{format("%.4f", Stats.cost(usage))}\u03bc$#{r}"
|
|
91
|
+
row = " #{cells.join(" #{g}\u2502#{r} ")} "
|
|
92
|
+
w = Ansi.width(row)
|
|
93
|
+
puts "\n#{g}\u256d#{"─" * w}\u256e#{r}"
|
|
94
|
+
puts "#{g}\u2502#{r}#{row}#{g}\u2502#{r}"
|
|
95
|
+
puts "#{g}\u2570#{"─" * w}\u256f#{r}"
|
|
96
|
+
puts
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.api_message(msg)
|
|
100
|
+
if msg["role"] == "user" && (atts = msg["attachments"]) && atts.any?
|
|
101
|
+
blocks = atts.map { |a| "<attached path=\"#{a["path"]}\">\n#{a["content"]}\n</attached>" }
|
|
102
|
+
{ "role" => "user", "content" => blocks.join("\n") + "\n\n" + msg["content"].to_s }
|
|
103
|
+
else
|
|
104
|
+
{ "role" => msg["role"], "content" => msg["content"] }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ── Entry point ──────────────────────────────────────────────────────
|
|
109
|
+
def self.run(argv = ARGV)
|
|
110
|
+
resume = false
|
|
111
|
+
rest = OptionParser.new { |o|
|
|
112
|
+
o.on("-c", "--continue") { resume = true }
|
|
113
|
+
o.on("--history") { exec("lf", DATA_DIR) }
|
|
114
|
+
o.on("--clean") { clean!; exit }
|
|
115
|
+
}.order(argv)
|
|
116
|
+
|
|
117
|
+
key = ENV["DEEPSEEK_API_KEY"] or abort "#{Ansi.fg(196)}DEEPSEEK_API_KEY not set#{Ansi.reset}"
|
|
118
|
+
app = App.new(key: key, profile: Profile.load, resume: resume)
|
|
119
|
+
|
|
120
|
+
piped = $stdin.tty? ? "" : $stdin.read.strip
|
|
121
|
+
text = [piped, rest.join(" ")].map(&:strip).reject(&:empty?).join(" ")
|
|
122
|
+
|
|
123
|
+
if text.empty?
|
|
124
|
+
app.repl
|
|
125
|
+
else
|
|
126
|
+
puts "#{Ansi.dim}> #{text}#{Ansi.reset}"
|
|
127
|
+
app.ask(text); puts
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.clean!
|
|
132
|
+
files = Dir[File.join(DATA_DIR, "*.md")] +
|
|
133
|
+
Dir[File.join(META_DIR, "*")].select { |x| File.file?(x) }
|
|
134
|
+
files.each { |f| File.delete(f) }
|
|
135
|
+
puts "#{Ansi.fg(114)}\u2726 #{files.size} files removed#{Ansi.reset}"
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/ansi.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── ANSI helpers ─────────────────────────────────────────────────────
|
|
3
|
+
module Ansi
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def reset = "\e[0m"
|
|
7
|
+
def bold = "\e[1m"
|
|
8
|
+
def dim = "\e[2m"
|
|
9
|
+
def fg(n) = "\e[38;5;#{n}m"
|
|
10
|
+
def strip(s) = s.gsub(/\e\[[\d;]*m/, "")
|
|
11
|
+
|
|
12
|
+
def width(s)
|
|
13
|
+
strip(s).each_char.sum do |c|
|
|
14
|
+
case c.ord
|
|
15
|
+
when 0..126, 57344..63743 then 1
|
|
16
|
+
when 65024..65039, 8205 then 0
|
|
17
|
+
when 4352..4447, 9000..9215, 9728..9983, 11088..11093, 11904..40959,
|
|
18
|
+
44032..55215, 63744..64255, 65040..65135, 65281..65376,
|
|
19
|
+
127744..131071, 131072..262143 then 2
|
|
20
|
+
else 1
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/app.rb
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── App / REPL ───────────────────────────────────────────────────────
|
|
3
|
+
class App
|
|
4
|
+
TITLE_PROMPT = "Give a concise title (3-10 words) for this conversation. " \
|
|
5
|
+
"Respond with ONLY the title, nothing else."
|
|
6
|
+
|
|
7
|
+
DISCARD_USAGE = "usage: /discard [all | N | A-B | A- | -B]"
|
|
8
|
+
|
|
9
|
+
attr_reader :session, :attachments
|
|
10
|
+
|
|
11
|
+
def initialize(key:, profile:, resume: false)
|
|
12
|
+
@key = key; @profile = profile
|
|
13
|
+
@editor = LineEditor.new
|
|
14
|
+
@editor.completer = ->(prefix) { complete(prefix) }
|
|
15
|
+
@hl = Highlighter.new
|
|
16
|
+
@fresh = true
|
|
17
|
+
@attachments = []
|
|
18
|
+
@title_thread = nil
|
|
19
|
+
if resume && (st = Session.all.last) && (s = Session.load(st))
|
|
20
|
+
@session = s; @fresh = false
|
|
21
|
+
end
|
|
22
|
+
@session ||= Session.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ask(text)
|
|
26
|
+
p = @profile
|
|
27
|
+
attached_data = @attachments.map { |abs|
|
|
28
|
+
{ "path" => Paths.short(abs), "content" => safe_read(abs) }
|
|
29
|
+
}
|
|
30
|
+
user_msg = { "role" => "user", "content" => text }
|
|
31
|
+
user_msg["attachments"] = attached_data unless attached_data.empty?
|
|
32
|
+
pending = @session.messages.map { |m| AI.api_message(m) } << AI.api_message(user_msg)
|
|
33
|
+
|
|
34
|
+
print_attachments(attached_data) if attached_data.any?
|
|
35
|
+
puts "\n#{Ansi.fg(p.color)}#{p.icon} #{p.name}#{Ansi.reset}"
|
|
36
|
+
reply, usage =
|
|
37
|
+
begin
|
|
38
|
+
AI.stream(pending, p.system, @key) { |chunk| print @hl.paint(chunk) }
|
|
39
|
+
rescue Interrupt
|
|
40
|
+
puts "\n#{Ansi.fg(220)}[interrupted — turn discarded]#{Ansi.reset}"
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
puts
|
|
44
|
+
@session.add_turn(text, reply, usage, attachments: attached_data)
|
|
45
|
+
@attachments = []
|
|
46
|
+
AI.print_usage(usage)
|
|
47
|
+
@session.save(p)
|
|
48
|
+
@fresh = false
|
|
49
|
+
Stats.record(usage)
|
|
50
|
+
kick_title_fetch if @session.title.nil? && @session.messages.length >= 2
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def discard(arg)
|
|
54
|
+
arg = arg.to_s.strip
|
|
55
|
+
return discard_session if arg == "all"
|
|
56
|
+
|
|
57
|
+
total = @session.turns
|
|
58
|
+
range =
|
|
59
|
+
if arg.empty?
|
|
60
|
+
[total, total]
|
|
61
|
+
else
|
|
62
|
+
parsed = parse_discard_arg(arg, total)
|
|
63
|
+
return notify(DISCARD_USAGE) unless parsed
|
|
64
|
+
parsed
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
first, last = range
|
|
68
|
+
first = first.clamp(1, total)
|
|
69
|
+
last = last.clamp(1, total)
|
|
70
|
+
if last < first
|
|
71
|
+
notify "no turns in that range"; return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
removed = @session.remove_turns(first, last)
|
|
75
|
+
if @session.empty?
|
|
76
|
+
@session.delete_files
|
|
77
|
+
@fresh = true
|
|
78
|
+
redraw
|
|
79
|
+
notify "\u2212 discarded #{removed} turn#{removed == 1 ? "" : "s"} · session is now empty"
|
|
80
|
+
else
|
|
81
|
+
@session.save(@profile)
|
|
82
|
+
redraw
|
|
83
|
+
notify "\u2212 discarded #{removed} turn#{removed == 1 ? "" : "s"}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def discard_session
|
|
88
|
+
delete_session(announce: "discarded session #{@session.stamp}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def set_title(text)
|
|
92
|
+
if text.empty?
|
|
93
|
+
notify "usage: /title <new title>"
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
@session.title = text
|
|
97
|
+
@session.save(@profile)
|
|
98
|
+
redraw
|
|
99
|
+
notify "title set"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clone_session
|
|
103
|
+
save_history
|
|
104
|
+
src = @session
|
|
105
|
+
dup = Session.new
|
|
106
|
+
dup.messages = Marshal.load(Marshal.dump(src.messages))
|
|
107
|
+
dup.usages = Marshal.load(Marshal.dump(src.usages))
|
|
108
|
+
dup.title = src.title ? "#{src.title} (clone)" : "(clone)"
|
|
109
|
+
dup.save(@profile)
|
|
110
|
+
FileUtils.cp(src.history_file, dup.history_file) if File.exist?(src.history_file)
|
|
111
|
+
@session = dup
|
|
112
|
+
@fresh = false
|
|
113
|
+
load_history
|
|
114
|
+
redraw
|
|
115
|
+
notify "cloned to new session"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def attach(arg)
|
|
119
|
+
if arg.empty?
|
|
120
|
+
notify "usage: /attach <file-or-directory-or-pattern>"
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
matches = Dir.glob(arg).map { |p| File.expand_path(p) }
|
|
124
|
+
if matches.empty?
|
|
125
|
+
expanded = File.expand_path(arg)
|
|
126
|
+
if File.exist?(expanded)
|
|
127
|
+
matches = [expanded]
|
|
128
|
+
else
|
|
129
|
+
notify "no such path: #{arg}"
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
files = []
|
|
134
|
+
matches.each do |p|
|
|
135
|
+
if File.directory?(p)
|
|
136
|
+
files.concat(Dir.children(p).map { |c| File.join(p, c) }.select { |cp| File.file?(cp) })
|
|
137
|
+
elsif File.file?(p)
|
|
138
|
+
files << p
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
added = files.uniq - @attachments
|
|
142
|
+
if added.empty?
|
|
143
|
+
notify "(nothing new to attach)"
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
@attachments.concat(added)
|
|
147
|
+
shorts = added.map { |p| Paths.short(p) }
|
|
148
|
+
notify "+ attached #{shorts.join(", ")}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def detach(arg)
|
|
152
|
+
if arg.empty?
|
|
153
|
+
notify "usage: /detach <path|pattern|directory>"
|
|
154
|
+
return
|
|
155
|
+
end
|
|
156
|
+
expanded = File.expand_path(arg)
|
|
157
|
+
targets = if File.directory?(expanded)
|
|
158
|
+
@attachments.select { |p| File.dirname(p) == expanded }
|
|
159
|
+
else
|
|
160
|
+
exact = @attachments.find { |p| Paths.short(p) == arg }
|
|
161
|
+
if exact
|
|
162
|
+
[exact]
|
|
163
|
+
else
|
|
164
|
+
@attachments.select { |p|
|
|
165
|
+
File.fnmatch(arg, File.basename(p)) ||
|
|
166
|
+
File.fnmatch(arg, Paths.short(p)) ||
|
|
167
|
+
File.fnmatch(arg, p)
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
if targets.empty?
|
|
172
|
+
notify "no matching attachments: #{arg}"
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
targets.each { |t| @attachments.delete(t) }
|
|
176
|
+
shorts = targets.map { |p| Paths.short(p) }
|
|
177
|
+
notify "\u2212 detached #{shorts.join(", ")}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def list_files
|
|
181
|
+
shorts = @attachments.map { |p| Paths.short(p) }
|
|
182
|
+
notify "#{@attachments.length} file(s) attached:\n#{shorts.join("\n")}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def repl
|
|
186
|
+
load_history
|
|
187
|
+
redraw if @session.messages.any?
|
|
188
|
+
loop do
|
|
189
|
+
puts format_attachment_summary if @attachments.any?
|
|
190
|
+
result = begin; @editor.readline(build_prompt); rescue Interrupt; break; end
|
|
191
|
+
case result
|
|
192
|
+
when nil then break
|
|
193
|
+
when :sess_next then switch_session(+1)
|
|
194
|
+
when :sess_prev then switch_session(-1)
|
|
195
|
+
when :redraw then redraw
|
|
196
|
+
when :del_session then delete_session
|
|
197
|
+
when String
|
|
198
|
+
t = result.strip
|
|
199
|
+
next if t.empty?
|
|
200
|
+
break if %w[exit quit bye :q].include?(t)
|
|
201
|
+
if Commands.slash?(t)
|
|
202
|
+
handle_command(t)
|
|
203
|
+
else
|
|
204
|
+
@hl = Highlighter.new
|
|
205
|
+
ask(t); puts
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
flush_title_fetch
|
|
210
|
+
save_history
|
|
211
|
+
footer
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def notify(msg) = puts("#{CMT_STYLE}#{msg}#{Ansi.reset}\n\n")
|
|
215
|
+
|
|
216
|
+
def format_attachment_summary
|
|
217
|
+
return nil if @attachments.empty?
|
|
218
|
+
counts = @attachments.group_by { |p| File.extname(p).delete_prefix(".") }
|
|
219
|
+
parts = counts.sort_by { |ext, _| ext }.map do |ext, fs|
|
|
220
|
+
ext_display = ext.empty? ? "(no ext)" : ext
|
|
221
|
+
"#{fs.length} #{ext_display}"
|
|
222
|
+
end
|
|
223
|
+
suffix = counts.length == 1 ? " file included" : " file(s) included"
|
|
224
|
+
"\e[2;38;5;75m#{parts.join(", ")}#{suffix}\e[0m"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def parse_discard_arg(arg, total)
|
|
230
|
+
case arg
|
|
231
|
+
when /\A(\d+)\z/
|
|
232
|
+
n = $1.to_i
|
|
233
|
+
return nil if n < 1
|
|
234
|
+
n = [n, total].min
|
|
235
|
+
[total - n + 1, total]
|
|
236
|
+
when /\A(\d+)-(\d+)\z/
|
|
237
|
+
a, b = $1.to_i, $2.to_i
|
|
238
|
+
return nil if a < 1 || b < a
|
|
239
|
+
[a, b]
|
|
240
|
+
when /\A(\d+)-\z/
|
|
241
|
+
a = $1.to_i
|
|
242
|
+
return nil if a < 1
|
|
243
|
+
[a, total]
|
|
244
|
+
when /\A-(\d+)\z/
|
|
245
|
+
n = $1.to_i
|
|
246
|
+
return nil if n < 1
|
|
247
|
+
[1, n]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def complete(prefix)
|
|
252
|
+
return nil unless Commands.slash?(prefix)
|
|
253
|
+
if (m = prefix.match(/\A(\/\S+)\s+/))
|
|
254
|
+
name = m[1]
|
|
255
|
+
arg_start = m.end(0)
|
|
256
|
+
arg = prefix[arg_start..]
|
|
257
|
+
cmd = Commands.find(name)
|
|
258
|
+
return nil unless cmd && cmd.available?(self) && cmd.arg_complete
|
|
259
|
+
{ matches: cmd.arg_complete.call(self, arg), start: arg_start }
|
|
260
|
+
else
|
|
261
|
+
{ matches: Commands.complete_name(self, prefix), start: 0 }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def handle_command(input)
|
|
266
|
+
case Commands.dispatch(self, input)
|
|
267
|
+
when :unknown then notify "unknown command: #{input.split.first}"
|
|
268
|
+
when :unavailable then notify "not available right now: #{input.split.first}"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def safe_read(path)
|
|
273
|
+
File.read(path, mode: "rb").force_encoding("UTF-8").scrub
|
|
274
|
+
rescue StandardError => e
|
|
275
|
+
"[could not read #{path}: #{e.message}]"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def clear_screen = print("\e[H\e[2J\e[3J")
|
|
279
|
+
|
|
280
|
+
def center(str)
|
|
281
|
+
cols = IO.console&.winsize&.last || 80
|
|
282
|
+
pad = [(cols - Ansi.width(str)) / 2, 0].max
|
|
283
|
+
(" " * pad) + str
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def print_attachments(attached)
|
|
287
|
+
puts "#{Ansi.dim}#{Ansi.fg(ATT_FG)}📎 attached#{Ansi.reset}"
|
|
288
|
+
attached.each { |a| puts " #{Ansi.fg(ATT_FG)}#{a["path"]}#{Ansi.reset}" }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def delete_session(announce: nil)
|
|
292
|
+
stamp = @session.stamp
|
|
293
|
+
save_history
|
|
294
|
+
all = Session.all
|
|
295
|
+
idx = all.index(stamp)
|
|
296
|
+
@session.delete_files
|
|
297
|
+
|
|
298
|
+
remaining = Session.all
|
|
299
|
+
target = idx && idx > 0 ? remaining[idx - 1] : remaining.first
|
|
300
|
+
if target && (s = Session.load(target))
|
|
301
|
+
@session = s; @fresh = false
|
|
302
|
+
load_history; redraw
|
|
303
|
+
else
|
|
304
|
+
@fresh = true
|
|
305
|
+
@session = Session.new
|
|
306
|
+
@editor.history = []
|
|
307
|
+
clear_screen
|
|
308
|
+
end
|
|
309
|
+
notify(announce || "deleted session #{stamp}")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def switch_session(dir)
|
|
313
|
+
save_history
|
|
314
|
+
all = Session.all
|
|
315
|
+
|
|
316
|
+
if @fresh
|
|
317
|
+
return unless dir < 0 && all.any?
|
|
318
|
+
@fresh = false
|
|
319
|
+
@session = Session.load(all.last) or return
|
|
320
|
+
load_history; redraw
|
|
321
|
+
return
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
return if all.empty?
|
|
325
|
+
cur = all.index(@session.stamp) or return
|
|
326
|
+
target = cur + dir
|
|
327
|
+
if target >= all.length
|
|
328
|
+
@fresh = true
|
|
329
|
+
@session = Session.new
|
|
330
|
+
@editor.history = []
|
|
331
|
+
clear_screen
|
|
332
|
+
return
|
|
333
|
+
end
|
|
334
|
+
target = target.clamp(0, all.length - 1)
|
|
335
|
+
return if target == cur
|
|
336
|
+
@session = Session.load(all[target]) or return
|
|
337
|
+
load_history; redraw
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def redraw
|
|
341
|
+
clear_screen
|
|
342
|
+
draw_title; draw_meta
|
|
343
|
+
puts
|
|
344
|
+
p = @profile
|
|
345
|
+
@session.messages.each do |m|
|
|
346
|
+
if m["role"] == "user"
|
|
347
|
+
puts "#{Ansi.bold}#{Ansi.fg(USER_FG)}#{USER_ICON} #{m["content"]}#{Ansi.reset}"
|
|
348
|
+
print_attachments(m["attachments"]) if m["attachments"]&.any?
|
|
349
|
+
else
|
|
350
|
+
puts "#{Ansi.fg(p.color)}#{p.icon} #{p.name}#{Ansi.reset}"
|
|
351
|
+
print Highlighter.new.paint(m["content"]); puts
|
|
352
|
+
end
|
|
353
|
+
puts
|
|
354
|
+
end
|
|
355
|
+
print_attachments(@attachments.map { |p| { "path" => Paths.short(p) } }) if @attachments.any?
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def draw_title
|
|
359
|
+
return unless @session.title
|
|
360
|
+
e = Ansi.fg(@profile.color)
|
|
361
|
+
inner = " #{@session.title} "
|
|
362
|
+
w = Ansi.width(inner)
|
|
363
|
+
puts center("#{e}\u256d#{"─" * w}\u256e#{Ansi.reset}")
|
|
364
|
+
puts center("#{e}\u2502#{Ansi.reset}#{inner}#{e}\u2502#{Ansi.reset}")
|
|
365
|
+
puts center("#{e}\u2570#{"─" * w}\u256f#{Ansi.reset}")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def draw_meta
|
|
369
|
+
all = Session.all
|
|
370
|
+
idx = all.index(@session.stamp)
|
|
371
|
+
n = idx ? idx + 1 : all.length + 1
|
|
372
|
+
total = [all.length, n].max
|
|
373
|
+
puts center("#{CMT_STYLE}session #{n}/#{total} \u00b7 #{@session.human_time}#{Ansi.reset}")
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def kick_title_fetch
|
|
377
|
+
return if @title_thread&.alive?
|
|
378
|
+
session = @session; profile = @profile; key = @key
|
|
379
|
+
context = session.messages.first(2)
|
|
380
|
+
.map { |m| "#{m["role"]}: #{m["content"]}" }.join("\n\n")
|
|
381
|
+
@title_thread = Thread.new do
|
|
382
|
+
Thread.current.report_on_exception = false
|
|
383
|
+
title = AI.title_query(TITLE_PROMPT, context, key)
|
|
384
|
+
if title && session.title.nil?
|
|
385
|
+
session.title = title
|
|
386
|
+
session.save(profile) if File.exist?(session.json_file)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def flush_title_fetch
|
|
392
|
+
@title_thread&.join(2.second) rescue nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def build_prompt
|
|
396
|
+
p = @profile; c = p.color
|
|
397
|
+
turn = @session.turns + 1
|
|
398
|
+
label = " #{p.icon} #{p.name} \u00b7#{turn} "
|
|
399
|
+
"\e[38;5;#{c}m#{LCAP}\e[48;5;#{c};38;5;0m#{label}\e[0;38;5;#{c}m#{RCAP}\e[0m " \
|
|
400
|
+
"\e[2;38;5;#{c}m#{p.prompt}\e[0m "
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def load_history
|
|
404
|
+
f = @session.history_file
|
|
405
|
+
@editor.history = File.exist?(f) ? File.readlines(f, chomp: true).last(500) : []
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def save_history
|
|
409
|
+
return if @editor.history.empty?
|
|
410
|
+
FileUtils.mkdir_p(META_DIR)
|
|
411
|
+
File.write(@session.history_file, @editor.history.last(1000).join("\n"))
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def footer
|
|
415
|
+
return if @session.messages.empty?
|
|
416
|
+
all = Session.all
|
|
417
|
+
n = (all.index(@session.stamp) || all.length - 1) + 1
|
|
418
|
+
puts "\n#{CMT_STYLE}session ##{n}#{Ansi.reset}"
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
data/lib/commands.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── Slash commands ───────────────────────────────────────────────────
|
|
3
|
+
module Commands
|
|
4
|
+
Command = Struct.new(:name, :desc, :available, :arg_complete, :run, keyword_init: true) do
|
|
5
|
+
def available?(app) = available.nil? || available.call(app)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
REGISTRY = {}
|
|
9
|
+
|
|
10
|
+
def self.register(name, desc:, available: nil, arg_complete: nil, &run)
|
|
11
|
+
REGISTRY[name] = Command.new(
|
|
12
|
+
name: name, desc: desc, available: available, arg_complete: arg_complete, run: run
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.find(name) = REGISTRY[name]
|
|
17
|
+
def self.slash?(input) = input.start_with?("/")
|
|
18
|
+
|
|
19
|
+
def self.available_names(app)
|
|
20
|
+
REGISTRY.values.select { |c| c.available?(app) }.map(&:name).sort
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.complete_name(app, prefix)
|
|
24
|
+
available_names(app).select { |n| n.start_with?(prefix) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.dispatch(app, input)
|
|
28
|
+
name, rest = input.strip.split(/\s+/, 2)
|
|
29
|
+
cmd = REGISTRY[name] or return :unknown
|
|
30
|
+
return :unavailable unless cmd.available?(app)
|
|
31
|
+
cmd.run.call(app, rest || "")
|
|
32
|
+
:ok
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
register("/discard",
|
|
36
|
+
desc: "Remove turn(s): /discard [all|N|A-B|A-|-B]",
|
|
37
|
+
available: ->(app) { !app.session.empty? },
|
|
38
|
+
arg_complete: ->(_app, arg) { %w[all].select { |x| x.start_with?(arg) } }
|
|
39
|
+
) do |app, args|
|
|
40
|
+
app.discard(args)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
register("/title",
|
|
44
|
+
desc: "Set the session title",
|
|
45
|
+
available: ->(app) { !app.session.empty? }
|
|
46
|
+
) do |app, args|
|
|
47
|
+
app.set_title(args.strip)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
register("/clone",
|
|
51
|
+
desc: "Clone this session into a new active one",
|
|
52
|
+
available: ->(app) { !app.session.empty? }
|
|
53
|
+
) do |app, _|
|
|
54
|
+
app.clone_session
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
register("/attach",
|
|
58
|
+
desc: "Attach a file or directory to the next turn",
|
|
59
|
+
arg_complete: ->(_app, arg) { Paths.complete(arg) }
|
|
60
|
+
) do |app, args|
|
|
61
|
+
app.attach(args.strip)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
register("/detach",
|
|
65
|
+
desc: "Remove an attachment",
|
|
66
|
+
available: ->(app) { app.attachments.any? },
|
|
67
|
+
arg_complete: ->(app, arg) {
|
|
68
|
+
app.attachments.map { |p| Paths.short(p) }.select { |s| s.start_with?(arg) }
|
|
69
|
+
}
|
|
70
|
+
) do |app, args|
|
|
71
|
+
app.detach(args.strip)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
register("/files",
|
|
75
|
+
desc: "List currently attached files",
|
|
76
|
+
available: ->(app) { app.attachments.any? }
|
|
77
|
+
) do |app, _|
|
|
78
|
+
app.list_files
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/highlighter.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── Streaming syntax highlighter ─────────────────────────────────────
|
|
3
|
+
class Highlighter
|
|
4
|
+
R = "\e[0m"
|
|
5
|
+
OPENERS = { "(" => ")", "[" => "]", "{" => "}" }.freeze
|
|
6
|
+
CLOSERS = OPENERS.invert.freeze
|
|
7
|
+
PINK = [255, 128, 170].freeze
|
|
8
|
+
PURPLE = [204, 153, 255].freeze
|
|
9
|
+
SALMON = [250, 128, 114].freeze
|
|
10
|
+
BROWN = [196, 168, 130].freeze
|
|
11
|
+
BG = [40, 42, 54].freeze
|
|
12
|
+
TAG_DELIM = "38;2;170;255;220"
|
|
13
|
+
TAG_NAME = "38;2;200;240;215"
|
|
14
|
+
STRING = "38;2;195;255;195"
|
|
15
|
+
NUM_FG = 183
|
|
16
|
+
SYMS = { ":" => 159, ";" => 159, "@" => 229, "^" => 229, "&" => 229,
|
|
17
|
+
"*" => 229, "-" => 180, "+" => 180, "." => 180, "," => 180,
|
|
18
|
+
"!" => 210, "~" => 217, "'" => 217 }.freeze
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@col = 0; @at_line_start = true
|
|
22
|
+
@mode = nil
|
|
23
|
+
@slash = false; @star = false
|
|
24
|
+
@in_string = false; @escape = false
|
|
25
|
+
@brackets = []; @in_word = false
|
|
26
|
+
@tag_state = nil; @tag_buf = +""; @tag_width = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def paint(text)
|
|
30
|
+
out = +""
|
|
31
|
+
text.each_char do |c|
|
|
32
|
+
if c == "\n" then out << newline
|
|
33
|
+
else out << paint_char(c); @col += 1
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
out
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def newline
|
|
42
|
+
if @tag_state == :markup
|
|
43
|
+
@tag_state = nil; @tag_buf = +""; @tag_width = 0
|
|
44
|
+
end
|
|
45
|
+
@mode = nil if @mode == :line_comment
|
|
46
|
+
@slash = @star = false
|
|
47
|
+
@in_word = @in_string = @escape = false
|
|
48
|
+
@col = 0; @at_line_start = true
|
|
49
|
+
"\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def paint_char(c)
|
|
53
|
+
if @mode == :block_comment
|
|
54
|
+
out = "#{CMT_STYLE}#{c}#{R}"
|
|
55
|
+
if @star && c == "/" then @mode = nil; @star = false
|
|
56
|
+
else @star = (c == "*")
|
|
57
|
+
end
|
|
58
|
+
return out
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return "#{CMT_STYLE}#{c}#{R}" if @mode == :line_comment
|
|
62
|
+
|
|
63
|
+
if @in_string
|
|
64
|
+
out = "\e[#{STRING}m#{c}#{R}"
|
|
65
|
+
if @escape then @escape = false
|
|
66
|
+
elsif c == "\\" then @escape = true
|
|
67
|
+
elsif c == '"' then @in_string = false
|
|
68
|
+
end
|
|
69
|
+
return out
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if @slash
|
|
73
|
+
@slash = false
|
|
74
|
+
return case c
|
|
75
|
+
when "/" then @mode = :line_comment; "\b \b#{CMT_STYLE}//#{R}"
|
|
76
|
+
when "*" then @mode = :block_comment; "\b \b#{CMT_STYLE}/*#{R}"
|
|
77
|
+
else "\b \b\e[38;5;229m/#{R}" << paint_char(c)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
return paint_in_tag(c) if @tag_state == :markup
|
|
82
|
+
|
|
83
|
+
was_at_line_start = @at_line_start
|
|
84
|
+
@at_line_start = was_at_line_start && !!c.match?(/\s/)
|
|
85
|
+
|
|
86
|
+
if c == '"'
|
|
87
|
+
@in_string = true; @in_word = false
|
|
88
|
+
return "\e[#{STRING}m\"#{R}"
|
|
89
|
+
end
|
|
90
|
+
if c == "/" then @slash = true; return "/" end
|
|
91
|
+
if c == "#" then @mode = :line_comment; return "#{CMT_STYLE}##{R}" end
|
|
92
|
+
if c == ";" && was_at_line_start
|
|
93
|
+
@mode = :line_comment; return "#{CMT_STYLE};#{R}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if @tag_state == :attrs
|
|
97
|
+
if c == ">" then @tag_state = nil; return "\e[#{TAG_DELIM}m>#{R}" end
|
|
98
|
+
return "\e[#{TAG_DELIM}m/#{R}" if c == "/"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if c == "<" && @tag_state.nil?
|
|
102
|
+
@tag_state = :markup; @tag_buf = +"<"; @tag_width = 1
|
|
103
|
+
@in_word = false
|
|
104
|
+
return "<"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if OPENERS[c]
|
|
108
|
+
clr = bracket_color(@col, @brackets.length)
|
|
109
|
+
@brackets.push(clr); @in_word = false
|
|
110
|
+
return "\e[38;2;#{clr}m#{c}#{R}"
|
|
111
|
+
end
|
|
112
|
+
if CLOSERS[c]
|
|
113
|
+
clr = @brackets.pop || bracket_color(@col, 0)
|
|
114
|
+
@in_word = false
|
|
115
|
+
return "\e[38;2;#{clr}m#{c}#{R}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
return (@in_word = true; c) if c.match?(/[A-Za-z_]/)
|
|
119
|
+
return (@in_word ? c : "\e[38;5;#{NUM_FG}m#{c}#{R}") if c.match?(/\d/)
|
|
120
|
+
|
|
121
|
+
@in_word = false
|
|
122
|
+
(sc = SYMS[c]) ? "\e[38;5;#{sc}m#{c}#{R}" : c
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def paint_in_tag(c)
|
|
126
|
+
if @tag_buf == "<" && c == "/"
|
|
127
|
+
@tag_buf << c; @tag_width += 1; return c
|
|
128
|
+
end
|
|
129
|
+
name_started = @tag_buf.match?(%r{\A</?[A-Za-z]})
|
|
130
|
+
if c.match?(/[A-Za-z]/) || (name_started && c.match?(/[\w\-]/))
|
|
131
|
+
@tag_buf << c; @tag_width += 1; return c
|
|
132
|
+
end
|
|
133
|
+
return abort_tag(c) unless name_started
|
|
134
|
+
|
|
135
|
+
case c
|
|
136
|
+
when ">"
|
|
137
|
+
s = render_tag << "\e[#{TAG_DELIM}m>#{R}"
|
|
138
|
+
@tag_state = nil; @tag_buf = +""; @tag_width = 0; s
|
|
139
|
+
when "/", /\s/
|
|
140
|
+
s = render_tag
|
|
141
|
+
@tag_state = :attrs; @tag_buf = +""; @tag_width = 0
|
|
142
|
+
s << (c == "/" ? "\e[#{TAG_DELIM}m/#{R}" : c)
|
|
143
|
+
else abort_tag(c)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render_tag
|
|
148
|
+
erase = "\b" * @tag_width
|
|
149
|
+
colored = @tag_buf.each_char.map { |c|
|
|
150
|
+
style = %w[< / >].include?(c) ? TAG_DELIM : TAG_NAME
|
|
151
|
+
"\e[#{style}m#{c}#{R}"
|
|
152
|
+
}.join
|
|
153
|
+
+"" << erase << (" " * @tag_width) << erase << colored
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def abort_tag(c)
|
|
157
|
+
@tag_state = nil; @tag_buf = +""; @tag_width = 0
|
|
158
|
+
paint_char(c)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def bracket_color(col, depth)
|
|
162
|
+
t = ([col / 80.0, 1].min + [depth / 5.0, 1].min) / 2.0
|
|
163
|
+
o = [1 - depth * 0.08, 0.55].max
|
|
164
|
+
warm = (depth * 13 + col * 7) % 4 == 0
|
|
165
|
+
a, b = warm ? [SALMON, BROWN] : [PINK, PURPLE]
|
|
166
|
+
a.zip(b, BG).map { |fa, fb, bg|
|
|
167
|
+
(bg + ((fa + (fb - fa) * t).round - bg) * o).round
|
|
168
|
+
}.join(";")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/line_editor.rb
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── Mini line editor with history + Tab completion (with cycling) ────
|
|
3
|
+
class LineEditor
|
|
4
|
+
attr_accessor :history, :completer
|
|
5
|
+
|
|
6
|
+
SIGNALS = {
|
|
7
|
+
"\ej" => :sess_next,
|
|
8
|
+
"\ek" => :sess_prev,
|
|
9
|
+
"\el" => :redraw, "\eL" => :redraw,
|
|
10
|
+
"\e[3;7~" => :del_session, "\e\e[3;5~" => :del_session,
|
|
11
|
+
"\e[3;5~" => :del_session, "\e\e[3^" => :del_session,
|
|
12
|
+
"\e[3^" => :del_session, "\e[3;8~" => :del_session
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize; @history = []; @completer = nil; end
|
|
16
|
+
|
|
17
|
+
def readline(prompt)
|
|
18
|
+
@prompt = prompt; @buf = +""; @cur = 0
|
|
19
|
+
@hpos = @history.length; @stash = nil
|
|
20
|
+
@prev_rows = 0; @prev_cur_row = 0
|
|
21
|
+
@cycle = nil
|
|
22
|
+
render
|
|
23
|
+
$stdin.raw do |io|
|
|
24
|
+
loop do
|
|
25
|
+
k = read_key(io) or next
|
|
26
|
+
if (sig = SIGNALS[k]) then return signal(sig) end
|
|
27
|
+
@cycle = nil unless k == "\t"
|
|
28
|
+
case k
|
|
29
|
+
when "\r", "\n" then return submit
|
|
30
|
+
when "\t" then handle_tab
|
|
31
|
+
when "\x03" then end_render; print "\r\n"; raise Interrupt
|
|
32
|
+
when "\e[3~"
|
|
33
|
+
@buf.slice!(@cur); render if @cur < @buf.length
|
|
34
|
+
when "\x02", "\e[D" then move(-1)
|
|
35
|
+
when "\x06", "\e[C" then move(+1)
|
|
36
|
+
when "\x01", "\e[H", "\eOH", "\e[1~" then @cur = 0; render
|
|
37
|
+
when "\x05", "\e[F", "\eOF", "\e[4~" then @cur = @buf.length; render
|
|
38
|
+
when "\eb" then @cur = word_back; render
|
|
39
|
+
when "\ef" then @cur = word_fwd; render
|
|
40
|
+
when "\x10", "\e[A" then history_step(-1)
|
|
41
|
+
when "\x0e", "\e[B" then history_step(+1)
|
|
42
|
+
when "\x7f", "\b", "\x08"
|
|
43
|
+
(@buf.slice!(@cur - 1); @cur -= 1; render) if @cur > 0
|
|
44
|
+
when "\x04"
|
|
45
|
+
if @buf.empty? then end_render; print "\r\n"; return nil
|
|
46
|
+
elsif @cur < @buf.length then @buf.slice!(@cur); render
|
|
47
|
+
end
|
|
48
|
+
when "\x0b" then @buf.slice!(@cur..); render
|
|
49
|
+
when "\x15" then @buf.slice!(0...@cur); @cur = 0; render
|
|
50
|
+
when "\x17"
|
|
51
|
+
j = word_back; @buf.slice!(j...@cur); @cur = j; render
|
|
52
|
+
when "\ed"
|
|
53
|
+
j = word_fwd; @buf.slice!(@cur...j); render
|
|
54
|
+
when "\eu" then case_word(:upcase); render
|
|
55
|
+
when "\eU" then case_word(:downcase); render
|
|
56
|
+
when "\x14" then transpose; render
|
|
57
|
+
when "\x0c" then print "\e[H\e[2J"; @prev_rows = 0; @prev_cur_row = 0; render
|
|
58
|
+
when "\e" then nil
|
|
59
|
+
else insert(k) if printable?(k)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def signal(s); clear_render; s; end
|
|
68
|
+
|
|
69
|
+
def submit
|
|
70
|
+
end_render
|
|
71
|
+
print "\r\n"
|
|
72
|
+
@history << @buf.dup unless @buf.empty? || @buf == @history.last
|
|
73
|
+
@buf
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def end_render
|
|
77
|
+
down = @prev_rows - 1 - @prev_cur_row
|
|
78
|
+
print "\e[#{down}B" if down > 0
|
|
79
|
+
print "\r"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clear_render
|
|
83
|
+
print "\e[#{@prev_cur_row}A" if @prev_cur_row > 0
|
|
84
|
+
print "\r\e[J"
|
|
85
|
+
@prev_rows = 0; @prev_cur_row = 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def printable?(k) = !k.start_with?("\e") && k.ord >= 32
|
|
89
|
+
|
|
90
|
+
def insert(k); @buf.insert(@cur, k); @cur += k.length; render; end
|
|
91
|
+
|
|
92
|
+
def move(d)
|
|
93
|
+
nc = @cur + d
|
|
94
|
+
(@cur = nc; render) if nc.between?(0, @buf.length)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_tab
|
|
98
|
+
if @cycle
|
|
99
|
+
@cycle[:index] = (@cycle[:index] + 1) % @cycle[:matches].length
|
|
100
|
+
apply_cycle
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return unless @completer
|
|
105
|
+
prefix = @buf[0...@cur]
|
|
106
|
+
result = @completer.call(prefix) or return
|
|
107
|
+
matches, start = result[:matches], result[:start]
|
|
108
|
+
return if matches.nil? || matches.empty?
|
|
109
|
+
|
|
110
|
+
if matches.length == 1
|
|
111
|
+
replace_arg(start, matches[0])
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
common = matches.reduce do |a, b|
|
|
116
|
+
i = 0
|
|
117
|
+
i += 1 while i < a.length && i < b.length && a[i] == b[i]
|
|
118
|
+
a[0...i]
|
|
119
|
+
end
|
|
120
|
+
current = prefix[start..]
|
|
121
|
+
|
|
122
|
+
if common.length > current.length
|
|
123
|
+
replace_arg(start, common)
|
|
124
|
+
else
|
|
125
|
+
@cycle = { matches: matches, start: start, suffix: @buf[@cur..] || "", index: 0 }
|
|
126
|
+
apply_cycle
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def replace_arg(start, text)
|
|
131
|
+
prefix = @buf[0...@cur]
|
|
132
|
+
@buf = prefix[0...start] + text + (@buf[@cur..] || "")
|
|
133
|
+
@cur = start + text.length
|
|
134
|
+
render
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def apply_cycle
|
|
138
|
+
c = @cycle
|
|
139
|
+
match = c[:matches][c[:index]]
|
|
140
|
+
@buf = @buf[0...c[:start]] + match + c[:suffix]
|
|
141
|
+
@cur = c[:start] + match.length
|
|
142
|
+
render
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def read_key(io)
|
|
146
|
+
b = io.getbyte or return nil
|
|
147
|
+
return read_escape(io) if b == 27
|
|
148
|
+
return b.chr if b < 128
|
|
149
|
+
n = b < 224 ? 2 : b < 240 ? 3 : 4
|
|
150
|
+
bytes = [b]
|
|
151
|
+
(n - 1).times { bytes << (io.getbyte || 0) }
|
|
152
|
+
bytes.pack("C*").force_encoding("UTF-8")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def read_escape(io)
|
|
156
|
+
return "\e" unless IO.select([io], nil, nil, 0.05)
|
|
157
|
+
c = io.getbyte or return "\e"
|
|
158
|
+
case c
|
|
159
|
+
when 91 then read_csi(io, +"\e[")
|
|
160
|
+
when 79 then "\eO#{(io.getbyte || 0).chr}"
|
|
161
|
+
when 27
|
|
162
|
+
return "\e\e" unless IO.select([io], nil, nil, 0.05)
|
|
163
|
+
c2 = io.getbyte or return "\e\e"
|
|
164
|
+
c2 == 91 ? read_csi(io, +"\e\e[") : "\e\e#{c2.chr}"
|
|
165
|
+
else "\e#{c.chr}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def read_csi(io, buf)
|
|
170
|
+
12.times do
|
|
171
|
+
nb = io.getbyte or break
|
|
172
|
+
buf << nb.chr
|
|
173
|
+
break if nb.between?(64, 126)
|
|
174
|
+
end
|
|
175
|
+
buf
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def render
|
|
179
|
+
cols = (IO.console&.winsize&.last || 80)
|
|
180
|
+
cols = 1 if cols < 1
|
|
181
|
+
print "\e[#{@prev_cur_row}A" if @prev_cur_row > 0
|
|
182
|
+
print "\r\e[J"
|
|
183
|
+
print "#{@prompt}#{@buf}"
|
|
184
|
+
|
|
185
|
+
prompt_w = Ansi.width(@prompt)
|
|
186
|
+
total_w = prompt_w + Ansi.width(@buf)
|
|
187
|
+
cur_w = prompt_w + Ansi.width(@buf[0...@cur] || "")
|
|
188
|
+
total_rows = total_w.zero? ? 1 : ((total_w - 1) / cols) + 1
|
|
189
|
+
end_row = total_rows - 1
|
|
190
|
+
cur_row = cur_w / cols
|
|
191
|
+
cur_col = cur_w % cols
|
|
192
|
+
if cur_w.positive? && cur_w == total_w && cur_w % cols == 0
|
|
193
|
+
cur_row -= 1; cur_col = cols
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
print "\r"
|
|
197
|
+
up = end_row - cur_row
|
|
198
|
+
print "\e[#{up}A" if up > 0
|
|
199
|
+
print "\e[#{cur_col}C" if cur_col > 0
|
|
200
|
+
@prev_rows = total_rows
|
|
201
|
+
@prev_cur_row = cur_row
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def history_step(d)
|
|
205
|
+
return if @history.empty?
|
|
206
|
+
@stash = @buf.dup if @hpos == @history.length
|
|
207
|
+
np = (@hpos + d).clamp(0, @history.length)
|
|
208
|
+
return if np == @hpos
|
|
209
|
+
@hpos = np
|
|
210
|
+
@buf = (@hpos == @history.length ? (@stash || +"") : @history[@hpos]).dup
|
|
211
|
+
@cur = @buf.length; render
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def word_fwd
|
|
215
|
+
j = @cur
|
|
216
|
+
j += 1 while j < @buf.length && @buf[j] !~ /\w/
|
|
217
|
+
j += 1 while j < @buf.length && @buf[j] =~ /\w/
|
|
218
|
+
j
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def word_back
|
|
222
|
+
j = @cur
|
|
223
|
+
j -= 1 while j > 0 && @buf[j - 1] !~ /\w/
|
|
224
|
+
j -= 1 while j > 0 && @buf[j - 1] =~ /\w/
|
|
225
|
+
j
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def case_word(method)
|
|
229
|
+
j = word_fwd
|
|
230
|
+
return if j == @cur
|
|
231
|
+
@buf[@cur...j] = @buf[@cur...j].public_send(method)
|
|
232
|
+
@cur = j
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def transpose
|
|
236
|
+
return if @buf.length < 2 || @cur == 0
|
|
237
|
+
p = @cur >= @buf.length ? @cur - 1 : @cur
|
|
238
|
+
@buf[p - 1], @buf[p] = @buf[p], @buf[p - 1]
|
|
239
|
+
@cur = [p + 1, @buf.length].min
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
data/lib/paths.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── Path helpers ─────────────────────────────────────────────────────
|
|
3
|
+
module Paths
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def short(abs)
|
|
7
|
+
abs = File.expand_path(abs)
|
|
8
|
+
home = Dir.home
|
|
9
|
+
tilde = (abs == home || abs.start_with?(home + "/")) ? "~" + abs[home.length..] : nil
|
|
10
|
+
relcwd = begin
|
|
11
|
+
r = Pathname.new(abs).relative_path_from(Pathname.pwd).to_s
|
|
12
|
+
r == "." ? "./" : (r.start_with?("..") ? r : "./" + r)
|
|
13
|
+
rescue StandardError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
[abs, tilde, relcwd].compact.min_by(&:length)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def complete(arg)
|
|
20
|
+
if arg.empty?
|
|
21
|
+
dir_text = ""; dir_real = "."; base = ""
|
|
22
|
+
elsif arg.end_with?("/")
|
|
23
|
+
dir_text = arg
|
|
24
|
+
dir_real = arg.start_with?("~") ? File.expand_path(arg) : arg
|
|
25
|
+
base = ""
|
|
26
|
+
else
|
|
27
|
+
slash = arg.rindex("/")
|
|
28
|
+
if slash
|
|
29
|
+
dir_text = arg[0..slash]
|
|
30
|
+
dir_real = dir_text.start_with?("~") ? File.expand_path(dir_text) : dir_text
|
|
31
|
+
base = arg[(slash + 1)..]
|
|
32
|
+
else
|
|
33
|
+
dir_text = ""; dir_real = "."; base = arg
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
return [] unless File.directory?(dir_real)
|
|
37
|
+
Dir.children(dir_real).select { |e| e.start_with?(base) }.sort.map do |e|
|
|
38
|
+
full = File.join(dir_real, e)
|
|
39
|
+
suffix = File.directory?(full) ? "/" : ""
|
|
40
|
+
dir_text + e + suffix
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/profile.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── Profile ──────────────────────────────────────────────────────────
|
|
3
|
+
# Loads user-facing configuration from environment variables.
|
|
4
|
+
module Profile
|
|
5
|
+
module_function
|
|
6
|
+
DEFAULT_SYSTEM = "You are a helpful assistant. Be direct and concise."
|
|
7
|
+
|
|
8
|
+
def color(var, fallback)
|
|
9
|
+
v = ENV[var]
|
|
10
|
+
v && v.match?(/\A\d+\z/) && (0..255).cover?(v.to_i) ? v.to_i : fallback
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load
|
|
14
|
+
let(
|
|
15
|
+
name: ENV["AI_NAME"] || "Assistant",
|
|
16
|
+
icon: ENV["AI_ICON"] || "\u2726",
|
|
17
|
+
prompt: ENV["AI_PROMPT"] || "\u276f",
|
|
18
|
+
color: color("AI_COLOR", 110),
|
|
19
|
+
system: ENV["AI_SYSTEM_PROMPT"] || DEFAULT_SYSTEM
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/session.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── Session ──────────────────────────────────────────────────────────
|
|
3
|
+
class Session
|
|
4
|
+
attr_accessor :stamp, :title, :messages, :usages
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@stamp = Time.now.strftime("%Y%m%d-%H%M%S-%L")
|
|
8
|
+
@title = nil
|
|
9
|
+
@messages = []
|
|
10
|
+
@usages = []
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def turns = @messages.length / 2
|
|
15
|
+
def empty? = @messages.empty?
|
|
16
|
+
|
|
17
|
+
def add_turn(user_content, assistant_content, usage = nil, attachments: [])
|
|
18
|
+
user_msg = { "role" => "user", "content" => user_content }
|
|
19
|
+
user_msg["attachments"] = attachments unless attachments.empty?
|
|
20
|
+
@messages << user_msg
|
|
21
|
+
@messages << { "role" => "assistant", "content" => assistant_content }
|
|
22
|
+
@usages << usage if usage
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def remove_turns(first, last)
|
|
26
|
+
total = turns
|
|
27
|
+
return 0 if total.zero? || first > total || last < 1
|
|
28
|
+
first = first.clamp(1, total)
|
|
29
|
+
last = last.clamp(1, total)
|
|
30
|
+
return 0 if first > last
|
|
31
|
+
count = last - first + 1
|
|
32
|
+
@messages.slice!((first - 1) * 2, count * 2)
|
|
33
|
+
if @usages.length >= first
|
|
34
|
+
take = [count, @usages.length - first + 1].min
|
|
35
|
+
@usages.slice!(first - 1, take)
|
|
36
|
+
end
|
|
37
|
+
@title = nil if @messages.empty?
|
|
38
|
+
count
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def discard_last_turn = remove_turns(turns, turns)
|
|
42
|
+
|
|
43
|
+
def human_time
|
|
44
|
+
Time.strptime(@stamp, "%Y%m%d-%H%M%S-%L").strftime("%b %-d, %Y · %-I:%M %p")
|
|
45
|
+
rescue StandardError
|
|
46
|
+
@stamp
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def history_file = File.join(META_DIR, "#{@stamp}.readline")
|
|
50
|
+
def json_file = File.join(META_DIR, "#{@stamp}.json")
|
|
51
|
+
def md_file = File.join(DATA_DIR, "#{@stamp}.md")
|
|
52
|
+
def files = [json_file, md_file, history_file]
|
|
53
|
+
|
|
54
|
+
def save(profile)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
FileUtils.mkdir_p(META_DIR)
|
|
57
|
+
File.write(json_file, JSON.pretty_generate(
|
|
58
|
+
"timestamp" => @stamp,
|
|
59
|
+
"title" => @title,
|
|
60
|
+
"messages" => @messages,
|
|
61
|
+
"usages" => @usages
|
|
62
|
+
))
|
|
63
|
+
write_markdown(profile)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def delete_files
|
|
68
|
+
files.each { |f| File.delete(f) if File.exist?(f) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.all
|
|
72
|
+
Dir[File.join(META_DIR, "*.json")]
|
|
73
|
+
.reject { |f| File.basename(f) == "stats.json" }
|
|
74
|
+
.map { |f| File.basename(f, ".json") }
|
|
75
|
+
.sort
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.load(stamp)
|
|
79
|
+
data = JSON.parse(File.read(File.join(META_DIR, "#{stamp}.json"))) rescue (return nil)
|
|
80
|
+
return nil unless data.is_a?(Hash)
|
|
81
|
+
s = new; s.stamp = stamp; s.title = data["title"]
|
|
82
|
+
if data["messages"].is_a?(Array)
|
|
83
|
+
s.messages = data["messages"]
|
|
84
|
+
s.usages = data["usages"] || data["usage_log"] || []
|
|
85
|
+
elsif data["branches"].is_a?(Array)
|
|
86
|
+
first = data["branches"].first || {}
|
|
87
|
+
s.messages = first["messages"] || []
|
|
88
|
+
s.usages = first["usages"] || []
|
|
89
|
+
else
|
|
90
|
+
return nil
|
|
91
|
+
end
|
|
92
|
+
s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def write_markdown(profile)
|
|
98
|
+
md = +"# #{@title || @stamp}\n\n**#{profile.icon} #{profile.name}**\n\n"
|
|
99
|
+
@messages.each do |m|
|
|
100
|
+
if m["role"] == "user"
|
|
101
|
+
md << "### You\n\n"
|
|
102
|
+
(m["attachments"] || []).each { |a| md << "📎 `#{a["path"]}`\n" }
|
|
103
|
+
md << "\n#{m["content"]}\n\n"
|
|
104
|
+
else
|
|
105
|
+
md << "### #{profile.icon} #{profile.name}\n\n#{m["content"]}\n\n"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
File.write(md_file, md)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/stats.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module AI
|
|
2
|
+
# ── Stats / cost tracking ────────────────────────────────────────────
|
|
3
|
+
module Stats
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def blank
|
|
7
|
+
{ "interactions" => 0, "tokens_in" => 0, "tokens_out" => 0, "cost" => 0.0 }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def read
|
|
11
|
+
File.exist?(STATS_FILE) ? (JSON.parse(File.read(STATS_FILE)) rescue blank) : blank
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def cost(usage)
|
|
15
|
+
(usage["prompt_cache_hit_tokens"] || 0) * RATES[:hit] +
|
|
16
|
+
(usage["prompt_cache_miss_tokens"] || 0) * RATES[:miss] +
|
|
17
|
+
(usage["completion_tokens"] || 0) * RATES[:out]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record(usage)
|
|
21
|
+
s = read
|
|
22
|
+
s["interactions"] = s["interactions"].to_i + 1
|
|
23
|
+
if usage
|
|
24
|
+
s["tokens_in"] = s["tokens_in"].to_i + (usage["prompt_tokens"] || 0)
|
|
25
|
+
s["tokens_out"] = s["tokens_out"].to_i + (usage["completion_tokens"] || 0)
|
|
26
|
+
s["cost"] = s["cost"].to_f + cost(usage)
|
|
27
|
+
end
|
|
28
|
+
FileUtils.mkdir_p(META_DIR)
|
|
29
|
+
File.write(STATS_FILE, JSON.pretty_generate(s))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: aids
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- emanrdesu
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: emanlib
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
description: Interactive AI assistant with session management, file attachments, syntax
|
|
27
|
+
highlighting, and cost tracking.
|
|
28
|
+
email:
|
|
29
|
+
- janitor@waifu.club
|
|
30
|
+
executables:
|
|
31
|
+
- aids
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- bin/aids
|
|
36
|
+
- lib/aids.rb
|
|
37
|
+
- lib/ansi.rb
|
|
38
|
+
- lib/app.rb
|
|
39
|
+
- lib/commands.rb
|
|
40
|
+
- lib/highlighter.rb
|
|
41
|
+
- lib/line_editor.rb
|
|
42
|
+
- lib/paths.rb
|
|
43
|
+
- lib/profile.rb
|
|
44
|
+
- lib/session.rb
|
|
45
|
+
- lib/stats.rb
|
|
46
|
+
homepage: https://github.com/emanrdesu/aids
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata: {}
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: 3.0.0
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 3.6.9
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: AI DeepSeek client REPL for the terminal
|
|
67
|
+
test_files: []
|