samagotchi 0.16.0 → 0.18.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +73 -19
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +12 -5
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -1
|
@@ -4,20 +4,27 @@ require_relative "../tools/tool_path"
|
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
5
|
require_relative "../log"
|
|
6
6
|
require_relative "model_size"
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "outside"
|
|
8
|
+
require_relative "shell_git_dirs"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module Guardrails
|
|
11
12
|
# What a tool call acts on, resolved the way the tools resolve it: the
|
|
12
13
|
# shell command, absolute paths (against Dir.pwd, as the file tools have
|
|
13
14
|
# no cwd:), the directory a command runs in, its repo root, and whether
|
|
14
|
-
# a path leaves the repo (the cwd
|
|
15
|
+
# a path leaves the session's repo (Outside: the context cwd's repo
|
|
16
|
+
# root, the cwd when there is no repo; not the call's own cwd:).
|
|
15
17
|
class Targets
|
|
16
18
|
SHELL_TOOLS = %w[execute task_create].freeze
|
|
17
19
|
PATH_TOOLS = %w[write edit read memory_write].freeze
|
|
18
20
|
|
|
19
21
|
attr_reader :tool, :command, :paths, :cwd, :repo_root
|
|
20
22
|
|
|
23
|
+
# @return [String] the session's repo root (the context cwd's; the cwd
|
|
24
|
+
# outside a repo): what outside_repo measures from. repo_root is the
|
|
25
|
+
# call's (its own cwd: for shell and plugin tools).
|
|
26
|
+
attr_reader :session_root
|
|
27
|
+
|
|
21
28
|
# @return [String, nil] the effective model: its bare name (no host
|
|
22
29
|
# prefix) and its key (ModelOverlay.key_for), for a rule's models:
|
|
23
30
|
attr_reader :model_name, :model_key
|
|
@@ -62,7 +69,7 @@ module Samagotchi
|
|
|
62
69
|
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
63
70
|
end
|
|
64
71
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
|
|
65
|
-
model_name: model_name, model_key: model_key)
|
|
72
|
+
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base)
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
# What a plugin tool's targets: callable says the call acts on:
|
|
@@ -102,7 +109,9 @@ module Samagotchi
|
|
|
102
109
|
nil
|
|
103
110
|
end
|
|
104
111
|
|
|
105
|
-
def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil
|
|
112
|
+
def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil,
|
|
113
|
+
session_root: nil)
|
|
114
|
+
@session_root = session_root || repo_root || cwd
|
|
106
115
|
@args = args
|
|
107
116
|
@model_name = model_name
|
|
108
117
|
@model_key = model_key
|
|
@@ -123,20 +132,28 @@ module Samagotchi
|
|
|
123
132
|
@small_model = ModelSize.small?(@model_name, @model_key)
|
|
124
133
|
end
|
|
125
134
|
|
|
126
|
-
# Whether any path is outside the repo
|
|
127
|
-
#
|
|
128
|
-
# write/edit there is what memory_write does, unasked; index.md, the
|
|
129
|
-
# bundles dir and the rest of chi's config dir still count.
|
|
135
|
+
# Whether any path is outside the session's repo (Outside.outside?:
|
|
136
|
+
# symlinks resolved; a memory's file and tmp dirs don't count).
|
|
130
137
|
def outside_repo?
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
@paths.any? { |p| Outside.outside?(p, root: @session_root) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Where a shell call runs mutating git (ShellGitDirs: dirs, and
|
|
142
|
+
# :unknown for one the text doesn't tell); [] for other tools.
|
|
143
|
+
def git_dirs
|
|
144
|
+
@git_dirs ||= shell? ? ShellGitDirs.for(@command, cwd: @cwd) : []
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Whether a shell call runs mutating git outside the session's repo
|
|
148
|
+
# (an :unknown dir doesn't count).
|
|
149
|
+
def git_outside_repo?
|
|
150
|
+
git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: @session_root) }
|
|
135
151
|
end
|
|
136
152
|
|
|
137
153
|
# The hook event's targets: hash.
|
|
138
154
|
def to_h
|
|
139
|
-
{ command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo
|
|
155
|
+
{ command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo?,
|
|
156
|
+
git_dirs: git_dirs.map(&:to_s) }
|
|
140
157
|
end
|
|
141
158
|
end
|
|
142
159
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "guardrails/verdict"
|
|
4
4
|
require_relative "guardrails/context"
|
|
5
|
+
require_relative "guardrails/outside"
|
|
6
|
+
require_relative "guardrails/shell_git_dirs"
|
|
5
7
|
require_relative "guardrails/targets"
|
|
6
8
|
require_relative "guardrails/model_size"
|
|
7
9
|
require_relative "guardrails/approval"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A turn's iteration limit (how many model ↔ tool rounds before the turn
|
|
8
|
+
# stops and offers to continue), one resolver for the Engine, the worker
|
|
9
|
+
# and the REPL.
|
|
10
|
+
module IterationLimit
|
|
11
|
+
KEY = "turn.max_iterations"
|
|
12
|
+
# A turn's limit unless turn.max_iterations says otherwise, and the
|
|
13
|
+
# loops' own default.
|
|
14
|
+
DEFAULT = 100
|
|
15
|
+
# A --no-interrupt / --non-interactive turn's, at least: nobody is there
|
|
16
|
+
# to answer a continue offer.
|
|
17
|
+
NO_INTERRUPT = 1000
|
|
18
|
+
|
|
19
|
+
# @param no_interrupt [Boolean] the turn runs with nobody to answer: the
|
|
20
|
+
# larger of NO_INTERRUPT and the configured limit, so a configured one
|
|
21
|
+
# above 1000 is never lowered
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
def self.for(no_interrupt: false) = no_interrupt ? [NO_INTERRUPT, configured].max : configured
|
|
24
|
+
|
|
25
|
+
# turn.max_iterations (env SAMAGOTCHI_TURN_MAX_ITERATIONS); one below 1
|
|
26
|
+
# is DEFAULT, with a warning.
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
def self.configured
|
|
29
|
+
value = Config.get(KEY)
|
|
30
|
+
return DEFAULT if value.nil?
|
|
31
|
+
return value if value.is_a?(Integer) && value >= 1
|
|
32
|
+
|
|
33
|
+
Log.warn(:config, "invalid_value", echo: "Warning: #{KEY} must be 1 or more, got #{value.inspect} — using #{DEFAULT}",
|
|
34
|
+
key: KEY)
|
|
35
|
+
DEFAULT
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "iteration_limit"
|
|
3
4
|
require_relative "model_profile"
|
|
4
5
|
require_relative "tool_call_parser"
|
|
5
6
|
require_relative "config"
|
|
@@ -174,7 +175,7 @@ module Samagotchi
|
|
|
174
175
|
# at the conversation tail (prefix KV cache preserved) and a
|
|
175
176
|
# :pending_input_merged stream event is emitted.
|
|
176
177
|
# @return [LLM::ModelResult] final visible response with continuation metadata
|
|
177
|
-
def run(messages, max_iterations:
|
|
178
|
+
def run(messages, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
178
179
|
@turn_settings = @turn_settings.with(model_name: completion_model_name(model_name))
|
|
179
180
|
turn = start_turn(messages, on_stream_event: on_stream_event, cancel_controller: cancel_controller,
|
|
180
181
|
pending_input: pending_input, cap: resolve_output_char_cap(max_tool_output_chars))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../iteration_limit"
|
|
3
4
|
require_relative "model_result"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
@@ -14,7 +15,7 @@ module Samagotchi
|
|
|
14
15
|
#
|
|
15
16
|
# The raw-prompt KernelLoop is NativeBackend; the chat loop is ChatLoop.
|
|
16
17
|
class ModelBackend
|
|
17
|
-
def complete(messages:, max_iterations:
|
|
18
|
+
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
18
19
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
19
20
|
raise NotImplementedError, "#{self.class}#complete must be implemented"
|
|
20
21
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../iteration_limit"
|
|
3
4
|
require "json"
|
|
4
5
|
require_relative "backend"
|
|
5
6
|
require_relative "model_result"
|
|
@@ -56,7 +57,7 @@ module Samagotchi
|
|
|
56
57
|
|
|
57
58
|
def provider = :chat
|
|
58
59
|
|
|
59
|
-
def complete(messages:, max_iterations:
|
|
60
|
+
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
60
61
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
61
62
|
raise ArgumentError, "ChatLoop has no adapter" unless @adapter
|
|
62
63
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../iteration_limit"
|
|
3
4
|
require_relative "backend"
|
|
4
5
|
require_relative "model_result"
|
|
5
6
|
|
|
@@ -15,7 +16,7 @@ module Samagotchi
|
|
|
15
16
|
|
|
16
17
|
def provider = :native
|
|
17
18
|
|
|
18
|
-
def complete(messages:, max_iterations:
|
|
19
|
+
def complete(messages:, max_iterations: IterationLimit::DEFAULT, on_stream_event: nil, cancel_controller: nil,
|
|
19
20
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
20
21
|
@kernel.run(
|
|
21
22
|
messages,
|
|
@@ -67,6 +67,8 @@ module Samagotchi
|
|
|
67
67
|
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(', ')}"
|
|
68
68
|
end
|
|
69
69
|
candidates = allow.map { |k| candidates_by_basename[k] }
|
|
70
|
+
else
|
|
71
|
+
candidates = leave_out_owned(candidates, resolved_name)
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
# Compute checksums and detect placeholders
|
|
@@ -250,6 +252,26 @@ module Samagotchi
|
|
|
250
252
|
|
|
251
253
|
private
|
|
252
254
|
|
|
255
|
+
# Without an allowlist, a memory another installed bundle owns (its
|
|
256
|
+
# record lists it) isn't the user's to share: left out, one line
|
|
257
|
+
# each. The bundle being built (same name) keeps its own.
|
|
258
|
+
def leave_out_owned(candidates, name)
|
|
259
|
+
require_relative "provenance"
|
|
260
|
+
owners = candidates.to_h do |path|
|
|
261
|
+
[path, Provenance.claimants(File.basename(path), scope: @scope, except: name)]
|
|
262
|
+
end
|
|
263
|
+
left_out = owners.reject { |_, names| names.empty? }
|
|
264
|
+
left_out.each do |path, names|
|
|
265
|
+
@warnings << "Left out #{File.basename(path)}: installed by bundle #{names.join(", ")} (name it to include it)"
|
|
266
|
+
end
|
|
267
|
+
kept = candidates - left_out.keys
|
|
268
|
+
if kept.empty?
|
|
269
|
+
raise BuildError, "No memories to build in #{@scope} scope: every one belongs to an installed bundle " \
|
|
270
|
+
"(#{left_out.keys.map { |p| File.basename(p) }.join(", ")}); name the ones to include"
|
|
271
|
+
end
|
|
272
|
+
kept
|
|
273
|
+
end
|
|
274
|
+
|
|
253
275
|
def normalize_scope(val)
|
|
254
276
|
return nil if val.nil? || val.to_s.strip.empty?
|
|
255
277
|
v = val.to_s.strip.downcase
|
|
@@ -10,18 +10,22 @@ module Samagotchi
|
|
|
10
10
|
module IndexUpdater
|
|
11
11
|
MEMORY_INDEX = "index"
|
|
12
12
|
LOCK_FILE = ".index.lock"
|
|
13
|
-
|
|
13
|
+
# @param source [String, false, nil] the bundle the memory came from
|
|
14
|
+
# ("· from <bundle>"): a name sets it, false drops it, nil keeps the
|
|
15
|
+
# line's (memory_write and write/edit pass nil)
|
|
16
|
+
def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
|
|
14
17
|
index_path = index_path_for(scope)
|
|
15
18
|
return true unless index_path
|
|
16
19
|
locked_write(File.dirname(index_path)) do |content|
|
|
17
|
-
new_line = managed_line(entry_name, scope, byte_count, description)
|
|
20
|
+
new_line = managed_line(entry_name, scope, byte_count, description, source || nil)
|
|
18
21
|
next auto_index_header + "\n\n" + new_line + "\n" if content.nil? || content.strip.empty?
|
|
19
22
|
|
|
20
23
|
pattern = managed_pattern(entry_name)
|
|
21
24
|
if content.match?(pattern)
|
|
22
|
-
|
|
23
|
-
resolved_desc = description&.to_s&.strip ||
|
|
24
|
-
|
|
25
|
+
existing = content[pattern].to_s.chomp
|
|
26
|
+
resolved_desc = description&.to_s&.strip || extract_description(existing)
|
|
27
|
+
resolved_source = source.nil? ? extract_source(existing) : (source || nil)
|
|
28
|
+
new_line = managed_line(entry_name, scope, byte_count, resolved_desc, resolved_source)
|
|
25
29
|
next content.sub(pattern) { new_line + $1 }
|
|
26
30
|
end
|
|
27
31
|
content.end_with?("\n") ? "#{content}#{new_line}\n" : "#{content}\n#{new_line}\n"
|
|
@@ -57,8 +61,9 @@ module Samagotchi
|
|
|
57
61
|
Samagotchi::AtomicFile.write(index_path, updated) unless updated.nil?
|
|
58
62
|
end
|
|
59
63
|
end
|
|
60
|
-
def self.managed_line(name, scope, byte_count, description)
|
|
64
|
+
def self.managed_line(name, scope, byte_count, description, source = nil)
|
|
61
65
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
66
|
+
line += " · from #{source}" unless source.to_s.strip.empty?
|
|
62
67
|
desc = description&.to_s&.strip
|
|
63
68
|
line += " — #{desc}" unless desc.to_s.empty?
|
|
64
69
|
line
|
|
@@ -70,6 +75,12 @@ module Samagotchi
|
|
|
70
75
|
$1
|
|
71
76
|
end
|
|
72
77
|
end
|
|
78
|
+
# The bundle a managed line names ("· from <bundle>", before any
|
|
79
|
+
# " — description"), or nil.
|
|
80
|
+
def self.extract_source(line)
|
|
81
|
+
line.to_s.chomp.split(" — ", 2).first[/ · from (\S+)/, 1]
|
|
82
|
+
end
|
|
83
|
+
|
|
73
84
|
def self.auto_index_header
|
|
74
85
|
"# Memory Index\n\n" \
|
|
75
86
|
"Managed entries below are auto-maintained by memory_write " \
|
|
@@ -9,6 +9,7 @@ require_relative "source"
|
|
|
9
9
|
require_relative "placeholder"
|
|
10
10
|
require_relative "index_updater"
|
|
11
11
|
require_relative "merger"
|
|
12
|
+
require_relative "trash"
|
|
12
13
|
require_relative "../version"
|
|
13
14
|
require_relative "../bundle_needs"
|
|
14
15
|
|
|
@@ -41,8 +42,13 @@ module Samagotchi
|
|
|
41
42
|
@warnings = []
|
|
42
43
|
@placeholder_warnings = []
|
|
43
44
|
@conflicts = {}
|
|
45
|
+
@trash = Trash.new(name)
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
# Where an upgrade moved the files the bundle no longer ships (nil:
|
|
49
|
+
# none moved).
|
|
50
|
+
def trash_dir = @trash.dir
|
|
51
|
+
|
|
46
52
|
def run
|
|
47
53
|
normalized_dir = nil
|
|
48
54
|
source_owned = false
|
|
@@ -73,14 +79,24 @@ module Samagotchi
|
|
|
73
79
|
existing_provenance = provenance.read
|
|
74
80
|
# A plain install over an installed bundle: summary points at upgrade.
|
|
75
81
|
@reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
|
|
82
|
+
# The bundle owns (records, upgrades, removes) only the files it
|
|
83
|
+
# wrote: these, from an earlier install, and the ones written now.
|
|
84
|
+
# A same-name file that was already there is the user's: skipped
|
|
85
|
+
# and never recorded.
|
|
86
|
+
owned_before = existing_provenance && existing_provenance[:files].is_a?(Hash) ? existing_provenance[:files].keys.map(&:to_s) : []
|
|
87
|
+
owned = []
|
|
76
88
|
|
|
77
89
|
Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
|
|
78
90
|
file_key = File.basename(file_path)
|
|
79
91
|
all_files_in_bundle << file_key
|
|
80
92
|
target_path = File.join(target_dir, file_key)
|
|
81
93
|
|
|
82
|
-
if @upgrade && existing_provenance && !@force &&
|
|
94
|
+
if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
|
|
95
|
+
# Not the bundle's (the user's, or another bundle's): no merge.
|
|
96
|
+
skip_existing(file_key, file_path, target_path, target_scope)
|
|
97
|
+
elsif @upgrade && existing_provenance && !@force && !@dry_run
|
|
83
98
|
# 3-way merge path for upgrades
|
|
99
|
+
owned << file_key
|
|
84
100
|
base_path = provenance.base_path(file_key)
|
|
85
101
|
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
86
102
|
case classification
|
|
@@ -120,16 +136,13 @@ module Samagotchi
|
|
|
120
136
|
else
|
|
121
137
|
@results[file_key] = { status: "would_install" }
|
|
122
138
|
end
|
|
123
|
-
elsif File.exist?(target_path) && !@force && FileUtils.identical?(file_path, target_path)
|
|
124
|
-
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
125
|
-
update_target_index(target_scope, target_path, file_key)
|
|
126
139
|
elsif File.exist?(target_path) && !@force
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
update_target_index(target_scope, target_path, file_key)
|
|
140
|
+
# A re-install keeps what an earlier install wrote.
|
|
141
|
+
owned << file_key if owned_before.include?(file_key)
|
|
142
|
+
skip_existing(file_key, file_path, target_path, target_scope, owned: owned_before.include?(file_key))
|
|
131
143
|
else
|
|
132
144
|
FileUtils.cp(file_path, target_path) unless @dry_run
|
|
145
|
+
owned << file_key
|
|
133
146
|
@results[file_key] = { status: "installed" }
|
|
134
147
|
# Update index for newly installed files.
|
|
135
148
|
update_target_index(target_scope, target_path, file_key) unless @dry_run
|
|
@@ -302,7 +315,7 @@ module Samagotchi
|
|
|
302
315
|
if manifest && !@dry_run
|
|
303
316
|
provenance_files = {}
|
|
304
317
|
if @upgrade && existing_provenance
|
|
305
|
-
|
|
318
|
+
owned.each do |file_key|
|
|
306
319
|
target_path = File.join(target_dir, file_key)
|
|
307
320
|
next unless File.exist?(target_path)
|
|
308
321
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
@@ -338,7 +351,7 @@ module Samagotchi
|
|
|
338
351
|
end
|
|
339
352
|
end
|
|
340
353
|
else
|
|
341
|
-
|
|
354
|
+
owned.each do |file_key|
|
|
342
355
|
target_path = File.join(target_dir, file_key)
|
|
343
356
|
next unless File.exist?(target_path)
|
|
344
357
|
|
|
@@ -408,7 +421,7 @@ module Samagotchi
|
|
|
408
421
|
lines << "Updated: #{updated.join(', ')}" unless updated.empty?
|
|
409
422
|
lines << "Would install: #{would_install.join(', ')}" unless would_install.empty?
|
|
410
423
|
lines << "Fast-forward: #{fast_forward.join(', ')}" unless fast_forward.empty?
|
|
411
|
-
lines << "Removed (no longer in the bundle): #{removed.join(', ')}" unless removed.empty?
|
|
424
|
+
lines << "Removed (no longer in the bundle): #{removed.join(', ')}#{trash_dir ? " (moved to #{trash_dir})" : ""}" unless removed.empty?
|
|
412
425
|
lines << "Would remove (no longer in the bundle): #{would_remove.join(', ')}" unless would_remove.empty?
|
|
413
426
|
lines << "Kept (local edits preserved): #{kept.join(', ')}" unless kept.empty?
|
|
414
427
|
lines << "Skipped: #{skipped.join(', ')}" unless skipped.empty?
|
|
@@ -422,9 +435,27 @@ module Samagotchi
|
|
|
422
435
|
|
|
423
436
|
private
|
|
424
437
|
|
|
438
|
+
# A file already in the target dir that the install doesn't write: the
|
|
439
|
+
# same bytes are "already up to date", others are skipped with a
|
|
440
|
+
# --force hint (a dry run: would_skip). Its index line is refreshed
|
|
441
|
+
# (not on a dry run).
|
|
442
|
+
def skip_existing(file_key, file_path, target_path, scope, owned: false)
|
|
443
|
+
if FileUtils.identical?(file_path, target_path)
|
|
444
|
+
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
445
|
+
elsif @dry_run
|
|
446
|
+
@results[file_key] = { status: "would_skip" }
|
|
447
|
+
return
|
|
448
|
+
else
|
|
449
|
+
@results[file_key] = { status: "skipped", reason: "already exists" }
|
|
450
|
+
@warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
|
|
451
|
+
end
|
|
452
|
+
update_target_index(scope, target_path, file_key, owned: owned) unless @dry_run
|
|
453
|
+
end
|
|
454
|
+
|
|
425
455
|
# A file the previous version installed that this one doesn't ship is
|
|
426
|
-
#
|
|
427
|
-
# installed, or with --force; one the user edited is
|
|
456
|
+
# moved to the trash (with its index line removed) when it still
|
|
457
|
+
# matches what was installed, or with --force; one the user edited is
|
|
458
|
+
# kept with a note.
|
|
428
459
|
def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
|
|
429
460
|
previous_files.each do |old_key, meta|
|
|
430
461
|
key = old_key.to_s
|
|
@@ -432,10 +463,14 @@ module Samagotchi
|
|
|
432
463
|
target = File.join(target_dir, key)
|
|
433
464
|
next unless File.exist?(target)
|
|
434
465
|
|
|
435
|
-
|
|
466
|
+
others = Provenance.claimants(key, scope: scope, except: @name)
|
|
467
|
+
if others.any?
|
|
468
|
+
@results[key] = { status: "kept_shared", reason: "bundle #{others.join(", ")} has it too" }
|
|
469
|
+
@warnings << "Kept #{key}: no longer in the bundle, but bundle #{others.join(", ")} has it too"
|
|
470
|
+
elsif @force || installed_unchanged?(target, meta, provenance.base_path(key))
|
|
436
471
|
@results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
|
|
437
472
|
next if @dry_run
|
|
438
|
-
|
|
473
|
+
@trash.move(target)
|
|
439
474
|
remove_target_index(scope, key)
|
|
440
475
|
else
|
|
441
476
|
@results[key] = { status: "kept_pruned", reason: "local edits preserved" }
|
|
@@ -452,11 +487,12 @@ module Samagotchi
|
|
|
452
487
|
File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
|
|
453
488
|
end
|
|
454
489
|
|
|
490
|
+
# Not fatal, like update_target_index: a warning says so.
|
|
455
491
|
def remove_target_index(scope, file_key)
|
|
456
492
|
IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
|
|
457
493
|
IndexUpdater.remove_index(scope, file_key) # legacy "name.md" line
|
|
458
|
-
rescue StandardError
|
|
459
|
-
#
|
|
494
|
+
rescue StandardError => e
|
|
495
|
+
@warnings << "index.md: line for #{file_key.delete_suffix(".md")} not removed (#{e.message})"
|
|
460
496
|
end
|
|
461
497
|
|
|
462
498
|
# One warning per need not found on this PATH (read-only, so dry-run
|
|
@@ -514,16 +550,19 @@ module Samagotchi
|
|
|
514
550
|
dest
|
|
515
551
|
end
|
|
516
552
|
|
|
517
|
-
|
|
553
|
+
# +owned+: the bundle wrote (owns) the file, so its line says
|
|
554
|
+
# "· from <bundle>"; a skipped file's line keeps whatever it had.
|
|
555
|
+
def update_target_index(scope, file_path, file_key, owned: true)
|
|
518
556
|
byte_count = File.exist?(file_path) ? File.size(file_path) : 0
|
|
519
557
|
entry_name = file_key.delete_suffix(".md")
|
|
520
558
|
begin
|
|
521
559
|
# Index lines use the entry name (as memory_write does); drop the
|
|
522
560
|
# legacy "name.md" line older installs wrote.
|
|
523
561
|
IndexUpdater.remove_index(scope, file_key) unless entry_name == file_key
|
|
524
|
-
IndexUpdater.update_index(scope, entry_name, byte_count)
|
|
525
|
-
rescue =>
|
|
526
|
-
#
|
|
562
|
+
IndexUpdater.update_index(scope, entry_name, byte_count, source: owned ? @name : nil)
|
|
563
|
+
rescue StandardError => e
|
|
564
|
+
# Not fatal (the file is in place): a warning says so.
|
|
565
|
+
@warnings << "index.md: line for #{entry_name} not updated (#{e.message})"
|
|
527
566
|
end
|
|
528
567
|
end
|
|
529
568
|
|
|
@@ -30,8 +30,10 @@ module Samagotchi
|
|
|
30
30
|
def failed? = !failed.empty?
|
|
31
31
|
end
|
|
32
32
|
# removed: members uninstalled; blocked: name => why; gone: the meta
|
|
33
|
-
# itself was removed (only once every member is)
|
|
34
|
-
|
|
33
|
+
# itself was removed (only once every member is); trash: member =>
|
|
34
|
+
# [memory files, the trash dir they were moved to]; warnings: the
|
|
35
|
+
# members' ("member: line").
|
|
36
|
+
UninstallResult = Struct.new(:name, :removed, :blocked, :gone, :trash, :warnings, keyword_init: true)
|
|
35
37
|
|
|
36
38
|
module_function
|
|
37
39
|
|
|
@@ -127,10 +129,13 @@ module Samagotchi
|
|
|
127
129
|
shipped = File.join(shipped_dir, name)
|
|
128
130
|
manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
|
|
129
131
|
members = recorded(data, manifest)
|
|
130
|
-
result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false)
|
|
132
|
+
result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false, trash: {}, warnings: [])
|
|
131
133
|
(members & installed_names).each do |member|
|
|
132
|
-
Uninstaller.new(name: member, force: force)
|
|
134
|
+
uninstaller = Uninstaller.new(name: member, force: force)
|
|
135
|
+
uninstaller.run
|
|
133
136
|
result.removed << member
|
|
137
|
+
result.trash[member] = [uninstaller.trashed_files, uninstaller.trash_dir] if uninstaller.trash_dir
|
|
138
|
+
result.warnings.concat(uninstaller.warnings.map { |w| "#{member}: #{w}" })
|
|
134
139
|
rescue Uninstaller::UninstallError => e
|
|
135
140
|
result.blocked[member] = e.message
|
|
136
141
|
end
|
|
@@ -84,6 +84,20 @@ module Samagotchi
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# The other installed bundles of +scope+ whose record lists the memory
|
|
88
|
+
# file +file_key+, by name (a manifest that doesn't read is left out).
|
|
89
|
+
# A project-scoped record doesn't say which project: one from another
|
|
90
|
+
# repo counts too, which errs on keeping the file.
|
|
91
|
+
def self.claimants(file_key, scope:, except: nil, dir: bundles_dir)
|
|
92
|
+
each_installed(dir: dir).filter_map do |name, data|
|
|
93
|
+
next if name == except || data[:error]
|
|
94
|
+
next unless (data[:scope].to_s.empty? ? "system" : data[:scope].to_s) == scope.to_s
|
|
95
|
+
next unless data[:files].is_a?(Hash) && data[:files].keys.map(&:to_s).include?(file_key.to_s)
|
|
96
|
+
|
|
97
|
+
name
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
87
101
|
# The hex digest a manifest records, in either of its formats:
|
|
88
102
|
# "sha256:<hex>" (hooks, rules, the plugin) or bare <hex> (a memory
|
|
89
103
|
# file's checksum:). "" when none is recorded.
|
|
@@ -143,13 +143,15 @@ module Samagotchi
|
|
|
143
143
|
row
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
# The .md files whose local edits conflict with the shipped
|
|
146
|
+
# The bundle's .md files whose local edits conflict with the shipped
|
|
147
|
+
# version (a same-name file it didn't install is skipped, not merged).
|
|
147
148
|
def conflicts(row, data)
|
|
148
149
|
provenance = Provenance.new(name: row.name)
|
|
149
150
|
target_dir = Status.resolve_target_dir(row.scope)
|
|
150
|
-
return [] unless data[:files]
|
|
151
|
+
return [] unless data[:files].is_a?(Hash)
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
owned = data[:files].keys.map(&:to_s)
|
|
154
|
+
(Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort & owned).select do |key|
|
|
153
155
|
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
|
|
154
156
|
incoming_path: File.join(row.source_dir, key)) == :conflict
|
|
155
157
|
end
|
|
@@ -169,11 +169,13 @@ module Samagotchi
|
|
|
169
169
|
result(:restored, from: version, to: version)
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
-
# The files an upgrade would keep:
|
|
173
|
-
# shipped bundle (a dry run's kept).
|
|
172
|
+
# The files an upgrade would keep: installed by the bundle, edited
|
|
173
|
+
# here and changed in the shipped bundle (a dry run's kept).
|
|
174
174
|
def conflicting(gem_manifest)
|
|
175
175
|
provenance = Provenance.new(name: BUNDLE_NAME)
|
|
176
|
-
|
|
176
|
+
owned = (provenance.read || {})[:files]
|
|
177
|
+
owned = owned.is_a?(Hash) ? owned.keys.map(&:to_s) : []
|
|
178
|
+
(gem_manifest.files.keys.map(&:to_s).sort & owned).select do |key|
|
|
177
179
|
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
|
|
178
180
|
incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
|
|
179
181
|
end
|