samagotchi 0.42.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/configuration.md +26 -0
  4. data/docs/context.md +46 -0
  5. data/docs/internals/context-telemetry.md +4 -1
  6. data/docs/internals/engine.md +1 -0
  7. data/docs/plugins.md +27 -5
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge/card_store.rb +20 -9
  10. data/lib/samagotchi/bridge/turn_notice.rb +3 -1
  11. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  12. data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/context_sources.rb +40 -0
  15. data/lib/samagotchi/context_status.rb +18 -5
  16. data/lib/samagotchi/kernel_loop.rb +24 -9
  17. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  18. data/lib/samagotchi/llm_context_forget.rb +6 -2
  19. data/lib/samagotchi/llm_context_notice.rb +96 -0
  20. data/lib/samagotchi/plugin/attached_context.rb +75 -11
  21. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  22. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  23. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  24. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  25. data/lib/samagotchi/tool_declarations.rb +2 -1
  26. data/lib/samagotchi/version.rb +1 -1
  27. data/lib/samagotchi/web/app.rb +47 -7
  28. data/lib/samagotchi/web/message_parts.rb +90 -11
  29. data/lib/samagotchi/web/public/app.js +80 -13
  30. data/lib/samagotchi/web/public/ctx.js +27 -2
  31. data/lib/samagotchi/web/public/data.js +6 -3
  32. data/lib/samagotchi/web/public/index.html +12 -0
  33. data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
  34. data/lib/samagotchi/web/public/model_pick.js +3 -1
  35. data/lib/samagotchi/web/public/model_picker.js +2 -0
  36. data/lib/samagotchi/web/public/session_state.js +2 -0
  37. data/lib/samagotchi/web/public/turn_events.js +34 -0
  38. data/lib/samagotchi/web/public/turn_html.js +33 -2
  39. data/lib/samagotchi/web/public/turn_view.js +4 -2
  40. metadata +2 -1
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "json"
5
5
  require "open3"
6
+ require "time"
6
7
  require "tmpdir"
7
8
 
8
9
  # github-pr: a session on a branch with an open GitHub PR gets the PR
@@ -24,15 +25,31 @@ require "tmpdir"
24
25
  # done); before_turn, before_generation and the init task fill the cache
25
26
  # off the turn's thread. `bundles: github-pr: line_links: false` turns it
26
27
  # off.
28
+ #
29
+ # `bundles: github-pr: auto_attach:` says what the init task does with the
30
+ # branch's open PR: `attach` (the default), `offer` (a card with Attach and
31
+ # Not here, which run /pr-attach N and /pr-decline N; nothing attached until
32
+ # a click; offered once per session) or `off` (nothing). In offer mode the
33
+ # offers, their outcomes and each session's first prompt go to
34
+ # <data_dir>/offers.ndjson (OffersLog), to find heuristics later.
27
35
  class Plugin
28
36
  # gh and git each get this long (a network hang must not keep a thread).
29
37
  COMMAND_SECONDS = 20
30
38
  # before_generation looks for a newly attached PR at most this often.
31
39
  GENERATION_CHECK_SECONDS = 30
40
+ # auto_attach's values (YAML's bare true/false: attach/off).
41
+ AUTO_ATTACH_MODES = { "attach" => :attach, "offer" => :offer, "off" => :off, true => :attach, false => :off }.freeze
42
+ # /pr-attach and /pr-decline take a PR number (`42` or `#42`).
43
+ PR_NUMBER = /\A#?(\d+)\z/
44
+ # The offer card's line under the PR's title.
45
+ OFFER_LINE = "Not attached: the agent gets nothing until you attach it."
46
+ # first_prompt keeps this much of the prompt.
47
+ FIRST_PROMPT_CHARS = 160
32
48
 
33
49
  def initialize
34
50
  @cache = PrCache.new
35
51
  @generation_checked_at = nil
52
+ @warned_mode = false
36
53
  end
37
54
 
38
55
  def register(chi)
@@ -48,6 +65,13 @@ class Plugin
48
65
  # The prompt too: messages is the history before this turn, and a
49
66
  # delegate child's task is its first prompt.
50
67
  kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
68
+ log_first_prompt(event, ctx)
69
+ end
70
+ chi.command("/pr-attach", "Attach the PR this session was offered: /pr-attach 42", anytime: true) do |args, ctx|
71
+ offer_command("/pr-attach", args, ctx) { |number, offer| attach_offered(ctx, number, offer) }
72
+ end
73
+ chi.command("/pr-decline", "Don't attach the PR this session was offered: /pr-decline 42", anytime: true) do |args, ctx|
74
+ offer_command("/pr-decline", args, ctx) { |number, offer| decline_offered(ctx, number, offer) }
51
75
  end
52
76
  chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
53
77
  # After source-links (90): its links are in the display this one gets.
@@ -60,15 +84,19 @@ class Plugin
60
84
  return "skipped: a delegate child" if ctx.delegate?
61
85
  return "no session yet" unless ctx.session_id
62
86
 
87
+ mode = auto_attach_mode(ctx)
88
+ return "auto_attach: off" if mode == :off
89
+
63
90
  branch = run(ctx, "git", "branch", "--show-current").to_s.strip
64
91
  return "not on a branch" if branch.empty?
65
92
 
66
- json = run(ctx, "gh", "pr", "view", "--json", "number,url,state") or return "no pull request for #{branch}"
93
+ json = run(ctx, "gh", "pr", "view", "--json", "number,url,state,title") or return "no pull request for #{branch}"
67
94
  pr = JSON.parse(json)
68
95
  return "pull request ##{pr["number"]} isn't open" unless pr["state"] == "OPEN"
69
96
 
70
97
  name = "pr-#{pr["number"]}"
71
98
  return "#{name} is attached already" if ctx.context.list.any? { |source| source[:name] == name || source[:hint] == pr["url"] }
99
+ return offer_branch_pr(ctx, branch, pr, name) if mode == :offer
72
100
 
73
101
  attached = ctx.context.attach(url: pr["url"], name: name, why: "branch #{branch} has open PR ##{pr["number"]}")
74
102
  attached ? "attached #{name}" : "#{name} was removed from this session; not attached again"
@@ -77,6 +105,21 @@ class Plugin
77
105
  "no pull request attached"
78
106
  end
79
107
 
108
+ # `bundles: github-pr: auto_attach:`, read at each worker start.
109
+ # @return [Symbol] :attach, :offer or :off; an unknown value is :attach
110
+ # (logged once)
111
+ def auto_attach_mode(ctx)
112
+ value = ctx.settings["auto_attach"]
113
+ return :attach if value.nil?
114
+
115
+ key = value.is_a?(String) ? value.strip.downcase : value
116
+ AUTO_ATTACH_MODES.fetch(key) do
117
+ ctx.log.warn(:pr_auto_attach_unknown, value: value.to_s[0, 40]) unless @warned_mode
118
+ @warned_mode = true
119
+ :attach
120
+ end
121
+ end
122
+
80
123
  # The after_turn handler: links the answer's `path:line` refs from the
81
124
  # PRs' cached data. Never runs gh: nothing cached yet links nothing.
82
125
  def link_answer(event, ctx)
@@ -109,6 +152,106 @@ class Plugin
109
152
 
110
153
  def line_links?(ctx) = ctx.settings["line_links"] != false
111
154
 
155
+ def offer_card_id(number) = "github-pr-offer-#{number}"
156
+
157
+ # Offer mode: a card instead of the attach, once per session per PR (a
158
+ # worker restart finds the offered marker). The marker is written after
159
+ # the card, so a card that failed leaves none.
160
+ def offer_branch_pr(ctx, branch, pr, name)
161
+ return "#{name} was removed from this session; not offered" if ctx.context.declined?(name, url: pr["url"])
162
+ return "#{name} was offered already" if ctx.context.offered(name)
163
+
164
+ number = pr["number"]
165
+ body = [pr["title"].to_s.gsub(/\s+/, " ").strip, OFFER_LINE].reject(&:empty?).join("\n\n")
166
+ ctx.card(id: offer_card_id(number), title: "PR ##{number} for branch #{branch}", body: body, level: :info,
167
+ actions: [{ label: "Attach", command: "/pr-attach #{number}" },
168
+ { label: "Not here", command: "/pr-decline #{number}" }])
169
+ ctx.context.mark_offered(name, pr["url"], why: "branch #{branch} has open PR ##{number}")
170
+ offers_log(ctx).write("offered", session: ctx.session_id, project_root: project_root(ctx), cwd: ctx.cwd,
171
+ branch: branch, pr: pr["url"], checkout: checkout_kind(ctx), model: ctx.model)
172
+ "offered #{name}"
173
+ end
174
+
175
+ # /pr-attach and /pr-decline: the PR number and its offer, else the
176
+ # answer (a usage line, no offer).
177
+ def offer_command(command, args, ctx)
178
+ number = args.to_s.strip[PR_NUMBER, 1] or return "usage: #{command} <PR number>, as the offer card names it"
179
+ number = number.to_i
180
+ offer = ctx.context.offered("pr-#{number}")
181
+ return "no offered PR ##{number} here; use `chi context add <PR URL>`" unless offer
182
+
183
+ yield number, offer
184
+ rescue Samagotchi::Plugin::AttachedContext::Error => e
185
+ "pr-#{number} not changed: #{e.message}"
186
+ end
187
+
188
+ # The user's click: attached as auto-attach would, past a decline, with
189
+ # the offer's why (no git or gh here).
190
+ def attach_offered(ctx, number, offer)
191
+ why = "#{offer.why || "open PR ##{number}"} (attached from the offer)"
192
+ attached = ctx.context.attach(url: offer.hint, name: offer.name, why: why, force: true)
193
+ return "#{offer.name} wasn't attached" unless attached
194
+
195
+ ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Attached #{attached}")
196
+ offers_log(ctx).write("attached", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
197
+ nil
198
+ end
199
+
200
+ # Not for a PR attached here already (a stale card): that is
201
+ # `chi context rm`'s, and a declined marker would leave it attached.
202
+ def decline_offered(ctx, number, offer)
203
+ attached = ctx.context.list.find { |source| source[:name] == offer.name || source[:hint] == offer.hint }
204
+ return "#{attached[:name]} is attached here; `chi context rm #{attached[:name]}` removes it" if attached
205
+
206
+ ctx.context.decline(url: offer.hint, name: offer.name)
207
+ ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Not attached here; + URL attaches it")
208
+ offers_log(ctx).write("declined", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
209
+ nil
210
+ end
211
+
212
+ def seconds_since(offer)
213
+ (Time.now - Time.iso8601(offer.at.to_s)).round
214
+ rescue ArgumentError
215
+ nil
216
+ end
217
+
218
+ # Offer mode: every session's first prompt (offered or not; a web chat's
219
+ # first turn starts before the init task's gh returns), joined to the
220
+ # offers by session at analysis time. First = no user message yet (a
221
+ # context note may be in messages already).
222
+ def log_first_prompt(event, ctx)
223
+ return if event[:prompt].nil? || auto_attach_mode(ctx) != :offer
224
+ return if Array(event[:messages]).any? { |message| message_role(message) == "user" }
225
+ return if ctx.scratch? || ctx.delegate? || ctx.session_id.nil?
226
+
227
+ offers_log(ctx).write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
228
+ rescue StandardError => e
229
+ ctx.log.debug(:pr_offers_log_failed, error: e.class.name)
230
+ end
231
+
232
+ def message_role(message)
233
+ return nil unless message.is_a?(Hash)
234
+
235
+ (message.key?(:role) ? message[:role] : message["role"]).to_s
236
+ end
237
+
238
+ def offers_log(ctx) = OffersLog.new(File.join(ctx.data_dir, OffersLog::FILE), ctx.log)
239
+
240
+ def project_root(ctx)
241
+ ctx.repo_root
242
+ rescue StandardError
243
+ nil
244
+ end
245
+
246
+ # "main" for the main checkout, "worktree" for a linked one (its git dir
247
+ # isn't the common one), nil outside a repo.
248
+ def checkout_kind(ctx)
249
+ dirs = run(ctx, "git", "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").to_s.split("\n")
250
+ return nil unless dirs.size == 2
251
+
252
+ dirs[0] == dirs[1] ? "main" : "worktree"
253
+ end
254
+
112
255
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
256
 
114
257
  # #refresh on its own thread (a raw one, docs/plugins.md), when there is
@@ -191,6 +334,39 @@ class Plugin
191
334
  end
192
335
  end
193
336
 
337
+ # <data_dir>/offers.ndjson: one JSON object a line, {event:, ts:, …},
338
+ # private to the user (0600: it holds prompts). Every worker shares it, so
339
+ # each write holds offers.ndjson.lock (flock) around the size check, the
340
+ # rotation and the append; over MAX_BYTES the file becomes
341
+ # offers.ndjson.1 (one old file kept). A failure is logged at debug: the
342
+ # log never breaks an attach.
343
+ class OffersLog
344
+ FILE = "offers.ndjson"
345
+ MAX_BYTES = 1024 * 1024
346
+ LINE_MAX_BYTES = 4000
347
+ MODE = 0o600
348
+
349
+ def initialize(path, log)
350
+ @path = path
351
+ @log = log
352
+ end
353
+
354
+ def write(event, **fields)
355
+ line = "#{JSON.generate({ event: event, ts: Time.now.utc.iso8601 }.merge(fields))}\n"
356
+ return @log.debug(:pr_offers_line_too_long, event: event) if line.bytesize > LINE_MAX_BYTES
357
+
358
+ File.open("#{@path}.lock", File::RDWR | File::CREAT, MODE) do |lock|
359
+ lock.flock(File::LOCK_EX)
360
+ File.rename(@path, "#{@path}.1") if File.size?(@path).to_i > MAX_BYTES
361
+ File.open(@path, File::WRONLY | File::APPEND | File::CREAT, MODE) { |file| file.write(line) }
362
+ end
363
+ nil
364
+ rescue StandardError => e
365
+ @log.debug(:pr_offers_log_failed, error: e.class.name)
366
+ nil
367
+ end
368
+ end
369
+
194
370
  # A GitHub pull request, by its URL.
195
371
  PrRef = Data.define(:owner, :repo, :number)
196
372
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.42.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:
@@ -22,6 +22,7 @@ module Samagotchi
22
22
  # sessions/<id>/subscriptions.json {name => Subscription}; the worker writes it
23
23
  # sessions/<id>/muted/<name> a marker: this session ignores <name>
24
24
  # sessions/<id>/declined/<name> a marker (its hint inside): the user removed <name>
25
+ # sessions/<id>/offered/<name> a marker ({hint, why, at}): a plugin offered <name> (ctx.context.mark_offered)
25
26
  # The guardrails protect the root (ProtectedPaths, CHI_TEXT): a source's
26
27
  # command runs later, outside the gate.
27
28
  module ContextSources
@@ -39,6 +40,7 @@ module Samagotchi
39
40
  SUBSCRIPTIONS_FILE = "subscriptions.json"
40
41
  MUTED_DIR = "muted"
41
42
  DECLINED_DIR = "declined"
43
+ OFFERED_DIR = "offered"
42
44
 
43
45
  # A name, a command, a value the store can't take.
44
46
  class Invalid < ArgumentError; end
@@ -131,6 +133,11 @@ module Samagotchi
131
133
  # What a command printed or a push sent (the contract, ContextSources.parse_output).
132
134
  Fetched = Data.define(:text, :summary, :wake, :hint)
133
135
 
136
+ # A plugin's offer of a source to a session (ctx.context.mark_offered):
137
+ # +hint+ (often the URL), +why+ (the plugin's reason, for the attach
138
+ # that may follow; nil for none) and +at+, when it was offered (ISO 8601).
139
+ Offer = Data.define(:name, :hint, :why, :at)
140
+
134
141
  # A source as one session sees it: the definition, where it lives, and
135
142
  # +shadowed+ for a project source hidden by a session one of its name.
136
143
  Attached = Data.define(:source, :location, :shadowed) do
@@ -273,6 +280,39 @@ module Samagotchi
273
280
  AtomicFile.write(declined_path(name), "#{hint}\n")
274
281
  end
275
282
 
283
+ # Clears the declined marker of +name+ and any whose hint is +hint+
284
+ # (the user's explicit attach: a URL declined under another name).
285
+ def undecline(name, hint: nil)
286
+ FileUtils.rm_f(declined_path(name))
287
+ folder = File.join(dir, DECLINED_DIR)
288
+ return if hint.to_s.empty? || !Dir.exist?(folder)
289
+
290
+ Dir.children(folder).each do |file|
291
+ path = File.join(folder, file)
292
+ FileUtils.rm_f(path) if File.read(path).strip == hint
293
+ rescue SystemCallError
294
+ nil
295
+ end
296
+ end
297
+
298
+ def offered_path(name) = File.join(dir, OFFERED_DIR, ContextSources.check_name!(name))
299
+
300
+ # Session only: a plugin offered +name+ here (so it doesn't offer it
301
+ # again). Neither an add nor a decline clears it.
302
+ # @return [Offer]
303
+ def mark_offered(name, hint, why: nil, at: Time.now)
304
+ offer = Offer.new(name: ContextSources.check_name!(name), hint: hint, why: why, at: at.utc.iso8601)
305
+ FileUtils.mkdir_p(File.join(dir, OFFERED_DIR))
306
+ AtomicFile.write(offered_path(name), "#{JSON.generate({ "hint" => offer.hint, "why" => offer.why, "at" => offer.at }.compact)}\n")
307
+ offer
308
+ end
309
+
310
+ # @return [Offer, nil]
311
+ def offered(name)
312
+ data = ContextSources.read_json(offered_path(name))
313
+ data.is_a?(Hash) ? Offer.new(name: name, hint: data["hint"], why: data["why"], at: data["at"]) : nil
314
+ end
315
+
276
316
  # Session only: the markers and the worker's subscription file.
277
317
  def muted?(name) = File.exist?(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
278
318
 
@@ -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