samagotchi 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +80 -1
- data/README.md +13 -2
- data/bin/chi +29 -39
- data/docs/cli.md +135 -73
- data/docs/configuration.md +15 -20
- data/docs/hooks.md +1 -1
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +9 -6
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +16 -11
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +16 -20
- data/lib/samagotchi/config.rb +40 -100
- data/lib/samagotchi/engine.rb +50 -354
- data/lib/samagotchi/kernel_loop.rb +33 -44
- data/lib/samagotchi/live_versions.rb +7 -1
- data/lib/samagotchi/llm/errors.rb +17 -0
- data/lib/samagotchi/llm/http.rb +4 -18
- data/lib/samagotchi/llm/openai_chat.rb +17 -0
- data/lib/samagotchi/model_profile.rb +4 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +20 -2
- data/lib/samagotchi/send_command.rb +84 -6
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_manager.rb +18 -37
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +95 -690
- data/lib/samagotchi/thinking.rb +11 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +3 -0
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/update_command.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +170 -35
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +15 -11
- data/lib/samagotchi/web/public/activity.js +7 -0
- data/lib/samagotchi/web/public/app.js +99 -54
- data/lib/samagotchi/web/public/chat_view.js +5 -1
- data/lib/samagotchi/web/public/index.html +163 -17
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +9 -5
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +74 -19
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- metadata +27 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -4,6 +4,7 @@ require "set"
|
|
|
4
4
|
require_relative "event_renderer"
|
|
5
5
|
require_relative "formatting"
|
|
6
6
|
require_relative "attached_view"
|
|
7
|
+
require_relative "status_row"
|
|
7
8
|
require_relative "input_support"
|
|
8
9
|
require_relative "image_input"
|
|
9
10
|
require_relative "line_reader"
|
|
@@ -45,7 +46,6 @@ module Samagotchi
|
|
|
45
46
|
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
46
47
|
"reminders" => "reminders are set", "starting" => "the worker is still starting"
|
|
47
48
|
}.freeze
|
|
48
|
-
ROLLBACK_HINT = "partial progress kept in context; !rollback restores the pre-turn state"
|
|
49
49
|
# How much of the last answer a join shows.
|
|
50
50
|
JOIN_ANSWER_LINES = 12
|
|
51
51
|
JOIN_ANSWER_CHARS = 1200
|
|
@@ -57,7 +57,8 @@ module Samagotchi
|
|
|
57
57
|
|
|
58
58
|
# @return [String, nil] the model the worker's turns run on, as the
|
|
59
59
|
# last command said
|
|
60
|
-
|
|
60
|
+
# @return [String, nil] the worker's model
|
|
61
|
+
def model_name = @status[:model]
|
|
61
62
|
|
|
62
63
|
# @return [QuestionPrompt, nil] the question waiting for an answer
|
|
63
64
|
attr_reader :question
|
|
@@ -77,8 +78,12 @@ module Samagotchi
|
|
|
77
78
|
# @param delete_session [#call] session id -> deletes it (/exit --delete)
|
|
78
79
|
# @param archive_session [#call] session id -> archives it (/archive);
|
|
79
80
|
# SessionManager.archive_session's result
|
|
81
|
+
# @param wait_at_eof [Boolean] lines come from a pipe or a file (`chi -p
|
|
82
|
+
# X </dev/null`): at their end, detach only once this run's prompts
|
|
83
|
+
# (the -p one too) have had their turns; if one failed, #run says so
|
|
80
84
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
81
|
-
default_input: false,
|
|
85
|
+
default_input: false, wait_at_eof: false,
|
|
86
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
82
87
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
83
88
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
|
|
84
89
|
@client = client
|
|
@@ -106,20 +111,18 @@ module Samagotchi
|
|
|
106
111
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
107
112
|
# the input when their turn fails (a replayed event must not).
|
|
108
113
|
@sent_ids = Set.new
|
|
114
|
+
@wait_at_eof = wait_at_eof
|
|
115
|
+
# Sent and not ended yet (by enqueued_id), the ones merged into the
|
|
116
|
+
# running turn, whether the input has ended, and whether one of this
|
|
117
|
+
# run's prompts failed (wait_at_eof only).
|
|
118
|
+
@open_ids = Set.new
|
|
119
|
+
@merged_ids = Set.new
|
|
120
|
+
@input_ended = false
|
|
121
|
+
@own_failed = false
|
|
109
122
|
# A continue offer is pending: the prompt asks for the answer.
|
|
110
123
|
@continue_offer = nil
|
|
111
|
-
|
|
112
|
-
@
|
|
113
|
-
# The idle status line's data (the REPL's segments).
|
|
114
|
-
@memory_names = []
|
|
115
|
-
# The session's --memory list (shown with the used memories until the
|
|
116
|
-
# first turn records them) and its --mute list, from the join.
|
|
117
|
-
@preloaded_names = []
|
|
118
|
-
@muted_names = []
|
|
119
|
-
# The session that delegated this one, shown as ↳ <short id>.
|
|
120
|
-
@parent_id = nil
|
|
121
|
-
@context_estimate = nil
|
|
122
|
-
@status_rows = nil
|
|
124
|
+
# The status row: the worker's model, ctx, the session's memories.
|
|
125
|
+
@status = StatusRow.new(screen)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
128
|
def running? = @running
|
|
@@ -127,8 +130,10 @@ module Samagotchi
|
|
|
127
130
|
# Follow the session and read input until Ctrl-D, /detach, /exit or
|
|
128
131
|
# the worker goes away. The worker keeps running after a detach; /exit
|
|
129
132
|
# asks it to exit too, and it does unless something still needs it.
|
|
130
|
-
# @return [Symbol] :detached, :closed when the worker went away,
|
|
131
|
-
# :failed when the first command (--model) didn't go through
|
|
133
|
+
# @return [Symbol] :detached, :closed when the worker went away,
|
|
134
|
+
# :failed when the first command (--model) didn't go through, or
|
|
135
|
+
# (wait_at_eof) :turn_failed when one of this run's prompts failed and
|
|
136
|
+
# :unanswered when a question came after the input ended
|
|
132
137
|
# @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
|
|
133
138
|
# Interrupt = Ctrl-C); defaults to Reline
|
|
134
139
|
def run(input: nil)
|
|
@@ -148,8 +153,17 @@ module Samagotchi
|
|
|
148
153
|
case kind
|
|
149
154
|
when :event
|
|
150
155
|
ended = safely_handle(payload)
|
|
151
|
-
return ended if
|
|
152
|
-
|
|
156
|
+
return ended if %i[closed failed unanswered].include?(ended)
|
|
157
|
+
return end_of_input if @input_ended && !own_turns_pending?
|
|
158
|
+
when :line
|
|
159
|
+
if payload.nil? && @wait_at_eof && own_turns_pending?
|
|
160
|
+
# The pipe's end: this run's turns first. An open question
|
|
161
|
+
# has nothing left to answer it.
|
|
162
|
+
return unanswered_question if @question
|
|
163
|
+
|
|
164
|
+
next @input_ended = true
|
|
165
|
+
end
|
|
166
|
+
return end_of_input if submit(payload) == :detach
|
|
153
167
|
when :interrupt then return :detached if interrupt(payload) == :detach
|
|
154
168
|
end
|
|
155
169
|
end
|
|
@@ -176,22 +190,19 @@ module Samagotchi
|
|
|
176
190
|
return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
|
|
177
191
|
when :turn_enqueued then show_enqueued(event)
|
|
178
192
|
when :turn_started then start_turn(event)
|
|
179
|
-
when :turn_completed
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
end_turn("turn failed: #{event[:summary] || "#{event[:message]} (#{event[:error_class]})"}")
|
|
193
|
+
when :turn_completed
|
|
194
|
+
complete_turn(event)
|
|
195
|
+
own_turn_ended(event)
|
|
196
|
+
# The renderer says how it ended (the REPL's words too).
|
|
197
|
+
when :turn_canceled, :turn_failed
|
|
198
|
+
@renderer.call(event)
|
|
199
|
+
end_turn
|
|
200
|
+
own_turn_ended(event)
|
|
188
201
|
when :prompt_restored then restore_prompt(event)
|
|
189
202
|
when :context_status
|
|
190
|
-
@
|
|
191
|
-
refresh_status
|
|
203
|
+
@status.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
192
204
|
when :used_memories_updated
|
|
193
|
-
@
|
|
194
|
-
refresh_status
|
|
205
|
+
@status.update(used_memories: Array(event[:used_memory_names]))
|
|
195
206
|
# Another client's anytime command: its line now, before the cards
|
|
196
207
|
# it shows (its command_ran comes when it's done).
|
|
197
208
|
when :command_queued
|
|
@@ -207,8 +218,12 @@ module Samagotchi
|
|
|
207
218
|
when :continue_resolved then continue_resolved(event)
|
|
208
219
|
# The note comes with the kernel's :pending_input_merged (EventRenderer),
|
|
209
220
|
# after the answer the merge follows.
|
|
210
|
-
when :input_merged then
|
|
211
|
-
when :question_requested
|
|
221
|
+
when :input_merged then merged_own_prompts(event)
|
|
222
|
+
when :question_requested
|
|
223
|
+
# Nothing is left to answer it with.
|
|
224
|
+
return unanswered_question if @input_ended
|
|
225
|
+
|
|
226
|
+
ask(event[:pending_question])
|
|
212
227
|
when :question_answered then question_answered(event)
|
|
213
228
|
when :question_cancelled
|
|
214
229
|
close_question("(question cancelled)") if @question
|
|
@@ -434,9 +449,8 @@ module Samagotchi
|
|
|
434
449
|
return unless event[:model_name]
|
|
435
450
|
|
|
436
451
|
# After the output, so the status row changes with the line saying why.
|
|
437
|
-
|
|
438
|
-
@
|
|
439
|
-
refresh_status
|
|
452
|
+
served = @status[:model] == event[:model_name] ? @status[:served] : nil
|
|
453
|
+
@status.update(model: event[:model_name], default_model: default_model_name, served: served)
|
|
440
454
|
end
|
|
441
455
|
|
|
442
456
|
# Our command the worker couldn't run yet goes back into the prompt, for
|
|
@@ -490,7 +504,7 @@ module Samagotchi
|
|
|
490
504
|
def send_prompt(text)
|
|
491
505
|
persist_recent_history(text)
|
|
492
506
|
images = attach_images(text)
|
|
493
|
-
return if images.nil?
|
|
507
|
+
return own_prompt_failed if images.nil?
|
|
494
508
|
|
|
495
509
|
options = { prompt: text, client_id: @client_id }
|
|
496
510
|
options[:no_interrupt] = true if @no_interrupt
|
|
@@ -499,9 +513,12 @@ module Samagotchi
|
|
|
499
513
|
if reply.status == 202
|
|
500
514
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
501
515
|
@sent_ids << enqueued_id if enqueued_id
|
|
516
|
+
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
502
517
|
return
|
|
503
518
|
end
|
|
504
519
|
|
|
520
|
+
own_prompt_failed
|
|
521
|
+
|
|
505
522
|
detail = reply.json&.fetch("error", nil)
|
|
506
523
|
# Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
|
|
507
524
|
return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
|
|
@@ -509,9 +526,51 @@ module Samagotchi
|
|
|
509
526
|
explained = reply.json&.fetch("detail", nil) if detail == "images_unsupported"
|
|
510
527
|
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
|
|
511
528
|
rescue SystemCallError, IOError => e
|
|
529
|
+
own_prompt_failed
|
|
512
530
|
@screen.commit("could not send the prompt (#{worker_down(e)})")
|
|
513
531
|
end
|
|
514
532
|
|
|
533
|
+
# Whether the input's end must wait: the -p prompt isn't sent yet, or
|
|
534
|
+
# a prompt this run sent hasn't had its turn.
|
|
535
|
+
def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty?
|
|
536
|
+
|
|
537
|
+
# A turn ended: this run's prompt (and ours merged into it) had its turn.
|
|
538
|
+
def own_turn_ended(event)
|
|
539
|
+
return unless @wait_at_eof
|
|
540
|
+
|
|
541
|
+
id = (event[:origin] || {})[:enqueued_id]
|
|
542
|
+
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
543
|
+
@own_failed = true if ours && event[:type] == :turn_failed
|
|
544
|
+
@open_ids.delete(id)
|
|
545
|
+
@open_ids.subtract(@merged_ids)
|
|
546
|
+
@merged_ids.clear
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Ours merged into the running turn: they end with it.
|
|
550
|
+
def merged_own_prompts(event)
|
|
551
|
+
Array(event[:origins]).each do |origin|
|
|
552
|
+
id = origin[:enqueued_id]
|
|
553
|
+
@merged_ids << id if own?(origin[:client_id]) && @open_ids.include?(id)
|
|
554
|
+
end
|
|
555
|
+
nil
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def own_prompt_failed
|
|
559
|
+
@own_failed = true if @wait_at_eof
|
|
560
|
+
nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# The input ended and nothing of ours is left to wait for.
|
|
564
|
+
def end_of_input
|
|
565
|
+
detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
|
|
566
|
+
@own_failed ? :turn_failed : :detached
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def unanswered_question
|
|
570
|
+
detach("A question waits for an answer: chi --attach #{@client.session_id}")
|
|
571
|
+
:unanswered
|
|
572
|
+
end
|
|
573
|
+
|
|
515
574
|
# The prompt's `@path` images, stored in the session's images/ here
|
|
516
575
|
# (this machine, the worker's state dir) and sent as refs: the Bridge
|
|
517
576
|
# never takes a path. nil (after a line saying why) when one can't be.
|
|
@@ -657,11 +716,13 @@ module Samagotchi
|
|
|
657
716
|
def restore_prompt(event)
|
|
658
717
|
origin = event[:origin] || {}
|
|
659
718
|
return unless own?(origin[:client_id]) && @sent_ids.include?(origin[:enqueued_id])
|
|
719
|
+
# Lines from a pipe: nobody to retry it (the REPL's rule).
|
|
720
|
+
return if @wait_at_eof
|
|
660
721
|
|
|
661
722
|
if @reader&.prefill(event[:prompt].to_s)
|
|
662
|
-
@screen.commit("
|
|
723
|
+
@screen.commit(turn_end_hint("prompt restored for retry"))
|
|
663
724
|
else
|
|
664
|
-
@screen.commit("
|
|
725
|
+
@screen.commit(turn_end_hint("the failed prompt is in the input history (↑)"))
|
|
665
726
|
end
|
|
666
727
|
end
|
|
667
728
|
|
|
@@ -903,8 +964,9 @@ module Samagotchi
|
|
|
903
964
|
@joined_mid_turn = @running
|
|
904
965
|
return unless turn
|
|
905
966
|
|
|
906
|
-
|
|
907
|
-
|
|
967
|
+
continues = turn[:prompt].nil?
|
|
968
|
+
@renderer.turn_continues = continues
|
|
969
|
+
unless continues && reminder_origin?(turn[:origin] || {})
|
|
908
970
|
@screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
|
|
909
971
|
end
|
|
910
972
|
Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
@@ -941,9 +1003,8 @@ module Samagotchi
|
|
|
941
1003
|
def start_turn(event)
|
|
942
1004
|
@running = true
|
|
943
1005
|
@joined_mid_turn = false
|
|
944
|
-
@turn_continues = event[:prompt].nil?
|
|
945
1006
|
origin = event[:origin] || {}
|
|
946
|
-
if
|
|
1007
|
+
if event[:prompt].nil?
|
|
947
1008
|
# A continue turn (after the offer's yes) has no prompt to show; a
|
|
948
1009
|
# reminder turn shows its reminders (:reminder_injected).
|
|
949
1010
|
@screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin)
|
|
@@ -961,16 +1022,13 @@ module Samagotchi
|
|
|
961
1022
|
end
|
|
962
1023
|
@renderer.call(event)
|
|
963
1024
|
# The turn's summary carries its last context estimate.
|
|
964
|
-
@
|
|
965
|
-
refresh_status
|
|
1025
|
+
@status.update(context: @view.context_status) if @view.context_status
|
|
966
1026
|
@running = false
|
|
967
1027
|
@joined_mid_turn = false
|
|
968
1028
|
close_question("(the turn ended)") if @question
|
|
969
1029
|
end
|
|
970
1030
|
|
|
971
|
-
def end_turn
|
|
972
|
-
@view.finish_thinking_spinner
|
|
973
|
-
@screen.commit(message)
|
|
1031
|
+
def end_turn
|
|
974
1032
|
@running = false
|
|
975
1033
|
@joined_mid_turn = false
|
|
976
1034
|
close_question("(the turn ended)") if @question
|
|
@@ -981,43 +1039,22 @@ module Samagotchi
|
|
|
981
1039
|
# The joining snapshot's session state: the worker's model and the
|
|
982
1040
|
# memories the session used.
|
|
983
1041
|
def take_session_state(state)
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1042
|
+
fields = { served: nil }
|
|
1043
|
+
fields.merge!(model: state[:model_name], default_model: default_model_name) if state[:model_name]
|
|
1044
|
+
fields[:served] = [state[:served_model], state[:served_model_for]] if state[:served_model]
|
|
1045
|
+
fields[:used_memories] = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
|
|
1046
|
+
fields[:preloaded] = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
|
|
1047
|
+
fields[:muted] = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
|
|
1048
|
+
fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
|
|
991
1049
|
# The last turn's ctx, so it shows before this client's first turn.
|
|
992
|
-
|
|
993
|
-
|
|
1050
|
+
fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
|
|
1051
|
+
@status.update(**fields)
|
|
994
1052
|
end
|
|
995
1053
|
|
|
996
|
-
#
|
|
997
|
-
#
|
|
998
|
-
def
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
served, served_for = @served_model
|
|
1002
|
-
segments = [@model_name ? status_model_text(@model_name, default_model_name, served: served, served_for: served_for) : "",
|
|
1003
|
-
@parent_id ? "↳ #{@parent_id.to_s[0, 8]}" : "",
|
|
1004
|
-
status_context_text(estimate: @context_estimate),
|
|
1005
|
-
status_memory_text(@memory_names | @preloaded_names, MEMORY_STICKY_PREVIEW_LIMIT),
|
|
1006
|
-
status_memory_text(@muted_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")].reject(&:empty?)
|
|
1007
|
-
rows = status_rows(segments, @screen.columns - 1)
|
|
1008
|
-
return if rows == @status_rows
|
|
1009
|
-
|
|
1010
|
-
@status_rows = rows
|
|
1011
|
-
rows.empty? ? @screen.clear_slot(:status) : @screen.set_slot(:status, rows)
|
|
1012
|
-
end
|
|
1013
|
-
|
|
1014
|
-
# What the worker's server said it served for a name (a generation, or
|
|
1015
|
-
# the join's session state); dropped when /model switches.
|
|
1016
|
-
def take_served_model(served, served_for, refresh: true)
|
|
1017
|
-
return unless served
|
|
1018
|
-
|
|
1019
|
-
@served_model = [served, served_for]
|
|
1020
|
-
refresh_status if refresh
|
|
1054
|
+
# What the worker's server said it served for a name (a generation);
|
|
1055
|
+
# dropped when /model switches.
|
|
1056
|
+
def take_served_model(served, served_for)
|
|
1057
|
+
@status.update(served: [served, served_for]) if served
|
|
1021
1058
|
end
|
|
1022
1059
|
|
|
1023
1060
|
# The config's default model, as the worker's /model names it.
|
|
@@ -8,13 +8,12 @@ require_relative "thinking_line"
|
|
|
8
8
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
class TerminalUI
|
|
11
|
-
# EventRenderer's view in attached mode
|
|
12
|
-
# thinking feedback is the activity slot, one row right
|
|
13
|
-
# (spinner frame, then the running tool or the newest
|
|
14
|
-
# model's thinking or text, ThinkingLine),
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# with an open prompt.
|
|
11
|
+
# EventRenderer's view in both TUIs (attached mode and the REPL), drawn
|
|
12
|
+
# on a Screen: the thinking feedback is the activity slot, one row right
|
|
13
|
+
# above the prompt (spinner frame, then the running tool or the newest
|
|
14
|
+
# sentence of the model's thinking or text, ThinkingLine), and every
|
|
15
|
+
# finished line is committed output. On a PlainSurface the activity
|
|
16
|
+
# slot is dropped.
|
|
18
17
|
#
|
|
19
18
|
# The spinner turns with time, not per chunk: while the slot is shown a
|
|
20
19
|
# ticker thread redraws it every TICK_INTERVAL, so a turn that gets no
|
|
@@ -59,6 +58,16 @@ module Samagotchi
|
|
|
59
58
|
@screen.commit(text)
|
|
60
59
|
end
|
|
61
60
|
|
|
61
|
+
# Draw on +surface+ from now on (the REPL swaps in its live region
|
|
62
|
+
# after the view is built, and back when it ends): what the slot shows
|
|
63
|
+
# moves there.
|
|
64
|
+
def surface=(surface)
|
|
65
|
+
@lock.synchronize do
|
|
66
|
+
@screen = surface
|
|
67
|
+
redraw_status if status_text
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
62
71
|
# A new turn: its tally starts over too.
|
|
63
72
|
def reset_turn_feedback
|
|
64
73
|
@lock.synchronize do
|
|
@@ -81,7 +90,7 @@ module Samagotchi
|
|
|
81
90
|
|
|
82
91
|
def generation_feedback_retrying(event)
|
|
83
92
|
@lock.synchronize do
|
|
84
|
-
@retry =
|
|
93
|
+
@retry = retry_text(event)
|
|
85
94
|
# The retry streams from the start: wait for its first token again.
|
|
86
95
|
@waiting_since = @clock.call if @thinking
|
|
87
96
|
redraw_status
|
|
@@ -194,9 +203,6 @@ module Samagotchi
|
|
|
194
203
|
@context_status = status if status
|
|
195
204
|
end
|
|
196
205
|
|
|
197
|
-
# The REPL's sticky memories line has no source in attached mode.
|
|
198
|
-
def emit_active_memories_line; end
|
|
199
|
-
|
|
200
206
|
private
|
|
201
207
|
|
|
202
208
|
def reset_feedback_state
|
|
@@ -207,6 +213,15 @@ module Samagotchi
|
|
|
207
213
|
@waiting_since = nil
|
|
208
214
|
end
|
|
209
215
|
|
|
216
|
+
# "retrying (1/4 in 0.5s): Errno::ECONNREFUSED": the attempt of all
|
|
217
|
+
# there will be, the wait before it, and what failed.
|
|
218
|
+
def retry_text(event)
|
|
219
|
+
return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
|
|
220
|
+
|
|
221
|
+
text = "retrying (#{event[:attempt]}/#{event[:max_retries].to_i + 1} in #{format("%.1f", event[:next_delay].to_f)}s)"
|
|
222
|
+
event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
|
|
223
|
+
end
|
|
224
|
+
|
|
210
225
|
def tally_key(event)
|
|
211
226
|
[event[:iteration].to_i, event[:call_index].to_i]
|
|
212
227
|
end
|
|
@@ -246,7 +261,7 @@ module Samagotchi
|
|
|
246
261
|
def status_text
|
|
247
262
|
now = @clock.call
|
|
248
263
|
frame = FRAMES[((now - @origin) / FRAME_INTERVAL).floor % FRAMES.length]
|
|
249
|
-
return "#{frame} #{@retry}" if @retry
|
|
264
|
+
return "#{frame} #{paint(@retry, 90)}" if @retry
|
|
250
265
|
return "#{frame} #{@tool}…" if @tool
|
|
251
266
|
return "#{frame} #{@init_tasks.values.join(" · ")}…" if !@thinking && @init_tasks.any?
|
|
252
267
|
return nil unless @thinking
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "set"
|
|
4
|
+
require_relative "formatting"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class TerminalUI
|
|
7
8
|
# Renders one UI's view of Engine turn events: the thinking spinner while
|
|
8
9
|
# the model generates, a `tool>` line per completed tool call, and the
|
|
9
10
|
# answer (with any tool activity not already shown) from :turn_completed's
|
|
10
|
-
# turn_summary. It is the `on_event:` sink the REPL
|
|
11
|
+
# turn_summary, and how a turn ended. It is the `on_event:` sink the REPL
|
|
12
|
+
# hands Engine#run_turn, and renders what attached mode gets over the
|
|
13
|
+
# Bridge.
|
|
11
14
|
#
|
|
12
|
-
# Drawing is delegated to a +view+ (
|
|
13
|
-
#
|
|
15
|
+
# Drawing is delegated to a +view+ (an AttachedView in both TUIs). The
|
|
16
|
+
# renderer keeps
|
|
14
17
|
# only per-turn bookkeeping that must come from the events themselves:
|
|
15
18
|
# which tool lines were already streamed, and when each tool call started.
|
|
16
19
|
# Events that came over the Bridge as JSON (string keys) are symbolized first.
|
|
@@ -22,8 +25,14 @@ module Samagotchi
|
|
|
22
25
|
@tool_started_at = {}
|
|
23
26
|
# Card ids shown so far (across turns): one shown again is updated.
|
|
24
27
|
@card_ids = Set.new
|
|
28
|
+
@turn_continues = false
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
# Whether the running turn continues an earlier one (a continue or a
|
|
32
|
+
# reminder turn): set from :turn_started, or by a UI that joined the
|
|
33
|
+
# turn mid-way. A canceled one gets no rollback hint.
|
|
34
|
+
attr_writer :turn_continues
|
|
35
|
+
|
|
27
36
|
# Deep-convert a string-keyed event (JSON from the Bridge) to the local
|
|
28
37
|
# shape: symbol keys and a symbol :type. Other values stay as they are.
|
|
29
38
|
def self.symbolize(event)
|
|
@@ -45,6 +54,7 @@ module Samagotchi
|
|
|
45
54
|
case event[:type]
|
|
46
55
|
when :turn_started
|
|
47
56
|
begin_turn
|
|
57
|
+
@turn_continues = event[:continue] ? true : event[:prompt].nil?
|
|
48
58
|
Array(event[:images]).each { |ref| @view.print_line(@view.format_image_line(ref)) }
|
|
49
59
|
when :generation_started
|
|
50
60
|
@view.generation_feedback_started(event)
|
|
@@ -55,8 +65,6 @@ module Samagotchi
|
|
|
55
65
|
when :tool_call_started
|
|
56
66
|
@tool_started_at[tool_call_key(event)] = @clock.call
|
|
57
67
|
@view.tool_call_feedback_started(event)
|
|
58
|
-
when :used_memories_updated
|
|
59
|
-
@view.used_memories_updated(event) if @view.respond_to?(:used_memories_updated)
|
|
60
68
|
when :tool_call_completed
|
|
61
69
|
@view.clear_generation_retry
|
|
62
70
|
@view.tool_call_feedback_completed(event)
|
|
@@ -71,6 +79,15 @@ module Samagotchi
|
|
|
71
79
|
@view.print_line(@view.format_empty_retry_line(event))
|
|
72
80
|
when :turn_completed
|
|
73
81
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
82
|
+
when :turn_canceled
|
|
83
|
+
@view.finish_thinking_spinner
|
|
84
|
+
@view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms]))
|
|
85
|
+
@view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) unless @turn_continues
|
|
86
|
+
when :turn_failed
|
|
87
|
+
# A provider error's one-line summary, else the message (an image
|
|
88
|
+
# that couldn't be used).
|
|
89
|
+
@view.finish_thinking_spinner
|
|
90
|
+
@view.print_line(@view.turn_failed_line(event[:summary] || event[:message], event[:duration_ms]))
|
|
74
91
|
when :guardrail_warning
|
|
75
92
|
@view.print_line(self.class.load_warning_line(event))
|
|
76
93
|
when :hook_notice
|
|
@@ -138,7 +155,7 @@ module Samagotchi
|
|
|
138
155
|
end
|
|
139
156
|
|
|
140
157
|
# Render a finished turn: tool activity the stream did not already show,
|
|
141
|
-
# the
|
|
158
|
+
# the answer, and the iteration-limit notice.
|
|
142
159
|
# @param summary [Hash] Engine#turn_summary
|
|
143
160
|
def render_turn_summary(summary)
|
|
144
161
|
@view.finish_thinking_spinner
|
|
@@ -148,7 +165,6 @@ module Samagotchi
|
|
|
148
165
|
|
|
149
166
|
@view.print_line(@view.format_tool_activity_line(activity))
|
|
150
167
|
end
|
|
151
|
-
@view.emit_active_memories_line
|
|
152
168
|
@view.print_line(summary[:output])
|
|
153
169
|
@view.print_line("iteration limit reached") if summary[:resumable]
|
|
154
170
|
end
|
|
@@ -135,6 +135,33 @@ module Samagotchi
|
|
|
135
135
|
text.strip.empty? ? [] : lines
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# ── Turn ends: one set of words for the REPL, attached mode and the
|
|
139
|
+
# web (timing.js cancelLineText, format.js failedTurnText) ──────────
|
|
140
|
+
|
|
141
|
+
# A cancel's reason as the UIs name it (anything else as it is).
|
|
142
|
+
CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
|
|
143
|
+
# Under a canceled prompt turn (a canceled continue is back where it
|
|
144
|
+
# started): its partial progress stays in the conversation.
|
|
145
|
+
ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
|
|
146
|
+
|
|
147
|
+
# "✕ turn canceled (Ctrl-C) · 3.1s"
|
|
148
|
+
def turn_canceled_line(reason, duration_ms)
|
|
149
|
+
label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
|
|
150
|
+
"#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# "✕ turn failed: <summary> · 2.0s"
|
|
154
|
+
def turn_failed_line(detail, duration_ms)
|
|
155
|
+
"#{paint("✕", 31)} turn failed: #{detail}#{turn_duration_suffix(duration_ms)}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A dim, indented line under a turn's end: what became of its prompt.
|
|
159
|
+
def turn_end_hint(text) = " #{paint(text, 90)}"
|
|
160
|
+
|
|
161
|
+
def turn_duration_suffix(duration_ms)
|
|
162
|
+
duration_ms.nil? ? "" : " · #{format_elapsed_duration(duration_ms)}"
|
|
163
|
+
end
|
|
164
|
+
|
|
138
165
|
private
|
|
139
166
|
|
|
140
167
|
# The recap shown on return: dim, one "recap>" block, noting how many
|
|
@@ -174,9 +201,11 @@ module Samagotchi
|
|
|
174
201
|
|
|
175
202
|
# ── The status line (the REPL's, and attached mode's idle one) ────────
|
|
176
203
|
|
|
177
|
-
#
|
|
204
|
+
# status.line: off hides it.
|
|
205
|
+
STATUS_LINE_OFF = "off"
|
|
206
|
+
|
|
178
207
|
def status_line_enabled?
|
|
179
|
-
value =
|
|
208
|
+
value = Samagotchi::Config.get("status.line").to_s.strip.downcase
|
|
180
209
|
!(value.empty? || value == STATUS_LINE_OFF || value == "0" || value == "false")
|
|
181
210
|
end
|
|
182
211
|
|
|
@@ -204,10 +233,8 @@ module Samagotchi
|
|
|
204
233
|
"model=#{model} (default: #{default_model})"
|
|
205
234
|
end
|
|
206
235
|
|
|
207
|
-
# @param server [Hash, nil] the server's own numbers ({ctx_pct:, prompt_tokens:, ...})
|
|
208
236
|
# @param estimate [Hash, nil] the kernel's estimate ({est_pct:, bucket:})
|
|
209
|
-
def status_context_text(
|
|
210
|
-
return format_server_context_segment(server) if server.is_a?(Hash)
|
|
237
|
+
def status_context_text(estimate: nil)
|
|
211
238
|
return "" unless estimate.is_a?(Hash)
|
|
212
239
|
|
|
213
240
|
pct = format("%.1f", estimate[:est_pct].to_f)
|
|
@@ -217,23 +244,6 @@ module Samagotchi
|
|
|
217
244
|
"ctx=#{pct}% (#{bucket})"
|
|
218
245
|
end
|
|
219
246
|
|
|
220
|
-
def format_server_context_segment(status)
|
|
221
|
-
pct = status[:ctx_pct]
|
|
222
|
-
base = pct ? "ctx=#{format('%.1f', pct)}%" : "ctx=srv"
|
|
223
|
-
|
|
224
|
-
tokens = []
|
|
225
|
-
prompt_tokens = status[:prompt_tokens]
|
|
226
|
-
completion_tokens = status[:completion_tokens]
|
|
227
|
-
total_tokens = status[:total_tokens]
|
|
228
|
-
tokens << "p=#{prompt_tokens}" if prompt_tokens
|
|
229
|
-
tokens << "c=#{completion_tokens}" if completion_tokens
|
|
230
|
-
tokens << "t=#{total_tokens}" if total_tokens
|
|
231
|
-
|
|
232
|
-
return base if tokens.empty?
|
|
233
|
-
|
|
234
|
-
"#{base} (#{tokens.join(' ')})"
|
|
235
|
-
end
|
|
236
|
-
|
|
237
247
|
# @param label [String] "mem" for the used memories, "muted" for the
|
|
238
248
|
# session's --mute list
|
|
239
249
|
def status_memory_text(names, limit, label: "mem")
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "reline"
|
|
6
6
|
|
|
7
|
+
require_relative "../config"
|
|
7
8
|
require_relative "../memory_paths"
|
|
8
9
|
require_relative "../tools/memory"
|
|
9
10
|
require_relative "../session_commands"
|
|
@@ -19,12 +20,10 @@ module Samagotchi
|
|
|
19
20
|
# Included for private use; it keeps state in @next_input_prefill and
|
|
20
21
|
# reads @no_default_input.
|
|
21
22
|
module InputSupport
|
|
22
|
-
PROMPT_HISTORY_ENV = "SAMAGOTCHI_HISTORY_FILE"
|
|
23
23
|
XDG_STATE_HOME_ENV = "XDG_STATE_HOME"
|
|
24
24
|
PROMPT_HISTORY_FILE = "history.json"
|
|
25
25
|
PROMPT_HISTORY_STATE_DIR = "samagotchi"
|
|
26
26
|
PROMPT_HISTORY_LIMIT = 20
|
|
27
|
-
DEFAULT_INPUT_ENV = "SAMAGOTCHI_DEFAULT_INPUT"
|
|
28
27
|
AT_PATH_COMPLETION_PREFIX = "@"
|
|
29
28
|
MEMORY_COMPLETION_PREFIX = "#"
|
|
30
29
|
AT_PATH_COMPLETION_MAX_CANDIDATES = 200
|
|
@@ -205,7 +204,7 @@ module Samagotchi
|
|
|
205
204
|
end
|
|
206
205
|
|
|
207
206
|
def history_file_path
|
|
208
|
-
explicit =
|
|
207
|
+
explicit = Samagotchi::Config.get("history.file").to_s.strip
|
|
209
208
|
return explicit unless explicit.empty?
|
|
210
209
|
|
|
211
210
|
File.join(xdg_state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
|
|
@@ -273,7 +272,7 @@ module Samagotchi
|
|
|
273
272
|
def default_input_text
|
|
274
273
|
return nil unless default_input_wanted?
|
|
275
274
|
|
|
276
|
-
default =
|
|
275
|
+
default = Samagotchi::Config.get("default.input")
|
|
277
276
|
return nil if default.nil? || default.strip.empty?
|
|
278
277
|
|
|
279
278
|
default
|