samagotchi 0.18.0 → 0.19.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 +119 -1
- data/README.md +4 -3
- data/bin/chi +5 -2
- data/docs/architecture.md +15 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +8 -4
- data/docs/guardrails.md +8 -0
- data/docs/memory.md +4 -0
- data/docs/sessions.md +6 -4
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +2 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/client.rb +58 -10
- data/lib/samagotchi/engine.rb +35 -11
- data/lib/samagotchi/guardrail_wiring.rb +64 -5
- data/lib/samagotchi/guardrails/approval.rb +10 -4
- data/lib/samagotchi/guardrails/approvals.rb +60 -12
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/http.rb +9 -3
- data/lib/samagotchi/memory_bundle/builder.rb +24 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +33 -0
- data/lib/samagotchi/memory_bundle/status.rb +5 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +6 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/served_model.rb +14 -4
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/terminal_ui.rb +65 -16
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +97 -19
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +64 -11
- data/lib/samagotchi/web/public/app.js +102 -29
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +4 -2
- data/lib/samagotchi/web/public/index.html +11 -0
- data/lib/samagotchi/web/public/question_card.js +12 -0
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/turn_events.js +107 -3
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +5 -0
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +4 -1
|
@@ -56,7 +56,7 @@ module Samagotchi
|
|
|
56
56
|
@jobs.each do |job|
|
|
57
57
|
job.tick
|
|
58
58
|
rescue StandardError => e
|
|
59
|
-
Log.
|
|
59
|
+
Log.exception(:idle, "tick_failed", e, echo: "[IdleScheduler] #{job.class} tick failed: #{e.class}: #{e.message}", job: job.class.name)
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "installed_gem"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# The newest chi on this machine, which a long-lived process (chi web, an
|
|
8
|
+
# attaching TUI) compares with its own VERSION to say "chi X is installed;
|
|
9
|
+
# restart". Run from an installed gem: the highest samagotchi-<v>.gemspec
|
|
10
|
+
# in RubyGems' specification dirs, matching the `>= 0.a` a new worker
|
|
11
|
+
# activates (prereleases count). Run from a source checkout: the VERSION
|
|
12
|
+
# in lib/samagotchi/version.rb on disk now (a `git pull` moves it).
|
|
13
|
+
#
|
|
14
|
+
# Cheap enough to poll: each dir is listed again only when its mtime
|
|
15
|
+
# changed, version.rb is re-read only when its mtime did, and nothing
|
|
16
|
+
# calls Gem::Specification.reset (that would disturb the running process).
|
|
17
|
+
#
|
|
18
|
+
# SAMAGOTCHI_INSTALLED_VERSION=<v> (hidden, for smokes and e2e) answers
|
|
19
|
+
# <v> instead.
|
|
20
|
+
class InstalledVersions
|
|
21
|
+
ENV_KEY = "SAMAGOTCHI_INSTALLED_VERSION"
|
|
22
|
+
GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
|
|
23
|
+
VERSION_LINE = /^\s*VERSION\s*=\s*["']([^"']+)["']/
|
|
24
|
+
REQUIREMENT = Gem::Requirement.new(">= 0.a")
|
|
25
|
+
|
|
26
|
+
# @param gem_spec [Gem::Specification, nil] the installed gem this code
|
|
27
|
+
# runs from (nil: a source checkout)
|
|
28
|
+
# @param dirs [#call] → the specification dirs to list
|
|
29
|
+
# @param version_file [String] a checkout's version.rb
|
|
30
|
+
def initialize(env: ENV, gem_spec: InstalledGem.spec, dirs: -> { Gem::Specification.dirs },
|
|
31
|
+
version_file: File.join(InstalledGem::ROOT, "lib", "samagotchi", "version.rb"))
|
|
32
|
+
@env = env
|
|
33
|
+
@gem_spec = gem_spec
|
|
34
|
+
@dirs = dirs
|
|
35
|
+
@version_file = version_file
|
|
36
|
+
@dir_cache = {}
|
|
37
|
+
@file_cache = nil
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String, nil] the newest installed version, or nil when none
|
|
42
|
+
# could be found out
|
|
43
|
+
def newest
|
|
44
|
+
seam = @env[ENV_KEY].to_s
|
|
45
|
+
return seam unless seam.empty?
|
|
46
|
+
|
|
47
|
+
@mutex.synchronize { @gem_spec ? newest_gem : checkout_version }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether +installed+ is a newer version than +running+.
|
|
51
|
+
def self.newer?(installed, running = VERSION)
|
|
52
|
+
return false if installed.to_s.empty? || !Gem::Version.correct?(installed) || !Gem::Version.correct?(running)
|
|
53
|
+
|
|
54
|
+
Gem::Version.new(installed) > Gem::Version.new(running)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def newest_gem
|
|
60
|
+
@dirs.call.filter_map { |dir| dir_newest(dir) }.max&.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dir_newest(dir)
|
|
64
|
+
mtime = File.mtime(dir)
|
|
65
|
+
cached = @dir_cache[dir]
|
|
66
|
+
return cached[1] if cached && cached[0] == mtime
|
|
67
|
+
|
|
68
|
+
best = Dir.children(dir).filter_map { |name| GEMSPEC.match(name)&.[](1) }
|
|
69
|
+
.select { |v| Gem::Version.correct?(v) }
|
|
70
|
+
.map { |v| Gem::Version.new(v) }
|
|
71
|
+
.select { |v| REQUIREMENT.satisfied_by?(v) }.max
|
|
72
|
+
@dir_cache[dir] = [mtime, best]
|
|
73
|
+
best
|
|
74
|
+
rescue SystemCallError
|
|
75
|
+
@dir_cache.delete(dir)
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def checkout_version
|
|
80
|
+
mtime = File.mtime(@version_file)
|
|
81
|
+
return @file_cache[1] if @file_cache && @file_cache[0] == mtime
|
|
82
|
+
|
|
83
|
+
version = File.read(@version_file)[VERSION_LINE, 1]
|
|
84
|
+
version = nil unless version && Gem::Version.correct?(version)
|
|
85
|
+
@file_cache = [mtime, version]
|
|
86
|
+
version
|
|
87
|
+
rescue SystemCallError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -13,8 +13,9 @@ module Samagotchi
|
|
|
13
13
|
module LiveVersions
|
|
14
14
|
WEB_TIMEOUT = 0.5
|
|
15
15
|
|
|
16
|
-
# version is nil for a sidecar written before sidecars carried one
|
|
17
|
-
|
|
16
|
+
# version is nil for a sidecar written before sidecars carried one;
|
|
17
|
+
# restart: the worker takes `chi sessions restart` (its "restart" feature).
|
|
18
|
+
Worker = Struct.new(:session_id, :version, :restart, keyword_init: true)
|
|
18
19
|
|
|
19
20
|
module_function
|
|
20
21
|
|
|
@@ -25,7 +26,8 @@ module Samagotchi
|
|
|
25
26
|
sidecar = WorkerSidecar.live(session_dir, unlink: false)
|
|
26
27
|
next unless sidecar
|
|
27
28
|
|
|
28
|
-
Worker.new(session_id: File.basename(session_dir), version: sidecar.version
|
|
29
|
+
Worker.new(session_id: File.basename(session_dir), version: sidecar.version,
|
|
30
|
+
restart: sidecar.features.include?("restart"))
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -168,7 +168,7 @@ module Samagotchi
|
|
|
168
168
|
|
|
169
169
|
# Send +request+ and return the response with its body read.
|
|
170
170
|
# @param retries [Boolean] false: one attempt, network errors raised as
|
|
171
|
-
# is
|
|
171
|
+
# is; either way Net::HTTP's own silent retry is off (see #start)
|
|
172
172
|
# @param check_status [Boolean] false: return an error response instead
|
|
173
173
|
# of raising its ProviderError
|
|
174
174
|
# @raise [RequestCancelled] when +cancel_controller+ cancels (with or
|
|
@@ -178,7 +178,7 @@ module Samagotchi
|
|
|
178
178
|
identify(request)
|
|
179
179
|
current = new_attempt_state(uri, request, log_fields, stream: false)
|
|
180
180
|
attempt = lambda do |state|
|
|
181
|
-
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries:
|
|
181
|
+
start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: 0) do |http|
|
|
182
182
|
state[:http] = http
|
|
183
183
|
http.request(request).tap do |response|
|
|
184
184
|
state[:status] = response.code.to_i
|
|
@@ -210,7 +210,13 @@ module Samagotchi
|
|
|
210
210
|
|
|
211
211
|
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
|
|
212
212
|
options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
|
|
213
|
-
|
|
213
|
+
# Net::HTTP retries an idempotent request once on its own (max_retries
|
|
214
|
+
# defaults to 1) when the connection drops before an answer: chi's
|
|
215
|
+
# RetryPolicy owns retries (and the log line says how many were sent),
|
|
216
|
+
# so every request here gets Net::HTTP's silent one off. Callers may
|
|
217
|
+
# still ask for it explicitly (a fetch whose retries: false passes 0).
|
|
218
|
+
retries = max_retries || 0
|
|
219
|
+
options[:max_retries] = retries
|
|
214
220
|
options[:use_ssl] = true if uri.scheme == "https"
|
|
215
221
|
Net::HTTP.start(uri.host, uri.port, **options, &block)
|
|
216
222
|
end
|
|
@@ -6,6 +6,7 @@ require "tmpdir"
|
|
|
6
6
|
require_relative "manifest"
|
|
7
7
|
require_relative "placeholder"
|
|
8
8
|
require_relative "../memory_paths"
|
|
9
|
+
require_relative "../model_overlay"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module MemoryBundle
|
|
@@ -66,7 +67,7 @@ module Samagotchi
|
|
|
66
67
|
unless missing.empty?
|
|
67
68
|
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(', ')}"
|
|
68
69
|
end
|
|
69
|
-
candidates = allow.map { |k| candidates_by_basename[k] }
|
|
70
|
+
candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
|
|
70
71
|
else
|
|
71
72
|
candidates = leave_out_owned(candidates, resolved_name)
|
|
72
73
|
end
|
|
@@ -302,6 +303,28 @@ module Samagotchi
|
|
|
302
303
|
end
|
|
303
304
|
end
|
|
304
305
|
|
|
306
|
+
# The named files, each base followed by its model overlays
|
|
307
|
+
# (<name>.<key>.md next to it, ModelOverlay.overlay_file?; a plain
|
|
308
|
+
# tips.v2.md with no tips.md isn't one), with a note listing them. An
|
|
309
|
+
# overlay named without its base is kept, with a warning.
|
|
310
|
+
def with_overlays(allow, candidates_by_basename)
|
|
311
|
+
dir = File.dirname(candidates_by_basename.values.first.to_s)
|
|
312
|
+
overlays = candidates_by_basename.keys.select { |k| ModelOverlay.overlay_file?(File.join(dir, k)) }
|
|
313
|
+
allow.flat_map do |key|
|
|
314
|
+
if overlays.include?(key)
|
|
315
|
+
base = ModelOverlay.base_file_for(key)
|
|
316
|
+
unless allow.include?(base)
|
|
317
|
+
@warnings << "#{key} is a model overlay of #{base}, which the bundle leaves out; it loads only where #{base} exists"
|
|
318
|
+
end
|
|
319
|
+
next [key]
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
own = overlays.select { |k| ModelOverlay.base_file_for(k) == key && !allow.include?(k) }.sort
|
|
323
|
+
@warnings << "Included the model overlays of #{key}: #{own.join(", ")}" unless own.empty?
|
|
324
|
+
[key, *own]
|
|
325
|
+
end.uniq
|
|
326
|
+
end
|
|
327
|
+
|
|
305
328
|
def normalize_filter_entry(entry)
|
|
306
329
|
s = entry.to_s.strip
|
|
307
330
|
# Allow with or without .md extension, but normalize to basename with .md
|
|
@@ -29,7 +29,7 @@ module Samagotchi
|
|
|
29
29
|
return false unless scope && File.file?(path)
|
|
30
30
|
|
|
31
31
|
name = File.basename(path, ".md")
|
|
32
|
-
return false if
|
|
32
|
+
return false if ModelOverlay.overlay_file?(path)
|
|
33
33
|
|
|
34
34
|
IndexUpdater.update_index(scope, name, File.size(path), nil)
|
|
35
35
|
rescue StandardError => e
|
|
@@ -55,14 +55,6 @@ module Samagotchi
|
|
|
55
55
|
nil
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def self.overlay?(path, name)
|
|
59
|
-
match = File.basename(path).match(ModelOverlay::OVERLAY_SUFFIX_PATTERN)
|
|
60
|
-
return false unless match
|
|
61
|
-
|
|
62
|
-
base = name.delete_suffix(".#{match[1]}")
|
|
63
|
-
File.file?(File.join(File.dirname(path), "#{base}.md"))
|
|
64
|
-
end
|
|
65
|
-
|
|
66
58
|
def self.real(dir)
|
|
67
59
|
File.realpath(dir)
|
|
68
60
|
rescue SystemCallError
|
|
@@ -12,6 +12,7 @@ require_relative "merger"
|
|
|
12
12
|
require_relative "trash"
|
|
13
13
|
require_relative "../version"
|
|
14
14
|
require_relative "../bundle_needs"
|
|
15
|
+
require_relative "../model_overlay"
|
|
15
16
|
|
|
16
17
|
module Samagotchi
|
|
17
18
|
module MemoryBundle
|
|
@@ -42,6 +43,7 @@ module Samagotchi
|
|
|
42
43
|
@warnings = []
|
|
43
44
|
@placeholder_warnings = []
|
|
44
45
|
@conflicts = {}
|
|
46
|
+
@overlays = []
|
|
45
47
|
@trash = Trash.new(name)
|
|
46
48
|
end
|
|
47
49
|
|
|
@@ -56,6 +58,7 @@ module Samagotchi
|
|
|
56
58
|
manifest = nil
|
|
57
59
|
begin
|
|
58
60
|
normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
|
|
61
|
+
@bundle_dir = normalized_dir
|
|
59
62
|
manifest = Manifest.read(dir: normalized_dir)
|
|
60
63
|
rescue SourceNormalizer::UnknownSourceError => e
|
|
61
64
|
raise InstallError, "Source normalization failed: #{e.message}"
|
|
@@ -89,6 +92,7 @@ module Samagotchi
|
|
|
89
92
|
Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
|
|
90
93
|
file_key = File.basename(file_path)
|
|
91
94
|
all_files_in_bundle << file_key
|
|
95
|
+
note_overlay(file_key, target_dir)
|
|
92
96
|
target_path = File.join(target_dir, file_key)
|
|
93
97
|
|
|
94
98
|
if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
|
|
@@ -550,9 +554,38 @@ module Samagotchi
|
|
|
550
554
|
dest
|
|
551
555
|
end
|
|
552
556
|
|
|
557
|
+
# A model overlay (<name>.<key>.md, docs/memory.md) loads only with
|
|
558
|
+
# its base, under the matching model: it gets no index line (every
|
|
559
|
+
# model would see it as a memory of its own). Its base is looked for
|
|
560
|
+
# in the bundle dir, not the target: Dir.glob sorts tips.<key>.md
|
|
561
|
+
# before tips.md, so on a fresh install the base isn't copied yet.
|
|
562
|
+
# An overlay-looking name with no base anywhere is an overlay too,
|
|
563
|
+
# with a warning (a dry run warns as well).
|
|
564
|
+
def note_overlay(file_key, target_dir)
|
|
565
|
+
@bundle_files ||= Dir.glob(File.join(@bundle_dir, "*.md")).map { |f| File.basename(f) }
|
|
566
|
+
return unless ModelOverlay.bundle_overlay?(file_key, bundle_files: @bundle_files, target_dir: target_dir)
|
|
567
|
+
|
|
568
|
+
@overlays << file_key
|
|
569
|
+
base = ModelOverlay.base_file_for(file_key)
|
|
570
|
+
return if @bundle_files.include?(base)
|
|
571
|
+
|
|
572
|
+
@warnings << if File.file?(File.join(target_dir, base))
|
|
573
|
+
"#{file_key} is taken as a model overlay of your #{base}, which the bundle doesn't ship; " \
|
|
574
|
+
"it gets no index line"
|
|
575
|
+
else
|
|
576
|
+
"#{file_key} is a model overlay with no #{base}; it loads only once #{base} exists"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
553
580
|
# +owned+: the bundle wrote (owns) the file, so its line says
|
|
554
581
|
# "· from <bundle>"; a skipped file's line keeps whatever it had.
|
|
582
|
+
# An overlay gets none: a stale one (an earlier install's) is removed.
|
|
555
583
|
def update_target_index(scope, file_path, file_key, owned: true)
|
|
584
|
+
if @overlays.include?(file_key)
|
|
585
|
+
remove_target_index(scope, file_key) if owned
|
|
586
|
+
return
|
|
587
|
+
end
|
|
588
|
+
|
|
556
589
|
byte_count = File.exist?(file_path) ? File.size(file_path) : 0
|
|
557
590
|
entry_name = file_key.delete_suffix(".md")
|
|
558
591
|
begin
|
|
@@ -6,6 +6,7 @@ require_relative "../version"
|
|
|
6
6
|
require_relative "index_updater"
|
|
7
7
|
require_relative "../memory_paths"
|
|
8
8
|
require_relative "../bundle_needs"
|
|
9
|
+
require_relative "../model_overlay"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module MemoryBundle
|
|
@@ -29,7 +30,9 @@ module Samagotchi
|
|
|
29
30
|
base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
|
|
30
31
|
modified = base_checksum && current_checksum && base_checksum != current_checksum
|
|
31
32
|
missing = !File.exist?(target_path)
|
|
32
|
-
|
|
33
|
+
# A model overlay has no index line (Installer#note_overlay).
|
|
34
|
+
overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
|
|
35
|
+
index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
|
|
33
36
|
details[file_key_str] = {
|
|
34
37
|
conflict: meta.is_a?(Hash) && meta[:conflict] == true,
|
|
35
38
|
stored_checksum: stored_checksum,
|
|
@@ -38,6 +41,7 @@ module Samagotchi
|
|
|
38
41
|
modified: modified,
|
|
39
42
|
missing: missing,
|
|
40
43
|
index_present: index_present,
|
|
44
|
+
overlay: overlay,
|
|
41
45
|
target_path: target_path,
|
|
42
46
|
base_path: base_path
|
|
43
47
|
}
|
|
@@ -6,6 +6,7 @@ require_relative "provenance"
|
|
|
6
6
|
require_relative "index_updater"
|
|
7
7
|
require_relative "merger"
|
|
8
8
|
require_relative "trash"
|
|
9
|
+
require_relative "../model_overlay"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module MemoryBundle
|
|
@@ -75,7 +76,9 @@ module Samagotchi
|
|
|
75
76
|
next unless File.exist?(target_path)
|
|
76
77
|
|
|
77
78
|
@warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
|
|
78
|
-
|
|
79
|
+
unless ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys.map(&:to_s), target_dir: target_dir)
|
|
80
|
+
retag(target_scope, target_path, file_key_str, others.first)
|
|
81
|
+
end
|
|
79
82
|
next
|
|
80
83
|
end
|
|
81
84
|
if File.exist?(target_path)
|
|
@@ -119,7 +122,8 @@ module Samagotchi
|
|
|
119
122
|
|
|
120
123
|
private
|
|
121
124
|
|
|
122
|
-
# A kept shared file's index line names the bundle that still has it
|
|
125
|
+
# A kept shared file's index line names the bundle that still has it
|
|
126
|
+
# (a model overlay has none: the caller skips it).
|
|
123
127
|
def retag(scope, path, file_key, bundle)
|
|
124
128
|
IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
|
|
125
129
|
rescue StandardError => e
|
|
@@ -49,5 +49,44 @@ module Samagotchi
|
|
|
49
49
|
# Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
|
|
50
50
|
# Useful for tooling, tests, and future index suppression.
|
|
51
51
|
OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9\-]+)\.md\z/
|
|
52
|
+
|
|
53
|
+
# The base file name (`tips.md`) an overlay-looking name
|
|
54
|
+
# (`tips.qwen3.md`) would belong to, or nil when it doesn't match the
|
|
55
|
+
# suffix pattern.
|
|
56
|
+
def self.base_file_for(path)
|
|
57
|
+
match = File.basename(path.to_s).match(OVERLAY_SUFFIX_PATTERN)
|
|
58
|
+
return nil unless match
|
|
59
|
+
|
|
60
|
+
stem = File.basename(path.to_s).delete_suffix(match[0])
|
|
61
|
+
stem.empty? ? nil : "#{stem}.md"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether +path+ is a model overlay: its name matches the suffix pattern
|
|
65
|
+
# and its base `<stem>.md` exists in one of +base_dirs+ (default: the
|
|
66
|
+
# file's own dir) or is one of +base_names+. A memory whose name has a
|
|
67
|
+
# dot and a sibling of the same stem reads as an overlay too (a
|
|
68
|
+
# leftover ambiguity).
|
|
69
|
+
def self.overlay_file?(path, base_dirs: [File.dirname(path.to_s)], base_names: [])
|
|
70
|
+
base = base_file_for(path)
|
|
71
|
+
return false unless base
|
|
72
|
+
|
|
73
|
+
base_names.map { |n| File.basename(n.to_s) }.include?(base) ||
|
|
74
|
+
base_dirs.compact.any? { |dir| File.file?(File.join(dir, base)) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# How a bundle's installer, status and uninstaller tell an overlay
|
|
78
|
+
# (they agree, so a line the installer left out isn't "no-index"): the
|
|
79
|
+
# base is one of +bundle_files+ (basenames), or it is nowhere in
|
|
80
|
+
# +target_dir+ (an orphan overlay; it loads once the base exists).
|
|
81
|
+
# A base only in +target_dir+ (an installed memory, maybe the user's
|
|
82
|
+
# own) counts too: the installer warns, so the dot-name ambiguity
|
|
83
|
+
# (notes.v2.md next to a user's notes.md) is visible.
|
|
84
|
+
def self.bundle_overlay?(file_key, bundle_files:, target_dir:)
|
|
85
|
+
base = base_file_for(file_key)
|
|
86
|
+
return false unless base
|
|
87
|
+
|
|
88
|
+
overlay_file?(file_key, base_dirs: [target_dir], base_names: bundle_files) ||
|
|
89
|
+
!File.file?(File.join(target_dir, base))
|
|
90
|
+
end
|
|
52
91
|
end
|
|
53
92
|
end
|
|
@@ -11,6 +11,10 @@ module Samagotchi
|
|
|
11
11
|
# gemma4 — Gemma 4 format
|
|
12
12
|
class ModelProfile
|
|
13
13
|
MODEL_ENV = "SAMAGOTCHI_DEFAULT_MODEL"
|
|
14
|
+
# Set beside MODEL_ENV for a worker spawned by a `chi --model X`: X is
|
|
15
|
+
# that run's default, not one its commands' chi should inherit
|
|
16
|
+
# (SessionManager.spawn_options, Tools::Builtins.parent_env).
|
|
17
|
+
MODEL_FROM_CLI_ENV = "SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI"
|
|
14
18
|
# No model anywhere (--model, default.model in config.yml, the env): a
|
|
15
19
|
# first run before any config. An ArgumentError, as before.
|
|
16
20
|
MissingModel = Class.new(ArgumentError)
|
|
@@ -112,7 +116,7 @@ module Samagotchi
|
|
|
112
116
|
end
|
|
113
117
|
|
|
114
118
|
def self.default
|
|
115
|
-
|
|
119
|
+
named(DEFAULT_NAME)
|
|
116
120
|
end
|
|
117
121
|
|
|
118
122
|
def self.normalize(value)
|
|
@@ -124,7 +128,7 @@ module Samagotchi
|
|
|
124
128
|
when "gemma", "gemma4", "gemma4o"
|
|
125
129
|
gemma4
|
|
126
130
|
else
|
|
127
|
-
|
|
131
|
+
named(DEFAULT_NAME)
|
|
128
132
|
end
|
|
129
133
|
end
|
|
130
134
|
|
|
@@ -45,13 +45,17 @@ module Samagotchi
|
|
|
45
45
|
)
|
|
46
46
|
INDIVIDUAL_RE.freeze
|
|
47
47
|
|
|
48
|
-
# Angle-bracketed literal tokens stripped wholesale (including the thought
|
|
49
|
-
# channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
|
|
50
|
-
# are removed as a unit.
|
|
51
48
|
# Angle-bracketed literal tokens stripped wholesale (including the thought
|
|
52
49
|
# channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
|
|
53
50
|
# are removed as a unit. Pipes are escaped; angle brackets are literal.
|
|
54
|
-
LITERALS_RE = /<\|"
|
|
51
|
+
LITERALS_RE = /<\|"\|>|<end_of_turn>/.freeze
|
|
52
|
+
|
|
53
|
+
# Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
|
|
54
|
+
# `<channel|>` close, or to the end of the text when the close is missing
|
|
55
|
+
# (ToolCallParser::Gemma strips the same pair). Removing only the marker
|
|
56
|
+
# would leave the model's private reasoning in the answer; the channel is
|
|
57
|
+
# the profile's thinking lane, not output.
|
|
58
|
+
GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m.freeze
|
|
55
59
|
|
|
56
60
|
# Qwen prompt-literal placeholders, e.g. </think>.
|
|
57
61
|
PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/.freeze
|
|
@@ -88,6 +92,7 @@ module Samagotchi
|
|
|
88
92
|
def remove_tokens(text)
|
|
89
93
|
text.to_s
|
|
90
94
|
.gsub(QWEN_THINK_RE, '')
|
|
95
|
+
.gsub(GEMMA_THOUGHT_RE, '')
|
|
91
96
|
.gsub(QWEN_TOOL_CALL_RE, '')
|
|
92
97
|
.gsub(LITERALS_RE, '')
|
|
93
98
|
.gsub(INDIVIDUAL_RE, '')
|
|
@@ -63,6 +63,15 @@ module Samagotchi
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Whether a relay is open, or settled recently enough that its child
|
|
67
|
+
# may still ask about it: a restart would drop them.
|
|
68
|
+
def active?
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
prune
|
|
71
|
+
!@relays.empty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
66
75
|
private
|
|
67
76
|
|
|
68
77
|
def prune
|
|
@@ -17,16 +17,26 @@ module Samagotchi
|
|
|
17
17
|
name.is_a?(String) && !name.strip.empty? ? name : nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The separators a provider uses when it decorates a model name: a tag
|
|
21
|
+
# (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
|
|
22
|
+
# owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
|
|
23
|
+
# after the name is a different model (`gpt-4` is not `gpt-4o`).
|
|
24
|
+
EXTENSION_SEPARATORS = [":", "-", "@", "/"].freeze
|
|
25
|
+
|
|
20
26
|
# Whether +served+ is another model than +asked+. Names that are equal
|
|
21
|
-
# (any case) or where one extends the other
|
|
22
|
-
# `:free`, or adding a date) are the
|
|
23
|
-
# differ.
|
|
27
|
+
# (any case), or where one extends the other at a tag/date/owner
|
|
28
|
+
# separator (a provider dropping `:free`, or adding a date), are the
|
|
29
|
+
# same model; unknown names never differ.
|
|
24
30
|
def differs?(asked, served)
|
|
25
31
|
asked = asked.to_s.strip.downcase
|
|
26
32
|
served = served.to_s.strip.downcase
|
|
27
33
|
return false if asked.empty? || served.empty?
|
|
34
|
+
return false if asked == served
|
|
35
|
+
|
|
36
|
+
shorter, longer = asked.length <= served.length ? [asked, served] : [served, asked]
|
|
37
|
+
return true unless longer.start_with?(shorter)
|
|
28
38
|
|
|
29
|
-
!
|
|
39
|
+
!longer[shorter.length..].start_with?(*EXTENSION_SEPARATORS)
|
|
30
40
|
end
|
|
31
41
|
end
|
|
32
42
|
end
|