samagotchi 0.51.0 → 0.53.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/releasing.md +6 -3
  11. data/docs/sessions.md +5 -1
  12. data/docs/testing.md +8 -1
  13. data/lib/samagotchi/analytics_file.rb +35 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  15. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  16. data/lib/samagotchi/bridge.rb +10 -13
  17. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  18. data/lib/samagotchi/bundle_command.rb +9 -13
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/children_status.rb +1 -1
  22. data/lib/samagotchi/commands/registry.rb +2 -1
  23. data/lib/samagotchi/config.rb +50 -182
  24. data/lib/samagotchi/config_text_edit.rb +8 -32
  25. data/lib/samagotchi/context_sources.rb +0 -7
  26. data/lib/samagotchi/context_window.rb +2 -2
  27. data/lib/samagotchi/continue_offer.rb +5 -4
  28. data/lib/samagotchi/cut_policy.rb +0 -1
  29. data/lib/samagotchi/delivery.rb +23 -0
  30. data/lib/samagotchi/engine.rb +63 -18
  31. data/lib/samagotchi/engine_peers.rb +1 -0
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +18 -10
  35. data/lib/samagotchi/last_turn.rb +48 -0
  36. data/lib/samagotchi/live_versions.rb +0 -6
  37. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  38. data/lib/samagotchi/llm_context_override.rb +3 -22
  39. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  40. data/lib/samagotchi/memory_bundle/builder.rb +23 -21
  41. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  42. data/lib/samagotchi/memory_bundle/installer.rb +4 -4
  43. data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
  44. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
  46. data/lib/samagotchi/model_profile.rb +4 -4
  47. data/lib/samagotchi/model_settings.rb +46 -0
  48. data/lib/samagotchi/plugin/context.rb +3 -3
  49. data/lib/samagotchi/plugin/sessions.rb +2 -2
  50. data/lib/samagotchi/queued_command.rb +35 -0
  51. data/lib/samagotchi/reminder_store.rb +0 -27
  52. data/lib/samagotchi/reply_wait.rb +11 -13
  53. data/lib/samagotchi/sampling_settings.rb +4 -4
  54. data/lib/samagotchi/self_report.rb +8 -5
  55. data/lib/samagotchi/send_command.rb +40 -46
  56. data/lib/samagotchi/session.rb +45 -24
  57. data/lib/samagotchi/session_commands.rb +68 -11
  58. data/lib/samagotchi/session_inbox.rb +18 -9
  59. data/lib/samagotchi/session_manager.rb +44 -11
  60. data/lib/samagotchi/session_metrics.rb +15 -20
  61. data/lib/samagotchi/session_setup.rb +30 -0
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/steer_cut.rb +9 -2
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  66. data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
  67. data/lib/samagotchi/terminal_ui.rb +21 -4
  68. data/lib/samagotchi/thinking.rb +67 -7
  69. data/lib/samagotchi/thinking_command.rb +72 -0
  70. data/lib/samagotchi/tools/builtins.rb +7 -2
  71. data/lib/samagotchi/tools/peers.rb +3 -2
  72. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +4 -3
  75. data/lib/samagotchi/web/app.rb +56 -13
  76. data/lib/samagotchi/web/public/app.js +131 -20
  77. data/lib/samagotchi/web/public/command_complete.js +7 -2
  78. data/lib/samagotchi/web/public/data.js +8 -4
  79. data/lib/samagotchi/web/public/format.js +8 -4
  80. data/lib/samagotchi/web/public/index.html +8 -5
  81. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  82. data/lib/samagotchi/web/public/model_pick.js +3 -1
  83. data/lib/samagotchi/web/public/thinking_chip.js +179 -0
  84. data/lib/samagotchi/web/public/timing.js +15 -0
  85. data/lib/samagotchi/web/public/turn_events.js +23 -0
  86. data/lib/samagotchi/web/public/turn_view.js +328 -310
  87. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  88. data/lib/samagotchi/web/session_summary.rb +1 -1
  89. data/lib/samagotchi/worker.rb +43 -42
  90. data/lib/samagotchi/worker_inbound.rb +5 -5
  91. data/lib/samagotchi/worker_wakes.rb +0 -2
  92. data/lib/samagotchi/yaml_lines.rb +72 -0
  93. metadata +13 -1
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  # (false: edit-driven stale stubs are opt-in, experimental) are global only.
24
24
  #
25
25
  # A soft context budget, llm_context.budget_tokens, and per model or host
26
- # the flat key llm_context_budget_tokens (a positive number of tokens),
26
+ # the flat key llm_context_budget_tokens (tokens or k, 4k to 10M, or off),
27
27
  # resolved as the apply rule is, each on its own: off (nil) by default.
28
28
  # When set, ContextStatus counts its bands against it instead of the
29
29
  # window (the smaller of the two), so the forget layer's offers come
@@ -103,21 +103,52 @@ module Samagotchi
103
103
  DEFAULT_APPLY
104
104
  end
105
105
 
106
- # +raw+ as written, a context budget in tokens; nil for unset or 0
107
- # (off). Anything but a number of tokens warns, naming +where+, and is
108
- # unset.
106
+ # +raw+ as written in config (llm_context.budget_tokens, a models: or
107
+ # hosts: entry's llm_context_budget_tokens), read as /llm-context
108
+ # budget reads it (.budget_tokens); nil for unset, 0 or off. Anything
109
+ # else warns, naming +where+, and is unset.
109
110
  # @return [Integer, nil]
110
111
  def parse_budget(raw, where)
111
112
  return nil if raw.nil? || raw.to_s.strip.empty?
112
113
 
113
- tokens = Integer(raw.to_s.strip, exception: false)
114
- return nil if tokens&.zero?
115
- return tokens if tokens&.positive?
116
-
117
- warn_once "Warning: #{where}: llm_context budget_tokens must be a positive number of tokens; ignored"
114
+ tokens = budget_tokens(raw)
115
+ tokens.zero? ? nil : tokens
116
+ rescue ArgumentError => e
117
+ warn_once "Warning: #{where}: #{e.message}; ignored"
118
118
  nil
119
119
  end
120
120
 
121
+ # The smallest and largest budget a session or the config may set.
122
+ MIN_BUDGET = 4_000
123
+ MAX_BUDGET = 10_000_000
124
+ BUDGET_OFF_WORDS = %w[off 0].freeze
125
+
126
+ # A budget in tokens: an Integer, "64000", "64_000", "64k", or "off"
127
+ # (0); the one grammar for /llm-context budget, chi
128
+ # --llm-context-budget and the config.
129
+ # @return [Integer] 0 for off
130
+ # @raise [ArgumentError] not a budget, or one out of MIN_BUDGET..MAX_BUDGET
131
+ def budget_tokens(raw)
132
+ return check_budget(raw) if raw.is_a?(Integer)
133
+
134
+ text = raw.to_s.strip
135
+ return 0 if BUDGET_OFF_WORDS.include?(text.downcase)
136
+
137
+ match = /\A(\d[\d_]*)(k)?\z/i.match(text)
138
+ raise ArgumentError, "a budget is a number of tokens (64000 or 64k) or off" unless match
139
+
140
+ check_budget(match[1].delete("_").to_i * (match[2] ? 1000 : 1))
141
+ end
142
+
143
+ # +tokens+, when 0 (off) or between MIN_BUDGET and MAX_BUDGET.
144
+ # @return [Integer]
145
+ # @raise [ArgumentError]
146
+ def check_budget(tokens)
147
+ return tokens if tokens.zero? || tokens.between?(MIN_BUDGET, MAX_BUDGET)
148
+
149
+ raise ArgumentError, "a budget of #{tokens} tokens is out of range: #{MIN_BUDGET} (4k) to #{MAX_BUDGET} (10000k), or off"
150
+ end
151
+
121
152
  # Where one value came from: :session, :model_setting, :host_setting
122
153
  # or :config, and the place as a warning names it ("the session",
123
154
  # "models: deepseek", "hosts entry 'box'", "llm_context.apply").
@@ -143,7 +174,7 @@ module Samagotchi
143
174
  # The strategy for +target+'s turn.
144
175
  # @param target [HostRegistry::ModelTarget, nil]
145
176
  # @param names [Array<String>] HostRegistry#lookup_names
146
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
177
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
147
178
  # @param session [Array<Symbol>, nil] the session's own layers ([]: none)
148
179
  # @param session_apply [Symbol, nil] the session's own apply rule
149
180
  # @param session_budget [Integer, nil] the session's own budget (0: off)
@@ -210,8 +241,9 @@ module Samagotchi
210
241
  def budget_for(target, names, models, session_budget)
211
242
  return [session_budget.positive? ? session_budget : nil, SESSION_ORIGIN] unless session_budget.nil?
212
243
 
213
- key, budget = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models)
214
- return [budget, Origin.new(source: :model_setting, where: "models: #{key}")] if budget
244
+ if (setting = ConfigFile.model_setting(names, :llm_context_budget_tokens, models: models))
245
+ return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
246
+ end
215
247
 
216
248
  budget = target&.entry&.llm_context_budget_tokens
217
249
  return [budget, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if budget
@@ -222,8 +254,9 @@ module Samagotchi
222
254
  def layers_for(target, names, models, session)
223
255
  return [session, :session, SESSION_ORIGIN.where] if session
224
256
 
225
- key, layers = ConfigFile.model_setting(names, :llm_context_strategy, models: models)
226
- return [layers, :model_setting, "models: #{key}"] if layers
257
+ if (setting = ConfigFile.model_setting(names, :llm_context_strategy, models: models))
258
+ return [setting.value, :model_setting, "models: #{setting.key}"]
259
+ end
227
260
 
228
261
  layers = target&.entry&.llm_context_strategy
229
262
  return [layers, :host_setting, "hosts entry '#{target.entry.name}'"] if layers
@@ -235,8 +268,9 @@ module Samagotchi
235
268
  def apply_for(target, names, models, session_apply)
236
269
  return [session_apply, SESSION_ORIGIN] if session_apply
237
270
 
238
- key, apply = ConfigFile.model_setting(names, :llm_context_apply, models: models)
239
- return [apply, Origin.new(source: :model_setting, where: "models: #{key}")] if apply
271
+ if (setting = ConfigFile.model_setting(names, :llm_context_apply, models: models))
272
+ return [setting.value, Origin.new(source: :model_setting, where: "models: #{setting.key}")]
273
+ end
240
274
 
241
275
  apply = target&.entry&.llm_context_apply
242
276
  return [apply, Origin.new(source: :host_setting, where: "hosts entry '#{target.entry.name}'")] if apply
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "tmpdir"
6
+ require_relative "bundle_hook"
6
7
  require_relative "manifest"
7
8
  require_relative "placeholder"
8
9
  require_relative "../context_providers"
@@ -50,6 +51,8 @@ module Samagotchi
50
51
  validate_version!(resolved_version)
51
52
  raise BuildError, "invalid scope: #{@scope}" unless %w[system project].include?(@scope)
52
53
 
54
+ refuse_profile!(resolved_name)
55
+
53
56
  # Collect *.md files, excluding index.md and hidden files
54
57
  all_md = Dir.glob(File.join(target_dir, "*.md")).sort
55
58
  candidates = all_md.reject { |p| File.basename(p) == "index.md" }
@@ -110,20 +113,9 @@ module Samagotchi
110
113
  # Skip if hook file missing on disk
111
114
  next unless File.exist?(src)
112
115
 
113
- # meta may be symbol-keyed
114
- m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
115
- sha = (m["sha256"] || m["checksum"] || "").to_s
116
- if sha.empty?
117
- sha = "sha256:#{Digest::SHA256.hexdigest(File.read(src))}"
118
- elsif !sha.start_with?("sha256:")
119
- sha = "sha256:#{sha}"
120
- end
121
- hooks_map[basename] = {
122
- "sha256" => sha,
123
- "event" => (m["event"] || "").to_s,
124
- "on_error" => (m["on_error"] || "skip").to_s,
125
- "priority" => (m["priority"] || 100).to_i
126
- }
116
+ hook = BundleHook.parse(meta)
117
+ hook = hook.with(sha256: BundleHook.of_file(src).sha256) if hook.sha256.empty?
118
+ hooks_map[basename] = hook
127
119
  hooks_to_copy << [src, basename]
128
120
  end
129
121
  else
@@ -132,13 +124,7 @@ module Samagotchi
132
124
  if Dir.exist?(local_hooks_dir)
133
125
  Dir.glob(File.join(local_hooks_dir, "*.rb")).sort.each do |src|
134
126
  basename = File.basename(src)
135
- sha = Digest::SHA256.hexdigest(File.read(src))
136
- hooks_map[basename] = {
137
- "sha256" => "sha256:#{sha}",
138
- "event" => "",
139
- "on_error" => "skip",
140
- "priority" => 100
141
- }
127
+ hooks_map[basename] = BundleHook.of_file(src)
142
128
  hooks_to_copy << [src, basename]
143
129
  end
144
130
  end
@@ -268,6 +254,22 @@ module Samagotchi
268
254
 
269
255
  private
270
256
 
257
+ # An installed profile (meta bundle) ships only its includes: a build
258
+ # under its name would make a plain bundle of the scope's memories,
259
+ # without the includes, carrying the profile's trust_level.
260
+ def refuse_profile!(name)
261
+ require_relative "profile"
262
+ data = Provenance.new(name: name).read
263
+ return unless Profile.installed_meta?(name, data)
264
+
265
+ shipped = File.join(SourceNormalizer::SHIPPED_DIR, name)
266
+ members = Profile.recorded(data, File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil)
267
+ raise BuildError, "#{name} is an installed profile (includes: #{members.join(", ")}): it ships only its includes, " \
268
+ "so a build can't remake it; pick another --name for these memories"
269
+ rescue JSON::ParserError, SystemCallError, Manifest::ValidationError
270
+ nil
271
+ end
272
+
271
273
  # The installed bundle's scripts its record lists, file name => path
272
274
  # in its scripts/ (a missing one is left out); {} without a record.
273
275
  def installed_scripts(prov, prov_data)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # One hook's metadata, as a bundle's manifest.yml declares it and its
8
+ # installed record (manifest.json) keeps it: the file's sha256
9
+ # ("sha256:<hex>", "" when none is recorded), the event it registers
10
+ # for ("" when none: it isn't loaded), on_error (skip, log or
11
+ # fail_closed) and priority. The one place the defaults live.
12
+ class BundleHook < Data.define(:sha256, :event, :on_error, :priority)
13
+ DEFAULT_ON_ERROR = "skip"
14
+ DEFAULT_PRIORITY = 100
15
+
16
+ def initialize(sha256: "", event: "", on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
17
+ super
18
+ end
19
+
20
+ # @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
21
+ # symbol keys (a read record); missing or blank values get the
22
+ # defaults
23
+ def self.parse(raw)
24
+ return raw if raw.is_a?(BundleHook)
25
+
26
+ h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
27
+ on_error = h["on_error"].to_s.strip
28
+ new(sha256: prefixed(h["sha256"]), event: h["event"].to_s.strip,
29
+ on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
30
+ priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
31
+ end
32
+
33
+ # A hook known only by its file (no declared event): its sha, the defaults.
34
+ def self.of_file(path)
35
+ new(sha256: "sha256:#{Digest::SHA256.hexdigest(File.binread(path))}")
36
+ end
37
+
38
+ # "sha256:<hex>" from either form, "" for none.
39
+ def self.prefixed(sha)
40
+ sha = sha.to_s.strip
41
+ sha.empty? || sha.start_with?("sha256:") ? sha : "sha256:#{sha}"
42
+ end
43
+
44
+ # The sha's hex, "" when none is recorded.
45
+ def hex = sha256.delete_prefix("sha256:")
46
+
47
+ # The string-keyed form manifest.yml and manifest.json hold.
48
+ def to_record = to_h.transform_keys(&:to_s)
49
+ end
50
+ end
51
+ end
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "digest"
5
5
  require_relative "../memory_paths"
6
+ require_relative "bundle_hook"
6
7
  require_relative "provenance"
7
8
  require_relative "manifest"
8
9
  require_relative "source"
@@ -180,7 +181,7 @@ module Samagotchi
180
181
  discovered = Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).map { |p| File.basename(p) }
181
182
  discovered.each do |bn|
182
183
  # Build minimal metadata so copy still happens (event unknown -> skipped by loader but copied for integrity)
183
- hook_entries[bn] = { sha256: "", event: "", on_error: "skip", priority: 100 }
184
+ hook_entries[bn] = BundleHook.new
184
185
  end
185
186
  end
186
187
 
@@ -206,7 +207,7 @@ module Samagotchi
206
207
  if @upgrade && existing_provenance && File.exist?(dest) && !@force
207
208
  prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
208
209
  if prev_meta
209
- prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
210
+ prev_sha = BundleHook.parse(prev_meta).sha256
210
211
  if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
211
212
  @warnings << "Hook #{basename} was locally modified; overwriting"
212
213
  end
@@ -403,8 +404,7 @@ module Samagotchi
403
404
  hooks_files_for_provenance.each do |basename, path|
404
405
  next unless File.exist?(path)
405
406
 
406
- sha = Digest::SHA256.hexdigest(File.read(path))
407
- hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
407
+ hooks_for_provenance[basename] = BundleHook.of_file(path)
408
408
  end
409
409
  end
410
410
  Provenance.new(name: @name).write(
@@ -4,6 +4,7 @@ require "yaml"
4
4
  require "digest"
5
5
  require "fileutils"
6
6
  require_relative "../context_providers"
7
+ require_relative "bundle_hook"
7
8
 
8
9
  module Samagotchi
9
10
  module MemoryBundle
@@ -53,7 +54,8 @@ module Samagotchi
53
54
  BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
54
55
 
55
56
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
56
- # the description a mapping gave, for the files that have one.
57
+ # the description a mapping gave, for the files that have one; hooks:
58
+ # name → BundleHook.
57
59
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
58
60
  :scripts, :context_providers
59
61
 
@@ -99,13 +101,7 @@ module Samagotchi
99
101
 
100
102
  # Returns sha256 hex for a hook basename (e.g. "guardrails.rb").
101
103
  def checksum_for_hook(basename)
102
- raw = @hooks[basename]
103
- return nil unless raw
104
-
105
- sha = raw[:sha256] || raw["sha256"]
106
- return nil unless sha
107
-
108
- sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
104
+ @hooks[basename]&.hex
109
105
  end
110
106
 
111
107
  # @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
@@ -142,14 +138,7 @@ module Samagotchi
142
138
  text = file_descriptions[key].to_s.strip
143
139
  [key, text.empty? ? sha : { "sha256" => sha, "description" => text }]
144
140
  end
145
- if hooks && !hooks.empty?
146
- # Normalize hooks to string-keyed with sha256 prefix preserved
147
- manifest["hooks"] = hooks.transform_keys(&:to_s).transform_values do |v|
148
- h = v.transform_keys(&:to_s)
149
- h["sha256"] = h["sha256"].to_s.start_with?("sha256:") ? h["sha256"].to_s : "sha256:#{h["sha256"]}" if h["sha256"]
150
- h
151
- end
152
- end
141
+ manifest["hooks"] = hooks.to_h { |k, v| [k.to_s, BundleHook.parse(v).to_record] } if hooks && !hooks.empty?
153
142
  manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
154
143
  if plugin
155
144
  sha = plugin[:sha256].to_s
@@ -306,18 +295,7 @@ module Samagotchi
306
295
  next unless k.is_a?(String) && !k.empty?
307
296
  next unless v.is_a?(Hash)
308
297
 
309
- sha = (v["sha256"] || v[:sha256] || "").to_s
310
- sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
311
- event = (v["event"] || v[:event] || "").to_s.strip
312
- on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
313
- on_error = "skip" if on_error.empty?
314
- priority = (v["priority"] || v[:priority] || 100).to_i
315
- acc[k] = {
316
- sha256: sha,
317
- event: event,
318
- on_error: on_error,
319
- priority: priority
320
- }
298
+ acc[k] = BundleHook.parse(v)
321
299
  end
322
300
  end
323
301
  end
@@ -18,11 +18,6 @@ module Samagotchi
18
18
  new(content: content).placeholders
19
19
  end
20
20
 
21
- # Returns a sorted list of unique placeholder names found.
22
- def unique_names
23
- @placeholders.map(&:strip).uniq.sort
24
- end
25
-
26
21
  def any?
27
22
  !@placeholders.empty?
28
23
  end
@@ -6,6 +6,7 @@ require "date"
6
6
  require "fileutils"
7
7
  require_relative "../atomic_file"
8
8
  require_relative "../memory_paths"
9
+ require_relative "bundle_hook"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
@@ -177,22 +178,14 @@ module Samagotchi
177
178
  hooks_map = {}
178
179
  # existing hooks for pruning
179
180
  existing_hooks = existing && existing[:hooks] ? existing[:hooks] : {}
180
- # Normalize incoming hooks (basename => metadata hash)
181
+ # Incoming hooks: basename => BundleHook (or its metadata hash)
181
182
  normalized_hooks = {}
182
183
  if hooks.is_a?(Hash)
183
184
  hooks.each do |k, v|
184
185
  next unless k.is_a?(String) && !k.empty?
185
- next unless v.is_a?(Hash)
186
-
187
- hv = v.transform_keys(&:to_sym)
188
- sha = (hv[:sha256] || "").to_s
189
- sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
190
- normalized_hooks[k] = {
191
- sha256: sha,
192
- event: (hv[:event] || "").to_s,
193
- on_error: (hv[:on_error] || "skip").to_s,
194
- priority: (hv[:priority] || 100).to_i
195
- }
186
+ next unless v.is_a?(Hash) || v.is_a?(BundleHook)
187
+
188
+ normalized_hooks[k] = BundleHook.parse(v)
196
189
  end
197
190
  end
198
191
  # Prune stale hook bases
@@ -210,12 +203,10 @@ module Samagotchi
210
203
  # compares against it, and a declared sha can be wrong (installing
211
204
  # only warns about that).
212
205
  hooks_files = {} unless hooks_files.is_a?(Hash)
213
- normalized_hooks.each do |k, meta|
206
+ normalized_hooks.each do |k, hook|
214
207
  src = hooks_files[k] || hooks_files[k.to_sym]
215
- if src && File.exist?(src.to_s)
216
- meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
217
- end
218
- hooks_map[k] = meta.transform_keys(&:to_s)
208
+ hook = hook.with(sha256: BundleHook.of_file(src.to_s).sha256) if src && File.exist?(src.to_s)
209
+ hooks_map[k] = hook.to_record
219
210
  next unless src && File.exist?(src.to_s)
220
211
 
221
212
  begin
@@ -227,7 +227,7 @@ module Samagotchi
227
227
  host = parsed.host_name
228
228
  return nil unless host
229
229
  # Declared under hosts.<name>.models: served whatever the host lists.
230
- return nil if hosts[host.to_s.strip.downcase]&.dig(:models)&.key?(parsed.id.to_s.strip.downcase)
230
+ return nil if hosts[host.to_s.strip.downcase]&.models&.key?(parsed.id.to_s.strip.downcase)
231
231
 
232
232
  list = lists.read(env: env)[host.to_s.strip.downcase]
233
233
  return nil if list.nil? || list.stale? || list.known?(parsed.id)
@@ -433,10 +433,10 @@ module Samagotchi
433
433
  end
434
434
 
435
435
  names.map(&:downcase).uniq.each do |key|
436
- setting = models[key]
437
- next unless setting && setting[:profile]
436
+ configured_profile = models[key]&.profile
437
+ next unless configured_profile
438
438
 
439
- profile = configured(setting[:profile], "models: #{key}")
439
+ profile = configured(configured_profile, "models: #{key}")
440
440
  return Resolution.new(profile: profile, source: :config, detail: "models: #{key}", retry: false) if profile
441
441
  end
442
442
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "llm_context_strategy"
5
+
6
+ module Samagotchi
7
+ # One config.yml models: entry (per-model settings keyed by model id or
8
+ # alias), parsed and checked; ConfigFile.model_settings holds them by
9
+ # downcased key. Each field is nil when unset: profile kept as written
10
+ # (downcased; ModelProfile.resolve validates it), vision true/false,
11
+ # sampling a request-field map, thinking a level, window_tokens, and the
12
+ # LLM context strategy, apply rule and budget (LLMContextStrategy). The
13
+ # same per-model fields a hosts: entry carries (HostConfig).
14
+ ModelSettings = Data.define(:profile, :vision, :sampling, :thinking, :window_tokens,
15
+ :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens) do
16
+ # Every field may be left out (nil).
17
+ def initialize(**fields)
18
+ super(**members.to_h { |m| [m, nil] }, **fields)
19
+ end
20
+
21
+ # A models: entry as written, or nil when it is not a map (or the key is
22
+ # blank). A bad value warns, naming the entry, and is left unset.
23
+ # @param key [String] the downcased model id or alias
24
+ # @param raw [Hash] string or symbol keys
25
+ # @return [ModelSettings, nil]
26
+ def self.parse(key, raw)
27
+ return nil if key.to_s.empty? || !raw.is_a?(Hash)
28
+
29
+ value = ->(name) { raw.key?(name.to_s) ? raw[name.to_s] : raw[name.to_sym] }
30
+ where = "models: #{key}"
31
+ profile = value.call(:profile).to_s.strip.downcase
32
+ new(profile: profile.empty? ? nil : profile,
33
+ vision: ConfigFile.vision_flag(value.call(:vision), where),
34
+ sampling: ConfigFile.sampling_map(value.call(:sampling), where),
35
+ thinking: Thinking.level(value.call(:thinking), where),
36
+ window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
37
+ llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
38
+ llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
39
+ llm_context_budget_tokens: LLMContextStrategy.parse_budget(value.call(LLMContextStrategy::BUDGET_KEY), where))
40
+ end
41
+ end
42
+
43
+ # One field ConfigFile.model_setting found: the models: key that set it
44
+ # and its value.
45
+ ModelSetting = Data.define(:key, :value)
46
+ end
@@ -20,15 +20,15 @@ module Samagotchi
20
20
  # the answer text.
21
21
  # +messages_partial+ says whether +messages+ leaves out a running turn;
22
22
  # +model_name+ (the session's resolved model ref, live after /model) and
23
- # +state_dir+ are what ctx.sessions forks with, and +llm_context+ (the
24
- # session's own LLMContextOverride, nil without one), which a fork copies; +model_key+ is the
23
+ # +state_dir+ are what ctx.sessions forks with, and +setup+ (the
24
+ # session's own SessionSetup), which a fork copies; +model_key+ is the
25
25
  # model's memory overlay key (ctx.model, ctx.model_key).
26
26
  # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
27
27
  # (reason, label), each true when it acted on a running turn.
28
28
  # +frontend+ returns the Engine's interface (:repl, :worker,
29
29
  # :non_interactive), which ctx.frontend names for plugins.
30
30
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
31
- :ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
31
+ :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :steer, :stop_turn, :stop_generation,
32
32
  :frontend, keyword_init: true)
33
33
 
34
34
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
@@ -32,7 +32,7 @@ module Samagotchi
32
32
  # Start a child session from +messages+ (usually ctx.messages plus
33
33
  # more). With no prompt the child waits idle for the user; with one it
34
34
  # runs it as its first turn, and counts against session.max_children.
35
- # It keeps this session's own llm_context values (Session#llm_context).
35
+ # It keeps this session's own setup (SessionSetup: its llm_context values).
36
36
  # Each image a message names is copied into the child, or dropped with
37
37
  # a note in its message when its file is gone.
38
38
  # @param messages [Array<Hash>] the child's conversation to start with
@@ -54,7 +54,7 @@ module Samagotchi
54
54
  prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
55
55
  parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
56
56
  images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
57
- llm_context: @host.llm_context&.call
57
+ setup: @host.setup&.call || SessionSetup.new
58
58
  )
59
59
  child.id
60
60
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Samagotchi
6
+ # A session command on its way from the Bridge (POST /command, a command
7
+ # sent as a message, the worker's own Bridge#queue_command) to the
8
+ # worker's command queue. +card+: a card's action (the step-limit
9
+ # question's answer), whose line the UIs leave out. The worker adds
10
+ # where it waits (Worker#on_command): +mid_turn+ (the policy it got:
11
+ # :queue, :refuse, :loop), +after_seq+ (the event count then, so a
12
+ # turn's end knows it came before) and +after_file+ (the input file it
13
+ # runs after, a basename).
14
+ QueuedCommand = Data.define(:command_id, :client_id, :line, :card, :mid_turn, :after_seq, :after_file) do
15
+ def initialize(command_id:, line:, client_id: nil, card: false, mid_turn: nil, after_seq: nil, after_file: nil) = super
16
+
17
+ # A new command with a fresh id.
18
+ def self.mint(line, client_id: nil, card: false)
19
+ new(command_id: SecureRandom.uuid, client_id: client_id, line: line, card: card ? true : false)
20
+ end
21
+
22
+ # The fields its command_queued and command_ran name: card only when
23
+ # it is a card's.
24
+ # @return [Hash]
25
+ def event_fields
26
+ fields = { command_id: command_id, client_id: client_id, line: line }
27
+ fields[:card] = true if card
28
+ fields
29
+ end
30
+
31
+ # Whether it waits for the running turn's end (its line was shown at
32
+ # its command_queued).
33
+ def waits_for_turn_end? = mid_turn == :queue
34
+ end
35
+ end
@@ -104,21 +104,6 @@ module Samagotchi
104
104
  end
105
105
  end
106
106
 
107
- # Mark a reminder as fired — resets its next_fire_at to now + interval.
108
- # Called by Engine after a due reminder has been delivered.
109
- # @param name [String] the reminder name
110
- def mark_fired(name)
111
- name = name.to_s.strip
112
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
- @mutex.synchronize do
114
- r = @reminders[name]
115
- if r
116
- r[:last_fire_at] = now
117
- r[:next_fire_at] = now + (r[:interval_minutes] * 60)
118
- end
119
- end
120
- end
121
-
122
107
  # Mark multiple reminders as fired — resets each next_fire_at to now + interval.
123
108
  # Called by Engine after injecting multiple due reminders (batch operation).
124
109
  # Thread-safe, single lock acquisition.
@@ -137,18 +122,6 @@ module Samagotchi
137
122
  end
138
123
  end
139
124
 
140
- # Get the description of a due reminder (for injection into the system prompt).
141
- # Called by Engine during run_turn. Thread-safe.
142
- # @param name [String] the reminder name
143
- # @return [String, nil] the description, or nil if not found
144
- def get_description(name)
145
- name = name.to_s.strip
146
- @mutex.synchronize do
147
- r = @reminders[name]
148
- r[:description] if r
149
- end
150
- end
151
-
152
125
  # Clear all reminders.
153
126
  # Called when the session ends or the engine is reset.
154
127
  def clear_all