samagotchi 0.43.0 → 0.45.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 +89 -1
- data/docs/cli.md +29 -7
- data/docs/configuration.md +18 -0
- data/docs/context.md +2 -1
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/context_status.rb +18 -5
- data/lib/samagotchi/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- data/lib/samagotchi/kernel_loop.rb +24 -9
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/llm_context_forget.rb +6 -2
- data/lib/samagotchi/llm_context_notice.rb +96 -0
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_profile.rb +71 -2
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/self_report.rb +31 -2
- data/lib/samagotchi/session_commands.rb +24 -7
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +39 -6
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -13
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +38 -19
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +42 -15
- data/lib/samagotchi/web/public/index.html +7 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +3 -3
- data/lib/samagotchi/web/public/turn_events.js +50 -4
- data/lib/samagotchi/web/public/turn_html.js +33 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -2
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +156 -20
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +2 -1
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -59,6 +59,8 @@ module Samagotchi
|
|
|
59
59
|
# How much of a command's output goes into its :command_ran.
|
|
60
60
|
COMMAND_OUTPUT_LIMIT = 4096
|
|
61
61
|
BUSY_OUTPUT = "busy: wait for the turn to end"
|
|
62
|
+
# !rollback mid-turn would mean "the turn I'm in".
|
|
63
|
+
ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
|
|
62
64
|
|
|
63
65
|
# Wakes the worker loop. Whoever queues work writes it first and wakes
|
|
64
66
|
# after, and #wait drains every wake before the loop looks for work: a
|
|
@@ -184,7 +186,10 @@ module Samagotchi
|
|
|
184
186
|
# queued while that turn ran.
|
|
185
187
|
@turn_end_seq = 0
|
|
186
188
|
@engine.subscribe(observer: lambda { |event|
|
|
187
|
-
|
|
189
|
+
if Events::TURN_END.include?(event[:type])
|
|
190
|
+
@turn_end_seq = event[:event_seq]
|
|
191
|
+
@turn_end_type = event[:type]
|
|
192
|
+
end
|
|
188
193
|
@turn_id = event[:turn_id] if event[:type] == :turn_started
|
|
189
194
|
ring_question(event) if event[:type] == :question_requested
|
|
190
195
|
})
|
|
@@ -198,7 +203,9 @@ module Samagotchi
|
|
|
198
203
|
context_absorber: @context_absorber, wakes: @wakes,
|
|
199
204
|
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
200
205
|
stopped: -> { stopped_on_disk? },
|
|
201
|
-
queue_command:
|
|
206
|
+
queue_command: lambda { |line, client_id, after_file|
|
|
207
|
+
@bridge.queue_command(line, client_id: client_id, after_file: after_file)
|
|
208
|
+
})
|
|
202
209
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
203
210
|
# on the session's model.
|
|
204
211
|
@default_model = ModelProfile.required_model_name(nil)
|
|
@@ -215,14 +222,7 @@ module Samagotchi
|
|
|
215
222
|
def serve
|
|
216
223
|
@bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
|
|
217
224
|
on_input: -> { @waker.wake },
|
|
218
|
-
on_command:
|
|
219
|
-
next start_anytime_command(command) if anytime_command?(command[:line])
|
|
220
|
-
|
|
221
|
-
# Called with the event log held: the
|
|
222
|
-
# count says which turn ends came before it.
|
|
223
|
-
@command_queue << command.merge(after_seq: @engine.event_count)
|
|
224
|
-
@waker.wake
|
|
225
|
-
},
|
|
225
|
+
on_command: method(:on_command),
|
|
226
226
|
on_exit_request: method(:exit_request),
|
|
227
227
|
# Decided again as it leaves (a note may still come in).
|
|
228
228
|
exit_discards: method(:empty_session?))
|
|
@@ -307,6 +307,9 @@ module Samagotchi
|
|
|
307
307
|
# A stop between two queued turns leaves the rest queued.
|
|
308
308
|
break if stopped_on_disk?
|
|
309
309
|
|
|
310
|
+
# Commands that came before this file (D1: a queued /model X sent
|
|
311
|
+
# before this prompt, a command an earlier file carried) run first.
|
|
312
|
+
run_queued_commands
|
|
310
313
|
@inbound.absorb_notes
|
|
311
314
|
run_input_file(input_file)
|
|
312
315
|
end
|
|
@@ -334,6 +337,7 @@ module Samagotchi
|
|
|
334
337
|
# group) before the Engine does.
|
|
335
338
|
def teardown
|
|
336
339
|
@context_poller&.stop
|
|
340
|
+
drop_queued_commands
|
|
337
341
|
@engine&.shutdown
|
|
338
342
|
@bridge&.stop
|
|
339
343
|
# The process exits next: a parent wake it rang for (a crash's
|
|
@@ -549,29 +553,122 @@ module Samagotchi
|
|
|
549
553
|
|
|
550
554
|
def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
|
|
551
555
|
|
|
556
|
+
# The queued commands that may run now, in order, up to one whose
|
|
557
|
+
# after_file (D1) still waits in the inbox: it and those behind it stay
|
|
558
|
+
# queued.
|
|
552
559
|
# @return [Boolean] whether any command ran
|
|
553
560
|
def run_queued_commands
|
|
554
561
|
ran = false
|
|
555
|
-
while (command =
|
|
562
|
+
while (command = next_ready_command)
|
|
556
563
|
run_command(command)
|
|
557
564
|
ran = true
|
|
558
565
|
end
|
|
559
566
|
ran
|
|
560
567
|
end
|
|
561
568
|
|
|
562
|
-
#
|
|
569
|
+
# Pops the first queued command if it may run now (its after_file is
|
|
570
|
+
# taken), else none: one behind it waits too, in arrival order (an idle
|
|
571
|
+
# /model Y that came just after the turn ended stays behind a /model X
|
|
572
|
+
# still waiting for a prompt sent before it). With the event log held,
|
|
573
|
+
# as the Bridge queues.
|
|
574
|
+
def next_ready_command
|
|
575
|
+
return nil if @command_queue.empty?
|
|
576
|
+
|
|
577
|
+
@engine.synchronize_events do
|
|
578
|
+
commands = drain_commands
|
|
579
|
+
first = commands.first
|
|
580
|
+
ready = first && ready_command?(first) ? commands.shift : nil
|
|
581
|
+
commands.each { |command| @command_queue << command }
|
|
582
|
+
ready
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def ready_command?(command)
|
|
587
|
+
return true if command[:after_file].nil?
|
|
588
|
+
|
|
589
|
+
SessionInbox.find_new_input_files(@session_dir).none? { |path| File.basename(path) <= command[:after_file] }
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def drain_commands
|
|
593
|
+
commands = []
|
|
594
|
+
while (command = next_command)
|
|
595
|
+
commands << command
|
|
596
|
+
end
|
|
597
|
+
commands
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# A command a turn refuses (:refuse while it ran, S1) is answered busy:
|
|
563
601
|
# at the turn's iteration boundaries (mid_turn: all of them), and when
|
|
564
602
|
# it ends (the ones queued before its end event; later ones run next).
|
|
603
|
+
# A queued one (:queue) stays for after the turn, but a !cmd after a
|
|
604
|
+
# canceled or failed turn (D4): running it would close the rollback
|
|
605
|
+
# window the turn left, so it is dropped. With the event log held, so
|
|
606
|
+
# a command the Bridge queues meanwhile stays behind the ones kept, in
|
|
607
|
+
# arrival order. One that comes after the empty look is the next
|
|
608
|
+
# boundary's (or end's), as it would be.
|
|
565
609
|
def refuse_queued_commands(mid_turn: false)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
610
|
+
return if @command_queue.empty?
|
|
611
|
+
|
|
612
|
+
@engine.synchronize_events do
|
|
613
|
+
drain_commands.each do |command|
|
|
614
|
+
during = mid_turn || command[:after_seq].to_i < @turn_end_seq
|
|
615
|
+
case (during ? turn_answer(command, ended: !mid_turn) : :keep)
|
|
616
|
+
when :refuse then announce_command(command, status: "busy", output: busy_output(command), changed: [])
|
|
617
|
+
when :drop then drop_command(waiting_for_turn_end(command), "turn #{@turn_end_type == :turn_failed ? "failed" : "canceled"}: " \
|
|
618
|
+
"#{command[:line]} not run; send it again")
|
|
619
|
+
else @command_queue << (during ? waiting_for_turn_end(command) : command)
|
|
620
|
+
end
|
|
572
621
|
end
|
|
573
622
|
end
|
|
574
|
-
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# What a turn does with +command+, queued while it ran: :refuse, :drop
|
|
626
|
+
# (D4, once it +ended+) or :keep. One queued idle just as the turn
|
|
627
|
+
# began (:loop) goes by its line's policy (a show form waits too).
|
|
628
|
+
def turn_answer(command, ended:)
|
|
629
|
+
policy = command[:mid_turn]
|
|
630
|
+
policy = @engine.command_registry.mid_turn(command[:line]) if policy == :loop
|
|
631
|
+
return :refuse if policy == :refuse
|
|
632
|
+
return :drop if ended && policy == :queue && shell_command?(command) &&
|
|
633
|
+
%i[turn_canceled turn_failed].include?(@turn_end_type)
|
|
634
|
+
|
|
635
|
+
:keep
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# +command+, kept for after the turn. One queued idle just as the turn
|
|
639
|
+
# began (:loop) whose line waits for a turn's end (/model X, !cmd) is
|
|
640
|
+
# marked as one queued mid-turn: its command_queued (waits) tells the
|
|
641
|
+
# UIs now, and its command_ran says queued.
|
|
642
|
+
def waiting_for_turn_end(command)
|
|
643
|
+
return command unless command[:mid_turn] == :loop && @engine.command_registry.mid_turn(command[:line]) == :queue
|
|
644
|
+
|
|
645
|
+
event = { type: :command_queued, **command.slice(:command_id, :client_id, :line), waits: "turn_end" }
|
|
646
|
+
event[:card] = true if command[:card]
|
|
647
|
+
@engine.announce(event)
|
|
648
|
+
command.merge(mid_turn: :queue)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def shell_command?(command) = @engine.command_registry.lookup(command[:line])&.id == :shell
|
|
652
|
+
|
|
653
|
+
def busy_output(command)
|
|
654
|
+
@engine.command_registry.lookup(command[:line])&.id == :rollback ? ROLLBACK_BUSY_OUTPUT : BUSY_OUTPUT
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# +command+ won't run: its sender (a UI's queued bubble) is told.
|
|
658
|
+
def drop_command(command, output)
|
|
659
|
+
announce_command(command, status: "dropped", output: output, changed: [])
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# The worker leaves with commands still queued (a stop, a crash): each
|
|
663
|
+
# one's sender is told it won't run, so no UI shows it waiting for good.
|
|
664
|
+
def drop_queued_commands
|
|
665
|
+
return if @command_queue.nil? || @command_queue.empty? || @engine.nil?
|
|
666
|
+
|
|
667
|
+
@engine.synchronize_events do
|
|
668
|
+
drain_commands.each { |command| drop_command(command, "dropped: the session's worker stopped before it ran") }
|
|
669
|
+
end
|
|
670
|
+
rescue StandardError => e
|
|
671
|
+
Log.warn(:worker, "drop_commands_failed", error: e.class.name, msg: e.message)
|
|
575
672
|
end
|
|
576
673
|
|
|
577
674
|
def next_command
|
|
@@ -607,7 +704,44 @@ module Samagotchi
|
|
|
607
704
|
[result || SessionCommands::Result.new(status: :error, output: "not a session command", changed: []), shown]
|
|
608
705
|
end
|
|
609
706
|
|
|
610
|
-
|
|
707
|
+
# A session command from the Bridge, called with the event log held:
|
|
708
|
+
# an anytime one starts now, any other is queued for the loop (and
|
|
709
|
+
# refused if a turn runs: #refuse_queued_commands). While a turn runs
|
|
710
|
+
# the line's policy decides (a show form like /model runs now); idle,
|
|
711
|
+
# only an always-anytime command (/help) runs beside the loop, so an
|
|
712
|
+
# idle /model X then /model run in order. A turn starting or ending
|
|
713
|
+
# right now may see the other side: the line is only shown early or
|
|
714
|
+
# run in order.
|
|
715
|
+
#
|
|
716
|
+
# A queued one (:queue) keeps arrival order with the prompts (D1): it
|
|
717
|
+
# runs once every input file written before it is taken (after_file,
|
|
718
|
+
# the newest one now; names compare, not clocks), so a prompt sent
|
|
719
|
+
# before it runs first and one sent after it runs after. One that came
|
|
720
|
+
# in an input file runs once that file is taken (+after_file+).
|
|
721
|
+
# @return [Symbol] the policy it applied (the Bridge marks an anytime
|
|
722
|
+
# one's command_queued, and a queued one's): :anytime, :queue or
|
|
723
|
+
# :refuse while a turn runs, :loop idle
|
|
724
|
+
def on_command(command, after_file: nil)
|
|
725
|
+
registry = @engine.command_registry
|
|
726
|
+
policy = if @engine.turn_running? then registry.mid_turn(command[:line])
|
|
727
|
+
else registry.anytime?(command[:line]) ? :anytime : :loop
|
|
728
|
+
end
|
|
729
|
+
if policy == :anytime
|
|
730
|
+
start_anytime_command(command)
|
|
731
|
+
return policy
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
after_file ||= newest_input_file if policy == :queue
|
|
735
|
+
# The count says which turn ends came before it.
|
|
736
|
+
@command_queue << command.merge(after_seq: @engine.event_count, mid_turn: policy, after_file: after_file).compact
|
|
737
|
+
@waker.wake
|
|
738
|
+
policy
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# @return [String, nil] the newest input file waiting (its basename)
|
|
742
|
+
def newest_input_file
|
|
743
|
+
SessionInbox.find_new_input_files(@session_dir).map { |path| File.basename(path) }.max
|
|
744
|
+
end
|
|
611
745
|
|
|
612
746
|
# An anytime command (/help, a plugin's /btw; D8) runs now, on its own
|
|
613
747
|
# thread, never queued behind a turn: it is never busy. The Bridge calls
|
|
@@ -640,6 +774,8 @@ module Samagotchi
|
|
|
640
774
|
status: status, output: text[0, COMMAND_OUTPUT_LIMIT], changed: changed.map(&:to_s),
|
|
641
775
|
model_name: @engine.effective_model_name }
|
|
642
776
|
event[:anytime] = true if anytime
|
|
777
|
+
# Its line was shown at its command_queued (waits: turn_end).
|
|
778
|
+
event[:queued] = true if command[:mid_turn] == :queue
|
|
643
779
|
event[:card] = true if command[:card]
|
|
644
780
|
# /llm-context or /model: what the next turn runs under now (the web's chip).
|
|
645
781
|
event[:llm_context] = @engine.llm_context_summary if changed.intersect?(%i[llm_context model])
|
|
@@ -61,8 +61,9 @@ module Samagotchi
|
|
|
61
61
|
# @param wakes [WorkerWakes] the wake budget a context wake asks
|
|
62
62
|
# @param awaiting_continue [#call] whether a continue offer waits
|
|
63
63
|
# @param stopped [#call] whether the session was stopped on disk
|
|
64
|
-
# @param queue_command [#call] (line, client_id) queues a
|
|
65
|
-
# command for the loop's next pass
|
|
64
|
+
# @param queue_command [#call] (line, client_id, after_file) queues a
|
|
65
|
+
# session command for the loop's next pass; after_file is the input
|
|
66
|
+
# file it came in, or nil
|
|
66
67
|
def initialize(session:, state_dir:, session_dir:, engine:, context_absorber:, wakes:, awaiting_continue:,
|
|
67
68
|
stopped:, queue_command:)
|
|
68
69
|
@session = session
|
|
@@ -182,7 +183,7 @@ module Samagotchi
|
|
|
182
183
|
begin
|
|
183
184
|
message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
|
|
184
185
|
return if message.to_s.strip.empty?
|
|
185
|
-
return if Array(images).empty? && queue_as_command(message, origin)
|
|
186
|
+
return if Array(images).empty? && queue_as_command(message, origin, File.basename(input_file))
|
|
186
187
|
|
|
187
188
|
yield Prompt.new(text: message, origin: origin, no_interrupt: !!no_interrupt, images: images || [])
|
|
188
189
|
ensure
|
|
@@ -211,10 +212,10 @@ module Samagotchi
|
|
|
211
212
|
# POST /turn; an unknown /word stays a prompt. Queued: the loop's next
|
|
212
213
|
# pass runs it.
|
|
213
214
|
# @return [Boolean] whether +text+ was one
|
|
214
|
-
def queue_as_command(text, origin)
|
|
215
|
+
def queue_as_command(text, origin, after_file = nil)
|
|
215
216
|
return false unless @engine.command_registry.command?(text.to_s)
|
|
216
217
|
|
|
217
|
-
@queue_command.call(text, origin&.dig(:client_id))
|
|
218
|
+
@queue_command.call(text, origin&.dig(:client_id), after_file)
|
|
218
219
|
true
|
|
219
220
|
end
|
|
220
221
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.45.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -315,6 +315,7 @@ files:
|
|
|
315
315
|
- lib/samagotchi/llm_context_command.rb
|
|
316
316
|
- lib/samagotchi/llm_context_edit.rb
|
|
317
317
|
- lib/samagotchi/llm_context_forget.rb
|
|
318
|
+
- lib/samagotchi/llm_context_notice.rb
|
|
318
319
|
- lib/samagotchi/llm_context_override.rb
|
|
319
320
|
- lib/samagotchi/llm_context_stale.rb
|
|
320
321
|
- lib/samagotchi/llm_context_strategy.rb
|