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 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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $stdout.sync = true
4
+ require "aids"
5
+ AI.run
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
@@ -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
@@ -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: []