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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +14 -10
  4. data/docs/architecture.md +10 -2
  5. data/docs/cli.md +100 -5
  6. data/docs/configuration.md +26 -4
  7. data/docs/internals/bundles.md +1 -1
  8. data/docs/internals/engine.md +1 -0
  9. data/docs/internals/llm-context-forget.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/internals/working-set.md +205 -0
  12. data/docs/plugins.md +6 -3
  13. data/docs/sessions.md +28 -11
  14. data/docs/testing.md +8 -1
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
  16. data/lib/samagotchi/bridge.rb +9 -3
  17. data/lib/samagotchi/bundle_command/check.rb +5 -1
  18. data/lib/samagotchi/bundle_command/try/command.rb +6 -6
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
  20. data/lib/samagotchi/bundle_command.rb +4 -4
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/commands/registry.rb +12 -1
  23. data/lib/samagotchi/config.rb +92 -53
  24. data/lib/samagotchi/engine.rb +153 -4
  25. data/lib/samagotchi/host_keys.rb +35 -18
  26. data/lib/samagotchi/idle_scheduler.rb +16 -1
  27. data/lib/samagotchi/llm_context_edit.rb +32 -10
  28. data/lib/samagotchi/llm_context_forget.rb +12 -3
  29. data/lib/samagotchi/llm_context_view.rb +19 -7
  30. data/lib/samagotchi/memory_bundle/installer.rb +2 -0
  31. data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
  32. data/lib/samagotchi/message_parts.rb +8 -3
  33. data/lib/samagotchi/plugin/context.rb +2 -2
  34. data/lib/samagotchi/prompt.rb +1 -1
  35. data/lib/samagotchi/recap_command.rb +68 -0
  36. data/lib/samagotchi/recap_setup.rb +27 -14
  37. data/lib/samagotchi/send_command.rb +14 -11
  38. data/lib/samagotchi/session.rb +19 -12
  39. data/lib/samagotchi/session_commands.rb +44 -2
  40. data/lib/samagotchi/session_config.rb +411 -0
  41. data/lib/samagotchi/session_setup.rb +9 -9
  42. data/lib/samagotchi/sessions_command.rb +6 -2
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  45. data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
  46. data/lib/samagotchi/terminal_ui.rb +8 -6
  47. data/lib/samagotchi/testing/bundle.rb +3 -2
  48. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  49. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  50. data/lib/samagotchi/tool_response.rb +10 -6
  51. data/lib/samagotchi/tool_runner.rb +25 -6
  52. data/lib/samagotchi/version.rb +1 -1
  53. data/lib/samagotchi/web/app.rb +76 -11
  54. data/lib/samagotchi/web/public/app.js +35 -3
  55. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  56. data/lib/samagotchi/web/public/commands.js +8 -2
  57. data/lib/samagotchi/web/public/data.js +7 -0
  58. data/lib/samagotchi/web/public/index.html +145 -1
  59. data/lib/samagotchi/web/public/info_bar.js +16 -4
  60. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  61. data/lib/samagotchi/web/public/refs.js +10 -1
  62. data/lib/samagotchi/web/public/stage_model.js +6 -5
  63. data/lib/samagotchi/web/public/stage_view.js +34 -2
  64. data/lib/samagotchi/web/public/stream_handlers.js +10 -1
  65. data/lib/samagotchi/web/public/turn_html.js +5 -0
  66. data/lib/samagotchi/web/public/turn_view.js +16 -4
  67. data/lib/samagotchi/web/public/working_set.js +612 -0
  68. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  69. data/lib/samagotchi/web/public/working_set_view.js +541 -0
  70. data/lib/samagotchi/web/server.rb +6 -4
  71. data/lib/samagotchi/worker.rb +9 -6
  72. data/lib/samagotchi/working_set.rb +238 -0
  73. data/lib/samagotchi/working_set_command.rb +119 -0
  74. data/lib/samagotchi/working_set_steer.rb +250 -0
  75. metadata +11 -2
  76. 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