samagotchi 0.2.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -94,14 +94,23 @@ module Samagotchi
94
94
  end
95
95
  end
96
96
 
97
+ # Yields [name, data] for each installed bundle with hooks, by name.
98
+ # A manifest that doesn't parse is yielded as {error:} when its
99
+ # bundle has a hooks/ dir, and skipped otherwise; the rest still load.
97
100
  def self.each_installed_holding_hooks
98
101
  return enum_for(:each_installed_holding_hooks) unless block_given?
99
102
  dir = self.bundles_dir
100
103
  return unless Dir.exist?(dir)
101
104
  Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
102
- data = JSON.parse(File.read(mjson), symbolize_names: true)
103
- next unless data && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
104
- yield File.basename(File.dirname(mjson)), data
105
+ name = File.basename(File.dirname(mjson))
106
+ begin
107
+ data = JSON.parse(File.read(mjson), symbolize_names: true)
108
+ rescue JSON::ParserError, SystemCallError => e
109
+ yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "hooks"))
110
+ next
111
+ end
112
+ next unless data.is_a?(Hash) && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
113
+ yield name, data
105
114
  end
106
115
  end
107
116
 
@@ -116,8 +125,10 @@ module Samagotchi
116
125
  # snapshot
117
126
  # @param requires_chi [String, nil] the manifest's requirement
118
127
  # @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
128
+ # @param conflicts [Array<String>] files an upgrade kept with local
129
+ # edits that conflict with it; their entries get conflict: true
119
130
  def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
120
- guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil)
131
+ guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [])
121
132
  FileUtils.mkdir_p(@bundle_dir)
122
133
  bases_dir = File.join(@bundle_dir, "bases")
123
134
  FileUtils.mkdir_p(bases_dir)
@@ -134,6 +145,7 @@ module Samagotchi
134
145
  # Save base snapshot — use file_key as-is (it already includes .md).
135
146
  File.write(File.join(bases_dir, file_key), content)
136
147
  merged_entries[file_key] = { checksum: checksum }
148
+ merged_entries[file_key][:conflict] = true if conflicts.include?(file_key.to_s)
137
149
  end
138
150
 
139
151
  # Prune stale base snapshots for files no longer in the bundle.
@@ -226,14 +238,29 @@ module Samagotchi
226
238
  manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
227
239
  manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
228
240
 
229
- # Write aside and rename, so a reader in another process (a parallel
230
- # chi start) never parses a truncated manifest.json.
231
- manifest_path = File.join(@bundle_dir, "manifest.json")
232
- tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
233
- File.write(tmp_path, JSON.pretty_generate(manifest_data))
234
- File.rename(tmp_path, manifest_path)
235
- ensure
236
- FileUtils.rm_f(tmp_path) if tmp_path
241
+ write_manifest(manifest_data)
242
+ end
243
+
244
+ # After a conflict was resolved by hand: each file's base becomes the
245
+ # bundle's version (the incoming file, else what's on disk now) and
246
+ # its conflict mark goes. The rest of the manifest stays as is.
247
+ # @param conflicts [Hash{String => Hash}] Installer#conflicts
248
+ def resolve_conflicts(conflicts)
249
+ return unless installed?
250
+
251
+ raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
252
+ raw["files"] ||= {}
253
+ conflicts.each do |file_key, info|
254
+ key = file_key.to_s
255
+ next unless raw["files"].key?(key)
256
+ src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
257
+ next unless src
258
+
259
+ content = File.read(src)
260
+ File.write(base_path(key), content)
261
+ raw["files"][key] = { "checksum" => Digest::SHA256.hexdigest(content) }
262
+ end
263
+ write_manifest(raw)
237
264
  end
238
265
 
239
266
  # Reads provenance data (returns nil if not installed).
@@ -243,6 +270,18 @@ module Samagotchi
243
270
  JSON.parse(File.read(manifest_path), symbolize_names: true)
244
271
  end
245
272
 
273
+ # Write aside and rename, so a reader in another process (a parallel
274
+ # chi start) never parses a truncated manifest.json.
275
+ def write_manifest(manifest_data)
276
+ manifest_path = File.join(@bundle_dir, "manifest.json")
277
+ tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
278
+ File.write(tmp_path, JSON.pretty_generate(manifest_data))
279
+ File.rename(tmp_path, manifest_path)
280
+ ensure
281
+ FileUtils.rm_f(tmp_path) if tmp_path
282
+ end
283
+ private :write_manifest
284
+
246
285
  # Returns the path to a base snapshot for a given file key.
247
286
  def base_path(file_key)
248
287
  File.join(@bundle_dir, "bases", file_key)
@@ -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
@@ -139,7 +162,7 @@ module Samagotchi
139
162
  "~/.config/samagotchi/config.yml"
140
163
  end
141
164
  "no model configured: set default.model in #{path} to the model id your server serves " \
142
- "(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md"
165
+ "(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md; or run: chi bootstrap HOST[:PORT]"
143
166
  end
144
167
 
145
168
  def self.from_model_name(model_name)
@@ -18,8 +18,10 @@ module Samagotchi
18
18
  # the answer text.
19
19
  # +messages_partial+ says whether +messages+ leaves out a running turn;
20
20
  # +model_name+ and +state_dir+ are what ctx.sessions forks with.
21
+ # +steer+ takes (text, source) and +stop_turn+ (reason, label), each
22
+ # true when it acted on a running turn.
21
23
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
22
- :ask_model, :model_name, :state_dir, keyword_init: true)
24
+ :ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, keyword_init: true)
23
25
 
24
26
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
25
27
  # sent nothing usable. The message says why, for the user.
@@ -178,6 +180,25 @@ module Samagotchi
178
180
  # Whether the running turn was cancelled (a long tool should stop).
179
181
  def cancelled? = !!@host.cancelled.call
180
182
 
183
+ # Put +text+ into the running turn, as a user's steering does: at the
184
+ # loop's next boundary (after the tool calls in flight) it joins the
185
+ # conversation as its own user message, shown in every UI as a nudge
186
+ # from this bundle. It never starts a turn. True means queued: if the
187
+ # model answers first, or the turn ends, it is dropped (logged).
188
+ # Callable from a hook, a command (an anytime one runs beside the
189
+ # turn) or your own thread.
190
+ # @return [Boolean] whether a turn was running and the text queued
191
+ def steer(text)
192
+ !!@host.steer&.call(text.to_s, @bundle)
193
+ end
194
+
195
+ # Stop the running turn, after a notice with +reason+, as a hook's
196
+ # event[:stop_turn] does (for commands and threads).
197
+ # @return [Boolean] whether a running turn was stopped now
198
+ def stop_turn(reason)
199
+ !!@host.stop_turn&.call(reason.to_s, @label)
200
+ end
201
+
181
202
  private
182
203
 
183
204
  def deep_freeze(value)
@@ -33,9 +33,11 @@ module Samagotchi
33
33
  # the first user message, by default)
34
34
  # @param prompt [String, nil] the child's first turn
35
35
  # @return [String] the child's id
36
- # @raise [Error] no session yet, or too many children running
36
+ # @raise [Error] no session yet, a scratch session (the child would
37
+ # outlive it), or too many children running
37
38
  def fork(messages:, title: nil, prompt: nil)
38
39
  parent_id = @host.session_id.call or raise Error, "this session has no id yet"
40
+ raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
39
41
  state_dir = self.state_dir
40
42
  prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
41
43
  check_children(parent_id, state_dir) if prompt
@@ -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