samagotchi 0.18.0 → 0.19.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -1
  3. data/README.md +4 -3
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +15 -2
  6. data/docs/cli.md +41 -9
  7. data/docs/configuration.md +8 -4
  8. data/docs/guardrails.md +8 -0
  9. data/docs/memory.md +4 -0
  10. data/docs/sessions.md +6 -4
  11. data/lib/samagotchi/bridge.rb +25 -9
  12. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  13. data/lib/samagotchi/bridge_client.rb +9 -5
  14. data/lib/samagotchi/bundle_command.rb +2 -1
  15. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  16. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/client.rb +58 -10
  20. data/lib/samagotchi/engine.rb +35 -11
  21. data/lib/samagotchi/guardrail_wiring.rb +64 -5
  22. data/lib/samagotchi/guardrails/approval.rb +10 -4
  23. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  24. data/lib/samagotchi/host_registry.rb +22 -4
  25. data/lib/samagotchi/idle_scheduler.rb +1 -1
  26. data/lib/samagotchi/installed_versions.rb +91 -0
  27. data/lib/samagotchi/live_versions.rb +5 -3
  28. data/lib/samagotchi/llm/http.rb +9 -3
  29. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  31. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  32. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  33. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  34. data/lib/samagotchi/model_overlay.rb +39 -0
  35. data/lib/samagotchi/model_profile.rb +6 -2
  36. data/lib/samagotchi/output_formatter.rb +9 -4
  37. data/lib/samagotchi/relay_desk.rb +9 -0
  38. data/lib/samagotchi/served_model.rb +14 -4
  39. data/lib/samagotchi/session_manager.rb +166 -11
  40. data/lib/samagotchi/session_metrics.rb +36 -13
  41. data/lib/samagotchi/sessions_command.rb +43 -4
  42. data/lib/samagotchi/system_prompt.rb +10 -1
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
  44. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  45. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  46. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  47. data/lib/samagotchi/terminal_ui.rb +65 -16
  48. data/lib/samagotchi/tool_declarations.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +21 -5
  50. data/lib/samagotchi/tools/execute.rb +97 -19
  51. data/lib/samagotchi/turn_note.rb +3 -2
  52. data/lib/samagotchi/update_command.rb +33 -12
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +64 -11
  55. data/lib/samagotchi/web/public/app.js +102 -29
  56. data/lib/samagotchi/web/public/data.js +6 -0
  57. data/lib/samagotchi/web/public/format.js +4 -2
  58. data/lib/samagotchi/web/public/index.html +11 -0
  59. data/lib/samagotchi/web/public/question_card.js +12 -0
  60. data/lib/samagotchi/web/public/question_cards.js +17 -11
  61. data/lib/samagotchi/web/public/scope.js +15 -6
  62. data/lib/samagotchi/web/public/session_state.js +4 -0
  63. data/lib/samagotchi/web/public/turn_events.js +107 -3
  64. data/lib/samagotchi/web/public/update_notice.js +87 -14
  65. data/lib/samagotchi/web/server.rb +28 -1
  66. data/lib/samagotchi/web/session_hub.rb +30 -3
  67. data/lib/samagotchi/web/session_summary.rb +5 -0
  68. data/lib/samagotchi/worker.rb +25 -8
  69. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  70. data/lib/samagotchi/worker_sidecar.rb +7 -5
  71. metadata +4 -1
@@ -56,7 +56,7 @@ module Samagotchi
56
56
  @jobs.each do |job|
57
57
  job.tick
58
58
  rescue StandardError => e
59
- Log.warn(:idle, "tick_failed", echo: "[IdleScheduler] #{job.class} tick failed: #{e.class}: #{e.message}", job: job.class.name, error: e.class.name)
59
+ Log.exception(:idle, "tick_failed", e, echo: "[IdleScheduler] #{job.class} tick failed: #{e.class}: #{e.message}", job: job.class.name)
60
60
  end
61
61
  end
62
62
 
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installed_gem"
4
+ require_relative "version"
5
+
6
+ module Samagotchi
7
+ # The newest chi on this machine, which a long-lived process (chi web, an
8
+ # attaching TUI) compares with its own VERSION to say "chi X is installed;
9
+ # restart". Run from an installed gem: the highest samagotchi-<v>.gemspec
10
+ # in RubyGems' specification dirs, matching the `>= 0.a` a new worker
11
+ # activates (prereleases count). Run from a source checkout: the VERSION
12
+ # in lib/samagotchi/version.rb on disk now (a `git pull` moves it).
13
+ #
14
+ # Cheap enough to poll: each dir is listed again only when its mtime
15
+ # changed, version.rb is re-read only when its mtime did, and nothing
16
+ # calls Gem::Specification.reset (that would disturb the running process).
17
+ #
18
+ # SAMAGOTCHI_INSTALLED_VERSION=<v> (hidden, for smokes and e2e) answers
19
+ # <v> instead.
20
+ class InstalledVersions
21
+ ENV_KEY = "SAMAGOTCHI_INSTALLED_VERSION"
22
+ GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
23
+ VERSION_LINE = /^\s*VERSION\s*=\s*["']([^"']+)["']/
24
+ REQUIREMENT = Gem::Requirement.new(">= 0.a")
25
+
26
+ # @param gem_spec [Gem::Specification, nil] the installed gem this code
27
+ # runs from (nil: a source checkout)
28
+ # @param dirs [#call] → the specification dirs to list
29
+ # @param version_file [String] a checkout's version.rb
30
+ def initialize(env: ENV, gem_spec: InstalledGem.spec, dirs: -> { Gem::Specification.dirs },
31
+ version_file: File.join(InstalledGem::ROOT, "lib", "samagotchi", "version.rb"))
32
+ @env = env
33
+ @gem_spec = gem_spec
34
+ @dirs = dirs
35
+ @version_file = version_file
36
+ @dir_cache = {}
37
+ @file_cache = nil
38
+ @mutex = Mutex.new
39
+ end
40
+
41
+ # @return [String, nil] the newest installed version, or nil when none
42
+ # could be found out
43
+ def newest
44
+ seam = @env[ENV_KEY].to_s
45
+ return seam unless seam.empty?
46
+
47
+ @mutex.synchronize { @gem_spec ? newest_gem : checkout_version }
48
+ end
49
+
50
+ # Whether +installed+ is a newer version than +running+.
51
+ def self.newer?(installed, running = VERSION)
52
+ return false if installed.to_s.empty? || !Gem::Version.correct?(installed) || !Gem::Version.correct?(running)
53
+
54
+ Gem::Version.new(installed) > Gem::Version.new(running)
55
+ end
56
+
57
+ private
58
+
59
+ def newest_gem
60
+ @dirs.call.filter_map { |dir| dir_newest(dir) }.max&.to_s
61
+ end
62
+
63
+ def dir_newest(dir)
64
+ mtime = File.mtime(dir)
65
+ cached = @dir_cache[dir]
66
+ return cached[1] if cached && cached[0] == mtime
67
+
68
+ best = Dir.children(dir).filter_map { |name| GEMSPEC.match(name)&.[](1) }
69
+ .select { |v| Gem::Version.correct?(v) }
70
+ .map { |v| Gem::Version.new(v) }
71
+ .select { |v| REQUIREMENT.satisfied_by?(v) }.max
72
+ @dir_cache[dir] = [mtime, best]
73
+ best
74
+ rescue SystemCallError
75
+ @dir_cache.delete(dir)
76
+ nil
77
+ end
78
+
79
+ def checkout_version
80
+ mtime = File.mtime(@version_file)
81
+ return @file_cache[1] if @file_cache && @file_cache[0] == mtime
82
+
83
+ version = File.read(@version_file)[VERSION_LINE, 1]
84
+ version = nil unless version && Gem::Version.correct?(version)
85
+ @file_cache = [mtime, version]
86
+ version
87
+ rescue SystemCallError
88
+ nil
89
+ end
90
+ end
91
+ end
@@ -13,8 +13,9 @@ module Samagotchi
13
13
  module LiveVersions
14
14
  WEB_TIMEOUT = 0.5
15
15
 
16
- # version is nil for a sidecar written before sidecars carried one.
17
- Worker = Struct.new(:session_id, :version, keyword_init: true)
16
+ # version is nil for a sidecar written before sidecars carried one;
17
+ # restart: the worker takes `chi sessions restart` (its "restart" feature).
18
+ Worker = Struct.new(:session_id, :version, :restart, keyword_init: true)
18
19
 
19
20
  module_function
20
21
 
@@ -25,7 +26,8 @@ module Samagotchi
25
26
  sidecar = WorkerSidecar.live(session_dir, unlink: false)
26
27
  next unless sidecar
27
28
 
28
- Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
29
+ Worker.new(session_id: File.basename(session_dir), version: sidecar.version,
30
+ restart: sidecar.features.include?("restart"))
29
31
  end
30
32
  end
31
33
 
@@ -168,7 +168,7 @@ module Samagotchi
168
168
 
169
169
  # Send +request+ and return the response with its body read.
170
170
  # @param retries [Boolean] false: one attempt, network errors raised as
171
- # is (Net::HTTP's own silent retry of an idempotent GET included)
171
+ # is; either way Net::HTTP's own silent retry is off (see #start)
172
172
  # @param check_status [Boolean] false: return an error response instead
173
173
  # of raising its ProviderError
174
174
  # @raise [RequestCancelled] when +cancel_controller+ cancels (with or
@@ -178,7 +178,7 @@ module Samagotchi
178
178
  identify(request)
179
179
  current = new_attempt_state(uri, request, log_fields, stream: false)
180
180
  attempt = lambda do |state|
181
- start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: retries ? nil : 0) do |http|
181
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
182
182
  state[:http] = http
183
183
  http.request(request).tap do |response|
184
184
  state[:status] = response.code.to_i
@@ -210,7 +210,13 @@ module Samagotchi
210
210
 
211
211
  def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
212
212
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
213
- options[:max_retries] = max_retries if max_retries
213
+ # Net::HTTP retries an idempotent request once on its own (max_retries
214
+ # defaults to 1) when the connection drops before an answer: chi's
215
+ # RetryPolicy owns retries (and the log line says how many were sent),
216
+ # so every request here gets Net::HTTP's silent one off. Callers may
217
+ # still ask for it explicitly (a fetch whose retries: false passes 0).
218
+ retries = max_retries || 0
219
+ options[:max_retries] = retries
214
220
  options[:use_ssl] = true if uri.scheme == "https"
215
221
  Net::HTTP.start(uri.host, uri.port, **options, &block)
216
222
  end
@@ -6,6 +6,7 @@ require "tmpdir"
6
6
  require_relative "manifest"
7
7
  require_relative "placeholder"
8
8
  require_relative "../memory_paths"
9
+ require_relative "../model_overlay"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
@@ -66,7 +67,7 @@ module Samagotchi
66
67
  unless missing.empty?
67
68
  raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(', ')}"
68
69
  end
69
- candidates = allow.map { |k| candidates_by_basename[k] }
70
+ candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
70
71
  else
71
72
  candidates = leave_out_owned(candidates, resolved_name)
72
73
  end
@@ -302,6 +303,28 @@ module Samagotchi
302
303
  end
303
304
  end
304
305
 
306
+ # The named files, each base followed by its model overlays
307
+ # (<name>.<key>.md next to it, ModelOverlay.overlay_file?; a plain
308
+ # tips.v2.md with no tips.md isn't one), with a note listing them. An
309
+ # overlay named without its base is kept, with a warning.
310
+ def with_overlays(allow, candidates_by_basename)
311
+ dir = File.dirname(candidates_by_basename.values.first.to_s)
312
+ overlays = candidates_by_basename.keys.select { |k| ModelOverlay.overlay_file?(File.join(dir, k)) }
313
+ allow.flat_map do |key|
314
+ if overlays.include?(key)
315
+ base = ModelOverlay.base_file_for(key)
316
+ unless allow.include?(base)
317
+ @warnings << "#{key} is a model overlay of #{base}, which the bundle leaves out; it loads only where #{base} exists"
318
+ end
319
+ next [key]
320
+ end
321
+
322
+ own = overlays.select { |k| ModelOverlay.base_file_for(k) == key && !allow.include?(k) }.sort
323
+ @warnings << "Included the model overlays of #{key}: #{own.join(", ")}" unless own.empty?
324
+ [key, *own]
325
+ end.uniq
326
+ end
327
+
305
328
  def normalize_filter_entry(entry)
306
329
  s = entry.to_s.strip
307
330
  # Allow with or without .md extension, but normalize to basename with .md
@@ -29,7 +29,7 @@ module Samagotchi
29
29
  return false unless scope && File.file?(path)
30
30
 
31
31
  name = File.basename(path, ".md")
32
- return false if overlay?(path, name)
32
+ return false if ModelOverlay.overlay_file?(path)
33
33
 
34
34
  IndexUpdater.update_index(scope, name, File.size(path), nil)
35
35
  rescue StandardError => e
@@ -55,14 +55,6 @@ module Samagotchi
55
55
  nil
56
56
  end
57
57
 
58
- def self.overlay?(path, name)
59
- match = File.basename(path).match(ModelOverlay::OVERLAY_SUFFIX_PATTERN)
60
- return false unless match
61
-
62
- base = name.delete_suffix(".#{match[1]}")
63
- File.file?(File.join(File.dirname(path), "#{base}.md"))
64
- end
65
-
66
58
  def self.real(dir)
67
59
  File.realpath(dir)
68
60
  rescue SystemCallError
@@ -12,6 +12,7 @@ require_relative "merger"
12
12
  require_relative "trash"
13
13
  require_relative "../version"
14
14
  require_relative "../bundle_needs"
15
+ require_relative "../model_overlay"
15
16
 
16
17
  module Samagotchi
17
18
  module MemoryBundle
@@ -42,6 +43,7 @@ module Samagotchi
42
43
  @warnings = []
43
44
  @placeholder_warnings = []
44
45
  @conflicts = {}
46
+ @overlays = []
45
47
  @trash = Trash.new(name)
46
48
  end
47
49
 
@@ -56,6 +58,7 @@ module Samagotchi
56
58
  manifest = nil
57
59
  begin
58
60
  normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
61
+ @bundle_dir = normalized_dir
59
62
  manifest = Manifest.read(dir: normalized_dir)
60
63
  rescue SourceNormalizer::UnknownSourceError => e
61
64
  raise InstallError, "Source normalization failed: #{e.message}"
@@ -89,6 +92,7 @@ module Samagotchi
89
92
  Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
90
93
  file_key = File.basename(file_path)
91
94
  all_files_in_bundle << file_key
95
+ note_overlay(file_key, target_dir)
92
96
  target_path = File.join(target_dir, file_key)
93
97
 
94
98
  if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
@@ -550,9 +554,38 @@ module Samagotchi
550
554
  dest
551
555
  end
552
556
 
557
+ # A model overlay (<name>.<key>.md, docs/memory.md) loads only with
558
+ # its base, under the matching model: it gets no index line (every
559
+ # model would see it as a memory of its own). Its base is looked for
560
+ # in the bundle dir, not the target: Dir.glob sorts tips.<key>.md
561
+ # before tips.md, so on a fresh install the base isn't copied yet.
562
+ # An overlay-looking name with no base anywhere is an overlay too,
563
+ # with a warning (a dry run warns as well).
564
+ def note_overlay(file_key, target_dir)
565
+ @bundle_files ||= Dir.glob(File.join(@bundle_dir, "*.md")).map { |f| File.basename(f) }
566
+ return unless ModelOverlay.bundle_overlay?(file_key, bundle_files: @bundle_files, target_dir: target_dir)
567
+
568
+ @overlays << file_key
569
+ base = ModelOverlay.base_file_for(file_key)
570
+ return if @bundle_files.include?(base)
571
+
572
+ @warnings << if File.file?(File.join(target_dir, base))
573
+ "#{file_key} is taken as a model overlay of your #{base}, which the bundle doesn't ship; " \
574
+ "it gets no index line"
575
+ else
576
+ "#{file_key} is a model overlay with no #{base}; it loads only once #{base} exists"
577
+ end
578
+ end
579
+
553
580
  # +owned+: the bundle wrote (owns) the file, so its line says
554
581
  # "· from <bundle>"; a skipped file's line keeps whatever it had.
582
+ # An overlay gets none: a stale one (an earlier install's) is removed.
555
583
  def update_target_index(scope, file_path, file_key, owned: true)
584
+ if @overlays.include?(file_key)
585
+ remove_target_index(scope, file_key) if owned
586
+ return
587
+ end
588
+
556
589
  byte_count = File.exist?(file_path) ? File.size(file_path) : 0
557
590
  entry_name = file_key.delete_suffix(".md")
558
591
  begin
@@ -6,6 +6,7 @@ require_relative "../version"
6
6
  require_relative "index_updater"
7
7
  require_relative "../memory_paths"
8
8
  require_relative "../bundle_needs"
9
+ require_relative "../model_overlay"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
@@ -29,7 +30,9 @@ module Samagotchi
29
30
  base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
30
31
  modified = base_checksum && current_checksum && base_checksum != current_checksum
31
32
  missing = !File.exist?(target_path)
32
- index_present = index_has_entry?(scope, file_key_str)
33
+ # A model overlay has no index line (Installer#note_overlay).
34
+ overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
35
+ index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
33
36
  details[file_key_str] = {
34
37
  conflict: meta.is_a?(Hash) && meta[:conflict] == true,
35
38
  stored_checksum: stored_checksum,
@@ -38,6 +41,7 @@ module Samagotchi
38
41
  modified: modified,
39
42
  missing: missing,
40
43
  index_present: index_present,
44
+ overlay: overlay,
41
45
  target_path: target_path,
42
46
  base_path: base_path
43
47
  }
@@ -6,6 +6,7 @@ require_relative "provenance"
6
6
  require_relative "index_updater"
7
7
  require_relative "merger"
8
8
  require_relative "trash"
9
+ require_relative "../model_overlay"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
@@ -75,7 +76,9 @@ module Samagotchi
75
76
  next unless File.exist?(target_path)
76
77
 
77
78
  @warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
78
- retag(target_scope, target_path, file_key_str, others.first)
79
+ unless ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
80
+ retag(target_scope, target_path, file_key_str, others.first)
81
+ end
79
82
  next
80
83
  end
81
84
  if File.exist?(target_path)
@@ -119,7 +122,8 @@ module Samagotchi
119
122
 
120
123
  private
121
124
 
122
- # A kept shared file's index line names the bundle that still has it.
125
+ # A kept shared file's index line names the bundle that still has it
126
+ # (a model overlay has none: the caller skips it).
123
127
  def retag(scope, path, file_key, bundle)
124
128
  IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
125
129
  rescue StandardError => e
@@ -49,5 +49,44 @@ module Samagotchi
49
49
  # Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
50
50
  # Useful for tooling, tests, and future index suppression.
51
51
  OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
52
+
53
+ # The base file name (`tips.md`) an overlay-looking name
54
+ # (`tips.qwen3.md`) would belong to, or nil when it doesn't match the
55
+ # suffix pattern.
56
+ def self.base_file_for(path)
57
+ match = File.basename(path.to_s).match(OVERLAY_SUFFIX_PATTERN)
58
+ return nil unless match
59
+
60
+ stem = File.basename(path.to_s).delete_suffix(match[0])
61
+ stem.empty? ? nil : "#{stem}.md"
62
+ end
63
+
64
+ # Whether +path+ is a model overlay: its name matches the suffix pattern
65
+ # and its base `<stem>.md` exists in one of +base_dirs+ (default: the
66
+ # file's own dir) or is one of +base_names+. A memory whose name has a
67
+ # dot and a sibling of the same stem reads as an overlay too (a
68
+ # leftover ambiguity).
69
+ def self.overlay_file?(path, base_dirs: [File.dirname(path.to_s)], base_names: [])
70
+ base = base_file_for(path)
71
+ return false unless base
72
+
73
+ base_names.map { |n| File.basename(n.to_s) }.include?(base) ||
74
+ base_dirs.compact.any? { |dir| File.file?(File.join(dir, base)) }
75
+ end
76
+
77
+ # How a bundle's installer, status and uninstaller tell an overlay
78
+ # (they agree, so a line the installer left out isn't "no-index"): the
79
+ # base is one of +bundle_files+ (basenames), or it is nowhere in
80
+ # +target_dir+ (an orphan overlay; it loads once the base exists).
81
+ # A base only in +target_dir+ (an installed memory, maybe the user's
82
+ # own) counts too: the installer warns, so the dot-name ambiguity
83
+ # (notes.v2.md next to a user's notes.md) is visible.
84
+ def self.bundle_overlay?(file_key, bundle_files:, target_dir:)
85
+ base = base_file_for(file_key)
86
+ return false unless base
87
+
88
+ overlay_file?(file_key, base_dirs: [target_dir], base_names: bundle_files) ||
89
+ !File.file?(File.join(target_dir, base))
90
+ end
52
91
  end
53
92
  end
@@ -11,6 +11,10 @@ module Samagotchi
11
11
  # gemma4 — Gemma 4 format
12
12
  class ModelProfile
13
13
  MODEL_ENV = "SAMAGOTCHI_DEFAULT_MODEL"
14
+ # Set beside MODEL_ENV for a worker spawned by a `chi --model X`: X is
15
+ # that run's default, not one its commands' chi should inherit
16
+ # (SessionManager.spawn_options, Tools::Builtins.parent_env).
17
+ MODEL_FROM_CLI_ENV = "SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI"
14
18
  # No model anywhere (--model, default.model in config.yml, the env): a
15
19
  # first run before any config. An ArgumentError, as before.
16
20
  MissingModel = Class.new(ArgumentError)
@@ -112,7 +116,7 @@ module Samagotchi
112
116
  end
113
117
 
114
118
  def self.default
115
- gemma4
119
+ named(DEFAULT_NAME)
116
120
  end
117
121
 
118
122
  def self.normalize(value)
@@ -124,7 +128,7 @@ module Samagotchi
124
128
  when "gemma", "gemma4", "gemma4o"
125
129
  gemma4
126
130
  else
127
- gemma4
131
+ named(DEFAULT_NAME)
128
132
  end
129
133
  end
130
134
 
@@ -45,13 +45,17 @@ module Samagotchi
45
45
  )
46
46
  INDIVIDUAL_RE.freeze
47
47
 
48
- # Angle-bracketed literal tokens stripped wholesale (including the thought
49
- # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
50
- # are removed as a unit.
51
48
  # Angle-bracketed literal tokens stripped wholesale (including the thought
52
49
  # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
53
50
  # are removed as a unit. Pipes are escaped; angle brackets are literal.
54
- LITERALS_RE = /<\|"\|>|<\|\w*channel>thought|<end_of_turn>/.freeze
51
+ LITERALS_RE = /<\|"\|>|<end_of_turn>/.freeze
52
+
53
+ # Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
54
+ # `<channel|>` close, or to the end of the text when the close is missing
55
+ # (ToolCallParser::Gemma strips the same pair). Removing only the marker
56
+ # would leave the model's private reasoning in the answer; the channel is
57
+ # the profile's thinking lane, not output.
58
+ GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m.freeze
55
59
 
56
60
  # Qwen prompt-literal placeholders, e.g. </think>.
57
61
  PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
@@ -88,6 +92,7 @@ module Samagotchi
88
92
  def remove_tokens(text)
89
93
  text.to_s
90
94
  .gsub(QWEN_THINK_RE, '')
95
+ .gsub(GEMMA_THOUGHT_RE, '')
91
96
  .gsub(QWEN_TOOL_CALL_RE, '')
92
97
  .gsub(LITERALS_RE, '')
93
98
  .gsub(INDIVIDUAL_RE, '')
@@ -63,6 +63,15 @@ module Samagotchi
63
63
  end
64
64
  end
65
65
 
66
+ # Whether a relay is open, or settled recently enough that its child
67
+ # may still ask about it: a restart would drop them.
68
+ def active?
69
+ @mutex.synchronize do
70
+ prune
71
+ !@relays.empty?
72
+ end
73
+ end
74
+
66
75
  private
67
76
 
68
77
  def prune
@@ -17,16 +17,26 @@ module Samagotchi
17
17
  name.is_a?(String) && !name.strip.empty? ? name : nil
18
18
  end
19
19
 
20
+ # The separators a provider uses when it decorates a model name: a tag
21
+ # (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
22
+ # owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
23
+ # after the name is a different model (`gpt-4` is not `gpt-4o`).
24
+ EXTENSION_SEPARATORS = [":", "-", "@", "/"].freeze
25
+
20
26
  # Whether +served+ is another model than +asked+. Names that are equal
21
- # (any case) or where one extends the other (a provider dropping
22
- # `:free`, or adding a date) are the same model; unknown names never
23
- # differ.
27
+ # (any case), or where one extends the other at a tag/date/owner
28
+ # separator (a provider dropping `:free`, or adding a date), are the
29
+ # same model; unknown names never differ.
24
30
  def differs?(asked, served)
25
31
  asked = asked.to_s.strip.downcase
26
32
  served = served.to_s.strip.downcase
27
33
  return false if asked.empty? || served.empty?
34
+ return false if asked == served
35
+
36
+ shorter, longer = asked.length <= served.length ? [asked, served] : [served, asked]
37
+ return true unless longer.start_with?(shorter)
28
38
 
29
- !(asked.start_with?(served) || served.start_with?(asked))
39
+ !longer[shorter.length..].start_with?(*EXTENSION_SEPARATORS)
30
40
  end
31
41
  end
32
42
  end