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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -1
- data/README.md +4 -3
- data/docs/architecture.md +15 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +8 -4
- data/docs/memory.md +4 -0
- data/docs/sessions.md +6 -4
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +2 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/engine.rb +9 -3
- data/lib/samagotchi/guardrail_wiring.rb +8 -2
- data/lib/samagotchi/guardrails/approval.rb +10 -4
- data/lib/samagotchi/guardrails/approvals.rb +60 -12
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/memory_bundle/builder.rb +24 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +33 -0
- data/lib/samagotchi/memory_bundle/status.rb +5 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +64 -11
- data/lib/samagotchi/web/public/app.js +102 -29
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +4 -2
- data/lib/samagotchi/web/public/index.html +11 -0
- data/lib/samagotchi/web/public/question_card.js +12 -0
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/turn_events.js +107 -3
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +5 -0
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- 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. |
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
173
|
-
# would too (write and edit
|
|
174
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
62
|
-
|
|
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" =>
|
|
70
|
-
when "repo" then entry.merge!("repo_root" =>
|
|
71
|
-
when "rule" then entry["repo_root"] =
|
|
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
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|