samagotchi 0.9.0 → 0.10.1
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 +68 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +5 -3
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +36 -13
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
|
@@ -21,6 +21,7 @@ require_relative "output_formatter"
|
|
|
21
21
|
require_relative "turn_flow"
|
|
22
22
|
require_relative "session_commands"
|
|
23
23
|
require_relative "terminal_ui/attached_view"
|
|
24
|
+
require_relative "terminal_ui/between_turns"
|
|
24
25
|
require_relative "terminal_ui/event_renderer"
|
|
25
26
|
require_relative "terminal_ui/formatting"
|
|
26
27
|
require_relative "terminal_ui/input_support"
|
|
@@ -63,6 +64,14 @@ module Samagotchi
|
|
|
63
64
|
# --resume with an id that has no saved session.
|
|
64
65
|
class SessionNotFound < StandardError; end
|
|
65
66
|
|
|
67
|
+
# What prints between turns (cards, notices, init lines, an idle recap):
|
|
68
|
+
# announced on other threads, printed at the open prompt.
|
|
69
|
+
# @return [BetweenTurns]
|
|
70
|
+
attr_reader :between_turns
|
|
71
|
+
# The session's Engine: the REPL drives it inline (#run_engine_turn).
|
|
72
|
+
# @return [Engine]
|
|
73
|
+
attr_reader :engine
|
|
74
|
+
|
|
66
75
|
# ── System prompts (delegated to Engine) ─────────────────────────────────
|
|
67
76
|
def self.system_prompt_for(profile)
|
|
68
77
|
Engine.system_prompt_for(profile)
|
|
@@ -117,12 +126,6 @@ module Samagotchi
|
|
|
117
126
|
@requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
|
|
118
127
|
@muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
|
|
119
128
|
end
|
|
120
|
-
# A recap written while idle, printed by the main thread at the open
|
|
121
|
-
# prompt (#flush_pending_recap).
|
|
122
|
-
@pending_recap = nil
|
|
123
|
-
# Cards and notices announced between turns, printed by the main
|
|
124
|
-
# thread at the open prompt (#flush_pending_cards).
|
|
125
|
-
@pending_cards = Queue.new
|
|
126
129
|
# Every terminal write goes through the surface. The REPL swaps in a
|
|
127
130
|
# live region when the terminal can show one (#assist_loop), unless it
|
|
128
131
|
# was given a surface to draw on.
|
|
@@ -137,6 +140,10 @@ module Samagotchi
|
|
|
137
140
|
@view = AttachedView.new(@surface, **view_options)
|
|
138
141
|
@status_row = StatusRow.new(@surface)
|
|
139
142
|
@renderer = EventRenderer.new(@view)
|
|
143
|
+
# Cards, notices and a recap announced between turns, printed by the
|
|
144
|
+
# main thread at the open prompt (#poll_input_with_reminder_check).
|
|
145
|
+
@between_turns = BetweenTurns.new(surface: @surface, view: @view, renderer: @renderer,
|
|
146
|
+
turn_running: -> { @engine.turn_running? }, quiet: @non_interactive)
|
|
140
147
|
@render_event = ->(event) { handle_stream_event(event) }
|
|
141
148
|
@engine = Engine.new(
|
|
142
149
|
client: client,
|
|
@@ -176,9 +183,9 @@ module Samagotchi
|
|
|
176
183
|
@engine.switch_model!(@effective_model_name) if @effective_model_name != @default_model_name
|
|
177
184
|
# Render an idle session-recap via the cursor-safe background writer; the
|
|
178
185
|
# detector itself is Engine-owned (see Engine#recap) and opt-in.
|
|
179
|
-
@recap_handle = @engine.subscribe(observer: ->(event) {
|
|
186
|
+
@recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
|
|
180
187
|
@question_handle = @engine.subscribe(observer: ->(event) { handle_question_event(event) })
|
|
181
|
-
@card_handle = @engine.subscribe(observer: ->(event) {
|
|
188
|
+
@card_handle = @engine.subscribe(observer: ->(event) { @between_turns.observe(event) })
|
|
182
189
|
# The REPL renders events from here on: the load warnings and notices
|
|
183
190
|
# now (at the first prompt), and the plugins' slow setup in the
|
|
184
191
|
# background. A --non-interactive run leaves both to its turn.
|
|
@@ -251,16 +258,12 @@ module Samagotchi
|
|
|
251
258
|
|
|
252
259
|
assist_loop(session: session, messages: messages)
|
|
253
260
|
# After the idle layer has stopped: nothing writes the session now.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
elsif @archive_on_exit
|
|
261
|
-
archive_after_exit(session)
|
|
262
|
-
else
|
|
263
|
-
keep_after_exit(session)
|
|
261
|
+
case exit_action
|
|
262
|
+
when :scratch then nil # deleted below, however the REPL ended
|
|
263
|
+
when :delete then delete_after_exit(session)
|
|
264
|
+
when :discard then discard_after_exit(session)
|
|
265
|
+
when :archive then archive_after_exit(session)
|
|
266
|
+
else keep_after_exit(session)
|
|
264
267
|
end
|
|
265
268
|
# Lines from a pipe (`chi -p X </dev/null`): a failed turn fails the run.
|
|
266
269
|
:turn_failed if @piped_turn_failed
|
|
@@ -360,9 +363,9 @@ module Samagotchi
|
|
|
360
363
|
@owner_lock = OwnerLock.acquire(session_dir, kind: "tui", wait: 1.0)
|
|
361
364
|
return @owner_lock if @owner_lock
|
|
362
365
|
|
|
363
|
-
owner = OwnerLock.owner(session_dir)
|
|
364
|
-
where = owner
|
|
365
|
-
raise SessionBusy, "Session #{session_id} is open in #{where} (pid #{owner
|
|
366
|
+
owner = OwnerLock.owner(session_dir)
|
|
367
|
+
where = owner&.tui? ? "another chi" : "a `chi web` worker"
|
|
368
|
+
raise SessionBusy, "Session #{session_id} is open in #{where} (pid #{owner&.pid || "unknown"}). " \
|
|
366
369
|
"Close it there first."
|
|
367
370
|
end
|
|
368
371
|
|
|
@@ -388,6 +391,124 @@ module Samagotchi
|
|
|
388
391
|
messages
|
|
389
392
|
end
|
|
390
393
|
|
|
394
|
+
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
395
|
+
# threaded in here (so --prompt / --resume share one code path). The
|
|
396
|
+
# session's conversation is the single working copy: turns go through
|
|
397
|
+
# Engine#run_turn and out-of-turn edits through Engine's messages API.
|
|
398
|
+
# The checkpoint and continue state live in @turn_flow (shared with
|
|
399
|
+
# session workers). The session is persisted at the end of every turn.
|
|
400
|
+
def run_assist_loop(session:, messages:)
|
|
401
|
+
session.messages = messages
|
|
402
|
+
@engine.session = session
|
|
403
|
+
# Steering: lines submitted at the open prompt during a turn
|
|
404
|
+
# (#steer_line), merged by the kernel (#drain_steering).
|
|
405
|
+
@pending_input_queue = PendingInputQueue.new
|
|
406
|
+
open_repl_input
|
|
407
|
+
|
|
408
|
+
loop do
|
|
409
|
+
if @exit_after_turn
|
|
410
|
+
# Ctrl-D or exit came during a turn: the lines sent before it still
|
|
411
|
+
# run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
|
|
412
|
+
input = queued_line
|
|
413
|
+
break if input.nil?
|
|
414
|
+
else
|
|
415
|
+
# Drain any pending ask_user_question first — it has priority over reminders and
|
|
416
|
+
# must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
|
|
417
|
+
drain_pending_question?
|
|
418
|
+
|
|
419
|
+
# A due reminder runs its turn now, with the prompt open (on a
|
|
420
|
+
# terminal): the turn's output commits above it and whatever is typed
|
|
421
|
+
# there stays.
|
|
422
|
+
unless @engine.due_reminder_names.empty?
|
|
423
|
+
run_reminder_turn(session)
|
|
424
|
+
@turn_flow.after_reminder_turn
|
|
425
|
+
next
|
|
426
|
+
end
|
|
427
|
+
input = @prompt
|
|
428
|
+
@prompt = nil if input
|
|
429
|
+
if input.nil?
|
|
430
|
+
input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
|
|
431
|
+
# A reminder fell due: run it at the top, with the prompt still open.
|
|
432
|
+
next if input == :due
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
break if input.nil?
|
|
436
|
+
local = local_command(input)
|
|
437
|
+
if local == :exit
|
|
438
|
+
@delete_on_exit ||= SessionCommands.delete_on_exit?(input)
|
|
439
|
+
break
|
|
440
|
+
end
|
|
441
|
+
if local == :archive
|
|
442
|
+
next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
|
|
443
|
+
|
|
444
|
+
@archive_on_exit = true
|
|
445
|
+
break
|
|
446
|
+
end
|
|
447
|
+
# Not an answer to a continue offer either.
|
|
448
|
+
next detach_note if local == :detach
|
|
449
|
+
|
|
450
|
+
# /stats and /recap run; the offer stays open.
|
|
451
|
+
if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
|
|
452
|
+
answer_continue_offer(session, input)
|
|
453
|
+
else
|
|
454
|
+
# The ? read left no echo: show what ran.
|
|
455
|
+
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
|
|
456
|
+
run_input_line(session, input)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
close_repl_input
|
|
461
|
+
@discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
465
|
+
# Engine#run_turn, rendering via @renderer.
|
|
466
|
+
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
467
|
+
def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
|
|
468
|
+
cancellation_controller = CancellationController.new
|
|
469
|
+
@active_cancel_controller = cancellation_controller
|
|
470
|
+
@renderer.begin_turn
|
|
471
|
+
# The row shows the turn's model (a -p turn runs before any read).
|
|
472
|
+
refresh_status_row
|
|
473
|
+
# A prompt the user typed brings an archived session back to the lists.
|
|
474
|
+
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
475
|
+
with_steering do
|
|
476
|
+
@engine.run_turn(
|
|
477
|
+
session,
|
|
478
|
+
prompt,
|
|
479
|
+
on_event: @render_event,
|
|
480
|
+
max_iterations: max_iterations,
|
|
481
|
+
cancel_controller: cancellation_controller,
|
|
482
|
+
pending_input: method(:drain_steering),
|
|
483
|
+
continue: continue,
|
|
484
|
+
images: images
|
|
485
|
+
)
|
|
486
|
+
end
|
|
487
|
+
rescue Interrupt
|
|
488
|
+
# Engine kept the prompt in the session and emitted :turn_canceled
|
|
489
|
+
# (the renderer's line).
|
|
490
|
+
cancellation_controller&.cancel!(:ctrl_c)
|
|
491
|
+
cancelled_result_from(session.messages, reason: :ctrl_c)
|
|
492
|
+
ensure
|
|
493
|
+
@active_cancel_controller = nil
|
|
494
|
+
@view.finish_thinking_spinner
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# How the REPL leaves its session once the loop ended (#run): :scratch
|
|
498
|
+
# (deleted however it ends), :delete (/exit --delete), :discard (left
|
|
499
|
+
# empty), :archive (/archive) or :keep.
|
|
500
|
+
def exit_action
|
|
501
|
+
if @scratch then :scratch
|
|
502
|
+
elsif @delete_on_exit then :delete
|
|
503
|
+
elsif @discard_on_exit then :discard
|
|
504
|
+
elsif @archive_on_exit then :archive
|
|
505
|
+
else :keep
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Ctrl-D or /exit came during a turn: the REPL ends after it.
|
|
510
|
+
def exit_after_turn? = @exit_after_turn == true
|
|
511
|
+
|
|
391
512
|
private
|
|
392
513
|
|
|
393
514
|
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
@@ -468,76 +589,7 @@ module Samagotchi
|
|
|
468
589
|
@surface = surface
|
|
469
590
|
@view.surface = surface
|
|
470
591
|
@status_row.surface = surface
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
# Interactive REPL loop. Session seed + messages are built by #run and
|
|
474
|
-
# threaded in here (so --prompt / --resume share one code path). The
|
|
475
|
-
# session's conversation is the single working copy: turns go through
|
|
476
|
-
# Engine#run_turn and out-of-turn edits through Engine's messages API.
|
|
477
|
-
# The checkpoint and continue state live in @turn_flow (shared with
|
|
478
|
-
# session workers). The session is persisted at the end of every turn.
|
|
479
|
-
def run_assist_loop(session:, messages:)
|
|
480
|
-
session.messages = messages
|
|
481
|
-
@engine.session = session
|
|
482
|
-
# Steering: lines submitted at the open prompt during a turn
|
|
483
|
-
# (#steer_line), merged by the kernel (#drain_steering).
|
|
484
|
-
@pending_input_queue = PendingInputQueue.new
|
|
485
|
-
open_repl_input
|
|
486
|
-
|
|
487
|
-
loop do
|
|
488
|
-
if @exit_after_turn
|
|
489
|
-
# Ctrl-D or exit came during a turn: the lines sent before it still
|
|
490
|
-
# run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
|
|
491
|
-
input = queued_line
|
|
492
|
-
break if input.nil?
|
|
493
|
-
else
|
|
494
|
-
# Drain any pending ask_user_question first — it has priority over reminders and
|
|
495
|
-
# must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
|
|
496
|
-
drain_pending_question?
|
|
497
|
-
|
|
498
|
-
# A due reminder runs its turn now, with the prompt open (on a
|
|
499
|
-
# terminal): the turn's output commits above it and whatever is typed
|
|
500
|
-
# there stays.
|
|
501
|
-
unless @engine.due_reminder_names.empty?
|
|
502
|
-
run_reminder_turn(session)
|
|
503
|
-
@turn_flow.after_reminder_turn
|
|
504
|
-
next
|
|
505
|
-
end
|
|
506
|
-
input = @prompt
|
|
507
|
-
@prompt = nil if input
|
|
508
|
-
if input.nil?
|
|
509
|
-
input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
|
|
510
|
-
# A reminder fell due: run it at the top, with the prompt still open.
|
|
511
|
-
next if input == :due
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
break if input.nil?
|
|
515
|
-
local = local_command(input)
|
|
516
|
-
if local == :exit
|
|
517
|
-
@delete_on_exit ||= SessionCommands.delete_on_exit?(input)
|
|
518
|
-
break
|
|
519
|
-
end
|
|
520
|
-
if local == :archive
|
|
521
|
-
next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
|
|
522
|
-
|
|
523
|
-
@archive_on_exit = true
|
|
524
|
-
break
|
|
525
|
-
end
|
|
526
|
-
# Not an answer to a continue offer either.
|
|
527
|
-
next detach_note if local == :detach
|
|
528
|
-
|
|
529
|
-
# /stats and /recap run; the offer stays open.
|
|
530
|
-
if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
|
|
531
|
-
answer_continue_offer(session, input)
|
|
532
|
-
else
|
|
533
|
-
# The ? read left no echo: show what ran.
|
|
534
|
-
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
|
|
535
|
-
run_input_line(session, input)
|
|
536
|
-
end
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
close_repl_input
|
|
540
|
-
@discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
|
|
592
|
+
@between_turns.surface = surface
|
|
541
593
|
end
|
|
542
594
|
|
|
543
595
|
# An answer at the ? prompt of a continue offer. A valid one closes the
|
|
@@ -693,8 +745,8 @@ module Samagotchi
|
|
|
693
745
|
sync_continue_slot(false)
|
|
694
746
|
@surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
|
|
695
747
|
end
|
|
696
|
-
@surface.commit(
|
|
697
|
-
hint = "
|
|
748
|
+
@surface.commit(reminder_line(names))
|
|
749
|
+
hint = "#{reminder_line(names)} · Ctrl-C cancels it"
|
|
698
750
|
@surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
|
|
699
751
|
result = nil
|
|
700
752
|
begin
|
|
@@ -725,7 +777,7 @@ module Samagotchi
|
|
|
725
777
|
return :due unless @engine.due_reminder_names.empty?
|
|
726
778
|
# Specs and pipes: a plain blocking read.
|
|
727
779
|
unless @repl_input
|
|
728
|
-
|
|
780
|
+
@between_turns.flush_cards
|
|
729
781
|
return read_input(awaiting_continue: awaiting_continue)
|
|
730
782
|
end
|
|
731
783
|
|
|
@@ -736,8 +788,8 @@ module Samagotchi
|
|
|
736
788
|
sync_continue_slot(awaiting_continue)
|
|
737
789
|
@repl_input.sync_prompt
|
|
738
790
|
loop do
|
|
739
|
-
|
|
740
|
-
|
|
791
|
+
@between_turns.flush_recap
|
|
792
|
+
@between_turns.flush_cards
|
|
741
793
|
kind, line = @repl_input.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
|
|
742
794
|
if kind
|
|
743
795
|
# What the line does may change the status (/model, a turn).
|
|
@@ -826,7 +878,7 @@ module Samagotchi
|
|
|
826
878
|
end
|
|
827
879
|
|
|
828
880
|
# /recap: the saved recap, and a new one asked for at once when the
|
|
829
|
-
# chat moved on (it prints when it arrives, #
|
|
881
|
+
# chat moved on (it prints when it arrives, BetweenTurns#flush_recap).
|
|
830
882
|
def handle_recap_command
|
|
831
883
|
recap = @engine.recap
|
|
832
884
|
return recap_command_text(enabled: false) unless recap
|
|
@@ -843,39 +895,6 @@ module Samagotchi
|
|
|
843
895
|
Array(messages).map(&:dup)
|
|
844
896
|
end
|
|
845
897
|
|
|
846
|
-
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
847
|
-
# Engine#run_turn, rendering via @renderer.
|
|
848
|
-
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
849
|
-
def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
|
|
850
|
-
cancellation_controller = CancellationController.new
|
|
851
|
-
@active_cancel_controller = cancellation_controller
|
|
852
|
-
@renderer.begin_turn
|
|
853
|
-
# The row shows the turn's model (a -p turn runs before any read).
|
|
854
|
-
refresh_status_row
|
|
855
|
-
# A prompt the user typed brings an archived session back to the lists.
|
|
856
|
-
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
857
|
-
with_steering do
|
|
858
|
-
@engine.run_turn(
|
|
859
|
-
session,
|
|
860
|
-
prompt,
|
|
861
|
-
on_event: @render_event,
|
|
862
|
-
max_iterations: max_iterations,
|
|
863
|
-
cancel_controller: cancellation_controller,
|
|
864
|
-
pending_input: method(:drain_steering),
|
|
865
|
-
continue: continue,
|
|
866
|
-
images: images
|
|
867
|
-
)
|
|
868
|
-
end
|
|
869
|
-
rescue Interrupt
|
|
870
|
-
# Engine kept the prompt in the session and emitted :turn_canceled
|
|
871
|
-
# (the renderer's line).
|
|
872
|
-
cancellation_controller&.cancel!(:ctrl_c)
|
|
873
|
-
cancelled_result_from(session.messages, reason: :ctrl_c)
|
|
874
|
-
ensure
|
|
875
|
-
@active_cancel_controller = nil
|
|
876
|
-
@view.finish_thinking_spinner
|
|
877
|
-
end
|
|
878
|
-
|
|
879
898
|
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
880
899
|
# merges at the next iteration boundary (Kernel), and one that comes after
|
|
881
900
|
# the last runs as the next turn. Reminder turns too.
|
|
@@ -942,16 +961,15 @@ module Samagotchi
|
|
|
942
961
|
# D8: an anytime command runs on its own thread while the turn goes on
|
|
943
962
|
# (it reads copies, and shows things through its ctx). What it prints
|
|
944
963
|
# while the turn runs goes above the live region now, its cards as it
|
|
945
|
-
# shows them (#
|
|
964
|
+
# shows them (BetweenTurns#observe); once the turn has ended it waits for
|
|
946
965
|
# the prompt's flush.
|
|
947
966
|
# @return [true]
|
|
948
967
|
def start_anytime_command(line)
|
|
949
968
|
@engine.spawn_anytime do
|
|
950
969
|
output = @engine.running_anytime { @commands.run(line) }&.output
|
|
951
|
-
|
|
952
|
-
items.each { |item| @engine.turn_running? ? show_pending_item(item) : @pending_cards << item }
|
|
970
|
+
@between_turns.show_or_keep({ type: :command_output, text: "\nmodel> #{output}" }) unless output.nil?
|
|
953
971
|
rescue StandardError => e
|
|
954
|
-
@
|
|
972
|
+
@between_turns.keep({ type: :command_output, text: "\nmodel> #{line.split.first}: #{e.message}" })
|
|
955
973
|
end
|
|
956
974
|
true
|
|
957
975
|
end
|
|
@@ -970,13 +988,7 @@ module Samagotchi
|
|
|
970
988
|
# Bridge's). Engine swallows on_event errors to protect the turn, so log
|
|
971
989
|
# ours instead.
|
|
972
990
|
def handle_stream_event(event)
|
|
973
|
-
|
|
974
|
-
when :context_status
|
|
975
|
-
@status_row.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
976
|
-
when :used_memories_updated then @status_row.update(used_memories: Array(event[:used_memory_names]))
|
|
977
|
-
when :generation_completed
|
|
978
|
-
@status_row.update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
|
|
979
|
-
end
|
|
991
|
+
@status_row.take_event(event)
|
|
980
992
|
@renderer.call(event)
|
|
981
993
|
rescue StandardError => e
|
|
982
994
|
Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
|
|
@@ -997,98 +1009,8 @@ module Samagotchi
|
|
|
997
1009
|
# The status row under the prompt, from the Engine's state (what a
|
|
998
1010
|
# worker hands an attached TUI when it joins): drawn when it changed.
|
|
999
1011
|
def refresh_status_row
|
|
1000
|
-
state = @engine.session_state_snapshot
|
|
1001
|
-
|
|
1002
|
-
fields = { model: @effective_model_name, default_model: @default_model_name, served: served,
|
|
1003
|
-
parent_id: state[:parent_id], used_memories: Array(state[:used_memory_names]),
|
|
1004
|
-
preloaded: Array(state[:preloaded_memory_names]), muted: Array(state[:muted_memory_names]) }
|
|
1005
|
-
fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
|
|
1006
|
-
@status_row.update(**fields)
|
|
1007
|
-
end
|
|
1008
|
-
|
|
1009
|
-
# ── Idle session recap ───────────────────────────────────────────────────
|
|
1010
|
-
#
|
|
1011
|
-
# The Engine's idle job emits :recap_ready once the session has been idle
|
|
1012
|
-
# for its inactivity threshold (or /recap asked). On the scheduler thread:
|
|
1013
|
-
# kept for the main thread to print at the open prompt. One collected
|
|
1014
|
-
# just as a turn started describes the chat before it.
|
|
1015
|
-
def handle_recap_ready(event)
|
|
1016
|
-
return unless event[:type] == :recap_ready
|
|
1017
|
-
return if event[:recap].to_s.strip.empty? || @engine.turn_running?
|
|
1018
|
-
|
|
1019
|
-
@pending_recap = event[:recap].to_s
|
|
1020
|
-
end
|
|
1021
|
-
|
|
1022
|
-
# Print a recap written while idle (main thread, at the open prompt).
|
|
1023
|
-
def flush_pending_recap
|
|
1024
|
-
recap = @pending_recap
|
|
1025
|
-
return unless recap
|
|
1026
|
-
|
|
1027
|
-
@pending_recap = nil
|
|
1028
|
-
@surface.commit(recap_block(recap))
|
|
1029
|
-
end
|
|
1030
|
-
|
|
1031
|
-
# ── Cards and notices between turns ─────────────────────────────────────
|
|
1032
|
-
#
|
|
1033
|
-
# A card or a plugin's notice shown outside a turn is announced; on the
|
|
1034
|
-
# announcing thread it is only kept, for the main thread to print at the
|
|
1035
|
-
# open prompt (after the command that showed it). One shown during a
|
|
1036
|
-
# turn is a turn event: the turn's sink prints it where it happens
|
|
1037
|
-
# (EventRenderer; the Screen draws it above the live region).
|
|
1038
|
-
#
|
|
1039
|
-
# An anytime command's (event[:anytime]) print as it shows them: on the
|
|
1040
|
-
# main thread (the command runs at the prompt) or beside a running turn
|
|
1041
|
-
# (above the live region), else at the next flush.
|
|
1042
|
-
#
|
|
1043
|
-
# A plugin's init task (chi.init) turns the activity row while it runs
|
|
1044
|
-
# (between turns too, as in attached mode) and prints a line when it is
|
|
1045
|
-
# done; a load warning announced before the first turn, one. Beside a
|
|
1046
|
-
# running turn they print at once; a --non-interactive run prints only
|
|
1047
|
-
# its answer.
|
|
1048
|
-
def handle_card_event(event)
|
|
1049
|
-
if INIT_EVENTS.include?(event[:type])
|
|
1050
|
-
return if @non_interactive
|
|
1051
|
-
return @view.init_started(event) if event[:type] == :plugin_init_started
|
|
1052
|
-
|
|
1053
|
-
@view.init_finished(event) if event[:type] == :plugin_init_finished
|
|
1054
|
-
return show_pending_item(event) if @engine.turn_running?
|
|
1055
|
-
|
|
1056
|
-
return @pending_cards << event
|
|
1057
|
-
end
|
|
1058
|
-
return unless (event[:type] == :card && !event[:in_turn]) || (event[:type] == :hook_notice && event[:between_turns])
|
|
1059
|
-
return show_pending_item(event) if event[:anytime] && (Thread.current == Thread.main || @engine.turn_running?)
|
|
1060
|
-
|
|
1061
|
-
@pending_cards << event
|
|
1062
|
-
end
|
|
1063
|
-
|
|
1064
|
-
# Print the cards, notices and anytime commands' output kept since the
|
|
1065
|
-
# last flush (main thread). A card replaced later in the same batch (the
|
|
1066
|
-
# same id: btw's "thinking…", then its answer) prints once, as its last.
|
|
1067
|
-
def flush_pending_cards
|
|
1068
|
-
items = []
|
|
1069
|
-
loop { items << @pending_cards.pop(true) }
|
|
1070
|
-
rescue ThreadError
|
|
1071
|
-
items.each_with_index do |item, index|
|
|
1072
|
-
replaced = item[:type] == :card && items.drop(index + 1).any? { |later| later[:type] == :card && later[:id] == item[:id] }
|
|
1073
|
-
show_pending_item(item) unless replaced
|
|
1074
|
-
end
|
|
1075
|
-
nil
|
|
1076
|
-
end
|
|
1077
|
-
|
|
1078
|
-
INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
|
|
1079
|
-
|
|
1080
|
-
# A card, a notice, an anytime command's output (:command_output), a
|
|
1081
|
-
# plugin init task's done line or a load warning.
|
|
1082
|
-
def show_pending_item(item)
|
|
1083
|
-
case item[:type]
|
|
1084
|
-
when :card then @renderer.render_card(item)
|
|
1085
|
-
when :command_output then @surface.commit(item[:text])
|
|
1086
|
-
when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
|
|
1087
|
-
when :plugin_init_finished
|
|
1088
|
-
line = EventRenderer.init_line(item)
|
|
1089
|
-
@surface.commit(line) if line
|
|
1090
|
-
else @surface.commit(EventRenderer.hook_notice_line(item))
|
|
1091
|
-
end
|
|
1012
|
+
state = @engine.session_state_snapshot.merge(model_name: @effective_model_name)
|
|
1013
|
+
@status_row.take_state(state, default_model: @default_model_name)
|
|
1092
1014
|
end
|
|
1093
1015
|
|
|
1094
1016
|
# Resolve the recap config (on by default). Returns false when explicitly
|
|
@@ -1131,7 +1053,7 @@ module Samagotchi
|
|
|
1131
1053
|
type = event[:type] || event["type"]
|
|
1132
1054
|
return unless type.to_s == "question_requested"
|
|
1133
1055
|
|
|
1134
|
-
pq = event[:pending_question] || event["pending_question"]
|
|
1056
|
+
pq = event[:pending_question] || event["pending_question"]
|
|
1135
1057
|
return unless pq
|
|
1136
1058
|
|
|
1137
1059
|
@pending_question_event = pq
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -50,10 +50,10 @@ module Samagotchi
|
|
|
50
50
|
# The level for +target+ and where it came from ("--thinking",
|
|
51
51
|
# "models: qwen", "hosts.work", "thinking.level"; nil for the default).
|
|
52
52
|
# @param target [HostRegistry::ModelTarget]
|
|
53
|
-
# @param names [Array<String
|
|
53
|
+
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
54
54
|
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
55
55
|
# @return [Array(Symbol, String|nil)]
|
|
56
|
-
def resolve(target, names
|
|
56
|
+
def resolve(target, names:, models: nil)
|
|
57
57
|
global, origin = global_level
|
|
58
58
|
return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
|
|
59
59
|
|
|
@@ -62,7 +62,6 @@ module Samagotchi
|
|
|
62
62
|
rescue StandardError
|
|
63
63
|
{}
|
|
64
64
|
end
|
|
65
|
-
names ||= [target.model, target.bare_model]
|
|
66
65
|
key, value = ConfigFile.model_setting(names, :thinking, models: models)
|
|
67
66
|
return [value, "models: #{key}"] if key
|
|
68
67
|
|
|
@@ -18,21 +18,22 @@ module Samagotchi
|
|
|
18
18
|
# fragments at the end of each chunk so a marker split across a stream boundary
|
|
19
19
|
# is still recognized. `ThoughtStreamSplitter.for_profile` derives the block
|
|
20
20
|
# markers from the active ModelProfile, so the same splitter works for the
|
|
21
|
-
# Qwen literal-marker family and
|
|
21
|
+
# Qwen literal-marker family and the Gemma control-token family.
|
|
22
22
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# so thinking is split cleanly.
|
|
23
|
+
# Gemma 4 thinks in a channel, `<|channel>thought` … `<channel|>`: that is
|
|
24
|
+
# its thinking block. Its bare `<|think|>` cue has no close marker, so it is
|
|
25
|
+
# not a block and stays in `:text` (the model doesn't emit it; the system
|
|
26
|
+
# prompt carries it).
|
|
28
27
|
class ThoughtStreamSplitter
|
|
28
|
+
# Gemma 4's thought channel (ToolCallParser::Gemma strips the same pair).
|
|
29
|
+
GEMMA_THOUGHT_CHANNEL = { open: "<|channel>thought", close: "<channel|>" }.freeze
|
|
30
|
+
|
|
29
31
|
# @return [Array<Hash>] each entry { open: String, close: String, lane: :thinking|:drop }
|
|
30
32
|
def self.for_profile(profile)
|
|
31
33
|
blocks = []
|
|
32
|
-
#
|
|
33
|
-
# marker (Qwen). A nil close (Gemma) means we can't bound the block, so we
|
|
34
|
-
# leave thinking in :text and let the renderer's strip handle it.
|
|
34
|
+
# A thinking block needs a close marker to be bounded.
|
|
35
35
|
blocks << { open: profile.thought_open, close: profile.thought_close, lane: :thinking } if profile.thought_close
|
|
36
|
+
blocks << GEMMA_THOUGHT_CHANNEL.merge(lane: :thinking) if profile.name == "gemma4"
|
|
36
37
|
blocks << { open: profile.tool_call_open, close: profile.tool_call_close, lane: :drop }
|
|
37
38
|
new(blocks)
|
|
38
39
|
end
|