samagotchi 0.75.0 → 0.76.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 +47 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +20 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +14 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +34 -0
- data/docs/plugins.md +1 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +84 -4
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +11 -14
- data/lib/samagotchi/memory_bundle/installer.rb +122 -5
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/session_commands.rb +2 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/app.js +4 -3
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +73 -11
- data/lib/samagotchi/web/public/index.html +30 -35
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +32 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- metadata +3 -1
|
@@ -146,8 +146,12 @@ module Samagotchi
|
|
|
146
146
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
bundle_name =
|
|
150
|
-
|
|
149
|
+
prepared, bundle_name = prepare_or_fail(expanded_source, "Install")
|
|
150
|
+
return 1 unless prepared
|
|
151
|
+
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
|
|
152
|
+
|
|
153
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
|
|
154
|
+
run_installer(installer, failed: "Install") do |manifest|
|
|
151
155
|
print_hooks_and_plugin(manifest)
|
|
152
156
|
print_provenance(bundle_name, manifest)
|
|
153
157
|
end
|
|
@@ -164,7 +168,7 @@ module Samagotchi
|
|
|
164
168
|
dry_run = opts.fetch(:dry_run, false)
|
|
165
169
|
agent = opts[:agent]
|
|
166
170
|
if source.nil? || source.empty?
|
|
167
|
-
@stderr.puts
|
|
171
|
+
@stderr.puts UPGRADE_HELP
|
|
168
172
|
return CLI::Exit::USAGE
|
|
169
173
|
end
|
|
170
174
|
expanded_source = expand_source(source)
|
|
@@ -172,18 +176,21 @@ module Samagotchi
|
|
|
172
176
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
|
|
173
177
|
end
|
|
174
178
|
|
|
175
|
-
bundle_name =
|
|
179
|
+
prepared, bundle_name = prepare_or_fail(expanded_source, "Upgrade")
|
|
180
|
+
return 1 unless prepared
|
|
181
|
+
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Upgrade")
|
|
182
|
+
|
|
176
183
|
unless Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
|
|
177
184
|
@stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
|
|
178
185
|
# (no Hooks/Plugin lines here)
|
|
179
|
-
installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, dry_run: dry_run)
|
|
186
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dry_run: dry_run)
|
|
180
187
|
return run_installer(installer, failed: "Install") do |manifest|
|
|
181
188
|
next dry_run_done if dry_run
|
|
182
189
|
|
|
183
190
|
print_provenance(bundle_name, manifest)
|
|
184
191
|
end
|
|
185
192
|
end
|
|
186
|
-
installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, upgrade: true, dry_run: dry_run)
|
|
193
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, upgrade: true, dry_run: dry_run)
|
|
187
194
|
run_installer(installer, failed: "Upgrade") do |manifest|
|
|
188
195
|
print_hooks_and_plugin(manifest, plugin: !dry_run)
|
|
189
196
|
next dry_run_done if dry_run
|
|
@@ -193,12 +200,44 @@ module Samagotchi
|
|
|
193
200
|
end
|
|
194
201
|
end
|
|
195
202
|
|
|
196
|
-
# The
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
203
|
+
# The source normalized once (a git clone, a zip or tar extract) and the
|
|
204
|
+
# bundle's name, its manifest's name: for every kind of source, a git URL
|
|
205
|
+
# too. The Installer installs from that extract and removes it. Nil
|
|
206
|
+
# after "<word> failed: …" (an unknown source, or a missing or invalid
|
|
207
|
+
# manifest: an install needs one anyway), the extract removed.
|
|
208
|
+
# @return [Array(MemoryBundle::PreparedSource, String), nil]
|
|
209
|
+
def prepare_or_fail(expanded_source, word)
|
|
210
|
+
prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
|
|
211
|
+
named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
|
|
212
|
+
rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
|
|
213
|
+
Samagotchi::MemoryBundle::Manifest::ValidationError => e
|
|
200
214
|
@stderr.puts "#{word} failed: #{e.message}"
|
|
201
|
-
|
|
215
|
+
ensure
|
|
216
|
+
# Any failure (an unexpected error too) removes the extract.
|
|
217
|
+
prepared&.cleanup unless named
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A bundle installed from this source under another name: a git URL
|
|
221
|
+
# bundle recorded before chi named it by its manifest (it took the
|
|
222
|
+
# repo's name, chi-foo for name: foo). Installing it again would load
|
|
223
|
+
# both copies' plugin and hooks, so "<word> failed: … installed as
|
|
224
|
+
# chi-foo" instead (the extract removed). Only when no bundle has the
|
|
225
|
+
# manifest's name; sources compare by SourceIdentity.
|
|
226
|
+
# @return [Boolean] refused
|
|
227
|
+
def installed_under_another_name?(expanded_source, bundle_name, prepared, word)
|
|
228
|
+
return false if Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
|
|
229
|
+
|
|
230
|
+
identity = Samagotchi::MemoryBundle::SourceIdentity.of(expanded_source)
|
|
231
|
+
return false unless identity
|
|
232
|
+
|
|
233
|
+
other, = Samagotchi::MemoryBundle::Provenance.each_installed.find do |name, bundle|
|
|
234
|
+
name != bundle_name && !bundle.error? && Samagotchi::MemoryBundle::SourceIdentity.of(bundle.source) == identity
|
|
235
|
+
end
|
|
236
|
+
return false unless other
|
|
237
|
+
|
|
238
|
+
prepared.cleanup
|
|
239
|
+
@stderr.puts "#{word} failed: #{expanded_source} is installed as #{other}: `chi bundle uninstall #{other}`, then install it again"
|
|
240
|
+
true
|
|
202
241
|
end
|
|
203
242
|
|
|
204
243
|
def installer_for(source, name, scope:, force:, **)
|
|
@@ -728,40 +767,14 @@ module Samagotchi
|
|
|
728
767
|
parsed
|
|
729
768
|
end
|
|
730
769
|
|
|
731
|
-
#
|
|
770
|
+
# The source as given: a git URL as is, a shipped bundle's name as its
|
|
771
|
+
# dir, anything else as an expanded path.
|
|
732
772
|
def expand_source(src)
|
|
733
773
|
return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
|
|
734
774
|
|
|
735
775
|
Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
|
|
736
776
|
end
|
|
737
777
|
|
|
738
|
-
def bundle_name_for(expanded_src)
|
|
739
|
-
# Try to derive from manifest if possible (skip git clone for speed if possible)
|
|
740
|
-
if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
|
|
741
|
-
# For git URLs, avoid double clone: derive name from URL basename if no manifest yet
|
|
742
|
-
# Let Installer do the real manifest read; fallback to URL basename
|
|
743
|
-
base = expanded_src.split("/").last.to_s.split("#").first.to_s
|
|
744
|
-
base = base.sub(/\.git\z/i, "")
|
|
745
|
-
return base.empty? ? "bundle" : base
|
|
746
|
-
end
|
|
747
|
-
require "yaml"
|
|
748
|
-
nd = nil
|
|
749
|
-
owned = false
|
|
750
|
-
begin
|
|
751
|
-
nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
|
|
752
|
-
m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
|
|
753
|
-
m.name
|
|
754
|
-
rescue Samagotchi::MemoryBundle::Manifest::ValidationError
|
|
755
|
-
# Fallback to source basename without archive extensions
|
|
756
|
-
src = expanded_src
|
|
757
|
-
name = File.basename(src)
|
|
758
|
-
name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
|
|
759
|
-
name.empty? ? File.basename(nd || src) : name
|
|
760
|
-
ensure
|
|
761
|
-
Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
|
|
762
|
-
end
|
|
763
|
-
end
|
|
764
|
-
|
|
765
778
|
def build_conflict_prompt(bundle_name, conflicts, expanded_source)
|
|
766
779
|
lines = []
|
|
767
780
|
lines << "Memory bundle upgrade has conflicts that need interactive resolution."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codex-peer
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
|
|
6
6
|
trust_level: reviewed
|
|
@@ -10,7 +10,7 @@ files:
|
|
|
10
10
|
description: "Send a part of the work to the paired Codex CLI session (codex_send): only when the user asks, or to reply to its answer in a peer turn; validate, context or question; Codex's answer comes as the codex-outbox context"
|
|
11
11
|
plugin:
|
|
12
12
|
file: plugin.rb
|
|
13
|
-
sha256: sha256:
|
|
13
|
+
sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
|
|
14
14
|
scripts:
|
|
15
15
|
pair: sha256:251448a31959b5c916400eff7909e85c696855d5c16b6b10973ada40915c8051
|
|
16
16
|
chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
|
|
@@ -168,10 +168,16 @@ class Plugin < Samagotchi::Plugin::Peer
|
|
|
168
168
|
# [stdout, stderr, status]; both pipes read on threads, so a full one
|
|
169
169
|
# can't block the command.
|
|
170
170
|
def run(*command)
|
|
171
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + QUEUE_SECONDS
|
|
171
172
|
Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
|
|
172
173
|
stdin.close
|
|
173
174
|
out, err = [stdout, stderr].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
|
|
174
|
-
|
|
175
|
+
threads = [wait, out, err]
|
|
176
|
+
within_deadline = lambda do |thread|
|
|
177
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
178
|
+
thread.join([remaining, 0].max)
|
|
179
|
+
end
|
|
180
|
+
unless threads.all?(&within_deadline)
|
|
175
181
|
begin
|
|
176
182
|
Process.kill("KILL", -wait.pid)
|
|
177
183
|
rescue SystemCallError
|
|
@@ -179,7 +185,18 @@ class Plugin < Samagotchi::Plugin::Peer
|
|
|
179
185
|
end
|
|
180
186
|
# The group is gone, so the pipes end: let the readers finish
|
|
181
187
|
# before popen3 closes them.
|
|
182
|
-
|
|
188
|
+
cleanup_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
|
|
189
|
+
threads.each do |thread|
|
|
190
|
+
remaining = cleanup_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
+
thread.join([remaining, 0].max)
|
|
192
|
+
end
|
|
193
|
+
# The queue itself finished fine, and only a process it left held
|
|
194
|
+
# the pipes: the message is likely queued, so don't say "try again"
|
|
195
|
+
# (a retry would deliver it twice).
|
|
196
|
+
if wait.join(0) && wait.value.success?
|
|
197
|
+
raise SendError, "codex queue exited but left a process holding its output (stopped after " \
|
|
198
|
+
"#{QUEUE_SECONDS} s): the message may already be queued, check Codex before sending again"
|
|
199
|
+
end
|
|
183
200
|
raise SendError, "Codex didn't take the message in #{QUEUE_SECONDS} s (codex queue was stopped): try " \
|
|
184
201
|
"again, or pair again from Codex"
|
|
185
202
|
end
|
|
@@ -152,8 +152,7 @@ module Samagotchi
|
|
|
152
152
|
def launch_config
|
|
153
153
|
env = { "LANG" => LANG }
|
|
154
154
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
155
|
-
env
|
|
156
|
-
env["XDG_STATE_HOME"] = Paths.state_home(env: @env) if env.key?("XDG_STATE_HOME")
|
|
155
|
+
env.merge!(Paths.absolute_xdg(env: env, set: :key))
|
|
157
156
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
158
157
|
TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
|
|
159
158
|
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
|
@@ -168,6 +168,11 @@ module Samagotchi
|
|
|
168
168
|
unresolved(:window_unresolved, e, nil)
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
+
# The window config.yml gives this model or its host
|
|
172
|
+
# (ContextWindow.setting), never asking a server: a session loaded on
|
|
173
|
+
# it (Engine#session=, before a worker's Bridge serves). nil when none.
|
|
174
|
+
def configured_context_window = ContextWindow.setting(target, names: names)
|
|
175
|
+
|
|
171
176
|
# The window as its loop would see it (ChatLoop#context_window: a remote
|
|
172
177
|
# chat host has no /props, only its model list); nil when it can't be
|
|
173
178
|
# resolved.
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1005,6 +1005,9 @@ module Samagotchi
|
|
|
1005
1005
|
sync_kernel_client!
|
|
1006
1006
|
@client.invalidate_context_window!
|
|
1007
1007
|
@metrics.forget_model_reports!
|
|
1008
|
+
# The last turn's ctx % was the old model's window's
|
|
1009
|
+
# (#announce_switched_context! sends the new one's).
|
|
1010
|
+
@last_context_status = nil
|
|
1008
1011
|
ref = effective_model_ref
|
|
1009
1012
|
if persist_default
|
|
1010
1013
|
ConfigFile.write_default_model!(ref)
|
|
@@ -1013,6 +1016,42 @@ module Samagotchi
|
|
|
1013
1016
|
ref
|
|
1014
1017
|
end
|
|
1015
1018
|
|
|
1019
|
+
# After /model: how full the context is on the new model's window,
|
|
1020
|
+
# before its first turn: the last turn's count (the old model's
|
|
1021
|
+
# tokenizer's, replaced at the next generation) against the new model's
|
|
1022
|
+
# window (EffectiveModel#context_window, ContextWindow.resolve: the
|
|
1023
|
+
# running server first, one /props GET that a remote chat host is
|
|
1024
|
+
# spared; else the host's cached model list; else the configured
|
|
1025
|
+
# window) and the
|
|
1026
|
+
# session's budget. Sent as a :context_status event (the web's ctx
|
|
1027
|
+
# meter, the attached TUI's status row) with context_window_tokens, and
|
|
1028
|
+
# kept for a client that joins later (#session_state_snapshot; the
|
|
1029
|
+
# window in the metrics' context too, which a page counts from). A window
|
|
1030
|
+
# that can't be resolved sends usage nil: the UIs clear the % until the
|
|
1031
|
+
# next generation. Nothing before the session's first counted turn.
|
|
1032
|
+
# +configured_only+: the configured window only, never asking a server
|
|
1033
|
+
# (EffectiveModel#configured_context_window): a loaded session's (#session=).
|
|
1034
|
+
# @return [Hash, nil] the event sent
|
|
1035
|
+
def announce_switched_context!(configured_only: false)
|
|
1036
|
+
used = @metrics.snapshot.dig(:context, :used_tokens)
|
|
1037
|
+
return nil unless used.is_a?(Integer)
|
|
1038
|
+
|
|
1039
|
+
model = effective_model
|
|
1040
|
+
window = configured_only ? model.configured_context_window : current_context_window(model)
|
|
1041
|
+
display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
|
|
1042
|
+
@last_context_status = display&.dup
|
|
1043
|
+
@metrics.note_context_window(window.tokens, window.source) if window
|
|
1044
|
+
usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
|
|
1045
|
+
window_source: window.source.to_s, source: "switch" }
|
|
1046
|
+
event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
|
|
1047
|
+
context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
|
|
1048
|
+
@session_observer.notify(event)
|
|
1049
|
+
event
|
|
1050
|
+
rescue StandardError => e
|
|
1051
|
+
Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1052
|
+
nil
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1016
1055
|
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
1017
1056
|
|
|
1018
1057
|
# Register a hook callback for a named lifecycle event.
|
|
@@ -1640,8 +1679,30 @@ module Samagotchi
|
|
|
1640
1679
|
# A woken worker's /stats and status line count the turns before it.
|
|
1641
1680
|
bind_metrics(session)
|
|
1642
1681
|
@used_memories.absorb(session)
|
|
1682
|
+
# Loaded on another model than its last turn ran on (a --resume'd
|
|
1683
|
+
# REPL's --model, a worker woken after a /model with no turn since):
|
|
1684
|
+
# the saved ctx % was that model's window's. Drop it and count it as
|
|
1685
|
+
# /model does, on a configured window only: this runs before a
|
|
1686
|
+
# worker's Bridge serves, so no /props GET or model list here (a slow
|
|
1687
|
+
# or hung host would hold up the worker's start). No configured
|
|
1688
|
+
# window: no % (ctx ?) until the first generation reports one.
|
|
1689
|
+
return unless model_switched_since_last_turn?
|
|
1690
|
+
|
|
1691
|
+
@metrics.forget_model_reports!
|
|
1692
|
+
announce_switched_context!(configured_only: true)
|
|
1643
1693
|
end
|
|
1644
1694
|
|
|
1695
|
+
# Whether the last turn that named its model ran on another one than
|
|
1696
|
+
# the effective model; false before any (or when the ref can't be told).
|
|
1697
|
+
def model_switched_since_last_turn?
|
|
1698
|
+
last = @metrics.last_model_ref
|
|
1699
|
+
!last.nil? && last != effective_model_ref
|
|
1700
|
+
rescue StandardError => e
|
|
1701
|
+
Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1702
|
+
false
|
|
1703
|
+
end
|
|
1704
|
+
private :model_switched_since_last_turn?
|
|
1705
|
+
|
|
1645
1706
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
1646
1707
|
attr_reader :recap
|
|
1647
1708
|
|
|
@@ -2047,7 +2108,8 @@ module Samagotchi
|
|
|
2047
2108
|
result = backend(model).complete(
|
|
2048
2109
|
messages: turn.messages,
|
|
2049
2110
|
max_iterations: turn.limit,
|
|
2050
|
-
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller
|
|
2111
|
+
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller,
|
|
2112
|
+
model_ref: generation_model_ref(model)),
|
|
2051
2113
|
cancel_controller: turn.controller,
|
|
2052
2114
|
model_name: bare_for_backend,
|
|
2053
2115
|
max_tool_output_chars: max_tool_output_chars,
|
|
@@ -2059,6 +2121,18 @@ module Samagotchi
|
|
|
2059
2121
|
end
|
|
2060
2122
|
private :generate
|
|
2061
2123
|
|
|
2124
|
+
# The ref (host:model, as asked) a turn's generations ran on: the model
|
|
2125
|
+
# #generate resolved, after the before_turn hooks (which may switch it).
|
|
2126
|
+
# SessionMetrics keys its per-model segments on it. nil when it can't be
|
|
2127
|
+
# named (the turn's record then has none).
|
|
2128
|
+
def generation_model_ref(model)
|
|
2129
|
+
model.ref
|
|
2130
|
+
rescue StandardError => e
|
|
2131
|
+
Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2132
|
+
nil
|
|
2133
|
+
end
|
|
2134
|
+
private :generation_model_ref
|
|
2135
|
+
|
|
2062
2136
|
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
2063
2137
|
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
2064
2138
|
# Entries before it never move between the turns (#turn_messages adds
|
|
@@ -2594,13 +2668,17 @@ module Samagotchi
|
|
|
2594
2668
|
#
|
|
2595
2669
|
# With a :generation_progress hook registered, a Hooks::StreamWatch gets
|
|
2596
2670
|
# each chunk's thinking and text after the UIs had it.
|
|
2597
|
-
|
|
2671
|
+
#
|
|
2672
|
+
# +model_ref+: the turn's model (#generation_model_ref), added to each
|
|
2673
|
+
# :generation_started as `model_ref:` (both loops send through here).
|
|
2674
|
+
def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
|
|
2598
2675
|
watch = stream_watch(cancel_controller)
|
|
2599
2676
|
proc do |event|
|
|
2600
2677
|
progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
|
|
2601
2678
|
@steer_cut.observe(event, progress)
|
|
2602
2679
|
case event[:type]
|
|
2603
2680
|
when :generation_started
|
|
2681
|
+
event = event.merge(model_ref: model_ref) if model_ref
|
|
2604
2682
|
@thinking_notices.generation_started
|
|
2605
2683
|
watch&.started(event[:iteration])
|
|
2606
2684
|
when :generation_retrying
|
|
@@ -2626,13 +2704,15 @@ module Samagotchi
|
|
|
2626
2704
|
|
|
2627
2705
|
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2628
2706
|
# {decode_tps:, source:}, nil without one), the session's running
|
|
2629
|
-
# token totals (`tokens:`, the snapshot's block) and
|
|
2707
|
+
# token totals (`tokens:`, the snapshot's block) and its per-model rows
|
|
2708
|
+
# (`model_segments:`, the snapshot's) and whether its served
|
|
2630
2709
|
# model is an expected one (`served_expected_by:`, its host): the web's info bar
|
|
2631
2710
|
# reads them from the stream. SessionMetrics closes the generation here,
|
|
2632
2711
|
# so the event it gets next finds it closed.
|
|
2633
2712
|
def with_generation_report(event)
|
|
2634
2713
|
report = @metrics.finish_generation
|
|
2635
|
-
event.merge(speed: report.speed&.to_h, tokens: report.tokens,
|
|
2714
|
+
event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
|
|
2715
|
+
served_expected_by: served_expected_by(event[:served_model]))
|
|
2636
2716
|
rescue StandardError => e
|
|
2637
2717
|
Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2638
2718
|
event
|
|
@@ -943,19 +943,22 @@ module Samagotchi
|
|
|
943
943
|
private :emit_llm_context
|
|
944
944
|
|
|
945
945
|
# A request's re-prefilled tokens, for both loops: of the prompt the
|
|
946
|
-
# last request on +model+
|
|
947
|
-
# server didn't reuse from its cache this
|
|
948
|
-
# +cached+), so an LLM context edit's cache break
|
|
949
|
-
# anything else that rewrote the prompt's middle or
|
|
950
|
-
# under TokenUsage::REPREFILL_MIN_TOKENS (a cache
|
|
951
|
-
# a server that reports no cached count (or 0: a
|
|
952
|
-
# from no cache), the first request, a model
|
|
953
|
-
#
|
|
946
|
+
# last request on +model+ through this client (the host) sent (this
|
|
947
|
+
# kernel's, any turn), what the server didn't reuse from its cache this
|
|
948
|
+
# time (that prompt less +cached+), so an LLM context edit's cache break
|
|
949
|
+
# shows, and so does anything else that rewrote the prompt's middle or
|
|
950
|
+
# lost the cache. nil under TokenUsage::REPREFILL_MIN_TOKENS (a cache
|
|
951
|
+
# block's rounding), for a server that reports no cached count (or 0: a
|
|
952
|
+
# cold cache isn't told from no cache), the first request, a model
|
|
953
|
+
# switch (another bare model, or the same one on another host: the
|
|
954
|
+
# Engine points #client at the new host's; SessionMetrics counts that
|
|
955
|
+
# prefill as the switch's), and the first request after a warm-up
|
|
956
|
+
# (#warmed_up!).
|
|
954
957
|
# @return [Integer, nil]
|
|
955
958
|
def reprefilled_tokens(model, prompt:, cached:)
|
|
956
959
|
previous = @reprefill_baseline
|
|
957
|
-
@reprefill_baseline = { model: model, prompt: prompt.to_i } if prompt.to_i.positive?
|
|
958
|
-
return nil unless previous && previous[:model] == model && cached.to_i.positive?
|
|
960
|
+
@reprefill_baseline = { model: model, client: @client, prompt: prompt.to_i } if prompt.to_i.positive?
|
|
961
|
+
return nil unless previous && previous[:model] == model && previous[:client].equal?(@client) && cached.to_i.positive?
|
|
959
962
|
|
|
960
963
|
tokens = previous[:prompt] - cached.to_i
|
|
961
964
|
tokens >= TokenUsage::REPREFILL_MIN_TOKENS ? tokens : nil
|
|
@@ -125,7 +125,14 @@ module Samagotchi
|
|
|
125
125
|
raise ArgumentError, "unknown llm_context field #{field}" unless FIELDS.include?(field)
|
|
126
126
|
|
|
127
127
|
text = word.to_s.strip
|
|
128
|
-
|
|
128
|
+
value = if text.casecmp?(DEFAULT_WORD)
|
|
129
|
+
nil
|
|
130
|
+
elsif field == :budget_tokens
|
|
131
|
+
LLMContextStrategy.budget_tokens(text)
|
|
132
|
+
else
|
|
133
|
+
send(:"parse_#{field}", text)
|
|
134
|
+
end
|
|
135
|
+
[field, value]
|
|
129
136
|
end
|
|
130
137
|
(current || new).with(**fields)
|
|
131
138
|
end
|
|
@@ -152,11 +159,6 @@ module Samagotchi
|
|
|
152
159
|
rule || raise(ArgumentError, "unknown llm_context apply #{text} (#{LLMContextStrategy::APPLIES.join(", ")})")
|
|
153
160
|
end
|
|
154
161
|
|
|
155
|
-
# "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
|
|
156
|
-
# .update reaches it (and the other parse_<field>s) by its name.
|
|
157
|
-
# @return [Integer] 0 for off
|
|
158
|
-
def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
|
|
159
|
-
|
|
160
162
|
private_class_method :file_value, :file_strategy, :file_budget
|
|
161
163
|
end
|
|
162
164
|
end
|
|
@@ -55,6 +55,7 @@ module Samagotchi
|
|
|
55
55
|
prune_dropped_hooks if @upgrade && @existing && !@dry_run
|
|
56
56
|
verify_hook_checksums if @manifest && @strict
|
|
57
57
|
incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
|
|
58
|
+
.reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
|
|
58
59
|
guardrail_files = install_guardrails(incoming_rules)
|
|
59
60
|
plugin_file = install_plugin
|
|
60
61
|
script_files = install_scripts
|
|
@@ -81,11 +82,14 @@ module Samagotchi
|
|
|
81
82
|
if hook_entries.empty?
|
|
82
83
|
Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each { |p| hook_entries[File.basename(p)] = BundleHook.new }
|
|
83
84
|
end
|
|
84
|
-
hook_entries.
|
|
85
|
+
hook_entries.keys.each do |basename|
|
|
85
86
|
next unless basename.is_a?(String) && !basename.empty?
|
|
86
87
|
|
|
87
|
-
@bundle_hooks << basename
|
|
88
88
|
src = File.join(@bundle_dir, "hooks", basename)
|
|
89
|
+
# A linked hook isn't the bundle's: not installed, counted or recorded.
|
|
90
|
+
next hook_entries.delete(basename) if (File.symlink?(src) || File.exist?(src)) && linked_out?(src, "hooks/#{basename}")
|
|
91
|
+
|
|
92
|
+
@bundle_hooks << basename
|
|
89
93
|
install_hook(basename, src) if File.exist?(src)
|
|
90
94
|
end
|
|
91
95
|
end
|
|
@@ -120,6 +124,8 @@ module Samagotchi
|
|
|
120
124
|
def install_unlisted_hooks
|
|
121
125
|
Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each do |src|
|
|
122
126
|
basename = File.basename(src)
|
|
127
|
+
next if linked_out?(src, "hooks/#{basename}")
|
|
128
|
+
|
|
123
129
|
@bundle_hooks << basename
|
|
124
130
|
if @dry_run
|
|
125
131
|
@results[basename] = { status: "would_install" }
|
|
@@ -207,6 +213,7 @@ module Samagotchi
|
|
|
207
213
|
|
|
208
214
|
src = File.join(@bundle_dir, plugin[:file])
|
|
209
215
|
raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
|
|
216
|
+
raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
|
|
210
217
|
|
|
211
218
|
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
|
|
212
219
|
@warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
|
|
@@ -243,9 +250,9 @@ module Samagotchi
|
|
|
243
250
|
def install_scripts
|
|
244
251
|
scripts = @manifest&.scripts || {}
|
|
245
252
|
scripts.each_key do |file|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
raise Installer::InstallError, "
|
|
253
|
+
src = File.join(@bundle_dir, "scripts", file)
|
|
254
|
+
raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
|
|
255
|
+
raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
|
|
249
256
|
end
|
|
250
257
|
return {} if @dry_run
|
|
251
258
|
|
|
@@ -274,6 +281,28 @@ module Samagotchi
|
|
|
274
281
|
end
|
|
275
282
|
end
|
|
276
283
|
|
|
284
|
+
# Whether the bundle file +src+ reads through a symlink: it is one, or
|
|
285
|
+
# it (or its dir, hooks/ say) resolves outside the bundle dir. A tar,
|
|
286
|
+
# zip or git repo keeps links; copying through one would install any
|
|
287
|
+
# file the user can read. MemoryFiles skips linked memories the same
|
|
288
|
+
# way.
|
|
289
|
+
def linked?(src)
|
|
290
|
+
return true if File.symlink?(src)
|
|
291
|
+
|
|
292
|
+
root = File.realpath(@bundle_dir)
|
|
293
|
+
!File.realpath(src).start_with?("#{root}/")
|
|
294
|
+
rescue SystemCallError
|
|
295
|
+
true
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# linked? with a "<label> is a symlink (not installed)" warning.
|
|
299
|
+
def linked_out?(src, label)
|
|
300
|
+
return false unless linked?(src)
|
|
301
|
+
|
|
302
|
+
@warnings << "#{label} is a symlink (not installed)"
|
|
303
|
+
true
|
|
304
|
+
end
|
|
305
|
+
|
|
277
306
|
# The hook loader skips every hook of a bundle whose requires_chi this
|
|
278
307
|
# chi doesn't meet (Hooks::BundleLoader); say so at install, as
|
|
279
308
|
# install_plugin does for the plugin.
|
|
@@ -398,11 +398,10 @@ module Samagotchi
|
|
|
398
398
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
399
399
|
end
|
|
400
400
|
|
|
401
|
-
# Use zip CLI like source.rb does with unzip
|
|
402
|
-
Dir.chdir
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
end
|
|
401
|
+
# Use zip CLI like source.rb does with unzip. It runs in the staging
|
|
402
|
+
# dir (chdir:): Dir.chdir would move the whole process.
|
|
403
|
+
result = system("zip", "-r", out_path, ".", chdir: staging)
|
|
404
|
+
raise BuildError, "zip failed for #{out_path} (is zip installed?)" unless result && File.exist?(out_path)
|
|
406
405
|
end
|
|
407
406
|
|
|
408
407
|
def tar_staging(staging, out_path, format)
|
|
@@ -411,15 +410,13 @@ module Samagotchi
|
|
|
411
410
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
412
411
|
end
|
|
413
412
|
|
|
414
|
-
# Use tar CLI
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
raise BuildError, "tar failed for #{out_path}" unless result && File.exist?(out_path)
|
|
422
|
-
end
|
|
413
|
+
# Use tar CLI, in the staging dir (chdir:, as zip_staging).
|
|
414
|
+
cmd = case format
|
|
415
|
+
when :tar_gz, :tgz then ["tar", "-czf", out_path, "."]
|
|
416
|
+
when :tar then ["tar", "-cf", out_path, "."]
|
|
417
|
+
end
|
|
418
|
+
result = system(*cmd, chdir: staging)
|
|
419
|
+
raise BuildError, "tar failed for #{out_path}" unless result && File.exist?(out_path)
|
|
423
420
|
end
|
|
424
421
|
end
|
|
425
422
|
end
|