samagotchi 0.3.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 +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- 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 +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- 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 +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- 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 +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- 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 +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- 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/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- 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/question_card.js +3 -1
- 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 +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -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
|
|
@@ -47,47 +44,10 @@ module Samagotchi
|
|
|
47
44
|
include Formatting
|
|
48
45
|
include InputSupport
|
|
49
46
|
|
|
50
|
-
AGENT_DESCRIPTION_FILE = "AGENT.md"
|
|
51
|
-
SKIP_AGENT_DESCRIPTION_ENV = "SAMAGOTCHI_SKIP_AGENT_MD"
|
|
52
|
-
STATS_COMMAND = "/stats"
|
|
53
|
-
# /exit --delete: delete the session on the way out.
|
|
54
|
-
EXIT_DELETE_FLAG = "--delete"
|
|
55
|
-
# Leave, then archive the session (hidden from the lists, kept for good).
|
|
56
|
-
ARCHIVE_COMMAND = "/archive"
|
|
57
47
|
SCRATCH_ARCHIVE_REFUSED = "a scratch session is deleted when you leave; nothing to archive"
|
|
58
|
-
RECAP_COMMAND = "/recap"
|
|
59
48
|
# The prompt while a question waits for its answer (its choices are in
|
|
60
49
|
# the notes slot).
|
|
61
50
|
QUESTION_PROMPT = "? "
|
|
62
|
-
THINKING_UI_ENV = "SAMAGOTCHI_THINKING_UI"
|
|
63
|
-
THINKING_UI_SPINNER = "spinner"
|
|
64
|
-
THINKING_UI_OFF = "off"
|
|
65
|
-
THINKING_SPINNER_FRAMES = ["|", "/", "-", "\\"].freeze
|
|
66
|
-
MEMORY_SPINNER_COLOR = "38;5;208"
|
|
67
|
-
TOOL_SPINNER_COLOR = 32
|
|
68
|
-
NETWORK_RETRY_SPINNER_COLOR = 31
|
|
69
|
-
MEMORY_SPINNER_PREVIEW_LIMIT = 3
|
|
70
|
-
MEMORY_STICKY_PREVIEW_LIMIT = 8
|
|
71
|
-
THINKING_PREVIEW_WIDTH = 120
|
|
72
|
-
# Columns a sentence needs on the spinner row; with less, it reads "thinking...".
|
|
73
|
-
MIN_SENTENCE_ROOM = 12
|
|
74
|
-
THINKING_TOOL_PREVIEW_LIMIT = 56
|
|
75
|
-
THINKING_RENDER_MIN_INTERVAL = 0.08
|
|
76
|
-
# The spinner also turns with time: a ticker redraws it when no chunk
|
|
77
|
-
# did for this long, and after THINKING_WAIT_NOTICE_AFTER seconds with no
|
|
78
|
-
# chunk it says how long the first token has taken.
|
|
79
|
-
THINKING_TICK_INTERVAL = 0.25
|
|
80
|
-
THINKING_WAIT_NOTICE_AFTER = 2.0
|
|
81
|
-
THINKING_RENDER_INTERVAL_ENV = "SAMAGOTCHI_THINKING_RENDER_INTERVAL"
|
|
82
|
-
STATUS_LINE_ENV = "SAMAGOTCHI_STATUS_LINE"
|
|
83
|
-
STATUS_LINE_ON = "on"
|
|
84
|
-
STATUS_LINE_OFF = "off"
|
|
85
|
-
STATUS_WIDTH_MODE_ENV = "SAMAGOTCHI_STATUS_WIDTH_MODE"
|
|
86
|
-
STATUS_WIDTH_MODE_TERMINAL_CAP = "terminal_cap"
|
|
87
|
-
STATUS_WIDTH_MODE_FIXED = "fixed"
|
|
88
|
-
STATUS_FIXED_WIDTH_ENV = "SAMAGOTCHI_STATUS_FIXED_WIDTH"
|
|
89
|
-
STATUS_MAX_WIDTH_ENV = "SAMAGOTCHI_STATUS_MAX_WIDTH"
|
|
90
|
-
STATUS_MAX_WIDTH_DEFAULT = 160
|
|
91
51
|
REMINDER_PENDING_POLL_INTERVAL = 0.5
|
|
92
52
|
# What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
|
|
93
53
|
COMMAND_BUSY = "busy: wait for the turn to end"
|
|
@@ -109,14 +69,9 @@ module Samagotchi
|
|
|
109
69
|
# @param scratch [Boolean] `chi scratch`: a new session that is deleted
|
|
110
70
|
# however the REPL ends, saves no memories and writes no recap
|
|
111
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,
|
|
112
|
-
spinner_tick_interval:
|
|
72
|
+
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
|
|
113
73
|
@mode = mode.to_sym
|
|
114
74
|
@scratch = scratch
|
|
115
|
-
# nil: no ticker thread (specs that compare exact frames).
|
|
116
|
-
@spinner_tick_interval = spinner_tick_interval
|
|
117
|
-
@spinner_lock = Monitor.new
|
|
118
|
-
@turn_tally = TurnTally.new
|
|
119
|
-
@thinking_line = ThinkingLine.new(clock: -> { monotonic_time })
|
|
120
75
|
@prompt = prompt
|
|
121
76
|
@default_model_name = ModelProfile.required_model_name(nil)
|
|
122
77
|
aliased_model_name = model_name.to_s.strip.empty? ? nil : ConfigFile.resolve_model_alias(model_name)
|
|
@@ -145,6 +100,9 @@ module Samagotchi
|
|
|
145
100
|
# Re-resolve client after resume may change effective model
|
|
146
101
|
@client = @host_registry.resolve(@effective_model_name).client
|
|
147
102
|
end
|
|
103
|
+
# The model it starts on names a configured host (a bad default.model
|
|
104
|
+
# doesn't matter when --model or the session picks another).
|
|
105
|
+
ModelProfile.check_host!(@effective_model_name, hosts: @host_registry.entries)
|
|
148
106
|
# Only a caller's profile goes in; otherwise the Engine resolves one
|
|
149
107
|
# for the effective model and hands it to the kernel.
|
|
150
108
|
@kernel = KernelLoop.new(client: @client, profile: profile, no_interrupt: no_interrupt, reminder_store: Samagotchi::ReminderStore.new)
|
|
@@ -167,9 +125,17 @@ module Samagotchi
|
|
|
167
125
|
# Every terminal write goes through the surface. The REPL swaps in a
|
|
168
126
|
# live region when the terminal can show one (#assist_loop), unless it
|
|
169
127
|
# was given a surface to draw on.
|
|
170
|
-
@surface = surface ||
|
|
128
|
+
@surface = surface || PlainSurface.new
|
|
171
129
|
@surface_given = !surface.nil?
|
|
172
|
-
|
|
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)
|
|
173
139
|
@render_event = ->(event) { handle_stream_event(event) }
|
|
174
140
|
@engine = Engine.new(
|
|
175
141
|
mode: :assist,
|
|
@@ -178,7 +144,9 @@ module Samagotchi
|
|
|
178
144
|
profile: profile,
|
|
179
145
|
session_id: session_id,
|
|
180
146
|
no_interrupt: no_interrupt,
|
|
181
|
-
|
|
147
|
+
# nil: the config default, unchecked here (the model the REPL starts
|
|
148
|
+
# on is checked above, and --model may make the default moot).
|
|
149
|
+
model_name: nil,
|
|
182
150
|
memories: @requested_memories,
|
|
183
151
|
muted_memories: @muted_memory_names,
|
|
184
152
|
kernel: @kernel,
|
|
@@ -235,6 +203,8 @@ module Samagotchi
|
|
|
235
203
|
# (auto-executing it, then saving), then either exits when there is no
|
|
236
204
|
# follow-up REPL (--non-interactive) or drops into the REPL carrying the
|
|
237
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)
|
|
238
208
|
def run
|
|
239
209
|
unless @mode == :assist
|
|
240
210
|
raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
|
|
@@ -296,6 +266,8 @@ module Samagotchi
|
|
|
296
266
|
else
|
|
297
267
|
keep_after_exit(session)
|
|
298
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
|
|
299
271
|
ensure
|
|
300
272
|
# However it ends (the early returns too; the Engine was built in
|
|
301
273
|
# #initialize): the anytime commands finish, the plugins' services
|
|
@@ -426,26 +398,8 @@ module Samagotchi
|
|
|
426
398
|
messages
|
|
427
399
|
end
|
|
428
400
|
|
|
429
|
-
# Public entrypoint for background session workers.
|
|
430
|
-
# Keeps worker call sites out of TerminalUI private API details.
|
|
431
|
-
def process_background_prompt(session:, prompt:)
|
|
432
|
-
@engine.process_background_prompt(session: session, prompt: prompt)
|
|
433
|
-
end
|
|
434
|
-
|
|
435
401
|
private
|
|
436
402
|
|
|
437
|
-
# Generate profile-aware tool calling hint.
|
|
438
|
-
# Kept as a one-line delegator to Engine (single source of truth).
|
|
439
|
-
def tool_call_hint
|
|
440
|
-
return @engine.tool_call_hint
|
|
441
|
-
end
|
|
442
|
-
|
|
443
|
-
# Assist system prompt — delegates wholesale to Engine so the two can
|
|
444
|
-
# never drift apart. This single delegator fixes the 4-line regression.
|
|
445
|
-
def assist_system_prompt
|
|
446
|
-
return @engine.assist_system_prompt
|
|
447
|
-
end
|
|
448
|
-
|
|
449
403
|
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
450
404
|
# threaded in here (so --prompt / --resume share one code path). The
|
|
451
405
|
# working session is persisted at the end of every turn.
|
|
@@ -508,15 +462,24 @@ module Samagotchi
|
|
|
508
462
|
return yield unless screen
|
|
509
463
|
|
|
510
464
|
plain = @surface
|
|
511
|
-
|
|
465
|
+
use_surface(screen)
|
|
512
466
|
begin
|
|
513
467
|
yield
|
|
514
468
|
ensure
|
|
515
|
-
|
|
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
|
|
516
473
|
LiveRegion.close(screen)
|
|
517
474
|
end
|
|
518
475
|
end
|
|
519
476
|
|
|
477
|
+
def use_surface(surface)
|
|
478
|
+
@surface = surface
|
|
479
|
+
@view.surface = surface
|
|
480
|
+
@status_row.surface = surface
|
|
481
|
+
end
|
|
482
|
+
|
|
520
483
|
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
521
484
|
# threaded in here (so --prompt / --resume share one code path). The
|
|
522
485
|
# session's conversation is the single working copy: turns go through
|
|
@@ -559,21 +522,22 @@ module Samagotchi
|
|
|
559
522
|
end
|
|
560
523
|
end
|
|
561
524
|
break if input.nil?
|
|
562
|
-
|
|
563
|
-
|
|
525
|
+
local = local_command(input)
|
|
526
|
+
if local == :exit
|
|
527
|
+
@delete_on_exit ||= SessionCommands.delete_on_exit?(input)
|
|
564
528
|
break
|
|
565
529
|
end
|
|
566
|
-
if
|
|
530
|
+
if local == :archive
|
|
567
531
|
next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
|
|
568
532
|
|
|
569
533
|
@archive_on_exit = true
|
|
570
534
|
break
|
|
571
535
|
end
|
|
572
536
|
# Not an answer to a continue offer either.
|
|
573
|
-
next detach_note if
|
|
537
|
+
next detach_note if local == :detach
|
|
574
538
|
|
|
575
539
|
# /stats and /recap run; the offer stays open.
|
|
576
|
-
if @turn_flow.awaiting_continue? &&
|
|
540
|
+
if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
|
|
577
541
|
answer_continue_offer(session, input)
|
|
578
542
|
else
|
|
579
543
|
# The ? read left no echo: show what ran.
|
|
@@ -602,8 +566,9 @@ module Samagotchi
|
|
|
602
566
|
@turn_flow.before_continue_turn
|
|
603
567
|
begin
|
|
604
568
|
result = run_engine_turn(session, nil, continue: true)
|
|
605
|
-
rescue LLM::ProviderError
|
|
606
|
-
|
|
569
|
+
rescue LLM::ProviderError
|
|
570
|
+
# The renderer showed the failure (:turn_failed).
|
|
571
|
+
@surface.commit(turn_end_hint("continue prompt preserved"))
|
|
607
572
|
return
|
|
608
573
|
end
|
|
609
574
|
finish_turn(session, result, continue: true)
|
|
@@ -626,20 +591,17 @@ module Samagotchi
|
|
|
626
591
|
persist_recent_history(input) if command.shell
|
|
627
592
|
return
|
|
628
593
|
end
|
|
629
|
-
return
|
|
630
|
-
return @surface.commit("\nmodel> #{handle_recap_command}") if recap_command?(input)
|
|
594
|
+
return if show_local_command(input)
|
|
631
595
|
|
|
632
596
|
@turn_flow.before_prompt_turn
|
|
633
597
|
persist_recent_history(input)
|
|
634
598
|
begin
|
|
635
599
|
# Engine#run_turn injects due reminders as a tail message, appends
|
|
636
600
|
# the prompt, and renders through @renderer via on_event.
|
|
637
|
-
result = run_engine_turn(session,
|
|
601
|
+
result = run_engine_turn(session, input, images: ImageInput.extract(input))
|
|
638
602
|
rescue LLM::ProviderError, ImageStore::Error => e
|
|
639
|
-
# Engine closed the turn (:turn_failed)
|
|
640
|
-
# Retries were already tallied via generation_retrying events.
|
|
603
|
+
# Engine closed the turn (:turn_failed), which the renderer showed.
|
|
641
604
|
# An @path image that can't be used fails the turn the same way.
|
|
642
|
-
emit_interactive_turn_duration(canceled: false)
|
|
643
605
|
summary = e.respond_to?(:summary) ? e.summary : e.message
|
|
644
606
|
# The model reads why on its next turn. An image that couldn't be
|
|
645
607
|
# used, or that the host refused, never reached it: no note for that
|
|
@@ -648,7 +610,14 @@ module Samagotchi
|
|
|
648
610
|
@turn_flow.prompt_turn_failed(note: note)
|
|
649
611
|
# The Engine saved the failed turn; the file follows the rollback.
|
|
650
612
|
save_session(session) if note
|
|
651
|
-
|
|
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
|
|
652
621
|
return
|
|
653
622
|
end
|
|
654
623
|
finish_turn(session, result, continue: false)
|
|
@@ -666,10 +635,9 @@ module Samagotchi
|
|
|
666
635
|
# and the partial assistant reply (marked [interrupted]) into
|
|
667
636
|
# result.conversation, so progress is preserved by default — the
|
|
668
637
|
# user's next message continues from it. !rollback restores the
|
|
669
|
-
# pre-turn checkpoint for an explicit full discard
|
|
670
|
-
|
|
638
|
+
# pre-turn checkpoint for an explicit full discard (the renderer's
|
|
639
|
+
# hint under :turn_canceled says so).
|
|
671
640
|
save_session(session) if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
|
|
672
|
-
@surface.commit("\nmodel> turn cancelled; partial progress kept in context; use !rollback immediately after cancellation to restore the pre-turn checkpoint")
|
|
673
641
|
return
|
|
674
642
|
end
|
|
675
643
|
|
|
@@ -702,191 +670,11 @@ module Samagotchi
|
|
|
702
670
|
@default_model_name = @commands.default_model
|
|
703
671
|
end
|
|
704
672
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
if @host_registry && @host_registry.entries.size > 1
|
|
708
|
-
active = @host_registry.resolve(@effective_model_name).entry rescue nil
|
|
709
|
-
if active
|
|
710
|
-
host = active.host
|
|
711
|
-
port = active.port
|
|
712
|
-
total = @host_registry.entries.size
|
|
713
|
-
return "" if ["localhost", "127.0.0.1"].include?(host) && total == 1
|
|
714
|
-
# When multiple hosts, always show active + count
|
|
715
|
-
return "server=#{host}:#{port} (#{total} hosts)"
|
|
716
|
-
end
|
|
717
|
-
end
|
|
718
|
-
host = Samagotchi::Config.get("server.host")
|
|
719
|
-
return "" if ["localhost", "127.0.0.1"].include?(host)
|
|
720
|
-
|
|
721
|
-
port = Samagotchi::Config.get("server.port")
|
|
722
|
-
"server=#{host}:#{port}"
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
# Engine's built-once system prompt (the one run_turn sends), plus the
|
|
726
|
-
# --memory activations it recorded for the sticky status line.
|
|
727
|
-
def seed_system_prompt
|
|
728
|
-
prompt = @engine.system_prompt
|
|
729
|
-
sync_engine_activated_memories
|
|
730
|
-
prompt
|
|
731
|
-
end
|
|
732
|
-
|
|
733
|
-
# Appends the current memory index to the base system prompt so the agent
|
|
734
|
-
# is always aware of stored memories without needing to call a tool first.
|
|
735
|
-
# Delegates wholesale to Engine (single source of truth).
|
|
736
|
-
def system_prompt_with_index(base)
|
|
737
|
-
result = @engine.system_prompt_with_index(base)
|
|
738
|
-
# Mirror any --memory activations the Engine performed so the sticky
|
|
739
|
-
# status line can surface them. The prompt body injection moved into
|
|
740
|
-
# Engine; echoing the activated names here is purely a UI concern.
|
|
741
|
-
sync_engine_activated_memories
|
|
742
|
-
result
|
|
743
|
-
end
|
|
744
|
-
|
|
745
|
-
# Engine owns the system prompt (including --memory activation), but the
|
|
746
|
-
# sticky status line is a UI concern. Mirror the activated names so they
|
|
747
|
-
# appear in the status line.
|
|
748
|
-
def sync_engine_activated_memories
|
|
749
|
-
@engine.activated_memory_names.each do |name|
|
|
750
|
-
add_unique_memory_name(:@session_memory_names, name)
|
|
751
|
-
end
|
|
752
|
-
end
|
|
753
|
-
|
|
754
|
-
# Render a result from a turn that bypassed Engine#run_turn (continue,
|
|
755
|
-
# reminder turns) exactly as a :turn_completed would be rendered.
|
|
756
|
-
def emit_result(result)
|
|
757
|
-
@renderer.render_turn_summary(@engine.turn_summary(result))
|
|
758
|
-
end
|
|
759
|
-
|
|
760
|
-
# ── Turn view: the drawing surface EventRenderer calls ──────────────────
|
|
761
|
-
public
|
|
762
|
-
|
|
763
|
-
def emit_active_memories_line
|
|
764
|
-
lines = sticky_status_lines(width: status_effective_width)
|
|
765
|
-
return if lines.empty?
|
|
766
|
-
|
|
767
|
-
lines.each { |line| @surface.commit(line) }
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
def print_line(text)
|
|
771
|
-
@surface.commit(text)
|
|
772
|
-
end
|
|
773
|
-
|
|
774
|
-
def reset_turn_feedback
|
|
775
|
-
@spinner_lock.synchronize { @turn_tally.reset }
|
|
776
|
-
clear_retry_spinner_status
|
|
777
|
-
reset_thinking_memory_notification
|
|
778
|
-
reset_thinking_memory_names
|
|
779
|
-
reset_thinking_tool_notification
|
|
780
|
-
end
|
|
781
|
-
|
|
782
|
-
# A turn waits for plugins' slow setup (chi.init) before its first
|
|
783
|
-
# request: the spinner says what for, until the model starts.
|
|
784
|
-
def init_wait_feedback(event)
|
|
785
|
-
labels = Array(event[:tasks]).map { |task| "#{task[:bundle]}: #{task[:label]}" }
|
|
786
|
-
@spinner_lock.synchronize do
|
|
787
|
-
@init_wait_status = "chi> waiting for #{labels.join(" · ")}..."
|
|
788
|
-
start_thinking_spinner
|
|
789
|
-
refresh_thinking_spinner_status
|
|
790
|
-
end
|
|
791
|
-
end
|
|
792
|
-
|
|
793
|
-
def generation_feedback_started(event = {})
|
|
794
|
-
@init_wait_status = nil
|
|
795
|
-
@context_window_tokens = event[:context_window_tokens] if event[:context_window_tokens]
|
|
796
|
-
clear_retry_spinner_status
|
|
797
|
-
@latest_server_context_status = nil
|
|
798
|
-
@spinner_lock.synchronize do
|
|
799
|
-
@thinking_line.reset
|
|
800
|
-
start_thinking_spinner
|
|
801
|
-
end
|
|
802
|
-
end
|
|
803
|
-
|
|
804
|
-
def generation_feedback_retrying(event)
|
|
805
|
-
@spinner_lock.synchronize do
|
|
806
|
-
# The retry streams from the start: wait for its first token again.
|
|
807
|
-
@thinking_waiting_since = monotonic_time if @thinking_spinner_active
|
|
808
|
-
set_retry_spinner_status(event)
|
|
809
|
-
refresh_thinking_spinner_status
|
|
810
|
-
end
|
|
811
|
-
end
|
|
812
|
-
|
|
813
|
-
def generation_feedback_chunk(event)
|
|
814
|
-
@spinner_lock.synchronize do
|
|
815
|
-
@thinking_waiting_since = nil
|
|
816
|
-
clear_retry_spinner_status if retry_spinner_status_active?
|
|
817
|
-
capture_server_context_status_from_payload(event[:payload])
|
|
818
|
-
# A new sentence shows at once (the line changes at most once a dwell).
|
|
819
|
-
next refresh_thinking_spinner_status if @thinking_line.chunk(event)
|
|
820
|
-
|
|
821
|
-
tick_thinking_spinner
|
|
822
|
-
end
|
|
823
|
-
end
|
|
824
|
-
|
|
825
|
-
def tool_call_feedback_started(event)
|
|
826
|
-
@spinner_lock.synchronize do
|
|
827
|
-
@thinking_waiting_since = nil
|
|
828
|
-
clear_retry_spinner_status
|
|
829
|
-
@turn_tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
|
|
830
|
-
memory_loaded = capture_memory_tool_call(event)
|
|
831
|
-
capture_thinking_tool_call(event) if memory_loaded
|
|
832
|
-
refresh_thinking_spinner_status
|
|
833
|
-
end
|
|
834
|
-
end
|
|
835
|
-
|
|
836
|
-
def tool_call_feedback_completed(event)
|
|
837
|
-
@spinner_lock.synchronize do
|
|
838
|
-
@turn_tally.completed(key: tally_key(event), tool: event[:tool],
|
|
839
|
-
status: event.dig(:activity, :status), params: event.dig(:activity, :params))
|
|
840
|
-
end
|
|
841
|
-
end
|
|
842
|
-
|
|
843
|
-
def clear_generation_retry
|
|
844
|
-
@spinner_lock.synchronize { clear_retry_spinner_status }
|
|
845
|
-
end
|
|
846
|
-
|
|
847
|
-
def generation_feedback_finished
|
|
848
|
-
@spinner_lock.synchronize do
|
|
849
|
-
clear_retry_spinner_status
|
|
850
|
-
@thinking_line.reset
|
|
851
|
-
finish_thinking_spinner
|
|
852
|
-
end
|
|
853
|
-
end
|
|
854
|
-
|
|
855
|
-
# The kernel reports the last emitted context status on the result; keep
|
|
856
|
-
# the previous one when a turn reports none.
|
|
857
|
-
def capture_context_status(status)
|
|
858
|
-
return unless status
|
|
859
|
-
|
|
860
|
-
@latest_context_status = {
|
|
861
|
-
est_pct: status[:est_pct],
|
|
862
|
-
bucket: status[:bucket]
|
|
863
|
-
}
|
|
864
|
-
end
|
|
865
|
-
|
|
866
|
-
private
|
|
867
|
-
|
|
868
|
-
# The last turn's line: completed, canceled or failed, as the Engine
|
|
869
|
-
# closed it (a provider error closes it with :turn_failed).
|
|
870
|
-
def emit_interactive_turn_duration(canceled:)
|
|
871
|
-
record = Array(@engine.metrics.snapshot[:turn_records]).last
|
|
872
|
-
return unless record && record[:duration_ms]
|
|
873
|
-
|
|
874
|
-
state = record[:status] == "failed" ? "failed" : (canceled ? "canceled" : "completed")
|
|
875
|
-
@surface.commit("#{paint('chi>', 36)} turn #{state} (#{format_elapsed_duration(record[:duration_ms])})")
|
|
876
|
-
end
|
|
877
|
-
|
|
878
|
-
def split_memory_scope(raw)
|
|
879
|
-
value = raw.to_s.strip
|
|
880
|
-
if value.include?("/")
|
|
881
|
-
scope, name = value.split("/", 2)
|
|
882
|
-
return [scope, name] if Tools::VALID_SCOPES.include?(scope)
|
|
883
|
-
end
|
|
884
|
-
|
|
885
|
-
[nil, value]
|
|
886
|
-
end
|
|
673
|
+
# Engine's built-once system prompt (the one run_turn sends).
|
|
674
|
+
def seed_system_prompt = @engine.system_prompt
|
|
887
675
|
|
|
888
676
|
def read_input(awaiting_continue:)
|
|
889
|
-
|
|
677
|
+
refresh_status_row
|
|
890
678
|
|
|
891
679
|
if awaiting_continue
|
|
892
680
|
sync_continue_slot(true)
|
|
@@ -910,20 +698,24 @@ module Samagotchi
|
|
|
910
698
|
# empty synthetic turn then (it duplicated output).
|
|
911
699
|
return unless @engine.reminders_due?
|
|
912
700
|
|
|
701
|
+
# A pending continue offer goes, as for a new prompt (the worker's rule).
|
|
702
|
+
if @turn_flow.before_reminder_turn
|
|
703
|
+
sync_continue_slot(false)
|
|
704
|
+
@surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
|
|
705
|
+
end
|
|
706
|
+
@surface.commit("reminder: #{names.join(", ")}")
|
|
913
707
|
hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
|
|
914
708
|
@surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
|
|
915
709
|
result = nil
|
|
916
710
|
begin
|
|
917
711
|
result = run_engine_turn(session, nil, continue: true)
|
|
918
712
|
@prompt = nil
|
|
919
|
-
rescue LLM::ProviderError
|
|
920
|
-
#
|
|
921
|
-
@surface.commit("\nmodel> #{e.summary}")
|
|
713
|
+
rescue LLM::ProviderError
|
|
714
|
+
nil # the renderer showed the failure; back to the prompt
|
|
922
715
|
ensure
|
|
923
716
|
@surface.clear_slot(:hints)
|
|
924
717
|
end
|
|
925
718
|
canceled = result.respond_to?(:canceled?) && result.canceled?
|
|
926
|
-
emit_interactive_turn_duration(canceled: canceled)
|
|
927
719
|
if result && !canceled
|
|
928
720
|
session.messages = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
|
|
929
721
|
session.model_name = @effective_model_name
|
|
@@ -949,7 +741,7 @@ module Samagotchi
|
|
|
949
741
|
|
|
950
742
|
if @idle_status_due
|
|
951
743
|
@idle_status_due = false
|
|
952
|
-
|
|
744
|
+
refresh_status_row
|
|
953
745
|
end
|
|
954
746
|
sync_continue_slot(awaiting_continue)
|
|
955
747
|
@repl_input.sync_prompt
|
|
@@ -972,7 +764,7 @@ module Samagotchi
|
|
|
972
764
|
return unless STDIN.tty? && $stdin.tty?
|
|
973
765
|
|
|
974
766
|
@idle_status_due = false
|
|
975
|
-
|
|
767
|
+
refresh_status_row
|
|
976
768
|
@repl_input = ReplInput.new(prompt: method(:repl_prompt_text), read: method(:read_repl_line), surface: @surface).start
|
|
977
769
|
end
|
|
978
770
|
|
|
@@ -1013,6 +805,10 @@ module Samagotchi
|
|
|
1013
805
|
prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
|
|
1014
806
|
end
|
|
1015
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
|
+
|
|
1016
812
|
# A failed prompt goes back into the input for a retry.
|
|
1017
813
|
# @return [String] what the failed-turn line says about it
|
|
1018
814
|
def restore_prompt_for_retry(input)
|
|
@@ -1023,27 +819,20 @@ module Samagotchi
|
|
|
1023
819
|
@repl_input.prefill(input) ? "prompt restored for retry" : "the failed prompt is in the input history (↑)"
|
|
1024
820
|
end
|
|
1025
821
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
def stats_command?(input)
|
|
1031
|
-
input.to_s.strip == STATS_COMMAND
|
|
1032
|
-
end
|
|
1033
|
-
|
|
1034
|
-
def recap_command?(input)
|
|
1035
|
-
input.to_s.strip == RECAP_COMMAND
|
|
1036
|
-
end
|
|
1037
|
-
|
|
1038
|
-
# SessionCommands runs /model; the REPL mirrors the result.
|
|
1039
|
-
def handle_model_command(input)
|
|
1040
|
-
result = @commands.run(input)
|
|
1041
|
-
sync_model_mirrors
|
|
1042
|
-
result.output
|
|
1043
|
-
end
|
|
822
|
+
# The id of the terminal's own command +input+ is (:exit, :archive,
|
|
823
|
+
# :detach, :stats, :recap; SessionCommands registers them), or nil.
|
|
824
|
+
def local_command(input) = command_registry.lookup_local(input)&.id
|
|
1044
825
|
|
|
1045
|
-
|
|
1046
|
-
|
|
826
|
+
# /stats and /recap, which show and change nothing (at the prompt, a
|
|
827
|
+
# continue offer's ? prompt, or during a turn).
|
|
828
|
+
# @return [Boolean] whether +input+ was one
|
|
829
|
+
def show_local_command(input)
|
|
830
|
+
case local_command(input)
|
|
831
|
+
when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
|
|
832
|
+
when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
|
|
833
|
+
else return false
|
|
834
|
+
end
|
|
835
|
+
true
|
|
1047
836
|
end
|
|
1048
837
|
|
|
1049
838
|
# /recap: the saved recap, and a new one asked for at once when the
|
|
@@ -1060,31 +849,10 @@ module Samagotchi
|
|
|
1060
849
|
# A resumed session doesn't get the default input either.
|
|
1061
850
|
def default_input_wanted? = !@resume_session && !@no_default_input
|
|
1062
851
|
|
|
1063
|
-
# exit or /exit, with --delete to delete the session on the way out.
|
|
1064
|
-
def exit_command?(input)
|
|
1065
|
-
words = input.to_s.strip.downcase.split
|
|
1066
|
-
%w[exit /exit].include?(words.first) && (words.size == 1 || words == [words.first, EXIT_DELETE_FLAG])
|
|
1067
|
-
end
|
|
1068
|
-
|
|
1069
|
-
def delete_on_exit?(input) = input.to_s.strip.downcase.split.last == EXIT_DELETE_FLAG
|
|
1070
|
-
|
|
1071
|
-
def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
|
|
1072
|
-
|
|
1073
|
-
def archive_command?(input) = input.to_s.strip.casecmp?(ARCHIVE_COMMAND)
|
|
1074
|
-
|
|
1075
852
|
def clone_messages(messages)
|
|
1076
853
|
Array(messages).map(&:dup)
|
|
1077
854
|
end
|
|
1078
855
|
|
|
1079
|
-
def skip_agent_description?
|
|
1080
|
-
value = ENV[SKIP_AGENT_DESCRIPTION_ENV]
|
|
1081
|
-
value == "1" || value&.casecmp?("true")
|
|
1082
|
-
end
|
|
1083
|
-
|
|
1084
|
-
def rg_available?
|
|
1085
|
-
system("command -v rg", out: File::NULL, err: File::NULL)
|
|
1086
|
-
end
|
|
1087
|
-
|
|
1088
856
|
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
1089
857
|
# Engine#run_turn, rendering via @renderer.
|
|
1090
858
|
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
@@ -1092,12 +860,11 @@ module Samagotchi
|
|
|
1092
860
|
cancellation_controller = CancellationController.new
|
|
1093
861
|
@active_cancel_controller = cancellation_controller
|
|
1094
862
|
@renderer.begin_turn
|
|
1095
|
-
#
|
|
1096
|
-
|
|
1097
|
-
seed_system_prompt
|
|
863
|
+
# The row shows the turn's model (a -p turn runs before any read).
|
|
864
|
+
refresh_status_row
|
|
1098
865
|
# A prompt the user typed brings an archived session back to the lists.
|
|
1099
866
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
1100
|
-
|
|
867
|
+
with_steering do
|
|
1101
868
|
@engine.run_turn(
|
|
1102
869
|
session,
|
|
1103
870
|
prompt,
|
|
@@ -1109,17 +876,14 @@ module Samagotchi
|
|
|
1109
876
|
images: images
|
|
1110
877
|
)
|
|
1111
878
|
end
|
|
1112
|
-
emit_cancellation_notice(result)
|
|
1113
|
-
result
|
|
1114
879
|
rescue Interrupt
|
|
1115
|
-
# 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).
|
|
1116
882
|
cancellation_controller&.cancel!(:ctrl_c)
|
|
1117
|
-
|
|
1118
|
-
emit_cancellation_notice(result)
|
|
1119
|
-
result
|
|
883
|
+
cancelled_result_from(session.messages, reason: :ctrl_c)
|
|
1120
884
|
ensure
|
|
1121
885
|
@active_cancel_controller = nil
|
|
1122
|
-
finish_thinking_spinner
|
|
886
|
+
@view.finish_thinking_spinner
|
|
1123
887
|
end
|
|
1124
888
|
|
|
1125
889
|
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
@@ -1139,11 +903,13 @@ module Samagotchi
|
|
|
1139
903
|
# in the prompt.
|
|
1140
904
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1141
905
|
def steer_line(line)
|
|
1142
|
-
|
|
1143
|
-
return
|
|
906
|
+
local = local_command(line) unless line.nil?
|
|
907
|
+
return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
|
|
908
|
+
return detach_note if local == :detach
|
|
1144
909
|
return false if @active_cancel_controller&.cancelled?
|
|
1145
910
|
return start_anytime_command(line) if command_registry.lookup(line)&.anytime
|
|
1146
|
-
|
|
911
|
+
# A command, never steering text (/archive waits, back in the prompt).
|
|
912
|
+
return command_during_turn(line) if local || command_registry.command?(line)
|
|
1147
913
|
return true if line.strip.empty?
|
|
1148
914
|
# Steering merges text only: a line with images runs as the next turn.
|
|
1149
915
|
return image_line_waits if ImageInput.extract(line).any?
|
|
@@ -1177,15 +943,10 @@ module Samagotchi
|
|
|
1177
943
|
|
|
1178
944
|
# @return [true, :back]
|
|
1179
945
|
def command_during_turn(line)
|
|
1180
|
-
if
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
else
|
|
1185
|
-
@surface.commit(COMMAND_BUSY)
|
|
1186
|
-
return :back
|
|
1187
|
-
end
|
|
1188
|
-
true
|
|
946
|
+
return true if show_local_command(line)
|
|
947
|
+
|
|
948
|
+
@surface.commit(COMMAND_BUSY)
|
|
949
|
+
:back
|
|
1189
950
|
end
|
|
1190
951
|
|
|
1191
952
|
# D8: an anytime command runs on its own thread while the turn goes on
|
|
@@ -1205,35 +966,32 @@ module Samagotchi
|
|
|
1205
966
|
true
|
|
1206
967
|
end
|
|
1207
968
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
end
|
|
1211
|
-
|
|
1212
|
-
# The kernel's drain at an iteration boundary: queued lines, #memory
|
|
1213
|
-
# shorthand made words as for a prompt, and saved in the history.
|
|
969
|
+
# The kernel's drain at an iteration boundary: queued lines, sent as
|
|
970
|
+
# typed and saved in the history.
|
|
1214
971
|
def drain_steering
|
|
1215
972
|
@pending_input_queue.drain.map do |line|
|
|
1216
973
|
persist_recent_history(line)
|
|
1217
|
-
|
|
974
|
+
line
|
|
1218
975
|
end
|
|
1219
976
|
end
|
|
1220
977
|
|
|
1221
|
-
# The REPL's on_event sink for Engine#run_turn: render one event
|
|
1222
|
-
#
|
|
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.
|
|
1223
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
|
|
1224
990
|
@renderer.call(event)
|
|
1225
991
|
rescue StandardError => e
|
|
1226
992
|
Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
|
|
1227
993
|
end
|
|
1228
994
|
|
|
1229
|
-
def emit_cancellation_notice(result)
|
|
1230
|
-
return unless result.respond_to?(:canceled?) && result.canceled?
|
|
1231
|
-
|
|
1232
|
-
reason = result.respond_to?(:cancellation_reason) ? result.cancellation_reason : nil
|
|
1233
|
-
label = cancellation_reason_label(reason)
|
|
1234
|
-
@surface.commit("\nmodel> request cancelled#{label.empty? ? "" : " (#{label})"}")
|
|
1235
|
-
end
|
|
1236
|
-
|
|
1237
995
|
def cancelled_result_from(messages, reason:)
|
|
1238
996
|
KernelLoop::Result.new(
|
|
1239
997
|
output: "",
|
|
@@ -1246,291 +1004,16 @@ module Samagotchi
|
|
|
1246
1004
|
)
|
|
1247
1005
|
end
|
|
1248
1006
|
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
def start_thinking_spinner
|
|
1261
|
-
return unless thinking_spinner_enabled?
|
|
1262
|
-
|
|
1263
|
-
@thinking_spinner_active = true
|
|
1264
|
-
@thinking_spinner_index = 0 if @thinking_spinner_index.nil?
|
|
1265
|
-
@thinking_spinner_last_render_at = nil
|
|
1266
|
-
@thinking_waiting_since = monotonic_time
|
|
1267
|
-
render_thinking_spinner
|
|
1268
|
-
start_thinking_ticker
|
|
1269
|
-
end
|
|
1270
|
-
|
|
1271
|
-
# One thread per spinner; it ends when the spinner does.
|
|
1272
|
-
def start_thinking_ticker
|
|
1273
|
-
return unless @spinner_tick_interval
|
|
1274
|
-
return if @thinking_ticker&.alive?
|
|
1275
|
-
|
|
1276
|
-
@thinking_ticker = Thread.new do
|
|
1277
|
-
loop do
|
|
1278
|
-
sleep(@spinner_tick_interval)
|
|
1279
|
-
break unless tick_thinking_spinner_on_timer
|
|
1280
|
-
end
|
|
1281
|
-
rescue StandardError
|
|
1282
|
-
nil
|
|
1283
|
-
end
|
|
1284
|
-
@thinking_ticker.report_on_exception = false
|
|
1285
|
-
end
|
|
1286
|
-
|
|
1287
|
-
# Turn the spinner when no chunk did for a tick.
|
|
1288
|
-
# @return [Boolean] whether the spinner is still shown
|
|
1289
|
-
def tick_thinking_spinner_on_timer
|
|
1290
|
-
@spinner_lock.synchronize do
|
|
1291
|
-
return false unless @thinking_spinner_active
|
|
1292
|
-
|
|
1293
|
-
last = @thinking_spinner_last_render_at
|
|
1294
|
-
if last.nil? || (monotonic_time - last) >= @spinner_tick_interval
|
|
1295
|
-
@thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
|
|
1296
|
-
render_thinking_spinner
|
|
1297
|
-
end
|
|
1298
|
-
true
|
|
1299
|
-
end
|
|
1300
|
-
end
|
|
1301
|
-
|
|
1302
|
-
def tick_thinking_spinner
|
|
1303
|
-
return unless @thinking_spinner_active
|
|
1304
|
-
|
|
1305
|
-
@thinking_spinner_index = (@thinking_spinner_index + 1) % THINKING_SPINNER_FRAMES.length
|
|
1306
|
-
render_thinking_spinner_if_due
|
|
1307
|
-
end
|
|
1308
|
-
|
|
1309
|
-
def refresh_thinking_spinner_status
|
|
1310
|
-
return unless @thinking_spinner_active
|
|
1311
|
-
|
|
1312
|
-
render_thinking_spinner
|
|
1313
|
-
end
|
|
1314
|
-
|
|
1315
|
-
def render_thinking_spinner_if_due
|
|
1316
|
-
last = @thinking_spinner_last_render_at
|
|
1317
|
-
return render_thinking_spinner if last.nil?
|
|
1318
|
-
return if (monotonic_time - last) < thinking_render_min_interval
|
|
1319
|
-
|
|
1320
|
-
render_thinking_spinner
|
|
1321
|
-
end
|
|
1322
|
-
|
|
1323
|
-
public
|
|
1324
|
-
|
|
1325
|
-
# Erase the spinner rows; nothing to do when none are shown.
|
|
1326
|
-
def finish_thinking_spinner
|
|
1327
|
-
@spinner_lock.synchronize do
|
|
1328
|
-
# Off even when the rows are gone already, so the ticker ends.
|
|
1329
|
-
@thinking_spinner_active = false
|
|
1330
|
-
@init_wait_status = nil
|
|
1331
|
-
@thinking_waiting_since = nil
|
|
1332
|
-
next unless @surface.clear_slot(:activity)
|
|
1333
|
-
|
|
1334
|
-
@thinking_spinner_last_render_at = nil
|
|
1335
|
-
end
|
|
1336
|
-
end
|
|
1337
|
-
|
|
1338
|
-
private
|
|
1339
|
-
|
|
1340
|
-
def retry_spinner_status_line(frame, width)
|
|
1341
|
-
data = @retry_spinner_status || {}
|
|
1342
|
-
attempt = data[:attempt].to_i
|
|
1343
|
-
max_retries = data[:max_retries].to_i
|
|
1344
|
-
total_attempts = max_retries + 1
|
|
1345
|
-
delay = format("%.1f", data[:next_delay].to_f)
|
|
1346
|
-
error_class = data[:error_class].to_s
|
|
1347
|
-
message = "model> network error: retrying (#{attempt}/#{total_attempts} in #{delay}s) #{frame}"
|
|
1348
|
-
message += " #{error_class}" unless error_class.empty?
|
|
1349
|
-
capped = cap_preview_line(message, width)
|
|
1350
|
-
color_output? ? paint(capped, NETWORK_RETRY_SPINNER_COLOR) : capped
|
|
1351
|
-
end
|
|
1352
|
-
|
|
1353
|
-
def cap_preview_line(text, width)
|
|
1354
|
-
cap_preview_text(text, thinking_preview_width(width))
|
|
1355
|
-
end
|
|
1356
|
-
|
|
1357
|
-
def thinking_preview_width(width)
|
|
1358
|
-
return THINKING_PREVIEW_WIDTH if width <= 0
|
|
1359
|
-
|
|
1360
|
-
[width, THINKING_PREVIEW_WIDTH].min
|
|
1361
|
-
end
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
def thinking_render_min_interval
|
|
1365
|
-
value = ENV.fetch(THINKING_RENDER_INTERVAL_ENV, THINKING_RENDER_MIN_INTERVAL.to_s).to_f
|
|
1366
|
-
return THINKING_RENDER_MIN_INTERVAL unless value.positive?
|
|
1367
|
-
|
|
1368
|
-
value
|
|
1369
|
-
end
|
|
1370
|
-
|
|
1371
|
-
def monotonic_time
|
|
1372
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1373
|
-
end
|
|
1374
|
-
|
|
1375
|
-
def capture_memory_tool_call(event)
|
|
1376
|
-
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
1377
|
-
memory_name = memory_name_from_tool_call(call)
|
|
1378
|
-
return false if memory_name.nil? || memory_name.empty?
|
|
1379
|
-
|
|
1380
|
-
added_to_thinking = add_unique_memory_name(:@thinking_memory_names, memory_name)
|
|
1381
|
-
add_unique_memory_name(:@session_memory_names, memory_name)
|
|
1382
|
-
@thinking_recent_memory_loaded = memory_name if added_to_thinking
|
|
1383
|
-
added_to_thinking
|
|
1384
|
-
end
|
|
1385
|
-
|
|
1386
|
-
def capture_thinking_tool_call(event)
|
|
1387
|
-
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
1388
|
-
name = call[:name].to_s.strip
|
|
1389
|
-
return if name.empty?
|
|
1390
|
-
|
|
1391
|
-
params = thinking_tool_params_preview(call, event[:params])
|
|
1392
|
-
text = params.empty? ? name : "#{name}(#{params})"
|
|
1393
|
-
@thinking_recent_tool_call = cap_preview_text(text, THINKING_TOOL_PREVIEW_LIMIT)
|
|
1394
|
-
end
|
|
1395
|
-
|
|
1396
|
-
def thinking_tool_params_preview(call, raw_params)
|
|
1397
|
-
compact = raw_params.to_s.gsub(/\s+/, " ").strip
|
|
1398
|
-
return compact unless compact.empty?
|
|
1399
|
-
|
|
1400
|
-
tool_name = call[:name].to_s
|
|
1401
|
-
case tool_name
|
|
1402
|
-
when Tools::Execute::NAME
|
|
1403
|
-
"command=#{preview_value_for_spinner(call[:content])}"
|
|
1404
|
-
when Tools::Read::NAME
|
|
1405
|
-
"path=#{preview_value_for_spinner(call[:content])}"
|
|
1406
|
-
when Tools::Write::NAME, Tools::Edit::NAME
|
|
1407
|
-
"path=#{preview_value_for_spinner(call[:path])}"
|
|
1408
|
-
when Tools::MemoryRead::NAME
|
|
1409
|
-
parts = []
|
|
1410
|
-
name = call[:content].to_s.strip
|
|
1411
|
-
parts << "name=#{preview_value_for_spinner(name)}" unless name.empty?
|
|
1412
|
-
scope = call[:scope].to_s.strip
|
|
1413
|
-
parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
|
|
1414
|
-
parts.join(" ")
|
|
1415
|
-
when Tools::MemoryWrite::NAME
|
|
1416
|
-
parts = []
|
|
1417
|
-
path = call[:path].to_s.strip
|
|
1418
|
-
parts << "name=#{preview_value_for_spinner(path)}" unless path.empty?
|
|
1419
|
-
scope = call[:scope].to_s.strip
|
|
1420
|
-
parts << "scope=#{preview_value_for_spinner(scope)}" unless scope.empty?
|
|
1421
|
-
parts.join(" ")
|
|
1422
|
-
else
|
|
1423
|
-
""
|
|
1424
|
-
end
|
|
1425
|
-
end
|
|
1426
|
-
|
|
1427
|
-
def preview_value_for_spinner(value)
|
|
1428
|
-
text = value.to_s.gsub(/\s+/, " ").strip
|
|
1429
|
-
return '""' if text.empty?
|
|
1430
|
-
|
|
1431
|
-
text.inspect
|
|
1432
|
-
end
|
|
1433
|
-
|
|
1434
|
-
def add_unique_memory_name(ivar_name, value)
|
|
1435
|
-
names = instance_variable_get(ivar_name) || []
|
|
1436
|
-
return false if names.include?(value)
|
|
1437
|
-
|
|
1438
|
-
names << value
|
|
1439
|
-
instance_variable_set(ivar_name, names)
|
|
1440
|
-
true
|
|
1441
|
-
end
|
|
1442
|
-
|
|
1443
|
-
def memory_name_from_tool_call(call)
|
|
1444
|
-
tool_name = call[:name].to_s
|
|
1445
|
-
case tool_name
|
|
1446
|
-
when Tools::MemoryRead::NAME
|
|
1447
|
-
normalize_memory_name(call[:content])
|
|
1448
|
-
when Tools::Read::NAME
|
|
1449
|
-
memory_name_from_read_path(call[:content])
|
|
1450
|
-
else
|
|
1451
|
-
nil
|
|
1452
|
-
end
|
|
1453
|
-
end
|
|
1454
|
-
|
|
1455
|
-
def normalize_memory_name(raw)
|
|
1456
|
-
value = raw.to_s.strip
|
|
1457
|
-
return nil if value.empty?
|
|
1458
|
-
|
|
1459
|
-
File.basename(value, ".md")
|
|
1460
|
-
end
|
|
1461
|
-
|
|
1462
|
-
def memory_name_from_read_path(raw_path)
|
|
1463
|
-
path = raw_path.to_s.strip.tr("\\", "/")
|
|
1464
|
-
return nil if path.empty?
|
|
1465
|
-
return nil unless path.match?(/memories[\/].+\.md\z/)
|
|
1466
|
-
|
|
1467
|
-
normalize_memory_name(path)
|
|
1468
|
-
end
|
|
1469
|
-
|
|
1470
|
-
def memory_spinner_segment
|
|
1471
|
-
segment = memory_spinner_segment_plain
|
|
1472
|
-
return "" if segment.empty?
|
|
1473
|
-
|
|
1474
|
-
color_output? ? paint(segment, MEMORY_SPINNER_COLOR) : segment
|
|
1475
|
-
end
|
|
1476
|
-
|
|
1477
|
-
def memory_spinner_segment_plain
|
|
1478
|
-
names = Array(@thinking_memory_names)
|
|
1479
|
-
return "" if names.empty?
|
|
1480
|
-
|
|
1481
|
-
visible = names.first(MEMORY_SPINNER_PREVIEW_LIMIT)
|
|
1482
|
-
suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
|
|
1483
|
-
" mem: #{visible.join(', ')}#{suffix}"
|
|
1484
|
-
end
|
|
1485
|
-
|
|
1486
|
-
def memory_sticky_line
|
|
1487
|
-
names = Array(@session_memory_names)
|
|
1488
|
-
muted = @engine.muted_memory_names
|
|
1489
|
-
return "" if names.empty? && muted.empty?
|
|
1490
|
-
|
|
1491
|
-
parts = []
|
|
1492
|
-
parts << "active this session: #{preview_names(names)}" unless names.empty?
|
|
1493
|
-
parts << "muted: #{preview_names(muted)}" unless muted.empty?
|
|
1494
|
-
body = "memories> #{parts.join(' · ')}"
|
|
1495
|
-
color_output? ? paint(body, MEMORY_SPINNER_COLOR) : body
|
|
1496
|
-
end
|
|
1497
|
-
|
|
1498
|
-
def preview_names(names, limit: MEMORY_STICKY_PREVIEW_LIMIT)
|
|
1499
|
-
visible = names.first(limit)
|
|
1500
|
-
suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
|
|
1501
|
-
"#{visible.join(', ')}#{suffix}"
|
|
1502
|
-
end
|
|
1503
|
-
|
|
1504
|
-
def reset_thinking_memory_names
|
|
1505
|
-
@thinking_memory_names = []
|
|
1506
|
-
end
|
|
1507
|
-
|
|
1508
|
-
def reset_thinking_memory_notification
|
|
1509
|
-
@thinking_recent_memory_loaded = nil
|
|
1510
|
-
end
|
|
1511
|
-
|
|
1512
|
-
def reset_thinking_tool_notification
|
|
1513
|
-
@thinking_recent_tool_call = nil
|
|
1514
|
-
end
|
|
1515
|
-
|
|
1516
|
-
def capture_server_context_status_from_payload(payload)
|
|
1517
|
-
# The window the kernel resolved for this generation (see
|
|
1518
|
-
# :generation_started); the configured one before the first generation.
|
|
1519
|
-
window_tokens = @context_window_tokens || ContextWindow.configured.tokens
|
|
1520
|
-
normalized = ContextUsage.normalize(payload, window_tokens: window_tokens)
|
|
1521
|
-
return unless normalized
|
|
1522
|
-
|
|
1523
|
-
@latest_server_context_status = normalized
|
|
1524
|
-
end
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
def emit_idle_status_line
|
|
1528
|
-
return unless status_line_enabled?
|
|
1529
|
-
|
|
1530
|
-
lines = idle_status_lines(width: status_effective_width)
|
|
1531
|
-
return if lines.empty?
|
|
1532
|
-
|
|
1533
|
-
@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)
|
|
1534
1017
|
end
|
|
1535
1018
|
|
|
1536
1019
|
# ── Idle session recap ───────────────────────────────────────────────────
|
|
@@ -1567,13 +1050,17 @@ module Samagotchi
|
|
|
1567
1050
|
# main thread (the command runs at the prompt) or beside a running turn
|
|
1568
1051
|
# (above the live region), else at the next flush.
|
|
1569
1052
|
#
|
|
1570
|
-
# A plugin's init task (chi.init)
|
|
1571
|
-
#
|
|
1572
|
-
#
|
|
1573
|
-
# 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.
|
|
1574
1058
|
def handle_card_event(event)
|
|
1575
1059
|
if INIT_EVENTS.include?(event[:type])
|
|
1576
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
|
|
1577
1064
|
return show_pending_item(event) if @engine.turn_running?
|
|
1578
1065
|
|
|
1579
1066
|
return @pending_cards << event
|
|
@@ -1601,13 +1088,13 @@ module Samagotchi
|
|
|
1601
1088
|
INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
|
|
1602
1089
|
|
|
1603
1090
|
# A card, a notice, an anytime command's output (:command_output), a
|
|
1604
|
-
# plugin init task's line or a load warning.
|
|
1091
|
+
# plugin init task's done line or a load warning.
|
|
1605
1092
|
def show_pending_item(item)
|
|
1606
1093
|
case item[:type]
|
|
1607
1094
|
when :card then @renderer.render_card(item)
|
|
1608
1095
|
when :command_output then @surface.commit(item[:text])
|
|
1609
1096
|
when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
|
|
1610
|
-
when :
|
|
1097
|
+
when :plugin_init_finished
|
|
1611
1098
|
line = EventRenderer.init_line(item)
|
|
1612
1099
|
@surface.commit(line) if line
|
|
1613
1100
|
else @surface.commit(EventRenderer.hook_notice_line(item))
|
|
@@ -1622,188 +1109,6 @@ module Samagotchi
|
|
|
1622
1109
|
ConfigFile.recap_config
|
|
1623
1110
|
end
|
|
1624
1111
|
|
|
1625
|
-
def bare_model_for(full_ref)
|
|
1626
|
-
@host_registry.bare_name(full_ref)
|
|
1627
|
-
end
|
|
1628
|
-
|
|
1629
|
-
def spinner_status_line
|
|
1630
|
-
return "" unless status_line_enabled?
|
|
1631
|
-
|
|
1632
|
-
lines = spinner_status_lines
|
|
1633
|
-
lines.empty? ? "" : lines.first
|
|
1634
|
-
end
|
|
1635
|
-
|
|
1636
|
-
def spinner_status_lines(width: status_effective_width)
|
|
1637
|
-
return [] unless status_line_enabled?
|
|
1638
|
-
|
|
1639
|
-
build_status_lines(scope: :spinner, width: width)
|
|
1640
|
-
end
|
|
1641
|
-
|
|
1642
|
-
def sticky_status_line
|
|
1643
|
-
return "" unless status_line_enabled?
|
|
1644
|
-
|
|
1645
|
-
lines = sticky_status_lines
|
|
1646
|
-
lines.empty? ? "" : lines.first
|
|
1647
|
-
end
|
|
1648
|
-
|
|
1649
|
-
def sticky_status_lines(width: status_effective_width)
|
|
1650
|
-
return [] unless status_line_enabled?
|
|
1651
|
-
|
|
1652
|
-
build_status_lines(scope: :sticky, width: width)
|
|
1653
|
-
end
|
|
1654
|
-
|
|
1655
|
-
def idle_status_line
|
|
1656
|
-
return "" unless status_line_enabled?
|
|
1657
|
-
|
|
1658
|
-
lines = idle_status_lines
|
|
1659
|
-
lines.empty? ? "" : lines.first
|
|
1660
|
-
end
|
|
1661
|
-
|
|
1662
|
-
def idle_status_lines(width: status_effective_width)
|
|
1663
|
-
return [] unless status_line_enabled?
|
|
1664
|
-
|
|
1665
|
-
build_status_lines(scope: :idle, width: width)
|
|
1666
|
-
end
|
|
1667
|
-
|
|
1668
|
-
def build_status_line(scope:)
|
|
1669
|
-
lines = build_status_lines(scope: scope)
|
|
1670
|
-
lines.empty? ? "" : lines.first
|
|
1671
|
-
end
|
|
1672
|
-
|
|
1673
|
-
# The status rows for +scope+ (:spinner, :sticky or :idle), cut to +width+.
|
|
1674
|
-
def build_status_lines(scope:, width: status_effective_width)
|
|
1675
|
-
status_rows(status_segments(scope), width)
|
|
1676
|
-
end
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
def status_width_mode
|
|
1680
|
-
mode = ENV.fetch(STATUS_WIDTH_MODE_ENV, STATUS_WIDTH_MODE_TERMINAL_CAP).to_s.strip.downcase
|
|
1681
|
-
return STATUS_WIDTH_MODE_FIXED if mode == STATUS_WIDTH_MODE_FIXED
|
|
1682
|
-
|
|
1683
|
-
STATUS_WIDTH_MODE_TERMINAL_CAP
|
|
1684
|
-
end
|
|
1685
|
-
|
|
1686
|
-
def status_effective_width
|
|
1687
|
-
mode = status_width_mode
|
|
1688
|
-
width = if mode == STATUS_WIDTH_MODE_FIXED
|
|
1689
|
-
status_fixed_width
|
|
1690
|
-
else
|
|
1691
|
-
[terminal_columns, status_max_width].min
|
|
1692
|
-
end
|
|
1693
|
-
width = status_fixed_width unless width.positive?
|
|
1694
|
-
width
|
|
1695
|
-
end
|
|
1696
|
-
|
|
1697
|
-
def status_fixed_width
|
|
1698
|
-
env_positive_int(STATUS_FIXED_WIDTH_ENV, THINKING_PREVIEW_WIDTH)
|
|
1699
|
-
end
|
|
1700
|
-
|
|
1701
|
-
def status_max_width
|
|
1702
|
-
env_positive_int(STATUS_MAX_WIDTH_ENV, STATUS_MAX_WIDTH_DEFAULT)
|
|
1703
|
-
end
|
|
1704
|
-
|
|
1705
|
-
def terminal_columns
|
|
1706
|
-
columns = begin
|
|
1707
|
-
io = IO.console
|
|
1708
|
-
io&.winsize&.[](1).to_i
|
|
1709
|
-
rescue StandardError
|
|
1710
|
-
0
|
|
1711
|
-
end
|
|
1712
|
-
return columns if columns.positive?
|
|
1713
|
-
|
|
1714
|
-
env_positive_int("COLUMNS", status_max_width)
|
|
1715
|
-
end
|
|
1716
|
-
|
|
1717
|
-
def env_positive_int(key, default)
|
|
1718
|
-
value = ENV.fetch(key, default.to_s).to_i
|
|
1719
|
-
value.positive? ? value : default
|
|
1720
|
-
end
|
|
1721
|
-
|
|
1722
|
-
def status_segments(scope)
|
|
1723
|
-
segments = [status_model_segment, status_server_segment].reject(&:empty?)
|
|
1724
|
-
context_segment = status_context_segment
|
|
1725
|
-
memory_segment = status_memory_segment(scope)
|
|
1726
|
-
segments << context_segment unless context_segment.empty?
|
|
1727
|
-
segments << memory_segment unless memory_segment.empty?
|
|
1728
|
-
# The session's --mute list, on the sticky and idle rows (not the spinner).
|
|
1729
|
-
muted_segment = scope == :spinner ? "" : status_memory_text(@engine.muted_memory_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")
|
|
1730
|
-
segments << muted_segment unless muted_segment.empty?
|
|
1731
|
-
segments
|
|
1732
|
-
end
|
|
1733
|
-
|
|
1734
|
-
def status_model_segment
|
|
1735
|
-
served, served_for = @engine.respond_to?(:served_model) ? @engine.served_model(probe: false) : nil
|
|
1736
|
-
status_model_text(@effective_model_name, @default_model_name, served: served, served_for: served_for)
|
|
1737
|
-
end
|
|
1738
|
-
|
|
1739
|
-
def status_context_segment
|
|
1740
|
-
status_context_text(server: @latest_server_context_status, estimate: @latest_context_status)
|
|
1741
|
-
end
|
|
1742
|
-
|
|
1743
|
-
def status_memory_segment(scope)
|
|
1744
|
-
names, limit = case scope
|
|
1745
|
-
when :spinner
|
|
1746
|
-
[Array(@thinking_memory_names), MEMORY_SPINNER_PREVIEW_LIMIT]
|
|
1747
|
-
else
|
|
1748
|
-
[Array(@session_memory_names), MEMORY_STICKY_PREVIEW_LIMIT]
|
|
1749
|
-
end
|
|
1750
|
-
status_memory_text(names, limit)
|
|
1751
|
-
end
|
|
1752
|
-
|
|
1753
|
-
def thinking_memory_notification_suffix
|
|
1754
|
-
memory_name = @thinking_recent_memory_loaded.to_s.strip
|
|
1755
|
-
return "" if memory_name.empty?
|
|
1756
|
-
|
|
1757
|
-
" memory_loaded: #{memory_name}"
|
|
1758
|
-
end
|
|
1759
|
-
|
|
1760
|
-
def thinking_tool_notification_suffix
|
|
1761
|
-
tool_call = @thinking_recent_tool_call.to_s.strip
|
|
1762
|
-
return "" if tool_call.empty?
|
|
1763
|
-
|
|
1764
|
-
" last_tool: #{tool_call}"
|
|
1765
|
-
end
|
|
1766
|
-
|
|
1767
|
-
def thinking_notification_segments(width)
|
|
1768
|
-
return ["", ""] if width <= 0
|
|
1769
|
-
|
|
1770
|
-
memory_suffix = cap_preview_text(thinking_memory_notification_suffix, width)
|
|
1771
|
-
remaining = [width - memory_suffix.length, 0].max
|
|
1772
|
-
tool_suffix = cap_preview_text(thinking_tool_notification_suffix, remaining)
|
|
1773
|
-
[memory_suffix, tool_suffix]
|
|
1774
|
-
end
|
|
1775
|
-
|
|
1776
|
-
def paint_if_present(text, code)
|
|
1777
|
-
return "" if text.to_s.empty?
|
|
1778
|
-
|
|
1779
|
-
paint(text, code)
|
|
1780
|
-
end
|
|
1781
|
-
|
|
1782
|
-
def thinking_spinner_enabled?
|
|
1783
|
-
return false unless $stdout.tty?
|
|
1784
|
-
|
|
1785
|
-
mode = ENV.fetch(THINKING_UI_ENV, THINKING_UI_SPINNER).to_s.strip.downcase
|
|
1786
|
-
return false if mode.empty? || mode == THINKING_UI_OFF || mode == "false" || mode == "0"
|
|
1787
|
-
|
|
1788
|
-
mode == THINKING_UI_SPINNER && ENV.fetch("TERM", "") != "dumb"
|
|
1789
|
-
end
|
|
1790
|
-
|
|
1791
|
-
def set_retry_spinner_status(event)
|
|
1792
|
-
@retry_spinner_status = {
|
|
1793
|
-
attempt: event[:attempt],
|
|
1794
|
-
max_retries: event[:max_retries],
|
|
1795
|
-
next_delay: event[:next_delay],
|
|
1796
|
-
error_class: event[:error_class]
|
|
1797
|
-
}
|
|
1798
|
-
end
|
|
1799
|
-
|
|
1800
|
-
def clear_retry_spinner_status
|
|
1801
|
-
@retry_spinner_status = nil
|
|
1802
|
-
end
|
|
1803
|
-
|
|
1804
|
-
def retry_spinner_status_active?
|
|
1805
|
-
@retry_spinner_status.is_a?(Hash)
|
|
1806
|
-
end
|
|
1807
1112
|
|
|
1808
1113
|
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1809
1114
|
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
@@ -1825,79 +1130,6 @@ module Samagotchi
|
|
|
1825
1130
|
RelineSeam.key_handler = previous_key_handler
|
|
1826
1131
|
end
|
|
1827
1132
|
|
|
1828
|
-
def render_thinking_spinner
|
|
1829
|
-
frame = THINKING_SPINNER_FRAMES[@thinking_spinner_index % THINKING_SPINNER_FRAMES.length]
|
|
1830
|
-
width = status_effective_width
|
|
1831
|
-
@thinking_line.tick
|
|
1832
|
-
# The spinner goes above the prompt, the status rows below it.
|
|
1833
|
-
@surface.set_slots(activity: thinking_spinner_status_lines(frame, width: width), status: spinner_status_lines(width: width))
|
|
1834
|
-
@thinking_spinner_last_render_at = monotonic_time
|
|
1835
|
-
end
|
|
1836
|
-
|
|
1837
|
-
# "model> waiting for the first token... 5s |" once a generation has
|
|
1838
|
-
# shown nothing for THINKING_WAIT_NOTICE_AFTER seconds, else nil.
|
|
1839
|
-
def first_token_wait_status(frame)
|
|
1840
|
-
return nil unless @thinking_waiting_since
|
|
1841
|
-
|
|
1842
|
-
waited = monotonic_time - @thinking_waiting_since
|
|
1843
|
-
return nil if waited < THINKING_WAIT_NOTICE_AFTER
|
|
1844
|
-
|
|
1845
|
-
"model> waiting for the first token... #{waited.floor}s #{frame}"
|
|
1846
|
-
end
|
|
1847
|
-
|
|
1848
|
-
def thinking_spinner_status_line(frame)
|
|
1849
|
-
lines = thinking_spinner_status_lines(frame)
|
|
1850
|
-
lines.empty? ? "" : lines.first
|
|
1851
|
-
end
|
|
1852
|
-
|
|
1853
|
-
# The spinner row, then (from a turn's 3rd tool call) its tool tally.
|
|
1854
|
-
# The spinner stops while tools run, so the tally shows while the model
|
|
1855
|
-
# generates between tool rounds.
|
|
1856
|
-
def thinking_spinner_status_lines(frame, width: status_effective_width)
|
|
1857
|
-
spinner_row_lines(frame, width: width) + tally_status_lines(width)
|
|
1858
|
-
end
|
|
1859
|
-
|
|
1860
|
-
def tally_status_lines(width)
|
|
1861
|
-
tally = @turn_tally.text(width: width)
|
|
1862
|
-
return [] unless tally
|
|
1863
|
-
|
|
1864
|
-
[color_output? ? paint(tally, 90) : tally]
|
|
1865
|
-
end
|
|
1866
|
-
|
|
1867
|
-
def tally_key(event)
|
|
1868
|
-
[event[:iteration].to_i, event[:call_index].to_i]
|
|
1869
|
-
end
|
|
1870
|
-
|
|
1871
|
-
def spinner_row_lines(frame, width:)
|
|
1872
|
-
if retry_spinner_status_active?
|
|
1873
|
-
return [retry_spinner_status_line(frame, width)]
|
|
1874
|
-
end
|
|
1875
|
-
|
|
1876
|
-
return ["#{@init_wait_status} #{frame}"[0, width]] if @init_wait_status
|
|
1877
|
-
|
|
1878
|
-
base = first_token_wait_status(frame) || "model> thinking... #{frame}"
|
|
1879
|
-
available_for_notification = [width - base.length, 0].max
|
|
1880
|
-
memory_notification, tool_notification = thinking_notification_segments(available_for_notification)
|
|
1881
|
-
notification = "#{memory_notification}#{tool_notification}"
|
|
1882
|
-
base = thinking_sentence_base(frame, width - notification.length) || base
|
|
1883
|
-
|
|
1884
|
-
return ["#{base}#{notification}"] unless color_output?
|
|
1885
|
-
|
|
1886
|
-
["#{paint(base, 90)}#{paint_if_present(memory_notification, MEMORY_SPINNER_COLOR)}#{paint_if_present(tool_notification, TOOL_SPINNER_COLOR)}"]
|
|
1887
|
-
end
|
|
1888
|
-
|
|
1889
|
-
# "model> thinking · <the newest sentence> |" within +room+ columns (the
|
|
1890
|
-
# web's thinking ticker, ThinkingLine), or nil before the first sentence.
|
|
1891
|
-
def thinking_sentence_base(frame, room)
|
|
1892
|
-
return nil if @thinking_waiting_since || @thinking_line.empty?
|
|
1893
|
-
|
|
1894
|
-
prefix = "model> #{@thinking_line.label} · "
|
|
1895
|
-
room -= prefix.length + frame.length + 1
|
|
1896
|
-
return nil if room < MIN_SENTENCE_ROOM
|
|
1897
|
-
|
|
1898
|
-
"#{prefix}#{@thinking_line.fit(room)} #{frame}"
|
|
1899
|
-
end
|
|
1900
|
-
|
|
1901
1133
|
# ── Ask-user-question adapter (generic TUI renderer) ─────────────────────
|
|
1902
1134
|
|
|
1903
1135
|
# Non-blocking observer: the turn thread emits :question_requested; we stash
|
|
@@ -1917,10 +1149,6 @@ module Samagotchi
|
|
|
1917
1149
|
nil
|
|
1918
1150
|
end
|
|
1919
1151
|
|
|
1920
|
-
def pending_question_event?
|
|
1921
|
-
!!@pending_question_event
|
|
1922
|
-
end
|
|
1923
|
-
|
|
1924
1152
|
# Called from REPL thread (run_assist_loop top) — renders widget, blocks
|
|
1925
1153
|
# until user selects, then answers via Engine#answer_question which wakes
|
|
1926
1154
|
# the parked turn thread.
|
|
@@ -1960,8 +1188,11 @@ module Samagotchi
|
|
|
1960
1188
|
def render_question_widget(pending)
|
|
1961
1189
|
prompt = QuestionPrompt.new(pending)
|
|
1962
1190
|
|
|
1963
|
-
#
|
|
1964
|
-
finish_thinking_spinner
|
|
1191
|
+
# The activity row goes while the question waits.
|
|
1192
|
+
@view.finish_thinking_spinner
|
|
1193
|
+
# An edit's diff goes above the slot, into the scrollback.
|
|
1194
|
+
preview = prompt.preview_lines(paint: method(:paint))
|
|
1195
|
+
@surface.commit(preview.join("\n")) unless preview.empty?
|
|
1965
1196
|
choices = prompt.slot(paint: method(:paint))
|
|
1966
1197
|
|
|
1967
1198
|
question_prompt = paint(QUESTION_PROMPT, 33)
|
|
@@ -2049,17 +1280,5 @@ module Samagotchi
|
|
|
2049
1280
|
return :canceled if controller&.cancelled?
|
|
2050
1281
|
end
|
|
2051
1282
|
end
|
|
2052
|
-
|
|
2053
|
-
# Process a prompt through the kernel loop and return the model response.
|
|
2054
|
-
# Delegates to the internal Engine instance.
|
|
2055
|
-
def process_prompt_through_kernel(session, prompt)
|
|
2056
|
-
@engine.process_prompt_through_kernel(session, prompt)
|
|
2057
|
-
end
|
|
2058
|
-
|
|
2059
|
-
# Write an agent response to the session output directory.
|
|
2060
|
-
def write_session_output(output_dir, response)
|
|
2061
|
-
timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
|
|
2062
|
-
File.write(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
|
|
2063
|
-
end
|
|
2064
1283
|
end
|
|
2065
1284
|
end
|