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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- 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 +24 -5
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- 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 +2 -2
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- 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/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- 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/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- 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/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- 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 +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +14 -1
- 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/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- 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 +11 -2
- 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 +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
|
-
#
|
|
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 }
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
data/lib/samagotchi/llm/usage.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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]] =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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.
|
|
91
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|