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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/bin/chi +19 -1
- data/docs/architecture.md +59 -1
- data/docs/cli.md +29 -8
- data/docs/configuration.md +115 -16
- data/docs/desktop.md +16 -6
- data/docs/guardrails.md +10 -4
- data/docs/internals/anthropic.md +3 -2
- data/docs/internals/engine.md +4 -4
- data/docs/internals/prompt-caching.md +26 -6
- data/docs/memory.md +16 -2
- data/docs/plugins.md +68 -1
- data/docs/sessions.md +60 -6
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +35 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
- data/lib/samagotchi/bundles/model-notes/manifest.yml +4 -1
- data/lib/samagotchi/bundles/model-notes/model_notes_tone.md +3 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +14 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cache_confirm.rb +18 -0
- data/lib/samagotchi/chi_home.rb +311 -0
- data/lib/samagotchi/chi_shim.rb +77 -0
- data/lib/samagotchi/child_env.rb +5 -3
- data/lib/samagotchi/config.rb +2 -0
- data/lib/samagotchi/context_fetch.rb +4 -2
- data/lib/samagotchi/desktop/macos.rb +100 -18
- data/lib/samagotchi/engine.rb +35 -4
- data/lib/samagotchi/engine_snapshots.rb +5 -0
- data/lib/samagotchi/gem_update.rb +19 -10
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
- data/lib/samagotchi/guardrails/pair_command.rb +57 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +6 -3
- data/lib/samagotchi/guardrails/review_paths.rb +7 -4
- data/lib/samagotchi/guardrails/verdict.rb +1 -1
- data/lib/samagotchi/home_command.rb +145 -0
- data/lib/samagotchi/home_doctor.rb +177 -0
- data/lib/samagotchi/home_notice.rb +80 -0
- data/lib/samagotchi/installed_versions.rb +5 -3
- data/lib/samagotchi/llm_context_command.rb +53 -7
- data/lib/samagotchi/llm_context_override.rb +3 -2
- data/lib/samagotchi/plugin/api.rb +45 -1
- data/lib/samagotchi/plugin/context.rb +34 -1
- data/lib/samagotchi/plugin/loader.rb +5 -2
- data/lib/samagotchi/plugin/prompt_sections.rb +124 -0
- data/lib/samagotchi/project_env.rb +360 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/self_report.rb +33 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +68 -19
- data/lib/samagotchi/session_manager.rb +54 -1
- data/lib/samagotchi/session_model_settings.rb +5 -2
- data/lib/samagotchi/system_prompt.rb +34 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/testing/boundary.rb +3 -1
- data/lib/samagotchi/testing/bundle.rb +22 -3
- data/lib/samagotchi/testing/host.rb +22 -1
- data/lib/samagotchi/testing/recorder.rb +6 -1
- data/lib/samagotchi/testing/recording_api.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +13 -2
- data/lib/samagotchi/update_command.rb +65 -7
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +61 -1
- data/lib/samagotchi/web/public/app.js +4 -1
- data/lib/samagotchi/web/public/chip_popover.js +5 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/index.html +52 -10
- data/lib/samagotchi/web/public/level_chip.js +22 -38
- data/lib/samagotchi/web/public/llm_context_chip.js +30 -73
- data/lib/samagotchi/web/public/model_pick.js +26 -0
- data/lib/samagotchi/web/public/model_picker.js +3 -1
- data/lib/samagotchi/web/public/review_start.js +13 -57
- data/lib/samagotchi/web/public/send_turn.js +3 -2
- data/lib/samagotchi/web/public/start_page.js +131 -99
- data/lib/samagotchi/web/public/start_settings.js +298 -0
- data/lib/samagotchi/web/public/start_toolkit.js +157 -0
- data/lib/samagotchi/web/public/thinking_chip.js +9 -22
- data/lib/samagotchi/web/server.rb +27 -8
- data/lib/samagotchi/web/update_check.rb +3 -2
- 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
|
|
12
|
-
#
|
|
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: -> {
|
|
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
|
-
# @
|
|
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
|
-
|
|
41
|
-
|
|
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.
|