samagotchi 0.3.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 +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- 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 +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- 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 +201 -104
- 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 +26 -79
- data/lib/samagotchi/live_versions.rb +59 -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 +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -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 +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- 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 +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- 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 +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- metadata +12 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "desktop"
|
|
5
|
+
require_relative "gem_update"
|
|
6
|
+
require_relative "installed_gem"
|
|
7
|
+
require_relative "live_versions"
|
|
8
|
+
require_relative "memory_bundle/shipped_update"
|
|
9
|
+
require_relative "memory_bundle/system_bundle"
|
|
10
|
+
require_relative "session"
|
|
11
|
+
require_relative "version"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# `chi update`: brings an installed chi up to date in one go: the system
|
|
15
|
+
# bundle, the shipped bundles the user installed, and the macOS helper.
|
|
16
|
+
# Unedited things update; edited ones are kept (.md) or replaced and
|
|
17
|
+
# reported (hooks, plugin, rules, which didn't load once edited). Live
|
|
18
|
+
# workers and a running chi web are reported, never restarted. Prints one
|
|
19
|
+
# table; a second run changes nothing and says so.
|
|
20
|
+
class UpdateCommand
|
|
21
|
+
USAGE = <<~TEXT
|
|
22
|
+
Usage: chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]
|
|
23
|
+
Updates this chi's install: the gem (from rubygems.org), then the
|
|
24
|
+
system bundle, the bundles chi ships that you installed (chi bundle
|
|
25
|
+
list), and the desktop helper (macOS; rebuilt only when its sources
|
|
26
|
+
changed). Edited memory files are kept and reported; nothing is
|
|
27
|
+
installed that isn't already, and old gem versions stay.
|
|
28
|
+
--dry-run show what would change; change nothing
|
|
29
|
+
--no-gem don't install a newer gem (config update.gem: false)
|
|
30
|
+
--no-bundles leave the shipped bundles alone (config update.bundles: false)
|
|
31
|
+
--no-desktop leave the desktop helper alone (config update.desktop: false)
|
|
32
|
+
Running sessions keep their chi until they idle out (30 min) or
|
|
33
|
+
chi sessions stop ID; a running chi web needs a restart.
|
|
34
|
+
TEXT
|
|
35
|
+
|
|
36
|
+
FLAGS = { "--dry-run" => :dry_run, "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
|
|
37
|
+
# Hidden: no pbs/lsregister/open/pkill for a helper under HOME=<tmp>.
|
|
38
|
+
"--no-register" => :no_register }.freeze
|
|
39
|
+
|
|
40
|
+
# A table row. info rows (live workers, chi web) say what runs; they
|
|
41
|
+
# never fail.
|
|
42
|
+
Row = Struct.new(:component, :from, :to, :status, :failed, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
# What a row says after a change, and in a dry run.
|
|
45
|
+
VERBS = { update: ["updated", "would update"], install: ["installed", "would install"],
|
|
46
|
+
restore: ["restored missing files", "would restore missing files"] }.freeze
|
|
47
|
+
|
|
48
|
+
# @param gem_spec [Gem::Specification, nil] the installed gem (nil: a checkout)
|
|
49
|
+
# @param platform [#call] register: → a Desktop::MacOS-like object
|
|
50
|
+
# @param exec [#call] argv → replaces this process (the new chi's update)
|
|
51
|
+
# @param bundler [Boolean] running under Bundler (bundle exec)
|
|
52
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, gem_spec: InstalledGem.spec,
|
|
53
|
+
shipped_dir: MemoryBundle::SourceNormalizer::SHIPPED_DIR, platform: nil,
|
|
54
|
+
supported: Desktop.supported?, state_dir: Session.default_state_dir, web: nil,
|
|
55
|
+
gem_update: GemUpdate.new, exec: nil, bundler: nil)
|
|
56
|
+
@gem_update = gem_update
|
|
57
|
+
@exec = exec || ->(argv) { Kernel.exec(*argv) }
|
|
58
|
+
@bundler = bundler.nil? ? (defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty?) : bundler
|
|
59
|
+
@argv = argv.dup
|
|
60
|
+
@stdout = stdout
|
|
61
|
+
@stderr = stderr
|
|
62
|
+
@gem_spec = gem_spec
|
|
63
|
+
@shipped_dir = shipped_dir
|
|
64
|
+
@platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
|
|
65
|
+
@supported = supported
|
|
66
|
+
@state_dir = state_dir
|
|
67
|
+
@web = web || [Config.get("web.host").to_s, Config.get("web.port").to_i]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Integer] 0 nothing failed, 1 a part failed (or a checkout), 2 usage
|
|
71
|
+
def run
|
|
72
|
+
options = parse or return 2
|
|
73
|
+
if options[:help]
|
|
74
|
+
@stdout.puts(USAGE)
|
|
75
|
+
return 0
|
|
76
|
+
end
|
|
77
|
+
unless @gem_spec
|
|
78
|
+
@stderr.puts("chi update: running from a checkout (#{InstalledGem::ROOT}): chi update updates an installed chi; " \
|
|
79
|
+
"git pull, or chi bundle upgrade NAME for one bundle")
|
|
80
|
+
return 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@dry_run = options[:dry_run]
|
|
84
|
+
gem = gem_row(options)
|
|
85
|
+
rows = [gem, system_bundle_row, *bundle_rows(options), desktop_row(options), *live_rows].compact
|
|
86
|
+
print_table(rows)
|
|
87
|
+
footers(options).each { |line| @stdout.puts(line) }
|
|
88
|
+
@stdout.puts(summary(rows))
|
|
89
|
+
rows.any?(&:failed) ? 1 : 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def parse
|
|
95
|
+
argv = @argv.dup
|
|
96
|
+
options = {}
|
|
97
|
+
while (arg = argv.shift)
|
|
98
|
+
if %w[-h --help help].include?(arg)
|
|
99
|
+
options[:help] = true
|
|
100
|
+
elsif arg == "--gem-from" && argv.first
|
|
101
|
+
# Hidden: the old version, passed by the chi that installed this one.
|
|
102
|
+
options[:gem_from] = argv.shift
|
|
103
|
+
elsif FLAGS.key?(arg)
|
|
104
|
+
options[FLAGS[arg]] = true
|
|
105
|
+
else
|
|
106
|
+
@stderr.puts("chi update: unknown option #{arg}")
|
|
107
|
+
@stderr.puts(USAGE)
|
|
108
|
+
return nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
options
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Installs a newer gem and hands over to it (exec: the new code syncs
|
|
115
|
+
# and prints the table), or says why not. A failed install is a failed
|
|
116
|
+
# row; the sync then runs on this version.
|
|
117
|
+
def gem_row(options)
|
|
118
|
+
row = Row.new(component: "chi (gem)", from: VERSION)
|
|
119
|
+
if options[:gem_from]
|
|
120
|
+
row.from = options[:gem_from]
|
|
121
|
+
row.to = VERSION
|
|
122
|
+
return done(row, :update)
|
|
123
|
+
end
|
|
124
|
+
if (why = off(options, "gem"))
|
|
125
|
+
return skipped(row, why)
|
|
126
|
+
end
|
|
127
|
+
return skipped(row, "under Bundler: bundle update samagotchi") if @bundler
|
|
128
|
+
|
|
129
|
+
latest = begin
|
|
130
|
+
@gem_update.latest
|
|
131
|
+
rescue GemUpdate::Error => e
|
|
132
|
+
return row.tap { |r| r.status = "couldn't check (#{e.message})" }
|
|
133
|
+
end
|
|
134
|
+
return up_to_date(row) unless @gem_update.newer?(latest)
|
|
135
|
+
|
|
136
|
+
row.to = latest.version
|
|
137
|
+
return done(row, :update) if @dry_run
|
|
138
|
+
|
|
139
|
+
@stdout.puts("installing samagotchi #{latest.version}…")
|
|
140
|
+
output, ok = @gem_update.install(latest)
|
|
141
|
+
return failed(row, output.lines.map(&:strip).reject(&:empty?).last.to_s) unless ok
|
|
142
|
+
|
|
143
|
+
hand_over(options, row)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# exec the wrapper, which activates the newest installed samagotchi.
|
|
147
|
+
# Without one (installed with --bindir), this version syncs.
|
|
148
|
+
def hand_over(options, row)
|
|
149
|
+
wrapper = InstalledGem.wrapper(@gem_spec)
|
|
150
|
+
return done(row, :update, "run chi update again to sync with it") unless wrapper
|
|
151
|
+
|
|
152
|
+
passed = FLAGS.select { |_, key| options[key] && key != :dry_run }.keys
|
|
153
|
+
@stdout.flush
|
|
154
|
+
@exec.call([RbConfig.ruby, wrapper, "update", "--no-gem", "--gem-from", VERSION, *passed])
|
|
155
|
+
done(row, :update) # only when exec is a spec's stand-in
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Why a part is off for this run, or nil.
|
|
159
|
+
def off(options, part)
|
|
160
|
+
return "--no-#{part}" if options[:"no_#{part}"]
|
|
161
|
+
return "update.#{part}: false" unless Config.get("update.#{part}")
|
|
162
|
+
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def system_bundle_row
|
|
167
|
+
result = MemoryBundle::SystemBundle.sync(dry_run: @dry_run)
|
|
168
|
+
row = Row.new(component: "system bundle", from: result.from, to: result.to)
|
|
169
|
+
case result.status
|
|
170
|
+
when :installed then done(row, :install)
|
|
171
|
+
when :updated then done(row, :update, kept(result.kept, MemoryBundle::SystemBundle::BUNDLE_NAME))
|
|
172
|
+
when :restored then done(row, :restore).tap { |r| r.to = nil }
|
|
173
|
+
when :up_to_date then up_to_date(row)
|
|
174
|
+
when :newer_installed then skipped(row, "newer than this chi's #{result.to}: left").tap { |r| r.to = nil }
|
|
175
|
+
when :skipped then skipped(row, result.error)
|
|
176
|
+
else failed(row, result.error)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def bundle_rows(options)
|
|
181
|
+
if (why = off(options, "bundles"))
|
|
182
|
+
return [Row.new(component: "bundles", status: "skipped (#{why})")]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
rows = MemoryBundle::ShippedUpdate.plan(shipped_dir: @shipped_dir)
|
|
186
|
+
rows = MemoryBundle::ShippedUpdate.apply(rows) unless @dry_run
|
|
187
|
+
rows.map { |r| bundle_row(r) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def bundle_row(update)
|
|
191
|
+
row = Row.new(component: update.name, from: update.from, to: update.to)
|
|
192
|
+
case update.status
|
|
193
|
+
when :would_update, :updated
|
|
194
|
+
notes = [kept(update.kept, update.name), replaced(update.replaced)].compact
|
|
195
|
+
done(row, :update, notes.empty? ? nil : notes.join("; "))
|
|
196
|
+
when :up_to_date then up_to_date(row)
|
|
197
|
+
when :skipped then skipped(row, update.note)
|
|
198
|
+
else failed(row, update.note)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def desktop_row(options)
|
|
203
|
+
return nil unless @supported
|
|
204
|
+
|
|
205
|
+
row = Row.new(component: "Chi Helper")
|
|
206
|
+
if (why = off(options, "desktop"))
|
|
207
|
+
return skipped(row, why)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
helper = @platform.call(register: !options[:no_register])
|
|
211
|
+
return row.tap { |r| r.status = "not installed" } unless helper.installed?
|
|
212
|
+
|
|
213
|
+
row.from = helper.app_version
|
|
214
|
+
if helper.stale?
|
|
215
|
+
row.to = VERSION
|
|
216
|
+
return done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade { nil } }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
return up_to_date(row) unless helper.launch_outdated?
|
|
220
|
+
|
|
221
|
+
helper.refresh_launch_file unless @dry_run
|
|
222
|
+
up_to_date(row).tap { |r| r.status += @dry_run ? " (would refresh the launch file)" : " (launch file refreshed)" }
|
|
223
|
+
rescue Desktop::MacOS::Error, SystemCallError => e
|
|
224
|
+
failed(row, e.message.lines.first.to_s.strip)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def live_rows
|
|
228
|
+
rows = []
|
|
229
|
+
stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
|
|
230
|
+
unless stale.empty?
|
|
231
|
+
versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
|
|
232
|
+
ids = stale.map { |w| w.session_id[0, 8] }.join(" ")
|
|
233
|
+
rows << Row.new(component: "workers",
|
|
234
|
+
status: "#{stale.size} live on #{versions}: they move to #{VERSION} at idle exit (30 min) " \
|
|
235
|
+
"or chi sessions stop #{ids}")
|
|
236
|
+
end
|
|
237
|
+
host, port = @web
|
|
238
|
+
web = LiveVersions.web_version(host, port)
|
|
239
|
+
if web && web != VERSION
|
|
240
|
+
rows << Row.new(component: "chi web :#{port}", from: web,
|
|
241
|
+
status: "running old code: new sessions it starts run #{web} too; restart it")
|
|
242
|
+
end
|
|
243
|
+
rows
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def footers(options)
|
|
247
|
+
return [] if off(options, "bundles")
|
|
248
|
+
|
|
249
|
+
names = MemoryBundle::ShippedUpdate.not_installed(shipped_dir: @shipped_dir).map(&:source)
|
|
250
|
+
names.empty? ? [] : ["Also shipped, not installed: #{names.join(", ")} (chi bundle install NAME)"]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def summary(rows)
|
|
254
|
+
return "(dry run: nothing was changed)" if @dry_run
|
|
255
|
+
return "some parts failed (see above)" if rows.any?(&:failed)
|
|
256
|
+
|
|
257
|
+
rows.all? { |r| r.status.start_with?("up to date", "skipped", "not installed", "couldn't check") } ? "everything is up to date" : "done"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# "updated" (or "would update" in a dry run), with a note; the block
|
|
261
|
+
# makes the change (not in a dry run).
|
|
262
|
+
def done(row, verb, note = nil)
|
|
263
|
+
yield if block_given? && !@dry_run
|
|
264
|
+
text = VERBS.fetch(verb)[@dry_run ? 1 : 0]
|
|
265
|
+
row.status = note ? "#{text} (#{note})" : text
|
|
266
|
+
row
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def up_to_date(row)
|
|
270
|
+
row.to = nil
|
|
271
|
+
row.status = "up to date"
|
|
272
|
+
row
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def skipped(row, why)
|
|
276
|
+
row.status = "skipped (#{why})"
|
|
277
|
+
row
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def failed(row, why)
|
|
281
|
+
row.status = "failed (#{why})"
|
|
282
|
+
row.failed = true
|
|
283
|
+
row
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def kept(files, bundle)
|
|
287
|
+
return nil if files.empty?
|
|
288
|
+
|
|
289
|
+
verb = @dry_run ? "would keep" : "kept"
|
|
290
|
+
"#{verb} your edits in #{files.join(", ")}: chi bundle diff #{bundle} #{files.first}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def replaced(files)
|
|
294
|
+
return nil if files.empty?
|
|
295
|
+
|
|
296
|
+
"#{@dry_run ? "would replace" : "replaced"} edited #{files.join(", ")} (it wasn't loading)"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def print_table(rows)
|
|
300
|
+
header = %w[component from to status]
|
|
301
|
+
cells = rows.map { |r| [r.component, r.from.to_s, r.to.to_s, r.status] }
|
|
302
|
+
widths = (0..2).map { |i| ([header] + cells).map { |c| c[i].length }.max }
|
|
303
|
+
([header] + cells).each do |c|
|
|
304
|
+
@stdout.puts((0..2).map { |i| c[i].ljust(widths[i]) }.join(" ") + " " + c[3])
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "desktop"
|
|
5
|
+
require_relative "memory_bundle/shipped_update"
|
|
6
|
+
require_relative "session"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# One line at the first interactive start of a new chi version (REPL,
|
|
11
|
+
# attached, chi web) when something chi update would update is behind:
|
|
12
|
+
# "chi 0.3.1: 2 bundles and the desktop helper can be updated: chi update".
|
|
13
|
+
# Noted once per version in $XDG_STATE_HOME/samagotchi/update_noted,
|
|
14
|
+
# whether or not anything was behind. No network; nothing is changed (the
|
|
15
|
+
# system bundle syncs itself at every start).
|
|
16
|
+
module UpdateHint
|
|
17
|
+
NOTED_FILE = "update_noted"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Only a person starting chi at a terminal: not -p or --non-interactive
|
|
22
|
+
# (a script), not a checkout (git pull updates it), never a worker
|
|
23
|
+
# (workers don't start through bin/chi's launch).
|
|
24
|
+
def wanted?(prompt:, non_interactive:, tty:, installed:)
|
|
25
|
+
!!(tty && installed && !prompt && !non_interactive)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param helper [#stale?, nil] the desktop helper (nil off macOS)
|
|
29
|
+
# @return [String, nil] the line shown, if any
|
|
30
|
+
def show(io: $stderr, env: ENV, version: VERSION, helper: default_helper(env),
|
|
31
|
+
shipped_dir: MemoryBundle::SourceNormalizer::SHIPPED_DIR)
|
|
32
|
+
path = File.join(File.dirname(Session.default_state_dir(env: env)), NOTED_FILE)
|
|
33
|
+
noted = File.exist?(path) ? File.readlines(path, chomp: true) : []
|
|
34
|
+
return nil if noted.include?(version)
|
|
35
|
+
|
|
36
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
37
|
+
File.write(path, "#{version}\n", mode: "a")
|
|
38
|
+
line = message(version, shipped_dir: shipped_dir, helper: helper)
|
|
39
|
+
io.puts(line) if line
|
|
40
|
+
line
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def message(version, shipped_dir:, helper:)
|
|
46
|
+
bundles = MemoryBundle::ShippedUpdate.plan(shipped_dir: shipped_dir).count { |r| r.status == :would_update }
|
|
47
|
+
parts = []
|
|
48
|
+
parts << (bundles == 1 ? "1 bundle" : "#{bundles} bundles") if bundles.positive?
|
|
49
|
+
parts << "the desktop helper" if helper&.stale?
|
|
50
|
+
return nil if parts.empty?
|
|
51
|
+
|
|
52
|
+
"chi #{version}: #{parts.join(" and ")} can be updated: chi update"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_helper(env)
|
|
56
|
+
Desktop.supported? ? Desktop::MacOS.new(env: env) : nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -25,10 +25,12 @@ module Samagotchi
|
|
|
25
25
|
# @param profile [ModelProfile, nil] the model's prompt profile (native)
|
|
26
26
|
# @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
|
|
27
27
|
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
28
|
+
# @param names [Array<String>, nil] the model's lookup names, the typed
|
|
29
|
+
# alias first (Engine#model_lookup_names); default: the target's names
|
|
28
30
|
# @return [Answer]
|
|
29
|
-
def self.for(target, profile: nil, adapter: nil, models: nil)
|
|
31
|
+
def self.for(target, profile: nil, adapter: nil, models: nil, names: nil)
|
|
30
32
|
entry = target.entry
|
|
31
|
-
configured = configured(target, entry, models)
|
|
33
|
+
configured = configured(target, entry, models, names)
|
|
32
34
|
return configured if configured&.no?
|
|
33
35
|
|
|
34
36
|
if entry.chat?
|
|
@@ -44,13 +46,13 @@ module Samagotchi
|
|
|
44
46
|
marker.is_a?(String) && !marker.empty? ? marker : nil
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
def self.configured(target, entry, models)
|
|
49
|
+
def self.configured(target, entry, models, names = nil)
|
|
48
50
|
models ||= begin
|
|
49
51
|
ConfigFile.model_settings
|
|
50
52
|
rescue StandardError
|
|
51
53
|
{}
|
|
52
54
|
end
|
|
53
|
-
key, value = ConfigFile.model_setting([target.model, target.bare_model], :vision, models: models)
|
|
55
|
+
key, value = ConfigFile.model_setting(names || [target.model, target.bare_model], :vision, models: models)
|
|
54
56
|
return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
|
|
55
57
|
return nil if entry.vision.nil?
|
|
56
58
|
|
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -415,9 +415,9 @@ module Samagotchi
|
|
|
415
415
|
folder[:title] = preview unless preview.empty?
|
|
416
416
|
begin
|
|
417
417
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
418
|
-
rescue
|
|
419
|
-
|
|
420
|
-
|
|
418
|
+
rescue ModelProfile::MissingModel => e
|
|
419
|
+
# No model configured, or one qualified with an unknown host.
|
|
420
|
+
return error_response(400, "invalid_model", e.message)
|
|
421
421
|
end
|
|
422
422
|
# The worker's Bridge is the single live transport: wait (bounded) for
|
|
423
423
|
# it so the client can attach without client-side polling.
|
|
@@ -93,9 +93,10 @@ module Samagotchi
|
|
|
93
93
|
outputs = split_outputs(joined, calls.length)
|
|
94
94
|
shown = saved_list(responses, :tool_params, calls.length)
|
|
95
95
|
labels = saved_list(responses, :tool_labels, calls.length)
|
|
96
|
+
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
96
97
|
images = split_images(responses, calls.length)
|
|
97
98
|
calls.each_with_index.map do |call, i|
|
|
98
|
-
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i])
|
|
99
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i])
|
|
99
100
|
end
|
|
100
101
|
end
|
|
101
102
|
|
|
@@ -124,7 +125,8 @@ module Samagotchi
|
|
|
124
125
|
call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
|
|
125
126
|
response = by_id[field(raw, :id)] || (field(responses[i], :tool_call_id).nil? ? responses[i] : nil)
|
|
126
127
|
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
127
|
-
response && field(response, :images), label: field(response, :tool_labels)
|
|
128
|
+
response && field(response, :images), label: field(response, :tool_labels),
|
|
129
|
+
diff: field(response, :tool_diffs))
|
|
128
130
|
end
|
|
129
131
|
end
|
|
130
132
|
|
|
@@ -145,7 +147,9 @@ module Samagotchi
|
|
|
145
147
|
end
|
|
146
148
|
|
|
147
149
|
# +shown+ is the saved params line, when it is a String.
|
|
148
|
-
|
|
150
|
+
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
151
|
+
# tool_diffs.
|
|
152
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil)
|
|
149
153
|
name = call[:name].to_s
|
|
150
154
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
151
155
|
part = { tool: name, params: params.to_s }
|
|
@@ -156,6 +160,7 @@ module Samagotchi
|
|
|
156
160
|
end
|
|
157
161
|
refs = Array(images).select { |ref| ref.is_a?(Hash) }
|
|
158
162
|
part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
|
|
163
|
+
part[:diff] = diff if diff.is_a?(Hash)
|
|
159
164
|
part
|
|
160
165
|
end
|
|
161
166
|
|
|
@@ -88,6 +88,9 @@ export function addCompleted(model, event = {}) {
|
|
|
88
88
|
// The images the tool returned (a reload in the turn view draws them from
|
|
89
89
|
// the saved parts, turn_view.js reloadRowHtml).
|
|
90
90
|
if (event.images?.length) row.images = event.images;
|
|
91
|
+
// What an edit/write changed in its file (a reload draws it from the
|
|
92
|
+
// saved parts, turn_view.js reloadRowHtml).
|
|
93
|
+
if (event.diff) row.diff = event.diff;
|
|
91
94
|
return row;
|
|
92
95
|
}
|
|
93
96
|
|
|
@@ -27,6 +27,7 @@ import { parsePresets, presetNote } from "./annotate_presets.js";
|
|
|
27
27
|
import { COPY_SOURCE_ATTR, copySource, copySourceAttr, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
28
28
|
import { routeChunk } from "./chunk_router.js";
|
|
29
29
|
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
30
|
+
import { diffHtml } from "./diff_view.js";
|
|
30
31
|
import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
31
32
|
import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
32
33
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
42
43
|
} from "./timing.js";
|
|
43
44
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
44
|
-
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
45
|
+
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
45
46
|
|
|
46
47
|
const $ = (s) => document.querySelector(s);
|
|
47
48
|
const topStripEl = $("#topStrip");
|
|
@@ -104,6 +105,10 @@ let currentOwner = null;
|
|
|
104
105
|
let currentTiming = normalizeTiming();
|
|
105
106
|
let turnRunning = false;
|
|
106
107
|
let liveTurnTimingEl = null;
|
|
108
|
+
// What the running turn waits on, after its live timing line: a provider
|
|
109
|
+
// retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
|
|
110
|
+
// it streams again.
|
|
111
|
+
let liveTurnNote = "";
|
|
107
112
|
let timingInterval = null;
|
|
108
113
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
109
114
|
// pending_question on session select so a reload mid-question restores it.
|
|
@@ -1272,6 +1277,7 @@ function startLiveTurnTiming(startedAt = new Date().toISOString()) {
|
|
|
1272
1277
|
currentTiming.activeTurn = { started_at: startedAt };
|
|
1273
1278
|
// The turn records hold the finished turns, so this one comes next.
|
|
1274
1279
|
liveTurnNumber = currentTiming.turnRecords.length + 1;
|
|
1280
|
+
liveTurnNote = "";
|
|
1275
1281
|
liveTurnTimingEl = document.createElement("div");
|
|
1276
1282
|
liveTurnTimingEl.className = "turn-timing live";
|
|
1277
1283
|
historyEl.appendChild(liveTurnTimingEl);
|
|
@@ -1286,16 +1292,24 @@ function appendToHistory(el) {
|
|
|
1286
1292
|
function updateLiveTiming() {
|
|
1287
1293
|
const duration = elapsedSince(currentTiming.activeTurn?.started_at);
|
|
1288
1294
|
if (liveTurnTimingEl && duration !== null) {
|
|
1289
|
-
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true });
|
|
1295
|
+
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
|
|
1290
1296
|
}
|
|
1291
1297
|
updateInfoBar();
|
|
1292
1298
|
}
|
|
1293
1299
|
|
|
1300
|
+
// Set (or clear, "") what the running turn waits on.
|
|
1301
|
+
function setLiveTurnNote(text) {
|
|
1302
|
+
if (liveTurnNote === text) return;
|
|
1303
|
+
liveTurnNote = text;
|
|
1304
|
+
if (currentTiming.activeTurn) updateLiveTiming();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1294
1307
|
function finishLiveTurnTiming(record = null) {
|
|
1295
1308
|
const duration = Number.isFinite(Number(record?.duration_ms))
|
|
1296
1309
|
? Number(record.duration_ms)
|
|
1297
1310
|
: elapsedSince(currentTiming.activeTurn?.started_at);
|
|
1298
1311
|
if (liveTurnTimingEl && duration !== null) {
|
|
1312
|
+
liveTurnNote = "";
|
|
1299
1313
|
liveTurnTimingEl.classList.remove("live");
|
|
1300
1314
|
const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
|
|
1301
1315
|
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration);
|
|
@@ -1322,15 +1336,12 @@ function updateFirstPeek() {
|
|
|
1322
1336
|
}
|
|
1323
1337
|
}
|
|
1324
1338
|
|
|
1325
|
-
function
|
|
1326
|
-
|
|
1327
|
-
if (!v || currentUsedMemories.includes(v)) return;
|
|
1328
|
-
currentUsedMemories.push(v);
|
|
1339
|
+
function setUsedMemories(names) {
|
|
1340
|
+
currentUsedMemories = names.slice();
|
|
1329
1341
|
updateInfoBar();
|
|
1330
1342
|
const sess = allSessions.find(s=>s.id===selected);
|
|
1331
1343
|
if (sess) {
|
|
1332
|
-
sess.used_memory_names =
|
|
1333
|
-
if (!sess.used_memory_names.includes(v)) sess.used_memory_names.push(v);
|
|
1344
|
+
sess.used_memory_names = currentUsedMemories.slice();
|
|
1334
1345
|
render();
|
|
1335
1346
|
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
1336
1347
|
}
|
|
@@ -1394,6 +1405,7 @@ const streamHandlers = {
|
|
|
1394
1405
|
// block (see chunk_router.js). Raw `content` is only used as a fallback
|
|
1395
1406
|
// for pre-Phase-2 / non-enriched servers.
|
|
1396
1407
|
const { text, thinking } = routeChunk(data);
|
|
1408
|
+
setLiveTurnNote("");
|
|
1397
1409
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1398
1410
|
const pct = extractCtxPct(data, currentCtxWindow);
|
|
1399
1411
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
@@ -1404,9 +1416,11 @@ const streamHandlers = {
|
|
|
1404
1416
|
},
|
|
1405
1417
|
generation_started: (data) => {
|
|
1406
1418
|
if (data?.context_window_tokens) currentCtxWindow = data.context_window_tokens;
|
|
1419
|
+
setLiveTurnNote("");
|
|
1407
1420
|
turnDom.generationStarted(data || {});
|
|
1408
1421
|
},
|
|
1409
1422
|
generation_completed: (data) => {
|
|
1423
|
+
setLiveTurnNote("");
|
|
1410
1424
|
if (data?.served_model) {
|
|
1411
1425
|
currentServed = [data.served_model, data.requested_model];
|
|
1412
1426
|
updateInfoBar();
|
|
@@ -1530,24 +1544,11 @@ const streamHandlers = {
|
|
|
1530
1544
|
context_added: (data) => addNoteBubble({ label: data.label, content: data.text || "" }),
|
|
1531
1545
|
continue_offered: (data) => renderContinueCard(data),
|
|
1532
1546
|
continue_resolved: (data) => resolveContinueCard(data),
|
|
1533
|
-
tool_call_started: (data) =>
|
|
1534
|
-
turnDom.toolStarted(data);
|
|
1535
|
-
const call = data.call || {};
|
|
1536
|
-
let name = null;
|
|
1537
|
-
if (call.name === "memory_read" && call.content) {
|
|
1538
|
-
name = String(call.content).split(",")[0].trim();
|
|
1539
|
-
if (name) name = name.replace(/\.md$/,"").split("/").pop();
|
|
1540
|
-
} else if (call.name === "read" && call.content && /memories\/.+\.md/.test(call.content)) {
|
|
1541
|
-
name = String(call.content).split("/").pop().replace(/\.md$/,"");
|
|
1542
|
-
}
|
|
1543
|
-
if (name) addUsedMemory(name);
|
|
1544
|
-
},
|
|
1547
|
+
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
1545
1548
|
tool_call_completed: (data) => turnDom.toolCompleted(data),
|
|
1549
|
+
// The engine's list, after every memory read and at a turn's end.
|
|
1546
1550
|
used_memories_updated: (data) => {
|
|
1547
|
-
if (Array.isArray(data.used_memory_names))
|
|
1548
|
-
currentUsedMemories = data.used_memory_names.slice();
|
|
1549
|
-
updateInfoBar();
|
|
1550
|
-
}
|
|
1551
|
+
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
1551
1552
|
},
|
|
1552
1553
|
context_status: (data) => {
|
|
1553
1554
|
const pct = extractCtxPct(data, currentCtxWindow);
|
|
@@ -1575,6 +1576,10 @@ const streamHandlers = {
|
|
|
1575
1576
|
// The loop asks again after an empty answer: a row of the empty step (the
|
|
1576
1577
|
// chat view shows nothing; after a reload neither does the turn view).
|
|
1577
1578
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1579
|
+
// The provider is asked again after an error, or the turn waits for
|
|
1580
|
+
// plugins' setup: a note on the live timing line until it streams.
|
|
1581
|
+
generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
|
|
1582
|
+
plugin_init_wait: (data) => setLiveTurnNote(initWaitLine(data)),
|
|
1578
1583
|
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
1579
1584
|
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
1580
1585
|
// A plugin's card, or a new version of one (the same id).
|
|
@@ -1752,6 +1757,13 @@ function renderApprovalDetails(view) {
|
|
|
1752
1757
|
what.appendChild(code);
|
|
1753
1758
|
box.appendChild(what);
|
|
1754
1759
|
}
|
|
1760
|
+
// An edit/write: the diff it would make, under the path.
|
|
1761
|
+
if (view.preview) {
|
|
1762
|
+
const diff = document.createElement("div");
|
|
1763
|
+
diff.className = "approval-diff";
|
|
1764
|
+
diff.innerHTML = diffHtml(view.preview);
|
|
1765
|
+
box.appendChild(diff);
|
|
1766
|
+
}
|
|
1755
1767
|
for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
|
|
1756
1768
|
if (!text) continue;
|
|
1757
1769
|
const row = document.createElement("div");
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
|
|
12
12
|
import { copySourceAttr } from "./copy.js";
|
|
13
13
|
import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
14
|
+
import { activityDiffHtml } from "./diff_view.js";
|
|
14
15
|
import { thumbsHtml } from "./images.js";
|
|
15
16
|
import { shouldFollowScroll } from "./scroll.js";
|
|
16
17
|
import { tallyText } from "./tally.js";
|
|
@@ -366,4 +367,6 @@ export function fillActivityRow(el, row, sessionId) {
|
|
|
366
367
|
// The images a tool returned (the chat view's reloaded history has no tool
|
|
367
368
|
// rows; the turn view's has, turn_view.js reloadRowHtml).
|
|
368
369
|
if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
|
|
370
|
+
// An edit/write's change, collapsed: "diff +3 −1".
|
|
371
|
+
if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
|
|
369
372
|
}
|