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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. 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 "../memory_bundle/index_sync"
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 when there is no repo).
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 root (the cwd outside a repo).
127
- # A memory's file doesn't count (MemoryBundle::IndexSync.memory_scope):
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
- root = @repo_root || @cwd
132
- @paths.any? do |p|
133
- p != root && !p.start_with?(File.join(root, "")) && !MemoryBundle::IndexSync.memory_scope(p)
134
- end
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: 100, on_stream_event: nil, cancel_controller: nil, model_name: nil, max_tool_output_chars: nil, pending_input: nil)
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: 100, on_stream_event: nil, cancel_controller: nil,
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: 100, on_stream_event: nil, cancel_controller: nil,
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: 100, on_stream_event: nil, cancel_controller: nil,
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
- def self.update_index(scope, entry_name, byte_count, description = nil)
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
- existing_desc = extract_description(content[pattern].to_s)
23
- resolved_desc = description&.to_s&.strip || existing_desc
24
- new_line = managed_line(entry_name, scope, byte_count, resolved_desc)
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 && !@dry_run
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
- @results[file_key] = { status: "skipped", reason: "already exists" }
128
- @warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
129
- # Still update the index for skipped files.
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
- all_files_in_bundle.each do |file_key|
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
- all_files_in_bundle.each do |file_key|
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
- # removed (with its index line) when it still matches what was
427
- # installed, or with --force; one the user edited is kept with a note.
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
- if @force || installed_unchanged?(target, meta, provenance.base_path(key))
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
- FileUtils.rm_f(target)
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
- # Best-effort, like update_target_index.
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
- def update_target_index(scope, file_path, file_key)
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 => _e
526
- # Silently skip index updates — they're best-effort.
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
- UninstallResult = Struct.new(:name, :removed, :blocked, :gone, keyword_init: true)
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).run
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 version.
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
- Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort.select do |key|
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: edited here and changed in the
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
- gem_manifest.files.keys.map(&:to_s).sort.select do |key|
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