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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/context.md +2 -1
  5. data/docs/plugins.md +4 -2
  6. data/docs/sessions.md +8 -4
  7. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  8. data/lib/samagotchi/bridge.rb +22 -10
  9. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  10. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  11. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  13. data/lib/samagotchi/commands/registry.rb +54 -9
  14. data/lib/samagotchi/engine.rb +8 -3
  15. data/lib/samagotchi/host_registry.rb +28 -3
  16. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  17. data/lib/samagotchi/model_notes.rb +8 -6
  18. data/lib/samagotchi/model_overlay.rb +3 -2
  19. data/lib/samagotchi/model_profile.rb +71 -2
  20. data/lib/samagotchi/plugin/context.rb +19 -7
  21. data/lib/samagotchi/self_report.rb +31 -2
  22. data/lib/samagotchi/session_commands.rb +24 -7
  23. data/lib/samagotchi/session_manager.rb +19 -1
  24. data/lib/samagotchi/session_metrics.rb +39 -6
  25. data/lib/samagotchi/sessions_command.rb +15 -5
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +19 -1
  28. data/lib/samagotchi/terminal_ui.rb +84 -12
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +7 -9
  31. data/lib/samagotchi/web/public/app.js +23 -15
  32. data/lib/samagotchi/web/public/command_complete.js +3 -1
  33. data/lib/samagotchi/web/public/ctx.js +42 -15
  34. data/lib/samagotchi/web/public/index.html +3 -1
  35. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  36. data/lib/samagotchi/web/public/stage_model.js +18 -4
  37. data/lib/samagotchi/web/public/stage_view.js +3 -3
  38. data/lib/samagotchi/web/public/turn_events.js +16 -4
  39. data/lib/samagotchi/web/session_hub.rb +9 -1
  40. data/lib/samagotchi/web/session_summary.rb +19 -2
  41. data/lib/samagotchi/worker.rb +156 -20
  42. data/lib/samagotchi/worker_inbound.rb +6 -5
  43. 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
- @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
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] == "busy" || PromptHistory.shell_line?(event[:line])
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[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
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
- if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
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.lookup(input)&.anytime
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
- @repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain }, &)
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; other commands wait, back
1045
- # in the prompt.
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
- return start_anytime_command(line) if command_registry.lookup(line)&.anytime
1053
- # A command, never steering text (/archive waits, back in the prompt).
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
- false
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.44.0"
4
+ VERSION = "0.45.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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 = host_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. +idOf+ names a card: a session's
451
- // id, or familyId (a card per family: its head's id). drawnOrder is the
452
- // cards drawn, which select mode picks from.
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 = (canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen());
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
- const queued = view.anytime ? commandBubble(view.commandId) : null;
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({ ...commandView(data, clientId), text: "" });
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), "" when there is none.
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) return (data.total_tokens / data.context_window_tokens) * 100;
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 window = num(p.n_ctx) || num(windowTokens);
47
+ const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
32
48
  const used = usedTokens(p);
33
- if (!window || window <= 0 || used === null) return null;
34
- return (used / window) * 100;
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. null when either count is missing.
40
- export function savedCtxPct(context) {
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 window = num(context?.window_tokens);
43
- if (used === null || !window || window <= 0) return null;
44
- return (used / window) * 100;
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 session's
103
- // window, where it came from and the meter's value): "context: ~41.0k of
104
- // 128.0k tokens (server)", as /stats names the source; "" without a window.
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
- return `context: ${used}${countK(tokens)} tokens${source ? ` (${source})` : ""}`;
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