samagotchi 0.75.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 +47 -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 +20 -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 +34 -0
- data/docs/plugins.md +1 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +3 -1
- 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 +122 -5
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
- 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_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 +1 -1
- 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 +30 -35
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +3 -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 +3 -1
|
@@ -34,8 +34,16 @@ module Samagotchi
|
|
|
34
34
|
|
|
35
35
|
attr_reader :results, :warnings, :placeholder_warnings, :conflicts
|
|
36
36
|
|
|
37
|
-
|
|
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)
|
|
38
44
|
@source = source
|
|
45
|
+
@shipped_dir = shipped_dir
|
|
46
|
+
@prepared = prepared
|
|
39
47
|
@name = name
|
|
40
48
|
@scope = scope&.to_s&.strip&.downcase || ""
|
|
41
49
|
@force = force
|
|
@@ -50,6 +58,10 @@ module Samagotchi
|
|
|
50
58
|
@trash = Trash.new(name)
|
|
51
59
|
end
|
|
52
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
|
+
|
|
53
65
|
# Where an upgrade moved the files the bundle no longer ships (nil:
|
|
54
66
|
# none moved).
|
|
55
67
|
def trash_dir = @trash.dir
|
|
@@ -78,6 +90,7 @@ module Samagotchi
|
|
|
78
90
|
@file_descriptions = @manifest&.file_descriptions || {}
|
|
79
91
|
@provenance = Provenance.new(name: @name)
|
|
80
92
|
@existing = @provenance.record
|
|
93
|
+
check_source
|
|
81
94
|
# A plain install over an installed bundle: summary points at upgrade.
|
|
82
95
|
@reinstall = @existing && !@upgrade && !@force && !@dry_run
|
|
83
96
|
install_memories
|
|
@@ -114,6 +127,7 @@ module Samagotchi
|
|
|
114
127
|
|
|
115
128
|
def summary
|
|
116
129
|
lines = []
|
|
130
|
+
lines << "Source changes: #{@source_change.join(" → ")}" if @source_change
|
|
117
131
|
installed = @results.select { |_, r| r[:status].to_s == "installed" }.keys
|
|
118
132
|
updated = @results.select { |_, r| r[:status].to_s == "updated" }.keys
|
|
119
133
|
kept = @results.select { |_, r| %w[kept kept_pruned].include?(r[:status].to_s) }.keys
|
|
@@ -142,12 +156,90 @@ module Samagotchi
|
|
|
142
156
|
|
|
143
157
|
private
|
|
144
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
|
+
|
|
145
235
|
# The source normalized to a dir (@bundle_dir; an owned one, extracted
|
|
146
236
|
# from git/zip/tar, is cleaned up after #run) and its manifest.yml
|
|
147
237
|
# (@manifest, nil when it has none and the install isn't strict).
|
|
148
238
|
def load_source
|
|
149
|
-
|
|
150
|
-
@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
|
|
151
243
|
@manifest = Manifest.read(dir: @bundle_dir)
|
|
152
244
|
rescue SourceNormalizer::UnknownSourceError => e
|
|
153
245
|
raise InstallError, "Source normalization failed: #{e.message}"
|
|
@@ -181,6 +273,7 @@ module Samagotchi
|
|
|
181
273
|
@bundle_md = []
|
|
182
274
|
@owned = []
|
|
183
275
|
warn_unshipped_files
|
|
276
|
+
warn_symlinked_files
|
|
184
277
|
memory_files.each do |file_key|
|
|
185
278
|
file_path = File.join(@bundle_dir, file_key)
|
|
186
279
|
@bundle_md << file_key
|
|
@@ -220,6 +313,11 @@ module Samagotchi
|
|
|
220
313
|
@warnings << "files: names #{missing.join(", ")}, which the bundle doesn't ship (not installed)" unless missing.empty?
|
|
221
314
|
end
|
|
222
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
|
+
|
|
223
321
|
# An upgrade's 3-way merge of one file (Merger.classify against the
|
|
224
322
|
# base the last install recorded); a conflict leaves the local file
|
|
225
323
|
# and is recorded in @conflicts for the agent step.
|
|
@@ -356,13 +454,32 @@ module Samagotchi
|
|
|
356
454
|
# for a local edit: keep the installed base, so the next upgrade still
|
|
357
455
|
# sees the edit (not a fast-forward over it).
|
|
358
456
|
def install_provenance_files
|
|
359
|
-
@owned.each_with_object({}) do |file_key,
|
|
457
|
+
files = @owned.each_with_object({}) do |file_key, map|
|
|
360
458
|
target_path = File.join(@target_dir, file_key)
|
|
361
459
|
next unless File.exist?(target_path)
|
|
362
460
|
|
|
363
461
|
base_path = @provenance.base_path(file_key)
|
|
364
462
|
kept = @existing && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
|
|
365
|
-
|
|
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)
|
|
366
483
|
end
|
|
367
484
|
end
|
|
368
485
|
|
|
@@ -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?
|
|
@@ -13,6 +13,9 @@ module Samagotchi
|
|
|
13
13
|
# files: {} means "no list" rather than "no memories": shipped bundles
|
|
14
14
|
# with nothing to install write it (mcp), and an external bundle that
|
|
15
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.
|
|
16
19
|
module MemoryFiles
|
|
17
20
|
# A repo's meta files, by name without extension (case-insensitive).
|
|
18
21
|
REPO_META = %w[readme changelog license licence contributing code_of_conduct security].freeze
|
|
@@ -22,18 +25,33 @@ module Samagotchi
|
|
|
22
25
|
|
|
23
26
|
# @return [Array<String>] the memories' basenames, sorted
|
|
24
27
|
def select(bundle_dir, manifest)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return present & listed if listed && !listed.empty?
|
|
28
|
+
candidates(bundle_dir, manifest).reject { |name| File.symlink?(File.join(bundle_dir, name)) }
|
|
29
|
+
end
|
|
28
30
|
|
|
29
|
-
|
|
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)) }
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
# The files: entries that name no top-level *.md of the bundle
|
|
36
|
+
# The files: entries that name no top-level *.md of the bundle (a
|
|
37
|
+
# symlink is in .symlinks, not here).
|
|
33
38
|
def unshipped(bundle_dir, manifest)
|
|
34
39
|
listed = manifest&.files&.keys || []
|
|
35
|
-
listed.reject
|
|
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) }
|
|
36
53
|
end
|
|
54
|
+
private_class_method :candidates
|
|
37
55
|
|
|
38
56
|
# Whether +name+ is a repo's meta file: README, README.md,
|
|
39
57
|
# readme.markdown, LICENSE.txt, ...
|
|
@@ -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),
|
|
@@ -592,6 +592,8 @@ module Samagotchi
|
|
|
592
592
|
@engine.switch_model!(name, persist_default: persist_default)
|
|
593
593
|
@default_model = @engine.default_model_name if persist_default
|
|
594
594
|
persist_session_model
|
|
595
|
+
# The ctx % on the new model's window, before its first turn.
|
|
596
|
+
@engine.announce_switched_context!
|
|
595
597
|
end
|
|
596
598
|
|
|
597
599
|
def persist_session_model
|
|
@@ -244,8 +244,7 @@ module Samagotchi
|
|
|
244
244
|
end
|
|
245
245
|
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
246
246
|
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
247
|
-
child_env
|
|
248
|
-
child_env["XDG_CONFIG_HOME"] = Paths.config_home unless ENV.fetch("XDG_CONFIG_HOME", "").to_s.strip.empty?
|
|
247
|
+
child_env.merge!(Paths.absolute_xdg)
|
|
249
248
|
# The spawner's environment must not reach the worker's guardrails (a
|
|
250
249
|
# parent agent starting or waking a session): Process.spawn unsets a
|
|
251
250
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "totals"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class SessionMetrics
|
|
7
|
+
# A run of consecutive turns on one model (their records' model_ref, the
|
|
8
|
+
# asked host:model): /stats and the web's ctx/cost tooltip split the
|
|
9
|
+
# session's tokens, cost and speed by these, in order, so A -> B -> A is
|
|
10
|
+
# three. Derived from the turn records as they are counted, never saved
|
|
11
|
+
# on their own. A record with no ref (a turn stopped before it asked a
|
|
12
|
+
# model) joins the segment before it; records with none before the first
|
|
13
|
+
# one that names a model (a session older than model_ref) make one
|
|
14
|
+
# leading segment with no ref.
|
|
15
|
+
#
|
|
16
|
+
# +prefill_tokens+ / +prefill_cost_estimate+: what the segment's first
|
|
17
|
+
# turn prefilled on its model after the switch (the records'
|
|
18
|
+
# switch_prefill_*), 0 / nil for none.
|
|
19
|
+
class ModelSegment < Data.define(:model_ref, :started_at, :ended_at, :turns, :totals, :prefill_tokens,
|
|
20
|
+
:prefill_cost_estimate)
|
|
21
|
+
# The most rows a snapshot sends: older ones fold into one.
|
|
22
|
+
CAP = 6
|
|
23
|
+
|
|
24
|
+
# A segment opened by +record+ (its first turn).
|
|
25
|
+
# @param record [Hash] a turn record (symbol keys)
|
|
26
|
+
# @return [ModelSegment]
|
|
27
|
+
def self.open(record)
|
|
28
|
+
ref = record[:model_ref].to_s
|
|
29
|
+
new(model_ref: ref.empty? ? nil : ref, started_at: record[:started_at], ended_at: nil, turns: 0,
|
|
30
|
+
totals: Totals.zero, prefill_tokens: 0, prefill_cost_estimate: nil).add(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The segment a snapshot shows for the running turn when it is on
|
|
34
|
+
# another model than the last segment's: no finished turn yet.
|
|
35
|
+
# @param ref [String]
|
|
36
|
+
# @param started_at [String, nil]
|
|
37
|
+
# @return [ModelSegment]
|
|
38
|
+
def self.empty(ref, started_at)
|
|
39
|
+
new(model_ref: ref, started_at: started_at, ended_at: nil, turns: 0, totals: Totals.zero, prefill_tokens: 0,
|
|
40
|
+
prefill_cost_estimate: nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether +record+ starts a new segment after this one: it names a
|
|
44
|
+
# model, and another one than this segment's.
|
|
45
|
+
def self.splits?(segment, record)
|
|
46
|
+
ref = record[:model_ref].to_s
|
|
47
|
+
segment.nil? || (!ref.empty? && segment.model_ref != ref)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# This segment with one more finished turn record.
|
|
51
|
+
# @return [ModelSegment]
|
|
52
|
+
def add(record)
|
|
53
|
+
tokens = record[:switch_prefill_tokens]
|
|
54
|
+
estimate = record[:switch_prefill_cost_estimate]
|
|
55
|
+
with(ended_at: record[:completed_at] || ended_at, turns: turns + 1, totals: totals.add(record),
|
|
56
|
+
prefill_tokens: prefill_tokens + (tokens.is_a?(Numeric) ? tokens : 0),
|
|
57
|
+
prefill_cost_estimate: estimate.is_a?(Numeric) ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# This segment with the running turn (SessionMetrics::TurnState) in:
|
|
61
|
+
# its finished generations and the prefill it recorded so far; it
|
|
62
|
+
# runs on, so it has no end.
|
|
63
|
+
# @return [ModelSegment]
|
|
64
|
+
def with_running(turn)
|
|
65
|
+
estimate = turn.switch_prefill_cost_estimate
|
|
66
|
+
with(ended_at: nil, turns: turns + 1, totals: totals.with_running(turn),
|
|
67
|
+
prefill_tokens: prefill_tokens + turn.switch_prefill_tokens.to_i,
|
|
68
|
+
prefill_cost_estimate: estimate ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The row a snapshot carries (JSON-ready). +ended_at+ nil: the segment
|
|
72
|
+
# runs now.
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def to_h
|
|
75
|
+
tokens = totals.tokens_block
|
|
76
|
+
{
|
|
77
|
+
model_ref: model_ref, started_at: started_at, ended_at: ended_at, turns: turns,
|
|
78
|
+
**tokens.slice(:prompt_sum, :completion_sum, :cached_sum, :cost_sum, :cost_estimate_sum, :avg_decode_tps,
|
|
79
|
+
:last_decode_tps, :tps_source),
|
|
80
|
+
prefill_tokens: prefill_tokens, prefill_cost_estimate: prefill_cost_estimate
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The rows of +segments+ a snapshot sends: none for fewer than 2 (one
|
|
85
|
+
# model: the plain totals say it all); over CAP, the oldest fold into
|
|
86
|
+
# one row with no ref that says how many it holds (folded_segments).
|
|
87
|
+
# @param segments [Array<ModelSegment>]
|
|
88
|
+
# @return [Array<Hash>]
|
|
89
|
+
def self.rows(segments)
|
|
90
|
+
return [] if segments.size < 2
|
|
91
|
+
return segments.map(&:to_h) if segments.size <= CAP
|
|
92
|
+
|
|
93
|
+
older = segments.first(segments.size - CAP + 1)
|
|
94
|
+
folded = older.drop(1).reduce(older.first.with(model_ref: nil)) do |sum, segment|
|
|
95
|
+
sum.merge(segment)
|
|
96
|
+
end
|
|
97
|
+
[folded.to_h.merge(folded_segments: older.size), *segments.last(CAP - 1).map(&:to_h)]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# This segment and a later one as one row (the folded older ones).
|
|
101
|
+
# @return [ModelSegment]
|
|
102
|
+
def merge(later)
|
|
103
|
+
estimate = [prefill_cost_estimate, later.prefill_cost_estimate].compact
|
|
104
|
+
with(ended_at: later.ended_at, turns: turns + later.turns, totals: totals.merge(later.totals),
|
|
105
|
+
prefill_tokens: prefill_tokens + later.prefill_tokens, prefill_cost_estimate: estimate.empty? ? nil : estimate.sum)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|