samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- 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 +180 -25
- 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/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- 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 +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -94,14 +94,23 @@ module Samagotchi
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# Yields [name, data] for each installed bundle with hooks, by name.
|
|
98
|
+
# A manifest that doesn't parse is yielded as {error:} when its
|
|
99
|
+
# bundle has a hooks/ dir, and skipped otherwise; the rest still load.
|
|
97
100
|
def self.each_installed_holding_hooks
|
|
98
101
|
return enum_for(:each_installed_holding_hooks) unless block_given?
|
|
99
102
|
dir = self.bundles_dir
|
|
100
103
|
return unless Dir.exist?(dir)
|
|
101
104
|
Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
name = File.basename(File.dirname(mjson))
|
|
106
|
+
begin
|
|
107
|
+
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
108
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
109
|
+
yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "hooks"))
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
next unless data.is_a?(Hash) && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
|
|
113
|
+
yield name, data
|
|
105
114
|
end
|
|
106
115
|
end
|
|
107
116
|
|
|
@@ -116,8 +125,10 @@ module Samagotchi
|
|
|
116
125
|
# snapshot
|
|
117
126
|
# @param requires_chi [String, nil] the manifest's requirement
|
|
118
127
|
# @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
|
|
128
|
+
# @param conflicts [Array<String>] files an upgrade kept with local
|
|
129
|
+
# edits that conflict with it; their entries get conflict: true
|
|
119
130
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
120
|
-
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil)
|
|
131
|
+
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [])
|
|
121
132
|
FileUtils.mkdir_p(@bundle_dir)
|
|
122
133
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
123
134
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -134,6 +145,7 @@ module Samagotchi
|
|
|
134
145
|
# Save base snapshot — use file_key as-is (it already includes .md).
|
|
135
146
|
File.write(File.join(bases_dir, file_key), content)
|
|
136
147
|
merged_entries[file_key] = { checksum: checksum }
|
|
148
|
+
merged_entries[file_key][:conflict] = true if conflicts.include?(file_key.to_s)
|
|
137
149
|
end
|
|
138
150
|
|
|
139
151
|
# Prune stale base snapshots for files no longer in the bundle.
|
|
@@ -226,14 +238,29 @@ module Samagotchi
|
|
|
226
238
|
manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
227
239
|
manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
|
|
228
240
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
241
|
+
write_manifest(manifest_data)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# After a conflict was resolved by hand: each file's base becomes the
|
|
245
|
+
# bundle's version (the incoming file, else what's on disk now) and
|
|
246
|
+
# its conflict mark goes. The rest of the manifest stays as is.
|
|
247
|
+
# @param conflicts [Hash{String => Hash}] Installer#conflicts
|
|
248
|
+
def resolve_conflicts(conflicts)
|
|
249
|
+
return unless installed?
|
|
250
|
+
|
|
251
|
+
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
252
|
+
raw["files"] ||= {}
|
|
253
|
+
conflicts.each do |file_key, info|
|
|
254
|
+
key = file_key.to_s
|
|
255
|
+
next unless raw["files"].key?(key)
|
|
256
|
+
src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
|
|
257
|
+
next unless src
|
|
258
|
+
|
|
259
|
+
content = File.read(src)
|
|
260
|
+
File.write(base_path(key), content)
|
|
261
|
+
raw["files"][key] = { "checksum" => Digest::SHA256.hexdigest(content) }
|
|
262
|
+
end
|
|
263
|
+
write_manifest(raw)
|
|
237
264
|
end
|
|
238
265
|
|
|
239
266
|
# Reads provenance data (returns nil if not installed).
|
|
@@ -243,6 +270,18 @@ module Samagotchi
|
|
|
243
270
|
JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
244
271
|
end
|
|
245
272
|
|
|
273
|
+
# Write aside and rename, so a reader in another process (a parallel
|
|
274
|
+
# chi start) never parses a truncated manifest.json.
|
|
275
|
+
def write_manifest(manifest_data)
|
|
276
|
+
manifest_path = File.join(@bundle_dir, "manifest.json")
|
|
277
|
+
tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
|
|
278
|
+
File.write(tmp_path, JSON.pretty_generate(manifest_data))
|
|
279
|
+
File.rename(tmp_path, manifest_path)
|
|
280
|
+
ensure
|
|
281
|
+
FileUtils.rm_f(tmp_path) if tmp_path
|
|
282
|
+
end
|
|
283
|
+
private :write_manifest
|
|
284
|
+
|
|
246
285
|
# Returns the path to a base snapshot for a given file key.
|
|
247
286
|
def base_path(file_key)
|
|
248
287
|
File.join(@bundle_dir, "bases", file_key)
|
|
@@ -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,
|
|
@@ -130,6 +133,26 @@ module Samagotchi
|
|
|
130
133
|
value
|
|
131
134
|
end
|
|
132
135
|
|
|
136
|
+
# Raises UnknownHost when +model_name+ (or the alias it names) is
|
|
137
|
+
# qualified with a host that isn't configured, instead of sending the
|
|
138
|
+
# whole ref to the default host as a model id. Where a model comes in
|
|
139
|
+
# (an Engine starting or switching, a spawned session) calls it.
|
|
140
|
+
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
141
|
+
# entries); config.yml's by default
|
|
142
|
+
# @return [String] +model_name+
|
|
143
|
+
def self.check_host!(model_name, env: ENV, hosts: nil)
|
|
144
|
+
require_relative "config"
|
|
145
|
+
hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
|
|
146
|
+
ref = Samagotchi::ConfigFile.resolve_model_alias(model_name, env: env, hosts: hosts)
|
|
147
|
+
host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
|
|
148
|
+
return model_name unless host
|
|
149
|
+
|
|
150
|
+
names = hosts.keys.map { |k| k.to_s.downcase }.sort
|
|
151
|
+
near = Samagotchi::Config.near_names(host, names).first(3)
|
|
152
|
+
hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(' or ')}?)"
|
|
153
|
+
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
|
|
154
|
+
end
|
|
155
|
+
|
|
133
156
|
# One line for the user: where to set the model.
|
|
134
157
|
def self.missing_model_message
|
|
135
158
|
path = begin
|
|
@@ -139,7 +162,7 @@ module Samagotchi
|
|
|
139
162
|
"~/.config/samagotchi/config.yml"
|
|
140
163
|
end
|
|
141
164
|
"no model configured: set default.model in #{path} to the model id your server serves " \
|
|
142
|
-
"(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md"
|
|
165
|
+
"(or #{MODEL_ENV}, or pass --model ID); see docs/configuration.md; or run: chi bootstrap HOST[:PORT]"
|
|
143
166
|
end
|
|
144
167
|
|
|
145
168
|
def self.from_model_name(model_name)
|
|
@@ -18,8 +18,10 @@ module Samagotchi
|
|
|
18
18
|
# the answer text.
|
|
19
19
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
20
20
|
# +model_name+ and +state_dir+ are what ctx.sessions forks with.
|
|
21
|
+
# +steer+ takes (text, source) and +stop_turn+ (reason, label), each
|
|
22
|
+
# true when it acted on a running turn.
|
|
21
23
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
22
|
-
:ask_model, :model_name, :state_dir, keyword_init: true)
|
|
24
|
+
:ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, keyword_init: true)
|
|
23
25
|
|
|
24
26
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
25
27
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -178,6 +180,25 @@ module Samagotchi
|
|
|
178
180
|
# Whether the running turn was cancelled (a long tool should stop).
|
|
179
181
|
def cancelled? = !!@host.cancelled.call
|
|
180
182
|
|
|
183
|
+
# Put +text+ into the running turn, as a user's steering does: at the
|
|
184
|
+
# loop's next boundary (after the tool calls in flight) it joins the
|
|
185
|
+
# conversation as its own user message, shown in every UI as a nudge
|
|
186
|
+
# from this bundle. It never starts a turn. True means queued: if the
|
|
187
|
+
# model answers first, or the turn ends, it is dropped (logged).
|
|
188
|
+
# Callable from a hook, a command (an anytime one runs beside the
|
|
189
|
+
# turn) or your own thread.
|
|
190
|
+
# @return [Boolean] whether a turn was running and the text queued
|
|
191
|
+
def steer(text)
|
|
192
|
+
!!@host.steer&.call(text.to_s, @bundle)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Stop the running turn, after a notice with +reason+, as a hook's
|
|
196
|
+
# event[:stop_turn] does (for commands and threads).
|
|
197
|
+
# @return [Boolean] whether a running turn was stopped now
|
|
198
|
+
def stop_turn(reason)
|
|
199
|
+
!!@host.stop_turn&.call(reason.to_s, @label)
|
|
200
|
+
end
|
|
201
|
+
|
|
181
202
|
private
|
|
182
203
|
|
|
183
204
|
def deep_freeze(value)
|
|
@@ -33,9 +33,11 @@ module Samagotchi
|
|
|
33
33
|
# the first user message, by default)
|
|
34
34
|
# @param prompt [String, nil] the child's first turn
|
|
35
35
|
# @return [String] the child's id
|
|
36
|
-
# @raise [Error] no session yet,
|
|
36
|
+
# @raise [Error] no session yet, a scratch session (the child would
|
|
37
|
+
# outlive it), or too many children running
|
|
37
38
|
def fork(messages:, title: nil, prompt: nil)
|
|
38
39
|
parent_id = @host.session_id.call or raise Error, "this session has no id yet"
|
|
40
|
+
raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
|
|
39
41
|
state_dir = self.state_dir
|
|
40
42
|
prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
|
|
41
43
|
check_children(parent_id, state_dir) if prompt
|
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
|