samagotchi 0.53.0 → 0.55.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 +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+ require_relative "bundle_hook"
6
+ require_relative "manifest"
7
+ require_relative "provenance"
8
+ require_relative "../version"
9
+
10
+ module Samagotchi
11
+ module MemoryBundle
12
+ autoload :Installer, File.expand_path("installer", __dir__)
13
+
14
+ # The install of a bundle's parts that live in its own dir
15
+ # (<bundles>/<name>/, Provenance#bundle_dir) rather than in the
16
+ # memories dir: hooks/*.rb, guardrails/*.yml, the plugin file and
17
+ # scripts/*. Each replaces what an earlier version installed. Installer
18
+ # runs it after the memories; it adds to the installer's results and
19
+ # warnings, and #run returns what the record (Provenance#write) keeps.
20
+ class AssetInstaller
21
+ # What was installed: the paths Provenance#write records (each
22
+ # empty / nil on a dry run).
23
+ # hooks every hook basename the bundle ships
24
+ # hook_files, guardrail_files, script_files
25
+ # basename => installed path
26
+ # plugin_file the installed plugin file, nil without one
27
+ Installed = Data.define(:hooks, :hook_files, :guardrail_files, :plugin_file, :script_files)
28
+
29
+ # @param source_dir [String] the normalized bundle source
30
+ # @param manifest [Manifest, nil] nil for a non-strict install without one
31
+ # @param existing [InstalledBundle, nil] the record before this install
32
+ # @param results, warnings, conflicts the Installer's (results and
33
+ # warnings are added to)
34
+ def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
35
+ force: false, strict: true, upgrade: false, dry_run: false)
36
+ @name = name
37
+ @bundle_dir = source_dir
38
+ @manifest = manifest
39
+ @provenance = provenance
40
+ @existing = existing
41
+ @results = results
42
+ @warnings = warnings
43
+ @conflicts = conflicts
44
+ @force = force
45
+ @strict = strict
46
+ @upgrade = upgrade
47
+ @dry_run = dry_run
48
+ end
49
+
50
+ # @return [Installed]
51
+ # @raise [Installer::InstallError] the manifest names a plugin or
52
+ # script the bundle doesn't have
53
+ def run
54
+ install_hooks
55
+ prune_dropped_hooks if @upgrade && @existing && !@dry_run
56
+ verify_hook_checksums if @manifest && @strict
57
+ incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
58
+ guardrail_files = install_guardrails(incoming_rules)
59
+ plugin_file = install_plugin
60
+ script_files = install_scripts
61
+ warn_hooks_requires_chi
62
+ warn_rules_requires_chi(incoming_rules)
63
+ Installed.new(hooks: @bundle_hooks, hook_files: @hook_files, guardrail_files: guardrail_files,
64
+ plugin_file: plugin_file, script_files: script_files)
65
+ end
66
+
67
+ private
68
+
69
+ # Copies the bundle's hooks/*.rb into <bundle_dir>/hooks/: the ones
70
+ # its manifest lists, else (no hooks: in it) every hooks/*.rb, which is
71
+ # then added to manifest.hooks with no event (the loader skips it, the
72
+ # record keeps it). Without a manifest every hooks/*.rb is copied.
73
+ # Sets @bundle_hooks (every hook the bundle ships) and @hook_files
74
+ # (basename => installed path, for the record).
75
+ def install_hooks
76
+ @bundle_hooks = []
77
+ @hook_files = {}
78
+ return install_unlisted_hooks unless @manifest
79
+
80
+ hook_entries = @manifest.hooks
81
+ if hook_entries.empty?
82
+ Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each { |p| hook_entries[File.basename(p)] = BundleHook.new }
83
+ end
84
+ hook_entries.each_key do |basename|
85
+ next unless basename.is_a?(String) && !basename.empty?
86
+
87
+ @bundle_hooks << basename
88
+ src = File.join(@bundle_dir, "hooks", basename)
89
+ install_hook(basename, src) if File.exist?(src)
90
+ end
91
+ end
92
+
93
+ # One listed hook; an upgrade overwrites a local edit with a warning.
94
+ def install_hook(basename, src)
95
+ dest = File.join(@provenance.hooks_dir, basename)
96
+ if @dry_run
97
+ @results[basename] = { status: File.exist?(dest) && !@force ? "would_skip" : "would_install" }
98
+ return
99
+ end
100
+
101
+ prev_sha = @existing&.hooks&.dig(basename)&.sha256
102
+ if @upgrade && File.exist?(dest) && !@force && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
103
+ @warnings << "Hook #{basename} was locally modified; overwriting"
104
+ end
105
+
106
+ FileUtils.mkdir_p(@provenance.hooks_dir)
107
+ unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
108
+ FileUtils.cp(src, dest)
109
+ @results[basename] = if unchanged
110
+ { status: "skipped", reason: "already up to date" }
111
+ elsif @upgrade && @existing
112
+ { status: "updated" }
113
+ else
114
+ { status: "installed" }
115
+ end
116
+ @hook_files[basename] = dest
117
+ end
118
+
119
+ # No manifest (a non-strict install): every hooks/*.rb is copied.
120
+ def install_unlisted_hooks
121
+ Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each do |src|
122
+ basename = File.basename(src)
123
+ @bundle_hooks << basename
124
+ if @dry_run
125
+ @results[basename] = { status: "would_install" }
126
+ else
127
+ FileUtils.mkdir_p(@provenance.hooks_dir)
128
+ dest = File.join(@provenance.hooks_dir, basename)
129
+ FileUtils.cp(src, dest)
130
+ @results[basename] = { status: "installed" }
131
+ @hook_files[basename] = dest
132
+ end
133
+ end
134
+ end
135
+
136
+ # An upgrade removes the hooks the previous version shipped and this
137
+ # one doesn't (a local file too, with a warning).
138
+ def prune_dropped_hooks
139
+ @existing.hooks.each_key do |old_key_str|
140
+ next if @bundle_hooks.include?(old_key_str)
141
+
142
+ old_dest = File.join(@provenance.hooks_dir, old_key_str)
143
+ if File.exist?(old_dest) && !@force
144
+ @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
145
+ end
146
+ FileUtils.rm_f(old_dest)
147
+ end
148
+ end
149
+
150
+ # Strict mode: an installed hook whose sha256 differs from the one its
151
+ # manifest declares warns (as verify_memory_checksums does).
152
+ def verify_hook_checksums
153
+ @bundle_hooks.each do |basename|
154
+ next if @conflicts.key?(basename)
155
+
156
+ expected = @manifest.checksum_for_hook(basename)
157
+ next unless expected
158
+
159
+ dest = File.join(@provenance.hooks_dir, basename)
160
+ next unless File.exist?(dest)
161
+
162
+ actual = Digest::SHA256.hexdigest(File.read(dest))
163
+ if actual != expected
164
+ @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
165
+ end
166
+ end
167
+ end
168
+
169
+ # The bundle's guardrails/*.yml into <bundle_dir>/guardrails/: the
170
+ # set replaces what an earlier version installed. Same bytes as
171
+ # installed are reported up to date, not "Installed".
172
+ # @return [Hash{String => String}] basename => installed path ({} on a dry run)
173
+ def install_guardrails(incoming_rules)
174
+ if @dry_run
175
+ incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
176
+ return {}
177
+ end
178
+
179
+ target = @provenance.guardrails_dir
180
+ unchanged = incoming_rules.select do |src|
181
+ dest = File.join(target, File.basename(src))
182
+ File.file?(dest) && FileUtils.identical?(src, dest)
183
+ end
184
+ FileUtils.rm_rf(target)
185
+ return {} if incoming_rules.empty?
186
+
187
+ FileUtils.mkdir_p(target)
188
+ incoming_rules.to_h do |src|
189
+ dest = File.join(target, File.basename(src))
190
+ FileUtils.cp(src, dest)
191
+ @results["guardrails/#{File.basename(src)}"] =
192
+ unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
193
+ [File.basename(src), dest]
194
+ end
195
+ end
196
+
197
+ # Copy the manifest's plugin file into the bundle's plugin/ dir,
198
+ # warning when its sha256 differs from the declared one or this chi
199
+ # doesn't meet requires_chi (the Engine then won't load it).
200
+ # @return [String, nil] the installed file (nil: none, or a dry run)
201
+ def install_plugin
202
+ plugin = @manifest&.plugin
203
+ unless plugin
204
+ FileUtils.rm_rf(@provenance.plugin_dir) unless @dry_run
205
+ return nil
206
+ end
207
+
208
+ src = File.join(@bundle_dir, plugin[:file])
209
+ raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
210
+
211
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
212
+ @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
213
+ end
214
+ if @dry_run
215
+ @results[plugin[:file]] = { status: "would_install" }
216
+ return nil
217
+ end
218
+
219
+ dest = File.join(@provenance.plugin_dir, plugin[:file])
220
+ unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
221
+ FileUtils.rm_rf(@provenance.plugin_dir)
222
+ FileUtils.mkdir_p(@provenance.plugin_dir)
223
+ FileUtils.cp(src, dest)
224
+ @results[plugin[:file]] = if unchanged
225
+ { status: "skipped", reason: "already up to date" }
226
+ elsif @upgrade && @provenance.installed?
227
+ { status: "updated" }
228
+ else
229
+ { status: "installed" }
230
+ end
231
+ expected = @manifest.checksum_for_plugin
232
+ actual = Digest::SHA256.hexdigest(File.binread(dest))
233
+ if @strict && expected && actual != expected
234
+ @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
235
+ end
236
+ dest
237
+ end
238
+
239
+ # The manifest's scripts, copied into <bundle_dir>/scripts/ (the
240
+ # folder replaced: a script the new version dropped goes). A declared
241
+ # sha256 that differs warns, as a plugin's does.
242
+ # @return [Hash{String => String}] file name => installed path
243
+ def install_scripts
244
+ scripts = @manifest&.scripts || {}
245
+ scripts.each_key do |file|
246
+ next if File.file?(File.join(@bundle_dir, "scripts", file))
247
+
248
+ raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
249
+ end
250
+ return {} if @dry_run
251
+
252
+ unchanged = scripts.keys.select do |file|
253
+ dest = File.join(@provenance.scripts_dir, file)
254
+ File.file?(dest) && FileUtils.identical?(File.join(@bundle_dir, "scripts", file), dest)
255
+ end
256
+ FileUtils.rm_rf(@provenance.scripts_dir)
257
+ return {} if scripts.empty?
258
+
259
+ FileUtils.mkdir_p(@provenance.scripts_dir)
260
+ scripts.to_h do |file, declared|
261
+ dest = File.join(@provenance.scripts_dir, file)
262
+ FileUtils.cp(File.join(@bundle_dir, "scripts", file), dest)
263
+ @results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
264
+ elsif @upgrade && @provenance.installed? then { status: "updated" }
265
+ else { status: "installed" }
266
+ end
267
+ FileUtils.chmod(0o755, dest)
268
+ actual = Digest::SHA256.hexdigest(File.binread(dest))
269
+ expected = declared.to_s.delete_prefix("sha256:")
270
+ if @strict && !expected.empty? && actual != expected
271
+ @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
272
+ end
273
+ [file, dest]
274
+ end
275
+ end
276
+
277
+ # The hook loader skips every hook of a bundle whose requires_chi this
278
+ # chi doesn't meet (Hooks::BundleLoader); say so at install, as
279
+ # install_plugin does for the plugin.
280
+ def warn_hooks_requires_chi
281
+ return if @manifest.nil? || @bundle_hooks.empty?
282
+
283
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
284
+ @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
285
+ end
286
+ end
287
+
288
+ # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
289
+ # requires_chi this chi doesn't meet, and a session that never loaded
290
+ # them records a required load failure (the gate refuses guarded calls
291
+ # until chi is updated): say so at install, as the hooks warning does.
292
+ def warn_rules_requires_chi(rule_files)
293
+ return if @manifest.nil? || rule_files.empty?
294
+
295
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
296
+ @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
297
+ "(chi update), guarded tool calls are refused"
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end