samagotchi 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '019e204c3cd98489376bc61446efc6c576d5eb31f73ca97331c23b2d551b015f'
4
- data.tar.gz: 20b4127aae83e29bfb00d67762ea443b78b2961622502df32458d9d6f9d89068
3
+ metadata.gz: 684d8e5c169b4ce5df4cd21c4c46d0ef8524230e530d3e90ec2076f5b674e905
4
+ data.tar.gz: 54dbaf8bd2ad38b78e16aca155414748491912bdaebe1069f128e6182df7bfe5
5
5
  SHA512:
6
- metadata.gz: cdd1bbcef93ae1d57db0fde19927ac5b1c3dbb2e0fe36ff6bc93296044ce305fb2b75c1cc62b6a96c84720d9cb6e288135b69f2aaa624f949282355f360c2ab6
7
- data.tar.gz: 91d1f3c0d5e2145135045e5f8fd79335142bc99bdccea708a608a541f72401812d1b965a4ec2e03e05a07d109664e40c9826d2573d51ec4cc918cc00b7d7400a
6
+ metadata.gz: de459298c279f571db785d374e1752a58060d26a13c4f7a6d22f8c4e4a5c82acbcffecd5784712eda405908cc3a8fd7bf1526952878b3791017088ebb62f120e
7
+ data.tar.gz: a99cf35d53b0a9fb7b3ed568a73d3383252ba334e4c8efd50efd9d97ded3cb716432fd51ee069e066ce3d21d137f1fadf49e20f2b99e9a769e430c8274278a64
data/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.44.0] - 2026-10-08
12
+
13
+ ### Added
14
+
15
+ - LLM context edits are visible: each batch of stubs that reaches the prompt gets a ✂ row in its step, in the web
16
+ and the terminal (`✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid off)`), whose web hover
17
+ lists the outputs, a stale stub's reason, a forget's note and kept lines, and the cost. After a reload a stubbed
18
+ output's tool row carries a ✂ mark (`✂ stubbed: superseded by a later read · ~1.0k tokens`, `✂ forgotten: <note>`,
19
+ `✂ forget staged`). The stream has a new event, `llm_context_edited`. See
20
+ [docs/configuration.md](docs/configuration.md) ("LLM context: what you see (✂)").
21
+
22
+ ### Fixed
23
+
24
+ - Under an `llm_context.budget_tokens` budget without the forget layer (strategy `none` or `stale`), the model's
25
+ `[CONTEXT: …]` line said "of the context window" for a percentage of the budget; it now reads "about 62% of the
26
+ context budget (64k tokens) is in use", and its top bucket no longer asks the model to "summarize aggressively"
27
+ ("context budget critical — avoid large outputs and re-reads, delegate broad work to subagents").
28
+
11
29
  ## [0.43.0] - 2026-10-08
12
30
 
13
31
  ### Added
@@ -2008,7 +2026,8 @@ and long-lived sessions.
2008
2026
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2009
2027
  hotkey panel that send selected text or the clipboard to your sessions.
2010
2028
 
2011
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.43.0...HEAD
2029
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.44.0...HEAD
2030
+ [0.44.0]: https://github.com/dm1try/samagotchi/compare/v0.43.0...v0.44.0
2012
2031
  [0.43.0]: https://github.com/dm1try/samagotchi/compare/v0.42.0...v0.43.0
2013
2032
  [0.42.0]: https://github.com/dm1try/samagotchi/compare/v0.41.0...v0.42.0
2014
2033
  [0.41.0]: https://github.com/dm1try/samagotchi/compare/v0.40.0...v0.41.0
@@ -900,6 +900,24 @@ models:
900
900
  - `/stats` counts the re-prefilled tokens each applied batch costs; `script/llm_context_bench.rb --strategy
901
901
  forget_outputs` replays the offer on stored sessions (docs/internals/llm-context-bench.md).
902
902
 
903
+ ## LLM context: what you see (✂)
904
+
905
+ Every edit is shown; the session keeps the originals.
906
+
907
+ - **A ✂ row** for each batch of stubs that reaches the prompt (`stale`'s, a `forget_outputs` call's, or the ones
908
+ staged until a turn's end), in the step it came in, in the web and the terminal alike:
909
+ `✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid off)`. It says why it went now (`paid off`,
910
+ `the context is nearly full`, `at turn end`; nothing under `next_request`) and how many stubs stay staged. In the
911
+ web, its hover lists each output (its id, tool and path or command), a stale stub's reason, a forget's note once
912
+ with the lines it keeps, the tokens it frees and the tail the server reads again. A batch held back, the warm-up's
913
+ own copy and a restore show no row (the `forget_outputs` row says what a restore did). A reload puts the rows back
914
+ where they were (`cards.json`, its own cap of 20, apart from hook notices).
915
+ - **A ✂ mark** on a tool row after a reload, above its output, when the model is sent a stub for it:
916
+ `✂ stubbed: superseded by a later read · ~1.0k tokens`, `✂ forgotten: <note>`, `✂ forgotten with t41` (the
917
+ other outputs of the call whose row carries the note), `✂ forget staged`, and `· lines 12-40 kept`.
918
+ The running turn's rows get theirs on the next reload.
919
+ - Tokens are estimates (chars/4), hence the `~`.
920
+
903
921
  ## LLM context: a session's own strategy
904
922
 
905
923
  A session can set its own `llm_context` strategy, apply rule and budget, which come before the model's, its host's
@@ -36,7 +36,10 @@ both loops):
36
36
 
37
37
  - `llm_context.budget_tokens` (or a model's or host's `llm_context_budget_tokens`): the buckets count against the
38
38
  budget instead of the window, the smaller of the two. The event's `window_tokens` and the status line's percentage
39
- are of it too, and so is `payoff`'s top bucket.
39
+ are of it too, and so is `payoff`'s top bucket. Without the forget layer the model's line names it, `[CONTEXT:
40
+ about 62% of the context budget (64k tokens) is in use (…)]` (the window when that is the smaller), and its top
41
+ bucket reads "context budget critical — avoid large outputs and re-reads, delegate broad work to subagents": no
42
+ "summarize", as nothing in the model's hands shrinks the context.
40
43
  - The forget layer replaces the guidance with tiered offers of `forget_outputs`, each line a readout,
41
44
  `[CONTEXT: ~52k/64k tokens in use (bucket=60plus). …]`: the readout alone in the guided buckets below the top two
42
45
  (CLM: how-to at low pressure makes a model wipe everything); "Finish the unit of work in flight, then tidy once
@@ -298,6 +298,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
298
298
  | `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
299
299
  | `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
300
300
  | `:steer_cut` | a steer cut the generation | `{iteration, source}` |
301
+ | `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
301
302
  | `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
302
303
  | `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
303
304
  | `:tool_call_started` | before a call runs | see ToolRunner |
@@ -16,7 +16,8 @@ module Samagotchi
16
16
  # iteration started before it (a before_tool_call hook's notice comes
17
17
  # before its call's row, so the web puts it above row calls + 1). The
18
18
  # loop's "asking again" row (:empty_answer_retry, after an empty or cut
19
- # answer) and a steer's cut row (:steer_cut) are kept the same way, and so is a question the turn asked
19
+ # answer), a steer's cut row (:steer_cut) and a ✂ row (:llm_context_edited,
20
+ # LLMContextNotice) are kept the same way, and so is a question the turn asked
20
21
  # (:question_requested), with how it was answered or cancelled, so a
21
22
  # reload draws the resolved card where it was.
22
23
  #
@@ -28,9 +29,10 @@ module Samagotchi
28
29
  # as /btw) goes after that turn: its prompt is already in the history.
29
30
  # A failed turn leaves no prompt, so its cards stay before the next one.
30
31
  #
31
- # Notices (hook notices, load warnings, "asking again" rows) and cards
32
- # (with questions) are capped apart, +capacity+ each, so a burst of
33
- # notices can't push out a card. An open question (an approval too) is
32
+ # Notices (hook notices, load warnings, "asking again" rows), ✂ rows
33
+ # and cards (with questions) are capped apart, +capacity+ each, so a
34
+ # burst of notices can't push out a card, nor a long turn's ✂ rows a
35
+ # hook's notice. An open question (an approval too) is
34
36
  # never pushed out: the oldest other card or answered question goes.
35
37
  #
36
38
  # With a +path+ (the Bridge's: FILE in the session's folder) the store
@@ -47,11 +49,14 @@ module Samagotchi
47
49
  class CardStore
48
50
  CAPACITY = 20
49
51
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
52
+ # Capped apart from the notices.
53
+ LLM_CONTEXT_TYPES = %i[llm_context_edited].freeze
54
+ LLM_CONTEXT_FIELDS = %i[type moment why freed_tokens tail_tokens staged text groups].freeze
50
55
  FILE = "cards.json"
51
56
  # The events after which the file is written again: the ones that
52
57
  # change an entry or the turn count.
53
- SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut question_requested question_answered question_cancelled
54
- question_relay turn_failed] + Events::TURN_KEPT).freeze
58
+ SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
59
+ question_cancelled question_relay turn_failed] + Events::TURN_KEPT).freeze
55
60
 
56
61
  # What a session's saved file lists, as a store started from it lists
57
62
  # (no turn running): the web's cards for a session no worker runs.
@@ -167,6 +172,7 @@ module Samagotchi
167
172
  .compact.merge(in_turn: false, turns: @turns_done))
168
173
  when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running
169
174
  when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
175
+ when :llm_context_edited then add_turn_row(Marshal.load(Marshal.dump(event.slice(*LLM_CONTEXT_FIELDS)))) if @running
170
176
  when :question_requested
171
177
  add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
172
178
  when :question_answered then resolve_question(event[:id], answer: event[:answer])
@@ -236,15 +242,20 @@ module Samagotchi
236
242
 
237
243
  def push(entry)
238
244
  @entries << entry
239
- notice = notice?(entry)
240
- kind = @entries.select { |e| notice?(e) == notice }
245
+ cap = cap_class(entry)
246
+ kind = @entries.select { |e| cap_class(e) == cap }
241
247
  return if kind.size <= @capacity
242
248
 
243
249
  evict = kind.find { |e| !open_question?(e) }
244
250
  @entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
245
251
  end
246
252
 
247
- def notice?(entry) = NOTICE_TYPES.include?(entry[:type])
253
+ # Which cap +entry+ counts against: :notice, :llm_context or :card.
254
+ def cap_class(entry)
255
+ return :llm_context if LLM_CONTEXT_TYPES.include?(entry[:type])
256
+
257
+ NOTICE_TYPES.include?(entry[:type]) ? :notice : :card
258
+ end
248
259
 
249
260
  def open_question?(entry)
250
261
  entry[:type] == :question && !entry.key?(:answer) && !entry[:cancelled]
@@ -4,7 +4,8 @@ module Samagotchi
4
4
  class Bridge
5
5
  # The events a running turn shows as rows of its own (not text, thinking
6
6
  # or a tool call): a hook's notice, the loop asking again after an empty
7
- # or cut answer, a question and how it was answered. A UI that joins the
7
+ # or cut answer, a batch of LLM context edits (✂), a question and how it
8
+ # was answered. A UI that joins the
8
9
  # turn mid-way (TurnAccumulator's "notice" part) and one that reloads a
9
10
  # finished turn (CardStore) get the event back with the fields listed
10
11
  # here, and draw it with the handler that drew it live, where it was.
@@ -13,6 +14,7 @@ module Samagotchi
13
14
  hook_notice: %i[hook text level fallback_for],
14
15
  empty_answer_retry: %i[iteration attempt of stopped_by malformed],
15
16
  steer_cut: %i[iteration source],
17
+ llm_context_edited: %i[moment why freed_tokens tail_tokens staged text groups],
16
18
  question_requested: %i[pending_question],
17
19
  question_answered: %i[id answer],
18
20
  question_cancelled: %i[id reason]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.43.0
3
+ version: 0.44.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -63,13 +63,21 @@ module Samagotchi
63
63
  "context elevated — be concise, prefer range reads, avoid re-reading large files",
64
64
  "context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
65
65
  ].freeze
66
+ # The top level's words when the buckets count against a budget
67
+ # (llm_context.budget_tokens) and no forget layer: nothing in the
68
+ # model's hands shrinks the context, so no "summarize".
69
+ BUDGET_CRITICAL = "context budget critical — avoid large outputs and re-reads, delegate broad work to subagents"
66
70
 
67
71
  # @param bucket [String] "under<N>" or "<N>plus"
68
72
  # @param thresholds [Array<Integer>] the configured ones, sorted
69
- def self.guidance(bucket, thresholds = DEFAULT_THRESHOLDS)
73
+ # @param budget [Boolean] the buckets count against a budget
74
+ def self.guidance(bucket, thresholds = DEFAULT_THRESHOLDS, budget: false)
70
75
  rank = bucket_rank(bucket, thresholds)
71
76
  level = rank < GUIDANCE_FROM_RANK ? 0 : GUIDANCE.size - 1 - (thresholds.size - rank)
72
- GUIDANCE[level.clamp(0, GUIDANCE.size - 1)]
77
+ level = level.clamp(0, GUIDANCE.size - 1)
78
+ return BUDGET_CRITICAL if budget && level == GUIDANCE.size - 1
79
+
80
+ GUIDANCE[level]
73
81
  end
74
82
 
75
83
  # 0 for the bucket under the first threshold, then one per threshold.
@@ -331,11 +339,16 @@ module Samagotchi
331
339
 
332
340
  def k(tokens) = tokens >= 1000 ? "#{(tokens / 1000.0).round}k" : tokens.to_s
333
341
 
342
+ # The buckets count against the budget (set, and not over the window).
343
+ def budget_counted?(usage) = !@budget.nil? && usage[:window_tokens] == @budget
344
+
334
345
  def guidance_message(usage:, bucket:)
335
346
  how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
347
+ budget = budget_counted?(usage)
348
+ of = budget ? "the context budget (#{k(@budget)} tokens)" : "the context window"
336
349
  { role: "system", kind: LINE_KIND,
337
- content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of the context window is in use " \
338
- "(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds)}]" }
350
+ content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of #{of} is in use " \
351
+ "(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds, budget: budget)}]" }
339
352
  end
340
353
 
341
354
  def status_message(usage:, bucket:)
@@ -350,7 +363,7 @@ module Samagotchi
350
363
  bucket: bucket,
351
364
  thresholds: @thresholds.join(","),
352
365
  src: usage[:source],
353
- guidance: self.class.guidance(bucket, @thresholds)
366
+ guidance: self.class.guidance(bucket, @thresholds, budget: budget_counted?(usage))
354
367
  )
355
368
  end
356
369
  end
@@ -28,6 +28,7 @@ require_relative "tool_ids"
28
28
  require_relative "llm_context_view"
29
29
  require_relative "llm_context_apply"
30
30
  require_relative "llm_context_forget"
31
+ require_relative "llm_context_notice"
31
32
  require_relative "answer_display"
32
33
  require_relative "llm/model_result"
33
34
  require_relative "llm/turn_settings"
@@ -279,7 +280,8 @@ module Samagotchi
279
280
  def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
280
281
  conversation = prepare_conversation(messages)
281
282
  context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
282
- @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context)
283
+ emit = ->(event) { emit_stream_event(on_stream_event, event) }
284
+ @llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
283
285
  Turn.new(
284
286
  conversation: conversation, context: context,
285
287
  empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
@@ -290,7 +292,7 @@ module Samagotchi
290
292
  prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
291
293
  pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
292
294
  on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
293
- emit: ->(event) { emit_stream_event(on_stream_event, event) }
295
+ emit: emit
294
296
  )
295
297
  end
296
298
 
@@ -321,7 +323,7 @@ module Samagotchi
321
323
  # (the prompt cache keeps its prefix), and the prompt is formatted again
322
324
  # with it.
323
325
  def prepare_request(turn)
324
- apply_llm_context!(turn.conversation, context: turn.context)
326
+ apply_llm_context!(turn.conversation, context: turn.context, emit: turn.emit)
325
327
  prompt, images = format_prompt(turn)
326
328
  image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
327
329
  window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
@@ -580,7 +582,7 @@ module Samagotchi
580
582
  # The model answered: the staged edits reach the session now (a turn
581
583
  # that ran out of steps, on tool results or on a retry's nudge, is
582
584
  # mid-task; they wait).
583
- apply_llm_context!(turn.conversation, moment: :turn_end) if turn.answered
585
+ apply_llm_context!(turn.conversation, moment: :turn_end, emit: turn.emit) if turn.answered
584
586
  # An empty answer left the conversation: an earlier model message
585
587
  # isn't this turn's answer.
586
588
  output = turn.ended_empty ? "" : strip_thought_blocks(last_model_content(turn.conversation))
@@ -784,8 +786,8 @@ module Samagotchi
784
786
  return "Error: forget_outputs is off (the llm_context strategy has no forget layer)"
785
787
  end
786
788
 
787
- LLMContextForget.call(turn, call, llm_context: llm_context,
788
- apply: -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context) })
789
+ apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
790
+ LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply)
789
791
  end
790
792
 
791
793
  # Before each request of both loops (+moment+ :request; the warm-up
@@ -797,10 +799,12 @@ module Samagotchi
797
799
  # first, and the warm-up) is the first with the stubs. +context+: the
798
800
  # turn's ContextStatus (payoff applies in its top bucket; an applied
799
801
  # batch rebases its estimate, ContextStatus#edited!). none changes
800
- # nothing. Logged as llm_context_apply when a batch is applied.
802
+ # nothing. Logged as llm_context_apply when a batch is applied, and
803
+ # shown to the user as a ✂ row (+emit+ takes the :llm_context_edited
804
+ # event, LLMContextNotice; the warm-up's own copy has none).
801
805
  # @return [LLMContextApply::Outcome]
802
806
  def apply_llm_context!(conversation, llm_context = @turn_settings&.llm_context, moment: :request, context: nil,
803
- warmup: false)
807
+ warmup: false, emit: nil)
804
808
  view = llm_context_view(llm_context)
805
809
  return LLMContextApply::Outcome.none if view.none?
806
810
 
@@ -811,7 +815,10 @@ module Samagotchi
811
815
  if outcome.applied?
812
816
  # The prompt just shrank: the context estimate counts from it.
813
817
  context&.edited!
814
- log_llm_context(outcome, llm_context, moment) unless warmup
818
+ unless warmup
819
+ log_llm_context(outcome, llm_context, moment)
820
+ emit_llm_context(emit, conversation, outcome, moment) if emit
821
+ end
815
822
  end
816
823
  outcome
817
824
  end
@@ -825,6 +832,14 @@ module Samagotchi
825
832
  end
826
833
  private :log_llm_context
827
834
 
835
+ # The batch's ✂ row; a row that can't be built never breaks the turn.
836
+ def emit_llm_context(emit, conversation, outcome, moment)
837
+ emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
838
+ rescue StandardError => e
839
+ Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
840
+ end
841
+ private :emit_llm_context
842
+
828
843
  # A request's re-prefilled tokens, for both loops: of the prompt the
829
844
  # last request on +model+ sent (this kernel's, any turn), what the
830
845
  # server didn't reuse from its cache this time (that prompt less
@@ -358,7 +358,8 @@ module Samagotchi
358
358
  # steps from them (ModelResult#empty_steps).
359
359
  @empty_steps = []
360
360
  @context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
361
- loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context)
361
+ loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
362
+ emit: method(:emit))
362
363
  end
363
364
 
364
365
  def call(max_iterations:, cap:)
@@ -455,7 +456,7 @@ module Samagotchi
455
456
  def generate(iteration)
456
457
  # The strategy's edits the apply rule lets through first (stale):
457
458
  # this request sends them.
458
- @loop.apply_llm_context!(@conversation, context: @context)
459
+ @loop.apply_llm_context!(@conversation, context: @context, emit: method(:emit))
459
460
  window = @window = @loop.context_window(@model_name)
460
461
  observe_context(iteration, window)
461
462
  retry_generation = @empty_retry.take_sampling!
@@ -640,7 +641,7 @@ module Samagotchi
640
641
  def result(text, exhausted:)
641
642
  # The model answered: the staged edits reach the session now (a
642
643
  # turn that ran out of steps is mid-task; they wait).
643
- @loop.apply_llm_context!(@conversation, moment: :turn_end) unless exhausted
644
+ @loop.apply_llm_context!(@conversation, moment: :turn_end, emit: method(:emit)) unless exhausted
644
645
  ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
645
646
  tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
646
647
  context_status: @context.display)
@@ -44,8 +44,12 @@ module Samagotchi
44
44
  SMALL = 80
45
45
 
46
46
  # The turn a forget_outputs call runs in: its conversation (the
47
- # entries the edits are saved on) and its ContextStatus (nil: none).
48
- Turn = Data.define(:conversation, :context)
47
+ # entries the edits are saved on), its ContextStatus (nil: none) and
48
+ # its stream (+emit+, nil: none), which gets an applied batch's ✂ row
49
+ # (LLMContextNotice).
50
+ Turn = Data.define(:conversation, :context, :emit) do
51
+ def initialize(emit: nil, **fields) = super
52
+ end
49
53
 
50
54
  # One output a call may name: its entry, its ToolIds ref, its tool,
51
55
  # its text (without the "[name]" lead) and its step; +offset+: what
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_context_stale"
4
+ require_relative "tool_activity"
5
+ require_relative "token_usage"
6
+
7
+ module Samagotchi
8
+ # The ✂ row a turn gets for a batch of LLM context edits the apply rule
9
+ # let through (LLMContextApply::Outcome#applied?): the :llm_context_edited
10
+ # event both loops emit (KernelLoop#apply_llm_context!, its +emit+), at a
11
+ # request (+moment+ :request; a forget_outputs call's batch too) or at the
12
+ # end of a turn (:turn_end); never for the warm-up, a held or a staged
13
+ # batch, or a restore (the forget_outputs row says it). The web and the
14
+ # TUI print +text+ as it is; the web's hover lists +groups+.
15
+ #
16
+ # {type: :llm_context_edited, moment:, why:, freed_tokens:, tail_tokens:,
17
+ # staged:, text: "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k
18
+ # tokens (paid off)", groups: [{kind: "stale", items: [{id, tool, title,
19
+ # note}]}, {kind: "forget", note:, by:, items: [{id, tool, title, kept}]}]}
20
+ #
21
+ # The stale items share one group, each with its own note; a forget group
22
+ # is one forget_outputs call's (the view's key: note, by and staged_at),
23
+ # its note once. Tokens are chars/4, so "~".
24
+ module LLMContextNotice
25
+ TYPE = :llm_context_edited
26
+ # A forget's note on the row, at most (the stub keeps all of it).
27
+ NOTE_MAX = 300
28
+ WHY = { payoff: "paid off", top_bucket: "the context is nearly full", turn_end: "at turn end" }.freeze
29
+
30
+ module_function
31
+
32
+ # @param outcome [LLMContextApply::Outcome] an applied one
33
+ # @param moment [Symbol] :request or :turn_end
34
+ # @return [Hash] the event
35
+ def event(conversation, outcome, moment:, cwd: Dir.pwd)
36
+ calls = LLMContextStale.paired_runs(conversation).to_h { |run| [run.ref.id, run.call] }
37
+ groups = groups(outcome.applied, calls, cwd)
38
+ freed = tokens(outcome.freed_chars)
39
+ { type: TYPE, moment: moment.to_s, why: outcome.why.to_s, freed_tokens: freed,
40
+ tail_tokens: tokens(outcome.tail_chars), staged: outcome.staged,
41
+ text: text(groups, freed, outcome.why, outcome.staged), groups: groups }
42
+ end
43
+
44
+ def groups(edits, calls, cwd)
45
+ stale = edits.select { |edit| edit.kind == :stale }
46
+ forgets = edits.select { |edit| edit.kind == :forget }.group_by { |edit| [edit.note, edit.by, edit.staged_at] }
47
+ groups = []
48
+ unless stale.empty?
49
+ groups << { kind: "stale", items: stale.map { |edit| item(edit, calls, cwd).merge(note: edit.note) } }
50
+ end
51
+ forgets.each do |(note, by, _), group|
52
+ items = group.map do |edit|
53
+ kept = edit.keep.map { |first, last| first == last ? first.to_s : "#{first}-#{last}" }
54
+ item(edit, calls, cwd).merge(kept: kept.empty? ? nil : kept.join(", ")).compact
55
+ end
56
+ groups << { kind: "forget", note: cut(note.to_s), by: by, items: items }
57
+ end
58
+ groups
59
+ end
60
+
61
+ # An output's id, its tool and its row's title (ToolActivity.tool_title;
62
+ # none when its call can't be told).
63
+ def item(edit, calls, cwd)
64
+ call = calls[edit.id]
65
+ return { id: edit.id } unless call
66
+
67
+ name = call[:name].to_s
68
+ { id: edit.id, tool: name, title: ToolActivity.tool_title(name, call, cwd: cwd) }.compact
69
+ end
70
+
71
+ # "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
72
+ # off) · 1 more staged".
73
+ def text(groups, freed, why, staged)
74
+ forgot = groups.select { |group| group[:kind] == "forget" }.sum { |group| group[:items].size }
75
+ stale = groups.select { |group| group[:kind] == "stale" }.sum { |group| group[:items].size }
76
+ done = []
77
+ done << "forgot #{count(forgot, "output")}" if forgot.positive?
78
+ done << "stubbed #{count(stale, "stale read")}" if stale.positive?
79
+ frees = freed.positive? ? "frees ~#{k(freed)}" : "frees nothing"
80
+ frees += " (#{WHY[why]})" if WHY.key?(why)
81
+ line = "✂ #{done.join(", ")} · #{frees}"
82
+ staged.positive? ? "#{line} · #{staged} more staged" : line
83
+ end
84
+
85
+ def count(number, noun) = "#{number} #{noun}#{"s" unless number == 1}"
86
+
87
+ def tokens(chars) = [(chars / TokenUsage::CHARS_PER_TOKEN).ceil, 0].max
88
+
89
+ # "4.1k tokens", "640 tokens".
90
+ def k(tokens) = tokens >= 1000 ? format("%.1fk tokens", tokens / 1000.0) : "#{tokens} tokens"
91
+
92
+ def cut(note) = note.length > NOTE_MAX ? "#{note[0, NOTE_MAX - 1]}…" : note
93
+
94
+ private_class_method :groups, :item, :text, :count, :tokens, :k, :cut
95
+ end
96
+ end
@@ -1222,7 +1222,8 @@ module Samagotchi
1222
1222
  # hook's notice, a retry's line), as they were drawn live.
1223
1223
  # Questions are left out: the pending one is asked after the
1224
1224
  # replay, answered ones showed no line.
1225
- when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut then @renderer.call(event)
1225
+ when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
1226
+ @renderer.call(event)
1226
1227
  end
1227
1228
  end
1228
1229
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
@@ -83,6 +83,9 @@ module Samagotchi
83
83
  when :steer_cut
84
84
  @view.finish_thinking_spinner
85
85
  @view.print_line(@view.format_steer_cut_line(event))
86
+ when :llm_context_edited
87
+ @view.finish_thinking_spinner
88
+ @view.print_line(@view.format_llm_context_line(event))
86
89
  when :turn_completed
87
90
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
88
91
  when :turn_canceled
@@ -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)
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.43.0"
4
+ VERSION = "0.44.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1658,6 +1658,8 @@ module Samagotchi
1658
1658
  def messages_for_display(msgs, parts: false, cwd: nil)
1659
1659
  filtered = []
1660
1660
  list = Array(msgs)
1661
+ # The outputs' ✂ marks (LLM context edits), by id, for the parts.
1662
+ marks = parts ? MessageParts.edit_marks(list) : {}
1661
1663
  list.each_with_index do |m, index|
1662
1664
  role = (m[:role] || m["role"]).to_s
1663
1665
  content = (m[:content] || m["content"]).to_s
@@ -1689,7 +1691,7 @@ module Samagotchi
1689
1691
  next if role == "tool_response"
1690
1692
 
1691
1693
  stripped = Samagotchi::OutputFormatter.strip_markup(content)
1692
- did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd) : nil
1694
+ did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
1693
1695
  next if stripped.empty? && did.nil?
1694
1696
 
1695
1697
  norm_role = role == "model" ? "assistant" : role
@@ -1757,10 +1759,11 @@ module Samagotchi
1757
1759
  end
1758
1760
 
1759
1761
  # 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)
1762
+ # messages right after it; +marks+: the session's ✂ marks
1763
+ # (MessageParts.edit_marks).
1764
+ def message_parts(list, index, cwd = nil, marks = {})
1762
1765
  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)
1766
+ MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
1764
1767
  end
1765
1768
 
1766
1769
  # 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");
@@ -1527,11 +1527,17 @@ function showSteers(steers) {
1527
1527
  }
1528
1528
  }
1529
1529
 
1530
+ // A ✂ row's notice data (turnDom.notice, placeTurnNotice).
1531
+ function llmContextNotice(data) {
1532
+ return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
1533
+ }
1534
+
1530
1535
  function showHookNotice(data, { before = null } = {}) {
1531
1536
  removeHintIfEmpty();
1532
1537
  const el = document.createElement("div");
1533
- el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
1538
+ el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
1534
1539
  el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1540
+ if (data.title) el.title = data.title;
1535
1541
  const follow = isNearBottom();
1536
1542
  if (before) historyEl.insertBefore(el, before);
1537
1543
  else appendToHistory(el);
@@ -1623,18 +1629,20 @@ function placeSnapshotCards(entries) {
1623
1629
  // plain answer, a step the history doesn't have) a bubble before the
1624
1630
  // turn's answer, or before its timing line when it has none.
1625
1631
  function placeTurnNotice(entry, before) {
1632
+ const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
1626
1633
  const line = noticeLine(entry);
1627
1634
  const turnEls = reloadedTurnEls(before);
1628
1635
  const place = turnNoticePlace(entry);
1629
1636
  const block = turnEls.find((el) => el.matches(".turn-work"));
1630
1637
  const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1631
1638
  if (!step) {
1632
- showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
1639
+ showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
1633
1640
  return;
1634
1641
  }
1635
1642
  const el = document.createElement("div");
1636
- el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
1643
+ el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
1637
1644
  el.textContent = line;
1645
+ if (extra.title) el.title = extra.title;
1638
1646
  let body = step.querySelector(":scope > .activity-body");
1639
1647
  if (!body) {
1640
1648
  body = document.createElement("div");
@@ -2191,6 +2199,9 @@ const streamHandlers = {
2191
2199
  // (a reload puts it back from the snapshot's cards).
2192
2200
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
2193
2201
  steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
2202
+ // A batch of LLM context edits the server applied: a ✂ row of the
2203
+ // current step, its hover listing the outputs.
2204
+ llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
2194
2205
  // The provider is asked again after an error, or the turn waits for
2195
2206
  // plugins' setup: a note on the live timing line until it streams.
2196
2207
  generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
@@ -172,6 +172,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
172
172
  .activity-output{flex:1 1 100%;min-width:0}
173
173
  /* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
174
174
  .activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
175
+ /* A reloaded row's ✂ mark (an LLM context edit on its output), one line above the output. */
176
+ .activity-edit{flex:1 1 100%;min-width:0;margin:2px 0 0 var(--row-gutter);font-size:12px;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:help}
175
177
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
176
178
  .activity-diff>summary:hover{color:var(--text-2)}
177
179
  .activity-diff[open]>summary{margin-bottom:4px}
@@ -212,6 +214,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
212
214
  .activity-params{flex:1 1 0}
213
215
  .activity-output{margin-left:0}
214
216
  .activity-diff{margin-left:0}
217
+ .activity-edit{margin-left:0}
215
218
  .activity-row>.activity-command{margin-left:0}
216
219
  }
217
220
  .bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
@@ -374,6 +377,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
374
377
  .gen .hook-notice{font-size:13px;color:var(--text-3);padding:5px 2px 5px 8px;border-top:1px solid var(--border);border-left:2px solid var(--text-faint);white-space:pre-wrap;word-break:break-word}
375
378
  .gen .activity-body>.hook-notice:first-child{border-top:none}
376
379
  .gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
380
+ .hook-notice.llm-context{border-left-style:dashed;cursor:help}
377
381
  /* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
378
382
  .steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
379
383
  .steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
@@ -156,6 +156,39 @@ export function steerCutLine({ source } = {}) {
156
156
  return `↪ cut in for ${STEER_CUT_FOR[source] || "your message"}`;
157
157
  }
158
158
 
159
+ // The ✂ row of a batch of LLM context edits the server applied
160
+ // (llm_context_edited, LLMContextNotice): its line as the server built
161
+ // it ("✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens"),
162
+ // so the web and the TUI say the same.
163
+ export function llmContextLine({ text } = {}) {
164
+ return text || "✂ LLM context edited";
165
+ }
166
+
167
+ // The ✂ row's hover: what each group did, one line per output (its id,
168
+ // tool and title), and what the batch costs ("~": chars/4).
169
+ export function llmContextHover({ groups = [], freed_tokens: freed, tail_tokens: tail, staged, moment } = {}) {
170
+ const lines = [];
171
+ const output = (item) => [item.id, item.tool, item.title].filter(Boolean).join(" ");
172
+ for (const group of Array.isArray(groups) ? groups : []) {
173
+ const items = Array.isArray(group.items) ? group.items : [];
174
+ if (group.kind === "stale") {
175
+ lines.push("Stubbed as stale:");
176
+ for (const item of items) lines.push(` ${output(item)}${item.note ? ` — ${item.note}` : ""}`);
177
+ } else if (group.kind === "forget") {
178
+ lines.push(`Forgotten by the ${group.by || "model"}: ${group.note || ""}`);
179
+ for (const item of items) lines.push(` ${output(item)}${item.kept ? ` · lines ${item.kept} kept` : ""}`);
180
+ }
181
+ }
182
+ const cost = [];
183
+ if (Number(freed) > 0) cost.push(`frees ~${freed} tokens`);
184
+ if (Number(tail) > 0) cost.push(`the server reads ~${tail} tokens again`);
185
+ if (cost.length) lines.push(cost.join("; "));
186
+ if (Number(staged) > 0) lines.push(`${staged} more staged until the turn ends`);
187
+ lines.push(moment === "turn_end" ? "Sent from the next turn on." : "Sent from the next request on.");
188
+ lines.push("The session keeps the originals.");
189
+ return lines.join("\n");
190
+ }
191
+
159
192
  // The one muted line for a turn that ended with no answer (turn_completed's
160
193
  // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
161
194
  // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
@@ -170,6 +203,7 @@ export function emptyAnswerLine({ retries = 0 } = {}) {
170
203
  export function noticeLine(entry = {}) {
171
204
  if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
172
205
  if (entry.type === "steer_cut") return steerCutLine(entry);
206
+ if (entry.type === "llm_context_edited") return llmContextLine(entry);
173
207
  return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
174
208
  }
175
209
 
@@ -33,12 +33,43 @@ function statusWordHtml({ cls, word }) {
33
33
  return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
34
34
  }
35
35
 
36
+ // A reloaded row's ✂ mark: what an LLM context edit saved on its output
37
+ // sends the model instead (MessageParts.edit_marks): a stale stub and
38
+ // what it frees, a forget's note (or the output whose row carries it) and
39
+ // its kept lines, a forget still staged. The live rows get none (a reload
40
+ // shows them).
41
+ export function llmEditLine(edit) {
42
+ if (!edit || typeof edit !== "object") return "";
43
+ const kept = edit.kept ? ` · lines ${edit.kept} kept` : "";
44
+ if (edit.kind === "forget") {
45
+ if (edit.staged) return `✂ forget staged${kept}`;
46
+ return `${edit.with ? `✂ forgotten with ${edit.with}` : `✂ forgotten: ${edit.note || ""}`}${kept}`;
47
+ }
48
+ if (edit.kind === "stale") {
49
+ const tokens = Number(edit.tokens) > 0 ? ` · ~${compactTokens(Number(edit.tokens))}` : "";
50
+ return `✂ stubbed: ${edit.note || "stale"}${tokens}`;
51
+ }
52
+ return "";
53
+ }
54
+
55
+ function compactTokens(n) {
56
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k tokens` : `${n} tokens`;
57
+ }
58
+
59
+ function llmEditHtml(row) {
60
+ const line = llmEditLine(row.edit);
61
+ if (!line) return "";
62
+ const hover = `${row.tool_id ? `${row.tool_id}: ` : ""}${line}\nThe model is sent this instead of the output; the session keeps it.`;
63
+ return `<div class="activity-edit" title="${escapeHtml(hover)}">${escapeHtml(line)}</div>`;
64
+ }
65
+
36
66
  // A tool row: its status mark, tool, title (else params; the full command,
37
67
  // else the full params line, on hover), the word an exception shows, the
38
68
  // call's duration, the full command (an execute/task_create's view, also
39
69
  // while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
40
70
  // the rest on hover), its images (+thumbs+(images) draws them) and an
41
- // edit's collapsed diff. A part with nothing to say is left out.
71
+ // edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
72
+ // part with nothing to say is left out.
42
73
  export function toolRowHtml(row, { thumbs = () => "" } = {}) {
43
74
  return `<div class="activity-row" data-key="${escapeHtml(row.key)}">${toolRowInnerHtml(row, { thumbs })}</div>`;
44
75
  }
@@ -55,7 +86,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
55
86
  const status = statusParts(row.status);
56
87
  return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${statusWordHtml(status)}` +
57
88
  `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
58
- `${commandBlockHtml(row.view, row.title)}${output}` +
89
+ `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
59
90
  `${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
60
91
  }
61
92
 
@@ -422,15 +422,17 @@ export function createTurnView({
422
422
  // A hook's line (hook_notice) as a row of the current step, above the row
423
423
  // of the call it judged; false with no running turn (app.js shows the
424
424
  // bubble then). data.line is a whole row of chi's own (an empty-answer
425
- // retry), shown as is.
425
+ // retry), shown as is; data.cls a class of its own and data.title its
426
+ // hover (a ✂ row's).
426
427
  function notice(data) {
427
428
  const r = turn && model.notice(turn, data);
428
429
  if (!r) return false;
429
430
  const entry = genEls.get(r.gen);
430
431
  if (!r.gen.text) entry.text.textContent = "";
431
432
  const el = document.createElement("div");
432
- el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
433
+ el.className = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
433
434
  el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
435
+ if (data.title) el.title = data.title;
434
436
  const stick = isNearBottom();
435
437
  activityEl(r.gen).appendChild(el);
436
438
  if (stick) follow();
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.43.0
4
+ version: 0.44.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -315,6 +315,7 @@ files:
315
315
  - lib/samagotchi/llm_context_command.rb
316
316
  - lib/samagotchi/llm_context_edit.rb
317
317
  - lib/samagotchi/llm_context_forget.rb
318
+ - lib/samagotchi/llm_context_notice.rb
318
319
  - lib/samagotchi/llm_context_override.rb
319
320
  - lib/samagotchi/llm_context_stale.rb
320
321
  - lib/samagotchi/llm_context_strategy.rb