samagotchi 0.3.0 → 0.4.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. metadata +12 -1
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "installer"
6
+ require_relative "listing"
7
+ require_relative "manifest"
8
+ require_relative "merger"
9
+ require_relative "provenance"
10
+ require_relative "source"
11
+ require_relative "status"
12
+ require_relative "system_bundle"
13
+ require_relative "../version"
14
+
15
+ module Samagotchi
16
+ module MemoryBundle
17
+ # What `chi update` does to the installed bundles that chi ships: one
18
+ # Row per installed bundle (the system bundle aside: SystemBundle.sync
19
+ # has it). A bundle is updated when it was installed from a shipped dir
20
+ # (its source is …/lib/samagotchi/bundles/<dir>, in a gem or a
21
+ # checkout), chi ships it under the same name, the shipped version is
22
+ # newer and this chi meets its requires_chi. It never installs a
23
+ # bundle that isn't installed and never downgrades.
24
+ #
25
+ # .md files take the Installer's 3-way merge: a conflict keeps the
26
+ # user's file (kept). Hooks, rules and the plugin are replaced; an
27
+ # edited one (which didn't load: its sha no longer matched) is listed
28
+ # in replaced.
29
+ module ShippedUpdate
30
+ # status: :would_update (plan), :updated, :up_to_date, :skipped,
31
+ # :failed. note says why for skipped/failed. kept and replaced are
32
+ # file names; under plan they are what apply would keep or replace.
33
+ Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, keyword_init: true)
34
+
35
+ SHIPPED_SOURCE = %r{/lib/samagotchi/bundles/[^/]+/?\z}
36
+
37
+ module_function
38
+
39
+ # Read-only.
40
+ # @return [Array<Row>] by name
41
+ def plan(shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION)
42
+ shipped = Listing.shipped(dir: shipped_dir).to_h { |s| [s.name, s] }
43
+ installed_names.map do |name|
44
+ plan_row(name, shipped[name], shipped_dir, chi_version)
45
+ end
46
+ end
47
+
48
+ # The shipped bundles not installed (what the footer lists).
49
+ # @return [Array<Listing::Shipped>]
50
+ def not_installed(shipped_dir: SourceNormalizer::SHIPPED_DIR)
51
+ names = installed_names
52
+ Listing.shipped(dir: shipped_dir).reject { |s| names.include?(s.name) }
53
+ end
54
+
55
+ # Runs the Installer for each :would_update row; the others pass
56
+ # through. A failing bundle doesn't stop the rest.
57
+ # @return [Array<Row>]
58
+ def apply(rows)
59
+ rows.map do |row|
60
+ next row unless row.status == :would_update
61
+
62
+ installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true)
63
+ installer.run
64
+ row.dup.tap do |r|
65
+ r.status = :updated
66
+ r.kept = installer.conflicts.keys
67
+ end
68
+ rescue StandardError => e
69
+ row.dup.tap do |r|
70
+ r.status = :failed
71
+ r.note = e.message.lines.first.to_s.strip
72
+ end
73
+ end
74
+ end
75
+
76
+ def installed_names
77
+ dir = Provenance.bundles_dir
78
+ return [] unless File.directory?(dir)
79
+
80
+ Dir.children(dir).reject { |e| e.start_with?(".") || e == SystemBundle::BUNDLE_NAME }
81
+ .select { |e| File.file?(File.join(dir, e, "manifest.json")) }.sort
82
+ end
83
+
84
+ def plan_row(name, ship, shipped_dir, chi_version)
85
+ row = Row.new(name: name, kept: [], replaced: [])
86
+ data = begin
87
+ Provenance.new(name: name).read
88
+ rescue JSON::ParserError, SystemCallError
89
+ nil
90
+ end
91
+ return skip(row, "manifest.json unreadable") unless data.is_a?(Hash)
92
+
93
+ row.from = data[:version].to_s
94
+ row.scope = data[:scope].to_s.empty? ? "system" : data[:scope].to_s
95
+ return skip(row, "not from chi") unless ship && data[:source].to_s.match?(SHIPPED_SOURCE)
96
+
97
+ row.to = ship.version.to_s
98
+ return skip(row, "newer than shipped: left") if Listing.newer?(row.from, row.to)
99
+ return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
100
+
101
+ row.source_dir = File.join(shipped_dir, ship.source)
102
+ manifest = Manifest.read(dir: row.source_dir)
103
+ if Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
104
+ return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
105
+ end
106
+ return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
107
+
108
+ row.kept = conflicts(row, data)
109
+ row.replaced = edited_executables(name, data)
110
+ row.status = :would_update
111
+ row
112
+ end
113
+
114
+ def skip(row, note, keep_to: false)
115
+ row.to = nil unless keep_to
116
+ row.status = :skipped
117
+ row.note = note
118
+ row
119
+ end
120
+
121
+ # The .md files whose local edits conflict with the shipped version.
122
+ def conflicts(row, data)
123
+ provenance = Provenance.new(name: row.name)
124
+ target_dir = Status.resolve_target_dir(row.scope)
125
+ return [] unless data[:files]
126
+
127
+ Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort.select do |key|
128
+ Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
129
+ incoming_path: File.join(row.source_dir, key)) == :conflict
130
+ end
131
+ end
132
+
133
+ # Installed hooks, rules and the plugin whose sha differs from the
134
+ # recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
135
+ def edited_executables(name, data)
136
+ provenance = Provenance.new(name: name)
137
+ edited = []
138
+ (data[:hooks] || {}).each do |file, meta|
139
+ edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file.to_s), meta)
140
+ end
141
+ (data[:guardrails] || {}).each do |file, meta|
142
+ edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file.to_s), meta)
143
+ end
144
+ plugin = provenance.plugin_path(data)
145
+ edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin, data[:plugin])
146
+ edited
147
+ end
148
+
149
+ def edited?(path, meta)
150
+ recorded = meta.is_a?(Hash) ? meta[:sha256].to_s.delete_prefix("sha256:") : ""
151
+ return false if recorded.empty? || !File.file?(path)
152
+
153
+ Digest::SHA256.hexdigest(File.binread(path)) != recorded
154
+ end
155
+ end
156
+ end
157
+ end
@@ -31,6 +31,7 @@ module Samagotchi
31
31
  missing = !File.exist?(target_path)
32
32
  index_present = index_has_entry?(scope, file_key_str)
33
33
  details[file_key_str] = {
34
+ conflict: meta.is_a?(Hash) && meta[:conflict] == true,
34
35
  stored_checksum: stored_checksum,
35
36
  current_checksum: current_checksum,
36
37
  base_checksum: base_checksum,
@@ -96,11 +97,13 @@ module Samagotchi
96
97
  end
97
98
  end
98
99
 
100
+ # Index lines name the entry without ".md" (Installer#update_target_index,
101
+ # memory_write); a legacy "name.md" line counts too.
99
102
  def self.index_has_entry?(scope, file_key)
100
103
  path = IndexUpdater.index_path_for(scope)
101
104
  return false unless path && File.exist?(path)
102
105
  content = File.read(path)
103
- content.match?(IndexUpdater.managed_pattern(file_key))
106
+ [file_key.delete_suffix(".md"), file_key].uniq.any? { |name| content.match?(IndexUpdater.managed_pattern(name)) }
104
107
  end
105
108
  end
106
109
  end
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require_relative "installer"
5
5
  require_relative "provenance"
6
6
  require_relative "manifest"
7
+ require_relative "merger"
7
8
  require_relative "../log"
8
9
 
9
10
  module Samagotchi
@@ -27,40 +28,72 @@ module Samagotchi
27
28
 
28
29
  module_function
29
30
 
31
+ # What sync did (or, with dry_run, would do). status is :installed,
32
+ # :updated, :restored (missing files put back), :up_to_date,
33
+ # :newer_installed (left as is), :skipped (SKIP_ENV or no shipped
34
+ # bundle) or :failed (error: says why); kept lists the files whose
35
+ # local edits conflicted with the update and were kept.
36
+ Result = Struct.new(:from, :to, :status, :kept, :warnings, :error, keyword_init: true) do
37
+ def changed? = %i[installed updated restored].include?(status)
38
+ end
39
+
30
40
  # Ensure the system bundle is installed and up-to-date.
31
41
  # Idempotent and warn-only: never raises to callers.
32
42
  # Safe for TUI and non-TUI (web/worker) paths — Engine calls this.
33
43
  def ensure!
34
- return false if skip?
35
- return false unless File.directory?(GEM_BUNDLE_DIR)
44
+ result = sync
45
+ case result.status
46
+ when :failed
47
+ Log.error(:memory, "system_bundle_failed", echo: "[samagotchi-system] #{result.error}")
48
+ when :newer_installed
49
+ with_lock { note_newer_installed(result.from, result.to) }
50
+ end
51
+ result.kept.each do |file_key|
52
+ Log.info(:memory, "system_local_edit_kept", echo: "[samagotchi-system] kept local edit in #{file_key} (bundle v#{result.to} has update — run: chi bundle status #{BUNDLE_NAME} / chi bundle diff #{BUNDLE_NAME} #{file_key})", file: file_key.to_s)
53
+ end
54
+ result.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") }
55
+ result.changed?
56
+ end
57
+
58
+ # Install, upgrade or repair the installed system bundle to match the
59
+ # shipped one, under the lock. Never raises; logs nothing (ensure!
60
+ # and chi update report the Result).
61
+ # @param dry_run [Boolean] classify only, write nothing
62
+ # @return [Result]
63
+ def sync(dry_run: false)
64
+ return result(:skipped, error: "#{SKIP_ENV} is set") if skip?
65
+ return result(:skipped, error: "no shipped system bundle") unless File.directory?(GEM_BUNDLE_DIR)
36
66
 
37
67
  begin
38
68
  gem_manifest = Manifest.read(dir: GEM_BUNDLE_DIR)
39
69
  rescue Manifest::ValidationError => e
40
- Log.error(:memory, "system_manifest_invalid", echo: "[samagotchi-system] invalid gem manifest: #{e.message}")
41
- return false
70
+ return result(:failed, error: "invalid gem manifest: #{e.message}")
42
71
  end
72
+ to = gem_manifest.version.to_s
43
73
 
44
74
  with_lock do
45
75
  data = Provenance.new(name: BUNDLE_NAME).read
76
+ from = data && data[:version].to_s
46
77
 
47
78
  if data.nil?
48
- install_fresh(gem_manifest)
49
- elsif installed_newer?(data[:version], gem_manifest.version)
79
+ dry_run ? result(:installed, to: to) : install_fresh(to)
80
+ elsif installed_newer?(from, to)
50
81
  # Running an older checkout/gem: never downgrade the user's system memories
51
82
  # (not even to restore a missing file). `chi self` shows installed vs shipped.
52
- note_newer_installed(data[:version], gem_manifest.version)
53
- false
54
- elsif data[:version].to_s != gem_manifest.version.to_s
55
- upgrade_existing(gem_manifest, data)
83
+ result(:newer_installed, from: from, to: to)
84
+ elsif from != to
85
+ dry_run ? result(:updated, from: from, to: to, kept: conflicting(gem_manifest)) : upgrade_existing(from, to)
56
86
  else
57
87
  # Already at desired version — still verify files present (e.g. user deleted one)
58
- verify_files_present(gem_manifest, data)
88
+ verify_files_present(gem_manifest, from, dry_run)
59
89
  end
60
90
  end
61
91
  rescue StandardError => e
62
- Log.error(:memory, "system_bundle_failed", echo: "[samagotchi-system] ensure failed: #{e.class}: #{e.message}", error: e.class.name)
63
- false
92
+ result(:failed, error: "ensure failed: #{e.class}: #{e.message}")
93
+ end
94
+
95
+ def result(status, from: nil, to: nil, kept: [], warnings: [], error: nil)
96
+ Result.new(from: from, to: to, status: status, kept: kept, warnings: warnings, error: error)
64
97
  end
65
98
 
66
99
  # Parallel chi starts on one config dir take turns: the second one reads
@@ -101,7 +134,7 @@ module Samagotchi
101
134
  File.write(path, "#{pair}\n", mode: "a")
102
135
  end
103
136
 
104
- def install_fresh(gem_manifest)
137
+ def install_fresh(to)
105
138
  installer = Installer.new(
106
139
  source: GEM_BUNDLE_DIR,
107
140
  name: BUNDLE_NAME,
@@ -109,53 +142,48 @@ module Samagotchi
109
142
  force: false,
110
143
  strict: true
111
144
  )
112
- _nd, _manifest = installer.run
113
- unless installer.warnings.empty?
114
- installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") }
115
- end
116
- true
145
+ installer.run
146
+ result(:installed, to: to, warnings: installer.warnings)
117
147
  end
118
148
 
119
- def upgrade_existing(gem_manifest, _existing_data)
120
- installer = Installer.new(
121
- source: GEM_BUNDLE_DIR,
122
- name: BUNDLE_NAME,
123
- scope: SCOPE,
124
- force: false,
125
- strict: true,
126
- upgrade: true
127
- )
128
- _nd, _manifest = installer.run
129
- # Installer already handled fast_forward/keep/noop. Conflicts are kept with warning.
130
- if installer.conflicts.any?
131
- installer.conflicts.each do |file_key, _info|
132
- Log.info(:memory, "system_local_edit_kept", echo: "[samagotchi-system] kept local edit in #{file_key} (bundle v#{gem_manifest.version} has update — run: chi bundle status #{BUNDLE_NAME} / chi bundle diff #{BUNDLE_NAME} #{file_key})", file: file_key.to_s)
133
- end
134
- end
135
- installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") } unless installer.warnings.empty?
136
- true
149
+ # Installer handles fast_forward/keep/noop; a conflict keeps the local
150
+ # file, and its own "Conflict in" warning is left out (kept says it).
151
+ def upgrade_existing(from, to)
152
+ installer = upgrade_installer
153
+ installer.run
154
+ kept = installer.conflicts.keys
155
+ warnings = installer.warnings.reject { |w| kept.any? { |k| w.start_with?("Conflict in #{k}:") } }
156
+ result(:updated, from: from, to: to, kept: kept, warnings: warnings)
137
157
  end
138
158
 
139
- def verify_files_present(gem_manifest, data)
140
- # If any file from the bundle is missing on disk but provenance says it should exist,
141
- # reinstall that file via Installer (which will classify as :install). This handles
142
- # accidental user deletion without forcing a full reinstall.
159
+ # A file from the bundle missing on disk (the user deleted it) is put
160
+ # back via the Installer (which classifies it as :install), without a
161
+ # full reinstall.
162
+ def verify_files_present(gem_manifest, version, dry_run)
143
163
  target_dir = Installer.system_dir
144
164
  missing = gem_manifest.files.keys.any? { |k| !File.exist?(File.join(target_dir, k)) }
145
- return false unless missing
165
+ return result(:up_to_date, from: version, to: version) unless missing
166
+ return result(:restored, from: version, to: version) if dry_run
146
167
 
147
- installer = Installer.new(
148
- source: GEM_BUNDLE_DIR,
149
- name: BUNDLE_NAME,
150
- scope: SCOPE,
151
- force: false,
152
- strict: true,
153
- upgrade: true
154
- )
155
- _nd, _manifest = installer.run
156
- true
168
+ upgrade_installer.run
169
+ result(:restored, from: version, to: version)
170
+ end
171
+
172
+ # The files an upgrade would keep: edited here and changed in the
173
+ # shipped bundle (a dry run's kept).
174
+ def conflicting(gem_manifest)
175
+ provenance = Provenance.new(name: BUNDLE_NAME)
176
+ gem_manifest.files.keys.map(&:to_s).sort.select do |key|
177
+ Merger.classify(base_path: provenance.base_path(key), current_path: File.join(Installer.system_dir, key),
178
+ incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
179
+ end
180
+ end
181
+
182
+ def upgrade_installer
183
+ Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true)
157
184
  end
158
- private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present
185
+ private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present,
186
+ :upgrade_installer, :result, :conflicting
159
187
  end
160
188
  end
161
189
  end
@@ -14,6 +14,9 @@ module Samagotchi
14
14
  # No model anywhere (--model, default.model in config.yml, the env): a
15
15
  # first run before any config. An ArgumentError, as before.
16
16
  MissingModel = Class.new(ArgumentError)
17
+ # A host-qualified model whose host isn't configured (a MissingModel,
18
+ # so every surface that reports a missing model reports it the same way).
19
+ UnknownHost = Class.new(MissingModel)
17
20
 
18
21
  attr_reader :name, :turn_start, :turn_end,
19
22
  :tool_call_open, :tool_call_close,
@@ -130,6 +133,26 @@ module Samagotchi
130
133
  value
131
134
  end
132
135
 
136
+ # Raises UnknownHost when +model_name+ (or the alias it names) is
137
+ # qualified with a host that isn't configured, instead of sending the
138
+ # whole ref to the default host as a model id. Where a model comes in
139
+ # (an Engine starting or switching, a spawned session) calls it.
140
+ # @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
141
+ # entries); config.yml's by default
142
+ # @return [String] +model_name+
143
+ def self.check_host!(model_name, env: ENV, hosts: nil)
144
+ require_relative "config"
145
+ hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
146
+ ref = Samagotchi::ConfigFile.resolve_model_alias(model_name, env: env, hosts: hosts)
147
+ host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
148
+ return model_name unless host
149
+
150
+ names = hosts.keys.map { |k| k.to_s.downcase }.sort
151
+ near = Samagotchi::Config.near_names(host, names).first(3)
152
+ hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(' or ')}?)"
153
+ raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
154
+ end
155
+
133
156
  # One line for the user: where to set the model.
134
157
  def self.missing_model_message
135
158
  path = begin
@@ -39,8 +39,10 @@ module Samagotchi
39
39
  # what it was before images existed.
40
40
  #
41
41
  # @param vision [VisionContext, nil] the turn's images context
42
+ # @param prefill [String] text after the generation cue, the start of
43
+ # the model's answer (Thinking.native: Qwen's empty thought)
42
44
  # @return [Array(String, Array<String>)]
43
- def self.format_with_images(messages, profile: ModelProfile.default, vision: nil)
45
+ def self.format_with_images(messages, profile: ModelProfile.default, vision: nil, prefill: "")
44
46
  plan = ImagePlan.new(messages, vision, data: :base64)
45
47
  images = []
46
48
  suffixes = messages.each_with_index.map { |m, index| image_lines(plan.items(m, index), profile, images) }
@@ -49,7 +51,7 @@ module Samagotchi
49
51
  else
50
52
  format_with_turn_markers(messages, profile, suffixes)
51
53
  end
52
- [text, images]
54
+ [text + prefill.to_s, images]
53
55
  end
54
56
 
55
57
  private
@@ -90,7 +90,7 @@ module Samagotchi
90
90
 
91
91
  # Get all due reminders whose interval has elapsed.
92
92
  # Returns an Array of {name:, description:, interval_minutes:} hashes.
93
- # Called by Engine#maybe_inject_reminders (synchronously, at run_turn start).
93
+ # Called by Engine#collect_due_reminders (synchronously, at run_turn start).
94
94
  # Thread-safe. Returns a frozen copy so the caller can't mutate internal state.
95
95
  # @return [Array<Hash>] array of due reminder hashes (may be empty)
96
96
  def due_reminders
@@ -103,14 +103,6 @@ module Samagotchi
103
103
  end
104
104
  end
105
105
 
106
- # Get the name of the next due reminder (if any) whose interval has elapsed.
107
- # Kept for backward compatibility with IdleReminders thread.
108
- # @return [String, nil] the reminder name, or nil if none are due
109
- def next_due_name
110
- due = due_reminders
111
- due.first&.fetch(:name)
112
- end
113
-
114
106
  # Mark a reminder as fired — resets its next_fire_at to now + interval.
115
107
  # Called by Engine after a due reminder has been delivered.
116
108
  # @param name [String] the reminder name
@@ -7,6 +7,7 @@ require_relative "context_window"
7
7
  require_relative "session"
8
8
  require_relative "log_path"
9
9
  require_relative "model_profile"
10
+ require_relative "thinking"
10
11
  require_relative "served_model"
11
12
  require_relative "host_registry"
12
13
  require_relative "tools/memory"
@@ -50,6 +51,7 @@ module Samagotchi
50
51
  ["api key", model ? api_key_for(model, env) : "-"],
51
52
  ["loop", model ? loop_for(model, env) : "-"],
52
53
  ["profile", model ? profile_for(model, env) : "-"],
54
+ ["thinking", model ? thinking_for(model, env) : "-"],
53
55
  ["served model", model ? served_model_for(model, env) : "-"],
54
56
  ["context window", context_window(env)],
55
57
  ["bundles", bundles_summary],
@@ -150,6 +152,15 @@ module Samagotchi
150
152
  end
151
153
  end
152
154
 
155
+ # The model's thinking level and where it came from (Thinking.resolve).
156
+ def thinking_for(model, env)
157
+ target = HostRegistry.new(env: env).resolve(model)
158
+ level, source = Thinking.resolve(target, models: ConfigFile.model_settings(env: env))
159
+ source ? "#{level} (#{source})" : level.to_s
160
+ rescue StandardError => e
161
+ "(unknown: #{e.message})"
162
+ end
163
+
153
164
  def host_for(model, env)
154
165
  entry, bare = HostRegistry.new(env: env).host_for_model(model)
155
166
  return "(no hosts configured)" unless entry
@@ -170,14 +181,17 @@ module Samagotchi
170
181
  end
171
182
 
172
183
  # The Chi Helper app's version against this chi's (read from its
173
- # Info.plist; no process or Services checks: chi desktop status has those).
184
+ # Info.plist and launch file; no process or Services checks: chi desktop
185
+ # status has those). An older app is fine while its sources are unchanged.
174
186
  def desktop_summary(env)
175
187
  return "- (macOS only)" unless Desktop.supported?
176
188
 
177
- version = Desktop::MacOS.new(env: env).app_version
189
+ helper = Desktop::MacOS.new(env: env)
190
+ version = helper.app_version
178
191
  return "not installed" unless version
192
+ return "#{version} (matches)" if version == VERSION
179
193
 
180
- version == VERSION ? "#{version} (matches)" : "#{version} (chi is #{VERSION}: chi desktop upgrade)"
194
+ helper.stale? ? "#{version} (chi is #{VERSION}: chi update)" : "#{version} (up to date for chi #{VERSION})"
181
195
  end
182
196
 
183
197
  # "samagotchi-system 0.1.5 (shipped 0.1.5), other 1.0.0"