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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -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.2.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,18 +46,14 @@ 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
- [target.model, target.bare_model].map { |name| name.to_s.strip.downcase }.uniq.each do |key|
54
- value = models.dig(key, :vision)
55
- next if value.nil?
56
-
57
- return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false")
58
- end
55
+ key, value = ConfigFile.model_setting(names || [target.model, target.bare_model], :vision, models: models)
56
+ return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
59
57
  return nil if entry.vision.nil?
60
58
 
61
59
  Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
@@ -13,10 +13,12 @@ require_relative "../bridge/bounded_queue"
13
13
  require_relative "../session"
14
14
  require_relative "../session_manager"
15
15
  require_relative "../session_commands"
16
+ require_relative "../steer"
16
17
  require_relative "../host_registry"
17
18
  require_relative "../model_profile"
18
19
  require_relative "../project_scope"
19
20
  require_relative "../version"
21
+ require_relative "../config"
20
22
  require_relative "../output_formatter"
21
23
  require_relative "../image_store"
22
24
  require_relative "../context_note"
@@ -71,6 +73,9 @@ module Samagotchi
71
73
  # @param turn_view [Boolean] the page's per-turn view (web.turn_view,
72
74
  # the default); false is the classic row of bubbles; ?view=turn|chat
73
75
  # overrides it for one page load
76
+ # @param annotate_presets [String, Array] the quick replies next to
77
+ # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
78
+ # page parses them (annotate_presets.js).
74
79
  # @param hub [SessionHub, nil] the session projection GET /api/events
75
80
  # streams from; without one the route answers 503
76
81
  # @param registry [HostRegistry, nil] the hosts GET /api/models lists
@@ -79,6 +84,7 @@ module Samagotchi
79
84
  # waits for the hosts' lists
80
85
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
81
86
  bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, turn_view: true, hub: nil,
87
+ annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
82
88
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
83
89
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT)
84
90
  @manager = manager || SessionManager
@@ -92,6 +98,7 @@ module Samagotchi
92
98
  @bridge_wait_timeout = bridge_wait_timeout
93
99
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
94
100
  @turn_view = turn_view
101
+ @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
95
102
  @hub = hub
96
103
  @events_heartbeat = events_heartbeat
97
104
  @events_queue = events_queue
@@ -138,6 +145,9 @@ module Samagotchi
138
145
  if (m = %r{\A/api/sessions/([^/]+)/stop\z}.match(req.path_info)) && req.post?
139
146
  return handle_stop(req, m[1])
140
147
  end
148
+ if (m = %r{\A/api/sessions/([^/]+)/(archive|unarchive)\z}.match(req.path_info)) && req.post?
149
+ return m[2] == "archive" ? handle_archive(req, m[1]) : handle_unarchive(req, m[1])
150
+ end
141
151
  if (m = %r{\A/api/sessions/([^/]+)/turn\z}.match(req.path_info)) && req.post?
142
152
  return handle_turn(req, m[1])
143
153
  end
@@ -224,6 +234,8 @@ module Samagotchi
224
234
  offset = sanitize_offset(req.params["offset"])
225
235
  return list_from_hub(root, sort: sort, order: order, limit: limit, offset: offset) if @hub
226
236
 
237
+ # Archived ones too, as the hub's: the page filters them at render.
238
+ scope[:include_archived] = true
227
239
  sessions = if @state_dir
228
240
  @manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, limit: limit, offset: offset,
229
241
  **scope)
@@ -396,11 +408,16 @@ module Samagotchi
396
408
  # spelled as GET /api/models lists it); blank means the default.
397
409
  model = (body["model"] || body["model_name"]).to_s.strip
398
410
  folder[:model_name] = model unless model.empty?
411
+ # preview: an idle session's first message, sent as its first turn
412
+ # next; the list and the notifications name the session by it
413
+ # until that turn is saved.
414
+ preview = idle ? body["preview"].to_s.strip : ""
415
+ folder[:title] = preview unless preview.empty?
399
416
  begin
400
417
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
401
- rescue ArgumentError => e
402
- return error_response(400, "invalid_model", e.message) if e.message.match?(/SAMAGOTCHI_DEFAULT_MODEL/)
403
- 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)
404
421
  end
405
422
  # The worker's Bridge is the single live transport: wait (bounded) for
406
423
  # it so the client can attach without client-side polling.
@@ -464,6 +481,7 @@ module Samagotchi
464
481
  session: session_json,
465
482
  history: read_history(id),
466
483
  messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1"),
484
+ failed_turn: failed_turn_for(session, raw_messages),
467
485
  current_turn: current_turn,
468
486
  queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
469
487
  recap: turn_snapshot && turn_snapshot["recap"],
@@ -597,7 +615,8 @@ module Samagotchi
597
615
  client = live_bridge_client(id)
598
616
  return error_response(503, "not_live", "no live bridge for session #{id}") unless client
599
617
 
600
- reply = client.post_command(line: line, client_id: body["client_id"])
618
+ # card: a card's action; its command events say so (no echo).
619
+ reply = client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true)
601
620
  case reply.status
602
621
  when 202 then json_response(202, reply.json || { status: "accepted" })
603
622
  when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply.json&.dig("detail") || "not a session command")
@@ -805,6 +824,32 @@ module Samagotchi
805
824
  error_response(404, "not_found", e.message)
806
825
  end
807
826
 
827
+ # POST /api/sessions/:id/archive: hide it (and its delegates) from the
828
+ # lists, keep it for good. A live idle worker is stopped first; a turn
829
+ # running in it or a delegate, a chi REPL's session and a scratch one
830
+ # are refused (409).
831
+ def handle_archive(_req, id)
832
+ result = @manager.archive_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
833
+ (result[:archived] + result[:discarded]).each { |sid| @hub&.touch(sid) }
834
+ json_response(200, { status: "archived", session_id: result[:id], archived: result[:archived],
835
+ stopped: result[:stopped], discarded: result[:discarded] })
836
+ rescue SessionManager::OwnedByTUI
837
+ error_response(409, "owned_by_tui", "session #{id} is open in a chi REPL; close it there first")
838
+ rescue SessionManager::ArchiveRefused => e
839
+ error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)
840
+ rescue ArgumentError => e
841
+ error_response(404, "not_found", e.message)
842
+ end
843
+
844
+ # POST /api/sessions/:id/unarchive: back to the lists, delegates too.
845
+ def handle_unarchive(_req, id)
846
+ result = @manager.unarchive_session(id, state_dir: @state_dir)
847
+ result[:unarchived].each { |sid| @hub&.touch(sid) }
848
+ json_response(200, { status: "unarchived", session_id: result[:id], unarchived: result[:unarchived] })
849
+ rescue ArgumentError => e
850
+ error_response(404, "not_found", e.message)
851
+ end
852
+
808
853
  # DELETE /api/sessions/:id: the session for good. A live worker is
809
854
  # stopped first (the page's confirm says so); a chi REPL's session is
810
855
  # refused.
@@ -1038,7 +1083,11 @@ module Samagotchi
1038
1083
  turn_records: Array(source["turn_records"]),
1039
1084
  tool_records: Array(source["tool_records"]),
1040
1085
  active_turn: source["active_turn"],
1041
- active_tools: Array(source["active_tools"])
1086
+ active_tools: Array(source["active_tools"]),
1087
+ # The context last seen and the token sums, for the ctx meter
1088
+ # before the next turn streams (live over saved).
1089
+ context: source["context"],
1090
+ tokens: source["tokens"]
1042
1091
  }
1043
1092
  end
1044
1093
 
@@ -1084,12 +1133,19 @@ module Samagotchi
1084
1133
  return nil unless saved
1085
1134
 
1086
1135
  messages = Array(session.messages)
1087
- since = messages.drop(saved[:covered].to_i).count { |m| (m[:role] || m["role"]).to_s == "user" }
1136
+ since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.prompt?(m) }
1088
1137
  { text: saved[:text], turns_since: since }
1089
1138
  rescue StandardError
1090
1139
  nil
1091
1140
  end
1092
1141
 
1142
+ # The step (iteration) that answered the steer at +index+: one past
1143
+ # the model messages between it and the prompt before it.
1144
+ def steer_step(list, index)
1145
+ list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.prompt?(m) }
1146
+ .count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
1147
+ end
1148
+
1093
1149
  def read_history(id)
1094
1150
  raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
1095
1151
  raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
@@ -1113,6 +1169,12 @@ module Samagotchi
1113
1169
  filtered << { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
1114
1170
  next
1115
1171
  end
1172
+ # A plugin's steer: a row of the step that answered it, which the
1173
+ # page finds by `step`.
1174
+ if Samagotchi::Steer.steer?(m)
1175
+ filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
1176
+ next
1177
+ end
1116
1178
  next if role == "system"
1117
1179
  next if role == "tool_response"
1118
1180
 
@@ -1129,7 +1191,16 @@ module Samagotchi
1129
1191
  message = { role: norm_role, content: stripped }
1130
1192
  images = m[:images] || m["images"]
1131
1193
  message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1132
- message[:html] = @markdown_renderer.render(stripped) if norm_role == "assistant" && !stripped.empty? && @markdown_renderer.available?
1194
+ if norm_role == "assistant"
1195
+ # What an after_turn hook presented (AnswerDisplay): rendered and
1196
+ # copied in place of the answer, sanitised the same way; content
1197
+ # stays the model's text, which the page finds the bubble by.
1198
+ display = m[:display] || m["display"]
1199
+ shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
1200
+ message[:display] = shown unless shown.strip.empty?
1201
+ rendered = message[:display] || stripped
1202
+ message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1203
+ end
1133
1204
  message[:parts] = did if did
1134
1205
  filtered << message
1135
1206
  end
@@ -1138,6 +1209,17 @@ module Samagotchi
1138
1209
  []
1139
1210
  end
1140
1211
 
1212
+ # The last turn failed and its prompt went back to the user: the
1213
+ # prompt (session.last_prompt) and why, for the page to show after the
1214
+ # conversation as it did live. nil otherwise.
1215
+ def failed_turn_for(session, msgs)
1216
+ summary = Samagotchi::TurnNote.restored_failure(msgs)
1217
+ prompt = session.last_prompt.to_s
1218
+ return nil if summary.nil? || prompt.strip.empty?
1219
+
1220
+ { prompt: prompt, summary: summary }
1221
+ end
1222
+
1141
1223
  # The parts of the assistant message at +index+, with the tool_response
1142
1224
  # messages right after it.
1143
1225
  def message_parts(list, index)
@@ -1199,6 +1281,8 @@ module Samagotchi
1199
1281
  def index_data_attributes(req)
1200
1282
  attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
1201
1283
  attrs["turn-view"] = "1" if turn_view?(req.params["view"])
1284
+ # Always there: an empty one means no presets, not the default.
1285
+ attrs["annotate-presets"] = @annotate_presets
1202
1286
  dir, = scope_dir(req.params["dir"])
1203
1287
  root = dir && ProjectScope.root_for(dir)
1204
1288
  if root
@@ -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