samagotchi 0.44.0 → 0.46.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +43 -12
  5. data/docs/configuration.md +102 -5
  6. data/docs/context.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/plugins.md +4 -2
  11. data/docs/sessions.md +9 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
  13. data/lib/samagotchi/bridge.rb +22 -10
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  18. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  19. data/lib/samagotchi/commands/registry.rb +54 -9
  20. data/lib/samagotchi/config.rb +57 -4
  21. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  22. data/lib/samagotchi/engine.rb +47 -9
  23. data/lib/samagotchi/host_model.rb +116 -0
  24. data/lib/samagotchi/host_registry.rb +43 -12
  25. data/lib/samagotchi/kernel_loop.rb +2 -1
  26. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  28. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  29. data/lib/samagotchi/model_catalog.rb +17 -12
  30. data/lib/samagotchi/model_notes.rb +8 -6
  31. data/lib/samagotchi/model_overlay.rb +3 -2
  32. data/lib/samagotchi/model_price.rb +54 -0
  33. data/lib/samagotchi/model_profile.rb +74 -3
  34. data/lib/samagotchi/plugin/context.rb +19 -7
  35. data/lib/samagotchi/process_group.rb +56 -9
  36. data/lib/samagotchi/recap_setup.rb +1 -1
  37. data/lib/samagotchi/self_report.rb +33 -3
  38. data/lib/samagotchi/session_commands.rb +47 -21
  39. data/lib/samagotchi/session_manager.rb +19 -1
  40. data/lib/samagotchi/session_metrics.rb +79 -11
  41. data/lib/samagotchi/sessions_command.rb +15 -5
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  44. data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
  45. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  46. data/lib/samagotchi/terminal_ui.rb +97 -12
  47. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  48. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -11
  51. data/lib/samagotchi/web/public/app.js +30 -18
  52. data/lib/samagotchi/web/public/command_complete.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +62 -22
  54. data/lib/samagotchi/web/public/format.js +7 -2
  55. data/lib/samagotchi/web/public/index.html +3 -1
  56. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  57. data/lib/samagotchi/web/public/model_pick.js +23 -3
  58. data/lib/samagotchi/web/public/model_picker.js +5 -3
  59. data/lib/samagotchi/web/public/stage_model.js +18 -4
  60. data/lib/samagotchi/web/public/stage_view.js +4 -3
  61. data/lib/samagotchi/web/public/turn_events.js +19 -6
  62. data/lib/samagotchi/web/public/turn_model.js +16 -0
  63. data/lib/samagotchi/web/public/turn_view.js +19 -0
  64. data/lib/samagotchi/web/session_hub.rb +9 -1
  65. data/lib/samagotchi/web/session_summary.rb +19 -2
  66. data/lib/samagotchi/worker.rb +172 -21
  67. data/lib/samagotchi/worker_inbound.rb +6 -5
  68. metadata +4 -1
@@ -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
@@ -1250,6 +1322,8 @@ module Samagotchi
1250
1322
 
1251
1323
  # The activity row goes while the question waits.
1252
1324
  @view.finish_thinking_spinner
1325
+ return dismiss_unaskable_question(prompt) if @non_interactive
1326
+
1253
1327
  # An edit's diff goes above the slot, into the scrollback.
1254
1328
  preview = prompt.preview_lines(paint: method(:paint))
1255
1329
  @surface.commit(preview.join("\n")) unless preview.empty?
@@ -1273,6 +1347,17 @@ module Samagotchi
1273
1347
  @surface.clear_slot(:notes)
1274
1348
  end
1275
1349
 
1350
+ # --non-interactive has no one to ask (as for an approval or a hook's
1351
+ # question there): the question is dismissed at once, as an empty answer
1352
+ # would be, and the model finishes its reply. Never a read of stdin: a
1353
+ # pipe its writer keeps open would block it forever. Its one line goes to
1354
+ # stderr (#banner): stdout is the answer's alone.
1355
+ def dismiss_unaskable_question(prompt)
1356
+ @engine.cancel_question("user") rescue nil
1357
+ banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
1358
+ false
1359
+ end
1360
+
1276
1361
  # Loop until a valid selection or a cancel. The block reads one answer:
1277
1362
  # a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
1278
1363
  # was cancelled).
@@ -84,10 +84,11 @@ module Samagotchi
84
84
  outcomes << outcome.line
85
85
  return with_outcomes(canceled_result(child_id, reports: reports), outcomes) if outcome.stopped
86
86
 
87
- # Wait on with the child as it is now: a turn that ends with no
88
- # reply after the relay still ends the wait, and the relayed
89
- # question isn't reported again.
90
- baseline = ReplyWait.baseline_of(Session.load(child_id, state_dir: sd), question_id: wait.question[:id])
87
+ # Wait on from the child as it was before the relay (read when it
88
+ # asked), the relayed question no longer reported: a turn that
89
+ # ends with no reply during the relay's POST or after it still
90
+ # ends the wait.
91
+ baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question[:id])
91
92
  end
92
93
  rescue ArgumentError => e
93
94
  "Error: #{e.message}"
@@ -66,6 +66,8 @@ module Samagotchi
66
66
  err: output_io
67
67
  )
68
68
  Process.detach(pid)
69
+ # Read now, compared before each signal: a pid reused later is not this task.
70
+ pid_started = ProcessGroup.start_time(pid)
69
71
 
70
72
  now = timestamp
71
73
  record = {
@@ -76,6 +78,7 @@ module Samagotchi
76
78
  "workspace_root" => Dir.pwd,
77
79
  "status" => "running",
78
80
  "pid" => pid,
81
+ "pid_started" => pid_started,
79
82
  "created_at" => now,
80
83
  "started_at" => now,
81
84
  "finished_at" => nil,
@@ -190,7 +193,7 @@ module Samagotchi
190
193
 
191
194
  pid = refreshed["pid"]
192
195
  # Checked again just before the signal: never a group chi didn't start.
193
- return [mark_not_chis(refreshed), nil] unless ProcessGroup.leader?(pid)
196
+ return [mark_not_chis(refreshed), nil] unless chis_group?(refreshed, started: refreshed["pid_started"])
194
197
 
195
198
  refreshed["stop_requested_by"] = by.to_s
196
199
  write_record(refreshed)
@@ -210,13 +213,19 @@ module Samagotchi
210
213
  return record unless record["status"] == "running"
211
214
 
212
215
  pid = record["pid"]
213
- return record if ProcessGroup.leader?(pid)
216
+ return record if chis_group?(record)
214
217
  # Gone: its exit code says how it ended.
215
218
  return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
216
219
 
217
220
  mark_not_chis(record)
218
221
  end
219
222
 
223
+ # Whether the record's pid still leads its own group. With +started+
224
+ # (the start time recorded at task_create), also the same process: a
225
+ # ps per call, so only task_stop asks it, just before the signal;
226
+ # a refresh (each task_wait poll, task_list) checks the group only.
227
+ def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
228
+
220
229
  # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
221
230
  # ends as failed, unless its exit code or a stop on disk says more.
222
231
  def mark_not_chis(record)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.44.0"
4
+ VERSION = "0.46.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -443,7 +443,9 @@ module Samagotchi
443
443
  warnings.concat(listing.warnings)
444
444
  # the web's own spelling, kept as it was: bare on the default host
445
445
  listing.rows.each do |row|
446
- models << { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
446
+ model = { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
447
+ model[:configured] = true if row.configured
448
+ models << model
447
449
  end
448
450
  end
449
451
  rescue StandardError => e
@@ -488,15 +490,10 @@ module Samagotchi
488
490
  # A session's LLM context strategy, apply rule and budget, each with
489
491
  # where it came from (LLMContextStrategy::Explained#summary), as its
490
492
  # worker would resolve them; nil when they can't be.
493
+ # (LLMContextStrategy.for_session, shared with the ctx % the session
494
+ # lists count.)
491
495
  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
496
+ LLMContextStrategy.for_session(session, registry: host_registry)
500
497
  end
501
498
 
502
499
  # Built again when config.yml's hosts: changed (compared as read: the
@@ -662,7 +659,8 @@ module Samagotchi
662
659
  session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
663
660
  # What the worker's server said it served for that model (after a turn).
664
661
  if snapshot
665
- session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
662
+ session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
663
+ served_expected_by: snapshot["served_expected_by"])
666
664
  # The model notes its prompt carries now (a /model there changes them).
667
665
  session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
668
666
  end
@@ -679,6 +677,8 @@ module Samagotchi
679
677
  failed_turn: failed_turn_for(session, raw_messages),
680
678
  current_turn: current_turn,
681
679
  queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
680
+ # Commands waiting for the running turn's end (their bubbles).
681
+ queued_commands: turn_snapshot ? Array(turn_snapshot["queued_commands"]) : [],
682
682
  recap: turn_snapshot && turn_snapshot["recap"],
683
683
  saved_recap: saved_recap_for(id, session, turn_snapshot),
684
684
  continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
@@ -1496,7 +1496,8 @@ module Samagotchi
1496
1496
  # @param owner [OwnerLock::Owner, nil] #session_owner; its kind is shown as `owner`
1497
1497
  def session_to_json(s, status: s.status, owner: nil)
1498
1498
  SessionSummary.build(s, status: status, owner: owner,
1499
- session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir))
1499
+ session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir),
1500
+ registry: host_registry)
1500
1501
  end
1501
1502
 
1502
1503
  # 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);
@@ -1152,7 +1156,7 @@ function renderSessionView(data) {
1152
1156
  setCapabilities(data.capabilities);
1153
1157
  current.status = s.status;
1154
1158
  current.model = s.model_name;
1155
- current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
1159
+ current.served = s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null;
1156
1160
  current.dir = s.working_directory;
1157
1161
  current.owner = s.owner || null;
1158
1162
  current.timing = normalizeTiming(data.timing);
@@ -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();
@@ -2001,7 +2007,7 @@ const streamHandlers = {
2001
2007
  },
2002
2008
  generation_completed: (data) => {
2003
2009
  liveTurn.timing.setNote("");
2004
- if (data?.served_model) current.served = [data.served_model, data.requested_model];
2010
+ if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
2005
2011
  // The generation's speed and the session's token totals, from the server.
2006
2012
  current.timing.tokens = generationTokens(current.timing.tokens, data);
2007
2013
  if (data?.served_model || data?.tokens) 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,13 +2206,17 @@ 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)); },
2205
2213
  // The provider is asked again after an error, or the turn waits for
2206
2214
  // plugins' setup: a note on the live timing line until it streams.
2207
- generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
2215
+ generation_retrying: (data) => {
2216
+ // A dropped stream's step asked again: what it streamed is void.
2217
+ if (data?.restarted) turnDom.generationRestarted(data);
2218
+ liveTurn.timing.setNote(retryStatusLine(data));
2219
+ },
2208
2220
  plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
2209
2221
  plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
2210
2222
  plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
@@ -2434,8 +2446,8 @@ function addCommandBubble(view) {
2434
2446
 
2435
2447
  // A command bubble's line and output (again, when its command_ran fills in
2436
2448
  // 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" : "");
2449
+ function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
2450
+ div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
2439
2451
  if (commandId) div.dataset.commandId = commandId;
2440
2452
  div.replaceChildren();
2441
2453
  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.
@@ -59,12 +77,24 @@ export function speedText(tps, source) {
59
77
  return `${source === "estimate" ? "~" : ""}${number} tok/s`;
60
78
  }
61
79
 
62
- // A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "" for
63
- // none or zero. As Formatting#cost_text.
64
- export function costText(cost) {
80
+ // A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "~$0.12"
81
+ // for an estimate (hosts.<name>.models prices); "" for none or zero. As
82
+ // Formatting#cost_text.
83
+ export function costText(cost, { estimate = false } = {}) {
65
84
  const value = num(cost);
66
85
  if (value === null || value <= 0) return "";
67
- return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
86
+ return `${estimate ? "~" : ""}${value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`}`;
87
+ }
88
+
89
+ // The ctx tooltip's cost: the reported cost as is; with an estimate (an
90
+ // older worker or saved file has none) the total with "~" and both parts.
91
+ function costLine(tokens) {
92
+ const reported = num(tokens.cost_sum) || 0;
93
+ const estimated = num(tokens.cost_estimate_sum) || 0;
94
+ if (estimated <= 0) return reported > 0 ? `cost: ${costText(reported)}` : "";
95
+ const total = costText(reported + estimated, { estimate: true });
96
+ const parts = reported > 0 ? `${costText(reported)} reported, ${costText(estimated, { estimate: true })} estimated` : "estimated";
97
+ return `cost: ${total} (${parts})`;
68
98
  }
69
99
 
70
100
  function count(n) {
@@ -99,23 +129,33 @@ export function ctxBarText(pct, windowSource = null) {
99
129
  return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
100
130
  }
101
131
 
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.
132
+ // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
133
+ // session's window, where it came from, the meter's value and the LLM
134
+ // context budget): "context: ~41.0k of 128.0k tokens (server)", as /stats
135
+ // names the source; under a budget smaller than the window the meter's
136
+ // percentage is of the budget: "context: ~41.0k of the 64.0k budget,
137
+ // 128.0k window (server)"; "" without a window.
105
138
  export function ctxWindowText(window) {
106
139
  const tokens = num(window?.tokens);
107
140
  if (!tokens || tokens <= 0) return "";
141
+ const against = countedTokens(tokens, window.budget);
108
142
  const pct = num(window.pct);
109
- const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
110
143
  const source = window.source === "default"
111
144
  ? "chi's default, a guess: set window_tokens for this model"
112
145
  : window.source;
113
- return `context: ${used}${countK(tokens)} tokens${source ? ` (${source})` : ""}`;
146
+ const sourceText = source ? ` (${source})` : "";
147
+ if (against < tokens) {
148
+ const used = pct === null ? "" : `~${countK((pct / 100) * against)} of `;
149
+ return `context: ${used}the ${countK(against)} budget, ${countK(tokens)} window${sourceText}`;
150
+ }
151
+ const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
152
+ return `context: ${used}${countK(tokens)} tokens${sourceText}`;
114
153
  }
115
154
 
116
155
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
117
156
  // (in summed over every request, cached with its share, out with the
118
- // reasoning part) and its cost, when it has any, and the memory indexes
157
+ // reasoning part) and its cost, when it has any (an estimated part with
158
+ // "~", costLine), and the memory indexes
119
159
  // its prompt holds (+memoryIndex+, the snapshot's memory_index); with
120
160
  // +window+ (ctxWindowText's) the window line under the title. A
121
161
  // delegated session keeps its own metrics, so these are this session's only.
@@ -133,8 +173,8 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
133
173
  if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
134
174
  parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
135
175
  const lines = [title, `tokens: ${parts.join(" · ")}`];
136
- const cost = costText(tokens.cost_sum);
137
- if (cost) lines.push(`cost: ${cost}`);
176
+ const cost = costLine(tokens);
177
+ if (cost) lines.push(cost);
138
178
  lines.push("this session only, all requests");
139
179
  return `${lines.join("\n")}${memoryLine}`;
140
180
  }