samagotchi 0.3.0 → 0.5.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 +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "installer"
|
|
6
|
+
require_relative "listing"
|
|
7
|
+
require_relative "manifest"
|
|
8
|
+
require_relative "merger"
|
|
9
|
+
require_relative "provenance"
|
|
10
|
+
require_relative "source"
|
|
11
|
+
require_relative "status"
|
|
12
|
+
require_relative "system_bundle"
|
|
13
|
+
require_relative "../version"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
module MemoryBundle
|
|
17
|
+
# What `chi update` does to the installed bundles that chi ships: one
|
|
18
|
+
# Row per installed bundle (the system bundle aside: SystemBundle.sync
|
|
19
|
+
# has it). A bundle is updated when it was installed from a shipped dir
|
|
20
|
+
# (its source is …/lib/samagotchi/bundles/<dir>, in a gem or a
|
|
21
|
+
# checkout), chi ships it under the same name, the shipped version is
|
|
22
|
+
# newer and this chi meets its requires_chi. It never installs a
|
|
23
|
+
# bundle that isn't installed and never downgrades.
|
|
24
|
+
#
|
|
25
|
+
# .md files take the Installer's 3-way merge: a conflict keeps the
|
|
26
|
+
# user's file (kept). Hooks, rules and the plugin are replaced; an
|
|
27
|
+
# edited one (which didn't load: its sha no longer matched) is listed
|
|
28
|
+
# in replaced.
|
|
29
|
+
module ShippedUpdate
|
|
30
|
+
# status: :would_update (plan), :updated, :up_to_date, :skipped,
|
|
31
|
+
# :failed. note says why for skipped/failed. kept and replaced are
|
|
32
|
+
# file names; under plan they are what apply would keep or replace.
|
|
33
|
+
Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
SHIPPED_SOURCE = %r{/lib/samagotchi/bundles/[^/]+/?\z}
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Read-only.
|
|
40
|
+
# @return [Array<Row>] by name
|
|
41
|
+
def plan(shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION)
|
|
42
|
+
shipped = Listing.shipped(dir: shipped_dir).to_h { |s| [s.name, s] }
|
|
43
|
+
installed_names.map do |name|
|
|
44
|
+
plan_row(name, shipped[name], shipped_dir, chi_version)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The shipped bundles not installed (what the footer lists).
|
|
49
|
+
# @return [Array<Listing::Shipped>]
|
|
50
|
+
def not_installed(shipped_dir: SourceNormalizer::SHIPPED_DIR)
|
|
51
|
+
names = installed_names
|
|
52
|
+
Listing.shipped(dir: shipped_dir).reject { |s| names.include?(s.name) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Runs the Installer for each :would_update row; the others pass
|
|
56
|
+
# through. A failing bundle doesn't stop the rest.
|
|
57
|
+
# @return [Array<Row>]
|
|
58
|
+
def apply(rows)
|
|
59
|
+
rows.map do |row|
|
|
60
|
+
next row unless row.status == :would_update
|
|
61
|
+
|
|
62
|
+
installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true)
|
|
63
|
+
installer.run
|
|
64
|
+
row.dup.tap do |r|
|
|
65
|
+
r.status = :updated
|
|
66
|
+
r.kept = installer.conflicts.keys
|
|
67
|
+
end
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
row.dup.tap do |r|
|
|
70
|
+
r.status = :failed
|
|
71
|
+
r.note = e.message.lines.first.to_s.strip
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def installed_names
|
|
77
|
+
dir = Provenance.bundles_dir
|
|
78
|
+
return [] unless File.directory?(dir)
|
|
79
|
+
|
|
80
|
+
Dir.children(dir).reject { |e| e.start_with?(".") || e == SystemBundle::BUNDLE_NAME }
|
|
81
|
+
.select { |e| File.file?(File.join(dir, e, "manifest.json")) }.sort
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def plan_row(name, ship, shipped_dir, chi_version)
|
|
85
|
+
row = Row.new(name: name, kept: [], replaced: [])
|
|
86
|
+
data = begin
|
|
87
|
+
Provenance.new(name: name).read
|
|
88
|
+
rescue JSON::ParserError, SystemCallError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
return skip(row, "manifest.json unreadable") unless data.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
row.from = data[:version].to_s
|
|
94
|
+
row.scope = data[:scope].to_s.empty? ? "system" : data[:scope].to_s
|
|
95
|
+
return skip(row, "not from chi") unless ship && data[:source].to_s.match?(SHIPPED_SOURCE)
|
|
96
|
+
|
|
97
|
+
row.to = ship.version.to_s
|
|
98
|
+
return skip(row, "newer than shipped: left") if Listing.newer?(row.from, row.to)
|
|
99
|
+
return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
|
|
100
|
+
|
|
101
|
+
row.source_dir = File.join(shipped_dir, ship.source)
|
|
102
|
+
manifest = Manifest.read(dir: row.source_dir)
|
|
103
|
+
if Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
|
|
104
|
+
return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
|
|
105
|
+
end
|
|
106
|
+
return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
|
|
107
|
+
|
|
108
|
+
row.kept = conflicts(row, data)
|
|
109
|
+
row.replaced = edited_executables(name, data)
|
|
110
|
+
row.status = :would_update
|
|
111
|
+
row
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def skip(row, note, keep_to: false)
|
|
115
|
+
row.to = nil unless keep_to
|
|
116
|
+
row.status = :skipped
|
|
117
|
+
row.note = note
|
|
118
|
+
row
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The .md files whose local edits conflict with the shipped version.
|
|
122
|
+
def conflicts(row, data)
|
|
123
|
+
provenance = Provenance.new(name: row.name)
|
|
124
|
+
target_dir = Status.resolve_target_dir(row.scope)
|
|
125
|
+
return [] unless data[:files]
|
|
126
|
+
|
|
127
|
+
Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort.select do |key|
|
|
128
|
+
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
|
|
129
|
+
incoming_path: File.join(row.source_dir, key)) == :conflict
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Installed hooks, rules and the plugin whose sha differs from the
|
|
134
|
+
# recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
|
|
135
|
+
def edited_executables(name, data)
|
|
136
|
+
provenance = Provenance.new(name: name)
|
|
137
|
+
edited = []
|
|
138
|
+
(data[:hooks] || {}).each do |file, meta|
|
|
139
|
+
edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file.to_s), meta)
|
|
140
|
+
end
|
|
141
|
+
(data[:guardrails] || {}).each do |file, meta|
|
|
142
|
+
edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file.to_s), meta)
|
|
143
|
+
end
|
|
144
|
+
plugin = provenance.plugin_path(data)
|
|
145
|
+
edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin, data[:plugin])
|
|
146
|
+
edited
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def edited?(path, meta)
|
|
150
|
+
recorded = meta.is_a?(Hash) ? meta[:sha256].to_s.delete_prefix("sha256:") : ""
|
|
151
|
+
return false if recorded.empty? || !File.file?(path)
|
|
152
|
+
|
|
153
|
+
Digest::SHA256.hexdigest(File.binread(path)) != recorded
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -31,6 +31,7 @@ module Samagotchi
|
|
|
31
31
|
missing = !File.exist?(target_path)
|
|
32
32
|
index_present = index_has_entry?(scope, file_key_str)
|
|
33
33
|
details[file_key_str] = {
|
|
34
|
+
conflict: meta.is_a?(Hash) && meta[:conflict] == true,
|
|
34
35
|
stored_checksum: stored_checksum,
|
|
35
36
|
current_checksum: current_checksum,
|
|
36
37
|
base_checksum: base_checksum,
|
|
@@ -96,11 +97,13 @@ module Samagotchi
|
|
|
96
97
|
end
|
|
97
98
|
end
|
|
98
99
|
|
|
100
|
+
# Index lines name the entry without ".md" (Installer#update_target_index,
|
|
101
|
+
# memory_write); a legacy "name.md" line counts too.
|
|
99
102
|
def self.index_has_entry?(scope, file_key)
|
|
100
103
|
path = IndexUpdater.index_path_for(scope)
|
|
101
104
|
return false unless path && File.exist?(path)
|
|
102
105
|
content = File.read(path)
|
|
103
|
-
content.match?(IndexUpdater.managed_pattern(
|
|
106
|
+
[file_key.delete_suffix(".md"), file_key].uniq.any? { |name| content.match?(IndexUpdater.managed_pattern(name)) }
|
|
104
107
|
end
|
|
105
108
|
end
|
|
106
109
|
end
|
|
@@ -4,6 +4,7 @@ require "fileutils"
|
|
|
4
4
|
require_relative "installer"
|
|
5
5
|
require_relative "provenance"
|
|
6
6
|
require_relative "manifest"
|
|
7
|
+
require_relative "merger"
|
|
7
8
|
require_relative "../log"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
@@ -27,40 +28,72 @@ module Samagotchi
|
|
|
27
28
|
|
|
28
29
|
module_function
|
|
29
30
|
|
|
31
|
+
# What sync did (or, with dry_run, would do). status is :installed,
|
|
32
|
+
# :updated, :restored (missing files put back), :up_to_date,
|
|
33
|
+
# :newer_installed (left as is), :skipped (SKIP_ENV or no shipped
|
|
34
|
+
# bundle) or :failed (error: says why); kept lists the files whose
|
|
35
|
+
# local edits conflicted with the update and were kept.
|
|
36
|
+
Result = Struct.new(:from, :to, :status, :kept, :warnings, :error, keyword_init: true) do
|
|
37
|
+
def changed? = %i[installed updated restored].include?(status)
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
# Ensure the system bundle is installed and up-to-date.
|
|
31
41
|
# Idempotent and warn-only: never raises to callers.
|
|
32
42
|
# Safe for TUI and non-TUI (web/worker) paths — Engine calls this.
|
|
33
43
|
def ensure!
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
result = sync
|
|
45
|
+
case result.status
|
|
46
|
+
when :failed
|
|
47
|
+
Log.error(:memory, "system_bundle_failed", echo: "[samagotchi-system] #{result.error}")
|
|
48
|
+
when :newer_installed
|
|
49
|
+
with_lock { note_newer_installed(result.from, result.to) }
|
|
50
|
+
end
|
|
51
|
+
result.kept.each do |file_key|
|
|
52
|
+
Log.info(:memory, "system_local_edit_kept", echo: "[samagotchi-system] kept local edit in #{file_key} (bundle v#{result.to} has update — run: chi bundle status #{BUNDLE_NAME} / chi bundle diff #{BUNDLE_NAME} #{file_key})", file: file_key.to_s)
|
|
53
|
+
end
|
|
54
|
+
result.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") }
|
|
55
|
+
result.changed?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Install, upgrade or repair the installed system bundle to match the
|
|
59
|
+
# shipped one, under the lock. Never raises; logs nothing (ensure!
|
|
60
|
+
# and chi update report the Result).
|
|
61
|
+
# @param dry_run [Boolean] classify only, write nothing
|
|
62
|
+
# @return [Result]
|
|
63
|
+
def sync(dry_run: false)
|
|
64
|
+
return result(:skipped, error: "#{SKIP_ENV} is set") if skip?
|
|
65
|
+
return result(:skipped, error: "no shipped system bundle") unless File.directory?(GEM_BUNDLE_DIR)
|
|
36
66
|
|
|
37
67
|
begin
|
|
38
68
|
gem_manifest = Manifest.read(dir: GEM_BUNDLE_DIR)
|
|
39
69
|
rescue Manifest::ValidationError => e
|
|
40
|
-
|
|
41
|
-
return false
|
|
70
|
+
return result(:failed, error: "invalid gem manifest: #{e.message}")
|
|
42
71
|
end
|
|
72
|
+
to = gem_manifest.version.to_s
|
|
43
73
|
|
|
44
74
|
with_lock do
|
|
45
75
|
data = Provenance.new(name: BUNDLE_NAME).read
|
|
76
|
+
from = data && data[:version].to_s
|
|
46
77
|
|
|
47
78
|
if data.nil?
|
|
48
|
-
install_fresh(
|
|
49
|
-
elsif installed_newer?(
|
|
79
|
+
dry_run ? result(:installed, to: to) : install_fresh(to)
|
|
80
|
+
elsif installed_newer?(from, to)
|
|
50
81
|
# Running an older checkout/gem: never downgrade the user's system memories
|
|
51
82
|
# (not even to restore a missing file). `chi self` shows installed vs shipped.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
upgrade_existing(gem_manifest, data)
|
|
83
|
+
result(:newer_installed, from: from, to: to)
|
|
84
|
+
elsif from != to
|
|
85
|
+
dry_run ? result(:updated, from: from, to: to, kept: conflicting(gem_manifest)) : upgrade_existing(from, to)
|
|
56
86
|
else
|
|
57
87
|
# Already at desired version — still verify files present (e.g. user deleted one)
|
|
58
|
-
verify_files_present(gem_manifest,
|
|
88
|
+
verify_files_present(gem_manifest, from, dry_run)
|
|
59
89
|
end
|
|
60
90
|
end
|
|
61
91
|
rescue StandardError => e
|
|
62
|
-
|
|
63
|
-
|
|
92
|
+
result(:failed, error: "ensure failed: #{e.class}: #{e.message}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def result(status, from: nil, to: nil, kept: [], warnings: [], error: nil)
|
|
96
|
+
Result.new(from: from, to: to, status: status, kept: kept, warnings: warnings, error: error)
|
|
64
97
|
end
|
|
65
98
|
|
|
66
99
|
# Parallel chi starts on one config dir take turns: the second one reads
|
|
@@ -101,7 +134,7 @@ module Samagotchi
|
|
|
101
134
|
File.write(path, "#{pair}\n", mode: "a")
|
|
102
135
|
end
|
|
103
136
|
|
|
104
|
-
def install_fresh(
|
|
137
|
+
def install_fresh(to)
|
|
105
138
|
installer = Installer.new(
|
|
106
139
|
source: GEM_BUNDLE_DIR,
|
|
107
140
|
name: BUNDLE_NAME,
|
|
@@ -109,53 +142,48 @@ module Samagotchi
|
|
|
109
142
|
force: false,
|
|
110
143
|
strict: true
|
|
111
144
|
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") }
|
|
115
|
-
end
|
|
116
|
-
true
|
|
145
|
+
installer.run
|
|
146
|
+
result(:installed, to: to, warnings: installer.warnings)
|
|
117
147
|
end
|
|
118
148
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
_nd, _manifest = installer.run
|
|
129
|
-
# Installer already handled fast_forward/keep/noop. Conflicts are kept with warning.
|
|
130
|
-
if installer.conflicts.any?
|
|
131
|
-
installer.conflicts.each do |file_key, _info|
|
|
132
|
-
Log.info(:memory, "system_local_edit_kept", echo: "[samagotchi-system] kept local edit in #{file_key} (bundle v#{gem_manifest.version} has update — run: chi bundle status #{BUNDLE_NAME} / chi bundle diff #{BUNDLE_NAME} #{file_key})", file: file_key.to_s)
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") } unless installer.warnings.empty?
|
|
136
|
-
true
|
|
149
|
+
# Installer handles fast_forward/keep/noop; a conflict keeps the local
|
|
150
|
+
# file, and its own "Conflict in" warning is left out (kept says it).
|
|
151
|
+
def upgrade_existing(from, to)
|
|
152
|
+
installer = upgrade_installer
|
|
153
|
+
installer.run
|
|
154
|
+
kept = installer.conflicts.keys
|
|
155
|
+
warnings = installer.warnings.reject { |w| kept.any? { |k| w.start_with?("Conflict in #{k}:") } }
|
|
156
|
+
result(:updated, from: from, to: to, kept: kept, warnings: warnings)
|
|
137
157
|
end
|
|
138
158
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
# A file from the bundle missing on disk (the user deleted it) is put
|
|
160
|
+
# back via the Installer (which classifies it as :install), without a
|
|
161
|
+
# full reinstall.
|
|
162
|
+
def verify_files_present(gem_manifest, version, dry_run)
|
|
143
163
|
target_dir = Installer.system_dir
|
|
144
164
|
missing = gem_manifest.files.keys.any? { |k| !File.exist?(File.join(target_dir, k)) }
|
|
145
|
-
return
|
|
165
|
+
return result(:up_to_date, from: version, to: version) unless missing
|
|
166
|
+
return result(:restored, from: version, to: version) if dry_run
|
|
146
167
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
|
|
168
|
+
upgrade_installer.run
|
|
169
|
+
result(:restored, from: version, to: version)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The files an upgrade would keep: edited here and changed in the
|
|
173
|
+
# shipped bundle (a dry run's kept).
|
|
174
|
+
def conflicting(gem_manifest)
|
|
175
|
+
provenance = Provenance.new(name: BUNDLE_NAME)
|
|
176
|
+
gem_manifest.files.keys.map(&:to_s).sort.select do |key|
|
|
177
|
+
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(Installer.system_dir, key),
|
|
178
|
+
incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def upgrade_installer
|
|
183
|
+
Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true)
|
|
157
184
|
end
|
|
158
|
-
private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present
|
|
185
|
+
private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present,
|
|
186
|
+
:upgrade_installer, :result, :conflicting
|
|
159
187
|
end
|
|
160
188
|
end
|
|
161
189
|
end
|
|
@@ -14,6 +14,9 @@ module Samagotchi
|
|
|
14
14
|
# No model anywhere (--model, default.model in config.yml, the env): a
|
|
15
15
|
# first run before any config. An ArgumentError, as before.
|
|
16
16
|
MissingModel = Class.new(ArgumentError)
|
|
17
|
+
# A host-qualified model whose host isn't configured (a MissingModel,
|
|
18
|
+
# so every surface that reports a missing model reports it the same way).
|
|
19
|
+
UnknownHost = Class.new(MissingModel)
|
|
17
20
|
|
|
18
21
|
attr_reader :name, :turn_start, :turn_end,
|
|
19
22
|
:tool_call_open, :tool_call_close,
|
|
@@ -113,23 +116,37 @@ module Samagotchi
|
|
|
113
116
|
end
|
|
114
117
|
end
|
|
115
118
|
|
|
116
|
-
def self.required_model_name(model_name = nil
|
|
119
|
+
def self.required_model_name(model_name = nil)
|
|
117
120
|
value = model_name.to_s.strip
|
|
118
121
|
if value.empty?
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
cfg = Samagotchi::Config.get("default.model") rescue nil
|
|
122
|
-
value = cfg.to_s.strip unless cfg.nil? || cfg.to_s.strip.empty?
|
|
123
|
-
rescue StandardError
|
|
124
|
-
nil
|
|
125
|
-
end
|
|
122
|
+
require_relative "config"
|
|
123
|
+
value = Samagotchi::Config.get("default.model").to_s.strip
|
|
126
124
|
end
|
|
127
|
-
value = env[MODEL_ENV].to_s.strip if value.empty?
|
|
128
125
|
raise MissingModel, missing_model_message if value.empty?
|
|
129
126
|
|
|
130
127
|
value
|
|
131
128
|
end
|
|
132
129
|
|
|
130
|
+
# Raises UnknownHost when +model_name+ (or the alias it names) is
|
|
131
|
+
# qualified with a host that isn't configured, instead of sending the
|
|
132
|
+
# whole ref to the default host as a model id. Where a model comes in
|
|
133
|
+
# (an Engine starting or switching, a spawned session) calls it.
|
|
134
|
+
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
135
|
+
# entries); config.yml's by default
|
|
136
|
+
# @return [String] +model_name+
|
|
137
|
+
def self.check_host!(model_name, env: ENV, hosts: nil)
|
|
138
|
+
require_relative "config"
|
|
139
|
+
hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
|
|
140
|
+
ref = Samagotchi::ConfigFile.resolve_model_alias(model_name, env: env, hosts: hosts)
|
|
141
|
+
host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
|
|
142
|
+
return model_name unless host
|
|
143
|
+
|
|
144
|
+
names = hosts.keys.map { |k| k.to_s.downcase }.sort
|
|
145
|
+
near = Samagotchi::Config.near_names(host, names).first(3)
|
|
146
|
+
hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(' or ')}?)"
|
|
147
|
+
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
|
|
148
|
+
end
|
|
149
|
+
|
|
133
150
|
# One line for the user: where to set the model.
|
|
134
151
|
def self.missing_model_message
|
|
135
152
|
path = begin
|
|
@@ -158,7 +175,7 @@ module Samagotchi
|
|
|
158
175
|
end
|
|
159
176
|
|
|
160
177
|
def self.from_env
|
|
161
|
-
from_model_name(required_model_name(nil
|
|
178
|
+
from_model_name(required_model_name(nil))
|
|
162
179
|
end
|
|
163
180
|
|
|
164
181
|
# ── Resolution: which profile a model gets ───────────────────────────
|
|
@@ -102,7 +102,8 @@ module Samagotchi
|
|
|
102
102
|
|
|
103
103
|
# --all: every live session. @return [Integer] exit status
|
|
104
104
|
def deliver_to_live(text, source)
|
|
105
|
-
ids = SessionManager.session_summaries(live: true, include_tests:
|
|
105
|
+
ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
|
|
106
|
+
state_dir: @state_dir).map { |s| s[:id] }
|
|
106
107
|
if ids.empty?
|
|
107
108
|
error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
|
|
108
109
|
return 1
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -39,8 +39,10 @@ module Samagotchi
|
|
|
39
39
|
# what it was before images existed.
|
|
40
40
|
#
|
|
41
41
|
# @param vision [VisionContext, nil] the turn's images context
|
|
42
|
+
# @param prefill [String] text after the generation cue, the start of
|
|
43
|
+
# the model's answer (Thinking.native: Qwen's empty thought)
|
|
42
44
|
# @return [Array(String, Array<String>)]
|
|
43
|
-
def self.format_with_images(messages, profile: ModelProfile.default, vision: nil)
|
|
45
|
+
def self.format_with_images(messages, profile: ModelProfile.default, vision: nil, prefill: "")
|
|
44
46
|
plan = ImagePlan.new(messages, vision, data: :base64)
|
|
45
47
|
images = []
|
|
46
48
|
suffixes = messages.each_with_index.map { |m, index| image_lines(plan.items(m, index), profile, images) }
|
|
@@ -49,7 +51,7 @@ module Samagotchi
|
|
|
49
51
|
else
|
|
50
52
|
format_with_turn_markers(messages, profile, suffixes)
|
|
51
53
|
end
|
|
52
|
-
[text, images]
|
|
54
|
+
[text + prefill.to_s, images]
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
private
|
|
@@ -90,7 +90,7 @@ module Samagotchi
|
|
|
90
90
|
|
|
91
91
|
# Get all due reminders whose interval has elapsed.
|
|
92
92
|
# Returns an Array of {name:, description:, interval_minutes:} hashes.
|
|
93
|
-
# Called by Engine#
|
|
93
|
+
# Called by Engine#collect_due_reminders (synchronously, at run_turn start).
|
|
94
94
|
# Thread-safe. Returns a frozen copy so the caller can't mutate internal state.
|
|
95
95
|
# @return [Array<Hash>] array of due reminder hashes (may be empty)
|
|
96
96
|
def due_reminders
|
|
@@ -103,14 +103,6 @@ module Samagotchi
|
|
|
103
103
|
end
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# Get the name of the next due reminder (if any) whose interval has elapsed.
|
|
107
|
-
# Kept for backward compatibility with IdleReminders thread.
|
|
108
|
-
# @return [String, nil] the reminder name, or nil if none are due
|
|
109
|
-
def next_due_name
|
|
110
|
-
due = due_reminders
|
|
111
|
-
due.first&.fetch(:name)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
106
|
# Mark a reminder as fired — resets its next_fire_at to now + interval.
|
|
115
107
|
# Called by Engine after a due reminder has been delivered.
|
|
116
108
|
# @param name [String] the reminder name
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
|
+
require_relative "turn_note"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# Loaded on first use: session_manager requires terminal_ui, which
|
|
@@ -23,7 +24,11 @@ module Samagotchi
|
|
|
23
24
|
# @!attribute file [String, nil] the reply's filename, the next cursor
|
|
24
25
|
# @!attribute question [Hash, nil] the pending question
|
|
25
26
|
# (:waiting_for_answer)
|
|
26
|
-
|
|
27
|
+
# @!attribute outcome [String, nil] how the turn ended when it left no
|
|
28
|
+
# reply (:no_reply): "failed", "canceled" or "completed" (empty), from
|
|
29
|
+
# the session's last_turn; nil when unknown. With "failed", text is
|
|
30
|
+
# the failure's summary when the turn note has one.
|
|
31
|
+
Result = Struct.new(:status, :text, :file, :question, :outcome, keyword_init: true)
|
|
27
32
|
|
|
28
33
|
module_function
|
|
29
34
|
|
|
@@ -38,7 +43,10 @@ module Samagotchi
|
|
|
38
43
|
# messages (a failed, canceled or empty one leaves its note) and went
|
|
39
44
|
# idle again ends the wait even if it was never seen running, and a
|
|
40
45
|
# question already pending then is not the answer's. A nil
|
|
41
|
-
# messages: skips the count.
|
|
46
|
+
# messages: skips the count. last_turn: (the session's
|
|
47
|
+
# last_turn["ended_at"] then, maybe nil) ends the wait on any turn
|
|
48
|
+
# that ended since, idle again, however fast it ran: a failure after
|
|
49
|
+
# a failure replaces the note and leaves the count as it was.
|
|
42
50
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
43
51
|
# :worker_gone (one that died before its rescue leaves it running)
|
|
44
52
|
# @return [Result]
|
|
@@ -72,13 +80,13 @@ module Samagotchi
|
|
|
72
80
|
|
|
73
81
|
if session.status == Session::STATUS_RUNNING
|
|
74
82
|
seen_running = true
|
|
75
|
-
elsif seen_running || (
|
|
83
|
+
elsif seen_running || turn_ended_since?(session, baseline)
|
|
76
84
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
77
85
|
# empty. Before it was ever seen running, idle means a
|
|
78
86
|
# file-delivered message its worker has not picked up yet.
|
|
79
87
|
# The worker writes the reply before its idle save, so the read
|
|
80
88
|
# above has it; one more look costs nothing should that change.
|
|
81
|
-
return reply_past(id, state_dir: state_dir, cursor: cursor) ||
|
|
89
|
+
return reply_past(id, state_dir: state_dir, cursor: cursor) || no_reply(session, baseline)
|
|
82
90
|
end
|
|
83
91
|
|
|
84
92
|
if owner_grace
|
|
@@ -95,6 +103,42 @@ module Samagotchi
|
|
|
95
103
|
end
|
|
96
104
|
end
|
|
97
105
|
|
|
106
|
+
# An idle session whose turn ended after the baseline was taken: its
|
|
107
|
+
# last_turn moved on, or its messages grew (a turn's note).
|
|
108
|
+
def turn_ended_since?(session, baseline)
|
|
109
|
+
return false unless baseline
|
|
110
|
+
|
|
111
|
+
if baseline.key?(:last_turn)
|
|
112
|
+
ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
|
|
113
|
+
return true if ended && ended != baseline[:last_turn]
|
|
114
|
+
end
|
|
115
|
+
!!(baseline[:messages] && session.messages.size > baseline[:messages])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Result] :no_reply with the outcome the session's last_turn
|
|
119
|
+
# records, when it is the turn waited for
|
|
120
|
+
def no_reply(session, baseline)
|
|
121
|
+
last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
|
|
122
|
+
fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
|
|
123
|
+
outcome = fresh ? last["outcome"] : nil
|
|
124
|
+
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
125
|
+
Result.new(status: :no_reply, outcome: outcome, text: text)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The summary in the failed-turn note at the conversation's tail, nil
|
|
129
|
+
# without one.
|
|
130
|
+
def failure_summary(messages)
|
|
131
|
+
list = Array(messages)
|
|
132
|
+
index = TurnNote.trailing_index(list)
|
|
133
|
+
return nil unless index
|
|
134
|
+
|
|
135
|
+
text = (list[index][:content] || list[index]["content"]).to_s
|
|
136
|
+
prefix = "#{TurnNote::OPEN}#{TurnNote::FAILED}"
|
|
137
|
+
return nil unless text.start_with?(prefix)
|
|
138
|
+
|
|
139
|
+
text[prefix.length..].sub(/\. [^.]*\.\]\z/, "")
|
|
140
|
+
end
|
|
141
|
+
|
|
98
142
|
# @return [Result, nil] :done with the newest reply past the cursor
|
|
99
143
|
def reply_past(id, state_dir:, cursor:)
|
|
100
144
|
file = newest_reply(id, state_dir: state_dir)
|