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
@@ -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"
@@ -16,6 +16,8 @@ require_relative "toolkit_command"
16
16
  require_relative "parent_answers_command"
17
17
  require_relative "title_command"
18
18
  require_relative "reasoning_history_command"
19
+ require_relative "working_set_command"
20
+ require_relative "recap_command"
19
21
  require_relative "guardrails/parent_policy"
20
22
 
21
23
  module Samagotchi
@@ -40,6 +42,8 @@ module Samagotchi
40
42
  PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
41
43
  TITLE_COMMAND = TitleCommand::NAME
42
44
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
45
+ WORKING_SET_COMMAND = WorkingSetCommand::NAME
46
+ RECAP_COMMAND = RecapCommand::NAME
43
47
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
44
48
  # shows this many per host; /models <text> lists every match.
45
49
  MODELS_PER_HOST = 20
@@ -109,6 +113,11 @@ module Samagotchi
109
113
  registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
110
114
  id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
111
115
  match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
116
+ # The list reads only: mid-turn too; a drop waits for the turn's end
117
+ # (the next turn's first request is the first with it).
118
+ registry.register(WORKING_SET_COMMAND, "list the working set; drop or undrop an item (drop KEY, undrop KEY)",
119
+ id: :working_set, mid_turn: show_mid_turn(WORKING_SET_COMMAND),
120
+ match: ->(text) { text.match?(%r{\A/working-set(?:\s+.*)?\z}) }) { |text| working_set(text) }
112
121
  # Tighten-only from anyone; loosening is a user's at a UI (#run's user_ui:).
113
122
  registry.register(PARENT_ANSWERS_COMMAND, "show or set what a parent agent may answer here (hard|standard|soft|default; " \
114
123
  "never looser than config.yml's)",
@@ -119,7 +128,14 @@ module Samagotchi
119
128
  id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
120
129
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
121
130
  registry.register("/stats", "show the session's stats", local: true)
122
- registry.register("/recap", "show the session's recap", local: true)
131
+ # /recap with words (on|off|default, or a slip: the usage) runs here:
132
+ # it toggles the idle job only, so it doesn't wait for a running turn.
133
+ # The bare /recap shows the recap: the UIs' own, under the same name
134
+ # (Registry#register); /help lists the name once, with this entry's
135
+ # description.
136
+ registry.register(RECAP_COMMAND, "show the session's recap (in a terminal); on|off|default sets this session's own",
137
+ id: :recap_setting, anytime: true, match: ->(text) { text.match?(%r{\A/recap\s+\S}) }) { |text| recap_setting(text) }
138
+ registry.register(RECAP_COMMAND, "show the session's recap", local: true, match: ->(text) { text == RECAP_COMMAND })
123
139
  # Bare `exit` too; any case; --delete after it.
124
140
  registry.register("/exit", "leave (--delete also deletes the session)", local: true,
125
141
  match: exit_match("/?exit"))
@@ -230,7 +246,9 @@ module Samagotchi
230
246
  # own (marked by UI), each group by name.
231
247
  # @return [String]
232
248
  def help_listing
233
- entries = @registry.entries.sort_by { |entry| [help_group(entry), entry.name] }
249
+ # A name with a UI's entry and the session's (/recap): listed once,
250
+ # as the session's (registered first).
251
+ entries = @registry.entries.uniq(&:name).sort_by { |entry| [help_group(entry), entry.name] }
234
252
  width = entries.map { |entry| self.class.display_name(entry).length }.max
235
253
  lines = entries.map do |entry|
236
254
  notes = []
@@ -527,6 +545,22 @@ module Samagotchi
527
545
  reply(output, status: status, changed: changed ? [:parent_answers] : [])
528
546
  end
529
547
 
548
+ # /working-set (WorkingSetCommand): an op changes the conversation
549
+ # (changed :messages: the worker saves it, the web reads it again), so
550
+ # a rollback past it would drop it silently (as !cmd), and the working
551
+ # set goes out again (held after the command's command_ran).
552
+ def working_set(text)
553
+ output, changed, status = WorkingSetCommand.new(engine: @engine, save: ->(_session) { save_session })
554
+ .run(text.delete_prefix(WORKING_SET_COMMAND))
555
+ if changed
556
+ @turn_flow.note_conversation_changed
557
+ @engine.announce_working_set
558
+ end
559
+ reply(output, status: status, changed: changed ? [:messages] : [])
560
+ rescue ArgumentError => e
561
+ reply(e.message, status: :error)
562
+ end
563
+
530
564
  # /title (TitleCommand); :title in changed when it named the session or
531
565
  # cleared its name. A plain save: no setting moved, and it may run on
532
566
  # an anytime thread beside a turn.
@@ -535,6 +569,14 @@ module Samagotchi
535
569
  reply(output, status: status, changed: changed ? [:title] : [])
536
570
  end
537
571
 
572
+ # /recap on|off|default (RecapCommand); :recap in changed when this
573
+ # session's own recap.enabled moved. It doesn't save: its host does on
574
+ # a changed result (Worker#run_command, the REPL).
575
+ def recap_setting(text)
576
+ output, changed, status = RecapCommand.new(engine: @engine).run(text.delete_prefix(RECAP_COMMAND))
577
+ reply(output, status: status, changed: changed ? [:recap] : [])
578
+ end
579
+
538
580
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
539
581
  # saved with its model.
540
582
  def save_setup(session)