samagotchi 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/configuration.md +18 -0
  5. data/docs/context.md +2 -1
  6. data/docs/internals/context-telemetry.md +4 -1
  7. data/docs/internals/engine.md +1 -0
  8. data/docs/plugins.md +4 -2
  9. data/docs/sessions.md +8 -4
  10. data/lib/samagotchi/bridge/card_store.rb +20 -9
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  12. data/lib/samagotchi/bridge/turn_notice.rb +3 -1
  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/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/commands/registry.rb +54 -9
  19. data/lib/samagotchi/context_status.rb +18 -5
  20. data/lib/samagotchi/engine.rb +8 -3
  21. data/lib/samagotchi/host_registry.rb +28 -3
  22. data/lib/samagotchi/kernel_loop.rb +24 -9
  23. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  24. data/lib/samagotchi/llm_context_forget.rb +6 -2
  25. data/lib/samagotchi/llm_context_notice.rb +96 -0
  26. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  27. data/lib/samagotchi/model_notes.rb +8 -6
  28. data/lib/samagotchi/model_overlay.rb +3 -2
  29. data/lib/samagotchi/model_profile.rb +71 -2
  30. data/lib/samagotchi/plugin/context.rb +19 -7
  31. data/lib/samagotchi/self_report.rb +31 -2
  32. data/lib/samagotchi/session_commands.rb +24 -7
  33. data/lib/samagotchi/session_manager.rb +19 -1
  34. data/lib/samagotchi/session_metrics.rb +39 -6
  35. data/lib/samagotchi/sessions_command.rb +15 -5
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
  39. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  40. data/lib/samagotchi/terminal_ui.rb +84 -12
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -13
  43. data/lib/samagotchi/web/message_parts.rb +90 -11
  44. data/lib/samagotchi/web/public/app.js +38 -19
  45. data/lib/samagotchi/web/public/command_complete.js +3 -1
  46. data/lib/samagotchi/web/public/ctx.js +42 -15
  47. data/lib/samagotchi/web/public/index.html +7 -1
  48. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  49. data/lib/samagotchi/web/public/stage_model.js +18 -4
  50. data/lib/samagotchi/web/public/stage_view.js +3 -3
  51. data/lib/samagotchi/web/public/turn_events.js +50 -4
  52. data/lib/samagotchi/web/public/turn_html.js +33 -2
  53. data/lib/samagotchi/web/public/turn_view.js +4 -2
  54. data/lib/samagotchi/web/session_hub.rb +9 -1
  55. data/lib/samagotchi/web/session_summary.rb +19 -2
  56. data/lib/samagotchi/worker.rb +156 -20
  57. data/lib/samagotchi/worker_inbound.rb +6 -5
  58. metadata +2 -1
@@ -83,6 +83,13 @@ module Samagotchi
83
83
  paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
84
84
  end
85
85
 
86
+ # "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
87
+ # off)": a batch of LLM context edits went in (LLMContextNotice builds
88
+ # the line; the web prints the same), dim like the retry row.
89
+ def format_llm_context_line(event)
90
+ paint(event[:text].to_s.empty? ? "✂ LLM context edited" : event[:text].to_s, 90)
91
+ end
92
+
86
93
  # "no answer: the model returned nothing (after 1 retry)", dim like the
87
94
  # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
88
95
  def format_empty_answer_line(retries)
@@ -479,7 +486,7 @@ module Samagotchi
479
486
  lines << "output cap hits: #{snapshot[:capped].to_i}"
480
487
  context = snapshot[:context] || {}
481
488
  if context[:used_tokens]
482
- 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])
483
490
  lines << "context used: #{context[:used_tokens]} tokens#{pct}"
484
491
  end
485
492
  lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
@@ -499,6 +506,24 @@ module Samagotchi
499
506
  lines.join("\n")
500
507
  end
501
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
+
502
527
  # "stale,forget (the session); apply turn_end (models: x); budget
503
528
  # 64000 (the session)" for /stats (Explained#summary; symbol or
504
529
  # string keys, the attached TUI's come as JSON); nil without one.
@@ -30,6 +30,7 @@ module Samagotchi
30
30
  case event[:type]
31
31
  when :empty_answer_retry then line(format_empty_retry_line(event))
32
32
  when :steer_cut then line(format_steer_cut_line(event))
33
+ when :llm_context_edited then line(format_llm_context_line(event))
33
34
  when :generation_retrying then line(format_generation_retry_line(event))
34
35
  when :turn_completed, :turn_canceled then @turn_over = true
35
36
  when :hook_notice
@@ -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.43.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
@@ -1658,6 +1656,8 @@ module Samagotchi
1658
1656
  def messages_for_display(msgs, parts: false, cwd: nil)
1659
1657
  filtered = []
1660
1658
  list = Array(msgs)
1659
+ # The outputs' ✂ marks (LLM context edits), by id, for the parts.
1660
+ marks = parts ? MessageParts.edit_marks(list) : {}
1661
1661
  list.each_with_index do |m, index|
1662
1662
  role = (m[:role] || m["role"]).to_s
1663
1663
  content = (m[:content] || m["content"]).to_s
@@ -1689,7 +1689,7 @@ module Samagotchi
1689
1689
  next if role == "tool_response"
1690
1690
 
1691
1691
  stripped = Samagotchi::OutputFormatter.strip_markup(content)
1692
- did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd) : nil
1692
+ did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
1693
1693
  next if stripped.empty? && did.nil?
1694
1694
 
1695
1695
  norm_role = role == "model" ? "assistant" : role
@@ -1757,10 +1757,11 @@ module Samagotchi
1757
1757
  end
1758
1758
 
1759
1759
  # The parts of the assistant message at +index+, with the tool_response
1760
- # messages right after it.
1761
- def message_parts(list, index, cwd = nil)
1760
+ # messages right after it; +marks+: the session's ✂ marks
1761
+ # (MessageParts.edit_marks).
1762
+ def message_parts(list, index, cwd = nil, marks = {})
1762
1763
  responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
1763
- MessageParts.for_message(list[index], responses, cwd: cwd)
1764
+ MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
1764
1765
  end
1765
1766
 
1766
1767
  # The last message messages_for_display shows as an answer
@@ -7,6 +7,8 @@ require_relative "../tool_view"
7
7
  require_relative "../llm/native_tool_normalizer"
8
8
  require_relative "../tools/builtins"
9
9
  require_relative "../image_store"
10
+ require_relative "../llm_context_edit"
11
+ require_relative "../token_usage"
10
12
 
11
13
  module Samagotchi
12
14
  module Web
@@ -28,6 +30,10 @@ module Samagotchi
28
30
  # by the saved image_counts (older sessions without them: only a lone
29
31
  # call gets them).
30
32
  #
33
+ # A call's chi-owned id (ToolIds, saved as tool_ids) is the part's
34
+ # tool_id:, and an LLM context edit saved on its output (LLMContextEdit,
35
+ # under edits) the part's edit: (.edit_marks), the row's ✂ mark.
36
+ #
31
37
  # A plugin tool's params are what its live row showed (its preview),
32
38
  # saved with the result as tool_params (native: one per call, chat: one
33
39
  # per result); its label ("chrome: screenshot") likewise as tool_labels,
@@ -59,15 +65,17 @@ module Samagotchi
59
65
  # @param registry [Tools::Registry] the session's tools
60
66
  # @param cwd [String, nil] the session's working directory (a file
61
67
  # tool's title is relative to it)
68
+ # @param marks [Hash{String => Hash}] the session's edit marks by
69
+ # output id (.edit_marks)
62
70
  # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
63
- # output:, output_truncated:, images: }] } with only what it found; nil for nothing
64
- def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil)
71
+ # output:, output_truncated:, images:, tool_id:, edit: }] } with only what it found; nil for nothing
72
+ def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil, marks: {})
65
73
  content = field(message, :content).to_s
66
74
  calls = field(message, :tool_calls)
67
75
  tools = if calls.is_a?(Array) && !calls.empty?
68
- native_tools(calls, responses, registry, cwd)
76
+ native_tools(calls, responses, registry, cwd, marks)
69
77
  else
70
- markup_tools(content, responses, registry, cwd)
78
+ markup_tools(content, responses, registry, cwd, marks)
71
79
  end
72
80
  parts = {}
73
81
  thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
@@ -87,7 +95,7 @@ module Samagotchi
87
95
  blocks.map(&:strip).reject(&:empty?).join("\n\n")
88
96
  end
89
97
 
90
- def markup_tools(content, responses, registry, cwd = nil)
98
+ def markup_tools(content, responses, registry, cwd = nil, marks = {})
91
99
  calls = []
92
100
  calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
93
101
  calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
@@ -98,9 +106,11 @@ module Samagotchi
98
106
  shown = saved_list(responses, :tool_params, calls.length)
99
107
  labels = saved_list(responses, :tool_labels, calls.length)
100
108
  diffs = saved_list(responses, :tool_diffs, calls.length)
109
+ ids = saved_list(responses, :tool_ids, calls.length)
101
110
  images = split_images(responses, calls.length)
102
111
  calls.each_with_index.map do |call, i|
103
- tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd)
112
+ tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd,
113
+ tool_id: ids[i], marks: marks)
104
114
  end
105
115
  end
106
116
 
@@ -122,16 +132,78 @@ module Samagotchi
122
132
  counts.map { |n| images.shift(n) }
123
133
  end
124
134
 
125
- def native_tools(calls, responses, registry, cwd = nil)
126
- by_id = responses.to_h { |r| [field(r, :tool_call_id), r] }
135
+ def native_tools(calls, responses, registry, cwd = nil, marks = {})
136
+ # By id only for an id that names one response: a server may omit
137
+ # it (nil), send "" or repeat it; those pair by position.
138
+ ids = responses.map { |r| field(r, :tool_call_id).to_s }
139
+ unique = ->(id) { !id.empty? && ids.count(id) == 1 }
140
+ by_id = responses.each_with_index.to_h { |r, i| [ids[i], r] }.select { |id, _| unique.call(id) }
127
141
  calls.each_with_index.map do |raw, i|
128
142
  ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
129
143
  call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
130
- response = by_id[field(raw, :id)] || (field(responses[i], :tool_call_id).nil? ? responses[i] : nil)
144
+ response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
131
145
  tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
132
146
  response && field(response, :images), label: field(response, :tool_labels),
133
- diff: field(response, :tool_diffs), cwd: cwd)
147
+ diff: field(response, :tool_diffs), cwd: cwd,
148
+ tool_id: Array(field(response, :tool_ids)).first, marks: marks)
149
+ end
150
+ end
151
+
152
+ # The ✂ mark of each output an LLM context edit is saved on, by its
153
+ # id, from +messages+ (a session's, symbol or string keys): {kind:
154
+ # "stale" | "forget", staged: true while a forget waits for the
155
+ # turn's end, note: (a stale stub's reason, "superseded by a later
156
+ # read"; the first output of a forget_outputs call's, its note), with:
157
+ # (the others of that call's: the first one's id), kept: ("12-40, 50",
158
+ # a forget's kept lines)}. A forget call's outputs share the note,
159
+ # author and stamp (LLMContextView's key); the first in the session's
160
+ # order carries the note.
161
+ # @return [Hash{String => Hash}]
162
+ def edit_marks(messages)
163
+ first = {}
164
+ Array(messages).each_with_object({}) do |entry, marks|
165
+ next unless field(entry, :role).to_s == "tool_response"
166
+
167
+ saved = field(entry, :edits)
168
+ next unless saved.is_a?(Hash)
169
+
170
+ edits = saved.filter_map { |id, hash| LLMContextEdit.from_h(id, hash) }.to_h { |edit| [edit.id, edit] }
171
+ order = Array(field(entry, :tool_ids)).map(&:to_s)
172
+ edits.sort_by { |id, _| order.index(id) || order.size }.each do |id, edit|
173
+ marks[id] = edit_mark(edit, first)
174
+ end
175
+ end
176
+ rescue StandardError
177
+ {}
178
+ end
179
+
180
+ def edit_mark(edit, first)
181
+ mark = { kind: edit.kind.to_s }
182
+ mark[:staged] = true unless edit.applied?
183
+ if edit.kind == :stale
184
+ mark[:note] = edit.note[/superseded by .*\z/] || edit.note
185
+ else
186
+ key = [edit.note, edit.by, edit.staged_at]
187
+ if first.key?(key)
188
+ mark[:with] = first[key]
189
+ else
190
+ first[key] = edit.id
191
+ mark[:note] = edit.note
192
+ end
193
+ kept = edit.keep.map { |from, to| from == to ? from.to_s : "#{from}-#{to}" }
194
+ mark[:kept] = kept.join(", ") unless kept.empty?
134
195
  end
196
+ mark
197
+ end
198
+
199
+ # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
200
+ # what it frees: its output, ~chars/4.
201
+ def mark_of(id, marks, output)
202
+ mark = marks[id]
203
+ return nil unless mark
204
+ return mark unless mark[:kind] == "stale" && !mark[:staged] && output
205
+
206
+ mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
135
207
  end
136
208
 
137
209
  # +count+ outputs from a joined tool_response: the "[tool]"-tagged
@@ -153,7 +225,9 @@ module Samagotchi
153
225
  # +shown+ is the saved params line, when it is a String.
154
226
  # +diff+ is what an edit/write changed (EditPreview.change), saved as
155
227
  # tool_diffs. +cwd+: the session's working directory, for the title.
156
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil)
228
+ # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
229
+ def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil, tool_id: nil,
230
+ marks: {})
157
231
  name = call[:name].to_s
158
232
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
159
233
  part = { tool: name, params: params.to_s }
@@ -171,6 +245,11 @@ module Samagotchi
171
245
  refs = Array(images).select { |ref| ref.is_a?(Hash) }
172
246
  part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
173
247
  part[:diff] = diff if diff.is_a?(Hash)
248
+ if tool_id
249
+ part[:tool_id] = tool_id.to_s
250
+ mark = mark_of(tool_id.to_s, marks, output)
251
+ part[:edit] = mark if mark
252
+ end
174
253
  part
175
254
  end
176
255
 
@@ -60,7 +60,7 @@ import {
60
60
  } from "./timing.js";
61
61
  import { createLiveTurn } from "./live_turn.js";
62
62
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
63
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
63
+ import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
64
64
 
65
65
  const $ = (s) => document.querySelector(s);
66
66
  const topStripEl = $("#topStrip");
@@ -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.
@@ -1527,11 +1532,17 @@ function showSteers(steers) {
1527
1532
  }
1528
1533
  }
1529
1534
 
1535
+ // A ✂ row's notice data (turnDom.notice, placeTurnNotice).
1536
+ function llmContextNotice(data) {
1537
+ return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
1538
+ }
1539
+
1530
1540
  function showHookNotice(data, { before = null } = {}) {
1531
1541
  removeHintIfEmpty();
1532
1542
  const el = document.createElement("div");
1533
- el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
1543
+ el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
1534
1544
  el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1545
+ if (data.title) el.title = data.title;
1535
1546
  const follow = isNearBottom();
1536
1547
  if (before) historyEl.insertBefore(el, before);
1537
1548
  else appendToHistory(el);
@@ -1623,18 +1634,20 @@ function placeSnapshotCards(entries) {
1623
1634
  // plain answer, a step the history doesn't have) a bubble before the
1624
1635
  // turn's answer, or before its timing line when it has none.
1625
1636
  function placeTurnNotice(entry, before) {
1637
+ const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
1626
1638
  const line = noticeLine(entry);
1627
1639
  const turnEls = reloadedTurnEls(before);
1628
1640
  const place = turnNoticePlace(entry);
1629
1641
  const block = turnEls.find((el) => el.matches(".turn-work"));
1630
1642
  const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1631
1643
  if (!step) {
1632
- showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
1644
+ showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
1633
1645
  return;
1634
1646
  }
1635
1647
  const el = document.createElement("div");
1636
- el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
1648
+ el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
1637
1649
  el.textContent = line;
1650
+ if (extra.title) el.title = extra.title;
1638
1651
  let body = step.querySelector(":scope > .activity-body");
1639
1652
  if (!body) {
1640
1653
  body = document.createElement("div");
@@ -1813,7 +1826,8 @@ function updateInfoBar() {
1813
1826
  const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
1814
1827
  const tokens = current.timing.tokens;
1815
1828
  const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
1816
- { 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 });
1817
1831
  const speedTextNow = lastSpeedText(tokens);
1818
1832
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1819
1833
  const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
@@ -1976,7 +1990,7 @@ const streamHandlers = {
1976
1990
  const { text, thinking } = routeChunk(data);
1977
1991
  liveTurn.timing.setNote("");
1978
1992
  turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
1979
- const pct = extractCtxPct(data, current.ctxWindow);
1993
+ const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
1980
1994
  if (pct !== null && !Number.isNaN(pct)) {
1981
1995
  current.ctxPct = pct;
1982
1996
  updateInfoBar();
@@ -2125,17 +2139,19 @@ const streamHandlers = {
2125
2139
  }
2126
2140
  if (view.resync) await resync();
2127
2141
  // A card's action that isn't anytime (the step-limit question's
2128
- // /continue) has no bubble: nothing to remove.
2129
- 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;
2130
2145
  if (view.hidden) queued?.remove();
2131
2146
  else if (queued) drawCommandBubble(queued, view);
2132
2147
  else addCommandBubble(view);
2133
2148
  },
2134
2149
  // An anytime command (/help, a plugin's /btw) runs at once, beside a
2135
- // 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.
2136
2152
  command_queued: (data) => {
2137
2153
  // A card's action waits for its command_ran: shown only if it answers.
2138
- 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));
2139
2155
  },
2140
2156
  // Due reminders went into the turn (a reminder turn has no prompt bubble).
2141
2157
  reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
@@ -2154,7 +2170,7 @@ const streamHandlers = {
2154
2170
  if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
2155
2171
  },
2156
2172
  context_status: (data) => {
2157
- const pct = extractCtxPct(data, current.ctxWindow);
2173
+ const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
2158
2174
  if (pct !== null) {
2159
2175
  current.ctxPct = pct;
2160
2176
  updateInfoBar();
@@ -2190,7 +2206,10 @@ const streamHandlers = {
2190
2206
  // The loop asks again after an empty answer: a row of the empty step
2191
2207
  // (a reload puts it back from the snapshot's cards).
2192
2208
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
2193
- steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
2209
+ steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
2210
+ // A batch of LLM context edits the server applied: a ✂ row of the
2211
+ // current step, its hover listing the outputs.
2212
+ llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
2194
2213
  // The provider is asked again after an error, or the turn waits for
2195
2214
  // plugins' setup: a note on the live timing line until it streams.
2196
2215
  generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
@@ -2423,8 +2442,8 @@ function addCommandBubble(view) {
2423
2442
 
2424
2443
  // A command bubble's line and output (again, when its command_ran fills in
2425
2444
  // an anytime command's bubble in place).
2426
- function drawCommandBubble(div, { label, line, text, busy = false, failed = false, commandId = null }) {
2427
- 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" : "");
2428
2447
  if (commandId) div.dataset.commandId = commandId;
2429
2448
  div.replaceChildren();
2430
2449
  const head = document.createElement("div");