samagotchi 0.101.0 → 0.102.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/bin/chi +13 -10
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +47 -7
  6. data/docs/configuration.md +25 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +86 -11
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +7 -0
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +89 -53
  24. data/lib/samagotchi/engine.rb +131 -12
  25. data/lib/samagotchi/idle_scheduler.rb +16 -1
  26. data/lib/samagotchi/llm_context_edit.rb +32 -10
  27. data/lib/samagotchi/llm_context_forget.rb +12 -3
  28. data/lib/samagotchi/llm_context_view.rb +19 -7
  29. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  30. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  31. data/lib/samagotchi/message_parts.rb +8 -3
  32. data/lib/samagotchi/plugin/context.rb +2 -2
  33. data/lib/samagotchi/prompt.rb +1 -1
  34. data/lib/samagotchi/recap_command.rb +68 -0
  35. data/lib/samagotchi/recap_setup.rb +27 -14
  36. data/lib/samagotchi/send_command.rb +14 -11
  37. data/lib/samagotchi/session.rb +19 -12
  38. data/lib/samagotchi/session_commands.rb +44 -2
  39. data/lib/samagotchi/session_config.rb +411 -0
  40. data/lib/samagotchi/session_setup.rb +9 -9
  41. data/lib/samagotchi/sessions_command.rb +6 -2
  42. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  44. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  45. data/lib/samagotchi/terminal_ui.rb +8 -6
  46. data/lib/samagotchi/testing/bundle.rb +3 -2
  47. data/lib/samagotchi/version.rb +1 -1
  48. data/lib/samagotchi/web/app.rb +30 -13
  49. data/lib/samagotchi/web/public/app.js +2 -0
  50. data/lib/samagotchi/web/public/commands.js +8 -2
  51. data/lib/samagotchi/web/public/data.js +3 -2
  52. data/lib/samagotchi/web/public/index.html +20 -2
  53. data/lib/samagotchi/web/public/stream_handlers.js +4 -0
  54. data/lib/samagotchi/web/public/turn_html.js +5 -0
  55. data/lib/samagotchi/web/public/working_set.js +113 -21
  56. data/lib/samagotchi/web/public/working_set_view.js +136 -21
  57. data/lib/samagotchi/worker.rb +9 -6
  58. data/lib/samagotchi/working_set.rb +49 -11
  59. data/lib/samagotchi/working_set_command.rb +119 -0
  60. data/lib/samagotchi/working_set_steer.rb +250 -0
  61. metadata +5 -2
  62. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -107,6 +107,8 @@ module Samagotchi
107
107
 
108
108
  # What memory_write answers in a scratch session.
109
109
  SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
110
+ # The session setting /recap on|off|default writes (#recap_override=).
111
+ RECAP_ENABLED_KEY = "recap.enabled"
110
112
 
111
113
  # @param client [Client, nil] defaults to Client.new
112
114
  # @param profile [ModelProfile, Symbol, String, nil]
@@ -232,9 +234,9 @@ module Samagotchi
232
234
  # across UIs.
233
235
  @turn_state.restart_clock!
234
236
  @side_client_mutex = Mutex.new
235
- @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
236
- session_target: -> { session_model_recap_target },
237
- session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
237
+ # Kept for a rebuild under a bound session's own recap values.
238
+ @recap_kwarg = recap
239
+ @recap = build_recap
238
240
  # One shared poller for the whole idle layer (reminders + optional
239
241
  # recap). Both jobs read the activity seam above; the scheduler owns
240
242
  # the single background thread and isolates per-job failures.
@@ -786,7 +788,7 @@ module Samagotchi
786
788
  # UIs need in the event log, emitted outside any turn's stream.
787
789
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
788
790
  command_queued command_ran context_added card hook_notice guardrail_warning
789
- plugin_init_started plugin_init_finished].freeze
791
+ plugin_init_started plugin_init_finished working_set_updated].freeze
790
792
 
791
793
  # Put a transport-level event into the ordered event log. Unlike turn
792
794
  # events it reaches only persistent observers (no turn sink, no memory
@@ -807,6 +809,34 @@ module Samagotchi
807
809
  @session_observer.synchronize(&)
808
810
  end
809
811
 
812
+ # The working set's :working_set_updated, announced between turns
813
+ # (after a command changed what the next request sends), when it
814
+ # differs from the last one emitted (#emit_working_set). Under the
815
+ # event lock, so it is ordered with the change's own events.
816
+ def announce_working_set(session = @session)
817
+ return unless session
818
+
819
+ synchronize_events { emit_working_set(nil, session, session.messages) }
820
+ end
821
+
822
+ # The strategy the working set is built under now, as LLMContextView
823
+ # takes it: the running turn's, else the next turn's (#emit_working_set).
824
+ # /working-set's ops act under it.
825
+ # @return [Symbol, Array<Symbol>]
826
+ def working_set_strategy
827
+ working_set_llm_context&.strategy || LLMContextView::NONE
828
+ end
829
+
830
+ # Replace the current session's conversation, between turns (a
831
+ # /working-set op's copy: #clone_messages, then its edits).
832
+ # @param messages [Array<Hash>]
833
+ # @return [Array<Hash>] the session's messages
834
+ def replace_messages(messages)
835
+ raise ArgumentError, "no current session" unless @session
836
+
837
+ replace_session_messages(@session, messages)
838
+ end
839
+
810
840
  # Add a context note to the session's conversation, between turns: a
811
841
  # tail system message the next turn sees, announced as :context_added.
812
842
  # The array is replaced, not mutated, and the append and the event
@@ -1131,6 +1161,8 @@ module Samagotchi
1131
1161
  # price_host: the effective model's host, whose hosts.<name>.models
1132
1162
  # prices the cost estimate names. kit_prompt: the next turn's kit
1133
1163
  # prompt text ({kit:, tokens:}), only when its toolkit has one.
1164
+ # settings: the session's own settings ({"recap.enabled" => false}),
1165
+ # only when it has some.
1134
1166
  # @return [Hash] @metrics.snapshot, filled in
1135
1167
  def stats_snapshot
1136
1168
  snapshot = @metrics.snapshot
@@ -1145,6 +1177,9 @@ module Samagotchi
1145
1177
  price_host: model.host_name)
1146
1178
  kit_prompt = kit_prompt_stats
1147
1179
  snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
1180
+ # The session's own settings (chi --set), as --set names them.
1181
+ settings = @session&.settings&.flat || {}
1182
+ snapshot = snapshot.merge(settings: settings) unless settings.empty?
1148
1183
  unless snapshot.dig(:context, :window_tokens)
1149
1184
  window = current_context_window(model)
1150
1185
  if window
@@ -1722,6 +1757,8 @@ module Samagotchi
1722
1757
  @session = session
1723
1758
  # One session per REPL/worker process: its records carry this sid.
1724
1759
  Log.session_id = session.id if session&.id
1760
+ # Its own recap values (chi --set recap.enabled=false) rebuild the job.
1761
+ rebuild_recap!
1725
1762
  # A woken worker's /stats and status line count the turns before it.
1726
1763
  bind_metrics(session)
1727
1764
  @used_memories.absorb(session)
@@ -1752,6 +1789,66 @@ module Samagotchi
1752
1789
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
1753
1790
  attr_reader :recap
1754
1791
 
1792
+ # Whether a continue offer waits (the Worker's or the REPL's TurnFlow),
1793
+ # for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
1794
+ def recap_awaiting_continue=(callable)
1795
+ @recap_awaiting_continue = callable
1796
+ @recap&.awaiting_continue = callable
1797
+ end
1798
+
1799
+ # The idle recap job (RecapSetup.build) under the current session's own
1800
+ # recap values, which it records.
1801
+ # @return [IdleRecap, nil]
1802
+ def build_recap
1803
+ @recap_built_for = session_recap_values
1804
+ recap = RecapSetup.build(@recap_kwarg, engine: self, host_registry: @host_registry,
1805
+ session_target: -> { session_model_recap_target },
1806
+ session_id: -> { @session&.id }, state_dir: -> { session_state_dir },
1807
+ session_config: -> { @session&.settings&.core })
1808
+ recap.awaiting_continue = @recap_awaiting_continue if recap && @recap_awaiting_continue
1809
+ recap
1810
+ end
1811
+ private :build_recap
1812
+
1813
+ # The bound session's own recap.* values ({} for none).
1814
+ def session_recap_values
1815
+ (@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
1816
+ end
1817
+ private :session_recap_values
1818
+
1819
+ # A new recap job when the bound session's own recap values differ from
1820
+ # the ones the current job was built under (none at start): the old one
1821
+ # is dropped (an attempt in flight goes stale) and the scheduler swaps
1822
+ # it, keeping its one thread. Binding the same session again changes
1823
+ # nothing (the REPL binds it twice).
1824
+ def rebuild_recap!
1825
+ return if session_recap_values == @recap_built_for
1826
+
1827
+ old = @recap
1828
+ old&.invalidate!
1829
+ @recap = build_recap
1830
+ @idle_scheduler&.replace_job(old, @recap)
1831
+ end
1832
+
1833
+ # /recap on|off|default: the bound session's own recap.enabled (true,
1834
+ # false; nil removes it, so config.yml's applies again), and the recap
1835
+ # job rebuilt under it at once. It doesn't save: the command says
1836
+ # changed: [:recap] and its host saves (Worker#run_command).
1837
+ # @param enabled [Boolean, nil]
1838
+ # @raise [ArgumentError] no session is bound
1839
+ def recap_override=(enabled)
1840
+ raise ArgumentError, "no session" unless @session
1841
+
1842
+ core = @session.settings.core.dup
1843
+ enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
1844
+ @session.settings = @session.settings.with(core: core)
1845
+ rebuild_recap!
1846
+ end
1847
+
1848
+ # The bound session's own recap.enabled (/recap on|off, --set), or nil.
1849
+ # @return [Boolean, nil]
1850
+ def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
1851
+
1755
1852
  # Write the recap now, before the session is left (IdleRecap#write_now).
1756
1853
  # @return [String, nil] the recap written, nil when none was (recap off,
1757
1854
  # nothing new, an error, the timeout)
@@ -2850,26 +2947,48 @@ module Samagotchi
2850
2947
  # stream handler's :generation_started, from the running turn's
2851
2948
  # conversation) and at turn end (#end_turn, from the session's kept
2852
2949
  # messages). Only when it differs from the last one emitted for this
2853
- # session, and never while it is empty, so a tool-free turn emits
2854
- # none. The web overlays it on the rows' set; other UIs ignore it.
2950
+ # session, and never while it is empty — except one empty event,
2951
+ # remembered, when the last one for this session had items (a
2952
+ # rollback removed the tool calls), so a tool-free turn emits none
2953
+ # and the next empty request sends nothing more. The web overlays it
2954
+ # on the rows' set; other UIs ignore it.
2955
+ #
2956
+ # Between turns (#announce_working_set: no +on_event+) it is
2957
+ # announced, under the next turn's strategy.
2855
2958
  def emit_working_set(on_event, session, conversation)
2856
2959
  return unless session && conversation
2857
2960
 
2858
- llm_context = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
2859
- snapshot = WorkingSet.build(conversation, cwd: session.working_directory,
2860
- strategy: llm_context&.strategy || LLMContextView::NONE)
2861
- return if snapshot.empty?
2961
+ snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy)
2862
2962
 
2863
2963
  items = snapshot.items.map(&:to_h)
2864
- return if @working_set_emitted == [session.id, items]
2964
+ last = @working_set_emitted
2965
+ if items.empty?
2966
+ return unless last && last[0] == session.id && !last[1].empty?
2967
+ elsif last == [session.id, items]
2968
+ return
2969
+ end
2865
2970
 
2866
2971
  @working_set_emitted = [session.id, items]
2867
- emit_event(on_event, { type: :working_set_updated, items: items, at: snapshot.at })
2972
+ event = { type: :working_set_updated, items: items, at: snapshot.at }
2973
+ on_event ? emit_event(on_event, event) : announce_or_hold(event)
2868
2974
  rescue StandardError => e
2869
2975
  Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2870
2976
  end
2871
2977
  private :emit_working_set
2872
2978
 
2979
+ # The strategy the set is built under: the running turn's
2980
+ # (TurnSettings#llm_context: what its requests send); between turns
2981
+ # the next turn's (as #warm_up_next_turn: the turn's settings keep the
2982
+ # last turn's, stale after a /llm-context or /model change), the last
2983
+ # turn's when it can't be resolved.
2984
+ def working_set_llm_context
2985
+ turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
2986
+ return turn if turn_running?
2987
+
2988
+ @model_settings.turn_llm_context || turn
2989
+ end
2990
+ private :working_set_llm_context
2991
+
2873
2992
  # A :generation_completed with the generation's decode speed (`speed:`
2874
2993
  # {decode_tps:, source:}, nil without one), the session's running
2875
2994
  # token totals (`tokens:`, the snapshot's block) and its per-model rows
@@ -25,7 +25,7 @@ module Samagotchi
25
25
  raise ArgumentError, "IdleScheduler requires an engine" unless engine
26
26
 
27
27
  @engine = engine
28
- @jobs = jobs.compact
28
+ @jobs = jobs.compact.freeze
29
29
  @thread = nil
30
30
  @stopped = false
31
31
  end
@@ -49,6 +49,21 @@ module Samagotchi
49
49
  @thread = nil
50
50
  end
51
51
 
52
+ # Swap the job +old+ for +new+ (Engine#session= rebuilding the recap):
53
+ # nil +old+ adds +new+, nil +new+ drops +old+. The list is replaced
54
+ # whole, so a tick on the thread steps the old list or the new one; a
55
+ # running scheduler keeps its one thread, and one with no jobs yet
56
+ # starts it once #start is called.
57
+ def replace_job(old, new)
58
+ jobs = if @jobs.any? { |job| job.equal?(old) }
59
+ @jobs.map { |job| job.equal?(old) ? new : job }
60
+ else
61
+ @jobs + [new]
62
+ end
63
+ @jobs = jobs.compact.freeze
64
+ self
65
+ end
66
+
52
67
  # One step across all jobs. Public so specs can drive it deterministically.
53
68
  def tick
54
69
  return if @stopped
@@ -14,17 +14,21 @@ module Samagotchi
14
14
  # are replaced, never changed in place (.store), or an edit would reach
15
15
  # a checkpoint it was rolled back from.
16
16
  #
17
- # kind: :stale (chi saw a later read or edit supersede it) or :forget
18
- # (the model forgot it, its note keeping the finding); note: what the
19
- # stub says; by: who made it ("chi", the model); staged_at/applied_at:
17
+ # kind: :stale (chi saw a later read or edit supersede it), :forget
18
+ # (the model forgot it, its note keeping the finding) or :drop (the user
19
+ # dropped it from the working set: WorkingSetSteer; sent whatever the
20
+ # strategy, LLMContextView#sends?); note: what the
21
+ # stub says; by: who made it ("chi", the model, "user"); staged_at/applied_at:
20
22
  # ISO 8601 times, applied_at nil while it waits to reach the prompt;
21
23
  # keep: the output's lines a forget keeps ([[first, last], …], as the
22
24
  # model named them; saved only when there are some), sent under the
23
25
  # stub; keep_offset: what those numbers are past the output's own lines
24
26
  # (a ranged read's start_line - 1: a read's keep names file lines; saved
25
- # only when not 0).
26
- LLMContextEdit = Data.define(:id, :kind, :note, :by, :staged_at, :applied_at, :keep, :keep_offset) do
27
- def initialize(keep: [], keep_offset: 0, **fields) = super
27
+ # only when not 0); was: a drop's, the edit the output held before it
28
+ # (its saved Hash: an applied forget sent whole under none, a staged
29
+ # one, a stale stub), which an undrop puts back (one edit per id).
30
+ LLMContextEdit = Data.define(:id, :kind, :note, :by, :staged_at, :applied_at, :keep, :keep_offset, :was) do
31
+ def initialize(keep: [], keep_offset: 0, was: nil, **fields) = super
28
32
 
29
33
  def applied? = !applied_at.nil?
30
34
 
@@ -33,19 +37,27 @@ module Samagotchi
33
37
  # What the model reads instead of the output, after the output's
34
38
  # "[name]" lead.
35
39
  def stub
36
- kind == :forget ? "(forgotten) #{note}" : note.to_s
40
+ case kind
41
+ when :forget then "(forgotten) #{note}"
42
+ when :drop then LLMContextEdit::DROP_STUB
43
+ else note.to_s
44
+ end
37
45
  end
38
46
 
39
47
  # The saved form, without the id (it is the key).
40
48
  def to_h
41
49
  saved = { "kind" => kind.to_s, "note" => note, "by" => by, "staged_at" => staged_at, "applied_at" => applied_at }
42
50
  saved = saved.merge("keep" => keep) if keep?
43
- keep_offset.zero? ? saved : saved.merge("keep_offset" => keep_offset)
51
+ saved = saved.merge("keep_offset" => keep_offset) unless keep_offset.zero?
52
+ was ? saved.merge("was" => was) : saved
44
53
  end
45
54
  end
46
55
 
47
56
  class LLMContextEdit
48
- KINDS = %i[stale forget].freeze
57
+ KINDS = %i[stale forget drop].freeze
58
+ # A user's drop is sent whatever the strategy (no layer names it).
59
+ DROP = :drop
60
+ DROP_STUB = "(dropped by the user) — read it again if the task needs it"
49
61
 
50
62
  # +hash+ as saved (string or symbol keys), or nil for one of no known
51
63
  # kind.
@@ -58,7 +70,8 @@ module Samagotchi
58
70
 
59
71
  new(id: id.to_s, kind: kind, note: field.call(:note).to_s, by: field.call(:by), staged_at: field.call(:staged_at),
60
72
  applied_at: field.call(:applied_at), keep: ranges(field.call(:keep)),
61
- keep_offset: field.call(:keep_offset).is_a?(Integer) ? [field.call(:keep_offset), 0].max : 0)
73
+ keep_offset: field.call(:keep_offset).is_a?(Integer) ? [field.call(:keep_offset), 0].max : 0,
74
+ was: field.call(:was).is_a?(Hash) ? field.call(:was) : nil)
62
75
  end
63
76
 
64
77
  # Saved keep ranges as [[first, last], …]: pairs of positive Integers,
@@ -87,6 +100,15 @@ module Samagotchi
87
100
  entry
88
101
  end
89
102
 
103
+ # Whether +entry+ holds a saved drop (applied or not): the cheap check
104
+ # LLMContextView#messages makes under none.
105
+ def self.drop_on?(entry)
106
+ saved = entry[:edits]
107
+ saved.is_a?(Hash) && saved.each_value.any? do |hash|
108
+ hash.is_a?(Hash) && (hash["kind"] || hash[:kind]).to_s == "drop"
109
+ end
110
+ end
111
+
90
112
  # The edits saved on +entry+, by id.
91
113
  # @return [Hash{String => LLMContextEdit}]
92
114
  def self.on(entry)
@@ -9,6 +9,7 @@ require_relative "llm_context_stale"
9
9
  require_relative "llm_context_apply"
10
10
  require_relative "llm_context_view"
11
11
  require_relative "tools/forget_outputs"
12
+ require_relative "working_set_steer"
12
13
 
13
14
  module Samagotchi
14
15
  # The forget layer (LLMContextStrategy :forget, experimental): the
@@ -33,7 +34,9 @@ module Samagotchi
33
34
  # read (CLM's lesson);
34
35
  # - a keep that holds all of the output, an output too small for a stub
35
36
  # to free anything (SMALL), an output already stubbed or forgotten, an
36
- # id nothing in context has.
37
+ # id nothing in context has;
38
+ # - an output the user dropped (WorkingSetSteer): neither forgotten nor
39
+ # restored by the model, in WorkingSetSteer::Refused's words.
37
40
  # A read is never restored: reading the file again is its restore, and
38
41
  # the file may have changed.
39
42
  module LLMContextForget
@@ -123,7 +126,10 @@ module Samagotchi
123
126
  # the model's latest outputs.
124
127
  def refusal(conversation, output, keep, last, protect_steps, kept_reads)
125
128
  return "no output with that id in context (ids look like t42)" unless output
126
- return "already forgotten or stubbed" if LLMContextEdit.on(conversation[output.index]).key?(output.ref.id)
129
+
130
+ held = LLMContextEdit.on(conversation[output.index])[output.ref.id]
131
+ return WorkingSetSteer::Refused.text(:dropped) if held&.kind == LLMContextEdit::DROP
132
+ return "already forgotten or stubbed" if held
127
133
  if protect_steps.positive? && output.step >= last - protect_steps
128
134
  return "from your last #{protect_steps} step#{"s" unless protect_steps == 1}; you may still need it, forget it later"
129
135
  end
@@ -172,7 +178,10 @@ module Samagotchi
172
178
  ids.each do |id|
173
179
  output = outputs[id]
174
180
  edit = output && LLMContextEdit.on(conversation[output.index])[id]
175
- if edit.nil? || edit.kind != :forget
181
+ if edit&.kind == LLMContextEdit::DROP
182
+ # The user's: only the user takes it back (/working-set undrop).
183
+ refused[id] = WorkingSetSteer::Refused.text(:dropped)
184
+ elsif edit.nil? || edit.kind != :forget
176
185
  refused[id] = "not forgotten"
177
186
  elsif output.read? && edit.applied?
178
187
  refused[id] = "a read: read the file again instead (it may have changed)"
@@ -5,6 +5,7 @@ require_relative "tool_response"
5
5
  require_relative "tool_ids"
6
6
  require_relative "llm_context_edit"
7
7
  require_relative "llm_context_stale"
8
+ require_relative "llm_context_strategy"
8
9
 
9
10
  module Samagotchi
10
11
  # What the model is sent of the stored conversation: the one step
@@ -15,11 +16,14 @@ module Samagotchi
15
16
  #
16
17
  # Under +none+ (the default) nothing changes: #messages returns the
17
18
  # conversation it was given, the same Array of the same entries, so the
18
- # prompt is byte for byte what it was before the view existed.
19
+ # prompt is byte for byte what it was before the view existed. The one
20
+ # exception is the user's layer: an applied :drop (WorkingSetSteer, the
21
+ # working set's drop) is sent as its stub under every strategy, none
22
+ # too; a conversation without one is still returned as it is.
19
23
  #
20
- # Under a strategy of layers (:stale, :forget), each applied edit saved
21
- # on a tool_response entry (LLMContextEdit) whose kind is one of the
22
- # layers sends that run's output as its stub. The entry stays where it
24
+ # Under a strategy of layers (:stale, :forget), each edit saved on a
25
+ # tool_response entry (LLMContextEdit) that #sends? (applied, and its
26
+ # kind one of the layers) sends that run's output as its stub. The entry stays where it
23
27
  # is, with its role and tool_call_id, so a call keeps its response and
24
28
  # the image plan (which indexes by entry) lines up. A stub keeps the
25
29
  # output's "[name]" lead (Gemma's prompt writes response:NAME from it),
@@ -44,7 +48,10 @@ module Samagotchi
44
48
  # (re-reading the file is a read's restore).
45
49
  class LLMContextView
46
50
  NONE = :none
47
- LAYERS = LLMContextEdit::KINDS
51
+ # The strategy's layers (LLMContextStrategy::LAYERS), not the edit
52
+ # kinds (LLMContextEdit::KINDS): a kind no strategy names never
53
+ # becomes a layer, whatever a caller passes as the strategy.
54
+ LAYERS = LLMContextStrategy::LAYERS
48
55
 
49
56
  # @return [Array<Symbol>] the layers; empty under none
50
57
  attr_reader :layers
@@ -78,7 +85,7 @@ module Samagotchi
78
85
  # @param conversation [Array<Hash>] the stored entries, in order
79
86
  # @return [Array<Hash>] the entries to format
80
87
  def messages(conversation)
81
- return conversation if none?
88
+ return conversation if none? && conversation.none? { |entry| LLMContextEdit.drop_on?(entry) }
82
89
 
83
90
  group = Group.new
84
91
  group.paired = forget? ? LLMContextStale.paired_runs(conversation).to_set { |run| run.ref.id } : Set.new
@@ -87,6 +94,11 @@ module Samagotchi
87
94
 
88
95
  def forget? = layers.include?(:forget)
89
96
 
97
+ # Whether +edit+ (an LLMContextEdit) is sent: the one rule for which
98
+ # edits the view applies, applied and of one of the layers, or the
99
+ # user's drop (always on: no strategy names it).
100
+ def sends?(edit) = edit.applied? && (edit.kind == LLMContextEdit::DROP || layers.include?(edit.kind))
101
+
90
102
  # The forget whose stub carries the note the next forgotten output of
91
103
  # the same call points to: its first id and its note, author and stamp.
92
104
  # +paired+: the ids of the runs that pair with their calls.
@@ -100,7 +112,7 @@ module Samagotchi
100
112
  def edited(conversation, entry, index, group)
101
113
  return entry unless entry[:role].to_s == "tool_response"
102
114
 
103
- edits = LLMContextEdit.on(entry).select { |_id, edit| edit.applied? && layers.include?(edit.kind) }
115
+ edits = LLMContextEdit.on(entry).select { |_id, edit| sends?(edit) }
104
116
  return entry if edits.empty? && !forget?
105
117
 
106
118
  refs = ToolIds.refs_at(conversation, index)
@@ -259,6 +259,8 @@ module Samagotchi
259
259
  @source_owned = prepared.owned
260
260
  @source_commit = prepared.commit
261
261
  @manifest = Manifest.read(dir: @bundle_dir)
262
+ clash = @manifest.session_settings_clash
263
+ raise InstallError, clash if clash
262
264
  rescue SourceNormalizer::UnknownSourceError => e
263
265
  raise InstallError, "Source normalization failed: #{e.message}"
264
266
  rescue Manifest::ValidationError => e
@@ -93,6 +93,17 @@ module Samagotchi
93
93
  @session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
94
94
  end
95
95
 
96
+ # Why the install refuses this bundle's session_settings: a key whose
97
+ # NAME.KEY is chi's own session setting (SessionConfig.clash, the
98
+ # words chi bundle check reports too), nil when none clashes.
99
+ # @return [String, nil]
100
+ def session_settings_clash
101
+ return nil if @session_settings.empty?
102
+
103
+ require_relative "../session_config"
104
+ SessionConfig.clash(@name, @session_settings)
105
+ end
106
+
96
107
  # A profile: it installs the bundles in includes instead of files.
97
108
  def meta?
98
109
  !@includes.empty?
@@ -171,11 +182,11 @@ module Samagotchi
171
182
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
172
183
  end
173
184
 
174
- # A setting key a session may set (BundleSessionSettings::KEY).
185
+ # A setting key a session may set (SessionConfig::KEY).
175
186
  SESSION_SETTING = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
176
187
 
177
188
  # The types a session setting may be given (session_settings: {key:
178
- # type}), checked by BundleSessionSettings.check!: number (or off),
189
+ # type}), checked by SessionConfig.check!: number (or off),
179
190
  # boolean, string; any (or a list's key) takes every scalar.
180
191
  SESSION_SETTING_TYPES = %w[number boolean string any].freeze
181
192
 
@@ -187,6 +187,9 @@ module Samagotchi
187
187
  mark[:staged] = true unless edit.applied?
188
188
  if edit.kind == :stale
189
189
  mark[:note] = edit.note[/superseded by .*\z/] || edit.note
190
+ elsif edit.kind == LLMContextEdit::DROP
191
+ # The user's: no note, no grouping (a forget's key is its note).
192
+ mark[:by] = edit.by
190
193
  else
191
194
  key = [edit.note, edit.by, edit.staged_at]
192
195
  if first.key?(key)
@@ -201,12 +204,14 @@ module Samagotchi
201
204
  mark
202
205
  end
203
206
 
204
- # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
205
- # what it frees: its output, ~chars/4.
207
+ # +output+'s ✂ mark (+marks+ by +id+); an applied stale stub or a
208
+ # user's drop says what it frees: its output, ~chars/4.
209
+ WHOLE_STUBS = %w[stale drop].freeze
210
+
206
211
  def mark_of(id, marks, output)
207
212
  mark = marks[id]
208
213
  return nil unless mark
209
- return mark unless mark[:kind] == "stale" && !mark[:staged] && output
214
+ return mark unless WHOLE_STUBS.include?(mark[:kind]) && !mark[:staged] && output
210
215
 
211
216
  mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
212
217
  end
@@ -104,7 +104,7 @@ module Samagotchi
104
104
 
105
105
  # config.yml `bundles: <bundle>:` with the session's own values for
106
106
  # the bundle laid over it (chi --set BUNDLE.KEY=VALUE, through the
107
- # Host's setup: SessionSetup#bundle_settings), string keys, frozen.
107
+ # Host's setup: SessionSetup#settings's bundles), string keys, frozen.
108
108
  # Plugin init and chi.ctx run before the session is bound: with no
109
109
  # session id yet it is the config alone, and it is built again once
110
110
  # per session id (the session's values never change after it starts).
@@ -473,7 +473,7 @@ module Samagotchi
473
473
  # The session's own values for this bundle ({} without any; nil when
474
474
  # the Host couldn't tell them, so #settings can retry).
475
475
  def session_values(id)
476
- values = @host.setup&.call&.bundle_settings
476
+ values = @host.setup&.call&.settings&.bundles
477
477
  values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
478
478
  rescue StandardError => e
479
479
  unless (@session_settings_unread ||= {}).key?(id)
@@ -27,7 +27,7 @@ module Samagotchi
27
27
  # - Generation cue: <|im_start|>assistant\n
28
28
  module Prompt
29
29
  # Tail system messages whose text isn't the harness's own.
30
- ESCAPED_KINDS = %w[note turn_note context].freeze
30
+ ESCAPED_KINDS = %w[note turn_note context working_set].freeze
31
31
 
32
32
  # @param messages [Array<Hash>] each element has :role and :content.
33
33
  # Recognised roles: "system", "user", "model", "tool_response".
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # /recap on|off|default, the setting form of /recap (the bare /recap,
7
+ # which shows the recap, is the UIs' own): this session's own
8
+ # recap.enabled (Engine#recap_override=), the same value
9
+ # `chi --set recap.enabled=…` saves, applied at once (the idle recap job
10
+ # is rebuilt or dropped). `default` removes it, and config.yml (or env)
11
+ # decides again. The session's value beats config.yml's: `/recap on`
12
+ # turns it on under `recap: false` too. It doesn't save: its host does
13
+ # on the reply's changed: [:recap] (Worker#run_command).
14
+ class RecapCommand
15
+ NAME = "/recap"
16
+ CHOICES = { "on" => true, "off" => false, "default" => nil }.freeze
17
+ USAGE = "usage: #{NAME} [on|off|default]".freeze
18
+
19
+ # @param engine [Engine] #session, #recap_override=, #recap
20
+ def initialize(engine:)
21
+ @engine = engine
22
+ end
23
+
24
+ # @param args [String] what follows /recap
25
+ # @return [Array(String, Boolean, Symbol)] the reply, whether this
26
+ # session's recap setting changed, and :ok or :error
27
+ def run(args)
28
+ word = args.to_s.strip.downcase
29
+ return [USAGE, false, :error] unless CHOICES.key?(word)
30
+ return ["no session yet", false, :error] unless @engine.session
31
+
32
+ value = CHOICES.fetch(word)
33
+ changed = @engine.recap_override != value
34
+ @engine.recap_override = value if changed
35
+ [status_line(value), changed, :ok]
36
+ end
37
+
38
+ # "recap: off (this session; config.yml: on)", "recap: on (config.yml)".
39
+ # @param own [Boolean, nil] the session's own value, nil for none
40
+ def status_line(own)
41
+ from_config = self.class.config_status
42
+ line = if own.nil?
43
+ "recap: #{word(from_config[:on])} (#{from_config[:source]})"
44
+ else
45
+ "recap: #{word(own)} (this session; #{from_config[:source]}: #{word(from_config[:on])})"
46
+ end
47
+ # On by its own word, yet no job: a pinned recap host or model that
48
+ # doesn't resolve (warned in the log), or an embedder's recap: false.
49
+ on = own.nil? ? from_config[:on] : own
50
+ on && @engine.recap.nil? ? "#{line}; no recap is written: its host or model doesn't resolve (see the log)" : line
51
+ end
52
+
53
+ # Whether the recap is on without the session's own value: off by a
54
+ # scalar `recap: false`, `recap: {enabled: false}` or
55
+ # SAMAGOTCHI_RECAP_ENABLED=false (ConfigFile.recap_config sees all
56
+ # three), else on (the default).
57
+ # @return [Hash] {on: Boolean, source: "config.yml" or "env"}
58
+ def self.config_status
59
+ on = ConfigFile.recap_config != false
60
+ _value, origin = Config.get_with_origin("recap.enabled")
61
+ { on: on, source: origin == :env ? "env" : "config.yml" }
62
+ end
63
+
64
+ private
65
+
66
+ def word(enabled) = enabled ? "on" : "off"
67
+ end
68
+ end