samagotchi 0.18.1 → 0.20.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. metadata +7 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "ipaddr"
4
4
  require_relative "config"
5
+ require_relative "log"
5
6
  require_relative "model_ref"
6
7
  require_relative "client"
7
8
  require_relative "llm/openai_chat"
@@ -125,9 +126,17 @@ module Samagotchi
125
126
  LOCAL_NETS = %w[127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10
126
127
  ::1/128 fc00::/7 fe80::/10].map { |net| IPAddr.new(net) }.freeze
127
128
 
128
- # Whether an address is remote: https, or an http IP address outside
129
- # the local nets (LOCAL_NETS). An http host named by a name (localhost,
130
- # box, mac.local, gpu.lan) counts as local: chi doesn't look names up.
129
+ # Name suffixes that stay on a home or office network: mDNS, the
130
+ # special-use and customary private names, router defaults (fritz.box)
131
+ # and Tailscale's MagicDNS. Compared on whole labels.
132
+ LOCAL_NAME_SUFFIXES = %w[localhost local lan home home.arpa internal intranet localdomain private corp test
133
+ box ts.net].freeze
134
+
135
+ # Whether an address is remote: https, an http IP address outside the
136
+ # local nets (LOCAL_NETS), or an http name with a dot whose suffix isn't
137
+ # a local one (LOCAL_NAME_SUFFIXES): `gpu.example.com` is remote, while
138
+ # `box`, `mac.local`, `gpu.lan` and `pc.tail1234.ts.net` are local. chi
139
+ # doesn't look names up; hosts.<name>.remote overrides either way.
131
140
  def self.remote_address?(scheme, host)
132
141
  return true if scheme.to_s == "https"
133
142
 
@@ -135,9 +144,17 @@ module Samagotchi
135
144
  address = address.native if address.ipv4_mapped?
136
145
  LOCAL_NETS.none? { |net| net.family == address.family && net.include?(address) }
137
146
  rescue IPAddr::Error
138
- false
147
+ remote_name?(host)
139
148
  end
140
149
 
150
+ def self.remote_name?(host)
151
+ name = host.to_s.downcase.delete_suffix(".")
152
+ return false unless name.include?(".")
153
+
154
+ LOCAL_NAME_SUFFIXES.none? { |suffix| name == suffix || name.end_with?(".#{suffix}") }
155
+ end
156
+ private_class_method :remote_name?
157
+
141
158
  # server.first_token_timeout, or nil when unset or unreadable.
142
159
  def self.configured_first_token_timeout
143
160
  Config.get("server.first_token_timeout")
@@ -273,6 +290,7 @@ module Samagotchi
273
290
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
274
291
  @mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
275
292
  rescue StandardError => e
293
+ Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
276
294
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
277
295
  end
278
296
  results_mutex.synchronize { results[name] = data }
@@ -52,6 +52,8 @@ module Samagotchi
52
52
  # separator, while the chat loop writes one message per call.
53
53
  TOOL_OUTPUT_SEPARATOR = "\n\n---\n\n"
54
54
  TOOL_OUTPUT_HEADER_RE = /\A\[([\w.:-]+)\]/
55
+ # The text the web sends for a turn of images alone (images.js turnText).
56
+ IMAGE_ONLY_RE = /\A\s*(?:\[image: [^\]\n]*\]\s*)+\z/
55
57
 
56
58
  module_function
57
59
 
@@ -64,8 +66,11 @@ module Samagotchi
64
66
  # A plugin's steer is a prod to the model, not what the user said.
65
67
  next nil if Steer.steer?(message)
66
68
 
67
- # An image is a line naming it (refs only, never its bytes).
68
- [message["content"].to_s, *image_lines(message["images"])].reject(&:empty?).join("\n")
69
+ # An image is a line naming it (refs only, never its bytes), unless
70
+ # the text is only the web's placeholder naming it already.
71
+ content = message["content"].to_s
72
+ images = IMAGE_ONLY_RE.match?(content) ? [] : image_lines(message["images"])
73
+ [content, *images].reject(&:empty?).join("\n")
69
74
  when "model", "assistant"
70
75
  strip_thought(message["content"].to_s.gsub(TOOL_CALL_RE, ""))
71
76
  else
@@ -56,7 +56,7 @@ module Samagotchi
56
56
  @jobs.each do |job|
57
57
  job.tick
58
58
  rescue StandardError => e
59
- Log.warn(:idle, "tick_failed", echo: "[IdleScheduler] #{job.class} tick failed: #{e.class}: #{e.message}", job: job.class.name, error: e.class.name)
59
+ Log.exception(:idle, "tick_failed", e, echo: "[IdleScheduler] #{job.class} tick failed: #{e.class}: #{e.message}", job: job.class.name)
60
60
  end
61
61
  end
62
62
 
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installed_gem"
4
+ require_relative "version"
5
+
6
+ module Samagotchi
7
+ # The newest chi on this machine, which a long-lived process (chi web, an
8
+ # attaching TUI) compares with its own VERSION to say "chi X is installed;
9
+ # restart". Run from an installed gem: the highest samagotchi-<v>.gemspec
10
+ # in RubyGems' specification dirs, matching the `>= 0.a` a new worker
11
+ # activates (prereleases count). Run from a source checkout: the VERSION
12
+ # in lib/samagotchi/version.rb on disk now (a `git pull` moves it).
13
+ #
14
+ # Cheap enough to poll: each dir is listed again only when its mtime
15
+ # changed, version.rb is re-read only when its mtime did, and nothing
16
+ # calls Gem::Specification.reset (that would disturb the running process).
17
+ #
18
+ # SAMAGOTCHI_INSTALLED_VERSION=<v> (hidden, for smokes and e2e) answers
19
+ # <v> instead.
20
+ class InstalledVersions
21
+ ENV_KEY = "SAMAGOTCHI_INSTALLED_VERSION"
22
+ GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
23
+ VERSION_LINE = /^\s*VERSION\s*=\s*["']([^"']+)["']/
24
+ REQUIREMENT = Gem::Requirement.new(">= 0.a")
25
+
26
+ # @param gem_spec [Gem::Specification, nil] the installed gem this code
27
+ # runs from (nil: a source checkout)
28
+ # @param dirs [#call] → the specification dirs to list
29
+ # @param version_file [String] a checkout's version.rb
30
+ def initialize(env: ENV, gem_spec: InstalledGem.spec, dirs: -> { Gem::Specification.dirs },
31
+ version_file: File.join(InstalledGem::ROOT, "lib", "samagotchi", "version.rb"))
32
+ @env = env
33
+ @gem_spec = gem_spec
34
+ @dirs = dirs
35
+ @version_file = version_file
36
+ @dir_cache = {}
37
+ @file_cache = nil
38
+ @mutex = Mutex.new
39
+ end
40
+
41
+ # @return [String, nil] the newest installed version, or nil when none
42
+ # could be found out
43
+ def newest
44
+ seam = @env[ENV_KEY].to_s
45
+ return seam unless seam.empty?
46
+
47
+ @mutex.synchronize { @gem_spec ? newest_gem : checkout_version }
48
+ end
49
+
50
+ # Whether +installed+ is a newer version than +running+.
51
+ def self.newer?(installed, running = VERSION)
52
+ return false if installed.to_s.empty? || !Gem::Version.correct?(installed) || !Gem::Version.correct?(running)
53
+
54
+ Gem::Version.new(installed) > Gem::Version.new(running)
55
+ end
56
+
57
+ private
58
+
59
+ def newest_gem
60
+ @dirs.call.filter_map { |dir| dir_newest(dir) }.max&.to_s
61
+ end
62
+
63
+ def dir_newest(dir)
64
+ mtime = File.mtime(dir)
65
+ cached = @dir_cache[dir]
66
+ return cached[1] if cached && cached[0] == mtime
67
+
68
+ best = Dir.children(dir).filter_map { |name| GEMSPEC.match(name)&.[](1) }
69
+ .select { |v| Gem::Version.correct?(v) }
70
+ .map { |v| Gem::Version.new(v) }
71
+ .select { |v| REQUIREMENT.satisfied_by?(v) }.max
72
+ @dir_cache[dir] = [mtime, best]
73
+ best
74
+ rescue SystemCallError
75
+ @dir_cache.delete(dir)
76
+ nil
77
+ end
78
+
79
+ def checkout_version
80
+ mtime = File.mtime(@version_file)
81
+ return @file_cache[1] if @file_cache && @file_cache[0] == mtime
82
+
83
+ version = File.read(@version_file)[VERSION_LINE, 1]
84
+ version = nil unless version && Gem::Version.correct?(version)
85
+ @file_cache = [mtime, version]
86
+ version
87
+ rescue SystemCallError
88
+ nil
89
+ end
90
+ end
91
+ end
@@ -13,8 +13,9 @@ module Samagotchi
13
13
  module LiveVersions
14
14
  WEB_TIMEOUT = 0.5
15
15
 
16
- # version is nil for a sidecar written before sidecars carried one.
17
- Worker = Struct.new(:session_id, :version, keyword_init: true)
16
+ # version is nil for a sidecar written before sidecars carried one;
17
+ # restart: the worker takes `chi sessions restart` (its "restart" feature).
18
+ Worker = Struct.new(:session_id, :version, :restart, keyword_init: true)
18
19
 
19
20
  module_function
20
21
 
@@ -25,7 +26,8 @@ module Samagotchi
25
26
  sidecar = WorkerSidecar.live(session_dir, unlink: false)
26
27
  next unless sidecar
27
28
 
28
- Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
29
+ Worker.new(session_id: File.basename(session_dir), version: sidecar.version,
30
+ restart: sidecar.features.include?("restart"))
29
31
  end
30
32
  end
31
33
 
@@ -12,7 +12,7 @@ module Samagotchi
12
12
  counts = TokenUsage.from_payload(payload)
13
13
  return nil unless counts
14
14
 
15
- new(prompt_tokens: counts[:prompt_tokens].to_i, completion_tokens: counts[:completion_tokens].to_i,
15
+ new(prompt_tokens: counts.prompt_tokens.to_i, completion_tokens: counts.completion_tokens.to_i,
16
16
  source: :server)
17
17
  end
18
18
 
@@ -6,6 +6,7 @@ require "tmpdir"
6
6
  require_relative "manifest"
7
7
  require_relative "placeholder"
8
8
  require_relative "../memory_paths"
9
+ require_relative "../model_overlay"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
@@ -40,7 +41,7 @@ module Samagotchi
40
41
  end
41
42
 
42
43
  def run
43
- target_dir = resolve_target_dir(@scope)
44
+ target_dir = MemoryPaths.scope_dir(@scope) or raise BuildError, "invalid scope: #{@scope}"
44
45
  resolved_name = @name || default_name(@scope)
45
46
  resolved_version = @version || DEFAULT_VERSION
46
47
 
@@ -66,7 +67,7 @@ module Samagotchi
66
67
  unless missing.empty?
67
68
  raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(', ')}"
68
69
  end
69
- candidates = allow.map { |k| candidates_by_basename[k] }
70
+ candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
70
71
  else
71
72
  candidates = leave_out_owned(candidates, resolved_name)
72
73
  end
@@ -302,6 +303,28 @@ module Samagotchi
302
303
  end
303
304
  end
304
305
 
306
+ # The named files, each base followed by its model overlays
307
+ # (<name>.<key>.md next to it, ModelOverlay.overlay_file?; a plain
308
+ # tips.v2.md with no tips.md isn't one), with a note listing them. An
309
+ # overlay named without its base is kept, with a warning.
310
+ def with_overlays(allow, candidates_by_basename)
311
+ dir = File.dirname(candidates_by_basename.values.first.to_s)
312
+ overlays = candidates_by_basename.keys.select { |k| ModelOverlay.overlay_file?(File.join(dir, k)) }
313
+ allow.flat_map do |key|
314
+ if overlays.include?(key)
315
+ base = ModelOverlay.base_file_for(key)
316
+ unless allow.include?(base)
317
+ @warnings << "#{key} is a model overlay of #{base}, which the bundle leaves out; it loads only where #{base} exists"
318
+ end
319
+ next [key]
320
+ end
321
+
322
+ own = overlays.select { |k| ModelOverlay.base_file_for(k) == key && !allow.include?(k) }.sort
323
+ @warnings << "Included the model overlays of #{key}: #{own.join(", ")}" unless own.empty?
324
+ [key, *own]
325
+ end.uniq
326
+ end
327
+
305
328
  def normalize_filter_entry(entry)
306
329
  s = entry.to_s.strip
307
330
  # Allow with or without .md extension, but normalize to basename with .md
@@ -310,10 +333,6 @@ module Samagotchi
310
333
  base
311
334
  end
312
335
 
313
- def resolve_target_dir(scope)
314
- MemoryPaths.scope_dir(scope) or raise BuildError, "invalid scope: #{scope}"
315
- end
316
-
317
336
  def resolve_out_path(out_arg, default_name)
318
337
  if out_arg.nil? || out_arg.empty?
319
338
  # Default: <name>.zip in Dir.pwd
@@ -29,7 +29,7 @@ module Samagotchi
29
29
  return false unless scope && File.file?(path)
30
30
 
31
31
  name = File.basename(path, ".md")
32
- return false if overlay?(path, name)
32
+ return false if ModelOverlay.overlay_file?(path)
33
33
 
34
34
  IndexUpdater.update_index(scope, name, File.size(path), nil)
35
35
  rescue StandardError => e
@@ -55,14 +55,6 @@ module Samagotchi
55
55
  nil
56
56
  end
57
57
 
58
- def self.overlay?(path, name)
59
- match = File.basename(path).match(ModelOverlay::OVERLAY_SUFFIX_PATTERN)
60
- return false unless match
61
-
62
- base = name.delete_suffix(".#{match[1]}")
63
- File.file?(File.join(File.dirname(path), "#{base}.md"))
64
- end
65
-
66
58
  def self.real(dir)
67
59
  File.realpath(dir)
68
60
  rescue SystemCallError
@@ -12,6 +12,7 @@ require_relative "merger"
12
12
  require_relative "trash"
13
13
  require_relative "../version"
14
14
  require_relative "../bundle_needs"
15
+ require_relative "../model_overlay"
15
16
 
16
17
  module Samagotchi
17
18
  module MemoryBundle
@@ -42,6 +43,7 @@ module Samagotchi
42
43
  @warnings = []
43
44
  @placeholder_warnings = []
44
45
  @conflicts = {}
46
+ @overlays = []
45
47
  @trash = Trash.new(name)
46
48
  end
47
49
 
@@ -49,6 +51,22 @@ module Samagotchi
49
51
  # none moved).
50
52
  def trash_dir = @trash.dir
51
53
 
54
+ # The normalized source dir, kept alive past #run when an upgrade has
55
+ # conflicts (the agent step reads the incoming files); nil once cleaned.
56
+ attr_reader :source_dir
57
+
58
+ # Removes the normalized source dir an owned source (git/zip/tar) was
59
+ # extracted to. #run defers this when it found conflicts, so the
60
+ # conflict prompt and resolve_conflicts can still read the incoming
61
+ # files; the caller runs it once the agent step is done. Idempotent.
62
+ def cleanup_source!
63
+ return unless @defer_cleanup && @normalized_dir
64
+
65
+ SourceNormalizer.cleanup(@normalized_dir)
66
+ @normalized_dir = nil
67
+ @defer_cleanup = false
68
+ end
69
+
52
70
  def run
53
71
  normalized_dir = nil
54
72
  source_owned = false
@@ -56,6 +74,8 @@ module Samagotchi
56
74
  manifest = nil
57
75
  begin
58
76
  normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
77
+ @bundle_dir = normalized_dir
78
+ @normalized_dir = normalized_dir
59
79
  manifest = Manifest.read(dir: normalized_dir)
60
80
  rescue SourceNormalizer::UnknownSourceError => e
61
81
  raise InstallError, "Source normalization failed: #{e.message}"
@@ -70,7 +90,7 @@ module Samagotchi
70
90
  # Determine target scope (CLI wins over manifest).
71
91
  cli_scope = @scope.to_s.strip.empty? ? nil : @scope
72
92
  target_scope = cli_scope || manifest&.scope || "system"
73
- target_dir = resolve_target_dir(target_scope)
93
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise InstallError, "invalid scope: #{target_scope}"
74
94
  FileUtils.mkdir_p(target_dir)
75
95
 
76
96
  # Copy .md files and update index for each.
@@ -89,6 +109,7 @@ module Samagotchi
89
109
  Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
90
110
  file_key = File.basename(file_path)
91
111
  all_files_in_bundle << file_key
112
+ note_overlay(file_key, target_dir)
92
113
  target_path = File.join(target_dir, file_key)
93
114
 
94
115
  if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
@@ -195,12 +216,15 @@ module Samagotchi
195
216
  end
196
217
 
197
218
  FileUtils.mkdir_p(hooks_target)
219
+ unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
198
220
  FileUtils.cp(src, dest)
199
- if @upgrade && existing_provenance
200
- @results[basename] = { status: "updated" }
201
- else
202
- @results[basename] = { status: "installed" }
203
- end
221
+ @results[basename] = if unchanged
222
+ { status: "skipped", reason: "already up to date" }
223
+ elsif @upgrade && existing_provenance
224
+ { status: "updated" }
225
+ else
226
+ { status: "installed" }
227
+ end
204
228
  hooks_files_for_provenance[basename] = dest
205
229
  end
206
230
  else
@@ -402,7 +426,17 @@ module Samagotchi
402
426
 
403
427
  [normalized_dir, manifest]
404
428
  ensure
405
- SourceNormalizer.cleanup(normalized_dir) if source_owned
429
+ # An owned source (git/zip/tar) is cleaned up here, unless an upgrade
430
+ # kept conflicts: the agent step still needs the incoming files, so
431
+ # the caller runs #cleanup_source! when it is done.
432
+ if source_owned
433
+ if @conflicts.any? && !@dry_run
434
+ @defer_cleanup = true
435
+ else
436
+ SourceNormalizer.cleanup(normalized_dir)
437
+ @normalized_dir = nil
438
+ end
439
+ end
406
440
  end
407
441
 
408
442
  def summary
@@ -537,11 +571,18 @@ module Samagotchi
537
571
  return nil
538
572
  end
539
573
 
574
+ dest = File.join(provenance.plugin_dir, plugin[:file])
575
+ unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
540
576
  FileUtils.rm_rf(provenance.plugin_dir)
541
577
  FileUtils.mkdir_p(provenance.plugin_dir)
542
- dest = File.join(provenance.plugin_dir, plugin[:file])
543
578
  FileUtils.cp(src, dest)
544
- @results[plugin[:file]] = { status: @upgrade && provenance.read ? "updated" : "installed" }
579
+ @results[plugin[:file]] = if unchanged
580
+ { status: "skipped", reason: "already up to date" }
581
+ elsif @upgrade && provenance.read
582
+ { status: "updated" }
583
+ else
584
+ { status: "installed" }
585
+ end
545
586
  expected = manifest.checksum_for_plugin
546
587
  actual = Digest::SHA256.hexdigest(File.binread(dest))
547
588
  if @strict && expected && actual != expected
@@ -550,9 +591,38 @@ module Samagotchi
550
591
  dest
551
592
  end
552
593
 
594
+ # A model overlay (<name>.<key>.md, docs/memory.md) loads only with
595
+ # its base, under the matching model: it gets no index line (every
596
+ # model would see it as a memory of its own). Its base is looked for
597
+ # in the bundle dir, not the target: Dir.glob sorts tips.<key>.md
598
+ # before tips.md, so on a fresh install the base isn't copied yet.
599
+ # An overlay-looking name with no base anywhere is an overlay too,
600
+ # with a warning (a dry run warns as well).
601
+ def note_overlay(file_key, target_dir)
602
+ @bundle_files ||= Dir.glob(File.join(@bundle_dir, "*.md")).map { |f| File.basename(f) }
603
+ return unless ModelOverlay.bundle_overlay?(file_key, bundle_files: @bundle_files, target_dir: target_dir)
604
+
605
+ @overlays << file_key
606
+ base = ModelOverlay.base_file_for(file_key)
607
+ return if @bundle_files.include?(base)
608
+
609
+ @warnings << if File.file?(File.join(target_dir, base))
610
+ "#{file_key} is taken as a model overlay of your #{base}, which the bundle doesn't ship; " \
611
+ "it gets no index line"
612
+ else
613
+ "#{file_key} is a model overlay with no #{base}; it loads only once #{base} exists"
614
+ end
615
+ end
616
+
553
617
  # +owned+: the bundle wrote (owns) the file, so its line says
554
618
  # "· from <bundle>"; a skipped file's line keeps whatever it had.
619
+ # An overlay gets none: a stale one (an earlier install's) is removed.
555
620
  def update_target_index(scope, file_path, file_key, owned: true)
621
+ if @overlays.include?(file_key)
622
+ remove_target_index(scope, file_key) if owned
623
+ return
624
+ end
625
+
556
626
  byte_count = File.exist?(file_path) ? File.size(file_path) : 0
557
627
  entry_name = file_key.delete_suffix(".md")
558
628
  begin
@@ -565,10 +635,6 @@ module Samagotchi
565
635
  @warnings << "index.md: line for #{entry_name} not updated (#{e.message})"
566
636
  end
567
637
  end
568
-
569
- def resolve_target_dir(scope)
570
- MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
571
- end
572
638
  end
573
639
  end
574
640
  end
@@ -9,6 +9,7 @@ require_relative "provenance"
9
9
  require_relative "source"
10
10
  require_relative "status"
11
11
  require_relative "system_bundle"
12
+ require_relative "../memory_paths"
12
13
  require_relative "../version"
13
14
 
14
15
  module Samagotchi
@@ -112,6 +113,7 @@ module Samagotchi
112
113
  return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
113
114
  end
114
115
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
116
+ return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
115
117
 
116
118
  row.kept = conflicts(row, data)
117
119
  row.replaced = edited_executables(name, data)
@@ -147,7 +149,7 @@ module Samagotchi
147
149
  # version (a same-name file it didn't install is skipped, not merged).
148
150
  def conflicts(row, data)
149
151
  provenance = Provenance.new(name: row.name)
150
- target_dir = Status.resolve_target_dir(row.scope)
152
+ target_dir = MemoryPaths.scope_dir!(row.scope)
151
153
  return [] unless data[:files].is_a?(Hash)
152
154
 
153
155
  owned = data[:files].keys.map(&:to_s)
@@ -6,10 +6,14 @@ require_relative "../version"
6
6
  require_relative "index_updater"
7
7
  require_relative "../memory_paths"
8
8
  require_relative "../bundle_needs"
9
+ require_relative "../model_overlay"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
12
13
  module Status
14
+ # A scope this chi can't resolve (a newer chi's, a hand edit) isn't
15
+ # guessed: scope_error says so, target_dir is nil and each file is
16
+ # unchecked (no disk reads, nothing counted as missing).
13
17
  def self.bundle_status(name, scope_override: nil)
14
18
  provenance = Provenance.new(name: name)
15
19
  data = provenance.read
@@ -17,11 +21,13 @@ module Samagotchi
17
21
 
18
22
  raw_scope = scope_override || data[:scope]&.to_s
19
23
  scope = (raw_scope.nil? || raw_scope.strip.empty?) ? "system" : raw_scope
20
- target_dir = resolve_target_dir(scope)
24
+ target_dir = MemoryPaths.scope_dir(scope)
21
25
  files = data[:files] || {}
22
26
  details = {}
23
27
  files.each do |file_key, meta|
24
28
  file_key_str = file_key.to_s
29
+ next details[file_key_str] = unchecked_file(meta) unless target_dir
30
+
25
31
  target_path = File.join(target_dir, file_key_str)
26
32
  base_path = provenance.base_path(file_key_str)
27
33
  stored_checksum = meta[:checksum] || meta["checksum"]
@@ -29,7 +35,9 @@ module Samagotchi
29
35
  base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
30
36
  modified = base_checksum && current_checksum && base_checksum != current_checksum
31
37
  missing = !File.exist?(target_path)
32
- index_present = index_has_entry?(scope, file_key_str)
38
+ # A model overlay has no index line (Installer#note_overlay).
39
+ overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
40
+ index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
33
41
  details[file_key_str] = {
34
42
  conflict: meta.is_a?(Hash) && meta[:conflict] == true,
35
43
  stored_checksum: stored_checksum,
@@ -38,11 +46,13 @@ module Samagotchi
38
46
  modified: modified,
39
47
  missing: missing,
40
48
  index_present: index_present,
49
+ overlay: overlay,
41
50
  target_path: target_path,
42
51
  base_path: base_path
43
52
  }
44
53
  end
45
- { provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
54
+ { provenance: data, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
55
+ target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
46
56
  hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
47
57
  end
48
58
 
@@ -87,9 +97,11 @@ module Samagotchi
87
97
  requires_failure: Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION) }
88
98
  end
89
99
 
90
- def self.resolve_target_dir(scope)
91
- MemoryPaths.scope_dir(scope) || MemoryPaths.system_dir
100
+ def self.unchecked_file(meta)
101
+ { conflict: meta.is_a?(Hash) && meta[:conflict] == true, stored_checksum: meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil,
102
+ unchecked: true, modified: false, missing: false, index_present: nil, overlay: false }
92
103
  end
104
+ private_class_method :unchecked_file
93
105
 
94
106
  # Index lines name the entry without ".md" (Installer#update_target_index,
95
107
  # memory_write); a legacy "name.md" line counts too.
@@ -6,6 +6,7 @@ require_relative "provenance"
6
6
  require_relative "index_updater"
7
7
  require_relative "merger"
8
8
  require_relative "trash"
9
+ require_relative "../model_overlay"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
@@ -38,7 +39,7 @@ module Samagotchi
38
39
  cli_scope = @scope.to_s.strip.empty? ? nil : @scope
39
40
  raw = cli_scope || data[:scope]&.to_s
40
41
  target_scope = (raw.nil? || raw.strip.empty?) ? "system" : raw
41
- target_dir = resolve_target_dir(target_scope)
42
+ target_dir = MemoryPaths.scope_dir(target_scope) or raise UninstallError, "invalid scope: #{target_scope}"
42
43
 
43
44
  files = data[:files] || {}
44
45
  hooks = data[:hooks] || {}
@@ -75,7 +76,9 @@ module Samagotchi
75
76
  next unless File.exist?(target_path)
76
77
 
77
78
  @warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
78
- retag(target_scope, target_path, file_key_str, others.first)
79
+ unless ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
80
+ retag(target_scope, target_path, file_key_str, others.first)
81
+ end
79
82
  next
80
83
  end
81
84
  if File.exist?(target_path)
@@ -119,16 +122,13 @@ module Samagotchi
119
122
 
120
123
  private
121
124
 
122
- # A kept shared file's index line names the bundle that still has it.
125
+ # A kept shared file's index line names the bundle that still has it
126
+ # (a model overlay has none: the caller skips it).
123
127
  def retag(scope, path, file_key, bundle)
124
128
  IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
125
129
  rescue StandardError => e
126
130
  @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
127
131
  end
128
-
129
- def resolve_target_dir(scope)
130
- MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
131
- end
132
132
  end
133
133
  end
134
134
  end
@@ -112,6 +112,12 @@ module Samagotchi
112
112
  end
113
113
  end
114
114
 
115
+ # scope_dir, or ArgumentError ("invalid scope: team") for a scope it
116
+ # doesn't know: for a caller that must not guess a folder.
117
+ def scope_dir!(scope, env: ENV, cwd: Dir.pwd)
118
+ scope_dir(scope, env: env, cwd: cwd) or raise ArgumentError, "invalid scope: #{scope}"
119
+ end
120
+
115
121
  def bundles_dir(env: ENV)
116
122
  File.join(system_dir(env: env), ".bundles")
117
123
  end