samagotchi 0.22.0 → 0.23.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/bin/chi +9 -4
  4. data/docs/configuration.md +4 -4
  5. data/docs/releasing.md +1 -1
  6. data/docs/testing.md +6 -0
  7. data/lib/samagotchi/answer_command.rb +4 -2
  8. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  9. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +2 -2
  11. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  12. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  14. data/lib/samagotchi/bridge.rb +12 -9
  15. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  16. data/lib/samagotchi/bridge_client.rb +1 -1
  17. data/lib/samagotchi/bundle_command.rb +39 -29
  18. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  22. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  23. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  28. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  30. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  31. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  34. data/lib/samagotchi/cancellation_controller.rb +1 -1
  35. data/lib/samagotchi/cli/command.rb +1 -1
  36. data/lib/samagotchi/client.rb +3 -5
  37. data/lib/samagotchi/commands/registry.rb +2 -2
  38. data/lib/samagotchi/config.rb +38 -14
  39. data/lib/samagotchi/context_note.rb +1 -1
  40. data/lib/samagotchi/context_status.rb +6 -6
  41. data/lib/samagotchi/continue_offer.rb +15 -8
  42. data/lib/samagotchi/debug_log.rb +1 -1
  43. data/lib/samagotchi/desktop/macos.rb +5 -4
  44. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  45. data/lib/samagotchi/engine.rb +17 -17
  46. data/lib/samagotchi/gem_update.rb +2 -1
  47. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  48. data/lib/samagotchi/guardrails/approval.rb +5 -5
  49. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  50. data/lib/samagotchi/guardrails/gate.rb +2 -2
  51. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  52. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  53. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  54. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  55. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  56. data/lib/samagotchi/hooks/loader.rb +18 -16
  57. data/lib/samagotchi/hooks/registry.rb +10 -2
  58. data/lib/samagotchi/host_registry.rb +1 -3
  59. data/lib/samagotchi/idle_recap.rb +18 -15
  60. data/lib/samagotchi/idle_reminders.rb +6 -3
  61. data/lib/samagotchi/image_store.rb +5 -2
  62. data/lib/samagotchi/kernel_loop.rb +31 -31
  63. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  64. data/lib/samagotchi/llm/errors.rb +2 -2
  65. data/lib/samagotchi/llm/http.rb +12 -12
  66. data/lib/samagotchi/llm/openai_chat.rb +39 -28
  67. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  68. data/lib/samagotchi/log.rb +2 -2
  69. data/lib/samagotchi/log_subscriber.rb +2 -1
  70. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  71. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  72. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  73. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  74. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  75. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  76. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  77. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  78. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  79. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  80. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  81. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  82. data/lib/samagotchi/memory_bundle.rb +1 -0
  83. data/lib/samagotchi/model_overlay.rb +3 -5
  84. data/lib/samagotchi/model_profile.rb +12 -8
  85. data/lib/samagotchi/note_command.rb +2 -1
  86. data/lib/samagotchi/output_formatter.rb +18 -17
  87. data/lib/samagotchi/owner_lock.rb +2 -1
  88. data/lib/samagotchi/plugin/api.rb +8 -5
  89. data/lib/samagotchi/plugin/context.rb +1 -3
  90. data/lib/samagotchi/plugin/loader.rb +1 -1
  91. data/lib/samagotchi/plugin/service.rb +1 -1
  92. data/lib/samagotchi/plugin/sessions.rb +1 -0
  93. data/lib/samagotchi/plugin_tasks.rb +1 -1
  94. data/lib/samagotchi/prompt.rb +68 -12
  95. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  96. data/lib/samagotchi/question_desk.rb +7 -7
  97. data/lib/samagotchi/recap_setup.rb +1 -1
  98. data/lib/samagotchi/relay_desk.rb +1 -1
  99. data/lib/samagotchi/reminder_store.rb +2 -1
  100. data/lib/samagotchi/send_command.rb +4 -2
  101. data/lib/samagotchi/session.rb +2 -2
  102. data/lib/samagotchi/session_archive_command.rb +5 -1
  103. data/lib/samagotchi/session_commands.rb +1 -3
  104. data/lib/samagotchi/session_manager.rb +2 -4
  105. data/lib/samagotchi/session_metrics.rb +10 -10
  106. data/lib/samagotchi/session_observer.rb +3 -2
  107. data/lib/samagotchi/session_retention.rb +2 -2
  108. data/lib/samagotchi/sessions_command.rb +14 -5
  109. data/lib/samagotchi/system_prompt.rb +19 -8
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  111. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  112. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  113. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  114. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  115. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  116. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  117. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  118. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  119. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  120. data/lib/samagotchi/terminal_ui.rb +9 -5
  121. data/lib/samagotchi/text_diff.rb +12 -12
  122. data/lib/samagotchi/thinking.rb +6 -2
  123. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  124. data/lib/samagotchi/tool_activity.rb +1 -0
  125. data/lib/samagotchi/tool_call_parser.rb +13 -14
  126. data/lib/samagotchi/tool_declarations.rb +36 -35
  127. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  128. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  129. data/lib/samagotchi/tools/delegate.rb +1 -1
  130. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  131. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  132. data/lib/samagotchi/tools/edit.rb +14 -13
  133. data/lib/samagotchi/tools/execute.rb +3 -5
  134. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  135. data/lib/samagotchi/tools/memory.rb +21 -22
  136. data/lib/samagotchi/tools/read.rb +7 -7
  137. data/lib/samagotchi/tools/task_create.rb +1 -1
  138. data/lib/samagotchi/tools/task_get.rb +1 -1
  139. data/lib/samagotchi/tools/task_list.rb +1 -1
  140. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  141. data/lib/samagotchi/tools/task_stop.rb +1 -1
  142. data/lib/samagotchi/tools/task_wait.rb +3 -4
  143. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  144. data/lib/samagotchi/tools/write.rb +1 -1
  145. data/lib/samagotchi/turn_flow.rb +8 -8
  146. data/lib/samagotchi/turn_note.rb +5 -1
  147. data/lib/samagotchi/version.rb +1 -1
  148. data/lib/samagotchi/vision_support.rb +2 -2
  149. data/lib/samagotchi/web/app.rb +13 -6
  150. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  151. data/lib/samagotchi/web/qr.rb +7 -1
  152. data/lib/samagotchi/web/server.rb +6 -2
  153. data/lib/samagotchi/worker.rb +10 -2
  154. metadata +20 -20
@@ -64,6 +64,7 @@ module Samagotchi
64
64
  def self.read(dir:)
65
65
  manifest_path = File.join(dir, "manifest.yml")
66
66
  raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
67
+
67
68
  new(path: manifest_path)
68
69
  end
69
70
 
@@ -72,6 +73,7 @@ module Samagotchi
72
73
  def checksum_for(file_key)
73
74
  raw = @files[file_key]
74
75
  return nil unless raw
76
+
75
77
  raw.start_with?("sha256:") ? raw[7..] : raw
76
78
  end
77
79
 
@@ -79,8 +81,10 @@ module Samagotchi
79
81
  def checksum_for_hook(basename)
80
82
  raw = @hooks[basename]
81
83
  return nil unless raw
84
+
82
85
  sha = raw[:sha256] || raw["sha256"]
83
86
  return nil unless sha
87
+
84
88
  sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
85
89
  end
86
90
 
@@ -99,18 +103,18 @@ module Samagotchi
99
103
  return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
100
104
 
101
105
  "it requires chi #{requirement} (this is chi #{version})"
102
- rescue ArgumentError, Gem::Requirement::BadRequirementError => e
106
+ rescue ArgumentError => e # Gem::Requirement::BadRequirementError is one
103
107
  "its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
104
108
  end
105
109
 
106
110
  # Writes a fresh manifest.yml from computed checksums at `dir`.
107
- def self.write(dir:, name:, version:, scope: nil, description: "", files:, hooks: nil, trust_level: nil,
111
+ def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
108
112
  plugin: nil, requires_chi: nil, needs: nil)
109
113
  FileUtils.mkdir_p(dir)
110
114
  manifest = {
111
115
  "name" => name,
112
116
  "version" => version,
113
- "description" => description,
117
+ "description" => description
114
118
  }
115
119
  manifest["scope"] = scope if scope
116
120
  manifest["files"] = files # { "file.md" => "sha256:abc..." }
@@ -167,19 +171,23 @@ module Samagotchi
167
171
  def require_str(hash, key)
168
172
  val = hash[key]
169
173
  return val if val.is_a?(String) && !val.empty?
174
+
170
175
  raise ValidationError, "manifest missing required field: #{key}"
171
176
  end
172
177
 
173
178
  def normalize_scope(val)
174
179
  return nil if val.nil?
180
+
175
181
  v = val.to_s.strip.downcase
176
182
  %w[project system].include?(v) ? v : nil
177
183
  end
178
184
 
179
185
  def parse_files(raw)
180
186
  return {} unless raw.is_a?(Hash)
187
+
181
188
  raw.each_with_object({}) do |(k, v), acc|
182
189
  next unless k.is_a?(String) && !k.empty?
190
+
183
191
  str = v.to_s
184
192
  acc[k] = if str.start_with?("sha256:")
185
193
  str
@@ -234,11 +242,13 @@ module Samagotchi
234
242
 
235
243
  def parse_hooks(raw)
236
244
  return {} unless raw.is_a?(Hash)
245
+
237
246
  raw.each_with_object({}) do |(k, v), acc|
238
247
  next unless k.is_a?(String) && !k.empty?
239
248
  next unless v.is_a?(Hash)
249
+
240
250
  sha = (v["sha256"] || v[:sha256] || "").to_s
241
- sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
251
+ sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
242
252
  event = (v["event"] || v[:event] || "").to_s.strip
243
253
  on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
244
254
  on_error = "skip" if on_error.empty?
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "digest"
3
4
 
4
5
  module Samagotchi
@@ -33,18 +34,19 @@ module Samagotchi
33
34
  incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
34
35
 
35
36
  if current_digest == incoming_digest
36
- return :noop
37
+ :noop
37
38
  elsif current_digest == base_digest
38
- return :fast_forward
39
+ :fast_forward
39
40
  elsif incoming_digest == base_digest
40
- return :keep
41
+ :keep
41
42
  else
42
- return :conflict
43
+ :conflict
43
44
  end
44
45
  end
45
46
 
46
47
  def self.current_modified?(base_path, current_path)
47
48
  return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
49
+
48
50
  Digest::SHA256.hexdigest(File.read(base_path)) != Digest::SHA256.hexdigest(File.read(current_path))
49
51
  end
50
52
  end
@@ -154,11 +154,11 @@ module Samagotchi
154
154
  if existing && existing[:files]
155
155
  existing[:files].keys.each do |old_key|
156
156
  old_key_str = old_key.to_s
157
- unless files.key?(old_key_str) || files.key?(old_key)
158
- old_base = File.join(bases_dir, old_key_str)
159
- FileUtils.rm_f(old_base) if File.exist?(old_base)
160
- merged_entries.delete(old_key_str)
161
- end
157
+ next if files.key?(old_key_str) || files.key?(old_key)
158
+
159
+ old_base = File.join(bases_dir, old_key_str)
160
+ FileUtils.rm_f(old_base)
161
+ merged_entries.delete(old_key_str)
162
162
  end
163
163
  end
164
164
 
@@ -172,9 +172,10 @@ module Samagotchi
172
172
  hooks.each do |k, v|
173
173
  next unless k.is_a?(String) && !k.empty?
174
174
  next unless v.is_a?(Hash)
175
+
175
176
  hv = v.transform_keys(&:to_sym)
176
177
  sha = (hv[:sha256] || "").to_s
177
- sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
178
+ sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
178
179
  normalized_hooks[k] = {
179
180
  sha256: sha,
180
181
  event: (hv[:event] || "").to_s,
@@ -189,7 +190,7 @@ module Samagotchi
189
190
  old_key_str = old_key.to_s
190
191
  unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
191
192
  old_base = File.join(bases_dir, old_key_str)
192
- FileUtils.rm_f(old_base) if File.exist?(old_base)
193
+ FileUtils.rm_f(old_base)
193
194
  end
194
195
  end
195
196
  end
@@ -204,12 +205,12 @@ module Samagotchi
204
205
  meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
205
206
  end
206
207
  hooks_map[k] = meta.transform_keys(&:to_s)
207
- if src && File.exist?(src.to_s)
208
- begin
209
- File.write(File.join(bases_dir, k), File.read(src.to_s))
210
- rescue StandardError
211
- nil
212
- end
208
+ next unless src && File.exist?(src.to_s)
209
+
210
+ begin
211
+ File.write(File.join(bases_dir, k), File.read(src.to_s))
212
+ rescue StandardError
213
+ nil
213
214
  end
214
215
  end
215
216
 
@@ -256,6 +257,7 @@ module Samagotchi
256
257
  conflicts.each do |file_key, info|
257
258
  key = file_key.to_s
258
259
  next unless raw["files"].key?(key)
260
+
259
261
  src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
260
262
  next unless src
261
263
 
@@ -270,6 +272,7 @@ module Samagotchi
270
272
  def read
271
273
  manifest_path = File.join(@bundle_dir, "manifest.json")
272
274
  return nil unless File.exist?(manifest_path)
275
+
273
276
  JSON.parse(File.read(manifest_path), symbolize_names: true)
274
277
  end
275
278
 
@@ -28,7 +28,7 @@ module Samagotchi
28
28
  lower = path.downcase
29
29
  if lower.end_with?(".zip")
30
30
  normalize_zip(path)
31
- elsif lower.end_with?(".tar.gz") || lower.end_with?(".tgz") || lower.end_with?(".tar")
31
+ elsif lower.end_with?(".tar.gz", ".tgz", ".tar")
32
32
  normalize_tar(path)
33
33
  else
34
34
  ext = File.extname(path).downcase
@@ -57,6 +57,7 @@ module Samagotchi
57
57
  def self.git_url?(str)
58
58
  s = str.to_s.strip
59
59
  return false if s.empty?
60
+
60
61
  s.match?(%r{\A(?:https?://|git@|ssh://|git://|file://).+})
61
62
  end
62
63
 
@@ -78,6 +79,7 @@ module Samagotchi
78
79
  args += [url, clone_dir]
79
80
  result = system(*args)
80
81
  raise UnknownSourceError, "git clone failed for #{git_url}" unless result && File.directory?(clone_dir)
82
+
81
83
  # Capture HEAD commit before stripping .git (audit trail)
82
84
  commit = nil
83
85
  begin
@@ -103,12 +105,11 @@ module Samagotchi
103
105
  end
104
106
  end
105
107
 
106
- private
107
-
108
108
  def self.normalize_zip(zip_path)
109
109
  Dir.mktmpdir("samagotchi-zip-") do |extract_dir|
110
110
  result = system("unzip", "-o", zip_path, "-d", extract_dir)
111
111
  raise UnknownSourceError, "unzip failed for #{zip_path}" unless result
112
+
112
113
  clean_copy_of(extract_dir)
113
114
  end
114
115
  end
@@ -119,6 +120,7 @@ module Samagotchi
119
120
  cmd = is_gz ? ["tar", "-xzf"] : ["tar", "-xf"]
120
121
  result = system(*cmd, tar_path, "-C", extract_dir)
121
122
  raise UnknownSourceError, "tar extraction failed for #{tar_path}" unless result
123
+
122
124
  clean_copy_of(extract_dir)
123
125
  end
124
126
  end
@@ -150,6 +152,8 @@ module Samagotchi
150
152
  end
151
153
  [dst, true]
152
154
  end
155
+
156
+ private_class_method :normalize_zip, :normalize_tar, :clean_copy_of
153
157
  end
154
158
  end
155
159
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "digest"
3
4
  require_relative "provenance"
4
5
  require_relative "manifest"
@@ -20,7 +21,7 @@ module Samagotchi
20
21
  return nil unless data
21
22
 
22
23
  raw_scope = scope_override || data[:scope]&.to_s
23
- scope = (raw_scope.nil? || raw_scope.strip.empty?) ? "system" : raw_scope
24
+ scope = raw_scope.nil? || raw_scope.strip.empty? ? "system" : raw_scope
24
25
  target_dir = MemoryPaths.scope_dir(scope)
25
26
  files = data[:files] || {}
26
27
  details = {}
@@ -108,6 +109,7 @@ module Samagotchi
108
109
  def self.index_has_entry?(scope, file_key)
109
110
  path = IndexUpdater.index_path_for(scope)
110
111
  return false unless path && File.exist?(path)
112
+
111
113
  content = File.read(path)
112
114
  [file_key.delete_suffix(".md"), file_key].uniq.any? { |name| content.match?(IndexUpdater.managed_pattern(name)) }
113
115
  end
@@ -110,12 +110,13 @@ module Samagotchi
110
110
 
111
111
  def skip?
112
112
  val = ENV[SKIP_ENV].to_s.strip.downcase
113
- val == "1" || val == "true"
113
+ %w[1 true].include?(val)
114
114
  end
115
115
 
116
116
  # Unparseable versions fall back to the old "any difference upgrades" behaviour.
117
117
  def installed_newer?(installed, shipped)
118
- installed, shipped = installed.to_s, shipped.to_s
118
+ installed = installed.to_s
119
+ shipped = shipped.to_s
119
120
  return false unless Gem::Version.correct?(installed) && Gem::Version.correct?(shipped)
120
121
 
121
122
  Gem::Version.new(installed) > Gem::Version.new(shipped)
@@ -44,7 +44,7 @@ module Samagotchi
44
44
  def self.empty!(bundles_dir: MemoryPaths.bundles_dir, older_than_days: nil, now: Time.now, dry_run: false)
45
45
  entries = self.entries(bundles_dir: bundles_dir, now: now)
46
46
  if older_than_days
47
- cutoff = now - older_than_days.to_i * 86_400
47
+ cutoff = now - (older_than_days.to_i * 86_400)
48
48
  entries = entries.select { |e| e.time < cutoff }
49
49
  end
50
50
  return entries if dry_run
@@ -98,7 +98,7 @@ module Samagotchi
98
98
  File.directory?(path) && !File.symlink?(path)
99
99
  end
100
100
 
101
- def self.entry_for(trash_dir, name, now)
101
+ def self.entry_for(trash_dir, name, _now)
102
102
  path = File.join(trash_dir, name)
103
103
  time = parse_timestamp(name) || File.mtime(path)
104
104
  files, bytes = count_files(path)
@@ -126,11 +126,14 @@ module Samagotchi
126
126
  bytes = 0
127
127
  Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |f|
128
128
  next if File.directory?(f)
129
+
129
130
  files += 1
130
131
  bytes += File.size(f) rescue 0
131
132
  end
132
133
  [files, bytes]
133
134
  end
135
+
136
+ private_class_method :real_dir?, :entry_for, :parse_timestamp, :count_files
134
137
  end
135
138
  end
136
139
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "fileutils"
3
4
  require "digest"
4
5
  require_relative "../memory_paths"
@@ -38,7 +39,7 @@ module Samagotchi
38
39
  # Determine scope from flag or provenance
39
40
  cli_scope = @scope.to_s.strip.empty? ? nil : @scope
40
41
  raw = cli_scope || data[:scope]&.to_s
41
- target_scope = (raw.nil? || raw.strip.empty?) ? "system" : raw
42
+ target_scope = raw.nil? || raw.strip.empty? ? "system" : raw
42
43
  target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
43
44
 
44
45
  files = data[:files] || {}
@@ -55,19 +56,20 @@ module Samagotchi
55
56
  end.reject { |_, others| others.empty? }
56
57
 
57
58
  blocked = []
58
- files.each do |file_key, meta|
59
+ files.each do |file_key, _meta|
59
60
  file_key_str = file_key.to_s
60
61
  target_path = File.join(target_dir, file_key_str)
61
62
  base_path = provenance.base_path(file_key_str)
62
63
  next unless File.exist?(target_path)
63
64
  next if shared.key?(file_key_str)
65
+
64
66
  if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
65
67
  blocked << file_key_str
66
68
  @warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
67
69
  end
68
70
  end
69
71
 
70
- raise UninstallError, "Uninstall blocked: #{blocked.join(', ')} has local edits (use --force)" if blocked.any?
72
+ raise UninstallError, "Uninstall blocked: #{blocked.join(", ")} has local edits (use --force)" if blocked.any?
71
73
 
72
74
  files.each do |file_key, _meta|
73
75
  file_key_str = file_key.to_s
@@ -108,10 +110,11 @@ module Samagotchi
108
110
  end
109
111
  end
110
112
  # Remove hooks dir if empty, else rm_rf will clean
111
- FileUtils.rm_rf(hooks_dir) if Dir.exist?(hooks_dir)
113
+ FileUtils.rm_rf(hooks_dir)
112
114
  else
113
- # Ensure stale hooks dir removed even if provenance has no hooks map but dir exists
114
- FileUtils.rm_rf(provenance.hooks_dir) if Dir.exist?(provenance.hooks_dir)
115
+ # A stale hooks dir goes even when provenance has no hooks map
116
+ # (rm_rf ignores a missing dir).
117
+ FileUtils.rm_rf(provenance.hooks_dir)
115
118
  end
116
119
 
117
120
  plugin_path = provenance.plugin_path(data)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require_relative "memory_bundle/manifest"
3
4
  require_relative "memory_bundle/source"
4
5
  require_relative "memory_bundle/provenance"
@@ -32,10 +32,7 @@ module Samagotchi
32
32
 
33
33
  key = model_name.to_s
34
34
  .downcase
35
- .gsub(/[^a-z0-9]+/, "-")
36
- .gsub(/-+/, "-")
37
- .sub(/\A-/, "")
38
- .sub(/-\z/, "")
35
+ .gsub(/[^a-z0-9]+/, "-").squeeze("-").delete_prefix("-").delete_suffix("-")
39
36
  key.empty? ? nil : key
40
37
  end
41
38
 
@@ -43,12 +40,13 @@ module Samagotchi
43
40
  # Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
44
41
  def self.overlay_path_for(name, key, scope)
45
42
  return nil unless key && !key.to_s.strip.empty?
43
+
46
44
  File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
47
45
  end
48
46
 
49
47
  # Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
50
48
  # Useful for tooling, tests, and future index suppression.
51
- OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
49
+ OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9-]+)\.md\z/
52
50
 
53
51
  # The base file name (`tips.md`) an overlay-looking name
54
52
  # (`tips.qwen3.md`) would belong to, or nil when it doesn't match the
@@ -17,10 +17,13 @@ module Samagotchi
17
17
  MODEL_FROM_CLI_ENV = "SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI"
18
18
  # No model anywhere (--model, default.model in config.yml, the env): a
19
19
  # first run before any config. An ArgumentError, as before.
20
- MissingModel = Class.new(ArgumentError)
20
+ class MissingModel < ArgumentError
21
+ end
22
+
21
23
  # A host-qualified model whose host isn't configured (a MissingModel,
22
24
  # so every surface that reports a missing model reports it the same way).
23
- UnknownHost = Class.new(MissingModel)
25
+ class UnknownHost < MissingModel
26
+ end
24
27
 
25
28
  # Gemma 4 thinks in a channel: `<|channel>thought` … `<channel|>`. The
26
29
  # one source for the parser, the stream splitter, the literal guard and
@@ -74,7 +77,7 @@ module Samagotchi
74
77
  new(
75
78
  name: "gemma4",
76
79
  turn_start: "<|turn>",
77
- turn_end: "<end_of_turn>",
80
+ turn_end: "<turn|>",
78
81
  tool_call_open: "<|tool_call>",
79
82
  tool_call_close: "<tool_call|>",
80
83
  tool_response_open: "<|tool_response>",
@@ -88,7 +91,7 @@ module Samagotchi
88
91
  user_prefix: "",
89
92
  assistant_prefix: "",
90
93
  model_prefix: "",
91
- stop_sequences: ["<end_of_turn>", "<|tool_response>"],
94
+ stop_sequences: ["<turn|>", "<|tool_response>"],
92
95
  tool_decl_format: :gemma4
93
96
  )
94
97
  end
@@ -165,14 +168,15 @@ module Samagotchi
165
168
  if !parsed.host_name && (disabled = disabled_host_prefix(ref, hosts, env))
166
169
  raise UnknownHost, "host '#{disabled}' is disabled (enabled: false in config.yml)"
167
170
  end
171
+
168
172
  warn_host_slash(ref, hosts) unless parsed.host_name
169
173
  host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
170
174
  return model_name unless host
171
175
 
172
176
  names = hosts.keys.map { |k| k.to_s.downcase }.sort
173
177
  near = Samagotchi::Config.near_names(host, names).first(3)
174
- hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(' or ')}?)"
175
- raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
178
+ hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(" or ")}?)"
179
+ raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
176
180
  end
177
181
 
178
182
  # The prefix of "box:x" when box is a host config.yml has with
@@ -275,7 +279,7 @@ module Samagotchi
275
279
  FINGERPRINTS.each do |name, markers|
276
280
  return [name, markers.join(" + ")] if markers.all? { |marker| template.include?(marker) }
277
281
  end
278
- return ["qwen36", "ChatML"] if template.include?("<|im_start|>")
282
+ return %w[qwen36 ChatML] if template.include?("<|im_start|>")
279
283
 
280
284
  nil
281
285
  end
@@ -341,7 +345,7 @@ module Samagotchi
341
345
 
342
346
  def self.configured(value, where)
343
347
  profile = named(value)
344
- Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(', ')}) — ignored", profile: value.to_s, where: where) unless profile
348
+ Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(", ")}) — ignored", profile: value.to_s, where: where) unless profile
345
349
  profile
346
350
  end
347
351
  private_class_method :configured
@@ -92,6 +92,7 @@ module Samagotchi
92
92
  # passes down may never close: waiting on either would hang a script.
93
93
  def read_stdin
94
94
  return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
95
+
95
96
  if @stdin.respond_to?(:stat)
96
97
  stat = @stdin.stat
97
98
  return nil unless stat.pipe? || stat.file?
@@ -135,7 +136,7 @@ module Samagotchi
135
136
  if owner
136
137
  @stdout.puts("#{short} queued: its worker adds it within a few seconds")
137
138
  else
138
- queued = SessionInbox.find_new_note_files(File.dirname(File.dirname(path))).size
139
+ queued = SessionInbox.find_new_note_files(File.dirname(path, 2)).size
139
140
  @stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
140
141
  end
141
142
  true
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Samagotchi
3
4
  # OutputFormatter strips model wire-format tokens from raw engine output at
4
5
  # render time, so downstream renderers (web UI, terminal UI) never show the
@@ -23,7 +24,6 @@ module Samagotchi
23
24
  # wrapper forms; bare <name> content (no pipe) is deliberately preserved.
24
25
  CONTROL_NAMES = %w[
25
26
  turn
26
- end_of_turn
27
27
  tool_call
28
28
  tool_response
29
29
  tool
@@ -40,31 +40,32 @@ module Samagotchi
40
40
  # <word> in the surrounding content is NOT stripped:
41
41
  # <|name> / <name|> / <|name|>
42
42
  INDIVIDUAL_RE = Regexp.new(
43
- '<\\|(?:' + CONTROL_NAMES.join('|') + ')\\|?>' \
44
- '|<(?:' + CONTROL_NAMES.join('|') + ')\\|>'
43
+ '<\\|(?:' + CONTROL_NAMES.join("|") + ')\\|?>' \
44
+ "|<(?:" + CONTROL_NAMES.join("|") + ')\\|>'
45
45
  )
46
46
  INDIVIDUAL_RE.freeze
47
47
 
48
48
  # Angle-bracketed literal tokens stripped wholesale (including the thought
49
49
  # channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
50
50
  # are removed as a unit. Pipes are escaped; angle brackets are literal.
51
- LITERALS_RE = /<\|"\|>|<end_of_turn>/.freeze
51
+ LITERALS_RE = /<\|"\|>/
52
52
 
53
53
  # Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
54
54
  # `<channel|>` close, or to the end of the text when the close is missing
55
55
  # (ToolCallParser::Gemma strips the same pair). Removing only the marker
56
56
  # would leave the model's private reasoning in the answer; the channel is
57
57
  # the profile's thinking lane, not output.
58
- GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m.freeze
58
+ GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m
59
59
 
60
60
  # Qwen prompt-literal placeholders, e.g. </think>.
61
- PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
61
+ PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/
62
62
 
63
63
  # Qwen3.6 thinking blocks: <think>...</think> (including empty)
64
- QWEN_THINK_RE = /<think>.*?<\/think>/m.freeze
64
+ QWEN_THINK_RE = %r{<think>.*?</think>}m
65
65
  # Qwen tool_call XML blocks that survive when a tool call is rendered as text
66
66
  # e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
67
- QWEN_TOOL_CALL_RE = /<tool_call>.*?<\/tool_call>/m.freeze
67
+ QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
68
+
68
69
  module_function
69
70
 
70
71
  # Remove both control-token and prompt-literal token families from +text+.
@@ -79,7 +80,7 @@ module Samagotchi
79
80
  .gsub(/[ \t]+/, " ")
80
81
  .gsub(/ *\n */, "\n")
81
82
  .strip
82
- cleaned.empty? ? '' : cleaned
83
+ cleaned.empty? ? "" : cleaned
83
84
  end
84
85
 
85
86
  # The same tokens as #strip, but the text keeps its layout (indentation,
@@ -91,15 +92,15 @@ module Samagotchi
91
92
 
92
93
  def remove_tokens(text)
93
94
  text.to_s
94
- .gsub(QWEN_THINK_RE, '')
95
- .gsub(GEMMA_THOUGHT_RE, '')
96
- .gsub(QWEN_TOOL_CALL_RE, '')
97
- .gsub(LITERALS_RE, '')
98
- .gsub(INDIVIDUAL_RE, '')
99
- .gsub(PROMPT_LITERALS, '')
95
+ .gsub(QWEN_THINK_RE, "")
96
+ .gsub(GEMMA_THOUGHT_RE, "")
97
+ .gsub(QWEN_TOOL_CALL_RE, "")
98
+ .gsub(LITERALS_RE, "")
99
+ .gsub(INDIVIDUAL_RE, "")
100
+ .gsub(PROMPT_LITERALS, "")
100
101
  # orphaned closing tags that may remain after block removal
101
- .gsub(/<\/?think>/, '')
102
- .gsub(/<\/?tool_call>/, '')
102
+ .gsub(%r{</?think>}, "")
103
+ .gsub(%r{</?tool_call>}, "")
103
104
  end
104
105
  end
105
106
  end
@@ -40,7 +40,8 @@ module Samagotchi
40
40
  # @return [OwnerLock, nil] nil when another owner holds it
41
41
  def self.acquire(session_dir, kind:, wait: DEFAULT_WAIT)
42
42
  FileUtils.mkdir_p(session_dir)
43
- file = File.open(path(session_dir), File::RDWR | File::CREAT, 0o644)
43
+ # Held open on purpose: the flock lives as long as the OwnerLock (#release closes it).
44
+ file = File.open(path(session_dir), File::RDWR | File::CREAT, 0o644) # rubocop:disable Style/FileOpen
44
45
  deadline = monotonic_now + wait.to_f
45
46
  until file.flock(File::LOCK_EX | File::LOCK_NB)
46
47
  if monotonic_now >= deadline
@@ -51,6 +51,7 @@ module Samagotchi
51
51
  # it runs as other commands do)
52
52
  def command(name, description, anytime: false, &block)
53
53
  raise ArgumentError, "command #{name.inspect} needs a block" unless block
54
+
54
55
  name = name.to_s
55
56
  raise ArgumentError, "command name #{name.inspect} must look like /name (a-z, 0-9, _ and -)" unless name.match?(COMMAND_NAME)
56
57
  if (taken = @registries.commands.entries.find { |entry| entry.name == name })
@@ -78,9 +79,9 @@ module Samagotchi
78
79
  # @param targets [#call, nil] args → what guardrail rules match: a Hash
79
80
  # with paths: (absolute or relative to the cwd), command: (a shell
80
81
  # command) and cwd:, each optional
81
- def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
82
+ def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
82
83
  spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
83
- targets: targets, &block)
84
+ targets: targets, &)
84
85
  if (taken = @registries.tools[spec[:name]])
85
86
  raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
86
87
  end
@@ -124,9 +125,9 @@ module Samagotchi
124
125
  end
125
126
 
126
127
  # As Api#tool.
127
- def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
128
+ def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
128
129
  spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
129
- targets: targets, &block)
130
+ targets: targets, &)
130
131
  raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
131
132
 
132
133
  @specs << spec
@@ -159,6 +160,7 @@ module Samagotchi
159
160
  # @return [Service]
160
161
  def service(name, eager: false, &block)
161
162
  raise ArgumentError, "service #{name.inspect} needs a block" unless block
163
+
162
164
  name = "#{@bundle}:#{name}"
163
165
  raise ArgumentError, "service #{name} is registered twice" if @services.any? { |svc| svc.name == name }
164
166
  raise ArgumentError, "this chi has no services (plugins: false)" unless @registries.services
@@ -249,6 +251,7 @@ module Samagotchi
249
251
  # @return [Hash] {name:, schema:, label:, preview:, targets:, block:}
250
252
  def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
251
253
  raise ArgumentError, "tool #{name.inspect} needs a block" unless block
254
+
252
255
  name = name.to_s
253
256
  raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
254
257
  raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
@@ -323,7 +326,7 @@ module Samagotchi
323
326
  # (a call built without one, in a spec say: the call without its
324
327
  # name), string keys, typed by +parameters+, frozen.
325
328
  def self.args_of(call, parameters = nil)
326
- given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| key == :name || key == :args }
329
+ given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| %i[name args].include?(key) }
327
330
  Tools::Args.coerce(given, parameters).freeze
328
331
  end
329
332
 
@@ -107,9 +107,7 @@ module Samagotchi
107
107
  # $XDG_STATE_HOME/samagotchi/plugins/<bundle>/, created on first use.
108
108
  # @return [String]
109
109
  def data_dir
110
- @data_dir ||= begin
111
- File.join(Paths.state_dir(env: @env), "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
112
- end
110
+ @data_dir ||= File.join(Paths.state_dir(env: @env), "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
113
111
  end
114
112
 
115
113
  # The conversation so far, a frozen copy, without the system prompt.