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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -1
- data/README.md +2 -0
- data/docs/architecture.md +13 -0
- data/docs/cli.md +4 -2
- data/docs/configuration.md +4 -4
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +3 -2
- data/docs/sub-agent.md +1 -0
- data/docs/testing.md +47 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +38 -7
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/engine.rb +31 -5
- 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 +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +37 -28
- data/lib/samagotchi/web/public/notify.js +7 -3
- 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 +26 -12
- data/lib/samagotchi/web/public/turn_events.js +13 -3
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +3 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
467
|
-
@kernel.sync_model_key!(@model_key, 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.
|
|
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: -> {
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
@
|
|
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 =>
|
|
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+
|
|
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,
|
|
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
|
|