samagotchi 0.16.0 → 0.17.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +13 -0
  5. data/docs/cli.md +4 -2
  6. data/docs/configuration.md +4 -4
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/memory.md +39 -0
  9. data/docs/plugins.md +2 -0
  10. data/docs/sessions.md +3 -2
  11. data/docs/sub-agent.md +1 -0
  12. data/docs/testing.md +47 -0
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  14. data/lib/samagotchi/bridge.rb +38 -7
  15. data/lib/samagotchi/bundle_command.rb +13 -2
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  19. data/lib/samagotchi/engine.rb +31 -5
  20. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  21. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  22. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  23. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  24. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  25. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  26. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  27. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  28. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  29. data/lib/samagotchi/plugin/context.rb +14 -2
  30. data/lib/samagotchi/self_report.rb +49 -3
  31. data/lib/samagotchi/send_command.rb +45 -6
  32. data/lib/samagotchi/session_metrics.rb +137 -48
  33. data/lib/samagotchi/system_prompt.rb +45 -4
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -3
  35. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  36. data/lib/samagotchi/terminal_ui.rb +24 -0
  37. data/lib/samagotchi/tools/builtins.rb +11 -3
  38. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  39. data/lib/samagotchi/tools/peers.rb +3 -2
  40. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +2 -1
  43. data/lib/samagotchi/web/public/app.js +37 -28
  44. data/lib/samagotchi/web/public/notify.js +7 -3
  45. data/lib/samagotchi/web/public/stage_view.js +13 -1
  46. data/lib/samagotchi/web/public/strip.js +11 -0
  47. data/lib/samagotchi/web/public/timing.js +26 -12
  48. data/lib/samagotchi/web/public/turn_events.js +13 -3
  49. data/lib/samagotchi/worker.rb +28 -2
  50. data/lib/samagotchi/worker_sidecar.rb +4 -1
  51. metadata +3 -1
@@ -243,7 +243,7 @@ module Samagotchi
243
243
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
244
244
  @resume_session = session_id ? Session.load(session_id) : nil
245
245
  @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
246
- thinking: -> { turn_thinking }, memories: memories,
246
+ thinking: -> { turn_thinking }, model: -> { model_identity }, memories: memories,
247
247
  muted_memory_names: @muted_memory_names)
248
248
  @session = nil
249
249
  @session_observer = SessionObserver.new
@@ -411,6 +411,18 @@ module Samagotchi
411
411
  model_ref_for(@effective_model_name)
412
412
  end
413
413
 
414
+ # The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
415
+ # the ref, its host, the overlay key and, for a local llama.cpp host
416
+ # only, the model its /props names when that differs. Never a new
417
+ # request: only the /props answer profile resolution already cached.
418
+ def model_identity
419
+ target = @host_registry.resolve(@effective_model_name)
420
+ entry = target.entry
421
+ host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
422
+ SystemPrompt::ModelIdentity.new(ref: effective_model_ref, host: host, key: @model_key,
423
+ served: prompt_served_model(target), fallback_key: @fallback_model_key)
424
+ end
425
+
414
426
  # +name+'s resolved ref (ModelRef#ref).
415
427
  def model_ref_for(name)
416
428
  @host_registry.model_ref(name).ref
@@ -463,8 +475,8 @@ module Samagotchi
463
475
  def sync_model_key!
464
476
  @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
465
477
  typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
466
- fallback = typed_key == @model_key ? nil : typed_key
467
- @kernel.sync_model_key!(@model_key, fallback: fallback)
478
+ @fallback_model_key = typed_key == @model_key ? nil : typed_key
479
+ @kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
468
480
  end
469
481
  private :sync_model_key!
470
482
 
@@ -904,7 +916,7 @@ module Samagotchi
904
916
  # else [nil, nil].
905
917
  # @return [Array(String, String), Array(nil, nil)]
906
918
  def served_model(probe: true)
907
- served_model_for(@metrics.snapshot, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
919
+ served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
908
920
  end
909
921
 
910
922
  # Read-only snapshot of the engine's view of the current session plus the
@@ -1307,6 +1319,7 @@ module Samagotchi
1307
1319
  def state_dir = engine.peer_state_dir
1308
1320
  def cancelled? = !!engine.active_cancel_controller&.cancelled?
1309
1321
  def relay = engine.relay_peer
1322
+ def model_ref = engine.effective_model_ref
1310
1323
  end
1311
1324
 
1312
1325
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -2125,7 +2138,8 @@ module Samagotchi
2125
2138
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2126
2139
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2127
2140
  },
2128
- model_name: -> { @session&.model_name || @effective_model_name },
2141
+ model_name: -> { effective_model_ref },
2142
+ model_key: -> { @model_key },
2129
2143
  state_dir: -> { session_state_dir },
2130
2144
  scratch: -> { @scratch }
2131
2145
  )
@@ -2320,6 +2334,18 @@ module Samagotchi
2320
2334
  [nil, nil]
2321
2335
  end
2322
2336
 
2337
+ # What a local native host's cached /props names, when it isn't the
2338
+ # model asked for; nil for a chat or remote host, or before any probe.
2339
+ def prompt_served_model(target)
2340
+ entry = target.entry
2341
+ return nil if entry.nil? || entry.chat? || entry.remote? || !target.client.respond_to?(:cached_server_props)
2342
+
2343
+ served = ServedModel.from_props(target.client.cached_server_props(model: target.bare_model))
2344
+ served if ServedModel.differs?(target.bare_model, served)
2345
+ rescue StandardError
2346
+ nil
2347
+ end
2348
+
2323
2349
  # ── Prompt profile ─────────────────────────────────────────────────────────
2324
2350
 
2325
2351
  def resolve_profile
@@ -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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../memory_paths"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # Where a bundle operation puts the memory files it takes away
9
+ # (uninstall, an upgrade's dropped file): moved, never deleted, into
10
+ # <system memories>/.bundles/.trash/<bundle>-<YYYYmmdd-HHMMSS>/, one dir
11
+ # per operation (-2, -3… when one already has that name). A dot dir, so
12
+ # Provenance.each_installed never reads it as a bundle. Nothing prunes
13
+ # it: the user deletes it when sure.
14
+ class Trash
15
+ def self.root(bundles_dir: MemoryPaths.bundles_dir)
16
+ File.join(bundles_dir, ".trash")
17
+ end
18
+
19
+ # The dir, once a file was moved (nil before).
20
+ attr_reader :dir
21
+
22
+ def initialize(name, now: Time.now, bundles_dir: MemoryPaths.bundles_dir)
23
+ @name = name.to_s
24
+ @now = now
25
+ @bundles_dir = bundles_dir
26
+ @dir = nil
27
+ end
28
+
29
+ # Moves +path+ into the trash dir (created on the first call).
30
+ # @return [String] the file's new path
31
+ def move(path)
32
+ dest = File.join(ensure_dir, File.basename(path))
33
+ FileUtils.mv(path, dest)
34
+ dest
35
+ end
36
+
37
+ private
38
+
39
+ def ensure_dir
40
+ return @dir if @dir
41
+
42
+ base = File.join(self.class.root(bundles_dir: @bundles_dir), "#{@name}-#{@now.strftime("%Y%m%d-%H%M%S")}")
43
+ FileUtils.mkdir_p(File.dirname(base))
44
+ candidate = base
45
+ n = 1
46
+ loop do
47
+ Dir.mkdir(candidate)
48
+ break
49
+ rescue Errno::EEXIST
50
+ n += 1
51
+ candidate = "#{base}-#{n}"
52
+ end
53
+ @dir = candidate
54
+ end
55
+ end
56
+ end
57
+ end
@@ -5,13 +5,17 @@ require_relative "../memory_paths"
5
5
  require_relative "provenance"
6
6
  require_relative "index_updater"
7
7
  require_relative "merger"
8
+ require_relative "trash"
8
9
 
9
10
  module Samagotchi
10
11
  module MemoryBundle
11
12
  class Uninstaller
12
13
  class UninstallError < StandardError; end
13
14
 
14
- attr_reader :warnings, :removed_files
15
+ # removed_files: the bundle's own hooks/ and plugin/ files (deleted);
16
+ # trashed_files: its memory files, moved to trash_dir (Trash), nil
17
+ # when none was moved.
18
+ attr_reader :warnings, :removed_files, :trashed_files
15
19
 
16
20
  def initialize(name:, scope: nil, force: false)
17
21
  @name = name
@@ -19,8 +23,12 @@ module Samagotchi
19
23
  @force = force
20
24
  @warnings = []
21
25
  @removed_files = []
26
+ @trashed_files = []
27
+ @trash = Trash.new(name)
22
28
  end
23
29
 
30
+ def trash_dir = @trash.dir
31
+
24
32
  def run
25
33
  provenance = Provenance.new(name: @name)
26
34
  data = provenance.read
@@ -40,12 +48,18 @@ module Samagotchi
40
48
  return true
41
49
  end
42
50
 
51
+ # A file another installed bundle lists too stays, edited or not.
52
+ shared = files.keys.map(&:to_s).to_h do |key|
53
+ [key, Provenance.claimants(key, scope: target_scope, except: @name)]
54
+ end.reject { |_, others| others.empty? }
55
+
43
56
  blocked = []
44
57
  files.each do |file_key, meta|
45
58
  file_key_str = file_key.to_s
46
59
  target_path = File.join(target_dir, file_key_str)
47
60
  base_path = provenance.base_path(file_key_str)
48
61
  next unless File.exist?(target_path)
62
+ next if shared.key?(file_key_str)
49
63
  if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
50
64
  blocked << file_key_str
51
65
  @warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
@@ -57,15 +71,23 @@ module Samagotchi
57
71
  files.each do |file_key, _meta|
58
72
  file_key_str = file_key.to_s
59
73
  target_path = File.join(target_dir, file_key_str)
74
+ if (others = shared[file_key_str])
75
+ next unless File.exist?(target_path)
76
+
77
+ @warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
78
+ retag(target_scope, target_path, file_key_str, others.first)
79
+ next
80
+ end
60
81
  if File.exist?(target_path)
61
- FileUtils.rm_f(target_path)
62
- @removed_files << file_key_str
82
+ @trash.move(target_path)
83
+ @trashed_files << file_key_str
63
84
  end
64
- # Remove index line
85
+ # Remove index line (not fatal: a warning says so)
65
86
  begin
66
87
  IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"))
67
88
  IndexUpdater.remove_index(target_scope, file_key_str) # legacy "name.md" line
68
- rescue => _e
89
+ rescue StandardError => e
90
+ @warnings << "index.md: line for #{file_key_str.delete_suffix(".md")} not removed (#{e.message})"
69
91
  end
70
92
  end
71
93
 
@@ -97,6 +119,13 @@ module Samagotchi
97
119
 
98
120
  private
99
121
 
122
+ # A kept shared file's index line names the bundle that still has it.
123
+ def retag(scope, path, file_key, bundle)
124
+ IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
125
+ rescue StandardError => e
126
+ @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
127
+ end
128
+
100
129
  def resolve_target_dir(scope)
101
130
  MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
102
131
  end
@@ -18,11 +18,14 @@ module Samagotchi
18
18
  # (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
19
19
  # the answer text.
20
20
  # +messages_partial+ says whether +messages+ leaves out a running turn;
21
- # +model_name+ and +state_dir+ are what ctx.sessions forks with.
21
+ # +model_name+ (the session's resolved model ref, live after /model) and
22
+ # +state_dir+ are what ctx.sessions forks with; +model_key+ is the
23
+ # model's memory overlay key (ctx.model, ctx.model_key).
22
24
  # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
23
25
  # (reason, label), each true when it acted on a running turn.
24
26
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
25
- :ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, :stop_generation, keyword_init: true)
27
+ :ask_model, :model_name, :model_key, :state_dir, :scratch, :steer, :stop_turn, :stop_generation,
28
+ keyword_init: true)
26
29
 
27
30
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
28
31
  # sent nothing usable. The message says why, for the user.
@@ -86,6 +89,15 @@ module Samagotchi
86
89
  # @return [String, nil] the session's id (nil before the first one)
87
90
  def session_id = @host.session_id.call
88
91
 
92
+ # @return [String, nil] the model the session runs on now: its
93
+ # resolved ref ("host:id"), right after /model too
94
+ def model = @host.model_name&.call
95
+
96
+ # @return [String, nil] the model's memory overlay key
97
+ # (`<name>.<key>.md`, ModelOverlay.key_for), as memory_write
98
+ # current_model_only names it
99
+ def model_key = @host.model_key&.call
100
+
89
101
  # @return [String] the session's working directory
90
102
  def cwd = @host.cwd.call || Dir.pwd
91
103