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
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "chi_home"
5
+ require_relative "home_doctor"
6
+ require_relative "cli/command"
7
+ require_relative "cli/exit"
8
+ require_relative "cli/flags"
9
+ require_relative "installed_gem"
10
+ require_relative "paths"
11
+ require_relative "version"
12
+
13
+ module Samagotchi
14
+ # `chi home`: the chi home (ChiHome), the Ruby every chi launch boots.
15
+ # Shows it, sets it (`chi home set [--ruby PATH]`), and checks it
16
+ # (`chi home doctor`, HomeDoctor's rows).
17
+ class HomeCommand
18
+ include CLI::Command
19
+
20
+ USAGE = <<~TEXT
21
+ Usage: chi home [set [--ruby PATH] | doctor]
22
+ (none) show the chi home: its Ruby, the chi it runs, its gem home and path, mise
23
+ set record this chi's Ruby and gem path as the home (a chruby shell's
24
+ GEM_HOME/GEM_PATH are part of it)
25
+ set --ruby PATH record that Ruby (its own gem dirs) as the home; it must have samagotchi
26
+ (gem install samagotchi with it first)
27
+ doctor check the home (its Ruby is there and has samagotchi) and what should
28
+ follow it: this chi, the desktop helper, sessions, the tools' chi script,
29
+ this project's Gemfile.lock, mise and the last worker's project env
30
+ Every chi launch (workers, chi web's restart and Update, the macOS helper's next
31
+ chi desktop install or upgrade) boots the home's Ruby with the home's gems. Saved in
32
+ #{Paths.tilde(ChiHome.path)}.
33
+ TEXT
34
+
35
+ SUBCOMMANDS = %w[set doctor].freeze
36
+ FLAGS = {
37
+ nil => CLI::Flags.new(help: HELP_WORDS, args: false),
38
+ "set" => CLI::Flags.new(help: HELP_WORDS, args: false) { |f| f.value "--ruby" },
39
+ "doctor" => CLI::Flags.new(help: HELP_WORDS, args: false)
40
+ }.freeze
41
+
42
+ # @param path [String] home.json
43
+ # @param probe [#call] (ruby) → ChiHome::Record or nil (ChiHome.probe)
44
+ # @param doctor [#call] (home) → HomeDoctor
45
+ def initialize(argv, stdout: $stdout, stderr: $stderr, path: ChiHome.path, gem_spec: InstalledGem.spec,
46
+ env: ENV, probe: nil, doctor: nil)
47
+ @argv = argv.dup
48
+ @stdout = stdout
49
+ @stderr = stderr
50
+ @path = path
51
+ @gem_spec = gem_spec
52
+ @env = env
53
+ @probe = probe || ->(ruby) { ChiHome.probe(ruby, env: env) }
54
+ @doctor = doctor || lambda { |home|
55
+ HomeDoctor.new(home: home, env: env, gem_spec: gem_spec, helper: HomeDoctor.default_helper(env: env))
56
+ }
57
+ end
58
+
59
+ # @return [Integer] 0, 1 (no usable home, a refusal), 2 usage
60
+ def run
61
+ @sub = SUBCOMMANDS.include?(@argv.first) ? @argv.first : nil
62
+ parsed = parse_flags(FLAGS.fetch(@sub), @sub ? @argv[1..] : @argv)
63
+ return parsed if parsed.is_a?(Integer)
64
+
65
+ case @sub
66
+ when "set" then run_set(parsed.options)
67
+ when "doctor" then run_doctor
68
+ else run_show
69
+ end
70
+ rescue ChiHome::Invalid => e
71
+ error_line("#{command_name}: #{e.message} (chi home set rewrites it)")
72
+ CLI::Exit::FAILED
73
+ end
74
+
75
+ private
76
+
77
+ def command_name = @sub ? "chi home #{@sub}" : "chi home"
78
+ def usage_on_error = nil
79
+
80
+ def run_show
81
+ home = ChiHome.read(path: @path)
82
+ unless home
83
+ @stdout.puts("no chi home: chi launches on the Ruby that starts it (chi home set records this one, #{running_ruby})")
84
+ return CLI::Exit::OK
85
+ end
86
+
87
+ rows(home).each { |label, value| @stdout.puts("#{label.ljust(9)} #{value}") }
88
+ problem = ChiHome.problem(home)
89
+ @stdout.puts("problem #{problem} (chi home doctor)") if problem
90
+ problem ? CLI::Exit::FAILED : CLI::Exit::OK
91
+ end
92
+
93
+ def rows(home)
94
+ list = [["ruby", home.ruby], ["chi", home.newest_version&.to_s || "none"], ["gem home", home.gem_home],
95
+ ["gem path", home.gem_path], ["mise", home.mise || "none"],
96
+ ["set", "#{home.set_at} by chi #{home.set_by_version}"]]
97
+ list << ["running", "chi #{VERSION} on #{running_ruby}"] unless running_home?(home)
98
+ list
99
+ end
100
+
101
+ def running_home?(home) = @gem_spec && home.running_here? && home.newest_version.to_s == VERSION
102
+
103
+ def running_ruby = "#{RbConfig.ruby}#{" (a checkout)" unless @gem_spec}"
104
+
105
+ def run_set(options)
106
+ home = options[:ruby] ? probed(options[:ruby]) : ChiHome.from_running(env: @env)
107
+ return CLI::Exit::FAILED unless home
108
+
109
+ if (problem = ChiHome.problem(home))
110
+ error_line("#{command_name}: #{problem}: gem install samagotchi with #{home.ruby} first")
111
+ return CLI::Exit::FAILED
112
+ end
113
+
114
+ ChiHome.write!(home, path: @path)
115
+ @stdout.puts("chi home set to #{home.ruby} (chi #{home.newest_version}); new workers and launches boot it")
116
+ CLI::Exit::OK
117
+ rescue SystemCallError => e
118
+ error_line("#{command_name}: couldn't write #{Paths.tilde(@path)}: #{e.message}")
119
+ CLI::Exit::FAILED
120
+ end
121
+
122
+ # The home a --ruby names: this one's (with this shell's gem path) when
123
+ # it is the running Ruby, else asked of it.
124
+ def probed(ruby)
125
+ path = File.expand_path(ruby)
126
+ unless File.file?(path) && File.executable?(path)
127
+ error_line("#{command_name}: no Ruby at #{ruby}")
128
+ return nil
129
+ end
130
+ return ChiHome.from_running(env: @env) if ChiHome.real(path) == ChiHome.real(RbConfig.ruby)
131
+
132
+ home = @probe.call(path)
133
+ error_line("#{command_name}: #{ruby} didn't answer as a Ruby") unless home
134
+ home
135
+ end
136
+
137
+ # The home's rows and what should follow it (HomeDoctor); exit 1 when
138
+ # one fails.
139
+ def run_doctor
140
+ rows = @doctor.call(ChiHome.read(path: @path)).rows
141
+ rows.each { |row| @stdout.puts(row.to_s) }
142
+ rows.any? { |row| row.status == :fail } ? CLI::Exit::FAILED : CLI::Exit::OK
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "chi_home"
5
+ require_relative "chi_shim"
6
+ require_relative "config"
7
+ require_relative "desktop"
8
+ require_relative "home_notice"
9
+ require_relative "installed_gem"
10
+ require_relative "log_path"
11
+ require_relative "paths"
12
+ require_relative "project_env"
13
+ require_relative "session"
14
+ require_relative "version"
15
+
16
+ module Samagotchi
17
+ # `chi home doctor`'s rows: the chi home (ChiHome) and what should follow
18
+ # it, each one line with a status and what to run. One check each; the
19
+ # text is the condition owner's (ChiHome, HomeNotice, Desktop::MacOS,
20
+ # ChiShim, ProjectEnv) where another surface says the same.
21
+ class HomeDoctor
22
+ # +status+ :ok, :warn (works, not as intended) or :fail (a launch can't
23
+ # boot what it should).
24
+ Row = Data.define(:status, :text) do
25
+ def to_s = "#{status.to_s.ljust(5)} #{text}"
26
+ end
27
+
28
+ # What a Gemfile.lock that bundles chi has (a GEM or PATH spec line).
29
+ LOCKED_CHI = /^ {4}samagotchi \(/
30
+
31
+ # @param home [ChiHome::Record, nil] home.json's record
32
+ # @param helper [Desktop::MacOS, nil] the desktop helper (nil off macOS)
33
+ # @param sessions [#call] () → [Session] this machine's sessions
34
+ def initialize(home:, env: ENV, cwd: Dir.pwd, gem_spec: InstalledGem.spec, helper: nil,
35
+ sessions: -> { Session.list(state_dir: Session.default_state_dir(env: env)) },
36
+ shim_dir: ChiShim.dir(env: env), log_path: LogPath.resolve(env: env),
37
+ ruby: RbConfig.ruby, gem_dir: Gem.dir)
38
+ @home = home
39
+ @env = env
40
+ @cwd = cwd
41
+ @gem_spec = gem_spec
42
+ @helper = helper
43
+ @sessions = sessions
44
+ @shim_dir = shim_dir
45
+ @log_path = log_path
46
+ @ruby = ruby
47
+ @gem_dir = gem_dir
48
+ end
49
+
50
+ # @return [Array<Row>]
51
+ def rows
52
+ list = [home_row]
53
+ if @home && !ChiHome.problem(@home)
54
+ list.concat([running_row, helper_row, sessions_row, shim_row].compact)
55
+ end
56
+ list.concat([gemfile_row, mise_row, project_env_row].compact)
57
+ end
58
+
59
+ # The desktop helper chi home doctor checks: this machine's, when it
60
+ # is installed.
61
+ def self.default_helper(env: ENV)
62
+ return nil unless Desktop.supported?
63
+
64
+ helper = Desktop::MacOS.new(env: env, register: false)
65
+ File.file?(helper.launch_path) ? helper : nil
66
+ end
67
+
68
+ private
69
+
70
+ def ok_row(text) = Row.new(status: :ok, text: text)
71
+ def warn_row(text) = Row.new(status: :warn, text: text)
72
+ def fail_row(text) = Row.new(status: :fail, text: text)
73
+
74
+ def home_row
75
+ return ok_row("no chi home: each launch uses the Ruby that starts it (chi home set to record one)") unless @home
76
+
77
+ if (problem = ChiHome.problem(@home))
78
+ return fail_row("#{problem}: after a Ruby upgrade, gem install samagotchi with the new Ruby, then " \
79
+ "chi home set from it (chi home set --ruby PATH); until then launches use the Ruby that starts them")
80
+ end
81
+
82
+ ok_row("chi home #{@home.ruby} runs chi #{@home.newest_version}")
83
+ end
84
+
85
+ def running_row
86
+ return ok_row("this chi runs from a source checkout: it starts its own code, not the home") unless @gem_spec
87
+
88
+ text = HomeNotice.mismatch(@home, ruby: @ruby, gem_dir: @gem_dir)
89
+ text && warn_row("#{text}: launches boot the home; chi home set makes this chi the home")
90
+ end
91
+
92
+ def helper_row
93
+ return nil unless @helper
94
+
95
+ if (problem = @helper.launch_problem)
96
+ fail_row(problem)
97
+ elsif (problem = @helper.home_problem(@home))
98
+ warn_row(problem)
99
+ end
100
+ end
101
+
102
+ # Sessions last saved by a newer chi than the home's: their workers
103
+ # (the home's chi) run them without what that chi added.
104
+ def sessions_row
105
+ newest = @home.newest_version
106
+ newer = @sessions.call.filter_map do |session|
107
+ version = session.written_by
108
+ Gem::Version.new(version) if version.is_a?(String) && Gem::Version.correct?(version)
109
+ end.select { |version| version > newest }
110
+ return nil if newer.empty?
111
+
112
+ count = newer.size == 1 ? "1 session was" : "#{newer.size} sessions were"
113
+ warn_row("#{count} last saved by a newer chi (#{newer.max}) than the home's #{newest}: " \
114
+ "their workers run without what it added; chi update brings the home up to date")
115
+ end
116
+
117
+ def shim_row
118
+ problem = ChiShim.problem(@home, dir: @shim_dir)
119
+ problem && warn_row(problem)
120
+ end
121
+
122
+ # The cwd's project bundling chi (a Gemfile.lock with samagotchi):
123
+ # `bundle exec chi` there runs the bundle's chi, off the home.
124
+ def gemfile_row
125
+ lock = project_lockfile
126
+ return nil unless lock && File.read(lock).match?(LOCKED_CHI)
127
+
128
+ warn_row("#{Paths.tilde(lock)} bundles samagotchi: chi under the project's bundle (bundle exec chi) isn't " \
129
+ "supported; run chi itself, and bundle remove samagotchi there")
130
+ rescue SystemCallError
131
+ nil
132
+ end
133
+
134
+ # The nearest Gemfile.lock from the cwd up to its git root; nil for
135
+ # chi's own source tree (its gemspec is next to it).
136
+ def project_lockfile
137
+ dir = project_dirs.find { |candidate| File.file?(File.join(candidate, "Gemfile.lock")) }
138
+ return nil if dir.nil? || File.file?(File.join(dir, "samagotchi.gemspec"))
139
+
140
+ File.join(dir, "Gemfile.lock")
141
+ end
142
+
143
+ # The cwd and the folders above it up to its git root (the cwd alone
144
+ # outside git).
145
+ def project_dirs
146
+ @project_dirs ||= begin
147
+ dirs = []
148
+ dir = File.expand_path(@cwd)
149
+ loop do
150
+ dirs << dir
151
+ break if File.exist?(File.join(dir, ".git"))
152
+
153
+ parent = File.dirname(dir)
154
+ break dirs = [dirs.first] if parent == dir
155
+
156
+ dir = parent
157
+ end
158
+ dirs
159
+ end
160
+ end
161
+
162
+ def mise_row
163
+ mode, origin = Config.get_with_origin("session.project_env")
164
+ return ok_row("project env off (session.project_env: off): tools keep the env of whoever starts the worker") if mode.to_s == "off"
165
+
166
+ mise = ProjectEnv.find_mise(env: @env)
167
+ return ok_row("project env: mise (#{mise})") if mise
168
+
169
+ origin == :default ? ok_row(ProjectEnv::NO_MISE) : warn_row(ProjectEnv::NO_MISE)
170
+ end
171
+
172
+ def project_env_row
173
+ warning = ProjectEnv.last_warning(project_dirs, log_path: @log_path)
174
+ warning && warn_row(warning.message)
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+ require_relative "chi_home"
6
+ require_relative "installed_gem"
7
+ require_relative "paths"
8
+ require_relative "version"
9
+
10
+ module Samagotchi
11
+ # One dim stderr line, at most once a day, when a person runs an installed
12
+ # chi at a terminal from a Ruby that isn't the chi home's: "chi 0.131.0 on
13
+ # /x/ruby; the chi home is /y/ruby (chi 0.132.0) (chi home doctor)". The
14
+ # sessions it starts boot the home anyway; the line says which chi answered.
15
+ # Never inside a worker's tools (SAMAGOTCHI_PROJECT_ENV), never for a
16
+ # script (no TTY, --json, --non-interactive), never from a checkout.
17
+ # The day is stamped in $XDG_STATE_HOME/samagotchi/home_noticed.
18
+ module HomeNotice
19
+ STAMP_FILE = "home_noticed"
20
+ # Runs whose output a program reads, or that say this themselves.
21
+ QUIET_COMMANDS = %w[home update self].freeze
22
+ QUIET_FLAGS = %w[--json --non-interactive --version -h --help].freeze
23
+ # Set in a worker's tools (ProjectEnv::MARKER_ENV) and in specs' test runs.
24
+ QUIET_ENV = %w[SAMAGOTCHI_PROJECT_ENV SAMAGOTCHI_ENV RACK_ENV CI].freeze
25
+
26
+ module_function
27
+
28
+ # Prints the line when it is due. Cheap when it isn't: the TTY, argv
29
+ # and env checks come first, then the stamp, then home.json.
30
+ # @return [String, nil] the line printed
31
+ def show(argv: ARGV, env: ENV, tty: $stdin.tty? && $stderr.tty?, io: $stderr,
32
+ gem_spec: nil, today: Time.now.strftime("%Y-%m-%d"))
33
+ return nil unless tty && !quiet_argv?(argv) && QUIET_ENV.none? { |key| env.key?(key) }
34
+
35
+ stamp = File.join(Paths.state_dir(env: env), STAMP_FILE)
36
+ return nil if stamped?(stamp, today)
37
+
38
+ gem_spec ||= InstalledGem.spec
39
+ text = gem_spec && mismatch(ChiHome.current(path: ChiHome.path(env: env), gem_spec: gem_spec))
40
+ return nil unless text
41
+
42
+ line = "#{text} (chi home doctor)"
43
+ io.puts("\e[2m#{line}\e[0m")
44
+ FileUtils.mkdir_p(File.dirname(stamp))
45
+ File.write(stamp, "#{today}\n")
46
+ line
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ # This chi isn't the home's: the text, also chi home doctor's row; nil
52
+ # when this process runs on +home+ (or there is none).
53
+ # @return [String, nil]
54
+ def mismatch(home, ruby: RbConfig.ruby, gem_dir: Gem.dir, version: VERSION)
55
+ return nil if home.nil? || (home.running_here?(ruby: ruby, gem_dir: gem_dir) && home.newest_version.to_s == version)
56
+
57
+ where = ruby
58
+ # The same Ruby on other gems (a chruby GEM_HOME): name them both.
59
+ if ChiHome.real(ruby) == ChiHome.real(home.ruby) && ChiHome.real(gem_dir) != ChiHome.real(home.gem_home)
60
+ where = "#{ruby} (gems #{gem_dir})"
61
+ home_where = "#{home.ruby} (gems #{home.gem_home})"
62
+ end
63
+ "chi #{version} on #{where}; the chi home is #{home_where || home.ruby} (chi #{home.newest_version})"
64
+ end
65
+
66
+ def quiet_argv?(argv)
67
+ return true if QUIET_COMMANDS.include?(argv.first)
68
+
69
+ argv.each_with_index.any? do |arg, i|
70
+ QUIET_FLAGS.include?(arg) || arg == "--format=json" || (arg == "--format" && argv[i + 1] == "json")
71
+ end
72
+ end
73
+
74
+ def stamped?(stamp, today)
75
+ File.read(stamp, 32).to_s.strip == today
76
+ rescue SystemCallError
77
+ false
78
+ end
79
+ end
80
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "chi_home"
3
4
  require_relative "installed_gem"
4
5
  require_relative "version"
5
6
  require_relative "redacted_inspect"
@@ -8,8 +9,9 @@ module Samagotchi
8
9
  # The newest chi on this machine, which a long-lived process (chi web, an
9
10
  # attaching TUI) compares with its own VERSION to say "chi X is installed;
10
11
  # restart". Run from an installed gem: the highest samagotchi-<v>.gemspec
11
- # in RubyGems' specification dirs, matching the `>= 0.a` a new worker
12
- # activates (prereleases count). Run from a source checkout: the VERSION
12
+ # in RubyGems' specification dirs (the chi home's when there is one,
13
+ # ChiHome), matching the `>= 0.a` a new worker activates (prereleases
14
+ # count). Run from a source checkout: the VERSION
13
15
  # in lib/samagotchi/version.rb on disk now (a `git pull` moves it).
14
16
  #
15
17
  # Cheap enough to poll: each dir is listed again only when its mtime
@@ -30,7 +32,7 @@ module Samagotchi
30
32
  # runs from (nil: a source checkout)
31
33
  # @param dirs [#call] → the specification dirs to list
32
34
  # @param version_file [String] a checkout's version.rb
33
- def initialize(env: ENV, gem_spec: InstalledGem.spec, dirs: -> { Gem::Specification.dirs },
35
+ def initialize(env: ENV, gem_spec: InstalledGem.spec, dirs: -> { ChiHome.spec_dirs(gem_spec: gem_spec) },
34
36
  version_file: File.join(InstalledGem::ROOT, "lib", "samagotchi", "version.rb"))
35
37
  @env = env
36
38
  @gem_spec = gem_spec
@@ -4,6 +4,7 @@ require_relative "llm_context_override"
4
4
  require_relative "llm_context_strategy"
5
5
  require_relative "llm_context_edit"
6
6
  require_relative "llm_context_stale"
7
+ require_relative "cache_confirm"
7
8
 
8
9
  module Samagotchi
9
10
  # /llm-context, the session's own LLM context values
@@ -18,7 +19,9 @@ module Samagotchi
18
19
  # system prompt (a full cache break); stale turned on stages the reads
19
20
  # already superseded as one batch under the apply rule; a layer turned
20
21
  # off sends its stubs whole again (the originals stay in the session, so
21
- # turning it back on brings them back).
22
+ # turning it back on brings them back). The two that restart the prompt
23
+ # cache (forget on or off, stubs sent whole again) ask first
24
+ # (CacheConfirm, #cache_cost); --yes (or -y) applies them.
22
25
  class LLMContextCommand
23
26
  NAME = LLMContextOverride::COMMAND
24
27
  USAGE = "usage: #{NAME} [strategy <none|stale,forget|default>] [apply <payoff|next_request|turn_end|default>] " \
@@ -33,20 +36,49 @@ module Samagotchi
33
36
  @save = save
34
37
  end
35
38
 
36
- # @param args [String] what follows /llm-context
37
- # @return [Array(String, Boolean)] the reply and whether the values changed
39
+ # @param args [String] what follows /llm-context (--yes anywhere)
40
+ # @param confirm [Boolean] ask before a change that restarts the prompt
41
+ # cache (CacheConfirm, #cache_cost); false applies it at once (chi
42
+ # send, a delegate, a start flag)
43
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
44
+ # whether the values changed, and the confirm it asks for (nothing
45
+ # changed then)
38
46
  # @raise [ArgumentError] a line that isn't one (its message and USAGE)
39
- def run(args)
40
- args = args.to_s.strip
41
- return [listing(@engine.llm_context_explained), false] if args.empty?
47
+ def run(args, confirm: true)
48
+ # Before #words: it refuses --yes, and "reset --yes" isn't RESET.
49
+ args, yes = CacheConfirm.split(args, downcase: false)
50
+ return [listing(@engine.llm_context_explained), false, nil] if args.empty?
42
51
 
43
52
  session = @engine.session or raise ArgumentError, "no session yet"
44
53
  before = @engine.llm_context_explained
45
54
  updated = args.casecmp?(RESET) ? LLMContextOverride.new : LLMContextOverride.update(@engine.llm_context_override, words(args))
55
+ ask = confirm && !yes ? ask(session, before, updated, args) : nil
56
+ return ask if ask
57
+
46
58
  @engine.llm_context_override = updated
47
59
  @save.call(session)
48
60
  after = @engine.llm_context_explained
49
- [[listing(after), *notes(session, before&.resolved, after&.resolved)].join("\n"), true]
61
+ [[listing(after), *notes(session, before&.resolved, after&.resolved)].join("\n"), true, nil]
62
+ end
63
+
64
+ # What a change from +before+ to +after+ (Resolved) costs the prompt
65
+ # cache, nil for nothing worth asking about: forget on or off changes
66
+ # the tool list and the system prompt (:full); a layer turned off while
67
+ # it has applied stubs sends them whole again (:history). Stale turned
68
+ # on only stages a batch under the apply rule (its break comes later,
69
+ # as designed), and the apply rule and budget only decide when a break
70
+ # comes: a switch to next_request with staged edits pending applies
71
+ # them at the next request, a break the old rule makes by the end of
72
+ # the next turn anyway.
73
+ # @return [Symbol, nil]
74
+ def cache_cost(session, before, after)
75
+ return nil unless before && after
76
+
77
+ was = before.active_layers
78
+ now = after.active_layers
79
+ return :full if was.include?(:forget) != now.include?(:forget)
80
+
81
+ :history if (was - now).any? { |layer| applied_count(session.messages, layer).positive? }
50
82
  end
51
83
 
52
84
  # The values, where each came from, and what the session sets itself.
@@ -66,6 +98,20 @@ module Samagotchi
66
98
 
67
99
  private
68
100
 
101
+ # The reply that asks before a costly change (CacheConfirm), nil when
102
+ # it needn't.
103
+ def ask(session, before, updated, args)
104
+ would = @engine.llm_context_explained(own: updated)
105
+ was = before&.resolved
106
+ now = would&.resolved
107
+ cost = cache_cost(session, was, now)
108
+ step = CacheConfirm.ask(line: "#{NAME} #{args}", cost: cost, changed: !cost.nil?, engine: @engine)
109
+ return nil unless step
110
+
111
+ costs = notes(session, was, now).drop(1).reject { |note| note.start_with?("stale:") }
112
+ [[listing(before), "This change restarts the prompt cache:", *costs, step.text].join("\n"), false, step]
113
+ end
114
+
69
115
  # "strategy stale stale forget apply turn_end budget 64k" as fields
70
116
  # and their words; a strategy takes the words up to the next field.
71
117
  def words(args)
@@ -55,7 +55,8 @@ module Samagotchi
55
55
  COMMAND_WORDS = { strategy: "strategy", apply: "apply", budget_tokens: "budget" }.freeze
56
56
 
57
57
  # The /llm-context line that sets +words+ (field => value as typed,
58
- # checked by .update first): what a flag on an existing session runs.
58
+ # checked by .update first): what a flag on an existing session runs,
59
+ # with --yes (typing the flag is the choice: no confirm step).
59
60
  # @return [String]
60
61
  def self.command_line(words)
61
62
  parts = words.map do |field, word|
@@ -63,7 +64,7 @@ module Samagotchi
63
64
  word = parse_strategy(word).then { |layers| layers.empty? ? "none" : layers.join(",") } if field == :strategy && !word.casecmp?(DEFAULT_WORD)
64
65
  "#{COMMAND_WORDS.fetch(field)} #{word}"
65
66
  end
66
- [COMMAND, *parts].join(" ")
67
+ [COMMAND, *parts, "--yes"].join(" ")
67
68
  end
68
69
 
69
70
  # A session file's "llm_context" (or an override already): nil when
@@ -7,6 +7,7 @@ require_relative "../commands/registry"
7
7
  require_relative "service"
8
8
  require_relative "tool_result"
9
9
  require_relative "tool_spec"
10
+ require_relative "prompt_sections"
10
11
 
11
12
  module Samagotchi
12
13
  module Plugin
@@ -45,7 +46,9 @@ module Samagotchi
45
46
  "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
46
47
  "ctx.usage" => "0.100.0", "ctx.sessions.stop" => "0.34.0", "ctx.turn_author" => "0.106.0",
47
48
  "ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
48
- "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0"
49
+ "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0",
50
+ "chi.prompt_section" => "0.132.0", "chi.prompt_changed" => "0.132.0", "ctx.cache_confirm" => "0.132.0",
51
+ "ctx.model_names" => "0.132.0"
49
52
  }.freeze
50
53
 
51
54
  # @param bundle [String]
@@ -65,6 +68,7 @@ module Samagotchi
65
68
  @services = []
66
69
  @inits = []
67
70
  @toolkits = []
71
+ @prompt_sections = []
68
72
  @peer_sources = []
69
73
  @tool_source = nil
70
74
  @committed = false
@@ -279,6 +283,42 @@ module Samagotchi
279
283
  nil
280
284
  end
281
285
 
286
+ # Text this session's system prompt carries (docs/plugins.md, Prompt
287
+ # sections): the block gets the Context and returns a String (at most
288
+ # PromptSections::MAX characters) or nil for none. It runs when the
289
+ # prompt is built (a session's first turn, after /model or a
290
+ # #prompt_changed!), never per request, on whatever thread builds:
291
+ # keep it quick (no I/O beyond reading a small file) and give the same
292
+ # text for the same inputs. A raise, a non-String or text over the
293
+ # limit keeps the section's last good text (one warn).
294
+ # @param name [String] a-z, 0-9, _ and -; unique in the bundle
295
+ def prompt_section(name, &block)
296
+ raise ArgumentError, "prompt_section #{name.inspect} needs a block" unless block
297
+
298
+ name = name.to_s
299
+ unless name.match?(PromptSections::NAME)
300
+ raise ArgumentError, "prompt section name #{name.inspect} must be a-z, 0-9, _ and - (at most 32)"
301
+ end
302
+ raise ArgumentError, "this chi has no prompt sections (plugins: false)" unless @registries.prompt_sections
303
+ if @prompt_sections.any? { |item| item[:name] == name } || @registries.prompt_sections.registered?(@bundle, name)
304
+ raise ArgumentError, "prompt section #{name} is registered twice"
305
+ end
306
+
307
+ @prompt_sections << { name: name, block: block }
308
+ nil
309
+ end
310
+
311
+ # Say this session's prompt sections would now give other text (a
312
+ # setting the plugin keeps changed): the system prompt is built again
313
+ # at its next build (the next turn's start; a running turn keeps the
314
+ # prompt it started with). That restarts the model server's prompt
315
+ # cache, so a command that changes a section asks first
316
+ # (Context#cache_confirm) and calls this only once it applies.
317
+ def prompt_changed!
318
+ @registries.prompt_changed&.call
319
+ nil
320
+ end
321
+
282
322
  # Publish tools into the session's tool index instead of declaring
283
323
  # them one by one (docs/plugins.md, "Publishing tools to the index"):
284
324
  # the model searches them with find_tools and calls one with
@@ -393,6 +433,9 @@ module Samagotchi
393
433
  end
394
434
  end
395
435
  @registries.toolkits&.concat(@toolkits)
436
+ @prompt_sections.each do |item|
437
+ @registries.prompt_sections.add(bundle: @bundle, name: item[:name], context: context, &item[:block])
438
+ end
396
439
  @registries.tool_index.add(@tool_source) if @tool_source
397
440
  @peer_sources.each { |item| @registries.peer_sources.declare(item[:name], label: item[:label], bundle: @bundle) }
398
441
  @committed = true
@@ -401,6 +444,7 @@ module Samagotchi
401
444
  # @return [Hash] how many of each it registered (for the log)
402
445
  def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
403
446
  services: @services.size, inits: @inits.size, toolkits: @toolkits.size,
447
+ prompt_sections: @prompt_sections.size,
404
448
  tool_sources: @tool_source ? 1 : 0, peer_sources: @peer_sources.size }
405
449
 
406
450
  # Each annotation's command is one #command staged.