samagotchi 0.44.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 +70 -1
- data/docs/cli.md +29 -7
- data/docs/context.md +2 -1
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- 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/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- 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 +85 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +19 -1
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +7 -9
- data/lib/samagotchi/web/public/app.js +23 -15
- 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 +3 -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 +16 -4
- 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 +1 -1
|
@@ -126,6 +126,10 @@ module Samagotchi
|
|
|
126
126
|
@early_lines = []
|
|
127
127
|
@question = nil
|
|
128
128
|
@answered_ids = Set.new
|
|
129
|
+
# Commands queued for after the running turn, any UI's: a /archive
|
|
130
|
+
# typed during the turn waits for them too (#archive_due?).
|
|
131
|
+
@queued_ids = Set.new
|
|
132
|
+
@archive_after_turn = false
|
|
129
133
|
@reader = nil
|
|
130
134
|
@first_prompt = first_prompt
|
|
131
135
|
@first_commands = Array(first_command)
|
|
@@ -188,6 +192,7 @@ module Samagotchi
|
|
|
188
192
|
when :event
|
|
189
193
|
ended = safely_handle(payload)
|
|
190
194
|
return ended if %i[closed failed unanswered].include?(ended)
|
|
195
|
+
return archive_now if archive_due?
|
|
191
196
|
|
|
192
197
|
ended = take_early_lines
|
|
193
198
|
return ended if ended
|
|
@@ -264,9 +269,11 @@ module Samagotchi
|
|
|
264
269
|
when :answer_display then @display_pending = false
|
|
265
270
|
when :context_status, :used_memories_updated then @status.take_event(event)
|
|
266
271
|
# Another client's anytime command: its line now, before the cards
|
|
267
|
-
# it shows (its command_ran comes when it's done).
|
|
272
|
+
# it shows (its command_ran comes when it's done). One queued for
|
|
273
|
+
# after the turn says so now.
|
|
268
274
|
when :command_queued
|
|
269
275
|
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
|
|
276
|
+
command_waits(event) if event[:waits] && !event[:card]
|
|
270
277
|
when :command_ran
|
|
271
278
|
command_ran(event)
|
|
272
279
|
return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
|
|
@@ -379,6 +386,7 @@ module Samagotchi
|
|
|
379
386
|
local = command_registry.lookup_local(text)&.id
|
|
380
387
|
return submit(nil) if local == :detach
|
|
381
388
|
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
389
|
+
return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
|
|
382
390
|
return exit_and_archive if local == :archive
|
|
383
391
|
|
|
384
392
|
if @continue_offer
|
|
@@ -446,6 +454,24 @@ module Samagotchi
|
|
|
446
454
|
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
|
|
447
455
|
end
|
|
448
456
|
|
|
457
|
+
# /archive during a turn (D2): it waits for the turn's end and the
|
|
458
|
+
# commands queued behind it (#archive_due?), then runs.
|
|
459
|
+
# @return [nil]
|
|
460
|
+
def archive_after_turn
|
|
461
|
+
@archive_after_turn = true
|
|
462
|
+
@screen.commit("(archives after this turn)")
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
|
|
467
|
+
|
|
468
|
+
# @return [Symbol] :detached
|
|
469
|
+
def archive_now
|
|
470
|
+
@archive_after_turn = false
|
|
471
|
+
exit_and_archive
|
|
472
|
+
:detached
|
|
473
|
+
end
|
|
474
|
+
|
|
449
475
|
# /archive: the worker is asked to exit as for /exit, then the session
|
|
450
476
|
# is archived. A worker staying up for another UI (or reminders) is
|
|
451
477
|
# stopped by the archive, as the web's archive stops it; a turn
|
|
@@ -546,13 +572,36 @@ module Samagotchi
|
|
|
546
572
|
"worker unreachable: #{head.downcase}"
|
|
547
573
|
end
|
|
548
574
|
|
|
575
|
+
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
576
|
+
|
|
577
|
+
# A command queued for after the running turn (command_queued with
|
|
578
|
+
# waits): its line now (another client's) and a note; its command_ran
|
|
579
|
+
# has no line again (queued: true).
|
|
580
|
+
def command_waits(event)
|
|
581
|
+
@queued_ids << event[:command_id]
|
|
582
|
+
if own?(event[:client_id])
|
|
583
|
+
first = event[:command_id] == @first_command_id
|
|
584
|
+
return @screen.commit(first ? first_command_queued : QUEUED_NOTE)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
@screen.commit("#{prompt_line(event[:client_id], event[:line])} #{QUEUED_NOTE}")
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def first_command_queued
|
|
591
|
+
flag = @first_command_line.to_s.start_with?("/model") ? "--model" : "--llm-context"
|
|
592
|
+
"(#{flag} queued: applies after the running turn)"
|
|
593
|
+
end
|
|
594
|
+
|
|
549
595
|
def command_ran(event)
|
|
550
596
|
@open_ids.delete(event[:command_id])
|
|
551
|
-
@
|
|
597
|
+
@queued_ids.delete(event[:command_id])
|
|
598
|
+
unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
|
|
599
|
+
@screen.commit(prompt_line(event[:client_id], event[:line]))
|
|
600
|
+
end
|
|
552
601
|
output = event[:output].to_s
|
|
553
602
|
if output.empty?
|
|
554
603
|
nil
|
|
555
|
-
elsif event[:status]
|
|
604
|
+
elsif %w[busy dropped].include?(event[:status]) || PromptHistory.shell_line?(event[:line])
|
|
556
605
|
@screen.commit(output)
|
|
557
606
|
put_back_busy_command(event) if event[:status] == "busy"
|
|
558
607
|
else
|
|
@@ -992,6 +1041,14 @@ module Samagotchi
|
|
|
992
1041
|
@shown_enqueued << entry[:enqueued_id]
|
|
993
1042
|
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
|
|
994
1043
|
end
|
|
1044
|
+
# Commands waiting for the turn's end (their command_ran prints no
|
|
1045
|
+
# line); a resync prints only the ones it hadn't shown.
|
|
1046
|
+
shown = @queued_ids.dup
|
|
1047
|
+
@queued_ids.clear
|
|
1048
|
+
Array(snapshot[:queued_commands]).each do |entry|
|
|
1049
|
+
@queued_ids << entry[:command_id]
|
|
1050
|
+
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:line])}") unless shown.include?(entry[:command_id])
|
|
1051
|
+
end
|
|
995
1052
|
return send_first_command unless @first_commands.empty?
|
|
996
1053
|
|
|
997
1054
|
send_first_prompt
|
|
@@ -1076,10 +1133,18 @@ module Samagotchi
|
|
|
1076
1133
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1077
1134
|
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1078
1135
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
1136
|
+
failed = join_failed_record(exchange[last_user])
|
|
1079
1137
|
# A turn that ended with no answer: its notice, as live.
|
|
1080
1138
|
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
1081
1139
|
if empty
|
|
1082
1140
|
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
|
|
1141
|
+
elsif failed
|
|
1142
|
+
# A failed turn whose steps stayed: the line the live view left,
|
|
1143
|
+
# and no step text as if it were the answer (the web keeps a
|
|
1144
|
+
# failed turn's texts as steps too).
|
|
1145
|
+
failure = failed["failure"]
|
|
1146
|
+
@screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
|
|
1147
|
+
@screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
|
|
1083
1148
|
else
|
|
1084
1149
|
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
1085
1150
|
# be only a tool call.
|
|
@@ -1119,6 +1184,23 @@ module Samagotchi
|
|
|
1119
1184
|
[]
|
|
1120
1185
|
end
|
|
1121
1186
|
|
|
1187
|
+
# The turn's saved record when it failed with the fields its line is
|
|
1188
|
+
# made of (SessionMetrics#failure_fields), else nil: a completed,
|
|
1189
|
+
# canceled or older turn — or one saved before the failure field —
|
|
1190
|
+
# draws its answer as before.
|
|
1191
|
+
def join_failed_record(prompt)
|
|
1192
|
+
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
|
|
1193
|
+
prompt[:turn_id] || prompt["turn_id"])
|
|
1194
|
+
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
1195
|
+
|
|
1196
|
+
failure = record["failure"]
|
|
1197
|
+
return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
|
|
1198
|
+
|
|
1199
|
+
record
|
|
1200
|
+
rescue Session::InvalidId
|
|
1201
|
+
nil
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1122
1204
|
# The duration of +part+'s call: the next record of its tool, in call
|
|
1123
1205
|
# order, consumed with the ones it skipped. By tool and order, not by
|
|
1124
1206
|
# iteration: an empty retry spends an iteration that left no message.
|
|
@@ -486,7 +486,7 @@ module Samagotchi
|
|
|
486
486
|
lines << "output cap hits: #{snapshot[:capped].to_i}"
|
|
487
487
|
context = snapshot[:context] || {}
|
|
488
488
|
if context[:used_tokens]
|
|
489
|
-
pct = context
|
|
489
|
+
pct = context_pct_text(context, snapshot[:llm_context])
|
|
490
490
|
lines << "context used: #{context[:used_tokens]} tokens#{pct}"
|
|
491
491
|
end
|
|
492
492
|
lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
|
|
@@ -506,6 +506,24 @@ module Samagotchi
|
|
|
506
506
|
lines.join("\n")
|
|
507
507
|
end
|
|
508
508
|
|
|
509
|
+
# " (25.0% of the 64000 budget)" when the session's llm_context budget
|
|
510
|
+
# is set and smaller than the window, else " (12.5%)": the live meter
|
|
511
|
+
# counts against the smaller of the two (ContextStatus#counted_against),
|
|
512
|
+
# and so does this. The summary is Explained#summary (symbol or string
|
|
513
|
+
# keys: the attached TUI's come as JSON); "" when there is no window.
|
|
514
|
+
def context_pct_text(context, llm_context)
|
|
515
|
+
window = context[:window_tokens].to_i
|
|
516
|
+
return "" unless window.positive?
|
|
517
|
+
|
|
518
|
+
summary = llm_context.is_a?(Hash) ? llm_context : {}
|
|
519
|
+
budget = summary[:budget_tokens] || summary["budget_tokens"]
|
|
520
|
+
budget = nil unless budget.is_a?(Numeric) && budget.positive?
|
|
521
|
+
used = context[:used_tokens] * 100.0
|
|
522
|
+
return format(" (%.1f%%)", used / window) unless budget && budget < window
|
|
523
|
+
|
|
524
|
+
format(" (%.1f%% of the %d budget)", used / budget, budget)
|
|
525
|
+
end
|
|
526
|
+
|
|
509
527
|
# "stale,forget (the session); apply turn_end (models: x); budget
|
|
510
528
|
# 64000 (the session)" for /stats (Explained#summary; symbol or
|
|
511
529
|
# string keys, the attached TUI's come as JSON); nil without one.
|
|
@@ -60,6 +60,9 @@ module Samagotchi
|
|
|
60
60
|
REMINDER_PENDING_POLL_INTERVAL = 0.5
|
|
61
61
|
# What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
|
|
62
62
|
COMMAND_BUSY = "busy: wait for the turn to end"
|
|
63
|
+
ROLLBACK_BUSY = "busy: Ctrl-C the turn first, then !rollback"
|
|
64
|
+
# A command typed during a turn that runs after it (/model X, !cmd).
|
|
65
|
+
COMMAND_QUEUED = "(queued: runs after this turn)"
|
|
63
66
|
# /detach is the attached terminal's; the REPL owns its session.
|
|
64
67
|
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
65
68
|
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
@@ -569,8 +572,10 @@ module Samagotchi
|
|
|
569
572
|
# Not an answer to a continue offer either.
|
|
570
573
|
next detach_note if local == :detach
|
|
571
574
|
|
|
572
|
-
# /stats and /recap run; the offer stays open.
|
|
573
|
-
|
|
575
|
+
# /stats and /recap run; the offer stays open. So does a session
|
|
576
|
+
# command (one queued during the turn: /model X, !cmd); /continue
|
|
577
|
+
# answers it.
|
|
578
|
+
if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local) && !offer_leaves_open?(input)
|
|
574
579
|
answer_continue_offer(session, input)
|
|
575
580
|
else
|
|
576
581
|
# The ? read left no echo: show what ran.
|
|
@@ -714,6 +719,13 @@ module Samagotchi
|
|
|
714
719
|
|
|
715
720
|
# An answer at the ? prompt of a continue offer. A valid one closes the
|
|
716
721
|
# offer's choices and leaves one line; an invalid one gets its error.
|
|
722
|
+
# Whether +input+ is a session command that runs past a continue offer,
|
|
723
|
+
# leaving it open: any but /continue (the worker's S2).
|
|
724
|
+
def offer_leaves_open?(input)
|
|
725
|
+
entry = command_registry.lookup(input)
|
|
726
|
+
!entry.nil? && entry.id != :continue
|
|
727
|
+
end
|
|
728
|
+
|
|
717
729
|
def answer_continue_offer(session, input)
|
|
718
730
|
answer = @commands.continue_answer(input)
|
|
719
731
|
if answer.decision == :invalid
|
|
@@ -743,7 +755,7 @@ module Samagotchi
|
|
|
743
755
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
744
756
|
# an anytime command's cards print as it shows them (btw's
|
|
745
757
|
# "thinking…" before the answer).
|
|
746
|
-
command = if command_registry.
|
|
758
|
+
command = if command_registry.anytime?(input)
|
|
747
759
|
@engine.running_anytime { @commands.run(input) }
|
|
748
760
|
else
|
|
749
761
|
@commands.run(input)
|
|
@@ -1031,26 +1043,58 @@ module Samagotchi
|
|
|
1031
1043
|
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
1032
1044
|
# merges at the next iteration boundary (Kernel), and one that comes after
|
|
1033
1045
|
# the last runs as the next turn. Reminder turns too.
|
|
1046
|
+
#
|
|
1047
|
+
# A command that waits for the turn's end (/model X, !cmd) and every line
|
|
1048
|
+
# after it wait in @after_turn, in the order typed (D1), with the image
|
|
1049
|
+
# lines (which never steer): once the turn ends they come back after the
|
|
1050
|
+
# steering it didn't merge (typed before them), ahead of anything sent
|
|
1051
|
+
# after Ctrl-C.
|
|
1034
1052
|
def with_steering(&)
|
|
1035
1053
|
return yield unless @repl_input
|
|
1036
1054
|
|
|
1037
|
-
@
|
|
1055
|
+
@after_turn = []
|
|
1056
|
+
@command_waits = false
|
|
1057
|
+
@repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain + lines_after_turn }, &)
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
# The lines that waited for the turn's end, taken from @after_turn. After
|
|
1061
|
+
# a canceled or failed turn (Ctrl-C raises through here; a failure too)
|
|
1062
|
+
# a !cmd among them is dropped (D4): running it would close the
|
|
1063
|
+
# !rollback the turn left.
|
|
1064
|
+
def lines_after_turn
|
|
1065
|
+
lines = @after_turn.slice!(0..)
|
|
1066
|
+
ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
|
|
1067
|
+
elsif $! then "failed"
|
|
1068
|
+
end
|
|
1069
|
+
return lines unless ended
|
|
1070
|
+
|
|
1071
|
+
lines.reject do |line|
|
|
1072
|
+
next false unless command_registry.lookup(line)&.id == :shell
|
|
1073
|
+
|
|
1074
|
+
@surface.commit("turn #{ended}: #{line.strip} not run; send it again")
|
|
1075
|
+
true
|
|
1076
|
+
end
|
|
1038
1077
|
end
|
|
1039
1078
|
|
|
1040
1079
|
# On the reader thread, from ReplInput: takes a line for the running turn.
|
|
1041
1080
|
# A line sent after Ctrl-C waits for the next turn (the kernel would not
|
|
1042
1081
|
# merge it into the cancelled one). Ctrl-D or exit ends the REPL after
|
|
1043
1082
|
# the turn. /stats and /recap run now, an anytime command (/help, a
|
|
1044
|
-
# plugin's /btw) starts now on its own thread;
|
|
1045
|
-
# in the
|
|
1083
|
+
# plugin's /btw, /model alone) starts now on its own thread; one that
|
|
1084
|
+
# runs after the turn (/model X, !cmd) waits in the queue, in arrival
|
|
1085
|
+
# order with the other lines that wait; others go back in the prompt.
|
|
1046
1086
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1047
1087
|
def steer_line(line)
|
|
1048
1088
|
local = local_command(line) unless line.nil?
|
|
1049
1089
|
return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
|
|
1090
|
+
return archive_after_turn if local == :archive
|
|
1050
1091
|
return detach_note if local == :detach
|
|
1051
1092
|
return false if @active_cancel_controller&.cancelled?
|
|
1052
|
-
|
|
1053
|
-
|
|
1093
|
+
|
|
1094
|
+
policy = command_registry.mid_turn(line)
|
|
1095
|
+
return start_anytime_command(line) if policy == :anytime
|
|
1096
|
+
return command_queued(line) if policy == :queue
|
|
1097
|
+
# A command, never steering text.
|
|
1054
1098
|
return command_during_turn(line) if local || command_registry.command?(line)
|
|
1055
1099
|
return true if line.strip.empty?
|
|
1056
1100
|
|
|
@@ -1060,7 +1104,9 @@ module Samagotchi
|
|
|
1060
1104
|
return true
|
|
1061
1105
|
end
|
|
1062
1106
|
# Steering merges text only: a line with images runs as the next turn.
|
|
1063
|
-
return image_line_waits if ImageInput.extract(line).any?
|
|
1107
|
+
return image_line_waits(line) if ImageInput.extract(line).any?
|
|
1108
|
+
# Behind a command that waits for the turn's end, a line waits too.
|
|
1109
|
+
return wait_for_turn_end(line) if @command_waits
|
|
1064
1110
|
|
|
1065
1111
|
epoch = @engine.input_epoch
|
|
1066
1112
|
@pending_input_queue.push(line.strip)
|
|
@@ -1071,9 +1117,16 @@ module Samagotchi
|
|
|
1071
1117
|
true
|
|
1072
1118
|
end
|
|
1073
1119
|
|
|
1074
|
-
def image_line_waits
|
|
1120
|
+
def image_line_waits(line)
|
|
1075
1121
|
@surface.commit(IMAGE_LINE_WAITS)
|
|
1076
|
-
|
|
1122
|
+
wait_for_turn_end(line)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# +line+ runs once the turn has ended, in the order typed (#with_steering).
|
|
1126
|
+
# @return [true] the turn took it
|
|
1127
|
+
def wait_for_turn_end(line)
|
|
1128
|
+
@after_turn << line
|
|
1129
|
+
true
|
|
1077
1130
|
end
|
|
1078
1131
|
|
|
1079
1132
|
def detach_note
|
|
@@ -1081,6 +1134,18 @@ module Samagotchi
|
|
|
1081
1134
|
true
|
|
1082
1135
|
end
|
|
1083
1136
|
|
|
1137
|
+
# /archive during a turn (D2): the REPL leaves and archives once the
|
|
1138
|
+
# turn and the lines queued behind it (S5) ran, as /exit does.
|
|
1139
|
+
# @return [true]
|
|
1140
|
+
def archive_after_turn
|
|
1141
|
+
return @surface.commit(SCRATCH_ARCHIVE_REFUSED).then { true } if @scratch
|
|
1142
|
+
|
|
1143
|
+
@exit_after_turn = true
|
|
1144
|
+
@archive_on_exit = true
|
|
1145
|
+
@surface.commit("(archives after this turn; Ctrl-C cancels it)")
|
|
1146
|
+
true
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1084
1149
|
def exit_after_turn(delete: false)
|
|
1085
1150
|
@exit_after_turn = true
|
|
1086
1151
|
@delete_on_exit = true if delete
|
|
@@ -1098,10 +1163,17 @@ module Samagotchi
|
|
|
1098
1163
|
def command_during_turn(line)
|
|
1099
1164
|
return true if show_local_command(line)
|
|
1100
1165
|
|
|
1101
|
-
@surface.commit(COMMAND_BUSY)
|
|
1166
|
+
@surface.commit(command_registry.lookup(line)&.id == :rollback ? ROLLBACK_BUSY : COMMAND_BUSY)
|
|
1102
1167
|
:back
|
|
1103
1168
|
end
|
|
1104
1169
|
|
|
1170
|
+
# @return [true] it waits for the turn's end (#wait_for_turn_end)
|
|
1171
|
+
def command_queued(line)
|
|
1172
|
+
@surface.commit(COMMAND_QUEUED)
|
|
1173
|
+
@command_waits = true
|
|
1174
|
+
wait_for_turn_end(line)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1105
1177
|
# D8: an anytime command runs on its own thread while the turn goes on
|
|
1106
1178
|
# (it reads copies, and shows things through its ctx). What it prints
|
|
1107
1179
|
# while the turn runs goes above the live region now, its cards as it
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -488,15 +488,10 @@ module Samagotchi
|
|
|
488
488
|
# A session's LLM context strategy, apply rule and budget, each with
|
|
489
489
|
# where it came from (LLMContextStrategy::Explained#summary), as its
|
|
490
490
|
# worker would resolve them; nil when they can't be.
|
|
491
|
+
# (LLMContextStrategy.for_session, shared with the ctx % the session
|
|
492
|
+
# lists count.)
|
|
491
493
|
def llm_context_for(session)
|
|
492
|
-
registry
|
|
493
|
-
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
494
|
-
target = registry.resolve(name)
|
|
495
|
-
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
496
|
-
own = session.llm_context
|
|
497
|
-
LLMContextStrategy.explain(target, names: names, **(own&.resolve_args || {})).summary(own)
|
|
498
|
-
rescue StandardError
|
|
499
|
-
nil
|
|
494
|
+
LLMContextStrategy.for_session(session, registry: host_registry)
|
|
500
495
|
end
|
|
501
496
|
|
|
502
497
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
@@ -679,6 +674,8 @@ module Samagotchi
|
|
|
679
674
|
failed_turn: failed_turn_for(session, raw_messages),
|
|
680
675
|
current_turn: current_turn,
|
|
681
676
|
queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
|
|
677
|
+
# Commands waiting for the running turn's end (their bubbles).
|
|
678
|
+
queued_commands: turn_snapshot ? Array(turn_snapshot["queued_commands"]) : [],
|
|
682
679
|
recap: turn_snapshot && turn_snapshot["recap"],
|
|
683
680
|
saved_recap: saved_recap_for(id, session, turn_snapshot),
|
|
684
681
|
continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
|
|
@@ -1496,7 +1493,8 @@ module Samagotchi
|
|
|
1496
1493
|
# @param owner [OwnerLock::Owner, nil] #session_owner; its kind is shown as `owner`
|
|
1497
1494
|
def session_to_json(s, status: s.status, owner: nil)
|
|
1498
1495
|
SessionSummary.build(s, status: status, owner: owner,
|
|
1499
|
-
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir)
|
|
1496
|
+
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir),
|
|
1497
|
+
registry: host_registry)
|
|
1500
1498
|
end
|
|
1501
1499
|
|
|
1502
1500
|
# The whole timing: analytics.json's records merged with a live
|
|
@@ -447,16 +447,20 @@ function fitStripColumns() {
|
|
|
447
447
|
// The order a list draws its cards in: +next+, what waits on the user
|
|
448
448
|
// first (waitingFirst), back to its place once answered. Never under the
|
|
449
449
|
// pointer, though: while it is over the list the order last drawn holds,
|
|
450
|
-
// and its leaving draws the list again.
|
|
451
|
-
//
|
|
452
|
-
//
|
|
450
|
+
// and its leaving draws the list again. A hidden list holds nothing: the
|
|
451
|
+
// strip keeps :hover for a moment after a click on its All sessions tile
|
|
452
|
+
// hid it, and an update held then brought it back from the all view in an
|
|
453
|
+
// order nobody saw (a waiting family behind). +idOf+ names a card: a
|
|
454
|
+
// session's id, or familyId (a card per family: its head's id). drawnOrder
|
|
455
|
+
// is the cards drawn, which select mode picks from.
|
|
453
456
|
const drawnOrder = new Map(); // list element → card ids in the order last drawn
|
|
454
457
|
const heldLists = new Set(); // lists drawn in a held order
|
|
455
458
|
const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
|
|
456
459
|
function listOrder(el, next, idOf = (s) => s.id) {
|
|
457
460
|
// The strip's popover holds it too (the pointer leaves the strip for it,
|
|
458
461
|
// and a touch screen has no hover at all).
|
|
459
|
-
const holding =
|
|
462
|
+
const holding = el.getClientRects().length > 0
|
|
463
|
+
&& ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()));
|
|
460
464
|
const held = holding ? drawnOrder.get(el) : null;
|
|
461
465
|
const ordered = held ? heldOrder(held, next, idOf) : next;
|
|
462
466
|
if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
|
|
@@ -1160,7 +1164,7 @@ function renderSessionView(data) {
|
|
|
1160
1164
|
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
1161
1165
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
1162
1166
|
// next turn (a running turn's replay below streams over it).
|
|
1163
|
-
const savedPct = savedCtxPct(data.timing?.context);
|
|
1167
|
+
const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
|
|
1164
1168
|
if (savedPct !== null) current.ctxPct = savedPct;
|
|
1165
1169
|
if (data.timing?.context?.window_tokens) {
|
|
1166
1170
|
current.ctxWindow = data.timing.context.window_tokens;
|
|
@@ -1211,6 +1215,7 @@ function renderSessionView(data) {
|
|
|
1211
1215
|
const replay = snapshotEvents({
|
|
1212
1216
|
current_turn: data.current_turn,
|
|
1213
1217
|
queued: data.queued,
|
|
1218
|
+
queued_commands: data.queued_commands,
|
|
1214
1219
|
started_at: current.timing.activeTurn?.started_at,
|
|
1215
1220
|
});
|
|
1216
1221
|
// Marked replayed: a call's duration isn't timed from the replay's clock.
|
|
@@ -1821,7 +1826,8 @@ function updateInfoBar() {
|
|
|
1821
1826
|
const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
|
|
1822
1827
|
const tokens = current.timing.tokens;
|
|
1823
1828
|
const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
|
|
1824
|
-
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct
|
|
1829
|
+
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
|
|
1830
|
+
budget: current.llmContext?.budget_tokens });
|
|
1825
1831
|
const speedTextNow = lastSpeedText(tokens);
|
|
1826
1832
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
1827
1833
|
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
@@ -1984,7 +1990,7 @@ const streamHandlers = {
|
|
|
1984
1990
|
const { text, thinking } = routeChunk(data);
|
|
1985
1991
|
liveTurn.timing.setNote("");
|
|
1986
1992
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1987
|
-
const pct = extractCtxPct(data, current.ctxWindow);
|
|
1993
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
1988
1994
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
1989
1995
|
current.ctxPct = pct;
|
|
1990
1996
|
updateInfoBar();
|
|
@@ -2133,17 +2139,19 @@ const streamHandlers = {
|
|
|
2133
2139
|
}
|
|
2134
2140
|
if (view.resync) await resync();
|
|
2135
2141
|
// A card's action that isn't anytime (the step-limit question's
|
|
2136
|
-
// /continue) has no bubble: nothing to remove.
|
|
2137
|
-
|
|
2142
|
+
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
2143
|
+
// one's bubble was drawn at its command_queued: filled in here.
|
|
2144
|
+
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
2138
2145
|
if (view.hidden) queued?.remove();
|
|
2139
2146
|
else if (queued) drawCommandBubble(queued, view);
|
|
2140
2147
|
else addCommandBubble(view);
|
|
2141
2148
|
},
|
|
2142
2149
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
2143
|
-
// turn: its bubble goes here, so the cards it shows come after it.
|
|
2150
|
+
// turn: its bubble goes here, so the cards it shows come after it. One
|
|
2151
|
+
// queued for after the turn (/model X mid-turn) shows waiting here.
|
|
2144
2152
|
command_queued: (data) => {
|
|
2145
2153
|
// A card's action waits for its command_ran: shown only if it answers.
|
|
2146
|
-
if (data.anytime && !data.card && !commandBubble(data.command_id)) addCommandBubble(
|
|
2154
|
+
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
2147
2155
|
},
|
|
2148
2156
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
2149
2157
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
@@ -2162,7 +2170,7 @@ const streamHandlers = {
|
|
|
2162
2170
|
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
2163
2171
|
},
|
|
2164
2172
|
context_status: (data) => {
|
|
2165
|
-
const pct = extractCtxPct(data, current.ctxWindow);
|
|
2173
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
2166
2174
|
if (pct !== null) {
|
|
2167
2175
|
current.ctxPct = pct;
|
|
2168
2176
|
updateInfoBar();
|
|
@@ -2198,7 +2206,7 @@ const streamHandlers = {
|
|
|
2198
2206
|
// The loop asks again after an empty answer: a row of the empty step
|
|
2199
2207
|
// (a reload puts it back from the snapshot's cards).
|
|
2200
2208
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
2201
|
-
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
|
|
2209
|
+
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
|
|
2202
2210
|
// A batch of LLM context edits the server applied: a ✂ row of the
|
|
2203
2211
|
// current step, its hover listing the outputs.
|
|
2204
2212
|
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
@@ -2434,8 +2442,8 @@ function addCommandBubble(view) {
|
|
|
2434
2442
|
|
|
2435
2443
|
// A command bubble's line and output (again, when its command_ran fills in
|
|
2436
2444
|
// an anytime command's bubble in place).
|
|
2437
|
-
function drawCommandBubble(div, { label, line, text, busy = false, failed = false, commandId = null }) {
|
|
2438
|
-
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "");
|
|
2445
|
+
function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
|
|
2446
|
+
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
|
|
2439
2447
|
if (commandId) div.dataset.commandId = commandId;
|
|
2440
2448
|
div.replaceChildren();
|
|
2441
2449
|
const head = document.createElement("div");
|
|
@@ -30,10 +30,12 @@ export function moveSelection(index, count, delta) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// One row's parts: the name, its description, and a note (the bundle it
|
|
33
|
-
// comes from; "mid-turn too" for an anytime one
|
|
33
|
+
// comes from; "mid-turn too" for an anytime one, "show: mid-turn too" for
|
|
34
|
+
// one whose show form is), "" when there is none.
|
|
34
35
|
export function commandRow(command) {
|
|
35
36
|
const notes = [];
|
|
36
37
|
if (command.source && command.source !== "core") notes.push(command.source);
|
|
37
38
|
if (command.anytime) notes.push("mid-turn too");
|
|
39
|
+
else if (command.mid_turn === "depends") notes.push("show: mid-turn too");
|
|
38
40
|
return { name: command.name, description: command.description || "", note: notes.join(" · ") };
|
|
39
41
|
}
|
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
// the kernel resolved for this generation (:generation_started carries it as
|
|
3
3
|
// context_window_tokens); llama.cpp's stream payloads report token counts
|
|
4
4
|
// but no n_ctx, so without it only a precomputed pct or an n_ctx works.
|
|
5
|
+
// `budgetTokens` is the session's LLM context budget (llm_context's
|
|
6
|
+
// budget_tokens): a percentage this file computes counts against it when it
|
|
7
|
+
// is smaller than the window, as the kernel's :context_status does
|
|
8
|
+
// (ContextStatus#counted_against), so the meter reads the same from either.
|
|
5
9
|
|
|
6
10
|
function num(v) {
|
|
7
11
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
8
12
|
}
|
|
9
13
|
|
|
14
|
+
// What a fill counts against: the window, or the budget when one is set
|
|
15
|
+
// and smaller; null without a window.
|
|
16
|
+
export function countedTokens(windowTokens, budgetTokens = null) {
|
|
17
|
+
const window = num(windowTokens);
|
|
18
|
+
if (!window || window <= 0) return null;
|
|
19
|
+
const budget = num(budgetTokens);
|
|
20
|
+
return budget && budget > 0 ? Math.min(budget, window) : window;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
// Tokens in context after this chunk: prompt + generated so far.
|
|
11
24
|
function usedTokens(p) {
|
|
12
25
|
if (!p || typeof p !== "object") return null;
|
|
@@ -18,30 +31,35 @@ function usedTokens(p) {
|
|
|
18
31
|
return (prompt ?? 0) + (completion ?? 0);
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
export function extractCtxPct(data, windowTokens = null) {
|
|
34
|
+
export function extractCtxPct(data, windowTokens = null, budgetTokens = null) {
|
|
22
35
|
if (!data || typeof data !== "object") return null;
|
|
23
36
|
if (num(data.ctx_pct) !== null) return data.ctx_pct;
|
|
24
37
|
if (num(data.est_pct) !== null) return data.est_pct;
|
|
25
38
|
if (num(data.ctxPct) !== null) return data.ctxPct;
|
|
26
39
|
// The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
|
|
27
40
|
if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
|
|
28
|
-
if (data.context_window_tokens && data.total_tokens)
|
|
41
|
+
if (data.context_window_tokens && data.total_tokens) {
|
|
42
|
+
const against = countedTokens(data.context_window_tokens, budgetTokens);
|
|
43
|
+
if (against) return (data.total_tokens / against) * 100;
|
|
44
|
+
}
|
|
29
45
|
const p = data.payload || data;
|
|
30
46
|
if (num(p.ctx_pct) !== null) return p.ctx_pct;
|
|
31
|
-
const
|
|
47
|
+
const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
|
|
32
48
|
const used = usedTokens(p);
|
|
33
|
-
if (!
|
|
34
|
-
return (used /
|
|
49
|
+
if (!against || used === null) return null;
|
|
50
|
+
return (used / against) * 100;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
// The saved context (the session's timing.context: used_tokens after the
|
|
38
54
|
// last counted turn, the window now): the meter's value on load, before a
|
|
39
|
-
// turn streams
|
|
40
|
-
|
|
55
|
+
// turn streams, counted against +budgetTokens+ (the session's llm_context
|
|
56
|
+
// budget_tokens) when it is smaller, as live. null when either count is
|
|
57
|
+
// missing.
|
|
58
|
+
export function savedCtxPct(context, budgetTokens = null) {
|
|
41
59
|
const used = num(context?.used_tokens);
|
|
42
|
-
const
|
|
43
|
-
if (used === null || !
|
|
44
|
-
return (used /
|
|
60
|
+
const against = countedTokens(context?.window_tokens, budgetTokens);
|
|
61
|
+
if (used === null || !against) return null;
|
|
62
|
+
return (used / against) * 100;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
// A session card's quiet ctx: "12%", or "" when the list row has none.
|
|
@@ -99,18 +117,27 @@ export function ctxBarText(pct, windowSource = null) {
|
|
|
99
117
|
return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
// The ctx tooltip's window line ({tokens:, source:, pct:} the
|
|
103
|
-
// window, where it came from
|
|
104
|
-
// 128.0k tokens (server)", as /stats
|
|
120
|
+
// The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
|
|
121
|
+
// session's window, where it came from, the meter's value and the LLM
|
|
122
|
+
// context budget): "context: ~41.0k of 128.0k tokens (server)", as /stats
|
|
123
|
+
// names the source; under a budget smaller than the window the meter's
|
|
124
|
+
// percentage is of the budget: "context: ~41.0k of the 64.0k budget,
|
|
125
|
+
// 128.0k window (server)"; "" without a window.
|
|
105
126
|
export function ctxWindowText(window) {
|
|
106
127
|
const tokens = num(window?.tokens);
|
|
107
128
|
if (!tokens || tokens <= 0) return "";
|
|
129
|
+
const against = countedTokens(tokens, window.budget);
|
|
108
130
|
const pct = num(window.pct);
|
|
109
|
-
const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
|
|
110
131
|
const source = window.source === "default"
|
|
111
132
|
? "chi's default, a guess: set window_tokens for this model"
|
|
112
133
|
: window.source;
|
|
113
|
-
|
|
134
|
+
const sourceText = source ? ` (${source})` : "";
|
|
135
|
+
if (against < tokens) {
|
|
136
|
+
const used = pct === null ? "" : `~${countK((pct / 100) * against)} of `;
|
|
137
|
+
return `context: ${used}the ${countK(against)} budget, ${countK(tokens)} window${sourceText}`;
|
|
138
|
+
}
|
|
139
|
+
const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
|
|
140
|
+
return `context: ${used}${countK(tokens)} tokens${sourceText}`;
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
// The ctx tooltip (info bar and card): +title+, then the session's tokens
|