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
@@ -27,27 +27,71 @@ module Samagotchi
27
27
  # +name+ and +cmd+ (+hint+, +every_seconds+). A source of that name
28
28
  # already attached stays as it is. One the user removed from the
29
29
  # session (its name, or its URL) isn't attached again (#declined?)
30
- # until the user adds it again.
30
+ # until the user adds it again, or +force+: the user's own explicit
31
+ # choice (a click), which clears the declined marker (by the name,
32
+ # and by the URL).
31
33
  # @return [String, nil] the source's name; nil when declined
32
34
  # @raise [Error]
33
- def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil)
34
- own = location or raise Error, "this session has no id yet"
35
+ def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false)
36
+ own = own_location!
35
37
  source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
36
- return nil if own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
38
+ if force
39
+ own.undecline(source.name, hint: url && source.hint)
40
+ elsif own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
41
+ return nil
42
+ end
37
43
  return source.name if own.source(source.name)
38
44
 
39
- own.add(source.with(scope: own.scope))
40
- source.name
45
+ add(own, source)
41
46
  rescue ContextSources::Invalid, ContextProviders::Invalid => e
42
47
  raise Error, e.message
43
48
  end
44
49
 
45
- # Whether the user removed +name+ from this session (so #attach skips it).
46
- def declined?(name)
47
- own = location
48
- own ? own.declined?(name) : false
50
+ # Marks +name+ (or +url+'s source, through its provider) declined in
51
+ # this session, as the user's removal does: #attach skips it until the
52
+ # user adds it again. An attached source stays (chi context rm).
53
+ # @return [String] the name
54
+ # @raise [Error]
55
+ def decline(url: nil, name: nil)
56
+ own = own_location!
57
+ if url
58
+ resolved = ContextProviders.resolve(url) or raise Error, "no installed bundle resolves #{url}"
59
+ name = name ? ContextSources.check_name!(name) : resolved.name
60
+ hint = resolved.hint
61
+ else
62
+ raise Error, "give url: or name:" if name.to_s.empty?
63
+
64
+ name = ContextSources.check_name!(name)
65
+ end
66
+ own.decline(name, hint)
67
+ name
68
+ rescue ContextSources::Invalid, ContextProviders::Invalid => e
69
+ raise Error, e.message
70
+ end
71
+
72
+ # Whether the user removed +name+ (or +url+) from this session (so
73
+ # #attach skips it).
74
+ def declined?(name = nil, url: nil)
75
+ own = location or return false
76
+ name_declined?(own, name) || (!url.to_s.empty? && own.declined_hints.include?(url))
77
+ end
78
+
79
+ # Records that this plugin offered +name+ (+hint+: its URL; +why+: the
80
+ # reason an attach from the offer gives) here, so a worker restart
81
+ # doesn't offer it again.
82
+ # @return [ContextSources::Offer]
83
+ # @raise [Error]
84
+ def mark_offered(name, hint, why: nil)
85
+ own_location!.mark_offered(name, hint, why: why)
86
+ rescue ContextSources::Invalid => e
87
+ raise Error, e.message
88
+ end
89
+
90
+ # @return [ContextSources::Offer, nil] the offer #mark_offered recorded
91
+ def offered(name)
92
+ location&.offered(name)
49
93
  rescue ContextSources::Invalid
50
- false
94
+ nil
51
95
  end
52
96
 
53
97
  # This session's sources (its own, then its project's).
@@ -69,6 +113,26 @@ module Samagotchi
69
113
 
70
114
  def state_dir = @host.state_dir&.call || Session.default_state_dir
71
115
 
116
+ # Two attaches at once (a double click): the one that lost the race
117
+ # finds the source there and returns its name too.
118
+ def add(own, source)
119
+ own.add(source.with(scope: own.scope))
120
+ source.name
121
+ rescue ContextSources::Invalid
122
+ raise unless own.source(source.name)
123
+
124
+ source.name
125
+ end
126
+
127
+ # An invalid name isn't declined (and doesn't hide the URL's check).
128
+ def name_declined?(own, name)
129
+ !name.nil? && own.declined?(name)
130
+ rescue ContextSources::Invalid
131
+ false
132
+ end
133
+
134
+ def own_location! = location || raise(Error, "this session has no id yet")
135
+
72
136
  def location
73
137
  id = @host.session_id.call
74
138
  id && ContextSources.session_location(id, state_dir: state_dir)
@@ -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
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "tools/args"
5
5
  require_relative "llm_context_strategy"
6
+ require_relative "tools/memory"
6
7
 
7
8
  module Samagotchi
8
9
  # Tool-declaration constants, protocol constants, and guidance text.
@@ -146,7 +147,7 @@ module Samagotchi
146
147
  },
147
148
  description: {
148
149
  type: "string",
149
- description: "Optional one-line description (at most 200 characters) appended to the managed index line; without content, it changes only that line"
150
+ description: "Optional one-line description (at most #{Tools::MemoryWrite::DESCRIPTION_LIMIT} characters) appended to the managed index line; without content, it changes only that line"
150
151
  },
151
152
  current_model_only: {
152
153
  type: "boolean",
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.42.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
@@ -20,6 +20,7 @@ require_relative "../session_metrics"
20
20
  require_relative "../session_commands"
21
21
  require_relative "../steer"
22
22
  require_relative "../host_registry"
23
+ require_relative "../llm_context_override"
23
24
  require_relative "../model_catalog"
24
25
  require_relative "../model_profile"
25
26
  require_relative "../sampling_settings"
@@ -458,7 +459,8 @@ module Samagotchi
458
459
  end
459
460
 
460
461
  # Each model's configured sampling as /model words it ("temperature=0.6
461
- # (hosts.work)"), on the models that have some: the picker's tooltip.
462
+ # (hosts.work)"), on the models that have some: the picker's tooltip;
463
+ # and each model's LLM context (LLMContextStrategy::Explained#summary).
462
464
  # A config that can't be read leaves the list as it is.
463
465
  def add_sampling(models, registry)
464
466
  return if registry.nil? || models.empty?
@@ -466,14 +468,23 @@ module Samagotchi
466
468
  settings = ConfigFile.model_settings
467
469
  models.each do |model|
468
470
  target = registry.resolve(model[:name])
469
- summary = SamplingSettings.summary(target, names: registry.lookup_names(model[:name], target: target),
470
- models: settings)
471
+ names = registry.lookup_names(model[:name], target: target)
472
+ summary = SamplingSettings.summary(target, names: names, models: settings)
471
473
  model[:sampling] = summary if summary
474
+ add_llm_context(model, target, names, settings)
472
475
  end
473
476
  rescue StandardError
474
477
  nil
475
478
  end
476
479
 
480
+ # The LLM context +model+ starts under (the start page's llm ctx chip);
481
+ # one that can't be read leaves this model without, not the others.
482
+ def add_llm_context(model, target, names, settings)
483
+ model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
484
+ rescue StandardError
485
+ nil
486
+ end
487
+
477
488
  # A session's LLM context strategy, apply rule and budget, each with
478
489
  # where it came from (LLMContextStrategy::Explained#summary), as its
479
490
  # worker would resolve them; nil when they can't be.
@@ -563,6 +574,12 @@ module Samagotchi
563
574
  dir, error = scope_dir(body["dir"])
564
575
  return error if error
565
576
 
577
+ # llm_context: the session's own LLM context values before its first
578
+ # turn (the start page's llm ctx chip; chi --llm-context), read
579
+ # before anything spawns so a bad one spawns nothing.
580
+ llm_context, error = request_llm_context(body["llm_context"])
581
+ return error if error
582
+
566
583
  folder = dir ? { working_directory: dir } : {}
567
584
  # model: the model the session starts on (the start page's picker,
568
585
  # spelled as GET /api/models lists it); blank means the default.
@@ -573,6 +590,7 @@ module Samagotchi
573
590
  # until that turn is saved.
574
591
  preview = idle ? body["preview"].to_s.strip : ""
575
592
  folder[:title] = preview unless preview.empty?
593
+ folder[:llm_context] = llm_context if llm_context
576
594
  begin
577
595
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
578
596
  rescue ModelProfile::MissingModel => e
@@ -588,6 +606,25 @@ module Samagotchi
588
606
  json_response(201, session_to_json(session).merge(bridge_port: port))
589
607
  end
590
608
 
609
+ # The create request's "llm_context" ({strategy:, apply:, budget:},
610
+ # each a word as /llm-context takes it, "default" for unset) as an
611
+ # override: [override, nil] (nil when absent or all default), or
612
+ # [nil, a 400 response] for anything else.
613
+ def request_llm_context(raw)
614
+ return [nil, nil] if raw.nil?
615
+
616
+ fields = LLMContextOverride::COMMAND_WORDS.invert
617
+ unless raw.is_a?(Hash) && raw.all? { |key, word| fields.key?(key) && word.is_a?(String) }
618
+ return [nil, error_response(400, "invalid_llm_context",
619
+ "llm_context is an object of #{fields.keys.join(", ")} words")]
620
+ end
621
+
622
+ override = LLMContextOverride.update(nil, raw.to_h { |key, word| [fields.fetch(key), word] })
623
+ [override.empty? ? nil : override, nil]
624
+ rescue ArgumentError => e
625
+ [nil, error_response(400, "invalid_llm_context", e.message)]
626
+ end
627
+
591
628
  def handle_show(req, id)
592
629
  # The page's light re-reads: never the whole conversation.
593
630
  return handle_cards_read(id) if req.params["cards"] == "1"
@@ -1621,6 +1658,8 @@ module Samagotchi
1621
1658
  def messages_for_display(msgs, parts: false, cwd: nil)
1622
1659
  filtered = []
1623
1660
  list = Array(msgs)
1661
+ # The outputs' ✂ marks (LLM context edits), by id, for the parts.
1662
+ marks = parts ? MessageParts.edit_marks(list) : {}
1624
1663
  list.each_with_index do |m, index|
1625
1664
  role = (m[:role] || m["role"]).to_s
1626
1665
  content = (m[:content] || m["content"]).to_s
@@ -1652,7 +1691,7 @@ module Samagotchi
1652
1691
  next if role == "tool_response"
1653
1692
 
1654
1693
  stripped = Samagotchi::OutputFormatter.strip_markup(content)
1655
- 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
1656
1695
  next if stripped.empty? && did.nil?
1657
1696
 
1658
1697
  norm_role = role == "model" ? "assistant" : role
@@ -1720,10 +1759,11 @@ module Samagotchi
1720
1759
  end
1721
1760
 
1722
1761
  # The parts of the assistant message at +index+, with the tool_response
1723
- # messages right after it.
1724
- 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 = {})
1725
1765
  responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
1726
- MessageParts.for_message(list[index], responses, cwd: cwd)
1766
+ MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
1727
1767
  end
1728
1768
 
1729
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