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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +20 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +14 -3
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +1 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/bundle_command.rb +52 -39
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  19. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/desktop/macos.rb +1 -2
  23. data/lib/samagotchi/effective_model.rb +5 -0
  24. data/lib/samagotchi/engine.rb +84 -4
  25. data/lib/samagotchi/kernel_loop.rb +13 -10
  26. data/lib/samagotchi/llm_context_override.rb +8 -6
  27. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  28. data/lib/samagotchi/memory_bundle/builder.rb +11 -14
  29. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  32. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  33. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  34. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  36. data/lib/samagotchi/model_price.rb +4 -3
  37. data/lib/samagotchi/paths.rb +15 -1
  38. data/lib/samagotchi/session_commands.rb +2 -0
  39. data/lib/samagotchi/session_manager.rb +1 -2
  40. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  41. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  42. data/lib/samagotchi/session_metrics.rb +118 -83
  43. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  44. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  45. data/lib/samagotchi/toolkit.rb +9 -8
  46. data/lib/samagotchi/toolkit_command.rb +2 -2
  47. data/lib/samagotchi/tools/builtins.rb +1 -5
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +3 -0
  50. data/lib/samagotchi/web/public/app.js +4 -3
  51. data/lib/samagotchi/web/public/chains.js +1 -1
  52. data/lib/samagotchi/web/public/composer.js +9 -5
  53. data/lib/samagotchi/web/public/ctx.js +73 -11
  54. data/lib/samagotchi/web/public/index.html +30 -35
  55. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  56. data/lib/samagotchi/web/public/question_cards.js +1 -1
  57. data/lib/samagotchi/web/public/session_state.js +3 -0
  58. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  59. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  60. data/lib/samagotchi/web/public/timing.js +11 -2
  61. 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 = bundle_name_or_fail(expanded_source, "Install") or return 1
150
- run_installer(installer_for(expanded_source, bundle_name, scope: scope, force: force), failed: "Install") do |manifest|
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 "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
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 = bundle_name_or_fail(expanded_source, "Upgrade") or return 1
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 bundle's name, or nil after "<word> failed: …".
197
- def bundle_name_or_fail(expanded_source, word)
198
- bundle_name_for(expanded_source)
199
- rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
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
- nil
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
- # helpers for bundle_name derivation
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.1
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:eb6888e3611b8ed42498cfecab5b430e0ef7a4c2dc7b3c090d015336aa039b11
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
- unless wait.join(QUEUE_SECONDS)
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
- [wait, out, err].each { |t| t.join(1) }
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
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: core
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.8.0"
7
+ requires_chi: ">= 0.60.0"
8
8
  includes:
9
9
  - loop-guard
10
10
  - check-in
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.5.0
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.34.0"
7
+ requires_chi: ">= 0.72.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.75.0
3
+ version: 0.76.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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["XDG_CONFIG_HOME"] = Paths.config_home(env: @env) if env.key?("XDG_CONFIG_HOME")
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.
@@ -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
- def build_stream_event_handler(on_event, cancel_controller: nil)
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 whether its served
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, served_expected_by: served_expected_by(event[:served_model]))
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+ sent (this kernel's, any turn), what the
947
- # server didn't reuse from its cache this time (that prompt less
948
- # +cached+), so an LLM context edit's cache break shows, and so does
949
- # anything else that rewrote the prompt's middle or lost the cache. nil
950
- # under TokenUsage::REPREFILL_MIN_TOKENS (a cache block's rounding), for
951
- # a server that reports no cached count (or 0: a cold cache isn't told
952
- # from no cache), the first request, a model switch, and the first
953
- # request after a warm-up (#warmed_up!).
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
- [field, text.casecmp?(DEFAULT_WORD) ? nil : send(:"parse_#{field}", text)]
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.each_key do |basename|
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
- next if File.file?(File.join(@bundle_dir, "scripts", file))
247
-
248
- raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
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(staging) do
403
- result = system("zip", "-r", out_path, ".")
404
- raise BuildError, "zip failed for #{out_path} (is zip installed?)" unless result && File.exist?(out_path)
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
- Dir.chdir(staging) do
416
- cmd = case format
417
- when :tar_gz, :tgz then ["tar", "-czf", out_path, "."]
418
- when :tar then ["tar", "-cf", out_path, "."]
419
- end
420
- result = system(*cmd)
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