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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "monitor"
|
|
4
3
|
require "json"
|
|
5
4
|
require "fileutils"
|
|
6
5
|
require "io/console"
|
|
@@ -13,8 +12,6 @@ require_relative "context_note"
|
|
|
13
12
|
require_relative "cancellation_controller"
|
|
14
13
|
require_relative "llm/errors"
|
|
15
14
|
require_relative "host_registry"
|
|
16
|
-
require_relative "context_usage"
|
|
17
|
-
require_relative "context_window"
|
|
18
15
|
require_relative "kernel_loop"
|
|
19
16
|
require_relative "session"
|
|
20
17
|
require_relative "owner_lock"
|
|
@@ -22,17 +19,17 @@ require_relative "engine"
|
|
|
22
19
|
require_relative "tools/memory"
|
|
23
20
|
require_relative "output_formatter"
|
|
24
21
|
require_relative "turn_flow"
|
|
25
|
-
require_relative "turn_tally"
|
|
26
22
|
require_relative "session_commands"
|
|
23
|
+
require_relative "terminal_ui/attached_view"
|
|
27
24
|
require_relative "terminal_ui/event_renderer"
|
|
28
25
|
require_relative "terminal_ui/formatting"
|
|
29
26
|
require_relative "terminal_ui/input_support"
|
|
30
27
|
require_relative "terminal_ui/image_input"
|
|
31
|
-
require_relative "terminal_ui/
|
|
28
|
+
require_relative "terminal_ui/plain_surface"
|
|
32
29
|
require_relative "terminal_ui/live_region"
|
|
33
30
|
require_relative "terminal_ui/question_prompt"
|
|
34
31
|
require_relative "terminal_ui/repl_input"
|
|
35
|
-
require_relative "terminal_ui/
|
|
32
|
+
require_relative "terminal_ui/status_row"
|
|
36
33
|
require_relative "log"
|
|
37
34
|
|
|
38
35
|
module Samagotchi
|
|
@@ -51,35 +48,6 @@ module Samagotchi
|
|
|
51
48
|
# The prompt while a question waits for its answer (its choices are in
|
|
52
49
|
# the notes slot).
|
|
53
50
|
QUESTION_PROMPT = "? "
|
|
54
|
-
THINKING_UI_ENV = "SAMAGOTCHI_THINKING_UI"
|
|
55
|
-
THINKING_UI_SPINNER = "spinner"
|
|
56
|
-
THINKING_UI_OFF = "off"
|
|
57
|
-
THINKING_SPINNER_FRAMES = ["|", "/", "-", "\\"].freeze
|
|
58
|
-
MEMORY_SPINNER_COLOR = "38;5;208"
|
|
59
|
-
TOOL_SPINNER_COLOR = 32
|
|
60
|
-
NETWORK_RETRY_SPINNER_COLOR = 31
|
|
61
|
-
MEMORY_SPINNER_PREVIEW_LIMIT = 3
|
|
62
|
-
MEMORY_STICKY_PREVIEW_LIMIT = 8
|
|
63
|
-
THINKING_PREVIEW_WIDTH = 120
|
|
64
|
-
# Columns a sentence needs on the spinner row; with less, it reads "thinking...".
|
|
65
|
-
MIN_SENTENCE_ROOM = 12
|
|
66
|
-
THINKING_TOOL_PREVIEW_LIMIT = 56
|
|
67
|
-
THINKING_RENDER_MIN_INTERVAL = 0.08
|
|
68
|
-
# The spinner also turns with time: a ticker redraws it when no chunk
|
|
69
|
-
# did for this long, and after THINKING_WAIT_NOTICE_AFTER seconds with no
|
|
70
|
-
# chunk it says how long the first token has taken.
|
|
71
|
-
THINKING_TICK_INTERVAL = 0.25
|
|
72
|
-
THINKING_WAIT_NOTICE_AFTER = 2.0
|
|
73
|
-
THINKING_RENDER_INTERVAL_ENV = "SAMAGOTCHI_THINKING_RENDER_INTERVAL"
|
|
74
|
-
STATUS_LINE_ENV = "SAMAGOTCHI_STATUS_LINE"
|
|
75
|
-
STATUS_LINE_ON = "on"
|
|
76
|
-
STATUS_LINE_OFF = "off"
|
|
77
|
-
STATUS_WIDTH_MODE_ENV = "SAMAGOTCHI_STATUS_WIDTH_MODE"
|
|
78
|
-
STATUS_WIDTH_MODE_TERMINAL_CAP = "terminal_cap"
|
|
79
|
-
STATUS_WIDTH_MODE_FIXED = "fixed"
|
|
80
|
-
STATUS_FIXED_WIDTH_ENV = "SAMAGOTCHI_STATUS_FIXED_WIDTH"
|
|
81
|
-
STATUS_MAX_WIDTH_ENV = "SAMAGOTCHI_STATUS_MAX_WIDTH"
|
|
82
|
-
STATUS_MAX_WIDTH_DEFAULT = 160
|
|
83
51
|
REMINDER_PENDING_POLL_INTERVAL = 0.5
|
|
84
52
|
# What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
|
|
85
53
|
COMMAND_BUSY = "busy: wait for the turn to end"
|
|
@@ -101,14 +69,9 @@ module Samagotchi
|
|
|
101
69
|
# @param scratch [Boolean] `chi scratch`: a new session that is deleted
|
|
102
70
|
# however the REPL ends, saves no memories and writes no recap
|
|
103
71
|
def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
104
|
-
spinner_tick_interval:
|
|
72
|
+
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
|
|
105
73
|
@mode = mode.to_sym
|
|
106
74
|
@scratch = scratch
|
|
107
|
-
# nil: no ticker thread (specs that compare exact frames).
|
|
108
|
-
@spinner_tick_interval = spinner_tick_interval
|
|
109
|
-
@spinner_lock = Monitor.new
|
|
110
|
-
@turn_tally = TurnTally.new
|
|
111
|
-
@thinking_line = ThinkingLine.new(clock: -> { monotonic_time })
|
|
112
75
|
@prompt = prompt
|
|
113
76
|
@default_model_name = ModelProfile.required_model_name(nil)
|
|
114
77
|
aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
|
|
@@ -162,9 +125,17 @@ module Samagotchi
|
|
|
162
125
|
# Every terminal write goes through the surface. The REPL swaps in a
|
|
163
126
|
# live region when the terminal can show one (#assist_loop), unless it
|
|
164
127
|
# was given a surface to draw on.
|
|
165
|
-
@surface = surface ||
|
|
128
|
+
@surface = surface || PlainSurface.new
|
|
166
129
|
@surface_given = !surface.nil?
|
|
167
|
-
|
|
130
|
+
# The turn view is the attached TUI's, and so is the status row; both
|
|
131
|
+
# move to the live region with the surface (#use_surface). A
|
|
132
|
+
# spinner_tick_interval of nil: no ticker thread, and a spinner_clock
|
|
133
|
+
# fixes the frames (specs that compare exact frames).
|
|
134
|
+
view_options = { tick_interval: spinner_tick_interval }
|
|
135
|
+
view_options[:clock] = spinner_clock if spinner_clock
|
|
136
|
+
@view = AttachedView.new(@surface, **view_options)
|
|
137
|
+
@status_row = StatusRow.new(@surface)
|
|
138
|
+
@renderer = EventRenderer.new(@view)
|
|
168
139
|
@render_event = ->(event) { handle_stream_event(event) }
|
|
169
140
|
@engine = Engine.new(
|
|
170
141
|
mode: :assist,
|
|
@@ -232,6 +203,8 @@ module Samagotchi
|
|
|
232
203
|
# (auto-executing it, then saving), then either exits when there is no
|
|
233
204
|
# follow-up REPL (--non-interactive) or drops into the REPL carrying the
|
|
234
205
|
# post-turn conversation.
|
|
206
|
+
# @return [Symbol, nil] :turn_failed when lines came from a pipe and a
|
|
207
|
+
# turn failed (bin/chi exits 1)
|
|
235
208
|
def run
|
|
236
209
|
unless @mode == :assist
|
|
237
210
|
raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
|
|
@@ -293,6 +266,8 @@ module Samagotchi
|
|
|
293
266
|
else
|
|
294
267
|
keep_after_exit(session)
|
|
295
268
|
end
|
|
269
|
+
# Lines from a pipe (`chi -p X </dev/null`): a failed turn fails the run.
|
|
270
|
+
:turn_failed if @piped_turn_failed
|
|
296
271
|
ensure
|
|
297
272
|
# However it ends (the early returns too; the Engine was built in
|
|
298
273
|
# #initialize): the anytime commands finish, the plugins' services
|
|
@@ -487,15 +462,24 @@ module Samagotchi
|
|
|
487
462
|
return yield unless screen
|
|
488
463
|
|
|
489
464
|
plain = @surface
|
|
490
|
-
|
|
465
|
+
use_surface(screen)
|
|
491
466
|
begin
|
|
492
467
|
yield
|
|
493
468
|
ensure
|
|
494
|
-
|
|
469
|
+
# Nothing may draw on the screen once it is closed: the view moves
|
|
470
|
+
# off it, then its ticker ends.
|
|
471
|
+
use_surface(plain)
|
|
472
|
+
@view.stop
|
|
495
473
|
LiveRegion.close(screen)
|
|
496
474
|
end
|
|
497
475
|
end
|
|
498
476
|
|
|
477
|
+
def use_surface(surface)
|
|
478
|
+
@surface = surface
|
|
479
|
+
@view.surface = surface
|
|
480
|
+
@status_row.surface = surface
|
|
481
|
+
end
|
|
482
|
+
|
|
499
483
|
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
500
484
|
# threaded in here (so --prompt / --resume share one code path). The
|
|
501
485
|
# session's conversation is the single working copy: turns go through
|
|
@@ -582,8 +566,9 @@ module Samagotchi
|
|
|
582
566
|
@turn_flow.before_continue_turn
|
|
583
567
|
begin
|
|
584
568
|
result = run_engine_turn(session, nil, continue: true)
|
|
585
|
-
rescue LLM::ProviderError
|
|
586
|
-
|
|
569
|
+
rescue LLM::ProviderError
|
|
570
|
+
# The renderer showed the failure (:turn_failed).
|
|
571
|
+
@surface.commit(turn_end_hint("continue prompt preserved"))
|
|
587
572
|
return
|
|
588
573
|
end
|
|
589
574
|
finish_turn(session, result, continue: true)
|
|
@@ -615,10 +600,8 @@ module Samagotchi
|
|
|
615
600
|
# the prompt, and renders through @renderer via on_event.
|
|
616
601
|
result = run_engine_turn(session, input, images: ImageInput.extract(input))
|
|
617
602
|
rescue LLM::ProviderError, ImageStore::Error => e
|
|
618
|
-
# Engine closed the turn (:turn_failed)
|
|
619
|
-
# Retries were already tallied via generation_retrying events.
|
|
603
|
+
# Engine closed the turn (:turn_failed), which the renderer showed.
|
|
620
604
|
# An @path image that can't be used fails the turn the same way.
|
|
621
|
-
emit_interactive_turn_duration(canceled: false)
|
|
622
605
|
summary = e.respond_to?(:summary) ? e.summary : e.message
|
|
623
606
|
# The model reads why on its next turn. An image that couldn't be
|
|
624
607
|
# used, or that the host refused, never reached it: no note for that
|
|
@@ -627,7 +610,14 @@ module Samagotchi
|
|
|
627
610
|
@turn_flow.prompt_turn_failed(note: note)
|
|
628
611
|
# The Engine saved the failed turn; the file follows the rollback.
|
|
629
612
|
save_session(session) if note
|
|
630
|
-
|
|
613
|
+
if piped_input?
|
|
614
|
+
# Nobody can edit a restored prompt, and Reline would hand it back
|
|
615
|
+
# as the next line: the turn would run again and again. It failed;
|
|
616
|
+
# the exit status says so.
|
|
617
|
+
@piped_turn_failed = true
|
|
618
|
+
else
|
|
619
|
+
@surface.commit(turn_end_hint(restore_prompt_for_retry(input)))
|
|
620
|
+
end
|
|
631
621
|
return
|
|
632
622
|
end
|
|
633
623
|
finish_turn(session, result, continue: false)
|
|
@@ -645,10 +635,9 @@ module Samagotchi
|
|
|
645
635
|
# and the partial assistant reply (marked [interrupted]) into
|
|
646
636
|
# result.conversation, so progress is preserved by default — the
|
|
647
637
|
# user's next message continues from it. !rollback restores the
|
|
648
|
-
# pre-turn checkpoint for an explicit full discard
|
|
649
|
-
|
|
638
|
+
# pre-turn checkpoint for an explicit full discard (the renderer's
|
|
639
|
+
# hint under :turn_canceled says so).
|
|
650
640
|
save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
|
|
651
|
-
@surface.commit("\nmodel> turn cancelled; partial progress kept in context; use !rollback immediately after cancellation to restore the pre-turn checkpoint")
|
|
652
641
|
return
|
|
653
642
|
end
|
|
654
643
|
|
|
@@ -681,176 +670,11 @@ module Samagotchi
|
|
|
681
670
|
@default_model_name = @commands.default_model
|
|
682
671
|
end
|
|
683
672
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
if @host_registry && @host_registry.entries.size > 1
|
|
687
|
-
active = @host_registry.resolve(@effective_model_name).entry rescue nil
|
|
688
|
-
if active
|
|
689
|
-
host = active.host
|
|
690
|
-
port = active.port
|
|
691
|
-
total = @host_registry.entries.size
|
|
692
|
-
return "" if ["localhost", "127.0.0.1"].include?(host) && total == 1
|
|
693
|
-
# When multiple hosts, always show active + count
|
|
694
|
-
return "server=#{host}:#{port} (#{total} hosts)"
|
|
695
|
-
end
|
|
696
|
-
end
|
|
697
|
-
host = Samagotchi::Config.get("server.host")
|
|
698
|
-
return "" if ["localhost", "127.0.0.1"].include?(host)
|
|
699
|
-
|
|
700
|
-
port = Samagotchi::Config.get("server.port")
|
|
701
|
-
"server=#{host}:#{port}"
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
# Engine's built-once system prompt (the one run_turn sends), plus the
|
|
705
|
-
# --memory activations it recorded for the sticky status line.
|
|
706
|
-
def seed_system_prompt
|
|
707
|
-
prompt = @engine.system_prompt
|
|
708
|
-
sync_engine_activated_memories
|
|
709
|
-
prompt
|
|
710
|
-
end
|
|
711
|
-
|
|
712
|
-
# Engine owns the system prompt (including --memory activation), but the
|
|
713
|
-
# sticky status line is a UI concern. Mirror the activated names so they
|
|
714
|
-
# appear in the status line.
|
|
715
|
-
def sync_engine_activated_memories
|
|
716
|
-
@engine.activated_memory_names.each do |name|
|
|
717
|
-
add_unique_memory_name(:@session_memory_names, name)
|
|
718
|
-
end
|
|
719
|
-
end
|
|
720
|
-
|
|
721
|
-
# ── Turn view: the drawing surface EventRenderer calls ──────────────────
|
|
722
|
-
public
|
|
723
|
-
|
|
724
|
-
def emit_active_memories_line
|
|
725
|
-
lines = sticky_status_lines(width: status_effective_width)
|
|
726
|
-
return if lines.empty?
|
|
727
|
-
|
|
728
|
-
lines.each { |line| @surface.commit(line) }
|
|
729
|
-
end
|
|
730
|
-
|
|
731
|
-
def print_line(text)
|
|
732
|
-
@surface.commit(text)
|
|
733
|
-
end
|
|
734
|
-
|
|
735
|
-
def reset_turn_feedback
|
|
736
|
-
@spinner_lock.synchronize { @turn_tally.reset }
|
|
737
|
-
clear_retry_spinner_status
|
|
738
|
-
reset_thinking_memory_notification
|
|
739
|
-
reset_thinking_memory_names
|
|
740
|
-
reset_thinking_tool_notification
|
|
741
|
-
end
|
|
742
|
-
|
|
743
|
-
# A turn waits for plugins' slow setup (chi.init) before its first
|
|
744
|
-
# request: the spinner says what for, until the model starts.
|
|
745
|
-
def init_wait_feedback(event)
|
|
746
|
-
labels = Array(event[:tasks]).map { |task| "#{task[:bundle]}: #{task[:label]}" }
|
|
747
|
-
@spinner_lock.synchronize do
|
|
748
|
-
@init_wait_status = "chi> waiting for #{labels.join(" · ")}..."
|
|
749
|
-
start_thinking_spinner
|
|
750
|
-
refresh_thinking_spinner_status
|
|
751
|
-
end
|
|
752
|
-
end
|
|
753
|
-
|
|
754
|
-
def generation_feedback_started(event = {})
|
|
755
|
-
@init_wait_status = nil
|
|
756
|
-
@context_window_tokens = event[:context_window_tokens] if event[:context_window_tokens]
|
|
757
|
-
clear_retry_spinner_status
|
|
758
|
-
@latest_server_context_status = nil
|
|
759
|
-
@spinner_lock.synchronize do
|
|
760
|
-
@thinking_line.reset
|
|
761
|
-
start_thinking_spinner
|
|
762
|
-
end
|
|
763
|
-
end
|
|
764
|
-
|
|
765
|
-
def generation_feedback_retrying(event)
|
|
766
|
-
@spinner_lock.synchronize do
|
|
767
|
-
# The retry streams from the start: wait for its first token again.
|
|
768
|
-
@thinking_waiting_since = monotonic_time if @thinking_spinner_active
|
|
769
|
-
set_retry_spinner_status(event)
|
|
770
|
-
refresh_thinking_spinner_status
|
|
771
|
-
end
|
|
772
|
-
end
|
|
773
|
-
|
|
774
|
-
def generation_feedback_chunk(event)
|
|
775
|
-
@spinner_lock.synchronize do
|
|
776
|
-
@thinking_waiting_since = nil
|
|
777
|
-
clear_retry_spinner_status if retry_spinner_status_active?
|
|
778
|
-
capture_server_context_status_from_payload(event[:payload])
|
|
779
|
-
# A new sentence shows at once (the line changes at most once a dwell).
|
|
780
|
-
next refresh_thinking_spinner_status if @thinking_line.chunk(event)
|
|
781
|
-
|
|
782
|
-
tick_thinking_spinner
|
|
783
|
-
end
|
|
784
|
-
end
|
|
785
|
-
|
|
786
|
-
def tool_call_feedback_started(event)
|
|
787
|
-
@spinner_lock.synchronize do
|
|
788
|
-
@thinking_waiting_since = nil
|
|
789
|
-
clear_retry_spinner_status
|
|
790
|
-
@turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
|
|
791
|
-
@last_tool_call_event = event
|
|
792
|
-
refresh_thinking_spinner_status
|
|
793
|
-
end
|
|
794
|
-
end
|
|
795
|
-
|
|
796
|
-
# The Engine's memory list: after a memory read (read_names, right after
|
|
797
|
-
# its tool_call_started) and at the end of a turn (preloads).
|
|
798
|
-
def used_memories_updated(event)
|
|
799
|
-
@spinner_lock.synchronize do
|
|
800
|
-
Array(event[:used_memory_names]).each { |name| add_unique_memory_name(:@session_memory_names, name) }
|
|
801
|
-
added = Array(event[:read_names]).select { |name| add_unique_memory_name(:@thinking_memory_names, name) }
|
|
802
|
-
next if added.empty?
|
|
803
|
-
|
|
804
|
-
@thinking_recent_memory_loaded = added.last
|
|
805
|
-
capture_thinking_tool_call(@last_tool_call_event) if @last_tool_call_event
|
|
806
|
-
refresh_thinking_spinner_status
|
|
807
|
-
end
|
|
808
|
-
end
|
|
809
|
-
|
|
810
|
-
def tool_call_feedback_completed(event)
|
|
811
|
-
@spinner_lock.synchronize do
|
|
812
|
-
@turn_tally.completed(key: tally_key(event), tool: event[:tool],
|
|
813
|
-
status: event.dig(:activity, :status), params: event.dig(:activity, :params))
|
|
814
|
-
end
|
|
815
|
-
end
|
|
816
|
-
|
|
817
|
-
def clear_generation_retry
|
|
818
|
-
@spinner_lock.synchronize { clear_retry_spinner_status }
|
|
819
|
-
end
|
|
820
|
-
|
|
821
|
-
def generation_feedback_finished
|
|
822
|
-
@spinner_lock.synchronize do
|
|
823
|
-
clear_retry_spinner_status
|
|
824
|
-
@thinking_line.reset
|
|
825
|
-
finish_thinking_spinner
|
|
826
|
-
end
|
|
827
|
-
end
|
|
828
|
-
|
|
829
|
-
# The kernel reports the last emitted context status on the result; keep
|
|
830
|
-
# the previous one when a turn reports none.
|
|
831
|
-
def capture_context_status(status)
|
|
832
|
-
return unless status
|
|
833
|
-
|
|
834
|
-
@latest_context_status = {
|
|
835
|
-
est_pct: status[:est_pct],
|
|
836
|
-
bucket: status[:bucket]
|
|
837
|
-
}
|
|
838
|
-
end
|
|
839
|
-
|
|
840
|
-
private
|
|
841
|
-
|
|
842
|
-
# The last turn's line: completed, canceled or failed, as the Engine
|
|
843
|
-
# closed it (a provider error closes it with :turn_failed).
|
|
844
|
-
def emit_interactive_turn_duration(canceled:)
|
|
845
|
-
record = Array(@engine.metrics.snapshot[:turn_records]).last
|
|
846
|
-
return unless record && record[:duration_ms]
|
|
847
|
-
|
|
848
|
-
state = record[:status] == "failed" ? "failed" : (canceled ? "canceled" : "completed")
|
|
849
|
-
@surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
|
|
850
|
-
end
|
|
673
|
+
# Engine's built-once system prompt (the one run_turn sends).
|
|
674
|
+
def seed_system_prompt = @engine.system_prompt
|
|
851
675
|
|
|
852
676
|
def read_input(awaiting_continue:)
|
|
853
|
-
|
|
677
|
+
refresh_status_row
|
|
854
678
|
|
|
855
679
|
if awaiting_continue
|
|
856
680
|
sync_continue_slot(true)
|
|
@@ -879,20 +703,19 @@ module Samagotchi
|
|
|
879
703
|
sync_continue_slot(false)
|
|
880
704
|
@surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
|
|
881
705
|
end
|
|
706
|
+
@surface.commit("reminder: #{names.join(", ")}")
|
|
882
707
|
hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
|
|
883
708
|
@surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
|
|
884
709
|
result = nil
|
|
885
710
|
begin
|
|
886
711
|
result = run_engine_turn(session, nil, continue: true)
|
|
887
712
|
@prompt = nil
|
|
888
|
-
rescue LLM::ProviderError
|
|
889
|
-
#
|
|
890
|
-
@surface.commit("\nmodel> #{e.summary}")
|
|
713
|
+
rescue LLM::ProviderError
|
|
714
|
+
nil # the renderer showed the failure; back to the prompt
|
|
891
715
|
ensure
|
|
892
716
|
@surface.clear_slot(:hints)
|
|
893
717
|
end
|
|
894
718
|
canceled = result.respond_to?(:canceled?) && result.canceled?
|
|
895
|
-
emit_interactive_turn_duration(canceled: canceled)
|
|
896
719
|
if result && !canceled
|
|
897
720
|
session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
|
|
898
721
|
session.model_name = @effective_model_name
|
|
@@ -918,7 +741,7 @@ module Samagotchi
|
|
|
918
741
|
|
|
919
742
|
if @idle_status_due
|
|
920
743
|
@idle_status_due = false
|
|
921
|
-
|
|
744
|
+
refresh_status_row
|
|
922
745
|
end
|
|
923
746
|
sync_continue_slot(awaiting_continue)
|
|
924
747
|
@repl_input.sync_prompt
|
|
@@ -941,7 +764,7 @@ module Samagotchi
|
|
|
941
764
|
return unless STDIN.tty? && $stdin.tty?
|
|
942
765
|
|
|
943
766
|
@idle_status_due = false
|
|
944
|
-
|
|
767
|
+
refresh_status_row
|
|
945
768
|
@repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
|
|
946
769
|
end
|
|
947
770
|
|
|
@@ -982,6 +805,10 @@ module Samagotchi
|
|
|
982
805
|
prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
|
|
983
806
|
end
|
|
984
807
|
|
|
808
|
+
# Lines come from a pipe or a file (`chi -p X </dev/null`, `echo … | chi`),
|
|
809
|
+
# not a terminal: no prompt is restored for a retry.
|
|
810
|
+
def piped_input? = !$stdin.tty?
|
|
811
|
+
|
|
985
812
|
# A failed prompt goes back into the input for a retry.
|
|
986
813
|
# @return [String] what the failed-turn line says about it
|
|
987
814
|
def restore_prompt_for_retry(input)
|
|
@@ -1033,12 +860,11 @@ module Samagotchi
|
|
|
1033
860
|
cancellation_controller = CancellationController.new
|
|
1034
861
|
@active_cancel_controller = cancellation_controller
|
|
1035
862
|
@renderer.begin_turn
|
|
1036
|
-
#
|
|
1037
|
-
|
|
1038
|
-
seed_system_prompt
|
|
863
|
+
# The row shows the turn's model (a -p turn runs before any read).
|
|
864
|
+
refresh_status_row
|
|
1039
865
|
# A prompt the user typed brings an archived session back to the lists.
|
|
1040
866
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
1041
|
-
|
|
867
|
+
with_steering do
|
|
1042
868
|
@engine.run_turn(
|
|
1043
869
|
session,
|
|
1044
870
|
prompt,
|
|
@@ -1050,17 +876,14 @@ module Samagotchi
|
|
|
1050
876
|
images: images
|
|
1051
877
|
)
|
|
1052
878
|
end
|
|
1053
|
-
emit_cancellation_notice(result)
|
|
1054
|
-
result
|
|
1055
879
|
rescue Interrupt
|
|
1056
|
-
# Engine kept the prompt in the session and emitted :turn_canceled
|
|
880
|
+
# Engine kept the prompt in the session and emitted :turn_canceled
|
|
881
|
+
# (the renderer's line).
|
|
1057
882
|
cancellation_controller&.cancel!(:ctrl_c)
|
|
1058
|
-
|
|
1059
|
-
emit_cancellation_notice(result)
|
|
1060
|
-
result
|
|
883
|
+
cancelled_result_from(session.messages, reason: :ctrl_c)
|
|
1061
884
|
ensure
|
|
1062
885
|
@active_cancel_controller = nil
|
|
1063
|
-
finish_thinking_spinner
|
|
886
|
+
@view.finish_thinking_spinner
|
|
1064
887
|
end
|
|
1065
888
|
|
|
1066
889
|
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
@@ -1152,22 +975,23 @@ module Samagotchi
|
|
|
1152
975
|
end
|
|
1153
976
|
end
|
|
1154
977
|
|
|
1155
|
-
# The REPL's on_event sink for Engine#run_turn: render one event
|
|
1156
|
-
#
|
|
978
|
+
# The REPL's on_event sink for Engine#run_turn: render one event, after
|
|
979
|
+
# taking what the status row shows from it (as AttachedLoop does from the
|
|
980
|
+
# Bridge's). Engine swallows on_event errors to protect the turn, so log
|
|
981
|
+
# ours instead.
|
|
1157
982
|
def handle_stream_event(event)
|
|
983
|
+
case event[:type]
|
|
984
|
+
when :context_status
|
|
985
|
+
@status_row.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
986
|
+
when :used_memories_updated then @status_row.update(used_memories: Array(event[:used_memory_names]))
|
|
987
|
+
when :generation_completed
|
|
988
|
+
@status_row.update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
|
|
989
|
+
end
|
|
1158
990
|
@renderer.call(event)
|
|
1159
991
|
rescue StandardError => e
|
|
1160
992
|
Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
|
|
1161
993
|
end
|
|
1162
994
|
|
|
1163
|
-
def emit_cancellation_notice(result)
|
|
1164
|
-
return unless result.respond_to?(:canceled?) && result.canceled?
|
|
1165
|
-
|
|
1166
|
-
reason = result.respond_to?(:cancellation_reason) ? result.cancellation_reason : nil
|
|
1167
|
-
label = cancellation_reason_label(reason)
|
|
1168
|
-
@surface.commit("\nmodel> request cancelled#{label.empty? ? "" : " (#{label})"}")
|
|
1169
|
-
end
|
|
1170
|
-
|
|
1171
995
|
def cancelled_result_from(messages, reason:)
|
|
1172
996
|
KernelLoop::Result.new(
|
|
1173
997
|
output: "",
|
|
@@ -1180,219 +1004,16 @@ module Samagotchi
|
|
|
1180
1004
|
)
|
|
1181
1005
|
end
|
|
1182
1006
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
def start_thinking_spinner
|
|
1195
|
-
return unless thinking_spinner_enabled?
|
|
1196
|
-
|
|
1197
|
-
@thinking_spinner_active = true
|
|
1198
|
-
@thinking_spinner_index = 0 if @thinking_spinner_index.nil?
|
|
1199
|
-
@thinking_spinner_last_render_at = nil
|
|
1200
|
-
@thinking_waiting_since = monotonic_time
|
|
1201
|
-
render_thinking_spinner
|
|
1202
|
-
start_thinking_ticker
|
|
1203
|
-
end
|
|
1204
|
-
|
|
1205
|
-
# One thread per spinner; it ends when the spinner does.
|
|
1206
|
-
def start_thinking_ticker
|
|
1207
|
-
return unless @spinner_tick_interval
|
|
1208
|
-
return if @thinking_ticker&.alive?
|
|
1209
|
-
|
|
1210
|
-
@thinking_ticker = Thread.new do
|
|
1211
|
-
loop do
|
|
1212
|
-
sleep(@spinner_tick_interval)
|
|
1213
|
-
break unless tick_thinking_spinner_on_timer
|
|
1214
|
-
end
|
|
1215
|
-
rescue StandardError
|
|
1216
|
-
nil
|
|
1217
|
-
end
|
|
1218
|
-
@thinking_ticker.report_on_exception = false
|
|
1219
|
-
end
|
|
1220
|
-
|
|
1221
|
-
# Turn the spinner when no chunk did for a tick.
|
|
1222
|
-
# @return [Boolean] whether the spinner is still shown
|
|
1223
|
-
def tick_thinking_spinner_on_timer
|
|
1224
|
-
@spinner_lock.synchronize do
|
|
1225
|
-
return false unless @thinking_spinner_active
|
|
1226
|
-
|
|
1227
|
-
last = @thinking_spinner_last_render_at
|
|
1228
|
-
if last.nil? || (monotonic_time - last) >= @spinner_tick_interval
|
|
1229
|
-
@thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
|
|
1230
|
-
render_thinking_spinner
|
|
1231
|
-
end
|
|
1232
|
-
true
|
|
1233
|
-
end
|
|
1234
|
-
end
|
|
1235
|
-
|
|
1236
|
-
def tick_thinking_spinner
|
|
1237
|
-
return unless @thinking_spinner_active
|
|
1238
|
-
|
|
1239
|
-
@thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
|
|
1240
|
-
render_thinking_spinner_if_due
|
|
1241
|
-
end
|
|
1242
|
-
|
|
1243
|
-
def refresh_thinking_spinner_status
|
|
1244
|
-
return unless @thinking_spinner_active
|
|
1245
|
-
|
|
1246
|
-
render_thinking_spinner
|
|
1247
|
-
end
|
|
1248
|
-
|
|
1249
|
-
def render_thinking_spinner_if_due
|
|
1250
|
-
last = @thinking_spinner_last_render_at
|
|
1251
|
-
return render_thinking_spinner if last.nil?
|
|
1252
|
-
return if (monotonic_time - last) < thinking_render_min_interval
|
|
1253
|
-
|
|
1254
|
-
render_thinking_spinner
|
|
1255
|
-
end
|
|
1256
|
-
|
|
1257
|
-
public
|
|
1258
|
-
|
|
1259
|
-
# Erase the spinner rows; nothing to do when none are shown.
|
|
1260
|
-
def finish_thinking_spinner
|
|
1261
|
-
@spinner_lock.synchronize do
|
|
1262
|
-
# Off even when the rows are gone already, so the ticker ends.
|
|
1263
|
-
@thinking_spinner_active = false
|
|
1264
|
-
@init_wait_status = nil
|
|
1265
|
-
@thinking_waiting_since = nil
|
|
1266
|
-
next unless @surface.clear_slot(:activity)
|
|
1267
|
-
|
|
1268
|
-
@thinking_spinner_last_render_at = nil
|
|
1269
|
-
end
|
|
1270
|
-
end
|
|
1271
|
-
|
|
1272
|
-
private
|
|
1273
|
-
|
|
1274
|
-
def retry_spinner_status_line(frame, width)
|
|
1275
|
-
data = @retry_spinner_status || {}
|
|
1276
|
-
attempt = data[:attempt].to_i
|
|
1277
|
-
max_retries = data[:max_retries].to_i
|
|
1278
|
-
total_attempts = max_retries + 1
|
|
1279
|
-
delay = format("%.1f", data[:next_delay].to_f)
|
|
1280
|
-
error_class = data[:error_class].to_s
|
|
1281
|
-
message = "model> network error: retrying (#{attempt}/#{total_attempts} in #{delay}s) #{frame}"
|
|
1282
|
-
message += " #{error_class}" unless error_class.empty?
|
|
1283
|
-
capped = cap_preview_line(message, width)
|
|
1284
|
-
color_output? ? paint(capped, NETWORK_RETRY_SPINNER_COLOR) : capped
|
|
1285
|
-
end
|
|
1286
|
-
|
|
1287
|
-
def cap_preview_line(text, width)
|
|
1288
|
-
cap_preview_text(text, thinking_preview_width(width))
|
|
1289
|
-
end
|
|
1290
|
-
|
|
1291
|
-
def thinking_preview_width(width)
|
|
1292
|
-
return THINKING_PREVIEW_WIDTH if width <= 0
|
|
1293
|
-
|
|
1294
|
-
[width, THINKING_PREVIEW_WIDTH].min
|
|
1295
|
-
end
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
def thinking_render_min_interval
|
|
1299
|
-
value = ENV.fetch(THINKING_RENDER_INTERVAL_ENV, THINKING_RENDER_MIN_INTERVAL.to_s).to_f
|
|
1300
|
-
return THINKING_RENDER_MIN_INTERVAL unless value.positive?
|
|
1301
|
-
|
|
1302
|
-
value
|
|
1303
|
-
end
|
|
1304
|
-
|
|
1305
|
-
def monotonic_time
|
|
1306
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1307
|
-
end
|
|
1308
|
-
|
|
1309
|
-
def capture_thinking_tool_call(event)
|
|
1310
|
-
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
1311
|
-
name = call[:name].to_s.strip
|
|
1312
|
-
return if name.empty?
|
|
1313
|
-
|
|
1314
|
-
params = thinking_tool_params_preview(call, event[:params])
|
|
1315
|
-
text = params.empty? ? name : "#{name}(#{params})"
|
|
1316
|
-
@thinking_recent_tool_call = cap_preview_text(text, THINKING_TOOL_PREVIEW_LIMIT)
|
|
1317
|
-
end
|
|
1318
|
-
|
|
1319
|
-
def thinking_tool_params_preview(call, raw_params)
|
|
1320
|
-
compact = raw_params.to_s.gsub(/\s+/, " ").strip
|
|
1321
|
-
return compact unless compact.empty?
|
|
1322
|
-
|
|
1323
|
-
tool_name = call[:name].to_s
|
|
1324
|
-
case tool_name
|
|
1325
|
-
when Tools::Execute::NAME
|
|
1326
|
-
"command=#{preview_value_for_spinner(call[:content])}"
|
|
1327
|
-
when Tools::Read::NAME
|
|
1328
|
-
"path=#{preview_value_for_spinner(call[:content])}"
|
|
1329
|
-
when Tools::Write::NAME, Tools::Edit::NAME
|
|
1330
|
-
"path=#{preview_value_for_spinner(call[:path])}"
|
|
1331
|
-
when Tools::MemoryRead::NAME
|
|
1332
|
-
parts = []
|
|
1333
|
-
name = call[:content].to_s.strip
|
|
1334
|
-
parts << "name=#{preview_value_for_spinner(name)}" unless name.empty?
|
|
1335
|
-
scope = call[:scope].to_s.strip
|
|
1336
|
-
parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
|
|
1337
|
-
parts.join(" ")
|
|
1338
|
-
when Tools::MemoryWrite::NAME
|
|
1339
|
-
parts = []
|
|
1340
|
-
path = call[:path].to_s.strip
|
|
1341
|
-
parts << "name=#{preview_value_for_spinner(path)}" unless path.empty?
|
|
1342
|
-
scope = call[:scope].to_s.strip
|
|
1343
|
-
parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
|
|
1344
|
-
parts.join(" ")
|
|
1345
|
-
else
|
|
1346
|
-
""
|
|
1347
|
-
end
|
|
1348
|
-
end
|
|
1349
|
-
|
|
1350
|
-
def preview_value_for_spinner(value)
|
|
1351
|
-
text = value.to_s.gsub(/\s+/, " ").strip
|
|
1352
|
-
return '""' if text.empty?
|
|
1353
|
-
|
|
1354
|
-
text.inspect
|
|
1355
|
-
end
|
|
1356
|
-
|
|
1357
|
-
def add_unique_memory_name(ivar_name, value)
|
|
1358
|
-
names = instance_variable_get(ivar_name) || []
|
|
1359
|
-
return false if names.include?(value)
|
|
1360
|
-
|
|
1361
|
-
names << value
|
|
1362
|
-
instance_variable_set(ivar_name, names)
|
|
1363
|
-
true
|
|
1364
|
-
end
|
|
1365
|
-
|
|
1366
|
-
def reset_thinking_memory_names
|
|
1367
|
-
@thinking_memory_names = []
|
|
1368
|
-
end
|
|
1369
|
-
|
|
1370
|
-
def reset_thinking_memory_notification
|
|
1371
|
-
@thinking_recent_memory_loaded = nil
|
|
1372
|
-
end
|
|
1373
|
-
|
|
1374
|
-
def reset_thinking_tool_notification
|
|
1375
|
-
@thinking_recent_tool_call = nil
|
|
1376
|
-
end
|
|
1377
|
-
|
|
1378
|
-
def capture_server_context_status_from_payload(payload)
|
|
1379
|
-
# The window the kernel resolved for this generation (see
|
|
1380
|
-
# :generation_started); the configured one before the first generation.
|
|
1381
|
-
window_tokens = @context_window_tokens || ContextWindow.configured.tokens
|
|
1382
|
-
normalized = ContextUsage.normalize(payload, window_tokens: window_tokens)
|
|
1383
|
-
return unless normalized
|
|
1384
|
-
|
|
1385
|
-
@latest_server_context_status = normalized
|
|
1386
|
-
end
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
def emit_idle_status_line
|
|
1390
|
-
return unless status_line_enabled?
|
|
1391
|
-
|
|
1392
|
-
lines = idle_status_lines(width: status_effective_width)
|
|
1393
|
-
return if lines.empty?
|
|
1394
|
-
|
|
1395
|
-
@surface.set_slot(:status, lines)
|
|
1007
|
+
# The status row under the prompt, from the Engine's state (what a
|
|
1008
|
+
# worker hands an attached TUI when it joins): drawn when it changed.
|
|
1009
|
+
def refresh_status_row
|
|
1010
|
+
state = @engine.session_state_snapshot
|
|
1011
|
+
served = state[:served_model] ? [state[:served_model], state[:served_model_for]] : nil
|
|
1012
|
+
fields = { model: @effective_model_name, default_model: @default_model_name, served: served,
|
|
1013
|
+
parent_id: state[:parent_id], used_memories: Array(state[:used_memory_names]),
|
|
1014
|
+
preloaded: Array(state[:preloaded_memory_names]), muted: Array(state[:muted_memory_names]) }
|
|
1015
|
+
fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
|
|
1016
|
+
@status_row.update(**fields)
|
|
1396
1017
|
end
|
|
1397
1018
|
|
|
1398
1019
|
# ── Idle session recap ───────────────────────────────────────────────────
|
|
@@ -1429,13 +1050,17 @@ module Samagotchi
|
|
|
1429
1050
|
# main thread (the command runs at the prompt) or beside a running turn
|
|
1430
1051
|
# (above the live region), else at the next flush.
|
|
1431
1052
|
#
|
|
1432
|
-
# A plugin's init task (chi.init)
|
|
1433
|
-
#
|
|
1434
|
-
#
|
|
1435
|
-
# prints only
|
|
1053
|
+
# A plugin's init task (chi.init) turns the activity row while it runs
|
|
1054
|
+
# (between turns too, as in attached mode) and prints a line when it is
|
|
1055
|
+
# done; a load warning announced before the first turn, one. Beside a
|
|
1056
|
+
# running turn they print at once; a --non-interactive run prints only
|
|
1057
|
+
# its answer.
|
|
1436
1058
|
def handle_card_event(event)
|
|
1437
1059
|
if INIT_EVENTS.include?(event[:type])
|
|
1438
1060
|
return if @non_interactive
|
|
1061
|
+
return @view.init_started(event) if event[:type] == :plugin_init_started
|
|
1062
|
+
|
|
1063
|
+
@view.init_finished(event) if event[:type] == :plugin_init_finished
|
|
1439
1064
|
return show_pending_item(event) if @engine.turn_running?
|
|
1440
1065
|
|
|
1441
1066
|
return @pending_cards << event
|
|
@@ -1463,13 +1088,13 @@ module Samagotchi
|
|
|
1463
1088
|
INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
|
|
1464
1089
|
|
|
1465
1090
|
# A card, a notice, an anytime command's output (:command_output), a
|
|
1466
|
-
# plugin init task's line or a load warning.
|
|
1091
|
+
# plugin init task's done line or a load warning.
|
|
1467
1092
|
def show_pending_item(item)
|
|
1468
1093
|
case item[:type]
|
|
1469
1094
|
when :card then @renderer.render_card(item)
|
|
1470
1095
|
when :command_output then @surface.commit(item[:text])
|
|
1471
1096
|
when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
|
|
1472
|
-
when :
|
|
1097
|
+
when :plugin_init_finished
|
|
1473
1098
|
line = EventRenderer.init_line(item)
|
|
1474
1099
|
@surface.commit(line) if line
|
|
1475
1100
|
else @surface.commit(EventRenderer.hook_notice_line(item))
|
|
@@ -1484,158 +1109,6 @@ module Samagotchi
|
|
|
1484
1109
|
ConfigFile.recap_config
|
|
1485
1110
|
end
|
|
1486
1111
|
|
|
1487
|
-
def spinner_status_lines(width: status_effective_width)
|
|
1488
|
-
return [] unless status_line_enabled?
|
|
1489
|
-
|
|
1490
|
-
build_status_lines(scope: :spinner, width: width)
|
|
1491
|
-
end
|
|
1492
|
-
|
|
1493
|
-
def sticky_status_lines(width: status_effective_width)
|
|
1494
|
-
return [] unless status_line_enabled?
|
|
1495
|
-
|
|
1496
|
-
build_status_lines(scope: :sticky, width: width)
|
|
1497
|
-
end
|
|
1498
|
-
|
|
1499
|
-
def idle_status_lines(width: status_effective_width)
|
|
1500
|
-
return [] unless status_line_enabled?
|
|
1501
|
-
|
|
1502
|
-
build_status_lines(scope: :idle, width: width)
|
|
1503
|
-
end
|
|
1504
|
-
|
|
1505
|
-
# The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
|
|
1506
|
-
def build_status_lines(scope:, width: status_effective_width)
|
|
1507
|
-
status_rows(status_segments(scope), width)
|
|
1508
|
-
end
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
def status_width_mode
|
|
1512
|
-
mode = ENV.fetch(STATUS_WIDTH_MODE_ENV, STATUS_WIDTH_MODE_TERMINAL_CAP).to_s.strip.downcase
|
|
1513
|
-
return STATUS_WIDTH_MODE_FIXED if mode == STATUS_WIDTH_MODE_FIXED
|
|
1514
|
-
|
|
1515
|
-
STATUS_WIDTH_MODE_TERMINAL_CAP
|
|
1516
|
-
end
|
|
1517
|
-
|
|
1518
|
-
def status_effective_width
|
|
1519
|
-
mode = status_width_mode
|
|
1520
|
-
width = if mode == STATUS_WIDTH_MODE_FIXED
|
|
1521
|
-
status_fixed_width
|
|
1522
|
-
else
|
|
1523
|
-
[terminal_columns, status_max_width].min
|
|
1524
|
-
end
|
|
1525
|
-
width = status_fixed_width unless width.positive?
|
|
1526
|
-
width
|
|
1527
|
-
end
|
|
1528
|
-
|
|
1529
|
-
def status_fixed_width
|
|
1530
|
-
env_positive_int(STATUS_FIXED_WIDTH_ENV, THINKING_PREVIEW_WIDTH)
|
|
1531
|
-
end
|
|
1532
|
-
|
|
1533
|
-
def status_max_width
|
|
1534
|
-
env_positive_int(STATUS_MAX_WIDTH_ENV, STATUS_MAX_WIDTH_DEFAULT)
|
|
1535
|
-
end
|
|
1536
|
-
|
|
1537
|
-
def terminal_columns
|
|
1538
|
-
columns = begin
|
|
1539
|
-
io = IO.console
|
|
1540
|
-
io&.winsize&.[](1).to_i
|
|
1541
|
-
rescue StandardError
|
|
1542
|
-
0
|
|
1543
|
-
end
|
|
1544
|
-
return columns if columns.positive?
|
|
1545
|
-
|
|
1546
|
-
env_positive_int("COLUMNS", status_max_width)
|
|
1547
|
-
end
|
|
1548
|
-
|
|
1549
|
-
def env_positive_int(key, default)
|
|
1550
|
-
value = ENV.fetch(key, default.to_s).to_i
|
|
1551
|
-
value.positive? ? value : default
|
|
1552
|
-
end
|
|
1553
|
-
|
|
1554
|
-
def status_segments(scope)
|
|
1555
|
-
segments = [status_model_segment, status_server_segment].reject(&:empty?)
|
|
1556
|
-
context_segment = status_context_segment
|
|
1557
|
-
memory_segment = status_memory_segment(scope)
|
|
1558
|
-
segments << context_segment unless context_segment.empty?
|
|
1559
|
-
segments << memory_segment unless memory_segment.empty?
|
|
1560
|
-
# The session's --mute list, on the sticky and idle rows (not the spinner).
|
|
1561
|
-
muted_segment = scope == :spinner ? "" : status_memory_text(@engine.muted_memory_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")
|
|
1562
|
-
segments << muted_segment unless muted_segment.empty?
|
|
1563
|
-
segments
|
|
1564
|
-
end
|
|
1565
|
-
|
|
1566
|
-
def status_model_segment
|
|
1567
|
-
served, served_for = @engine.respond_to?(:served_model) ? @engine.served_model(probe: false) : nil
|
|
1568
|
-
status_model_text(@effective_model_name, @default_model_name, served: served, served_for: served_for)
|
|
1569
|
-
end
|
|
1570
|
-
|
|
1571
|
-
def status_context_segment
|
|
1572
|
-
status_context_text(server: @latest_server_context_status, estimate: @latest_context_status)
|
|
1573
|
-
end
|
|
1574
|
-
|
|
1575
|
-
def status_memory_segment(scope)
|
|
1576
|
-
names, limit = case scope
|
|
1577
|
-
when :spinner
|
|
1578
|
-
[Array(@thinking_memory_names), MEMORY_SPINNER_PREVIEW_LIMIT]
|
|
1579
|
-
else
|
|
1580
|
-
[Array(@session_memory_names), MEMORY_STICKY_PREVIEW_LIMIT]
|
|
1581
|
-
end
|
|
1582
|
-
status_memory_text(names, limit)
|
|
1583
|
-
end
|
|
1584
|
-
|
|
1585
|
-
def thinking_memory_notification_suffix
|
|
1586
|
-
memory_name = @thinking_recent_memory_loaded.to_s.strip
|
|
1587
|
-
return "" if memory_name.empty?
|
|
1588
|
-
|
|
1589
|
-
" memory_loaded: #{memory_name}"
|
|
1590
|
-
end
|
|
1591
|
-
|
|
1592
|
-
def thinking_tool_notification_suffix
|
|
1593
|
-
tool_call = @thinking_recent_tool_call.to_s.strip
|
|
1594
|
-
return "" if tool_call.empty?
|
|
1595
|
-
|
|
1596
|
-
" last_tool: #{tool_call}"
|
|
1597
|
-
end
|
|
1598
|
-
|
|
1599
|
-
def thinking_notification_segments(width)
|
|
1600
|
-
return ["", ""] if width <= 0
|
|
1601
|
-
|
|
1602
|
-
memory_suffix = cap_preview_text(thinking_memory_notification_suffix, width)
|
|
1603
|
-
remaining = [width - memory_suffix.length, 0].max
|
|
1604
|
-
tool_suffix = cap_preview_text(thinking_tool_notification_suffix, remaining)
|
|
1605
|
-
[memory_suffix, tool_suffix]
|
|
1606
|
-
end
|
|
1607
|
-
|
|
1608
|
-
def paint_if_present(text, code)
|
|
1609
|
-
return "" if text.to_s.empty?
|
|
1610
|
-
|
|
1611
|
-
paint(text, code)
|
|
1612
|
-
end
|
|
1613
|
-
|
|
1614
|
-
def thinking_spinner_enabled?
|
|
1615
|
-
return false unless $stdout.tty?
|
|
1616
|
-
|
|
1617
|
-
mode = ENV.fetch(THINKING_UI_ENV, THINKING_UI_SPINNER).to_s.strip.downcase
|
|
1618
|
-
return false if mode.empty? || mode == THINKING_UI_OFF || mode == "false" || mode == "0"
|
|
1619
|
-
|
|
1620
|
-
mode == THINKING_UI_SPINNER && ENV.fetch("TERM", "") != "dumb"
|
|
1621
|
-
end
|
|
1622
|
-
|
|
1623
|
-
def set_retry_spinner_status(event)
|
|
1624
|
-
@retry_spinner_status = {
|
|
1625
|
-
attempt: event[:attempt],
|
|
1626
|
-
max_retries: event[:max_retries],
|
|
1627
|
-
next_delay: event[:next_delay],
|
|
1628
|
-
error_class: event[:error_class]
|
|
1629
|
-
}
|
|
1630
|
-
end
|
|
1631
|
-
|
|
1632
|
-
def clear_retry_spinner_status
|
|
1633
|
-
@retry_spinner_status = nil
|
|
1634
|
-
end
|
|
1635
|
-
|
|
1636
|
-
def retry_spinner_status_active?
|
|
1637
|
-
@retry_spinner_status.is_a?(Hash)
|
|
1638
|
-
end
|
|
1639
1112
|
|
|
1640
1113
|
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1641
1114
|
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
@@ -1657,74 +1130,6 @@ module Samagotchi
|
|
|
1657
1130
|
RelineSeam.key_handler = previous_key_handler
|
|
1658
1131
|
end
|
|
1659
1132
|
|
|
1660
|
-
def render_thinking_spinner
|
|
1661
|
-
frame = THINKING_SPINNER_FRAMES[@thinking_spinner_index % THINKING_SPINNER_FRAMES.length]
|
|
1662
|
-
width = status_effective_width
|
|
1663
|
-
@thinking_line.tick
|
|
1664
|
-
# The spinner goes above the prompt, the status rows below it.
|
|
1665
|
-
@surface.set_slots(activity: thinking_spinner_status_lines(frame, width: width), status: spinner_status_lines(width: width))
|
|
1666
|
-
@thinking_spinner_last_render_at = monotonic_time
|
|
1667
|
-
end
|
|
1668
|
-
|
|
1669
|
-
# "model> waiting for the first token... 5s |" once a generation has
|
|
1670
|
-
# shown nothing for THINKING_WAIT_NOTICE_AFTER seconds, else nil.
|
|
1671
|
-
def first_token_wait_status(frame)
|
|
1672
|
-
return nil unless @thinking_waiting_since
|
|
1673
|
-
|
|
1674
|
-
waited = monotonic_time - @thinking_waiting_since
|
|
1675
|
-
return nil if waited < THINKING_WAIT_NOTICE_AFTER
|
|
1676
|
-
|
|
1677
|
-
"model> waiting for the first token... #{waited.floor}s #{frame}"
|
|
1678
|
-
end
|
|
1679
|
-
|
|
1680
|
-
# The spinner row, then (from a turn's 3rd tool call) its tool tally.
|
|
1681
|
-
# The spinner stops while tools run, so the tally shows while the model
|
|
1682
|
-
# generates between tool rounds.
|
|
1683
|
-
def thinking_spinner_status_lines(frame, width: status_effective_width)
|
|
1684
|
-
spinner_row_lines(frame, width: width) + tally_status_lines(width)
|
|
1685
|
-
end
|
|
1686
|
-
|
|
1687
|
-
def tally_status_lines(width)
|
|
1688
|
-
tally = @turn_tally.text(width: width)
|
|
1689
|
-
return [] unless tally
|
|
1690
|
-
|
|
1691
|
-
[color_output? ? paint(tally, 90) : tally]
|
|
1692
|
-
end
|
|
1693
|
-
|
|
1694
|
-
def tally_key(event)
|
|
1695
|
-
[event[:iteration].to_i, event[:call_index].to_i]
|
|
1696
|
-
end
|
|
1697
|
-
|
|
1698
|
-
def spinner_row_lines(frame, width:)
|
|
1699
|
-
if retry_spinner_status_active?
|
|
1700
|
-
return [retry_spinner_status_line(frame, width)]
|
|
1701
|
-
end
|
|
1702
|
-
|
|
1703
|
-
return ["#{@init_wait_status} #{frame}"[0, width]] if @init_wait_status
|
|
1704
|
-
|
|
1705
|
-
base = first_token_wait_status(frame) || "model> thinking... #{frame}"
|
|
1706
|
-
available_for_notification = [width - base.length, 0].max
|
|
1707
|
-
memory_notification, tool_notification = thinking_notification_segments(available_for_notification)
|
|
1708
|
-
notification = "#{memory_notification}#{tool_notification}"
|
|
1709
|
-
base = thinking_sentence_base(frame, width - notification.length) || base
|
|
1710
|
-
|
|
1711
|
-
return ["#{base}#{notification}"] unless color_output?
|
|
1712
|
-
|
|
1713
|
-
["#{paint(base, 90)}#{paint_if_present(memory_notification, MEMORY_SPINNER_COLOR)}#{paint_if_present(tool_notification, TOOL_SPINNER_COLOR)}"]
|
|
1714
|
-
end
|
|
1715
|
-
|
|
1716
|
-
# "model> thinking · <the newest sentence> |" within +room+ columns (the
|
|
1717
|
-
# web's thinking ticker, ThinkingLine), or nil before the first sentence.
|
|
1718
|
-
def thinking_sentence_base(frame, room)
|
|
1719
|
-
return nil if @thinking_waiting_since || @thinking_line.empty?
|
|
1720
|
-
|
|
1721
|
-
prefix = "model> #{@thinking_line.label} · "
|
|
1722
|
-
room -= prefix.length + frame.length + 1
|
|
1723
|
-
return nil if room < MIN_SENTENCE_ROOM
|
|
1724
|
-
|
|
1725
|
-
"#{prefix}#{@thinking_line.fit(room)} #{frame}"
|
|
1726
|
-
end
|
|
1727
|
-
|
|
1728
1133
|
# ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
|
|
1729
1134
|
|
|
1730
1135
|
# Non-blocking observer: the turn thread emits :question_requested; we stash
|
|
@@ -1783,8 +1188,8 @@ module Samagotchi
|
|
|
1783
1188
|
def render_question_widget(pending)
|
|
1784
1189
|
prompt = QuestionPrompt.new(pending)
|
|
1785
1190
|
|
|
1786
|
-
#
|
|
1787
|
-
finish_thinking_spinner
|
|
1191
|
+
# The activity row goes while the question waits.
|
|
1192
|
+
@view.finish_thinking_spinner
|
|
1788
1193
|
# An edit's diff goes above the slot, into the scrollback.
|
|
1789
1194
|
preview = prompt.preview_lines(paint: method(:paint))
|
|
1790
1195
|
@surface.commit(preview.join("\n")) unless preview.empty?
|