samagotchi 0.18.1 → 0.19.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +15 -2
  5. data/docs/cli.md +34 -8
  6. data/docs/configuration.md +8 -4
  7. data/docs/memory.md +4 -0
  8. data/docs/sessions.md +6 -4
  9. data/lib/samagotchi/bridge.rb +25 -9
  10. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  11. data/lib/samagotchi/bridge_client.rb +9 -5
  12. data/lib/samagotchi/bundle_command.rb +2 -1
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  17. data/lib/samagotchi/engine.rb +9 -3
  18. data/lib/samagotchi/guardrail_wiring.rb +8 -2
  19. data/lib/samagotchi/guardrails/approval.rb +10 -4
  20. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  21. data/lib/samagotchi/host_registry.rb +22 -4
  22. data/lib/samagotchi/idle_scheduler.rb +1 -1
  23. data/lib/samagotchi/installed_versions.rb +91 -0
  24. data/lib/samagotchi/live_versions.rb +5 -3
  25. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  26. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  27. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  28. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  29. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  30. data/lib/samagotchi/model_overlay.rb +39 -0
  31. data/lib/samagotchi/model_profile.rb +4 -0
  32. data/lib/samagotchi/relay_desk.rb +9 -0
  33. data/lib/samagotchi/session_manager.rb +166 -11
  34. data/lib/samagotchi/session_metrics.rb +36 -13
  35. data/lib/samagotchi/sessions_command.rb +43 -4
  36. data/lib/samagotchi/system_prompt.rb +10 -1
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  38. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  39. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  40. data/lib/samagotchi/tools/builtins.rb +21 -5
  41. data/lib/samagotchi/turn_note.rb +3 -2
  42. data/lib/samagotchi/update_command.rb +33 -12
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +64 -11
  45. data/lib/samagotchi/web/public/app.js +102 -29
  46. data/lib/samagotchi/web/public/data.js +6 -0
  47. data/lib/samagotchi/web/public/format.js +4 -2
  48. data/lib/samagotchi/web/public/index.html +11 -0
  49. data/lib/samagotchi/web/public/question_card.js +12 -0
  50. data/lib/samagotchi/web/public/question_cards.js +17 -11
  51. data/lib/samagotchi/web/public/scope.js +15 -6
  52. data/lib/samagotchi/web/public/session_state.js +4 -0
  53. data/lib/samagotchi/web/public/turn_events.js +107 -3
  54. data/lib/samagotchi/web/public/update_notice.js +87 -14
  55. data/lib/samagotchi/web/server.rb +28 -1
  56. data/lib/samagotchi/web/session_hub.rb +30 -3
  57. data/lib/samagotchi/web/session_summary.rb +5 -0
  58. data/lib/samagotchi/worker.rb +25 -8
  59. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  60. data/lib/samagotchi/worker_sidecar.rb +7 -5
  61. metadata +3 -1
@@ -88,7 +88,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
88
88
  | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
89
89
  | `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
90
90
  | `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
91
- | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
91
+ | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot; a model overlay (`<name>.<key>.md`) has no index line and reads `ok (model overlay)`. |
92
92
  | `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
93
93
  | `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
94
94
  | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
@@ -169,9 +169,10 @@ module Samagotchi
169
169
  # kernel's dispatch): the built-ins, per Engine.
170
170
  @tools = Tools::Builtins.registry
171
171
  if @scratch
172
- # A scratch session's children would outlive it; its memories
173
- # would too (write and edit into the memories: ScratchWrites).
174
- [Tools::Delegate::NAME, Tools::DelegateResult::NAME].each { |name| @tools.unregister(name) }
172
+ # A scratch session's children would outlive it, and so would a
173
+ # note it left in a peer; its memories would too (write and edit
174
+ # into the memories: ScratchWrites).
175
+ [Tools::Delegate::NAME, Tools::DelegateResult::NAME, Tools::SendNote::NAME].each { |name| @tools.unregister(name) }
175
176
  @tools[Tools::MemoryWrite::NAME].handler = ->(_call, _kctx) { SCRATCH_MEMORY_WRITE }
176
177
  end
177
178
  # Likewise the slash commands its SessionCommands run.
@@ -614,6 +615,11 @@ module Samagotchi
614
615
  Thread.current[key] = outer
615
616
  end
616
617
 
618
+ # Whether an anytime command (/btw) still runs on its thread.
619
+ def anytime_running?
620
+ @lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
621
+ end
622
+
617
623
  # Start an anytime command on its own thread (D8), one #shutdown waits
618
624
  # for, so its command_ran is announced before the process leaves.
619
625
  # @return [Thread]
@@ -70,10 +70,15 @@ module Samagotchi
70
70
  @interface = value
71
71
  end
72
72
 
73
+ # The approval scopes a scratch session offers: nothing outlives it.
74
+ SCRATCH_SCOPES = %w[once session].freeze
75
+
73
76
  # Where the approval store lives: beside Session's state dir
74
- # ($XDG_STATE_HOME/samagotchi/guardrails/).
77
+ # ($XDG_STATE_HOME/samagotchi/guardrails/). A scratch session reads
78
+ # it but keeps its own session approvals in memory.
75
79
  def state_dir=(state_dir)
76
80
  @approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
81
+ @approvals = Guardrails::Approvals::InMemory.new(@approvals) if @scratch
77
82
  @protected = nil
78
83
  end
79
84
 
@@ -207,7 +212,8 @@ module Samagotchi
207
212
  # A plugin tool is asked about by its label, as its row shows it.
208
213
  label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
209
214
  # verdict.call is the call that will run (a hook may have replaced it).
210
- payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
215
+ payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
216
+ only: (SCRATCH_SCOPES if @scratch))
211
217
  Guardrails::Approval.settle(verdict, @ask.call(payload), payload[:approval][:scopes])
212
218
  end
213
219
 
@@ -22,9 +22,11 @@ module Samagotchi
22
22
  # approval[:tool] stays the raw name
23
23
  # @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
24
24
  # write: the card shows its diff, the question text one change line
25
+ # @param only [Array<String>, nil] offer no scope beyond these (a
26
+ # scratch session: once, session); "once" when none is left
25
27
  # @return [Hash] open_question fields
26
- def payload(verdict, label: nil, preview: nil)
27
- scopes = offered_scopes(verdict)
28
+ def payload(verdict, label: nil, preview: nil, only: nil)
29
+ scopes = offered_scopes(verdict, only: only)
28
30
  targets = verdict.targets
29
31
  {
30
32
  question: question_text(verdict, label: label, preview: preview),
@@ -81,8 +83,12 @@ module Samagotchi
81
83
  end
82
84
 
83
85
  # The rule's scopes; "rule" only when a rule id names what to approve.
84
- def offered_scopes(verdict)
85
- verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
86
+ def offered_scopes(verdict, only: nil)
87
+ scopes = verdict.scopes.reject { |scope| scope == "rule" && verdict.rule.nil? }
88
+ return scopes unless only
89
+
90
+ scopes &= only
91
+ scopes.empty? ? ["once"] : scopes
86
92
  end
87
93
 
88
94
  def label(scope, verdict)
@@ -45,10 +45,25 @@ module Samagotchi
45
45
  # @param verdict [Verdict] an ask with its targets and context
46
46
  # @return [Hash, nil] the first entry that allows it
47
47
  def match(verdict)
48
- key = self.class.key_for(verdict)
49
- place = self.class.place_for(verdict)
48
+ self.class.find_in(read, verdict)
49
+ end
50
+
51
+ # Store the verdict's allowed scope (not "once").
52
+ # @return [Hash, nil] the entry
53
+ def add(verdict, scope)
54
+ entry = self.class.entry_for(verdict, scope) or return nil
55
+
56
+ update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
57
+ entry
58
+ end
59
+
60
+ # @param list [Array<Hash>] entries
61
+ # @return [Hash, nil] the first of +list+ that allows +verdict+
62
+ def self.find_in(list, verdict)
63
+ key = key_for(verdict)
64
+ place = place_for(verdict)
50
65
  session_id = verdict.context&.session_id
51
- read.find do |e|
66
+ list.find do |e|
52
67
  case e["scope"]
53
68
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
54
69
  when "repo" then e["repo_root"] == place && e["key"] == key
@@ -58,21 +73,18 @@ module Samagotchi
58
73
  end
59
74
  end
60
75
 
61
- # Store the verdict's allowed scope (not "once").
62
- # @return [Hash, nil] the entry
63
- def add(verdict, scope)
76
+ # @return [Hash, nil] the entry that stores +scope+ for +verdict+; nil for "once"
77
+ def self.entry_for(verdict, scope)
64
78
  return nil unless STORED_SCOPES.include?(scope)
65
79
 
66
80
  entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
67
81
  "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
68
82
  case scope
69
- when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => self.class.key_for(verdict))
70
- when "repo" then entry.merge!("repo_root" => self.class.place_for(verdict), "key" => self.class.key_for(verdict))
71
- when "rule" then entry["repo_root"] = self.class.place_for(verdict)
83
+ when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
84
+ when "repo" then entry.merge!("repo_root" => place_for(verdict), "key" => key_for(verdict))
85
+ when "rule" then entry["repo_root"] = place_for(verdict)
72
86
  end
73
- entry.compact!
74
- update { |list| list << entry unless list.any? { |e| e.except("created_at") == entry.except("created_at") } }
75
- entry
87
+ entry.compact
76
88
  end
77
89
 
78
90
  # Remove entry +index+ (0-based, as #entries lists them).
@@ -167,6 +179,42 @@ module Samagotchi
167
179
  AtomicFile.write(@path, JSON.pretty_generate(list))
168
180
  end
169
181
  end
182
+
183
+ # A scratch session's approvals: the store's entries still apply, and
184
+ # its own session approvals are kept in memory, so none outlives it
185
+ # (it offers no wider scope; one that comes anyway is not kept).
186
+ class InMemory
187
+ def initialize(store)
188
+ @store = store
189
+ @own = []
190
+ @mutex = Mutex.new
191
+ end
192
+
193
+ def path = @store.path
194
+
195
+ # The store's entries, then this session's own.
196
+ def entries = @store.entries + @mutex.synchronize { @own.dup }
197
+
198
+ def match(verdict)
199
+ @mutex.synchronize { Approvals.find_in(@own, verdict) } || @store.match(verdict)
200
+ end
201
+
202
+ def add(verdict, scope)
203
+ return nil unless scope == "session"
204
+
205
+ entry = Approvals.entry_for(verdict, scope)
206
+ @mutex.synchronize { @own << entry unless @own.any? { |e| e.except("created_at") == entry.except("created_at") } }
207
+ entry
208
+ end
209
+
210
+ # Index as #entries lists them: the store's first, then its own.
211
+ def revoke(index)
212
+ stored = @store.entries.size
213
+ return @store.revoke(index) if index < stored
214
+
215
+ @mutex.synchronize { index - stored < @own.size ? @own.delete_at(index - stored) : nil }
216
+ end
217
+ end
170
218
  end
171
219
  end
172
220
  end
@@ -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 }
@@ -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
 
@@ -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
@@ -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
@@ -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
 
@@ -56,6 +58,7 @@ module Samagotchi
56
58
  manifest = nil
57
59
  begin
58
60
  normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
61
+ @bundle_dir = normalized_dir
59
62
  manifest = Manifest.read(dir: normalized_dir)
60
63
  rescue SourceNormalizer::UnknownSourceError => e
61
64
  raise InstallError, "Source normalization failed: #{e.message}"
@@ -89,6 +92,7 @@ module Samagotchi
89
92
  Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
90
93
  file_key = File.basename(file_path)
91
94
  all_files_in_bundle << file_key
95
+ note_overlay(file_key, target_dir)
92
96
  target_path = File.join(target_dir, file_key)
93
97
 
94
98
  if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
@@ -550,9 +554,38 @@ module Samagotchi
550
554
  dest
551
555
  end
552
556
 
557
+ # A model overlay (<name>.<key>.md, docs/memory.md) loads only with
558
+ # its base, under the matching model: it gets no index line (every
559
+ # model would see it as a memory of its own). Its base is looked for
560
+ # in the bundle dir, not the target: Dir.glob sorts tips.<key>.md
561
+ # before tips.md, so on a fresh install the base isn't copied yet.
562
+ # An overlay-looking name with no base anywhere is an overlay too,
563
+ # with a warning (a dry run warns as well).
564
+ def note_overlay(file_key, target_dir)
565
+ @bundle_files ||= Dir.glob(File.join(@bundle_dir, "*.md")).map { |f| File.basename(f) }
566
+ return unless ModelOverlay.bundle_overlay?(file_key, bundle_files: @bundle_files, target_dir: target_dir)
567
+
568
+ @overlays << file_key
569
+ base = ModelOverlay.base_file_for(file_key)
570
+ return if @bundle_files.include?(base)
571
+
572
+ @warnings << if File.file?(File.join(target_dir, base))
573
+ "#{file_key} is taken as a model overlay of your #{base}, which the bundle doesn't ship; " \
574
+ "it gets no index line"
575
+ else
576
+ "#{file_key} is a model overlay with no #{base}; it loads only once #{base} exists"
577
+ end
578
+ end
579
+
553
580
  # +owned+: the bundle wrote (owns) the file, so its line says
554
581
  # "· from <bundle>"; a skipped file's line keeps whatever it had.
582
+ # An overlay gets none: a stale one (an earlier install's) is removed.
555
583
  def update_target_index(scope, file_path, file_key, owned: true)
584
+ if @overlays.include?(file_key)
585
+ remove_target_index(scope, file_key) if owned
586
+ return
587
+ end
588
+
556
589
  byte_count = File.exist?(file_path) ? File.size(file_path) : 0
557
590
  entry_name = file_key.delete_suffix(".md")
558
591
  begin
@@ -6,6 +6,7 @@ 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
@@ -29,7 +30,9 @@ module Samagotchi
29
30
  base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
30
31
  modified = base_checksum && current_checksum && base_checksum != current_checksum
31
32
  missing = !File.exist?(target_path)
32
- index_present = index_has_entry?(scope, file_key_str)
33
+ # A model overlay has no index line (Installer#note_overlay).
34
+ overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
35
+ index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
33
36
  details[file_key_str] = {
34
37
  conflict: meta.is_a?(Hash) && meta[:conflict] == true,
35
38
  stored_checksum: stored_checksum,
@@ -38,6 +41,7 @@ module Samagotchi
38
41
  modified: modified,
39
42
  missing: missing,
40
43
  index_present: index_present,
44
+ overlay: overlay,
41
45
  target_path: target_path,
42
46
  base_path: base_path
43
47
  }
@@ -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
@@ -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,7 +122,8 @@ 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
@@ -49,5 +49,44 @@ module Samagotchi
49
49
  # Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
50
50
  # Useful for tooling, tests, and future index suppression.
51
51
  OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
52
+
53
+ # The base file name (`tips.md`) an overlay-looking name
54
+ # (`tips.qwen3.md`) would belong to, or nil when it doesn't match the
55
+ # suffix pattern.
56
+ def self.base_file_for(path)
57
+ match = File.basename(path.to_s).match(OVERLAY_SUFFIX_PATTERN)
58
+ return nil unless match
59
+
60
+ stem = File.basename(path.to_s).delete_suffix(match[0])
61
+ stem.empty? ? nil : "#{stem}.md"
62
+ end
63
+
64
+ # Whether +path+ is a model overlay: its name matches the suffix pattern
65
+ # and its base `<stem>.md` exists in one of +base_dirs+ (default: the
66
+ # file's own dir) or is one of +base_names+. A memory whose name has a
67
+ # dot and a sibling of the same stem reads as an overlay too (a
68
+ # leftover ambiguity).
69
+ def self.overlay_file?(path, base_dirs: [File.dirname(path.to_s)], base_names: [])
70
+ base = base_file_for(path)
71
+ return false unless base
72
+
73
+ base_names.map { |n| File.basename(n.to_s) }.include?(base) ||
74
+ base_dirs.compact.any? { |dir| File.file?(File.join(dir, base)) }
75
+ end
76
+
77
+ # How a bundle's installer, status and uninstaller tell an overlay
78
+ # (they agree, so a line the installer left out isn't "no-index"): the
79
+ # base is one of +bundle_files+ (basenames), or it is nowhere in
80
+ # +target_dir+ (an orphan overlay; it loads once the base exists).
81
+ # A base only in +target_dir+ (an installed memory, maybe the user's
82
+ # own) counts too: the installer warns, so the dot-name ambiguity
83
+ # (notes.v2.md next to a user's notes.md) is visible.
84
+ def self.bundle_overlay?(file_key, bundle_files:, target_dir:)
85
+ base = base_file_for(file_key)
86
+ return false unless base
87
+
88
+ overlay_file?(file_key, base_dirs: [target_dir], base_names: bundle_files) ||
89
+ !File.file?(File.join(target_dir, base))
90
+ end
52
91
  end
53
92
  end
@@ -11,6 +11,10 @@ module Samagotchi
11
11
  # gemma4 — Gemma 4 format
12
12
  class ModelProfile
13
13
  MODEL_ENV = "SAMAGOTCHI_DEFAULT_MODEL"
14
+ # Set beside MODEL_ENV for a worker spawned by a `chi --model X`: X is
15
+ # that run's default, not one its commands' chi should inherit
16
+ # (SessionManager.spawn_options, Tools::Builtins.parent_env).
17
+ MODEL_FROM_CLI_ENV = "SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI"
14
18
  # No model anywhere (--model, default.model in config.yml, the env): a
15
19
  # first run before any config. An ArgumentError, as before.
16
20
  MissingModel = Class.new(ArgumentError)
@@ -63,6 +63,15 @@ module Samagotchi
63
63
  end
64
64
  end
65
65
 
66
+ # Whether a relay is open, or settled recently enough that its child
67
+ # may still ask about it: a restart would drop them.
68
+ def active?
69
+ @mutex.synchronize do
70
+ prune
71
+ !@relays.empty?
72
+ end
73
+ end
74
+
66
75
  private
67
76
 
68
77
  def prune