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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. 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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
 
@@ -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 ArgumentError => e
419
- return error_response(400, "invalid_model", e.message) if e.message.match?(/SAMAGOTCHI_DEFAULT_MODEL/)
420
- raise
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
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil)
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 addUsedMemory(name) {
1326
- const v = String(name||"").trim();
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 = 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
  }
@@ -29,6 +29,8 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
29
29
  "guardrail_warning",
30
30
  "hook_notice",
31
31
  "empty_answer_retry",
32
+ "generation_retrying",
33
+ "plugin_init_wait",
32
34
  "card",
33
35
  "plugin_init_started",
34
36
  "plugin_init_finished",