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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +21 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +14 -3
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +48 -1
  13. data/docs/plugins.md +2 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +7 -5
  16. data/lib/samagotchi/bundle_command.rb +52 -39
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  19. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/desktop/macos.rb +1 -2
  23. data/lib/samagotchi/effective_model.rb +5 -0
  24. data/lib/samagotchi/engine.rb +84 -4
  25. data/lib/samagotchi/kernel_loop.rb +13 -10
  26. data/lib/samagotchi/llm_context_override.rb +8 -6
  27. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  28. data/lib/samagotchi/memory_bundle/builder.rb +11 -14
  29. data/lib/samagotchi/memory_bundle/installer.rb +140 -9
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
  32. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  33. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  34. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  36. data/lib/samagotchi/model_price.rb +4 -3
  37. data/lib/samagotchi/paths.rb +15 -1
  38. data/lib/samagotchi/session.rb +1 -1
  39. data/lib/samagotchi/session_chain.rb +2 -8
  40. data/lib/samagotchi/session_commands.rb +2 -0
  41. data/lib/samagotchi/session_manager.rb +1 -2
  42. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  43. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  44. data/lib/samagotchi/session_metrics.rb +118 -83
  45. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  46. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  47. data/lib/samagotchi/toolkit.rb +9 -8
  48. data/lib/samagotchi/toolkit_command.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +1 -5
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +3 -0
  52. data/lib/samagotchi/web/public/app.js +4 -3
  53. data/lib/samagotchi/web/public/chains.js +42 -21
  54. data/lib/samagotchi/web/public/composer.js +9 -5
  55. data/lib/samagotchi/web/public/ctx.js +73 -11
  56. data/lib/samagotchi/web/public/index.html +32 -35
  57. data/lib/samagotchi/web/public/info_bar.js +4 -4
  58. data/lib/samagotchi/web/public/info_bar_model.js +18 -8
  59. data/lib/samagotchi/web/public/lists_view.js +2 -2
  60. data/lib/samagotchi/web/public/question_cards.js +1 -1
  61. data/lib/samagotchi/web/public/session_state.js +7 -0
  62. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  63. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  64. data/lib/samagotchi/web/public/timing.js +11 -2
  65. 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.each_key do |basename|
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
- next if File.file?(File.join(@bundle_dir, "scripts", file))
247
-
248
- raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
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(staging) do
403
- result = system("zip", "-r", out_path, ".")
404
- raise BuildError, "zip failed for #{out_path} (is zip installed?)" unless result && File.exist?(out_path)
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
- Dir.chdir(staging) do
416
- cmd = case format
417
- when :tar_gz, :tgz then ["tar", "-czf", out_path, "."]
418
- when :tar then ["tar", "-cf", out_path, "."]
419
- end
420
- result = system(*cmd)
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
- def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false)
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
- @bundle_dir, @source_owned, @source_commit = SourceNormalizer.normalize(@source)
149
- @source_dir = @bundle_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 *.md files into the target dir and refreshes
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
- Dir.glob(File.join(@bundle_dir, "*.md")).each do |file_path|
182
- file_key = File.basename(file_path)
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, files|
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
- files[file_key] = kept ? base_path : target_path
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 ||= Dir.glob(File.join(@bundle_dir, "*.md")).map { |f| File.basename(f) }
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 = YAML.load_file(path) || {}
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 timings.prompt_n leaves its cache_n out, so the
41
- # uncached part is clamped at 0: an undercount, harmless because priced
42
- # models are remote.
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) +
@@ -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.expand_path("~/.config") : File.absolute_path(xdg)
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),
@@ -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 day of the same routine", else
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