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
@@ -3,7 +3,8 @@
3
3
  ## Start here
4
4
  - `chi self` (via `execute`) prints my version, **source dir**, config path, hooks dir,
5
5
  memory dirs, sessions dir, model/host and bundles. Use its source dir; don't hunt via `which`/`gem list`/`find /`.
6
- Its `model` row is this session's model (live after `/model`), `model key` my overlay key.
6
+ Its `model` row is this session's model (live after `/model`), `model key` my overlay key,
7
+ `model notes` the model notes my prompt loads (`/stats` names the ones this session's prompt carried).
7
8
  - My current session id is in the system prompt; resume with `chi --resume <id>`.
8
9
  - My debug log is the `log` line of `chi self` (default `$XDG_STATE_HOME/samagotchi/samagotchi.log`,
9
10
  next to the sessions dir; not `~/.local/state` when `XDG_STATE_HOME` is set). One record per line,
@@ -10,9 +10,15 @@ module Samagotchi
10
10
  # An entry the UI runs itself (/stats, /exit, …) is +local+: it is listed
11
11
  # for Tab completion and help, and #lookup never returns it.
12
12
  class Registry
13
+ # What a line does while a turn runs: runs now on its own thread
14
+ # (:anytime), runs after the turn ends (:queue), or is refused
15
+ # (:refuse, "busy").
16
+ MID_TURN = %i[anytime queue refuse].freeze
17
+
13
18
  # @!attribute id [Symbol] the entry's key (:model, :rollback, …)
14
19
  # @!attribute name [String] "/model", "!rollback", …
15
- # @!attribute anytime [Boolean] may run while a turn runs
20
+ # @!attribute mid_turn [Symbol, #call] what a line of it does while a
21
+ # turn runs (MID_TURN): a symbol, or a lambda line (stripped) → one
16
22
  # @!attribute local [Boolean] the UI runs it; completion and help only
17
23
  # @!attribute uis [Array<Symbol>, nil] a local entry's UIs (:repl,
18
24
  # :attached) for completion; nil means every UI
@@ -21,9 +27,19 @@ module Samagotchi
21
27
  # a built-in's with the line, and calls a bundle's with the text
22
28
  # after the name
23
29
  # @!attribute source [String] "core", or the bundle that added it
24
- Entry = Struct.new(:id, :name, :description, :anytime, :local, :uis, :match, :handler, :source,
30
+ Entry = Struct.new(:id, :name, :description, :mid_turn, :local, :uis, :match, :handler, :source,
25
31
  keyword_init: true) do
26
32
  def match?(text) = match.call(text)
33
+
34
+ # Whether every line of it runs at once, a turn running or not (D8).
35
+ def anytime = mid_turn == :anytime
36
+
37
+ # @param text [String] a line of it, stripped
38
+ # @return [Symbol] one of MID_TURN
39
+ def mid_turn_for(text) = mid_turn.respond_to?(:call) ? mid_turn.call(text) : mid_turn
40
+
41
+ # How a listing names #mid_turn: "depends" when it is the line's.
42
+ def mid_turn_label = mid_turn.respond_to?(:call) ? "depends" : mid_turn.to_s
27
43
  def slash? = name.start_with?("/")
28
44
  def in_ui?(ui) = uis.nil? || uis.include?(ui)
29
45
  end
@@ -49,21 +65,36 @@ module Samagotchi
49
65
  next if name.empty? || known.include?(name)
50
66
 
51
67
  known << name
52
- registry.register(name, item[:description].to_s, anytime: item[:anytime] == true, local: item[:local] == true,
68
+ registry.register(name, item[:description].to_s, mid_turn: listed_mid_turn(item), local: item[:local] == true,
53
69
  uis: item[:uis]&.map(&:to_sym), source: item[:source] || "core")
54
70
  end
55
71
  registry
56
72
  end
57
73
 
74
+ # A listed entry's #mid_turn: its mid_turn, else (an older worker's
75
+ # listing) its anytime. "depends" (a line's own, which only the
76
+ # worker's entry knows) and anything unknown are :refuse: the UI
77
+ # sends the line and the worker decides.
78
+ def self.listed_mid_turn(item)
79
+ label = item[:mid_turn]&.to_sym
80
+ return label if MID_TURN.include?(label)
81
+ return :refuse if label
82
+
83
+ item[:anytime] == true ? :anytime : :refuse
84
+ end
85
+ private_class_method :listed_mid_turn
86
+
87
+ # @param anytime [Boolean] mid_turn: :anytime (a bundle's command)
88
+ # @param mid_turn [Symbol, #call, nil] see Entry; nil takes +anytime+
58
89
  # @param match [#call, nil] the default matches the name alone or the
59
90
  # name, a space and arguments
60
91
  # @return [Entry]
61
- def register(name, description, id: nil, anytime: false, local: false, uis: nil, match: nil,
92
+ def register(name, description, id: nil, anytime: false, mid_turn: nil, local: false, uis: nil, match: nil,
62
93
  source: "core", &handler)
63
94
  raise ArgumentError, "command #{name} is already registered" if @entries.any? { |entry| entry.name == name }
64
95
 
65
96
  entry = Entry.new(id: id || name.delete_prefix("/").to_sym, name: name, description: description,
66
- anytime: anytime, local: local, uis: uis, match: match || default_match(name),
97
+ mid_turn: mid_turn || (anytime ? :anytime : :refuse), local: local, uis: uis, match: match || default_match(name),
67
98
  handler: handler, source: source)
68
99
  @entries << entry
69
100
  entry
@@ -77,6 +108,19 @@ module Samagotchi
77
108
 
78
109
  def command?(line) = !lookup(line).nil?
79
110
 
111
+ # Whether every line of +line+'s command runs at once, a turn running
112
+ # or not (D8). A line whose #mid_turn is :anytime but whose command's
113
+ # isn't always (/model) runs at once only while a turn runs.
114
+ def anytime?(line) = lookup(line)&.anytime == true
115
+
116
+ # What +line+ does while a turn runs (MID_TURN); :refuse for a line
117
+ # no command answers.
118
+ # @return [Symbol]
119
+ def mid_turn(line)
120
+ text = line.to_s.strip
121
+ lookup(text)&.mid_turn_for(text) || :refuse
122
+ end
123
+
80
124
  # A line that is one word: a slash and a name, no spaces, no second
81
125
  # slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
82
126
  # are prompts).
@@ -115,12 +159,13 @@ module Samagotchi
115
159
  def entries = @entries.dup
116
160
 
117
161
  # What a snapshot tells a UI without an Engine (see .from_listing).
118
- # @return [Array<Hash>] {name:, description:, anytime:, local:, uis:,
119
- # source:} per entry, in registration order
162
+ # @return [Array<Hash>] {name:, description:, anytime:, mid_turn:,
163
+ # local:, uis:, source:} per entry, in registration order; anytime
164
+ # is true only when every line is (mid_turn "anytime")
120
165
  def listing
121
166
  @entries.map do |entry|
122
- { name: entry.name, description: entry.description, anytime: entry.anytime ? true : false,
123
- local: entry.local ? true : false, uis: entry.uis&.map(&:to_s), source: entry.source }
167
+ { name: entry.name, description: entry.description, anytime: entry.anytime,
168
+ mid_turn: entry.mid_turn_label, local: entry.local ? true : false, uis: entry.uis&.map(&:to_s), source: entry.source }
124
169
  end
125
170
  end
126
171
 
@@ -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
@@ -481,11 +481,16 @@ module Samagotchi
481
481
 
482
482
  # The model notes the session's prompt carried at its last build
483
483
  # (Session#prompt_notes): a resumed session's or a woken worker's saved
484
- # ones until this process builds its prompt; without a session, the
485
- # notes the effective model's prompt would load.
484
+ # ones until this process builds its prompt; a fresh session (no build
485
+ # yet, no messages) has none saved, so the effective model's prompt
486
+ # would load them: the notes #model_notes names, as without a session.
487
+ # The three readers (/model, /stats, the web) share this.
486
488
  # @return [Array<PromptNote>]
487
489
  def prompt_notes
488
- return @session.prompt_notes.dup if @session
490
+ return model_notes.map { |note| PromptNote.from_note(note) } unless @session
491
+
492
+ saved = @session.prompt_notes
493
+ return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
489
494
 
490
495
  model_notes.map { |note| PromptNote.from_note(note) }
491
496
  rescue StandardError
@@ -235,6 +235,26 @@ module Samagotchi
235
235
  end
236
236
  end
237
237
 
238
+ # List one host's models and save them to ModelListStore: what
239
+ # ModelProfile.model_warning re-lists on a miss (a fresh saved list that
240
+ # lacks the id: the server may have been reloaded with another model),
241
+ # so the saved list catches up at spawn time. Only that host is asked,
242
+ # and never through the cache (a fresh listing is the point).
243
+ # @param name [String, Symbol, nil] a host name; anything else lists
244
+ # nothing
245
+ # @return [Array<String>, nil] the ids the host lists now, nil for an
246
+ # unknown name or when the host fails to list (nothing is saved then:
247
+ # a host that is down keeps the ids we know)
248
+ def list_models(name)
249
+ entry = name.nil? ? nil : find_entry(name)
250
+ return nil unless entry
251
+
252
+ save_model_list(entry.name, list_models_for(entry))&.ids
253
+ rescue StandardError => e
254
+ Log.warn(:model, "list_failed", host: name.to_s, error: e.class.name, msg: e.message.to_s[0, 500])
255
+ nil
256
+ end
257
+
238
258
  def client_for_model(raw_model)
239
259
  host_entry, bare = host_for_model(raw_model)
240
260
  [client_for(host_entry), bare, host_entry]
@@ -293,9 +313,7 @@ module Samagotchi
293
313
  models = list_models_for(entry)
294
314
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
295
315
  @mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
296
- # On disk too: a process that spawns a worker without listing
297
- # (`chi send --new --model`, delegate) checks an id against it.
298
- ModelListStore.save(name, models.map(&:id))
316
+ save_model_list(name, models)
299
317
  rescue StandardError => e
300
318
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
301
319
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
@@ -350,6 +368,13 @@ module Samagotchi
350
368
 
351
369
  private
352
370
 
371
+ # Save a host's just-listed models on disk, for the processes that spawn
372
+ # a worker without listing (`chi send --new --model`, delegate):
373
+ # ModelListStore is what they check an id against.
374
+ def save_model_list(name, models)
375
+ ModelListStore.save(name, Array(models).map(&:id))
376
+ end
377
+
353
378
  def fresh_list(name, entry)
354
379
  @mutex.synchronize do
355
380
  cached = @host_lists[name]
@@ -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
@@ -167,6 +167,43 @@ module Samagotchi
167
167
  budget_tokens: budget_origin)
168
168
  end
169
169
 
170
+ # A session's LLM context strategy, apply rule and budget, each with
171
+ # where it came from (Explained#summary), as its worker would resolve
172
+ # them; nil when they can't be (an unreadable config, a model that
173
+ # resolves nowhere). Resolves from config and the registry only: no
174
+ # network, no model call. What a session page, a session list's ctx %
175
+ # and a spawn-time check all read.
176
+ # @param session [Session, nil]
177
+ # @param registry [HostRegistry, nil] the hosts to resolve against;
178
+ # HostRegistry.new by default (what the caller usually has)
179
+ # @return [Hash, nil] Explained#summary, symbol keys
180
+ def for_session(session, registry: nil)
181
+ return nil if session.nil?
182
+
183
+ require_relative "config"
184
+ registry ||= begin
185
+ require_relative "host_registry"
186
+ HostRegistry.new
187
+ end
188
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
189
+ target = registry.resolve(name)
190
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
191
+ own = session.llm_context
192
+ explain(target, names: names, **(own&.resolve_args || {})).summary(own)
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ # The budget a session's turns run under (for_session's :budget_tokens),
198
+ # nil when it is off or can't be resolved. What a session list's ctx %
199
+ # counts against, so a saved session's fill reads as the live meter.
200
+ # @param session [Session, nil]
201
+ # @param registry [HostRegistry, nil] see .for_session
202
+ # @return [Integer, nil]
203
+ def session_budget(session, registry: nil)
204
+ for_session(session, registry: registry)&.fetch(:budget_tokens, nil)
205
+ end
206
+
170
207
  # [the budget, its Origin]: the session's own first (0 there is off,
171
208
  # whatever the model says).
172
209
  def budget_for(target, names, models, session_budget)
@@ -63,7 +63,8 @@ module Samagotchi
63
63
  files(scope, env).filter_map do |path|
64
64
  next unless seen.add?(File.expand_path(path))
65
65
 
66
- note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
66
+ note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once,
67
+ env: env)
67
68
  end
68
69
  end
69
70
  warn_sizes(notes) if warn
@@ -110,7 +111,7 @@ module Samagotchi
110
111
  []
111
112
  end
112
113
 
113
- def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
114
+ def note_at(path, scope, name:, key:, fallback_key:, muted:, small:, env: ENV)
114
115
  stem = File.basename(path, ".md")
115
116
  return nil if dotted?(path, stem)
116
117
  return nil if MutedMemories.muted?(stem, muted)
@@ -122,7 +123,7 @@ module Samagotchi
122
123
  body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
123
124
  return nil if body.empty?
124
125
 
125
- body = with_overlay(body, stem, scope, [key, fallback_key])
126
+ body = with_overlay(body, stem, scope, [key, fallback_key], env: env)
126
127
 
127
128
  Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
128
129
  rescue StandardError
@@ -131,10 +132,11 @@ module Samagotchi
131
132
 
132
133
  # The note's model overlay appended as memory_read appends one: the
133
134
  # key's, else the fallback key's. The file is read by its own path, not
134
- # by name through memory_read (whose names are comma lists).
135
- def with_overlay(body, stem, scope, keys)
135
+ # by name through memory_read (whose names are comma lists). +env+: the
136
+ # XDG env the scope's dir (and so the overlay) resolves from.
137
+ def with_overlay(body, stem, scope, keys, env: ENV)
136
138
  keys.compact.each do |overlay_key|
137
- overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
139
+ overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope, env: env)
138
140
  next unless overlay && File.file?(overlay)
139
141
 
140
142
  return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
@@ -38,10 +38,11 @@ module Samagotchi
38
38
 
39
39
  # Return the expected file path for an overlay in the given scope.
40
40
  # Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
41
- def self.overlay_path_for(name, key, scope)
41
+ # @param env [Hash] the XDG env the scope's memories dir resolves from
42
+ def self.overlay_path_for(name, key, scope, env: ENV)
42
43
  return nil unless key && !key.to_s.strip.empty?
43
44
 
44
- File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
45
+ File.join(Tools::MemoryRead.memories_dir(scope, env: env), "#{name}.#{key.to_s.strip}.md")
45
46
  end
46
47
 
47
48
  # Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z