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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +5 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +10 -13
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundle_command.rb +9 -13
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +23 -0
- data/lib/samagotchi/engine.rb +63 -18
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +18 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/builder.rb +23 -21
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installer.rb +4 -4
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +40 -46
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +68 -11
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +44 -11
- data/lib/samagotchi/session_metrics.rb +15 -20
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
- data/lib/samagotchi/terminal_ui.rb +21 -4
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +56 -13
- data/lib/samagotchi/web/public/app.js +131 -20
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +8 -5
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/thinking_chip.js +179 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- 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 (
|
|
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
|
|
107
|
-
#
|
|
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 =
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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]&.
|
|
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
|
-
|
|
437
|
-
next unless
|
|
436
|
+
configured_profile = models[key]&.profile
|
|
437
|
+
next unless configured_profile
|
|
438
438
|
|
|
439
|
-
profile = configured(
|
|
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 +
|
|
24
|
-
# session's own
|
|
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, :
|
|
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
|
|
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
|
-
|
|
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
|