samagotchi 0.100.1 → 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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +14 -10
  4. data/docs/architecture.md +10 -2
  5. data/docs/cli.md +100 -5
  6. data/docs/configuration.md +26 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -0
  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 +205 -0
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +8 -1
  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 +92 -53
  24. data/lib/samagotchi/engine.rb +153 -4
  25. data/lib/samagotchi/host_keys.rb +35 -18
  26. data/lib/samagotchi/idle_scheduler.rb +16 -1
  27. data/lib/samagotchi/llm_context_edit.rb +32 -10
  28. data/lib/samagotchi/llm_context_forget.rb +12 -3
  29. data/lib/samagotchi/llm_context_view.rb +19 -7
  30. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  31. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  32. data/lib/samagotchi/message_parts.rb +8 -3
  33. data/lib/samagotchi/plugin/context.rb +2 -2
  34. data/lib/samagotchi/prompt.rb +1 -1
  35. data/lib/samagotchi/recap_command.rb +68 -0
  36. data/lib/samagotchi/recap_setup.rb +27 -14
  37. data/lib/samagotchi/send_command.rb +14 -11
  38. data/lib/samagotchi/session.rb +19 -12
  39. data/lib/samagotchi/session_commands.rb +44 -2
  40. data/lib/samagotchi/session_config.rb +411 -0
  41. data/lib/samagotchi/session_setup.rb +9 -9
  42. data/lib/samagotchi/sessions_command.rb +6 -2
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  45. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  46. data/lib/samagotchi/terminal_ui.rb +8 -6
  47. data/lib/samagotchi/testing/bundle.rb +3 -2
  48. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  49. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  50. data/lib/samagotchi/tool_response.rb +10 -6
  51. data/lib/samagotchi/tool_runner.rb +25 -6
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +76 -11
  54. data/lib/samagotchi/web/public/app.js +35 -3
  55. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  56. data/lib/samagotchi/web/public/commands.js +8 -2
  57. data/lib/samagotchi/web/public/data.js +7 -0
  58. data/lib/samagotchi/web/public/index.html +145 -1
  59. data/lib/samagotchi/web/public/info_bar.js +16 -4
  60. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  61. data/lib/samagotchi/web/public/refs.js +10 -1
  62. data/lib/samagotchi/web/public/stage_model.js +6 -5
  63. data/lib/samagotchi/web/public/stage_view.js +34 -2
  64. data/lib/samagotchi/web/public/stream_handlers.js +10 -1
  65. data/lib/samagotchi/web/public/turn_html.js +5 -0
  66. data/lib/samagotchi/web/public/turn_view.js +16 -4
  67. data/lib/samagotchi/web/public/working_set.js +612 -0
  68. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  69. data/lib/samagotchi/web/public/working_set_view.js +541 -0
  70. data/lib/samagotchi/web/server.rb +6 -4
  71. data/lib/samagotchi/worker.rb +9 -6
  72. data/lib/samagotchi/working_set.rb +238 -0
  73. data/lib/samagotchi/working_set_command.rb +119 -0
  74. data/lib/samagotchi/working_set_steer.rb +250 -0
  75. metadata +11 -2
  76. data/lib/samagotchi/bundle_session_settings.rb +0 -251
@@ -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
@@ -7,8 +7,10 @@ require_relative "idle_target"
7
7
  require_relative "recap_store"
8
8
 
9
9
  module Samagotchi
10
- # Builds (or disables) an Engine's idle recap job from the `recap:` kwarg
11
- # and the Config registry. Init-only: the Engine keeps the job it returns.
10
+ # Builds (or disables) an Engine's idle recap job from the `recap:` kwarg,
11
+ # the session's own settings and the Config registry. The Engine builds
12
+ # one at start, and again when it binds a session whose own recap values
13
+ # differ (Engine#session=).
12
14
  module RecapSetup
13
15
  # Build (or disable) the idle recap job. On by default: with no recap
14
16
  # host or model configured it asks the session's current model on its
@@ -18,9 +20,10 @@ module Samagotchi
18
20
  # --model goes; an incomplete one warns and leaves recap off.
19
21
  #
20
22
  # Single precedence path: explicit `recap:` kwarg > Config registry
21
- # (CLI > ENV > file > default). An explicit disable (`recap: false` as
22
- # the kwarg or in the config file, `recap: {enabled: false}`, or
23
- # SAMAGOTCHI_RECAP_ENABLED=false) always wins.
23
+ # (session > CLI > ENV > file > default). An explicit disable
24
+ # (`recap: false` as the kwarg or in the config file, `recap: {enabled:
25
+ # false}`, or SAMAGOTCHI_RECAP_ENABLED=false) wins, but over a session's
26
+ # own recap.enabled (chi --set recap.enabled=true) only as the kwarg.
24
27
  #
25
28
  # @param recap [Hash, false, nil] the Engine's `recap:` kwarg
26
29
  # @param engine [Engine] the recap job's engine
@@ -28,13 +31,22 @@ module Samagotchi
28
31
  # @param session_target [#call] → the session's current model as a recap target
29
32
  # @param session_id [#call] → String, nil
30
33
  # @param state_dir [#call] → String
34
+ # @param session_config [#call] → the session's own core settings
35
+ # (SessionConfig::Settings#core), read once here
31
36
  # @return [IdleRecap, nil]
32
- def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
37
+ def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:, session_config: -> { {} })
33
38
  return nil if recap == false
34
- # The REPL and a worker pass nothing: the config is read here alone
35
- # (a scalar `recap: false` is only seen this way).
36
- return nil if recap.nil? && ConfigFile.recap_config == false
37
- return nil if Samagotchi::Config.get("recap.enabled") == false
39
+
40
+ session = session_config.call || {}
41
+ # The session's own recap.enabled replaces both checks below.
42
+ if session.key?("recap.enabled")
43
+ return nil unless session["recap.enabled"]
44
+ else
45
+ # The REPL and a worker pass nothing: the config is read here alone
46
+ # (a scalar `recap: false` is only seen this way).
47
+ return nil if recap.nil? && ConfigFile.recap_config == false
48
+ return nil if Samagotchi::Config.get("recap.enabled") == false
49
+ end
38
50
 
39
51
  # An explicit kwarg (a spec, an embedder) over the registry.
40
52
  kwarg_config = recap.is_a?(Hash) ? recap : {}
@@ -64,7 +76,7 @@ module Samagotchi
64
76
  inactivity: recap_number_setting(kwarg_config, :inactivity, "recap.inactivity", IdleRecap::DEFAULT_INACTIVITY_SECONDS, :float),
65
77
  min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
66
78
  timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
67
- sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
79
+ sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences", session: session)),
68
80
  store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call }),
69
81
  session_id: -> { session_id.call },
70
82
  on_usage: ->(usage) { engine.note_side_usage(usage) }
@@ -108,9 +120,10 @@ module Samagotchi
108
120
  end
109
121
  private_class_method :recap_sentences
110
122
 
111
- # Read a scalar recap setting via the Config registry (ENV > file > default).
112
- def self.registry_string(key)
113
- value = Samagotchi::Config.get(key)
123
+ # Read a scalar recap setting via the Config registry (session > CLI >
124
+ # ENV > file > default).
125
+ def self.registry_string(key, session: nil)
126
+ value = Samagotchi::Config.get(key, session: session)
114
127
  value = value.to_s.strip
115
128
  value.empty? ? nil : value
116
129
  rescue StandardError
@@ -8,7 +8,7 @@ require_relative "session_commands"
8
8
  require_relative "parent_answers_command"
9
9
  require_relative "session_inbox"
10
10
  require_relative "session_manager"
11
- require_relative "bundle_session_settings"
11
+ require_relative "session_config"
12
12
  require_relative "session_chain"
13
13
  require_relative "context_quote"
14
14
  require_relative "reply_wait"
@@ -46,7 +46,7 @@ module Samagotchi
46
46
 
47
47
  USAGE = <<~TEXT
48
48
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
49
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set BUNDLE.KEY=VALUE]... [--parent-answers LEVEL] [-m TEXT] [--image PATH]...
49
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [--set KEY=VALUE]... [--parent-answers LEVEL] [-m TEXT] [--image PATH]...
50
50
  chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
51
51
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
52
52
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
@@ -92,12 +92,15 @@ module Samagotchi
92
92
  (full, lean, child, a config or bundle one; default
93
93
  config's toolkit.default), and tools declared on top
94
94
  of it or left out (repeatable, a comma list too)
95
- --set BUNDLE.KEY=VALUE
96
- (--new) a bundle setting for this session, over
97
- config.yml's bundles: (repeatable; a key its bundle's
98
- manifest lists in session_settings:; numbers (3,
99
- .5) and booleans (true, off, no) are typed, the
100
- rest is text: --set cost-cap.cap=3)
95
+ --set KEY=VALUE
96
+ (--new) a setting for this session, as chi --set:
97
+ a core key a session may set (recap.enabled,
98
+ recap.sentences), typed by it, or BUNDLE.KEY over
99
+ config.yml's bundles: (a key its bundle's manifest
100
+ lists in session_settings:; numbers (3, .5) and
101
+ booleans (true, off, no) are typed, the rest is
102
+ text); repeatable: --set recap.enabled=false,
103
+ --set cost-cap.cap=3
101
104
  --parent-answers LEVEL
102
105
  (--new) what a parent agent may answer on its
103
106
  questions: hard, standard or soft, only stricter than
@@ -372,8 +375,8 @@ module Samagotchi
372
375
  toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
373
376
  options[:toolkit_value] = toolkit if toolkit
374
377
  begin
375
- options[:bundle_settings] = BundleSessionSettings.from_flags(options[:sets]) unless Array(options[:sets]).empty?
376
- rescue BundleSessionSettings::Error => e
378
+ options[:settings] = SessionConfig.from_flags(options[:sets]) unless Array(options[:sets]).empty?
379
+ rescue SessionConfig::Error => e
377
380
  return usage_error(e.message)
378
381
  end
379
382
 
@@ -426,7 +429,7 @@ module Samagotchi
426
429
  setup: SessionSetup.new(llm_context: options[:llm_context],
427
430
  thinking: options[:thinking],
428
431
  toolkit: options[:toolkit_value],
429
- bundle_settings: options[:bundle_settings] || {},
432
+ settings: options[:settings],
430
433
  parent_answers: options[:parent_answers]),
431
434
  state_dir: @state_dir)
432
435
  end
@@ -213,16 +213,19 @@ module Samagotchi
213
213
  @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
214
214
  end
215
215
 
216
- # The session's own bundle settings (chi --set BUNDLE.KEY=VALUE):
217
- # {bundle => {key => value}}, frozen, {} for none and for a session from
218
- # before them. Given when it was created and never changed; a respawn,
219
- # a continue and a fork keep them (SessionSetup.of), a delegate child
220
- # doesn't. Plugin::Context#settings lays them over config.yml's.
221
- attr_reader :bundle_settings
216
+ # The session's own settings (chi --set), a SessionConfig::Settings:
217
+ # empty for none and for a session from before them. Its core part
218
+ # ({key => value}: recap.enabled) is the file's config_settings, its
219
+ # bundles part ({bundle => {key => value}}) the file's bundle_settings.
220
+ # Given when it was created; a respawn, a continue and a fork keep them
221
+ # (SessionSetup.of), a delegate child doesn't. Plugin::Context#settings
222
+ # lays the bundles part over config.yml's; the core part goes first in
223
+ # Config.get(key, session:) (RecapSetup).
224
+ attr_reader :settings
222
225
 
223
- # @param value [Hash, nil] the session file's form too
224
- def bundle_settings=(value)
225
- @bundle_settings = BundleSessionSettings.normalize(value)
226
+ # @param value [SessionConfig::Settings, nil]
227
+ def settings=(value)
228
+ @settings = SessionConfig::Settings.of(value)
226
229
  end
227
230
 
228
231
  # The session's own guardrails.parent_answers (hard|standard|soft), nil
@@ -280,7 +283,7 @@ module Samagotchi
280
283
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
281
284
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
282
285
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
283
- toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil, title: nil)
286
+ toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil)
284
287
  @id = id
285
288
  @metadata_version = metadata_version
286
289
  @mode = mode
@@ -311,7 +314,7 @@ module Samagotchi
311
314
  self.reasoning_history = reasoning_history
312
315
  self.appended_prompt_files = appended_prompt_files
313
316
  self.toolkit = toolkit
314
- self.bundle_settings = bundle_settings
317
+ self.settings = settings
315
318
  self.parent_answers = parent_answers
316
319
  self.interrupted_turn = interrupted_turn
317
320
  @turn_checkpoint = nil
@@ -410,7 +413,7 @@ module Samagotchi
410
413
  reasoning_history: setup.reasoning_history,
411
414
  appended_prompt_files: setup.appended_prompt_files,
412
415
  toolkit: setup.toolkit,
413
- bundle_settings: setup.bundle_settings,
416
+ settings: setup.settings,
414
417
  parent_answers: setup.parent_answers,
415
418
  continues: continues
416
419
  )
@@ -479,6 +482,7 @@ module Samagotchi
479
482
  "appended_prompt_files" => [],
480
483
  "toolkit" => nil,
481
484
  "bundle_settings" => {},
485
+ "config_settings" => {},
482
486
  "interrupted_turn" => nil,
483
487
  "parent_answers" => nil
484
488
  }.freeze
@@ -490,6 +494,7 @@ module Samagotchi
490
494
  attrs = FIELDS.to_h do |key, default|
491
495
  [key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
492
496
  end
497
+ attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
493
498
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
494
499
  new(**attrs)
495
500
  end
@@ -618,6 +623,8 @@ module Samagotchi
618
623
  when "thinking" then thinking_file
619
624
  when "reasoning_history" then reasoning_history_file
620
625
  when "toolkit" then @toolkit&.to_file
626
+ when "bundle_settings" then @settings.bundles
627
+ when "config_settings" then @settings.core
621
628
  when "prompt_notes" then @prompt_notes.map(&:to_file)
622
629
  when "last_turn" then @last_turn&.to_file
623
630
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"