samagotchi 0.3.0 → 0.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -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,
@@ -113,23 +116,37 @@ module Samagotchi
113
116
  end
114
117
  end
115
118
 
116
- def self.required_model_name(model_name = nil, env: ENV)
119
+ def self.required_model_name(model_name = nil)
117
120
  value = model_name.to_s.strip
118
121
  if value.empty?
119
- begin
120
- require_relative "config"
121
- cfg = Samagotchi::Config.get("default.model") rescue nil
122
- value = cfg.to_s.strip unless cfg.nil? || cfg.to_s.strip.empty?
123
- rescue StandardError
124
- nil
125
- end
122
+ require_relative "config"
123
+ value = Samagotchi::Config.get("default.model").to_s.strip
126
124
  end
127
- value = env[MODEL_ENV].to_s.strip if value.empty?
128
125
  raise MissingModel, missing_model_message if value.empty?
129
126
 
130
127
  value
131
128
  end
132
129
 
130
+ # Raises UnknownHost when +model_name+ (or the alias it names) is
131
+ # qualified with a host that isn't configured, instead of sending the
132
+ # whole ref to the default host as a model id. Where a model comes in
133
+ # (an Engine starting or switching, a spawned session) calls it.
134
+ # @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
135
+ # entries); config.yml's by default
136
+ # @return [String] +model_name+
137
+ def self.check_host!(model_name, env: ENV, hosts: nil)
138
+ require_relative "config"
139
+ hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
140
+ ref = Samagotchi::ConfigFile.resolve_model_alias(model_name, env: env, hosts: hosts)
141
+ host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
142
+ return model_name unless host
143
+
144
+ names = hosts.keys.map { |k| k.to_s.downcase }.sort
145
+ near = Samagotchi::Config.near_names(host, names).first(3)
146
+ hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(' or ')}?)"
147
+ raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
148
+ end
149
+
133
150
  # One line for the user: where to set the model.
134
151
  def self.missing_model_message
135
152
  path = begin
@@ -158,7 +175,7 @@ module Samagotchi
158
175
  end
159
176
 
160
177
  def self.from_env
161
- from_model_name(required_model_name(nil, env: ENV))
178
+ from_model_name(required_model_name(nil))
162
179
  end
163
180
 
164
181
  # ── Resolution: which profile a model gets ───────────────────────────
@@ -102,7 +102,8 @@ module Samagotchi
102
102
 
103
103
  # --all: every live session. @return [Integer] exit status
104
104
  def deliver_to_live(text, source)
105
- ids = SessionManager.session_summaries(live: true, include_tests: false, state_dir: @state_dir).map { |s| s[:id] }
105
+ ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
106
+ state_dir: @state_dir).map { |s| s[:id] }
106
107
  if ids.empty?
107
108
  error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
108
109
  return 1
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "session"
4
+ require_relative "turn_note"
4
5
 
5
6
  module Samagotchi
6
7
  # Loaded on first use: session_manager requires terminal_ui, which
@@ -23,7 +24,11 @@ module Samagotchi
23
24
  # @!attribute file [String, nil] the reply's filename, the next cursor
24
25
  # @!attribute question [Hash, nil] the pending question
25
26
  # (:waiting_for_answer)
26
- Result = Struct.new(:status, :text, :file, :question, keyword_init: true)
27
+ # @!attribute outcome [String, nil] how the turn ended when it left no
28
+ # reply (:no_reply): "failed", "canceled" or "completed" (empty), from
29
+ # the session's last_turn; nil when unknown. With "failed", text is
30
+ # the failure's summary when the turn note has one.
31
+ Result = Struct.new(:status, :text, :file, :question, :outcome, keyword_init: true)
27
32
 
28
33
  module_function
29
34
 
@@ -38,7 +43,10 @@ module Samagotchi
38
43
  # messages (a failed, canceled or empty one leaves its note) and went
39
44
  # idle again ends the wait even if it was never seen running, and a
40
45
  # question already pending then is not the answer's. A nil
41
- # messages: skips the count.
46
+ # messages: skips the count. last_turn: (the session's
47
+ # last_turn["ended_at"] then, maybe nil) ends the wait on any turn
48
+ # that ended since, idle again, however fast it ran: a failure after
49
+ # a failure replaces the note and leaves the count as it was.
42
50
  # @param owner_grace [Numeric, nil] seconds with no live worker before
43
51
  # :worker_gone (one that died before its rescue leaves it running)
44
52
  # @return [Result]
@@ -72,13 +80,13 @@ module Samagotchi
72
80
 
73
81
  if session.status == Session::STATUS_RUNNING
74
82
  seen_running = true
75
- elsif seen_running || (baseline&.dig(:messages) && session.messages.size > baseline[:messages])
83
+ elsif seen_running || turn_ended_since?(session, baseline)
76
84
  # It ran and is idle again with no new reply: canceled, failed or
77
85
  # empty. Before it was ever seen running, idle means a
78
86
  # file-delivered message its worker has not picked up yet.
79
87
  # The worker writes the reply before its idle save, so the read
80
88
  # above has it; one more look costs nothing should that change.
81
- return reply_past(id, state_dir: state_dir, cursor: cursor) || Result.new(status: :no_reply)
89
+ return reply_past(id, state_dir: state_dir, cursor: cursor) || no_reply(session, baseline)
82
90
  end
83
91
 
84
92
  if owner_grace
@@ -95,6 +103,42 @@ module Samagotchi
95
103
  end
96
104
  end
97
105
 
106
+ # An idle session whose turn ended after the baseline was taken: its
107
+ # last_turn moved on, or its messages grew (a turn's note).
108
+ def turn_ended_since?(session, baseline)
109
+ return false unless baseline
110
+
111
+ if baseline.key?(:last_turn)
112
+ ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
113
+ return true if ended && ended != baseline[:last_turn]
114
+ end
115
+ !!(baseline[:messages] && session.messages.size > baseline[:messages])
116
+ end
117
+
118
+ # @return [Result] :no_reply with the outcome the session's last_turn
119
+ # records, when it is the turn waited for
120
+ def no_reply(session, baseline)
121
+ last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
122
+ fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
123
+ outcome = fresh ? last["outcome"] : nil
124
+ text = outcome == "failed" ? failure_summary(session.messages) : nil
125
+ Result.new(status: :no_reply, outcome: outcome, text: text)
126
+ end
127
+
128
+ # The summary in the failed-turn note at the conversation's tail, nil
129
+ # without one.
130
+ def failure_summary(messages)
131
+ list = Array(messages)
132
+ index = TurnNote.trailing_index(list)
133
+ return nil unless index
134
+
135
+ text = (list[index][:content] || list[index]["content"]).to_s
136
+ prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
137
+ return nil unless text.start_with?(prefix)
138
+
139
+ text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
140
+ end
141
+
98
142
  # @return [Result, nil] :done with the newest reply past the cursor
99
143
  def reply_past(id, state_dir:, cursor:)
100
144
  file = newest_reply(id, state_dir: state_dir)