samagotchi 0.74.0 → 0.76.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 +68 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +21 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +14 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +84 -4
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +11 -14
- data/lib/samagotchi/memory_bundle/installer.rb +140 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/session.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -8
- data/lib/samagotchi/session_commands.rb +2 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/app.js +4 -3
- data/lib/samagotchi/web/public/chains.js +42 -21
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +73 -11
- data/lib/samagotchi/web/public/index.html +32 -35
- data/lib/samagotchi/web/public/info_bar.js +4 -4
- data/lib/samagotchi/web/public/info_bar_model.js +18 -8
- data/lib/samagotchi/web/public/lists_view.js +2 -2
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +7 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +32 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- metadata +4 -1
|
@@ -55,6 +55,7 @@ module Samagotchi
|
|
|
55
55
|
prune_dropped_hooks if @upgrade && @existing && !@dry_run
|
|
56
56
|
verify_hook_checksums if @manifest && @strict
|
|
57
57
|
incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
|
|
58
|
+
.reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
|
|
58
59
|
guardrail_files = install_guardrails(incoming_rules)
|
|
59
60
|
plugin_file = install_plugin
|
|
60
61
|
script_files = install_scripts
|
|
@@ -81,11 +82,14 @@ module Samagotchi
|
|
|
81
82
|
if hook_entries.empty?
|
|
82
83
|
Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each { |p| hook_entries[File.basename(p)] = BundleHook.new }
|
|
83
84
|
end
|
|
84
|
-
hook_entries.
|
|
85
|
+
hook_entries.keys.each do |basename|
|
|
85
86
|
next unless basename.is_a?(String) && !basename.empty?
|
|
86
87
|
|
|
87
|
-
@bundle_hooks << basename
|
|
88
88
|
src = File.join(@bundle_dir, "hooks", basename)
|
|
89
|
+
# A linked hook isn't the bundle's: not installed, counted or recorded.
|
|
90
|
+
next hook_entries.delete(basename) if (File.symlink?(src) || File.exist?(src)) && linked_out?(src, "hooks/#{basename}")
|
|
91
|
+
|
|
92
|
+
@bundle_hooks << basename
|
|
89
93
|
install_hook(basename, src) if File.exist?(src)
|
|
90
94
|
end
|
|
91
95
|
end
|
|
@@ -120,6 +124,8 @@ module Samagotchi
|
|
|
120
124
|
def install_unlisted_hooks
|
|
121
125
|
Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each do |src|
|
|
122
126
|
basename = File.basename(src)
|
|
127
|
+
next if linked_out?(src, "hooks/#{basename}")
|
|
128
|
+
|
|
123
129
|
@bundle_hooks << basename
|
|
124
130
|
if @dry_run
|
|
125
131
|
@results[basename] = { status: "would_install" }
|
|
@@ -207,6 +213,7 @@ module Samagotchi
|
|
|
207
213
|
|
|
208
214
|
src = File.join(@bundle_dir, plugin[:file])
|
|
209
215
|
raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
|
|
216
|
+
raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
|
|
210
217
|
|
|
211
218
|
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
|
|
212
219
|
@warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
|
|
@@ -243,9 +250,9 @@ module Samagotchi
|
|
|
243
250
|
def install_scripts
|
|
244
251
|
scripts = @manifest&.scripts || {}
|
|
245
252
|
scripts.each_key do |file|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
raise Installer::InstallError, "
|
|
253
|
+
src = File.join(@bundle_dir, "scripts", file)
|
|
254
|
+
raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
|
|
255
|
+
raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
|
|
249
256
|
end
|
|
250
257
|
return {} if @dry_run
|
|
251
258
|
|
|
@@ -274,6 +281,28 @@ module Samagotchi
|
|
|
274
281
|
end
|
|
275
282
|
end
|
|
276
283
|
|
|
284
|
+
# Whether the bundle file +src+ reads through a symlink: it is one, or
|
|
285
|
+
# it (or its dir, hooks/ say) resolves outside the bundle dir. A tar,
|
|
286
|
+
# zip or git repo keeps links; copying through one would install any
|
|
287
|
+
# file the user can read. MemoryFiles skips linked memories the same
|
|
288
|
+
# way.
|
|
289
|
+
def linked?(src)
|
|
290
|
+
return true if File.symlink?(src)
|
|
291
|
+
|
|
292
|
+
root = File.realpath(@bundle_dir)
|
|
293
|
+
!File.realpath(src).start_with?("#{root}/")
|
|
294
|
+
rescue SystemCallError
|
|
295
|
+
true
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# linked? with a "<label> is a symlink (not installed)" warning.
|
|
299
|
+
def linked_out?(src, label)
|
|
300
|
+
return false unless linked?(src)
|
|
301
|
+
|
|
302
|
+
@warnings << "#{label} is a symlink (not installed)"
|
|
303
|
+
true
|
|
304
|
+
end
|
|
305
|
+
|
|
277
306
|
# The hook loader skips every hook of a bundle whose requires_chi this
|
|
278
307
|
# chi doesn't meet (Hooks::BundleLoader); say so at install, as
|
|
279
308
|
# install_plugin does for the plugin.
|
|
@@ -398,11 +398,10 @@ module Samagotchi
|
|
|
398
398
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
399
399
|
end
|
|
400
400
|
|
|
401
|
-
# Use zip CLI like source.rb does with unzip
|
|
402
|
-
Dir.chdir
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
end
|
|
401
|
+
# Use zip CLI like source.rb does with unzip. It runs in the staging
|
|
402
|
+
# dir (chdir:): Dir.chdir would move the whole process.
|
|
403
|
+
result = system("zip", "-r", out_path, ".", chdir: staging)
|
|
404
|
+
raise BuildError, "zip failed for #{out_path} (is zip installed?)" unless result && File.exist?(out_path)
|
|
406
405
|
end
|
|
407
406
|
|
|
408
407
|
def tar_staging(staging, out_path, format)
|
|
@@ -411,15 +410,13 @@ module Samagotchi
|
|
|
411
410
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
412
411
|
end
|
|
413
412
|
|
|
414
|
-
# Use tar CLI
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
raise BuildError, "tar failed for #{out_path}" unless result && File.exist?(out_path)
|
|
422
|
-
end
|
|
413
|
+
# Use tar CLI, in the staging dir (chdir:, as zip_staging).
|
|
414
|
+
cmd = case format
|
|
415
|
+
when :tar_gz, :tgz then ["tar", "-czf", out_path, "."]
|
|
416
|
+
when :tar then ["tar", "-cf", out_path, "."]
|
|
417
|
+
end
|
|
418
|
+
result = system(*cmd, chdir: staging)
|
|
419
|
+
raise BuildError, "tar failed for #{out_path}" unless result && File.exist?(out_path)
|
|
423
420
|
end
|
|
424
421
|
end
|
|
425
422
|
end
|
|
@@ -11,6 +11,7 @@ require_relative "index_updater"
|
|
|
11
11
|
require_relative "merger"
|
|
12
12
|
require_relative "trash"
|
|
13
13
|
require_relative "asset_installer"
|
|
14
|
+
require_relative "memory_files"
|
|
14
15
|
require_relative "../bundle_needs"
|
|
15
16
|
require_relative "../model_overlay"
|
|
16
17
|
|
|
@@ -33,8 +34,16 @@ module Samagotchi
|
|
|
33
34
|
|
|
34
35
|
attr_reader :results, :warnings, :placeholder_warnings, :conflicts
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
# prepared: the source already normalized (a PreparedSource, which the
|
|
38
|
+
# Installer then owns and cleans up as its own); nil normalizes source.
|
|
39
|
+
# shipped_dir: the bundles this chi ships (a bundle of their names
|
|
40
|
+
# installs from a dir in it only, unless force); ShippedUpdate,
|
|
41
|
+
# Profile and SystemBundle pass the one they install from.
|
|
42
|
+
def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false, prepared: nil,
|
|
43
|
+
shipped_dir: SourceNormalizer::SHIPPED_DIR)
|
|
37
44
|
@source = source
|
|
45
|
+
@shipped_dir = shipped_dir
|
|
46
|
+
@prepared = prepared
|
|
38
47
|
@name = name
|
|
39
48
|
@scope = scope&.to_s&.strip&.downcase || ""
|
|
40
49
|
@force = force
|
|
@@ -49,6 +58,10 @@ module Samagotchi
|
|
|
49
58
|
@trash = Trash.new(name)
|
|
50
59
|
end
|
|
51
60
|
|
|
61
|
+
# [recorded source, this source] when an upgrade switches the bundle's
|
|
62
|
+
# source (Installer#check_source), else nil.
|
|
63
|
+
attr_reader :source_change
|
|
64
|
+
|
|
52
65
|
# Where an upgrade moved the files the bundle no longer ships (nil:
|
|
53
66
|
# none moved).
|
|
54
67
|
def trash_dir = @trash.dir
|
|
@@ -77,6 +90,7 @@ module Samagotchi
|
|
|
77
90
|
@file_descriptions = @manifest&.file_descriptions || {}
|
|
78
91
|
@provenance = Provenance.new(name: @name)
|
|
79
92
|
@existing = @provenance.record
|
|
93
|
+
check_source
|
|
80
94
|
# A plain install over an installed bundle: summary points at upgrade.
|
|
81
95
|
@reinstall = @existing && !@upgrade && !@force && !@dry_run
|
|
82
96
|
install_memories
|
|
@@ -113,6 +127,7 @@ module Samagotchi
|
|
|
113
127
|
|
|
114
128
|
def summary
|
|
115
129
|
lines = []
|
|
130
|
+
lines << "Source changes: #{@source_change.join(" → ")}" if @source_change
|
|
116
131
|
installed = @results.select { |_, r| r[:status].to_s == "installed" }.keys
|
|
117
132
|
updated = @results.select { |_, r| r[:status].to_s == "updated" }.keys
|
|
118
133
|
kept = @results.select { |_, r| %w[kept kept_pruned].include?(r[:status].to_s) }.keys
|
|
@@ -141,12 +156,90 @@ module Samagotchi
|
|
|
141
156
|
|
|
142
157
|
private
|
|
143
158
|
|
|
159
|
+
# Who may install under this name (a manifest can take any name: a
|
|
160
|
+
# repo calling itself guardrails). Sources compare by SourceIdentity
|
|
161
|
+
# (a shipped dir by name, in any gem or checkout; a git URL without
|
|
162
|
+
# #ref; a path resolved); one it can't place (an older record's
|
|
163
|
+
# relative path) never refuses.
|
|
164
|
+
# - a name chi ships comes from a shipped dir only, unless --force
|
|
165
|
+
# (install and upgrade): the plugin and hooks it replaces are chi's;
|
|
166
|
+
# - a plain install over a bundle from another source is refused,
|
|
167
|
+
# --force too (that overwrites files, it doesn't switch sources):
|
|
168
|
+
# it would delete the plugin and hooks and rewrite the record;
|
|
169
|
+
# - an upgrade may switch sources (a URL and a local copy of it are
|
|
170
|
+
# one bundle, an archive's file name has its version), and
|
|
171
|
+
# #source_change says so (the summary's first line).
|
|
172
|
+
def check_source
|
|
173
|
+
refuse_case_twin
|
|
174
|
+
refuse_shipped_name
|
|
175
|
+
now = incoming_identity
|
|
176
|
+
return if now.nil?
|
|
177
|
+
|
|
178
|
+
was = @existing && SourceIdentity.of(@existing.source)
|
|
179
|
+
return if was.nil? || was == now
|
|
180
|
+
return @source_change = [@existing.source, @source.to_s] if @upgrade
|
|
181
|
+
|
|
182
|
+
raise InstallError, "#{@name} is installed from #{@existing.source}; `chi bundle upgrade #{@source}` " \
|
|
183
|
+
"switches its source, or `chi bundle uninstall #{@name}` first"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A name that differs from an installed bundle's only in case is that
|
|
187
|
+
# bundle's dir on a case-insensitive file system (macOS): BTW would
|
|
188
|
+
# replace btw. Refused, --force too.
|
|
189
|
+
def refuse_case_twin
|
|
190
|
+
twin, = Provenance.each_installed.find { |name, _| name != @name && name.casecmp?(@name) }
|
|
191
|
+
raise InstallError, "#{@name} differs only in case from the installed #{twin}" if twin
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A shipped name (in any case) from anything but this chi's own
|
|
195
|
+
# shipped dir. A recorded source may be an older gem's (SourceIdentity
|
|
196
|
+
# matches the path); an incoming one must be a dir in SHIPPED_DIR, so
|
|
197
|
+
# a fork's lib/samagotchi/bundles/btw isn't chi's.
|
|
198
|
+
def refuse_shipped_name
|
|
199
|
+
return if @force || chi_copy?(@source)
|
|
200
|
+
|
|
201
|
+
shipped = shipped_names.find { |name| name.casecmp?(@name) }
|
|
202
|
+
return unless shipped
|
|
203
|
+
|
|
204
|
+
what = shipped == @name ? "#{@name} is a bundle shipped with chi" : "#{@name} is #{shipped}, a bundle shipped with chi"
|
|
205
|
+
raise InstallError, "#{what}; #{@source} isn't chi's copy of it (--force to install it anyway)"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# This source's SourceIdentity; one that looks shipped by its path (a
|
|
209
|
+
# fork's lib/samagotchi/bundles/btw) but isn't a dir in the shipped
|
|
210
|
+
# dir is its path, so it isn't taken for chi's btw. A recorded source
|
|
211
|
+
# keeps the path match (older gems).
|
|
212
|
+
def incoming_identity
|
|
213
|
+
identity = SourceIdentity.of(@source)
|
|
214
|
+
identity&.shipped? && !chi_copy?(@source) ? SourceIdentity.path(@source) : identity
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Whether +source+ is a dir in the shipped dir.
|
|
218
|
+
def chi_copy?(source)
|
|
219
|
+
path = File.realpath(source.to_s)
|
|
220
|
+
File.directory?(path) && File.dirname(path) == File.realpath(@shipped_dir)
|
|
221
|
+
rescue SystemCallError
|
|
222
|
+
false
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The names of the bundles shipped with chi (their manifests'),
|
|
226
|
+
# samagotchi-system and profiles included.
|
|
227
|
+
def shipped_names
|
|
228
|
+
Dir.glob(File.join(@shipped_dir, "*", "manifest.yml")).filter_map do |path|
|
|
229
|
+
Manifest.read(dir: File.dirname(path)).name
|
|
230
|
+
rescue Manifest::ValidationError
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
144
235
|
# The source normalized to a dir (@bundle_dir; an owned one, extracted
|
|
145
236
|
# from git/zip/tar, is cleaned up after #run) and its manifest.yml
|
|
146
237
|
# (@manifest, nil when it has none and the install isn't strict).
|
|
147
238
|
def load_source
|
|
148
|
-
|
|
149
|
-
@source_dir =
|
|
239
|
+
prepared = @prepared || PreparedSource.prepare(@source)
|
|
240
|
+
@bundle_dir = @source_dir = prepared.dir
|
|
241
|
+
@source_owned = prepared.owned
|
|
242
|
+
@source_commit = prepared.commit
|
|
150
243
|
@manifest = Manifest.read(dir: @bundle_dir)
|
|
151
244
|
rescue SourceNormalizer::UnknownSourceError => e
|
|
152
245
|
raise InstallError, "Source normalization failed: #{e.message}"
|
|
@@ -167,7 +260,8 @@ module Samagotchi
|
|
|
167
260
|
[scope, dir]
|
|
168
261
|
end
|
|
169
262
|
|
|
170
|
-
# Copies the bundle's
|
|
263
|
+
# Copies the bundle's memories (MemoryFiles: the files: list, else
|
|
264
|
+
# every *.md but README and the like) into the target dir and refreshes
|
|
171
265
|
# their index lines: an upgrade merges (merge_memory), a dry run only
|
|
172
266
|
# classifies (preview_memory), a plain install writes the new ones.
|
|
173
267
|
# Sets @bundle_md (every file the bundle ships) and @owned: the bundle
|
|
@@ -178,8 +272,10 @@ module Samagotchi
|
|
|
178
272
|
owned_before = @existing ? @existing.files.keys : []
|
|
179
273
|
@bundle_md = []
|
|
180
274
|
@owned = []
|
|
181
|
-
|
|
182
|
-
|
|
275
|
+
warn_unshipped_files
|
|
276
|
+
warn_symlinked_files
|
|
277
|
+
memory_files.each do |file_key|
|
|
278
|
+
file_path = File.join(@bundle_dir, file_key)
|
|
183
279
|
@bundle_md << file_key
|
|
184
280
|
note_overlay(file_key, @target_dir)
|
|
185
281
|
target_path = File.join(@target_dir, file_key)
|
|
@@ -206,6 +302,22 @@ module Samagotchi
|
|
|
206
302
|
end
|
|
207
303
|
end
|
|
208
304
|
|
|
305
|
+
# The bundle's memories' basenames (MemoryFiles.select), once.
|
|
306
|
+
def memory_files
|
|
307
|
+
@memory_files ||= MemoryFiles.select(@bundle_dir, @manifest)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# A files: entry with no such *.md in the bundle installs nothing.
|
|
311
|
+
def warn_unshipped_files
|
|
312
|
+
missing = MemoryFiles.unshipped(@bundle_dir, @manifest)
|
|
313
|
+
@warnings << "files: names #{missing.join(", ")}, which the bundle doesn't ship (not installed)" unless missing.empty?
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# A memory that is a symlink isn't installed (MemoryFiles.symlinks).
|
|
317
|
+
def warn_symlinked_files
|
|
318
|
+
MemoryFiles.symlinks(@bundle_dir, @manifest).each { |name| @warnings << "#{name} is a symlink (not installed)" }
|
|
319
|
+
end
|
|
320
|
+
|
|
209
321
|
# An upgrade's 3-way merge of one file (Merger.classify against the
|
|
210
322
|
# base the last install recorded); a conflict leaves the local file
|
|
211
323
|
# and is recorded in @conflicts for the agent step.
|
|
@@ -342,13 +454,32 @@ module Samagotchi
|
|
|
342
454
|
# for a local edit: keep the installed base, so the next upgrade still
|
|
343
455
|
# sees the edit (not a fast-forward over it).
|
|
344
456
|
def install_provenance_files
|
|
345
|
-
@owned.each_with_object({}) do |file_key,
|
|
457
|
+
files = @owned.each_with_object({}) do |file_key, map|
|
|
346
458
|
target_path = File.join(@target_dir, file_key)
|
|
347
459
|
next unless File.exist?(target_path)
|
|
348
460
|
|
|
349
461
|
base_path = @provenance.base_path(file_key)
|
|
350
462
|
kept = @existing && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
|
|
351
|
-
|
|
463
|
+
map[file_key] = kept ? base_path : target_path
|
|
464
|
+
end
|
|
465
|
+
files.merge(dropped_owned_files)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# file key => its base, for each file an earlier install owned that
|
|
469
|
+
# this version doesn't ship and that is still on disk. A plain
|
|
470
|
+
# install leaves such a file where it is, and keeps its record, so the
|
|
471
|
+
# upgrade the summary points at still moves it to the trash
|
|
472
|
+
# (prune_dropped_files); dropping the record orphaned it as a memory.
|
|
473
|
+
def dropped_owned_files
|
|
474
|
+
return {} unless @existing
|
|
475
|
+
|
|
476
|
+
@existing.files.each_key.with_object({}) do |key, files|
|
|
477
|
+
file_key = key.to_s
|
|
478
|
+
next if @bundle_md.include?(file_key)
|
|
479
|
+
next unless File.exist?(File.join(@target_dir, file_key))
|
|
480
|
+
|
|
481
|
+
base_path = @provenance.base_path(file_key)
|
|
482
|
+
files[file_key] = base_path if File.exist?(base_path)
|
|
352
483
|
end
|
|
353
484
|
end
|
|
354
485
|
|
|
@@ -431,7 +562,7 @@ module Samagotchi
|
|
|
431
562
|
# An overlay-looking name with no base anywhere is an overlay too,
|
|
432
563
|
# with a warning (a dry run warns as well).
|
|
433
564
|
def note_overlay(file_key, target_dir)
|
|
434
|
-
@bundle_files ||=
|
|
565
|
+
@bundle_files ||= memory_files
|
|
435
566
|
return unless ModelOverlay.bundle_overlay?(file_key, bundle_files: @bundle_files, target_dir: target_dir)
|
|
436
567
|
|
|
437
568
|
@overlays << file_key
|
|
@@ -64,8 +64,13 @@ module Samagotchi
|
|
|
64
64
|
|
|
65
65
|
def initialize(path:)
|
|
66
66
|
@path = path
|
|
67
|
-
raw =
|
|
67
|
+
raw = load_mapping(path)
|
|
68
68
|
@name = require_str(raw, "name")
|
|
69
|
+
# The name is a directory under .bundles/: never a path.
|
|
70
|
+
if @name.match?(%r{[/\\\0]}) || %w[. ..].include?(@name)
|
|
71
|
+
raise ValidationError, "name: #{@name.inspect} is not a bundle name (no path separators, not . or ..)"
|
|
72
|
+
end
|
|
73
|
+
|
|
69
74
|
@version = require_str(raw, "version")
|
|
70
75
|
@scope = normalize_scope(raw["scope"])
|
|
71
76
|
@description = (raw["description"] || "").to_s
|
|
@@ -188,6 +193,20 @@ module Samagotchi
|
|
|
188
193
|
|
|
189
194
|
private
|
|
190
195
|
|
|
196
|
+
# The manifest.yml's top-level mapping ({} for an empty file); YAML that
|
|
197
|
+
# doesn't parse, or isn't a mapping, raises ValidationError, as every
|
|
198
|
+
# other manifest problem does.
|
|
199
|
+
def load_mapping(path)
|
|
200
|
+
raw = YAML.load_file(path) || {}
|
|
201
|
+
raise ValidationError, "manifest.yml is not a YAML mapping in #{File.dirname(path)}" unless raw.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
raw
|
|
204
|
+
rescue Psych::SyntaxError => e
|
|
205
|
+
raise ValidationError, "manifest.yml is not valid YAML in #{File.dirname(path)}: line #{e.line}, column #{e.column}: #{e.problem}"
|
|
206
|
+
rescue Psych::Exception => e
|
|
207
|
+
raise ValidationError, "manifest.yml is not valid YAML in #{File.dirname(path)}: #{e.message}"
|
|
208
|
+
end
|
|
209
|
+
|
|
191
210
|
def require_str(hash, key)
|
|
192
211
|
val = hash[key]
|
|
193
212
|
return val if val.is_a?(String) && !val.empty?
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module MemoryBundle
|
|
5
|
+
# Which of a bundle dir's top-level *.md files are its memories (the
|
|
6
|
+
# ones an install copies into the scope dir, docs/memory.md):
|
|
7
|
+
# - a manifest whose files: lists any: exactly those (that exist);
|
|
8
|
+
# - else (no manifest, no files: key, or files: {}): every *.md but a
|
|
9
|
+
# repo's meta files (README, CHANGELOG, LICENSE, ...), so a bundle
|
|
10
|
+
# kept in its own git repo doesn't install its README as a memory.
|
|
11
|
+
# A listed file is installed whatever its name: `chi bundle build` of
|
|
12
|
+
# a user's own security.md lists it, and it is a memory.
|
|
13
|
+
# files: {} means "no list" rather than "no memories": shipped bundles
|
|
14
|
+
# with nothing to install write it (mcp), and an external bundle that
|
|
15
|
+
# left the map empty installed its *.md before this rule.
|
|
16
|
+
# A symlink is never a memory (.symlinks names the ones skipped): a
|
|
17
|
+
# tar, zip or git repo keeps links, and copying through one would put
|
|
18
|
+
# any file the user can read (~/.ssh/..., an API key) into a memory.
|
|
19
|
+
module MemoryFiles
|
|
20
|
+
# A repo's meta files, by name without extension (case-insensitive).
|
|
21
|
+
REPO_META = %w[readme changelog license licence contributing code_of_conduct security].freeze
|
|
22
|
+
META_EXTENSIONS = %w[.md .markdown .txt].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @return [Array<String>] the memories' basenames, sorted
|
|
27
|
+
def select(bundle_dir, manifest)
|
|
28
|
+
candidates(bundle_dir, manifest).reject { |name| File.symlink?(File.join(bundle_dir, name)) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The memories .select leaves out because they are symlinks, sorted.
|
|
32
|
+
def symlinks(bundle_dir, manifest)
|
|
33
|
+
candidates(bundle_dir, manifest).select { |name| File.symlink?(File.join(bundle_dir, name)) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The files: entries that name no top-level *.md of the bundle (a
|
|
37
|
+
# symlink is in .symlinks, not here).
|
|
38
|
+
def unshipped(bundle_dir, manifest)
|
|
39
|
+
listed = manifest&.files&.keys || []
|
|
40
|
+
listed.reject do |name|
|
|
41
|
+
path = File.join(bundle_dir, name)
|
|
42
|
+
name.end_with?(".md") && !name.include?("/") && (File.symlink?(path) || File.file?(path))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The top-level *.md files the rules above pick, symlinks included.
|
|
47
|
+
def candidates(bundle_dir, manifest)
|
|
48
|
+
present = Dir.glob(File.join(bundle_dir, "*.md")).map { |f| File.basename(f) }.sort
|
|
49
|
+
listed = manifest&.files&.keys
|
|
50
|
+
return present & listed if listed && !listed.empty?
|
|
51
|
+
|
|
52
|
+
present.reject { |name| repo_meta?(name) }
|
|
53
|
+
end
|
|
54
|
+
private_class_method :candidates
|
|
55
|
+
|
|
56
|
+
# Whether +name+ is a repo's meta file: README, README.md,
|
|
57
|
+
# readme.markdown, LICENSE.txt, ...
|
|
58
|
+
def repo_meta?(name)
|
|
59
|
+
base = name.downcase
|
|
60
|
+
ext = META_EXTENSIONS.find { |e| base.end_with?(e) }
|
|
61
|
+
REPO_META.include?(ext ? base.delete_suffix(ext) : base)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -109,7 +109,7 @@ module Samagotchi
|
|
|
109
109
|
return result.skipped[member] = failure if failure
|
|
110
110
|
return result.installed << member if dry_run
|
|
111
111
|
|
|
112
|
-
Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true).run
|
|
112
|
+
Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true, shipped_dir: shipped_dir).run
|
|
113
113
|
result.installed << member
|
|
114
114
|
rescue StandardError => e
|
|
115
115
|
result.failed[member] = e.message.lines.first.to_s.strip
|
|
@@ -65,7 +65,8 @@ module Samagotchi
|
|
|
65
65
|
next row unless row.status == :would_update
|
|
66
66
|
next apply_profile(row) if row.adds
|
|
67
67
|
|
|
68
|
-
installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true
|
|
68
|
+
installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true,
|
|
69
|
+
shipped_dir: File.dirname(row.source_dir))
|
|
69
70
|
installer.run
|
|
70
71
|
row.dup.tap do |r|
|
|
71
72
|
r.status = :updated
|
|
@@ -50,6 +50,10 @@ module Samagotchi
|
|
|
50
50
|
return nil unless name.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
|
|
51
51
|
return nil if File.exist?(File.join(cwd, name))
|
|
52
52
|
|
|
53
|
+
# Dir.children, not File.file? alone: the name in its exact case
|
|
54
|
+
# (a case-insensitive file system finds btw's dir for BTW).
|
|
55
|
+
return nil unless File.directory?(SHIPPED_DIR) && Dir.children(SHIPPED_DIR).include?(name)
|
|
56
|
+
|
|
53
57
|
dir = File.join(SHIPPED_DIR, name)
|
|
54
58
|
File.file?(File.join(dir, "manifest.yml")) ? dir : nil
|
|
55
59
|
end
|
|
@@ -128,6 +132,9 @@ module Samagotchi
|
|
|
128
132
|
# Copies the contents of `src_dir` into a new permanent directory, stripping
|
|
129
133
|
# one level of nesting if there's a single top-level entry.
|
|
130
134
|
# The returned path survives after `src_dir` is cleaned up.
|
|
135
|
+
# A symlink stays a symlink, at the top level too (cp_r dereferences
|
|
136
|
+
# its root by default): the Installer then skips it (MemoryFiles,
|
|
137
|
+
# AssetInstaller#linked?) instead of installing what it points at.
|
|
131
138
|
def self.clean_copy_of(src_dir)
|
|
132
139
|
contents = Dir.entries(src_dir).reject { |e| e.start_with?(".") }
|
|
133
140
|
# Zip-slip check: reject entries with ".." path components.
|
|
@@ -139,21 +146,77 @@ module Samagotchi
|
|
|
139
146
|
|
|
140
147
|
if contents.size == 1
|
|
141
148
|
entry_path = File.join(src_dir, contents.first)
|
|
142
|
-
if File.directory?(entry_path) && !Dir.empty?(entry_path)
|
|
149
|
+
if File.directory?(entry_path) && !File.symlink?(entry_path) && !Dir.empty?(entry_path)
|
|
143
150
|
dst = Dir.mktmpdir("samagotchi-bundle-")
|
|
144
|
-
FileUtils.cp_r("#{entry_path}/.", dst)
|
|
151
|
+
FileUtils.cp_r("#{entry_path}/.", dst, dereference_root: false)
|
|
145
152
|
return [dst, true]
|
|
146
153
|
end
|
|
147
154
|
end
|
|
148
155
|
|
|
149
156
|
dst = Dir.mktmpdir("samagotchi-bundle-")
|
|
150
157
|
contents.each do |entry|
|
|
151
|
-
FileUtils.cp_r(File.join(src_dir, entry), dst)
|
|
158
|
+
FileUtils.cp_r(File.join(src_dir, entry), dst, dereference_root: false)
|
|
152
159
|
end
|
|
153
160
|
[dst, true]
|
|
154
161
|
end
|
|
155
162
|
|
|
156
163
|
private_class_method :normalize_zip, :normalize_tar, :clean_copy_of
|
|
157
164
|
end
|
|
165
|
+
|
|
166
|
+
# What a bundle's source is, for telling whether two installs of one
|
|
167
|
+
# name come from the same place (Installer: a bundle from elsewhere
|
|
168
|
+
# doesn't silently replace an installed one). key is
|
|
169
|
+
# shipped:<dir> a shipped dir (SHIPPED_SOURCE), in any gem or checkout,
|
|
170
|
+
# so `chi update` and a newer chi upgrade it
|
|
171
|
+
# git:<url> a git URL without its #ref, a trailing / or .git
|
|
172
|
+
# path:<path> an absolute path, symlinks resolved when it exists
|
|
173
|
+
# .of is nil for a source it can't place (empty, or a relative path an
|
|
174
|
+
# older record may hold): unknown never refuses.
|
|
175
|
+
SourceIdentity = Data.define(:key) do
|
|
176
|
+
def self.of(source)
|
|
177
|
+
s = source.to_s.strip
|
|
178
|
+
return nil if s.empty?
|
|
179
|
+
|
|
180
|
+
if SourceNormalizer.git_url?(s)
|
|
181
|
+
url = SourceNormalizer.strip_git_ref(s).first.sub(%r{/+\z}, "").delete_suffix(".git")
|
|
182
|
+
new(key: "git:#{url}")
|
|
183
|
+
elsif s.match?(SourceNormalizer::SHIPPED_SOURCE)
|
|
184
|
+
new(key: "shipped:#{File.basename(s.chomp("/"))}")
|
|
185
|
+
elsif s.start_with?("/")
|
|
186
|
+
new(key: "path:#{real(s)}")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# +source+ as a path:<realpath>, whatever it looks like.
|
|
191
|
+
def self.path(source) = new(key: "path:#{real(source.to_s)}")
|
|
192
|
+
|
|
193
|
+
def self.real(path)
|
|
194
|
+
File.realpath(path)
|
|
195
|
+
rescue SystemCallError
|
|
196
|
+
File.expand_path(path)
|
|
197
|
+
end
|
|
198
|
+
private_class_method :real
|
|
199
|
+
|
|
200
|
+
def shipped? = key.start_with?("shipped:")
|
|
201
|
+
|
|
202
|
+
def to_s = key
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A source normalized once to a local dir (SourceNormalizer.normalize):
|
|
206
|
+
# owned when it is a git clone or a zip/tar extract, which #cleanup
|
|
207
|
+
# removes; commit is a git clone's HEAD (nil otherwise). BundleCommand
|
|
208
|
+
# reads the bundle's name from it and the Installer installs from it, so
|
|
209
|
+
# a source is cloned or extracted once per command.
|
|
210
|
+
PreparedSource = Data.define(:dir, :owned, :commit) do
|
|
211
|
+
# @raise [SourceNormalizer::UnknownSourceError]
|
|
212
|
+
def self.prepare(source)
|
|
213
|
+
dir, owned, commit = SourceNormalizer.normalize(source)
|
|
214
|
+
new(dir: dir, owned: owned, commit: commit)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def cleanup
|
|
218
|
+
SourceNormalizer.cleanup(dir) if owned
|
|
219
|
+
end
|
|
220
|
+
end
|
|
158
221
|
end
|
|
159
222
|
end
|
|
@@ -141,7 +141,8 @@ module Samagotchi
|
|
|
141
141
|
name: BUNDLE_NAME,
|
|
142
142
|
scope: SCOPE,
|
|
143
143
|
force: false,
|
|
144
|
-
strict: true
|
|
144
|
+
strict: true,
|
|
145
|
+
shipped_dir: File.dirname(GEM_BUNDLE_DIR)
|
|
145
146
|
)
|
|
146
147
|
installer.run
|
|
147
148
|
result(:installed, to: to, warnings: installer.warnings)
|
|
@@ -182,7 +183,8 @@ module Samagotchi
|
|
|
182
183
|
end
|
|
183
184
|
|
|
184
185
|
def upgrade_installer
|
|
185
|
-
Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true
|
|
186
|
+
Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
|
|
187
|
+
shipped_dir: File.dirname(GEM_BUNDLE_DIR))
|
|
186
188
|
end
|
|
187
189
|
private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present,
|
|
188
190
|
:upgrade_installer, :result, :conflicting
|
|
@@ -37,9 +37,10 @@ module Samagotchi
|
|
|
37
37
|
# USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
|
|
38
38
|
# the cached reads and the cache writes (OpenRouter keeps that for
|
|
39
39
|
# Anthropic models), and reasoning is inside +completion_tokens+.
|
|
40
|
-
# llama.cpp's native
|
|
41
|
-
#
|
|
42
|
-
#
|
|
40
|
+
# llama.cpp's native stream ends with tokens_evaluated, the whole prompt
|
|
41
|
+
# (SessionMetrics keeps the largest count, so not timings.prompt_n,
|
|
42
|
+
# which leaves cache_n out); the uncached part is clamped at 0 for a
|
|
43
|
+
# report that has prompt_n alone.
|
|
43
44
|
def cost(prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
|
|
44
45
|
uncached = (prompt_tokens - cached_tokens - cache_write_tokens).clamp(0..)
|
|
45
46
|
((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
|
data/lib/samagotchi/paths.rb
CHANGED
|
@@ -28,7 +28,21 @@ module Samagotchi
|
|
|
28
28
|
# @return [String] $XDG_CONFIG_HOME (absolute), else ~/.config
|
|
29
29
|
def config_home(env: ENV)
|
|
30
30
|
xdg = env.fetch("XDG_CONFIG_HOME", "").to_s.strip
|
|
31
|
-
xdg.empty? ? File.
|
|
31
|
+
xdg.empty? ? File.join(Dir.home, ".config") : File.absolute_path(xdg)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The set XDG dirs, made absolute. +set+ is :nonblank (unset or blank
|
|
35
|
+
# values omitted) or :key (present keys included, even when blank).
|
|
36
|
+
# @return [Hash{String => String}]
|
|
37
|
+
def absolute_xdg(env: ENV, set: :nonblank)
|
|
38
|
+
included = case set
|
|
39
|
+
when :nonblank then ->(key) { !env.fetch(key, "").to_s.strip.empty? }
|
|
40
|
+
when :key then ->(key) { env.key?(key) }
|
|
41
|
+
else raise ArgumentError, "set must be :nonblank or :key"
|
|
42
|
+
end
|
|
43
|
+
{ "XDG_STATE_HOME" => :state_home, "XDG_CONFIG_HOME" => :config_home }.each_with_object({}) do |(key, home), dirs|
|
|
44
|
+
dirs[key] = public_send(home, env: env) if included.call(key)
|
|
45
|
+
end
|
|
32
46
|
end
|
|
33
47
|
|
|
34
48
|
# +path+ as chi shows it: ~ for the home folder (~/a for one in it),
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -91,7 +91,7 @@ module Samagotchi
|
|
|
91
91
|
# one): see #delegate?.
|
|
92
92
|
attr_writer :delegate
|
|
93
93
|
# The session this one continues (SessionManager.continue_session): the
|
|
94
|
-
# previous link of a chain, "the next
|
|
94
|
+
# previous link of a chain, "the next run of the same routine", else
|
|
95
95
|
# nil. Not a parent: archiving one link leaves the next alone. The chain
|
|
96
96
|
# itself is derived (SessionChain), never stored.
|
|
97
97
|
attr_accessor :continues
|