samagotchi 0.100.1 → 0.102.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/bin/chi +14 -10
- data/docs/architecture.md +10 -2
- data/docs/cli.md +100 -5
- data/docs/configuration.md +26 -4
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -0
- data/docs/internals/llm-context-forget.md +14 -0
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/working-set.md +205 -0
- data/docs/plugins.md +6 -3
- data/docs/sessions.md +28 -11
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +9 -3
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +92 -53
- data/lib/samagotchi/engine.rb +153 -4
- data/lib/samagotchi/host_keys.rb +35 -18
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +12 -3
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/send_command.rb +14 -11
- data/lib/samagotchi/session.rb +19 -12
- data/lib/samagotchi/session_commands.rb +44 -2
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_setup.rb +9 -9
- data/lib/samagotchi/sessions_command.rb +6 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/testing/fake_executable.rb +6 -16
- data/lib/samagotchi/testing/fake_executable_launcher +6 -0
- data/lib/samagotchi/tool_response.rb +10 -6
- data/lib/samagotchi/tool_runner.rb +25 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +76 -11
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/chip_popover.js +56 -17
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +7 -0
- data/lib/samagotchi/web/public/index.html +145 -1
- data/lib/samagotchi/web/public/info_bar.js +16 -4
- data/lib/samagotchi/web/public/info_bar_model.js +7 -3
- data/lib/samagotchi/web/public/refs.js +10 -1
- data/lib/samagotchi/web/public/stage_model.js +6 -5
- data/lib/samagotchi/web/public/stage_view.js +34 -2
- data/lib/samagotchi/web/public/stream_handlers.js +10 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/turn_view.js +16 -4
- data/lib/samagotchi/web/public/working_set.js +612 -0
- data/lib/samagotchi/web/public/working_set_rail.js +278 -0
- data/lib/samagotchi/web/public/working_set_view.js +541 -0
- data/lib/samagotchi/web/server.rb +6 -4
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/working_set.rb +238 -0
- data/lib/samagotchi/working_set_command.rb +119 -0
- data/lib/samagotchi/working_set_steer.rb +250 -0
- metadata +11 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require_relative "version"
|
|
5
|
-
|
|
6
|
-
module Samagotchi
|
|
7
|
-
# A session's own bundle settings (chi --set BUNDLE.KEY=VALUE, chi send
|
|
8
|
-
# --new --set, POST /api/sessions bundle_settings): {bundle => {key =>
|
|
9
|
-
# value}}, string keys, scalar values (.typed). A bundle offers the keys a
|
|
10
|
-
# session may set in its manifest's session_settings: (recorded at install,
|
|
11
|
-
# InstalledBundle#session_settings); any other bundle or key is refused, so
|
|
12
|
-
# a typo isn't saved, and so is a value of another type than the manifest
|
|
13
|
-
# gives the key (session_settings: {cap: number}). The session keeps them
|
|
14
|
-
# (Session#bundle_settings, SessionSetup) and Plugin::Context#settings
|
|
15
|
-
# lays them over config.yml's bundles: <name>:.
|
|
16
|
-
module BundleSessionSettings
|
|
17
|
-
# A refused --set (or request field): the message says why.
|
|
18
|
-
class Error < ArgumentError; end
|
|
19
|
-
|
|
20
|
-
# A bundle name (MemoryBundle::Manifest::BUNDLE_NAME) and a setting key.
|
|
21
|
-
BUNDLE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
22
|
-
KEY = /\A[A-Za-z0-9_][A-Za-z0-9_-]*\z/
|
|
23
|
-
# The values a setting may take.
|
|
24
|
-
SCALARS = [String, Integer, Float, TrueClass, FalseClass].freeze
|
|
25
|
-
REQUEST_SHAPE = "bundle_settings is an object of bundle => {key => scalar}"
|
|
26
|
-
# What .typed reads as a number: no leading zero, no _; a decimal may
|
|
27
|
-
# leave out either side of its point (.5, 5.) and take an exponent
|
|
28
|
-
# (1e1, 2.5e-3), which YAML 1.1 leaves as text.
|
|
29
|
-
INTEGER = /\A-?(?:0|[1-9]\d*)\z/
|
|
30
|
-
DECIMAL = /\A-?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?\z/
|
|
31
|
-
# YAML 1.1's booleans (config.yml's), any case.
|
|
32
|
-
TRUE_WORDS = %w[true yes on].freeze
|
|
33
|
-
FALSE_WORDS = %w[false no off].freeze
|
|
34
|
-
# The shipped bundles' sources (a stale record's hint reads them).
|
|
35
|
-
SHIPPED_DIR = File.expand_path("bundles", __dir__)
|
|
36
|
-
|
|
37
|
-
# The installed bundles a session may set settings of: +offered+ the
|
|
38
|
-
# keys of each bundle whose plugin loads ({name => keys}), +notes+ what
|
|
39
|
-
# a refusal adds for a bundle ({name => text}): why its plugin doesn't
|
|
40
|
-
# load, or how to refresh a record from before session_settings;
|
|
41
|
-
# +types+ the types its manifest gives keys ({name => {key => type}}).
|
|
42
|
-
Availability = Data.define(:offered, :notes, :types) do
|
|
43
|
-
def initialize(offered:, notes:, types: {}) = super
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# What each type (MemoryBundle::Manifest::SESSION_SETTING_TYPES) takes,
|
|
47
|
-
# as a value .typed gives it, and how a refusal names it. A number may
|
|
48
|
-
# be off (false), as a bundle's own settings are turned off.
|
|
49
|
-
TYPES = {
|
|
50
|
-
"number" => [->(value) { (value.is_a?(Numeric) && value.finite?) || value == false }, "a number (or off)"],
|
|
51
|
-
"boolean" => [->(value) { [true, false].include?(value) }, "true or false"],
|
|
52
|
-
"string" => [->(value) { value.is_a?(String) }, "text"]
|
|
53
|
-
}.freeze
|
|
54
|
-
|
|
55
|
-
module_function
|
|
56
|
-
|
|
57
|
-
# --set's values as typed, in order: a later one of the same key wins.
|
|
58
|
-
# A key +types+ gives as string keeps its text as given (.value_for).
|
|
59
|
-
# @param args [Array<String>] "BUNDLE.KEY=VALUE" each
|
|
60
|
-
# @param types [Hash{String => Hash{String => String}}] Availability#types
|
|
61
|
-
# @return [Hash{String => Hash{String => Object}}] frozen
|
|
62
|
-
# @raise [Error] a value of another shape
|
|
63
|
-
def parse(args, types: {})
|
|
64
|
-
settings = {}
|
|
65
|
-
Array(args).each do |arg|
|
|
66
|
-
target, sep, raw = arg.to_s.partition("=")
|
|
67
|
-
bundle, *keys = target.split(".", -1)
|
|
68
|
-
if sep.empty? || keys.empty? || !BUNDLE.match?(bundle.to_s) || keys.any? { |key| !KEY.match?(key) }
|
|
69
|
-
raise Error, "--set takes BUNDLE.KEY=VALUE, not #{arg}"
|
|
70
|
-
end
|
|
71
|
-
raise Error, "--set #{arg}: one key under the bundle (BUNDLE.KEY), no nested path" if keys.size > 1
|
|
72
|
-
raise Error, "--set #{arg}: give a value" if raw.strip.empty?
|
|
73
|
-
|
|
74
|
-
(settings[bundle] ||= {})[keys.first] = value_for(raw, types.dig(bundle, keys.first))
|
|
75
|
-
end
|
|
76
|
-
deep_freeze(settings)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# +settings+ when every bundle is installed and offers each key.
|
|
80
|
-
# @param bundles [Hash{String => Array<String>}] the installed bundles'
|
|
81
|
-
# offered keys (.offered)
|
|
82
|
-
# @param notes [Hash{String => String}] Availability#notes
|
|
83
|
-
# @param types [Hash{String => Hash{String => String}}] Availability#types:
|
|
84
|
-
# a typed key's value must be of its type (TYPES)
|
|
85
|
-
# @param flag [String] what a refusal names ("--set", a request field)
|
|
86
|
-
# @return [Hash] +settings+
|
|
87
|
-
# @raise [Error]
|
|
88
|
-
def check!(settings, bundles: offered, notes: {}, types: {}, flag: "--set")
|
|
89
|
-
settings.each do |bundle, values|
|
|
90
|
-
keys = bundles[bundle]
|
|
91
|
-
note = notes[bundle]
|
|
92
|
-
values.each do |key, value|
|
|
93
|
-
where = "#{flag} #{bundle}.#{key}"
|
|
94
|
-
raise Error, "#{where}: #{note || "no installed bundle #{bundle} (#{takers(bundles)})"}" if keys.nil?
|
|
95
|
-
raise Error, "#{where}: #{bundle} takes no session settings#{" #{note}" if note}" if keys.empty?
|
|
96
|
-
raise Error, "#{where}: #{bundle} takes #{keys.join(", ")}" unless keys.include?(key)
|
|
97
|
-
|
|
98
|
-
takes, what = TYPES[types.dig(bundle, key)]
|
|
99
|
-
raise Error, "#{where}: #{bundle} takes #{what} for #{key}, not #{value.inspect}" if takes && !takes.call(value)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
settings
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# --set's values parsed and checked (.parse, .check!) against the
|
|
106
|
-
# installed bundles (.availability).
|
|
107
|
-
def from_flags(args, available: availability)
|
|
108
|
-
check!(parse(args, types: available.types), bundles: available.offered, notes: available.notes, types: available.types)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# A value as --set (and a request's string) gives it: an integer
|
|
112
|
-
# ("3", "-1"), a decimal ("2.5", ".5", "1e1"), a boolean as config.yml's
|
|
113
|
-
# YAML reads one (true/yes/on, false/no/off, any case); any other string
|
|
114
|
-
# stays as given ("010", "0x10", "3:30", "y", "null"). A value that
|
|
115
|
-
# isn't a String (a JSON number or boolean) is kept.
|
|
116
|
-
def typed(value)
|
|
117
|
-
return value unless value.is_a?(String)
|
|
118
|
-
return Integer(value, 10) if INTEGER.match?(value)
|
|
119
|
-
|
|
120
|
-
if DECIMAL.match?(value)
|
|
121
|
-
number = Float(float_text(value))
|
|
122
|
-
# 1e400 is Infinity: no cap reaches it and JSON can't save it.
|
|
123
|
-
return number.finite? ? number : value
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
word = value.downcase
|
|
127
|
-
return true if TRUE_WORDS.include?(word)
|
|
128
|
-
return false if FALSE_WORDS.include?(word)
|
|
129
|
-
|
|
130
|
-
value
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# A value for a key of +type+: a string key's text as given (--set
|
|
134
|
-
# b.name=yes stays "yes"), any other's .typed.
|
|
135
|
-
def value_for(value, type) = type == "string" ? value : typed(value)
|
|
136
|
-
|
|
137
|
-
# A decimal DECIMAL matches as Float reads it on every Ruby: a point
|
|
138
|
-
# with no digit after it ("5.", "5.e1") gets a 0, which Ruby 3.3's
|
|
139
|
-
# Float wants.
|
|
140
|
-
def float_text(value) = value.sub(/\.(?=e|\z)/i, ".0")
|
|
141
|
-
|
|
142
|
-
# POST /api/sessions bundle_settings: nil for none, else an object of
|
|
143
|
-
# objects of scalars (a string typed as --set types it), checked like
|
|
144
|
-
# --set; an empty bundle entry is dropped.
|
|
145
|
-
# @param bundles [Hash, nil] the offered keys (.availability's by default)
|
|
146
|
-
# @param types [Hash] their types, with +bundles+ (Availability#types)
|
|
147
|
-
# @return [Hash] frozen
|
|
148
|
-
# @raise [Error]
|
|
149
|
-
def from_request(raw, bundles: nil, types: {})
|
|
150
|
-
return {} if raw.nil?
|
|
151
|
-
raise Error, REQUEST_SHAPE unless raw.is_a?(Hash) && raw.all? { |_, values| scalar_map?(values) }
|
|
152
|
-
|
|
153
|
-
given = normalize(raw)
|
|
154
|
-
return given if given.empty?
|
|
155
|
-
|
|
156
|
-
available = bundles ? Availability.new(offered: bundles, notes: {}, types: types) : availability
|
|
157
|
-
settings = normalize(given.to_h do |bundle, values|
|
|
158
|
-
[bundle, values.to_h { |key, value| [key, value_for(value, available.types.dig(bundle, key))] }]
|
|
159
|
-
end)
|
|
160
|
-
check!(settings, bundles: available.offered, notes: available.notes, types: available.types, flag: "bundle_settings")
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Each installed bundle's offered keys, by name (Availability#offered).
|
|
164
|
-
# @return [Hash{String => Array<String>}]
|
|
165
|
-
def offered(dir: nil) = availability(dir: dir).offered
|
|
166
|
-
|
|
167
|
-
# The installed bundles a session may set settings of: a record that
|
|
168
|
-
# doesn't read is left out, and so is one whose plugin doesn't load as
|
|
169
|
-
# chi's loader decides (Plugin::Loader#unloadable_reason: requires_chi,
|
|
170
|
-
# the file, its sha256), with a note saying why. A record with no
|
|
171
|
-
# session_settings whose shipped bundle lists some (installed before
|
|
172
|
-
# them) gets a note on refreshing it.
|
|
173
|
-
# @return [Availability]
|
|
174
|
-
def availability(dir: nil, shipped_dir: SHIPPED_DIR)
|
|
175
|
-
require_relative "memory_bundle/provenance"
|
|
176
|
-
require_relative "memory_bundle/manifest"
|
|
177
|
-
dir ||= MemoryBundle::Provenance.bundles_dir
|
|
178
|
-
offered = {}
|
|
179
|
-
notes = {}
|
|
180
|
-
types = {}
|
|
181
|
-
MemoryBundle::Provenance.each_installed(dir: dir) do |name, bundle|
|
|
182
|
-
next if bundle.error?
|
|
183
|
-
|
|
184
|
-
if (reason = unloadable(dir, name, bundle))
|
|
185
|
-
notes[name] = "#{name} is installed but its plugin doesn't load: #{reason}"
|
|
186
|
-
next
|
|
187
|
-
end
|
|
188
|
-
offered[name] = bundle.session_settings
|
|
189
|
-
types[name] = bundle.session_setting_types unless bundle.session_setting_types.empty?
|
|
190
|
-
if bundle.session_settings.empty? && shipped_settings?(shipped_dir, name)
|
|
191
|
-
notes[name] = "(run `chi update` or `chi bundle upgrade #{name}` to refresh it)"
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
Availability.new(offered: offered, notes: notes, types: types)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# A session file's value, read tolerantly: the bundles that map to
|
|
198
|
-
# settings, the scalar ones of those, string keys, frozen; {} for
|
|
199
|
-
# anything else.
|
|
200
|
-
# @return [Hash{String => Hash{String => Object}}]
|
|
201
|
-
def normalize(raw)
|
|
202
|
-
return {}.freeze unless raw.is_a?(Hash)
|
|
203
|
-
|
|
204
|
-
deep_freeze(raw.each_with_object({}) do |(bundle, values), acc|
|
|
205
|
-
next unless values.is_a?(Hash)
|
|
206
|
-
|
|
207
|
-
kept = values.each_with_object({}) do |(key, value), scalars|
|
|
208
|
-
scalars[key.to_s] = value if SCALARS.any? { |kind| value.is_a?(kind) }
|
|
209
|
-
end
|
|
210
|
-
acc[bundle.to_s] = kept unless kept.empty?
|
|
211
|
-
end)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
# Why +bundle+'s plugin doesn't load, as chi's loader decides it
|
|
215
|
-
# (Plugin::Loader.unloadable_reason), nil when it does or has none.
|
|
216
|
-
def unloadable(dir, name, bundle)
|
|
217
|
-
return nil unless bundle.plugin
|
|
218
|
-
|
|
219
|
-
require_relative "plugin/loader"
|
|
220
|
-
# +dir+, not Provenance#plugin_path's bundles_dir: a spec's records live elsewhere.
|
|
221
|
-
Plugin::Loader.unloadable_reason(File.join(dir, name, "plugin", bundle.plugin.file.to_s), bundle)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Whether chi ships a bundle +name+ whose manifest lists session_settings.
|
|
225
|
-
def shipped_settings?(shipped_dir, name)
|
|
226
|
-
path = File.join(shipped_dir, name, "manifest.yml")
|
|
227
|
-
return false unless File.file?(path)
|
|
228
|
-
|
|
229
|
-
raw = YAML.safe_load_file(path)
|
|
230
|
-
listed = raw.is_a?(Hash) ? raw["session_settings"] : nil
|
|
231
|
-
(listed.is_a?(Array) || listed.is_a?(Hash)) && !listed.empty?
|
|
232
|
-
rescue Psych::Exception, SystemCallError
|
|
233
|
-
false
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def scalar_map?(values)
|
|
237
|
-
values.is_a?(Hash) && values.values.all? { |value| SCALARS.any? { |kind| value.is_a?(kind) } }
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def takers(bundles)
|
|
241
|
-
names = bundles.reject { |_, keys| keys.empty? }.keys.sort
|
|
242
|
-
names.empty? ? "no installed bundle takes --set" : "bundles that take --set: #{names.join(", ")}"
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def deep_freeze(settings)
|
|
246
|
-
settings.to_h { |bundle, values| [bundle.dup.freeze, values.to_h { |k, v| [k.dup.freeze, v.frozen? ? v : v.dup.freeze] }.freeze] }.freeze
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
private_class_method :unloadable, :shipped_settings?, :scalar_map?, :takers, :deep_freeze
|
|
250
|
-
end
|
|
251
|
-
end
|