samagotchi 0.131.0 → 0.133.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/bin/chi +19 -1
  4. data/docs/architecture.md +59 -1
  5. data/docs/cli.md +29 -8
  6. data/docs/configuration.md +115 -16
  7. data/docs/desktop.md +16 -6
  8. data/docs/guardrails.md +10 -4
  9. data/docs/internals/anthropic.md +3 -2
  10. data/docs/internals/engine.md +4 -4
  11. data/docs/internals/prompt-caching.md +26 -6
  12. data/docs/memory.md +16 -2
  13. data/docs/plugins.md +68 -1
  14. data/docs/sessions.md +60 -6
  15. data/docs/testing.md +1 -1
  16. data/lib/samagotchi/bootstrap_command.rb +35 -6
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
  19. data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
  20. data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
  21. data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/cache_confirm.rb +18 -0
  24. data/lib/samagotchi/chi_home.rb +311 -0
  25. data/lib/samagotchi/chi_shim.rb +77 -0
  26. data/lib/samagotchi/child_env.rb +5 -3
  27. data/lib/samagotchi/config.rb +2 -0
  28. data/lib/samagotchi/context_fetch.rb +4 -2
  29. data/lib/samagotchi/desktop/macos.rb +100 -18
  30. data/lib/samagotchi/engine.rb +35 -4
  31. data/lib/samagotchi/engine_snapshots.rb +5 -0
  32. data/lib/samagotchi/gem_update.rb +19 -10
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
  35. data/lib/samagotchi/guardrails/pair_command.rb +57 -1
  36. data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
  37. data/lib/samagotchi/guardrails/review_paths.rb +7 -4
  38. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  39. data/lib/samagotchi/home_command.rb +145 -0
  40. data/lib/samagotchi/home_doctor.rb +177 -0
  41. data/lib/samagotchi/home_notice.rb +80 -0
  42. data/lib/samagotchi/installed_versions.rb +5 -3
  43. data/lib/samagotchi/llm_context_command.rb +53 -7
  44. data/lib/samagotchi/llm_context_override.rb +3 -2
  45. data/lib/samagotchi/plugin/api.rb +45 -1
  46. data/lib/samagotchi/plugin/context.rb +34 -1
  47. data/lib/samagotchi/plugin/loader.rb +5 -2
  48. data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
  49. data/lib/samagotchi/project_env.rb +360 -0
  50. data/lib/samagotchi/run_options.rb +1 -0
  51. data/lib/samagotchi/self_report.rb +33 -0
  52. data/lib/samagotchi/session_access/contract.rb +1 -1
  53. data/lib/samagotchi/session_commands.rb +68 -19
  54. data/lib/samagotchi/session_manager.rb +54 -1
  55. data/lib/samagotchi/session_model_settings.rb +5 -2
  56. data/lib/samagotchi/system_prompt.rb +34 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  59. data/lib/samagotchi/testing/boundary.rb +3 -1
  60. data/lib/samagotchi/testing/bundle.rb +22 -3
  61. data/lib/samagotchi/testing/host.rb +22 -1
  62. data/lib/samagotchi/testing/recorder.rb +6 -1
  63. data/lib/samagotchi/testing/recording_api.rb +1 -1
  64. data/lib/samagotchi/tools/builtins.rb +13 -2
  65. data/lib/samagotchi/update_command.rb +65 -7
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +61 -1
  68. data/lib/samagotchi/web/public/app.js +4 -1
  69. data/lib/samagotchi/web/public/chip_popover.js +5 -2
  70. data/lib/samagotchi/web/public/composer.css +11 -1
  71. data/lib/samagotchi/web/public/data.js +8 -4
  72. data/lib/samagotchi/web/public/index.html +52 -10
  73. data/lib/samagotchi/web/public/level_chip.js +22 -38
  74. data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
  75. data/lib/samagotchi/web/public/model_pick.js +26 -0
  76. data/lib/samagotchi/web/public/model_picker.js +3 -1
  77. data/lib/samagotchi/web/public/review_start.js +13 -57
  78. data/lib/samagotchi/web/public/send_turn.js +3 -2
  79. data/lib/samagotchi/web/public/start_page.js +131 -99
  80. data/lib/samagotchi/web/public/start_settings.js +298 -0
  81. data/lib/samagotchi/web/public/start_toolkit.js +157 -0
  82. data/lib/samagotchi/web/public/thinking_chip.js +9 -22
  83. data/lib/samagotchi/web/server.rb +27 -8
  84. data/lib/samagotchi/web/update_check.rb +3 -2
  85. metadata +11 -1
@@ -5,6 +5,7 @@ require_relative "model_profile"
5
5
  require_relative "served_model"
6
6
  require_relative "prompt_note"
7
7
  require_relative "turn_flow"
8
+ require_relative "cache_confirm"
8
9
  require_relative "tools/execute"
9
10
  require_relative "tools/builtins"
10
11
  require_relative "tools/context_read"
@@ -61,7 +62,7 @@ module Samagotchi
61
62
 
62
63
  # Commands that may ask before a change that restarts the prompt cache
63
64
  # (CacheConfirm): #run passes them its confirm flag.
64
- CONFIRMING = %i[thinking reasoning_history toolkit].freeze
65
+ CONFIRMING = %i[model llm_context thinking reasoning_history toolkit].freeze
65
66
 
66
67
  # @!attribute status [Symbol] :ok, or :error when the line was refused
67
68
  # @!attribute output [String, nil] what to tell the user
@@ -302,7 +303,7 @@ module Samagotchi
302
303
  text = line.to_s.strip
303
304
  entry = @registry.lookup(text)
304
305
  return nil unless entry&.handler
305
- return run_bundle_command(entry, text) unless entry.source == "core"
306
+ return run_bundle_command(entry, text, confirm: confirm) unless entry.source == "core"
306
307
 
307
308
  # Only commands that may ask take the flag.
308
309
  return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
@@ -355,16 +356,32 @@ module Samagotchi
355
356
  # The resolved ref (what the session stores and --alias/--default write).
356
357
  def model_name = @engine.effective_model_ref
357
358
 
358
- # A bundle plugin's command: its handler gets the text after the name;
359
- # what it returns is the output (nil: nothing to show), and a raise is
360
- # an error result.
361
- def run_bundle_command(entry, text)
362
- output = entry.handler.call(text.delete_prefix(entry.name).strip)
363
- reply(output.nil? ? nil : output.to_s)
359
+ # A bundle plugin's command: its handler gets the text after the name
360
+ # (--yes kept); what it returns is the output (nil: nothing to show),
361
+ # or [output, CacheConfirm::Confirm or nil] for a change that asks
362
+ # first (ctx.cache_confirm), and a raise is an error result. The
363
+ # sender's +confirm+ (false once the line has --yes) reaches
364
+ # ctx.cache_confirm through the thread (CacheConfirm.asking).
365
+ def run_bundle_command(entry, text, confirm: false)
366
+ args = text.delete_prefix(entry.name).strip
367
+ _, yes = CacheConfirm.split(args)
368
+ output, ask = bundle_reply(CacheConfirm.asking(confirm && !yes) { entry.handler.call(args) })
369
+ reply(output, confirm: ask)
364
370
  rescue StandardError => e
365
371
  reply("#{entry.name}: #{e.class}: #{e.message}", status: :error)
366
372
  end
367
373
 
374
+ # A plugin command's return value as [output, confirm]: a String (or
375
+ # anything, shown as text) or nil, or [text, Confirm/nil].
376
+ def bundle_reply(value)
377
+ if value.is_a?(Array) && value.size == 2 && (value[1].nil? || value[1].is_a?(CacheConfirm::Confirm))
378
+ text, ask = value
379
+ return [text.nil? ? nil : text.to_s, ask]
380
+ end
381
+
382
+ [value.nil? ? nil : value.to_s, nil]
383
+ end
384
+
368
385
  def help_group(entry)
369
386
  return 2 if entry.local
370
387
 
@@ -393,14 +410,16 @@ module Samagotchi
393
410
  # here (as in the REPL, the next turn saves it). No parent-session
394
411
  # marker: the user runs this, not the model. A worker's `chi --model X`
395
412
  # default is unset, though: it was that run's, not the default of a chi
396
- # run here (Tools::Builtins.cli_model_unset).
413
+ # run here (Tools::Builtins.cli_model_unset). It runs in the session's
414
+ # project env (ProjectEnv), as the model's execute does.
397
415
  def shell(text)
398
416
  command = text.delete_prefix(SHELL_BANG_PREFIX).strip
399
417
  return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
400
418
 
401
419
  # Killed at the limit, never handed over: the output goes in a user
402
420
  # message, which no one reads a task id from (created_ids_in).
403
- output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset, on_timeout: :kill)
421
+ env = Samagotchi::ProjectEnv.tools_env.merge(Samagotchi::Tools::Builtins.cli_model_unset)
422
+ output = Samagotchi::Tools::Execute.call(command, env: env, on_timeout: :kill)
404
423
  @engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
405
424
  # Rolling back past this would silently drop the command output.
406
425
  @turn_flow.note_conversation_changed
@@ -583,13 +602,13 @@ module Samagotchi
583
602
 
584
603
  # /llm-context (LLMContextCommand); :llm_context in changed when it set
585
604
  # the session's values.
586
- def llm_context(text)
587
- output, changed = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
588
- .run(text.delete_prefix(LLM_CONTEXT_COMMAND))
605
+ def llm_context(text, confirm: true)
606
+ output, changed, ask = LLMContextCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
607
+ .run(text.delete_prefix(LLM_CONTEXT_COMMAND), confirm: confirm)
589
608
  # What the model has of each item is the next turn's strategy's: the
590
609
  # working set goes out again (sent only when it differs).
591
610
  @engine.announce_working_set if changed
592
- reply(output, changed: changed ? [:llm_context] : [])
611
+ reply(output, changed: changed ? [:llm_context] : [], confirm: ask)
593
612
  rescue ArgumentError => e
594
613
  reply(e.message, status: :error)
595
614
  end
@@ -672,16 +691,23 @@ module Samagotchi
672
691
  @save.call(session)
673
692
  end
674
693
 
675
- def model(text)
676
- output, switched = model_command(text)
677
- reply(output, changed: switched ? [:model] : [])
694
+ # /model; a switch to another model asks first (CacheConfirm, cost
695
+ # :full: the new model has no cache of the conversation), --yes applies.
696
+ def model(text, confirm: true)
697
+ suffix, yes = CacheConfirm.split(text.delete_prefix(MODEL_COMMAND), downcase: false)
698
+ output, switched, ask = model_command("#{MODEL_COMMAND} #{suffix}", confirm: confirm && !yes)
699
+ reply(output, changed: switched ? [:model] : [], confirm: ask)
678
700
  rescue ModelProfile::MissingModel => e
679
701
  # A model qualified with an unknown host: nothing switched.
680
702
  reply(e.message, status: :error)
681
703
  end
682
704
 
683
- # @return [Array(String, Boolean)] the message, and whether the model changed
684
- def model_command(input)
705
+ # @param input [String] the /model line, --yes taken out
706
+ # @param confirm [Boolean] a switch to another model asks first
707
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the message,
708
+ # whether the model changed, and the confirm it asks for (nothing
709
+ # changed then)
710
+ def model_command(input, confirm: false)
685
711
  suffix = input.delete_prefix(MODEL_COMMAND).strip
686
712
  if suffix.empty?
687
713
  return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{steer_cut_after_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
@@ -735,6 +761,9 @@ module Samagotchi
735
761
  invalid = ConfigFile.model_alias_error(alias_name, arg)
736
762
  return ["invalid alias: #{invalid}", false] if invalid
737
763
 
764
+ ask = confirm && model_ask(arg, input)
765
+ return ask if ask
766
+
738
767
  switch_model(arg, persist_default: persist_default)
739
768
 
740
769
  begin
@@ -756,10 +785,16 @@ module Samagotchi
756
785
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
757
786
  raise ModelProfile::MissingModel, "no default model is configured (set default.model in config.yml, or /model <name> --default)" if @default_model.nil?
758
787
 
788
+ ask = confirm && model_ask(@default_model, input)
789
+ return ask if ask
790
+
759
791
  switch_model(@default_model)
760
792
  return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
761
793
  end
762
794
 
795
+ ask = confirm && model_ask(arg, input)
796
+ return ask if ask
797
+
763
798
  switch_model(arg, persist_default: persist_default)
764
799
  if persist_default
765
800
  ["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
@@ -769,6 +804,20 @@ module Samagotchi
769
804
  end
770
805
  end
771
806
 
807
+ # The reply that asks before switching to +name+ (CacheConfirm), nil
808
+ # when it needn't: the name is checked first (a typo errors at once,
809
+ # MissingModel), and its resolved ref against the current one (the same
810
+ # model costs nothing; any other restarts the whole cache).
811
+ # @param line [String] the /model line as typed (its flags too), --yes taken out
812
+ def model_ask(name, line)
813
+ ref = @engine.model_switch_ref(name)
814
+ step = CacheConfirm.ask(line: line, cost: :full, changed: ref != model_name, engine: @engine)
815
+ return nil unless step
816
+
817
+ ["runtime model: #{model_name}\nSwitching to #{ref} restarts the prompt cache: the new model has no cache of " \
818
+ "this conversation.\n#{step.text}", false, step]
819
+ end
820
+
772
821
  # Engine owns the switch (alias resolution, profile, kernel, client and
773
822
  # the optional default persist); the session keeps the new model.
774
823
  def switch_model(name, persist_default: false)
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
+ require_relative "chi_home"
8
9
  require_relative "child_env"
9
10
  require_relative "client_id"
10
11
  require_relative "analytics_file"
@@ -33,6 +34,7 @@ require_relative "chi_invocation"
33
34
  require_relative "log"
34
35
  require_relative "log_path"
35
36
  require_relative "paths"
37
+ require_relative "project_env"
36
38
  require_relative "model_profile"
37
39
  require_relative "installed_gem"
38
40
  require_relative "installed_versions"
@@ -256,6 +258,8 @@ module Samagotchi
256
258
  else
257
259
  Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
258
260
  end
261
+ # (A chi a session's tool runs took that session's project env out at
262
+ # its start: bin/chi, ProjectEnv.take_orig!.)
259
263
  child_env = {}
260
264
  # A worker gets no CLI args: --model, --thinking, --log-level and the
261
265
  # other flags this chi was started with.
@@ -1127,6 +1131,9 @@ module Samagotchi
1127
1131
  fallback = ENV.delete(BOOT_FALLBACK_ENV)
1128
1132
  Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
1129
1133
  Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
1134
+ # Tools follow the project (its `mise env`, computed once per worker;
1135
+ # this process's ENV is left as it is); never in a loop run in-process.
1136
+ apply_project_env(session_id, state_dir: sd) if spawned
1130
1137
  # A spawned worker stops its turn on TERM/HUP/INT and saves it; a
1131
1138
  # loop run in-process (specs) traps nothing.
1132
1139
  signals = spawned ? WorkerSignals.new.install! : WorkerSignals::NONE
@@ -1215,6 +1222,18 @@ module Samagotchi
1215
1222
  Log.info(:worker, "kept_session", sid: session_id, error: e.class.name, msg: e.message)
1216
1223
  end
1217
1224
 
1225
+ # The tools' ProjectEnv for the session's folder (its working_directory;
1226
+ # spawn_options chdirs there unless it is gone), installed for this
1227
+ # process's tool spawns. Never fatal: a worker without it still runs.
1228
+ private_class_method def self.apply_project_env(session_id, state_dir:)
1229
+ session = Session.summary_from_file(Session.session_file(session_id, state_dir: state_dir))
1230
+ dir = session&.working_directory.to_s
1231
+ overlay = ProjectEnv.install!(ProjectEnv.compute(dir))
1232
+ Log.info(:worker, "project_env_applied", dir: dir, ruby: overlay.ruby) if overlay.applied
1233
+ rescue StandardError => e
1234
+ Log.warn(:worker, "project_env", reason: "error", error: e.class.name, msg: e.message)
1235
+ end
1236
+
1218
1237
  def self.config_idle_exit_minutes
1219
1238
  Samagotchi::Config.get("session.idle_exit_minutes")
1220
1239
  rescue StandardError
@@ -1252,16 +1271,50 @@ module Samagotchi
1252
1271
  # as it is read, so an rspec run by the worker's execute never forks.
1253
1272
  WORKER_DETACH_ENV = "SAMAGOTCHI_WORKER_DETACH"
1254
1273
 
1255
- def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
1274
+ # The boot string's rescue under a chi home: one line in the spawner's
1275
+ # log file (SAMAGOTCHI_LOG_FILE, set by spawn_options), LogLine's shape,
1276
+ # then exit. Single-quoted: the #{} run in the worker. SID is replaced.
1277
+ # rubocop:disable-next Lint/InterpolationCheck -- the worker interpolates these, not this process
1278
+ HOME_BOOT_FAILED = 'rescue ScriptError, StandardError => e; f = ENV["SAMAGOTCHI_LOG_FILE"].to_s; begin; ' \
1279
+ 'File.open(f, "a") { |io| io.puts(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") + ' \
1280
+ '" ERROR worker pid=#{Process.pid} sid=SID boot_failed ruby=#{RbConfig.ruby.inspect} error=" + ' \
1281
+ "e.message.lines.first.to_s.strip.inspect) } unless f.empty?; rescue SystemCallError; end; exit 1"
1282
+
1283
+ def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec,
1284
+ home: ChiHome.current(gem_spec: gem_spec))
1256
1285
  boot = "require 'samagotchi/session_manager'; " \
1257
1286
  "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
1258
1287
  return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
1288
+ return home_worker_command(session_id, state_dir: state_dir, gem_spec: gem_spec, home: home) if home
1259
1289
 
1260
1290
  activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
1261
1291
  "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
1262
1292
  [RbConfig.ruby, "-e", "#{activate}; #{boot}"]
1263
1293
  end
1264
1294
 
1295
+ # With a chi home (ChiHome.current, docs/architecture.md "How chi
1296
+ # launches itself"): the home's ruby, `Gem.paths` set to its gems in the
1297
+ # boot string (no GEM_* in the worker's env), the newest chi there, else
1298
+ # the fallback pin (ChiHome.fallback_version). A home that still can't
1299
+ # load chi (a broken install) logs HOME_BOOT_FAILED and exits: never a
1300
+ # silent spawn timeout.
1301
+ private_class_method def self.home_worker_command(session_id, state_dir:, gem_spec:, home:)
1302
+ newest = home.newest_version
1303
+ if newest && newest < Gem::Version.new(gem_spec.version.to_s)
1304
+ ChiHome.warn_once(:older_than_spawner, home: newest.to_s, spawner: gem_spec.version.to_s)
1305
+ end
1306
+ fallback = ChiHome.fallback_version(home, gem_spec)
1307
+ activate = if fallback
1308
+ "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
1309
+ "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{fallback}'; end"
1310
+ else
1311
+ "gem 'samagotchi', '>= 0.a'"
1312
+ end
1313
+ home.boot_argv(run: "begin; #{activate}; require 'samagotchi/session_manager'; " \
1314
+ "#{HOME_BOOT_FAILED.sub("SID", session_id)}; end; " \
1315
+ "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})")
1316
+ end
1317
+
1265
1318
  private_class_method def self.spawn_worker_for_session(session, state_dir:)
1266
1319
  session_dir = Session.session_dir(session.id, state_dir: state_dir)
1267
1320
  FileUtils.mkdir_p(session_dir)
@@ -61,9 +61,12 @@ module Samagotchi
61
61
 
62
62
  # #turn_llm_context with where each value came from
63
63
  # (LLMContextStrategy.explain), nil when it can't be resolved.
64
+ # @param own [LLMContextOverride, nil] the session's own values to
65
+ # resolve with (the ones a change would set: /llm-context's confirm)
64
66
  # @return [LLMContextStrategy::Explained, nil]
65
- def llm_context_explained
66
- model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
67
+ def llm_context_explained(own: :session)
68
+ args = own == :session ? llm_context_session_args : own&.resolve_args || {}
69
+ model_setting(nil) { |model| model.llm_context_explained(args) }
67
70
  end
68
71
 
69
72
  # #llm_context_explained's summary (Explained#summary), nil when it
@@ -14,6 +14,7 @@ require_relative "memory_bundle/index_size"
14
14
  require_relative "thinking"
15
15
  require_relative "prompt_files"
16
16
  require_relative "participant_mode"
17
+ require_relative "plugin/prompt_sections"
17
18
 
18
19
  module Samagotchi
19
20
  # The system prompt an Engine gives its model: the base prompt (tool
@@ -57,9 +58,12 @@ module Samagotchi
57
58
  # @param participants [#call] → ParticipantMode::PAIR / REVIEW while the
58
59
  # session has participants (SessionParticipants#prompt_mode; true reads
59
60
  # as review), nil or false with none (the #participants_section line)
61
+ # @param sections [#call] → Array<Plugin::PromptSections::Built>, the
62
+ # plugins' prompt sections (chi.prompt_section) run for this build
60
63
  def initialize(profile:, tools:, session:, thinking:, model: -> {}, model_notes: -> { [] }, memories: [],
61
- muted_memory_names: [], appended_files: [], participants: -> { false })
64
+ muted_memory_names: [], appended_files: [], participants: -> { false }, sections: -> { [] })
62
65
  @profile_lookup = profile
66
+ @sections_lookup = sections
63
67
  @participants_lookup = participants
64
68
  @model_lookup = model
65
69
  @model_notes_lookup = model_notes
@@ -97,19 +101,23 @@ module Samagotchi
97
101
  @index_sizes ||= {}
98
102
  @notes_loaded ||= {}
99
103
  @files_loaded ||= {}
104
+ @sections_loaded ||= {}
100
105
  unless @built.key?(key)
101
106
  @section_notes = nil
102
107
  @section_files = []
108
+ @section_plugins = []
103
109
  @built[key] = system_prompt_with_index(
104
110
  assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
105
111
  )
106
112
  @index_sizes[key] = @measured_index
107
113
  @notes_loaded[key] = @section_notes || []
108
114
  @files_loaded[key] = @section_files.freeze
115
+ @sections_loaded[key] = @section_plugins.freeze
109
116
  end
110
117
  @memory_index = @index_sizes[key]
111
118
  @prompt_notes = @notes_loaded[key]
112
119
  @prompt_files = @files_loaded[key]
120
+ @prompt_sections = @sections_loaded[key]
113
121
  @built[key]
114
122
  end
115
123
 
@@ -135,6 +143,12 @@ module Samagotchi
135
143
  # @return [Array<String>]
136
144
  def prompt_files = @prompt_files || []
137
145
 
146
+ # The plugins' prompt sections the prompt #build returned last holds
147
+ # (Plugin::PromptSections::Built, prompt order), [] before a build and
148
+ # after #reset!.
149
+ # @return [Array<Plugin::PromptSections::Built>]
150
+ def prompt_sections = @prompt_sections || []
151
+
138
152
  # Drops the built prompts: the next #build reads the profile, tools,
139
153
  # indexes and memories again (a model or profile switch, changed tools).
140
154
  def reset!
@@ -144,6 +158,8 @@ module Samagotchi
144
158
  @prompt_notes = nil
145
159
  @files_loaded = nil
146
160
  @prompt_files = nil
161
+ @sections_loaded = nil
162
+ @prompt_sections = nil
147
163
  @stable_lengths = nil
148
164
  end
149
165
 
@@ -298,7 +314,8 @@ module Samagotchi
298
314
  # prefix): what every session shares first, what changes least before
299
315
  # what changes more, and the per-session lines last. The global parts
300
316
  # (identity, preloads) come before the project's AGENTS.md, the
301
- # toolkit's prompt text after the appended files, the memory indexes (a
317
+ # toolkit's prompt text after the appended files, then the plugins'
318
+ # prompt sections, the memory indexes (a
302
319
  # memory write changes them) after all of them; the model, the location
303
320
  # and the session close the prompt (Gemma's tool declarations still
304
321
  # follow them).
@@ -311,7 +328,7 @@ module Samagotchi
311
328
  "System memories:\n#{indexes[:system]}"
312
329
  ].join("\n\n")
313
330
  stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
314
- project_specific_description, appended_section, kit_prompt_section, memory_sections]
331
+ project_specific_description, appended_section, kit_prompt_section, plugin_prompt_section, memory_sections]
315
332
  stable_text = stable.compact.join("\n")
316
333
  volatile = [participants_section, current_model, project_location, current_session].compact
317
334
  return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
@@ -362,6 +379,20 @@ module Samagotchi
362
379
  registry.respond_to?(:toolkit) ? registry.toolkit&.prompt : nil
363
380
  end
364
381
 
382
+ # The plugins' prompt sections (chi.prompt_section), right after the
383
+ # kit prompt and before the memory indexes: a plugin's text depends on
384
+ # its settings and the model, not on a memory write. Each is
385
+ # "From <bundle> (<name>):\n<text>", sorted by (bundle, name)
386
+ # (Plugin::PromptSections). nil with none: the prompt is what it was
387
+ # before prompt sections.
388
+ def plugin_prompt_section
389
+ built = Array(@sections_lookup.call)
390
+ @section_plugins = built
391
+ Plugin::PromptSections.text(built)
392
+ rescue StandardError
393
+ nil
394
+ end
395
+
365
396
  def with_tail_declarations(prompt, chat:, layers:)
366
397
  chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
367
398
  end
@@ -87,9 +87,10 @@ module Samagotchi
87
87
  toolkit_flags: toolkit_flags, settings: settings, parent_answers: parent_answers)
88
88
  end
89
89
  commands = []
90
- commands << "/model #{model}" if model && (attach || resume)
90
+ # --yes on /model and /thinking: typing the flag is the choice (no
91
+ # confirm step); its applied reply ends the launch's command.
92
+ commands << "/model #{model} --yes" if model && (attach || resume)
91
93
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
92
- # --yes: typing the flag is the choice (no confirm step).
93
94
  commands << "#{ThinkingCommand::NAME} #{thinking} --yes" if (attach || resume) && thinking
94
95
  first_command = commands.size > 1 ? commands : commands.first
95
96
  surface = open_surface
@@ -728,6 +728,9 @@ module Samagotchi
728
728
  # The files whose text is in the prompt (SystemPrompt#prompt_files).
729
729
  files = prompt_files_text(snapshot[:prompt_files])
730
730
  lines << "prompt files: #{files}" if files
731
+ # The plugins' prompt sections it holds (chi.prompt_section): name (bundle).
732
+ sections = Array(snapshot[:prompt_sections]).map { |item| "#{item[:name]} (#{item[:bundle]})" }
733
+ lines << "prompt sections: #{sections.join(", ")}" unless sections.empty?
731
734
  # The toolkit's prompt text (Engine#stats_snapshot's kit_prompt).
732
735
  if (kit = snapshot[:kit_prompt])
733
736
  lines << "kit prompt: #{kit[:kit]} (~#{kit[:tokens]} tokens)"
@@ -101,7 +101,9 @@ module Samagotchi
101
101
  "Samagotchi::ProcessGroup.stop" => "stops a server's process group: needs a ctx process API",
102
102
  'require "samagotchi/token_usage"' => "for CHARS_PER_TOKEN",
103
103
  "Samagotchi::TokenUsage::CHARS_PER_TOKEN" => "estimates a tool's tokens: could be a stable constant",
104
- 'require "samagotchi/tool_declarations"' => "loaded for Api.tool_spec's schemas"
104
+ 'require "samagotchi/tool_declarations"' => "loaded for Api.tool_spec's schemas",
105
+ "Samagotchi::ProjectEnv" => "checks the worker has a project env (an older chi doesn't)",
106
+ "Samagotchi::ProjectEnv.current" => "starts a server in the session's project env: needs a ctx tools-env API"
105
107
  },
106
108
  "slow-commands" => {
107
109
  'require "samagotchi/guardrails/shell_lex"' => "for ShellLex",
@@ -101,7 +101,7 @@ module Samagotchi
101
101
  Plugin::Registries.new(
102
102
  commands: SessionCommands.register_builtins(Commands::Registry.new), tools: Tools::Builtins.registry,
103
103
  services: Plugin::Services.new, stage_tools: ->(*) {}, toolkits: [], tool_index: Tools::ToolIndex.new,
104
- peer_sources: PeerSources.new, **fields
104
+ peer_sources: PeerSources.new, prompt_sections: Plugin::PromptSections.new, **fields
105
105
  )
106
106
  end
107
107
  end
@@ -138,6 +138,9 @@ module Samagotchi
138
138
  attr_reader :errors
139
139
  # @return [Integer] how often chi was told the tools changed
140
140
  attr_reader :tools_changes
141
+ # @return [Integer] how often chi was told the prompt sections changed
142
+ # (chi.prompt_changed!)
143
+ attr_reader :prompt_changes
141
144
 
142
145
  def initialize(dir, settings: {}, host: {}, session_settings: {}, sandbox: nil)
143
146
  @dir = File.expand_path(dir)
@@ -163,6 +166,7 @@ module Samagotchi
163
166
  @errors = []
164
167
  @lenient = false
165
168
  @tools_changes = 0
169
+ @prompt_changes = 0
166
170
  @contexts = {}
167
171
  @registries = build_registries
168
172
  end
@@ -261,12 +265,16 @@ module Samagotchi
261
265
  # Run a command line ("/name args") as chi does: the first matching
262
266
  # command, given the text after its name. Returns the handler's value
263
267
  # (a raise propagates: chi would show it as an error result).
264
- def command(line)
268
+ # @param confirm [Boolean] sent by a person at a web tab or a TUI, who
269
+ # can answer ctx.cache_confirm (false: chi send, a delegate); a line
270
+ # with --yes never asks, as in chi
271
+ def command(line, confirm: false)
265
272
  entry = commands.lookup(line)
266
273
  raise ArgumentError, "no command matches #{line.inspect}" unless entry
267
274
  raise ArgumentError, "#{entry.name} is chi's own command, not the bundle's" if entry.source == "core"
268
275
 
269
- failing { entry.handler.call(line.to_s.strip.delete_prefix(entry.name).strip) }
276
+ args = line.to_s.strip.delete_prefix(entry.name).strip
277
+ failing { CacheConfirm.asking(confirm && !CacheConfirm.split(args).last) { entry.handler.call(args) } }
270
278
  end
271
279
 
272
280
  # Call a registered tool as chi's dispatch does: its handler gets the
@@ -324,6 +332,16 @@ module Samagotchi
324
332
  # @return [PeerSources]
325
333
  def peer_sources = @registries.peer_sources
326
334
 
335
+ # The prompt sections as chi's next prompt build runs them, for the
336
+ # session now (its id: a failure keeps that session's last good text).
337
+ # @return [Array<Plugin::PromptSections::Built>]
338
+ def prompt_sections = @registries.prompt_sections.render(@recorder[:session_id])
339
+
340
+ # The text they add to the system prompt, as chi puts it there; nil
341
+ # with none.
342
+ # @return [String, nil]
343
+ def prompt_text = Plugin::PromptSections.text(prompt_sections)
344
+
327
345
  # The bundle's own command entries.
328
346
  def bundle_commands = commands.entries.select { |entry| entry.source == @name }
329
347
  # The bundle's own tool entries.
@@ -401,6 +419,7 @@ module Samagotchi
401
419
  env: ENV, sessions: @sessions, http_transport: @http)
402
420
  },
403
421
  tools_changed: -> { @tools_changes += 1 },
422
+ prompt_changed: -> { @prompt_changes += 1 },
404
423
  stage_tools: ->(_bundle, specs, context) { @staged_tools << StagedTools.new(specs: specs, context: context) },
405
424
  init: lambda { |_bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
406
425
  @inits << Init.new(label: label, plugin_label: plugin_label, provides_tools: provides_tools, quiet: quiet,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../cancellation_controller"
4
+ require_relative "../cache_confirm"
4
5
  require_relative "../plugin/context"
5
6
  require_relative "../session_metrics/cost"
6
7
  require_relative "../session_metrics/usage"
@@ -12,6 +13,14 @@ module Samagotchi
12
13
  # with every member set, its values read from the Recorder at each call
13
14
  # and its actions landing in it.
14
15
  module Host
16
+ # What CacheConfirm.ask reads of an Engine, from the Recorder's
17
+ # history: and context_tokens:.
18
+ CacheEngine = Struct.new(:history, :tokens) do
19
+ def history? = history ? true : false
20
+ def cache_restart_tokens = tokens
21
+ def cache_restart_anthropic? = false
22
+ end
23
+
15
24
  module_function
16
25
 
17
26
  # @param recorder [Recorder]
@@ -37,6 +46,7 @@ module Samagotchi
37
46
  },
38
47
  model_name: -> { recorder[:model_name] },
39
48
  model_key: -> { recorder[:model_key] },
49
+ model_names: -> { recorder[:model_names] || model_names(recorder[:model_name]) },
40
50
  state_dir: -> { recorder[:state_dir] || Session.default_state_dir },
41
51
  scratch: -> { recorder[:scratch] },
42
52
  setup: -> { recorder[:setup] },
@@ -52,10 +62,21 @@ module Samagotchi
52
62
  cancel_controller: ->(handler: false) { recorder[:cancelled] ? stopped : nil },
53
63
  cost: -> { cost(recorder[:cost]) },
54
64
  usage: -> { usage(recorder[:usage]) },
55
- turn_author: -> { recorder[:turn_author] }
65
+ turn_author: -> { recorder[:turn_author] },
66
+ cache_confirm: lambda { |line, changed|
67
+ CacheConfirm.ask(line: line, cost: :full, changed: changed,
68
+ engine: CacheEngine.new(recorder[:history], recorder[:context_tokens]))
69
+ }
56
70
  )
57
71
  end
58
72
 
73
+ # ctx.model_names for a model ref when the test sets none: the ref,
74
+ # then its id without the host ("test:model" → test:model, model).
75
+ def model_names(ref)
76
+ ref = ref.to_s
77
+ [ref, ref.split(":", 2).last].reject(&:empty?).uniq
78
+ end
79
+
59
80
  # The parts a test may give bundle.set(cost: {...}).
60
81
  COST_PARTS = %i[reported estimated side known].freeze
61
82
 
@@ -43,7 +43,12 @@ module Samagotchi
43
43
  # callable giving one.
44
44
  usage: nil,
45
45
  # ctx.turn_author and the hooks' author: (a participant's "p:<id>"), nil for the owner.
46
- turn_author: nil
46
+ turn_author: nil,
47
+ # ctx.cache_confirm: whether the session has a model reply (a cache
48
+ # to restart), and the context size its confirm names (nil: none).
49
+ history: false, context_tokens: nil,
50
+ # ctx.model_names: nil gives model_name and its id without the host.
51
+ model_names: nil
47
52
  }.freeze
48
53
 
49
54
  attr_reader :notices, :cards, :steers, :stops, :questions, :model_requests, :toolkit_uses, :tool_loads, :answers,
@@ -11,7 +11,7 @@ module Samagotchi
11
11
  # The real Plugin::Api, also recording each declaration in order
12
12
  # (#declarations), for a spec of what #register declares as written.
13
13
  class RecordingApi < Plugin::Api
14
- DECLARATIONS = %i[command annotation tool on service init toolkit tool_source peer_source].freeze
14
+ DECLARATIONS = %i[command annotation tool on service init toolkit tool_source peer_source prompt_section].freeze
15
15
 
16
16
  # @return [Array<Declaration>]
17
17
  def declarations = (@declarations ||= [])
@@ -3,6 +3,7 @@
3
3
  require_relative "registry"
4
4
  require_relative "../tool_declarations"
5
5
  require_relative "../paths"
6
+ require_relative "../project_env"
6
7
  require_relative "execute"
7
8
  require_relative "read"
8
9
  require_relative "write"
@@ -91,7 +92,7 @@ module Samagotchi
91
92
  end,
92
93
  Execute::NAME => lambda do |call, kctx|
93
94
  Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
94
- **participant_execute(kctx, parent_env(kctx)))
95
+ **participant_execute(kctx, project_env(kctx).merge(parent_env(kctx))))
95
96
  end,
96
97
  RegisterReminder::NAME => lambda do |call, kctx|
97
98
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
@@ -145,6 +146,16 @@ module Samagotchi
145
146
  SESSION_REASONING_HISTORY_ENV => history }.merge(cli_model_unset(env)).merge(xdg_absolute(env))
146
147
  end
147
148
 
149
+ # The session's project env (ProjectEnv.current: the worker's mise env,
150
+ # the chi shim first on PATH) for the commands the owner's turns run;
151
+ # less what every tool runs without (TaskRuntime::SANITIZED_ENV_KEYS).
152
+ # A participant's turn gets none of it: a project's [env] may load its
153
+ # .env files.
154
+ def project_env(kctx)
155
+ mode = kctx.respond_to?(:participant_mode) ? kctx.participant_mode : nil
156
+ mode ? {} : ProjectEnv.tools_env
157
+ end
158
+
148
159
  # execute's env: and on_timeout: for the running turn. A participant's
149
160
  # turn runs without the variables whose names hold a secret
150
161
  # (Guardrails::PairBoundary.scrubbed_env), and a command still running
@@ -179,7 +190,7 @@ module Samagotchi
179
190
  # markers are reserved, the model's env can't set them).
180
191
  def task_marker_env(kctx, env)
181
192
  given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
182
- parent_env(kctx).reject { |key, _| given.include?(key) }
193
+ project_env(kctx).merge(parent_env(kctx)).reject { |key, _| given.include?(key) }
183
194
  end
184
195
 
185
196
  # Stop flips the turn's controller, seen through the Engine's PeerView